diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..39636ff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.env +.env.* +!.env.example +bin/ +.acceptance-canary/ +.hermes/ +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..392d46e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 0dce091..b3fd777 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,24 @@ DOBOTAI_TENANT_KEY 持续推理默认最多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 不提供网页管理面;钉钉凭据和运行配置由部署环境文件管理。 + ## 覆盖与人工支持 - 有结构化检索引用的回答标记为已覆盖; diff --git a/cmd/dobotai/bootstrap_admin.go b/cmd/dobotai/bootstrap_admin.go new file mode 100644 index 0000000..982767b --- /dev/null +++ b/cmd/dobotai/bootstrap_admin.go @@ -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: + } + } +} diff --git a/cmd/dobotai/bootstrap_admin_test.go b/cmd/dobotai/bootstrap_admin_test.go new file mode 100644 index 0000000..83737e2 --- /dev/null +++ b/cmd/dobotai/bootstrap_admin_test.go @@ -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) + } +} diff --git a/cmd/dobotai/candidate_activation.go b/cmd/dobotai/candidate_activation.go deleted file mode 100644 index e3c3db0..0000000 --- a/cmd/dobotai/candidate_activation.go +++ /dev/null @@ -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 -} diff --git a/cmd/dobotai/candidate_activation_test.go b/cmd/dobotai/candidate_activation_test.go deleted file mode 100644 index ec92d88..0000000 --- a/cmd/dobotai/candidate_activation_test.go +++ /dev/null @@ -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") - } -} diff --git a/cmd/dobotai/main.go b/cmd/dobotai/main.go index 9516dda..6f254ac 100644 --- a/cmd/dobotai/main.go +++ b/cmd/dobotai/main.go @@ -12,7 +12,6 @@ import ( "syscall" "time" - "gitea.zheromiku.top/Weknora/dobotai-platform/internal/adminweb" "gitea.zheromiku.top/Weknora/dobotai-platform/internal/agentbinding" "gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit" "gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk" @@ -45,6 +44,12 @@ func runtimeBuildInfo() httpapi.BuildInfo { func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 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 storeCtx, storeCancel := context.WithTimeout(ctx, 10*time.Second) defer storeCancel() @@ -64,8 +69,7 @@ func main() { address = "127.0.0.1:8090" } if strings.TrimSpace(settingsRuntime.clientID) == "" || strings.TrimSpace(settingsRuntime.secret) == "" { - runSetupOnly(ctx, cancel, settingsRuntime, address, os.Getenv("DOBOTAI_TENANT_KEY"), &restartRequested) - return + log.Fatal("DingTalk credentials are required; configure DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET in the protected runtime environment") } client, err := weknora.NewClient(os.Getenv("WEKNORA_BASE_URL"), os.Getenv("WEKNORA_API_KEY"), 120*time.Second) if err != nil { @@ -131,11 +135,6 @@ func main() { if err != nil { 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{}) faqCfg, err := readFAQConfig() if err != nil { @@ -329,37 +328,11 @@ func main() { log.Printf("DingTalk Stream ingress started for tenant %s", dingCfg.TenantKey) } 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.Handle("/", api.Handler()) if outboundImages.handler != nil { 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} go func() { log.Printf("DobotAI ingress listening on %s", address) diff --git a/cmd/dobotai/main_test.go b/cmd/dobotai/main_test.go index 0fe67e2..2217dea 100644 --- a/cmd/dobotai/main_test.go +++ b/cmd/dobotai/main_test.go @@ -183,3 +183,19 @@ func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) { 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") + } +} diff --git a/cmd/dobotai/operational_status.go b/cmd/dobotai/operational_status.go deleted file mode 100644 index 231b2e6..0000000 --- a/cmd/dobotai/operational_status.go +++ /dev/null @@ -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 -} diff --git a/cmd/dobotai/operational_status_test.go b/cmd/dobotai/operational_status_test.go deleted file mode 100644 index d88a34f..0000000 --- a/cmd/dobotai/operational_status_test.go +++ /dev/null @@ -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) - } -} diff --git a/cmd/dobotai/settings_wiring.go b/cmd/dobotai/settings_wiring.go index 7f4da79..ba2437b 100644 --- a/cmd/dobotai/settings_wiring.go +++ b/cmd/dobotai/settings_wiring.go @@ -7,9 +7,7 @@ import ( "path/filepath" "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/dingtalkconfig" "gitea.zheromiku.top/Weknora/dobotai-platform/internal/secretstore" "gitea.zheromiku.top/Weknora/dobotai-platform/internal/settings" "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 { - repository *postgres.SettingsRepository - bindings *validatedSettingsRepository - controller *settings.Controller - admin *adminweb.ControlService - clientID string - secret string - secretRef string - secrets *secretstore.Store - verifier *dingtalkconfig.Verifier - pendingActivation bool + repository *postgres.SettingsRepository + bindings *validatedSettingsRepository + controller *settings.Controller + clientID string + secret string + secretRef string } 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 { return nil, err } - pendingActivation, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey) - if err != nil { + if _, err := repo.PrepareDingTalkRuntimeStart(ctx, tenantKey); err != nil { return nil, err } clientID, secretRef, _, err := repo.ActiveDingTalkConfig(ctx, tenantKey) @@ -88,11 +81,6 @@ func buildSettingsRuntime(ctx context.Context, store *postgres.Store, tenantKey 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} - 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 } diff --git a/cmd/dobotai/setup_only.go b/cmd/dobotai/setup_only.go deleted file mode 100644 index 0be6344..0000000 --- a/cmd/dobotai/setup_only.go +++ /dev/null @@ -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) - } -} diff --git a/cmd/dobotai/setup_only_test.go b/cmd/dobotai/setup_only_test.go deleted file mode 100644 index 51db859..0000000 --- a/cmd/dobotai/setup_only_test.go +++ /dev/null @@ -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") - } -} diff --git a/deploy/dobotai-dingtalk/README.md b/deploy/dobotai-dingtalk/README.md new file mode 100644 index 0000000..998bc67 --- /dev/null +++ b/deploy/dobotai-dingtalk/README.md @@ -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。它不读取或打印环境文件中的值,也不停止旧服务。生产环境必须独立审查、备份和验证,不复用测试镜像以外的未验证构建。 diff --git a/deploy/dobotai-dingtalk/compose.yaml b/deploy/dobotai-dingtalk/compose.yaml new file mode 100644 index 0000000..9c8ebbb --- /dev/null +++ b/deploy/dobotai-dingtalk/compose.yaml @@ -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 diff --git a/deploy/dobotai-dingtalk/configure_env.py b/deploy/dobotai-dingtalk/configure_env.py new file mode 100644 index 0000000..801598a --- /dev/null +++ b/deploy/dobotai-dingtalk/configure_env.py @@ -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) diff --git a/deploy/dobotai-dingtalk/dobotai-dingtalk-bind-admin b/deploy/dobotai-dingtalk/dobotai-dingtalk-bind-admin new file mode 100644 index 0000000..5f42a7a --- /dev/null +++ b/deploy/dobotai-dingtalk/dobotai-dingtalk-bind-admin @@ -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 diff --git a/deploy/dobotai-dingtalk/dobotai-dingtalk-configure b/deploy/dobotai-dingtalk/dobotai-dingtalk-configure new file mode 100644 index 0000000..3658b81 --- /dev/null +++ b/deploy/dobotai-dingtalk/dobotai-dingtalk-configure @@ -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 diff --git a/deploy/dobotai-dingtalk/dobotai-dingtalk-start b/deploy/dobotai-dingtalk/dobotai-dingtalk-start new file mode 100644 index 0000000..ab7f146 --- /dev/null +++ b/deploy/dobotai-dingtalk/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 diff --git a/deploy/dobotai-dingtalk/install-test.sh b/deploy/dobotai-dingtalk/install-test.sh new file mode 100644 index 0000000..f2e4a6e --- /dev/null +++ b/deploy/dobotai-dingtalk/install-test.sh @@ -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" <:*`; 3. 没有任何绑定时失败关闭。 +## 首位管理员终端绑定 + +首位管理员绑定由部署服务器终端发起,而非网页发码。钉钉 Stream 机器人已正常运行且当前不存在有效管理员时,操作人员执行: + +```bash +dobotai bootstrap-admin +``` + +命令只连接 DobotAI PostgreSQL,使用加密随机数创建一个有效期10分钟、最多5次错误尝试的六码挑战,并在终端持续等待。候选用户必须私聊机器人发送: + +```text +/setting <六码绑定码> +``` + +验证码匹配后,渠道侧只记录候选身份;终端轮询并事务性将该候选用户写为 `super_admin/active`,然后显示成功并退出。已有有效管理员时该命令拒绝执行,避免任意用户通过终端绑定流程被提权。DobotAI 不提供网页管理面,钉钉凭据和运行配置由部署环境文件管理。 + ## 图片失败策略 启用图片流量前,WeKnora Adapter 必须针对选定的官方版本和视觉模型执行契约测试。 diff --git a/internal/adminweb/control.go b/internal/adminweb/control.go deleted file mode 100644 index cbacf4b..0000000 --- a/internal/adminweb/control.go +++ /dev/null @@ -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) -} diff --git a/internal/adminweb/control_test.go b/internal/adminweb/control_test.go deleted file mode 100644 index 8746381..0000000 --- a/internal/adminweb/control_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/adminweb/server.go b/internal/adminweb/server.go deleted file mode 100644 index fc532bd..0000000 --- a/internal/adminweb/server.go +++ /dev/null @@ -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 = ` - - - -DobotAI 设置 - - - -

