# Spec 16 — Edge Apache do WHM (proxy burro) no layout padrão

> **Dono:** Edge/Infra · **Status:** Proposta · **Plano:** 04
> **Relaciona:** ADR-005, ADR-006, ADR-007, ADR-020, ADR-021, ADR-022
> **Objetivo:** definir o **único** contrato entre o WHM/cPanel e o app **mantendo o layout
> padrão** (`git clone --depth=1` em `/home/<tenant>/public_html`) e fazendo o container
> **ignorar** os arquivos que o painel cria no docroot.

## 1. Princípio

> **O layout do WHM permanece; o container ignora os artefatos do painel por configuração.**

As equipes clonam o repo em `public_html` e o cPanel age sobre a pasta. O cPanel cria
`.user.ini`, `php.ini`, `.htaccess` e `error_log`; o container **não os apaga** — apenas os
ignora. O Apache do WHM é um **proxy burro**; o container (`web`/Caddy + `php-fpm`) é dono do
runtime.

```
/home/<tenant>/public_html/         ← repo clonado (bind mount → /var/www/html)
    ├── public/                     ← Laravel public (servido pelo Caddy)
    ├── .user.ini  php.ini          ← criados pelo cPanel → IGNORADOS
    ├── .htaccess  error_log        ← criados pelo cPanel → IGNORADOS
    └── .well-known/acme-challenge/ ← AutoSSL (servido pelo Apache, não proxiado)
```

## 2. Quem lê o quê e como é ignorado

| Arquivo | Quem lê | Como o container ignora |
|---|---|---|
| `.user.ini` | qualquer PHP-FPM (incl. o nosso) | `user_ini.filename=` (vazio) + `php_admin_value[user_ini.filename]=` |
| `php.ini` (docroot) | pool `ea-php` do host | `PHPRC`/`PHP_INI_SCAN_DIR` fixos no container → não entra no scan |
| `.htaccess` | Apache | Caddy **nunca** lê; Apache não mapeia o app (`ProxyPass`) + `AllowOverride None` |
| `error_log` | Apache/PHP do host | `error_log` em stderr no FPM; Caddy **nega** `error_log`/`*.ini`/dotfiles |

## 3. Include do vhost (o único acoplamento)

Instalar `app.conf` nos **dois** escopos e **também** `app-ssl.conf` no escopo ssl. Os dois
arquivos **não** são cópias um do outro: proto/HSTS vivem só no ssl.

```
/etc/apache2/conf.d/userdata/std/2_4/<acct>/<domain>/app.conf
/etc/apache2/conf.d/userdata/ssl/2_4/<acct>/<domain>/app.conf
/etc/apache2/conf.d/userdata/ssl/2_4/<acct>/<domain>/app-ssl.conf
```

```apache
# app.conf — std e ssl
<IfModule mod_proxy.c>
    ProxyPreserveHost On
    # Desafios de validação ficam no filesystem; TODO o resto de /.well-known
    # (inclui security.txt — RFC 9116) vai ao app.
    ProxyPass        /.well-known/acme-challenge !   # AutoSSL / Let's Encrypt
    ProxyPass        /.well-known/pki-validation !   # DCV de CA comercial
    ProxyPass        / http://127.0.0.1:8080/ connectiontimeout=5 timeout=60 retry=0
    ProxyPassReverse / http://127.0.0.1:8080/
</IfModule>
# Falha segura: sem o proxy, o Apache não serve a raiz do clone (.env/vendor/storage).
<Directory /home/<acct>/public_html>
    AllowOverride None
    Options -Indexes
    Require all denied
</Directory>
<Directory /home/<acct>/public_html/.well-known/acme-challenge>
    AllowOverride None
    Options -Indexes
    Require all granted
</Directory>
<Directory /home/<acct>/public_html/.well-known/pki-validation>
    AllowOverride None
    Options -Indexes
    Require all granted
</Directory>

TraceEnable Off

<IfModule mod_remoteip.c>
    RemoteIPHeader X-Forwarded-For
    RemoteIPInternalProxy 127.0.0.1
</IfModule>

# Camada 1 (ADR-021): slowloris, corpo/URL/cabeçalho e limites por IP (módulos condicionais).
LimitRequestBody   115343360   # 110 MiB (maior hard de mídia: PDF 100 MiB + multipart)
LimitRequestLine   8190
LimitRequestFields 100
LimitRequestFieldSize 8190
<IfModule mod_reqtimeout.c>
    RequestReadTimeout header=10-20,MinRate=500 body=20,MinRate=500
</IfModule>
<IfModule mod_qos.c>
    QS_SrvMaxConnPerIP 50
    QS_SrvMaxConnClose 100
</IfModule>
<IfModule mod_evasive20.c>
    DOSPageCount 20
    DOSPageInterval 1
    DOSSiteCount 100
    DOSSiteInterval 1
    DOSBlockingPeriod 10
</IfModule>
```

