Add dashboard support for API service
This commit is contained in:
@@ -119,7 +119,16 @@ HTTP Client for downloading rule-set.
|
|||||||
|
|
||||||
See [HTTP Client Fields](/configuration/shared/http-client/) for details.
|
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
|
#### update_interval
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,13 @@
|
|||||||
|
|
||||||
参阅 [HTTP 客户端字段](/zh/configuration/shared/http-client/) 了解详情。
|
参阅 [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
|
#### update_interval
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ for bidirectional streaming methods.
|
|||||||
"secret": "",
|
"secret": "",
|
||||||
"access_control_allow_origin": [],
|
"access_control_allow_origin": [],
|
||||||
"access_control_allow_private_network": false,
|
"access_control_allow_private_network": false,
|
||||||
|
"dashboard": {
|
||||||
|
"enabled": true,
|
||||||
|
"path": "",
|
||||||
|
"download_url": "",
|
||||||
|
"http_client": "", // or {}
|
||||||
|
"update_interval": ""
|
||||||
|
},
|
||||||
"tls": {}
|
"tls": {}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -49,6 +56,59 @@ CORS allowed origins, `*` will be used if empty.
|
|||||||
|
|
||||||
Allow access from private network.
|
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": <bool> }`),
|
||||||
|
or with a string path (equivalent to `{ "enabled": true, "path": "<string>" }`).
|
||||||
|
|
||||||
|
##### 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
|
||||||
|
|
||||||
TLS configuration, see [TLS](/configuration/shared/tls/#inbound).
|
TLS configuration, see [TLS](/configuration/shared/tls/#inbound).
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ sing-box API 服务是用于观察与控制正在运行的 sing-box 实例的 gR
|
|||||||
"secret": "",
|
"secret": "",
|
||||||
"access_control_allow_origin": [],
|
"access_control_allow_origin": [],
|
||||||
"access_control_allow_private_network": false,
|
"access_control_allow_private_network": false,
|
||||||
|
"dashboard": {
|
||||||
|
"enabled": true,
|
||||||
|
"path": "",
|
||||||
|
"download_url": "",
|
||||||
|
"http_client": "", // 或 {}
|
||||||
|
"update_interval": ""
|
||||||
|
},
|
||||||
"tls": {}
|
"tls": {}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -38,7 +45,7 @@ API 密钥。
|
|||||||
|
|
||||||
客户端通过标准的 `authorization: Bearer <secret>` gRPC metadata 头认证。
|
客户端通过标准的 `authorization: Bearer <secret>` gRPC metadata 头认证。
|
||||||
|
|
||||||
留空则禁用认证。
|
默认无需认证。
|
||||||
|
|
||||||
#### access_control_allow_origin
|
#### access_control_allow_origin
|
||||||
|
|
||||||
@@ -48,6 +55,54 @@ API 密钥。
|
|||||||
|
|
||||||
允许从私有网络访问。
|
允许从私有网络访问。
|
||||||
|
|
||||||
|
#### dashboard
|
||||||
|
|
||||||
|
下载并通过 API 监听器在 `/dashboard/` 提供的 Web 仪表板;其他浏览器请求将被重定向到该路径。
|
||||||
|
|
||||||
|
!!! info ""
|
||||||
|
|
||||||
|
该对象可以替换为布尔值(等同于 `{ "enabled": <bool> }`),
|
||||||
|
或字符串路径(等同于 `{ "enabled": true, "path": "<string>" }`)。
|
||||||
|
|
||||||
|
##### 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 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。
|
TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。
|
||||||
|
|||||||
+40
-1
@@ -1,11 +1,50 @@
|
|||||||
package option
|
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 {
|
type APIServiceOptions struct {
|
||||||
ListenOptions
|
ListenOptions
|
||||||
Secret string `json:"secret,omitempty"`
|
Secret string `json:"secret,omitempty"`
|
||||||
AccessControlAllowOrigin badoption.Listable[string] `json:"access_control_allow_origin,omitempty"`
|
AccessControlAllowOrigin badoption.Listable[string] `json:"access_control_allow_origin,omitempty"`
|
||||||
AccessControlAllowPrivateNetwork bool `json:"access_control_allow_private_network,omitempty"`
|
AccessControlAllowPrivateNetwork bool `json:"access_control_allow_private_network,omitempty"`
|
||||||
|
Dashboard *APIDashboardOptions `json:"dashboard,omitempty"`
|
||||||
InboundTLSOptionsContainer
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 "<repo>-<branch>/" 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 != ""
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+14
-1
@@ -38,6 +38,7 @@ type Service struct {
|
|||||||
startedService *daemon.StartedService
|
startedService *daemon.StartedService
|
||||||
grpcServer *grpc.Server
|
grpcServer *grpc.Server
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
|
dashboard *dashboard
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.APIServiceOptions) (adapter.Service, error) {
|
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
|
s.tlsConfig = tlsConfig
|
||||||
}
|
}
|
||||||
|
if options.Dashboard != nil && options.Dashboard.Enabled {
|
||||||
|
s.dashboard = newDashboard(ctx, logger, *options.Dashboard)
|
||||||
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,8 +76,14 @@ func (s *Service) Start(stage adapter.StartStage) error {
|
|||||||
}
|
}
|
||||||
s.startedService = daemon.NewAttachedService(s.ctx)
|
s.startedService = daemon.NewAttachedService(s.ctx)
|
||||||
s.grpcServer = daemon.NewServer(s.startedService, s.options.Secret)
|
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{
|
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 {
|
BaseContext: func(net.Listener) context.Context {
|
||||||
return s.ctx
|
return s.ctx
|
||||||
},
|
},
|
||||||
@@ -108,6 +118,9 @@ func (s *Service) Start(stage adapter.StartStage) error {
|
|||||||
|
|
||||||
func (s *Service) Close() error {
|
func (s *Service) Close() error {
|
||||||
s.cancel()
|
s.cancel()
|
||||||
|
if s.dashboard != nil {
|
||||||
|
s.dashboard.close()
|
||||||
|
}
|
||||||
if s.httpServer != nil {
|
if s.httpServer != nil {
|
||||||
s.httpServer.Close()
|
s.httpServer.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,14 +26,14 @@ const (
|
|||||||
// (https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) and gRPC-Web
|
// (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
|
// streams over WebSocket, wire compatible with the improbable-eng/grpc-web
|
||||||
// client transports.
|
// 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
|
allowedOrigins := options.AccessControlAllowOrigin
|
||||||
if len(allowedOrigins) == 0 {
|
if len(allowedOrigins) == 0 {
|
||||||
allowedOrigins = []string{"*"}
|
allowedOrigins = []string{"*"}
|
||||||
}
|
}
|
||||||
corsHandler := cors.New(cors.Options{
|
corsHandler := cors.New(cors.Options{
|
||||||
AllowedOrigins: allowedOrigins,
|
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"},
|
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Grpc-Web", "X-User-Agent", "Grpc-Timeout"},
|
||||||
ExposedHeaders: []string{"Grpc-Status", "Grpc-Message", "Grpc-Status-Details-Bin"},
|
ExposedHeaders: []string{"Grpc-Status", "Grpc-Message", "Grpc-Status-Details-Bin"},
|
||||||
AllowPrivateNetwork: options.AccessControlAllowPrivateNetwork,
|
AllowPrivateNetwork: options.AccessControlAllowPrivateNetwork,
|
||||||
@@ -42,12 +42,14 @@ func newHTTPHandler(logger log.ContextLogger, grpcServer *grpc.Server, options o
|
|||||||
return corsHandler.Handler(&webBridge{
|
return corsHandler.Handler(&webBridge{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
grpcServer: grpcServer,
|
grpcServer: grpcServer,
|
||||||
|
dashboard: dashboard,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type webBridge struct {
|
type webBridge struct {
|
||||||
logger log.ContextLogger
|
logger log.ContextLogger
|
||||||
grpcServer *grpc.Server
|
grpcServer *grpc.Server
|
||||||
|
dashboard *dashboard
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *webBridge) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
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)
|
b.serveWeb(writer, request)
|
||||||
case request.ProtoMajor == 2 && strings.HasPrefix(contentType, contentTypeGRPC):
|
case request.ProtoMajor == 2 && strings.HasPrefix(contentType, contentTypeGRPC):
|
||||||
b.grpcServer.ServeHTTP(writer, request)
|
b.grpcServer.ServeHTTP(writer, request)
|
||||||
|
case b.dashboard != nil:
|
||||||
|
b.dashboard.serveHTTP(writer, request)
|
||||||
default:
|
default:
|
||||||
http.NotFound(writer, request)
|
http.NotFound(writer, request)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user