accounts/internal/xrayconfig/generator.go
Haitao Pan 07e31ff6bd feat: move account service to repo root
# Conflicts:
#	account/Makefile
#	account/go.mod
#	docs/account-admin-settings.md
#	docs/account-svc-plus.md
2026-01-16 16:15:23 +08:00

195 lines
4.9 KiB
Go

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[0].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 {
// Definition provides the base Xray configuration that will be mutated to
// include the provided clients. When nil, DefaultDefinition is used.
Definition Definition
// 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.OutputPath) == "" {
return errors.New("output path is required")
}
buf, err := g.Render(clients)
if err != nil {
return err
}
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
}
// Render returns the rendered configuration JSON without writing it to disk.
// The returned buffer always ends with a newline.
func (g Generator) Render(clients []Client) ([]byte, error) {
definition := g.Definition
if definition == nil {
definition = DefaultDefinition()
}
root, err := definition.Base()
if err != nil {
return nil, fmt.Errorf("load template: %w", err)
}
if err := replaceClients(root, clients); err != nil {
return nil, err
}
buf, err := json.MarshalIndent(root, "", " ")
if err != nil {
return nil, fmt.Errorf("encode config: %w", err)
}
buf = append(buf, '\n')
return buf, 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)
}
if len(inboundsSlice) == 0 {
return errors.New("template missing inbound entry")
}
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)
}
inbound, ok := inboundsSlice[0].(map[string]interface{})
if !ok {
return fmt.Errorf("template inbound 0 has unexpected type %T", inboundsSlice[0])
}
settingsValue, ok := inbound["settings"]
if !ok {
return errors.New("template inbound missing settings object")
}
settingsMap, ok := settingsValue.(map[string]interface{})
if !ok {
return fmt.Errorf("template inbound settings has unexpected type %T", settingsValue)
}
clientsValue, ok := settingsMap["clients"]
if !ok {
return errors.New("template inbound missing clients array")
}
if _, ok := clientsValue.([]interface{}); !ok {
return fmt.Errorf("template inbound clients has unexpected type %T", clientsValue)
}
// Always replace the clients array so the config reflects the exact
// state from the database.
settingsMap["clients"] = clientObjects
inbound["settings"] = settingsMap
inboundsSlice[0] = inbound
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
}