Apply inbounds on nodes via Xray with Reality settings and client sync.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+15
-2
@@ -13,7 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultVersion = "0.1.0"
|
const defaultVersion = "0.2.0"
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
name string
|
name string
|
||||||
@@ -54,6 +54,7 @@ func main() {
|
|||||||
config: map[string]any{},
|
config: map[string]any{},
|
||||||
}
|
}
|
||||||
a.loadConfig(filepath.Join(dataDir, "config.json"))
|
a.loadConfig(filepath.Join(dataDir, "config.json"))
|
||||||
|
a.applyXray(a.config)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/health", a.auth(a.health))
|
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.config = cfg
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
_ = a.saveConfig(filepath.Join(a.dataDir, "config.json"))
|
_ = 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})
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
default:
|
default:
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func anySlice(v any) []any {
|
||||||
|
if arr, ok := v.([]any); ok {
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+52
-9
@@ -2,6 +2,7 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
@@ -228,6 +229,8 @@ func ClientSubscriptionHosts(db *sql.DB, client *models.Client) ([]models.SubHos
|
|||||||
}
|
}
|
||||||
rows, err := db.Query(`
|
rows, err := db.Query(`
|
||||||
SELECT i.id, i.tag, i.remark, p.code, p.name, i.port, i.network, i.security,
|
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.host, ''), COALESCE(n.name, ''),
|
||||||
COALESCE(n.status = 'online', FALSE),
|
COALESCE(n.status = 'online', FALSE),
|
||||||
COALESCE(ni.enabled AND 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
|
var h models.SubHost
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&h.InboundID, &h.Tag, &h.Remark, &h.ProtocolCode, &h.ProtocolName,
|
&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,
|
&h.NodeOnline, &h.Available,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
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
|
seen[key] = true
|
||||||
|
|
||||||
if h.Available && h.Address != "" {
|
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)
|
hosts = append(hosts, h)
|
||||||
}
|
}
|
||||||
return hosts, rows.Err()
|
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)
|
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":
|
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":
|
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":
|
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":
|
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:
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,19 @@ CREATE TABLE IF NOT EXISTS inbounds (
|
|||||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
sort_order INT NOT NULL DEFAULT 0,
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
remark TEXT NOT NULL DEFAULT '',
|
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(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
UNIQUE (profile_id, tag)
|
UNIQUE (profile_id, tag)
|
||||||
@@ -160,6 +173,25 @@ CREATE TABLE IF NOT EXISTS client_inbounds (
|
|||||||
// Additive migrations for existing deployments.
|
// 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 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`)
|
_, _ = 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/lib/pq"
|
||||||
|
|
||||||
"github.com/orohi/vpn-panel/internal/models"
|
"github.com/orohi/vpn-panel/internal/models"
|
||||||
)
|
)
|
||||||
@@ -310,6 +311,22 @@ ORDER BY p.sort_order`, nodeID)
|
|||||||
if !in.NodeEnabled || !in.Enabled {
|
if !in.NodeEnabled || !in.Enabled {
|
||||||
continue
|
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{
|
inList = append(inList, map[string]any{
|
||||||
"id": in.ID.String(),
|
"id": in.ID.String(),
|
||||||
"tag": in.Tag,
|
"tag": in.Tag,
|
||||||
@@ -319,6 +336,20 @@ ORDER BY p.sort_order`, nodeID)
|
|||||||
"security": in.Security,
|
"security": in.Security,
|
||||||
"listen": in.Listen,
|
"listen": in.Listen,
|
||||||
"remark": in.Remark,
|
"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 {
|
if inList == nil {
|
||||||
@@ -329,3 +360,60 @@ ORDER BY p.sort_order`, nodeID)
|
|||||||
|
|
||||||
return payload, nil
|
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
@@ -99,6 +99,9 @@ func scanInbound(scanner interface {
|
|||||||
err := scanner.Scan(
|
err := scanner.Scan(
|
||||||
&in.ID, &in.ProfileID, &in.Tag, &in.ProtocolID, &in.ProtocolCode, &in.ProtocolName,
|
&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.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.CreatedAt, &in.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,6 +113,9 @@ func scanInbound(scanner interface {
|
|||||||
const inboundColumns = `
|
const inboundColumns = `
|
||||||
i.id, i.profile_id, i.tag, i.protocol_id, p.code, p.name,
|
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.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`
|
i.created_at, i.updated_at`
|
||||||
|
|
||||||
func ListInboundsByProfile(db *sql.DB, profileID uuid.UUID) ([]models.Inbound, error) {
|
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 == "" {
|
if in.Listen == "" {
|
||||||
in.Listen = "0.0.0.0"
|
in.Listen = "0.0.0.0"
|
||||||
}
|
}
|
||||||
|
if in.Fingerprint == "" {
|
||||||
|
in.Fingerprint = "chrome"
|
||||||
|
}
|
||||||
|
if in.SSMethod == "" {
|
||||||
|
in.SSMethod = "aes-128-gcm"
|
||||||
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
in.CreatedAt = now
|
in.CreatedAt = now
|
||||||
in.UpdatedAt = now
|
in.UpdatedAt = now
|
||||||
_, err := db.Exec(`
|
_, err := db.Exec(`
|
||||||
INSERT INTO inbounds (
|
INSERT INTO inbounds (
|
||||||
id, profile_id, tag, protocol_id, port, network, security, listen, enabled, sort_order, remark, created_at, updated_at
|
id, profile_id, tag, protocol_id, port, network, security, listen, enabled, sort_order, remark,
|
||||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
|
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.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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateInbound(db *sql.DB, in *models.Inbound) error {
|
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(`
|
_, err := db.Exec(`
|
||||||
UPDATE inbounds SET
|
UPDATE inbounds SET
|
||||||
tag = $2, protocol_id = $3, port = $4, network = $5, security = $6,
|
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`,
|
WHERE id = $1`,
|
||||||
in.ID, in.Tag, in.ProtocolID, in.Port, in.Network, in.Security,
|
in.ID, in.Tag, in.ProtocolID, in.Port, in.Network, in.Security,
|
||||||
in.Listen, in.Enabled, in.SortOrder, in.Remark,
|
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
|
return err
|
||||||
}
|
}
|
||||||
@@ -270,6 +306,9 @@ ORDER BY i.sort_order ASC, i.tag ASC`, nodeID, *n.ProfileID)
|
|||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&in.ID, &in.ProfileID, &in.Tag, &in.ProtocolID, &in.ProtocolCode, &in.ProtocolName,
|
&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.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,
|
&in.CreatedAt, &in.UpdatedAt, &in.NodeEnabled,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ func (s *Server) clientsCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
formErr("Не удалось создать (возможно username занят)")
|
formErr("Не удалось создать (возможно username занят)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.syncNodesForInbounds(inboundIDs)
|
||||||
http.Redirect(w, r, "/admin/clients/"+c.ID.String()+"?ok=created", http.StatusSeeOther)
|
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)
|
http.Redirect(w, r, "/admin/clients/"+id.String()+"?err=username", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
oldIDs, _ := db.ListClientInboundIDs(s.DB, id)
|
||||||
if err := db.UpdateClient(s.DB, c, parseInboundIDs(r)); err != nil {
|
if err := db.UpdateClient(s.DB, c, parseInboundIDs(r)); err != nil {
|
||||||
log.Printf("update client: %v", err)
|
log.Printf("update client: %v", err)
|
||||||
http.Redirect(w, r, "/admin/clients/"+id.String()+"?err=save", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/clients/"+id.String()+"?err=save", http.StatusSeeOther)
|
||||||
return
|
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)
|
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
|
next = models.ClientStatusActive
|
||||||
}
|
}
|
||||||
_ = db.SetClientStatus(s.DB, id, next)
|
_ = db.SetClientStatus(s.DB, id, next)
|
||||||
|
s.syncNodesForInbounds(c.InboundIDs)
|
||||||
http.Redirect(w, r, "/admin/clients/"+id.String()+"?ok=status", http.StatusSeeOther)
|
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)
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
c, _ := db.GetClient(s.DB, id)
|
||||||
|
var inboundIDs []uuid.UUID
|
||||||
|
if c != nil {
|
||||||
|
inboundIDs = c.InboundIDs
|
||||||
|
}
|
||||||
_ = db.DeleteClient(s.DB, id)
|
_ = db.DeleteClient(s.DB, id)
|
||||||
|
s.syncNodesForInbounds(inboundIDs)
|
||||||
http.Redirect(w, r, "/admin/clients?ok=deleted", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/clients?ok=deleted", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}", 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}/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", 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}/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)
|
r.HandleFunc("/admin/profiles/{id}/inbounds/{inboundID}/delete", s.Auth.RequireAuth(s.inboundDelete)).Methods(http.MethodPost)
|
||||||
|
|
||||||
|
|||||||
+190
-35
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/orohi/vpn-panel/internal/db"
|
"github.com/orohi/vpn-panel/internal/db"
|
||||||
"github.com/orohi/vpn-panel/internal/models"
|
"github.com/orohi/vpn-panel/internal/models"
|
||||||
|
"github.com/orohi/vpn-panel/internal/xraykeys"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) profilesList(w http.ResponseWriter, r *http.Request) {
|
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)
|
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) {
|
func (s *Server) inboundCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
profileID, err := uuid.Parse(mux.Vars(r)["id"])
|
profileID, err := uuid.Parse(mux.Vars(r)["id"])
|
||||||
if err != nil {
|
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)
|
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?err=protocol", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
port, _ := strconv.Atoi(r.FormValue("port"))
|
in, err := parseInboundForm(r, profileID, protocolID, proto)
|
||||||
if port <= 0 {
|
if err != nil {
|
||||||
port = proto.Port
|
log.Printf("inbound form: %v", err)
|
||||||
if port <= 0 {
|
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?err=inbound", http.StatusSeeOther)
|
||||||
port = 443
|
return
|
||||||
}
|
|
||||||
}
|
|
||||||
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")),
|
|
||||||
}
|
}
|
||||||
if err := db.CreateInbound(s.DB, in); err != nil {
|
if err := db.CreateInbound(s.DB, in); err != nil {
|
||||||
log.Printf("create inbound: %v", err)
|
log.Printf("create inbound: %v", err)
|
||||||
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?err=inbound", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?err=inbound", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Enable on all nodes already using this profile.
|
|
||||||
nodes, _ := db.ListNodes(s.DB)
|
nodes, _ := db.ListNodes(s.DB)
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
if n.ProfileID != nil && *n.ProfileID == profileID {
|
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)
|
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) {
|
func (s *Server) inboundDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
profileID, err := uuid.Parse(mux.Vars(r)["id"])
|
profileID, err := uuid.Parse(mux.Vars(r)["id"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -192,16 +355,7 @@ func (s *Server) inboundDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = db.DeleteInbound(s.DB, inboundID)
|
_ = db.DeleteInbound(s.DB, inboundID)
|
||||||
nodes, _ := db.ListNodes(s.DB)
|
s.syncProfileNodes(profileID)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?ok=inbound_deleted", http.StatusSeeOther)
|
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
|
return
|
||||||
}
|
}
|
||||||
_ = db.ToggleInbound(s.DB, inboundID)
|
_ = db.ToggleInbound(s.DB, inboundID)
|
||||||
|
s.syncProfileNodes(profileID)
|
||||||
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?ok=inbound_toggled", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/profiles/"+profileID.String()+"?ok=inbound_toggled", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,22 @@ type Inbound struct {
|
|||||||
Enabled bool `json:"enabled"` // definition enabled in profile
|
Enabled bool `json:"enabled"` // definition enabled in profile
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
|
|
||||||
|
// 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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
@@ -236,6 +252,16 @@ type SubHost struct {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Network string `json:"network"`
|
Network string `json:"network"`
|
||||||
Security string `json:"security"`
|
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"`
|
Address string `json:"address"`
|
||||||
NodeName string `json:"node_name"`
|
NodeName string `json:"node_name"`
|
||||||
NodeOnline bool `json:"node_online"`
|
NodeOnline bool `json:"node_online"`
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"github.com/orohi/vpn-panel/internal/models"
|
"github.com/orohi/vpn-panel/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
const AgentVersion = "0.1.0"
|
const AgentVersion = "0.2.0"
|
||||||
|
|
||||||
func ComposeYAML(n *models.Node) string {
|
func ComposeYAML(n *models.Node) string {
|
||||||
return fmt.Sprintf(`services:
|
return fmt.Sprintf(`services:
|
||||||
@@ -21,10 +21,13 @@ func ComposeYAML(n *models.Node) string {
|
|||||||
NODE_PORT: %q
|
NODE_PORT: %q
|
||||||
SECRET_KEY: %q
|
SECRET_KEY: %q
|
||||||
AGENT_VERSION: %q
|
AGENT_VERSION: %q
|
||||||
|
DATA_DIR: /var/lib/vpn-node
|
||||||
|
HOST_DATA_DIR: /opt/vpn-panel-node/data
|
||||||
volumes:
|
volumes:
|
||||||
- ./vpn-node:/usr/local/bin/vpn-node:ro
|
- ./vpn-node:/usr/local/bin/vpn-node:ro
|
||||||
- ./data:/var/lib/vpn-node
|
- ./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)
|
`, n.Name, fmt.Sprintf("%d", n.NodePort), n.SecretKey, AgentVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -456,6 +456,21 @@ textarea { resize: vertical; font-family: var(--mono); font-size: 0.85rem; }
|
|||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
}
|
}
|
||||||
.actions { width: 1%; white-space: nowrap; }
|
.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 {
|
@keyframes rise {
|
||||||
from { opacity: 0; transform: translateY(12px); }
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}} · VPN Panel</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-admin">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<a class="brand" href="/admin">VPN Panel</a>
|
||||||
|
<nav>
|
||||||
|
<a href="/admin">Обзор</a>
|
||||||
|
<a href="/admin/clients">Пользователи</a>
|
||||||
|
<a href="/admin/nodes">Ноды</a>
|
||||||
|
<a class="active" href="/admin/profiles">Профили</a>
|
||||||
|
<a href="/admin/protocols">Протоколы</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-foot">
|
||||||
|
<span class="user-chip">{{.UserName}}</span>
|
||||||
|
<form method="POST" action="/logout"><button type="submit" class="link-btn">Выйти</button></form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="admin-main">
|
||||||
|
<header class="admin-header row-head">
|
||||||
|
<div>
|
||||||
|
<h1>Inbound <code>{{.Inbound.Tag}}</code></h1>
|
||||||
|
<p class="muted">Профиль {{.Profile.Name}}</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-ghost" href="/admin/profiles/{{.Profile.ID}}">К профилю</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
|
||||||
|
{{if .Error}}<div class="alert">{{.Error}}</div>{{end}}
|
||||||
|
|
||||||
|
<section class="panel-section">
|
||||||
|
<form class="form-card" method="POST" action="/admin/profiles/{{.Profile.ID}}/inbounds/{{.Inbound.ID}}">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Протокол
|
||||||
|
<select name="protocol_id" required>
|
||||||
|
{{range .Protocols}}
|
||||||
|
<option value="{{.ID}}" {{if eq .ID $.Inbound.ProtocolID}}selected{{end}}>{{.Name}} ({{.Code}})</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Tag
|
||||||
|
<input name="tag" required value="{{.Inbound.Tag}}">
|
||||||
|
</label>
|
||||||
|
<label>Порт
|
||||||
|
<input name="port" type="number" value="{{.Inbound.Port}}">
|
||||||
|
</label>
|
||||||
|
<label>Listen
|
||||||
|
<input name="listen" value="{{.Inbound.Listen}}">
|
||||||
|
</label>
|
||||||
|
<label>Network
|
||||||
|
<select name="network">
|
||||||
|
<option value="tcp" {{if eq .Inbound.Network "tcp"}}selected{{end}}>tcp</option>
|
||||||
|
<option value="ws" {{if eq .Inbound.Network "ws"}}selected{{end}}>ws</option>
|
||||||
|
<option value="grpc" {{if eq .Inbound.Network "grpc"}}selected{{end}}>grpc</option>
|
||||||
|
<option value="quic" {{if eq .Inbound.Network "quic"}}selected{{end}}>quic</option>
|
||||||
|
<option value="udp" {{if eq .Inbound.Network "udp"}}selected{{end}}>udp</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Security
|
||||||
|
<select name="security">
|
||||||
|
<option value="none" {{if eq .Inbound.Security "none"}}selected{{end}}>none</option>
|
||||||
|
<option value="tls" {{if eq .Inbound.Security "tls"}}selected{{end}}>tls</option>
|
||||||
|
<option value="reality" {{if eq .Inbound.Security "reality"}}selected{{end}}>reality</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Path / serviceName
|
||||||
|
<input name="path" value="{{.Inbound.Path}}" placeholder="/ws или grpc service">
|
||||||
|
</label>
|
||||||
|
<label>Host header
|
||||||
|
<input name="host" value="{{.Inbound.Host}}">
|
||||||
|
</label>
|
||||||
|
<label>SNI
|
||||||
|
<input name="sni" value="{{.Inbound.SNI}}">
|
||||||
|
</label>
|
||||||
|
<label>Fingerprint
|
||||||
|
<input name="fingerprint" value="{{.Inbound.Fingerprint}}" placeholder="chrome">
|
||||||
|
</label>
|
||||||
|
<label>Flow
|
||||||
|
<input name="flow" value="{{.Inbound.Flow}}" placeholder="xtls-rprx-vision">
|
||||||
|
</label>
|
||||||
|
<label>ALPN
|
||||||
|
<input name="alpn" value="{{.Inbound.ALPN}}" placeholder="h2,http/1.1">
|
||||||
|
</label>
|
||||||
|
<label>Порядок
|
||||||
|
<input name="sort_order" type="number" value="{{.Inbound.SortOrder}}">
|
||||||
|
</label>
|
||||||
|
<label>Заметка
|
||||||
|
<input name="remark" value="{{.Inbound.Remark}}">
|
||||||
|
</label>
|
||||||
|
<label>SS method
|
||||||
|
<input name="ss_method" value="{{.Inbound.SSMethod}}" placeholder="aes-128-gcm">
|
||||||
|
</label>
|
||||||
|
<label>Password (trojan shared, optional)
|
||||||
|
<input name="password" value="{{.Inbound.Password}}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-sub">Reality</h3>
|
||||||
|
<p class="muted">Пустые ключи сгенерируются автоматически при security=reality.</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Dest
|
||||||
|
<input name="reality_dest" value="{{.Inbound.RealityDest}}" placeholder="www.microsoft.com:443">
|
||||||
|
</label>
|
||||||
|
<label>Server names
|
||||||
|
<input name="reality_server_names" value="{{.Inbound.RealityServerNames}}" placeholder="www.microsoft.com">
|
||||||
|
</label>
|
||||||
|
<label>Private key
|
||||||
|
<input name="reality_private_key" value="{{.Inbound.RealityPrivateKey}}" class="mono">
|
||||||
|
</label>
|
||||||
|
<label>Public key (для клиентов)
|
||||||
|
<input name="reality_public_key" value="{{.Inbound.RealityPublicKey}}" class="mono">
|
||||||
|
</label>
|
||||||
|
<label>Short ID
|
||||||
|
<input name="reality_short_id" value="{{.Inbound.RealityShortID}}" class="mono">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="hidden" name="enabled" value="off">
|
||||||
|
<input type="checkbox" name="enabled" value="on" {{if .Inbound.Enabled}}checked{{end}}>
|
||||||
|
Включён в профиле
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="cta-row">
|
||||||
|
<button class="btn btn-primary" type="submit">Сохранить</button>
|
||||||
|
<a class="btn btn-ghost" href="/admin/profiles/{{.Profile.ID}}">Отмена</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -81,6 +81,7 @@
|
|||||||
<td>{{.Security}}</td>
|
<td>{{.Security}}</td>
|
||||||
<td><span class="badge {{if .Enabled}}on{{else}}off{{end}}">{{if .Enabled}}ON{{else}}OFF{{end}}</span></td>
|
<td><span class="badge {{if .Enabled}}on{{else}}off{{end}}">{{if .Enabled}}ON{{else}}OFF{{end}}</span></td>
|
||||||
<td class="actions actions-multi">
|
<td class="actions actions-multi">
|
||||||
|
<a class="btn btn-sm btn-ghost" href="/admin/profiles/{{$.Profile.ID}}/inbounds/{{.ID}}">Изменить</a>
|
||||||
<form method="POST" action="/admin/profiles/{{$.Profile.ID}}/inbounds/{{.ID}}/toggle">
|
<form method="POST" action="/admin/profiles/{{$.Profile.ID}}/inbounds/{{.ID}}/toggle">
|
||||||
<button class="btn btn-sm btn-ghost" type="submit">{{if .Enabled}}Выкл{{else}}Вкл{{end}}</button>
|
<button class="btn btn-sm btn-ghost" type="submit">{{if .Enabled}}Выкл{{else}}Вкл{{end}}</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -130,16 +131,44 @@
|
|||||||
<select name="security">
|
<select name="security">
|
||||||
<option value="none">none</option>
|
<option value="none">none</option>
|
||||||
<option value="tls">tls</option>
|
<option value="tls">tls</option>
|
||||||
<option value="reality">reality</option>
|
<option value="reality" selected>reality</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>Path / serviceName
|
||||||
|
<input name="path" placeholder="/ws или grpc service">
|
||||||
|
</label>
|
||||||
|
<label>Host header
|
||||||
|
<input name="host" placeholder="для ws">
|
||||||
|
</label>
|
||||||
|
<label>SNI
|
||||||
|
<input name="sni" placeholder="авто из reality names">
|
||||||
|
</label>
|
||||||
|
<label>Fingerprint
|
||||||
|
<input name="fingerprint" value="chrome">
|
||||||
|
</label>
|
||||||
|
<label>Flow
|
||||||
|
<input name="flow" placeholder="xtls-rprx-vision для VLESS+Reality">
|
||||||
|
</label>
|
||||||
|
<label>ALPN
|
||||||
|
<input name="alpn" placeholder="h2,http/1.1">
|
||||||
|
</label>
|
||||||
<label>Порядок
|
<label>Порядок
|
||||||
<input name="sort_order" type="number" value="0">
|
<input name="sort_order" type="number" value="0">
|
||||||
</label>
|
</label>
|
||||||
<label>Заметка
|
<label>Заметка
|
||||||
<input name="remark" placeholder="опционально">
|
<input name="remark" placeholder="опционально">
|
||||||
</label>
|
</label>
|
||||||
|
<label>Reality dest
|
||||||
|
<input name="reality_dest" placeholder="www.microsoft.com:443">
|
||||||
|
</label>
|
||||||
|
<label>Reality server names
|
||||||
|
<input name="reality_server_names" placeholder="www.microsoft.com">
|
||||||
|
</label>
|
||||||
|
<label>SS method
|
||||||
|
<input name="ss_method" value="aes-128-gcm">
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="muted">Для Reality ключи и shortId генерируются автоматически. После назначения профиля ноде Xray поднимется на агенте.</p>
|
||||||
<button class="btn btn-primary" type="submit">Добавить inbound</button>
|
<button class="btn btn-primary" type="submit">Добавить inbound</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user