diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 663c9b403..819709535 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -184,6 +184,7 @@ "self-hosting/overview", "self-hosting/quickstart", "self-hosting/configuration", + "self-hosting/lan-access", "self-hosting/embeddings", "self-hosting/providers", "self-hosting/local-vs-enterprise" diff --git a/apps/docs/self-hosting/configuration.mdx b/apps/docs/self-hosting/configuration.mdx index 094787220..7630865ff 100644 --- a/apps/docs/self-hosting/configuration.mdx +++ b/apps/docs/self-hosting/configuration.mdx @@ -116,6 +116,12 @@ SUPERMEMORY_EMBEDDING_RAM_LIMIT=4gb ./supermemory-server Raise the limit and concurrency on machines with spare RAM for faster bulk imports; lower them on small VPSes where you want the server to stay lean and don't mind adds draining slowly. +## Dashboard auth (localhost vs LAN) + +The self-hosted binary auto-applies the printed API key only when the request **Host** is `localhost`, `127.0.0.1`, or `::1`. The local dashboard Memory tab does not send `Authorization`, so opening `http://:6767` loads the overview but **documents / stats return 401**. + +Workarounds, a LAN proxy, and why this happens: **[Access the dashboard over LAN](/self-hosting/lan-access)**. + ## Telemetry The self-hosted binary sends no analytics — there is nothing to opt out of. The only related switch: diff --git a/apps/docs/self-hosting/lan-access.mdx b/apps/docs/self-hosting/lan-access.mdx new file mode 100644 index 000000000..88a213c96 --- /dev/null +++ b/apps/docs/self-hosting/lan-access.mdx @@ -0,0 +1,91 @@ +--- +title: "Access the dashboard over LAN" +sidebarTitle: "LAN access" +description: "Why the Memory tab returns 401 off localhost, and how to open the local dash from another device on your network." +icon: "wifi" +--- + + +Tracked in [issue #1538](https://github.com/supermemoryai/supermemory/issues/1538) (supermemory-server v0.0.8). + + +The self-hosted dash at `http://localhost:6767` works without pasting the API key. Opening the same UI via the machine's LAN or public IP — `http://192.168.x.x:6767` — still renders the overview, but switching to the **Memory** tab shows **401 Unauthorized** for documents and stats. + +## What is going on + +On boot the server prints: + +``` +the api key above is auto-applied for unauthenticated localhost requests. +``` + +That auto-auth checks the request **Host**, not whether you are physically on the same box. Only these hosts qualify: + +- `localhost` +- `127.0.0.1` +- `::1` + +A LAN hostname such as `192.168.1.20` or `10.0.0.5` does **not**. + +The Memory tab (`/local-console.js`) then calls: + +- `POST /v3/documents/documents` +- `GET /v3/container-tags/list` + +with **no `Authorization` header**. On localhost those succeed; off localhost they return `{"error":"Unauthorized"}`. Sending the banner key as `Authorization: Bearer sm_…` succeeds on the same IP — which is why a request modifier that injects the header unblocks the UI. + +```bash +# 200 on loopback, no header +curl -sS -X POST http://127.0.0.1:6767/v3/documents/documents \ + -H 'Content-Type: application/json' -d '{"page":1}' + +# 401 on a LAN Host, no header +curl -sS -X POST http://192.168.1.20:6767/v3/documents/documents \ + -H 'Content-Type: application/json' -d '{"page":1}' + +# 200 on LAN Host once the banner key is sent +curl -sS -X POST http://192.168.1.20:6767/v3/documents/documents \ + -H "Authorization: Bearer sm_…" \ + -H 'Content-Type: application/json' -d '{"page":1}' +``` + +SDK and curl clients that already send the key are unaffected. This is a dashboard-only gap. + +## Recommended: SSH tunnel (no extra attack surface) + +From the laptop you browse on: + +```bash +ssh -L 6767:127.0.0.1:6767 user@the-linux-box +``` + +Open `http://localhost:6767` locally. Host stays loopback, auto-auth applies, the Memory tab works. + +## Option: inject the API key for LAN browsing + +If you need to open the dash at `http://:…` in a browser, run the proxy in this repo in front of supermemory-server. It forwards traffic and adds `Authorization: Bearer ` when the Memory tab omits it. + + +Anyone who can reach the proxy can use the local API as the instance owner. Bind it to a trusted network only — do not expose it to the public internet. + + +```bash +# supermemory-server already running on :6767 +SUPERMEMORY_DATA_DIR=./.supermemory \ + node scripts/lan-dashboard-proxy.mjs --listen 0.0.0.0:6768 +``` + +Then open `http://:6768` (not `:6767`). + +| Flag | Default | Meaning | +| --- | --- | --- | +| `--target` | `http://127.0.0.1:6767` | Upstream supermemory-server | +| `--listen` | `0.0.0.0:6768` | Address the browser should use | +| `--data-dir` | `SUPERMEMORY_DATA_DIR` or `./.supermemory` | Directory with the `api-key` file | +| `--api-key` | unset | Override instead of reading `api-key` | + +You can keep using a request modifier that adds the same `Authorization` header directly against `:6767`; the proxy is that behavior without a browser extension. + +## What a first-class server option would look like + +A durable fix belongs in supermemory-server: either send the key from `/local-console.js`, or treat additional Hosts as local behind an explicit allowlist (for example `SUPERMEMORY_TRUSTED_HOSTS`). Until that ships in a `server-v*` release, use a tunnel or the proxy above. diff --git a/scripts/lan-dashboard-proxy.mjs b/scripts/lan-dashboard-proxy.mjs new file mode 100755 index 000000000..1ff34fdcf --- /dev/null +++ b/scripts/lan-dashboard-proxy.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node +/** + * Reverse proxy that injects the local supermemory-server API key so the + * dashboard Memory tab works when you open it via a LAN / public IP. + * + * supermemory-server (v0.0.8) only auto-applies that key when the request + * Host is localhost / 127.0.0.1 / ::1. The dash JS never sends Authorization, + * so POST /v3/documents/documents returns 401 off-loopback. + * + * Usage: + * SUPERMEMORY_DATA_DIR=./.supermemory node scripts/lan-dashboard-proxy.mjs + * bun scripts/lan-dashboard-proxy.mjs --target http://127.0.0.1:6767 --listen 0.0.0.0:6768 + * + * Then open http://:6768 instead of :6767. + */ + +import http from "node:http" +import fs from "node:fs" +import path from "node:path" +import { pathToFileURL } from "node:url" + +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", +]) + +/** Hostnames the self-hosted binary treats as local (loopback auto-auth). */ +export function isLoopbackHostname(hostname) { + const host = hostname.replace(/^\[|\]$/g, "").toLowerCase() + return host === "localhost" || host === "127.0.0.1" || host === "::1" +} + +export function pickAuthorization(existingHeader, apiKey) { + const existing = existingHeader?.trim() + if (existing) return existing + if (!apiKey) return null + return `Bearer ${apiKey.trim()}` +} + +export function resolveApiKeyFile(dataDir = process.env.SUPERMEMORY_DATA_DIR) { + const dir = dataDir?.trim() || path.resolve(".supermemory") + return path.join(dir, "api-key") +} + +export function readApiKeyFile(filePath) { + const raw = fs.readFileSync(filePath, "utf8").trim() + if (!raw) throw new Error(`API key file is empty: ${filePath}`) + return raw +} + +export function parseListen(value) { + const raw = value?.trim() || "0.0.0.0:6768" + const idx = raw.lastIndexOf(":") + if (idx <= 0 || idx === raw.length - 1) { + throw new Error(`Invalid --listen value: ${value}`) + } + const host = raw.slice(0, idx) + const port = Number(raw.slice(idx + 1)) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid --listen port: ${value}`) + } + return { host, port } +} + +export function parseArgs(argv) { + const out = { + target: process.env.SUPERMEMORY_PROXY_TARGET || "http://127.0.0.1:6767", + listen: process.env.SUPERMEMORY_PROXY_LISTEN || "0.0.0.0:6768", + dataDir: process.env.SUPERMEMORY_DATA_DIR, + apiKey: process.env.SUPERMEMORY_API_KEY, + } + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + const next = argv[i + 1] + if (arg === "--target" && next) { + out.target = next + i++ + } else if (arg === "--listen" && next) { + out.listen = next + i++ + } else if (arg === "--data-dir" && next) { + out.dataDir = next + i++ + } else if (arg === "--api-key" && next) { + out.apiKey = next + i++ + } else if (arg === "--help" || arg === "-h") { + out.help = true + } + } + return out +} + +function copyRequestHeaders(incoming, apiKey) { + const headers = {} + for (const [key, value] of Object.entries(incoming.headers)) { + if (value == null) continue + if (HOP_BY_HOP.has(key.toLowerCase())) continue + headers[key] = value + } + const auth = pickAuthorization( + Array.isArray(incoming.headers.authorization) + ? incoming.headers.authorization[0] + : incoming.headers.authorization, + apiKey, + ) + if (auth) headers.authorization = auth + return headers +} + +export function createLanDashboardProxy({ target, apiKey }) { + const targetUrl = new URL(target) + return http.createServer((req, res) => { + const incomingUrl = new URL(req.url || "/", `http://${req.headers.host}`) + const options = { + protocol: targetUrl.protocol, + hostname: targetUrl.hostname, + port: targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80), + method: req.method, + path: `${incomingUrl.pathname}${incomingUrl.search}`, + headers: copyRequestHeaders(req, apiKey), + } + const upstream = http.request(options, (up) => { + res.writeHead(up.statusCode ?? 502, up.headers) + up.pipe(res) + }) + upstream.on("error", (err) => { + if (!res.headersSent) { + res.writeHead(502, { "content-type": "application/json" }) + } + res.end(JSON.stringify({ error: "Bad gateway", details: err.message })) + }) + req.pipe(upstream) + }) +} + +function printHelp() { + process.stdout.write(`Inject the local API key so the Memory tab works off localhost. + +Usage: + node scripts/lan-dashboard-proxy.mjs [--target URL] [--listen HOST:PORT] [--data-dir DIR] + +Options: + --target supermemory-server URL (default http://127.0.0.1:6767) + --listen bind address (default 0.0.0.0:6768) + --data-dir directory containing api-key (default SUPERMEMORY_DATA_DIR or ./.supermemory) + --api-key override key instead of reading api-key file +`) +} + +function isMain() { + try { + return import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href + } catch { + return false + } +} + +if (isMain()) { + const args = parseArgs(process.argv.slice(2)) + if (args.help) { + printHelp() + process.exit(0) + } + const apiKey = + args.apiKey?.trim() || readApiKeyFile(resolveApiKeyFile(args.dataDir)) + const listen = parseListen(args.listen) + const server = createLanDashboardProxy({ target: args.target, apiKey }) + server.listen(listen.port, listen.host, () => { + process.stdout.write( + `lan-dashboard-proxy listening on http://${listen.host}:${listen.port} → ${args.target}\n`, + ) + }) +} diff --git a/scripts/lan-dashboard-proxy.test.mjs b/scripts/lan-dashboard-proxy.test.mjs new file mode 100644 index 000000000..1c6c79967 --- /dev/null +++ b/scripts/lan-dashboard-proxy.test.mjs @@ -0,0 +1,97 @@ +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import http from "node:http" +import { + createLanDashboardProxy, + isLoopbackHostname, + parseArgs, + parseListen, + pickAuthorization, +} from "./lan-dashboard-proxy.mjs" + +describe("isLoopbackHostname", () => { + it("matches the self-hosted binary's local-auth Hosts", () => { + assert.equal(isLoopbackHostname("localhost"), true) + assert.equal(isLoopbackHostname("127.0.0.1"), true) + assert.equal(isLoopbackHostname("::1"), true) + assert.equal(isLoopbackHostname("[::1]"), true) + }) + + it("rejects LAN and public Hosts that currently 401", () => { + assert.equal(isLoopbackHostname("192.168.1.10"), false) + assert.equal(isLoopbackHostname("172.31.13.46"), false) + assert.equal(isLoopbackHostname("10.0.0.5"), false) + assert.equal(isLoopbackHostname("example.local"), false) + }) +}) + +describe("pickAuthorization", () => { + it("keeps a caller-supplied header", () => { + assert.equal(pickAuthorization("Bearer already", "sm_new"), "Bearer already") + }) + + it("injects the local key when the Memory tab sends none", () => { + assert.equal(pickAuthorization(undefined, "sm_local"), "Bearer sm_local") + assert.equal(pickAuthorization(" ", "sm_local"), "Bearer sm_local") + }) + + it("does not invent a header without a key", () => { + assert.equal(pickAuthorization(undefined, ""), null) + }) +}) + +describe("parseListen / parseArgs", () => { + it("parses host:port", () => { + assert.deepEqual(parseListen("0.0.0.0:6768"), { host: "0.0.0.0", port: 6768 }) + }) + + it("reads CLI flags", () => { + assert.deepEqual( + parseArgs(["--target", "http://127.0.0.1:9", "--listen", "127.0.0.1:9"]), + { + target: "http://127.0.0.1:9", + listen: "127.0.0.1:9", + dataDir: process.env.SUPERMEMORY_DATA_DIR, + apiKey: process.env.SUPERMEMORY_API_KEY, + }, + ) + }) +}) + +describe("createLanDashboardProxy", () => { + it("injects Authorization for unauthenticated Memory-tab requests", async () => { + let seenAuth + const upstream = http.createServer((req, res) => { + seenAuth = req.headers.authorization + res.writeHead(200, { "content-type": "application/json" }) + res.end(JSON.stringify({ documents: [], via: req.url })) + }) + await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)) + const upstreamPort = upstream.address().port + + const proxy = createLanDashboardProxy({ + target: `http://127.0.0.1:${upstreamPort}`, + apiKey: "sm_test_key", + }) + await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve)) + const proxyPort = proxy.address().port + + const res = await fetch( + `http://127.0.0.1:${proxyPort}/v3/documents/documents`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ page: 1 }), + }, + ) + assert.equal(res.status, 200) + assert.equal(seenAuth, "Bearer sm_test_key") + assert.deepEqual(await res.json(), { + documents: [], + via: "/v3/documents/documents", + }) + + proxy.close() + upstream.close() + }) +})