mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
Initial commit
This commit is contained in:
3
agent/jboard-agent/.gitignore
vendored
Normal file
3
agent/jboard-agent/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
jboard-agent
|
||||
jboard-agent-linux-amd64
|
||||
jboard-agent-linux-arm64
|
||||
15
agent/jboard-agent/Makefile
Normal file
15
agent/jboard-agent/Makefile
Normal file
@@ -0,0 +1,15 @@
|
||||
BINARY := jboard-agent
|
||||
VERSION := 2.3.0
|
||||
LDFLAGS := -s -w
|
||||
|
||||
.PHONY: build build-linux clean
|
||||
|
||||
build:
|
||||
go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/agent
|
||||
|
||||
build-linux:
|
||||
GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(BINARY)-linux-amd64 ./cmd/agent
|
||||
GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o $(BINARY)-linux-arm64 ./cmd/agent
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY) $(BINARY)-linux-amd64 $(BINARY)-linux-arm64
|
||||
41
agent/jboard-agent/README.md
Normal file
41
agent/jboard-agent/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# jboard-agent
|
||||
|
||||
`jboard-agent` 只负责节点探测上报:
|
||||
|
||||
- 三网 TCP 延迟:`POST /api/agent/latency`
|
||||
- 三网路由跟踪:`POST /api/agent/trace`
|
||||
|
||||
节点入站、客户端开通、暂停、删除、流量限制等配置均由 3x-ui 面板维护。J-Board 后端不向节点下发 Xray/Hy2 配置。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
make build
|
||||
make build-linux
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
SERVER_URL=https://your-domain.com \
|
||||
AUTH_TOKEN=后台生成的探测Token \
|
||||
./jboard-agent
|
||||
```
|
||||
|
||||
可选环境变量:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `LATENCY_INTERVAL` | `5m` | 延迟探测间隔,支持 `30s`、`5m` 或秒数 |
|
||||
| `TRACE_INTERVAL` | `30m` | 路由探测间隔,支持 `30m` 或秒数 |
|
||||
|
||||
路由探测依赖 `nexttrace` 命令;延迟探测无需额外依赖。
|
||||
|
||||
## systemd
|
||||
|
||||
推荐从 J-Board 后台节点页复制一键安装命令。该命令会下载 release 二进制、安装 `nexttrace`、写入 systemd 服务并启动。
|
||||
|
||||
## 延迟算法
|
||||
|
||||
延迟探测使用三组 zstaticcdn 运营商目标,先解析域名再开始计时,只统计 TCP connect 耗时,避免 DNS 抖动混入延迟;当单次结果超过 1000ms 时会额外重试最多 3 次并采用更低的有效结果。
|
||||
35
agent/jboard-agent/cmd/agent/main.go
Normal file
35
agent/jboard-agent/cmd/agent/main.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime/debug"
|
||||
"syscall"
|
||||
|
||||
"github.com/jboard/jboard-agent/internal/config"
|
||||
"github.com/jboard/jboard-agent/internal/probe"
|
||||
)
|
||||
|
||||
const version = "2.3.0"
|
||||
|
||||
func main() {
|
||||
debug.SetGCPercent(50)
|
||||
|
||||
cfg := config.Load()
|
||||
log.Printf("[agent] jboard-agent v%s starting in probe-only mode — server=%s", version, cfg.ServerURL)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go probe.LatencyLoop(ctx, cfg)
|
||||
go probe.TraceLoop(ctx, cfg)
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
log.Println("[agent] shutting down...")
|
||||
cancel()
|
||||
}
|
||||
3
agent/jboard-agent/go.mod
Normal file
3
agent/jboard-agent/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module github.com/jboard/jboard-agent
|
||||
|
||||
go 1.22
|
||||
55
agent/jboard-agent/internal/config/config.go
Normal file
55
agent/jboard-agent/internal/config/config.go
Normal 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
|
||||
}
|
||||
171
agent/jboard-agent/internal/probe/latency.go
Normal file
171
agent/jboard-agent/internal/probe/latency.go
Normal 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)
|
||||
}
|
||||
200
agent/jboard-agent/internal/probe/trace.go
Normal file
200
agent/jboard-agent/internal/probe/trace.go
Normal 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 "普通线路"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user