Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/self-hosting/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
91 changes: 91 additions & 0 deletions apps/docs/self-hosting/lan-access.mdx
Original file line number Diff line number Diff line change
@@ -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 changes: 180 additions & 0 deletions scripts/lan-dashboard-proxy.mjs
Original file line number Diff line number Diff line change
@@ -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 changes: 97 additions & 0 deletions scripts/lan-dashboard-proxy.test.mjs
Original file line number Diff line number Diff line change
@@ -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()
})
})