Add Radar DC Telegram functionality and configuration
- 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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user