Ground-Zerro / Geo-Aggregator Public
Code Issues Pull requests Actions Releases View on GitHub ↗
6.6 KB go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"gopkg.in/yaml.v3"
)

const (
	speedtestMapPath  = "db/speedtest.map.yml"
	speedtestPath     = "db/speedtest.json"
	speedtestVersion  = 1
	libreSpeedServers = "https://librespeed.org/backend-servers/servers.php"
	aliveTimeout      = 8 * time.Second
	aliveParallel     = 16
	aliveProbeBytes   = 64 << 10
)

var libreSpeedCountries = map[string]string{
	"Australia": "AU", "Austria": "AT", "Belgium": "BE", "Brazil": "BR", "Bulgaria": "BG", "Canada": "CA",
	"Czech Republic": "CZ", "Czechia": "CZ", "Denmark": "DK", "England": "GB", "Estonia": "EE", "Finland": "FI",
	"France": "FR", "Germany": "DE", "Greece": "GR", "Hong Kong": "HK", "Hungary": "HU", "India": "IN",
	"Ireland": "IE", "Italy": "IT", "Japan": "JP", "Kazakhstan": "KZ", "Latvia": "LV", "Lithuania": "LT",
	"Netherlands": "NL", "Norway": "NO", "Poland": "PL", "Portugal": "PT", "Romania": "RO", "Russia": "RU",
	"Serbia": "RS", "Singapore": "SG", "Spain": "ES", "Sweden": "SE", "Switzerland": "CH", "Turkey": "TR",
	"UK": "GB", "Ukraine": "UA", "United Kingdom": "GB", "United States": "US", "USA": "US",
}

type speedIperf3 struct {
	Host  string `yaml:"host" json:"host"`
	Ports string `yaml:"ports" json:"ports"`
}

type speedLibreSpeed struct {
	Download string `yaml:"dl" json:"dl"`
	Upload   string `yaml:"ul" json:"ul"`
	Ping     string `yaml:"ping" json:"ping"`
}

type speedNode struct {
	Country    string           `yaml:"country" json:"country"`
	City       string           `yaml:"city" json:"city"`
	Owner      string           `yaml:"owner" json:"owner"`
	Iperf3     *speedIperf3     `yaml:"iperf3" json:"iperf3,omitempty"`
	LibreSpeed *speedLibreSpeed `yaml:"librespeed" json:"librespeed,omitempty"`
	File       string           `yaml:"file" json:"file,omitempty"`
}

type speedtestFile struct {
	Version int         `json:"version"`
	Updated string      `json:"updated"`
	Nodes   []speedNode `json:"nodes"`
}

func buildSpeedtest(now time.Time) int {
	data, err := os.ReadFile(speedtestMapPath)
	if err != nil {
		fmt.Println(" ", err)
		return 0
	}
	var nodes []speedNode
	if err := yaml.Unmarshal(data, &nodes); err != nil {
		fmt.Println(" ", speedtestMapPath+":", err)
		return 0
	}
	nodes = append(nodes, importLibreSpeed()...)
	alive := checkSpeedNodes(nodes)
	sort.SliceStable(alive, func(i, j int) bool {
		a, b := alive[i], alive[j]
		if a.Country != b.Country {
			return a.Country < b.Country
		}
		if a.City != b.City {
			return a.City < b.City
		}
		return a.Owner < b.Owner
	})
	out, err := json.MarshalIndent(speedtestFile{Version: speedtestVersion, Updated: now.Format(time.RFC3339), Nodes: alive}, "", "  ")
	if err != nil {
		fmt.Println(" ", err)
		return 0
	}
	if err := os.WriteFile(speedtestPath, append(out, '\n'), 0644); err != nil {
		fmt.Println(" ", err)
		return 0
	}
	fmt.Printf("  %d узлов из %d\n", len(alive), len(nodes))
	return len(alive)
}

