mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93a9fd3c28 | |||
| 62ff3217d0 | |||
| b086390558 | |||
| d08a57dfc2 | |||
| 9fdf51ff3d | |||
| 45471894da | |||
| c0b5952573 | |||
| b9d5b5b671 | |||
| 274c97135e | |||
| e87f8e19c2 |
+2
-2
@@ -25,12 +25,12 @@ ENV UV_COMPILE_BYTECODE=1
|
||||
# Install dependencies first (cached layer — only re-runs when deps change)
|
||||
COPY pyproject.toml uv.lock README.md LICENSE ./
|
||||
RUN uv sync --frozen --no-install-project --no-dev \
|
||||
--extra mq --extra console --extra sim --extra postgres --extra discord --extra anthropic --extra ddg
|
||||
--extra all
|
||||
|
||||
# Install the project itself
|
||||
COPY turnstone/ turnstone/
|
||||
RUN uv sync --frozen --no-dev \
|
||||
--extra mq --extra console --extra sim --extra postgres --extra discord --extra anthropic --extra ddg
|
||||
--extra all
|
||||
|
||||
# Add venv to PATH so entry points are found
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# TLS overlay — enables mTLS across the turnstone cluster.
|
||||
#
|
||||
# Usage (requires base compose.yaml with production profile):
|
||||
# docker compose -f compose.yaml -f deploy/docker-compose.tls.yml --profile production up
|
||||
#
|
||||
# The tls-init service bootstraps a CA and issues a cert for Redis.
|
||||
# All turnstone services auto-provision their own certs via the
|
||||
# console's ACME endpoint.
|
||||
|
||||
services:
|
||||
# Bootstrap: create CA + Redis cert before anything starts.
|
||||
# Runs as root to create directories in the volume, then chowns
|
||||
# to turnstone:turnstone with restrictive perms (keys 0600).
|
||||
tls-init:
|
||||
build: .
|
||||
user: root
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
turnstone-admin tls-bootstrap --out /certs --issue redis
|
||||
chown -R turnstone:turnstone /certs
|
||||
find /certs -type d -exec chmod 750 {} +
|
||||
find /certs -type f -name '*key.pem' -exec chmod 600 {} +
|
||||
find /certs -type f ! -name '*key.pem' -exec chmod 640 {} +
|
||||
volumes:
|
||||
- tls-certs:/certs
|
||||
networks:
|
||||
- turnstone-net
|
||||
restart: "no"
|
||||
|
||||
# Console: runs the internal CA + ACME server
|
||||
console:
|
||||
depends_on:
|
||||
tls-init:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
environment:
|
||||
TURNSTONE_TLS_ENABLED: "true"
|
||||
TURNSTONE_TLS_SANS: "console"
|
||||
TURNSTONE_CONSOLE_URL: "http://console:8090"
|
||||
command:
|
||||
- turnstone-console
|
||||
- --host=0.0.0.0
|
||||
- --port=8090
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --poll-interval=${CONSOLE_POLL_INTERVAL:-10}
|
||||
- --redis-tls
|
||||
- --redis-tls-ca=/certs/ca.pem
|
||||
|
||||
# Server: auto-provisions certs via console ACME, serves HTTPS
|
||||
server:
|
||||
depends_on:
|
||||
console:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
environment:
|
||||
TURNSTONE_TLS_ENABLED: "true"
|
||||
TURNSTONE_TLS_SANS: "server"
|
||||
# Disable healthcheck — server serves HTTPS with mTLS which the
|
||||
# stdlib healthcheck script can't satisfy. The base compose
|
||||
# healthcheck uses plain HTTP which won't work on an HTTPS listener.
|
||||
# TODO: wire healthcheck with client cert from /certs volume
|
||||
healthcheck:
|
||||
disable: true
|
||||
|
||||
# Bridge: mTLS to server + Redis TLS
|
||||
bridge:
|
||||
depends_on:
|
||||
console:
|
||||
condition: service_healthy
|
||||
server:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
environment:
|
||||
TURNSTONE_TLS_ENABLED: "true"
|
||||
TURNSTONE_TLS_SANS: "bridge"
|
||||
command:
|
||||
- turnstone-bridge
|
||||
- --server-url=http://server:8080
|
||||
- --redis-host=redis
|
||||
- --redis-port=6379
|
||||
- --heartbeat-ttl=${HEARTBEAT_TTL:-60}
|
||||
- --approval-timeout=${APPROVAL_TIMEOUT:-3600}
|
||||
- --redis-tls
|
||||
- --redis-tls-ca=/certs/ca.pem
|
||||
|
||||
# Channel: Redis TLS
|
||||
channel:
|
||||
depends_on:
|
||||
console:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
environment:
|
||||
TURNSTONE_TLS_ENABLED: "true"
|
||||
TURNSTONE_TLS_SANS: "channel"
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >-
|
||||
turnstone-channel
|
||||
--redis-host=redis
|
||||
--redis-port=6379
|
||||
--redis-tls
|
||||
--redis-tls-ca=/certs/ca.pem
|
||||
--http-host=0.0.0.0
|
||||
$${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD}
|
||||
|
||||
# Redis: TLS with certs from bootstrap
|
||||
redis:
|
||||
depends_on:
|
||||
tls-init:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- tls-certs:/certs:ro
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
ARGS="--tls-port 6379 --port 0 \
|
||||
--tls-cert-file /certs/certs/redis/cert.pem \
|
||||
--tls-key-file /certs/certs/redis/key.pem \
|
||||
--tls-ca-cert-file /certs/ca.pem \
|
||||
--tls-auth-clients no"
|
||||
if [ -n "$$REDIS_PASSWORD" ]; then
|
||||
ARGS="$$ARGS --requirepass $$REDIS_PASSWORD"
|
||||
fi
|
||||
exec redis-server $$ARGS
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "if [ -n \"$$REDIS_PASSWORD\" ]; then redis-cli --tls --cacert /certs/ca.pem -a $$REDIS_PASSWORD ping; else redis-cli --tls --cacert /certs/ca.pem ping; fi"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
tls-certs:
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
# TLS / mTLS
|
||||
|
||||
Turnstone supports end-to-end transport encryption with mutual TLS (mTLS) for
|
||||
inter-service communication, powered by [lacme](https://pypi.org/project/lacme/).
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (Docker Compose)
|
||||
|
||||
```bash
|
||||
docker compose -f compose.yaml -f deploy/docker-compose.tls.yml up
|
||||
```
|
||||
|
||||
This:
|
||||
1. Bootstraps an internal CA and issues certs for Redis/PostgreSQL
|
||||
2. Starts the console with TLS enabled (internal CA + ACME server)
|
||||
3. Server nodes auto-provision certs via the console's ACME endpoint
|
||||
4. All inter-service communication uses mTLS
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Console (CA + ACME Server)
|
||||
+-- CertificateAuthority (owns root key, signs certs)
|
||||
+-- ACMEResponder (mounted at /acme, RFC 8555)
|
||||
+-- GET /acme/ca.pem (root cert for node bootstrapping)
|
||||
|
|
||||
| ACME protocol (auto-approve, no challenge validation)
|
||||
+-----------+-----------+
|
||||
| | |
|
||||
Server(s) Bridge Channel GW
|
||||
(auto-cert (mTLS (mTLS
|
||||
+ renewal) client) client)
|
||||
```
|
||||
|
||||
**Two cert paths on the console:**
|
||||
- **Internal cert** (mTLS): Always from the internal CA. Used for cluster
|
||||
service mesh communication.
|
||||
- **Frontend cert** (HTTPS): From an external ACME CA (e.g. Let's Encrypt)
|
||||
if `tls.acme_directory` is set, otherwise self-issued from the internal CA.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Settings (ConfigStore / Admin Settings tab)
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `tls.enabled` | `false` | Master switch for internal mTLS |
|
||||
| `tls.acme_directory` | `""` | External ACME CA URL for console frontend cert |
|
||||
|
||||
### Bootstrap Config (config.toml)
|
||||
|
||||
These are needed before storage is available:
|
||||
|
||||
```toml
|
||||
[redis]
|
||||
tls = false
|
||||
tls_ca = "" # path to CA cert
|
||||
tls_cert = "" # path to client cert
|
||||
tls_key = "" # path to client key
|
||||
|
||||
[database]
|
||||
sslmode = "prefer" # disable, allow, prefer, require, verify-full
|
||||
sslrootcert = "" # path to CA cert
|
||||
sslcert = "" # path to client cert
|
||||
sslkey = "" # path to client key
|
||||
```
|
||||
|
||||
### Hardcoded Defaults
|
||||
|
||||
| Parameter | Value | Notes |
|
||||
|-----------|-------|-------|
|
||||
| CA common name | "Turnstone CA" | |
|
||||
| CA validity | 10 years | |
|
||||
| Cert validity | 48 hours | Short-lived, auto-renewed |
|
||||
| Renewal interval | 24 hours | Half of validity |
|
||||
| ACME auto-approve | true | Internal network, no challenge validation |
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
### Offline Bootstrap
|
||||
|
||||
Create a CA and infrastructure certs without a running console:
|
||||
|
||||
```bash
|
||||
# Bootstrap CA + Redis + PostgreSQL certs
|
||||
turnstone-admin tls-bootstrap --out /certs --issue redis --issue postgres
|
||||
|
||||
# Output:
|
||||
# /certs/ca.pem (CA root certificate)
|
||||
# /certs/certs/redis/ (Redis cert + key)
|
||||
# /certs/certs/postgres/ (PostgreSQL cert + key)
|
||||
```
|
||||
|
||||
The output directory is chmod 0700 (contains the CA private key).
|
||||
|
||||
### Online Cert Issuance
|
||||
|
||||
Request certs from a running console's ACME endpoint:
|
||||
|
||||
```bash
|
||||
# Download CA root cert (TOFU — verify fingerprint)
|
||||
turnstone-admin tls-ca-cert --out ca.pem --console-url http://console:8080
|
||||
|
||||
# Request a cert for a domain
|
||||
turnstone-admin tls-issue worker-1.internal --out /certs --console-url http://console:8080
|
||||
|
||||
# List issued certs
|
||||
turnstone-admin tls-list --console-url http://console:8080 --auth-token $TOKEN
|
||||
```
|
||||
|
||||
### Console URL Discovery
|
||||
|
||||
If `--console-url` is not provided, the CLI discovers it from the `services`
|
||||
table in the shared database. The console registers itself on startup.
|
||||
|
||||
---
|
||||
|
||||
## Admin UI
|
||||
|
||||
The **TLS** tab in the console admin panel (System group) shows:
|
||||
- CA status (common name, certificate count)
|
||||
- Certificate table (domain, SANs, issued, expires)
|
||||
- Force-renew and delete actions per certificate
|
||||
|
||||
---
|
||||
|
||||
## SDK
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from turnstone.sdk import TurnstoneServer
|
||||
|
||||
client = TurnstoneServer(
|
||||
base_url="https://server:8080",
|
||||
token="tok_xxx",
|
||||
ca_cert="/path/to/ca.pem",
|
||||
client_cert="/path/to/cert.pem",
|
||||
client_key="/path/to/key.pem",
|
||||
)
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
```typescript
|
||||
import { TurnstoneServer } from "@turnstone/sdk";
|
||||
import { Agent } from "undici";
|
||||
import * as fs from "fs";
|
||||
|
||||
const agent = new Agent({
|
||||
connect: {
|
||||
ca: fs.readFileSync("/path/to/ca.pem"),
|
||||
cert: fs.readFileSync("/path/to/cert.pem"),
|
||||
key: fs.readFileSync("/path/to/key.pem"),
|
||||
},
|
||||
});
|
||||
|
||||
const client = new TurnstoneServer({
|
||||
baseUrl: "https://server:8080",
|
||||
token: "tok_xxx",
|
||||
// Node.js 18+ uses undici under the hood
|
||||
fetch: (url, init) =>
|
||||
fetch(url, { ...init, dispatcher: agent } as RequestInit),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### Node Bootstrap Flow
|
||||
|
||||
1. Node starts, connects to shared database (plain connection)
|
||||
2. Discovers console URL from `services` table
|
||||
3. Fetches CA root cert from `http://console/acme/ca.pem` (plain HTTP, TOFU)
|
||||
4. Requests service cert via ACME protocol (plain HTTP, JWS-signed)
|
||||
5. Starts auto-renewal (24h interval, re-issues before expiry)
|
||||
6. All subsequent inter-service communication uses mTLS
|
||||
|
||||
### Console Startup Flow
|
||||
|
||||
1. Read `tls.enabled` from ConfigStore
|
||||
2. Initialize CA (load from DB or generate new root key)
|
||||
3. Mount ACME responder at `/acme` (serves `/ca.pem` natively)
|
||||
4. Issue console certs (internal + optional frontend)
|
||||
5. Start CA-direct auto-renewal (no network, signs directly)
|
||||
6. Register console URL in services table with heartbeat
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cert expired / mTLS connection refused
|
||||
|
||||
Certs are valid for 48 hours. If auto-renewal stopped (e.g. console was down),
|
||||
restart the service to re-request a cert.
|
||||
|
||||
### "No console service found"
|
||||
|
||||
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.
|
||||
|
||||
### Let's Encrypt for console frontend
|
||||
|
||||
Set `tls.acme_directory` to `https://acme-v02.api.letsencrypt.org/directory`
|
||||
in the admin Settings tab. The console will request a publicly trusted cert
|
||||
for its HTTPS endpoint. Internal mTLS still uses the private CA.
|
||||
|
||||
### Verifying the cert chain
|
||||
|
||||
```bash
|
||||
openssl s_client -connect server:8080 -CAfile ca.pem
|
||||
```
|
||||
+7
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
@@ -53,7 +53,8 @@ anthropic = ["anthropic>=0.39"]
|
||||
postgres = ["psycopg[binary]>=3.2"]
|
||||
ddg = ["ddgs>=9.0"]
|
||||
discord = ["discord.py>=2.4", "redis>=7.2"]
|
||||
all = ["turnstone[mq,console,sim,anthropic,postgres,discord,ddg]"]
|
||||
tls = ["lacme>=1.0.4"]
|
||||
all = ["turnstone[mq,console,sim,anthropic,postgres,discord,ddg,tls]"]
|
||||
|
||||
[project.scripts]
|
||||
turnstone = "turnstone.cli:main"
|
||||
@@ -169,6 +170,10 @@ ignore_missing_imports = true
|
||||
module = ["ddgs", "ddgs.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["lacme", "lacme.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["turnstone.channels.discord.*"]
|
||||
disallow_subclassing_any = false
|
||||
|
||||
Generated
+3
-4
@@ -912,11 +912,10 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { TurnstoneAPIError } from "./errors.js";
|
||||
import { parseSSEStream } from "./sse.js";
|
||||
|
||||
export interface TlsOptions {
|
||||
/** Path to CA certificate PEM file (Node.js only). */
|
||||
caCert?: string;
|
||||
/** Path to client certificate PEM file for mTLS (Node.js only). */
|
||||
clientCert?: string;
|
||||
/** Path to client key PEM file for mTLS (Node.js only). */
|
||||
clientKey?: string;
|
||||
}
|
||||
|
||||
export interface ClientOptions {
|
||||
/** Server base URL (e.g. "http://localhost:8080"). */
|
||||
baseUrl: string;
|
||||
@@ -8,6 +17,13 @@ export interface ClientOptions {
|
||||
token?: string;
|
||||
/** Custom fetch implementation (defaults to globalThis.fetch). */
|
||||
fetch?: typeof globalThis.fetch;
|
||||
/**
|
||||
* TLS certificate paths for documentation and tooling.
|
||||
* The SDK does not read these directly — pass a custom `fetch`
|
||||
* configured with your runtime's TLS agent (e.g. Node.js https.Agent).
|
||||
* See docs/tls.md for examples.
|
||||
*/
|
||||
tls?: TlsOptions;
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
// Clients
|
||||
export { TurnstoneServer } from "./server.js";
|
||||
export { TurnstoneConsole } from "./console.js";
|
||||
export type { ClientOptions } from "./base.js";
|
||||
export type { ClientOptions, TlsOptions } from "./base.js";
|
||||
|
||||
// Errors
|
||||
export { TurnstoneAPIError } from "./errors.js";
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Tests for TLS admin API endpoints and CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage import get_storage, init_storage, reset_storage
|
||||
|
||||
lacme = pytest.importorskip("lacme")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _storage(tmp_path):
|
||||
"""Initialize ephemeral SQLite storage for each test."""
|
||||
reset_storage()
|
||||
db = str(tmp_path / "test.db")
|
||||
init_storage("sqlite", path=db)
|
||||
yield
|
||||
reset_storage()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tls_manager():
|
||||
"""Create an initialized TLSManager."""
|
||||
import asyncio
|
||||
|
||||
from turnstone.console.tls import TLSManager
|
||||
|
||||
mgr = TLSManager(get_storage())
|
||||
asyncio.run(mgr.init_ca())
|
||||
# Issue a test cert
|
||||
asyncio.run(mgr.issue_console_certs(["test.internal", "localhost"]))
|
||||
return mgr
|
||||
|
||||
|
||||
# ── Admin API endpoints ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_app(tls_manager):
|
||||
"""Create a minimal Starlette app with TLS endpoints."""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Route
|
||||
|
||||
from turnstone.console.server import (
|
||||
tls_ca_cert,
|
||||
tls_ca_status,
|
||||
tls_delete_cert,
|
||||
tls_list_certs,
|
||||
tls_renew_cert,
|
||||
)
|
||||
from turnstone.core.auth import AuthResult
|
||||
|
||||
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="",
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="config",
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/ca", tls_ca_status),
|
||||
Route("/ca.pem", tls_ca_cert),
|
||||
Route("/certs", tls_list_certs),
|
||||
Route("/certs/{domain}/renew", tls_renew_cert, methods=["POST"]),
|
||||
Route("/certs/{domain}", tls_delete_cert, methods=["DELETE"]),
|
||||
],
|
||||
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_access)],
|
||||
)
|
||||
app.state.tls_manager = tls_manager
|
||||
return app
|
||||
|
||||
|
||||
def test_list_certs(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app(tls_manager))
|
||||
resp = client.get("/certs")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["certs"]) >= 1
|
||||
assert data["certs"][0]["domain"] == "test.internal"
|
||||
|
||||
|
||||
def test_renew_cert(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app(tls_manager))
|
||||
resp = client.post("/certs/test.internal/renew")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["domain"] == "test.internal"
|
||||
|
||||
|
||||
def test_renew_cert_not_found(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app(tls_manager))
|
||||
resp = client.post("/certs/nonexistent.internal/renew")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_cert(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app(tls_manager))
|
||||
resp = client.delete("/certs/test.internal")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["deleted"] == "test.internal"
|
||||
# Verify it's gone
|
||||
resp = client.get("/certs")
|
||||
domains = [c["domain"] for c in resp.json()["certs"]]
|
||||
assert "test.internal" not in domains
|
||||
|
||||
|
||||
def test_delete_cert_not_found(tls_manager):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
client = TestClient(_make_app(tls_manager))
|
||||
resp = client.delete("/certs/nonexistent.internal")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── CLI bootstrap ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cli_bootstrap(tmp_path):
|
||||
"""Test offline CA bootstrap."""
|
||||
import argparse
|
||||
|
||||
from turnstone.admin import _cmd_tls_bootstrap
|
||||
|
||||
out = tmp_path / "certs"
|
||||
args = argparse.Namespace(out=str(out), issue=["redis.internal", "pg.internal"])
|
||||
_cmd_tls_bootstrap(args)
|
||||
|
||||
assert (out / "ca.pem").exists()
|
||||
assert b"BEGIN CERTIFICATE" in (out / "ca.pem").read_bytes()
|
||||
# Check certs were issued
|
||||
assert (out / "certs" / "redis.internal").exists()
|
||||
assert (out / "certs" / "pg.internal").exists()
|
||||
|
||||
|
||||
def test_cli_bootstrap_no_issue(tmp_path):
|
||||
"""Bootstrap with no --issue creates CA only."""
|
||||
import argparse
|
||||
|
||||
from turnstone.admin import _cmd_tls_bootstrap
|
||||
|
||||
out = tmp_path / "certs"
|
||||
args = argparse.Namespace(out=str(out), issue=[])
|
||||
_cmd_tls_bootstrap(args)
|
||||
|
||||
assert (out / "ca.pem").exists()
|
||||
# No certs dir
|
||||
certs_dir = out / "certs"
|
||||
if certs_dir.exists():
|
||||
assert len(list(certs_dir.iterdir())) == 0
|
||||
|
||||
|
||||
# ── Config parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_redis_tls_config_map():
|
||||
"""Redis TLS keys are in the config map."""
|
||||
from turnstone.core.config import _CONFIG_MAP
|
||||
|
||||
redis_map = _CONFIG_MAP["redis"]
|
||||
assert "tls" in redis_map
|
||||
assert "tls_ca" in redis_map
|
||||
assert "tls_cert" in redis_map
|
||||
assert "tls_key" in redis_map
|
||||
|
||||
|
||||
def test_database_ssl_config_map():
|
||||
"""Database SSL keys are in the config map."""
|
||||
from turnstone.core.config import _CONFIG_MAP
|
||||
|
||||
db_map = _CONFIG_MAP["database"]
|
||||
assert "sslmode" in db_map
|
||||
assert "sslrootcert" in db_map
|
||||
assert "sslcert" in db_map
|
||||
assert "sslkey" in db_map
|
||||
|
||||
|
||||
# ── Auth enforcement ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_tls_endpoints_require_auth(tls_manager):
|
||||
"""TLS admin endpoints return 401 without auth."""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import tls_ca_status, tls_list_certs
|
||||
|
||||
# No auth middleware — request.state.auth_result will be missing
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/ca", tls_ca_status),
|
||||
Route("/certs", tls_list_certs),
|
||||
]
|
||||
)
|
||||
app.state.tls_manager = tls_manager
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/ca")
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = client.get("/certs")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ── SDK TLS params ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_sdk_client_cert_requires_both():
|
||||
"""SDK raises ValueError if only one of client_cert/client_key provided."""
|
||||
from turnstone.sdk._base import _BaseClient
|
||||
|
||||
with pytest.raises(ValueError, match="Both client_cert and client_key"):
|
||||
_BaseClient(
|
||||
base_url="http://localhost:8080",
|
||||
client_cert="/path/to/cert.pem",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Both client_cert and client_key"):
|
||||
_BaseClient(
|
||||
base_url="http://localhost:8080",
|
||||
client_key="/path/to/key.pem",
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for TLSClient — service node certificate provisioning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage import get_storage, init_storage, reset_storage
|
||||
|
||||
lacme = pytest.importorskip("lacme")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _storage(tmp_path):
|
||||
"""Initialize ephemeral SQLite storage for each test."""
|
||||
reset_storage()
|
||||
db = str(tmp_path / "test.db")
|
||||
init_storage("sqlite", path=db)
|
||||
yield
|
||||
reset_storage()
|
||||
|
||||
|
||||
# ── Console URL discovery ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_discover_console_url():
|
||||
"""TLSClient discovers console URL from services table."""
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
storage = get_storage()
|
||||
storage.register_service("console", "console", "http://console:8080")
|
||||
|
||||
client = TLSClient(storage=storage, hostnames=["node-1"])
|
||||
url = client._discover_console_url()
|
||||
assert url == "http://console:8080"
|
||||
|
||||
|
||||
def test_discover_console_url_missing():
|
||||
"""TLSClient raises if no console registered."""
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
client = TLSClient(storage=get_storage(), hostnames=["node-1"])
|
||||
with pytest.raises(RuntimeError, match="No console service found"):
|
||||
client._discover_console_url()
|
||||
|
||||
|
||||
def test_explicit_console_url_skips_discovery():
|
||||
"""When console_url is provided, discovery is skipped."""
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
client = TLSClient(
|
||||
storage=get_storage(),
|
||||
console_url="http://explicit:9090",
|
||||
hostnames=["node-1"],
|
||||
)
|
||||
assert client._console_url == "http://explicit:9090"
|
||||
|
||||
|
||||
# ── SSL context construction ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ssl_contexts_none_before_init():
|
||||
"""SSL contexts are None before init()."""
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
client = TLSClient(
|
||||
storage=get_storage(),
|
||||
console_url="http://localhost:8080",
|
||||
hostnames=["node-1"],
|
||||
)
|
||||
assert client.get_server_ssl_context() is None
|
||||
assert client.get_client_ssl_context() is None
|
||||
assert not client.initialized
|
||||
|
||||
|
||||
# ── Backward compatibility ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bridge_tls_defaults():
|
||||
"""Bridge with default TLS params works without changes."""
|
||||
from turnstone.mq.bridge import Bridge
|
||||
|
||||
# Default: tls_verify=True, tls_cert=None — no mTLS
|
||||
bridge = Bridge(server_url="http://localhost:8080")
|
||||
assert bridge._tls_verify is True
|
||||
assert bridge._tls_cert is None
|
||||
|
||||
|
||||
def test_collector_tls_defaults():
|
||||
"""Collector with default TLS params works without changes."""
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
|
||||
broker_mock = MagicMock()
|
||||
collector = ClusterCollector(broker=broker_mock)
|
||||
# Should create httpx client without errors
|
||||
assert collector._http_client is not None
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Tests for TLSManager — console CA and ACME server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage import get_storage, init_storage, reset_storage
|
||||
|
||||
lacme = pytest.importorskip("lacme")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _storage(tmp_path):
|
||||
"""Initialize ephemeral SQLite storage for each test."""
|
||||
reset_storage()
|
||||
db = str(tmp_path / "test.db")
|
||||
init_storage("sqlite", path=db)
|
||||
yield
|
||||
reset_storage()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tls_manager():
|
||||
"""Create a TLSManager backed by test storage."""
|
||||
from turnstone.console.tls import TLSManager
|
||||
|
||||
return TLSManager(get_storage())
|
||||
|
||||
|
||||
# ── CA initialization ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_init_ca(tls_manager):
|
||||
await tls_manager.init_ca()
|
||||
assert tls_manager.ca_initialized
|
||||
root_pem = tls_manager.get_root_cert_pem()
|
||||
assert b"BEGIN CERTIFICATE" in root_pem
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_init_ca_persists(tls_manager):
|
||||
"""CA root survives re-initialization (loaded from storage)."""
|
||||
await tls_manager.init_ca()
|
||||
pem1 = tls_manager.get_root_cert_pem()
|
||||
|
||||
# Create a new manager on the same storage
|
||||
from turnstone.console.tls import TLSManager
|
||||
|
||||
mgr2 = TLSManager(get_storage())
|
||||
await mgr2.init_ca()
|
||||
pem2 = mgr2.get_root_cert_pem()
|
||||
|
||||
assert pem1 == pem2 # Same CA loaded from DB
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_responder_before_init(tls_manager):
|
||||
with pytest.raises(RuntimeError, match="CA not initialized"):
|
||||
tls_manager.get_responder()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_responder(tls_manager):
|
||||
await tls_manager.init_ca()
|
||||
responder = tls_manager.get_responder()
|
||||
assert responder is not None
|
||||
# Should be an ASGI app (callable)
|
||||
assert callable(responder)
|
||||
|
||||
|
||||
# ── Cert issuance ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_console_certs_internal(tls_manager):
|
||||
"""Console certs issued from internal CA when no external directory."""
|
||||
await tls_manager.init_ca()
|
||||
await tls_manager.issue_console_certs(["console.internal", "localhost"])
|
||||
assert tls_manager.internal_bundle is not None
|
||||
assert tls_manager.frontend_bundle is not None
|
||||
assert tls_manager.internal_bundle.domain == "console.internal"
|
||||
assert b"BEGIN CERTIFICATE" in tls_manager.internal_bundle.cert_pem
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_console_certs_persists(tls_manager):
|
||||
"""Certs loaded from storage on re-issue."""
|
||||
await tls_manager.init_ca()
|
||||
await tls_manager.issue_console_certs(["console.internal"])
|
||||
bundle1 = tls_manager.internal_bundle
|
||||
|
||||
# New manager, same storage
|
||||
from turnstone.console.tls import TLSManager
|
||||
|
||||
mgr2 = TLSManager(get_storage())
|
||||
await mgr2.init_ca()
|
||||
await mgr2.issue_console_certs(["console.internal"])
|
||||
bundle2 = mgr2.internal_bundle
|
||||
|
||||
assert bundle1.cert_pem == bundle2.cert_pem
|
||||
|
||||
|
||||
# ── SSL contexts ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ssl_contexts_none_before_certs(tls_manager):
|
||||
await tls_manager.init_ca()
|
||||
assert tls_manager.get_server_ssl_context() is None
|
||||
assert tls_manager.get_client_ssl_context() is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ssl_contexts_after_certs(tls_manager):
|
||||
await tls_manager.init_ca()
|
||||
await tls_manager.issue_console_certs(["console.internal"])
|
||||
server_ctx = tls_manager.get_server_ssl_context()
|
||||
client_ctx = tls_manager.get_client_ssl_context()
|
||||
assert server_ctx is not None
|
||||
assert client_ctx is not None
|
||||
import ssl
|
||||
|
||||
assert isinstance(server_ctx, ssl.SSLContext)
|
||||
assert isinstance(client_ctx, ssl.SSLContext)
|
||||
|
||||
|
||||
# ── Root cert endpoint ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tls_ca_cert_endpoint(tls_manager):
|
||||
"""Test the CA cert download endpoint via test client."""
|
||||
await tls_manager.init_ca()
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import tls_ca_cert, tls_ca_status
|
||||
|
||||
# Middleware that grants full access (config-token style: no user_id)
|
||||
from turnstone.core.auth import AuthResult
|
||||
|
||||
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="", scopes=frozenset({"approve"}), token_source="config"
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/ca.pem", tls_ca_cert),
|
||||
Route("/ca", tls_ca_status),
|
||||
],
|
||||
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_access)],
|
||||
)
|
||||
app.state.tls_manager = tls_manager
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# CA cert download
|
||||
resp = client.get("/ca.pem")
|
||||
assert resp.status_code == 200
|
||||
assert b"BEGIN CERTIFICATE" in resp.content
|
||||
assert resp.headers["content-type"] == "application/x-pem-file"
|
||||
|
||||
# CA status
|
||||
resp = client.get("/ca")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["enabled"] is True
|
||||
assert data["ca_cn"] == "Turnstone CA"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tls_endpoints_disabled():
|
||||
"""Endpoints return 404/disabled when TLS not enabled."""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import tls_ca_cert, tls_ca_status
|
||||
from turnstone.core.auth import AuthResult
|
||||
|
||||
async def _grant_access(request, call_next): # type: ignore[no-untyped-def]
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="", scopes=frozenset({"approve"}), token_source="config"
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/ca.pem", tls_ca_cert),
|
||||
Route("/ca", tls_ca_status),
|
||||
],
|
||||
middleware=[Middleware(BaseHTTPMiddleware, dispatch=_grant_access)],
|
||||
)
|
||||
# No tls_manager on state
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/ca.pem")
|
||||
assert resp.status_code == 404
|
||||
|
||||
resp = client.get("/ca")
|
||||
data = resp.json()
|
||||
assert data["enabled"] is False
|
||||
|
||||
|
||||
# ── Events ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_event_dispatcher_wired(tls_manager):
|
||||
"""Verify the event dispatcher has subscribers."""
|
||||
assert tls_manager._event_dispatcher is not None
|
||||
# Should have at least 4 subscriptions (issued, renewed, expiring, failed)
|
||||
# The exact check depends on lacme's EventDispatcher internals,
|
||||
# so just verify the dispatcher exists and the manager initializes cleanly
|
||||
await tls_manager.init_ca()
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Tests for TLS storage backend and lacme Store adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage import get_storage, init_storage, reset_storage
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _storage(tmp_path):
|
||||
"""Initialize ephemeral SQLite storage for each test."""
|
||||
reset_storage()
|
||||
db = str(tmp_path / "test.db")
|
||||
init_storage("sqlite", path=db)
|
||||
yield
|
||||
reset_storage()
|
||||
|
||||
|
||||
# ── Account keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_save_and_load_account_key():
|
||||
s = get_storage()
|
||||
s.save_tls_account_key(
|
||||
"default",
|
||||
"-----BEGIN EC PRIVATE KEY-----\nfake\n-----END EC PRIVATE KEY-----",
|
||||
)
|
||||
result = s.load_tls_account_key("default")
|
||||
assert result is not None
|
||||
assert "EC PRIVATE KEY" in result
|
||||
|
||||
|
||||
def test_load_account_key_missing():
|
||||
s = get_storage()
|
||||
assert s.load_tls_account_key("nonexistent") is None
|
||||
|
||||
|
||||
def test_save_account_key_upsert():
|
||||
s = get_storage()
|
||||
s.save_tls_account_key("default", "key-v1")
|
||||
s.save_tls_account_key("default", "key-v2")
|
||||
assert s.load_tls_account_key("default") == "key-v2"
|
||||
|
||||
|
||||
# ── CA ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_save_and_load_ca():
|
||||
s = get_storage()
|
||||
s.save_tls_ca("Turnstone CA", "cert-pem-data", "key-pem-data")
|
||||
result = s.load_tls_ca("Turnstone CA")
|
||||
assert result is not None
|
||||
assert result["cert_pem"] == "cert-pem-data"
|
||||
assert result["key_pem"] == "key-pem-data"
|
||||
assert result["name"] == "Turnstone CA"
|
||||
|
||||
|
||||
def test_load_ca_missing():
|
||||
s = get_storage()
|
||||
assert s.load_tls_ca("nonexistent") is None
|
||||
|
||||
|
||||
def test_save_ca_upsert():
|
||||
s = get_storage()
|
||||
s.save_tls_ca("CA", "cert-v1", "key-v1")
|
||||
s.save_tls_ca("CA", "cert-v2", "key-v2")
|
||||
result = s.load_tls_ca("CA")
|
||||
assert result["cert_pem"] == "cert-v2"
|
||||
assert result["key_pem"] == "key-v2"
|
||||
|
||||
|
||||
# ── Certificates ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_save_and_load_cert():
|
||||
s = get_storage()
|
||||
s.save_tls_cert(
|
||||
domain="node-1.internal",
|
||||
cert_pem="cert-data",
|
||||
fullchain_pem="fullchain-data",
|
||||
key_pem="key-data",
|
||||
issued_at="2026-03-25T00:00:00",
|
||||
expires_at="2026-03-27T00:00:00",
|
||||
meta=json.dumps({"domains": ["node-1.internal", "10.0.1.5"]}),
|
||||
)
|
||||
result = s.load_tls_cert("node-1.internal")
|
||||
assert result is not None
|
||||
assert result["domain"] == "node-1.internal"
|
||||
assert result["cert_pem"] == "cert-data"
|
||||
assert result["fullchain_pem"] == "fullchain-data"
|
||||
assert result["key_pem"] == "key-data"
|
||||
assert result["issued_at"] == "2026-03-25T00:00:00"
|
||||
assert result["expires_at"] == "2026-03-27T00:00:00"
|
||||
meta = json.loads(result["meta"])
|
||||
assert meta["domains"] == ["node-1.internal", "10.0.1.5"]
|
||||
|
||||
|
||||
def test_load_cert_missing():
|
||||
s = get_storage()
|
||||
assert s.load_tls_cert("nonexistent") is None
|
||||
|
||||
|
||||
def test_save_cert_upsert():
|
||||
s = get_storage()
|
||||
s.save_tls_cert("d", "c1", "f1", "k1", "2026-01-01", "2026-01-02")
|
||||
s.save_tls_cert("d", "c2", "f2", "k2", "2026-02-01", "2026-02-02")
|
||||
result = s.load_tls_cert("d")
|
||||
assert result["cert_pem"] == "c2"
|
||||
assert result["issued_at"] == "2026-02-01"
|
||||
|
||||
|
||||
def test_list_certs_empty():
|
||||
s = get_storage()
|
||||
assert s.list_tls_certs() == []
|
||||
|
||||
|
||||
def test_list_certs():
|
||||
s = get_storage()
|
||||
s.save_tls_cert("alpha.internal", "c", "f", "k", "2026-01-01", "2026-01-02")
|
||||
s.save_tls_cert("beta.internal", "c", "f", "k", "2026-01-01", "2026-01-02")
|
||||
certs = s.list_tls_certs()
|
||||
assert len(certs) == 2
|
||||
assert certs[0]["domain"] == "alpha.internal" # sorted by domain
|
||||
assert certs[1]["domain"] == "beta.internal"
|
||||
|
||||
|
||||
def test_delete_cert():
|
||||
s = get_storage()
|
||||
s.save_tls_cert("d", "c", "f", "k", "2026-01-01", "2026-01-02")
|
||||
assert s.delete_tls_cert("d") is True
|
||||
assert s.load_tls_cert("d") is None
|
||||
|
||||
|
||||
def test_delete_cert_missing():
|
||||
s = get_storage()
|
||||
assert s.delete_tls_cert("nonexistent") is False
|
||||
|
||||
|
||||
# ── StorageStore adapter ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_adapter():
|
||||
"""Create a StorageStore backed by the test database."""
|
||||
from turnstone.core.tls_store import StorageStore
|
||||
|
||||
return StorageStore(get_storage())
|
||||
|
||||
|
||||
def test_adapter_save_load_ca(store_adapter):
|
||||
store_adapter.save_ca("test-ca", b"cert-pem", b"key-pem")
|
||||
result = store_adapter.load_ca("test-ca")
|
||||
assert result is not None
|
||||
cert_pem, key_pem = result
|
||||
assert cert_pem == b"cert-pem"
|
||||
assert key_pem == b"key-pem"
|
||||
|
||||
|
||||
def test_adapter_load_ca_missing(store_adapter):
|
||||
assert store_adapter.load_ca("missing") is None
|
||||
|
||||
|
||||
def test_adapter_save_load_cert(store_adapter):
|
||||
lacme = pytest.importorskip("lacme")
|
||||
now = datetime.now(UTC)
|
||||
bundle = lacme.CertBundle(
|
||||
domain="test.internal",
|
||||
domains=("test.internal", "10.0.1.1"),
|
||||
cert_pem=b"cert",
|
||||
fullchain_pem=b"fullchain",
|
||||
key_pem=b"key",
|
||||
issued_at=now,
|
||||
expires_at=now,
|
||||
)
|
||||
store_adapter.save_cert(bundle)
|
||||
loaded = store_adapter.load_cert("test.internal")
|
||||
assert loaded is not None
|
||||
assert loaded.domain == "test.internal"
|
||||
assert loaded.domains == ("test.internal", "10.0.1.1")
|
||||
assert loaded.cert_pem == b"cert"
|
||||
assert loaded.fullchain_pem == b"fullchain"
|
||||
assert loaded.key_pem == b"key"
|
||||
|
||||
|
||||
def test_adapter_list_certs(store_adapter):
|
||||
lacme = pytest.importorskip("lacme")
|
||||
now = datetime.now(UTC)
|
||||
for name in ["alpha", "beta"]:
|
||||
bundle = lacme.CertBundle(
|
||||
domain=f"{name}.internal",
|
||||
domains=(f"{name}.internal",),
|
||||
cert_pem=b"c",
|
||||
fullchain_pem=b"f",
|
||||
key_pem=b"k",
|
||||
issued_at=now,
|
||||
expires_at=now,
|
||||
)
|
||||
store_adapter.save_cert(bundle)
|
||||
certs = store_adapter.list_certs()
|
||||
assert len(certs) == 2
|
||||
assert certs[0].domain == "alpha.internal"
|
||||
|
||||
|
||||
def test_adapter_load_cert_missing(store_adapter):
|
||||
assert store_adapter.load_cert("missing") is None
|
||||
|
||||
|
||||
def test_adapter_account_key_roundtrip(store_adapter):
|
||||
"""Test account key save/load with real cryptography objects."""
|
||||
pytest.importorskip("lacme")
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
store_adapter.save_account_key(key)
|
||||
loaded = store_adapter.load_account_key()
|
||||
assert loaded is not None
|
||||
# Verify it's a usable EC key
|
||||
assert loaded.key_size == key.key_size
|
||||
|
||||
|
||||
def test_adapter_account_key_missing(store_adapter):
|
||||
assert store_adapter.load_account_key() is None
|
||||
@@ -0,0 +1,124 @@
|
||||
# turnstone.toml — shared bootstrap configuration
|
||||
#
|
||||
# This file is read once at startup. Values here are overridden by
|
||||
# environment variables, which are in turn overridden by CLI flags.
|
||||
#
|
||||
# All sections are optional. Missing sections use binary defaults.
|
||||
# Config file location precedence:
|
||||
# 1. --config flag
|
||||
# 2. $TURNSTONE_CONFIG env var
|
||||
# 3. ~/.config/turnstone/config.toml
|
||||
|
||||
# --- LLM API (turnstone, node, eval) ---
|
||||
|
||||
[api]
|
||||
# base_url = "" # API endpoint; empty = binary default
|
||||
# api_key = "" # env: OPENAI_API_KEY or ANTHROPIC_API_KEY
|
||||
|
||||
# --- Default Model (turnstone, node, eval) ---
|
||||
|
||||
[model]
|
||||
# name = "" # Model ID; empty = provider default (gpt-5 / claude-sonnet-4)
|
||||
# temperature = 0.0 # 0 = provider default
|
||||
# reasoning_effort = "" # "low", "medium", "high", "max"
|
||||
# context_window = 0 # 0 = auto-detect from provider capabilities
|
||||
# max_tokens = 0 # 0 = provider default
|
||||
|
||||
# --- Named Models (turnstone, node, eval) ---
|
||||
# Define model aliases with per-model overrides. Useful for local model
|
||||
# servers or mixing providers. Reference by name with --model flag.
|
||||
#
|
||||
# [models.local]
|
||||
# name = "llama-3-70b"
|
||||
# provider = "openai"
|
||||
# base_url = "http://localhost:8000/v1"
|
||||
# context_window = 8192
|
||||
#
|
||||
# [models.local.capabilities]
|
||||
# supports_vision = false
|
||||
# supports_web_search = false
|
||||
#
|
||||
# [models.claude]
|
||||
# name = "claude-opus-4-6"
|
||||
# provider = "anthropic"
|
||||
|
||||
# --- Database (turnstone, node, console) ---
|
||||
|
||||
[database]
|
||||
# url = "" # postgres://user:pass@host/db or /path/to.db
|
||||
# env: TURNSTONE_DB_URL
|
||||
# SSL params (passed through to SQLAlchemy connection):
|
||||
# sslmode = "prefer" # disable, allow, prefer, require, verify-ca, verify-full
|
||||
# sslrootcert = "" # path to CA cert for verify-ca/verify-full
|
||||
# sslcert = "" # path to client cert (mTLS)
|
||||
# sslkey = "" # path to client key (mTLS)
|
||||
|
||||
# --- Redis (bridge, console, channel) ---
|
||||
|
||||
[redis]
|
||||
# url = "" # redis://host:6379/0 or rediss://host:6380/0
|
||||
# env: TURNSTONE_REDIS_URL
|
||||
# TLS params (passed through to Redis connection):
|
||||
# tls = false # enable TLS (also auto-enabled by rediss:// scheme)
|
||||
# tls_ca = "" # path to CA cert
|
||||
# tls_cert = "" # path to client cert (mTLS)
|
||||
# tls_key = "" # path to client key (mTLS)
|
||||
|
||||
# --- Auth (node, console) ---
|
||||
|
||||
[auth]
|
||||
# enabled = true # env: TURNSTONE_AUTH_ENABLED
|
||||
# jwt_secret = "" # HS256 signing secret (min 32 bytes recommended)
|
||||
# env: TURNSTONE_JWT_SECRET
|
||||
# token = "" # Static config token for full access
|
||||
# env: TURNSTONE_AUTH_TOKEN
|
||||
|
||||
# --- Logging (turnstone, node, console) ---
|
||||
|
||||
[log]
|
||||
# level = "" # "debug", "info", "warn", "error"
|
||||
# empty = binary default (warn for CLI, info for servers)
|
||||
# env: TURNSTONE_LOG_LEVEL
|
||||
# json = false # JSON output; auto-enabled when stderr is not a TTY
|
||||
|
||||
# --- Session (turnstone, node) ---
|
||||
|
||||
[session]
|
||||
# instructions = "" # Default system message
|
||||
# compact_max_tokens = 32768 # Max tokens for context compaction summary
|
||||
# auto_compact_pct = 0.8 # Trigger compaction at this % of context window
|
||||
|
||||
# --- Tools (turnstone, node) ---
|
||||
|
||||
[tools]
|
||||
# timeout = 120 # Tool execution timeout in seconds
|
||||
# skip_permissions = false # Auto-approve all tool calls
|
||||
|
||||
# --- Judge (turnstone, node) ---
|
||||
|
||||
[judge]
|
||||
# enabled = true # Enable intent validation
|
||||
# confidence_threshold = 0.7 # Minimum confidence for heuristic verdicts
|
||||
# output_guard = true # Scan tool output for security signals
|
||||
# redact_secrets = true # Redact detected credentials in output
|
||||
|
||||
# --- Memory (turnstone, node) ---
|
||||
|
||||
[memory]
|
||||
# relevance_k = 5 # Top-K memories for context injection
|
||||
# fetch_limit = 50 # Max memories to fetch for ranking
|
||||
# max_content = 32768 # Max memory content size in chars
|
||||
# nudge_cooldown = 300 # Min seconds between metacognitive nudges
|
||||
# nudges = true # Enable memory nudges
|
||||
|
||||
# --- MCP (turnstone, node) ---
|
||||
|
||||
[mcp]
|
||||
# config_path = "" # Path to MCP servers config file (JSON)
|
||||
# refresh_interval = 14400 # Refresh interval in seconds (default: 4h)
|
||||
|
||||
# --- Server (node, console) ---
|
||||
|
||||
[server]
|
||||
# max_workstreams = 50 # Maximum concurrent workstreams per node
|
||||
# env: TURNSTONE_MAX_WORKSTREAMS
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "0.8.8"
|
||||
__version__ = "0.8.9"
|
||||
|
||||
@@ -142,6 +142,189 @@ def _cmd_revoke_token(args: argparse.Namespace) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLS commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cmd_tls_bootstrap(args: argparse.Namespace) -> None:
|
||||
"""Initialize CA and issue certs offline."""
|
||||
try:
|
||||
from lacme import CertificateAuthority, FileStore
|
||||
except ImportError:
|
||||
print("lacme not installed. Run: pip install turnstone[tls]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
out_dir = Path(args.out)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
with contextlib.suppress(PermissionError):
|
||||
os.chmod(out_dir, 0o700) # Restrict access — contains CA private key
|
||||
|
||||
store = FileStore(str(out_dir))
|
||||
ca = CertificateAuthority(store, name="turnstone")
|
||||
ca.init(cn="Turnstone CA", validity_days=3650)
|
||||
print(f"CA initialized in {out_dir} (permissions: 0700)")
|
||||
|
||||
# Write CA cert to a well-known location
|
||||
ca_cert_path = out_dir / "ca.pem"
|
||||
ca_cert_path.write_bytes(ca.root_cert_pem)
|
||||
with contextlib.suppress(PermissionError):
|
||||
os.chmod(ca_cert_path, 0o644)
|
||||
print(f"CA cert: {ca_cert_path}")
|
||||
|
||||
# Issue certs for requested domains
|
||||
for domain in args.issue:
|
||||
bundle = ca.issue([domain], validity_hours=48)
|
||||
store.save_cert(bundle)
|
||||
cert_dir = out_dir / "certs" / domain
|
||||
print(f"Issued: {domain} -> {cert_dir}")
|
||||
|
||||
print(f"\nBootstrap complete. {len(args.issue)} cert(s) issued.")
|
||||
print(f"CA and certs written to: {out_dir}")
|
||||
|
||||
|
||||
def _cmd_tls_issue(args: argparse.Namespace) -> None:
|
||||
"""Request a cert from the console's ACME endpoint."""
|
||||
try:
|
||||
from lacme import SyncClient
|
||||
except ImportError:
|
||||
print("lacme not installed. Run: pip install turnstone[tls]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
console_url = args.console_url
|
||||
if not console_url:
|
||||
console_url = _discover_console_url()
|
||||
|
||||
domains = [args.domain] + args.san
|
||||
directory_url = f"{console_url}/acme/directory"
|
||||
print(f"Requesting cert for {domains} from {directory_url}")
|
||||
|
||||
client = SyncClient(
|
||||
directory_url=directory_url,
|
||||
allow_insecure=True,
|
||||
)
|
||||
bundle = client.issue(domains)
|
||||
|
||||
out_dir = Path(args.out)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / "cert.pem").write_bytes(bundle.cert_pem)
|
||||
(out_dir / "fullchain.pem").write_bytes(bundle.fullchain_pem)
|
||||
(out_dir / "key.pem").write_bytes(bundle.key_pem)
|
||||
os.chmod(out_dir / "key.pem", 0o600)
|
||||
|
||||
print(f"Certificate written to {out_dir}/")
|
||||
print(" cert.pem (leaf certificate)")
|
||||
print(" fullchain.pem (cert + chain)")
|
||||
print(" key.pem (private key, 0600)")
|
||||
|
||||
|
||||
def _cmd_tls_ca_cert(args: argparse.Namespace) -> None:
|
||||
"""Download the CA root certificate from the console."""
|
||||
import httpx
|
||||
|
||||
console_url = args.console_url
|
||||
if not console_url:
|
||||
console_url = _discover_console_url()
|
||||
|
||||
# Use plain HTTP for bootstrap (node may not have CA cert yet)
|
||||
# WARNING: This is trust-on-first-use (TOFU) — verify the fingerprint
|
||||
base = console_url.replace("https://", "http://")
|
||||
url = f"{base}/acme/ca.pem"
|
||||
print(f"Fetching CA cert from {url}")
|
||||
print("WARNING: Fetching over plain HTTP — verify the fingerprint below")
|
||||
|
||||
resp = httpx.get(url)
|
||||
resp.raise_for_status()
|
||||
|
||||
# Show fingerprint for out-of-band verification
|
||||
import hashlib
|
||||
|
||||
fingerprint = hashlib.sha256(resp.content).hexdigest()
|
||||
print(f"CA cert SHA-256: {fingerprint}")
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
Path(args.out).write_bytes(resp.content)
|
||||
print(f"CA cert written to {args.out}")
|
||||
|
||||
|
||||
def _cmd_tls_list(args: argparse.Namespace) -> None:
|
||||
"""List certificates from the console."""
|
||||
import httpx
|
||||
|
||||
console_url = args.console_url
|
||||
if not console_url:
|
||||
console_url = _discover_console_url()
|
||||
|
||||
url = f"{console_url}/v1/api/admin/tls/certs"
|
||||
headers = {}
|
||||
token = getattr(args, "auth_token", "") or _get_config_token()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
resp = httpx.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
certs = data.get("certs", [])
|
||||
if not certs:
|
||||
print("No certificates issued.")
|
||||
return
|
||||
|
||||
print(f"{'DOMAIN':<30s} {'ISSUED':<22s} {'EXPIRES':<22s}")
|
||||
print("-" * 74)
|
||||
for c in certs:
|
||||
print(f"{c['domain']:<30s} {c['issued_at']:<22s} {c['expires_at']:<22s}")
|
||||
|
||||
|
||||
def _get_config_token() -> str:
|
||||
"""Try to load auth token from config.toml or environment."""
|
||||
token = os.environ.get("TURNSTONE_AUTH_TOKEN", "")
|
||||
if token:
|
||||
return token
|
||||
try:
|
||||
from turnstone.core.config import load_config
|
||||
|
||||
cfg = load_config("auth")
|
||||
return str(cfg.get("token", ""))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _discover_console_url() -> str:
|
||||
"""Discover console URL from the services table."""
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
try:
|
||||
storage = get_storage()
|
||||
except Exception:
|
||||
print(
|
||||
"No storage configured. Use --console-url or run from a "
|
||||
"directory with a turnstone database.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
consoles = storage.list_services("console", max_age_seconds=3600)
|
||||
if not consoles:
|
||||
print(
|
||||
"No console found in services table. Use --console-url explicitly.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
return consoles[0]["url"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for turnstone-admin CLI."""
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -171,6 +354,35 @@ def main() -> None:
|
||||
p_rt = sub.add_parser("revoke-token", help="Revoke an API token")
|
||||
p_rt.add_argument("--token-id", required=True, help="Token ID to revoke")
|
||||
|
||||
# TLS subcommands
|
||||
p_bootstrap = sub.add_parser(
|
||||
"tls-bootstrap",
|
||||
help="Initialize CA and issue certs offline (no running console needed)",
|
||||
)
|
||||
p_bootstrap.add_argument("--out", required=True, help="Output directory for PEM files")
|
||||
p_bootstrap.add_argument(
|
||||
"--issue",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Domain to issue cert for (repeatable)",
|
||||
)
|
||||
|
||||
p_issue = sub.add_parser("tls-issue", help="Request cert from console ACME")
|
||||
p_issue.add_argument("domain", help="Primary domain for the certificate")
|
||||
p_issue.add_argument("--san", action="append", default=[], help="Additional SAN (repeatable)")
|
||||
p_issue.add_argument("--out", default=".", help="Output directory for PEM files")
|
||||
p_issue.add_argument(
|
||||
"--console-url", default="", help="Console URL (discovered from DB if empty)"
|
||||
)
|
||||
|
||||
p_cacert = sub.add_parser("tls-ca-cert", help="Download CA root certificate")
|
||||
p_cacert.add_argument("--out", default="ca.pem", help="Output file path")
|
||||
p_cacert.add_argument("--console-url", default="", help="Console URL")
|
||||
|
||||
p_tlslist = sub.add_parser("tls-list", help="List issued certificates")
|
||||
p_tlslist.add_argument("--console-url", default="", help="Console URL")
|
||||
p_tlslist.add_argument("--auth-token", default="", help="Auth token for admin API")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
@@ -182,5 +394,9 @@ def main() -> None:
|
||||
"list-users": _cmd_list_users,
|
||||
"list-tokens": _cmd_list_tokens,
|
||||
"revoke-token": _cmd_revoke_token,
|
||||
"tls-bootstrap": _cmd_tls_bootstrap,
|
||||
"tls-issue": _cmd_tls_issue,
|
||||
"tls-ca-cert": _cmd_tls_ca_cert,
|
||||
"tls-list": _cmd_tls_list,
|
||||
}
|
||||
dispatch[args.command](args)
|
||||
|
||||
@@ -843,6 +843,39 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
error_codes=[400],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Admin: TLS / ACME ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/tls/ca",
|
||||
"GET",
|
||||
"CA status: initialization state, CN, cert count, cert inventory",
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/tls/ca.pem",
|
||||
"GET",
|
||||
"Download CA root certificate (PEM format)",
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/tls/certs",
|
||||
"GET",
|
||||
"List all issued TLS certificates",
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/tls/certs/{domain}/renew",
|
||||
"POST",
|
||||
"Force-renew a certificate by domain",
|
||||
error_codes=[404, 500],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/tls/certs/{domain}",
|
||||
"DELETE",
|
||||
"Delete a certificate by domain",
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Observability ---
|
||||
EndpointSpec(
|
||||
"/health",
|
||||
|
||||
@@ -58,6 +58,11 @@ def main() -> None:
|
||||
help="HTTP server port (default: $TURNSTONE_CHANNEL_PORT or 8091)",
|
||||
)
|
||||
|
||||
# -- TLS -----------------------------------------------------------------
|
||||
parser.add_argument("--ssl-certfile", default=None, help="SSL certificate file for HTTPS")
|
||||
parser.add_argument("--ssl-keyfile", default=None, help="SSL private key file")
|
||||
parser.add_argument("--ssl-ca-certs", default=None, help="SSL CA certs for client verification")
|
||||
|
||||
# -- Auth ----------------------------------------------------------------
|
||||
parser.add_argument(
|
||||
"--auth-token",
|
||||
@@ -189,7 +194,8 @@ def main() -> None:
|
||||
advertise_host = socket.gethostname()
|
||||
else:
|
||||
advertise_host = args.http_host
|
||||
advertise_url = f"http://{advertise_host}:{args.http_port}"
|
||||
scheme = "https" if args.ssl_certfile else "http"
|
||||
advertise_url = f"{scheme}://{advertise_host}:{args.http_port}"
|
||||
service_url = advertise_url
|
||||
|
||||
# Register in service registry
|
||||
@@ -209,11 +215,25 @@ def main() -> None:
|
||||
except Exception:
|
||||
log.exception("channel.heartbeat_failed")
|
||||
|
||||
# TLS: use cert files if available (from bootstrap or TLSClient)
|
||||
ssl_certfile = getattr(args, "ssl_certfile", None)
|
||||
ssl_keyfile = getattr(args, "ssl_keyfile", None)
|
||||
ssl_ca_certs = getattr(args, "ssl_ca_certs", None)
|
||||
if bool(ssl_certfile) != bool(ssl_keyfile):
|
||||
print(
|
||||
"Both --ssl-certfile and --ssl-keyfile are required for TLS",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
uv_config = uvicorn.Config(
|
||||
channel_app,
|
||||
host=args.http_host,
|
||||
port=args.http_port,
|
||||
log_level="warning",
|
||||
ssl_certfile=ssl_certfile,
|
||||
ssl_keyfile=ssl_keyfile,
|
||||
ssl_ca_certs=ssl_ca_certs,
|
||||
)
|
||||
server = uvicorn.Server(uv_config)
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ class ClusterCollector:
|
||||
http_timeout: float = 30.0,
|
||||
auth_token: str = "",
|
||||
token_manager: ServiceTokenManager | None = None,
|
||||
tls_verify: Any = True,
|
||||
tls_cert: tuple[str, str] | None = None,
|
||||
):
|
||||
self._broker = broker
|
||||
self._prefix = prefix
|
||||
@@ -86,12 +88,33 @@ class ClusterCollector:
|
||||
max_connections=max_poll_workers + 10,
|
||||
max_keepalive_connections=min(max_poll_workers, 200),
|
||||
),
|
||||
verify=tls_verify,
|
||||
cert=tls_cert,
|
||||
)
|
||||
|
||||
# SSE fan-out to browser clients
|
||||
self._listeners: list[queue.Queue[dict[str, Any]]] = []
|
||||
self._listeners_lock = threading.Lock()
|
||||
|
||||
def upgrade_tls(self, tls_verify: Any = True, tls_cert: tuple[str, str] | None = None) -> None:
|
||||
"""Replace the httpx client with one using mTLS context."""
|
||||
old = self._http_client
|
||||
self._http_client = httpx.Client(
|
||||
timeout=httpx.Timeout(
|
||||
connect=10, read=self._http_timeout, write=5, pool=self._http_timeout
|
||||
),
|
||||
limits=httpx.Limits(
|
||||
max_connections=self._max_poll_workers + 10,
|
||||
max_keepalive_connections=min(self._max_poll_workers, 200),
|
||||
),
|
||||
verify=tls_verify,
|
||||
cert=tls_cert,
|
||||
)
|
||||
# Don't close old client — concurrent _fetch_node() threads may still
|
||||
# be using it. It will be GC'd once all references are released, and
|
||||
# the current client is closed in stop().
|
||||
del old
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
|
||||
+314
-2
@@ -727,18 +727,25 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
config_store.get("cluster.node_fan_out_limit") if config_store else _NODE_FAN_OUT_LIMIT
|
||||
)
|
||||
app.state.fan_out_limit = fan_out
|
||||
# Build mTLS context for proxy clients if TLS is enabled
|
||||
_tls_mgr = getattr(app.state, "tls_manager", None)
|
||||
_proxy_ssl = _tls_mgr.get_client_ssl_context() if _tls_mgr and _tls_mgr.ca_initialized else None
|
||||
_proxy_verify: Any = _proxy_ssl if _proxy_ssl else True
|
||||
|
||||
app.state.proxy_client = httpx.AsyncClient(
|
||||
timeout=30,
|
||||
limits=httpx.Limits(
|
||||
max_connections=fan_out + 50,
|
||||
max_keepalive_connections=min(fan_out // 4, 100),
|
||||
),
|
||||
verify=_proxy_verify,
|
||||
)
|
||||
app.state.proxy_sse_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=5, read=30, write=5, pool=5),
|
||||
limits=httpx.Limits(
|
||||
max_connections=1100, max_keepalive_connections=100, keepalive_expiry=30
|
||||
),
|
||||
verify=_proxy_verify,
|
||||
)
|
||||
# Start scheduler if configured
|
||||
scheduler = getattr(app.state, "scheduler", None)
|
||||
@@ -769,8 +776,84 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
"OIDC JWKS prefetch failed — will retry on first login",
|
||||
exc_info=True,
|
||||
)
|
||||
# Register console in service registry so other services can discover it
|
||||
console_url = getattr(app.state, "console_url", "")
|
||||
_console_heartbeat_task: Any = None
|
||||
if console_url and storage:
|
||||
try:
|
||||
storage.register_service("console", "console", console_url)
|
||||
|
||||
# Periodic heartbeat to keep the registration alive
|
||||
import asyncio
|
||||
|
||||
async def _console_heartbeat() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
try:
|
||||
storage.heartbeat_service("console", "console")
|
||||
except Exception:
|
||||
log.warning("console.heartbeat_failed", exc_info=True)
|
||||
|
||||
_console_heartbeat_task = asyncio.create_task(_console_heartbeat())
|
||||
except Exception:
|
||||
log.warning("Failed to register console service", exc_info=True)
|
||||
|
||||
# TLS: init CA, issue console certs, start renewal
|
||||
tls_mgr = getattr(app.state, "tls_manager", None)
|
||||
if tls_mgr is not None:
|
||||
import socket
|
||||
|
||||
try:
|
||||
if not tls_mgr.ca_initialized:
|
||||
await tls_mgr.init_ca()
|
||||
hostname = socket.getfqdn()
|
||||
cert_hostnames = [hostname, "localhost", "127.0.0.1"]
|
||||
extra_sans = os.environ.get("TURNSTONE_TLS_SANS", "")
|
||||
if extra_sans:
|
||||
cert_hostnames.extend(s.strip() for s in extra_sans.split(",") if s.strip())
|
||||
await tls_mgr.issue_console_certs(cert_hostnames)
|
||||
await tls_mgr.start_renewal()
|
||||
# Re-create proxy clients with mTLS context now that certs are ready
|
||||
client_ctx = tls_mgr.get_client_ssl_context()
|
||||
if client_ctx:
|
||||
await app.state.proxy_client.aclose()
|
||||
await app.state.proxy_sse_client.aclose()
|
||||
app.state.proxy_client = httpx.AsyncClient(
|
||||
timeout=30,
|
||||
limits=httpx.Limits(
|
||||
max_connections=app.state.fan_out_limit + 50,
|
||||
max_keepalive_connections=min(app.state.fan_out_limit // 4, 100),
|
||||
),
|
||||
verify=client_ctx,
|
||||
)
|
||||
app.state.proxy_sse_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=5, read=30, write=5, pool=5),
|
||||
limits=httpx.Limits(
|
||||
max_connections=1100,
|
||||
max_keepalive_connections=100,
|
||||
keepalive_expiry=30,
|
||||
),
|
||||
verify=client_ctx,
|
||||
)
|
||||
# Upgrade collector httpx client for mTLS node polling
|
||||
app.state.collector.upgrade_tls(tls_verify=client_ctx)
|
||||
log.info("tls.proxy_clients.upgraded")
|
||||
except Exception:
|
||||
log.warning("TLS initialization failed — continuing without TLS", exc_info=True)
|
||||
|
||||
yield
|
||||
# Shutdown
|
||||
if _console_heartbeat_task is not None:
|
||||
_console_heartbeat_task.cancel()
|
||||
# Deregister console from services table
|
||||
if console_url and storage:
|
||||
try:
|
||||
storage.deregister_service("console", "console")
|
||||
except Exception:
|
||||
log.debug("console.deregister_failed", exc_info=True)
|
||||
tls_mgr = getattr(app.state, "tls_manager", None)
|
||||
if tls_mgr is not None:
|
||||
await tls_mgr.stop_renewal()
|
||||
if scheduler is not None:
|
||||
scheduler.stop()
|
||||
await app.state.proxy_sse_client.aclose()
|
||||
@@ -4580,6 +4663,161 @@ async def admin_import_mcp_config(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"imported": imported, "skipped": skipped, "errors": errors})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TLS endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def tls_ca_cert(request: Request) -> Response:
|
||||
"""GET /v1/api/admin/tls/ca.pem — Download CA root certificate."""
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
err = require_permission(request, "admin.settings")
|
||||
if err:
|
||||
return err
|
||||
mgr = getattr(request.app.state, "tls_manager", None)
|
||||
if mgr is None or not mgr.ca_initialized:
|
||||
return JSONResponse({"error": "TLS not enabled"}, status_code=404)
|
||||
return Response(
|
||||
content=mgr.get_root_cert_pem(),
|
||||
media_type="application/x-pem-file",
|
||||
headers={"Content-Disposition": "attachment; filename=turnstone-ca.pem"},
|
||||
)
|
||||
|
||||
|
||||
async def tls_ca_status(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/tls/ca — CA status."""
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
err = require_permission(request, "admin.settings")
|
||||
if err:
|
||||
return err
|
||||
mgr = getattr(request.app.state, "tls_manager", None)
|
||||
if mgr is None or not mgr.ca_initialized:
|
||||
return JSONResponse({"enabled": False})
|
||||
from turnstone.console.tls import _CA_CN
|
||||
|
||||
certs = mgr.list_certs()
|
||||
return JSONResponse(
|
||||
{
|
||||
"enabled": True,
|
||||
"ca_cn": _CA_CN,
|
||||
"cert_count": len(certs),
|
||||
"certs": [
|
||||
{
|
||||
"domain": c.domain,
|
||||
"issued_at": c.issued_at.isoformat(),
|
||||
"expires_at": c.expires_at.isoformat(),
|
||||
}
|
||||
for c in certs
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def tls_list_certs(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/tls/certs — List issued certificates."""
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
err = require_permission(request, "admin.settings")
|
||||
if err:
|
||||
return err
|
||||
mgr = getattr(request.app.state, "tls_manager", None)
|
||||
if mgr is None or not mgr.ca_initialized:
|
||||
return JSONResponse({"certs": []})
|
||||
certs = mgr.list_certs()
|
||||
return JSONResponse(
|
||||
{
|
||||
"certs": [
|
||||
{
|
||||
"domain": c.domain,
|
||||
"domains": list(c.domains),
|
||||
"issued_at": c.issued_at.isoformat(),
|
||||
"expires_at": c.expires_at.isoformat(),
|
||||
}
|
||||
for c in certs
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def tls_renew_cert(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/admin/tls/certs/{domain}/renew — Force cert renewal."""
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
err = require_permission(request, "admin.settings")
|
||||
if err:
|
||||
return err
|
||||
mgr = getattr(request.app.state, "tls_manager", None)
|
||||
if mgr is None or not mgr.ca_initialized:
|
||||
return JSONResponse({"error": "TLS not enabled"}, status_code=404)
|
||||
domain = request.path_params["domain"]
|
||||
try:
|
||||
bundle = mgr.renew_cert(domain)
|
||||
return JSONResponse(
|
||||
{
|
||||
"domain": bundle.domain,
|
||||
"issued_at": bundle.issued_at.isoformat(),
|
||||
"expires_at": bundle.expires_at.isoformat(),
|
||||
},
|
||||
)
|
||||
except ValueError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=404)
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
async def tls_delete_cert(request: Request) -> JSONResponse:
|
||||
"""DELETE /v1/api/admin/tls/certs/{domain} — Delete a certificate."""
|
||||
from turnstone.core.auth import require_permission
|
||||
|
||||
err = require_permission(request, "admin.settings")
|
||||
if err:
|
||||
return err
|
||||
mgr = getattr(request.app.state, "tls_manager", None)
|
||||
if mgr is None or not mgr.ca_initialized:
|
||||
return JSONResponse({"error": "TLS not enabled"}, status_code=404)
|
||||
domain = request.path_params["domain"]
|
||||
if not mgr.delete_cert(domain):
|
||||
return JSONResponse({"error": f"No cert for {domain}"}, status_code=404)
|
||||
return JSONResponse({"deleted": domain})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConfigStore env seeding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_config_from_env(config_store: Any, storage: Any) -> None:
|
||||
"""Seed ConfigStore settings from environment variables.
|
||||
|
||||
Checks for ``TURNSTONE_{SECTION}_{KEY}`` env vars and writes them
|
||||
to ConfigStore if they aren't already set. This allows container
|
||||
deployments to configure settings before the admin UI is available.
|
||||
|
||||
Only seeds known settings from the registry to avoid storing garbage.
|
||||
Uses config_store.set() for proper validation, serialization, and
|
||||
cache invalidation.
|
||||
"""
|
||||
from turnstone.core.settings_registry import SETTINGS
|
||||
|
||||
for key in SETTINGS:
|
||||
env_name = "TURNSTONE_" + key.replace(".", "_").upper()
|
||||
env_val = os.environ.get(env_name)
|
||||
if env_val is None:
|
||||
continue
|
||||
# Only seed if not already stored (check raw storage to avoid
|
||||
# config_store cache, which may not reflect DB state yet)
|
||||
existing = storage.get_system_setting(key)
|
||||
if existing is not None:
|
||||
continue
|
||||
try:
|
||||
config_store.set(key, env_val, changed_by="env")
|
||||
log.info("config.seeded_from_env: %s from %s", key, env_name)
|
||||
except Exception:
|
||||
log.warning("config.seed_failed: %s from %s", key, env_name, exc_info=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -4595,6 +4833,8 @@ def create_app(
|
||||
proxy_auth_token: str = "",
|
||||
proxy_token_mgr: Any = None,
|
||||
cors_origins: list[str] | None = None,
|
||||
tls_manager: Any = None,
|
||||
console_url: str = "",
|
||||
) -> Starlette:
|
||||
"""Build the Starlette ASGI application for the console dashboard."""
|
||||
_spec = build_console_spec()
|
||||
@@ -4817,6 +5057,20 @@ def create_app(
|
||||
admin_rescan_skill,
|
||||
methods=["POST"],
|
||||
),
|
||||
# TLS / ACME
|
||||
Route("/api/admin/tls/ca", tls_ca_status),
|
||||
Route("/api/admin/tls/ca.pem", tls_ca_cert),
|
||||
Route("/api/admin/tls/certs", tls_list_certs),
|
||||
Route(
|
||||
"/api/admin/tls/certs/{domain}/renew",
|
||||
tls_renew_cert,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/tls/certs/{domain}",
|
||||
tls_delete_cert,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
],
|
||||
),
|
||||
Route("/health", health),
|
||||
@@ -4842,6 +5096,15 @@ def create_app(
|
||||
app.state.auth_storage = auth_storage
|
||||
app.state.proxy_auth_token = proxy_auth_token
|
||||
app.state.proxy_token_mgr = proxy_token_mgr
|
||||
app.state.console_url = console_url
|
||||
app.state.tls_manager = tls_manager
|
||||
|
||||
# Mount ACME responder whenever a TLS manager is configured.
|
||||
# ACMEResponder (lacme 1.0.2+) serves /ca.pem natively.
|
||||
if tls_manager is not None:
|
||||
from starlette.routing import Mount as RouteMount
|
||||
|
||||
app.routes.insert(0, RouteMount("/acme", app=tls_manager.get_responder()))
|
||||
|
||||
from turnstone.core.auth import LoginRateLimiter
|
||||
|
||||
@@ -4989,7 +5252,15 @@ def main() -> None:
|
||||
db_backend = os.environ.get("TURNSTONE_DB_BACKEND", "sqlite")
|
||||
db_url = os.environ.get("TURNSTONE_DB_URL", "")
|
||||
db_path = os.environ.get("TURNSTONE_DB_PATH", "")
|
||||
auth_storage = init_storage(db_backend, path=db_path, url=db_url)
|
||||
auth_storage = init_storage(
|
||||
db_backend,
|
||||
path=db_path,
|
||||
url=db_url,
|
||||
sslmode=os.environ.get("TURNSTONE_DB_SSLMODE", ""),
|
||||
sslrootcert=os.environ.get("TURNSTONE_DB_SSLROOTCERT", ""),
|
||||
sslcert=os.environ.get("TURNSTONE_DB_SSLCERT", ""),
|
||||
sslkey=os.environ.get("TURNSTONE_DB_SSLKEY", ""),
|
||||
)
|
||||
except Exception:
|
||||
log.info("Console storage not available — admin API disabled, JWT-only auth")
|
||||
|
||||
@@ -5014,6 +5285,45 @@ def main() -> None:
|
||||
|
||||
cors_origins = parse_cors_origins()
|
||||
|
||||
# TLS: initialize manager if enabled
|
||||
tls_mgr = None
|
||||
# Console URL for service registration — other services use this to discover the console.
|
||||
# Precedence: TURNSTONE_CONSOLE_URL env > auto-detect from bind address.
|
||||
# In Docker Compose, set TURNSTONE_CONSOLE_URL to the service name (e.g. http://console:8090).
|
||||
import socket as _socket
|
||||
|
||||
_console_url_env = os.environ.get("TURNSTONE_CONSOLE_URL", "")
|
||||
if _console_url_env:
|
||||
console_url = _console_url_env
|
||||
else:
|
||||
_advertise_host = args.host
|
||||
if _advertise_host in ("0.0.0.0", "::", ""):
|
||||
_advertise_host = _socket.getfqdn()
|
||||
console_url = f"http://{_advertise_host}:{args.port}"
|
||||
if auth_storage:
|
||||
try:
|
||||
from turnstone.core.config_store import ConfigStore
|
||||
|
||||
_cs = ConfigStore(auth_storage)
|
||||
# Seed ConfigStore from env vars (TURNSTONE_{SECTION}_{KEY})
|
||||
_seed_config_from_env(_cs, auth_storage)
|
||||
if _cs.get("tls.enabled"):
|
||||
from turnstone.console.tls import TLSManager
|
||||
|
||||
tls_mgr = TLSManager(auth_storage, config_store=_cs)
|
||||
# Init CA before create_app so ACME responder can be mounted
|
||||
import asyncio
|
||||
|
||||
asyncio.run(tls_mgr.init_ca())
|
||||
# Upgrade scheme to https if no explicit URL was provided
|
||||
if not _console_url_env:
|
||||
console_url = console_url.replace("http://", "https://")
|
||||
log.info("TLS enabled")
|
||||
except ImportError:
|
||||
log.warning("TLS enabled but lacme not installed — pip install turnstone[tls]")
|
||||
except Exception:
|
||||
log.warning("TLS initialization failed", exc_info=True)
|
||||
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
broker=broker,
|
||||
@@ -5023,9 +5333,11 @@ def main() -> None:
|
||||
proxy_auth_token=proxy_token if proxy_token_mgr is None else "",
|
||||
proxy_token_mgr=proxy_token_mgr,
|
||||
cors_origins=cors_origins,
|
||||
tls_manager=tls_mgr,
|
||||
console_url=console_url,
|
||||
)
|
||||
|
||||
log.info("Console starting on http://%s:%s", args.host, args.port)
|
||||
log.info("Console starting on %s", console_url)
|
||||
if auth_config.enabled:
|
||||
log.info("Auth: enabled (%d config token(s))", len(auth_config.tokens))
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
@@ -64,6 +64,7 @@ function showAdmin() {
|
||||
audit: "admin.audit",
|
||||
memories: "admin.memories",
|
||||
settings: "admin.settings",
|
||||
tls: "admin.settings",
|
||||
mcp: "admin.mcp",
|
||||
};
|
||||
if (perms) {
|
||||
@@ -193,6 +194,7 @@ function switchAdminTab(tab) {
|
||||
"audit",
|
||||
"memories",
|
||||
"settings",
|
||||
"tls",
|
||||
"mcp",
|
||||
];
|
||||
for (var p = 0; p < panels.length; p++) {
|
||||
@@ -215,6 +217,7 @@ function switchAdminTab(tab) {
|
||||
}
|
||||
if (tab === "memories") loadAdminMemories();
|
||||
if (tab === "settings") loadSettings();
|
||||
if (tab === "tls") loadTlsCerts();
|
||||
if (tab === "mcp") loadAdminMcp();
|
||||
|
||||
// Update breadcrumb with active tab label
|
||||
@@ -2083,6 +2086,180 @@ function _settingsSectionLabel(section) {
|
||||
return labels[section] || section;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TLS tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadTlsCerts() {
|
||||
var statusEl = document.getElementById("tls-ca-status");
|
||||
var listEl = document.getElementById("tls-cert-list");
|
||||
if (!statusEl || !listEl) return;
|
||||
|
||||
// Fetch CA status and cert list in parallel
|
||||
Promise.all([
|
||||
authFetch("/v1/api/admin/tls/ca").then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed");
|
||||
return r.json();
|
||||
}),
|
||||
authFetch("/v1/api/admin/tls/certs").then(function (r) {
|
||||
if (!r.ok) return { certs: [] };
|
||||
return r.json();
|
||||
}),
|
||||
])
|
||||
.then(function (results) {
|
||||
var data = results[0];
|
||||
var certData = results[1];
|
||||
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
|
||||
while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
|
||||
|
||||
if (!data.enabled) {
|
||||
var msg = document.createElement("div");
|
||||
msg.className = "dashboard-empty";
|
||||
msg.textContent =
|
||||
"TLS is not enabled. Set tls.enabled = true in Settings.";
|
||||
statusEl.appendChild(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// CA status bar
|
||||
var bar = document.createElement("div");
|
||||
bar.className = "tls-ca-bar";
|
||||
var caLabel = document.createElement("span");
|
||||
caLabel.textContent = "CA: " + data.ca_cn;
|
||||
var countLabel = document.createElement("span");
|
||||
countLabel.textContent = "Certificates: " + data.cert_count;
|
||||
bar.appendChild(caLabel);
|
||||
bar.appendChild(countLabel);
|
||||
statusEl.appendChild(bar);
|
||||
|
||||
var certs = certData.certs || [];
|
||||
if (certs.length === 0) {
|
||||
var empty = document.createElement("div");
|
||||
empty.className = "dashboard-empty";
|
||||
empty.textContent = "No certificates issued yet.";
|
||||
listEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cert rows
|
||||
certs.forEach(function (c) {
|
||||
var row = document.createElement("div");
|
||||
row.className = "admin-row";
|
||||
row.setAttribute("role", "listitem");
|
||||
|
||||
var colDomain = document.createElement("span");
|
||||
colDomain.className = "admin-col";
|
||||
colDomain.textContent = c.domain;
|
||||
|
||||
var colSans = document.createElement("span");
|
||||
colSans.className = "admin-col";
|
||||
colSans.textContent = (c.domains || [c.domain]).join(", ");
|
||||
|
||||
var colIssued = document.createElement("span");
|
||||
colIssued.className = "admin-col";
|
||||
colIssued.textContent = (c.issued_at || "")
|
||||
.slice(0, 16)
|
||||
.replace("T", " ");
|
||||
|
||||
var colExpires = document.createElement("span");
|
||||
colExpires.className = "admin-col";
|
||||
var expires = new Date(c.expires_at);
|
||||
var isExpired = expires < new Date();
|
||||
colExpires.textContent =
|
||||
(isExpired ? "EXPIRED " : "") +
|
||||
(c.expires_at || "").slice(0, 16).replace("T", " ");
|
||||
if (isExpired) colExpires.style.color = "var(--red)";
|
||||
|
||||
var colActions = document.createElement("span");
|
||||
colActions.className = "admin-col admin-col-actions";
|
||||
var renewBtn = document.createElement("button");
|
||||
renewBtn.className = "admin-btn-action";
|
||||
renewBtn.textContent = "Renew";
|
||||
renewBtn.setAttribute(
|
||||
"aria-label",
|
||||
"Renew certificate for " + c.domain,
|
||||
);
|
||||
renewBtn.onclick = function () {
|
||||
tlsRenewCert(c.domain);
|
||||
};
|
||||
var deleteBtn = document.createElement("button");
|
||||
deleteBtn.className = "admin-btn-danger";
|
||||
deleteBtn.textContent = "Delete";
|
||||
deleteBtn.setAttribute(
|
||||
"aria-label",
|
||||
"Delete certificate for " + c.domain,
|
||||
);
|
||||
deleteBtn.onclick = function () {
|
||||
tlsDeleteCert(c.domain);
|
||||
};
|
||||
colActions.appendChild(renewBtn);
|
||||
colActions.appendChild(deleteBtn);
|
||||
|
||||
row.appendChild(colDomain);
|
||||
row.appendChild(colSans);
|
||||
row.appendChild(colIssued);
|
||||
row.appendChild(colExpires);
|
||||
row.appendChild(colActions);
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
|
||||
while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
|
||||
var errMsg = document.createElement("div");
|
||||
errMsg.className = "dashboard-empty";
|
||||
errMsg.textContent = "Failed to load TLS status";
|
||||
statusEl.appendChild(errMsg);
|
||||
});
|
||||
}
|
||||
|
||||
function tlsRenewCert(domain) {
|
||||
showConfirmModal(
|
||||
"Renew Certificate",
|
||||
"Force renew certificate for \u2018" + domain + "\u2019?",
|
||||
"Renew",
|
||||
function () {
|
||||
authFetch(
|
||||
"/v1/api/admin/tls/certs/" + encodeURIComponent(domain) + "/renew",
|
||||
{ method: "POST" },
|
||||
)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Renew failed");
|
||||
showToast("Certificate renewed for " + domain);
|
||||
loadTlsCerts();
|
||||
})
|
||||
.catch(function () {
|
||||
showToast("Failed to renew certificate", "error");
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function tlsDeleteCert(domain) {
|
||||
showConfirmModal(
|
||||
"Delete Certificate",
|
||||
"Delete certificate for \u2018" + domain + "\u2019? This cannot be undone.",
|
||||
"Delete",
|
||||
function () {
|
||||
authFetch("/v1/api/admin/tls/certs/" + encodeURIComponent(domain), {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Delete failed");
|
||||
showToast("Certificate deleted for " + domain);
|
||||
loadTlsCerts();
|
||||
})
|
||||
.catch(function () {
|
||||
showToast("Failed to delete certificate", "error");
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadSettings() {
|
||||
var el = document.getElementById("admin-settings-content");
|
||||
if (!el) return;
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
<div class="admin-sidebar-group" data-group="system" role="group" aria-label="System">
|
||||
<div class="admin-sidebar-group-label" aria-hidden="true">System</div>
|
||||
<button id="tab-settings" class="admin-nav" data-tab="settings" role="tab" aria-selected="false" aria-controls="admin-settings" tabindex="-1" onclick="switchAdminTab('settings')">Settings</button>
|
||||
<button id="tab-tls" class="admin-nav" data-tab="tls" role="tab" aria-selected="false" aria-controls="admin-tls" tabindex="-1" onclick="switchAdminTab('tls')">TLS</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div id="admin-sidebar-backdrop" class="admin-sidebar-backdrop" aria-hidden="true"></div>
|
||||
@@ -404,6 +405,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TLS Tab -->
|
||||
<div id="admin-tls" class="admin-panel" role="tabpanel" aria-labelledby="tab-tls" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header" style="margin:0">TLS / CERTIFICATES</span>
|
||||
</div>
|
||||
<div id="tls-ca-status"></div>
|
||||
<div class="admin-colheaders admin-colheaders-tls" aria-hidden="true">
|
||||
<span class="admin-col">DOMAIN</span>
|
||||
<span class="admin-col">SANS</span>
|
||||
<span class="admin-col">ISSUED</span>
|
||||
<span class="admin-col">EXPIRES</span>
|
||||
<span class="admin-col admin-col-actions">ACTIONS</span>
|
||||
</div>
|
||||
<div id="tls-cert-list" role="list" aria-label="TLS certificates" aria-live="polite">
|
||||
<div class="dashboard-empty">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-mcp" class="admin-panel" role="tabpanel" aria-labelledby="tab-mcp" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<span class="section-header">MCP</span>
|
||||
|
||||
@@ -948,6 +948,19 @@
|
||||
grid-template-columns: 100px 1fr 100px 80px;
|
||||
}
|
||||
|
||||
/* TLS grid: DOMAIN | SANS | ISSUED | EXPIRES | ACTIONS */
|
||||
#admin-tls .admin-colheaders,
|
||||
#admin-tls .admin-row {
|
||||
grid-template-columns: 160px 1fr 130px 150px 120px;
|
||||
}
|
||||
.tls-ca-bar {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 12px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
/* Scope badges */
|
||||
.scope-badge {
|
||||
display: inline-block;
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"""TLS Manager — Certificate Authority and ACME server for the console.
|
||||
|
||||
Owns the lacme CertificateAuthority, ACMEResponder, and RenewalManager
|
||||
lifecycle. When TLS is enabled, the console acts as the cluster's internal
|
||||
CA and ACME server, issuing short-lived mTLS certificates to all services.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ssl
|
||||
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from turnstone.core.config_store import ConfigStore
|
||||
from turnstone.core.storage._protocol import StorageBackend
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
# Hardcoded defaults — no operator config needed
|
||||
_CA_CN = "Turnstone CA"
|
||||
_CA_NAME = "turnstone" # Store key for save_ca/load_ca
|
||||
_CA_VALIDITY_DAYS = 3650 # 10 years
|
||||
_CERT_VALIDITY_HOURS = 48
|
||||
_RENEW_INTERVAL_HOURS = 24
|
||||
_RENEW_BEFORE_EXPIRY_DAYS = 1
|
||||
|
||||
|
||||
def _require_lacme() -> Any:
|
||||
try:
|
||||
import lacme
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"lacme is required for TLS support. Install with: pip install turnstone[tls]",
|
||||
) from None
|
||||
return lacme
|
||||
|
||||
|
||||
class TLSManager:
|
||||
"""Manages the internal CA, ACME responder, and certificate lifecycle.
|
||||
|
||||
Typical usage::
|
||||
|
||||
mgr = TLSManager(storage, config_store)
|
||||
await mgr.init_ca()
|
||||
responder = mgr.get_responder() # Mount at /acme
|
||||
await mgr.issue_console_certs() # Self-issue for this node
|
||||
mgr.start_renewal() # Background auto-renewal
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage: StorageBackend,
|
||||
config_store: ConfigStore | None = None,
|
||||
) -> None:
|
||||
lacme = _require_lacme()
|
||||
|
||||
from turnstone.core.tls_store import StorageStore
|
||||
|
||||
self._store = StorageStore(storage)
|
||||
self._config_store = config_store
|
||||
self._event_dispatcher = lacme.EventDispatcher()
|
||||
self._ca: Any | None = None
|
||||
self._responder: Any | None = None
|
||||
self._renewal_task: Any | None = None
|
||||
self._renewal_manager: Any | None = None
|
||||
self._internal_bundle: Any | None = None
|
||||
self._frontend_bundle: Any | None = None
|
||||
|
||||
# Wire structlog to lacme events
|
||||
self._subscribe_events()
|
||||
|
||||
# Wire Prometheus metrics (if prometheus_client available)
|
||||
try:
|
||||
from lacme.metrics import setup_metrics
|
||||
|
||||
setup_metrics(self._event_dispatcher)
|
||||
except ImportError:
|
||||
pass # prometheus_client not installed
|
||||
|
||||
def _subscribe_events(self) -> None:
|
||||
"""Subscribe structlog handlers to lacme lifecycle events."""
|
||||
_require_lacme()
|
||||
from lacme.events import (
|
||||
CertificateExpiring,
|
||||
CertificateIssued,
|
||||
CertificateRenewed,
|
||||
ChallengeFailed,
|
||||
)
|
||||
|
||||
def _on_issued(event: Any) -> None:
|
||||
if isinstance(event, CertificateIssued):
|
||||
log.info("tls.cert.issued", domain=event.domain)
|
||||
|
||||
def _on_renewed(event: Any) -> None:
|
||||
if isinstance(event, CertificateRenewed):
|
||||
log.info("tls.cert.renewed", domain=event.domain)
|
||||
|
||||
def _on_expiring(event: Any) -> None:
|
||||
if isinstance(event, CertificateExpiring):
|
||||
log.warning("tls.cert.expiring", domain=event.domain)
|
||||
|
||||
def _on_failed(event: Any) -> None:
|
||||
if isinstance(event, ChallengeFailed):
|
||||
log.error("tls.challenge.failed", domain=getattr(event, "domain", "unknown"))
|
||||
|
||||
self._event_dispatcher.subscribe(_on_issued, event_type=CertificateIssued)
|
||||
self._event_dispatcher.subscribe(_on_renewed, event_type=CertificateRenewed)
|
||||
self._event_dispatcher.subscribe(_on_expiring, event_type=CertificateExpiring)
|
||||
self._event_dispatcher.subscribe(_on_failed, event_type=ChallengeFailed)
|
||||
|
||||
# -- CA lifecycle ----------------------------------------------------------
|
||||
|
||||
async def init_ca(self) -> None:
|
||||
"""Initialize the internal Certificate Authority.
|
||||
|
||||
If a bootstrap CA exists on disk (from tls-bootstrap), imports it
|
||||
into the database store first so the console uses the same CA that
|
||||
signed the infrastructure certs.
|
||||
"""
|
||||
lacme = _require_lacme()
|
||||
|
||||
# Import bootstrap CA from well-known volume path if not already in DB
|
||||
self._import_bootstrap_ca()
|
||||
|
||||
self._ca = lacme.CertificateAuthority(
|
||||
self._store,
|
||||
name=_CA_NAME,
|
||||
event_dispatcher=self._event_dispatcher,
|
||||
)
|
||||
self._ca.init(cn=_CA_CN, validity_days=_CA_VALIDITY_DAYS)
|
||||
log.info("tls.ca.initialized", cn=_CA_CN)
|
||||
|
||||
def _import_bootstrap_ca(self) -> None:
|
||||
"""Import a bootstrap CA from /certs into the database store.
|
||||
|
||||
The tls-bootstrap CLI writes the CA to a FileStore at /certs.
|
||||
On first boot, the console imports it so all services share
|
||||
the same trust root.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Check if bootstrap CA exists and DB CA doesn't
|
||||
bootstrap_dir = Path(os.environ.get("TURNSTONE_TLS_BOOTSTRAP_DIR", "/certs"))
|
||||
ca_dir = bootstrap_dir / "ca" / _CA_NAME
|
||||
ca_cert_file = ca_dir / "cert.pem"
|
||||
ca_key_file = ca_dir / "key.pem"
|
||||
|
||||
if not ca_cert_file.exists() or not ca_key_file.exists():
|
||||
return # No bootstrap CA found
|
||||
|
||||
existing = self._store.load_ca(_CA_NAME)
|
||||
if existing is not None:
|
||||
return # Already imported
|
||||
|
||||
cert_pem = ca_cert_file.read_bytes()
|
||||
key_pem = ca_key_file.read_bytes()
|
||||
self._store.save_ca(_CA_NAME, cert_pem, key_pem)
|
||||
log.info("tls.ca.imported_from_bootstrap", path=str(ca_dir))
|
||||
|
||||
def get_responder(self) -> ASGIApp:
|
||||
"""Return the ACME responder ASGI app for mounting."""
|
||||
if self._ca is None:
|
||||
raise RuntimeError("CA not initialized — call init_ca() first")
|
||||
lacme = _require_lacme()
|
||||
if self._responder is None:
|
||||
self._responder = lacme.ACMEResponder(
|
||||
ca=self._ca,
|
||||
auto_approve=True,
|
||||
)
|
||||
return self._responder # type: ignore[no-any-return]
|
||||
|
||||
def get_root_cert_pem(self) -> bytes:
|
||||
"""Return the CA root certificate in PEM format."""
|
||||
if self._ca is None:
|
||||
raise RuntimeError("CA not initialized — call init_ca() first")
|
||||
return self._ca.root_cert_pem # type: ignore[no-any-return]
|
||||
|
||||
# -- Cert issuance ---------------------------------------------------------
|
||||
|
||||
async def issue_console_certs(self, hostnames: list[str]) -> None:
|
||||
"""Issue certificates for the console node.
|
||||
|
||||
Raises ValueError if hostnames is empty.
|
||||
|
||||
Issues two certificates:
|
||||
- Internal cert: always from the internal CA (for mTLS with cluster)
|
||||
- Frontend cert: from external ACME CA if configured, else internal CA
|
||||
"""
|
||||
if not hostnames:
|
||||
raise ValueError("issue_console_certs requires at least one hostname")
|
||||
|
||||
# Internal cert — always from our own CA
|
||||
await self._issue_internal_cert(hostnames)
|
||||
|
||||
# Frontend cert — external CA if configured
|
||||
acme_directory = ""
|
||||
if self._config_store:
|
||||
acme_directory = self._config_store.get("tls.acme_directory") or ""
|
||||
|
||||
if acme_directory:
|
||||
await self._issue_frontend_cert(hostnames, acme_directory)
|
||||
else:
|
||||
# Self-issue from internal CA (behind reverse proxy or internal only)
|
||||
self._frontend_bundle = self._internal_bundle
|
||||
log.info("tls.frontend.self_issued", hostnames=hostnames)
|
||||
|
||||
async def _issue_internal_cert(self, hostnames: list[str]) -> None:
|
||||
"""Issue an internal mTLS cert from the internal CA."""
|
||||
if self._ca is None:
|
||||
raise RuntimeError("CA not initialized")
|
||||
|
||||
# Check for existing cert in store (skip if expired)
|
||||
existing = self._store.load_cert(hostnames[0])
|
||||
if existing is not None:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
if existing.expires_at > datetime.now(UTC):
|
||||
self._internal_bundle = existing
|
||||
log.info("tls.internal.loaded", domain=hostnames[0])
|
||||
return
|
||||
log.info("tls.internal.expired", domain=hostnames[0])
|
||||
self._store.delete_cert(hostnames[0])
|
||||
|
||||
# Issue new cert
|
||||
bundle = self._ca.issue(
|
||||
hostnames,
|
||||
validity_hours=_CERT_VALIDITY_HOURS,
|
||||
)
|
||||
self._store.save_cert(bundle)
|
||||
self._internal_bundle = bundle
|
||||
log.info("tls.internal.issued", domain=hostnames[0])
|
||||
|
||||
async def _issue_frontend_cert(
|
||||
self,
|
||||
hostnames: list[str],
|
||||
acme_directory: str,
|
||||
) -> None:
|
||||
"""Issue a frontend cert from an external ACME CA."""
|
||||
lacme = _require_lacme()
|
||||
from lacme.challenges.http01 import HTTP01Handler
|
||||
|
||||
handler = HTTP01Handler()
|
||||
|
||||
async with lacme.Client(
|
||||
directory_url=acme_directory,
|
||||
store=self._store,
|
||||
challenge_handler=handler,
|
||||
event_dispatcher=self._event_dispatcher,
|
||||
) as client:
|
||||
self._frontend_bundle = await client.issue(hostnames)
|
||||
self._store.save_cert(self._frontend_bundle)
|
||||
log.info(
|
||||
"tls.frontend.issued",
|
||||
domain=hostnames[0],
|
||||
ca=acme_directory,
|
||||
)
|
||||
|
||||
# -- Auto-renewal ----------------------------------------------------------
|
||||
|
||||
async def start_renewal(self) -> None:
|
||||
"""Start background auto-renewal for all stored certificates.
|
||||
|
||||
Uses CA-direct mode (lacme 1.0.2+) — signs directly via the CA
|
||||
without going through ACME. No loopback client, no network,
|
||||
no startup ordering dependency.
|
||||
"""
|
||||
if self._ca is None:
|
||||
raise RuntimeError("CA not initialized")
|
||||
lacme = _require_lacme()
|
||||
|
||||
def _on_renewed(bundle: Any) -> None:
|
||||
# Update our cached bundles if the renewed domain matches
|
||||
if self._internal_bundle and bundle.domain == self._internal_bundle.domain:
|
||||
self._internal_bundle = bundle
|
||||
if self._frontend_bundle and bundle.domain == self._frontend_bundle.domain:
|
||||
self._frontend_bundle = bundle
|
||||
|
||||
self._renewal_manager = lacme.RenewalManager(
|
||||
ca=self._ca,
|
||||
store=self._store,
|
||||
interval_hours=_RENEW_INTERVAL_HOURS,
|
||||
days_before_expiry=_RENEW_BEFORE_EXPIRY_DAYS,
|
||||
on_renewed=_on_renewed,
|
||||
event_dispatcher=self._event_dispatcher,
|
||||
)
|
||||
self._renewal_task = self._renewal_manager.start()
|
||||
log.info(
|
||||
"tls.renewal.started",
|
||||
interval_hours=_RENEW_INTERVAL_HOURS,
|
||||
)
|
||||
|
||||
async def stop_renewal(self) -> None:
|
||||
"""Stop the background renewal task."""
|
||||
if self._renewal_task is not None:
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
self._renewal_task.cancel()
|
||||
try:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._renewal_task
|
||||
except Exception:
|
||||
log.exception("tls.renewal.stop_error")
|
||||
self._renewal_task = None
|
||||
|
||||
# -- SSL contexts ----------------------------------------------------------
|
||||
|
||||
def get_server_ssl_context(self) -> ssl.SSLContext | None:
|
||||
"""Build an SSL context for the uvicorn HTTPS listener.
|
||||
|
||||
Uses the frontend cert (external CA or self-issued).
|
||||
Returns None if no certs are available.
|
||||
"""
|
||||
if self._frontend_bundle is None:
|
||||
return None
|
||||
_require_lacme()
|
||||
from lacme.mtls import server_ssl_context
|
||||
|
||||
return server_ssl_context( # type: ignore[no-any-return]
|
||||
cert_pem=self._frontend_bundle.fullchain_pem,
|
||||
key_pem=self._frontend_bundle.key_pem,
|
||||
ca_cert_pem=self.get_root_cert_pem(),
|
||||
)
|
||||
|
||||
def get_client_ssl_context(self) -> ssl.SSLContext | None:
|
||||
"""Build an mTLS client context for connecting to cluster services.
|
||||
|
||||
Uses the internal cert for mutual authentication.
|
||||
Returns None if no certs are available.
|
||||
"""
|
||||
if self._internal_bundle is None:
|
||||
return None
|
||||
_require_lacme()
|
||||
from lacme.mtls import client_ssl_context
|
||||
|
||||
return client_ssl_context( # type: ignore[no-any-return]
|
||||
cert_pem=self._internal_bundle.cert_pem,
|
||||
key_pem=self._internal_bundle.key_pem,
|
||||
ca_cert_pem=self.get_root_cert_pem(),
|
||||
)
|
||||
|
||||
# -- Properties ------------------------------------------------------------
|
||||
|
||||
def list_certs(self) -> list[Any]:
|
||||
"""List all stored certificate bundles."""
|
||||
return self._store.list_certs()
|
||||
|
||||
def renew_cert(self, domain: str) -> Any:
|
||||
"""Force-renew a certificate by domain. Returns the new bundle."""
|
||||
if self._ca is None:
|
||||
raise RuntimeError("CA not initialized")
|
||||
existing = self._store.load_cert(domain)
|
||||
if existing is None:
|
||||
raise ValueError(f"No certificate for {domain}")
|
||||
# Issue new cert first, then delete old (safe if issuance fails)
|
||||
bundle = self._ca.issue(list(existing.domains))
|
||||
self._store.delete_cert(domain)
|
||||
self._store.save_cert(bundle)
|
||||
# Update in-memory bundles if this is the console's own cert
|
||||
if self._internal_bundle and bundle.domain == self._internal_bundle.domain:
|
||||
self._internal_bundle = bundle
|
||||
if self._frontend_bundle and bundle.domain == self._frontend_bundle.domain:
|
||||
self._frontend_bundle = bundle
|
||||
return bundle
|
||||
|
||||
def delete_cert(self, domain: str) -> bool:
|
||||
"""Delete a certificate by domain."""
|
||||
return self._store.delete_cert(domain)
|
||||
|
||||
@property
|
||||
def ca_initialized(self) -> bool:
|
||||
return self._ca is not None
|
||||
|
||||
@property
|
||||
def internal_bundle(self) -> Any | None:
|
||||
return self._internal_bundle
|
||||
|
||||
@property
|
||||
def frontend_bundle(self) -> Any | None:
|
||||
return self._frontend_bundle
|
||||
@@ -157,7 +157,7 @@ PUBLIC_PATHS: frozenset[str] = frozenset(
|
||||
"/api/auth/oidc/callback",
|
||||
}
|
||||
)
|
||||
PUBLIC_PREFIXES: tuple[str, ...] = ("/static/", "/shared/")
|
||||
PUBLIC_PREFIXES: tuple[str, ...] = ("/static/", "/shared/", "/acme/")
|
||||
|
||||
WRITE_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
|
||||
@@ -125,6 +125,10 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
|
||||
"port": "redis_port",
|
||||
"password": "redis_password",
|
||||
"db": "redis_db",
|
||||
"tls": "redis_tls",
|
||||
"tls_ca": "redis_tls_ca",
|
||||
"tls_cert": "redis_tls_cert",
|
||||
"tls_key": "redis_tls_key",
|
||||
},
|
||||
"console": {
|
||||
"host": "host",
|
||||
@@ -157,6 +161,10 @@ _CONFIG_MAP: dict[str, dict[str, str]] = {
|
||||
"url": "db_url",
|
||||
"path": "db_path",
|
||||
"pool_size": "db_pool_size",
|
||||
"sslmode": "db_sslmode",
|
||||
"sslrootcert": "db_sslrootcert",
|
||||
"sslcert": "db_sslcert",
|
||||
"sslkey": "db_sslkey",
|
||||
},
|
||||
"judge": {
|
||||
"enabled": "judge_enabled",
|
||||
|
||||
@@ -891,7 +891,10 @@ class ChatSession:
|
||||
" search(query='MAX_RETRIES') → "
|
||||
"read_file(path='found.py') → "
|
||||
"edit_file(path='found.py')\n\n"
|
||||
"Plan, think through, or strategize → plan_agent:\n"
|
||||
"Plan, design, or architect something → "
|
||||
"explore codebase then plan_agent:\n"
|
||||
" bash(command='ls') → read_file(path='app.py') → "
|
||||
"plan_agent(goal='add caching to the application')\n"
|
||||
" plan_agent(goal='refactor database layer "
|
||||
"from monolith to service')\n"
|
||||
" plan_agent(goal='restructure auth module')\n\n"
|
||||
|
||||
@@ -528,6 +528,29 @@ def _build_registry() -> dict[str, SettingDef]:
|
||||
"information from conversations into long-term memory. This helps the AI "
|
||||
"remember context across separate conversations.",
|
||||
),
|
||||
# -- tls ----------------------------------------------------------------
|
||||
SettingDef(
|
||||
"tls.enabled",
|
||||
"bool",
|
||||
False,
|
||||
"Enable mTLS for inter-service communication",
|
||||
"tls",
|
||||
restart_required=True,
|
||||
help="When enabled, the console runs an internal Certificate Authority and "
|
||||
"ACME server. All cluster services (servers, bridge, channels) auto-provision "
|
||||
"short-lived certificates for mutual TLS. Requires lacme: pip install turnstone[tls]",
|
||||
),
|
||||
SettingDef(
|
||||
"tls.acme_directory",
|
||||
"str",
|
||||
"",
|
||||
"External ACME CA URL for the console's frontend HTTPS cert",
|
||||
"tls",
|
||||
restart_required=True,
|
||||
help="Set to a public ACME directory URL (e.g. https://acme-v02.api.letsencrypt.org/"
|
||||
"directory) to get a publicly trusted certificate for the console's HTTPS endpoint. "
|
||||
"Leave empty to self-issue from the internal CA (use when behind a reverse proxy).",
|
||||
),
|
||||
]
|
||||
return {d.key: d for d in defs}
|
||||
|
||||
@@ -543,7 +566,7 @@ BOOTSTRAP_SECTIONS: frozenset[str] = frozenset(
|
||||
"auth",
|
||||
"bridge",
|
||||
"console",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ from turnstone.core.storage._schema import (
|
||||
skill_versions,
|
||||
structured_memories,
|
||||
system_settings,
|
||||
tls_account_keys,
|
||||
tls_ca,
|
||||
tls_certificates,
|
||||
tool_policies,
|
||||
usage_events,
|
||||
user_roles,
|
||||
@@ -2805,6 +2808,108 @@ class PostgreSQLBackend:
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- TLS / ACME ------------------------------------------------------------
|
||||
|
||||
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
stmt = pg_insert(tls_account_keys).values(id=key_id, key_pem=key_pem, created=now)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["id"],
|
||||
set_={"key_pem": key_pem},
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
def load_tls_account_key(self, key_id: str) -> str | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(tls_account_keys.c.key_pem).where(tls_account_keys.c.id == key_id)
|
||||
).first()
|
||||
return row[0] if row else None
|
||||
|
||||
def save_tls_ca(self, name: str, cert_pem: str, key_pem: str) -> None:
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
stmt = pg_insert(tls_ca).values(name=name, cert_pem=cert_pem, key_pem=key_pem, created=now)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["name"],
|
||||
set_={"cert_pem": cert_pem, "key_pem": key_pem},
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
def load_tls_ca(self, name: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(tls_ca).where(tls_ca.c.name == name)).first()
|
||||
if not row:
|
||||
return None
|
||||
return _row_to_dict(row)
|
||||
|
||||
def save_tls_cert(
|
||||
self,
|
||||
domain: str,
|
||||
cert_pem: str,
|
||||
fullchain_pem: str,
|
||||
key_pem: str,
|
||||
issued_at: str,
|
||||
expires_at: str,
|
||||
meta: str | None = None,
|
||||
) -> None:
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
stmt = pg_insert(tls_certificates).values(
|
||||
domain=domain,
|
||||
cert_pem=cert_pem,
|
||||
fullchain_pem=fullchain_pem,
|
||||
key_pem=key_pem,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
meta=meta,
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["domain"],
|
||||
set_={
|
||||
"cert_pem": cert_pem,
|
||||
"fullchain_pem": fullchain_pem,
|
||||
"key_pem": key_pem,
|
||||
"issued_at": issued_at,
|
||||
"expires_at": expires_at,
|
||||
"meta": meta,
|
||||
},
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
def load_tls_cert(self, domain: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(tls_certificates).where(tls_certificates.c.domain == domain)
|
||||
).first()
|
||||
if not row:
|
||||
return None
|
||||
return _row_to_dict(row)
|
||||
|
||||
def list_tls_certs(self) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(tls_certificates).order_by(tls_certificates.c.domain)
|
||||
).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
def delete_tls_cert(self, domain: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(tls_certificates).where(tls_certificates.c.domain == domain)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -952,6 +952,49 @@ class StorageBackend(Protocol):
|
||||
"""Delete an MCP server definition. Returns True if existed."""
|
||||
...
|
||||
|
||||
# -- TLS / ACME (lacme Store) ----------------------------------------------
|
||||
|
||||
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
|
||||
"""Persist an ACME account private key."""
|
||||
...
|
||||
|
||||
def load_tls_account_key(self, key_id: str) -> str | None:
|
||||
"""Load an ACME account key PEM by ID. Returns None if not found."""
|
||||
...
|
||||
|
||||
def save_tls_ca(self, name: str, cert_pem: str, key_pem: str) -> None:
|
||||
"""Persist a CA root certificate and key."""
|
||||
...
|
||||
|
||||
def load_tls_ca(self, name: str) -> dict[str, Any] | None:
|
||||
"""Load CA cert+key by name. Returns dict with cert_pem, key_pem or None."""
|
||||
...
|
||||
|
||||
def save_tls_cert(
|
||||
self,
|
||||
domain: str,
|
||||
cert_pem: str,
|
||||
fullchain_pem: str,
|
||||
key_pem: str,
|
||||
issued_at: str,
|
||||
expires_at: str,
|
||||
meta: str | None = None,
|
||||
) -> None:
|
||||
"""Persist an issued certificate (upsert by domain)."""
|
||||
...
|
||||
|
||||
def load_tls_cert(self, domain: str) -> dict[str, Any] | None:
|
||||
"""Load certificate by domain. Returns dict or None."""
|
||||
...
|
||||
|
||||
def list_tls_certs(self) -> list[dict[str, Any]]:
|
||||
"""List all stored certificates."""
|
||||
...
|
||||
|
||||
def delete_tls_cert(self, domain: str) -> bool:
|
||||
"""Delete a certificate by domain. Returns True if existed."""
|
||||
...
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -22,6 +22,10 @@ def init_storage(
|
||||
url: str = "",
|
||||
pool_size: int = 2,
|
||||
run_migrations: bool = True,
|
||||
sslmode: str = "",
|
||||
sslrootcert: str = "",
|
||||
sslcert: str = "",
|
||||
sslkey: str = "",
|
||||
) -> StorageBackend:
|
||||
"""Initialize the storage backend singleton.
|
||||
|
||||
@@ -52,6 +56,26 @@ def init_storage(
|
||||
if not url:
|
||||
msg = "PostgreSQL backend requires a connection URL (db_url)"
|
||||
raise ValueError(msg)
|
||||
# Append SSL params to URL if provided (validated + encoded)
|
||||
valid_sslmodes = {"disable", "allow", "prefer", "require", "verify-ca", "verify-full"}
|
||||
if sslmode and sslmode not in valid_sslmodes:
|
||||
msg = f"Invalid sslmode: {sslmode!r} (expected one of {sorted(valid_sslmodes)})"
|
||||
raise ValueError(msg)
|
||||
ssl_params = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"sslmode": sslmode,
|
||||
"sslrootcert": sslrootcert,
|
||||
"sslcert": sslcert,
|
||||
"sslkey": sslkey,
|
||||
}.items()
|
||||
if v
|
||||
}
|
||||
if ssl_params:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
sep = "&" if "?" in url else "?"
|
||||
url += sep + urlencode(ssl_params)
|
||||
_storage = PostgreSQLBackend(url, pool_size=pool_size, create_tables=create_tables)
|
||||
log.info("Storage initialized: PostgreSQL")
|
||||
|
||||
|
||||
@@ -547,3 +547,34 @@ oidc_pending_states = sa.Table(
|
||||
sa.Column("audience", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
# ── TLS / ACME (lacme integration) ──────────────────────────────────────────
|
||||
|
||||
tls_account_keys = sa.Table(
|
||||
"tls_account_keys",
|
||||
metadata,
|
||||
sa.Column("id", sa.Text, primary_key=True),
|
||||
sa.Column("key_pem", sa.Text, nullable=False),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
tls_ca = sa.Table(
|
||||
"tls_ca",
|
||||
metadata,
|
||||
sa.Column("name", sa.Text, primary_key=True),
|
||||
sa.Column("cert_pem", sa.Text, nullable=False),
|
||||
sa.Column("key_pem", sa.Text, nullable=False),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
tls_certificates = sa.Table(
|
||||
"tls_certificates",
|
||||
metadata,
|
||||
sa.Column("domain", sa.Text, primary_key=True),
|
||||
sa.Column("cert_pem", sa.Text, nullable=False),
|
||||
sa.Column("fullchain_pem", sa.Text, nullable=False),
|
||||
sa.Column("key_pem", sa.Text, nullable=False),
|
||||
sa.Column("issued_at", sa.Text, nullable=False),
|
||||
sa.Column("expires_at", sa.Text, nullable=False),
|
||||
sa.Column("meta", sa.Text, nullable=True),
|
||||
)
|
||||
|
||||
@@ -30,6 +30,9 @@ from turnstone.core.storage._schema import (
|
||||
skill_versions,
|
||||
structured_memories,
|
||||
system_settings,
|
||||
tls_account_keys,
|
||||
tls_ca,
|
||||
tls_certificates,
|
||||
tool_policies,
|
||||
usage_events,
|
||||
user_roles,
|
||||
@@ -2854,6 +2857,110 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
# -- TLS / ACME ------------------------------------------------------------
|
||||
|
||||
def save_tls_account_key(self, key_id: str, key_pem: str) -> None:
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
stmt = sqlite_insert(tls_account_keys).values(id=key_id, key_pem=key_pem, created=now)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["id"],
|
||||
set_={"key_pem": key_pem},
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
def load_tls_account_key(self, key_id: str) -> str | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(tls_account_keys.c.key_pem).where(tls_account_keys.c.id == key_id)
|
||||
).first()
|
||||
return row[0] if row else None
|
||||
|
||||
def save_tls_ca(self, name: str, cert_pem: str, key_pem: str) -> None:
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
|
||||
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
stmt = sqlite_insert(tls_ca).values(
|
||||
name=name, cert_pem=cert_pem, key_pem=key_pem, created=now
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["name"],
|
||||
set_={"cert_pem": cert_pem, "key_pem": key_pem},
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
def load_tls_ca(self, name: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(sa.select(tls_ca).where(tls_ca.c.name == name)).first()
|
||||
if not row:
|
||||
return None
|
||||
return _row_to_dict(row)
|
||||
|
||||
def save_tls_cert(
|
||||
self,
|
||||
domain: str,
|
||||
cert_pem: str,
|
||||
fullchain_pem: str,
|
||||
key_pem: str,
|
||||
issued_at: str,
|
||||
expires_at: str,
|
||||
meta: str | None = None,
|
||||
) -> None:
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
|
||||
stmt = sqlite_insert(tls_certificates).values(
|
||||
domain=domain,
|
||||
cert_pem=cert_pem,
|
||||
fullchain_pem=fullchain_pem,
|
||||
key_pem=key_pem,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
meta=meta,
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["domain"],
|
||||
set_={
|
||||
"cert_pem": cert_pem,
|
||||
"fullchain_pem": fullchain_pem,
|
||||
"key_pem": key_pem,
|
||||
"issued_at": issued_at,
|
||||
"expires_at": expires_at,
|
||||
"meta": meta,
|
||||
},
|
||||
)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
def load_tls_cert(self, domain: str) -> dict[str, Any] | None:
|
||||
with self._engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
sa.select(tls_certificates).where(tls_certificates.c.domain == domain)
|
||||
).first()
|
||||
if not row:
|
||||
return None
|
||||
return _row_to_dict(row)
|
||||
|
||||
def list_tls_certs(self) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(tls_certificates).order_by(tls_certificates.c.domain)
|
||||
).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
def delete_tls_cert(self, domain: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(tls_certificates).where(tls_certificates.c.domain == domain)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""TLS certificate storage for lacme ACME integration.
|
||||
|
||||
Three tables for the lacme Store protocol:
|
||||
- tls_account_keys: ACME account private keys
|
||||
- tls_ca: CA root certificate and key
|
||||
- tls_certificates: Issued service certificates
|
||||
|
||||
Revision ID: 026
|
||||
Revises: 025
|
||||
Create Date: 2026-03-25
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "026"
|
||||
down_revision = "025"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tls_account_keys",
|
||||
sa.Column("id", sa.Text, primary_key=True),
|
||||
sa.Column("key_pem", sa.Text, nullable=False),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"tls_ca",
|
||||
sa.Column("name", sa.Text, primary_key=True),
|
||||
sa.Column("cert_pem", sa.Text, nullable=False),
|
||||
sa.Column("key_pem", sa.Text, nullable=False),
|
||||
sa.Column("created", sa.Text, nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"tls_certificates",
|
||||
sa.Column("domain", sa.Text, primary_key=True),
|
||||
sa.Column("cert_pem", sa.Text, nullable=False),
|
||||
sa.Column("fullchain_pem", sa.Text, nullable=False),
|
||||
sa.Column("key_pem", sa.Text, nullable=False),
|
||||
sa.Column("issued_at", sa.Text, nullable=False),
|
||||
sa.Column("expires_at", sa.Text, nullable=False),
|
||||
sa.Column("meta", sa.Text, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("tls_certificates")
|
||||
op.drop_table("tls_ca")
|
||||
op.drop_table("tls_account_keys")
|
||||
@@ -0,0 +1,254 @@
|
||||
"""TLS Client — certificate provisioning for service nodes.
|
||||
|
||||
Non-console services (server, bridge, channel gateway) use this to
|
||||
request certificates from the console's ACME endpoint and build
|
||||
SSL contexts for mTLS communication.
|
||||
|
||||
Flow:
|
||||
1. Fetch CA root cert from console (plain HTTP, first boot)
|
||||
2. Request service cert via ACME (plain HTTP, first boot)
|
||||
3. Build SSL contexts for uvicorn (server) and httpx (client)
|
||||
4. Start auto-renewal (uses existing cert for mTLS to console)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ssl
|
||||
|
||||
from turnstone.core.storage._protocol import StorageBackend
|
||||
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_RENEW_INTERVAL_HOURS = 24
|
||||
_RENEW_BEFORE_EXPIRY_DAYS = 1
|
||||
|
||||
|
||||
def _require_lacme() -> Any:
|
||||
try:
|
||||
import lacme
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"lacme is required for TLS support. Install with: pip install turnstone[tls]",
|
||||
) from None
|
||||
return lacme
|
||||
|
||||
|
||||
class TLSClient:
|
||||
"""TLS client for service nodes.
|
||||
|
||||
Requests certificates from the console's ACME endpoint and provides
|
||||
SSL contexts for server (uvicorn) and client (httpx) use.
|
||||
|
||||
Typical usage::
|
||||
|
||||
client = TLSClient(storage, console_url="http://console:8080")
|
||||
await client.init() # Fetch CA, request cert
|
||||
server_ctx = client.get_server_ssl_context() # For uvicorn
|
||||
client_ctx = client.get_client_ssl_context() # For httpx
|
||||
await client.start_renewal() # Background auto-renewal
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage: StorageBackend,
|
||||
console_url: str = "",
|
||||
hostnames: list[str] | None = None,
|
||||
) -> None:
|
||||
lacme = _require_lacme()
|
||||
|
||||
from turnstone.core.tls_store import StorageStore
|
||||
|
||||
self._storage = storage
|
||||
self._store = StorageStore(storage)
|
||||
self._console_url = console_url.rstrip("/") if console_url else ""
|
||||
self._hostnames = hostnames or []
|
||||
self._event_dispatcher = lacme.EventDispatcher()
|
||||
self._ca_pem: bytes | None = None
|
||||
self._bundle: Any | None = None
|
||||
self._renewal_task: Any | None = None
|
||||
self._renewal_client: Any | None = None
|
||||
|
||||
# Wire Prometheus metrics
|
||||
try:
|
||||
from lacme.metrics import setup_metrics
|
||||
|
||||
setup_metrics(self._event_dispatcher)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
async def init(self) -> None:
|
||||
"""Fetch CA root cert and request a service certificate.
|
||||
|
||||
If no console_url was provided, discovers it from the services
|
||||
table. Performs initial cert provisioning over plain HTTP (ACME
|
||||
protocol provides integrity via JWS).
|
||||
"""
|
||||
if not self._console_url:
|
||||
self._console_url = self._discover_console_url()
|
||||
await self._fetch_ca_cert()
|
||||
await self._request_cert()
|
||||
|
||||
def _discover_console_url(self) -> str:
|
||||
"""Look up the console URL from the services table."""
|
||||
consoles = self._storage.list_services("console", max_age_seconds=3600)
|
||||
if not consoles:
|
||||
raise RuntimeError(
|
||||
"No console service found in services table. "
|
||||
"Ensure the console is running and has registered, "
|
||||
"or provide console_url explicitly."
|
||||
)
|
||||
url = consoles[0]["url"]
|
||||
log.info("tls.console.discovered", url=url)
|
||||
return url
|
||||
|
||||
async def _fetch_ca_cert(self) -> None:
|
||||
"""Fetch the CA root cert from the console.
|
||||
|
||||
Always uses plain HTTP for bootstrapping — the node doesn't have
|
||||
the CA cert yet, so it can't verify HTTPS.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
# Force HTTP for bootstrap (can't verify HTTPS without CA cert)
|
||||
base = self._console_url.replace("https://", "http://")
|
||||
url = f"{base}/acme/ca.pem"
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
self._ca_pem = resp.content
|
||||
log.info("tls.ca.fetched", url=url)
|
||||
except Exception:
|
||||
log.error("tls.ca.fetch_failed", url=url, exc_info=True)
|
||||
raise
|
||||
|
||||
async def _request_cert(self) -> None:
|
||||
"""Request a certificate from the console's ACME endpoint."""
|
||||
if not self._hostnames:
|
||||
raise ValueError("No hostnames configured for TLS cert request")
|
||||
|
||||
# Check for existing valid cert
|
||||
from datetime import UTC, datetime
|
||||
|
||||
existing = self._store.load_cert(self._hostnames[0])
|
||||
if existing is not None and existing.expires_at > datetime.now(UTC):
|
||||
self._bundle = existing
|
||||
log.info("tls.cert.loaded", domain=self._hostnames[0])
|
||||
return
|
||||
|
||||
# Request new cert via ACME (plain HTTP for initial request)
|
||||
lacme = _require_lacme()
|
||||
from lacme.challenges.http01 import HTTP01Handler
|
||||
|
||||
directory_url = f"{self._console_url}/acme/directory"
|
||||
|
||||
async with lacme.Client(
|
||||
directory_url=directory_url,
|
||||
store=self._store,
|
||||
event_dispatcher=self._event_dispatcher,
|
||||
challenge_handler=HTTP01Handler(),
|
||||
allow_insecure=True,
|
||||
) as client:
|
||||
self._bundle = await client.issue(self._hostnames)
|
||||
self._store.save_cert(self._bundle)
|
||||
log.info("tls.cert.issued", domain=self._hostnames[0])
|
||||
|
||||
# -- Auto-renewal ----------------------------------------------------------
|
||||
|
||||
async def start_renewal(self) -> None:
|
||||
"""Start background auto-renewal via the console's ACME endpoint."""
|
||||
lacme = _require_lacme()
|
||||
|
||||
def _on_renewed(bundle: Any) -> None:
|
||||
self._bundle = bundle
|
||||
log.info("tls.cert.renewed", domain=bundle.domain)
|
||||
|
||||
from lacme.challenges.http01 import HTTP01Handler
|
||||
|
||||
directory_url = f"{self._console_url}/acme/directory"
|
||||
client = lacme.Client(
|
||||
directory_url=directory_url,
|
||||
store=self._store,
|
||||
event_dispatcher=self._event_dispatcher,
|
||||
challenge_handler=HTTP01Handler(),
|
||||
allow_insecure=True,
|
||||
)
|
||||
await client.__aenter__()
|
||||
|
||||
manager = lacme.RenewalManager(
|
||||
client=client,
|
||||
store=self._store,
|
||||
interval_hours=_RENEW_INTERVAL_HOURS,
|
||||
days_before_expiry=_RENEW_BEFORE_EXPIRY_DAYS,
|
||||
on_renewed=_on_renewed,
|
||||
event_dispatcher=self._event_dispatcher,
|
||||
)
|
||||
self._renewal_task = manager.start()
|
||||
self._renewal_client = client
|
||||
log.info("tls.renewal.started", directory=directory_url)
|
||||
|
||||
async def stop_renewal(self) -> None:
|
||||
"""Stop background renewal and close the ACME client."""
|
||||
import contextlib
|
||||
|
||||
if self._renewal_task is not None:
|
||||
import asyncio
|
||||
|
||||
self._renewal_task.cancel()
|
||||
try:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._renewal_task
|
||||
except Exception:
|
||||
log.exception("tls.renewal.stop_error")
|
||||
self._renewal_task = None
|
||||
if self._renewal_client is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._renewal_client.__aexit__(None, None, None)
|
||||
self._renewal_client = None
|
||||
|
||||
# -- SSL contexts ----------------------------------------------------------
|
||||
|
||||
def get_server_ssl_context(self) -> ssl.SSLContext | None:
|
||||
"""Build SSL context for uvicorn HTTPS listener."""
|
||||
if self._bundle is None or self._ca_pem is None:
|
||||
return None
|
||||
_require_lacme()
|
||||
from lacme.mtls import server_ssl_context
|
||||
|
||||
return server_ssl_context( # type: ignore[no-any-return]
|
||||
cert_pem=self._bundle.fullchain_pem,
|
||||
key_pem=self._bundle.key_pem,
|
||||
ca_cert_pem=self._ca_pem,
|
||||
)
|
||||
|
||||
def get_client_ssl_context(self) -> ssl.SSLContext | None:
|
||||
"""Build mTLS client context for httpx connections."""
|
||||
if self._bundle is None or self._ca_pem is None:
|
||||
return None
|
||||
_require_lacme()
|
||||
from lacme.mtls import client_ssl_context
|
||||
|
||||
return client_ssl_context( # type: ignore[no-any-return]
|
||||
cert_pem=self._bundle.cert_pem,
|
||||
key_pem=self._bundle.key_pem,
|
||||
ca_cert_pem=self._ca_pem,
|
||||
)
|
||||
|
||||
# -- Properties ------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def ca_pem(self) -> bytes | None:
|
||||
return self._ca_pem
|
||||
|
||||
@property
|
||||
def bundle(self) -> Any | None:
|
||||
return self._bundle
|
||||
|
||||
@property
|
||||
def initialized(self) -> bool:
|
||||
return self._bundle is not None and self._ca_pem is not None
|
||||
@@ -0,0 +1,127 @@
|
||||
"""lacme Store adapter backed by turnstone's storage backend.
|
||||
|
||||
Bridges lacme's Store protocol to turnstone's StorageBackend, keeping
|
||||
all TLS state (account keys, CA, certificates) in the shared database
|
||||
rather than the filesystem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.core.storage._protocol import StorageBackend
|
||||
|
||||
|
||||
def _parse_utc(iso: str) -> datetime:
|
||||
"""Parse an ISO timestamp, assuming UTC if naive."""
|
||||
dt = datetime.fromisoformat(iso)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
|
||||
|
||||
def _ensure_lacme() -> Any:
|
||||
"""Import lacme, raising a clear error if not installed."""
|
||||
try:
|
||||
import lacme
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"lacme is required for TLS support. Install with: pip install turnstone[tls]",
|
||||
) from None
|
||||
return lacme
|
||||
|
||||
|
||||
class StorageStore:
|
||||
"""lacme Store implementation backed by turnstone's database.
|
||||
|
||||
Implements the 7-method Store protocol that lacme's CertificateAuthority
|
||||
and Client use for persistence.
|
||||
"""
|
||||
|
||||
def __init__(self, storage: StorageBackend) -> None:
|
||||
self._storage = storage
|
||||
|
||||
# -- Account key -----------------------------------------------------------
|
||||
|
||||
def save_account_key(self, key: Any) -> None:
|
||||
"""Persist the ACME account private key."""
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
)
|
||||
|
||||
key_pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode()
|
||||
self._storage.save_tls_account_key("default", key_pem)
|
||||
|
||||
def load_account_key(self) -> Any | None:
|
||||
"""Load the ACME account private key, or None."""
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
|
||||
pem = self._storage.load_tls_account_key("default")
|
||||
if pem is None:
|
||||
return None
|
||||
return load_pem_private_key(pem.encode(), password=None)
|
||||
|
||||
# -- CA --------------------------------------------------------------------
|
||||
|
||||
def save_ca(self, name: str, cert_pem: bytes, key_pem: bytes) -> None:
|
||||
"""Persist a CA root certificate and key."""
|
||||
self._storage.save_tls_ca(name, cert_pem.decode(), key_pem.decode())
|
||||
|
||||
def load_ca(self, name: str) -> tuple[bytes, bytes] | None:
|
||||
"""Load CA cert+key by name. Returns (cert_pem, key_pem) or None."""
|
||||
row = self._storage.load_tls_ca(name)
|
||||
if row is None:
|
||||
return None
|
||||
return row["cert_pem"].encode(), row["key_pem"].encode()
|
||||
|
||||
# -- Certificates ----------------------------------------------------------
|
||||
|
||||
def save_cert(self, bundle: Any) -> Any:
|
||||
"""Persist an issued certificate bundle."""
|
||||
meta = json.dumps({"domains": list(bundle.domains)})
|
||||
self._storage.save_tls_cert(
|
||||
domain=bundle.domain,
|
||||
cert_pem=bundle.cert_pem.decode(),
|
||||
fullchain_pem=bundle.fullchain_pem.decode(),
|
||||
key_pem=bundle.key_pem.decode(),
|
||||
issued_at=bundle.issued_at.isoformat(),
|
||||
expires_at=bundle.expires_at.isoformat(),
|
||||
meta=meta,
|
||||
)
|
||||
return bundle
|
||||
|
||||
def load_cert(self, domain: str) -> Any | None:
|
||||
"""Load a certificate bundle by domain."""
|
||||
row = self._storage.load_tls_cert(domain)
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_bundle(row)
|
||||
|
||||
def list_certs(self) -> list[Any]:
|
||||
"""List all stored certificate bundles."""
|
||||
rows = self._storage.list_tls_certs()
|
||||
return [self._row_to_bundle(r) for r in rows]
|
||||
|
||||
def delete_cert(self, domain: str) -> bool:
|
||||
"""Delete a stored certificate bundle by domain."""
|
||||
return self._storage.delete_tls_cert(domain)
|
||||
|
||||
def _row_to_bundle(self, row: dict[str, Any]) -> Any:
|
||||
"""Convert a storage row dict to a lacme CertBundle."""
|
||||
lacme = _ensure_lacme()
|
||||
meta = json.loads(row.get("meta") or "{}")
|
||||
domains = tuple(meta.get("domains", [row["domain"]]))
|
||||
return lacme.CertBundle(
|
||||
domain=row["domain"],
|
||||
domains=domains,
|
||||
cert_pem=row["cert_pem"].encode(),
|
||||
fullchain_pem=row["fullchain_pem"].encode(),
|
||||
key_pem=row["key_pem"].encode(),
|
||||
issued_at=_parse_utc(row["issued_at"]),
|
||||
expires_at=_parse_utc(row["expires_at"]),
|
||||
)
|
||||
@@ -47,6 +47,10 @@ class AsyncRedisBroker:
|
||||
prefix: str = "turnstone",
|
||||
password: str | None = None,
|
||||
response_ttl: int = 600,
|
||||
ssl: bool = False,
|
||||
ssl_ca_certs: str | None = None,
|
||||
ssl_certfile: str | None = None,
|
||||
ssl_keyfile: str | None = None,
|
||||
) -> None:
|
||||
self._host = host
|
||||
self._port = port
|
||||
@@ -54,6 +58,15 @@ class AsyncRedisBroker:
|
||||
self._password = password
|
||||
self._prefix = prefix
|
||||
self._response_ttl = response_ttl
|
||||
self._ssl_kwargs: dict[str, Any] = {}
|
||||
if ssl:
|
||||
self._ssl_kwargs["ssl"] = True
|
||||
if ssl_ca_certs:
|
||||
self._ssl_kwargs["ssl_ca_certs"] = ssl_ca_certs
|
||||
if ssl_certfile:
|
||||
self._ssl_kwargs["ssl_certfile"] = ssl_certfile
|
||||
if ssl_keyfile:
|
||||
self._ssl_kwargs["ssl_keyfile"] = ssl_keyfile
|
||||
self._redis: _aredis_t.Redis[str] | None = None
|
||||
self._pubsub: _aredis_t.client.PubSub | None = None
|
||||
self._tasks: dict[str, asyncio.Task[None]] = {}
|
||||
@@ -82,6 +95,7 @@ class AsyncRedisBroker:
|
||||
password=self._password,
|
||||
decode_responses=True,
|
||||
retry_on_timeout=True,
|
||||
**self._ssl_kwargs,
|
||||
max_connections=200,
|
||||
)
|
||||
self._pubsub = self._redis.pubsub(ignore_subscribe_messages=True)
|
||||
|
||||
@@ -78,6 +78,8 @@ class Bridge:
|
||||
heartbeat_ttl: int = 60,
|
||||
auth_token: str = "",
|
||||
token_manager: Any = None,
|
||||
tls_verify: Any = True,
|
||||
tls_cert: tuple[str, str] | None = None,
|
||||
) -> None:
|
||||
self._server_url = server_url.rstrip("/")
|
||||
self._broker = broker or RedisBroker()
|
||||
@@ -89,6 +91,8 @@ class Bridge:
|
||||
self._started_at = time.time()
|
||||
self._auth_token = auth_token
|
||||
self._token_manager = token_manager # ServiceTokenManager (auto-rotating)
|
||||
self._tls_verify = tls_verify # CA cert path or ssl.SSLContext or True
|
||||
self._tls_cert = tls_cert # (cert_path, key_path) for mTLS
|
||||
|
||||
# Shared httpx client for short-lived POST requests (main thread only).
|
||||
# Auth headers refreshed per-request via event hook so auto-rotating
|
||||
@@ -97,6 +101,8 @@ class Bridge:
|
||||
base_url=self._server_url,
|
||||
timeout=30,
|
||||
event_hooks={"request": [self._inject_auth]},
|
||||
verify=self._tls_verify,
|
||||
cert=self._tls_cert,
|
||||
)
|
||||
|
||||
# Protected by _lock — accessed from main, global SSE, and per-ws SSE threads
|
||||
@@ -592,6 +598,8 @@ class Bridge:
|
||||
base_url=self._server_url,
|
||||
timeout=None,
|
||||
event_hooks={"request": [self._inject_auth]},
|
||||
verify=self._tls_verify,
|
||||
cert=self._tls_cert,
|
||||
) as sse_client:
|
||||
while self._running:
|
||||
# Stop if workstream was closed (thread removed from registry)
|
||||
@@ -903,6 +911,8 @@ class Bridge:
|
||||
base_url=self._server_url,
|
||||
timeout=None,
|
||||
event_hooks={"request": [self._inject_auth]},
|
||||
verify=self._tls_verify,
|
||||
cert=self._tls_cert,
|
||||
) as sse_client:
|
||||
while self._running:
|
||||
try:
|
||||
@@ -1125,6 +1135,45 @@ def main() -> None:
|
||||
)
|
||||
log.info("bridge.jwt_minted")
|
||||
|
||||
# TLS: request cert from console ACME if enabled
|
||||
tls_verify: Any = True
|
||||
tls_cert: tuple[str, str] | None = None
|
||||
if os.environ.get("TURNSTONE_TLS_ENABLED", "").lower() in ("true", "1", "yes"):
|
||||
try:
|
||||
import asyncio
|
||||
import socket
|
||||
|
||||
from turnstone.core.storage import init_storage
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
db_backend = os.environ.get("TURNSTONE_DB_BACKEND", "sqlite")
|
||||
db_url = os.environ.get("TURNSTONE_DB_URL", "")
|
||||
db_path = os.environ.get("TURNSTONE_DB_PATH", "")
|
||||
storage = init_storage(db_backend, path=db_path, url=db_url)
|
||||
|
||||
hostname = socket.getfqdn()
|
||||
hostnames = [hostname, "localhost", "127.0.0.1"]
|
||||
extra_sans = os.environ.get("TURNSTONE_TLS_SANS", "")
|
||||
if extra_sans:
|
||||
hostnames.extend(s.strip() for s in extra_sans.split(",") if s.strip())
|
||||
tls_client = TLSClient(
|
||||
storage=storage,
|
||||
hostnames=hostnames,
|
||||
)
|
||||
asyncio.run(tls_client.init())
|
||||
ssl_ctx = tls_client.get_client_ssl_context()
|
||||
if ssl_ctx:
|
||||
# SSLContext has both CA (verify server) and client cert
|
||||
# (present to server) loaded — full mTLS in one object
|
||||
tls_verify = ssl_ctx
|
||||
if args.server_url.startswith("http://"):
|
||||
args.server_url = args.server_url.replace("http://", "https://")
|
||||
log.info("bridge.tls.enabled: %s", args.server_url)
|
||||
except ImportError:
|
||||
log.warning("TLS enabled but lacme not installed")
|
||||
except Exception:
|
||||
log.warning("bridge.tls.init_failed", exc_info=True)
|
||||
|
||||
bridge = Bridge(
|
||||
server_url=args.server_url,
|
||||
broker=broker,
|
||||
@@ -1133,6 +1182,8 @@ def main() -> None:
|
||||
heartbeat_ttl=args.heartbeat_ttl,
|
||||
auth_token=auth_token,
|
||||
token_manager=token_manager,
|
||||
tls_verify=tls_verify,
|
||||
tls_cert=tls_cert,
|
||||
)
|
||||
bridge.run()
|
||||
|
||||
|
||||
+40
-3
@@ -122,11 +122,24 @@ class RedisBroker:
|
||||
prefix: str = "turnstone",
|
||||
password: str | None = None,
|
||||
response_ttl: int = 600,
|
||||
ssl: bool = False,
|
||||
ssl_ca_certs: str | None = None,
|
||||
ssl_certfile: str | None = None,
|
||||
ssl_keyfile: str | None = None,
|
||||
) -> None:
|
||||
import redis
|
||||
|
||||
self._prefix = prefix
|
||||
self._response_ttl = response_ttl
|
||||
pool_kwargs: dict[str, Any] = {}
|
||||
if ssl:
|
||||
pool_kwargs["connection_class"] = redis.SSLConnection
|
||||
if ssl_ca_certs:
|
||||
pool_kwargs["ssl_ca_certs"] = ssl_ca_certs
|
||||
if ssl_certfile:
|
||||
pool_kwargs["ssl_certfile"] = ssl_certfile
|
||||
if ssl_keyfile:
|
||||
pool_kwargs["ssl_keyfile"] = ssl_keyfile
|
||||
self._pool: _redis_t.ConnectionPool = redis.ConnectionPool(
|
||||
host=host,
|
||||
port=port,
|
||||
@@ -135,6 +148,7 @@ class RedisBroker:
|
||||
decode_responses=True,
|
||||
retry_on_timeout=True,
|
||||
max_connections=200,
|
||||
**pool_kwargs,
|
||||
)
|
||||
self._redis: _redis_t.Redis[str] = cast(
|
||||
"_redis_t.Redis[str]",
|
||||
@@ -256,7 +270,7 @@ class RedisBroker:
|
||||
|
||||
|
||||
def add_redis_args(parser: Any) -> None:
|
||||
"""Add ``--redis-host``, ``--redis-port``, ``--redis-password``, ``--redis-db``."""
|
||||
"""Add Redis CLI arguments including TLS options."""
|
||||
import os
|
||||
|
||||
parser.add_argument(
|
||||
@@ -281,6 +295,27 @@ def add_redis_args(parser: Any) -> None:
|
||||
default=0,
|
||||
help="Redis DB number (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument("--redis-tls", action="store_true", help="Enable Redis TLS")
|
||||
parser.add_argument("--redis-tls-ca", default=None, help="Redis CA cert path")
|
||||
parser.add_argument("--redis-tls-cert", default=None, help="Redis client cert path")
|
||||
parser.add_argument("--redis-tls-key", default=None, help="Redis client key path")
|
||||
|
||||
|
||||
def _redis_tls_kwargs(args: Any) -> dict[str, Any]:
|
||||
"""Extract Redis TLS kwargs from parsed args."""
|
||||
kwargs: dict[str, Any] = {}
|
||||
if getattr(args, "redis_tls", False):
|
||||
kwargs["ssl"] = True
|
||||
ca = getattr(args, "redis_tls_ca", None)
|
||||
if ca:
|
||||
kwargs["ssl_ca_certs"] = ca
|
||||
cert = getattr(args, "redis_tls_cert", None)
|
||||
if cert:
|
||||
kwargs["ssl_certfile"] = cert
|
||||
key = getattr(args, "redis_tls_key", None)
|
||||
if key:
|
||||
kwargs["ssl_keyfile"] = key
|
||||
return kwargs
|
||||
|
||||
|
||||
def broker_from_args(args: Any) -> RedisBroker:
|
||||
@@ -289,7 +324,8 @@ def broker_from_args(args: Any) -> RedisBroker:
|
||||
host=args.redis_host,
|
||||
port=args.redis_port,
|
||||
db=args.redis_db,
|
||||
password=args.redis_password,
|
||||
password=args.redis_password or None,
|
||||
**_redis_tls_kwargs(args),
|
||||
)
|
||||
|
||||
|
||||
@@ -301,5 +337,6 @@ def async_broker_from_args(args: Any) -> Any:
|
||||
host=args.redis_host,
|
||||
port=args.redis_port,
|
||||
db=args.redis_db,
|
||||
password=args.redis_password,
|
||||
password=args.redis_password or None,
|
||||
**_redis_tls_kwargs(args),
|
||||
)
|
||||
|
||||
+21
-4
@@ -25,12 +25,18 @@ class _BaseClient:
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
httpx_client: httpx.AsyncClient | None = None,
|
||||
ca_cert: str | None = None,
|
||||
client_cert: str | None = None,
|
||||
client_key: str | None = None,
|
||||
) -> None:
|
||||
"""Initialise the client.
|
||||
|
||||
When *httpx_client* is provided it is used directly and *base_url*,
|
||||
*token*, and *timeout* are ignored — configure headers and base URL
|
||||
on the injected client instead.
|
||||
When *httpx_client* is provided it is used directly and all other
|
||||
params are ignored — configure headers, base URL, and TLS on the
|
||||
injected client instead.
|
||||
|
||||
For mTLS, pass *ca_cert* (CA bundle path), *client_cert* and
|
||||
*client_key* (client certificate + key paths).
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
if token:
|
||||
@@ -39,8 +45,19 @@ class _BaseClient:
|
||||
self._client = httpx_client
|
||||
self._owns_client = False
|
||||
else:
|
||||
tls_kwargs: dict[str, Any] = {}
|
||||
if ca_cert:
|
||||
tls_kwargs["verify"] = ca_cert
|
||||
if client_cert or client_key:
|
||||
if not (client_cert and client_key):
|
||||
raise ValueError("Both client_cert and client_key must be provided for mTLS")
|
||||
tls_kwargs["cert"] = (client_cert, client_key)
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=base_url, timeout=timeout, headers=headers, follow_redirects=True
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
headers=headers,
|
||||
follow_redirects=True,
|
||||
**tls_kwargs,
|
||||
)
|
||||
self._owns_client = True
|
||||
|
||||
|
||||
@@ -76,8 +76,19 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
httpx_client: httpx.AsyncClient | None = None,
|
||||
ca_cert: str | None = None,
|
||||
client_cert: str | None = None,
|
||||
client_key: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(base_url=base_url, token=token, timeout=timeout, httpx_client=httpx_client)
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
token=token,
|
||||
timeout=timeout,
|
||||
httpx_client=httpx_client,
|
||||
ca_cert=ca_cert,
|
||||
client_cert=client_cert,
|
||||
client_key=client_key,
|
||||
)
|
||||
|
||||
# -- cluster overview ----------------------------------------------------
|
||||
|
||||
@@ -847,9 +858,19 @@ class TurnstoneConsole:
|
||||
base_url: str = "http://localhost:8081",
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
ca_cert: str | None = None,
|
||||
client_cert: str | None = None,
|
||||
client_key: str | None = None,
|
||||
) -> None:
|
||||
self._runner = _SyncRunner()
|
||||
self._async = AsyncTurnstoneConsole(base_url=base_url, token=token, timeout=timeout)
|
||||
self._async = AsyncTurnstoneConsole(
|
||||
base_url=base_url,
|
||||
token=token,
|
||||
timeout=timeout,
|
||||
ca_cert=ca_cert,
|
||||
client_cert=client_cert,
|
||||
client_key=client_key,
|
||||
)
|
||||
|
||||
# -- cluster overview ----------------------------------------------------
|
||||
|
||||
|
||||
+23
-2
@@ -60,8 +60,19 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
httpx_client: httpx.AsyncClient | None = None,
|
||||
ca_cert: str | None = None,
|
||||
client_cert: str | None = None,
|
||||
client_key: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(base_url=base_url, token=token, timeout=timeout, httpx_client=httpx_client)
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
token=token,
|
||||
timeout=timeout,
|
||||
httpx_client=httpx_client,
|
||||
ca_cert=ca_cert,
|
||||
client_cert=client_cert,
|
||||
client_key=client_key,
|
||||
)
|
||||
|
||||
# -- workstream management -----------------------------------------------
|
||||
|
||||
@@ -395,9 +406,19 @@ class TurnstoneServer:
|
||||
base_url: str = "http://localhost:8080",
|
||||
token: str = "",
|
||||
timeout: float = 30.0,
|
||||
ca_cert: str | None = None,
|
||||
client_cert: str | None = None,
|
||||
client_key: str | None = None,
|
||||
) -> None:
|
||||
self._runner = _SyncRunner()
|
||||
self._async = AsyncTurnstoneServer(base_url=base_url, token=token, timeout=timeout)
|
||||
self._async = AsyncTurnstoneServer(
|
||||
base_url=base_url,
|
||||
token=token,
|
||||
timeout=timeout,
|
||||
ca_cert=ca_cert,
|
||||
client_cert=client_cert,
|
||||
client_key=client_key,
|
||||
)
|
||||
|
||||
# -- workstream management -----------------------------------------------
|
||||
|
||||
|
||||
+70
-3
@@ -18,7 +18,6 @@ import functools
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import socket
|
||||
import sys
|
||||
import textwrap
|
||||
import threading
|
||||
@@ -1851,8 +1850,19 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
"OIDC JWKS prefetch failed — will retry on first login",
|
||||
exc_info=True,
|
||||
)
|
||||
# TLS: start auto-renewal if client was initialized
|
||||
tls_client = getattr(app.state, "tls_client", None)
|
||||
if tls_client is not None:
|
||||
try:
|
||||
await tls_client.start_renewal()
|
||||
except Exception:
|
||||
log.warning("TLS auto-renewal startup failed", exc_info=True)
|
||||
|
||||
yield
|
||||
# Shutdown
|
||||
tls_client = getattr(app.state, "tls_client", None)
|
||||
if tls_client is not None:
|
||||
await tls_client.stop_renewal()
|
||||
if app.state.watch_runner:
|
||||
app.state.watch_runner.stop()
|
||||
if app.state.health_monitor:
|
||||
@@ -2075,6 +2085,8 @@ def main() -> None:
|
||||
|
||||
configure_logging_from_args(args, "server")
|
||||
|
||||
import socket
|
||||
|
||||
# Initialize storage backend
|
||||
from turnstone.core.storage import init_storage
|
||||
|
||||
@@ -2086,7 +2098,17 @@ def main() -> None:
|
||||
db_pool_size = int(
|
||||
getattr(args, "db_pool_size", None) or os.environ.get("TURNSTONE_DB_POOL_SIZE", "2")
|
||||
)
|
||||
init_storage(db_backend, path=db_path, url=db_url, pool_size=db_pool_size)
|
||||
init_storage(
|
||||
db_backend,
|
||||
path=db_path,
|
||||
url=db_url,
|
||||
pool_size=db_pool_size,
|
||||
sslmode=getattr(args, "db_sslmode", None) or os.environ.get("TURNSTONE_DB_SSLMODE", ""),
|
||||
sslrootcert=getattr(args, "db_sslrootcert", None)
|
||||
or os.environ.get("TURNSTONE_DB_SSLROOTCERT", ""),
|
||||
sslcert=getattr(args, "db_sslcert", None) or os.environ.get("TURNSTONE_DB_SSLCERT", ""),
|
||||
sslkey=getattr(args, "db_sslkey", None) or os.environ.get("TURNSTONE_DB_SSLKEY", ""),
|
||||
)
|
||||
|
||||
# Server-owned node identity (needed before ConfigStore for node_id scoping)
|
||||
def _default_node_id() -> str:
|
||||
@@ -2428,11 +2450,56 @@ def main() -> None:
|
||||
)
|
||||
log.info("Max workstreams: %s", config_store.get("server.max_workstreams"))
|
||||
log.info("Node ID: %s", _node_id)
|
||||
|
||||
# TLS: request cert from console ACME if enabled
|
||||
ssl_kwargs: dict[str, Any] = {}
|
||||
if config_store.get("tls.enabled"):
|
||||
try:
|
||||
import asyncio
|
||||
|
||||
from turnstone.core.tls import TLSClient
|
||||
|
||||
hostname = socket.getfqdn()
|
||||
hostnames = [hostname, "localhost", "127.0.0.1"]
|
||||
# Only add bind host if it's a concrete address
|
||||
if args.host not in ("0.0.0.0", "::", ""):
|
||||
hostnames.append(args.host)
|
||||
# Additional SANs from env (e.g. Docker service name)
|
||||
extra_sans = os.environ.get("TURNSTONE_TLS_SANS", "")
|
||||
if extra_sans:
|
||||
hostnames.extend(s.strip() for s in extra_sans.split(",") if s.strip())
|
||||
tls_client = TLSClient(
|
||||
storage=get_storage(),
|
||||
hostnames=hostnames,
|
||||
)
|
||||
asyncio.run(tls_client.init())
|
||||
bundle = tls_client.bundle
|
||||
if bundle:
|
||||
from lacme.mtls import write_pem_files_persistent
|
||||
|
||||
pem_paths = write_pem_files_persistent(
|
||||
bundle,
|
||||
ca_pem=tls_client.ca_pem,
|
||||
)
|
||||
ssl_kwargs.update(pem_paths.as_uvicorn_kwargs())
|
||||
if tls_client.ca_pem:
|
||||
import ssl as _ssl
|
||||
|
||||
ssl_kwargs["ssl_cert_reqs"] = _ssl.CERT_REQUIRED
|
||||
|
||||
# Store client on app state for lifespan renewal
|
||||
app.state.tls_client = tls_client
|
||||
log.info("TLS enabled — serving HTTPS")
|
||||
else:
|
||||
log.warning("TLS enabled but no cert available")
|
||||
except Exception:
|
||||
log.warning("TLS initialization failed — serving plain HTTP", exc_info=True)
|
||||
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
|
||||
uvicorn.run(app, host=args.host, port=args.port, log_level="warning", **ssl_kwargs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -982,6 +982,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lacme"
|
||||
version = "1.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "httpx" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/27/1f1b78b53b4190a15234deffef8459ce9af9c32251fe669b3c884373d954/lacme-1.0.4.tar.gz", hash = "sha256:c147cac91bcc243b0799264a0da31de44922f494c58d2d5fa9f62712455eda69", size = 200855, upload-time = "2026-03-26T20:31:20.984Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/6d/a43c37dd2914560f9954f46598d07f976d3861722052deabe17a3f60ddb0/lacme-1.0.4-py3-none-any.whl", hash = "sha256:a21ed4a634c2c23a3afc0aaf327421fad709be9ae284fd6019a109b011fa52f7", size = 72122, upload-time = "2026-03-26T20:31:19.758Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.8.1"
|
||||
@@ -2322,7 +2335,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "turnstone"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
@@ -2347,6 +2360,7 @@ all = [
|
||||
{ name = "croniter" },
|
||||
{ name = "ddgs" },
|
||||
{ name = "discord-py" },
|
||||
{ name = "lacme" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "redis" },
|
||||
]
|
||||
@@ -2383,6 +2397,9 @@ test = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
]
|
||||
tls = [
|
||||
{ name = "lacme" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
@@ -2395,6 +2412,7 @@ requires-dist = [
|
||||
{ name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.4" },
|
||||
{ name = "httpx", specifier = ">=0.28" },
|
||||
{ name = "httpx-sse", specifier = ">=0.4" },
|
||||
{ name = "lacme", marker = "extra == 'tls'", specifier = ">=1.0.4" },
|
||||
{ name = "mcp", specifier = ">=1.6" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" },
|
||||
{ name = "openai", specifier = ">=2.24" },
|
||||
@@ -2413,11 +2431,11 @@ requires-dist = [
|
||||
{ name = "sse-starlette", specifier = ">=2.0" },
|
||||
{ name = "starlette", specifier = ">=0.45" },
|
||||
{ name = "structlog", specifier = ">=24.1" },
|
||||
{ name = "turnstone", extras = ["mq", "console", "sim", "anthropic", "postgres", "discord", "ddg"], marker = "extra == 'all'" },
|
||||
{ name = "turnstone", extras = ["mq", "console", "sim", "anthropic", "postgres", "discord", "ddg", "tls"], marker = "extra == 'all'" },
|
||||
{ name = "types-redis", marker = "extra == 'dev'", specifier = ">=4.6" },
|
||||
{ name = "uvicorn", specifier = ">=0.34" },
|
||||
]
|
||||
provides-extras = ["test", "dev", "mq", "console", "sim", "anthropic", "postgres", "ddg", "discord", "all"]
|
||||
provides-extras = ["test", "dev", "mq", "console", "sim", "anthropic", "postgres", "ddg", "discord", "tls", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "types-cffi"
|
||||
|
||||
Reference in New Issue
Block a user