{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://schema.spbx.tel/v1/structs.server_config",
  "$ref": "#/$defs/structs.server_config",
  "$defs": {
    "structs.abuse_ip_db_config": {
      "properties": {
        "api_key": {
          "type": "string",
          "description": "AbuseIPDB API key (https://www.abuseipdb.com/api.html). Stored in plain text in the server config file — restrict its permissions accordingly.",
          "x-validate": "required"
        },
        "categories": {
          "type": "string",
          "description": "Comma-separated AbuseIPDB category IDs sent with each report. Defaults to \"18,8\" (Brute-Force, Fraud VoIP)."
        },
        "comment": {
          "type": "string",
          "description": "Comment sent with each report. Defaults to a generic automated-report notice. Reports are public — never put sensitive data here."
        }
      },
      "additionalProperties": false,
      "type": "object",
      "required": [
        "api_key"
      ]
    },
    "structs.admin_api_config": {
      "properties": {
        "enabled": {
          "type": "boolean",
          "description": "Whether the admin API is served."
        },
        "listen": {
          "type": "string",
          "description": "host:port the admin HTTP server binds to, e.g. 127.0.0.1:8080. When listen_tls is also set, this listener redirects to HTTPS by default (see redirect_http_to_https).",
          "x-validate": "required_if=Enabled true,omitempty,hostname_port"
        },
        "listen_tls": {
          "type": "string",
          "description": "host:port for an HTTPS admin listener, e.g. 127.0.0.1:8443. When set, the admin API is also served over TLS reusing the server's SIP TLS certificate (tls_cert_file/tls_key_file become required). Omit to serve plain HTTP only.",
          "x-validate": "omitempty,hostname_port"
        },
        "users_file": {
          "type": "string",
          "description": "Path to the JSON file holding admin users (username, argon2id PHC hash, role, realm). Generated with a random super_admin on first start if missing; watched for edits; plaintext password fields in it are hashed and rewritten in place.",
          "x-validate": "required_if=Enabled true"
        },
        "jwt_secret_file": {
          "type": "string",
          "description": "Path to the file holding the HS256 JWT signing secret. A random 32-byte secret is generated and written if the file is missing.",
          "x-validate": "required_if=Enabled true"
        },
        "redirect_http_to_https": {
          "type": "boolean",
          "description": "When an HTTPS listener (listen_tls) is configured, redirect plain-HTTP requests on 'listen' to HTTPS with 308 instead of serving the API over HTTP. Defaults to true. Set false to serve the API over both HTTP and HTTPS."
        }
      },
      "additionalProperties": false,
      "type": "object",
      "required": [
        "listen",
        "users_file",
        "jwt_secret_file"
      ]
    },
    "structs.call_log_config": {
      "properties": {
        "directory": {
          "type": "string",
          "description": "Directory where per-call log files are written. Relative paths are resolved against the directory containing the server config file.",
          "x-validate": "required"
        },
        "max_files": {
          "type": "integer",
          "description": "Maximum number of call log files kept; the oldest are deleted to make room (ring buffer).",
          "x-validate": "min=1"
        },
        "network_log": {
          "type": "boolean",
          "description": "When true, raw SIP messages sent and received for a call are recorded in its log file."
        }
      },
      "additionalProperties": false,
      "type": "object",
      "required": [
        "directory",
        "max_files"
      ]
    },
    "structs.fail2ban_config": {
      "properties": {
        "enabled": {
          "type": "boolean",
          "description": "Whether automatic IP banning is active."
        },
        "max_fails": {
          "type": "integer",
          "description": "Number of failed authentication attempts within fail_window_seconds that triggers a ban. Only attempts that presented credentials and were rejected count — the 401 challenge of a normal digest round-trip does not. Defaults to 15.",
          "x-validate": "omitempty,min=1"
        },
        "fail_window_seconds": {
          "type": "integer",
          "description": "Sliding window in seconds: each recorded failure is forgotten this many seconds after it happened, so only recent failures count toward max_fails. Defaults to 3600 (1 hour).",
          "x-validate": "omitempty,min=1"
        },
        "ban_time_seconds": {
          "type": "integer",
          "description": "How long a banned IP stays banned, in seconds. Defaults to 10800 (3 hours). Bans live in memory only and do not survive a restart.",
          "x-validate": "omitempty,min=1"
        },
        "whitelist": {
          "items": {
            "type": "string"
          },
          "type": "array",
          "description": "IP addresses or CIDR ranges whose failures are never counted and that are never banned (e.g. the office network).",
          "x-validate": "omitempty,dive,ip|cidr"
        },
        "auto_whitelist": {
          "type": "boolean",
          "description": "When true (the default) an IP that completes a successful authentication is whitelisted for the rest of the process lifetime, so one misconfigured phone behind a shared NAT cannot get the whole site banned. Set false in adversarial environments."
        },
        "ban_command": {
          "type": "string",
          "description": "Optional system command executed via 'sh -c' when an IP is banned, with %ip% replaced by the offending address, e.g. \"iptables -A INPUT -s %ip% -j DROP\". The software-internal ban applies regardless. Requires unban_command.",
          "x-validate": "required_with=UnbanCommand"
        },
        "unban_command": {
          "type": "string",
          "description": "System command executed via 'sh -c' when a ban expires, with %ip% replaced by the address, e.g. \"iptables -D INPUT -s %ip% -j DROP\". Required when ban_command is set so external bans are always rolled back.",
          "x-validate": "required_with=BanCommand"
        },
        "abuseipdb": {
          "$ref": "#/$defs/structs.abuse_ip_db_config",
          "description": "Automatic reporting of banned IPs to AbuseIPDB (https://www.abuseipdb.com). Omit to disable reporting.",
          "x-validate": "omitempty"
        }
      },
      "additionalProperties": false,
      "type": "object",
      "required": [
        "enabled"
      ]
    },
    "structs.observability_config": {
      "properties": {
        "service_name": {
          "type": "string",
          "description": "OpenTelemetry service.name resource attribute for every exported signal. Defaults to \"s.pbx\" when empty."
        },
        "insecure": {
          "type": "boolean",
          "description": "When true, the OTLP/gRPC connection to the collector uses plaintext (no TLS). When false (the default), the exporter uses TLS with the system certificate pool."
        },
        "headers": {
          "additionalProperties": {
            "type": "string"
          },
          "type": "object",
          "description": "Extra headers (gRPC metadata) sent with every OTLP export request on all enabled pillars, e.g. {\"authorization\": \"Bearer \u003ctoken\u003e\"} or an api-key header for authenticated collectors.",
          "x-validate": "omitempty"
        },
        "traces": {
          "$ref": "#/$defs/structs.otlp_pillar_config",
          "description": "OTLP trace export (root span per call plus dialplan and outbound-dial child spans). Omit or disable to skip trace export.",
          "x-validate": "omitempty"
        },
        "metrics": {
          "$ref": "#/$defs/structs.otlp_pillar_config",
          "description": "OTLP metric export (call/registration counters, active-call and registered-extension gauges, call-duration histogram). Omit or disable to skip metric export.",
          "x-validate": "omitempty"
        },
        "logs": {
          "$ref": "#/$defs/structs.otlp_pillar_config",
          "description": "OTLP log export bridging zerolog records to the collector. Additive: stdout logging is unaffected. Omit or disable to skip log export.",
          "x-validate": "omitempty"
        },
        "prometheus": {
          "$ref": "#/$defs/structs.prometheus_config",
          "description": "Prometheus scrape endpoint (GET /metrics on the admin API listener) exposing the same metric instruments as the OTLP metrics pillar. Works with or without OTLP metric export. Requires admin_api.enabled=true.",
          "x-validate": "omitempty"
        }
      },
      "additionalProperties": false,
      "type": "object"
    },
    "structs.otlp_pillar_config": {
      "properties": {
        "enabled": {
          "type": "boolean",
          "description": "Whether this signal is exported."
        },
        "endpoint": {
          "type": "string",
          "description": "OTLP/gRPC collector endpoint as host:port (e.g. localhost:4317). Required when enabled is true.",
          "x-validate": "omitempty,min=3"
        }
      },
      "additionalProperties": false,
      "type": "object",
      "required": [
        "enabled"
      ]
    },
    "structs.prometheus_config": {
      "properties": {
        "enabled": {
          "type": "boolean",
          "description": "Whether GET /metrics is served on the admin API listener. Requires admin_api.enabled=true."
        },
        "password": {
          "type": "string",
          "description": "Optional static password for the global (all-realms) metrics view, checked via HTTP basic auth (the username is ignored). When empty, the global view is served WITHOUT authentication — bind the admin API to localhost or set a password before exposing it. Admin-API users with the statistics role (or higher) can always fetch their realm's metrics with a bearer token, regardless of this password."
        }
      },
      "additionalProperties": false,
      "type": "object",
      "required": [
        "enabled"
      ]
    },
    "structs.server_config": {
      "properties": {
        "host": {
          "type": "string",
          "description": "IP address the SIP listeners and RTP sockets bind to. Use 0.0.0.0 to listen on all interfaces; a concrete address also becomes the default advertised in SIP/SDP.",
          "x-validate": "required,ip"
        },
        "from": {
          "type": "string",
          "description": "Hostname this PBX identifies as (e.g. pbx.example.com). Used as the From-header host of internal dials and as the subject (CN and DNS SAN) of certificates generated by --new-tls-cert.",
          "x-validate": "required"
        },
        "reachable_ip": {
          "type": "string",
          "description": "Public IP address this PBX is reachable at (static NAT). Overrides the per-peer route lookup when advertising addresses in SIP/SDP. A trunk's reachable_ip takes precedence for legs toward that trunk.",
          "x-validate": "omitempty,ip"
        },
        "port_tcp": {
          "type": "integer",
          "description": "TCP port for plain SIP over TCP. Omit to disable the TCP listener.",
          "x-validate": "omitempty,min=1,max=65535"
        },
        "port_tls": {
          "type": "integer",
          "description": "TCP port for SIP over TLS (SIPS). Requires tls_cert_file and tls_key_file.",
          "x-validate": "omitempty,min=1,max=65535"
        },
        "port_udp": {
          "type": "integer",
          "description": "UDP port for plain SIP over UDP. Omit to disable the UDP listener.",
          "x-validate": "omitempty,min=1,max=65535"
        },
        "port_ws": {
          "type": "integer",
          "description": "TCP port for SIP over WebSocket (WS). Omit to disable the WebSocket listener.",
          "x-validate": "omitempty,min=1,max=65535"
        },
        "port_wss": {
          "type": "integer",
          "description": "TCP port for SIP over secure WebSocket (WSS). Requires tls_cert_file and tls_key_file.",
          "x-validate": "omitempty,min=1,max=65535"
        },
        "tls_cert_file": {
          "type": "string",
          "description": "Path to the PEM-encoded server certificate chain (leaf first). Relative paths are resolved against the directory containing this server config file. Required when port_tls or port_wss is set. Loaded once at startup; rotating the certificate requires a server restart.",
          "x-validate": "required_with=PortTls PortWss TlsKeyFile TlsMode,omitempty,file"
        },
        "tls_key_file": {
          "type": "string",
          "description": "Path to the PEM-encoded private key matching tls_cert_file. Relative paths are resolved against the directory containing this server config file. Required when port_tls or port_wss is set.",
          "x-validate": "required_with=PortTls PortWss TlsCertFile TlsMode,omitempty,file"
        },
        "tls_mode": {
          "type": "string",
          "description": "Automatic self-signed TLS provisioning. When set to 'ecdsa' or 'rsa' and the files at tls_cert_file/tls_key_file do not both exist at startup, a self-signed certificate/key pair is generated there (same output as --new-tls-cert). Existing files are never overwritten — rotation stays explicit via --new-tls-cert. Omit to require the files to already exist.",
          "x-validate": "omitempty,oneof=ecdsa rsa"
        },
        "max_connections": {
          "type": "integer",
          "description": "Maximum number of concurrent connections. Reserved: currently not enforced.",
          "x-validate": "min=1"
        },
        "rtp_port_range_start": {
          "type": "integer",
          "description": "First UDP port (inclusive) of the range RTP media sockets are allocated from; one port is taken per call leg.",
          "x-validate": "min=1024,max=65535"
        },
        "rtp_port_range_end": {
          "type": "integer",
          "description": "Last UDP port (inclusive) of the RTP media port range. Must be greater than rtp_port_range_start.",
          "x-validate": "min=1024,max=65535,gtfield=RtpPortRangeStart"
        },
        "disabled_codecs": {
          "items": {
            "type": "string"
          },
          "type": "array",
          "description": "Codec engines removed from the registry at startup, disabling them server-wide. Entries match the engine's unique name exactly (e.g. PCMA, OPUS_48000). For per-peer filtering see the disabled_codecs list on trunks and extensions.",
          "x-validate": "omitempty,dive,required"
        },
        "raise_udp_mtu": {
          "type": "boolean",
          "description": "When true (the default), RFC 3261 UDP size cap (1300 bytes) is raised to 65535, relying on IP fragmentation for large SIP messages, and outgoing calls offer every compiled codec. When false the RFC cap is enforced and outgoing calls offer only simple narrowband codecs (PCMU/PCMA/G722) so INVITEs stay under it."
        },
        "stun_server": {
          "type": "string",
          "description": "STUN server (host:port, e.g. stun.example.com:3478) used to discover the NAT mapping of each trunk-leg RTP socket before the SDP is sent. Omit to disable STUN discovery. A trunk's reachable_ip overrides it.",
          "x-validate": "omitempty,min=3"
        },
        "srtp": {
          "type": "string",
          "description": "SDES-SRTP media encryption mode (RFC 4568). 'disabled' refuses RTP/SAVP offers; 'optional' (the default) accepts SRTP from peers that offer it — including best-effort crypto on RTP/AVP offers — and places outbound calls with a best-effort crypto offer that falls back to plain RTP when the peer declines; 'required' offers strict RTP/SAVP on every outbound leg (a crypto-less answer drops the leg) and rejects inbound calls without SRTP or arriving over unencrypted signaling transports. SDES keys travel inside SDP, so use TLS/WSS signaling whenever this is not 'disabled'.",
          "x-validate": "omitempty,oneof=disabled optional required"
        },
        "secrets_key_file": {
          "type": "string",
          "description": "Path to a PEM-encoded RSA private key enabling realm-secret encryption. Relative paths are resolved against the directory containing this server config file. Generated (RSA-4096, PKCS#8, file mode 0600) at startup when the file does not exist. When set, secret realm-config fields are stored as encrypted:\u003cbase64\u003e (RSA-OAEP-SHA256) and decrypted on load: configs saved through the admin API are encrypted before they land on disk, and locally edited realm config files are rewritten in place (preserving $schema and unknown fields, normalizing formatting once) when a load — startup, watch reload or admin reload — finds plaintext secrets; a write failure (e.g. read-only config mount) is logged and the file keeps its plaintext values. Secrets inside imported fragment files are not rewritten. Clients may pre-encrypt values themselves using the public key from GET /v1/system/public-key. Omit to keep secrets in plain text.",
          "x-validate": "omitempty"
        },
        "call_log": {
          "$ref": "#/$defs/structs.call_log_config",
          "description": "Per-call JSON trace logging. Omit to disable call logging entirely.",
          "x-validate": "omitempty"
        },
        "admin_api": {
          "$ref": "#/$defs/structs.admin_api_config",
          "description": "REST admin API (HTTP, optionally HTTPS). Omit or set enabled=false to disable. The plain-HTTP listener can be fronted by a TLS-terminating reverse proxy, or set listen_tls to serve HTTPS directly using the server's SIP TLS certificate.",
          "x-validate": "omitempty"
        },
        "observability": {
          "$ref": "#/$defs/structs.observability_config",
          "description": "OpenTelemetry (OTLP/gRPC) export of traces, metrics and logs. Omit to disable all three pillars. Each pillar is enabled independently.",
          "x-validate": "omitempty"
        },
        "fail2ban": {
          "$ref": "#/$defs/structs.fail2ban_config",
          "description": "Automatic banning of source IPs that repeatedly fail SIP authentication. Bans are enforced in software (SIP requests from banned IPs are silently dropped) and can additionally run a system command (e.g. iptables) and report the IP to AbuseIPDB. Omit or set enabled=false to disable.",
          "x-validate": "omitempty"
        },
        "log_format": {
          "type": "string",
          "description": "Logging output format: json (the default) or console (human-readable, colorized). An empty value means json.",
          "x-validate": "omitempty,oneof=json console"
        },
        "log_level": {
          "type": "string",
          "description": "Global logging level, parsed by zerolog: trace, debug, info, warn, error, fatal, panic or disabled. An empty value means info.",
          "x-validate": "omitempty"
        },
        "realm_config": {
          "type": "string",
          "description": "Path to a single realm configuration file. Relative paths are resolved against the directory containing this server config file. Exactly one of realm_config and realm_config_dir must be set to run the server.",
          "x-validate": "excluded_with=RealmConfigDir"
        },
        "realm_config_dir": {
          "type": "string",
          "description": "Path to a directory of realm configuration files. Relative paths are resolved against the directory containing this server config file. Exactly one of realm_config and realm_config_dir must be set to run the server.",
          "x-validate": "omitempty"
        },
        "watch": {
          "type": "boolean",
          "description": "When true the realm configuration file or directory is watched for changes and hot-reloaded while the server runs."
        },
        "$schema": {
          "type": "string"
        }
      },
      "additionalProperties": false,
      "type": "object",
      "required": [
        "host",
        "from",
        "max_connections",
        "rtp_port_range_start",
        "rtp_port_range_end",
        "disabled_codecs",
        "log_format",
        "log_level",
        "watch"
      ]
    }
  }
}
