This commit is contained in:
jimleerx
2026-01-28 13:13:58 +08:00
commit e605cd07bf
21 changed files with 5320 additions and 0 deletions

25
Makefile Normal file
View File

@@ -0,0 +1,25 @@
.PHONY: build clean install run
BINARY_NAME=mmw-agent
BUILD_DIR=build
build:
go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/mmw-agent
clean:
rm -rf $(BUILD_DIR)
install: build
cp $(BUILD_DIR)/$(BINARY_NAME) /usr/local/bin/
run:
go run ./cmd/mmw-agent
test:
go test ./...
vet:
go vet ./...
tidy:
go mod tidy

127
cmd/mmw-agent/main.go Normal file
View File

@@ -0,0 +1,127 @@
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"mmw-agent/internal/agent"
"mmw-agent/internal/config"
"mmw-agent/internal/handler"
)
func main() {
configPath := flag.String("config", "", "Path to config file")
flag.Parse()
// Load configuration
var cfg *config.Config
var err error
if *configPath != "" {
cfg, err = config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Merge environment variables (env takes precedence)
cfg.Merge(config.FromEnv())
} else {
cfg = config.FromEnv()
}
if err := cfg.Validate(); err != nil {
log.Fatalf("Invalid config: %v", err)
}
log.Printf("[Main] Starting mmw-agent")
log.Printf("[Main] Connection mode: %s", cfg.ConnectionMode)
log.Printf("[Main] Listen port: %s", cfg.ListenPort)
log.Printf("[Main] Xray servers: %d configured", len(cfg.XrayServers))
// Create agent client
agentClient := agent.NewClient(cfg)
// Create handlers
apiHandler := handler.NewAPIHandler(agentClient, cfg.Token)
manageHandler := handler.NewManageHandler(cfg.Token)
// Setup HTTP routes
mux := http.NewServeMux()
// Pull mode API
mux.HandleFunc("/api/child/traffic", apiHandler.ServeHTTP)
mux.HandleFunc("/api/child/speed", apiHandler.ServeSpeedHTTP)
// Management API
mux.HandleFunc("/api/child/services/status", manageHandler.HandleServicesStatus)
mux.HandleFunc("/api/child/services/control", manageHandler.HandleServiceControl)
mux.HandleFunc("/api/child/xray/install", manageHandler.HandleXrayInstall)
mux.HandleFunc("/api/child/xray/remove", manageHandler.HandleXrayRemove)
mux.HandleFunc("/api/child/xray/config", manageHandler.HandleXrayConfig)
mux.HandleFunc("/api/child/xray/system-config", manageHandler.HandleXraySystemConfig)
mux.HandleFunc("/api/child/xray/config-files", manageHandler.HandleXrayConfigFiles)
mux.HandleFunc("/api/child/nginx/install", manageHandler.HandleNginxInstall)
mux.HandleFunc("/api/child/nginx/remove", manageHandler.HandleNginxRemove)
mux.HandleFunc("/api/child/nginx/config", manageHandler.HandleNginxConfig)
mux.HandleFunc("/api/child/nginx/config-files", manageHandler.HandleNginxConfigFiles)
mux.HandleFunc("/api/child/system/info", manageHandler.HandleSystemInfo)
mux.HandleFunc("/api/child/inbounds", manageHandler.HandleInbounds)
mux.HandleFunc("/api/child/outbounds", manageHandler.HandleOutbounds)
mux.HandleFunc("/api/child/routing", manageHandler.HandleRouting)
mux.HandleFunc("/api/child/scan", manageHandler.HandleScan)
// Health check
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok","mode":"` + string(agentClient.GetCurrentMode()) + `"}`))
})
// Create HTTP server
server := &http.Server{
Addr: ":" + cfg.ListenPort,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
// Setup graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
// Start agent client
agentClient.Start(ctx)
// Start HTTP server
go func() {
log.Printf("[Main] HTTP server listening on :%s", cfg.ListenPort)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[Main] HTTP server error: %v", err)
}
}()
// Wait for shutdown signal
sig := <-sigCh
log.Printf("[Main] Received signal %v, shutting down...", sig)
// Graceful shutdown
cancel()
agentClient.Stop()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("[Main] HTTP server shutdown error: %v", err)
}
log.Printf("[Main] Shutdown complete")
}

32
config.example.yaml Normal file
View File

@@ -0,0 +1,32 @@
# mmw-agent 配置示例
# Master 服务器地址 (WebSocket/HTTP 模式需要)
master_url: "http://localhost:8080"
# 认证令牌 (从 master 服务器获取)
token: "your-remote-token"
# 连接模式: auto | websocket | http | pull
# - auto: 自动选择最佳模式 (WebSocket -> HTTP -> Pull)
# - websocket: 仅使用 WebSocket 长连接
# - http: 仅使用 HTTP 推送
# - pull: 仅提供 API 供 master 拉取
connection_mode: "auto"
# HTTP 监听端口 (Pull 模式和管理 API)
listen_port: "23889"
# 流量上报间隔
traffic_report_interval: 1m
# 速度上报间隔
speed_report_interval: 3s
# Xray 服务器配置 (可选)
# 如果不配置,将自动扫描以下路径:
# - /usr/local/etc/xray/config.json
# - /etc/xray/config.json
# - /opt/xray/config.json
xray_servers:
- name: "primary"
config_path: "/usr/local/etc/xray/config.json"

32
config.yaml Normal file
View File

@@ -0,0 +1,32 @@
# mmw-agent 配置示例
# Master 服务器地址 (WebSocket/HTTP 模式需要)
master_url: "http://localhost:8080"
# 认证令牌 (从 master 服务器获取)
token: "xWAvZ5qOuhOGBa4jtkuOE9IukqIJgRepKtcUiHD9Qoc="
# 连接模式: auto | websocket | http | pull
# - auto: 自动选择最佳模式 (WebSocket -> HTTP -> Pull)
# - websocket: 仅使用 WebSocket 长连接
# - http: 仅使用 HTTP 推送
# - pull: 仅提供 API 供 master 拉取
connection_mode: "auto"
# HTTP 监听端口 (Pull 模式和管理 API)
listen_port: "23889"
# 流量上报间隔
traffic_report_interval: 1m
# 速度上报间隔
speed_report_interval: 3s
# Xray 服务器配置 (可选)
# 如果不配置,将自动扫描以下路径:
# - /usr/local/etc/xray/config.json
# - /etc/xray/config.json
# - /opt/xray/config.json
xray_servers:
- name: "primary"
config_path: "/usr/local/etc/xray/config.json"

52
go.mod Normal file
View File

@@ -0,0 +1,52 @@
module mmw-agent
go 1.25.6
require (
github.com/go-acme/lego/v4 v4.31.0
github.com/gorilla/websocket v1.5.3
github.com/xtls/xray-core v1.260123.0
google.golang.org/grpc v1.78.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/dgryski/go-metro v0.0.0-20200812162917-85c65e2d0165 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/juju/ratelimit v1.0.2 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/cpuid/v2 v2.0.12 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/pires/go-proxyproto v0.9.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
github.com/sagernet/sing v0.5.1 // indirect
github.com/sagernet/sing-shadowsocks v0.2.7 // indirect
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 // indirect
github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/xtls/reality v0.0.0-20251014195629-e4eec4520535 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.40.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/protobuf v1.36.11 // indirect
gvisor.dev/gvisor v0.0.0-20260109181451-4be7c433dae2 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
)

144
go.sum Normal file
View File

@@ -0,0 +1,144 @@
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178 h1:bSq8n+gX4oO/qnM3MKf4kroW75n+phO9Qp6nigJKZ1E=
github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178/go.mod h1:N1WIjPphkqs4efXWuyDNQ6OjjIK04vM3h+bEgwV+eVU=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-metro v0.0.0-20200812162917-85c65e2d0165 h1:BS21ZUJ/B5X2UVUbczfmdWH7GapPWAhxcMsDnjJTU1E=
github.com/dgryski/go-metro v0.0.0-20200812162917-85c65e2d0165/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
github.com/go-acme/lego/v4 v4.31.0 h1:gd4oUYdfs83PR1/SflkNdit9xY1iul2I4EystnU8NXM=
github.com/go-acme/lego/v4 v4.31.0/go.mod h1:m6zcfX/zcbMYDa8s6AnCMnoORWNP8Epnei+6NBCTUGs=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.12 h1:p9dKCg8i4gmOxtv35DvrYoWqYzQrvEVdjQ762Y0OqZE=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pires/go-proxyproto v0.9.0 h1:3Qg3CLxWx4wJOw5uxhTvc0VrgsJeerDbGTvexu4UK1E=
github.com/pires/go-proxyproto v0.9.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sagernet/sing v0.5.1 h1:mhL/MZVq0TjuvHcpYcFtmSD1BFOxZ/+8ofbNZcg1k1Y=
github.com/sagernet/sing v0.5.1/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing-shadowsocks v0.2.7 h1:zaopR1tbHEw5Nk6FAkM05wCslV6ahVegEZaKMv9ipx8=
github.com/sagernet/sing-shadowsocks v0.2.7/go.mod h1:0rIKJZBR65Qi0zwdKezt4s57y/Tl1ofkaq6NlkzVuyE=
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 h1:emzAzMZ1L9iaKCTxdy3Em8Wv4ChIAGnfiz18Cda70g4=
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771/go.mod h1:bR6DqgcAl1zTcOX8/pE2Qkj9XO00eCNqmKb7lXP8EAg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e h1:5QefA066A1tF8gHIiADmOVOV5LS43gt3ONnlEl3xkwI=
github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e/go.mod h1:5t19P9LBIrNamL6AcMQOncg/r10y3Pc01AbHeMhwlpU=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/xtls/reality v0.0.0-20251014195629-e4eec4520535 h1:nwobseOLLRtdbP6z7Z2aVI97u8ZptTgD1ofovhAKmeU=
github.com/xtls/reality v0.0.0-20251014195629-e4eec4520535/go.mod h1:vbHCV/3VWUvy1oKvTxxWJRPEWSeR1sYgQHIh6u/JiZQ=
github.com/xtls/xray-core v1.260123.0 h1:FCaIDJ1ThRaG9b8TpqNNq3hIcPTN+24vPWQY3s9lSXg=
github.com/xtls/xray-core v1.260123.0/go.mod h1:xfHDVg861cIAR5WjwEVKHr/G/HMHSGSC9j3LZmt6sKM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU=
golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gvisor.dev/gvisor v0.0.0-20260109181451-4be7c433dae2 h1:fr6L00yGG2RP5NMea6njWpdC+bm+cMdFClrSpaicp1c=
gvisor.dev/gvisor v0.0.0-20260109181451-4be7c433dae2/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=

