Ver código fonte

clash api: download clash-dashboard if external-ui directory is empty

世界 2 anos atrás
pai
commit
750f87bb0a

+ 114 - 0
common/badversion/version.go

@@ -0,0 +1,114 @@
+package badversion
+
+import (
+	"strconv"
+	"strings"
+
+	F "github.com/sagernet/sing/common/format"
+)
+
+type Version struct {
+	Major                int
+	Minor                int
+	Patch                int
+	PreReleaseIdentifier string
+	PreReleaseVersion    int
+}
+
+func (v Version) After(anotherVersion Version) bool {
+	if v.Major > anotherVersion.Major {
+		return true
+	} else if v.Major < anotherVersion.Major {
+		return false
+	}
+	if v.Minor > anotherVersion.Minor {
+		return true
+	} else if v.Minor < anotherVersion.Minor {
+		return false
+	}
+	if v.Patch > anotherVersion.Patch {
+		return true
+	} else if v.Patch < anotherVersion.Patch {
+		return false
+	}
+	if v.PreReleaseIdentifier == "" && anotherVersion.PreReleaseIdentifier != "" {
+		return true
+	} else if v.PreReleaseIdentifier != "" && anotherVersion.PreReleaseIdentifier == "" {
+		return false
+	}
+	if v.PreReleaseIdentifier != "" && anotherVersion.PreReleaseIdentifier != "" {
+		if v.PreReleaseIdentifier == "beta" && anotherVersion.PreReleaseIdentifier == "alpha" {
+			return true
+		} else if v.PreReleaseIdentifier == "alpha" && anotherVersion.PreReleaseIdentifier == "beta" {
+			return false
+		}
+		if v.PreReleaseVersion > anotherVersion.PreReleaseVersion {
+			return true
+		} else if v.PreReleaseVersion < anotherVersion.PreReleaseVersion {
+			return false
+		}
+	}
+	return false
+}
+
+func (v Version) String() string {
+	version := F.ToString(v.Major, ".", v.Minor, ".", v.Patch)
+	if v.PreReleaseIdentifier != "" {
+		version = F.ToString(version, "-", v.PreReleaseIdentifier, ".", v.PreReleaseVersion)
+	}
+	return version
+}
+
+func (v Version) BadString() string {
+	version := F.ToString(v.Major, ".", v.Minor)
+	if v.Patch > 0 {
+		version = F.ToString(version, ".", v.Patch)
+	}
+	if v.PreReleaseIdentifier != "" {
+		version = F.ToString(version, "-", v.PreReleaseIdentifier)
+		if v.PreReleaseVersion > 0 {
+			version = F.ToString(version, v.PreReleaseVersion)
+		}
+	}
+	return version
+}
+
+func Parse(versionName string) (version Version) {
+	if strings.HasPrefix(versionName, "v") {
+		versionName = versionName[1:]
+	}
+	if strings.Contains(versionName, "-") {
+		parts := strings.Split(versionName, "-")
+		versionName = parts[0]
+		identifier := parts[1]
+		if strings.Contains(identifier, ".") {
+			identifierParts := strings.Split(identifier, ".")
+			version.PreReleaseIdentifier = identifierParts[0]
+			if len(identifierParts) >= 2 {
+				version.PreReleaseVersion, _ = strconv.Atoi(identifierParts[1])
+			}
+		} else {
+			if strings.HasPrefix(identifier, "alpha") {
+				version.PreReleaseIdentifier = "alpha"
+				version.PreReleaseVersion, _ = strconv.Atoi(identifier[5:])
+			} else if strings.HasPrefix(identifier, "beta") {
+				version.PreReleaseIdentifier = "beta"
+				version.PreReleaseVersion, _ = strconv.Atoi(identifier[4:])
+			} else {
+				version.PreReleaseIdentifier = identifier
+			}
+		}
+	}
+	versionElements := strings.Split(versionName, ".")
+	versionLen := len(versionElements)
+	if versionLen >= 1 {
+		version.Major, _ = strconv.Atoi(versionElements[0])
+	}
+	if versionLen >= 2 {
+		version.Minor, _ = strconv.Atoi(versionElements[1])
+	}
+	if versionLen >= 3 {
+		version.Patch, _ = strconv.Atoi(versionElements[2])
+	}
+	return
+}

+ 17 - 0
common/badversion/version_json.go

