mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-10-01 01:38:07 +08:00
* feat(uri)!: reject uid-less current-user shorthand in favor of viking://~ viking://user/<segment> (memories/resources/skills/peers/privacy/sessions without a user id) was ambiguous with a user literally named after the segment, and a user actually named e.g. "memories" was unreachable for USER/ADMIN callers. Now that the viking://~ home alias (#4167) covers the same need unambiguously, the shorthand fails closed at the request boundary instead of expanding: - resolve_current_user_uri raises NamespaceShapeError with a corrective hint naming both viking://~/<rest> and the explicit-uid form. Silently parsing the reserved segment as a peer user id would misdirect reads and writes, so rejection is the only safe removal. - Bare viking://user falls through to the canonical parser and keeps container semantics (a user key listing it sees only its own space). - The self-id escape stays: a caller whose user_id equals a reserved name keeps viking://user/<own-id> as their canonical root. ROOT-role literal parsing and the legacy viking://session alias are unchanged. - AddTargetsConfig normalizes stored legacy config spellings (viking://user/resources|skills) to the viking://~ form at validation so existing ov.conf/user_config deployments keep working; the accepted per-user spelling is now viking://~/resources and viking://~/skills. - usage_reporter keeps canonicalizing the historical shorthand found in old transcripts and additionally recognizes viking://~/memories/. BREAKING CHANGE: requests using the uid-less viking://user/<segment> spelling now fail with 400; use viking://~/<segment> or an explicit viking://user/{user_id}/<segment> URI. * refactor(clients): migrate first-party emitters to the viking://~ home alias Every in-repo client that emitted the removed uid-less current-user shorthand now sends viking://~/... instead: vikingbot fallbacks and default sentinels, the LangChain store/tools defaults, the shared recall-core.mjs (all synced plugin copies), the codex/claude-code/ openclaw/openwebui/dsh/zcode/pi plugin emitters, quick-app examples, Go SDK example, tau2 benchmark targets, and the eval golden dataset. Compat kept where legacy strings live in stored user configs: bot and ov_dream sentinels accept both spellings while emitting only ~, and recall-core still rewrites legacy viking://user/<reserved> config values client-side. langchain_openviking._uri now classifies viking://~ with the explicit-user shape so canonicalized server responses keep matching a ~ root. Plugin READMEs note the server requirement for the alias. * docs: replace current-user shorthand guidance with the viking://~ home alias Rewrite every EN/ZH doc and model-facing prompt that advertised the uid-less viking://user/<segment> spelling: URI concept catalogue, context-types/storage/extraction/retrieval/session/privacy concepts, configuration guide (with the legacy add_targets auto-normalization note), resources/skills/sessions/retrieval/admin API references, FAQ, capability reference, and the openviking-memory / ov-experience-memory / openclaw / ov-resources skills. The stale MCP viking://user/<path> dialect passage in the MCP guide is replaced by ~ guidance, and bare viking://user is documented as the container of user spaces. * test(api): migrate live API session-used tests off the removed shorthand tests/api_test/sessions sent uid-less viking://user/skills/... URIs to record_used, which the request boundary now rejects with 400 (caught by the API & CLI Integration Tests CI job; these tests need a live server and are not part of the local suites). The api_test client authenticates as an admin-role user key, so the viking://~ home alias expands for it. tests/api_test/common/test_edge_cases.py is left as is: it asserts a 400 for a non-resource add target, which still holds.
588 lines
22 KiB
Python
588 lines
22 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 Any, 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
|
|
|
|
|
|
def _rewrite_legacy_current_user_uri(uri: str, field_name: str) -> str:
|
|
"""Rewrite legacy uid-less current-user spellings to the ``~`` home alias.
|
|
|
|
``viking://user/resources[/...]`` and ``viking://user/skills`` are no longer
|
|
expanded at request boundaries, but stored configurations (ov.conf
|
|
``user_config_defaults.add_targets``, persisted ``user_config.json``, old
|
|
PATCH bodies) may still carry them. Their intent was unambiguous, so
|
|
normalize them here instead of breaking existing deployments.
|
|
"""
|
|
from openviking.core.namespace import uri_parts
|
|
|
|
try:
|
|
parts = uri_parts(uri.rstrip("/"))
|
|
except ValueError:
|
|
return uri
|
|
if parts[:2] not in (["user", "resources"], ["user", "skills"]):
|
|
return uri
|
|
rewritten = f"viking://~/{'/'.join(parts[1:])}"
|
|
logger.info(
|
|
"Rewrote legacy current-user %s %r to %r; use the viking://~ home alias instead.",
|
|
field_name,
|
|
uri,
|
|
rewritten,
|
|
)
|
|
return rewritten
|
|
|
|
|
|
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
|
|
|
|
uri = _rewrite_legacy_current_user_uri(uri, "resource_uri")
|
|
validate_viking_uri(uri, field_name="resource_uri")
|
|
normalized = uri.rstrip("/")
|
|
parts = uri_parts(normalized)
|
|
classification = classify_uri(normalized)
|
|
if (
|
|
parts[:1] == ["resources"]
|
|
or parts[:2] == ["~", "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: viking://resources/..., "
|
|
"viking://~/resources[/...], or viking://user/{user_id}/resources[/...]"
|
|
)
|
|
|
|
@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.core.namespace import uri_parts
|
|
from openviking.core.uri_validation import validate_viking_uri
|
|
|
|
uri = _rewrite_legacy_current_user_uri(uri, "skill_uri")
|
|
validate_viking_uri(uri, field_name="skill_uri")
|
|
normalized = uri.rstrip("/")
|
|
parts = uri_parts(normalized)
|
|
if parts in (["~", "skills"], ["agent", "skills"]) or (
|
|
len(parts) == 3 and parts[0] == "user" and parts[2] == "skills"
|
|
):
|
|
return normalized
|
|
raise ValueError(
|
|
"skill_uri must be viking://~/skills, viking://user/{user_id}/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)
|
|
memory_policy: Optional[Dict[str, Any]] = None
|
|
agent_evolution: DeprecatedUserAgentEvolutionConfig = Field(
|
|
default_factory=DeprecatedUserAgentEvolutionConfig,
|
|
exclude=True,
|
|
)
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
@field_validator("memory_policy", mode="before")
|
|
@classmethod
|
|
def validate_memory_policy(cls, value: Any) -> Optional[Dict[str, Any]]:
|
|
if value is None:
|
|
return None
|
|
from openviking.session.memory_policy import MemoryPolicy
|
|
|
|
return MemoryPolicy.from_dict(value).to_dict()
|
|
|
|
|
|
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
|
|
ttl_seconds: int = Field(12 * 60 * 60, ge=0)
|
|
|
|
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)
|