Apply inbounds on nodes via Xray with Reality settings and client sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-29 05:11:45 +03:00
co-authored by Cursor
parent c41bae9852
commit 20a20168b6
15 changed files with 1081 additions and 63 deletions
+52 -9
View File
@@ -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)
}
}
+32
View File
@@ -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
}
+96 -8
View File
@@ -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()
}
+43 -4
View File
@@ -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