mirror of
https://github.com/XTLS/Xray-docs-next.git
synced 2026-10-12 17:28:16 +03:00
Xray-core: Add Lua script for dns and routing
https://github.com/XTLS/Xray-core/pull/6823
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
# DNS Scripts
|
||||
|
||||
When a script is specified through [`dns.script`](../../../config/dns.md#dnsobject), Lua's `HandleDNSQuery` function takes over the built-in DNS processing flow.
|
||||
|
||||
## Minimal Example
|
||||
|
||||
Add the following to an existing configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"dns": {
|
||||
"script": "dns.lua",
|
||||
"servers": ["1.1.1.1"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Save `dns.lua`:
|
||||
|
||||
```lua
|
||||
local dns = require("xray.dns")
|
||||
local server = dns.Servers[1]
|
||||
|
||||
function HandleDNSQuery(domain, ipv4, ipv6, fake)
|
||||
return server:Query(domain, ipv4, ipv6, fake)
|
||||
end
|
||||
```
|
||||
|
||||
Merge the fragment above into a complete configuration. See [Script File Paths](../environment.md#script-file-paths) for file lookup rules.
|
||||
|
||||
The example passes the parameters unchanged to the upstream and returns the query results directly. See [HandleDNSQuery](../reference/hook-dns.md) for the full parameters, return-value restrictions, and failure behavior.
|
||||
|
||||
## Relationship with DNS Configuration
|
||||
|
||||
Before a query reaches the script, the built-in DNS domain validation, global query-type restrictions, and Hosts processing still apply. The script is not called if Hosts has already supplied valid IPs or explicitly rejected the request. If Hosts replaces the domain, the script queries the replacement domain.
|
||||
|
||||
When the script is enabled, it controls upstream selection, so `domains`, `skipFallback`, `finalQuery`, `disableFallback`, `disableFallbackIfMatch`, and `enableParallelQuery` no longer have a practical effect.
|
||||
|
||||
Use [`serverObj:Query`](../reference/module-dns.md#query) to query a specific upstream. See that API for the server settings that still take effect.
|
||||
|
||||
::: tip
|
||||
When the mapping between upstreams and filtering rules is fixed, configuring `expectedIPs` / `unexpectedIPs` directly is more convenient and efficient. You can also omit these settings and filter in Lua with methods such as [`ipMatcherObj:FilterIPs`](../reference/module-geodata.md#filterips) for greater flexibility.
|
||||
:::
|
||||
|
||||
## Example: Domain-Based Upstream Selection and Result Filtering
|
||||
|
||||
The following example selects upstreams by domain category and filters Chinese IPs as needed. Each server is configured with an `id`, which the script uses for queries and fallback:
|
||||
|
||||
```json
|
||||
{
|
||||
"dns": {
|
||||
"script": "dns.lua",
|
||||
"tag": "dns-proxy",
|
||||
"servers": [
|
||||
{ "id": "cf", "address": "1.1.1.1" },
|
||||
{ "id": "google", "address": "8.8.8.8" },
|
||||
{ "id": "cn114", "address": "114.114.114.114", "tag": "dns-direct" },
|
||||
{ "id": "cn223", "address": "223.5.5.5", "tag": "dns-direct" },
|
||||
{
|
||||
"id": "google-ecs",
|
||||
"address": "8.8.8.8",
|
||||
"clientIp": "222.85.85.85"
|
||||
},
|
||||
{
|
||||
"id": "google-alt-ecs",
|
||||
"address": "8.8.4.4",
|
||||
"clientIp": "222.85.85.85"
|
||||
}
|
||||
]
|
||||
},
|
||||
"routing": {
|
||||
"rules": [
|
||||
{ "inboundTag": ["dns-direct"], "outboundTag": "direct" },
|
||||
{ "inboundTag": ["dns-proxy"], "outboundTag": "proxy" }
|
||||
]
|
||||
},
|
||||
"outbounds": [
|
||||
{ "tag": "direct", "protocol": "freedom" },
|
||||
{
|
||||
"tag": "proxy",
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`dns-direct` and `dns-proxy` are inbound tags for DNS upstream queries. The routing rules above select the direct outbound `direct` and the VLESS proxy outbound `proxy`, respectively.
|
||||
|
||||
The script first chooses a query order based on the domain, then queries the upstreams one by one. If a query fails or its filtered result is empty, it tries the next server:
|
||||
|
||||
```lua
|
||||
local geodata = require("xray.geodata")
|
||||
local dns = require("xray.dns")
|
||||
local servers = {}
|
||||
for _, server in ipairs(dns.Servers) do
|
||||
servers[server.ID] = server
|
||||
end
|
||||
|
||||
local googleDomainMatcher = geodata.BuildDomainMatcher("geosite:google")
|
||||
local cnDomainMatcher = geodata.BuildDomainMatcher("geosite:cn")
|
||||
local foreignDomainMatcher = geodata.BuildDomainMatcher("geosite:geolocation-!cn")
|
||||
local cnIPMatcher = geodata.BuildIPMatcher("geoip:cn")
|
||||
|
||||
function HandleDNSQuery(domain, ipv4, ipv6, fake)
|
||||
local queries
|
||||
if googleDomainMatcher:MatchAny(domain) then
|
||||
-- Use public DNS for Google domains.
|
||||
queries = {{"cf"}, {"google"}}
|
||||
elseif cnDomainMatcher:MatchAny(domain) then
|
||||
-- For Chinese domains, query direct DNS, keep Chinese IPs, then fall back to proxied DNS.
|
||||
queries = {
|
||||
{"cn114", "cn"}, {"cn223", "cn"}, {"cf"}, {"google"}
|
||||
}
|
||||
elseif foreignDomainMatcher:MatchAny(domain) then
|
||||
-- For non-Chinese domains, exclude Chinese IPs first, then try queries with ECS.
|
||||
queries = {
|
||||
{"cf", "non-cn"}, {"google", "non-cn"},
|
||||
{"google-ecs"}, {"google-alt-ecs"}
|
||||
}
|
||||
else
|
||||
-- For unlisted domains, use ECS to look for Chinese IPs, then fall back to regular public DNS.
|
||||
queries = {
|
||||
{"google-ecs", "cn"}, {"google-alt-ecs", "cn"},
|
||||
{"cf"}, {"google"}
|
||||
}
|
||||
end
|
||||
|
||||
local lastError = "No DNS result meets the conditions"
|
||||
for _, query in ipairs(queries) do
|
||||
local ips, ttl, err =
|
||||
servers[query[1]]:Query(domain, ipv4, ipv6, fake)
|
||||
if err then
|
||||
lastError = err
|
||||
else
|
||||
if query[2] then
|
||||
local inCn, outsideCn = cnIPMatcher:FilterIPs(ips)
|
||||
if query[2] == "cn" then
|
||||
ips = inCn
|
||||
else
|
||||
ips = outsideCn
|
||||
end
|
||||
end
|
||||
if ips and #ips > 0 then
|
||||
return ips, ttl, nil
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil, 0, lastError
|
||||
end
|
||||
```
|
||||
|
||||
See [Pooled Lifecycle](./lifecycle.md#pooled-lifecycle) for top-level initialization and state retention in DNS scripts.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Instances and Lifecycle
|
||||
|
||||
The instance management strategy used by a script entry point determines how Lua instances are created, Hooks are called, state is retained, and instances are destroyed. This page describes these rules by lifecycle type.
|
||||
|
||||
## Pooled Lifecycle
|
||||
|
||||
Currently, [HandleRoute](../reference/hook-routing.md) in [routing scripts](./routing.md) and [HandleDNSQuery](../reference/hook-dns.md) in [DNS scripts](./dns.md) use pooled instances. The following rules apply to both Hooks.
|
||||
|
||||
### Instance Pools and Initialization
|
||||
|
||||
Routing and DNS scripts are bound through the `script` field in their respective configurations and manage separate Lua instance pools. Even when configured to use the same file, they do not share state within Lua instances.
|
||||
|
||||
At startup, Xray reads and compiles the script once, creates the first instance, executes its top-level code, and checks that the required handler exists. Failure to read the file, parse it, execute top-level code, or validate the handler prevents Xray from starting.
|
||||
|
||||
### Hook Calls and Instance Recycling
|
||||
|
||||
Each routing decision or DNS query exclusively uses one instance. An idle instance is reused when available; when concurrent calls require more instances, new ones are created from the compiled script and the top-level code runs again.
|
||||
|
||||
After a handler completes normally, the instance can return to the pool for reuse. An uncaught Lua exception or execution timeout destroys the instance. Reporting a business error through return values, or failing return-value validation, is not a Lua execution exception: the instance can still be reused. Excess idle instances are destroyed automatically.
|
||||
|
||||
When an idle instance is available, each Hook follows a lightweight path: **take an instance → execute the Hook → return the instance**. The script is read and compiled at Xray startup. The green nodes in the diagram show this normal path.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
LOAD["Xray startup<br/>Read and compile the main script once"] --> INIT["Create the first Lua instance<br/>Run top-level code and check the Hook"]
|
||||
INIT --> POOL[("Idle instance pool")]
|
||||
|
||||
subgraph CALL["Pooled Hook call: lightweight reuse path"]
|
||||
TAKE["Take exclusive use of an idle instance"] --> RUN["Execute HandleRoute / HandleDNSQuery"]
|
||||
RUN -->|Normal completion| PUT["Return the instance, retaining state"]
|
||||
end
|
||||
|
||||
REQUEST["Routing decision / DNS query"] --> AVAILABLE{"Idle instance available?"}
|
||||
POOL -.-> AVAILABLE
|
||||
AVAILABLE -->|Yes| TAKE
|
||||
PUT --> POOL
|
||||
AVAILABLE -. No: expand on demand .-> CREATE["Create a new instance from the compiled script<br/>Run top-level code and check the Hook"]
|
||||
CREATE --> RUN
|
||||
RUN -. Uncaught Lua exception / timeout .-> DESTROY["Destroy the instance"]
|
||||
POOL -. Excess idle instances / Xray shutdown .-> DESTROY
|
||||
|
||||
classDef reuse fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20
|
||||
class TAKE,RUN,PUT reuse
|
||||
```
|
||||
|
||||
### State in Pooled Instances
|
||||
|
||||
Global variables, top-level `local` variables, closures, and the `require` module cache belong to the current instance. They persist across calls handled by that instance and are lost when it is destroyed:
|
||||
|
||||
```lua
|
||||
local calls = 0
|
||||
|
||||
function HandleRoute()
|
||||
calls = calls + 1
|
||||
return "direct", "instance-call-" .. calls
|
||||
end
|
||||
```
|
||||
|
||||
The counter above only counts calls handled by the current instance. Different instances have independent `calls` values, and requests are not guaranteed to use the same instance. It cannot serve as a counter shared by all connections or as persistent state for a particular connection.
|
||||
|
||||
Place initialization code, such as loading Lua modules, creating matchers, and saving DNS server objects, outside the Hook function at the script's top level. It runs once when each instance is created, and subsequent Hook calls on that instance can reuse the results.
|
||||
|
||||
### Timeouts and Script Updates
|
||||
|
||||
Currently, each routing and DNS instance has an initialization timeout of **120 seconds**, and each handler call has an execution timeout of **6 seconds**. The call timeout starts after an instance is acquired; these values currently have no separate configuration fields. When calling Xray APIs, cancellation also depends on whether the API responds to cancellation signals; each DNS server is additionally limited by its `timeoutMs` setting.
|
||||
|
||||
Xray does not automatically watch or recompile the main script. Restart Xray after changing it for the changes to take effect. Instances created later also use the main script compiled during the current startup.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Routing Scripts
|
||||
|
||||
When a script is specified through [`routing.script`](../../../config/routing.md#routingobject), Lua's `HandleRoute` function takes over outbound selection.
|
||||
|
||||
## Minimal Example
|
||||
|
||||
The following configuration fragment specifies the script file and configures direct and blackhole outbounds:
|
||||
|
||||
```json
|
||||
{
|
||||
"routing": {
|
||||
"script": "routing.lua"
|
||||
},
|
||||
"outbounds": [
|
||||
{ "tag": "direct", "protocol": "freedom" },
|
||||
{ "tag": "block", "protocol": "blackhole" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Save `routing.lua`:
|
||||
|
||||
```lua
|
||||
function HandleRoute(ctx)
|
||||
local sourceIPs = ctx:GetSourceIPs()
|
||||
if sourceIPs and #sourceIPs > 0
|
||||
and sourceIPs[1]:String() == "127.0.0.1" then
|
||||
return "block"
|
||||
end
|
||||
return "direct"
|
||||
end
|
||||
```
|
||||
|
||||
The script reads the source IP through `ctx`. It returns `block` when the source IP is `127.0.0.1`, and `direct` otherwise, corresponding to the outbound `tag` values configured above.
|
||||
|
||||
Merge the fragment above into a complete configuration. See [Script File Paths](../environment.md#script-file-paths) for file lookup rules.
|
||||
|
||||
`HandleRoute` can also receive more parameters and return a rule name and an error. See [HandleRoute](../reference/hook-routing.md) for the full calling convention and failure behavior.
|
||||
|
||||
## Relationship with Routing Configuration
|
||||
|
||||
When a routing script is enabled, neither `rules` nor `domainStrategy` takes effect. If the script does not select an outbound or encounters an error, the built-in routing rules are not evaluated either.
|
||||
|
||||
You can still configure `balancers`. The script selects an outbound from a balancer through [`router:PickOutbound`](../reference/module-router.md#router-pickoutbound):
|
||||
|
||||
```lua
|
||||
local router = require("xray.router")
|
||||
|
||||
function HandleRoute(ctx)
|
||||
local outboundTag, err = router:PickOutbound("balance")
|
||||
return outboundTag, "lua-balance", err
|
||||
end
|
||||
```
|
||||
|
||||
Replace `balance` in the example with the `tag` of a balancer configured in `routing.balancers`.
|
||||
|
||||
## Example: Explicit DNS Queries for IP-Based Routing
|
||||
|
||||
The following script first handles DNS upstream query traffic, then checks the request's existing target IPs. When needed, it explicitly resolves the domain, and selects an outbound based on private address ranges.
|
||||
|
||||
```json
|
||||
{
|
||||
"dns": {
|
||||
"tag": "dns-query",
|
||||
"servers": ["1.1.1.1"]
|
||||
},
|
||||
"routing": {
|
||||
"script": "routing.lua"
|
||||
},
|
||||
"outbounds": [
|
||||
{ "tag": "direct", "protocol": "freedom" },
|
||||
{
|
||||
"tag": "proxy",
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```lua
|
||||
local dns = require("xray.dns")
|
||||
local geodata = require("xray.geodata")
|
||||
local log = require("xray.log")
|
||||
local privateIPMatcher = geodata.BuildIPMatcher("geoip:private")
|
||||
|
||||
function HandleRoute(ctx, inboundTag, sourcePort, targetPort,
|
||||
localPort, targetDomain, network, protocol, user,
|
||||
vlessRoute, skipDNSResolve)
|
||||
if skipDNSResolve or inboundTag == "dns-query" then
|
||||
return "direct", "lua-dns"
|
||||
end
|
||||
|
||||
local ips = ctx:GetTargetIPs()
|
||||
if privateIPMatcher:AnyMatch(ips) then
|
||||
return "direct", "lua-private"
|
||||
end
|
||||
|
||||
if targetDomain ~= "" then
|
||||
local resolved, ttl, err =
|
||||
dns.Query(targetDomain, true, true, false)
|
||||
if err ~= nil then
|
||||
log.Warning("Resolution of ", targetDomain, " failed: ", err)
|
||||
elseif privateIPMatcher:AnyMatch(resolved) then
|
||||
return "direct", "lua-resolved-private"
|
||||
end
|
||||
end
|
||||
|
||||
return "proxy", "lua-default"
|
||||
end
|
||||
```
|
||||
|
||||
DNS upstream requests in normal mode also pass through routing. The example directly selects an outbound for requests where `skipDNSResolve` is `true` or `inboundTag` is `"dns-query"`, avoiding another DNS lookup that would create a loop.
|
||||
|
||||
The result of `dns.Query` is only used for decisions in the script. It does not automatically change the target IPs in `ctx` or the actual connection destination. The example selects `proxy` if resolution fails; adjust this to your own policy as needed.
|
||||
|
||||
See [Pooled Lifecycle](./lifecycle.md#pooled-lifecycle) for top-level initialization and state retention in routing scripts.
|
||||
Reference in New Issue
Block a user