mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-01 02:01:40 +00:00
Merge a503a5a373 into cfa6c7cb17
This commit is contained in:
commit
ec82941685
5 changed files with 375 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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://<lan-ip>: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:
|
||||
|
|
|
|||
91
apps/docs/self-hosting/lan-access.mdx
Normal file
91
apps/docs/self-hosting/lan-access.mdx
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<Info>
|
||||
Tracked in [issue #1538](https://github.com/supermemoryai/supermemory/issues/1538) (supermemory-server v0.0.8).
|
||||
</Info>
|
||||
|
||||
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://<lan-ip>:…` in a browser, run the proxy in this repo in front of supermemory-server. It forwards traffic and adds `Authorization: Bearer <api-key>` when the Memory tab omits it.
|
||||
|
||||
<Warning>
|
||||
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.
|
||||
</Warning>
|
||||
|
||||
```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://<this-machine-ip>: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.
|
||||
180
scripts/lan-dashboard-proxy.mjs
Executable file
180
scripts/lan-dashboard-proxy.mjs
Executable file
|
|
@ -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://<this-machine-ip>: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`,
|
||||
)
|
||||
})
|
||||
}
|
||||
97
scripts/lan-dashboard-proxy.test.mjs
Normal file
97
scripts/lan-dashboard-proxy.test.mjs
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue