Initial commit: DSH Web Manager
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const previewLimit = 1 << 20
|
||||
|
||||
var bundleReference = regexp.MustCompile("^(@[a-z0-9._-]+/)?[a-z0-9._-]+$")
|
||||
|
||||
// The caller must authenticate and check instance ownership before dispatching.
|
||||
func (a *API) profilePreview(w http.ResponseWriter, r *http.Request, i store.Instance) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method != http.MethodGet {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
bundles, err := a.profileBundles(i)
|
||||
if err != nil {
|
||||
fail(w, 400, "bundle manifest unavailable")
|
||||
return
|
||||
}
|
||||
result := map[string]any{"bundles": bundles, "config": "", "redacted": true}
|
||||
if i.ContainerID == "" || i.Status != "running" {
|
||||
result["preview_error"] = "启动实例后可预览已保存的配置。"
|
||||
writeJSON(w, result)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
content, err := a.dumpProfile(ctx, i.ContainerID)
|
||||
if err != nil {
|
||||
result["preview_error"] = "配置预览不可用;命令输出和错误详情已隐藏。"
|
||||
} else {
|
||||
result["config"] = content
|
||||
}
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func (a *API) profileBundles(i store.Instance) (any, error) {
|
||||
parent, err := upgradeRoot(a.root, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer parent.Close()
|
||||
if err = configCheckPath(parent, "home/profiles/web/package.json"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := configProfileRoot(parent, false)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer root.Close()
|
||||
f, err := root.OpenFile("package.json", os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil || !st.Mode().IsRegular() || st.Size() > previewLimit {
|
||||
return nil, errors.New("invalid manifest")
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, previewLimit+1))
|
||||
if err != nil || len(data) > previewLimit {
|
||||
return nil, errors.New("invalid manifest")
|
||||
}
|
||||
var pkg map[string]json.RawMessage
|
||||
if json.Unmarshal(data, &pkg) != nil {
|
||||
return nil, errors.New("invalid manifest")
|
||||
}
|
||||
var profile any
|
||||
raw := pkg["dsh.profile"]
|
||||
if raw == nil {
|
||||
var dsh map[string]json.RawMessage
|
||||
if pkg["dsh"] != nil && json.Unmarshal(pkg["dsh"], &dsh) != nil {
|
||||
return nil, errors.New("invalid manifest")
|
||||
}
|
||||
raw = dsh["profile"]
|
||||
}
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if json.Unmarshal(raw, &profile) != nil {
|
||||
return nil, errors.New("invalid manifest")
|
||||
}
|
||||
return redactPreview(profile, false), nil
|
||||
}
|
||||
|
||||
func (a *API) dumpProfile(ctx context.Context, container string) (string, error) {
|
||||
var exec struct{ ID string }
|
||||
err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(container)+"/exec", map[string]any{
|
||||
"Cmd": []string{"dsh", "--profile", "web", "--dump-config"}, "AttachStdout": true, "AttachStderr": true, "Tty": false,
|
||||
}, &exec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exec.ID == "" {
|
||||
return "", errors.New("missing exec ID")
|
||||
}
|
||||
path := "/exec/" + url.PathEscape(exec.ID)
|
||||
body, _ := json.Marshal(map[string]bool{"Detach": false, "Tty": false})
|
||||
resp, err := a.docker.Stream(ctx, "POST", path+"/start", bytes.NewReader(body), "application/json")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := previewStdout(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var state struct {
|
||||
Running bool
|
||||
ExitCode int
|
||||
}
|
||||
if err = a.docker.Do(ctx, "GET", path+"/json", nil, &state); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if state.Running || state.ExitCode != 0 {
|
||||
return "", errors.New("preview command failed")
|
||||
}
|
||||
var document any
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||
if err = decoder.Decode(&document); err != nil {
|
||||
return "", errors.New("invalid preview")
|
||||
}
|
||||
var extra any
|
||||
if decoder.Decode(&extra) != io.EOF {
|
||||
return "", errors.New("multiple preview documents")
|
||||
}
|
||||
switch document.(type) {
|
||||
case map[string]any, []any:
|
||||
// DSH exports its composed plugin tree as a YAML sequence.
|
||||
default:
|
||||
return "", errors.New("invalid preview structure")
|
||||
}
|
||||
safe, err := yaml.Marshal(redactPreview(document, true))
|
||||
return string(safe), err
|
||||
}
|
||||
|
||||
func previewStdout(r io.Reader) ([]byte, error) {
|
||||
var output bytes.Buffer
|
||||
total := 0
|
||||
for {
|
||||
var header [8]byte
|
||||
_, err := io.ReadFull(r, header[:])
|
||||
if err == io.EOF {
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if (header[0] != 1 && header[0] != 2) || header[1] != 0 || header[2] != 0 || header[3] != 0 {
|
||||
return nil, errors.New("invalid exec stream")
|
||||
}
|
||||
size := int64(binary.BigEndian.Uint32(header[4:]))
|
||||
total += 8
|
||||
if size > int64(previewLimit-total) {
|
||||
return nil, errors.New("preview too large")
|
||||
}
|
||||
total += int(size)
|
||||
var dst io.Writer = io.Discard
|
||||
if header[0] == 1 {
|
||||
dst = &output
|
||||
}
|
||||
if _, err = io.CopyN(dst, r, size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func redactPreview(value any, strict bool) any {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for key, item := range v {
|
||||
normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", ".", "").Replace(key))
|
||||
sensitive := false
|
||||
for _, word := range []string{"key", "secret", "token", "password", "passwd", "credential", "authorization", "cookie", "env", "header"} {
|
||||
sensitive = sensitive || strings.Contains(normalized, word)
|
||||
}
|
||||
if sensitive {
|
||||
out[key] = "[REDACTED]"
|
||||
} else {
|
||||
out[key] = redactPreview(item, strict)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(v))
|
||||
for j, item := range v {
|
||||
out[j] = redactPreview(item, strict)
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
// Plugins can resolve credentials into arbitrary string fields, not just apiKey.
|
||||
if strict || !bundleReference.MatchString(v) {
|
||||
return "[REDACTED]"
|
||||
}
|
||||
return v
|
||||
case map[any]any:
|
||||
return "[REDACTED]"
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user