DobotAI 配置中心

-
系统状态:{{.SystemState}}
Client ID:{{.ClientIDMasked}}
Client Secret:{{if .SecretConfigured}}已配置(不可查看){{else}}未配置{{end}}
-

配置/切换钉钉应用

新凭据先在线验证并保存为候选。激活后受控重启整组钉钉组件,失败自动回滚。

-

新增/更换管理员

必须先加后停:先确认新管理员已生效,再停用旧管理员。系统禁止停用最后一位有效管理员。

  1. 输入任意6位一次性绑定码并生成,有效期10分钟。
  2. 新管理员私聊机器人发送 /setting 6位码。
  3. 回到本页确认检测到的新管理员。
  4. 在下方确认新管理员状态为active,再停用旧管理员。

新管理员私聊验证后再点击:

管理员列表

{{range .Administrators}}{{else}}{{end}}
姓名部门Staff ID角色状态操作
{{if .DisplayName}}{{.DisplayName}}{{else}}—{{end}}{{if .Department}}{{.Department}}{{else}}—{{end}}{{.StaffIDMasked}}{{.Role}}{{.Status}}{{if .CanDisable}}
{{else}}—{{end}}
暂无管理员记录
-

组件检查

{{range .Components}}
{{.Name}}:{{.State}}
错误码:{{if .Code}}{{.Code}}{{else}}—{{end}}
最后成功:{{timeText .LastSuccess}}
最后检查:{{timeText .CheckedAt}}

