Refine Xray config generator flow handling (#580)

This commit is contained in:
shenlan 2025-10-26 20:51:41 +08:00 committed by GitHub
parent 87c97a465a
commit 5ddec3e578
4 changed files with 498 additions and 0 deletions

View File

@ -0,0 +1,65 @@
{
"log": {
"loglevel": "error"
},
"routing": {
"rules": []
},
"inbounds": [
{
"listen": "0.0.0.0",
"port": 1443,
"protocol": "vless",
"settings": {
"clients": [],
"decryption": "none",
"fallbacks": [
{
"dest": 8001,
"xver": 1
},
{
"alpn": "h2",
"dest": 8002,
"xver": 1
}
]
},
"streamSettings": {
"network": "tcp",
"security": "tls",
"tlsSettings": {
"minVersion": "1.2",
"rejectUnknownSni": true,
"certificates": [
{
"ocspStapling": 3600,
"certificateFile": "/etc/ssl/onwalk.net.pem",
"keyFile": "/etc/ssl/onwalk.net.key"
}
]
}
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
}
}
],
"outbounds": [
{
"protocol": "freedom"
}
],
"policy": {
"levels": {
"0": {
"handshake": 2,
"connIdle": 120
}
}
}
}

View File

@ -0,0 +1,178 @@
package xrayconfig
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
const (
// DefaultFlow is applied to VLESS clients when no explicit flow is
// provided. It matches the tlsSettings baked into the template.
DefaultFlow = "xtls-rprx-vision"
)
// Client represents an entry under inbounds.settings.clients in the Xray config.
type Client struct {
ID string
Email string
Flow string
}
// Generator updates the Xray configuration file based on a template and a set of
// active clients.
type Generator struct {
// TemplatePath is the path to the base configuration template. The
// template must contain an "inbounds" array with objects that expose a
// "settings" object.
TemplatePath string
// OutputPath is the destination path for the generated configuration
// (typically /usr/local/etc/xray/config.json).
OutputPath string
// FileMode controls the permissions for the generated file. When zero it
// defaults to 0644.
FileMode fs.FileMode
}
// Generate writes a new Xray configuration with the provided clients. The base
// template is loaded on every invocation to ensure updates remain additive and
// idempotent even when multiple callers trigger regeneration.
func (g Generator) Generate(clients []Client) error {
if strings.TrimSpace(g.TemplatePath) == "" {
return errors.New("template path is required")
}
if strings.TrimSpace(g.OutputPath) == "" {
return errors.New("output path is required")
}
rawTemplate, err := os.ReadFile(g.TemplatePath)
if err != nil {
return fmt.Errorf("read template: %w", err)
}
var root map[string]interface{}
if err := json.Unmarshal(rawTemplate, &root); err != nil {
return fmt.Errorf("decode template json: %w", err)
}
if err := replaceClients(root, clients); err != nil {
return err
}
buf, err := json.MarshalIndent(root, "", " ")
if err != nil {
return fmt.Errorf("encode config: %w", err)
}
buf = append(buf, '\n')
mode := g.FileMode
if mode == 0 {
mode = 0o644
}
if err := atomicWriteFile(g.OutputPath, buf, mode); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
}
func replaceClients(root map[string]interface{}, clients []Client) error {
inboundsValue, ok := root["inbounds"]
if !ok {
return errors.New("template missing inbounds array")
}
inboundsSlice, ok := inboundsValue.([]interface{})
if !ok {
return fmt.Errorf("template inbounds has unexpected type %T", inboundsValue)
}
clientObjects := make([]interface{}, 0, len(clients))
for idx, client := range clients {
id := strings.TrimSpace(client.ID)
if id == "" {
return fmt.Errorf("client %d missing id", idx)
}
entry := map[string]interface{}{
"id": id,
}
if email := strings.TrimSpace(client.Email); email != "" {
entry["email"] = email
}
flow := strings.TrimSpace(client.Flow)
if flow == "" {
flow = DefaultFlow
}
entry["flow"] = flow
clientObjects = append(clientObjects, entry)
}
for idx, inbound := range inboundsSlice {
inboundMap, ok := inbound.(map[string]interface{})
if !ok {
return fmt.Errorf("template inbound %d has unexpected type %T", idx, inbound)
}
settingsValue, ok := inboundMap["settings"]
if !ok {
settingsValue = make(map[string]interface{})
}
settingsMap, ok := settingsValue.(map[string]interface{})
if !ok {
return fmt.Errorf("template inbound %d settings has unexpected type %T", idx, settingsValue)
}
// Always replace the clients array so the config reflects the exact
// state from the database.
settingsMap["clients"] = clientObjects
inboundMap["settings"] = settingsMap
inboundsSlice[idx] = inboundMap
}
root["inbounds"] = inboundsSlice
return nil
}
func atomicWriteFile(path string, data []byte, mode fs.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create directory %s: %w", dir, err)
}
tmp, err := os.CreateTemp(dir, ".xray-config-*.tmp")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpName := tmp.Name()
defer func() {
_ = tmp.Close()
_ = os.Remove(tmpName)
}()
if _, err := tmp.Write(data); err != nil {
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
return fmt.Errorf("sync temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Chmod(tmpName, mode); err != nil {
return fmt.Errorf("chmod temp file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename temp file: %w", err)
}
return nil
}

View File

@ -0,0 +1,117 @@
package xrayconfig
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
type testConfig struct {
Inbounds []struct {
Settings struct {
Clients []struct {
ID string `json:"id"`
Email string `json:"email,omitempty"`
Flow string `json:"flow,omitempty"`
} `json:"clients"`
Decryption string `json:"decryption"`
} `json:"settings"`
} `json:"inbounds"`
}
func TestGeneratorGenerate(t *testing.T) {
dir := t.TempDir()
templatePath := filepath.Join(dir, "template.json")
outputPath := filepath.Join(dir, "config.json")
template := `{
"inbounds": [
{
"tag": "vless",
"settings": {
"clients": [
{"id": "old", "email": "old@example"}
],
"decryption": "none"
}
}
],
"outbounds": [
{"protocol": "freedom"}
]
}`
if err := os.WriteFile(templatePath, []byte(template), 0o644); err != nil {
t.Fatalf("write template: %v", err)
}
gen := Generator{
TemplatePath: templatePath,
OutputPath: outputPath,
}
clients := []Client{
{ID: "uuid-a", Email: "a@demo", Flow: "xtls-rprx-vision"},
{ID: "uuid-b"},
}
if err := gen.Generate(clients); err != nil {
t.Fatalf("generate: %v", err)
}
raw, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read output: %v", err)
}
var cfg testConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatalf("decode output: %v", err)
}
if len(cfg.Inbounds) != 1 {
t.Fatalf("expected 1 inbound, got %d", len(cfg.Inbounds))
}
gotClients := cfg.Inbounds[0].Settings.Clients
if len(gotClients) != len(clients) {
t.Fatalf("expected %d clients, got %d", len(clients), len(gotClients))
}
if gotClients[0].ID != "uuid-a" || gotClients[0].Email != "a@demo" {
t.Fatalf("unexpected first client: %+v", gotClients[0])
}
if gotClients[0].Flow != "xtls-rprx-vision" {
t.Fatalf("unexpected first client flow: %+v", gotClients[0])
}
if gotClients[1].ID != "uuid-b" || gotClients[1].Email != "" || gotClients[1].Flow != DefaultFlow {
t.Fatalf("unexpected second client: %+v", gotClients[1])
}
if cfg.Inbounds[0].Settings.Decryption != "none" {
t.Fatalf("decryption field was modified: %q", cfg.Inbounds[0].Settings.Decryption)
}
}
func TestGeneratorGenerateMissingID(t *testing.T) {
dir := t.TempDir()
templatePath := filepath.Join(dir, "template.json")
outputPath := filepath.Join(dir, "config.json")
template := `{"inbounds":[{"settings":{"clients":[]}}]}`
if err := os.WriteFile(templatePath, []byte(template), 0o644); err != nil {
t.Fatalf("write template: %v", err)
}
gen := Generator{
TemplatePath: templatePath,
OutputPath: outputPath,
}
err := gen.Generate([]Client{{Email: "missing@id"}})
if err == nil || !strings.Contains(err.Error(), "missing id") {
t.Fatalf("expected missing id error, got %v", err)
}
}

