The Wasm Component Model on Fastly Compute

http-downstream: What Kind of Connection Is This, Really

The first post on http-downstream was about identity and observability: which headers arrived and in what order, which IP they came from, how many times this service has been visited already. Those are all questions the connection can answer about itself.

This post picks up the functions it deferred. Those ask something different: not what the client is, but what the Fastly edge makes of it. They report whether the TLS handshake matches a known client stack, whether the IP belongs to a VPN, whether Fastly's detection thinks there's a person on the other end at all. A conclusion can go missing in ways a fact can't, since the analysis has to have run and the database has to have a row for this IP. It makes sense that these are all things Viceroy has little to say about.

The TLS functions

The first group is eight functions that cover the handshake, from what was negotiated to what can be fingerprinted from it:

WIT
interface http-downstream {
  ...

  /// Gets the cipher suite used to secure the downstream client TLS connection.
  ///
  /// The value returned will be consistent with the [OpenSSL name] for the cipher suite.
  ///
  /// Returns `ok(none)` if the downstream client connection is not a TLS connection.
  ///
  /// [OpenSSL name]: https://testssl.sh/openssl-iana.mapping.html (opens in a new window)
  downstream-tls-cipher-openssl-name: func(
    ds-request: borrow<request>,
    max-len: u64
  ) -> result<option<list<u8>>, error>;

  /// Gets the TLS protocol version used to secure the downstream client TLS connection.
  ///
  /// Returns `ok(none)` if the downstream client connection is not a TLS connection.
  downstream-tls-protocol: func(
    ds-request: borrow<request>,
    max-len: u64
  ) -> result<option<list<u8>>, error>;

  /// Gets the raw bytes sent by the client in the TLS ClientHello message.
  ///
  /// See [RFC 5246] for details.
  ///
  /// Returns `ok(none)` if the downstream client connection is not a TLS connection.
  ///
  /// [RFC 5246]: https://www.rfc-editor.org/rfc/rfc5246#section-7.4.1.2 (opens in a new window)
  downstream-tls-client-hello: func(
    ds-request: borrow<request>,
    max-len: u64
  ) -> result<option<list<u8>>, error>;

  /// Gets the raw client certificate used to secure the downstream client mTLS connection.
  ///
  /// The value returned will be based on PEM format.
  ///
  /// Returns `ok(none)` if the downstream client connection is not a TLS connection.
  downstream-tls-raw-client-certificate: func(
    ds-request: borrow<request>,
    max-len: u64
  ) -> result<option<list<u8>>, error>;

  /// Returns the `client-cert-verify-result` from the downstream client mTLS handshake.
  ///
  /// Returns `ok(none)` if the downstream client connection is not a TLS connection.
  downstream-tls-client-cert-verify-result: func(
    ds-request: borrow<request>
  ) -> result<option<client-cert-verify-result>, error>;

  /// Returns the Server Name Indication from the downstream client TLS handshake.
  ///
  /// Returns `ok(none)` if not available.
  downstream-tls-client-servername: func(
    ds-request: borrow<request>,
    max-len: u64
  ) -> result<option<string>, error>;

  /// Gets the JA3 hash of the TLS ClientHello message.
  ///
  /// Returns `ok(none)` if the downstream client connection is not a TLS connection.
  downstream-tls-ja3-md5: func(
    ds-request: borrow<request>
  ) -> result<option<list<u8>>, error>;

  /// Gets the JA4 hash of the TLS ClientHello message.
  ///
  /// Returns `ok(none)` if the downstream client connection is not a TLS connection.
  downstream-tls-ja4: func(
    ds-request: borrow<request>,
    max-len: u64
  ) -> result<option<string>, error>;

  ...
}

Each of these functions promises to return ok(none) when not applicable, i.e., for a non-TLS connection, or for downstream-tls-client-servername, if the server name is not available. This is unlike the fingerprint functions from last time (h2-fingerprint, oh-fingerprint) that had no option at all—those two would just fail with error.generic-error when there's nothing to report.

