Skip to content

Commit 244f20a

Browse files
committed
Merge dev: feat(xray) multi-inbound support per node (1.2.0)
2 parents 8c8e111 + 0d723cf commit 244f20a

19 files changed

Lines changed: 1513 additions & 281 deletions

File tree

cc-agent/config.go

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,30 @@ type TLSConfig struct {
1111
Key string `json:"key"`
1212
}
1313

14+
// InboundEntry describes a single Xray VLESS inbound the agent has to
15+
// add/remove users to/from. Flow is the per-inbound XTLS flow (empty for
16+
// transports that do not support flow, e.g. WebSocket/gRPC/XHTTP).
17+
type InboundEntry struct {
18+
Tag string `json:"tag"`
19+
Flow string `json:"flow"`
20+
}
21+
1422
type Config struct {
15-
Listen string `json:"listen"`
16-
Token string `json:"token"`
17-
XrayAPI string `json:"xray_api"`
18-
InboundTag string `json:"inbound_tag"`
19-
DataDir string `json:"data_dir"`
20-
TLS TLSConfig `json:"tls"`
23+
Listen string `json:"listen"`
24+
Token string `json:"token"`
25+
XrayAPI string `json:"xray_api"`
26+
DataDir string `json:"data_dir"`
27+
TLS TLSConfig `json:"tls"`
28+
29+
// InboundTag is the legacy single-inbound tag. It is kept for backward
30+
// compatibility with old panels that do not write the Inbounds array.
31+
InboundTag string `json:"inbound_tag"`
32+
33+
// Inbounds is the new multi-inbound configuration. When set, AddUser /
34+
// RemoveUser iterate over every entry and apply the per-tag Flow.
35+
// When empty, the loader synthesizes a single entry from InboundTag and
36+
// flow is resolved from the running Xray config (best-effort).
37+
Inbounds []InboundEntry `json:"inbounds,omitempty"`
2138
}
2239

2340
func LoadConfig(path string) (*Config, error) {
@@ -37,5 +54,17 @@ func LoadConfig(path string) (*Config, error) {
3754
return nil, err
3855
}
3956

57+
// Backward compatibility: if the new Inbounds array is missing but the
58+
// legacy InboundTag is present, synthesize a single entry. Flow is
59+
// resolved from the running Xray config (best-effort) so XTLS-Vision
60+
// clients keep working when the panel only writes the legacy field.
61+
if len(cfg.Inbounds) == 0 && cfg.InboundTag != "" {
62+
flow := ""
63+
if probed, ok := probeFlowFromXrayConfig(cfg.InboundTag); ok {
64+
flow = probed
65+
}
66+
cfg.Inbounds = []InboundEntry{{Tag: cfg.InboundTag, Flow: flow}}
67+
}
68+
4069
return cfg, nil
4170
}

cc-agent/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
"time"
1313
)
1414

15-
const Version = "2.0.0"
15+
const Version = "1.2.0"
1616

1717
var startTime = time.Now()
1818

cc-agent/xray.go

Lines changed: 93 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package main
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
7+
"os"
68

79
proxyman_command "github.com/xtls/xray-core/app/proxyman/command"
810
stats_command "github.com/xtls/xray-core/app/stats/command"
@@ -13,12 +15,50 @@ import (
1315
"google.golang.org/grpc/credentials/insecure"
1416
)
1517

