mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-03 16:56:13 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ffebe6d91 |
@@ -34,6 +34,14 @@ UPSTREAM_API_KEY=your-upstream-api-key
|
|||||||
# LOG_LEVEL=INFO
|
# LOG_LEVEL=INFO
|
||||||
# ENABLE_CONSOLE_LOGGING=true
|
# ENABLE_CONSOLE_LOGGING=true
|
||||||
|
|
||||||
|
# SentryStr: mirror logs to Nostr relays (requires `pip install sentrystr`).
|
||||||
|
# Announcements run on a background worker thread so they never block requests.
|
||||||
|
# ENABLE_SENTRYSTR=false
|
||||||
|
# SENTRYSTR_LOG_LEVEL=ERROR # defaults to ERROR; keep >= LOG_LEVEL
|
||||||
|
# SENTRYSTR_RELAYS="wss://relay.damus.io,wss://nos.lol" # defaults to RELAYS
|
||||||
|
# SENTRYSTR_NSEC=nsec1... # defaults to NSEC; ephemeral key if unset
|
||||||
|
# SENTRYSTR_RECIPIENT_NPUB=npub1... # if set, log events are NIP-44 encrypted
|
||||||
|
|
||||||
# Custom Model Management
|
# Custom Model Management
|
||||||
# BASE_URL=https://openrouter.ai/api/v1
|
# BASE_URL=https://openrouter.ai/api/v1
|
||||||
# MODELS_PATH=models.json
|
# MODELS_PATH=models.json
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ dependencies = [
|
|||||||
"litellm>=1.55.0",
|
"litellm>=1.55.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
# Decentralized log mirroring to Nostr relays. Enable at runtime with
|
||||||
|
# ENABLE_SENTRYSTR=true (see routstr/core/logging.py).
|
||||||
|
sentrystr = ["sentrystr>=0.2.0"]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"mypy>=1.15.0",
|
"mypy>=1.15.0",
|
||||||
|
|||||||
+150
-21
@@ -232,32 +232,47 @@ class SecurityFilter(logging.Filter):
|
|||||||
"refund_address",
|
"refund_address",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _scrub(self, message: str) -> str:
|
||||||
|
"""Redact secrets from a single string."""
|
||||||
|
standalone_patterns = [
|
||||||
|
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
|
||||||
|
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
|
||||||
|
r"nsec[a-z0-9]+", # Nostr Public / Private Key
|
||||||
|
]
|
||||||
|
for pattern in standalone_patterns:
|
||||||
|
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
for key in self.SENSITIVE_KEYS:
|
||||||
|
if key in message.lower():
|
||||||
|
key_patterns = [
|
||||||
|
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
|
||||||
|
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
|
||||||
|
]
|
||||||
|
for pattern in key_patterns:
|
||||||
|
message = re.sub(
|
||||||
|
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
|
||||||
|
)
|
||||||
|
return message
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
"""Filter out sensitive information from log records."""
|
"""Filter out sensitive information from log records."""
|
||||||
try:
|
try:
|
||||||
message = record.getMessage()
|
message = redact_org_ids(record.getMessage())
|
||||||
message = redact_org_ids(message)
|
record.msg = self._scrub(message)
|
||||||
standalone_patterns = [
|
|
||||||
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
|
|
||||||
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
|
|
||||||
r"nsec[a-z0-9]+", # Nostr Public / Private Key
|
|
||||||
]
|
|
||||||
for pattern in standalone_patterns:
|
|
||||||
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
|
|
||||||
|
|
||||||
for key in self.SENSITIVE_KEYS:
|
|
||||||
if key in message.lower():
|
|
||||||
key_patterns = [
|
|
||||||
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
|
|
||||||
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
|
|
||||||
]
|
|
||||||
for pattern in key_patterns:
|
|
||||||
message = re.sub(
|
|
||||||
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
|
|
||||||
)
|
|
||||||
record.msg = message
|
|
||||||
record.args = ()
|
record.args = ()
|
||||||
|
|
||||||
|
# The exception traceback is appended by the *formatter* (e.g. when a
|
||||||
|
# QueueHandler flattens the record before it is announced to the
|
||||||
|
# public, permanent SentryStr relays), so the message scrub above
|
||||||
|
# never sees it. Pre-render and redact it here and cache the result
|
||||||
|
# in `exc_text` so every formatter reuses the scrubbed version.
|
||||||
|
if record.exc_info and record.exc_info[0] is not None and not record.exc_text:
|
||||||
|
record.exc_text = logging.Formatter().formatException(record.exc_info)
|
||||||
|
if record.exc_text:
|
||||||
|
record.exc_text = self._scrub(redact_org_ids(record.exc_text))
|
||||||
|
if record.stack_info:
|
||||||
|
record.stack_info = self._scrub(redact_org_ids(record.stack_info))
|
||||||
|
|
||||||
# Structured `extra={...}` fields are emitted by the JSON formatter
|
# Structured `extra={...}` fields are emitted by the JSON formatter
|
||||||
# straight from the record dict and never pass through the message
|
# straight from the record dict and never pass through the message
|
||||||
# formatting above. Redact organization IDs from any string-valued
|
# formatting above. Redact organization IDs from any string-valued
|
||||||
@@ -452,6 +467,120 @@ def setup_logging() -> None:
|
|||||||
|
|
||||||
logging.config.dictConfig(LOGGING_CONFIG)
|
logging.config.dictConfig(LOGGING_CONFIG)
|
||||||
|
|
||||||
|
_install_sentrystr_logging(LOGGING_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
# Handle for the background SentryStr logging worker (None when disabled).
|
||||||
|
_sentrystr_handle: Any = None
|
||||||
|
|
||||||
|
# Bound on the in-memory queue feeding the background SentryStr worker. Records
|
||||||
|
# beyond this are dropped rather than blocking the caller or growing memory
|
||||||
|
# without limit when a relay is slow or unreachable.
|
||||||
|
_SENTRYSTR_QUEUE_SIZE = 10_000
|
||||||
|
|
||||||
|
# Max seconds to wait for the queued backlog to flush on shutdown before
|
||||||
|
# abandoning it, so shutdown cannot hang on a slow or unreachable relay.
|
||||||
|
_SENTRYSTR_SHUTDOWN_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def _install_sentrystr_logging(logging_config: dict[str, Any]) -> None:
|
||||||
|
"""Mirror routstr application logs to Nostr relays via SentryStr.
|
||||||
|
|
||||||
|
Announcing to relays blocks, so SentryStr's installer runs the publish on a
|
||||||
|
dedicated background worker thread fed by an in-memory queue; the request /
|
||||||
|
event-loop threads only pay the cost of enqueueing a record. Disabled unless
|
||||||
|
`ENABLE_SENTRYSTR` is set. Any failure here is swallowed so optional logging
|
||||||
|
can never take down startup.
|
||||||
|
"""
|
||||||
|
global _sentrystr_handle
|
||||||
|
|
||||||
|
if _sentrystr_handle is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .settings import settings
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not getattr(settings, "enable_sentrystr", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
routstr_logger = logging.getLogger("routstr")
|
||||||
|
|
||||||
|
relays = list(settings.sentrystr_relays or settings.relays or [])
|
||||||
|
if not relays:
|
||||||
|
routstr_logger.warning(
|
||||||
|
"SentryStr logging is enabled but no relays are configured; set "
|
||||||
|
"SENTRYSTR_RELAYS or RELAYS. Skipping SentryStr logging."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from sentrystr import install_sentrystr_logging
|
||||||
|
except Exception:
|
||||||
|
routstr_logger.warning(
|
||||||
|
"SentryStr logging is enabled but the `sentrystr` package is not "
|
||||||
|
"installed. Install it with `pip install sentrystr`. Skipping "
|
||||||
|
"SentryStr logging."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Default to ERROR: these events are announced to public, permanent relays,
|
||||||
|
# so only genuine problems should be mirrored unless the operator opts into
|
||||||
|
# something more verbose via SENTRYSTR_LOG_LEVEL. (Keep this at or above
|
||||||
|
# LOG_LEVEL; a lower value is gated out by the routstr loggers' own level.)
|
||||||
|
level = (settings.sentrystr_log_level or "ERROR").upper()
|
||||||
|
|
||||||
|
# routstr application loggers set propagate=False, so the handler must be
|
||||||
|
# attached to each of them rather than relying on propagation to the root.
|
||||||
|
routstr_loggers = [
|
||||||
|
name
|
||||||
|
for name in logging_config.get("loggers", {})
|
||||||
|
if name == "routstr" or name.startswith("routstr.")
|
||||||
|
] or ["routstr"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
_sentrystr_handle = install_sentrystr_logging(
|
||||||
|
relays=relays,
|
||||||
|
private_key=(settings.sentrystr_nsec or settings.nsec or None),
|
||||||
|
level=level,
|
||||||
|
loggers=routstr_loggers,
|
||||||
|
recipient_pubkey=(settings.sentrystr_recipient_npub or None),
|
||||||
|
platform="routstr",
|
||||||
|
formatter=logging.Formatter("%(message)s"),
|
||||||
|
# Run the same scrubbing/enrichment filters used by the other
|
||||||
|
# handlers, on the calling thread, before records are handed to the
|
||||||
|
# background worker. RequestIdFilter must run here because it reads a
|
||||||
|
# ContextVar that is only set on the request thread; SecurityFilter
|
||||||
|
# must run here so secrets are redacted before anything is announced
|
||||||
|
# to the (public, permanent) relays. Passing them to the installer
|
||||||
|
# attaches them before the handler goes live, so no record can slip
|
||||||
|
# through unfiltered during startup.
|
||||||
|
filters=[RequestIdFilter(), SecurityFilter()],
|
||||||
|
# Bound the queue so a slow/unreachable relay can't grow memory.
|
||||||
|
queue_size=_SENTRYSTR_QUEUE_SIZE,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
routstr_logger.warning("Failed to initialize SentryStr logging: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
routstr_logger.info(
|
||||||
|
"SentryStr logging enabled",
|
||||||
|
extra={"sentrystr_level": level, "sentrystr_relay_count": len(relays)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown_sentrystr_logging() -> None:
|
||||||
|
"""Flush and stop the background SentryStr logging worker, if running."""
|
||||||
|
global _sentrystr_handle
|
||||||
|
handle = _sentrystr_handle
|
||||||
|
_sentrystr_handle = None
|
||||||
|
if handle is not None:
|
||||||
|
try:
|
||||||
|
handle.close(timeout=_SENTRYSTR_SHUTDOWN_TIMEOUT)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_logger(name: str) -> logging.Logger:
|
def get_logger(name: str) -> logging.Logger:
|
||||||
"""Get a logger instance for the given module name."""
|
"""Get a logger instance for the given module name."""
|
||||||
|
|||||||
@@ -230,6 +230,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
extra={"error": str(e), "error_type": type(e).__name__},
|
extra={"error": str(e), "error_type": type(e).__name__},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Flush and stop the background SentryStr logging worker (no-op when
|
||||||
|
# SentryStr logging is disabled). Run off the event loop: the drain is
|
||||||
|
# blocking (though bounded) and must not stall the loop during shutdown.
|
||||||
|
from .logging import shutdown_sentrystr_logging
|
||||||
|
|
||||||
|
await asyncio.to_thread(shutdown_sentrystr_logging)
|
||||||
|
|
||||||
|
|
||||||
class _ImmutableStaticFiles(StaticFiles):
|
class _ImmutableStaticFiles(StaticFiles):
|
||||||
"""Static files with long Cache-Control for content-hashed Next.js assets.
|
"""Static files with long Cache-Control for content-hashed Next.js assets.
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_env_var(cls, field_name: str, raw_value: str) -> Any: # type: ignore[override]
|
def parse_env_var(cls, field_name: str, raw_value: str) -> Any: # type: ignore[override]
|
||||||
if field_name in {"cashu_mints", "cors_origins", "relays"}:
|
if field_name in {
|
||||||
|
"cashu_mints",
|
||||||
|
"cors_origins",
|
||||||
|
"relays",
|
||||||
|
"sentrystr_relays",
|
||||||
|
}:
|
||||||
v = str(raw_value).strip()
|
v = str(raw_value).strip()
|
||||||
if v == "":
|
if v == "":
|
||||||
return []
|
return []
|
||||||
@@ -103,6 +108,21 @@ class Settings(BaseSettings):
|
|||||||
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
||||||
enable_console_logging: bool = Field(default=True, env="ENABLE_CONSOLE_LOGGING")
|
enable_console_logging: bool = Field(default=True, env="ENABLE_CONSOLE_LOGGING")
|
||||||
|
|
||||||
|
# SentryStr (decentralized logging to Nostr relays). When enabled, routstr
|
||||||
|
# application logs at `sentrystr_log_level` and above are mirrored to Nostr
|
||||||
|
# relays on a background worker thread (see routstr/core/logging.py).
|
||||||
|
enable_sentrystr: bool = Field(default=False, env="ENABLE_SENTRYSTR")
|
||||||
|
# Minimum level forwarded to relays. Empty -> defaults to ERROR. Should be
|
||||||
|
# at or above `log_level`, since the routstr loggers gate lower records.
|
||||||
|
sentrystr_log_level: str = Field(default="", env="SENTRYSTR_LOG_LEVEL")
|
||||||
|
# Relays to announce to. Empty -> fall back to the discovery `relays`.
|
||||||
|
sentrystr_relays: list[str] = Field(default_factory=list, env="SENTRYSTR_RELAYS")
|
||||||
|
# Secret key (nsec or hex) used to sign log events. Empty -> fall back to
|
||||||
|
# `nsec`; if that is also empty an ephemeral key is generated per process.
|
||||||
|
sentrystr_nsec: str = Field(default="", env="SENTRYSTR_NSEC")
|
||||||
|
# Optional recipient npub. When set, log events are NIP-44 encrypted to it.
|
||||||
|
sentrystr_recipient_npub: str = Field(default="", env="SENTRYSTR_RECIPIENT_NPUB")
|
||||||
|
|
||||||
# Other
|
# Other
|
||||||
chat_completions_api_version: str = Field(
|
chat_completions_api_version: str = Field(
|
||||||
default="", env="CHAT_COMPLETIONS_API_VERSION"
|
default="", env="CHAT_COMPLETIONS_API_VERSION"
|
||||||
|
|||||||
Reference in New Issue
Block a user