Add Radar DC Telegram functionality and configuration
Publish telemt-api gateway Docker image / test (push) Successful in 23s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m9s

- Introduced new `RadarConfig` structure in `config.go` to manage radar settings, including `statuses_url`, `http_timeout_ms`, and `ping_from`.
- Implemented validation for radar configuration in `config_test.go` to ensure correct URL schemes and timeout limits.
- Added new API routes for radar statuses and ping functionality in the gateway, enhancing the service's capabilities.
- Updated documentation in `GATEWAY_RUN.md` to include details about the new radar features and their usage.
- Enhanced the user interface to include navigation and display options for the Radar DC section in the sidebar and page titles.
- Added client-side API functions for fetching radar statuses and ping responses, improving integration with the frontend.
This commit is contained in:
Denozordec
2026-04-12 12:53:20 +07:00
parent 6b2e38d051
commit d021e4b1d7
15 changed files with 723 additions and 1 deletions
+22
View File
@@ -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
}
+24
View File
@@ -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")
}
}
+29
View File
@@ -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
+124
View File
@@ -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
}
+19
View File
@@ -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)
}
}
}