Run official Xray-core on nodes with full Reality, TLS, and routing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-29 05:41:11 +03:00
co-authored by Cursor
parent b48b936c27
commit c567693651
13 changed files with 831 additions and 325 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"time"
)
const defaultVersion = "0.2.0"
const defaultVersion = "0.3.0"
type Agent struct {
name string
+53 -289
View File
@@ -8,18 +8,47 @@ import (
"os/exec"
"path/filepath"
"strings"
"github.com/orohi/vpn-panel/internal/xraycfg"
)
const xrayContainer = "vpn-panel-xray"
const xrayImage = "teddysun/xray:latest"
// Official Xray-core image: https://github.com/XTLS/Xray-core
const xrayImage = "ghcr.io/xtls/xray-core:latest"
func (a *Agent) applyXray(cfg map[string]any) {
xrayDir := filepath.Join(a.dataDir, "xray")
_ = os.MkdirAll(xrayDir, 0o755)
certDir := filepath.Join(xrayDir, "certs")
_ = os.MkdirAll(certDir, 0o755)
configPath := filepath.Join(xrayDir, "config.json")
var list []xraycfg.Inbound
inboundsRaw, _ := cfg["inbounds"].([]any)
xrayCfg, n := buildXrayConfig(inboundsRaw)
for _, raw := range inboundsRaw {
m, ok := raw.(map[string]any)
if !ok {
continue
}
in := xraycfg.FromMap(m)
if in.TLSCertPEM != "" && in.TLSKeyPEM != "" {
id := in.ID
if id == "" {
id = sanitizeFile(in.Tag)
}
_ = os.WriteFile(filepath.Join(certDir, id+".crt"), []byte(in.TLSCertPEM), 0o600)
_ = os.WriteFile(filepath.Join(certDir, id+".key"), []byte(in.TLSKeyPEM), 0o600)
}
list = append(list, in)
}
// Paths inside the Xray container
containerCertDir := "/usr/local/etc/xray/certs"
xrayCfg, n, err := xraycfg.Build(list, containerCertDir)
if err != nil {
log.Printf("xray build: %v", err)
return
}
b, err := json.MarshalIndent(xrayCfg, "", " ")
if err != nil {
log.Printf("xray marshal: %v", err)
@@ -29,13 +58,14 @@ func (a *Agent) applyXray(cfg map[string]any) {
log.Printf("xray write config: %v", err)
return
}
log.Printf("xray config written: %d inbound(s)", n)
log.Printf("xray-core 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
@@ -45,254 +75,26 @@ func (a *Agent) applyXray(cfg map[string]any) {
log.Printf("xray container: %v", err)
return
}
log.Printf("xray container ready")
log.Printf("xray-core container ready (%s)", xrayImage)
}
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 {
func ensureXrayContainer(hostXrayDir string) error {
if _, err := exec.LookPath("docker"); err != nil {
return fmt.Errorf("docker not found (mount docker.sock and install docker CLI): %w", err)
return fmt.Errorf("docker not found (mount docker.sock): %w", err)
}
abs := hostXrayDir
if a, err := filepath.Abs(hostXrayDir); err == nil {
abs = a
}
// Prefer recreate for clean config pickup (official image has no inotify reload by default).
_ = 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",
"-v", abs+":/usr/local/etc/xray",
xrayImage,
"run", "-c", "/usr/local/etc/xray/config.json",
)
}
@@ -305,53 +107,15 @@ func dockerRun(args ...string) error {
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)
func sanitizeFile(tag string) string {
tag = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
return r
}
return '_'
}, tag)
if tag == "" {
return "inbound"
}
return out
return tag
}