In reality, a non-TLS connection is not likely on a real Fastly service because (unless specially configured) Compute services only accept secure client connections (opens in a new window).

JA3 and JA4 are fingerprints of the ClientHello itself, built from which extensions the client offered, in which order, with which parameters. Two clients running the same TLS stack hash the same way no matter what User-Agent they claim.

The signatures give away the difference between the two. downstream-tls-ja3-md5 returns option<list<u8>> and takes no max-len at all, because a JA3 is an MD5 digest: sixteen bytes, always, nothing to size a buffer for. downstream-tls-ja4 returns option<string> with a max-len, because a JA4 is a structured printable string whose length depends on what it's describing. The same idea in two generations, and the ABI types each one for what it actually is rather than settling on one shape for both.

That's the pattern across this whole group: the ABI hands you the evidence and the conclusion drawn from it, the ClientHello next to its hashes, the raw client certificate next to the verdict from checking it.

The function downstream-tls-client-cert-verify-result makes a reference to the enumeration client-cert-verify-result describing the result of verifying a TLS client certificate. This type isn't declared in http-downstream at all; it's imported from http-req via the use http-req.{request, client-cert-verify-result, ...} line at the top of the interface.

WIT
/// TLS client certificate verified result from downstream.
enum client-cert-verify-result {
  /// Success value.
  ///
  /// This indicates that client certificate verified successfully.
  ok,
  /// bad certificate error.
  ///
  /// This error means the certificate is corrupt
  /// (for example, when the certificate signatures do not verify correctly).
  bad-certificate,
  /// certificate revoked error.
  ///
  /// This error means the client certificate is revoked by its signer.
  certificate-revoked,
  /// certificate expired error.
  ///
  /// This error means the client certificate has expired or is not currently valid.
  certificate-expired,
  /// unknown CA error.
  ///
  /// This error means the valid certificate chain or partial chain was received,
  /// but the certificate was not accepted because the CA certificate could not be
  /// located or could not be matched with a known trust anchor.
  unknown-ca,
  /// certificate missing error.
  ///
  /// This error means the client does not provide a certificate
  /// during the handshake.
  certificate-missing,
  /// certificate unknown error.
  ///
  /// This error means the client certificate was received, but some other (unspecified)
  /// issue arose in processing the certificate, rendering it unacceptable.
  certificate-unknown,
}

Both of those functions need more than a TLS connection to return anything. A client certificate only exists if the service has mutual TLS (opens in a new window) configured on the domain, which is a separate setup step from ordinary TLS. On a service without it, the connection is TLS, the doc comment's stated none condition doesn't apply, and you still get ok(none). The doc comment simply doesn't speak to the case where TLS is present but no client certificate was ever asked for.

Which raises the question of why the enum has six failure cases at all. If mTLS always meant "reject anything that doesn't verify," a corrupt or expired certificate would be turned away during the handshake and your code would never see it, so ok would be the only value that ever arrives. Fastly lets you configure mTLS the other way too: enforce it, or allow the connection through and record what happened. Every case in that enum is reachable only because the second mode exists.

Bot detection

http-downstream also has a handful of functions that work with Fastly's Bot Management (opens in a new window) product, such as the ability to analyze and detect when the downstream client connection comes from a bot:

WIT
interface http-downstream {
  ...

  /// Whether bot detection analysis was performed for this request.
  downstream-bot-analyzed: func(
    ds-request: borrow<request>,
  ) -> result<bool, error>;

  /// Whether a bot was detected in the request.
  downstream-bot-detected: func(
    ds-request: borrow<request>,
  ) -> result<bool, error>;

  /// A string identifying the specific bot detected (e.g., `GoogleBot`, `GPTBot`, `Bingbot`).
  /// Returns `ok(none)` if bot detection was not executed or no bot was detected.
  ///
  /// **Warning:** String values may change over time. Use this for logging or informational purposes.
  /// For conditional logic, use the `downstream-bot-category-kind` value.
  downstream-bot-name: func(
    ds-request: borrow<request>,
    max-len: u64
  ) -> result<option<string>, error>;

