diff --git a/Cargo.toml b/Cargo.toml index e43e6ff..ee7134f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.3.20" +version = "3.3.21" edition = "2024" [dependencies] diff --git a/docs/CONFIG_PARAMS.en.md b/docs/CONFIG_PARAMS.en.md new file mode 100644 index 0000000..ed89b3d --- /dev/null +++ b/docs/CONFIG_PARAMS.en.md @@ -0,0 +1,289 @@ +# Telemt Config Parameters Reference + +This document lists all configuration keys accepted by `config.toml`. + +> [!WARNING] +> +> The configuration parameters detailed in this document are intended for advanced users and fine-tuning purposes. Modifying these settings without a clear understanding of their function may lead to application instability or other unexpected behavior. Please proceed with caution and at your own risk. + +## Top-level keys + +| Parameter | Type | Description | +|---|---|---| +| include | `String` (special directive) | Includes another TOML file with `include = "relative/or/absolute/path.toml"`; includes are processed recursively before parsing. | +| show_link | `"*" \| String[]` | Legacy top-level link visibility selector (`"*"` for all users or explicit usernames list). | +| dc_overrides | `Map` | Overrides DC endpoints for non-standard DCs; key is DC id string, value is `ip:port` list. | +| default_dc | `u8` | Default DC index used for unmapped non-standard DCs. | + +## [general] + +| Parameter | Type | Description | +|---|---|---| +| data_path | `String` | Optional runtime data directory path. | +| prefer_ipv6 | `bool` | Prefer IPv6 where applicable in runtime logic. | +| fast_mode | `bool` | Enables fast-path optimizations for traffic processing. | +| use_middle_proxy | `bool` | Enables Middle Proxy mode. | +| proxy_secret_path | `String` | Path to proxy secret binary; can be auto-downloaded if absent. | +| proxy_config_v4_cache_path | `String` | Optional cache path for raw `getProxyConfig` (IPv4) snapshot. | +| proxy_config_v6_cache_path | `String` | Optional cache path for raw `getProxyConfigV6` (IPv6) snapshot. | +| ad_tag | `String` | Global fallback ad tag (32 hex characters). | +| middle_proxy_nat_ip | `IpAddr` | Explicit public IP override for NAT environments. | +| middle_proxy_nat_probe | `bool` | Enables NAT probing for Middle Proxy KDF/public address discovery. | +| middle_proxy_nat_stun | `String` | Deprecated legacy single STUN server for NAT probing. | +| middle_proxy_nat_stun_servers | `String[]` | Deprecated legacy STUN list for NAT probing fallback. | +| stun_nat_probe_concurrency | `usize` | Maximum concurrent STUN probes during NAT detection. | +| middle_proxy_pool_size | `usize` | Target size of active Middle Proxy writer pool. | +| middle_proxy_warm_standby | `usize` | Number of warm standby Middle-End connections. | +| me_init_retry_attempts | `u32` | Startup retries for ME pool initialization (`0` means unlimited). | +| me2dc_fallback | `bool` | Allows fallback from ME mode to direct DC when ME startup fails. | +| me_keepalive_enabled | `bool` | Enables ME keepalive padding frames. | +| me_keepalive_interval_secs | `u64` | Keepalive interval in seconds. | +| me_keepalive_jitter_secs | `u64` | Keepalive jitter in seconds. | +| me_keepalive_payload_random | `bool` | Randomizes keepalive payload bytes instead of zero payload. | +| rpc_proxy_req_every | `u64` | Interval for service `RPC_PROXY_REQ` activity signals (`0` disables). | +| me_writer_cmd_channel_capacity | `usize` | Capacity of per-writer command channel. | +| me_route_channel_capacity | `usize` | Capacity of per-connection ME response route channel. | +| me_c2me_channel_capacity | `usize` | Capacity of per-client command queue (client reader -> ME sender). | +| me_reader_route_data_wait_ms | `u64` | Bounded wait for routing ME DATA to per-connection queue (`0` = no wait). | +| me_d2c_flush_batch_max_frames | `usize` | Max ME->client frames coalesced before flush. | +| me_d2c_flush_batch_max_bytes | `usize` | Max ME->client payload bytes coalesced before flush. | +| me_d2c_flush_batch_max_delay_us | `u64` | Max microsecond wait for coalescing more ME->client frames (`0` disables timed coalescing). | +| me_d2c_ack_flush_immediate | `bool` | Flushes client writer immediately after quick-ack write. | +| direct_relay_copy_buf_c2s_bytes | `usize` | Copy buffer size for client->DC direction in direct relay. | +| direct_relay_copy_buf_s2c_bytes | `usize` | Copy buffer size for DC->client direction in direct relay. | +| crypto_pending_buffer | `usize` | Max pending ciphertext buffer per client writer (bytes). | +| max_client_frame | `usize` | Maximum allowed client MTProto frame size (bytes). | +| desync_all_full | `bool` | Emits full crypto-desync forensic logs for every event. | +| beobachten | `bool` | Enables per-IP forensic observation buckets. | +| beobachten_minutes | `u64` | Retention window (minutes) for per-IP observation buckets. | +| beobachten_flush_secs | `u64` | Snapshot flush interval (seconds) for observation output file. | +| beobachten_file | `String` | Observation snapshot output file path. | +| hardswap | `bool` | Enables hard-swap generation switching for ME pool updates. | +| me_warmup_stagger_enabled | `bool` | Enables staggered warmup for extra ME writers. | +| me_warmup_step_delay_ms | `u64` | Base delay between warmup connections (ms). | +| me_warmup_step_jitter_ms | `u64` | Jitter for warmup delay (ms). | +| me_reconnect_max_concurrent_per_dc | `u32` | Max concurrent reconnect attempts per DC. | +| me_reconnect_backoff_base_ms | `u64` | Base reconnect backoff in ms. | +| me_reconnect_backoff_cap_ms | `u64` | Cap reconnect backoff in ms. | +| me_reconnect_fast_retry_count | `u32` | Number of fast retry attempts before backoff. | +| me_single_endpoint_shadow_writers | `u8` | Additional reserve writers for one-endpoint DC groups. | +| me_single_endpoint_outage_mode_enabled | `bool` | Enables aggressive outage recovery for one-endpoint DC groups. | +| me_single_endpoint_outage_disable_quarantine | `bool` | Ignores endpoint quarantine in one-endpoint outage mode. | +| me_single_endpoint_outage_backoff_min_ms | `u64` | Minimum reconnect backoff in outage mode (ms). | +| me_single_endpoint_outage_backoff_max_ms | `u64` | Maximum reconnect backoff in outage mode (ms). | +| me_single_endpoint_shadow_rotate_every_secs | `u64` | Periodic shadow writer rotation interval (`0` disables). | +| me_floor_mode | `"static" \| "adaptive"` | Writer floor policy mode. | +| me_adaptive_floor_idle_secs | `u64` | Idle time before adaptive floor may reduce one-endpoint target. | +| me_adaptive_floor_min_writers_single_endpoint | `u8` | Minimum adaptive writer target for one-endpoint DC groups. | +| me_adaptive_floor_min_writers_multi_endpoint | `u8` | Minimum adaptive writer target for multi-endpoint DC groups. | +| me_adaptive_floor_recover_grace_secs | `u64` | Grace period to hold static floor after activity. | +| me_adaptive_floor_writers_per_core_total | `u16` | Global writer budget per logical CPU core in adaptive mode. | +| me_adaptive_floor_cpu_cores_override | `u16` | Manual CPU core count override (`0` uses auto-detection). | +| me_adaptive_floor_max_extra_writers_single_per_core | `u16` | Per-core max extra writers above base floor for one-endpoint DCs. | +| me_adaptive_floor_max_extra_writers_multi_per_core | `u16` | Per-core max extra writers above base floor for multi-endpoint DCs. | +| me_adaptive_floor_max_active_writers_per_core | `u16` | Hard cap for active ME writers per logical CPU core. | +| me_adaptive_floor_max_warm_writers_per_core | `u16` | Hard cap for warm ME writers per logical CPU core. | +| me_adaptive_floor_max_active_writers_global | `u32` | Hard global cap for active ME writers. | +| me_adaptive_floor_max_warm_writers_global | `u32` | Hard global cap for warm ME writers. | +| upstream_connect_retry_attempts | `u32` | Connect attempts for selected upstream before error/fallback. | +| upstream_connect_retry_backoff_ms | `u64` | Delay between upstream connect attempts (ms). | +| upstream_connect_budget_ms | `u64` | Total wall-clock budget for one upstream connect request (ms). | +| upstream_unhealthy_fail_threshold | `u32` | Consecutive failed requests before upstream is marked unhealthy. | +| upstream_connect_failfast_hard_errors | `bool` | Skips additional retries for hard non-transient connect errors. | +| stun_iface_mismatch_ignore | `bool` | Ignores STUN/interface mismatch and keeps Middle Proxy mode. | +| unknown_dc_log_path | `String` | File path for unknown-DC request logging (`null` disables file path). | +| unknown_dc_file_log_enabled | `bool` | Enables unknown-DC file logging. | +| log_level | `"debug" \| "verbose" \| "normal" \| "silent"` | Runtime logging verbosity. | +| disable_colors | `bool` | Disables ANSI colors in logs. | +| me_socks_kdf_policy | `"strict" \| "compat"` | SOCKS-bound KDF fallback policy for ME handshake. | +| me_route_backpressure_base_timeout_ms | `u64` | Base backpressure timeout for route-channel send (ms). | +| me_route_backpressure_high_timeout_ms | `u64` | High backpressure timeout when queue occupancy exceeds watermark (ms). | +| me_route_backpressure_high_watermark_pct | `u8` | Queue occupancy threshold (%) for high timeout mode. | +| me_health_interval_ms_unhealthy | `u64` | Health monitor interval while writer coverage is degraded (ms). | +| me_health_interval_ms_healthy | `u64` | Health monitor interval while writer coverage is healthy (ms). | +| me_admission_poll_ms | `u64` | Poll interval for conditional-admission checks (ms). | +| me_warn_rate_limit_ms | `u64` | Cooldown for repetitive ME warning logs (ms). | +| me_route_no_writer_mode | `"async_recovery_failfast" \| "inline_recovery_legacy" \| "hybrid_async_persistent"` | Route behavior when no writer is immediately available. | +| me_route_no_writer_wait_ms | `u64` | Max wait in async-recovery failfast mode (ms). | +| me_route_inline_recovery_attempts | `u32` | Inline recovery attempts in legacy mode. | +| me_route_inline_recovery_wait_ms | `u64` | Max inline recovery wait in legacy mode (ms). | +| fast_mode_min_tls_record | `usize` | Minimum TLS record size when fast-mode coalescing is enabled (`0` disables). | +| update_every | `u64` | Unified interval for config/secret updater tasks. | +| me_reinit_every_secs | `u64` | Periodic ME pool reinitialization interval (seconds). | +| me_hardswap_warmup_delay_min_ms | `u64` | Minimum delay between hardswap warmup connects (ms). | +| me_hardswap_warmup_delay_max_ms | `u64` | Maximum delay between hardswap warmup connects (ms). | +| me_hardswap_warmup_extra_passes | `u8` | Additional warmup passes per hardswap cycle. | +| me_hardswap_warmup_pass_backoff_base_ms | `u64` | Base backoff between hardswap warmup passes (ms). | +| me_config_stable_snapshots | `u8` | Number of identical config snapshots required before apply. | +| me_config_apply_cooldown_secs | `u64` | Cooldown between applied ME map updates (seconds). | +| me_snapshot_require_http_2xx | `bool` | Requires 2xx HTTP responses for applying config snapshots. | +| me_snapshot_reject_empty_map | `bool` | Rejects empty config snapshots. | +| me_snapshot_min_proxy_for_lines | `u32` | Minimum parsed `proxy_for` rows required to accept snapshot. | +| proxy_secret_stable_snapshots | `u8` | Number of identical secret snapshots required before runtime rotation. | +| proxy_secret_rotate_runtime | `bool` | Enables runtime proxy-secret rotation from remote source. | +| me_secret_atomic_snapshot | `bool` | Keeps selector and secret bytes from the same snapshot atomically. | +| proxy_secret_len_max | `usize` | Maximum allowed proxy-secret length (bytes). | +| me_pool_drain_ttl_secs | `u64` | Drain TTL for stale ME writers after endpoint-map changes (seconds). | +| me_pool_drain_threshold | `u64` | Max draining stale writers before batch force-close (`0` disables threshold cleanup). | +| me_bind_stale_mode | `"never" \| "ttl" \| "always"` | Policy for new binds on stale draining writers. | +| me_bind_stale_ttl_secs | `u64` | TTL for stale bind allowance when stale mode is `ttl`. | +| me_pool_min_fresh_ratio | `f32` | Minimum desired-DC fresh coverage ratio before draining stale writers. | +| me_reinit_drain_timeout_secs | `u64` | Force-close timeout for stale writers after endpoint-map changes (`0` disables force-close). | +| proxy_secret_auto_reload_secs | `u64` | Deprecated legacy secret reload interval (fallback when `update_every` is not set). | +| proxy_config_auto_reload_secs | `u64` | Deprecated legacy config reload interval (fallback when `update_every` is not set). | +| me_reinit_singleflight | `bool` | Serializes ME reinit cycles across trigger sources. | +| me_reinit_trigger_channel | `usize` | Trigger queue capacity for reinit scheduler. | +| me_reinit_coalesce_window_ms | `u64` | Trigger coalescing window before starting reinit (ms). | +| me_deterministic_writer_sort | `bool` | Enables deterministic candidate sort for writer binding path. | +| me_writer_pick_mode | `"sorted_rr" \| "p2c"` | Writer selection mode for route bind path. | +| me_writer_pick_sample_size | `u8` | Number of candidates sampled by picker in `p2c` mode. | +| ntp_check | `bool` | Enables NTP drift check at startup. | +| ntp_servers | `String[]` | NTP servers used for drift check. | +| auto_degradation_enabled | `bool` | Enables automatic degradation from ME to direct DC. | +| degradation_min_unavailable_dc_groups | `u8` | Minimum unavailable ME DC groups required before degrading. | + +## [general.modes] + +| Parameter | Type | Description | +|---|---|---| +| classic | `bool` | Enables classic MTProxy mode. | +| secure | `bool` | Enables secure mode. | +| tls | `bool` | Enables TLS mode. | + +## [general.links] + +| Parameter | Type | Description | +|---|---|---| +| show | `"*" \| String[]` | Selects users whose tg:// links are shown at startup. | +| public_host | `String` | Public hostname/IP override for generated tg:// links. | +| public_port | `u16` | Public port override for generated tg:// links. | + +## [general.telemetry] + +| Parameter | Type | Description | +|---|---|---| +| core_enabled | `bool` | Enables core hot-path telemetry counters. | +| user_enabled | `bool` | Enables per-user telemetry counters. | +| me_level | `"silent" \| "normal" \| "debug"` | Middle-End telemetry verbosity level. | + +## [network] + +| Parameter | Type | Description | +|---|---|---| +| ipv4 | `bool` | Enables IPv4 networking. | +| ipv6 | `bool` | Enables/disables IPv6 (`null` = auto-detect availability). | +| prefer | `u8` | Preferred IP family for selection (`4` or `6`). | +| multipath | `bool` | Enables multipath behavior where supported. | +| stun_use | `bool` | Global switch for STUN probing. | +| stun_servers | `String[]` | STUN server list for public IP detection. | +| stun_tcp_fallback | `bool` | Enables TCP STUN fallback when UDP STUN is blocked. | +| http_ip_detect_urls | `String[]` | HTTP endpoints used as fallback public IP detectors. | +| cache_public_ip_path | `String` | File path for caching detected public IP. | +| dns_overrides | `String[]` | Runtime DNS overrides in `host:port:ip` format. | + +## [server] + +| Parameter | Type | Description | +|---|---|---| +| port | `u16` | Main proxy listen port. | +| listen_addr_ipv4 | `String` | IPv4 bind address for TCP listener. | +| listen_addr_ipv6 | `String` | IPv6 bind address for TCP listener. | +| listen_unix_sock | `String` | Unix socket path for listener. | +| listen_unix_sock_perm | `String` | Unix socket permissions in octal string (e.g., `"0666"`). | +| listen_tcp | `bool` | Explicit TCP listener enable/disable override. | +| proxy_protocol | `bool` | Enables HAProxy PROXY protocol parsing on incoming client connections. | +| proxy_protocol_header_timeout_ms | `u64` | Timeout for PROXY protocol header read/parse (ms). | +| metrics_port | `u16` | Metrics endpoint port (enables metrics listener). | +| metrics_listen | `String` | Full metrics bind address (`IP:PORT`), overrides `metrics_port`. | +| metrics_whitelist | `IpNetwork[]` | CIDR whitelist for metrics endpoint access. | +| max_connections | `u32` | Max concurrent client connections (`0` = unlimited). | + +## [server.api] + +| Parameter | Type | Description | +|---|---|---| +| enabled | `bool` | Enables control-plane REST API. | +| listen | `String` | API bind address in `IP:PORT` format. | +| whitelist | `IpNetwork[]` | CIDR whitelist allowed to access API. | +| auth_header | `String` | Exact expected `Authorization` header value (empty = disabled). | +| request_body_limit_bytes | `usize` | Maximum accepted HTTP request body size. | +| minimal_runtime_enabled | `bool` | Enables minimal runtime snapshots endpoint logic. | +| minimal_runtime_cache_ttl_ms | `u64` | Cache TTL for minimal runtime snapshots (ms; `0` disables cache). | +| runtime_edge_enabled | `bool` | Enables runtime edge endpoints. | +| runtime_edge_cache_ttl_ms | `u64` | Cache TTL for runtime edge aggregation payloads (ms). | +| runtime_edge_top_n | `usize` | Top-N size for edge connection leaderboard. | +| runtime_edge_events_capacity | `usize` | Ring-buffer capacity for runtime edge events. | +| read_only | `bool` | Rejects mutating API endpoints when enabled. | + +## [[server.listeners]] + +| Parameter | Type | Description | +|---|---|---| +| ip | `IpAddr` | Listener bind IP. | +| announce | `String` | Public IP/domain announced in proxy links (priority over `announce_ip`). | +| announce_ip | `IpAddr` | Deprecated legacy announce IP (migrated to `announce` if needed). | +| proxy_protocol | `bool` | Per-listener override for PROXY protocol enable flag. | +| reuse_allow | `bool` | Enables `SO_REUSEPORT` for multi-instance bind sharing. | + +## [timeouts] + +| Parameter | Type | Description | +|---|---|---| +| client_handshake | `u64` | Client handshake timeout. | +| tg_connect | `u64` | Upstream Telegram connect timeout. | +| client_keepalive | `u64` | Client keepalive timeout. | +| client_ack | `u64` | Client ACK timeout. | +| me_one_retry | `u8` | Quick ME reconnect attempts for single-address DC. | +| me_one_timeout_ms | `u64` | Timeout per quick attempt for single-address DC (ms). | + +## [censorship] + +| Parameter | Type | Description | +|---|---|---| +| tls_domain | `String` | Primary TLS domain used in fake TLS handshake profile. | +| tls_domains | `String[]` | Additional TLS domains for generating multiple links. | +| mask | `bool` | Enables masking/fronting relay mode. | +| mask_host | `String` | Upstream mask host for TLS fronting relay. | +| mask_port | `u16` | Upstream mask port for TLS fronting relay. | +| mask_unix_sock | `String` | Unix socket path for mask backend instead of TCP host/port. | +| fake_cert_len | `usize` | Length of synthetic certificate payload when emulation data is unavailable. | +| tls_emulation | `bool` | Enables certificate/TLS behavior emulation from cached real fronts. | +| tls_front_dir | `String` | Directory path for TLS front cache storage. | +| server_hello_delay_min_ms | `u64` | Minimum server_hello delay for anti-fingerprint behavior (ms). | +| server_hello_delay_max_ms | `u64` | Maximum server_hello delay for anti-fingerprint behavior (ms). | +| tls_new_session_tickets | `u8` | Number of `NewSessionTicket` messages to emit after handshake. | +| tls_full_cert_ttl_secs | `u64` | TTL for sending full cert payload per (domain, client IP) tuple. | +| alpn_enforce | `bool` | Enforces ALPN echo behavior based on client preference. | +| mask_proxy_protocol | `u8` | PROXY protocol mode for mask backend (`0` disabled, `1` v1, `2` v2). | + +## [access] + +| Parameter | Type | Description | +|---|---|---| +| users | `Map` | Username -> 32-hex secret mapping. | +| user_ad_tags | `Map` | Per-user ad tags (32 hex chars). | +| user_max_tcp_conns | `Map` | Per-user maximum concurrent TCP connections. | +| user_expirations | `Map>` | Per-user account expiration timestamps. | +| user_data_quota | `Map` | Per-user data quota limits. | +| user_max_unique_ips | `Map` | Per-user unique source IP limits. | +| user_max_unique_ips_global_each | `usize` | Global fallback per-user unique IP limit when no per-user override exists. | +| user_max_unique_ips_mode | `"active_window" \| "time_window" \| "combined"` | Unique source IP limit accounting mode. | +| user_max_unique_ips_window_secs | `u64` | Recent-window size for unique IP accounting (seconds). | +| replay_check_len | `usize` | Replay check storage length. | +| replay_window_secs | `u64` | Replay protection time window in seconds. | +| ignore_time_skew | `bool` | Ignores client/server timestamp skew in replay validation. | + +## [[upstreams]] + +| Parameter | Type | Description | +|---|---|---| +| type | `"direct" \| "socks4" \| "socks5"` | Upstream transport type selector. | +| weight | `u16` | Weighted selection coefficient for this upstream. | +| enabled | `bool` | Enables/disables this upstream entry. | +| scopes | `String` | Comma-separated scope tags for routing. | +| interface | `String` | Optional outgoing interface name (`direct`, `socks4`, `socks5`). | +| bind_addresses | `String[]` | Optional source bind addresses for `direct` upstream. | +| address | `String` | Upstream proxy address (`host:port`) for SOCKS upstreams. | +| user_id | `String` | SOCKS4 user ID (only for `type = "socks4"`). | +| username | `String` | SOCKS5 username (only for `type = "socks5"`). | +| password | `String` | SOCKS5 password (only for `type = "socks5"`). | diff --git a/src/api/model.rs b/src/api/model.rs index c484466..5e64d2d 100644 --- a/src/api/model.rs +++ b/src/api/model.rs @@ -238,6 +238,7 @@ pub(super) struct MeWritersSummary { pub(super) available_pct: f64, pub(super) required_writers: usize, pub(super) alive_writers: usize, + pub(super) coverage_ratio: f64, pub(super) coverage_pct: f64, pub(super) fresh_alive_writers: usize, pub(super) fresh_coverage_pct: f64, @@ -286,6 +287,7 @@ pub(super) struct DcStatus { pub(super) floor_max: usize, pub(super) floor_capped: bool, pub(super) alive_writers: usize, + pub(super) coverage_ratio: f64, pub(super) coverage_pct: f64, pub(super) fresh_alive_writers: usize, pub(super) fresh_coverage_pct: f64, diff --git a/src/api/runtime_min.rs b/src/api/runtime_min.rs index 2336663..b217ad0 100644 --- a/src/api/runtime_min.rs +++ b/src/api/runtime_min.rs @@ -113,6 +113,7 @@ pub(super) struct RuntimeMeQualityDcRttData { pub(super) rtt_ema_ms: Option, pub(super) alive_writers: usize, pub(super) required_writers: usize, + pub(super) coverage_ratio: f64, pub(super) coverage_pct: f64, } @@ -389,6 +390,7 @@ pub(super) async fn build_runtime_me_quality_data(shared: &ApiShared) -> Runtime rtt_ema_ms: dc.rtt_ms, alive_writers: dc.alive_writers, required_writers: dc.required_writers, + coverage_ratio: dc.coverage_ratio, coverage_pct: dc.coverage_pct, }) .collect(), diff --git a/src/api/runtime_stats.rs b/src/api/runtime_stats.rs index 7e12ca7..b1bc9e3 100644 --- a/src/api/runtime_stats.rs +++ b/src/api/runtime_stats.rs @@ -317,6 +317,7 @@ async fn get_minimal_payload_cached( available_pct: status.available_pct, required_writers: status.required_writers, alive_writers: status.alive_writers, + coverage_ratio: status.coverage_ratio, coverage_pct: status.coverage_pct, fresh_alive_writers: status.fresh_alive_writers, fresh_coverage_pct: status.fresh_coverage_pct, @@ -374,6 +375,7 @@ async fn get_minimal_payload_cached( floor_max: entry.floor_max, floor_capped: entry.floor_capped, alive_writers: entry.alive_writers, + coverage_ratio: entry.coverage_ratio, coverage_pct: entry.coverage_pct, fresh_alive_writers: entry.fresh_alive_writers, fresh_coverage_pct: entry.fresh_coverage_pct, @@ -500,6 +502,7 @@ fn disabled_me_writers(now_epoch_secs: u64, reason: &'static str) -> MeWritersDa available_pct: 0.0, required_writers: 0, alive_writers: 0, + coverage_ratio: 0.0, coverage_pct: 0.0, fresh_alive_writers: 0, fresh_coverage_pct: 0.0, diff --git a/src/transport/middle_proxy/pool_reinit.rs b/src/transport/middle_proxy/pool_reinit.rs index 3d9d679..0d5c6f4 100644 --- a/src/transport/middle_proxy/pool_reinit.rs +++ b/src/transport/middle_proxy/pool_reinit.rs @@ -70,10 +70,12 @@ impl MePool { let mut missing_dc = Vec::::new(); let mut covered = 0usize; + let mut total = 0usize; for (dc, endpoints) in desired_by_dc { if endpoints.is_empty() { continue; } + total += 1; if endpoints .iter() .any(|addr| active_writer_addrs.contains(&(*dc, *addr))) @@ -85,7 +87,9 @@ impl MePool { } missing_dc.sort_unstable(); - let total = desired_by_dc.len().max(1); + if total == 0 { + return (1.0, missing_dc); + } let ratio = (covered as f32) / (total as f32); (ratio, missing_dc) } @@ -399,29 +403,21 @@ impl MePool { } if hardswap { - let mut fresh_missing_dc = Vec::<(i32, usize, usize)>::new(); - for (dc, endpoints) in &desired_by_dc { - if endpoints.is_empty() { - continue; - } - let required = self.required_writers_for_dc(endpoints.len()); - let fresh_count = writers - .iter() - .filter(|w| !w.draining.load(Ordering::Relaxed)) - .filter(|w| w.generation == generation) - .filter(|w| w.writer_dc == *dc) - .filter(|w| endpoints.contains(&w.addr)) - .count(); - if fresh_count < required { - fresh_missing_dc.push((*dc, fresh_count, required)); - } - } + let fresh_writer_addrs: HashSet<(i32, SocketAddr)> = writers + .iter() + .filter(|w| !w.draining.load(Ordering::Relaxed)) + .filter(|w| w.generation == generation) + .map(|w| (w.writer_dc, w.addr)) + .collect(); + let (fresh_coverage_ratio, fresh_missing_dc) = + Self::coverage_ratio(&desired_by_dc, &fresh_writer_addrs); if !fresh_missing_dc.is_empty() { warn!( previous_generation, generation, + fresh_coverage_ratio = format_args!("{fresh_coverage_ratio:.3}"), missing_dc = ?fresh_missing_dc, - "ME hardswap pending: fresh generation coverage incomplete" + "ME hardswap pending: fresh generation DC coverage incomplete" ); return; } @@ -491,3 +487,61 @@ impl MePool { self.zero_downtime_reinit_after_map_change(rng).await; } } + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use super::MePool; + + fn addr(octet: u8, port: u16) -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, octet)), port) + } + + #[test] + fn coverage_ratio_counts_dc_coverage_not_floor() { + let dc1 = addr(1, 2001); + let dc2 = addr(2, 2002); + + let mut desired_by_dc = HashMap::>::new(); + desired_by_dc.insert(1, HashSet::from([dc1])); + desired_by_dc.insert(2, HashSet::from([dc2])); + + let active_writer_addrs = HashSet::from([(1, dc1)]); + let (ratio, missing_dc) = MePool::coverage_ratio(&desired_by_dc, &active_writer_addrs); + + assert_eq!(ratio, 0.5); + assert_eq!(missing_dc, vec![2]); + } + + #[test] + fn coverage_ratio_ignores_empty_dc_groups() { + let dc1 = addr(1, 2001); + + let mut desired_by_dc = HashMap::>::new(); + desired_by_dc.insert(1, HashSet::from([dc1])); + desired_by_dc.insert(2, HashSet::new()); + + let active_writer_addrs = HashSet::from([(1, dc1)]); + let (ratio, missing_dc) = MePool::coverage_ratio(&desired_by_dc, &active_writer_addrs); + + assert_eq!(ratio, 1.0); + assert!(missing_dc.is_empty()); + } + + #[test] + fn coverage_ratio_reports_missing_dcs_sorted() { + let dc1 = addr(1, 2001); + let dc2 = addr(2, 2002); + + let mut desired_by_dc = HashMap::>::new(); + desired_by_dc.insert(2, HashSet::from([dc2])); + desired_by_dc.insert(1, HashSet::from([dc1])); + + let (ratio, missing_dc) = MePool::coverage_ratio(&desired_by_dc, &HashSet::new()); + + assert_eq!(ratio, 0.0); + assert_eq!(missing_dc, vec![1, 2]); + } +} diff --git a/src/transport/middle_proxy/pool_status.rs b/src/transport/middle_proxy/pool_status.rs index d32835c..214ee49 100644 --- a/src/transport/middle_proxy/pool_status.rs +++ b/src/transport/middle_proxy/pool_status.rs @@ -40,6 +40,7 @@ pub(crate) struct MeApiDcStatusSnapshot { pub floor_max: usize, pub floor_capped: bool, pub alive_writers: usize, + pub coverage_ratio: f64, pub coverage_pct: f64, pub fresh_alive_writers: usize, pub fresh_coverage_pct: f64, @@ -62,6 +63,7 @@ pub(crate) struct MeApiStatusSnapshot { pub available_pct: f64, pub required_writers: usize, pub alive_writers: usize, + pub coverage_ratio: f64, pub coverage_pct: f64, pub fresh_alive_writers: usize, pub fresh_coverage_pct: f64, @@ -342,6 +344,8 @@ impl MePool { let mut available_endpoints = 0usize; let mut alive_writers = 0usize; let mut fresh_alive_writers = 0usize; + let mut coverage_ratio_dcs_total = 0usize; + let mut coverage_ratio_dcs_covered = 0usize; let floor_mode = self.floor_mode(); let adaptive_cpu_cores = (self .me_adaptive_floor_cpu_cores_effective @@ -393,6 +397,12 @@ impl MePool { available_endpoints += dc_available_endpoints; alive_writers += dc_alive_writers; fresh_alive_writers += dc_fresh_alive_writers; + if endpoint_count > 0 { + coverage_ratio_dcs_total += 1; + if dc_alive_writers > 0 { + coverage_ratio_dcs_covered += 1; + } + } dcs.push(MeApiDcStatusSnapshot { dc, @@ -415,6 +425,11 @@ impl MePool { floor_max, floor_capped, alive_writers: dc_alive_writers, + coverage_ratio: if endpoint_count > 0 && dc_alive_writers > 0 { + 100.0 + } else { + 0.0 + }, coverage_pct: ratio_pct(dc_alive_writers, dc_required_writers), fresh_alive_writers: dc_fresh_alive_writers, fresh_coverage_pct: ratio_pct(dc_fresh_alive_writers, dc_required_writers), @@ -431,6 +446,7 @@ impl MePool { available_pct: ratio_pct(available_endpoints, configured_endpoints), required_writers, alive_writers, + coverage_ratio: ratio_pct(coverage_ratio_dcs_covered, coverage_ratio_dcs_total), coverage_pct: ratio_pct(alive_writers, required_writers), fresh_alive_writers, fresh_coverage_pct: ratio_pct(fresh_alive_writers, required_writers), diff --git a/tools/telemt_api.py b/tools/telemt_api.py new file mode 100644 index 0000000..36ba5e1 --- /dev/null +++ b/tools/telemt_api.py @@ -0,0 +1,728 @@ +""" +Telemt Control API Python Client +Full-coverage client for https://github.com/telemt/telemt + +Usage: + client = TelemtAPI("http://127.0.0.1:9091", auth_header="your-secret") + client.health() + client.create_user("alice", max_tcp_conns=10) + client.patch_user("alice", data_quota_bytes=1_000_000_000) + client.delete_user("alice") +""" + +from __future__ import annotations + +import json +import secrets +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Union +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + +class TememtAPIError(Exception): + """Raised when the API returns an error envelope or a transport error.""" + + def __init__(self, message: str, code: str | None = None, + http_status: int | None = None, request_id: int | None = None): + super().__init__(message) + self.code = code + self.http_status = http_status + self.request_id = request_id + + def __repr__(self) -> str: + return (f"TememtAPIError(message={str(self)!r}, code={self.code!r}, " + f"http_status={self.http_status}, request_id={self.request_id})") + + +# --------------------------------------------------------------------------- +# Response wrapper +# --------------------------------------------------------------------------- + +@dataclass +class APIResponse: + """Wraps a successful API response envelope.""" + ok: bool + data: Any + revision: str | None = None + + def __repr__(self) -> str: # pragma: no cover + return f"APIResponse(ok={self.ok}, revision={self.revision!r}, data={self.data!r})" + + +# --------------------------------------------------------------------------- +# Main client +# --------------------------------------------------------------------------- + +class TememtAPI: + """ + HTTP client for the Telemt Control API. + + Parameters + ---------- + base_url: + Scheme + host + port, e.g. ``"http://127.0.0.1:9091"``. + Trailing slash is stripped automatically. + auth_header: + Exact value for the ``Authorization`` header. + Leave *None* when ``auth_header`` is not configured server-side. + timeout: + Socket timeout in seconds for every request (default 10). + """ + + def __init__( + self, + base_url: str = "http://127.0.0.1:9091", + auth_header: str | None = None, + timeout: int = 10, + ) -> None: + self.base_url = base_url.rstrip("/") + self.auth_header = auth_header + self.timeout = timeout + + # ------------------------------------------------------------------ + # Low-level HTTP helpers + # ------------------------------------------------------------------ + + def _headers(self, extra: dict | None = None) -> dict: + h = {"Content-Type": "application/json; charset=utf-8", + "Accept": "application/json"} + if self.auth_header: + h["Authorization"] = self.auth_header + if extra: + h.update(extra) + return h + + def _request( + self, + method: str, + path: str, + body: dict | None = None, + if_match: str | None = None, + query: dict | None = None, + ) -> APIResponse: + url = self.base_url + path + if query: + qs = "&".join(f"{k}={v}" for k, v in query.items()) + url = f"{url}?{qs}" + + raw_body: bytes | None = None + if body is not None: + raw_body = json.dumps(body).encode() + + extra_headers: dict = {} + if if_match is not None: + extra_headers["If-Match"] = if_match + + req = Request( + url, + data=raw_body, + headers=self._headers(extra_headers), + method=method, + ) + + try: + with urlopen(req, timeout=self.timeout) as resp: + payload = json.loads(resp.read()) + except HTTPError as exc: + raw = exc.read() + try: + payload = json.loads(raw) + except Exception: + raise TememtAPIError( + str(exc), http_status=exc.code + ) from exc + err = payload.get("error", {}) + raise TememtAPIError( + err.get("message", str(exc)), + code=err.get("code"), + http_status=exc.code, + request_id=payload.get("request_id"), + ) from exc + except URLError as exc: + raise TememtAPIError(str(exc)) from exc + + if not payload.get("ok"): + err = payload.get("error", {}) + raise TememtAPIError( + err.get("message", "unknown error"), + code=err.get("code"), + request_id=payload.get("request_id"), + ) + + return APIResponse( + ok=True, + data=payload.get("data"), + revision=payload.get("revision"), + ) + + def _get(self, path: str, query: dict | None = None) -> APIResponse: + return self._request("GET", path, query=query) + + def _post(self, path: str, body: dict | None = None, + if_match: str | None = None) -> APIResponse: + return self._request("POST", path, body=body, if_match=if_match) + + def _patch(self, path: str, body: dict, + if_match: str | None = None) -> APIResponse: + return self._request("PATCH", path, body=body, if_match=if_match) + + def _delete(self, path: str, if_match: str | None = None) -> APIResponse: + return self._request("DELETE", path, if_match=if_match) + + # ------------------------------------------------------------------ + # Health & system + # ------------------------------------------------------------------ + + def health(self) -> APIResponse: + """GET /v1/health — liveness probe.""" + return self._get("/v1/health") + + def system_info(self) -> APIResponse: + """GET /v1/system/info — binary version, uptime, config hash.""" + return self._get("/v1/system/info") + + # ------------------------------------------------------------------ + # Runtime gates & initialization + # ------------------------------------------------------------------ + + def runtime_gates(self) -> APIResponse: + """GET /v1/runtime/gates — admission gates and startup progress.""" + return self._get("/v1/runtime/gates") + + def runtime_initialization(self) -> APIResponse: + """GET /v1/runtime/initialization — detailed startup timeline.""" + return self._get("/v1/runtime/initialization") + + # ------------------------------------------------------------------ + # Limits & security + # ------------------------------------------------------------------ + + def limits_effective(self) -> APIResponse: + """GET /v1/limits/effective — effective timeout/upstream/ME limits.""" + return self._get("/v1/limits/effective") + + def security_posture(self) -> APIResponse: + """GET /v1/security/posture — API auth, telemetry, log-level summary.""" + return self._get("/v1/security/posture") + + def security_whitelist(self) -> APIResponse: + """GET /v1/security/whitelist — current IP whitelist CIDRs.""" + return self._get("/v1/security/whitelist") + + # ------------------------------------------------------------------ + # Stats + # ------------------------------------------------------------------ + + def stats_summary(self) -> APIResponse: + """GET /v1/stats/summary — uptime, connection totals, user count.""" + return self._get("/v1/stats/summary") + + def stats_zero_all(self) -> APIResponse: + """GET /v1/stats/zero/all — zero-cost counters (core, upstream, ME, pool, desync).""" + return self._get("/v1/stats/zero/all") + + def stats_upstreams(self) -> APIResponse: + """GET /v1/stats/upstreams — upstream health + zero counters.""" + return self._get("/v1/stats/upstreams") + + def stats_minimal_all(self) -> APIResponse: + """GET /v1/stats/minimal/all — ME writers + DC snapshot (requires minimal_runtime_enabled).""" + return self._get("/v1/stats/minimal/all") + + def stats_me_writers(self) -> APIResponse: + """GET /v1/stats/me-writers — per-writer ME status (requires minimal_runtime_enabled).""" + return self._get("/v1/stats/me-writers") + + def stats_dcs(self) -> APIResponse: + """GET /v1/stats/dcs — per-DC coverage and writer counts (requires minimal_runtime_enabled).""" + return self._get("/v1/stats/dcs") + + # ------------------------------------------------------------------ + # Runtime deep-dive + # ------------------------------------------------------------------ + + def runtime_me_pool_state(self) -> APIResponse: + """GET /v1/runtime/me_pool_state — ME pool generation/writer/refill snapshot.""" + return self._get("/v1/runtime/me_pool_state") + + def runtime_me_quality(self) -> APIResponse: + """GET /v1/runtime/me_quality — ME KDF, route-drop, and per-DC RTT counters.""" + return self._get("/v1/runtime/me_quality") + + def runtime_upstream_quality(self) -> APIResponse: + """GET /v1/runtime/upstream_quality — per-upstream health, latency, DC preferences.""" + return self._get("/v1/runtime/upstream_quality") + + def runtime_nat_stun(self) -> APIResponse: + """GET /v1/runtime/nat_stun — NAT probe state, STUN servers, reflected IPs.""" + return self._get("/v1/runtime/nat_stun") + + def runtime_me_selftest(self) -> APIResponse: + """GET /v1/runtime/me-selftest — KDF/timeskew/IP/PID/BND health state.""" + return self._get("/v1/runtime/me-selftest") + + def runtime_connections_summary(self) -> APIResponse: + """GET /v1/runtime/connections/summary — live connection totals + top-N users (requires runtime_edge_enabled).""" + return self._get("/v1/runtime/connections/summary") + + def runtime_events_recent(self, limit: int | None = None) -> APIResponse: + """GET /v1/runtime/events/recent — recent ring-buffer events (requires runtime_edge_enabled). + + Parameters + ---------- + limit: + Optional cap on returned events (1–1000, server default 50). + """ + query = {"limit": str(limit)} if limit is not None else None + return self._get("/v1/runtime/events/recent", query=query) + + # ------------------------------------------------------------------ + # Users (read) + # ------------------------------------------------------------------ + + def list_users(self) -> APIResponse: + """GET /v1/users — list all users with connection/traffic info.""" + return self._get("/v1/users") + + def get_user(self, username: str) -> APIResponse: + """GET /v1/users/{username} — single user info.""" + return self._get(f"/v1/users/{_safe(username)}") + + # ------------------------------------------------------------------ + # Users (write) + # ------------------------------------------------------------------ + + def create_user( + self, + username: str, + *, + secret: str | None = None, + user_ad_tag: str | None = None, + max_tcp_conns: int | None = None, + expiration_rfc3339: str | None = None, + data_quota_bytes: int | None = None, + max_unique_ips: int | None = None, + if_match: str | None = None, + ) -> APIResponse: + """POST /v1/users — create a new user. + + Parameters + ---------- + username: + ``[A-Za-z0-9_.-]``, length 1–64. + secret: + Exactly 32 hex chars. Auto-generated if omitted. + user_ad_tag: + Exactly 32 hex chars. + max_tcp_conns: + Per-user concurrent TCP limit. + expiration_rfc3339: + RFC3339 expiration timestamp, e.g. ``"2025-12-31T23:59:59Z"``. + data_quota_bytes: + Per-user traffic quota in bytes. + max_unique_ips: + Per-user unique source IP limit. + if_match: + Optional ``If-Match`` revision for optimistic concurrency. + """ + body: Dict[str, Any] = {"username": username} + _opt(body, "secret", secret) + _opt(body, "user_ad_tag", user_ad_tag) + _opt(body, "max_tcp_conns", max_tcp_conns) + _opt(body, "expiration_rfc3339", expiration_rfc3339) + _opt(body, "data_quota_bytes", data_quota_bytes) + _opt(body, "max_unique_ips", max_unique_ips) + return self._post("/v1/users", body=body, if_match=if_match) + + def patch_user( + self, + username: str, + *, + secret: str | None = None, + user_ad_tag: str | None = None, + max_tcp_conns: int | None = None, + expiration_rfc3339: str | None = None, + data_quota_bytes: int | None = None, + max_unique_ips: int | None = None, + if_match: str | None = None, + ) -> APIResponse: + """PATCH /v1/users/{username} — partial update; only provided fields change. + + Parameters + ---------- + username: + Existing username to update. + secret: + New secret (32 hex chars). + user_ad_tag: + New ad tag (32 hex chars). + max_tcp_conns: + New TCP concurrency limit. + expiration_rfc3339: + New expiration timestamp. + data_quota_bytes: + New quota in bytes. + max_unique_ips: + New unique IP limit. + if_match: + Optional ``If-Match`` revision. + """ + body: Dict[str, Any] = {} + _opt(body, "secret", secret) + _opt(body, "user_ad_tag", user_ad_tag) + _opt(body, "max_tcp_conns", max_tcp_conns) + _opt(body, "expiration_rfc3339", expiration_rfc3339) + _opt(body, "data_quota_bytes", data_quota_bytes) + _opt(body, "max_unique_ips", max_unique_ips) + if not body: + raise ValueError("patch_user: at least one field must be provided") + return self._patch(f"/v1/users/{_safe(username)}", body=body, + if_match=if_match) + + def delete_user( + self, + username: str, + *, + if_match: str | None = None, + ) -> APIResponse: + """DELETE /v1/users/{username} — remove user; blocks deletion of last user. + + Parameters + ---------- + if_match: + Optional ``If-Match`` revision for optimistic concurrency. + """ + return self._delete(f"/v1/users/{_safe(username)}", if_match=if_match) + + # NOTE: POST /v1/users/{username}/rotate-secret currently returns 404 + # in the route matcher (documented limitation). The method is provided + # for completeness and future compatibility. + def rotate_secret( + self, + username: str, + *, + secret: str | None = None, + if_match: str | None = None, + ) -> APIResponse: + """POST /v1/users/{username}/rotate-secret — rotate user secret. + + .. warning:: + This endpoint currently returns ``404 not_found`` in all released + versions (documented route matcher limitation). The method is + included for future compatibility. + + Parameters + ---------- + secret: + New secret (32 hex chars). Auto-generated if omitted. + """ + body: Dict[str, Any] = {} + _opt(body, "secret", secret) + return self._post(f"/v1/users/{_safe(username)}/rotate-secret", + body=body or None, if_match=if_match) + + # ------------------------------------------------------------------ + # Convenience helpers + # ------------------------------------------------------------------ + + @staticmethod + def generate_secret() -> str: + """Generate a random 32-character hex secret suitable for user creation.""" + return secrets.token_hex(16) # 16 bytes → 32 hex chars + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _safe(username: str) -> str: + """Minimal guard: reject obvious path-injection attempts.""" + if "/" in username or "\\" in username: + raise ValueError(f"Invalid username: {username!r}") + return username + + +def _opt(d: dict, key: str, value: Any) -> None: + """Add key to dict only when value is not None.""" + if value is not None: + d[key] = value + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def _print(resp: APIResponse) -> None: + print(json.dumps(resp.data, indent=2)) + if resp.revision: + print(f"# revision: {resp.revision}", flush=True) + + +def _build_parser(): + import argparse + + p = argparse.ArgumentParser( + prog="telemt_api.py", + description="Telemt Control API CLI", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +COMMANDS (read) + health Liveness check + info System info (version, uptime, config hash) + status Runtime gates + startup progress + init Runtime initialization timeline + limits Effective limits (timeouts, upstream, ME) + posture Security posture summary + whitelist IP whitelist entries + summary Stats summary (conns, uptime, users) + zero Zero-cost counters (core/upstream/ME/pool/desync) + upstreams Upstream health + zero counters + minimal ME writers + DC snapshot [minimal_runtime_enabled] + me-writers Per-writer ME status [minimal_runtime_enabled] + dcs Per-DC coverage [minimal_runtime_enabled] + me-pool ME pool generation/writer/refill snapshot + me-quality ME KDF, route-drops, per-DC RTT + upstream-quality Per-upstream health + latency + nat-stun NAT probe state + STUN servers + me-selftest KDF/timeskew/IP/PID/BND health + connections Live connection totals + top-N [runtime_edge_enabled] + events [--limit N] Recent ring-buffer events [runtime_edge_enabled] + +COMMANDS (users) + users List all users + user Get single user + create [OPTIONS] Create user + patch [OPTIONS] Partial update user + delete Delete user + secret [--secret S] Rotate secret (reserved; returns 404 in current release) + gen-secret Print a random 32-hex secret and exit + +USER OPTIONS (for create / patch) + --secret S 32 hex chars + --ad-tag S 32 hex chars (ad tag) + --max-conns N Max concurrent TCP connections + --expires DATETIME RFC3339 expiration (e.g. 2026-12-31T23:59:59Z) + --quota N Data quota in bytes + --max-ips N Max unique source IPs + +EXAMPLES + telemt_api.py health + telemt_api.py -u http://10.0.0.1:9091 -a mysecret users + telemt_api.py create alice --max-conns 5 --quota 10000000000 + telemt_api.py patch alice --expires 2027-01-01T00:00:00Z + telemt_api.py delete alice + telemt_api.py events --limit 20 + """, + ) + + p.add_argument("-u", "--url", default="http://127.0.0.1:9091", + metavar="URL", help="API base URL (default: http://127.0.0.1:9091)") + p.add_argument("-a", "--auth", default=None, metavar="TOKEN", + help="Authorization header value") + p.add_argument("-t", "--timeout", type=int, default=10, metavar="SEC", + help="Request timeout in seconds (default: 10)") + + p.add_argument("command", nargs="?", default="help", + help="Command to run (see COMMANDS below)") + p.add_argument("arg", nargs="?", default=None, metavar="USERNAME", + help="Username for user commands") + + # user create/patch fields + p.add_argument("--secret", default=None) + p.add_argument("--ad-tag", dest="ad_tag", default=None) + p.add_argument("--max-conns", dest="max_conns", type=int, default=None) + p.add_argument("--expires", default=None) + p.add_argument("--quota", type=int, default=None) + p.add_argument("--max-ips", dest="max_ips", type=int, default=None) + + # events + p.add_argument("--limit", type=int, default=None, + help="Max events for `events` command") + + # optimistic concurrency + p.add_argument("--if-match", dest="if_match", default=None, + metavar="REVISION", help="If-Match revision header") + + return p + + +if __name__ == "__main__": + import sys + + parser = _build_parser() + args = parser.parse_args() + + cmd = (args.command or "help").lower() + + if cmd in ("help", "--help"): + parser.print_help() + sys.exit(0) + + if cmd == "gen-secret": + print(TememtAPI.generate_secret()) + sys.exit(0) + + api = TememtAPI(args.url, auth_header=args.auth, timeout=args.timeout) + + try: + # -- read endpoints -------------------------------------------------- + if cmd == "health": + _print(api.health()) + + elif cmd == "info": + _print(api.system_info()) + + elif cmd == "status": + _print(api.runtime_gates()) + + elif cmd == "init": + _print(api.runtime_initialization()) + + elif cmd == "limits": + _print(api.limits_effective()) + + elif cmd == "posture": + _print(api.security_posture()) + + elif cmd == "whitelist": + _print(api.security_whitelist()) + + elif cmd == "summary": + _print(api.stats_summary()) + + elif cmd == "zero": + _print(api.stats_zero_all()) + + elif cmd == "upstreams": + _print(api.stats_upstreams()) + + elif cmd == "minimal": + _print(api.stats_minimal_all()) + + elif cmd == "me-writers": + _print(api.stats_me_writers()) + + elif cmd == "dcs": + _print(api.stats_dcs()) + + elif cmd == "me-pool": + _print(api.runtime_me_pool_state()) + + elif cmd == "me-quality": + _print(api.runtime_me_quality()) + + elif cmd == "upstream-quality": + _print(api.runtime_upstream_quality()) + + elif cmd == "nat-stun": + _print(api.runtime_nat_stun()) + + elif cmd == "me-selftest": + _print(api.runtime_me_selftest()) + + elif cmd == "connections": + _print(api.runtime_connections_summary()) + + elif cmd == "events": + _print(api.runtime_events_recent(limit=args.limit)) + + # -- user read ------------------------------------------------------- + elif cmd == "users": + resp = api.list_users() + users = resp.data or [] + if not users: + print("No users configured.") + else: + fmt = "{:<24} {:>7} {:>14} {}" + print(fmt.format("USERNAME", "CONNS", "OCTETS", "LINKS")) + print("-" * 72) + for u in users: + links = (u.get("links") or {}) + all_links = (links.get("classic") or []) + \ + (links.get("secure") or []) + \ + (links.get("tls") or []) + link_str = all_links[0] if all_links else "-" + print(fmt.format( + u["username"], + u.get("current_connections", 0), + u.get("total_octets", 0), + link_str, + )) + if resp.revision: + print(f"# revision: {resp.revision}") + + elif cmd == "user": + if not args.arg: + parser.error("user command requires ") + _print(api.get_user(args.arg)) + + # -- user write ------------------------------------------------------ + elif cmd == "create": + if not args.arg: + parser.error("create command requires ") + resp = api.create_user( + args.arg, + secret=args.secret, + user_ad_tag=args.ad_tag, + max_tcp_conns=args.max_conns, + expiration_rfc3339=args.expires, + data_quota_bytes=args.quota, + max_unique_ips=args.max_ips, + if_match=args.if_match, + ) + d = resp.data or {} + print(f"Created: {d.get('user', {}).get('username')}") + print(f"Secret: {d.get('secret')}") + links = (d.get("user") or {}).get("links") or {} + for kind, lst in links.items(): + for link in (lst or []): + print(f"Link ({kind}): {link}") + if resp.revision: + print(f"# revision: {resp.revision}") + + elif cmd == "patch": + if not args.arg: + parser.error("patch command requires ") + if not any([args.secret, args.ad_tag, args.max_conns, + args.expires, args.quota, args.max_ips]): + parser.error("patch requires at least one field (--secret, --max-conns, --expires, --quota, --max-ips, --ad-tag)") + _print(api.patch_user( + args.arg, + secret=args.secret, + user_ad_tag=args.ad_tag, + max_tcp_conns=args.max_conns, + expiration_rfc3339=args.expires, + data_quota_bytes=args.quota, + max_unique_ips=args.max_ips, + if_match=args.if_match, + )) + + elif cmd == "delete": + if not args.arg: + parser.error("delete command requires ") + resp = api.delete_user(args.arg, if_match=args.if_match) + print(f"Deleted: {resp.data}") + if resp.revision: + print(f"# revision: {resp.revision}") + + elif cmd == "secret": + if not args.arg: + parser.error("secret command requires ") + _print(api.rotate_secret(args.arg, secret=args.secret, + if_match=args.if_match)) + + else: + print(f"Unknown command: {cmd!r}\nRun with 'help' to see available commands.", + file=sys.stderr) + sys.exit(1) + + except TememtAPIError as exc: + print(f"API error [{exc.http_status}] {exc.code}: {exc}", file=sys.stderr) + sys.exit(1) + except KeyboardInterrupt: + sys.exit(130)