224
internal/acme/client.go Normal file
View File

@@ -0,0 +1,224 @@
package acme
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/http01"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
)
// CertResult represents the result of a certificate issuance.
type CertResult struct {
Domain string
CertPath string
KeyPath string
CertPEM string
KeyPEM string
IssueDate time.Time
ExpiryDate time.Time
}
// User implements the acme.User interface for lego.
type User struct {
Email string
Registration *registration.Resource
key *ecdsa.PrivateKey
}
func (u *User) GetEmail() string { return u.Email }
func (u *User) GetRegistration() *registration.Resource { return u.Registration }
func (u *User) GetPrivateKey() crypto.PrivateKey { return u.key }
// Client wraps the lego ACME client.
type Client struct {
certDir string
staging bool
httpPort string
webrootDir string
}
// ClientOption configures the Client.
type ClientOption func(*Client)
// WithCertDir sets the certificate storage directory.
func WithCertDir(dir string) ClientOption {
return func(c *Client) { c.certDir = dir }
}
// WithStaging enables the Let's Encrypt staging environment.
func WithStaging(staging bool) ClientOption {
return func(c *Client) { c.staging = staging }
}
// WithHTTPPort sets the port for HTTP-01 challenge (default: ":80").
func WithHTTPPort(port string) ClientOption {
return func(c *Client) { c.httpPort = port }
}
// WithWebrootDir sets the webroot directory for webroot challenge mode.
func WithWebrootDir(dir string) ClientOption {
return func(c *Client) { c.webrootDir = dir }
}
// NewClient creates a new ACME client.
func NewClient(opts ...ClientOption) *Client {
c := &Client{
certDir: "/etc/miaomiaowu/certs",
staging: false,
httpPort: ":80",
}
for _, opt := range opts {
opt(c)
}
return c
}
// ObtainCertificate requests a new certificate for the given domain.
func (c *Client) ObtainCertificate(ctx context.Context, email, domain string, useWebroot bool) (*CertResult, error) {
if email == "" {
return nil, errors.New("email is required")
}
if domain == "" {
return nil, errors.New("domain is required")
}
// Generate a new private key for the user
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate private key: %w", err)
}
user := &User{
Email: email,
key: privateKey,
}
config := lego.NewConfig(user)
if c.staging {
config.CADirURL = lego.LEDirectoryStaging
} else {
config.CADirURL = lego.LEDirectoryProduction
}
config.Certificate.KeyType = certcrypto.EC256
client, err := lego.NewClient(config)
if err != nil {
return nil, fmt.Errorf("create lego client: %w", err)
}
// Set up HTTP-01 challenge provider
if useWebroot && c.webrootDir != "" {
// Webroot mode: write challenge files to the specified directory
provider, err := NewWebrootProvider(c.webrootDir)
if err != nil {
return nil, fmt.Errorf("create webroot provider: %w", err)
}
if err := client.Challenge.SetHTTP01Provider(provider); err != nil {
return nil, fmt.Errorf("set webroot provider: %w", err)
}
} else {
// Standalone mode: lego starts its own HTTP server
provider := http01.NewProviderServer("", c.httpPort)
if err := client.Challenge.SetHTTP01Provider(provider); err != nil {
return nil, fmt.Errorf("set http01 provider: %w", err)
}
}
// Register the user
reg, err := client.Registration.Register(registration.RegisterOptions{
TermsOfServiceAgreed: true,
})
if err != nil {
return nil, fmt.Errorf("register with ACME: %w", err)
}
user.Registration = reg
// Request the certificate
request := certificate.ObtainRequest{
Domains: []string{domain},
Bundle: true,
}
certificates, err := client.Certificate.Obtain(request)
if err != nil {
return nil, fmt.Errorf("obtain certificate: %w", err)
}
// Parse the certificate to get expiry date
expiryDate, issueDate, err := parseCertificateDates(certificates.Certificate)
if err != nil {
return nil, fmt.Errorf("parse certificate: %w", err)
}
// Save the certificate to disk
certPath, keyPath, err := c.saveCertificate(domain, certificates.Certificate, certificates.PrivateKey)
if err != nil {
return nil, fmt.Errorf("save certificate: %w", err)
}
return &CertResult{
Domain: domain,
CertPath: certPath,
KeyPath: keyPath,
CertPEM: string(certificates.Certificate),
KeyPEM: string(certificates.PrivateKey),
IssueDate: issueDate,
ExpiryDate: expiryDate,
}, nil
}
func (c *Client) saveCertificate(domain string, certPEM, keyPEM []byte) (string, string, error) {
// Ensure directory exists
domainDir := filepath.Join(c.certDir, domain)
if err := os.MkdirAll(domainDir, 0700); err != nil {
return "", "", fmt.Errorf("create cert directory: %w", err)
}
certPath := filepath.Join(domainDir, "fullchain.pem")
keyPath := filepath.Join(domainDir, "privkey.pem")
// Write certificate
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
return "", "", fmt.Errorf("write certificate: %w", err)
}
// Write private key with restrictive permissions
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
return "", "", fmt.Errorf("write private key: %w", err)
}
return certPath, keyPath, nil
}
func parseCertificateDates(certPEM []byte) (expiryDate, issueDate time.Time, err error) {
block, _ := pem.Decode(certPEM)
if block == nil {
return time.Time{}, time.Time{}, errors.New("failed to decode PEM block")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("parse certificate: %w", err)
}
return cert.NotAfter, cert.NotBefore, nil
}
// GetCertDir returns the certificate storage directory.
func (c *Client) GetCertDir() string {
return c.certDir
}

55
internal/acme/webroot.go Normal file
View File

@@ -0,0 +1,55 @@
package acme
import (
"fmt"
"os"
"path/filepath"
"github.com/go-acme/lego/v4/challenge/http01"
)
// WebrootProvider implements the HTTP-01 challenge using a webroot directory.
type WebrootProvider struct {
path string
}
// NewWebrootProvider creates a new webroot provider.
func NewWebrootProvider(path string) (*WebrootProvider, error) {
if path == "" {
return nil, fmt.Errorf("webroot path is required")
}
// Ensure the webroot directory exists
challengeDir := filepath.Join(path, http01.ChallengePath(""))
if err := os.MkdirAll(challengeDir, 0755); err != nil {
return nil, fmt.Errorf("create challenge directory: %w", err)
}
return &WebrootProvider{path: path}, nil
}
// Present writes the challenge token to the webroot directory.
func (w *WebrootProvider) Present(domain, token, keyAuth string) error {
challengePath := filepath.Join(w.path, http01.ChallengePath(token))
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(challengePath), 0755); err != nil {
return fmt.Errorf("create challenge directory: %w", err)
}
// Write the key authorization to the challenge file
if err := os.WriteFile(challengePath, []byte(keyAuth), 0644); err != nil {
return fmt.Errorf("write challenge file: %w", err)
}
return nil
}
// CleanUp removes the challenge token file.
func (w *WebrootProvider) CleanUp(domain, token, keyAuth string) error {
challengePath := filepath.Join(w.path, http01.ChallengePath(token))
if err := os.Remove(challengePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove challenge file: %w", err)
}
return nil
}

990
internal/agent/client.go Normal file
View File

