diff --git a/backend/internal/bootstrap/certificate_diagnostic.go b/backend/internal/bootstrap/certificate_diagnostic.go new file mode 100644 index 0000000..bfa2be6 --- /dev/null +++ b/backend/internal/bootstrap/certificate_diagnostic.go @@ -0,0 +1,60 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "net" + "os" + + "github.com/go-acme/lego/v5/acme" +) + +// certificateFailureDiagnostic 只输出受控分类和数值,禁止输出远端说明、地址及凭据。 +func certificateFailureDiagnostic(err error) string { + category := "unclassified" + status := 0 + retrySeconds := int64(0) + var problem *acme.ProblemDetails + var rateLimit *acme.RateLimitedError + var network *net.OpError + switch { + case errors.As(err, &problem): + category = "acme_other" + if problem.HTTPStatus >= 100 && problem.HTTPStatus <= 599 { + status = problem.HTTPStatus + } + switch problem.Type { + case acme.RateLimitedErrorType: + category = "acme_rate_limited" + case acme.DNSErrorType: + category = "acme_dns" + case acme.ConnectionErrorType: + category = "acme_connection" + case acme.AccountDoesNotExistErrorType: + category = "acme_account_missing" + case acme.UnauthorizedErrorType: + category = "acme_unauthorized" + case acme.CaaErrorType: + category = "acme_caa" + case acme.InvalidContactErrorType: + category = "acme_invalid_contact" + case acme.RejectedIdentifierErrorType: + category = "acme_rejected_identifier" + } + if errors.As(err, &rateLimit) && rateLimit.RetryAfter > 0 { + retrySeconds = int64(rateLimit.RetryAfter.Seconds()) + } + case errors.Is(err, context.DeadlineExceeded): + category = "deadline" + case errors.Is(err, context.Canceled): + category = "canceled" + case errors.Is(err, os.ErrPermission): + category = "filesystem_permission" + case errors.Is(err, os.ErrNotExist): + category = "filesystem_missing" + case errors.As(err, &network): + category = "network" + } + return fmt.Sprintf("管理证书签发失败:stage=management_certificate category=%s acme_status=%d retry_after_seconds=%d", category, status, retrySeconds) +} diff --git a/backend/internal/bootstrap/certificate_diagnostic_test.go b/backend/internal/bootstrap/certificate_diagnostic_test.go new file mode 100644 index 0000000..a7ffc8e --- /dev/null +++ b/backend/internal/bootstrap/certificate_diagnostic_test.go @@ -0,0 +1,58 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/go-acme/lego/v5/acme" +) + +func TestCertificateDiagnosticRedactsUntrustedFields(t *testing.T) { + for _, item := range []struct { + name string + err error + category string + status int + retry int + }{ + {"rate", &acme.RateLimitedError{ProblemDetails: &acme.ProblemDetails{ + Type: acme.RateLimitedErrorType, HTTPStatus: 429, Detail: "secret-value", + URL: "https://secret-value/", Instance: "secret-value", Method: "secret-value", + SubProblems: []acme.SubProblem{{Type: "secret-value", Detail: "secret-value"}}, + }, RetryAfter: 2 * time.Hour}, "acme_rate_limited", 429, 7200}, + {"unknown", &acme.ProblemDetails{Type: "secret-value", Detail: "secret-value", HTTPStatus: -1}, "acme_other", 0, 0}, + {"dns", &acme.ProblemDetails{Type: acme.DNSErrorType, HTTPStatus: 400}, "acme_dns", 400, 0}, + {"permission", fmt.Errorf("secret-value: %w", os.ErrPermission), "filesystem_permission", 0, 0}, + {"timeout", context.DeadlineExceeded, "deadline", 0, 0}, + {"opaque", errors.New("secret-value"), "unclassified", 0, 0}, + } { + t.Run(item.name, func(t *testing.T) { + message := certificateFailureDiagnostic(fmt.Errorf("secret-value: %w", item.err)) + want := fmt.Sprintf("category=%s acme_status=%d retry_after_seconds=%d", item.category, item.status, item.retry) + if !strings.Contains(message, want) || strings.Contains(message, "secret-value") { + t.Fatalf("unsafe or incomplete diagnostic: %s", message) + } + }) + } +} + +func TestCompleterReportsSafeCertificateFailure(t *testing.T) { + fixture := newCompletionFixture(t, "admin.example.test") + fixture.issuer.err = &acme.ProblemDetails{Type: acme.RateLimitedErrorType, HTTPStatus: 429, Detail: "secret-value"} + var warnings []string + completer := fixture.completer(func(dependencies *CompleteDependencies) { + dependencies.Warn = func(message string) { warnings = append(warnings, message) } + }) + _, err := completer.Complete(context.Background(), fixture.request("admin.example.test")) + if err == nil || len(warnings) != 1 || !strings.Contains(warnings[0], "acme_rate_limited") || strings.Contains(warnings[0], "secret-value") { + t.Fatalf("failure not safely reported: err=%v warnings=%v", err, warnings) + } + if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatal("failed certificate issuance must not commit configuration") + } +} diff --git a/backend/internal/bootstrap/complete.go b/backend/internal/bootstrap/complete.go index c273e55..a6bd839 100644 --- a/backend/internal/bootstrap/complete.go +++ b/backend/internal/bootstrap/complete.go @@ -376,6 +376,9 @@ func (completer *Completer) issueManagementCertificate(ctx context.Context, cand CertificateRoot: candidate.Paths.Certificate, BackupRoot: candidate.Paths.Backup, }) if err != nil { + if completer.warn != nil { + completer.warn(certificateFailureDiagnostic(err)) + } return completionCertificate{}, completeError(CompleteErrorDependency, "申请管理域名证书失败", err) } return completionCertificate{