NAME
    Proxy::Impersonate - EV MITM proxy that re-originates with a browser
    TLS/HTTP2 fingerprint

SYNOPSIS
        use EV;
        use Proxy::Impersonate;

        my $proxy = Proxy::Impersonate->new(
            impersonate => 'chrome131',
            listen      => '127.0.0.1:0',   # ephemeral port
            cert_dir    => '/path/to/ca',   # persists the self-signed cert
        );
        printf "proxy on 127.0.0.1:%d\n", $proxy->port;
        $proxy->run;   # EV loop

    A client (or EV::WebKit) uses it as an HTTP/HTTPS forward proxy. For
    HTTPS the client issues "CONNECT host:443"; the proxy terminates that
    TLS with its own cert, then re-originates the request upstream through
    Curl::Impersonate so the origin sees the chosen browser's TLS (JA3/JA4)
    and HTTP/2 (Akamai) fingerprint.

DESCRIPTION
    EV::WebKit cannot present a browser's connection fingerprint itself --
    WebKitGTK speaks GnuTLS/libsoup. This proxy sits in front of it: it
    MITMs WebKit's TLS on localhost, reads the plaintext request, and sends
    it upstream with a real browser's handshake via "libcurl-impersonate".
    The origin's TLS/HTTP2 fingerprint therefore matches the impersonated
    browser, not WebKit.

    It is an HTTP client, not a browser: it reproduces the connection
    fingerprint only. HTTP/3 and WebSockets are out of scope in this
    release, as are streaming request uploads. A CONNECT client must wait
    for the 200 response before beginning its TLS handshake: a ClientHello
    optimistically coalesced into the CONNECT segment is not supported (the
    connection is closed rather than left to stall). Browsers and libsoup --
    the intended clients -- already do this.

    Header-order ceiling: curl-impersonate reproduces the target's TLS
    (JA3/JA4) and HTTP/2 (Akamai) fingerprints exactly, and template headers
    keep their positions. But headers the proxy adds that are not in the
    template (Cookie, Referer, and the high-entropy Sec-CH-UA hints) are
    appended after the template block rather than in the browser's exact
    positions, so a header-order-only hash (e.g. JA4H) will not match on
    requests carrying them. The dominant fingerprints (JA3/JA4/Akamai) are
    unaffected.

    Priority ceiling: the proxy synthesizes a per-destination Accept, but
    the HTTP/2 request priority (the "priority" header and any
    PRIORITY_UPDATE frames) comes from curl-impersonate's static template,
    not from the resource type. Chrome varies urgency per resource; the
    proxy does not, so a resource-priority-aware fingerprinter could tell
    subresources apart. This lives in curl-impersonate's protocol layer, not
    in a header the proxy re-writes.

TRUST MODEL
    WebKitGTK 6.0 exposes no way to trust a custom CA (its network process
    honors neither "SSL_CERT_FILE" nor a settable "GTlsDatabase"; this was
    verified by a spike). So the proxy presents a single self-signed cert
    and the WebKit side is told to accept it:

        # on the EV::WebKit network session (sub-project 3 wires this):
        $session->set_tls_errors_policy('ignore');
        $browser->set_proxy("http://127.0.0.1:" . $proxy->port);

    This is safe: the WebKit-to-proxy hop is localhost, and the proxy
    re-verifies the real origin certificate upstream ("verify => 1", the
    default).