@@ -0,0 +1,990 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"mmw-agent/internal/acme"
"mmw-agent/internal/collector"
"mmw-agent/internal/config"
"github.com/gorilla/websocket"
)
// ConnectionMode represents the current connection mode
type ConnectionMode string
const (
ModeWebSocket ConnectionMode = "websocket"
ModeHTTP ConnectionMode = "http"
ModePull ConnectionMode = "pull"
ModeAuto ConnectionMode = "auto"
)
// Client represents an agent client that connects to a master server
type Client struct {
config *config.Config
collector *collector.Collector
xrayServers []config.XrayServer
wsConn *websocket.Conn
wsMu sync.Mutex
connected bool
reconnects int
stopCh chan struct{}
wg sync.WaitGroup
// Connection state
currentMode ConnectionMode
httpClient *http.Client
httpAvailable bool
modeMu sync.RWMutex
// Speed calculation (from system network interface)
lastRxBytes int64
lastTxBytes int64
lastSampleTime time.Time
speedMu sync.Mutex
}
// NewClient creates a new agent client
func NewClient(cfg *config.Config) *Client {
return &Client{
config: cfg,
collector: collector.NewCollector(),
xrayServers: cfg.XrayServers,
stopCh: make(chan struct{}),
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
currentMode: ModePull, // Default to pull mode
}
}
// Start starts the agent client with automatic mode selection
func (c *Client) Start(ctx context.Context) {
log.Printf("[Agent] Starting in %s mode", c.config.ConnectionMode)
mode := ConnectionMode(c.config.ConnectionMode)
switch mode {
case ModeWebSocket:
c.wg.Add(1)
go c.runWebSocket(ctx)
case ModeHTTP:
c.wg.Add(1)
go c.runHTTPReporter(ctx)
case ModePull:
c.setCurrentMode(ModePull)
log.Printf("[Agent] Pull mode enabled - API will be served at /api/child/traffic and /api/child/speed")
case ModeAuto:
fallthrough
default:
c.wg.Add(1)
go c.runAutoMode(ctx)
}
}
// Stop stops the agent client
func (c *Client) Stop() {
close(c.stopCh)
c.wg.Wait()
c.wsMu.Lock()
if c.wsConn != nil {
c.wsConn.Close()
}
c.wsMu.Unlock()
log.Printf("[Agent] Stopped")
}
// IsConnected returns whether the WebSocket is connected
func (c *Client) IsConnected() bool {
c.wsMu.Lock()
defer c.wsMu.Unlock()
return c.connected
}
// GetCurrentMode returns the current connection mode
func (c *Client) GetCurrentMode() ConnectionMode {
c.modeMu.RLock()
defer c.modeMu.RUnlock()
return c.currentMode
}
// setCurrentMode sets the current connection mode
func (c *Client) setCurrentMode(mode ConnectionMode) {
c.modeMu.Lock()
defer c.modeMu.Unlock()
c.currentMode = mode
}
// runWebSocket manages the WebSocket connection lifecycle with fallback to auto mode
func (c *Client) runWebSocket(ctx context.Context) {
defer c.wg.Done()
maxConsecutiveFailures := 5
consecutiveFailures := 0
for {
select {
case <-ctx.Done():
return
case <-c.stopCh:
return
default:
}
c.setCurrentMode(ModeWebSocket)
if err := c.connectAndRun(ctx); err != nil {
if ctx.Err() != nil {
log.Printf("[Agent] Context canceled, stopping gracefully")
return
}
log.Printf("[Agent] WebSocket error: %v", err)
consecutiveFailures++
if consecutiveFailures >= maxConsecutiveFailures {
log.Printf("[Agent] Too many WebSocket failures (%d), switching to auto mode for fallback...", consecutiveFailures)
c.runAutoModeLoop(ctx)
consecutiveFailures = 0
continue
}
} else {
consecutiveFailures = 0
}
backoff := c.calculateBackoff()
log.Printf("[Agent] Reconnecting in %v...", backoff)
c.waitWithTrafficReport(ctx, backoff)
}
}
// calculateBackoff calculates the reconnection backoff duration
func (c *Client) calculateBackoff() time.Duration {
c.reconnects++
backoff := time.Duration(c.reconnects) * 5 * time.Second
if backoff > 5*time.Minute {
backoff = 5 * time.Minute
}
return backoff
}
// connectAndRun establishes and maintains a WebSocket connection
func (c *Client) connectAndRun(ctx context.Context) error {
masterURL := c.config.MasterURL
u, err := url.Parse(masterURL)
if err != nil {
return err
}
switch u.Scheme {
case "http":
u.Scheme = "ws"
case "https":
u.Scheme = "wss"
}
u.Path = "/api/remote/ws"
log.Printf("[Agent] Connecting to %s", u.String())
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
}
conn, _, err := dialer.DialContext(ctx, u.String(), nil)
if err != nil {
return err
}
c.wsMu.Lock()
c.wsConn = conn
c.wsMu.Unlock()
defer func() {
c.wsMu.Lock()
c.wsConn = nil
c.connected = false
c.wsMu.Unlock()
conn.Close()
}()
if err := c.authenticate(conn); err != nil {
return err
}
c.wsMu.Lock()
c.connected = true
c.reconnects = 0
c.wsMu.Unlock()
log.Printf("[Agent] Connected and authenticated")
return c.runMessageLoop(ctx, conn)
}
// authenticate sends the authentication message
func (c *Client) authenticate(conn *websocket.Conn) error {
authPayload, _ := json.Marshal(map[string]string{
"token": c.config.Token,
})
msg := map[string]interface{}{
"type": "auth",
"payload": json.RawMessage(authPayload),
}
if err := conn.WriteJSON(msg); err != nil {
return err
}
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
_, message, err := conn.ReadMessage()
if err != nil {
return err
}
var result struct {
Type string `json:"type"`
Payload struct {
Success bool `json:"success"`
Message string `json:"message"`
} `json:"payload"`
}
if err := json.Unmarshal(message, &result); err != nil {
return err
}
if result.Type != "auth_result" || !result.Payload.Success {
return &AuthError{Message: result.Payload.Message}
}
return nil
}
// runMessageLoop handles sending traffic data, speed data, and heartbeats
func (c *Client) runMessageLoop(ctx context.Context, conn *websocket.Conn) error {
trafficTicker := time.NewTicker(c.config.TrafficReportInterval)
speedTicker := time.NewTicker(c.config.SpeedReportInterval)
heartbeatTicker := time.NewTicker(30 * time.Second)
defer trafficTicker.Stop()
defer speedTicker.Stop()
defer heartbeatTicker.Stop()
msgCh := make(chan []byte, 10)
errCh := make(chan error, 1)
go func() {
for {
conn.SetReadDeadline(time.Now().Add(5 * time.Minute))
_, message, err := conn.ReadMessage()
if err != nil {
errCh <- err
return
}
// Send message to processing channel
select {
case msgCh <- message:
default:
log.Printf("[Agent] Message queue full, dropping message")
}
}
}()
c.sendTrafficData(conn)
c.sendSpeedData(conn)
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-c.stopCh:
return nil
case err := <-errCh:
return err
case msg := <-msgCh:
c.handleMessage(conn, msg)
case <-trafficTicker.C:
if err := c.sendTrafficData(conn); err != nil {
return err
}
case <-speedTicker.C:
if err := c.sendSpeedData(conn); err != nil {
return err
}
case <-heartbeatTicker.C:
if err := c.sendHeartbeat(conn); err != nil {
return err
}
}
}
}
// sendTrafficData collects and sends traffic data to the master
func (c *Client) sendTrafficData(conn *websocket.Conn) error {
stats, err := c.collectLocalMetrics()
if err != nil {
log.Printf("[Agent] Failed to collect metrics: %v", err)
stats = &collector.XrayStats{}
}
payload, _ := json.Marshal(map[string]interface{}{
"stats": stats,
})
msg := map[string]interface{}{
"type": "traffic",
"payload": json.RawMessage(payload),
}
c.wsMu.Lock()
err = conn.WriteJSON(msg)
c.wsMu.Unlock()
if err != nil {
return err
}
log.Printf("[Agent] Sent traffic data: %d inbounds, %d outbounds, %d users",
len(stats.Inbound), len(stats.Outbound), len(stats.User))
return nil
}
// sendHeartbeat sends a heartbeat message
func (c *Client) sendHeartbeat(conn *websocket.Conn) error {
now := time.Now()
listenPort, _ := strconv.Atoi(c.config.ListenPort)
payload, _ := json.Marshal(map[string]interface{}{
"boot_time": now,
"listen_port": listenPort,
})
msg := map[string]interface{}{
"type": "heartbeat",
"payload": json.RawMessage(payload),
}
c.wsMu.Lock()
err := conn.WriteJSON(msg)
c.wsMu.Unlock()
return err
}
// collectLocalMetrics collects traffic metrics from local Xray servers
func (c *Client) collectLocalMetrics() (*collector.XrayStats, error) {
stats := &collector.XrayStats{
Inbound: make(map[string]collector.TrafficData),
Outbound: make(map[string]collector.TrafficData),
User: make(map[string]collector.TrafficData),
}
for _, server := range c.xrayServers {
host, port, err := c.collector.GetMetricsPortFromConfig(server.ConfigPath)
if err != nil {
log.Printf("[Agent] Failed to get metrics config for %s: %v", server.Name, err)
continue
}
metrics, err := c.collector.FetchMetrics(host, port)
if err != nil {
log.Printf("[Agent] Failed to fetch metrics for %s: %v", server.Name, err)
continue
}
if metrics.Stats != nil {
collector.MergeStats(stats, metrics.Stats)
}
}
return stats, nil
}
// GetStats returns the current traffic stats (for pull mode)
func (c *Client) GetStats() (*collector.XrayStats, error) {
return c.collectLocalMetrics()
}
// GetSpeed returns the current speed data (for pull mode)
func (c *Client) GetSpeed() (uploadSpeed, downloadSpeed int64) {
return c.collectSpeed()
}
// runAutoMode implements the three-tier fallback: WebSocket -> HTTP -> Pull
func (c *Client) runAutoMode(ctx context.Context) {
defer c.wg.Done()
c.runAutoModeLoop(ctx)
}
// runAutoModeLoop is the internal loop for auto mode fallback
func (c *Client) runAutoModeLoop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-c.stopCh:
return
default:
}
log.Printf("[Agent] Trying WebSocket connection...")
if err := c.tryWebSocketOnce(ctx); err == nil {
c.setCurrentMode(ModeWebSocket)
log.Printf("[Agent] WebSocket mode active")
if err := c.connectAndRun(ctx); err != nil {
if ctx.Err() != nil {
log.Printf("[Agent] Context canceled, stopping gracefully")
return
}
log.Printf("[Agent] WebSocket disconnected: %v", err)
}
c.reconnects = 0
continue
} else {
log.Printf("[Agent] WebSocket failed: %v, trying HTTP...", err)
}
if c.tryHTTPOnce(ctx) {
c.setCurrentMode(ModeHTTP)
log.Printf("[Agent] HTTP mode active")
c.runHTTPReporterLoop(ctx)
if ctx.Err() != nil {
return
}
continue
}
c.setCurrentMode(ModePull)
log.Printf("[Agent] Falling back to pull mode - API available at /api/child/traffic and /api/child/speed")
c.runPullModeWithTrafficReport(ctx, 30*time.Second)
if ctx.Err() != nil {
return
}
log.Printf("[Agent] Retrying higher-priority connection modes...")
}
}
// tryWebSocketOnce attempts a single WebSocket connection test
func (c *Client) tryWebSocketOnce(ctx context.Context) error {
masterURL := c.config.MasterURL
u, err := url.Parse(masterURL)
if err != nil {
return err
}
switch u.Scheme {
case "http":
u.Scheme = "ws"
case "https":
u.Scheme = "wss"
}
u.Path = "/api/remote/ws"
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
}
conn, _, err := dialer.DialContext(ctx, u.String(), nil)
if err != nil {
return err
}
conn.Close()
return nil
}
// tryHTTPOnce tests if HTTP push is available
func (c *Client) tryHTTPOnce(ctx context.Context) bool {
u, err := url.Parse(c.config.MasterURL)
if err != nil {
return false
}
u.Path = "/api/remote/heartbeat"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader([]byte("{}")))
if err != nil {
return false
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.config.Token)
resp, err := c.httpClient.Do(req)
if err != nil {
log.Printf("[Agent] HTTP test failed: %v", err)
return false
}
defer resp.Body.Close()
c.httpAvailable = resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized
return c.httpAvailable
}
// runHTTPReporter runs the HTTP push reporter
func (c *Client) runHTTPReporter(ctx context.Context) {
defer c.wg.Done()
c.setCurrentMode(ModeHTTP)
c.runHTTPReporterLoop(ctx)
}
// runHTTPReporterLoop runs the HTTP reporting loop
func (c *Client) runHTTPReporterLoop(ctx context.Context) {
trafficTicker := time.NewTicker(c.config.TrafficReportInterval)
speedTicker := time.NewTicker(c.config.SpeedReportInterval)
heartbeatTicker := time.NewTicker(30 * time.Second)
defer trafficTicker.Stop()
defer speedTicker.Stop()
defer heartbeatTicker.Stop()
c.sendTrafficHTTP(ctx)
c.sendSpeedHTTP(ctx)
consecutiveErrors := 0
maxErrors := 5
for {
select {
case <-ctx.Done():
return
case <-c.stopCh:
return
case <-trafficTicker.C:
if err := c.sendTrafficHTTP(ctx); err != nil {
consecutiveErrors++
if consecutiveErrors >= maxErrors {
log.Printf("[Agent] Too many HTTP errors, will retry connection modes")
return
}
} else {
consecutiveErrors = 0
}
case <-speedTicker.C:
if err := c.sendSpeedHTTP(ctx); err != nil {
log.Printf("[Agent] Failed to send speed via HTTP: %v", err)
}
case <-heartbeatTicker.C:
if err := c.sendHeartbeatHTTP(ctx); err != nil {
consecutiveErrors++
if consecutiveErrors >= maxErrors {
log.Printf("[Agent] Too many HTTP errors, will retry connection modes")
return
}
} else {
consecutiveErrors = 0
}
}
}
}
// sendTrafficHTTP sends traffic data via HTTP POST
func (c *Client) sendTrafficHTTP(ctx context.Context) error {
stats, err := c.collectLocalMetrics()
if err != nil {
stats = &collector.XrayStats{}
}
payload, _ := json.Marshal(map[string]interface{}{
"stats": stats,
})
u, err := url.Parse(c.config.MasterURL)
if err != nil {
return err
}
u.Path = "/api/remote/traffic"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.config.Token)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
log.Printf("[Agent] Sent traffic data via HTTP: %d inbounds, %d outbounds, %d users",
len(stats.Inbound), len(stats.Outbound), len(stats.User))
return nil
}
// sendSpeedHTTP sends speed data via HTTP POST
func (c *Client) sendSpeedHTTP(ctx context.Context) error {
uploadSpeed, downloadSpeed := c.collectSpeed()
payload, _ := json.Marshal(map[string]interface{}{
"upload_speed": uploadSpeed,
"download_speed": downloadSpeed,
})
u, err := url.Parse(c.config.MasterURL)
if err != nil {
return err
}
u.Path = "/api/remote/speed"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.config.Token)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
log.Printf("[Agent] Sent speed via HTTP: ↑%d B/s ↓%d B/s", uploadSpeed, downloadSpeed)
return nil
}
// sendHeartbeatHTTP sends heartbeat via HTTP POST
func (c *Client) sendHeartbeatHTTP(ctx context.Context) error {
now := time.Now()
listenPort, _ := strconv.Atoi(c.config.ListenPort)
payload, _ := json.Marshal(map[string]interface{}{
"boot_time": now,
"listen_port": listenPort,
})
u, err := url.Parse(c.config.MasterURL)
if err != nil {
return err
}
u.Path = "/api/remote/heartbeat"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.config.Token)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}
// runPullModeWithTrafficReport runs pull mode while sending traffic data to keep server online
func (c *Client) runPullModeWithTrafficReport(ctx context.Context, duration time.Duration) {
trafficTicker := time.NewTicker(c.config.TrafficReportInterval)
defer trafficTicker.Stop()
timeout := time.After(duration)
if err := c.sendTrafficHTTP(ctx); err != nil {
log.Printf("[Agent] Pull mode traffic report failed: %v", err)
}
for {
select {
case <-ctx.Done():
return
case <-c.stopCh:
return
case <-timeout:
return
case <-trafficTicker.C:
if err := c.sendTrafficHTTP(ctx); err != nil {
log.Printf("[Agent] Pull mode traffic report failed: %v", err)
}
}
}
}
// waitWithTrafficReport waits for the specified duration while sending traffic data
func (c *Client) waitWithTrafficReport(ctx context.Context, duration time.Duration) {
if duration <= 0 {
return
}
if duration > 30*time.Second {
if err := c.sendTrafficHTTP(ctx); err != nil {
log.Printf("[Agent] Traffic report during backoff failed: %v", err)
}
}
trafficTicker := time.NewTicker(c.config.TrafficReportInterval)
defer trafficTicker.Stop()
timeout := time.After(duration)
for {
select {
case <-ctx.Done():
return
case <-c.stopCh:
return
case <-timeout:
return
case <-trafficTicker.C:
if err := c.sendTrafficHTTP(ctx); err != nil {
log.Printf("[Agent] Traffic report during backoff failed: %v", err)
}
}
}
}
// sendSpeedData sends speed data via WebSocket
func (c *Client) sendSpeedData(conn *websocket.Conn) error {
uploadSpeed, downloadSpeed := c.collectSpeed()
payload, _ := json.Marshal(map[string]interface{}{
"upload_speed": uploadSpeed,
"download_speed": downloadSpeed,
})
msg := map[string]interface{}{
"type": "speed",
"payload": json.RawMessage(payload),
}
c.wsMu.Lock()
err := conn.WriteJSON(msg)
c.wsMu.Unlock()
if err != nil {
return err
}
log.Printf("[Agent] Sent speed data: ↑%d B/s ↓%d B/s", uploadSpeed, downloadSpeed)
return nil
}
// collectSpeed calculates the current upload and download speed from system network interface
func (c *Client) collectSpeed() (uploadSpeed, downloadSpeed int64) {
c.speedMu.Lock()
defer c.speedMu.Unlock()
rxBytes, txBytes := c.getSystemNetworkStats()
now := time.Now()
if !c.lastSampleTime.IsZero() && c.lastRxBytes > 0 {
elapsed := now.Sub(c.lastSampleTime).Seconds()
if elapsed > 0 {
uploadSpeed = int64(float64(txBytes-c.lastTxBytes) / elapsed)
downloadSpeed = int64(float64(rxBytes-c.lastRxBytes) / elapsed)
if uploadSpeed < 0 {
uploadSpeed = 0
}
if downloadSpeed < 0 {
downloadSpeed = 0
}
}
}
c.lastRxBytes = rxBytes
c.lastTxBytes = txBytes
c.lastSampleTime = now
return uploadSpeed, downloadSpeed
}
// getSystemNetworkStats reads network statistics from /proc/net/dev
func (c *Client) getSystemNetworkStats() (rxBytes, txBytes int64) {
data, err := os.ReadFile("/proc/net/dev")
if err != nil {
log.Printf("[Agent] Failed to read /proc/net/dev: %v", err)
return 0, 0
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Inter") || strings.HasPrefix(line, "face") || strings.HasPrefix(line, "lo:") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
fields := strings.Fields(parts[1])
if len(fields) < 10 {
continue
}
rx, err1 := strconv.ParseInt(fields[0], 10, 64)
tx, err2 := strconv.ParseInt(fields[8], 10, 64)
if err1 == nil && err2 == nil {
rxBytes += rx
txBytes += tx
}
}
return rxBytes, txBytes
}
// AuthError represents an authentication error
type AuthError struct {
Message string
}
func (e *AuthError) Error() string {
return "authentication failed: " + e.Message
}
// WebSocket message types
const (
WSMsgTypeCertRequest = "cert_request"
WSMsgTypeCertUpdate = "cert_update"
)
// WSCertRequestPayload represents a certificate request from master
type WSCertRequestPayload struct {
CertID int64 `json:"cert_id"`
Domain string `json:"domain"`
Email string `json:"email"`
Provider string `json:"provider"`
ChallengeMode string `json:"challenge_mode"`
WebrootPath string `json:"webroot_path,omitempty"`
}
// WSCertUpdatePayload represents a certificate update response to master
type WSCertUpdatePayload struct {
CertID int64 `json:"cert_id"`
Domain string `json:"domain"`
Success bool `json:"success"`
CertPath string `json:"cert_path,omitempty"`
KeyPath string `json:"key_path,omitempty"`
CertPEM string `json:"cert_pem,omitempty"`
KeyPEM string `json:"key_pem,omitempty"`
IssueDate time.Time `json:"issue_date,omitempty"`
ExpiryDate time.Time `json:"expiry_date,omitempty"`
Error string `json:"error,omitempty"`
}
// handleMessage processes incoming messages from master
func (c *Client) handleMessage(conn *websocket.Conn, message []byte) {
var msg struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
if err := json.Unmarshal(message, &msg); err != nil {
log.Printf("[Agent] Failed to parse message: %v", err)
return
}
switch msg.Type {
case WSMsgTypeCertRequest:
var payload WSCertRequestPayload
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
log.Printf("[Agent] Failed to parse cert_request payload: %v", err)
return
}
go c.handleCertRequest(conn, payload)
default:
// Ignore unknown message types
}
}
// handleCertRequest processes a certificate request from master
func (c *Client) handleCertRequest(conn *websocket.Conn, req WSCertRequestPayload) {
log.Printf("[Agent] Received certificate request for domain: %s", req.Domain)
result := c.requestCertificate(req)
// Send result back to master
payload, _ := json.Marshal(result)
msg := map[string]interface{}{
"type": WSMsgTypeCertUpdate,
"payload": json.RawMessage(payload),
}
c.wsMu.Lock()
err := conn.WriteJSON(msg)
c.wsMu.Unlock()
if err != nil {
log.Printf("[Agent] Failed to send cert_update: %v", err)
} else {
log.Printf("[Agent] Sent cert_update for domain %s, success=%v", req.Domain, result.Success)
}
}
// requestCertificate performs the actual certificate request using ACME
func (c *Client) requestCertificate(req WSCertRequestPayload) WSCertUpdatePayload {
result := WSCertUpdatePayload{
CertID: req.CertID,
Domain: req.Domain,
}
// Create ACME client with appropriate options
opts := []acme.ClientOption{}
if req.ChallengeMode == "webroot" && req.WebrootPath != "" {
opts = append(opts, acme.WithWebrootDir(req.WebrootPath))
}
acmeClient := acme.NewClient(opts...)
// Determine if we should use webroot mode
useWebroot := req.ChallengeMode == "webroot" && req.WebrootPath != ""
// Request the certificate
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
certResult, err := acmeClient.ObtainCertificate(ctx, req.Email, req.Domain, useWebroot)
if err != nil {
result.Success = false
result.Error = err.Error()
log.Printf("[Agent] Certificate request failed for %s: %v", req.Domain, err)
return result
}
result.Success = true
result.CertPath = certResult.CertPath
result.KeyPath = certResult.KeyPath
result.CertPEM = certResult.CertPEM
result.KeyPEM = certResult.KeyPEM
result.IssueDate = certResult.IssueDate
result.ExpiryDate = certResult.ExpiryDate
log.Printf("[Agent] Certificate obtained for %s, expires: %s", req.Domain, certResult.ExpiryDate)
return result
}

