Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "certstream-server-rust"
version = "1.5.3"
version = "1.5.4"
edition = "2024"
repository = "https://github.com/reloading01/certstream-server-rust"
license = "MIT"
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,20 @@ rate_limit:
| `CERTSTREAM_CT_LOG_REQUEST_TIMEOUT_SECS` | 30 | Request timeout |
| `CERTSTREAM_CT_LOG_BATCH_SIZE` | 1024 | Entries requested per get-entries call (servers clamp to their own max) |
| `CERTSTREAM_CT_LOG_FETCH_CONCURRENCY` | 4 | Concurrent range/tile fetches per watcher during catch-up (1-16) |
| `CERTSTREAM_USER_AGENT` | certstream-server-rust/{VERSION} | User-Agent for all outbound HTTP (CT log fetches and catalog fetches). Some operators (e.g. Geomys) apply a more generous rate limit tier to clients that include a contact email. Blank falls back to the default. |
| `CERTSTREAM_CT_LOG_FORCE_HTTP1_OPERATORS` | - | Comma-separated operator names whose watchers fetch over HTTP/1.1 instead of HTTP/2 (see below) |

**Forcing HTTP/1.1 per operator**

Some operators (DigiCert) throttle per TCP connection rather than per IP. Under HTTP/2 reqwest multiplexes every request for a host onto a single connection, so that per-connection quota ends up capping the whole process — DigiCert serves several logs from one host (`wyvern.ct.digicert.com`), so their watchers all share it. Listing an operator gives its watchers a dedicated HTTP/1.1 client, where each in-flight fetch needs its own connection and therefore carries its own quota:

```yaml
ct_log:
force_http1_operators:
- DigiCert
```

This does not raise the request rate — the per-operator token bucket (`default_operator_rate_limit_ms`, 500 ms) still gates every fetch. It only spreads the same requests across more connections; `fetch_concurrency` sets how many of those stay warm in the pool between polls. Operator names are matched case-insensitively and ignoring punctuation, the same as `operator_rate_limits`; a name that matches no discovered log is logged as a warning at startup.

**Hot Reload**

Expand Down
62 changes: 62 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,65 @@
# Release Notes — v1.5.4

**Release date:** August 22, 2026

Two outbound-HTTP controls for deployments that hit CT log operator rate limits. No wire-format or API changes.

## Configurable User-Agent

`ct_log.user_agent` (env `CERTSTREAM_USER_AGENT`) sets the User-Agent on every outbound request — CT log fetches and catalog fetches alike. Some operators (Geomys) apply a more generous rate limit tier to clients that carry a contact address:

```yaml
ct_log:
user_agent: "certstream-server-rust (security@example.com)"
```

Unset or blank falls back to `certstream-server-rust/{VERSION}`, so `CERTSTREAM_USER_AGENT=` in a compose file or `.env` cannot silently strip the header; a blank value is logged at startup. A value that is not legal HTTP header content fails config validation before the server binds.

Contributed by Effy Elden (@ineffyble) in #12.

## Per-operator HTTP/1.1 transport

DigiCert throttles per TCP connection rather than per IP. Under HTTP/2 reqwest multiplexes every request for a host onto a single connection, so a per-connection quota ends up capping the whole process — and DigiCert serves several logs from one host (`wyvern.ct.digicert.com` negotiates h2), so all of their watchers share it. Listing an operator gives its watchers a dedicated HTTP/1.1 client, where each in-flight fetch needs its own connection and therefore carries its own quota:

```yaml
ct_log:
force_http1_operators:
- DigiCert
```

Env: `CERTSTREAM_CT_LOG_FORCE_HTTP1_OPERATORS=DigiCert,Geomys`.

This does not raise the outbound request rate. The per-operator token bucket (`default_operator_rate_limit_ms`, 500 ms) still gates every fetch — the same requests are spread across more connections, and `fetch_concurrency` only decides how many of those stay warm in the pool. Operators not listed keep the shared HTTP/2 client.

The observation comes from certspotter's `digicerthack` branch ([SSLMate/certspotter#126](https://github.com/SSLMate/certspotter/issues/126)), which drops keep-alives outright instead.

## Operator name matching

`operator_rate_limits` and `force_http1_operators` are both looked up by canonicalized operator name (case, whitespace and punctuation collapsed). A key matching no discovered log used to be silently inert, which is indistinguishable from a working config until you measure. Startup now names them:

```
WARN configured operator names match no discovered CT log operator and have no effect field="ct_log.operator_rate_limits" unmatched=["digicert inc"]
```

## Configuration

| Setting | Old | New |
| ------- | --: | --: |
| `ct_log.user_agent` | — | `null` (new; env `CERTSTREAM_USER_AGENT`) |
| `ct_log.force_http1_operators` | — | `[]` (new; env `CERTSTREAM_CT_LOG_FORCE_HTTP1_OPERATORS`) |

