Hermes

Hermes is a minimal HTTP/1.1 forward proxy written in Rust. It supports streaming plain HTTP requests, HTTPS tunneling with CONNECT, YAML-configured ACLs, and OpenTelemetry traces, metrics, and logs exported over OTLP/HTTP.

Features

  • Absolute-form HTTP forwarding (GET http://host/path)
  • HTTPS and arbitrary TCP tunneling with CONNECT host:port
  • Streaming request and response bodies
  • Upstream connection pooling and configurable timeouts
  • Ordered allow/deny ACLs using Basic authentication, client IP networks, and destination domains
  • Fast salted SHA-256 password hashes; plaintext passwords are never stored in YAML
  • Removal of standard and Connection-nominated hop-by-hop headers
  • W3C Trace Context extraction and injection for plain HTTP traffic
  • Structured, trace-correlated logs on stdout and through OTLP
  • Bounded-cardinality HTTP, tunnel, and ACL metrics
  • Localhost-only binding by default

TLS is not terminated or inspected. HTTPS remains end-to-end encrypted between the client and destination.

Build and run

cargo build
cargo run -- --config examples/hermes.yaml

The default configuration path is hermes.yaml. The included example listens on 127.0.0.1:8080 and allows only localhost clients.

# Plain HTTP
curl --proxy http://127.0.0.1:8080 http://example.com/

# HTTPS via CONNECT
curl --proxy http://127.0.0.1:8080 https://example.com/

YAML configuration

listen_addr: "127.0.0.1:8080"
connect_timeout_ms: 10000
upstream_timeout_ms: 30000

acl:
  default_action: deny

  users:
    alice:
      password_hash: "$hermes-sha256$v=1$..."

  rules:
    - name: alice
      action: allow
      users: ["alice"]

    - name: office-network
      action: allow
      networks: ["10.20.0.0/16", "2001:db8:20::/48"]

    - name: approved-domains
      action: allow
      domains: ["docs.example.com", "example.org", "*.rust-lang.org"]
Field Default Description
listen_addr 127.0.0.1:8080 Downstream listen address
connect_timeout_ms 10000 TCP connection timeout for CONNECT and the HTTP connector
upstream_timeout_ms 30000 Time limit for receiving upstream HTTP response headers
acl.default_action deny Action when no rule matches
acl.users empty Usernames and salted SHA-256 password hashes
acl.rules empty Ordered ACL rules

Configuration is loaded and validated at startup. Unknown fields, invalid CIDRs or domain patterns, duplicate or empty rules, references to unknown users, and unsupported password hashes prevent startup. Restart Hermes to apply configuration changes.

RUST_LOG controls log filtering and defaults to info, for example RUST_LOG=hermes=debug.

ACL rules

  • Rules are evaluated in order; the first match wins.
  • Separate user, network, and domain rules provide Basic-auth, source-IP, or destination-domain authorization.
  • If a rule contains multiple selector types, every specified selector must match. Entries within each selector are ORed.
  • A rule must contain at least one selector; accidental match-all rules are rejected.
  • networks uses the actual client TCP peer address. X-Forwarded-For is never trusted.
  • domains uses the request-target authority, never the downstream Host header, and works for both HTTP and CONNECT.
  • Requests without required credentials receive 407 Proxy Authentication Required and a Basic Proxy-Authenticate challenge.
  • Invalid credentials receive 407; clients denied by policy receive 403.
  • ACLs are enforced before upstream HTTP requests or CONNECT connections are made.
  • Proxy-Authorization is consumed by Hermes and is never forwarded upstream.

Destination domains

Domain entries support exact names and leading-subdomain wildcards:

  • example.com matches only that name.
  • *.example.com matches www.example.com and a.b.example.com, but not the apex example.com or a lookalike such as evil-example.com.
  • Matching is case-insensitive, ignores ports, and normalizes one trailing DNS root dot.
  • Single-label names such as localhost are supported. International domains must use ASCII/punycode form.
  • IP literals never match domain patterns.

A domain whitelist uses default_action: deny with allow rules:

acl:
  default_action: deny
  rules:
    - name: approved-domains
      action: allow
      domains: ["example.com", "*.rust-lang.org"]

A domain blacklist uses deny rules, typically with default_action: allow:

acl:
  default_action: allow
  rules:
    - name: blocked-domains
      action: deny
      domains: ["ads.example.net", "*.tracker.example"]

Domain selectors can be combined with users and/or networks when client and destination restrictions must both apply. A domain-only allow rule does not implicitly require authentication.

Domain matching is not destination-IP filtering. A hostname blacklist can be bypassed by connecting directly to an IP address. Hermes also does not inspect resolved addresses or provide DNS-rebinding protection. A domain-only whitelist with default_action: deny rejects IP-literal destinations unless another rule allows them.

Basic authentication

For Basic authentication, curl can supply proxy credentials with --proxy-user:

curl --proxy http://127.0.0.1:8080 \
  --proxy-user 'alice:your-password' \
  https://example.com/

Generate a password hash

cargo run -- hash-password

The helper prompts without echoing, confirms the password, and prints a versioned string in this format:

$hermes-sha256$v=1$<base64-salt>$<base64-digest>

Copy the complete string into the user's password_hash field. Hermes computes SHA-256(salt || password) using a cryptographically random 16-byte salt. No separate salt field is needed, and running the helper twice for the same password produces different salted hashes. Argon2id hashes from earlier configurations are not supported; regenerate them with the helper.

Password verification is synchronous and inexpensive, and digest comparisons are constant-time. Decoded password buffers are zeroized after use.

Fast password hashes can be brute-forced offline. Anyone who obtains the YAML can efficiently guess passwords and compare them with the stored salted SHA-256 digest. Use strong, unique passwords and protect the configuration file; this format deliberately trades password-cracking resistance for cheap request verification.

Basic authentication is not encrypted in transit. A client sending Basic credentials over cleartext HTTP exposes them to the network. Use Basic authentication only on localhost or a trusted private network unless downstream TLS is added. Do not expose Hermes publicly without appropriate network controls.

OpenTelemetry

Hermes exports traces, metrics, and logs independently to an OpenTelemetry Collector using OTLP/HTTP (protobuf). Exporters use the standard OpenTelemetry environment variables.

export OTEL_SERVICE_NAME=hermes
export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
cargo run -- --config examples/hermes.yaml

Signal-specific settings such as OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, and OTEL_EXPORTER_OTLP_LOGS_ENDPOINT are also supported. Standard OTLP headers, timeouts, and trace sampling environment variables can be used.

Traces and logs are batched, metrics are exported periodically, and all providers are flushed during shutdown. An unavailable collector does not prevent the proxy from forwarding traffic, although exporter errors may appear in stdout logs.

Local collector

examples/otel-collector.yaml receives OTLP/HTTP and prints telemetry to the collector log:

docker run --rm \
  -p 4318:4318 \
  -v "$PWD/examples/otel-collector.yaml:/etc/otelcol/config.yaml:ro" \
  otel/opentelemetry-collector:latest

In another terminal, run Hermes and send a request through it. The collector will print the exported resource spans, metrics, and log records.

Traces

  • proxy.request: downstream plain HTTP server operation
  • proxy.upstream: upstream HTTP client operation
  • proxy.connect: CONNECT handshake and tunnel lifetime
  • proxy.upstream_connect: upstream TCP connection establishment

For plain HTTP, incoming W3C traceparent and tracestate headers establish the parent context, and a new context is injected upstream. Trace context cannot be propagated inside an encrypted CONNECT tunnel. Request spans include the ACL decision, reason, and matched rule name without credentials.

Metrics

Hermes uses standard HTTP instrument names where applicable and adds proxy-specific instruments:

  • hermes.proxy.requests
  • http.server.request.duration
  • http.server.active_requests
  • http.client.request.duration
  • hermes.proxy.errors
  • hermes.proxy.tunnels.active
  • hermes.proxy.tunnel.duration
  • hermes.proxy.tunnel.bytes
  • hermes.proxy.acl.decisions

Metric attributes are deliberately bounded. Raw URLs, destination hostnames, usernames, rule names, and client addresses are not metric attributes.

Logs

Startup, shutdown, ACL decisions, request completion, request failures, and tunnel completion are emitted as structured tracing events. Logs emitted inside a trace span include its trace and span IDs in OTLP. Headers, bodies, passwords, authorization values, password hashes, and query strings are not logged by the proxy.

Limitations

  • HTTP/1.1 downstream and upstream only; no HTTP/2 or gRPC proxying
  • No downstream TLS, TLS interception, caching, rate limiting, or configuration hot reload
  • No HTTP Upgrade forwarding other than CONNECT
  • No dynamic configuration or Envoy xDS support

Development

cargo fmt --all -- --check
cargo test
cargo clippy --all-targets --all-features -- -D warnings
S
Description
No description provided
Readme
241 KiB
Languages
Rust 98.6%
Dockerfile 1.4%