Skip to content
Open
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Honor `no_proxy`/`NO_PROXY` when routing HTTP and HTTPS requests through environment-configured proxies. #583

- Add `gpt-6-astra` support: built-in variants `low`, `medium`, `high`, `xhigh`, `max` (no `none`), Codex Lite/context fallbacks, web search and image generation.

## 0.158.1
Expand Down
24 changes: 24 additions & 0 deletions docs/config/network.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,30 @@ https_proxy="http://user:pass@host:port"

Lowercase wins if both are set. Credentials (if used) must match for HTTP and HTTPS.

### Bypassing the proxy

For ECA's Hato HTTP client (including LLM API requests), set `no_proxy` or `NO_PROXY`:

```bash
export no_proxy="localhost,127.0.0.1,.internal.example"
```

Lowercase takes precedence even when it is empty. Entries are comma-separated;
whitespace and empty entries are ignored. Matching is case-insensitive and uses
the URL hostname without resolving DNS:

- `internal.example` or `.internal.example` matches that host and its subdomains,
such as `api.internal.example`, but not `notinternal.example`.
- IP literals match exactly; write IPv6 addresses without brackets, such as `::1`.
- A value of `*` bypasses the configured proxy for every host.
- Host entries apply to all ports and both HTTP and HTTPS. Port-qualified entries
(`internal.example:8443`), CIDR ranges and partial wildcards (`*.example`) are
not supported and do not match.

When the variable is absent or empty, or no entry matches, the configured proxy
continues to be used. This bypass list applies to ECA's environment-configured
Hato proxies; it does not change JVM proxy properties or other HTTP clients.

## Custom CA certificates

When behind a corporate firewall that uses its own root CA, you will see errors like `PKIX path building failed`. To fix this, point ECA to a PEM file containing the additional CA certificates:
Expand Down
14 changes: 11 additions & 3 deletions src/eca/client_http.clj
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,30 @@
both are present, they must be identical, otherwise an exception is
thrown.

`:eca.client-http/no-proxy-hosts` optionally bypasses these proxies for
the hosts described by `eca.network/proxy-bypass?`.

Each proxy map includes:
:host - the proxy host
:port - the proxy port
:username - optional username for proxy authentication
:password - optional password for proxy authentication

