store sessions in the DB

This commit is contained in:
Dobromir Popov
2026-07-07 22:13:12 +03:00
parent dae0719a32
commit a0b37ad1b9
5 changed files with 176 additions and 34 deletions

3
.gitignore vendored
View File

@@ -23,3 +23,6 @@ dist/
# Local tracker/node sqlite databases (never commit runtime state) # Local tracker/node sqlite databases (never commit runtime state)
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
logs/tracker/error.log
logs/tracker/info.log
logs/tracker/warning.log

View File

@@ -8,7 +8,8 @@ regular user.
Mutations are append-only events with unique ids — the same replication Mutations are append-only events with unique ids — the same replication
model as ``BillingLedger`` — so accounts and API keys converge across the model as ``BillingLedger`` — so accounts and API keys converge across the
tracker hive via gossip, and every dashboard can serve registration/login. tracker hive via gossip, and every dashboard can serve registration/login.
Sessions are deliberately local to each tracker (bearer tokens in memory). Sessions are local to each tracker and persisted so dashboard cookies survive
tracker restarts.
""" """
from __future__ import annotations from __future__ import annotations
@@ -115,6 +116,8 @@ class AccountStore:
"account_id": account_id, "account_id": account_id,
"expires": time.time() + SESSION_TTL, "expires": time.time() + SESSION_TTL,
} }
self._dirty = True
self.save_to_db()
return token return token
def session_account(self, token: str | None) -> dict | None: def session_account(self, token: str | None) -> dict | None:
@@ -134,7 +137,9 @@ class AccountStore:
if not token: if not token:
return return
with self._lock: with self._lock:
self._sessions.pop(token, None) if self._sessions.pop(token, None) is not None:
self._dirty = True
self.save_to_db()
# ---- API keys ---- # ---- API keys ----
@@ -271,6 +276,10 @@ class AccountStore:
"CREATE TABLE IF NOT EXISTS account_events " "CREATE TABLE IF NOT EXISTS account_events "
"(event_id TEXT PRIMARY KEY, payload TEXT NOT NULL, ts REAL NOT NULL)" "(event_id TEXT PRIMARY KEY, payload TEXT NOT NULL, ts REAL NOT NULL)"
) )
con.execute(
"CREATE TABLE IF NOT EXISTS account_sessions "
"(token TEXT PRIMARY KEY, account_id TEXT NOT NULL, expires REAL NOT NULL)"
)
con.commit() con.commit()
con.close() con.close()
@@ -279,6 +288,10 @@ class AccountStore:
rows = con.execute( rows = con.execute(
"SELECT payload FROM account_events ORDER BY ts, event_id" "SELECT payload FROM account_events ORDER BY ts, event_id"
).fetchall() ).fetchall()
session_rows = con.execute(
"SELECT token, account_id, expires FROM account_sessions WHERE expires >= ?",
(time.time(),),
).fetchall()
con.close() con.close()
with self._lock: with self._lock:
for (payload,) in rows: for (payload,) in rows:
@@ -288,6 +301,11 @@ class AccountStore:
continue continue
if event.get("id") not in self._seen_event_ids: if event.get("id") not in self._seen_event_ids:
self._apply_locked(event) self._apply_locked(event)
self._sessions = {
token: {"account_id": account_id, "expires": float(expires)}
for token, account_id, expires in session_rows
if account_id in self._accounts
}
self._dirty = False self._dirty = False
def save_to_db(self) -> None: def save_to_db(self) -> None:
@@ -297,11 +315,21 @@ class AccountStore:
if not self._dirty: if not self._dirty:
return return
events = list(self._event_log) events = list(self._event_log)
sessions = [
(token, session["account_id"], float(session["expires"]))
for token, session in self._sessions.items()
if session["expires"] >= time.time()
]
self._dirty = False self._dirty = False
con = sqlite3.connect(self._db_path) # type: ignore[arg-type] con = sqlite3.connect(self._db_path) # type: ignore[arg-type]
con.executemany( con.executemany(
"INSERT OR IGNORE INTO account_events (event_id, payload, ts) VALUES (?, ?, ?)", "INSERT OR IGNORE INTO account_events (event_id, payload, ts) VALUES (?, ?, ?)",
[(e["id"], json.dumps(e), float(e.get("ts", 0.0))) for e in events], [(e["id"], json.dumps(e), float(e.get("ts", 0.0))) for e in events],
) )
con.execute("DELETE FROM account_sessions")
con.executemany(
"INSERT INTO account_sessions (token, account_id, expires) VALUES (?, ?, ?)",
sessions,
)
con.commit() con.commit()
con.close() con.close()