## Tests

262 unit tests (was 251) plus the integration and snapshot suites, unchanged.

## Upgrade

Drop-in — both settings default to the v1.5.3 behaviour.

```bash
docker pull ghcr.io/reloading01/certstream-server-rust:1.5.4
```

# Release Notes — v1.5.3

**Release date:** July 18, 2026
Expand Down
13 changes: 13 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ ct_log:
# any operator absent from operator_rate_limits.
default_operator_rate_limit_ms: 500
operator_rate_limits: {}
# User-Agent for all outbound HTTP. Some operators (e.g. Geomys) apply a
# more generous rate limit tier to clients that include a contact email.
# Unset or blank defaults to certstream-server-rust/{VERSION}.
# (env: CERTSTREAM_USER_AGENT)
user_agent: null
# Operators whose watchers fetch over HTTP/1.1 instead of HTTP/2. DigiCert
# throttles per TCP connection, and HTTP/2 puts every request for a host on
# one connection; under HTTP/1.1 each in-flight fetch needs its own
# connection and so carries its own quota. The per-operator rate limiter
# still gates every fetch, so this redistributes the request rate rather
# than raising it. Names are matched like operator_rate_limits keys.
# (env: CERTSTREAM_CT_LOG_FORCE_HTTP1_OPERATORS, comma-separated)
force_http1_operators: []
# Per-catalog-source runtime-authority overrides. Keys are
# google_v3_usable, google_v3_all, and apple. An override can only grant
# authority to a source that currently verifies; it cannot promote an
Expand Down
4 changes: 4 additions & 0 deletions docs/docs.html
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,8 @@ <h3>CT log settings</h3>
<tr><td><code>CERTSTREAM_CT_LOG_REQUEST_TIMEOUT_SECS</code></td><td>30</td><td>Request timeout</td></tr>
<tr><td><code>CERTSTREAM_CT_LOG_BATCH_SIZE</code></td><td>1024</td><td>Entries requested per get-entries call (servers clamp to their own max)</td></tr>
<tr><td><code>CERTSTREAM_CT_LOG_FETCH_CONCURRENCY</code></td><td>4</td><td>Concurrent range/tile fetches per watcher during catch-up (1-16)</td></tr>
<tr><td><code>CERTSTREAM_USER_AGENT</code></td><td>certstream-server-rust/{VERSION}</td><td>User-Agent for all outbound HTTP. Some operators (e.g. Geomys) apply a more generous rate limit tier to clients that include a contact email. Blank falls back to the default.</td></tr>
<tr><td><code>CERTSTREAM_CT_LOG_FORCE_HTTP1_OPERATORS</code></td><td>-</td><td>Comma-separated operators whose watchers fetch over HTTP/1.1 instead of HTTP/2, so each in-flight fetch carries its own per-connection quota (DigiCert)</td></tr>
<tr><td><code>CERTSTREAM_STATIC_CT_CHECKPOINT_SIGNATURE</code></td><td>warn</td><td>Checkpoint signature policy: <code>warn</code> or <code>enforce</code></td></tr>
<tr><td><code>CERTSTREAM_DEDUP_CAPACITY</code></td><td>200000</td><td>Cross-log dedup capacity</td></tr>
<tr><td><code>CERTSTREAM_DEDUP_TTL_SECS</code></td><td>900</td><td>Dedup window (seconds)</td></tr>
Expand Down Expand Up @@ -549,6 +551,8 @@ <h2>YAML config file</h2>
<span class="str">poll_interval_ms</span>: <span class="var">500</span>
<span class="str">retry_max_attempts</span>: <span class="var">3</span>
<span class="str">request_timeout_secs</span>: <span class="var">30</span>
<span class="str">user_agent</span>: <span class="str">"certstream-server-rust (security@example.com)"</span>
<span class="str">force_http1_operators</span>: [<span class="str">"DigiCert"</span>]

<span class="str">dedup</span>:
<span class="str">capacity</span>: <span class="var">200000</span>
Expand Down
6 changes: 6 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ use std::env;

pub const VERSION: &str = env!("CARGO_PKG_VERSION");

/// User-Agent sent on every outbound HTTP request unless `ct_log.user_agent`
/// overrides it. Built from the package version at compile time so the CT log
/// fetch client and the TLS-pinned Apple catalog client can never drift apart.
pub const DEFAULT_USER_AGENT: &str = concat!("certstream-server-rust/", env!("CARGO_PKG_VERSION"));

