diff --git a/cmd/node/main.go b/cmd/node/main.go index eb11020..8f7c29b 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -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 +} diff --git a/cmd/node/xray.go b/cmd/node/xray.go new file mode 100644 index 0000000..8b3ba89 --- /dev/null +++ b/cmd/node/xray.go @@ -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 +} diff --git a/internal/db/clients.go b/internal/db/clients.go index b7fd3c7..e879356 100644 --- a/internal/db/clients.go +++ b/internal/db/clients.go @@ -2,6 +2,7 @@ package db import ( "database/sql" + "encoding/base64" "fmt" "net/url" "time" @@ -228,6 +229,8 @@ 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, COALESCE(n.host, ''), COALESCE(n.name, ''), COALESCE(n.status = 'online', FALSE), COALESCE(ni.enabled AND n.status = 'online', FALSE) @@ -250,7 +253,10 @@ ORDER BY i.sort_order ASC, i.tag ASC, n.name ASC NULLS LAST`, client.ID) var h models.SubHost if err := rows.Scan( &h.InboundID, &h.Tag, &h.Remark, &h.ProtocolCode, &h.ProtocolName, - &h.Port, &h.Network, &h.Security, &h.Address, &h.NodeName, + &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.Address, &h.NodeName, &h.NodeOnline, &h.Available, ); err != nil { return nil, err @@ -266,26 +272,63 @@ ORDER BY i.sort_order ASC, i.tag ASC, n.name ASC NULLS LAST`, client.ID) seen[key] = true if h.Available && h.Address != "" { - h.Link = buildShareLink(h.ProtocolCode, uid, h.Address, h.Port, h.Network, h.Security, client.Username+"-"+h.Tag) + h.Link = buildShareLink(uid, client.Username+"-"+h.Tag, &h) } hosts = append(hosts, h) } return hosts, rows.Err() } -func buildShareLink(code, uid, host string, port int, network, security, name string) string { +func buildShareLink(uid, name string, h *models.SubHost) string { name = url.QueryEscape(name) - switch code { + q := url.Values{} + q.Set("type", h.Network) + q.Set("security", h.Security) + if h.Path != "" { + q.Set("path", h.Path) + } + if h.HostHeader != "" { + q.Set("host", h.HostHeader) + } + if h.SNI != "" { + q.Set("sni", h.SNI) + } + if h.Fingerprint != "" { + q.Set("fp", h.Fingerprint) + } + if h.Flow != "" { + q.Set("flow", h.Flow) + } + if h.ALPN != "" { + q.Set("alpn", h.ALPN) + } + if h.PublicKey != "" { + q.Set("pbk", h.PublicKey) + } + if h.ShortID != "" { + q.Set("sid", h.ShortID) + } + switch h.ProtocolCode { case "vless": - return fmt.Sprintf("vless://%s@%s:%d?encryption=none&type=%s&security=%s#%s", uid, host, port, network, security, name) + q.Set("encryption", "none") + return fmt.Sprintf("vless://%s@%s:%d?%s#%s", uid, h.Address, h.Port, q.Encode(), name) case "vmess": - return fmt.Sprintf("vmess://%s@%s:%d?type=%s&security=%s#%s", uid, host, port, network, security, name) + return fmt.Sprintf("vmess://%s@%s:%d?%s#%s", uid, h.Address, h.Port, q.Encode(), name) case "trojan": - return fmt.Sprintf("trojan://%s@%s:%d?type=%s&security=%s#%s", uid, host, port, network, security, name) + pass := uid + if h.Password != "" { + pass = h.Password + } + return fmt.Sprintf("trojan://%s@%s:%d?%s#%s", pass, h.Address, h.Port, q.Encode(), name) case "shadowsocks": - return fmt.Sprintf("ss://%s@%s:%d#%s", uid, host, port, name) + method := h.SSMethod + if method == "" { + method = "aes-128-gcm" + } + userInfo := base64.RawURLEncoding.EncodeToString([]byte(method + ":" + uid)) + return fmt.Sprintf("ss://%s@%s:%d#%s", userInfo, h.Address, h.Port, name) default: - return fmt.Sprintf("%s://%s@%s:%d?type=%s&security=%s#%s", code, uid, host, port, network, security, name) + return fmt.Sprintf("%s://%s@%s:%d?%s#%s", h.ProtocolCode, uid, h.Address, h.Port, q.Encode(), name) } } diff --git a/internal/db/db.go b/internal/db/db.go index fcd1597..f067010 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -116,6 +116,19 @@ CREATE TABLE IF NOT EXISTS inbounds ( enabled BOOLEAN NOT NULL DEFAULT TRUE, sort_order INT NOT NULL DEFAULT 0, remark TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL DEFAULT '', + host TEXT NOT NULL DEFAULT '', + sni TEXT NOT NULL DEFAULT '', + fingerprint TEXT NOT NULL DEFAULT 'chrome', + flow TEXT NOT NULL DEFAULT '', + alpn TEXT NOT NULL DEFAULT '', + reality_dest TEXT NOT NULL DEFAULT '', + reality_server_names TEXT NOT NULL DEFAULT '', + reality_private_key TEXT NOT NULL DEFAULT '', + reality_public_key TEXT NOT NULL DEFAULT '', + reality_short_id TEXT NOT NULL DEFAULT '', + ss_method TEXT NOT NULL DEFAULT 'aes-128-gcm', + password TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (profile_id, tag) @@ -160,6 +173,25 @@ CREATE TABLE IF NOT EXISTS client_inbounds ( // Additive migrations for existing deployments. _, _ = db.Exec(`ALTER TABLE nodes ADD COLUMN IF NOT EXISTS runtime_log TEXT NOT NULL DEFAULT ''`) _, _ = db.Exec(`ALTER TABLE nodes ADD COLUMN IF NOT EXISTS profile_id UUID REFERENCES config_profiles(id) ON DELETE SET NULL`) + for _, q := range []string{ + `ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS path TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS host TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS sni TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS fingerprint TEXT NOT NULL DEFAULT 'chrome'`, + `ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS flow TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS alpn TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS reality_dest TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS reality_server_names TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS reality_private_key TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE inbounds ADD COLUMN IF NOT EXISTS reality_public_key TEXT NOT NULL DEFAULT ''`, + `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 ''`, + } { + if _, err := db.Exec(q); err != nil { + return err + } + } return nil } diff --git a/internal/db/node_protocols.go b/internal/db/node_protocols.go index c8e9dc3..efe5998 100644 --- a/internal/db/node_protocols.go +++ b/internal/db/node_protocols.go @@ -4,6 +4,7 @@ import ( "database/sql" "github.com/google/uuid" + "github.com/lib/pq" "github.com/orohi/vpn-panel/internal/models" ) @@ -310,15 +311,45 @@ ORDER BY p.sort_order`, nodeID) if !in.NodeEnabled || !in.Enabled { continue } + clients, err := ListActiveClientsForInbound(db, in.ID) + if err != nil { + return nil, err + } + var clientList []map[string]any + for _, c := range clients { + clientList = append(clientList, map[string]any{ + "id": c.ID.String(), + "uuid": c.UUID.String(), + "email": c.Email, + "username": c.Username, + }) + } + if clientList == nil { + clientList = []map[string]any{} + } inList = append(inList, map[string]any{ - "id": in.ID.String(), - "tag": in.Tag, - "protocol": in.ProtocolCode, - "port": in.Port, - "network": in.Network, - "security": in.Security, - "listen": in.Listen, - "remark": in.Remark, + "id": in.ID.String(), + "tag": in.Tag, + "protocol": in.ProtocolCode, + "port": in.Port, + "network": in.Network, + "security": in.Security, + "listen": in.Listen, + "remark": in.Remark, + "path": in.Path, + "host": in.Host, + "sni": in.SNI, + "fingerprint": in.Fingerprint, + "flow": in.Flow, + "alpn": in.ALPN, + "reality_dest": in.RealityDest, + "reality_server_names": in.RealityServerNames, + "reality_private_key": in.RealityPrivateKey, + "reality_public_key": in.RealityPublicKey, + "reality_short_id": in.RealityShortID, + "ss_method": in.SSMethod, + "password": in.Password, + "clients": clientList, }) } if inList == nil { @@ -329,3 +360,60 @@ ORDER BY p.sort_order`, nodeID) return payload, nil } + +// ListActiveClientsForInbound returns active non-expired clients assigned to an inbound. +func ListActiveClientsForInbound(db *sql.DB, inboundID uuid.UUID) ([]models.Client, error) { + rows, err := db.Query(` +SELECT c.id, c.username, c.email, c.uuid, c.status, + c.traffic_limit_bytes, c.traffic_used_bytes, c.expire_at, c.sub_token, c.note, + c.created_at, c.updated_at, 0 +FROM clients c +JOIN client_inbounds ci ON ci.client_id = c.id AND ci.inbound_id = $1 +WHERE c.status = 'active' + AND (c.expire_at IS NULL OR c.expire_at > NOW()) + AND (c.traffic_limit_bytes = 0 OR c.traffic_used_bytes < c.traffic_limit_bytes) +ORDER BY c.username ASC`, inboundID) + if err != nil { + return nil, err + } + defer rows.Close() + var list []models.Client + for rows.Next() { + c, err := scanClient(rows) + if err != nil { + return nil, err + } + list = append(list, *c) + } + return list, rows.Err() +} + +// ListOnlineNodesByInboundIDs returns online nodes that have any of the inbounds enabled. +func ListOnlineNodesByInboundIDs(db *sql.DB, inboundIDs []uuid.UUID) ([]models.Node, error) { + if len(inboundIDs) == 0 { + return nil, nil + } + ids := make([]string, len(inboundIDs)) + for i, id := range inboundIDs { + ids[i] = id.String() + } + rows, err := db.Query(` +SELECT DISTINCT `+nodeColumns+` +FROM nodes n +JOIN node_inbounds ni ON ni.node_id = n.id AND ni.enabled = TRUE +LEFT JOIN config_profiles cp ON cp.id = n.profile_id +WHERE n.status = 'online' AND ni.inbound_id = ANY($1)`, pq.Array(ids)) + if err != nil { + return nil, err + } + defer rows.Close() + var list []models.Node + for rows.Next() { + n, err := scanNode(rows) + if err != nil { + return nil, err + } + list = append(list, *n) + } + return list, rows.Err() +} diff --git a/internal/db/profiles.go b/internal/db/profiles.go index 2083d81..dd35f79 100644 --- a/internal/db/profiles.go +++ b/internal/db/profiles.go @@ -99,6 +99,9 @@ func scanInbound(scanner interface { err := scanner.Scan( &in.ID, &in.ProfileID, &in.Tag, &in.ProtocolID, &in.ProtocolCode, &in.ProtocolName, &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, ) if err != nil { @@ -110,6 +113,9 @@ func scanInbound(scanner interface { const inboundColumns = ` 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.ss_method, i.password, i.created_at, i.updated_at` func ListInboundsByProfile(db *sql.DB, profileID uuid.UUID) ([]models.Inbound, error) { @@ -163,27 +169,57 @@ func CreateInbound(db *sql.DB, in *models.Inbound) error { if in.Listen == "" { in.Listen = "0.0.0.0" } + if in.Fingerprint == "" { + in.Fingerprint = "chrome" + } + if in.SSMethod == "" { + in.SSMethod = "aes-128-gcm" + } now := time.Now().UTC() in.CreatedAt = now in.UpdatedAt = now _, err := db.Exec(` INSERT INTO inbounds ( - id, profile_id, tag, protocol_id, port, network, security, listen, enabled, sort_order, remark, created_at, updated_at -) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, + 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 +) 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 +)`, in.ID, in.ProfileID, in.Tag, in.ProtocolID, in.Port, in.Network, in.Security, in.Listen, - in.Enabled, in.SortOrder, in.Remark, in.CreatedAt, in.UpdatedAt, + 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, ) return err } func UpdateInbound(db *sql.DB, in *models.Inbound) error { + if in.Fingerprint == "" { + in.Fingerprint = "chrome" + } + if in.SSMethod == "" { + in.SSMethod = "aes-128-gcm" + } _, 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, updated_at = NOW() + 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, + 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.SSMethod, in.Password, ) return err } @@ -270,6 +306,9 @@ ORDER BY i.sort_order ASC, i.tag ASC`, nodeID, *n.ProfileID) if err := rows.Scan( &in.ID, &in.ProfileID, &in.Tag, &in.ProtocolID, &in.ProtocolCode, &in.ProtocolName, &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.NodeEnabled, ); err != nil { return nil, err diff --git a/internal/handlers/clients.go b/internal/handlers/clients.go index 6b0356f..3503d36 100644 --- a/internal/handlers/clients.go +++ b/internal/handlers/clients.go @@ -117,6 +117,7 @@ func (s *Server) clientsCreate(w http.ResponseWriter, r *http.Request) { formErr("Не удалось создать (возможно username занят)") return } + s.syncNodesForInbounds(inboundIDs) http.Redirect(w, r, "/admin/clients/"+c.ID.String()+"?ok=created", http.StatusSeeOther) } @@ -187,11 +188,16 @@ func (s *Server) clientUpdate(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/clients/"+id.String()+"?err=username", http.StatusSeeOther) return } + oldIDs, _ := db.ListClientInboundIDs(s.DB, id) if err := db.UpdateClient(s.DB, c, parseInboundIDs(r)); err != nil { log.Printf("update client: %v", err) http.Redirect(w, r, "/admin/clients/"+id.String()+"?err=save", http.StatusSeeOther) return } + newIDs := parseInboundIDs(r) + merged := append([]uuid.UUID{}, oldIDs...) + merged = append(merged, newIDs...) + s.syncNodesForInbounds(merged) http.Redirect(w, r, "/admin/clients/"+id.String()+"?ok=saved", http.StatusSeeOther) } @@ -211,6 +217,7 @@ func (s *Server) clientToggle(w http.ResponseWriter, r *http.Request) { next = models.ClientStatusActive } _ = db.SetClientStatus(s.DB, id, next) + s.syncNodesForInbounds(c.InboundIDs) http.Redirect(w, r, "/admin/clients/"+id.String()+"?ok=status", http.StatusSeeOther) } @@ -220,6 +227,12 @@ func (s *Server) clientDelete(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid id", http.StatusBadRequest) return } + c, _ := db.GetClient(s.DB, id) + var inboundIDs []uuid.UUID + if c != nil { + inboundIDs = c.InboundIDs + } _ = db.DeleteClient(s.DB, id) + s.syncNodesForInbounds(inboundIDs) http.Redirect(w, r, "/admin/clients?ok=deleted", http.StatusSeeOther) } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 3b62cdc..9080305 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -88,6 +88,8 @@ func (s *Server) Routes() http.Handler { r.HandleFunc("/admin/profiles/{id}", s.Auth.RequireAuth(s.profileUpdate)).Methods(http.MethodPost) r.HandleFunc("/admin/profiles/{id}/delete", s.Auth.RequireAuth(s.profileDelete)).Methods(http.MethodPost) r.HandleFunc("/admin/profiles/{id}/inbounds", s.Auth.RequireAuth(s.inboundCreate)).Methods(http.MethodPost) + r.HandleFunc("/admin/profiles/{id}/inbounds/{inboundID}", s.Auth.RequireAuth(s.inboundEditGet)).Methods(http.MethodGet) + r.HandleFunc("/admin/profiles/{id}/inbounds/{inboundID}", s.Auth.RequireAuth(s.inboundUpdate)).Methods(http.MethodPost) r.HandleFunc("/admin/profiles/{id}/inbounds/{inboundID}/toggle", s.Auth.RequireAuth(s.inboundToggle)).Methods(http.MethodPost) r.HandleFunc("/admin/profiles/{id}/inbounds/{inboundID}/delete", s.Auth.RequireAuth(s.inboundDelete)).Methods(http.MethodPost) diff --git a/internal/handlers/profiles.go b/internal/handlers/profiles.go index 8f172ad..3c49faf 100644 --- a/internal/handlers/profiles.go +++ b/internal/handlers/profiles.go @@ -11,6 +11,7 @@ import ( "github.com/orohi/vpn-panel/internal/db" "github.com/orohi/vpn-panel/internal/models" + "github.com/orohi/vpn-panel/internal/xraykeys" ) func (s *Server) profilesList(w http.ResponseWriter, r *http.Request) { @@ -119,6 +120,95 @@ func (s *Server) profileDelete(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/profiles?ok=deleted", http.StatusSeeOther) } +func parseInboundForm(r *http.Request, profileID, protocolID uuid.UUID, proto *models.Protocol) (*models.Inbound, error) { + port, _ := strconv.Atoi(r.FormValue("port")) + if port <= 0 { + port = proto.Port + if port <= 0 { + port = 443 + } + } + tag := strings.TrimSpace(r.FormValue("tag")) + if tag == "" { + tag = strings.ToUpper(proto.Code) + "-" + strconv.Itoa(port) + } + sortOrder, _ := strconv.Atoi(r.FormValue("sort_order")) + network := r.FormValue("network") + security := r.FormValue("security") + flow := strings.TrimSpace(r.FormValue("flow")) + if security == "reality" && network == "tcp" && flow == "" && proto.Code == "vless" { + flow = "xtls-rprx-vision" + } + in := &models.Inbound{ + ProfileID: profileID, + Tag: tag, + ProtocolID: protocolID, + ProtocolCode: proto.Code, + Port: port, + Network: network, + Security: security, + Listen: strings.TrimSpace(r.FormValue("listen")), + Enabled: true, + SortOrder: sortOrder, + Remark: strings.TrimSpace(r.FormValue("remark")), + Path: strings.TrimSpace(r.FormValue("path")), + Host: strings.TrimSpace(r.FormValue("host")), + SNI: strings.TrimSpace(r.FormValue("sni")), + Fingerprint: strings.TrimSpace(r.FormValue("fingerprint")), + Flow: flow, + ALPN: strings.TrimSpace(r.FormValue("alpn")), + RealityDest: strings.TrimSpace(r.FormValue("reality_dest")), + RealityServerNames: strings.TrimSpace(r.FormValue("reality_server_names")), + RealityPrivateKey: strings.TrimSpace(r.FormValue("reality_private_key")), + RealityPublicKey: strings.TrimSpace(r.FormValue("reality_public_key")), + RealityShortID: strings.TrimSpace(r.FormValue("reality_short_id")), + SSMethod: strings.TrimSpace(r.FormValue("ss_method")), + Password: strings.TrimSpace(r.FormValue("password")), + } + if in.Security == "reality" { + priv, pub, sid, dest, names, err := xraykeys.EnsureReality( + in.RealityPrivateKey, in.RealityPublicKey, in.RealityShortID, + in.RealityDest, in.RealityServerNames, + ) + if err != nil { + return nil, err + } + in.RealityPrivateKey, in.RealityPublicKey, in.RealityShortID = priv, pub, sid + in.RealityDest, in.RealityServerNames = dest, names + if in.SNI == "" { + in.SNI = strings.Split(names, ",")[0] + } + if in.Fingerprint == "" { + in.Fingerprint = "chrome" + } + } + return in, nil +} + +func (s *Server) syncProfileNodes(profileID uuid.UUID) { + nodes, _ := db.ListNodes(s.DB) + for _, n := range nodes { + if n.ProfileID != nil && *n.ProfileID == profileID { + _ = db.ApplyProfileInboundsToNodeProtocols(s.DB, n.ID) + if n.Status == models.NodeStatusOnline { + nn := n + _ = s.syncNodeConfig(&nn) + } + } + } +} + +func (s *Server) syncNodesForInbounds(inboundIDs []uuid.UUID) { + nodes, err := db.ListOnlineNodesByInboundIDs(s.DB, inboundIDs) + if err != nil { + log.Printf("list nodes for inbounds: %v", err) + return + } + for i := range nodes { + _ = s.syncNodeConfig(&nodes[i]) + } +} + func (s *Server) inboundCreate(w http.ResponseWriter, r *http.Request) { profileID, err := uuid.Parse(mux.Vars(r)["id"]) if err != nil { @@ -136,37 +226,17 @@ func (s *Server) inboundCreate(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?err=protocol", http.StatusSeeOther) return } - port, _ := strconv.Atoi(r.FormValue("port")) - if port <= 0 { - port = proto.Port - if port <= 0 { - port = 443 - } - } - tag := strings.TrimSpace(r.FormValue("tag")) - if tag == "" { - tag = strings.ToUpper(proto.Code) + "-" + strconv.Itoa(port) - } - sortOrder, _ := strconv.Atoi(r.FormValue("sort_order")) - in := &models.Inbound{ - ProfileID: profileID, - Tag: tag, - ProtocolID: protocolID, - ProtocolCode: proto.Code, - Port: port, - Network: r.FormValue("network"), - Security: r.FormValue("security"), - Listen: strings.TrimSpace(r.FormValue("listen")), - Enabled: true, - SortOrder: sortOrder, - Remark: strings.TrimSpace(r.FormValue("remark")), + in, err := parseInboundForm(r, profileID, protocolID, proto) + if err != nil { + log.Printf("inbound form: %v", err) + http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?err=inbound", http.StatusSeeOther) + return } if err := db.CreateInbound(s.DB, in); err != nil { log.Printf("create inbound: %v", err) http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?err=inbound", http.StatusSeeOther) return } - // Enable on all nodes already using this profile. nodes, _ := db.ListNodes(s.DB) for _, n := range nodes { if n.ProfileID != nil && *n.ProfileID == profileID { @@ -180,6 +250,99 @@ func (s *Server) inboundCreate(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?ok=inbound_added", http.StatusSeeOther) } +func (s *Server) inboundEditGet(w http.ResponseWriter, r *http.Request) { + profileID, err := uuid.Parse(mux.Vars(r)["id"]) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + inboundID, err := uuid.Parse(mux.Vars(r)["inboundID"]) + if err != nil { + http.Error(w, "invalid inbound", http.StatusBadRequest) + return + } + in, err := db.GetInbound(s.DB, inboundID) + if err != nil || in == nil || in.ProfileID != profileID { + http.NotFound(w, r) + return + } + p, err := db.GetProfile(s.DB, profileID) + if err != nil || p == nil { + http.NotFound(w, r) + return + } + protocols, _ := db.ListProtocols(s.DB) + s.render(w, "inbound_edit.html", pageData{ + "Title": "Inbound " + in.Tag, + "UserName": s.Auth.CurrentName(r), + "Active": "profiles", + "Profile": p, + "Inbound": in, + "Protocols": protocols, + "Flash": r.URL.Query().Get("ok"), + "Error": r.URL.Query().Get("err"), + }) +} + +func (s *Server) inboundUpdate(w http.ResponseWriter, r *http.Request) { + profileID, err := uuid.Parse(mux.Vars(r)["id"]) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + inboundID, err := uuid.Parse(mux.Vars(r)["inboundID"]) + if err != nil { + http.Error(w, "invalid inbound", http.StatusBadRequest) + return + } + existing, err := db.GetInbound(s.DB, inboundID) + if err != nil || existing == nil || existing.ProfileID != profileID { + http.NotFound(w, r) + return + } + _ = r.ParseForm() + protocolID, err := uuid.Parse(r.FormValue("protocol_id")) + if err != nil { + http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"/inbounds/"+inboundID.String()+"?err=protocol", http.StatusSeeOther) + return + } + proto, err := db.GetProtocol(s.DB, protocolID) + if err != nil || proto == nil { + http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"/inbounds/"+inboundID.String()+"?err=protocol", http.StatusSeeOther) + return + } + in, err := parseInboundForm(r, profileID, protocolID, proto) + if err != nil { + http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"/inbounds/"+inboundID.String()+"?err=keys", http.StatusSeeOther) + return + } + in.ID = inboundID + in.Enabled = false + for _, v := range r.Form["enabled"] { + if v == "on" || v == "true" { + in.Enabled = true + } + } + if in.Security == "reality" { + if strings.TrimSpace(r.FormValue("reality_private_key")) == "" { + in.RealityPrivateKey = existing.RealityPrivateKey + } + if strings.TrimSpace(r.FormValue("reality_public_key")) == "" { + in.RealityPublicKey = existing.RealityPublicKey + } + if strings.TrimSpace(r.FormValue("reality_short_id")) == "" && existing.RealityShortID != "" { + in.RealityShortID = existing.RealityShortID + } + } + if err := db.UpdateInbound(s.DB, in); err != nil { + log.Printf("update inbound: %v", err) + http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"/inbounds/"+inboundID.String()+"?err=save", http.StatusSeeOther) + return + } + s.syncProfileNodes(profileID) + http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"/inbounds/"+inboundID.String()+"?ok=saved", http.StatusSeeOther) +} + func (s *Server) inboundDelete(w http.ResponseWriter, r *http.Request) { profileID, err := uuid.Parse(mux.Vars(r)["id"]) if err != nil { @@ -192,16 +355,7 @@ func (s *Server) inboundDelete(w http.ResponseWriter, r *http.Request) { return } _ = db.DeleteInbound(s.DB, inboundID) - nodes, _ := db.ListNodes(s.DB) - for _, n := range nodes { - if n.ProfileID != nil && *n.ProfileID == profileID { - _ = db.ApplyProfileInboundsToNodeProtocols(s.DB, n.ID) - if n.Status == models.NodeStatusOnline { - nn := n - _ = s.syncNodeConfig(&nn) - } - } - } + s.syncProfileNodes(profileID) http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?ok=inbound_deleted", http.StatusSeeOther) } @@ -217,6 +371,7 @@ func (s *Server) inboundToggle(w http.ResponseWriter, r *http.Request) { return } _ = db.ToggleInbound(s.DB, inboundID) + s.syncProfileNodes(profileID) http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?ok=inbound_toggled", http.StatusSeeOther) } diff --git a/internal/models/models.go b/internal/models/models.go index ec508d9..4e5fc30 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -140,8 +140,24 @@ type Inbound struct { Enabled bool `json:"enabled"` // definition enabled in profile SortOrder int `json:"sort_order"` Remark string `json:"remark"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + + // Transport / TLS / Reality / SS settings + Path string `json:"path"` + Host string `json:"host"` + SNI string `json:"sni"` + Fingerprint string `json:"fingerprint"` + Flow string `json:"flow"` + ALPN string `json:"alpn"` + RealityDest string `json:"reality_dest"` + RealityServerNames string `json:"reality_server_names"` + RealityPrivateKey string `json:"reality_private_key"` + RealityPublicKey string `json:"reality_public_key"` + RealityShortID string `json:"reality_short_id"` + SSMethod string `json:"ss_method"` + Password string `json:"password"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // Per-node activation when listed under a node NodeEnabled bool `json:"node_enabled,omitempty"` @@ -236,6 +252,16 @@ type SubHost struct { Port int `json:"port"` Network string `json:"network"` Security string `json:"security"` + Path string `json:"path,omitempty"` + HostHeader string `json:"host_header,omitempty"` + SNI string `json:"sni,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Flow string `json:"flow,omitempty"` + ALPN string `json:"alpn,omitempty"` + PublicKey string `json:"public_key,omitempty"` + ShortID string `json:"short_id,omitempty"` + SSMethod string `json:"ss_method,omitempty"` + Password string `json:"password,omitempty"` Address string `json:"address"` NodeName string `json:"node_name"` NodeOnline bool `json:"node_online"` diff --git a/internal/nodeinstall/compose.go b/internal/nodeinstall/compose.go index 9dae669..5e7bd6d 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.1.0" +const AgentVersion = "0.2.0" func ComposeYAML(n *models.Node) string { return fmt.Sprintf(`services: @@ -21,10 +21,13 @@ func ComposeYAML(n *models.Node) string { NODE_PORT: %q SECRET_KEY: %q AGENT_VERSION: %q + DATA_DIR: /var/lib/vpn-node + HOST_DATA_DIR: /opt/vpn-panel-node/data volumes: - ./vpn-node:/usr/local/bin/vpn-node:ro - ./data:/var/lib/vpn-node - command: ["/usr/local/bin/vpn-node"] + - /var/run/docker.sock:/var/run/docker.sock + command: ["sh", "-c", "apk add --no-cache docker-cli >/dev/null && exec /usr/local/bin/vpn-node"] `, n.Name, fmt.Sprintf("%d", n.NodePort), n.SecretKey, AgentVersion) } diff --git a/internal/xraykeys/reality.go b/internal/xraykeys/reality.go new file mode 100644 index 0000000..c15010d --- /dev/null +++ b/internal/xraykeys/reality.go @@ -0,0 +1,62 @@ +package xraykeys + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + + "golang.org/x/crypto/curve25519" +) + +// GenerateRealityKeyPair returns Xray-compatible Reality private/public keys +// (raw URL-safe base64 without padding, same as `xray x25519`). +func GenerateRealityKeyPair() (privateKey, publicKey string, err error) { + var priv [32]byte + if _, err = rand.Read(priv[:]); err != nil { + return "", "", err + } + priv[0] &= 248 + priv[31] &= 127 + priv[31] |= 64 + + var pub [32]byte + curve25519.ScalarBaseMult(&pub, &priv) + + privateKey = base64.RawURLEncoding.EncodeToString(priv[:]) + publicKey = base64.RawURLEncoding.EncodeToString(pub[:]) + return privateKey, publicKey, nil +} + +// GenerateShortID returns a random Reality shortId (1–16 hex chars). +func GenerateShortID() (string, error) { + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// EnsureReality fills missing Reality keys/shortId on an inbound-like struct. +func EnsureReality(privateKey, publicKey, shortID, dest, serverNames string) (priv, pub, sid, d, names string, err error) { + priv, pub, sid, d, names = privateKey, publicKey, shortID, dest, serverNames + if d == "" { + d = "www.microsoft.com:443" + } + if names == "" { + names = "www.microsoft.com" + } + if priv == "" || pub == "" { + priv, pub, err = GenerateRealityKeyPair() + if err != nil { + return "", "", "", "", "", fmt.Errorf("reality keys: %w", err) + } + } + if sid == "" { + sid, err = GenerateShortID() + if err != nil { + return "", "", "", "", "", fmt.Errorf("reality short id: %w", err) + } + } + return priv, pub, sid, d, names, nil +} diff --git a/web/static/css/app.css b/web/static/css/app.css index a16d3f0..abdbfbb 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -456,6 +456,21 @@ textarea { resize: vertical; font-family: var(--mono); font-size: 0.85rem; } max-width: 360px; } .actions { width: 1%; white-space: nowrap; } +.section-sub { + margin: 1.25rem 0 0.35rem; + font-size: 1rem; + font-weight: 600; +} +.check-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 1rem 0; +} +input.mono, .mono { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 0.85rem; +} @keyframes rise { from { opacity: 0; transform: translateY(12px); } diff --git a/web/templates/inbound_edit.html b/web/templates/inbound_edit.html new file mode 100644 index 0000000..07b7fa0 --- /dev/null +++ b/web/templates/inbound_edit.html @@ -0,0 +1,141 @@ + + + + + + {{.Title}} · VPN Panel + + + + + + + + +
+
+
+

Inbound {{.Inbound.Tag}}

+

Профиль {{.Profile.Name}}

+
+ К профилю +
+ + {{if .Flash}}
{{.Flash}}
{{end}} + {{if .Error}}
{{.Error}}
{{end}} + +
+
+
+ + + + + + + + + + + + + + + + +
+ +

Reality

+

Пустые ключи сгенерируются автоматически при security=reality.

+
+ + + + + +
+ + + +
+ + Отмена +
+
+
+
+ + diff --git a/web/templates/profile_view.html b/web/templates/profile_view.html index 3edee63..2c4a346 100644 --- a/web/templates/profile_view.html +++ b/web/templates/profile_view.html @@ -81,6 +81,7 @@ {{.Security}} {{if .Enabled}}ON{{else}}OFF{{end}} + Изменить
@@ -130,16 +131,44 @@ + + + + + + + + + +

Для Reality ключи и shortId генерируются автоматически. После назначения профиля ноде Xray поднимется на агенте.