reliamesh-server 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reliamesh/__init__.py +3 -0
- reliamesh/api.py +340 -0
- reliamesh/cli.py +109 -0
- reliamesh/config.py +47 -0
- reliamesh/detection.py +327 -0
- reliamesh/main.py +3 -0
- reliamesh/protocol.py +129 -0
- reliamesh/security.py +36 -0
- reliamesh/storage.py +647 -0
- reliamesh_server-0.1.0.dist-info/METADATA +184 -0
- reliamesh_server-0.1.0.dist-info/RECORD +16 -0
- reliamesh_server-0.1.0.dist-info/WHEEL +5 -0
- reliamesh_server-0.1.0.dist-info/entry_points.txt +2 -0
- reliamesh_server-0.1.0.dist-info/licenses/LICENSE +202 -0
- reliamesh_server-0.1.0.dist-info/licenses/NOTICE +7 -0
- reliamesh_server-0.1.0.dist-info/top_level.txt +1 -0
reliamesh/__init__.py
ADDED
reliamesh/api.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Authenticated, content-free reliability ingestion and inspection API."""
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Annotated, Literal
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
11
|
+
from fastapi.exceptions import RequestValidationError
|
|
12
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
13
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
15
|
+
|
|
16
|
+
from reliamesh import __version__
|
|
17
|
+
from reliamesh.config import Settings, make_store
|
|
18
|
+
from reliamesh.detection import DetectionError, process_events, summarize
|
|
19
|
+
from reliamesh.protocol import EventBatch
|
|
20
|
+
from reliamesh.security import Limiter, digest_key, new_key
|
|
21
|
+
from reliamesh.storage import StorageLimit, TenantExists, TenantMissing
|
|
22
|
+
|
|
23
|
+
LOG = logging.getLogger("reliamesh")
|
|
24
|
+
TenantId = Annotated[str, Field(pattern=r"^[a-z0-9][a-z0-9_-]{2,63}$")]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TenantRequest(BaseModel):
|
|
28
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
29
|
+
tenant_id: TenantId
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class KeyRequest(BaseModel):
|
|
33
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
34
|
+
scopes: list[Literal["ingest", "read", "manage"]] = Field(min_length=1, max_length=3)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class IngestReceipt(BaseModel):
|
|
38
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
39
|
+
schema_version: Literal["1.0"]
|
|
40
|
+
accepted: int = Field(ge=0)
|
|
41
|
+
duplicates: int = Field(ge=0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ErrorResponse(BaseModel):
|
|
45
|
+
"""Public failures contain fixed codes, never request values or exceptions."""
|
|
46
|
+
|
|
47
|
+
error: str
|
|
48
|
+
schema_url: str | None = Field(default=None, alias="schema")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
TENANT_BEARER = HTTPBearer(
|
|
52
|
+
auto_error=False, scheme_name="TenantBearer", bearerFormat="rm_<43 URL-safe characters>",
|
|
53
|
+
description="Tenant API key. Endpoint descriptions specify the required ingest, read, or manage scope.",
|
|
54
|
+
)
|
|
55
|
+
ADMIN_BEARER = HTTPBearer(
|
|
56
|
+
auto_error=False, scheme_name="AdministratorBearer", bearerFormat="rm_<43 URL-safe characters>",
|
|
57
|
+
description="Operator administrator key used only to provision tenants. Tenant keys cannot authorize this endpoint.",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SafetyMiddleware:
|
|
62
|
+
"""Buffer at most the payload cap before JSON parsing, including chunked bodies."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, app, settings):
|
|
65
|
+
self.app, self.settings = app, settings
|
|
66
|
+
self.global_limit = Limiter(settings.global_requests_per_second, 1)
|
|
67
|
+
|
|
68
|
+
async def __call__(self, scope, receive, send):
|
|
69
|
+
if scope["type"] != "http":
|
|
70
|
+
return await self.app(scope, receive, send)
|
|
71
|
+
request_id = str(uuid4())
|
|
72
|
+
response_started = False
|
|
73
|
+
|
|
74
|
+
async def safe_send(message):
|
|
75
|
+
nonlocal response_started
|
|
76
|
+
if message["type"] == "http.response.start":
|
|
77
|
+
response_started = True
|
|
78
|
+
message["headers"] = list(message["headers"]) + [
|
|
79
|
+
(b"x-request-id", request_id.encode()),
|
|
80
|
+
(b"x-content-type-options", b"nosniff"),
|
|
81
|
+
(b"referrer-policy", b"no-referrer"),
|
|
82
|
+
(b"cache-control", b"no-store"),
|
|
83
|
+
(b"content-security-policy", b"default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"),
|
|
84
|
+
(b"strict-transport-security", b"max-age=31536000"),
|
|
85
|
+
]
|
|
86
|
+
await send(message)
|
|
87
|
+
|
|
88
|
+
async def reject(status, code):
|
|
89
|
+
response = JSONResponse({"error": code}, status_code=status)
|
|
90
|
+
if status == 429:
|
|
91
|
+
response.headers["Retry-After"] = "60"
|
|
92
|
+
await response(scope, receive, safe_send)
|
|
93
|
+
|
|
94
|
+
if scope["path"] not in {"/health", "/"} and not self.global_limit.allow("all"):
|
|
95
|
+
return await reject(429, "rate_limited")
|
|
96
|
+
headers = dict(scope.get("headers", []))
|
|
97
|
+
if headers.get(b"content-encoding", b"identity") != b"identity":
|
|
98
|
+
return await reject(415, "unsupported_encoding")
|
|
99
|
+
if scope["method"] in {"POST", "PUT", "PATCH"}:
|
|
100
|
+
if headers.get(b"content-type", b"").split(b";")[0] != b"application/json":
|
|
101
|
+
return await reject(415, "application_json_required")
|
|
102
|
+
body = bytearray()
|
|
103
|
+
while True:
|
|
104
|
+
message = await receive()
|
|
105
|
+
if message["type"] == "http.disconnect":
|
|
106
|
+
return
|
|
107
|
+
chunk = message.get("body", b"")
|
|
108
|
+
if len(body) + len(chunk) > self.settings.max_body_bytes:
|
|
109
|
+
return await reject(413, "payload_too_large")
|
|
110
|
+
body.extend(chunk)
|
|
111
|
+
if not message.get("more_body", False):
|
|
112
|
+
break
|
|
113
|
+
# Bound JSON nesting before framework parsing; never echo rejected bytes.
|
|
114
|
+
if body:
|
|
115
|
+
depth = 0
|
|
116
|
+
quoted = escaped = False
|
|
117
|
+
for byte in body:
|
|
118
|
+
if escaped:
|
|
119
|
+
escaped = False
|
|
120
|
+
elif quoted and byte == 92:
|
|
121
|
+
escaped = True
|
|
122
|
+
elif byte == 34:
|
|
123
|
+
quoted = not quoted
|
|
124
|
+
elif not quoted:
|
|
125
|
+
depth += (byte in (91, 123)) - (byte in (93, 125))
|
|
126
|
+
if depth > 12:
|
|
127
|
+
return await reject(422, "json_too_deep")
|
|
128
|
+
consumed = False
|
|
129
|
+
|
|
130
|
+
async def bounded_receive():
|
|
131
|
+
nonlocal consumed
|
|
132
|
+
if not consumed:
|
|
133
|
+
consumed = True
|
|
134
|
+
return {"type": "http.request", "body": bytes(body), "more_body": False}
|
|
135
|
+
return await receive()
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
await self.app(scope, bounded_receive, safe_send)
|
|
139
|
+
except Exception:
|
|
140
|
+
# Exception strings/tracebacks may include request data: log fixed metadata only.
|
|
141
|
+
LOG.error("request_failed request_id=%s", request_id)
|
|
142
|
+
if response_started:
|
|
143
|
+
# An ASGI response cannot restart after headers were sent. Abort
|
|
144
|
+
# the stream with a fixed error, never the original exception
|
|
145
|
+
# message or chained customer content in server tracebacks.
|
|
146
|
+
raise RuntimeError("response_failed") from None
|
|
147
|
+
await reject(503, "temporarily_unavailable")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def create_app(settings=None, store=None):
|
|
151
|
+
settings = settings or Settings.from_env()
|
|
152
|
+
store = store or make_store(settings)
|
|
153
|
+
app = FastAPI(
|
|
154
|
+
title="ReliaMesh", version=__version__, docs_url=None, redoc_url=None,
|
|
155
|
+
responses={
|
|
156
|
+
code: {"model": ErrorResponse, "description": description}
|
|
157
|
+
for code, description in {
|
|
158
|
+
401: "Missing, invalid, or revoked credential",
|
|
159
|
+
403: "Credential lacks the required scope",
|
|
160
|
+
409: "Atomic request rejected due to a conflict or bounded resource limit",
|
|
161
|
+
413: "Request body exceeds the configured size limit",
|
|
162
|
+
415: "Unsupported request content type or encoding",
|
|
163
|
+
422: "Invalid request; rejected input values are never echoed",
|
|
164
|
+
429: "Request rate or daily event quota exceeded",
|
|
165
|
+
503: "Service temporarily unavailable",
|
|
166
|
+
}.items()
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
app.state.store, app.state.settings = store, settings
|
|
170
|
+
tenant_limiter = Limiter(settings.requests_per_minute, 60)
|
|
171
|
+
|
|
172
|
+
def bearer(request):
|
|
173
|
+
header = request.headers.get("authorization", "")
|
|
174
|
+
if not re.fullmatch(r"Bearer rm_[A-Za-z0-9_-]{43}", header):
|
|
175
|
+
raise HTTPException(401, "invalid_credentials")
|
|
176
|
+
return digest_key(header[7:])
|
|
177
|
+
|
|
178
|
+
def require(scope):
|
|
179
|
+
def authenticate(
|
|
180
|
+
request: Request,
|
|
181
|
+
_credential: Annotated[HTTPAuthorizationCredentials | None, Depends(TENANT_BEARER)],
|
|
182
|
+
):
|
|
183
|
+
key_hash = bearer(request)
|
|
184
|
+
principal = store.authenticate(key_hash)
|
|
185
|
+
if not principal or not principal.get("active", True):
|
|
186
|
+
raise HTTPException(401, "invalid_credentials")
|
|
187
|
+
if scope not in principal["scopes"]:
|
|
188
|
+
raise HTTPException(403, "insufficient_scope")
|
|
189
|
+
if not tenant_limiter.allow(principal["tenant_id"]):
|
|
190
|
+
raise HTTPException(429, "rate_limited", headers={"Retry-After": "60"})
|
|
191
|
+
# Bind the eventual storage operation to this credential inside its
|
|
192
|
+
# transaction, including revocation/deletion/recreation races.
|
|
193
|
+
return {**principal, "key_hash": key_hash}
|
|
194
|
+
return authenticate
|
|
195
|
+
|
|
196
|
+
def admin(
|
|
197
|
+
request: Request,
|
|
198
|
+
_credential: Annotated[HTTPAuthorizationCredentials | None, Depends(ADMIN_BEARER)],
|
|
199
|
+
):
|
|
200
|
+
candidate = bearer(request)
|
|
201
|
+
if not settings.admin_hash or not hmac.compare_digest(candidate, settings.admin_hash):
|
|
202
|
+
raise HTTPException(401, "invalid_credentials")
|
|
203
|
+
|
|
204
|
+
@app.exception_handler(RequestValidationError)
|
|
205
|
+
async def invalid_request(request, exc):
|
|
206
|
+
# Even field names can be caller content: return a fixed error only.
|
|
207
|
+
return JSONResponse({"error": "invalid_request", "schema": "/openapi.json"}, status_code=422)
|
|
208
|
+
|
|
209
|
+
@app.exception_handler(HTTPException)
|
|
210
|
+
async def http_error(request, exc):
|
|
211
|
+
return JSONResponse({"error": exc.detail}, status_code=exc.status_code, headers=exc.headers)
|
|
212
|
+
|
|
213
|
+
@app.exception_handler(DetectionError)
|
|
214
|
+
async def detection_error(request, exc):
|
|
215
|
+
return JSONResponse({"error": exc.code}, status_code=409)
|
|
216
|
+
|
|
217
|
+
@app.exception_handler(StorageLimit)
|
|
218
|
+
async def storage_limit(request, exc):
|
|
219
|
+
return JSONResponse({"error": "storage_limit"}, status_code=409)
|
|
220
|
+
|
|
221
|
+
@app.exception_handler(TenantMissing)
|
|
222
|
+
async def tenant_missing(request, exc):
|
|
223
|
+
return JSONResponse({"error": "invalid_credentials"}, status_code=401)
|
|
224
|
+
|
|
225
|
+
@app.get("/health")
|
|
226
|
+
def health():
|
|
227
|
+
return {"status": "ok", "version": __version__}
|
|
228
|
+
|
|
229
|
+
@app.get("/ready", dependencies=[Depends(require("read"))], description="Requires a tenant API key with read scope.")
|
|
230
|
+
def ready():
|
|
231
|
+
if not store.health():
|
|
232
|
+
raise HTTPException(503, "storage_unavailable")
|
|
233
|
+
return {"status": "ready"}
|
|
234
|
+
|
|
235
|
+
@app.post("/v1/admin/tenants", status_code=201, dependencies=[Depends(admin)],
|
|
236
|
+
description="Requires the operator administrator key. Returns the new tenant credential exactly once.")
|
|
237
|
+
def create_tenant(body: TenantRequest):
|
|
238
|
+
key = new_key()
|
|
239
|
+
try:
|
|
240
|
+
store.create_tenant(body.tenant_id, digest_key(key), datetime.now(UTC))
|
|
241
|
+
except TenantExists:
|
|
242
|
+
raise HTTPException(409, "tenant_exists") from None
|
|
243
|
+
return {"tenant_id": body.tenant_id, "key": key, "scopes": ["ingest", "read", "manage"]}
|
|
244
|
+
|
|
245
|
+
@app.post("/v1/events", response_model=IngestReceipt,
|
|
246
|
+
description="Requires a tenant API key with ingest scope. Accepts 1–100 events atomically; identical retained retries are duplicates.")
|
|
247
|
+
def ingest(body: EventBatch, principal: Annotated[dict, Depends(require("ingest"))]):
|
|
248
|
+
now = datetime.now(UTC)
|
|
249
|
+
|
|
250
|
+
def update(state):
|
|
251
|
+
usage = state.get("usage", {})
|
|
252
|
+
today = now.date().isoformat()
|
|
253
|
+
used = usage.get("events", 0) if usage.get("day") == today else 0
|
|
254
|
+
new_state, result = process_events(state, body.events, now)
|
|
255
|
+
if used + result["accepted"] > settings.daily_events:
|
|
256
|
+
raise HTTPException(429, "daily_event_quota", headers={"Retry-After": "3600"})
|
|
257
|
+
new_state["usage"] = {"day": today, "events": used + result["accepted"]}
|
|
258
|
+
return new_state, result
|
|
259
|
+
|
|
260
|
+
result = store.transact(principal["tenant_id"], update, expected_key_hash=principal["key_hash"])
|
|
261
|
+
return {"schema_version": "1.0", **result}
|
|
262
|
+
|
|
263
|
+
@app.get("/v1/summary", description="Requires a tenant API key with read scope. Returns bounded tenant-local window statistics, versions, and incident history.")
|
|
264
|
+
def summary(principal: Annotated[dict, Depends(require("read"))]):
|
|
265
|
+
state = store.read_state(principal["tenant_id"], expected_key_hash=principal["key_hash"])
|
|
266
|
+
if state is None:
|
|
267
|
+
raise HTTPException(401, "invalid_credentials")
|
|
268
|
+
return summarize(state, datetime.now(UTC))
|
|
269
|
+
|
|
270
|
+
@app.get("/v1/incidents", description="Requires a tenant API key with read scope. Returns this tenant's retained incidents only.")
|
|
271
|
+
def incidents(principal: Annotated[dict, Depends(require("read"))]):
|
|
272
|
+
return {"incidents": summary(principal)["incidents"]}
|
|
273
|
+
|
|
274
|
+
@app.get("/v1/keys", description="Requires a tenant API key with manage scope. Returns key identifiers and scopes, never raw credentials or full digests.")
|
|
275
|
+
def keys(principal: Annotated[dict, Depends(require("manage"))]):
|
|
276
|
+
return {"keys": store.list_keys(principal["tenant_id"], expected_key_hash=principal["key_hash"])}
|
|
277
|
+
|
|
278
|
+
@app.post("/v1/keys", status_code=201, description="Requires manage scope. Requested scopes must be a subset of the caller's scopes. The new credential is returned exactly once.")
|
|
279
|
+
def add_key(body: KeyRequest, principal: Annotated[dict, Depends(require("manage"))]):
|
|
280
|
+
if not set(body.scopes).issubset(principal["scopes"]):
|
|
281
|
+
raise HTTPException(403, "scope_escalation")
|
|
282
|
+
key = new_key()
|
|
283
|
+
store.add_key(principal["tenant_id"], digest_key(key), sorted(set(body.scopes)), datetime.now(UTC),
|
|
284
|
+
expected_key_hash=principal["key_hash"])
|
|
285
|
+
return {"key": key, "key_id": digest_key(key)[:12], "scopes": body.scopes}
|
|
286
|
+
|
|
287
|
+
@app.delete("/v1/keys/{key_id}", description="Requires a tenant API key with manage scope. Revokes one key belonging to this tenant.")
|
|
288
|
+
def revoke_key(key_id: str, principal: Annotated[dict, Depends(require("manage"))]):
|
|
289
|
+
if not re.fullmatch(r"[0-9a-f]{12}", key_id):
|
|
290
|
+
raise HTTPException(422, "invalid_key_id")
|
|
291
|
+
removed = store.revoke_key(principal["tenant_id"], key_id, expected_key_hash=principal["key_hash"])
|
|
292
|
+
if not removed:
|
|
293
|
+
raise HTTPException(404, "key_not_found")
|
|
294
|
+
return {"revoked": True}
|
|
295
|
+
|
|
296
|
+
@app.delete("/v1/tenant", description="Requires manage scope and X-ReliaMesh-Confirm-Delete equal to the authenticated tenant ID. Permanently deletes that tenant's current state and keys.")
|
|
297
|
+
def delete_tenant(request: Request, principal: Annotated[dict, Depends(require("manage"))]):
|
|
298
|
+
if request.headers.get("x-reliamesh-confirm-delete") != principal["tenant_id"]:
|
|
299
|
+
raise HTTPException(400, "tenant_confirmation_required")
|
|
300
|
+
store.delete_tenant(principal["tenant_id"], expected_key_hash=principal["key_hash"])
|
|
301
|
+
return {"deleted": True}
|
|
302
|
+
|
|
303
|
+
@app.get("/v1/network", dependencies=[Depends(require("read"))], description="Requires a tenant API key with read scope. Network intelligence is disabled and returns no incidents.")
|
|
304
|
+
def network():
|
|
305
|
+
return {"status": "disabled", "reason": "No verified independent contributor cohort is configured.", "incidents": []}
|
|
306
|
+
|
|
307
|
+
@app.get("/", response_class=HTMLResponse)
|
|
308
|
+
def home():
|
|
309
|
+
return LANDING
|
|
310
|
+
|
|
311
|
+
app.add_middleware(SafetyMiddleware, settings=settings)
|
|
312
|
+
return app
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
LANDING = """<!doctype html><html lang="en"><meta charset="utf-8">
|
|
316
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
317
|
+
<title>ReliaMesh — Reliability infrastructure for AI agents</title>
|
|
318
|
+
<style>body{margin:0;background:#101b24;color:#eaf0f3;font:18px/1.7 system-ui,sans-serif}
|
|
319
|
+
main{max-width:880px;margin:auto;padding:9vh 7vw}small{color:#7edcbd;letter-spacing:.13em}
|
|
320
|
+
h1{font-size:clamp(36px,6vw,66px);line-height:1.12;letter-spacing:-.04em}p{color:#b9cad5;max-width:720px}
|
|
321
|
+
a{color:#7edcbd}code{background:#1d303b;padding:4px 8px}footer{border-top:1px solid #304550;margin-top:70px;padding-top:20px;font-size:14px}
|
|
322
|
+
</style><main><small>RELIAMESH / OPEN-SOURCE INFRASTRUCTURE</small>
|
|
323
|
+
<h1>When an agent runs,<br>did it actually work?</h1>
|
|
324
|
+
<p>Measure agent failures, detect version-associated regressions, and track recovery
|
|
325
|
+
using structured reliability signals. API uptime alone does not tell that story.</p>
|
|
326
|
+
<h2>Reliability without customer content.</h2>
|
|
327
|
+
<p>Send outcomes, durations, counters, and opaque version identifiers. The event contract
|
|
328
|
+
rejects prompts, responses, tool arguments, arbitrary attributes, and exception messages.</p>
|
|
329
|
+
<p>Authenticated ingestion · Python SDK · Deterministic detection · SQLite self-hosting</p>
|
|
330
|
+
<p><a href="https://github.com/PrefilerLabs/reliamesh">Source & self-hosting</a> ·
|
|
331
|
+
<a href="https://github.com/PrefilerLabs/reliamesh/tree/main/sdk/python">Python SDK</a> ·
|
|
332
|
+
<a href="/openapi.json">API contract</a> · <a href="/health">Service health</a></p>
|
|
333
|
+
<h2>Early release. Evidence first.</h2><p>Managed access is provisioned by the operator.
|
|
334
|
+
ReliaMesh 0.1.0 supports tenant-local detection. Cross-organization network intelligence
|
|
335
|
+
is disabled until independent, verified contributor cohorts exist.</p>
|
|
336
|
+
<footer>© 2026 Prefiler Labs Private Limited. ReliaMesh core is licensed under Apache-2.0.</footer>
|
|
337
|
+
</main></html>"""
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
reliamesh/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Operator CLI. Credentials are written once to a user-chosen private file."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sqlite3
|
|
8
|
+
from contextlib import closing
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from reliamesh.config import Settings, make_store
|
|
13
|
+
from reliamesh.security import digest_key, new_key
|
|
14
|
+
from reliamesh.storage import SCHEMA_VERSION
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def write_private(path, data):
|
|
18
|
+
path = Path(path)
|
|
19
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
# Never overwrite a credential file. POSIX mode is private; on Windows keep
|
|
21
|
+
# the containing directory private using the operating system's ACLs.
|
|
22
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
23
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
24
|
+
json.dump(data, handle, indent=2)
|
|
25
|
+
handle.write("\n")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def backup_sqlite(source_path, output_path):
|
|
29
|
+
"""Back up an existing supported database without creating a missing source."""
|
|
30
|
+
source_path, output_path = Path(source_path).expanduser().resolve(), Path(output_path)
|
|
31
|
+
# mode=ro fails on a missing source instead of creating a plausible empty backup.
|
|
32
|
+
with closing(sqlite3.connect(source_path.as_uri() + "?mode=ro", uri=True)) as source:
|
|
33
|
+
if source.execute("PRAGMA user_version").fetchone()[0] != SCHEMA_VERSION:
|
|
34
|
+
raise ValueError("source is not a supported ReliaMesh database")
|
|
35
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
37
|
+
os.close(fd)
|
|
38
|
+
try:
|
|
39
|
+
with closing(sqlite3.connect(output_path)) as target:
|
|
40
|
+
source.backup(target)
|
|
41
|
+
except BaseException:
|
|
42
|
+
# Only remove the output this invocation exclusively created.
|
|
43
|
+
output_path.unlink(missing_ok=True)
|
|
44
|
+
raise
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main():
|
|
48
|
+
parser = argparse.ArgumentParser(description="ReliaMesh operator CLI")
|
|
49
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
50
|
+
serve = commands.add_parser("serve", help="Start the API")
|
|
51
|
+
serve.add_argument("--host", default="127.0.0.1")
|
|
52
|
+
serve.add_argument("--port", type=int, default=8080)
|
|
53
|
+
tenant = commands.add_parser("tenant").add_subparsers(dest="action", required=True)
|
|
54
|
+
create = tenant.add_parser("create")
|
|
55
|
+
create.add_argument("tenant_id")
|
|
56
|
+
create.add_argument("--output", required=True)
|
|
57
|
+
delete = tenant.add_parser("delete")
|
|
58
|
+
delete.add_argument("tenant_id")
|
|
59
|
+
delete.add_argument("--confirm", required=True)
|
|
60
|
+
commands.add_parser("purge", help="Physically remove expired local state")
|
|
61
|
+
bootstrap = commands.add_parser("admin-key", help="Generate an administrator credential")
|
|
62
|
+
bootstrap.add_argument("--output", required=True)
|
|
63
|
+
backup = commands.add_parser("backup", help="Consistent SQLite backup; contains private data")
|
|
64
|
+
backup.add_argument("--output", required=True)
|
|
65
|
+
args = parser.parse_args()
|
|
66
|
+
settings = Settings.from_env()
|
|
67
|
+
if args.command == "serve":
|
|
68
|
+
import uvicorn
|
|
69
|
+
|
|
70
|
+
uvicorn.run("reliamesh.main:app", host=args.host, port=args.port, access_log=False)
|
|
71
|
+
return
|
|
72
|
+
if args.command == "admin-key":
|
|
73
|
+
key = new_key()
|
|
74
|
+
write_private(args.output, {"key": key, "sha256": digest_key(key)})
|
|
75
|
+
print("Administrator credential written. Set RM_ADMIN_HASH to its sha256 value.")
|
|
76
|
+
return
|
|
77
|
+
if args.command == "backup":
|
|
78
|
+
if settings.store != "sqlite":
|
|
79
|
+
parser.error("Use managed Firestore backup/PITR for the cloud backend")
|
|
80
|
+
backup_sqlite(settings.sqlite_path, args.output)
|
|
81
|
+
print("Consistent private SQLite backup created.")
|
|
82
|
+
return
|
|
83
|
+
if args.command == "purge" and settings.store != "sqlite":
|
|
84
|
+
parser.error("purge supports SQLite only; cloud state expiry uses Firestore TTL")
|
|
85
|
+
store = make_store(settings)
|
|
86
|
+
if args.command == "purge":
|
|
87
|
+
print(json.dumps({"purged": store.purge_expired()}))
|
|
88
|
+
return
|
|
89
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{2,63}", args.tenant_id):
|
|
90
|
+
parser.error("Tenant ID must be 3..64 lowercase letters, digits, underscores or hyphens")
|
|
91
|
+
if args.action == "create":
|
|
92
|
+
if Path(args.output).exists():
|
|
93
|
+
parser.error("Output file exists; choose a new private credential path")
|
|
94
|
+
key = new_key()
|
|
95
|
+
store.create_tenant(args.tenant_id, digest_key(key), datetime.now(UTC))
|
|
96
|
+
try:
|
|
97
|
+
write_private(args.output, {"tenant_id": args.tenant_id, "key": key, "scopes": ["ingest", "read", "manage"]})
|
|
98
|
+
except Exception:
|
|
99
|
+
store.delete_tenant(args.tenant_id)
|
|
100
|
+
raise
|
|
101
|
+
print("Tenant created; credential written to private output file.")
|
|
102
|
+
elif args.action == "delete":
|
|
103
|
+
if args.confirm != args.tenant_id:
|
|
104
|
+
parser.error("--confirm must exactly match the tenant ID")
|
|
105
|
+
print(json.dumps({"deleted": store.delete_tenant(args.tenant_id)}))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
main()
|
reliamesh/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Explicit, fail-closed service configuration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Settings:
|
|
10
|
+
store: str = "sqlite"
|
|
11
|
+
sqlite_path: str = ".local/reliamesh.db"
|
|
12
|
+
project: str = "reliamesh"
|
|
13
|
+
database: str = "(default)"
|
|
14
|
+
admin_hash: str = ""
|
|
15
|
+
max_body_bytes: int = 131072
|
|
16
|
+
daily_events: int = 10000
|
|
17
|
+
requests_per_minute: int = 120
|
|
18
|
+
global_requests_per_second: int = 10
|
|
19
|
+
|
|
20
|
+
def __post_init__(self):
|
|
21
|
+
if self.store not in {"sqlite", "firestore"}:
|
|
22
|
+
raise ValueError("RM_STORE must be sqlite or firestore")
|
|
23
|
+
if self.store == "firestore" and self.project != "reliamesh":
|
|
24
|
+
raise ValueError("Cloud project must be exactly reliamesh")
|
|
25
|
+
if self.admin_hash and not re.fullmatch(r"[0-9a-f]{64}", self.admin_hash):
|
|
26
|
+
raise ValueError("RM_ADMIN_HASH must be a SHA256 digest")
|
|
27
|
+
if not 1 <= self.daily_events <= 100000:
|
|
28
|
+
raise ValueError("daily event limit must be 1..100000")
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_env(cls):
|
|
32
|
+
return cls(
|
|
33
|
+
store=os.getenv("RM_STORE", "sqlite"),
|
|
34
|
+
sqlite_path=os.getenv("RM_SQLITE_PATH", ".local/reliamesh.db"),
|
|
35
|
+
project=os.getenv("RM_GCP_PROJECT", "reliamesh"),
|
|
36
|
+
database=os.getenv("RM_DATABASE", "(default)"),
|
|
37
|
+
admin_hash=os.getenv("RM_ADMIN_HASH", ""),
|
|
38
|
+
daily_events=int(os.getenv("RM_DAILY_EVENTS", "10000")),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def make_store(settings):
|
|
43
|
+
from reliamesh.storage import FirestoreStore, SQLiteStore
|
|
44
|
+
|
|
45
|
+
if settings.store == "firestore":
|
|
46
|
+
return FirestoreStore(project=settings.project, database=settings.database)
|
|
47
|
+
return SQLiteStore(settings.sqlite_path)
|