feat(dobotai): containerize test runtime and admin bootstrap
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
bin/
|
||||||
|
.acceptance-canary/
|
||||||
|
.hermes/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
FROM golang:1.26.5-bookworm AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG COMMIT_SHA=unknown
|
||||||
|
ARG BUILD_TIME=unknown
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
|
||||||
|
-ldflags "-s -w -X main.version=${VERSION} -X main.commitSHA=${COMMIT_SHA} -X main.buildTime=${BUILD_TIME}" \
|
||||||
|
-o /out/dobotai ./cmd/dobotai
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=build /out/dobotai /usr/local/bin/dobotai
|
||||||
|
USER 10001:10001
|
||||||
|
ENTRYPOINT ["/usr/local/bin/dobotai"]
|
||||||
@@ -65,6 +65,24 @@ DOBOTAI_TENANT_KEY
|
|||||||
|
|
||||||
持续推理默认最多10轮,成功回答才消耗轮次;10分钟无交互后逻辑到期,下一次问题自动回到快速问答。状态按 `tenant + channel + external_actor_id` 隔离。
|
持续推理默认最多10轮,成功回答才消耗轮次;10分钟无交互后逻辑到期,下一次问题自动回到快速问答。状态按 `tenant + channel + external_actor_id` 隔离。
|
||||||
|
|
||||||
|
## 首位管理员终端绑定
|
||||||
|
|
||||||
|
首位管理员不通过网页生成验证码。先确保钉钉 Stream 机器人已经正常运行,登录部署服务器后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dobotai bootstrap-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
若二进制未加入 `PATH`,使用其实际绝对路径执行。该命令只读取 `DATABASE_URL` 和 `DOBOTAI_TENANT_KEY`,不会启动 WeKnora、钉钉消费者或问答服务。
|
||||||
|
|
||||||
|
命令会在终端使用加密随机数生成一个有效期10分钟的六码绑定码,并保持等待。候选管理员必须私聊机器人发送:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/setting <六码绑定码>
|
||||||
|
```
|
||||||
|
|
||||||
|
验证码正确后,终端自动将该钉钉用户绑定为 `super_admin` 并显示成功。已有有效管理员时命令会拒绝执行,防止通过该入口追加或替换管理员。DobotAI 不提供网页管理面;钉钉凭据和运行配置由部署环境文件管理。
|
||||||
|
|
||||||
## 覆盖与人工支持
|
## 覆盖与人工支持
|
||||||
|
|
||||||
- 有结构化检索引用的回答标记为已覆盖;
|
- 有结构化检索引用的回答标记为已覆盖;
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
cryptorand "crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||||
|
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bootstrapAdminTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
type bootstrapAdminRepository interface {
|
||||||
|
AdministratorCount(context.Context, string) (int, error)
|
||||||
|
CreateBootstrapChallenge(context.Context, string, string, time.Time) error
|
||||||
|
ConfirmBootstrap(context.Context, string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var createBootstrapChallenge = func(ctx context.Context, repo bootstrapAdminRepository, tenant, code string, expiry time.Time) error {
|
||||||
|
return repo.CreateBootstrapChallenge(ctx, tenant, code, expiry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBootstrapAdminCommand(ctx context.Context, output io.Writer) error {
|
||||||
|
tenant := strings.TrimSpace(os.Getenv("DOBOTAI_TENANT_KEY"))
|
||||||
|
store, err := postgres.Open(ctx, os.Getenv("DATABASE_URL"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
repository, err := postgres.NewSettingsRepository(store)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runBootstrapAdmin(ctx, output, repository, tenant, cryptorand.Reader, time.Now, time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBootstrapAdmin(ctx context.Context, output io.Writer, repository bootstrapAdminRepository, tenant string, random io.Reader, now func() time.Time, pollInterval time.Duration) error {
|
||||||
|
if repository == nil || strings.TrimSpace(tenant) == "" || random == nil || now == nil || pollInterval <= 0 {
|
||||||
|
return errors.New("bootstrap administrator command requires repository, tenant, random source, clock, and poll interval")
|
||||||
|
}
|
||||||
|
count, err := repository.AdministratorCount(ctx, tenant)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check active administrators: %w", err)
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return errors.New("an active administrator already exists; use administrator management instead")
|
||||||
|
}
|
||||||
|
codeNumber, err := cryptorand.Int(random, big.NewInt(1_000_000))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generate bootstrap code: %w", err)
|
||||||
|
}
|
||||||
|
code := fmt.Sprintf("%06d", codeNumber.Int64())
|
||||||
|
expiresAt := now().Add(bootstrapAdminTTL)
|
||||||
|
if err := createBootstrapChallenge(ctx, repository, tenant, code, expiresAt); err != nil {
|
||||||
|
return fmt.Errorf("create bootstrap challenge: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(output, "首位管理员绑定已开始。\n绑定码:%s\n请在%s前私聊钉钉机器人发送:/setting %s\n终端正在等待绑定完成;按 Ctrl+C 取消。\n", code, expiresAt.Local().Format("2006-01-02 15:04:05"), code)
|
||||||
|
|
||||||
|
waitFor := expiresAt.Sub(now())
|
||||||
|
if waitFor <= 0 {
|
||||||
|
return errors.New("bootstrap binding expiry must be in the future")
|
||||||
|
}
|
||||||
|
waitCtx, cancel := context.WithTimeout(ctx, waitFor)
|
||||||
|
defer cancel()
|
||||||
|
ticker := time.NewTicker(pollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
err := repository.ConfirmBootstrap(waitCtx, tenant)
|
||||||
|
if err == nil {
|
||||||
|
fmt.Fprintln(output, "首位管理员绑定成功:候选用户已设为 super_admin。")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, settings.ErrBootstrapCandidatePending) {
|
||||||
|
return fmt.Errorf("confirm bootstrap candidate: %w", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-waitCtx.Done():
|
||||||
|
return errors.New("bootstrap binding expired or cancelled before a candidate was verified")
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bootstrapAdminRepoStub struct {
|
||||||
|
administratorCount int
|
||||||
|
createdCode string
|
||||||
|
createdExpiry time.Time
|
||||||
|
confirmCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *bootstrapAdminRepoStub) AdministratorCount(context.Context, string) (int, error) {
|
||||||
|
return r.administratorCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *bootstrapAdminRepoStub) CreateBootstrapChallenge(_ context.Context, _ string, code string, expiry time.Time) error {
|
||||||
|
r.createdCode, r.createdExpiry = code, expiry
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *bootstrapAdminRepoStub) ConfirmBootstrap(context.Context, string) error {
|
||||||
|
r.confirmCalls++
|
||||||
|
if r.confirmCalls == 1 {
|
||||||
|
return settings.ErrBootstrapCandidatePending
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBootstrapAdminGeneratesCodeWaitsAndConfirmsCandidate(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.September, 10, 8, 0, 0, 0, time.UTC)
|
||||||
|
repo := &bootstrapAdminRepoStub{}
|
||||||
|
var output bytes.Buffer
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := runBootstrapAdmin(ctx, &output, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), func() time.Time { return now }, time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if repo.createdCode != "000000" {
|
||||||
|
t.Fatalf("code=%q", repo.createdCode)
|
||||||
|
}
|
||||||
|
if !repo.createdExpiry.Equal(now.Add(10 * time.Minute)) {
|
||||||
|
t.Fatalf("expiry=%s", repo.createdExpiry)
|
||||||
|
}
|
||||||
|
if repo.confirmCalls < 2 {
|
||||||
|
t.Fatalf("confirm calls=%d", repo.confirmCalls)
|
||||||
|
}
|
||||||
|
for _, required := range []string{"/setting 000000", "绑定成功", "super_admin"} {
|
||||||
|
if !strings.Contains(output.String(), required) {
|
||||||
|
t.Fatalf("output=%q missing %q", output.String(), required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBootstrapAdminRefusesWhenAdministratorAlreadyExists(t *testing.T) {
|
||||||
|
repo := &bootstrapAdminRepoStub{administratorCount: 1}
|
||||||
|
err := runBootstrapAdmin(context.Background(), &bytes.Buffer{}, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), time.Now, time.Millisecond)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
if repo.createdCode != "" || repo.confirmCalls != 0 {
|
||||||
|
t.Fatalf("repo mutated: %+v", repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBootstrapAdminPropagatesChallengeCreationFailure(t *testing.T) {
|
||||||
|
repo := &bootstrapAdminRepoStub{}
|
||||||
|
original := createBootstrapChallenge
|
||||||
|
createBootstrapChallenge = func(context.Context, bootstrapAdminRepository, string, string, time.Time) error {
|
||||||
|
return errors.New("database unavailable")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { createBootstrapChallenge = original })
|
||||||
|
|
||||||
|
err := runBootstrapAdmin(context.Background(), &bytes.Buffer{}, repo, "tenant-test", bytes.NewReader(make([]byte, 32)), time.Now, time.Millisecond)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "database unavailable") {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type candidateConfigRepository interface {
|
|
||||||
CandidateDingTalkConfig(context.Context, string) (string, string, error)
|
|
||||||
ActivateDingTalkCandidate(context.Context, string, string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type candidateCredentialVerifier interface {
|
|
||||||
Verify(context.Context, string, string) dingtalkconfig.Result
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
candidateTokenURL = ""
|
|
||||||
candidateTableBaseURL = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
type dingTalkCandidateActivator struct {
|
|
||||||
tenant string
|
|
||||||
repo candidateConfigRepository
|
|
||||||
secrets *secretstore.Store
|
|
||||||
verifier candidateCredentialVerifier
|
|
||||||
bindings map[string]settings.ResourceBinding
|
|
||||||
restart func()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *dingTalkCandidateActivator) ActivateCandidate(ctx context.Context) error {
|
|
||||||
if a == nil || a.repo == nil || a.secrets == nil || a.verifier == nil || a.restart == nil {
|
|
||||||
return errors.New("candidate_activation_unavailable")
|
|
||||||
}
|
|
||||||
clientID, secretRef, err := a.repo.CandidateDingTalkConfig(ctx, a.tenant)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("candidate_not_found")
|
|
||||||
}
|
|
||||||
secret, err := a.secrets.Get(secretRef)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("candidate_secret_unavailable")
|
|
||||||
}
|
|
||||||
if result := a.verifier.Verify(ctx, clientID, secret); !result.OK {
|
|
||||||
return errors.New("candidate_oauth_failed")
|
|
||||||
}
|
|
||||||
for _, kind := range []string{"question_table", "faq_table"} {
|
|
||||||
binding, ok := a.bindings[kind]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
client, err := questiontable.NewClient(questiontable.Config{ClientID: clientID, Secret: secret, BaseID: binding.BaseID, SheetID: binding.SheetID, OperatorID: binding.OperatorID, TokenURL: candidateTokenURL, BaseURL: candidateTableBaseURL}, nil)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("candidate_table_config_failed")
|
|
||||||
}
|
|
||||||
fields, err := client.ReadFields(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("candidate_table_probe_failed")
|
|
||||||
}
|
|
||||||
required := questiontable.QuestionSchema()
|
|
||||||
if kind == "faq_table" {
|
|
||||||
required = questiontable.FAQSchema()
|
|
||||||
}
|
|
||||||
if _, err = questiontable.MapRequiredFields(fields, required); err != nil {
|
|
||||||
return errors.New("candidate_table_schema_failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := a.repo.ActivateDingTalkCandidate(ctx, a.tenant, secretRef); err != nil {
|
|
||||||
return errors.New("candidate_database_activation_failed")
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
time.Sleep(300 * time.Millisecond)
|
|
||||||
a.restart()
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type candidateRepoStub struct {
|
|
||||||
activated bool
|
|
||||||
ref string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *candidateRepoStub) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
|
|
||||||
return "app", r.ref, nil
|
|
||||||
}
|
|
||||||
func (r *candidateRepoStub) ActivateDingTalkCandidate(context.Context, string, string) error {
|
|
||||||
r.activated = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type candidateVerifierStub struct{ ok bool }
|
|
||||||
|
|
||||||
func (v candidateVerifierStub) Verify(context.Context, string, string) dingtalkconfig.Result {
|
|
||||||
return dingtalkconfig.Result{OK: v.ok}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCandidateActivatorRejectsOAuthWithoutActivation(t *testing.T) {
|
|
||||||
secrets, _ := secretstore.New(t.TempDir())
|
|
||||||
ref, _ := secrets.Put("dingtalk", "secret")
|
|
||||||
repo := &candidateRepoStub{ref: ref}
|
|
||||||
a := &dingTalkCandidateActivator{tenant: "t", repo: repo, secrets: secrets, verifier: candidateVerifierStub{}, bindings: map[string]settings.ResourceBinding{}, restart: func() {}}
|
|
||||||
if err := a.ActivateCandidate(context.Background()); err == nil {
|
|
||||||
t.Fatal("expected error")
|
|
||||||
}
|
|
||||||
if repo.activated {
|
|
||||||
t.Fatal("must not activate")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func TestCandidateActivatorValidatesSchemasThenActivates(t *testing.T) {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(`{"accessToken":"token","expireIn":7200}`))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/v1.0/notable/bases/base/sheets/sheet/fields", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(`{"value":[{"id":"q","name":"中文问题","type":"text"},{"id":"tm","name":"提问时间","type":"date"},{"id":"u","name":"提问人","type":"user"},{"id":"o","name":"原始问题","type":"text"},{"id":"a","name":"AI答案","type":"text"},{"id":"p","name":"流程触发人员信息","type":"text"},{"id":"l","name":"语言","type":"text"},{"id":"d","name":"部门","type":"text"},{"id":"r","name":"区域","type":"text"}]}`))
|
|
||||||
})
|
|
||||||
server := httptest.NewServer(mux)
|
|
||||||
defer server.Close()
|
|
||||||
secrets, _ := secretstore.New(t.TempDir())
|
|
||||||
ref, _ := secrets.Put("dingtalk", "secret")
|
|
||||||
repo := &candidateRepoStub{ref: ref}
|
|
||||||
restarted := make(chan struct{}, 1)
|
|
||||||
a := &dingTalkCandidateActivator{tenant: "t", repo: repo, secrets: secrets, verifier: candidateVerifierStub{ok: true}, bindings: map[string]settings.ResourceBinding{"question_table": {BaseID: "base", SheetID: "sheet", OperatorID: "u"}}, restart: func() { restarted <- struct{}{} }}
|
|
||||||
oldTokenURL, oldBaseURL := candidateTokenURL, candidateTableBaseURL
|
|
||||||
candidateTokenURL, candidateTableBaseURL = server.URL+"/token", server.URL
|
|
||||||
defer func() { candidateTokenURL, candidateTableBaseURL = oldTokenURL, oldBaseURL }()
|
|
||||||
if err := a.ActivateCandidate(context.Background()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !repo.activated {
|
|
||||||
t.Fatal("expected activation")
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-restarted:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("restart not requested")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-34
@@ -12,7 +12,6 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/agentbinding"
|
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/agentbinding"
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
|
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||||
@@ -45,6 +44,12 @@ func runtimeBuildInfo() httpapi.BuildInfo {
|
|||||||
func main() {
|
func main() {
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
if len(os.Args) == 2 && os.Args[1] == "bootstrap-admin" {
|
||||||
|
if err := runBootstrapAdminCommand(ctx, os.Stdout); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
var restartRequested atomic.Bool
|
var restartRequested atomic.Bool
|
||||||
storeCtx, storeCancel := context.WithTimeout(ctx, 10*time.Second)
|
storeCtx, storeCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer storeCancel()
|
defer storeCancel()
|
||||||
@@ -64,8 +69,7 @@ func main() {
|
|||||||
address = "127.0.0.1:8090"
|
address = "127.0.0.1:8090"
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(settingsRuntime.clientID) == "" || strings.TrimSpace(settingsRuntime.secret) == "" {
|
if strings.TrimSpace(settingsRuntime.clientID) == "" || strings.TrimSpace(settingsRuntime.secret) == "" {
|
||||||
runSetupOnly(ctx, cancel, settingsRuntime, address, os.Getenv("DOBOTAI_TENANT_KEY"), &restartRequested)
|
log.Fatal("DingTalk credentials are required; configure DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET in the protected runtime environment")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 120*time.Second)
|
client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 120*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -131,11 +135,6 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
settingsRuntime.admin.SetCandidateActivator(&dingTalkCandidateActivator{
|
|
||||||
tenant: dingCfg.TenantKey, repo: settingsRuntime.repository, secrets: settingsRuntime.secrets,
|
|
||||||
verifier: settingsRuntime.verifier, bindings: activeBindings,
|
|
||||||
restart: func() { restartRequested.Store(true); cancel() },
|
|
||||||
})
|
|
||||||
runtimeReady := make(chan struct{})
|
runtimeReady := make(chan struct{})
|
||||||
faqCfg, err := readFAQConfig()
|
faqCfg, err := readFAQConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -329,37 +328,11 @@ func main() {
|
|||||||
log.Printf("DingTalk Stream ingress started for tenant %s", dingCfg.TenantKey)
|
log.Printf("DingTalk Stream ingress started for tenant %s", dingCfg.TenantKey)
|
||||||
}
|
}
|
||||||
close(runtimeReady)
|
close(runtimeReady)
|
||||||
_, questionBound := activeBindings["question_table"]
|
|
||||||
_, faqBound := activeBindings["faq_table"]
|
|
||||||
_, supportBound := activeBindings["support_group"]
|
|
||||||
weknoraHealthURL := strings.TrimRight(strings.TrimSpace(os.Getenv("WEKNORA_BASE_URL")), "/")
|
|
||||||
if weknoraHealthURL != "" {
|
|
||||||
weknoraHealthURL += "/health"
|
|
||||||
}
|
|
||||||
settingsRuntime.admin.SetOperationalStatusProvider(&operationalStatusProvider{
|
|
||||||
metrics: store,
|
|
||||||
tenant: dingCfg.TenantKey,
|
|
||||||
weknoraHealthURL: weknoraHealthURL,
|
|
||||||
httpClient: &http.Client{Timeout: 3 * time.Second},
|
|
||||||
now: time.Now,
|
|
||||||
version: runtimeBuildInfo().Version,
|
|
||||||
streamRunning: streamClient != nil,
|
|
||||||
questionRunning: questionTableDone != nil,
|
|
||||||
faqRunning: faqWorkflowDone != nil,
|
|
||||||
supportRunning: supportDispatcher != nil,
|
|
||||||
questionBound: questionBound,
|
|
||||||
faqBound: faqBound,
|
|
||||||
supportBound: supportBound,
|
|
||||||
ossConfigured: outboundOSSConfigured(),
|
|
||||||
})
|
|
||||||
rootHandler := http.NewServeMux()
|
rootHandler := http.NewServeMux()
|
||||||
rootHandler.Handle("/", api.Handler())
|
rootHandler.Handle("/", api.Handler())
|
||||||
if outboundImages.handler != nil {
|
if outboundImages.handler != nil {
|
||||||
rootHandler.Handle(outboundImageRoute, outboundImages.handler)
|
rootHandler.Handle(outboundImageRoute, outboundImages.handler)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(os.Getenv("DOBOTAI_ADMIN_TOKEN")) != "" {
|
|
||||||
rootHandler.Handle("/admin/", adminweb.New(settingsRuntime.admin, os.Getenv("DOBOTAI_ADMIN_TOKEN")))
|
|
||||||
}
|
|
||||||
server := &http.Server{Addr: address, Handler: rootHandler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
|
server := &http.Server{Addr: address, Handler: rootHandler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 130 * time.Second, IdleTimeout: 60 * time.Second}
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("DobotAI ingress listening on %s", address)
|
log.Printf("DobotAI ingress listening on %s", address)
|
||||||
|
|||||||
@@ -183,3 +183,19 @@ func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) {
|
|||||||
t.Fatal("complete stable image configuration should be active")
|
t.Fatal("complete stable image configuration should be active")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMainDoesNotExposeWebAdministrationOrSetupOnlyMode(t *testing.T) {
|
||||||
|
data, err := os.ReadFile("main.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source := string(data)
|
||||||
|
for _, forbidden := range []string{"internal/adminweb", "adminweb.", `"/admin/"`, "runSetupOnly"} {
|
||||||
|
if strings.Contains(source, forbidden) {
|
||||||
|
t.Fatalf("main must not expose web administration: found %q", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(source, "DingTalk credentials are required") {
|
||||||
|
t.Fatal("main must fail closed when DingTalk credentials are absent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
|
|
||||||
)
|
|
||||||
|
|
||||||
type adminComponent = adminweb.Component
|
|
||||||
type operationalMetrics = postgres.OperationalMetrics
|
|
||||||
|
|
||||||
type operationalMetricsSource interface {
|
|
||||||
Ping(context.Context) error
|
|
||||||
ReadOperationalMetrics(context.Context, string) (postgres.OperationalMetrics, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type operationalStatusProvider struct {
|
|
||||||
metrics operationalMetricsSource
|
|
||||||
tenant string
|
|
||||||
weknoraHealthURL string
|
|
||||||
httpClient *http.Client
|
|
||||||
now func() time.Time
|
|
||||||
version string
|
|
||||||
streamRunning bool
|
|
||||||
questionRunning bool
|
|
||||||
faqRunning bool
|
|
||||||
supportRunning bool
|
|
||||||
questionBound bool
|
|
||||||
faqBound bool
|
|
||||||
supportBound bool
|
|
||||||
ossConfigured bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *operationalStatusProvider) Components(ctx context.Context) ([]adminweb.Component, error) {
|
|
||||||
now := time.Now()
|
|
||||||
if p.now != nil {
|
|
||||||
now = p.now()
|
|
||||||
}
|
|
||||||
components := []adminweb.Component{{Name: "运行版本", State: "ok", Code: "version_ok", Detail: "版本:" + safeVersion(p.version), LastSuccess: now, CheckedAt: now}}
|
|
||||||
|
|
||||||
metrics := operationalMetrics{}
|
|
||||||
if p.metrics == nil || p.metrics.Ping(ctx) != nil {
|
|
||||||
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "error", Code: "database_unavailable", Detail: "数据库健康检查失败", CheckedAt: now})
|
|
||||||
} else {
|
|
||||||
var err error
|
|
||||||
metrics, err = p.metrics.ReadOperationalMetrics(ctx, p.tenant)
|
|
||||||
if err != nil {
|
|
||||||
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "error", Code: "metrics_query_failed", Detail: "数据库可连接,但状态聚合失败", LastSuccess: now, CheckedAt: now})
|
|
||||||
} else {
|
|
||||||
components = append(components, adminweb.Component{Name: "PostgreSQL", State: "ok", Code: "database_ok", Detail: "连接与状态聚合正常", LastSuccess: now, CheckedAt: now})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
components = append(components,
|
|
||||||
booleanComponent("钉钉Stream", p.streamRunning, "stream_running", "stream_stopped", "正式Stream消费者运行中", "正式Stream消费者未运行", now),
|
|
||||||
p.weknoraComponent(ctx, now),
|
|
||||||
outboxComponent("问题表Outbox", metrics.QuestionPending, metrics.QuestionFailed, metrics.LastQuestionSuccess, now),
|
|
||||||
outboxComponent("FAQ Outbox", metrics.FAQPending, metrics.FAQFailed, metrics.LastFAQSuccess, now),
|
|
||||||
supportComponent(p, metrics, now),
|
|
||||||
dispatcherComponent("问题表Dispatcher", p.questionBound, p.questionRunning, now),
|
|
||||||
dispatcherComponent("FAQ Dispatcher", p.faqBound, p.faqRunning, now),
|
|
||||||
booleanComponent("Private OSS图片", p.ossConfigured, "oss_configured", "oss_unconfigured", "图片出站配置完整", "图片出站未配置", now),
|
|
||||||
)
|
|
||||||
return components, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *operationalStatusProvider) weknoraComponent(ctx context.Context, now time.Time) adminweb.Component {
|
|
||||||
component := adminweb.Component{Name: "WeKnora", State: "error", Code: "health_probe_failed", Detail: "健康探针失败", CheckedAt: now}
|
|
||||||
if strings.TrimSpace(p.weknoraHealthURL) == "" {
|
|
||||||
component.Code = "health_url_missing"
|
|
||||||
component.Detail = "健康探针地址未配置"
|
|
||||||
return component
|
|
||||||
}
|
|
||||||
client := p.httpClient
|
|
||||||
if client == nil {
|
|
||||||
client = &http.Client{Timeout: 3 * time.Second}
|
|
||||||
}
|
|
||||||
reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, p.weknoraHealthURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return component
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return component
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
component.Code = "health_http_error"
|
|
||||||
component.Detail = fmt.Sprintf("健康探针HTTP %d", resp.StatusCode)
|
|
||||||
return component
|
|
||||||
}
|
|
||||||
component.State, component.Code, component.Detail = "ok", "health_ok", "HTTP健康探针正常"
|
|
||||||
component.LastSuccess = now
|
|
||||||
return component
|
|
||||||
}
|
|
||||||
|
|
||||||
func outboxComponent(name string, pending, failed int, lastSuccess, now time.Time) adminweb.Component {
|
|
||||||
component := adminweb.Component{Name: name, State: "ok", Code: "outbox_empty", Detail: "pending=0,failed=0", LastSuccess: lastSuccess, CheckedAt: now}
|
|
||||||
if failed > 0 {
|
|
||||||
component.State, component.Code = "error", "outbox_failed"
|
|
||||||
} else if pending > 0 {
|
|
||||||
component.State, component.Code = "warning", "outbox_pending"
|
|
||||||
}
|
|
||||||
component.Detail = fmt.Sprintf("pending=%d,failed=%d", pending, failed)
|
|
||||||
return component
|
|
||||||
}
|
|
||||||
|
|
||||||
func supportComponent(p *operationalStatusProvider, metrics operationalMetrics, now time.Time) adminweb.Component {
|
|
||||||
component := adminweb.Component{Name: "技术支持通知", CheckedAt: now, LastSuccess: metrics.LastSupportSuccess}
|
|
||||||
switch {
|
|
||||||
case !p.supportBound:
|
|
||||||
component.State, component.Code, component.Detail = "disabled", "binding_missing", "技术支持群未绑定"
|
|
||||||
case !p.faqBound:
|
|
||||||
component.State, component.Code, component.Detail = "blocked", "faq_binding_required", "已绑定支持群;需先激活FAQ表"
|
|
||||||
case metrics.SupportFailed > 0:
|
|
||||||
component.State, component.Code = "error", "notification_failed"
|
|
||||||
component.Detail = fmt.Sprintf("pending=%d,failed=%d", metrics.SupportPending, metrics.SupportFailed)
|
|
||||||
case metrics.SupportPending > 0:
|
|
||||||
component.State, component.Code = "warning", "notification_pending"
|
|
||||||
component.Detail = fmt.Sprintf("pending=%d,failed=0", metrics.SupportPending)
|
|
||||||
case p.supportRunning:
|
|
||||||
component.State, component.Code, component.Detail = "ok", "dispatcher_running", "通知Dispatcher运行中;pending=0,failed=0"
|
|
||||||
default:
|
|
||||||
component.State, component.Code, component.Detail = "error", "dispatcher_stopped", "绑定与依赖完整,但Dispatcher未运行"
|
|
||||||
}
|
|
||||||
return component
|
|
||||||
}
|
|
||||||
|
|
||||||
func dispatcherComponent(name string, bound, running bool, now time.Time) adminweb.Component {
|
|
||||||
if !bound {
|
|
||||||
return adminweb.Component{Name: name, State: "disabled", Code: "binding_missing", Detail: "无active绑定", CheckedAt: now}
|
|
||||||
}
|
|
||||||
if !running {
|
|
||||||
return adminweb.Component{Name: name, State: "error", Code: "dispatcher_stopped", Detail: "已有active绑定,但Dispatcher未运行", CheckedAt: now}
|
|
||||||
}
|
|
||||||
return adminweb.Component{Name: name, State: "ok", Code: "dispatcher_running", Detail: "Dispatcher运行中", LastSuccess: now, CheckedAt: now}
|
|
||||||
}
|
|
||||||
|
|
||||||
func booleanComponent(name string, ok bool, successCode, failureCode, successDetail, failureDetail string, now time.Time) adminweb.Component {
|
|
||||||
if ok {
|
|
||||||
return adminweb.Component{Name: name, State: "ok", Code: successCode, Detail: successDetail, LastSuccess: now, CheckedAt: now}
|
|
||||||
}
|
|
||||||
return adminweb.Component{Name: name, State: "disabled", Code: failureCode, Detail: failureDetail, CheckedAt: now}
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeVersion(value string) string {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
if len(value) > 80 {
|
|
||||||
return value[:80]
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestOperationalStatusProviderBuildsLiveComponents(t *testing.T) {
|
|
||||||
health := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer health.Close()
|
|
||||||
|
|
||||||
checked := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
|
|
||||||
provider := &operationalStatusProvider{
|
|
||||||
metrics: operationalMetricsStub{value: operationalMetrics{
|
|
||||||
QuestionPending: 2, FAQFailed: 1, SupportPending: 3,
|
|
||||||
LastQuestionSuccess: checked.Add(-time.Minute),
|
|
||||||
}},
|
|
||||||
weknoraHealthURL: health.URL,
|
|
||||||
httpClient: health.Client(),
|
|
||||||
now: func() time.Time { return checked },
|
|
||||||
version: "settings-test",
|
|
||||||
streamRunning: true,
|
|
||||||
questionRunning: true,
|
|
||||||
faqRunning: false,
|
|
||||||
supportRunning: false,
|
|
||||||
questionBound: true,
|
|
||||||
faqBound: false,
|
|
||||||
supportBound: true,
|
|
||||||
ossConfigured: true,
|
|
||||||
}
|
|
||||||
components, err := provider.Components(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
byName := componentMap(components)
|
|
||||||
assertComponent(t, byName, "运行版本", "ok", "version_ok")
|
|
||||||
assertComponent(t, byName, "PostgreSQL", "ok", "database_ok")
|
|
||||||
assertComponent(t, byName, "钉钉Stream", "ok", "stream_running")
|
|
||||||
assertComponent(t, byName, "WeKnora", "ok", "health_ok")
|
|
||||||
assertComponent(t, byName, "问题表Outbox", "warning", "outbox_pending")
|
|
||||||
assertComponent(t, byName, "FAQ Outbox", "error", "outbox_failed")
|
|
||||||
assertComponent(t, byName, "技术支持通知", "blocked", "faq_binding_required")
|
|
||||||
assertComponent(t, byName, "问题表Dispatcher", "ok", "dispatcher_running")
|
|
||||||
assertComponent(t, byName, "FAQ Dispatcher", "disabled", "binding_missing")
|
|
||||||
assertComponent(t, byName, "Private OSS图片", "ok", "oss_configured")
|
|
||||||
for _, component := range components {
|
|
||||||
for _, forbidden := range []string{health.URL, "cid-secret-group", "base-secret", "access-token"} {
|
|
||||||
if strings.Contains(component.Detail, forbidden) {
|
|
||||||
t.Fatalf("component %q leaks sensitive identifier %q", component.Name, forbidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOperationalStatusProviderIsolatesWeKnoraFailure(t *testing.T) {
|
|
||||||
provider := &operationalStatusProvider{
|
|
||||||
metrics: operationalMetricsStub{value: operationalMetrics{}},
|
|
||||||
weknoraHealthURL: "http://127.0.0.1:1/health",
|
|
||||||
httpClient: &http.Client{Timeout: 50 * time.Millisecond},
|
|
||||||
now: time.Now,
|
|
||||||
}
|
|
||||||
components, err := provider.Components(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
byName := componentMap(components)
|
|
||||||
assertComponent(t, byName, "PostgreSQL", "ok", "database_ok")
|
|
||||||
assertComponent(t, byName, "WeKnora", "error", "health_probe_failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
type operationalMetricsStub struct {
|
|
||||||
value operationalMetrics
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s operationalMetricsStub) ReadOperationalMetrics(context.Context, string) (operationalMetrics, error) {
|
|
||||||
return s.value, s.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s operationalMetricsStub) Ping(context.Context) error { return s.err }
|
|
||||||
|
|
||||||
func componentMap(values []adminComponent) map[string]adminComponent {
|
|
||||||
out := make(map[string]adminComponent, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
out[value.Name] = value
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertComponent(t *testing.T, values map[string]adminComponent, name, state, code string) {
|
|
||||||
t.Helper()
|
|
||||||
value, ok := values[name]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("missing component %q: %+v", name, values)
|
|
||||||
}
|
|
||||||
if value.State != state || value.Code != code {
|
|
||||||
t.Fatalf("component %q=%+v want state=%q code=%q", name, value, state, code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,9 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
|
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
|
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/store/postgres"
|
||||||
@@ -29,16 +27,12 @@ func (r *dingTalkSettingsRouter) Handle(ctx context.Context, m dingtalk.Settings
|
|||||||
}
|
}
|
||||||
|
|
||||||
type settingsRuntime struct {
|
type settingsRuntime struct {
|
||||||
repository *postgres.SettingsRepository
|
repository *postgres.SettingsRepository
|
||||||
bindings *validatedSettingsRepository
|
bindings *validatedSettingsRepository
|
||||||
controller *settings.Controller
|
controller *settings.Controller
|
||||||
admin *adminweb.ControlService
|
clientID string
|
||||||
clientID string
|
secret string
|
||||||
secret string
|
secretRef string
|
||||||
secretRef string
|
|
||||||
secrets *secretstore.Store
|
|
||||||
verifier *dingtalkconfig.Verifier
|
|
||||||
pendingActivation bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey string) (*settingsRuntime, error) {
|
func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey string) (*settingsRuntime, error) {
|
||||||
@@ -62,8 +56,7 @@ func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pendingActivation, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey)
|
if _, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
clientID, secretRef, _, err := repo.ActiveDingTalkConfig(ctx, tenantKey)
|
clientID, secretRef, _, err := repo.ActiveDingTalkConfig(ctx, tenantKey)
|
||||||
@@ -88,11 +81,6 @@ func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
verifier := dingtalkconfig.NewVerifier("https://oapi.dingtalk.com/gettoken", nil)
|
|
||||||
control, err := adminweb.NewControlService(tenantKey, repo, secrets, verifier)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
validatedRepo := &validatedSettingsRepository{SettingsRepository: repo, clientID: clientID, secret: secret}
|
validatedRepo := &validatedSettingsRepository{SettingsRepository: repo, clientID: clientID, secret: secret}
|
||||||
return &settingsRuntime{repository: repo, bindings: validatedRepo, controller: settings.NewController(validatedRepo), admin: control, clientID: clientID, secret: secret, secretRef: secretRef, secrets: secrets, verifier: verifier, pendingActivation: pendingActivation}, nil
|
return &settingsRuntime{repository: repo, bindings: validatedRepo, controller: settings.NewController(validatedRepo), clientID: clientID, secret: secret, secretRef: secretRef}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newSetupOnlyHandler(runtime *settingsRuntime, token string) (http.Handler, error) {
|
|
||||||
if runtime == nil || runtime.admin == nil {
|
|
||||||
return nil, errors.New("setup runtime unavailable")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(token) == "" {
|
|
||||||
return nil, errors.New("DOBOTAI_ADMIN_TOKEN is required in setup-only mode")
|
|
||||||
}
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok", "mode": "setup_required"})
|
|
||||||
})
|
|
||||||
mux.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(runtimeBuildInfo())
|
|
||||||
})
|
|
||||||
mux.Handle("/admin/", adminweb.New(runtime.admin, token))
|
|
||||||
return mux, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runSetupOnly(ctx context.Context, cancel context.CancelFunc, runtime *settingsRuntime, address, tenant string, restartRequested *atomic.Bool) {
|
|
||||||
runtime.admin.SetCandidateActivator(&dingTalkCandidateActivator{
|
|
||||||
tenant: tenant, repo: runtime.repository, secrets: runtime.secrets, verifier: runtime.verifier,
|
|
||||||
bindings: map[string]settings.ResourceBinding{},
|
|
||||||
restart: func() { restartRequested.Store(true); cancel() },
|
|
||||||
})
|
|
||||||
handler, err := newSetupOnlyHandler(runtime, os.Getenv("DOBOTAI_ADMIN_TOKEN"))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
server := &http.Server{Addr: address, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 20 * time.Second, IdleTimeout: 60 * time.Second}
|
|
||||||
go func() {
|
|
||||||
log.Printf("DobotAI setup-only listener on %s", address)
|
|
||||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
log.Printf("setup HTTP server failed: %v", err)
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
<-ctx.Done()
|
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer shutdownCancel()
|
|
||||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
|
||||||
log.Printf("setup HTTP shutdown: %v", err)
|
|
||||||
}
|
|
||||||
if restartRequested.Load() {
|
|
||||||
os.Exit(75)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type setupRepoStub struct{}
|
|
||||||
|
|
||||||
func (setupRepoStub) ActiveDingTalkConfig(context.Context, string) (string, string, bool, error) {
|
|
||||||
return "", "", false, nil
|
|
||||||
}
|
|
||||||
func (setupRepoStub) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
|
|
||||||
return "", "", nil
|
|
||||||
}
|
|
||||||
func (setupRepoStub) SaveDingTalkConfig(context.Context, string, string, string, bool) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (setupRepoStub) MarkDingTalkCandidateFailed(context.Context, string, string, string) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (setupRepoStub) AdministratorCount(context.Context, string) (int, error) { return 0, nil }
|
|
||||||
func (setupRepoStub) ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error) {
|
|
||||||
return map[string]settings.ResourceBinding{}, nil
|
|
||||||
}
|
|
||||||
func (setupRepoStub) CreateBootstrapChallenge(context.Context, string, string, time.Time) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (setupRepoStub) ConfirmBootstrap(context.Context, string) error { return nil }
|
|
||||||
|
|
||||||
type setupVerifierStub struct{}
|
|
||||||
|
|
||||||
func (setupVerifierStub) Verify(context.Context, string, string) dingtalkconfig.Result {
|
|
||||||
return dingtalkconfig.Result{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupControl(t *testing.T) *adminweb.ControlService {
|
|
||||||
t.Helper()
|
|
||||||
secrets, err := secretstore.New(t.TempDir())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
control, err := adminweb.NewControlService("tenant", setupRepoStub{}, secrets, setupVerifierStub{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return control
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetupOnlyHandlerExposesHealthVersionAndProtectedAdmin(t *testing.T) {
|
|
||||||
runtime := &settingsRuntime{admin: setupControl(t)}
|
|
||||||
h, err := newSetupOnlyHandler(runtime, "admin-token")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
||||||
resp := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(resp, req)
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("health status=%d", resp.Code)
|
|
||||||
}
|
|
||||||
var health map[string]string
|
|
||||||
if err = json.Unmarshal(resp.Body.Bytes(), &health); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if health["status"] != "ok" || health["mode"] != "setup_required" {
|
|
||||||
t.Fatalf("health=%v", health)
|
|
||||||
}
|
|
||||||
resp = httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/version", nil))
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("version status=%d", resp.Code)
|
|
||||||
}
|
|
||||||
resp = httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/admin/settings", nil))
|
|
||||||
if resp.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("unauth status=%d", resp.Code)
|
|
||||||
}
|
|
||||||
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer admin-token")
|
|
||||||
resp = httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(resp, req)
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("auth status=%d body=%s", resp.Code, resp.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetupOnlyHandlerRequiresAdminToken(t *testing.T) {
|
|
||||||
_, err := newSetupOnlyHandler(&settingsRuntime{admin: setupControl(t)}, "")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected token error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# DobotAI 容器化运行(测试环境)
|
||||||
|
|
||||||
|
容器和Compose服务固定名为 `dobotai-dingtalk`。容器使用宿主网络,以兼容测试环境已有的 `127.0.0.1` PostgreSQL、WeKnora和健康检查地址;它不暴露新端口,不更改数据库容器或WeKnora容器。
|
||||||
|
|
||||||
|
## 日常操作
|
||||||
|
|
||||||
|
完成一次安装后,日常仅需要两条命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dobotai-dingtalk-configure
|
||||||
|
sudo dobotai-dingtalk-bind-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
第一条命令以隐藏输入方式更新 `/etc/dobotai-test/dobotai.env` 中的必填配置;不会将秘密传入命令行、终端输出或Git。环境文件写入成功后,它会受控停止原生 `dobotai-test.service`、启动 `dobotai-dingtalk`,并以 `/healthz` 验证。容器健康检查失败时自动停止容器并恢复原生服务。
|
||||||
|
|
||||||
|
第二条命令在已运行的容器内执行 `dobotai bootstrap-admin`。终端显示六码后,候选管理员私聊钉钉机器人:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/setting <六码>
|
||||||
|
```
|
||||||
|
|
||||||
|
绑定成功后终端自动退出。该命令不会启动第二个钉钉 Stream 消费者。
|
||||||
|
|
||||||
|
## 安装前置条件
|
||||||
|
|
||||||
|
安装脚本只接受已构建、已审查的本地镜像,并在切换前备份原生二进制与Systemd Unit。它不读取或打印环境文件中的值,也不停止旧服务。生产环境必须独立审查、备份和验证,不复用测试镜像以外的未验证构建。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
dobotai-dingtalk:
|
||||||
|
image: ${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE in runtime.env}
|
||||||
|
container_name: dobotai-dingtalk
|
||||||
|
network_mode: host
|
||||||
|
user: "${DOBOTAI_RUNTIME_UID:?set DOBOTAI_RUNTIME_UID}:${DOBOTAI_RUNTIME_GID:?set DOBOTAI_RUNTIME_GID}"
|
||||||
|
env_file:
|
||||||
|
- ${DOBOTAI_ENV_FILE:?set DOBOTAI_ENV_FILE in runtime.env}
|
||||||
|
environment:
|
||||||
|
DOBOTAI_SECRET_DIR: /var/lib/dobotai/secrets
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${DOBOTAI_SECRET_DIR_HOST:?set DOBOTAI_SECRET_DIR_HOST in runtime.env}
|
||||||
|
target: /var/lib/dobotai/secrets
|
||||||
|
read_only: false
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,size=32m
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
FIELDS = (
|
||||||
|
("DATABASE_URL", "DobotAI PostgreSQL 连接串", True),
|
||||||
|
("WEKNORA_BASE_URL", "WeKnora 地址", False),
|
||||||
|
("WEKNORA_API_KEY", "WeKnora API Key", True),
|
||||||
|
("DOBOTAI_INGRESS_TOKEN", "DobotAI Ingress Token", True),
|
||||||
|
("DOBOTAI_TENANT_KEY", "DobotAI 租户键", False),
|
||||||
|
("DOBOTAI_AGENT_BINDINGS", "DobotAI Agent Bindings JSON", False),
|
||||||
|
("DINGTALK_CLIENT_ID", "钉钉 Client ID", False),
|
||||||
|
("DINGTALK_CLIENT_SECRET", "钉钉 Client Secret", True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_env(path: Path):
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
values = {}
|
||||||
|
for line in lines:
|
||||||
|
if line and not line.lstrip().startswith("#") and "=" in line:
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
values[key.strip()] = value
|
||||||
|
return lines, values
|
||||||
|
|
||||||
|
|
||||||
|
def prompt(label: str, secret: bool, configured: bool) -> str:
|
||||||
|
suffix = "(已配置,直接回车保留)" if configured else "(必填)"
|
||||||
|
if secret:
|
||||||
|
return getpass.getpass(f"{label}{suffix}: ").strip()
|
||||||
|
return input(f"{label}{suffix}: ").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def write_env(path: Path, lines, values):
|
||||||
|
rendered = []
|
||||||
|
written = set()
|
||||||
|
for line in lines:
|
||||||
|
if line and not line.lstrip().startswith("#") and "=" in line:
|
||||||
|
key = line.split("=", 1)[0].strip()
|
||||||
|
if key in values:
|
||||||
|
rendered.append(f"{key}={values[key]}")
|
||||||
|
written.add(key)
|
||||||
|
continue
|
||||||
|
rendered.append(line)
|
||||||
|
for key, value in values.items():
|
||||||
|
if key not in written:
|
||||||
|
rendered.append(f"{key}={value}")
|
||||||
|
temporary = path.with_name(path.name + ".new")
|
||||||
|
temporary.write_text("\n".join(rendered) + "\n", encoding="utf-8")
|
||||||
|
os.chmod(temporary,0o600)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="配置 DobotAI 容器运行环境")
|
||||||
|
parser.add_argument("--env-file", required=True)
|
||||||
|
parser.add_argument("--start-script", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
env_file = Path(args.env_file)
|
||||||
|
if not env_file.is_file():
|
||||||
|
raise SystemExit("DobotAI 环境文件不存在,请先完成部署。")
|
||||||
|
lines, values = parse_env(env_file)
|
||||||
|
for key, label, secret in FIELDS:
|
||||||
|
entered = prompt(label, secret, bool(values.get(key, "").strip()))
|
||||||
|
if entered:
|
||||||
|
values[key] = entered
|
||||||
|
missing = [label for key, label, _ in FIELDS if not values.get(key, "").strip()]
|
||||||
|
if missing:
|
||||||
|
raise SystemExit("以下必填项未配置:" + "、".join(missing))
|
||||||
|
write_env(env_file, lines, values)
|
||||||
|
os.execv(args.start_script, [args.start_script])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(130)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
container="dobotai-dingtalk"
|
||||||
|
if [[ "$(docker inspect --format '{{.State.Running}}' "$container" 2>/dev/null || true)" != "true" ]]; then
|
||||||
|
echo "DobotAI 容器未运行。请先执行:sudo dobotai-dingtalk-configure" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec docker exec -it dobotai-dingtalk /usr/local/bin/dobotai bootstrap-admin
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
exec python3 /opt/dobotai-test/container/configure_env.py \
|
||||||
|
--env-file /etc/dobotai-test/dobotai.env \
|
||||||
|
--start-script /usr/local/sbin/dobotai-dingtalk-start
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
container_dir="/opt/dobotai-test/container"
|
||||||
|
compose_file="$container_dir/compose.yaml"
|
||||||
|
runtime_env="$container_dir/runtime.env"
|
||||||
|
legacy_service="dobotai-test.service"
|
||||||
|
health_url="http://127.0.0.1:8090/healthz"
|
||||||
|
|
||||||
|
if [[ ! -r "$compose_file" || ! -r "$runtime_env" ]]; then
|
||||||
|
echo "容器运行文件未安装。请先部署 dobotai-dingtalk。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
legacy_was_active=false
|
||||||
|
if systemctl is-active --quiet "$legacy_service"; then
|
||||||
|
legacy_was_active=true
|
||||||
|
systemctl stop "$legacy_service"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rollback() {
|
||||||
|
docker compose --env-file "$runtime_env" -f "$compose_file" down --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
if [[ "$legacy_was_active" == "true" ]]; then
|
||||||
|
systemctl start "$legacy_service" || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap rollback ERR
|
||||||
|
|
||||||
|
docker compose --env-file "$runtime_env" -f "$compose_file" up -d --no-build --force-recreate
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if curl --noproxy '*' --fail --silent --max-time 2 "$health_url" >/dev/null; then
|
||||||
|
systemctl disable "$legacy_service" >/dev/null
|
||||||
|
trap - ERR
|
||||||
|
echo "DobotAI 容器已启动:dobotai-dingtalk"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "DobotAI 容器健康检查失败,已回滚到原生服务。" >&2
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
container_dir="${DOBOTAI_CONTAINER_DIR:-/opt/dobotai-test/container}"
|
||||||
|
service="${DOBOTAI_LEGACY_SERVICE:-dobotai-test.service}"
|
||||||
|
runtime_user="${DOBOTAI_RUNTIME_USER:-dobotai-test}"
|
||||||
|
env_file="${DOBOTAI_ENV_FILE:-/etc/dobotai-test/dobotai.env}"
|
||||||
|
secret_dir="${DOBOTAI_SECRET_DIR_HOST:-/var/lib/dobotai-test/secrets}"
|
||||||
|
image="${DOBOTAI_IMAGE:?set DOBOTAI_IMAGE to the reviewed image tag before installation}"
|
||||||
|
backup_root="${DOBOTAI_BACKUP_ROOT:-/opt/dobotai-test/backups}"
|
||||||
|
|
||||||
|
if [[ ${EUID} -ne 0 ]]; then
|
||||||
|
echo "请使用 sudo 执行安装脚本。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! docker image inspect "$image" >/dev/null 2>&1; then
|
||||||
|
echo "候选镜像不存在:$image" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -r "$env_file" || ! -d "$secret_dir" ]]; then
|
||||||
|
echo "现有 DobotAI 环境文件或密钥目录不存在,拒绝安装。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
uid="$(id -u "$runtime_user")"
|
||||||
|
gid="$(id -g "$runtime_user")"
|
||||||
|
stamp="$(date -u '+%Y%m%dT%H%M%SZ')"
|
||||||
|
backup_dir="$backup_root/pre-container-$stamp"
|
||||||
|
install -d -m 0700 "$backup_dir" "$container_dir"
|
||||||
|
cp -p /opt/dobotai-test/bin/dobotai "$backup_dir/dobotai.native"
|
||||||
|
cp -p /etc/systemd/system/dobotai-test.service "$backup_dir/dobotai-test.service"
|
||||||
|
sha256sum "$backup_dir/dobotai.native" > "$backup_dir/SHA256SUMS"
|
||||||
|
|
||||||
|
install -m 0644 "$script_dir/compose.yaml" "$container_dir/compose.yaml"
|
||||||
|
install -m 0750 "$script_dir/dobotai-dingtalk-start" /usr/local/sbin/dobotai-dingtalk-start
|
||||||
|
install -m 0750 "$script_dir/dobotai-dingtalk-configure" /usr/local/sbin/dobotai-dingtalk-configure
|
||||||
|
install -m 0750 "$script_dir/dobotai-dingtalk-bind-admin" /usr/local/sbin/dobotai-dingtalk-bind-admin
|
||||||
|
install -m 0700 "$script_dir/configure_env.py" "$container_dir/configure_env.py"
|
||||||
|
|
||||||
|
runtime_tmp="$container_dir/runtime.env.new"
|
||||||
|
umask 077
|
||||||
|
cat > "$runtime_tmp" <<EOF
|
||||||
|
DOBOTAI_IMAGE=$image
|
||||||
|
DOBOTAI_RUNTIME_UID=$uid
|
||||||
|
DOBOTAI_RUNTIME_GID=$gid
|
||||||
|
DOBOTAI_ENV_FILE=$env_file
|
||||||
|
DOBOTAI_SECRET_DIR_HOST=$secret_dir
|
||||||
|
EOF
|
||||||
|
chmod 0600 "$runtime_tmp"
|
||||||
|
mv -f "$runtime_tmp" "$container_dir/runtime.env"
|
||||||
|
|
||||||
|
echo "容器资产已安装,旧服务仍在运行。"
|
||||||
|
echo "下一步执行:sudo dobotai-dingtalk-configure"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 非敏感容器运行参数;install-test.sh 会根据测试服务器实际用户和目录生成此文件。
|
||||||
|
DOBOTAI_IMAGE=dobotai-dingtalk:test
|
||||||
|
DOBOTAI_RUNTIME_UID=996
|
||||||
|
DOBOTAI_RUNTIME_GID=988
|
||||||
|
DOBOTAI_ENV_FILE=/etc/dobotai-test/dobotai.env
|
||||||
|
DOBOTAI_SECRET_DIR_HOST=/var/lib/dobotai-test/secrets
|
||||||
@@ -37,6 +37,22 @@ Agent 由配置控制,渠道调用方不得任意指定 `agent_id`。
|
|||||||
2. 渠道通配 `<channel>:*`;
|
2. 渠道通配 `<channel>:*`;
|
||||||
3. 没有任何绑定时失败关闭。
|
3. 没有任何绑定时失败关闭。
|
||||||
|
|
||||||
|
## 首位管理员终端绑定
|
||||||
|
|
||||||
|
首位管理员绑定由部署服务器终端发起,而非网页发码。钉钉 Stream 机器人已正常运行且当前不存在有效管理员时,操作人员执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dobotai bootstrap-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
命令只连接 DobotAI PostgreSQL,使用加密随机数创建一个有效期10分钟、最多5次错误尝试的六码挑战,并在终端持续等待。候选用户必须私聊机器人发送:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/setting <六码绑定码>
|
||||||
|
```
|
||||||
|
|
||||||
|
验证码匹配后,渠道侧只记录候选身份;终端轮询并事务性将该候选用户写为 `super_admin/active`,然后显示成功并退出。已有有效管理员时该命令拒绝执行,避免任意用户通过终端绑定流程被提权。DobotAI 不提供网页管理面,钉钉凭据和运行配置由部署环境文件管理。
|
||||||
|
|
||||||
## 图片失败策略
|
## 图片失败策略
|
||||||
|
|
||||||
启用图片流量前,WeKnora Adapter 必须针对选定的官方版本和视觉模型执行契约测试。
|
启用图片流量前,WeKnora Adapter 必须针对选定的官方版本和视觉模型执行契约测试。
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
package adminweb
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ConfigRepository interface {
|
|
||||||
ActiveDingTalkConfig(context.Context, string) (string, string, bool, error)
|
|
||||||
CandidateDingTalkConfig(context.Context, string) (string, string, error)
|
|
||||||
SaveDingTalkConfig(context.Context, string, string, string, bool) error
|
|
||||||
MarkDingTalkCandidateFailed(context.Context, string, string, string) error
|
|
||||||
AdministratorCount(context.Context, string) (int, error)
|
|
||||||
ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error)
|
|
||||||
CreateBootstrapChallenge(context.Context, string, string, time.Time) error
|
|
||||||
ConfirmBootstrap(context.Context, string) error
|
|
||||||
}
|
|
||||||
type AdministratorRepository interface {
|
|
||||||
ListAdministrators(context.Context, string) ([]settings.Administrator, error)
|
|
||||||
DisableAdministrator(context.Context, string, string) error
|
|
||||||
}
|
|
||||||
type CandidateActivator interface{ ActivateCandidate(context.Context) error }
|
|
||||||
type OperationalStatusProvider interface {
|
|
||||||
Components(context.Context) ([]Component, error)
|
|
||||||
}
|
|
||||||
type CredentialVerifier interface {
|
|
||||||
Verify(context.Context, string, string) dingtalkconfig.Result
|
|
||||||
}
|
|
||||||
type ControlService struct {
|
|
||||||
tenant string
|
|
||||||
repo ConfigRepository
|
|
||||||
secrets *secretstore.Store
|
|
||||||
verifier CredentialVerifier
|
|
||||||
activator CandidateActivator
|
|
||||||
status OperationalStatusProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewControlService(tenant string, repo ConfigRepository, secrets *secretstore.Store, verifier CredentialVerifier) (*ControlService, error) {
|
|
||||||
if strings.TrimSpace(tenant) == "" || repo == nil || secrets == nil || verifier == nil {
|
|
||||||
return nil, errors.New("admin control dependencies are required")
|
|
||||||
}
|
|
||||||
return &ControlService{tenant: strings.TrimSpace(tenant), repo: repo, secrets: secrets, verifier: verifier}, nil
|
|
||||||
}
|
|
||||||
func (s *ControlService) Snapshot(ctx context.Context) (Snapshot, error) {
|
|
||||||
id, ref, verified, err := s.repo.ActiveDingTalkConfig(ctx, s.tenant)
|
|
||||||
if err != nil {
|
|
||||||
return Snapshot{}, err
|
|
||||||
}
|
|
||||||
admins, err := s.repo.AdministratorCount(ctx, s.tenant)
|
|
||||||
if err != nil {
|
|
||||||
return Snapshot{}, err
|
|
||||||
}
|
|
||||||
bindings, err := s.repo.ActiveBindings(ctx, s.tenant)
|
|
||||||
if err != nil {
|
|
||||||
return Snapshot{}, err
|
|
||||||
}
|
|
||||||
administratorViews := []AdministratorView{}
|
|
||||||
if administratorRepo, ok := s.repo.(AdministratorRepository); ok {
|
|
||||||
administrators, listErr := administratorRepo.ListAdministrators(ctx, s.tenant)
|
|
||||||
if listErr != nil {
|
|
||||||
return Snapshot{}, listErr
|
|
||||||
}
|
|
||||||
activeCount := 0
|
|
||||||
for _, administrator := range administrators {
|
|
||||||
if administrator.Status == "active" {
|
|
||||||
activeCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, administrator := range administrators {
|
|
||||||
administratorViews = append(administratorViews, AdministratorView{
|
|
||||||
StaffID: administrator.StaffID, StaffIDMasked: maskID(administrator.StaffID),
|
|
||||||
DisplayName: administrator.DisplayName, Department: administrator.Department,
|
|
||||||
Role: administrator.Role, Status: administrator.Status,
|
|
||||||
CanDisable: administrator.Status == "active" && activeCount > 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state := settings.DetermineState(settings.Snapshot{ClientID: id, SecretConfigured: s.secrets.Configured(ref), CredentialsVerified: verified, AdministratorCount: admins})
|
|
||||||
now := time.Now()
|
|
||||||
components := []Component{
|
|
||||||
{Name: "配置仓库", State: "ok", Code: "repository_ok", Detail: "PostgreSQL配置查询成功", LastSuccess: now, CheckedAt: now},
|
|
||||||
{Name: "钉钉OAuth", State: status(verified), Code: ternaryCode(verified, "oauth_verified", "oauth_unconfigured"), Detail: "当前应用凭据验证状态", CheckedAt: now},
|
|
||||||
{Name: "管理员", State: countStatus(admins), Code: ternaryCode(admins > 0, "admin_configured", "admin_missing"), Detail: itoa(admins) + " 位有效管理员", CheckedAt: now},
|
|
||||||
{Name: "技术支持群绑定", State: bindingStatus(bindings, "support_group"), Code: bindingCode(bindings, "support_group"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
|
|
||||||
{Name: "问题AI表格绑定", State: bindingStatus(bindings, "question_table"), Code: bindingCode(bindings, "question_table"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
|
|
||||||
{Name: "FAQ表格绑定", State: bindingStatus(bindings, "faq_table"), Code: bindingCode(bindings, "faq_table"), Detail: "仅显示是否存在active绑定", CheckedAt: now},
|
|
||||||
}
|
|
||||||
if s.status != nil {
|
|
||||||
operational, probeErr := s.status.Components(ctx)
|
|
||||||
if probeErr != nil {
|
|
||||||
components = append(components, Component{Name: "运行探针", State: "error", Code: "component_probe_failed", Detail: "运行状态探针失败;未显示原始错误", CheckedAt: time.Now()})
|
|
||||||
} else {
|
|
||||||
components = append(components, operational...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Snapshot{SystemState: string(state), ClientIDMasked: maskID(id), SecretConfigured: s.secrets.Configured(ref), Components: components, Administrators: administratorViews}, nil
|
|
||||||
}
|
|
||||||
func (s *ControlService) VerifyCredentials(ctx context.Context, id, secret string) Verification {
|
|
||||||
r := s.verifier.Verify(ctx, id, secret)
|
|
||||||
return Verification{OK: r.OK, Code: r.Code, CheckedAt: r.CheckedAt}
|
|
||||||
}
|
|
||||||
func (s *ControlService) SaveCredentials(ctx context.Context, id, secret string) error {
|
|
||||||
_, oldRef, _ := s.repo.CandidateDingTalkConfig(ctx, s.tenant)
|
|
||||||
ref, err := s.secrets.Put("dingtalk", secret)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = s.repo.SaveDingTalkConfig(ctx, s.tenant, strings.TrimSpace(id), ref, false); err != nil {
|
|
||||||
_ = s.secrets.Delete(ref)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if oldRef != "" && oldRef != ref {
|
|
||||||
_ = s.secrets.Delete(oldRef)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (s *ControlService) SetCandidateActivator(activator CandidateActivator) { s.activator = activator }
|
|
||||||
func (s *ControlService) SetOperationalStatusProvider(provider OperationalStatusProvider) {
|
|
||||||
s.status = provider
|
|
||||||
}
|
|
||||||
func (s *ControlService) ActivateCandidate(ctx context.Context) error {
|
|
||||||
if s.activator == nil {
|
|
||||||
return errors.New("candidate activator unavailable")
|
|
||||||
}
|
|
||||||
_, ref, err := s.repo.CandidateDingTalkConfig(ctx, s.tenant)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = s.activator.ActivateCandidate(ctx); err != nil {
|
|
||||||
_ = s.repo.MarkDingTalkCandidateFailed(ctx, s.tenant, ref, "activation_failed")
|
|
||||||
return errors.New("candidate activation failed")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (s *ControlService) CreateBootstrap(ctx context.Context, code string) error {
|
|
||||||
return s.repo.CreateBootstrapChallenge(ctx, s.tenant, code, time.Now().Add(10*time.Minute))
|
|
||||||
}
|
|
||||||
func (s *ControlService) ConfirmBootstrap(ctx context.Context) error {
|
|
||||||
return s.repo.ConfirmBootstrap(ctx, s.tenant)
|
|
||||||
}
|
|
||||||
func (s *ControlService) DisableAdministrator(ctx context.Context, staffID string) error {
|
|
||||||
staffID = strings.TrimSpace(staffID)
|
|
||||||
if staffID == "" {
|
|
||||||
return settings.ErrAdministratorNotFound
|
|
||||||
}
|
|
||||||
repo, ok := s.repo.(AdministratorRepository)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("administrator management unavailable")
|
|
||||||
}
|
|
||||||
return repo.DisableAdministrator(ctx, s.tenant, staffID)
|
|
||||||
}
|
|
||||||
func maskID(v string) string {
|
|
||||||
v = strings.TrimSpace(v)
|
|
||||||
if len(v) <= 6 {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return v[:3] + "***" + v[len(v)-3:]
|
|
||||||
}
|
|
||||||
func status(ok bool) string {
|
|
||||||
if ok {
|
|
||||||
return "ok"
|
|
||||||
}
|
|
||||||
return "unconfigured"
|
|
||||||
}
|
|
||||||
func countStatus(n int) string {
|
|
||||||
if n > 0 {
|
|
||||||
return "ok"
|
|
||||||
}
|
|
||||||
return "unconfigured"
|
|
||||||
}
|
|
||||||
func bindingStatus(m map[string]settings.ResourceBinding, k string) string {
|
|
||||||
if _, ok := m[k]; ok {
|
|
||||||
return "configured"
|
|
||||||
}
|
|
||||||
return "unconfigured"
|
|
||||||
}
|
|
||||||
func bindingCode(m map[string]settings.ResourceBinding, k string) string {
|
|
||||||
if _, ok := m[k]; ok {
|
|
||||||
return "binding_active"
|
|
||||||
}
|
|
||||||
return "binding_missing"
|
|
||||||
}
|
|
||||||
func ternaryCode(ok bool, success, failure string) string {
|
|
||||||
if ok {
|
|
||||||
return success
|
|
||||||
}
|
|
||||||
return failure
|
|
||||||
}
|
|
||||||
func itoa(n int) string {
|
|
||||||
if n == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
b := make([]byte, 0, 8)
|
|
||||||
for n > 0 {
|
|
||||||
b = append([]byte{byte('0' + n%10)}, b...)
|
|
||||||
n /= 10
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
package adminweb
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/dingtalkconfig"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore"
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type controlRepo struct {
|
|
||||||
savedVerified bool
|
|
||||||
savedRef string
|
|
||||||
candidateRef string
|
|
||||||
failedCode string
|
|
||||||
admins []settings.Administrator
|
|
||||||
disabledStaff string
|
|
||||||
disableErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*controlRepo) ActiveDingTalkConfig(context.Context, string) (string, string, bool, error) {
|
|
||||||
return "old", "old-ref", true, nil
|
|
||||||
}
|
|
||||||
func (r *controlRepo) CandidateDingTalkConfig(context.Context, string) (string, string, error) {
|
|
||||||
return "new", r.candidateRef, nil
|
|
||||||
}
|
|
||||||
func (r *controlRepo) SaveDingTalkConfig(_ context.Context, _, _, ref string, verified bool) error {
|
|
||||||
r.savedRef = ref
|
|
||||||
r.savedVerified = verified
|
|
||||||
r.candidateRef = ref
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (r *controlRepo) MarkDingTalkCandidateFailed(_ context.Context, _, _, code string) error {
|
|
||||||
r.failedCode = code
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (r *controlRepo) AdministratorCount(context.Context, string) (int, error) {
|
|
||||||
if r.admins == nil {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
n := 0
|
|
||||||
for _, admin := range r.admins {
|
|
||||||
if admin.Status == "active" {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
func (r *controlRepo) ListAdministrators(context.Context, string) ([]settings.Administrator, error) {
|
|
||||||
return r.admins, nil
|
|
||||||
}
|
|
||||||
func (r *controlRepo) DisableAdministrator(_ context.Context, _, staffID string) error {
|
|
||||||
r.disabledStaff = staffID
|
|
||||||
return r.disableErr
|
|
||||||
}
|
|
||||||
func (*controlRepo) ActiveBindings(context.Context, string) (map[string]settings.ResourceBinding, error) {
|
|
||||||
return map[string]settings.ResourceBinding{}, nil
|
|
||||||
}
|
|
||||||
func (*controlRepo) CreateBootstrapChallenge(context.Context, string, string, time.Time) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (*controlRepo) ConfirmBootstrap(context.Context, string) error { return nil }
|
|
||||||
|
|
||||||
type verifyOK struct{}
|
|
||||||
|
|
||||||
func (verifyOK) Verify(context.Context, string, string) dingtalkconfig.Result {
|
|
||||||
return dingtalkconfig.Result{OK: true, Code: "oauth_ok"}
|
|
||||||
}
|
|
||||||
|
|
||||||
type activatorStub struct {
|
|
||||||
err error
|
|
||||||
calls int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activatorStub) ActivateCandidate(context.Context) error { a.calls++; return a.err }
|
|
||||||
|
|
||||||
type operationalStatusStub struct {
|
|
||||||
components []Component
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s operationalStatusStub) Components(context.Context) ([]Component, error) {
|
|
||||||
return s.components, s.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSnapshotIncludesOperationalComponents(t *testing.T) {
|
|
||||||
secrets, err := secretstore.New(t.TempDir())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
service, err := NewControlService("tenant", &controlRepo{}, secrets, verifyOK{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
checked := time.Unix(100, 0)
|
|
||||||
service.SetOperationalStatusProvider(operationalStatusStub{components: []Component{{
|
|
||||||
Name: "WeKnora", State: "ok", Code: "health_ok", CheckedAt: checked,
|
|
||||||
}}})
|
|
||||||
snapshot, err := service.Snapshot(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
found := false
|
|
||||||
for _, component := range snapshot.Components {
|
|
||||||
if component.Name == "WeKnora" && component.Code == "health_ok" && component.CheckedAt.Equal(checked) {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatalf("components=%+v", snapshot.Components)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSnapshotKeepsPageAvailableWhenOperationalProbeFails(t *testing.T) {
|
|
||||||
secrets, _ := secretstore.New(t.TempDir())
|
|
||||||
service, err := NewControlService("tenant", &controlRepo{}, secrets, verifyOK{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
service.SetOperationalStatusProvider(operationalStatusStub{err: errors.New("probe failed")})
|
|
||||||
snapshot, err := service.Snapshot(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
last := snapshot.Components[len(snapshot.Components)-1]
|
|
||||||
if last.Name != "运行探针" || last.State != "error" || last.Code != "component_probe_failed" {
|
|
||||||
t.Fatalf("component=%+v", last)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSaveCredentialsCreatesCandidateNotActive(t *testing.T) {
|
|
||||||
secrets, err := secretstore.New(t.TempDir())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
repo := &controlRepo{}
|
|
||||||
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err = service.SaveCredentials(context.Background(), "new", "secret"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if repo.savedVerified {
|
|
||||||
t.Fatal("candidate must not be active")
|
|
||||||
}
|
|
||||||
if repo.savedRef == "" || !secrets.Configured(repo.savedRef) {
|
|
||||||
t.Fatalf("ref=%q", repo.savedRef)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func TestActivateCandidateDelegatesAndMarksFailure(t *testing.T) {
|
|
||||||
secrets, err := secretstore.New(t.TempDir())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
repo := &controlRepo{candidateRef: "candidate-ref"}
|
|
||||||
activator := &activatorStub{err: errors.New("candidate_start_failed_rolled_back")}
|
|
||||||
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
service.SetCandidateActivator(activator)
|
|
||||||
if err = service.ActivateCandidate(context.Background()); err == nil {
|
|
||||||
t.Fatal("expected failure")
|
|
||||||
}
|
|
||||||
if activator.calls != 1 || repo.failedCode != "activation_failed" {
|
|
||||||
t.Fatalf("calls=%d code=%q", activator.calls, repo.failedCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSnapshotIncludesAdministratorsAndOnlyAllowsDisableWhenAnotherIsActive(t *testing.T) {
|
|
||||||
secrets, _ := secretstore.New(t.TempDir())
|
|
||||||
repo := &controlRepo{admins: []settings.Administrator{
|
|
||||||
{StaffID: "staff-old", DisplayName: "旧管理员", Department: "技术部", Role: "super_admin", Status: "active"},
|
|
||||||
{StaffID: "staff-new", DisplayName: "新管理员", Department: "产品部", Role: "super_admin", Status: "active"},
|
|
||||||
{StaffID: "staff-off", DisplayName: "停用管理员", Role: "admin", Status: "disabled"},
|
|
||||||
}}
|
|
||||||
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
snapshot, err := service.Snapshot(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(snapshot.Administrators) != 3 || !snapshot.Administrators[0].CanDisable || !snapshot.Administrators[1].CanDisable || snapshot.Administrators[2].CanDisable {
|
|
||||||
t.Fatalf("administrators=%+v", snapshot.Administrators)
|
|
||||||
}
|
|
||||||
if snapshot.Administrators[0].StaffIDMasked == "staff-old" || snapshot.Administrators[0].StaffIDMasked == "" {
|
|
||||||
t.Fatalf("staff ID must be masked: %+v", snapshot.Administrators[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestControlServiceDelegatesAdministratorDisable(t *testing.T) {
|
|
||||||
secrets, _ := secretstore.New(t.TempDir())
|
|
||||||
repo := &controlRepo{}
|
|
||||||
service, err := NewControlService("tenant", repo, secrets, verifyOK{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err = service.DisableAdministrator(context.Background(), "staff-old"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if repo.disabledStaff != "staff-old" {
|
|
||||||
t.Fatalf("disabled=%q", repo.disabledStaff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
package adminweb
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Snapshot struct {
|
|
||||||
SystemState, ClientIDMasked string
|
|
||||||
SecretConfigured bool
|
|
||||||
Components []Component
|
|
||||||
Administrators []AdministratorView
|
|
||||||
}
|
|
||||||
type AdministratorView struct {
|
|
||||||
StaffID, StaffIDMasked, DisplayName, Department, Role, Status string
|
|
||||||
CanDisable bool
|
|
||||||
}
|
|
||||||
type Component struct {
|
|
||||||
Name, State, Code, Detail string
|
|
||||||
LastSuccess, CheckedAt time.Time
|
|
||||||
}
|
|
||||||
type Verification struct {
|
|
||||||
OK bool
|
|
||||||
Code string
|
|
||||||
CheckedAt time.Time
|
|
||||||
}
|
|
||||||
type Service interface {
|
|
||||||
Snapshot(context.Context) (Snapshot, error)
|
|
||||||
SaveCredentials(context.Context, string, string) error
|
|
||||||
VerifyCredentials(context.Context, string, string) Verification
|
|
||||||
}
|
|
||||||
type BootstrapService interface {
|
|
||||||
CreateBootstrap(context.Context, string) error
|
|
||||||
ConfirmBootstrap(context.Context) error
|
|
||||||
}
|
|
||||||
type CandidateService interface {
|
|
||||||
ActivateCandidate(context.Context) error
|
|
||||||
}
|
|
||||||
type AdministratorService interface {
|
|
||||||
DisableAdministrator(context.Context, string) error
|
|
||||||
}
|
|
||||||
type server struct {
|
|
||||||
service Service
|
|
||||||
token string
|
|
||||||
csrfToken string
|
|
||||||
mux *http.ServeMux
|
|
||||||
page *template.Template
|
|
||||||
}
|
|
||||||
type pageData struct {
|
|
||||||
Snapshot
|
|
||||||
CSRFToken string
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingsPage = `<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
|
||||||
<title>DobotAI 设置</title>
|
|
||||||
<style>
|
|
||||||
body{font-family:system-ui,sans-serif;background:#f5f8ff;color:#17324d;max-width:1080px;margin:36px auto;padding:0 20px;line-height:1.55}
|
|
||||||
.card{background:#fff;border:1px solid #dbe7f4;border-radius:14px;padding:20px;margin:14px 0;box-shadow:0 4px 16px rgba(32,77,120,.05)}
|
|
||||||
input{box-sizing:border-box;padding:10px;width:100%;margin:6px 0 12px;border:1px solid #bfd0e2;border-radius:8px}
|
|
||||||
button{padding:10px 18px;background:#1677ff;color:#fff;border:0;border-radius:8px;margin:5px 0;cursor:pointer}.danger{background:#c73535}.muted{color:#55708a}.warn{background:#fff7e6;border-left:4px solid #f0a020;padding:10px 12px;border-radius:6px}.meta{color:#55708a;font-size:13px;line-height:1.6}
|
|
||||||
table{width:100%;border-collapse:collapse;margin-top:12px}th,td{text-align:left;padding:10px;border-bottom:1px solid #e4edf6;vertical-align:middle}.inline{margin:0}.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}@media(max-width:760px){.grid{grid-template-columns:1fr}table{font-size:13px}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>DobotAI 配置中心</h1>
|
|
||||||
<div class="card"><b>系统状态:</b>{{.SystemState}}<br><b>Client ID:</b>{{.ClientIDMasked}}<br><b>Client Secret:</b>{{if .SecretConfigured}}已配置(不可查看){{else}}未配置{{end}}</div>
|
|
||||||
<div class="card"><h2>配置/切换钉钉应用</h2><p>新凭据先在线验证并保存为候选。激活后受控重启整组钉钉组件,失败自动回滚。</p><form method="post" action="/admin/setup/credentials"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>Client ID / AppKey</label><input name="client_id" required><label>Client Secret / AppSecret</label><input type="password" name="client_secret" required autocomplete="new-password"><button>验证并保存候选</button></form><form method="post" action="/admin/setup/candidate/activate"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button>激活候选并重启</button></form></div>
|
|
||||||
<div class="card"><h2>新增/更换管理员</h2><p class="warn"><b>必须先加后停:</b>先确认新管理员已生效,再停用旧管理员。系统禁止停用最后一位有效管理员。</p><ol><li>输入任意6位一次性绑定码并生成,有效期10分钟。</li><li>新管理员私聊机器人发送 <code>/setting 6位码</code>。</li><li>回到本页确认检测到的新管理员。</li><li>在下方确认新管理员状态为active,再停用旧管理员。</li></ol><div class="grid"><form method="post" action="/admin/setup/bootstrap"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><label>一次性绑定码</label><input name="code" pattern="[0-9]{6}" placeholder="输入任意6位数字" required><button>生成6位绑定码</button></form><form method="post" action="/admin/setup/bootstrap/confirm"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><p class="muted">新管理员私聊验证后再点击:</p><button>确认检测到的新管理员</button></form></div><h3>管理员列表</h3><table><thead><tr><th>姓名</th><th>部门</th><th>Staff ID</th><th>角色</th><th>状态</th><th>操作</th></tr></thead><tbody>{{range .Administrators}}<tr><td>{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}</td><td>{{if .Department}}{{.Department}}{{else}}—{{end}}</td><td>{{.StaffIDMasked}}</td><td>{{.Role}}</td><td>{{.Status}}</td><td>{{if .CanDisable}}<form class="inline" method="post" action="/admin/settings/administrators/disable"><input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"><input type="hidden" name="staff_id" value="{{.StaffID}}"><button class="danger" type="submit">停用此管理员</button></form>{{else}}—{{end}}</td></tr>{{else}}<tr><td colspan="6" class="muted">暂无管理员记录</td></tr>{{end}}</tbody></table></div>
|
|
||||||
<h2>组件检查</h2>{{range .Components}}<div class="card"><b>{{.Name}}</b>:{{.State}}<div class="meta">错误码:{{if .Code}}{{.Code}}{{else}}—{{end}}<br>最后成功:{{timeText .LastSuccess}}<br>最后检查:{{timeText .CheckedAt}}</div><p>{{.Detail}}</p></div>{{end}}
|
|
||||||
</body></html>`
|
|
||||||
|
|
||||||
func New(service Service, token string) http.Handler {
|
|
||||||
functions := template.FuncMap{"timeText": func(value time.Time) string {
|
|
||||||
if value.IsZero() {
|
|
||||||
return "—"
|
|
||||||
}
|
|
||||||
return value.UTC().Format("2006-01-02 15:04:05")
|
|
||||||
}}
|
|
||||||
csrfBytes := make([]byte, 32)
|
|
||||||
if _, err := rand.Read(csrfBytes); err != nil {
|
|
||||||
panic("admin CSRF token unavailable")
|
|
||||||
}
|
|
||||||
s := &server{service: service, token: strings.TrimSpace(token), csrfToken: hex.EncodeToString(csrfBytes), mux: http.NewServeMux(), page: template.Must(template.New("settings").Funcs(functions).Parse(settingsPage))}
|
|
||||||
s.mux.HandleFunc("GET /admin/settings", s.settings)
|
|
||||||
s.mux.HandleFunc("GET /admin/setup", s.settings)
|
|
||||||
s.mux.HandleFunc("POST /admin/setup/credentials", s.saveCredentials)
|
|
||||||
s.mux.HandleFunc("POST /admin/setup/candidate/activate", s.activateCandidate)
|
|
||||||
s.mux.HandleFunc("POST /admin/setup/bootstrap", s.createBootstrap)
|
|
||||||
s.mux.HandleFunc("POST /admin/setup/bootstrap/confirm", s.confirmBootstrap)
|
|
||||||
s.mux.HandleFunc("POST /admin/settings/administrators/disable", s.disableAdministrator)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
if !s.authorized(r) {
|
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="DobotAI Admin"`)
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.mux.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
func (s *server) authorized(r *http.Request) bool {
|
|
||||||
if s.token == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if _, password, ok := r.BasicAuth(); ok && len(password) == len(s.token) && subtle.ConstantTimeCompare([]byte(password), []byte(s.token)) == 1 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
v := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
||||||
return len(v) == len(s.token) && subtle.ConstantTimeCompare([]byte(v), []byte(s.token)) == 1
|
|
||||||
}
|
|
||||||
func (s *server) requireCSRFForm(w http.ResponseWriter, r *http.Request, maxBytes int64) bool {
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
provided := strings.TrimSpace(r.FormValue("csrf_token"))
|
|
||||||
if len(provided) != len(s.csrfToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.csrfToken)) != 1 {
|
|
||||||
http.Error(w, "forbidden", http.StatusForbidden)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
func (s *server) settings(w http.ResponseWriter, r *http.Request) {
|
|
||||||
snap, err := s.service.Snapshot(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "settings unavailable", 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
_ = s.page.Execute(w, pageData{Snapshot: snap, CSRFToken: s.csrfToken})
|
|
||||||
}
|
|
||||||
func (s *server) saveCredentials(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.requireCSRFForm(w, r, 16<<10) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id, secret := strings.TrimSpace(r.FormValue("client_id")), strings.TrimSpace(r.FormValue("client_secret"))
|
|
||||||
result := s.service.VerifyCredentials(r.Context(), id, secret)
|
|
||||||
if !result.OK {
|
|
||||||
http.Error(w, fmt.Sprintf("credential verification failed: %s", result.Code), 422)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.service.SaveCredentials(r.Context(), id, secret); err != nil {
|
|
||||||
http.Error(w, "credential save failed", 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
func (s *server) activateCandidate(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.requireCSRFForm(w, r, 4<<10) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
service, ok := s.service.(CandidateService)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "candidate unavailable", http.StatusNotImplemented)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := service.ActivateCandidate(r.Context()); err != nil {
|
|
||||||
http.Error(w, "candidate activation failed", http.StatusConflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
_, _ = w.Write([]byte("候选已激活,DobotAI正在受控重启。\n"))
|
|
||||||
}
|
|
||||||
func (s *server) createBootstrap(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.requireCSRFForm(w, r, 4<<10) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
service, ok := s.service.(BootstrapService)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "bootstrap unavailable", http.StatusNotImplemented)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
code := strings.TrimSpace(r.FormValue("code"))
|
|
||||||
if !sixDigits(code) {
|
|
||||||
http.Error(w, "binding code must be 6 digits", 400)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := service.CreateBootstrap(r.Context(), code); err != nil {
|
|
||||||
http.Error(w, "create binding code failed", 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
fmt.Fprintf(w, "请在10分钟内私聊机器人发送:/setting %s\n", code)
|
|
||||||
}
|
|
||||||
func (s *server) confirmBootstrap(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.requireCSRFForm(w, r, 4<<10) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
service, ok := s.service.(BootstrapService)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "bootstrap unavailable", http.StatusNotImplemented)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := service.ConfirmBootstrap(r.Context()); err != nil {
|
|
||||||
http.Error(w, "尚未检测到可确认的管理员", http.StatusConflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
func (s *server) disableAdministrator(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.requireCSRFForm(w, r, 4<<10) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
service, ok := s.service.(AdministratorService)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "administrator management unavailable", http.StatusNotImplemented)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
staffID := strings.TrimSpace(r.FormValue("staff_id"))
|
|
||||||
if staffID == "" {
|
|
||||||
http.Error(w, "administrator not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err := service.DisableAdministrator(r.Context(), staffID)
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
|
||||||
case errors.Is(err, settings.ErrLastAdministrator):
|
|
||||||
http.Error(w, "至少保留一位有效管理员;请先确认新管理员已生效。", http.StatusConflict)
|
|
||||||
case errors.Is(err, settings.ErrAdministratorAlreadyDisabled):
|
|
||||||
http.Error(w, "管理员已经停用。", http.StatusConflict)
|
|
||||||
case errors.Is(err, settings.ErrAdministratorNotFound):
|
|
||||||
http.Error(w, "administrator not found", http.StatusNotFound)
|
|
||||||
default:
|
|
||||||
http.Error(w, "administrator update failed", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func sixDigits(value string) bool {
|
|
||||||
if len(value) != 6 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, ch := range value {
|
|
||||||
if ch < '0' || ch > '9' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
package adminweb
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeService struct {
|
|
||||||
snapshot Snapshot
|
|
||||||
verified bool
|
|
||||||
saved bool
|
|
||||||
disabled string
|
|
||||||
disableErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeService) Snapshot(context.Context) (Snapshot, error) { return f.snapshot, nil }
|
|
||||||
func (f *fakeService) SaveCredentials(context.Context, string, string) error {
|
|
||||||
f.saved = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (f *fakeService) VerifyCredentials(context.Context, string, string) Verification {
|
|
||||||
f.verified = true
|
|
||||||
return Verification{OK: true, Code: "ok", CheckedAt: time.Unix(1, 0)}
|
|
||||||
}
|
|
||||||
func (f *fakeService) DisableAdministrator(_ context.Context, staffID string) error {
|
|
||||||
f.disabled = staffID
|
|
||||||
return f.disableErr
|
|
||||||
}
|
|
||||||
func csrfTokenFromPage(t *testing.T, h http.Handler) string {
|
|
||||||
t.Helper()
|
|
||||||
get := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
|
||||||
get.Header.Set("Authorization", "Bearer admin-token")
|
|
||||||
page := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(page, get)
|
|
||||||
marker := `name="csrf_token" value="`
|
|
||||||
start := strings.Index(page.Body.String(), marker)
|
|
||||||
if start < 0 {
|
|
||||||
t.Fatalf("csrf token missing: %s", page.Body.String())
|
|
||||||
}
|
|
||||||
start += len(marker)
|
|
||||||
end := strings.Index(page.Body.String()[start:], `"`)
|
|
||||||
if end < 0 {
|
|
||||||
t.Fatal("csrf token terminator missing")
|
|
||||||
}
|
|
||||||
return page.Body.String()[start : start+end]
|
|
||||||
}
|
|
||||||
func TestDashboardNeverRendersSecrets(t *testing.T) {
|
|
||||||
svc := &fakeService{snapshot: Snapshot{SystemState: "active", ClientIDMasked: "ding***", SecretConfigured: true}}
|
|
||||||
h := New(svc, "admin-token")
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer admin-token")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
if w.Code != 200 {
|
|
||||||
t.Fatalf("status=%d", w.Code)
|
|
||||||
}
|
|
||||||
body := w.Body.String()
|
|
||||||
if strings.Contains(body, "admin-token") || strings.Contains(strings.ToLower(body), "client secret value") {
|
|
||||||
t.Fatal("secret leaked")
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "ding***") {
|
|
||||||
t.Fatal("masked client id missing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func TestDashboardRendersOperationalComponentFields(t *testing.T) {
|
|
||||||
checked := time.Date(2026, 8, 13, 18, 0, 0, 0, time.UTC)
|
|
||||||
svc := &fakeService{snapshot: Snapshot{Components: []Component{{
|
|
||||||
Name: "WeKnora", State: "ok", Code: "health_ok", Detail: "HTTP健康探针正常",
|
|
||||||
LastSuccess: checked.Add(-time.Minute), CheckedAt: checked,
|
|
||||||
}}}}
|
|
||||||
h := New(svc, "admin-token")
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer admin-token")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
body := w.Body.String()
|
|
||||||
for _, want := range []string{"WeKnora", "health_ok", "HTTP健康探针正常", "2026-08-13 17:59:00", "2026-08-13 18:00:00"} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Fatalf("dashboard missing %q: %s", want, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSaveCredentialsRequiresVerification(t *testing.T) {
|
|
||||||
svc := &fakeService{}
|
|
||||||
h := New(svc, "admin-token")
|
|
||||||
csrf := csrfTokenFromPage(t, h)
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/admin/setup/credentials", strings.NewReader("client_id=id&client_secret=secret&csrf_token="+csrf))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Authorization", "Bearer admin-token")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusSeeOther || !svc.verified || !svc.saved {
|
|
||||||
t.Fatalf("status=%d verified=%v saved=%v", w.Code, svc.verified, svc.saved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAllAdminPOSTRoutesRejectMissingCSRF(t *testing.T) {
|
|
||||||
svc := &fakeService{}
|
|
||||||
h := New(svc, "admin-token")
|
|
||||||
for _, route := range []string{"/admin/setup/credentials", "/admin/setup/candidate/activate", "/admin/setup/bootstrap", "/admin/setup/bootstrap/confirm", "/admin/settings/administrators/disable"} {
|
|
||||||
req := httptest.NewRequest(http.MethodPost, route, strings.NewReader("client_id=id&client_secret=secret&code=123456&staff_id=staff-old"))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Authorization", "Bearer admin-token")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("route=%s status=%d body=%q", route, w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if svc.saved || svc.disabled != "" {
|
|
||||||
t.Fatalf("business service called saved=%v disabled=%q", svc.saved, svc.disabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDashboardRendersAdministratorReplacementWorkflow(t *testing.T) {
|
|
||||||
svc := &fakeService{snapshot: Snapshot{Administrators: []AdministratorView{{StaffID: "staff-old", StaffIDMasked: "sta***old", DisplayName: "旧管理员", Department: "技术部", Role: "super_admin", Status: "active", CanDisable: true}}}}
|
|
||||||
h := New(svc, "admin-token")
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer admin-token")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
for _, want := range []string{"新增/更换管理员", "先加后停", "旧管理员", "技术部", "sta***old", "生成6位绑定码", "停用此管理员"} {
|
|
||||||
if !strings.Contains(w.Body.String(), want) {
|
|
||||||
t.Fatalf("dashboard missing %q: %s", want, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(w.Body.String(), ">staff-old<") {
|
|
||||||
t.Fatalf("raw staff ID must not be rendered as visible text: %s", w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDisableAdministratorRequiresCSRFAndMapsLastAdminConflict(t *testing.T) {
|
|
||||||
svc := &fakeService{}
|
|
||||||
h := New(svc, "admin-token")
|
|
||||||
post := func(body string) *httptest.ResponseRecorder {
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/admin/settings/administrators/disable", strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Authorization", "Bearer admin-token")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
if w := post("staff_id=staff-old"); w.Code != http.StatusForbidden || svc.disabled != "" {
|
|
||||||
t.Fatalf("missing csrf status=%d disabled=%q", w.Code, svc.disabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
csrf := csrfTokenFromPage(t, h)
|
|
||||||
|
|
||||||
svc.disableErr = settings.ErrLastAdministrator
|
|
||||||
w := post("staff_id=staff-old&csrf_token=" + csrf)
|
|
||||||
if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "至少保留一位有效管理员") {
|
|
||||||
t.Fatalf("last-admin status=%d body=%q", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
svc.disableErr = nil
|
|
||||||
w = post("staff_id=staff-old&csrf_token=" + csrf)
|
|
||||||
if w.Code != http.StatusSeeOther || svc.disabled != "staff-old" {
|
|
||||||
t.Fatalf("success status=%d disabled=%q body=%q", w.Code, svc.disabled, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,4 +14,5 @@ var (
|
|||||||
ErrAdministratorNotFound = errors.New("administrator not found")
|
ErrAdministratorNotFound = errors.New("administrator not found")
|
||||||
ErrLastAdministrator = errors.New("last active administrator cannot be disabled")
|
ErrLastAdministrator = errors.New("last active administrator cannot be disabled")
|
||||||
ErrAdministratorAlreadyDisabled = errors.New("administrator already disabled")
|
ErrAdministratorAlreadyDisabled = errors.New("administrator already disabled")
|
||||||
|
ErrBootstrapCandidatePending = errors.New("bootstrap candidate pending")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func (c *Controller) Handle(ctx context.Context, in SettingInput) (string, error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return "身份已验证,请回到服务器命令行等待确认。", nil
|
return "身份已验证,服务器终端将自动完成管理员绑定。", nil
|
||||||
}
|
}
|
||||||
if explicit && len(args) == 0 {
|
if explicit && len(args) == 0 {
|
||||||
admin, err := c.repo.IsAdministrator(ctx, in.TenantKey, in.StaffID)
|
admin, err := c.repo.IsAdministrator(ctx, in.TenantKey, in.StaffID)
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ func TestControllerDetectsBootstrapCandidate(t *testing.T) {
|
|||||||
if r.candidate.Code != "123456" || r.candidate.StaffID != "a" {
|
if r.candidate.Code != "123456" || r.candidate.StaffID != "a" {
|
||||||
t.Fatalf("candidate=%+v", r.candidate)
|
t.Fatalf("candidate=%+v", r.candidate)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "服务器命令行") || strings.Contains(out, "后台") {
|
if !strings.Contains(out, "服务器终端将自动完成管理员绑定") || strings.Contains(out, "后台") {
|
||||||
t.Fatalf("reply=%q must direct confirmation back to the server CLI only", out)
|
t.Fatalf("reply=%q must explain terminal auto-confirmation only", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ func (r *SettingsRepository) ConfirmBootstrap(ctx context.Context, tenant string
|
|||||||
var challengeID, staff, union, name, dept string
|
var challengeID, staff, union, name, dept string
|
||||||
err = tx.QueryRow(ctx, `SELECT id,candidate_staff_id,candidate_union_id,candidate_display_name,candidate_department FROM admin_bootstrap_challenges WHERE tenant_key=$1 AND status='candidate_detected' AND expires_at>NOW() FOR UPDATE`, tenant).Scan(&challengeID, &staff, &union, &name, &dept)
|
err = tx.QueryRow(ctx, `SELECT id,candidate_staff_id,candidate_union_id,candidate_display_name,candidate_department FROM admin_bootstrap_challenges WHERE tenant_key=$1 AND status='candidate_detected' AND expires_at>NOW() FOR UPDATE`, tenant).Scan(&challengeID, &staff, &union, &name, &dept)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return settings.ErrBootstrapCandidatePending
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(staff) == "" {
|
if strings.TrimSpace(staff) == "" {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package scripts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDobotAIDingTalkContainerAssetsProvideSafeShortOperatorCommands(t *testing.T) {
|
||||||
|
compose, err := os.ReadFile("../deploy/dobotai-dingtalk/compose.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, required := range []string{
|
||||||
|
"dobotai-dingtalk:",
|
||||||
|
"container_name: dobotai-dingtalk",
|
||||||
|
"network_mode: host",
|
||||||
|
"env_file:",
|
||||||
|
"read_only: true",
|
||||||
|
"/var/lib/dobotai/secrets",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(string(compose), required) {
|
||||||
|
t.Errorf("compose missing %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configure, err := os.ReadFile("../deploy/dobotai-dingtalk/dobotai-dingtalk-configure")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, required := range []string{
|
||||||
|
"configure_env.py",
|
||||||
|
"/etc/dobotai-test/dobotai.env",
|
||||||
|
"dobotai-dingtalk-start",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(string(configure), required) {
|
||||||
|
t.Errorf("configure command missing %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bind, err := os.ReadFile("../deploy/dobotai-dingtalk/dobotai-dingtalk-bind-admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, required := range []string{
|
||||||
|
"docker exec -it dobotai-dingtalk",
|
||||||
|
"/usr/local/bin/dobotai bootstrap-admin",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(string(bind), required) {
|
||||||
|
t.Errorf("bind command missing %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDobotAIDingTalkCredentialConfigurationNeverPassesSecretsOnCommandLine(t *testing.T) {
|
||||||
|
data, err := os.ReadFile("../deploy/dobotai-dingtalk/configure_env.py")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source := string(data)
|
||||||
|
for _, required := range []string{"getpass.getpass", "os.replace", "0o600", "DINGTALK_CLIENT_SECRET"} {
|
||||||
|
if !strings.Contains(source, required) {
|
||||||
|
t.Errorf("credential configurator missing %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{"subprocess.run([", "-v", "DINGTALK_CLIENT_SECRET="} {
|
||||||
|
if strings.Contains(source, forbidden) {
|
||||||
|
t.Errorf("credential configurator must not expose secret through process arguments: %q", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user