Returns a map suitable for passing to `hato.client-http/build-http-client`."
[{:eca.client-http/keys [proxy-http proxy-https] :as opts}]
[{:eca.client-http/keys [proxy-http proxy-https no-proxy-hosts] :as opts}]
(logger/debug "[HATO]" "client-config:" opts)
(let [{http-host :host http-port :port http-user :username http-pass :password} proxy-http
{https-host :host https-port :port https-user :username https-pass :password} proxy-https
opts (apply dissoc opts [:eca.client-http/proxy-http :eca.client-http/proxy-https])
opts (apply dissoc opts [:eca.client-http/proxy-http :eca.client-http/proxy-https :eca.client-http/no-proxy-hosts])
proxy-http-addr (and http-host http-port (InetSocketAddress. ^String http-host ^int http-port))
proxy-https-addr (and https-host https-port (InetSocketAddress. ^String https-host ^int https-port))
proxy-selector (when (or proxy-http-addr proxy-https-addr)
(proxy [ProxySelector] []
(select [^URI uri]
(let [scheme (.getScheme uri)]
(cond
(network/proxy-bypass? (.getHost uri) no-proxy-hosts)
[Proxy/NO_PROXY]
(and proxy-http-addr (= scheme "http"))
[(Proxy. Proxy$Type/HTTP proxy-http-addr)]
(and proxy-https-addr (= scheme "https"))
Expand Down Expand Up @@ -114,14 +119,17 @@
settings are present in the environment
variables (`http_proxy`/`HTTP_PROXY` and `https_proxy`/`HTTPS_PROXY`),
the corresponding proxy configuration is added to the build.
`no_proxy`/`NO_PROXY` supplies the bypass hosts, preferring lowercase.

When a custom SSL context has been set up via `eca.network/setup!`,
it is included so that custom CA certificates and mTLS are honoured."
[hato-opts]
(enable-stale-connection-retry!)
(let [{:keys [http https] :as _env-proxies} (network/env-proxy-urls-parse)
ssl-ctx network/*ssl-context*
opts (cond-> (assoc hato-opts :executor ^java.util.concurrent.ExecutorService @shared-executor*)
opts (cond-> (assoc hato-opts
:executor ^java.util.concurrent.ExecutorService @shared-executor*
:eca.client-http/no-proxy-hosts (network/env-no-proxy-hosts))
http
(assoc :eca.client-http/proxy-http http)
https
Expand Down
24 changes: 24 additions & 0 deletions src/eca/network.clj
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,30 @@
[]
(proxy-urls-parse (proxy-urls-system-env-get)))

(defn env-no-proxy-hosts
"Reads comma-separated no_proxy/NO_PROXY hosts; lowercase wins, including an empty value."
[]
(->> (string/split (or (config/get-env "no_proxy") (config/get-env "NO_PROXY") "") #",")
(map (comp string/lower-case string/trim))
(remove string/blank?)
vec))

(defn proxy-bypass?
"Matches a URI host against no_proxy hosts without DNS lookups.
Hostnames match themselves and subdomains; IP literals match exactly.
A single * bypasses all hosts. Ports, CIDR and partial wildcards are unsupported."
[host no-proxy-hosts]
(when host
(let [host (-> host string/lower-case (string/replace #"^\[|\]$" ""))
ip? (or (string/includes? host ":") (re-matches #"[0-9.]+" host))]
(or (= ["*"] no-proxy-hosts)
(some (fn [entry]
(let [entry (string/replace entry #"^\." "")]
(and (not (string/blank? entry))
(or (= host entry)
(and (not ip?) (string/ends-with? host (str "." entry)))))))
no-proxy-hosts)))))

(defn ^:private non-blank [^String s]
(when-not (or (nil? s) (string/blank? s)) s))

Expand Down
89 changes: 88 additions & 1 deletion test/eca/client_http_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
[eca.client-test-helpers :refer [with-proxy *proxy-host* *proxy-port*]]
[eca.config :as config]
[hato.client :as hato])
(:import [java.io IOException]))
(:import [com.sun.net.httpserver HttpExchange HttpHandler HttpServer]
[java.io IOException]
[java.net InetSocketAddress Proxy ProxySelector URI]))

(set! *warn-on-reflection* true)

(deftest hato-client-make-test
(testing "proxy http setup"
Expand Down Expand Up @@ -216,3 +220,86 @@
(System/clearProperty prop))
(alter-var-root #'client/*hato-http-client* (constantly nil))))))
#_(hato-client-global-setup-tests)

(defn- loopback-server
"Start a loopback HTTP endpoint that records requests and identifies its route."
^HttpServer [route requests]
(let [server (HttpServer/create (InetSocketAddress. "127.0.0.1" 0) 0)]
(.createContext server "/"
(reify HttpHandler
(handle [_ exchange]
(with-open [^HttpExchange exchange exchange]
(swap! requests conj [route (str (.getRequestURI exchange))])
(let [body (.getBytes ^String route java.nio.charset.StandardCharsets/UTF_8)]
(.sendResponseHeaders exchange 200 (alength body))
(.write (.getResponseBody exchange) body))))))
(.start server)
server))

(deftest no-proxy-loopback-test
(let [requests (atom [])
destination (loopback-server "destination" requests)
proxy-server (loopback-server "proxy" requests)
destination-url (str "http://127.0.0.1:" (.getPort (.getAddress destination)) "/route")
proxy-url (str "http://127.0.0.1:" (.getPort (.getAddress proxy-server)))
original client/*hato-http-client*]
(try
(doseq [[label env route host]
[["absent" {} "proxy"]
["nonmatching" {"no_proxy" "other.example"} "proxy"]
["matching hostname" {"no_proxy" "LOCALHOST"} "destination" "localhost"]
["matching IP" {"no_proxy" "127.0.0.1"} "destination"]
["uppercase fallback" {"NO_PROXY" "127.0.0.1"} "destination"]
["lowercase wins" {"no_proxy" "other.example" "NO_PROXY" "127.0.0.1"} "proxy"]
["lowercase matching wins" {"no_proxy" "127.0.0.1" "NO_PROXY" "other.example"} "destination"]
["empty lowercase wins" {"no_proxy" "" "NO_PROXY" "*"} "proxy"]
["trim entries" {"no_proxy" " , other.example, 127.0.0.1 , "} "destination"]
["wildcard" {"no_proxy" "*"} "destination"]
["port qualifier unsupported" {"no_proxy" (str "127.0.0.1:" (.getPort (.getAddress destination)))} "proxy"]
["CIDR unsupported" {"no_proxy" "127.0.0.0/8"} "proxy"]]]
(testing label
(reset! requests [])
(with-redefs [config/get-env (merge {"http_proxy" proxy-url} env)]
(client/hato-client-global-setup! {:connect-timeout 2000})
(let [url (if host
(str "http://" host ":" (.getPort (.getAddress destination)) "/route")
destination-url)
response (hato/get url {:http-client client/*hato-http-client* :timeout 2000})]
(is (= 200 (:status response)))
(is (= route (:body response)))
(is (= [[route (if (= route "proxy") url "/route")]] @requests))))))
(finally
(alter-var-root #'client/*hato-http-client* (constantly original))
(.stop proxy-server 0)
(.stop destination 0)))))

(deftest no-proxy-host-matching-test
;; These select a route without resolving the synthetic hostnames or using TLS.
(doseq [scheme ["http" "https"]
[no-proxy host direct?]
[["internal.example" "internal.example" true]
["internal.example" "api.internal.example" true]
[".internal.example" "internal.example" true]
[".internal.example" "api.internal.example" true]
["INTERNAL.EXAMPLE" "API.INTERNAL.EXAMPLE" true]
["internal.example" "notinternal.example" false]
["internal.example" "internal.example.evil.test" false]
["internal.example:8443" "internal.example" false]
["*.internal.example" "api.internal.example" false]
["*" "any.example" true]
["other.example,*" "any.example" false]
["" "internal.example" false]
["0.0.1" "127.0.0.1" false]
["::1" "[::1]" true]]]
(testing (str scheme " " no-proxy " -> " host)
(let [original client/*hato-http-client*]
(try
(with-redefs [config/get-env {"http_proxy" "http://127.0.0.1:8888"
"https_proxy" "http://127.0.0.1:8888"
"no_proxy" no-proxy}]
(client/hato-client-global-setup! {})
(let [selector ^ProxySelector (:proxy client/*hato-http-client*)
proxies (.select selector (URI. (str scheme "://" host ":8443/path")))]
(is (= direct? (= [Proxy/NO_PROXY] proxies)))))
(finally
(alter-var-root #'client/*hato-http-client* (constantly original))))))))
Loading