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. It is built on the primitives provided by confval.
The pipeline
Every startup (and every config-check run) passes through four ordered phases:
Parse is structural and strict. Each file is registered in a SourceMap and parsed with
confval::format::hcl::parse_hcl. Unknown fields, wrong types, missing required fields, and duplicate
blocks are reported with spans. Parsing continues across files: a file whose tree was built keeps
flowing into validation even if some of its fields failed, so the operator sees parse and validation
problems in one pass. Only a file that produced no tree at all (a syntax error) stops the load, and
even then only after every file has been read, so all syntax errors are reported together.
Validate never panics and never returns early. Every validator receives &mut Report and
appends issues to it. Spans come from the Located fields on the specs, so validation works the
same whether the spec was parsed from a file or constructed in code.
Gate: if the report contains any errors after validation, load_config returns
ConfigError::SemanticValidationFailed { report, sources } without lowering. Warnings alone do not
block startup.
Lower converts spec types to config types via the Lower trait. Because the gate ran, the
narrowing conversions inside lowering (string to IpNet, i64 to u16) are safe; a failure here
indicates a missing validation rule, not bad operator input. Lowering errors report through the
same span pipeline (a second gate after lowering returns SemanticValidationFailed), so even a
missing-rule bug renders with a source location.
Entry points
All entry points live in crates/snakeway-conf/src/loader.rs:
pub fn load_config(root: &Path) -> Result<ValidatedConfig, ConfigError>
ValidatedConfig bundles the result with its diagnostics:
pub struct ValidatedConfig {
pub config: RuntimeConfig,
pub report: Report, // warnings that survived the gate
pub sources: SourceMap, // needed to render them
}
Two more entry points serve other callers:
load_spec_files(root)runs only the parse phase and returns(SourceMap, Report, ServerSpec, Vec<Located<DeviceSpec>>, Vec<Located<IngressSpec>>). Theconfig dump --repr speccommand uses this.load_config_from_specs(server, ingresses, devices)skips files entirely and runs validate-gate-lower on specs built in Rust. The integration-testConfigBuilderuses this; the spec values are wrapped inLocated::detached, so validation messages render without source locations but otherwise behave identically.
Spec types vs Config types
Every setting exists in two parallel structs:
| Layer | Location | Derives | Purpose |
|---|---|---|---|
| Spec | crates/snakeway-conf/src/types/specification/ | confval::Spec, Serialize | Populated from HCL; every field is span-tracked |
| Config | crates/snakeway-conf/src/types/runtime/ | confval::Config, serde | Resolved, executable form used by the proxy at runtime |
Spec fields are wrapped in Located<T>:
#[derive(Debug, Serialize, confval::Spec)]
pub struct ServerSpec {
pub version: Located<HclInt>,
pub threads: Option<Located<HclInt>>,
#[confval(nested)]
pub tls_automation: Option<Located<TlsAutomationSpec>>,
#[confval(default = 30)]
pub dns_refresh_interval_seconds: Located<HclInt>,
}
Config structs declare how each field lowers:
#[derive(Debug, Clone, Deserialize, Serialize, confval::Config)]
#[confval(lower_from = ServerSpec)]
pub struct ServerConfig {
#[confval(lower(from = version, with = i64_to_u32))]
pub version: u32,
#[confval(nested)]
pub tls_automation: Option<TlsAutomationConfig>,
pub ca_file: Option<String>, // auto-mapped, Located stripped
}
The generated lowering destructures the spec exhaustively, so adding a field to one side without
accounting for it on the other is a compile error. The with functions live in the runtime module
next to the config struct they serve.
Type selection principle
Spec types use the rawest type that parses infallibly from HCL. Strings, HclInt (an alias for
i64, HCL's native integer type), bools, paths. The structural parsers never reject a value for
semantic reasons, so a port of 99999 or a strategy of "failovr" parses fine and is caught by
validation with a span, alongside every other problem.
Keyword fields are Located<String> in specs. Closed sets like load-balancing strategies or
log levels are validated against a constant slice, with a help line listing the options. The
runtime enum implements TryFrom<&str> and the conversion happens at lowering. There are no serde
keyword enums in the spec layer; a typo there would abort parsing with a single error instead of
joining the report.
Config types use the fully parsed, typed form. IpNet, Method, HeaderName, SocketAddr,
runtime enums. Downstream code never re-parses a string it received from config.
Hand-written FromFields impls cover the shapes the derive does not. Tagged unions parse their
discriminator first and dispatch: tls blocks on mode (manual or acme), cert_store
blocks on type. The WASM device's free-form config block is captured as an arbitrary value
rather than a struct: its FromFields impl reads the neutral field model and reconstructs an
hcl::Value to hand the module untouched.
Both HCL spellings
Operators write nested structures either as blocks or as attribute-with-object, and real configs mix the two:
tls_automation {
enable = true
}
tls_automation = {
enable = true
}
The Fields view in confval normalizes both, so every nested spec accepts either spelling with
identical spans and identical error messages.
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.
Validation
Validation is split between entity-local Validate impls and centralized relational validators.
Entity Validate impls
Each spec implements confval::pipeline::Validate to check its own fields:
impl Validate for ServerSpec {
fn validate(&self, report: &mut Report) {
if let Some(threads) = &self.threads {
THREADS.check_located(threads, "threads", report);
}
// ...
}
}
These impls live next to the spec struct they validate (for example BindSpec in the bind module,
ServiceSpec in the service module). They cover ranges (via RangeConstraint::check_located),
closed keyword sets, format checks, and path existence. Because spans live inside the Located
fields, the method takes only &self and the report; no span or origin parameter is threaded
through.
The Validate trait is not just a convention. It is a compile-time bound on lowering: a spec that
can be lowered into a runtime config but has no validator fails to compile. The bound lives where
each family lowers:
- Server and the device configs carry it on their
Lowerimpls (impl Lower<ServerSpec> for ServerConfig where ServerSpec: Validate, written as#[confval(lower_from = ServerSpec, validate)]on the derive, and as an explicitwhereclause on the hand-written device impls). - Ingresses lower by flattening in
lower_configsrather than through a per-entityLowerimpl, so the bound is awhere IngressSpec: Validateclause on that function. A compositionalimpl Validate for IngressSpecdelegates to each child entity, so the bound transitively requires every ingress child to be validatable.
The effect is that you cannot produce a RuntimeConfig from a spec family whose entities are not all
validatable. The guarantee is existence (a validator is defined), not invocation; the orchestrator
below still calls validation explicitly, before the lowering gate.
What does not belong in a Validate impl is any check needing more than &self: a missing
required child needs the entity's enclosing span, which lives on the Located wrapper the caller
holds, so those presence checks live in the central validator instead (see below).
Centralized validators
Cross-field and cross-file rules live under crates/snakeway-conf/src/validation/:
single_file/ingress.rsandsingle_file/device.rswalk every parsed ingress and device, dispatch each entity'sValidateimpl (ingress.value.validate(report),device_cfg.validate(report)), and check relational rules within and across files: duplicate bind addresses, duplicate route paths, HTTP/2 and TLS dependency, WebSocket and HTTP/2 conflict. Cross-file duplicates are tracked in a map from key to first-seenSpan, so the second occurrence reports with a related span labelled "first declared here". The single-entity presence checks ("ingress must have a bind or bind_admin", "service has no upstream backends", "bind_admin.auth is required") also live here, because each points at an entity's enclosing span that aValidateimpl cannot reach from&self.multi_file/tls.rschecks invariants that span the entrypoint and the ingress set: ACME TLS requiresserver.tls_automation; a configuredtls_automationwith no TLS listener anywhere earns a warning.
The orchestrator ties it together:
pub(crate) fn validate_spec(
server: &ServerSpec,
ingresses: &[Located<IngressSpec>],
devices: &[Located<DeviceSpec>],
report: &mut Report,
)
It returns nothing; all findings land in the report. Server validation always runs; the ingress, device, and multi-file validators only run when version is a recognized schema version, since their rules are defined per schema version.
A required field that fails structural parsing makes its entity's parse return None, which means
the entity's semantic checks do not run that pass. The operator fixes the structural error first and
sees the semantic findings on the next run.
Diagnostics
When the gate trips, load_config returns:
ConfigError::SemanticValidationFailed {
report: confval::diagnostic::Report,
sources: confval::source::SourceMap,
}
snakeway config check renders this in one of three formats (--format pretty|plain|json), all
provided by confval. Pretty output is rustc-style with source excerpts and carets:
error: unknown cert_store type: filesyste
--> config/snakeway.hcl:14:12
|
14 | type = "filesyste"
| ^^^^^^^^^^^
= help: expected "memory" or "filesystem"
The remaining ConfigError variants cover hard failures outside the report: file I/O and glob
resolution.
On a successful load, warnings still surface: ValidatedConfig::has_warnings() and
render_plain let callers print them without failing startup.
Key files at a glance
| File | Responsibility |
|---|---|
confval/src/source/ | Located, Span, SourceId, SourceMap |
confval/src/diagnostic/ | Report, Issue, Severity, the renderers |
confval/src/pipeline/ | Lower, LowerAuto, Validate, narrow, RangeConstraint |
confval/src/format/field.rs | the neutral field model and FromFields, leaf and struct parsers |
confval/src/format/hcl.rs | parse_hcl: the HCL frontend (format/toml.rs is the TOML one) |
confval-derive/src/lib.rs | #[derive(Spec)] and #[derive(Config)] |
conf/loader.rs | load_config, load_spec_files, load_config_from_specs, the gate |
conf/discover.rs | Glob-based file discovery |
conf/parse.rs | flatten_devices: device file to per-device specs |
conf/lower.rs | lower_configs: assembles the final RuntimeConfig (where IngressSpec: Validate) |
conf/types/specification/ | All *Spec structs with their Validate impls |
conf/types/runtime/ | All *Config structs, Lower derives, and with conversion functions |
conf/validation/validate.rs | validate_spec orchestrator |
conf/validation/single_file/ | Per-file relational validators (ingress, device) |
conf/validation/multi_file/ | Cross-file validators (TLS) |
conf/validation/error.rs | ConfigError enum |