mirror of
https://github.com/XTLS/Xray-core.git
synced 2026-10-29 22:46:58 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e9d5726c7 | ||
|
|
cd5bfb21d3 | ||
|
|
0afec66085 | ||
|
|
55eecaed16 | ||
|
|
2dfee4b842 | ||
|
|
b4123b5748 | ||
|
|
ec124ff9e0 | ||
|
|
8a3c0cbf1d | ||
|
|
78b5a6ec5c | ||
|
|
5865e3ac3d | ||
|
|
48f62b801a | ||
|
|
43c29f8eb1 | ||
|
|
613d8147bb | ||
|
|
38f090255c | ||
|
|
35a3121a83 | ||
|
|
bca08ac080 | ||
|
|
6a4274ab75 | ||
|
|
229799a7a3 | ||
|
|
3071fbfa25 | ||
|
|
1576e79563 | ||
|
|
67e7f82ca1 | ||
|
|
1b1647cd9d | ||
|
|
557657b576 | ||
|
|
fc5ba8e6a2 | ||
|
|
00c0277787 | ||
|
|
3653dcb4fc | ||
|
|
061c2df291 | ||
|
|
23e2292344 | ||
|
|
41797c234d | ||
|
|
9424810830 | ||
|
|
12b7f85de3 | ||
|
|
de1bbbff88 | ||
|
|
ec73b73f5e | ||
|
|
6307fa1265 | ||
|
|
92ccc939aa | ||
|
|
95461cc148 | ||
|
|
3834e504b8 | ||
|
|
0f127f934c | ||
|
|
0f3d884786 | ||
|
|
da2248c516 | ||
|
|
fe2ae23f42 | ||
|
|
d15966c76c | ||
|
|
206a24a9f0 | ||
|
|
ac5d8a83ba | ||
|
|
8703fdb101 | ||
|
|
10879a4a8b | ||
|
|
0888beb38a | ||
|
|
34acded003 | ||
|
|
676a600edb | ||
|
|
c77a4d0e49 | ||
|
|
5a64db009b | ||
|
|
2bea2df6a1 | ||
|
|
fb2dcedbb7 | ||
|
|
7c2e05dc47 | ||
|
|
1dd2a0bd3c | ||
|
|
70ef97d564 | ||
|
|
701af60772 | ||
|
|
8855145e58 | ||
|
|
c7dbfd5e1e | ||
|
|
aa7aa9ad0a | ||
|
|
2cc08769a5 | ||
|
|
5af2e9c228 | ||
|
|
836a6fed38 | ||
|
|
2a14c50775 | ||
|
|
8989adfd3f | ||
|
|
1a60fc78ff | ||
|
|
2eedef51a2 | ||
|
|
37184948d4 | ||
|
|
9e55a6ed18 | ||
|
|
7da5dae650 | ||
|
|
a3c6cc6aa8 | ||
|
|
1e08d48d88 | ||
|
|
5121c28b85 | ||
|
|
92f7e72490 | ||
|
|
eddad37c69 | ||
|
|
00cde72a9b | ||
|
|
08775afd65 | ||
|
|
e0bae21201 | ||
|
|
9d9a7a1c00 | ||
|
|
b26a91de4f | ||
|
|
1f304916bd |
@@ -9,8 +9,8 @@ ARG TARGETARCH
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -o xray -trimpath -buildvcs=false -gcflags="all=-l=4" -ldflags "-s -w -buildid=" ./main
|
||||
|
||||
# Download geodat into a staging directory
|
||||
ADD https://raw.githubusercontent.com/Loyalsoldier/v2ray-rules-dat/release/geoip.dat /tmp/geodat/geoip.dat
|
||||
ADD https://raw.githubusercontent.com/Loyalsoldier/v2ray-rules-dat/release/geosite.dat /tmp/geodat/geosite.dat
|
||||
ADD https://raw.githubusercontent.com/XTLS/Xray-rules-dat/release/geoip/geoip-full.dat /tmp/geodat/geoip.dat
|
||||
ADD https://raw.githubusercontent.com/XTLS/Xray-rules-dat/release/geosite/geosite-expand.dat /tmp/geodat/geosite.dat
|
||||
|
||||
RUN mkdir -p /tmp/empty
|
||||
|
||||
@@ -40,21 +40,15 @@ RUN echo '{}' >/tmp/usr/local/etc/xray/11_geodata.json
|
||||
RUN echo '{}' >/tmp/usr/local/etc/xray/12_env.json
|
||||
RUN echo '{}' >/tmp/usr/local/etc/xray/99_version.json
|
||||
|
||||
# Create log files
|
||||
RUN mkdir -p /tmp/var/log/xray && touch \
|
||||
/tmp/var/log/xray/access.log \
|
||||
/tmp/var/log/xray/error.log
|
||||
|
||||
# Build finally image
|
||||
FROM gcr.io/distroless/static:nonroot
|
||||
|
||||
COPY --from=build --chown=0:0 --chmod=755 /src/xray /usr/local/bin/xray
|
||||
COPY --from=build --chown=65532:65532 --chmod=755 /tmp/empty /usr/local/share/xray
|
||||
COPY --from=build --chown=65532:65532 --chmod=644 /tmp/geodat/*.dat /usr/local/share/xray/
|
||||
COPY --from=build --chown=0:0 --chmod=755 /tmp/empty /usr/local/etc/xray
|
||||
COPY --from=build --chown=0:0 --chmod=644 /tmp/usr/local/etc/xray/*.json /usr/local/etc/xray/
|
||||
COPY --from=build --chown=0:0 --chmod=755 /tmp/empty /var/log/xray
|
||||
COPY --from=build --chown=65532:65532 --chmod=600 /tmp/var/log/xray/*.log /var/log/xray/
|
||||
COPY --from=build --chown=0:65532 --chmod=750 /tmp/empty /usr/local/etc/xray
|
||||
COPY --from=build --chown=0:65532 --chmod=640 /tmp/usr/local/etc/xray/*.json /usr/local/etc/xray/
|
||||
COPY --from=build --chown=65532:65532 --chmod=700 /tmp/empty /var/log/xray
|
||||
|
||||
VOLUME /usr/local/etc/xray
|
||||
VOLUME /var/log/xray
|
||||
|
||||
@@ -9,8 +9,8 @@ ARG TARGETARCH
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -o xray -trimpath -buildvcs=false -gcflags="all=-l=4" -ldflags "-s -w -buildid=" ./main
|
||||
|
||||
# Download geodat into a staging directory
|
||||
ADD https://raw.githubusercontent.com/Loyalsoldier/v2ray-rules-dat/release/geoip.dat /tmp/geodat/geoip.dat
|
||||
ADD https://raw.githubusercontent.com/Loyalsoldier/v2ray-rules-dat/release/geosite.dat /tmp/geodat/geosite.dat
|
||||
ADD https://raw.githubusercontent.com/XTLS/Xray-rules-dat/release/geoip/geoip-full.dat /tmp/geodat/geoip.dat
|
||||
ADD https://raw.githubusercontent.com/XTLS/Xray-rules-dat/release/geosite/geosite-expand.dat /tmp/geodat/geosite.dat
|
||||
|
||||
RUN mkdir -p /tmp/empty
|
||||
|
||||
@@ -40,11 +40,6 @@ RUN echo '{}' >/tmp/usr/local/etc/xray/11_geodata.json
|
||||
RUN echo '{}' >/tmp/usr/local/etc/xray/12_env.json
|
||||
RUN echo '{}' >/tmp/usr/local/etc/xray/99_version.json
|
||||
|
||||
# Create log files
|
||||
RUN mkdir -p /tmp/var/log/xray && touch \
|
||||
/tmp/var/log/xray/access.log \
|
||||
/tmp/var/log/xray/error.log
|
||||
|
||||
# Build finally image
|
||||
# Note on Distroless Base Image and Architecture Support:
|
||||
# - The official 'gcr.io/distroless/static' image provided by Google only supports a limited set of architectures for Linux:
|
||||
@@ -60,10 +55,9 @@ FROM --platform=linux/amd64 gcr.io/distroless/static:nonroot
|
||||
COPY --from=build --chown=0:0 --chmod=755 /src/xray /usr/local/bin/xray
|
||||
COPY --from=build --chown=65532:65532 --chmod=755 /tmp/empty /usr/local/share/xray
|
||||
COPY --from=build --chown=65532:65532 --chmod=644 /tmp/geodat/*.dat /usr/local/share/xray/
|
||||
COPY --from=build --chown=0:0 --chmod=755 /tmp/empty /usr/local/etc/xray
|
||||
COPY --from=build --chown=0:0 --chmod=644 /tmp/usr/local/etc/xray/*.json /usr/local/etc/xray/
|
||||
COPY --from=build --chown=0:0 --chmod=755 /tmp/empty /var/log/xray
|
||||
COPY --from=build --chown=65532:65532 --chmod=600 /tmp/var/log/xray/*.log /var/log/xray/
|
||||
COPY --from=build --chown=0:65532 --chmod=750 /tmp/empty /usr/local/etc/xray
|
||||
COPY --from=build --chown=0:65532 --chmod=640 /tmp/usr/local/etc/xray/*.json /usr/local/etc/xray/
|
||||
COPY --from=build --chown=65532:65532 --chmod=700 /tmp/empty /var/log/xray
|
||||
|
||||
VOLUME /usr/local/etc/xray
|
||||
VOLUME /var/log/xray
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
max_attempts: 60
|
||||
command: |
|
||||
[ -d 'resources' ] || mkdir resources
|
||||
LIST=('Loyalsoldier v2ray-rules-dat geoip geoip' 'Loyalsoldier v2ray-rules-dat geosite geosite')
|
||||
LIST=('XTLS Xray-rules-dat geoip/geoip-lite geoip' 'XTLS Xray-rules-dat geosite/geosite geosite')
|
||||
for i in "${LIST[@]}"
|
||||
do
|
||||
INFO=($(echo $i | awk 'BEGIN{FS=" ";OFS=" "} {print $1,$2,$3,$4}'))
|
||||
|
||||
@@ -35,24 +35,7 @@ jobs:
|
||||
if: steps.check-assets.outputs.missing == 'true'
|
||||
run: sleep 90
|
||||
|
||||
check-proto:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
steps:
|
||||
- name: Checkout codebase
|
||||
uses: actions/checkout@v7
|
||||
- name: Check Proto Version Header
|
||||
run: |
|
||||
head -n 4 core/config.pb.go > ref.txt
|
||||
find . -name "*.pb.go" ! -name "*_grpc.pb.go" -print0 | while IFS= read -r -d '' file; do
|
||||
if ! cmp -s ref.txt <(head -n 4 "$file"); then
|
||||
echo "Error: Header mismatch in $file"
|
||||
head -n 4 "$file"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
check-format:
|
||||
check-format-and-proto:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
|
||||
permissions:
|
||||
@@ -68,6 +51,8 @@ jobs:
|
||||
cache: false
|
||||
- name: Check Format
|
||||
run: go run ./infra/vformat/main.go -mode check -pwd ./
|
||||
- name: Check Protobuf
|
||||
run: go run ./infra/vprotogen/main.go -mode check -pwd ./
|
||||
|
||||
test:
|
||||
needs: check-assets
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/commander/config.proto
|
||||
|
||||
package commander
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/dispatcher/config.proto
|
||||
|
||||
package dispatcher
|
||||
|
||||
@@ -470,6 +470,9 @@ func (d *DefaultDispatcher) routedDispatch(ctx context.Context, link *transport.
|
||||
return // DO NOT CHANGE: the traffic shouldn't be processed by default outbound if the specified outbound tag doesn't exist (yet), e.g., VLESS Reverse Proxy
|
||||
}
|
||||
} else {
|
||||
if err != common.ErrNoClue {
|
||||
errors.LogErrorInner(ctx, err, "failed to pick route for ", destination)
|
||||
}
|
||||
errors.LogInfo(ctx, "default route for ", destination)
|
||||
}
|
||||
}
|
||||
|
||||
+24
-6
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/dns/config.proto
|
||||
|
||||
package dns
|
||||
@@ -93,6 +93,7 @@ type NameServer struct {
|
||||
UnexpectedIp []*geodata.IPRule `protobuf:"bytes,13,rep,name=unexpected_ip,json=unexpectedIp,proto3" json:"unexpected_ip,omitempty"`
|
||||
ActUnprior bool `protobuf:"varint,14,opt,name=actUnprior,proto3" json:"actUnprior,omitempty"`
|
||||
PolicyID uint32 `protobuf:"varint,17,opt,name=policyID,proto3" json:"policyID,omitempty"`
|
||||
Id string `protobuf:"bytes,18,opt,name=id,proto3" json:"id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -239,6 +240,13 @@ func (x *NameServer) GetPolicyID() uint32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *NameServer) GetId() string {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// NameServer list used by this DNS client.
|
||||
@@ -258,6 +266,7 @@ type Config struct {
|
||||
DisableFallback bool `protobuf:"varint,10,opt,name=disableFallback,proto3" json:"disableFallback,omitempty"`
|
||||
DisableFallbackIfMatch bool `protobuf:"varint,11,opt,name=disableFallbackIfMatch,proto3" json:"disableFallbackIfMatch,omitempty"`
|
||||
EnableParallelQuery bool `protobuf:"varint,14,opt,name=enableParallelQuery,proto3" json:"enableParallelQuery,omitempty"`
|
||||
Script string `protobuf:"bytes,15,opt,name=script,proto3" json:"script,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -369,6 +378,13 @@ func (x *Config) GetEnableParallelQuery() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *Config) GetScript() string {
|
||||
if x != nil {
|
||||
return x.Script
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type Config_HostMapping struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Domain *geodata.DomainRule `protobuf:"bytes,2,opt,name=domain,proto3" json:"domain,omitempty"`
|
||||
@@ -435,7 +451,7 @@ var File_app_dns_config_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_app_dns_config_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x14app/dns/config.proto\x12\fxray.app.dns\x1a\x1ccommon/net/destination.proto\x1a\x1bcommon/geodata/geodat.proto\"\xde\x05\n" +
|
||||
"\x14app/dns/config.proto\x12\fxray.app.dns\x1a\x1ccommon/net/destination.proto\x1a\x1bcommon/geodata/geodat.proto\"\xee\x05\n" +
|
||||
"\n" +
|
||||
"NameServer\x123\n" +
|
||||
"\aaddress\x18\x01 \x01(\v2\x19.xray.common.net.EndpointR\aaddress\x12\x1b\n" +
|
||||
@@ -461,10 +477,11 @@ const file_app_dns_config_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"actUnprior\x18\x0e \x01(\bR\n" +
|
||||
"actUnprior\x12\x1a\n" +
|
||||
"\bpolicyID\x18\x11 \x01(\rR\bpolicyIDB\x0f\n" +
|
||||
"\bpolicyID\x18\x11 \x01(\rR\bpolicyID\x12\x0e\n" +
|
||||
"\x02id\x18\x12 \x01(\tR\x02idB\x0f\n" +
|
||||
"\r_disableCacheB\r\n" +
|
||||
"\v_serveStaleB\x12\n" +
|
||||
"\x10_serveExpiredTTLJ\x04\b\x04\x10\x05\"\x82\x05\n" +
|
||||
"\x10_serveExpiredTTLJ\x04\b\x04\x10\x05\"\x9a\x05\n" +
|
||||
"\x06Config\x129\n" +
|
||||
"\vname_server\x18\x05 \x03(\v2\x18.xray.app.dns.NameServerR\n" +
|
||||
"nameServer\x12\x1b\n" +
|
||||
@@ -480,7 +497,8 @@ const file_app_dns_config_proto_rawDesc = "" +
|
||||
"\x0fdisableFallback\x18\n" +
|
||||
" \x01(\bR\x0fdisableFallback\x126\n" +
|
||||
"\x16disableFallbackIfMatch\x18\v \x01(\bR\x16disableFallbackIfMatch\x120\n" +
|
||||
"\x13enableParallelQuery\x18\x0e \x01(\bR\x13enableParallelQuery\x1a}\n" +
|
||||
"\x13enableParallelQuery\x18\x0e \x01(\bR\x13enableParallelQuery\x12\x16\n" +
|
||||
"\x06script\x18\x0f \x01(\tR\x06script\x1a}\n" +
|
||||
"\vHostMapping\x127\n" +
|
||||
"\x06domain\x18\x02 \x01(\v2\x1f.xray.common.geodata.DomainRuleR\x06domain\x12\x0e\n" +
|
||||
"\x02ip\x18\x03 \x03(\fR\x02ip\x12%\n" +
|
||||
|
||||
@@ -27,6 +27,7 @@ message NameServer {
|
||||
repeated xray.common.geodata.IPRule unexpected_ip = 13;
|
||||
bool actUnprior = 14;
|
||||
uint32 policyID = 17;
|
||||
string id = 18;
|
||||
}
|
||||
|
||||
enum QueryStrategy {
|
||||
@@ -73,4 +74,6 @@ message Config {
|
||||
bool disableFallbackIfMatch = 11;
|
||||
|
||||
bool enableParallelQuery = 14;
|
||||
|
||||
string script = 15;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type DNS struct {
|
||||
domainMatcher geodata.DomainMatcher
|
||||
matcherInfos []*DomainMatcherInfo
|
||||
checkSystem bool
|
||||
script *scriptEngine
|
||||
}
|
||||
|
||||
// DomainMatcherInfo contains information attached to index returned by Server.domainMatcher.
|
||||
@@ -169,6 +170,13 @@ func New(ctx context.Context, config *Config) (*DNS, error) {
|
||||
clients = append(clients, NewLocalDNSClient(ipOption))
|
||||
}
|
||||
|
||||
var script *scriptEngine
|
||||
if config.Script != "" {
|
||||
if script, err = newScriptEngine(config.Script); err != nil {
|
||||
return nil, errors.New("failed to prepare DNS script").Base(err)
|
||||
}
|
||||
}
|
||||
|
||||
return &DNS{
|
||||
hosts: hosts,
|
||||
ipOption: &ipOption,
|
||||
@@ -180,6 +188,7 @@ func New(ctx context.Context, config *Config) (*DNS, error) {
|
||||
disableFallbackIfMatch: config.DisableFallbackIfMatch,
|
||||
enableParallelQuery: config.EnableParallelQuery,
|
||||
checkSystem: checkSystem,
|
||||
script: script,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -190,11 +199,19 @@ func (*DNS) Type() interface{} {
|
||||
|
||||
// Start implements common.Runnable.
|
||||
func (s *DNS) Start() error {
|
||||
if s.script != nil {
|
||||
if err := s.script.start(s); err != nil {
|
||||
return errors.New("failed to initialize DNS script").Base(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close implements common.Closable.
|
||||
func (s *DNS) Close() error {
|
||||
if s.script != nil {
|
||||
s.script.close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -279,6 +296,9 @@ func (s *DNS) LookupIP(domain string, option dns.IPOption) ([]net.IP, uint32, er
|
||||
}
|
||||
|
||||
// Name servers lookup
|
||||
if s.script != nil {
|
||||
return s.script.query(domain, option)
|
||||
}
|
||||
if s.enableParallelQuery {
|
||||
return s.parallelQuery(domain, option)
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/dns/fakedns/fakedns.proto
|
||||
|
||||
package fakedns
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/xtls/xray-core/common/errors"
|
||||
xlua "github.com/xtls/xray-core/common/lua"
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
featureDNS "github.com/xtls/xray-core/features/dns"
|
||||
"github.com/xtls/xray-core/features/dns/localdns"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// luaDNSServer adapts configured and local DNS to the same Lua API.
|
||||
type luaDNSServer struct {
|
||||
id string
|
||||
name string
|
||||
query func(context.Context, string, featureDNS.IPOption) ([]net.IP, uint32, error)
|
||||
}
|
||||
|
||||
// RegisterLua makes xray.dns available to scripts backed by client.
|
||||
func RegisterLua(L *lua.LState, client featureDNS.Client) {
|
||||
var servers []luaDNSServer
|
||||
switch client := client.(type) {
|
||||
case *DNS:
|
||||
servers = luaServers(client)
|
||||
case *localdns.Client:
|
||||
servers = []luaDNSServer{{
|
||||
id: "localhost",
|
||||
name: "localhost",
|
||||
query: func(_ context.Context, domain string, option featureDNS.IPOption) ([]net.IP, uint32, error) {
|
||||
return client.LookupIP(domain, option)
|
||||
},
|
||||
}}
|
||||
}
|
||||
registerLua(L, servers, client)
|
||||
}
|
||||
|
||||
// registerLua makes xray.dns available to DNS scripts.
|
||||
func (s *DNS) registerLua(L *lua.LState) {
|
||||
registerLua(L, luaServers(s), nil)
|
||||
}
|
||||
|
||||
func luaServers(s *DNS) []luaDNSServer {
|
||||
servers := make([]luaDNSServer, len(s.clients))
|
||||
for i, client := range s.clients {
|
||||
servers[i] = luaDNSServer{id: client.id, name: client.Name(), query: client.QueryIP}
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
func registerLua(L *lua.LState, servers []luaDNSServer, client featureDNS.Client) {
|
||||
L.PreloadModule("xray.dns", func(L *lua.LState) int {
|
||||
pushIPs := xlua.NewReadOnlySlicePusher[net.IP](L)
|
||||
|
||||
serverList := L.CreateTable(len(servers), 0)
|
||||
for i, client := range servers {
|
||||
server := L.CreateTable(0, 2)
|
||||
|
||||
server.RawSetString("ID", lua.LString(client.id))
|
||||
|
||||
server.RawSetString("Query", L.NewFunction(func(L *lua.LState) int {
|
||||
domain, ok := L.Get(2).(lua.LString)
|
||||
if !ok {
|
||||
L.RaiseError("server:Query requires a domain")
|
||||
return 0
|
||||
}
|
||||
option := featureDNS.IPOption{
|
||||
IPv4Enable: L.CheckBool(3),
|
||||
IPv6Enable: L.CheckBool(4),
|
||||
FakeEnable: L.CheckBool(5),
|
||||
}
|
||||
ctx := L.Context()
|
||||
if ctx == nil {
|
||||
L.RaiseError("server:Query requires an active DNS query")
|
||||
return 0
|
||||
}
|
||||
var ips []net.IP
|
||||
var ttl uint32
|
||||
var err error
|
||||
if !option.FakeEnable && strings.EqualFold(client.name, "FakeDNS") {
|
||||
err = featureDNS.ErrEmptyResponse
|
||||
} else {
|
||||
ips, ttl, err = client.query(ctx, string(domain), option)
|
||||
}
|
||||
pushIPs(L, ips)
|
||||
xlua.PushNumber(L, ttl)
|
||||
xlua.PushError(L, err)
|
||||
return 3
|
||||
}))
|
||||
serverList.RawSetInt(i+1, server)
|
||||
}
|
||||
|
||||
module := L.CreateTable(0, 2)
|
||||
if servers != nil {
|
||||
module.RawSetString("Servers", serverList)
|
||||
}
|
||||
if client != nil {
|
||||
module.RawSetString("Query", newLuaClientQuery(L, client, pushIPs))
|
||||
}
|
||||
L.Push(module)
|
||||
return 1
|
||||
})
|
||||
}
|
||||
|
||||
func newLuaClientQuery(L *lua.LState, client featureDNS.Client, pushIPs func(*lua.LState, []net.IP)) *lua.LFunction {
|
||||
return L.NewFunction(func(L *lua.LState) int {
|
||||
domain, ok := L.Get(1).(lua.LString)
|
||||
if !ok {
|
||||
L.RaiseError("dns.Query requires a domain")
|
||||
return 0
|
||||
}
|
||||
option := featureDNS.IPOption{
|
||||
IPv4Enable: L.CheckBool(2),
|
||||
IPv6Enable: L.CheckBool(3),
|
||||
FakeEnable: L.CheckBool(4),
|
||||
}
|
||||
if L.Context() == nil {
|
||||
L.RaiseError("dns.Query requires an active DNS query")
|
||||
return 0
|
||||
}
|
||||
ips, ttl, err := client.LookupIP(string(domain), option)
|
||||
pushIPs(L, ips)
|
||||
xlua.PushNumber(L, ttl)
|
||||
xlua.PushError(L, err)
|
||||
return 3
|
||||
})
|
||||
}
|
||||
|
||||
// callLuaQuery runs HandleDNSQuery and leaves (ips, ttl, err) on the stack.
|
||||
func callLuaQuery(L *lua.LState, domain string, option featureDNS.IPOption) error {
|
||||
fn := L.GetGlobal("HandleDNSQuery")
|
||||
if fn.Type() != lua.LTFunction {
|
||||
return errors.New("DNS script must define HandleDNSQuery(...)")
|
||||
}
|
||||
|
||||
return L.CallByParam(lua.P{Fn: fn, NRet: 3, Protect: true},
|
||||
lua.LString(strings.ToLower(domain)),
|
||||
lua.LBool(option.IPv4Enable),
|
||||
lua.LBool(option.IPv6Enable),
|
||||
lua.LBool(option.FakeEnable))
|
||||
}
|
||||
|
||||
// readLuaQueryResult reads (ips, ttl, err) from the stack without copying the IPs.
|
||||
func readLuaQueryResult(L *lua.LState) ([]net.IP, uint32, error) {
|
||||
if err := xlua.ReadError(L.Get(-1), "DNS script error must be an error or string"); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
ttl, err := xlua.ReadUint32(L.Get(-2), "DNS script returned invalid TTL")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
addresses := L.Get(-3)
|
||||
if addresses == lua.LNil {
|
||||
return nil, 0, featureDNS.ErrEmptyResponse
|
||||
}
|
||||
ips, err := xlua.ReadUserData[[]net.IP](addresses, "DNS script IPs must be native IP slice userdata")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, 0, featureDNS.ErrEmptyResponse
|
||||
}
|
||||
|
||||
return ips, ttl, nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
featureDNS "github.com/xtls/xray-core/features/dns"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// BenchmarkLuaDNSHook isolates scalar argument bridging and a fixed return.
|
||||
// It excludes upstream queries, result decoding, and state pool management.
|
||||
func BenchmarkLuaDNSHook(b *testing.B) {
|
||||
L := lua.NewState()
|
||||
b.Cleanup(L.Close)
|
||||
if err := L.DoString(`
|
||||
function HandleDNSQuery(domain, ipv4, ipv6, fake)
|
||||
return true
|
||||
end
|
||||
`); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
L.SetContext(context.Background())
|
||||
option := featureDNS.IPOption{IPv4Enable: true}
|
||||
if err := callLuaQuery(L, "example.com", option); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if L.Get(-3) != lua.LTrue {
|
||||
b.Fatal("hook did not return true")
|
||||
}
|
||||
L.Pop(3)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := callLuaQuery(L, "example.com", option); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
L.Pop(3)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkLuaDNSQuery queries the same preselected, in-memory upstream.
|
||||
// client_query compares Client.QueryIP to a preloaded server:Query hook.
|
||||
// script_query additionally measures production pool and timeout management.
|
||||
// These cases do not measure DNS.LookupIP server selection or network latency.
|
||||
func BenchmarkLuaDNSQuery(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
option := featureDNS.IPOption{IPv4Enable: true}
|
||||
ip := net.ParseIP("127.0.0.1")
|
||||
upstream := &benchmarkLuaNameServer{ips: []net.IP{ip}}
|
||||
client := &Client{server: upstream, ipOption: &option, timeoutMs: time.Second}
|
||||
server := &DNS{ctx: ctx, clients: []*Client{client}}
|
||||
const script = `
|
||||
local server = require("xray.dns").Servers[1]
|
||||
function HandleDNSQuery(domain, ipv4, ipv6, fake)
|
||||
return server:Query(domain, ipv4, ipv6, fake)
|
||||
end
|
||||
`
|
||||
L := lua.NewState()
|
||||
b.Cleanup(L.Close)
|
||||
server.registerLua(L)
|
||||
if err := L.DoString(script); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
L.SetContext(ctx)
|
||||
|
||||
path := filepath.Join(b.TempDir(), "query.lua")
|
||||
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
engine, err := newScriptEngine(path)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := engine.start(server); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.Cleanup(engine.close)
|
||||
for _, bench := range []struct {
|
||||
name string
|
||||
query func() ([]net.IP, uint32, error)
|
||||
}{
|
||||
{"client_query/native", func() ([]net.IP, uint32, error) {
|
||||
return client.QueryIP(ctx, "example.com", option)
|
||||
}},
|
||||
{"client_query/lua", func() ([]net.IP, uint32, error) {
|
||||
if err := callLuaQuery(L, "example.com", option); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
ips, ttl, err := readLuaQueryResult(L)
|
||||
L.Pop(3)
|
||||
return ips, ttl, err
|
||||
}},
|
||||
{"script_query/lua", func() ([]net.IP, uint32, error) {
|
||||
return engine.query("example.com", option)
|
||||
}},
|
||||
} {
|
||||
b.Run(bench.name, func(b *testing.B) {
|
||||
ips, ttl, err := bench.query()
|
||||
if err != nil || ttl != 60 || len(ips) != 1 || !ips[0].Equal(ip) {
|
||||
b.Fatalf("query() = %v, TTL %d, %v; want %v, TTL 60", ips, ttl, err, ip)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
ips, ttl, err = bench.query()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.StopTimer()
|
||||
if ttl != 60 || len(ips) != 1 || !ips[0].Equal(ip) {
|
||||
b.Fatalf("query() = %v, TTL %d; want %v, TTL 60", ips, ttl, ip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
go_errors "errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xtls/xray-core/common/geodata"
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
featureDNS "github.com/xtls/xray-core/features/dns"
|
||||
"github.com/xtls/xray-core/features/dns/localdns"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func TestReadLuaQueryResult(t *testing.T) {
|
||||
wantIPs := []net.IP{net.ParseIP("8.8.8.8"), {127, 0, 0, 1}, net.ParseIP("::1")}
|
||||
nativeErr := go_errors.New("upstream failed")
|
||||
for _, tc := range []struct {
|
||||
name, values string
|
||||
wantIPs []net.IP
|
||||
wantTTL uint32
|
||||
wantErr error
|
||||
wantMessage string
|
||||
}{
|
||||
{name: "IPs", values: `ips, 45`, wantIPs: wantIPs, wantTTL: 45},
|
||||
{name: "nil IPs", values: `nil, 0`, wantErr: featureDNS.ErrEmptyResponse},
|
||||
{name: "empty IPs", values: `emptyIPs, 0`, wantErr: featureDNS.ErrEmptyResponse},
|
||||
{name: "native error", values: `nil, nil, nativeError`, wantErr: nativeErr},
|
||||
{name: "string error", values: `nil, nil, "blocked"`, wantMessage: "blocked"},
|
||||
{name: "fractional TTL", values: `ips, 1.5`, wantMessage: "invalid TTL"},
|
||||
{name: "oversized TTL", values: `ips, 4294967296`, wantMessage: "invalid TTL"},
|
||||
{name: "negative TTL", values: `ips, -1`, wantMessage: "invalid TTL"},
|
||||
{name: "NaN TTL", values: `ips, 0/0`, wantMessage: "invalid TTL"},
|
||||
{name: "missing TTL", values: `ips`, wantMessage: "invalid TTL"},
|
||||
{name: "string IPs", values: `"127.0.0.1", 60`, wantMessage: "native IP slice"},
|
||||
{name: "wrong userdata", values: `ip, 60`, wantMessage: "native IP slice"},
|
||||
{name: "invalid error", values: `ips, 60, false`, wantMessage: "error or string"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
for name, value := range map[string]any{"ips": wantIPs, "ip": wantIPs[0], "emptyIPs": []net.IP(nil), "nativeError": nativeErr} {
|
||||
ud := L.NewUserData()
|
||||
ud.Value = value
|
||||
L.SetGlobal(name, ud)
|
||||
}
|
||||
fn, err := L.LoadString("return " + tc.values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := L.CallByParam(lua.P{Fn: fn, NRet: 3, Protect: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ips, ttl, err := readLuaQueryResult(L)
|
||||
switch {
|
||||
case tc.wantErr != nil:
|
||||
if err != tc.wantErr {
|
||||
t.Fatalf("error = %v, want original error %v", err, tc.wantErr)
|
||||
}
|
||||
case tc.wantMessage != "":
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantMessage) {
|
||||
t.Fatalf("error = %v, want %q", err, tc.wantMessage)
|
||||
}
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ttl != tc.wantTTL || len(ips) != len(tc.wantIPs) {
|
||||
t.Fatalf("result = %v, TTL %d; want %v, TTL %d", ips, ttl, tc.wantIPs, tc.wantTTL)
|
||||
}
|
||||
for i := range ips {
|
||||
if !ips[i].Equal(tc.wantIPs[i]) {
|
||||
t.Fatalf("IP %d = %v, want %v", i, ips[i], tc.wantIPs[i])
|
||||
}
|
||||
}
|
||||
if len(ips) != 0 && &ips[0] != &tc.wantIPs[0] {
|
||||
t.Fatal("result copied the IP slice")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallLuaQueryCancellation(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
if err := L.DoString(`function HandleDNSQuery(domain, ipv4, ipv6, fake) while true do end end`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
L.SetContext(ctx)
|
||||
err := callLuaQuery(L, "example.com", featureDNS.IPOption{IPv4Enable: true})
|
||||
if err == nil {
|
||||
t.Fatal("callLuaQuery did not stop after context cancellation")
|
||||
}
|
||||
if L.Context() != ctx {
|
||||
t.Fatal("callLuaQuery changed the Lua state's context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallLuaQuery(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
addresses := L.NewUserData()
|
||||
addresses.Value = []net.IP{net.ParseIP("127.0.0.1")}
|
||||
L.SetGlobal("ips", addresses)
|
||||
if err := L.DoString(`
|
||||
function HandleDNSQuery(domain, ipv4, ipv6, fake)
|
||||
assert(domain == "example.com")
|
||||
assert(ipv4 and not ipv6 and not fake)
|
||||
return ips, 60, nil
|
||||
end
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := callLuaQuery(L, "ExAmPlE.CoM", featureDNS.IPOption{IPv4Enable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if L.GetTop() != 3 || L.Get(1) != addresses || L.Get(2) != lua.LNumber(60) || L.Get(3) != lua.LNil {
|
||||
t.Fatal("callLuaQuery did not leave the three query results on the stack")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaDNSServerQuery(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
geodata.RegisterLua(L)
|
||||
option := featureDNS.IPOption{IPv4Enable: true}
|
||||
ips := []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("8.8.8.8")}
|
||||
server := &DNS{clients: []*Client{{server: &benchmarkLuaNameServer{ips: ips}, ipOption: &option, timeoutMs: time.Second}}}
|
||||
server.registerLua(L)
|
||||
if err := L.DoString(`
|
||||
local server = require("xray.dns").Servers[1]
|
||||
local matcher = require("xray.geodata").BuildIPMatcher("127.0.0.0/8")
|
||||
function HandleDNSQuery(domain, ipv4, ipv6, fake)
|
||||
local ips, ttl, err = server:Query(domain, ipv4, ipv6, fake)
|
||||
assert(type(ips) == "userdata" and not err)
|
||||
assert(#ips == 2 and ips[1]:String() == "127.0.0.1" and ips[2]:String() == "8.8.8.8")
|
||||
assert(matcher:Match(ips[1]) and not matcher:Match(ips[2]))
|
||||
assert(matcher:AnyMatch(ips))
|
||||
local matched, unmatched = matcher:FilterIPs(ips)
|
||||
assert(#matched == 1 and #unmatched == 1)
|
||||
assert(matched[1]:Equal(ips[1]) and unmatched[1]:Equal(ips[2]))
|
||||
return matched, ttl, err
|
||||
end
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
L.SetContext(context.Background())
|
||||
if err := callLuaQuery(L, "example.com", option); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ttl, err := readLuaQueryResult(L)
|
||||
if err != nil || ttl != 60 || len(got) != 1 || !got[0].Equal(ips[0]) {
|
||||
t.Fatalf("server query = %v, TTL %d, %v", got, ttl, err)
|
||||
}
|
||||
}
|
||||
|
||||
type luaDNSClient struct {
|
||||
featureDNS.Client
|
||||
lookup func(string, featureDNS.IPOption) ([]net.IP, uint32, error)
|
||||
}
|
||||
|
||||
func (c *luaDNSClient) LookupIP(domain string, option featureDNS.IPOption) ([]net.IP, uint32, error) {
|
||||
return c.lookup(domain, option)
|
||||
}
|
||||
|
||||
func TestLuaDNSClientQuery(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
L.SetContext(context.Background())
|
||||
geodata.RegisterLua(L)
|
||||
want := []net.IP{{127, 0, 0, 1}, net.ParseIP("::1")}
|
||||
client := &luaDNSClient{lookup: func(domain string, option featureDNS.IPOption) ([]net.IP, uint32, error) {
|
||||
if domain != "MiXeD.Example." || !option.IPv4Enable || option.IPv6Enable || !option.FakeEnable {
|
||||
t.Fatalf("dns.Query arguments = %q, %+v", domain, option)
|
||||
}
|
||||
return want, 42, nil
|
||||
}}
|
||||
RegisterLua(L, client)
|
||||
if err := L.DoString(`
|
||||
local dns = require("xray.dns")
|
||||
local matcher = require("xray.geodata").BuildIPMatcher("127.0.0.1")
|
||||
assert(dns.Servers == nil)
|
||||
ips, ttl, err = dns.Query("MiXeD.Example.", true, false, true)
|
||||
assert(not err and ttl == 42 and matcher:AnyMatch(ips))
|
||||
assert(#ips == 2 and ips[1]:String() == "127.0.0.1" and ips[2]:String() == "::1")
|
||||
assert(matcher:Match(ips[1]) and not matcher:Match(ips[2]))
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := L.GetGlobal("ips").(*lua.LUserData).Value.([]net.IP)
|
||||
if &got[0] != &want[0] {
|
||||
t.Fatal("dns.Query copied the IP slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaDNSLocalClient(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
L.SetContext(context.Background())
|
||||
RegisterLua(L, localdns.New())
|
||||
if err := L.DoString(`
|
||||
local dns = require("xray.dns")
|
||||
assert(dns.Servers[1].ID == "localhost")
|
||||
serverIPs, _, serverErr = dns.Servers[1]:Query("127.0.0.1", true, false, false)
|
||||
clientIPs, _, clientErr = dns.Query("127.0.0.1", true, false, false)
|
||||
assert(not serverErr and not clientErr)
|
||||
assert(#serverIPs == 1 and #clientIPs == 1)
|
||||
assert(serverIPs[1]:String() == "127.0.0.1" and serverIPs[1]:Equal(clientIPs[1]))
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"serverIPs", "clientIPs"} {
|
||||
ips := L.GetGlobal(name).(*lua.LUserData).Value.([]net.IP)
|
||||
if len(ips) != 1 || !ips[0].Equal(net.ParseIP("127.0.0.1")) {
|
||||
t.Fatalf("%s = %v", name, ips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaDNSQueryEmptyIPs(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ips []net.IP
|
||||
}{
|
||||
{"nil", nil},
|
||||
{"empty", []net.IP{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
L.SetContext(context.Background())
|
||||
L.SetGlobal("expectNil", lua.LBool(tc.ips == nil))
|
||||
client := &luaDNSClient{lookup: func(string, featureDNS.IPOption) ([]net.IP, uint32, error) {
|
||||
return tc.ips, 0, featureDNS.ErrEmptyResponse
|
||||
}}
|
||||
registerLua(L, []luaDNSServer{{query: func(_ context.Context, domain string, option featureDNS.IPOption) ([]net.IP, uint32, error) {
|
||||
return client.LookupIP(domain, option)
|
||||
}}}, client)
|
||||
if err := L.DoString(`
|
||||
local dns = require("xray.dns")
|
||||
for _, query in ipairs({
|
||||
function() return dns.Servers[1]:Query("empty.example", true, false, false) end,
|
||||
function() return dns.Query("empty.example", true, false, false) end,
|
||||
}) do
|
||||
local ips, ttl, err = query()
|
||||
assert(ttl == 0 and err)
|
||||
if expectNil then
|
||||
assert(ips == nil)
|
||||
else
|
||||
assert(type(ips) == "userdata" and #ips == 0)
|
||||
assert(not pcall(function() return ips[1] end))
|
||||
end
|
||||
end
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type benchmarkLuaNameServer struct {
|
||||
ips []net.IP
|
||||
}
|
||||
|
||||
func (*benchmarkLuaNameServer) Name() string { return "benchmark" }
|
||||
func (*benchmarkLuaNameServer) IsDisableCache() bool { return true }
|
||||
func (s *benchmarkLuaNameServer) QueryIP(context.Context, string, featureDNS.IPOption) ([]net.IP, uint32, error) {
|
||||
return s.ips, 60, nil
|
||||
}
|
||||
@@ -29,6 +29,7 @@ type Server interface {
|
||||
|
||||
// Client is the interface for DNS client.
|
||||
type Client struct {
|
||||
id string
|
||||
server Server
|
||||
skipFallback bool
|
||||
expectedIPs geodata.IPMatcher
|
||||
@@ -97,7 +98,7 @@ func NewClient(
|
||||
ipOption dns.IPOption,
|
||||
updateRules func(bool),
|
||||
) (*Client, error) {
|
||||
client := &Client{}
|
||||
client := &Client{id: ns.Id}
|
||||
err := core.RequireFeatures(ctx, func(dispatcher routing.Dispatcher) error {
|
||||
// Create a new server for each client for now
|
||||
server, err := NewServer(ctx, ns.Address.AsDestination(), dispatcher, disableCache, serveStale, serveExpiredTTL, clientIP)
|
||||
|
||||
@@ -49,5 +49,5 @@ func NewLocalNameServer() *LocalNameServer {
|
||||
|
||||
// NewLocalDNSClient creates localdns client object for directly lookup in system DNS.
|
||||
func NewLocalDNSClient(ipOption dns.IPOption) *Client {
|
||||
return &Client{server: NewLocalNameServer(), ipOption: &ipOption}
|
||||
return &Client{id: "localhost", server: NewLocalNameServer(), ipOption: &ipOption}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/xtls/xray-core/common/errors"
|
||||
"github.com/xtls/xray-core/common/geodata"
|
||||
"github.com/xtls/xray-core/common/log"
|
||||
xlua "github.com/xtls/xray-core/common/lua"
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
"github.com/xtls/xray-core/features/dns"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
const scriptExecutionTimeout = 6 * time.Second
|
||||
|
||||
type scriptEngine struct {
|
||||
program *xlua.Program
|
||||
pool *xlua.Pool
|
||||
}
|
||||
|
||||
func newScriptEngine(script string) (*scriptEngine, error) {
|
||||
program, err := xlua.Compile(script)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &scriptEngine{program: program}, nil
|
||||
}
|
||||
|
||||
func (e *scriptEngine) start(server *DNS) error {
|
||||
pool, err := xlua.NewPool(server.ctx, scriptExecutionTimeout, e.program.NewStateFactory(
|
||||
scriptExecutionTimeout*20,
|
||||
256,
|
||||
func(L *lua.LState) {
|
||||
geodata.RegisterLua(L)
|
||||
log.RegisterLua(L)
|
||||
server.registerLua(L)
|
||||
},
|
||||
func(L *lua.LState) error {
|
||||
if L.GetGlobal("HandleDNSQuery").Type() != lua.LTFunction {
|
||||
return errors.New("DNS script must define HandleDNSQuery(...)")
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.pool = pool
|
||||
errors.LogInfo(server.ctx, "DNS script initialized")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *scriptEngine) close() {
|
||||
if e.pool != nil {
|
||||
e.pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (e *scriptEngine) query(domain string, option dns.IPOption) (ips []net.IP, ttl uint32, queryErr error) {
|
||||
if err := e.pool.WithState(nil, 0, func(L *lua.LState) error {
|
||||
if err := callLuaQuery(L, domain, option); err != nil {
|
||||
return err
|
||||
}
|
||||
ips, ttl, queryErr = readLuaQueryResult(L)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return ips, ttl, queryErr
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
go_errors "errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
featureDNS "github.com/xtls/xray-core/features/dns"
|
||||
)
|
||||
|
||||
type scriptNameServer struct {
|
||||
name string
|
||||
answers map[string]net.IP
|
||||
errors map[string]error
|
||||
ttl uint32
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *scriptNameServer) Name() string { return s.name }
|
||||
func (s *scriptNameServer) IsDisableCache() bool { return true }
|
||||
|
||||
func (s *scriptNameServer) QueryIP(ctx context.Context, domain string, _ featureDNS.IPOption) ([]net.IP, uint32, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
s.calls++
|
||||
if err := s.errors[domain]; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
ip, ok := s.answers[domain]
|
||||
if !ok {
|
||||
return nil, 0, featureDNS.ErrEmptyResponse
|
||||
}
|
||||
return []net.IP{ip}, s.ttl, nil
|
||||
}
|
||||
|
||||
func TestDNSScriptQuery(t *testing.T) {
|
||||
wantIP := net.ParseIP("127.0.0.1")
|
||||
upstreamErr := go_errors.New("upstream failed")
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
wantIPs []net.IP
|
||||
wantTTL uint32
|
||||
wantErr error
|
||||
wantMessage string
|
||||
wantCalls uint32
|
||||
}{
|
||||
{name: "IPs", body: `return server:Query(domain, ipv4, ipv6, fake)`, wantIPs: []net.IP{wantIP}, wantTTL: 60, wantCalls: 2},
|
||||
{name: "empty result", body: `return nil, 0`, wantErr: featureDNS.ErrEmptyResponse, wantCalls: 2},
|
||||
{name: "upstream error", body: `return server:Query("failed.example", ipv4, ipv6, fake)`, wantErr: upstreamErr, wantCalls: 2},
|
||||
{name: "string error", body: `return nil, nil, "blocked"`, wantMessage: "blocked", wantCalls: 2},
|
||||
{name: "invalid result", body: `return false, 0`, wantMessage: "native IP slice", wantCalls: 2},
|
||||
{name: "execution error", body: `error("execution failed")`, wantMessage: "execution failed", wantCalls: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
script := `
|
||||
local server = require("xray.dns").Servers[1]
|
||||
local calls = 0
|
||||
function HandleDNSQuery(domain, ipv4, ipv6, fake)
|
||||
calls = calls + 1
|
||||
if domain == "count.example" then
|
||||
local ips, _, err = server:Query("good.example", ipv4, ipv6, fake)
|
||||
return ips, calls, err
|
||||
end
|
||||
` + tc.body + `
|
||||
end
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), "query.lua")
|
||||
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
option := featureDNS.IPOption{IPv4Enable: true}
|
||||
upstream := &scriptNameServer{
|
||||
name: "test",
|
||||
answers: map[string]net.IP{"good.example": wantIP},
|
||||
errors: map[string]error{"failed.example": upstreamErr},
|
||||
ttl: 60,
|
||||
}
|
||||
server := &DNS{
|
||||
ctx: context.Background(),
|
||||
clients: []*Client{{server: upstream, ipOption: &option, timeoutMs: time.Second}},
|
||||
}
|
||||
engine, err := newScriptEngine(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := engine.start(server); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer engine.close()
|
||||
|
||||
ips, ttl, err := engine.query("good.example", option)
|
||||
switch {
|
||||
case tc.wantErr != nil:
|
||||
if err != tc.wantErr {
|
||||
t.Fatalf("query error = %v, want original error %v", err, tc.wantErr)
|
||||
}
|
||||
case tc.wantMessage != "":
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantMessage) {
|
||||
t.Fatalf("query error = %v, want %q", err, tc.wantMessage)
|
||||
}
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ttl != tc.wantTTL || len(ips) != len(tc.wantIPs) {
|
||||
t.Fatalf("query = %v, TTL %d; want %v, TTL %d", ips, ttl, tc.wantIPs, tc.wantTTL)
|
||||
}
|
||||
for i := range ips {
|
||||
if !ips[i].Equal(tc.wantIPs[i]) {
|
||||
t.Fatalf("IP %d = %v, want %v", i, ips[i], tc.wantIPs[i])
|
||||
}
|
||||
}
|
||||
|
||||
ips, calls, err := engine.query("count.example", option)
|
||||
if err != nil || calls != tc.wantCalls || len(ips) != 1 || !ips[0].Equal(wantIP) {
|
||||
t.Fatalf("next query = %v, calls %d, %v; want %v, calls %d", ips, calls, err, wantIP, tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSScriptGeoIPFallback(t *testing.T) {
|
||||
t.Setenv("xray.location.asset", filepath.Join("..", "..", "resources"))
|
||||
script := `
|
||||
local servers = require("xray.dns").Servers
|
||||
local ru_ips = require("xray.geodata").BuildIPMatcher("geoip:ru")
|
||||
|
||||
local by_id = {}
|
||||
for _, server in ipairs(servers) do
|
||||
by_id[server.ID] = server
|
||||
end
|
||||
assert(by_id.primary and by_id.fallback, "primary and fallback DNS servers are required")
|
||||
|
||||
function HandleDNSQuery(domain, ipv4, ipv6, fake)
|
||||
local ips, ttl, err = by_id.primary:Query(domain, ipv4, ipv6, fake)
|
||||
if not err and ru_ips:AnyMatch(ips) then
|
||||
return ips, ttl, nil
|
||||
end
|
||||
return by_id.fallback:Query(domain, ipv4, ipv6, fake)
|
||||
end
|
||||
`
|
||||
scriptPath := filepath.Join(t.TempDir(), "geoip_fallback.lua")
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
primary := &scriptNameServer{
|
||||
name: "primary",
|
||||
answers: map[string]net.IP{
|
||||
"ru.example": net.ParseIP("2a02:6b8::feed:0ff"),
|
||||
"other.example": net.ParseIP("127.0.0.1"),
|
||||
},
|
||||
ttl: 30,
|
||||
}
|
||||
fallback := &scriptNameServer{
|
||||
name: "fallback",
|
||||
answers: map[string]net.IP{"other.example": net.ParseIP("9.9.9.9")},
|
||||
ttl: 60,
|
||||
}
|
||||
option := featureDNS.IPOption{IPv4Enable: true, IPv6Enable: true}
|
||||
hosts, err := NewStaticHosts(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine, err := newScriptEngine(scriptPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &DNS{
|
||||
ctx: context.Background(),
|
||||
hosts: hosts,
|
||||
ipOption: &option,
|
||||
script: engine,
|
||||
clients: []*Client{
|
||||
{id: "primary", server: primary, ipOption: &option, timeoutMs: 2 * time.Second},
|
||||
{id: "fallback", server: fallback, ipOption: &option, timeoutMs: 2 * time.Second},
|
||||
},
|
||||
}
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
for _, tc := range []struct {
|
||||
domain string
|
||||
ip net.IP
|
||||
ttl uint32
|
||||
}{
|
||||
{"Ru.Example.", net.ParseIP("2a02:6b8::feed:0ff"), 30},
|
||||
{"other.example", net.ParseIP("9.9.9.9"), 60},
|
||||
} {
|
||||
ips, ttl, err := server.LookupIP(tc.domain, option)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupIP(%q): %v", tc.domain, err)
|
||||
}
|
||||
if ttl != tc.ttl || len(ips) != 1 || !ips[0].Equal(tc.ip) {
|
||||
t.Fatalf("LookupIP(%q) = %v, TTL %d; want %v, TTL %d", tc.domain, ips, ttl, tc.ip, tc.ttl)
|
||||
}
|
||||
}
|
||||
if primary.calls != 2 || fallback.calls != 1 {
|
||||
t.Fatalf("upstream calls: primary %d, fallback %d; want 2 and 1", primary.calls, fallback.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSScriptRejectsInvalidStartup(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, script string
|
||||
compileError bool
|
||||
}{
|
||||
{"syntax", "function HandleDNSQuery(", true},
|
||||
{"missing hook", "value = 1", false},
|
||||
{"invalid hook", "HandleDNSQuery = true", false},
|
||||
{"top-level error", `error("setup failed")`, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "script.lua")
|
||||
if err := os.WriteFile(path, []byte(tc.script), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := New(context.Background(), &Config{Script: path})
|
||||
if tc.compileError {
|
||||
if server != nil || err == nil || !strings.Contains(err.Error(), "failed to prepare DNS script") {
|
||||
t.Fatalf("New = %v, %v; want nil server and script preparation error", server, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.Close()
|
||||
if server.script.program == nil || server.script.pool != nil {
|
||||
t.Fatal("New did not compile the script without starting its pool")
|
||||
}
|
||||
if err := server.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := server.Start(); err == nil {
|
||||
t.Fatal("Start accepted an invalid DNS script")
|
||||
}
|
||||
if server.script.pool != nil {
|
||||
t.Fatal("Start retained a script pool after failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSScriptProgramSnapshot(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "script.lua")
|
||||
if err := os.WriteFile(path, []byte(`function HandleDNSQuery() return nil, nil, "compiled" end`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine, err := newScriptEngine(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer engine.close()
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := engine.start(&DNS{ctx: context.Background()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := engine.query("example.com", featureDNS.IPOption{}); err == nil || !strings.Contains(err.Error(), "compiled") {
|
||||
t.Fatalf("query error = %v, want compiled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSScriptFakeDNSOption(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "script.lua")
|
||||
script := `
|
||||
local server = require("xray.dns").Servers[1]
|
||||
local log = require("xray.log")
|
||||
log.Info("DNS script loaded")
|
||||
function HandleDNSQuery(domain, ipv4, ipv6, fake)
|
||||
log.Debug("DNS query: ", domain)
|
||||
local ips, ttl, err = server:Query(domain, ipv4, ipv6, fake)
|
||||
if err then log.Error("DNS failed: ", err) end
|
||||
return ips, ttl, err
|
||||
end
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
option := featureDNS.IPOption{IPv4Enable: true}
|
||||
hosts, err := NewStaticHosts(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
upstream := &scriptNameServer{
|
||||
name: "FakeDNS",
|
||||
answers: map[string]net.IP{"good.example": net.ParseIP("198.18.0.1")},
|
||||
ttl: 30,
|
||||
}
|
||||
engine, err := newScriptEngine(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := &DNS{
|
||||
ctx: context.Background(),
|
||||
hosts: hosts,
|
||||
ipOption: &option,
|
||||
script: engine,
|
||||
clients: []*Client{{id: "fake", server: upstream, ipOption: &option, timeoutMs: time.Second}},
|
||||
}
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
if _, _, err := server.LookupIP("good.example", option); err != featureDNS.ErrEmptyResponse {
|
||||
t.Fatalf("FakeDNS without FakeEnable = %v, want ErrEmptyResponse", err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("FakeDNS was queried without FakeEnable: %d calls", upstream.calls)
|
||||
}
|
||||
withFake := featureDNS.IPOption{IPv4Enable: true, FakeEnable: true}
|
||||
ips, ttl, err := server.LookupIP("good.example", withFake)
|
||||
if err != nil || ttl != 30 || len(ips) != 1 || !ips[0].Equal(net.ParseIP("198.18.0.1")) {
|
||||
t.Fatalf("FakeDNS with FakeEnable = %v, TTL %d, %v", ips, ttl, err)
|
||||
}
|
||||
if upstream.calls != 1 {
|
||||
t.Fatalf("FakeDNS query count = %d, want 1", upstream.calls)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/geodata/config.proto
|
||||
|
||||
package geodata
|
||||
|
||||
+45
-58
@@ -21,7 +21,13 @@ import (
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
const idleTimeout = 30 * time.Second
|
||||
const (
|
||||
idleTimeout = 30 * time.Second
|
||||
maxDownloadSize int64 = 256 * 1024 * 1024
|
||||
)
|
||||
|
||||
// DO NOT EDIT. Ignored by geodata, rejected by Lua.
|
||||
const luaGuardHeader = "\x12\x01\x00"
|
||||
|
||||
type stage struct {
|
||||
target string
|
||||
@@ -30,7 +36,6 @@ type stage struct {
|
||||
|
||||
type downloader struct {
|
||||
ctx context.Context
|
||||
httpClient *http.Client
|
||||
httpsClient *http.Client
|
||||
}
|
||||
|
||||
@@ -58,12 +63,11 @@ func (c *idleConn) Write(b []byte) (int, error) {
|
||||
func newDownloader(ctx context.Context, dispatcher routing.Dispatcher, outbound string) *downloader {
|
||||
return &downloader{
|
||||
ctx: ctx,
|
||||
httpClient: newClient(ctx, dispatcher, outbound, false),
|
||||
httpsClient: newClient(ctx, dispatcher, outbound, true),
|
||||
httpsClient: newClient(ctx, dispatcher, outbound),
|
||||
}
|
||||
}
|
||||
|
||||
func newClient(baseCtx context.Context, dispatcher routing.Dispatcher, outbound string, isHTTPS bool) *http.Client {
|
||||
func newClient(baseCtx context.Context, dispatcher routing.Dispatcher, outbound string) *http.Client {
|
||||
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
var conn net.Conn
|
||||
err := task.Run(ctx, func() error {
|
||||
@@ -88,53 +92,33 @@ func newClient(baseCtx context.Context, dispatcher routing.Dispatcher, outbound
|
||||
Conn: conn,
|
||||
}, nil
|
||||
}
|
||||
if isHTTPS {
|
||||
return &http.Client{
|
||||
Transport: &http2.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network string, address string, cfg *tls.Config) (net.Conn, error) {
|
||||
conn, err := dial(ctx, network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(address)
|
||||
tlsConn := utls.UClient(conn, &utls.Config{ServerName: host}, utls.HelloChrome_Auto)
|
||||
handshakeCtx, cancel := context.WithTimeout(ctx, idleTimeout)
|
||||
defer cancel()
|
||||
if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return tlsConn, nil
|
||||
},
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
return errors.New("redirected to non-https URL: ", req.URL.String())
|
||||
return &http.Client{
|
||||
Transport: &http2.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network string, address string, cfg *tls.Config) (net.Conn, error) {
|
||||
conn, err := dial(ctx, network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
host, _, _ := net.SplitHostPort(address)
|
||||
tlsConn := utls.UClient(conn, &utls.Config{ServerName: host}, utls.HelloChrome_Auto)
|
||||
handshakeCtx, cancel := context.WithTimeout(ctx, idleTimeout)
|
||||
defer cancel()
|
||||
if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return nil
|
||||
return tlsConn, nil
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil,
|
||||
DisableKeepAlives: true,
|
||||
DialContext: dial,
|
||||
ResponseHeaderTimeout: idleTimeout,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
return errors.New("redirected to non-https URL: ", req.URL.String())
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
return errors.New("redirected to non-https URL: ", req.URL.String())
|
||||
}
|
||||
if len(via) >= 20 {
|
||||
return errors.New("stopped after 20 redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +154,10 @@ func (d *downloader) downloadOne(asset *Asset) (stage, error) {
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := temp.WriteString(luaGuardHeader); err != nil {
|
||||
temp.Close()
|
||||
return stage{}, err
|
||||
}
|
||||
if err := d.fetch(asset.Url, temp); err != nil {
|
||||
temp.Close()
|
||||
return stage{}, err
|
||||
@@ -196,27 +184,26 @@ func (d *downloader) fetch(rawURL string, writer io.Writer) error {
|
||||
}
|
||||
utils.TryDefaultHeadersWith(req.Header, "nav")
|
||||
|
||||
var client *http.Client
|
||||
if req.URL.Scheme == "https" {
|
||||
client = d.httpsClient
|
||||
} else {
|
||||
client = d.httpClient
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := d.httpsClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return errors.New("unexpected status code: ", resp.StatusCode)
|
||||
}
|
||||
if resp.ContentLength > maxDownloadSize {
|
||||
return errors.New("geodata download exceeds 256 MiB")
|
||||
}
|
||||
|
||||
n, err := io.Copy(writer, resp.Body)
|
||||
n, err := io.Copy(writer, io.LimitReader(resp.Body, maxDownloadSize+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > maxDownloadSize {
|
||||
return errors.New("geodata download exceeds 256 MiB")
|
||||
}
|
||||
if n == 0 {
|
||||
return errors.New("empty response body")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package geodata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commongeodata "github.com/xtls/xray-core/common/geodata"
|
||||
xlua "github.com/xtls/xray-core/common/lua"
|
||||
"github.com/xtls/xray-core/common/serial"
|
||||
"github.com/xtls/xray-core/core"
|
||||
"github.com/yuin/gopher-lua/parse"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestDownloadOneLuaGuard(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
code string
|
||||
}{
|
||||
{"geodata", "\x0a\x03\x0a\x01A", "A"},
|
||||
{"prefixed geodata", luaGuardHeader + "\x0a\x03\x0a\x01A", "A"},
|
||||
{"Lua", "function HandleRoute() end", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("xray.location.asset", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "asset.lua"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(tc.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
d := &downloader{ctx: context.Background(), httpsClient: server.Client()}
|
||||
staged, err := d.downloadOne(&Asset{Url: server.URL, File: "asset.lua"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := swapAll([]stage{staged})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(staged.target)
|
||||
if err != nil || string(data) != luaGuardHeader+tc.body {
|
||||
t.Fatalf("downloaded data = % x, %v", data, err)
|
||||
}
|
||||
program, err := xlua.Compile(staged.target)
|
||||
var syntaxErr *parse.Error
|
||||
if program != nil || !errors.As(err, &syntaxErr) || syntaxErr.Pos.Line != 1 || syntaxErr.Pos.Column != 1 {
|
||||
t.Fatalf("want Lua rejection at the first byte, got %v, %v", program, err)
|
||||
}
|
||||
if tc.code != "" {
|
||||
var list commongeodata.GeoSiteList
|
||||
if err := proto.Unmarshal(data, &list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list.Entry) != 1 || list.Entry[0].Code != tc.code {
|
||||
t.Fatalf("header changed geodata: %v", &list)
|
||||
}
|
||||
if _, err := commongeodata.ParseDomainRules([]string{"ext:" + filepath.Base(staged.target) + ":" + tc.code}, commongeodata.Domain_Full); err != nil {
|
||||
t.Fatalf("geodata loader rejected the header: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtobufConfigValidation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("xray.location.asset", dir)
|
||||
for _, name := range []string{"asset.dat", "asset.DAT"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dir, "directory.dat"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := core.New(&core.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = server.Close() })
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantError string
|
||||
}{
|
||||
{"minutely cron", &Config{Cron: "* * * * *"}, ""},
|
||||
{"minimum interval", &Config{Cron: "@every 1m"}, ""},
|
||||
{"short interval", &Config{Cron: "@every 59s"}, "interval must be at least 1 minute"},
|
||||
{"fractional interval", &Config{Cron: "@every 59.999s"}, "interval must be at least 1 minute"},
|
||||
{"invalid cron", &Config{Cron: "invalid"}, "invalid geodata cron"},
|
||||
{"dat extension", &Config{Cron: "@daily", Assets: []*Asset{{Url: "https://example.com/asset.dat", File: "asset.dat"}}}, ""},
|
||||
{"uppercase extension", &Config{Cron: "@daily", Assets: []*Asset{{Url: "https://example.com/asset.dat", File: "asset.DAT"}}}, ""},
|
||||
{"invalid extension", &Config{Cron: "@daily", Assets: []*Asset{{Url: "https://example.com/asset.dat", File: "asset.lua"}}}, "extension must be .dat"},
|
||||
{"missing extension", &Config{Cron: "@daily", Assets: []*Asset{{Url: "https://example.com/asset.dat", File: "asset"}}}, "extension must be .dat"},
|
||||
{"missing file", &Config{Cron: "@daily", Assets: []*Asset{{Url: "https://example.com/asset.dat", File: "missing.dat"}}}, "invalid geodata asset file"},
|
||||
{"directory", &Config{Cron: "@daily", Assets: []*Asset{{Url: "https://example.com/asset.dat", File: "directory.dat"}}}, "asset is not a regular file"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
config, err := serial.ToTypedMessage(tc.config).GetInstance()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = core.CreateObject(server, config)
|
||||
if tc.wantError == "" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Fatalf("CreateObject() error = %v; want %q", err, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -2,12 +2,16 @@ package geodata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/xtls/xray-core/common"
|
||||
"github.com/xtls/xray-core/common/errors"
|
||||
commongeodata "github.com/xtls/xray-core/common/geodata"
|
||||
"github.com/xtls/xray-core/common/platform/filesystem"
|
||||
"github.com/xtls/xray-core/core"
|
||||
"github.com/xtls/xray-core/features/routing"
|
||||
)
|
||||
@@ -25,6 +29,24 @@ func New(ctx context.Context, config *Config) (*Instance, error) {
|
||||
if config.Cron == "" {
|
||||
return &Instance{}, nil
|
||||
}
|
||||
if len(config.Assets) > 100 {
|
||||
return nil, errors.New("geodata supports at most 100 assets")
|
||||
}
|
||||
for _, asset := range config.Assets {
|
||||
if !strings.EqualFold(filepath.Ext(asset.GetFile()), ".dat") {
|
||||
return nil, errors.New("geodata asset file extension must be .dat")
|
||||
}
|
||||
if _, err := filesystem.ResolveAsset(asset.GetFile()); err != nil {
|
||||
return nil, errors.New("invalid geodata asset file: ", asset.GetFile()).Base(err)
|
||||
}
|
||||
}
|
||||
schedule, err := cron.ParseStandard(config.Cron)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid geodata cron").Base(err)
|
||||
}
|
||||
if interval, ok := schedule.(cron.ConstantDelaySchedule); ok && interval.Delay < time.Minute {
|
||||
return nil, errors.New("geodata cron interval must be at least 1 minute")
|
||||
}
|
||||
|
||||
g := &Instance{
|
||||
assets: config.Assets,
|
||||
@@ -44,9 +66,7 @@ func New(ctx context.Context, config *Config) (*Instance, error) {
|
||||
cron.WithChain(cron.SkipIfStillRunning(cron.DiscardLogger)),
|
||||
cron.WithLogger(cron.DiscardLogger),
|
||||
)
|
||||
if _, err := g.tasker.AddFunc(config.Cron, g.execute); err != nil {
|
||||
return nil, errors.New("invalid geodata cron").Base(err)
|
||||
}
|
||||
g.tasker.Schedule(schedule, cron.FuncJob(g.execute))
|
||||
errors.LogInfo(ctx, "scheduled geodata reload with cron: ", config.Cron)
|
||||
|
||||
return g, nil
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/log/command/config.proto
|
||||
|
||||
package command
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.0
|
||||
// - protoc v6.33.5
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc (unknown)
|
||||
// source: app/log/command/config.proto
|
||||
|
||||
package command
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/log/config.proto
|
||||
|
||||
package log
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/metrics/config.proto
|
||||
|
||||
package metrics
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/observatory/burst/config.proto
|
||||
|
||||
package burst
|
||||
|
||||
@@ -109,7 +109,7 @@ func (h *HealthPingRTTS) getStatistics() *HealthPingStats {
|
||||
stats.Min = 0
|
||||
return stats
|
||||
}
|
||||
stats.Average = time.Duration(int(sum) / cnt)
|
||||
stats.Average = sum / time.Duration(cnt)
|
||||
var std float64
|
||||
if cnt < 2 {
|
||||
// no enough data for standard deviation, we assume it's half of the average rtt
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/observatory/command/command.proto
|
||||
|
||||
package command
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.0
|
||||
// - protoc v6.33.5
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc (unknown)
|
||||
// source: app/observatory/command/command.proto
|
||||
|
||||
package command
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/observatory/config.proto
|
||||
|
||||
package observatory
|
||||
@@ -152,23 +152,23 @@ func (x *HealthPingMeasurementResult) GetMin() int64 {
|
||||
type OutboundStatus struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// @Document Whether this outbound is usable
|
||||
// @Restriction ReadOnlyForUser
|
||||
//@Restriction ReadOnlyForUser
|
||||
Alive bool `protobuf:"varint,1,opt,name=alive,proto3" json:"alive,omitempty"`
|
||||
// @Document The time for probe request to finish.
|
||||
// @Type time.ms
|
||||
// @Restriction ReadOnlyForUser
|
||||
//@Type time.ms
|
||||
//@Restriction ReadOnlyForUser
|
||||
Delay int64 `protobuf:"varint,2,opt,name=delay,proto3" json:"delay,omitempty"`
|
||||
// @Document The last error caused this outbound failed to relay probe request
|
||||
// @Restriction NotMachineReadable
|
||||
//@Restriction NotMachineReadable
|
||||
LastErrorReason string `protobuf:"bytes,3,opt,name=last_error_reason,json=lastErrorReason,proto3" json:"last_error_reason,omitempty"`
|
||||
// @Document The outbound tag for this Server
|
||||
// @Type id.outboundTag
|
||||
//@Type id.outboundTag
|
||||
OutboundTag string `protobuf:"bytes,4,opt,name=outbound_tag,json=outboundTag,proto3" json:"outbound_tag,omitempty"`
|
||||
// @Document The time this outbound is known to be alive
|
||||
// @Type id.outboundTag
|
||||
//@Type id.outboundTag
|
||||
LastSeenTime int64 `protobuf:"varint,5,opt,name=last_seen_time,json=lastSeenTime,proto3" json:"last_seen_time,omitempty"`
|
||||
// @Document The time this outbound is tried
|
||||
// @Type id.outboundTag
|
||||
//@Type id.outboundTag
|
||||
LastTryTime int64 `protobuf:"varint,6,opt,name=last_try_time,json=lastTryTime,proto3" json:"last_try_time,omitempty"`
|
||||
HealthPing *HealthPingMeasurementResult `protobuf:"bytes,7,opt,name=health_ping,json=healthPing,proto3" json:"health_ping,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
@@ -257,14 +257,14 @@ func (x *OutboundStatus) GetHealthPing() *HealthPingMeasurementResult {
|
||||
type ProbeResult struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// @Document Whether this outbound is usable
|
||||
// @Restriction ReadOnlyForUser
|
||||
//@Restriction ReadOnlyForUser
|
||||
Alive bool `protobuf:"varint,1,opt,name=alive,proto3" json:"alive,omitempty"`
|
||||
// @Document The time for probe request to finish.
|
||||
// @Type time.ms
|
||||
// @Restriction ReadOnlyForUser
|
||||
//@Type time.ms
|
||||
//@Restriction ReadOnlyForUser
|
||||
Delay int64 `protobuf:"varint,2,opt,name=delay,proto3" json:"delay,omitempty"`
|
||||
// @Document The error caused this outbound failed to relay probe request
|
||||
// @Restriction NotMachineReadable
|
||||
//@Restriction NotMachineReadable
|
||||
LastErrorReason string `protobuf:"bytes,3,opt,name=last_error_reason,json=lastErrorReason,proto3" json:"last_error_reason,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -324,7 +324,7 @@ func (x *ProbeResult) GetLastErrorReason() string {
|
||||
type Intensity struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// @Document The time interval for a probe request in ms.
|
||||
// @Type time.ms
|
||||
//@Type time.ms
|
||||
ProbeInterval uint32 `protobuf:"varint,1,opt,name=probe_interval,json=probeInterval,proto3" json:"probe_interval,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/policy/config.proto
|
||||
|
||||
package policy
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/proxyman/command/command.proto
|
||||
|
||||
package command
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.0
|
||||
// - protoc v6.33.5
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc (unknown)
|
||||
// source: app/proxyman/command/command.proto
|
||||
|
||||
package command
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/proxyman/config.proto
|
||||
|
||||
package proxyman
|
||||
|
||||
@@ -174,7 +174,7 @@ func NewHandler(ctx context.Context, config *core.InboundHandlerConfig) (inbound
|
||||
Mark: streamSettings.SocketSettings.Mark,
|
||||
})
|
||||
}
|
||||
if streamSettings != nil && streamSettings.ProtocolName == "splithttp" {
|
||||
if streamSettings != nil && (streamSettings.ProtocolName == "splithttp" || streamSettings.ProtocolName == "masque") {
|
||||
ctx = session.ContextWithAllowedNetwork(ctx, net.Network_UDP)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/reverse/config.proto
|
||||
|
||||
package reverse
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/router/command/command.proto
|
||||
|
||||
package command
|
||||
|
||||
@@ -34,18 +34,19 @@ message RoutingContext {
|
||||
// opened by xray-core.
|
||||
// * FieldSelectors selects a subset of fields in routing statistics to return.
|
||||
// Valid selectors:
|
||||
// - inbound: Selects connection's inbound tag.
|
||||
// - network: Selects connection's network.
|
||||
// - ip: Equivalent as "ip_source" and "ip_target", selects both source and
|
||||
// target IP.
|
||||
// - port: Equivalent as "port_source" and "port_target", selects both source
|
||||
// and target port.
|
||||
// - domain: Selects target domain.
|
||||
// - protocol: Select connection's protocol.
|
||||
// - user: Select connection's inbound user email.
|
||||
// - attributes: Select connection's additional attributes.
|
||||
// - outbound: Equivalent as "outbound" and "outbound_group", select both
|
||||
// outbound tag and outbound group tags.
|
||||
// - inbound: Selects connection's inbound tag.
|
||||
// - network: Selects connection's network.
|
||||
// - ip: Equivalent as "ip_source" and "ip_target", selects both source and
|
||||
// target IP.
|
||||
// - port: Equivalent as "port_source" and "port_target", selects both source
|
||||
// and target port.
|
||||
// - domain: Selects target domain.
|
||||
// - protocol: Select connection's protocol.
|
||||
// - user: Select connection's inbound user email.
|
||||
// - attributes: Select connection's additional attributes.
|
||||
// - outbound: Equivalent as "outbound" and "outbound_group", select both
|
||||
// outbound tag and outbound group tags.
|
||||
//
|
||||
// * If FieldSelectors is left empty, all fields will be returned.
|
||||
message SubscribeRoutingStatsRequest {
|
||||
repeated string FieldSelectors = 1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.0
|
||||
// - protoc v6.33.5
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc (unknown)
|
||||
// source: app/router/command/command.proto
|
||||
|
||||
package command
|
||||
|
||||
@@ -415,7 +415,7 @@ func BenchmarkMphDomainMatcher(b *testing.B) {
|
||||
|
||||
func BenchmarkMultiGeoIPMatcher(b *testing.B) {
|
||||
b.Setenv("xray.location.asset", filepath.Join("..", "..", "resources"))
|
||||
rules, err := geodata.ParseIPRules([]string{"geoip:cn", "geoip:jp", "geoip:ca", "geoip:us"})
|
||||
rules, err := geodata.ParseIPRules([]string{"geoip:cn", "geoip:ir", "geoip:ru"})
|
||||
common.Must(err)
|
||||
|
||||
matcher, err := NewIPMatcher(rules, MatcherAsType_Target)
|
||||
|
||||
+13
-4
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/router/config.proto
|
||||
|
||||
package router
|
||||
@@ -587,6 +587,7 @@ type Config struct {
|
||||
DomainStrategy Config_DomainStrategy `protobuf:"varint,1,opt,name=domain_strategy,json=domainStrategy,proto3,enum=xray.app.router.Config_DomainStrategy" json:"domain_strategy,omitempty"`
|
||||
Rule []*RoutingRule `protobuf:"bytes,2,rep,name=rule,proto3" json:"rule,omitempty"`
|
||||
BalancingRule []*BalancingRule `protobuf:"bytes,3,rep,name=balancing_rule,json=balancingRule,proto3" json:"balancing_rule,omitempty"`
|
||||
Script string `protobuf:"bytes,4,opt,name=script,proto3" json:"script,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -642,6 +643,13 @@ func (x *Config) GetBalancingRule() []*BalancingRule {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Config) GetScript() string {
|
||||
if x != nil {
|
||||
return x.Script
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_app_router_config_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_app_router_config_proto_rawDesc = "" +
|
||||
@@ -699,11 +707,12 @@ const file_app_router_config_proto_rawDesc = "" +
|
||||
"\tbaselines\x18\x03 \x03(\x03R\tbaselines\x12\x1a\n" +
|
||||
"\bexpected\x18\x04 \x01(\x05R\bexpected\x12\x16\n" +
|
||||
"\x06maxRTT\x18\x05 \x01(\x03R\x06maxRTT\x12\x1c\n" +
|
||||
"\ttolerance\x18\x06 \x01(\x02R\ttolerance\"\x96\x02\n" +
|
||||
"\ttolerance\x18\x06 \x01(\x02R\ttolerance\"\xae\x02\n" +
|
||||
"\x06Config\x12O\n" +
|
||||
"\x0fdomain_strategy\x18\x01 \x01(\x0e2&.xray.app.router.Config.DomainStrategyR\x0edomainStrategy\x120\n" +
|
||||
"\x04rule\x18\x02 \x03(\v2\x1c.xray.app.router.RoutingRuleR\x04rule\x12E\n" +
|
||||
"\x0ebalancing_rule\x18\x03 \x03(\v2\x1e.xray.app.router.BalancingRuleR\rbalancingRule\"B\n" +
|
||||
"\x0ebalancing_rule\x18\x03 \x03(\v2\x1e.xray.app.router.BalancingRuleR\rbalancingRule\x12\x16\n" +
|
||||
"\x06script\x18\x04 \x01(\tR\x06script\"B\n" +
|
||||
"\x0eDomainStrategy\x12\b\n" +
|
||||
"\x04AsIs\x10\x00\x12\x10\n" +
|
||||
"\fIpIfNonMatch\x10\x02\x12\x0e\n" +
|
||||
|
||||
@@ -110,4 +110,5 @@ message Config {
|
||||
DomainStrategy domain_strategy = 1;
|
||||
repeated RoutingRule rule = 2;
|
||||
repeated BalancingRule balancing_rule = 3;
|
||||
string script = 4;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/xtls/xray-core/common/errors"
|
||||
xlua "github.com/xtls/xray-core/common/lua"
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
"github.com/xtls/xray-core/features/routing"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
const (
|
||||
luaContextType = "xray.router.Context"
|
||||
luaAttributesType = "xray.router.Attributes"
|
||||
)
|
||||
|
||||
// RegisterLua makes xray.router available to routing scripts.
|
||||
func (r *Router) RegisterLua(L *lua.LState) {
|
||||
registerLuaContext(L)
|
||||
|
||||
L.PreloadModule("xray.router", func(L *lua.LState) int {
|
||||
module := L.CreateTable(0, 7)
|
||||
|
||||
module.RawSetString("NetworkUnknown", lua.LNumber(net.Network_Unknown))
|
||||
module.RawSetString("NetworkTCP", lua.LNumber(net.Network_TCP))
|
||||
module.RawSetString("NetworkUDP", lua.LNumber(net.Network_UDP))
|
||||
module.RawSetString("NetworkUNIX", lua.LNumber(net.Network_UNIX))
|
||||
module.RawSetString("LocalOS", lua.LString(runtime.GOOS))
|
||||
|
||||
module.RawSetString("PickOutbound", L.NewFunction(func(L *lua.LState) int {
|
||||
tag, ok := L.Get(1).(lua.LString)
|
||||
if !ok {
|
||||
L.ArgError(1, "balancer tag must be a string")
|
||||
return 0
|
||||
}
|
||||
balancer, found := (*r.balancers.Load())[string(tag)]
|
||||
if !found {
|
||||
xlua.PushNil(L)
|
||||
xlua.PushError(L, errors.New("balancer ", tag, " not found"))
|
||||
return 2
|
||||
}
|
||||
outboundTag, err := balancer.PickOutbound()
|
||||
xlua.PushString(L, outboundTag)
|
||||
xlua.PushError(L, err)
|
||||
return 2
|
||||
}))
|
||||
|
||||
module.RawSetString("FindProcess", L.NewFunction(func(L *lua.LState) int {
|
||||
pid, name, path, err := findProcess(checkLuaContext(L), net.FindProcess)
|
||||
xlua.PushNumber(L, pid)
|
||||
xlua.PushString(L, name)
|
||||
xlua.PushString(L, path)
|
||||
xlua.PushError(L, err)
|
||||
return 4
|
||||
}))
|
||||
|
||||
L.Push(module)
|
||||
return 1
|
||||
})
|
||||
}
|
||||
|
||||
func registerLuaContext(L *lua.LState) {
|
||||
pushIPs := xlua.NewReadOnlySlicePusher[net.IP](L)
|
||||
attributes := L.NewTypeMetatable(luaAttributesType)
|
||||
L.SetField(attributes, "__index", L.NewFunction(func(L *lua.LState) int {
|
||||
values := L.CheckUserData(1).Value.(map[string]string)
|
||||
key := L.CheckString(2)
|
||||
if value, found := values[key]; found {
|
||||
xlua.PushString(L, value)
|
||||
} else {
|
||||
xlua.PushNil(L)
|
||||
}
|
||||
return 1
|
||||
}))
|
||||
methods := L.CreateTable(0, 4)
|
||||
L.SetFuncs(methods, map[string]lua.LGFunction{
|
||||
"GetSourceIPs": func(L *lua.LState) int {
|
||||
pushIPs(L, checkLuaContext(L).GetSourceIPs())
|
||||
return 1
|
||||
},
|
||||
"GetTargetIPs": func(L *lua.LState) int {
|
||||
pushIPs(L, checkLuaContext(L).GetTargetIPs())
|
||||
return 1
|
||||
},
|
||||
"GetLocalIPs": func(L *lua.LState) int {
|
||||
pushIPs(L, checkLuaContext(L).GetLocalIPs())
|
||||
return 1
|
||||
},
|
||||
"GetAttributes": func(L *lua.LState) int {
|
||||
values := L.NewUserData()
|
||||
values.Value = checkLuaContext(L).GetAttributes()
|
||||
L.SetMetatable(values, attributes)
|
||||
L.Push(values)
|
||||
return 1
|
||||
},
|
||||
})
|
||||
L.SetField(L.NewTypeMetatable(luaContextType), "__index", methods)
|
||||
}
|
||||
|
||||
func checkLuaContext(L *lua.LState) routing.Context {
|
||||
ctx, ok := L.CheckUserData(1).Value.(routing.Context)
|
||||
if !ok {
|
||||
L.ArgError(1, "routing context expected")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// callLuaRoute runs HandleRoute and leaves (outboundTag, ruleTag, err) on the stack.
|
||||
func callLuaRoute(L *lua.LState, ctx routing.Context) error {
|
||||
fn := L.GetGlobal("HandleRoute")
|
||||
if fn.Type() != lua.LTFunction {
|
||||
return errors.New("routing script must define HandleRoute(...)")
|
||||
}
|
||||
|
||||
value := L.NewUserData()
|
||||
value.Value = ctx
|
||||
L.SetMetatable(value, L.GetTypeMetatable(luaContextType))
|
||||
|
||||
return L.CallByParam(lua.P{Fn: fn, NRet: 3, Protect: true},
|
||||
value,
|
||||
lua.LString(ctx.GetInboundTag()),
|
||||
lua.LNumber(ctx.GetSourcePort()),
|
||||
lua.LNumber(ctx.GetTargetPort()),
|
||||
lua.LNumber(ctx.GetLocalPort()),
|
||||
lua.LString(strings.ToLower(ctx.GetTargetDomain())),
|
||||
lua.LNumber(ctx.GetNetwork()),
|
||||
lua.LString(ctx.GetProtocol()),
|
||||
lua.LString(ctx.GetUser()),
|
||||
lua.LNumber(ctx.GetVlessRoute()),
|
||||
lua.LBool(ctx.GetSkipDNSResolve()))
|
||||
}
|
||||
|
||||
// readLuaRouteResult reads (outboundTag, ruleTag, err) from the stack.
|
||||
func readLuaRouteResult(L *lua.LState) (string, string, error) {
|
||||
if err := xlua.ReadError(L.Get(-1), "routing script error must be an error or string"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
outboundTag, err := xlua.ReadOptionalString(L.Get(-3), "routing script outboundTag must be a string or nil")
|
||||
if err != nil || outboundTag == "" {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
ruleTag, err := xlua.ReadOptionalString(L.Get(-2), "routing script ruleTag must be a string")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return outboundTag, ruleTag, nil
|
||||
}
|
||||
|
||||
type processFinder func(string, string, uint16, string, uint16) (int, string, string, error)
|
||||
|
||||
func findProcess(ctx routing.Context, finder processFinder) (int, string, string, error) {
|
||||
sources := ctx.GetSourceIPs()
|
||||
if len(sources) == 0 {
|
||||
return 0, "", "", errors.New("process lookup requires a source IP")
|
||||
}
|
||||
var network string
|
||||
switch ctx.GetNetwork() {
|
||||
case net.Network_TCP:
|
||||
network = "tcp"
|
||||
case net.Network_UDP:
|
||||
network = "udp"
|
||||
default:
|
||||
return 0, "", "", errors.New("process lookup requires TCP or UDP")
|
||||
}
|
||||
targetIP, targetPort := "", uint16(0)
|
||||
if targets := ctx.GetTargetIPs(); len(targets) > 0 {
|
||||
targetIP, targetPort = targets[0].String(), uint16(ctx.GetTargetPort())
|
||||
}
|
||||
return finder(network, sources[0].String(), uint16(ctx.GetSourcePort()), targetIP, targetPort)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xtls/xray-core/common/geodata"
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
"github.com/xtls/xray-core/common/session"
|
||||
"github.com/xtls/xray-core/features/routing"
|
||||
routing_session "github.com/xtls/xray-core/features/routing/session"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func benchmarkRouteContext(target net.Destination) *routing_session.Context {
|
||||
// Use the production context: its IP getters construct a slice per call.
|
||||
// The cached IP slices in luaRouteTestContext would undercount this cost.
|
||||
return &routing_session.Context{
|
||||
Inbound: &session.Inbound{
|
||||
Tag: "in",
|
||||
Source: net.TCPDestination(net.LocalHostIP, 1234),
|
||||
Local: net.TCPDestination(net.LocalHostIP, 5678),
|
||||
},
|
||||
Outbound: &session.Outbound{Target: target},
|
||||
Content: &session.Content{Protocol: "tls"},
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkRouteState(b *testing.B, r *Router, script string) *lua.LState {
|
||||
b.Helper()
|
||||
L := lua.NewState()
|
||||
b.Cleanup(L.Close)
|
||||
r.RegisterLua(L)
|
||||
geodata.RegisterLua(L)
|
||||
if err := L.DoString(script); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
L.SetContext(context.Background())
|
||||
return L
|
||||
}
|
||||
|
||||
// BenchmarkLuaRouteHook isolates argument bridging and a fixed-return hook.
|
||||
// It excludes rules, result decoding, the state pool, and Route construction.
|
||||
func BenchmarkLuaRouteHook(b *testing.B) {
|
||||
L := benchmarkRouteState(b, new(Router), `
|
||||
function HandleRoute(ctx, inboundTag, sourcePort, targetPort, localPort,
|
||||
targetDomain, network, protocol, user, vlessRoute, skipDNSResolve)
|
||||
return "out", "rule"
|
||||
end
|
||||
`)
|
||||
ctx := benchmarkRouteContext(net.TCPDestination(net.LocalHostIP, 443))
|
||||
if err := callLuaRoute(L, ctx); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
outboundTag, ruleTag, err := readLuaRouteResult(L)
|
||||
L.Pop(3)
|
||||
if err != nil || outboundTag != "out" || ruleTag != "rule" {
|
||||
b.Fatalf("hook() = %q, %q, %v", outboundTag, ruleTag, err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := callLuaRoute(L, ctx); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
L.Pop(3)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkLuaRoute compares equivalent ordered rules on the same session.
|
||||
// rules returns tags only; pick_route uses Router.PickRoute on both sides.
|
||||
// All compilation, matcher construction, and pool startup are outside timing.
|
||||
func BenchmarkLuaRoute(b *testing.B) {
|
||||
for _, name := range []string{"scalar", "ip", "domain", "domain_32_last"} {
|
||||
b.Run(name, func(b *testing.B) {
|
||||
config, script, ctx, wantTag, wantRule := benchmarkRouteFixture(b, name)
|
||||
native := new(Router)
|
||||
if err := native.Init(context.Background(), config, nil, nil, nil); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
L := benchmarkRouteState(b, native, script)
|
||||
|
||||
path := filepath.Join(b.TempDir(), "route.lua")
|
||||
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
scripted := new(Router)
|
||||
if err := scripted.Init(context.Background(), &Config{Script: path}, nil, nil, nil); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := scripted.Start(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.Cleanup(func() {
|
||||
if err := scripted.Close(); err != nil {
|
||||
b.Error(err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, bench := range []struct {
|
||||
name string
|
||||
route func() (string, string, error)
|
||||
}{
|
||||
{"rules/native", func() (string, string, error) {
|
||||
rule, _, err := native.pickRouteInternal(ctx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tag, err := rule.GetTag()
|
||||
return tag, rule.RuleTag, err
|
||||
}},
|
||||
{"rules/lua", func() (string, string, error) {
|
||||
if err := callLuaRoute(L, ctx); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tag, ruleTag, err := readLuaRouteResult(L)
|
||||
L.Pop(3)
|
||||
return tag, ruleTag, err
|
||||
}},
|
||||
{"pick_route/native", func() (string, string, error) {
|
||||
return benchmarkPickRoute(native, ctx)
|
||||
}},
|
||||
{"pick_route/lua", func() (string, string, error) {
|
||||
return benchmarkPickRoute(scripted, ctx)
|
||||
}},
|
||||
} {
|
||||
b.Run(bench.name, func(b *testing.B) {
|
||||
// Validate and warm both paths before measuring steady state.
|
||||
tag, ruleTag, err := bench.route()
|
||||
if err != nil || tag != wantTag || ruleTag != wantRule {
|
||||
b.Fatalf("route() = %q, %q, %v; want %q, %q", tag, ruleTag, err, wantTag, wantRule)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
tag, ruleTag, err = bench.route()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
b.StopTimer()
|
||||
if tag != wantTag || ruleTag != wantRule {
|
||||
b.Fatalf("route() = %q, %q; want %q, %q", tag, ruleTag, wantTag, wantRule)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkPickRoute(r *Router, ctx routing.Context) (string, string, error) {
|
||||
route, err := r.PickRoute(ctx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return route.GetOutboundTag(), route.GetRuleTag(), nil
|
||||
}
|
||||
|
||||
func benchmarkRouteFixture(b *testing.B, name string) (*Config, string, routing.Context, string, string) {
|
||||
b.Helper()
|
||||
config := new(Config)
|
||||
ctx := benchmarkRouteContext(net.TCPDestination(net.LocalHostIP, 443))
|
||||
prelude := `local router = require("xray.router")
|
||||
local geodata = require("xray.geodata")
|
||||
`
|
||||
body := `if inboundTag == "in" and network == router.NetworkTCP then return "out", "rule" end`
|
||||
wantTag, wantRule := "out", "rule"
|
||||
if name == "scalar" || name == "ip" {
|
||||
rule := &RoutingRule{
|
||||
TargetTag: &RoutingRule_Tag{Tag: wantTag},
|
||||
RuleTag: wantRule,
|
||||
InboundTag: []string{"in"},
|
||||
Networks: []net.Network{net.Network_TCP},
|
||||
}
|
||||
if name == "ip" {
|
||||
var err error
|
||||
rule.Ip, err = geodata.ParseIPRules([]string{"127.0.0.0/8"})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
prelude += `local matcher = geodata.BuildIPMatcher("127.0.0.0/8")` + "\n"
|
||||
body = `if inboundTag == "in" and network == router.NetworkTCP and matcher:AnyMatch(ctx:GetTargetIPs()) then return "out", "rule" end`
|
||||
}
|
||||
config.Rule = []*RoutingRule{rule}
|
||||
} else {
|
||||
count := 1
|
||||
if name == "domain_32_last" {
|
||||
count = 32
|
||||
}
|
||||
var rules strings.Builder
|
||||
rules.WriteString("local rules = {\n")
|
||||
for i := 0; i < count; i++ {
|
||||
domain := fmt.Sprintf("route-%d.example.com", i)
|
||||
tag, ruleTag := fmt.Sprintf("out-%d", i), fmt.Sprintf("rule-%d", i)
|
||||
domains, err := geodata.ParseDomainRules([]string{"full:" + domain}, geodata.Domain_Domain)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
config.Rule = append(config.Rule, &RoutingRule{
|
||||
TargetTag: &RoutingRule_Tag{Tag: tag}, RuleTag: ruleTag, Domain: domains,
|
||||
})
|
||||
fmt.Fprintf(&rules, "{geodata.BuildDomainMatcher(%q), %q, %q},\n", "full:"+domain, tag, ruleTag)
|
||||
if i == count-1 {
|
||||
ctx.Outbound.Target = net.TCPDestination(net.DomainAddress(domain), 443)
|
||||
wantTag, wantRule = tag, ruleTag
|
||||
}
|
||||
}
|
||||
rules.WriteString("}\n")
|
||||
prelude += rules.String()
|
||||
body = `for i = 1, #rules do
|
||||
local rule = rules[i]
|
||||
if rule[1]:MatchAny(targetDomain) then return rule[2], rule[3] end
|
||||
end`
|
||||
}
|
||||
script := prelude + `function HandleRoute(ctx, inboundTag, sourcePort, targetPort, localPort,
|
||||
targetDomain, network, protocol, user, vlessRoute, skipDNSResolve)
|
||||
` + body + "\nend\n"
|
||||
return config, script, ctx, wantTag, wantRule
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
go_errors "errors"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xtls/xray-core/common/geodata"
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
"github.com/xtls/xray-core/common/protocol"
|
||||
"github.com/xtls/xray-core/common/session"
|
||||
"github.com/xtls/xray-core/features/routing"
|
||||
routing_session "github.com/xtls/xray-core/features/routing/session"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
type luaRouteTestContext struct {
|
||||
*routing_session.Context
|
||||
sourceIPs, targetIPs, localIPs []net.IP
|
||||
}
|
||||
|
||||
func (c *luaRouteTestContext) GetSourceIPs() []net.IP { return c.sourceIPs }
|
||||
func (c *luaRouteTestContext) GetTargetIPs() []net.IP { return c.targetIPs }
|
||||
func (c *luaRouteTestContext) GetLocalIPs() []net.IP { return c.localIPs }
|
||||
|
||||
func newLuaRouteTestContext() *luaRouteTestContext {
|
||||
return &luaRouteTestContext{
|
||||
Context: &routing_session.Context{
|
||||
Inbound: &session.Inbound{
|
||||
Tag: "in", VlessRoute: 4321,
|
||||
Source: net.TCPDestination(net.LocalHostIP, 1234),
|
||||
Local: net.TCPDestination(net.LocalHostIP, 5678),
|
||||
User: &protocol.MemoryUser{Email: "user@example.com"},
|
||||
},
|
||||
Outbound: &session.Outbound{
|
||||
Target: net.TCPDestination(net.LocalHostIP, 443),
|
||||
RouteTarget: net.TCPDestination(net.DomainAddress("MiXeD.Example."), 443),
|
||||
},
|
||||
Content: &session.Content{
|
||||
Protocol: "tls", Attributes: map[string]string{"key": "value"}, SkipDNSResolve: true,
|
||||
},
|
||||
},
|
||||
sourceIPs: []net.IP{{127, 0, 0, 2}},
|
||||
targetIPs: []net.IP{{127, 0, 0, 3}},
|
||||
localIPs: []net.IP{{127, 0, 0, 1}},
|
||||
}
|
||||
}
|
||||
|
||||
func newLuaRouterState(t *testing.T, script string) *lua.LState {
|
||||
t.Helper()
|
||||
r := new(Router)
|
||||
if err := r.Init(context.Background(), &Config{}, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
L := lua.NewState()
|
||||
t.Cleanup(L.Close)
|
||||
r.RegisterLua(L)
|
||||
geodata.RegisterLua(L)
|
||||
if err := L.DoString(script); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return L
|
||||
}
|
||||
|
||||
func TestLuaRouteBinding(t *testing.T) {
|
||||
L := newLuaRouterState(t, `
|
||||
local router = require("xray.router")
|
||||
local matcher = require("xray.geodata").BuildIPMatcher("127.0.0.0/8")
|
||||
assert(router.NetworkUnknown == 0 and router.NetworkTCP == 2)
|
||||
assert(router.NetworkUDP == 3 and router.NetworkUNIX == 4)
|
||||
assert(router.BuildIPMatcher == nil and router.BuildDomainMatcher == nil)
|
||||
function HandleRoute(ctx, inboundTag, sourcePort, targetPort, localPort,
|
||||
targetDomain, network, protocol, user, vlessRoute, skipDNSResolve, ...)
|
||||
assert(select("#", ...) == 0)
|
||||
assert(inboundTag == "in" and sourcePort == 1234 and targetPort == 443 and localPort == 5678)
|
||||
assert(targetDomain == "mixed.example." and network == router.NetworkTCP)
|
||||
assert(protocol == "tls" and user == "user@example.com" and vlessRoute == 4321 and skipDNSResolve)
|
||||
assert(ctx.GetNetwork == nil and ctx.Context == nil)
|
||||
savedContext = ctx
|
||||
sourceIPs, targetIPs, localIPs = ctx:GetSourceIPs(), ctx:GetTargetIPs(), ctx:GetLocalIPs()
|
||||
attributes = ctx:GetAttributes()
|
||||
assert(#sourceIPs == 1 and #targetIPs == 1 and #localIPs == 1)
|
||||
assert(sourceIPs[1]:String() == "127.0.0.2" and targetIPs[1]:String() == "127.0.0.3")
|
||||
assert(localIPs[1]:String() == "127.0.0.1")
|
||||
assert(matcher:Match(sourceIPs[1]) and matcher:Match(targetIPs[1]) and matcher:Match(localIPs[1]))
|
||||
assert(matcher:AnyMatch(sourceIPs) and matcher:AnyMatch(targetIPs) and matcher:AnyMatch(localIPs))
|
||||
local matched = matcher:FilterIPs(targetIPs)
|
||||
assert(#matched == 1 and matched[1]:Equal(targetIPs[1]))
|
||||
assert(attributes.key == "value" and attributes.missing == nil)
|
||||
assert(not pcall(function() attributes.key = "changed" end))
|
||||
return "out", "rule"
|
||||
end`)
|
||||
|
||||
ctx := newLuaRouteTestContext()
|
||||
if err := callLuaRoute(L, ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if L.GetTop() != 3 || L.Get(1) != lua.LString("out") || L.Get(2) != lua.LString("rule") || L.Get(3) != lua.LNil {
|
||||
t.Fatal("callLuaRoute did not leave the three route results on the stack")
|
||||
}
|
||||
if L.GetGlobal("savedContext").(*lua.LUserData).Value != ctx {
|
||||
t.Fatal("routing context was copied")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
want []net.IP
|
||||
}{
|
||||
{"sourceIPs", ctx.sourceIPs},
|
||||
{"targetIPs", ctx.targetIPs},
|
||||
{"localIPs", ctx.localIPs},
|
||||
} {
|
||||
got := L.GetGlobal(tc.name).(*lua.LUserData).Value.([]net.IP)
|
||||
if &got[0] != &tc.want[0] {
|
||||
t.Fatalf("%s storage was copied", tc.name)
|
||||
}
|
||||
}
|
||||
ctx.Content.Attributes["key"] = "updated"
|
||||
L.SetGlobal("expectedOS", lua.LString(runtime.GOOS))
|
||||
if err := L.DoString(`
|
||||
assert(attributes.key == "updated")
|
||||
assert(require("xray.router").LocalOS == expectedOS)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaRouteEmptyIPs(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ips []net.IP
|
||||
}{
|
||||
{"nil", nil},
|
||||
{"empty", []net.IP{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
L := newLuaRouterState(t, `
|
||||
function HandleRoute(ctx)
|
||||
for _, name in ipairs({"GetSourceIPs", "GetTargetIPs", "GetLocalIPs"}) do
|
||||
local ips = ctx[name](ctx)
|
||||
if expectNil then
|
||||
assert(ips == nil)
|
||||
else
|
||||
assert(type(ips) == "userdata" and #ips == 0)
|
||||
assert(not pcall(function() return ips[1] end))
|
||||
end
|
||||
end
|
||||
return "out"
|
||||
end
|
||||
`)
|
||||
L.SetGlobal("expectNil", lua.LBool(tc.ips == nil))
|
||||
ctx := newLuaRouteTestContext()
|
||||
ctx.sourceIPs, ctx.targetIPs, ctx.localIPs = tc.ips, tc.ips, tc.ips
|
||||
if err := callLuaRoute(L, ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLuaRouteResult(t *testing.T) {
|
||||
nativeErr := go_errors.New("native failure")
|
||||
for _, tc := range []struct {
|
||||
name, values string
|
||||
wantTag, wantRule string
|
||||
wantErr error
|
||||
wantMessage string
|
||||
}{
|
||||
{name: "route", values: `"out", "rule"`, wantTag: "out", wantRule: "rule"},
|
||||
{name: "no match", values: `nil`},
|
||||
{name: "empty tag", values: `""`},
|
||||
{name: "no match ignores rule", values: `nil, false`},
|
||||
{name: "empty tag ignores rule", values: `"", false`},
|
||||
{name: "missing rule", values: `"out"`, wantTag: "out"},
|
||||
{name: "invalid tag", values: `1`, wantMessage: "outboundTag"},
|
||||
{name: "invalid rule", values: `"out", false`, wantMessage: "ruleTag"},
|
||||
{name: "string error", values: `nil, nil, "script failure"`, wantMessage: "script failure"},
|
||||
{name: "native error", values: `nil, nil, nativeError`, wantErr: nativeErr},
|
||||
{name: "error overrides invalid tags", values: `false, false, nativeError`, wantErr: nativeErr},
|
||||
{name: "invalid error", values: `"out", "rule", false`, wantMessage: "error or string"},
|
||||
{name: "wrong error userdata", values: `"out", "rule", wrongError`, wantMessage: "error or string"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
for name, value := range map[string]any{"nativeError": nativeErr, "wrongError": "not a native error"} {
|
||||
ud := L.NewUserData()
|
||||
ud.Value = value
|
||||
L.SetGlobal(name, ud)
|
||||
}
|
||||
fn, err := L.LoadString("return " + tc.values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := L.CallByParam(lua.P{Fn: fn, NRet: 3, Protect: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outboundTag, ruleTag, err := readLuaRouteResult(L)
|
||||
if outboundTag != tc.wantTag || ruleTag != tc.wantRule {
|
||||
t.Fatalf("result = %q, %q, %v; want %q, %q", outboundTag, ruleTag, err, tc.wantTag, tc.wantRule)
|
||||
}
|
||||
switch {
|
||||
case tc.wantErr != nil:
|
||||
if err != tc.wantErr {
|
||||
t.Fatalf("error = %v, want original error", err)
|
||||
}
|
||||
case tc.wantMessage != "":
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantMessage) {
|
||||
t.Fatalf("error = %v, want %q", err, tc.wantMessage)
|
||||
}
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallLuaRouteCancellation(t *testing.T) {
|
||||
L := newLuaRouterState(t, `function HandleRoute() while true do end end`)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
L.SetContext(ctx)
|
||||
if err := callLuaRoute(L, &routing_session.Context{}); err == nil {
|
||||
t.Fatal("callLuaRoute did not stop after context cancellation")
|
||||
}
|
||||
if L.Context() != ctx {
|
||||
t.Fatal("callLuaRoute changed the Lua state's context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindProcess(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, network, target string
|
||||
targetPort uint16
|
||||
modify func(*luaRouteTestContext)
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "TCP", network: "tcp", target: "127.0.0.3", targetPort: 443},
|
||||
{name: "UDP", network: "udp", target: "127.0.0.3", targetPort: 443, modify: func(c *luaRouteTestContext) {
|
||||
c.Outbound.Target.Network = net.Network_UDP
|
||||
}},
|
||||
{name: "domain target", network: "tcp", modify: func(c *luaRouteTestContext) { c.targetIPs = nil }},
|
||||
{name: "missing source", modify: func(c *luaRouteTestContext) { c.sourceIPs = nil }, wantErr: true},
|
||||
{name: "unsupported network", modify: func(c *luaRouteTestContext) {
|
||||
c.Outbound.Target.Network = net.Network_UNIX
|
||||
}, wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := newLuaRouteTestContext()
|
||||
if tc.modify != nil {
|
||||
tc.modify(ctx)
|
||||
}
|
||||
called := false
|
||||
pid, name, path, err := findProcess(ctx, func(network, source string, sourcePort uint16, target string, targetPort uint16) (int, string, string, error) {
|
||||
called = true
|
||||
if network != tc.network || source != "127.0.0.2" || sourcePort != 1234 || target != tc.target || targetPort != tc.targetPort {
|
||||
t.Fatalf("endpoints = %s %s:%d -> %s:%d", network, source, sourcePort, target, targetPort)
|
||||
}
|
||||
return 42, "process", "/path/process", nil
|
||||
})
|
||||
if tc.wantErr {
|
||||
if err == nil || called {
|
||||
t.Fatalf("findProcess = %d, %q, %q, %v", pid, name, path, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || !called || pid != 42 || name != "process" || path != "/path/process" {
|
||||
t.Fatalf("findProcess = %d, %q, %q, %v", pid, name, path, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var _ routing.Context = (*luaRouteTestContext)(nil)
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
type Router struct {
|
||||
domainStrategy Config_DomainStrategy
|
||||
rules atomic.Pointer[[]*Rule]
|
||||
script *scriptEngine
|
||||
balancers atomic.Pointer[map[string]*Balancer]
|
||||
dns dns.Client
|
||||
|
||||
@@ -45,6 +46,14 @@ func (r *Router) Init(ctx context.Context, config *Config, d dns.Client, ohm out
|
||||
r.ohm = ohm
|
||||
r.dispatcher = dispatcher
|
||||
|
||||
if config.Script != "" {
|
||||
script, err := newScriptEngine(config.Script)
|
||||
if err != nil {
|
||||
return errors.New("failed to prepare routing script").Base(err)
|
||||
}
|
||||
r.script = script
|
||||
}
|
||||
|
||||
r.rules.Store(new([]*Rule))
|
||||
r.balancers.Store(&map[string]*Balancer{})
|
||||
return r.ReloadRules(config, false)
|
||||
@@ -52,6 +61,10 @@ func (r *Router) Init(ctx context.Context, config *Config, d dns.Client, ohm out
|
||||
|
||||
// PickRoute implements routing.Router.
|
||||
func (r *Router) PickRoute(ctx routing.Context) (routing.Route, error) {
|
||||
if r.script != nil {
|
||||
return r.script.pickRoute(ctx)
|
||||
}
|
||||
|
||||
originalCtx := ctx
|
||||
rule, ctx, err := r.pickRouteInternal(ctx)
|
||||
if err != nil {
|
||||
@@ -221,6 +234,11 @@ func (r *Router) pickRouteInternal(ctx routing.Context) (*Rule, routing.Context,
|
||||
|
||||
// Start implements common.Runnable.
|
||||
func (r *Router) Start() error {
|
||||
if r.script != nil {
|
||||
if err := r.script.start(r); err != nil {
|
||||
return errors.New("failed to initialize routing script").Base(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -235,6 +253,9 @@ func closeWebhooks(rules []*Rule) {
|
||||
|
||||
// Close implements common.Closable.
|
||||
func (r *Router) Close() error {
|
||||
if r.script != nil {
|
||||
r.script.close()
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
closeWebhooks(*r.rules.Load())
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/xtls/xray-core/app/dns"
|
||||
"github.com/xtls/xray-core/common"
|
||||
"github.com/xtls/xray-core/common/errors"
|
||||
"github.com/xtls/xray-core/common/geodata"
|
||||
"github.com/xtls/xray-core/common/log"
|
||||
xlua "github.com/xtls/xray-core/common/lua"
|
||||
"github.com/xtls/xray-core/features/routing"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
const scriptExecutionTimeout = 6 * time.Second
|
||||
|
||||
type scriptEngine struct {
|
||||
program *xlua.Program
|
||||
pool *xlua.Pool
|
||||
}
|
||||
|
||||
func newScriptEngine(script string) (*scriptEngine, error) {
|
||||
program, err := xlua.Compile(script)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &scriptEngine{program: program}, nil
|
||||
}
|
||||
|
||||
func (e *scriptEngine) start(router *Router) error {
|
||||
pool, err := xlua.NewPool(router.ctx, scriptExecutionTimeout, e.program.NewStateFactory(
|
||||
scriptExecutionTimeout*20,
|
||||
256,
|
||||
func(L *lua.LState) {
|
||||
geodata.RegisterLua(L)
|
||||
log.RegisterLua(L)
|
||||
router.RegisterLua(L)
|
||||
dns.RegisterLua(L, router.dns)
|
||||
},
|
||||
func(L *lua.LState) error {
|
||||
if L.GetGlobal("HandleRoute").Type() != lua.LTFunction {
|
||||
return errors.New("routing script must define HandleRoute(...)")
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.pool = pool
|
||||
errors.LogInfo(router.ctx, "routing script initialized")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *scriptEngine) close() {
|
||||
if e.pool != nil {
|
||||
e.pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (e *scriptEngine) pickRoute(ctx routing.Context) (routing.Route, error) {
|
||||
var outboundTag, ruleTag string
|
||||
var routeErr error
|
||||
|
||||
if err := e.pool.WithState(nil, 0, func(L *lua.LState) error {
|
||||
if err := callLuaRoute(L, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
outboundTag, ruleTag, routeErr = readLuaRouteResult(L)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if routeErr != nil {
|
||||
return nil, routeErr
|
||||
}
|
||||
if outboundTag == "" {
|
||||
return nil, common.ErrNoClue
|
||||
}
|
||||
|
||||
return &Route{Context: ctx, outboundTag: outboundTag, ruleTag: ruleTag}, nil
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdnet "net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
wireDNS "github.com/miekg/dns"
|
||||
"github.com/xtls/xray-core/app/dispatcher"
|
||||
appdns "github.com/xtls/xray-core/app/dns"
|
||||
"github.com/xtls/xray-core/app/proxyman"
|
||||
_ "github.com/xtls/xray-core/app/proxyman/outbound"
|
||||
"github.com/xtls/xray-core/common"
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
"github.com/xtls/xray-core/common/serial"
|
||||
"github.com/xtls/xray-core/core"
|
||||
featureDNS "github.com/xtls/xray-core/features/dns"
|
||||
"github.com/xtls/xray-core/features/outbound"
|
||||
"github.com/xtls/xray-core/features/routing"
|
||||
routing_session "github.com/xtls/xray-core/features/routing/session"
|
||||
"github.com/xtls/xray-core/proxy/blackhole"
|
||||
"github.com/xtls/xray-core/proxy/freedom"
|
||||
)
|
||||
|
||||
type luaRouteDNSClient struct {
|
||||
featureDNS.Client
|
||||
lookup func(string, featureDNS.IPOption) ([]net.IP, uint32, error)
|
||||
}
|
||||
|
||||
func (d *luaRouteDNSClient) LookupIP(domain string, option featureDNS.IPOption) ([]net.IP, uint32, error) {
|
||||
return d.lookup(domain, option)
|
||||
}
|
||||
|
||||
type luaRouteOutboundManager struct{ outbound.Manager }
|
||||
|
||||
func (*luaRouteOutboundManager) Select(selectors []string) []string { return selectors }
|
||||
|
||||
func writeRouteScript(t *testing.T, script string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "route.lua")
|
||||
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func startLuaRouter(t *testing.T, script string, d featureDNS.Client, config *Config) *Router {
|
||||
t.Helper()
|
||||
if config == nil {
|
||||
config = &Config{}
|
||||
}
|
||||
config.Script = writeRouteScript(t, script)
|
||||
r := new(Router)
|
||||
if err := r.Init(context.Background(), config, d, &luaRouteOutboundManager{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := r.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestRouterScriptStartup(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, script string
|
||||
compileError bool
|
||||
}{
|
||||
{"syntax error", "function HandleRoute(", true},
|
||||
{"missing hook", "value = 1", false},
|
||||
{"invalid hook", "HandleRoute = true", false},
|
||||
{"initialization error", `error("setup failed")`, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := new(Router)
|
||||
err := r.Init(context.Background(), &Config{Script: writeRouteScript(t, tc.script)}, nil, nil, nil)
|
||||
if tc.compileError {
|
||||
if r.script != nil || err == nil || !strings.Contains(err.Error(), "failed to prepare routing script") {
|
||||
t.Fatalf("Init = %v; want no engine and script preparation error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
if r.script.program == nil || r.script.pool != nil {
|
||||
t.Fatal("Init did not compile the script without starting its pool")
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Start(); err == nil {
|
||||
t.Fatal("Start accepted an invalid routing script")
|
||||
}
|
||||
if r.script.pool != nil {
|
||||
t.Fatal("Start retained a script pool after failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterScriptProgramSnapshot(t *testing.T) {
|
||||
path := writeRouteScript(t, `function HandleRoute() return "compiled", "compiled-rule" end`)
|
||||
engine, err := newScriptEngine(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer engine.close()
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := engine.start(&Router{ctx: context.Background()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route, err := engine.pickRoute(newLuaRouteTestContext())
|
||||
if err != nil || route == nil || route.GetOutboundTag() != "compiled" || route.GetRuleTag() != "compiled-rule" {
|
||||
t.Fatalf("route = %v, %v; want compiled, compiled-rule", route, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterScriptRouting(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
wantTag, wantRule string
|
||||
wantErr error
|
||||
wantMessage string
|
||||
wantCalls string
|
||||
}{
|
||||
{name: "route", body: `return "lua-out", "lua-rule"`, wantTag: "lua-out", wantRule: "lua-rule", wantCalls: "2"},
|
||||
{name: "no match", body: `return nil`, wantErr: common.ErrNoClue, wantCalls: "2"},
|
||||
{name: "empty tag", body: `return ""`, wantErr: common.ErrNoClue, wantCalls: "2"},
|
||||
{name: "balancer error", body: `local tag, err = router.PickOutbound("missing"); return tag, nil, err`, wantMessage: "not found", wantCalls: "2"},
|
||||
{name: "string error", body: `return nil, nil, "blocked"`, wantMessage: "blocked", wantCalls: "2"},
|
||||
{name: "invalid tag", body: `return false`, wantMessage: "outboundTag", wantCalls: "2"},
|
||||
{name: "invalid rule", body: `return "lua-out", false`, wantMessage: "ruleTag", wantCalls: "2"},
|
||||
{name: "execution error", body: `error("execution failed")`, wantMessage: "execution failed", wantCalls: "1"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var dnsCalls atomic.Int32
|
||||
d := &luaRouteDNSClient{lookup: func(string, featureDNS.IPOption) ([]net.IP, uint32, error) {
|
||||
dnsCalls.Add(1)
|
||||
return []net.IP{{1, 2, 3, 4}}, 60, nil
|
||||
}}
|
||||
script := `
|
||||
local router = require("xray.router")
|
||||
local calls = 0
|
||||
function HandleRoute(ctx, inbound)
|
||||
calls = calls + 1
|
||||
if inbound == "count" then return "lua-out", tostring(calls) end
|
||||
` + tc.body + `
|
||||
end
|
||||
`
|
||||
r := startLuaRouter(t, script, d, &Config{
|
||||
DomainStrategy: Config_IpOnDemand,
|
||||
Rule: []*RoutingRule{{
|
||||
TargetTag: &RoutingRule_Tag{Tag: "json-out"},
|
||||
Networks: []net.Network{net.Network_TCP},
|
||||
}},
|
||||
})
|
||||
ctx := newLuaRouteTestContext()
|
||||
ctx.Content.SkipDNSResolve = false
|
||||
route, err := r.PickRoute(ctx)
|
||||
switch {
|
||||
case tc.wantErr != nil:
|
||||
if err != tc.wantErr {
|
||||
t.Fatalf("route error = %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
case tc.wantMessage != "":
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantMessage) {
|
||||
t.Fatalf("route error = %v, want %q", err, tc.wantMessage)
|
||||
}
|
||||
case err != nil:
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.wantTag == "" {
|
||||
if route != nil {
|
||||
t.Fatalf("route = %v, want nil", route)
|
||||
}
|
||||
} else if route == nil || route.GetOutboundTag() != tc.wantTag || route.GetRuleTag() != tc.wantRule || route.(*Route).Context != ctx {
|
||||
t.Fatalf("route = %v; want %q, %q and original context", route, tc.wantTag, tc.wantRule)
|
||||
}
|
||||
|
||||
ctx.Inbound.Tag = "count"
|
||||
route, err = r.PickRoute(ctx)
|
||||
if err != nil || route == nil || route.GetOutboundTag() != "lua-out" || route.GetRuleTag() != tc.wantCalls {
|
||||
t.Fatalf("next route = %v, %v; want lua-out, calls %s", route, err, tc.wantCalls)
|
||||
}
|
||||
if dnsCalls.Load() != 0 {
|
||||
t.Fatal("script routing implicitly resolved DNS")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterScriptModules(t *testing.T) {
|
||||
ips := []net.IP{{127, 0, 0, 7}}
|
||||
calls := 0
|
||||
d := &luaRouteDNSClient{lookup: func(domain string, option featureDNS.IPOption) ([]net.IP, uint32, error) {
|
||||
calls++
|
||||
if domain != "mixed.example." || !option.IPv4Enable || option.IPv6Enable || !option.FakeEnable {
|
||||
t.Fatalf("dns.Query arguments = %q, %+v", domain, option)
|
||||
}
|
||||
return ips, 17, nil
|
||||
}}
|
||||
r := startLuaRouter(t, `
|
||||
local dns = require("xray.dns")
|
||||
local matcher = require("xray.geodata").BuildIPMatcher("127.0.0.0/8")
|
||||
assert(dns.Servers == nil and type(dns.Query) == "function")
|
||||
assert(type(require("xray.log").Info) == "function")
|
||||
function HandleRoute(ctx, inbound, sourcePort, targetPort, localPort, domain)
|
||||
local ips, ttl, err = dns.Query(domain, true, false, true)
|
||||
assert(not err and ttl == 17)
|
||||
assert(matcher:AnyMatch(ips) and matcher:AnyMatch(ctx:GetTargetIPs()))
|
||||
return "out"
|
||||
end`, d, nil)
|
||||
if _, err := r.PickRoute(newLuaRouteTestContext()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("DNS calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterScriptBalancerReload(t *testing.T) {
|
||||
config := func(tag string) *Config {
|
||||
return &Config{BalancingRule: []*BalancingRule{{
|
||||
Tag: "balance", Strategy: "roundrobin", OutboundSelector: []string{tag},
|
||||
}}}
|
||||
}
|
||||
r := startLuaRouter(t, `
|
||||
local router = require("xray.router")
|
||||
function HandleRoute()
|
||||
local tag, err = router.PickOutbound("balance")
|
||||
return tag, "balanced", err
|
||||
end`, nil, config("old"))
|
||||
pick := func(want string) {
|
||||
t.Helper()
|
||||
route, err := r.PickRoute(&routing_session.Context{})
|
||||
if err != nil || route.GetOutboundTag() != want || route.GetRuleTag() != "balanced" {
|
||||
t.Fatalf("route = %v, %v, want %q", route, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
pick("old")
|
||||
if err := r.SetOverrideTarget("balance", "override"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pick("override")
|
||||
if err := r.SetOverrideTarget("balance", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.ReloadRules(config("new"), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pick("new")
|
||||
}
|
||||
|
||||
func TestRouterScriptConcurrentBalancerReload(t *testing.T) {
|
||||
config := func(tag string) *Config {
|
||||
return &Config{BalancingRule: []*BalancingRule{{
|
||||
Tag: "balance", Strategy: "roundrobin", OutboundSelector: []string{tag},
|
||||
}}}
|
||||
}
|
||||
r := startLuaRouter(t, `
|
||||
local router = require("xray.router")
|
||||
function HandleRoute()
|
||||
local tag, err = router.PickOutbound("balance")
|
||||
return tag, nil, err
|
||||
end`, nil, config("a"))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 4 {
|
||||
wg.Go(func() {
|
||||
for range 20 {
|
||||
route, err := r.PickRoute(&routing_session.Context{})
|
||||
if err != nil {
|
||||
t.Errorf("PickRoute: %v", err)
|
||||
return
|
||||
}
|
||||
if tag := route.GetOutboundTag(); tag != "a" && tag != "b" {
|
||||
t.Errorf("unexpected tag %q", tag)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Go(func() {
|
||||
for range 20 {
|
||||
for _, tag := range []string{"a", "b"} {
|
||||
if err := r.ReloadRules(config(tag), false); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestRouterScriptDNSDispatcherReentry(t *testing.T) {
|
||||
conn, err := stdnet.ListenPacket("udp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := conn.LocalAddr().(*stdnet.UDPAddr).Port
|
||||
ready, stopped := make(chan struct{}), make(chan error, 1)
|
||||
var queries atomic.Int32
|
||||
server := &wireDNS.Server{
|
||||
PacketConn: conn,
|
||||
NotifyStartedFunc: func() {
|
||||
close(ready)
|
||||
},
|
||||
Handler: wireDNS.HandlerFunc(func(w wireDNS.ResponseWriter, query *wireDNS.Msg) {
|
||||
queries.Add(1)
|
||||
response := new(wireDNS.Msg).SetReply(query)
|
||||
for _, question := range query.Question {
|
||||
if question.Name == "nested.example." && question.Qtype == wireDNS.TypeA {
|
||||
response.Answer = append(response.Answer, &wireDNS.A{
|
||||
Hdr: wireDNS.RR_Header{Name: question.Name, Rrtype: wireDNS.TypeA, Class: wireDNS.ClassINET, Ttl: 60},
|
||||
A: stdnet.IP{127, 0, 0, 7},
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := w.WriteMsg(response); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}),
|
||||
}
|
||||
go func() { stopped <- server.ActivateAndServe() }()
|
||||
defer func() {
|
||||
server.Shutdown()
|
||||
select {
|
||||
case err := <-stopped:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Error("DNS server did not stop")
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-ready:
|
||||
case err := <-stopped:
|
||||
t.Fatalf("DNS server startup: %v", err)
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("DNS server did not start")
|
||||
}
|
||||
|
||||
dnsScript := writeRouteScript(t, `
|
||||
local server = require("xray.dns").Servers[1]
|
||||
function HandleDNSQuery(domain, ipv4, ipv6, fake)
|
||||
return server:Query(domain, ipv4, ipv6, fake)
|
||||
end`)
|
||||
routerScript := writeRouteScript(t, `
|
||||
local router = require("xray.router")
|
||||
local dns = require("xray.dns")
|
||||
local matcher = require("xray.geodata").BuildIPMatcher("127.0.0.7")
|
||||
local active = false
|
||||
function HandleRoute(ctx, inbound, sourcePort, targetPort, localPort, domain, network,
|
||||
protocol, user, vlessRoute, skipDNSResolve)
|
||||
assert(not active, "borrowed Router VM reentered")
|
||||
if inbound == "dns" then
|
||||
assert(network == router.NetworkUDP and skipDNSResolve == false)
|
||||
return "direct", "dns-route"
|
||||
end
|
||||
active = true
|
||||
local ips, ttl, err = dns.Query("nested.example", true, false, false)
|
||||
assert(not err and matcher:AnyMatch(ips) and active)
|
||||
active = false
|
||||
return "direct", "outer-route"
|
||||
end`)
|
||||
instance, err := core.New(&core.Config{
|
||||
App: []*serial.TypedMessage{
|
||||
serial.ToTypedMessage(&appdns.Config{
|
||||
Tag: "dns", Script: dnsScript, DisableCache: true,
|
||||
NameServer: []*appdns.NameServer{{
|
||||
Id: "upstream", TimeoutMs: 1000,
|
||||
Address: &net.Endpoint{
|
||||
Network: net.Network_UDP,
|
||||
Address: &net.IPOrDomain{Address: &net.IPOrDomain_Ip{Ip: []byte{127, 0, 0, 1}}},
|
||||
Port: uint32(port),
|
||||
},
|
||||
}},
|
||||
}),
|
||||
serial.ToTypedMessage(&Config{Script: routerScript}),
|
||||
serial.ToTypedMessage(&dispatcher.Config{}),
|
||||
serial.ToTypedMessage(&proxyman.OutboundConfig{}),
|
||||
},
|
||||
Outbound: []*core.OutboundHandlerConfig{
|
||||
{Tag: "default", ProxySettings: serial.ToTypedMessage(&blackhole.Config{})},
|
||||
{Tag: "direct", ProxySettings: serial.ToTypedMessage(&freedom.Config{
|
||||
FinalRules: []*freedom.FinalRuleConfig{{Action: freedom.RuleAction_Allow}},
|
||||
})},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := instance.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer instance.Close()
|
||||
r := instance.GetFeature(routing.RouterType()).(*Router)
|
||||
route, err := r.PickRoute(newLuaRouteTestContext())
|
||||
if err != nil || route.GetOutboundTag() != "direct" || route.GetRuleTag() != "outer-route" {
|
||||
t.Fatalf("nested DNS routing = %v, %v", route, err)
|
||||
}
|
||||
if queries.Load() == 0 {
|
||||
t.Fatal("DNS query did not pass through the dispatcher")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/stats/command/command.proto
|
||||
|
||||
package command
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.0
|
||||
// - protoc v6.33.5
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc (unknown)
|
||||
// source: app/stats/command/command.proto
|
||||
|
||||
package command
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/stats/config.proto
|
||||
|
||||
package stats
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: app/version/config.proto
|
||||
|
||||
package version
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package base64
|
||||
|
||||
const (
|
||||
Prefix = "base64:"
|
||||
GzipPrefix = "base64+gzip:"
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
package base64
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/xtls/xray-core/common/errors"
|
||||
)
|
||||
|
||||
// Decode decodes Base64 text with an optional [Prefix] or [GzipPrefix].
|
||||
func Decode(value string) ([]byte, error) {
|
||||
value, zip := strings.CutPrefix(value, GzipPrefix)
|
||||
if !zip {
|
||||
value = strings.TrimPrefix(value, Prefix)
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to decode base64").Base(err)
|
||||
}
|
||||
if !zip {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
reader, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to decompress gzip").Base(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err = io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to decompress gzip").Base(err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package base64
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeFormats(t *testing.T) {
|
||||
compressed, err := Encode([]byte("x"), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, value, want string
|
||||
}{
|
||||
{"unprefixed", "eA==", "x"},
|
||||
{"base64", Prefix + "eA==", "x"},
|
||||
{"gzip", compressed, "x"},
|
||||
{"empty", Prefix, ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := Decode(tc.value)
|
||||
if err != nil || !bytes.Equal(data, []byte(tc.want)) {
|
||||
t.Fatalf("Decode = %q, %v; want %q", data, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package base64
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/xtls/xray-core/common/errors"
|
||||
)
|
||||
|
||||
// Encode returns prefixed Base64 text, optionally compressed with gzip at the highest level.
|
||||
func Encode(data []byte, zip bool) (string, error) {
|
||||
if !zip {
|
||||
return Prefix + base64.StdEncoding.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer, err := gzip.NewWriterLevel(&buf, gzip.BestCompression)
|
||||
if err != nil {
|
||||
return "", errors.New("failed to compress gzip").Base(err)
|
||||
}
|
||||
if _, err := writer.Write(data); err != nil {
|
||||
return "", errors.New("failed to compress gzip").Base(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", errors.New("failed to compress gzip").Base(err)
|
||||
}
|
||||
return GzipPrefix + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package base64
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncodeFormats(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{"empty", nil},
|
||||
{"bytes", []byte("你好\r\n\x00\xff")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, zip := range []bool{false, true} {
|
||||
prefix := Prefix
|
||||
if zip {
|
||||
prefix = GzipPrefix
|
||||
}
|
||||
t.Run(prefix, func(t *testing.T) {
|
||||
value, err := Encode(tc.data, zip)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, ok := strings.CutPrefix(value, prefix)
|
||||
if !ok {
|
||||
t.Fatalf("encoded value does not start with %q", prefix)
|
||||
}
|
||||
// Read the output independently of Decode so matching bugs cannot hide.
|
||||
data, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if zip {
|
||||
// gzip's XFL byte identifies the compression level we selected.
|
||||
if len(data) < 10 || data[8] != 2 {
|
||||
t.Fatal("gzip output does not use the highest compression level")
|
||||
}
|
||||
reader, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err = io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(data, tc.data) {
|
||||
t.Fatal("encoded output does not preserve the input bytes")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+59
-17
@@ -8,23 +8,65 @@ import (
|
||||
|
||||
var privateIPMatcher = sync.OnceValue(func() IPMatcher {
|
||||
return common.Must2(IPReg.BuildIPMatcher(common.Must2(ParseIPRules([]string{
|
||||
"0.0.0.0/8",
|
||||
"10.0.0.0/8",
|
||||
"100.64.0.0/10",
|
||||
"127.0.0.0/8",
|
||||
"169.254.0.0/16",
|
||||
"172.16.0.0/12",
|
||||
"192.0.0.0/24",
|
||||
"192.0.2.0/24",
|
||||
"192.88.99.0/24",
|
||||
"192.168.0.0/16",
|
||||
"198.18.0.0/15",
|
||||
"198.51.100.0/24",
|
||||
"203.0.113.0/24",
|
||||
"224.0.0.0/3",
|
||||
"::/127",
|
||||
"fc00::/7",
|
||||
"fe80::/10",
|
||||
// https://www.iana.org/assignments/iana-ipv4-special-registry
|
||||
"0.0.0.0/8", // "This network"
|
||||
"0.0.0.0/32", // "This host on this network"
|
||||
"10.0.0.0/8", // Private-Use
|
||||
"100.64.0.0/10", // Shared Address Space
|
||||
"127.0.0.0/8", // Loopback
|
||||
"169.254.0.0/16", // Link Local
|
||||
"172.16.0.0/12", // Private-Use
|
||||
// "192.0.0.0/24", // IETF Protocol Assignments
|
||||
"192.0.0.0/29", // IPv4 Service Continuity Prefix
|
||||
"192.0.0.8/32", // IPv4 dummy address
|
||||
"192.0.0.9/32", // Port Control Protocol Anycast
|
||||
"192.0.0.10/32", // Traversal Using Relays around NAT Anycast
|
||||
"192.0.0.170/32", // NAT64/DNS64 Discovery
|
||||
"192.0.0.171/32", // NAT64/DNS64 Discovery
|
||||
"192.0.2.0/24", // Documentation (TEST-NET-1)
|
||||
// "192.31.196.0/24", // AS112-v4
|
||||
"192.52.193.0/24", // AMT
|
||||
// "192.88.99.0/24", // Deprecated (6to4 Relay Anycast)
|
||||
"192.88.99.2/32", // 6a44-relay anycast address
|
||||
"192.168.0.0/16", // Private-Use
|
||||
// "192.175.48.0/24", // Direct Delegation AS112 Service
|
||||
"198.18.0.0/15", // Benchmarking
|
||||
"198.51.100.0/24", // Documentation (TEST-NET-2)
|
||||
"203.0.113.0/24", // Documentation (TEST-NET-3)
|
||||
"240.0.0.0/4", // Reserved
|
||||
"255.255.255.255/32", // Limited Broadcast
|
||||
|
||||
// https://www.iana.org/assignments/multicast-addresses
|
||||
"224.0.0.0/4",
|
||||
|
||||
// https://www.iana.org/assignments/iana-ipv6-special-registry
|
||||
"::1/128", // Loopback Address
|
||||
"::/128", // Unspecified Address
|
||||
// "::ffff:0:0/96", // IPv4-mapped Address
|
||||
"64:ff9b::/96", // IPv4-IPv6 Translat.
|
||||
"64:ff9b:1::/48", // IPv4-IPv6 Translat.
|
||||
"100::/64", // Discard-Only Address Block
|
||||
"100:0:0:1::/64", // Dummy IPv6 Prefix
|
||||
// "2001::/23", // IETF Protocol Assignments
|
||||
"2001::/32", // TEREDO
|
||||
"2001:1::1/128", // Port Control Protocol Anycast
|
||||
"2001:1::2/128", // Traversal Using Relays around NAT Anycast
|
||||
"2001:1::3/128", // DNS-SD Service Registration Protocol Anycast
|
||||
"2001:2::/48", // Benchmarking
|
||||
"2001:3::/32", // AMT
|
||||
// "2001:4:112::/48", // AS112-v6
|
||||
// "2001:10::/28", // Deprecated (previously ORCHID)
|
||||
"2001:20::/28", // ORCHIDv2
|
||||
"2001:30::/28", // Drone Remote ID Protocol Entity Tags (DETs) Prefix
|
||||
"2001:db8::/32", // Documentation
|
||||
"2002::/16", // 6to4
|
||||
// "2620:4f:8000::/48", // Direct Delegation AS112 Service
|
||||
"3fff::/20", // Documentation
|
||||
"5f00::/16", // Segment Routing (SRv6) SIDs
|
||||
"fc00::/7", // Unique-Local
|
||||
"fe80::/10", // Link-Local Unicast
|
||||
|
||||
// https://www.iana.org/assignments/ipv6-multicast-addresses
|
||||
"ff00::/8",
|
||||
}))))
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: common/geodata/geodat.proto
|
||||
|
||||
package geodata
|
||||
|
||||
@@ -17,12 +17,12 @@ import (
|
||||
)
|
||||
|
||||
func checkFile(file, code string) error {
|
||||
r, err := filesystem.OpenAsset(file)
|
||||
r, size, err := filesystem.OpenAsset(file)
|
||||
if err != nil {
|
||||
return errors.New("failed to open ", file).Base(err)
|
||||
}
|
||||
defer r.Close()
|
||||
if _, err := find(r, []byte(code), false); err != nil {
|
||||
if _, err := find(r, []byte(code), false, size); err != nil {
|
||||
return errors.New("failed to check code ", code, " from ", file).Base(err)
|
||||
}
|
||||
return nil
|
||||
@@ -30,12 +30,12 @@ func checkFile(file, code string) error {
|
||||
|
||||
func loadFile(file, code string) ([]byte, error) {
|
||||
runtime.GC() // peak mem
|
||||
r, err := filesystem.OpenAsset(file)
|
||||
r, size, err := filesystem.OpenAsset(file)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to open ", file).Base(err)
|
||||
}
|
||||
defer r.Close()
|
||||
bs, err := find(r, []byte(code), true)
|
||||
bs, err := find(r, []byte(code), true, size)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to load code ", code, " from ", file).Base(err)
|
||||
}
|
||||
@@ -60,13 +60,13 @@ func loadIP(file, code string) ([]*CIDR, error) {
|
||||
// unmarshalling it into a []*Domain, so value is only valid during fn.
|
||||
func loadSite(file, code, attrs string, fn func(Domain_Type, []byte)) error {
|
||||
runtime.GC() // peak mem
|
||||
r, err := filesystem.OpenAsset(file)
|
||||
r, size, err := filesystem.OpenAsset(file)
|
||||
if err != nil {
|
||||
return errors.New("failed to open ", file).Base(err)
|
||||
}
|
||||
defer r.Close()
|
||||
br := bufio.NewReaderSize(r, 64*1024)
|
||||
n, err := seek(br, []byte(code))
|
||||
n, err := seek(br, []byte(code), size)
|
||||
if err != nil {
|
||||
return errors.New("failed to load code ", code, " from ", file).Base(err)
|
||||
}
|
||||
@@ -114,6 +114,9 @@ func decodeVarint(br *bufio.Reader) (uint64, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if shift == 63 && b > 1 {
|
||||
break // Only one value bit fits in the tenth byte.
|
||||
}
|
||||
x |= (uint64(b) & 0x7F) << shift
|
||||
if (b & 0x80) == 0 {
|
||||
return x, nil
|
||||
@@ -123,9 +126,9 @@ func decodeVarint(br *bufio.Reader) (uint64, error) {
|
||||
return 0, errors.New("varint overflow")
|
||||
}
|
||||
|
||||
func find(r io.Reader, code []byte, readBody bool) ([]byte, error) {
|
||||
func find(r io.Reader, code []byte, readBody bool, size int64) ([]byte, error) {
|
||||
br := bufio.NewReaderSize(r, 64*1024)
|
||||
bodyL, err := seek(br, code)
|
||||
bodyL, err := seek(br, code, size)
|
||||
if err != nil || !readBody {
|
||||
return nil, err
|
||||
}
|
||||
@@ -137,7 +140,7 @@ func find(r io.Reader, code []byte, readBody bool) ([]byte, error) {
|
||||
}
|
||||
|
||||
// seek advances br to the body of the entry for code and returns the body length.
|
||||
func seek(br *bufio.Reader, code []byte) (int, error) {
|
||||
func seek(br *bufio.Reader, code []byte, size int64) (int, error) {
|
||||
codeL := len(code)
|
||||
if codeL == 0 {
|
||||
return 0, errors.New("empty code")
|
||||
@@ -145,7 +148,8 @@ func seek(br *bufio.Reader, code []byte) (int, error) {
|
||||
need := 2 + codeL // TODO: if code too long
|
||||
|
||||
for {
|
||||
if _, err := br.ReadByte(); err != nil {
|
||||
tag, err := br.ReadByte()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -153,10 +157,10 @@ func seek(br *bufio.Reader, code []byte) (int, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
bodyL := int(x)
|
||||
if bodyL <= 0 {
|
||||
return 0, errors.New("invalid body length: ", bodyL)
|
||||
if x == 0 || x > uint64(size) || x > uint64(^uint(0)>>1) {
|
||||
return 0, errors.New("invalid body length: ", x)
|
||||
}
|
||||
bodyL := int(x)
|
||||
|
||||
// Peek no more than the buffer holds: a code longer than the buffer cannot match a single
|
||||
// length byte anyway, so a short peek only skips it, as base find (io.ReadFull) does.
|
||||
@@ -167,7 +171,8 @@ func seek(br *bufio.Reader, code []byte) (int, error) {
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if bodyL >= need && len(prefix) >= need && int(prefix[1]) == codeL && bytes.Equal(prefix[2:], code) {
|
||||
if tag == 0x0a && bodyL >= need && len(prefix) >= need &&
|
||||
prefix[0] == 0x0a && int(prefix[1]) == codeL && bytes.Equal(prefix[2:], code) {
|
||||
return bodyL, nil
|
||||
}
|
||||
if _, err := br.Discard(bodyL); err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package geodata
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -12,6 +13,58 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestFindChecksEntryAndCodeTags(t *testing.T) {
|
||||
for _, payload := range [][]byte{
|
||||
{0x12, 6, 0x0a, 4, 'T', 'E', 'S', 'T'}, // Unknown field posing as entry.
|
||||
{0x08, 6, 0x0a, 4, 'T', 'E', 'S', 'T'}, // Wrong entry wire type.
|
||||
{0x0a, 6, 0x1a, 4, 'T', 'E', 'S', 'T'}, // Unknown field posing as code.
|
||||
{0x0a, 6, 0x08, 4, 'T', 'E', 'S', 'T'}, // Wrong code wire type.
|
||||
} {
|
||||
if _, err := find(bytes.NewReader(payload), []byte("TEST"), true, int64(len(payload))); err == nil {
|
||||
t.Fatalf("matched incorrect entry/code tags in %x", payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsVarintOverflow(t *testing.T) {
|
||||
payload := []byte{
|
||||
0x0a,
|
||||
0x86, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x02, // 2^64 + 6
|
||||
0x0a, 0x04, 'T', 'E', 'S', 'T',
|
||||
}
|
||||
dir := t.TempDir()
|
||||
t.Setenv("xray.location.asset", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "overflow.dat"), payload, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadFile("overflow.dat", "TEST"); err == nil || !strings.Contains(err.Error(), "varint overflow") {
|
||||
t.Fatalf("want varint overflow, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileRejectsOversizedEntry(t *testing.T) {
|
||||
payload := []byte{
|
||||
0x0a, 0x03, 0x0a, 0x01, 'A', // A valid entry before the oversized one.
|
||||
0x0a, 0x80, 0x80, 0x80, 0x80, 0x04, // Entry length: 1 GiB.
|
||||
0x0a, 0x04, 'T', 'E', 'S', 'T',
|
||||
}
|
||||
dir := t.TempDir()
|
||||
t.Setenv("xray.location.asset", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "oversized.dat"), payload, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Check without allocating the body first, so a regression fails safely.
|
||||
if err := checkFile("oversized.dat", "TEST"); err == nil {
|
||||
t.Fatal("accepted an entry length larger than the file")
|
||||
}
|
||||
if _, err := loadFile("oversized.dat", "TEST"); err == nil || !strings.Contains(err.Error(), "invalid body length") {
|
||||
t.Fatalf("want invalid body length, got %v", err)
|
||||
}
|
||||
if err := loadSite("oversized.dat", "TEST", "", func(Domain_Type, []byte) {}); err == nil || !strings.Contains(err.Error(), "invalid body length") {
|
||||
t.Fatalf("want invalid body length, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type siteEntry struct {
|
||||
Type Domain_Type
|
||||
Value string
|
||||
|
||||
@@ -400,13 +400,13 @@ func TestIPMatcher4CN(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPMatcher6US(t *testing.T) {
|
||||
func TestIPMatcher6RU(t *testing.T) {
|
||||
t.Setenv("xray.location.asset", filepath.Join("..", "..", "resources"))
|
||||
|
||||
matcher := buildIPMatcher("geoip:us")
|
||||
matcher := buildIPMatcher("geoip:ru")
|
||||
|
||||
if !matcher.Match(xnet.ParseAddress("2001:4860:4860::8888").IP()) {
|
||||
t.Error("expect US geoip contain 2001:4860:4860::8888, but actually not")
|
||||
if !matcher.Match(xnet.ParseAddress("2a02:6b8::feed:0ff").IP()) {
|
||||
t.Error("expect RU geoip contain 2a02:6b8::feed:0ff, but actually not")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,11 +423,11 @@ func BenchmarkIPMatcher4CN(b *testing.B) {
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIPMatcher6US(b *testing.B) {
|
||||
func BenchmarkIPMatcher6RU(b *testing.B) {
|
||||
b.Setenv("xray.location.asset", filepath.Join("..", "..", "resources"))
|
||||
|
||||
matcher := buildIPMatcher("geoip:us")
|
||||
ip := xnet.ParseAddress("2001:4860:4860::8888").IP()
|
||||
matcher := buildIPMatcher("geoip:ru")
|
||||
ip := xnet.ParseAddress("2a02:6b8::feed:0ff").IP()
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package geodata
|
||||
|
||||
import (
|
||||
xlua "github.com/xtls/xray-core/common/lua"
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// RegisterLua makes xray.geodata available to require in an LState.
|
||||
func RegisterLua(L *lua.LState) {
|
||||
L.PreloadModule("xray.geodata", func(L *lua.LState) int {
|
||||
module := L.CreateTable(0, 2)
|
||||
|
||||
module.RawSetString("BuildDomainMatcher", L.NewFunction(func(L *lua.LState) int {
|
||||
parsed, err := ParseDomainRules(luaRules(L), Domain_Domain)
|
||||
if err != nil {
|
||||
L.RaiseError("%v", err)
|
||||
return 0
|
||||
}
|
||||
matcher, err := DomainReg.BuildDomainMatcher(parsed)
|
||||
if err != nil {
|
||||
L.RaiseError("%v", err)
|
||||
return 0
|
||||
}
|
||||
xlua.PushWithDirectMethods(L, matcher, map[string]xlua.DirectMethod{
|
||||
"Match": newLuaDomainMatch(xlua.NewSlicePusher[uint32](L)),
|
||||
"MatchAny": luaDomainMatchAny,
|
||||
})
|
||||
return 1
|
||||
}))
|
||||
|
||||
module.RawSetString("BuildIPMatcher", L.NewFunction(func(L *lua.LState) int {
|
||||
parsed, err := ParseIPRules(luaRules(L))
|
||||
if err != nil {
|
||||
L.RaiseError("%v", err)
|
||||
return 0
|
||||
}
|
||||
matcher, err := IPReg.BuildIPMatcher(parsed)
|
||||
if err != nil {
|
||||
L.RaiseError("%v", err)
|
||||
return 0
|
||||
}
|
||||
xlua.PushWithDirectMethods(L, matcher, map[string]xlua.DirectMethod{
|
||||
"Match": luaIPMatch,
|
||||
"AnyMatch": luaIPAnyMatch,
|
||||
"Matches": luaIPMatches,
|
||||
"FilterIPs": newLuaIPFilterIPs(xlua.NewReadOnlySlicePusher[net.IP](L)),
|
||||
}, "SetReverse", "ToggleReverse")
|
||||
return 1
|
||||
}))
|
||||
|
||||
L.Push(module)
|
||||
return 1
|
||||
})
|
||||
}
|
||||
|
||||
// Read native Go values by type assertion; slices keep their original storage.
|
||||
func readLuaIPMatcherArgs[T any](L *lua.LState) (IPMatcher, T, bool) {
|
||||
var input T
|
||||
if L.GetTop() != 2 {
|
||||
return nil, input, false
|
||||
}
|
||||
value, ok := L.Get(1).(*lua.LUserData)
|
||||
if !ok {
|
||||
return nil, input, false
|
||||
}
|
||||
matcher, ok := value.Value.(IPMatcher)
|
||||
if !ok {
|
||||
return nil, input, false
|
||||
}
|
||||
if L.Get(2) == lua.LNil {
|
||||
return matcher, input, true
|
||||
}
|
||||
value, ok = L.Get(2).(*lua.LUserData)
|
||||
if !ok {
|
||||
return nil, input, false
|
||||
}
|
||||
input, ok = value.Value.(T)
|
||||
return matcher, input, ok
|
||||
}
|
||||
|
||||
func luaIPMatch(L *lua.LState) (int, bool) {
|
||||
matcher, ip, ok := readLuaIPMatcherArgs[net.IP](L)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
L.Push(lua.LBool(matcher.Match(ip)))
|
||||
return 1, true
|
||||
}
|
||||
|
||||
func luaIPAnyMatch(L *lua.LState) (int, bool) {
|
||||
matcher, ips, ok := readLuaIPMatcherArgs[[]net.IP](L)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
L.Push(lua.LBool(matcher.AnyMatch(ips)))
|
||||
return 1, true
|
||||
}
|
||||
|
||||
func luaIPMatches(L *lua.LState) (int, bool) {
|
||||
matcher, ips, ok := readLuaIPMatcherArgs[[]net.IP](L)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
L.Push(lua.LBool(matcher.Matches(ips)))
|
||||
return 1, true
|
||||
}
|
||||
|
||||
func newLuaIPFilterIPs(pushIPs func(*lua.LState, []net.IP)) xlua.DirectMethod {
|
||||
return func(L *lua.LState) (int, bool) {
|
||||
matcher, ips, ok := readLuaIPMatcherArgs[[]net.IP](L)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
matched, unmatched := matcher.FilterIPs(ips)
|
||||
pushIPs(L, matched)
|
||||
pushIPs(L, unmatched)
|
||||
return 2, true
|
||||
}
|
||||
}
|
||||
|
||||
func newLuaDomainMatch(pushMatches func(*lua.LState, []uint32)) xlua.DirectMethod {
|
||||
return func(L *lua.LState) (int, bool) {
|
||||
if L.GetTop() == 2 {
|
||||
if value, ok := L.Get(1).(*lua.LUserData); ok {
|
||||
matcher, validMatcher := value.Value.(DomainMatcher)
|
||||
domain, validDomain := L.Get(2).(lua.LString)
|
||||
if validMatcher && validDomain {
|
||||
pushMatches(L, matcher.Match(string(domain)))
|
||||
return 1, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func luaDomainMatchAny(L *lua.LState) (int, bool) {
|
||||
if L.GetTop() == 2 {
|
||||
if value, ok := L.Get(1).(*lua.LUserData); ok {
|
||||
matcher, validMatcher := value.Value.(DomainMatcher)
|
||||
domain, validDomain := L.Get(2).(lua.LString)
|
||||
if validMatcher && validDomain {
|
||||
L.Push(lua.LBool(matcher.MatchAny(string(domain))))
|
||||
return 1, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func luaRules(L *lua.LState) []string {
|
||||
rules := make([]string, L.GetTop())
|
||||
for i := range rules {
|
||||
value, ok := L.Get(i + 1).(lua.LString)
|
||||
if !ok {
|
||||
L.RaiseError("geodata rules must be strings")
|
||||
return nil
|
||||
}
|
||||
rules[i] = string(value)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package geodata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func TestLuaIPMatcher(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
RegisterLua(L)
|
||||
ip := L.NewUserData()
|
||||
ip.Value = net.ParseIP("127.0.0.1")
|
||||
L.SetGlobal("ip", ip)
|
||||
ips := L.NewUserData()
|
||||
ips.Value = []net.IP{ip.Value.(net.IP), net.ParseIP("8.8.8.8")}
|
||||
L.SetGlobal("ips", ips)
|
||||
if err := L.DoString(`
|
||||
local matcher = require("xray.geodata").BuildIPMatcher("127.0.0.0/8", "::1")
|
||||
assert(matcher:Match(ip))
|
||||
assert(matcher:AnyMatch(ips))
|
||||
assert(not matcher:Matches(ips))
|
||||
local matched, unmatched = matcher:FilterIPs(ips)
|
||||
assert(type(matched) == "userdata" and type(unmatched) == "userdata")
|
||||
assert(#matched == 1 and #unmatched == 1)
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaDomainMatcher(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
RegisterLua(L)
|
||||
if err := L.DoString(`
|
||||
local matcher = require("xray.geodata").BuildDomainMatcher("example.com", "full:other.com")
|
||||
assert(matcher:MatchAny("example.com"))
|
||||
assert(matcher:MatchAny("www.example.com"))
|
||||
assert(matcher:MatchAny("other.com"))
|
||||
assert(not matcher:MatchAny("www.other.com"))
|
||||
assert(#(matcher:Match("www.example.com")) == 1)
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaMatchersRejectInvalidRules(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
script string
|
||||
}{
|
||||
{"IP rule", `require("xray.geodata").BuildIPMatcher("not-an-ip")`},
|
||||
{"non-string domain rule", `require("xray.geodata").BuildDomainMatcher("example.com", true)`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
RegisterLua(L)
|
||||
if err := L.DoString(tc.script); err == nil {
|
||||
t.Fatal("invalid geodata rule was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaMatcherArgumentsAndAliases(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
RegisterLua(L)
|
||||
ip := L.NewUserData()
|
||||
ip.Value = net.ParseIP("127.0.0.1")
|
||||
L.SetGlobal("ip", ip)
|
||||
if err := L.DoString(`
|
||||
local geodata = require("xray.geodata")
|
||||
local matcher = geodata.BuildIPMatcher("127.0.0.0/8")
|
||||
assert(matcher.Match == matcher.match and matcher.AnyMatch == matcher.anyMatch)
|
||||
assert(matcher.Matches == matcher.matches and matcher.FilterIPs == matcher.filterIPs)
|
||||
assert(matcher.Reload == nil and matcher.reload == nil)
|
||||
assert(matcher:match(ip))
|
||||
assert(matcher:anyMatch({ip}) and matcher:matches({ip}))
|
||||
assert(not matcher:AnyMatch(nil))
|
||||
assert(matcher:Matches(nil) == matcher:Matches({}))
|
||||
local matched, unmatched = matcher:FilterIPs({ip})
|
||||
assert(#matched == 1 and matched[1]:Equal(ip))
|
||||
assert(matcher:AnyMatch(matched) and matcher:Matches(matched))
|
||||
local filtered, excluded = matcher:filterIPs(matched)
|
||||
assert(#filtered == 1 and #excluded == 0 and filtered[1]:Equal(ip))
|
||||
local emptyMatched, emptyUnmatched = matcher:FilterIPs(nil)
|
||||
assert(#emptyMatched == 0 and #emptyUnmatched == 0)
|
||||
matcher:SetReverse(true)
|
||||
assert(not matcher:Match(ip) and not matcher:AnyMatch(matched))
|
||||
matcher:ToggleReverse()
|
||||
assert(matcher:Match(ip) and matcher:AnyMatch(matched))
|
||||
assert(matcher.missing == nil)
|
||||
|
||||
local domain = geodata.BuildDomainMatcher("full:example.com")
|
||||
assert(domain.Match == domain.match and domain.MatchAny == domain.matchAny)
|
||||
assert(domain.Reload == nil and domain.reload == nil)
|
||||
for _, value in ipairs({matcher, domain}) do
|
||||
assert(not pcall(function() return -value end))
|
||||
assert(not pcall(function() return value ^ {} end))
|
||||
end
|
||||
assert(domain:matchAny("example.com"))
|
||||
assert(#domain:Match("example.com") == 1)
|
||||
assert(domain:match("example.com")[1] == 0)
|
||||
assert(not pcall(function() matcher:AnyMatch() end))
|
||||
assert(not pcall(function() matcher:AnyMatch(matched, true) end))
|
||||
assert(not pcall(function() matcher.AnyMatch(ip, matched) end))
|
||||
assert(not pcall(function() matcher:Match(true) end))
|
||||
assert(not pcall(function() domain:MatchAny(123) end))
|
||||
assert(not pcall(function() domain:MatchAny("example.com", true) end))
|
||||
assert(not pcall(function() matcher:FilterIPs(true) end))
|
||||
assert(not pcall(function() matcher:FilterIPs(matched, true) end))
|
||||
assert(not pcall(function() domain:Match(123) end))
|
||||
assert(not pcall(function() domain:Match("example.com", true) end))
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkLuaMatcherCall measures repeated calls with prebuilt matchers and inputs.
|
||||
func BenchmarkLuaMatcherCall(b *testing.B) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
RegisterLua(L)
|
||||
ip := net.ParseIP("127.0.0.1")
|
||||
for name, value := range map[string]any{"ip": ip, "ips": []net.IP{ip}} {
|
||||
ud := L.NewUserData()
|
||||
ud.Value = value
|
||||
L.SetGlobal(name, ud)
|
||||
}
|
||||
if err := L.DoString(`
|
||||
local geodata = require("xray.geodata")
|
||||
ipMatcher = geodata.BuildIPMatcher("127.0.0.0/8")
|
||||
domainMatcher = geodata.BuildDomainMatcher("full:example.com")
|
||||
`); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
for _, benchmark := range []struct {
|
||||
name, expression string
|
||||
}{
|
||||
{"ip_match", "ipMatcher:Match(ip)"},
|
||||
{"ip_match_lower", "ipMatcher:match(ip)"},
|
||||
{"ip_any_match", "ipMatcher:AnyMatch(ips)"},
|
||||
{"ip_any_match_lower", "ipMatcher:anyMatch(ips)"},
|
||||
{"ip_matches", "ipMatcher:Matches(ips)"},
|
||||
{"ip_matches_lower", "ipMatcher:matches(ips)"},
|
||||
{"domain_match_any", `domainMatcher:MatchAny("example.com")`},
|
||||
{"domain_match_any_lower", `domainMatcher:matchAny("example.com")`},
|
||||
{"ip_filter", "select(1, ipMatcher:FilterIPs(ips)) ~= nil"},
|
||||
{"ip_filter_lower", "select(1, ipMatcher:filterIPs(ips)) ~= nil"},
|
||||
{"domain_match", `#domainMatcher:Match("example.com") == 1`},
|
||||
{"domain_match_lower", `#domainMatcher:match("example.com") == 1`},
|
||||
{"ip_lua_table", "ipMatcher:AnyMatch({ip})"},
|
||||
{"ip_lua_table_lower", "ipMatcher:anyMatch({ip})"},
|
||||
} {
|
||||
b.Run(benchmark.name, func(b *testing.B) {
|
||||
if err := L.DoString(fmt.Sprintf("function benchmarkMatch() return %s end", benchmark.expression)); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
fn := L.GetGlobal("benchmarkMatch")
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := L.CallByParam(lua.P{Fn: fn, NRet: 1, Protect: true}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if L.Get(-1) != lua.LTrue {
|
||||
b.Fatal("matcher returned false")
|
||||
}
|
||||
L.Pop(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ func TestParseIPRules(t *testing.T) {
|
||||
t.Setenv("xray.location.asset", filepath.Join("..", "..", "resources"))
|
||||
|
||||
rules := []string{
|
||||
"geoip:us",
|
||||
"geoip:ru",
|
||||
"geoip:cn",
|
||||
"!geoip:cn",
|
||||
"!!geoip:cn",
|
||||
@@ -20,9 +20,9 @@ func TestParseIPRules(t *testing.T) {
|
||||
"!geoip:!cn",
|
||||
"ext:geoip.dat:!cn",
|
||||
"ext:geoip.dat:!!cn",
|
||||
"ext:geoip.dat:ca",
|
||||
"ext:geoip.dat:ir",
|
||||
"ext-ip:geoip.dat:!cn",
|
||||
"ext-ip:geoip.dat:!ca",
|
||||
"ext-ip:geoip.dat:!ir",
|
||||
"192.168.0.0/24",
|
||||
"!192.168.0.0/24",
|
||||
"!!192.168.0.0/24",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: common/log/log.proto
|
||||
|
||||
package log
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// RegisterLua makes xray.log available to require in an LState.
|
||||
func RegisterLua(L *lua.LState) {
|
||||
L.PreloadModule("xray.log", func(L *lua.LState) int {
|
||||
module := L.CreateTable(0, 4)
|
||||
var source, prefix string // cache
|
||||
for name, severity := range map[string]Severity{
|
||||
"Debug": Severity_Debug,
|
||||
"Info": Severity_Info,
|
||||
"Warning": Severity_Warning,
|
||||
"Error": Severity_Error,
|
||||
} {
|
||||
module.RawSetString(name, L.NewFunction(func(L *lua.LState) int {
|
||||
if GetSeverity() < severity {
|
||||
return 0
|
||||
}
|
||||
var content strings.Builder
|
||||
// Prefix with the calling script's filename.
|
||||
if caller, ok := L.GetStack(1); ok {
|
||||
if _, err := L.GetInfo("S", caller, lua.LNil); err == nil && caller.Source != "" {
|
||||
if caller.Source != source {
|
||||
source = caller.Source
|
||||
prefix = filepath.Base(strings.TrimPrefix(source, "@")) + ": "
|
||||
}
|
||||
content.WriteString(prefix)
|
||||
}
|
||||
}
|
||||
for i := 1; i <= L.GetTop(); i++ {
|
||||
content.WriteString(luaLogString(L, L.Get(i)))
|
||||
}
|
||||
Record(&GeneralMessage{
|
||||
Severity: severity,
|
||||
Content: content.String(),
|
||||
})
|
||||
return 0
|
||||
}))
|
||||
}
|
||||
L.Push(module)
|
||||
return 1
|
||||
})
|
||||
}
|
||||
|
||||
func luaLogString(L *lua.LState, value lua.LValue) string {
|
||||
if ud, ok := value.(*lua.LUserData); ok {
|
||||
if err, ok := ud.Value.(error); ok {
|
||||
return err.Error()
|
||||
}
|
||||
}
|
||||
if _, ok := L.GetMetaField(value, "__tostring").(*lua.LFunction); ok {
|
||||
return L.ToStringMeta(value).String()
|
||||
}
|
||||
return value.String()
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
type luaLogHandler struct {
|
||||
messages []Message
|
||||
}
|
||||
|
||||
func (h *luaLogHandler) Handle(msg Message) {
|
||||
h.messages = append(h.messages, msg)
|
||||
}
|
||||
|
||||
func TestLuaLog(t *testing.T) {
|
||||
previous := logHandler.Load()
|
||||
t.Cleanup(func() { logHandler.Store(previous) })
|
||||
handler := &luaLogHandler{}
|
||||
RegisterHandler(handler)
|
||||
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
RegisterLua(L)
|
||||
nativeError := L.NewUserData()
|
||||
nativeError.Value = fmt.Errorf("lookup failed: %w", errors.New("upstream timeout"))
|
||||
L.SetGlobal("nativeError", nativeError)
|
||||
path := filepath.Join(t.TempDir(), "logging.lua")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
local log = require("xray.log")
|
||||
assert(log == require("xray.log"))
|
||||
log.Debug("query: ", "example.com")
|
||||
log.Info("count=", 42, ", enabled=", true, ", value=", nil)
|
||||
log.Warning(setmetatable({}, {
|
||||
__tostring = function() return "fallback" end
|
||||
}))
|
||||
assert(select("#", log.Error("failed")) == 0)
|
||||
log.Error("DNS failed: ", nativeError)
|
||||
log.Warning(nativeError)
|
||||
local ok, err = pcall(function() error("Lua failure", 0) end)
|
||||
assert(not ok)
|
||||
log.Error(err)
|
||||
local calls = 0
|
||||
local custom = setmetatable({}, {
|
||||
__tostring = function() calls = calls + 1; return "custom" end
|
||||
})
|
||||
log.Info(custom, custom)
|
||||
assert(calls == 2)
|
||||
log.Info("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l")
|
||||
log.Info()
|
||||
function logHook()
|
||||
log.Info("hook")
|
||||
end
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := L.DoFile(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := L.DoString(`
|
||||
logHook()
|
||||
require("xray.log").Info("anonymous")
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := filepath.Join(t.TempDir(), "other.lua")
|
||||
if err := os.WriteFile(other, []byte(`
|
||||
local log = require("xray.log")
|
||||
log.Info("other")
|
||||
logHook()
|
||||
log.Info("other again")
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := L.DoFile(other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []struct {
|
||||
severity Severity
|
||||
message string
|
||||
}{
|
||||
{Severity_Debug, "[Debug] logging.lua: query: example.com"},
|
||||
{Severity_Info, "[Info] logging.lua: count=42, enabled=true, value=nil"},
|
||||
{Severity_Warning, "[Warning] logging.lua: fallback"},
|
||||
{Severity_Error, "[Error] logging.lua: failed"},
|
||||
{Severity_Error, "[Error] logging.lua: DNS failed: lookup failed: upstream timeout"},
|
||||
{Severity_Warning, "[Warning] logging.lua: lookup failed: upstream timeout"},
|
||||
{Severity_Error, "[Error] logging.lua: Lua failure"},
|
||||
{Severity_Info, "[Info] logging.lua: customcustom"},
|
||||
{Severity_Info, "[Info] logging.lua: abcdefghijkl"},
|
||||
{Severity_Info, "[Info] logging.lua: "},
|
||||
{Severity_Info, "[Info] logging.lua: hook"},
|
||||
{Severity_Info, "[Info] <string>: anonymous"},
|
||||
{Severity_Info, "[Info] other.lua: other"},
|
||||
{Severity_Info, "[Info] logging.lua: hook"},
|
||||
{Severity_Info, "[Info] other.lua: other again"},
|
||||
}
|
||||
if len(handler.messages) != len(want) {
|
||||
t.Fatalf("logged %d messages, want %d", len(handler.messages), len(want))
|
||||
}
|
||||
for i, expected := range want {
|
||||
msg, ok := handler.messages[i].(*GeneralMessage)
|
||||
if !ok {
|
||||
t.Fatalf("message %d has type %T, want *GeneralMessage", i, handler.messages[i])
|
||||
}
|
||||
if msg.Severity != expected.severity || msg.String() != expected.message {
|
||||
t.Errorf("message %d = %q with severity %v, want %q with severity %v", i, msg.String(), msg.Severity, expected.message, expected.severity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type luaSeverityLogHandler struct {
|
||||
luaLogHandler
|
||||
level Severity
|
||||
}
|
||||
|
||||
func (h *luaSeverityLogHandler) Severity() Severity { return h.level }
|
||||
|
||||
func TestLuaLogSeverity(t *testing.T) {
|
||||
previous := logHandler.Load()
|
||||
t.Cleanup(func() { logHandler.Store(previous) })
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
RegisterLua(L)
|
||||
for _, level := range []Severity{Severity_Unknown, Severity_Error, Severity_Warning, Severity_Info, Severity_Debug, Severity_Warning} {
|
||||
t.Run(level.String(), func(t *testing.T) {
|
||||
handler := &luaSeverityLogHandler{level: level}
|
||||
RegisterHandler(handler)
|
||||
want := []Severity{}
|
||||
for _, severity := range []Severity{Severity_Error, Severity_Warning, Severity_Info, Severity_Debug} {
|
||||
if severity <= level {
|
||||
want = append(want, severity)
|
||||
}
|
||||
}
|
||||
if err := L.DoString(fmt.Sprintf(`
|
||||
local log = require("xray.log")
|
||||
local calls = 0
|
||||
local value = setmetatable({}, {
|
||||
__tostring = function() calls = calls + 1; return "message" end
|
||||
})
|
||||
for _, write in ipairs({log.Error, log.Warning, log.Info, log.Debug}) do
|
||||
assert(select("#", write(value)) == 0)
|
||||
end
|
||||
assert(calls == %d)
|
||||
`, len(want))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(handler.messages) != len(want) {
|
||||
t.Fatalf("logged %d messages, want %d", len(handler.messages), len(want))
|
||||
}
|
||||
for i, severity := range want {
|
||||
msg := handler.messages[i].(*GeneralMessage)
|
||||
if msg.Severity != severity || msg.Content != "<string>: message" {
|
||||
t.Errorf("message %d = %v, want severity %v and content %q", i, msg, severity, "<string>: message")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type luaDiscardLogHandler struct{ level Severity }
|
||||
|
||||
func (luaDiscardLogHandler) Handle(Message) {}
|
||||
func (h luaDiscardLogHandler) Severity() Severity { return h.level }
|
||||
|
||||
func BenchmarkLuaLog(b *testing.B) {
|
||||
benchmarkLuaLog(b, Severity_Debug)
|
||||
}
|
||||
|
||||
func BenchmarkLuaLogFiltered(b *testing.B) {
|
||||
benchmarkLuaLog(b, Severity_Warning)
|
||||
}
|
||||
|
||||
func benchmarkLuaLog(b *testing.B, level Severity) {
|
||||
previous := logHandler.Load()
|
||||
b.Cleanup(func() { logHandler.Store(previous) })
|
||||
RegisterHandler(luaDiscardLogHandler{level: level})
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
RegisterLua(L)
|
||||
if err := L.DoString(`custom = setmetatable({}, {__tostring = function() return "custom" end})`); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
for _, benchmark := range []struct {
|
||||
name, arguments string
|
||||
}{
|
||||
{"strings", `"query: ", "example.com"`},
|
||||
{"mixed", `"count=", 42, ", enabled=", true, ", value=", nil`},
|
||||
{"many_arguments", `"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"`},
|
||||
{"tostring", "custom"},
|
||||
} {
|
||||
b.Run(benchmark.name, func(b *testing.B) {
|
||||
if err := L.DoString(fmt.Sprintf(`local log = require("xray.log")
|
||||
function benchmarkLog() log.Info(%s) end`, benchmark.arguments)); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
fn := L.GetGlobal("benchmarkLog")
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := L.CallByParam(lua.P{Fn: fn, NRet: 0, Protect: true}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// DO NOT EDIT. Regression test for app/geodata's luaGuardHeader.
|
||||
|
||||
package lua
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/gopher-lua/parse"
|
||||
)
|
||||
|
||||
func TestGeodataLuaGuard(t *testing.T) {
|
||||
_, err := parse.Parse(strings.NewReader("\x12\x01\x00function HandleRoute() end"), "geodata")
|
||||
var syntaxErr *parse.Error
|
||||
if !errors.As(err, &syntaxErr) || syntaxErr.Pos.Line != 1 || syntaxErr.Pos.Column != 1 {
|
||||
t.Fatalf("want Lua rejection at the first byte, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package lua provides shared GopherLua programs, state management, and value
|
||||
// conversion and validation helpers for Xray scripts.
|
||||
package lua
|
||||
@@ -0,0 +1,156 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"net"
|
||||
"reflect"
|
||||
|
||||
glua "github.com/yuin/gopher-lua"
|
||||
luar "layeh.com/gopher-luar"
|
||||
)
|
||||
|
||||
// NewSlicePusher captures luar's slice metatable during state initialization.
|
||||
// The returned function wraps slices without reflection or metatable lookup,
|
||||
// and pushes nil for nil slices. Use it with this state or its coroutines.
|
||||
func NewSlicePusher[T any](L *glua.LState) func(*glua.LState, []T) {
|
||||
metatable := luar.MT(L, []T(nil)).LTable
|
||||
return func(L *glua.LState, values []T) {
|
||||
if values == nil {
|
||||
L.Push(glua.LNil)
|
||||
return
|
||||
}
|
||||
userdata := L.NewUserData()
|
||||
userdata.Value = values
|
||||
userdata.Metatable = metatable
|
||||
L.Push(userdata)
|
||||
}
|
||||
}
|
||||
|
||||
// NewReadOnlySlicePusher borrows slices without copying and recursively makes
|
||||
// nested slice types read-only through their shared metatables. IP byte views
|
||||
// and masks are also read-only. Other referenced elements retain their mutability.
|
||||
// Element bindings are initialized on first indexing or iteration.
|
||||
func NewReadOnlySlicePusher[T any](L *glua.LState) func(*glua.LState, []T) {
|
||||
sliceMetatable := luar.MT(L, []T{})
|
||||
if sliceMetatable.RawGetString("__readonly") != glua.LTrue {
|
||||
readOnly := L.NewFunction(func(L *glua.LState) int {
|
||||
L.RaiseError("slice is read-only")
|
||||
return 0
|
||||
})
|
||||
setSliceReadOnly[[]T](L, readOnly)
|
||||
}
|
||||
return NewSlicePusher[T](L)
|
||||
}
|
||||
|
||||
func setSliceReadOnly[S ~[]E, E any](L *glua.LState, readOnly *glua.LFunction) {
|
||||
var visit func(reflect.Type)
|
||||
visit = func(typ reflect.Type) {
|
||||
if typ.Kind() != reflect.Slice {
|
||||
return
|
||||
}
|
||||
metatable := luar.MT(L, reflect.Zero(typ).Interface())
|
||||
if metatable.RawGetString("__readonly") == glua.LTrue {
|
||||
return
|
||||
}
|
||||
// Mark before descending because named slice types can be recursive.
|
||||
metatable.RawSetString("__readonly", glua.LTrue)
|
||||
metatable.RawSetString("__newindex", readOnly)
|
||||
// Reject explicitly so Lua cannot fall back to the other operand.
|
||||
metatable.RawSetString("__add", readOnly)
|
||||
if typ == reflect.TypeFor[net.IP]() {
|
||||
// A nil receiver lets AppendText return its argument as a writable []byte.
|
||||
methods := metatable.RawGetString("methods").(*glua.LTable)
|
||||
appendText := methods.RawGetString("AppendText")
|
||||
for key, method := methods.Next(glua.LNil); key != glua.LNil; key, method = methods.Next(key) {
|
||||
if method == appendText {
|
||||
methods.RawSet(key, glua.LNil)
|
||||
}
|
||||
}
|
||||
visit(reflect.TypeFor[net.IPMask]())
|
||||
}
|
||||
if element := typ.Elem(); element.Kind() == reflect.Slice {
|
||||
index := metatable.RawGetString("__index").(*glua.LFunction)
|
||||
call := metatable.RawGetString("__call").(*glua.LFunction)
|
||||
initialize := func() {
|
||||
visit(element)
|
||||
metatable.RawSetString("__index", index)
|
||||
metatable.RawSetString("__call", call)
|
||||
}
|
||||
metatable.RawSetString("__index", L.NewFunction(func(L *glua.LState) int {
|
||||
initialize()
|
||||
return index.GFunction(L)
|
||||
}))
|
||||
metatable.RawSetString("__call", L.NewFunction(func(L *glua.LState) int {
|
||||
initialize()
|
||||
return call.GFunction(L)
|
||||
}))
|
||||
}
|
||||
}
|
||||
visit(reflect.TypeFor[S]())
|
||||
}
|
||||
|
||||
// DirectMethod handles a Lua call without luar's reflected method invocation.
|
||||
// It returns the result count and whether it handled the arguments. On false,
|
||||
// it must leave the stack unchanged for the original luar wrapper.
|
||||
type DirectMethod func(L *glua.LState) (nresults int, handled bool)
|
||||
|
||||
// PushMethods pushes a luar userdata exposing only the named Go methods and
|
||||
// their aliases through reflected calls. The method list is fixed by the first
|
||||
// binding of that Go type in each LState.
|
||||
func PushMethods(L *glua.LState, value any, methods ...string) {
|
||||
PushWithDirectMethods(L, value, nil, methods...)
|
||||
}
|
||||
|
||||
// PushWithDirectMethods pushes a luar userdata with typed Go method bindings.
|
||||
// Handled calls bypass luar's argument conversion and reflect.Call; method lookup
|
||||
// uses the methods table directly instead of luar's reflected __index handler.
|
||||
// Only directMethods and additionalMethods are exposed, including their aliases.
|
||||
// Bindings and their closures are installed once per Go type per LState, outside
|
||||
// the method-call hot path.
|
||||
func PushWithDirectMethods(L *glua.LState, value any, directMethods map[string]DirectMethod, additionalMethods ...string) {
|
||||
userdata := luar.New(L, value).(*glua.LUserData)
|
||||
metatable := userdata.Metatable.(*glua.LTable)
|
||||
methods := metatable.RawGetString("methods").(*glua.LTable)
|
||||
if metatable.RawGetString("__index") != methods {
|
||||
// Keep all aliases for the explicitly exposed methods.
|
||||
exposed := make(map[glua.LValue]bool, len(directMethods)+len(additionalMethods))
|
||||
for name := range directMethods {
|
||||
exposed[methods.RawGetString(name)] = true
|
||||
}
|
||||
for _, name := range additionalMethods {
|
||||
exposed[methods.RawGetString(name)] = true
|
||||
}
|
||||
for key, method := methods.Next(glua.LNil); key != glua.LNil; key, method = methods.Next(key) {
|
||||
if !exposed[method] {
|
||||
methods.RawSet(key, glua.LNil)
|
||||
}
|
||||
}
|
||||
for name, direct := range directMethods {
|
||||
original := methods.RawGetString(name)
|
||||
fn := L.NewFunction(func(L *glua.LState) int {
|
||||
if nresults, handled := direct(L); handled {
|
||||
return nresults
|
||||
}
|
||||
return callLuarMethod(L, original)
|
||||
})
|
||||
// Keep luar's method aliases on the same direct binding.
|
||||
for key, method := methods.Next(glua.LNil); key != glua.LNil; key, method = methods.Next(key) {
|
||||
if method == original {
|
||||
methods.RawSet(key, fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pointer operations would bypass the method-only API.
|
||||
metatable.RawSetString("__newindex", glua.LNil)
|
||||
metatable.RawSetString("__pow", glua.LNil)
|
||||
metatable.RawSetString("__unm", glua.LNil)
|
||||
metatable.RawSetString("__index", methods)
|
||||
}
|
||||
L.Push(userdata)
|
||||
}
|
||||
|
||||
func callLuarMethod(L *glua.LState, method glua.LValue) int {
|
||||
nargs := L.GetTop()
|
||||
L.Insert(method, 1)
|
||||
L.Call(nargs, glua.MultRet)
|
||||
return L.GetTop()
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
glua "github.com/yuin/gopher-lua"
|
||||
luar "layeh.com/gopher-luar"
|
||||
)
|
||||
|
||||
func TestSlicePusher(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
push := NewSlicePusher[int](L)
|
||||
values := []int{3, 5}
|
||||
L.SetGlobal("getValues", L.NewFunction(func(L *glua.LState) int {
|
||||
push(L, values)
|
||||
return 1
|
||||
}))
|
||||
if err := L.DoString(`
|
||||
local values = getValues()
|
||||
assert(#values == 2 and values[1] == 3 and values[2] == 5)
|
||||
values[2] = 7
|
||||
local co = coroutine.create(function()
|
||||
local values = getValues()
|
||||
assert(#values == 2 and values[1] == 3 and values[2] == 7)
|
||||
return true
|
||||
end)
|
||||
local ok, result = coroutine.resume(co)
|
||||
assert(ok and result == true)
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values[1] != 7 {
|
||||
t.Fatal("slice storage was copied")
|
||||
}
|
||||
push(L, nil)
|
||||
if L.Get(-1) != glua.LNil {
|
||||
t.Fatal("nil slice must push Lua nil")
|
||||
}
|
||||
L.Pop(1)
|
||||
push(L, []int{})
|
||||
L.SetGlobal("empty", L.Get(-1))
|
||||
L.Pop(1)
|
||||
if err := L.DoString(`assert(type(empty) == "userdata" and #empty == 0)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlySlicePusher(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
values := []int{3, 5}
|
||||
NewReadOnlySlicePusher[int](L)(L, values)
|
||||
L.SetGlobal("values", L.Get(-1))
|
||||
L.Pop(1)
|
||||
if err := L.DoString(`
|
||||
assert(#values == 2 and values[1] == 3 and values[2] == 5)
|
||||
assert(not pcall(function() values[1] = 7 end))
|
||||
assert(not pcall(function() return values + 7 end))
|
||||
local sum = 0
|
||||
for _, value in values() do sum = sum + value end
|
||||
assert(sum == 8)
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := L.GetGlobal("values").(*glua.LUserData).Value.([]int)
|
||||
if &got[0] != &values[0] || values[0] != 3 || values[1] != 5 {
|
||||
t.Fatal("read-only binding copied or modified the slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyIPSlicePusher(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
ips := []net.IP{{192, 0, 2, 1}, net.ParseIP("2001:db8::1")}
|
||||
NewReadOnlySlicePusher[net.IP](L)(L, ips)
|
||||
L.SetGlobal("ips", L.Get(-1))
|
||||
L.Pop(1)
|
||||
readOnly := luar.MT(L, ips).RawGetString("__newindex")
|
||||
NewReadOnlySlicePusher[net.IP](L)
|
||||
if luar.MT(L, ips).RawGetString("__newindex") != readOnly {
|
||||
t.Fatal("IP metatables were initialized more than once")
|
||||
}
|
||||
if err := L.DoString(`
|
||||
local function readOnly(value)
|
||||
assert(not pcall(function() value[1] = value[1] end))
|
||||
assert(not pcall(function() return value + value[1] end))
|
||||
end
|
||||
assert(#ips == 2 and ips[1]:String() == "192.0.2.1")
|
||||
readOnly(ips)
|
||||
for _, ip in ips() do
|
||||
readOnly(ip)
|
||||
readOnly(ip:To16())
|
||||
if ip:To4() then
|
||||
readOnly(ip:To4())
|
||||
readOnly(ip:DefaultMask())
|
||||
end
|
||||
assert(ip:Equal(ip:To16()) and type(ip:String()) == "string")
|
||||
assert(ip.AppendText == nil and ip.appendText == nil)
|
||||
local text, err = ip:MarshalText()
|
||||
assert(not err)
|
||||
text[1] = 88 -- Independently owned byte buffers remain writable.
|
||||
assert(not pcall(function() return ips + text end))
|
||||
end
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := L.GetGlobal("ips").(*glua.LUserData).Value.([]net.IP)
|
||||
if &got[0] != &ips[0] || &got[0][0] != &ips[0][0] || &got[1][0] != &ips[1][0] {
|
||||
t.Fatal("read-only binding copied the IP data")
|
||||
}
|
||||
for i, want := range []string{"192.0.2.1", "2001:db8::1"} {
|
||||
if got[i].String() != want {
|
||||
t.Fatalf("IP %d changed to %s; want %s", i, got[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyIPSlicePusherLazy(t *testing.T) {
|
||||
for _, tc := range []struct{ name, access string }{
|
||||
{"index", `local ip = ips[1]`},
|
||||
{"iterate", `local _, ip = ips()()`},
|
||||
{"coroutine", `local co = coroutine.create(function() return ips[1] end); local ok, ip = coroutine.resume(co); assert(ok)`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
methods := 0
|
||||
luar.GetConfig(L).MethodNames = func(_ reflect.Type, method reflect.Method) []string {
|
||||
methods++
|
||||
return []string{method.Name}
|
||||
}
|
||||
NewReadOnlySlicePusher[net.IP](L)(L, []net.IP{{192, 0, 2, 1}})
|
||||
L.SetGlobal("ips", L.Get(-1))
|
||||
L.Pop(1)
|
||||
if err := L.DoString(`assert(#ips == 1); assert(not pcall(function() ips[1] = nil end))`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if methods != 0 {
|
||||
t.Fatal("IP methods initialized without reading an element")
|
||||
}
|
||||
source := tc.access + `
|
||||
assert(ip:String() == "192.0.2.1" and ip.AppendText == nil)
|
||||
assert(not pcall(function() ip[1] = 0 end))
|
||||
assert(not pcall(function() ip:To4()[1] = 0 end))
|
||||
assert(not pcall(function() ip:To16()[1] = 0 end))
|
||||
assert(not pcall(function() ip:DefaultMask()[1] = 0 end))
|
||||
`
|
||||
if err := L.DoString(source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initialized := methods
|
||||
if initialized == 0 {
|
||||
t.Fatal("IP access did not initialize its methods")
|
||||
}
|
||||
if err := L.DoString(source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if methods != initialized {
|
||||
t.Fatal("IP methods initialized more than once")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyNestedSlicePusher(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
values := [][][]byte{{{3, 5}}}
|
||||
NewReadOnlySlicePusher[[][]byte](L)(L, values)
|
||||
L.SetGlobal("values", L.Get(-1))
|
||||
L.Pop(1)
|
||||
ips := [][]net.IP{{{192, 0, 2, 1}}}
|
||||
NewReadOnlySlicePusher[[]net.IP](L)(L, ips)
|
||||
L.SetGlobal("ips", L.Get(-1))
|
||||
L.Pop(1)
|
||||
if err := L.DoString(`
|
||||
local function readOnly(value)
|
||||
assert(not pcall(function() value[1] = value[1] end))
|
||||
assert(not pcall(function() return value + value[1] end))
|
||||
end
|
||||
assert(values[1][1][1] == 3 and values[1][1][2] == 5)
|
||||
readOnly(values)
|
||||
for _, group in values() do
|
||||
readOnly(group)
|
||||
for _, bytes in group() do readOnly(bytes) end
|
||||
end
|
||||
readOnly(ips)
|
||||
readOnly(ips[1])
|
||||
local ip = ips[1][1]
|
||||
assert(ip:String() == "192.0.2.1")
|
||||
readOnly(ip)
|
||||
readOnly(ip:To4())
|
||||
readOnly(ip:To16())
|
||||
readOnly(ip:DefaultMask())
|
||||
assert(ip.AppendText == nil and ip.appendText == nil)
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
readOnly := luar.MT(L, ips[0]).RawGetString("__newindex")
|
||||
NewReadOnlySlicePusher[net.IP](L)
|
||||
if luar.MT(L, ips[0]).RawGetString("__newindex") != readOnly {
|
||||
t.Fatal("nested slice metatable was initialized again")
|
||||
}
|
||||
got := L.GetGlobal("values").(*glua.LUserData).Value.([][][]byte)
|
||||
if &got[0][0][0] != &values[0][0][0] || values[0][0][0] != 3 || values[0][0][1] != 5 {
|
||||
t.Fatal("read-only binding copied or modified the nested slice")
|
||||
}
|
||||
}
|
||||
|
||||
type recursiveReadOnlySlice []recursiveReadOnlySlice
|
||||
|
||||
func TestReadOnlyRecursiveSlicePusher(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
values := make(recursiveReadOnlySlice, 1)
|
||||
values[0] = values
|
||||
NewReadOnlySlicePusher[recursiveReadOnlySlice](L)(L, values)
|
||||
L.SetGlobal("values", L.Get(-1))
|
||||
L.Pop(1)
|
||||
if err := L.DoString(`
|
||||
local nested = values[1][1][1]
|
||||
assert(#nested == 1)
|
||||
assert(not pcall(function() nested[1] = nested end))
|
||||
assert(not pcall(function() return nested + nested end))
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlicePusherMetatablePerState(t *testing.T) {
|
||||
first := glua.NewState()
|
||||
defer first.Close()
|
||||
second := glua.NewState()
|
||||
defer second.Close()
|
||||
NewSlicePusher[int](first)(first, []int{1})
|
||||
NewSlicePusher[int](second)(second, []int{1})
|
||||
if first.Get(-1).(*glua.LUserData).Metatable == second.Get(-1).(*glua.LUserData).Metatable {
|
||||
t.Fatal("independent states share a slice metatable")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSlicePusher(b *testing.B) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
ips := []net.IP{net.ParseIP("127.0.0.1")}
|
||||
pushIPs := NewSlicePusher[net.IP](L)
|
||||
for _, benchmark := range []struct {
|
||||
name string
|
||||
push func(*glua.LState, []net.IP)
|
||||
}{
|
||||
{"bare", func(L *glua.LState, ips []net.IP) { PushUserData(L, ips) }},
|
||||
{"luar", func(L *glua.LState, ips []net.IP) { L.Push(luar.New(L, ips)) }},
|
||||
{"cached", pushIPs},
|
||||
} {
|
||||
b.Run(benchmark.name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
benchmark.push(L, ips)
|
||||
L.Pop(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
glua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
const maxIdleStates = 16
|
||||
|
||||
// Pool lends each state to one caller at a time. It grows on contention and
|
||||
// keeps up to maxIdleStates idle states until Close. Acquire/Release callers
|
||||
// decide reusability; WithState uses its callback's error.
|
||||
type Pool struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
timeout time.Duration
|
||||
|
||||
factory LStateFactory
|
||||
idle []*glua.LState
|
||||
top int
|
||||
|
||||
mu sync.Mutex
|
||||
active sync.WaitGroup
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewPool tests the factory by creating one state during initialization.
|
||||
func NewPool(ctx context.Context, timeout time.Duration, factory LStateFactory) (*Pool, error) {
|
||||
if timeout <= 0 {
|
||||
return nil, errors.New("Lua pool timeout must be positive")
|
||||
}
|
||||
|
||||
poolCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
state, err := factory(poolCtx)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Pool{ctx: poolCtx, cancel: cancel, timeout: timeout, factory: factory, idle: []*glua.LState{state}, top: state.GetTop()}, nil
|
||||
}
|
||||
|
||||
// Acquire returns an initialized exclusive state, growing the pool if necessary.
|
||||
// ctx is passed to the factory for state creation; nil uses the pool context.
|
||||
func (p *Pool) Acquire(ctx context.Context) (*glua.LState, error) {
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return nil, errors.New("Lua pool is closed")
|
||||
}
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
p.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = p.ctx
|
||||
} else if err := ctx.Err(); err != nil {
|
||||
p.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.active.Add(1)
|
||||
|
||||
n := len(p.idle)
|
||||
if n != 0 {
|
||||
state := p.idle[n-1]
|
||||
p.idle[n-1] = nil
|
||||
p.idle = p.idle[:n-1]
|
||||
p.mu.Unlock()
|
||||
return state, nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
// TODO: Limit the total number of states. When the limit is reached, wait
|
||||
// for a Release instead of creating another state; allow the wait to be
|
||||
// cancelled by the caller or by Close.
|
||||
state, err := p.factory(ctx)
|
||||
if err != nil {
|
||||
p.active.Done()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// WithState runs work on an exclusive state and releases it afterward.
|
||||
// Nil ctx and zero timeout use pool defaults. The timeout starts after acquisition.
|
||||
func (p *Pool) WithState(ctx context.Context, timeout time.Duration, work func(*glua.LState) error) error {
|
||||
state, err := p.Acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = p.ctx
|
||||
}
|
||||
if timeout == 0 {
|
||||
timeout = p.timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
state.SetContext(ctx)
|
||||
reusable := false
|
||||
defer func() {
|
||||
cancel()
|
||||
p.Release(state, reusable)
|
||||
}()
|
||||
err = work(state)
|
||||
reusable = err == nil
|
||||
return err
|
||||
}
|
||||
|
||||
// Release resets a state for reuse or closes it.
|
||||
func (p *Pool) Release(state *glua.LState, reusable bool) {
|
||||
if reusable {
|
||||
state.RemoveContext()
|
||||
state.SetTop(p.top)
|
||||
p.mu.Lock()
|
||||
if !p.closed && p.ctx.Err() == nil && len(p.idle) < maxIdleStates {
|
||||
p.idle = append(p.idle, state)
|
||||
} else {
|
||||
reusable = false
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
if !reusable {
|
||||
state.Close()
|
||||
}
|
||||
|
||||
p.active.Done()
|
||||
}
|
||||
|
||||
// Close cancels the pool context, closes idle states, and waits for borrowed states.
|
||||
func (p *Pool) Close() {
|
||||
p.mu.Lock()
|
||||
if !p.closed {
|
||||
p.closed = true
|
||||
p.cancel()
|
||||
for _, state := range p.idle {
|
||||
state.Close()
|
||||
}
|
||||
p.idle = nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
p.active.Wait()
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
glua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func newTestPool(t testing.TB, ctx context.Context, timeout time.Duration, factory LStateFactory) *Pool {
|
||||
t.Helper()
|
||||
pool, err := NewPool(ctx, timeout, factory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return pool
|
||||
}
|
||||
|
||||
func assertPoolCloseBlocked(t *testing.T, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("Close returned while work was still active")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolTimeoutValidation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
timeout time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{"zero", 0, true},
|
||||
{"negative", -time.Nanosecond, true},
|
||||
{"positive", time.Nanosecond, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
called := false
|
||||
pool, err := NewPool(context.Background(), tc.timeout, func(context.Context) (*glua.LState, error) {
|
||||
called = true
|
||||
return glua.NewState(), nil
|
||||
})
|
||||
if pool != nil {
|
||||
t.Cleanup(pool.Close)
|
||||
}
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("NewPool error = %v, want error %t", err, tc.wantErr)
|
||||
}
|
||||
if tc.wantErr && (pool != nil || called) {
|
||||
t.Fatal("invalid timeout created a pool or called the factory")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolFactoryFailure(t *testing.T) {
|
||||
failure := errors.New("factory failed")
|
||||
_, err := NewPool(context.Background(), time.Second, func(context.Context) (*glua.LState, error) {
|
||||
return nil, failure
|
||||
})
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("NewPool error = %v, want original factory error", err)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
pool := newTestPool(t, context.Background(), time.Second, func(context.Context) (*glua.LState, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return glua.NewState(), nil
|
||||
}
|
||||
return nil, failure
|
||||
})
|
||||
state, err := pool.Acquire(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Release(state, true)
|
||||
err = pool.WithState(nil, 0, func(*glua.LState) error {
|
||||
t.Error("work ran after factory failure")
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("WithState error = %v, want original factory error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolReusesStatesAndLimitsIdle(t *testing.T) {
|
||||
created := 0
|
||||
pool := newTestPool(t, context.Background(), time.Second, func(context.Context) (*glua.LState, error) {
|
||||
created++
|
||||
return glua.NewState(), nil
|
||||
})
|
||||
var borrowed []*glua.LState
|
||||
defer func() {
|
||||
for _, state := range borrowed {
|
||||
pool.Release(state, false)
|
||||
}
|
||||
}()
|
||||
for range maxIdleStates + 3 {
|
||||
state, err := pool.Acquire(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
borrowed = append(borrowed, state)
|
||||
state.SetContext(context.Background())
|
||||
}
|
||||
states := borrowed
|
||||
for _, state := range states {
|
||||
pool.Release(state, true)
|
||||
}
|
||||
borrowed = nil
|
||||
open := 0
|
||||
for _, state := range states {
|
||||
if !state.IsClosed() {
|
||||
if state.Context() != nil {
|
||||
t.Fatal("Release left a context on a reusable state")
|
||||
}
|
||||
open++
|
||||
}
|
||||
}
|
||||
if open != maxIdleStates {
|
||||
t.Fatalf("retained %d states, want %d", open, maxIdleStates)
|
||||
}
|
||||
if err := pool.WithState(nil, 0, func(*glua.LState) error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created != len(states) {
|
||||
t.Fatalf("created %d states, want %d", created, len(states))
|
||||
}
|
||||
pool.Close()
|
||||
for _, state := range states {
|
||||
if !state.IsClosed() {
|
||||
t.Fatal("Close left an idle state open")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolWithStateOptions(t *testing.T) {
|
||||
key := struct{}{}
|
||||
parent := context.WithValue(context.Background(), key, "pool")
|
||||
caller := context.WithValue(context.Background(), key, "caller")
|
||||
pool := newTestPool(t, parent, time.Second, func(context.Context) (*glua.LState, error) {
|
||||
return glua.NewState(), nil
|
||||
})
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
timeout time.Duration
|
||||
wantValue string
|
||||
wantTimeout time.Duration
|
||||
}{
|
||||
{"defaults", nil, 0, "pool", time.Second},
|
||||
{"context", caller, 0, "caller", time.Second},
|
||||
{"timeout", nil, 2 * time.Second, "pool", 2 * time.Second},
|
||||
{"both", caller, 2 * time.Second, "caller", 2 * time.Second},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
started := time.Now()
|
||||
err := pool.WithState(tc.ctx, tc.timeout, func(L *glua.LState) error {
|
||||
ctx := L.Context()
|
||||
if ctx.Value(key) != tc.wantValue {
|
||||
t.Errorf("context value = %v, want %q", ctx.Value(key), tc.wantValue)
|
||||
}
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok || deadline.Before(started.Add(tc.wantTimeout)) || deadline.After(time.Now().Add(tc.wantTimeout)) {
|
||||
t.Errorf("deadline = %v, want timeout %v", deadline, tc.wantTimeout)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolFactoryContext(t *testing.T) {
|
||||
caller, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
}{
|
||||
{"default", nil},
|
||||
{"caller", caller},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var contexts []context.Context
|
||||
pool := newTestPool(t, context.Background(), time.Second, func(ctx context.Context) (*glua.LState, error) {
|
||||
contexts = append(contexts, ctx)
|
||||
return glua.NewState(), nil
|
||||
})
|
||||
state, err := pool.Acquire(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Release(state, true)
|
||||
if err := pool.WithState(tc.ctx, 2*time.Second, func(*glua.LState) error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := tc.ctx
|
||||
if want == nil {
|
||||
want = pool.ctx
|
||||
}
|
||||
if len(contexts) != 2 || contexts[0] != pool.ctx || contexts[1] != want {
|
||||
t.Fatal("factory did not receive the initialization and acquisition contexts unchanged")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolWithStateLifecycle(t *testing.T) {
|
||||
failure := errors.New("work failed")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
work func(*glua.LState, context.CancelFunc) error
|
||||
reusable bool
|
||||
wantPanic bool
|
||||
wantErr error
|
||||
}{
|
||||
{"success", func(*glua.LState, context.CancelFunc) error { return nil }, true, false, nil},
|
||||
{"canceled success", func(_ *glua.LState, cancel context.CancelFunc) error {
|
||||
cancel()
|
||||
return nil
|
||||
}, true, false, nil},
|
||||
{"error", func(*glua.LState, context.CancelFunc) error { return failure }, false, false, failure},
|
||||
{"timeout", func(L *glua.LState, _ context.CancelFunc) error { return L.DoString("while true do end") }, false, false, nil},
|
||||
{"panic", func(*glua.LState, context.CancelFunc) error { panic(failure) }, false, true, nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pool := newTestPool(t, context.Background(), 10*time.Millisecond, func(context.Context) (*glua.LState, error) {
|
||||
state := glua.NewState()
|
||||
state.Push(glua.LTrue)
|
||||
return state, nil
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var state *glua.LState
|
||||
var workCtx context.Context
|
||||
var recovered any
|
||||
err := func() (err error) {
|
||||
defer func() { recovered = recover() }()
|
||||
return pool.WithState(ctx, 0, func(L *glua.LState) error {
|
||||
state, workCtx = L, L.Context()
|
||||
L.Push(glua.LFalse)
|
||||
return tc.work(L, cancel)
|
||||
})
|
||||
}()
|
||||
if tc.wantPanic {
|
||||
if recovered != failure {
|
||||
t.Fatalf("panic = %v, want original panic", recovered)
|
||||
}
|
||||
} else {
|
||||
if recovered != nil || (err == nil) != tc.reusable {
|
||||
t.Fatalf("WithState error = %v, panic = %v", err, recovered)
|
||||
}
|
||||
if tc.wantErr != nil && !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("WithState error = %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
}
|
||||
if workCtx.Err() == nil {
|
||||
t.Fatal("WithState did not cancel the execution context")
|
||||
}
|
||||
if closed := state.IsClosed(); closed == tc.reusable {
|
||||
t.Fatalf("state closed = %t, want %t", closed, !tc.reusable)
|
||||
}
|
||||
if tc.reusable && (state.Context() != nil || state.GetTop() != 1 || state.Get(1) != glua.LTrue) {
|
||||
t.Fatal("WithState did not reset the state for reuse")
|
||||
}
|
||||
if err := pool.WithState(nil, 0, func(L *glua.LState) error {
|
||||
if (L == state) != tc.reusable {
|
||||
t.Error("unexpected state reuse")
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolClose(t *testing.T) {
|
||||
pool := newTestPool(t, context.Background(), time.Second, func(context.Context) (*glua.LState, error) {
|
||||
return glua.NewState(), nil
|
||||
})
|
||||
finishCtx, finish := context.WithCancel(context.Background())
|
||||
t.Cleanup(finish)
|
||||
started, done := make(chan *glua.LState, 1), make(chan error, 1)
|
||||
var workCtx context.Context
|
||||
go func() {
|
||||
done <- pool.WithState(nil, 0, func(L *glua.LState) error {
|
||||
workCtx = L.Context()
|
||||
started <- L
|
||||
<-finishCtx.Done()
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
var state *glua.LState
|
||||
select {
|
||||
case state = <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("WithState did not start")
|
||||
}
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
pool.Close()
|
||||
close(closed)
|
||||
}()
|
||||
select {
|
||||
case <-workCtx.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Close did not cancel work using the pool context")
|
||||
}
|
||||
if !errors.Is(workCtx.Err(), context.Canceled) {
|
||||
t.Fatalf("work context error = %v, want context.Canceled", workCtx.Err())
|
||||
}
|
||||
assertPoolCloseBlocked(t, closed)
|
||||
finish()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("successful work returned an error: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("WithState did not finish")
|
||||
}
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Close did not finish after WithState")
|
||||
}
|
||||
if !state.IsClosed() {
|
||||
t.Fatal("Release returned a state to a closed pool")
|
||||
}
|
||||
if state, err := pool.Acquire(nil); state != nil || err == nil || errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Acquire after Close = %v, %v; want closed pool error", state, err)
|
||||
}
|
||||
pool.Close()
|
||||
}
|
||||
|
||||
func TestPoolCloseWaitsForFactory(t *testing.T) {
|
||||
finishCtx, finish := context.WithCancel(context.Background())
|
||||
started, canceled := make(chan struct{}), make(chan struct{})
|
||||
first := true
|
||||
pool := newTestPool(t, context.Background(), time.Second, func(ctx context.Context) (*glua.LState, error) {
|
||||
if first {
|
||||
first = false
|
||||
return glua.NewState(), nil
|
||||
}
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
close(canceled)
|
||||
<-finishCtx.Done()
|
||||
return nil, ctx.Err()
|
||||
})
|
||||
t.Cleanup(finish)
|
||||
state, err := pool.Acquire(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool.Release(state, false)
|
||||
acquireDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := pool.Acquire(nil)
|
||||
acquireDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("state creation did not start")
|
||||
}
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
pool.Close()
|
||||
close(closed)
|
||||
}()
|
||||
select {
|
||||
case <-canceled:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Close did not cancel state creation")
|
||||
}
|
||||
assertPoolCloseBlocked(t, closed)
|
||||
finish()
|
||||
select {
|
||||
case err := <-acquireDone:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Acquire error = %v, want context.Canceled", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("state creation did not finish")
|
||||
}
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Close did not finish after state creation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolCloseWaitsForCallerContext(t *testing.T) {
|
||||
pool := newTestPool(t, context.Background(), time.Minute, func(context.Context) (*glua.LState, error) {
|
||||
return glua.NewState(), nil
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
started, done := make(chan context.Context, 1), make(chan error, 1)
|
||||
go func() {
|
||||
done <- pool.WithState(ctx, 0, func(L *glua.LState) error {
|
||||
started <- L.Context()
|
||||
<-L.Context().Done()
|
||||
return L.Context().Err()
|
||||
})
|
||||
}()
|
||||
var workCtx context.Context
|
||||
select {
|
||||
case workCtx = <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("WithState did not start")
|
||||
}
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
pool.Close()
|
||||
close(closed)
|
||||
}()
|
||||
select {
|
||||
case <-pool.ctx.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Close did not cancel the pool context")
|
||||
}
|
||||
assertPoolCloseBlocked(t, closed)
|
||||
if workCtx.Err() != nil || ctx.Err() != nil {
|
||||
t.Fatal("Close canceled the caller's execution context")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("WithState error = %v, want context.Canceled", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("WithState did not stop after caller cancellation")
|
||||
}
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Close did not finish after WithState")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPoolAcquireRelease(b *testing.B) {
|
||||
pool := newTestPool(b, context.Background(), time.Second, func(context.Context) (*glua.LState, error) {
|
||||
return glua.NewState(), nil
|
||||
})
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
state, err := pool.Acquire(nil)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
pool.Release(state, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"sync"
|
||||
|
||||
"github.com/xtls/xray-core/common/errors"
|
||||
)
|
||||
|
||||
var privilegedKey struct {
|
||||
sync.Once
|
||||
publicKey ed25519.PublicKey
|
||||
}
|
||||
|
||||
// SetPrivilegedPublicKey configures the trusted Ed25519 public key once at
|
||||
// startup, before compiling scripts. An empty value disables authorization.
|
||||
// Nonempty values must be unpadded Base64URL encodings of exactly 32 bytes.
|
||||
func SetPrivilegedPublicKey(value string) error {
|
||||
var publicKey ed25519.PublicKey
|
||||
if value != "" {
|
||||
decoded, err := decodePrivilegedValue(value, ed25519.PublicKeySize)
|
||||
if err != nil {
|
||||
return errors.New("invalid Lua privileged public key").Base(err)
|
||||
}
|
||||
publicKey = ed25519.PublicKey(decoded)
|
||||
}
|
||||
privilegedKey.Do(func() {
|
||||
privilegedKey.publicKey = publicKey
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodePrivilegedValue(value string, size int) ([]byte, error) {
|
||||
encoding := base64.RawURLEncoding.Strict()
|
||||
if len(value) != encoding.EncodedLen(size) {
|
||||
return nil, errors.New("expected an unpadded Base64URL encoding of ", size, " bytes")
|
||||
}
|
||||
decoded, err := encoding.DecodeString(value)
|
||||
if err != nil || len(decoded) != size || encoding.EncodeToString(decoded) != value {
|
||||
return nil, errors.New("expected an unpadded Base64URL encoding of ", size, " bytes")
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func verifyPrivilegedSignature(code []byte) (bool, error) {
|
||||
_, end, value, found := privilegedSignatureLine(code)
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
publicKey := privilegedKey.publicKey
|
||||
if len(publicKey) == 0 {
|
||||
return false, errors.New("Lua privileged signature requires --lua-privileged-pubkey")
|
||||
}
|
||||
signature, err := decodePrivilegedValue(string(bytes.TrimSpace(value)), ed25519.SignatureSize)
|
||||
if err != nil {
|
||||
return false, errors.New("invalid Lua privileged signature").Base(err)
|
||||
}
|
||||
digest := sha256.Sum256(bytes.TrimSpace(code[end:]))
|
||||
if !ed25519.Verify(publicKey, digest[:], signature) {
|
||||
return false, errors.New("Lua privileged signature verification failed")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SignPrivilegedScript signs the script body, then prepends or replaces the signature line.
|
||||
func SignPrivilegedScript(code []byte, seed string) ([]byte, error) {
|
||||
decoded, err := decodePrivilegedValue(seed, ed25519.SeedSize)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid Ed25519 private key seed").Base(err)
|
||||
}
|
||||
|
||||
start, end, _, found := privilegedSignatureLine(code)
|
||||
body := code
|
||||
if found {
|
||||
body = code[end:]
|
||||
}
|
||||
|
||||
digest := sha256.Sum256(bytes.TrimSpace(body))
|
||||
signature := ed25519.Sign(ed25519.NewKeyFromSeed(decoded), digest[:])
|
||||
header := []byte("-- @xray:privileged-signature=" + base64.RawURLEncoding.EncodeToString(signature))
|
||||
|
||||
if !found {
|
||||
if newline := bytes.IndexByte(code, '\n'); newline > 0 && code[newline-1] == '\r' {
|
||||
header = append(header, '\r')
|
||||
}
|
||||
header = append(header, '\n')
|
||||
return append(header, code...), nil
|
||||
}
|
||||
signed := make([]byte, 0, start+len(header)+len(code)-end)
|
||||
signed = append(signed, code[:start]...)
|
||||
signed = append(signed, header...)
|
||||
return append(signed, code[end:]...), nil
|
||||
}
|
||||
|
||||
func privilegedSignatureLine(code []byte) (start, end int, value []byte, found bool) {
|
||||
for rest := code; len(rest) != 0; {
|
||||
start = len(code) - len(rest)
|
||||
var line []byte
|
||||
line, rest, _ = bytes.Cut(rest, []byte{'\n'})
|
||||
line = bytes.TrimSuffix(line, []byte{'\r'})
|
||||
if len(bytes.TrimSpace(line)) == 0 {
|
||||
continue
|
||||
}
|
||||
end = start + len(line)
|
||||
line, found := bytes.CutPrefix(bytes.TrimLeft(line, " \t"), []byte("--"))
|
||||
if !found {
|
||||
return 0, 0, nil, false
|
||||
}
|
||||
value, found = bytes.CutPrefix(bytes.TrimLeft(line, " \t"), []byte("@xray:privileged-signature="))
|
||||
return start, end, value, found
|
||||
}
|
||||
return 0, 0, nil, false
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package lua
|
||||
|
||||
// TODO: Re-enable these tests and their helper when Compile supports privileged signature authorization again.
|
||||
/*
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func privilegedSeed(t *testing.T) string {
|
||||
t.Helper()
|
||||
seed, err := hex.DecodeString(privilegedTestSeed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(seed)
|
||||
}
|
||||
|
||||
func TestSignPrivilegedScriptPreservesSource(t *testing.T) {
|
||||
setTestPrivilegedPublicKey(t, privilegedTestPublicKey)
|
||||
seed := privilegedSeed(t)
|
||||
for _, tc := range []struct {
|
||||
name, prefix, header, body, newline string
|
||||
}{
|
||||
{name: "unsigned", body: privilegedFixtureBody, newline: "\n"},
|
||||
{name: "unsigned boundary whitespace", body: " \t\n\n" + privilegedFixtureBody + " \t\n", newline: "\n"},
|
||||
{name: "unsigned CRLF", body: strings.ReplaceAll(privilegedFixtureBody, "\n", "\r\n"), newline: "\r\n"},
|
||||
{name: "replace invalid header", header: privilegedSignatureHeader + "%", body: "\n" + privilegedFixtureBody, newline: "\n"},
|
||||
{name: "replace indented header", prefix: " \t\r\n\n", header: " \t--\t @xray:privileged-signature=% \t", body: "\r\n \t\r\n" + privilegedFixtureBody + "\r\n", newline: "\r\n"},
|
||||
{name: "replace valid header", header: privilegedSignatureHeader + privilegedFixtureSignature, body: "\n" + privilegedFixtureBody, newline: "\n"},
|
||||
{name: "empty script", newline: "\n"},
|
||||
{name: "header without newline", header: privilegedSignatureHeader + "%"},
|
||||
{name: "ordinary comment before marker", body: "-- ordinary comment\n" + privilegedSignatureHeader + "%\nvalue = 1", newline: "\n"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
code := []byte(tc.prefix + tc.header + tc.body)
|
||||
original := bytes.Clone(code)
|
||||
signed, err := SignPrivilegedScript(code, seed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(code, original) {
|
||||
t.Fatal("signing modified the input buffer")
|
||||
}
|
||||
body := tc.body
|
||||
if tc.header != "" {
|
||||
body = strings.TrimPrefix(body, tc.newline)
|
||||
}
|
||||
header := privilegedSignatureHeader + testSignature(t, body)
|
||||
want := header + tc.newline + tc.body
|
||||
if tc.header != "" {
|
||||
want = tc.prefix + header + tc.body
|
||||
}
|
||||
if string(signed) != want {
|
||||
t.Fatalf("signed source = %q, want %q", signed, want)
|
||||
}
|
||||
program, err := Compile(writeTestScript(t, string(signed)))
|
||||
if err != nil || program == nil || !program.privileged {
|
||||
t.Fatalf("signed source Compile = %v, %v; want privileged program", program, err)
|
||||
}
|
||||
again, err := SignPrivilegedScript(signed, seed)
|
||||
if err != nil || !bytes.Equal(again, signed) {
|
||||
t.Fatalf("resigning changed the source: %q, %v", again, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignPrivilegedScriptRejectsInvalidSeed(t *testing.T) {
|
||||
seed := privilegedSeed(t)
|
||||
for _, value := range []string{
|
||||
"", "%", seed + "=",
|
||||
base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.SeedSize-1)),
|
||||
base64.RawURLEncoding.EncodeToString(make([]byte, ed25519.PrivateKeySize)),
|
||||
} {
|
||||
if signed, err := SignPrivilegedScript([]byte(privilegedFixtureBody), value); signed != nil || err == nil {
|
||||
t.Fatalf("SignPrivilegedScript with invalid seed = %q, %v; want rejection", signed, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,260 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xtls/xray-core/common/base64"
|
||||
"github.com/xtls/xray-core/common/errors"
|
||||
"github.com/xtls/xray-core/common/platform/filesystem"
|
||||
glua "github.com/yuin/gopher-lua"
|
||||
"github.com/yuin/gopher-lua/parse"
|
||||
)
|
||||
|
||||
// Program holds immutable bytecode that can be run by independent LStates.
|
||||
type Program struct {
|
||||
proto *glua.FunctionProto
|
||||
privileged bool
|
||||
}
|
||||
|
||||
// LStateFactory returns a fully initialized state or nil and an error.
|
||||
// Implementations must close partial states on failure; callers own successful states.
|
||||
type LStateFactory func(context.Context) (*glua.LState, error)
|
||||
|
||||
// Compile reads a Lua file or decodes an inline script and compiles it into an immutable program.
|
||||
func Compile(script string) (*Program, error) {
|
||||
var code []byte
|
||||
var err error
|
||||
name := "[inline]"
|
||||
privileged := false
|
||||
if strings.HasPrefix(script, base64.GzipPrefix) || strings.HasPrefix(script, base64.Prefix) {
|
||||
// TODO: Restore inline script support and re-enable the related tests.
|
||||
return nil, errors.New("currently unavailable")
|
||||
code, err = base64.Decode(script)
|
||||
} else if name, err = filesystem.ResolveLuaFile(script); err == nil {
|
||||
code, err = os.ReadFile(name)
|
||||
// TODO: Restore privileged signature authorization and re-enable the related tests and CLI entries.
|
||||
// if err == nil {
|
||||
// privileged, err = verifyPrivilegedSignature(code)
|
||||
// }
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
chunk, err := parse.Parse(bytes.NewReader(code), name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proto, err := glua.Compile(chunk, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Program{proto: proto, privileged: privileged}, nil
|
||||
}
|
||||
|
||||
// NewState creates a state, runs register, executes the program under ctx, and
|
||||
// runs validate. It removes the initialization context before returning a state
|
||||
// owned by the caller.
|
||||
// Choose registrySize for the script type's expected stack usage, balancing
|
||||
// per-state memory usage against the number of states retained in a pool.
|
||||
func (p *Program) NewState(ctx context.Context, registrySize int, register func(*glua.LState), validate func(*glua.LState) error) (*glua.LState, error) {
|
||||
options := glua.Options{
|
||||
RegistrySize: registrySize,
|
||||
RegistryMaxSize: glua.RegistrySize,
|
||||
RegistryGrowStep: registrySize,
|
||||
}
|
||||
var L *glua.LState
|
||||
if p.privileged {
|
||||
L = glua.NewState(options)
|
||||
} else {
|
||||
L = newSandboxedState(options)
|
||||
}
|
||||
valid := false
|
||||
defer func() {
|
||||
if !valid {
|
||||
L.Close()
|
||||
}
|
||||
}()
|
||||
L.SetContext(ctx)
|
||||
defer L.RemoveContext()
|
||||
if register != nil {
|
||||
register(L)
|
||||
}
|
||||
if !p.privileged {
|
||||
// Hide package internals to prevent data snooping and sandbox escape.
|
||||
L.SetGlobal(glua.LoadLibName, glua.LNil)
|
||||
}
|
||||
L.Push(L.NewFunctionFromProto(p.proto))
|
||||
// Execute the Lua script's top level.
|
||||
if err := L.PCall(0, 0, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validate != nil {
|
||||
if err := validate(L); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
valid = true
|
||||
return L, nil
|
||||
}
|
||||
|
||||
func newSandboxedState(options glua.Options) *glua.LState {
|
||||
options.SkipOpenLibs = true
|
||||
L := glua.NewState(options)
|
||||
|
||||
for _, lib := range []struct {
|
||||
name string
|
||||
open glua.LGFunction
|
||||
}{
|
||||
{glua.LoadLibName, glua.OpenPackage},
|
||||
{glua.BaseLibName, glua.OpenBase},
|
||||
{glua.TabLibName, glua.OpenTable},
|
||||
{glua.StringLibName, glua.OpenString},
|
||||
{glua.MathLibName, glua.OpenMath},
|
||||
{glua.OsLibName, glua.OpenOs},
|
||||
} {
|
||||
L.Push(L.NewFunction(lib.open))
|
||||
L.Push(glua.LString(lib.name))
|
||||
L.Call(1, 0)
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
"dofile", "loadfile", "load", "loadstring", "getfenv", "setfenv",
|
||||
"getmetatable", "setmetatable", "rawget", "rawset",
|
||||
"collectgarbage", "module", "newproxy", "print", "_printregs",
|
||||
} {
|
||||
L.SetGlobal(name, glua.LNil)
|
||||
}
|
||||
|
||||
// Prevent printing memory addresses that reveal the memory layout.
|
||||
toString := func(value glua.LValue) glua.LString {
|
||||
result := "<opaque>"
|
||||
switch value.Type() {
|
||||
case glua.LTNil, glua.LTBool, glua.LTNumber, glua.LTString:
|
||||
result = value.String()
|
||||
case glua.LTUserData:
|
||||
switch native := value.(*glua.LUserData).Value.(type) {
|
||||
case error: // Errors can also implement fmt.Stringer; redact them first.
|
||||
result = redactMemoryAddresses(native.Error())
|
||||
case fmt.Stringer:
|
||||
result = native.String()
|
||||
}
|
||||
}
|
||||
return glua.LString(result)
|
||||
}
|
||||
L.SetGlobal("tostring", L.NewFunction(func(L *glua.LState) int {
|
||||
L.Push(toString(L.CheckAny(1)))
|
||||
return 1
|
||||
}))
|
||||
|
||||
// Prevent printing memory addresses that reveal the memory layout.
|
||||
stringLib := L.GetGlobal(glua.StringLibName).(*glua.LTable)
|
||||
stringLib.RawSetString("dump", glua.LNil)
|
||||
format := stringLib.RawGetString("format")
|
||||
stringLib.RawSetString("format", L.NewFunction(func(L *glua.LState) int {
|
||||
nargs := L.GetTop()
|
||||
for i := 2; i <= nargs; i++ {
|
||||
value := L.Get(i)
|
||||
switch value.Type() {
|
||||
case glua.LTBool, glua.LTNumber, glua.LTString:
|
||||
continue
|
||||
}
|
||||
L.Replace(i, toString(value))
|
||||
}
|
||||
L.Insert(format, 1)
|
||||
L.Call(nargs, 1)
|
||||
return 1
|
||||
}))
|
||||
|
||||
// Redact addresses before caught errors become readable by the script.
|
||||
redactError := func(value glua.LValue) glua.LValue {
|
||||
if message, ok := value.(glua.LString); ok {
|
||||
if redacted := redactMemoryAddresses(string(message)); redacted != string(message) {
|
||||
return glua.LString(redacted)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
for _, name := range []string{"pcall", "xpcall"} {
|
||||
call := L.GetGlobal(name)
|
||||
L.SetGlobal(name, L.NewFunction(func(L *glua.LState) int {
|
||||
nargs := L.GetTop()
|
||||
if name == "xpcall" {
|
||||
L.CheckFunction(1)
|
||||
handler := L.CheckFunction(2)
|
||||
L.Replace(2, L.NewFunction(func(L *glua.LState) int {
|
||||
L.Replace(1, redactError(L.Get(1)))
|
||||
L.Insert(handler, 1)
|
||||
L.Call(1, 1)
|
||||
return 1
|
||||
}))
|
||||
}
|
||||
L.Insert(call, 1)
|
||||
L.Call(nargs, glua.MultRet)
|
||||
if L.Get(1) == glua.LFalse {
|
||||
L.Replace(2, redactError(L.Get(2)))
|
||||
}
|
||||
return L.GetTop()
|
||||
}))
|
||||
}
|
||||
|
||||
// Prevent seed output from compromising cryptographic security.
|
||||
L.GetGlobal(glua.MathLibName).(*glua.LTable).RawSetString("randomseed", glua.LNil)
|
||||
|
||||
// Keep only time functions, including when os is accessed through require.
|
||||
osLib := L.GetGlobal(glua.OsLibName).(*glua.LTable)
|
||||
for key, _ := osLib.Next(glua.LNil); key != glua.LNil; key, _ = osLib.Next(key) {
|
||||
switch key {
|
||||
case glua.LString("clock"), glua.LString("date"), glua.LString("difftime"), glua.LString("time"):
|
||||
default:
|
||||
osLib.RawSet(key, glua.LNil)
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent module injection through the global package table.
|
||||
module := L.GetGlobal(glua.LoadLibName).(*glua.LTable)
|
||||
preload := module.RawGetString("preload").(*glua.LTable)
|
||||
loaders := L.CreateTable(1, 0)
|
||||
loaders.RawSetInt(1, L.NewFunction(func(L *glua.LState) int {
|
||||
name := L.CheckString(1)
|
||||
loader := preload.RawGetString(name)
|
||||
if loader == glua.LNil {
|
||||
loader = glua.LString("no preloaded module: " + name)
|
||||
}
|
||||
L.Push(loader)
|
||||
return 1
|
||||
}))
|
||||
module.RawSetString("loaders", loaders)
|
||||
L.SetField(L.Get(glua.RegistryIndex), "_LOADERS", loaders)
|
||||
for _, name := range []string{"loadlib", "seeall", "path", "cpath", "config"} {
|
||||
module.RawSetString(name, glua.LNil)
|
||||
}
|
||||
|
||||
return L
|
||||
}
|
||||
|
||||
var memoryAddressPattern = regexp.MustCompile(`0[xX][0-9a-fA-F]+`)
|
||||
|
||||
func redactMemoryAddresses(message string) string {
|
||||
if !strings.Contains(message, "0x") && !strings.Contains(message, "0X") {
|
||||
return message
|
||||
}
|
||||
return memoryAddressPattern.ReplaceAllString(message, "<address>")
|
||||
}
|
||||
|
||||
// NewStateFactory returns a factory that gives each state an initialization timeout.
|
||||
// Choose registrySize for the script type's expected stack usage, balancing
|
||||
// per-state memory usage against the number of states retained in a pool.
|
||||
func (p *Program) NewStateFactory(initTimeout time.Duration, registrySize int, register func(*glua.LState), validate func(*glua.LState) error) LStateFactory {
|
||||
return func(ctx context.Context) (*glua.LState, error) {
|
||||
initCtx, cancel := context.WithTimeout(ctx, initTimeout)
|
||||
defer cancel()
|
||||
return p.NewState(initCtx, registrySize, register, validate)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/xtls/xray-core/common/errors"
|
||||
glua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
type number interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64 |
|
||||
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
|
||||
~float32 | ~float64
|
||||
}
|
||||
|
||||
// PushNumber converts a Go number to a Lua number and pushes it.
|
||||
func PushNumber[T number](L *glua.LState, value T) {
|
||||
L.Push(glua.LNumber(value))
|
||||
}
|
||||
|
||||
// PushString converts a Go string to a Lua string and pushes it.
|
||||
func PushString(L *glua.LState, value string) {
|
||||
L.Push(glua.LString(value))
|
||||
}
|
||||
|
||||
// PushNil pushes Lua nil.
|
||||
func PushNil(L *glua.LState) {
|
||||
L.Push(glua.LNil)
|
||||
}
|
||||
|
||||
// PushUserData pushes a native Go value without copying it.
|
||||
func PushUserData(L *glua.LState, value any) {
|
||||
ud := L.NewUserData()
|
||||
ud.Value = value
|
||||
L.Push(ud)
|
||||
}
|
||||
|
||||
// PushError pushes nil or the original Go error as userdata.
|
||||
func PushError(L *glua.LState, err error) {
|
||||
if err == nil {
|
||||
L.Push(glua.LNil)
|
||||
return
|
||||
}
|
||||
PushUserData(L, err)
|
||||
}
|
||||
|
||||
// ReadUserData reads a native Go value of type T without copying it.
|
||||
// Other Lua values or userdata containing a different type return invalidMessage.
|
||||
func ReadUserData[T any](value glua.LValue, invalidMessage string) (T, error) {
|
||||
if ud, ok := value.(*glua.LUserData); ok {
|
||||
if result, ok := ud.Value.(T); ok {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
var zero T
|
||||
return zero, errors.New(invalidMessage)
|
||||
}
|
||||
|
||||
// ReadError accepts nil, a native Go error, or a Lua string.
|
||||
// Native errors retain their identity; other values return invalidMessage.
|
||||
func ReadError(value glua.LValue, invalidMessage string) error {
|
||||
if value == glua.LNil {
|
||||
return nil
|
||||
}
|
||||
if ud, ok := value.(*glua.LUserData); ok {
|
||||
if err, ok := ud.Value.(error); ok {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if message, ok := value.(glua.LString); ok {
|
||||
return errors.New(string(message))
|
||||
}
|
||||
return errors.New(invalidMessage)
|
||||
}
|
||||
|
||||
// ReadUint32 accepts only integral Lua numbers in the uint32 range.
|
||||
func ReadUint32(value glua.LValue, invalidMessage string) (uint32, error) {
|
||||
number, ok := value.(glua.LNumber)
|
||||
if !ok || number < 0 || number > math.MaxUint32 || math.Trunc(float64(number)) != float64(number) {
|
||||
return 0, errors.New(invalidMessage)
|
||||
}
|
||||
return uint32(number), nil
|
||||
}
|
||||
|
||||
// ReadOptionalString accepts a Lua string or nil, which becomes an empty string.
|
||||
// It does not coerce other values to strings.
|
||||
func ReadOptionalString(value glua.LValue, invalidMessage string) (string, error) {
|
||||
if value == glua.LNil {
|
||||
return "", nil
|
||||
}
|
||||
if result, ok := value.(glua.LString); ok {
|
||||
return string(result), nil
|
||||
}
|
||||
return "", errors.New(invalidMessage)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
glua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func TestReadUint32(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value glua.LValue
|
||||
want uint32
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "zero", value: glua.LNumber(0)},
|
||||
{name: "integer", value: glua.LNumber(45), want: 45},
|
||||
{name: "maximum", value: glua.LNumber(math.MaxUint32), want: math.MaxUint32},
|
||||
{name: "fraction", value: glua.LNumber(1.5), wantErr: true},
|
||||
{name: "negative", value: glua.LNumber(-1), wantErr: true},
|
||||
{name: "overflow", value: glua.LNumber(math.MaxUint32 + 1), wantErr: true},
|
||||
{name: "NaN", value: glua.LNumber(math.NaN()), wantErr: true},
|
||||
{name: "positive infinity", value: glua.LNumber(math.Inf(1)), wantErr: true},
|
||||
{name: "negative infinity", value: glua.LNumber(math.Inf(-1)), wantErr: true},
|
||||
{name: "nil", value: glua.LNil, wantErr: true},
|
||||
{name: "numeric string", value: glua.LString("45"), wantErr: true},
|
||||
{name: "boolean", value: glua.LTrue, wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := ReadUint32(tc.value, "invalid number")
|
||||
if got != tc.want || (err != nil) != tc.wantErr {
|
||||
t.Fatalf("ReadUint32() = %d, %v; want %d, error %t", got, err, tc.want, tc.wantErr)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "invalid number") {
|
||||
t.Fatalf("error = %v, want invalid number", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOptionalString(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value glua.LValue
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "nil", value: glua.LNil},
|
||||
{name: "empty", value: glua.LString("")},
|
||||
{name: "string", value: glua.LString("out"), want: "out"},
|
||||
{name: "number", value: glua.LNumber(1), wantErr: true},
|
||||
{name: "boolean", value: glua.LFalse, wantErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := ReadOptionalString(tc.value, "invalid string")
|
||||
if got != tc.want || (err != nil) != tc.wantErr {
|
||||
t.Fatalf("ReadOptionalString() = %q, %v; want %q, error %t", got, err, tc.want, tc.wantErr)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "invalid string") {
|
||||
t.Fatalf("error = %v, want invalid string", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDataRoundTrip(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
want := []int{1, 2}
|
||||
PushUserData(L, want)
|
||||
if L.GetTop() != 1 {
|
||||
t.Fatalf("stack top = %d, want 1", L.GetTop())
|
||||
}
|
||||
got, err := ReadUserData[[]int](L.Get(-1), "invalid userdata")
|
||||
if err != nil || len(got) != len(want) || &got[0] != &want[0] {
|
||||
t.Fatalf("userdata = %v, %v; want original slice", got, err)
|
||||
}
|
||||
PushUserData(L, []int(nil))
|
||||
if got, err := ReadUserData[[]int](L.Get(-1), "invalid userdata"); err != nil || got != nil {
|
||||
t.Fatalf("nil slice userdata = %v, %v", got, err)
|
||||
}
|
||||
for _, value := range []glua.LValue{glua.LNil, glua.LString("1"), L.NewTable(), L.Get(1)} {
|
||||
if got, err := ReadUserData[int](value, "invalid userdata"); got != 0 || err == nil || !strings.Contains(err.Error(), "invalid userdata") {
|
||||
t.Fatalf("ReadUserData(%v) = %d, %v; want invalid userdata", value, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorRoundTrip(t *testing.T) {
|
||||
L := glua.NewState()
|
||||
defer L.Close()
|
||||
want := errors.New("upstream failed")
|
||||
for _, err := range []error{nil, want} {
|
||||
PushError(L, err)
|
||||
if L.GetTop() != 1 {
|
||||
t.Fatalf("stack top = %d, want 1", L.GetTop())
|
||||
}
|
||||
if err == nil && L.Get(-1) != glua.LNil {
|
||||
t.Fatalf("nil error pushed as %v", L.Get(-1))
|
||||
}
|
||||
if got := ReadError(L.Get(-1), "invalid error"); got != err {
|
||||
t.Fatalf("ReadError() = %v, want original error %v", got, err)
|
||||
}
|
||||
L.Pop(1)
|
||||
}
|
||||
for _, message := range []string{"script failed", ""} {
|
||||
if err := ReadError(glua.LString(message), "invalid error"); err == nil || !strings.Contains(err.Error(), message) {
|
||||
t.Fatalf("string error = %v, want %q", err, message)
|
||||
}
|
||||
}
|
||||
wrong := L.NewUserData()
|
||||
wrong.Value = "not a native error"
|
||||
for _, value := range []glua.LValue{glua.LTrue, glua.LNumber(1), L.NewTable(), wrong, L.NewUserData()} {
|
||||
if err := ReadError(value, "invalid error"); err == nil || !strings.Contains(err.Error(), "invalid error") {
|
||||
t.Fatalf("ReadError(%v) = %v, want invalid error", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,12 +314,10 @@ func (m *ClientWorker) Dispatch(ctx context.Context, link *transport.Link) bool
|
||||
}
|
||||
|
||||
sm := m.sessionManager
|
||||
s := sm.Allocate(&m.strategy)
|
||||
s := sm.Allocate(&m.strategy, link.Reader, link.Writer)
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.input = link.Reader
|
||||
s.output = link.Writer
|
||||
go fetchInput(ctx, s, m.link.Writer)
|
||||
if _, ok := link.Reader.(*pipe.Reader); !ok {
|
||||
select {
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/xtls/xray-core/common"
|
||||
"github.com/xtls/xray-core/common/buf"
|
||||
"github.com/xtls/xray-core/common/errors"
|
||||
"github.com/xtls/xray-core/common/mux"
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
"github.com/xtls/xray-core/common/protocol"
|
||||
"github.com/xtls/xray-core/common/session"
|
||||
"github.com/xtls/xray-core/testing/mocks"
|
||||
"github.com/xtls/xray-core/transport"
|
||||
@@ -114,3 +116,48 @@ func TestClientWorkerClose(t *testing.T) {
|
||||
|
||||
common.Must(w2.Close())
|
||||
}
|
||||
|
||||
func TestClientWorkerUDPSource(t *testing.T) {
|
||||
downR, downW := pipe.New(pipe.WithoutSizeLimit())
|
||||
upR, upW := pipe.New(pipe.WithoutSizeLimit())
|
||||
worker, err := mux.NewClientWorker(transport.Link{Reader: downR, Writer: upW}, mux.ClientStrategy{})
|
||||
common.Must(err)
|
||||
|
||||
inR, inW := pipe.New(pipe.WithoutSizeLimit())
|
||||
outR, outW := pipe.New(pipe.WithoutSizeLimit())
|
||||
ctx := session.ContextWithOutbounds(context.Background(), []*session.Outbound{{
|
||||
Target: net.UDPDestination(net.ParseAddress("8.8.8.8"), 53),
|
||||
}})
|
||||
if !worker.Dispatch(ctx, &transport.Link{Reader: inR, Writer: outW}) {
|
||||
t.Fatal("failed to dispatch")
|
||||
}
|
||||
b := buf.New()
|
||||
b.WriteString("query")
|
||||
common.Must(inW.WriteMultiBuffer(buf.MultiBuffer{b}))
|
||||
mb, err := upR.ReadMultiBuffer() // New frame, the session is UDP from now on
|
||||
common.Must(err)
|
||||
buf.ReleaseMulti(mb)
|
||||
|
||||
srcs := []net.Destination{
|
||||
net.UDPDestination(net.ParseAddress("1.1.1.1"), 1111),
|
||||
net.UDPDestination(net.DomainAddress("example.com"), 2222),
|
||||
net.UDPDestination(net.ParseAddress("3.3.3.3"), 3333),
|
||||
}
|
||||
w := mux.NewResponseWriter(1, downW, protocol.TransferTypePacket)
|
||||
var got buf.MultiBuffer
|
||||
for i := range srcs {
|
||||
b := buf.New()
|
||||
b.WriteString("reply")
|
||||
b.UDP = &srcs[i]
|
||||
common.Must(w.WriteMultiBuffer(buf.MultiBuffer{b}))
|
||||
// keep earlier replies around while the next frame is parsed
|
||||
mb, err := outR.ReadMultiBuffer()
|
||||
common.Must(err)
|
||||
got = append(got, mb...)
|
||||
}
|
||||
for i, b := range got {
|
||||
if b.UDP == nil || *b.UDP != srcs[i] {
|
||||
t.Errorf("reply %d: source = %v, want %v", i, b.UDP, srcs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,16 @@ import (
|
||||
type PacketReader struct {
|
||||
reader io.Reader
|
||||
eof bool
|
||||
dest *net.Destination
|
||||
dest net.Destination
|
||||
}
|
||||
|
||||
// NewPacketReader creates a new PacketReader.
|
||||
// dest is copied because the caller reuses it for the next frame.
|
||||
func NewPacketReader(reader io.Reader, dest *net.Destination) *PacketReader {
|
||||
return &PacketReader{
|
||||
reader: reader,
|
||||
eof: false,
|
||||
dest: dest,
|
||||
dest: *dest,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +48,8 @@ func (r *PacketReader) ReadMultiBuffer() (buf.MultiBuffer, error) {
|
||||
return nil, err
|
||||
}
|
||||
r.eof = true
|
||||
if r.dest != nil && r.dest.Network == net.Network_UDP {
|
||||
b.UDP = r.dest
|
||||
if r.dest.Network == net.Network_UDP {
|
||||
b.UDP = &r.dest // only one packet is read, so b owns r.dest
|
||||
}
|
||||
return buf.MultiBuffer{b}, nil
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func (m *SessionManager) Count() int {
|
||||
return int(m.count)
|
||||
}
|
||||
|
||||
func (m *SessionManager) Allocate(Strategy *ClientStrategy) *Session {
|
||||
func (m *SessionManager) Allocate(Strategy *ClientStrategy, input buf.Reader, output buf.Writer) *Session {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
|
||||
@@ -64,6 +64,8 @@ func (m *SessionManager) Allocate(Strategy *ClientStrategy) *Session {
|
||||
|
||||
m.count++
|
||||
s := &Session{
|
||||
input: input,
|
||||
output: output,
|
||||
ID: m.count,
|
||||
parent: m,
|
||||
done: done.New(),
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
func TestSessionManagerAdd(t *testing.T) {
|
||||
m := NewSessionManager()
|
||||
|
||||
s := m.Allocate(&ClientStrategy{})
|
||||
s := m.Allocate(&ClientStrategy{}, nil, nil)
|
||||
if s.ID != 1 {
|
||||
t.Error("id: ", s.ID)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ func TestSessionManagerAdd(t *testing.T) {
|
||||
t.Error("size: ", m.Size())
|
||||
}
|
||||
|
||||
s = m.Allocate(&ClientStrategy{})
|
||||
s = m.Allocate(&ClientStrategy{}, nil, nil)
|
||||
if s.ID != 2 {
|
||||
t.Error("id: ", s.ID)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestSessionManagerAdd(t *testing.T) {
|
||||
|
||||
func TestSessionManagerClose(t *testing.T) {
|
||||
m := NewSessionManager()
|
||||
s := m.Allocate(&ClientStrategy{})
|
||||
s := m.Allocate(&ClientStrategy{}, nil, nil)
|
||||
|
||||
if m.CloseIfNoSessionAndIdle(m.Size(), m.Count()) {
|
||||
t.Error("able to close")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: common/net/address.proto
|
||||
|
||||
package net
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: common/net/destination.proto
|
||||
|
||||
package net
|
||||
|
||||
@@ -4,7 +4,7 @@ package net
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/hex"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
@@ -26,36 +26,38 @@ func FindProcess(network, srcIP string, srcPort uint16, destIP string, destPort
|
||||
panic("Unsupported network type for process lookup.")
|
||||
}
|
||||
|
||||
var procFile string
|
||||
var procFiles []string
|
||||
|
||||
switch network {
|
||||
case "tcp":
|
||||
if net.ParseIP(srcIP).To4() != nil {
|
||||
procFile = "/proc/net/tcp"
|
||||
procFiles = []string{"/proc/net/tcp"}
|
||||
} else {
|
||||
procFile = "/proc/net/tcp6"
|
||||
procFiles = []string{"/proc/net/tcp6"}
|
||||
}
|
||||
case "udp":
|
||||
if net.ParseIP(srcIP).To4() != nil {
|
||||
procFile = "/proc/net/udp"
|
||||
procFiles = []string{"/proc/net/udp", "/proc/net/udp6"}
|
||||
} else {
|
||||
procFile = "/proc/net/udp6"
|
||||
procFiles = []string{"/proc/net/udp6", "/proc/net/udp"}
|
||||
}
|
||||
default:
|
||||
panic("Unsupported network type for process lookup.")
|
||||
}
|
||||
|
||||
targetHexAddr, err := formatLittleEndianString(net.ParseIP(srcIP), Port(srcPort))
|
||||
if err != nil {
|
||||
return 0, "", "", errors.New("failed to format address: ", err)
|
||||
targetHexAddr := formatProcNetAddress(net.ParseIP(srcIP), Port(srcPort))
|
||||
|
||||
portFallback := ""
|
||||
if network == "udp" {
|
||||
portFallback = fmt.Sprintf(":%04X", srcPort)
|
||||
}
|
||||
|
||||
inode, err := findInodeInFile(procFile, targetHexAddr)
|
||||
inode, err := findInodeInFiles(procFiles, targetHexAddr, portFallback)
|
||||
if err != nil {
|
||||
return 0, "", "", errors.New("could not search in ", procFile).Base(err)
|
||||
return 0, "", "", errors.New("could not search in ", strings.Join(procFiles, " and ")).Base(err)
|
||||
}
|
||||
if inode == "" {
|
||||
return 0, "", "", errors.New("connection for ", srcIP, ":", srcPort, " not found in ", procFile)
|
||||
return 0, "", "", errors.New("connection for ", srcIP, ":", srcPort, " not found in ", strings.Join(procFiles, " or "))
|
||||
}
|
||||
|
||||
pidStr, err := findPidByInode(inode)
|
||||
@@ -82,51 +84,60 @@ func FindProcess(network, srcIP string, srcPort uint16, destIP string, destPort
|
||||
return pid, procName, absPath, nil
|
||||
}
|
||||
|
||||
func formatLittleEndianString(addr net.IP, port Port) (string, error) {
|
||||
ip := addr
|
||||
func formatProcNetAddress(addr net.IP, port Port) string {
|
||||
var ipBytes []byte
|
||||
if ip.To4() != nil {
|
||||
ipBytes = ip.To4()
|
||||
if ipv4 := addr.To4(); ipv4 != nil {
|
||||
ipBytes = ipv4
|
||||
} else {
|
||||
ipBytes = ip.To16()
|
||||
}
|
||||
if ipBytes == nil {
|
||||
return "", errors.New("invalid IP format for ", addr, ": ", ip)
|
||||
ipBytes = addr.To16()
|
||||
}
|
||||
|
||||
for i, j := 0, len(ipBytes)-1; i < j; i, j = i+1, j-1 {
|
||||
ipBytes[i], ipBytes[j] = ipBytes[j], ipBytes[i]
|
||||
var formatted strings.Builder
|
||||
formatted.Grow(len(ipBytes)*2 + 5)
|
||||
for i := 0; i < len(ipBytes); i += 4 {
|
||||
fmt.Fprintf(&formatted, "%08X", binary.NativeEndian.Uint32(ipBytes[i:i+4]))
|
||||
}
|
||||
portHex := fmt.Sprintf("%04X", uint16(port))
|
||||
ipHex := strings.ToUpper(hex.EncodeToString(ipBytes))
|
||||
return fmt.Sprintf("%s:%s", ipHex, portHex), nil
|
||||
fmt.Fprintf(&formatted, ":%04X", uint16(port))
|
||||
return formatted.String()
|
||||
}
|
||||
|
||||
func findInodeInFile(filePath, targetHexAddr string) (string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
func findInodeInFiles(filePaths []string, targetHexAddr, portFallback string) (string, error) {
|
||||
onlyMatchedPort := ""
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
for i, filePath := range filePaths {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
if i > 0 && os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
localAddress := fields[1]
|
||||
if localAddress == targetHexAddr {
|
||||
inode := fields[9]
|
||||
return inode, nil
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
|
||||
localAddress := fields[1]
|
||||
if localAddress == targetHexAddr {
|
||||
return fields[9], nil
|
||||
}
|
||||
if portFallback != "" && strings.HasSuffix(localAddress, portFallback) {
|
||||
wildcardIP := strings.TrimSuffix(localAddress, portFallback)
|
||||
if (len(wildcardIP) == 8 || len(wildcardIP) == 32) && strings.Trim(wildcardIP, "0") == "" {
|
||||
onlyMatchedPort = fields[9]
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", scanner.Err()
|
||||
return onlyMatchedPort, nil
|
||||
}
|
||||
|
||||
func findPidByInode(inode string) (string, error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: common/net/network.proto
|
||||
|
||||
package net
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: common/net/port.proto
|
||||
|
||||
package net
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/xtls/xray-core/common/buf"
|
||||
"github.com/xtls/xray-core/common/platform"
|
||||
@@ -26,25 +27,13 @@ func ReadFile(path string) ([]byte, error) {
|
||||
return buf.ReadAllToBytes(reader)
|
||||
}
|
||||
|
||||
func ReadAsset(file string) ([]byte, error) {
|
||||
path, _, err := getAssetFileLocation(file)
|
||||
func OpenAsset(file string) (io.ReadCloser, int64, error) {
|
||||
path, info, err := getAssetFileLocation(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
return ReadFile(path)
|
||||
}
|
||||
|
||||
func OpenAsset(file string) (io.ReadCloser, error) {
|
||||
path, _, err := getAssetFileLocation(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewFileReader(path)
|
||||
}
|
||||
|
||||
func StatAsset(file string) (os.FileInfo, error) {
|
||||
_, info, err := getAssetFileLocation(file)
|
||||
return info, err
|
||||
r, err := NewFileReader(path)
|
||||
return r, info.Size(), err
|
||||
}
|
||||
|
||||
func ResolveAsset(file string) (string, error) {
|
||||
@@ -78,17 +67,29 @@ func ReadCert(file string) ([]byte, error) {
|
||||
return ReadFile(platform.GetCertLocation(file))
|
||||
}
|
||||
|
||||
func CopyFile(dst string, src string) error {
|
||||
bytes, err := ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
// ResolveLuaFile finds a local .lua script and returns its absolute path.
|
||||
// Relative paths: XRAY_LOCATION_CONFDIR > XRAY_LOCATION_CONFIG > working dir > executable dir.
|
||||
func ResolveLuaFile(path string) (string, error) {
|
||||
if !strings.EqualFold(filepath.Ext(path), ".lua") {
|
||||
return "", errors.New("Lua file extension must be .lua")
|
||||
}
|
||||
f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
if filepath.IsAbs(path) {
|
||||
return path, nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = f.Write(bytes)
|
||||
return err
|
||||
for _, dir := range []string{
|
||||
platform.GetConfDirPath(),
|
||||
platform.NewEnvFlag(platform.ConfigLocation).GetValue(func() string { return "" }),
|
||||
".",
|
||||
platform.GetExecutableDir(),
|
||||
} {
|
||||
if dir != "" {
|
||||
resolved := filepath.Join(dir, path)
|
||||
if _, err := os.Stat(resolved); err == nil {
|
||||
return filepath.Abs(resolved)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package filesystem_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/xtls/xray-core/common"
|
||||
"github.com/xtls/xray-core/common/platform"
|
||||
. "github.com/xtls/xray-core/common/platform/filesystem"
|
||||
)
|
||||
|
||||
func TestStatAssetRejectsInvalidPath(t *testing.T) {
|
||||
func TestResolveAssetRejectsInvalidPath(t *testing.T) {
|
||||
for _, file := range []string{
|
||||
"",
|
||||
".",
|
||||
@@ -25,8 +29,49 @@ func TestStatAssetRejectsInvalidPath(t *testing.T) {
|
||||
`nested\..\geoip.dat`,
|
||||
filepath.Join(t.TempDir(), "geoip.dat"),
|
||||
} {
|
||||
if _, err := StatAsset(file); err == nil {
|
||||
if _, err := ResolveAsset(file); err == nil {
|
||||
t.Fatalf("expected error for %q", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLuaFile(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
workingDir, err := os.Getwd()
|
||||
common.Must(err)
|
||||
executable, err := os.Executable()
|
||||
common.Must(err)
|
||||
file, err := os.CreateTemp(filepath.Dir(executable), "lua-*.lua")
|
||||
common.Must(err)
|
||||
common.Must(file.Close())
|
||||
defer os.Remove(file.Name())
|
||||
|
||||
name := filepath.Base(file.Name())
|
||||
paths := []string{
|
||||
filepath.Join(t.TempDir(), name),
|
||||
filepath.Join(t.TempDir(), name),
|
||||
filepath.Join(workingDir, name),
|
||||
file.Name(),
|
||||
}
|
||||
t.Setenv(platform.ConfdirLocation, filepath.Dir(paths[0]))
|
||||
t.Setenv(platform.ConfigLocation, filepath.Dir(paths[1]))
|
||||
for _, path := range paths[:3] {
|
||||
common.Must(os.WriteFile(path, nil, 0o600))
|
||||
}
|
||||
if got, err := ResolveLuaFile(paths[2]); err != nil || got != paths[2] {
|
||||
t.Fatalf("absolute path = %q, %v; want %q", got, err, paths[2])
|
||||
}
|
||||
for i, want := range paths {
|
||||
if i == 2 {
|
||||
t.Setenv(platform.ConfdirLocation, "")
|
||||
t.Setenv(platform.ConfigLocation, "")
|
||||
}
|
||||
if got, err := ResolveLuaFile(name); err != nil || got != want {
|
||||
t.Fatalf("resolved path = %q, %v; want %q", got, err, want)
|
||||
}
|
||||
common.Must(os.Remove(want))
|
||||
}
|
||||
if _, err := ResolveLuaFile(name); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("missing file error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func LineSeparator() string {
|
||||
|
||||
// GetAssetLocation searches for `file` in the env dir, the executable dir, and certain locations
|
||||
func GetAssetLocation(file string) string {
|
||||
assetPath := NewEnvFlag(AssetLocation).GetValue(getExecutableDir)
|
||||
assetPath := NewEnvFlag(AssetLocation).GetValue(GetExecutableDir)
|
||||
defPath := filepath.Join(assetPath, file)
|
||||
for _, p := range []string{
|
||||
defPath,
|
||||
@@ -36,6 +36,6 @@ func GetAssetLocation(file string) string {
|
||||
|
||||
// GetCertLocation searches for `file` in the env dir and the executable dir
|
||||
func GetCertLocation(file string) string {
|
||||
certPath := NewEnvFlag(CertLocation).GetValue(getExecutableDir)
|
||||
certPath := NewEnvFlag(CertLocation).GetValue(GetExecutableDir)
|
||||
return filepath.Join(certPath, file)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func NormalizeEnvName(name string) string {
|
||||
return strings.ReplaceAll(strings.ToUpper(strings.TrimSpace(name)), ".", "_")
|
||||
}
|
||||
|
||||
func getExecutableDir() string {
|
||||
func GetExecutableDir() string {
|
||||
exec, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -81,7 +81,7 @@ func getExecutableDir() string {
|
||||
}
|
||||
|
||||
func GetConfigurationPath() string {
|
||||
configPath := NewEnvFlag(ConfigLocation).GetValue(getExecutableDir)
|
||||
configPath := NewEnvFlag(ConfigLocation).GetValue(GetExecutableDir)
|
||||
return filepath.Join(configPath, "config.json")
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ func LineSeparator() string {
|
||||
|
||||
// GetAssetLocation searches for `file` in the env dir and the executable dir
|
||||
func GetAssetLocation(file string) string {
|
||||
assetPath := NewEnvFlag(AssetLocation).GetValue(getExecutableDir)
|
||||
assetPath := NewEnvFlag(AssetLocation).GetValue(GetExecutableDir)
|
||||
return filepath.Join(assetPath, file)
|
||||
}
|
||||
|
||||
// GetCertLocation searches for `file` in the env dir and the executable dir
|
||||
func GetCertLocation(file string) string {
|
||||
certPath := NewEnvFlag(CertLocation).GetValue(getExecutableDir)
|
||||
certPath := NewEnvFlag(CertLocation).GetValue(GetExecutableDir)
|
||||
return filepath.Join(certPath, file)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.5
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: common/protocol/headers.proto
|
||||
|
||||
package protocol
|
||||
|
||||
@@ -146,6 +146,10 @@ func SniffQUIC(b []byte) (*SniffHeader, error) {
|
||||
}
|
||||
|
||||
restPayload := b[hdrLen+int(packetLen):]
|
||||
// cachedReader can concatenate zero-padded UDP datagrams.
|
||||
for len(restPayload) > 0 && restPayload[0] == 0 {
|
||||
restPayload = restPayload[1:]
|
||||
}
|
||||
if !isQUICInitial { // Skip this packet if it's not initial packet
|
||||
b = restPayload
|
||||
continue
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user