diff --git a/common/badversion/version.go b/common/badversion/version.go new file mode 100644 index 00000000..faf292fe --- /dev/null +++ b/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 +} diff --git a/common/badversion/version_json.go b/common/badversion/version_json.go new file mode 100644 index 00000000..0647b2bf --- /dev/null +++ b/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 +} diff --git a/common/badversion/version_test.go b/common/badversion/version_test.go new file mode 100644 index 00000000..9d6e8a7c --- /dev/null +++ b/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"))) +} diff --git a/constant/path.go b/constant/path.go index 7e423312..6ce1270c 100644 --- a/constant/path.go +++ b/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) { diff --git a/docs/configuration/experimental/index.md b/docs/configuration/experimental/index.md index 63e3b464..4aa61676 100644 --- a/docs/configuration/experimental/index.md +++ b/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) diff --git a/docs/configuration/experimental/index.zh.md b/docs/configuration/experimental/index.zh.md index 562cca0a..23d52b60 100644 --- a/docs/configuration/experimental/index.zh.md +++ b/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 的密钥(可选) diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index 9d377280..ceb92092 100644 --- a/experimental/clashapi/server.go +++ b/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") diff --git a/experimental/clashapi/server_resources.go b/experimental/clashapi/server_resources.go new file mode 100644 index 00000000..570e029e --- /dev/null +++ b/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 +} diff --git a/experimental/libbox/setup.go b/experimental/libbox/setup.go index c0466471..9a9f128a 100644 --- a/experimental/libbox/setup.go +++ b/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 } diff --git a/option/clash.go b/option/clash.go index 7f040ac0..1a02cdcb 100644 --- a/option/clash.go +++ b/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 {