METHODS
  new
        my $proxy = Proxy::Impersonate->new(%opt);

    impersonate => $target
        Required. The Curl::Impersonate target (e.g. 'chrome131') applied to
        every upstream request. Keep it coherent with EV::WebKit's
        "fingerprint" profile.

    listen => 'host:port'
        Bind address; default '127.0.0.1:0' (an ephemeral port, reported by
        "port").

    cert_dir => $path
        Where the self-signed cert is persisted. Defaults to a temporary
        directory (mode 0700), which is the safe case.

        If you point this at a location of your own, note that an existing
        key there is adopted, and whoever can write that key can impersonate
        this proxy to the client it fronts -- which is configured to accept
        its certificate. So a key that is group- or world-accessible, owned
        by another user, or a symlink is refused rather than used. Keep it
        0600 and yours.

    on_request => sub { my ($req) = @_; ... }
        Per-request interception hook, called after TLS termination and
        before anything goes upstream -- so it sees every request the client
        makes (navigations, subresources, XHR, fetch), and can rewrite,
        answer or refuse each one.

        $req is a hashref with "method", "url", "headers" (a lowercase-keyed
        hashref), "body" and "host" (the bare hostname). Modify any of them
        in place to rewrite the request:

            on_request => sub {
                my ($req) = @_;
                $req->{url} =~ s{^https://cdn\.}{https://local-mirror.};
                $req->{headers}{'x-trace'} = 'yes';
                return;                       # proceed with the rewrite
            }

        Return value decides what happens next:

        nothing (or "undef")
            The request proceeds, carrying whatever rewrites the handler
            made.

        a hashref
            Answered locally; the network is never touched. Keys: "status"
            (default 200), "headers", "body". "Content-Length" is computed
            from the body, not taken from the handler, so a handler that
            disagrees with its own body cannot desynchronise the connection.
            Useful for mocking an endpoint, or for blocking with a visible
            answer:

                return { status => 403, body => 'blocked' } if $req->{host} =~ /ads\./;

        the string 'abort'
            The connection is closed without any response -- the closest
            thing to a network-level block.

        What the handler sees in "headers" is the set this proxy forces on
        top of Curl::Impersonate's template: what the client sent that must
        be carried through ("Cookie", "Referer", "Sec-Fetch-*",
        "Content-Type", ...). It does not include the headers
        curl-impersonate supplies from its fingerprint template
        ("User-Agent", "Accept", "Accept-Language", "Sec-CH-UA", ...) --
        forwarding the client's own would break the very fingerprint this
        proxy exists to reproduce. Setting any of those keys still works and
        overrides the template; you simply cannot read their template values
        here.

        A handler that dies refuses the request with a 502 and warns. It
        fails closed deliberately: this hook is used to block traffic, so an
        exception must not quietly let through exactly what the caller was
        trying to stop.

    on_response => sub { my ($res) = @_; ... }
        The counterpart to "on_request", called when the upstream response
        head arrives -- before any of it reaches the client, so the status
        and headers can be observed or rewritten. Stripping a policy header
        is the usual reason:

            on_response => sub {
                my ($res) = @_;
                delete $res->{headers}{'content-security-policy'};
                delete $res->{headers}{'x-frame-options'};
                return;
            }

        $res has "status", "headers" (lowercase-keyed), and -- for context
        -- the request's "url", "method" and "host". Modify "status" or
        "headers" in place; the return value is ignored.

        The framing is not yours. "Content-Length", "Connection" and the
        hop-by-hop headers are snapshotted before the hook and forced back
        after it: a handler that edits them does not desynchronise its own
        connection, it desynchronises the client's. Setting "content-length"
        to a value that disagrees with the body, or reintroducing
        "transfer-encoding", therefore has no effect.

        Bodies are out of scope: they stream through with backpressure, and
        buffering them to offer a rewrite would defeat that. Use
        "on_request"'s synthetic response if you need to replace content
        wholesale.

        A handler that dies passes the response through unchanged and warns.
        It fails open, unlike "on_request": the request has already been
        made and the response already fetched, so there is no security
        decision left to protect, and breaking the page over a bug in an
        observer would be the worse outcome.

    timeout => $seconds
        Per-request upstream timeout. Default 30.

    verify => $bool
        Verify the real origin's certificate upstream. Default true; leave
        it on.

    follow_redirects => $bool
        Whether the upstream client follows redirects. Default false -- the
        browser handles 3xx itself, so the proxy forwards them.

  port
    The bound listen port (useful with "listen => '...:0'").

  cert_dir
    The directory holding the self-signed cert.

  run
    Run the EV loop. Blocks until "stop" or "EV::break".

  stop
    Stop accepting and break the EV loop. Use this when the proxy owns the
    loop -- i.e. when you called "run".

  shutdown
    Stop accepting, close every active connection, and release the
    "curl_multi" wiring -- without breaking the EV loop, so a caller whose
    loop is shared keeps running. That is the difference from "stop": this
    is the in-process teardown, and it is what EV::WebKit calls when the
    browser it fronts quits.

    Safe to call more than once, and safe from inside a callback: it is
    plain EV/Perl with no GObject-Introspection dispatch to unwind.

REQUEST HANDLING LIMITS
    The proxy refuses what it cannot frame exactly, because guessing would
    leave bytes in the read buffer to be re-parsed as a second request:

    *   A request head over 64KB is closed, which also caps a slow-drip
        head.

    *   A chunked request body gets 411: there is no chunked decoder, since
        streaming uploads are out of scope.

    *   A "Content-Length" that is repeated with disagreeing values, or is
        not a plain non-negative integer, gets 400. An identical repeat is
        legal and is accepted.

    *   A header value containing NUL, CR or LF gets 400. RFC 9110 bars all
        three from a field value; CR and LF cannot survive the line split,
        but a NUL can, and forwarded headers are re-sent upstream.

    Request bodies are buffered whole before being forwarded -- there is no
    streaming upload path, and therefore no size cap. A client that uploads
    a gigabyte makes the proxy hold a gigabyte. That is tolerable because
    the listener is bound to localhost by default and fronts one browser,
    but it is worth knowing before binding it anywhere else.

REQUIREMENTS
    Curl::Impersonate 0.01 or later, Net::SSLeay, EV.

SEE ALSO
    Curl::Impersonate, EV::WebKit

AUTHOR
    vividsnow

LICENSE
    This library is free software; you can redistribute it and/or modify it
    under the same terms as Perl itself.

