diff --git a/docs/configuration/rule-set/index.md b/docs/configuration/rule-set/index.md index c6d4a17c0..108e2c109 100644 --- a/docs/configuration/rule-set/index.md +++ b/docs/configuration/rule-set/index.md @@ -119,7 +119,16 @@ HTTP Client for downloading rule-set. See [HTTP Client Fields](/configuration/shared/http-client/) for details. -Default transport will be used if empty. +When empty, the default HTTP client is used: the one named by +[`default_http_client`](/configuration/route/#default_http_client), or the first top-level +`http_clients` entry when `default_http_client` is empty. + +!!! failure "Implicit default deprecated in sing-box 1.14.0" + + When neither `http_clients` nor `default_http_client` is configured, an implicit HTTP + client connecting through the default outbound is used. This implicit default is + deprecated in sing-box 1.14.0 and will be removed in sing-box 1.16.0; define + `http_clients` instead. #### update_interval diff --git a/docs/configuration/rule-set/index.zh.md b/docs/configuration/rule-set/index.zh.md index 2cd6f9379..342dc9abc 100644 --- a/docs/configuration/rule-set/index.zh.md +++ b/docs/configuration/rule-set/index.zh.md @@ -119,7 +119,13 @@ 参阅 [HTTP 客户端字段](/zh/configuration/shared/http-client/) 了解详情。 -如果为空,将使用默认传输。 +留空时使用默认 HTTP 客户端:即由 [`default_http_client`](/zh/configuration/route/#default_http_client) +指定的客户端,或当 `default_http_client` 为空时使用顶级 `http_clients` 的第一项。 + +!!! failure "隐式默认已在 sing-box 1.14.0 废弃" + + 当 `http_clients` 与 `default_http_client` 均未配置时,将使用通过默认出站连接的隐式 HTTP 客户端。 + 该隐式默认已在 sing-box 1.14.0 废弃,并将在 sing-box 1.16.0 移除;请改为定义 `http_clients`。 #### update_interval diff --git a/docs/configuration/service/api.md b/docs/configuration/service/api.md index e24478b22..7c06ff10a 100644 --- a/docs/configuration/service/api.md +++ b/docs/configuration/service/api.md @@ -23,6 +23,13 @@ for bidirectional streaming methods. "secret": "", "access_control_allow_origin": [], "access_control_allow_private_network": false, + "dashboard": { + "enabled": true, + "path": "", + "download_url": "", + "http_client": "", // or {} + "update_interval": "" + }, "tls": {} } ``` @@ -49,6 +56,59 @@ CORS allowed origins, `*` will be used if empty. Allow access from private network. +#### dashboard + +Web dashboard downloaded and served over the API listener at `/dashboard/`; other browser +requests are redirected to it. + +!!! info "" + + The object can be replaced with a boolean value (equivalent to `{ "enabled": }`), + or with a string path (equivalent to `{ "enabled": true, "path": "" }`). + +##### enabled + +Enable the dashboard. + +##### path + +Directory the dashboard files are stored in. + +`dashboard` in the working directory will be used by default. + +If the directory is empty, the dashboard is downloaded and an `.etag` file is stored inside +it to skip unchanged updates. A non-empty directory without an `.etag` file is served as-is +and never updated automatically. + +##### download_url + +Download URL of the dashboard archive (zip). + +`https://github.com/SagerNet/sing-box-dashboard/archive/refs/heads/gh-pages.zip` will be used by default. + +##### http_client + +HTTP client used to download the dashboard, with the same behavior as remote rule-sets. + +See [HTTP Client Fields](/configuration/shared/http-client/) for details. + +When empty, the default HTTP client is used: the one named by +[`default_http_client`](/configuration/route/#default_http_client), or the first top-level +`http_clients` entry when `default_http_client` is empty. + +!!! failure "Implicit default deprecated in sing-box 1.14.0" + + When neither `http_clients` nor `default_http_client` is configured, an implicit HTTP + client connecting through the default outbound is used. This implicit default is + deprecated in sing-box 1.14.0 and will be removed in sing-box 1.16.0; define + `http_clients` instead. + +##### update_interval + +Update interval of the dashboard. + +`1d` will be used by default. + #### tls TLS configuration, see [TLS](/configuration/shared/tls/#inbound). diff --git a/docs/configuration/service/api.zh.md b/docs/configuration/service/api.zh.md index 6db5640ba..71ac16446 100644 --- a/docs/configuration/service/api.zh.md +++ b/docs/configuration/service/api.zh.md @@ -22,6 +22,13 @@ sing-box API 服务是用于观察与控制正在运行的 sing-box 实例的 gR "secret": "", "access_control_allow_origin": [], "access_control_allow_private_network": false, + "dashboard": { + "enabled": true, + "path": "", + "download_url": "", + "http_client": "", // 或 {} + "update_interval": "" + }, "tls": {} } ``` @@ -38,7 +45,7 @@ API 密钥。 客户端通过标准的 `authorization: Bearer ` gRPC metadata 头认证。 -留空则禁用认证。 +默认无需认证。 #### access_control_allow_origin @@ -48,6 +55,54 @@ API 密钥。 允许从私有网络访问。 +#### dashboard + +下载并通过 API 监听器在 `/dashboard/` 提供的 Web 仪表板;其他浏览器请求将被重定向到该路径。 + +!!! info "" + + 该对象可以替换为布尔值(等同于 `{ "enabled": }`), + 或字符串路径(等同于 `{ "enabled": true, "path": "" }`)。 + +##### enabled + +启用仪表板。 + +##### path + +存放仪表板文件的目录。 + +默认使用工作目录下的 `dashboard`。 + +如果目录为空,将下载仪表板,并在其中存放 `.etag` 文件以跳过未变更的更新。 +非空且不含 `.etag` 文件的目录将按原样提供,且不会自动更新。 + +##### download_url + +仪表板压缩包(zip)的下载 URL。 + +默认使用 `https://github.com/SagerNet/sing-box-dashboard/archive/refs/heads/gh-pages.zip`。 + +##### http_client + +用于下载仪表板的 HTTP 客户端,行为与远程规则集相同。 + +参阅 [HTTP 客户端字段](/zh/configuration/shared/http-client/)。 + +留空时使用默认 HTTP 客户端:即由 [`default_http_client`](/zh/configuration/route/#default_http_client) +指定的客户端,或当 `default_http_client` 为空时使用顶级 `http_clients` 的第一项。 + +!!! failure "隐式默认已在 sing-box 1.14.0 废弃" + + 当 `http_clients` 与 `default_http_client` 均未配置时,将使用通过默认出站连接的隐式 HTTP 客户端。 + 该隐式默认已在 sing-box 1.14.0 废弃,并将在 sing-box 1.16.0 移除;请改为定义 `http_clients`。 + +##### update_interval + +仪表板的更新间隔。 + +默认使用 `1d`。 + #### tls TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 diff --git a/option/api.go b/option/api.go index 56ca50e27..6a8766678 100644 --- a/option/api.go +++ b/option/api.go @@ -1,11 +1,50 @@ package option -import "github.com/sagernet/sing/common/json/badoption" +import ( + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badoption" +) type APIServiceOptions struct { ListenOptions Secret string `json:"secret,omitempty"` AccessControlAllowOrigin badoption.Listable[string] `json:"access_control_allow_origin,omitempty"` AccessControlAllowPrivateNetwork bool `json:"access_control_allow_private_network,omitempty"` + Dashboard *APIDashboardOptions `json:"dashboard,omitempty"` InboundTLSOptionsContainer } + +type _APIDashboardOptions struct { + Enabled bool `json:"enabled,omitempty"` + Path string `json:"path,omitempty"` + DownloadURL string `json:"download_url,omitempty"` + HTTPClient *HTTPClientOptions `json:"http_client,omitempty"` + UpdateInterval badoption.Duration `json:"update_interval,omitempty"` +} + +type APIDashboardOptions _APIDashboardOptions + +func (o APIDashboardOptions) MarshalJSON() ([]byte, error) { + if o.DownloadURL == "" && o.HTTPClient == nil && o.UpdateInterval == 0 { + if o.Path == "" { + return json.Marshal(o.Enabled) + } + if o.Enabled { + return json.Marshal(o.Path) + } + } + return json.Marshal(_APIDashboardOptions(o)) +} + +func (o *APIDashboardOptions) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, &o.Enabled) + if err == nil { + return nil + } + err = json.Unmarshal(bytes, &o.Path) + if err == nil { + o.Enabled = true + return nil + } + return json.UnmarshalDisallowUnknownFields(bytes, (*_APIDashboardOptions)(o)) +} diff --git a/service/api/dashboard.go b/service/api/dashboard.go new file mode 100644 index 000000000..a1085dae8 --- /dev/null +++ b/service/api/dashboard.go @@ -0,0 +1,310 @@ +package api + +import ( + "archive/zip" + "context" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" +) + +const ( + dashboardRoutePrefix = "/dashboard/" + dashboardEtagFileName = ".etag" + defaultDashboardURL = "https://github.com/SagerNet/sing-box-dashboard/archive/refs/heads/gh-pages.zip" +) + +type dashboardStatus int + +const ( + dashboardEmpty dashboardStatus = iota + dashboardManaged + dashboardUserProvided +) + +type dashboard struct { + ctx context.Context + cancel context.CancelFunc + logger log.ContextLogger + options option.APIDashboardOptions + path string + url string + updateInterval time.Duration + fileServer http.Handler + httpClient *http.Client + lastEtag string + lastUpdated time.Time +} + +func newDashboard(ctx context.Context, logger log.ContextLogger, options option.APIDashboardOptions) *dashboard { + ctx, cancel := context.WithCancel(ctx) + path := options.Path + if path == "" { + path = "dashboard" + } + path = filemanager.BasePath(ctx, os.ExpandEnv(path)) + url := options.DownloadURL + if url == "" { + url = defaultDashboardURL + } + updateInterval := 24 * time.Hour + if options.UpdateInterval > 0 { + updateInterval = time.Duration(options.UpdateInterval) + } + return &dashboard{ + ctx: ctx, + cancel: cancel, + logger: logger, + options: options, + path: path, + url: url, + updateInterval: updateInterval, + fileServer: http.StripPrefix(dashboardRoutePrefix, http.FileServer(dashboardDir(path))), + } +} + +func (d *dashboard) start() error { + transport, err := d.resolveTransport() + if err != nil { + return E.Cause(err, "create dashboard http client") + } + d.httpClient = &http.Client{Transport: transport} + go d.loopUpdate() + return nil +} + +func (d *dashboard) close() error { + d.cancel() + if d.httpClient != nil { + d.httpClient.CloseIdleConnections() + } + return nil +} + +func (d *dashboard) resolveTransport() (adapter.HTTPTransport, error) { + httpClientManager := service.FromContext[adapter.HTTPClientManager](d.ctx) + if httpClientManager == nil { + return nil, E.New("missing http client manager in context") + } + if d.options.HTTPClient != nil && !d.options.HTTPClient.IsEmpty() { + return httpClientManager.ResolveTransport(d.ctx, d.logger, *d.options.HTTPClient) + } + defaultTransport := httpClientManager.DefaultTransport() + if defaultTransport == nil { + return nil, E.New("default http client transport is not initialized") + } + return defaultTransport, nil +} + +func (d *dashboard) serveHTTP(writer http.ResponseWriter, request *http.Request) { + if strings.HasPrefix(request.URL.Path, dashboardRoutePrefix) { + d.fileServer.ServeHTTP(writer, request) + return + } + http.Redirect(writer, request, dashboardRoutePrefix, http.StatusFound) +} + +func (d *dashboard) loopUpdate() { + status := d.loadState() + if status == dashboardUserProvided { + d.logger.Info("dashboard: serving user-provided files at ", d.path, ", auto-update disabled") + return + } + var nextUpdate time.Time + if status == dashboardManaged { + nextUpdate = d.lastUpdated.Add(d.updateInterval) + } + timer := time.NewTimer(0) + defer timer.Stop() + for { + select { + case <-d.ctx.Done(): + return + case <-timer.C: + } + now := time.Now() + if !now.Before(nextUpdate) { + err := d.fetch(d.ctx) + if err != nil { + d.logger.Error(E.Cause(err, "update dashboard")) + nextUpdate = now.Add(d.updateInterval) + } else { + nextUpdate = d.lastUpdated.Add(d.updateInterval) + } + } + timer.Reset(max(time.Until(nextUpdate), 0)) + } +} + +func (d *dashboard) loadState() dashboardStatus { + entries, err := os.ReadDir(d.path) + if err != nil { + return dashboardEmpty + } + if len(entries) == 0 { + return dashboardEmpty + } + etagPath := filepath.Join(d.path, dashboardEtagFileName) + etagBytes, err := os.ReadFile(etagPath) + if err != nil { + return dashboardUserProvided + } + d.lastEtag = strings.TrimSpace(string(etagBytes)) + info, err := os.Stat(etagPath) + if err == nil { + d.lastUpdated = info.ModTime() + } + return dashboardManaged +} + +func (d *dashboard) fetch(ctx context.Context) error { + d.logger.Info("updating dashboard from URL: ", d.url) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, d.url, nil) + if err != nil { + return err + } + if d.lastEtag != "" { + request.Header.Set("If-None-Match", d.lastEtag) + } + defer d.httpClient.CloseIdleConnections() + response, err := d.httpClient.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + switch response.StatusCode { + case http.StatusOK: + case http.StatusNotModified: + d.lastUpdated = time.Now() + err = filemanager.WriteFile(d.ctx, filepath.Join(d.path, dashboardEtagFileName), []byte(d.lastEtag), 0o644) + if err != nil { + d.logger.Warn(E.Cause(err, "save dashboard update time")) + } + d.logger.Info("dashboard: not modified") + return nil + default: + return E.New("unexpected status: ", response.Status) + } + etag := response.Header.Get("Etag") + err = d.extract(response.Body, etag) + if err != nil { + return err + } + d.lastEtag = etag + d.lastUpdated = time.Now() + d.logger.Info("dashboard: updated") + return nil +} + +func (d *dashboard) extract(body io.Reader, etag string) error { + tempFile, err := filemanager.CreateTemp(d.ctx, "sing-box-dashboard-*.zip") + if err != nil { + return err + } + tempZipPath := tempFile.Name() + defer os.Remove(tempZipPath) + _, err = io.Copy(tempFile, body) + tempFile.Close() + if err != nil { + return err + } + reader, err := zip.OpenReader(tempZipPath) + if err != nil { + return err + } + defer reader.Close() + + tempDir := d.path + ".tmp" + err = filemanager.RemoveAll(d.ctx, tempDir) + if err != nil { + return err + } + err = filemanager.MkdirAll(d.ctx, tempDir, 0o755) + if err != nil { + return err + } + trimDir := zipIsInSingleDirectory(reader.File) + for _, file := range reader.File { + if file.FileInfo().IsDir() { + continue + } + pathElements := strings.Split(file.Name, "/") + if trimDir { + pathElements = pathElements[1:] + } + if len(pathElements) == 0 { + continue + } + relativePath := filepath.Join(pathElements...) + if !filepath.IsLocal(relativePath) { + filemanager.RemoveAll(d.ctx, tempDir) + return E.New("invalid dashboard archive entry: ", file.Name) + } + savePath := filepath.Join(tempDir, relativePath) + err = filemanager.MkdirAll(d.ctx, filepath.Dir(savePath), 0o755) + if err != nil { + filemanager.RemoveAll(d.ctx, tempDir) + return err + } + err = extractZipEntry(d.ctx, file, savePath) + if err != nil { + filemanager.RemoveAll(d.ctx, tempDir) + return err + } + } + err = filemanager.WriteFile(d.ctx, filepath.Join(tempDir, dashboardEtagFileName), []byte(etag), 0o644) + if err != nil { + filemanager.RemoveAll(d.ctx, tempDir) + return err + } + err = filemanager.RemoveAll(d.ctx, d.path) + if err != nil { + return err + } + return os.Rename(tempDir, d.path) +} + +func extractZipEntry(ctx context.Context, file *zip.File, savePath string) error { + reader, err := file.Open() + if err != nil { + return err + } + defer reader.Close() + writer, err := filemanager.Create(ctx, savePath) + if err != nil { + return err + } + defer writer.Close() + _, err = io.Copy(writer, reader) + return err +} + +// GitHub archives wrap every file under a single "-/" top-level directory. +func zipIsInSingleDirectory(files []*zip.File) bool { + var dirName string + for _, file := range files { + if file.FileInfo().IsDir() { + continue + } + pathElements := strings.Split(file.Name, "/") + if len(pathElements) < 2 { + return false + } + if dirName == "" { + dirName = pathElements[0] + } else if dirName != pathElements[0] { + return false + } + } + return dirName != "" +} diff --git a/service/api/dashboard_fs.go b/service/api/dashboard_fs.go new file mode 100644 index 000000000..88443668c --- /dev/null +++ b/service/api/dashboard_fs.go @@ -0,0 +1,18 @@ +package api + +import "net/http" + +type dashboardDir http.Dir + +func (d dashboardDir) Open(name string) (http.File, error) { + file, err := http.Dir(d).Open(name) + if err != nil { + return nil, err + } + return &fileWrapper{file}, nil +} + +// workaround for #2345 #2596 +type fileWrapper struct { + http.File +} diff --git a/service/api/server.go b/service/api/server.go index f18b1a432..40cd960c1 100644 --- a/service/api/server.go +++ b/service/api/server.go @@ -38,6 +38,7 @@ type Service struct { startedService *daemon.StartedService grpcServer *grpc.Server httpServer *http.Server + dashboard *dashboard } func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.APIServiceOptions) (adapter.Service, error) { @@ -63,6 +64,9 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio } s.tlsConfig = tlsConfig } + if options.Dashboard != nil && options.Dashboard.Enabled { + s.dashboard = newDashboard(ctx, logger, *options.Dashboard) + } return s, nil } @@ -72,8 +76,14 @@ func (s *Service) Start(stage adapter.StartStage) error { } s.startedService = daemon.NewAttachedService(s.ctx) s.grpcServer = daemon.NewServer(s.startedService, s.options.Secret) + if s.dashboard != nil { + err := s.dashboard.start() + if err != nil { + return E.Cause(err, "start dashboard") + } + } s.httpServer = &http.Server{ - Handler: h2c.NewHandler(newHTTPHandler(s.logger, s.grpcServer, s.options), new(http2.Server)), + Handler: h2c.NewHandler(newHTTPHandler(s.logger, s.grpcServer, s.options, s.dashboard), new(http2.Server)), BaseContext: func(net.Listener) context.Context { return s.ctx }, @@ -108,6 +118,9 @@ func (s *Service) Start(stage adapter.StartStage) error { func (s *Service) Close() error { s.cancel() + if s.dashboard != nil { + s.dashboard.close() + } if s.httpServer != nil { s.httpServer.Close() } diff --git a/service/api/web_bridge.go b/service/api/web_bridge.go index 929daffc8..39c256874 100644 --- a/service/api/web_bridge.go +++ b/service/api/web_bridge.go @@ -26,14 +26,14 @@ const ( // (https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) and gRPC-Web // streams over WebSocket, wire compatible with the improbable-eng/grpc-web // client transports. -func newHTTPHandler(logger log.ContextLogger, grpcServer *grpc.Server, options option.APIServiceOptions) http.Handler { +func newHTTPHandler(logger log.ContextLogger, grpcServer *grpc.Server, options option.APIServiceOptions, dashboard *dashboard) http.Handler { allowedOrigins := options.AccessControlAllowOrigin if len(allowedOrigins) == 0 { allowedOrigins = []string{"*"} } corsHandler := cors.New(cors.Options{ AllowedOrigins: allowedOrigins, - AllowedMethods: []string{http.MethodPost, http.MethodOptions}, + AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions}, AllowedHeaders: []string{"Content-Type", "Authorization", "X-Grpc-Web", "X-User-Agent", "Grpc-Timeout"}, ExposedHeaders: []string{"Grpc-Status", "Grpc-Message", "Grpc-Status-Details-Bin"}, AllowPrivateNetwork: options.AccessControlAllowPrivateNetwork, @@ -42,12 +42,14 @@ func newHTTPHandler(logger log.ContextLogger, grpcServer *grpc.Server, options o return corsHandler.Handler(&webBridge{ logger: logger, grpcServer: grpcServer, + dashboard: dashboard, }) } type webBridge struct { logger log.ContextLogger grpcServer *grpc.Server + dashboard *dashboard } func (b *webBridge) ServeHTTP(writer http.ResponseWriter, request *http.Request) { @@ -59,6 +61,8 @@ func (b *webBridge) ServeHTTP(writer http.ResponseWriter, request *http.Request) b.serveWeb(writer, request) case request.ProtoMajor == 2 && strings.HasPrefix(contentType, contentTypeGRPC): b.grpcServer.ServeHTTP(writer, request) + case b.dashboard != nil: + b.dashboard.serveHTTP(writer, request) default: http.NotFound(writer, request) }