  /// A string indicating the type of bot detected (e.g., `SEARCH-ENGINE-CRAWLER`, `AI-CRAWLER`, `SUSPECTED-BOT`).
  /// Returns `ok(none)` if bot detection was not executed.
  ///
  /// **Warning:** String values may change over time. Use this for logging or informational purposes.
  /// For conditional logic, use the `downstream-bot-category-kind` value.
  downstream-bot-category: func(
    ds-request: borrow<request>,
    max-len: u64
  ) -> result<option<string>, error>;

  /// A value uniquely identifying the type of bot detected.
  /// Returns `ok(none)` if bot detection was not executed.
  downstream-bot-category-kind: func(
    ds-request: borrow<request>,
  ) -> result<option<bot-category>, error>;

  /// Whether the detected bot is a verified bot.
  /// Returns `ok(none)` if bot detection was not executed or no bot was detected.
  downstream-bot-verified: func(
    ds-request: borrow<request>,
  ) -> result<option<bool>, error>;

  ...
}

downstream-bot-analyzed exists specifically to disambiguate a none/false elsewhere in this group: downstream-bot-detected returning false could mean "we checked, this isn't a bot" or "we never checked at all," and without bot-analyzed there'd be no way to tell those apart. bot-name and bot-category are both explicitly flagged, in their own doc comments, as unstable strings meant for logs, not conditionals—bot-category-kind, returning the closed bot-category type (below), is what a real if statement should actually branch on.

That's variant bot-category, not enum bot-category, and the difference is the last case. A WIT enum is a closed set of bare names, which is all client-cert-verify-result above needed. A variant lets a case carry a payload, and this type needs one: extra(extra-bot-category) is an escape hatch backed by a resource rather than a bare integer, so whatever Fastly adds later arrives through that existing case instead of as a new name in the type's own definition.

WIT
/// Categories of detected bots.
variant bot-category {
  /// No bot was detected.
  none,
  /// A suspected bot.
  suspected,
  /// Tools that make content accessible (e.g., screen readers).
  accessibility,
  /// Crawlers used for training AIs and LLMs, generally used for building AI models or indexes.
  ai-crawler,
  /// Fetchers used by AIs and LLMs for enriching results in response to a user query.
  ai-fetcher,
  /// Tools that extract content from websites to be used elsewhere.
  content-fetcher,
  /// Tools that access your website to monitor things like performance, uptime, and proving domain control.
  monitoring-site-tools,
  /// Crawlers from online marketing platforms (e.g., Facebook, Pinterest).
  online-marketing,
  /// Tools that access your website to show a preview of the page in other online services and social media platforms.
  page-preview,
  /// Integration with other platforms by accessing the website's API, notably Webhooks.
  platform-integrations,
  /// Commercial and academic tools that collect and analyze data for research purposes.
  research,
  /// Crawlers that index your website for search engines.
  search-engine-crawler,
  /// Tools that support search engine optimization tasks (e.g., link analysis, ranking).
  search-engine-optimization,
  /// Security analysis tools that inspect your website for vulnerabilities, misconfigurations and other security features.
  security-tools,
  /// Additional bot kinds may be added in the future via this resource type.
  extra(extra-bot-category),
}

/// Extensibility for `bot-category`.
resource extra-bot-category {
  /// Returns an opaque value representing a bot category added in a future interface version.
  as-raw: func() -> u32;
}

That leaves downstream-bot-category-kind with two different ways to say no. It returns result<option<bot-category>, error>, and bot-category's first case is itself none, so ok(none) means bot detection never ran, while ok(some(none)) means it ran and found nothing. Same word, one level apart, and the outer one is what the doc comment is talking about. It's the same trap as downstream-compliance-region's literal "none" string sitting inside a Some, except here both layers are real ABI cases rather than one being a Viceroy stub. In the output below, bot-category-kind comes back Ok(None): the outer one, detection not executed.

The extra case isn't unique to bot-category. The KV Store's kv-error ends with extra(extra-kv-error), and its insert-options record carries an extra field doing the record-shaped version of the same job. It's a recurring answer across the ABI to "how do you extend a closed type later" without touching the type's own definition. What's unusual here is as-raw: of every extra-* resource in compute.wit, extra-bot-category is the only one that hands back a value at all. The rest are empty placeholders, since there's nothing to read from an option field nobody has added yet.

VPN and proxy detection

The last group doesn't look at what the client sent at all. Every answer comes from checking the client's IP against a database of known VPNs, proxies, and hosting ranges:

WIT
interface http-downstream {
  ...

  /// True if the IP range belongs to any kind of anonymous network.
  downstream-resvpnproxy-is-anonymous: func(
    ds-request: borrow<request>,
  ) -> result<option<bool>, error>;

  /// True if the IP range is identified as a VPN system.
  downstream-resvpnproxy-is-anonymous-vpn: func(
    ds-request: borrow<request>,
  ) -> result<option<bool>, error>;

  /// True if the IP range is identified as a hosting provider.
  downstream-resvpnproxy-is-hosting-provider: func(
    ds-request: borrow<request>,
  ) -> result<option<bool>, error>;

  /// True if the IP range is detected with a proxy over VPN technique.
  downstream-resvpnproxy-is-proxy-over-vpn: func(
    ds-request: borrow<request>,
  ) -> result<option<bool>, error>;

  /// True if the IP range is identified as a public proxy.
  downstream-resvpnproxy-is-public-proxy: func(
    ds-request: borrow<request>,
  ) -> result<option<bool>, error>;

  /// True if the IP belongs to relay service provider.
  downstream-resvpnproxy-is-relay-proxy: func(
    ds-request: borrow<request>,
  ) -> result<option<bool>, error>;

  /// True if the IP range is detected with residential proxy over VPN technique.
  downstream-resvpnproxy-is-residential-proxy: func(
    ds-request: borrow<request>,
  ) -> result<option<bool>, error>;

  /// True if the IP range is identified as a Smart DNS Proxy.
  downstream-resvpnproxy-is-smart-dns-proxy: func(
    ds-request: borrow<request>,
  ) -> result<option<bool>, error>;

  /// True if the IP range is identified as a Tor exit node.
  downstream-resvpnproxy-is-tor-exit-node: func(
    ds-request: borrow<request>,
  ) -> result<option<bool>, error>;

  /// True if the IP range is identified as a VPN datacenter.
  downstream-resvpnproxy-is-vpn-datacenter: func(
    ds-request: borrow<request>,
  ) -> result<option<bool>, error>;

  /// VPN service name. Available only if the range is identified as a VPN system.
  downstream-resvpnproxy-vpn-service-name: func(
    ds-request: borrow<request>,
    max-len: u64,
  ) -> result<option<string>, error>;

  ...
}

There are ten questions, all of them the same shape—option<bool>, one classification each—plus a name lookup that only means anything once is-anonymous-vpn comes back true.

You've seen part of this before. The geo.lookup payload in the geo post carried proxy_type and proxy_description fields, and their values (anonymous, hosting, public) line up with three of the questions above. The difference is what you get to do with them. There, the answer is two loose strings inside a JSON blob the ABI makes no promises about; here, each classification is its own function with its own type, and its own way of saying it has no answer. There's no "give me everything" call in this group at all, and no shared none covering the lot of them—is-tor-exit-node can come back none while is-hosting-provider comes back false.

Reading it directly

Full working code: full example on GitHub (opens in a new window). Ten near-identical option<bool> functions are exactly the case where a lookup table beats ten copy-pasted call sites:

Rust
mod bindings;

use bindings::{
    exports::fastly::compute::http_incoming,
    fastly::compute::{http_body, http_downstream, http_resp},
};


fn call_growable<T>(
    mut f: impl FnMut(u64) -> Result<T, http_downstream::Error>,
) -> Result<T, http_downstream::Error> {
    let mut max_len: u64 = 256;
    loop {
        match f(max_len) {
            Ok(v) => return Ok(v),
            Err(http_downstream::Error::BufferLen(needed)) => max_len = needed,
            Err(e) => return Err(e),
        }
    }
}

