Files
OpenViking/openviking/server/config.py
T
dingbenandTRAE CLI 9097fef478 feat(server): refresh read-replica API key index via store watcher (#3857)
Read replicas load the API key store once at startup and never rewrite
it, so a user registered/rotated/removed on the writer stays invisible
(new key -> "Invalid API Key"; removed key -> still accepted).

Add an optional background watcher that polls the shared key store and
reloads the in-memory index only when it actually changes:

- APIKeyManager.reload(): strictly read-only refresh that rebuilds state
  and swaps it in atomically, never writing or migrating plaintext keys.
- compute_store_signature(): cheap (path, size, modTime) signature over
  accounts.json + every users.json so the watcher skips unchanged polls.
- ApiKeyAuthPlugin starts/stops the watcher behind api_key_watch_enabled
  (default off) with api_key_watch_interval_seconds; AuthPlugin.shutdown()
  is wired into app shutdown to cancel it cleanly.

Add coverage for reload convergence, read-only/no-migrate guarantees,
uninitialized-store tolerance, signature change detection, and watcher
reload/skip/shutdown behavior.

Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-08-10 11:28:52 +08:00

535 lines
20 KiB
Python

# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
# SPDX-License-Identifier: AGPL-3.0
"""Server configuration for OpenViking HTTP Server."""
import sys
from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field, ValidationError, field_validator
# Import auth plugin registry for config validation
from openviking.server.auth.ldap_config import LDAPConfig
from openviking.server.auth.oidc_config import OIDCConfig
from openviking.server.auth.registry import get_registry
from openviking.server.identity import AuthMode
from openviking_cli.utils import get_logger
from openviking_cli.utils.config.config_loader import (
load_json_config,
resolve_config_path,
)
from openviking_cli.utils.config.config_utils import format_validation_error
from openviking_cli.utils.config.consts import (
DEFAULT_CONFIG_DIR,
DEFAULT_OV_CONF,
OPENVIKING_CONFIG_ENV,
SYSTEM_CONFIG_DIR,
)
logger = get_logger(__name__)
def _normalize_config_uri(value: Optional[str], field_name: str) -> Optional[str]:
if value is None:
return None
if not isinstance(value, str):
raise ValueError(f"{field_name} must be a string")
uri = value.strip().rstrip("/")
if not uri:
raise ValueError(f"{field_name} must not be empty")
return uri
class AddTargetsConfig(BaseModel):
"""Add targets for resource and skill writes."""
resource_uri: Optional[str] = None
skill_uri: Optional[str] = None
model_config = {"extra": "forbid"}
@field_validator("resource_uri")
@classmethod
def validate_resource_uri(cls, value: Optional[str]) -> Optional[str]:
uri = _normalize_config_uri(value, "resource_uri")
if uri is None:
return None
from openviking.core.namespace import classify_uri, uri_parts
from openviking.core.uri_validation import validate_viking_uri
from openviking_cli.utils.uri import VikingURI
validate_viking_uri(uri, field_name="resource_uri")
normalized = VikingURI.normalize(uri).rstrip("/")
parts = uri_parts(normalized)
classification = classify_uri(normalized)
if parts[:1] == ["resources"] or (
parts[:1] == ["user"]
and classification.context_type == "resource"
and classification.content_index is not None
):
return normalized
raise ValueError("resource_uri must be a resource directory URI")
@field_validator("skill_uri")
@classmethod
def validate_skill_uri(cls, value: Optional[str]) -> Optional[str]:
uri = _normalize_config_uri(value, "skill_uri")
if uri is None:
return None
from openviking_cli.utils.uri import VikingURI
normalized = VikingURI.normalize(uri).rstrip("/")
if normalized in {"viking://user/skills", "viking://agent/skills"}:
return normalized
raise ValueError("skill_uri must be viking://user/skills or viking://agent/skills")
class AgentEvolutionConfig(BaseModel):
"""Default Agent Evolution setting for accounts without an override."""
enabled: bool = False
model_config = {"extra": "forbid"}
class DeprecatedUserAgentEvolutionConfig(BaseModel):
"""Parse-only compatibility for legacy per-user configuration files."""
enabled: Optional[bool] = None
model_config = {"extra": "forbid"}
class UserConfig(BaseModel):
"""User configuration values that can be defaulted or initialized."""
add_targets: AddTargetsConfig = Field(default_factory=AddTargetsConfig)
agent_evolution: DeprecatedUserAgentEvolutionConfig = Field(
default_factory=DeprecatedUserAgentEvolutionConfig,
exclude=True,
)
model_config = {"extra": "forbid"}
class MetricsAccountDimensionConfig(BaseModel):
"""Account-dimension configuration for metrics label injection."""
# Enabled by default, but still allowlist-gated to avoid accidental high-cardinality exposure.
enabled: bool = True
max_active_accounts: int = 100
metric_allowlist: List[str] = Field(default_factory=list)
model_config = {"extra": "forbid"}
class PrometheusExporterConfig(BaseModel):
"""Prometheus exporter configuration."""
enabled: bool = True
model_config = {"extra": "forbid"}
class OTelExporterConfig(BaseModel):
"""OpenTelemetry exporter configuration."""
class TLSConfig(BaseModel):
"""TLS configuration for OTLP exporters."""
insecure: bool = False
model_config = {"extra": "forbid"}
enabled: bool = False
protocol: str = "grpc" # "grpc", "http", or "local" for traces
tls: TLSConfig = Field(default_factory=TLSConfig)
endpoint: str = "localhost:4317" # gRPC default: 4317; HTTP default: 4318
service_name: str = "openviking-server"
export_interval_ms: int = 10000
headers: Dict[str, str] = Field(default_factory=dict)
local_path: str = "~/.openviking/logs/traces.jsonl"
local_rotation_mb: int = Field(default=40, gt=0)
local_backup_count: int = Field(default=2, ge=0)
model_config = {"extra": "forbid"}
class MetricsExportersConfig(BaseModel):
"""Metrics exporters configuration."""
prometheus: PrometheusExporterConfig = Field(default_factory=PrometheusExporterConfig)
otel: OTelExporterConfig = Field(default_factory=OTelExporterConfig)
model_config = {"extra": "forbid"}
class MetricsConfig(BaseModel):
"""Metrics subsystem configuration."""
enabled: bool = False
bot_data_path: Optional[str] = None
account_dimension: MetricsAccountDimensionConfig = Field(
default_factory=MetricsAccountDimensionConfig
)
exporters: MetricsExportersConfig = Field(default_factory=MetricsExportersConfig)
model_config = {"extra": "forbid"}
class UsageAuditConfig(BaseModel):
"""Product usage and audit projection configuration."""
enabled: bool = True
backend: Literal["sqlite"] = "sqlite"
sqlite_path: Optional[str] = None
queue_size: int = Field(10_000, gt=0)
batch_size: int = Field(500, gt=0)
flush_interval_seconds: float = Field(1.0, gt=0)
shutdown_flush_timeout_seconds: float = Field(3.0, gt=0)
usage_retention_days: int = Field(14, ge=0)
audit_retention_days: int = Field(7, ge=0)
audit_retention_per_account: int = Field(1000, ge=0)
timezone: str = "local"
inventory_ttl_seconds: float = Field(10.0, ge=0)
model_config = {"extra": "forbid"}
class UsageReporterSinkConfig(BaseModel):
"""Usage reporter sink configuration."""
type: Literal["custom", "file_log"] = "custom"
class_path: Optional[str] = None
config: Dict[str, object] = Field(default_factory=dict)
model_config = {"extra": "forbid"}
class UsageReporterConfig(BaseModel):
"""Usage event reporter configuration."""
enabled: bool = False
extractors: List[Literal["memory_usage"]] = Field(default_factory=lambda: ["memory_usage"])
sinks: List[UsageReporterSinkConfig] = Field(default_factory=list)
model_config = {"extra": "forbid"}
class TraceDumpBodyConfig(BaseModel):
"""HTTP body dump configuration.
Attaches request/response bodies as attributes on the active trace span so
they can be inspected in trace UIs. Off by default — bodies may contain
secrets and high-cardinality content.
"""
enabled: bool = False
max_bytes: int = 4096
model_config = {"extra": "forbid"}
class ObservabilityConfig(BaseModel):
"""Server-side observability configuration."""
metrics: MetricsConfig = Field(default_factory=MetricsConfig)
usage_audit: UsageAuditConfig = Field(default_factory=UsageAuditConfig)
traces: OTelExporterConfig = Field(default_factory=OTelExporterConfig)
logs: OTelExporterConfig = Field(default_factory=OTelExporterConfig)
dump_body: TraceDumpBodyConfig = Field(default_factory=TraceDumpBodyConfig)
model_config = {"extra": "forbid"}
class TempUploadConfig(BaseModel):
"""Temporary upload configuration."""
default_mode: Literal["local", "shared"] = "local"
shared_max_size_bytes: int = 512 * 1024 * 1024
shared_prefix: str = "viking://upload"
model_config = {"extra": "forbid"}
class ToolOutputExternalizationConfig(BaseModel):
"""External storage controls for oversized tool outputs."""
enabled: bool = True
threshold_chars: int = 20_000
preview_chars: int = 2_000
assistant_turn_inline_budget_chars: int = 100_000
assistant_turn_preview_budget_chars: int = 10_000
min_preview_chars: int = 1_000
aggregate_selection_strategy: Literal["largest_first"] = "largest_first"
failure_mode: Literal["reject", "preserve_raw", "preview_only"] = "preserve_raw"
model_config = {"extra": "forbid"}
class ServerConfig(BaseModel):
host: str = "127.0.0.1"
port: int = 1933
workers: int = 1
# Seconds an idle HTTP keep-alive connection is kept open before the server
# closes it. Defaults to 5 to match uvicorn's built-in default and preserve
# the existing service behavior. Raise it above the idle-connection lifetime
# of any upstream client or load balancer that reuses connections (e.g. the
# serverless VKE forwarder keeps idle connections for up to 60s via
# WithMaxIdleConnDuration(1*time.Minute)); otherwise the server may close
# connections the client still believes are reusable, causing sporadic
# connection-reset / EOF errors.
timeout_keep_alive: int = 5
auth_mode: Optional[str] = None # If None, auto-detect based on root_api_key
root_api_key: Optional[str] = None
# OIDC/LDAP authentication configuration
oidc: Optional[OIDCConfig] = None
ldap: Optional[LDAPConfig] = None
profile_enabled: bool = False
cors_origins: List[str] = Field(default_factory=lambda: ["*"])
with_bot: bool = False # Enable Bot API proxy to Vikingbot
bot_api_url: str = "http://localhost:18790" # Vikingbot OpenAPIChannel URL (default port)
encryption_enabled: bool = False # Whether file-level AES encryption is enabled
api_key_hashing_enabled: bool = False # Whether API key Argon2id hashing is enabled (default: false, rely on file encryption)
# When true, poll the shared key store and reload the in-memory index on change so
# read replicas pick up writer-side user add/rotate/remove. Default off (single writer).
api_key_watch_enabled: bool = False
# Poll interval; each check only stats registry files and reads fully on change.
api_key_watch_interval_seconds: float = 30.0
observability: ObservabilityConfig = Field(default_factory=ObservabilityConfig)
usage_reporter: UsageReporterConfig = Field(default_factory=UsageReporterConfig)
# Public-facing base URL emitted in MCP-issued upload instructions. See
# ``openviking.server.mcp_endpoint._resolve_public_base_url`` for the full
# resolution chain: env var > this field > X-Forwarded-Host/Proto > Host header
# > listen-address fallback. Set this (or the env var) when the server runs
# behind a reverse proxy that does not forward X-Forwarded-* headers.
public_base_url: Optional[str] = None
upload_signed_ttl_seconds: int = 600
temp_upload: TempUploadConfig = Field(default_factory=TempUploadConfig)
user_config_defaults: UserConfig = Field(default_factory=UserConfig)
agent_evolution: AgentEvolutionConfig = Field(default_factory=AgentEvolutionConfig)
tool_output_externalization: ToolOutputExternalizationConfig = Field(
default_factory=ToolOutputExternalizationConfig
)
model_config = {"extra": "forbid"}
def get_effective_auth_mode(self) -> str:
"""Get effective auth mode, auto-detecting if not explicitly set.
- If root_api_key is configured (non-empty) and auth_mode is None: api_key
- If root_api_key is not configured and auth_mode is None: dev
"""
auth_mode_text = str(self.auth_mode).strip() if self.auth_mode is not None else ""
if auth_mode_text:
return auth_mode_text
if self.root_api_key is not None and str(self.root_api_key).strip():
return AuthMode.API_KEY.value
return AuthMode.DEV.value
def map_bind_host_to_loopback(host: str) -> str:
"""Map a server *bind* host to a client-connectable *loopback* host.
``server.host`` configures the address the server binds/listens on. A
wildcard bind address such as ``0.0.0.0`` (or IPv6 ``::``) is valid to
listen on but is *not* a destination a client can connect to. Clients that
derive a connect URL from the configured host (the bot proxy, the bot's
own config loader, ``doctor``) must translate the wildcard to loopback:
- ``"0.0.0.0"``, ``""``, ``"*"`` -> ``"127.0.0.1"``
- ``"::"``, ``"::0"``, ``"[::]"`` -> ``"[::1]"``
- bare IPv6 literals (e.g. ``"::1"``) are bracketed for URL syntax
- everything else passes through unchanged
"""
host = str(host or "").strip()
if host in ("0.0.0.0", "", "*"):
return "127.0.0.1"
if host in ("::", "::0", "[::]"):
return "[::1]"
if ":" in host and not (host.startswith("[") and host.endswith("]")):
return f"[{host}]"
return host
def get_server_url_from_server_data(server_data: object) -> str:
"""Return the loopback URL clients use for the configured OpenViking server."""
if isinstance(server_data, dict):
host_value = server_data.get("host")
port_value = server_data.get("port")
else:
host_value = getattr(server_data, "host", None)
port_value = getattr(server_data, "port", None)
host = map_bind_host_to_loopback(str(host_value or "127.0.0.1"))
port = str(port_value or "1933").strip()
return f"http://{host}:{port}"
def load_server_config(config_path: Optional[str] = None) -> ServerConfig:
"""Load server configuration from ov.conf.
Reads the ``server`` section of ov.conf and also ensures the full
ov.conf is loaded into the OpenVikingConfigSingleton so that model
and storage settings are available.
Resolution chain:
1. Explicit ``config_path`` (from --config)
2. OPENVIKING_CONFIG_FILE environment variable
3. ~/.openviking/ov.conf
Args:
config_path: Explicit path to ov.conf.
Returns:
ServerConfig instance with defaults for missing fields.
Raises:
FileNotFoundError: If no config file is found.
"""
path = resolve_config_path(config_path, OPENVIKING_CONFIG_ENV, DEFAULT_OV_CONF)
if path is None:
default_path_user = DEFAULT_CONFIG_DIR / DEFAULT_OV_CONF
default_path_system = SYSTEM_CONFIG_DIR / DEFAULT_OV_CONF
raise FileNotFoundError(
f"OpenViking configuration file not found.\n"
f"Please create {default_path_user} or {default_path_system}, or set {OPENVIKING_CONFIG_ENV}.\n"
f"See: https://openviking.ai/docs"
)
data = load_json_config(path)
server_data = data.get("server", {})
if server_data is None:
server_data = {}
if not isinstance(server_data, dict):
raise ValueError("Invalid server config: 'server' section must be an object")
# Convert auth_mode string — built-in enums are converted to their string
# value; custom modes are kept as-is for plugin extensibility.
if "auth_mode" in server_data and isinstance(server_data["auth_mode"], str):
try:
server_data["auth_mode"] = AuthMode(server_data["auth_mode"]).value
except ValueError:
# Custom auth mode — keep as string for plugin registration
pass
# Get encryption enabled from config data directly (for test compatibility)
encryption_enabled = data.get("encryption", {}).get("enabled", False)
# Get API key hashing enabled from config data directly (under encryption namespace)
# Default: false - rely on file-level encryption for API key protection
api_key_hashing_enabled = (
data.get("encryption", {}).get("api_key_hashing", {}).get("enabled", False)
)
# BREAKING CHANGE: Previously, encryption.enabled=true implicitly enabled API key Argon2id hashing.
# Now, you must explicitly configure encryption.api_key_hashing.enabled=true to enable hashing.
# When encryption.enabled=true but api_key_hashing.enabled=false (default),
# API keys will be stored in plaintext within AES-GCM encrypted files.
if encryption_enabled and not api_key_hashing_enabled:
logger.info(
"API key hashing is disabled while file encryption is enabled. "
"Previously, encryption.enabled=true implicitly enabled API key Argon2id hashing. "
"Now, API keys will be stored in plaintext within AES-GCM encrypted files. "
"To maintain the previous behavior, set encryption.api_key_hashing.enabled=true. "
"See documentation for more details."
)
try:
config = ServerConfig.model_validate(server_data)
except ValidationError as e:
raise ValueError(
f"Invalid server config in {path}:\n"
f"{format_validation_error(root_model=ServerConfig, error=e, path_prefix='server')}"
) from e
return config.model_copy(
update={
"encryption_enabled": encryption_enabled,
"api_key_hashing_enabled": api_key_hashing_enabled,
}
)
_LOCALHOST_HOSTS = {"127.0.0.1", "localhost", "::1"}
def _is_localhost(host: str) -> bool:
"""Return True if *host* resolves to a loopback address."""
return host in _LOCALHOST_HOSTS
def load_bot_gateway_token(config_path: Optional[str] = None) -> str:
"""Load bot gateway token from ov.conf bot.gateway.token."""
path = resolve_config_path(config_path, OPENVIKING_CONFIG_ENV, DEFAULT_OV_CONF)
if path is None:
return ""
data = load_json_config(path)
bot_config = data.get("bot", {})
if not isinstance(bot_config, dict):
return ""
gateway_config = bot_config.get("gateway", {})
if not isinstance(gateway_config, dict):
return ""
return gateway_config.get("token", "") or ""
def validate_server_config(config: ServerConfig) -> None:
"""Validate server config for safe startup.
Validation is delegated to the auth plugin registered for the effective
auth_mode. Built-in plugins (dev, api_key, trusted) preserve the original
validation behaviour.
If auth_mode is not explicitly configured:
- If root_api_key is configured (non-empty): auto-select api_key mode
- If root_api_key is not configured: auto-select dev mode
Raises:
SystemExit: If the configuration is unsafe.
"""
# Check for empty root_api_key
if config.root_api_key == "":
logger.error(
"Invalid server.root_api_key: empty string is not allowed. "
"Either set a non-empty root_api_key or remove the setting entirely."
)
sys.exit(1)
effective_auth_mode = config.get_effective_auth_mode()
# Ensure built-in plugins are registered before validation.
# If a non-built-in plugin has already claimed a built-in mode name,
# log a security warning and forcefully override it.
from openviking.server.auth.plugins import ApiKeyAuthPlugin, DevAuthPlugin, TrustedAuthPlugin
registry = get_registry()
_BUILTIN_PLUGINS = {
"dev": DevAuthPlugin,
"api_key": ApiKeyAuthPlugin,
"trusted": TrustedAuthPlugin,
}
for mode, plugin_cls in _BUILTIN_PLUGINS.items():
existing = registry.get(mode)
if existing is None:
registry.register(plugin_cls)
elif existing is not plugin_cls:
logger.warning(
"SECURITY: Auth mode %r was registered by %s but is being "
"overridden by the built-in %s.",
mode,
existing.__name__,
plugin_cls.__name__,
)
registry._plugins[mode] = plugin_cls
plugin_cls = registry.get(effective_auth_mode)
if plugin_cls is None:
logger.error(
"Unknown auth_mode: %r. No auth plugin registered for this mode. Registered modes: %s.",
effective_auth_mode,
", ".join(registry.list_modes()),
)
sys.exit(1)
plugin_cls().validate_config(config)