{{.Detail}}

{{end}} -` - -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 -} diff --git a/internal/adminweb/server_test.go b/internal/adminweb/server_test.go deleted file mode 100644 index b060f9b..0000000 --- a/internal/adminweb/server_test.go +++ /dev/null @@ -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()) - } -} diff --git a/internal/settings/administrator.go b/internal/settings/administrator.go index 9b5a20c..1af81c5 100644 --- a/internal/settings/administrator.go +++ b/internal/settings/administrator.go @@ -14,4 +14,5 @@ var ( ErrAdministratorNotFound = errors.New("administrator not found") ErrLastAdministrator = errors.New("last active administrator cannot be disabled") ErrAdministratorAlreadyDisabled = errors.New("administrator already disabled") + ErrBootstrapCandidatePending = errors.New("bootstrap candidate pending") ) diff --git a/internal/settings/controller.go b/internal/settings/controller.go index 5a22895..c6ded9b 100644 --- a/internal/settings/controller.go +++ b/internal/settings/controller.go @@ -88,7 +88,7 @@ func (c *Controller) Handle(ctx context.Context, in SettingInput) (string, error if err != nil { return "", err } - return "身份已验证,请回到服务器命令行等待确认。", nil + return "身份已验证,服务器终端将自动完成管理员绑定。", nil } if explicit && len(args) == 0 { admin, err := c.repo.IsAdministrator(ctx, in.TenantKey, in.StaffID) diff --git a/internal/settings/controller_test.go b/internal/settings/controller_test.go index 8e2832c..0f921b3 100644 --- a/internal/settings/controller_test.go +++ b/internal/settings/controller_test.go @@ -372,7 +372,7 @@ func TestControllerDetectsBootstrapCandidate(t *testing.T) { if r.candidate.Code != "123456" || r.candidate.StaffID != "a" { t.Fatalf("candidate=%+v", r.candidate) } - if !strings.Contains(out, "服务器命令行") || strings.Contains(out, "后台") { - t.Fatalf("reply=%q must direct confirmation back to the server CLI only", out) + if !strings.Contains(out, "服务器终端将自动完成管理员绑定") || strings.Contains(out, "后台") { + t.Fatalf("reply=%q must explain terminal auto-confirmation only", out) } } diff --git a/internal/store/postgres/settings.go b/internal/store/postgres/settings.go index 7aaffce..c699d20 100644 --- a/internal/store/postgres/settings.go +++ b/internal/store/postgres/settings.go @@ -147,6 +147,9 @@ func (r *SettingsRepository) ConfirmBootstrap(ctx context.Context, tenant 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) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return settings.ErrBootstrapCandidatePending + } return err } if strings.TrimSpace(staff) == "" { diff --git a/scripts/containerized_deployment_test.go b/scripts/containerized_deployment_test.go new file mode 100644 index 0000000..1e44a2c --- /dev/null +++ b/scripts/containerized_deployment_test.go @@ -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) + } + } +}