struct HttpDownstreamTlsBotsExample;

impl http_incoming::Guest for HttpDownstreamTlsBotsExample {
    fn handle(request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
        let mut lines = String::new();

        lines.push_str("-- TLS --\n");
        let cipher = call_growable(|max_len| {
            http_downstream::downstream_tls_cipher_openssl_name(&request, max_len)
        });
        lines.push_str(&format!("cipher-openssl-name: {cipher:?}\n"));

        let protocol =
            call_growable(|max_len| http_downstream::downstream_tls_protocol(&request, max_len));
        lines.push_str(&format!("protocol: {protocol:?}\n"));
        let client_hello = call_growable(|max_len| {
            http_downstream::downstream_tls_client_hello(&request, max_len)
        });
        lines.push_str(&format!("client-hello: {client_hello:?}\n"));
        let raw_cert = call_growable(|max_len| {
            http_downstream::downstream_tls_raw_client_certificate(&request, max_len)
        });
        lines.push_str(&format!("raw-client-certificate: {raw_cert:?}\n"));
        let verify_result = http_downstream::downstream_tls_client_cert_verify_result(&request);
        lines.push_str(&format!("client-cert-verify-result: {verify_result:?}\n"));
        let servername = call_growable(|max_len| {
            http_downstream::downstream_tls_client_servername(&request, max_len)
        });
        lines.push_str(&format!("client-servername: {servername:?}\n"));
        let ja3 = http_downstream::downstream_tls_ja3_md5(&request);
        lines.push_str(&format!("ja3-md5: {ja3:?}\n"));
        let ja4 = call_growable(|max_len| http_downstream::downstream_tls_ja4(&request, max_len));
        lines.push_str(&format!("ja4: {ja4:?}\n"));

        lines.push_str("\n-- Bot detection --\n");
        let analyzed = http_downstream::downstream_bot_analyzed(&request);
        lines.push_str(&format!("bot-analyzed: {analyzed:?}\n"));
        let detected = http_downstream::downstream_bot_detected(&request);
        lines.push_str(&format!("bot-detected: {detected:?}\n"));

        let name = call_growable(|max_len| http_downstream::downstream_bot_name(&request, max_len));
        lines.push_str(&format!("bot-name: {name:?}\n"));
        let category =
            call_growable(|max_len| http_downstream::downstream_bot_category(&request, max_len));
        lines.push_str(&format!("bot-category: {category:?}\n"));
        let category_kind = http_downstream::downstream_bot_category_kind(&request);
        lines.push_str(&format!("bot-category-kind: {category_kind:?}\n"));
        let verified = http_downstream::downstream_bot_verified(&request);
        lines.push_str(&format!("bot-verified: {verified:?}\n"));

        lines.push_str("\n-- VPN / proxy detection --\n");
        let checks: [(
            &str,
            fn(&http_incoming::Request) -> Result<Option<bool>, http_downstream::Error>,
        ); 10] = [
            ("is-anonymous", http_downstream::downstream_resvpnproxy_is_anonymous),
            ("is-anonymous-vpn", http_downstream::downstream_resvpnproxy_is_anonymous_vpn),
            ("is-hosting-provider", http_downstream::downstream_resvpnproxy_is_hosting_provider),
            ("is-proxy-over-vpn", http_downstream::downstream_resvpnproxy_is_proxy_over_vpn),
            ("is-public-proxy", http_downstream::downstream_resvpnproxy_is_public_proxy),
            ("is-relay-proxy", http_downstream::downstream_resvpnproxy_is_relay_proxy),
            ("is-residential-proxy", http_downstream::downstream_resvpnproxy_is_residential_proxy),
            ("is-smart-dns-proxy", http_downstream::downstream_resvpnproxy_is_smart_dns_proxy),
            ("is-tor-exit-node", http_downstream::downstream_resvpnproxy_is_tor_exit_node),
            ("is-vpn-datacenter", http_downstream::downstream_resvpnproxy_is_vpn_datacenter),
        ];
        for (name, check) in checks {
            let result = check(&request);
            lines.push_str(&format!("resvpnproxy-{name}: {result:?}\n"));
        }
        let vpn_service_name = call_growable(|max_len| {
            http_downstream::downstream_resvpnproxy_vpn_service_name(&request, max_len)
        });
        lines.push_str(&format!("resvpnproxy-vpn-service-name: {vpn_service_name:?}\n"));


        let response = http_resp::Response::new().map_err(|_| ())?;
        response.insert_header("content-type", b"text/plain").map_err(|_| ())?;
        let out_body = http_body::new().map_err(|_| ())?;
        http_body::write(&out_body, lines.as_bytes()).map_err(|_| ())?;
        http_resp::send_downstream(response, out_body).map_err(|_| ())?;

        Ok(())
    }
}