View File

@ -0,0 +1,138 @@
# Xray Single-Port Multi-User Synchronization Design
## Background
The XControl platform manages access to an Xray proxy node that exposes a single inbound port while supporting multiple end users. Xray allows sharing the same inbound by enumerating client credentials under `inbounds.settings.clients`. Each client entry contains a UUID (`id`) and an optional label (`email`). Keeping the Xray configuration aligned with the account database requires an automated synchronization mechanism.
## Goals
- Maintain a single inbound listener that multiplexes all end-user credentials.
- Add or remove client UUIDs automatically whenever account state changes in XControl.
- Ensure that regenerated configuration files are syntactically valid before applying them.
- Restart the Xray service only when a configuration change occurs and after validation succeeds.
## Non-Goals
- Managing multiple inbound listeners or transport protocols. The scope is limited to updating `inbounds.settings.clients` for a single inbound.
- Modifying other parts of the Xray configuration (e.g., routing, outbound settings).
- Providing UI flows for manual configuration edits; the process is backend-driven.
## Data Model
| Field | Source | Notes |
|--------------|---------------|-----------------------------------------------------------|
| `id` | Account table | Stored as UUID v4 for compatibility with Xray clients. |
| `email` | Account table | Optional identifier; used for auditing and debugging. |
| `flow` | Derived | Optional; defaults to `xtls-rprx-vision` for Vision mode. |
| `enabled` | Account table | Only enabled users contribute to the generated array. |
The backend queries all enabled accounts and materializes the JSON payload expected by Xray.
## Component Overview
```
+-----------------+ +-------------------+ +---------------------------+
| Account Service | --> | Config Generator | --> | /usr/local/etc/xray/... |
+-----------------+ +-------------------+ +---------------------------+
^ | |
| v v
| JSON schema validator systemctl restart xray
| | |
+---------------------+---------------------------+
```
- **Account Service**: Emits events (e.g., user registration, disablement) or exposes API endpoints that trigger the synchronization job.
- **Config Generator**: Go routine/function that builds the new configuration JSON from a template and the latest client list.
- **JSON Validator**: Ensures the generated file is well-formed before Xray reload.
- **Supervisor**: Invokes `systemctl restart xray.service` if validation succeeds.
## Update Workflow
1. **Trigger**: Any operation that creates, updates, or deletes a user credential triggers the synchronization. Hook into existing user management flows (e.g., registration endpoint).
2. **Load Active Users**: Fetch all enabled users from the database, retrieving their UUID and email.
3. **Merge Clients Array**: Construct the `clients` slice (`[]Client`) ordered deterministically (e.g., sorted by creation time) to keep diffs stable.
4. **Generate Configuration**:
- Load the base template for `/usr/local/etc/xray/config.json`.
- Replace the `inbounds.settings.clients` node with the freshly computed array. New registrations simply append to the slice
composed in memory before the generator writes it back. Each client entry includes the UUID, optional email, and any flow
directive required by the transport profile.
- Persist the resulting JSON atomically (write to temp file then move into place).
5. **Validate JSON**:
- Run `jq . /usr/local/etc/xray/config.json` or an equivalent Go `json.Unmarshal` check to confirm syntax correctness.
- Optionally, verify required fields (e.g., at least one inbound, TLS settings) through schema assertions.
6. **Apply Changes**:
- If validation passes and the new file differs from the previous version, execute `systemctl restart xray.service`.
- Log success or failure, including the number of clients synchronized.
7. **Error Handling**:
- On failure, restore the previous configuration and alert operators.
- Ensure retries/backoff so transient issues (e.g., temporary DB outage) do not leave the system inconsistent.
## Implementation Sketch (Go)
```go
// Client represents an entry in inbounds.settings.clients.
type Client struct {
ID string `json:"id"`
Email string `json:"email"`
Flow string `json:"flow"`
}
func SyncXrayClients(ctx context.Context, db *sql.DB, fs afero.Fs, runner command.Runner) error {
clients, err := loadEnabledClients(ctx, db)
if err != nil {
return fmt.Errorf("load clients: %w", err)
}
cfg, err := loadBaseConfig(fs)
if err != nil {
return fmt.Errorf("load base config: %w", err)
}
cfg.Inbounds[0].Settings.Clients = clients
for i := range cfg.Inbounds[0].Settings.Clients {
if cfg.Inbounds[0].Settings.Clients[i].Flow == "" {
cfg.Inbounds[0].Settings.Clients[i].Flow = "xtls-rprx-vision"
}
}
buf, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
if err := writeAtomically(fs, "/usr/local/etc/xray/config.json", buf); err != nil {
return fmt.Errorf("write config: %w", err)
}
if err := validateJSON(buf); err != nil {
return fmt.Errorf("validate config: %w", err)
}
return runner.Run(ctx, "systemctl", "restart", "xray.service")
}
```
The concrete implementation should wire in dependency-injected collaborators for database access, filesystem operations, validation, and command execution to simplify testing.
The initial `Config Generator` module lives at `account/internal/xrayconfig`. It loads `account/config/xray.config.template.json`,
overwrites the client array with the current database view (setting `flow` to `xtls-rprx-vision` unless callers request a
different value), and writes the merged document to `/usr/local/etc/xray/config.json` using an atomic rename so that Xray always
observes a complete file.
## Operational Considerations
- **Atomic Writes**: Write the new configuration to `/usr/local/etc/xray/config.json.tmp` and `os.Rename` it into place to avoid partial files.
- **Permissions**: The service account running the backend must have write access to the Xray config path and permission to restart the service (e.g., via sudoers entry).
- **Audit Logging**: Log each synchronization with the count of clients and a checksum of the generated array for troubleshooting.
- **Monitoring**: Expose metrics such as `xray_sync_success_total` and `xray_sync_duration_seconds` to observe reliability.
## Future Enhancements
- **Inotify Hooks**: Instead of restarting the service, consider using Xray's hot-reload API if available to reduce downtime.
- **Template Versioning**: Store the base config template in version control and tag deployments so rollbacks are traceable.
- **Dry-Run Mode**: Provide an administrative command to preview the generated configuration without applying it.
- **Event-Driven Sync**: Replace polling with message-based events (e.g., via Redis or Kafka) to react more quickly to account changes.
## Summary
By centralizing client credential management in the database and regenerating the Xray configuration dynamically, XControl can support a single inbound port for multiple users. Automating validation and restart steps keeps the service consistent and minimizes operator intervention.