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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
20 changes: 19 additions & 1 deletion docs/config/remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}
Expand Down
43 changes: 29 additions & 14 deletions src/eca/remote/server.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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))))
Expand All @@ -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)))))))

Expand All @@ -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)
Expand All @@ -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))
Expand Down
Loading