View File

@@ -0,0 +1,176 @@
package collector
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// XrayMetrics represents the metrics response from Xray's /debug/vars endpoint
type XrayMetrics struct {
Stats *XrayStats `json:"stats,omitempty"`
}
// XrayStats contains inbound, outbound, and user traffic stats
type XrayStats struct {
Inbound map[string]TrafficData `json:"inbound,omitempty"`
Outbound map[string]TrafficData `json:"outbound,omitempty"`
User map[string]TrafficData `json:"user,omitempty"`
}
// TrafficData contains uplink and downlink traffic in bytes
type TrafficData struct {
Uplink int64 `json:"uplink"`
Downlink int64 `json:"downlink"`
}
// XrayConfig represents the structure of xray config.json for reading metrics port
type XrayConfig struct {
Log json.RawMessage `json:"log,omitempty"`
DNS json.RawMessage `json:"dns,omitempty"`
API json.RawMessage `json:"api,omitempty"`
Stats json.RawMessage `json:"stats,omitempty"`
Policy json.RawMessage `json:"policy,omitempty"`
Routing json.RawMessage `json:"routing,omitempty"`
Inbounds json.RawMessage `json:"inbounds,omitempty"`
Outbounds json.RawMessage `json:"outbounds,omitempty"`
Metrics *MetricsConfig `json:"metrics,omitempty"`
}
// MetricsConfig represents the metrics section in xray config
type MetricsConfig struct {
Tag string `json:"tag,omitempty"`
Listen string `json:"listen,omitempty"` // Format: "127.0.0.1:38889"
}
// Collector collects traffic metrics from Xray servers
type Collector struct {
httpClient *http.Client
defaultMetricsPort int
defaultMetricsHost string
}
// NewCollector creates a new metrics collector
func NewCollector() *Collector {
return &Collector{
httpClient: &http.Client{Timeout: 10 * time.Second},
defaultMetricsPort: 38889,
defaultMetricsHost: "127.0.0.1",
}
}
// GetMetricsPortFromConfig reads the metrics port from xray config file
func (c *Collector) GetMetricsPortFromConfig(configPath string) (string, int, error) {
if configPath == "" {
return "127.0.0.1", c.defaultMetricsPort, nil
}
data, err := os.ReadFile(configPath)
if err != nil {
return "127.0.0.1", c.defaultMetricsPort, fmt.Errorf("read config file: %w", err)
}
var config XrayConfig
if err := json.Unmarshal(data, &config); err != nil {
return "127.0.0.1", c.defaultMetricsPort, fmt.Errorf("parse config file: %w", err)
}
if config.Metrics == nil || config.Metrics.Listen == "" {
return "", 0, fmt.Errorf("metrics not configured in xray config")
}
// Parse listen address (format: "127.0.0.1:38889" or ":38889")
listen := config.Metrics.Listen
host := "127.0.0.1"
var port int
if strings.Contains(listen, ":") {
parts := strings.Split(listen, ":")
if len(parts) == 2 {
if parts[0] != "" {
host = parts[0]
}
p, err := strconv.Atoi(parts[1])
if err != nil {
return "", 0, fmt.Errorf("invalid metrics port: %s", parts[1])
}
port = p
}
} else {
// Try to parse as port only
p, err := strconv.Atoi(listen)
if err != nil {
return "", 0, fmt.Errorf("invalid metrics listen format: %s", listen)
}
port = p
}
if port <= 0 || port > 65535 {
return "", 0, fmt.Errorf("invalid metrics port: %d", port)
}
return host, port, nil
}
// FetchMetrics fetches metrics from Xray's /debug/vars endpoint
func (c *Collector) FetchMetrics(host string, port int) (*XrayMetrics, error) {
url := fmt.Sprintf("http://%s:%d/debug/vars", host, port)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
var metrics XrayMetrics
if err := json.Unmarshal(body, &metrics); err != nil {
return nil, fmt.Errorf("unmarshal metrics: %w", err)
}
return &metrics, nil
}
// MergeStats merges source stats into dest stats
func MergeStats(dest, source *XrayStats) {
if source == nil {
return
}
if dest.Inbound == nil {
dest.Inbound = make(map[string]TrafficData)
}
if dest.Outbound == nil {
dest.Outbound = make(map[string]TrafficData)
}
if dest.User == nil {
dest.User = make(map[string]TrafficData)
}
for k, v := range source.Inbound {
dest.Inbound[k] = v
}
for k, v := range source.Outbound {
dest.Outbound[k] = v
}
for k, v := range source.User {
dest.User[k] = v
}
}

