|
11 | 11 | (:import |
12 | 12 | [java.io IOException] |
13 | 13 | [java.net BindException Inet4Address InetAddress NetworkInterface] |
14 | | - [org.eclipse.jetty.server NetworkConnector Server])) |
| 14 | + [org.eclipse.jetty.server NetworkConnector Server ServerConnector])) |
15 | 15 |
|
16 | 16 | (set! *warn-on-reflection* true) |
17 | 17 |
|
|
63 | 63 | (.isSiteLocalAddress (InetAddress/getByName ip)) |
64 | 64 | (catch Exception _ false))) |
65 | 65 |
|
66 | | -(def ^:private bind-hosts |
67 | | - "Ordered list of bind addresses to try per port. |
68 | | - 0.0.0.0 gives full connectivity; 127.0.0.1 is the fallback when another |
69 | | - service (e.g. Tailscale) already holds the port on a specific interface." |
70 | | - ["0.0.0.0" "127.0.0.1"]) |
| 66 | +(defn ^:private try-start-jetty |
| 67 | + "Tries to start Jetty on the given port and host. |
| 68 | + Returns the Server on success, nil on BindException/IOException." |
| 69 | + ^Server [handler port host] |
| 70 | + (try |
| 71 | + (let [server (jetty/run-jetty handler {:port port :host host :join? false})] |
| 72 | + (logger/debug logger-tag (str "Bound to " host ":" port)) |
| 73 | + server) |
| 74 | + (catch BindException _ nil) |
| 75 | + (catch IOException _ nil))) |
| 76 | + |
| 77 | +(defn ^:private add-connector! |
| 78 | + "Adds a secondary ServerConnector to an existing Jetty server. |
| 79 | + Returns true on success, false on bind failure." |
| 80 | + [^Server server port host] |
| 81 | + (try |
| 82 | + (let [connector (doto (ServerConnector. server) |
| 83 | + (.setHost host) |
| 84 | + (.setPort port))] |
| 85 | + (.addConnector server connector) |
| 86 | + (.start connector) |
| 87 | + (logger/debug logger-tag (str "Added connector " host ":" port)) |
| 88 | + true) |
| 89 | + (catch BindException _ false) |
| 90 | + (catch IOException _ false))) |
71 | 91 |
|
72 | 92 | (defn ^:private try-start-jetty-any-host |
73 | | - "Tries to start Jetty on the given port, attempting each bind address in |
74 | | - bind-hosts order. Returns the Server on success, nil if all fail." |
75 | | - ^Server [handler port] |
76 | | - (reduce (fn [_ bind-host] |
77 | | - (try |
78 | | - (let [server (jetty/run-jetty handler {:port port :host bind-host :join? false})] |
79 | | - (logger/debug logger-tag (str "Bound to " bind-host ":" port)) |
80 | | - (reduced server)) |
81 | | - (catch BindException _ nil) |
82 | | - (catch IOException _ nil))) |
83 | | - nil |
84 | | - bind-hosts)) |
| 93 | + "Tries to start Jetty on the given port. Attempts 0.0.0.0 first for full |
| 94 | + connectivity. When that fails (e.g. Tailscale holds the port on its virtual |
| 95 | + interface), binds to 127.0.0.1 and adds the LAN IP as a secondary connector |
| 96 | + so that both Tailscale proxy (which targets localhost) and Direct LAN work. |
| 97 | + Returns [server bind-host] on success, nil if all fail." |
| 98 | + [handler port lan-ip] |
| 99 | + ;; 1. Try 0.0.0.0 — covers all interfaces in one binding |
| 100 | + (if-let [server (try-start-jetty handler port "0.0.0.0")] |
| 101 | + [server "0.0.0.0"] |
| 102 | + ;; 2. 0.0.0.0 failed — bind localhost first (for Tailscale proxy), then |
| 103 | + ;; add the LAN IP as a secondary connector so Direct LAN also works. |
| 104 | + (when-let [server (try-start-jetty handler port "127.0.0.1")] |
| 105 | + (when lan-ip |
| 106 | + (if (add-connector! server port lan-ip) |
| 107 | + (logger/debug logger-tag (str "Also listening on " lan-ip ":" port " for Direct LAN")) |
| 108 | + (logger/warn logger-tag (str "Could not bind to " lan-ip ":" port " — Direct LAN connections may not work")))) |
| 109 | + [server (if lan-ip "127.0.0.1+lan" "127.0.0.1")]))) |
85 | 110 |
|
86 | 111 | (defn ^:private start-with-retry |
87 | 112 | "Tries sequential ports starting from base-port up to max-port-attempts. |
88 | 113 | For each port, tries all bind-hosts before moving to the next port. |
89 | | - Returns [server actual-port] on success, nil if all attempts fail." |
90 | | - [handler base-port] |
| 114 | + Returns [server actual-port bind-host] on success, nil if all attempts fail." |
| 115 | + [handler base-port lan-ip] |
91 | 116 | (loop [port base-port |
92 | 117 | attempts 0] |
93 | 118 | (when (< attempts max-port-attempts) |
94 | | - (if-let [server (try-start-jetty-any-host handler port)] |
95 | | - [server (.getLocalPort ^NetworkConnector (first (.getConnectors ^Server server)))] |
| 119 | + (if-let [[server bind-host] (try-start-jetty-any-host handler port lan-ip)] |
| 120 | + [server (.getLocalPort ^NetworkConnector (first (.getConnectors ^Server server))) bind-host] |
96 | 121 | (do (logger/debug logger-tag (str "Port " port " in use, trying " (inc port) "...")) |
97 | 122 | (recur (inc port) (inc attempts))))))) |
98 | 123 |
|
|
108 | 133 | (when (:enabled remote-config) |
109 | 134 | (let [token (or (:password remote-config) (auth/generate-token)) |
110 | 135 | host-base (or (:host remote-config) (detect-host)) |
| 136 | + lan-ip (detect-lan-ip) |
111 | 137 | user-port (:port remote-config) |
112 | 138 | ;; Use atom so the handler sees host:port after Jetty resolves the actual port |
113 | 139 | host+port* (atom host-base) |
|
116 | 142 | :host* host+port* |
117 | 143 | :sse-connections* sse-connections*})] |
118 | 144 | (try |
119 | | - (if-let [[^Server jetty-server actual-port] |
| 145 | + (if-let [[^Server jetty-server actual-port bind-host] |
120 | 146 | (if user-port |
121 | 147 | ;; User-specified port: single attempt, try all bind hosts |
122 | | - (if-let [server (try-start-jetty-any-host handler user-port)] |
123 | | - [server (.getLocalPort ^NetworkConnector (first (.getConnectors ^Server server)))] |
| 148 | + (if-let [[server bh] (try-start-jetty-any-host handler user-port lan-ip)] |
| 149 | + [server (.getLocalPort ^NetworkConnector (first (.getConnectors ^Server server))) bh] |
124 | 150 | (do (logger/warn logger-tag "Port" user-port "is already in use." |
125 | 151 | "Remote server will not start.") |
126 | 152 | nil)) |
127 | 153 | ;; Default: try sequential ports starting from default-port |
128 | | - (or (start-with-retry handler default-port) |
| 154 | + (or (start-with-retry handler default-port lan-ip) |
129 | 155 | (do (logger/warn logger-tag |
130 | 156 | (str "Could not bind to ports " default-port "-" |
131 | 157 | (+ default-port (dec max-port-attempts)) |
|
135 | 161 | _ (reset! host+port* host-with-port) |
136 | 162 | heartbeat-ch (sse/start-heartbeat! sse-connections*) |
137 | 163 | private? (private-ip? host-base) |
138 | | - connect-url (when-not private? |
| 164 | + localhost-only? (and (= bind-host "127.0.0.1") (not lan-ip)) |
| 165 | + connect-url (if private? |
| 166 | + (str "https://web.eca.dev?host=" |
| 167 | + host-with-port |
| 168 | + "&pass=" token |
| 169 | + "&protocol=http") |
139 | 170 | (str "https://web.eca.dev?host=" |
140 | 171 | host-with-port |
141 | | - "&pass=" token))] |
| 172 | + "&pass=" token |
| 173 | + "&protocol=https"))] |
| 174 | + (when (and localhost-only? private? (not= host-base "127.0.0.1")) |
| 175 | + (logger/warn logger-tag |
| 176 | + (str "⚠️ Bound to 127.0.0.1:" actual-port " (localhost only) because another service " |
| 177 | + "(e.g. Tailscale) holds port " actual-port " on the external interface. " |
| 178 | + "Direct LAN connections to " host-base " will not work. " |
| 179 | + "Use a different port, stop the conflicting service, or connect via Tailscale."))) |
142 | 180 | (logger/info logger-tag (str "🌐 Remote server started on port " actual-port)) |
143 | | - (if connect-url |
144 | | - (logger/info logger-tag (str "🔗 " connect-url)) |
145 | | - (do (logger/info logger-tag (str "🔗 http://" host-with-port)) |
146 | | - (logger/info logger-tag "📖 Private IP detected — see https://eca.dev/config/remote for connection options"))) |
| 181 | + (logger/info logger-tag (str "🔗 " connect-url)) |
147 | 182 | {:server jetty-server |
148 | 183 | :sse-connections* sse-connections* |
149 | 184 | :heartbeat-stop-ch heartbeat-ch |
|
0 commit comments