func importLibreSpeed() []speedNode {
	data, err := fetch(libreSpeedServers)
	if err != nil {
		fmt.Println("  LibreSpeed:", err)
		return nil
	}
	var servers []struct {
		Name    string `json:"name"`
		Server  string `json:"server"`
		DlURL   string `json:"dlURL"`
		UlURL   string `json:"ulURL"`
		PingURL string `json:"pingURL"`
		Sponsor string `json:"sponsorName"`
	}
	if err := json.Unmarshal(data, &servers); err != nil {
		fmt.Println("  LibreSpeed:", err)
		return nil
	}
	var nodes []speedNode
	for _, s := range servers {
		parts := strings.Split(stripParens(s.Name), ",")
		country, ok := libreSpeedCountries[strings.TrimSpace(parts[len(parts)-1])]
		if !ok || len(parts) < 2 {
			fmt.Println("  LibreSpeed: страна не распознана:", s.Name)
			continue
		}
		base := strings.TrimSuffix(s.Server, "/") + "/"
		nodes = append(nodes, speedNode{
			Country: country,
			City:    strings.TrimSpace(parts[0]),
			Owner:   s.Sponsor,
			LibreSpeed: &speedLibreSpeed{
				Download: base + strings.TrimPrefix(s.DlURL, "/"),
				Upload:   base + strings.TrimPrefix(s.UlURL, "/"),
				Ping:     base + strings.TrimPrefix(s.PingURL, "/"),
			},
		})
	}
	return nodes
}

func stripParens(s string) string {
	for {
		open := strings.Index(s, "(")
		if open < 0 {
			return s
		}
		end := strings.Index(s[open:], ")")
		if end < 0 {
			return s[:open]
		}
		s = s[:open] + s[open+end+1:]
	}
}

func checkSpeedNodes(nodes []speedNode) []speedNode {
	alive := make([]*speedNode, len(nodes))
	slots := make(chan struct{}, aliveParallel)
	var wg sync.WaitGroup
	for i := range nodes {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			slots <- struct{}{}
			defer func() { <-slots }()
			n := nodes[i]
			if n.Iperf3 != nil && !iperf3Alive(n.Iperf3) {
				fmt.Println("  нет ответа iperf3:", n.Owner, n.City, n.Iperf3.Host)
				n.Iperf3 = nil
			}
			if n.LibreSpeed != nil && !httpAlive(n.LibreSpeed.Download+"?ckSize=1") {
				fmt.Println("  нет ответа LibreSpeed:", n.Owner, n.City, n.LibreSpeed.Download)
				n.LibreSpeed = nil
			}
			if n.File != "" && !httpAlive(n.File) {
				fmt.Println("  нет ответа файла:", n.Owner, n.City, n.File)
				n.File = ""
			}
			if n.Iperf3 != nil || n.LibreSpeed != nil || n.File != "" {
				alive[i] = &n
			}
		}(i)
	}
	wg.Wait()
	var out []speedNode
	for _, n := range alive {
		if n != nil {
			out = append(out, *n)
		}
	}
	return out
}

func iperf3Alive(p *speedIperf3) bool {
	lo, hi, ok := strings.Cut(p.Ports, "-")
	if !ok {
		hi = lo
	}
	from, errLo := strconv.Atoi(lo)
	to, errHi := strconv.Atoi(hi)
	if errLo != nil || errHi != nil || to < from {
		return false
	}
	for port := from; port <= to && port < from+3; port++ {
		conn, err := net.DialTimeout("tcp4", net.JoinHostPort(p.Host, strconv.Itoa(port)), aliveTimeout)
		if err == nil {
			conn.Close()
			return true
		}
	}
	return false
}

var aliveClient = &http.Client{Transport: &http.Transport{
	DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) {
		return (&net.Dialer{Timeout: aliveTimeout}).DialContext(ctx, "tcp4", addr)
	},
	TLSHandshakeTimeout: aliveTimeout,
}}

func httpAlive(url string) bool {
	ctx, cancel := context.WithTimeout(context.Background(), aliveTimeout)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return false
	}
	resp, err := aliveClient.Do(req)
	if err != nil {
		return false
	}
	defer resp.Body.Close()
	n, _ := io.CopyN(io.Discard, resp.Body, aliveProbeBytes)
	return resp.StatusCode == http.StatusOK && n == aliveProbeBytes
}

func runSpeedtestOnly() {
	fmt.Println("=== Генерация db/speedtest.json ===")
	buildSpeedtest(time.Now().UTC())
}