No escopo **ssl**, adicionalmente (`app-ssl.conf`, distinto do std):

```apache
<IfModule mod_headers.c>
    RequestHeader set X-Forwarded-Proto "https"
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
</IfModule>
```

Server-wide, no **WHM → Include Editor** (não neste include, cujo contexto é de vhost):

```apache
ServerTokens Prod
ServerSignature Off
```

Regras de ouro:
- Só `acme-challenge`/`pki-validation` **antes** do `ProxyPass /` → **AutoSSL** renova e o
  `security.txt` continua alcançável.
- `Require all denied` na raiz + grant **apenas** nos dois diretórios de DCV: se o include do
  proxy não carregar, o modo de falha nega, não expõe.
- Porta só em **`127.0.0.1`**.
- **Um dono por header:** compressão/CSP/ETag/XFO no `web` (Caddy); HSTS/TLS no **Apache**
  (`app-ssl.conf`). Nunca emitir HSTS nos dois. `preload` só depois de todos os subdomínios
  (`www`/`cms`/`api`/`storage`) em HTTPS.
- Nenhum `mod_deflate`/`mod_brotli` no vhost do app (dupla compressão mangla `ETag`).
- **Comentário só no começo da linha:** o Apache não aceita `#` depois da diretiva — `ProxyPass
  ... ! # x` vira argumento e derruba o `apachectl configtest` (`security-check` + canário cobrem).
- Nunca editar `/etc/apache2/conf/httpd.conf` (o `rebuildhttpdconf` sobrescreve); usar `userdata`.
- WAF (`ModSecurity`) **permanece**; o HTML do editor se resolve com CSP/sanitização.

### 3.1 Canário (antes e depois de instalar)

`tools/whm-canary.sh` fecha o ciclo do include **sem depender de olho**:

```bash
# no host do WHM, com o repo clonado: renderiza os dois arquivos e valida com um Apache 2.4 real
tools/whm-canary.sh --self-test                       # render + configtest + controle negativo
tools/whm-canary.sh --render <acct> <domain>          # grava build/whm/{app.conf,app-ssl.conf}

# depois de instalar em userdata (std: app.conf; ssl: app.conf + app-ssl.conf)
tools/whm-canary.sh --url https://portal.exemplo.gov.br --require-deny
```

O `configtest` usa a **mesma árvore de diretivas do vhost** (com os módulos que o WHM tem) e o
controle negativo injeta `ServerTokens` no vhost: se o Apache **aceitar**, o canário falha —
prova que o exame é real. O `security-check` (dentro do `make check`) proíbe comentário inline
nos dois arquivos, que é o erro que só o `configtest` acusava.


## 4. `web` (Caddy 2) — dono do HTTP do app

