Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Keep the relay Docker build context lean and free of machine-local state.
# web/dist is built inside the image (stage 1); web/node_modules would bloat
# the context and is never committed upstream.
.git/
.venv/
__pycache__/
*.pyc
.pytest_cache/
web/node_modules/
web/dist/
web/test-results/
*.log
# Never leak a populated env file into the image. `cp env.relay.docker.example
# env.relay` is the documented first step; the real env holds the login
# password, session secret, and wrapper token, so it must never reach the
# build context.
deploy/env.relay
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@

## Unreleased

- Add an official container deploy for the Relay: a multi-stage
`deploy/Dockerfile` builds `web/dist` from source and installs the
hash-locked wheels as a non-root `ccremote` user, with `docker-compose.yml`,
a Docker-oriented `env.relay.docker.example`, and a compose layout that only
publishes the relay to the host loopback so an existing nginx can keep TLS
and WebSocket termination. `deploy/nginx-reverse-proxy.conf.example`
documents that alternative front, and the Docker build exposes
`PIP_INDEX_URL` / `PIP_EXTRA_INDEX_URL` build args plus documented
mainland-China mirror guidance.
- Upgrade the coordinated Wrapper/Relay/Web gate to protocol v35. Exact Codex
app-server and source-validated rollout terminals now travel independently of
the narrative History projection, so a multi-hundred-MiB rollout cannot keep
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

## 未发布

- 新增 Relay 的官方容器化部署:`deploy/Dockerfile` 分阶段构建——Node 阶段从源码
编译 `web/dist`,Python 阶段以非 root 的 `ccremote` 用户按哈希锁安装依赖;附
`docker-compose.yml` 与 Docker 版 `env.relay.docker.example`。compose 只把端口
发布到宿主机 loopback,TLS/WebSocket 终止仍交给现有 nginx;
`deploy/nginx-reverse-proxy.conf.example` 记录该反代前端。Docker 构建支持
`PIP_INDEX_URL` / `PIP_EXTRA_INDEX_URL` 构建参数,并补充大陆镜像加速指引。
- Wrapper、Relay 与 Web 的协同 gate 升级到 protocol v35。Codex app-server 的
精确终态与通过源文件校验的 rollout 终态现在独立于 History 正文投影下发;数百
MiB 的 rollout 即使仍在补建内容索引,也不会让已经完成的回合继续转圈。终态事实
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,25 @@ Wrapper 已有设备凭据时只需:
协议大版本升级仍应在同一维护窗口完成 Relay、Web 和所有 Wrapper;已经打开的页面要
硬刷新。安装器保留上一 release,服务验活失败会把 `current` 和服务定义恢复到旧版。

### 中国大陆网络加速(可选)

`install.sh` 从 GitHub Release 下载角色包、内置的 `uv` 再下载 Python 运行时,
两者在国内默认路径都可能很慢。可用镜像加速:

```bash
# 1) 加速 uv 安装 PyPI 依赖(阿里云 PyPI 镜像)
export UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple
# 2) 加速 uv 下载 python-build-standalone 运行时(npmmirror)
export UV_PYTHON_INSTALL_MIRROR=https://registry.npmmirror.com/-/binary/python-build-standalone
./install.sh wrapper --relay https://remote.example.com --pair XXXXX-XXXXX-XXXXX-XXXXX --name "MacBook Pro"
```

若 `install.sh` 下载角色包超时:脚本每次都会从
`CC_REMOTE_RELEASE_BASE_URL`(默认 GitHub Release)重新下载、没有本地缓存,
可把该变量指向镜像源或本地 `file://` 路径后重试。Relay 用 Docker 容器部署时,
构建阶段同样可用 `--build-arg PIP_INDEX_URL=...` 加速(见 deploy/README.md 的
容器章节)。

## 生产部署(公网 VPS 中继 + 你机器上的 wrapper)

以下保留源码 staging / 手工配置路径,适合开发、自定义部署和故障恢复。普通正式安装
Expand Down
21 changes: 21 additions & 0 deletions README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,27 @@ window, then hard-refresh open browser tabs. The installers retain the previous
release and restore both `current` and the service definition if activation
does not become healthy.

### Faster installs behind the GFW (optional)

`install.sh` downloads the role bundle from the GitHub Release and the bundled
`uv` then downloads the Python runtime — both default paths can be slow from
mainland China. Point uv at mirrors to speed it up:

```bash
# 1) Faster PyPI dependencies (Aliyun PyPI mirror)
export UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple
# 2) Faster python-build-standalone runtime downloads (npmmirror)
export UV_PYTHON_INSTALL_MIRROR=https://registry.npmmirror.com/-/binary/python-build-standalone
Comment on lines +516 to +518

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward the uv mirror variables through sudo

This mirror recipe does not work for the documented Linux installation path: deploy/install.sh crosses into sudo env at lines 120–128 while forwarding only CC_REMOTE_INSTALL_USER and the optional password-file variable, so normal sudo environment filtering removes both exported UV_* values before the bundled uv runs. Local sudo --help identifies -E/--preserve-env as the option for retaining these variables; alternatively, add both variables to the explicit sudo_env array.

Useful? React with 👍 / 👎.

./install.sh wrapper --relay https://remote.example.com --pair XXXXX-XXXXX-XXXXX-XXXXX --name "MacBook Pro"
```

If `install.sh` times out fetching the role bundle, note that it always
re-downloads from `CC_REMOTE_RELEASE_BASE_URL` (GitHub Release by default) with
no local cache - point that variable at a mirror or a local `file://` path and
retry. When deploying the Relay in a Docker container, the build stage can use
the same mirror idea via `--build-arg PIP_INDEX_URL=...` (see the container
section of [deploy/README.md](deploy/README.md)).

## Production deploy (public VPS relay + wrapper on your machine)

The source-staging/manual path below remains available for development, custom
Expand Down
75 changes: 75 additions & 0 deletions deploy/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Multi-stage image for the cc-remote Relay.
#
# The official deploy path is a systemd venv staged by setup-vps.sh in front of
# a managed Caddy (see deploy/README.md and install-relay.sh). This container
# runs the exact same `python -m cc_remote.relay` release in an isolated root
# filesystem — for VPSes that already manage services with Docker, or where the
# relay should be pinned to its own writable state volume.
#
# The repository does not track web/dist, so the first stage builds the web
# client from source (npm run build) and the second stage only carries the
# built artifact. Python wheels are installed with the same hash-locked,
# binary-only rules the official installer uses; http-ece ships only an sdist
# on PyPI, so it is exempted with --no-binary exactly as setup-vps.sh does.
#
# Mirrors: PyPI.org by default. In mainland China build with
# docker build -f deploy/Dockerfile \
# --build-arg PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple \
# --build-arg PIP_EXTRA_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
# -t cc-remote-relay .

FROM node:24-alpine AS web-build
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build

FROM python:3.13-slim-bookworm AS relay

ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
RELAY_HOST=127.0.0.1 \
RELAY_PORT=8765 \
WEB_STATIC_DIR=/app/web/dist \
PUSH_DB_PATH=/app/state/relay-push.sqlite3 \
DEVICE_DB_PATH=/app/state/relay-devices.sqlite3 \
SESSION_TTL_SECONDS=604800 \
LOG_LEVEL=INFO

WORKDIR /app

# Relay application code, deploy reference files, and the hash-locked
# dependency list (the installed wheels are the ones setup-vps.sh pins).
COPY cc_remote/ ./cc_remote/
COPY deploy/ ./deploy/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude deploy/env.relay from the image

When users follow deploy/README.md and create deploy/env.relay with the login password, session secret, and wrapper token before building, this broad COPY deploy/ includes that file because neither .dockerignore nor .gitignore excludes it. The resulting image therefore contains all production credentials at /app/deploy/env.relay, exposing them to anyone who receives or can inspect the image; exclude the populated environment file and avoid copying deployment-only files into the runtime image.

Useful? React with 👍 / 👎.

COPY requirements-relay.lock requirements-relay.lock

# The systemd unit runs the relay as a dedicated `ccremote` user
# (deploy/cc-remote-relay.service); mirror that inside the container. State is
# written to /app/state — mount it as a volume (see docker-compose.yml).
RUN groupadd --gid 10001 ccremote \
&& useradd --uid 10001 --gid ccremote --home-dir /app \
--shell /usr/sbin/nologin ccremote \
&& mkdir -p /app/state \
&& chown -R ccremote:ccremote /app/state

ARG PIP_INDEX_URL=https://pypi.org/simple
ARG PIP_EXTRA_INDEX_URL=
RUN pip install --index-url "$PIP_INDEX_URL" \
${PIP_EXTRA_INDEX_URL:+--extra-index-url "$PIP_EXTRA_INDEX_URL"} \
--require-hashes --only-binary=:all: --no-binary=http-ece \
-r requirements-relay.lock

# Built web client from the first stage.
COPY --from=web-build --chown=ccremote:ccremote /src/web/dist ./web/dist/

EXPOSE 8765

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/healthz', timeout=4)" || exit 1

USER ccremote