bindings::export!(HttpDownstreamTlsBotsExample with_types_in bindings);

The table works because every one of those ten functions really is the same Rust type after wit-bindgen generates it: fn(&Request) -> Result<Option<bool>, Error>, no exceptions, so an array of (&str, fn(...)) pairs typechecks and a for loop replaces what would otherwise be ten nearly-identical paragraphs of call-and-print.

Against Viceroy, a plain curl request over plain HTTP:

Terminal output
-- TLS --
cipher-openssl-name: Ok(None)
protocol: Ok(None)
client-hello: Ok(None)
raw-client-certificate: Ok(None)
client-cert-verify-result: Ok(None)
client-servername: Ok(None)
ja3-md5: Ok(None)
ja4: Ok(None)

-- Bot detection --
bot-analyzed: Ok(false)
bot-detected: Ok(false)
bot-name: Ok(None)
bot-category: Ok(None)
bot-category-kind: Ok(None)
bot-verified: Ok(None)

-- VPN / proxy detection --
resvpnproxy-is-anonymous: Ok(None)
resvpnproxy-is-anonymous-vpn: Ok(None)
resvpnproxy-is-hosting-provider: Ok(None)
resvpnproxy-is-proxy-over-vpn: Ok(None)
resvpnproxy-is-public-proxy: Ok(None)
resvpnproxy-is-relay-proxy: Ok(None)
resvpnproxy-is-residential-proxy: Ok(None)
resvpnproxy-is-smart-dns-proxy: Ok(None)
resvpnproxy-is-tor-exit-node: Ok(None)
resvpnproxy-is-vpn-datacenter: Ok(None)
resvpnproxy-vpn-service-name: Ok(None)

Compare this against last time's output, where h2-fingerprint and oh-fingerprint came back Err(GenericError) for the same reason—no real signal available locally. Everything on this page comes back a clean Ok(None) or Ok(false) instead, exactly matching what every doc comment above promises for "not a TLS connection" or "detection wasn't executed." This isn't a Viceroy gap the way the KV Store's blocking functions were; it's the ABI's own documented behavior holding up under a connection that genuinely has none of these signals to offer, and Viceroy honoring the contract precisely rather than faking a value it has no way to produce honestly.

Beyond the WIT

This is the part of the interface where the local development environment stops being able to help. An actual Fastly service at the edge sits on real network infrastructure, and these functions report on it: how the client's TLS stack is put together, whether the person behind it is on a VPN, whether Fastly's detection thinks it's a bot at all.

That changes what a binding is doing when it wraps one of these. compute-runtime's getters could reasonably be eager properties, computed once and cached. Wrapping downstream-tls-ja4 or downstream-resvpnproxy-is-anonymous-vpn the same way means running a classification lookup a caller might never ask for, on every single request, regardless of whether the code path that cares about VPN detection even runs.

The idiomatic shape here probably wants to stay lazy—a method, or a property that computes on first read: a Lazy<T> where the language has one, a memoized accessor where it doesn't. Then code that never asks "is this a bot" doesn't pay for finding out. It's a smaller decision than the resource-lifetime question this series keeps running into elsewhere, but it's the same instinct: match what the wrapper does to what the ABI's shape implies about cost and timing, not just to its types.