|
2 | 2 | "HTTP server lifecycle for the remote web control server." |
3 | 3 | (:require |
4 | 4 | [clojure.core.async :as async] |
| 5 | + [clojure.java.io :as io] |
| 6 | + [clojure.string :as string] |
5 | 7 | [eca.config :as config] |
6 | 8 | [eca.logger :as logger] |
7 | 9 | [eca.remote.auth :as auth] |
|
11 | 13 | (:import |
12 | 14 | [java.io IOException] |
13 | 15 | [java.net BindException Inet4Address InetAddress NetworkInterface] |
14 | | - [org.eclipse.jetty.server NetworkConnector Server ServerConnector])) |
| 16 | + [java.security KeyFactory KeyStore] |
| 17 | + [java.security.cert CertificateFactory] |
| 18 | + [java.security.spec PKCS8EncodedKeySpec] |
| 19 | + [java.util Base64] |
| 20 | + [javax.net.ssl KeyManagerFactory SSLContext] |
| 21 | + [org.eclipse.jetty.server NetworkConnector Server ServerConnector] |
| 22 | + [org.eclipse.jetty.util.ssl SslContextFactory$Server])) |
15 | 23 |
|
16 | 24 | (set! *warn-on-reflection* true) |
17 | 25 |
|
|
79 | 87 | (.isSiteLocalAddress (InetAddress/getByName ip)) |
80 | 88 | (catch Exception _ false))) |
81 | 89 |
|
| 90 | +(def ^:private sslip-domain |
| 91 | + "Domain suffix for sslip.io-style hostnames that resolve to the embedded IP." |
| 92 | + "local.eca.dev") |
| 93 | + |
| 94 | +(defn ^:private build-server-ssl-context |
| 95 | + "Loads the bundled TLS certificate chain and private key from classpath |
| 96 | + resources and builds an SSLContext for the HTTPS server. |
| 97 | + Returns nil if the resources are not found (TLS disabled)." |
| 98 | + ^SSLContext [] |
| 99 | + (let [cert-url (io/resource "tls/local-eca-dev-fullchain.pem") |
| 100 | + key-url (io/resource "tls/local-eca-dev-privkey.pem")] |
| 101 | + (when (and cert-url key-url) |
| 102 | + (try |
| 103 | + (let [cf (CertificateFactory/getInstance "X.509") |
| 104 | + certs (with-open [is (.openStream ^java.net.URL cert-url)] |
| 105 | + (into [] (.generateCertificates cf is))) |
| 106 | + pem-text (slurp key-url) |
| 107 | + b64 (->> (string/split-lines pem-text) |
| 108 | + (remove #(or (string/starts-with? % "-----BEGIN") |
| 109 | + (string/starts-with? % "-----END"))) |
| 110 | + (map string/trim) |
| 111 | + (remove string/blank?) |
| 112 | + (string/join)) |
| 113 | + key-bytes (.decode (Base64/getDecoder) ^String b64) |
| 114 | + pk (or (try (.generatePrivate (KeyFactory/getInstance "RSA") |
| 115 | + (PKCS8EncodedKeySpec. key-bytes)) |
| 116 | + (catch Exception _ nil)) |
| 117 | + (try (.generatePrivate (KeyFactory/getInstance "EC") |
| 118 | + (PKCS8EncodedKeySpec. key-bytes)) |
| 119 | + (catch Exception _ nil))) |
| 120 | + ks (doto (KeyStore/getInstance (KeyStore/getDefaultType)) |
| 121 | + (.load nil nil) |
| 122 | + (.setKeyEntry "server" pk (char-array 0) |
| 123 | + (into-array java.security.cert.Certificate certs))) |
| 124 | + kmf (doto (KeyManagerFactory/getInstance (KeyManagerFactory/getDefaultAlgorithm)) |
| 125 | + (.init ks (char-array 0))) |
| 126 | + ctx (doto (SSLContext/getInstance "TLS") |
| 127 | + (.init (.getKeyManagers kmf) nil nil))] |
| 128 | + (logger/info logger-tag (str "TLS enabled with bundled *." sslip-domain " certificate")) |
| 129 | + ctx) |
| 130 | + (catch Exception e |
| 131 | + (logger/warn logger-tag "Failed to load TLS certificates:" (.getMessage e)) |
| 132 | + nil))))) |
| 133 | + |
| 134 | +(defn ^:private ip->sslip-hostname |
| 135 | + "Converts an IP address to an sslip.io-style hostname under local.eca.dev. |
| 136 | + E.g. \"192.168.15.17\" → \"192-168-15-17.local.eca.dev\"" |
| 137 | + ^String [^String ip] |
| 138 | + (str (string/replace ip "." "-") "." sslip-domain)) |
| 139 | + |
82 | 140 | (defn ^:private try-start-jetty |
83 | 141 | "Tries to start Jetty on the given port and host. |
| 142 | + When ssl-context is provided, starts HTTPS-only (no HTTP listener). |
84 | 143 | Returns the Server on success, nil on BindException/IOException." |
85 | | - ^Server [handler port host] |
| 144 | + ^Server [handler port host ^SSLContext ssl-context] |
86 | 145 | (try |
87 | | - (let [server (jetty/run-jetty handler {:port port :host host :join? false})] |
88 | | - (logger/debug logger-tag (str "Bound to " host ":" port)) |
| 146 | + (let [opts (if ssl-context |
| 147 | + {:ssl? true :ssl-port port :http? false |
| 148 | + :ssl-context ssl-context :host host :join? false} |
| 149 | + {:port port :host host :join? false}) |
| 150 | + server (jetty/run-jetty handler opts)] |
| 151 | + (logger/debug logger-tag (str "Bound to " host ":" port (when ssl-context " (HTTPS)"))) |
89 | 152 | server) |
90 | 153 | (catch BindException _ nil) |
91 | 154 | (catch IOException _ nil))) |
92 | 155 |
|
93 | 156 | (defn ^:private add-connector! |
94 | 157 | "Adds a secondary ServerConnector to an existing Jetty server. |
| 158 | + When ssl-context is provided, creates an SSL-enabled connector. |
95 | 159 | Returns true on success, false on bind failure." |
96 | | - [^Server server port host] |
| 160 | + [^Server server port host ^SSLContext ssl-context] |
97 | 161 | (try |
98 | | - (let [connector (doto (ServerConnector. server) |
99 | | - (.setHost host) |
100 | | - (.setPort port))] |
| 162 | + (let [connector (if ssl-context |
| 163 | + (let [ssl-factory (doto (SslContextFactory$Server.) |
| 164 | + (.setSslContext ssl-context))] |
| 165 | + (doto (ServerConnector. server ^SslContextFactory$Server ssl-factory) |
| 166 | + (.setHost ^String host) |
| 167 | + (.setPort (int port)))) |
| 168 | + (doto (ServerConnector. server) |
| 169 | + (.setHost ^String host) |
| 170 | + (.setPort (int port))))] |
101 | 171 | (.addConnector server connector) |
102 | 172 | (.start connector) |
103 | | - (logger/debug logger-tag (str "Added connector " host ":" port)) |
| 173 | + (logger/debug logger-tag (str "Added connector " host ":" port (when ssl-context " (HTTPS)"))) |
104 | 174 | true) |
105 | 175 | (catch BindException _ false) |
106 | 176 | (catch IOException _ false))) |
|
111 | 181 | interface), binds to 127.0.0.1 and adds the LAN IP as a secondary connector |
112 | 182 | so that both Tailscale proxy (which targets localhost) and Direct LAN work. |
113 | 183 | Returns [server bind-host] on success, nil if all fail." |
114 | | - [handler port lan-ip] |
| 184 | + [handler port lan-ip ^SSLContext ssl-context] |
115 | 185 | ;; 1. Try 0.0.0.0 — covers all interfaces in one binding |
116 | | - (if-let [server (try-start-jetty handler port "0.0.0.0")] |
| 186 | + (if-let [server (try-start-jetty handler port "0.0.0.0" ssl-context)] |
117 | 187 | [server "0.0.0.0"] |
118 | 188 | ;; 2. 0.0.0.0 failed — bind localhost first (for Tailscale proxy), then |
119 | 189 | ;; add the LAN IP as a secondary connector so Direct LAN also works. |
120 | | - (when-let [server (try-start-jetty handler port "127.0.0.1")] |
| 190 | + (when-let [server (try-start-jetty handler port "127.0.0.1" ssl-context)] |
121 | 191 | (when lan-ip |
122 | | - (if (add-connector! server port lan-ip) |
| 192 | + (if (add-connector! server port lan-ip ssl-context) |
123 | 193 | (logger/debug logger-tag (str "Also listening on " lan-ip ":" port " for Direct LAN")) |
124 | 194 | (logger/warn logger-tag (str "Could not bind to " lan-ip ":" port " — Direct LAN connections may not work")))) |
125 | 195 | [server (if lan-ip "127.0.0.1+lan" "127.0.0.1")]))) |
|
128 | 198 | "Tries sequential ports starting from base-port up to max-port-attempts. |
129 | 199 | For each port, tries all bind-hosts before moving to the next port. |
130 | 200 | Returns [server actual-port bind-host] on success, nil if all attempts fail." |
131 | | - [handler base-port lan-ip] |
| 201 | + [handler base-port lan-ip ssl-context] |
132 | 202 | (loop [port base-port |
133 | 203 | attempts 0] |
134 | 204 | (when (< attempts max-port-attempts) |
135 | | - (if-let [[server bind-host] (try-start-jetty-any-host handler port lan-ip)] |
| 205 | + (if-let [[server bind-host] (try-start-jetty-any-host handler port lan-ip ssl-context)] |
136 | 206 | [server (.getLocalPort ^NetworkConnector (first (.getConnectors ^Server server))) bind-host] |
137 | 207 | (do (logger/debug logger-tag (str "Port " port " in use, trying " (inc port) "...")) |
138 | 208 | (recur (inc port) (inc attempts))))))) |
|
151 | 221 | host-base (or (:host remote-config) (detect-host)) |
152 | 222 | lan-ip (detect-lan-ip) |
153 | 223 | user-port (:port remote-config) |
| 224 | + ssl-context (build-server-ssl-context) |
154 | 225 | ;; Use atom so the handler sees host:port after Jetty resolves the actual port |
155 | 226 | host+port* (atom host-base) |
156 | 227 | handler (routes/create-handler components |
|
161 | 232 | (if-let [[^Server jetty-server actual-port bind-host] |
162 | 233 | (if user-port |
163 | 234 | ;; User-specified port: single attempt, try all bind hosts |
164 | | - (if-let [[server bh] (try-start-jetty-any-host handler user-port lan-ip)] |
| 235 | + (if-let [[server bh] (try-start-jetty-any-host handler user-port lan-ip ssl-context)] |
165 | 236 | [server (.getLocalPort ^NetworkConnector (first (.getConnectors ^Server server))) bh] |
166 | 237 | (do (logger/warn logger-tag "Port" user-port "is already in use." |
167 | 238 | "Remote server will not start.") |
168 | 239 | nil)) |
169 | 240 | ;; Default: try sequential ports starting from default-port |
170 | | - (or (start-with-retry handler default-port lan-ip) |
| 241 | + (or (start-with-retry handler default-port lan-ip ssl-context) |
171 | 242 | (do (logger/warn logger-tag |
172 | 243 | (str "Could not bind to ports " default-port "-" |
173 | 244 | (+ default-port (dec max-port-attempts)) |
174 | 245 | ". Remote server will not start.")) |
175 | 246 | nil)))] |
176 | | - (let [host-with-port (str host-base ":" actual-port) |
| 247 | + (let [private? (private-ip? host-base) |
| 248 | + display-host (if (and ssl-context private?) |
| 249 | + (ip->sslip-hostname host-base) |
| 250 | + host-base) |
| 251 | + host-with-port (str display-host ":" actual-port) |
177 | 252 | _ (reset! host+port* host-with-port) |
178 | 253 | heartbeat-ch (sse/start-heartbeat! sse-connections*) |
179 | | - private? (private-ip? host-base) |
180 | 254 | localhost-only? (and (= bind-host "127.0.0.1") (not lan-ip)) |
181 | | - connect-url (if private? |
182 | | - (str "https://web.eca.dev?host=" |
183 | | - host-with-port |
184 | | - "&pass=" token |
185 | | - "&protocol=http") |
186 | | - (str "https://web.eca.dev?host=" |
187 | | - host-with-port |
188 | | - "&pass=" token |
189 | | - "&protocol=https"))] |
| 255 | + protocol (if ssl-context "https" (if private? "http" "https")) |
| 256 | + connect-url (str "https://web.eca.dev?host=" |
| 257 | + host-with-port |
| 258 | + "&pass=" token |
| 259 | + "&protocol=" protocol)] |
190 | 260 | (when (and localhost-only? private? (not= host-base "127.0.0.1")) |
191 | 261 | (logger/warn logger-tag |
192 | 262 | (str "⚠️ Bound to 127.0.0.1:" actual-port " (localhost only) because another service " |
193 | 263 | "(e.g. Tailscale) holds port " actual-port " on the external interface. " |
194 | 264 | "Direct LAN connections to " host-base " will not work. " |
195 | 265 | "Use a different port, stop the conflicting service, or connect via Tailscale."))) |
196 | | - (logger/info logger-tag (str "🌐 Remote server started on port " actual-port " — use /remote for connection details")) |
| 266 | + (logger/info logger-tag (str "🌐 Remote server started on port " actual-port |
| 267 | + (when ssl-context " (HTTPS)") |
| 268 | + " — use /remote for connection details")) |
197 | 269 | {:server jetty-server |
198 | 270 | :sse-connections* sse-connections* |
199 | 271 | :heartbeat-stop-ch heartbeat-ch |
|
0 commit comments