diff --git a/README.md b/README.md index 8eb77f4..4f8c8f2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # telemt-api -HTTP‑шлюз на Go для [Telemt Control API](docs/API.md): один порт, **белый список IP (CIDR)**, маршруты вида `/api/{alias}/…` → `{base_url}/v1/…`, опционально **Mihomo** — `/api/{alias}/mihomo/…` к external-controller (см. [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md#mihomo-external-controller)), агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md), live SSE поток — `/api/live/events`, метрики Prometheus на `/metrics`. **Web UI** (SvelteKit) встроен в тот же процесс/образ: статика на `/`, API на `/api/…` и `/health`, раздел **Mihomo** на `/servers/{alias}/mihomo`. +HTTP‑шлюз на Go для [Telemt Control API](docs/API.md): один порт, **белый список IP (CIDR)**, маршруты вида `/api/{alias}/…` → `{base_url}/v1/…`, опционально **Mihomo** — `/api/{alias}/mihomo/…` к external-controller (см. [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md#mihomo-external-controller)), агрегация нескольких инстансов — [`/api/agg/…`](docs/AGGREGATE.md), live SSE поток — `/api/live/events`, **радар DC Telegram** — `GET /api/radar/statuses` и `GET /api/radar/ping-dc` (см. [docs/GATEWAY_RUN.md](docs/GATEWAY_RUN.md#radar-dc-telegram)), метрики Prometheus на `/metrics`. **Web UI** (SvelteKit) встроен в тот же процесс/образ: статика на `/`, API на `/api/…` и `/health`, раздел **Mihomo** на `/servers/{alias}/mihomo`, **Радар DC** на `/radar`. ## Быстрый старт (Linux) diff --git a/config.example.yaml b/config.example.yaml index 795cf80..7c8f4bb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -43,6 +43,12 @@ trusted_proxies: [] # - gt2 # cache_ttl_ms: 2000 +# Радар DC Telegram: прокси к radar.telemt.top и TCP-пинг DC с хоста шлюза (см. README). +# radar: +# statuses_url: "https://radar.telemt.top/api/v1/endpoints/statuses" +# http_timeout_ms: 30000 +# ping_from: "stats.example.com" + # Геолокация IP в /api/agg/unique-ips. См. docs/GEOIP.md (City + опционально ASN; Country-only не нужен) # geoip: # enabled: true diff --git a/docs/GATEWAY_RUN.md b/docs/GATEWAY_RUN.md index 8408a52..6292c7b 100644 --- a/docs/GATEWAY_RUN.md +++ b/docs/GATEWAY_RUN.md @@ -146,6 +146,32 @@ services: - `mihomo_authorization_env` в YAML — это **имя** переменной; **полная** строка `Authorization` (например `Bearer `) задаётся в **environment** контейнера gateway, не в YAML. - После изменений в этом месте пересоберите образ / задеплойте актуальный бинарь — иначе будет старое поведение. + + +### Радар DC Telegram + +Два встроенных маршрута (аналог `radar_proxy.php` и `ping_proxy.php` из [telemt-proxy-panel-bot-firewall](https://github.com/upe4d/telemt-proxy-panel-bot-firewall)): + +| Маршрут | Назначение | +|---------|------------| +| `GET /api/radar/statuses` | Прокси JSON с `https://radar.telemt.top/api/v1/endpoints/statuses` (без CORS в браузере к внешнему хосту). При ошибке исходящего запроса — HTTP 500 и `{"error":"failed"}`. | +| `GET /api/radar/ping-dc` | TCP-подключение с **хоста шлюза** к каждому из пяти DC Telegram на порт **443**, таймаут **2 с**; JSON с полями `ok`, `results`, `from` (как в PHP-панели). | + +В **Web UI** раздел **«Радар DC»**: путь `/radar`. + +Опциональный блок в `config.yaml` (см. [config.example.yaml](../config.example.yaml)): + +```yaml +radar: + statuses_url: "https://radar.telemt.top/api/v1/endpoints/statuses" + http_timeout_ms: 30000 + ping_from: "stats.example.com" +``` + +Поле `ping_from` задаёт строку `from` в ответе `ping-dc`; если пусто — используется заголовок `Host` запроса (без порта) или значение `telemt-gateway`. + +Зарезервированы **только** пути `/api/radar/statuses` и `/api/radar/ping-dc`. Остальные URL вида `/api/radar/…` по-прежнему относятся к серверу с **alias** `radar` в `servers[]`, если такой задан. + ## Переменные окружения | Переменная | Описание | diff --git a/go.mod b/go.mod index 524e30a..28b9c8e 100644 --- a/go.mod +++ b/go.mod @@ -8,3 +8,17 @@ require ( github.com/prometheus/client_golang v1.20.5 gopkg.in/yaml.v3 v3.0.1 ) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oschwald/maxminddb-golang v1.13.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + golang.org/x/sys v0.22.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a11f5d --- /dev/null +++ b/go.sum @@ -0,0 +1,48 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w= +github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= +github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= +github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 839bff8..199f33e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,17 @@ type Config struct { Servers []Server `yaml:"servers"` Aggregate *AggregateConfig `yaml:"aggregate"` GeoIP *GeoIPConfig `yaml:"geoip"` + Radar *RadarConfig `yaml:"radar"` +} + +// RadarConfig controls GET /api/radar/statuses and /api/radar/ping-dc (Telegram DC radar). +type RadarConfig struct { + // StatusesURL defaults to https://radar.telemt.top/api/v1/endpoints/statuses when empty. + StatusesURL string `yaml:"statuses_url"` + // HTTPTimeoutMs is outbound GET timeout for statuses; 0 means 30000. + HTTPTimeoutMs uint64 `yaml:"http_timeout_ms"` + // PingFrom is copied into JSON field "from" in ping-dc; empty uses request Host (no port) or "telemt-gateway". + PingFrom string `yaml:"ping_from"` } // GeoIPConfig enables GeoLite2 lookups for /api/agg/unique-ips (optional). @@ -162,6 +173,17 @@ func (c *Config) Validate() error { return fmt.Errorf("aggregate.cache_ttl_ms must be within [0, 60000]") } } + if c.Radar != nil { + if c.Radar.HTTPTimeoutMs > 600000 { + return fmt.Errorf("radar.http_timeout_ms must be within [0, 600000]") + } + if u := strings.TrimSpace(c.Radar.StatusesURL); u != "" { + pu, err := url.Parse(u) + if err != nil || (pu.Scheme != "http" && pu.Scheme != "https") || pu.Host == "" { + return fmt.Errorf("radar.statuses_url: invalid URL %q", c.Radar.StatusesURL) + } + } + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3090cd2..5b26ffe 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -114,3 +114,27 @@ func TestValidateAggregateCacheTTL(t *testing.T) { t.Fatal(err) } } + +func TestValidateRadarStatusesURL(t *testing.T) { + c := &Config{ + Servers: []Server{{Alias: "a", BaseURL: "http://x:1"}}, + Radar: &RadarConfig{StatusesURL: "ftp://bad"}, + } + if err := c.Validate(); err == nil { + t.Fatal("expected error for invalid radar.statuses_url scheme") + } + c.Radar.StatusesURL = "https://radar.example/api" + if err := c.Validate(); err != nil { + t.Fatal(err) + } +} + +func TestValidateRadarHTTPTimeoutMs(t *testing.T) { + c := &Config{ + Servers: []Server{{Alias: "a", BaseURL: "http://x:1"}}, + Radar: &RadarConfig{HTTPTimeoutMs: 600001}, + } + if err := c.Validate(); err == nil { + t.Fatal("expected error for radar.http_timeout_ms > 600000") + } +} diff --git a/internal/server/gateway.go b/internal/server/gateway.go index f9633bf..21d23d9 100644 --- a/internal/server/gateway.go +++ b/internal/server/gateway.go @@ -34,6 +34,9 @@ type Gateway struct { promHandler http.Handler corsAllowed []string webUI http.Handler + radarStatusesURL string + radarPingFrom string + radarHTTPClient *http.Client } func writeBadGatewayJSON(w http.ResponseWriter, expose bool, code, message string, upstreamErr error) { @@ -96,6 +99,21 @@ func NewGateway(p *config.Parsed, log *slog.Logger, geo *geoip.Service) (*Gatewa g.corsAllowed = append([]string(nil), p.Config.CorsAllowedOrigins...) g.agg = aggregate.NewHandler(p, &http.Client{Transport: t}, geo, aggCacheTTL) g.webUI = webui.Handler() + + statusesURL := defaultRadarStatusesURL + radarTo := 30 * time.Second + if p.Config.Radar != nil { + if u := strings.TrimSpace(p.Config.Radar.StatusesURL); u != "" { + statusesURL = u + } + if p.Config.Radar.HTTPTimeoutMs > 0 { + radarTo = time.Duration(p.Config.Radar.HTTPTimeoutMs) * time.Millisecond + } + g.radarPingFrom = strings.TrimSpace(p.Config.Radar.PingFrom) + } + g.radarStatusesURL = statusesURL + g.radarHTTPClient = &http.Client{Transport: t, Timeout: radarTo} + return g, nil } @@ -242,6 +260,13 @@ func routeEndpoint(path string) string { if path == "/api/agg" { return "agg" } + if strings.HasPrefix(path, "/api/radar/") { + rest := strings.TrimPrefix(path, "/api/radar/") + if rest == "" { + return "radar" + } + return "radar_" + strings.ReplaceAll(rest, "/", "_") + } if strings.HasPrefix(path, "/api/live/events") { return "live_events" } @@ -291,6 +316,10 @@ func (g *Gateway) serve(w http.ResponseWriter, r *http.Request) { g.serveLiveEvents(w, r) return } + if r.URL.Path == "/api/radar/statuses" || r.URL.Path == "/api/radar/ping-dc" { + g.serveRadar(w, r) + return + } if !strings.HasPrefix(r.URL.Path, prefix) { g.webUI.ServeHTTP(w, r) return diff --git a/internal/server/radar.go b/internal/server/radar.go new file mode 100644 index 0000000..a5ca025 --- /dev/null +++ b/internal/server/radar.go @@ -0,0 +1,124 @@ +package server + +import ( + "encoding/json" + "io" + "net" + "net/http" + "strconv" + "strings" + "time" +) + +const defaultRadarStatusesURL = "https://radar.telemt.top/api/v1/endpoints/statuses" + +// telegramDC443 — IPv4 DC Telegram для MTProto (как в ping_proxy.php исходной панели). +var telegramDC443 = map[int]string{ + 1: "149.154.175.50", + 2: "149.154.167.51", + 3: "149.154.175.100", + 4: "149.154.167.91", + 5: "149.154.171.5", +} + +func (g *Gateway) serveRadar(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/radar/statuses": + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + g.serveRadarStatuses(w, r) + case "/api/radar/ping-dc": + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + g.serveRadarPingDC(w, r) + default: + http.NotFound(w, r) + } +} + +// serveRadarStatuses проксирует JSON radar.telemt.top (аналог radar_proxy.php). +func (g *Gateway) serveRadarStatuses(w http.ResponseWriter, r *http.Request) { + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, g.radarStatusesURL, nil) + if err != nil { + writeRadarFailed(w) + return + } + resp, err := g.radarHTTPClient.Do(req) + if err != nil { + writeRadarFailed(w) + return + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + writeRadarFailed(w) + return + } + ct := resp.Header.Get("Content-Type") + if ct == "" { + ct = "application/json" + } + w.Header().Set("Content-Type", ct) + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, resp.Body) +} + +func writeRadarFailed(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "failed"}) +} + +// serveRadarPingDC — TCP :443 до каждого DC с таймаутом 2 с (аналог ping_proxy.php). +func (g *Gateway) serveRadarPingDC(w http.ResponseWriter, r *http.Request) { + from := strings.TrimSpace(g.radarPingFrom) + if from == "" { + from = hostOnly(r.Host) + if from == "" { + from = "telemt-gateway" + } + } + results := make(map[string]map[string]any, len(telegramDC443)) + for i := 1; i <= 5; i++ { + ip := telegramDC443[i] + addr := net.JoinHostPort(ip, "443") + t0 := time.Now() + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + ms := int(time.Since(t0).Milliseconds()) + key := strconv.Itoa(i) + if err != nil { + results[key] = map[string]any{"ok": false, "ms": nil} + continue + } + _ = conn.Close() + results[key] = map[string]any{"ok": true, "ms": ms} + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "results": results, + "from": from, + }) +} + +func hostOnly(hostPort string) string { + hostPort = strings.TrimSpace(hostPort) + if hostPort == "" { + return "" + } + // IPv6 в квадратных скобках: [::1]:8080 + if strings.HasPrefix(hostPort, "[") { + if i := strings.IndexByte(hostPort, ']'); i > 0 { + return hostPort[1:i] + } + } + h, _, err := net.SplitHostPort(hostPort) + if err != nil { + return hostPort + } + return h +} diff --git a/internal/server/radar_test.go b/internal/server/radar_test.go new file mode 100644 index 0000000..3051104 --- /dev/null +++ b/internal/server/radar_test.go @@ -0,0 +1,19 @@ +package server + +import "testing" + +func TestHostOnly(t *testing.T) { + tests := []struct { + in, want string + }{ + {"example.com:8080", "example.com"}, + {"[::1]:443", "::1"}, + {"127.0.0.1", "127.0.0.1"}, + {"", ""}, + } + for _, tt := range tests { + if got := hostOnly(tt.in); got != tt.want { + t.Errorf("hostOnly(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 5358a72..699f049 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -357,3 +357,52 @@ export async function deleteUser( } return parsed as TelemtSuccess; } + +/** Ответ GET /api/radar/ping-dc (как ping_proxy.php). */ +export type RadarPingRow = { ok: boolean; ms: number | null }; +export type RadarPingResponse = { ok: boolean; results: Record; from: string }; + +/** Прокси к radar.telemt.top — массив эндпоинтов (как radar_proxy.php). */ +export async function fetchRadarStatuses(): Promise { + const res = await fetch(`${gatewayBase()}/api/radar/statuses`); + const text = await res.text(); + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { + const j = JSON.parse(text) as { error?: string }; + if (j?.error) msg = j.error; + } catch { + /* ignore */ + } + throw new ApiError(msg, res.status, text); + } + let data: unknown; + try { + data = text ? JSON.parse(text) : null; + } catch { + throw new ApiError(`Ответ не JSON (HTTP ${res.status})`, res.status, text); + } + if (!Array.isArray(data)) { + throw new ApiError('radar: ожидался JSON-массив', res.status, data); + } + return data; +} + +export async function fetchRadarPingDC(): Promise { + const res = await fetch(`${gatewayBase()}/api/radar/ping-dc`); + const text = await res.text(); + let parsed: unknown; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + throw new ApiError(`Ответ не JSON (HTTP ${res.status})`, res.status, text); + } + if (!res.ok) { + throw new ApiError(`ping-dc HTTP ${res.status}`, res.status, parsed); + } + const body = parsed as RadarPingResponse; + if (!body || body.ok !== true || !body.results) { + throw new ApiError('ping-dc: неверный ответ', res.status, parsed); + } + return body; +} diff --git a/web/src/lib/breadcrumb-trail.ts b/web/src/lib/breadcrumb-trail.ts index 10fe832..a7e4e58 100644 --- a/web/src/lib/breadcrumb-trail.ts +++ b/web/src/lib/breadcrumb-trail.ts @@ -5,6 +5,7 @@ const SEG_LABELS: Record = { ips: 'Уникальные IP', incidents: 'Инциденты', live: 'Поток', + radar: 'Радар DC', mihomo: 'Mihomo', servers: 'Серверы', runtime: 'Состояние', diff --git a/web/src/lib/components/app-sidebar.svelte b/web/src/lib/components/app-sidebar.svelte index 331cb59..d54e58b 100644 --- a/web/src/lib/components/app-sidebar.svelte +++ b/web/src/lib/components/app-sidebar.svelte @@ -12,6 +12,7 @@ import ZapIcon from '@lucide/svelte/icons/zap'; import SirenIcon from '@lucide/svelte/icons/siren'; import RadioIcon from '@lucide/svelte/icons/radio'; + import RadarIcon from '@lucide/svelte/icons/radar'; import * as Sidebar from '$lib/components/ui/sidebar/index.js'; let { @@ -104,6 +105,16 @@ {/snippet} + + + {#snippet child({ props })} + + + Радар DC + + {/snippet} + + {#snippet child({ props })} diff --git a/web/src/lib/page-title.ts b/web/src/lib/page-title.ts index 3c8b61b..c857486 100644 --- a/web/src/lib/page-title.ts +++ b/web/src/lib/page-title.ts @@ -3,6 +3,7 @@ const SEG_LABELS: Record = { ips: 'Уникальные IP', incidents: 'Инциденты', live: 'Поток', + radar: 'Радар DC', mihomo: 'Mihomo', servers: 'Серверы', runtime: 'Состояние', diff --git a/web/src/routes/radar/+page.svelte b/web/src/routes/radar/+page.svelte new file mode 100644 index 0000000..b5755bc --- /dev/null +++ b/web/src/routes/radar/+page.svelte @@ -0,0 +1,348 @@ + + +
+
+
+ +
+