`docker/web/Caddyfile`:
```caddyfile
{
    auto_https off          # TLS/AutoSSL terminam no Apache do WHM
    admin off               # sem Admin API (:2019)
    servers {
        trusted_proxies static private_ranges   # IP real do Apache
        trusted_proxies_strict
        timeouts {                               # anti-slowloris + janela de upload
            read_body      300s                  # janela do upload (ADR-026)
            read_body_idle 30s                   # anti-drip (bytes deitada)
            read_header    5s                    # anti-slowloris
            write          300s                  # janela da resposta
            write_idle     30s
            idle           5m
        }
    }
}

:8080 {                     # porta alta → roda non-root
    root * /var/www/html/public
    encode zstd gzip
    request_body { max_size 110MiB } # maior hard de mídia (PDF 100 MiB + multipart; ADR-021)
    # Lote: N requisições de 1 arquivo; NÃO aumentar o body para somar 20–50 imagens.

    # Access log JSON (ADR-024): 1 linha por request. O `filter` apaga a credencial
    # (Cookie/Authorization do request, Set-Cookie da response) e renomeia o
    # `X-Request-ID` da resposta para `request_id` **dentro de `resp_headers`**
    # (`resp_headers.request_id`) — o value é o mesmo id do log do app (correlação;
    # o `rename` NÃO promove a campo de topo, só troca a chave).
    # O path do filter NÃO leva ">" à frente: o delete viraria no-op silencioso.
    log {
        output stdout
        format filter {
            wrap json
            fields {
                request>headers>Cookie delete
                request>headers>Authorization delete
                resp_headers>Set-Cookie delete
                resp_headers>Content-Security-Policy delete
                resp_headers>X-Request-Id rename request_id
            }
        }
    }

    # Não servir artefatos do cPanel nem arquivos sensíveis
    # (a ordem padrão do Caddy põe `respond` antes de `php_fastcgi`)
    @forbidden path *.ini *.log error_log .*
    respond @forbidden 404

    php_fastcgi php-fpm:9000 {  # já inclui try_files + file_server para o Laravel
        read_timeout  360s   # espera o FPM terminar (o FPM mata o worker em 360s)
        write_timeout 360s   # envio do corpo ao FPM
    }

    file_server {
        hide .user.ini .htaccess error_log
        precompressed br gzip       # serve .br/.gz do Vite (ADR-008); zstd é dinâmico (`encode`)
    }

    header {
        -Server
        -X-Powered-By
        X-Content-Type-Options nosniff
        Referrer-Policy strict-origin-when-cross-origin
        Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=()"
        X-Frame-Options SAMEORIGIN
        X-XSS-Protection "0"
        Cross-Origin-Opener-Policy same-origin
        # CORP é exclusivo: same-origin no app, cross-origin no @api
        # CSP (hash-based, sem nonce — ADR-020/022); HSTS é do Apache (app-ssl.conf)
    }
}
```

> Validar sempre antes de subir: `caddy fmt --overwrite /etc/caddy/Caddyfile` e
> `caddy validate --config /etc/caddy/Caddyfile`. Em prod, o HSTS/HTTP2/HTTP3 são do
> **Apache** (termina TLS); o Caddy fala **HTTP/1.1** com o Apache e **FastCGI** com o FPM.
>
> As variantes `.br`/`.gz` são geradas **no build** pelo plugin `concordia:precompress`
> (`vite.config.js`, `node:zlib`, qualidade fixa → saída determinística bit a bit, gzip com
> `MTIME = 0`). O **PHP** nunca comprime em runtime: o Caddy serve a variante pronta e só o que é
> **dinâmico** (HTML das páginas) passa pelo `encode` — por isso o `precompressed` lista apenas o
> que o build realmente gera. O orçamento de bytes é cobrado por
> `tests/Feature/PerformanceBudgetTest.php` (plano 14 §2).

### 4.1 A cadeia de timeout do upload (4 camadas, uma ordem)

O maior upload legítimo é **PDF 100 MiB** (ADR-026) e o limite de corpo é **110 MiB**, em
três camadas. Para que esse limite seja **alcançável**, os prazos das quatro camadas
precisam estar **ordenados** — subir um sem os outros troca o sintoma (504 → HTTP 100)
sem resolver o defeito:

