diff --git a/app/dns/config.pb.go b/app/dns/config.pb.go index 053721239..f12e060b0 100644 --- a/app/dns/config.pb.go +++ b/app/dns/config.pb.go @@ -266,10 +266,9 @@ 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"` - // Absolute path to the Lua DNS query script. - Script string `protobuf:"bytes,15,opt,name=script,proto3" json:"script,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Script string `protobuf:"bytes,15,opt,name=script,proto3" json:"script,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Config) Reset() { diff --git a/app/dns/config.proto b/app/dns/config.proto index ca85582fe..4ab634030 100644 --- a/app/dns/config.proto +++ b/app/dns/config.proto @@ -75,6 +75,5 @@ message Config { bool enableParallelQuery = 14; - // Absolute path to the Lua DNS query script. string script = 15; } diff --git a/app/dns/dns.go b/app/dns/dns.go index 9943da09d..e452bda5e 100644 --- a/app/dns/dns.go +++ b/app/dns/dns.go @@ -32,7 +32,6 @@ type DNS struct { matcherInfos []*DomainMatcherInfo checkSystem bool script *scriptEngine - scriptPath string } // DomainMatcherInfo contains information attached to index returned by Server.domainMatcher. @@ -171,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, @@ -182,7 +188,7 @@ func New(ctx context.Context, config *Config) (*DNS, error) { disableFallbackIfMatch: config.DisableFallbackIfMatch, enableParallelQuery: config.EnableParallelQuery, checkSystem: checkSystem, - scriptPath: config.Script, + script: script, }, nil } @@ -193,12 +199,10 @@ func (*DNS) Type() interface{} { // Start implements common.Runnable. func (s *DNS) Start() error { - if s.scriptPath != "" { - engine, err := newScriptEngine(s.scriptPath, s) - if err != nil { + if s.script != nil { + if err := s.script.start(s); err != nil { return errors.New("failed to initialize DNS script").Base(err) } - s.script = engine } return nil } diff --git a/app/dns/lua_benchmark_test.go b/app/dns/lua_benchmark_test.go index ac4f6b8c8..245492fd3 100644 --- a/app/dns/lua_benchmark_test.go +++ b/app/dns/lua_benchmark_test.go @@ -72,10 +72,13 @@ end if err := os.WriteFile(path, []byte(script), 0o600); err != nil { b.Fatal(err) } - engine, err := newScriptEngine(path, server) + 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 diff --git a/app/dns/script.go b/app/dns/script.go index 012d44f7a..c465d175a 100644 --- a/app/dns/script.go +++ b/app/dns/script.go @@ -15,16 +15,20 @@ import ( const scriptExecutionTimeout = 6 * time.Second type scriptEngine struct { - pool *xlua.Pool + program *xlua.Program + pool *xlua.Pool } -func newScriptEngine(path string, server *DNS) (*scriptEngine, error) { - program, err := xlua.CompileFile(path) +func newScriptEngine(script string) (*scriptEngine, error) { + program, err := xlua.Compile(script) if err != nil { return nil, err } + return &scriptEngine{program: program}, nil +} - pool, err := xlua.NewPool(server.ctx, scriptExecutionTimeout, program.NewStateFactory( +func (e *scriptEngine) start(server *DNS) error { + pool, err := xlua.NewPool(server.ctx, scriptExecutionTimeout, e.program.NewStateFactory( scriptExecutionTimeout*20, func(L *lua.LState) { geodata.RegisterLua(L) @@ -38,15 +42,18 @@ func newScriptEngine(path string, server *DNS) (*scriptEngine, error) { return nil })) if err != nil { - return nil, err + return err } - errors.LogInfo(server.ctx, "DNS script initialized from ", path) - return &scriptEngine{pool: pool}, nil + e.pool = pool + errors.LogInfo(server.ctx, "DNS script initialized") + return nil } func (e *scriptEngine) close() { - e.pool.Close() + if e.pool != nil { + e.pool.Close() + } } func (e *scriptEngine) query(domain string, option dns.IPOption) (ips []net.IP, ttl uint32, queryErr error) { diff --git a/app/dns/script_test.go b/app/dns/script_test.go index cea0bb1c7..506a79b13 100644 --- a/app/dns/script_test.go +++ b/app/dns/script_test.go @@ -85,10 +85,13 @@ end ctx: context.Background(), clients: []*Client{{server: upstream, ipOption: &option, timeoutMs: time.Second}}, } - engine, err := newScriptEngine(path, server) + 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) @@ -164,11 +167,15 @@ end 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, - scriptPath: scriptPath, + 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}, @@ -202,29 +209,67 @@ end func TestDNSScriptRejectsInvalidStartup(t *testing.T) { for _, tc := range []struct { - name string - script string + name, script string + compileError bool }{ - {"syntax", "function HandleDNSQuery("}, - {"missing hook", "value = 1"}, - {"top-level error", `error("setup failed")`}, + {"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 := &DNS{ctx: context.Background(), scriptPath: path} + 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 != nil { - t.Fatal("Start retained a script engine after failure") + 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 := ` @@ -251,12 +296,16 @@ end 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, - scriptPath: path, - clients: []*Client{{id: "fake", server: upstream, ipOption: &option, timeoutMs: time.Second}}, + 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) diff --git a/app/router/config.pb.go b/app/router/config.pb.go index 2eea46d5f..d3c596311 100644 --- a/app/router/config.pb.go +++ b/app/router/config.pb.go @@ -587,10 +587,9 @@ 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"` - // Absolute path to the Lua routing script. - Script string `protobuf:"bytes,4,opt,name=script,proto3" json:"script,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Script string `protobuf:"bytes,4,opt,name=script,proto3" json:"script,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Config) Reset() { diff --git a/app/router/config.proto b/app/router/config.proto index 09b64b0a3..01532375a 100644 --- a/app/router/config.proto +++ b/app/router/config.proto @@ -110,6 +110,5 @@ message Config { DomainStrategy domain_strategy = 1; repeated RoutingRule rule = 2; repeated BalancingRule balancing_rule = 3; - // Absolute path to the Lua routing script. string script = 4; } diff --git a/app/router/lua.go b/app/router/lua.go index 6cf55d5f1..75da27ccc 100644 --- a/app/router/lua.go +++ b/app/router/lua.go @@ -30,9 +30,9 @@ func (r *Router) RegisterLua(L *lua.LState) { module.RawSetString("LocalOS", lua.LString(runtime.GOOS)) module.RawSetString("PickOutbound", L.NewFunction(func(L *lua.LState) int { - tag, ok := L.Get(2).(lua.LString) + tag, ok := L.Get(1).(lua.LString) if !ok { - L.ArgError(2, "balancer tag must be a string") + L.ArgError(1, "balancer tag must be a string") return 0 } balancer, found := (*r.balancers.Load())[string(tag)] diff --git a/app/router/router.go b/app/router/router.go index 97ffba13c..cd695d8ea 100644 --- a/app/router/router.go +++ b/app/router/router.go @@ -20,7 +20,6 @@ import ( type Router struct { domainStrategy Config_DomainStrategy rules atomic.Pointer[[]*Rule] - scriptPath string script *scriptEngine balancers atomic.Pointer[map[string]*Balancer] dns dns.Client @@ -42,12 +41,19 @@ type Route struct { // Init initializes the Router. func (r *Router) Init(ctx context.Context, config *Config, d dns.Client, ohm outbound.Manager, dispatcher routing.Dispatcher) error { r.domainStrategy = config.DomainStrategy - r.scriptPath = config.Script r.dns = d r.ctx = ctx 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) @@ -228,12 +234,10 @@ func (r *Router) pickRouteInternal(ctx routing.Context) (*Rule, routing.Context, // Start implements common.Runnable. func (r *Router) Start() error { - if r.scriptPath != "" { - engine, err := newScriptEngine(r.scriptPath, r) - if err != nil { + if r.script != nil { + if err := r.script.start(r); err != nil { return errors.New("failed to initialize routing script").Base(err) } - r.script = engine } return nil } diff --git a/app/router/script.go b/app/router/script.go index b4a720b56..2893e0624 100644 --- a/app/router/script.go +++ b/app/router/script.go @@ -16,16 +16,20 @@ import ( const scriptExecutionTimeout = 6 * time.Second type scriptEngine struct { - pool *xlua.Pool + program *xlua.Program + pool *xlua.Pool } -func newScriptEngine(path string, router *Router) (*scriptEngine, error) { - program, err := xlua.CompileFile(path) +func newScriptEngine(script string) (*scriptEngine, error) { + program, err := xlua.Compile(script) if err != nil { return nil, err } + return &scriptEngine{program: program}, nil +} - pool, err := xlua.NewPool(router.ctx, scriptExecutionTimeout, program.NewStateFactory( +func (e *scriptEngine) start(router *Router) error { + pool, err := xlua.NewPool(router.ctx, scriptExecutionTimeout, e.program.NewStateFactory( scriptExecutionTimeout*20, func(L *lua.LState) { geodata.RegisterLua(L) @@ -40,15 +44,18 @@ func newScriptEngine(path string, router *Router) (*scriptEngine, error) { return nil })) if err != nil { - return nil, err + return err } - errors.LogInfo(router.ctx, "routing script initialized from ", path) - return &scriptEngine{pool: pool}, nil + e.pool = pool + errors.LogInfo(router.ctx, "routing script initialized") + return nil } func (e *scriptEngine) close() { - e.pool.Close() + if e.pool != nil { + e.pool.Close() + } } func (e *scriptEngine) pickRoute(ctx routing.Context) (routing.Route, error) { diff --git a/app/router/script_test.go b/app/router/script_test.go index 7daeb868a..599ebbf7d 100644 --- a/app/router/script_test.go +++ b/app/router/script_test.go @@ -72,24 +72,63 @@ func startLuaRouter(t *testing.T, script string, d featureDNS.Client, config *Co } func TestRouterScriptStartup(t *testing.T) { - for _, tc := range []struct{ name, script string }{ - {"syntax error", "function HandleRoute("}, - {"missing hook", "value = 1"}, - {"initialization error", `error("setup failed")`}, + 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) - if err := r.Init(context.Background(), &Config{Script: writeRouteScript(t, tc.script)}, nil, nil, nil); err != nil { + 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 @@ -101,7 +140,7 @@ func TestRouterScriptRouting(t *testing.T) { {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: "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"}, @@ -202,7 +241,7 @@ func TestRouterScriptBalancerReload(t *testing.T) { r := startLuaRouter(t, ` local router = require("xray.router") function HandleRoute() - local tag, err = router:PickOutbound("balance") + local tag, err = router.PickOutbound("balance") return tag, "balanced", err end`, nil, config("old")) pick := func(want string) { @@ -236,7 +275,7 @@ func TestRouterScriptConcurrentBalancerReload(t *testing.T) { r := startLuaRouter(t, ` local router = require("xray.router") function HandleRoute() - local tag, err = router:PickOutbound("balance") + local tag, err = router.PickOutbound("balance") return tag, nil, err end`, nil, config("a")) @@ -367,10 +406,10 @@ end`) if err != nil { t.Fatal(err) } - defer instance.Close() 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" { diff --git a/common/base64/base64.go b/common/base64/base64.go new file mode 100644 index 000000000..71b16a887 --- /dev/null +++ b/common/base64/base64.go @@ -0,0 +1,6 @@ +package base64 + +const ( + Prefix = "base64:" + GzipPrefix = "base64+gzip:" +) diff --git a/common/base64/decode.go b/common/base64/decode.go new file mode 100644 index 000000000..052b0037e --- /dev/null +++ b/common/base64/decode.go @@ -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 +} diff --git a/common/base64/decode_test.go b/common/base64/decode_test.go new file mode 100644 index 000000000..e5209f023 --- /dev/null +++ b/common/base64/decode_test.go @@ -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) + } + }) + } +} diff --git a/common/base64/encode.go b/common/base64/encode.go new file mode 100644 index 000000000..20ca72d7c --- /dev/null +++ b/common/base64/encode.go @@ -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 +} diff --git a/common/base64/encode_test.go b/common/base64/encode_test.go new file mode 100644 index 000000000..0616d345b --- /dev/null +++ b/common/base64/encode_test.go @@ -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") + } + }) + } + }) + } +} diff --git a/common/lua/program.go b/common/lua/program.go index 38eb225fc..f37e91d50 100644 --- a/common/lua/program.go +++ b/common/lua/program.go @@ -1,11 +1,14 @@ package lua import ( - "bufio" + "bytes" "context" "os" + "strings" "time" + "github.com/xtls/xray-core/common/base64" + "github.com/xtls/xray-core/common/platform" glua "github.com/yuin/gopher-lua" "github.com/yuin/gopher-lua/parse" ) @@ -19,18 +22,25 @@ type Program struct { // Implementations must close partial states on failure; callers own successful states. type LStateFactory func(context.Context) (*glua.LState, error) -// CompileFile reads and compiles a Lua file once. -func CompileFile(path string) (*Program, error) { - f, err := os.Open(path) +// 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]" + if strings.HasPrefix(script, base64.GzipPrefix) || strings.HasPrefix(script, base64.Prefix) { + code, err = base64.Decode(script) + } else if name, err = platform.ResolveLuaFile(script); err == nil { + code, err = os.ReadFile(name) + } if err != nil { return nil, err } - defer f.Close() - chunk, err := parse.Parse(bufio.NewReader(f), path) + + chunk, err := parse.Parse(bytes.NewReader(code), name) if err != nil { return nil, err } - proto, err := glua.Compile(chunk, path) + proto, err := glua.Compile(chunk, name) if err != nil { return nil, err } diff --git a/common/lua/program_test.go b/common/lua/program_test.go index f4593f7e6..0465f7a15 100644 --- a/common/lua/program_test.go +++ b/common/lua/program_test.go @@ -1,12 +1,17 @@ package lua import ( + "bytes" + "compress/gzip" "context" + "encoding/base64" "errors" "os" "path/filepath" + "strings" "testing" + xbase64 "github.com/xtls/xray-core/common/base64" glua "github.com/yuin/gopher-lua" ) @@ -15,10 +20,13 @@ func TestProgramStatesAreIndependent(t *testing.T) { if err := os.WriteFile(path, []byte("value = (value or 0) + 1"), 0o600); err != nil { t.Fatal(err) } - program, err := CompileFile(path) + program, err := Compile(path) if err != nil { t.Fatal(err) } + if program.proto.SourceName != path { + t.Fatalf("program source = %q, want %q", program.proto.SourceName, path) + } first, err := program.NewState(context.Background(), nil, nil) if err != nil { t.Fatal(err) @@ -40,7 +48,7 @@ func TestProgramInitializationObservesCancellation(t *testing.T) { if err := os.WriteFile(path, []byte("while true do end"), 0o600); err != nil { t.Fatal(err) } - program, err := CompileFile(path) + program, err := Compile(path) if err != nil { t.Fatal(err) } @@ -60,7 +68,7 @@ func TestNewStateClosesFailedValidation(t *testing.T) { if err := os.WriteFile(path, []byte("value = 1"), 0o600); err != nil { t.Fatal(err) } - program, err := CompileFile(path) + program, err := Compile(path) if err != nil { t.Fatal(err) } @@ -74,3 +82,115 @@ func TestNewStateClosesFailedValidation(t *testing.T) { t.Fatalf("state = %v, error = %v, checked state closed = %t", L, err, checked != nil && checked.IsClosed()) } } + +func compressSource(t *testing.T, code string) []byte { + t.Helper() + var data bytes.Buffer + writer := gzip.NewWriter(&data) + if _, err := writer.Write([]byte(code)); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return data.Bytes() +} + +func TestCompileInline(t *testing.T) { + const code = `value = "你好"` + for _, tc := range []struct { + name, script string + value glua.LValue + }{ + {"base64", xbase64.Prefix + base64.StdEncoding.EncodeToString([]byte(code)), glua.LString("你好")}, + {"base64+gzip", xbase64.GzipPrefix + base64.StdEncoding.EncodeToString(compressSource(t, code)), glua.LString("你好")}, + {"empty base64", xbase64.Prefix, glua.LNil}, + } { + t.Run(tc.name, func(t *testing.T) { + program, err := Compile(tc.script) + if err != nil { + t.Fatal(err) + } + if program.proto.SourceName != "[inline]" { + t.Fatalf("program source = %q, want [inline]", program.proto.SourceName) + } + state, err := program.NewState(context.Background(), nil, nil) + if err != nil { + t.Fatal(err) + } + defer state.Close() + if got := state.GetGlobal("value"); got != tc.value { + t.Fatalf("value = %v, want %v", got, tc.value) + } + }) + } +} + +func TestCompileRejectsInvalidInline(t *testing.T) { + corrupt := compressSource(t, "value = 1") + corrupt[len(corrupt)-8] ^= 0xff + for _, tc := range []struct { + name, script string + wantErr error + wantMessage string + }{ + {"base64", xbase64.Prefix + "%", base64.CorruptInputError(0), "failed to decode base64"}, + {"gzip base64", xbase64.GzipPrefix + "%", base64.CorruptInputError(0), "failed to decode base64"}, + {"gzip header", xbase64.GzipPrefix + base64.StdEncoding.EncodeToString([]byte("value = 'uncompressed'")), gzip.ErrHeader, "failed to decompress gzip"}, + {"gzip checksum", xbase64.GzipPrefix + base64.StdEncoding.EncodeToString(corrupt), gzip.ErrChecksum, "failed to decompress gzip"}, + } { + t.Run(tc.name, func(t *testing.T) { + program, err := Compile(tc.script) + if program != nil || !errors.Is(err, tc.wantErr) || !strings.Contains(err.Error(), tc.wantMessage) { + t.Fatalf("Compile = %v, %v; want nil program and %q error wrapping %v", program, err, tc.wantMessage, tc.wantErr) + } + }) + } +} + +func TestCompileInlineDiagnostics(t *testing.T) { + for _, tc := range []struct { + code string + compileError bool + }{ + {"function broken(", true}, + {`error("initialization failed")`, false}, + } { + program, err := Compile(xbase64.Prefix + base64.StdEncoding.EncodeToString([]byte(tc.code))) + if tc.compileError { + if program != nil || err == nil { + t.Fatalf("Compile = %v, %v; want nil program and syntax error", program, err) + } + } else { + if err != nil { + t.Fatal(err) + } + state, initErr := program.NewState(context.Background(), nil, nil) + if state != nil { + state.Close() + t.Fatal("NewState returned a state after failed initialization") + } + err = initErr + } + if err == nil || !strings.Contains(err.Error(), "[inline]") { + t.Fatalf("Lua error = %v, want [inline] diagnostic", err) + } + } +} + +func TestCompileRejectsReadFailure(t *testing.T) { + path := filepath.Join(t.TempDir(), "unreadable.lua") + if err := os.WriteFile(path, []byte("value = 42"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o600) }) + if _, err := os.ReadFile(path); !errors.Is(err, os.ErrPermission) { + t.Skip("file read permissions are not enforced") + } + if program, err := Compile(path); program != nil || !errors.Is(err, os.ErrPermission) { + t.Fatalf("Compile = %v, %v; want nil program and permission error", program, err) + } +} diff --git a/infra/conf/dns.go b/infra/conf/dns.go index 07b482742..71ac95115 100644 --- a/infra/conf/dns.go +++ b/infra/conf/dns.go @@ -14,7 +14,6 @@ import ( "github.com/xtls/xray-core/common/errors" "github.com/xtls/xray-core/common/geodata" "github.com/xtls/xray-core/common/net" - "github.com/xtls/xray-core/common/platform" ) type NameServerConfig struct { @@ -273,6 +272,17 @@ func (m *HostsWrapper) Build() ([]*dns.Config_HostMapping, error) { // Build implements Buildable func (c *DNSConfig) Build() (*dns.Config, error) { + if c.Script != "" { + if c.DisableFallback || c.DisableFallbackIfMatch || c.EnableParallelQuery { + return nil, errors.New("DNS script cannot be used with disableFallback, disableFallbackIfMatch or enableParallelQuery") + } + for i, server := range c.Servers { + if len(server.Domains) > 0 || server.SkipFallback || server.FinalQuery { + return nil, errors.New("DNS script cannot be used with domains, skipFallback or finalQuery in server ", i) + } + } + } + config := &dns.Config{ Tag: c.Tag, DisableCache: c.DisableCache, @@ -282,14 +292,7 @@ func (c *DNSConfig) Build() (*dns.Config, error) { DisableFallbackIfMatch: c.DisableFallbackIfMatch, EnableParallelQuery: c.EnableParallelQuery, QueryStrategy: resolveQueryStrategy(c.QueryStrategy), - } - - if c.Script != "" { - path, err := platform.ResolveLuaFile(c.Script) - if err != nil { - return nil, errors.New("failed to resolve DNS script: ", c.Script).Base(err) - } - config.Script = path + Script: c.Script, } if c.ClientIP != nil { diff --git a/infra/conf/dns_test.go b/infra/conf/dns_test.go index 5657f0c70..0cca52522 100644 --- a/infra/conf/dns_test.go +++ b/infra/conf/dns_test.go @@ -2,8 +2,7 @@ package conf_test import ( "encoding/json" - "os" - "path/filepath" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -125,41 +124,7 @@ func TestDNSConfigParsing(t *testing.T) { } } -func TestDNSScriptConfig(t *testing.T) { - dir := t.TempDir() - t.Setenv("xray.location.confdir", dir) - path := filepath.Join(dir, "lookup.lua") - if err := os.WriteFile(path, []byte("function HandleDNSQuery(domain, ipv4, ipv6, fake) end"), 0o600); err != nil { - t.Fatal(err) - } - - for _, tc := range []struct { - name string - script string - wantError bool - }{ - {"relative", "lookup.lua", false}, - {"absolute", path, false}, - {"missing", "missing.lua", true}, - {"directory", dir, true}, - } { - t.Run(tc.name, func(t *testing.T) { - built, err := (&DNSConfig{Script: tc.script}).Build() - if tc.wantError { - if err == nil { - t.Fatal("Build accepted an invalid script path") - } - return - } - if err != nil { - t.Fatal(err) - } - if built.Script != path { - t.Fatalf("script path = %q, want %q", built.Script, path) - } - }) - } - +func TestDNSNameServerID(t *testing.T) { var parsed DNSConfig if err := json.Unmarshal([]byte(`{"servers":[{"id":"primary","address":"1.1.1.1"}]}`), &parsed); err != nil { t.Fatal(err) @@ -172,3 +137,43 @@ func TestDNSScriptConfig(t *testing.T) { t.Fatalf("nameserver IDs = %v, want primary", built.NameServer) } } + +func TestDNSScriptConfig(t *testing.T) { + for _, test := range []struct { + name string + input string + wantError string + }{ + {name: "script only", input: `{"script":"script.lua"}`}, + {name: "compatible fields", input: `{"script":"script.lua","servers":["1.1.1.1",{"id":"primary","address":"8.8.8.8","expectedIPs":["8.8.8.8"],"queryStrategy":"UseIPv4","disableCache":true}],"hosts":{"example.com":"127.0.0.1"},"disableCache":true}`}, + {name: "disabled options", input: `{"script":"script.lua","disableFallback":false,"disableFallbackIfMatch":false,"enableParallelQuery":false,"servers":[{"address":"1.1.1.1","domains":[],"skipFallback":false,"finalQuery":false}]}`}, + {name: "domains", input: `{"script":"script.lua","servers":[{"address":"1.1.1.1","domains":["domain:example.com"]}]}`, wantError: "domains"}, + {name: "skip fallback", input: `{"script":"script.lua","servers":[{"address":"1.1.1.1","skipFallback":true}]}`, wantError: "skipFallback"}, + {name: "final query", input: `{"script":"script.lua","servers":[{"address":"1.1.1.1","finalQuery":true}]}`, wantError: "finalQuery"}, + {name: "later server", input: `{"script":"script.lua","servers":["1.1.1.1",{"address":"8.8.8.8","finalQuery":true}]}`, wantError: "server 1"}, + {name: "disable fallback", input: `{"script":"script.lua","disableFallback":true}`, wantError: "disableFallback"}, + {name: "disable fallback if match", input: `{"script":"script.lua","disableFallbackIfMatch":true}`, wantError: "disableFallbackIfMatch"}, + {name: "parallel query", input: `{"script":"script.lua","enableParallelQuery":true}`, wantError: "enableParallelQuery"}, + {name: "empty script", input: `{"script":"","disableFallback":true,"disableFallbackIfMatch":true,"enableParallelQuery":true,"servers":[{"address":"1.1.1.1","domains":["example.com"],"skipFallback":true,"finalQuery":true}]}`}, + } { + t.Run(test.name, func(t *testing.T) { + var config DNSConfig + if err := json.Unmarshal([]byte(test.input), &config); err != nil { + t.Fatal(err) + } + built, err := config.Build() + if test.wantError != "" { + if built != nil || err == nil || !strings.Contains(err.Error(), "DNS script cannot be used with ") || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("Build() = %v, %v; want nil config and script conflict error mentioning %s", built, err, test.wantError) + } + return + } + if err != nil { + t.Fatal(err) + } + if built.Script != config.Script || len(built.NameServer) != len(config.Servers) { + t.Fatalf("Build() did not preserve script or servers: %v", built) + } + }) + } +} diff --git a/infra/conf/router.go b/infra/conf/router.go index ba5cfeef2..85d50ad1b 100644 --- a/infra/conf/router.go +++ b/infra/conf/router.go @@ -7,7 +7,6 @@ import ( "github.com/xtls/xray-core/app/router" "github.com/xtls/xray-core/common/errors" "github.com/xtls/xray-core/common/geodata" - "github.com/xtls/xray-core/common/platform" "github.com/xtls/xray-core/common/serial" "google.golang.org/protobuf/proto" @@ -95,13 +94,10 @@ func (c *RouterConfig) getDomainStrategy() router.Config_DomainStrategy { func (c *RouterConfig) Build() (*router.Config, error) { config := new(router.Config) - if c.Script != "" { - path, err := platform.ResolveLuaFile(c.Script) - if err != nil { - return nil, errors.New("failed to resolve routing script").Base(err) - } - config.Script = path + if c.Script != "" && (c.DomainStrategy != nil || len(c.RuleList) > 0) { + return nil, errors.New("routing script cannot be used with domainStrategy or rules") } + config.Script = c.Script config.DomainStrategy = c.getDomainStrategy() diff --git a/infra/conf/router_test.go b/infra/conf/router_test.go index 04e4bcd83..2caf2e558 100644 --- a/infra/conf/router_test.go +++ b/infra/conf/router_test.go @@ -2,8 +2,7 @@ package conf_test import ( "encoding/json" - "os" - "path/filepath" + "strings" "testing" "time" _ "unsafe" @@ -240,36 +239,37 @@ func TestRouterConfig(t *testing.T) { } func TestRouterScriptConfig(t *testing.T) { - dir := t.TempDir() - t.Setenv("xray.location.confdir", dir) - path := filepath.Join(dir, "route.lua") - if err := os.WriteFile(path, []byte("function HandleRoute() end"), 0o600); err != nil { - t.Fatal(err) - } - - for _, tc := range []struct { + for _, test := range []struct { name string - script string + input string wantError bool }{ - {"relative", "route.lua", false}, - {"absolute", path, false}, - {"missing", "missing.lua", true}, - {"directory", dir, true}, + {name: "script only", input: `{"script":"missing.lua"}`}, + {name: "empty rules", input: `{"script":"missing.lua","rules":[]}`}, + {name: "balancers", input: `{"script":"missing.lua","balancers":[{"tag":"b1","selector":["direct"]}]}`}, + {name: "default domain strategy", input: `{"script":"missing.lua","domainStrategy":"AsIs"}`, wantError: true}, + {name: "IP domain strategy", input: `{"script":"missing.lua","domainStrategy":"IPIfNonMatch"}`, wantError: true}, + {name: "empty domain strategy", input: `{"script":"missing.lua","domainStrategy":""}`, wantError: true}, + {name: "rules", input: `{"script":"missing.lua","rules":[{"outboundTag":"direct","domain":["example.com"]}]}`, wantError: true}, + {name: "empty script", input: `{"script":"","domainStrategy":"IPOnDemand","rules":[{"outboundTag":"direct","domain":["example.com"]}]}`}, } { - t.Run(tc.name, func(t *testing.T) { - built, err := (&RouterConfig{Script: tc.script}).Build() - if tc.wantError { - if err == nil { - t.Fatal("Build accepted invalid script path") + t.Run(test.name, func(t *testing.T) { + var config RouterConfig + if err := json.Unmarshal([]byte(test.input), &config); err != nil { + t.Fatal(err) + } + built, err := config.Build() + if test.wantError { + if built != nil || err == nil || !strings.Contains(err.Error(), "routing script cannot be used with domainStrategy or rules") { + t.Fatalf("Build() = %v, %v; want nil config and script conflict error", built, err) } return } if err != nil { t.Fatal(err) } - if built.Script != path { - t.Fatalf("script path = %q, want %q", built.Script, path) + if built.Script != config.Script || len(built.Rule) != len(config.RuleList) || len(built.BalancingRule) != len(config.Balancers) { + t.Fatalf("Build() did not preserve script, rules or balancers: %v", built) } }) } diff --git a/main/commands/all/base64/base64.go b/main/commands/all/base64/base64.go new file mode 100644 index 000000000..2bf2fcfa5 --- /dev/null +++ b/main/commands/all/base64/base64.go @@ -0,0 +1,14 @@ +package base64 + +import "github.com/xtls/xray-core/main/commands/base" + +// CmdBase64 holds all Base64 subcommands. +var CmdBase64 = &base.Command{ + UsageLine: "{{.Exec}} base64", + Short: "Encode and decode Base64", + Long: "{{.Exec}} {{.LongName}} provides tools to encode and decode arbitrary bytes.", + Commands: []*base.Command{ + cmdEncode, + cmdDecode, + }, +} diff --git a/main/commands/all/base64/decode.go b/main/commands/all/base64/decode.go new file mode 100644 index 000000000..a3981e1d7 --- /dev/null +++ b/main/commands/all/base64/decode.go @@ -0,0 +1,66 @@ +package base64 + +import ( + "fmt" + "io" + "os" + "strings" + + xbase64 "github.com/xtls/xray-core/common/base64" + "github.com/xtls/xray-core/main/commands/base" +) + +var cmdDecode = &base.Command{ + UsageLine: "{{.Exec}} base64 decode [-gzip] [output-file]", + Short: "Decode Base64", + Long: fmt.Sprintf(` +Decode a Base64 value, or pass "-" or "stdin:" to read stdin. +Write to output-file, or stdout if omitted. +Accepts %s, %s, or no prefix; gzip is enabled by %s or -gzip. + +Examples: + + {{.Exec}} base64 decode eA== out.bin + {{.Exec}} base64 decode -gzip H4sIAAAAAAAC/6sAAIMW3IwBAAAA out.bin +`, xbase64.Prefix, xbase64.GzipPrefix, xbase64.GzipPrefix), +} + +var decodeGzipEnabled = cmdDecode.Flag.Bool("gzip", false, "Decompress the decoded bytes with gzip.") + +func init() { + cmdDecode.Run = executeDecode +} + +func executeDecode(cmd *base.Command, args []string) { + if len(args) == 0 || len(args) > 2 { + cmd.Usage() + } + + value := args[0] + if value == "-" || value == "stdin:" { + input, err := io.ReadAll(os.Stdin) + if err != nil { + base.Fatalf("failed to read input: %s", err) + } + value = string(input) + } + + value = strings.TrimSpace(value) + if *decodeGzipEnabled && !strings.HasPrefix(value, xbase64.GzipPrefix) { + value = xbase64.GzipPrefix + strings.TrimPrefix(value, xbase64.Prefix) + } + + data, err := xbase64.Decode(value) + if err != nil { + base.Fatalf("failed to decode input: %s", err) + } + + if len(args) == 2 && args[1] != "-" { + err = os.WriteFile(args[1], data, 0o666) + } else { + _, err = os.Stdout.Write(data) + } + if err != nil { + base.Fatalf("failed to write decoded output: %s", err) + } +} diff --git a/main/commands/all/base64/encode.go b/main/commands/all/base64/encode.go new file mode 100644 index 000000000..cfa86fb68 --- /dev/null +++ b/main/commands/all/base64/encode.go @@ -0,0 +1,57 @@ +package base64 + +import ( + "fmt" + "io" + "os" + + xbase64 "github.com/xtls/xray-core/common/base64" + "github.com/xtls/xray-core/main/commands/base" +) + +var cmdEncode = &base.Command{ + UsageLine: "{{.Exec}} base64 encode [-gzip] ", + Short: "Encode as Base64", + Long: fmt.Sprintf(` +Encode input-file as Base64 with the %s prefix. +Pass "-" or "stdin:" to read stdin. +Use -gzip to compress and output %s. + +Examples: + + {{.Exec}} base64 encode input.bin + {{.Exec}} base64 encode -gzip input.bin +`, xbase64.Prefix, xbase64.GzipPrefix), +} + +var gzipEnabled = cmdEncode.Flag.Bool("gzip", false, "Compress the input with gzip.") + +func init() { + cmdEncode.Run = executeEncode +} + +func executeEncode(cmd *base.Command, args []string) { + if len(args) != 1 { + cmd.Usage() + } + + var data []byte + var err error + if args[0] == "-" || args[0] == "stdin:" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(args[0]) + } + if err != nil { + base.Fatalf("failed to read input: %s", err) + } + + value, err := xbase64.Encode(data, *gzipEnabled) + if err != nil { + base.Fatalf("failed to encode input: %s", err) + } + + if _, err := fmt.Fprintln(os.Stdout, value); err != nil { + base.Fatalf("failed to write encoded output: %s", err) + } +} diff --git a/main/commands/all/commands.go b/main/commands/all/commands.go index fba3a4b8b..be2771da9 100644 --- a/main/commands/all/commands.go +++ b/main/commands/all/commands.go @@ -2,6 +2,7 @@ package all import ( "github.com/xtls/xray-core/main/commands/all/api" + "github.com/xtls/xray-core/main/commands/all/base64" "github.com/xtls/xray-core/main/commands/all/convert" "github.com/xtls/xray-core/main/commands/all/tls" "github.com/xtls/xray-core/main/commands/base" @@ -11,6 +12,7 @@ func init() { base.RootCommand.Commands = append( base.RootCommand.Commands, api.CmdAPI, + base64.CmdBase64, convert.CmdConvert, tls.CmdTLS, cmdUUID,