scroll # portal-ingress — Chronicle **Day 355** —portal-ingress conceived, designed, and builtin one session. ## Why axum over pingora/sozuThree candidates evaluated: - **Pingora**(Cloudflare) — most capable proxy frameworkbut runs as a separate daemon. Introduces asecond process, a second config layer, nonative Things integration. - **sozu** —nginx drop-in with hot-reload but still aseparate config file daemon. Same problem: twosystems to keep in sync. - **axum + hyper**— Portal already runs axum 0.7. hyper 1.x isa transitive dep. Adding a proxy layer to theexisting binary means: one process, one DBpool, one config source (route Things), zerointer-process coordination. **Decision:**extend Portal. The ingress is not a sidecar— it IS Portal, listening on a second port.## How route Things map to nginx locationblocks nginx `server {}` block → one routeThing (`sapphire=route`). | nginx | routeThing gem | |-------|----------------| |`server_name` | `citrine.host` (array,wildcard supported) | | `location /` |`citrine.path` | | `proxy_pass` |`citrine.upstream` | | `proxy_*` handler |`ruby.handler = "proxy"` | | `root` +`try_files` | `ruby.handler = "static"` | |`return 301` | `ruby.handler = "redirect"` | |per-server SSL cert | `obsidian.cert` → certThing UUID | | (implicit) node |`citrine.hearth` | Route Things live in the`route` ring. Priority (`topaz.priority`)resolves overlaps — higher wins. Pathspecificity breaks ties (longer path prefixwins). ## Cert-as-Thing lifecycle ``` RouteThing (sapphire=route,citrine.host=cocotrix.stream, ruby.tls=auto)↓ ACME manager reads domain list from routeThings ↓ Runs HTTP-01 challenge (tokenwritten to ChallengeStore, served on :80) ↓Let's Encrypt validates, issues cert ↓ certstored as Thing (sapphire=cert,onyx=cocotrix.stream) pearl.cert = chain PEMpearl.key = private key PEM topaz.expiry =unix timestamp ↓ CertStore (in-memory)loaded, SniFinder resolves at TLS handshaketime ↓ Daily renewal check: expiry - now <30 days → re-provision ``` ACME accountcredentials stored as `sapphire=acme-account`Thing (`pearl.credentials=<json>`). Activatevia `PORTAL_ACME=1`. ## Env vars | Var |Effect | |-----|--------| |`PORTAL_INGRESS_PORT=4444` | HTTP proxy ontest port (parallel run) | |`PORTAL_INGRESS_HTTP=1` | Bind :80 — ACMEchallenges + HTTPS redirect | |`PORTAL_INGRESS_TLS=1` | Bind :443 — TLStermination via cert Things | |`PORTAL_ACME=1` | Enable cert managerbackground task | | `PORTAL_ACME_EMAIL` |Contact email for Let's Encrypt account | |`PORTAL_ACME_STAGING` | Use Let's Encryptstaging (testing) | | `PORTAL_NODE_NAME` |Node identity for hearth-based route filtering| ## nginx-mesh gaps retired - **nm-001**(cert SAN expansion) — obsolete. ACMEmanager provisions certs for all domains inroute Things automatically. No manual certrenewal or SAN list needed. - **nm-002**(nginx server_name expansion) — obsolete.Route Things replace nginx vhosts.Portal-ingress reads from DB; no config fileto update. - **nm-tendril** (ingress eventobservability) — fulfilled. pi-006 proxyhandler logs upstream errors; moon emissioncan be added to `proxy_handler` as a one-linerwhen the event-stream FID lands. ## Sunsetsequence (per-domain) 1. Add route Thing fordomain (`sapphire=route`, `ruby.tls=auto`) 2.Set `PORTAL_ACME=1`, let cert managerprovision cert Thing 3. Set`PORTAL_INGRESS_HTTP=1` (nginx steps off :80for that domain, or nginx proxies ACME path)4. Set `PORTAL_INGRESS_TLS=1` — Portal binds:443 5. Remove nginx vhost for domain 6.Repeat per domain until nginx has nothing left→ stop nginx ## Files -`src/server/ingress.rs` — RouteTable, proxyhandler, ChallengeStore, SniFinder,:80/:443/:XXXX servers - `src/server/acme.rs`— ACME cert manager, cert Thing CRUD -`src/server/mod.rs` — startup wiring(env-gated ingress spawning)