diff --git a/CHANGELOG.md b/CHANGELOG.md index 120b29818..08b0ed206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add `remote.bindHost` config to force the remote server's listening interface (e.g. a Tailscale IP), binding only there with no localhost fallback. + ## 0.136.3 - Anthropic: request uncompressed SSE (`accept-encoding: identity` + `:decompress-body false`) so streamed responses arrive token-by-token instead of all at once. diff --git a/docs/config.json b/docs/config.json index f58ae43e3..e04296abf 100644 --- a/docs/config.json +++ b/docs/config.json @@ -394,13 +394,22 @@ }, "host": { "type": "string", - "description": "Host used in the logged URL for web.eca.dev to connect back to. Can be a LAN IP, public IP, domain, or tunnel URL. When unset, auto-detected via InetAddress/getLocalHost.", - "markdownDescription": "Host used in the logged URL for `web.eca.dev` to connect back to. Can be a LAN IP, public IP, domain, or tunnel URL. When unset, auto-detected via `InetAddress/getLocalHost`.", + "description": "Host used in the logged URL for web.eca.dev to connect back to. Can be a LAN IP, public IP, domain, or tunnel URL. When unset, auto-detected via InetAddress/getLocalHost. This is display-only and does not affect which interface the server binds to (see bindHost).", + "markdownDescription": "Host used in the logged URL for `web.eca.dev` to connect back to. Can be a LAN IP, public IP, domain, or tunnel URL. When unset, auto-detected via `InetAddress/getLocalHost`. This is display-only and does not affect which interface the server binds to (see `bindHost`).", "examples": [ "192.168.1.42", "myserver.example.com" ] }, + "bindHost": { + "type": "string", + "description": "Network interface the HTTP server binds to. When unset, ECA auto-detects. Set this to pin the server to a specific interface — e.g. a Tailscale/WireGuard IP, or '0.0.0.0' to bind all interfaces.", + "markdownDescription": "Network interface the HTTP server binds to. When unset, ECA auto-detects. Set this to pin the server to a specific interface — e.g. a Tailscale/WireGuard IP, or `0.0.0.0` to bind all interfaces.", + "examples": [ + "100.101.146.110", + "0.0.0.0" + ] + }, "port": { "type": "integer", "description": "Port the HTTP server listens on. When unset, tries port 7777, then 7778, 7779, etc. up to 20 attempts.", diff --git a/docs/config/remote.md b/docs/config/remote.md index dee2bf856..9e49237f8 100644 --- a/docs/config/remote.md +++ b/docs/config/remote.md @@ -87,6 +87,20 @@ When enabled, ECA starts an embedded HTTPS server that the web frontend at [web. 4. Open `https://web.eca.dev` and enter your Tailscale hostname and password + !!! tip "Binding to a specific interface" + Set `bindHost` to pin the server to a specific interface — e.g. your + Tailscale IP — instead of the default auto-detection. ECA then binds + only that interface: + + ```javascript title="~/.config/eca/config.json" + { + "remote": { + "enabled": true, + "bindHost": "100.101.146.110" + } + } + ``` + === "Local Docker" Run the web frontend locally over HTTP — useful for development or restricted environments. @@ -118,8 +132,12 @@ When enabled, ECA starts an embedded HTTPS server that the web frontend at [web. { "remote": { "enabled": true, - // optional — override the hostname in the connect URL (e.g. Tailscale DNS) + // optional — override the hostname in the connect URL (e.g. Tailscale DNS). + // Display-only; does NOT change which interface the server binds to. "host": "my-machine.tail1234.ts.net", + // optional — pin the interface the server binds to (e.g. a Tailscale IP). + // When unset, ECA auto-detects. Use "0.0.0.0" to bind all interfaces. + "bindHost": "100.101.146.110", // optional — defaults to 7777, auto-increments up to 7796 if busy "port": 9876, // optional — auto-generated when unset, supports ${env:MY_PASS} diff --git a/src/eca/remote/server.clj b/src/eca/remote/server.clj index 0af2c640f..a691876c3 100644 --- a/src/eca/remote/server.clj +++ b/src/eca/remote/server.clj @@ -219,20 +219,29 @@ [server (if lan-ip "127.0.0.1+lan" "127.0.0.1")])) (defn ^:private try-start-jetty-any-host - "Tries to start Jetty on the given port. On Windows with active tunnel - interfaces (Tailscale, WireGuard, etc.), skips the 0.0.0.0 wildcard bind - because Windows would capture traffic on the tunnel interface, preventing - services like `tailscale serve` from terminating TLS on the same port. - Otherwise attempts 0.0.0.0 first for full connectivity, falling back to - 127.0.0.1 + LAN IP connector. + "Tries to start Jetty on the given port. + When bind-host is provided, binds ONLY to that interface (no fallback). + On Windows with active tunnel interfaces (Tailscale, WireGuard, etc.), + skips the 0.0.0.0 wildcard bind because Windows would capture traffic on + the tunnel interface, preventing services like `tailscale serve` from + terminating TLS on the same port. + Otherwise attempts 0.0.0.0 first, falling back to 127.0.0.1 + LAN IP connector. Returns [server bind-host] on success, nil if all fail." - [handler port lan-ip ^SSLContext ssl-context] - (if (and shared/windows-os? (has-tunnel-interfaces?)) + [handler port lan-ip ^SSLContext ssl-context bind-host] + (cond + ;; Explicit bind host: bind only there, no wildcard attempt or fallback. + bind-host + (when-let [server (try-start-jetty handler port bind-host ssl-context)] + [server bind-host]) + ;; On Windows with tunnel interfaces, bind only to specific interfaces ;; to avoid stealing traffic from Tailscale/WireGuard virtual interfaces. + (and shared/windows-os? (has-tunnel-interfaces?)) (do (logger/debug logger-tag "Tunnel interface detected on Windows, binding to specific interfaces only") (start-on-specific-interfaces handler port lan-ip ssl-context)) + ;; Default: try 0.0.0.0 first, fall back to specific interfaces + :else (if-let [server (try-start-jetty handler port "0.0.0.0" ssl-context)] [server "0.0.0.0"] (start-on-specific-interfaces handler port lan-ip ssl-context)))) @@ -241,12 +250,12 @@ "Tries sequential ports starting from base-port up to max-port-attempts. For each port, tries all bind-hosts before moving to the next port. Returns [server actual-port bind-host] on success, nil if all attempts fail." - [handler base-port lan-ip ssl-context] + [handler base-port lan-ip ssl-context bind-host] (loop [port base-port attempts 0] (when (< attempts max-port-attempts) - (if-let [[server bind-host] (try-start-jetty-any-host handler port lan-ip ssl-context)] - [server (.getLocalPort ^NetworkConnector (first (.getConnectors ^Server server))) bind-host] + (if-let [[server actual-bind-host] (try-start-jetty-any-host handler port lan-ip ssl-context bind-host)] + [server (.getLocalPort ^NetworkConnector (first (.getConnectors ^Server server))) actual-bind-host] (do (logger/debug logger-tag (str "Port " port " in use, trying " (inc port) "...")) (recur (inc port) (inc attempts))))))) @@ -261,7 +270,13 @@ remote-config (:remote config)] (when (:enabled remote-config) (let [token (or (:password remote-config) (auth/generate-token)) - host-base (or (:host remote-config) (detect-host)) + bind-host (:bindHost remote-config) + host-base (or (:host remote-config) + ;; Default the display host to the explicit bind host + ;; (e.g. a Tailscale IP) unless it's the wildcard. + (when (and bind-host (not= bind-host "0.0.0.0")) + bind-host) + (detect-host)) lan-ip (detect-lan-ip) user-port (:port remote-config) ssl-context (build-server-ssl-context) @@ -275,13 +290,13 @@ (if-let [[^Server jetty-server actual-port bind-host] (if user-port ;; User-specified port: single attempt, try all bind hosts - (if-let [[server bh] (try-start-jetty-any-host handler user-port lan-ip ssl-context)] + (if-let [[server bh] (try-start-jetty-any-host handler user-port lan-ip ssl-context bind-host)] [server (.getLocalPort ^NetworkConnector (first (.getConnectors ^Server server))) bh] (do (logger/warn logger-tag "Port" user-port "is already in use." "Remote server will not start.") nil)) ;; Default: try sequential ports starting from default-port - (or (start-with-retry handler default-port lan-ip ssl-context) + (or (start-with-retry handler default-port lan-ip ssl-context bind-host) (do (logger/warn logger-tag (str "Could not bind to ports " default-port "-" (+ default-port (dec max-port-attempts))