Files
knowledge-governance/internal/appconfig/config.go
T
姚章浩 910c0fcd76
build / build (push) Failing after 16s
feat(library): 页面发起封存的最小权限主机侧执行边界
- libraryagent:0700 目录内 0600 unix socket + SO_PEERCRED 同 uid,
  固定 binary/归档/日志/项目/写入者声明,请求只能选范围与作业号
- API:POST/GET /api/v1/library/captures(入队 library.capture + 读队列状态)
- worker:library.capture handler 走 socket;启动先 recover;不挂 Docker socket、
  不持有来源凭据
- web:整库版本页显示真实作业状态并可发起封存(未接线/无权限给出原因)
- scripts/library-agent-install.sh:构建、安装 systemd user 服务并读回权限
2026-10-28 12:11:08 +08:00

403 lines
19 KiB
Go

package appconfig
import (
"errors"
"fmt"
"path/filepath"
"strconv"
"strings"
"time"
"knowledge-governance/internal/governance"
)
type Config struct {
DatabaseURL string
AppAddr string
WorkerID string
PollInterval time.Duration
LeaseDuration time.Duration
RetryDelay time.Duration
SourceRefreshSchedulerInterval time.Duration
ReleaseWorkerEnabled bool
ReleaseConfigFile string
ProductionReleaseRequestsEnabled bool
ProductionBackupRestoreVerified bool
ProductionReleaseAuthorizerRef string
ProductionReleaseAuthTokenFile string
// Production WeKnora endpoint: the release target itself stays declared in
// the release runtime config (RELEASE_CONFIG_FILE); these settings only let
// the API probe that endpoint so the console can show its real state.
ProductionWeKnoraBaseURL string
ProductionWeKnoraAPIKeyFile string
ProductionWeKnoraAllowDockerHTTP bool
PPScanEnabled bool
PPScanAllowedRoot string
PPScanLedgerPath string
PPScanManifestPath string
TestWeKnoraEnabled bool
TestWeKnoraAllowDockerHTTP bool
TestWeKnoraBaseURL string
MinerUBaseURL string
TestWeKnoraKBID string
InternalRoutingKnowledgeBaseID string
ExternalRoutingKnowledgeBaseID string
TestWeKnoraAPIKeyFile string
GovernanceSpoolRoot string
IngestionSpoolRoot string
ArtifactFetchMaxBytes int64
WorkerLivenessStaleAfter time.Duration
TestWeKnoraHTTPTimeout time.Duration
TestWeKnoraPollInitial time.Duration
TestWeKnoraPollMax time.Duration
TestWeKnoraStalledAfter time.Duration
TestWeKnoraMaxUploadBytes int64
TestAcceptancePolicyVersion string
// TestAutoReviewPolicy:验收通过后是否自动批准(off|internal|all;默认 internal)。
TestAutoReviewPolicy string
// 目标库所在主机(整库逻辑备份真正执行的地方)。凭据是挂载进来的 0600 ssh
// 配置文件与私钥;三项同时给出才接线,缺任一项即"未接线"(页面降级展示)。
BackupHostSSHConfig string
BackupHostTarget string
BackupHostScriptDir string
BackupHostRoot string
// 版本记录(「版本记录」页):**匿名只读**远端仓库的提交与 tag。来源留空即"未接线"
// (页面降级展示,接口返回可用性=false)。只允许配置里批准的 origin 与仓库,
// 不接受任何调用方传入的 URL;本功能**不使用凭据**。
VersionHistoryGiteaBaseURL string
VersionHistoryRepo string
VersionHistoryAllowedOrigins []string
// VersionHistoryLinkOrigin 是**页面链接**用的 origin(给内网外的浏览器点开),
// 留空即与抓取 origin 相同。只用于拼展示链接,不参与任何请求。
// LibraryArchiveRoot 是整库封存产物的只读归档根(主机私有 0700 目录的只读挂载)。
// 留空即"未接线":页面只显示原因,不显示空表冒充没有快照。
LibraryArchiveRoot string
// LibraryAgentSocket 是主机侧封存 agent 的 unix socket(只由 worker 连接)。
// 留空即"未接线":页面不显示可用的封存入口,接口返回原因而不是假装已排队。
LibraryAgentSocket string
VersionHistoryLinkOrigin string
VersionHistoryHTTPTimeout time.Duration
VersionHistoryMaxPages int
}
func Load(getenv func(string) string) (Config, error) {
config := Config{
DatabaseURL: getenv("DATABASE_URL"),
AppAddr: valueOrDefault(getenv("APP_ADDR"), ":8080"),
WorkerID: valueOrDefault(getenv("WORKER_ID"), "worker-1"),
PollInterval: time.Second,
LeaseDuration: 30 * time.Second,
RetryDelay: 5 * time.Second,
SourceRefreshSchedulerInterval: 30 * time.Second,
ReleaseConfigFile: strings.TrimSpace(getenv("RELEASE_CONFIG_FILE")),
ProductionReleaseAuthorizerRef: strings.TrimSpace(getenv("PRODUCTION_RELEASE_AUTHORIZER_REF")),
ProductionReleaseAuthTokenFile: strings.TrimSpace(getenv("PRODUCTION_RELEASE_AUTH_TOKEN_FILE")),
ProductionWeKnoraBaseURL: strings.TrimSpace(getenv("PRODUCTION_WEKNORA_BASE_URL")),
ProductionWeKnoraAPIKeyFile: strings.TrimSpace(getenv("PRODUCTION_WEKNORA_API_KEY_FILE")),
PPScanAllowedRoot: strings.TrimSpace(getenv("PP_SCAN_ALLOWED_ROOT")),
PPScanLedgerPath: strings.TrimSpace(getenv("PP_SCAN_LEDGER_PATH")),
PPScanManifestPath: strings.TrimSpace(getenv("PP_SCAN_MANIFEST_PATH")),
TestWeKnoraBaseURL: strings.TrimSpace(getenv("TEST_WEKNORA_BASE_URL")),
MinerUBaseURL: strings.TrimSpace(getenv("MINERU_BASE_URL")),
TestWeKnoraKBID: strings.TrimSpace(getenv("TEST_WEKNORA_KB_ID")),
InternalRoutingKnowledgeBaseID: strings.TrimSpace(getenv("TEST_INTERNAL_WEKNORA_KB_ID")),
ExternalRoutingKnowledgeBaseID: strings.TrimSpace(getenv("TEST_EXTERNAL_WEKNORA_KB_ID")),
TestWeKnoraAPIKeyFile: valueOrDefault(getenv("TEST_WEKNORA_API_KEY_FILE"), "/run/secrets/test_weknora_api_key"),
GovernanceSpoolRoot: valueOrDefault(getenv("GOVERNANCE_SPOOL_ROOT"), "/var/lib/governance/spool"),
IngestionSpoolRoot: strings.TrimSpace(getenv("INGESTION_SPOOL_ROOT")),
ArtifactFetchMaxBytes: 200 * 1024 * 1024,
WorkerLivenessStaleAfter: 2 * time.Minute,
TestWeKnoraHTTPTimeout: 60 * time.Second,
TestWeKnoraPollInitial: 10 * time.Second,
TestWeKnoraPollMax: time.Minute,
TestWeKnoraStalledAfter: 30 * time.Minute,
TestWeKnoraMaxUploadBytes: 50 * 1024 * 1024,
TestAcceptancePolicyVersion: valueOrDefault(getenv("TEST_ACCEPTANCE_POLICY_VERSION"), "structural-v1"),
TestAutoReviewPolicy: valueOrDefault(getenv("TEST_AUTO_REVIEW_POLICY"), string(governance.AutoReviewInternal)),
// 整库逻辑备份的目标主机:三项同时给出才接线(脚本目录/备份根有默认值)。
BackupHostSSHConfig: strings.TrimSpace(getenv("BACKUP_HOST_SSH_CONFIG")),
BackupHostTarget: strings.TrimSpace(getenv("BACKUP_HOST_TARGET")),
BackupHostScriptDir: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_SCRIPT_DIR")), "/opt/weknora-test"),
BackupHostRoot: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_ROOT")), "/srv/kb-backups"),
VersionHistoryGiteaBaseURL: strings.TrimSpace(getenv("VERSION_HISTORY_GITEA_BASE_URL")),
VersionHistoryRepo: valueOrDefault(strings.TrimSpace(getenv("VERSION_HISTORY_REPO")), "Weknora/knowledge-governance"),
VersionHistoryAllowedOrigins: splitOrigins(getenv("VERSION_HISTORY_ALLOWED_ORIGINS")),
LibraryArchiveRoot: strings.TrimSpace(getenv("LIBRARY_ARCHIVE_ROOT")),
LibraryAgentSocket: strings.TrimSpace(getenv("LIBRARY_AGENT_SOCKET")),
VersionHistoryLinkOrigin: strings.TrimSpace(getenv("VERSION_HISTORY_LINK_ORIGIN")),
VersionHistoryHTTPTimeout: 15 * time.Second,
VersionHistoryMaxPages: valueOrDefaultInt(getenv("VERSION_HISTORY_MAX_PAGES"), 200),
}
if config.DatabaseURL == "" {
return Config{}, errors.New("DATABASE_URL is required")
}
var err error
if config.PollInterval, err = durationOrDefault(getenv("WORKER_POLL_INTERVAL"), config.PollInterval); err != nil {
return Config{}, fmt.Errorf("WORKER_POLL_INTERVAL: %w", err)
}
if config.LeaseDuration, err = durationOrDefault(getenv("WORKER_LEASE_DURATION"), config.LeaseDuration); err != nil {
return Config{}, fmt.Errorf("WORKER_LEASE_DURATION: %w", err)
}
if config.RetryDelay, err = durationOrDefault(getenv("WORKER_RETRY_DELAY"), config.RetryDelay); err != nil {
return Config{}, fmt.Errorf("WORKER_RETRY_DELAY: %w", err)
}
if config.SourceRefreshSchedulerInterval, err = durationOrDefault(getenv("SOURCE_REFRESH_SCHEDULER_INTERVAL"), config.SourceRefreshSchedulerInterval); err != nil {
return Config{}, fmt.Errorf("SOURCE_REFRESH_SCHEDULER_INTERVAL: %w", err)
}
if config.SourceRefreshSchedulerInterval < 5*time.Second {
return Config{}, errors.New("SOURCE_REFRESH_SCHEDULER_INTERVAL must be at least 5s")
}
if raw := getenv("RELEASE_WORKER_ENABLED"); raw != "" {
config.ReleaseWorkerEnabled, err = strconv.ParseBool(raw)
if err != nil {
return Config{}, fmt.Errorf("RELEASE_WORKER_ENABLED: %w", err)
}
}
if config.ReleaseWorkerEnabled && (!filepath.IsAbs(config.ReleaseConfigFile) || filepath.Clean(config.ReleaseConfigFile) != config.ReleaseConfigFile) {
return Config{}, errors.New("RELEASE_CONFIG_FILE must be an absolute path when release worker is enabled")
}
if raw := getenv("PRODUCTION_RELEASE_REQUESTS_ENABLED"); raw != "" {
config.ProductionReleaseRequestsEnabled, err = strconv.ParseBool(raw)
if err != nil {
return Config{}, fmt.Errorf("PRODUCTION_RELEASE_REQUESTS_ENABLED: %w", err)
}
}
if raw := getenv("PRODUCTION_BACKUP_RESTORE_VERIFIED"); raw != "" {
config.ProductionBackupRestoreVerified, err = strconv.ParseBool(raw)
if err != nil {
return Config{}, fmt.Errorf("PRODUCTION_BACKUP_RESTORE_VERIFIED: %w", err)
}
}
if raw := getenv("PRODUCTION_WEKNORA_ALLOW_DOCKER_HTTP"); raw != "" {
config.ProductionWeKnoraAllowDockerHTTP, err = strconv.ParseBool(raw)
if err != nil {
return Config{}, fmt.Errorf("PRODUCTION_WEKNORA_ALLOW_DOCKER_HTTP: %w", err)
}
}
if config.ProductionWeKnoraBaseURL != "" {
if err := validateHTTPBaseURL(config.ProductionWeKnoraBaseURL); err != nil {
return Config{}, fmt.Errorf("PRODUCTION_WEKNORA_BASE_URL: %w", err)
}
}
if config.ProductionWeKnoraAPIKeyFile != "" {
if config.ProductionWeKnoraBaseURL == "" {
return Config{}, errors.New("PRODUCTION_WEKNORA_API_KEY_FILE requires PRODUCTION_WEKNORA_BASE_URL")
}
if !filepath.IsAbs(config.ProductionWeKnoraAPIKeyFile) {
return Config{}, errors.New("PRODUCTION_WEKNORA_API_KEY_FILE must be an absolute path")
}
}
if raw := getenv("PP_SCAN_ENABLED"); raw != "" {
config.PPScanEnabled, err = strconv.ParseBool(raw)
if err != nil {
return Config{}, fmt.Errorf("PP_SCAN_ENABLED: %w", err)
}
}
if raw := getenv("TEST_WEKNORA_ENABLED"); raw != "" {
config.TestWeKnoraEnabled, err = strconv.ParseBool(raw)
if err != nil {
return Config{}, fmt.Errorf("TEST_WEKNORA_ENABLED: %w", err)
}
}
if raw := getenv("TEST_WEKNORA_ALLOW_DOCKER_HTTP"); raw != "" {
config.TestWeKnoraAllowDockerHTTP, err = strconv.ParseBool(raw)
if err != nil {
return Config{}, fmt.Errorf("TEST_WEKNORA_ALLOW_DOCKER_HTTP: %w", err)
}
}
if config.WorkerLivenessStaleAfter, err = durationOrDefault(getenv("WORKER_LIVENESS_STALE_AFTER"), config.WorkerLivenessStaleAfter); err != nil {
return Config{}, fmt.Errorf("WORKER_LIVENESS_STALE_AFTER: %w", err)
}
if config.VersionHistoryHTTPTimeout, err = durationOrDefault(getenv("VERSION_HISTORY_HTTP_TIMEOUT"), config.VersionHistoryHTTPTimeout); err != nil {
return Config{}, fmt.Errorf("VERSION_HISTORY_HTTP_TIMEOUT: %w", err)
}
// 版本记录来源:给了 origin 就必须是干净的 http(s) origin;白名单留空则默认只许自己。
if config.VersionHistoryGiteaBaseURL != "" {
if err := validateHTTPBaseURL(config.VersionHistoryGiteaBaseURL); err != nil {
return Config{}, fmt.Errorf("VERSION_HISTORY_GITEA_BASE_URL: %w%s", err,
envHint(config.VersionHistoryGiteaBaseURL))
}
if len(config.VersionHistoryAllowedOrigins) == 0 {
config.VersionHistoryAllowedOrigins = []string{config.VersionHistoryGiteaBaseURL}
}
for _, origin := range config.VersionHistoryAllowedOrigins {
if err := validateHTTPBaseURL(origin); err != nil {
return Config{}, fmt.Errorf("VERSION_HISTORY_ALLOWED_ORIGINS: %w", err)
}
}
if strings.Count(config.VersionHistoryRepo, "/") != 1 {
return Config{}, errors.New("VERSION_HISTORY_REPO 必须是 owner/name 形式")
}
if config.VersionHistoryLinkOrigin != "" {
if err := validateHTTPBaseURL(config.VersionHistoryLinkOrigin); err != nil {
return Config{}, fmt.Errorf("VERSION_HISTORY_LINK_ORIGIN: %w", err)
}
}
}
if config.LibraryArchiveRoot != "" && !filepath.IsAbs(config.LibraryArchiveRoot) {
return Config{}, errors.New("LIBRARY_ARCHIVE_ROOT 必须是绝对路径(归档根按只读目录挂载)")
}
if config.LibraryAgentSocket != "" && !filepath.IsAbs(config.LibraryAgentSocket) {
return Config{}, errors.New("LIBRARY_AGENT_SOCKET 必须是绝对路径(unix socket 由主机侧 agent 持有)")
}
if config.TestWeKnoraHTTPTimeout, err = durationOrDefault(getenv("TEST_WEKNORA_HTTP_TIMEOUT"), config.TestWeKnoraHTTPTimeout); err != nil {
return Config{}, fmt.Errorf("TEST_WEKNORA_HTTP_TIMEOUT: %w", err)
}
if config.TestWeKnoraPollInitial, err = durationOrDefault(getenv("TEST_WEKNORA_POLL_INITIAL"), config.TestWeKnoraPollInitial); err != nil {
return Config{}, fmt.Errorf("TEST_WEKNORA_POLL_INITIAL: %w", err)
}
if config.TestWeKnoraPollMax, err = durationOrDefault(getenv("TEST_WEKNORA_POLL_MAX"), config.TestWeKnoraPollMax); err != nil {
return Config{}, fmt.Errorf("TEST_WEKNORA_POLL_MAX: %w", err)
}
if config.TestWeKnoraStalledAfter, err = durationOrDefault(getenv("TEST_WEKNORA_STALLED_AFTER"), config.TestWeKnoraStalledAfter); err != nil {
return Config{}, fmt.Errorf("TEST_WEKNORA_STALLED_AFTER: %w", err)
}
if config.TestWeKnoraMaxUploadBytes, err = positiveInt64OrDefault(getenv("TEST_WEKNORA_MAX_UPLOAD_BYTES"), config.TestWeKnoraMaxUploadBytes); err != nil {
return Config{}, fmt.Errorf("TEST_WEKNORA_MAX_UPLOAD_BYTES: %w", err)
}
if config.TestWeKnoraMaxUploadBytes > 50*1024*1024 {
return Config{}, errors.New("TEST_WEKNORA_MAX_UPLOAD_BYTES must not exceed 50 MiB")
}
if config.TestWeKnoraPollMax < config.TestWeKnoraPollInitial {
return Config{}, errors.New("TEST_WEKNORA_POLL_MAX must not be less than TEST_WEKNORA_POLL_INITIAL")
}
if config.TestWeKnoraStalledAfter <= config.TestWeKnoraPollMax {
return Config{}, errors.New("TEST_WEKNORA_STALLED_AFTER must be greater than TEST_WEKNORA_POLL_MAX")
}
config.TestAcceptancePolicyVersion = strings.TrimSpace(config.TestAcceptancePolicyVersion)
if config.TestAcceptancePolicyVersion == "" {
return Config{}, errors.New("TEST_ACCEPTANCE_POLICY_VERSION must not be empty")
}
// 审核策略:值不认识就拒绝启动,避免"写错一个词 → 静默变成另一种审核强度"。
normalizedPolicy, err := governance.ParseAutoReviewPolicy(config.TestAutoReviewPolicy)
if err != nil {
return Config{}, errors.New("TEST_AUTO_REVIEW_POLICY must be one of off, internal, all")
}
config.TestAutoReviewPolicy = string(normalizedPolicy)
if config.ProductionReleaseRequestsEnabled {
// 目标主机凭据要么都给要么都不给:只给一半会把"未接线"伪装成"已接线"。
if (config.BackupHostSSHConfig == "") != (config.BackupHostTarget == "") {
return Config{}, errors.New("BACKUP_HOST_SSH_CONFIG and BACKUP_HOST_TARGET must be set together")
}
if config.ProductionReleaseAuthorizerRef == "" || len(config.ProductionReleaseAuthorizerRef) > 128 || strings.ContainsAny(config.ProductionReleaseAuthorizerRef, "\x00\r\n ") {
return Config{}, errors.New("PRODUCTION_RELEASE_AUTHORIZER_REF is required and must be safe when production release requests are enabled")
}
if config.ProductionReleaseAuthTokenFile == "" || !filepath.IsAbs(config.ProductionReleaseAuthTokenFile) {
return Config{}, errors.New("PRODUCTION_RELEASE_AUTH_TOKEN_FILE must be an absolute path when production release requests are enabled")
}
}
if config.PPScanEnabled {
for key, value := range map[string]string{
"PP_SCAN_ALLOWED_ROOT": config.PPScanAllowedRoot,
"PP_SCAN_LEDGER_PATH": config.PPScanLedgerPath,
"PP_SCAN_MANIFEST_PATH": config.PPScanManifestPath,
} {
if value == "" {
return Config{}, fmt.Errorf("%s is required when PP scan is enabled", key)
}
if !filepath.IsAbs(value) {
return Config{}, fmt.Errorf("%s must be absolute", key)
}
}
}
if config.TestWeKnoraEnabled {
if config.TestWeKnoraBaseURL == "" {
return Config{}, errors.New("TEST_WEKNORA_BASE_URL is required when test WeKnora is enabled")
}
if config.TestWeKnoraKBID == "" {
return Config{}, errors.New("TEST_WEKNORA_KB_ID is required when test WeKnora is enabled")
}
if strings.TrimSpace(config.TestWeKnoraAPIKeyFile) == "" {
return Config{}, errors.New("TEST_WEKNORA_API_KEY_FILE is required when test WeKnora is enabled")
}
if strings.TrimSpace(config.GovernanceSpoolRoot) == "" {
return Config{}, errors.New("GOVERNANCE_SPOOL_ROOT is required when test WeKnora is enabled")
}
}
if (config.InternalRoutingKnowledgeBaseID == "") != (config.ExternalRoutingKnowledgeBaseID == "") {
return Config{}, errors.New("both internal and external routing knowledge bases must be configured together")
}
for _, knowledgeBaseID := range []string{config.InternalRoutingKnowledgeBaseID, config.ExternalRoutingKnowledgeBaseID} {
if len(knowledgeBaseID) > 128 || strings.ContainsAny(knowledgeBaseID, "\x00\r\n /?#\\") {
return Config{}, errors.New("routing knowledge base ID is invalid")
}
}
return config, nil
}
// validateHTTPBaseURL accepts only a bare http(s) origin: no credentials, query
// or fragment, so a probe address can never smuggle in secrets. The rule itself
// lives with the domain type because the web-configured endpoint must obey the
// exact same shape.
func validateHTTPBaseURL(value string) error {
if !governance.ValidHTTPOrigin(value) {
return errors.New("must be an http(s) origin without credentials, query or fragment")
}
return nil
}
// envHint 给"值明显不像一个地址"的情况补一句线索:最常见的成因是 `.env` 里两行粘在一起
// (新键被追加到没有结尾换行的上一行后面)—— 值里就会出现第二个 `KEY=`。
func envHint(value string) string {
if strings.Contains(value, "=") {
return "(值里出现了 `=`:`.env` 里两行可能粘成了一行,改成一行一值再上线)"
}
return ""
}
// splitOrigins 解析逗号分隔的 origin 白名单(空项丢弃)。
func splitOrigins(value string) []string {
parts := strings.Split(value, ",")
origins := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
origins = append(origins, trimmed)
}
}
return origins
}
// valueOrDefaultInt 解析正整数(非法/缺省都退回默认值)。
func valueOrDefaultInt(value string, fallback int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func valueOrDefault(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func durationOrDefault(value string, fallback time.Duration) (time.Duration, error) {
if value == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil {
return 0, err
}
if parsed <= 0 {
return 0, errors.New("must be positive")
}
return parsed, nil
}
func positiveInt64OrDefault(value string, fallback int64) (int64, error) {
if value == "" {
return fallback, nil
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, err
}
if parsed <= 0 {
return 0, errors.New("must be positive")
}
return parsed, nil
}