16-
// XrayClient wraps the Xray gRPC API
18+
// xrayConfigPath is the canonical location written by nodeSetup.installXray.
19+
const xrayConfigPath = "/usr/local/etc/xray/config.json"
20+
21+
// probeFlowFromXrayConfig reads the on-disk Xray config, finds the inbound
22+
// with the given tag and returns the flow value from its first client.
23+
// This is a best-effort backward-compat helper for legacy panels that did
24+
// not write the explicit per-tag Flow into cc-agent config.json.
25+
func probeFlowFromXrayConfig(tag string) (string, bool) {
26+
data, err := os.ReadFile(xrayConfigPath)
27+
if err != nil {
28+
return "", false
29+
}
30+
31+
var parsed struct {
32+
Inbounds []struct {
33+
Tag string `json:"tag"`
34+
Settings struct {
35+
Clients []struct {
36+
Flow string `json:"flow"`
37+
} `json:"clients"`
38+
} `json:"settings"`
39+
} `json:"inbounds"`
40+
}
41+
if err := json.Unmarshal(data, &parsed); err != nil {
42+
return "", false
43+
}
44+
45+
for _, ib := range parsed.Inbounds {
46+
if ib.Tag == tag && len(ib.Settings.Clients) > 0 {
47+
return ib.Settings.Clients[0].Flow, true
48+
}
49+
}
50+
return "", false
51+
}
52+
53+
// XrayClient wraps the Xray gRPC API. It owns the list of VLESS inbounds the
54+
// agent must keep in sync — every AddUser/RemoveUser call iterates over them.
55+
// The per-tag Flow value is used as-is when calling AlterInbound, so flow=""
56+
// is sent for transports where flow is not supported (WS/gRPC/XHTTP).
1757
type XrayClient struct {
18-
conn *grpc.ClientConn
19-
proxyman proxyman_command.HandlerServiceClient
20-
stats stats_command.StatsServiceClient
21-
inboundTag string
58+
conn *grpc.ClientConn
59+
proxyman proxyman_command.HandlerServiceClient
60+
stats stats_command.StatsServiceClient
61+
inbounds []InboundEntry
2262
}
2363

2464
func NewXrayClient(cfg *Config) (*XrayClient, error) {
@@ -29,47 +69,64 @@ func NewXrayClient(cfg *Config) (*XrayClient, error) {
2969
return nil, fmt.Errorf("grpc.NewClient: %w", err)
3070
}
3171

72+
// LoadConfig guarantees Inbounds is populated (synthesizes a single
73+
// entry from the legacy InboundTag when missing). No further fallback
74+
// is needed here.
3275
return &XrayClient{
33-
conn: conn,
34-
proxyman: proxyman_command.NewHandlerServiceClient(conn),
35-
stats: stats_command.NewStatsServiceClient(conn),
36-
inboundTag: cfg.InboundTag,
76+
conn: conn,
77+
proxyman: proxyman_command.NewHandlerServiceClient(conn),
78+
stats: stats_command.NewStatsServiceClient(conn),
79+
inbounds: cfg.Inbounds,
3780
}, nil
3881
}
3982