| Camada | Arquivo | Prazo | Papel |
|---|---|---|---|
| Edge (Caddy) | `docker/web/Caddyfile` | `read_body 300s`, `read_body_idle 30s`, `write 300s`, `write_idle 30s` | janela do corpo e da resposta; o `_idle` mata o drip |
| Transporte (FastCGI) | `docker/web/Caddyfile` | `read_timeout 360s`, `write_timeout 360s` | o edge espera o FPM **terminar** (receber 500) em vez de desistir (504) |
| Proxy (Apache) | `docker/web/app.conf` | `ProxyPass … timeout=300` (o `connectiontimeout=5` e o `RequestReadTimeout body=20,MinRate=500` **permanecem**) | prazo do proxy; a defesa por taxa (mata quem envia <500 B/s) continua |
| Aplicação (PHP) | `docker/php-fpm/php.ini` | `max_input_time = 300` (`max_execution_time = 30` **permanece**) | janela de leitura do corpo; o tempo de CPU é outro orçamento |
| Worker (FPM) | `docker/php-fpm/zz-custom.conf` | `request_terminate_timeout = 360s` | teto: um request que estourou não vira worker eterno |

**Invariantes** (afirmadas pelo `tools/security-check.sh`, que confere a **ordem**, não só a
presença — e o `tools/gates-selftest.sh` injeta cada violação para provar que o gate pega):

```
apache.timeout  ≥  caddy.read_body  ≥  php.max_input_time
caddy.write     ≥  caddy.read_body
caddy.fastcgi.read_timeout  ≥  fpm.request_terminate_timeout  ≥  max_input_time + max_execution_time
caddy.read_body ≥ 300 s
```

**Por que 300 s e não menos:** o cliente lento legítimo (500 KiB/s) precisa de ≈225 s para
entregar 110 MiB; a janela tem folga de 33 %. O `MinRate=500` do Apache é o **piso
anti-drip** (bytes/s), não a taxa legítima: ele mata quem envia menos de 500 B/s, mas não
limita quem envia 500 KiB/s. Consequência: a cadeia comporta cliente ≥ ≈385 KiB/s num
upload de 110 MiB — é uma **decisão de produto**, e vale para as quatro camadas.

**Prova de ponta a ponta:** `tools/upload-chain-check.sh` faz upload e download de 100 MiB
a 2 MB/s contra o stack real e falha se o edge cortar (504/100/502) ou se o corpo não
chegar inteiro (o tempo medido tem de bater com a taxa). Entra no `make check` como passo
**gated por staleness** (só roda quando a cadeia muda).

### 4.2 CSP e os dois terceiros permitidos

O portal é `'self'` por padrão; a CSP só abre os dois serviços do ADR-002, e **nenhum deles
gera requisição sozinho**:

| Serviço | Diretivas | Quando carrega |
|---|---|---|
| VLibras | `script-src`, `frame-src`, `connect-src`, `img-src`, `style-src`, `font-src`, `media-src` (`vlibras.gov.br`, `www.vlibras.gov.br`) | clique no botão de a11y |
| Google Analytics | `script-src` `googletagmanager.com`; `connect-src`/`img-src` `*.google-analytics.com` | só com ID configurado no painel |

`@cms` e `@embed` **não** recebem esses hosts. A CSP é estática (sem nonce) e continua
cacheável/ETag. **Sem `report-uri`/`report-to`** por decisão (2026-10-20): as violações são
provadas no e2e (`securitypolicyviolation`) e no `security-check` efetivo; adotar relatório
exige endpoint same-origin com throttle, corpo ≤16 KB e log rotacionado.

## 5. Higiene do PHP-FPM (o que efetivamente "ignora")

`docker/php-fpm/zz-custom.conf`:
```ini
[www]
clear_env = yes
user_ini.filename =
php_admin_value[user_ini.filename] =
php_admin_value[open_basedir]      = /var/www/html:/tmp
php_admin_value[doc_root]          = /var/www/html/public
php_admin_value[session.save_path] = /var/www/html/storage/framework/sessions
php_admin_value[upload_tmp_dir]    = /tmp
php_admin_value[error_log]         = /proc/self/fd/2
```
E no `php.ini` do container (imutável pelo docroot):
```ini
PHPRC=/usr/local/etc/php
PHP_INI_SCAN_DIR=/usr/local/etc/php/conf.d
```

