mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: release v3.0.0 risk telemetry
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
BINARY := jboard-agent
|
BINARY := jboard-agent
|
||||||
VERSION := 2.3.0
|
VERSION := 3.0.0
|
||||||
LDFLAGS := -s -w
|
LDFLAGS := -s -w
|
||||||
|
|
||||||
.PHONY: build build-linux clean
|
.PHONY: build build-linux clean
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
# jboard-agent
|
# jboard-agent
|
||||||
|
|
||||||
`jboard-agent` 只负责节点探测上报:
|
`jboard-agent` 以旁路方式负责节点探测和可选的 Xray access log 风控上报:
|
||||||
|
|
||||||
- 三网 TCP 延迟:`POST /api/agent/latency`
|
- 三网 TCP 延迟:`POST /api/agent/latency`
|
||||||
- 三网路由跟踪:`POST /api/agent/trace`
|
- 三网路由跟踪:`POST /api/agent/trace`
|
||||||
|
- Xray access log 聚合:`POST /api/agent/node-access`,安装/升级脚本会自动探测并写入 `XRAY_ACCESS_LOG_PATH`
|
||||||
|
|
||||||
节点入站、客户端开通、暂停、删除、流量限制等配置均由 3x-ui 面板维护。J-Board 后端不向节点下发 Xray/Hy2 配置。
|
节点入站、客户端开通、暂停、删除、流量限制等配置均由 3x-ui 面板维护。Agent 只读日志文件,不修改 3x-ui 配置、不重启 Xray。
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
@@ -29,12 +30,16 @@ AUTH_TOKEN=后台生成的探测Token \
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `LATENCY_INTERVAL` | `5m` | 延迟探测间隔,支持 `30s`、`5m` 或秒数 |
|
| `LATENCY_INTERVAL` | `5m` | 延迟探测间隔,支持 `30s`、`5m` 或秒数 |
|
||||||
| `TRACE_INTERVAL` | `30m` | 路由探测间隔,支持 `30m` 或秒数 |
|
| `TRACE_INTERVAL` | `30m` | 路由探测间隔,支持 `30m` 或秒数 |
|
||||||
|
| `XRAY_ACCESS_LOG_PATH` | 自动探测 | Xray access log 路径;安装/升级脚本会优先查找 `/usr/local/x-ui/access.log` 等常见路径,仍为空时禁用节点真实连接风控 |
|
||||||
|
| `XRAY_LOG_INTERVAL` | `1m` | 日志读取和聚合上报间隔 |
|
||||||
|
| `XRAY_LOG_STATE_FILE` | `/var/lib/jboard-agent/xray-log-state.json` | 日志 offset 状态文件 |
|
||||||
|
| `XRAY_LOG_START_AT_END` | `1` | 首次启动从文件末尾开始,避免上传历史巨量日志;设为 `0` 可从头读取 |
|
||||||
|
|
||||||
路由探测依赖 `nexttrace` 命令;延迟探测无需额外依赖。
|
路由探测依赖 `nexttrace` 命令;延迟探测无需额外依赖。
|
||||||
|
|
||||||
## systemd
|
## systemd
|
||||||
|
|
||||||
推荐从 J-Board 后台节点页复制一键安装命令。该命令会下载 release 二进制、安装 `nexttrace`、写入 systemd 服务并启动。
|
推荐从 J-Board 后台节点页复制一键安装命令。该命令会下载 release 二进制、安装 `nexttrace`、自动探测 3x-ui/Xray access log、写入 systemd 服务并启动。
|
||||||
|
|
||||||
## 延迟算法
|
## 延迟算法
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"github.com/jboard/jboard-agent/internal/probe"
|
"github.com/jboard/jboard-agent/internal/probe"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "2.3.0"
|
const version = "3.0.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
debug.SetGCPercent(50)
|
debug.SetGCPercent(50)
|
||||||
@@ -25,6 +25,7 @@ func main() {
|
|||||||
|
|
||||||
go probe.LatencyLoop(ctx, cfg)
|
go probe.LatencyLoop(ctx, cfg)
|
||||||
go probe.TraceLoop(ctx, cfg)
|
go probe.TraceLoop(ctx, cfg)
|
||||||
|
go probe.XrayAccessLogLoop(ctx, cfg)
|
||||||
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,14 +14,23 @@ type Config struct {
|
|||||||
|
|
||||||
LatencyInterval time.Duration
|
LatencyInterval time.Duration
|
||||||
TraceInterval time.Duration
|
TraceInterval time.Duration
|
||||||
|
|
||||||
|
XrayAccessLogPath string
|
||||||
|
XrayLogStateFile string
|
||||||
|
XrayLogInterval time.Duration
|
||||||
|
XrayLogStartAtEnd bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ServerURL: envOrDefault("SERVER_URL", ""),
|
ServerURL: envOrDefault("SERVER_URL", ""),
|
||||||
AuthToken: envOrDefault("AUTH_TOKEN", ""),
|
AuthToken: envOrDefault("AUTH_TOKEN", ""),
|
||||||
LatencyInterval: envDuration("LATENCY_INTERVAL", 5*time.Minute),
|
LatencyInterval: envDuration("LATENCY_INTERVAL", 5*time.Minute),
|
||||||
TraceInterval: envDuration("TRACE_INTERVAL", 30*time.Minute),
|
TraceInterval: envDuration("TRACE_INTERVAL", 30*time.Minute),
|
||||||
|
XrayAccessLogPath: envOrDefault("XRAY_ACCESS_LOG_PATH", ""),
|
||||||
|
XrayLogStateFile: envOrDefault("XRAY_LOG_STATE_FILE", "/var/lib/jboard-agent/xray-log-state.json"),
|
||||||
|
XrayLogInterval: envDuration("XRAY_LOG_INTERVAL", time.Minute),
|
||||||
|
XrayLogStartAtEnd: envBool("XRAY_LOG_START_AT_END", true),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.ServerURL == "" || cfg.AuthToken == "" {
|
if cfg.ServerURL == "" || cfg.AuthToken == "" {
|
||||||
@@ -53,3 +63,19 @@ func envDuration(key string, fallback time.Duration) time.Duration {
|
|||||||
|
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envBool(key string, fallback bool) bool {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "1", "true", "yes", "y", "on":
|
||||||
|
return true
|
||||||
|
case "0", "false", "no", "n", "off":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
124
agent/jboard-agent/internal/probe/route_classify.go
Normal file
124
agent/jboard-agent/internal/probe/route_classify.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package probe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var asnPattern = regexp.MustCompile(`(?i)(?:^|\b)AS?\s*(\d{2,10})(?:\b|$)`)
|
||||||
|
|
||||||
|
func normalizeRouteText(value string) string {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeASN(value string) string {
|
||||||
|
text := normalizeRouteText(value)
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
match := asnPattern.FindStringSubmatch(text)
|
||||||
|
if len(match) > 1 {
|
||||||
|
return match[1]
|
||||||
|
}
|
||||||
|
for _, r := range text {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasASN(asns map[string]struct{}, values ...string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if _, ok := asns[value]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasText(combined string, values ...string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.Contains(combined, value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasIPPrefix(ips []string, prefixes ...string) bool {
|
||||||
|
for _, ip := range ips {
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
if strings.HasPrefix(ip, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectSummary(hops []hopDetail) string {
|
||||||
|
var texts []string
|
||||||
|
var ips []string
|
||||||
|
asns := make(map[string]struct{})
|
||||||
|
|
||||||
|
for _, hop := range hops {
|
||||||
|
if hop.IP != "" && hop.IP != "*" {
|
||||||
|
ips = append(ips, hop.IP)
|
||||||
|
}
|
||||||
|
parts := []string{hop.Geo, hop.ASN, hop.Owner, hop.ISP}
|
||||||
|
text := normalizeRouteText(strings.Join(parts, " "))
|
||||||
|
texts = append(texts, text)
|
||||||
|
|
||||||
|
if asn := normalizeASN(hop.ASN); asn != "" {
|
||||||
|
asns[asn] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, match := range asnPattern.FindAllStringSubmatch(text, -1) {
|
||||||
|
if len(match) > 1 {
|
||||||
|
asns[match[1]] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
combined := strings.Join(texts, " ")
|
||||||
|
cn2Evidence := hasASN(asns, "4809") ||
|
||||||
|
hasIPPrefix(ips, "59.43.") ||
|
||||||
|
hasText(combined, "CN2", "CTGNET", "CHINANET NEXT CARRYING NETWORK", "CHINA TELECOM GLOBAL")
|
||||||
|
cn2GIAText := hasText(combined, "CN2 GIA", "CN2GIA", "GIA", "GLOBAL INTERNET ACCESS")
|
||||||
|
ordinaryTelecomHops := 0
|
||||||
|
for _, text := range texts {
|
||||||
|
if strings.Contains(text, "AS4134") ||
|
||||||
|
strings.Contains(text, "CHINANET BACKBONE") ||
|
||||||
|
strings.Contains(text, "CHINANET 163") ||
|
||||||
|
strings.Contains(text, "163骨干") {
|
||||||
|
ordinaryTelecomHops++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
if strings.HasPrefix(ip, "202.97.") {
|
||||||
|
ordinaryTelecomHops++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cn2Evidence {
|
||||||
|
if cn2GIAText || ordinaryTelecomHops <= 1 {
|
||||||
|
return "CN2 GIA"
|
||||||
|
}
|
||||||
|
return "CN2 GT"
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasASN(asns, "9929", "10099") || hasText(combined, "CUII", "A网", "AS9929") {
|
||||||
|
return "AS9929"
|
||||||
|
}
|
||||||
|
if hasText(combined, "CMIN2") || hasASN(asns, "58807", "58809", "58813", "58819", "59807") {
|
||||||
|
return "CMIN2"
|
||||||
|
}
|
||||||
|
if hasText(combined, "CMI") || hasASN(asns, "58453") {
|
||||||
|
return "CMI"
|
||||||
|
}
|
||||||
|
if hasASN(asns, "4837") || hasText(combined, "AS4837") {
|
||||||
|
return "AS4837"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "普通线路"
|
||||||
|
}
|
||||||
35
agent/jboard-agent/internal/probe/route_classify_test.go
Normal file
35
agent/jboard-agent/internal/probe/route_classify_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package probe
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDetectSummaryCN2GIAFromAS4809And59_43(t *testing.T) {
|
||||||
|
hops := []hopDetail{
|
||||||
|
{Hop: 1, IP: "*"},
|
||||||
|
{Hop: 2, IP: "59.43.246.237", ASN: "AS4809", Geo: "中国 上海 China Telecom CN2"},
|
||||||
|
{Hop: 3, IP: "219.141.136.12", ASN: "AS4134", Geo: "中国 北京 电信"},
|
||||||
|
}
|
||||||
|
if got := detectSummary(hops); got != "CN2 GIA" {
|
||||||
|
t.Fatalf("detectSummary() = %q, want CN2 GIA", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectSummaryCN2GTWhenCN2FallsBackTo163(t *testing.T) {
|
||||||
|
hops := []hopDetail{
|
||||||
|
{Hop: 1, IP: "59.43.248.1", ASN: "AS4809", Geo: "CN2"},
|
||||||
|
{Hop: 2, IP: "202.97.12.1", ASN: "AS4134", Geo: "CHINANET BACKBONE"},
|
||||||
|
{Hop: 3, IP: "202.97.18.1", ASN: "AS4134", Geo: "CHINANET BACKBONE"},
|
||||||
|
}
|
||||||
|
if got := detectSummary(hops); got != "CN2 GT" {
|
||||||
|
t.Fatalf("detectSummary() = %q, want CN2 GT", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectSummaryCMIN2BeforeCMI(t *testing.T) {
|
||||||
|
hops := []hopDetail{
|
||||||
|
{Hop: 2, IP: "223.120.20.1", ASN: "AS58807", Geo: "CMIN2 China Mobile"},
|
||||||
|
{Hop: 3, IP: "211.136.25.153", ASN: "AS9808", Geo: "CMI Mobile"},
|
||||||
|
}
|
||||||
|
if got := detectSummary(hops); got != "CMIN2" {
|
||||||
|
t.Fatalf("detectSummary() = %q, want CMIN2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ type hopDetail struct {
|
|||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Geo string `json:"geo"`
|
Geo string `json:"geo"`
|
||||||
Latency float64 `json:"latency"`
|
Latency float64 `json:"latency"`
|
||||||
|
ASN string `json:"asn,omitempty"`
|
||||||
|
Owner string `json:"owner,omitempty"`
|
||||||
|
ISP string `json:"isp,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type traceResult struct {
|
type traceResult struct {
|
||||||
@@ -129,7 +132,6 @@ func runTrace(ip string) ([]hopDetail, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var hops []hopDetail
|
var hops []hopDetail
|
||||||
var asnumbers []string
|
|
||||||
for i, hopGroup := range parsed.Hops {
|
for i, hopGroup := range parsed.Hops {
|
||||||
hop := hopDetail{Hop: i + 1}
|
hop := hopDetail{Hop: i + 1}
|
||||||
for _, probe := range hopGroup {
|
for _, probe := range hopGroup {
|
||||||
@@ -151,9 +153,9 @@ func runTrace(ip string) ([]hopDetail, string, error) {
|
|||||||
parts = append(parts, probe.Geo.Owner)
|
parts = append(parts, probe.Geo.Owner)
|
||||||
}
|
}
|
||||||
hop.Geo = strings.Join(parts, " ")
|
hop.Geo = strings.Join(parts, " ")
|
||||||
if probe.Geo.Asnumber != "" {
|
hop.ASN = probe.Geo.Asnumber
|
||||||
asnumbers = append(asnumbers, probe.Geo.Asnumber)
|
hop.Owner = probe.Geo.Owner
|
||||||
}
|
hop.ISP = probe.Geo.Isp
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -167,34 +169,6 @@ func runTrace(ip string) ([]hopDetail, string, error) {
|
|||||||
hops[0].Geo = ""
|
hops[0].Geo = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
summary := detectSummary(hops, asnumbers)
|
summary := detectSummary(hops)
|
||||||
return hops, summary, nil
|
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 "普通线路"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
345
agent/jboard-agent/internal/probe/xraylog.go
Normal file
345
agent/jboard-agent/internal/probe/xraylog.go
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
package probe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jboard/jboard-agent/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxXrayReadBytes int64 = 2 * 1024 * 1024
|
||||||
|
const maxXrayEventsPerPush = 300
|
||||||
|
|
||||||
|
var xrayAccessLinePattern = regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})\s+(\S+)\s+(accepted|rejected)\s+(?:(tcp|udp):)?(\S+)\s+\[([^\]]+)\](?:.*?\bemail:\s*([^\s]+))?`)
|
||||||
|
|
||||||
|
type xrayLogState struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Inode uint64 `json:"inode"`
|
||||||
|
Offset int64 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nodeAccessEvent struct {
|
||||||
|
ClientEmail string `json:"clientEmail"`
|
||||||
|
SourceIP string `json:"sourceIp"`
|
||||||
|
InboundTag string `json:"inboundTag,omitempty"`
|
||||||
|
Network string `json:"network,omitempty"`
|
||||||
|
TargetHost string `json:"targetHost,omitempty"`
|
||||||
|
TargetPort int `json:"targetPort,omitempty"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
ConnectionCount int `json:"connectionCount"`
|
||||||
|
UniqueTargetCount int `json:"uniqueTargetCount,omitempty"`
|
||||||
|
FirstSeenAt string `json:"firstSeenAt,omitempty"`
|
||||||
|
LastSeenAt string `json:"lastSeenAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nodeAccessPayload struct {
|
||||||
|
Events []nodeAccessEvent `json:"events"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type parsedXrayAccess struct {
|
||||||
|
ClientEmail string
|
||||||
|
SourceIP string
|
||||||
|
InboundTag string
|
||||||
|
Network string
|
||||||
|
TargetHost string
|
||||||
|
TargetPort int
|
||||||
|
Action string
|
||||||
|
SeenAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type accessAggregate struct {
|
||||||
|
event nodeAccessEvent
|
||||||
|
targets map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func XrayAccessLogLoop(ctx context.Context, cfg *config.Config) {
|
||||||
|
if strings.TrimSpace(cfg.XrayAccessLogPath) == "" {
|
||||||
|
log.Println("[xray-log] disabled; set XRAY_ACCESS_LOG_PATH to enable node access risk telemetry")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(cfg.XrayLogInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
collectAndPushXrayLogs(cfg)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
collectAndPushXrayLogs(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectAndPushXrayLogs(cfg *config.Config) {
|
||||||
|
events, state, err := readNewXrayEvents(cfg)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
log.Printf("[xray-log] read error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(events) == 0 {
|
||||||
|
if err := saveXrayLogState(cfg.XrayLogStateFile, state); err != nil {
|
||||||
|
log.Printf("[xray-log] state save error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := nodeAccessPayload{Events: events}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
if err := postToServer(cfg, "/api/agent/node-access", body); err != nil {
|
||||||
|
log.Printf("[xray-log] push error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := saveXrayLogState(cfg.XrayLogStateFile, state); err != nil {
|
||||||
|
log.Printf("[xray-log] state save error: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("[xray-log] pushed %d aggregate access events", len(events))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readNewXrayEvents(cfg *config.Config) ([]nodeAccessEvent, xrayLogState, error) {
|
||||||
|
path := strings.TrimSpace(cfg.XrayAccessLogPath)
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xrayLogState{}, err
|
||||||
|
}
|
||||||
|
inode := fileInode(info)
|
||||||
|
state := loadXrayLogState(cfg.XrayLogStateFile)
|
||||||
|
|
||||||
|
if state.Path != path || state.Inode != inode || state.Offset > info.Size() {
|
||||||
|
state = xrayLogState{Path: path, Inode: inode}
|
||||||
|
if cfg.XrayLogStartAtEnd {
|
||||||
|
state.Offset = info.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Size() <= state.Offset {
|
||||||
|
return nil, state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
readBytes := info.Size() - state.Offset
|
||||||
|
if readBytes > maxXrayReadBytes {
|
||||||
|
readBytes = maxXrayReadBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, state, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, readBytes)
|
||||||
|
n, err := file.ReadAt(buf, state.Offset)
|
||||||
|
if err != nil && n == 0 {
|
||||||
|
return nil, state, err
|
||||||
|
}
|
||||||
|
data := string(buf[:n])
|
||||||
|
consumed := int64(n)
|
||||||
|
if lastNewline := strings.LastIndexByte(data, '\n'); lastNewline >= 0 && lastNewline < len(data)-1 {
|
||||||
|
data = data[:lastNewline+1]
|
||||||
|
consumed = int64(len(data))
|
||||||
|
}
|
||||||
|
state.Offset += consumed
|
||||||
|
|
||||||
|
events := aggregateXrayAccessLines(strings.Split(data, "\n"))
|
||||||
|
if len(events) > maxXrayEventsPerPush {
|
||||||
|
events = events[:maxXrayEventsPerPush]
|
||||||
|
}
|
||||||
|
|
||||||
|
return events, state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadXrayLogState(path string) xrayLogState {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return xrayLogState{}
|
||||||
|
}
|
||||||
|
var state xrayLogState
|
||||||
|
if err := json.Unmarshal(data, &state); err != nil {
|
||||||
|
return xrayLogState{}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveXrayLogState(path string, state xrayLogState) error {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(state)
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileInode(info os.FileInfo) uint64 {
|
||||||
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok || stat == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return uint64(stat.Ino)
|
||||||
|
}
|
||||||
|
|
||||||
|
func aggregateXrayAccessLines(lines []string) []nodeAccessEvent {
|
||||||
|
aggregates := make(map[string]*accessAggregate)
|
||||||
|
var order []string
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
parsed, ok := parseXrayAccessLine(line)
|
||||||
|
if !ok || parsed.ClientEmail == "" || parsed.SourceIP == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.Join([]string{
|
||||||
|
parsed.ClientEmail,
|
||||||
|
parsed.SourceIP,
|
||||||
|
parsed.InboundTag,
|
||||||
|
parsed.Network,
|
||||||
|
parsed.Action,
|
||||||
|
}, "|")
|
||||||
|
|
||||||
|
agg, ok := aggregates[key]
|
||||||
|
if !ok {
|
||||||
|
agg = &accessAggregate{
|
||||||
|
event: nodeAccessEvent{
|
||||||
|
ClientEmail: parsed.ClientEmail,
|
||||||
|
SourceIP: parsed.SourceIP,
|
||||||
|
InboundTag: parsed.InboundTag,
|
||||||
|
Network: parsed.Network,
|
||||||
|
TargetHost: parsed.TargetHost,
|
||||||
|
TargetPort: parsed.TargetPort,
|
||||||
|
Action: parsed.Action,
|
||||||
|
ConnectionCount: 0,
|
||||||
|
FirstSeenAt: parsed.SeenAt.Format(time.RFC3339),
|
||||||
|
LastSeenAt: parsed.SeenAt.Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
targets: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
aggregates[key] = agg
|
||||||
|
order = append(order, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
agg.event.ConnectionCount++
|
||||||
|
if parsed.TargetHost != "" {
|
||||||
|
agg.targets[parsed.TargetHost] = struct{}{}
|
||||||
|
}
|
||||||
|
if parsed.SeenAt.After(parseRFC3339OrZero(agg.event.LastSeenAt)) {
|
||||||
|
agg.event.LastSeenAt = parsed.SeenAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events := make([]nodeAccessEvent, 0, len(order))
|
||||||
|
for _, key := range order {
|
||||||
|
agg := aggregates[key]
|
||||||
|
agg.event.UniqueTargetCount = len(agg.targets)
|
||||||
|
events = append(events, agg.event)
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseXrayAccessLine(line string) (parsedXrayAccess, bool) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
return parsedXrayAccess{}, false
|
||||||
|
}
|
||||||
|
match := xrayAccessLinePattern.FindStringSubmatch(line)
|
||||||
|
if len(match) == 0 {
|
||||||
|
return parsedXrayAccess{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
seenAt, err := time.ParseInLocation("2006/01/02 15:04:05", match[1], time.Local)
|
||||||
|
if err != nil {
|
||||||
|
seenAt = time.Now()
|
||||||
|
}
|
||||||
|
network := strings.ToLower(match[4])
|
||||||
|
targetHost, targetPort := splitTarget(match[5])
|
||||||
|
if network == "" {
|
||||||
|
network = inferNetwork(match[5])
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedXrayAccess{
|
||||||
|
ClientEmail: strings.TrimSpace(match[7]),
|
||||||
|
SourceIP: stripPort(match[2]),
|
||||||
|
InboundTag: normalizeInboundTag(match[6]),
|
||||||
|
Network: network,
|
||||||
|
TargetHost: targetHost,
|
||||||
|
TargetPort: targetPort,
|
||||||
|
Action: strings.ToLower(match[3]),
|
||||||
|
SeenAt: seenAt,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeInboundTag(value string) string {
|
||||||
|
parts := strings.Split(value, ">>")
|
||||||
|
return strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferNetwork(target string) string {
|
||||||
|
if strings.HasPrefix(strings.ToLower(target), "udp:") {
|
||||||
|
return "udp"
|
||||||
|
}
|
||||||
|
return "tcp"
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitTarget(value string) (string, int) {
|
||||||
|
value = trimTransportPrefix(strings.TrimSpace(value))
|
||||||
|
if host, port, err := net.SplitHostPort(value); err == nil {
|
||||||
|
return strings.Trim(host, "[]"), atoiOrZero(port)
|
||||||
|
}
|
||||||
|
idx := strings.LastIndex(value, ":")
|
||||||
|
if idx > 0 && idx < len(value)-1 {
|
||||||
|
port := atoiOrZero(value[idx+1:])
|
||||||
|
if port > 0 {
|
||||||
|
return strings.Trim(value[:idx], "[]"), port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(value, "[]"), 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripPort(value string) string {
|
||||||
|
value = trimTransportPrefix(strings.TrimSpace(value))
|
||||||
|
if host, _, err := net.SplitHostPort(value); err == nil {
|
||||||
|
return strings.Trim(host, "[]")
|
||||||
|
}
|
||||||
|
idx := strings.LastIndex(value, ":")
|
||||||
|
if idx > 0 && idx < len(value)-1 && atoiOrZero(value[idx+1:]) > 0 {
|
||||||
|
return strings.Trim(value[:idx], "[]")
|
||||||
|
}
|
||||||
|
return strings.Trim(value, "[]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimTransportPrefix(value string) string {
|
||||||
|
lower := strings.ToLower(value)
|
||||||
|
if strings.HasPrefix(lower, "tcp:") || strings.HasPrefix(lower, "udp:") {
|
||||||
|
return value[4:]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoiOrZero(value string) int {
|
||||||
|
n, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRFC3339OrZero(value string) time.Time {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
63
agent/jboard-agent/internal/probe/xraylog_test.go
Normal file
63
agent/jboard-agent/internal/probe/xraylog_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package probe
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseXrayAccessLine(t *testing.T) {
|
||||||
|
line := "2026/04/29 10:11:12 203.0.113.9:51820 accepted tcp:example.com:443 [proxy-in >> freedom] email: user@example.com-cabc1234"
|
||||||
|
got, ok := parseXrayAccessLine(line)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("parseXrayAccessLine() failed")
|
||||||
|
}
|
||||||
|
if got.SourceIP != "203.0.113.9" {
|
||||||
|
t.Fatalf("SourceIP = %q", got.SourceIP)
|
||||||
|
}
|
||||||
|
if got.ClientEmail != "user@example.com-cabc1234" {
|
||||||
|
t.Fatalf("ClientEmail = %q", got.ClientEmail)
|
||||||
|
}
|
||||||
|
if got.InboundTag != "proxy-in" {
|
||||||
|
t.Fatalf("InboundTag = %q", got.InboundTag)
|
||||||
|
}
|
||||||
|
if got.Network != "tcp" || got.TargetHost != "example.com" || got.TargetPort != 443 {
|
||||||
|
t.Fatalf("target = %s %s:%d", got.Network, got.TargetHost, got.TargetPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseXrayAccessLineWithTransportPrefixedSource(t *testing.T) {
|
||||||
|
line := "2026/04/29 10:11:12 tcp:203.0.113.9:51820 accepted tcp:example.com:443 [proxy] email: user@example.com-cabc1234"
|
||||||
|
got, ok := parseXrayAccessLine(line)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("parseXrayAccessLine() failed")
|
||||||
|
}
|
||||||
|
if got.SourceIP != "203.0.113.9" {
|
||||||
|
t.Fatalf("SourceIP = %q", got.SourceIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseXrayAccessLineWithIPv6Source(t *testing.T) {
|
||||||
|
line := "2026/04/29 10:11:12 tcp:[2001:db8::1]:51820 accepted tcp:example.com:443 [proxy] email: user@example.com-cabc1234"
|
||||||
|
got, ok := parseXrayAccessLine(line)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("parseXrayAccessLine() failed")
|
||||||
|
}
|
||||||
|
if got.SourceIP != "2001:db8::1" {
|
||||||
|
t.Fatalf("SourceIP = %q", got.SourceIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateXrayAccessLines(t *testing.T) {
|
||||||
|
lines := []string{
|
||||||
|
"2026/04/29 10:11:12 203.0.113.9:51820 accepted tcp:example.com:443 [proxy] email: user@example.com-cabc1234",
|
||||||
|
"2026/04/29 10:11:13 203.0.113.9:51821 accepted tcp:openai.com:443 [proxy] email: user@example.com-cabc1234",
|
||||||
|
"2026/04/29 10:11:14 198.51.100.2:51821 accepted udp:1.1.1.1:53 [proxy] email: user@example.com-cabc1234",
|
||||||
|
}
|
||||||
|
events := aggregateXrayAccessLines(lines)
|
||||||
|
if len(events) != 2 {
|
||||||
|
t.Fatalf("len(events) = %d, want 2", len(events))
|
||||||
|
}
|
||||||
|
if events[0].ConnectionCount != 2 || events[0].UniqueTargetCount != 2 {
|
||||||
|
t.Fatalf("first aggregate = %+v", events[0])
|
||||||
|
}
|
||||||
|
if events[1].Network != "udp" || events[1].TargetPort != 53 {
|
||||||
|
t.Fatalf("second aggregate = %+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "j-board",
|
"name": "j-board",
|
||||||
"version": "0.1.0",
|
"version": "3.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "j-board",
|
"name": "j-board",
|
||||||
"version": "0.1.0",
|
"version": "3.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.1",
|
"@base-ui/react": "^1.4.1",
|
||||||
"@marsidev/react-turnstile": "^1.5.0",
|
"@marsidev/react-turnstile": "^1.5.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "j-board",
|
"name": "j-board",
|
||||||
"version": "0.1.0",
|
"version": "3.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ enum SubscriptionRiskReason {
|
|||||||
REGION_VARIANCE_SUSPEND
|
REGION_VARIANCE_SUSPEND
|
||||||
COUNTRY_VARIANCE_WARNING
|
COUNTRY_VARIANCE_WARNING
|
||||||
COUNTRY_VARIANCE_SUSPEND
|
COUNTRY_VARIANCE_SUSPEND
|
||||||
|
NODE_ACCESS_VOLUME_WARNING
|
||||||
|
NODE_ACCESS_VOLUME_SUSPEND
|
||||||
|
NODE_ACCESS_TARGET_WARNING
|
||||||
|
NODE_ACCESS_TARGET_SUSPEND
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SubscriptionRiskReviewStatus {
|
enum SubscriptionRiskReviewStatus {
|
||||||
@@ -775,6 +779,11 @@ model AppConfig {
|
|||||||
subscriptionRiskCountrySuspend Int @default(3)
|
subscriptionRiskCountrySuspend Int @default(3)
|
||||||
subscriptionRiskIpLimitPerHour Int @default(180)
|
subscriptionRiskIpLimitPerHour Int @default(180)
|
||||||
subscriptionRiskTokenLimitPerHour Int @default(60)
|
subscriptionRiskTokenLimitPerHour Int @default(60)
|
||||||
|
nodeAccessRiskEnabled Boolean @default(true)
|
||||||
|
nodeAccessConnectionWarning Int @default(180)
|
||||||
|
nodeAccessConnectionSuspend Int @default(360)
|
||||||
|
nodeAccessUniqueTargetWarning Int @default(80)
|
||||||
|
nodeAccessUniqueTargetSuspend Int @default(160)
|
||||||
inviteRewardCouponId String?
|
inviteRewardCouponId String?
|
||||||
inviteRewardRate Decimal @default(0) @db.Decimal(5, 2)
|
inviteRewardRate Decimal @default(0) @db.Decimal(5, 2)
|
||||||
inviteRewardEnabled Boolean @default(false)
|
inviteRewardEnabled Boolean @default(false)
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ SERVICE_NAME="${SERVICE_NAME:-jboard-agent}"
|
|||||||
ENV_FILE="${ENV_FILE:-/etc/jboard-agent.env}"
|
ENV_FILE="${ENV_FILE:-/etc/jboard-agent.env}"
|
||||||
LATENCY_INTERVAL="${LATENCY_INTERVAL:-5m}"
|
LATENCY_INTERVAL="${LATENCY_INTERVAL:-5m}"
|
||||||
TRACE_INTERVAL="${TRACE_INTERVAL:-30m}"
|
TRACE_INTERVAL="${TRACE_INTERVAL:-30m}"
|
||||||
|
XRAY_ACCESS_LOG_PATH="${XRAY_ACCESS_LOG_PATH:-}"
|
||||||
|
XRAY_LOG_INTERVAL="${XRAY_LOG_INTERVAL:-1m}"
|
||||||
|
XRAY_LOG_STATE_FILE="${XRAY_LOG_STATE_FILE:-/var/lib/jboard-agent/xray-log-state.json}"
|
||||||
|
XRAY_LOG_START_AT_END="${XRAY_LOG_START_AT_END:-1}"
|
||||||
INSTALL_NEXTTRACE="${INSTALL_NEXTTRACE:-1}"
|
INSTALL_NEXTTRACE="${INSTALL_NEXTTRACE:-1}"
|
||||||
TMP_DIR="$(mktemp -d)"
|
TMP_DIR="$(mktemp -d)"
|
||||||
ARCH="$(uname -m)"
|
ARCH="$(uname -m)"
|
||||||
@@ -35,6 +39,16 @@ run_as_root() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
run_as_root_output() {
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
"$@"
|
||||||
|
elif command -v sudo >/dev/null 2>&1; then
|
||||||
|
sudo "$@"
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
detect_asset() {
|
detect_asset() {
|
||||||
case "$ARCH" in
|
case "$ARCH" in
|
||||||
x86_64|amd64)
|
x86_64|amd64)
|
||||||
@@ -61,6 +75,77 @@ resolve_release_tag() {
|
|||||||
| head -n 1
|
| head -n 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
detect_xray_access_log() {
|
||||||
|
if [ -n "$XRAY_ACCESS_LOG_PATH" ]; then
|
||||||
|
printf '%s\n' "$XRAY_ACCESS_LOG_PATH"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for candidate in \
|
||||||
|
/usr/local/x-ui/access.log \
|
||||||
|
/usr/local/x-ui/bin/access.log \
|
||||||
|
/usr/local/x-ui/xray/access.log \
|
||||||
|
/etc/x-ui/access.log \
|
||||||
|
/etc/x-ui/xray/access.log \
|
||||||
|
/var/log/xray/access.log \
|
||||||
|
/var/log/x-ui/access.log \
|
||||||
|
/opt/3x-ui/access.log \
|
||||||
|
/opt/x-ui/access.log; do
|
||||||
|
if run_as_root_output test -f "$candidate" 2>/dev/null; then
|
||||||
|
printf '%s\n' "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
for root in /usr/local /etc /var/log /opt /var/lib/docker/volumes; do
|
||||||
|
if ! run_as_root_output test -d "$root" 2>/dev/null; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
while IFS= read -r candidate; do
|
||||||
|
case "$candidate" in
|
||||||
|
*x-ui*|*3x-ui*|*xray*|*Xray*)
|
||||||
|
printf '%s\n' "$candidate"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < <(run_as_root_output find "$root" -type f \( -name 'access.log' -o -name '*xray*.log' \) 2>/dev/null | head -n 50)
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_xray_access_log() {
|
||||||
|
local detected=""
|
||||||
|
detected="$(detect_xray_access_log || true)"
|
||||||
|
if [ -z "$detected" ]; then
|
||||||
|
XRAY_ACCESS_LOG_PATH=""
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
XRAY_ACCESS_LOG_PATH="$detected"
|
||||||
|
run_as_root mkdir -p "$(dirname "$XRAY_LOG_STATE_FILE")"
|
||||||
|
if run_as_root_output test -f "$XRAY_ACCESS_LOG_PATH" 2>/dev/null; then
|
||||||
|
run_as_root chmod a+r "$XRAY_ACCESS_LOG_PATH" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
print_xray_log_hint() {
|
||||||
|
cat <<'HINT'
|
||||||
|
|
||||||
|
Xray access log was not found automatically, so node access risk telemetry is disabled for now.
|
||||||
|
To enable it, open 3x-ui panel -> Xray Config and set:
|
||||||
|
|
||||||
|
"log": {
|
||||||
|
"access": "/usr/local/x-ui/access.log",
|
||||||
|
"error": "/usr/local/x-ui/error.log",
|
||||||
|
"loglevel": "warning"
|
||||||
|
}
|
||||||
|
|
||||||
|
Then restart x-ui and rerun this installer, or add XRAY_ACCESS_LOG_PATH=/usr/local/x-ui/access.log to /etc/jboard-agent.env.
|
||||||
|
HINT
|
||||||
|
}
|
||||||
|
|
||||||
ASSET="$(detect_asset)"
|
ASSET="$(detect_asset)"
|
||||||
RESOLVED_TAG="$(resolve_release_tag)"
|
RESOLVED_TAG="$(resolve_release_tag)"
|
||||||
|
|
||||||
@@ -73,12 +158,12 @@ DOWNLOAD_BASE="https://github.com/${GH_REPO}/releases/download/${RESOLVED_TAG}"
|
|||||||
DOWNLOAD_URL="${DOWNLOAD_BASE}/${ASSET}"
|
DOWNLOAD_URL="${DOWNLOAD_BASE}/${ASSET}"
|
||||||
CHECKSUM_URL="${DOWNLOAD_BASE}/SHA256SUMS"
|
CHECKSUM_URL="${DOWNLOAD_BASE}/SHA256SUMS"
|
||||||
|
|
||||||
echo "[1/8] Release tag: ${RESOLVED_TAG}"
|
echo "[1/9] Release tag: ${RESOLVED_TAG}"
|
||||||
echo "[2/8] Downloading probe agent binary: ${ASSET}"
|
echo "[2/9] Downloading probe agent binary: ${ASSET}"
|
||||||
curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET"
|
curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET"
|
||||||
|
|
||||||
if curl -fsSL "$CHECKSUM_URL" -o "$TMP_DIR/SHA256SUMS" 2>/dev/null; then
|
if curl -fsSL "$CHECKSUM_URL" -o "$TMP_DIR/SHA256SUMS" 2>/dev/null; then
|
||||||
echo "[3/8] Verifying checksum..."
|
echo "[3/9] Verifying checksum..."
|
||||||
grep " ${ASSET}$" "$TMP_DIR/SHA256SUMS" > "$TMP_DIR/SHA256SUMS.current"
|
grep " ${ASSET}$" "$TMP_DIR/SHA256SUMS" > "$TMP_DIR/SHA256SUMS.current"
|
||||||
(
|
(
|
||||||
cd "$TMP_DIR"
|
cd "$TMP_DIR"
|
||||||
@@ -89,32 +174,43 @@ if curl -fsSL "$CHECKSUM_URL" -o "$TMP_DIR/SHA256SUMS" 2>/dev/null; then
|
|||||||
fi
|
fi
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
echo "[3/8] Checksum file not found; skipping verification."
|
echo "[3/9] Checksum file not found; skipping verification."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[4/8] Installing binary..."
|
echo "[4/9] Installing binary..."
|
||||||
run_as_root install -m 0755 "$TMP_DIR/$ASSET" "${INSTALL_DIR}/jboard-agent"
|
run_as_root install -m 0755 "$TMP_DIR/$ASSET" "${INSTALL_DIR}/jboard-agent"
|
||||||
run_as_root mkdir -p /var/log/jboard
|
run_as_root mkdir -p /var/log/jboard /var/lib/jboard-agent
|
||||||
|
|
||||||
if [ "$INSTALL_NEXTTRACE" = "1" ] && ! command -v nexttrace >/dev/null 2>&1; then
|
if [ "$INSTALL_NEXTTRACE" = "1" ] && ! command -v nexttrace >/dev/null 2>&1; then
|
||||||
echo "[5/8] Installing nexttrace for route probing..."
|
echo "[5/9] Installing nexttrace for route probing..."
|
||||||
curl -fsSL https://raw.githubusercontent.com/nxtrace/NTrace-core/main/nt_install.sh -o "$TMP_DIR/nt_install.sh"
|
curl -fsSL https://raw.githubusercontent.com/nxtrace/NTrace-core/main/nt_install.sh -o "$TMP_DIR/nt_install.sh"
|
||||||
run_as_root bash "$TMP_DIR/nt_install.sh"
|
run_as_root bash "$TMP_DIR/nt_install.sh"
|
||||||
else
|
else
|
||||||
echo "[5/8] nexttrace already installed or skipped."
|
echo "[5/9] nexttrace already installed or skipped."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[6/8] Writing environment file..."
|
echo "[6/9] Detecting Xray access log..."
|
||||||
|
if prepare_xray_access_log; then
|
||||||
|
echo "Found Xray access log: ${XRAY_ACCESS_LOG_PATH}"
|
||||||
|
else
|
||||||
|
echo "Xray access log not found; continuing without node access risk telemetry."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[7/9] Writing environment file..."
|
||||||
ENV_TMP="$TMP_DIR/jboard-agent.env"
|
ENV_TMP="$TMP_DIR/jboard-agent.env"
|
||||||
{
|
{
|
||||||
printf 'SERVER_URL=%q\n' "$SERVER_URL"
|
printf 'SERVER_URL=%q\n' "$SERVER_URL"
|
||||||
printf 'AUTH_TOKEN=%q\n' "$AUTH_TOKEN"
|
printf 'AUTH_TOKEN=%q\n' "$AUTH_TOKEN"
|
||||||
printf 'LATENCY_INTERVAL=%q\n' "$LATENCY_INTERVAL"
|
printf 'LATENCY_INTERVAL=%q\n' "$LATENCY_INTERVAL"
|
||||||
printf 'TRACE_INTERVAL=%q\n' "$TRACE_INTERVAL"
|
printf 'TRACE_INTERVAL=%q\n' "$TRACE_INTERVAL"
|
||||||
|
printf 'XRAY_ACCESS_LOG_PATH=%q\n' "$XRAY_ACCESS_LOG_PATH"
|
||||||
|
printf 'XRAY_LOG_INTERVAL=%q\n' "$XRAY_LOG_INTERVAL"
|
||||||
|
printf 'XRAY_LOG_STATE_FILE=%q\n' "$XRAY_LOG_STATE_FILE"
|
||||||
|
printf 'XRAY_LOG_START_AT_END=%q\n' "$XRAY_LOG_START_AT_END"
|
||||||
} > "$ENV_TMP"
|
} > "$ENV_TMP"
|
||||||
run_as_root install -m 0600 "$ENV_TMP" "$ENV_FILE"
|
run_as_root install -m 0600 "$ENV_TMP" "$ENV_FILE"
|
||||||
|
|
||||||
echo "[7/8] Writing systemd service..."
|
echo "[8/9] Writing systemd service..."
|
||||||
SERVICE_TMP="$TMP_DIR/${SERVICE_NAME}.service"
|
SERVICE_TMP="$TMP_DIR/${SERVICE_NAME}.service"
|
||||||
cat > "$SERVICE_TMP" <<SERVICE
|
cat > "$SERVICE_TMP" <<SERVICE
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -135,12 +231,19 @@ WantedBy=multi-user.target
|
|||||||
SERVICE
|
SERVICE
|
||||||
run_as_root install -m 0644 "$SERVICE_TMP" "/etc/systemd/system/${SERVICE_NAME}.service"
|
run_as_root install -m 0644 "$SERVICE_TMP" "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
|
||||||
echo "[8/8] Enabling and starting service..."
|
echo "[9/9] Enabling and starting service..."
|
||||||
run_as_root systemctl daemon-reload
|
run_as_root systemctl daemon-reload
|
||||||
run_as_root systemctl enable --now "$SERVICE_NAME"
|
run_as_root systemctl enable --now "$SERVICE_NAME"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Install complete."
|
echo "Install complete."
|
||||||
|
if [ -n "$XRAY_ACCESS_LOG_PATH" ]; then
|
||||||
|
echo "Node access risk telemetry: enabled (${XRAY_ACCESS_LOG_PATH})"
|
||||||
|
else
|
||||||
|
echo "Node access risk telemetry: disabled"
|
||||||
|
print_xray_log_hint
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Service status:"
|
echo "Service status:"
|
||||||
run_as_root systemctl --no-pager --full status "$SERVICE_NAME" || true
|
run_as_root systemctl --no-pager --full status "$SERVICE_NAME" || true
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ GH_REPO="${GH_REPO:-JetSprow/J-Board}"
|
|||||||
AGENT_TAG="${AGENT_TAG:-latest}"
|
AGENT_TAG="${AGENT_TAG:-latest}"
|
||||||
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
|
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
|
||||||
SERVICE_NAME="${SERVICE_NAME:-jboard-agent}"
|
SERVICE_NAME="${SERVICE_NAME:-jboard-agent}"
|
||||||
|
ENV_FILE="${ENV_FILE:-/etc/jboard-agent.env}"
|
||||||
|
XRAY_ACCESS_LOG_PATH="${XRAY_ACCESS_LOG_PATH:-}"
|
||||||
|
XRAY_LOG_INTERVAL="${XRAY_LOG_INTERVAL:-1m}"
|
||||||
|
XRAY_LOG_STATE_FILE="${XRAY_LOG_STATE_FILE:-/var/lib/jboard-agent/xray-log-state.json}"
|
||||||
|
XRAY_LOG_START_AT_END="${XRAY_LOG_START_AT_END:-1}"
|
||||||
TMP_DIR="$(mktemp -d)"
|
TMP_DIR="$(mktemp -d)"
|
||||||
ARCH="$(uname -m)"
|
ARCH="$(uname -m)"
|
||||||
|
|
||||||
@@ -24,6 +29,16 @@ run_as_root() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
run_as_root_output() {
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
"$@"
|
||||||
|
elif command -v sudo >/dev/null 2>&1; then
|
||||||
|
sudo "$@"
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
detect_asset() {
|
detect_asset() {
|
||||||
case "$ARCH" in
|
case "$ARCH" in
|
||||||
x86_64|amd64)
|
x86_64|amd64)
|
||||||
@@ -50,6 +65,119 @@ resolve_release_tag() {
|
|||||||
| head -n 1
|
| head -n 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
if ! run_as_root_output test -f "$ENV_FILE" 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
run_as_root_output grep -E "^${key}=" "$ENV_FILE" 2>/dev/null \
|
||||||
|
| tail -n 1 \
|
||||||
|
| cut -d= -f2- \
|
||||||
|
| sed -e "s/^\'//" -e "s/\'$//" -e 's/^"//' -e 's/"$//' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_xray_access_log() {
|
||||||
|
if [ -n "$XRAY_ACCESS_LOG_PATH" ]; then
|
||||||
|
printf '%s\n' "$XRAY_ACCESS_LOG_PATH"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for candidate in \
|
||||||
|
/usr/local/x-ui/access.log \
|
||||||
|
/usr/local/x-ui/bin/access.log \
|
||||||
|
/usr/local/x-ui/xray/access.log \
|
||||||
|
/etc/x-ui/access.log \
|
||||||
|
/etc/x-ui/xray/access.log \
|
||||||
|
/var/log/xray/access.log \
|
||||||
|
/var/log/x-ui/access.log \
|
||||||
|
/opt/3x-ui/access.log \
|
||||||
|
/opt/x-ui/access.log; do
|
||||||
|
if run_as_root_output test -f "$candidate" 2>/dev/null; then
|
||||||
|
printf '%s\n' "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
for root in /usr/local /etc /var/log /opt /var/lib/docker/volumes; do
|
||||||
|
if ! run_as_root_output test -d "$root" 2>/dev/null; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
while IFS= read -r candidate; do
|
||||||
|
case "$candidate" in
|
||||||
|
*x-ui*|*3x-ui*|*xray*|*Xray*)
|
||||||
|
printf '%s\n' "$candidate"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < <(run_as_root_output find "$root" -type f \( -name 'access.log' -o -name '*xray*.log' \) 2>/dev/null | head -n 50)
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
local quoted=""
|
||||||
|
printf -v quoted %q "$value"
|
||||||
|
|
||||||
|
if run_as_root_output test -f "$ENV_FILE" 2>/dev/null; then
|
||||||
|
run_as_root_output cat "$ENV_FILE" > "$TMP_DIR/env.current"
|
||||||
|
else
|
||||||
|
: > "$TMP_DIR/env.current"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -qE "^${key}=" "$TMP_DIR/env.current"; then
|
||||||
|
sed -E "s|^${key}=.*|${key}=${quoted}|" "$TMP_DIR/env.current" > "$TMP_DIR/env.next"
|
||||||
|
else
|
||||||
|
cp "$TMP_DIR/env.current" "$TMP_DIR/env.next"
|
||||||
|
printf '%s=%s\n' "$key" "$quoted" >> "$TMP_DIR/env.next"
|
||||||
|
fi
|
||||||
|
run_as_root install -m 0600 "$TMP_DIR/env.next" "$ENV_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_xray_log_env() {
|
||||||
|
local current=""
|
||||||
|
current="$(get_env_value XRAY_ACCESS_LOG_PATH)"
|
||||||
|
if [ -n "$current" ]; then
|
||||||
|
XRAY_ACCESS_LOG_PATH="$current"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local detected=""
|
||||||
|
detected="$(detect_xray_access_log || true)"
|
||||||
|
if [ -z "$detected" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
XRAY_ACCESS_LOG_PATH="$detected"
|
||||||
|
run_as_root mkdir -p "$(dirname "$XRAY_LOG_STATE_FILE")"
|
||||||
|
if run_as_root_output test -f "$XRAY_ACCESS_LOG_PATH" 2>/dev/null; then
|
||||||
|
run_as_root chmod a+r "$XRAY_ACCESS_LOG_PATH" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
upsert_env_value XRAY_ACCESS_LOG_PATH "$XRAY_ACCESS_LOG_PATH"
|
||||||
|
upsert_env_value XRAY_LOG_INTERVAL "$XRAY_LOG_INTERVAL"
|
||||||
|
upsert_env_value XRAY_LOG_STATE_FILE "$XRAY_LOG_STATE_FILE"
|
||||||
|
upsert_env_value XRAY_LOG_START_AT_END "$XRAY_LOG_START_AT_END"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
print_xray_log_hint() {
|
||||||
|
cat <<'HINT'
|
||||||
|
|
||||||
|
Xray access log was not found automatically. Node access risk telemetry remains disabled.
|
||||||
|
To enable it, open 3x-ui panel -> Xray Config and set:
|
||||||
|
|
||||||
|
"log": {
|
||||||
|
"access": "/usr/local/x-ui/access.log",
|
||||||
|
"error": "/usr/local/x-ui/error.log",
|
||||||
|
"loglevel": "warning"
|
||||||
|
}
|
||||||
|
|
||||||
|
Then restart x-ui and rerun this upgrade script, or add XRAY_ACCESS_LOG_PATH=/usr/local/x-ui/access.log to /etc/jboard-agent.env.
|
||||||
|
HINT
|
||||||
|
}
|
||||||
|
|
||||||
ASSET="$(detect_asset)"
|
ASSET="$(detect_asset)"
|
||||||
RESOLVED_TAG="$(resolve_release_tag)"
|
RESOLVED_TAG="$(resolve_release_tag)"
|
||||||
|
|
||||||
@@ -62,12 +190,12 @@ DOWNLOAD_BASE="https://github.com/${GH_REPO}/releases/download/${RESOLVED_TAG}"
|
|||||||
DOWNLOAD_URL="${DOWNLOAD_BASE}/${ASSET}"
|
DOWNLOAD_URL="${DOWNLOAD_BASE}/${ASSET}"
|
||||||
CHECKSUM_URL="${DOWNLOAD_BASE}/SHA256SUMS"
|
CHECKSUM_URL="${DOWNLOAD_BASE}/SHA256SUMS"
|
||||||
|
|
||||||
echo "[1/5] Release tag: ${RESOLVED_TAG}"
|
echo "[1/6] Release tag: ${RESOLVED_TAG}"
|
||||||
echo "[2/5] Downloading probe agent binary: ${ASSET}"
|
echo "[2/6] Downloading probe agent binary: ${ASSET}"
|
||||||
curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET"
|
curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET"
|
||||||
|
|
||||||
if curl -fsSL "$CHECKSUM_URL" -o "$TMP_DIR/SHA256SUMS" 2>/dev/null; then
|
if curl -fsSL "$CHECKSUM_URL" -o "$TMP_DIR/SHA256SUMS" 2>/dev/null; then
|
||||||
echo "[3/5] Verifying checksum..."
|
echo "[3/6] Verifying checksum..."
|
||||||
grep " ${ASSET}$" "$TMP_DIR/SHA256SUMS" > "$TMP_DIR/SHA256SUMS.current"
|
grep " ${ASSET}$" "$TMP_DIR/SHA256SUMS" > "$TMP_DIR/SHA256SUMS.current"
|
||||||
(
|
(
|
||||||
cd "$TMP_DIR"
|
cd "$TMP_DIR"
|
||||||
@@ -78,14 +206,22 @@ if curl -fsSL "$CHECKSUM_URL" -o "$TMP_DIR/SHA256SUMS" 2>/dev/null; then
|
|||||||
fi
|
fi
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
echo "[3/5] Checksum file not found; skipping verification."
|
echo "[3/6] Checksum file not found; skipping verification."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[4/5] Installing binary..."
|
echo "[4/6] Installing binary..."
|
||||||
run_as_root install -m 0755 "$TMP_DIR/$ASSET" "${INSTALL_DIR}/jboard-agent"
|
run_as_root install -m 0755 "$TMP_DIR/$ASSET" "${INSTALL_DIR}/jboard-agent"
|
||||||
run_as_root mkdir -p /var/log/jboard
|
run_as_root mkdir -p /var/log/jboard /var/lib/jboard-agent
|
||||||
|
|
||||||
echo "[5/5] Restarting service..."
|
echo "[5/6] Detecting Xray access log..."
|
||||||
|
if configure_xray_log_env; then
|
||||||
|
echo "Node access risk telemetry: enabled (${XRAY_ACCESS_LOG_PATH})"
|
||||||
|
else
|
||||||
|
echo "Node access risk telemetry: disabled"
|
||||||
|
print_xray_log_hint
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[6/6] Restarting service..."
|
||||||
run_as_root systemctl daemon-reload
|
run_as_root systemctl daemon-reload
|
||||||
run_as_root systemctl restart "$SERVICE_NAME"
|
run_as_root systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ const settingsSchema = z.object({
|
|||||||
subscriptionRiskCountrySuspend: z.coerce.number().int().min(2).max(100).optional(),
|
subscriptionRiskCountrySuspend: z.coerce.number().int().min(2).max(100).optional(),
|
||||||
subscriptionRiskIpLimitPerHour: z.coerce.number().int().min(1).max(100000).optional(),
|
subscriptionRiskIpLimitPerHour: z.coerce.number().int().min(1).max(100000).optional(),
|
||||||
subscriptionRiskTokenLimitPerHour: z.coerce.number().int().min(1).max(100000).optional(),
|
subscriptionRiskTokenLimitPerHour: z.coerce.number().int().min(1).max(100000).optional(),
|
||||||
|
nodeAccessRiskEnabled: z.string().optional(),
|
||||||
|
nodeAccessConnectionWarning: z.coerce.number().int().min(1).max(100000).optional(),
|
||||||
|
nodeAccessConnectionSuspend: z.coerce.number().int().min(1).max(100000).optional(),
|
||||||
|
nodeAccessUniqueTargetWarning: z.coerce.number().int().min(1).max(100000).optional(),
|
||||||
|
nodeAccessUniqueTargetSuspend: z.coerce.number().int().min(1).max(100000).optional(),
|
||||||
inviteRewardEnabled: z.string().optional(),
|
inviteRewardEnabled: z.string().optional(),
|
||||||
inviteRewardRate: z.coerce.number().min(0).max(100).optional(),
|
inviteRewardRate: z.coerce.number().min(0).max(100).optional(),
|
||||||
inviteRewardCouponId: z.string().trim().optional(),
|
inviteRewardCouponId: z.string().trim().optional(),
|
||||||
@@ -158,6 +163,18 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
|
|||||||
parsed.subscriptionRiskIpLimitPerHour ?? current.subscriptionRiskIpLimitPerHour,
|
parsed.subscriptionRiskIpLimitPerHour ?? current.subscriptionRiskIpLimitPerHour,
|
||||||
subscriptionRiskTokenLimitPerHour:
|
subscriptionRiskTokenLimitPerHour:
|
||||||
parsed.subscriptionRiskTokenLimitPerHour ?? current.subscriptionRiskTokenLimitPerHour,
|
parsed.subscriptionRiskTokenLimitPerHour ?? current.subscriptionRiskTokenLimitPerHour,
|
||||||
|
nodeAccessRiskEnabled: optionalBoolean(
|
||||||
|
parsed.nodeAccessRiskEnabled,
|
||||||
|
current.nodeAccessRiskEnabled,
|
||||||
|
),
|
||||||
|
nodeAccessConnectionWarning:
|
||||||
|
parsed.nodeAccessConnectionWarning ?? current.nodeAccessConnectionWarning,
|
||||||
|
nodeAccessConnectionSuspend:
|
||||||
|
parsed.nodeAccessConnectionSuspend ?? current.nodeAccessConnectionSuspend,
|
||||||
|
nodeAccessUniqueTargetWarning:
|
||||||
|
parsed.nodeAccessUniqueTargetWarning ?? current.nodeAccessUniqueTargetWarning,
|
||||||
|
nodeAccessUniqueTargetSuspend:
|
||||||
|
parsed.nodeAccessUniqueTargetSuspend ?? current.nodeAccessUniqueTargetSuspend,
|
||||||
inviteRewardEnabled: optionalBoolean(parsed.inviteRewardEnabled, current.inviteRewardEnabled),
|
inviteRewardEnabled: optionalBoolean(parsed.inviteRewardEnabled, current.inviteRewardEnabled),
|
||||||
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
|
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
|
||||||
inviteRewardCouponId: parsed.inviteRewardCouponId || null,
|
inviteRewardCouponId: parsed.inviteRewardCouponId || null,
|
||||||
@@ -182,6 +199,12 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
|
|||||||
if (next.subscriptionRiskCountrySuspend < next.subscriptionRiskCountryWarning) {
|
if (next.subscriptionRiskCountrySuspend < next.subscriptionRiskCountryWarning) {
|
||||||
throw new Error("国家暂停阈值不能小于国家警告阈值");
|
throw new Error("国家暂停阈值不能小于国家警告阈值");
|
||||||
}
|
}
|
||||||
|
if (next.nodeAccessConnectionSuspend < next.nodeAccessConnectionWarning) {
|
||||||
|
throw new Error("节点连接暂停阈值不能小于警告阈值");
|
||||||
|
}
|
||||||
|
if (next.nodeAccessUniqueTargetSuspend < next.nodeAccessUniqueTargetWarning) {
|
||||||
|
throw new Error("节点目标数暂停阈值不能小于警告阈值");
|
||||||
|
}
|
||||||
|
|
||||||
if (next.smtpEnabled || next.emailVerificationRequired) {
|
if (next.smtpEnabled || next.emailVerificationRequired) {
|
||||||
if (!next.smtpHost || !next.smtpPort || !next.smtpFromEmail) {
|
if (!next.smtpHost || !next.smtpPort || !next.smtpFromEmail) {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
title="撤销这个探测 Token?"
|
title="撤销这个探测 Token?"
|
||||||
description="撤销后,延迟和线路探测程序将无法继续上报数据。"
|
description="撤销后,延迟、线路探测和节点日志风控程序将无法继续上报数据。"
|
||||||
confirmLabel="撤销 Token"
|
confirmLabel="撤销 Token"
|
||||||
successMessage="探测 Token 已撤销"
|
successMessage="探测 Token 已撤销"
|
||||||
errorMessage="撤销失败"
|
errorMessage="撤销失败"
|
||||||
@@ -170,7 +170,7 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
此 Agent 仅用于 `/api/agent/latency` 和 `/api/agent/trace` 探测上报;节点客户端开通已改由 3x-ui 面板 API 处理。
|
此 Agent 用于 `/api/agent/latency`、`/api/agent/trace` 探测上报;安装脚本会自动查找 3x-ui/Xray access log,找到后启用节点日志风控。Agent 只读日志,不修改 3x-ui 配置。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export default async function AdminSettingsPage() {
|
|||||||
subscriptionRiskCountrySuspend: config.subscriptionRiskCountrySuspend,
|
subscriptionRiskCountrySuspend: config.subscriptionRiskCountrySuspend,
|
||||||
subscriptionRiskIpLimitPerHour: config.subscriptionRiskIpLimitPerHour,
|
subscriptionRiskIpLimitPerHour: config.subscriptionRiskIpLimitPerHour,
|
||||||
subscriptionRiskTokenLimitPerHour: config.subscriptionRiskTokenLimitPerHour,
|
subscriptionRiskTokenLimitPerHour: config.subscriptionRiskTokenLimitPerHour,
|
||||||
|
nodeAccessRiskEnabled: config.nodeAccessRiskEnabled,
|
||||||
|
nodeAccessConnectionWarning: config.nodeAccessConnectionWarning,
|
||||||
|
nodeAccessConnectionSuspend: config.nodeAccessConnectionSuspend,
|
||||||
|
nodeAccessUniqueTargetWarning: config.nodeAccessUniqueTargetWarning,
|
||||||
|
nodeAccessUniqueTargetSuspend: config.nodeAccessUniqueTargetSuspend,
|
||||||
inviteRewardEnabled: config.inviteRewardEnabled,
|
inviteRewardEnabled: config.inviteRewardEnabled,
|
||||||
inviteRewardRate: Number(config.inviteRewardRate),
|
inviteRewardRate: Number(config.inviteRewardRate),
|
||||||
inviteRewardCouponId: config.inviteRewardCouponId,
|
inviteRewardCouponId: config.inviteRewardCouponId,
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ interface AppConfig {
|
|||||||
subscriptionRiskCountrySuspend: number;
|
subscriptionRiskCountrySuspend: number;
|
||||||
subscriptionRiskIpLimitPerHour: number;
|
subscriptionRiskIpLimitPerHour: number;
|
||||||
subscriptionRiskTokenLimitPerHour: number;
|
subscriptionRiskTokenLimitPerHour: number;
|
||||||
|
nodeAccessRiskEnabled: boolean;
|
||||||
|
nodeAccessConnectionWarning: number;
|
||||||
|
nodeAccessConnectionSuspend: number;
|
||||||
|
nodeAccessUniqueTargetWarning: number;
|
||||||
|
nodeAccessUniqueTargetSuspend: number;
|
||||||
inviteRewardEnabled: boolean;
|
inviteRewardEnabled: boolean;
|
||||||
inviteRewardRate: number;
|
inviteRewardRate: number;
|
||||||
inviteRewardCouponId: string | null;
|
inviteRewardCouponId: string | null;
|
||||||
@@ -389,9 +394,37 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
|||||||
defaultValue={config.subscriptionRiskTokenLimitPerHour}
|
defaultValue={config.subscriptionRiskTokenLimitPerHour}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nodeAccessRiskEnabled">节点日志风控</Label>
|
||||||
|
<select
|
||||||
|
id="nodeAccessRiskEnabled"
|
||||||
|
name="nodeAccessRiskEnabled"
|
||||||
|
defaultValue={String(config.nodeAccessRiskEnabled)}
|
||||||
|
className={selectClassName}
|
||||||
|
>
|
||||||
|
<option value="true">开启,接收 Agent Xray 日志上报</option>
|
||||||
|
<option value="false">关闭,只保留订阅接口风控</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nodeAccessConnectionWarning">节点连接警告阈值</Label>
|
||||||
|
<Input id="nodeAccessConnectionWarning" name="nodeAccessConnectionWarning" type="number" min={1} max={100000} defaultValue={config.nodeAccessConnectionWarning} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nodeAccessConnectionSuspend">节点连接暂停阈值</Label>
|
||||||
|
<Input id="nodeAccessConnectionSuspend" name="nodeAccessConnectionSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessConnectionSuspend} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nodeAccessUniqueTargetWarning">不同目标警告阈值</Label>
|
||||||
|
<Input id="nodeAccessUniqueTargetWarning" name="nodeAccessUniqueTargetWarning" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetWarning} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nodeAccessUniqueTargetSuspend">不同目标暂停阈值</Label>
|
||||||
|
<Input id="nodeAccessUniqueTargetSuspend" name="nodeAccessUniqueTargetSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetSuspend} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
默认值对应原规则:24 小时内 4 城市警告、5 城市暂停;2 省/地区警告、3 省/地区暂停;2 国家警告、3 国家暂停;IP 180 次/小时,订阅 60 次/小时。
|
默认值对应原规则:24 小时内 4 城市警告、5 城市暂停;2 省/地区警告、3 省/地区暂停;2 国家警告、3 国家暂停;IP 180 次/小时,订阅 60 次/小时。节点日志风控只在 Agent 配置 XRAY_ACCESS_LOG_PATH 后生效;连接数和不同目标数按 Agent 单次聚合窗口计算。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function WorldRiskMap({ summary }: { summary: SubscriptionRiskGeoSummary }) {
|
|||||||
viewBox="0 0 360 180"
|
viewBox="0 0 360 180"
|
||||||
className="h-[15rem] w-full bg-[radial-gradient(circle_at_30%_20%,color-mix(in_oklch,var(--primary)_10%,transparent),transparent_30%),linear-gradient(135deg,var(--muted),var(--card))]"
|
className="h-[15rem] w-full bg-[radial-gradient(circle_at_30%_20%,color-mix(in_oklch,var(--primary)_10%,transparent),transparent_30%),linear-gradient(135deg,var(--muted),var(--card))]"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="订阅访问 IP 世界地图分布"
|
aria-label="订阅访问与节点连接 IP 世界地图分布"
|
||||||
>
|
>
|
||||||
<rect width="360" height="180" rx="12" fill="transparent" />
|
<rect width="360" height="180" rx="12" fill="transparent" />
|
||||||
{[-120, -60, 0, 60, 120].map((longitude) => {
|
{[-120, -60, 0, 60, 120].map((longitude) => {
|
||||||
@@ -164,7 +164,7 @@ export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionR
|
|||||||
<details className="group rounded-xl border border-border/70 bg-muted/20">
|
<details className="group rounded-xl border border-border/70 bg-muted/20">
|
||||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 text-sm font-medium [&::-webkit-details-marker]:hidden">
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 text-sm font-medium [&::-webkit-details-marker]:hidden">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<MapPin className="size-4 text-primary" /> IP 访问明细
|
<MapPin className="size-4 text-primary" /> IP 访问/连接明细
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
|
<ChevronDown className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||||
</summary>
|
</summary>
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ function reasonLabel(reason: SubscriptionRiskEvent["reason"]) {
|
|||||||
return "国家异常警告";
|
return "国家异常警告";
|
||||||
case "COUNTRY_VARIANCE_SUSPEND":
|
case "COUNTRY_VARIANCE_SUSPEND":
|
||||||
return "国家异常暂停";
|
return "国家异常暂停";
|
||||||
|
case "NODE_ACCESS_VOLUME_WARNING":
|
||||||
|
return "节点高频警告";
|
||||||
|
case "NODE_ACCESS_VOLUME_SUSPEND":
|
||||||
|
return "节点高频暂停";
|
||||||
|
case "NODE_ACCESS_TARGET_WARNING":
|
||||||
|
return "目标分散警告";
|
||||||
|
case "NODE_ACCESS_TARGET_SUSPEND":
|
||||||
|
return "目标分散暂停";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +263,7 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
|
|||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="暂无订阅风控事件"
|
title="暂无订阅风控事件"
|
||||||
description="订阅链接出现跨城市、跨省份或跨国家访问异常后,会在这里进入人工跟进队列。"
|
description="订阅链接或节点真实连接出现跨城市、跨省份或跨国家异常后,会在这里进入人工跟进队列。"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getSubscriptionRiskEvents } from "./risk-data";
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "订阅风控",
|
title: "订阅风控",
|
||||||
description: "查看订阅访问异常、关联用户和人工处理状态。",
|
description: "查看订阅访问与节点连接异常、关联用户和人工处理状态。",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminSubscriptionRiskPage({
|
export default async function AdminSubscriptionRiskPage({
|
||||||
@@ -22,7 +22,7 @@ export default async function AdminSubscriptionRiskPage({
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="商品与订单"
|
eyebrow="商品与订单"
|
||||||
title="订阅风控"
|
title="订阅风控"
|
||||||
description="订阅链接跨城市或跨省份访问异常后,会进入这里供管理员确认、备注、恢复或继续处置。"
|
description="订阅链接或节点真实连接出现跨城市、跨省份访问异常后,会进入这里供管理员确认、备注、恢复或继续处置。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminFilterBar
|
<AdminFilterBar
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ function reasonLabel(reason: SubscriptionRiskEvent["reason"]) {
|
|||||||
return "国家异常警告";
|
return "国家异常警告";
|
||||||
case "COUNTRY_VARIANCE_SUSPEND":
|
case "COUNTRY_VARIANCE_SUSPEND":
|
||||||
return "国家异常暂停";
|
return "国家异常暂停";
|
||||||
|
case "NODE_ACCESS_VOLUME_WARNING":
|
||||||
|
return "节点高频警告";
|
||||||
|
case "NODE_ACCESS_VOLUME_SUSPEND":
|
||||||
|
return "节点高频暂停";
|
||||||
|
case "NODE_ACCESS_TARGET_WARNING":
|
||||||
|
return "目标分散警告";
|
||||||
|
case "NODE_ACCESS_TARGET_SUSPEND":
|
||||||
|
return "目标分散暂停";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +121,7 @@ export function SubscriptionAccessRiskSection({
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold tracking-[-0.02em]">订阅访问风控</h3>
|
<h3 className="text-lg font-semibold tracking-[-0.02em]">订阅访问风控</h3>
|
||||||
<p className="mt-0.5 text-sm text-muted-foreground">记录订阅拉取 IP、地区变化和人工处理状态。</p>
|
<p className="mt-0.5 text-sm text-muted-foreground">记录订阅拉取与节点真实连接 IP、地区变化和人工处理状态。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/subscription-risk" className={buttonVariants({ variant: "outline", size: "sm" })}>
|
<Link href="/admin/subscription-risk" className={buttonVariants({ variant: "outline", size: "sm" })}>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export default async function SupportPage({
|
|||||||
subject: "订阅风控复核申请",
|
subject: "订阅风控复核申请",
|
||||||
category: "订阅风控",
|
category: "订阅风控",
|
||||||
priority: "HIGH" as const,
|
priority: "HIGH" as const,
|
||||||
body: "我需要复核订阅风控限制。\n\n请在这里补充:近期访问订阅的设备、所在城市/国家、是否出差或旅行、是否曾分享订阅链接。\n\n系统判定:" + reasonLabel(riskEvent.reason) + "\n" + riskEvent.message,
|
body: "我需要复核订阅风控限制。\n\n请在这里补充:近期访问订阅的设备、所在城市/国家、是否出差或旅行、是否曾分享订阅链接或通过其他设备转发节点。\n\n系统判定:" + reasonLabel(riskEvent.reason) + "\n" + riskEvent.message,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
148
src/app/api/agent/node-access/route.ts
Normal file
148
src/app/api/agent/node-access/route.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { isIP } from "node:net";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { authenticateAgent, isAuthError } from "@/lib/agent-auth";
|
||||||
|
import { getIpGeoContext } from "@/lib/request-context";
|
||||||
|
import { getAppConfig } from "@/services/app-config";
|
||||||
|
import { evaluateNodeAccessAbuseRisk, recordSubscriptionAccess } from "@/services/subscription-risk";
|
||||||
|
|
||||||
|
const MAX_EVENTS = 500;
|
||||||
|
const MAX_TEXT_LENGTH = 200;
|
||||||
|
|
||||||
|
const nodeAccessEventSchema = z.object({
|
||||||
|
clientEmail: z.string().trim().min(1).max(320),
|
||||||
|
sourceIp: z.string().trim().refine((value) => isIP(value) !== 0, "sourceIp 必须是有效 IP"),
|
||||||
|
inboundTag: z.string().trim().max(MAX_TEXT_LENGTH).optional().nullable(),
|
||||||
|
network: z.string().trim().max(16).optional().nullable(),
|
||||||
|
targetHost: z.string().trim().max(MAX_TEXT_LENGTH).optional().nullable(),
|
||||||
|
targetPort: z.coerce.number().int().min(0).max(65535).optional().nullable(),
|
||||||
|
action: z.string().trim().max(32).optional().nullable(),
|
||||||
|
connectionCount: z.coerce.number().int().min(1).max(100000).optional().default(1),
|
||||||
|
uniqueTargetCount: z.coerce.number().int().min(0).max(100000).optional().default(0),
|
||||||
|
firstSeenAt: z.string().trim().max(64).optional().nullable(),
|
||||||
|
lastSeenAt: z.string().trim().max(64).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeAccessPayloadSchema = z.object({
|
||||||
|
events: z.array(nodeAccessEventSchema).min(1).max(MAX_EVENTS),
|
||||||
|
});
|
||||||
|
|
||||||
|
function compactText(value: string | null | undefined) {
|
||||||
|
const text = value?.trim();
|
||||||
|
return text ? text.slice(0, MAX_TEXT_LENGTH) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAction(action: string | null | undefined) {
|
||||||
|
const normalized = action?.trim().toLowerCase();
|
||||||
|
return normalized === "rejected" ? "rejected" : "accepted";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReason(event: z.infer<typeof nodeAccessEventSchema>, nodeId: string) {
|
||||||
|
const parts = [
|
||||||
|
"来源:节点 Xray access log",
|
||||||
|
"节点:" + nodeId,
|
||||||
|
event.inboundTag ? "入站:" + event.inboundTag : null,
|
||||||
|
event.network ? "网络:" + event.network : null,
|
||||||
|
event.targetPort ? "目标端口:" + event.targetPort : null,
|
||||||
|
event.targetHost ? "样本目标:" + event.targetHost : null,
|
||||||
|
"连接数:" + event.connectionCount,
|
||||||
|
event.uniqueTargetCount ? "不同目标:" + event.uniqueTargetCount : null,
|
||||||
|
event.firstSeenAt ? "首次:" + event.firstSeenAt : null,
|
||||||
|
event.lastSeenAt ? "最近:" + event.lastSeenAt : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(";").slice(0, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const auth = await authenticateAgent(req);
|
||||||
|
if (isAuthError(auth)) return auth;
|
||||||
|
const { nodeId } = auth;
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "请求体不是有效 JSON,期望格式:{ events: [...] }" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = nodeAccessPayloadSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "节点访问日志格式无效" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getAppConfig();
|
||||||
|
if (!config.subscriptionRiskEnabled || !config.nodeAccessRiskEnabled) {
|
||||||
|
return NextResponse.json({ ok: true, skipped: parsed.data.events.length, reason: "node_access_risk_disabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientEmails = [...new Set(parsed.data.events.map((event) => event.clientEmail))];
|
||||||
|
const clients = await prisma.nodeClient.findMany({
|
||||||
|
where: {
|
||||||
|
email: { in: clientEmails },
|
||||||
|
inbound: { serverId: nodeId },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
userId: true,
|
||||||
|
subscriptionId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const clientByEmail = new Map(clients.map((client) => [client.email, client]));
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let warnings = 0;
|
||||||
|
let suspended = 0;
|
||||||
|
|
||||||
|
for (const event of parsed.data.events) {
|
||||||
|
const client = clientByEmail.get(event.clientEmail);
|
||||||
|
if (!client) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = normalizeAction(event.action);
|
||||||
|
const allowed = action === "accepted";
|
||||||
|
const result = await recordSubscriptionAccess({
|
||||||
|
kind: "SINGLE",
|
||||||
|
userId: client.userId,
|
||||||
|
subscriptionId: client.subscriptionId,
|
||||||
|
context: {
|
||||||
|
ip: event.sourceIp,
|
||||||
|
userAgent: "jboard-agent/xray-access-log",
|
||||||
|
geo: getIpGeoContext(event.sourceIp),
|
||||||
|
},
|
||||||
|
allowed,
|
||||||
|
reason: buildReason({
|
||||||
|
...event,
|
||||||
|
inboundTag: compactText(event.inboundTag),
|
||||||
|
network: compactText(event.network),
|
||||||
|
targetHost: compactText(event.targetHost),
|
||||||
|
action,
|
||||||
|
}, nodeId),
|
||||||
|
evaluateRisk: allowed,
|
||||||
|
riskConfig: config,
|
||||||
|
sourceLabel: "节点真实连接",
|
||||||
|
});
|
||||||
|
|
||||||
|
const abuseResult = allowed
|
||||||
|
? await evaluateNodeAccessAbuseRisk({
|
||||||
|
userId: client.userId,
|
||||||
|
subscriptionId: client.subscriptionId,
|
||||||
|
ip: event.sourceIp,
|
||||||
|
connectionCount: event.connectionCount,
|
||||||
|
uniqueTargetCount: event.uniqueTargetCount,
|
||||||
|
targetHost: compactText(event.targetHost),
|
||||||
|
targetPort: event.targetPort ?? null,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
: { warned: false, suspended: false };
|
||||||
|
|
||||||
|
processed++;
|
||||||
|
if (result.warned || abuseResult.warned) warnings++;
|
||||||
|
if (result.suspended || abuseResult.suspended) suspended++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, processed, skipped, warnings, suspended });
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { authenticateAgent, isAuthError } from "@/lib/agent-auth";
|
import { authenticateAgent, isAuthError } from "@/lib/agent-auth";
|
||||||
import { normalizeTraceHops, normalizeTraceText } from "@/lib/trace-normalize";
|
import { normalizeTraceHops, normalizeTraceText } from "@/lib/trace-normalize";
|
||||||
|
import { classifyTraceRoute } from "@/lib/route-classify";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const auth = await authenticateAgent(req);
|
const auth = await authenticateAgent(req);
|
||||||
@@ -32,7 +33,8 @@ export async function POST(req: Request) {
|
|||||||
for (const trace of body.traces) {
|
for (const trace of body.traces) {
|
||||||
if (!validCarriers.has(trace.carrier)) continue;
|
if (!validCarriers.has(trace.carrier)) continue;
|
||||||
const normalizedHops = normalizeTraceHops(trace.hops);
|
const normalizedHops = normalizeTraceHops(trace.hops);
|
||||||
const normalizedSummary = normalizeTraceText(trace.summary) || "路由信息";
|
const submittedSummary = normalizeTraceText(trace.summary);
|
||||||
|
const normalizedSummary = classifyTraceRoute({ summary: submittedSummary, hops: normalizedHops });
|
||||||
const hopCount = Number(trace.hopCount);
|
const hopCount = Number(trace.hopCount);
|
||||||
const normalizedHopCount =
|
const normalizedHopCount =
|
||||||
Number.isFinite(hopCount) && hopCount > 0
|
Number.isFinite(hopCount) && hopCount > 0
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { classifyTraceRoute } from "@/lib/route-classify";
|
||||||
|
import { normalizeTraceHops } from "@/lib/trace-normalize";
|
||||||
|
|
||||||
const MAX_NODE_IDS = 100;
|
const MAX_NODE_IDS = 100;
|
||||||
|
|
||||||
@@ -24,7 +26,7 @@ export async function GET(req: Request) {
|
|||||||
}
|
}
|
||||||
result[row.nodeId].push({
|
result[row.nodeId].push({
|
||||||
carrier: row.carrier,
|
carrier: row.carrier,
|
||||||
summary: row.summary,
|
summary: classifyTraceRoute({ summary: row.summary, hops: normalizeTraceHops(row.hops) }),
|
||||||
hopCount: row.hopCount,
|
hopCount: row.hopCount,
|
||||||
hops: row.hops,
|
hops: row.hops,
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function localizedName(record: { names?: object } | null | undefined) {
|
|||||||
return names["zh-CN"] ?? names.en ?? Object.values(names).find((value) => typeof value === "string") ?? null;
|
return names["zh-CN"] ?? names.en ?? Object.values(names).find((value) => typeof value === "string") ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGeoIpLocation(ip: string): RequestGeoContext {
|
export function getIpGeoContext(ip: string): RequestGeoContext {
|
||||||
if (ip === "unknown" || !isIP(ip)) return emptyGeoContext();
|
if (ip === "unknown" || !isIP(ip)) return emptyGeoContext();
|
||||||
|
|
||||||
const reader = getGeoIpReader();
|
const reader = getGeoIpReader();
|
||||||
@@ -244,7 +244,7 @@ export function getRequestGeo(headers: HeaderReader, ip = "unknown"): RequestGeo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headerGeo = { country, region, regionCode, city, latitude, longitude, source };
|
const headerGeo = { country, region, regionCode, city, latitude, longitude, source };
|
||||||
return mergeGeoContext(headerGeo, getGeoIpLocation(ip));
|
return mergeGeoContext(headerGeo, getIpGeoContext(ip));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getClientRequestContext(headers: HeaderReader): ClientRequestContext {
|
export function getClientRequestContext(headers: HeaderReader): ClientRequestContext {
|
||||||
|
|||||||
77
src/lib/route-classify.ts
Normal file
77
src/lib/route-classify.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { normalizeTraceText, type NormalizedTraceHop } from "@/lib/trace-normalize";
|
||||||
|
|
||||||
|
function normalizeAsn(value: unknown) {
|
||||||
|
const text = normalizeTraceText(value).toUpperCase();
|
||||||
|
const match = text.match(/(?:^|\b)AS?\s*(\d{2,10})(?:\b|$)/);
|
||||||
|
if (match) return match[1];
|
||||||
|
return /^\d{2,10}$/.test(text) ? text : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIpInPrefix(ip: string, prefix: string) {
|
||||||
|
return ip === prefix.slice(0, -1) || ip.startsWith(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countMatches(values: string[], predicate: (value: string) => boolean) {
|
||||||
|
return values.reduce((count, value) => count + (predicate(value) ? 1 : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyTraceRoute(input: {
|
||||||
|
summary?: unknown;
|
||||||
|
hops: Array<NormalizedTraceHop | { ip?: unknown; geo?: unknown; asn?: unknown; owner?: unknown; isp?: unknown }>;
|
||||||
|
}) {
|
||||||
|
const summary = normalizeTraceText(input.summary).toUpperCase();
|
||||||
|
const hopTexts = input.hops.map((hop) => [
|
||||||
|
normalizeTraceText(hop.ip),
|
||||||
|
normalizeTraceText(hop.geo),
|
||||||
|
normalizeTraceText("owner" in hop ? hop.owner : ""),
|
||||||
|
normalizeTraceText("isp" in hop ? hop.isp : ""),
|
||||||
|
normalizeTraceText("asn" in hop ? hop.asn : ""),
|
||||||
|
].join(" ").toUpperCase());
|
||||||
|
const combined = [summary, ...hopTexts].join(" ");
|
||||||
|
const ips = input.hops.map((hop) => normalizeTraceText(hop.ip)).filter(Boolean);
|
||||||
|
const asns = new Set<string>();
|
||||||
|
|
||||||
|
for (const hop of input.hops) {
|
||||||
|
const directAsn = normalizeAsn("asn" in hop ? hop.asn : "");
|
||||||
|
if (directAsn) asns.add(directAsn);
|
||||||
|
}
|
||||||
|
for (const match of combined.matchAll(/(?:^|\b)AS\s*(\d{2,10})(?:\b|$)/g)) {
|
||||||
|
asns.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAsn = (...values: string[]) => values.some((value) => asns.has(value));
|
||||||
|
const hasText = (...values: string[]) => values.some((value) => combined.includes(value));
|
||||||
|
const hasIpPrefix = (...prefixes: string[]) => ips.some((ip) => prefixes.some((prefix) => isIpInPrefix(ip, prefix)));
|
||||||
|
|
||||||
|
const cn2Evidence = hasAsn("4809")
|
||||||
|
|| hasIpPrefix("59.43.")
|
||||||
|
|| hasText("CN2", "CTGNET", "CHINANET NEXT CARRYING NETWORK", "CHINA TELECOM GLOBAL");
|
||||||
|
const cn2GiaText = hasText("CN2 GIA", "CN2GIA", "GIA", "GLOBAL INTERNET ACCESS");
|
||||||
|
const ordinaryTelecomHops = countMatches(hopTexts, (text) => (
|
||||||
|
text.includes("AS4134")
|
||||||
|
|| text.includes("CHINANET BACKBONE")
|
||||||
|
|| text.includes("CHINANET 163")
|
||||||
|
|| text.includes("163骨干")
|
||||||
|
)) + countMatches(ips, (ip) => isIpInPrefix(ip, "202.97."));
|
||||||
|
|
||||||
|
if (cn2Evidence) {
|
||||||
|
if (cn2GiaText || ordinaryTelecomHops <= 1) return "CN2 GIA";
|
||||||
|
return "CN2 GT";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAsn("9929", "10099") || hasText("CUII", "A网", "AS9929")) {
|
||||||
|
return "AS9929";
|
||||||
|
}
|
||||||
|
if (hasText("CMIN2") || hasAsn("58807", "58809", "58813", "58819", "59807")) {
|
||||||
|
return "CMIN2";
|
||||||
|
}
|
||||||
|
if (hasText("CMI") || hasAsn("58453")) {
|
||||||
|
return "CMI";
|
||||||
|
}
|
||||||
|
if (hasAsn("4837") || hasText("AS4837")) {
|
||||||
|
return "AS4837";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSummary = normalizeTraceText(input.summary);
|
||||||
|
return normalizedSummary && normalizedSummary !== "普通线路" ? normalizedSummary : "普通线路";
|
||||||
|
}
|
||||||
@@ -77,6 +77,9 @@ export interface NormalizedTraceHop {
|
|||||||
ip: string;
|
ip: string;
|
||||||
geo: string;
|
geo: string;
|
||||||
latency: number;
|
latency: number;
|
||||||
|
asn?: string;
|
||||||
|
owner?: string;
|
||||||
|
isp?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeTraceHops(hops: unknown): NormalizedTraceHop[] {
|
export function normalizeTraceHops(hops: unknown): NormalizedTraceHop[] {
|
||||||
@@ -94,6 +97,9 @@ export function normalizeTraceHops(hops: unknown): NormalizedTraceHop[] {
|
|||||||
ip: normalizeTraceText(hopObject.ip),
|
ip: normalizeTraceText(hopObject.ip),
|
||||||
geo: normalizeTraceText(hopObject.geo),
|
geo: normalizeTraceText(hopObject.geo),
|
||||||
latency: Math.max(0, toSafeNumber(hopObject.latency, 0)),
|
latency: Math.max(0, toSafeNumber(hopObject.latency, 0)),
|
||||||
|
asn: normalizeTraceText(hopObject.asn) || undefined,
|
||||||
|
owner: normalizeTraceText(hopObject.owner) || undefined,
|
||||||
|
isp: normalizeTraceText(hopObject.isp) || undefined,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((hop) => hop.ip || hop.geo || hop.latency > 0);
|
.filter((hop) => hop.ip || hop.geo || hop.latency > 0);
|
||||||
|
|||||||
@@ -142,6 +142,14 @@ export function reasonLabel(reason: SubscriptionRiskReason) {
|
|||||||
return "国家异常警告";
|
return "国家异常警告";
|
||||||
case "COUNTRY_VARIANCE_SUSPEND":
|
case "COUNTRY_VARIANCE_SUSPEND":
|
||||||
return "国家异常暂停";
|
return "国家异常暂停";
|
||||||
|
case "NODE_ACCESS_VOLUME_WARNING":
|
||||||
|
return "节点高频警告";
|
||||||
|
case "NODE_ACCESS_VOLUME_SUSPEND":
|
||||||
|
return "节点高频暂停";
|
||||||
|
case "NODE_ACCESS_TARGET_WARNING":
|
||||||
|
return "目标分散警告";
|
||||||
|
case "NODE_ACCESS_TARGET_SUSPEND":
|
||||||
|
return "目标分散暂停";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,8 +342,8 @@ export function buildSubscriptionRiskReport(input: {
|
|||||||
const userLabel = user ? `${user.email}${user.name ? `(${user.name})` : ""}` : event.userId ?? "未知用户";
|
const userLabel = user ? `${user.email}${user.name ? `(${user.name})` : ""}` : event.userId ?? "未知用户";
|
||||||
const windowRange = `${formatDate(event.windowStartedAt)} 至 ${formatDate(event.createdAt)}`;
|
const windowRange = `${formatDate(event.windowStartedAt)} 至 ${formatDate(event.createdAt)}`;
|
||||||
const actionSuggestion = event.level === "SUSPENDED"
|
const actionSuggestion = event.level === "SUSPENDED"
|
||||||
? "建议保持暂停,等待用户确认是否本人跨地区使用、订阅链接是否外泄,并在工单中补充说明后再解除限制。"
|
? "建议保持暂停,等待用户确认是否本人跨地区使用、订阅链接是否外泄或节点连接是否被共享,并在工单中补充说明后再解除限制。"
|
||||||
: "建议先联系用户确认近期访问来源;如果用户无法解释这些地区/IP,建议重置订阅链接并临时暂停相关订阅。";
|
: "建议先联系用户确认近期访问/连接来源;如果用户无法解释这些地区/IP,建议重置订阅链接并临时暂停相关订阅。";
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"订阅风控风险报告",
|
"订阅风控风险报告",
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ type SubscriptionRiskConfig = Pick<
|
|||||||
| "subscriptionRiskRegionSuspend"
|
| "subscriptionRiskRegionSuspend"
|
||||||
| "subscriptionRiskCountryWarning"
|
| "subscriptionRiskCountryWarning"
|
||||||
| "subscriptionRiskCountrySuspend"
|
| "subscriptionRiskCountrySuspend"
|
||||||
|
| "nodeAccessRiskEnabled"
|
||||||
|
| "nodeAccessConnectionWarning"
|
||||||
|
| "nodeAccessConnectionSuspend"
|
||||||
|
| "nodeAccessUniqueTargetWarning"
|
||||||
|
| "nodeAccessUniqueTargetSuspend"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
interface RecordSubscriptionAccessInput {
|
interface RecordSubscriptionAccessInput {
|
||||||
@@ -35,6 +40,7 @@ interface RecordSubscriptionAccessInput {
|
|||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
evaluateRisk?: boolean;
|
evaluateRisk?: boolean;
|
||||||
riskConfig?: SubscriptionRiskConfig;
|
riskConfig?: SubscriptionRiskConfig;
|
||||||
|
sourceLabel?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RiskDecision {
|
interface RiskDecision {
|
||||||
@@ -99,8 +105,9 @@ function riskMessage(options: {
|
|||||||
countryLabels: string[];
|
countryLabels: string[];
|
||||||
regionLabels: string[];
|
regionLabels: string[];
|
||||||
cityLabels: string[];
|
cityLabels: string[];
|
||||||
|
sourceLabel?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const scope = getScopeLabel(options.kind);
|
const scope = options.sourceLabel?.trim() || getScopeLabel(options.kind);
|
||||||
const locationSummary = options.decision.reason.startsWith("COUNTRY")
|
const locationSummary = options.decision.reason.startsWith("COUNTRY")
|
||||||
? `${options.countryCount} 个国家/地区:${formatKeyPreview(options.countryLabels)}`
|
? `${options.countryCount} 个国家/地区:${formatKeyPreview(options.countryLabels)}`
|
||||||
: options.decision.reason.startsWith("REGION")
|
: options.decision.reason.startsWith("REGION")
|
||||||
@@ -358,6 +365,7 @@ async function evaluateSubscriptionRisk(input: {
|
|||||||
userId?: string | null;
|
userId?: string | null;
|
||||||
subscriptionId?: string | null;
|
subscriptionId?: string | null;
|
||||||
ip: string;
|
ip: string;
|
||||||
|
sourceLabel?: string | null;
|
||||||
db: DbClient;
|
db: DbClient;
|
||||||
config?: SubscriptionRiskConfig;
|
config?: SubscriptionRiskConfig;
|
||||||
}): Promise<RiskEvaluationResult> {
|
}): Promise<RiskEvaluationResult> {
|
||||||
@@ -433,6 +441,7 @@ async function evaluateSubscriptionRisk(input: {
|
|||||||
countryLabels,
|
countryLabels,
|
||||||
regionLabels,
|
regionLabels,
|
||||||
cityLabels,
|
cityLabels,
|
||||||
|
sourceLabel: input.sourceLabel,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { event, created } = await createRiskEvent({
|
const { event, created } = await createRiskEvent({
|
||||||
@@ -497,6 +506,142 @@ async function evaluateSubscriptionRisk(input: {
|
|||||||
return { warned: true, suspended: false, eventId: event.id };
|
return { warned: true, suspended: false, eventId: event.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decideNodeAccessAbuseRisk(input: {
|
||||||
|
connectionCount: number;
|
||||||
|
uniqueTargetCount: number;
|
||||||
|
config: SubscriptionRiskConfig;
|
||||||
|
}): RiskDecision | null {
|
||||||
|
if (!input.config.nodeAccessRiskEnabled) return null;
|
||||||
|
|
||||||
|
if (input.config.subscriptionRiskAutoSuspend && input.uniqueTargetCount >= input.config.nodeAccessUniqueTargetSuspend) {
|
||||||
|
return { level: "SUSPENDED", reason: "NODE_ACCESS_TARGET_SUSPEND" };
|
||||||
|
}
|
||||||
|
if (input.config.subscriptionRiskAutoSuspend && input.connectionCount >= input.config.nodeAccessConnectionSuspend) {
|
||||||
|
return { level: "SUSPENDED", reason: "NODE_ACCESS_VOLUME_SUSPEND" };
|
||||||
|
}
|
||||||
|
if (input.uniqueTargetCount >= input.config.nodeAccessUniqueTargetWarning) {
|
||||||
|
return { level: "WARNING", reason: "NODE_ACCESS_TARGET_WARNING" };
|
||||||
|
}
|
||||||
|
if (input.connectionCount >= input.config.nodeAccessConnectionWarning) {
|
||||||
|
return { level: "WARNING", reason: "NODE_ACCESS_VOLUME_WARNING" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeAccessAbuseMessage(input: {
|
||||||
|
decision: RiskDecision;
|
||||||
|
ip: string;
|
||||||
|
connectionCount: number;
|
||||||
|
uniqueTargetCount: number;
|
||||||
|
targetHost?: string | null;
|
||||||
|
targetPort?: number | null;
|
||||||
|
}) {
|
||||||
|
const metric = input.decision.reason.includes("TARGET")
|
||||||
|
? "不同目标 " + input.uniqueTargetCount + " 个"
|
||||||
|
: "连接 " + input.connectionCount + " 次";
|
||||||
|
const targetValue = [input.targetHost, input.targetPort].filter(Boolean).join(":");
|
||||||
|
const target = targetValue ? ",样本目标 " + targetValue : "";
|
||||||
|
const action = input.decision.level === "SUSPENDED" ? "已自动暂停" : "已记录警告";
|
||||||
|
return "节点真实连接行为异常,单个聚合窗口内出现 " + metric + ",来源 IP " + input.ip + target + "," + action + "。";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluateNodeAccessAbuseRisk(input: {
|
||||||
|
userId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
ip: string;
|
||||||
|
connectionCount: number;
|
||||||
|
uniqueTargetCount: number;
|
||||||
|
targetHost?: string | null;
|
||||||
|
targetPort?: number | null;
|
||||||
|
config?: SubscriptionRiskConfig;
|
||||||
|
db?: DbClient;
|
||||||
|
}): Promise<RiskEvaluationResult> {
|
||||||
|
const db = input.db ?? prisma;
|
||||||
|
const config = input.config ?? await getAppConfig(db);
|
||||||
|
if (!config.subscriptionRiskEnabled || !config.nodeAccessRiskEnabled) {
|
||||||
|
return { warned: false, suspended: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const decision = decideNodeAccessAbuseRisk({
|
||||||
|
connectionCount: input.connectionCount,
|
||||||
|
uniqueTargetCount: input.uniqueTargetCount,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
if (!decision) return { warned: false, suspended: false };
|
||||||
|
|
||||||
|
const windowStartedAt = new Date(Date.now() - config.subscriptionRiskWindowHours * 60 * 60 * 1000);
|
||||||
|
const message = nodeAccessAbuseMessage({
|
||||||
|
decision,
|
||||||
|
ip: input.ip,
|
||||||
|
connectionCount: input.connectionCount,
|
||||||
|
uniqueTargetCount: input.uniqueTargetCount,
|
||||||
|
targetHost: input.targetHost,
|
||||||
|
targetPort: input.targetPort,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { event, created } = await createRiskEvent({
|
||||||
|
kind: "SINGLE",
|
||||||
|
userId: input.userId,
|
||||||
|
subscriptionId: input.subscriptionId,
|
||||||
|
ip: input.ip,
|
||||||
|
decision,
|
||||||
|
message,
|
||||||
|
windowStartedAt,
|
||||||
|
countryLabels: [],
|
||||||
|
regionLabels: [],
|
||||||
|
cityLabels: [],
|
||||||
|
db,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
const targetLabel = await getTargetLabel({ userId: input.userId, subscriptionId: input.subscriptionId }, db);
|
||||||
|
await recordAuditLog({
|
||||||
|
action: decision.level === "SUSPENDED" ? "risk.node_access.suspend" : "risk.node_access.warning",
|
||||||
|
targetType: "UserSubscription",
|
||||||
|
targetId: input.subscriptionId,
|
||||||
|
targetLabel,
|
||||||
|
message,
|
||||||
|
metadata: {
|
||||||
|
eventId: event.id,
|
||||||
|
reason: decision.reason,
|
||||||
|
ip: input.ip,
|
||||||
|
connectionCount: input.connectionCount,
|
||||||
|
uniqueTargetCount: input.uniqueTargetCount,
|
||||||
|
targetHost: input.targetHost ?? null,
|
||||||
|
targetPort: input.targetPort ?? null,
|
||||||
|
windowStartedAt: windowStartedAt.toISOString(),
|
||||||
|
},
|
||||||
|
}, db);
|
||||||
|
|
||||||
|
if (decision.level === "WARNING") {
|
||||||
|
await createNotification({
|
||||||
|
userId: input.userId,
|
||||||
|
type: "SUBSCRIPTION",
|
||||||
|
level: "WARNING",
|
||||||
|
title: "节点连接行为异常",
|
||||||
|
body: "检测到你的订阅在节点侧出现异常高频连接或目标分散。如果不是你本人操作,请重置订阅访问并联系管理员。",
|
||||||
|
link: "/subscriptions/" + input.subscriptionId,
|
||||||
|
dedupeKey: "risk:node-access:warning:" + event.id,
|
||||||
|
}, db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.level === "SUSPENDED") {
|
||||||
|
const suspendedIds = await suspendScopeForRisk({
|
||||||
|
kind: "SINGLE",
|
||||||
|
userId: input.userId,
|
||||||
|
subscriptionId: input.subscriptionId,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
revalidateRiskViews(suspendedIds);
|
||||||
|
return { warned: false, suspended: true, eventId: event.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created) revalidateRiskViews([input.subscriptionId]);
|
||||||
|
return { warned: true, suspended: false, eventId: event.id };
|
||||||
|
}
|
||||||
|
|
||||||
export async function recordSubscriptionAccess(
|
export async function recordSubscriptionAccess(
|
||||||
input: RecordSubscriptionAccessInput,
|
input: RecordSubscriptionAccessInput,
|
||||||
db: DbClient = prisma,
|
db: DbClient = prisma,
|
||||||
@@ -529,6 +674,7 @@ export async function recordSubscriptionAccess(
|
|||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
subscriptionId: input.subscriptionId,
|
subscriptionId: input.subscriptionId,
|
||||||
ip: input.context.ip,
|
ip: input.context.ip,
|
||||||
|
sourceLabel: input.sourceLabel,
|
||||||
db,
|
db,
|
||||||
config: input.riskConfig,
|
config: input.riskConfig,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user