14.04.2026 Update

This commit is contained in:
2026-04-15 11:38:26 +03:00
parent 6aa0349f5d
commit f50d79fab3
45 changed files with 5645 additions and 751 deletions

832
monitor/monitor.go Normal file
View File

@@ -0,0 +1,832 @@
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'))
}