CMD ["python", "-m", "cc_remote.relay"]
44 changes: 44 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ machine). The **full step-by-step guide is in the main [README](../README.md#生
- `com.muggle.cc-remote.wrapper.plist.in` — secret-free macOS LaunchAgent
template. The runtime reads the current user's mode-0600 device JSON instead
of embedding control credentials in the plist.
- `Dockerfile` / `docker-compose.yml` / `env.relay.docker.example` — the same
relay release as a container (build `web/dist` in a Node stage, install the
hash-locked wheels, run as the `ccremote` user). See the container section
below; this is an alternative to the systemd + Caddy path, not a fork of it.
- `nginx-reverse-proxy.conf.example` — a WebSocket reverse-proxy front for
hosts that already run nginx instead of the managed Caddy. Loopback-only
requirement is documented in the file header.
- `work_registry_snapshot.py` — snapshots provider-local Work SQLite databases
through SQLite's backup API, restores the matching pre-release images before
an older wrapper is restarted, and verifies the v34 Codex ownership backfill.
Expand Down Expand Up @@ -87,6 +94,43 @@ created after activation will no longer be registered (their private files are
not deleted). Use this for immediate failed activation; after normal use,
prefer a roll-forward fix unless that metadata rollback is explicitly accepted.

## Container deploy (Docker) and the nginx alternative

The official relay install is a systemd venv staged by `setup-vps.sh` behind a
managed Caddy. Two alternative topologies are supported for hosts that already
manage their own services or TLS:

**Docker container.** `Dockerfile` builds the same relay release as a
multi-stage image: the Node stage compiles `web/dist` from source, the Python
stage installs the same hash-locked wheels `setup-vps.sh` pins and runs
`python -m cc_remote.relay` as a non-root `ccremote` user. From the `deploy/`
directory:

```bash
cp env.relay.docker.example env.relay # then fill in the secrets
docker compose up -d --build
curl https://your-domain/healthz # -> {"ok":true,...}
```

The compose file publishes the relay only to the host loopback
(`127.0.0.1:8765`) and mounts a named volume for the SQLite device/Web Push
state. Public TLS + WebSocket termination stays with your existing front.

**nginx instead of Caddy.** `nginx-reverse-proxy.conf.example` terminates TLS
and proxies the `/ws` WebSocket to `127.0.0.1:8765`. Keep it loopback-only:
the relay trusts forwarded transport metadata only from loopback peers.

**Mainland-China mirrors.** The Docker build defaults to PyPI.org. Behind the
GFW, build with Aliyun as the primary index and TUNA as the fallback (both
carry the sdist-only `http-ece` wheel):

```bash
docker build -f deploy/Dockerfile \
--build-arg PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple \
--build-arg PIP_EXTRA_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \
-t cc-remote-relay .
```

## Native terminal coordination

- **Claude Code:** run `claude` directly for the untouched official process;
Expand Down
44 changes: 44 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# cc-remote Relay container.
#
# The official deploy is a systemd venv + managed Caddy (see deploy/README.md).
# This compose file runs the same relay inside a container for isolated,
# memory-bounded VPSes, or for any host that already terminates TLS itself.
#
# Public TLS/WebSocket termination is expected to stay with nginx or another
# host reverse proxy: the relay is published only to the host loopback here,
# and the relay only trusts forwarded transport metadata from loopback peers.
# See nginx-reverse-proxy.conf.example for the nginx alternative to Caddy.
#
# Usage (from this deploy/ directory):
# cp env.relay.docker.example env.relay # then fill in the secrets
# docker compose up -d --build
# curl https://your-domain/healthz # -> {"ok":true,...}
services:
relay:
build:
context: ..
dockerfile: deploy/Dockerfile
image: cc-remote-relay:latest
container_name: cc-remote-relay
restart: unless-stopped
# Host networking so the relay sees loopback peers. The relay only trusts
# forwarded transport metadata (X-Forwarded-Proto / X-Forwarded-For) from
# 127.0.0.1 and ::1; the Docker bridge gateway is not one of those, so a
# normal `ports:` mapping would make it ignore the proxy headers and reject
# the HTTPS PUBLIC_ORIGIN. With host networking the relay binds loopback
# only (RELAY_HOST=127.0.0.1), which the host nginx/Caddy reaches directly.
# Linux hosts only; the `ports:` mapping is ignored under host networking.
network_mode: host
env_file:
- env.relay
volumes:
# Persistent device registry + Web Push subscription databases. The named
# volume inherits /app/state ownership from the image (the ccremote
# user), so no host-side chown is needed.
- relay-state:/app/state
read_only: true
tmpfs:
- /tmp

volumes:
relay-state:
36 changes: 36 additions & 0 deletions deploy/env.relay.docker.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# deploy/env.relay — environment for the Docker container (docker-compose.yml).
#
# cp env.relay.docker.example env.relay # then fill in the secrets below
#
# Generate each secret with: openssl rand -hex 32
# setup-vps.sh rejects placeholders, LOGIN_PASSWORD shorter than 16 characters,
# and SESSION_SECRET / WRAPPER_TOKEN shorter than 32 characters. The relay
# enforces the same rules at startup, so the container will fail fast on any
# placeholder left behind.
#
# The Dockerfile already sets the container-appropriate defaults, so these do
# not need to appear in this file:
# RELAY_HOST=127.0.0.1 (host networking; loopback-bound, see compose)
# RELAY_PORT=8765
# WEB_STATIC_DIR=/app/web/dist
# PUSH_DB_PATH=/app/state/relay-push.sqlite3
# DEVICE_DB_PATH=/app/state/relay-devices.sqlite3
# SESSION_TTL_SECONDS=604800
# Override any of them here if you need a different value.

# Exact browser origin allowed to open a cookie-authenticated WebSocket.
PUBLIC_ORIGIN=https://cc-remote.example.com
# Leave at 0 for the normal domain + TLS path (see the nginx example / Caddy).
ALLOW_INSECURE_HTTP=0
# Web login password (what you type in the browser to log in). REQUIRED.
LOGIN_PASSWORD=REPLACE_WITH_A_STRONG_PASSWORD
# HMAC secret for signing web session tokens. REQUIRED.
SESSION_SECRET=REPLACE_WITH_openssl_rand_hex_32
# Must match the wrapper's WRAPPER_TOKEN.
WRAPPER_TOKEN=REPLACE_WITH_STRONG_WRAPPER_TOKEN
# Optional durable Web Push completion notifications. Configure all three
# VAPID values; keep the private key readable only by the relay service user.
# PUSH_VAPID_PUBLIC_KEY=REPLACE_WITH_VAPID_PUBLIC_KEY
# PUSH_VAPID_PRIVATE_KEY=REPLACE_WITH_VAPID_PRIVATE_KEY
# PUSH_VAPID_SUBJECT=mailto:admin@example.com
LOG_LEVEL=INFO
8 changes: 8 additions & 0 deletions deploy/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ if [ "$system" = linux ] && [ "$(id -u)" -ne 0 ]; then
if [ -n "${CC_REMOTE_LOGIN_PASSWORD_FILE:-}" ]; then
sudo_env+=(CC_REMOTE_LOGIN_PASSWORD_FILE="$CC_REMOTE_LOGIN_PASSWORD_FILE")
fi
# Preserve mirror-acceleration variables for the bundled uv across sudo.
# Without this, `export UV_DEFAULT_INDEX=... ./install.sh wrapper ...` on
# Linux silently loses the mirror because sudo filters the environment.
for uv_var in UV_DEFAULT_INDEX UV_PYTHON_INSTALL_MIRROR; do
if [ -n "${!uv_var:-}" ]; then
sudo_env+=("$uv_var=${!uv_var}")
fi
done
sudo env "${sudo_env[@]}" \
"$installer" "$bundle" "$@"
else
Expand Down
62 changes: 62 additions & 0 deletions deploy/nginx-reverse-proxy.conf.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# cc-remote Relay behind nginx.
#
# The official deploy uses a managed Caddy that terminates TLS and reverse
# proxies the WebSocket (deploy/Caddyfile). If you already run nginx as the
# only front on this host, use this file as the alternative. The relay
# deliberately trusts forwarded transport metadata only from loopback peers, so
# nginx MUST run on the same host and proxy to the relay's loopback address
# (with the Docker compose file, that is 127.0.0.1:8765 on host networking).
#
# Bootstrap on a fresh host:
# 1. Point an A/AAAA record for cc-remote.example.com at this VPS and open
# 80/443.
# 2. Install this file with ONLY the HTTP block active (the 443 block below
# is commented out until a certificate exists - otherwise `nginx -t`
# fails before the ACME challenge can even be served).
# 3. Issue the certificate: `certbot certonly --webroot -w /var/www/html
# -d cc-remote.example.com`, then uncomment the 443 block (or let
# `certbot --nginx` fill it in once the challenge can be served).
# 4. `nginx -t && systemctl reload nginx`.

server {
listen 80;
listen [::]:80;
server_name cc-remote.example.com;

# Serves the ACME challenge so certbot can complete the certificate
# issuance before the TLS block below is enabled.
location /.well-known/acme-challenge/ {
root /var/www/html;
}

location / {
return 301 https://$host$request_uri;
}
}

# TLS block. Keep it commented out until the certificate exists (see the
# bootstrap steps above), then uncomment it - or let certbot --nginx manage it.
#server {
# listen 443 ssl;
# listen [::]:443 ssl;
# server_name cc-remote.example.com;
#
# ssl_certificate /etc/letsencrypt/live/cc-remote.example.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/cc-remote.example.com/privkey.pem;
#
# # The /ws WebSocket carries wrapper and browser connections, so the upgrade
# # headers and long timeouts are required - keep-alive settings alone are
# # not enough.
# location / {
# proxy_pass http://127.0.0.1:8765;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_read_timeout 3600s;
# proxy_send_timeout 3600s;
# }
#}
Loading