Apply inbounds on nodes via Xray with Reality settings and client sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-29 05:11:45 +03:00
co-authored by Cursor
parent c41bae9852
commit 20a20168b6
15 changed files with 1081 additions and 63 deletions
+15 -2
View File
@@ -13,7 +13,7 @@ import (
"time"
)
const defaultVersion = "0.1.0"
const defaultVersion = "0.2.0"
type Agent struct {
name string
@@ -54,6 +54,7 @@ func main() {
config: map[string]any{},
}
a.loadConfig(filepath.Join(dataDir, "config.json"))
a.applyXray(a.config)
mux := http.NewServeMux()
mux.HandleFunc("/health", a.auth(a.health))
@@ -186,7 +187,12 @@ func (a *Agent) configHandler(w http.ResponseWriter, r *http.Request) {
a.config = cfg
a.mu.Unlock()
_ = a.saveConfig(filepath.Join(a.dataDir, "config.json"))
log.Printf("config updated: installed=%v enabled=%v", stringList(cfg, "protocols_installed"), stringList(cfg, "protocols_enabled"))
a.applyXray(cfg)
log.Printf("config updated: installed=%v enabled=%v inbounds=%d",
stringList(cfg, "protocols_installed"),
stringList(cfg, "protocols_enabled"),
len(anySlice(cfg["inbounds"])),
)
writeJSON(w, map[string]any{"ok": true})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -218,3 +224,10 @@ func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func anySlice(v any) []any {
if arr, ok := v.([]any); ok {
return arr
}
return nil
}
+357
View File
@@ -0,0 +1,357 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
)
const xrayContainer = "vpn-panel-xray"
const xrayImage = "teddysun/xray:latest"
func (a *Agent) applyXray(cfg map[string]any) {
xrayDir := filepath.Join(a.dataDir, "xray")
_ = os.MkdirAll(xrayDir, 0o755)
configPath := filepath.Join(xrayDir, "config.json")
inboundsRaw, _ := cfg["inbounds"].([]any)
xrayCfg, n := buildXrayConfig(inboundsRaw)
b, err := json.MarshalIndent(xrayCfg, "", " ")
if err != nil {
log.Printf("xray marshal: %v", err)
return
}
if err := os.WriteFile(configPath, b, 0o600); err != nil {
log.Printf("xray write config: %v", err)
return
}
log.Printf("xray config written: %d inbound(s)", n)
if n == 0 {
_ = dockerRun("rm", "-f", xrayContainer)
log.Printf("xray stopped (no inbounds)")
return
}
hostData := os.Getenv("HOST_DATA_DIR")
if hostData == "" {
hostData = a.dataDir
}
hostXray := filepath.Join(hostData, "xray")
if err := ensureXrayContainer(hostXray); err != nil {
log.Printf("xray container: %v", err)
return
}
log.Printf("xray container ready")
}
func buildXrayConfig(inboundsRaw []any) (map[string]any, int) {
var inbounds []map[string]any
for _, raw := range inboundsRaw {
m, ok := raw.(map[string]any)
if !ok {
continue
}
proto := strings.ToLower(strVal(m, "protocol"))
switch proto {
case "vless", "vmess", "trojan", "shadowsocks":
default:
continue
}
in, ok := buildXrayInbound(m)
if !ok {
continue
}
inbounds = append(inbounds, in)
}
if inbounds == nil {
inbounds = []map[string]any{}
}
return map[string]any{
"log": map[string]any{
"loglevel": "warning",
"access": "/var/log/xray/access.log",
"error": "/var/log/xray/error.log",
},
"inbounds": inbounds,
"outbounds": []map[string]any{
{"protocol": "freedom", "tag": "direct"},
{"protocol": "blackhole", "tag": "block"},
},
}, len(inbounds)
}
func buildXrayInbound(m map[string]any) (map[string]any, bool) {
proto := strings.ToLower(strVal(m, "protocol"))
tag := strVal(m, "tag")
if tag == "" {
tag = proto
}
listen := strVal(m, "listen")
if listen == "" {
listen = "0.0.0.0"
}
port := intVal(m, "port")
if port <= 0 {
return nil, false
}
network := strVal(m, "network")
if network == "" {
network = "tcp"
}
security := strVal(m, "security")
if security == "" {
security = "none"
}
clients := parseClients(m["clients"])
settings := map[string]any{}
switch proto {
case "vless":
var list []map[string]any
for _, c := range clients {
item := map[string]any{"id": c.UUID, "email": c.EmailOrUser()}
if flow := strVal(m, "flow"); flow != "" {
item["flow"] = flow
}
list = append(list, item)
}
if list == nil {
list = []map[string]any{}
}
settings = map[string]any{"clients": list, "decryption": "none"}
case "vmess":
var list []map[string]any
for _, c := range clients {
list = append(list, map[string]any{"id": c.UUID, "email": c.EmailOrUser(), "alterId": 0})
}
if list == nil {
list = []map[string]any{}
}
settings = map[string]any{"clients": list}
case "trojan":
var list []map[string]any
inboundPass := strVal(m, "password")
for _, c := range clients {
pass := c.UUID
if inboundPass != "" {
pass = inboundPass
}
list = append(list, map[string]any{"password": pass, "email": c.EmailOrUser()})
}
if list == nil {
list = []map[string]any{}
}
settings = map[string]any{"clients": list}
case "shadowsocks":
method := strVal(m, "ss_method")
if method == "" {
method = "aes-128-gcm"
}
var list []map[string]any
for _, c := range clients {
list = append(list, map[string]any{"password": c.UUID, "email": c.EmailOrUser(), "method": method})
}
if list == nil {
list = []map[string]any{}
}
settings = map[string]any{
"method": method,
"password": strVal(m, "password"),
"clients": list,
}
}
stream := map[string]any{"network": network, "security": security}
switch network {
case "ws":
ws := map[string]any{}
if path := strVal(m, "path"); path != "" {
ws["path"] = path
}
if host := strVal(m, "host"); host != "" {
ws["headers"] = map[string]any{"Host": host}
}
stream["wsSettings"] = ws
case "grpc":
grpc := map[string]any{}
if path := strVal(m, "path"); path != "" {
grpc["serviceName"] = path
}
stream["grpcSettings"] = grpc
case "tcp":
// plain tcp
}
switch security {
case "tls":
tls := map[string]any{}
if sni := strVal(m, "sni"); sni != "" {
tls["serverName"] = sni
}
if alpn := strVal(m, "alpn"); alpn != "" {
tls["alpn"] = splitCSV(alpn)
}
stream["tlsSettings"] = tls
case "reality":
priv := strVal(m, "reality_private_key")
if priv == "" {
log.Printf("skip inbound %s: missing reality private key", tag)
return nil, false
}
dest := strVal(m, "reality_dest")
if dest == "" {
dest = "www.microsoft.com:443"
}
names := splitCSV(strVal(m, "reality_server_names"))
if len(names) == 0 {
names = []string{"www.microsoft.com"}
}
shortIDs := []string{strVal(m, "reality_short_id")}
if shortIDs[0] == "" {
shortIDs = []string{""}
}
reality := map[string]any{
"show": false,
"dest": dest,
"xver": 0,
"serverNames": names,
"privateKey": priv,
"shortIds": shortIDs,
}
stream["realitySettings"] = reality
}
return map[string]any{
"tag": tag,
"listen": listen,
"port": port,
"protocol": proto,
"settings": settings,
"streamSettings": stream,
"sniffing": map[string]any{
"enabled": true,
"destOverride": []string{"http", "tls", "quic"},
},
}, true
}
type xrayClient struct {
UUID string
Email string
Username string
}
func (c xrayClient) EmailOrUser() string {
if c.Email != "" {
return c.Email
}
if c.Username != "" {
return c.Username
}
return c.UUID
}
func parseClients(raw any) []xrayClient {
arr, ok := raw.([]any)
if !ok {
return nil
}
var out []xrayClient
for _, item := range arr {
m, ok := item.(map[string]any)
if !ok {
continue
}
uid := strVal(m, "uuid")
if uid == "" {
continue
}
out = append(out, xrayClient{
UUID: uid,
Email: strVal(m, "email"),
Username: strVal(m, "username"),
})
}
return out
}
func ensureXrayContainer(xrayDir string) error {
if _, err := exec.LookPath("docker"); err != nil {
return fmt.Errorf("docker not found (mount docker.sock and install docker CLI): %w", err)
}
_ = dockerRun("rm", "-f", xrayContainer)
abs, err := filepath.Abs(xrayDir)
if err != nil {
abs = xrayDir
}
return dockerRun("run", "-d",
"--name", xrayContainer,
"--network", "host",
"--restart", "unless-stopped",
"-v", abs+":/etc/xray",
xrayImage,
)
}
func dockerRun(args ...string) error {
cmd := exec.Command("docker", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker %v: %v (%s)", args, err, strings.TrimSpace(string(out)))
}
return nil
}
func strVal(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
case fmt.Stringer:
return t.String()
default:
return fmt.Sprint(t)
}
}
func intVal(m map[string]any, key string) int {
v, ok := m[key]
if !ok || v == nil {
return 0
}
switch t := v.(type) {
case float64:
return int(t)
case int:
return t
case int64:
return int(t)
case json.Number:
n, _ := t.Int64()
return int(n)
case string:
var n int
_, _ = fmt.Sscanf(t, "%d", &n)
return n
default:
return 0
}
}
func splitCSV(s string) []string {
parts := strings.Split(s, ",")
var out []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}