227 lines
5.9 KiB
Go
227 lines
5.9 KiB
Go
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
|
|
}
|
|
}
|