feat: embed independently managed Xray with encrypted DNS

This commit is contained in:
chuan
2026-10-27 01:20:07 +08:00 Unverified
parent a1f61d2daf
commit f8a60c136d
13 changed files with 351 additions and 21 deletions
+3
View File
@@ -0,0 +1,3 @@
*
!docker/
!docker/Dockerfile
+2
View File
@@ -1,5 +1,7 @@
* text=auto
*.yaml text eol=lf
*.json text eol=lf
*.conf text eol=lf
*.sh text eol=lf
*.md text eol=lf
.env* text eol=lf
+1
View File
@@ -4,3 +4,4 @@
.runtime/
backups/
*.log
configs/xray/10-node.json
+41 -11
View File
@@ -1,6 +1,6 @@
# CPR 部署配置
使用官方 `ghcr.io/zyycn/codex-proxy-rs:3.33.0` 镜像,包含单副本应用、PostgreSQL 18 和 Redis 8。需要 Docker Engine 和 Docker Compose 2.20.0 或更新版本
基于官方 CPR `3.33.0` 和 Xray `26.7.28` 镜像构建运行环境,不修改或编译上游源码。单副本应用容器内运行 CPR、Xray 和 DNS 转发器,PostgreSQL 18、Redis 8 使用独立容器。需要 Linux Docker Engine 和 Docker Compose 2.20.0 或更新版本
## 文件
@@ -15,28 +15,49 @@
| `compose/redis.yaml` | Redis、认证、AOF 和数据卷 |
| `configs/config.yaml` | 独立的应用配置模板,包含密码占位符 |
| `configs/caddy.caddy` | `cpr.pchuan.top` 的 Caddy 站点配置 |
| `configs/xray/00-base.json` | 透明代理、DoH、日志及统计配置 |
| `configs/xray/10-node.json` | 真实节点配置,Git 忽略 |
| `configs/xray-node.example.json` | VLESS Reality 节点配置示例 |
| `configs/dnsmasq.conf` | Docker 服务名与公网 DNS 分流 |
| `configs/supervisord.conf` | CPR、Xray、DNS 的独立进程管理与日志轮转 |
| `docker/Dockerfile` | 官方镜像的运行环境扩展 |
| `scripts/start-app.sh` | 启动时填入密码并执行官方程序 |
| `scripts/start-proxy.sh` | 配置容器透明代理规则并启动进程管理器 |
| `.externals/codex-proxy-rs` | 官方源码子模块,固定为 `v3.33.0`,仅供核对,不参与构建 |
## 配置
新检出目录时,将 `.env.example` 复制为 `.env`。在 `.env` 分别填写 `ADMIN_PASSWORD`、`POSTGRES_PASSWORD`、`REDIS_PASSWORD`,每项使用独立的 48 位随机十六进制值。空密码会被 Compose 拒绝,不提供默认密码
`.env` 仅维护三项密码。镜像版本和端口写在对应 Compose 文件中,管理员用户名和业务时区写在 `configs/config.yaml`,项目名在根 `compose.yaml` 中固定为 `cpr`
`.env` 仅维护三项密码。基础镜像版本写在 `docker/Dockerfile`,生成的镜像标签与端口写在 `compose/app.yaml`,管理员用户名和业务时区写在 `configs/config.yaml`,项目名在根 `compose.yaml` 中固定为 `cpr`
参考 `configs/xray-node.example.json` 创建 `configs/xray/10-node.json`,将 `proxy` 出站填写为真实节点。节点地址使用 IP,TLS/Reality 的 `serverName` 保留节点要求的域名,避免代理 DNS 启动时循环依赖。节点文件含认证信息,只保存在本机或服务器,不提交 Git
配置模板只读挂载到容器。启动脚本检查密码格式,替换三个占位符,在容器内生成仅当前用户可读写的 `/app/deploy/config.yaml`,然后启动官方程序。宿主模板始终不含真实密码,不需要手动生成配置文件
默认管理端用户名为 `admin@cpr.local`,本机地址为 `http://127.0.0.1:8080`。PostgreSQL 和 Redis 仅在项目默认网络内可达,不映射宿主端口
应用同时接入已有外部网络 `web-network`,别名为 `cpr-app`,部署前需确保该网络存在。将 `configs/caddy.caddy` 的站点配置接入同网络的 Caddy,可通过 `https://cpr.pchuan.top` 访问,客户端 Base URL 为 `https://cpr.pchuan.top/v1`。域名需解析到服务器,Caddy 自动申请和续期证书,SSE 即时刷新且支持 WebSocket Upgrade。账号出站代理在管理端另行配置
应用同时接入已有外部网络 `web-network`,别名为 `cpr-app`,部署前需确保该网络存在。将 `configs/caddy.caddy` 的站点配置接入同网络的 Caddy,可通过 `https://cpr.pchuan.top` 访问,客户端 Base URL 为 `https://cpr.pchuan.top/v1`。域名需解析到服务器,Caddy 自动申请和续期证书,SSE 即时刷新且支持 WebSocket Upgrade
数据库密码和管理员密码是初始化参数:已有 PostgreSQL 数据卷时,修改 `.env` 不会自动修改库内用户密码;管理员创建后,修改初始密码也不会重设已有管理员。密码轮换需同步修改对应服务中的实际密码
## 持久化
应用数据、日志、PostgreSQL、Redis 分别保存到四个 Docker 命名卷,卷名前缀为 `cpr`。已有数据后保持项目名不变
应用数据、应用日志、Xray/DNS 日志、PostgreSQL、Redis 分别保存到五个 Docker 命名卷,卷名前缀为 `cpr`。已有数据后保持项目名不变
应用以官方 UID/GID `10001:10001` 运行。空应用卷使用镜像目录初始化;恢复旧卷时需保留对应属主与权限
入口和进程管理器以 root 配置容器网络,CPR 仍以官方 UID/GID `10001:10001` 运行。空应用卷使用镜像目录初始化;恢复旧卷时需保留对应属主与权限。仅增加容器内所需能力,不使用 privileged 或 host 网络
## 代理与 DNS
CPR 的公网 IPv4 TCP/UDP 经容器内 TPROXY 进入 Xray。Docker 内网、环回和入站连接的返回流量绕过代理;Xray 自身使用专用 GID 防止循环。IPv6 出站阻断,避免绕过。规则仅作用于应用容器,不影响宿主机、CPA、数据库或 Redis 容器
管理页面中的“直连”表示未配置账号级代理,公网流量仍会经过容器的 Xray。另行给账号绑定 HTTP/SOCKS 代理会形成代理链,应确认这是预期行为
应用使用 `127.0.0.1` 的 DNS 转发器:`postgres`、`redis` 交给 Docker 的 `127.0.0.11` 动态解析,公网域名交给 Xray。Xray 通过代理并行查询 Cloudflare `1.1.1.1` 和 Quad9 `9.9.9.9` 的 DoH,并缓存结果。DNS 故障不会回退到公网明文 DNS
Xray 可以独立重启或自动从崩溃恢复,期间 CPR 进程不重启、透明代理规则保留,公网连接暂时不可用,已有上游连接会中断,需要客户端重试。独立重启不保证正在生成的请求无中断
Xray 的访问、错误和 DNS 转发器日志保存在 `/app/.runtime/xray/`,每类单文件上限 10 MiB,最多保留 3 个轮转文件。错误日志中的 DNS 记录可查看 DoH 解析结果与耗时;访问日志可查看目标地址及所选出站。日志包含访问域名和 IP,分享前需脱敏
## 运维命令
@@ -45,23 +66,32 @@
| 命令 | 用途 |
| --- | --- |
| `just check` | 检查配置,不输出展开后的密码 |
| `just pull` | 拉取固定版本镜像 |
| `just pull` | 拉取 PostgreSQL 和 Redis 镜像 |
| `just build` | 构建包含 Xray 的应用镜像 |
| `just up` | 创建或更新全部服务,等待健康 |
| `just status` | 查看服务状态 |
| `just logs` | 持续查看应用日志 |
| `just logs postgres` | 持续查看 PostgreSQL 日志,Redis 同理 |
| `just health` | 在应用容器内检查健康接口 |
| `just health` | 检查 Xray、DNS 进程及应用健康接口 |
| `just restart` | 重启应用,不应用配置变更 |
| `just recreate` | 重新创建应用,应用配置变更 |
| `just update` | 拉取并重新创建应用,不更新数据库和 Redis |
| `just update` | 更新基础镜像、构建并重新创建应用 |
| `just xray-check` | 校验 Xray 和节点配置 |
| `just xray-restart` | 校验成功后仅重启 Xray |
| `just xray-status` | 查看 CPR、Xray 和 DNS 进程状态 |
| `just xray-logs` | 持续查看 Xray 访问日志 |
| `just xray-logs error` | 持续查看连接错误和 DoH 日志 |
| `just xray-logs dns` | 持续查看应用的 DNS 查询与缓存记录 |
| `just xray-stats` | 查看出站收发字节,Xray 重启后清零 |
| `just dns api.openai.com` | 检查解析结果及耗时 |
| `just stop` | 停止全部服务,保留容器 |
| `just down` | 移除容器和网络,保留数据卷 |
`restart`、`recreate` 接受服务名,默认 `app`,也可指定 `postgres` 或 `redis`。首次启动使用 `just pull`、`just up`;应用配置修改后使用 `just recreate`
`restart`、`recreate` 接受服务名,默认 `app`,也可指定 `postgres` 或 `redis`。首次启动使用 `just pull`、`just build`、`just up`。应用配置修改后使用 `just recreate`;仅修改 Xray 配置或节点时使用 `just xray-restart`
健康接口 `/healthz` 返回 204 表示网关、数据库、Redis 和后台任务通过检查,不代表上游账号可用。创建或导入账号后,在管理端创建客户端 Key,按“使用密钥”页面配置客户端
健康接口 `/healthz` 返回 204 表示网关、数据库、Redis 和后台任务通过检查;容器健康检查还确认 Xray 和 DNS 进程运行,但不证明代理节点可达。通过 DNS 查询、出口 IP 和真实账号请求分别验证网络与模型能力
升级修改 `compose/app.yaml` 中的镜像版本,并核对对应 Release 的配置及迁移说明。应用内置 PostgreSQL 备份客户端为 18,不随意切换数据库大版本。升级前备份数据库、应用数据与 `.env`;停止服务保留命名卷,不使用 `down -v`
升级修改 `docker/Dockerfile` 中的基础镜像版本和 `compose/app.yaml` 中的生成镜像标签,核对对应 Release 的配置及迁移说明,再执行 `just update`。应用内置 PostgreSQL 备份客户端为 18,不随意切换数据库大版本。升级前备份数据库、应用数据、节点文件与 `.env`;停止服务保留命名卷,不使用 `down -v`
## 官方参考
+22 -5
View File
@@ -1,10 +1,14 @@
services:
app:
image: ghcr.io/zyycn/codex-proxy-rs:3.33.0
image: cpr:3.33.0-xray26.7.28
build:
context: ..
dockerfile: docker/Dockerfile
restart: unless-stopped
user: '10001:10001'
# 入口配置网络,Supervisor 将 CPR 降权到官方 cpr 用户。
user: '0:0'
init: true
entrypoint: [/bin/sh, /app/scripts/start-app.sh]
entrypoint: [/bin/bash, /app/scripts/start-proxy.sh]
stop_grace_period: 75s
depends_on:
postgres:
@@ -23,8 +27,12 @@ services:
volumes:
- app-data:/app/.runtime/data
- app-logs:/app/.runtime/logs
- xray-logs:/app/.runtime/xray
- ../configs/config.yaml:/app/deploy/config.template.yaml:ro
- ../scripts/start-app.sh:/app/scripts/start-app.sh:ro
- ../configs/xray:/etc/xray:ro
- ../configs/dnsmasq.conf:/etc/cpr/dnsmasq.conf:ro
- ../configs/supervisord.conf:/etc/supervisor/supervisord.conf:ro
- ../scripts:/app/scripts:ro
environment:
HOME: /app
CPR_DEPLOYMENT_MODE: docker
@@ -37,10 +45,18 @@ services:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_ADMIN
- NET_BIND_SERVICE
- SETUID
- SETGID
- KILL
- DAC_OVERRIDE
tmpfs:
- /tmp:size=64m,mode=1777
- /run/cpr:size=8m,mode=0700
healthcheck:
test: [CMD, curl, -fsS, http://127.0.0.1:8080/healthz]
test: [CMD, /bin/sh, /app/scripts/health.sh]
interval: 30s
timeout: 5s
retries: 3
@@ -54,6 +70,7 @@ services:
volumes:
app-data:
app-logs:
xray-logs:
networks:
web-network:
+17
View File
@@ -0,0 +1,17 @@
# 容器服务名仍由 Docker 动态解析;公网域名交给 Xray 的 DoH。
listen-address=127.0.0.1
bind-interfaces
port=53
no-resolv
no-hosts
server=/postgres/127.0.0.11
server=/redis/127.0.0.11
server=127.0.0.1#5353
cache-size=1000
max-cache-ttl=60
neg-ttl=5
user=root
group=root
pid-file=/run/cpr/dnsmasq.pid
log-queries=extra
log-facility=-
+63
View File
@@ -0,0 +1,63 @@
[unix_http_server]
file=/run/cpr/supervisor.sock
chmod=0600
[supervisord]
nodaemon=true
user=root
pidfile=/run/cpr/supervisord.pid
logfile=/dev/stdout
logfile_maxbytes=0
[rpcinterface:supervisor]
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///run/cpr/supervisor.sock
[program:xray]
command=/usr/bin/setpriv --regid 23333 --clear-groups /usr/local/bin/xray run -confdir /etc/xray
priority=10
autostart=true
autorestart=true
startsecs=1
startretries=5
stopasgroup=true
killasgroup=true
stopwaitsecs=15
stdout_logfile=/app/.runtime/xray/access.log
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=3
stderr_logfile=/app/.runtime/xray/error.log
stderr_logfile_maxbytes=10MB
stderr_logfile_backups=3
[program:dns]
command=/usr/sbin/dnsmasq --keep-in-foreground --conf-file=/etc/cpr/dnsmasq.conf
priority=20
autostart=true
autorestart=true
startsecs=1
startretries=5
stopasgroup=true
killasgroup=true
redirect_stderr=true
stdout_logfile=/app/.runtime/xray/dns.log
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=3
[program:app]
command=/bin/sh /app/scripts/start-app.sh
user=cpr
priority=30
autostart=true
autorestart=true
startsecs=3
startretries=5
stopasgroup=true
killasgroup=true
stopwaitsecs=45
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
+30
View File
@@ -0,0 +1,30 @@
{
"outbounds": [
{
"tag": "proxy",
"protocol": "vless",
"settings": {
"vnext": [
{
"address": "192.0.2.1",
"port": 443,
"users": [
{"id": "00000000-0000-0000-0000-000000000000", "encryption": "none", "flow": "xtls-rprx-vision"}
]
}
]
},
"streamSettings": {
"network": "raw",
"security": "reality",
"realitySettings": {
"serverName": "replace-with-node-sni.example",
"fingerprint": "chrome",
"password": "replace-with-node-public-key",
"shortId": ""
},
"sockopt": {"mark": 2}
}
}
]
}
+67
View File
@@ -0,0 +1,67 @@
{
"log": {
"access": "/dev/stdout",
"error": "/dev/stderr",
"loglevel": "info",
"dnsLog": true
},
"api": {
"tag": "api",
"listen": "127.0.0.1:10085",
"services": ["StatsService"]
},
"stats": {},
"policy": {
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"inbounds": [
{
"tag": "transparent-in",
"listen": "0.0.0.0",
"port": 12345,
"protocol": "tunnel",
"settings": {"allowedNetwork": "tcp,udp", "followRedirect": true},
"streamSettings": {"sockopt": {"tproxy": "tproxy"}},
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls", "quic"],
"routeOnly": true
}
},
{
"tag": "dns-in",
"listen": "127.0.0.1",
"port": 5353,
"protocol": "tunnel",
"settings": {"address": "1.1.1.1", "port": 53, "allowedNetwork": "tcp,udp"}
}
],
"outbounds": [
{"tag": "blocked", "protocol": "blackhole"},
{"tag": "dns-out", "protocol": "dns"}
],
"routing": {
"domainStrategy": "AsIs",
"rules": [
{"type": "field", "inboundTag": ["dns-in"], "outboundTag": "dns-out"},
{
"type": "field",
"inboundTag": ["dns-upstream", "transparent-in"],
"outboundTag": "proxy"
}
]
},
"dns": {
"servers": [
{"address": "https://1.1.1.1/dns-query", "timeoutMs": 3000},
{"address": "https://9.9.9.9/dns-query", "timeoutMs": 3000}
],
"queryStrategy": "UseIPv4",
"disableCache": false,
"enableParallelQuery": true,
"tag": "dns-upstream"
}
}
+16
View File
@@ -0,0 +1,16 @@
FROM ghcr.io/xtls/xray-core:26.7.28 AS xray
FROM ghcr.io/zyycn/codex-proxy-rs:3.33.0
USER root
# 保留官方 CPR 二进制与页面,仅补充透明代理、DNS 和进程管理工具。
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash dnsmasq-base dnsutils iproute2 iptables supervisor util-linux \
&& rm -rf /var/lib/apt/lists/* \
&& install -d /app/scripts /app/.runtime/xray /run/cpr
COPY --from=xray /usr/local/bin/xray /usr/local/bin/xray
WORKDIR /app
+33 -5
View File
@@ -9,9 +9,13 @@ default:
check:
docker compose config --quiet
# 拉取配置中固定版本的镜像。
# 拉取数据库和 Redis 的固定版本镜像。
pull: check
docker compose pull
docker compose pull postgres redis
# 基于官方 CPR 与 Xray 镜像构建运行环境,不编译上游源码。
build: check
docker compose build app
# 创建或更新全部服务,等待健康检查通过。
up: check
@@ -33,9 +37,9 @@ restart service="app":
recreate service="app": check
docker compose up -d --no-build --no-deps --force-recreate --wait {{service}}
# 拉取并更新应用镜像;先在 compose/app.yaml 中修改版本。
# 更新基础镜像并重建应用;版本固定在 Dockerfile 和 Compose 中。
update: check
docker compose pull app
docker compose build --pull app
docker compose up -d --no-build --no-deps --force-recreate --wait app
# 查看所有服务的状态,包括已停止的容器。
@@ -48,4 +52,28 @@ logs service="app":
# 在应用容器内检查健康接口,成功返回 HTTP 204。
health:
docker compose exec -T app curl --fail --silent --show-error --include http://127.0.0.1:8080/healthz
docker compose exec -T app /bin/sh /app/scripts/health.sh
# 校验 Xray 基础配置和节点文件,不重启进程。
xray-check:
docker compose exec -T app xray run -test -confdir /etc/xray
# 配置有效才单独重启 Xray,保留 CPR 进程与透明代理规则。
xray-restart: xray-check
docker compose exec -T app supervisorctl -c /etc/supervisor/supervisord.conf restart xray
# 查看 CPR、Xray 和 DNS 的独立进程状态。
xray-status:
docker compose exec -T app supervisorctl -c /etc/supervisor/supervisord.conf status
# 查看访问日志;error 可查看连接错误和 DoH 查询耗时。
xray-logs kind="access":
docker compose exec -T app tail -n 100 -F /app/.runtime/xray/{{kind}}.log
# 查询各出站的收发字节,Xray 重启后计数清零。
xray-stats:
docker compose exec -T app xray api statsquery --server=127.0.0.1:10085
# 查看当前解析结果和耗时,默认检查 OpenAI 域名。
dns domain="api.openai.com":
docker compose exec -T app dig "@127.0.0.1" {{domain}} A +tries=1 +time=5
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
[ "$(supervisorctl -c /etc/supervisor/supervisord.conf status xray dns | grep -c RUNNING)" -eq 2 ]
exec curl --fail --silent --show-error http://127.0.0.1:8080/healthz
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# 只管理当前容器的网络命名空间,不修改宿主或 CPA 的规则。
[[ "$(id -u)" == 0 ]] || { echo 'Proxy setup requires root.' >&2; exit 1; }
[[ -s /etc/xray/10-node.json ]] || { echo 'Missing configs/xray/10-node.json.' >&2; exit 1; }
xray run -test -confdir /etc/xray >/dev/null
dnsmasq --test --conf-file=/etc/cpr/dnsmasq.conf
mkdir -p /run/cpr /app/.runtime/xray
chmod 0700 /run/cpr
# 容器重启时可重复执行;重启 Xray 本身不会经过这里或删除规则。
for chain in CPR_XRAY_OUT CPR_XRAY_IN CPR_LOCAL_IN; do
iptables -t mangle -N "$chain" 2>/dev/null || iptables -t mangle -F "$chain"
done
while iptables -t mangle -D OUTPUT -j CPR_XRAY_OUT 2>/dev/null; do :; done
while iptables -t mangle -D PREROUTING -j CPR_LOCAL_IN 2>/dev/null; do :; done
while iptables -t mangle -D PREROUTING -m mark --mark 1/1 -j CPR_XRAY_IN 2>/dev/null; do :; done
while ip rule del priority 10010 2>/dev/null; do :; done
ip route replace local 0.0.0.0/0 dev lo table 100
ip rule add fwmark 1/1 table 100 priority 10010
# 保留入站连接的返回路径,避免对 Caddy 的响应也进入代理。
iptables -t mangle -A CPR_LOCAL_IN -i lo -j RETURN
iptables -t mangle -A CPR_LOCAL_IN -m conntrack --ctstate NEW -m addrtype --dst-type LOCAL -j CONNMARK --set-xmark 4/4
iptables -t mangle -A PREROUTING -j CPR_LOCAL_IN
iptables -t mangle -A CPR_XRAY_IN -p tcp -j TPROXY --on-port 12345 --tproxy-mark 1/1
iptables -t mangle -A CPR_XRAY_IN -p udp -j TPROXY --on-port 12345 --tproxy-mark 1/1
iptables -t mangle -A PREROUTING -m mark --mark 1/1 -j CPR_XRAY_IN
# Xray 使用专用 GID,自己的出口和 Docker 内网不会被再次截获。
iptables -t mangle -A CPR_XRAY_OUT -m owner --gid-owner 23333 -j RETURN
iptables -t mangle -A CPR_XRAY_OUT -m mark --mark 2/2 -j RETURN
iptables -t mangle -A CPR_XRAY_OUT -m connmark --mark 4/4 -j RETURN
iptables -t mangle -A CPR_XRAY_OUT -m addrtype --dst-type LOCAL -j RETURN
for subnet in 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/3; do
iptables -t mangle -A CPR_XRAY_OUT -d "$subnet" -j RETURN
done
iptables -t mangle -A CPR_XRAY_OUT -p tcp -j MARK --set-xmark 1/1
iptables -t mangle -A CPR_XRAY_OUT -p udp -j MARK --set-xmark 1/1
iptables -t mangle -A OUTPUT -j CPR_XRAY_OUT
# IPv6 暂不代理,禁止绕过;本机回环保留。
ip6tables -N CPR_IPV6_OUT 2>/dev/null || ip6tables -F CPR_IPV6_OUT
while ip6tables -D OUTPUT -j CPR_IPV6_OUT 2>/dev/null; do :; done
ip6tables -A CPR_IPV6_OUT -o lo -j RETURN
ip6tables -A CPR_IPV6_OUT -j REJECT
ip6tables -A OUTPUT -j CPR_IPV6_OUT
printf 'nameserver 127.0.0.1\noptions ndots:0 timeout:2 attempts:2\n' > /etc/resolv.conf
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf