diff --git a/.gitignore b/.gitignore index 1ba33c2..133c155 100644 --- a/.gitignore +++ b/.gitignore @@ -12,17 +12,15 @@ out/ .lsp/.cache/ # Build output -static/js/ -static/out/ -static/mobile/js/ -static/db-worker-node.js -static/db-worker-node-assets.json -static/electron.js +static/ dist/ cli/_build/ cli/**/*.install resources/.cache/ +# Test run temp dirs (frontend.test.node-helper/create-tmp-dir) +tmp/ + # OCaml/Dune cli/_opam/ diff --git a/src/electron/electron/db_worker.cljs b/src/electron/electron/db_worker.cljs index b2745b3..6ed30a1 100644 --- a/src/electron/electron/db_worker.cljs +++ b/src/electron/electron/db_worker.cljs @@ -246,7 +246,7 @@ repo)] {:repo repo :base-url (:base-url config) - :auth-token nil + :auth-token (:auth-token config) :owned? (:owned? config)}))) (defn- stop-managed-daemon! diff --git a/src/main/frontend/persist_db/remote.cljs b/src/main/frontend/persist_db/remote.cljs index cf3945c..f0ba748 100644 --- a/src/main/frontend/persist_db/remote.cljs +++ b/src/main/frontend/persist_db/remote.cljs @@ -23,8 +23,14 @@ (js/encodeURIComponent repo))) (defn- events-url - [base-url] - (str (normalize-base-url base-url) "/v1/events")) + "EventSource has no header API, so the bearer token travels as a query + param here instead of the `Authorization` header every other request + uses -- the server accepts it for this route only (see + request-authorized? in db_worker_node.cljs). Interim until the Fase 4 + WebSocket migration, which carries it via Sec-WebSocket-Protocol instead." + [base-url auth-token] + (cond-> (str (normalize-base-url base-url) "/v1/events") + (seq auth-token) (str "?token=" (js/encodeURIComponent auth-token)))) (defn- base-headers [auth-token] @@ -175,7 +181,7 @@ (when @connected? (reset! subscription (open-sse-fn - {:url (events-url base-url) + {:url (events-url base-url auth-token) :headers (base-headers auth-token) :on-message (fn [chunk] (when @connected? diff --git a/src/main/frontend/worker/db_worker_node.cljs b/src/main/frontend/worker/db_worker_node.cljs index e6b0185..9db9021 100644 --- a/src/main/frontend/worker/db_worker_node.cljs +++ b/src/main/frontend/worker/db_worker_node.cljs @@ -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- 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 (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") " ")) (println (str " " (style/bold "--embedding-model-id") " ")) (println (str " " (style/bold "--log-level") " (default info)")) + (println (str " " (style/bold "--host") " (default 127.0.0.1; use an explicit --allowed-host too when binding beyond loopback)")) + (println (str " " (style/bold "--port") " (default OS-assigned)")) + (println (str " " (style/bold "--allowed-origin") " (repeatable; browser CORS allowlist, not access control)")) + (println (str " " (style/bold "--allowed-host") " (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 /db-worker-auth.json, or restore that file.") (println " logs: /graphs//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)) diff --git a/src/main/frontend/worker/db_worker_node_auth.cljs b/src/main/frontend/worker/db_worker_node_auth.cljs new file mode 100644 index 0000000..01d424e --- /dev/null +++ b/src/main/frontend/worker/db_worker_node_auth.cljs @@ -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 + /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 ` 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?)) diff --git a/src/main/logseq/cli/server.cljs b/src/main/logseq/cli/server.cljs index 813eaf6..4c63e88 100644 --- a/src/main/logseq/cli/server.cljs +++ b/src/main/logseq/cli/server.cljs @@ -329,11 +329,19 @@ (p/let [_ (profile/time! profile-session "server.wait-ready" (fn [] - (wait-for-ready repo-server')))] + (wait-for-ready repo-server'))) + auth-token (profile/time! profile-session + "server.login" + (fn [] + (daemon/login! {:host (:host repo-server') + :port (:port repo-server') + :root-dir root-dir + :device-label (name requester-owner)})))] (let [lock-owner (lock-owner-source lock)] (assoc repo-server' :owner-source lock-owner - :owned? (owner-manageable? requester-owner lock-owner))))))))) + :owned? (owner-manageable? requester-owner lock-owner) + :auth-token auth-token)))))))) (declare stop-version-mismatched-server!) @@ -382,7 +390,8 @@ (assoc config :base-url (base-url lock) :owner-source (:owner-source lock) - :owned? (:owned? lock)))) + :owned? (:owned? lock) + :auth-token (:auth-token lock)))) (defn- shutdown! [{:keys [host port]}] diff --git a/src/main/logseq/db_worker/daemon.cljs b/src/main/logseq/db_worker/daemon.cljs index daced79..b3f97a5 100644 --- a/src/main/logseq/db_worker/daemon.cljs +++ b/src/main/logseq/db_worker/daemon.cljs @@ -1,8 +1,10 @@ (ns logseq.db-worker.daemon "Shared db-worker-node lifecycle helpers for CLI and Electron." (:require ["child_process" :as child-process] + ["crypto" :as crypto] ["fs" :as fs] ["http" :as http] + ["path" :as node-path] [clojure.string :as string] [lambdaisland.glogi :as log] [logseq.db-worker.log :as db-worker-log] @@ -103,6 +105,55 @@ (contains? #{200 503} status)) (p/catch (fn [_] false)))) +(defn- local-credential-path + [root-dir] + (node-path/join root-dir "db-worker-local-credential.json")) + +(defn ensure-local-credential! + "A local automated device credential for the CLI/Electron process that + spawns and talks to its own db-worker-node daemon (see spawn-server!, + which bootstraps the daemon's stored credential from this same + username/password). Generated once and reused across restarts so the + spawning process can log in without interactive input. + + Lives in root-dir alongside the daemon's other discovery/lock files: + anyone with filesystem access to root-dir already has full read/write + access to the graph's SQLite files, so a plaintext bootstrap password + here doesn't widen the local trust boundary. It only bootstraps HTTP + session auth, which exists to stop *network* clients, not local ones." + [root-dir] + (let [path (local-credential-path root-dir)] + (if (fs/existsSync path) + (js->clj (js/JSON.parse (.toString (fs/readFileSync path) "utf8")) :keywordize-keys true) + (let [credential {:username "local" + :password (.toString (crypto/randomBytes 24) "base64url")}] + (when-not (fs/existsSync root-dir) + (fs/mkdirSync root-dir #js {:recursive true})) + (fs/writeFileSync path (js/JSON.stringify (clj->js credential))) + (try (fs/chmodSync path 384) (catch :default _ nil)) ;; 384 = 0o600, owner read/write only + credential)))) + +(defn login! + "Logs into a db-worker-node daemon's HTTP auth using the local device + credential for root-dir, returning a bearer token. Always succeeds + against a daemon this process itself spawned, since spawn-server! seeds + that daemon's stored credential from the same local file on first run." + [{:keys [host port root-dir device-label]}] + (p/let [{:keys [username password]} (ensure-local-credential! root-dir) + request-body (js/JSON.stringify (clj->js {:username username + :password password + :deviceLabel (or device-label "local")})) + {:keys [status body]} (http-request {:method "POST" + :host host + :port port + :path "/v1/auth/login" + :headers {"Content-Type" "application/json"} + :body request-body + :timeout-ms 5000})] + (if (= 200 status) + (:token (js->clj (js/JSON.parse body) :keywordize-keys true)) + (throw (ex-info "db-worker-node login failed" {:code :login-failed :status status}))))) + (defn valid-lock? [lock] (and (seq (:repo lock)) @@ -227,11 +278,15 @@ [{:keys [script repo root-dir owner-source create-empty-db? embedding-endpoint embedding-model-id]}] (let [owner-source (normalize-owner-source owner-source) detached? (not= owner-source :electron) + {:keys [username password]} (ensure-local-credential! root-dir) args (clj->js (cond-> [script "--repo" repo "--root-dir" root-dir "--owner-source" (name owner-source)] create-empty-db? (conj "--create-empty-db") (seq embedding-endpoint) (conj "--embedding-endpoint" embedding-endpoint) (seq embedding-model-id) (conj "--embedding-model-id" embedding-model-id))) - env (js/Object.assign #js {} (.-env js/process) #js {:ELECTRON_RUN_AS_NODE "1"})] + env (js/Object.assign #js {} (.-env js/process) + #js {:ELECTRON_RUN_AS_NODE "1" + :LOGSEQ_DAEMON_USERNAME username + :LOGSEQ_DAEMON_PASSWORD password})] (when (and (= :electron owner-source) (not (seq embedding-endpoint))) (js-delete env embedding-url-env)) diff --git a/src/test/frontend/worker/db_worker_node_test.cljs b/src/test/frontend/worker/db_worker_node_test.cljs index 1fb66e1..dbfddf4 100644 --- a/src/test/frontend/worker/db_worker_node_test.cljs +++ b/src/test/frontend/worker/db_worker_node_test.cljs @@ -7,6 +7,7 @@ [frontend.test.node-helper :as node-helper] [frontend.worker.db-core :as db-core] [frontend.worker.db-worker-node :as db-worker-node] + [frontend.worker.db-worker-node-auth :as db-worker-node-auth] [frontend.worker.db-worker-node-lock :as db-lock] [frontend.worker.platform.node :as platform-node] [goog.object :as gobj] @@ -20,6 +21,17 @@ [logseq.db-worker.log :as db-worker-log] [promesa.core :as p])) +;; Fase 1 (server-client migration) made a credential mandatory for every +;; db-worker-node daemon, bootstrapped from these env vars on first launch +;; against each test's fresh root-dir (see db-worker-node-auth/ensure-credential!). +;; Tests that hit the HTTP surface directly must log in with the same +;; username/password to get a bearer token -- see `auth-headers!` below. +(def ^:private test-daemon-username "db-worker-node-test-user") +(def ^:private test-daemon-password "db-worker-node-test-password") + +(aset (.-env js/process) "LOGSEQ_DAEMON_USERNAME" test-daemon-username) +(aset (.-env js/process) "LOGSEQ_DAEMON_PASSWORD" test-daemon-password) + (defn- http-request [opts body] (p/create @@ -56,17 +68,35 @@ :method "GET"} nil)) +(defn- auth-headers! + [host port] + (p/let [payload (js/JSON.stringify + (clj->js {:username test-daemon-username + :password test-daemon-password + :deviceLabel "test"})) + {:keys [status body]} (http-request {:hostname host + :port port + :path "/v1/auth/login" + :method "POST" + :headers {"Content-Type" "application/json"}} + payload)] + (when (not= 200 status) + (println "[db-worker-node-test] login failed" {:status status :body body})) + {"Content-Type" "application/json" + "Authorization" (str "Bearer " (:token (js->clj (js/JSON.parse body) :keywordize-keys true)))})) + (defn- invoke [host port method args] (let [payload (js/JSON.stringify (clj->js {:method method :argsTransit (ldb/write-transit-str args)}))] - (p/let [{:keys [status body]} + (p/let [headers (auth-headers! host port) + {:keys [status body]} (http-request {:hostname host :port port :path "/v1/invoke" :method "POST" - :headers {"Content-Type" "application/json"}} + :headers headers} payload) parsed (js->clj (js/JSON.parse body) :keywordize-keys true)] (when (not= 200 status) @@ -83,21 +113,23 @@ (let [payload (js/JSON.stringify (clj->js {:method method :argsTransit (ldb/write-transit-str args)}))] - (http-request {:hostname host - :port port - :path "/v1/invoke" - :method "POST" - :headers {"Content-Type" "application/json"}} - payload))) + (p/let [headers (auth-headers! host port)] + (http-request {:hostname host + :port port + :path "/v1/invoke" + :method "POST" + :headers headers} + payload)))) (defn- invoke-import-db-binary-raw [host port repo payload] - (http-request {:hostname host - :port port - :path (str "/v1/import-db-binary?repo=" (js/encodeURIComponent repo)) - :method "POST" - :headers {"Content-Type" "application/octet-stream"}} - payload)) + (p/let [headers (auth-headers! host port)] + (http-request {:hostname host + :port port + :path (str "/v1/import-db-binary?repo=" (js/encodeURIComponent repo)) + :method "POST" + :headers (assoc headers "Content-Type" "application/octet-stream")} + payload))) (defn- lock-path [root-dir repo] @@ -385,18 +417,29 @@ "db-worker-node-20240109.log"] remaining))))) -(deftest db-worker-node-parse-args-ignores-host-and-port +(deftest db-worker-node-parse-args-parses-host-and-port (let [parse-args #'db-worker-node/parse-args result (parse-args #js ["node" "dist/db-worker-node.js" "--host" "0.0.0.0" "--port" "1234" "--repo" "logseq_db_parse_args" "--root-dir" "/tmp/logseq-root"])] - (is (nil? (:host result))) - (is (nil? (:port result))) + (is (= "0.0.0.0" (:host result))) + (is (= 1234 (:port result))) (is (= "logseq_db_parse_args" (:repo result))) (is (= "/tmp/logseq-root" (:root-dir result))))) +(deftest db-worker-node-parse-args-collects-allowed-origins-and-hosts + (let [parse-args #'db-worker-node/parse-args + result (parse-args #js ["node" "dist/db-worker-node.js" + "--allowed-origin" "https://a.example" + "--allowed-origin" "https://b.example" + "--allowed-host" "example.local:1234" + "--repo" "logseq_db_parse_args" + "--root-dir" "/tmp/logseq-root"])] + (is (= #{"https://a.example" "https://b.example"} (:allowed-origins result))) + (is (= #{"example.local:1234"} (:allowed-hosts result))))) + (deftest db-worker-node-parse-args-ignores-auth-token (let [parse-args #'db-worker-node/parse-args result (parse-args #js ["node" "dist/db-worker-node.js" @@ -907,19 +950,24 @@ (start-daemon! {:root-dir data-dir :repo repo}) health (http-get host port "/healthz") + ;; /healthz is unauthenticated by design (readiness polling + ;; happens before any session exists), but every other route + ;; -- including one that doesn't exist -- requires a bearer + ;; token first: an unauthenticated caller shouldn't learn + ;; whether a route exists. missing-readyz (http-get host port "/readyz") health-body (js->clj (js/JSON.parse (:body health)) :keywordize-keys true) server-list-contents (.toString (fs/readFileSync server-list-file) "utf8") _ (do (reset! daemon {:host host :port port :stop! stop!}) (is (= 200 (:status health))) - (is (= 404 (:status missing-readyz))) + (is (= 401 (:status missing-readyz))) (is (= repo (:repo health-body))) (is (= "ready" (:status health-body))) (is (= host (:host health-body))) (is (= port (:port health-body))) - (is (= (.-pid js/process) (:pid health-body))) - (is (= (node-path/resolve data-dir) (:root-dir health-body))) + (is (not (contains? health-body :pid))) + (is (not (contains? health-body :root-dir))) (is (contains? health-body :owner-source)) (is (contains? health-body :revision)) (is (string/includes? server-list-contents (str (.-pid js/process) " " port)))) @@ -1557,17 +1605,31 @@ (deftest db-worker-node-desktop-and-cli-share-same-graph-daemon (async done - (let [daemon (atom nil) + (let [daemon-state (atom nil) data-dir (node-helper/create-tmp-dir "db-worker-desktop-cli") config-path (node-path/join data-dir "cli.edn") server-list-file (cli-config/server-list-path data-dir) repo (str "logseq_db_desktop_cli_" (subs (str (random-uuid)) 0 8)) now (js/Date.now) - page-uuid (random-uuid)] + page-uuid (random-uuid) + ;; This test's own `invoke` calls authenticate via the shared + ;; test-daemon-username/password constants (see auth-headers! + ;; above); ensure-server! (used below to simulate the CLI + ;; discovering the desktop-started daemon) instead logs in via + ;; the root-dir-local device credential that spawn-server! + ;; seeds in production (daemon/ensure-local-credential!). Seed + ;; both directly with the same test constants -- rather than + ;; via daemon/ensure-local-credential!'s random password, or + ;; via the global LOGSEQ_DAEMON_USERNAME/PASSWORD env vars -- + ;; so every path in this test agrees on one credential. + _ (db-worker-node-auth/set-credential! data-dir test-daemon-username test-daemon-password) + _ (fs/writeFileSync (node-path/join data-dir "db-worker-local-credential.json") + (js/JSON.stringify (clj->js {:username test-daemon-username + :password test-daemon-password})))] (-> (p/let [{:keys [host port stop!]} (start-daemon! {:root-dir data-dir :repo repo}) - _ (reset! daemon {:stop! stop!}) + _ (reset! daemon-state {:stop! stop!}) _ (invoke host port "thread-api/create-or-open-db" [repo {}]) _ (invoke host port "thread-api/transact" [repo @@ -1601,7 +1663,7 @@ (p/catch (fn [e] (is false (str "unexpected error: " e)))) (p/finally (fn [] - (if-let [stop! (:stop! @daemon)] + (if-let [stop! (:stop! @daemon-state)] (-> (stop!) (p/finally (fn [] (done)))) (done)))))))) diff --git a/src/test/logseq/cli/server_test.cljs b/src/test/logseq/cli/server_test.cljs index 72d1967..b5223dc 100644 --- a/src/test/logseq/cli/server_test.cljs +++ b/src/test/logseq/cli/server_test.cljs @@ -117,7 +117,8 @@ daemon/http-request (fn [_] (swap! shutdown-calls inc) (p/resolved {:status 200 :body ""})) - daemon/wait-for-ready (fn [_] (p/resolved true))] + daemon/wait-for-ready (fn [_] (p/resolved true)) + daemon/login! (fn [_] (p/resolved "test-token"))] (cli-server/ensure-server! {:root-dir root-dir :owner-source :cli :expected-revision "expected-revision"} @@ -152,7 +153,8 @@ (if matched? true (throw (ex-info "timed out" {:code :timeout}))))) - daemon/wait-for-ready (fn [_] (p/resolved true))] + daemon/wait-for-ready (fn [_] (p/resolved true)) + daemon/login! (fn [_] (p/resolved "test-token"))] (cli-server/ensure-server! {:root-dir root-dir :owner-source :cli :expected-revision "expected-revision"} @@ -204,7 +206,8 @@ 2 [] [new-server])))) daemon/wait-for-lock (fn [_] (p/resolved true)) - daemon/wait-for-ready (fn [_] (p/resolved true))] + daemon/wait-for-ready (fn [_] (p/resolved true)) + daemon/login! (fn [_] (p/resolved "test-token"))] (log/add-handler log-handler) (cli-server/ensure-server! {:root-dir root-dir :owner-source :cli @@ -265,7 +268,8 @@ 2 [] [new-server])))) daemon/wait-for-lock (fn [_] (p/resolved true)) - daemon/wait-for-ready (fn [_] (p/resolved true))] + daemon/wait-for-ready (fn [_] (p/resolved true)) + daemon/login! (fn [_] (p/resolved "test-token"))] (cli-server/ensure-server! {:root-dir root-dir :owner-source :cli :expected-revision "expected-revision"} @@ -313,7 +317,8 @@ 2 [] [new-server])))) daemon/wait-for-lock (fn [_] (p/resolved true)) - daemon/wait-for-ready (fn [_] (p/resolved true))] + daemon/wait-for-ready (fn [_] (p/resolved true)) + daemon/login! (fn [_] (p/resolved "test-token"))] (cli-server/ensure-server! {:root-dir root-dir :owner-source :cli :expected-revision "expected-revision"} @@ -361,7 +366,8 @@ 2 [] [wrong-server])))) daemon/wait-for-lock (fn [_] (p/resolved true)) - daemon/wait-for-ready (fn [_] (p/resolved true))] + daemon/wait-for-ready (fn [_] (p/resolved true)) + daemon/login! (fn [_] (p/resolved "test-token"))] (cli-server/ensure-server! {:root-dir root-dir :owner-source :cli :expected-revision "expected-revision"} @@ -408,7 +414,8 @@ 2 [] [wrong-server])))) daemon/wait-for-lock (fn [_] (p/resolved true)) - daemon/wait-for-ready (fn [_] (p/resolved true))] + daemon/wait-for-ready (fn [_] (p/resolved true)) + daemon/login! (fn [_] (p/resolved "test-token"))] (cli-server/start-server! {:root-dir root-dir :owner-source :cli :expected-revision "expected-revision"} @@ -488,6 +495,8 @@ :pid (.-pid js/process) :owner-source "cli" :revision "server-revision"}))) + "/v1/auth/login" (do (.writeHead res 200 #js {"Content-Type" "application/json"}) + (.end res (js/JSON.stringify #js {:ok true :token "test-token"}))) (do (.writeHead res 404 #js {"Content-Type" "text/plain"}) (.end res "not-found")))))] (.listen server 0 host @@ -668,7 +677,8 @@ :owner-source :cli :revision (version/revision) :status :ready}]))) - daemon/wait-for-ready (fn [_] (p/resolved true))] + daemon/wait-for-ready (fn [_] (p/resolved true)) + daemon/login! (fn [_] (p/resolved "test-token"))] (cli-server/ensure-server! {:root-dir root-dir :config-path config-path :create-empty-db? true} @@ -712,7 +722,8 @@ :owner-source :cli :revision (version/revision) :status :ready}]))) - daemon/wait-for-ready (fn [_] (p/resolved true))] + daemon/wait-for-ready (fn [_] (p/resolved true)) + daemon/login! (fn [_] (p/resolved "test-token"))] (cli-server/ensure-server! {:root-dir root-dir :profile-session session} repo)) @@ -757,7 +768,8 @@ (p/resolved (if (< call 3) [] [server])))) - daemon/wait-for-ready (fn [_] (p/resolved true))] + daemon/wait-for-ready (fn [_] (p/resolved true)) + daemon/login! (fn [_] (p/resolved "test-token"))] (cli-server/ensure-server! {:root-dir root-dir} repo)) (p/then (fn [config] (is (= "http://127.0.0.1:9311" (:base-url config))) @@ -808,7 +820,8 @@ :root-dir root-dir :revision "server-revision"}))}))) (p/resolved {:status 200 :body ""}))) - daemon/wait-for-ready (fn [_] (p/resolved true))] + daemon/wait-for-ready (fn [_] (p/resolved true)) + daemon/login! (fn [_] (p/resolved "test-token"))] (cli-server/ensure-server! {:root-dir root-dir :config-path config-path :expected-revision "server-revision"} @@ -892,7 +905,8 @@ (p/resolved (if @spawned? [remote-server local-server] [remote-server]))) - daemon/wait-for-ready (fn [_] (p/resolved true))] + daemon/wait-for-ready (fn [_] (p/resolved true)) + daemon/login! (fn [_] (p/resolved "test-token"))] (cli-server/ensure-server! {:root-dir root-dir-b :config-path config-path} repo)) @@ -932,7 +946,8 @@ :revision (version/revision) :status :ready :root-dir symlink-root-dir}])) - daemon/wait-for-ready (fn [_] (p/resolved true))] + daemon/wait-for-ready (fn [_] (p/resolved true)) + daemon/login! (fn [_] (p/resolved "test-token"))] (cli-server/ensure-server! {:root-dir real-root-dir} repo)) (p/then (fn [config]