From 8fa1c82730d0e0fbede514ef434a1f3e160799ba Mon Sep 17 00:00:00 2001 From: xuans Date: Tue, 15 Sep 2026 11:30:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E9=98=BF=E9=87=8C=E4=BA=91RA?= =?UTF-8?q?M=E6=9D=83=E9=99=90=E5=8F=8C=E8=BD=A8=E8=80=83=E8=AF=81?= =?UTF-8?q?=E4=B8=8E=E5=9F=9F=E5=90=8D=E6=93=8D=E4=BD=9C=E5=89=8D=E7=BD=AE?= =?UTF-8?q?=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/app.go | 5 +- .../dnsprovider/aliyun_authorization.go | 23 ++- .../dnsprovider/aliyun_authorization_test.go | 36 ++++ backend/internal/httpapi/aliyun_scope_api.go | 189 ++++++++++++++++++ .../internal/httpapi/aliyun_scope_api_test.go | 168 ++++++++++++++++ backend/internal/httpapi/server.go | 27 ++- 6 files changed, 444 insertions(+), 4 deletions(-) create mode 100644 backend/internal/httpapi/aliyun_scope_api.go create mode 100644 backend/internal/httpapi/aliyun_scope_api_test.go diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index a6c600e..1646dda 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -114,7 +114,8 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string sshKeys := sshkey.NewService(secretStore) accounts := certificate.NewAccountRepository(secretStore) certificates := certificate.NewService(cfg, repo, secretStore, dnsprovider.NewLegoFactory(), accounts) - certificates.SetDNSAuthorizationChecker(dnsprovider.NewAliyunAuthorizationChecker()) + aliyunAuthChecker := dnsprovider.NewAliyunAuthorizationChecker() + certificates.SetDNSAuthorizationChecker(aliyunAuthChecker) deploymentService := deployment.NewService(deployment.SSHFactory{Secrets: secretStore}) notifier := notification.New(cfg.Notification, secretStore) remoteValidator := certificate.RemoteValidator{} @@ -140,7 +141,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string }) server := httpapi.New(httpapi.Dependencies{ Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, SSHKeys: sshKeys, - SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(), + SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(), AliyunChecker: aliyunAuthChecker, IssueCertificate: workflow.IssueForHTTP, IssueSetupCertificate: func(ctx context.Context, request certificate.SetupCertificateRequest) (certificate.SavedResource, error) { return certificates.IssueForSetup(ctx, request) diff --git a/backend/internal/dnsprovider/aliyun_authorization.go b/backend/internal/dnsprovider/aliyun_authorization.go index 7cbe875..2b2349e 100644 --- a/backend/internal/dnsprovider/aliyun_authorization.go +++ b/backend/internal/dnsprovider/aliyun_authorization.go @@ -42,6 +42,7 @@ type AuthorizationReport struct { MatchedDomain string `json:"matched_domain,omitempty"` Domains []AuthorizationDomain `json:"domains"` Verified bool `json:"verified"` + Diagnosis string `json:"diagnosis,omitempty"` CheckedAt time.Time `json:"checked_at"` } @@ -124,7 +125,8 @@ func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain if lastErr == nil { lastErr = errors.New("未找到可验证的阿里云 DNS 托管根域名") } - return report, fmt.Errorf("阿里云 RAM 账号无法写入域名 %s:%w", requestedDomain, lastErr) + report.Diagnosis = DiagnoseAliyunError(lastErr, requestedDomain) + return report, fmt.Errorf("阿里云 RAM 账号无法写入域名 %s:%w(建议:%s)", requestedDomain, lastErr, report.Diagnosis) } // VerifyDomain 实现证书服务使用的最小授权检查接口。 @@ -373,3 +375,22 @@ func uniqueStrings(values []string) []string { } return result } + +// DiagnoseAliyunError 对阿里云 API 返回的错误进行分类并提供明确的修复指导。 +func DiagnoseAliyunError(err error, domainName string) string { + if err == nil { + return "" + } + msg := err.Error() + lowered := strings.ToLower(msg) + if strings.Contains(lowered, "forbidden.nopermission") || strings.Contains(lowered, "accessdenied") || strings.Contains(lowered, "not authorized") { + return fmt.Sprintf("当前阿里云 RAM 账号未获得域名 %s 的 DNS 解析权限。请在阿里云 RAM 控制台授予该账号针对 acs:alidns:*:*:domain/%s 的操作权限(Action: alidns:*)", domainName, domainName) + } + if strings.Contains(lowered, "nosuchdomain") || strings.Contains(lowered, "invaliddomainname.noexist") { + return fmt.Sprintf("域名 %s 未在当前阿里云账号下添加 DNS 托管,请先在阿里云云解析 DNS 控制台添加此域名", domainName) + } + if strings.Contains(lowered, "invalidaccesskeyid.notfound") || strings.Contains(lowered, "signaturedoesnotmatch") { + return "阿里云 AccessKey ID 或 AccessKey Secret 无效,请检查系统 DNS 凭据配置" + } + return fmt.Sprintf("阿里云 RAM 权限验证未通过:%s", msg) +} diff --git a/backend/internal/dnsprovider/aliyun_authorization_test.go b/backend/internal/dnsprovider/aliyun_authorization_test.go index e05f75e..9b64711 100644 --- a/backend/internal/dnsprovider/aliyun_authorization_test.go +++ b/backend/internal/dnsprovider/aliyun_authorization_test.go @@ -121,3 +121,39 @@ func TestAliyunAuthorizationCheckerCacheIsSafeForConcurrentUse(t *testing.T) { <-done } } + +func TestDiagnoseAliyunError(t *testing.T) { + testCases := []struct { + err error + domain string + expected string + }{ + { + err: errors.New("SDKError: Code: Forbidden.NoPermission, Message: Specified access key is not authorized."), + domain: "solit.cn", + expected: "当前阿里云 RAM 账号未获得域名 solit.cn 的 DNS 解析权限。请在阿里云 RAM 控制台授予该账号针对 acs:alidns:*:*:domain/solit.cn 的操作权限(Action: alidns:*)", + }, + { + err: errors.New("SDKError: Code: NoSuchDomain, Message: The specified domain name does not exist."), + domain: "nonexistent.cn", + expected: "域名 nonexistent.cn 未在当前阿里云账号下添加 DNS 托管,请先在阿里云云解析 DNS 控制台添加此域名", + }, + { + err: errors.New("SDKError: Code: InvalidAccessKeyId.NotFound, Message: Specified access key is not found."), + domain: "solit.cn", + expected: "阿里云 AccessKey ID 或 AccessKey Secret 无效,请检查系统 DNS 凭据配置", + }, + { + err: nil, + domain: "solit.cn", + expected: "", + }, + } + + for _, tc := range testCases { + got := DiagnoseAliyunError(tc.err, tc.domain) + if got != tc.expected { + t.Errorf("DiagnoseAliyunError(%v, %s) = %q; want %q", tc.err, tc.domain, got, tc.expected) + } + } +} \ No newline at end of file diff --git a/backend/internal/httpapi/aliyun_scope_api.go b/backend/internal/httpapi/aliyun_scope_api.go new file mode 100644 index 0000000..8d529b9 --- /dev/null +++ b/backend/internal/httpapi/aliyun_scope_api.go @@ -0,0 +1,189 @@ +package httpapi + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +type aliyunScopeChecker interface { + Discover(ctx context.Context, credentials map[string]string) (dnsprovider.AuthorizationReport, error) + Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (dnsprovider.AuthorizationReport, error) + VerifyDomain(ctx context.Context, providerID, requestedDomain string, credentials map[string]string) error +} + +type aliyunDomainCheckRequest struct { + Domain string `json:"domain"` +} + +type aliyunScopeResponse struct { + Provider string `json:"provider"` + Configured bool `json:"configured"` + CredentialRef string `json:"credential_ref,omitempty"` + PolicyMode string `json:"policy_mode"` + GlobalDiscoveryAvailable bool `json:"global_discovery_available"` + Message string `json:"message"` + DiscoveredDomains []dnsprovider.AuthorizationDomain `json:"discovered_domains,omitempty"` + ManagedDomains []string `json:"managed_domains"` + CheckedAt string `json:"checked_at"` +} + +func (s *Server) getAliyunDNSScope(c *gin.Context) { + s.configMu.RLock() + provider := strings.ToLower(strings.TrimSpace(s.config.DNS.Provider)) + credentialRef := strings.TrimSpace(s.config.DNS.CredentialRef) + s.configMu.RUnlock() + + if provider != dnsprovider.Aliyun { + c.JSON(http.StatusOK, aliyunScopeResponse{ + Provider: provider, + Configured: false, + PolicyMode: "not_aliyun", + Message: "系统当前未将阿里云配置为主 DNS 服务商", + }) + return + } + + credentials, err := s.readDNSCredentials(c.Request.Context(), credentialRef) + if err != nil { + s.writeError(c, http.StatusInternalServerError, "读取阿里云 DNS 凭据失败", err) + return + } + + response := aliyunScopeResponse{ + Provider: dnsprovider.Aliyun, + Configured: true, + CredentialRef: credentialRef, + CheckedAt: s.now().UTC().Format("2006-01-02T15:04:05Z"), + } + + // 读取当前系统内已经纳管的域名列表 + if s.repository != nil { + if items, listErr := s.repository.List(c.Request.Context()); listErr == nil { + for _, item := range items { + response.ManagedDomains = append(response.ManagedDomains, item.Name) + } + } + } + + if s.aliyunChecker == nil { + response.PolicyMode = "uninitialized" + response.Message = "阿里云权限探测器未装载" + c.JSON(http.StatusOK, response) + return + } + + // 尝试全局发现 + report, discoverErr := s.aliyunChecker.Discover(c.Request.Context(), credentials) + if discoverErr == nil && len(report.Domains) > 0 { + response.GlobalDiscoveryAvailable = true + response.PolicyMode = "broad_or_read_all" + response.Message = "当前 RAM 账号具备全局根域名读取权限" + response.DiscoveredDomains = report.Domains + } else { + response.GlobalDiscoveryAvailable = false + response.PolicyMode = "resource_scoped" + response.Message = "当前 RAM 账号受特定域名资源策略限制(最小权限原则),不支持全局枚举,已启用单域名靶向权威考证" + } + + c.JSON(http.StatusOK, response) +} + +func (s *Server) checkAliyunDNSDomain(c *gin.Context) { + var request aliyunDomainCheckRequest + if err := bindJSON(c, &request, jsonRequestBodyLimit); err != nil { + s.writeError(c, http.StatusBadRequest, "请求参数无效", err) + return + } + targetDomain := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(request.Domain), ".")) + if !domain.IsValidName(targetDomain) { + s.writeError(c, http.StatusBadRequest, fmt.Sprintf("域名格式无效:%s", targetDomain), nil) + return + } + + s.configMu.RLock() + provider := strings.ToLower(strings.TrimSpace(s.config.DNS.Provider)) + credentialRef := strings.TrimSpace(s.config.DNS.CredentialRef) + s.configMu.RUnlock() + + if provider != dnsprovider.Aliyun { + s.writeError(c, http.StatusBadRequest, "当前系统 DNS 服务商不是阿里云,无法进行阿里云 RAM 权限考证", nil) + return + } + + credentials, err := s.readDNSCredentials(c.Request.Context(), credentialRef) + if err != nil { + s.writeError(c, http.StatusInternalServerError, "读取阿里云 DNS 凭据失败", err) + return + } + + if s.aliyunChecker == nil { + s.writeError(c, http.StatusNotImplemented, "阿里云权限探测器未装载", nil) + return + } + + report, verifyErr := s.aliyunChecker.Verify(c.Request.Context(), targetDomain, credentials) + if verifyErr != nil { + c.JSON(http.StatusOK, gin.H{ + "verified": false, + "domain": targetDomain, + "matched_domain": report.MatchedDomain, + "diagnosis": report.Diagnosis, + "error": verifyErr.Error(), + "report": report, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "verified": true, + "domain": targetDomain, + "matched_domain": report.MatchedDomain, + "message": "阿里云 RAM 账号对该域名的 DNS TXT 记录写入与清理权限验证通过", + "report": report, + }) +} + +func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainName string) error { + if s == nil || s.aliyunChecker == nil { + return nil + } + s.configMu.RLock() + setupReady := s.config.SetupReady + provider := strings.ToLower(strings.TrimSpace(s.config.DNS.Provider)) + credentialRef := strings.TrimSpace(s.config.DNS.CredentialRef) + s.configMu.RUnlock() + + if !setupReady || provider != dnsprovider.Aliyun || credentialRef == "" { + return nil + } + + credentials, err := s.readDNSCredentials(ctx, credentialRef) + if err != nil || len(credentials) == 0 { + return nil + } + + if err := s.aliyunChecker.VerifyDomain(ctx, dnsprovider.Aliyun, domainName, credentials); err != nil { + diagnosis := dnsprovider.DiagnoseAliyunError(err, domainName) + return fmt.Errorf("阿里云 RAM 账号无法操作域名 %s:%s", domainName, diagnosis) + } + return nil +} + +func (s *Server) readDNSCredentials(ctx context.Context, credentialRef string) (map[string]string, error) { + if s == nil || strings.TrimSpace(credentialRef) == "" { + return nil, errors.New("DNS 凭据引用未配置") + } + reader, ok := s.secrets.(readinessSecretReader) + if !ok || reader == nil { + return nil, errors.New("凭据存储未就绪") + } + return reader.Get(ctx, credentialRef) +} \ No newline at end of file diff --git a/backend/internal/httpapi/aliyun_scope_api_test.go b/backend/internal/httpapi/aliyun_scope_api_test.go new file mode 100644 index 0000000..2d9a192 --- /dev/null +++ b/backend/internal/httpapi/aliyun_scope_api_test.go @@ -0,0 +1,168 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" +) + +type fakeAliyunScopeChecker struct { + allowedZones []string +} + +func (f *fakeAliyunScopeChecker) Discover(ctx context.Context, credentials map[string]string) (dnsprovider.AuthorizationReport, error) { + // 模拟 RAM 账号处于最小资源限制模式,无全局枚举权限 + return dnsprovider.AuthorizationReport{}, errors.New("SDKError: Code: Forbidden.NoPermission") +} + +func (f *fakeAliyunScopeChecker) Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (dnsprovider.AuthorizationReport, error) { + report := dnsprovider.AuthorizationReport{ + Provider: dnsprovider.Aliyun, + RequestedDomain: requestedDomain, + } + for _, zone := range f.allowedZones { + if requestedDomain == zone || strings.HasSuffix(requestedDomain, "."+zone) { + report.Verified = true + report.MatchedDomain = zone + return report, nil + } + } + err := errors.New("SDKError: Code: Forbidden.NoPermission, Message: Specified access key is not authorized.") + report.Diagnosis = dnsprovider.DiagnoseAliyunError(err, requestedDomain) + return report, err +} + +func (f *fakeAliyunScopeChecker) VerifyDomain(ctx context.Context, providerID, requestedDomain string, credentials map[string]string) error { + for _, zone := range f.allowedZones { + if requestedDomain == zone || strings.HasSuffix(requestedDomain, "."+zone) { + return nil + } + } + return errors.New("SDKError: Code: Forbidden.NoPermission, Message: Specified access key is not authorized.") +} + +type fakeSecretReaderStore struct { + data map[string]map[string]string +} + +func (s *fakeSecretReaderStore) Put(ctx context.Context, ref string, val map[string]string) error { + if s.data == nil { + s.data = make(map[string]map[string]string) + } + s.data[ref] = val + return nil +} + +func (s *fakeSecretReaderStore) Delete(ctx context.Context, ref string) error { + delete(s.data, ref) + return nil +} + +func (s *fakeSecretReaderStore) Get(ctx context.Context, ref string) (map[string]string, error) { + if val, ok := s.data[ref]; ok { + return val, nil + } + return nil, errors.New("secret not found") +} + +func TestAliyunScopeAPIsAndDomainValidation(t *testing.T) { + cfg := readyTestConfig(t.TempDir()) + cfg.SetupReady = true + cfg.DNS.Provider = "aliyun" + cfg.DNS.CredentialRef = "secret://dns/aliyun/ref1" + + store := &fakeSecretReaderStore{ + data: map[string]map[string]string{ + "secret://dns/aliyun/ref1": { + "access_key_id": "LTAI_test", + "access_key_secret": "test_secret", + }, + }, + } + + checker := &fakeAliyunScopeChecker{ + allowedZones: []string{"solit.cn", "odacm.cn", "apmmk.cn", "ifmj.uno", "vraq.uno"}, + } + + repo := &stubDomainRepository{} + server := New(Dependencies{ + Config: cfg, + Repository: repo, + Secrets: store, + AliyunChecker: checker, + }) + + // 1. 测试 GET /api/v1/dns/aliyun/scope(受限模式报告) + { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/dns/aliyun/scope", nil) + serveAdminHTTP(server, w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET /api/v1/dns/aliyun/scope status = %d; want 200, body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "resource_scoped") { + t.Fatalf("expected resource_scoped policy mode, got: %s", w.Body.String()) + } + } + + // 2. 测试 POST /api/v1/dns/aliyun/check 验证授权域名(如 solit.cn) + { + w := httptest.NewRecorder() + body := strings.NewReader(`{"domain":"solit.cn"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/dns/aliyun/check", body) + serveAdminHTTP(server, w, req) + if w.Code != http.StatusOK { + t.Fatalf("POST check solit.cn status = %d; body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"verified":true`) { + t.Fatalf("solit.cn 应验证通过: %s", w.Body.String()) + } + } + + // 3. 测试 POST /api/v1/dns/aliyun/check 验证未授权域名(如 unauth.com) + { + w := httptest.NewRecorder() + body := strings.NewReader(`{"domain":"unauth.com"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/dns/aliyun/check", body) + serveAdminHTTP(server, w, req) + if w.Code != http.StatusOK { + t.Fatalf("POST check unauth.com status = %d; body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"verified":false`) { + t.Fatalf("unauth.com 应验证失败: %s", w.Body.String()) + } + if !strings.Contains(w.Body.String(), "acs:alidns:*:*:domain/unauth.com") { + t.Fatalf("应包含明确的 RAM 策略建议: %s", w.Body.String()) + } + } + + // 4. 测试 POST /api/v1/domains 添加授权域名(例如 *.solit.cn) + { + w := httptest.NewRecorder() + body := strings.NewReader(`{"name":"test.solit.cn","certificate_type":"single"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", body) + serveAdminHTTP(server, w, req) + if w.Code != http.StatusCreated { + t.Fatalf("创建授权域名应成功,got status = %d; body=%s", w.Code, w.Body.String()) + } + } + + // 5. 测试 POST /api/v1/domains 添加未授权域名(例如 forbidden.cn),应被前置阻断 + { + w := httptest.NewRecorder() + body := strings.NewReader(`{"name":"forbidden.cn","certificate_type":"single"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", body) + serveAdminHTTP(server, w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("添加未授权域名应被 400 阻断,got status = %d; body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "acs:alidns:*:*:domain/forbidden.cn") { + t.Fatalf("阻断提示应包含针对该域名的 RAM 授权指导,got: %s", w.Body.String()) + } + } +} \ No newline at end of file diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 1940eb2..3475b4d 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -64,6 +64,7 @@ type Dependencies struct { Audit repository.AuditRepository Tasks repository.TaskRepository ReadinessCheck func(context.Context) error + AliyunChecker aliyunScopeChecker } type Server struct { @@ -93,6 +94,7 @@ type Server struct { audit repository.AuditRepository tasks repository.TaskRepository readinessCheck func(context.Context) error + aliyunChecker aliyunScopeChecker taskMu sync.Mutex taskCancels map[string]context.CancelFunc issueTaskMu sync.Mutex @@ -135,6 +137,7 @@ func New(deps Dependencies) *Server { audit: deps.Audit, tasks: deps.Tasks, readinessCheck: deps.ReadinessCheck, + aliyunChecker: deps.AliyunChecker, taskCancels: make(map[string]context.CancelFunc), resetLimiter: newResetRateLimiter(), authSessions: make(map[string]authSession), @@ -180,6 +183,8 @@ func (s *Server) registerRoutes() { adminAPI.PUT("/settings", s.updateSettings) adminAPI.GET("/dns/providers", s.listAdminDNSProviders) adminAPI.POST("/dns/test", s.testAdminDNS) + adminAPI.GET("/dns/aliyun/scope", s.getAliyunDNSScope) + adminAPI.POST("/dns/aliyun/check", s.checkAliyunDNSDomain) adminAPI.POST("/renewal/run", s.runRenewalNow) adminAPI.GET("/certificates", s.listCertificates) adminAPI.GET("/certificates/:id", s.getCertificate) @@ -453,6 +458,16 @@ func (s *Server) createDomain(c *gin.Context) { s.writeError(c, http.StatusBadRequest, "域名配置无效", err) return } + if err := s.validateDomainAliyunAuthorization(c.Request.Context(), item.Name); err != nil { + s.writeError(c, http.StatusBadRequest, err.Error(), err) + return + } + for _, san := range item.SANs { + if err := s.validateDomainAliyunAuthorization(c.Request.Context(), san); err != nil { + s.writeError(c, http.StatusBadRequest, err.Error(), err) + return + } + } if err := s.repository.Create(c.Request.Context(), item); err != nil { if repository.IsConflict(err) { s.writeError(c, http.StatusConflict, "域名已经存在", err) @@ -487,6 +502,16 @@ func (s *Server) updateDomain(c *gin.Context) { s.writeError(c, http.StatusBadRequest, "域名配置无效", err) return } + if err := s.validateDomainAliyunAuthorization(c.Request.Context(), item.Name); err != nil { + s.writeError(c, http.StatusBadRequest, err.Error(), err) + return + } + for _, san := range item.SANs { + if err := s.validateDomainAliyunAuthorization(c.Request.Context(), san); err != nil { + s.writeError(c, http.StatusBadRequest, err.Error(), err) + return + } + } item.ID = existing.ID item.CreatedAt = existing.CreatedAt item.Status = "pending" @@ -764,4 +789,4 @@ func (s *Server) writeError(c *gin.Context, status int, message string, err erro s.logger.Error(message, "error", err) } c.JSON(status, gin.H{"error": message}) -} +} \ No newline at end of file