152
internal/config/config.go Normal file
View File

@@ -0,0 +1,152 @@
package config
import (
"os"
"strconv"
"time"
"gopkg.in/yaml.v3"
)
// Config holds the agent configuration
type Config struct {
MasterURL string `yaml:"master_url"`
Token string `yaml:"token"`
ConnectionMode string `yaml:"connection_mode"`
ListenPort string `yaml:"listen_port"`
XrayServers []XrayServer `yaml:"xray_servers"`
TrafficReportInterval time.Duration `yaml:"traffic_report_interval"`
SpeedReportInterval time.Duration `yaml:"speed_report_interval"`
}
// XrayServer represents a local Xray server configuration
type XrayServer struct {
Name string `yaml:"name"`
ConfigPath string `yaml:"config_path"`
}
// DefaultXrayConfigPaths are the default paths to search for Xray config
var DefaultXrayConfigPaths = []string{
"/usr/local/etc/xray/config.json",
"/etc/xray/config.json",
"/opt/xray/config.json",
}
// Load loads configuration from a YAML file
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, err
}
// Apply defaults
config.applyDefaults()
return &config, nil
}
// FromEnv creates configuration from environment variables
func FromEnv() *Config {
config := &Config{
MasterURL: os.Getenv("MMWX_MASTER_URL"),
Token: os.Getenv("MMWX_TOKEN"),
ConnectionMode: os.Getenv("MMWX_CONNECTION_MODE"),
ListenPort: os.Getenv("MMWX_LISTEN_PORT"),
}
// Parse Xray config path from env
if xrayConfig := os.Getenv("MMWX_XRAY_CONFIG"); xrayConfig != "" {
config.XrayServers = []XrayServer{
{Name: "primary", ConfigPath: xrayConfig},
}
}
// Parse intervals
if interval := os.Getenv("MMWX_TRAFFIC_INTERVAL"); interval != "" {
if d, err := time.ParseDuration(interval); err == nil {
config.TrafficReportInterval = d
}
}
if interval := os.Getenv("MMWX_SPEED_INTERVAL"); interval != "" {
if d, err := time.ParseDuration(interval); err == nil {
config.SpeedReportInterval = d
}
}
config.applyDefaults()
return config
}
// Merge merges environment config into file config (env takes precedence)
func (c *Config) Merge(env *Config) {
if env.MasterURL != "" {
c.MasterURL = env.MasterURL
}
if env.Token != "" {
c.Token = env.Token
}
if env.ConnectionMode != "" {
c.ConnectionMode = env.ConnectionMode
}
if env.ListenPort != "" {
c.ListenPort = env.ListenPort
}
if len(env.XrayServers) > 0 {
c.XrayServers = env.XrayServers
}
if env.TrafficReportInterval > 0 {
c.TrafficReportInterval = env.TrafficReportInterval
}
if env.SpeedReportInterval > 0 {
c.SpeedReportInterval = env.SpeedReportInterval
}
}
// applyDefaults sets default values for unset fields
func (c *Config) applyDefaults() {
if c.ConnectionMode == "" {
c.ConnectionMode = "auto"
}
if c.ListenPort == "" {
c.ListenPort = "8081"
}
if c.TrafficReportInterval == 0 {
c.TrafficReportInterval = 1 * time.Minute
}
if c.SpeedReportInterval == 0 {
c.SpeedReportInterval = 3 * time.Second
}
// Auto-discover Xray servers if not configured
if len(c.XrayServers) == 0 {
c.XrayServers = c.discoverXrayServers()
}
}
// discoverXrayServers scans default paths for Xray config files
func (c *Config) discoverXrayServers() []XrayServer {
var servers []XrayServer
for i, path := range DefaultXrayConfigPaths {
if _, err := os.Stat(path); err == nil {
servers = append(servers, XrayServer{
Name: "xray-" + strconv.Itoa(i+1),
ConfigPath: path,
})
}
}
return servers
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
// Token is required for non-pull modes
if c.ConnectionMode != "pull" && c.Token == "" {
// Allow empty token, will work in pull mode only
}
return nil
}

