Skip to main content
Version: 0.10.0-dev

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:

LayerLocationDerivesPurpose
Speccrates/snakeway-conf/src/types/specification/DeserializePopulated directly from HCL; uses user-friendly types
Configcrates/snakeway-conf/src/types/runtime/SerializeResolved, 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:

SettingSpec typeConfig type
Bind addressBindInterfaceInput (symbolic: "loopback", "all", or an IP string)Resolved SocketAddr string
TLSTlsTerminationSpec (enum: Manual or Acme)TlsTerminationConfig with resolved cert paths
UpstreamsMixed UpstreamSpec (either sock or endpoint)Separated into UpstreamUnixConfig / UpstreamTcpConfig
ServicesArray inside IngressSpecFlattened into HashMap<String, ServiceConfig>
CIDR listsVec<String> (raw CIDR notation)Vec<IpNet> (parsed network addresses)
HTTP methodsVec<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 be 1. 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_automation must be configured. If server.tls_automation is 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:

TypeWhen usedWhat triggers it
ConfigErrorHard failure; load_config returns Err(…)File I/O failure, HCL syntax error, unresolvable address during lowering
ValidationReportSoft 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

FileResponsibility
conf/loader.rsEntry point: load_config, load_spec_files, load_config_from_specs
conf/discover.rsGlob-based file discovery
conf/parse.rsparse_devices, parse_ingress: HCL to Spec
conf/lower.rslower_configs: Spec to Config
conf/types/specification/All *Spec structs
conf/types/runtime/All *Config structs and their From/TryFrom impls
conf/validation/validate.rsvalidate_spec orchestrator
conf/validation/single_file/Per-file validators
conf/validation/multi_file/Cross-file validators
conf/validation/report.rsValidationReport, ValidationIssue, typed error helpers
conf/validation/error.rsConfigError enum
conf/validation/validated_config.rsValidatedConfig wrapper