Exposure
An endpoint maps a path prefix to a backend. Without the keys on this page it forwards everything the backend serves under that prefix, on every host of every route that lists it. Four sets of keys narrow that down:
| Question | Keys |
|---|---|
| Which paths of the backend does the endpoint publish? | allow, deny on an endpoint or a backend |
| Is it for the internet or for the inside? | zone on entrypoints and domainmaps, exposure on endpoints, sensitive on backends |
| Who may call it? | cors, sources, csrf on an endpoint |
| Who must the backend be, and who does it see? | identity on a backend, gatewayrole in the bootstrap |
gateway.svc config generate exposure prints a complete example to start from.
Every check runs when the configuration loads, on boot and on every reload. A configuration that breaks a rule is refused as a whole. On boot the gateway does not start; on a reload it keeps serving the previous configuration.
Paths and methods
Section titled “Paths and methods”endpoints: - name: to-vault-jwks path: { prefix: /vault/ } backend: vault exposure: both allow: # only these reach vault.svc - { path: /.well-known/jwks.json, methods: [GET] } middlewares: - replaceRegexPrefix: { regex: "^/vault/(.*)", replacement: "/${1}" }
- name: to-console path: { prefix: /munin/ } backend: munin allow: - "/observe/ui/**" # a bare string allows every method - { path: "/observe/api/*/series", methods: [GET, POST] } deny: # refused even when allowed - "/observe/ui/admin/**"Patterns are relative to the endpoint’s prefix. /.well-known/jwks.json under /vault/ publishes
/vault/.well-known/jwks.json. When the endpoint strips its prefix, that is also the path the backend
sees, so patterns read like the backend’s own routes.
| Pattern | Matches | Does not match |
|---|---|---|
/health | /health | /health/, /healthz |
/items/* | /items/42 | /items/, /items/42/tags |
/files/*.json | /files/a.json | /files/a/b.json |
/observe/ui/** | /observe/ui/, /observe/ui/app.js | /observe/ui |
/a/**/b | /a/b, /a/x/y/b | /a/bc |
*matches within one path segment,**matches any number of segments. Everything else is literal.- Methods:
GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS.- Listing
GETallowsHEADtoo. - When the endpoint has CORS,
OPTIONSis added, so browser preflight reaches the CORS answer. - A pattern without
methodsallows every method.
- Listing
denywins overallow. With onlydeny, the whole prefix is published except the listed holes. Useallowfor anything sensitive: a deny list refuses only what it names.- On a backend,
allowanddenyare the default for every endpoint that targets it. An endpoint’s ownallowreplaces the backend’s.denylists add up, so an endpoint never drops a backend’s deny. - Everything else under the prefix answers
404 page not found, the same answer as a path no route matches. A refused request does not fall through to a shorter prefix on the same host, such as a site served at/. - Paths are matched after
/./,/../and//are resolved, with reserved characters still encoded:/vault/.well-known%2Fjwks.jsonis not/vault/.well-known/jwks.json.
The configuration is refused if a pattern:
- does not start with
/; - puts
**inside a segment (/a**b); - has a
.or..segment; - lists an unknown method;
- is an empty list, or
allow:with no value.
# bootstrapentrypoints: - { name: websecure, address: ":443", zone: external } - { name: websecureinternal, address: "10.0.0.5:8443", zone: internal, trustedips: [10.0.0.0/24] }
tlsoptions: - kisaitlsinternal: clientAuth: { clientAuthType: RequireAndVerifyClientCert }
# routingdomainmaps: - { name: acme, zone: external, domains: [app.acme.com], customer: acme, environment: prod, product: forge, tenant: main } - { name: acme-internal, zone: internal, domains: [app.acme.internal], customer: acme, environment: prod, product: forge, tenant: main }
backends: - backend: { name: config, urls: [https://127.0.0.1:7123] } - backend: { name: forge-api, urls: [https://10.0.0.11:7102] }
endpoints: - { name: to-config, path: { prefix: /config/ }, backend: config } # internal: config.svc is sensitive - { name: to-api, path: { prefix: /api/ }, backend: forge-api, exposure: both }
routes: - { name: acme-public, domainmap: acme, entrypoint: websecure, certresolver: letsencrypt, endpoints: [to-api] } - { name: acme-internal, domainmap: acme-internal, entrypoint: websecureinternal, tlsoptions: kisaitlsinternal, endpoints: [to-api, to-config] }| Key | On | Values | Default |
|---|---|---|---|
zone | entrypoint, domainmap | external, internal | none |
exposure | endpoint | internal, external, both | both, or internal when the backend is sensitive |
sensitive | backend | true, false | true for backends named vault, horus, config or meta, false otherwise |
A route’s zone is its entrypoint’s, else its domainmap’s. With no entrypoints: block, the listeners
built from port and internal.port are websecure (external) and websecureinternal (internal).
Sensitive backends are matched by name, including a name qualified by a routing file’s namespace
(app.vault).
The configuration is refused when:
- an endpoint’s
exposuredoes not include the zone of a route that lists it. A sensitive backend’s endpoint on an external route is the common case. To publish part of one, writeexposure: bothand anallowlist, asto-vault-jwksdoes above; - an endpoint writes
exposure: internalorexternalon a route whose entrypoint and domainmap declare no zone. A sensitive endpoint without a written exposure on such a route is a warning; - an internal domainmap is on an external entrypoint, or the reverse;
- an internal entrypoint binds every interface (
:8443,0.0.0.0:8443,[::]:8443). Bind the private or loopback address; - a route on an internal entrypoint has no
tlsoptions, has a profile whoseclientAuth.clientAuthTypeis notRequireAndVerifyClientCert, or usestlspassthrough.
Routes in the internal zone pass the caller’s verified client certificate on to the backend and are not anonymously tracked.
Callers
Section titled “Callers”endpoints: - name: to-console path: { prefix: /munin/ } backend: munin cors: false # or a map: replaces the domainmap's corsconfig sources: [10.0.0.0/8, 203.0.113.7] # client addresses or CIDRs
- name: to-iam path: { prefix: /account/ } backend: iam csrf: trustedorigins: [https://partner.example.com] bypass: [/saml/acs, /oidc/callback]
- name: to-public-api path: { prefix: /public/ } backend: public-api csrf: falseCORS per endpoint
Section titled “CORS per endpoint”cors takes the same keys as a domainmap’s corsconfig and replaces it for this endpoint. cors: false
sends no CORS headers. Without cors, the endpoint answers with its route’s domainmap corsconfig:
an endpoint listed on several routes answers each with that route’s own.
Client address ranges
Section titled “Client address ranges”sources lets through only the listed addresses and CIDRs. Everyone else gets the same 404 as a
refused path.
- The client is the connecting address.
- When the connecting address is one of the entrypoint’s
trustedips(a proxy in front of the gateway), the client is instead the right-mostX-Forwarded-Forentry that is not a trusted proxy. X-Forwarded-Forfrom an untrusted peer is discarded before this check, so a client cannot write its way in.
Cross-origin writes
Section titled “Cross-origin writes”The check is on for every endpoint. A browser request that changes state (any method but GET,
HEAD and OPTIONS) from another origin is refused with 403.
- A request is same-origin when
Sec-Fetch-Sitesays so, or, from browsers without it, whenOriginmatches the request’s host. - Requests that carry neither header, from curl, SDKs, services and webhooks, always pass.
- Trusted origins are the endpoint’s CORS origins (exact and regex) plus
csrf.trustedorigins. csrf.bypasstakes path patterns, written likeallowpatterns, for cross-site form posts that are expected: a SAML assertion consumer, an OIDCform_postcallback.csrf: falseturns the check off. CORS that allows every origin (*) also turns it off for that endpoint, with a warning at load.
Backend identity
Section titled “Backend identity”With zerotrust: true the gateway dials every backend over mutual TLS and accepts any server certificate
from the installation’s CA. identity pins one backend:
backends: - backend: name: vault urls: [http://127.0.0.1:9999] identity: "spiffe://example.com/dc1/c1/vault/*"- The backend’s server certificate must carry this URI SAN. A trailing
/*matches exactly one more path segment, the instance. - A server with another identity fails the handshake: the request is answered
502and nothing reaches the server. identityneedszerotrust: true. Without it the configuration is refused.
The caller a backend sees
Section titled “The caller a backend sees”Services read their caller from the client certificate of the connection. Through the gateway, that connection is the gateway’s:
- Inbound certificate headers never pass. Any
cert_*orcert-metaheader a client sends is removed on every route. In the internal zone the gateway then sets them from the client certificate it verified. - A gateway role tells the backend the request was relayed. With a credential whose URI SAN is
spiffe://<trust-domain>/gateway/<name>, a service takes the caller from the gateway’s certificate headers: the verified client in the internal zone, nobody in the external zone. Without one, the service sees the gateway itself as the caller, and the gateway warns at boot under zerotrust.
# bootstrapgatewayrole: cert: /etc/gateway/role.pem key: /etc/gateway/role.keyThe files are re-read when they change.
What a configuration publishes
Section titled “What a configuration publishes”gateway.svc config exposure -f .gateway.yamlzone=external route=acme-public entrypoint=websecure hosts=app.acme.com to-api -> forge-api /api/** to-vault-jwks -> vault GET,HEAD /vault/.well-known/jwks.jsonzone=internal route=acme-internal entrypoint=websecureinternal hosts=app.acme.internal to-api -> forge-api /api/** to-config -> config /config/**The command loads the bootstrap and its routing directory, applies every check on this page, and prints each route’s endpoints and paths by zone. Nothing is started. It exits non-zero when the gateway would refuse the configuration, so it can run in a pipeline before a deploy.
The same table is in the log after every load, and two metrics follow it:
| Metric | Type | Attributes |
|---|---|---|
meili.gateway.exposure | gauge, one series per published surface (value 1) | zone, route, entrypoint, hosts, endpoint, backend, paths |
meili.gateway.refused_requests | counter | route, endpoint, reason (path, source, csrf) |
Upgrading a deployment
Section titled “Upgrading a deployment”A configuration that loaded on an earlier gateway can be refused by this one. Before upgrading:
- Run
gateway.svc config exposure -f <bootstrap>with the new binary against the live configuration. - If it reports a sensitive backend on an external route, remove that endpoint from the external routes.
To keep part of it public, give the endpoint
exposure: bothand anallowlist. - Declare
zoneon every entrypoint and domainmap. Bind internal entrypoints to a private or loopback address, and give their routes atlsoptionsprofile withRequireAndVerifyClientCert. - Review the boot warnings: sensitive endpoints without
allow, routes with no zone, and a missinggatewayroleunder zerotrust.
See also
Section titled “See also”- Security: tenancy, forwarded headers, token verification
- Entrypoints: listeners, trusted IPs, zones
- Configuration: every key in one place
- Operations: reload behaviour and warnings