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
+13
View File
@@ -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)
}
+2
View File
@@ -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)
+190 -35
View File
@@ -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)
}
+28 -2
View File
@@ -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"`
+5 -2
View File
@@ -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)
}
+62
View File
@@ -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 (116 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
}