434 lines
15 KiB
Go
434 lines
15 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
transportcfgpkg "selective-vpn-api/app/transportcfg"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestSingBoxProfileFlowRenderApplyRollbackHistory(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
oldStatePath := singBoxProfilesStatePath
|
|
oldSecretsDir := singBoxSecretsRootDir
|
|
oldRenderedDir := singBoxRenderedRootDir
|
|
oldHistoryDir := singBoxHistoryRootDir
|
|
oldAppliedDir := singBoxAppliedRootDir
|
|
singBoxProfilesStatePath = filepath.Join(tmp, "transport", "singbox-profiles.json")
|
|
singBoxSecretsRootDir = filepath.Join(tmp, "transport", "secrets", "singbox")
|
|
singBoxRenderedRootDir = filepath.Join(tmp, "transport", "singbox-rendered")
|
|
singBoxHistoryRootDir = filepath.Join(tmp, "transport", "singbox-history")
|
|
singBoxAppliedRootDir = filepath.Join(tmp, "transport", "singbox-applied")
|
|
t.Cleanup(func() {
|
|
singBoxProfilesStatePath = oldStatePath
|
|
singBoxSecretsRootDir = oldSecretsDir
|
|
singBoxRenderedRootDir = oldRenderedDir
|
|
singBoxHistoryRootDir = oldHistoryDir
|
|
singBoxAppliedRootDir = oldAppliedDir
|
|
})
|
|
|
|
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles", strings.NewReader(`{
|
|
"id":"e5-flow",
|
|
"name":"E5 Flow",
|
|
"mode":"typed",
|
|
"protocol":"vless",
|
|
"typed":{"server":"a.example.org","port":443}
|
|
}`))
|
|
createRec := httptest.NewRecorder()
|
|
handleTransportSingBoxProfiles(createRec, createReq)
|
|
if createRec.Code != http.StatusOK {
|
|
t.Fatalf("create status=%d body=%s", createRec.Code, createRec.Body.String())
|
|
}
|
|
var createResp SingBoxProfilesResponse
|
|
if err := json.Unmarshal(createRec.Body.Bytes(), &createResp); err != nil {
|
|
t.Fatalf("decode create response: %v", err)
|
|
}
|
|
if !createResp.OK || createResp.Item == nil {
|
|
t.Fatalf("create failed: %#v", createResp)
|
|
}
|
|
profileID := createResp.Item.ID
|
|
baseRev := createResp.Item.ProfileRevision
|
|
|
|
validateReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/validate", strings.NewReader(`{"check_binary":false}`))
|
|
validateRec := httptest.NewRecorder()
|
|
handleTransportSingBoxProfileByID(validateRec, validateReq)
|
|
if validateRec.Code != http.StatusOK {
|
|
t.Fatalf("validate status=%d body=%s", validateRec.Code, validateRec.Body.String())
|
|
}
|
|
var validateResp SingBoxProfileValidateResponse
|
|
if err := json.Unmarshal(validateRec.Body.Bytes(), &validateResp); err != nil {
|
|
t.Fatalf("decode validate response: %v", err)
|
|
}
|
|
if !validateResp.OK || !validateResp.Valid {
|
|
t.Fatalf("validate must be valid: %#v", validateResp)
|
|
}
|
|
|
|
renderReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/render", strings.NewReader(`{"check_binary":false}`))
|
|
renderRec := httptest.NewRecorder()
|
|
handleTransportSingBoxProfileByID(renderRec, renderReq)
|
|
if renderRec.Code != http.StatusOK {
|
|
t.Fatalf("render status=%d body=%s", renderRec.Code, renderRec.Body.String())
|
|
}
|
|
var renderResp SingBoxProfileRenderResponse
|
|
if err := json.Unmarshal(renderRec.Body.Bytes(), &renderResp); err != nil {
|
|
t.Fatalf("decode render response: %v", err)
|
|
}
|
|
if !renderResp.OK || !renderResp.Valid {
|
|
t.Fatalf("render must be valid: %#v", renderResp)
|
|
}
|
|
if _, err := os.Stat(renderResp.RenderPath); err != nil {
|
|
t.Fatalf("rendered file missing: %v", err)
|
|
}
|
|
|
|
targetCfg := filepath.Join(tmp, "runtime", "singbox.json")
|
|
applyReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/apply", strings.NewReader(`{
|
|
"config_path":"`+targetCfg+`",
|
|
"skip_runtime":true,
|
|
"check_binary":false
|
|
}`))
|
|
applyRec := httptest.NewRecorder()
|
|
handleTransportSingBoxProfileByID(applyRec, applyReq)
|
|
if applyRec.Code != http.StatusOK {
|
|
t.Fatalf("apply status=%d body=%s", applyRec.Code, applyRec.Body.String())
|
|
}
|
|
var applyResp SingBoxProfileApplyResponse
|
|
if err := json.Unmarshal(applyRec.Body.Bytes(), &applyResp); err != nil {
|
|
t.Fatalf("decode apply response: %v", err)
|
|
}
|
|
if !applyResp.OK {
|
|
t.Fatalf("apply failed: %#v", applyResp)
|
|
}
|
|
cfgA, err := os.ReadFile(targetCfg)
|
|
if err != nil {
|
|
t.Fatalf("read applied config A: %v", err)
|
|
}
|
|
if !strings.Contains(string(cfgA), `"a.example.org"`) {
|
|
t.Fatalf("applied config must contain first server")
|
|
}
|
|
|
|
patchReq := httptest.NewRequest(http.MethodPatch, "/api/v1/transport/singbox/profiles/"+profileID, strings.NewReader(`{
|
|
"base_revision":`+itoa(baseRev)+`,
|
|
"typed":{"server":"b.example.org","port":443}
|
|
}`))
|
|
patchRec := httptest.NewRecorder()
|
|
handleTransportSingBoxProfileByID(patchRec, patchReq)
|
|
if patchRec.Code != http.StatusOK {
|
|
t.Fatalf("patch status=%d body=%s", patchRec.Code, patchRec.Body.String())
|
|
}
|
|
var patchResp SingBoxProfilesResponse
|
|
if err := json.Unmarshal(patchRec.Body.Bytes(), &patchResp); err != nil {
|
|
t.Fatalf("decode patch response: %v", err)
|
|
}
|
|
if !patchResp.OK || patchResp.Item == nil {
|
|
t.Fatalf("patch failed: %#v", patchResp)
|
|
}
|
|
|
|
apply2Req := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/apply", strings.NewReader(`{
|
|
"config_path":"`+targetCfg+`",
|
|
"skip_runtime":true,
|
|
"check_binary":false
|
|
}`))
|
|
apply2Rec := httptest.NewRecorder()
|
|
handleTransportSingBoxProfileByID(apply2Rec, apply2Req)
|
|
if apply2Rec.Code != http.StatusOK {
|
|
t.Fatalf("apply2 status=%d body=%s", apply2Rec.Code, apply2Rec.Body.String())
|
|
}
|
|
var apply2Resp SingBoxProfileApplyResponse
|
|
if err := json.Unmarshal(apply2Rec.Body.Bytes(), &apply2Resp); err != nil {
|
|
t.Fatalf("decode apply2 response: %v", err)
|
|
}
|
|
if !apply2Resp.OK {
|
|
t.Fatalf("apply2 failed: %#v", apply2Resp)
|
|
}
|
|
cfgB, err := os.ReadFile(targetCfg)
|
|
if err != nil {
|
|
t.Fatalf("read applied config B: %v", err)
|
|
}
|
|
if !strings.Contains(string(cfgB), `"b.example.org"`) {
|
|
t.Fatalf("applied config must contain second server")
|
|
}
|
|
|
|
rollbackReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/"+profileID+"/rollback", strings.NewReader(`{
|
|
"config_path":"`+targetCfg+`",
|
|
"skip_runtime":true
|
|
}`))
|
|
rollbackRec := httptest.NewRecorder()
|
|
handleTransportSingBoxProfileByID(rollbackRec, rollbackReq)
|
|
if rollbackRec.Code != http.StatusOK {
|
|
t.Fatalf("rollback status=%d body=%s", rollbackRec.Code, rollbackRec.Body.String())
|
|
}
|
|
var rollbackResp SingBoxProfileRollbackResponse
|
|
if err := json.Unmarshal(rollbackRec.Body.Bytes(), &rollbackResp); err != nil {
|
|
t.Fatalf("decode rollback response: %v", err)
|
|
}
|
|
if !rollbackResp.OK {
|
|
t.Fatalf("rollback failed: %#v", rollbackResp)
|
|
}
|
|
cfgAfterRollback, err := os.ReadFile(targetCfg)
|
|
if err != nil {
|
|
t.Fatalf("read config after rollback: %v", err)
|
|
}
|
|
if string(cfgAfterRollback) != string(cfgA) {
|
|
t.Fatalf("rollback must restore first config")
|
|
}
|
|
|
|
historyReq := httptest.NewRequest(http.MethodGet, "/api/v1/transport/singbox/profiles/"+profileID+"/history?limit=20", nil)
|
|
historyRec := httptest.NewRecorder()
|
|
handleTransportSingBoxProfileByID(historyRec, historyReq)
|
|
if historyRec.Code != http.StatusOK {
|
|
t.Fatalf("history status=%d body=%s", historyRec.Code, historyRec.Body.String())
|
|
}
|
|
var historyResp SingBoxProfileHistoryResponse
|
|
if err := json.Unmarshal(historyRec.Body.Bytes(), &historyResp); err != nil {
|
|
t.Fatalf("decode history response: %v", err)
|
|
}
|
|
if !historyResp.OK || historyResp.Count == 0 {
|
|
t.Fatalf("history must return items: %#v", historyResp)
|
|
}
|
|
hasRollback := false
|
|
for _, it := range historyResp.Items {
|
|
if it.Action == "rollback" && it.Status == "success" {
|
|
hasRollback = true
|
|
break
|
|
}
|
|
}
|
|
if !hasRollback {
|
|
t.Fatalf("history must contain successful rollback action")
|
|
}
|
|
}
|
|
|
|
func TestSingBoxProfileValidateFail(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
oldStatePath := singBoxProfilesStatePath
|
|
oldSecretsDir := singBoxSecretsRootDir
|
|
oldRenderedDir := singBoxRenderedRootDir
|
|
oldHistoryDir := singBoxHistoryRootDir
|
|
oldAppliedDir := singBoxAppliedRootDir
|
|
singBoxProfilesStatePath = filepath.Join(tmp, "transport", "singbox-profiles.json")
|
|
singBoxSecretsRootDir = filepath.Join(tmp, "transport", "secrets", "singbox")
|
|
singBoxRenderedRootDir = filepath.Join(tmp, "transport", "singbox-rendered")
|
|
singBoxHistoryRootDir = filepath.Join(tmp, "transport", "singbox-history")
|
|
singBoxAppliedRootDir = filepath.Join(tmp, "transport", "singbox-applied")
|
|
t.Cleanup(func() {
|
|
singBoxProfilesStatePath = oldStatePath
|
|
singBoxSecretsRootDir = oldSecretsDir
|
|
singBoxRenderedRootDir = oldRenderedDir
|
|
singBoxHistoryRootDir = oldHistoryDir
|
|
singBoxAppliedRootDir = oldAppliedDir
|
|
})
|
|
|
|
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles", strings.NewReader(`{
|
|
"id":"invalid-typed",
|
|
"mode":"typed",
|
|
"typed":{"port":443}
|
|
}`))
|
|
createRec := httptest.NewRecorder()
|
|
handleTransportSingBoxProfiles(createRec, createReq)
|
|
if createRec.Code != http.StatusOK {
|
|
t.Fatalf("create status=%d body=%s", createRec.Code, createRec.Body.String())
|
|
}
|
|
|
|
validateReq := httptest.NewRequest(http.MethodPost, "/api/v1/transport/singbox/profiles/invalid-typed/validate", strings.NewReader(`{"check_binary":false}`))
|
|
validateRec := httptest.NewRecorder()
|
|
handleTransportSingBoxProfileByID(validateRec, validateReq)
|
|
if validateRec.Code != http.StatusOK {
|
|
t.Fatalf("validate status=%d body=%s", validateRec.Code, validateRec.Body.String())
|
|
}
|
|
var resp SingBoxProfileValidateResponse
|
|
if err := json.Unmarshal(validateRec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.OK || resp.Valid {
|
|
t.Fatalf("validate must fail for incomplete typed profile: %#v", resp)
|
|
}
|
|
if len(resp.Errors) == 0 {
|
|
t.Fatalf("validation errors expected")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeSingBoxRenderedConfigDropsLegacyPacketEncodingNone(t *testing.T) {
|
|
cfg := map[string]any{
|
|
"outbounds": []any{
|
|
map[string]any{
|
|
"type": "vless",
|
|
"packet_encoding": "none",
|
|
},
|
|
map[string]any{
|
|
"type": "vless",
|
|
"packet_encoding": "xudp",
|
|
},
|
|
map[string]any{
|
|
"type": "trojan",
|
|
"packet_encoding": "none",
|
|
},
|
|
},
|
|
}
|
|
|
|
out := transportcfgpkg.NormalizeSingBoxRenderedConfig(cfg, asString)
|
|
rows, _ := out["outbounds"].([]any)
|
|
if len(rows) != 3 {
|
|
t.Fatalf("unexpected outbounds len: %d", len(rows))
|
|
}
|
|
vlessLegacy, _ := rows[0].(map[string]any)
|
|
if _, ok := vlessLegacy["packet_encoding"]; ok {
|
|
t.Fatalf("legacy vless packet_encoding=none must be removed: %#v", vlessLegacy)
|
|
}
|
|
vlessXUDP, _ := rows[1].(map[string]any)
|
|
if got := strings.TrimSpace(asString(vlessXUDP["packet_encoding"])); got != "xudp" {
|
|
t.Fatalf("xudp must be preserved, got=%q", got)
|
|
}
|
|
trojan, _ := rows[2].(map[string]any)
|
|
if got := strings.TrimSpace(asString(trojan["packet_encoding"])); got != "none" {
|
|
t.Fatalf("non-vless packet_encoding must be unchanged, got=%q", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeSingBoxRenderedConfigDropsVLESSFlowNone(t *testing.T) {
|
|
cfg := map[string]any{
|
|
"outbounds": []any{
|
|
map[string]any{
|
|
"type": "vless",
|
|
"flow": "none",
|
|
},
|
|
map[string]any{
|
|
"type": "vless",
|
|
"flow": "xtls-rprx-vision",
|
|
},
|
|
map[string]any{
|
|
"type": "trojan",
|
|
"flow": "none",
|
|
},
|
|
},
|
|
}
|
|
|
|
out := transportcfgpkg.NormalizeSingBoxRenderedConfig(cfg, asString)
|
|
rows, _ := out["outbounds"].([]any)
|
|
if len(rows) != 3 {
|
|
t.Fatalf("unexpected outbounds len: %d", len(rows))
|
|
}
|
|
vlessNone, _ := rows[0].(map[string]any)
|
|
if _, ok := vlessNone["flow"]; ok {
|
|
t.Fatalf("legacy vless flow=none must be removed: %#v", vlessNone)
|
|
}
|
|
vlessVision, _ := rows[1].(map[string]any)
|
|
if got := strings.TrimSpace(asString(vlessVision["flow"])); got != "xtls-rprx-vision" {
|
|
t.Fatalf("vless valid flow must be preserved, got=%q", got)
|
|
}
|
|
trojan, _ := rows[2].(map[string]any)
|
|
if got := strings.TrimSpace(asString(trojan["flow"])); got != "none" {
|
|
t.Fatalf("non-vless flow must be unchanged, got=%q", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareSingBoxClientProfileWritesConfigForLinkedClient(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
oldStatePath := singBoxProfilesStatePath
|
|
oldRenderedDir := singBoxRenderedRootDir
|
|
oldAppliedDir := singBoxAppliedRootDir
|
|
singBoxProfilesStatePath = filepath.Join(tmp, "transport", "singbox-profiles.json")
|
|
singBoxRenderedRootDir = filepath.Join(tmp, "transport", "singbox-rendered")
|
|
singBoxAppliedRootDir = filepath.Join(tmp, "transport", "singbox-applied")
|
|
t.Cleanup(func() {
|
|
singBoxProfilesStatePath = oldStatePath
|
|
singBoxRenderedRootDir = oldRenderedDir
|
|
singBoxAppliedRootDir = oldAppliedDir
|
|
})
|
|
|
|
profile := SingBoxProfile{
|
|
ID: "c-sg",
|
|
Name: "Linked SG",
|
|
Mode: SingBoxProfileModeRaw,
|
|
Protocol: "vless",
|
|
Enabled: true,
|
|
SchemaVersion: 1,
|
|
RawConfig: map[string]any{
|
|
"inbounds": []any{
|
|
map[string]any{
|
|
"type": "socks",
|
|
"tag": "socks-in",
|
|
"listen": "127.0.0.1",
|
|
"listen_port": 10808,
|
|
},
|
|
},
|
|
"outbounds": []any{
|
|
map[string]any{
|
|
"type": "vless",
|
|
"tag": "proxy",
|
|
"server": "example.com",
|
|
"server_port": 443,
|
|
"uuid": "11111111-1111-1111-1111-111111111111",
|
|
"packet_encoding": "none",
|
|
},
|
|
},
|
|
},
|
|
Meta: map[string]any{
|
|
"client_id": "c-sg",
|
|
},
|
|
ProfileRevision: 1,
|
|
}
|
|
state := singBoxProfilesState{
|
|
Version: singBoxProfilesStateVersion,
|
|
Items: []SingBoxProfile{profile},
|
|
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Revision: 1,
|
|
}
|
|
if err := saveSingBoxProfilesState(state); err != nil {
|
|
t.Fatalf("save profile state: %v", err)
|
|
}
|
|
|
|
configPath := filepath.Join(tmp, "runtime", "c-sg.json")
|
|
client := TransportClient{
|
|
ID: "c-sg",
|
|
Kind: TransportClientSingBox,
|
|
Config: map[string]any{
|
|
"config_path": configPath,
|
|
},
|
|
}
|
|
|
|
res := prepareSingBoxClientProfile(client, false)
|
|
if !res.OK {
|
|
t.Fatalf("prepare failed: %#v", res)
|
|
}
|
|
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("read prepared config: %v", err)
|
|
}
|
|
text := string(data)
|
|
if strings.Contains(text, "\"packet_encoding\": \"none\"") {
|
|
t.Fatalf("prepared config must not contain legacy packet_encoding=none: %s", text)
|
|
}
|
|
if !strings.Contains(text, "\"server\": \"example.com\"") {
|
|
t.Fatalf("prepared config must contain outbound server: %s", text)
|
|
}
|
|
}
|
|
|
|
func TestFindSingBoxProfileIndexForClient(t *testing.T) {
|
|
st := singBoxProfilesState{
|
|
Items: []SingBoxProfile{
|
|
{ID: "p-1", ProfileRevision: 1, Meta: map[string]any{"client_id": "c-1"}},
|
|
{ID: "p-2", ProfileRevision: 3, Meta: map[string]any{"client_id": "c-1"}},
|
|
{ID: "c-2", ProfileRevision: 1},
|
|
},
|
|
}
|
|
if idx := findSingBoxProfileIndexForClient(st, "c-1"); idx != 1 {
|
|
t.Fatalf("expected highest revision linked profile idx=1, got=%d", idx)
|
|
}
|
|
if idx := findSingBoxProfileIndexForClient(st, "c-2"); idx != 2 {
|
|
t.Fatalf("expected id fallback idx=2, got=%d", idx)
|
|
}
|
|
if idx := findSingBoxProfileIndexForClient(st, "missing"); idx != -1 {
|
|
t.Fatalf("expected missing=-1, got=%d", idx)
|
|
}
|
|
}
|
|
|
|
func itoa(n int64) string {
|
|
return strconv.FormatInt(n, 10)
|
|
}
|