diff --git a/openflare_server/controller/tls_certificate.go b/openflare_server/controller/tls_certificate.go index dda205b3..79b02b35 100644 --- a/openflare_server/controller/tls_certificate.go +++ b/openflare_server/controller/tls_certificate.go @@ -334,6 +334,50 @@ func UpdateAcmeCertificate(c *gin.Context) { }) } +// ConvertTLSCertificateToAcme godoc +// @Summary Convert uploaded TLS certificate to ACME managed certificate +// @Tags TLSCertificates +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Certificate ID" +// @Param payload body service.TLSApplyInput true "TLS apply payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Router /api/tls-certificates/{id}/convert-acme [post] +func ConvertTLSCertificateToAcme(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "invalid request", + }) + return + } + + var input service.TLSApplyInput + if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + certificate, err := service.ConvertTLSCertificateToAcme(uint(id), input) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": certificate, + }) +} + // RenewTLSCertificate godoc // @Summary Renew TLS certificate // @Tags TLSCertificates diff --git a/openflare_server/docs/docs.go b/openflare_server/docs/docs.go index 173ddda8..78e49c4a 100644 --- a/openflare_server/docs/docs.go +++ b/openflare_server/docs/docs.go @@ -326,6 +326,31 @@ const docTemplate = `{ } } }, + "/api/acme-accounts/default": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AcmeAccounts" + ], + "summary": "Get default ACME account", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/agent/apply-logs": { "post": { "security": [ @@ -600,6 +625,49 @@ const docTemplate = `{ } } }, + "/api/config-versions/cleanup": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "ConfigVersions" + ], + "summary": "Cleanup old config versions", + "parameters": [ + { + "description": "Cleanup request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.CleanupConfigVersionRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/config-versions/diff": { "get": { "security": [ @@ -789,6 +857,148 @@ const docTemplate = `{ } } }, + "/api/dns-accounts/": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "DnsAccounts" + ], + "summary": "List DNS accounts", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "DnsAccounts" + ], + "summary": "Create DNS account", + "parameters": [ + { + "description": "DNS account payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.DnsAccountInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/dns-accounts/{id}/delete": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "DnsAccounts" + ], + "summary": "Delete DNS account", + "parameters": [ + { + "type": "integer", + "description": "DNS Account ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/dns-accounts/{id}/update": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "DnsAccounts" + ], + "summary": "Update DNS account", + "parameters": [ + { + "type": "integer", + "description": "DNS Account ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "DNS account payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.DnsAccountInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/managed-domains/": { "get": { "security": [ @@ -1552,6 +1762,47 @@ const docTemplate = `{ } } }, + "/api/option/update-batch": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Options" + ], + "summary": "Batch update options", + "parameters": [ + { + "description": "Batch option payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.optionBatchPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/proxy-routes/": { "get": { "security": [ @@ -1621,6 +1872,47 @@ const docTemplate = `{ } } }, + "/api/proxy-routes/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "ProxyRoutes" + ], + "summary": "Get proxy route detail", + "parameters": [ + { + "type": "integer", + "description": "Route ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/proxy-routes/{id}/delete": { "post": { "security": [ @@ -1804,6 +2096,52 @@ const docTemplate = `{ } } }, + "/api/tls-certificates/apply": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TLSCertificates" + ], + "summary": "Apply TLS certificate via ACME", + "parameters": [ + { + "description": "TLS apply payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/service.TLSApplyInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/tls-certificates/import-file": { "post": { "security": [ @@ -1950,6 +2288,59 @@ const docTemplate = `{ } } }, + "/api/tls-certificates/{id}/convert-acme": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TLSCertificates" + ], + "summary": "Convert uploaded TLS certificate to ACME managed certificate", + "parameters": [ + { + "type": "integer", + "description": "Certificate ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "TLS apply payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/service.TLSApplyInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/tls-certificates/{id}/delete": { "post": { "security": [ @@ -1991,6 +2382,47 @@ const docTemplate = `{ } } }, + "/api/tls-certificates/{id}/renew": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "TLSCertificates" + ], + "summary": "Renew TLS certificate", + "parameters": [ + { + "type": "integer", + "description": "Certificate ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/tls-certificates/{id}/update": { "post": { "security": [ @@ -2044,6 +2476,59 @@ const docTemplate = `{ } } }, + "/api/tls-certificates/{id}/update-acme": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TLSCertificates" + ], + "summary": "Update ACME TLS certificate", + "parameters": [ + { + "type": "integer", + "description": "Certificate ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "TLS apply payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/service.TLSApplyInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/update/latest-release": { "get": { "produces": [ @@ -2141,6 +2626,32 @@ const docTemplate = `{ } }, "definitions": { + "controller.CleanupConfigVersionRequest": { + "type": "object", + "required": [ + "keep_count" + ], + "properties": { + "keep_count": { + "type": "integer", + "minimum": 3 + } + } + }, + "controller.DnsAccountInput": { + "type": "object", + "properties": { + "authorization": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "controller.geoIPLookupRequest": { "type": "object", "properties": { @@ -2152,6 +2663,17 @@ const docTemplate = `{ } } }, + "controller.optionBatchPayload": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Option" + } + } + } + }, "model.Option": { "type": "object", "properties": { @@ -2491,6 +3013,15 @@ const docTemplate = `{ "service.ProxyRouteInput": { "type": "object", "properties": { + "basic_auth_enabled": { + "type": "boolean" + }, + "basic_auth_password": { + "type": "string" + }, + "basic_auth_username": { + "type": "string" + }, "cache_enabled": { "type": "boolean" }, @@ -2506,6 +3037,12 @@ const docTemplate = `{ "cert_id": { "type": "integer" }, + "cert_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, "custom_headers": { "type": "array", "items": { @@ -2515,24 +3052,69 @@ const docTemplate = `{ "domain": { "type": "string" }, + "domain_cert_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "domains": { + "type": "array", + "items": { + "type": "string" + } + }, "enable_https": { "type": "boolean" }, "enabled": { "type": "boolean" }, + "limit_conn_per_ip": { + "type": "integer" + }, + "limit_conn_per_server": { + "type": "integer" + }, + "limit_rate": { + "type": "string" + }, + "origin_address": { + "type": "string" + }, "origin_host": { "type": "string" }, + "origin_id": { + "type": "integer" + }, + "origin_port": { + "type": "string" + }, + "origin_scheme": { + "type": "string" + }, + "origin_uri": { + "type": "string" + }, "origin_url": { "type": "string" }, + "pow_config": { + "type": "string" + }, + "pow_enabled": { + "type": "boolean" + }, "redirect_http": { "type": "boolean" }, "remark": { "type": "string" }, + "site_name": { + "type": "string" + }, "upstreams": { "type": "array", "items": { @@ -2541,6 +3123,47 @@ const docTemplate = `{ } } }, + "service.TLSApplyInput": { + "type": "object", + "properties": { + "acme_account_id": { + "type": "integer" + }, + "auto_renew": { + "type": "boolean" + }, + "disable_cname": { + "type": "boolean" + }, + "dns1": { + "type": "string" + }, + "dns2": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "key_algorithm": { + "type": "string" + }, + "name": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + } + } + }, "service.TLSCertificateInput": { "type": "object", "properties": { diff --git a/openflare_server/docs/swagger.json b/openflare_server/docs/swagger.json index 0e2b163e..c8acf721 100644 --- a/openflare_server/docs/swagger.json +++ b/openflare_server/docs/swagger.json @@ -323,6 +323,31 @@ } } }, + "/api/acme-accounts/default": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "AcmeAccounts" + ], + "summary": "Get default ACME account", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/agent/apply-logs": { "post": { "security": [ @@ -597,6 +622,49 @@ } } }, + "/api/config-versions/cleanup": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "ConfigVersions" + ], + "summary": "Cleanup old config versions", + "parameters": [ + { + "description": "Cleanup request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.CleanupConfigVersionRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/config-versions/diff": { "get": { "security": [ @@ -786,6 +854,148 @@ } } }, + "/api/dns-accounts/": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "DnsAccounts" + ], + "summary": "List DNS accounts", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "DnsAccounts" + ], + "summary": "Create DNS account", + "parameters": [ + { + "description": "DNS account payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.DnsAccountInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/dns-accounts/{id}/delete": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "DnsAccounts" + ], + "summary": "Delete DNS account", + "parameters": [ + { + "type": "integer", + "description": "DNS Account ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/dns-accounts/{id}/update": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "DnsAccounts" + ], + "summary": "Update DNS account", + "parameters": [ + { + "type": "integer", + "description": "DNS Account ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "DNS account payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.DnsAccountInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/managed-domains/": { "get": { "security": [ @@ -1549,6 +1759,47 @@ } } }, + "/api/option/update-batch": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Options" + ], + "summary": "Batch update options", + "parameters": [ + { + "description": "Batch option payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controller.optionBatchPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/proxy-routes/": { "get": { "security": [ @@ -1618,6 +1869,47 @@ } } }, + "/api/proxy-routes/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "ProxyRoutes" + ], + "summary": "Get proxy route detail", + "parameters": [ + { + "type": "integer", + "description": "Route ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/proxy-routes/{id}/delete": { "post": { "security": [ @@ -1801,6 +2093,52 @@ } } }, + "/api/tls-certificates/apply": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TLSCertificates" + ], + "summary": "Apply TLS certificate via ACME", + "parameters": [ + { + "description": "TLS apply payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/service.TLSApplyInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/tls-certificates/import-file": { "post": { "security": [ @@ -1947,6 +2285,59 @@ } } }, + "/api/tls-certificates/{id}/convert-acme": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TLSCertificates" + ], + "summary": "Convert uploaded TLS certificate to ACME managed certificate", + "parameters": [ + { + "type": "integer", + "description": "Certificate ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "TLS apply payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/service.TLSApplyInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/tls-certificates/{id}/delete": { "post": { "security": [ @@ -1988,6 +2379,47 @@ } } }, + "/api/tls-certificates/{id}/renew": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "TLSCertificates" + ], + "summary": "Renew TLS certificate", + "parameters": [ + { + "type": "integer", + "description": "Certificate ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/tls-certificates/{id}/update": { "post": { "security": [ @@ -2041,6 +2473,59 @@ } } }, + "/api/tls-certificates/{id}/update-acme": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TLSCertificates" + ], + "summary": "Update ACME TLS certificate", + "parameters": [ + { + "type": "integer", + "description": "Certificate ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "TLS apply payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/service.TLSApplyInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/update/latest-release": { "get": { "produces": [ @@ -2138,6 +2623,32 @@ } }, "definitions": { + "controller.CleanupConfigVersionRequest": { + "type": "object", + "required": [ + "keep_count" + ], + "properties": { + "keep_count": { + "type": "integer", + "minimum": 3 + } + } + }, + "controller.DnsAccountInput": { + "type": "object", + "properties": { + "authorization": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "controller.geoIPLookupRequest": { "type": "object", "properties": { @@ -2149,6 +2660,17 @@ } } }, + "controller.optionBatchPayload": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Option" + } + } + } + }, "model.Option": { "type": "object", "properties": { @@ -2488,6 +3010,15 @@ "service.ProxyRouteInput": { "type": "object", "properties": { + "basic_auth_enabled": { + "type": "boolean" + }, + "basic_auth_password": { + "type": "string" + }, + "basic_auth_username": { + "type": "string" + }, "cache_enabled": { "type": "boolean" }, @@ -2503,6 +3034,12 @@ "cert_id": { "type": "integer" }, + "cert_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, "custom_headers": { "type": "array", "items": { @@ -2512,24 +3049,69 @@ "domain": { "type": "string" }, + "domain_cert_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "domains": { + "type": "array", + "items": { + "type": "string" + } + }, "enable_https": { "type": "boolean" }, "enabled": { "type": "boolean" }, + "limit_conn_per_ip": { + "type": "integer" + }, + "limit_conn_per_server": { + "type": "integer" + }, + "limit_rate": { + "type": "string" + }, + "origin_address": { + "type": "string" + }, "origin_host": { "type": "string" }, + "origin_id": { + "type": "integer" + }, + "origin_port": { + "type": "string" + }, + "origin_scheme": { + "type": "string" + }, + "origin_uri": { + "type": "string" + }, "origin_url": { "type": "string" }, + "pow_config": { + "type": "string" + }, + "pow_enabled": { + "type": "boolean" + }, "redirect_http": { "type": "boolean" }, "remark": { "type": "string" }, + "site_name": { + "type": "string" + }, "upstreams": { "type": "array", "items": { @@ -2538,6 +3120,47 @@ } } }, + "service.TLSApplyInput": { + "type": "object", + "properties": { + "acme_account_id": { + "type": "integer" + }, + "auto_renew": { + "type": "boolean" + }, + "disable_cname": { + "type": "boolean" + }, + "dns1": { + "type": "string" + }, + "dns2": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "key_algorithm": { + "type": "string" + }, + "name": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + } + } + }, "service.TLSCertificateInput": { "type": "object", "properties": { diff --git a/openflare_server/docs/swagger.yaml b/openflare_server/docs/swagger.yaml index aa431802..40813ce7 100644 --- a/openflare_server/docs/swagger.yaml +++ b/openflare_server/docs/swagger.yaml @@ -1,5 +1,22 @@ basePath: / definitions: + controller.CleanupConfigVersionRequest: + properties: + keep_count: + minimum: 3 + type: integer + required: + - keep_count + type: object + controller.DnsAccountInput: + properties: + authorization: + type: string + name: + type: string + type: + type: string + type: object controller.geoIPLookupRequest: properties: ip: @@ -7,6 +24,13 @@ definitions: provider: type: string type: object + controller.optionBatchPayload: + properties: + options: + items: + $ref: '#/definitions/model.Option' + type: array + type: object model.Option: properties: key: @@ -229,6 +253,12 @@ definitions: type: object service.ProxyRouteInput: properties: + basic_auth_enabled: + type: boolean + basic_auth_password: + type: string + basic_auth_username: + type: string cache_enabled: type: boolean cache_policy: @@ -239,29 +269,90 @@ definitions: type: array cert_id: type: integer + cert_ids: + items: + type: integer + type: array custom_headers: items: $ref: '#/definitions/service.ProxyRouteCustomHeaderInput' type: array domain: type: string + domain_cert_ids: + items: + type: integer + type: array + domains: + items: + type: string + type: array enable_https: type: boolean enabled: type: boolean + limit_conn_per_ip: + type: integer + limit_conn_per_server: + type: integer + limit_rate: + type: string + origin_address: + type: string origin_host: type: string + origin_id: + type: integer + origin_port: + type: string + origin_scheme: + type: string + origin_uri: + type: string origin_url: type: string + pow_config: + type: string + pow_enabled: + type: boolean redirect_http: type: boolean remark: type: string + site_name: + type: string upstreams: items: type: string type: array type: object + service.TLSApplyInput: + properties: + acme_account_id: + type: integer + auto_renew: + type: boolean + disable_cname: + type: boolean + dns_account_id: + type: integer + dns1: + type: string + dns2: + type: string + key_algorithm: + type: string + name: + type: string + other_domains: + type: string + primary_domain: + type: string + remark: + type: string + skip_dns: + type: boolean + type: object service.TLSCertificateInput: properties: cert_pem: @@ -477,6 +568,21 @@ paths: summary: Get access log IP trend tags: - AccessLogs + /api/acme-accounts/default: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Get default ACME account + tags: + - AcmeAccounts /api/agent/apply-logs: post: consumes: @@ -698,6 +804,33 @@ paths: summary: Get active config version tags: - ConfigVersions + /api/config-versions/cleanup: + post: + parameters: + - description: Cleanup request + in: body + name: request + required: true + schema: + $ref: '#/definitions/controller.CleanupConfigVersionRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Cleanup old config versions + tags: + - ConfigVersions /api/config-versions/diff: get: produces: @@ -763,6 +896,94 @@ paths: summary: Get dashboard overview tags: - Dashboard + /api/dns-accounts/: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: List DNS accounts + tags: + - DnsAccounts + post: + consumes: + - application/json + parameters: + - description: DNS account payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/controller.DnsAccountInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Create DNS account + tags: + - DnsAccounts + /api/dns-accounts/{id}/delete: + post: + parameters: + - description: DNS Account ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Delete DNS account + tags: + - DnsAccounts + /api/dns-accounts/{id}/update: + post: + consumes: + - application/json + parameters: + - description: DNS Account ID + in: path + name: id + required: true + type: integer + - description: DNS account payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/controller.DnsAccountInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Update DNS account + tags: + - DnsAccounts /api/managed-domains/: get: produces: @@ -1246,6 +1467,33 @@ paths: summary: Update option tags: - Options + /api/option/update-batch: + post: + consumes: + - application/json + parameters: + - description: Batch option payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/controller.optionBatchPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + summary: Batch update options + tags: + - Options /api/proxy-routes/: get: produces: @@ -1289,6 +1537,32 @@ paths: summary: Create proxy route tags: - ProxyRoutes + /api/proxy-routes/{id}: + get: + parameters: + - description: Route ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Get proxy route detail + tags: + - ProxyRoutes /api/proxy-routes/{id}/delete: post: parameters: @@ -1457,6 +1731,40 @@ paths: summary: Get TLS certificate PEM content tags: - TLSCertificates + /api/tls-certificates/{id}/convert-acme: + post: + consumes: + - application/json + parameters: + - description: Certificate ID + in: path + name: id + required: true + type: integer + - description: TLS apply payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/service.TLSApplyInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Convert uploaded TLS certificate to ACME managed certificate + tags: + - TLSCertificates /api/tls-certificates/{id}/delete: post: parameters: @@ -1483,6 +1791,32 @@ paths: summary: Delete TLS certificate tags: - TLSCertificates + /api/tls-certificates/{id}/renew: + post: + parameters: + - description: Certificate ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Renew TLS certificate + tags: + - TLSCertificates /api/tls-certificates/{id}/update: post: consumes: @@ -1517,6 +1851,69 @@ paths: summary: Update TLS certificate from PEM tags: - TLSCertificates + /api/tls-certificates/{id}/update-acme: + post: + consumes: + - application/json + parameters: + - description: Certificate ID + in: path + name: id + required: true + type: integer + - description: TLS apply payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/service.TLSApplyInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Update ACME TLS certificate + tags: + - TLSCertificates + /api/tls-certificates/apply: + post: + consumes: + - application/json + parameters: + - description: TLS apply payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/service.TLSApplyInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Apply TLS certificate via ACME + tags: + - TLSCertificates /api/tls-certificates/import-file: post: consumes: diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go index 498ac108..2cbce3ec 100644 --- a/openflare_server/router/api-router.go +++ b/openflare_server/router/api-router.go @@ -121,6 +121,7 @@ func SetApiRouter(router *gin.Engine) { tlsCertificateRoute.POST("/", controller.CreateTLSCertificate) tlsCertificateRoute.POST("/:id/update", controller.UpdateTLSCertificate) tlsCertificateRoute.POST("/:id/update-acme", controller.UpdateAcmeCertificate) + tlsCertificateRoute.POST("/:id/convert-acme", controller.ConvertTLSCertificateToAcme) tlsCertificateRoute.POST("/import-file", controller.ImportTLSCertificateFile) tlsCertificateRoute.POST("/:id/delete", controller.DeleteTLSCertificate) tlsCertificateRoute.POST("/apply", controller.ApplyTLSCertificate) diff --git a/openflare_server/router/api_phase1_test.go b/openflare_server/router/api_phase1_test.go index 06e87df5..aa04177f 100644 --- a/openflare_server/router/api_phase1_test.go +++ b/openflare_server/router/api_phase1_test.go @@ -8,6 +8,7 @@ import ( "crypto/x509/pkix" "encoding/json" "encoding/pem" + "errors" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" @@ -387,6 +388,95 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) { } } +func TestTLSCertificateConvertAcmeAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + common.RedisEnabled = false + setupTestDB(t) + + engine := gin.New() + engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret")))) + router.SetApiRouter(engine) + + token := prepareRootToken(t) + certPEM, keyPEM := generateCertificatePairForRouterTest(t, []string{"manual.example.com"}) + createResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{ + "name": "manual-example", + "cert_pem": certPEM, + "key_pem": keyPEM, + }) + var certificate model.TLSCertificate + decodeResponseData(t, createResp, &certificate) + + started := make(chan struct{}, 1) + release := make(chan struct{}) + done := make(chan struct{}) + restore := service.SetTLSCertificateObtainFuncForTest(func(c *model.TLSCertificate) error { + defer close(done) + started <- struct{}{} + <-release + return errors.New("stop test conversion before external ACME call") + }) + t.Cleanup(func() { + close(release) + <-done + restore() + }) + + convertResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/"+toString(certificate.ID)+"/convert-acme", map[string]any{ + "name": "managed-example", + "remark": "convert via api", + "acme_account_id": 1, + "dns_account_id": 2, + "key_algorithm": "EC256", + "auto_renew": true, + "primary_domain": "manual.example.com", + }) + var converted model.TLSCertificate + decodeResponseData(t, convertResp, &converted) + if converted.ID != certificate.ID || converted.Provider != "upload" || converted.ApplyStatus != "applying" { + t.Fatalf("expected conversion API to keep upload provider while applying, got %+v", converted) + } + + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("expected conversion task to start") + } + + duplicateResp := performJSONRequestNoFatal(t, engine, token, http.MethodPost, "/api/tls-certificates/"+toString(certificate.ID)+"/convert-acme", map[string]any{ + "name": "managed-example", + "primary_domain": "manual.example.com", + }) + if duplicateResp.Success || !strings.Contains(duplicateResp.Message, "already applying") { + t.Fatalf("expected duplicate conversion to fail, got %+v", duplicateResp) + } + + invalidResp := performJSONRequestNoFatal(t, engine, token, http.MethodPost, "/api/tls-certificates/not-a-number/convert-acme", map[string]any{}) + if invalidResp.Success || !strings.Contains(invalidResp.Message, "invalid request") { + t.Fatalf("expected invalid id to fail, got %+v", invalidResp) + } + + acmeCertPEM, acmeKeyPEM := generateCertificatePairForRouterTest(t, []string{"acme.example.com"}) + acmeResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{ + "name": "already-acme", + "cert_pem": acmeCertPEM, + "key_pem": acmeKeyPEM, + }) + var acmeCertificate model.TLSCertificate + decodeResponseData(t, acmeResp, &acmeCertificate) + acmeCertificate.Provider = "acme" + if err := acmeCertificate.Update(); err != nil { + t.Fatalf("failed to mark certificate acme: %v", err) + } + nonUploadResp := performJSONRequestNoFatal(t, engine, token, http.MethodPost, "/api/tls-certificates/"+toString(acmeCertificate.ID)+"/convert-acme", map[string]any{ + "name": "already-acme", + "primary_domain": "acme.example.com", + }) + if nonUploadResp.Success || !strings.Contains(nonUploadResp.Message, "only uploaded") { + t.Fatalf("expected non-upload conversion to fail, got %+v", nonUploadResp) + } +} + func setupTestDB(t *testing.T) { t.Helper() dbPath := filepath.Join(t.TempDir(), "phase1.db") @@ -445,6 +535,33 @@ func performJSONRequest(t *testing.T, engine http.Handler, token string, method return resp } +func performJSONRequestNoFatal(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse { + t.Helper() + var payload []byte + var err error + if body != nil { + payload, err = json.Marshal(body) + if err != nil { + t.Fatalf("failed to marshal request body: %v", err) + } + } + req := httptest.NewRequest(method, path, bytes.NewReader(payload)) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer "+token) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK && recorder.Code != http.StatusBadRequest { + t.Fatalf("unexpected status %d for %s %s: %s", recorder.Code, method, path, recorder.Body.String()) + } + var resp apiResponse + if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + return resp +} + func decodeResponseData(t *testing.T, resp apiResponse, target any) { t.Helper() if err := json.Unmarshal(resp.Data, target); err != nil { diff --git a/openflare_server/service/tls_acme_test.go b/openflare_server/service/tls_acme_test.go index 8c01ac4b..c1811ab1 100644 --- a/openflare_server/service/tls_acme_test.go +++ b/openflare_server/service/tls_acme_test.go @@ -1,7 +1,9 @@ package service import ( + "errors" "openflare/model" + "strings" "testing" "time" ) @@ -84,3 +86,186 @@ func TestAcmeAndDnsIntegration(t *testing.T) { t.Fatalf("Failed to delete DNS Account after cert cleanup: %v", err) } } + +func TestConvertTLSCertificateToAcmePreservesUploadUntilSuccess(t *testing.T) { + setupServiceTestDB(t) + + originalCertPEM, originalKeyPEM := generateCertificatePair(t, []string{"manual.example.com"}) + cert, err := CreateTLSCertificate(TLSCertificateInput{ + Name: "manual-cert", + CertPEM: originalCertPEM, + KeyPEM: originalKeyPEM, + Remark: "manual upload", + }) + if err != nil { + t.Fatalf("CreateTLSCertificate failed: %v", err) + } + originalCertPEM = cert.CertPEM + originalKeyPEM = cert.KeyPEM + + newCertPEM, newKeyPEM := generateCertificatePair(t, []string{"managed.example.com"}) + started := make(chan struct{}, 1) + release := make(chan struct{}) + restore := SetTLSCertificateObtainFuncForTest(func(c *model.TLSCertificate) error { + started <- struct{}{} + <-release + c.CertPEM = newCertPEM + c.KeyPEM = newKeyPEM + c.NotBefore = time.Now().Add(-time.Hour) + c.NotAfter = time.Now().Add(90 * 24 * time.Hour) + c.ApplyStatus = "ready" + c.ApplyMessage = "" + return model.DB.Save(c).Error + }) + t.Cleanup(restore) + + converted, err := ConvertTLSCertificateToAcme(cert.ID, TLSApplyInput{ + Name: "managed-cert", + Remark: "converted", + AcmeAccountID: 1, + DnsAccountID: 2, + KeyAlgorithm: "EC256", + AutoRenew: true, + PrimaryDomain: "managed.example.com", + OtherDomains: "www.managed.example.com", + }) + if err != nil { + t.Fatalf("ConvertTLSCertificateToAcme failed: %v", err) + } + if converted.ID != cert.ID { + t.Fatalf("expected converted certificate to keep id %d, got %d", cert.ID, converted.ID) + } + + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("expected conversion obtain task to start") + } + + applying, err := model.GetTLSCertificateByID(cert.ID) + if err != nil { + t.Fatalf("reload applying certificate failed: %v", err) + } + if applying.Provider != "upload" { + t.Fatalf("expected provider to remain upload while applying, got %s", applying.Provider) + } + if applying.ApplyStatus != "applying" { + t.Fatalf("expected applying status, got %s", applying.ApplyStatus) + } + if applying.CertPEM != originalCertPEM || applying.KeyPEM != originalKeyPEM { + t.Fatal("expected original PEM payloads to be preserved while applying") + } + + close(release) + + finalCert := waitForCertificateState(t, cert.ID, func(c *model.TLSCertificate) bool { + return c.Provider == "acme" && c.ApplyStatus == "ready" + }) + if finalCert.CertPEM != newCertPEM || finalCert.KeyPEM != newKeyPEM { + t.Fatal("expected successful conversion to replace PEM payloads") + } + if !finalCert.AutoRenew { + t.Fatal("expected converted certificate to keep auto renew enabled") + } + if finalCert.PrimaryDomain != "managed.example.com" || finalCert.OtherDomains != "www.managed.example.com" { + t.Fatalf("expected converted certificate to persist ACME domains, got %+v", finalCert) + } +} + +func TestConvertTLSCertificateToAcmePreservesUploadOnFailure(t *testing.T) { + setupServiceTestDB(t) + + originalCertPEM, originalKeyPEM := generateCertificatePair(t, []string{"manual.example.com"}) + cert, err := CreateTLSCertificate(TLSCertificateInput{ + Name: "manual-cert", + CertPEM: originalCertPEM, + KeyPEM: originalKeyPEM, + }) + if err != nil { + t.Fatalf("CreateTLSCertificate failed: %v", err) + } + originalCertPEM = cert.CertPEM + originalKeyPEM = cert.KeyPEM + + restore := SetTLSCertificateObtainFuncForTest(func(c *model.TLSCertificate) error { + err := errors.New("dns challenge failed") + updateCertError(c, err.Error()) + return err + }) + t.Cleanup(restore) + + if _, err := ConvertTLSCertificateToAcme(cert.ID, TLSApplyInput{ + Name: "manual-cert", + DnsAccountID: 1, + PrimaryDomain: "manual.example.com", + }); err != nil { + t.Fatalf("ConvertTLSCertificateToAcme failed: %v", err) + } + + finalCert := waitForCertificateState(t, cert.ID, func(c *model.TLSCertificate) bool { + return c.ApplyStatus == "error" + }) + if finalCert.Provider != "upload" { + t.Fatalf("expected failed conversion to keep upload provider, got %s", finalCert.Provider) + } + if finalCert.CertPEM != originalCertPEM || finalCert.KeyPEM != originalKeyPEM { + t.Fatal("expected failed conversion to preserve original PEM payloads") + } + if !strings.Contains(finalCert.ApplyMessage, "dns challenge failed") { + t.Fatalf("expected conversion error message, got %q", finalCert.ApplyMessage) + } +} + +func TestConvertTLSCertificateToAcmeRejectsInvalidStates(t *testing.T) { + setupServiceTestDB(t) + + certPEM, keyPEM := generateCertificatePair(t, []string{"manual.example.com"}) + cert, err := CreateTLSCertificate(TLSCertificateInput{ + Name: "manual-cert", + CertPEM: certPEM, + KeyPEM: keyPEM, + }) + if err != nil { + t.Fatalf("CreateTLSCertificate failed: %v", err) + } + + cert.Provider = "acme" + if err := cert.Update(); err != nil { + t.Fatalf("failed to mark certificate acme: %v", err) + } + if _, err := ConvertTLSCertificateToAcme(cert.ID, TLSApplyInput{Name: "manual-cert"}); err == nil || !strings.Contains(err.Error(), "only uploaded") { + t.Fatalf("expected non-upload conversion to fail, got %v", err) + } + + cert.Provider = "upload" + cert.ApplyStatus = "applying" + if err := cert.Update(); err != nil { + t.Fatalf("failed to mark certificate applying: %v", err) + } + if _, err := ConvertTLSCertificateToAcme(cert.ID, TLSApplyInput{Name: "manual-cert"}); err == nil || !strings.Contains(err.Error(), "already applying") { + t.Fatalf("expected applying conversion to fail, got %v", err) + } +} + +func waitForCertificateState(t *testing.T, id uint, matches func(*model.TLSCertificate) bool) *model.TLSCertificate { + t.Helper() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + cert, err := model.GetTLSCertificateByID(id) + if err != nil { + t.Fatalf("reload certificate %d failed: %v", id, err) + } + if matches(cert) { + return cert + } + time.Sleep(10 * time.Millisecond) + } + + cert, err := model.GetTLSCertificateByID(id) + if err != nil { + t.Fatalf("reload certificate %d failed: %v", id, err) + } + t.Fatalf("certificate %d did not reach expected state: %+v", id, cert) + return nil +} diff --git a/openflare_server/service/tls_certificate.go b/openflare_server/service/tls_certificate.go index cd870a0f..097b9191 100644 --- a/openflare_server/service/tls_certificate.go +++ b/openflare_server/service/tls_certificate.go @@ -40,6 +40,16 @@ type TLSApplyInput struct { DNS2 string `json:"dns2"` } +var obtainTLSCertificate = ObtainSSL + +func SetTLSCertificateObtainFuncForTest(fn func(*model.TLSCertificate) error) func() { + previous := obtainTLSCertificate + obtainTLSCertificate = fn + return func() { + obtainTLSCertificate = previous + } +} + func ListTLSCertificates() ([]*model.TLSCertificate, error) { return model.ListTLSCertificates() } @@ -191,7 +201,7 @@ func ApplyTLSCertificate(input TLSApplyInput) (*model.TLSCertificate, error) { // Async obtain SSL go func(c *model.TLSCertificate) { - _ = ObtainSSL(c) + _ = obtainTLSCertificate(c) }(cert) return cert, nil @@ -233,7 +243,64 @@ func UpdateAcmeCertificate(id uint, input TLSApplyInput) (*model.TLSCertificate, // Async obtain SSL with updated config go func(c *model.TLSCertificate) { - _ = ObtainSSL(c) + _ = obtainTLSCertificate(c) + }(cert) + + return cert, nil +} + +func ConvertTLSCertificateToAcme(id uint, input TLSApplyInput) (*model.TLSCertificate, error) { + cert, err := model.GetTLSCertificateByID(id) + if err != nil { + return nil, err + } + if cert.Provider != "upload" { + return nil, errors.New("only uploaded certificates can be converted to acme") + } + if cert.ApplyStatus == "applying" { + return nil, errors.New("certificate is already applying") + } + + name := strings.TrimSpace(input.Name) + if name == "" { + return nil, errors.New("certificate name cannot be empty") + } + + cert.Name = name + cert.Remark = strings.TrimSpace(input.Remark) + cert.AcmeAccountID = input.AcmeAccountID + cert.DnsAccountID = input.DnsAccountID + cert.KeyAlgorithm = input.KeyAlgorithm + cert.AutoRenew = input.AutoRenew + cert.PrimaryDomain = strings.TrimSpace(input.PrimaryDomain) + cert.OtherDomains = strings.TrimSpace(input.OtherDomains) + cert.DisableCNAME = input.DisableCNAME + cert.SkipDNS = input.SkipDNS + cert.DNS1 = strings.TrimSpace(input.DNS1) + cert.DNS2 = strings.TrimSpace(input.DNS2) + cert.ApplyStatus = "applying" + cert.ApplyMessage = "" + + if err := cert.Update(); err != nil { + if isUniqueConstraintError(err) { + return nil, errors.New("certificate name already exists") + } + return nil, err + } + + go func(c *model.TLSCertificate) { + if err := obtainTLSCertificate(c); err != nil { + return + } + + latest, err := model.GetTLSCertificateByID(c.ID) + if err != nil { + return + } + latest.Provider = "acme" + latest.ApplyStatus = "ready" + latest.ApplyMessage = "" + _ = latest.Update() }(cert) return cert, nil @@ -250,7 +317,7 @@ func RenewTLSCertificate(id uint) (*model.TLSCertificate, error) { // Async obtain SSL go func(c *model.TLSCertificate) { - _ = ObtainSSL(c) + _ = obtainTLSCertificate(c) }(cert) cert.ApplyStatus = "applying" diff --git a/openflare_server/web/features/dns-accounts/components/dns-accounts-page.tsx b/openflare_server/web/features/dns-accounts/components/dns-accounts-page.tsx index f67c0538..e33e59b7 100644 --- a/openflare_server/web/features/dns-accounts/components/dns-accounts-page.tsx +++ b/openflare_server/web/features/dns-accounts/components/dns-accounts-page.tsx @@ -158,15 +158,15 @@ function DnsAccountCreateModal({ isOpen, onClose, onCreated }: { isOpen: boolean
{error && } - + - - + + {createMutation.isPending ? '提交中...' : '提交'} diff --git a/openflare_server/web/features/tls-certificates/api/tls-certificates.ts b/openflare_server/web/features/tls-certificates/api/tls-certificates.ts index 1bbf8803..894b3ae8 100644 --- a/openflare_server/web/features/tls-certificates/api/tls-certificates.ts +++ b/openflare_server/web/features/tls-certificates/api/tls-certificates.ts @@ -25,7 +25,9 @@ export function getTlsCertificate(id: number) { } export function getTlsCertificateContent(id: number) { - return apiRequest(`/tls-certificates/${id}/content`); + return apiRequest( + `/tls-certificates/${id}/content`, + ); } export function updateTlsCertificate( @@ -38,7 +40,9 @@ export function updateTlsCertificate( }); } -export function importTlsCertificateFiles(payload: TlsCertificateFileImportPayload) { +export function importTlsCertificateFiles( + payload: TlsCertificateFileImportPayload, +) { const formData = new FormData(); formData.append('name', payload.name); formData.append('remark', payload.remark); @@ -53,7 +57,7 @@ export function importTlsCertificateFiles(payload: TlsCertificateFileImportPaylo export function deleteTlsCertificate(id: number) { return apiRequest(`/tls-certificates/${id}/delete`, { - method: 'POST', + method: 'POST', }); } @@ -70,9 +74,25 @@ export function renewTlsCertificate(id: number) { }); } -export function updateAcmeCertificate(id: number, payload: TlsCertificateApplyPayload) { +export function updateAcmeCertificate( + id: number, + payload: TlsCertificateApplyPayload, +) { return apiRequest(`/tls-certificates/${id}/update-acme`, { method: 'POST', body: JSON.stringify(payload), }); } + +export function convertTlsCertificateToAcme( + id: number, + payload: TlsCertificateApplyPayload, +) { + return apiRequest( + `/tls-certificates/${id}/convert-acme`, + { + method: 'POST', + body: JSON.stringify(payload), + }, + ); +} diff --git a/openflare_server/web/features/tls-certificates/components/tls-certificates-page.tsx b/openflare_server/web/features/tls-certificates/components/tls-certificates-page.tsx index bc23e1ff..de06fd14 100644 --- a/openflare_server/web/features/tls-certificates/components/tls-certificates-page.tsx +++ b/openflare_server/web/features/tls-certificates/components/tls-certificates-page.tsx @@ -21,7 +21,10 @@ import { CertificateDetailModal } from '@/features/websites/components/certifica import { CertificateEditorModal } from '@/features/websites/components/certificate-editor-modal'; import { CertificateImportModal } from '@/features/websites/components/certificate-import-modal'; import { CertificateApplyModal } from '@/features/websites/components/certificate-apply-modal'; -import { getCertificateStatus, getErrorMessage } from '@/features/websites/utils'; +import { + getCertificateStatus, + getErrorMessage, +} from '@/features/websites/utils'; import { DangerButton, PrimaryButton, @@ -35,6 +38,7 @@ type FeedbackState = { }; const certificatesQueryKey = ['tls-certificates', 'list'] as const; +type CertificateApplyMode = 'edit-acme' | 'convert-upload'; export function TlsCertificatesPage() { const queryClient = useQueryClient(); @@ -46,7 +50,9 @@ export function TlsCertificatesPage() { >(null); const [isDetailOpen, setIsDetailOpen] = useState(false); const [isEditorOpen, setIsEditorOpen] = useState(false); - const [editAcmeCertificate, setEditAcmeCertificate] = useState(null); + const [applyCertificate, setApplyCertificate] = + useState(null); + const [applyMode, setApplyMode] = useState('edit-acme'); const certificatesQuery = useQuery({ queryKey: certificatesQueryKey, @@ -67,7 +73,10 @@ export function TlsCertificatesPage() { const renewCertificateMutation = useMutation({ mutationFn: renewTlsCertificate, onSuccess: async (cert) => { - setFeedback({ tone: 'success', message: `证书 ${cert.name} 续期任务已提交。` }); + setFeedback({ + tone: 'success', + message: `证书 ${cert.name} 续期任务已提交。`, + }); await queryClient.invalidateQueries({ queryKey: ['tls-certificates'] }); }, onError: (error) => { @@ -105,7 +114,8 @@ export function TlsCertificatesPage() { const handleOpenCertificateEditor = (certificate: TlsCertificateItem) => { if (certificate.provider === 'acme') { - setEditAcmeCertificate(certificate); + setApplyMode('edit-acme'); + setApplyCertificate(certificate); } else { setSelectedCertificateId(certificate.id); setIsEditorOpen(true); @@ -142,7 +152,10 @@ export function TlsCertificatesPage() { > DNS 账号 - setIsImportOpen(true)}> + setIsImportOpen(true)} + > 导入证书 setIsApplyOpen(true)}> @@ -196,9 +209,28 @@ export function TlsCertificatesPage() {

生效:{formatDateTime(certificate.not_before)}

到期:{formatDateTime(certificate.not_after)}

-

来源:{certificate.provider === 'acme' ? 'ACME 申请' : '手动上传'}

- {certificate.apply_status === 'applying' &&

状态:申请中...

} - {certificate.apply_status === 'error' &&

状态:申请失败 ({certificate.apply_message})

} +

+ 来源: + {certificate.provider === 'acme' + ? 'ACME 申请' + : '手动上传'} +

+ {certificate.provider === 'upload' && + certificate.apply_status === 'applying' ? ( +

状态:转换申请中...

+ ) : certificate.apply_status === 'applying' ? ( +

状态:申请中...

+ ) : null} + {certificate.provider === 'upload' && + certificate.apply_status === 'error' ? ( +

+ 状态:转换失败 ({certificate.apply_message}) +

+ ) : certificate.apply_status === 'error' ? ( +

+ 状态:申请失败 ({certificate.apply_message}) +

+ ) : null}

备注:{certificate.remark || '暂无备注'}

@@ -206,14 +238,18 @@ export function TlsCertificatesPage() {
handleOpenCertificateDetail(certificate)} + onClick={() => + handleOpenCertificateDetail(certificate) + } className="px-3 py-2 text-xs" > 查看 handleOpenCertificateEditor(certificate)} + onClick={() => + handleOpenCertificateEditor(certificate) + } className="px-3 py-2 text-xs" > 编辑 @@ -272,15 +308,19 @@ export function TlsCertificatesPage() { /> ) : null} - {editAcmeCertificate ? ( + {applyCertificate ? ( setEditAcmeCertificate(null)} - editCertificate={editAcmeCertificate} + onClose={() => setApplyCertificate(null)} + mode={applyMode} + certificate={applyCertificate} onApplied={(certificate) => { setFeedback({ tone: 'success', - message: `证书 ${certificate.name} 配置已更新,重新申请中...`, + message: + applyMode === 'convert-upload' + ? `证书 ${certificate.name} 转换申请已提交。` + : `证书 ${certificate.name} 配置已更新,重新申请中...`, }); }} /> @@ -324,6 +364,11 @@ export function TlsCertificatesPage() { message: `证书 ${certificate.name} 已更新。`, }); }} + onConvert={(certificate) => { + setIsEditorOpen(false); + setApplyMode('convert-upload'); + setApplyCertificate(certificate); + }} /> ) : null} diff --git a/openflare_server/web/features/websites/components/certificate-apply-modal.tsx b/openflare_server/web/features/websites/components/certificate-apply-modal.tsx index 2b58e075..2673470b 100644 --- a/openflare_server/web/features/websites/components/certificate-apply-modal.tsx +++ b/openflare_server/web/features/websites/components/certificate-apply-modal.tsx @@ -7,7 +7,11 @@ import { useForm } from 'react-hook-form'; import { InlineMessage } from '@/components/feedback/inline-message'; import { AppModal } from '@/components/ui/app-modal'; -import { applyTlsCertificate, updateAcmeCertificate } from '@/features/tls-certificates/api/tls-certificates'; +import { + applyTlsCertificate, + convertTlsCertificateToAcme, + updateAcmeCertificate, +} from '@/features/tls-certificates/api/tls-certificates'; import type { TlsCertificateItem } from '@/features/tls-certificates/types'; import { getDnsAccounts } from '@/features/dns-accounts/api/dns-accounts'; import { getDefaultAcmeAccount } from '@/features/acme-accounts/api/acme-accounts'; @@ -29,17 +33,22 @@ interface CertificateApplyModalProps { isOpen: boolean; onClose: () => void; onApplied?: (certificate: TlsCertificateItem) => void; - editCertificate?: TlsCertificateItem | null; + mode?: 'create' | 'edit-acme' | 'convert-upload'; + certificate?: TlsCertificateItem | null; } export function CertificateApplyModal({ isOpen, onClose, onApplied, - editCertificate, + mode = 'create', + certificate, }: CertificateApplyModalProps) { const queryClient = useQueryClient(); - const [feedback, setFeedback] = useState<{ tone: 'success' | 'danger'; message: string } | null>(null); + const [feedback, setFeedback] = useState<{ + tone: 'success' | 'danger'; + message: string; + } | null>(null); const [showAdvanced, setShowAdvanced] = useState(false); const dnsAccountsQuery = useQuery({ @@ -63,41 +72,59 @@ export function CertificateApplyModal({ if (!isOpen) return; setFeedback(null); setShowAdvanced(false); - - if (editCertificate) { + + if (certificate) { form.reset({ - name: editCertificate.name, - primary_domain: editCertificate.primary_domain || '', - other_domains: editCertificate.other_domains || '', - remark: editCertificate.remark || '', - acme_account_id: editCertificate.acme_account_id, - dns_account_id: editCertificate.dns_account_id, - key_algorithm: editCertificate.key_algorithm as any || 'EC256', - auto_renew: editCertificate.auto_renew, - dns1: editCertificate.dns1 || '', - dns2: editCertificate.dns2 || '', - disable_cname: editCertificate.disable_cname, - skip_dns: editCertificate.skip_dns, + name: certificate.name, + primary_domain: + mode === 'convert-upload' ? '' : certificate.primary_domain || '', + other_domains: + mode === 'convert-upload' ? '' : certificate.other_domains || '', + remark: certificate.remark || '', + acme_account_id: certificate.acme_account_id, + dns_account_id: + mode === 'convert-upload' ? 0 : certificate.dns_account_id, + key_algorithm: certificate.key_algorithm || 'EC256', + auto_renew: mode === 'convert-upload' ? true : certificate.auto_renew, + dns1: mode === 'convert-upload' ? '' : certificate.dns1 || '', + dns2: mode === 'convert-upload' ? '' : certificate.dns2 || '', + disable_cname: + mode === 'convert-upload' ? false : certificate.disable_cname, + skip_dns: mode === 'convert-upload' ? false : certificate.skip_dns, }); - if (editCertificate.dns1 || editCertificate.dns2 || editCertificate.disable_cname || editCertificate.skip_dns) { + if ( + mode !== 'convert-upload' && + (certificate.dns1 || + certificate.dns2 || + certificate.disable_cname || + certificate.skip_dns) + ) { setShowAdvanced(true); } } else { form.reset(defaultAcmeApplyValues); } - }, [isOpen, form, editCertificate]); + }, [isOpen, form, mode, certificate]); useEffect(() => { - if (defaultAcmeAccountQuery.data) { + if ( + defaultAcmeAccountQuery.data && + form.getValues('acme_account_id') === 0 + ) { form.setValue('acme_account_id', defaultAcmeAccountQuery.data.id); } - }, [defaultAcmeAccountQuery.data, form]); + }, [defaultAcmeAccountQuery.data, form, isOpen]); const applyMutation = useMutation({ - mutationFn: (values: AcmeApplyFormValues) => - editCertificate - ? updateAcmeCertificate(editCertificate.id, values) - : applyTlsCertificate(values), + mutationFn: (values: AcmeApplyFormValues) => { + if (mode === 'edit-acme' && certificate) { + return updateAcmeCertificate(certificate.id, values); + } + if (mode === 'convert-upload' && certificate) { + return convertTlsCertificateToAcme(certificate.id, values); + } + return applyTlsCertificate(values); + }, onSuccess: async (certificate) => { await queryClient.invalidateQueries({ queryKey: ['tls-certificates'] }); onApplied?.(certificate); @@ -117,8 +144,20 @@ export function CertificateApplyModal({ @@ -131,13 +170,19 @@ export function CertificateApplyModal({ label="证书名称" error={form.formState.errors.name?.message} > - + - +
@@ -147,7 +192,7 @@ export function CertificateApplyModal({ error={form.formState.errors.other_domains?.message} >