@@ -0,0 +1,17 @@
+package badversion
+
+import "github.com/sagernet/sing-box/common/json"
+
+func (v Version) MarshalJSON() ([]byte, error) {
+	return json.Marshal(v.String())
+}
+
+func (v *Version) UnmarshalJSON(data []byte) error {
+	var version string
+	err := json.Unmarshal(data, &version)
+	if err != nil {
+		return err
+	}
+	*v = Parse(version)
+	return nil
+}

+ 18 - 0
common/badversion/version_test.go

@@ -0,0 +1,18 @@
+package badversion
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestCompareVersion(t *testing.T) {
+	t.Parallel()
+	require.Equal(t, "1.3.0-beta.1", Parse("v1.3.0-beta1").String())
+	require.Equal(t, "1.3-beta1", Parse("v1.3.0-beta.1").BadString())
+	require.True(t, Parse("1.3.0").After(Parse("1.3-beta1")))
+	require.True(t, Parse("1.3.0").After(Parse("1.3.0-beta1")))
+	require.True(t, Parse("1.3.0-beta1").After(Parse("1.3.0-alpha1")))
+	require.True(t, Parse("1.3.1").After(Parse("1.3.0")))
+	require.True(t, Parse("1.4").After(Parse("1.3")))
+}

+ 12 - 0
constant/path.go

@@ -12,6 +12,7 @@ const dirName = "sing-box"
 
 var (
 	basePath      string
+	tempPath      string
 	resourcePaths []string
 )
 
@@ -22,10 +23,21 @@ func BasePath(name string) string {
 	return filepath.Join(basePath, name)
 }
 
+func CreateTemp(pattern string) (*os.File, error) {
+	if tempPath == "" {
+		tempPath = os.TempDir()
+	}
+	return os.CreateTemp(tempPath, pattern)
+}
+
 func SetBasePath(path string) {
 	basePath = path
 }
 
