523 lines
15 KiB
TypeScript
523 lines
15 KiB
TypeScript
/** Helpers for Telemt Control API envelopes — ops UI, not raw JSON dumps. */
|
|
|
|
export interface TelemtEnvelope<T = unknown> {
|
|
ok?: boolean
|
|
data?: T
|
|
revision?: string
|
|
error?: { code?: string; message?: string }
|
|
}
|
|
|
|
export interface SummaryData {
|
|
uptime_seconds?: number
|
|
connections_total?: number
|
|
connections_current?: number
|
|
connections_bad_total?: number
|
|
configured_users?: number
|
|
connections_bad_by_class?: Array<{ class: string; total: number }>
|
|
handshake_failures_by_class?: Array<{ class: string; total: number }>
|
|
handshake_failures_by_stage?: Array<{ stage: string; total: number }>
|
|
handshake_timeouts_total?: number
|
|
}
|
|
|
|
export interface UserInfo {
|
|
username: string
|
|
enabled?: boolean
|
|
in_runtime?: boolean
|
|
current_connections?: number
|
|
active_unique_ips?: number
|
|
active_unique_ips_list?: string[]
|
|
recent_unique_ips?: number
|
|
recent_unique_ips_list?: string[]
|
|
total_octets?: number
|
|
expiration_rfc3339?: string | null
|
|
max_tcp_conns?: number | null
|
|
user_ad_tag?: string | null
|
|
data_quota_bytes?: number | null
|
|
links?: {
|
|
classic?: string[]
|
|
secure?: string[]
|
|
tls?: string[]
|
|
tls_domains?: Array<{ domain?: string; link?: string }>
|
|
}
|
|
}
|
|
|
|
export function asRecord(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {}
|
|
}
|
|
|
|
/** ME pool snapshot — GET /v1/runtime/me_pool_state */
|
|
export interface RuntimeMePoolStateGenerationData {
|
|
active_generation?: number
|
|
warm_generation?: number
|
|
pending_hardswap_generation?: number
|
|
pending_hardswap_age_secs?: number | null
|
|
draining_generations?: number[]
|
|
}
|
|
|
|
export interface RuntimeMePoolStateHardswapData {
|
|
enabled?: boolean
|
|
pending?: boolean
|
|
}
|
|
|
|
export interface RuntimeMePoolStateWriterContourData {
|
|
warm?: number
|
|
active?: number
|
|
draining?: number
|
|
}
|
|
|
|
export interface RuntimeMePoolStateWriterHealthData {
|
|
healthy?: number
|
|
degraded?: number
|
|
draining?: number
|
|
}
|
|
|
|
export interface RuntimeMePoolStateWriterData {
|
|
total?: number
|
|
alive_non_draining?: number
|
|
draining?: number
|
|
degraded?: number
|
|
contour?: RuntimeMePoolStateWriterContourData
|
|
health?: RuntimeMePoolStateWriterHealthData
|
|
}
|
|
|
|
export interface RuntimeMePoolStateRefillDcData {
|
|
dc?: number
|
|
family?: string
|
|
inflight?: number
|
|
}
|
|
|
|
export interface RuntimeMePoolStateRefillData {
|
|
inflight_endpoints_total?: number
|
|
inflight_dc_total?: number
|
|
by_dc?: RuntimeMePoolStateRefillDcData[]
|
|
}
|
|
|
|
export interface RuntimeMePoolStatePayload {
|
|
generations?: RuntimeMePoolStateGenerationData
|
|
hardswap?: RuntimeMePoolStateHardswapData
|
|
writers?: RuntimeMePoolStateWriterData
|
|
refill?: RuntimeMePoolStateRefillData
|
|
}
|
|
|
|
export interface RuntimeMePoolStateData {
|
|
enabled?: boolean
|
|
reason?: string | null
|
|
generated_at_epoch_secs?: number
|
|
data?: RuntimeMePoolStatePayload | null
|
|
/** Legacy flat status some proxies expose */
|
|
status?: string
|
|
}
|
|
|
|
export interface RuntimeMeQualityCountersData {
|
|
idle_close_by_peer_total?: number
|
|
reader_eof_total?: number
|
|
kdf_drift_total?: number
|
|
kdf_port_only_drift_total?: number
|
|
reconnect_attempt_total?: number
|
|
reconnect_success_total?: number
|
|
}
|
|
|
|
export interface RuntimeMeQualityRouteDropData {
|
|
no_conn_total?: number
|
|
channel_closed_total?: number
|
|
queue_full_total?: number
|
|
queue_full_base_total?: number
|
|
queue_full_high_total?: number
|
|
}
|
|
|
|
export interface RuntimeMeQualityDrainGateData {
|
|
route_quorum_ok?: boolean
|
|
redundancy_ok?: boolean
|
|
block_reason?: string
|
|
updated_at_epoch_secs?: number
|
|
}
|
|
|
|
export interface RuntimeMeQualityFamilyStateData {
|
|
family?: string
|
|
state?: string
|
|
state_since_epoch_secs?: number
|
|
suppressed_until_epoch_secs?: number | null
|
|
fail_streak?: number
|
|
recover_success_streak?: number
|
|
}
|
|
|
|
export interface RuntimeMeQualityDcRttData {
|
|
dc?: number
|
|
rtt_ema_ms?: number | null
|
|
alive_writers?: number
|
|
required_writers?: number
|
|
coverage_pct?: number
|
|
}
|
|
|
|
export interface RuntimeMeQualityPayload {
|
|
counters?: RuntimeMeQualityCountersData
|
|
route_drops?: RuntimeMeQualityRouteDropData
|
|
drain_gate?: RuntimeMeQualityDrainGateData
|
|
family_states?: RuntimeMeQualityFamilyStateData[]
|
|
dc_rtt?: RuntimeMeQualityDcRttData[]
|
|
}
|
|
|
|
export interface RuntimeMeQualityData {
|
|
enabled?: boolean
|
|
reason?: string | null
|
|
generated_at_epoch_secs?: number
|
|
data?: RuntimeMeQualityPayload | null
|
|
}
|
|
|
|
/** GET /v1/stats/dcs — per-DC endpoints + RTT */
|
|
export interface DcEndpointWriters {
|
|
endpoint?: string
|
|
active_writers?: number
|
|
}
|
|
|
|
export interface DcStatus {
|
|
dc?: number
|
|
endpoints?: string[]
|
|
endpoint_writers?: DcEndpointWriters[]
|
|
available_endpoints?: number
|
|
available_pct?: number
|
|
required_writers?: number
|
|
floor_min?: number
|
|
floor_target?: number
|
|
floor_max?: number
|
|
floor_capped?: boolean
|
|
alive_writers?: number
|
|
coverage_pct?: number
|
|
fresh_alive_writers?: number
|
|
fresh_coverage_pct?: number
|
|
rtt_ms?: number | null
|
|
load?: number
|
|
}
|
|
|
|
export interface DcStatusData {
|
|
middle_proxy_enabled?: boolean
|
|
reason?: string | null
|
|
generated_at_epoch_secs?: number
|
|
dcs?: DcStatus[]
|
|
}
|
|
|
|
/** GET /v1/stats/me-writers — per-writer/endpoint latency */
|
|
export interface MeWriterStatus {
|
|
writer_id?: number
|
|
dc?: number | null
|
|
endpoint?: string
|
|
generation?: number
|
|
state?: string
|
|
draining?: boolean
|
|
degraded?: boolean
|
|
bound_clients?: number
|
|
idle_for_secs?: number | null
|
|
rtt_ema_ms?: number | null
|
|
matches_active_generation?: boolean
|
|
in_desired_map?: boolean
|
|
}
|
|
|
|
export interface MeWritersSummary {
|
|
configured_dc_groups?: number
|
|
configured_endpoints?: number
|
|
available_endpoints?: number
|
|
available_pct?: number
|
|
required_writers?: number
|
|
alive_writers?: number
|
|
coverage_pct?: number
|
|
fresh_alive_writers?: number
|
|
fresh_coverage_pct?: number
|
|
}
|
|
|
|
export interface MeWritersData {
|
|
middle_proxy_enabled?: boolean
|
|
reason?: string | null
|
|
generated_at_epoch_secs?: number
|
|
summary?: MeWritersSummary
|
|
writers?: MeWriterStatus[]
|
|
}
|
|
|
|
export function parseDcStatus(payload: unknown): DcStatusData {
|
|
const root = asRecord(unwrapData(payload) ?? payload)
|
|
const nested = asRecord(root.data)
|
|
if (Array.isArray(root.dcs)) {
|
|
return root as unknown as DcStatusData
|
|
}
|
|
if (Array.isArray(nested.dcs)) {
|
|
return {
|
|
middle_proxy_enabled: (nested.middle_proxy_enabled ?? root.middle_proxy_enabled) as
|
|
| boolean
|
|
| undefined,
|
|
reason: (nested.reason ?? root.reason) as string | null | undefined,
|
|
generated_at_epoch_secs: (nested.generated_at_epoch_secs ??
|
|
root.generated_at_epoch_secs) as number | undefined,
|
|
dcs: nested.dcs as DcStatus[],
|
|
}
|
|
}
|
|
return root as unknown as DcStatusData
|
|
}
|
|
|
|
export function parseMeWriters(payload: unknown): MeWritersData {
|
|
const root = asRecord(unwrapData(payload) ?? payload)
|
|
const nested = asRecord(root.data)
|
|
if (Array.isArray(root.writers) || root.summary) {
|
|
return root as unknown as MeWritersData
|
|
}
|
|
if (Array.isArray(nested.writers) || nested.summary) {
|
|
return {
|
|
middle_proxy_enabled: (nested.middle_proxy_enabled ?? root.middle_proxy_enabled) as
|
|
| boolean
|
|
| undefined,
|
|
reason: (nested.reason ?? root.reason) as string | null | undefined,
|
|
generated_at_epoch_secs: (nested.generated_at_epoch_secs ??
|
|
root.generated_at_epoch_secs) as number | undefined,
|
|
summary: nested.summary as MeWritersSummary | undefined,
|
|
writers: nested.writers as MeWriterStatus[] | undefined,
|
|
}
|
|
}
|
|
return root as unknown as MeWritersData
|
|
}
|
|
|
|
export function formatRttMs(ms: unknown): string {
|
|
const n = typeof ms === 'number' ? ms : Number(ms)
|
|
if (!Number.isFinite(n) || n < 0) return '—'
|
|
if (n < 10) return `${n.toFixed(1)} ms`
|
|
return `${Math.round(n)} ms`
|
|
}
|
|
|
|
export function parseMePoolState(payload: unknown): RuntimeMePoolStateData {
|
|
const root = asRecord(unwrapData(payload) ?? payload)
|
|
const nested = asRecord(root.data)
|
|
// Envelope may be { enabled, data: payload } or already the pool payload
|
|
if ('enabled' in root || 'generated_at_epoch_secs' in root || 'reason' in root) {
|
|
const inner =
|
|
nested.generations || nested.writers || nested.hardswap || nested.refill
|
|
? (nested as unknown as RuntimeMePoolStatePayload)
|
|
: asRecord(nested.data).generations || asRecord(nested.data).writers
|
|
? (asRecord(nested.data) as unknown as RuntimeMePoolStatePayload)
|
|
: (root.data as RuntimeMePoolStatePayload | null | undefined)
|
|
return {
|
|
enabled: root.enabled as boolean | undefined,
|
|
reason: (root.reason as string | null | undefined) ?? null,
|
|
generated_at_epoch_secs: root.generated_at_epoch_secs as number | undefined,
|
|
data: inner ?? null,
|
|
status: root.status as string | undefined,
|
|
}
|
|
}
|
|
return {
|
|
enabled: true,
|
|
data: root as unknown as RuntimeMePoolStatePayload,
|
|
status: root.status as string | undefined,
|
|
}
|
|
}
|
|
|
|
export function parseMeQuality(payload: unknown): RuntimeMeQualityData {
|
|
const root = asRecord(unwrapData(payload) ?? payload)
|
|
if ('enabled' in root || 'generated_at_epoch_secs' in root || 'reason' in root) {
|
|
return {
|
|
enabled: root.enabled as boolean | undefined,
|
|
reason: (root.reason as string | null | undefined) ?? null,
|
|
generated_at_epoch_secs: root.generated_at_epoch_secs as number | undefined,
|
|
data: (root.data as RuntimeMeQualityPayload | null | undefined) ?? null,
|
|
}
|
|
}
|
|
return {
|
|
enabled: true,
|
|
data: root as unknown as RuntimeMeQualityPayload,
|
|
}
|
|
}
|
|
|
|
export interface ApiEventRecord {
|
|
seq?: number
|
|
ts_epoch_secs?: number
|
|
event_type?: string
|
|
context?: string
|
|
}
|
|
|
|
export interface TlsFingerprintRow {
|
|
scope?: string
|
|
ja3?: string
|
|
ja4?: string
|
|
total?: number
|
|
auth_success?: number
|
|
bad_or_probe?: number
|
|
first_seen_epoch_secs?: number
|
|
last_seen_epoch_secs?: number
|
|
}
|
|
|
|
export interface UserTimelineItem {
|
|
id: string
|
|
at: number | null
|
|
title: string
|
|
detail: string
|
|
kind: 'active_ip' | 'recent_ip' | 'tls' | 'event'
|
|
}
|
|
|
|
export interface CreateUserResponse {
|
|
user: UserInfo
|
|
secret: string
|
|
}
|
|
|
|
export function formatEpoch(secs: unknown): string {
|
|
const n = typeof secs === 'number' ? secs : Number(secs)
|
|
if (!Number.isFinite(n) || n <= 0) return '—'
|
|
return new Date(n * 1000).toLocaleString('ru-RU')
|
|
}
|
|
|
|
export function buildUserTimeline(opts: {
|
|
user: UserInfo
|
|
events?: ApiEventRecord[]
|
|
tlsRows?: TlsFingerprintRow[]
|
|
}): UserTimelineItem[] {
|
|
const username = opts.user.username
|
|
const active = new Set(opts.user.active_unique_ips_list ?? [])
|
|
const items: UserTimelineItem[] = []
|
|
|
|
for (const ip of opts.user.active_unique_ips_list ?? []) {
|
|
items.push({
|
|
id: `active-${ip}`,
|
|
at: Date.now() / 1000,
|
|
title: 'Активный IP',
|
|
detail: ip,
|
|
kind: 'active_ip',
|
|
})
|
|
}
|
|
|
|
for (const ip of opts.user.recent_unique_ips_list ?? []) {
|
|
if (active.has(ip)) continue
|
|
items.push({
|
|
id: `recent-${ip}`,
|
|
at: null,
|
|
title: 'Недавний IP',
|
|
detail: `${ip} · в окне recent`,
|
|
kind: 'recent_ip',
|
|
})
|
|
}
|
|
|
|
for (const row of opts.tlsRows ?? []) {
|
|
if (row.scope && row.scope !== username) continue
|
|
const when = row.last_seen_epoch_secs ?? row.first_seen_epoch_secs ?? null
|
|
items.push({
|
|
id: `tls-${row.ja4 ?? row.ja3 ?? when}`,
|
|
at: when,
|
|
title: 'TLS fingerprint',
|
|
detail: [
|
|
row.scope ? `scope ${row.scope}` : null,
|
|
row.ja4 ? `JA4 ${row.ja4.slice(0, 18)}…` : null,
|
|
`наблюдений ${row.total ?? 0}`,
|
|
row.first_seen_epoch_secs
|
|
? `первый ${formatEpoch(row.first_seen_epoch_secs)}`
|
|
: null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · '),
|
|
kind: 'tls',
|
|
})
|
|
}
|
|
|
|
for (const ev of opts.events ?? []) {
|
|
const ctx = String(ev.context ?? '')
|
|
if (!ctx.toLowerCase().includes(username.toLowerCase())) continue
|
|
items.push({
|
|
id: `ev-${ev.seq ?? ev.ts_epoch_secs}-${ev.event_type}`,
|
|
at: ev.ts_epoch_secs ?? null,
|
|
title: String(ev.event_type ?? 'Событие'),
|
|
detail: ctx,
|
|
kind: 'event',
|
|
})
|
|
}
|
|
|
|
return items.sort((a, b) => {
|
|
if (a.at == null && b.at == null) return 0
|
|
if (a.at == null) return 1
|
|
if (b.at == null) return -1
|
|
return b.at - a.at
|
|
})
|
|
}
|
|
|
|
export function unwrapData<T>(payload: unknown): T | undefined {
|
|
if (payload == null) return undefined
|
|
if (typeof payload === 'object' && 'data' in (payload as object)) {
|
|
return (payload as TelemtEnvelope<T>).data
|
|
}
|
|
return payload as T
|
|
}
|
|
|
|
export function normalizeUsers(payload: unknown): UserInfo[] {
|
|
const data = unwrapData<unknown>(payload)
|
|
if (Array.isArray(data)) return data as UserInfo[]
|
|
if (data && typeof data === 'object' && Array.isArray((data as { users?: unknown }).users)) {
|
|
return (data as { users: UserInfo[] }).users
|
|
}
|
|
return []
|
|
}
|
|
|
|
export function formatNumber(value: unknown): string {
|
|
const n = typeof value === 'number' ? value : Number(value)
|
|
if (!Number.isFinite(n)) return '—'
|
|
return new Intl.NumberFormat('ru-RU').format(n)
|
|
}
|
|
|
|
export function formatUptime(seconds: unknown): string {
|
|
const n = typeof seconds === 'number' ? seconds : Number(seconds)
|
|
if (!Number.isFinite(n) || n < 0) return '—'
|
|
const total = Math.floor(n)
|
|
const d = Math.floor(total / 86400)
|
|
const h = Math.floor((total % 86400) / 3600)
|
|
const m = Math.floor((total % 3600) / 60)
|
|
if (d > 0) return `${d}д ${h}ч`
|
|
if (h > 0) return `${h}ч ${m}м`
|
|
return `${m}м`
|
|
}
|
|
|
|
export function formatBytes(octets: unknown): string {
|
|
const n = typeof octets === 'number' ? octets : Number(octets)
|
|
if (!Number.isFinite(n) || n < 0) return '—'
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
let v = n
|
|
let i = 0
|
|
while (v >= 1024 && i < units.length - 1) {
|
|
v /= 1024
|
|
i += 1
|
|
}
|
|
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
|
}
|
|
|
|
export function telegramHttpLink(link: string): string {
|
|
if (link.startsWith('tg://proxy?')) {
|
|
return `https://t.me/proxy?${link.slice('tg://proxy?'.length)}`
|
|
}
|
|
if (link.startsWith('tg://socks?')) {
|
|
return `https://t.me/socks?${link.slice('tg://socks?'.length)}`
|
|
}
|
|
return link
|
|
}
|
|
|
|
export interface UserShareLink {
|
|
mode: 'classic' | 'secure' | 'tls'
|
|
label: string
|
|
tg: string
|
|
http: string
|
|
}
|
|
|
|
export function collectUserShareLinks(links?: UserInfo['links']): UserShareLink[] {
|
|
if (!links) return []
|
|
const out: UserShareLink[] = []
|
|
for (const tg of links.classic ?? []) {
|
|
out.push({ mode: 'classic', label: 'Classic', tg, http: telegramHttpLink(tg) })
|
|
}
|
|
for (const tg of links.secure ?? []) {
|
|
out.push({ mode: 'secure', label: 'Secure', tg, http: telegramHttpLink(tg) })
|
|
}
|
|
for (const tg of links.tls ?? []) {
|
|
out.push({ mode: 'tls', label: 'TLS', tg, http: telegramHttpLink(tg) })
|
|
}
|
|
for (const row of links.tls_domains ?? []) {
|
|
if (!row.link) continue
|
|
out.push({
|
|
mode: 'tls',
|
|
label: row.domain ? `TLS · ${row.domain}` : 'TLS',
|
|
tg: row.link,
|
|
http: telegramHttpLink(row.link),
|
|
})
|
|
}
|
|
return out
|
|
}
|