106
internal/handler/api.go Normal file
View File

@@ -0,0 +1,106 @@
package handler
import (
"encoding/json"
"log"
"net/http"
"strings"
"mmw-agent/internal/agent"
)
// APIHandler handles API requests from the master server (for pull mode)
type APIHandler struct {
client *agent.Client
configToken string
}
// NewAPIHandler creates a new API handler
func NewAPIHandler(client *agent.Client, configToken string) *APIHandler {
return &APIHandler{
client: client,
configToken: configToken,
}
}
// ServeHTTP handles the HTTP request for traffic data
func (h *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !h.authenticate(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Unauthorized",
})
return
}
stats, err := h.client.GetStats()
if err != nil {
log.Printf("[API] Failed to get stats: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Failed to collect stats",
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"stats": stats,
})
}
// ServeSpeedHTTP handles the HTTP request for speed data
func (h *APIHandler) ServeSpeedHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !h.authenticate(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": "Unauthorized",
})
return
}
uploadSpeed, downloadSpeed := h.client.GetSpeed()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"upload_speed": uploadSpeed,
"download_speed": downloadSpeed,
})
}
// authenticate checks if the request is authorized
func (h *APIHandler) authenticate(r *http.Request) bool {
if h.configToken == "" {
return true
}
auth := r.Header.Get("Authorization")
if auth == "" {
return false
}
if strings.HasPrefix(auth, "Bearer ") {
token := strings.TrimPrefix(auth, "Bearer ")
return token == h.configToken
}
return auth == h.configToken
}

2368
internal/handler/manage.go Normal file

File diff suppressed because it is too large Load Diff

41
internal/xrpc/client.go Normal file
View File

@@ -0,0 +1,41 @@
package xrpc
import (
"context"
"fmt"
loggerpb "github.com/xtls/xray-core/app/log/command"
handlerpb "github.com/xtls/xray-core/app/proxyman/command"
statspb "github.com/xtls/xray-core/app/stats/command"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// Clients groups the gRPC stubs that the samples rely on.
type Clients struct {
Connection *grpc.ClientConn
Handler handlerpb.HandlerServiceClient
Logger loggerpb.LoggerServiceClient
Stats statspb.StatsServiceClient
}
// New establishes an insecure (plaintext) connection against a running Xray API endpoint.
func New(ctx context.Context, addr string, port uint16, dialOpts ...grpc.DialOption) (*Clients, error) {
target := fmt.Sprintf("%s:%d", addr, port)
opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
}
opts = append(opts, dialOpts...)
conn, err := grpc.DialContext(ctx, target, opts...)
if err != nil {
return nil, err
}
return &Clients{
Connection: conn,
Handler: handlerpb.NewHandlerServiceClient(conn),
Logger: loggerpb.NewLoggerServiceClient(conn),
Stats: statspb.NewStatsServiceClient(conn),
}, nil
}

View File

@@ -0,0 +1,76 @@
package handler
import (
"github.com/xtls/xray-core/app/proxyman"
cnet "github.com/xtls/xray-core/common/net"
"github.com/xtls/xray-core/common/protocol"
"github.com/xtls/xray-core/common/serial"
"github.com/xtls/xray-core/common/uuid"
"github.com/xtls/xray-core/core"
"github.com/xtls/xray-core/transport/internet"
)
func receiverSettings(port uint32, enableSniff bool) *serial.TypedMessage {
pr := cnet.SinglePortRange(cnet.Port(port))
rc := &proxyman.ReceiverConfig{
PortList: &cnet.PortList{Range: []*cnet.PortRange{pr}},
Listen: cnet.NewIPOrDomain(cnet.AnyIP),
StreamSettings: &internet.StreamConfig{
ProtocolName: "tcp",
},
}
if enableSniff {
rc.SniffingSettings = &proxyman.SniffingConfig{
Enabled: true,
DestinationOverride: []string{"http", "tls"},
}
}
return serial.ToTypedMessage(rc)
}
func senderSettings() *serial.TypedMessage {
return serial.ToTypedMessage(&proxyman.SenderConfig{
StreamSettings: &internet.StreamConfig{
ProtocolName: "tcp",
},
MultiplexSettings: &proxyman.MultiplexingConfig{
Enabled: true,
Concurrency: 8,
XudpProxyUDP443: "reject",
},
TargetStrategy: internet.DomainStrategy_USE_IP,
})
}
func endpoint(address string, port uint32, user *protocol.User) *protocol.ServerEndpoint {
return &protocol.ServerEndpoint{
Address: cnet.NewIPOrDomain(cnet.ParseAddress(address)),
Port: port,
User: user,
}
}
func randomUUID() string {
u := uuid.New()
return (&u).String()
}
func cnetOrDomain(value string) *cnet.IPOrDomain {
return cnet.NewIPOrDomain(cnet.ParseAddress(value))
}
func inboundConfig(tag string, receiver *serial.TypedMessage, proxy *serial.TypedMessage) *core.InboundHandlerConfig {
return &core.InboundHandlerConfig{
Tag: tag,
ReceiverSettings: receiver,
ProxySettings: proxy,
}
}
func outboundConfig(tag string, sender *serial.TypedMessage, proxy *serial.TypedMessage) *core.OutboundHandlerConfig {
return &core.OutboundHandlerConfig{
Tag: tag,
SenderSettings: sender,
ProxySettings: proxy,
}
}

View File

