# Cloudflared ingress edit — append a hostname rule before the catch-all

This is the canonical pattern for adding a new hostname → local port mapping to an existing brand cloudflared `config.yml`. Sourced from the multi-hostname ingress block documented at [`platform/plugins/cloudflare/references/manual-setup.md`](../../../../../platform/plugins/cloudflare/references/manual-setup.md) under the "Multi-ingress: adding hostnames for prototype services" section.

## The config.yml shape

A working brand `config.yml` always looks like:

```yaml
tunnel: <TUNNEL_ID>
credentials-file: <CFG_DIR>/<TUNNEL_ID>.json
ingress:
  - hostname: admin.<BRAND_HOSTNAME>
    service: http://localhost:<PORT>
  - hostname: public.<BRAND_HOSTNAME>
    service: http://localhost:<PORT>
  - service: http_status:404
```

A new prototype rule must land **before** the `http_status:404` catch-all; otherwise cloudflared matches the catch-all first and the prototype hostname returns 404.

## The append pattern

`sed` cannot safely insert before the last block of a multi-line YAML rule (`- service: http_status:404` is one logical entry but the line above it might be a comment or blank). `awk` is the right primitive: scan for the literal `- service: http_status:404` line and insert two lines before it.

```bash
SURFACE="<surface>"            # e.g. landing
PORT="<port>"                  # from <surface-dir>/.port
HOSTNAME="${SURFACE}.${BRAND_HOSTNAME}"

# Idempotency guard — skip if the hostname already appears as an ingress rule.
if grep -qE "^[[:space:]]*-[[:space:]]+hostname:[[:space:]]+${HOSTNAME}\b" "${CFG_DIR}/config.yml"; then
  echo "[prototype-host] ingress-row-exists hostname=${HOSTNAME} — skipping append"
else
  awk -v host="${HOSTNAME}" -v port="${PORT}" '
    /^[[:space:]]*-[[:space:]]+service:[[:space:]]+http_status:404[[:space:]]*$/ {
      print "  - hostname: " host
      print "    service: http://localhost:" port
    }
    { print }
  ' "${CFG_DIR}/config.yml" > "${CFG_DIR}/config.yml.new"
  mv "${CFG_DIR}/config.yml.new" "${CFG_DIR}/config.yml"
fi
```

## Validate, route, reload

```bash
# 1. Validate the YAML and ingress semantics.
cloudflared --origincert "${CFG_DIR}/cert.pem" \
  --config "${CFG_DIR}/config.yml" \
  tunnel ingress validate

# 2. Create the DNS row (CNAME from <hostname> to <TUNNEL_ID>.cfargotunnel.com).
TUNNEL_ID=$(jq -r '.id // .tunnelId' "${CFG_DIR}/tunnel.state")
cloudflared --origincert "${CFG_DIR}/cert.pem" \
  tunnel route dns --overwrite-dns "${TUNNEL_ID}" "${HOSTNAME}"

# 3. Reload the connector so it picks up the new ingress rule.
systemctl --user restart "${BRAND}-cloudflared.service"
```

Each step is its own command, runs sequentially, exits non-zero on failure. The skill propagates the failing command's stderr verbatim — no parsing, no narration ([[feedback_no_stdout_parsing_for_control_flow]]).

## What `--overwrite-dns` does

If a CNAME for `<hostname>` already exists in the account's DNS (e.g. from a prior install), `tunnel route dns` errors with `record already exists`. `--overwrite-dns` replaces the existing record with one pointing at this tunnel. Use it unconditionally — the alternative (probe-then-create) doubles the API surface and races with concurrent DNS edits.

## Why restart, not reload

`cloudflared` watches `config.yml` only when launched with `--config-watch` (off by default in the brand service unit). `systemctl --user restart` is the deterministic way to pick up the new rule. Brief connection blip (~1s) is acceptable because the brand's admin + public hostnames already tolerate the same blip on every cloudflared upgrade.

## Failure points

| Symptom | Root cause | Fix |
|---|---|---|
| `validate: ingress rule N has no service` | `awk` inserted only the hostname line, not the service line | Re-run; the snippet emits both lines atomically per match |
| `validate: failed to parse YAML` | A previous `awk` run left a partial line | `cat ${CFG_DIR}/config.yml` and hand-fix; the skill cannot recover from corrupt YAML |
| `route dns: 1004 invalid hostname` | `<surface>` violates DNS label rules (uppercase, underscore, leading dash) | Re-run with a valid `<surface>`; the skill validates `^[a-z0-9-]+$` before phase 4 |
| `route dns: 9103 unauthorized` | The cert.pem in `${CFG_DIR}` doesn't grant DNS-edit on the zone | Re-run Step 1 of the cloudflare runbook to refresh `cert.pem` |
| Restart succeeds but `curl -I` returns 502 | The dev server isn't actually listening on `<port>` | Phase 5 (systemd) failed earlier; the skill should have caught it. Read `journalctl --user -u ${BRAND}-prototype-<surface>.service -n 50` |
