Compare commits

..

4 Commits

Author SHA1 Message Date
Patrick Buckley b5c1baf29d chore: bump version to 1.6.5 2026-06-15 04:25:53 -07:00
Patrick Buckley fd8ec8ad18 feat(deploy): systemd units for a bare-metal turnstone-server node
Hardened service + slice + node-identity drop-in template + a README for
running a turnstone-server outside Docker that joins the compose cluster —
the production-shaped counterpart to the one-liner in docs/docker.md. Secrets
stay in config.toml; per-host identity + cluster URLs go in the drop-in. The
README notes the cross-host mTLS caveat (turnstonelabs/lacme#22).
2026-06-15 04:24:23 -07:00
Patrick Buckley 04b3e8e36f feat(compose): let bare-metal turnstone-servers join the cluster (incl. mTLS)
A turnstone-server running outside the compose network ("bare-metal", e.g. a
local-GPU box) couldn't fully join: it can't resolve the in-cluster console
(console:8090) to enroll its mTLS cert, and SearxNG was unreachable for
web_search. Only Postgres was published.

Publish the console's plain-HTTP ACME endpoint (:8090) and SearxNG (:8081)
alongside Postgres, all bound via one knob TURNSTONE_HOST_IP (default 127.0.0.1
-- nothing new on the LAN; set it to the host's LAN IP for a node on another
machine). Postgres keeps honoring the legacy POSTGRES_BIND as a fallback, so
existing .env files don't break.

The node's TLS client now honors TURNSTONE_CONSOLE_URL so a bare-metal node can
point at the published ACME endpoint instead of the unreachable in-cluster name
(empty = in-cluster service discovery, unchanged).

Docs (docker.md, tls.md), the run.sh-generated .env, and the bootstrap wizard
updated to match. The advertised host is the cert's primary SAN and the console
collector dials it back, so mTLS hostname verification holds both ways.
2026-06-15 04:24:23 -07:00
Patrick Buckley 80530aba94 fix(auth): isolate server/console session cookies by name
The server (:8080) and console (:8090) both set a cookie named
`turnstone_auth`. Cookies ignore port (RFC 6265), so on a shared host
(localhost dev, the Electron build, single-box installs) logging into one
surface overwrote the other's cookie and 401'd the first session.

Give each surface its own cookie name -- `turnstone_auth_server` /
`turnstone_auth_console` -- threaded as a required `cookie_name` argument
through the cookie builders, `check_request`, `AuthMiddleware`, and the six
shared auth handlers (login/logout/setup/whoami/refresh/oidc_callback). Each
app passes its own constant; the parameter is required (no default) so a
forgotten caller fails loudly instead of silently reverting to the legacy name.

Names key on role, not node: the cluster shares one JWT identity and the
console->node proxy re-mints a bearer token (dropping Set-Cookie), so
per-instance names would break identity portability and aren't used.

Hard cutover: the legacy `turnstone_auth` cookie is no longer read and
self-expires within its 24h TTL (one forced re-login). JWT audience was
already enforced, so the shared cookie was a session clobber, not an auth
bypass.
2026-06-15 04:24:23 -07:00
20 changed files with 556 additions and 118 deletions
+44 -16
View File
@@ -29,10 +29,11 @@
# Fewer nodes (lighter machines):
# docker compose up postgres console caddy channel node-1 node-2 node-3
#
# Join a bare-metal host: Postgres is published on 127.0.0.1:5432, so a
# turnstone-server running directly on this machine (e.g. to use a local GPU)
# can join the same cluster. Keep the secret + connection settings in
# ~/.config/turnstone/config.toml (chmod 0600 — the loader warns otherwise):
# Join a bare-metal host: a turnstone-server running OUTSIDE compose (e.g. to use
# a local GPU) can join this cluster. Postgres, the console's ACME endpoint, and
# SearxNG are published on 127.0.0.1 so a node on THIS machine reaches them via
# localhost. Keep secrets in ~/.config/turnstone/config.toml (chmod 0600 — the
# loader warns otherwise):
# [auth]
# jwt_secret = "dev-only-insecure-jwt-secret-change-me-for-real-deployments"
# [database]
@@ -41,10 +42,19 @@
# [api]
# base_url = "http://localhost:8000/v1"
# api_key = "dummy"
# [tls] # only if the cluster runs mTLS
# enabled = true
# then run (node identity isn't a secret, so it stays on the command line):
# TURNSTONE_NODE_ID=host-1 TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
# TURNSTONE_NODE_ID=host-1 \
# TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
# TURNSTONE_CONSOLE_URL=http://localhost:8090 \
# TURNSTONE_SEARXNG_URL=http://localhost:8081 \
# turnstone-server --host 0.0.0.0 --port 8080
# It registers in Postgres and the console reaches it back via host.docker.internal.
# The node registers in Postgres, auto-enrolls its mTLS cert from the console's
# ACME endpoint (when the cluster runs mTLS), and the console collector reaches
# it back via host.docker.internal. To join from ANOTHER machine, set
# TURNSTONE_HOST_IP to this host's LAN IP and use it in the URLs above (and the
# node's TURNSTONE_ADVERTISE_URL = the NODE host's IP) — see docs/docker.md.
# =============================================================================
name: turnstone
@@ -93,13 +103,14 @@ services:
# INSECURE dev default — override POSTGRES_PASSWORD in .env for real use.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-turnstone}
PGDATA: /var/lib/postgresql/data
# Published on localhost so a bare-metal turnstone-server running on THIS
# host can join the cluster (see "Join a bare-metal host" in the header).
# Bound to 127.0.0.1 by default; set POSTGRES_BIND=0.0.0.0 to let another
# machine connect — but set a real POSTGRES_PASSWORD first, or you'll expose
# a database with the insecure default password to your network.
# Published so a bare-metal turnstone-server can join the cluster (see "Join
# a bare-metal host" in the header). Bound to 127.0.0.1 by default (same-host
# nodes only); set TURNSTONE_HOST_IP to this host's LAN IP to let another
# machine connect — but set a real POSTGRES_PASSWORD first, or you'll expose a
# database with the insecure default password to your network. (The legacy
# POSTGRES_BIND is still honored as a fallback when TURNSTONE_HOST_IP is unset.)
ports:
- "${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432"
- "${TURNSTONE_HOST_IP:-${POSTGRES_BIND:-127.0.0.1}}:${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
@@ -120,10 +131,12 @@ services:
# turnstone-console — cluster dashboard. Reach it ONLY through Caddy at
# https://localhost:8443 (see the caddy service below).
#
# The console port (8090) is deliberately NOT published to the host: a plain
# HTTP/1.1 origin caps the browser at 6 connections, which starves the
# dashboard's per-pane SSE streams. Caddy serves the browser over HTTP/2
# (multiplexed) and proxies to console:8090 internally, so the cap is gone.
# Browsers must reach the dashboard through Caddy (https://localhost:8443): a
# plain HTTP/1.1 origin caps the browser at 6 connections, which starves the
# dashboard's per-pane SSE streams, whereas Caddy serves HTTP/2 (multiplexed)
# and proxies to console:8090 internally. The console's :8090 is published
# below ONLY so bare-metal nodes can reach the plain-HTTP ACME enrollment
# endpoint — don't point a browser at it.
#
# The single `build:` here produces the turnstone:local image every other
# service reuses. extra_hosts lets the console reach a bare-metal server
@@ -138,6 +151,14 @@ services:
- turnstone-console
- --host=0.0.0.0
- --port=8090
# Publishes the console's plain-HTTP listener so a bare-metal node can reach
# the ACME endpoint, fetch the CA, and enroll its cert (the console serves
# HTTP here even under mTLS). Bound to 127.0.0.1 by default; setting
# TURNSTONE_HOST_IP exposes the WHOLE console HTTP API — including the
# cert-issuing ACME endpoint — on that interface, so the JWT secret's
# strength is the only gate. Browsers use Caddy :8443, never this port.
ports:
- "${TURNSTONE_HOST_IP:-127.0.0.1}:8090:8090"
environment:
TURNSTONE_JWT_SECRET: *jwt-secret
TURNSTONE_DB_BACKEND: *db-backend
@@ -219,6 +240,13 @@ services:
# -------------------------------------------------------------------
searxng:
image: searxng/searxng:${SEARXNG_IMAGE_TAG:-latest}
# Published so a bare-metal node's web_search can reach it. SearxNG has NO
# auth, so it is bound to 127.0.0.1 by default; setting TURNSTONE_HOST_IP
# exposes it on that interface — an open search proxy on your LAN, which also
# triggers the SearxNG AGPL-3.0 §13 source-offer obligation (see docs/docker.md).
# In-compose nodes always use the internal http://searxng:8080 and ignore this.
ports:
- "${TURNSTONE_HOST_IP:-127.0.0.1}:${SEARXNG_API_PORT:-8081}:8080"
volumes:
- ./turnstone/deploy/searxng:/etc/searxng:ro
- searxng-cache:/var/cache/searxng # favicon + internal SQLite cache (survives restarts)
+72
View File
@@ -0,0 +1,72 @@
# Running a bare-metal turnstone-server under systemd
These units run a `turnstone-server` **outside** Docker (e.g. on a box with a
local GPU) so it joins an existing cluster — typically the docker-compose stack
in [`compose.yaml`](../../compose.yaml). They are the hardened, production-shaped
counterpart to the quick `turnstone-server …` invocation in
[`docs/docker.md`](../../docs/docker.md) ("Join a bare-metal host").
| File | Purpose |
|------|---------|
| `turnstone-server.service` | The hardened server unit (sandboxed; secrets via `config.toml`). |
| `turnstone.slice` | Shared memory/process budget for colocated Turnstone units. |
| `turnstone-server.service.d/node.conf.example` | Per-host identity + cluster URLs drop-in (no secrets). |
## Cluster-side prerequisite
The compose stack must publish Postgres, the console's ACME endpoint, and SearxNG
on an address the bare-metal host can reach. Start it with `TURNSTONE_HOST_IP`
set to the compose host's LAN IP (default `127.0.0.1` keeps everything host-local):
```bash
TURNSTONE_HOST_IP=<compose-host-ip> docker compose up -d
```
## Install (run as root on the bare-metal host)
```bash
# 1. A dedicated, unprivileged user.
useradd --system --no-create-home --shell /usr/sbin/nologin turnstone
# 2. Install turnstone into a venv at /opt/turnstone-venv (lacme/mTLS is a core dep).
uv venv /opt/turnstone-venv --python 3.12
uv pip install --python /opt/turnstone-venv 'turnstone @ git+https://github.com/turnstonelabs/turnstone'
# …or from a local checkout: uv pip install --python /opt/turnstone-venv /path/to/turnstone
# 3. Secrets — match the cluster's JWT secret + DB credentials (kept out of env).
install -d -m 750 -o turnstone -g turnstone /etc/turnstone
cat > /etc/turnstone/config.toml <<'TOML'
[auth]
jwt_secret = "<same secret as the cluster>"
[database]
backend = "postgresql"
url = "postgresql+psycopg://turnstone:<password>@<compose-host-ip>:5432/turnstone"
[api]
base_url = "http://localhost:8000/v1" # a real model backend is configured in the console UI
api_key = "dummy"
TOML
chown turnstone:turnstone /etc/turnstone/config.toml
chmod 600 /etc/turnstone/config.toml
# 4. Units + per-host drop-in.
cp turnstone-server.service turnstone.slice /etc/systemd/system/
install -d /etc/systemd/system/turnstone-server.service.d
cp turnstone-server.service.d/node.conf.example \
/etc/systemd/system/turnstone-server.service.d/node.conf
$EDITOR /etc/systemd/system/turnstone-server.service.d/node.conf # set the addresses
# 5. Go.
systemctl daemon-reload
systemctl enable --now turnstone-server.service
journalctl -u turnstone-server -f # watch it register + (if the cluster runs mTLS) enroll
```
`tls.enabled` is **not** set here — a joining node inherits it from the cluster's
shared settings (the database). If the cluster runs mTLS, the node auto-enrolls a
cert from the console's ACME endpoint and re-advertises itself over `https://`.
> **mTLS + cross-host caveat:** a node on a *different* host than the console
> currently can't complete ACME enrollment — the console advertises an
> unroutable in-container address in its ACME directory
> ([turnstonelabs/lacme#22](https://github.com/turnstonelabs/lacme/issues/22)).
> Same-host bare-metal nodes, and any node in a non-mTLS cluster, are unaffected.
+85
View File
@@ -0,0 +1,85 @@
# Run a bare-metal turnstone-server as a systemd service so it joins a cluster
# (e.g. the docker-compose stack) from outside Docker — typically to use a local
# GPU. Install steps + the cluster-side prerequisites are in deploy/systemd/README.md
# and docs/docker.md ("Join a bare-metal host"). Per-host identity + the cluster
# URLs go in a drop-in (see node.conf.example); secrets go in config.toml.
[Unit]
Description=Turnstone server (chat workstreams + LLM gateway)
Documentation=https://github.com/turnstonelabs/turnstone
# Postgres is required. After= orders against a colocated postgresql.service
# when present and silently no-ops otherwise (the cluster DB is usually remote).
After=network.target postgresql.service
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=exec
User=turnstone
Group=turnstone
# Secrets live in config.toml — JWT secret, Postgres URL+password, LLM API key —
# kept out of os.environ so a prompt-injected tool can't dump them via `env`.
Environment=TURNSTONE_CONFIG=/etc/turnstone/config.toml
Environment=TURNSTONE_LOG_LEVEL=info
Slice=turnstone.slice
# Per-host node identity + cluster wiring (TURNSTONE_NODE_ID / _ADVERTISE_URL /
# _CONSOLE_URL / _SEARXNG_URL) go in a drop-in, not here — see node.conf.example.
StateDirectory=turnstone
StateDirectoryMode=0750
LogsDirectory=turnstone
LogsDirectoryMode=0750
WorkingDirectory=/var/lib/turnstone
# --host 0.0.0.0 so the console collector + peer nodes can dial this node back
# at its advertised address. (A single-node, Caddy-fronted install can use
# 127.0.0.1 instead.) Rewrite --port if :8080 is already taken on the host.
ExecStart=/opt/turnstone-venv/bin/turnstone-server --host 0.0.0.0 --port 8080
Restart=on-failure
RestartSec=5s
TimeoutStartSec=120
TimeoutStopSec=30
KillSignal=SIGTERM
KillMode=mixed
# --- Resource limits ---
# SSE keeps an fd per active workstream + outbound LLM stream + MCP stdio pipe.
LimitNOFILE=65535
LimitNPROC=8192
TasksMax=8192
LimitCORE=0
# --- Hardening ---
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
UMask=0027
PrivateTmp=true
# PrivateDevices=true — disabled: GPU access via /sys/class/drm
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @mount
StandardOutput=journal
StandardError=journal
SyslogIdentifier=turnstone-server
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,25 @@
# Per-host node identity + cluster wiring for a bare-metal turnstone-server.
# Copy to /etc/systemd/system/turnstone-server.service.d/node.conf and edit the
# addresses, then `systemctl daemon-reload`. Identity + URLs are NOT secrets, so
# they live here; the JWT secret + DB URL live in /etc/turnstone/config.toml.
#
# Addresses below use RFC 5737 documentation IPs — replace them:
# <this-host> = the bare-metal host's own LAN IP (what the console dials back)
# <compose-host> = the host running the cluster / docker-compose stack, started
# with TURNSTONE_HOST_IP=<compose-host> so :8090 and :8081 are
# published on its LAN interface (see docs/docker.md).
[Service]
# Unique node id (defaults to the hostname if unset).
Environment=TURNSTONE_NODE_ID=host-1
# The address peers + the console collector dial back. Auto-upgrades to https://
# once the node enrolls its mTLS cert.
Environment=TURNSTONE_ADVERTISE_URL=http://192.0.2.10:8080
# The cluster console's reachable plain-HTTP ACME/API endpoint. A bare-metal node
# can't resolve the in-cluster name (console:8090), so point it at the published
# port; turnstone-server honors this for cert enrollment.
Environment=TURNSTONE_CONSOLE_URL=http://192.0.2.1:8090
# The cluster's published SearxNG, for the web_search tool.
Environment=TURNSTONE_SEARXNG_URL=http://192.0.2.1:8081
+15
View File
@@ -0,0 +1,15 @@
# Shared resource budget for the colocated Turnstone units. Without a slice each
# unit's MemoryMax= is enforced independently — three units at 85% each can sum
# to 255% of host RAM before any throttles. Under a shared slice the cap is
# hierarchical: the slice ceiling is the real limit. (A bare-metal node that runs
# only turnstone-server still benefits — and keeps the unit's Slice= reference
# valid.) Adjust if the host runs other meaningful workloads alongside Turnstone.
[Unit]
Description=Turnstone services slice (server + console + channel)
Documentation=https://github.com/turnstonelabs/turnstone
Before=slices.target
[Slice]
MemoryHigh=70%
MemoryMax=85%
TasksMax=16384
+10 -4
View File
@@ -63,7 +63,10 @@ Auth is always enabled. All API endpoints except public paths require a valid to
Include a token in one of two ways:
- **Bearer header**: `Authorization: Bearer <token>`
- **Cookie**: `turnstone_auth=<token>` (set automatically by the login endpoint)
- **Cookie**: the surface-scoped auth cookie — `turnstone_auth_server` on
turnstone-server, `turnstone_auth_console` on turnstone-console (set
automatically by the login endpoint). The names differ so the two surfaces,
when co-hosted on one origin, don't overwrite each other's session.
The server accepts two token types:
@@ -102,7 +105,8 @@ Authenticate with credentials and receive a JWT. Accepts two credential formats:
}
```
The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT.
The response also sets a surface-scoped HttpOnly cookie containing the JWT
(`turnstone_auth_server` on turnstone-server, `turnstone_auth_console` on turnstone-console).
**Response (failure):** `401`
@@ -114,7 +118,8 @@ The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT.
### `POST /v1/api/auth/logout`
Clears the `turnstone_auth` cookie. No request body required.
Clears the surface-scoped auth cookie (`turnstone_auth_server` /
`turnstone_auth_console`). No request body required.
**Response:** `200`
@@ -199,7 +204,8 @@ this endpoint.
}
```
The response also sets a `turnstone_auth` HttpOnly cookie containing the JWT.
The response also sets a surface-scoped HttpOnly cookie containing the JWT
(`turnstone_auth_server` on turnstone-server, `turnstone_auth_console` on turnstone-console).
**Response (already set up):** `409`
+2 -1
View File
@@ -1191,7 +1191,8 @@ Three hierarchical scopes control endpoint access:
`/metrics`, `/openapi.json`, `/docs`, `/api/auth/*`, and `/api/auth/setup`
are always allowed.
2. **Token extraction**`Authorization: Bearer <token>` header first, then
`turnstone_auth` cookie as fallback.
surface-scoped auth cookie (`turnstone_auth_server` on the node server,
`turnstone_auth_console` on the console) as fallback.
3. **Token type detection** — dots in the token indicate JWT; `ts_` prefix
indicates API token.
4. **Validation** — JWT signature check or API token hash lookup in storage.
+32 -14
View File
@@ -59,9 +59,11 @@ is gone. Everything goes through `https://localhost:8443`.
## Join a bare-metal host
PostgreSQL is published on `127.0.0.1:5432`, so a `turnstone-server` running
directly on the same machine — for example to use a local GPU — can join the
same cluster and show up in the console alongside the containerized nodes.
PostgreSQL, the console's ACME endpoint (`:8090`), and SearxNG (`:8081`) are
published on `127.0.0.1`, so a `turnstone-server` running directly on the same
machine — for example to use a local GPU — can join the same cluster (enrolling
its mTLS cert and running `web_search`) and show up in the console alongside the
containerized nodes.
Put the secret and connection settings in `~/.config/turnstone/config.toml`
(secrets belong in this file, not the process environment — keep it `0600`,
@@ -85,18 +87,31 @@ command line:
```bash
chmod 600 ~/.config/turnstone/config.toml
TURNSTONE_NODE_ID=host-1 TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
TURNSTONE_NODE_ID=host-1 \
TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
TURNSTONE_CONSOLE_URL=http://localhost:8090 \
TURNSTONE_SEARXNG_URL=http://localhost:8081 \
turnstone-server --host 0.0.0.0 --port 8080
```
The host server registers itself in PostgreSQL; the console reaches it back via
`host.docker.internal`. The `jwt_secret` and DB credentials above are the
dev-stack defaults — match whatever you set in `.env` if you changed them. To
let a **different** machine join, start the stack with `POSTGRES_BIND=0.0.0.0`
and use the host's routable IP in the `url` and `TURNSTONE_ADVERTISE_URL`
but **set a strong `POSTGRES_PASSWORD` first**, or you'll expose a database with
the insecure default password (and every user account + API-token hash in it) to
your network.
`host.docker.internal`. `TURNSTONE_CONSOLE_URL` points the node at the console's
published ACME endpoint so it can enroll its mTLS certificate (needed only when
the cluster runs mTLS; harmless otherwise), and `TURNSTONE_SEARXNG_URL` points
`web_search` at the published SearxNG. The `jwt_secret` and DB credentials above
are the dev-stack defaults — match whatever you set in `.env` if you changed them.
To let a server on a **different** machine join, start the stack with
`TURNSTONE_HOST_IP=<this host's LAN IP>` — that binds PostgreSQL, the console
ACME endpoint, and SearxNG to that interface. Then on the remote box set the
three URLs above to that IP, and set `TURNSTONE_ADVERTISE_URL` to the **remote**
box's own IP (the address the console dials back). **Set a strong
`POSTGRES_PASSWORD` first** — `TURNSTONE_HOST_IP` exposes the database (and every
user account + API-token hash in it), the console API, and the unauthenticated
SearxNG to your network.
To run the bare-metal node as a hardened, persistent service instead of by hand,
use the systemd units in [`deploy/systemd/`](../deploy/systemd/).
## Production stack
@@ -174,15 +189,18 @@ overrides.
### Ports
Both stacks publish Caddy (dashboard) and PostgreSQL; the dev stack additionally
publishes the SearxNG UI on localhost. Everything else is reached through Caddy or
proxied by the console:
publishes the console's ACME endpoint and SearxNG on localhost so a bare-metal
node can enroll its cert and run `web_search`. Everything else is reached through
Caddy or proxied by the console:
| Variable | Default | Description |
|----------|---------|-------------|
| `CONSOLE_HTTPS_PORT` | `8443` | Host port for Caddy (dashboard HTTPS) |
| `SEARXNG_HTTPS_PORT` | `8444` | Host port for the SearxNG UI via Caddy (dev: localhost-only; prod: opt-in) |
| `POSTGRES_PORT` | `5432` | Host port for PostgreSQL (for bare-metal joins) |
| `POSTGRES_BIND` | `127.0.0.1` | Interface PostgreSQL binds on; set `0.0.0.0` for LAN access |
| `SEARXNG_API_PORT` | `8081` | Host port for the SearxNG API a bare-metal node's `web_search` dials (dev stack) |
| `TURNSTONE_HOST_IP` | `127.0.0.1` | Interface PostgreSQL, the console ACME endpoint, and SearxNG bind on (dev stack). Set to this host's LAN IP so a bare-metal node on **another machine** can reach them — set a strong `POSTGRES_PASSWORD` first (it also exposes the DB and the unauthenticated SearxNG to your network). |
| `POSTGRES_BIND` | `127.0.0.1` | Production stack (`turnstone/deploy/compose.yaml`) only: interface PostgreSQL binds on; set to the host's LAN IP for remote joins. |
### Channel gateway
+6 -2
View File
@@ -244,7 +244,10 @@ const client = new TurnstoneServer({
### Node Bootstrap Flow
1. Node starts, connects to shared database (plain connection)
2. Discovers console URL from `services` table
2. Discovers the console URL from the `services` table — or honors an explicit
`TURNSTONE_CONSOLE_URL` (a bare-metal node outside the compose network can't
resolve the in-cluster `console` name, so it points this at the console's
published ACME endpoint)
3. Fetches CA root cert from `http://console/acme/ca.pem` (plain HTTP, TOFU)
4. Requests a service cert via ACME (plain HTTP, JWS-signed). The cert's
primary domain / SAN is the node's **advertised host** (the host of
@@ -291,7 +294,8 @@ cert's SANs don't include the dialed name.
The console registers itself in the `services` table on startup. If the console
hasn't started or the registration expired (1 hour TTL), nodes can't discover
it. Use `--console-url` explicitly.
it. Set `TURNSTONE_CONSOLE_URL` to a reachable console address (this is also how
a bare-metal node that can't resolve the in-cluster `console` name enrolls).
### Browser HTTPS to the console
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.6.4"
version = "1.6.5"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "Apache-2.0"
+3 -1
View File
@@ -333,7 +333,9 @@ prepare_env() {
TURNSTONE_JWT_SECRET=$jwt
POSTGRES_USER=turnstone
POSTGRES_PASSWORD=$pgpw
POSTGRES_BIND=127.0.0.1
# Bind published bare-metal ports (Postgres, console ACME, SearxNG) to this
# interface. 127.0.0.1 = same-host only; set your LAN IP to join from another box.
TURNSTONE_HOST_IP=127.0.0.1
POSTGRES_PORT=$PG_PORT
CONSOLE_HTTPS_PORT=$CADDY_PORT
EOF
+166 -37
View File
@@ -8,6 +8,9 @@ from unittest.mock import MagicMock, patch
import pytest
from turnstone.core.auth import (
AUTH_COOKIE,
AUTH_COOKIE_CONSOLE,
AUTH_COOKIE_SERVER,
WRITE_PATHS,
_extract_bearer,
_extract_cookie,
@@ -374,48 +377,59 @@ class TestExtractCookie:
class TestMakeSetCookie:
def test_contains_token(self):
val = make_set_cookie("tok_abc")
assert "turnstone_auth=tok_abc" in val
val = make_set_cookie("tok_abc", AUTH_COOKIE_SERVER)
assert "turnstone_auth_server=tok_abc" in val
def test_httponly(self):
assert "HttpOnly" in make_set_cookie("tok_abc")
assert "HttpOnly" in make_set_cookie("tok_abc", AUTH_COOKIE_SERVER)
def test_samesite_lax(self):
assert "SameSite=Lax" in make_set_cookie("tok_abc")
assert "SameSite=Lax" in make_set_cookie("tok_abc", AUTH_COOKIE_SERVER)
def test_path(self):
assert "Path=/" in make_set_cookie("tok_abc")
assert "Path=/" in make_set_cookie("tok_abc", AUTH_COOKIE_SERVER)
def test_max_age_default(self):
val = make_set_cookie("tok_abc")
val = make_set_cookie("tok_abc", AUTH_COOKIE_SERVER)
assert "Max-Age=86400" in val # 24 hours (matches JWT expiry)
def test_max_age_custom(self):
val = make_set_cookie("tok_abc", max_age=3600)
val = make_set_cookie("tok_abc", AUTH_COOKIE_SERVER, max_age=3600)
assert "Max-Age=3600" in val
def test_secure_default(self):
val = make_set_cookie("tok_abc")
val = make_set_cookie("tok_abc", AUTH_COOKIE_SERVER)
assert "; Secure" in val
def test_secure_false(self):
val = make_set_cookie("tok_abc", secure=False)
val = make_set_cookie("tok_abc", AUTH_COOKIE_SERVER, secure=False)
assert "; Secure" not in val
def test_secure_true(self):
val = make_set_cookie("tok_abc", secure=True)
val = make_set_cookie("tok_abc", AUTH_COOKIE_SERVER, secure=True)
assert "; Secure" in val
def test_uses_provided_name(self):
# Name is honored verbatim; one surface's name never leaks into the other.
val = make_set_cookie("tok_abc", AUTH_COOKIE_CONSOLE)
assert "turnstone_auth_console=tok_abc" in val
assert "turnstone_auth_server" not in val
class TestMakeClearCookie:
def test_max_age_zero(self):
assert "Max-Age=0" in make_clear_cookie()
assert "Max-Age=0" in make_clear_cookie(AUTH_COOKIE_SERVER)
def test_empty_value(self):
assert "turnstone_auth=;" in make_clear_cookie()
assert "turnstone_auth_server=;" in make_clear_cookie(AUTH_COOKIE_SERVER)
def test_httponly(self):
assert "HttpOnly" in make_clear_cookie()
assert "HttpOnly" in make_clear_cookie(AUTH_COOKIE_SERVER)
def test_uses_provided_name(self):
val = make_clear_cookie(AUTH_COOKIE_CONSOLE)
assert "turnstone_auth_console=;" in val
assert "Max-Age=0" in val
# ---------------------------------------------------------------------------
@@ -437,47 +451,67 @@ class TestCheckRequest:
return f"Bearer {create_jwt('u1', frozenset({'read', 'write', 'approve'}), 'test', self._SECRET)}"
def test_public_path_no_token_ok(self):
allowed, status, msg, _result = check_request("GET", "/health", None)
allowed, status, msg, _result = check_request(
"GET", "/health", None, cookie_name=AUTH_COOKIE_SERVER
)
assert allowed is True
assert status == 200
def test_public_root_no_token_ok(self):
allowed, status, msg, _result = check_request("GET", "/", None)
allowed, status, msg, _result = check_request(
"GET", "/", None, cookie_name=AUTH_COOKIE_SERVER
)
assert allowed is True
def test_public_static_no_token_ok(self):
allowed, status, msg, _result = check_request("GET", "/static/style.css", None)
allowed, status, msg, _result = check_request(
"GET", "/static/style.css", None, cookie_name=AUTH_COOKIE_SERVER
)
assert allowed is True
def test_api_no_token_401(self):
allowed, status, msg, _result = check_request("GET", "/api/workstreams", None)
allowed, status, msg, _result = check_request(
"GET", "/api/workstreams", None, cookie_name=AUTH_COOKIE_SERVER
)
assert allowed is False
assert status == 401
assert "Unauthorized" in msg
def test_api_invalid_token_401(self):
allowed, status, msg, _result = check_request(
"GET", "/api/workstreams", "Bearer wrong_token"
"GET", "/api/workstreams", "Bearer wrong_token", cookie_name=AUTH_COOKIE_SERVER
)
assert allowed is False
assert status == 401
def test_api_read_token_ok(self, read_jwt):
allowed, status, msg, _result = check_request(
"GET", "/api/workstreams", read_jwt, jwt_secret=self._SECRET
"GET",
"/api/workstreams",
read_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is True
assert status == 200
def test_api_full_token_ok(self, full_jwt):
allowed, status, msg, _result = check_request(
"GET", "/api/workstreams", full_jwt, jwt_secret=self._SECRET
"GET",
"/api/workstreams",
full_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is True
def test_write_read_token_403(self, read_jwt):
allowed, status, msg, _result = check_request(
"POST", "/api/workstreams/abc/send", read_jwt, jwt_secret=self._SECRET
"POST",
"/api/workstreams/abc/send",
read_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is False
assert status == 403
@@ -485,14 +519,22 @@ class TestCheckRequest:
def test_write_full_token_ok(self, full_jwt):
allowed, status, msg, _result = check_request(
"POST", "/api/workstreams/abc/send", full_jwt, jwt_secret=self._SECRET
"POST",
"/api/workstreams/abc/send",
full_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is True
assert status == 200
def test_approve_read_token_403(self, read_jwt):
allowed, status, msg, _result = check_request(
"POST", "/api/workstreams/abc/approve", read_jwt, jwt_secret=self._SECRET
"POST",
"/api/workstreams/abc/approve",
read_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is False
assert status == 403
@@ -504,6 +546,7 @@ class TestCheckRequest:
"/node/node-a/api/workstreams/abc/send",
read_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is False
assert status == 403
@@ -515,6 +558,7 @@ class TestCheckRequest:
"/node/node-a/api/workstreams/abc/send/",
read_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is False
assert status == 403
@@ -522,7 +566,11 @@ class TestCheckRequest:
def test_direct_write_trailing_slash_read_token_403(self, read_jwt):
"""Trailing slash must not bypass write-role check on direct routes."""
allowed, status, msg, _result = check_request(
"POST", "/api/workstreams/abc/send/", read_jwt, jwt_secret=self._SECRET
"POST",
"/api/workstreams/abc/send/",
read_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is False
assert status == 403
@@ -534,6 +582,7 @@ class TestCheckRequest:
"/node/node-a/api/workstreams/abc/send",
full_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is True
@@ -544,6 +593,7 @@ class TestCheckRequest:
"/node/node-a/v1/api/workstreams/abc/send",
read_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is False
assert status == 403
@@ -555,6 +605,7 @@ class TestCheckRequest:
"/node/node-a/v1/api/workstreams/abc/send",
full_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is True
@@ -565,6 +616,7 @@ class TestCheckRequest:
"/node/node-a/v1/api/cluster/workstreams/new",
read_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is False
assert status == 403
@@ -572,26 +624,40 @@ class TestCheckRequest:
def test_proxy_read_endpoint_read_token_ok(self, read_jwt):
"""Read tokens can access proxy read endpoints."""
allowed, status, msg, _result = check_request(
"GET", "/node/node-a/api/workstreams", read_jwt, jwt_secret=self._SECRET
"GET",
"/node/node-a/api/workstreams",
read_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is True
def test_console_create_ws_read_token_403(self, read_jwt):
"""Read tokens cannot create workstreams."""
allowed, status, msg, _result = check_request(
"POST", "/api/cluster/workstreams/new", read_jwt, jwt_secret=self._SECRET
"POST",
"/api/cluster/workstreams/new",
read_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is False
assert status == 403
def test_approve_full_token_ok(self, full_jwt):
allowed, status, msg, _result = check_request(
"POST", "/api/workstreams/abc/approve", full_jwt, jwt_secret=self._SECRET
"POST",
"/api/workstreams/abc/approve",
full_jwt,
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is True
def test_no_auth_header_string(self):
allowed, status, msg, _result = check_request("GET", "/api/dashboard", "")
allowed, status, msg, _result = check_request(
"GET", "/api/dashboard", "", cookie_name=AUTH_COOKIE_SERVER
)
assert allowed is False
assert status == 401
@@ -619,8 +685,9 @@ class TestCheckRequestWithCookie:
"GET",
"/api/workstreams",
None,
cookie_header=f"turnstone_auth={read_jwt}",
cookie_header=f"{AUTH_COOKIE_SERVER}={read_jwt}",
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is True
assert status == 200
@@ -630,8 +697,9 @@ class TestCheckRequestWithCookie:
"POST",
"/api/workstreams/abc/send",
f"Bearer {full_jwt}",
cookie_header=f"turnstone_auth={read_jwt}",
cookie_header=f"{AUTH_COOKIE_SERVER}={read_jwt}",
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is True
@@ -640,8 +708,9 @@ class TestCheckRequestWithCookie:
"GET",
"/api/workstreams",
None,
cookie_header="turnstone_auth=wrong_token",
cookie_header=f"{AUTH_COOKIE_SERVER}=wrong_token",
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is False
assert status == 401
@@ -651,8 +720,9 @@ class TestCheckRequestWithCookie:
"POST",
"/api/workstreams/abc/send",
None,
cookie_header=f"turnstone_auth={read_jwt}",
cookie_header=f"{AUTH_COOKIE_SERVER}={read_jwt}",
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is False
assert status == 403
@@ -662,8 +732,9 @@ class TestCheckRequestWithCookie:
"POST",
"/api/workstreams/abc/send",
None,
cookie_header=f"turnstone_auth={full_jwt}",
cookie_header=f"{AUTH_COOKIE_SERVER}={full_jwt}",
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is True
@@ -673,6 +744,7 @@ class TestCheckRequestWithCookie:
"/api/workstreams",
None,
cookie_header=None,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is False
assert status == 401
@@ -682,6 +754,7 @@ class TestCheckRequestWithCookie:
"POST",
"/api/auth/login",
None,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is True
@@ -690,9 +763,56 @@ class TestCheckRequestWithCookie:
"POST",
"/api/auth/logout",
None,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is True
# --- Cookie-name isolation (the server/console fix) -----------------------
def test_legacy_cookie_name_rejected(self, read_jwt):
"""A pre-isolation ``turnstone_auth`` cookie no longer authenticates once
the surface is configured for a scoped name: existing sessions must
re-login after the rename (intentional hard cutover, no read-fallback)."""
allowed, status, _, _r = check_request(
"GET",
"/api/workstreams",
None,
cookie_header=f"{AUTH_COOKIE}={read_jwt}",
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is False
assert status == 401
def test_console_cookie_name_not_read_by_server(self, read_jwt):
"""The console's cookie name is invisible to a server-configured surface,
so a console session can't satisfy a server request (no cross-read)."""
allowed, status, _, _r = check_request(
"GET",
"/api/workstreams",
None,
cookie_header=f"{AUTH_COOKIE_CONSOLE}={read_jwt}",
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is False
assert status == 401
def test_both_cookies_present_no_clobber(self, read_jwt):
"""With distinct names both surfaces' cookies coexist in one jar; the
server reads only its own and authenticates even with a console cookie
also present — the core property the rename buys."""
allowed, status, _, _r = check_request(
"GET",
"/api/workstreams",
None,
cookie_header=f"{AUTH_COOKIE_CONSOLE}=other; {AUTH_COOKIE_SERVER}={read_jwt}",
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed is True
assert status == 200
# ---------------------------------------------------------------------------
# Integration tests — actual HTTP server with auth enabled
@@ -1020,6 +1140,10 @@ class TestServerLogin:
assert resp.status_code == 200
data = resp.json()
assert "jwt" in data
# Server surface sets ITS OWN scoped cookie — not the console's, not the legacy name.
set_cookie = resp.headers.get("set-cookie", "")
assert AUTH_COOKIE_SERVER in set_cookie
assert AUTH_COOKIE_CONSOLE not in set_cookie
def test_cookie_auth_on_api(self):
# Login to get cookie (TestClient tracks cookies automatically)
@@ -1044,6 +1168,7 @@ class TestServerLogin:
assert logout_resp.status_code == 200
cookie = logout_resp.headers.get("set-cookie", "")
assert "Max-Age=0" in cookie
assert AUTH_COOKIE_SERVER in cookie
# API should now fail (cookie cleared)
resp = self.test_client.get("/v1/api/workstreams")
@@ -1070,8 +1195,6 @@ class TestServerLogin:
def test_refresh_returns_new_jwt_and_cookie(self):
"""POST /api/auth/refresh re-mints the cookie with a fresh exp."""
from turnstone.core.auth import AUTH_COOKIE
# Storage needs get_user_permissions for the refresh re-resolve path.
# Mock is shared across tests in the class — re-arm here in case a
# prior test left it default.
@@ -1098,7 +1221,7 @@ class TestServerLogin:
# and refresh produce identical iat/exp claims and therefore an
# identical token, which is fine: the cookie still gets re-set.
cookie_hdr = refresh.headers.get("set-cookie", "")
assert AUTH_COOKIE in cookie_hdr
assert AUTH_COOKIE_SERVER in cookie_hdr
assert "HttpOnly" in cookie_hdr
# The refreshed cookie must keep working.
@@ -1279,7 +1402,10 @@ class TestConsoleLogin:
json={"username": "testuser", "password": "testpass"},
)
assert resp.status_code == 200
assert "turnstone_auth" in resp.headers.get("set-cookie", "")
# Exact scoped name (not the legacy prefix) and no server-name bleed.
set_cookie = resp.headers.get("set-cookie", "")
assert AUTH_COOKIE_CONSOLE in set_cookie
assert AUTH_COOKIE_SERVER not in set_cookie
def test_cookie_auth_on_api(self):
self.test_client.post(
@@ -1559,6 +1685,7 @@ class TestJWTVersionClaim:
jwt_secret=self.SECRET,
jwt_audience=JWT_AUD_SERVER,
jwt_version="1.2",
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed
assert result is not None
@@ -1581,6 +1708,7 @@ class TestJWTVersionClaim:
jwt_secret=self.SECRET,
jwt_audience=JWT_AUD_SERVER,
jwt_version="1.2",
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed
@@ -1602,6 +1730,7 @@ class TestJWTVersionClaim:
jwt_secret=self.SECRET,
jwt_audience=JWT_AUD_SERVER,
jwt_version="1.2",
cookie_name=AUTH_COOKIE_SERVER,
)
assert not allowed
assert status == 401
+7
View File
@@ -7,6 +7,7 @@ import time
import pytest
from turnstone.core.auth import (
AUTH_COOKIE_SERVER,
AuthResult,
_authenticate_token,
check_request,
@@ -273,6 +274,7 @@ class TestCheckRequestScopes:
"/api/workstreams/abc/send",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert not allowed
assert status == 403
@@ -285,6 +287,7 @@ class TestCheckRequestScopes:
"/api/workstreams/abc/approve",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert not allowed
assert status == 403
@@ -297,6 +300,7 @@ class TestCheckRequestScopes:
"/api/workstreams/abc/approve",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed
assert result is not None
@@ -309,6 +313,7 @@ class TestCheckRequestScopes:
"/api/workstreams/abc/send",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert allowed
assert result is not None
@@ -321,6 +326,7 @@ class TestCheckRequestScopes:
"/api/workstreams/abc/send",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert not allowed
assert status == 403
@@ -332,6 +338,7 @@ class TestCheckRequestScopes:
"/v1/api/admin/users",
f"Bearer {jwt_tok}",
jwt_secret=self._SECRET,
cookie_name=AUTH_COOKIE_SERVER,
)
assert not allowed
assert status == 403
+9 -4
View File
@@ -24,6 +24,8 @@ from turnstone.console.server import (
admin_list_oidc_identities,
)
from turnstone.core.auth import (
AUTH_COOKIE_CONSOLE,
AUTH_COOKIE_SERVER,
AuthResult,
LoginRateLimiter,
handle_oidc_authorize,
@@ -46,7 +48,7 @@ async def _oidc_authorize(request: Request) -> Response:
async def _oidc_callback(request: Request) -> Response:
return await handle_oidc_callback(request, "test-audience")
return await handle_oidc_callback(request, "test-audience", cookie_name=AUTH_COOKIE_SERVER)
# ---------------------------------------------------------------------------
@@ -292,7 +294,8 @@ class TestOIDCCallback:
assert resp.status_code == 302
assert "oidc_success=1" in resp.headers["location"]
assert "set-cookie" in resp.headers
assert "turnstone_auth=" in resp.headers["set-cookie"]
set_cookie = resp.headers["set-cookie"]
assert set_cookie.split(";", 1)[0].partition("=")[0] == AUTH_COOKIE_SERVER
def test_oidc_not_configured_returns_404(self, storage: SQLiteBackend) -> None:
app = Starlette(
@@ -645,7 +648,9 @@ class TestOIDCCallback:
# Wire a callback bound to the CONSOLE audience. After bug-3 the
# stored audience must take precedence.
async def _console_callback(request: Request) -> Response:
return await handle_oidc_callback(request, "turnstone-console")
return await handle_oidc_callback(
request, "turnstone-console", cookie_name=AUTH_COOKIE_CONSOLE
)
jwt_secret = "test-jwt-secret-key-padded-32b!!"
app = Starlette(
@@ -670,7 +675,7 @@ class TestOIDCCallback:
set_cookie = resp.headers["set-cookie"]
cookie_kv = set_cookie.split(";", 1)[0]
name, _, token = cookie_kv.partition("=")
assert name == "turnstone_auth"
assert name == AUTH_COOKIE_CONSOLE
assert token
# Decoding without audience verification first to inspect the claim.
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "1.6.4"
__version__ = "1.6.5"
+4 -1
View File
@@ -106,7 +106,10 @@ Generate with: `python -c "import secrets; print(secrets.token_hex(32))"`
### Ports / networking
- `CONSOLE_HTTPS_PORT` Caddy HTTPS port for the dashboard (default: 8443)
- `POSTGRES_PORT` PostgreSQL host port, for joining a bare-metal server (default: 5432)
- `POSTGRES_BIND` interface PostgreSQL binds on (default: 127.0.0.1; set 0.0.0.0 for LAN)
- `TURNSTONE_HOST_IP` interface the published bare-metal ports (Postgres, console
ACME, SearxNG) bind on (default: 127.0.0.1; set your host's LAN IP to join from
another machine). The legacy `POSTGRES_BIND` is still honored for Postgres.
- `SEARXNG_API_PORT` host port a bare-metal node's web_search dials SearxNG on (default: 8081)
### Channel Gateway (optional)
- `TURNSTONE_DISCORD_TOKEN` Discord bot token
+13 -7
View File
@@ -47,6 +47,7 @@ from turnstone.console.metrics import ConsoleMetrics
from turnstone.console.router import ConsoleRouter
from turnstone.core.audit import record_audit
from turnstone.core.auth import (
AUTH_COOKIE_CONSOLE,
JWT_AUD_CONSOLE,
JWT_AUD_SERVER,
AuthMiddleware,
@@ -1574,14 +1575,14 @@ async def auth_login(request: Request) -> Response:
"""Authenticate via username:password or legacy token, return JWT."""
from turnstone.core.auth import handle_auth_login
return await handle_auth_login(request, JWT_AUD_CONSOLE)
return await handle_auth_login(request, JWT_AUD_CONSOLE, cookie_name=AUTH_COOKIE_CONSOLE)
async def auth_logout(request: Request) -> Response:
"""POST /v1/api/auth/logout — clear auth cookie."""
from turnstone.core.auth import handle_auth_logout
return await handle_auth_logout(request)
return await handle_auth_logout(request, cookie_name=AUTH_COOKIE_CONSOLE)
async def auth_status(request: Request) -> Response:
@@ -1595,14 +1596,14 @@ async def auth_setup(request: Request) -> Response:
"""POST /v1/api/auth/setup — create first admin user (public, one-time only)."""
from turnstone.core.auth import handle_auth_setup
return await handle_auth_setup(request, JWT_AUD_CONSOLE)
return await handle_auth_setup(request, JWT_AUD_CONSOLE, cookie_name=AUTH_COOKIE_CONSOLE)
async def auth_whoami(request: Request) -> Response:
"""GET /v1/api/auth/whoami — return authenticated user info."""
from turnstone.core.auth import handle_auth_whoami
return await handle_auth_whoami(request)
return await handle_auth_whoami(request, cookie_name=AUTH_COOKIE_CONSOLE)
async def auth_refresh(request: Request) -> Response:
@@ -1613,7 +1614,7 @@ async def auth_refresh(request: Request) -> Response:
"""
from turnstone.core.auth import handle_auth_refresh
return await handle_auth_refresh(request, JWT_AUD_CONSOLE)
return await handle_auth_refresh(request, JWT_AUD_CONSOLE, cookie_name=AUTH_COOKIE_CONSOLE)
async def oidc_authorize(request: Request) -> Response:
@@ -1627,7 +1628,7 @@ async def oidc_callback(request: Request) -> Response:
"""GET /v1/api/auth/oidc/callback — OIDC callback, exchange code for JWT."""
from turnstone.core.auth import handle_oidc_callback
return await handle_oidc_callback(request, JWT_AUD_CONSOLE)
return await handle_oidc_callback(request, JWT_AUD_CONSOLE, cookie_name=AUTH_COOKIE_CONSOLE)
async def mcp_oauth_authorize(request: Request) -> Response:
@@ -13564,7 +13565,12 @@ def _build_console_middleware(cors_origins: list[str] | None = None) -> list[Mid
stack.append(cors_middleware(cors_origins))
stack.append(
Middleware(AuthMiddleware, jwt_audience=JWT_AUD_CONSOLE, jwt_version=jwt_version_slot())
Middleware(
AuthMiddleware,
jwt_audience=JWT_AUD_CONSOLE,
jwt_version=jwt_version_slot(),
cookie_name=AUTH_COOKIE_CONSOLE,
)
)
return stack
+42 -21
View File
@@ -54,7 +54,14 @@ log = get_logger(__name__)
# Constants
# ---------------------------------------------------------------------------
AUTH_COOKIE = "turnstone_auth"
AUTH_COOKIE = "turnstone_auth" # legacy unscoped name (pre-isolation); see per-surface names below
# Per-surface cookie names. The server (:8080) and console (:8090) are co-hostable
# on a single origin; cookies ignore port, so distinct *names* keep one surface's
# session from clobbering the other's. Keyed by role/audience, NOT by node — the
# cluster shares one JWT identity (same secret + audience), so a token stays
# portable across nodes and per-instance names would break proxy identity re-mint.
AUTH_COOKIE_SERVER = "turnstone_auth_server"
AUTH_COOKIE_CONSOLE = "turnstone_auth_console"
TOKEN_PREFIX = "ts_"
TOKEN_BYTES = 32 # 64 hex chars after prefix
@@ -801,11 +808,12 @@ def check_request(
jwt_audience: str = "",
jwt_version: str = "",
storage: Any = None,
cookie_name: str,
) -> tuple[bool, int, str, AuthResult | None]:
"""Validate a request.
Checks ``Authorization: Bearer <token>`` first, then falls back to the
``turnstone_auth`` cookie. Token types are auto-detected:
*cookie_name* cookie. Token types are auto-detected:
- Contains ``.`` JWT (validated with *jwt_secret*)
- Starts with ``ts_`` API token (looked up in *storage* by hash)
@@ -818,7 +826,7 @@ def check_request(
# Extract token from header or cookie
raw_token = _extract_bearer(auth_header)
if raw_token is None:
raw_token = _extract_cookie(cookie_header, AUTH_COOKIE)
raw_token = _extract_cookie(cookie_header, cookie_name)
if not raw_token:
return False, 401, "Unauthorized: missing or invalid token", None
@@ -932,22 +940,26 @@ def _extract_cookie(cookie_header: str | None, name: str) -> str | None:
# ---------------------------------------------------------------------------
def make_set_cookie(token: str, max_age: int = 86400, *, secure: bool | None = None) -> str:
def make_set_cookie(
token: str, cookie_name: str, max_age: int = 86400, *, secure: bool | None = None
) -> str:
"""Return a ``Set-Cookie`` header value that stores the auth token.
*cookie_name* selects the per-surface cookie (``AUTH_COOKIE_SERVER`` /
``AUTH_COOKIE_CONSOLE``) so co-hosted surfaces don't share a jar slot.
When *secure* is ``None`` (default) the ``Secure`` flag is set
unconditionally. Pass ``secure=False`` only for plaintext development.
*max_age* defaults to 24 hours to match the default JWT expiry.
"""
val = f"{AUTH_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
val = f"{cookie_name}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
if secure is None or secure:
val += "; Secure"
return val
def make_clear_cookie() -> str:
"""Return a ``Set-Cookie`` header value that expires the auth cookie."""
return f"{AUTH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
def make_clear_cookie(cookie_name: str) -> str:
"""Return a ``Set-Cookie`` header value that expires the named auth cookie."""
return f"{cookie_name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
def is_secure_request(headers: dict[str, str], scheme: str = "") -> bool:
@@ -1094,10 +1106,18 @@ class AuthMiddleware:
server (``JWT_AUD_SERVER``) and the console (``JWT_AUD_CONSOLE``).
"""
def __init__(self, app: ASGIApp, jwt_audience: str = "", jwt_version: str = "") -> None:
def __init__(
self,
app: ASGIApp,
jwt_audience: str = "",
jwt_version: str = "",
*,
cookie_name: str,
) -> None:
self.app = app
self._jwt_audience = jwt_audience
self._jwt_version = jwt_version
self._cookie_name = cookie_name
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
@@ -1128,6 +1148,7 @@ class AuthMiddleware:
jwt_audience=self._jwt_audience,
jwt_version=self._jwt_version,
storage=storage,
cookie_name=self._cookie_name,
)
if not allowed:
body: dict[str, Any] = {"error": msg}
@@ -1154,7 +1175,7 @@ class AuthMiddleware:
# ---------------------------------------------------------------------------
async def handle_auth_login(request: Request, audience: str) -> Response:
async def handle_auth_login(request: Request, audience: str, cookie_name: str) -> Response:
"""Shared ``POST /api/auth/login`` handler.
Authenticates via username:password or legacy token exchange, returning
@@ -1257,16 +1278,16 @@ async def handle_auth_login(request: Request, audience: str) -> Response:
response = JSONResponse(resp_body)
cookie_value = jwt_token if jwt_token else body.get("token", "")
if cookie_value:
response.headers["Set-Cookie"] = make_set_cookie(cookie_value, secure=secure)
response.headers["Set-Cookie"] = make_set_cookie(cookie_value, cookie_name, secure=secure)
return response
async def handle_auth_logout(request: Request) -> Response:
async def handle_auth_logout(request: Request, cookie_name: str) -> Response:
"""Shared ``POST /api/auth/logout`` handler — clear auth cookie."""
from starlette.responses import JSONResponse
response = JSONResponse({"status": "ok"})
response.headers["Set-Cookie"] = make_clear_cookie()
response.headers["Set-Cookie"] = make_clear_cookie(cookie_name)
return response
@@ -1300,7 +1321,7 @@ async def handle_auth_status(request: Request) -> Response:
return JSONResponse(resp)
async def handle_auth_setup(request: Request, audience: str) -> Response:
async def handle_auth_setup(request: Request, audience: str, cookie_name: str) -> Response:
"""Shared ``POST /api/auth/setup`` handler — create first admin user.
Only works when zero users exist. Returns JWT on success.
@@ -1401,11 +1422,11 @@ async def handle_auth_setup(request: Request, audience: str) -> Response:
secure = is_secure_request(dict(request.headers), request.url.scheme)
response = JSONResponse(resp_body)
if jwt_token:
response.headers["Set-Cookie"] = make_set_cookie(jwt_token, secure=secure)
response.headers["Set-Cookie"] = make_set_cookie(jwt_token, cookie_name, secure=secure)
return response
async def handle_auth_whoami(request: Request) -> Response:
async def handle_auth_whoami(request: Request, cookie_name: str) -> Response:
"""Shared ``GET /api/auth/whoami`` handler — return authenticated user info.
Includes the JWT ``exp`` claim (epoch seconds) so the frontend can
@@ -1440,7 +1461,7 @@ async def handle_auth_whoami(request: Request) -> Response:
resp["permissions"] = ",".join(sorted(auth_result.permissions))
# Surface the cookie/JWT expiry so the client can schedule refresh.
# Decoded without re-validating (auth middleware already validated).
cookie_token = request.cookies.get(AUTH_COOKIE, "")
cookie_token = request.cookies.get(cookie_name, "")
if cookie_token:
try:
import jwt as _jwt
@@ -1455,7 +1476,7 @@ async def handle_auth_whoami(request: Request) -> Response:
return JSONResponse(resp)
async def handle_auth_refresh(request: Request, audience: str) -> Response:
async def handle_auth_refresh(request: Request, audience: str, cookie_name: str) -> Response:
"""Shared ``POST /api/auth/refresh`` handler — re-mint the auth cookie.
Requires a currently-valid auth cookie (auth middleware enforces).
@@ -1551,7 +1572,7 @@ async def handle_auth_refresh(request: Request, audience: str) -> Response:
response = JSONResponse(resp_body)
secure = is_secure_request(dict(request.headers), request.url.scheme)
response.headers["Set-Cookie"] = make_set_cookie(new_token, secure=secure)
response.headers["Set-Cookie"] = make_set_cookie(new_token, cookie_name, secure=secure)
return response
@@ -1657,7 +1678,7 @@ async def _refetch_jwks_locked(
return fresh
async def handle_oidc_callback(request: Request, audience: str) -> Response:
async def handle_oidc_callback(request: Request, audience: str, cookie_name: str) -> Response:
"""Shared ``GET /api/auth/oidc/callback`` handler — exchange code, provision user, issue JWT."""
from starlette.responses import JSONResponse, RedirectResponse
@@ -1808,5 +1829,5 @@ async def handle_oidc_callback(request: Request, audience: str) -> Response:
response = RedirectResponse("/?oidc_success=1", status_code=302)
if jwt_token:
secure = is_secure_request(dict(request.headers), request.url.scheme)
response.headers["Set-Cookie"] = make_set_cookie(jwt_token, secure=secure)
response.headers["Set-Cookie"] = make_set_cookie(jwt_token, cookie_name, secure=secure)
return response
+18 -7
View File
@@ -48,6 +48,7 @@ from turnstone.api.docs import make_docs_handler, make_openapi_handler
from turnstone.api.server_spec import build_server_spec
from turnstone.core.adapters.interactive_adapter import InteractiveAdapter
from turnstone.core.auth import (
AUTH_COOKIE_SERVER,
DENY_EMPTY_SUB,
JWT_AUD_SERVER,
AuthMiddleware,
@@ -2574,14 +2575,14 @@ async def auth_login(request: Request) -> Response:
"""POST /v1/api/auth/login — authenticate and return JWT."""
from turnstone.core.auth import handle_auth_login
return await handle_auth_login(request, JWT_AUD_SERVER)
return await handle_auth_login(request, JWT_AUD_SERVER, cookie_name=AUTH_COOKIE_SERVER)
async def auth_logout(request: Request) -> Response:
"""POST /v1/api/auth/logout — clear auth cookie."""
from turnstone.core.auth import handle_auth_logout
return await handle_auth_logout(request)
return await handle_auth_logout(request, cookie_name=AUTH_COOKIE_SERVER)
async def auth_status(request: Request) -> Response:
@@ -2595,14 +2596,14 @@ async def auth_setup(request: Request) -> Response:
"""POST /v1/api/auth/setup — create first admin user (public, one-time only)."""
from turnstone.core.auth import handle_auth_setup
return await handle_auth_setup(request, JWT_AUD_SERVER)
return await handle_auth_setup(request, JWT_AUD_SERVER, cookie_name=AUTH_COOKIE_SERVER)
async def auth_whoami(request: Request) -> Response:
"""GET /v1/api/auth/whoami — return authenticated user info."""
from turnstone.core.auth import handle_auth_whoami
return await handle_auth_whoami(request)
return await handle_auth_whoami(request, cookie_name=AUTH_COOKIE_SERVER)
async def auth_refresh(request: Request) -> Response:
@@ -2613,7 +2614,7 @@ async def auth_refresh(request: Request) -> Response:
"""
from turnstone.core.auth import handle_auth_refresh
return await handle_auth_refresh(request, JWT_AUD_SERVER)
return await handle_auth_refresh(request, JWT_AUD_SERVER, cookie_name=AUTH_COOKIE_SERVER)
async def oidc_authorize(request: Request) -> Response:
@@ -2627,7 +2628,7 @@ async def oidc_callback(request: Request) -> Response:
"""GET /v1/api/auth/oidc/callback — OIDC callback, exchange code for JWT."""
from turnstone.core.auth import handle_oidc_callback
return await handle_oidc_callback(request, JWT_AUD_SERVER)
return await handle_oidc_callback(request, JWT_AUD_SERVER, cookie_name=AUTH_COOKIE_SERVER)
async def mcp_oauth_authorize(request: Request) -> Response:
@@ -3666,7 +3667,12 @@ def _build_middleware(cors_origins: list[str] | None = None) -> list[Middleware]
stack.append(cors_middleware(cors_origins))
stack.extend(
[
Middleware(AuthMiddleware, jwt_audience=JWT_AUD_SERVER, jwt_version=jwt_version_slot()),
Middleware(
AuthMiddleware,
jwt_audience=JWT_AUD_SERVER,
jwt_version=jwt_version_slot(),
cookie_name=AUTH_COOKIE_SERVER,
),
Middleware(RateLimitMiddleware),
]
)
@@ -4661,6 +4667,11 @@ def main() -> None:
tls_client = TLSClient(
storage=get_storage(),
hostnames=hostnames,
# A bare-metal node can't resolve the in-network console URL the
# services table advertises (http://console:8090), so honor an
# explicit override pointing at the published ACME endpoint.
# Empty (the in-cluster default) falls back to service discovery.
console_url=os.environ.get("TURNSTONE_CONSOLE_URL", ""),
)
asyncio.run(tls_client.init(attempts=TLS_INIT_RETRY_ATTEMPTS))
bundle = tls_client.bundle
Generated
+1 -1
View File
@@ -2324,7 +2324,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "1.6.4"
version = "1.6.5"
source = { editable = "." }
dependencies = [
{ name = "alembic" },