Configuration Internals
The configuration subsystem lives in the snakeway-conf crate. It is responsible for
reading HCL files from disk, checking them for semantic correctness, and producing the
runtime types that the rest of the proxy reads.
The three-phase pipeline
Every startup (and every config-check run) passes through three ordered phases:
HCL files on disk
│
│ hcl::from_str()
▼
Spec types ← the operator's intent, as parsed
│
│ validate_spec()
▼
ValidationReport ← semantic errors and warnings, aggregated
│
│ lower_configs()
▼
Config types ← the executable runtime representation
Parse is a hard fail. A syntax error or an unrecognised field causes load_config to
return a ConfigError::Parse immediately: no validation report is generated.
Validate never panics and never returns early. Every validator receives a mutable
ValidationReport and appends issues to it. Validators keep running even after earlier
errors are found, so the operator sees all problems in one pass.
Lower converts Spec types to Config types. It assumes validation already passed,
so a failure here (e.g. an unresolvable socket address) is wrapped in a ConfigError
variant and treated as an internal error rather than user-visible feedback.
The entry point for all three phases is load_config in
crates/snakeway-conf/src/loader.rs:
pub fn load_config(root: &Path) -> Result<ValidatedConfig, ConfigError>
ValidatedConfig bundles the fully-lowered RuntimeConfig together with the
ValidationReport. The caller (the snakeway binary) inspects is_valid() and renders
the report before deciding whether to start the server.
Spec types vs Config types
Every setting exists in two parallel structs:
| Layer | Location | Derives | Purpose |
|---|---|---|---|
| Spec | crates/snakeway-conf/src/types/specification/ | Deserialize | Populated directly from HCL; uses user-friendly types |
| Config | crates/snakeway-conf/src/types/runtime/ | Serialize | Resolved, executable form used by the proxy at runtime |
The conversion between them is always a From<FooSpec> for FooConfig or
TryFrom<FooSpec> for FooConfig impl that lives in the runtime module alongside
FooConfig. This keeps the lowering logic close to the type that benefits from it.
Type selection principle
Spec and Config types serve different stages of the pipeline and follow different rules for which Rust types to use:
Spec types use the rawest type that deserializes infallibly from user input. Strings,
numbers, bools, paths -- types where serde will never reject the HCL value. This keeps the
parsing stage permissive and lets the validation stage do all semantic checking with proper
error collection. If a spec field used a parsed type like IpNet or Method, a malformed
value would cause a hard serde deserialization error (single error, stops processing) instead
of being collected into the ValidationReport alongside all other problems.
Config types use the fully parsed, typed form. IpNet, Method, HeaderName,
SocketAddr. This is the executable representation -- downstream code should never need to
re-parse a string it received from config.
Lowering is the natural parsing boundary. The TryFrom conversion transforms
raw-but-validated spec values into typed config values. The validation stage and the lowering
stage both parse the same strings, but for different purposes: validation parses to check
(read-only, collecting errors into ValidationReport), while lowering parses to store
(producing typed values for the runtime). This intentional duplication is a safety net --
if validation passes, lowering should never fail. If it does, the TryFrom error catches
the bug gracefully rather than panicking during a live config reload.
A few concrete differences between the layers:
| Setting | Spec type | Config type |
|---|---|---|
| Bind address | BindInterfaceInput (symbolic: "loopback", "all", or an IP string) | Resolved SocketAddr string |
| TLS | TlsTerminationSpec (enum: Manual or Acme) | TlsTerminationConfig with resolved cert paths |
| Upstreams | Mixed UpstreamSpec (either sock or endpoint) | Separated into UpstreamUnixConfig / UpstreamTcpConfig |
| Services | Array inside IngressSpec | Flattened into HashMap<String, ServiceConfig> |
| CIDR lists | Vec<String> (raw CIDR notation) | Vec<IpNet> (parsed network addresses) |
| HTTP methods | Vec<String> (raw method names) | Vec<Method> (parsed HTTP methods) |
Origin tracking
Every Spec struct carries an origin: Origin field tagged #[serde(skip)]. Origin
records the source file, section name, and optional array index:
pub struct Origin {
pub(crate) file: PathBuf, // e.g. "ingress.d/api.hcl"
pub(crate) section: String, // e.g. "service"
pub(crate) index: Option<usize>, // e.g. Some(0) for the first service block
}
The loader injects Origin values immediately after deserialisation: before any
validation runs. Every ValidationIssue carries the Origin of the spec that triggered
it, so error messages can point at a specific file and block rather than just a field name.
File discovery
snakeway.hcl is the entrypoint. It contains an include block with two glob patterns:
server {
version = 1
}
include {
devices = "device.d/*.hcl"
ingresses = "ingress.d/*.hcl"
}
discover() in crates/snakeway-conf/src/discover.rs resolves each pattern relative to
the config root and returns an ordered list of paths. Ordering is deterministic
(lexicographic within each directory), which matters for listener naming.
ValidateSpec trait
Spec types implement the ValidateSpec trait to validate their own field-local invariants:
pub trait ValidateSpec {
fn validate(&self, origin: &Origin, report: &mut ValidationReport);
}
Field-local means single-field checks: range validation, format checks, path existence.
These live in crates/snakeway-conf/src/validation/spec_impls/, organised by domain
(server.rs, ingress.rs, service.rs, device.rs).
Cross-field checks (e.g. "HTTP/2 requires TLS") and cross-file checks (e.g.
"duplicate bind addresses across ingresses") remain in the centralized validators under
validation/single_file/ and validation/multi_file/. The centralized validators call
spec.validate(origin, report) first to run field-local checks, then perform their own
relational checks.
The origin parameter is passed explicitly because nested specs (e.g. AcmeServerSpec)
do not carry their own Origin field. The parent passes its origin down.
Validation in depth
validate_spec in crates/snakeway-conf/src/validation/validate.rs is the single
orchestration point:
pub fn validate_spec(
server: &ServerSpec,
ingresses: &[IngressSpec],
devices: &[DeviceSpec],
) -> ValidationReport
Internally it runs two categories of checks:
Single-file validation
Each spec is validated in isolation against its own fields. Validators live in
crates/snakeway-conf/src/validation/single_file/:
validate_version: must be1. If it fails, all other validation is skipped (a version mismatch means the entire schema could be wrong).validate_server: PID file parent directory, CA file PEM validity, thread count range, ACME directory URL format, contact emails, cert store, renewal window.validate_ingresses: bind address validity and uniqueness, TLS cert/key file existence, HTTP/2 ↔ TLS dependency, redirect ↔ TLS dependency, redirect status codes, connection filter CIDR syntax, upstream weights and addresses, route host lists, WebSocket ↔ HTTP/2 conflict.validate_devices: WASM file path existence, GeoIP database paths, trusted proxy CIDR syntax, network policy allow list, HTTP method and header name syntax.
Multi-file validation
Some invariants span multiple files and can only be checked once the full set is known.
These live in crates/snakeway-conf/src/validation/multi_file/:
validate_tls: if any ingress uses ACME TLS,server.tls_automationmust be configured. Ifserver.tls_automationis configured but no ingress has a TLS listener, a warning is emitted.
ValidationReport and ValidationIssue
ValidationReport accumulates issues through two primitives:
report.error("message", & origin, Some("help text"));
report.warning("message", & origin, None);
For each distinct error kind there is also a typed helper method on ValidationReport:
for example:
report.http2_requires_tls( & addr, & bind.origin);
report.acme_tls_requires_domains( & bind.origin);
These helpers keep validator code readable and keep error messages consistent across the
codebase. They are defined as impl ValidationReport blocks grouped by the spec they
belong to, at the bottom of crates/snakeway-conf/src/validation/report.rs.
ValidationReport::is_valid() returns true only when errors is empty. Warnings alone
do not block startup.
ConfigError vs ValidationReport
ConfigError and ValidationReport serve different roles:
| Type | When used | What triggers it |
|---|---|---|
ConfigError | Hard failure; load_config returns Err(…) | File I/O failure, HCL syntax error, unresolvable address during lowering |
ValidationReport | Soft accumulation; load_config returns Ok(…) | Semantic violations (wrong value, missing field, cross-file inconsistency) |
A ConfigError means the pipeline cannot proceed at all. A non-empty ValidationReport
means the pipeline completed but the operator should not start the server.
Key files at a glance
| File | Responsibility |
|---|---|
conf/loader.rs | Entry point: load_config, load_spec_files, load_config_from_specs |
conf/discover.rs | Glob-based file discovery |
conf/parse.rs | parse_devices, parse_ingress: HCL to Spec |
conf/lower.rs | lower_configs: Spec to Config |
conf/types/specification/ | All *Spec structs |
conf/types/runtime/ | All *Config structs and their From/TryFrom impls |
conf/validation/validate.rs | validate_spec orchestrator |
conf/validation/single_file/ | Per-file validators |
conf/validation/multi_file/ | Cross-file validators |
conf/validation/report.rs | ValidationReport, ValidationIssue, typed error helpers |
conf/validation/error.rs | ConfigError enum |
conf/validation/validated_config.rs | ValidatedConfig wrapper |