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 <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@ btcprice's docker-compose includes btcdata via `include: ../btcdata/docker-compo
|
|||||||
- **Routing:** Reitit
|
- **Routing:** Reitit
|
||||||
- **Templates:** Hiccup + HTMX (WebSocket extension)
|
- **Templates:** Hiccup + HTMX (WebSocket extension)
|
||||||
- **Styling:** Tailwind CSS v4
|
- **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
|
- **No database** — all data comes from btcdata
|
||||||
|
|
||||||
## Source Layout
|
## Source Layout
|
||||||
@@ -59,5 +59,6 @@ src/clj/pmagnus/btcprice/
|
|||||||
└── routes/
|
└── routes/
|
||||||
├── api.clj # /api routes (JSON)
|
├── api.clj # /api routes (JSON)
|
||||||
├── ui.clj # UI routes (HTML + HTMX WebSocket)
|
├── ui.clj # UI routes (HTML + HTMX WebSocket)
|
||||||
|
├── ws_proxy.clj # WebSocket proxy to btcdata
|
||||||
└── utils.clj # Route utilities
|
└── utils.clj # Route utilities
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -7,5 +7,3 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "4041:4040"
|
- "4041:4040"
|
||||||
environment:
|
|
||||||
BTCDATA_URL: "http://192.168.50.42:4101"
|
|
||||||
|
|||||||
@@ -36,5 +36,8 @@
|
|||||||
{:base-path "/api"}
|
{:base-path "/api"}
|
||||||
|
|
||||||
:reitit.routes/ui
|
:reitit.routes/ui
|
||||||
|
{:base-path ""}
|
||||||
|
|
||||||
|
:reitit.routes/ws-proxy
|
||||||
{:base-path ""
|
{:base-path ""
|
||||||
:btcdata-url #or [#env BTCDATA_URL "http://localhost:4100"]}}
|
:btcdata-url #or [#env BTCDATA_URL "http://localhost:4100"]}}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
[pmagnus.btcprice.web.handler]
|
[pmagnus.btcprice.web.handler]
|
||||||
;; Routes
|
;; Routes
|
||||||
[pmagnus.btcprice.web.routes.api]
|
[pmagnus.btcprice.web.routes.api]
|
||||||
[pmagnus.btcprice.web.routes.ui])
|
[pmagnus.btcprice.web.routes.ui]
|
||||||
|
[pmagnus.btcprice.web.routes.ws-proxy])
|
||||||
(:gen-class))
|
(:gen-class))
|
||||||
|
|
||||||
(defonce system (atom nil))
|
(defonce system (atom nil))
|
||||||
|
|||||||
@@ -2,8 +2,17 @@
|
|||||||
(:require
|
(:require
|
||||||
[ring.middleware.defaults :as defaults]))
|
[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]}]
|
(defn wrap-base [{:keys [site-defaults-config]}]
|
||||||
(fn [handler]
|
(fn [handler]
|
||||||
(cond-> handler
|
(-> handler
|
||||||
true (defaults/wrap-defaults
|
(defaults/wrap-defaults
|
||||||
(or site-defaults-config defaults/site-defaults)))))
|
(or site-defaults-config defaults/site-defaults))
|
||||||
|
(wrap-nil-guard))))
|
||||||
|
|||||||
@@ -7,22 +7,20 @@
|
|||||||
[reitit.ring.middleware.muuntaja :as muuntaja]
|
[reitit.ring.middleware.muuntaja :as muuntaja]
|
||||||
[reitit.ring.middleware.parameters :as parameters]))
|
[reitit.ring.middleware.parameters :as parameters]))
|
||||||
|
|
||||||
(defn- home-page [{:keys [btcdata-url]} _req]
|
(defn- home-page [_opts _req]
|
||||||
(let [ws-url (str (.replaceFirst ^String btcdata-url "^http" "ws")
|
(page {:title "BTC Price"}
|
||||||
"/api/price/ws")]
|
[:header.text-center.mb-8
|
||||||
(page {:title "BTC Price"}
|
[:h1.text-3xl.font-bold.text-gray-900 "BTC Price"]
|
||||||
[:header.text-center.mb-8
|
[:p.text-sm.text-gray-500.mt-1 "Bitcoin price tracker"]]
|
||||||
[: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-panel {:hx-ext "ws" :ws-connect "/ws/price"}
|
||||||
[:div#price-display
|
[:div#price-display
|
||||||
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
|
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
|
||||||
[:p.text-center.text-gray-400.text-sm "Connecting..."]]]]
|
[:p.text-center.text-gray-400.text-sm "Connecting..."]]]]
|
||||||
|
|
||||||
;; Safelist classes used in btcdata HTML fragments
|
;; 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"
|
[: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"}])))
|
:aria-hidden "true"}]))
|
||||||
|
|
||||||
(defn- ui-routes [opts]
|
(defn- ui-routes [opts]
|
||||||
[["/"
|
[["/"
|
||||||
|
|||||||
@@ -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)])
|
||||||
Reference in New Issue
Block a user