Skip to content

Commit 19a56b7

Browse files
committed
Start remote server as https using built-in cert/private key. Fixes mixed content issues.
1 parent 4ce3ed6 commit 19a56b7

5 files changed

Lines changed: 157 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Fix `/compact` triggering empty-response retries and rejected tool errors after the compact tool finishes.
6+
- Start remote server as https using built-in cert/private key. Fixes mixed content issues.
67

78
## 0.116.6
89

resources/META-INF/native-image/eca/eca/native-image.properties

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,6 @@ Args=-J-Dborkdude.dynaload.aot=true \
4040
-H:IncludeResources=prompts/tools/spawn_agent.md \
4141
-H:IncludeResources=prompts/tools/write_file.md \
4242
-H:IncludeResources=webpages/oauth.html \
43-
-H:IncludeResources=logo.svg
43+
-H:IncludeResources=logo.svg \
44+
-H:IncludeResources=tls/local-eca-dev-fullchain.pem \
45+
-H:IncludeResources=tls/local-eca-dev-privkey.pem
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDgTCCAwegAwIBAgISBZo3hsPP0WouEsn01FghveYkMAoGCCqGSM49BAMDMDIx
3+
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
4+
NzAeFw0yNjAzMjUxMzIzMTNaFw0yNjA2MjMxMzIzMTJaMBoxGDAWBgNVBAMMDyou
5+
bG9jYWwuZWNhLmRldjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABC8vrBzn4dJv
6+
uiTXqt+aN41MhHQbWYbhiya1ytwElriu/MCGatsrcr7FW2SAjiY1BE8JD6Z1BYNx
7+
H59Vp2SeCnOjggITMIICDzAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB
8+
BQUHAwEwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUEGY/2KWQbvzAm1nzrNb4rbKN
9+
LtcwHwYDVR0jBBgwFoAUrkie3IcdRKBv2qLlYHQEeMKcAIAwMgYIKwYBBQUHAQEE
10+
JjAkMCIGCCsGAQUFBzAChhZodHRwOi8vZTcuaS5sZW5jci5vcmcvMBoGA1UdEQQT
11+
MBGCDyoubG9jYWwuZWNhLmRldjATBgNVHSAEDDAKMAgGBmeBDAECATAtBgNVHR8E
12+
JjAkMCKgIKAehhxodHRwOi8vZTcuYy5sZW5jci5vcmcvMzcuY3JsMIIBBAYKKwYB
13+
BAHWeQIEAgSB9QSB8gDwAHUAZBHEbKQS7KeJHKICLgC8q08oB9QeNSer6v7VA8l9
14+
zfAAAAGdJV9qNAAABAMARjBEAiBXFlanoej4o/KhndYSsncaB4yokIvsvVpRwreO
15+
2D610gIgVukD/+nrWdPHLZukNhQiklTt+HIvZTIquiMWDfgJFNQAdwAWgy2r8Kkl
16+
Dw/wOqVF/8i/yCPQh0v2BCkn+OcfMxP1+gAAAZ0lX2pkAAAEAwBIMEYCIQCtjGEp
17+
j34zE64kr7R05S1kCirQxdTpkYwiim/vOw5WnAIhAOli5eMOYiMAryI+XkhzvlBn
18+
4zBB+9tTQkTIXn8DF1tCMAoGCCqGSM49BAMDA2gAMGUCMGFoENaPEKOo7p2ZR2Qf
19+
j8pY7v5v19VGiXJWQ+SNnVmHpothN5apZwINAYKJHx+pswIxALiGl/H3HR5/iVBj
20+
ZkFZcpuZB+QbtZt6fTmzZgNXTcgm6rYYfM7Vx2wv4/QylZEa8w==
21+
-----END CERTIFICATE-----
22+
-----BEGIN CERTIFICATE-----
23+
MIIEVzCCAj+gAwIBAgIRAKp18eYrjwoiCWbTi7/UuqEwDQYJKoZIhvcNAQELBQAw
24+
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
25+
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
26+
WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
27+
RW5jcnlwdDELMAkGA1UEAxMCRTcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARB6AST
28+
CFh/vjcwDMCgQer+VtqEkz7JANurZxLP+U9TCeioL6sp5Z8VRvRbYk4P1INBmbef
29+
QHJFHCxcSjKmwtvGBWpl/9ra8HW0QDsUaJW2qOJqceJ0ZVFT3hbUHifBM/2jgfgw
30+
gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
31+
ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSuSJ7chx1EoG/aouVgdAR4
32+
wpwAgDAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
33+
AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
34+
BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
35+
Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAjx66fDdLk5ywFn3CzA1w1qfylHUD
36+
aEf0QZpXcJseddJGSfbUUOvbNR9N/QQ16K1lXl4VFyhmGXDT5Kdfcr0RvIIVrNxF
37+
h4lqHtRRCP6RBRstqbZ2zURgqakn/Xip0iaQL0IdfHBZr396FgknniRYFckKORPG
38+
yM3QKnd66gtMst8I5nkRQlAg/Jb+Gc3egIvuGKWboE1G89NTsN9LTDD3PLj0dUMr
39+
OIuqVjLB8pEC6yk9enrlrqjXQgkLEYhXzq7dLafv5Vkig6Gl0nuuqjqfp0Q1bi1o
40+
yVNAlXe6aUXw92CcghC9bNsKEO1+M52YY5+ofIXlS/SEQbvVYYBLZ5yeiglV6t3S
41+
M6H+vTG0aP9YHzLn/KVOHzGQfXDP7qM5tkf+7diZe7o2fw6O7IvN6fsQXEQQj8TJ
42+
UXJxv2/uJhcuy/tSDgXwHM8Uk34WNbRT7zGTGkQRX0gsbjAea/jYAoWv0ZvQRwpq
43+
Pe79D/i7Cep8qWnA+7AE/3B3S/3dEEYmc0lpe1366A/6GEgk3ktr9PEoQrLChs6I
44+
tu3wnNLB2euC8IKGLQFpGtOO/2/hiAKjyajaBP25w1jF0Wl8Bbqne3uZ2q1GyPFJ
45+
YRmT7/OXpmOH/FVLtwS+8ng1cAmpCujPwteJZNcDG0sF2n/sc0+SQf49fdyUK0ty
46+
+VUwFj9tmWxyR/M=
47+
-----END CERTIFICATE-----
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-----BEGIN PRIVATE KEY-----
2+
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgGwsDTDNF7ZHqnnKf
3+
eCeBccEzC0k1NIn8jI6VKHaSpjahRANCAAQvL6wc5+HSb7ok16rfmjeNTIR0G1mG
4+
4YsmtcrcBJa4rvzAhmrbK3K+xVtkgI4mNQRPCQ+mdQWDcR+fVadkngpz
5+
-----END PRIVATE KEY-----