Радар DC Telegram

+

+ Источник матрицы: radar.telemt.top + {#if radarUpdated} + · обновлено {radarUpdated} + {/if} +

+
+
+ +
+ + {#if radarErr} + + Ошибка загрузки радара + {radarErr} + + {/if} + + + + Сводка по регионам (TCP 443 IPv4) + + Матрица ниже строится из ответа шлюза /api/radar/statuses (прокси к + radar.telemt.top). + + + + {#if radarLoading && !summaryHtml} +

Загрузка…

+ {:else if summaryHtml} +
{@html summaryHtml}
+ {/if} +
+
+ + + + Матрица доступности + + доступен + заблокирован + нет данных + + + + {#if radarLoading && !matrixHtml} +

Загрузка…

+ {:else if matrixHtml} + {@html matrixHtml} + {/if} + + + Про ICMP и TCP 443 + + ICMP может проходить даже при блокировке MTProto по TCP 443. Для Telegram важна доступность + TCP 443 до DC — её отражает radar и тест ниже. + + +
+
+ + + + + + Диагностика TCP с хоста шлюза + + + Запрос /api/radar/ping-dc — TCP :443 до каждого DC с таймаутом 2 с + (как ping_proxy.php). Поле «from» в ответе: источник метки на + сервере. + {#if pingFrom} + from: {pingFrom} + {/if} + + + + +
+ {#each [1, 2, 3, 4, 5] as n (n)} + {@const row = pingByDc[n]} + {@const st = row ? pingStyle(row.ms, row.ok) : { text: '—', barPct: 0, color: 'bg-muted' }} +
+
DC{n}
+
+
+
+
+
+
+ {diagLoading && !row ? '…' : st.text} +
+
+ {#if row} + {row.ok ? 'доступен' : 'заблокирован'} + {:else if !diagLoading} + ожидание + {/if} +
+
+ {/each} +
+
+
+ + + + Справка — датацентры Telegram + + +
+ + + + DC + Локация + Назначение + + + + {#each legendRows as row (row.dc)} + + {row.dc} + {row.loc} + {row.desc} + + {/each} + + +
+

+ Telegram назначает DC при регистрации; при блокировке своего DC нужен прокси в регионе, где DC + доступен по TCP 443. +

+
+
+