# Hardening a Public Deployment

> Applies to: ChengOS v0.2.0+ ｜ Last verified: 2026-09-25 ｜ Source: `deploy/HARDENING.md`, `deploy/lib/doctor.sh`, `deploy/.env.example`, `deploy/docker/docker-compose.yml`, `deploy/infra/cloudflare/`

This is the checklist for putting ChengOS on the public internet. It has two routes —
**A. Cloudflare Tunnel** (recommended) and **B. a reverse proxy** — which share the same first
step. The full operator guide, with every command, is `deploy/HARDENING.md`; the helper scripts
are in `deploy/infra/cloudflare/`.

## Know what is exposed

A ChengOS host listens on several ports, and they are not equal:

| Service | Default port | Who should reach it | Why |
|---|---|---|---|
| `cheng-api` | `API_PORT` (19225) | **This host only** | The frontends proxy it. Reached directly, it bypasses every scanner rule, rate limit, and security header the frontends add — and the login endpoint is on it. |
| `ui-server` | `UI_PORT` (8080) | Your proxy or `cloudflared` | The editor and admin console |
| `app-server` | `APP_PORT` (5055) | Your proxy or `cloudflared` | The channel gateway |
| `laya` (optional) | `LAYA_PORT` (1215) | **`cheng-api` only** | Local decision model; unauthenticated unless `LAYA_API_KEY` is set |

Only 80/443, held by `cloudflared` or your reverse proxy, should face the internet. Check the
**effective** ports in your `deploy/.env` rather than assuming the defaults.

A different port number is not a security measure. Internet-wide scanners cover every port in
hours; what protects a port is not being reachable.

## The settings involved

| Key | Controls | Default for a new install |
|---|---|---|
| `BIND_ADDRESS` | Interface the API listens on | `127.0.0.1` |
| `UI_BIND` / `APP_BIND` | Interface the native frontends listen on | **`0.0.0.0`** |
| `TRUSTED_PROXY_IPS` | Proxies allowed to report a visitor's real IP | empty |
| `TRUST_CLOUDFLARE` | Read `CF-Connecting-IP` instead of `X-Forwarded-For` | `false` |
| `CORS_PERMISSIVE` | Allow any origin | `false` |
| `CSP_CONNECT_SRC_EXTRA` | Extra `connect-src` origins | empty |

Two things are easy to miss:

- **`UI_BIND` and `APP_BIND` default to every interface.** Hardening is not automatic: you
  change them in step 1.
- **`.env` is instance-owned and never overwritten by an upgrade.** These defaults apply to new
  installs only. An older installation keeps its old values, and `UI_BIND`, `APP_BIND`, and
  `TRUSTED_PROXY_IPS` may not exist in its `.env` at all — add them by hand.

## Step 1: tighten the listen addresses

In `deploy/.env`:

```bash
# The API is only ever for this host.
BIND_ADDRESS=127.0.0.1

# Where the frontends listen depends on where the proxy is:
#   route A or B1 (proxy on this host)    -> 127.0.0.1
#   route B2 (proxy on another machine)   -> a private or VPN address of THIS host
UI_BIND=127.0.0.1
APP_BIND=127.0.0.1

CORS_PERMISSIVE=false
CORS_ALLOWED_ORIGINS=
```

Then restart and confirm:

```bash
./chengos.sh restart
ss -tlnp | grep -E '19225|8080|5055'    # substitute your API_PORT / UI_PORT / APP_PORT
```

Every line should show `127.0.0.1:<port>`, not `0.0.0.0` or `*`.

**Docker installs ignore these keys.** `docker-compose.yml` publishes every host port to
`127.0.0.1` only (`127.0.0.1:${API_PORT}`, `127.0.0.1:${UI_PORT}`, and so on) and pins the
API's `BIND_ADDRESS` to `0.0.0.0` *inside* its container, because the UI and app containers must
reach it over the Compose network. Do not change that pin.

## Route A: Cloudflare Tunnel

`cloudflared` connects **out** from your host to Cloudflare and requests come back along that
connection, so the host needs **no inbound ports at all**.

1. Install `cloudflared`, then run `cloudflared tunnel login` and
   `cloudflared tunnel create chengos` yourself — they open a browser and issue credentials.
2. `cloudflared tunnel route dns chengos chengos.example.com` creates the proxied CNAME.
3. `./deploy/infra/cloudflare/setup-tunnel.sh --ui chengos.example.com --app app.example.com`
   writes `config.yml` and a systemd unit, reading ports from `.env`. The generated ingress
   publishes the UI server, **never the API**: the UI server proxies `/api` and `/ws` itself.
4. Install the config and the tunnel's `<UUID>.json` under `/etc/cloudflared`, readable only by
   a dedicated `cloudflared` system user — that JSON file is a bearer credential for the tunnel.
