2026-09-03 21:15:42 +02:00
|
|
|
"""Encrypted, tenant-scoped AI provider configuration storage.
|
|
|
|
|
|
|
|
|
|
The key is generated under the private data volume and is never stored in SQLite.
|
|
|
|
|
The small authenticated stream construction here uses HMAC-SHA256 for the
|
|
|
|
|
keystream and integrity tag; ciphertext is prefixed with ``pc1`` and never
|
|
|
|
|
returned by the API.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import base64
|
|
|
|
|
import hashlib
|
|
|
|
|
import hmac
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import secrets
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
|
|
|
|
MAX_SECRET = 4096
|
|
|
|
|
MAX_MODEL = 160
|
|
|
|
|
ALLOWED_PROVIDERS = {"nous_portal", "nous_portal_web_research"}
|
|
|
|
|
DEFAULT_NOUS_URL = "https://inference-api.nousresearch.com/v1"
|
2026-09-03 21:36:50 +02:00
|
|
|
DEFAULT_FIRECRAWL_URL = "https://api.firecrawl.dev/v2"
|
2026-09-03 22:04:58 +02:00
|
|
|
DEFAULT_SEARXNG_URL = "http://searxng:8080"
|
2026-09-03 21:15:42 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def key_path() -> Path:
|
|
|
|
|
return Path(os.environ.get("PROVIDER_CONFIG_KEY_FILE", "/data/provider-config.key")).expanduser()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _key() -> bytes:
|
|
|
|
|
path = key_path()
|
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
if path.exists():
|
|
|
|
|
key = path.read_bytes()
|
|
|
|
|
if len(key) != 32:
|
|
|
|
|
raise RuntimeError("invalid_provider_config_key")
|
|
|
|
|
return key
|
|
|
|
|
key = secrets.token_bytes(32)
|
|
|
|
|
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
|
|
|
fd = os.open(path, flags, 0o600)
|
|
|
|
|
try:
|
|
|
|
|
os.write(fd, key)
|
|
|
|
|
finally:
|
|
|
|
|
os.close(fd)
|
|
|
|
|
os.chmod(path, 0o600)
|
|
|
|
|
return key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _stream(key: bytes, nonce: bytes, size: int) -> bytes:
|
|
|
|
|
return b"".join(hmac.new(key, nonce + i.to_bytes(8, "big"), hashlib.sha256).digest() for i in range((size + 31) // 32))[:size]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def encrypt(value: str) -> str:
|
|
|
|
|
raw = value.encode("utf-8")
|
|
|
|
|
nonce = secrets.token_bytes(16)
|
|
|
|
|
cipher = bytes(a ^ b for a, b in zip(raw, _stream(_key(), nonce, len(raw))))
|
|
|
|
|
tag = hmac.new(_key(), nonce + cipher, hashlib.sha256).digest()
|
|
|
|
|
return "pc1:" + base64.urlsafe_b64encode(nonce + tag + cipher).decode("ascii")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def decrypt(value: str) -> str:
|
|
|
|
|
if not isinstance(value, str) or not value.startswith("pc1:"):
|
|
|
|
|
raise ValueError("invalid_ciphertext")
|
|
|
|
|
raw = base64.urlsafe_b64decode(value[4:].encode("ascii"))
|
|
|
|
|
nonce, tag, cipher = raw[:16], raw[16:48], raw[48:]
|
|
|
|
|
key = _key()
|
|
|
|
|
if not hmac.compare_digest(tag, hmac.new(key, nonce + cipher, hashlib.sha256).digest()):
|
|
|
|
|
raise ValueError("invalid_ciphertext")
|
|
|
|
|
return bytes(a ^ b for a, b in zip(cipher, _stream(key, nonce, len(cipher)))).decode("utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_payload(payload: dict) -> dict:
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
raise ValueError("invalid_config")
|
|
|
|
|
provider = str(payload.get("provider", "nous_portal")).strip().lower()
|
|
|
|
|
if provider not in ALLOWED_PROVIDERS:
|
|
|
|
|
raise ValueError("invalid_provider")
|
|
|
|
|
model = str(payload.get("model", "Hermes-4-405B")).strip()
|
|
|
|
|
if not model or len(model) > MAX_MODEL:
|
|
|
|
|
raise ValueError("invalid_model")
|
|
|
|
|
enabled = payload.get("enabled", True)
|
|
|
|
|
if not isinstance(enabled, bool):
|
|
|
|
|
raise ValueError("invalid_enabled")
|
2026-09-03 22:04:58 +02:00
|
|
|
urls = {"nous_base_url": DEFAULT_NOUS_URL, "firecrawl_base_url": str(payload.get("searxng_base_url", DEFAULT_SEARXNG_URL)).strip() or DEFAULT_SEARXNG_URL}
|
2026-09-03 21:15:42 +02:00
|
|
|
for field, default in urls.items():
|
|
|
|
|
value = str(payload.get(field, default)).strip().rstrip("/")
|
|
|
|
|
parsed = urlparse(value)
|
2026-09-03 21:49:02 +02:00
|
|
|
if (parsed.scheme != "https" and not (field == "firecrawl_base_url" and parsed.scheme == "http" and parsed.hostname == "searxng")) or not parsed.hostname or parsed.username or parsed.password or parsed.fragment or parsed.query:
|
2026-09-03 21:15:42 +02:00
|
|
|
raise ValueError("unsafe_provider_url")
|
|
|
|
|
urls[field] = value
|
|
|
|
|
credentials = payload.get("credentials", {})
|
|
|
|
|
if not isinstance(credentials, dict):
|
|
|
|
|
raise ValueError("invalid_credentials")
|
|
|
|
|
result = {"provider": provider, "model": model, "enabled": enabled, **urls, "credentials": {}}
|
|
|
|
|
for name in ("nous_api_key", "firecrawl_api_key"):
|
|
|
|
|
if name in credentials:
|
|
|
|
|
value = credentials[name]
|
|
|
|
|
if not isinstance(value, str) or not value or len(value) > MAX_SECRET:
|
|
|
|
|
raise ValueError("invalid_secret")
|
|
|
|
|
result["credentials"][name] = value
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def safe_status(row, *, env_bootstrap=False) -> dict:
|
|
|
|
|
if not row:
|
|
|
|
|
return {"provider": "", "status": "not_configured", "configured": False, "enabled": False, "network_enabled": False, "outbound_calls": False, "source": "env_bootstrap" if env_bootstrap else "none"}
|
|
|
|
|
return {"provider": row["provider"], "model": row["model"], "enabled": bool(row["enabled"]), "configured": bool(row["credentials_ciphertext"]), "status": "ready" if row["enabled"] and row["credentials_ciphertext"] else "disabled", "network_enabled": bool(row["enabled"] and row["credentials_ciphertext"]), "outbound_calls": bool(row["enabled"] and row["credentials_ciphertext"]), "source": "database", "nous_host": urlparse(row["nous_base_url"]).hostname, "firecrawl_host": urlparse(row["firecrawl_base_url"]).hostname, "updated_at": row["updated_at"]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_connectivity(row) -> dict:
|
|
|
|
|
"""Make only bounded GET requests to fixed configured HTTPS hosts."""
|
|
|
|
|
if not row or not row["enabled"] or not row["credentials_ciphertext"]:
|
|
|
|
|
return {"status": "not_configured", "network_calls": 0, "outbound_calls": False}
|
|
|
|
|
credentials = json.loads(decrypt(row["credentials_ciphertext"]))
|
|
|
|
|
checks = []
|
|
|
|
|
for label, url, key_name in (("nous", row["nous_base_url"] + "/models", "nous_api_key"), ("firecrawl", row["firecrawl_base_url"], "firecrawl_api_key")):
|
|
|
|
|
key = credentials.get(key_name)
|
|
|
|
|
if not isinstance(key, str) or not key:
|
|
|
|
|
checks.append({"provider": label, "ok": False})
|
|
|
|
|
continue
|
|
|
|
|
request = Request(url, headers={"Accept": "application/json", "Authorization": "Bearer " + key}, method="GET")
|
|
|
|
|
try:
|
|
|
|
|
with urlopen(request, timeout=3) as response:
|
|
|
|
|
response.read(8193)
|
|
|
|
|
checks.append({"provider": label, "ok": 200 <= response.status < 500})
|
|
|
|
|
except Exception:
|
|
|
|
|
checks.append({"provider": label, "ok": False})
|
|
|
|
|
return {"status": "ready" if all(x["ok"] for x in checks) else "unavailable", "network_calls": len(checks), "outbound_calls": False, "checks": checks}
|