Files
hermes/README.md
woggioni c797d83988
CI / Build and push docker image (push) Successful in 42s
Publish multi-architecture Docker images
2026-10-30 03:42:05 +00:00

326 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
- Optional SOCKS5 CONNECT listener with no-auth and username/password authentication
- SOCKS5 IPv4, IPv6, and domain destinations with shared ACL enforcement
- Streaming request and response bodies
- Upstream connection pooling and configurable timeouts
- Ordered allow/deny ACLs using Basic authentication, client IP networks, destination domains, and destination ports
- 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
```sh
cargo build
HERMES_CONFIG_PATH=examples/hermes.yaml cargo run
# The CLI flag takes precedence over HERMES_CONFIG_PATH:
cargo run -- --config examples/hermes.yaml
```
Configuration path precedence is `--config`, then `HERMES_CONFIG_PATH`, then `hermes.yaml`. This is the only environment variable read by Hermes; logging and OpenTelemetry are also configured in YAML. The included example listens on `127.0.0.1:8080` and allows only localhost clients.
```sh
# 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/
```
## Docker
The root `Dockerfile` builds a statically linked musl binary and packages it in a non-root `scratch` image with CA certificates. No configuration or credentials are included in the image.
```sh
docker build -t hermes:local .
```
Mount a configuration file read-only at `/etc/hermes/hermes.yaml`. Inside the container, set `listen_addr` to `0.0.0.0:8080` so the published port is reachable:
```sh
docker run --rm \
-p 127.0.0.1:8080:8080 \
-v "$PWD/hermes.yaml:/etc/hermes/hermes.yaml:ro" \
hermes:local
```
The image sets `HERMES_CONFIG_PATH` to the mount location. Ensure the mounted file is readable by the numeric non-root container user. ACL `networks` rules see the container-network peer address, which may differ from the original host or external client address.
The `scratch` runtime contains no shell or healthcheck utility. Use `docker run --rm hermes:local --version` to verify the binary starts.
### Publishing
The Gitea workflow in `.gitea/workflows/build.yaml` builds and publishes multi-architecture `linux/amd64` and `linux/arm64` images to `gitea.woggioni.net/woggioni/hermes` when a tag matching `release/*` is pushed. The ARM64 binary is cross-compiled on the AMD64 builder without emulation. For example, `release/v0.2.0` publishes `v0.2.0` and `latest` tags containing both architectures. The repository must define the `PUBLISHER_TOKEN` secret used for registry authentication.
## YAML configuration
```yaml
listen_addr: "127.0.0.1:8080"
socks5_listen_addr: "127.0.0.1:1080"
connect_timeout_ms: 10000
socks5_handshake_timeout_ms: 10000
upstream_timeout_ms: 30000
telemetry:
service_name: "hermes"
log_filter: "info"
resource_attributes:
deployment.environment.name: "development"
otlp:
endpoint: "http://127.0.0.1:4318"
timeout_ms: 10000
headers: {}
traces:
sample_ratio: 1.0
metrics:
export_interval_ms: 60000
temporality: "cumulative"
logs: {}
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"]
ports: [80, 443]
```
| Field | Default | Description |
| --- | --- | --- |
| `listen_addr` | `127.0.0.1:8080` | Downstream listen address |
| `socks5_listen_addr` | `null` | Optional SOCKS5 listen address; disabled when omitted |
| `connect_timeout_ms` | `10000` | TCP connection timeout for `CONNECT` and the HTTP connector |
| `socks5_handshake_timeout_ms` | `10000` | Timeout for SOCKS5 negotiation and CONNECT request parsing |
| `upstream_timeout_ms` | `30000` | Time limit for receiving upstream HTTP response headers |
| `telemetry` | see OpenTelemetry below | Logging, resource, and OTLP configuration |
| `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, domain patterns, or ports, duplicate or empty rules, references to unknown users, and unsupported password hashes prevent startup. Restart Hermes to apply configuration changes.
`telemetry.log_filter` controls log filtering and defaults to `info`, for example `log_filter: "hermes=debug"`. `RUST_LOG` is not read.
### ACL rules
- Rules are evaluated in order; the **first match wins**.
- Separate user, network, domain, and port rules provide Basic-auth, source-IP, destination-domain, or destination-port 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`.
- `ports` matches the destination port. Plain HTTP defaults to port 80 when omitted; `CONNECT` and SOCKS5 provide an explicit port.
- 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 and ports
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`.
- Domain matching is case-insensitive and normalizes one trailing DNS root dot. Use `ports` when port restrictions are also required.
- 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:
```yaml
acl:
default_action: deny
rules:
- name: approved-domains
action: allow
domains: ["example.com", "*.rust-lang.org"]
ports: [80, 443]
```
A domain blacklist uses deny rules, typically with `default_action: allow`:
```yaml
acl:
default_action: allow
rules:
- name: blocked-domains
action: deny
domains: ["ads.example.net", "*.tracker.example"]
```
Domain and port 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. Multiple ports are ORed; different selector types are ANDed. Ports must be in the range 1–65535, and omitting `ports` matches any destination port.
**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`:
```sh
curl --proxy http://127.0.0.1:8080 \
--proxy-user 'alice:your-password' \
https://example.com/
```
### Generate a password hash
```sh
cargo run -- hash-password
```
The helper prompts without echoing, confirms the password, and prints a versioned string in this format:
```text
$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.
## SOCKS5
Set `socks5_listen_addr` to enable a separate SOCKS5 listener alongside the HTTP proxy:
```yaml
listen_addr: "127.0.0.1:8080"
socks5_listen_addr: "127.0.0.1:1080"
socks5_handshake_timeout_ms: 10000
```
The listener supports SOCKS5 CONNECT with IPv4, IPv6, and domain destinations. Domain destinations are resolved by Hermes; use `--socks5-hostname` with curl to request proxy-side DNS resolution:
```sh
# Client-side DNS with an IP destination
curl --socks5 127.0.0.1:1080 http://198.51.100.10/
# Proxy-side DNS with a domain destination
curl --socks5-hostname 127.0.0.1:1080 http://example.com/
# Username/password authentication using the configured ACL user
curl --socks5-hostname 127.0.0.1:1080 \
--proxy-user 'alice:your-password' \
http://example.com/
```
SOCKS5 reuses the ACL's salted password hashes and applies `users`, `networks`, `domains`, and `ports` before upstream dialing. Username/password negotiation is selected when required by the ACL; otherwise no-auth is used. Authentication is cleartext on the network, so restrict the listener to localhost or trusted networks. ACL denial returns SOCKS reply `0x02` before any upstream connection.
Only CONNECT is supported. SOCKS4, BIND, and UDP ASSOCIATE are not implemented. Docker users can publish the listener with `-p 127.0.0.1:1080:1080` when it is enabled.
## OpenTelemetry
Hermes exports traces, metrics, and logs independently to an OpenTelemetry Collector using OTLP/HTTP (protobuf). All settings come from `telemetry` in YAML; `OTEL_*` environment variables are not read.
```yaml
telemetry:
service_name: "hermes"
log_filter: "info"
resource_attributes:
deployment.environment.name: "production"
otlp:
endpoint: "http://127.0.0.1:4318"
timeout_ms: 10000
headers: {}
traces:
sample_ratio: 1.0
metrics:
export_interval_ms: 60000
temporality: "cumulative"
logs: {}
```
The common `otlp.endpoint` is a base URL; Hermes appends `/v1/traces`, `/v1/metrics`, and `/v1/logs`. Set `otlp.traces.endpoint`, `otlp.metrics.endpoint`, or `otlp.logs.endpoint` to override a signal with an exact full URL. OTLP headers may contain credentials, so protect the configuration file and never log its contents.
Trace `sample_ratio` must be between 0 and 1. Metric `temporality` accepts `cumulative` or `delta`. Timeouts and export intervals must be greater than zero.
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`](examples/otel-collector.yaml) receives OTLP/HTTP and prints telemetry to the collector log:
```sh
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
- `socks5.connect`: SOCKS5 negotiation, authorization, upstream connection, and tunnel lifetime
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. Tunnel and ACL metrics include `proxy.protocol` to distinguish HTTP, HTTP CONNECT, and SOCKS5. Raw URLs, destination hostnames, destination ports, 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
```sh
cargo fmt --all -- --check
cargo test
cargo clippy --all-targets --all-features -- -D warnings
```