From 32a150948227f61c6926d30b5820169e971e8a1b Mon Sep 17 00:00:00 2001 From: Per Magnus Petersen Date: Sat, 28 Feb 2026 13:43:05 +0100 Subject: [PATCH] Proxy btcdata WebSocket through btcprice for remote access Browser can't reach the LAN-only btcdata WebSocket when accessing btcprice remotely (Tailscale/Cloudflare Tunnel). Add a /ws/price proxy route that relays btcdata frames to the browser so only btcprice needs to be reachable. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 3 +- docker-compose.yml | 2 - resources/system.edn | 3 + src/clj/pmagnus/btcprice/core.clj | 3 +- .../pmagnus/btcprice/web/middleware/core.clj | 15 +++- src/clj/pmagnus/btcprice/web/routes/ui.clj | 26 +++--- .../pmagnus/btcprice/web/routes/ws_proxy.clj | 81 +++++++++++++++++++ 7 files changed, 112 insertions(+), 21 deletions(-) create mode 100644 src/clj/pmagnus/btcprice/web/routes/ws_proxy.clj diff --git a/CLAUDE.md b/CLAUDE.md index 8615c58..f1ee68b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compo - **Routing:** Reitit - **Templates:** Hiccup + HTMX (WebSocket extension) - **Styling:** Tailwind CSS v4 -- **Data backend:** btcdata (separate service at BTCDATA_URL, default http://192.168.50.42:4100) +- **Data backend:** btcdata (separate service at BTCDATA_URL, default http://localhost:4100) - **No database** — all data comes from btcdata ## Source Layout @@ -59,5 +59,6 @@ src/clj/pmagnus/btcprice/ └── routes/ ├── api.clj # /api routes (JSON) ├── ui.clj # UI routes (HTML + HTMX WebSocket) + ├── ws_proxy.clj # WebSocket proxy to btcdata └── utils.clj # Route utilities ``` diff --git a/docker-compose.yml b/docker-compose.yml index 8d22f5e..4dd39a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,5 +7,3 @@ services: restart: unless-stopped ports: - "4041:4040" - environment: - BTCDATA_URL: "http://192.168.50.42:4101" diff --git a/resources/system.edn b/resources/system.edn index ebc4640..be1e749 100644 --- a/resources/system.edn +++ b/resources/system.edn @@ -36,5 +36,8 @@ {:base-path "/api"} :reitit.routes/ui + {:base-path ""} + + :reitit.routes/ws-proxy {:base-path "" :btcdata-url #or [#env BTCDATA_URL "http://localhost:4100"]}} diff --git a/src/clj/pmagnus/btcprice/core.clj b/src/clj/pmagnus/btcprice/core.clj index f38ccb0..541483f 100644 --- a/src/clj/pmagnus/btcprice/core.clj +++ b/src/clj/pmagnus/btcprice/core.clj @@ -10,7 +10,8 @@ [pmagnus.btcprice.web.handler] ;; Routes [pmagnus.btcprice.web.routes.api] - [pmagnus.btcprice.web.routes.ui]) + [pmagnus.btcprice.web.routes.ui] + [pmagnus.btcprice.web.routes.ws-proxy]) (:gen-class)) (defonce system (atom nil)) diff --git a/src/clj/pmagnus/btcprice/web/middleware/core.clj b/src/clj/pmagnus/btcprice/web/middleware/core.clj index 25dd47d..b420c43 100644 --- a/src/clj/pmagnus/btcprice/web/middleware/core.clj +++ b/src/clj/pmagnus/btcprice/web/middleware/core.clj @@ -2,8 +2,17 @@ (:require [ring.middleware.defaults :as defaults])) +(defn- wrap-nil-guard + "Discard responses with no :status — these are artifacts of ring-defaults + middleware wrapping a nil response (e.g., from a raw WebSocket upgrade)." + [handler] + (fn [request] + (let [resp (handler request)] + (when (:status resp) resp)))) + (defn wrap-base [{:keys [site-defaults-config]}] (fn [handler] - (cond-> handler - true (defaults/wrap-defaults - (or site-defaults-config defaults/site-defaults))))) + (-> handler + (defaults/wrap-defaults + (or site-defaults-config defaults/site-defaults)) + (wrap-nil-guard)))) diff --git a/src/clj/pmagnus/btcprice/web/routes/ui.clj b/src/clj/pmagnus/btcprice/web/routes/ui.clj index adf7db6..d87351f 100644 --- a/src/clj/pmagnus/btcprice/web/routes/ui.clj +++ b/src/clj/pmagnus/btcprice/web/routes/ui.clj @@ -7,22 +7,20 @@ [reitit.ring.middleware.muuntaja :as muuntaja] [reitit.ring.middleware.parameters :as parameters])) -(defn- home-page [{:keys [btcdata-url]} _req] - (let [ws-url (str (.replaceFirst ^String btcdata-url "^http" "ws") - "/api/price/ws")] - (page {:title "BTC Price"} - [:header.text-center.mb-8 - [:h1.text-3xl.font-bold.text-gray-900 "BTC Price"] - [:p.text-sm.text-gray-500.mt-1 "Bitcoin price tracker"]] +(defn- home-page [_opts _req] + (page {:title "BTC Price"} + [:header.text-center.mb-8 + [:h1.text-3xl.font-bold.text-gray-900 "BTC Price"] + [:p.text-sm.text-gray-500.mt-1 "Bitcoin price tracker"]] - [:div#price-panel {:hx-ext "ws" :ws-connect ws-url} - [:div#price-display - [:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6 - [:p.text-center.text-gray-400.text-sm "Connecting..."]]]] + [:div#price-panel {:hx-ext "ws" :ws-connect "/ws/price"} + [:div#price-display + [:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6 + [:p.text-center.text-gray-400.text-sm "Connecting..."]]]] - ;; Safelist classes used in btcdata HTML fragments - [:div {:class "hidden text-4xl text-2xl font-bold text-green-600 text-red-600 text-gray-900 text-xs text-gray-400 mt-2 flex justify-center gap-4 mt-1 mt-3 text-sm text-gray-600" - :aria-hidden "true"}]))) + ;; Safelist classes used in btcdata HTML fragments + [:div {:class "hidden text-4xl text-2xl font-bold text-green-600 text-red-600 text-gray-900 text-xs text-gray-400 mt-2 flex justify-center gap-4 mt-1 mt-3 text-sm text-gray-600" + :aria-hidden "true"}])) (defn- ui-routes [opts] [["/" diff --git a/src/clj/pmagnus/btcprice/web/routes/ws_proxy.clj b/src/clj/pmagnus/btcprice/web/routes/ws_proxy.clj new file mode 100644 index 0000000..f0d1ad9 --- /dev/null +++ b/src/clj/pmagnus/btcprice/web/routes/ws_proxy.clj @@ -0,0 +1,81 @@ +(ns pmagnus.btcprice.web.routes.ws-proxy + (:require + [clojure.tools.logging :as log] + [integrant.core :as ig]) + (:import + [io.undertow.server HttpServerExchange] + [io.undertow.websockets WebSocketConnectionCallback WebSocketProtocolHandshakeHandler] + [io.undertow.websockets.core AbstractReceiveListener WebSockets WebSocketChannel] + [java.net URI] + [java.net.http HttpClient WebSocket$Builder WebSocket$Listener] + [java.util.concurrent CompletableFuture])) + +(defn- close-quietly [^WebSocketChannel ch] + (when (and ch (.isOpen ch)) + (try (.close ch) (catch Exception _)))) + +(defn- close-upstream-quietly [^java.net.http.WebSocket ws] + (when ws + (try (.sendClose ws java.net.http.WebSocket/NORMAL_CLOSURE "") (catch Exception _)))) + +(defn- connect-upstream + "Open a Java HttpClient WebSocket to btcdata and relay text frames to the + browser channel. Returns the upstream WebSocket." + [^String btcdata-ws-url ^WebSocketChannel browser-ch] + (let [client (HttpClient/newHttpClient) + listener (reify java.net.http.WebSocket$Listener + (onOpen [_ ws] + (.request ws 1)) + (onText [_ ws data last?] + (let [text (str data)] + (try + (when (.isOpen browser-ch) + (WebSockets/sendTextBlocking text browser-ch)) + (catch Exception e + (log/debug e "Error forwarding to browser") + (close-upstream-quietly ws)))) + (.request ws 1) + (CompletableFuture/completedFuture nil)) + (onClose [_ _ws status-code _reason] + (log/debug "Upstream closed" status-code) + (close-quietly browser-ch)) + (onError [_ _ws error] + (log/debug error "Upstream error") + (close-quietly browser-ch)))] + (-> (.newWebSocketBuilder client) + ^WebSocket$Builder identity + (.buildAsync (URI. btcdata-ws-url) listener) + (.join)))) + +(defn- ws-proxy-handler [{:keys [btcdata-url]} req] + (let [^HttpServerExchange exchange (:server-exchange req) + btcdata-ws-url (str (.replaceFirst ^String btcdata-url "^http" "ws") + "/api/price/ws") + callback (proxy [WebSocketConnectionCallback] [] + (onConnect [_ws-exchange browser-ch] + (log/info "Browser connected, proxying to" btcdata-ws-url) + (let [upstream (connect-upstream btcdata-ws-url browser-ch)] + ;; Listen for browser close to tear down upstream + (.set (.getReceiveSetter ^WebSocketChannel browser-ch) + (proxy [AbstractReceiveListener] [] + (onCloseMessage [_this _msg _ch] + (log/debug "Browser closed") + (close-upstream-quietly upstream)))) + (.resumeReceives ^WebSocketChannel browser-ch)))) + handler (WebSocketProtocolHandshakeHandler. callback)] + (.handleRequest handler exchange) + nil)) + +(defn- ws-proxy-routes [opts] + [["/ws/price" + {:get (fn [req] (ws-proxy-handler opts req)) + :no-doc true + :middleware []}]]) + +(derive :reitit.routes/ws-proxy :reitit/routes) + +(defmethod ig/init-key :reitit.routes/ws-proxy + [_ {:keys [base-path] + :or {base-path ""} + :as opts}] + [base-path {} (ws-proxy-routes opts)])