mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat: TLS storage backend + config for lacme integration (#178)
Storage layer for mTLS certificate management via lacme: - Migration 026: tls_account_keys, tls_ca, tls_certificates tables - 8 protocol methods on StorageBackend (save/load account keys, CA, certs; list/delete certs) - SQLite and PostgreSQL implementations with dialect-specific upserts - StorageStore adapter bridging lacme's Store protocol to turnstone storage (bytes↔str PEM conversion, CertBundle↔dict mapping) - Settings registry: tls.enabled (bool), tls.acme_directory (string) - Config.toml: [redis] TLS and [database] SSL passthrough params - lacme>=1.0.1 as optional [tls] dependency - 20 unit tests covering storage CRUD + adapter + crypto roundtrip
This commit is contained in:
+6
-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.1"]
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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) -> None:
|
||||
"""Delete a stored certificate bundle by domain."""
|
||||
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"]),
|
||||
)
|
||||
@@ -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.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "httpx" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/3f/0371df0b8c5d92697234893ec90168983d86a6608cadae01f287b9093aa9/lacme-1.0.1.tar.gz", hash = "sha256:d90f919f7eb98e6569d05acc9e8e86006540356d9c1321cb7dd868352965201f", size = 196508, upload-time = "2026-03-25T21:42:25.638Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/e4/fe7d02c95f20c4212683456da605125a28463d3838a77cbcab4234c63015/lacme-1.0.1-py3-none-any.whl", hash = "sha256:3ceb598c4044378d55f91ca661d5fab1366739711ca05f8e3f8143904a04a1a9", size = 70062, upload-time = "2026-03-25T21:42:24.023Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.8.1"
|
||||
@@ -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.1" },
|
||||
{ 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