Fase 1: require session auth on the db-worker-node HTTP/SSE server

Prepares the daemon to be reachable off loopback as part of the
server-client migration (see the approved implementation plan). Today it
trusts any caller that can reach the port; this makes every route except
health checks and login require a bearer session token.

- New frontend.worker.db-worker-node-auth: scrypt-hashed username/password
  credential persisted at <root-dir>/db-worker-auth.json (mode 0600),
  bootstrapped from LOGSEQ_DAEMON_USERNAME/PASSWORD on first launch since
  there's no interactive setup yet. In-memory bearer sessions with
  constant-time comparison, a failed-login lockout, and list/revoke by
  device. The daemon now refuses to start with no credential configured at
  all -- not just when binding beyond loopback -- since without one no
  token could ever be issued.
- db_worker_node.cljs: POST /v1/auth/login and GET/DELETE
  /v1/auth/sessions; every other route requires Authorization: Bearer
  except /healthz, which stays open (readiness polling happens before any
  session exists) but now omits :pid/:root-dir from its payload. Adds a
  Host-header allowlist ahead of auth to guard against DNS rebinding, and
  configurable --host/--port/--allowed-origin/--allowed-host in place of
  the hardcoded 127.0.0.1 bind and single CORS origin.
- EventSource can't send custom headers, so /v1/events accepts the bearer
  token as a query param instead; persist_db/remote.cljs's events-url
  carries it there now (interim until the Fase 4 WebSocket migration).
- Electron always sent :auth-token nil (verified by reading db_worker.cljs
  -- one of the plan's open questions). daemon/spawn-server! now seeds a
  per-root-dir local device credential
  (<root-dir>/db-worker-local-credential.json, reused across restarts) via
  LOGSEQ_DAEMON_USERNAME/PASSWORD, and cli/server.cljs logs in once the
  spawned or reused daemon is ready, threading the resulting token through
  ensure-server! to Electron's runtime config.

Not done: the OCaml CLI (cli/lib/transport.ml) also calls /v1/invoke
directly and will start receiving 401s once this ships -- confirmed with
the user to leave that gap for now rather than touch the OCaml toolchain
in this pass.

Verified with clojure -M:cljs compile app db-worker-node electron (0
warnings) and the full logseq.db-worker.daemon-test,
logseq.cli.server-test, frontend.worker.db-worker-node-test,
electron.db-worker-manager-test suites (113 tests, 480 assertions, 0
failures) plus frontend.state-test and frontend.persist-db.remote-test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 18:59:08 +02:00
parent 52971f8ec9
commit 49d9f7da84
9 changed files with 507 additions and 74 deletions

View File

@@ -3,6 +3,7 @@
(:require ["http" :as http]
[clojure.string :as string]
[frontend.worker.db-core :as db-core]
[frontend.worker.db-worker-node-auth :as auth]
[frontend.worker.db-worker-node-lock :as db-lock]
[frontend.worker.platform.node :as platform-node]
[frontend.worker.state :as worker-state]
@@ -44,28 +45,44 @@
[root-dir]
(server-list/path root-dir))
(def ^:private cors-headers
#js {"Access-Control-Allow-Origin" "lsp://logseq.com"
"Access-Control-Allow-Methods" "GET,POST,OPTIONS"
"Access-Control-Allow-Headers" "Content-Type,Authorization"})
(def ^:private default-allowed-origins #{"lsp://logseq.com"})
(defn- cors-headers-for-request
"CORS is a browser-only safety layer, not access control (that's the
bearer-token + Host checks below) -- it only decides whether a browser is
allowed to read the response. Reflects the request Origin when allowed,
per the standard multi-origin CORS pattern (a static header can't list
more than one origin)."
[^js req allowed-origins]
(let [origin (some-> req .-headers .-origin)
base #js {"Access-Control-Allow-Methods" "GET,POST,DELETE,OPTIONS"
"Access-Control-Allow-Headers" "Content-Type,Authorization"
"Vary" "Origin"}]
(if (and origin (contains? allowed-origins origin))
(doto base (aset "Access-Control-Allow-Origin" origin))
base)))
(defn- set-cors-headers!
[^js res headers]
(set! (.-corsHeaders res) headers))
(defn- response-headers
[headers]
(js/Object.assign #js {} cors-headers headers))
[^js res headers]
(js/Object.assign #js {} (or (.-corsHeaders res) #js {}) headers))
(defn- send-no-content!
[^js res]
(.writeHead res 204 cors-headers)
(.writeHead res 204 (or (.-corsHeaders res) #js {}))
(.end res))
(defn- send-json!
[^js res status payload]
(.writeHead res status (response-headers #js {"Content-Type" "application/json"}))
(.writeHead res status (response-headers res #js {"Content-Type" "application/json"}))
(.end res (js/JSON.stringify (clj->js payload))))
(defn- send-text!
[^js res status text]
(.writeHead res status (response-headers #js {"Content-Type" "text/plain"}))
(.writeHead res status (response-headers res #js {"Content-Type" "text/plain"}))
(.end res text))
(defn- <read-body-buffer
@@ -98,6 +115,10 @@
"--log-level" (recur (subvec args 2) (assoc opts :log-level (second args)))
"--embedding-endpoint" (recur (subvec args 2) (assoc opts :embedding-endpoint (second args)))
"--embedding-model-id" (recur (subvec args 2) (assoc opts :embedding-model-id (second args)))
"--host" (recur (subvec args 2) (assoc opts :host (second args)))
"--port" (recur (subvec args 2) (assoc opts :port (js/parseInt (second args) 10)))
"--allowed-origin" (recur (subvec args 2) (update opts :allowed-origins (fnil conj #{}) (second args)))
"--allowed-host" (recur (subvec args 2) (update opts :allowed-hosts (fnil conj #{}) (second args)))
"--create-empty-db" (recur (subvec args 1) (assoc opts :create-empty-db? true))
"--version" (recur (subvec args 1) (assoc opts :version? true))
"--help" (recur (subvec args 1) (assoc opts :help? true))
@@ -150,9 +171,9 @@
(defn- sse-handler
[^js req ^js res]
(.writeHead res 200 (response-headers #js {"Content-Type" "text/event-stream"
"Cache-Control" "no-cache"
"Connection" "keep-alive"}))
(.writeHead res 200 (response-headers res #js {"Content-Type" "text/event-stream"
"Cache-Control" "no-cache"
"Connection" "keep-alive"}))
(.write res "\n")
(swap! *sse-clients conj res)
(.on req "close" (fn []
@@ -299,14 +320,16 @@
(str error)))
(defn- health-payload
[{:keys [bound-repo host port owner-source root-dir]}]
"Unauthenticated by design (see request-authorized?), so this intentionally
omits :pid and :root-dir -- filesystem/process details that shouldn't be
handed to anything reachable on the allowed network segment without a
bearer token."
[{:keys [bound-repo host port owner-source]}]
{:repo bound-repo
:status (if @*ready? "ready" "starting")
:host host
:port (if (satisfies? IDeref port) @port port)
:pid (.-pid js/process)
:owner-source (name (normalize-owner-source owner-source))
:root-dir root-dir
:revision (build-version/revision)})
(defn- log-invoke-error!
@@ -326,25 +349,101 @@
:method method-kw})
(send-json! res status payload)))
(defn- effective-port
[port]
(if (satisfies? IDeref port) @port port))
(defn- allowed-hosts-for
"Host header allowlist, guarding against DNS rebinding: without this, any
web page the operator visits could script a request to the daemon on
loopback. Always includes loopback for the bound port; a non-default
--host (e.g. 0.0.0.0 behind Tailscale) needs its reachable name/IP added
explicitly via --allowed-host."
[{:keys [port extra-allowed-hosts]}]
(let [p (effective-port port)]
(into #{(str "localhost:" p) (str "127.0.0.1:" p) (str "[::1]:" p)}
extra-allowed-hosts)))
(defn- host-allowed?
[^js req allowed-hosts]
(contains? allowed-hosts (some-> req .-headers .-host)))
(defn- request-authorized?
"/v1/events (SSE) can't carry an Authorization header -- EventSource has no
header API -- so it accepts the bearer token as a query param instead.
This is interim: the WebSocket migration (see docs/agent-guide) replaces
it with Sec-WebSocket-Protocol, which doesn't leak into proxy/access logs
the way a query string does."
[^js req parsed-url request-path]
(if (= request-path "/v1/events")
(auth/authorized? (some->> (.get (.-searchParams parsed-url) "token")
(str "Bearer ")))
(auth/authorized? (.. req -headers -authorization))))
(defn- make-server
[proxy {:keys [bound-repo stop-fn host port owner-source root-dir]}]
[proxy {:keys [bound-repo stop-fn host port owner-source root-dir
allowed-origins extra-allowed-hosts]}]
(http/createServer
(fn [^js req ^js res]
(let [url (.-url req)
parsed-url (js/URL. url "http://127.0.0.1")
request-path (.-pathname parsed-url)
method (.-method req)]
method (.-method req)
;; Computed per-request (not once at server start) because `port`
;; is an atom that only resolves to the real OS-assigned port
;; after `.listen` calls back -- see `start-http-server!`.
allowed-hosts (allowed-hosts-for {:port port :extra-allowed-hosts extra-allowed-hosts})]
(set-cors-headers! res (cors-headers-for-request req allowed-origins))
(cond
(= method "OPTIONS")
(send-no-content! res)
(not (host-allowed? req allowed-hosts))
(send-json! res 403 {:ok false :error {:code :host-not-allowed
:message "Host header not allowed"}})
;; Unauthenticated on purpose: readiness polling (daemon lifecycle,
;; CLI/Electron discovery) happens before any session exists, so
;; this can't require a token without a chicken-and-egg startup
;; deadlock. Still behind the Host allowlist above. health-payload
;; omits root-dir/pid so it doesn't leak filesystem/process details
;; to anything reachable on the allowed network segment.
(= request-path "/healthz")
(send-json! res (if @*ready? 200 503)
(health-payload {:bound-repo bound-repo
:host host
:port port
:owner-source owner-source
:root-dir root-dir}))
:owner-source owner-source}))
(and (= request-path "/v1/auth/login") (= method "POST"))
(-> (p/let [body (<read-body req)
{:keys [username password deviceLabel]}
(js->clj (js/JSON.parse body) :keywordize-keys true)
{:keys [session error]} (auth/login! root-dir
{:username username
:password password
:device-label deviceLabel})]
(if session
(send-json! res 200 {:ok true
:token (:token session)
:sessionId (:id session)
:deviceLabel (:device-label session)})
(send-json! res 401 {:ok false :error {:code error :message "invalid credentials"}})))
(p/catch (fn [_error]
(send-json! res 400 {:ok false :error {:code :invalid-request
:message "invalid login request body"}}))))
(not (request-authorized? req parsed-url request-path))
(send-json! res 401 {:ok false :error {:code :unauthorized
:message "missing or invalid credentials"}})
(and (= request-path "/v1/auth/sessions") (= method "GET"))
(send-json! res 200 {:ok true :sessions (auth/list-sessions)})
(and (string/starts-with? request-path "/v1/auth/sessions/") (= method "DELETE"))
(send-json! res 200 {:ok true
:revoked (auth/revoke-session!
(subs request-path (count "/v1/auth/sessions/")))})
(= request-path "/v1/events")
(sse-handler req res)
@@ -427,7 +526,13 @@
(println (str " " (style/bold "--embedding-endpoint") " <url>"))
(println (str " " (style/bold "--embedding-model-id") " <id>"))
(println (str " " (style/bold "--log-level") " <level> (default info)"))
(println (str " " (style/bold "--host") " <host> (default 127.0.0.1; use an explicit --allowed-host too when binding beyond loopback)"))
(println (str " " (style/bold "--port") " <port> (default OS-assigned)"))
(println (str " " (style/bold "--allowed-origin") " <origin> (repeatable; browser CORS allowlist, not access control)"))
(println (str " " (style/bold "--allowed-host") " <host:port> (repeatable; Host header allowlist beyond loopback)"))
(println (str " " (style/bold "--version") " (print build metadata and exit)"))
(println " Requires a credential: set LOGSEQ_DAEMON_USERNAME and LOGSEQ_DAEMON_PASSWORD")
(println " on first run to bootstrap <root-dir>/db-worker-auth.json, or restore that file.")
(println " logs: <root-dir>/graphs/<graph-dir>/db-worker-node-YYYYMMDD.log (retains 7)"))
(defn- startup-db-opts
@@ -521,7 +626,8 @@
:stop! stop!})))
(defn- start-http-server!
[{:keys [proxy repo host port owner-source root-dir on-stopped!]}]
[{:keys [proxy repo host port owner-source root-dir on-stopped!
allowed-origins extra-allowed-hosts]}]
(let [stop!* (atom nil)
stopped? (atom false)
port* (atom nil)
@@ -530,6 +636,8 @@
:port port*
:owner-source owner-source
:root-dir root-dir
:allowed-origins allowed-origins
:extra-allowed-hosts extra-allowed-hosts
:stop-fn (fn []
(when-let [stop! @stop!*]
(stop!)))})]
@@ -554,9 +662,11 @@
(reject error)))))))
(defn start-daemon!
[{:keys [root-dir repo log-level owner-source on-stopped!] :as opts}]
(let [host "127.0.0.1"
port 0
[{:keys [root-dir repo log-level owner-source on-stopped!
host port allowed-origins allowed-hosts] :as opts}]
(let [host (or host "127.0.0.1")
port (or port 0)
allowed-origins (into default-allowed-origins allowed-origins)
owner-source (normalize-owner-source owner-source)]
(cond
(not (seq root-dir))
@@ -568,6 +678,7 @@
:else
(try
(let [root-dir (root-dir/ensure-root-dir! root-dir)
_ (auth/ensure-credential! root-dir)
server-list-file (server-list-file-path root-dir)]
(db-worker-log/install! {:root-dir root-dir
:repo repo
@@ -600,6 +711,8 @@
:port port
:owner-source owner-source
:root-dir root-dir
:allowed-origins allowed-origins
:extra-allowed-hosts allowed-hosts
:on-stopped! on-stopped!}))
(p/catch (fn [e]
(when-let [lock-path (:path @*lock-info)]
@@ -631,6 +744,10 @@
:owner-source owner-source
:embedding-endpoint (:embedding-endpoint opts)
:embedding-model-id (:embedding-model-id opts)
:host (:host opts)
:port (:port opts)
:allowed-origins (:allowed-origins opts)
:allowed-hosts (:allowed-hosts opts)
:on-stopped! (fn []
(log/info :db-worker-node-stopped nil)
(.exit js/process 0))

View File

@@ -0,0 +1,171 @@
(ns frontend.worker.db-worker-node-auth
"Session-based authentication for the db-worker-node HTTP/SSE server.
A single username/password credential is stored, scrypt-hashed, in
<root-dir>/db-worker-auth.json. There is no interactive setup yet: the
credential is bootstrapped from the LOGSEQ_DAEMON_USERNAME /
LOGSEQ_DAEMON_PASSWORD env vars on first run (see `ensure-credential!`).
Sessions are bearer tokens held in memory only; a daemon restart revokes
every session, which is acceptable for a personal single-user server."
(:require ["crypto" :as crypto]
["fs" :as fs]
["path" :as node-path]
[clojure.string :as string]
[lambdaisland.glogi :as log]))
(defonce ^:private *sessions (atom {}))
(defonce ^:private *failed-attempts (atom {}))
(def ^:private max-failed-attempts 5)
(def ^:private lockout-ms (* 5 60 1000))
(def ^:private scrypt-key-length 64)
(defn- credential-file-path
[root-dir]
(node-path/join root-dir "db-worker-auth.json"))
(defn- try-chmod-owner-only!
[path]
(try
(fs/chmodSync path 384) ;; 384 = 0o600, owner read/write only
(catch :default _ nil)))
(defn- scrypt-hash
[password salt]
(.toString (crypto/scryptSync password salt scrypt-key-length) "hex"))
(defn- gen-salt-hex [] (.toString (crypto/randomBytes 16) "hex"))
(defn- gen-token [] (.toString (crypto/randomBytes 32) "base64url"))
(defn- gen-session-id [] (.toString (crypto/randomBytes 8) "hex"))
(defn- now-iso [] (.toISOString (js/Date.)))
(defn- timing-safe-equal?
"Constant-time string comparison. Guards the length check outside
crypto.timingSafeEqual, which throws on mismatched buffer lengths instead
of returning false."
[a b]
(let [ba (js/Buffer.from (or a "") "utf8")
bb (js/Buffer.from (or b "") "utf8")]
(and (= (.-length ba) (.-length bb))
(crypto/timingSafeEqual ba bb))))
(defn read-credential
"Returns {:username :salt :hash :created-at} or nil if none is configured."
[root-dir]
(let [path (credential-file-path root-dir)]
(when (fs/existsSync path)
(-> (fs/readFileSync path "utf8")
js/JSON.parse
(js->clj :keywordize-keys true)))))
(defn set-credential!
[root-dir username password]
(let [salt (gen-salt-hex)
path (credential-file-path root-dir)
credential {:username username
:salt salt
:hash (scrypt-hash password salt)
:created-at (now-iso)}]
(fs/writeFileSync path (js/JSON.stringify (clj->js credential)) "utf8")
(try-chmod-owner-only! path)
credential))
(defn ensure-credential!
"Returns the configured credential, bootstrapping one from
LOGSEQ_DAEMON_USERNAME / LOGSEQ_DAEMON_PASSWORD on first run. Throws if
none is configured and no bootstrap env vars are set: the daemon must
refuse to start rather than serve with no way to ever authenticate."
[root-dir]
(or (read-credential root-dir)
(let [username (not-empty (.. js/process -env -LOGSEQ_DAEMON_USERNAME))
password (not-empty (.. js/process -env -LOGSEQ_DAEMON_PASSWORD))]
(if (and username password)
(let [credential (set-credential! root-dir username password)]
(log/info :db-worker-node-auth-bootstrapped
{:username username :path (credential-file-path root-dir)})
credential)
(throw (ex-info
(str "No db-worker-node credential configured. Set "
"LOGSEQ_DAEMON_USERNAME and LOGSEQ_DAEMON_PASSWORD to "
"bootstrap one on first run, or restore "
(credential-file-path root-dir) ".")
{:code :missing-daemon-credential}))))))
(defn- verify-password?
[{:keys [username salt hash]} candidate-username candidate-password]
(and hash salt username
(= username candidate-username)
(timing-safe-equal? hash (scrypt-hash (or candidate-password "") salt))))
(defn- locked-out?
[username]
(when-let [{:keys [locked-until]} (get @*failed-attempts username)]
(and locked-until (< (js/Date.now) locked-until))))
(defn- record-failed-attempt!
[username]
(swap! *failed-attempts update username
(fn [{:keys [count] :or {count 0}}]
(let [count' (inc count)]
(cond-> {:count count'}
(>= count' max-failed-attempts)
(assoc :locked-until (+ (js/Date.now) lockout-ms)))))))
(defn- clear-failed-attempts!
[username]
(swap! *failed-attempts dissoc username))
(defn create-session!
[device-label]
(let [id (gen-session-id)
now (now-iso)
session {:id id
:token (gen-token)
:device-label (or (not-empty device-label) "unknown")
:created-at now
:last-seen-at now}]
(swap! *sessions assoc id session)
session))
(defn login!
"Verifies username/password against the stored credential and, on
success, creates a session. Returns {:session {...}} or {:error kw}."
[root-dir {:keys [username password device-label]}]
(cond
(string/blank? username)
{:error :invalid-credentials}
(locked-out? username)
{:error :locked-out}
:else
(let [credential (read-credential root-dir)]
(if (and credential (verify-password? credential username password))
(do (clear-failed-attempts! username)
{:session (create-session! device-label)})
(do (record-failed-attempt! username)
{:error :invalid-credentials})))))
(defn authorized?
"Given an `Authorization: Bearer <token>` header value (or nil), returns
true and refreshes :last-seen-at when it matches an active session."
[authorization-header]
(when-let [token (some-> authorization-header
(as-> h (when (string/starts-with? h "Bearer ") (subs h 7))))]
(when-let [session (some #(when (timing-safe-equal? token (:token %)) %)
(vals @*sessions))]
(swap! *sessions assoc-in [(:id session) :last-seen-at] (now-iso))
true)))
(defn list-sessions
[]
(->> (vals @*sessions)
(map #(dissoc % :token))
(sort-by :created-at)
vec))
(defn revoke-session!
[id]
(let [existed? (contains? @*sessions id)]
(swap! *sessions dissoc id)
existed?))