Initial commit

This commit is contained in:
JetSprow
2026-04-29 05:12:39 +10:00
commit 27dbca9cbf
379 changed files with 43486 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
package config
import (
"log"
"os"
"strconv"
"time"
)
type Config struct {
ServerURL string
AuthToken string
LatencyInterval time.Duration
TraceInterval time.Duration
}
func Load() *Config {
cfg := &Config{
ServerURL: envOrDefault("SERVER_URL", ""),
AuthToken: envOrDefault("AUTH_TOKEN", ""),
LatencyInterval: envDuration("LATENCY_INTERVAL", 5*time.Minute),
TraceInterval: envDuration("TRACE_INTERVAL", 30*time.Minute),
}
if cfg.ServerURL == "" || cfg.AuthToken == "" {
log.Fatal("[config] SERVER_URL and AUTH_TOKEN are required")
}
return cfg
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envDuration(key string, fallback time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
return fallback
}
if d, err := time.ParseDuration(v); err == nil {
return d
}
if seconds, err := strconv.Atoi(v); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
return fallback
}

View File

@@ -0,0 +1,171 @@
package probe
import (
"bytes"
"context"
"encoding/json"
"errors"
"log"
"net"
"net/http"
"time"
"github.com/jboard/jboard-agent/internal/config"
)
// Three-carrier TCP ping targets (Chinese ISP backbone nodes)
var latencyTargets = []struct {
Carrier string
Host string
Port string
}{
{"mobile", "js-cm-v4.ip.zstaticcdn.com", "80"},
{"unicom", "js-cu-v4.ip.zstaticcdn.com", "80"},
{"telecom", "js-ct-v4.ip.zstaticcdn.com", "80"},
}
type latencyEntry struct {
Carrier string `json:"carrier"`
LatencyMs int `json:"latencyMs"`
}
type latencyPayload struct {
Latencies []latencyEntry `json:"latencies"`
}
// LatencyLoop periodically measures TCP ping latency to three carriers and pushes to J-Board.
func LatencyLoop(ctx context.Context, cfg *config.Config) {
ticker := time.NewTicker(cfg.LatencyInterval)
defer ticker.Stop()
// Run immediately
measureAndPush(cfg)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
measureAndPush(cfg)
}
}
}
func measureAndPush(cfg *config.Config) {
var entries []latencyEntry
for _, target := range latencyTargets {
ms := tcpPing(target.Host, target.Port)
if ms >= 0 {
entries = append(entries, latencyEntry{
Carrier: target.Carrier,
LatencyMs: ms,
})
log.Printf("[latency] %s: %dms", target.Carrier, ms)
} else {
log.Printf("[latency] %s: timeout", target.Carrier)
}
}
if len(entries) == 0 {
return
}
payload := latencyPayload{Latencies: entries}
body, _ := json.Marshal(payload)
if err := postToServer(cfg, "/api/agent/latency", body); err != nil {
log.Printf("[latency] push error: %v", err)
}
}
// tcpPing measures TCP handshake latency in milliseconds. Returns -1 on failure.
// The DNS lookup is intentionally performed before timing starts, matching
// classic probe panels such as Komari, so DNS jitter is not mixed into latency.
func tcpPing(host, port string) int {
const (
timeout = 3 * time.Second
highLatencyThreshold = 1000
highLatencyRetries = 3
)
ip, err := resolveIP(host)
if err != nil {
return -1
}
latency, err := measureTCPConnect(ip, port, timeout)
if err != nil {
return -1
}
best := latency
if latency > highLatencyThreshold {
for i := 0; i < highLatencyRetries; i++ {
retryLatency, retryErr := measureTCPConnect(ip, port, timeout)
if retryErr != nil {
continue
}
if retryLatency < best {
best = retryLatency
}
if retryLatency <= highLatencyThreshold {
break
}
}
}
return best
}
func resolveIP(host string) (string, error) {
if ip := net.ParseIP(host); ip != nil {
return host, nil
}
addrs, err := net.LookupHost(host)
if err != nil || len(addrs) == 0 {
return "", errors.New("failed to resolve target")
}
return addrs[0], nil
}
func measureTCPConnect(ip string, port string, timeout time.Duration) (int, error) {
start := time.Now()
conn, err := net.DialTimeout("tcp", net.JoinHostPort(ip, port), timeout)
if err != nil {
return -1, err
}
conn.Close()
return int(time.Since(start).Milliseconds()), nil
}
func postToServer(cfg *config.Config, path string, body []byte) error {
req, err := http.NewRequest("POST", cfg.ServerURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.AuthToken)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &httpError{StatusCode: resp.StatusCode}
}
return nil
}
type httpError struct {
StatusCode int
}
func (e *httpError) Error() string {
return "server returned " + http.StatusText(e.StatusCode)
}

View File