40-
// AddUser adds a VLESS user to the Xray inbound via gRPC
83+
// AddUser adds a VLESS user to every configured Xray inbound via gRPC.
84+
// Flow is taken from the per-inbound configuration; the value of u.Flow
85+
// is intentionally ignored — the agent is the source of truth here.
4186
func (c *XrayClient) AddUser(ctx context.Context, u *User) error {
42-
_, err := c.proxyman.AlterInbound(ctx, &proxyman_command.AlterInboundRequest{
43-
Tag: c.inboundTag,
44-
Operation: serial.ToTypedMessage(&proxyman_command.AddUserOperation{
45-
User: &protocol.User{
46-
Level: 0,
47-
Email: u.Email,
48-
Account: serial.ToTypedMessage(&vless.Account{
49-
Id: u.ID,
50-
Flow: u.Flow,
51-
}),
52-
},
53-
}),
54-
})
55-
if err != nil {
56-
return fmt.Errorf("AddUser %s: %w", u.Email, err)
87+
if len(c.inbounds) == 0 {
88+
return fmt.Errorf("AddUser %s: no inbounds configured", u.Email)
5789
}
58-
return nil
90+
var firstErr error
91+
for _, ib := range c.inbounds {
92+
_, err := c.proxyman.AlterInbound(ctx, &proxyman_command.AlterInboundRequest{
93+
Tag: ib.Tag,
94+
Operation: serial.ToTypedMessage(&proxyman_command.AddUserOperation{
95+
User: &protocol.User{
96+
Level: 0,
97+
Email: u.Email,
98+
Account: serial.ToTypedMessage(&vless.Account{
99+
Id: u.ID,
100+
Flow: ib.Flow,
101+
}),
102+
},
103+
}),
104+
})
105+
if err != nil && firstErr == nil {
106+
firstErr = fmt.Errorf("AddUser %s on %s: %w", u.Email, ib.Tag, err)
107+
}
108+
}
109+
return firstErr
59110
}
60111

61-
// RemoveUser removes a user from the Xray inbound via gRPC
112+
// RemoveUser removes a user from every configured Xray inbound via gRPC.
62113
func (c *XrayClient) RemoveUser(ctx context.Context, email string) error {
63-
_, err := c.proxyman.AlterInbound(ctx, &proxyman_command.AlterInboundRequest{
64-
Tag: c.inboundTag,
65-
Operation: serial.ToTypedMessage(&proxyman_command.RemoveUserOperation{
66-
Email: email,
67-
}),
68-
})
69-
if err != nil {
70-
return fmt.Errorf("RemoveUser %s: %w", email, err)
114+
if len(c.inbounds) == 0 {
115+
return fmt.Errorf("RemoveUser %s: no inbounds configured", email)
116+
}
117+
var firstErr error
118+
for _, ib := range c.inbounds {
119+
_, err := c.proxyman.AlterInbound(ctx, &proxyman_command.AlterInboundRequest{
120+
Tag: ib.Tag,
121+
Operation: serial.ToTypedMessage(&proxyman_command.RemoveUserOperation{
122+
Email: email,
123+
}),
124+
})
125+
if err != nil && firstErr == nil {
126+
firstErr = fmt.Errorf("RemoveUser %s on %s: %w", email, ib.Tag, err)
127+
}
71128
}
72-
return nil
129+
return firstErr
73130
}
74131

75132
// QueryStats fetches traffic stats from Xray matching the given pattern.

public/css/style.css

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3094,3 +3094,29 @@ p {
30943094
.btn, .btn-icon, .nav-menu a, .card, .stat-card, .action-btn {
30953095
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
30963096
}
3097+
3098+
/* Port chips for the Nodes table — used when an Xray node has multiple
3099+
* client-facing inbounds (main + extras). */
3100+
.port-chip-list {
3101+
display: flex;
3102+
flex-wrap: wrap;
3103+
gap: 4px;
3104+
align-items: center;
3105+
}
3106+
.port-chip {
3107+
display: inline-block;
3108+
padding: 2px 8px;
3109+
border-radius: 10px;
3110+
background: var(--bg-secondary);
3111+
border: 1px solid var(--border);
3112+
color: var(--text-primary);
3113+
font-family: 'JetBrains Mono', 'Consolas', 'Monaco', monospace;
3114+
font-size: 12px;
3115+
line-height: 1.4;
3116+
cursor: help;
3117+
}
3118+
.port-chip-extra {
3119+
background: var(--accent-glow, rgba(99, 102, 241, 0.12));
3120+
border-color: var(--accent, #6366f1);
3121+
color: var(--accent, #6366f1);
3122+
}

src/locales/en.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,19 @@
453453
"xrayXhttpModeHint": "auto (recommended), packet-up (for strict CDN), stream-up (for H2/H3)",
454454
"xrayApiPort": "Xray API Port (local)",
455455
"xrayApiPortHint": "Local port for management API (not public, default 61000)",
456+
"xrayExtraInboundsTitle": "Additional inbounds",
457+
"xrayExtraInboundsHint": "Run extra VLESS inbounds on different ports/transports — clients receive every entry in their subscription.",
458+
"xrayAddInbound": "Add inbound",
459+
"xrayRemoveInbound": "Remove inbound",
460+
"xrayRemoveInboundConfirm": "Remove this inbound? Connected clients on this port will be disconnected on the next sync.",
461+
"xrayExtraInbound": "Extra inbound",
462+
"xrayInboundLabel": "Label",
463+
"xrayInboundLabelPlaceholder": "e.g. Mobile, CDN, Backup",
464+
"xrayInboundTag": "Inbound tag",
465+
"xrayInboundPortConflict": "This port is already used by another inbound or the API",
466+
"xrayInboundTagConflict": "This tag is already used by another inbound",
467+
"agentOutdatedTitle": "cc-agent is outdated",
468+
"agentOutdatedMessage": "Extra inbounds are configured, but the agent on this node is older than 1.2.0 — it cannot manage users on the additional inbounds. Re-run the node setup from the management section to upgrade the agent.",
456469
"xrayGenerateKeys": "Generate keys",
457470
"xrayKeysGenerated": "Keys generated!",
458471
"xrayAutoGenerated": "Auto-generated on setup",

src/locales/ru.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,19 @@
453453
"xrayXhttpModeHint": "auto (рекомендуется), packet-up (для строгих CDN), stream-up (для H2/H3)",
454454
"xrayApiPort": "Xray API Port (локальный)",
455455
"xrayApiPortHint": "Локальный порт для API управления (не публичный, по умолчанию 61000)",
456+
"xrayExtraInboundsTitle": "Дополнительные inbound'ы",
457+
"xrayExtraInboundsHint": "Запускайте на ноде несколько VLESS inbound'ов на разных портах и транспортах — каждый попадёт в подписку клиента.",
458+
"xrayAddInbound": "Добавить inbound",
459+
"xrayRemoveInbound": "Удалить inbound",
460+
"xrayRemoveInboundConfirm": "Удалить этот inbound? Подключённые клиенты на этом порту будут отключены при следующей синхронизации.",
461+
"xrayExtraInbound": "Дополнительный inbound",
462+
"xrayInboundLabel": "Метка",
463+
"xrayInboundLabelPlaceholder": "Например: Mobile, CDN, Backup",
464+
"xrayInboundTag": "Тег inbound'а",
465+
"xrayInboundPortConflict": "Этот порт уже занят другим inbound'ом или API",
466+
"xrayInboundTagConflict": "Этот тег уже используется другим inbound'ом",
467+
"agentOutdatedTitle": "cc-agent устарел",
468+
"agentOutdatedMessage": "На ноде настроены дополнительные inbound'ы, но версия агента ниже 1.2.0 — он не сможет управлять пользователями на этих inbound'ах. Запустите автоустановку ноды из раздела управления, чтобы обновить агент.",
456469
"xrayGenerateKeys": "Сгенерировать ключи",
457470
"xrayKeysGenerated": "Ключи сгенерированы!",
458471
"xrayAutoGenerated": "Авто-генерация при настройке",

src/models/hyNodeModel.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,40 @@ const quicSchema = new mongoose.Schema({
106106
disablePathMTUDiscovery: { type: Boolean, default: false },
107107
}, { _id: false });
108108

109+
// Per-inbound stream/security settings shared by the main inbound and extras.
110+
// Extras add `id`, `label`, `port` and reuse their own `inboundTag` instead of
111+
// the node-level fields.
112+
const xrayExtraInboundSchema = new mongoose.Schema({
113+
// Stable client-generated id (uuid) used to track edits across form submits
114+
id: { type: String, required: true },
115+
label: { type: String, default: '' },
116+
port: { type: Number, required: true },
117+
inboundTag: { type: String, required: true },
118+
119+
transport: { type: String, enum: ['tcp', 'ws', 'grpc', 'xhttp'], default: 'tcp' },
120+
security: { type: String, enum: ['reality', 'tls', 'none'], default: 'reality' },
121+
flow: { type: String, default: 'xtls-rprx-vision' },
122+
123+
fingerprint: { type: String, default: 'chrome' },
124+
alpn: { type: [String], default: [] },
125+
126+
realityDest: { type: String, default: 'www.google.com:443' },
127+
realitySni: { type: [String], default: ['www.google.com'] },
128+
realityPrivateKey: { type: String, default: '' },
129+
realityPublicKey: { type: String, default: '' },
130+
realityShortIds: { type: [String], default: [''] },
131+
realitySpiderX: { type: String, default: '/' },
132+
133+
wsPath: { type: String, default: '/' },
134+
wsHost: { type: String, default: '' },
135+
136+
grpcServiceName: { type: String, default: 'grpc' },
137+
138+
xhttpPath: { type: String, default: '/' },
139+
xhttpHost: { type: String, default: '' },
140+
xhttpMode: { type: String, enum: ['auto', 'packet-up', 'stream-up'], default: 'auto' },
141+
}, { _id: false });
142+
109143
const xrayConfigSchema = new mongoose.Schema({
110144
// Transport: tcp, ws, grpc, xhttp (splithttp)
111145
transport: { type: String, enum: ['tcp', 'ws', 'grpc', 'xhttp'], default: 'tcp' },
@@ -149,6 +183,11 @@ const xrayConfigSchema = new mongoose.Schema({
149183
agentPort: { type: Number, default: 62080 },
150184
agentToken: { type: String, default: '' },
151185
agentTls: { type: Boolean, default: true },
186+
187+
// Additional VLESS inbounds running alongside the main one with their own
188+
// ports and transports (Reality TCP + WS+TLS + gRPC, etc). Optional, the
189+
// main inbound is still defined by the flat fields above.
190+
extraInbounds: { type: [xrayExtraInboundSchema], default: [] },
152191
}, { _id: false });
153192

154193
const hyNodeSchema = new mongoose.Schema({

0 commit comments

Comments
 (0)