Apply inbounds on nodes via Xray with Reality settings and client sync.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+15
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user