diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 421cd9b..0000000 --- a/.dockerignore +++ /dev/null @@ -1,19 +0,0 @@ -.git -.github -.env -.env.* - -data -sing-box - -cli-proxy-api -cliproxyapi/cli-proxy-api -cliproxyapi/config.yaml -cliproxyapi/docker-config.yaml -cliproxyapi/oa -cliproxyapi/logs -cliproxyapi/plugins - -README.md -scripts -deploy/s-ui-server diff --git a/.env.example b/.env.example index cc30682..cb33d48 100644 --- a/.env.example +++ b/.env.example @@ -1,49 +1,10 @@ -# 首次执行 ./manage.sh init 会自动替换下面两个占位值。 -COMPOSE_PROJECT_NAME=proxy-llm-api -# full: Hub + CLIProxyAPI + 所需存储;cliproxy: 仅 CLIProxyAPI。 -DEPLOY_MODE=full -ADMIN_TOKEN=change-me -DB_USER=postgres -DB_PASSWORD=your-secure-password_change-me -DB_NAME=claude_code_hub - -# Hub 入口。只允许本机访问可写成 127.0.0.1:23000。 -APP_PORT=23000 -APP_URL= -# 远程纯 HTTP 调试时可设 false;正式公网部署应保留 true 并使用 HTTPS。 -ENABLE_SECURE_COOKIES=true - -# 留空时自动启动内置 PostgreSQL 与 Dragonfly;两项可独立外接。 -EXTERNAL_POSTGRES_DSN= -EXTERNAL_REDIS_URL= - -# 1 GiB 机器建议改为 384mb 或 512mb。 -DRAGONFLY_IMAGE=docker.dragonflydb.io/dragonflydb/dragonfly:v1.40.0 -DRAGONFLY_MAXMEMORY=1gb -DRAGONFLY_THREADS=2 - -# 可选代理。分享链接含 # 时必须用单引号包住整个值。 -SINGBOX_NODE_URL= -SINGBOX_CONFIG_PATH= -SINGBOX_IMAGE=ghcr.io/sagernet/sing-box:v1.13.16 - -# DEPLOY_MODE=cliproxy 时必填。在 Cloudflare 创建 remotely-managed Tunnel -# 后,从官方 Docker 命令中复制 --token 后面的完整值。 -CF_TUNNEL_TOKEN= -CLOUDFLARED_IMAGE=cloudflare/cloudflared:2026.7.3 - -# CLIProxyAPI 默认只供本机与 Compose 内网访问。 -CLIPROXY_BIND_ADDRESS=127.0.0.1 -CLIPROXY_PORT=8317 -CLIPROXY_CALLBACK_BIND_ADDRESS=127.0.0.1 - -# 默认直接使用 GitHub Actions 构建的 GHCR 镜像。 -CLI_PROXY_IMAGE=ghcr.io/pluxeljs/proxy-llm-api:latest - -# 以下仅用于可选的本地源码构建和持久化路径覆盖。 -CLIPROXY_REPOSITORY=https://github.com/router-for-me/CLIProxyAPI.git -CLIPROXY_REF=main -CLIPROXY_CONFIG_PATH=./cliproxyapi/config.yaml -CLIPROXY_AUTH_PATH=./cliproxyapi/oa -CLIPROXY_LOG_PATH=./cliproxyapi/logs -CLIPROXY_PLUGIN_PATH=./cliproxyapi/plugins +# Documentation only. `./manage.sh init` generates the actual private .env +# under ~/.local/state/new-api-runtime, outside the checkout and Nix store. +NEW_API_IMAGE=docker.io/calciumion/new-api:v0.13.2 +NEW_API_BIND_ADDRESS=127.0.0.1 +NEW_API_PORT=23000 +TZ=Asia/Taipei +# NEW_API_DATA_DIR is generated as an absolute path to /data. +# NEW_API_SESSION_SECRET is generated once. Keep it with SQLite backups. +# NEW_API_ENGINE and NEW_API_PROJECT are persisted by init. +# First Docker use: NEW_API_ENGINE=docker ./manage.sh init diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..ba2dfe4 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,23 @@ +name: Check New API runtime +on: + push: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + runtime: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Runtime and SQLite backup tests + run: python3 -m unittest discover -s tests -v + - name: Validate single-service Compose + env: + NEW_API_SESSION_SECRET: ci-config-validation-only + NEW_API_DATA_DIR: /tmp/new-api-ci-data + run: | + test "$(docker compose -f docker-compose.yaml config --services)" = new-api + bash -n manage.sh + - name: Docker lifecycle, file ownership and SQLite recovery + run: python3 tests/smoke_docker.py diff --git a/.github/workflows/cliproxy-ghcr.yaml b/.github/workflows/cliproxy-ghcr.yaml deleted file mode 100644 index 0b7da66..0000000 --- a/.github/workflows/cliproxy-ghcr.yaml +++ /dev/null @@ -1,77 +0,0 @@ -name: Build CLIProxyAPI image - -on: - push: - branches: [main] - paths: - - ".github/workflows/cliproxy-ghcr.yaml" - - "cliproxyapi/Dockerfile" - schedule: - # 每天拉取一次上游 main;只有内容变化时层缓存才会重新编译。 - - cron: "17 19 * * *" - workflow_dispatch: - -permissions: - contents: read - packages: write - -concurrency: - group: cliproxy-ghcr-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout integration repository - uses: actions/checkout@v4 - - - name: Resolve upstream main - id: upstream - shell: bash - run: | - commit="$(git ls-remote https://github.com/router-for-me/CLIProxyAPI.git refs/heads/main | cut -f1)" - test -n "$commit" - echo "commit=$commit" >> "$GITHUB_OUTPUT" - echo "short=${commit:0:12}" >> "$GITHUB_OUTPUT" - echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Generate image metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/pluxeljs/proxy-llm-api - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=raw,value=upstream-${{ steps.upstream.outputs.short }} - type=sha,prefix=integration- - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: cliproxyapi/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - CLIPROXY_REF=${{ steps.upstream.outputs.commit }} - VERSION=main-${{ steps.upstream.outputs.short }} - COMMIT=${{ steps.upstream.outputs.commit }} - BUILD_DATE=${{ steps.upstream.outputs.build_date }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 48d14a0..108fff0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,45 +1,24 @@ -# Local environment and secrets +# Private configuration and runtime data .env .env.*.local +/data/ +/backups/ +*.db +*.db-wal +*.db-shm -# Runtime configuration may contain API and management keys -cliproxyapi/config.yaml -cliproxyapi/docker-config.yaml - -# OAuth credentials, refresh tokens, logs, and installed plugins -cliproxyapi/oa/* -!cliproxyapi/oa/.gitkeep -cliproxyapi/logs/* -!cliproxyapi/logs/.gitkeep -cliproxyapi/plugins/* -!cliproxyapi/plugins/.gitkeep - -# Generated/custom sing-box configs contain node credentials. -sing-box/*.json -!sing-box/*.example.json - -# Legacy downloaded artifacts from the previous image-based deployment +# Upgraded checkouts may still contain old private state; never commit it. +/cliproxyapi/ +/sing-box/ +/deploy/ /cli-proxy-api -/cliproxyapi/cli-proxy-api -/cliproxyapi/LICENSE -/cliproxyapi/README.md -/cliproxyapi/README_CN.md - -# Stateful services -data/postgres/ -data/redis/ -data/dragonfly/ -data/sing-box/ - -# Remote s-ui deployment secrets and state -deploy/s-ui-server/.env -deploy/s-ui-server/data/ -deploy/s-ui-server/backups/ -# Editor and OS files +# Build and editor artifacts +/result +/result-* +__pycache__/ +*.py[cod] .DS_Store .idea/ .vscode/ *.swp -__pycache__/ -*.py[cod] diff --git a/README.md b/README.md index 443a5a6..07cf2e8 100644 --- a/README.md +++ b/README.md @@ -1,275 +1,151 @@ -# Proxy-LLM-API +# New API Runtime -本仓库组合 Claude Code Hub 与 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)。CLIProxyAPI 不使用来源不明的第三方镜像:GitHub Actions 每天从官方 `main` 源码构建并发布多架构镜像;本地也可按需从同一上游构建: +这个仓库现在只负责 **New API + SQLite** 的部署。默认入口为 +`http://127.0.0.1:23000`,提供模型转发、Web 管理、持久 token 用量和消费日志。 +镜像固定为 `docker.io/calciumion/new-api:v0.13.2`,不自动跟随 `latest`。 -```text -ghcr.io/pluxeljs/proxy-llm-api:latest -``` - -OAuth 凭证只保存在宿主机 `cliproxyapi/oa/`,以读写卷挂载到容器 `/data/auth`。CLIProxyAPI 会以正确映射的宿主用户身份写入这些目录,不需要 `sudo`、`chown` 或 `chmod 777`。凭证、配置密钥、日志、数据库和构建出的二进制均已排除在 Git 之外。默认的 PostgreSQL、Dragonfly 与 sing-box 数据继续使用 Compose 命名卷;只有旧版 `data/` 已存在或 `.env` 显式指定 bind source 时才使用状态目录下的数据路径。 +原来的 Claude Code Hub、CLIProxyAPI、PostgreSQL、Dragonfly、sing-box、OAuth +登录助手和每日镜像构建工作流已移除。仓库 URL 暂时保留以免现有链接失效;这不是 +New API 源码分叉,不再发布原来的 `ghcr.io/pluxeljs/proxy-llm-api` 镜像。 +上游项目:[QuantumNous/new-api](https://github.com/QuantumNous/new-api)。 ## 快速开始 -宿主机只需要 Git,以及 Docker Compose v2 或 Podman Compose。设置分享链接代理时还需要 Python 3,执行出口验证时需要 curl。默认直接拉取本仓库发布的 GHCR 镜像,不需要在部署机器编译 CLIProxyAPI。 +Linux 上需要 Python 3、Podman 和 podman-compose;也支持 Docker Compose v2。 ```bash -git clone https://github.com/PluxelJS/Proxy-LLM-API.git -cd Proxy-LLM-API ./manage.sh init ./manage.sh up -./manage.sh login codex-device +./manage.sh check ``` -`init` 会自动创建 `.env` 与 `cliproxyapi/config.yaml`,生成 Hub 管理 Token、数据库密码和 CLIProxyAPI API key;重复执行不会覆盖已有配置。它会直接打印首次需要的两项凭据,之后也可执行: - -```bash -./manage.sh secrets -``` - -### Nix / Home Manager - -仓库同时提供官方 flake。直接运行时不需要保留 Git checkout,程序文件来自 -Nix store,可变状态默认写入 -`${XDG_STATE_HOME:-$HOME/.local/state}/proxy-llm`: - -```bash -nix run github:PluxelJS/Proxy-LLM-API -- init -nix run github:PluxelJS/Proxy-LLM-API -- up -``` - -Home Manager 可直接导入模块。`initialize = true` 会由 systemd 在首次启动前生成 -缺失配置和随机凭据,但不会把凭据打印到 journal;之后需要查看时显式执行 -`proxy-llm secrets`: - -```nix -{ - inputs.proxy-llm = { - url = "github:PluxelJS/Proxy-LLM-API/main"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - - # 加入 Home Manager modules: - # inputs.proxy-llm.homeManagerModules.default - services.proxyLlm = { - enable = true; - autoStart = true; - initialize = true; - # 只在从旧 checkout 迁移时设置;迁移完成后运行不依赖该目录。 - legacyStateDir = "/home/you/code/_ACode"; - }; -} -``` +打开 `http://127.0.0.1:23000` 完成首次设置,创建自己的管理员密码并选择自用模式。 +添加 OpenAI 类型渠道时,填写上游 origin(如 `https://api.example.com`,不带 `/v1`), +配置实际支持的模型,再创建客户端令牌。客户端 API 地址为 +`http://127.0.0.1:23000/v1`。 -flake lock 固定实际部署过的提交,不会在每次登录或开机时追逐 `main`。开发上游 -模块时,可以让现有配置临时使用本地 checkout,而不改变 lock: +本项目不会生成通用管理员密码,也不会把任何真实凭证放进 Git 或 Nix store。 +自用模式不是无限余额;令牌的无限额度也不等于用户无限余额。按实际需要设置 +用户额度、模型价格和日志选项。 ```bash -home-manager switch --flake ~/.config/nix#current --impure \ - --override-input proxy-llm "git+file://$HOME/code/_ACode" -``` - -不设置 `PROXY_LLM_STATE_DIR` 的 `./manage.sh` 仍保持原来的仓库内状态布局;这让 -Git checkout 用户完全向后兼容。显式设置该变量时,`.env`、配置、OAuth、日志、 -插件、生成的 sing-box 配置和旧版 `data/` 都位于该目录。相对的 -`CLIPROXY_*_PATH` 与 `SINGBOX_CONFIG_PATH` 也统一相对于状态目录解析。 -`legacyStateDir` 不会让 Home Manager 擅自停止现有服务。首次切换前使用提示中的 -`proxy-llm cutover <旧目录>`:它先预检,再正常停止旧栈,通过 Podman user -namespace 保留旧 bind 数据的容器 UID/GID,并验证新栈;任一步失败都会自动恢复 -旧服务。旧 Compose project name 也会固定到新 `.env`,继续使用同一组容器、网络 -和命名卷。迁移不修改或删除源目录,目标有真实文件时也拒绝覆盖或合并。服务启动由 -systemd 异步排队,不会让 Home Manager activation 等待镜像下载或健康检查;可用 -`systemctl --user status proxy-llm.service` 查看结果。Home Manager 更新已运行 unit -时会保留旧进程;需要立即采用新 helper 时显式执行 -`systemctl --user restart proxy-llm.service`。 - -需要 AnyTLS、VLESS 等出站代理时,只需在 `up` 前编辑 `.env` 中这一项: - -```dotenv -SINGBOX_NODE_URL='anytls://password@example.com:443?security=tls&sni=example.com#node' -``` - -启动成功后打开 `http://127.0.0.1:23000`;远程部署则使用服务器 IP 和 `APP_PORT`。在 Hub 添加 CLIProxyAPI 时使用 Compose 内部地址 `http://cli-proxy-api:8317` 以及 `init` 生成的 API key。 - -## 只部署 CLIProxyAPI - -远端不需要 Hub 和数据库时,先在 Cloudflare Zero Trust 创建一个 **remotely-managed Tunnel**。在 Tunnel 的 Public Hostname 中选择所需域名,Service 设置为: - -```text -http://cli-proxy-api:8317 -``` - -复制 Cloudflare 官方 Docker 命令中 `--token` 后面的完整 Token,然后在初始化后把 `.env` 改为: - -```dotenv -DEPLOY_MODE=cliproxy -CF_TUNNEL_TOKEN=eyJ... +./manage.sh status +./manage.sh logs +./manage.sh restart +./manage.sh down +./manage.sh config # 只列出服务名,不输出秘密 ``` -然后仍使用相同入口: +Docker Compose v2 用户首次运行: ```bash +NEW_API_ENGINE=docker ./manage.sh init ./manage.sh up -./manage.sh login codex-device -./manage.sh status -``` - -该模式强制启用官方 `cloudflare/cloudflared:2026.7.3`,只启动 CLIProxyAPI 和 cloudflared;如果填写了 `SINGBOX_NODE_URL` 或 `SINGBOX_CONFIG_PATH`,再附加 sing-box。不会启动 Hub、PostgreSQL 或 Dragonfly,外部数据库字段也会被忽略。Token 缺失或仍是占位值时,管理脚本会在创建容器前直接报错,但仍允许执行 `init`、`status` 和 `down`。 - -cloudflared 通过 Compose 内网访问 CLIProxyAPI,服务器不需要在云防火墙或主机防火墙开放 `8317`。本机 `127.0.0.1:8317` 仍保留用于服务器诊断和 SSH 应急访问,不会监听公网地址。`init` 生成的 CLIProxyAPI API key 可通过 `./manage.sh secrets` 查看。 - -Cloudflare Tunnel 只提供连通和 TLS,不会自动替代 API 身份认证。所有请求仍须携带 CLIProxyAPI Bearer Key。如果启用 Cloudflare Access,调用端还必须支持发送 `CF-Access-Client-Id` 和 `CF-Access-Client-Secret`;不支持额外请求头的 OpenAI 兼容客户端会被 Access 拒绝。此时可只使用强 API key,并在 Cloudflare 配置限速、WAF 或来源 IP 规则。 - -日常升级使用 `./manage.sh update`,它会同时拉取 CLIProxyAPI 和固定版本的 cloudflared。cloudflared 的 metrics/readiness 只监听容器网络,`status` 会验证它至少有一条可服务的 Cloudflare Edge 连接。 - -## 目录结构 - -```text -cliproxyapi/ -├── Dockerfile # 拉取官方 main 并编译 -├── config.example.yaml # 跟随上游当前 main 的可提交配置基线 -├── config.yaml # 本机运行配置,包含 API key,不进 Git -├── oa/ # OAuth JSON 凭证,不进 Git -├── logs/ # 运行日志,不进 Git -└── plugins/ # 本机安装的插件,不进 Git -compose.internal-postgres.yaml # 可选内置 PostgreSQL 服务与健康依赖 -compose.internal-dragonfly.yaml # 可选内置 Dragonfly 服务与健康依赖 -compose.singbox.yaml # 设置代理节点后追加官方 sing-box sidecar -compose.cloudflare-tunnel.yaml # CLIProxyAPI 独立模式的官方 cloudflared sidecar -compose.podman.yaml # Podman 健康调度兼容层,由包装脚本自动选择 -compose.build.yaml # 仅本地源码构建时追加,不参与默认启动 -sing-box/ -└── config.json # 根据节点链接生成的运行配置,含密钥,不进 Git -scripts/ -├── build-cliproxy # 解析 main 为提交 SHA,再构建默认镜像 -├── cliproxy-login # 在运行容器中登录,或复用同一镜像启动登录容器 -├── generate-singbox-config # 将常见节点分享链接转换为 sing-box 配置 -├── healthcheck # 串行检查实际服务,不依赖容器运行时状态缓存 -├── init # 创建本机配置并生成强随机凭据 -├── service-exec # 绕过 Compose API,直接选择 Docker/Podman exec -└── compose # 自动选择 Docker Compose / Podman Compose -manage.sh # 日常唯一入口:启动、状态、日志、登录与代理验证 -deploy/s-ui-server/ # 独立的远端 s-ui + AnyTLS + CF DNS-01 部署 -deploy/ssh-hardening/ # Debian VPS 一次性 SSH 密钥与非标端口引导 ``` -## PostgreSQL 与 Redis 协议存储 - -默认不填写外部连接字段时,`./manage.sh up` 会启动 Compose 内置的 PostgreSQL 18 和 Dragonfly v1.40.0,并等待两者健康后再启动应用。它们和可选 sing-box 的运行状态默认保存在 Compose 命名卷,不会在仓库里生成 `root`、`nobody` 所有的 `data/` 文件。Dragonfly 每 5 分钟生成快照;小型部署可通过 `DRAGONFLY_THREADS`、`DRAGONFLY_MAXMEMORY` 调整资源。Compose 同时启用了 Dragonfly 的 `allow-undeclared-keys` Lua 兼容标志,以支持应用在脚本内动态生成 Session 键。 +容器引擎和项目名会写入状态目录的 `.env`,后续命令无需重复指定。 +Docker 容器使用当前用户 UID/GID 写入 SQLite,普通用户可以直接备份;rootless +Podman 使用容器内 root(映射到当前宿主用户)。请用有 Docker daemon 访问权限的 +同一个用户执行全部命令,不要混用 `sudo` 或在同一状态目录交替运行两个引擎。 +默认使用独立 Compose 项目 +`new-api-runtime`,可通过 `NEW_API_PROJECT` 覆盖。Podman 不创建共享 pod。 -要使用外部服务,在 `.env` 填写对应字段: +## 状态与配置 -```dotenv -EXTERNAL_POSTGRES_DSN=postgresql://user:password@db.example.com:5432/database -EXTERNAL_REDIS_URL=rediss://user:password@redis.example.com:6379 -``` +默认状态目录为 `~/.local/state/new-api-runtime`,可用 `NEW_API_STATE_DIR` 覆盖; +支持 `XDG_STATE_HOME`。目录权限为 0700,配置和备份文件为 0600。 -两项独立判断:只填写 PostgreSQL 就仍会启动内置 Dragonfly;只填写 Redis URL 就仍会启动内置 PostgreSQL;两项都填写则只启动应用和 CLIProxyAPI。日常只使用根目录的 `./manage.sh`;它会在内部选择正确的 Compose overlays,避免直接运行底层 Compose 留下旧服务。 +| 文件 | 用途 | +| --- | --- | +| `.env` | 端口、固定镜像、持久会话密钥、数据路径 | +| `data/new-api.db` | 账号、渠道、客户端令牌、配置、消费记录 | +| `data/new-api.db-wal` / `-shm` | SQLite 运行时可能存在的事务文件 | -从旧版本升级时,如果 `data/postgres`、`data/dragonfly` 或 `data/sing-box` 中已有文件,包装脚本会自动继续挂载原目录,不会静默换成空卷。确认数据迁移完成后才应自行删除旧目录。命名卷可用 `docker volume ls` 或 `podman volume ls` 查看;`./manage.sh down` 不会删除它们。 +运行 `init` 不会覆盖已经配置的端口或会话密钥。编辑状态目录中的 `.env` 后执行 +`restart` 生效;模板 `.env.example` 仅供参考。值采用 `KEY=value` 原样格式, +不支持 shell 表达式、引号或换行。 -`./manage.sh up` 会等待当前模式下的每个服务真正可用后才成功返回;`./manage.sh status` 同时显示容器状态并重新执行串行健康检查。Podman 模式会自动启用专用兼容层,避开部分 Podman/conmon 组合通过 systemd timer 执行健康检查时产生的误报;Docker 模式仍使用 Compose 原生健康检查。 +主要变量和本机 dev-runtime 保持一致:`NEW_API_IMAGE`、`NEW_API_BIND_ADDRESS`、 +`NEW_API_PORT`、`NEW_API_DATA_DIR`、`NEW_API_SESSION_SECRET`、`TZ`。 +`NEW_API_DATA_DIR` 必须为绝对路径。容器诊断日志限制为 16 MB;消费记录保存在 +SQLite,除非管理员主动清理。管理界面的统计图表可能存在聚合刷新延迟。 -## 可选 sing-box 出站代理 +默认只监听 loopback。如需跨机器访问,应由已有的 HTTPS 反向代理负责入口和访问 +控制;此仓库不再捆绑隧道、出站代理或额外网络服务。 -不设置代理字段时不会启动 sing-box,CLIProxyAPI 继续直连。要使用节点分享链接,在 `.env` 填写: - -```dotenv -SINGBOX_NODE_URL='vless://uuid@example.com:443?security=reality&type=tcp&sni=example.com&pbk=...#node' -``` - -`./manage.sh up` 会生成不进 Git 的 `sing-box/config.json`,启用官方 `ghcr.io/sagernet/sing-box:v1.13.16`,并让 CLIProxyAPI 通过 Compose 内网的 `socks5h://sing-box:1080` 出站。自动解析常见的 VLESS、VMess、Trojan、Shadowsocks、Hysteria2、TUIC、AnyTLS、HTTP 和 SOCKS 分享链接。链接通常包含 UUID、密码等秘密,务必放在已忽略的 `.env` 中;包含 `#` 时必须用引号包住整个值。 - -非标准分享格式或更复杂的 WireGuard、SSH、链式出站等配置,请编写完整的官方 sing-box JSON,然后设置: - -```dotenv -SINGBOX_CONFIG_PATH=./sing-box/custom.json -``` +## 用量与价格 -`SINGBOX_NODE_URL` 与 `SINGBOX_CONFIG_PATH` 只能设置一个。自定义配置必须提供监听 `0.0.0.0:1080` 的 SOCKS 或 mixed inbound,供 CLIProxyAPI 容器访问。 +在管理界面确认启用消费日志和数据统计。实际 token 取决于上游返回的 usage; +缺少 usage 的上游无法保证精确计数。对于仅支持 Responses 的上游,客户端直接使用 +`/v1/responses`,不要依赖协议转换来补齐上游能力。 -填写或更换节点后执行 `./manage.sh up`。入口脚本会按配置内容计算哈希,节点变化时自动重建 sing-box;OAuth 登录容器也使用同一代理链路。执行 `./manage.sh proxy-test` 可以比较宿主机直连出口、sing-box SOCKS 出口和 CLIProxyAPI 容器自动出口,验证代理确实生效。 +本地开发机建议在性能设置中将 CPU 阈值设为 `0`,禁用 CPU 过载拒绝请求, +避免编译测试触发 503;该设置在线生效并保存到 SQLite。 -要恢复直连,清空两个 sing-box 字段后执行 `./manage.sh up`;这会移除不再属于当前模式的 sing-box 容器,但不会删除其配置或运行数据。 +如需先按本地额度观察消耗,可将模型倍率和分组倍率设为 `1`;输入基准为 +$2/百万 token,输出采用 New API 生效的模型规则。管理员可在用户管理中增加 +本地额度。这不是对上游账户充值,也不等同于上游真实账单。 -## 可选:从最新上游源码构建 +模型价格必须按你的上游收费填写。模型倍率设置为零时,仍可记录 token,但费用为零, +不能用作上游账单。运行时不会捏造或自动覆盖模型价格。 -正常部署无需执行本节。默认 `./manage.sh up` 使用 GHCR 镜像;只有需要自行审计构建或立即跟进 CLIProxyAPI 上游尚未发布的提交时,才执行: +## 备份、恢复和升级 ```bash -./manage.sh build -./manage.sh up -``` - -`build-cliproxy` 会先把 `main` 解析成不可变提交 SHA,避免 Docker 把旧的 `main` clone 层当作缓存。若要复现指定版本: - -```bash -CLIPROXY_REF= ./manage.sh build +./manage.sh backup "$HOME/new-api-backup-$(date +%Y%m%d-%H%M%S)" ``` -默认只把 API 暴露在宿主机 `127.0.0.1:8317`。其他 Compose 服务可使用 `http://cli-proxy-api:8317`。确需从其他机器访问时,在 `.env` 设置 `CLIPROXY_BIND_ADDRESS=0.0.0.0`,并确认 `api-keys` 足够强且防火墙规则正确。 - -## 在宿主机发起账号登录 - -推荐远程服务器优先使用无需回调端口的 Codex device-code: - -```bash -./manage.sh login codex-device -``` +备份通过 SQLite backup API 获取一致快照,包括已提交的 WAL 事务,不需要停止服务。 +备份包含数据库、会话密钥、镜像版本及时间;目标目录必须不存在。妥善保护整个备份。 -其余当前上游支持的登录方式: +恢复只允许写入空状态目录,不覆盖当前数据库: ```bash -./manage.sh login codex -./manage.sh login claude -./manage.sh login antigravity -./manage.sh login kimi -./manage.sh login xai +NEW_API_STATE_DIR="$HOME/.local/state/new-api-restored" \ + ./manage.sh restore "$HOME/new-api-backup-YYYYMMDD-HHMMSS" ``` -脚本始终以 `-no-browser` 启动登录,并在终端显示授权 URL。服务已运行时,脚本在现有容器内执行登录;服务未运行时,它创建一个临时登录容器并开放需要的回调端口。两种方式都写入同一个宿主机 `cliproxyapi/oa/`,主服务会热加载新增或更新的凭证。 - -回调型登录端口如下: - -| Provider | 端口 | 回调方式 | -| --- | ---: | --- | -| Codex OAuth | 1455 | `localhost` 浏览器回调 | -| Claude | 54545 | `localhost` 浏览器回调 | -| Antigravity | 51121 | `localhost` 浏览器回调 | -| Codex device / Kimi / xAI | 无 | device code | - -若 CLIProxyAPI 部署在远端主机而浏览器在本机,按命令打印的 SSH tunnel 提示转发对应端口。 +恢复后检查 `.env` 的端口与镜像。先停止旧实例,再启动恢复实例;不要让两个实例 +同时打开同一个 SQLite 数据目录。恢复保留备份时的镜像版本,不自动升级数据库。 -## 调用与运维 +升级流程:先备份,在 `.env` 修改 `NEW_API_IMAGE`,执行 `pull`,再 `restart`, +检查转发及消费记录。需要回滚时,使用旧镜像和升级前的数据库快照,不要直接让旧版 +程序打开新版迁移过的数据库。 -使用 `cliproxyapi/config.yaml` 里的 API key: +## Nix / Home Manager ```bash -curl http://127.0.0.1:8317/v1/models \ - -H 'Authorization: Bearer ' +nix run . -- init +nix run . -- up +nix flake check ``` -常用命令: +导出 `packages..new-api-runtime`(也是 default)、默认 app 和 +`homeManagerModules.default`。支持 x86_64-linux、aarch64-linux。 +独立管理模式示例: -```bash -./manage.sh status -./manage.sh logs cli-proxy-api -./manage.sh proxy-test -./manage.sh restart -``` - -默认启动配置不包含 `build:`,因此只会使用 GHCR 镜像。本地执行 `build-cliproxy` 时才追加构建 overlay,并用源码构建覆盖同名本地标签: - -```bash -./manage.sh update cli-proxy-api +```nix +{ + imports = [ inputs.proxy-llm.homeManagerModules.default ]; + services.newApiRuntime = { + enable = true; + # stateDir = "${config.xdg.stateHome}/new-api-runtime"; + }; +} ``` -配置基线来自上游当前 `main` 的 `config.example.yaml`。仓库内只维护与本部署有关的精简配置;新增 provider 或高级配置请对照[官方完整示例](https://github.com/router-for-me/CLIProxyAPI/blob/main/config.example.yaml)和[官方中文文档](https://help.router-for.me/cn/)。 +`proxy-llm` 只是示例中的 flake input 名称,旧 `services.proxyLlm` 和 `proxy-llm` +命令已删除。系统需要提供 Podman 和 systemd 用户会话。Home Manager 单元名为 +`new-api-runtime.service`,`autoStart = false` 可关闭登录时启动。 -## 独立的远端工具 +**已有 dev-runtime 的机器继续由 dev-runtime 管理,不启用上述独立服务。** +使用 `dev-runtime enable new-api`、`dev-runtime logs new-api` 等命令。它的状态目录 +是 `~/.local/state/dev-runtime/new-api/`,会话密钥在 dev-runtime 的 `.env` 中。 +此仓库可作为单服务 Compose 定义和其他机器的部署入口,不抢占本机 23000 端口。 -`deploy/` 下的内容不会被根目录 `manage.sh` 或 Compose 自动加载: +## 从旧设计迁移 -- [`deploy/s-ui-server/README.md`](deploy/s-ui-server/README.md):在独立 VPS 部署固定版本的 s-ui + AnyTLS,并用 Cloudflare DNS-01 自动维护证书。 -- [`deploy/ssh-hardening/README.md`](deploy/ssh-hardening/README.md):一次性创建部署用户、SSH 密钥、非标准端口和基础防护。 +这是一次明确的架构变更,不会在首次启动时自动停止或删除旧容器。迁移流程见 +[迁移说明](docs/migration.md)。旧数据库日志不伪装成新系统的历史统计。 -远端 s-ui 使用 host 网络,节点协议端口无需再同步维护 Docker `ports`;面板默认只监听远端 `127.0.0.1`,通过 SSH 隧道管理。 +旧节点部署及 SSH 加固工具也已移除;如需查阅,可从 Git 历史恢复。 diff --git a/cliproxyapi/Dockerfile b/cliproxyapi/Dockerfile deleted file mode 100644 index 3d9f526..0000000 --- a/cliproxyapi/Dockerfile +++ /dev/null @@ -1,62 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -ARG GO_VERSION=1.26 -FROM golang:${GO_VERSION}-bookworm AS builder - -ARG CLIPROXY_REPOSITORY=https://github.com/router-for-me/CLIProxyAPI.git -ARG CLIPROXY_REF=main - -WORKDIR /src - -RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential git \ - && rm -rf /var/lib/apt/lists/* \ - && git init \ - && git remote add origin "${CLIPROXY_REPOSITORY}" \ - && git fetch --depth=1 origin "${CLIPROXY_REF}" \ - && git checkout --detach FETCH_HEAD - -RUN go mod download - -ARG VERSION=dev -ARG COMMIT=none -ARG BUILD_DATE=unknown - -RUN CGO_ENABLED=1 GOOS=linux go build \ - -buildvcs=false \ - -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" \ - -o /out/CLIProxyAPI ./cmd/server/ - -FROM debian:bookworm-slim - -ARG VERSION=dev -ARG COMMIT=none -ARG BUILD_DATE=unknown - -LABEL org.opencontainers.image.title="CLIProxyAPI for Proxy-LLM-API" \ - org.opencontainers.image.description="CLIProxyAPI built directly from router-for-me/CLIProxyAPI source" \ - org.opencontainers.image.source="https://github.com/PluxelJS/Proxy-LLM-API" \ - org.opencontainers.image.url="https://github.com/router-for-me/CLIProxyAPI" \ - org.opencontainers.image.version="${VERSION}" \ - org.opencontainers.image.revision="${COMMIT}" \ - org.opencontainers.image.created="${BUILD_DATE}" \ - org.opencontainers.image.licenses="MIT" - -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl tzdata \ - && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /CLIProxyAPI/logs /CLIProxyAPI/plugins /data/auth - -COPY --from=builder /out/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI -COPY --from=builder /src/config.example.yaml /CLIProxyAPI/config.upstream.example.yaml - -WORKDIR /CLIProxyAPI - -ENV TZ=Asia/Taipei - -EXPOSE 8317 1455 54545 51121 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD curl --fail --silent --show-error http://127.0.0.1:8317/healthz >/dev/null || exit 1 - -CMD ["./CLIProxyAPI", "-config", "/CLIProxyAPI/config.yaml"] diff --git a/cliproxyapi/config.example.yaml b/cliproxyapi/config.example.yaml deleted file mode 100644 index 6ed7c3d..0000000 --- a/cliproxyapi/config.example.yaml +++ /dev/null @@ -1,99 +0,0 @@ -# 本文件只保留此部署实际使用的 CLIProxyAPI 当前 main 配置。 -# 完整的最新字段参考: -# https://github.com/router-for-me/CLIProxyAPI/blob/main/config.example.yaml - -host: "" -port: 8317 - -tls: - enable: false - cert: "" - key: "" - -remote-management: - # Docker 端口默认只绑定宿主机 127.0.0.1。确需远程管理时,再同时设置此项和强密码。 - allow-remote: false - secret-key: "" - disable-control-panel: false - disable-auto-update-panel: false - panel-github-repository: "https://github.com/router-for-me/Cli-Proxy-API-Management-Center" - -# Compose 将宿主机 ./cliproxyapi/oa 挂载到此目录。 -# 登录命令写入的 JSON 凭证会被服务热加载,且不会进入 Git 或镜像。 -auth-dir: "/data/auth" - -# 调用代理 API 时使用的 Bearer Key。复制本文件后务必更换。 -api-keys: - - "change-this-api-key" - -debug: false - -pprof: - enable: false - addr: "127.0.0.1:8316" - -plugins: - enabled: false - dir: "/CLIProxyAPI/plugins" - configs: {} - -commercial-mode: false -logging-to-file: false -logs-max-total-size-mb: 0 -error-logs-max-files: 10 -usage-statistics-enabled: false -redis-usage-queue-retention-seconds: 60 - -# 留空时继承 HTTP_PROXY/HTTPS_PROXY;可选 sing-box override 会自动注入这些环境变量。 -proxy-url: "" -force-model-prefix: false -passthrough-headers: false - -request-retry: 3 -max-retry-credentials: 0 -max-retry-interval: 30 -disable-cooling: false -save-cooldown-status: false -transient-error-cooldown-seconds: 0 - -# Claude/Codex/xAI compatibility defaults from the current upstream main config. -disable-claude-cloak-mode: false - -claude-code: - disable-cloaking-model-list: false - -disable-image-generation: false -video-result-auth-cache-ttl: "3h" - -quota-exceeded: - switch-project: true - switch-preview-model: true - antigravity-credits: true - -routing: - strategy: "round-robin" - session-affinity: false - session-affinity-ttl: "1h" - -codex: - identity-confuse: false - disable-codex-cloaking: false - optimize-multi-agent-v2: false - live-media-relay: - enabled: false - max-sessions: 32 - disable-private-remote-ips: false - public-ip: "" - udp-port-min: 0 - udp-port-max: 0 - -xai: - inject-x-search: false - -# 上游当前示例默认开启 WebSocket API 认证。 -ws-auth: true -nonstream-keepalive-interval: 0 - -streaming: - keepalive-seconds: 15 - bootstrap-retries: 1 diff --git a/cliproxyapi/logs/.gitkeep b/cliproxyapi/logs/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/cliproxyapi/logs/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/cliproxyapi/oa/.gitkeep b/cliproxyapi/oa/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/cliproxyapi/oa/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/cliproxyapi/plugins/.gitkeep b/cliproxyapi/plugins/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/cliproxyapi/plugins/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/compose.build.yaml b/compose.build.yaml deleted file mode 100644 index 0a33ec8..0000000 --- a/compose.build.yaml +++ /dev/null @@ -1,11 +0,0 @@ -services: - cli-proxy-api: - build: - context: . - dockerfile: cliproxyapi/Dockerfile - args: - CLIPROXY_REPOSITORY: ${CLIPROXY_REPOSITORY:-https://github.com/router-for-me/CLIProxyAPI.git} - CLIPROXY_REF: ${CLIPROXY_REF:-main} - VERSION: ${CLIPROXY_VERSION:-dev} - COMMIT: ${CLIPROXY_COMMIT:-none} - BUILD_DATE: ${CLIPROXY_BUILD_DATE:-unknown} diff --git a/compose.cloudflare-tunnel.yaml b/compose.cloudflare-tunnel.yaml deleted file mode 100644 index ce582c7..0000000 --- a/compose.cloudflare-tunnel.yaml +++ /dev/null @@ -1,18 +0,0 @@ -services: - cloudflared: - image: ${CLOUDFLARED_IMAGE:-cloudflare/cloudflared:2026.7.3} - profiles: ["cloudflare-tunnel"] - restart: unless-stopped - environment: - # cloudflared 官方支持 TUNNEL_TOKEN;值来自权限为 0600 的 .env。 - TUNNEL_TOKEN: ${CF_TUNNEL_TOKEN:-} - command: ["tunnel", "--metrics", "0.0.0.0:2000", "run"] - depends_on: - cli-proxy-api: - condition: service_healthy - healthcheck: - test: ["CMD", "cloudflared", "tunnel", "--metrics", "127.0.0.1:2000", "ready"] - interval: 15s - timeout: 5s - retries: 5 - start_period: 15s diff --git a/compose.internal-dragonfly.yaml b/compose.internal-dragonfly.yaml deleted file mode 100644 index 9e95b0f..0000000 --- a/compose.internal-dragonfly.yaml +++ /dev/null @@ -1,32 +0,0 @@ -services: - dragonfly: - image: ${DRAGONFLY_IMAGE:-docker.dragonflydb.io/dragonflydb/dragonfly:v1.40.0} - profiles: ["internal-dragonfly"] - restart: unless-stopped - ulimits: - memlock: -1 - volumes: - - ${DRAGONFLY_DATA_SOURCE:-dragonfly-data}:/data - command: - - "--logtostderr" - - "--dir=/data" - - "--dbfilename=dump" - - "--snapshot_cron=*/5 * * * *" - - "--maxmemory=${DRAGONFLY_MAXMEMORY:-1gb}" - - "--proactor_threads=${DRAGONFLY_THREADS:-2}" - # Hub 的 SessionManager 会在 Lua 脚本中动态拼接键。 - - "--default_lua_flags=allow-undeclared-keys" - healthcheck: - test: ["CMD", "/usr/local/bin/healthcheck.sh"] - interval: 5s - timeout: 3s - retries: 10 - start_period: 10s - - app: - depends_on: - dragonfly: - condition: service_healthy - -volumes: - dragonfly-data: diff --git a/compose.internal-postgres.yaml b/compose.internal-postgres.yaml deleted file mode 100644 index 24008d8..0000000 --- a/compose.internal-postgres.yaml +++ /dev/null @@ -1,28 +0,0 @@ -services: - postgres: - image: postgres:18 - profiles: ["internal-postgres"] - restart: unless-stopped - environment: - POSTGRES_USER: ${DB_USER:-postgres} - POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} - POSTGRES_DB: ${DB_NAME:-claude_code_hub} - PGDATA: /data/pgdata - TZ: Asia/Shanghai - PGTZ: Asia/Shanghai - volumes: - - ${POSTGRES_DATA_SOURCE:-postgres-data}:/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-claude_code_hub}"] - interval: 5s - timeout: 5s - retries: 10 - start_period: 10s - - app: - depends_on: - postgres: - condition: service_healthy - -volumes: - postgres-data: diff --git a/compose.podman.yaml b/compose.podman.yaml deleted file mode 100644 index 6110539..0000000 --- a/compose.podman.yaml +++ /dev/null @@ -1,49 +0,0 @@ -# Podman 6.0 can schedule health checks through transient systemd units that -# sporadically fail before exec (missing conmon exec_pid). Keep the -# Docker health checks in the primary files, but use scripts/healthcheck as the -# reliable, serial readiness check when this overlay is selected by Podman. -services: - app: - healthcheck: - disable: true - depends_on: - postgres: - condition: service_started - required: false - dragonfly: - condition: service_started - required: false - - cli-proxy-api: - healthcheck: - disable: true - depends_on: - sing-box: - condition: service_started - required: false - - cloudflared: - profiles: ["cloudflare-tunnel"] - healthcheck: - disable: true - depends_on: - cli-proxy-api: - condition: service_started - - postgres: - image: postgres:18 - profiles: ["internal-postgres"] - healthcheck: - disable: true - - dragonfly: - image: ${DRAGONFLY_IMAGE:-docker.dragonflydb.io/dragonflydb/dragonfly:v1.40.0} - profiles: ["internal-dragonfly"] - healthcheck: - disable: true - - sing-box: - image: ${SINGBOX_IMAGE:-ghcr.io/sagernet/sing-box:v1.13.16} - profiles: ["singbox"] - healthcheck: - disable: true diff --git a/compose.singbox.yaml b/compose.singbox.yaml deleted file mode 100644 index c72a82f..0000000 --- a/compose.singbox.yaml +++ /dev/null @@ -1,35 +0,0 @@ -services: - sing-box: - image: ${SINGBOX_IMAGE:-ghcr.io/sagernet/sing-box:v1.13.16} - profiles: ["singbox"] - restart: unless-stopped - labels: - io.github.pluxeljs.sing-box-config-sha256: ${SINGBOX_CONFIG_SHA256:-unknown} - command: ["-D", "/var/lib/sing-box", "-C", "/etc/sing-box", "run"] - volumes: - - ${SINGBOX_CONFIG_SOURCE:-./sing-box/config.json}:/etc/sing-box/config.json:ro - - ${SINGBOX_DATA_SOURCE:-singbox-data}:/var/lib/sing-box - healthcheck: - test: ["CMD", "sing-box", "check", "-c", "/etc/sing-box/config.json"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 5s - - cli-proxy-api: - depends_on: - sing-box: - condition: service_healthy - environment: - # CLIProxyAPI 的空 proxy-url 会继承环境代理。SOCKS5H 让目标域名也由代理侧解析。 - HTTP_PROXY: socks5h://sing-box:1080 - HTTPS_PROXY: socks5h://sing-box:1080 - ALL_PROXY: socks5h://sing-box:1080 - NO_PROXY: 127.0.0.1,localhost,::1,cli-proxy-api - http_proxy: socks5h://sing-box:1080 - https_proxy: socks5h://sing-box:1080 - all_proxy: socks5h://sing-box:1080 - no_proxy: 127.0.0.1,localhost,::1,cli-proxy-api - -volumes: - singbox-data: diff --git a/deploy/s-ui-server/.env.example b/deploy/s-ui-server/.env.example deleted file mode 100644 index e1319f6..0000000 --- a/deploy/s-ui-server/.env.example +++ /dev/null @@ -1,43 +0,0 @@ -# Maintainer-published multi-architecture image, pinned to the v1.5.4 index -# digest so an upstream tag rewrite cannot silently change the API contract. -SUI_IMAGE=alireza7/s-ui:v1.5.4@sha256:aa25d0018b210d71f40f3754e32dacfb98c49aa1cc9649ca93d20872fd942472 -SUI_EXPECTED_VERSION=1.5.4 -SUI_EXPECTED_SINGBOX_VERSION=v1.13.14 -TZ=Asia/Tokyo -SUI_LOG_LEVEL=info - -# The panel is private by default. Access it with the SSH tunnel printed by -# install.sh. Set 0.0.0.0 only after configuring a firewall or private overlay. -SUI_PANEL_BIND=127.0.0.1 -SUI_PANEL_PORT=2095 -# Leave blank on first install to generate an unguessable path. -SUI_PANEL_PATH= - -# Subscription service is private by default. It is not required for the -# generated AnyTLS link. -SUI_SUB_BIND=127.0.0.1 -SUI_SUB_PORT=2096 -SUI_SUB_PATH=/sub/ - -SUI_ADMIN_USERNAME=suiadmin -# Leave blank on first install to generate a password. -SUI_ADMIN_PASSWORD= - -# Set false to start an empty s-ui panel and configure protocols manually. -AUTO_CONFIGURE_ANYTLS=true -ANYTLS_DOMAIN=jp.example.com -# Any TCP port is valid with DNS-01. TCP 443 has the best compatibility. -ANYTLS_PORT=443 -ANYTLS_CLIENT_NAME=cliproxy -ANYTLS_TAG=jp-anytls -# Leave blank on first install to generate a password. -ANYTLS_PASSWORD= - -# The domain must already resolve to this server with Cloudflare proxying off -# (DNS only / grey cloud). ACME uses DNS-01, so ports 80 and 443 are not needed -# for certificate validation. -ACME_EMAIL= -CF_API_TOKEN=replace-with-a-zone-scoped-token -# Optional separate Zone:Read token. Leave empty when CF_API_TOKEN has both -# Zone:DNS:Edit and Zone:Zone:Read for this zone. -CF_ZONE_TOKEN= diff --git a/deploy/s-ui-server/README.md b/deploy/s-ui-server/README.md deleted file mode 100644 index a6fe2e6..0000000 --- a/deploy/s-ui-server/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# s-ui 远端节点 - -这套目录用于在单台 Linux VPS 上运行 s-ui,并自动创建一个使用 Cloudflare DNS-01 ACME 的 AnyTLS 入站。它与仓库根目录的 CLIProxyAPI Compose 完全独立;远端节点只需要 Docker Compose 和 Python 3.9 以上版本。 - -## 部署 - -在远端克隆仓库后执行: - -```bash -cd deploy/s-ui-server -cp .env.example .env -chmod 600 .env -``` - -至少填写: - -```dotenv -ANYTLS_DOMAIN=jp.example.com -CF_API_TOKEN=... -``` - -域名应提前建立指向 VPS 公网地址的 A/AAAA 记录,并保持 Cloudflare **DNS Only(灰云)**。Token 最小权限为该 Zone 的 `Zone:DNS:Edit` 和 `Zone:Zone:Read`;也可以把只读权限拆成单独的 `CF_ZONE_TOKEN`。 - -然后运行: - -```bash -./install.sh -``` - -首次执行会自动生成管理员密码、随机面板路径和 AnyTLS 密码。安装结果和 Token 保存在权限为 `0600` 的 `.env`,客户端链接保存在 `data/anytls-url`。再次执行是幂等的,会更新同名的 TLS、客户端和入站配置。 - -自动配置通过 s-ui 自己前端使用的会话 API 完成。当前载荷已经针对 s-ui `1.5.4` 的真实发布二进制验证,但该 API 不视为跨大版本稳定接口。因此镜像同时固定了版本标签和多架构内容摘要,安装时还会核对 s-ui `1.5.4` 与内置 sing-box `v1.13.14`;上游重推标签不会静默改变行为。升级时必须一起修改 `SUI_IMAGE`、`SUI_EXPECTED_VERSION` 和 `SUI_EXPECTED_SINGBOX_VERSION`,并重新完成契约测试。 - -自动配置由多个独立 API 事务组成,并不是一个跨对象数据库事务。如果 Cloudflare Token 或 DNS 配置错误,TLS/客户端对象可能已经创建而 AnyTLS 入站尚未成功;修正 `.env` 后重新执行 `./install.sh` 即可幂等续跑,不需要手工清理。 - -如果只想启动空面板,把 `AUTO_CONFIGURE_ANYTLS=false`,之后在 s-ui 中手动配置。 - -## 非标准端口 - -ACME 使用 DNS-01,因此 AnyTLS 可以监听任意 TCP 端口,不依赖 80/443: - -```dotenv -ANYTLS_PORT=28443 -``` - -客户端链接会自动包含 `:28443`。非标端口需要在云防火墙和 VPS 防火墙放行 TCP;部分公司、校园或移动网络只允许常见端口,所以长期稳定性仍以 TCP 443 最好。 - -Compose 使用 `network_mode: host`,s-ui 后续在面板中新增任何入站端口都不需要修改 Docker 端口映射。相应地,端口隔离完全由主机防火墙负责。 - -面板默认只监听 `127.0.0.1`,通过 SSH 隧道访问: - -```bash -ssh -L 2095:127.0.0.1:2095 user@jp-server -``` - -如果修改了 `SUI_PANEL_PORT`,隧道两侧使用修改后的端口。只有在已经配置好来源 IP 白名单、Tailscale 或其他私网访问控制后,才应把 `SUI_PANEL_BIND` 改成 `0.0.0.0`。 - -## 运维 - -```bash -./manage.sh status -./manage.sh logs -./manage.sh link -./manage.sh backup -./manage.sh update -./manage.sh reconfigure -``` - -`update` 会重新进入安装器,执行镜像版本核对和幂等 API 配置。要升级 s-ui,必须同时明确修改镜像、期望版本并重新验证 API;不要在无人值守环境使用浮动的 `latest`。 - -持久数据位于: - -```text -data/db/ s-ui SQLite 数据库(含 CF Token 与节点配置) -data/acme/ ACME 账号、证书及续期状态 -data/cert/ 手工证书预留目录 -``` - -请备份 `data/`,且不要提交 `.env`、数据库、证书或生成的分享链接。 diff --git a/deploy/s-ui-server/bootstrap.py b/deploy/s-ui-server/bootstrap.py deleted file mode 100755 index 122cddf..0000000 --- a/deploy/s-ui-server/bootstrap.py +++ /dev/null @@ -1,322 +0,0 @@ -#!/usr/bin/env python3 -"""Initialize private s-ui settings and optionally create an AnyTLS inbound. - -The session API payloads are intentionally coupled to s-ui v1.5.4. install.sh -checks the panel and embedded sing-box versions before this module may write. -""" - -from __future__ import annotations - -import argparse -import http.cookiejar -import json -import os -import pathlib -import sqlite3 -import sys -import time -import urllib.error -import urllib.parse -import urllib.request -from typing import Optional - - -def env(name: str, default: str = "") -> str: - return os.environ.get(name, default).strip() - - -def env_bool(name: str, default: bool = False) -> bool: - value = env(name) - if not value: - return default - if value.lower() in {"1", "true", "yes", "on"}: - return True - if value.lower() in {"0", "false", "no", "off"}: - return False - raise ValueError(f"{name} must be true or false") - - -def env_port(name: str, default: int) -> int: - value = int(env(name, str(default))) - if not 1 <= value <= 65535: - raise ValueError(f"{name} must be between 1 and 65535") - return value - - -def configure_db(db_path: pathlib.Path) -> None: - if not db_path.is_file(): - raise RuntimeError(f"s-ui database does not exist: {db_path}") - - settings = { - "webListen": env("SUI_PANEL_BIND", "127.0.0.1"), - "subListen": env("SUI_SUB_BIND", "127.0.0.1"), - "timeLocation": env("TZ", "Asia/Tokyo"), - } - with sqlite3.connect(db_path) as connection: - for key, value in settings.items(): - cursor = connection.execute( - "UPDATE settings SET value = ? WHERE key = ?", (value, key) - ) - if cursor.rowcount == 0: - connection.execute( - "INSERT INTO settings(key, value) VALUES(?, ?)", (key, value) - ) - connection.commit() - - -class SUIClient: - def __init__(self, base_url: str, username: str, password: str) -> None: - cookie_jar = http.cookiejar.CookieJar() - self.opener = urllib.request.build_opener( - urllib.request.HTTPCookieProcessor(cookie_jar) - ) - self.base_url = base_url.rstrip("/") + "/" - self.username = username - self.password = password - - def request( - self, method: str, endpoint: str, form: Optional[dict[str, str]] = None - ) -> dict: - data = None - headers = {"X-Requested-With": "XMLHttpRequest"} - if form is not None: - data = urllib.parse.urlencode(form).encode() - headers["Content-Type"] = "application/x-www-form-urlencoded" - request = urllib.request.Request( - urllib.parse.urljoin(self.base_url, endpoint), - data=data, - headers=headers, - method=method, - ) - with self.opener.open(request, timeout=300) as response: - result = json.load(response) - if not result.get("success"): - raise RuntimeError(result.get("msg") or f"s-ui API failed: {endpoint}") - return result - - def wait_and_login(self) -> None: - last_error: Optional[Exception] = None - for _ in range(60): - try: - self.request( - "POST", - "api/login", - {"user": self.username, "pass": self.password}, - ) - return - except (OSError, RuntimeError, urllib.error.URLError) as error: - last_error = error - time.sleep(2) - raise RuntimeError(f"s-ui did not become ready: {last_error}") - - def load(self, object_name: str, object_id: Optional[int] = None) -> list[dict]: - suffix = f"?id={object_id}" if object_id is not None else "" - result = self.request("GET", f"api/{object_name}{suffix}") - payload = result.get("obj") - if not isinstance(payload, dict): - raise RuntimeError(f"unexpected s-ui API payload for {object_name}") - items = payload.get(object_name) - # s-ui v1.5.4 serializes an empty clients/inbounds collection as null, - # while an empty tls collection is []. Treat both as the same empty - # collection and retain strict validation for every non-empty shape. - if items is None: - return [] - if not isinstance(items, list): - raise RuntimeError(f"unexpected s-ui API shape for {object_name}") - return items - - def save( - self, - object_name: str, - action: str, - data: object, - init_users: Optional[list[int]] = None, - ) -> dict: - form = { - "object": object_name, - "action": action, - "data": json.dumps(data, separators=(",", ":")), - } - if init_users: - form["initUsers"] = ",".join(str(user_id) for user_id in init_users) - return self.request("POST", "api/save", form) - - -def find_named(items: list[dict], key: str, value: str) -> Optional[dict]: - return next((item for item in items if item.get(key) == value), None) - - -def configure_anytls() -> str: - domain = env("ANYTLS_DOMAIN") - cf_token = env("CF_API_TOKEN") - if not domain or domain == "jp.example.com": - raise ValueError("set ANYTLS_DOMAIN in .env") - if not cf_token or cf_token.startswith("replace-with-"): - raise ValueError("set CF_API_TOKEN in .env") - - panel_port = env_port("SUI_PANEL_PORT", 2095) - panel_path = env("SUI_PANEL_PATH", "/app/").strip("/") - client = SUIClient( - f"http://127.0.0.1:{panel_port}/{panel_path}/", - env("SUI_ADMIN_USERNAME", "suiadmin"), - env("SUI_ADMIN_PASSWORD"), - ) - client.wait_and_login() - - # Fail before making changes if the frontend API no longer has the object - # shapes used by v1.5.4. Individual save responses are checked as well. - for object_name in ("tls", "clients", "inbounds"): - objects = client.load(object_name) - if not isinstance(objects, list): - raise RuntimeError(f"unexpected s-ui API shape for {object_name}") - - tls_name = "anytls-acme" - tls_items = client.load("tls") - existing_tls = find_named(tls_items, "name", tls_name) - dns_challenge = { - "provider": "cloudflare", - "api_token": cf_token, - } - zone_token = env("CF_ZONE_TOKEN") - if zone_token: - dns_challenge["zone_token"] = zone_token - - acme = { - "domain": [domain], - "data_directory": "/app/acme", - "default_server_name": domain, - "provider": "letsencrypt", - "disable_http_challenge": True, - "disable_tls_alpn_challenge": True, - "dns01_challenge": dns_challenge, - } - email = env("ACME_EMAIL") - if email: - acme["email"] = email - - tls_data = { - "id": int(existing_tls["id"]) if existing_tls else 0, - "name": tls_name, - "server": { - "enabled": True, - "server_name": domain, - "min_version": "1.2", - "max_version": "1.3", - "acme": acme, - }, - "client": {"server_name": domain}, - } - client.save("tls", "edit" if existing_tls else "new", tls_data) - tls_id = int(find_named(client.load("tls"), "name", tls_name)["id"]) - - client_name = env("ANYTLS_CLIENT_NAME", "cliproxy") - anytls_password = env("ANYTLS_PASSWORD") - existing_client = find_named(client.load("clients"), "name", client_name) - if existing_client: - client_id = int(existing_client["id"]) - client_data = client.load("clients", client_id)[0] - client_data.setdefault("config", {}) - client_data.setdefault("inbounds", []) - client_data.setdefault("links", []) - else: - client_id = 0 - client_data = { - "enable": True, - "name": client_name, - "config": {}, - "inbounds": [], - "links": [], - "volume": 0, - "expiry": 0, - "up": 0, - "down": 0, - "desc": "CLIProxyAPI egress", - "group": "proxy-llm-api", - "remark": "jp", - } - client_data["config"]["anytls"] = { - "name": client_name, - "password": anytls_password, - } - client.save("clients", "edit" if existing_client else "new", client_data) - if not client_id: - client_id = int(find_named(client.load("clients"), "name", client_name)["id"]) - - inbound_tag = env("ANYTLS_TAG", "jp-anytls") - inbound_port = env_port("ANYTLS_PORT", 443) - existing_inbound = find_named(client.load("inbounds"), "tag", inbound_tag) - inbound_id = int(existing_inbound["id"]) if existing_inbound else 0 - inbound_data = { - "id": inbound_id, - "type": "anytls", - "tag": inbound_tag, - "listen": "::", - "listen_port": inbound_port, - "tls_id": tls_id, - "padding_scheme": [ - "stop=8", - "0=30-30", - "1=100-400", - "2=400-500,c,500-1000,c,500-1000,c,500-1000,c,500-1000", - "3=9-9,500-1000", - "4=500-1000", - "5=500-1000", - "6=500-1000", - "7=500-1000", - ], - "addrs": [ - {"server": domain, "server_port": inbound_port, "remark": "-jp"} - ], - "out_json": {}, - } - client.save( - "inbounds", - "edit" if existing_inbound else "new", - inbound_data, - [client_id] if not existing_inbound else None, - ) - inbound_id = int(find_named(client.load("inbounds"), "tag", inbound_tag)["id"]) - - # Editing an existing deployment may need to repair the client association. - full_client = client.load("clients", client_id)[0] - inbound_ids = [int(item) for item in full_client.get("inbounds", [])] - if inbound_id not in inbound_ids or full_client.get("config", {}).get( - "anytls", {} - ).get("password") != anytls_password: - full_client.setdefault("config", {})["anytls"] = { - "name": client_name, - "password": anytls_password, - } - full_client["inbounds"] = sorted(set(inbound_ids + [inbound_id])) - client.save("clients", "edit", full_client) - - final_client = client.load("clients", client_id)[0] - for link in final_client.get("links", []): - if link.get("type") == "local" and link.get("uri", "").startswith( - "anytls://" - ): - return link["uri"] - raise RuntimeError("s-ui saved AnyTLS but did not generate a client link") - - -def main() -> int: - parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers(dest="command", required=True) - db_parser = subparsers.add_parser("db") - db_parser.add_argument("path", type=pathlib.Path) - subparsers.add_parser("anytls") - args = parser.parse_args() - - try: - if args.command == "db": - configure_db(args.path) - elif args.command == "anytls": - print(configure_anytls()) - except (OSError, RuntimeError, ValueError, urllib.error.URLError) as error: - print(f"bootstrap failed: {error}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/deploy/s-ui-server/docker-compose.yaml b/deploy/s-ui-server/docker-compose.yaml deleted file mode 100644 index 7bea452..0000000 --- a/deploy/s-ui-server/docker-compose.yaml +++ /dev/null @@ -1,29 +0,0 @@ -services: - s-ui: - image: ${SUI_IMAGE:-alireza7/s-ui:v1.5.4@sha256:aa25d0018b210d71f40f3754e32dacfb98c49aa1cc9649ca93d20872fd942472} - container_name: proxy-llm-s-ui - hostname: s-ui - restart: unless-stopped - # Linux host networking lets sing-box listen on any port selected in the - # panel without changing Compose port mappings. - network_mode: host - environment: - TZ: ${TZ:-Asia/Tokyo} - SUI_DB_FOLDER: /app/db - SUI_LOG_LEVEL: ${SUI_LOG_LEVEL:-info} - SUI_DEBUG: "false" - volumes: - - ./data/db:/app/db - - ./data/acme:/app/acme - - ./data/cert:/app/cert - healthcheck: - test: - - CMD-SHELL - - >- - wget -q -T 3 -O /dev/null - http://127.0.0.1:${SUI_PANEL_PORT:-2095}${SUI_PANEL_PATH:-/app/} - || exit 1 - interval: 15s - timeout: 5s - retries: 5 - start_period: 20s diff --git a/deploy/s-ui-server/install.sh b/deploy/s-ui-server/install.sh deleted file mode 100755 index d0839e3..0000000 --- a/deploy/s-ui-server/install.sh +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -deploy_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -env_file="$deploy_dir/.env" -compose_file="$deploy_dir/docker-compose.yaml" - -die() { - echo "错误: $*" >&2 - exit 1 -} - -compose() { - docker compose --env-file "$env_file" -f "$compose_file" "$@" -} - -random_hex() { - local bytes="$1" - if command -v openssl >/dev/null 2>&1; then - openssl rand -hex "$bytes" - else - od -An -N "$bytes" -tx1 /dev/urandom | tr -d ' \n' - fi -} - -set_env_value() { - local key="$1" - local value="$2" - local temp_file - temp_file="$(mktemp "${env_file}.XXXXXX")" - awk -v key="$key" -v value="$value" ' - BEGIN { found = 0 } - $0 ~ "^" key "=" { print key "=" value; found = 1; next } - { print } - END { if (!found) print key "=" value } - ' "$env_file" > "$temp_file" - chmod 600 "$temp_file" - mv "$temp_file" "$env_file" -} - -load_env() { - set -a - # The file is local, mode 0600, and intentionally follows shell-compatible - # KEY=value syntax so quoted Cloudflare tokens are supported. - # shellcheck disable=SC1090 - source "$env_file" - set +a -} - -normalize_path() { - local value="$1" - value="/${value#/}" - value="${value%/}/" - printf '%s' "$value" -} - -[[ "$(uname -s)" == "Linux" ]] || die "host-network 模式仅支持 Linux VPS" -command -v docker >/dev/null 2>&1 || die "需要先安装 Docker Engine" -docker compose version >/dev/null 2>&1 || die "需要 Docker Compose v2 插件" -command -v python3 >/dev/null 2>&1 || die "自动初始化需要 python3" - -if [[ ! -f "$env_file" ]]; then - cp "$deploy_dir/.env.example" "$env_file" - chmod 600 "$env_file" - echo "已创建 $env_file;请填写 ANYTLS_DOMAIN 和 CF_API_TOKEN 后重新运行。" - exit 2 -fi -chmod 600 "$env_file" -load_env - -if [[ -z "${SUI_ADMIN_PASSWORD:-}" ]]; then - set_env_value SUI_ADMIN_PASSWORD "$(random_hex 18)" -fi -if [[ -z "${ANYTLS_PASSWORD:-}" ]]; then - set_env_value ANYTLS_PASSWORD "$(random_hex 18)" -fi -if [[ -z "${SUI_PANEL_PATH:-}" ]]; then - set_env_value SUI_PANEL_PATH "/$(random_hex 10)/" -fi -load_env - -SUI_PANEL_PATH="$(normalize_path "$SUI_PANEL_PATH")" -SUI_SUB_PATH="$(normalize_path "${SUI_SUB_PATH:-/sub/}")" -set_env_value SUI_PANEL_PATH "$SUI_PANEL_PATH" -set_env_value SUI_SUB_PATH "$SUI_SUB_PATH" -load_env - -[[ "${SUI_PANEL_PORT:-}" =~ ^[0-9]+$ ]] || die "SUI_PANEL_PORT 必须是端口数字" -[[ "${SUI_SUB_PORT:-}" =~ ^[0-9]+$ ]] || die "SUI_SUB_PORT 必须是端口数字" -[[ "${ANYTLS_PORT:-}" =~ ^[0-9]+$ ]] || die "ANYTLS_PORT 必须是端口数字" -for port in "$SUI_PANEL_PORT" "$SUI_SUB_PORT" "$ANYTLS_PORT"; do - (( port >= 1 && port <= 65535 )) || die "端口必须介于 1 和 65535" -done -if [[ "$SUI_PANEL_PORT" == "$SUI_SUB_PORT" || "$SUI_PANEL_PORT" == "$ANYTLS_PORT" || "$SUI_SUB_PORT" == "$ANYTLS_PORT" ]]; then - die "面板、订阅和 AnyTLS 端口不能重复" -fi -case "${SUI_PANEL_BIND:-127.0.0.1}" in - 127.0.0.1|0.0.0.0) ;; - *) die "SUI_PANEL_BIND 目前只支持 127.0.0.1 或 0.0.0.0" ;; -esac - -auto_configure_anytls="${AUTO_CONFIGURE_ANYTLS:-true}" -auto_configure_anytls="${auto_configure_anytls,,}" -case "$auto_configure_anytls" in - true|false) ;; - *) die "AUTO_CONFIGURE_ANYTLS 必须是 true 或 false" ;; -esac - -if [[ "$auto_configure_anytls" == "true" ]]; then - [[ -n "${ANYTLS_DOMAIN:-}" && "$ANYTLS_DOMAIN" != "jp.example.com" ]] || die "请在 .env 设置 ANYTLS_DOMAIN" - [[ -n "${CF_API_TOKEN:-}" && "$CF_API_TOKEN" != replace-with-* ]] || die "请在 .env 设置 CF_API_TOKEN" -fi - -mkdir -p "$deploy_dir/data/db" "$deploy_dir/data/acme" "$deploy_dir/data/cert" -chmod 700 "$deploy_dir/data" "$deploy_dir/data/db" "$deploy_dir/data/acme" "$deploy_dir/data/cert" - -compose pull - -version_output="$(compose run --rm --entrypoint ./sui s-ui -v)" -actual_sui_version="$(printf '%s\n' "$version_output" | awk '/^S-UI Panel/ {print $3}')" -actual_singbox_version="$(printf '%s\n' "$version_output" | awk '/^Sing-Box/ {print $2}')" -expected_sui_version="${SUI_EXPECTED_VERSION:-1.5.4}" -expected_singbox_version="${SUI_EXPECTED_SINGBOX_VERSION:-v1.13.14}" -[[ "$actual_sui_version" == "$expected_sui_version" ]] || \ - die "s-ui 版本不匹配:期望 $expected_sui_version,实际 ${actual_sui_version:-unknown}" -[[ "$actual_singbox_version" == "$expected_singbox_version" ]] || \ - die "sing-box 版本不匹配:期望 $expected_singbox_version,实际 ${actual_singbox_version:-unknown}" -printf '%s\n' "$version_output" - -compose down --remove-orphans - -# The image CLI initializes/migrates the SQLite database and hashes the admin -# password. It is run while the long-lived container is stopped. -compose run --rm --entrypoint ./sui s-ui admin \ - -username "$SUI_ADMIN_USERNAME" -password "$SUI_ADMIN_PASSWORD" -compose run --rm --entrypoint ./sui s-ui setting \ - -port "$SUI_PANEL_PORT" -path "$SUI_PANEL_PATH" \ - -subPort "$SUI_SUB_PORT" -subPath "$SUI_SUB_PATH" -# A rootful Docker daemon may leave the database, its WAL/journal files, or the -# bind-mount directory itself owned by root. SQLite needs write access to both -# the database and its containing directory, so hand the complete DB tree back -# to the invoking host user before bootstrap.py opens it. -host_uid="$(id -u)" -host_gid="$(id -g)" -compose run --rm --entrypoint sh s-ui -c \ - "chown -R ${host_uid}:${host_gid} /app/db" -python3 "$deploy_dir/bootstrap.py" db "$deploy_dir/data/db/s-ui.db" - -compose up -d - -if [[ "$auto_configure_anytls" == "true" ]]; then - anytls_url="$(python3 "$deploy_dir/bootstrap.py" anytls)" - umask 077 - printf '%s\n' "$anytls_url" > "$deploy_dir/data/anytls-url" - echo "AnyTLS 已配置,客户端链接保存在: $deploy_dir/data/anytls-url" -fi - -echo -echo "s-ui 已启动。" -echo "管理员: $SUI_ADMIN_USERNAME" -echo "密码保存在: $env_file" -if [[ "${SUI_PANEL_BIND:-127.0.0.1}" == "127.0.0.1" ]]; then - echo "从本机建立隧道: ssh -L ${SUI_PANEL_PORT}:127.0.0.1:${SUI_PANEL_PORT} @" - echo "随后访问: http://127.0.0.1:${SUI_PANEL_PORT}${SUI_PANEL_PATH}" -else - echo "面板监听: ${SUI_PANEL_BIND}:${SUI_PANEL_PORT}${SUI_PANEL_PATH}" -fi -echo "AnyTLS TCP 端口: ${ANYTLS_PORT}(请在云防火墙和主机防火墙放行)" diff --git a/deploy/s-ui-server/manage.sh b/deploy/s-ui-server/manage.sh deleted file mode 100755 index fe70103..0000000 --- a/deploy/s-ui-server/manage.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -deploy_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -env_file="$deploy_dir/.env" -compose_file="$deploy_dir/docker-compose.yaml" - -[[ -f "$env_file" ]] || { - echo "缺少 $env_file,请先运行 ./install.sh" >&2 - exit 2 -} - -compose() { - docker compose --env-file "$env_file" -f "$compose_file" "$@" -} - -command="${1:-status}" -shift || true - -case "$command" in - up) - compose up -d "$@" - ;; - down) - compose down "$@" - ;; - restart) - compose restart s-ui - ;; - logs) - compose logs -f --tail=200 s-ui "$@" - ;; - status) - compose ps - ;; - update) - # Upgrades must pass the version/API guard and idempotent bootstrap. - exec "$deploy_dir/install.sh" - ;; - backup) - backup_dir="$deploy_dir/backups" - mkdir -p "$backup_dir" - chmod 700 "$backup_dir" - output="$backup_dir/s-ui-$(date -u +%Y%m%dT%H%M%SZ).db" - compose exec -T s-ui ./sui backup -output - > "$output" - if [[ "$(head -c 16 "$output")" != "SQLite format 3"* ]]; then - rm -f "$output" - echo "s-ui 备份失败,输出不是有效的 SQLite 数据库。" >&2 - exit 1 - fi - chmod 600 "$output" - echo "$output" - ;; - link) - [[ -f "$deploy_dir/data/anytls-url" ]] || { - echo "尚未生成 AnyTLS 链接,请运行 ./install.sh 完成自动配置。" >&2 - exit 2 - } - cat "$deploy_dir/data/anytls-url" - ;; - reconfigure) - exec "$deploy_dir/install.sh" - ;; - *) - echo "用法: $0 {up|down|restart|logs|status|update|backup|link|reconfigure}" >&2 - exit 2 - ;; -esac diff --git a/deploy/ssh-hardening/README.md b/deploy/ssh-hardening/README.md deleted file mode 100644 index f085618..0000000 --- a/deploy/ssh-hardening/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# SSH 一键加固 - -脚本面向新安装的 Debian VPS。它会创建非 root 管理用户、生成一次性 -Ed25519 密钥、配置免密 sudo、禁用 SSH 密码与 root 登录,并增加非标准 -SSH 端口。私钥在 `/run` 的内存文件系统中生成,只打印到当前终端,脚本 -退出时会删除临时文件。 - -先在云服务商防火墙放行准备使用的新端口。如果服务器上已经克隆本仓库, -以 root 执行: - -```bash -env SSH_PORT=23472 SSH_ADMIN_USER=deploy \ - ./deploy/ssh-hardening/bootstrap.sh -``` - -私有仓库的新服务器不必配置 GitHub Token,也可以从已检出仓库的本机通过 -现有 SSH 连接直接执行: - -```bash -ssh root@SERVER_IP \ - 'SSH_PORT=23472 SSH_ADMIN_USER=deploy bash -s' \ - < deploy/ssh-hardening/bootstrap.sh -``` - -不要立即关闭当前连接。把输出的私钥保存到本机,按脚本打印的命令建立 -第二条 SSH 连接。确认成功后,在新连接运行: - -```bash -sudo /usr/local/sbin/proxy-llm-ssh-finalize -``` - -在确认前,脚本会同时保留原 SSH 端口与新端口作为密钥入口,但两个端口 -都已禁用密码及 root 登录。确认命令会移除旧端口,只留下新端口。 - -重复运行脚本会生成并追加一把新密钥,可用于旧私钥丢失但当前控制台或 -SSH 会话仍然可用的情况。需要撤销某把密钥时,从 -`/home/deploy/.ssh/authorized_keys` 删除对应行。 - -确认命令会处理 OpenSSH 的端口累加语义:如果云镜像在主配置或其他 -drop-in 中显式声明旧端口,它会先把原文件备份到 -`/root/proxy-llm-ssh-backups/`,再注释旧端口声明并校验配置。 diff --git a/deploy/ssh-hardening/bootstrap.sh b/deploy/ssh-hardening/bootstrap.sh deleted file mode 100755 index 2ec8757..0000000 --- a/deploy/ssh-hardening/bootstrap.sh +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -admin_user="${SSH_ADMIN_USER:-deploy}" -new_port="${SSH_PORT:-23472}" -config_file="/etc/ssh/sshd_config.d/00-proxy-llm-hardening.conf" -finalize_script="/usr/local/sbin/proxy-llm-ssh-finalize" - -die() { - echo "错误: $*" >&2 - exit 1 -} - -reload_ssh() { - if systemctl list-unit-files ssh.service >/dev/null 2>&1; then - systemctl reload ssh - elif systemctl list-unit-files sshd.service >/dev/null 2>&1; then - systemctl reload sshd - else - die "找不到 ssh.service 或 sshd.service" - fi -} - -[[ "$(id -u)" -eq 0 ]] || die "请以 root 运行(curl ... | sudo env ... bash)" -[[ "$admin_user" =~ ^[a-z_][a-z0-9_-]{0,30}$ ]] || die "SSH_ADMIN_USER 格式无效" -[[ "$admin_user" != "root" ]] || die "SSH_ADMIN_USER 不能是 root" -[[ "$new_port" =~ ^[0-9]+$ ]] || die "SSH_PORT 必须是数字" -(( new_port >= 1024 && new_port <= 65535 )) || die "SSH_PORT 必须介于 1024 和 65535" -case "$new_port" in - 2095|2096) die "SSH_PORT 与 s-ui 默认端口冲突" ;; -esac - -export DEBIAN_FRONTEND=noninteractive -apt-get update -apt-get install -y --no-install-recommends \ - ca-certificates curl iproute2 openssh-server openssh-client sudo - -command -v sshd >/dev/null 2>&1 || die "未找到 sshd" -grep -Eq '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf' \ - /etc/ssh/sshd_config || die "sshd_config 未包含 /etc/ssh/sshd_config.d/*.conf" - -current_port="" -if [[ -n "${SSH_CONNECTION:-}" ]]; then - current_port="$(awk '{print $4}' <<<"$SSH_CONNECTION")" -fi -if [[ ! "$current_port" =~ ^[0-9]+$ ]]; then - current_port="$(sshd -T | awk '$1 == "port" { print $2; exit }')" -fi -[[ "$current_port" =~ ^[0-9]+$ ]] || current_port=22 - -if [[ "$new_port" != "$current_port" ]] && \ - ss -H -ltn | awk '{print $4}' | grep -Eq "(^|:)$new_port$"; then - die "TCP $new_port 已被其他服务占用" -fi - -if ! id "$admin_user" >/dev/null 2>&1; then - useradd --create-home --shell /bin/bash "$admin_user" -fi -usermod --append --groups sudo --shell /bin/bash "$admin_user" -passwd --lock "$admin_user" >/dev/null 2>&1 || true - -admin_home="$(getent passwd "$admin_user" | cut -d: -f6)" -[[ -n "$admin_home" && -d "$admin_home" ]] || die "无法确定 $admin_user 的主目录" -admin_group="$(id -gn "$admin_user")" - -# /run is normally tmpfs on Debian, so the generated private key is never -# written to persistent storage. -temp_dir="$(mktemp -d /run/proxy-llm-ssh.XXXXXX)" -cleanup() { - rm -rf -- "$temp_dir" -} -trap cleanup EXIT -chmod 700 "$temp_dir" - -key_comment="$admin_user@$(hostname)-$(date -u +%Y%m%dT%H%M%SZ)" -ssh-keygen -q -t ed25519 -a 64 -N '' -C "$key_comment" -f "$temp_dir/id_ed25519" -public_key="$(<"$temp_dir/id_ed25519.pub")" - -install -d -m 700 -o "$admin_user" -g "$admin_group" "$admin_home/.ssh" -touch "$admin_home/.ssh/authorized_keys" -chmod 600 "$admin_home/.ssh/authorized_keys" -chown "$admin_user:$admin_group" "$admin_home/.ssh/authorized_keys" -if ! grep -qxF "$public_key" "$admin_home/.ssh/authorized_keys"; then - printf '%s\n' "$public_key" >> "$admin_home/.ssh/authorized_keys" -fi - -sudoers_temp="$temp_dir/sudoers" -printf '%s ALL=(ALL:ALL) NOPASSWD: ALL\n' "$admin_user" > "$sudoers_temp" -chmod 440 "$sudoers_temp" -visudo -cf "$sudoers_temp" >/dev/null -install -o root -g root -m 440 "$sudoers_temp" "/etc/sudoers.d/90-$admin_user" - -write_sshd_config() { - local include_old_port="$1" - local output="$2" - { - echo "# Managed by Proxy-LLM-API deploy/ssh-hardening/bootstrap.sh" - if [[ "$include_old_port" == "true" && "$current_port" != "$new_port" ]]; then - echo "Port $current_port" - fi - echo "Port $new_port" - echo "PubkeyAuthentication yes" - echo "AuthenticationMethods publickey" - echo "PasswordAuthentication no" - echo "KbdInteractiveAuthentication no" - echo "PermitRootLogin no" - echo "PermitEmptyPasswords no" - echo "MaxAuthTries 3" - echo "X11Forwarding no" - echo "AllowUsers $admin_user" - } > "$output" -} - -config_temp="$temp_dir/sshd.conf" -write_sshd_config true "$config_temp" -install -o root -g root -m 600 "$config_temp" "$config_file" -sshd -t || die "新的 sshd 配置校验失败" - -if command -v ufw >/dev/null 2>&1 && ufw status | grep -q '^Status: active'; then - ufw allow "$new_port/tcp" >/dev/null -fi -if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then - firewall-cmd --permanent --add-port="$new_port/tcp" >/dev/null - firewall-cmd --reload >/dev/null -fi - -finalize_temp="$temp_dir/finalize" -cat > "$finalize_temp" <&2; exit 1; } -temp_file="\$(mktemp)" -trap 'rm -f -- "\$temp_file"' EXIT -cat > "\$temp_file" <<'CONFIG' -# Managed by Proxy-LLM-API deploy/ssh-hardening/bootstrap.sh -Port $new_port -PubkeyAuthentication yes -AuthenticationMethods publickey -PasswordAuthentication no -KbdInteractiveAuthentication no -PermitRootLogin no -PermitEmptyPasswords no -MaxAuthTries 3 -X11Forwarding no -AllowUsers $admin_user -CONFIG -install -o root -g root -m 600 "\$temp_file" "$config_file" - -# Port directives are cumulative in OpenSSH. Some provider images explicitly -# set Port 22 in the main file, so a drop-in with only the new port does not -# override it. Back up and disable only declarations of the old listener. -backup_dir="/root/proxy-llm-ssh-backups/\$(date -u +%Y%m%dT%H%M%SZ)" -install -d -o root -g root -m 700 "\$backup_dir" -declare -a changed_files=() -shopt -s nullglob -for candidate in /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf; do - [[ "\$candidate" == "$config_file" ]] && continue - if grep -Eq '^[[:space:]]*Port[[:space:]]+$current_port([[:space:]]*(#.*)?)?\$' \ - "\$candidate"; then - backup_path="\$backup_dir\$candidate" - install -d -o root -g root -m 700 "\$(dirname "\$backup_path")" - cp -a -- "\$candidate" "\$backup_path" - sed -i -E \ - 's/^[[:space:]]*Port[[:space:]]+$current_port([[:space:]]*(#.*)?)?\$/# Port $current_port disabled by Proxy-LLM-API SSH hardening/' \ - "\$candidate" - changed_files+=("\$candidate") - fi -done -shopt -u nullglob -if ! sshd -t; then - for candidate in "\${changed_files[@]}"; do - cp -a -- "\$backup_dir\$candidate" "\$candidate" - done - echo "sshd 配置校验失败;已恢复旧端口声明。" >&2 - exit 1 -fi -if systemctl list-unit-files ssh.service >/dev/null 2>&1; then - systemctl reload ssh -else - systemctl reload sshd -fi -sleep 1 -if [[ "$current_port" != "$new_port" ]] && \ - ss -H -ltn | awk '{print \$4}' | grep -Eq '(^|:)$current_port\$'; then - echo "警告: 旧端口 $current_port 仍被另一份 sshd 配置声明。" >&2 - echo "请执行 sshd -T | grep ^port 并检查 /etc/ssh/sshd_config。" >&2 - exit 1 -fi -rm -f -- "$finalize_script" -echo "SSH 加固已确认;旧端口 $current_port 已停止监听。" -FINALIZE -install -o root -g root -m 700 "$finalize_temp" "$finalize_script" - -reload_ssh -sleep 1 -ss -H -ltn | awk '{print $4}' | grep -Eq "(^|:)$new_port$" || \ - die "sshd 没有监听新端口 $new_port" - -public_ip="$(curl -4 -fsSL --max-time 10 https://api.ipify.org 2>/dev/null || true)" -[[ -n "$public_ip" ]] || public_ip="<服务器IP>" - -echo -echo "SSH 密钥入口已建立。当前会话确认完成前不要关闭。" -if [[ "$current_port" != "$new_port" ]]; then - echo "旧端口 $current_port 暂时保留,但只允许 $admin_user 使用密钥登录。" -fi -echo "请先确认云防火墙已放行 TCP $new_port。" -echo -echo "========== 私钥开始(只显示这一次) ==========" -cat "$temp_dir/id_ed25519" -echo "========== 私钥结束 ==========" -echo -echo "保存到本机 ~/.ssh/jp_proxy 后执行:" -echo " chmod 600 ~/.ssh/jp_proxy" -echo " ssh -p $new_port -i ~/.ssh/jp_proxy $admin_user@$public_ip" -echo -echo "新连接成功后,在新连接中关闭旧 SSH 端口:" -echo " sudo $finalize_script" -echo -echo "对应的 ~/.ssh/config:" -echo "Host jp-proxy" -echo " HostName $public_ip" -echo " User $admin_user" -echo " Port $new_port" -echo " IdentityFile ~/.ssh/jp_proxy" -echo " IdentitiesOnly yes" diff --git a/docker-compose.yaml b/docker-compose.yaml index 8206ef9..fe1f761 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,58 +1,23 @@ services: - app: - image: ghcr.io/ding113/claude-code-hub:latest - profiles: ["hub"] - environment: - NODE_ENV: production - # 容器内使用 Dockerfile 默认端口 3000,对外通过 APP_PORT 暴露(默认 23000) - # 包装脚本按外部字段是否为空,选择内部存储 overlays。 - DSN: ${EXTERNAL_POSTGRES_DSN:-postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-claude_code_hub}} - REDIS_URL: ${EXTERNAL_REDIS_URL:-redis://dragonfly:6379} - AUTO_MIGRATE: ${AUTO_MIGRATE:-true} - ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me} - APP_URL: ${APP_URL:-} - API_TEST_TIMEOUT_MS: ${API_TEST_TIMEOUT_MS:-15000} - ENABLE_SECURE_COOKIES: ${ENABLE_SECURE_COOKIES:-true} - ENABLE_RATE_LIMIT: ${ENABLE_RATE_LIMIT:-true} - SESSION_TTL: ${SESSION_TTL:-300} - STORE_SESSION_MESSAGES: ${STORE_SESSION_MESSAGES:-false} - ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS: ${ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS:-false} - MAX_RETRY_ATTEMPTS_DEFAULT: ${MAX_RETRY_ATTEMPTS_DEFAULT:-2} - ENABLE_SMART_PROBING: ${ENABLE_SMART_PROBING:-false} - PROBE_INTERVAL_MS: ${PROBE_INTERVAL_MS:-30000} - PROBE_TIMEOUT_MS: ${PROBE_TIMEOUT_MS:-5000} - ENABLE_MULTI_PROVIDER_TYPES: ${ENABLE_MULTI_PROVIDER_TYPES:-false} - # 设置时区为上海 - TZ: Asia/Shanghai - ports: - - "${APP_PORT:-23000}:3000" - restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3000/api/actions/health || exit 1"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 30s - cli-proxy-api: - # 默认只拉取 GHCR;本地源码构建由 compose.build.yaml 按需追加。 - image: ${CLI_PROXY_IMAGE:-ghcr.io/pluxeljs/proxy-llm-api:latest} + new-api: + image: ${NEW_API_IMAGE:-docker.io/calciumion/new-api:v0.13.2} restart: unless-stopped - # Docker 下由 scripts/compose 注入宿主 UID/GID;rootless Podman 使用 - # 容器 root(映射为当前宿主用户)。这样 OAuth 和日志不会变成 root 所有。 - user: "${CLIPROXY_RUN_UID:-0}:${CLIPROXY_RUN_GID:-0}" + user: ${NEW_API_CONTAINER_USER:-0:0} + environment: + TZ: ${TZ:-Asia/Taipei} + SESSION_SECRET: ${NEW_API_SESSION_SECRET:?Run new-api-runtime init first} + SQLITE_PATH: /data/new-api.db + ERROR_LOG_ENABLED: "true" + GIN_MODE: release ports: - - "${CLIPROXY_BIND_ADDRESS:-127.0.0.1}:${CLIPROXY_PORT:-8317}:8317" - # OAuth browser callbacks. Device-code flows do not need a callback port. - - "${CLIPROXY_CALLBACK_BIND_ADDRESS:-127.0.0.1}:1455:1455" - - "${CLIPROXY_CALLBACK_BIND_ADDRESS:-127.0.0.1}:54545:54545" - - "${CLIPROXY_CALLBACK_BIND_ADDRESS:-127.0.0.1}:51121:51121" + - "${NEW_API_BIND_ADDRESS:-127.0.0.1}:${NEW_API_PORT:-23000}:3000" volumes: - - ${CLIPROXY_CONFIG_PATH:-./cliproxyapi/config.yaml}:/CLIProxyAPI/config.yaml:ro - - ${CLIPROXY_AUTH_PATH:-./cliproxyapi/oa}:/data/auth - - ${CLIPROXY_LOG_PATH:-./cliproxyapi/logs}:/CLIProxyAPI/logs - - ${CLIPROXY_PLUGIN_PATH:-./cliproxyapi/plugins}:/CLIProxyAPI/plugins + - ${NEW_API_DATA_DIR:?Run new-api-runtime init first}:/data + logging: + options: + max-size: "16m" healthcheck: - test: ["CMD", "curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8317/healthz"] + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/api/status || exit 1"] interval: 30s timeout: 5s retries: 3 diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..4815d18 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,32 @@ +# 从 Hub / CLIProxyAPI 迁移 + +目标链路是「客户端 → New API → 上游」,一个 New API 容器和一个本地 SQLite +数据库,不需要 PostgreSQL、Dragonfly、CLIProxyAPI 或 sing-box。 + +1. 在旧版本仍可运行时,保存 `.env`、CLIProxyAPI 配置、OAuth 数据以及 Hub 数据库 + 的一致备份。记录旧镜像版本和 Compose 项目名。备份必须留在私有目录。 +2. 导出渠道 origin、上游密钥、模型列表和需要保留的客户端令牌。OAuth 订阅凭据不能 + 直接当作 New API 的普通 API 渠道密钥;这类用户必须先确认可用的 API 上游。 +3. 初始化新状态,在 `.env` 将 `NEW_API_PORT` 暂设为 23002,启动 New API 并完成 + Web 设置。添加实际渠道、模型、价格,开启消费日志和统计。 +4. 用最小模型请求验证鉴权、流式完成、输入/输出 token、失败日志和重启后的记录。 + New API v0.13.2 的常规管理 API 生成随机客户端令牌,不支持直接指定旧令牌。 + 通用迁移应更新客户端令牌;需要保留旧令牌时,必须另做针对实际数据库结构的 + 离线迁移与鉴权验证,不应直接执行未经验证的 SQL 模板。 +5. 停止旧入口及其自动启动服务,确认 23000 已释放,将新端口改为 23000 并重启。 + 检查实际客户端请求和 Web 消费记录。 +6. 确认新入口稳定后,删除旧网关容器和旧服务定义。PostgreSQL / Dragonfly 如仍被 + 其他开发项目使用,应保留;否则停止并移除相关容器。旧数据先归档,不使用 + `down -v`、全局 `prune` 或不加区分的 `--remove-orphans`。 + +在 Home Manager 配置中移除旧 `services.proxyLlm`,改用 +`services.newApiRuntime`;已有 dev-runtime 则只启用它的 `new-api` 目标,不再创建 +第二套 systemd 网关服务。旧版本曾手工创建的 +`default.target.wants/proxy-llm.service` 链接也应在切换时停用。 + +Hub 的 PostgreSQL 历史统计不会自动导入 SQLite。新统计从迁移后的请求开始; +如果需要审计旧统计,应单独保存原数据库或导出报表。 + +回退时先停止新入口,使用旧版本配置与旧数据库重新启动旧栈;不要将两个入口同时 +绑定到 23000。此版本不再构建 CLIProxyAPI 镜像,也不把旧镜像的 `latest` 标签 +替换为不兼容的 New API 镜像。 diff --git a/flake.nix b/flake.nix index bc2b506..f83cf6e 100644 --- a/flake.nix +++ b/flake.nix @@ -1,124 +1,36 @@ { - description = "Proxy-LLM-API package and Home Manager module"; - + description = "New API runtime: one container, SQLite accounting, no external database"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - - outputs = - { self, nixpkgs }: + outputs = { self, nixpkgs }: let - systems = [ - "x86_64-linux" - "aarch64-linux" - ]; - forAllSystems = nixpkgs.lib.genAttrs systems; - in - { - packages = forAllSystems ( - system: - let - pkgs = import nixpkgs { inherit system; }; - in - rec { - proxy-llm = pkgs.callPackage ./nix/package.nix { }; - default = proxy-llm; - } - ); - + forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ]; + in { + packages = forAllSystems (system: + let pkgs = import nixpkgs { inherit system; }; + in rec { + new-api-runtime = pkgs.callPackage ./nix/package.nix { }; + default = new-api-runtime; + }); apps = forAllSystems (system: { default = { type = "app"; - program = "${self.packages.${system}.default}/bin/proxy-llm"; + program = "${self.packages.${system}.default}/bin/new-api-runtime"; }; }); - - checks = forAllSystems ( - system: - let - pkgs = import nixpkgs { inherit system; }; + checks = forAllSystems (system: + let pkgs = import nixpkgs { inherit system; }; + in { package = self.packages.${system}.default; - in - { - inherit package; - state-directory = pkgs.runCommand "proxy-llm-state-directory-check" { } '' - mkdir -p "$TMPDIR/fake-bin" "$TMPDIR/home" "$out" - printf '%s\n' '#!${pkgs.runtimeShell}' 'exit 0' > "$TMPDIR/fake-bin/podman" - chmod +x "$TMPDIR/fake-bin/podman" - printf '%s\n' \ - '#!${pkgs.runtimeShell}' \ - 'case "$*" in *is-active*) exit 1 ;; *) exit 0 ;; esac' \ - > "$TMPDIR/fake-bin/systemctl" - chmod +x "$TMPDIR/fake-bin/systemctl" - - export PATH="$TMPDIR/fake-bin:$PATH" - export HOME="$TMPDIR/home" - export XDG_STATE_HOME="$TMPDIR/state home" - ${package}/bin/proxy-llm init --no-show-secrets >/dev/null - - state="$XDG_STATE_HOME/proxy-llm" - test -f "$state/.env" - test -f "$state/cliproxyapi/config.yaml" - test "$(stat -c %a "$state/.env")" = 600 - test "$(stat -c %a "$state/cliproxyapi/config.yaml")" = 600 - test ! -e ${self}/.env - - legacy="$TMPDIR/legacy-checkout" - migrated="$TMPDIR/migrated-state" - mkdir -p "$legacy/cliproxyapi/oa" - cp ${self}/.env.example "$legacy/.env" - cp ${self}/cliproxyapi/config.example.yaml "$legacy/cliproxyapi/config.yaml" - touch "$legacy/cliproxyapi/oa/account.json" - PROXY_LLM_STATE_DIR="$migrated" \ - ${package}/bin/proxy-llm migrate "$legacy" >/dev/null - test -f "$migrated/.env" - test -f "$migrated/cliproxyapi/config.yaml" - test -f "$migrated/cliproxyapi/oa/account.json" - test "$(cat "$migrated/.migrated-from")" = "$legacy" - PROXY_LLM_STATE_DIR="$migrated" \ - ${package}/bin/proxy-llm migrate "$legacy" >/dev/null - - cutover_legacy="$TMPDIR/cutover-legacy" - cutover_state="$TMPDIR/cutover-state" - mkdir -p "$cutover_legacy/cliproxyapi" - cp -r ${self}/scripts "$cutover_legacy/scripts" - cp ${self}/manage.sh ${self}/.env.example \ - ${self}/docker-compose.yaml ${self}/compose.*.yaml \ - "$cutover_legacy/" - cp ${self}/.env.example "$cutover_legacy/.env" - sed -i '/^COMPOSE_PROJECT_NAME=/d' "$cutover_legacy/.env" - cp ${self}/cliproxyapi/config.example.yaml \ - "$cutover_legacy/cliproxyapi/config.yaml" - chmod -R u+w "$cutover_legacy" - chmod +x "$cutover_legacy/manage.sh" "$cutover_legacy/scripts/"* - for script in "$cutover_legacy/manage.sh" "$cutover_legacy/scripts/"*; do - if head -n 1 "$script" | grep -q '/usr/bin/env bash'; then - sed -i '1c #!${pkgs.bash}/bin/bash' "$script" - fi - done - PROXY_LLM_STATE_DIR="$cutover_state" \ - ${package}/bin/proxy-llm cutover "$cutover_legacy" >/dev/null - test -f "$cutover_state/.migrated-from" - grep -qxF 'COMPOSE_PROJECT_NAME=cutover-legacy' "$cutover_state/.env" - touch "$out/passed" - ''; - shell-syntax = pkgs.runCommand "proxy-llm-shell-syntax-check" { } '' - ${pkgs.bash}/bin/bash -n \ - ${self}/manage.sh \ - ${self}/scripts/build-cliproxy \ - ${self}/scripts/cliproxy-login \ - ${self}/scripts/compose \ - ${self}/scripts/cutover \ - ${self}/scripts/healthcheck \ - ${self}/scripts/init \ - ${self}/scripts/migrate-state \ - ${self}/scripts/runtime \ - ${self}/scripts/service-exec \ - ${self}/scripts/verify-proxy \ - ${self}/nix/proxy-llm-wrapper + runtime = pkgs.runCommand "new-api-runtime-tests" { + nativeBuildInputs = [ pkgs.python3 ]; + } '' + cp -r ${self} source + chmod -R u+w source + cd source + python3 -m unittest discover -s tests -v touch "$out" ''; - } - ); - + }); homeManagerModules.default = import ./nix/home-manager.nix; formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.nixfmt-tree); }; diff --git a/manage.sh b/manage.sh index e6f8d3c..9cbfa17 100755 --- a/manage.sh +++ b/manage.sh @@ -1,113 +1,3 @@ #!/usr/bin/env bash set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$repo_root/scripts/runtime" -compose="$repo_root/scripts/compose" -config_file="$(state_path CLIPROXY_CONFIG_PATH cliproxyapi/config.yaml)" - -require_initialized() { - if [[ ! -f "$env_file" || ! -f "$config_file" ]]; then - echo "尚未初始化,请先执行: proxy-llm init(Git checkout 也可使用 ./manage.sh init)" >&2 - exit 1 - fi - if grep -qE '^(ADMIN_TOKEN=change-me|DB_PASSWORD=(postgres|your-secure-password_change-me))$' \ - "$env_file" || \ - grep -q 'change-this-api-key' "$config_file"; then - echo "检测到占位凭证,请重新执行 init 自动替换。" >&2 - exit 1 - fi -} - -usage() { - cat <<'EOF' -用法: ./manage.sh <命令> [参数] - - up 生成所需配置并启动完整服务,清理旧 orphan - init 首次初始化配置并生成强随机凭证(不覆盖已有值) - migrate <目录> 无覆盖迁移旧 checkout 的本机状态 - cutover <目录> 停旧栈、迁移、验证新栈,失败时自动回滚 - down 停止当前配置选择的服务 - restart 强制重建并重启当前配置选择的服务 - update 拉取镜像并重新应用当前配置 - status 查看容器状态并执行真实健康检查 - check 执行真实健康检查 - logs [服务] 跟踪全部或指定服务日志 - pull 拉取当前配置选择的镜像 - config 列出当前配置实际启用的服务(不输出秘密) - secrets 显示 Hub Token 与 CLIProxyAPI API key - proxy-test 验证 CLIProxyAPI 是否实际通过 sing-box 出站 - login ... 调用 CLIProxyAPI 登录助手 - build 从上游源码构建 CLIProxyAPI 镜像 -EOF -} - -command="${1:-status}" -shift || true - -case "$command" in - init) - exec "$repo_root/scripts/init" "$@" - ;; - migrate) - exec "$repo_root/scripts/migrate-state" "$@" - ;; - cutover) - exec "$repo_root/scripts/cutover" "$@" - ;; - up) - require_initialized - "$compose" up -d --remove-orphans "$@" - exec "$repo_root/scripts/healthcheck" --wait - ;; - restart) - require_initialized - "$compose" up -d --force-recreate --remove-orphans "$@" - exec "$repo_root/scripts/healthcheck" --wait - ;; - update) - require_initialized - "$compose" pull "$@" - "$compose" up -d --remove-orphans "$@" - exec "$repo_root/scripts/healthcheck" --wait - ;; - down) - exec "$compose" down --remove-orphans "$@" - ;; - status|ps) - "$compose" ps "$@" - exec "$repo_root/scripts/healthcheck" - ;; - check) - exec "$repo_root/scripts/healthcheck" "$@" - ;; - logs) - exec "$compose" logs -f --tail=200 "$@" - ;; - pull) - exec "$compose" pull "$@" - ;; - config) - exec "$compose" config --services "$@" - ;; - secrets) - exec "$repo_root/scripts/init" --show "$@" - ;; - proxy-test) - exec "$repo_root/scripts/verify-proxy" "$@" - ;; - login) - require_initialized - exec "$repo_root/scripts/cliproxy-login" "$@" - ;; - build) - exec "$repo_root/scripts/build-cliproxy" "$@" - ;; - help|-h|--help) - usage - ;; - *) - usage >&2 - exit 2 - ;; -esac +exec python3 "$(dirname "$(realpath "${BASH_SOURCE[0]}")")/scripts/runtime.py" "$@" diff --git a/nix/home-manager.nix b/nix/home-manager.nix index d6141ff..aebf5e3 100644 --- a/nix/home-manager.nix +++ b/nix/home-manager.nix @@ -1,152 +1,51 @@ -{ - config, - lib, - pkgs, - ... -}: +{ config, lib, pkgs, ... }: let - cfg = config.services.proxyLlm; + cfg = config.services.newApiRuntime; command = lib.getExe cfg.package; - escapedStateDir = lib.escapeShellArg cfg.stateDir; - escapedLegacyStateDir = lib.escapeShellArg ( - if cfg.legacyStateDir == null then "" else cfg.legacyStateDir - ); in { - options.services.proxyLlm = { - enable = lib.mkEnableOption "Proxy-LLM-API compose stack"; - + options.services.newApiRuntime = { + enable = lib.mkEnableOption "standalone New API SQLite runtime"; package = lib.mkOption { type = lib.types.package; default = pkgs.callPackage ./package.nix { }; - defaultText = lib.literalExpression "pkgs.callPackage ./nix/package.nix { }"; - description = "Proxy-LLM-API helper package to run."; + description = "Runtime helper package."; }; - stateDir = lib.mkOption { type = lib.types.str; - default = "${config.xdg.stateHome}/proxy-llm"; - description = "Writable local configuration, credentials, and bind-mounted runtime state."; + default = "${config.xdg.stateHome}/new-api-runtime"; + description = "Private writable configuration and SQLite data; never put secrets in Nix options."; }; - autoStart = lib.mkOption { type = lib.types.bool; default = true; - description = "Add proxy-llm.service to default.target and queue its first start asynchronously."; - }; - - initialize = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Create missing local configuration and random credentials without printing secrets."; - }; - - legacyStateDir = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Optional old checkout state to migrate once without overwriting the target."; + description = "Start the standalone runtime at login. Leave this module disabled when dev-runtime owns the gateway."; }; }; - - config = lib.mkMerge [ - (lib.mkIf cfg.enable { - assertions = [ - { - assertion = lib.hasPrefix "/" cfg.stateDir; - message = "services.proxyLlm.stateDir must be an absolute path"; - } - { - assertion = cfg.legacyStateDir == null || lib.hasPrefix "/" cfg.legacyStateDir; - message = "services.proxyLlm.legacyStateDir must be null or an absolute path"; - } - ]; - - home.packages = [ cfg.package ]; - - systemd.user.services.proxy-llm = { - Unit = { - Description = "Proxy-LLM-API compose stack"; - Wants = [ "podman.socket" ]; - After = [ "podman.socket" ]; - # Keep a healthy running stack across Home Manager switches. A - # deliberate service restart (or the next login) adopts new code. - X-SwitchMethod = "keep-old"; - }; - Service = { - Type = "oneshot"; - RemainAfterExit = true; - Environment = [ "PROXY_LLM_STATE_DIR=${cfg.stateDir}" ]; - ExecStartPre = lib.optional cfg.initialize "${command} init --no-show-secrets"; - ExecStart = "${command} up"; - ExecStop = "${command} down"; - TimeoutStartSec = 900; - TimeoutStopSec = 120; - }; - # The activation below owns this enable symlink so first start can be - # queued with --no-block after Home Manager finishes sd-switch. - Install.WantedBy = [ ]; + config = lib.mkIf cfg.enable { + assertions = [{ + assertion = lib.hasPrefix "/" cfg.stateDir; + message = "services.newApiRuntime.stateDir must be absolute"; + }]; + home.packages = [ cfg.package ]; + systemd.user.services.new-api-runtime = { + Unit = { + Description = "New API with SQLite"; + Wants = [ "podman.socket" ]; + After = [ "podman.socket" ]; + X-SwitchMethod = "keep-old"; + }; + Service = { + Type = "oneshot"; + RemainAfterExit = true; + Environment = [ "NEW_API_STATE_DIR=${cfg.stateDir}" ]; + ExecStartPre = "${command} init"; + ExecStart = "${command} up"; + ExecStop = "${command} down"; + TimeoutStartSec = 900; + TimeoutStopSec = 120; }; - - home.activation.prepareProxyLlmUnit = lib.hm.dag.entryBefore [ "checkLinkTargets" ] '' - ${lib.optionalString (cfg.legacyStateDir != null) '' - marker=${lib.escapeShellArg "${cfg.stateDir}/.migrated-from"} - if [ ! -f "$marker" ] || [ "$(${lib.getExe' pkgs.coreutils "cat"} "$marker")" != ${escapedLegacyStateDir} ]; then - echo "Proxy-LLM-API 旧状态尚未安全切换。" >&2 - echo "请先执行: PROXY_LLM_STATE_DIR=${escapedStateDir} ${command} cutover ${escapedLegacyStateDir}" >&2 - exit 1 - fi - ''} - # Retire symlinks created by an older generation before Home Manager - # installs the new unit. `disable` without `--now` never stops the - # currently loaded service, which sd-switch keeps active below. - if command -v systemctl >/dev/null 2>&1; then - systemctl --user disable proxy-llm.service >/dev/null 2>&1 || true - fi - ''; - - home.activation.enableProxyLlmPodmanSocket = lib.hm.dag.entryAfter [ "reloadSystemd" ] '' - if command -v systemctl >/dev/null 2>&1; then - systemctl --user daemon-reload - systemctl --user enable --now podman.socket - ${ - if cfg.autoStart then - '' - # The unit deliberately has no [Install] target: declaring - # WantedBy in Home Manager would make sd-switch wait for the - # first, potentially image-pulling start. add-wants establishes - # the same boot relationship without starting it synchronously. - systemctl --user add-wants default.target proxy-llm.service - if ! systemctl --user is-active --quiet proxy-llm.service; then - systemctl --user start --no-block proxy-llm.service - fi - '' - else - "" - } - else - echo "Proxy-LLM-API requires a systemd user session." >&2 - exit 1 - fi - ''; - }) - - (lib.mkIf (!cfg.enable) { - # Stop only a unit previously materialized from the Nix store. A regular - # user-owned unit with the same name remains untouched. - home.activation.retireProxyLlm = lib.hm.dag.entryBefore [ "checkLinkTargets" ] '' - unit="$HOME/.config/systemd/user/proxy-llm.service" - if [ -L "$unit" ]; then - resolved="$(${lib.getExe' pkgs.coreutils "readlink"} -f "$unit" 2>/dev/null || true)" - case "$resolved" in - /nix/store/*) - if command -v systemctl >/dev/null 2>&1; then - systemctl --user disable --now proxy-llm.service >/dev/null 2>&1 || true - systemctl --user reset-failed proxy-llm.service >/dev/null 2>&1 || true - fi - ;; - esac - fi - ''; - }) - ]; + Install.WantedBy = lib.optional cfg.autoStart "default.target"; + }; + }; } diff --git a/nix/package.nix b/nix/package.nix index 1d2c1c1..58b0409 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,83 +1,27 @@ -{ - lib, - stdenvNoCC, - makeWrapper, - bash, - coreutils, - curl, - findutils, - gawk, - git, - gnugrep, - gnused, - openssl, - python3, -}: -let - runtimeInputs = [ - bash - coreutils - curl - findutils - gawk - git - gnugrep - gnused - openssl - python3 - ]; -in +{ lib, stdenvNoCC, makeWrapper, python3, bash, coreutils, podman-compose }: stdenvNoCC.mkDerivation { - pname = "proxy-llm"; - version = "0-unstable-2026-08-09"; - # Use an allowlist so a direct callPackage can never copy ignored runtime - # credentials or databases from a mutable checkout into the Nix store. + pname = "new-api-runtime"; + version = "1.0.0"; + # Explicit allowlist: credentials and SQLite files cannot enter the Nix store. src = lib.fileset.toSource { root = ../.; - fileset = lib.fileset.unions [ - ../.env.example - ../compose.build.yaml - ../compose.cloudflare-tunnel.yaml - ../compose.internal-dragonfly.yaml - ../compose.internal-postgres.yaml - ../compose.podman.yaml - ../compose.singbox.yaml - ../docker-compose.yaml - ../manage.sh - ../scripts - ../cliproxyapi/Dockerfile - ../cliproxyapi/config.example.yaml - ./proxy-llm-wrapper - ]; + fileset = lib.fileset.unions [ ../docker-compose.yaml ../scripts/runtime.py ]; }; - nativeBuildInputs = [ makeWrapper ]; - installPhase = '' - runHook preInstall - - resourceRoot="$out/share/proxy-llm" - mkdir -p "$resourceRoot/cliproxyapi" "$resourceRoot/scripts" "$out/bin" - - install -m755 manage.sh "$resourceRoot/manage.sh" - install -m755 scripts/* "$resourceRoot/scripts/" - install -m644 .env.example docker-compose.yaml compose.*.yaml "$resourceRoot/" - install -m644 cliproxyapi/Dockerfile cliproxyapi/config.example.yaml \ - "$resourceRoot/cliproxyapi/" - - install -m755 nix/proxy-llm-wrapper "$out/bin/proxy-llm" - substituteInPlace "$out/bin/proxy-llm" \ - --replace-fail '@resourceRoot@' "$resourceRoot" - wrapProgram "$out/bin/proxy-llm" \ - --prefix PATH : ${lib.makeBinPath runtimeInputs} - - runHook postInstall + resourceRoot="$out/share/new-api-runtime" + mkdir -p "$resourceRoot/scripts" "$out/bin" + install -m644 docker-compose.yaml "$resourceRoot/" + install -m644 scripts/runtime.py "$resourceRoot/scripts/" + makeWrapper ${python3}/bin/python3 "$out/bin/new-api-runtime" \ + --add-flags "$resourceRoot/scripts/runtime.py" \ + --set PODMAN_COMPOSE_PROVIDER ${lib.getExe podman-compose} \ + --prefix PATH : ${lib.makeBinPath [ bash coreutils ]} ''; - meta = { - description = "Compose lifecycle helper for Proxy-LLM-API"; + description = "New API single-service runtime with persistent SQLite accounting"; homepage = "https://github.com/PluxelJS/Proxy-LLM-API"; - mainProgram = "proxy-llm"; + mainProgram = "new-api-runtime"; platforms = lib.platforms.linux; }; } diff --git a/nix/proxy-llm-wrapper b/nix/proxy-llm-wrapper deleted file mode 100644 index c8a128d..0000000 --- a/nix/proxy-llm-wrapper +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ -z "${PROXY_LLM_STATE_DIR:-}" ]]; then - : "${HOME:?HOME must be set when PROXY_LLM_STATE_DIR is unset}" - export PROXY_LLM_STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/proxy-llm" -fi - -exec @resourceRoot@/manage.sh "$@" diff --git a/scripts/build-cliproxy b/scripts/build-cliproxy deleted file mode 100755 index 6b35f0c..0000000 --- a/scripts/build-cliproxy +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -upstream_repository="${CLIPROXY_REPOSITORY:-https://github.com/router-for-me/CLIProxyAPI.git}" -requested_ref="${CLIPROXY_REF:-main}" - -if [[ "$requested_ref" == "main" ]]; then - resolved_ref="$(git ls-remote "$upstream_repository" refs/heads/main | awk 'NR == 1 {print $1}')" - if [[ -z "$resolved_ref" ]]; then - echo "无法解析 CLIProxyAPI main 分支提交。" >&2 - exit 1 - fi -else - resolved_ref="$requested_ref" -fi - -short_ref="${resolved_ref:0:12}" -build_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - -echo "Building CLIProxyAPI from ${upstream_repository}@${resolved_ref}" - -cd "$repo_root" -CLIPROXY_REPOSITORY="$upstream_repository" \ -CLIPROXY_REF="$resolved_ref" \ -CLIPROXY_VERSION="main-${short_ref}" \ -CLIPROXY_COMMIT="$resolved_ref" \ -CLIPROXY_BUILD_DATE="$build_date" \ -"$repo_root/scripts/compose" \ - -f "$repo_root/compose.build.yaml" build --pull cli-proxy-api diff --git a/scripts/cliproxy-login b/scripts/cliproxy-login deleted file mode 100755 index 7dff29b..0000000 --- a/scripts/cliproxy-login +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "$repo_root/scripts/runtime" -config_file="$(state_path CLIPROXY_CONFIG_PATH cliproxyapi/config.yaml)" - -provider="${1:-}" -if [[ -z "$provider" ]]; then - cat >&2 <<'EOF' -用法: ./scripts/cliproxy-login [额外参数] - -provider: - codex-device Codex device-code 登录(无回调端口,推荐远程服务器使用) - codex Codex OAuth 登录(回调端口 1455) - claude Claude OAuth 登录(回调端口 54545) - antigravity Antigravity OAuth 登录(回调端口 51121) - kimi Kimi device-code 登录 - xai xAI device-code 登录 -EOF - exit 2 -fi -shift - -case "$provider" in - codex-device) login_flag="-codex-device-login" ;; - codex) login_flag="-codex-login" ;; - claude) login_flag="-claude-login" ;; - antigravity) login_flag="-antigravity-login" ;; - kimi) login_flag="-kimi-login" ;; - xai) login_flag="-xai-login" ;; - *) - echo "不支持的 provider: $provider" >&2 - exit 2 - ;; -esac - -if [[ ! -f "$config_file" ]]; then - echo "缺少 $config_file;请先执行 init。" >&2 - exit 1 -fi - -compose=("$repo_root/scripts/compose") - -container_id="$("${compose[@]}" ps -q cli-proxy-api 2>/dev/null || true)" -running=false -if [[ -n "$container_id" ]]; then - if command -v docker >/dev/null 2>&1 && running="$(docker inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null)"; then - : - elif command -v podman >/dev/null 2>&1 && running="$(podman inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null)"; then - : - fi -fi - -if [[ "$running" == "true" ]]; then - exec "$repo_root/scripts/service-exec" -i cli-proxy-api \ - /CLIProxyAPI/CLIProxyAPI -config /CLIProxyAPI/config.yaml -no-browser "$login_flag" "$@" -fi - -exec "${compose[@]}" run --rm --service-ports cli-proxy-api \ - /CLIProxyAPI/CLIProxyAPI -config /CLIProxyAPI/config.yaml -no-browser "$login_flag" "$@" diff --git a/scripts/compose b/scripts/compose deleted file mode 100755 index 181d257..0000000 --- a/scripts/compose +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "$repo_root/scripts/runtime" - -compose_args=() -if [[ -f "$env_file" ]]; then - compose_args+=(--env-file "$env_file") -fi -compose_args+=(-f "$repo_root/docker-compose.yaml") - -# Compose resolves relative bind paths against the immutable compose source. -# Export absolute state paths so both Docker and Podman use identical mounts. -CLIPROXY_CONFIG_PATH="$(state_path CLIPROXY_CONFIG_PATH cliproxyapi/config.yaml)" -CLIPROXY_AUTH_PATH="$(state_path CLIPROXY_AUTH_PATH cliproxyapi/oa)" -CLIPROXY_LOG_PATH="$(state_path CLIPROXY_LOG_PATH cliproxyapi/logs)" -CLIPROXY_PLUGIN_PATH="$(state_path CLIPROXY_PLUGIN_PATH cliproxyapi/plugins)" -export CLIPROXY_CONFIG_PATH CLIPROXY_AUTH_PATH CLIPROXY_LOG_PATH CLIPROXY_PLUGIN_PATH - -requires_tunnel_token=false -for argument in "$@"; do - case "$argument" in - up|run|create) - requires_tunnel_token=true - ;; - esac -done - -deploy_mode="$(dotenv_value DEPLOY_MODE)" -deploy_mode="${deploy_mode:-full}" -case "$deploy_mode" in - full) - compose_args+=(--profile hub) - ;; - cliproxy) - cloudflare_tunnel_token="$(dotenv_value CF_TUNNEL_TOKEN)" - if $requires_tunnel_token; then - case "$cloudflare_tunnel_token" in - ""|change-me|replace-with-*) - echo "DEPLOY_MODE=cliproxy 必须在 .env 设置 CF_TUNNEL_TOKEN。" >&2 - exit 2 - ;; - esac - fi - compose_args+=( - -f "$repo_root/compose.cloudflare-tunnel.yaml" - --profile cloudflare-tunnel - ) - ;; - *) - echo "DEPLOY_MODE 只支持 full 或 cliproxy,当前值: $deploy_mode" >&2 - exit 2 - ;; -esac - -# Releases before the named-volume layout stored state in ./data. Preserve a -# non-empty legacy directory automatically; fresh clones use engine-managed -# volumes and therefore do not inherit container UID/GID ownership problems. -use_legacy_data_if_present() { - local variable="$1" relative_path="$2" - [[ -z "$(dotenv_value "$variable")" ]] || return 0 - [[ -e "$state_dir/$relative_path" ]] || return 0 - if [[ ! -d "$state_dir/$relative_path" || ! -r "$state_dir/$relative_path" || \ - ! -x "$state_dir/$relative_path" ]]; then - echo "旧版状态目录无法安全读取: $state_dir/$relative_path" >&2 - exit 1 - fi - if [[ -n "$(find "$state_dir/$relative_path" -mindepth 1 -print -quit)" ]]; then - printf -v "$variable" '%s/%s' "$state_dir" "$relative_path" - export "${variable?}" - fi -} - -use_legacy_data_if_present SINGBOX_DATA_SOURCE data/sing-box - -if [[ "$deploy_mode" == full ]]; then - use_legacy_data_if_present POSTGRES_DATA_SOURCE data/postgres - use_legacy_data_if_present DRAGONFLY_DATA_SOURCE data/dragonfly - - if [[ -z "$(dotenv_value EXTERNAL_POSTGRES_DSN)" ]]; then - compose_args+=(-f "$repo_root/compose.internal-postgres.yaml" --profile internal-postgres) - fi - - if [[ -z "$(dotenv_value EXTERNAL_REDIS_URL)" ]]; then - compose_args+=(-f "$repo_root/compose.internal-dragonfly.yaml" --profile internal-dragonfly) - fi -fi - -for volume_variable in SINGBOX_DATA_SOURCE POSTGRES_DATA_SOURCE DRAGONFLY_DATA_SOURCE; do - resolved_volume_source="$(volume_source "$volume_variable")" - if [[ -n "$resolved_volume_source" ]]; then - printf -v "$volume_variable" '%s' "$resolved_volume_source" - export "${volume_variable?}" - fi -done - -singbox_node_url="$(dotenv_value SINGBOX_NODE_URL)" -singbox_config_path="$(dotenv_value SINGBOX_CONFIG_PATH)" - -if [[ -n "$singbox_node_url" && -n "$singbox_config_path" ]]; then - echo "SINGBOX_NODE_URL 与 SINGBOX_CONFIG_PATH 只能设置一个。" >&2 - exit 2 -fi - -if [[ -n "$singbox_node_url" ]]; then - if ! command -v python3 >/dev/null 2>&1; then - echo "使用 SINGBOX_NODE_URL 自动生成配置需要 python3。" >&2 - exit 127 - fi - SINGBOX_NODE_URL="$singbox_node_url" \ - "$repo_root/scripts/generate-singbox-config" "$state_dir/sing-box/config.json" - resolved_singbox_config="$state_dir/sing-box/config.json" - compose_args+=(-f "$repo_root/compose.singbox.yaml" --profile singbox) -elif [[ -n "$singbox_config_path" ]]; then - if [[ "$singbox_config_path" = /* ]]; then - resolved_singbox_config="$singbox_config_path" - else - resolved_singbox_config="$state_dir/${singbox_config_path#./}" - fi - if [[ ! -f "$resolved_singbox_config" ]]; then - echo "SINGBOX_CONFIG_PATH 指向的文件不存在: $singbox_config_path" >&2 - exit 2 - fi - compose_args+=(-f "$repo_root/compose.singbox.yaml" --profile singbox) -fi - -if [[ -n "${resolved_singbox_config:-}" ]]; then - export SINGBOX_CONFIG_SOURCE="$resolved_singbox_config" - if command -v sha256sum >/dev/null 2>&1; then - SINGBOX_CONFIG_SHA256="$(sha256sum "$resolved_singbox_config" | awk '{print $1}')" - elif command -v shasum >/dev/null 2>&1; then - SINGBOX_CONFIG_SHA256="$(shasum -a 256 "$resolved_singbox_config" | awk '{print $1}')" - else - echo "启用 sing-box 需要 sha256sum 或 shasum。" >&2 - exit 127 - fi - export SINGBOX_CONFIG_SHA256 -fi - -docker_is_podman=false -if command -v docker >/dev/null 2>&1; then - docker_version="$(docker --version 2>&1 || true)" - if grep -qi podman <<<"$docker_version"; then - docker_is_podman=true - fi -fi - -run_with_podman() { - # In rootless Podman, container root maps to the invoking host user. An - # explicit host UID would instead map through the subordinate-ID range. - export CLIPROXY_RUN_UID=0 - export CLIPROXY_RUN_GID=0 - compose_args+=(-f "$repo_root/compose.podman.yaml") - exec podman compose "${compose_args[@]}" "$@" -} - -# podman-docker intentionally exposes a `docker` command, but it must retain -# Podman's rootless UID mapping and compatibility overlay. Do not mistake that -# wrapper for a real Docker daemon. -if $docker_is_podman && \ - command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; then - run_with_podman "$@" -fi - -if command -v docker >/dev/null 2>&1 && \ - docker compose version >/dev/null 2>&1 && \ - docker info >/dev/null 2>&1; then - CLIPROXY_RUN_UID="$(id -u)" - CLIPROXY_RUN_GID="$(id -g)" - export CLIPROXY_RUN_UID CLIPROXY_RUN_GID - exec docker compose "${compose_args[@]}" "$@" -fi - -if command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; then - run_with_podman "$@" -fi - -if command -v docker-compose >/dev/null 2>&1; then - CLIPROXY_RUN_UID="$(id -u)" - CLIPROXY_RUN_GID="$(id -g)" - export CLIPROXY_RUN_UID CLIPROXY_RUN_GID - exec docker-compose "${compose_args[@]}" "$@" -fi - -echo "未找到可用的 Docker Compose 或 Podman Compose。" >&2 -exit 127 diff --git a/scripts/cutover b/scripts/cutover deleted file mode 100755 index 2343220..0000000 --- a/scripts/cutover +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -# shellcheck source=runtime -source "$repo_root/scripts/runtime" -# shellcheck disable=SC2154 # Assigned by scripts/runtime. -state_dir="${state_dir:?}" - -if (($# != 1)); then - echo "用法: proxy-llm cutover <旧 checkout 状态目录>" >&2 - exit 2 -fi - -legacy_dir="$1" -if [[ "$legacy_dir" != /* ]]; then - legacy_dir="$(pwd -P)/${legacy_dir#./}" -fi -if [[ ! -x "$legacy_dir/manage.sh" || ! -f "$legacy_dir/.env" ]]; then - echo "旧 checkout 未初始化或缺少 manage.sh: $legacy_dir" >&2 - exit 1 -fi -legacy_dir="$(cd "$legacy_dir" && pwd -P)" - -if [[ "$legacy_dir" == "$state_dir" ]]; then - echo "旧状态目录与目标相同,无需切换。" >&2 - exit 1 -fi - -# Resolve the actual legacy Compose identity. Older .env files may predate an -# explicit COMPOSE_PROJECT_NAME and therefore inherit the checkout basename. -legacy_container_id="$("$legacy_dir/scripts/compose" ps -q | head -n 1)" -legacy_project_name="" -if [[ -n "$legacy_container_id" ]]; then - if command -v podman >/dev/null 2>&1 && \ - podman inspect "$legacy_container_id" >/dev/null 2>&1; then - legacy_project_name="$(podman inspect -f \ - '{{index .Config.Labels "com.docker.compose.project"}}' \ - "$legacy_container_id")" - elif command -v docker >/dev/null 2>&1 && \ - docker inspect "$legacy_container_id" >/dev/null 2>&1; then - legacy_project_name="$(docker inspect -f \ - '{{index .Config.Labels "com.docker.compose.project"}}' \ - "$legacy_container_id")" - fi -fi -if [[ -z "$legacy_project_name" ]]; then - legacy_project_name="$(basename "$legacy_dir" | tr '[:upper:]' '[:lower:]' | \ - sed -E 's/^[^a-z0-9]+//; s/[^a-z0-9_-]+//g')" -fi -if [[ ! "$legacy_project_name" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then - echo "无法安全解析旧 Compose project name。" >&2 - exit 1 -fi - -# Prove that the new packaged helper recognizes and health-checks the running -# legacy project before any downtime. No rendered environment is printed. -PROXY_LLM_STATE_DIR="$legacy_dir" \ -COMPOSE_PROJECT_NAME="$legacy_project_name" \ - "$repo_root/manage.sh" check - -managed_by_systemd=false -if command -v systemctl >/dev/null 2>&1 && \ - systemctl --user is-active --quiet proxy-llm.service; then - loaded_start="$(systemctl --user show proxy-llm.service -p ExecStart --value)" - case "$loaded_start" in - *"$legacy_dir/manage.sh"*) managed_by_systemd=true ;; - *) - echo "运行中的 proxy-llm.service 不属于指定旧 checkout,拒绝切换。" >&2 - exit 1 - ;; - esac -fi - -restore_legacy() { - PROXY_LLM_STATE_DIR="$state_dir" "$repo_root/manage.sh" down >/dev/null 2>&1 || true - echo "新栈切换失败,正在恢复旧服务..." >&2 - if $managed_by_systemd; then - systemctl --user start proxy-llm.service - else - "$legacy_dir/manage.sh" up - fi -} - -cutover_started=false -cutover_succeeded=false -finish_cutover() { - local original_status="$1" - trap - EXIT INT TERM - if $cutover_started && ! $cutover_succeeded; then - if ! restore_legacy; then - echo "自动恢复旧服务也失败,请立即执行: $legacy_dir/manage.sh up" >&2 - fi - fi - exit "$original_status" -} -trap 'exit 130' INT -trap 'exit 143' TERM -trap 'finish_cutover $?' EXIT - -cutover_started=true -if $managed_by_systemd; then - systemctl --user stop proxy-llm.service -else - "$legacy_dir/manage.sh" down -fi - -if ! PROXY_LLM_STATE_DIR="$state_dir" \ - PROXY_LLM_LEGACY_PROJECT_NAME="$legacy_project_name" \ - "$repo_root/scripts/migrate-state" "$legacy_dir"; then - exit 1 -fi - -if ! PROXY_LLM_STATE_DIR="$state_dir" \ - "$repo_root/manage.sh" init --no-show-secrets; then - exit 1 -fi - -if ! PROXY_LLM_STATE_DIR="$state_dir" "$repo_root/manage.sh" up; then - exit 1 -fi - -cutover_succeeded=true -trap - EXIT INT TERM -echo "切换完成:新状态目录的全部服务已通过健康检查。" diff --git a/scripts/generate-singbox-config b/scripts/generate-singbox-config deleted file mode 100755 index 51b0e24..0000000 --- a/scripts/generate-singbox-config +++ /dev/null @@ -1,409 +0,0 @@ -#!/usr/bin/env python3 -"""Generate a minimal sing-box client config from a standard share link. - -The node URL is read from SINGBOX_NODE_URL so credentials do not appear in the -process argument list. The generated file is a runtime secret and is ignored by -Git. -""" - -from __future__ import annotations - -import base64 -import json -import os -import pathlib -import sys -import tempfile -import urllib.parse -from typing import Any - - -class ConfigError(ValueError): - pass - - -def first(query: dict[str, list[str]], *names: str, default: str = "") -> str: - for name in names: - values = query.get(name) - if values: - return values[0] - return default - - -def boolean(value: str, default: bool = False) -> bool: - if not value: - return default - return value.strip().lower() in {"1", "true", "yes", "on"} - - -def integer(value: Any, field: str) -> int: - try: - result = int(value) - except (TypeError, ValueError) as error: - raise ConfigError(f"{field} must be an integer") from error - if not 1 <= result <= 65535: - raise ConfigError(f"{field} must be between 1 and 65535") - return result - - -def decode_base64(value: str) -> str: - compact = "".join(value.split()) - compact += "=" * (-len(compact) % 4) - try: - return base64.urlsafe_b64decode(compact.encode()).decode() - except (ValueError, UnicodeDecodeError) as error: - raise ConfigError("invalid base64 payload") from error - - -def require_server(parsed: urllib.parse.SplitResult) -> tuple[str, int]: - try: - host = parsed.hostname - port = parsed.port - except ValueError as error: - raise ConfigError("invalid node host or port") from error - if not host or port is None: - raise ConfigError("node link must include host and port") - return host, integer(port, "server port") - - -def add_tls( - outbound: dict[str, Any], - query: dict[str, list[str]], - server: str, - *, - enabled: bool, - reality: bool = False, -) -> None: - if not enabled: - return - tls: dict[str, Any] = { - "enabled": True, - "server_name": first(query, "sni", "serverName", "peer", default=server), - } - if boolean(first(query, "allowInsecure", "insecure")): - tls["insecure"] = True - alpn = first(query, "alpn") - if alpn: - tls["alpn"] = [item for item in alpn.split(",") if item] - fingerprint = first(query, "fp", "fingerprint") - if fingerprint and fingerprint.lower() not in {"none", "disable"}: - tls["utls"] = {"enabled": True, "fingerprint": fingerprint} - if reality: - public_key = first(query, "pbk", "publicKey") - if not public_key: - raise ConfigError("Reality node link is missing pbk/publicKey") - reality_config: dict[str, Any] = {"enabled": True, "public_key": public_key} - short_id = first(query, "sid", "shortId") - if short_id: - reality_config["short_id"] = short_id - tls["reality"] = reality_config - outbound["tls"] = tls - - -def add_transport(outbound: dict[str, Any], query: dict[str, list[str]], network: str) -> None: - network = network.lower() - if network in {"", "tcp", "raw", "none"}: - return - if network in {"ws", "websocket"}: - transport: dict[str, Any] = { - "type": "ws", - "path": first(query, "path", default="/"), - } - host = first(query, "host") - if host: - transport["headers"] = {"Host": host} - outbound["transport"] = transport - return - if network == "grpc": - outbound["transport"] = { - "type": "grpc", - "service_name": first(query, "serviceName", "service_name"), - } - return - if network in {"http", "h2"}: - transport = { - "type": "http", - "path": first(query, "path", default="/"), - } - host = first(query, "host") - if host: - transport["host"] = [host] - outbound["transport"] = transport - return - if network in {"httpupgrade", "http-upgrade"}: - transport = { - "type": "httpupgrade", - "path": first(query, "path", default="/"), - } - host = first(query, "host") - if host: - transport["host"] = host - outbound["transport"] = transport - return - raise ConfigError( - f"unsupported share-link transport '{network}'; use SINGBOX_CONFIG_PATH for a native config" - ) - - -def parse_vless_or_trojan(parsed: urllib.parse.SplitResult, scheme: str) -> dict[str, Any]: - server, port = require_server(parsed) - query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True) - credential = urllib.parse.unquote(parsed.username or "") - if not credential: - raise ConfigError(f"{scheme} link is missing credentials") - outbound: dict[str, Any] = { - "type": scheme, - "tag": "proxy", - "server": server, - "server_port": port, - } - if scheme == "vless": - outbound["uuid"] = credential - flow = first(query, "flow") - if flow: - outbound["flow"] = flow - packet_encoding = first(query, "packetEncoding", "packet_encoding") - if packet_encoding: - outbound["packet_encoding"] = packet_encoding - else: - outbound["password"] = credential - - security = first(query, "security", default="tls" if scheme == "trojan" else "none").lower() - add_tls( - outbound, - query, - server, - enabled=security in {"tls", "reality"}, - reality=security == "reality", - ) - add_transport(outbound, query, first(query, "type", "network", default="tcp")) - return outbound - - -def parse_hysteria2(parsed: urllib.parse.SplitResult) -> dict[str, Any]: - server, port = require_server(parsed) - query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True) - password = urllib.parse.unquote(parsed.username or "") - if parsed.password: - password = f"{password}:{urllib.parse.unquote(parsed.password)}" - if not password: - raise ConfigError("hysteria2 link is missing password") - outbound: dict[str, Any] = { - "type": "hysteria2", - "tag": "proxy", - "server": server, - "server_port": port, - "password": password, - } - obfs_type = first(query, "obfs") - if obfs_type: - outbound["obfs"] = { - "type": obfs_type, - "password": first(query, "obfs-password", "obfs_password"), - } - add_tls(outbound, query, server, enabled=True) - return outbound - - -def parse_tuic(parsed: urllib.parse.SplitResult) -> dict[str, Any]: - server, port = require_server(parsed) - query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True) - uuid = urllib.parse.unquote(parsed.username or "") - password = urllib.parse.unquote(parsed.password or "") - if not uuid or not password: - raise ConfigError("tuic link must contain uuid and password") - outbound: dict[str, Any] = { - "type": "tuic", - "tag": "proxy", - "server": server, - "server_port": port, - "uuid": uuid, - "password": password, - } - congestion = first(query, "congestion_control", "congestion-control") - if congestion: - outbound["congestion_control"] = congestion - udp_mode = first(query, "udp_relay_mode", "udp-relay-mode") - if udp_mode: - outbound["udp_relay_mode"] = udp_mode - if boolean(first(query, "allowInsecure", "insecure")): - query.setdefault("insecure", ["1"]) - add_tls(outbound, query, server, enabled=True) - return outbound - - -def parse_anytls(parsed: urllib.parse.SplitResult) -> dict[str, Any]: - server, port = require_server(parsed) - query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True) - password = urllib.parse.unquote(parsed.username or "") - if parsed.password: - password = f"{password}:{urllib.parse.unquote(parsed.password)}" - if not password: - raise ConfigError("anytls link is missing password") - outbound: dict[str, Any] = { - "type": "anytls", - "tag": "proxy", - "server": server, - "server_port": port, - "password": password, - } - add_tls(outbound, query, server, enabled=True) - return outbound - - -def parse_shadowsocks(raw_url: str) -> dict[str, Any]: - raw = raw_url[len("ss://") :].split("#", 1)[0] - raw, _, raw_query = raw.partition("?") - query = urllib.parse.parse_qs(raw_query, keep_blank_values=True) - - if "@" not in raw: - decoded = decode_base64(urllib.parse.unquote(raw)) - if "@" not in decoded: - raise ConfigError("invalid legacy Shadowsocks link") - raw = decoded - - user_info, server_info = raw.rsplit("@", 1) - user_info = urllib.parse.unquote(user_info) - if ":" not in user_info: - user_info = decode_base64(user_info) - if ":" not in user_info: - raise ConfigError("Shadowsocks link is missing method or password") - method, password = user_info.split(":", 1) - parsed_server = urllib.parse.urlsplit(f"ss://x@{server_info}") - server, port = require_server(parsed_server) - outbound: dict[str, Any] = { - "type": "shadowsocks", - "tag": "proxy", - "server": server, - "server_port": port, - "method": method, - "password": password, - } - plugin = first(query, "plugin") - if plugin: - raise ConfigError( - "Shadowsocks SIP003 plugins are not bundled in the official sing-box image; use SINGBOX_CONFIG_PATH and a suitable image" - ) - return outbound - - -def parse_vmess(raw_url: str) -> dict[str, Any]: - payload = raw_url[len("vmess://") :].split("#", 1)[0] - try: - data = json.loads(decode_base64(payload)) - except json.JSONDecodeError as error: - raise ConfigError("invalid VMess JSON payload") from error - server = str(data.get("add", "")).strip() - if not server: - raise ConfigError("VMess link is missing server") - uuid = str(data.get("id", "")).strip() - if not uuid: - raise ConfigError("VMess link is missing uuid") - outbound: dict[str, Any] = { - "type": "vmess", - "tag": "proxy", - "server": server, - "server_port": integer(data.get("port"), "server port"), - "uuid": uuid, - "security": str(data.get("scy") or "auto"), - "alter_id": int(data.get("aid") or 0), - } - query = {key: [str(value)] for key, value in data.items() if value is not None} - tls_mode = str(data.get("tls") or "").lower() - add_tls(outbound, query, server, enabled=tls_mode in {"tls", "reality"}, reality=tls_mode == "reality") - add_transport(outbound, query, str(data.get("net") or "tcp")) - return outbound - - -def parse_forward_proxy(parsed: urllib.parse.SplitResult, scheme: str) -> dict[str, Any]: - server, port = require_server(parsed) - outbound_type = "socks" if scheme.startswith("socks") else "http" - outbound: dict[str, Any] = { - "type": outbound_type, - "tag": "proxy", - "server": server, - "server_port": port, - } - if outbound_type == "socks": - outbound["version"] = "4" if scheme == "socks4" else "5" - if parsed.username: - outbound["username"] = urllib.parse.unquote(parsed.username) - if parsed.password: - outbound["password"] = urllib.parse.unquote(parsed.password) - if scheme == "https": - query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True) - add_tls(outbound, query, server, enabled=True) - return outbound - - -def parse_node_url(raw_url: str) -> dict[str, Any]: - scheme = raw_url.split(":", 1)[0].lower() - if scheme == "vmess": - return parse_vmess(raw_url) - if scheme == "ss": - return parse_shadowsocks(raw_url) - parsed = urllib.parse.urlsplit(raw_url) - if scheme in {"vless", "trojan"}: - return parse_vless_or_trojan(parsed, scheme) - if scheme in {"hysteria2", "hy2"}: - return parse_hysteria2(parsed) - if scheme == "tuic": - return parse_tuic(parsed) - if scheme == "anytls": - return parse_anytls(parsed) - if scheme in {"http", "https", "socks", "socks4", "socks5", "socks5h"}: - return parse_forward_proxy(parsed, scheme) - raise ConfigError( - f"unsupported node-link scheme '{scheme}'; use SINGBOX_CONFIG_PATH for a native config" - ) - - -def main() -> int: - if len(sys.argv) != 2: - print("usage: generate-singbox-config ", file=sys.stderr) - return 2 - node_url = os.environ.get("SINGBOX_NODE_URL", "").strip() - if not node_url: - print("SINGBOX_NODE_URL is empty", file=sys.stderr) - return 2 - output_path = pathlib.Path(sys.argv[1]) - try: - outbound = parse_node_url(node_url) - except ConfigError as error: - print(f"invalid SINGBOX_NODE_URL: {error}", file=sys.stderr) - return 2 - - config = { - "log": {"level": "info", "timestamp": True}, - "inbounds": [ - { - "type": "mixed", - "tag": "mixed-in", - "listen": "0.0.0.0", - "listen_port": 1080, - } - ], - "outbounds": [outbound, {"type": "direct", "tag": "direct"}], - "route": {"final": "proxy", "auto_detect_interface": True}, - } - rendered = json.dumps(config, ensure_ascii=False, indent=2) + "\n" - output_path.parent.mkdir(parents=True, exist_ok=True) - try: - if output_path.read_text(encoding="utf-8") == rendered: - return 0 - except FileNotFoundError: - pass - fd, temporary_name = tempfile.mkstemp(prefix=".config.", suffix=".json", dir=output_path.parent) - try: - os.fchmod(fd, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as output: - output.write(rendered) - os.replace(temporary_name, output_path) - finally: - if os.path.exists(temporary_name): - os.unlink(temporary_name) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/healthcheck b/scripts/healthcheck deleted file mode 100755 index 83128b7..0000000 --- a/scripts/healthcheck +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -compose="$repo_root/scripts/compose" -service_exec="$repo_root/scripts/service-exec" -wait_mode=false - -if [[ "${1:-}" == "--wait" ]]; then - wait_mode=true - shift -fi - -if (($#)); then - echo "用法: ./manage.sh check" >&2 - exit 2 -fi - -check_once() { - local configured running service result="" failed=0 - configured="$($compose config --services | sort)" - running="$($compose ps --services --status running | sort)" - - while IFS= read -r service; do - [[ -n "$service" ]] || continue - if ! grep -qxF "$service" <<<"$running"; then - result+="FAIL $service (not running)"$'\n' - failed=1 - continue - fi - - case "$service" in - app) - if $service_exec app curl --fail --silent --show-error \ - http://127.0.0.1:3000/api/actions/health /dev/null 2>&1; then - result+="OK app"$'\n' - else - result+="FAIL app HTTP health"$'\n' - failed=1 - fi - ;; - cli-proxy-api) - if $service_exec cli-proxy-api curl --fail --silent --show-error \ - http://127.0.0.1:8317/healthz /dev/null 2>&1; then - result+="OK cli-proxy-api"$'\n' - else - result+="FAIL cli-proxy-api HTTP health"$'\n' - failed=1 - fi - ;; - postgres) - if $service_exec postgres sh -c \ - 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ - /dev/null 2>&1; then - result+="OK postgres"$'\n' - else - result+="FAIL postgres readiness"$'\n' - failed=1 - fi - ;; - dragonfly) - if $service_exec dragonfly /usr/local/bin/healthcheck.sh \ - /dev/null 2>&1; then - result+="OK dragonfly"$'\n' - else - result+="FAIL dragonfly PING"$'\n' - failed=1 - fi - ;; - sing-box) - if $service_exec sing-box sing-box check \ - -c /etc/sing-box/config.json /dev/null 2>&1; then - result+="OK sing-box"$'\n' - else - result+="FAIL sing-box config"$'\n' - failed=1 - fi - ;; - cloudflared) - if $service_exec cloudflared cloudflared tunnel \ - --metrics 127.0.0.1:2000 ready \ - /dev/null 2>&1; then - result+="OK cloudflared tunnel"$'\n' - else - result+="FAIL cloudflared tunnel readiness"$'\n' - failed=1 - fi - ;; - esac - done <<<"$configured" - - printf '%s' "$result" - return "$failed" -} - -if ! $wait_mode; then - check_once - exit -fi - -for _ in {1..30}; do - if output="$(check_once)"; then - printf '%s\n' "$output" - exit 0 - fi - sleep 2 -done - -check_once diff --git a/scripts/init b/scripts/init deleted file mode 100755 index 23588b2..0000000 --- a/scripts/init +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "$repo_root/scripts/runtime" -config_file="$(state_path CLIPROXY_CONFIG_PATH cliproxyapi/config.yaml)" -umask 077 - -generate_secret() { - local bytes="${1:-24}" - if command -v openssl >/dev/null 2>&1; then - openssl rand -hex "$bytes" - else - od -An -N "$bytes" -tx1 /dev/urandom | tr -d ' \n' - fi -} - -dotenv_get() { - local key="$1" - awk -v key="$key" 'index($0, key "=") == 1 {sub(/^[^=]*=/, ""); print; exit}' "$env_file" -} - -dotenv_set() { - local key="$1" value="$2" temporary - temporary="$(mktemp "${env_file}.tmp.XXXXXX")" - awk -v key="$key" -v value="$value" ' - index($0, key "=") == 1 { - if (!written) print key "=" value - written = 1 - next - } - { print } - END { if (!written) print key "=" value } - ' "$env_file" >"$temporary" - chmod 600 "$temporary" - mv "$temporary" "$env_file" -} - -cli_api_key() { - sed -n -E '/^api-keys:/,/^[^[:space:]]/{ - s/^[[:space:]]*-[[:space:]]*"([^"]+)".*/\1/p - }' "$config_file" | head -n 1 -} - -show_secrets() { - [[ -f "$env_file" && -f "$config_file" ]] || { - echo "尚未初始化,请先执行 ./manage.sh init。" >&2 - exit 1 - } - local admin_token api_key - admin_token="$(dotenv_get ADMIN_TOKEN)" - api_key="$(cli_api_key)" - [[ -n "$admin_token" && -n "$api_key" ]] || { - echo "本机凭证配置不完整,请重新执行 ./manage.sh init。" >&2 - exit 1 - } - printf 'Hub ADMIN_TOKEN: %s\n' "$admin_token" - printf 'CLIProxyAPI API key: %s\n' "$api_key" -} - -if [[ "${1:-}" == "--show" ]]; then - shift - (($# == 0)) || { echo "用法: ./manage.sh secrets" >&2; exit 2; } - show_secrets - exit -fi - -show_generated_secrets=true -if [[ "${1:-}" == "--no-show-secrets" ]]; then - show_generated_secrets=false - shift -fi - -(($# == 0)) || { echo "用法: proxy-llm init [--no-show-secrets]" >&2; exit 2; } - -if ! command -v openssl >/dev/null 2>&1 && ! command -v od >/dev/null 2>&1; then - echo "生成凭证需要 openssl 或 od。" >&2 - exit 127 -fi - -created_env=false -created_config=false -changed_secrets=false - -state_existed=false -[[ -d "$state_dir" ]] && state_existed=true -mkdir -p "$state_dir" -if ! $state_existed && [[ "$state_dir" != "$repo_root" ]]; then - chmod 700 "$state_dir" -fi - -if [[ ! -f "$env_file" ]]; then - cp "$repo_root/.env.example" "$env_file" - chmod 600 "$env_file" - created_env=true -fi - -case "$(dotenv_get ADMIN_TOKEN)" in - ""|change-me) - dotenv_set ADMIN_TOKEN "$(generate_secret 24)" - changed_secrets=true - ;; -esac - -case "$(dotenv_get DB_PASSWORD)" in - ""|postgres|your-secure-password_change-me) - dotenv_set DB_PASSWORD "$(generate_secret 24)" - changed_secrets=true - ;; -esac - -if [[ ! -f "$config_file" ]]; then - mkdir -p "$(dirname "$config_file")" - cp "$repo_root/cliproxyapi/config.example.yaml" "$config_file" - chmod 600 "$config_file" - created_config=true -fi - -if grep -q 'change-this-api-key' "$config_file"; then - api_key="$(generate_secret 24)" - sed -i "s/change-this-api-key/$api_key/g" "$config_file" - changed_secrets=true -fi - -# These are deliberately host-managed bind mounts. CLIProxyAPI runs with the -# matching host identity, so files remain accessible without world-writable -# directories or recursive chown. Stateful services use named volumes instead. -auth_dir="$(state_path CLIPROXY_AUTH_PATH cliproxyapi/oa)" -logs_dir="$(state_path CLIPROXY_LOG_PATH cliproxyapi/logs)" -plugins_dir="$(state_path CLIPROXY_PLUGIN_PATH cliproxyapi/plugins)" -for runtime_dir in "$auth_dir" "$logs_dir" "$plugins_dir"; do - existed=false - [[ -d "$runtime_dir" ]] && existed=true - mkdir -p "$runtime_dir" - if ! $existed || [[ "$runtime_dir" == "$state_dir/cliproxyapi/oa" || \ - "$runtime_dir" == "$state_dir/cliproxyapi/logs" || \ - "$runtime_dir" == "$state_dir/cliproxyapi/plugins" ]]; then - chmod 700 "$runtime_dir" - fi - [[ -w "$runtime_dir" && -x "$runtime_dir" ]] || { - echo "运行目录无法由当前用户写入: $runtime_dir" >&2 - exit 1 - } -done -chmod 600 "$env_file" "$config_file" -find "$auth_dir" "$logs_dir" \ - -type f -uid "$(id -u)" -exec chmod 600 {} + -foreign_owned_file="$(find \ - "$auth_dir" "$logs_dir" \ - -type f ! -uid "$(id -u)" -print -quit)" -if [[ -n "$foreign_owned_file" ]]; then - echo "警告: 发现旧版本留下的非当前用户文件: $foreign_owned_file" >&2 - echo "新文件会使用当前用户;旧文件可在服务停止后按需备份并更正所有者。" >&2 -fi - -if [[ -n "$(dotenv_get SINGBOX_NODE_URL)" ]] && ! command -v python3 >/dev/null 2>&1; then - echo "已设置代理节点,但缺少 python3。请安装后再执行 ./manage.sh up。" >&2 - exit 127 -fi - -if ! "$repo_root/scripts/compose" version >/dev/null 2>&1; then - echo "未找到可用的 Docker Compose 或 Podman Compose。" >&2 - exit 127 -fi - -if $created_env || $created_config || $changed_secrets; then - echo "初始化完成:已创建缺失配置并生成强随机凭证。" -else - echo "初始化检查通过:现有配置和凭证均已保留。" -fi - -if $show_generated_secrets; then - show_secrets - printf '%s\n' '' '下一步:' \ - " 1. 编辑 $env_file:独立模式填写 DEPLOY_MODE=cliproxy 和 CF_TUNNEL_TOKEN" \ - ' 可选:填写 SINGBOX_NODE_URL 或完整模式的外部数据库地址' \ - ' 2. proxy-llm up(Git checkout 也可使用 ./manage.sh up)' \ - ' 3. proxy-llm login codex-device' -fi diff --git a/scripts/migrate-state b/scripts/migrate-state deleted file mode 100755 index d4c3c62..0000000 --- a/scripts/migrate-state +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -# shellcheck source=runtime -source "$repo_root/scripts/runtime" -# shellcheck disable=SC2154 # Assigned by scripts/runtime. -state_dir="${state_dir:?}" -umask 077 - -if (($# != 1)); then - echo "用法: proxy-llm migrate <旧状态目录>" >&2 - exit 2 -fi - -source_dir="$1" -if [[ "$source_dir" != /* ]]; then - source_dir="$(pwd -P)/${source_dir#./}" -fi -if [[ ! -d "$source_dir" ]]; then - echo "显式指定的旧状态目录不存在,停止迁移: $source_dir" >&2 - exit 1 -fi -source_dir="$(cd "$source_dir" && pwd -P)" - -if [[ "$source_dir" == "$state_dir" ]]; then - echo "旧状态目录与目标相同,无需迁移。" - exit 0 -fi - -marker="$state_dir/.migrated-from" -if [[ -f "$marker" ]] && [[ "$(<"$marker")" == "$source_dir" ]]; then - echo "旧状态已经迁移,保留现有目标。" - exit 0 -fi - -source_env="$source_dir/.env" -source_dotenv_value() { - local key="$1" value="" - if [[ -f "$source_env" ]]; then - value="$(awk -v key="$key" ' - index($0, key "=") == 1 { value = substr($0, length(key) + 2) } - END { print value } - ' "$source_env")" - value="${value#\"}" - value="${value%\"}" - value="${value#\'}" - value="${value%\'}" - fi - printf '%s' "$value" -} - -declare -a relative_paths=( - .env - cliproxyapi/config.yaml - cliproxyapi/oa - cliproxyapi/logs - cliproxyapi/plugins - sing-box - data -) -add_relative_path() { - local value="$1" existing_path - local -a non_descendants=() - value="${value#./}" - [[ -n "$value" ]] || return 0 - if [[ "$value" == "." ]]; then - echo "旧状态路径指向整个 checkout,需手动迁移。" >&2 - exit 1 - fi - case "/$value/" in - */../*) - echo "旧状态使用了跨目录相对路径,需手动迁移: $value" >&2 - exit 1 - ;; - esac - - # Copy only minimal, non-overlapping roots. This avoids copying `data` and - # then nesting a second `data/postgres` below the already copied directory. - for existing_path in "${relative_paths[@]}"; do - case "$value" in - "$existing_path"|"$existing_path"/*) return 0 ;; - esac - case "$existing_path" in - "$value"/*) ;; - *) non_descendants+=("$existing_path") ;; - esac - done - relative_paths=("${non_descendants[@]}" "$value") -} - -for path_variable in \ - CLIPROXY_CONFIG_PATH \ - CLIPROXY_AUTH_PATH \ - CLIPROXY_LOG_PATH \ - CLIPROXY_PLUGIN_PATH \ - SINGBOX_CONFIG_PATH; do - configured_path="$(source_dotenv_value "$path_variable")" - case "$configured_path" in - ""|/*) ;; - *) add_relative_path "$configured_path" ;; - esac -done - -for volume_variable in POSTGRES_DATA_SOURCE DRAGONFLY_DATA_SOURCE SINGBOX_DATA_SOURCE; do - configured_source="$(source_dotenv_value "$volume_variable")" - case "$configured_source" in - ""|/*) ;; - */*) add_relative_path "$configured_source" ;; - *) ;; # A source without a slash is a Compose named volume. - esac -done - -has_source_state=false -for relative_path in "${relative_paths[@]}"; do - if [[ -e "$source_dir/$relative_path" || -L "$source_dir/$relative_path" ]]; then - has_source_state=true - break - fi -done -if ! $has_source_state; then - echo "旧目录没有 Proxy-LLM-API 本机状态,跳过迁移。" - exit 0 -fi - -if [[ -e "$state_dir" && ! -d "$state_dir" ]]; then - echo "目标状态路径不是目录,停止迁移: $state_dir" >&2 - exit 1 -fi -if [[ -d "$state_dir" && -n "$(find "$state_dir" -mindepth 1 ! -type d -print -quit)" ]]; then - echo "目标状态目录已有文件、链接或数据,拒绝覆盖或合并: $state_dir" >&2 - exit 1 -fi - -mkdir -p "$state_dir" -chmod 700 "$state_dir" - -for relative_path in "${relative_paths[@]}"; do - source_path="$source_dir/$relative_path" - [[ -e "$source_path" || -L "$source_path" ]] || continue - destination_path="$state_dir/$relative_path" - mkdir -p "$(dirname "$destination_path")" - copy_command=(cp -a --) - case "$relative_path" in - data|data/*) - # Rootless containers may own database files through subordinate UIDs. - # Enter Podman's user namespace so they remain readable and retain the - # same container-visible ownership at the new location. - if command -v podman >/dev/null 2>&1 && podman unshare true >/dev/null 2>&1; then - copy_command=(podman unshare cp -a --) - fi - ;; - esac - if [[ -d "$source_path" && ! -L "$source_path" && -d "$destination_path" ]]; then - # Previous module revisions may have prepared empty state directories. - # Copy directory contents into them instead of nesting a second basename. - "${copy_command[@]}" "$source_path/." "$destination_path/" - else - "${copy_command[@]}" "$source_path" "$destination_path" - fi -done - -# Old deployments inferred the project name from the checkout directory. Pin -# that resolved identity into the migrated env so store paths and future Nix -# generations keep using the same containers, networks, and named volumes. -if [[ -f "$state_dir/.env" && -z "$(source_dotenv_value COMPOSE_PROJECT_NAME)" ]]; then - legacy_project_name="${PROXY_LLM_LEGACY_PROJECT_NAME:-}" - if [[ -z "$legacy_project_name" ]]; then - legacy_project_name="$(basename "$source_dir" | tr '[:upper:]' '[:lower:]' | \ - sed -E 's/^[^a-z0-9]+//; s/[^a-z0-9_-]+//g')" - fi - if [[ ! "$legacy_project_name" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then - echo "无法安全解析旧 Compose project name,停止迁移。" >&2 - exit 1 - fi - printf '\nCOMPOSE_PROJECT_NAME=%s\n' "$legacy_project_name" >> "$state_dir/.env" - chmod 600 "$state_dir/.env" -fi - -printf '%s\n' "$source_dir" > "$marker" -chmod 600 "$marker" -echo "旧状态已无覆盖迁移到: $state_dir" diff --git a/scripts/runtime b/scripts/runtime deleted file mode 100644 index 6a05448..0000000 --- a/scripts/runtime +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash - -# Shared runtime paths for both a mutable Git checkout and the Nix package. -# A checkout keeps its historical repository-local state unless explicitly -# overridden. The packaged wrapper always supplies an XDG state directory. -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -state_dir="${PROXY_LLM_STATE_DIR:-$repo_root}" -if [[ "$state_dir" != /* ]]; then - state_dir="$(pwd -P)/${state_dir#./}" -fi -if [[ "$state_dir" != / ]]; then - state_dir="${state_dir%/}" -fi - -export PROXY_LLM_STATE_DIR="$state_dir" -env_file="$state_dir/.env" - -dotenv_value() { - local key="$1" value="" - - if [[ -v "$key" ]]; then - printf '%s' "${!key}" - return - fi - - if [[ -f "$env_file" ]]; then - value="$(awk -v key="$key" ' - index($0, key "=") == 1 { value = substr($0, length(key) + 2) } - END { print value } - ' "$env_file")" - value="${value#\"}" - value="${value%\"}" - value="${value#\'}" - value="${value%\'}" - fi - - printf '%s' "$value" -} - -state_path() { - local key="$1" fallback="$2" value - value="$(dotenv_value "$key")" - [[ -n "$value" ]] || value="$fallback" - - if [[ "$value" = /* ]]; then - printf '%s' "$value" - else - printf '%s/%s' "$state_dir" "${value#./}" - fi -} - -# Compose volume sources without a slash are named volumes. Relative bind -# sources are state paths, never paths below the immutable packaged source. -volume_source() { - local key="$1" value - value="$(dotenv_value "$key")" - case "$value" in - ""|/*) - printf '%s' "$value" - ;; - */*) - printf '%s/%s' "$state_dir" "${value#./}" - ;; - *) - printf '%s' "$value" - ;; - esac -} diff --git a/scripts/runtime.py b/scripts/runtime.py new file mode 100644 index 0000000..6478dc1 --- /dev/null +++ b/scripts/runtime.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Single-service New API lifecycle; all mutable state stays outside the checkout.""" +import argparse +import json +import os +from pathlib import Path +import re +import secrets +import shutil +import sqlite3 +import subprocess +import sys +import time +import urllib.request + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_IMAGE = 'docker.io/calciumion/new-api:v0.13.2' + + +def state_path(): + return Path(os.environ.get('NEW_API_STATE_DIR', + str(Path(os.environ.get('XDG_STATE_HOME', str(Path.home() / '.local/state'))) / 'new-api-runtime'))).resolve() + + +def read_env(path): + result = {} + if not path.exists(): + return result + for line in path.read_text().splitlines(): + if not line.strip() or line.lstrip().startswith('#'): + continue + key, sep, value = line.partition('=') + if not sep or not re.fullmatch(r'[A-Z][A-Z0-9_]*', key): + raise ValueError('Invalid .env syntax; use KEY=value without shell expressions') + result[key] = value + return result + + +def write_env(path, values): + for value in values.values(): + if any(c in value for c in "\n\r'"): + raise ValueError('Configuration values cannot contain quotes or newlines') + temp = path.with_name(path.name + '.tmp') + with temp.open('x') as f: + f.write(''.join(f"{k}={v}\n" for k, v in values.items())) + temp.replace(path) + + +def initialize(state): + state.mkdir(parents=True, exist_ok=True, mode=0o700) + state.chmod(0o700) + env_file = state / '.env' + values = read_env(env_file) + defaults = {'NEW_API_IMAGE': DEFAULT_IMAGE, 'NEW_API_BIND_ADDRESS': '127.0.0.1', + 'NEW_API_PORT': '23000', 'NEW_API_DATA_DIR': str(state / 'data'), + 'NEW_API_SESSION_SECRET': secrets.token_hex(32), 'TZ': 'Asia/Taipei', + 'NEW_API_ENGINE': os.environ.get('NEW_API_ENGINE', 'podman'), + 'NEW_API_PROJECT': os.environ.get('NEW_API_PROJECT', 'new-api-runtime')} + changed = False + for key, value in defaults.items(): + if key not in values: + values[key] = value + changed = True + if not values['NEW_API_SESSION_SECRET']: + raise ValueError('NEW_API_SESSION_SECRET must not be empty') + data = Path(values['NEW_API_DATA_DIR']) + if not data.is_absolute(): + raise ValueError('NEW_API_DATA_DIR must be absolute') + if changed: + write_env(env_file, values) + env_file.chmod(0o600) + data.mkdir(parents=True, exist_ok=True, mode=0o700) + data.chmod(0o700) + return values + + +def engine(values=None): + value = os.environ.get('NEW_API_ENGINE', (values or {}).get('NEW_API_ENGINE', 'podman')) + if value not in ('podman', 'docker'): + raise ValueError('NEW_API_ENGINE must be podman or docker') + return value + + +def project(values=None): + value = os.environ.get('NEW_API_PROJECT', (values or {}).get('NEW_API_PROJECT', 'new-api-runtime')) + if not re.fullmatch(r'[a-z0-9][a-z0-9_-]*', value): + raise ValueError('Invalid NEW_API_PROJECT') + return value + + +def compose(state, values, *args): + env = os.environ.copy() + # Pass parsed values literally, independent of shell or dotenv interpolation. + env.update(values) + selected_engine = engine(values) + # Docker writes bind mounts as the host user; rootless Podman maps root to that user. + env.setdefault('NEW_API_CONTAINER_USER', + f'{os.getuid()}:{os.getgid()}' if selected_engine == 'docker' else '0:0') + command = [selected_engine, 'compose'] + if selected_engine == 'podman': + command += ['--in-pod=false'] + env.setdefault('PODMAN_COMPOSE_PROVIDER', 'podman-compose') + command += ['-f', str(ROOT / 'docker-compose.yaml'), '-p', project(values), *args] + subprocess.run(command, env=env, check=True) + + +def check(values, wait=False): + host = values['NEW_API_BIND_ADDRESS'] + if host == '0.0.0.0': + host = '127.0.0.1' + if host == '::': + host = '::1' + if ':' in host: + host = '[' + host + ']' + url = f"http://{host}:{values['NEW_API_PORT']}/api/status" + client = urllib.request.build_opener(urllib.request.ProxyHandler({})) + deadline = time.monotonic() + (120 if wait else 0) + while True: + try: + with client.open(url, timeout=5) as response: + if json.load(response).get('success') is True: + print('OK new-api') + return + except (OSError, ValueError): + pass + if time.monotonic() >= deadline: + raise ValueError('New API health check failed; inspect logs') + time.sleep(2) + + +def backup(state, values, destination): + source = Path(values['NEW_API_DATA_DIR']) / 'new-api.db' + if not source.is_file(): + raise ValueError('No SQLite database to back up; finish Web setup first') + destination.mkdir(mode=0o700) # Never overwrite a previous snapshot. + try: + with sqlite3.connect(source.as_uri() + '?mode=ro', uri=True) as src: + with sqlite3.connect(destination / 'new-api.db') as dst: + src.backup(dst) + if dst.execute('PRAGMA quick_check').fetchone() != ('ok',): + raise ValueError('SQLite backup integrity check failed') + shutil.copy2(state / '.env', destination / '.env') + (destination / 'manifest.json').write_text(json.dumps({'image': values['NEW_API_IMAGE'], + 'created_at': int(time.time()), 'format': 1}, indent=2) + '\n') + except BaseException: + shutil.rmtree(destination) + raise + print('Backup saved:', destination) + + +def restore(state, source): + # Restore only into a fresh state directory; existing state is never replaced. + if state.exists() and any(state.iterdir()): + raise ValueError('Restore requires an empty NEW_API_STATE_DIR') + values = read_env(source / '.env') + if not values.get('NEW_API_SESSION_SECRET') or not values.get('NEW_API_IMAGE'): + raise ValueError('Backup configuration missing') + with sqlite3.connect((source / 'new-api.db').as_uri() + '?mode=ro', uri=True) as db: + if db.execute('PRAGMA quick_check').fetchone() != ('ok',): + raise ValueError('Backup integrity check failed') + state.mkdir(parents=True, exist_ok=True, mode=0o700) + data = state / 'data' + data.mkdir(mode=0o700) + shutil.copyfile(source / 'new-api.db', data / 'new-api.db') + values['NEW_API_DATA_DIR'] = str(data) + write_env(state / '.env', values) + print('Restored; review port and image in .env before starting') + + +def main(): + os.umask(0o077) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('command', nargs='?', default='status', choices=[ + 'init', 'up', 'down', 'restart', 'pull', 'status', 'check', 'logs', 'config', 'backup', 'restore']) + parser.add_argument('path', nargs='?', help='New backup directory, or backup to restore') + args = parser.parse_args() + if (args.command in ('backup', 'restore')) != bool(args.path): + parser.error('Only backup and restore require a directory argument') + state = state_path() + if args.command == 'restore': + restore(state, Path(args.path).resolve()) + return + values = initialize(state) + if args.command == 'init': + print('Initialized:', state) + elif args.command == 'backup': + backup(state, values, Path(args.path).resolve()) + elif args.command in ('up', 'restart'): + flags = ['--force-recreate'] if args.command == 'restart' else [] + compose(state, values, 'up', '-d', *flags, 'new-api') + check(values, wait=True) + elif args.command == 'check': + check(values) + elif args.command == 'status': + compose(state, values, 'ps') + check(values) + elif args.command == 'config': + compose(state, values, 'config', '--services') + elif args.command == 'logs': + compose(state, values, 'logs', '-f', '--tail=200', 'new-api') + else: + compose(state, values, args.command) + + +if __name__ == '__main__': + try: + main() + except (ValueError, OSError, sqlite3.Error, subprocess.CalledProcessError) as error: + print(f'new-api-runtime: {error}', file=sys.stderr) + sys.exit(1) diff --git a/scripts/service-exec b/scripts/service-exec deleted file mode 100755 index feb1705..0000000 --- a/scripts/service-exec +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -compose="$repo_root/scripts/compose" -interactive=false - -if [[ "${1:-}" == "-i" ]]; then - interactive=true - shift -fi - -service="${1:-}" -[[ -n "$service" ]] || { - echo "用法: service-exec [-i] [args...]" >&2 - exit 2 -} -shift -(($#)) || { - echo "service-exec 缺少容器内命令。" >&2 - exit 2 -} - -container_id="$($compose ps -q "$service")" -[[ -n "$container_id" ]] || { - echo "服务未创建或未运行: $service" >&2 - exit 1 -} - -if command -v podman >/dev/null 2>&1 && podman inspect "$container_id" >/dev/null 2>&1; then - if $interactive; then - if [[ -t 0 && -t 1 ]]; then - exec podman exec -it "$container_id" "$@" - fi - exec podman exec -i "$container_id" "$@" - fi - exec podman exec "$container_id" "$@" -fi - -if command -v docker >/dev/null 2>&1 && docker inspect "$container_id" >/dev/null 2>&1; then - if $interactive; then - if [[ -t 0 && -t 1 ]]; then - exec docker exec -it "$container_id" "$@" - fi - exec docker exec -i "$container_id" "$@" - fi - exec docker exec "$container_id" "$@" -fi - -if $interactive; then - exec "$compose" exec "$service" "$@" -fi -exec "$compose" exec -T "$service" "$@" diff --git a/scripts/verify-proxy b/scripts/verify-proxy deleted file mode 100755 index 127dbd9..0000000 --- a/scripts/verify-proxy +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -compose="$repo_root/scripts/compose" -service_exec="$repo_root/scripts/service-exec" - -mask_ipv4() { - awk -F. 'NF == 4 { printf "%s.%s.*.*", $1, $2 }' -} - -command -v curl >/dev/null 2>&1 || { - echo "验证需要宿主机 curl。" >&2 - exit 127 -} - -services="$($compose ps --services --status running)" -grep -qx 'sing-box' <<<"$services" || { - echo "sing-box 未运行;请先在 .env 设置节点并执行 ./manage.sh up。" >&2 - exit 1 -} -grep -qx 'cli-proxy-api' <<<"$services" || { - echo "CLIProxyAPI 未运行;请先执行 ./manage.sh up。" >&2 - exit 1 -} - -$service_exec sing-box sing-box check -c /etc/sing-box/config.json >/dev/null - -proxy_env="$($service_exec cli-proxy-api sh -c \ - 'tr "\000" "\n" < /proc/1/environ | sed -n "s/^HTTPS_PROXY=//p"')" -[[ "$proxy_env" == "socks5h://sing-box:1080" ]] || { - echo "CLIProxyAPI PID 1 未继承预期 HTTPS_PROXY。" >&2 - exit 1 -} - -host_ip="$(curl -4 -fsSL --max-time 15 --noproxy '*' https://api.ipify.org)" -automatic_ip="$($service_exec cli-proxy-api \ - curl -4 -fsSL --max-time 30 https://api.ipify.org)" -explicit_ip="$($service_exec cli-proxy-api \ - curl -4 -fsSL --max-time 30 --proxy socks5h://sing-box:1080 https://api.ipify.org)" - -[[ -n "$host_ip" && -n "$automatic_ip" && -n "$explicit_ip" ]] || { - echo "出口 IP 验证没有返回完整结果。" >&2 - exit 1 -} -[[ "$automatic_ip" == "$explicit_ip" ]] || { - echo "CLIProxyAPI 自动代理出口与 sing-box 显式代理出口不一致。" >&2 - exit 1 -} -printf '宿主机直连出口: %s\n' "$(mask_ipv4 <<<"$host_ip")" -printf 'CLIProxyAPI 出口: %s\n' "$(mask_ipv4 <<<"$automatic_ip")" -if [[ "$automatic_ip" == "$host_ip" ]]; then - echo "提示:两者公网出口相同;代理环境与显式 SOCKS 路径仍已验证。" -fi -echo "验证通过:CLIProxyAPI PID 1 持有代理环境,自动出口与 sing-box SOCKS 出口一致。" diff --git a/sing-box/.gitkeep b/sing-box/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/sing-box/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/smoke_docker.py b/tests/smoke_docker.py new file mode 100644 index 0000000..a604ccc --- /dev/null +++ b/tests/smoke_docker.py @@ -0,0 +1,64 @@ +"""Real Docker lifecycle and SQLite restore test; no upstream calls or credentials.""" +import json +import os +from pathlib import Path +import secrets +import subprocess +import tempfile +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] +PORT = 23003 +client = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def api(path, body=None): + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request(f'http://127.0.0.1:{PORT}'+path, data=data, + headers={'Content-Type': 'application/json'}) + with client.open(request, timeout=15) as response: + result = json.load(response) + assert result['success'], path + return result.get('data') + + +with tempfile.TemporaryDirectory(prefix='new-api-docker-') as temporary: + root = Path(temporary) + state = root / 'state with spaces' + restored = root / 'restored' + backup = root / 'backup' + env = os.environ.copy() + env.update(NEW_API_STATE_DIR=str(state), NEW_API_ENGINE='docker', + NEW_API_PROJECT='new-api-docker-smoke') + + def run(*args): + subprocess.run([str(ROOT / 'manage.sh'), *args], env=env, check=True) + + try: + run('init') + config = state / '.env' + config.write_text(config.read_text().replace('NEW_API_PORT=23000', f'NEW_API_PORT={PORT}')) + # All remaining operations must use the persisted Docker engine. + env.pop('NEW_API_ENGINE') + env.pop('NEW_API_PROJECT') + run('up') + password = secrets.token_hex(16) + api('/api/setup', {'username': 'admin', 'password': password, + 'confirmPassword': password, 'SelfUseModeEnabled': True, 'DemoSiteEnabled': False}) + assert api('/api/setup')['status'] is True + assert (state / 'data/new-api.db').stat().st_uid == os.getuid() + run('restart') + assert api('/api/setup')['status'] is True + run('backup', str(backup)) + assert (backup / 'new-api.db').stat().st_mode & 0o777 == 0o600 + run('down') + env['NEW_API_STATE_DIR'] = str(restored) + run('restore', str(backup)) + run('up') + assert api('/api/setup')['status'] is True + print('Docker startup, persistent setup, restart, backup and restored startup passed') + finally: + for folder in (state, restored): + if (folder / '.env').exists(): + env['NEW_API_STATE_DIR'] = str(folder) + subprocess.run([str(ROOT / 'manage.sh'), 'down'], env=env, check=False) diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..4873d70 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,95 @@ +import importlib.util +import os +from pathlib import Path +import sqlite3 +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('runtime', Path(__file__).parents[1] / 'scripts/runtime.py') +runtime = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runtime) + + +class RuntimeTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.state = self.root / 'state with spaces' + old = os.umask(0o077) + self.addCleanup(os.umask, old) + + def test_private_idempotent_state(self): + first = runtime.initialize(self.state) + first['NEW_API_PORT'] = '23002' + runtime.write_env(self.state / '.env', first) + second = runtime.initialize(self.state) + self.assertEqual(first, second) + self.assertEqual((self.state / '.env').stat().st_mode & 0o777, 0o600) + self.assertEqual(self.state.stat().st_mode & 0o777, 0o700) + self.assertEqual(Path(first['NEW_API_DATA_DIR']).stat().st_mode & 0o777, 0o700) + self.assertEqual(len(first['NEW_API_SESSION_SECRET']), 64) + + def test_backup_captures_wal_and_restore_preserves_usage(self): + values = runtime.initialize(self.state) + db = sqlite3.connect(Path(values['NEW_API_DATA_DIR']) / 'new-api.db') + self.addCleanup(db.close) + db.execute('PRAGMA journal_mode=WAL') + db.execute('CREATE TABLE usage (tokens INTEGER)') + db.execute('INSERT INTO usage VALUES (311)') + db.commit() + backup = self.root / 'backup' + runtime.backup(self.state, values, backup) + db.execute('INSERT INTO usage VALUES (500)') + db.commit() + restored = self.root / 'restored' + runtime.restore(restored, backup) + restored_values = runtime.initialize(restored) + self.assertEqual(restored_values['NEW_API_SESSION_SECRET'], values['NEW_API_SESSION_SECRET']) + self.assertEqual(restored_values['NEW_API_IMAGE'], values['NEW_API_IMAGE']) + with sqlite3.connect(restored / 'data/new-api.db') as copy: + self.assertEqual(copy.execute('SELECT sum(tokens) FROM usage').fetchone(), (311,)) + with self.assertRaises(ValueError): + runtime.restore(restored, backup) + with self.assertRaises(FileExistsError): + runtime.backup(self.state, values, backup) + + def test_only_selected_engine_and_service(self): + values = runtime.initialize(self.state) + with patch.dict(os.environ, {'NEW_API_ENGINE': 'podman', 'NEW_API_PROJECT': 'runtime-test'}): + with patch.object(runtime.subprocess, 'run') as run: + runtime.compose(self.state, values, 'up', '-d', 'new-api') + args = run.call_args.args[0] + self.assertEqual(args[:3], ['podman', 'compose', '--in-pod=false']) + self.assertEqual(args[-3:], ['up', '-d', 'new-api']) + self.assertNotIn('--remove-orphans', args) + self.assertNotIn(values['NEW_API_SESSION_SECRET'], ' '.join(args)) + self.assertEqual(run.call_args.kwargs['env']['NEW_API_DATA_DIR'], values['NEW_API_DATA_DIR']) + with patch.dict(os.environ, {'NEW_API_ENGINE': 'docker'}): + with patch.object(runtime.subprocess, 'run') as run: + runtime.compose(self.state, values, 'config', '--services') + self.assertNotIn('--in-pod=false', run.call_args.args[0]) + + def test_docker_selection_is_persistent_and_files_use_host_user(self): + with patch.dict(os.environ, {'NEW_API_ENGINE': 'docker', 'NEW_API_PROJECT': 'saved-project'}): + values = runtime.initialize(self.state) + with patch.dict(os.environ, {}, clear=True): + values = runtime.initialize(self.state) + with patch.object(runtime.subprocess, 'run') as run: + runtime.compose(self.state, values, 'up', '-d', 'new-api') + self.assertEqual(run.call_args.args[0][0], 'docker') + self.assertIn('saved-project', run.call_args.args[0]) + self.assertEqual(run.call_args.kwargs['env']['NEW_API_CONTAINER_USER'], + f'{os.getuid()}:{os.getgid()}') + + def test_bad_restore_does_not_create_destination(self): + source = self.root / 'bad-backup' + source.mkdir() + with self.assertRaises(ValueError): + runtime.restore(self.state, source) + self.assertFalse(self.state.exists()) + + +if __name__ == '__main__': + unittest.main()