5. Start the service and wait for `Registered tunnel connection` in its log.
6. `./chengos.sh cloudflare on` and restart, so the frontends read `CF-Connecting-IP`.
7. Optionally apply edge rules (TLS/HSTS, Bot Fight Mode, WAF, rate limiting) with
   `apply-zone-settings.sh --dry-run`, then without `--dry-run`.
8. **Last**, once the site works through the tunnel, close inbound 80/443 in your firewall.

Free Universal SSL covers one subdomain level: `app.example.com` works,
`app.chengos.example.com` does not, and the failure looks like a TLS error rather than a naming
problem. Do not run `origin-firewall.sh` on this route — there is no inbound traffic to allow.

## Route B: a reverse proxy

**B1 — proxy on the same host.** Traffic to `127.0.0.1` never leaves the kernel, so nothing needs
encrypting and `TRUSTED_PROXY_IPS` can stay empty (loopback is trusted implicitly). The proxy
must forward `Host`, `X-Forwarded-For` (appended, not replaced — ChengOS reads the **last**
entry), `X-Forwarded-Proto` (it decides HSTS), and the WebSocket upgrade headers. Behind
Cloudflare, set the DNS records to Proxied, then restrict the origin to Cloudflare's ranges with
`sync-cf-ips.sh` and `origin-firewall.sh --apply`.

**B2 — proxy on another machine.** A firewall decides who can connect, but not who can read the
proxy-to-origin hop: over the public internet it is plaintext. Use a provider private network in
the same region, or WireGuard/Tailscale. Then:

- Set `UI_BIND` / `APP_BIND` to **this host's** private or tunnel address. Another machine's
  address cannot be bound at all (`EADDRNOTAVAIL`).
- **List the proxy in `TRUSTED_PROXY_IPS`** unless it is on an RFC 1918 address. Tailscale and
  other CGNAT addresses (`100.64.0.0/10`) are *not* RFC 1918. If this is wrong, every visitor is
  attributed to the proxy's address and the whole internet shares one rate-limit bucket —
  silently. The native servers print their effective trust set at startup
  (`Trusted proxies: loopback + RFC1918 + …`).
- Firewall the frontend ports to the proxy's address only.

## Verify with `doctor`

```bash
./chengos.sh doctor
```

`doctor` is read-only: it changes no setting, restarts nothing, and writes no file. It probes the
**running** system rather than reading configuration back, because every failure in this area is
quiet. It reports four sections:

| Section | Checks |
|---|---|
| Listening addresses | The API is loopback-only; the frontends match their role; an enabled Laya does not collide with a ChengOS port and is not reachable without `LAYA_API_KEY` |
| Trust and CORS | `CORS_PERMISSIVE` is off; `TRUSTED_PROXY_IPS` is set when the frontends listen beyond loopback |
| Live frontend behaviour | Scanner paths return 404, SPA deep links return 200, security headers are present, the CSP nonce changes per request |
| Edge | `cloudflared` running (tunnel mode), or a firewall enabled |

The exit code is `0` when there are no failures (warnings do not change it), so `doctor` works as
a deployment gate. Run it after every change here and after every upgrade.

The one thing `doctor` cannot check is public reachability — a host probing itself always
succeeds. From **another** machine, confirm the origin refuses direct connections:

```bash
curl -sv --max-time 5 http://<origin-public-ip>:19225/health   # must time out or be refused
curl -sv --max-time 5 http://<origin-public-ip>:8080/          # route A: must fail too
```

## Troubleshooting

| Symptom | Cause |
|---|---|
| Browser TLS error on a two-level subdomain | Free Universal SSL covers one level only |
| `EADDRNOTAVAIL` at startup | `UI_BIND` names another machine's address |
| Tunnel up, but 502 | Tunnel port differs from `UI_PORT`, or `UI_BIND` is not `127.0.0.1` |
| Every visitor rate-limited as one | `TRUSTED_PROXY_IPS` missing for a public or CGNAT proxy |
| Scanner paths return 200 | The host proxy serves static files itself instead of passing to the UI server |
| Blank page, CSP errors in the console | An external origin missing from `CSP_CONNECT_SRC_EXTRA` |
| Desktop client or remote CLI cannot connect | The API is loopback-only; go through the proxy on 443, where `/api` is forwarded |
| Docker API unreachable after editing `BIND_ADDRESS` | The container pin to `0.0.0.0` was removed from the Compose file |

## Next steps

- [Native Deployment](/docs/deploy-native) — the services you are fronting.
- [Upgrades & Rollback](/docs/deploy-upgrade-rollback) — why `.env` survives upgrades, and what that means for new defaults.
- [Environment Variable Reference](/docs/config-env-reference) — every key used here.
