Refine Xray config generator flow handling (#580)
This commit is contained in:
parent
87c97a465a
commit
5ddec3e578
65
account/config/xray.config.template.json
Normal file
65
account/config/xray.config.template.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
178
account/internal/xrayconfig/generator.go
Normal file
178
account/internal/xrayconfig/generator.go
Normal 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
|
||||
}
|
||||
117
account/internal/xrayconfig/generator_test.go
Normal file
117
account/internal/xrayconfig/generator_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
138
docs/xray-single-port-multi-user.md
Normal file
138
docs/xray-single-port-multi-user.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user