@@ -0,0 +1,274 @@
package handler
import (
"context"
"github.com/xtls/xray-core/app/proxyman/command"
cnet "github.com/xtls/xray-core/common/net"
"github.com/xtls/xray-core/common/protocol"
"github.com/xtls/xray-core/common/serial"
"github.com/xtls/xray-core/proxy/dns"
"github.com/xtls/xray-core/proxy/dokodemo"
"github.com/xtls/xray-core/proxy/http"
"github.com/xtls/xray-core/proxy/loopback"
"github.com/xtls/xray-core/proxy/shadowsocks"
ss2022 "github.com/xtls/xray-core/proxy/shadowsocks_2022"
"github.com/xtls/xray-core/proxy/socks"
"github.com/xtls/xray-core/proxy/trojan"
"github.com/xtls/xray-core/proxy/vless"
vlessin "github.com/xtls/xray-core/proxy/vless/inbound"
"github.com/xtls/xray-core/proxy/vmess"
vmessin "github.com/xtls/xray-core/proxy/vmess/inbound"
"github.com/xtls/xray-core/proxy/wireguard"
)
// AddVMessInbound demonstrates HandlerServiceClient.AddInbound for VMess inbound.
func AddVMessInbound(ctx context.Context, client command.HandlerServiceClient, tag string, port uint32) error {
inbound := inboundConfig(
tag,
receiverSettings(port, true),
serial.ToTypedMessage(&vmessin.Config{
User: []*protocol.User{
{
Level: 0,
Email: "demo@vmess.local",
Account: serial.ToTypedMessage(&vmess.Account{
Id: randomUUID(),
SecuritySettings: &protocol.SecurityConfig{
Type: protocol.SecurityType_AUTO,
},
}),
},
},
Default: &vmessin.DefaultConfig{Level: 0},
}),
)
_, err := client.AddInbound(ctx, &command.AddInboundRequest{Inbound: inbound})
return err
}
// AddVLESSInbound adds a VLESS inbound with Vision style fallbacks.
func AddVLESSInbound(ctx context.Context, client command.HandlerServiceClient, tag string, port uint32) error {
inbound := inboundConfig(
tag,
receiverSettings(port, true),
serial.ToTypedMessage(&vlessin.Config{
Clients: []*protocol.User{
{
Level: 1,
Email: "client@vless.local",
Account: serial.ToTypedMessage(&vless.Account{
Id: randomUUID(),
Encryption: "none",
}),
},
},
Fallbacks: []*vlessin.Fallback{
{
Name: "websocket",
Alpn: "h2",
Path: "/ws",
Type: "http",
Dest: "127.0.0.1:8080",
Xver: 1,
},
},
Decryption: "none",
Padding: "enable",
}),
)
_, err := client.AddInbound(ctx, &command.AddInboundRequest{Inbound: inbound})
return err
}
// AddTrojanInbound registers a Trojan inbound with two users and ALPN fallback.
func AddTrojanInbound(ctx context.Context, client command.HandlerServiceClient, tag string, port uint32) error {
inbound := inboundConfig(
tag,
receiverSettings(port, true),
serial.ToTypedMessage(&trojan.ServerConfig{
Users: []*protocol.User{
{
Level: 0,
Email: "alice@trojan.local",
Account: serial.ToTypedMessage(&trojan.Account{
Password: randomUUID(),
}),
},
{
Level: 0,
Email: "bob@trojan.local",
Account: serial.ToTypedMessage(&trojan.Account{
Password: randomUUID(),
}),
},
},
Fallbacks: []*trojan.Fallback{
{
Name: "http",
Alpn: "http/1.1",
Dest: "127.0.0.1:8081",
},
},
}),
)
_, err := client.AddInbound(ctx, &command.AddInboundRequest{Inbound: inbound})
return err
}
// AddShadowsocksInbound adds an AEAD Shadowsocks inbound supporting both TCP and UDP.
func AddShadowsocksInbound(ctx context.Context, client command.HandlerServiceClient, tag string, port uint32) error {
inbound := inboundConfig(
tag,
receiverSettings(port, false),
serial.ToTypedMessage(&shadowsocks.ServerConfig{
Users: []*protocol.User{
{
Level: 0,
Email: "ss@demo.local",
Account: serial.ToTypedMessage(&shadowsocks.Account{
Password: "s3cret-pass",
CipherType: shadowsocks.CipherType_AES_128_GCM,
}),
},
},
}),
)
_, err := client.AddInbound(ctx, &command.AddInboundRequest{Inbound: inbound})
return err
}
// AddShadowsocks2022Inbound covers both single user and multi-user deployment.
func AddShadowsocks2022Inbound(ctx context.Context, client command.HandlerServiceClient, tag string, port uint32) error {
server := &ss2022.MultiUserServerConfig{
Method: "2022-blake3-aes-128-gcm",
Key: "0123456789abcdef0123456789abcdef",
Users: []*protocol.User{
{
Level: 0,
Email: "user1@ss2022.local",
Account: serial.ToTypedMessage(&ss2022.Account{
Key: randomUUID(),
}),
},
{
Level: 0,
Email: "user2@ss2022.local",
Account: serial.ToTypedMessage(&ss2022.Account{
Key: randomUUID(),
}),
},
},
}
inbound := inboundConfig(
tag,
receiverSettings(port, false),
serial.ToTypedMessage(server),
)
_, err := client.AddInbound(ctx, &command.AddInboundRequest{Inbound: inbound})
return err
}
// AddSocksInbound exposes a SOCKS5 server with username/password authentication.
func AddSocksInbound(ctx context.Context, client command.HandlerServiceClient, tag string, port uint32) error {
inbound := inboundConfig(
tag,
receiverSettings(port, false),
serial.ToTypedMessage(&socks.ServerConfig{
AuthType: socks.AuthType_PASSWORD,
Accounts: map[string]string{"demo": "passw0rd"},
UdpEnabled: true,
UserLevel: 0,
}),
)
_, err := client.AddInbound(ctx, &command.AddInboundRequest{Inbound: inbound})
return err
}
// AddHTTPInbound adds an HTTP proxy inbound with basic auth.
func AddHTTPInbound(ctx context.Context, client command.HandlerServiceClient, tag string, port uint32) error {
inbound := inboundConfig(
tag,
receiverSettings(port, false),
serial.ToTypedMessage(&http.ServerConfig{
Accounts: map[string]string{"demo": "http-pass"},
AllowTransparent: true,
UserLevel: 0,
}),
)
_, err := client.AddInbound(ctx, &command.AddInboundRequest{Inbound: inbound})
return err
}
// AddDokodemoInbound configures a dokodemo-door mirror port.
func AddDokodemoInbound(ctx context.Context, client command.HandlerServiceClient, tag string, port uint32, targetPort uint32) error {
inbound := inboundConfig(
tag,
receiverSettings(port, false),
serial.ToTypedMessage(&dokodemo.Config{
Address: cnetOrDomain("example.com"),
Port: targetPort,
Networks: []cnet.Network{cnet.Network_TCP, cnet.Network_UDP},
FollowRedirect: false,
UserLevel: 0,
}),
)
_, err := client.AddInbound(ctx, &command.AddInboundRequest{Inbound: inbound})
return err
}
// AddDNSInbound exposes the built-in DNS server on an API port.
func AddDNSInbound(ctx context.Context, client command.HandlerServiceClient, tag string, port uint32) error {
inbound := inboundConfig(
tag,
receiverSettings(port, false),
serial.ToTypedMessage(&dns.Config{
Server: &cnet.Endpoint{
Network: cnet.Network_UDP,
Address: cnetOrDomain("1.1.1.1"),
Port: 53,
},
Non_IPQuery: "drop",
BlockTypes: []int32{65, 28},
}),
)
_, err := client.AddInbound(ctx, &command.AddInboundRequest{Inbound: inbound})
return err
}
// AddLoopbackInbound ties an inbound to an existing outbound chain.
func AddLoopbackInbound(ctx context.Context, client command.HandlerServiceClient, tag string, port uint32, targetInbound string) error {
inbound := inboundConfig(
tag,
receiverSettings(port, false),
serial.ToTypedMessage(&loopback.Config{InboundTag: targetInbound}),
)
_, err := client.AddInbound(ctx, &command.AddInboundRequest{Inbound: inbound})
return err
}
// AddWireGuardInbound sets up a WireGuard entry point with a single peer.
func AddWireGuardInbound(ctx context.Context, client command.HandlerServiceClient, tag string, port uint32) error {
cfg := &wireguard.DeviceConfig{
SecretKey: "yAnExampleSecretKeyBase64==",
Endpoint: []string{":51820"},
Mtu: 1420,
NumWorkers: 2,
DomainStrategy: wireguard.DeviceConfig_FORCE_IP46,
Peers: []*wireguard.PeerConfig{
{
PublicKey: "peerPublicKeyBase64==",
Endpoint: "203.0.113.1:51820",
KeepAlive: 25,
AllowedIps: []string{"0.0.0.0/0", "::/0"},
},
},
}
inbound := inboundConfig(
tag,
receiverSettings(port, false),
serial.ToTypedMessage(cfg),
)
_, err := client.AddInbound(ctx, &command.AddInboundRequest{Inbound: inbound})
return err
}

View File

@@ -0,0 +1,59 @@
package handler
import (
"context"
"github.com/xtls/xray-core/app/proxyman/command"
"github.com/xtls/xray-core/common/protocol"
"github.com/xtls/xray-core/common/serial"
)
func RemoveInbound(ctx context.Context, client command.HandlerServiceClient, tag string) error {
_, err := client.RemoveInbound(ctx, &command.RemoveInboundRequest{Tag: tag})
return err
}
func RemoveOutbound(ctx context.Context, client command.HandlerServiceClient, tag string) error {
_, err := client.RemoveOutbound(ctx, &command.RemoveOutboundRequest{Tag: tag})
return err
}
func ListInboundTags(ctx context.Context, client command.HandlerServiceClient) ([]string, error) {
resp, err := client.ListInbounds(ctx, &command.ListInboundsRequest{IsOnlyTags: true})
if err != nil {
return nil, err
}
tags := make([]string, 0, len(resp.GetInbounds()))
for _, inbound := range resp.GetInbounds() {
tags = append(tags, inbound.GetTag())
}
return tags, nil
}
func GetInboundUsers(ctx context.Context, client command.HandlerServiceClient, inboundTag string) ([]*protocol.User, error) {
resp, err := client.GetInboundUsers(ctx, &command.GetInboundUserRequest{
Tag: inboundTag,
})
if err != nil {
return nil, err
}
return resp.GetUsers(), nil
}
func GetInboundUsersCount(ctx context.Context, client command.HandlerServiceClient, inboundTag string) (int64, error) {
resp, err := client.GetInboundUsersCount(ctx, &command.GetInboundUserRequest{
Tag: inboundTag,
})
if err != nil {
return 0, err
}
return resp.GetCount(), nil
}
func AlterOutbound(ctx context.Context, client command.HandlerServiceClient, tag string, operation *serial.TypedMessage) error {
_, err := client.AlterOutbound(ctx, &command.AlterOutboundRequest{
Tag: tag,
Operation: operation,
})
return err
}

View File

