Files
3x-ui/internal/web/service/inbound_xdrive_test.go
T
MHSanaei 62d27d54d5 feat(xray): support the XDRIVE s3 storage service
xray-core 26.10.31 (#7231) added an "s3" XDRIVE service that carries the
stream through S3-compatible storage: secrets are AccessKeyID,
SecretAccessKey and an optional SessionToken, an "s3" object names the
endpoint, region, bucket and pathStyle, and remoteFolder becomes the
object prefix.

The schema and the shared XDRIVE form offer the service with a field for
every S3 key, so an outbound or inbound keeps its storage on save. The
save path refused "s3" as an unknown service; it now runs the vendored
loader, whose ValidateS3Config would otherwise only fire when the
listener starts and stop every inbound. A node refuses a pushed S3
storage like a pushed template one, since its endpoint is any URL the
node would dial, including its own panel. Labels in all 14 locales;
docs updated in en/fa/ru/zh.
2026-11-01 14:56:26 +01:00

212 lines
10 KiB
Go

package service
import (
"encoding/json"
"slices"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func xdriveInbound(port int, xdriveSettings string) *model.Inbound {
return &model.Inbound{
Enable: true, Listen: "127.0.0.1", Port: port, Protocol: model.VLESS,
Settings: `{"clients":[],"decryption":"` + goldenVlessDecryption + `"}`,
StreamSettings: `{"network":"xdrive","security":"none","xdriveSettings":` + xdriveSettings + `}`,
}
}
// xray-core 26.10.17 (#7182) refuses to start an XDRIVE inbound that is not VLESS with
// "decryption" set, and with it every other inbound of the config.
func TestSavePathsRefuseXDriveOutsideVlessEncryption(t *testing.T) {
const local = `{"service":"local","remoteFolder":"/tmp/drive"}`
vmess := xdriveInbound(18490, local)
vmess.Protocol = model.VMESS
vmess.Settings = `{"clients":[]}`
plain := xdriveInbound(18491, local)
plain.Settings = `{"clients":[],"decryption":"none"}`
tests := []struct {
name string
save func(t *testing.T) error
want string
}{
{"vmess inbound", func(t *testing.T) error {
_, _, err := (&InboundService{}).AddInbound(vmess)
return err
}, "XDRIVE carries only VLESS, not vmess"},
{"vless inbound without decryption", func(t *testing.T) error {
_, _, err := (&InboundService{}).AddInbound(plain)
return err
}, "XDRIVE needs VLESS encryption"},
{"xray template inbound without decryption", func(t *testing.T) error {
return (&XraySettingService{}).SaveXraySetting(`{"inbounds":[{"tag":"drive","port":18492,"protocol":"vless",
"settings":` + plain.Settings + `,"streamSettings":` + plain.StreamSettings + `}],
"outbounds":[{"protocol":"freedom","tag":"direct"}]}`)
}, "XDRIVE needs VLESS encryption"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
setupBulkDB(t)
if err := tc.save(t); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("save err = %v, want one containing %q", err, tc.want)
}
})
}
}
// An XDRIVE inbound saved before xray-core 26.10.17 without VLESS encryption makes the core refuse
// the whole config at startup; the build leaves that inbound out so the others keep running.
func TestGetXrayConfig_LeavesOutAStoredXDriveInboundTheCoreRefuses(t *testing.T) {
seedVlessInbound(t, "plain-drive", 43130, nil)
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "plain-drive").
Update("stream_settings", `{"network":"xdrive","security":"none","xdriveSettings":{"service":"local","remoteFolder":"/tmp/shared"}}`).Error; err != nil {
t.Fatalf("set stream: %v", err)
}
cfg, err := (&XrayService{}).GetXrayConfig()
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
}
for _, ib := range cfg.InboundConfigs {
if ib.Tag == "plain-drive" {
t.Fatalf("XDRIVE inbound without decryption emitted: %s", ib.Settings)
}
}
}
// A template XDRIVE outbound from before 26.10.17 lacks the VLESS encryption and mux the core now
// requires; the build drops only that one instead of letting the core refuse the whole config.
func TestGetXrayConfig_DropsATemplateXDriveOutboundTheCoreRefuses(t *testing.T) {
setupConflictDB(t)
const stream = `{"network":"xdrive","security":"none","xdriveSettings":{"service":"local","remoteFolder":"/tmp/shared"}}`
template := `{"outbounds":[{"protocol":"freedom","tag":"direct"},
{"protocol":"vless","tag":"drive-plain","streamSettings":` + stream + `,
"settings":{"address":"10.0.0.1","port":443,"id":"11111111-2222-4333-8444-555555555555","encryption":"none"}},
{"protocol":"vless","tag":"drive-encrypted","streamSettings":` + stream + `,"mux":{"enabled":true,"concurrency":8},
"settings":{"address":"10.0.0.1","port":443,"id":"11111111-2222-4333-8444-555555555555",
"encryption":"mlkem768x25519plus.native.0rtt.mMFxPe7lz5xoq2qBk22cQYefu5fpc_2dGR8lMOKem0E"}}]}`
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", template); err != nil {
t.Fatalf("seed template: %v", err)
}
cfg, err := (&XrayService{}).GetXrayConfig()
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
}
var outbounds []struct {
Tag string `json:"tag"`
}
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
t.Fatalf("decode emitted outbounds: %v", err)
}
var tags []string
for _, ob := range outbounds {
tags = append(tags, ob.Tag)
}
if !slices.Equal(tags, []string{"direct", "drive-encrypted"}) {
t.Fatalf("emitted outbound tags = %v, want [direct drive-encrypted]", tags)
}
}
// xray-core 26.10.17 renamed the service to "google-drive" and reads "Google Drive" only as a
// deprecated alias, so a save posting the old name stores the new one.
func TestAddInboundStoresTheGoogleDriveServiceUnderItsNewName(t *testing.T) {
setupBulkDB(t)
in, _, err := (&InboundService{}).AddInbound(xdriveInbound(18493,
`{"service":"Google Drive","remoteFolder":"f","secrets":["id","secret","token"]}`))
if err != nil {
t.Fatalf("AddInbound: %v", err)
}
if !strings.Contains(in.StreamSettings, `"service": "google-drive"`) {
t.Fatalf("stored streamSettings = %s, want service google-drive", in.StreamSettings)
}
}
// xray-core opens XDRIVE storage when the listener starts; a storage it refuses there stops every inbound.
func TestAddInboundRefusesXDriveStorageTheCoreCannotOpen(t *testing.T) {
tests := []struct {
name string
settings string
want string
}{
{"local without folder", `{"service":"local","remoteFolder":""}`, `XDRIVE needs a "remoteFolder"`},
{"google drive without folder", `{"service":"Google Drive","remoteFolder":" ","secrets":["id","secret","token"]}`, `XDRIVE needs a "remoteFolder"`},
{"google drive with two secrets", `{"service":"Google Drive","remoteFolder":"f","secrets":["id","secret"]}`, "Google Drive needs 3 secrets"},
{"google drive with an empty secret", `{"service":"Google Drive","remoteFolder":"f","secrets":["id","","token"]}`, "Google Drive needs 3 secrets"},
{"template without template", `{"service":"template","remoteFolder":"f"}`, `XDRIVE service "template" needs a "template" object`},
{"template that is empty", `{"service":"template","remoteFolder":"f","template":{}}`, "XDRIVE template needs put, get, list and delete operations"},
{"template that is not an object", `{"service":"template","remoteFolder":"f","template":"put"}`, "XDRIVE template is not a valid storage template"},
{"template without a delete op", `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u","namesRegex":"(.+)"}}}`, "XDRIVE template needs put, get, list and delete operations"},
{"template without namesRegex", `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u"},"delete":{"url":"u"}}}`, "XDRIVE template list needs a namesRegex"},
{"template namesRegex without a group", `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u","namesRegex":"name"},"delete":{"url":"u"}}}`, "XDRIVE template namesRegex needs one capture group"},
{"s3 without an s3 object", `{"service":"s3","remoteFolder":"f","secrets":["key","secret"]}`, `service "s3" needs an "s3" object`},
{"s3 endpoint that is not http", `{"service":"s3","remoteFolder":"f","secrets":["key","secret"],"s3":{"endpoint":"ftp://s3.example","region":"r","bucket":"b"}}`, "S3 endpoint must be an http(s) URL"},
{"s3 with one secret", `{"service":"s3","remoteFolder":"f","secrets":["key"],"s3":{"endpoint":"https://s3.example","region":"r","bucket":"b"}}`, "S3 needs AccessKeyID, SecretAccessKey"},
{"s3 virtual-hosted on an IP endpoint", `{"service":"s3","remoteFolder":"f","secrets":["key","secret"],"s3":{"endpoint":"https://10.0.0.1","region":"r","bucket":"b","pathStyle":false}}`, "requires a DNS endpoint"},
{"unknown service", `{"service":"Dropbox","remoteFolder":"f"}`, `unsupported XDRIVE service "Dropbox"`},
}
for i, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
setupBulkDB(t)
_, _, err := (&InboundService{}).AddInbound(xdriveInbound(18460+i, tc.settings))
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("AddInbound err = %v, want one containing %q", err, tc.want)
}
})
}
}
func TestAddInboundAcceptsACompleteXDriveTemplate(t *testing.T) {
setupBulkDB(t)
tmpl := `{"service":"template","template":{"put":{"url":"u"},"get":{"url":"u"},"list":{"url":"u","namesRegex":"\"name\":\"([^\"]+)\""},"delete":{"url":"u"}}}`
if _, _, err := (&InboundService{}).AddInbound(xdriveInbound(18480, tmpl)); err != nil {
t.Fatalf("AddInbound of a template xray-core can open: %v", err)
}
}
func TestAddInboundAcceptsACompleteXDriveS3Storage(t *testing.T) {
setupBulkDB(t)
s3 := `{"service":"s3","remoteFolder":"xdrive","secrets":["AKID","SECRET"],"s3":{"endpoint":"https://storage.yandexcloud.net","region":"ru-central1","bucket":"example-bucket"}}`
if _, _, err := (&InboundService{}).AddInbound(xdriveInbound(18481, s3)); err != nil {
t.Fatalf("AddInbound of an S3 storage xray-core can open: %v", err)
}
}
func TestSavePathsRefuseXDriveWithoutStorage(t *testing.T) {
const want = `XDRIVE needs a "remoteFolder"`
const empty = `{"service":"local","remoteFolder":""}`
tests := []struct {
name string
save func(t *testing.T) error
}{
{"update inbound", func(t *testing.T) error {
in, _, err := (&InboundService{}).AddInbound(xdriveInbound(18470, `{"service":"local","remoteFolder":"/tmp/drive"}`))
if err != nil {
t.Fatalf("AddInbound of a complete XDRIVE inbound: %v", err)
}
in.StreamSettings = xdriveInbound(18470, empty).StreamSettings
_, _, err = (&InboundService{}).UpdateInbound(in)
return err
}},
{"xray template inbound", func(t *testing.T) error {
return (&XraySettingService{}).SaveXraySetting(`{"inbounds":[{"tag":"drive","port":18471,"protocol":"vless",
"settings":{"clients":[],"decryption":"none"},"streamSettings":` + xdriveInbound(18471, empty).StreamSettings + `}],
"outbounds":[{"protocol":"freedom","tag":"direct"}]}`)
}},
{"xray template outbound", func(t *testing.T) error {
return (&XraySettingService{}).SaveXraySetting(`{"outbounds":[{"protocol":"freedom","tag":"direct"},
{"protocol":"vless","tag":"drive-out","settings":{"address":"drive.example","port":443,"id":"11111111-2222-4333-8444-555555555555","encryption":"none"},
"streamSettings":` + xdriveInbound(0, empty).StreamSettings + `}]}`)
}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
setupBulkDB(t)
if err := tc.save(t); err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("save err = %v, want one containing %q", err, want)
}
})
}
}