diff --git a/cmd/node/main.go b/cmd/node/main.go index 8f7c29b..9361ade 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -13,7 +13,7 @@ import ( "time" ) -const defaultVersion = "0.2.0" +const defaultVersion = "0.3.0" type Agent struct { name string diff --git a/cmd/node/xray.go b/cmd/node/xray.go index 8b3ba89..36a9917 100644 --- a/cmd/node/xray.go +++ b/cmd/node/xray.go @@ -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 } diff --git a/internal/db/clients.go b/internal/db/clients.go index 5956b3a..f5999f1 100644 --- a/internal/db/clients.go +++ b/internal/db/clients.go @@ -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) } diff --git a/internal/db/db.go b/internal/db/db.go index f067010..e80664d 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 diff --git a/internal/db/node_protocols.go b/internal/db/node_protocols.go index efe5998..a15e72c 100644 --- a/internal/db/node_protocols.go +++ b/internal/db/node_protocols.go @@ -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, }) } diff --git a/internal/db/profiles.go b/internal/db/profiles.go index dd35f79..8c7564b 100644 --- a/internal/db/profiles.go +++ b/internal/db/profiles.go @@ -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 diff --git a/internal/handlers/profiles.go b/internal/handlers/profiles.go index b6b0033..d636028 100644 --- a/internal/handlers/profiles.go +++ b/internal/handlers/profiles.go @@ -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 { diff --git a/internal/models/models.go b/internal/models/models.go index 4e5fc30..ca4b36b 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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"` diff --git a/internal/nodeinstall/compose.go b/internal/nodeinstall/compose.go index 5e7bd6d..7c6f327 100644 --- a/internal/nodeinstall/compose.go +++ b/internal/nodeinstall/compose.go @@ -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: diff --git a/internal/xraycfg/config.go b/internal/xraycfg/config.go new file mode 100644 index 0000000..fc77482 --- /dev/null +++ b/internal/xraycfg/config.go @@ -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 + } +} diff --git a/web/static/css/app.css b/web/static/css/app.css index abdbfbb..345f38a 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -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); } diff --git a/web/templates/inbound_edit.html b/web/templates/inbound_edit.html index 07b7fa0..e8d19db 100644 --- a/web/templates/inbound_edit.html +++ b/web/templates/inbound_edit.html @@ -29,7 +29,7 @@ Inbound {{.Inbound.Tag}} - Профиль {{.Profile.Name}} + Xray-core · профиль {{.Profile.Name}} К профилю @@ -58,11 +58,13 @@ Network - tcp - ws - grpc - quic - udp + {{$n := .Inbound.Network}} + tcp + ws + grpc + httpupgrade + xhttp + kcp Security @@ -73,7 +75,7 @@ Path / serviceName - + Host header @@ -82,13 +84,13 @@ Fingerprint - + Flow - + ALPN - + Порядок @@ -97,33 +99,68 @@ SS method - + - Password (trojan shared, optional) + Password (shared, optional) + Fallback dest + + - Reality - Пустые ключи сгенерируются автоматически при security=reality. + Reality (XTLS) Dest Server names - + - Private key - + SpiderX + - Public key (для клиентов) - + xver + Short ID + Short IDs (через запятую) + + + Private key + + + Public key + + + TLS certificates (PEM) + Нужны только при security=tls. На ноде пишутся в /usr/local/etc/xray/certs/ + + Certificate PEM + {{.Inbound.TLSCertPEM}} + + Private key PEM + {{.Inbound.TLSKeyPEM}} + + Fallbacks JSON + {{.Inbound.FallbacksJSON}} + + + + + + + Sniffing + + + + + Sniffing routeOnly + diff --git a/web/templates/profile_view.html b/web/templates/profile_view.html index 0d12c33..c923531 100644 --- a/web/templates/profile_view.html +++ b/web/templates/profile_view.html @@ -123,8 +123,9 @@ tcp ws grpc - quic - udp + httpupgrade + xhttp + kcp Security @@ -138,16 +139,16 @@ Host header - + SNI - Fingerprint + Fingerprint (клиент) Flow - + ALPN @@ -159,16 +160,22 @@ Reality dest - + Reality server names - + + + SpiderX + + + Fallback dest + SS method - Только Xray-протоколы (VLESS/VMess/Trojan/SS). Для Reality ключи и shortId генерируются автоматически. + Xray-core (XTLS/Xray-core): Reality-ключи и shortId генерируются автоматически. На ноде поднимается официальный образ ghcr.io/xtls/xray-core. Добавить inbound
{{.Inbound.Tag}}
Профиль {{.Profile.Name}}
Xray-core · профиль {{.Profile.Name}}
Пустые ключи сгенерируются автоматически при security=reality.
Нужны только при security=tls. На ноде пишутся в /usr/local/etc/xray/certs/
Только Xray-протоколы (VLESS/VMess/Trojan/SS). Для Reality ключи и shortId генерируются автоматически.
Xray-core (XTLS/Xray-core): Reality-ключи и shortId генерируются автоматически. На ноде поднимается официальный образ ghcr.io/xtls/xray-core.