View File

@@ -224,6 +224,7 @@
<section data-tab="overview"><h2>Model usage (RPM)</h2><div id="stats" class="empty">loading…</div></section> <section data-tab="overview"><h2>Model usage (RPM)</h2><div id="stats" class="empty">loading…</div></section>
<section data-tab="overview" class="wide"><h2>Call wall</h2><div id="call-wall" class="empty">loading...</div></section> <section data-tab="overview" class="wide"><h2>Call wall</h2><div id="call-wall" class="empty">loading...</div></section>
<section data-tab="chat" class="wide chat-section"> <section data-tab="chat" class="wide chat-section">
<h2 style="display:none">Chat / inference</h2>
<div class="chat-app"> <div class="chat-app">
<aside class="chat-sidebar"> <aside class="chat-sidebar">
<button type="button" class="chat-new-btn" onclick="createNewChatSession()">+ New chat</button> <button type="button" class="chat-new-btn" onclick="createNewChatSession()">+ New chat</button>
@@ -273,7 +274,7 @@ async function fetchJson(path) {
const headers = {}; const headers = {};
const token = localStorage.getItem("meshnet_session"); const token = localStorage.getItem("meshnet_session");
if (token) headers["Authorization"] = "Bearer " + token; if (token) headers["Authorization"] = "Bearer " + token;
const r = await fetch(path, { headers }); const r = await fetch(path, { headers, credentials: "same-origin" });
if (!r.ok) return null; if (!r.ok) return null;
return await r.json(); return await r.json();
} catch { return null; } } catch { return null; }
@@ -1029,6 +1030,7 @@ async function apiCall(path, method, body, bearerToken) {
const r = await fetch(path, { const r = await fetch(path, {
method: method || "GET", method: method || "GET",
headers, headers,
credentials: "same-origin",
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
const data = await r.json().catch(() => ({})); const data = await r.json().catch(() => ({}));
@@ -1371,7 +1373,7 @@ renderChatModels();
renderChatHistory(); renderChatHistory();
renderChatAuthHint(); renderChatAuthHint();
setInterval(refresh, 4000); setInterval(refresh, 4000);
setInterval(() => { if (sessionToken) renderAccountPanel(); }, 8000); setInterval(() => { if (sessionToken || isLoggedIn) renderAccountPanel(); }, 8000);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -21,6 +21,7 @@ HTTP API contract:
Response 400/404/503: {"error": str} Response 400/404/503: {"error": str}
""" """
import http.cookies
import http.server import http.server
import hashlib import hashlib
import itertools import itertools
@@ -47,14 +48,15 @@ from .wallet_proof import binding_message, verify_wallet_signature
from .billing import DEFAULT_BILLING_DB_PATH, BillingLedger from .billing import DEFAULT_BILLING_DB_PATH, BillingLedger
from .calibration import DEFAULT_CALIBRATION_DB_PATH, ToplocCalibrationStore from .calibration import DEFAULT_CALIBRATION_DB_PATH, ToplocCalibrationStore
from .hf_pricing import DEFAULT_HF_PRICING_LOG_DB_PATH, HfPricingLog, refresh_preset_price from .hf_pricing import DEFAULT_HF_PRICING_LOG_DB_PATH, HfPricingLog, refresh_preset_price
from .gossip import NodeGossip from .gossip import NodeGossip
from .logging_setup import tracker_logger from .logging_setup import tracker_logger
from .model_files import files_for_layer_range, snapshot_dir_for_repo from .model_files import files_for_layer_range, snapshot_dir_for_repo
from .raft import RaftNode from .raft import RaftNode
_CONSOLE_LIMIT = 300 _CONSOLE_LIMIT = 300
_PROXY_PROGRESS_LOG_INTERVAL = 5.0 _PROXY_PROGRESS_LOG_INTERVAL = 5.0
_SESSION_COOKIE_NAME = "meshnet_session"
def _preset_price_keys(name: str, preset: dict) -> set[str]: def _preset_price_keys(name: str, preset: dict) -> set[str]:
@@ -1935,6 +1937,38 @@ def _api_key_from_headers(headers) -> str | None:
return auth.strip() or None return auth.strip() or None
def _session_token_from_headers(headers) -> str | None:
token = _api_key_from_headers(headers)
if token:
return token
cookie_header = headers.get("Cookie")
if not cookie_header:
return None
cookie = http.cookies.SimpleCookie()
try:
cookie.load(cookie_header)
except http.cookies.CookieError:
return None
morsel = cookie.get(_SESSION_COOKIE_NAME)
if morsel is None:
return None
return morsel.value.strip() or None
def _session_cookie_header(token: str | None) -> str:
cookie = http.cookies.SimpleCookie()
cookie[_SESSION_COOKIE_NAME] = token or ""
morsel = cookie[_SESSION_COOKIE_NAME]
morsel["path"] = "/"
morsel["httponly"] = True
morsel["samesite"] = "Lax"
if token:
morsel["max-age"] = str(int(7 * 86400))
else:
morsel["max-age"] = "0"
return morsel.OutputString()
def _usage_total_tokens(payload: dict) -> int | None: def _usage_total_tokens(payload: dict) -> int | None:
usage = payload.get("usage") usage = payload.get("usage")
if not isinstance(usage, dict): if not isinstance(usage, dict):
@@ -2094,7 +2128,7 @@ def _registration_ban_error(contracts: Any | None, wallet_address: str | None) -
return None return None
def _tracker_log( def _tracker_log(
server: "_TrackerHTTPServer", server: "_TrackerHTTPServer",
level: str, level: str,
message: str, message: str,
@@ -2102,18 +2136,18 @@ def _tracker_log(
stdout: bool = True, stdout: bool = True,
update_console_key: str | None = None, update_console_key: str | None = None,
**fields: Any, **fields: Any,
) -> None: ) -> None:
log_level = { log_level = {
"debug": 10, "debug": 10,
"info": 20, "info": 20,
"warn": 30, "warn": 30,
"warning": 30, "warning": 30,
"error": 40, "error": 40,
}.get(level.lower(), 20) }.get(level.lower(), 20)
event = { event = {
"ts": time.time(), "ts": time.time(),
"level": level, "level": level,
"message": message, "message": message,
"fields": { "fields": {
key: value key: value
for key, value in fields.items() for key, value in fields.items()
@@ -2134,13 +2168,13 @@ def _tracker_log(
break break
if not updated: if not updated:
server.console_events.append(event) server.console_events.append(event)
else: else:
server.console_events.append(event) server.console_events.append(event)
extras = " ".join(f"{key}={value}" for key, value in event["fields"].items()) extras = " ".join(f"{key}={value}" for key, value in event["fields"].items())
suffix = f" {extras}" if extras else "" suffix = f" {extras}" if extras else ""
tracker_logger().log(log_level, f"{message}{suffix}") tracker_logger().log(log_level, f"{message}{suffix}")
if stdout: if stdout:
print(f"[tracker] {level}: {message}{suffix}", flush=True) print(f"[tracker] {level}: {message}{suffix}", flush=True)
@dataclass @dataclass
@@ -2344,11 +2378,13 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args): # noqa: suppress request logs in tests def log_message(self, fmt, *args): # noqa: suppress request logs in tests
pass pass
def _send_json(self, status: int, data: dict) -> None: def _send_json(self, status: int, data: dict, headers: dict[str, str] | None = None) -> None:
body = json.dumps(data).encode() body = json.dumps(data).encode()
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
for name, value in (headers or {}).items():
self.send_header(name, value)
self.end_headers() self.end_headers()
try: try:
self.wfile.write(body) self.wfile.write(body)
@@ -2365,7 +2401,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
inference and wallet binding only, never operator endpoints. inference and wallet binding only, never operator endpoints.
""" """
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
token = _api_key_from_headers(self.headers) token = _session_token_from_headers(self.headers)
if not token: if not token:
return None, None return None, None
if is_validator_token(token, server.validator_service_token): if is_validator_token(token, server.validator_service_token):
@@ -4209,7 +4245,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if server.accounts is None: if server.accounts is None:
return None return None
return server.accounts.session_account(_api_key_from_headers(self.headers)) return server.accounts.session_account(_session_token_from_headers(self.headers))
def _require_accounts(self) -> "AccountStore | None": def _require_accounts(self) -> "AccountStore | None":
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
@@ -4249,7 +4285,11 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
f"[tracker] account registered: {account.get('email') or account.get('wallet')} " f"[tracker] account registered: {account.get('email') or account.get('wallet')} "
f"role={account['role']}", flush=True, f"role={account['role']}", flush=True,
) )
self._send_json(200, {"account": account, "session_token": token, "api_key": api_key}) self._send_json(
200,
{"account": account, "session_token": token, "api_key": api_key},
headers={"Set-Cookie": _session_cookie_header(token)},
)
def _handle_auth_login(self): def _handle_auth_login(self):
accounts = self._require_accounts() accounts = self._require_accounts()
@@ -4264,14 +4304,18 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
self._send_json(401, {"error": "invalid credentials"}) self._send_json(401, {"error": "invalid credentials"})
return return
token = accounts.create_session(account["account_id"]) token = accounts.create_session(account["account_id"])
self._send_json(200, {"account": account, "session_token": token}) self._send_json(
200,
{"account": account, "session_token": token},
headers={"Set-Cookie": _session_cookie_header(token)},
)
def _handle_auth_logout(self): def _handle_auth_logout(self):
accounts = self._require_accounts() accounts = self._require_accounts()
if accounts is None: if accounts is None:
return return
accounts.destroy_session(_api_key_from_headers(self.headers)) accounts.destroy_session(_session_token_from_headers(self.headers))
self._send_json(200, {"ok": True}) self._send_json(200, {"ok": True}, headers={"Set-Cookie": _session_cookie_header(None)})
def _handle_account_me(self): def _handle_account_me(self):
"""Balance, usage, and API keys for the logged-in account.""" """Balance, usage, and API keys for the logged-in account."""

View File

@@ -5,6 +5,7 @@ register/login/logout, per-account balance and usage, API-key lifecycle
(revoked keys rejected by the OpenAI proxy), and the admin listing. (revoked keys rejected by the OpenAI proxy), and the admin listing.
""" """
import http.cookies
import json import json
import urllib.error import urllib.error
import urllib.request import urllib.request
@@ -68,6 +69,17 @@ def test_sessions_resolve_and_destroy():
assert store.session_account("bogus") is None assert store.session_account("bogus") is None
def test_sessions_persist_across_restart(tmp_path):
db = str(tmp_path / "accounts.db")
store = AccountStore(db_path=db)
account = store.register(email="cookie@example.com", password="secret-123")
token = store.create_session(account["account_id"])
store.save_to_db()
reloaded = AccountStore(db_path=db)
assert reloaded.session_account(token)["account_id"] == account["account_id"]
def test_api_key_lifecycle(): def test_api_key_lifecycle():
store = AccountStore() store = AccountStore()
account = store.register(email="k@example.com", password="secret-123") account = store.register(email="k@example.com", password="secret-123")
@@ -156,6 +168,59 @@ def test_register_login_and_account_view(account_tracker):
assert me["usage"]["requests"] == 0 assert me["usage"]["requests"] == 0
def test_login_sets_cookie_and_cookie_auth_survives_tracker_restart(tmp_path):
accounts_db = str(tmp_path / "accounts.db")
tracker = TrackerServer(
billing=BillingLedger(starting_credit=0.0, default_price_per_1k=0.02),
accounts_db=accounts_db,
starting_credit=0.0,
devnet_topup_amount=0.0,
)
port = tracker.start()
url = f"http://127.0.0.1:{port}"
try:
_call(f"{url}/v1/auth/register", "POST",
{"email": "cookie-http@example.com", "password": "secret-123"})
req = urllib.request.Request(
f"{url}/v1/auth/login",
data=json.dumps({
"identifier": "cookie-http@example.com",
"password": "secret-123",
}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as r:
assert json.loads(r.read())["session_token"]
cookie_header = r.headers["Set-Cookie"]
finally:
tracker.stop()
cookie = http.cookies.SimpleCookie(cookie_header)
session_cookie = cookie["meshnet_session"].OutputString()
restarted = TrackerServer(
billing=BillingLedger(starting_credit=0.0, default_price_per_1k=0.02),
accounts_db=accounts_db,
starting_credit=0.0,
devnet_topup_amount=0.0,
)
restarted_port = restarted.start()
restarted_url = f"http://127.0.0.1:{restarted_port}"
try:
req = urllib.request.Request(
f"{restarted_url}/v1/account",
headers={"Cookie": session_cookie},
method="GET",
)
with urllib.request.urlopen(req) as r:
me = json.loads(r.read())
finally:
restarted.stop()
assert me["account"]["email"] == "cookie-http@example.com"
def test_bad_credentials_and_missing_session_are_401(account_tracker): def test_bad_credentials_and_missing_session_are_401(account_tracker):
url, _ = account_tracker url, _ = account_tracker
_call(f"{url}/v1/auth/register", "POST", _call(f"{url}/v1/auth/register", "POST",