Files
opsx-vpn/backend/internal/api/tls.go
T

272 lines
10 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"vpn-platform/backend/internal/nginxconfig"
"vpn-platform/backend/internal/nginxgateway"
"vpn-platform/backend/internal/nginxsetup"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/store"
"vpn-platform/backend/internal/tlsservice"
)
func (s *Server) nginxSetupStatus(c *gin.Context) {
status := nginxsetup.Inspect()
c.JSON(http.StatusOK, gin.H{"os": status.OS, "version": status.Version, "package_manager": status.PackageManager,
"supported": status.Supported, "root": status.Root, "nginx_installed": status.Nginx,
"certbot_installed": status.Certbot, "webroot_ready": status.Webroot, "running": status.Running || nginxgateway.Running(),
"site_stage": nginxgateway.Stage()})
}
func (s *Server) activateNginx(c *gin.Context) {
status := tlsservice.Status()
if status.Mode != "nginx" || status.ConfiguredMode != "nginx" || status.ConfiguredDomain == "" {
c.JSON(http.StatusPreconditionFailed, gin.H{"error": "请先完成旧入口迁移并保存 Nginx 公网入口"})
return
}
scheme := status.ConfiguredScheme
if scheme == "" {
scheme = "https"
}
authPort, err := authGatewayPort(status.ConfiguredPublicBase)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if !nginxgateway.Begin() {
c.JSON(http.StatusConflict, gin.H{"error": "入口配置正在运行,请等待完成"})
return
}
defer nginxgateway.End()
c.Header("Content-Type", "application/x-ndjson; charset=utf-8")
c.Header("Cache-Control", "no-store")
c.Header("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
write := func(kind, message string) {
payload, _ := json.Marshal(gin.H{"type": kind, "message": message})
fmt.Fprintf(c.Writer, "%s\n", payload)
c.Writer.Flush()
}
if scheme == "http" {
write("info", "启用 HTTP 公网入口(不申请证书)。\n")
} else {
write("info", "检查现有站点并启用 HTTP-01 验证。\n")
}
opts := nginxconfig.RenderOptions{Domain: status.ConfiguredDomain, Scheme: scheme, Port: status.ConfiguredPort}
if err := nginxgateway.RunEntryWithAuth(opts, s.router.Routes(), authPort, func(message string) { write("output", message) }); err != nil {
store.Audit(c.GetString("username"), "nginx_activate", "failed", err.Error())
write("error", err.Error())
return
}
if err := tlsservice.AdoptNginxConfiguration(service.BaseDir()); err != nil {
write("error", "站点已更新,但状态同步失败:"+err.Error())
return
}
store.Audit(c.GetString("username"), "nginx_activate", "completed", status.ConfiguredPublicBase)
write("done", "站点和入口已就绪。")
}
func (s *Server) initializeNginx(c *gin.Context) {
status := nginxsetup.Inspect()
if !status.Supported || !status.Root {
c.JSON(http.StatusPreconditionFailed, gin.H{"error": "仅支持 root 运行的 Ubuntu / TencentOS Server 4"})
return
}
if !nginxsetup.Begin() {
c.JSON(http.StatusConflict, gin.H{"error": "初始化正在运行,请等待完成"})
return
}
defer nginxsetup.End()
c.Header("Content-Type", "application/x-ndjson; charset=utf-8")
c.Header("Cache-Control", "no-store")
c.Header("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
write := func(kind, message string) {
payload, _ := json.Marshal(gin.H{"type": kind, "message": message})
fmt.Fprintf(c.Writer, "%s\n", payload)
c.Writer.Flush()
}
write("info", "开始初始化 Nginx / Certbot;此步骤不会启用 80/443 或签发证书。\n")
err := nginxsetup.Run(func(message string) { write("output", message) })
if err != nil {
store.Audit(c.GetString("username"), "nginx_setup", "failed", err.Error())
write("error", err.Error())
return
}
store.Audit(c.GetString("username"), "nginx_setup", "completed", "software and webroot ready")
write("done", "初始化完成;继续配置 HTTP-01 站点与证书。")
}
func (s *Server) tlsStatus(c *gin.Context) {
c.JSON(http.StatusOK, tlsservice.Status())
}
func (s *Server) nginxPreview(c *gin.Context) {
status := tlsservice.Status()
if status.ConfiguredDomain == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请先在页面保存公网入口"})
return
}
scheme := status.ConfiguredScheme
if scheme == "" {
scheme = "https"
}
httpSite, httpsSite, err := nginxconfig.RenderWithOptions(nginxconfig.RenderOptions{
Domain: status.ConfiguredDomain, Scheme: scheme, Port: status.ConfiguredPort,
}, s.router.Routes(), 0)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"http_site": httpSite, "https_site": httpsSite,
"public_base": status.ConfiguredPublicBase,
"warning": "仅预览:首次启用站点需手动执行,已启用的 Nginx 入口保存后热重载。",
})
}
func (s *Server) setTLSDomain(c *gin.Context) {
var req struct {
Domain string `json:"domain"`
Scheme string `json:"scheme"`
Port *int `json:"port"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请输入公网入口配置"})
return
}
scheme := strings.ToLower(strings.TrimSpace(req.Scheme))
if scheme == "" {
scheme = "https"
}
port := 0
if req.Port != nil {
port = *req.Port
}
status := tlsservice.Status()
if status.Mode != "nginx" || status.ConfiguredMode != "nginx" {
c.JSON(http.StatusConflict, gin.H{"error": "当前使用旧 Go 直监听入口,请按部署文档迁移至 Nginx 后再修改公网入口"})
return
}
newBase := tlsservice.PublicBaseURL(scheme, strings.ToLower(strings.TrimSpace(req.Domain)), port)
if newBase == "" || port < 0 || port > 65535 {
c.JSON(http.StatusBadRequest, gin.H{"error": "公网地址、协议或端口无效;IPv4 地址仅支持 HTTP"})
return
}
// The Go listener cannot be rebound in place; Nginx owns its own sockets.
if status.Mode == "nginx" && status.ConfiguredMode == "nginx" && nginxgateway.Stage() != "none" {
s.gatewayMu.Lock()
defer s.gatewayMu.Unlock()
actual, err := nginxgateway.ManagedEntry()
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if err := checkAuthGatewayChange(status.PublicBase, newBase); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
// A plain HTTP site can stage HTTPS on the same host before a certificate
// exists. The explicit activation action sets up HTTP-01, issues the
// certificate and only then replaces the live site.
if actual.Scheme == "http" && scheme == "https" && actual.Domain == strings.ToLower(strings.TrimSpace(req.Domain)) && !nginxgateway.CertificateReady(actual.Domain) {
if !tlsservice.ValidDomain(actual.Domain) {
if err := nginxgateway.CheckIPCertificateSupport(actual.Domain); err != nil {
c.JSON(http.StatusPreconditionFailed, gin.H{"error": err.Error()})
return
}
}
if err := tlsservice.ConfigurePublicEntry(service.BaseDir(), scheme, req.Domain, port); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "tls_domain_staged", newBase, "待签发证书并启用 HTTPS")
c.JSON(http.StatusOK, gin.H{"status": tlsservice.Status(), "site_activation_required": true})
return
}
authPort, err := authGatewayPort(newBase)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
opts := nginxconfig.RenderOptions{Domain: req.Domain, Scheme: scheme, Port: port}
err = nginxgateway.UpdateEntry(opts, actual.Domain, s.router.Routes(), authPort, func() error {
if err := tlsservice.ConfigurePublicEntry(service.BaseDir(), scheme, req.Domain, port); err != nil {
return err
}
return tlsservice.AdoptNginxConfiguration(service.BaseDir())
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "tls_domain_applied", newBase, "Nginx 已重载")
c.JSON(http.StatusOK, gin.H{"status": tlsservice.Status(), "restart_required": false, "applied": true})
return
}
if err := tlsservice.ConfigurePublicEntry(service.BaseDir(), scheme, req.Domain, port); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "tls_domain_configured", tlsservice.PublicBaseURL(scheme, strings.ToLower(strings.TrimSpace(req.Domain)), port), "需重启后生效")
status = tlsservice.Status()
c.JSON(http.StatusOK, gin.H{"status": status, "restart_required": status.RestartRequired,
"site_activation_required": status.Mode == "nginx" && status.ConfiguredMode == "nginx" && nginxgateway.Stage() == "none"})
}
func (s *Server) uploadTLSCertificate(c *gin.Context) {
var req struct {
CertificatePEM string `json:"certificate_pem"`
PrivateKeyPEM string `json:"private_key_pem"`
Host string `json:"host"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提交证书与私钥 PEM"})
return
}
status := tlsservice.Status()
domain := strings.ToLower(strings.TrimSpace(req.Host))
if domain == "" {
domain = status.ConfiguredDomain
}
if domain == "" {
domain = status.Domain
}
if domain == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请先填写公网域名"})
return
}
if domain != status.ConfiguredDomain && domain != status.Domain {
c.JSON(http.StatusBadRequest, gin.H{"error": "证书域名必须与公网入口一致"})
return
}
apply := func() error { return nil }
if status.Mode == "nginx" && status.ConfiguredMode == "nginx" && !status.RestartRequired &&
(status.ConfiguredScheme == "https" || status.Scheme == "https" || status.ConfiguredScheme == "") &&
nginxgateway.Stage() == "https" && s.router != nil {
apply = func() error {
s.gatewayMu.Lock()
defer s.gatewayMu.Unlock()
port := 0
if pb := tlsservice.Status().PublicBase; pb != "" {
port, _ = authGatewayPort(pb)
}
return nginxgateway.RefreshAuthEntry(nginxconfig.RenderOptions{
Domain: domain, Scheme: "https", Port: status.ConfiguredPort,
}, s.router.Routes(), port)
}
}
if err := tlsservice.InstallUploadedCertificateWithApply(service.BaseDir(), domain, req.CertificatePEM, req.PrivateKeyPEM, apply); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "tls_certificate_uploaded", domain, "upload")
c.JSON(http.StatusOK, gin.H{"ok": true, "status": tlsservice.Status()})
}