src/eca/remote/server.clj

Lines changed: 101 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
"HTTP server lifecycle for the remote web control server."
33
(:require
44
[clojure.core.async :as async]
5+
[clojure.java.io :as io]
6+
[clojure.string :as string]
57
[eca.config :as config]
68
[eca.logger :as logger]
79
[eca.remote.auth :as auth]
@@ -11,7 +13,13 @@
1113
(:import
1214
[java.io IOException]
1315
[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]))
1523

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

@@ -79,28 +87,90 @@
7987
(.isSiteLocalAddress (InetAddress/getByName ip))
8088
(catch Exception _ false)))
8189

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+
82140
(defn ^:private try-start-jetty
83141
"Tries to start Jetty on the given port and host.
142+
When ssl-context is provided, starts HTTPS-only (no HTTP listener).
84143
Returns the Server on success, nil on BindException/IOException."
85-
^Server [handler port host]
144+
^Server [handler port host ^SSLContext ssl-context]
86145
(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)")))
89152
server)
90153
(catch BindException _ nil)
91154
(catch IOException _ nil)))
92155

93156
(defn ^:private add-connector!
94157
"Adds a secondary ServerConnector to an existing Jetty server.
158+
When ssl-context is provided, creates an SSL-enabled connector.
95159
Returns true on success, false on bind failure."
96-
[^Server server port host]
160+
[^Server server port host ^SSLContext ssl-context]
97161
(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))))]
101171
(.addConnector server connector)
102172
(.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)")))
104174
true)
105175
(catch BindException _ false)
106176
(catch IOException _ false)))
@@ -111,15 +181,15 @@
111181
interface), binds to 127.0.0.1 and adds the LAN IP as a secondary connector
112182
so that both Tailscale proxy (which targets localhost) and Direct LAN work.
113183
Returns [server bind-host] on success, nil if all fail."
114-
[handler port lan-ip]
184+
[handler port lan-ip ^SSLContext ssl-context]
115185
;; 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)]
117187
[server "0.0.0.0"]
118188
;; 2. 0.0.0.0 failed — bind localhost first (for Tailscale proxy), then
119189
;; 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)]
121191
(when lan-ip
122-
(if (add-connector! server port lan-ip)
192+
(if (add-connector! server port lan-ip ssl-context)
123193
(logger/debug logger-tag (str "Also listening on " lan-ip ":" port " for Direct LAN"))
124194
(logger/warn logger-tag (str "Could not bind to " lan-ip ":" port " — Direct LAN connections may not work"))))
125195
[server (if lan-ip "127.0.0.1+lan" "127.0.0.1")])))
@@ -128,11 +198,11 @@
128198
"Tries sequential ports starting from base-port up to max-port-attempts.
129199
For each port, tries all bind-hosts before moving to the next port.
130200
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]
132202
(loop [port base-port
133203
attempts 0]
134204
(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)]
136206
[server (.getLocalPort ^NetworkConnector (first (.getConnectors ^Server server))) bind-host]
137207
(do (logger/debug logger-tag (str "Port " port " in use, trying " (inc port) "..."))
138208
(recur (inc port) (inc attempts)))))))
@@ -151,6 +221,7 @@
151221
host-base (or (:host remote-config) (detect-host))
152222
lan-ip (detect-lan-ip)
153223
user-port (:port remote-config)
224+
ssl-context (build-server-ssl-context)
154225
;; Use atom so the handler sees host:port after Jetty resolves the actual port
155226
host+port* (atom host-base)
156227
handler (routes/create-handler components
@@ -161,39 +232,40 @@
161232
(if-let [[^Server jetty-server actual-port bind-host]
162233
(if user-port
163234
;; 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)]
165236
[server (.getLocalPort ^NetworkConnector (first (.getConnectors ^Server server))) bh]
166237
(do (logger/warn logger-tag "Port" user-port "is already in use."
167238
"Remote server will not start.")
168239
nil))
169240
;; 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)
171242
(do (logger/warn logger-tag
172243
(str "Could not bind to ports " default-port "-"
173244
(+ default-port (dec max-port-attempts))
174245
". Remote server will not start."))
175246
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)
177252
_ (reset! host+port* host-with-port)
178253
heartbeat-ch (sse/start-heartbeat! sse-connections*)
179-
private? (private-ip? host-base)
180254
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)]
190260
(when (and localhost-only? private? (not= host-base "127.0.0.1"))
191261
(logger/warn logger-tag
192262
(str "⚠️ Bound to 127.0.0.1:" actual-port " (localhost only) because another service "
193263
"(e.g. Tailscale) holds port " actual-port " on the external interface. "
194264
"Direct LAN connections to " host-base " will not work. "
195265
"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"))
197269
{:server jetty-server
198270
:sse-connections* sse-connections*
199271
:heartbeat-stop-ch heartbeat-ch

0 commit comments

Comments
 (0)