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
}
+9 -2
View File
@@ -231,7 +231,7 @@ func ClientSubscriptionHosts(db *sql.DB, client *models.Client) ([]models.SubHos
rows, err := db.Query(`
SELECT i.id, i.tag, i.remark, p.code, p.name, i.port, i.network, i.security,
i.path, i.host, i.sni, i.fingerprint, i.flow, i.alpn,
i.reality_public_key, i.reality_short_id, i.ss_method, i.password,
i.reality_public_key, i.reality_short_id, i.spider_x, i.ss_method, i.password,
COALESCE(n.host, ''), COALESCE(n.name, ''),
COALESCE(n.status = 'online', FALSE),
COALESCE(ni.enabled AND n.status = 'online', FALSE)
@@ -256,7 +256,7 @@ ORDER BY i.sort_order ASC, i.tag ASC, n.name ASC NULLS LAST`, client.ID)
&h.InboundID, &h.Tag, &h.Remark, &h.ProtocolCode, &h.ProtocolName,
&h.Port, &h.Network, &h.Security,
&h.Path, &h.HostHeader, &h.SNI, &h.Fingerprint, &h.Flow, &h.ALPN,
&h.PublicKey, &h.ShortID, &h.SSMethod, &h.Password,
&h.PublicKey, &h.ShortID, &h.SpiderX, &h.SSMethod, &h.Password,
&h.Address, &h.NodeName,
&h.NodeOnline, &h.Available,
); err != nil {
@@ -363,6 +363,13 @@ func buildVLESSLink(uid, name string, h *models.SubHost) string {
if h.ShortID != "" {
parts = append(parts, "sid="+url.QueryEscape(h.ShortID))
}
spx := h.SpiderX
if spx == "" && h.Security == "reality" {
spx = "/"
}
if spx != "" {
parts = append(parts, "spx="+url.QueryEscape(spx))
}
return fmt.Sprintf("vless://%s@%s:%d?%s#%s", uid, h.Address, h.Port, strings.Join(parts, "&"), name)
}
+9
View File
@@ -187,6 +187,15 @@ CREATE TABLE IF NOT EXISTS client_inbounds (
`ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS reality_short_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS ss_method TEXT NOT NULL DEFAULT 'aes-128-gcm'`,
`ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS password TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS reality_short_ids TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS spider_x TEXT NOT NULL DEFAULT '/'`,
`ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS reality_xver INT NOT NULL DEFAULT 0`,
`ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS fallback_dest TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS fallbacks_json TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS tls_cert_pem TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS tls_key_pem TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS sniffing BOOLEAN NOT NULL DEFAULT TRUE`,
`ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS sniffing_route_only BOOLEAN NOT NULL DEFAULT FALSE`,
} {
if _, err := db.Exec(q); err != nil {
return err
+9
View File
@@ -347,8 +347,17 @@ ORDER BY p.sort_order`, nodeID)
"reality_private_key": in.RealityPrivateKey,
"reality_public_key": in.RealityPublicKey,
"reality_short_id": in.RealityShortID,
"reality_short_ids": in.RealityShortIDs,
"spider_x": in.SpiderX,
"reality_xver": in.RealityXver,
"ss_method": in.SSMethod,
"password": in.Password,
"fallback_dest": in.FallbackDest,
"fallbacks_json": in.FallbacksJSON,
"tls_cert_pem": in.TLSCertPEM,
"tls_key_pem": in.TLSKeyPEM,
"sniffing": in.Sniffing,
"sniffing_route_only": in.SniffingRouteOnly,
"clients": clientList,
})
}
+41 -4
View File
@@ -101,7 +101,10 @@ func scanInbound(scanner interface {
&in.Port, &in.Network, &in.Security, &in.Listen, &in.Enabled, &in.SortOrder, &in.Remark,
&in.Path, &in.Host, &in.SNI, &in.Fingerprint, &in.Flow, &in.ALPN,
&in.RealityDest, &in.RealityServerNames, &in.RealityPrivateKey, &in.RealityPublicKey, &in.RealityShortID,
&in.RealityShortIDs, &in.SpiderX, &in.RealityXver,
&in.SSMethod, &in.Password,
&in.FallbackDest, &in.FallbacksJSON, &in.TLSCertPEM, &in.TLSKeyPEM,
&in.Sniffing, &in.SniffingRouteOnly,
&in.CreatedAt, &in.UpdatedAt,
)
if err != nil {
@@ -115,7 +118,10 @@ i.id, i.profile_id, i.tag, i.protocol_id, p.code, p.name,
i.port, i.network, i.security, i.listen, i.enabled, i.sort_order, i.remark,
i.path, i.host, i.sni, i.fingerprint, i.flow, i.alpn,
i.reality_dest, i.reality_server_names, i.reality_private_key, i.reality_public_key, i.reality_short_id,
i.reality_short_ids, i.spider_x, i.reality_xver,
i.ss_method, i.password,
i.fallback_dest, i.fallbacks_json, i.tls_cert_pem, i.tls_key_pem,
i.sniffing, i.sniffing_route_only,
i.created_at, i.updated_at`
func ListInboundsByProfile(db *sql.DB, profileID uuid.UUID) ([]models.Inbound, error) {
@@ -175,6 +181,12 @@ func CreateInbound(db *sql.DB, in *models.Inbound) error {
if in.SSMethod == "" {
in.SSMethod = "aes-128-gcm"
}
if in.SpiderX == "" {
in.SpiderX = "/"
}
if in.RealityShortIDs == "" && in.RealityShortID != "" {
in.RealityShortIDs = in.RealityShortID
}
now := time.Now().UTC()
in.CreatedAt = now
in.UpdatedAt = now
@@ -183,18 +195,27 @@ INSERT INTO inbounds (
id, profile_id, tag, protocol_id, port, network, security, listen, enabled, sort_order, remark,
path, host, sni, fingerprint, flow, alpn,
reality_dest, reality_server_names, reality_private_key, reality_public_key, reality_short_id,
ss_method, password, created_at, updated_at
reality_short_ids, spider_x, reality_xver,
ss_method, password,
fallback_dest, fallbacks_json, tls_cert_pem, tls_key_pem,
sniffing, sniffing_route_only, created_at, updated_at
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,
$12,$13,$14,$15,$16,$17,
$18,$19,$20,$21,$22,
$23,$24,$25,$26
$23,$24,$25,
$26,$27,
$28,$29,$30,$31,
$32,$33,$34,$35
)`,
in.ID, in.ProfileID, in.Tag, in.ProtocolID, in.Port, in.Network, in.Security, in.Listen,
in.Enabled, in.SortOrder, in.Remark,
in.Path, in.Host, in.SNI, in.Fingerprint, in.Flow, in.ALPN,
in.RealityDest, in.RealityServerNames, in.RealityPrivateKey, in.RealityPublicKey, in.RealityShortID,
in.SSMethod, in.Password, in.CreatedAt, in.UpdatedAt,
in.RealityShortIDs, in.SpiderX, in.RealityXver,
in.SSMethod, in.Password,
in.FallbackDest, in.FallbacksJSON, in.TLSCertPEM, in.TLSKeyPEM,
in.Sniffing, in.SniffingRouteOnly, in.CreatedAt, in.UpdatedAt,
)
return err
}
@@ -206,20 +227,33 @@ func UpdateInbound(db *sql.DB, in *models.Inbound) error {
if in.SSMethod == "" {
in.SSMethod = "aes-128-gcm"
}
if in.SpiderX == "" {
in.SpiderX = "/"
}
if in.RealityShortIDs == "" && in.RealityShortID != "" {
in.RealityShortIDs = in.RealityShortID
}
_, err := db.Exec(`
UPDATE inbounds SET
tag = $2, protocol_id = $3, port = $4, network = $5, security = $6,
listen = $7, enabled = $8, sort_order = $9, remark = $10,
path = $11, host = $12, sni = $13, fingerprint = $14, flow = $15, alpn = $16,
reality_dest = $17, reality_server_names = $18, reality_private_key = $19,
reality_public_key = $20, reality_short_id = $21, ss_method = $22, password = $23,
reality_public_key = $20, reality_short_id = $21,
reality_short_ids = $22, spider_x = $23, reality_xver = $24,
ss_method = $25, password = $26,
fallback_dest = $27, fallbacks_json = $28, tls_cert_pem = $29, tls_key_pem = $30,
sniffing = $31, sniffing_route_only = $32,
updated_at = NOW()
WHERE id = $1`,
in.ID, in.Tag, in.ProtocolID, in.Port, in.Network, in.Security,
in.Listen, in.Enabled, in.SortOrder, in.Remark,
in.Path, in.Host, in.SNI, in.Fingerprint, in.Flow, in.ALPN,
in.RealityDest, in.RealityServerNames, in.RealityPrivateKey, in.RealityPublicKey, in.RealityShortID,
in.RealityShortIDs, in.SpiderX, in.RealityXver,
in.SSMethod, in.Password,
in.FallbackDest, in.FallbacksJSON, in.TLSCertPEM, in.TLSKeyPEM,
in.Sniffing, in.SniffingRouteOnly,
)
return err
}
@@ -308,7 +342,10 @@ ORDER BY i.sort_order ASC, i.tag ASC`, nodeID, *n.ProfileID)
&in.Port, &in.Network, &in.Security, &in.Listen, &in.Enabled, &in.SortOrder, &in.Remark,
&in.Path, &in.Host, &in.SNI, &in.Fingerprint, &in.Flow, &in.ALPN,
&in.RealityDest, &in.RealityServerNames, &in.RealityPrivateKey, &in.RealityPublicKey, &in.RealityShortID,
&in.RealityShortIDs, &in.SpiderX, &in.RealityXver,
&in.SSMethod, &in.Password,
&in.FallbackDest, &in.FallbacksJSON, &in.TLSCertPEM, &in.TLSKeyPEM,
&in.Sniffing, &in.SniffingRouteOnly,
&in.CreatedAt, &in.UpdatedAt, &in.NodeEnabled,
); err != nil {
return nil, err
+31
View File
@@ -162,8 +162,22 @@ func parseInboundForm(r *http.Request, profileID, protocolID uuid.UUID, proto *m
RealityPrivateKey: strings.TrimSpace(r.FormValue("reality_private_key")),
RealityPublicKey: strings.TrimSpace(r.FormValue("reality_public_key")),
RealityShortID: strings.TrimSpace(r.FormValue("reality_short_id")),
RealityShortIDs: strings.TrimSpace(r.FormValue("reality_short_ids")),
SpiderX: strings.TrimSpace(r.FormValue("spider_x")),
SSMethod: strings.TrimSpace(r.FormValue("ss_method")),
Password: strings.TrimSpace(r.FormValue("password")),
FallbackDest: strings.TrimSpace(r.FormValue("fallback_dest")),
FallbacksJSON: strings.TrimSpace(r.FormValue("fallbacks_json")),
TLSCertPEM: strings.TrimSpace(r.FormValue("tls_cert_pem")),
TLSKeyPEM: strings.TrimSpace(r.FormValue("tls_key_pem")),
Sniffing: formCheckbox(r, "sniffing", true),
SniffingRouteOnly: formCheckbox(r, "sniffing_route_only", false),
}
if xver, err := strconv.Atoi(r.FormValue("reality_xver")); err == nil {
in.RealityXver = xver
}
if in.SpiderX == "" {
in.SpiderX = "/"
}
if in.Security == "reality" {
priv, pub, sid, dest, names, err := xraykeys.EnsureReality(
@@ -175,6 +189,9 @@ func parseInboundForm(r *http.Request, profileID, protocolID uuid.UUID, proto *m
}
in.RealityPrivateKey, in.RealityPublicKey, in.RealityShortID = priv, pub, sid
in.RealityDest, in.RealityServerNames = dest, names
if in.RealityShortIDs == "" {
in.RealityShortIDs = sid
}
if in.SNI == "" {
in.SNI = strings.Split(names, ",")[0]
}
@@ -301,6 +318,20 @@ func inboundProtocols(all []models.Protocol) []models.Protocol {
return out
}
func formCheckbox(r *http.Request, name string, defaultOn bool) bool {
vals := r.Form[name]
if len(vals) == 0 {
return defaultOn
}
on := false
for _, v := range vals {
if v == "on" || v == "true" || v == "1" {
on = true
}
}
return on
}
func (s *Server) inboundUpdate(w http.ResponseWriter, r *http.Request) {
profileID, err := uuid.Parse(mux.Vars(r)["id"])
if err != nil {
+10
View File
@@ -153,8 +153,17 @@ type Inbound struct {
RealityPrivateKey string `json:"reality_private_key"`
RealityPublicKey string `json:"reality_public_key"`
RealityShortID string `json:"reality_short_id"`
RealityShortIDs string `json:"reality_short_ids"`
SpiderX string `json:"spider_x"`
RealityXver int `json:"reality_xver"`
SSMethod string `json:"ss_method"`
Password string `json:"password"`
FallbackDest string `json:"fallback_dest"`
FallbacksJSON string `json:"fallbacks_json"`
TLSCertPEM string `json:"tls_cert_pem"`
TLSKeyPEM string `json:"tls_key_pem"`
Sniffing bool `json:"sniffing"`
SniffingRouteOnly bool `json:"sniffing_route_only"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -260,6 +269,7 @@ type SubHost struct {
ALPN string `json:"alpn,omitempty"`
PublicKey string `json:"public_key,omitempty"`
ShortID string `json:"short_id,omitempty"`
SpiderX string `json:"spider_x,omitempty"`
SSMethod string `json:"ss_method,omitempty"`
Password string `json:"password,omitempty"`
Address string `json:"address"`
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/orohi/vpn-panel/internal/models"
)
const AgentVersion = "0.2.0"
const AgentVersion = "0.3.0"
func ComposeYAML(n *models.Node) string {
return fmt.Sprintf(`services:
+591
View File
@@ -0,0 +1,591 @@
// Package xraycfg builds Xray-core (XTLS) JSON configs from panel inbound payloads.
// Spec: https://github.com/XTLS/Xray-core
package xraycfg
import (
"encoding/json"
"fmt"
"strings"
)
// Inbound is a panel→node inbound description used to generate Xray inbound objects.
type Inbound struct {
ID string
Tag string
Protocol string
Port int
Listen string
Network string
Security string
Remark string
Path string
Host string
SNI string
Fingerprint string
Flow string
ALPN string
RealityDest string
RealityServerNames string
RealityPrivateKey string
RealityPublicKey string
RealityShortID string
RealityShortIDs string // comma-separated; overrides RealityShortID when set
RealitySpiderX string
RealityXver int
SSMethod string
Password string
FallbackDest string // simple fallback, e.g. 127.0.0.1:80
FallbacksJSON string // optional raw JSON array of fallback objects
TLSCertPEM string
TLSKeyPEM string
Sniffing bool
SniffingRouteOnly bool
Clients []Client
}
type Client struct {
ID string
UUID string
Email string
Username string
Flow string // optional per-client override
}
func (c Client) EmailOrUser() string {
if c.Email != "" {
return c.Email
}
if c.Username != "" {
return c.Username
}
if c.UUID != "" {
return c.UUID
}
return c.ID
}
// Build produces a full Xray-core config.json object.
func Build(inbounds []Inbound, certDir string) (map[string]any, int, error) {
var xIn []map[string]any
for _, in := range inbounds {
obj, ok, err := buildInbound(in, certDir)
if err != nil {
return nil, 0, err
}
if ok {
xIn = append(xIn, obj)
}
}
if xIn == nil {
xIn = []map[string]any{}
}
cfg := map[string]any{
"log": map[string]any{
"loglevel": "warning",
"access": "none",
"error": "",
},
"dns": map[string]any{
"servers": []any{
"1.1.1.1",
"1.0.0.1",
"8.8.8.8",
"localhost",
},
"queryStrategy": "UseIP",
},
"inbounds": xIn,
"outbounds": []map[string]any{
{
"tag": "direct",
"protocol": "freedom",
"settings": map[string]any{
"domainStrategy": "UseIP",
},
},
{
"tag": "block",
"protocol": "blackhole",
"settings": map[string]any{
"response": map[string]any{"type": "http"},
},
},
},
"routing": map[string]any{
"domainStrategy": "AsIs",
"rules": []map[string]any{
{
"type": "field",
"protocol": []string{"bittorrent"},
"outboundTag": "block",
},
{
"type": "field",
"ip": []string{"geoip:private"},
"outboundTag": "block",
},
},
},
"policy": map[string]any{
"levels": map[string]any{
"0": map[string]any{
"handshake": 4,
"connIdle": 300,
"uplinkOnly": 2,
"downlinkOnly": 5,
"statsUserUplink": true,
"statsUserDownlink": true,
},
},
"system": map[string]any{
"statsInboundUplink": true,
"statsInboundDownlink": true,
"statsOutboundUplink": true,
"statsOutboundDownlink": true,
},
},
"stats": map[string]any{},
}
return cfg, len(xIn), nil
}
func buildInbound(in Inbound, certDir string) (map[string]any, bool, error) {
proto := strings.ToLower(strings.TrimSpace(in.Protocol))
switch proto {
case "vless", "vmess", "trojan", "shadowsocks":
default:
return nil, false, nil
}
tag := in.Tag
if tag == "" {
tag = proto
}
listen := in.Listen
if listen == "" {
listen = "0.0.0.0"
}
if in.Port <= 0 {
return nil, false, nil
}
network := strings.ToLower(or(in.Network, "tcp"))
security := strings.ToLower(or(in.Security, "none"))
settings, err := protocolSettings(proto, in)
if err != nil {
return nil, false, err
}
stream, err := streamSettings(in, network, security, certDir)
if err != nil {
return nil, false, err
}
obj := map[string]any{
"tag": tag,
"listen": listen,
"port": in.Port,
"protocol": proto,
"settings": settings,
"streamSettings": stream,
"sniffing": map[string]any{
"enabled": in.Sniffing,
"destOverride": []string{"http", "tls", "quic"},
"routeOnly": in.SniffingRouteOnly,
},
}
return obj, true, nil
}
func protocolSettings(proto string, in Inbound) (map[string]any, error) {
switch proto {
case "vless":
var list []map[string]any
for _, c := range in.Clients {
item := map[string]any{
"id": c.UUID,
"email": c.EmailOrUser(),
}
flow := c.Flow
if flow == "" {
flow = in.Flow
}
if flow == "" && in.Security == "reality" && or(in.Network, "tcp") == "tcp" {
flow = "xtls-rprx-vision"
}
if flow != "" {
item["flow"] = flow
}
list = append(list, item)
}
if list == nil {
list = []map[string]any{}
}
settings := map[string]any{
"clients": list,
"decryption": "none",
}
if fb := parseFallbacks(in); len(fb) > 0 {
settings["fallbacks"] = fb
}
return settings, nil
case "vmess":
var list []map[string]any
for _, c := range in.Clients {
list = append(list, map[string]any{
"id": c.UUID,
"email": c.EmailOrUser(),
"alterId": 0,
})
}
if list == nil {
list = []map[string]any{}
}
return map[string]any{"clients": list}, nil
case "trojan":
var list []map[string]any
for _, c := range in.Clients {
pass := c.UUID
if in.Password != "" {
pass = in.Password
}
list = append(list, map[string]any{
"password": pass,
"email": c.EmailOrUser(),
})
}
if list == nil {
list = []map[string]any{}
}
settings := map[string]any{"clients": list}
if fb := parseFallbacks(in); len(fb) > 0 {
settings["fallbacks"] = fb
}
return settings, nil
case "shadowsocks":
method := or(in.SSMethod, "aes-128-gcm")
var list []map[string]any
for _, c := range in.Clients {
list = append(list, map[string]any{
"password": c.UUID,
"email": c.EmailOrUser(),
"method": method,
})
}
if list == nil {
list = []map[string]any{}
}
return map[string]any{
"method": method,
"password": or(in.Password, "panel-ss"),
"clients": list,
"network": "tcp,udp",
}, nil
}
return nil, fmt.Errorf("unsupported protocol %s", proto)
}
func streamSettings(in Inbound, network, security, certDir string) (map[string]any, error) {
stream := map[string]any{
"network": network,
"security": security,
}
switch network {
case "ws":
ws := map[string]any{}
if in.Path != "" {
ws["path"] = in.Path
}
if in.Host != "" {
ws["headers"] = map[string]any{"Host": in.Host}
}
stream["wsSettings"] = ws
case "grpc", "gun":
grpc := map[string]any{
"multiMode": false,
}
if in.Path != "" {
grpc["serviceName"] = in.Path
}
stream["grpcSettings"] = grpc
case "httpupgrade":
hu := map[string]any{}
if in.Path != "" {
hu["path"] = in.Path
}
if in.Host != "" {
hu["host"] = in.Host
}
stream["httpupgradeSettings"] = hu
case "xhttp", "splithttp":
xh := map[string]any{
"mode": "auto",
}
if in.Path != "" {
xh["path"] = in.Path
}
if in.Host != "" {
xh["host"] = in.Host
}
stream["xhttpSettings"] = xh
case "tcp":
// optional HTTP camouflage when path/host set without ws
if in.Path != "" || in.Host != "" {
stream["tcpSettings"] = map[string]any{
"header": map[string]any{
"type": "http",
"request": map[string]any{
"path": splitPaths(in.Path),
"headers": map[string]any{
"Host": splitCSV(in.Host),
},
},
},
}
}
case "kcp", "mkcp":
stream["kcpSettings"] = map[string]any{
"mtu": 1350,
"tti": 50,
"uplinkCapacity": 5,
"downlinkCapacity": 20,
"congestion": false,
"header": map[string]any{"type": "none"},
}
}
switch security {
case "tls":
tls := map[string]any{
"rejectUnknownSni": false,
"minVersion": "1.2",
}
if in.SNI != "" {
tls["serverName"] = in.SNI
}
if in.ALPN != "" {
tls["alpn"] = splitCSV(in.ALPN)
}
if in.Fingerprint != "" {
tls["fingerprint"] = in.Fingerprint
}
if in.TLSCertPEM != "" && in.TLSKeyPEM != "" && certDir != "" {
id := in.ID
if id == "" {
id = sanitizeTag(in.Tag)
}
certFile := certDir + "/" + id + ".crt"
keyFile := certDir + "/" + id + ".key"
tls["certificates"] = []map[string]any{{
"certificateFile": certFile,
"keyFile": keyFile,
}}
}
stream["tlsSettings"] = tls
case "reality":
if in.RealityPrivateKey == "" {
return nil, fmt.Errorf("inbound %s: reality private key required", in.Tag)
}
dest := or(in.RealityDest, "www.microsoft.com:443")
names := splitCSV(in.RealityServerNames)
if len(names) == 0 {
names = []string{"www.microsoft.com"}
}
shortIDs := shortIDList(in)
spiderX := in.RealitySpiderX
if spiderX == "" {
spiderX = "/"
}
reality := map[string]any{
"show": false,
"dest": dest,
"xver": in.RealityXver,
"serverNames": names,
"privateKey": in.RealityPrivateKey,
"shortIds": shortIDs,
"spiderX": spiderX,
}
stream["realitySettings"] = reality
}
stream["sockopt"] = map[string]any{
"tcpFastOpen": false,
"tcpKeepAliveIdle": 100,
"domainStrategy": "UseIP",
}
return stream, nil
}
func parseFallbacks(in Inbound) []map[string]any {
if strings.TrimSpace(in.FallbacksJSON) != "" {
var raw []map[string]any
if err := json.Unmarshal([]byte(in.FallbacksJSON), &raw); err == nil && len(raw) > 0 {
return raw
}
}
if dest := strings.TrimSpace(in.FallbackDest); dest != "" {
return []map[string]any{{"dest": dest}}
}
return nil
}
func shortIDList(in Inbound) []string {
raw := in.RealityShortIDs
if strings.TrimSpace(raw) == "" {
raw = in.RealityShortID
}
ids := splitCSV(raw)
if len(ids) == 0 {
return []string{""}
}
return ids
}
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
}
func splitPaths(path string) []string {
if path == "" {
return []string{"/"}
}
parts := splitCSV(path)
if len(parts) == 0 {
return []string{"/"}
}
return parts
}
func or(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
func sanitizeTag(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 tag
}
// FromMap converts a loose JSON map (agent payload item) into Inbound.
func FromMap(m map[string]any) Inbound {
in := Inbound{
ID: str(m, "id"),
Tag: str(m, "tag"),
Protocol: str(m, "protocol"),
Port: asInt(m["port"]),
Listen: str(m, "listen"),
Network: str(m, "network"),
Security: str(m, "security"),
Remark: str(m, "remark"),
Path: str(m, "path"),
Host: str(m, "host"),
SNI: str(m, "sni"),
Fingerprint: str(m, "fingerprint"),
Flow: str(m, "flow"),
ALPN: str(m, "alpn"),
RealityDest: str(m, "reality_dest"),
RealityServerNames: str(m, "reality_server_names"),
RealityPrivateKey: str(m, "reality_private_key"),
RealityPublicKey: str(m, "reality_public_key"),
RealityShortID: str(m, "reality_short_id"),
RealityShortIDs: str(m, "reality_short_ids"),
RealitySpiderX: str(m, "spider_x"),
RealityXver: asInt(m["reality_xver"]),
SSMethod: str(m, "ss_method"),
Password: str(m, "password"),
FallbackDest: str(m, "fallback_dest"),
FallbacksJSON: str(m, "fallbacks_json"),
TLSCertPEM: str(m, "tls_cert_pem"),
TLSKeyPEM: str(m, "tls_key_pem"),
Sniffing: asBoolDefault(m["sniffing"], true),
SniffingRouteOnly: asBoolDefault(m["sniffing_route_only"], false),
}
if arr, ok := m["clients"].([]any); ok {
for _, item := range arr {
cm, ok := item.(map[string]any)
if !ok {
continue
}
uid := str(cm, "uuid")
if uid == "" {
continue
}
in.Clients = append(in.Clients, Client{
ID: str(cm, "id"),
UUID: uid,
Email: str(cm, "email"),
Username: str(cm, "username"),
Flow: str(cm, "flow"),
})
}
}
return in
}
func str(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
default:
return fmt.Sprint(t)
}
}
func asInt(v any) int {
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 asBoolDefault(v any, def bool) bool {
if v == nil {
return def
}
switch t := v.(type) {
case bool:
return t
case string:
return t == "1" || strings.EqualFold(t, "true") || t == "on"
default:
return def
}
}
+5 -1
View File
@@ -467,10 +467,14 @@ textarea { resize: vertical; font-family: var(--mono); font-size: 0.85rem; }
gap: 0.5rem;
margin: 1rem 0;
}
input.mono, .mono {
input.mono, .mono, textarea.mono {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.85rem;
}
textarea.mono {
width: 100%;
resize: vertical;
}
@keyframes rise {
from { opacity: 0; transform: translateY(12px); }
+56 -19
View File
@@ -29,7 +29,7 @@
<header class="admin-header row-head">
<div>
<h1>Inbound <code>{{.Inbound.Tag}}</code></h1>
<p class="muted">Профиль {{.Profile.Name}}</p>
<p class="muted">Xray-core · профиль {{.Profile.Name}}</p>
</div>
<a class="btn btn-ghost" href="/admin/profiles/{{.Profile.ID}}">К профилю</a>
</header>
@@ -58,11 +58,13 @@
</label>
<label>Network
<select name="network">
<option value="tcp" {{if eq .Inbound.Network "tcp"}}selected{{end}}>tcp</option>
<option value="ws" {{if eq .Inbound.Network "ws"}}selected{{end}}>ws</option>
<option value="grpc" {{if eq .Inbound.Network "grpc"}}selected{{end}}>grpc</option>
<option value="quic" {{if eq .Inbound.Network "quic"}}selected{{end}}>quic</option>
<option value="udp" {{if eq .Inbound.Network "udp"}}selected{{end}}>udp</option>
{{$n := .Inbound.Network}}
<option value="tcp" {{if eq $n "tcp"}}selected{{end}}>tcp</option>
<option value="ws" {{if eq $n "ws"}}selected{{end}}>ws</option>
<option value="grpc" {{if eq $n "grpc"}}selected{{end}}>grpc</option>
<option value="httpupgrade" {{if eq $n "httpupgrade"}}selected{{end}}>httpupgrade</option>
<option value="xhttp" {{if eq $n "xhttp"}}selected{{end}}>xhttp</option>
<option value="kcp" {{if eq $n "kcp"}}selected{{end}}>kcp</option>
</select>
</label>
<label>Security
@@ -73,7 +75,7 @@
</select>
</label>
<label>Path / serviceName
<input name="path" value="{{.Inbound.Path}}" placeholder="/ws или grpc service">
<input name="path" value="{{.Inbound.Path}}">
</label>
<label>Host header
<input name="host" value="{{.Inbound.Host}}">
@@ -82,13 +84,13 @@
<input name="sni" value="{{.Inbound.SNI}}">
</label>
<label>Fingerprint
<input name="fingerprint" value="{{.Inbound.Fingerprint}}" placeholder="chrome">
<input name="fingerprint" value="{{.Inbound.Fingerprint}}">
</label>
<label>Flow
<input name="flow" value="{{.Inbound.Flow}}" placeholder="xtls-rprx-vision">
<input name="flow" value="{{.Inbound.Flow}}">
</label>
<label>ALPN
<input name="alpn" value="{{.Inbound.ALPN}}" placeholder="h2,http/1.1">
<input name="alpn" value="{{.Inbound.ALPN}}">
</label>
<label>Порядок
<input name="sort_order" type="number" value="{{.Inbound.SortOrder}}">
@@ -97,33 +99,68 @@
<input name="remark" value="{{.Inbound.Remark}}">
</label>
<label>SS method
<input name="ss_method" value="{{.Inbound.SSMethod}}" placeholder="aes-128-gcm">
<input name="ss_method" value="{{.Inbound.SSMethod}}">
</label>
<label>Password (trojan shared, optional)
<label>Password (shared, optional)
<input name="password" value="{{.Inbound.Password}}">
</label>
<label>Fallback dest
<input name="fallback_dest" value="{{.Inbound.FallbackDest}}" placeholder="127.0.0.1:80">
</label>
</div>
<h3 class="section-sub">Reality</h3>
<p class="muted">Пустые ключи сгенерируются автоматически при security=reality.</p>
<h3 class="section-sub">Reality (XTLS)</h3>
<div class="form-grid">
<label>Dest
<input name="reality_dest" value="{{.Inbound.RealityDest}}" placeholder="www.microsoft.com:443">
</label>
<label>Server names
<input name="reality_server_names" value="{{.Inbound.RealityServerNames}}" placeholder="www.microsoft.com">
<input name="reality_server_names" value="{{.Inbound.RealityServerNames}}">
</label>
<label>Private key
<input name="reality_private_key" value="{{.Inbound.RealityPrivateKey}}" class="mono">
<label>SpiderX
<input name="spider_x" value="{{.Inbound.SpiderX}}">
</label>
<label>Public key (для клиентов)
<input name="reality_public_key" value="{{.Inbound.RealityPublicKey}}" class="mono">
<label>xver
<input name="reality_xver" type="number" value="{{.Inbound.RealityXver}}">
</label>
<label>Short ID
<input name="reality_short_id" value="{{.Inbound.RealityShortID}}" class="mono">
</label>
<label>Short IDs (через запятую)
<input name="reality_short_ids" value="{{.Inbound.RealityShortIDs}}" class="mono">
</label>
<label>Private key
<input name="reality_private_key" value="{{.Inbound.RealityPrivateKey}}" class="mono">
</label>
<label>Public key
<input name="reality_public_key" value="{{.Inbound.RealityPublicKey}}" class="mono">
</label>
</div>
<h3 class="section-sub">TLS certificates (PEM)</h3>
<p class="muted">Нужны только при security=tls. На ноде пишутся в /usr/local/etc/xray/certs/</p>
<div class="form-grid">
<label>Certificate PEM
<textarea name="tls_cert_pem" rows="4" class="mono">{{.Inbound.TLSCertPEM}}</textarea>
</label>
<label>Private key PEM
<textarea name="tls_key_pem" rows="4" class="mono">{{.Inbound.TLSKeyPEM}}</textarea>
</label>
<label>Fallbacks JSON
<textarea name="fallbacks_json" rows="3" class="mono" placeholder='[{"dest":"127.0.0.1:80"}]'>{{.Inbound.FallbacksJSON}}</textarea>
</label>
</div>
<label class="check-row">
<input type="hidden" name="sniffing" value="off">
<input type="checkbox" name="sniffing" value="on" {{if .Inbound.Sniffing}}checked{{end}}>
Sniffing
</label>
<label class="check-row">
<input type="hidden" name="sniffing_route_only" value="off">
<input type="checkbox" name="sniffing_route_only" value="on" {{if .Inbound.SniffingRouteOnly}}checked{{end}}>
Sniffing routeOnly
</label>
<label class="check-row">
<input type="hidden" name="enabled" value="off">
<input type="checkbox" name="enabled" value="on" {{if .Inbound.Enabled}}checked{{end}}>
+15 -8
View File
@@ -123,8 +123,9 @@
<option value="tcp" selected>tcp</option>
<option value="ws">ws</option>
<option value="grpc">grpc</option>
<option value="quic">quic</option>
<option value="udp">udp</option>
<option value="httpupgrade">httpupgrade</option>
<option value="xhttp">xhttp</option>
<option value="kcp">kcp</option>
</select>
</label>
<label>Security
@@ -138,16 +139,16 @@
<input name="path" placeholder="/ws или grpc service">
</label>
<label>Host header
<input name="host" placeholder="для ws">
<input name="host" placeholder="для ws/xhttp">
</label>
<label>SNI
<input name="sni" placeholder="авто из reality names">
</label>
<label>Fingerprint
<label>Fingerprint (клиент)
<input name="fingerprint" value="chrome">
</label>
<label>Flow
<input name="flow" value="xtls-rprx-vision" placeholder="xtls-rprx-vision для VLESS+Reality">
<input name="flow" value="xtls-rprx-vision" placeholder="xtls-rprx-vision">
</label>
<label>ALPN
<input name="alpn" placeholder="h2,http/1.1">
@@ -159,16 +160,22 @@
<input name="remark" placeholder="Sweden RU (имя в подписке)">
</label>
<label>Reality dest
<input name="reality_dest" value="www.microsoft.com:443" placeholder="www.microsoft.com:443">
<input name="reality_dest" value="www.microsoft.com:443">
</label>
<label>Reality server names
<input name="reality_server_names" value="www.microsoft.com" placeholder="www.microsoft.com">
<input name="reality_server_names" value="www.microsoft.com">
</label>
<label>SpiderX
<input name="spider_x" value="/">
</label>
<label>Fallback dest
<input name="fallback_dest" placeholder="127.0.0.1:80">
</label>
<label>SS method
<input name="ss_method" value="aes-128-gcm">
</label>
</div>
<p class="muted">Только Xray-протоколы (VLESS/VMess/Trojan/SS). Для Reality ключи и shortId генерируются автоматически.</p>
<p class="muted">Xray-core (XTLS/Xray-core): Reality-ключи и shortId генерируются автоматически. На ноде поднимается официальный образ ghcr.io/xtls/xray-core.</p>
<button class="btn btn-primary" type="submit">Добавить inbound</button>
</form>
</section>