Config Generation
xrat generates runtime configuration JSON from normalized Node objects for
both Xray-core and sing-box engines.
Overview
The config generation pipeline:
- Node (domain model) → Protocol-specific mapping → JSON config
- Supports probe configs (short-lived, for testing) and runtime configs (long-lived)
flowchart LR
classDef domain fill:#2e2a1a,stroke:#dfba5b,color:#e6edf3
classDef select fill:#1a2c3a,stroke:#5b8def,color:#e6edf3
classDef engine fill:#2e1a1a,stroke:#df6060,color:#e6edf3
classDef out fill:#1a2e1a,stroke:#5bdf8a,color:#e6edf3
NODE["model::Node<br/>(protocol, address, port, ...)"]:::domain
ENGINE{{"resolve_engine()<br/>Auto | Xray | SingBox"}}:::select
XGEN["xray/config/generator/<br/>generate_probe_config()<br/>generate_runtime_config()"]:::engine
SGEN["singbox/config/<br/>generate_probe_config()<br/>generate_runtime_config()"]:::engine
XOUT["Xray JSON config<br/>(XrayConfig)"]:::out
SOUT["sing-box JSON config<br/>(serde_json::Value)"]:::out
NODE --> ENGINE
ENGINE -- "VLESS, VMess, Trojan<br/>SS, SOCKS5, HTTP" --> XGEN
ENGINE -- "Hysteria2" --> SGEN
XGEN --> XOUT
SGEN --> SOUT
Xray Config Generation
Located in src/xray/config/.
Entry Points
#![allow(unused)]
fn main() {
// Probe config (used for testing)
pub fn generate_probe_config(node: &Node, local_port: u16) -> Result<XrayConfig, String>
// Runtime config (used for connect)
pub fn generate_runtime_config(node: &Node, socks_port: u16, http_port: Option<u16>) -> Result<XrayConfig, String>
// Runtime config with custom inbound hosts
pub fn generate_runtime_config_with_inbounds(
node: &Node,
socks_host: &str,
socks_port: u16,
http_host: Option<&str>,
http_port: Option<u16>,
) -> Result<XrayConfig, String>
// Runtime config with fully configurable inbounds
pub fn generate_runtime_config_for_inbounds(
node: &Node,
socks: Option<(&str, u16, bool)>,
http: Option<(&str, u16)>,
) -> Result<XrayConfig, String>
}
Probe Config
Used by the testing pipeline to measure real-delay latency:
{
"log": { "loglevel": "warning" },
"inbounds": [
{
"tag": "probe-in",
"port": <random>,
"listen": "127.0.0.1",
"protocol": "socks",
"settings": { "udp": false }
}
],
"outbounds": [
{
"tag": "proxy",
"protocol": "<node.protocol>",
"settings": { ... },
"stream_settings": { ... }
}
]
}
Runtime Config
Used by the managed proxy session:
{
"log": { "loglevel": "warning" },
"inbounds": [
{
"tag": "socks-in",
"port": 1080,
"listen": "0.0.0.0",
"protocol": "socks",
"settings": { "udp": true }
},
{
"tag": "http-in",
"port": 8080,
"listen": "0.0.0.0",
"protocol": "http"
}
],
"outbounds": [
{
"tag": "proxy",
"protocol": "<node.protocol>",
"settings": { ... },
"stream_settings": { ... }
}
]
}
XrayConfig Struct
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct XrayConfig {
pub log: LogConfig,
pub inbounds: Vec<Inbound>,
pub outbounds: Vec<Outbound>,
}
}
Outbound Generation
Each protocol maps to a specific outbound format:
VLESS
When the link carries a flow extension (e.g. xtls-rprx-vision), it is added
to the user entry:
{
"vnext": [
{
"address": "example.com",
"port": 443,
"users": [
{
"id": "uuid-123",
"encryption": "none",
"flow": "xtls-rprx-vision"
}
]
}
]
}
VMess
{
"vnext": [
{
"address": "example.com",
"port": 443,
"users": [
{
"id": "uuid-456",
"alterId": 0,
"security": "auto"
}
]
}
]
}
Trojan
{
"servers": [
{
"address": "example.com",
"port": 443,
"password": "password"
}
]
}
Shadowsocks
{
"servers": [
{
"address": "example.com",
"port": 8388,
"method": "aes-256-gcm",
"password": "secret"
}
]
}
SOCKS5
{
"servers": [
{
"address": "example.com",
"port": 1080,
"users": [
{
"user": "username",
"pass": "password"
}
]
}
]
}
HTTP
{
"servers": [
{
"address": "example.com",
"port": 8080,
"users": [
{
"user": "username",
"pass": "password"
}
]
}
]
}
Hysteria2
Hysteria2 is not supported by Xray. Returns an error if attempted:
Error: hysteria2/hy2 is not supported by xray config generator
Stream Settings
Generated based on node fields. A shared extension resolver removes a parameter only when the protocol, security, or transport builder consumes and validates it. Generation fails if anything remains, preventing a newly preserved query parameter from being silently omitted or used by the wrong transport.
#![allow(unused)]
fn main() {
fn build_stream_settings(node: &Node) -> Result<Option<StreamSettings>, String> {
let network = node.network.as_str();
// SOCKS5 and HTTP have no stream settings
if matches!(node.protocol, Protocol::Socks5 | Protocol::Http) {
return Ok(None);
}
// Network-specific settings
StreamSettings {
method, // "raw" | "websocket" | "grpc" | "xhttp" | "mkcp" | "httpupgrade"
security, // "tls" | "reality" | "none" | None
tls_settings, // serverName, fingerprint, alpn
reality_settings, // serverName, password, shortId, spiderX, fingerprint
ws_settings, // path, headers (Host)
raw_settings, // HTTP header obfuscation
kcp_settings, // current mKCP transport fields
grpc_settings, // serviceName, multiMode, authority, idle_timeout
xhttp_settings, // host, path, mode, extra
httpupgrade_settings,
}
}
}
TLS Settings
{
"method": "raw",
"security": "tls",
"tlsSettings": {
"serverName": "cdn.example.com",
"allowInsecure": false
}
}
WebSocket Settings
{
"method": "websocket",
"security": "tls",
"tlsSettings": {
"serverName": "cdn.example.com"
},
"wsSettings": {
"path": "/ray",
"headers": {
"Host": "cdn.example.com"
}
}
}
gRPC Settings
{
"method": "grpc",
"grpcSettings": {
"serviceName": "service"
}
}
REALITY Settings
Built when security=reality, using preserved extensions (pbk/password,
sid, spx, fp) and sni:
{
"method": "xhttp",
"security": "reality",
"realitySettings": {
"serverName": "www.example.com",
"password": "PUBLICKEY",
"shortId": "SHORTID",
"spiderX": "/",
"fingerprint": "chrome"
},
"xhttpSettings": {
"host": "www.example.com",
"path": "/",
"mode": "packet-up"
}
}
XHTTP Extra Settings
The xhttp builder merges compatibility padding aliases, the official JSON
extra object, and known camelCase flat parameters in that order. Nested
unknown fields remain intact for forward compatibility. Outer host, path,
and mode are authoritative; conflicting copies inside extra are rejected.
{
"method": "xhttp",
"xhttpSettings": {
"path": "/",
"mode": "auto",
"extra": {
"xPaddingBytes": "100-1000",
"noSSEHeader": true
}
}
}
TCP HTTP Obfuscation
{
"method": "raw",
"rawSettings": {
"header": {
"type": "http",
"request": {
"path": ["/custom-path"]
}
}
}
}
Stream Settings Logic
#![allow(unused)]
fn main() {
pub(super) fn build_stream_settings(node: &Node) -> Result<Option<StreamSettings>, String> {
let network = node.network.as_str();
if matches!(node.protocol, Protocol::Socks5 | Protocol::Http) {
return Ok(None);
}
let security = node.tls.as_ref().map(|s| s.to_string());
let tls_settings = if node.tls.as_deref() == Some("tls") {
Some(TlsSettings {
server_name: node.sni.clone().unwrap_or_else(|| node.address.clone()),
allow_insecure: None,
})
} else {
None
};
let ws_settings = if network == "ws" {
let mut headers = HashMap::new();
if let Some(host) = &node.host {
headers.insert("Host".to_string(), host.clone());
}
Some(WsSettings {
path: node.path.clone().unwrap_or_else(|| "/".to_string()),
headers: if headers.is_empty() { None } else { Some(headers) },
})
} else {
None
};
let grpc_settings = if network == "grpc" {
Some(GrpcSettings {
service_name: node.path.clone().unwrap_or_default(),
})
} else {
None
};
let tcp_settings = if network == "tcp" {
node.path.as_ref().map(|path| TcpSettings {
header: Some(json!({
"type": "http",
"request": { "path": [path] }
})),
})
} else {
None
};
Ok(Some(StreamSettings {
network: network.to_string(),
security,
tls_settings,
ws_settings,
tcp_settings,
grpc_settings,
}))
}
}
Runtime Tuning
Global runtime tuning from config.toml ([runtime.mux], [runtime.fragment],
[runtime.network]) is not derived from Node data. It is applied as a
post-build mutation of the generated config via
apply_runtime_tuning(&mut XrayConfig, &XrayGenOptions), mirroring how
enable_stats_api mutates an already-built config. XrayGenOptions defaults to
empty, so generated config is byte-identical to before unless a section is
enabled.
- Mux sets a
muxobject on the proxy outbound (outbounds[0]). - Fragment appends a
freedomoutbound taggedfragmentand points the proxy outbound at it viastreamSettings.sockopt.dialerProxy. - Interface/
marksetstreamSettings.sockopt.interface/.markon the egress outbound — thefragmentoutbound when fragmentation is enabled, otherwise the proxy outbound. A minimaltcpstreamSettingsis created for socks/http upstreams that have none. bind_addresshas no Xraysockoptequivalent and is intentionally not emitted (a warning is logged at launch).listen_interfaceis resolved to an interface address in the runtime service and used as the inboundlistenvalue; it does not affect probes.
The same XrayGenOptions is threaded into probe config generation
(generate_probe_config_with_options) so xrat test/scan exercise the same
outbound and DNS behavior as the managed runtime. Probe configs still omit
managed-runtime routing rules.
sing-box Config Generation
Located in src/singbox/config/.
Supported Protocols
Currently only Hysteria2 is implemented.
Hysteria2 Config
#![allow(unused)]
fn main() {
pub fn generate_probe_config(node: &Node, local_port: u16) -> Result<Value, String>
pub fn generate_runtime_config(node: &Node, socks_port: u16) -> Result<Value, String>
}
Probe Config
{
"log": { "level": "warn" },
"inbounds": [
{
"type": "socks",
"tag": "socks-in",
"listen": "127.0.0.1",
"listen_port": <local_port>
}
],
"outbounds": [
{
"type": "hysteria2",
"tag": "proxy",
"server": "example.com",
"server_port": 443,
"password": "secret",
"tls": {
"enabled": true,
"server_name": "cdn.example.com"
}
},
{
"type": "direct",
"tag": "direct"
}
]
}
Engine Resolution
The parser service resolves which engine to use:
#![allow(unused)]
fn main() {
pub fn resolve_engine(mode: EngineMode, protocol: Protocol) -> Result<ResolvedEngine, ConfigParseError> {
match mode {
EngineMode::Auto => {
if matches!(protocol, Protocol::Hy2) {
Ok(ResolvedEngine::SingBox)
} else {
Ok(ResolvedEngine::Xray)
}
}
EngineMode::Xray => {
if matches!(protocol, Protocol::Hy2) {
return Err(ConfigParseError::UnsupportedScheme(
"hysteria2/hy2 is not compatible with xray engine".to_string()
));
}
Ok(ResolvedEngine::Xray)
}
EngineMode::SingBox => Ok(ResolvedEngine::SingBox),
}
}
}
Xray JSON Parsing
xrat includes a full Xray JSON config parser for reading existing configs.
Parser Modes
| Mode | Behavior |
|---|---|
strict | Rejects unknown fields using #[serde(deny_unknown_fields)] |
lenient | Allows unknown fields (default) |
auto | Same as lenient (reserved for future source-aware parsing) |
Parsed Structures
The parser can read:
- Log: access, error, loglevel, dns_log, mask_address
- API: tag, listen, services
- DNS: managed and Xray probe configs receive hosts, servers, query strategy, and cache/fallback settings; managed sing-box configs receive validated modern typed servers, supported strategies, cache settings, and exact hosts. Probe configs remain routing-free.
- Routing: domain_strategy, rules, balancers
- Policy: levels (handshake, connIdle, etc.), system stats
- Inbounds: various protocol inbounds with full settings
- Outbounds: various protocol outbounds with full settings
- Transports: TCP, WebSocket, gRPC, HTTP/2, QUIC, KCP
- Features: stats, reverse, fakedns, metrics, observatory
Process Management
Xray Process Spawning
Low-level process lifecycle:
#![allow(unused)]
fn main() {
pub async fn spawn(config: &XrayConfig, startup_timeout: Duration) -> Result<XrayProcess, XrayProcessError>
}
- Write config JSON to temp file
- Spawn
xray run -c <config_path> - Poll SOCKS port every 100ms
- Return when port is ready or timeout
Managed Process Spawning
High-level process lifecycle for daemon:
#![allow(unused)]
fn main() {
pub async fn spawn_detached(
binary_path: &Path,
runtime_dir: &Path,
session_id: i64,
config: &XrayConfig,
ready_host: &str,
ready_port: u16,
startup_timeout: Duration,
) -> Result<ManagedXrayProcess, AppError>
}
- Write config to
runtime_dir/session-<id>.json - Create stdout/stderr log files
- Spawn detached process
- Poll for readiness
- Return ManagedXrayProcess (PID, port, paths)
Signal Handling
#![allow(unused)]
fn main() {
pub fn terminate_process_gracefully(
pid: i64,
timeout: Duration,
) -> Result<TerminationOutcome, AppError>
}
- Send SIGTERM
- Poll every 100ms up to timeout
- If still running, send SIGKILL
- Return outcome:
Terminated,Killed,NotRunning
Config Generation Tests
#![allow(unused)]
fn main() {
#[test]
fn test_generate_vless_probe_config() {
let node = Node {
protocol: Protocol::Vless,
address: "example.com".to_string(),
port: 443,
uuid: Some("test-uuid".to_string()),
network: "tcp".to_string(),
tls: Some("tls".to_string()),
sni: Some("example.com".to_string()),
// ...
};
let config = generate_probe_config(&node, 10808).unwrap();
assert_eq!(config.inbounds[0].port, 10808);
assert_eq!(config.outbounds[0].protocol, "vless");
}
}