# Caddyfile for the relay-shell HTTP transport.
#
# relay-shell binds 127.0.0.1:8080 by design. Caddy terminates TLS via ACME
# (Let's Encrypt by default, ZeroSSL fallback), restricts the source to an
# explicit CIDR allowlist, sets security headers, and reverse proxies to the
# loopback MCP port.
#
# Automated provisioning and renewal are handled by Caddy's built-in ACME
# client; no cron, no certbot. Certificates persist under Caddy's data
# directory (default `/var/lib/caddy/.local/share/caddy/certificates`) and
# renew in the background well before expiry.
#
# This file is parameterized through environment variables so it can be
# installed unmodified by `deploy/install-edge.sh`:
#
#   RELAY_SHELL_EDGE_DOMAIN        Public hostname presented in the TLS cert
#                                  (must have a DNS A/AAAA record pointing here)
#   RELAY_SHELL_EDGE_ACME_EMAIL    Contact email for ACME registration
#   RELAY_SHELL_EDGE_CLIENT_CIDRS  Space-separated allowlist for tool traffic
#                                  (defaults to loopback only)
#   RELAY_SHELL_EDGE_UPSTREAM      Loopback upstream (default 127.0.0.1:8080)
#   RELAY_SHELL_EDGE_ACME_CA       Optional ACME directory override
#                                  (e.g. Let's Encrypt staging for dry runs)
#
# To stage against the Let's Encrypt staging CA before going live, set
# RELAY_SHELL_EDGE_ACME_CA=https://acme-staging-v02.api.letsencrypt.org/directory.
{
	email {$RELAY_SHELL_EDGE_ACME_EMAIL}
	acme_ca {$RELAY_SHELL_EDGE_ACME_CA:https://acme-v02.api.letsencrypt.org/directory}
}

# A14-D10: the OAuth endpoints are reachable from any address (the client is not yet
# authorized), so they get a small request-body cap. Their bodies are form or JSON
# objects of a few hundred bytes. The MCP path below is CIDR-restricted and is capped by
# the relay itself (RELAY_SHELL_MAX_INPUT), not here.
(oauth_public) {
	request_body {
		max_size 64KB
	}
	reverse_proxy {$RELAY_SHELL_EDGE_UPSTREAM:127.0.0.1:8080} {
		header_up Host {upstream_hostport}
	}
}

{$RELAY_SHELL_EDGE_DOMAIN} {
	# EDGE-1: OAuth browser/redirect, discovery, dynamic client registration,
	# token exchange, and protected-resource metadata are handled before the
	# CIDR block so remote clients can reach the OAuth flow. MCP transport
	# remains CIDR-restricted even when upstream OAuth is disabled.
	handle /authorize {
		import oauth_public
	}
	handle /.well-known/oauth-authorization-server {
		import oauth_public
	}
	handle /.well-known/oauth-protected-resource* {
		import oauth_public
	}
	handle /register {
		import oauth_public
	}
	handle /token {
		import oauth_public
	}
	# EDGE-3: RFC 7009 token revocation. The OAuth provider enables revocation
	# (RevocationOptions), so /revoke is advertised in the discovery metadata
	# (revocation_endpoint). A remote client that obtained a token through the
	# public flow above must be able to revoke it — token self-revocation is the
	# client's incident-response path for a leaked bearer token. Revocation
	# requires presenting the token itself (RFC 7009), so public reachability is
	# safe, exactly like /token. Without this handle the CIDR block below returns
	# a flat 403 and the client cannot revoke.
	handle /revoke {
		import oauth_public
	}
	# MCP and all other operational paths are CIDR-restricted. Override
	# RELAY_SHELL_EDGE_CLIENT_CIDRS for any extra operational paths you expose.
	@blocked not remote_ip {$RELAY_SHELL_EDGE_CLIENT_CIDRS:127.0.0.1/8 ::1}
	handle @blocked {
		respond "Forbidden" 403
	}

	reverse_proxy {$RELAY_SHELL_EDGE_UPSTREAM:127.0.0.1:8080} {
		header_up Host {upstream_hostport}
	}

	header {
		Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "no-referrer"
		# EDGE-2: restrict the authorization page to same-origin resources and
		# block framing/base-tag abuse; JSON protocol responses are unaffected.
		Content-Security-Policy "default-src 'self'; frame-ancestors 'none'; base-uri 'none'"
		-Server
	}

	log {
		output file /var/log/caddy/relay-shell-access.log {
			roll_size 50MiB
			roll_keep 5
		}
		# A14-D10: the /authorize 302 carries the authorization code and state in its
		# Location header; the access log is not a place for them. Authorization and
		# Cookie headers are already replaced by Caddy (log_credentials is off).
		format filter {
			wrap json
			fields {
				resp_headers>Location delete
			}
		}
	}
}