+func SetTempPath(path string) {
+	tempPath = path
+}
+
 func FindPath(name string) (string, bool) {
 	name = os.ExpandEnv(name)
 	if rw.FileExists(name) {

+ 14 - 0
docs/configuration/experimental/index.md

@@ -8,6 +8,8 @@
     "clash_api": {
       "external_controller": "127.0.0.1:9090",
       "external_ui": "folder",
+      "external_ui_download_url": "",
+      "external_ui_download_detour": "",
       "secret": "",
       "default_mode": "rule",
       "store_selected": false,
@@ -53,6 +55,18 @@ A relative path to the configuration directory or an absolute path to a
 directory in which you put some static web resource. sing-box will then
 serve it at `http://{{external-controller}}/ui`.
 
+#### external_ui_download_url
+
+ZIP download URL for the external UI, will be used if the specified `external_ui` directory is empty.
+
+`https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip` will be used if empty.
+
+#### external_ui_download_detour
+
+The tag of the outbound to download the external UI.
+
+Default outbound will be used if empty.
+
 #### secret
 
 Secret for the RESTful API (optional)

+ 14 - 0
docs/configuration/experimental/index.zh.md

@@ -8,6 +8,8 @@
     "clash_api": {
       "external_controller": "127.0.0.1:9090",
       "external_ui": "folder",
+      "external_ui_download_url": "",
+      "external_ui_download_detour": "",
       "secret": "",
       "default_mode": "rule",
       "store_selected": false,
@@ -51,6 +53,18 @@ RESTful web API 监听地址。如果为空,则禁用 Clash API。
 
 到静态网页资源目录的相对路径或绝对路径。sing-box 会在 `http://{{external-controller}}/ui` 下提供它。
 
+#### external_ui_download_url
+
+静态网页资源的 ZIP 下载 URL,如果指定的 `external_ui` 目录为空,将使用。
+
+默认使用 `https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip`。
+
+#### external_ui_download_detour
+
+用于下载静态网页资源的出站的标签。
+
+如果为空,将使用默认出站。
+
 #### secret
 
 RESTful API 的密钥(可选)

+ 14 - 6
experimental/clashapi/server.go

@@ -47,6 +47,10 @@ type Server struct {
 	storeFakeIP    bool
 	cacheFilePath  string
 	cacheFile      adapter.ClashCacheFile
+
+	externalUI               string
+	externalUIDownloadURL    string
+	externalUIDownloadDetour string
 }
 
 func NewServer(router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) {
@@ -59,11 +63,13 @@ func NewServer(router adapter.Router, logFactory log.ObservableFactory, options
 			Addr:    options.ExternalController,
 			Handler: chiRouter,
 		},
-		trafficManager: trafficManager,
-		urlTestHistory: urltest.NewHistoryStorage(),
-		mode:           strings.ToLower(options.DefaultMode),
-		storeSelected:  options.StoreSelected,
-		storeFakeIP:    options.StoreFakeIP,
+		trafficManager:           trafficManager,
+		urlTestHistory:           urltest.NewHistoryStorage(),
+		mode:                     strings.ToLower(options.DefaultMode),
+		storeSelected:            options.StoreSelected,
+		storeFakeIP:              options.StoreFakeIP,
+		externalUIDownloadURL:    options.ExternalUIDownloadURL,
+		externalUIDownloadDetour: options.ExternalUIDownloadDetour,
 	}
 	if server.mode == "" {
 		server.mode = "rule"
@@ -105,8 +111,9 @@ func NewServer(router adapter.Router, logFactory log.ObservableFactory, options
 		r.Mount("/dns", dnsRouter(router))
 	})
 	if options.ExternalUI != "" {
+		server.externalUI = C.BasePath(os.ExpandEnv(options.ExternalUI))
 		chiRouter.Group(func(r chi.Router) {
-			fs := http.StripPrefix("/ui", http.FileServer(http.Dir(C.BasePath(os.ExpandEnv(options.ExternalUI)))))
+			fs := http.StripPrefix("/ui", http.FileServer(http.Dir(server.externalUI)))
 			r.Get("/ui", http.RedirectHandler("/ui/", http.StatusTemporaryRedirect).ServeHTTP)
 			r.Get("/ui/*", func(w http.ResponseWriter, r *http.Request) {
 				fs.ServeHTTP(w, r)
@@ -128,6 +135,7 @@ func (s *Server) PreStart() error {
 }
 
 func (s *Server) Start() error {
+	s.checkAndDownloadExternalUI()
 	listener, err := net.Listen("tcp", s.httpServer.Addr)
 	if err != nil {
 		return E.Cause(err, "external controller listen error")

+ 164 - 0
experimental/clashapi/server_resources.go

@@ -0,0 +1,164 @@
+package clashapi
+
+import (
+	"archive/zip"
+	"context"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/sagernet/sing-box/adapter"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing/common"
+	E "github.com/sagernet/sing/common/exceptions"
+	M "github.com/sagernet/sing/common/metadata"
+	N "github.com/sagernet/sing/common/network"
+)
+
+func (s *Server) checkAndDownloadExternalUI() {
+	if s.externalUI == "" {
+		return
+	}
+	entries, err := os.ReadDir(s.externalUI)
+	if err != nil {
+		os.MkdirAll(s.externalUI, 0o755)
+	}
+	if len(entries) == 0 {
+		err = s.downloadExternalUI()
+		if err != nil {
+			s.logger.Error("download external ui error: ", err)
+		}
+	}
+}
+
+func (s *Server) downloadExternalUI() error {
+	var downloadURL string
+	if s.externalUIDownloadURL != "" {
+		downloadURL = s.externalUIDownloadURL
+	} else {
+		downloadURL = "https://github.com/Dreamacro/clash-dashboard/archive/refs/heads/gh-pages.zip"
+	}
+	s.logger.Info("downloading external ui")
+	var detour adapter.Outbound
+	if s.externalUIDownloadDetour != "" {
+		outbound, loaded := s.router.Outbound(s.externalUIDownloadDetour)
+		if !loaded {
+			return E.New("detour outbound not found: ", s.externalUIDownloadDetour)
+		}
+		detour = outbound
+	} else {
+		detour = s.router.DefaultOutbound(N.NetworkTCP)
+	}
+	httpClient := &http.Client{
+		Transport: &http.Transport{
+			ForceAttemptHTTP2:   true,
+			TLSHandshakeTimeout: 5 * time.Second,
+			DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+				return detour.DialContext(ctx, network, M.ParseSocksaddr(addr))
+			},
+		},
+	}
+	defer httpClient.CloseIdleConnections()
+	response, err := httpClient.Get(downloadURL)
+	if err != nil {
+		return err
+	}
+	defer response.Body.Close()
+	if response.StatusCode != http.StatusOK {
+		return E.New("download external ui failed: ", response.Status)
+	}
+	err = s.downloadZIP(filepath.Base(downloadURL), response.Body, s.externalUI)
+	if err != nil {
+		removeAllInDirectory(s.externalUI)
+	}
+	return err
+}
+
+func (s *Server) downloadZIP(name string, body io.Reader, output string) error {
+	tempFile, err := C.CreateTemp(name)
+	if err != nil {
+		return err
+	}
+	defer os.Remove(tempFile.Name())
+	_, err = io.Copy(tempFile, body)
+	tempFile.Close()
+	if err != nil {
+		return err
+	}
+	reader, err := zip.OpenReader(tempFile.Name())
+	if err != nil {
+		return err
+	}
+	defer reader.Close()
+	trimDir := zipIsInSingleDirectory(reader.File)
+	for _, file := range reader.File {
+		if file.FileInfo().IsDir() {
+			continue
+		}
+		pathElements := strings.Split(file.Name, "/")
+		if trimDir {
+			pathElements = pathElements[1:]
+		}
+		saveDirectory := output
+		if len(pathElements) > 1 {
+			saveDirectory = filepath.Join(saveDirectory, filepath.Join(pathElements[:len(pathElements)-1]...))
+		}
+		err = os.MkdirAll(saveDirectory, 0o755)
+		if err != nil {
+			return err
+		}
+		savePath := filepath.Join(saveDirectory, pathElements[len(pathElements)-1])
+		err = downloadZIPEntry(file, savePath)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func downloadZIPEntry(zipFile *zip.File, savePath string) error {
+	saveFile, err := os.Create(savePath)
+	if err != nil {
+		return err
+	}
+	defer saveFile.Close()
+	reader, err := zipFile.Open()
+	if err != nil {
+		return err
+	}
+	defer reader.Close()
+	return common.Error(io.Copy(saveFile, reader))
+}
+
+func removeAllInDirectory(directory string) {
+	dirEntries, err := os.ReadDir(directory)
+	if err != nil {
+		return
+	}
+	for _, dirEntry := range dirEntries {
+		os.RemoveAll(filepath.Join(directory, dirEntry.Name()))
+	}
+}
+
+func zipIsInSingleDirectory(files []*zip.File) bool {
+	var singleDirectory string
+	for _, file := range files {
+		if file.FileInfo().IsDir() {
+			continue
+		}
+		pathElements := strings.Split(file.Name, "/")
+		if len(pathElements) == 0 {
+			return false
+		}
+		if singleDirectory == "" {
+			singleDirectory = pathElements[0]
+		} else if singleDirectory != pathElements[0] {
+			return false
+		}
+	}
+	return true
+}

+ 4 - 0
experimental/libbox/setup.go

@@ -10,6 +10,10 @@ func SetBasePath(path string) {
 	C.SetBasePath(path)
 }
 
+func SetTempPath(path string) {
+	C.SetTempPath(path)
+}
+
 func Version() string {
 	return C.Version
 }

+ 9 - 7
option/clash.go

@@ -1,13 +1,15 @@
 package option
 
 type ClashAPIOptions struct {
-	ExternalController string `json:"external_controller,omitempty"`
-	ExternalUI         string `json:"external_ui,omitempty"`
-	Secret             string `json:"secret,omitempty"`
-	DefaultMode        string `json:"default_mode,omitempty"`
-	StoreSelected      bool   `json:"store_selected,omitempty"`
-	StoreFakeIP        bool   `json:"store_fakeip,omitempty"`
-	CacheFile          string `json:"cache_file,omitempty"`
+	ExternalController       string `json:"external_controller,omitempty"`
+	ExternalUI               string `json:"external_ui,omitempty"`
+	ExternalUIDownloadURL    string `json:"external_ui_download_url,omitempty"`
+	ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty"`
+	Secret                   string `json:"secret,omitempty"`
+	DefaultMode              string `json:"default_mode,omitempty"`
+	StoreSelected            bool   `json:"store_selected,omitempty"`
+	StoreFakeIP              bool   `json:"store_fakeip,omitempty"`
+	CacheFile                string `json:"cache_file,omitempty"`
 }
 
 type SelectorOutboundOptions struct {