yerd-proxy
yerd-proxy is the hand-rolled reverse proxy that terminates *.test HTTP/HTTPS traffic and forwards each routed request to its site backend. It is built directly on hyper (HTTP/1.1) and tokio-rustls (TLS termination) - there is no Caddy, no nginx, no embedded web server. The crate owns the accept loops, TLS handshake, request routing, the FastCGI client that talks to PHP-FPM, and the HTTP/1.1 client that talks to FrankenPHP workers.
Its source description is concise:
HTTP/HTTPS reverse proxy for Yerd's
*.testtraffic.
The crate is deliberately decoupled from the rest of the workspace. It depends only on yerd-core (for the Site / SiteRouter types) plus the async/HTTP/TLS stack. It does not depend on yerd-tls or yerd-php - those couplings are inverted through two trait seams (CertStore, BackendResolver) injected by the daemon. See the Crates Overview for how it sits in the dependency graph and The Daemon for the runtime that drives it.
#![forbid(unsafe_code)]
The whole crate is #![forbid(unsafe_code)]. A compile-time guard in lib.rs also asserts ProxyError: Send + Sync + 'static so the error can cross hyper service boundaries and tokio::spawn sites cleanly.
Module map
crates/yerd-proxy/src/
├── lib.rs # re-exports + Send/Sync compile guard
├── backend.rs # Backend enum - where a routed request is forwarded
├── error.rs # ProxyError
├── traits.rs # CertStore + BackendResolver (daemon-injected seams)
├── tls.rs # rustls ServerConfig build + SNI cert resolution
├── server.rs # ProxyServer::serve - accept loops, dispatch
├── pure/ # synchronous, runtime-free, I/O-free helpers
│ ├── mod.rs
│ ├── cgi_params.rs # build the CGI/1.1 param list for FastCGI
│ ├── fcgi_codec.rs # FastCGI record framing (encode/decode)
│ ├── try_files.rs # static-file/directory-index candidate resolution + MIME map
│ └── redirect.rs # HTTP → HTTPS redirect URI builder
└── forward/ # async per-backend forwarding I/O
├── mod.rs # BoxBody + body helpers
├── static_file.rs # serve a real static file, or a directory's index.html/.htm
├── fcgi.rs # FastCGI forwarder (PHP-FPM)
├── http.rs # plain HTTP/1.1 forwarder (FrankenPHP)
└── upgrade.rs # Connection: Upgrade tunnel (WebSocket etc.)The split between pure/ and forward/ is the central design seam: pure/ is synchronous, allocation-only, I/O-free, and exhaustively table-tested; forward/ owns the actual socket reads and writes and the tokio runtime.
The pure/ layer
Everything in pure/ is deterministic and unit-testable without a runtime, sockets, or a backend. This is where the fiddly protocol logic lives.
fcgi_codec - FastCGI record framing
A from-scratch FastCGI codec. It does encode/decode of records only - the forwarder owns the socket. Constants pin the protocol shape:
pub const FCGI_VERSION: u8 = 1;
pub const FCGI_RESPONDER: u16 = 1;
pub const FCGI_MAX_PAYLOAD: usize = 65_535; // content_length is a u16
pub const FCGI_REQUEST_COMPLETE: u8 = 0;The 8-byte record header round-trips through Header::encode/Header::decode:
pub struct Header {
pub version: u8,
pub record_type: RecordType,
pub request_id: u16,
pub content_length: u16,
pub padding_length: u8,
}RecordType is a #[repr(u8)] enum covering BeginRequest (1) through UnknownType (11), with from_u8 for decode. Name/value pairs use FastCGI's length-prefix scheme via encode_name_value: lengths <= 127 take one byte; longer lengths take four bytes with the high bit set on the first. encode_begin_request_body(role, keep_conn) produces the 8-byte BEGIN_REQUEST body (role big-endian, then a FCGI_KEEP_CONN flag byte). EndRequest::decode pulls the app_status (u32) and protocol_status (u8) out of the END_REQUEST body.
Decode is strict: Header::decode returns FcgiError::BadVersion when the version byte is not 1, FcgiError::Short when the slice is not exactly 8 bytes, and FcgiError::UnknownRecordType for unknown type bytes. The tests pin the wire layout exactly - e.g. encoding a 200-byte name yields a four-byte length of [0x80, 0x00, 0x00, 0xC8] (200 | 0x80000000).
cgi_params - building the CGI/1.1 variable list
build_params turns an HTTP request into the Vec<(Vec<u8>, Vec<u8>)> of CGI variables sent in FastCGI PARAMS records:
pub fn build_params(
method: &str,
path_and_query: &str,
headers: &http::HeaderMap,
document_root: &Path,
https: bool,
remote_addr: SocketAddr,
server_addr: SocketAddr,
) -> Vec<(Vec<u8>, Vec<u8>)>Front-controller routing for dynamic requests
For requests that reach FastCGI, the policy is Caddy-style front-controller routing - the request is mapped to the served root's index.php:
SCRIPT_FILENAME = document_root / "index.php"SCRIPT_NAME = "/index.php"PATH_INFO = <original path>REQUEST_URI = <original path_and_query>
Static files are handled before this, by a try_files-style short-circuit (see try_files and static_file): a request that resolves to a real, non-PHP file under the served root is returned directly. A directory-style request (trailing slash, including the site root) with no index.php falls back to that directory's index.html/index.htm next, so a plain static site works without a front controller at all. Only everything else falls through to FastCGI. Arbitrary on-disk .php scripts are still routed through index.php (and PHP source is never served as a static file, directly or via a directory index).
document_root here is the site's served web root
The document_root parameter is the directory actually served, which is the site's web root - not necessarily its project root. The daemon passes site.served_root() (e.g. <project>/public for Laravel), so SCRIPT_FILENAME / DOCUMENT_ROOT resolve under the framework's front-controller directory. build_params itself is web-root-agnostic - it just joins index.php onto whatever root it's given. See Sites → Web root.
Beyond the script vars, build_params emits the standard CGI/1.1 set (GATEWAY_INTERFACE, SERVER_PROTOCOL, REQUEST_METHOD, QUERY_STRING, DOCUMENT_ROOT, REMOTE_ADDR/REMOTE_PORT, SERVER_ADDR/SERVER_PORT, SERVER_SOFTWARE = yerd). HTTPS=on is added only when the request arrived on the TLS listener. Host is surfaced as both SERVER_NAME and HTTP_HOST; Content-Type and Content-Length are emitted un-prefixed (FPM expects them that way). Every other header is translated to the generic HTTP_* form (uppercased, - → _), with Host/Content-Type/Content-Length explicitly skipped so they are not double-emitted.
redirect - HTTP → HTTPS upgrade URI
build_redirect_uri(host, path_and_query, https_port) constructs the Location for the permanent redirect used when a secure site is hit on the plain-HTTP listener:
pub fn build_redirect_uri(host: &str, path_and_query: &str, https_port: u16) -> StringIt strips any inbound port from host (handling both host:80 and bracketed IPv6 [::1]:80), lowercases the host, defaults an empty path to /, and appends :port only when the HTTPS port is not 443. The strip_port helper is careful about IPv6: a bracketed literal keeps everything up to and including the ], and a plain host is only split when it contains exactly one colon (so an unbracketed IPv6 address is left intact). All of this is exercised by a table test (build_table) covering app.test:80, [::1]:80, [2001:db8::1]:80, and the 443-vs-8443 port cases.
try_files - static-file resolution
try_files decides, purely, whether a request could be a static file and what its safe relative path and MIME type would be. It does no I/O - the static_file forwarder does the actual stat/read.
static_candidate(path)maps a URL path to a safe relativePathBuf, orNonewhen the request must go to the front controller instead. It returnsNonefor/, for a directory-style request (trailing slash), and for any traversal attempt. It percent-decodes the path and rejects encoded slashes and NUL bytes, so a decoded segment can never escape the served root.directory_candidate(path)isstatic_candidate's counterpart for directory-index resolution: it maps a directory-style URL path (trailing slash, or the bare root/) to a safe relative directoryPathBuf, orNonefor anything else. Same percent-decoding and traversal rules asstatic_candidate- the two intentionally partition every URL shape between them (a path never satisfies both).is_php_source(path)flags PHP source extensions (php,phtml,php3/php4/php5/php7,phps,pht) so they are never served as static bytes - they fall through to FastCGI.content_type_for(path)maps a file extension to aContent-Typefor the response (a small MIME table, defaulting toapplication/octet-stream).
The Backend enum
Backend (in backend.rs) is the single description of where a routed request goes:
#[non_exhaustive]
pub enum Backend {
/// FastCGI over a Unix domain socket. Unix-only.
PhpFpm { socket: PathBuf },
/// FastCGI over TCP loopback. Required on Windows; allowed elsewhere.
PhpFpmTcp { addr: SocketAddr },
/// Plain HTTP/1.1 to a FrankenPHP worker.
FrankenPhp { addr: SocketAddr },
}Its Display impl produces the stable labels used in logs and ProxyError: fpm-unix:<path>, fpm-tcp:<addr>, franken:<addr>.
Crucially, From<yerd_php::Listen> is intentionally not implemented. The daemon's BackendResolver does that translation, keeping yerd-proxy free of any yerd-php dependency.
FrankenPHP is wired in the proxy but not yet driven
The FrankenPhp variant and its HTTP/upgrade forwarders are fully implemented in this crate, but the daemon's resolver currently produces PHP-FPM backends. Treat the FrankenPHP path as forward-looking plumbing rather than a user-facing feature today.
Trait seams: CertStore and BackendResolver
These two traits (traits.rs) are how the daemon injects behaviour without yerd-proxy depending on yerd-tls or yerd-php.
pub trait CertStore: std::fmt::Debug + Send + Sync + 'static {
fn certified_key(&self, sni_host: &str) -> Option<Arc<rustls::sign::CertifiedKey>>;
}
#[async_trait]
pub trait BackendResolver: Send + Sync + 'static {
async fn backend_for(&self, site: &yerd_core::Site) -> Result<Backend, ProxyError>;
}CertStoreis synchronous because rustls'sResolvesServerCert::resolveis synchronous - it is called inside the TLS handshake. The daemon's impl is expected to hold the active cert material in an in-memory map and refresh it out-of-band. See HTTPS & Certificates.BackendResolveris async and consulted once per request, mapping the routed&Siteto a concreteBackend. The daemon's impl typically callsyerd_php::PhpManager::ensure(site.php())and translates the returnedListeninto aBackend. The implementer note in the source is load-bearing: copy out theSitefields you need before any.await, so the per-request closure doesn't hold a router guard across an await point.
Foreign errors (e.g. PhpError) are boxed into ProxyError::BackendResolver { host, source }, so the proxy never names yerd-php in its type signatures.
TLS and per-SNI cert selection
tls.rs wires CertStore into rustls. build_server_config constructs a rustls::ServerConfig with no client auth and an SniResolver as the cert resolver:
pub fn build_server_config<C: CertStore>(store: Arc<C>) -> Arc<ServerConfig> {
init_crypto_once();
let resolver = Arc::new(SniResolver::new(store));
let config = ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(resolver);
Arc::new(config)
}SniResolver::resolve reads the SNI host from the ClientHello and delegates to CertStore::certified_key. A miss is a hard refusal: it returns None (logged at debug as "SNI miss - dropping connection"), which aborts the handshake rather than presenting a default certificate.
init_crypto_once installs the ring CryptoProvider as the process default exactly once (via OnceLock). This is required because the workspace pins rustls 0.23 with no preinstalled global provider; without it, the first ServerConfig::builder() call would panic. The function is idempotent and tolerates another provider already being installed (multi-process tests, multi-binary daemons) - it only needs some provider in place. ProxyServer::serve calls it before binding anything.
Binding: never bind privileged ports directly
yerd-proxy does not bind ports itself. The caller binds via yerd-platform's PortBinder and hands the listeners in already-bound. This keeps all privileged-socket logic - and the rootless fallback - out of the proxy and in the platform layer. See Elevation & Privileges.
HttpsBinding documents the contract precisely:
pub struct HttpsBinding<C: CertStore> {
/// The bound TCP listener (caller obtained from `PortBinder::bind_pair`
/// and converted via `tokio::net::TcpListener::from_std`).
pub listener: TcpListener,
/// Public port the HTTP→HTTPS redirect should target - not
/// necessarily what `listener.local_addr()` reports. Shared so the
/// daemon can flip it live, without restarting the proxy.
pub public_port: Arc<AtomicU16>,
/// Cert lookup. Arc-wrapped so the SNI resolver can clone cheaply.
pub cert_store: Arc<C>,
}public_port is separate from the listener's local port on purpose: in rootless mode the daemon may bind_pair((80, 443), (8080, 8443)) and end up listening on 8443, but the redirect target must reflect the port clients actually use. The daemon's bind_pair atomically binds the desired HTTP/HTTPS pair, falling back to (8080, 8443) when 80/443 require elevation, then converts each std listener with TcpListener::from_std before passing it in.
It's an Arc<AtomicU16> rather than a plain u16 because the fallback story doesn't end at startup: on macOS, yerd elevate ports installs a pf redirect (80/443 → the bound rootless pair) that goes live immediately, with no daemon restart - see Elevation & Privileges. If the redirect target stayed a fixed u16 captured once in ProxyServer::serve, a browser would keep getting bounced to https://site.test:8443 even after elevation made the plain https://site.test reachable. Instead, the daemon owns a shared cell (DaemonState::redirect_https_port, see yerdd) that a background prober flips between the rootless and well-known port as yerd_platform::PortRedirector::is_active changes, and dispatch loads the current value on every redirect it builds. yerd-proxy itself has no opinion on why the port changes - it just reads whatever the cell holds at request time.
The server: ProxyServer::serve
server.rs is the runtime entry point:
pub async fn serve<R, C, S>(
http_listener: TcpListener,
https: Option<HttpsBinding<C>>,
router: SharedRouter,
backend_resolver: Arc<R>,
shutdown: S,
) -> Result<(), ProxyError>
where
R: BackendResolver,
C: CertStore,
S: Future<Output = ()> + Send + 'static,SharedRouter is Arc<tokio::sync::RwLock<yerd_core::SiteRouter>>. Reads are brief: each request takes a read guard only long enough to resolve(&host) and clone the matched Site (cheap - small strings and PathBufs), then drops the guard before any .await. The daemon is the only writer and swaps the whole router under a write guard when a site is parked/linked/unlinked or its PHP version changes.
Lifecycle
init_crypto_once().- A shutdown task awaits the
shutdownfuture, then callsnotify_waiters()on an internalNotify. - Two accept loops are spawned: one HTTP, one HTTPS (only if
httpsisSome). Each loop is atokio::select! { biased; … }that breaks on the notify and otherwise accepts connections.biasedmakes shutdown take priority over a ready accept. - Each accepted connection is handled in its own
tokio::spawn. The HTTPS path first runsTlsAcceptor::accept; handshake failures are logged at debug and the connection is dropped. - Connections are served with
hyper::server::conn::http1::Builder::new().serve_connection(io, svc).with_upgrades()-with_upgrades()is required for the WebSocket tunnel path.
On shutdown, accept loops stop immediately; in-flight requests run to hyper's default timeouts. serve returns once both accept tasks and the shutdown task have joined.
Per-request dispatch
The hyper service is infallible - internal errors are logged and turned into a 500 so hyper's connection loop survives. dispatch does the real work, in order:
- Host header. Missing or non-UTF-8 →
400 Bad Request("Missing or invalid Host header."). - Route.
router.resolve(&host); no match →404 Not Found("No site matches this Host."). The matchedSiteis cloned and the guard dropped; the request is served fromsite.served_root()(the site's web root, e.g.<project>/public). - HTTP → HTTPS redirect. On the HTTP listener, if
site.secure()is true and aredirect_portis set, return301 Moved PermanentlywithLocationbuilt bybuild_redirect_uri. - Resolve backend via
BackendResolver::backend_for(&site). Errors already in the connect/protocol/resolver family pass through; any other variant is wrapped inProxyError::BackendResolver { host, source }. - Upgrade dispatch. If
upgrade::is_upgrade(headers), forward toupgrade::forwardforFrankenPhp, or return501 Not Implementedfor FastCGI backends (FastCGI cannot model a duplex byte stream). - Static-file short-circuit. For the FastCGI backends (
PhpFpm/PhpFpmTcp),static_file::try_serveis attempted first: a GET/HEAD request that resolves to a real, non-PHP file under the served root - allowing symlinks that resolve anywhere within the site'sdocument_root, not just the served subdirectory - is returned directly with a guessedContent-Type. A candidate that resolves outsidedocument_rootgets an explicit403 Forbiddenfrom yerd-proxy instead of falling through. (FrankenPhpserves its own static files, so this step is skipped for it.) - Directory-index short-circuit. Still FastCGI-only: if
try_servedidn't match,static_file::try_serve_indexis tried next - a GET/HEAD directory-style request (trailing slash, including the site root) with noindex.phpin that directory serves itsindex.html/index.htmdirectly, so plain static sites work with no PHP front controller at all. Samedocument_rootcontainment and403behavior astry_serve. - Normal dispatch. Anything not served as a static file or directory index goes to the front controller:
FrankenPhp→http::forward;PhpFpm/PhpFpmTcp→fcgi::forward.
The Listener::{Http, Https} discriminator is threaded through so the redirect rule and the HTTPS=on CGI var both know which listener the connection arrived on.
The forward/ layer
All response bodies share one type so every path returns the same Response:
pub type BoxBody = http_body_util::combinators::BoxBody<bytes::Bytes, std::io::Error>;with empty_body() (for 301/404/501/101) and bytes_body(&'static [u8]) helpers.
static_file - serving real files
Both lookup functions return a StaticOutcome: Served(Response), NotFound (fall through to the front controller, same as a bare None before this type existed), or SymlinkEscape { requested_path, resolved, allowed_root } (the candidate resolved via symlink to somewhere outside the site's document_root - the caller turns this into a 403 via symlink_escape_response, never a silent fallthrough). The 403 body names only requested_path; resolved and allowed_root go to a tracing::warn! (target yerd_proxy::static_file) instead, since a site can be exposed beyond loopback via yerd-tunnel and absolute local paths shouldn't reach a remote client.
static_file::try_serve is the static short-circuit for the FastCGI backends. Given the served root, the site's document_root, and the request, it:
- asks
try_files::static_candidatefor a safe relative path (NotFoundfor/, directory requests, traversal, or a non-GET/HEAD method); - refuses PHP source (
is_php_source) so a.phpfile is never returned as bytes; - joins the candidate onto the served root, canonicalises, and verifies the result is still inside the site's
document_root- not just the served subdirectory, so e.g. Laravel'spublic/storage -> ../storage/app/publicsymlink is served normally even though it points outsidepublic/. A candidate that canonicalises fine but lands outsidedocument_rootentirely is aSymlinkEscape; a candidate that simply doesn't exist isNotFound, unchanged; - on a hit, reads the file and returns
200 OKwith theContent-Typefromcontent_type_forand theServer: <PROXY_SERVER_ID>header (aHEADreturns an empty body).
static_file::try_serve_index is the directory-index counterpart, tried next when try_serve misses. Given the same served root, document_root, and the request, it:
- asks
try_files::directory_candidatefor a safe relative directory path (NotFoundfor anything that isn't a directory-style request, or a non-GET/HEAD method); - joins the candidate onto the served root, canonicalises, and verifies it's still inside
document_rootand is actually a directory - an escaped directory candidate is reported immediately, since there's no other candidate to fall back to; - defers to the front controller (
NotFound) if that directory containsindex.php- the front controller always wins when present; - otherwise probes
index.html, thenindex.htm: each candidate is joined onto the (already-canonical) directory and re-canonicalised in its own right againstdocument_rootbefore being served. If one candidate escapes but the other is servable, the servable one still wins - aSymlinkEscapeis only reported once every candidate has been tried and none served. - on a hit, serves the file exactly like
try_serve(sameContent-Typelookup,HEADhandling, headers).
A NotFound result here means no index file exists (or index.php won) and the request falls through to fcgi::forward exactly as it did before this short-circuit existed.
fcgi - the PHP-FPM forwarder
fcgi::forward drives the full FastCGI exchange against PHP-FPM:
- Connect.
open_backendopens aUnixStreamforPhpFpm { socket }(Unix only - non-Unix returnsErrorKind::Unsupported) or aTcpStreamforPhpFpmTcp { addr }. The two are unified behind aBackendStreamenum that implementsAsyncRead/AsyncWrite. (FrankenPhpreaching this path is a#[cold]"dispatch bug" error.) - BEGIN_REQUEST with
FCGI_RESPONDER,keep_conn = false,request_id = 1. - PARAMS from
build_params, chunked atFCGI_MAX_PAYLOAD, followed by a zero-length PARAMS terminator. The prelude is flushed before the body is drained. - STDIN. The request body is streamed frame-by-frame, chunked at
FCGI_MAX_PAYLOAD, then a zero-length STDIN terminator. HTTP trailers are dropped (FastCGI cannot represent them). - Read STDOUT/STDERR until
END_REQUEST. Each record's content and padding are read withread_exact; arequest_id != 1yieldsFcgiError::UnexpectedRequestId. Any non-empty STDERR is logged at warn ("FPM stderr"). - Synthesise the response.
parse_cgi_responsesplits the CGI header block at the first\r\n\r\nor\n\n, translatesStatus: NNN [Reason]into the HTTP status (defaulting to200 OK), and copies the rest as the body. Headers that failHeaderName/HeaderValuevalidation are silently skipped.
upgrade_not_supported() is the 501 response for upgrade attempts on a FastCGI backend.
http - the FrankenPHP forwarder
http::forward connects to the FrankenPHP worker over TCP and uses raw hyper::client::conn::http1::handshake rather than hyper-util's pooled legacy client - the comment notes the pooled client has historical upgrade gotchas and doesn't expose the upgraded socket cleanly. The driver connection runs in a detached task; the response body is re-boxed into BoxBody (mapping the hyper body error into io::Error).
upgrade - the WebSocket/Upgrade tunnel
upgrade::is_upgrade detects Connection: Upgrade per RFC 9110 §7.8, handling comma-separated, case-insensitive tokens (keep-alive, Upgrade) and requiring the Upgrade header to be present.
upgrade::forward implements the hyper-1 upgrade dance:
- Capture the client's upgrade future (
hyper::upgrade::on(&mut req)) before moving the request upstream. - Open the backend connection with raw
http1::handshake().with_upgrades(). - Rebuild the upstream request with an
Emptybody (bytes flow through the upgraded socket, not the body) and send it. - Capture the backend's upgrade future from the response.
- Strip hop-by-hop headers (
strip_hop_by_hopremoves the fixed set -connection,proxy-connection,keep-alive,te,transfer-encoding,trailer- plus any tokens listed inConnection:, while preservingUpgrade, then re-inserts a freshConnection: upgrade). - Return the
101to the service so hyper flushes it to the client, then in a detached tasktry_join!both upgrade futures andcopy_bidirectionalthe twoUpgradedstreams (each wrapped inTokioIo).
Errors
ProxyError (error.rs) is #[non_exhaustive] and intentionally not Clone/Eq (it wraps io::Error, hyper::Error, rustls::Error, and a boxed dyn Error). Variants: Accept, BackendResolver { host, source }, BackendConnect { backend, source }, BackendProtocol, Upgrade, Fcgi (#[from] FcgiError), Hyper, and Tls. The daemon translates these to a stable code when crossing the IPC wire - see the IPC Protocol.
Tests and invariants
pure/unit tests pin the wire format and policy:fcgi_codecround-trips headers and verifies short/long name-value encoding;cgi_paramsasserts the Caddy-style mapping, theHTTPS=onrule, andHTTP_*translation;redirectruns the full host/port table.tests/integration_http.rsdrivesProxyServer::serveagainst a fake FastCGI listener and a hyper client, asserting both the round-trip body (hello) and the captured CGI params (REQUEST_METHOD,REQUEST_URI,SCRIPT_NAME,PATH_INFO,QUERY_STRING,SERVER_NAME). It also covers the404(unknown host) and400(missing Host) paths, plain static-file serving, and the directory-index fallback end to end:index.html/index.htmserved when there's noindex.php,index.phpwinning when both are present (asserted via the capturedSCRIPT_NAME, not just the response body), a directory with no index of any kind and a nonexistent directory both still falling through to FastCGI,HEADreturning an empty body, and a symlinkedindex.htmlescaping the served root being refused rather than served.tests/integration_https.rsissues a real CA + leaf fromyerd-tls, serves over TLS with a single-hostCertStore, and drives a rustls hyper client through the full SNI handshake to the fake backend.tests/no_runtime_deps.rsis a dependency-graph guard: it walkscargo metadataand asserts the runtime graph never pullsanyhow, the OpenSSL/native-tls family,hyper-tls,tokio-native-tls, orwebpki-roots, and thathyper,rustls,tokio, andtimeeach resolve to a single version. This keeps the proxy on a pure-Rust, rustls-only TLS stack.
See also
- yerd-tls - the CA and leaf issuance that backs the daemon's
CertStore. - yerd-php - the PHP-FPM supervisor whose
Listenthe daemon translates into aBackend. - yerd-platform -
PortBinder/bind_pairand the rootless port fallback. - yerdd (daemon) - the binary that binds the ports, builds the
CertStore/BackendResolver, and callsProxyServer::serve. - Source: github.com/forjedio/yerd