## 5.1. Cadeia de proxy, IP real e host confiável

Toda request passa por **Apache (WHM) → Caddy → FPM**. Sem configurar a cadeia, o Laravel vê o IP
do proxy e gera `http://` — quebra **rate limiting (ADR-021)**, logs e links. Duas pontas:

1. **Caddy** (recebe do Apache): `servers { trusted_proxies static private_ranges }` (aciona
   `{client_ip}`/`X-Forwarded-For`) e `php_fastcgi` repassa o IP real ao FPM.
2. **Laravel** (`bootstrap/app.php`): confiar apenas em faixas privadas, nunca `*`:
   ```php
   ->withMiddleware(function (Middleware $m): void {
       $m->trustProxies(at: array_filter(explode(',', (string) config('app.trusted_proxies'))));
       $m->trustHosts(at: fn () => config('app.trusted_hosts'));
       $m->throttleWithRedis();            // rate limiting no Valkey (ADR-021)
   })
   ```
   ```ini
   TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
   TRUSTED_HOSTS=<dominio>
   ```
   E, em `AppServiceProvider::boot()`, `URL::forceScheme('https')` quando `APP_URL` for https
   (links corretos quando o `X-Forwarded-Proto` não estiver disponível).
   > Após mudar `TRUSTED_PROXIES`/`TRUSTED_HOSTS`, reexecute `php artisan optimize`.

## 6. Storage (local, configurável)

- **Padrão:** `public_html/storage` → `/var/www/html/storage`.
- **Opcional:** `STORAGE_PATH=/srv/<tenant>/storage` (disco maior, fora da cota).
- **PG data:** `${DATA_PATH:-./s3-local}/pgdata` (fora da cota).

## 7. O que o WHM continua fazendo (o preço de mantê-lo)

- Domínios, DNS, **AutoSSL**, e-mail e TLS.
- Servir `/.well-known/acme-challenge` do docroot (`public_html`).
- Encaminhar HTTP(S) via `ProxyPass`.

## 8. Checklist de verificação

- [ ] `caddy fmt` e `caddy validate` sem erro; `web` non-root escutando em `:8080`.
- [ ] `php -i | grep user_ini` → `.user.ini` **desligado**; `php --ini` só o `conf.d`.
- [ ] `public_html/.user.ini` com `display_errors=On` **não** altera o comportamento da app.
- [ ] `public_html/.htaccess` do cPanel **não** afeta a app.
- [ ] `curl -I /error_log`, `/.user.ini`, `/php.ini` → **404**.
- [ ] `tools/whm-canary.sh --self-test` verde **antes** de instalar (configtest real + controle negativo).
- [ ] `tools/whm-canary.sh --url https://<domain> --require-deny` verde **depois** (HSTS, TRACE,
      `Server` sem versão, `security.txt` 200, deny 403, compressão com um dono).
- [ ] `ProxyPass` estreito (`acme-challenge` + `pki-validation`) presente e AutoSSL renova.
- [ ] `GET /.well-known/security.txt` → **200** `text/plain` (não interceptado pelo Apache).
- [ ] `GET /.env`, `/composer.json`, `/artisan` → **403** com o include do proxy desativado.
- [ ] HSTS presente em `https://` e ausente do Caddy; `Server` sem versão (`ServerTokens Prod`).
- [ ] `OPTIONS`/`TRACE` → 405/403; nenhuma compressão no Apache (`Content-Encoding` uma vez).
- [ ] `curl -I https://<domain>/` com headers do ADR-020; CSP sem `unsafe-inline`.
- [ ] **IP real** e `https` corretos: `X-Forwarded-For`/`X-Forwarded-Proto` chegam; `$request->ip()`
      e `$request->isSecure()` corretos atrás do proxy; `TrustHosts` rejeita host estranho.
- [ ] `ss -ltn`: `5432`/`6379` ausentes; `web` só em `127.0.0.1`.
- [ ] Após `rebuildhttpdconf`, o `ProxyPass` continua ativo.