@@ -0,0 +1,200 @@
package probe
import (
"context"
"encoding/json"
"fmt"
"log"
"os/exec"
"strings"
"time"
"github.com/jboard/jboard-agent/internal/config"
)
// Traceroute targets — same as latency targets
var traceTargets = []struct {
Carrier string
IP string
}{
{"telecom", "219.141.136.12"},
{"mobile", "211.136.25.153"},
{"unicom", "210.22.70.3"},
}
type hopDetail struct {
Hop int `json:"hop"`
IP string `json:"ip"`
Geo string `json:"geo"`
Latency float64 `json:"latency"`
}
type traceResult struct {
Carrier string `json:"carrier"`
Hops []hopDetail `json:"hops"`
Summary string `json:"summary"`
HopCount int `json:"hopCount"`
}
type tracePayload struct {
Traces []traceResult `json:"traces"`
}
// nexttrace JSON output structures
type ntHop struct {
Success bool `json:"Success"`
Address *struct {
IP string `json:"IP"`
} `json:"Address"`
Geo *struct {
Asnumber string `json:"asnumber"`
Country string `json:"country"`
Prov string `json:"prov"`
City string `json:"city"`
Owner string `json:"owner"`
Isp string `json:"isp"`
} `json:"Geo"`
TTL int `json:"TTL"`
RTT int64 `json:"RTT"` // nanoseconds
}
type ntOutput struct {
Hops [][]ntHop `json:"Hops"`
}
// TraceLoop periodically runs traceroute to three carriers and pushes to J-Board.
func TraceLoop(ctx context.Context, cfg *config.Config) {
ticker := time.NewTicker(cfg.TraceInterval)
defer ticker.Stop()
// Run immediately
traceAndPush(cfg)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
traceAndPush(cfg)
}
}
}
func traceAndPush(cfg *config.Config) {
log.Println("[trace] starting trace cycle")
var results []traceResult
for _, target := range traceTargets {
hops, summary, err := runTrace(target.IP)
if err != nil {
log.Printf("[trace] %s (%s): %v", target.Carrier, target.IP, err)
continue
}
results = append(results, traceResult{
Carrier: target.Carrier,
Hops: hops,
Summary: summary,
HopCount: len(hops),
})
log.Printf("[trace] %s: %s (%d hops)", target.Carrier, summary, len(hops))
}
if len(results) == 0 {
log.Println("[trace] no results, skipping upload")
return
}
payload := tracePayload{Traces: results}
body, _ := json.Marshal(payload)
if err := postToServer(cfg, "/api/agent/trace", body); err != nil {
log.Printf("[trace] push error: %v — retrying in 10s", err)
time.Sleep(10 * time.Second)
if err := postToServer(cfg, "/api/agent/trace", body); err != nil {
log.Printf("[trace] retry failed: %v", err)
}
}
}
func runTrace(ip string) ([]hopDetail, string, error) {
cmd := exec.Command("nexttrace", "-j", "--no-color", "-n", ip)
out, err := cmd.Output()
if err != nil {
return nil, "", fmt.Errorf("nexttrace failed for %s: %w", ip, err)
}
var parsed ntOutput
if err := json.Unmarshal(out, &parsed); err != nil {
return nil, "", fmt.Errorf("parse nexttrace output for %s: %w", ip, err)
}
var hops []hopDetail
var asnumbers []string
for i, hopGroup := range parsed.Hops {
hop := hopDetail{Hop: i + 1}
for _, probe := range hopGroup {
if probe.Success && probe.Address != nil && probe.Address.IP != "" {
hop.IP = probe.Address.IP
hop.Latency = float64(probe.RTT) / 1e6
if probe.Geo != nil {
var parts []string
if probe.Geo.Country != "" {
parts = append(parts, probe.Geo.Country)
}
if probe.Geo.Prov != "" {
parts = append(parts, probe.Geo.Prov)
}
if probe.Geo.City != "" {
parts = append(parts, probe.Geo.City)
}
if probe.Geo.Owner != "" {
parts = append(parts, probe.Geo.Owner)
}
hop.Geo = strings.Join(parts, " ")
if probe.Geo.Asnumber != "" {
asnumbers = append(asnumbers, probe.Geo.Asnumber)
}
}
break
}
}
hops = append(hops, hop)
}
// Hide the first hop (server gateway IP) for security
if len(hops) > 0 {
hops[0].IP = "*"
hops[0].Geo = ""
}
summary := detectSummary(hops, asnumbers)
return hops, summary, nil
}
func detectSummary(hops []hopDetail, asnumbers []string) string {
combined := ""
for _, h := range hops {
combined += " " + strings.ToUpper(h.Geo)
}
asSet := ""
for _, asn := range asnumbers {
asSet += " " + asn
}
switch {
case strings.Contains(combined, "CN2") && strings.Contains(combined, "GIA"):
return "CN2 GIA"
case strings.Contains(combined, "CN2"):
return "CN2 GT"
case strings.Contains(asSet, "9929") || strings.Contains(combined, "CUII") || strings.Contains(combined, "A网"):
return "AS9929"
case strings.Contains(asSet, "4837"):
return "AS4837"
case strings.Contains(combined, "CMI") || strings.Contains(asSet, "58453"):
return "CMI"
case strings.Contains(combined, "CMIN2") || strings.Contains(asSet, "59807"):
return "CMIN2"
default:
return "普通线路"
}
}