832 lines
17 KiB
Go
832 lines
17 KiB
Go
|
|
package monitor
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"os"
|
||
|
|
"os/exec"
|
||
|
|
"regexp"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"nano-router/config"
|
||
|
|
"nano-router/mihomo"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
checkInterval = 30 * time.Second
|
||
|
|
speedInterval = 5 * time.Second
|
||
|
|
ipCheckInterval = 10 * time.Minute
|
||
|
|
minuteSlots = 60
|
||
|
|
)
|
||
|
|
|
||
|
|
type MinuteSlot struct {
|
||
|
|
Minute int64 `json:"minute"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
Pings map[string]int `json:"pings,omitempty"`
|
||
|
|
PingCF int `json:"ping_cf,omitempty"`
|
||
|
|
PingGG int `json:"ping_gg,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type ConnectivityState struct {
|
||
|
|
DirectUp bool `json:"direct_up"`
|
||
|
|
VPNUp bool `json:"vpn_up"`
|
||
|
|
DirectUpSince string `json:"direct_up_since,omitempty"`
|
||
|
|
VPNUpSince string `json:"vpn_up_since,omitempty"`
|
||
|
|
MinutesD []MinuteSlot `json:"minutes_direct"`
|
||
|
|
MinutesV []MinuteSlot `json:"minutes_vpn"`
|
||
|
|
EndpointNames []string `json:"endpoint_names"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type Sample struct {
|
||
|
|
Time time.Time `json:"time"`
|
||
|
|
RxPS float64 `json:"rx_bps"`
|
||
|
|
TxPS float64 `json:"tx_bps"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type IPInfo struct {
|
||
|
|
IP string `json:"ip"`
|
||
|
|
Country string `json:"country"`
|
||
|
|
CC string `json:"cc"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type SystemStats struct {
|
||
|
|
CPUPct float64 `json:"cpu_pct"`
|
||
|
|
MemPct float64 `json:"mem_pct"`
|
||
|
|
MemTotal uint64 `json:"mem_total"`
|
||
|
|
MemUsed uint64 `json:"mem_used"`
|
||
|
|
MemFree uint64 `json:"mem_free"`
|
||
|
|
Uptime int64 `json:"uptime"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type DashboardData struct {
|
||
|
|
System SystemStats `json:"system"`
|
||
|
|
Mihomo MihomoStatus `json:"mihomo"`
|
||
|
|
Connectivity ConnectivityState `json:"connectivity"`
|
||
|
|
GatewayIface string `json:"gateway_iface"`
|
||
|
|
TrafficAvg []Sample `json:"traffic_avg"`
|
||
|
|
TrafficReal []Sample `json:"traffic_real"`
|
||
|
|
IPDirect *IPInfo `json:"ip_direct"`
|
||
|
|
IPVPN *IPInfo `json:"ip_vpn"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type MihomoStatus struct {
|
||
|
|
Running bool `json:"running"`
|
||
|
|
PID int `json:"pid,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
var (
|
||
|
|
mu sync.RWMutex
|
||
|
|
connState ConnectivityState
|
||
|
|
minuteSlotsD [minuteSlots]MinuteSlot
|
||
|
|
minuteSlotsV [minuteSlots]MinuteSlot
|
||
|
|
gatewayIface string
|
||
|
|
prevRx uint64
|
||
|
|
prevTx uint64
|
||
|
|
prevSpeedTime time.Time
|
||
|
|
curRxPS float64
|
||
|
|
curTxPS float64
|
||
|
|
avgSamples []Sample
|
||
|
|
realSamples []Sample
|
||
|
|
ipDirect *IPInfo
|
||
|
|
ipVPN *IPInfo
|
||
|
|
ipDirectT time.Time
|
||
|
|
ipVPNT time.Time
|
||
|
|
|
||
|
|
directUpSince *time.Time
|
||
|
|
vpnUpSince *time.Time
|
||
|
|
wasDirectUp bool
|
||
|
|
wasVPNUp bool
|
||
|
|
|
||
|
|
prevCPUTotal uint64
|
||
|
|
prevCPUIdle uint64
|
||
|
|
cpuInitialized bool
|
||
|
|
|
||
|
|
stopCh chan struct{}
|
||
|
|
)
|
||
|
|
|
||
|
|
func Start() {
|
||
|
|
stopCh = make(chan struct{})
|
||
|
|
gatewayIface = detectGatewayIface()
|
||
|
|
for i := 0; i < minuteSlots; i++ {
|
||
|
|
minuteSlotsD[i] = MinuteSlot{Status: "no_data"}
|
||
|
|
minuteSlotsV[i] = MinuteSlot{Status: "no_data"}
|
||
|
|
}
|
||
|
|
go connectivityLoop()
|
||
|
|
go speedLoop()
|
||
|
|
go ipCheckLoop()
|
||
|
|
}
|
||
|
|
|
||
|
|
func Stop() {
|
||
|
|
close(stopCh)
|
||
|
|
}
|
||
|
|
|
||
|
|
func GetData() DashboardData {
|
||
|
|
mu.RLock()
|
||
|
|
defer mu.RUnlock()
|
||
|
|
|
||
|
|
cfg, _ := config.Load()
|
||
|
|
var epNames []string
|
||
|
|
if cfg != nil && len(cfg.Connectivity.Direct) > 0 {
|
||
|
|
for _, ep := range cfg.Connectivity.Direct {
|
||
|
|
epNames = append(epNames, ep.Name)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
epNames = []string{"Cloudflare", "Google"}
|
||
|
|
}
|
||
|
|
connState.EndpointNames = epNames
|
||
|
|
|
||
|
|
d := DashboardData{
|
||
|
|
System: readSystemStats(),
|
||
|
|
Mihomo: getMihomoRunning(),
|
||
|
|
Connectivity: connState,
|
||
|
|
GatewayIface: gatewayIface,
|
||
|
|
IPDirect: ipDirect,
|
||
|
|
IPVPN: ipVPN,
|
||
|
|
}
|
||
|
|
|
||
|
|
d.Connectivity.MinutesD = buildMinuteSlots(minuteSlotsD[:])
|
||
|
|
d.Connectivity.MinutesV = buildMinuteSlots(minuteSlotsV[:])
|
||
|
|
|
||
|
|
now := time.Now()
|
||
|
|
cutoff10m := now.Add(-10 * time.Minute)
|
||
|
|
cutoff1m := now.Add(-1 * time.Minute)
|
||
|
|
|
||
|
|
filteredAvg := make([]Sample, 0, len(avgSamples))
|
||
|
|
for _, s := range avgSamples {
|
||
|
|
if !s.Time.Before(cutoff10m) {
|
||
|
|
filteredAvg = append(filteredAvg, s)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
d.TrafficAvg = filteredAvg
|
||
|
|
|
||
|
|
filteredReal := make([]Sample, 0, len(realSamples))
|
||
|
|
for _, s := range realSamples {
|
||
|
|
if !s.Time.Before(cutoff1m) {
|
||
|
|
filteredReal = append(filteredReal, s)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
d.TrafficReal = filteredReal
|
||
|
|
|
||
|
|
if d.IPDirect != nil && time.Since(ipDirectT) > ipCheckInterval+time.Minute {
|
||
|
|
d.IPDirect = nil
|
||
|
|
}
|
||
|
|
if d.IPVPN != nil && time.Since(ipVPNT) > ipCheckInterval+time.Minute {
|
||
|
|
d.IPVPN = nil
|
||
|
|
}
|
||
|
|
|
||
|
|
return d
|
||
|
|
}
|
||
|
|
|
||
|
|
func getMihomoRunning() MihomoStatus {
|
||
|
|
st := mihomo.Status()
|
||
|
|
r, _ := st["running"].(bool)
|
||
|
|
s := MihomoStatus{Running: r}
|
||
|
|
if r {
|
||
|
|
if pid, ok := st["pid"].(int); ok {
|
||
|
|
s.PID = pid
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
|
||
|
|
func readSystemStats() SystemStats {
|
||
|
|
s := SystemStats{}
|
||
|
|
readCPUStats(&s)
|
||
|
|
readMemStats(&s)
|
||
|
|
readUptime(&s)
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
|
||
|
|
func readCPUStats(s *SystemStats) {
|
||
|
|
f, err := os.Open("/proc/stat")
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer f.Close()
|
||
|
|
|
||
|
|
scanner := bufio.NewScanner(f)
|
||
|
|
if !scanner.Scan() {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
line := scanner.Text()
|
||
|
|
if !strings.HasPrefix(line, "cpu ") {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
fields := strings.Fields(line)
|
||
|
|
if len(fields) < 5 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
var total, idle uint64
|
||
|
|
for i := 1; i < len(fields) && i <= 8; i++ {
|
||
|
|
v, _ := strconv.ParseUint(fields[i], 10, 64)
|
||
|
|
total += v
|
||
|
|
if i == 4 {
|
||
|
|
idle = v
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if cpuInitialized && prevCPUTotal > 0 {
|
||
|
|
dTotal := total - prevCPUTotal
|
||
|
|
dIdle := idle - prevCPUIdle
|
||
|
|
if dTotal > 0 {
|
||
|
|
s.CPUPct = float64(dTotal-dIdle) / float64(dTotal) * 100.0
|
||
|
|
}
|
||
|
|
}
|
||
|
|
prevCPUTotal = total
|
||
|
|
prevCPUIdle = idle
|
||
|
|
cpuInitialized = true
|
||
|
|
}
|
||
|
|
|
||
|
|
func readMemStats(s *SystemStats) {
|
||
|
|
f, err := os.Open("/proc/meminfo")
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer f.Close()
|
||
|
|
|
||
|
|
var memTotal, memFree, memAvailable, buffers, cached uint64
|
||
|
|
scanner := bufio.NewScanner(f)
|
||
|
|
for scanner.Scan() {
|
||
|
|
line := scanner.Text()
|
||
|
|
parts := strings.Fields(line)
|
||
|
|
if len(parts) < 2 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
val, _ := strconv.ParseUint(parts[1], 10, 64)
|
||
|
|
switch {
|
||
|
|
case strings.HasPrefix(parts[0], "MemTotal:"):
|
||
|
|
memTotal = val
|
||
|
|
case strings.HasPrefix(parts[0], "MemFree:"):
|
||
|
|
memFree = val
|
||
|
|
case strings.HasPrefix(parts[0], "MemAvailable:"):
|
||
|
|
memAvailable = val
|
||
|
|
case strings.HasPrefix(parts[0], "Buffers:"):
|
||
|
|
buffers = val
|
||
|
|
case strings.HasPrefix(parts[0], "Cached:"):
|
||
|
|
cached = val
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
s.MemTotal = memTotal * 1024
|
||
|
|
s.MemFree = memFree * 1024
|
||
|
|
used := memTotal - memAvailable
|
||
|
|
if memAvailable == 0 {
|
||
|
|
used = memTotal - memFree - buffers - cached
|
||
|
|
}
|
||
|
|
s.MemUsed = used * 1024
|
||
|
|
if memTotal > 0 {
|
||
|
|
s.MemPct = float64(used) / float64(memTotal) * 100.0
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func readUptime(s *SystemStats) {
|
||
|
|
f, err := os.Open("/proc/uptime")
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer f.Close()
|
||
|
|
var upSec float64
|
||
|
|
fmt.Fscanf(f, "%f", &upSec)
|
||
|
|
s.Uptime = int64(upSec)
|
||
|
|
}
|
||
|
|
|
||
|
|
func detectGatewayIface() string {
|
||
|
|
out, err := exec.Command("ip", "route", "show", "default").Output()
|
||
|
|
if err != nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
for _, line := range strings.Split(string(out), "\n") {
|
||
|
|
fields := strings.Fields(line)
|
||
|
|
for i, f := range fields {
|
||
|
|
if f == "dev" && i+1 < len(fields) {
|
||
|
|
return fields[i+1]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
func readIfaceBytes(iface string) (rx, tx uint64) {
|
||
|
|
f, err := os.Open("/proc/net/dev")
|
||
|
|
if err != nil {
|
||
|
|
return 0, 0
|
||
|
|
}
|
||
|
|
defer f.Close()
|
||
|
|
|
||
|
|
scanner := bufio.NewScanner(f)
|
||
|
|
for scanner.Scan() {
|
||
|
|
line := scanner.Text()
|
||
|
|
colon := strings.Index(line, ":")
|
||
|
|
if colon < 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
name := strings.TrimSpace(line[:colon])
|
||
|
|
if name != iface {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
fields := strings.Fields(line[colon+1:])
|
||
|
|
if len(fields) >= 10 {
|
||
|
|
rx, _ = strconv.ParseUint(fields[0], 10, 64)
|
||
|
|
tx, _ = strconv.ParseUint(fields[8], 10, 64)
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
return 0, 0
|
||
|
|
}
|
||
|
|
|
||
|
|
func connectivityLoop() {
|
||
|
|
ticker := time.NewTicker(checkInterval)
|
||
|
|
defer ticker.Stop()
|
||
|
|
|
||
|
|
checkConnectivity()
|
||
|
|
|
||
|
|
for {
|
||
|
|
select {
|
||
|
|
case <-ticker.C:
|
||
|
|
checkConnectivity()
|
||
|
|
case <-stopCh:
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func checkConnectivity() {
|
||
|
|
directResult := checkDirectConnectivity()
|
||
|
|
vpnResult := checkVPNConnectivity()
|
||
|
|
|
||
|
|
now := time.Now()
|
||
|
|
minuteIdx := int(now.Unix()/60) % minuteSlots
|
||
|
|
|
||
|
|
mu.Lock()
|
||
|
|
defer mu.Unlock()
|
||
|
|
|
||
|
|
connState.DirectUp = directResult.Up
|
||
|
|
connState.VPNUp = vpnResult.Up
|
||
|
|
|
||
|
|
if directResult.Up && !wasDirectUp {
|
||
|
|
directUpSince = &time.Time{}
|
||
|
|
*directUpSince = now
|
||
|
|
} else if !directResult.Up {
|
||
|
|
directUpSince = nil
|
||
|
|
}
|
||
|
|
wasDirectUp = directResult.Up
|
||
|
|
|
||
|
|
mihomoRunning := mihomo.IsRunning()
|
||
|
|
if vpnResult.Up && !wasVPNUp {
|
||
|
|
vpnUpSince = &time.Time{}
|
||
|
|
*vpnUpSince = now
|
||
|
|
} else if !vpnResult.Up {
|
||
|
|
vpnUpSince = nil
|
||
|
|
}
|
||
|
|
wasVPNUp = vpnResult.Up
|
||
|
|
|
||
|
|
if directUpSince != nil {
|
||
|
|
dur := now.Sub(*directUpSince)
|
||
|
|
connState.DirectUpSince = fmtDuration(dur)
|
||
|
|
} else {
|
||
|
|
connState.DirectUpSince = ""
|
||
|
|
}
|
||
|
|
if vpnUpSince != nil {
|
||
|
|
dur := now.Sub(*vpnUpSince)
|
||
|
|
connState.VPNUpSince = fmtDuration(dur)
|
||
|
|
} else {
|
||
|
|
connState.VPNUpSince = ""
|
||
|
|
}
|
||
|
|
|
||
|
|
statusD := "up"
|
||
|
|
if !directResult.Up {
|
||
|
|
statusD = "down"
|
||
|
|
}
|
||
|
|
statusV := "up"
|
||
|
|
if !vpnResult.Up {
|
||
|
|
statusV = "down"
|
||
|
|
}
|
||
|
|
if !mihomoRunning {
|
||
|
|
statusV = "no_data"
|
||
|
|
}
|
||
|
|
|
||
|
|
dSlot := MinuteSlot{Status: statusD, Pings: directResult.Pings}
|
||
|
|
if v, ok := directResult.Pings["Cloudflare"]; ok {
|
||
|
|
dSlot.PingCF = v
|
||
|
|
}
|
||
|
|
if v, ok := directResult.Pings["Google"]; ok {
|
||
|
|
dSlot.PingGG = v
|
||
|
|
}
|
||
|
|
minuteSlotsD[minuteIdx] = mergeSlot(minuteSlotsD[minuteIdx], dSlot)
|
||
|
|
|
||
|
|
vSlot := MinuteSlot{Status: statusV, Pings: make(map[string]int)}
|
||
|
|
if vpnResult.Up {
|
||
|
|
vSlot.Pings = vpnResult.Pings
|
||
|
|
if v, ok := vpnResult.Pings["Cloudflare"]; ok {
|
||
|
|
vSlot.PingCF = v
|
||
|
|
}
|
||
|
|
if v, ok := vpnResult.Pings["Google"]; ok {
|
||
|
|
vSlot.PingGG = v
|
||
|
|
}
|
||
|
|
}
|
||
|
|
minuteSlotsV[minuteIdx] = mergeSlot(minuteSlotsV[minuteIdx], vSlot)
|
||
|
|
}
|
||
|
|
|
||
|
|
func fmtDuration(d time.Duration) string {
|
||
|
|
s := int(d.Seconds())
|
||
|
|
if s < 0 {
|
||
|
|
s = 0
|
||
|
|
}
|
||
|
|
days := s / 86400
|
||
|
|
s %= 86400
|
||
|
|
hours := s / 3600
|
||
|
|
s %= 3600
|
||
|
|
mins := s / 60
|
||
|
|
secs := s % 60
|
||
|
|
|
||
|
|
parts := []string{}
|
||
|
|
if days > 0 {
|
||
|
|
parts = append(parts, fmt.Sprintf("%dd", days))
|
||
|
|
}
|
||
|
|
if hours > 0 || days > 0 {
|
||
|
|
parts = append(parts, fmt.Sprintf("%dh", hours))
|
||
|
|
}
|
||
|
|
if mins > 0 || hours > 0 || days > 0 {
|
||
|
|
parts = append(parts, fmt.Sprintf("%dm", mins))
|
||
|
|
}
|
||
|
|
parts = append(parts, fmt.Sprintf("%ds", secs))
|
||
|
|
return strings.Join(parts, " ")
|
||
|
|
}
|
||
|
|
|
||
|
|
func mergeSlot(existing, new MinuteSlot) MinuteSlot {
|
||
|
|
merged := MinuteSlot{
|
||
|
|
Status: new.Status,
|
||
|
|
Pings: make(map[string]int),
|
||
|
|
}
|
||
|
|
if new.Status == "down" && existing.Status == "up" {
|
||
|
|
merged.Status = "down"
|
||
|
|
} else if new.Status == "up" && existing.Status == "down" {
|
||
|
|
merged.Status = "down"
|
||
|
|
}
|
||
|
|
for k, v := range existing.Pings {
|
||
|
|
merged.Pings[k] = v
|
||
|
|
}
|
||
|
|
for k, v := range new.Pings {
|
||
|
|
if v > 0 {
|
||
|
|
merged.Pings[k] = v
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if v, ok := merged.Pings["Cloudflare"]; ok {
|
||
|
|
merged.PingCF = v
|
||
|
|
}
|
||
|
|
if v, ok := merged.Pings["Google"]; ok {
|
||
|
|
merged.PingGG = v
|
||
|
|
}
|
||
|
|
return merged
|
||
|
|
}
|
||
|
|
|
||
|
|
type connResult struct {
|
||
|
|
Up bool
|
||
|
|
Pings map[string]int
|
||
|
|
PingCF int
|
||
|
|
PingGG int
|
||
|
|
}
|
||
|
|
|
||
|
|
func checkDirectConnectivity() connResult {
|
||
|
|
result := connResult{Pings: make(map[string]int)}
|
||
|
|
client := &http.Client{Timeout: 8 * time.Second}
|
||
|
|
|
||
|
|
cfg, _ := config.Load()
|
||
|
|
if cfg == nil || len(cfg.Connectivity.Direct) == 0 {
|
||
|
|
def := config.DefaultConnectivity()
|
||
|
|
cfg = &config.AppConfig{Connectivity: def}
|
||
|
|
}
|
||
|
|
endpoints := cfg.Connectivity.Direct
|
||
|
|
|
||
|
|
anyUp := false
|
||
|
|
for _, ep := range endpoints {
|
||
|
|
start := time.Now()
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||
|
|
req, err := http.NewRequestWithContext(ctx, "GET", ep.URL, nil)
|
||
|
|
if err != nil {
|
||
|
|
cancel()
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
resp, err := client.Do(req)
|
||
|
|
elapsed := time.Since(start)
|
||
|
|
cancel()
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
resp.Body.Close()
|
||
|
|
ms := int(elapsed.Milliseconds())
|
||
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
||
|
|
anyUp = true
|
||
|
|
result.Pings[ep.Name] = ms
|
||
|
|
}
|
||
|
|
}
|
||
|
|
result.Up = anyUp
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func checkVPNConnectivity() connResult {
|
||
|
|
result := connResult{Pings: make(map[string]int)}
|
||
|
|
if !mihomo.IsRunning() {
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
mixedPort := getMihomoMixedPort()
|
||
|
|
if mixedPort == 0 {
|
||
|
|
mixedPort = 7890
|
||
|
|
}
|
||
|
|
|
||
|
|
proxyAddr := fmt.Sprintf("127.0.0.1:%d", mixedPort)
|
||
|
|
proxyURL, _ := url.Parse("http://" + proxyAddr)
|
||
|
|
|
||
|
|
proxyClient := &http.Client{
|
||
|
|
Timeout: 10 * time.Second,
|
||
|
|
Transport: &http.Transport{
|
||
|
|
Proxy: http.ProxyURL(proxyURL),
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
cfg, _ := config.Load()
|
||
|
|
if cfg == nil || len(cfg.Connectivity.ViaProxy) == 0 {
|
||
|
|
def := config.DefaultConnectivity()
|
||
|
|
cfg = &config.AppConfig{Connectivity: def}
|
||
|
|
}
|
||
|
|
endpoints := cfg.Connectivity.ViaProxy
|
||
|
|
|
||
|
|
anyUp := false
|
||
|
|
for _, ep := range endpoints {
|
||
|
|
start := time.Now()
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
|
|
req, err := http.NewRequestWithContext(ctx, "GET", ep.URL, nil)
|
||
|
|
if err != nil {
|
||
|
|
cancel()
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
resp, err := proxyClient.Do(req)
|
||
|
|
elapsed := time.Since(start)
|
||
|
|
cancel()
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
resp.Body.Close()
|
||
|
|
ms := int(elapsed.Milliseconds())
|
||
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
||
|
|
anyUp = true
|
||
|
|
result.Pings[ep.Name] = ms
|
||
|
|
}
|
||
|
|
}
|
||
|
|
result.Up = anyUp
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func getMihomoMixedPort() int {
|
||
|
|
cfg, err := mihomo.LoadConfig()
|
||
|
|
if err != nil {
|
||
|
|
return 7890
|
||
|
|
}
|
||
|
|
if mp, ok := cfg["mixed-port"]; ok {
|
||
|
|
switch v := mp.(type) {
|
||
|
|
case int:
|
||
|
|
return v
|
||
|
|
case float64:
|
||
|
|
return int(v)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return 7890
|
||
|
|
}
|
||
|
|
|
||
|
|
func speedLoop() {
|
||
|
|
iface := gatewayIface
|
||
|
|
if iface == "" {
|
||
|
|
iface = detectGatewayIface()
|
||
|
|
gatewayIface = iface
|
||
|
|
}
|
||
|
|
|
||
|
|
if iface == "" {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
rx, tx := readIfaceBytes(iface)
|
||
|
|
prevRx = rx
|
||
|
|
prevTx = tx
|
||
|
|
prevSpeedTime = time.Now()
|
||
|
|
|
||
|
|
ticker := time.NewTicker(speedInterval)
|
||
|
|
defer ticker.Stop()
|
||
|
|
|
||
|
|
for {
|
||
|
|
select {
|
||
|
|
case <-ticker.C:
|
||
|
|
captureSpeedSample()
|
||
|
|
case <-stopCh:
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func captureSpeedSample() {
|
||
|
|
iface := gatewayIface
|
||
|
|
if iface == "" {
|
||
|
|
iface = detectGatewayIface()
|
||
|
|
if iface == "" {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
mu.Lock()
|
||
|
|
gatewayIface = iface
|
||
|
|
mu.Unlock()
|
||
|
|
}
|
||
|
|
|
||
|
|
rx, tx := readIfaceBytes(iface)
|
||
|
|
now := time.Now()
|
||
|
|
|
||
|
|
mu.Lock()
|
||
|
|
defer mu.Unlock()
|
||
|
|
|
||
|
|
dt := now.Sub(prevSpeedTime).Seconds()
|
||
|
|
if dt > 0 && prevSpeedTime.Before(now) {
|
||
|
|
curRxPS = float64(rx-prevRx) * 8.0 / dt
|
||
|
|
curTxPS = float64(tx-prevTx) * 8.0 / dt
|
||
|
|
}
|
||
|
|
|
||
|
|
prevRx = rx
|
||
|
|
prevTx = tx
|
||
|
|
prevSpeedTime = now
|
||
|
|
|
||
|
|
sample := Sample{Time: now, RxPS: curRxPS, TxPS: curTxPS}
|
||
|
|
realSamples = append(realSamples, sample)
|
||
|
|
|
||
|
|
cutoff1m := now.Add(-1 * time.Minute)
|
||
|
|
filtered := make([]Sample, 0, len(realSamples))
|
||
|
|
for _, s := range realSamples {
|
||
|
|
if !s.Time.Before(cutoff1m) {
|
||
|
|
filtered = append(filtered, s)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
realSamples = filtered
|
||
|
|
|
||
|
|
avgSamples = append(avgSamples, sample)
|
||
|
|
cutoff10m := now.Add(-10 * time.Minute)
|
||
|
|
filteredAvg := make([]Sample, 0, len(avgSamples))
|
||
|
|
for _, s := range avgSamples {
|
||
|
|
if !s.Time.Before(cutoff10m) {
|
||
|
|
filteredAvg = append(filteredAvg, s)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
avgSamples = filteredAvg
|
||
|
|
}
|
||
|
|
|
||
|
|
func ipCheckLoop() {
|
||
|
|
checkIPs()
|
||
|
|
ticker := time.NewTicker(ipCheckInterval)
|
||
|
|
defer ticker.Stop()
|
||
|
|
|
||
|
|
for {
|
||
|
|
select {
|
||
|
|
case <-ticker.C:
|
||
|
|
checkIPs()
|
||
|
|
case <-stopCh:
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func checkIPs() {
|
||
|
|
directInfo := fetchIPDirect()
|
||
|
|
if directInfo != nil {
|
||
|
|
mu.Lock()
|
||
|
|
ipDirect = directInfo
|
||
|
|
ipDirectT = time.Now()
|
||
|
|
mu.Unlock()
|
||
|
|
}
|
||
|
|
|
||
|
|
if mihomo.IsRunning() {
|
||
|
|
vpnInfo := fetchIPVPN()
|
||
|
|
if vpnInfo != nil {
|
||
|
|
mu.Lock()
|
||
|
|
ipVPN = vpnInfo
|
||
|
|
ipVPNT = time.Now()
|
||
|
|
mu.Unlock()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func fetchIPDirect() *IPInfo {
|
||
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
||
|
|
return fetchIPFromServices(client)
|
||
|
|
}
|
||
|
|
|
||
|
|
func fetchIPVPN() *IPInfo {
|
||
|
|
mixedPort := getMihomoMixedPort()
|
||
|
|
proxyAddr := fmt.Sprintf("127.0.0.1:%d", mixedPort)
|
||
|
|
proxyURL, _ := url.Parse("http://" + proxyAddr)
|
||
|
|
|
||
|
|
client := &http.Client{
|
||
|
|
Timeout: 10 * time.Second,
|
||
|
|
Transport: &http.Transport{
|
||
|
|
Proxy: http.ProxyURL(proxyURL),
|
||
|
|
},
|
||
|
|
}
|
||
|
|
return fetchIPFromServices(client)
|
||
|
|
}
|
||
|
|
|
||
|
|
func fetchIPFromServices(client *http.Client) *IPInfo {
|
||
|
|
services := []struct {
|
||
|
|
url string
|
||
|
|
parse func(body []byte) *IPInfo
|
||
|
|
}{
|
||
|
|
{
|
||
|
|
"https://ipinfo.io/json",
|
||
|
|
func(body []byte) *IPInfo {
|
||
|
|
var data struct {
|
||
|
|
IP string `json:"ip"`
|
||
|
|
Country string `json:"country"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(body, &data); err != nil || data.IP == "" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return &IPInfo{IP: data.IP, Country: data.Country, CC: strings.ToLower(data.Country)}
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"https://ipapi.co/json/",
|
||
|
|
func(body []byte) *IPInfo {
|
||
|
|
var data struct {
|
||
|
|
IP string `json:"ip"`
|
||
|
|
CountryCode string `json:"country_code"`
|
||
|
|
CountryName string `json:"country_name"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(body, &data); err != nil || data.IP == "" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
cc := strings.ToLower(data.CountryCode)
|
||
|
|
country := data.CountryName
|
||
|
|
if country == "" {
|
||
|
|
country = data.CountryCode
|
||
|
|
}
|
||
|
|
return &IPInfo{IP: data.IP, Country: country, CC: cc}
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, svc := range services {
|
||
|
|
resp, err := client.Get(svc.url)
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||
|
|
resp.Body.Close()
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
info := svc.parse(body)
|
||
|
|
if info != nil {
|
||
|
|
return info
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func buildMinuteSlots(slots []MinuteSlot) []MinuteSlot {
|
||
|
|
result := make([]MinuteSlot, minuteSlots)
|
||
|
|
now := time.Now()
|
||
|
|
currentMinute := now.Unix() / 60
|
||
|
|
|
||
|
|
for i := 0; i < minuteSlots; i++ {
|
||
|
|
minuteOffset := minuteSlots - 1 - i
|
||
|
|
minuteIdx := int((currentMinute - int64(minuteOffset)) % minuteSlots)
|
||
|
|
if minuteIdx < 0 {
|
||
|
|
minuteIdx += minuteSlots
|
||
|
|
}
|
||
|
|
result[i] = slots[minuteIdx]
|
||
|
|
result[i].Minute = currentMinute - int64(minuteOffset)
|
||
|
|
if result[i].Status == "" {
|
||
|
|
result[i].Status = "no_data"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
var countryFlagRe = regexp.MustCompile(`^[a-zA-Z]{2}$`)
|
||
|
|
|
||
|
|
func FlagEmoji(cc string) string {
|
||
|
|
if !countryFlagRe.MatchString(cc) {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
cc = strings.ToUpper(cc)
|
||
|
|
runes := []rune(cc)
|
||
|
|
if len(runes) != 2 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
base := rune(0x1F1E6)
|
||
|
|
return string(base+(runes[0]-'A')) + string(base+(runes[1]-'A'))
|
||
|
|
}
|