#[derive(Debug, Clone)]
pub struct CliArgs {
pub validate_config: bool,
Expand Down Expand Up @@ -45,6 +50,7 @@ impl CliArgs {
println!(" CERTSTREAM_PORT Server port (default: 8080)");
println!(" CERTSTREAM_LOG_LEVEL Log level (default: info)");
println!(" CERTSTREAM_BUFFER_SIZE Broadcast buffer size (default: 1000)");
println!(" CERTSTREAM_USER_AGENT Override the outbound HTTP User-Agent");
println!();
println!("For more information, see: https://github.com/reloading01/certstream-server-rust");
}
Expand Down
144 changes: 144 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,22 @@ pub struct CtLogConfig {
/// whitespace, or punctuation. Empty map means every operator uses the default.
#[serde(default)]
pub operator_rate_limits: std::collections::HashMap<String, u64>,
/// HTTP User-Agent for outbound requests. Some CT log operators (e.g.
/// Geomys) apply a more generous rate limit tier to clients that include
/// a contact email. Unset or blank falls back to the compiled-in
/// `certstream-server-rust/{VERSION}`; read this through
/// [`CtLogConfig::user_agent_override`] rather than directly.
#[serde(default)]
pub user_agent: Option<String>,
/// Operators whose watchers fetch over a dedicated HTTP/1.1-only client.
/// DigiCert throttles per TCP connection rather than per IP; under HTTP/2
/// reqwest multiplexes every request for a host onto one connection, so
/// that per-connection quota becomes a whole-process quota. HTTP/1.1
/// spreads the same request rate — still capped by the per-operator
/// limiter — over one connection per in-flight fetch. Names are
/// canonicalized with the same rules as `operator_rate_limits`.
#[serde(default)]
pub force_http1_operators: Vec<String>,
/// Per-catalog-source runtime-authority overrides. Keys are the catalog
/// registry source names (`google_v3_usable`, `google_v3_all`, `apple`).
/// An override can only grant authority to a source that currently verifies;
Expand Down Expand Up @@ -199,6 +215,20 @@ impl std::str::FromStr for CheckpointSignatureMode {
}
}

impl CtLogConfig {
/// The configured User-Agent with surrounding whitespace trimmed, or
/// `None` when unset or blank. A blank value is treated as unset because
/// `CERTSTREAM_USER_AGENT=` in a compose file or `.env` reads back as an
/// empty string, and an empty `User-Agent:` header is exactly the opposite
/// of what an operator setting this is asking for.
pub fn user_agent_override(&self) -> Option<&str> {
self.user_agent
.as_deref()
.map(str::trim)
.filter(|ua| !ua.is_empty())
}
}

impl Default for CtLogConfig {
fn default() -> Self {
Self {
Expand All @@ -219,13 +249,25 @@ impl Default for CtLogConfig {
static_ct_enabled: true,
default_operator_rate_limit_ms: default_operator_rate_limit_ms(),
operator_rate_limits: std::collections::HashMap::new(),
user_agent: None,
force_http1_operators: Vec::new(),
catalog_authority_overrides: std::collections::HashMap::new(),
}
}
}

pub const MAX_START_OVERLAP_LEAVES: u64 = 100_000;

/// Split a comma-separated env value into operator names, dropping blanks so
/// `"digicert,"` and `"digicert, ,geomys"` behave like the obvious YAML list.
fn parse_operator_list(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.collect()
}

fn default_operator_rate_limit_ms() -> u64 {
500
}
Expand Down Expand Up @@ -586,6 +628,10 @@ impl Config {
ct_log.checkpoint_signature_mode,
"CERTSTREAM_STATIC_CT_CHECKPOINT_SIGNATURE"
);
env_override!(ct_log.user_agent, "CERTSTREAM_USER_AGENT", some_str);
if let Ok(v) = env::var("CERTSTREAM_CT_LOG_FORCE_HTTP1_OPERATORS") {
ct_log.force_http1_operators = parse_operator_list(&v);
}

let mut connection_limit = yaml_config.connection_limit.unwrap_or_default();
env_override!(connection_limit.enabled, "CERTSTREAM_CONNECTION_LIMIT_ENABLED");
Expand Down Expand Up @@ -706,6 +752,14 @@ impl Config {
message: "Fetch concurrency must be between 1 and 16".to_string(),
});
}
if let Some(ua) = self.ct_log.user_agent_override()
&& reqwest::header::HeaderValue::try_from(ua).is_err()
{
errors.push(ConfigValidationError {
field: "ct_log.user_agent".to_string(),
message: "User-Agent must be a valid HTTP header value".to_string(),
});
}

