mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-11-07 00:05:14 +00:00
* fix(ai): preserve legacy Feishu and migrate bundled plugin upgrades * fix(ai): override inherited Feishu allowlists when cleared
202 lines
6.5 KiB
Go
202 lines
6.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/docker"
|
|
"github.com/docker/docker/api/types/container"
|
|
)
|
|
|
|
// Inspect a stopped disposable container; its entrypoint never touches retained state.
|
|
func bundledFeishuImage(image string) (string, error) {
|
|
cli, err := docker.NewDockerClient()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer cli.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
info, _, err := cli.ImageInspectWithRaw(ctx, image)
|
|
if err != nil {
|
|
return "", buserr.New("ErrFeishuImageRequired")
|
|
}
|
|
created, err := cli.ContainerCreate(ctx, &container.Config{Image: info.ID, Entrypoint: []string{"/bin/true"}}, &container.HostConfig{NetworkMode: "none"}, nil, nil, "")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() {
|
|
cleanup, done := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer done()
|
|
_ = cli.ContainerRemove(cleanup, created.ID, container.RemoveOptions{Force: true, RemoveVolumes: true})
|
|
}()
|
|
if _, err = cli.ContainerStatPath(ctx, created.ID, "/app/.1panel-feishu-bundled-v1"); err != nil {
|
|
return "", buserr.New("ErrFeishuImageRequired")
|
|
}
|
|
return info.ID, nil
|
|
}
|
|
|
|
func feishuContainerImage(name string) (string, error) {
|
|
cli, err := docker.NewDockerClient()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer cli.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
info, err := cli.ContainerInspect(ctx, name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return info.Image, nil
|
|
}
|
|
|
|
// The original state is read-only and the normal Doctor entrypoint is bypassed.
|
|
func validateFeishuImageConfig(image, configPath string) error {
|
|
info, err := os.Stat(configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
uid, gid := uint32(1000), uint32(1000)
|
|
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
uid, gid = stat.Uid, stat.Gid
|
|
}
|
|
user := fmt.Sprintf("%d:%d", uid, gid)
|
|
tmpfs := fmt.Sprintf("/tmp:rw,mode=0700,uid=%d,gid=%d", uid, gid)
|
|
name := "1panel-feishu-" + strings.TrimSuffix(filepath.Base(configPath), ".json")
|
|
mgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Minute))
|
|
defer func() { _ = cmd.NewCommandMgr(cmd.WithTimeout(15*time.Second)).Run("docker", "rm", "-f", "-v", name) }()
|
|
out, runErr := mgr.RunWithStdout("docker", "run", "--name", name, "--rm", "--network", "none", "--read-only", "--user", user,
|
|
"--tmpfs", tmpfs,
|
|
"--mount", "type=bind,src="+filepath.Dir(configPath)+",dst=/home/node/.openclaw,readonly",
|
|
"--env", "HOME=/home/node", "--env", "TMPDIR=/tmp", "--env", "OPENCLAW_STATE_DIR=/tmp/state",
|
|
"--env", "OPENCLAW_CONFIG_PATH=/home/node/.openclaw/"+filepath.Base(configPath),
|
|
"--entrypoint", "sh", image, "-c", "mkdir -m 700 /tmp/state && ln -s /home/node/.openclaw/extensions /tmp/state/extensions && exec node /app/openclaw.mjs config validate --json")
|
|
return feishuValidationResult(out, runErr)
|
|
}
|
|
|
|
func feishuValidationResult(out string, runErr error) error {
|
|
raw, _ := extractEmbeddedJSON(out)
|
|
var result struct {
|
|
Valid bool `json:"valid"`
|
|
Issues []struct {
|
|
Path string `json:"path"`
|
|
} `json:"issues"`
|
|
}
|
|
if json.Unmarshal(raw, &result) == nil && result.Valid && runErr == nil {
|
|
return nil
|
|
}
|
|
fields := []string{}
|
|
for _, issue := range result.Issues {
|
|
if issue.Path != "" {
|
|
fields = append(fields, issue.Path)
|
|
}
|
|
}
|
|
if len(fields) == 0 {
|
|
fields = append(fields, "openclaw.json")
|
|
}
|
|
return feishuConfigError(strings.Join(fields, ", "))
|
|
}
|
|
|
|
func backupFeishuConfig(configPath string, original []byte) (string, error) {
|
|
f, err := os.CreateTemp(filepath.Dir(configPath), ".feishu-backup-*.json")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err = f.Write(original); err == nil {
|
|
err = f.Sync()
|
|
}
|
|
closeErr := f.Close()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if closeErr != nil {
|
|
return "", closeErr
|
|
}
|
|
return f.Name(), nil
|
|
}
|
|
|
|
// Doctor can spend 94 seconds waiting for the previous container's owner lease.
|
|
func waitOpenclawReady(name string) error {
|
|
script := `const fs=require('fs');const c=JSON.parse(fs.readFileSync('/home/node/.openclaw/openclaw.json','utf8'));const tls=c.gateway?.tls?.enabled===true;const h=require(tls?'https':'http');function check(p){const r=h.get({hostname:'127.0.0.1',port:c.gateway?.port||18789,path:p,rejectUnauthorized:false,timeout:3000},s=>{s.resume();if(s.statusCode===404&&p==='/readyz')return check('/healthz');process.exitCode=s.statusCode===200?0:1});r.on('timeout',()=>r.destroy());r.on('error',()=>process.exitCode=1)}check('/readyz')`
|
|
deadline := time.Now().Add(4 * time.Minute)
|
|
for time.Now().Before(deadline) {
|
|
if cmd.RunDockerExec(8*time.Second, name, "node", "-e", script) == nil {
|
|
return nil
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
return buserr.New("ErrFeishuActivation")
|
|
}
|
|
|
|
func verifyFeishuActivation(name string, enabled bool) error {
|
|
if err := waitOpenclawReady(name); err != nil {
|
|
return err
|
|
}
|
|
output, err := cmd.RunDockerExecWithStdout(time.Minute, name, "openclaw", "plugins", "list", "--json")
|
|
if err != nil {
|
|
return buserr.New("ErrFeishuActivation")
|
|
}
|
|
raw, _ := extractEmbeddedJSON(output)
|
|
var result struct {
|
|
Plugins []struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
Origin string `json:"origin"`
|
|
} `json:"plugins"`
|
|
}
|
|
if json.Unmarshal(raw, &result) != nil {
|
|
return buserr.New("ErrFeishuActivation")
|
|
}
|
|
found := false
|
|
for _, p := range result.Plugins {
|
|
if p.ID == "openclaw-lark" && p.Status != "disabled" {
|
|
return buserr.New("ErrFeishuActivation")
|
|
}
|
|
if p.ID == "feishu" && p.Origin == "bundled" && (p.Status == "loaded" || (!enabled && p.Status == "disabled")) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
return buserr.New("ErrFeishuActivation")
|
|
}
|
|
health, err := cmd.RunDockerExecWithStdout(20*time.Second, name, "openclaw", "gateway", "health", "--json")
|
|
if err != nil || !feishuGatewayReady(health, enabled) {
|
|
return buserr.New("ErrFeishuActivation")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readFeishuConfigBytes(file string) ([]byte, map[string]interface{}, error) {
|
|
info, err := os.Lstat(file)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil, nil, feishuConfigError("openclaw.json")
|
|
}
|
|
f, err := os.Open(file)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer f.Close()
|
|
raw, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var conf map[string]interface{}
|
|
if err = json.Unmarshal(raw, &conf); err != nil || conf == nil {
|
|
return nil, nil, feishuConfigError("openclaw.json")
|
|
}
|
|
return raw, conf, nil
|
|
}
|