@@ -0,0 +1,221 @@
package handler
import (
"context"
"github.com/xtls/xray-core/app/proxyman/command"
cnet "github.com/xtls/xray-core/common/net"
"github.com/xtls/xray-core/common/protocol"
"github.com/xtls/xray-core/common/serial"
"github.com/xtls/xray-core/proxy/blackhole"
"github.com/xtls/xray-core/proxy/dns"
"github.com/xtls/xray-core/proxy/freedom"
"github.com/xtls/xray-core/proxy/http"
"github.com/xtls/xray-core/proxy/shadowsocks"
ss2022 "github.com/xtls/xray-core/proxy/shadowsocks_2022"
"github.com/xtls/xray-core/proxy/socks"
"github.com/xtls/xray-core/proxy/trojan"
"github.com/xtls/xray-core/proxy/vless"
vlessout "github.com/xtls/xray-core/proxy/vless/outbound"
"github.com/xtls/xray-core/proxy/vmess"
vmessout "github.com/xtls/xray-core/proxy/vmess/outbound"
"github.com/xtls/xray-core/proxy/wireguard"
"github.com/xtls/xray-core/transport/internet"
)
func AddFreedomOutbound(ctx context.Context, client command.HandlerServiceClient, tag string) error {
cfg := outboundConfig(
tag,
senderSettings(),
serial.ToTypedMessage(&freedom.Config{
DomainStrategy: internet.DomainStrategy_AS_IS,
UserLevel: 0,
Fragment: &freedom.Fragment{
PacketsFrom: 5,
PacketsTo: 10,
LengthMin: 50,
LengthMax: 150,
IntervalMin: 10,
IntervalMax: 20,
},
}),
)
_, err := client.AddOutbound(ctx, &command.AddOutboundRequest{Outbound: cfg})
return err
}
func AddBlackholeOutbound(ctx context.Context, client command.HandlerServiceClient, tag string) error {
cfg := outboundConfig(
tag,
senderSettings(),
serial.ToTypedMessage(&blackhole.Config{
Response: serial.ToTypedMessage(&blackhole.HTTPResponse{}),
}),
)
_, err := client.AddOutbound(ctx, &command.AddOutboundRequest{Outbound: cfg})
return err
}
func AddDNSOutbound(ctx context.Context, client command.HandlerServiceClient, tag string, upstream string) error {
endpointCfg := &cnet.Endpoint{
Network: cnet.Network_UDP,
Address: cnet.NewIPOrDomain(cnet.ParseAddress(upstream)),
Port: 53,
}
cfg := outboundConfig(
tag,
senderSettings(),
serial.ToTypedMessage(&dns.Config{
Server: endpointCfg,
UserLevel: 0,
BlockTypes: []int32{1, 28},
}),
)
_, err := client.AddOutbound(ctx, &command.AddOutboundRequest{Outbound: cfg})
return err
}
func AddHTTPOutbound(ctx context.Context, client command.HandlerServiceClient, tag string) error {
cfg := outboundConfig(
tag,
senderSettings(),
serial.ToTypedMessage(&http.ClientConfig{
Server: endpoint("example.com", 80, nil),
Header: []*http.Header{
{Key: "User-Agent", Value: "miaomiaowu"},
},
}),
)
_, err := client.AddOutbound(ctx, &command.AddOutboundRequest{Outbound: cfg})
return err
}
func AddSocksOutbound(ctx context.Context, client command.HandlerServiceClient, tag string) error {
cfg := outboundConfig(
tag,
senderSettings(),
serial.ToTypedMessage(&socks.ClientConfig{
Server: endpoint("127.0.0.1", 1080, nil),
}),
)
_, err := client.AddOutbound(ctx, &command.AddOutboundRequest{Outbound: cfg})
return err
}
func AddTrojanOutbound(ctx context.Context, client command.HandlerServiceClient, tag string, password string) error {
user := &protocol.User{
Email: "trojan@client.local",
Level: 0,
Account: serial.ToTypedMessage(&trojan.Account{
Password: password,
}),
}
cfg := outboundConfig(
tag,
senderSettings(),
serial.ToTypedMessage(&trojan.ClientConfig{
Server: endpoint("trojan.example.com", 443, user),
}),
)
_, err := client.AddOutbound(ctx, &command.AddOutboundRequest{Outbound: cfg})
return err
}
func AddShadowsocksOutbound(ctx context.Context, client command.HandlerServiceClient, tag string) error {
user := &protocol.User{
Email: "ss@client.local",
Account: serial.ToTypedMessage(&shadowsocks.Account{
Password: "client-pass",
CipherType: shadowsocks.CipherType_AES_256_GCM,
}),
}
cfg := outboundConfig(
tag,
senderSettings(),
serial.ToTypedMessage(&shadowsocks.ClientConfig{
Server: endpoint("ss.example.com", 8388, user),
}),
)
_, err := client.AddOutbound(ctx, &command.AddOutboundRequest{Outbound: cfg})
return err
}
func AddShadowsocks2022Outbound(ctx context.Context, client command.HandlerServiceClient, tag string) error {
cfg := outboundConfig(
tag,
senderSettings(),
serial.ToTypedMessage(&ss2022.ClientConfig{
Address: cnetOrDomain("203.0.113.2"),
Port: 8389,
Method: "2022-blake3-aes-256-gcm",
Key: "clientkeybase64==",
UdpOverTcp: true,
UdpOverTcpVersion: 2,
}),
)
_, err := client.AddOutbound(ctx, &command.AddOutboundRequest{Outbound: cfg})
return err
}
func AddVLESSOutbound(ctx context.Context, client command.HandlerServiceClient, tag string) error {
user := &protocol.User{
Email: "vless@client.local",
Account: serial.ToTypedMessage(&vless.Account{
Id: randomUUID(),
Encryption: "none",
}),
}
cfg := outboundConfig(
tag,
senderSettings(),
serial.ToTypedMessage(&vlessout.Config{
Vnext: endpoint("vless.example.com", 443, user),
}),
)
_, err := client.AddOutbound(ctx, &command.AddOutboundRequest{Outbound: cfg})
return err
}
func AddVMessOutbound(ctx context.Context, client command.HandlerServiceClient, tag string) error {
user := &protocol.User{
Email: "vmess@client.local",
Account: serial.ToTypedMessage(&vmess.Account{
Id: randomUUID(),
SecuritySettings: &protocol.SecurityConfig{
Type: protocol.SecurityType_AUTO,
},
}),
}
cfg := outboundConfig(
tag,
senderSettings(),
serial.ToTypedMessage(&vmessout.Config{
Receiver: endpoint("vmess.example.com", 443, user),
}),
)
_, err := client.AddOutbound(ctx, &command.AddOutboundRequest{Outbound: cfg})
return err
}
func AddWireGuardOutbound(ctx context.Context, client command.HandlerServiceClient, tag string) error {
cfg := outboundConfig(
tag,
senderSettings(),
serial.ToTypedMessage(&wireguard.DeviceConfig{
SecretKey: "clientSecretKeyBase64==",
Endpoint: []string{"198.51.100.2:51820"},
IsClient: true,
Mtu: 1420,
DomainStrategy: wireguard.DeviceConfig_FORCE_IP4,
Peers: []*wireguard.PeerConfig{
{
PublicKey: "serverPublicKeyBase64==",
AllowedIps: []string{"0.0.0.0/0"},
KeepAlive: 30,
},
},
}),
)
_, err := client.AddOutbound(ctx, &command.AddOutboundRequest{Outbound: cfg})
return err
}

View File

@@ -0,0 +1,120 @@
package handler
import (
"context"
"github.com/xtls/xray-core/app/proxyman/command"
"github.com/xtls/xray-core/common/protocol"
"github.com/xtls/xray-core/common/serial"
"github.com/xtls/xray-core/proxy/shadowsocks"
ss2022 "github.com/xtls/xray-core/proxy/shadowsocks_2022"
"github.com/xtls/xray-core/proxy/trojan"
"github.com/xtls/xray-core/proxy/vless"
"github.com/xtls/xray-core/proxy/vmess"
)
// AddVMessUser demonstrates AlterInbound(AddUserOperation) for VMess.
func AddVMessUser(ctx context.Context, client command.HandlerServiceClient, inboundTag, email string) error {
req := &command.AlterInboundRequest{
Tag: inboundTag,
Operation: serial.ToTypedMessage(&command.AddUserOperation{
User: &protocol.User{
Level: 0,
Email: email,
Account: serial.ToTypedMessage(&vmess.Account{
Id: randomUUID(),
SecuritySettings: &protocol.SecurityConfig{
Type: protocol.SecurityType_AUTO,
},
}),
},
}),
}
_, err := client.AlterInbound(ctx, req)
return err
}
// AddVLESSUser shows how to add VLESS users dynamically.
func AddVLESSUser(ctx context.Context, client command.HandlerServiceClient, inboundTag, email string) error {
req := &command.AlterInboundRequest{
Tag: inboundTag,
Operation: serial.ToTypedMessage(&command.AddUserOperation{
User: &protocol.User{
Level: 0,
Email: email,
Account: serial.ToTypedMessage(&vless.Account{
Id: randomUUID(),
Encryption: "none",
}),
},
}),
}
_, err := client.AlterInbound(ctx, req)
return err
}
// AddTrojanUser adds a Trojan password to an inbound handler.
func AddTrojanUser(ctx context.Context, client command.HandlerServiceClient, inboundTag, email, password string) error {
req := &command.AlterInboundRequest{
Tag: inboundTag,
Operation: serial.ToTypedMessage(&command.AddUserOperation{
User: &protocol.User{
Level: 0,
Email: email,
Account: serial.ToTypedMessage(&trojan.Account{
Password: password,
}),
},
}),
}
_, err := client.AlterInbound(ctx, req)
return err
}
// AddShadowsocksUser sets up a Shadowsocks AEAD credential.
func AddShadowsocksUser(ctx context.Context, client command.HandlerServiceClient, inboundTag, email, password string) error {
req := &command.AlterInboundRequest{
Tag: inboundTag,
Operation: serial.ToTypedMessage(&command.AddUserOperation{
User: &protocol.User{
Level: 0,
Email: email,
Account: serial.ToTypedMessage(&shadowsocks.Account{
Password: password,
CipherType: shadowsocks.CipherType_CHACHA20_POLY1305,
}),
},
}),
}
_, err := client.AlterInbound(ctx, req)
return err
}
// AddShadowsocks2022User covers key rotation for SS2022.
func AddShadowsocks2022User(ctx context.Context, client command.HandlerServiceClient, inboundTag, email string) error {
req := &command.AlterInboundRequest{
Tag: inboundTag,
Operation: serial.ToTypedMessage(&command.AddUserOperation{
User: &protocol.User{
Email: email,
Account: serial.ToTypedMessage(&ss2022.Account{
Key: randomUUID(),
}),
},
}),
}
_, err := client.AlterInbound(ctx, req)
return err
}
// RemoveUser removes any user (identified by email) from an inbound.
func RemoveUser(ctx context.Context, client command.HandlerServiceClient, inboundTag, email string) error {
req := &command.AlterInboundRequest{
Tag: inboundTag,
Operation: serial.ToTypedMessage(&command.RemoveUserOperation{
Email: email,
}),
}
_, err := client.AlterInbound(ctx, req)
return err
}

View File

@@ -0,0 +1,16 @@
package logger
import (
"context"
"time"
loggerpb "github.com/xtls/xray-core/app/log/command"
)
// RestartLogger triggers the LoggerService restartLogger RPC and waits for completion.
func RestartLogger(ctx context.Context, client loggerpb.LoggerServiceClient) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_, err := client.RestartLogger(ctx, &loggerpb.RestartLoggerRequest{})
return err
}

View File

@@ -0,0 +1,30 @@
package stats
import (
"context"
"time"
statspb "github.com/xtls/xray-core/app/stats/command"
)
func QueryTraffic(ctx context.Context, client statspb.StatsServiceClient, pattern string, reset bool) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
resp, err := client.QueryStats(ctx, &statspb.QueryStatsRequest{
Pattern: pattern,
Reset_: reset,
})
if err != nil {
return -1, err
}
if len(resp.GetStat()) == 0 {
return -1, nil
}
return resp.GetStat()[0].GetValue(), nil
}
func GetSystemStats(ctx context.Context, client statspb.StatsServiceClient) (*statspb.SysStatsResponse, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return client.GetSysStats(ctx, &statspb.SysStatsRequest{})
}