if errors.is_empty() {
Ok(())
Expand Down Expand Up @@ -801,6 +855,96 @@ mod tests {
assert_eq!(config.start_overlap_leaves, 256);
assert!(config.rfc6962_enabled);
assert!(config.static_ct_enabled);
assert!(config.user_agent.is_none());
}

#[test]
fn test_ct_log_config_deserialize_user_agent() {
let yaml = r#"
user_agent: "certstream-server-rust/1.5.3 (contact@example.com)"
"#;
let config: CtLogConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
config.user_agent.as_deref(),
Some("certstream-server-rust/1.5.3 (contact@example.com)")
);
}

#[test]
fn test_blank_user_agent_falls_back_to_default() {
// `CERTSTREAM_USER_AGENT=` in a compose file reads back as Ok(""), and
// an empty User-Agent header is worse than the default one.
for blank in ["", " ", "\t"] {
let config = CtLogConfig {
user_agent: Some(blank.to_string()),
..CtLogConfig::default()
};
assert_eq!(config.user_agent_override(), None, "blank: {blank:?}");
}
}

#[test]
fn test_user_agent_override_is_trimmed() {
let config = CtLogConfig {
user_agent: Some(" certstream/1.0 (me@example.com) ".to_string()),
..CtLogConfig::default()
};
assert_eq!(
config.user_agent_override(),
Some("certstream/1.0 (me@example.com)")
);
}

#[test]
fn test_validate_blank_user_agent_is_not_an_error() {
let config = Config {
ct_log: CtLogConfig {
user_agent: Some(" ".to_string()),
..CtLogConfig::default()
},
..test_config()
};
assert!(config.validate().is_ok());
}

#[test]
fn test_parse_operator_list_drops_blanks_and_trims() {
assert_eq!(
parse_operator_list("DigiCert, Geomys"),
vec!["DigiCert".to_string(), "Geomys".to_string()]
);
assert_eq!(
parse_operator_list("digicert,, ,geomys,"),
vec!["digicert".to_string(), "geomys".to_string()]
);
assert!(parse_operator_list("").is_empty());
assert!(parse_operator_list(" , ").is_empty());
}

#[test]
fn test_ct_log_config_deserialize_force_http1_operators() {
let yaml = r#"
force_http1_operators:
- DigiCert
- Geomys
"#;
let config: CtLogConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.force_http1_operators, vec!["DigiCert", "Geomys"]);
}

#[test]
fn test_validate_user_agent_invalid_header() {
let config = Config {
ct_log: CtLogConfig {
user_agent: Some("bad\nuser-agent".to_string()),
..CtLogConfig::default()
},
..test_config()
};
let result = config.validate();
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors.iter().any(|e| e.field == "ct_log.user_agent"));
}

#[test]
Expand Down
10 changes: 5 additions & 5 deletions src/ct/catalog/tls_pin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,10 @@ impl ServerCertVerifier for PinnedIssuerVerifier {
/// Build a dedicated reqwest client that pins the Apple issuer-CA SPKI on top of
/// normal WebPKI validation. Used ONLY for the `apple` catalog fetch. `timeout`
/// The timeout bounds the fetch so a hung Apple endpoint cannot wedge startup.
pub fn build_apple_pinned_client(timeout: std::time::Duration) -> Result<reqwest::Client, String> {
pub fn build_apple_pinned_client(
timeout: std::time::Duration,
user_agent: &str,
) -> Result<reqwest::Client, String> {
let provider = Arc::new(aws_lc_rs::default_provider());

// Trust roots from the OS store (same source rustls-native-certs feeds the
Expand Down Expand Up @@ -152,10 +155,7 @@ pub fn build_apple_pinned_client(timeout: std::time::Duration) -> Result<reqwest
.with_no_client_auth();

reqwest::Client::builder()
.user_agent(concat!(
"certstream-server-rust/",
env!("CARGO_PKG_VERSION")
))
.user_agent(user_agent)
.use_preconfigured_tls(config)
.timeout(timeout)
.build()
Expand Down
3 changes: 2 additions & 1 deletion src/ct/log_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,12 +434,13 @@ pub async fn fetch_log_list(
authority_overrides: &HashMap<String, bool>,
custom_logs: Vec<CustomCtLog>,
request_timeout: Duration,
user_agent: &str,
) -> Result<Vec<CtLog>, LogListError> {
// Apple has no detached signature, so it is fetched through a dedicated
// client that pins the issuer-CA SPKI on top of WebPKI validation. If that
// client cannot be built, Apple is skipped this cycle rather than fetched
// unpinned. Apple is non-authoritative, so skipping has no spawn impact.
let apple_client = match catalog::build_apple_pinned_client(request_timeout) {
let apple_client = match catalog::build_apple_pinned_client(request_timeout, user_agent) {
Ok(c) => Some(c),
Err(e) => {
warn!(error = %e, "failed to build TLS-pinned Apple client; skipping the Apple catalog this cycle");
Expand Down
Loading
Loading