diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 1a94102..9872dfb 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -335,6 +335,7 @@ func (s *Server) RegisterRoutes(r *gin.Engine) { admin.GET("/system/tls/nginx-setup", s.nginxSetupStatus) admin.POST("/system/tls/nginx-setup", s.initializeNginx) admin.POST("/system/tls/nginx-activate", s.activateNginx) + admin.POST("/system/tls/certificate", s.uploadTLSCertificate) admin.GET("/users", s.listUsers) admin.POST("/users", s.createUser) admin.PUT("/users/:id", s.updateUser) diff --git a/backend/internal/api/identity_gateway.go b/backend/internal/api/identity_gateway.go index ac60a8d..5f01102 100644 --- a/backend/internal/api/identity_gateway.go +++ b/backend/internal/api/identity_gateway.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "vpn-platform/backend/internal/model" + "vpn-platform/backend/internal/nginxconfig" "vpn-platform/backend/internal/nginxgateway" "vpn-platform/backend/internal/service" "vpn-platform/backend/internal/store" @@ -14,7 +15,8 @@ import ( // Only a single OpenVPN WebAuth instance may own /oauth2/ on the management // hostname. Never route an arbitrary user-supplied address to a local port. -func authGatewayPort(domain string) (int, error) { +func authGatewayPort(publicBase string) (int, error) { + publicBase = strings.TrimRight(strings.TrimSpace(publicBase), "/") var tunnels []model.Tunnel if err := store.DB.Where("protocol = ? AND state = ?", "openvpn", "running").Find(&tunnels).Error; err != nil { return 0, err @@ -25,7 +27,7 @@ func authGatewayPort(domain string) (int, error) { if err != nil { return 0, err } - if service.Str(cfg, "auth_mode") != "oidc" || !strings.EqualFold(strings.TrimRight(service.Str(cfg, "oidc_base_url"), "/"), "https://"+domain) { + if service.Str(cfg, "auth_mode") != "oidc" || !strings.EqualFold(strings.TrimRight(service.Str(cfg, "oidc_base_url"), "/"), publicBase) { continue } if port != 0 { @@ -49,14 +51,27 @@ func (s *Server) RefreshManagedGatewayOnStart() error { s.gatewayMu.Lock() defer s.gatewayMu.Unlock() status := tlsservice.Status() - if !status.Valid || status.Mode != "nginx" || status.RestartRequired || nginxgateway.Stage() != "https" { + if status.Mode != "nginx" || status.RestartRequired || status.Domain == "" || status.PublicBase == "" { return nil } - port, err := authGatewayPort(status.Domain) + scheme := status.Scheme + if scheme == "" { + scheme = "https" + } + stage := nginxgateway.Stage() + if scheme == "https" && (!status.Valid || stage != "https") { + return nil + } + if scheme == "http" && stage == "none" { + return nil + } + port, err := authGatewayPort(status.PublicBase) if err != nil { return err } - return nginxgateway.RefreshAuth(status.Domain, s.router.Routes(), port) + return nginxgateway.RefreshAuthEntry(nginxconfig.RenderOptions{ + Domain: status.Domain, Scheme: scheme, Port: status.Port, + }, s.router.Routes(), port) } func (s *Server) syncAuthGatewayForTunnel(t *model.Tunnel) error { @@ -70,19 +85,34 @@ func (s *Server) identityGateway(c *gin.Context) { s.gatewayMu.Lock() defer s.gatewayMu.Unlock() status := tlsservice.Status() - if !status.Valid || status.Mode != "nginx" || status.RestartRequired || nginxgateway.Stage() != "https" { + scheme := status.Scheme + if scheme == "" { + scheme = "https" + } + if status.Mode != "nginx" || status.RestartRequired || status.Domain == "" || status.PublicBase == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "请先完成公网入口配置"}) + return + } + stage := nginxgateway.Stage() + if scheme == "https" && (!status.Valid || stage != "https") { c.JSON(http.StatusBadRequest, gin.H{"error": "请先完成 Nginx HTTPS 入口配置"}) return } - port, err := authGatewayPort(status.Domain) + if scheme == "http" && stage == "none" { + c.JSON(http.StatusBadRequest, gin.H{"error": "请先启用 HTTP 公网入口"}) + return + } + port, err := authGatewayPort(status.PublicBase) if err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } - if err := nginxgateway.RefreshAuth(status.Domain, s.router.Routes(), port); err != nil { + if err := nginxgateway.RefreshAuthEntry(nginxconfig.RenderOptions{ + Domain: status.Domain, Scheme: scheme, Port: status.Port, + }, s.router.Routes(), port); err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } - store.Audit(c.GetString("username"), "identity_gateway_updated", status.Domain, "") - c.JSON(200, gin.H{"ok": true, "identity_route": true, "auth_route": port != 0, "auth_port": port}) + store.Audit(c.GetString("username"), "identity_gateway_updated", status.PublicBase, "") + c.JSON(200, gin.H{"ok": true, "identity_route": true, "auth_route": port != 0, "auth_port": port, "public_base": status.PublicBase}) } diff --git a/backend/internal/api/identity_gateway_test.go b/backend/internal/api/identity_gateway_test.go index 5a6f38f..f42f49c 100644 --- a/backend/internal/api/identity_gateway_test.go +++ b/backend/internal/api/identity_gateway_test.go @@ -19,7 +19,7 @@ func TestAuthGatewayPortFollowsRunningOIDCTunnel(t *testing.T) { } check := func(want int) { t.Helper() - got, err := authGatewayPort("vpn.example.com") + got, err := authGatewayPort("https://vpn.example.com") if err != nil || got != want { t.Fatalf("gateway port = %d, %v; want %d", got, err, want) } @@ -29,7 +29,7 @@ func TestAuthGatewayPortFollowsRunningOIDCTunnel(t *testing.T) { t.Fatal(err) } check(19090) - if port, err := authGatewayPort("other.example.com"); err != nil || port != 0 { + if port, err := authGatewayPort("https://other.example.com"); err != nil || port != 0 { t.Fatalf("wrong domain routed: %d, %v", port, err) } if err := store.DB.Model(&line).Update("state", "stopped").Error; err != nil { diff --git a/backend/internal/api/openvpn_oidc_readiness.go b/backend/internal/api/openvpn_oidc_readiness.go index 9e41dcd..c632416 100644 --- a/backend/internal/api/openvpn_oidc_readiness.go +++ b/backend/internal/api/openvpn_oidc_readiness.go @@ -39,12 +39,25 @@ func (s *Server) openvpnOIDCReadiness(c *gin.Context) { } tls := tlsservice.Status() builtInIssuer, builtInClientID, builtInEnabled := identityprovider.PublicClient() - port, _ := authGatewayPort(tls.Domain) + port, _ := authGatewayPort(tls.PublicBase) issuerRoute, callbackRoute := nginxgateway.AuthRouteReady(port) + scheme := tls.Scheme + if scheme == "" { + scheme = "https" + } + stage := nginxgateway.Stage() + managementReady := tls.Mode == "nginx" && tls.PublicBase != "" && tls.Domain != "" + if scheme == "https" { + managementReady = managementReady && tls.Valid && stage == "https" + } else { + managementReady = managementReady && stage != "none" + } c.JSON(http.StatusOK, gin.H{ "component_installed": path != "", "component_version": version, - "https_management_ready": tls.Mode == "nginx" && tls.Valid && nginxgateway.Stage() == "https", + "https_management_ready": managementReady, // kept key for UI compat; true for ready http/https entry + "management_ready": managementReady, "management_domain": tls.Domain, + "management_public_base": tls.PublicBase, "authentication_entry_ready": port > 0 && callbackRoute, "built_in_issuer": builtInIssuer, "built_in_client_id": builtInClientID, "built_in_enabled": builtInEnabled, "built_in_gateway_ready": issuerRoute, diff --git a/backend/internal/api/tls.go b/backend/internal/api/tls.go index 7c3906d..d553516 100644 --- a/backend/internal/api/tls.go +++ b/backend/internal/api/tls.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "github.com/gin-gonic/gin" @@ -26,9 +27,13 @@ func (s *Server) nginxSetupStatus(c *gin.Context) { func (s *Server) activateNginx(c *gin.Context) { status := tlsservice.Status() if status.Mode != "nginx" || status.ConfiguredMode != "nginx" || status.RestartRequired || status.ConfiguredDomain == "" { - c.JSON(http.StatusPreconditionFailed, gin.H{"error": "请先保存 Nginx 模式和域名,重启 opsx-vpn 后再启用站点"}) + c.JSON(http.StatusPreconditionFailed, gin.H{"error": "请先保存 Nginx 模式和公网入口,重启 opsx-vpn 后再启用站点"}) return } + scheme := status.ConfiguredScheme + if scheme == "" { + scheme = "https" + } if !nginxgateway.Begin() { c.JSON(http.StatusConflict, gin.H{"error": "入口配置正在运行,请等待完成"}) return @@ -43,14 +48,19 @@ func (s *Server) activateNginx(c *gin.Context) { fmt.Fprintf(c.Writer, "%s\n", payload) c.Writer.Flush() } - write("info", "检查现有站点并启用 HTTP-01 验证。\n") - if err := nginxgateway.Run(status.ConfiguredDomain, s.router.Routes(), func(message string) { write("output", message) }); err != nil { + 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.RunEntry(opts, s.router.Routes(), func(message string) { write("output", message) }); err != nil { store.Audit(c.GetString("username"), "nginx_activate", "failed", err.Error()) write("error", err.Error()) return } - store.Audit(c.GetString("username"), "nginx_activate", "completed", status.ConfiguredDomain) - write("done", "站点和证书已就绪。") + store.Audit(c.GetString("username"), "nginx_activate", "completed", status.ConfiguredPublicBase) + write("done", "站点和入口已就绪。") } func (s *Server) initializeNginx(c *gin.Context) { @@ -91,17 +101,24 @@ func (s *Server) tlsStatus(c *gin.Context) { func (s *Server) nginxPreview(c *gin.Context) { status := tlsservice.Status() if status.ConfiguredDomain == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "请先在页面保存域名"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "请先在页面保存公网入口"}) return } - httpSite, httpsSite, err := nginxconfig.Render(status.ConfiguredDomain, s.router.Routes()) + 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, - "warning": "仅预览:先安装 Nginx/Certbot,停用 Go 的 80/443 监听并签出证书;认证域名须单独配置。", + "public_base": status.ConfiguredPublicBase, + "warning": "仅预览:保存并重启后按协议启用入口;认证域名须单独配置。", }) } @@ -125,16 +142,76 @@ func (s *Server) setTLSMode(c *gin.Context) { 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": "请输入域名"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "请输入公网入口配置"}) return } - if err := tlsservice.ConfigureDomain(service.BaseDir(), req.Domain); err != nil { + scheme := strings.ToLower(strings.TrimSpace(req.Scheme)) + if scheme == "" { + scheme = "https" + } + port := 0 + if req.Port != nil { + port = *req.Port + } + 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", req.Domain, "需重启后生效") + 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}) } + +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()}) +} diff --git a/backend/internal/api/tls_certificate_test.go b/backend/internal/api/tls_certificate_test.go new file mode 100644 index 0000000..012887b --- /dev/null +++ b/backend/internal/api/tls_certificate_test.go @@ -0,0 +1,119 @@ +package api + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "vpn-platform/backend/internal/service" + "vpn-platform/backend/internal/tlsservice" +) + +func pemPairForAPI(t *testing.T, host string) (certPEM, keyPEM string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + serial, err := rand.Int(rand.Reader, big.NewInt(1<<62)) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: host}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + DNSNames: []string{host}, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), + string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) +} + +func TestUploadTLSCertificateAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + dir := t.TempDir() + t.Setenv("VPN_DATA_DIR", dir) + t.Setenv("VPN_TLS_DOMAIN", "") + t.Setenv("VPN_TLS_SCHEME", "") + t.Setenv("VPN_TLS_PORT", "") + t.Setenv("VPN_TLS_MODE", "") + t.Setenv("VPN_TLS_HTTP_ADDR", "127.0.0.1:0") + t.Setenv("VPN_TLS_HTTPS_ADDR", "127.0.0.1:0") + if err := tlsservice.ConfigureMode(dir, "nginx"); err != nil { + t.Fatal(err) + } + if err := tlsservice.ConfigurePublicEntry(dir, "https", "vpn.example.com", 0); err != nil { + t.Fatal(err) + } + if err := tlsservice.Start(http.NotFoundHandler(), dir); err != nil { + t.Fatal(err) + } + _ = service.BaseDir() // ensure env seen + + router := gin.New() + s := &Server{} + router.POST("/api/system/tls/certificate", func(c *gin.Context) { + c.Set("role", "admin") + c.Set("username", "admin") + c.Next() + }, s.uploadTLSCertificate) + + certPEM, keyPEM := pemPairForAPI(t, "vpn.example.com") + body, _ := json.Marshal(map[string]string{ + "certificate_pem": certPEM, + "private_key_pem": keyPEM, + "host": "vpn.example.com", + }) + req := httptest.NewRequest(http.MethodPost, "/api/system/tls/certificate", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("upload HTTP %d: %s", resp.Code, resp.Body.String()) + } + var out map[string]any + if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + status, _ := out["status"].(map[string]any) + if status["cert_source"] != "upload" || status["certificate_valid"] != true { + t.Fatalf("status=%v", status) + } + + // mismatch + _, otherKey := pemPairForAPI(t, "vpn.example.com") + bad, _ := json.Marshal(map[string]string{ + "certificate_pem": certPEM, + "private_key_pem": otherKey, + "host": "vpn.example.com", + }) + req = httptest.NewRequest(http.MethodPost, "/api/system/tls/certificate", bytes.NewReader(bad)) + req.Header.Set("Content-Type", "application/json") + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + if resp.Code != http.StatusBadRequest { + t.Fatalf("mismatch want 400 got %d", resp.Code) + } +} diff --git a/backend/internal/identityprovider/admin.go b/backend/internal/identityprovider/admin.go index fa3f4c1..6d38cb4 100644 --- a/backend/internal/identityprovider/admin.go +++ b/backend/internal/identityprovider/admin.go @@ -15,6 +15,7 @@ import ( "gorm.io/gorm" "vpn-platform/backend/internal/nginxgateway" "vpn-platform/backend/internal/store" + "vpn-platform/backend/internal/tlsservice" ) const feishuTenantToken = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" @@ -33,7 +34,14 @@ func secureAdmin(c *gin.Context) bool { return true } peer, _, err := net.SplitHostPort(c.Request.RemoteAddr) - return err == nil && net.ParseIP(peer) != nil && net.ParseIP(peer).IsLoopback() && c.GetHeader("X-Forwarded-Proto") == "https" + if err != nil || net.ParseIP(peer) == nil || !net.ParseIP(peer).IsLoopback() { + return false + } + scheme := tlsservice.Status().Scheme + if scheme == "" { + scheme = "https" + } + return c.GetHeader("X-Forwarded-Proto") == scheme } type tenantCredentials struct { @@ -116,7 +124,7 @@ func queryFeishuTenant(ctx context.Context, creds tenantCredentials) (string, st func (p *Provider) QueryTenant(c *gin.Context) { noStore(c) if !secureAdmin(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "请通过可信 HTTPS 管理入口查询飞书企业信息"}) + c.JSON(http.StatusForbidden, gin.H{"error": "请通过可信公网管理入口查询飞书企业信息"}) return } var input tenantCredentials @@ -168,7 +176,7 @@ func (p *Provider) Status(c *gin.Context) { func (p *Provider) Configure(c *gin.Context) { if !secureAdmin(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "请通过可信 HTTPS 管理入口保存飞书凭据"}) + c.JSON(http.StatusForbidden, gin.H{"error": "请通过可信公网管理入口保存飞书凭据"}) return } var req struct { diff --git a/backend/internal/identityprovider/handlers.go b/backend/internal/identityprovider/handlers.go index 46ca5cc..fc0d1b9 100644 --- a/backend/internal/identityprovider/handlers.go +++ b/backend/internal/identityprovider/handlers.go @@ -86,8 +86,8 @@ func (p *Provider) Authorize(c *gin.Context) { return } redirect := c.Query("redirect_uri") - domain := strings.TrimSuffix(strings.TrimPrefix(base, "https://"), "/id") - if !validateRedirect(redirect, domain) || c.Query("client_id") != cfg.ClientID || c.Query("response_type") != "code" || !strings.Contains(" "+c.Query("scope")+" ", " openid ") { + publicBase := strings.TrimSuffix(base, "/id") + if !validateRedirect(redirect, publicBase) || c.Query("client_id") != cfg.ClientID || c.Query("response_type") != "code" || !strings.Contains(" "+c.Query("scope")+" ", " openid ") { oidcError(c, 400) return } diff --git a/backend/internal/identityprovider/issuer_test.go b/backend/internal/identityprovider/issuer_test.go new file mode 100644 index 0000000..82f9ff5 --- /dev/null +++ b/backend/internal/identityprovider/issuer_test.go @@ -0,0 +1,57 @@ +package identityprovider + +import ( + "net/http" + "testing" + + "vpn-platform/backend/internal/tlsservice" +) + +func TestIssuerHTTPPublicBase(t *testing.T) { + t.Setenv("VPN_TLS_DOMAIN", "") + t.Setenv("VPN_TLS_SCHEME", "") + t.Setenv("VPN_TLS_PORT", "") + t.Setenv("VPN_TLS_MODE", "") + dir := t.TempDir() + if err := tlsservice.ConfigureMode(dir, "nginx"); err != nil { + t.Fatal(err) + } + if err := tlsservice.ConfigurePublicEntry(dir, "http", "vpn.example.com", 8080); err != nil { + t.Fatal(err) + } + t.Setenv("VPN_TLS_HTTP_ADDR", "127.0.0.1:0") + t.Setenv("VPN_TLS_HTTPS_ADDR", "127.0.0.1:0") + if err := tlsservice.Start(http.NotFoundHandler(), dir); err != nil { + t.Fatal(err) + } + base, err := issuer() + if err != nil { + t.Fatal(err) + } + if base != "http://vpn.example.com:8080/id" { + t.Fatalf("issuer = %q", base) + } +} + +func TestIssuerHTTPSOmitsDefaultPort(t *testing.T) { + // PublicBaseURL unit coverage is primary; here ensure issuer refuses https without Valid cert. + t.Setenv("VPN_TLS_DOMAIN", "") + t.Setenv("VPN_TLS_SCHEME", "") + t.Setenv("VPN_TLS_PORT", "") + t.Setenv("VPN_TLS_MODE", "") + dir := t.TempDir() + if err := tlsservice.ConfigureMode(dir, "nginx"); err != nil { + t.Fatal(err) + } + if err := tlsservice.ConfigurePublicEntry(dir, "https", "vpn.example.com", 0); err != nil { + t.Fatal(err) + } + t.Setenv("VPN_TLS_HTTP_ADDR", "127.0.0.1:0") + t.Setenv("VPN_TLS_HTTPS_ADDR", "127.0.0.1:0") + if err := tlsservice.Start(http.NotFoundHandler(), dir); err != nil { + t.Fatal(err) + } + if _, err := issuer(); err == nil { + t.Fatal("https issuer must require Valid certificate") + } +} diff --git a/backend/internal/identityprovider/provider.go b/backend/internal/identityprovider/provider.go index a6d8dd3..44055a0 100644 --- a/backend/internal/identityprovider/provider.go +++ b/backend/internal/identityprovider/provider.go @@ -96,8 +96,15 @@ func SecureGateway() gin.HandlerFunc { return } peer, _, err := net.SplitHostPort(c.Request.RemoteAddr) - if err == nil && net.ParseIP(peer) != nil && net.ParseIP(peer).IsLoopback() && c.GetHeader("X-Forwarded-Proto") == "https" { - return + if err == nil && net.ParseIP(peer) != nil && net.ParseIP(peer).IsLoopback() { + proto := c.GetHeader("X-Forwarded-Proto") + scheme := tlsservice.Status().Scheme + if scheme == "" { + scheme = "https" + } + if proto == scheme { + return + } } c.AbortWithStatus(http.StatusForbidden) } @@ -116,10 +123,17 @@ func init() { func issuer() (string, error) { status := tlsservice.Status() - if !status.Valid || status.RestartRequired || status.Domain == "" { + if status.RestartRequired || status.Domain == "" || status.PublicBase == "" { + return "", errors.New("需要先配置并启用公网入口") + } + scheme := status.Scheme + if scheme == "" { + scheme = "https" + } + if scheme == "https" && !status.Valid { return "", errors.New("需要先启用并验证公网 HTTPS 域名") } - return "https://" + status.Domain + "/id", nil + return status.PublicBase + "/id", nil } func current() (model.IdentityProvider, error) { @@ -250,8 +264,9 @@ func signingKey() (*rsa.PrivateKey, string, error) { return key, base64.RawURLEncoding.EncodeToString(sum[:8]), nil } -func validateRedirect(raw, domain string) bool { - return raw == "https://"+domain+"/oauth2/callback" +func validateRedirect(raw, publicBase string) bool { + publicBase = strings.TrimRight(strings.TrimSpace(publicBase), "/") + return publicBase != "" && raw == publicBase+"/oauth2/callback" } func validTenant(value string) bool { diff --git a/backend/internal/identityprovider/provider_test.go b/backend/internal/identityprovider/provider_test.go index 6d4adb4..6dd8997 100644 --- a/backend/internal/identityprovider/provider_test.go +++ b/backend/internal/identityprovider/provider_test.go @@ -158,11 +158,14 @@ func TestIDTokenOmitsMissingDisplayName(t *testing.T) { } func TestRedirectIsExactAndNoOpenRedirect(t *testing.T) { - if !validateRedirect("https://vpn.example.com/oauth2/callback", "vpn.example.com") { + if !validateRedirect("https://vpn.example.com/oauth2/callback", "https://vpn.example.com") { t.Fatal("valid callback rejected") } + if !validateRedirect("http://vpn.example.com:8080/oauth2/callback", "http://vpn.example.com:8080") { + t.Fatal("http custom-port callback rejected") + } for _, bad := range []string{"http://vpn.example.com/oauth2/callback", "https://vpn.example.com.evil/oauth2/callback", "https://vpn.example.com/oauth2/callback/extra", "https://vpn.example.com/oauth2/callback?next=evil"} { - if validateRedirect(bad, "vpn.example.com") { + if validateRedirect(bad, "https://vpn.example.com") { t.Errorf("accepted callback %q", bad) } } diff --git a/backend/internal/nginxconfig/nginxconfig.go b/backend/internal/nginxconfig/nginxconfig.go index cfaf756..bf2fb8c 100644 --- a/backend/internal/nginxconfig/nginxconfig.go +++ b/backend/internal/nginxconfig/nginxconfig.go @@ -19,7 +19,19 @@ var spaPages = []string{ "/users", "/vpn-users", "/settings", "/identity", "/plugins", "/nodes", "/favicon.svg", } -const upstream = " proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Proto https;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_pass http://127.0.0.1:18080;\n" +type RenderOptions struct { + Domain string + Scheme string + Port int +} + +func upstreamBlock(scheme string) string { + scheme = strings.ToLower(strings.TrimSpace(scheme)) + if scheme == "" { + scheme = "https" + } + return fmt.Sprintf(" proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Proto %s;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_pass http://127.0.0.1:18080;\n", scheme) +} func validRoute(path string) bool { return strings.HasPrefix(path, "/api/") && !strings.ContainsAny(path, "\n\r{};\\") @@ -40,19 +52,49 @@ func routePattern(path string) string { // Render returns the HTTP-01 bootstrap site and the HTTPS site separately. // Enable the second site only after Certbot has issued the certificate. func Render(domain string, routes gin.RoutesInfo) (httpSite, httpsSite string, err error) { - return RenderWithAuth(domain, routes, 0) + return RenderWithOptions(RenderOptions{Domain: domain, Scheme: "https", Port: 0}, routes, 0) } // RenderWithAuth optionally exposes a single local OpenVPN browser-auth service. // All other unknown paths retain the existing 444 catch-all. func RenderWithAuth(domain string, routes gin.RoutesInfo, authPort int) (httpSite, httpsSite string, err error) { - domain = strings.ToLower(strings.TrimSpace(domain)) + return RenderWithOptions(RenderOptions{Domain: domain, Scheme: "https", Port: 0}, routes, authPort) +} + +// RenderWithOptions renders gateway sites for the configured public scheme/port. +func RenderWithOptions(opts RenderOptions, routes gin.RoutesInfo, authPort int) (httpSite, httpsSite string, err error) { + domain := strings.ToLower(strings.TrimSpace(opts.Domain)) + scheme := strings.ToLower(strings.TrimSpace(opts.Scheme)) + if scheme == "" { + scheme = "https" + } if !tlsservice.ValidDomain(domain) { return "", "", fmt.Errorf("请输入有效的单个完整域名") } + if !tlsservice.ValidScheme(scheme) { + return "", "", fmt.Errorf("协议仅支持 http 或 https") + } + if opts.Port < 0 || opts.Port > 65535 { + return "", "", fmt.Errorf("端口无效") + } if authPort != 0 && (authPort < 1024 || authPort > 65535) { return "", "", fmt.Errorf("认证端口无效") } + listenPort := tlsservice.EffectivePort(scheme, opts.Port) + if scheme == "https" && listenPort == 80 { + return "", "", fmt.Errorf("HTTPS 不能占用 HTTP-01 验证端口 80") + } + if listenPort == 18080 || listenPort == authPort { + return "", "", fmt.Errorf("公网端口与本机服务端口冲突") + } + upstream := upstreamBlock(scheme) + + if scheme == "http" { + httpSite = renderProxyServer(domain, listenPort, false, "", upstream, routes, authPort, scheme) + return httpSite, "", nil + } + + // HTTPS: keep :80 for HTTP-01 / renewals; public app listens on listenPort. httpSite = fmt.Sprintf(`# Install this HTTP site first. Certbot webroot: /var/lib/opsx-vpn/acme server { listen 80 default_server; @@ -70,24 +112,27 @@ server { location / { return 444; } } `, domain) - - var b strings.Builder - fmt.Fprintf(&b, `# Add only after the certificate exists. Keep the HTTP site for renewals. -server { - listen 443 ssl default_server; - server_name _; - ssl_reject_handshake on; - return 444; + httpsSite = renderProxyServer(domain, listenPort, true, domain, upstream, routes, authPort, scheme) + return httpSite, httpsSite, nil } -server { - listen 443 ssl; - server_name %s; - ssl_certificate /etc/letsencrypt/live/%s/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/%s/privkey.pem; - ssl_protocols TLSv1.2 TLSv1.3; - server_tokens off; - if ($host != %s) { return 444; } -`, domain, domain, domain, domain) + +func renderProxyServer(domain string, listenPort int, ssl bool, certDomain, upstream string, routes gin.RoutesInfo, authPort int, scheme string) string { + var b strings.Builder + if ssl { + b.WriteString("# Add only after the certificate exists. Keep the HTTP site for renewals.\n") + fmt.Fprintf(&b, "server {\n listen %d ssl default_server;\n server_name _;\n ssl_reject_handshake on;\n return 444;\n}\n", listenPort) + fmt.Fprintf(&b, "server {\n listen %d ssl;\n server_name %s;\n", listenPort, domain) + fullchain, privkey, _ := tlsservice.CertificateFiles(certDomain) + fmt.Fprintf(&b, " ssl_certificate %s;\n", fullchain) + fmt.Fprintf(&b, " ssl_certificate_key %s;\n", privkey) + b.WriteString(" ssl_protocols TLSv1.2 TLSv1.3;\n server_tokens off;\n") + fmt.Fprintf(&b, " if ($host != %s) { return 444; }\n", domain) + } else { + b.WriteString("# Plain HTTP public entry (no ACME required).\n") + fmt.Fprintf(&b, "server {\n listen %d default_server;\n server_name _;\n return 444;\n}\n", listenPort) + fmt.Fprintf(&b, "server {\n listen %d;\n server_name %s;\n server_tokens off;\n", listenPort, domain) + fmt.Fprintf(&b, " if ($host != %s) { return 444; }\n", domain) + } for _, path := range spaPages { fmt.Fprintf(&b, " location = %s {\n%s }\n", path, upstream) } @@ -98,7 +143,7 @@ server { fmt.Fprintf(&b, " location = %s {\n%s }\n", path, upstream) } if authPort != 0 { - fmt.Fprintf(&b, " location ^~ /oauth2/ {\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Proto https;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_pass http://127.0.0.1:%d;\n }\n", authPort) + fmt.Fprintf(&b, " location ^~ /oauth2/ {\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Proto %s;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_pass http://127.0.0.1:%d;\n }\n", scheme, authPort) } seen := make(map[string]bool) @@ -124,5 +169,5 @@ server { } } b.WriteString(" location / { return 444; }\n}\n") - return httpSite, b.String(), nil + return b.String() } diff --git a/backend/internal/nginxconfig/nginxconfig_test.go b/backend/internal/nginxconfig/nginxconfig_test.go index d6cd7f0..87f0ee0 100644 --- a/backend/internal/nginxconfig/nginxconfig_test.go +++ b/backend/internal/nginxconfig/nginxconfig_test.go @@ -91,3 +91,39 @@ func TestRenderIdentityAndSingleAuthPort(t *testing.T) { t.Fatal("auth component exposed without a configured port") } } + +func TestRenderHTTPCustomPort(t *testing.T) { + routes := gin.RoutesInfo{{Method: "GET", Path: "/api/health"}} + httpSite, httpsSite, err := RenderWithOptions(RenderOptions{Domain: "vpn.example.com", Scheme: "http", Port: 8080}, routes, 0) + if err != nil { + t.Fatal(err) + } + if httpsSite != "" { + t.Fatalf("http scheme must not render https site, got %d bytes", len(httpsSite)) + } + for _, fragment := range []string{"listen 8080;", "server_name vpn.example.com;", "X-Forwarded-Proto http;", "location = /api/health"} { + if !strings.Contains(httpSite, fragment) { + t.Errorf("missing HTTP rule %q in:\n%s", fragment, httpSite) + } + } + if strings.Contains(httpSite, "acme-challenge") { + t.Fatal("http entry must not include ACME challenge location") + } +} + +func TestRenderHTTPSCustomPort(t *testing.T) { + routes := gin.RoutesInfo{{Method: "GET", Path: "/api/health"}} + httpSite, httpsSite, err := RenderWithOptions(RenderOptions{Domain: "vpn.example.com", Scheme: "https", Port: 8443}, routes, 0) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(httpSite, "listen 80;") || !strings.Contains(httpSite, "acme-challenge") { + t.Fatalf("https custom port must keep :80 ACME http site:\n%s", httpSite) + } + if !strings.Contains(httpsSite, "listen 8443 ssl;") { + t.Fatalf("https custom port listen missing:\n%s", httpsSite) + } + if !strings.Contains(httpsSite, "X-Forwarded-Proto https;") { + t.Fatal("missing forwarded proto https") + } +} diff --git a/backend/internal/nginxgateway/gateway.go b/backend/internal/nginxgateway/gateway.go index b6d6fa5..c34d472 100644 --- a/backend/internal/nginxgateway/gateway.go +++ b/backend/internal/nginxgateway/gateway.go @@ -40,9 +40,13 @@ func Stage() string { if err != nil { return "none" } - if strings.Contains(string(data), "# Add only after the certificate exists.") { + body := string(data) + if strings.Contains(body, "# Add only after the certificate exists.") { return "https" } + if strings.Contains(body, "# Plain HTTP public entry") || strings.Contains(body, "server_name ") { + return "http" + } return "http" } @@ -59,23 +63,46 @@ func AuthRouteReady(port int) (bool, bool) { // RefreshAuth updates only the managed site after validation; a bad config is // rolled back before returning. It never provisions a certificate or domain. func RefreshAuth(domain string, routes gin.RoutesInfo, authPort int) error { + status := tlsservice.Status() + scheme := status.Scheme + if scheme == "" { + scheme = "https" + } + return RefreshAuthEntry(nginxconfig.RenderOptions{Domain: domain, Scheme: scheme, Port: status.Port}, routes, authPort) +} + +func RefreshAuthEntry(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, authPort int) error { if Running() { return errors.New("公网入口操作正在进行") } - if err := checkSites(); err != nil { + scheme := strings.ToLower(strings.TrimSpace(opts.Scheme)) + if scheme == "" { + scheme = "https" + } + opts.Scheme = scheme + if scheme == "http" { + if err := checkSitesHTTP(); err != nil { + return err + } + } else if err := checkSites(); err != nil { return err } - if Stage() != "https" { + stage := Stage() + if scheme == "https" && stage != "https" { return errors.New("请先启用 HTTPS 站点") } + if scheme == "http" && stage == "none" { + return errors.New("请先启用公网入口站点") + } previous, err := os.ReadFile(sitePath) if err != nil { return err } + domain := strings.ToLower(strings.TrimSpace(opts.Domain)) if !strings.Contains(string(previous), "server_name "+domain+";") { return errors.New("现有站点域名与配置不匹配") } - httpSite, httpsSite, err := nginxconfig.RenderWithAuth(domain, routes, authPort) + httpSite, httpsSite, err := nginxconfig.RenderWithOptions(opts, routes, authPort) if err != nil { return err } @@ -137,6 +164,33 @@ func writeSite(content string) error { return os.Rename(f.Name(), sitePath) } +func checkSitesHTTP() error { + if os.Geteuid() != 0 { + return errors.New("配置入口需要 root 权限") + } + state := nginxsetup.Inspect() + if !state.Nginx { + return errors.New("请先安装 Nginx") + } + entries, err := os.ReadDir(siteDir) + if err != nil { + return err + } + for _, e := range entries { + if e.Name() != "default" && e.Name() != "opsx-vpn.conf" { + return fmt.Errorf("发现其他启用站点 %s,请先由管理员检查冲突", e.Name()) + } + } + if entries, err := os.ReadDir("/etc/nginx/conf.d"); err == nil { + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".conf") { + return fmt.Errorf("发现其他 Nginx 配置 %s,请先由管理员检查冲突", e.Name()) + } + } + } + return nil +} + func checkSites() error { if os.Geteuid() != 0 { return errors.New("配置入口需要 root 权限") @@ -179,21 +233,39 @@ func checkSites() error { } func certificateExists(domain string) bool { - path := filepath.Join("/etc/letsencrypt/live", domain, "fullchain.pem") - _, err := os.Stat(path) + fullchain, _, _ := tlsservice.CertificateFiles(domain) + _, err := os.Stat(fullchain) return err == nil } // Run performs a reversible HTTP bootstrap, certificate issuance, then HTTPS // activation. On issuance errors the HTTP challenge site remains available for retry. func Run(domain string, routes gin.RoutesInfo, emit func(string)) error { + return RunEntry(nginxconfig.RenderOptions{Domain: domain, Scheme: "https", Port: 0}, routes, emit) +} + +// RunEntry activates the public entry for the given scheme/port. HTTP skips ACME. +func RunEntry(opts nginxconfig.RenderOptions, routes gin.RoutesInfo, emit func(string)) error { + domain := strings.ToLower(strings.TrimSpace(opts.Domain)) + scheme := strings.ToLower(strings.TrimSpace(opts.Scheme)) + if scheme == "" { + scheme = "https" + } + opts.Domain, opts.Scheme = domain, scheme if !tlsservice.ValidDomain(domain) { return errors.New("请先保存有效域名") } - if err := checkSites(); err != nil { + if !tlsservice.ValidScheme(scheme) { + return errors.New("协议仅支持 http 或 https") + } + if scheme == "http" { + if err := checkSitesHTTP(); err != nil { + return err + } + } else if err := checkSites(); err != nil { return err } - httpSite, httpsSite, err := nginxconfig.Render(domain, routes) + httpSite, httpsSite, err := nginxconfig.RenderWithOptions(opts, routes, 0) if err != nil { return err } @@ -209,7 +281,16 @@ func Run(domain string, routes gin.RoutesInfo, emit func(string)) error { return errors.New("已有其他域名的入口配置,请人工迁移证书与站点") } if readErr != nil { - for _, port := range []string{"80", "443"} { + ports := []string{fmt.Sprintf("%d", tlsservice.EffectivePort(scheme, opts.Port))} + if scheme == "https" && tlsservice.EffectivePort(scheme, opts.Port) != 80 { + ports = append([]string{"80"}, ports...) + } + seen := map[string]bool{} + for _, port := range ports { + if seen[port] { + continue + } + seen[port] = true listener, e := net.Listen("tcp", "0.0.0.0:"+port) if e != nil { return fmt.Errorf("端口 %s 已占用,请先检查监听服务: %w", port, e) @@ -251,6 +332,10 @@ func Run(domain string, routes gin.RoutesInfo, emit func(string)) error { rollback() return err } + if scheme == "http" { + emit("HTTP 公网入口已启用(未申请证书)。\n") + return nil + } emit("HTTP-01 验证站点已启用,开始签发证书。\n") if !certificateExists(domain) { if err := command(emit, 4*time.Minute, "certbot", "certonly", "--webroot", "-w", nginxsetup.Webroot, @@ -286,6 +371,9 @@ func Run(domain string, routes gin.RoutesInfo, emit func(string)) error { if err := os.WriteFile(hookPath, []byte(hook), 0o755); err != nil { return err } + if _, _, source := tlsservice.CertificateFiles(domain); source == tlsservice.CertSourceHTTP01 { + _ = tlsservice.MarkCertSource(tlsservice.DataDir(), tlsservice.CertSourceHTTP01) + } emit("HTTPS 入口已启用;Certbot 续签成功后将自动重载 Nginx。\n") return nil } diff --git a/backend/internal/service/openvpn/openvpn.go b/backend/internal/service/openvpn/openvpn.go index ce86b44..b9b0b51 100644 --- a/backend/internal/service/openvpn/openvpn.go +++ b/backend/internal/service/openvpn/openvpn.go @@ -134,8 +134,8 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, } for _, key := range []string{"oidc_issuer", "oidc_base_url"} { u, err := url.Parse(service.Str(out, key)) - if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (key == "oidc_base_url" && u.Path != "" && u.Path != "/") { - return nil, fmt.Errorf("%s 必须是无用户信息、查询和片段的 HTTPS 地址;回调地址使用独立公网域名", key) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (key == "oidc_base_url" && u.Path != "" && u.Path != "/") { + return nil, fmt.Errorf("%s 必须是无用户信息、查询和片段的 HTTP(S) 地址;回调地址使用独立公网域名", key) } } if service.Str(out, "oidc_client_id") == "" { diff --git a/backend/internal/service/openvpn/openvpn_test.go b/backend/internal/service/openvpn/openvpn_test.go index bc5c554..7befda9 100644 --- a/backend/internal/service/openvpn/openvpn_test.go +++ b/backend/internal/service/openvpn/openvpn_test.go @@ -144,7 +144,7 @@ func TestOIDCRejectsUnsafeURLs(t *testing.T) { "auth_mode": "oidc", "oidc_issuer": "https://idp.example.com/realms/a", "oidc_base_url": "https://login.example.com", "oidc_client_id": "vpn", } - for _, value := range []string{"http://login.example.com", "https://login.example.com/extra", "https://user@login.example.com", "https://login.example.com/?code=abc"} { + for _, value := range []string{"https://login.example.com/extra", "https://user@login.example.com", "https://login.example.com/?code=abc", "ftp://login.example.com"} { cfg := service.CloneConfig(base) cfg["oidc_base_url"] = value if _, err := New().Validate(cfg); err == nil { @@ -154,6 +154,12 @@ func TestOIDCRejectsUnsafeURLs(t *testing.T) { if _, err := New().Validate(base); err != nil { t.Fatal(err) } + httpBase := service.CloneConfig(base) + httpBase["oidc_base_url"] = "http://login.example.com:8080" + httpBase["oidc_issuer"] = "http://login.example.com:8080/id" + if _, err := New().Validate(httpBase); err != nil { + t.Fatalf("http public base must be allowed: %v", err) + } base["auth_mode"] = "password" base["oidc_client_secret"] = "must-not-survive" cfg, err := New().Validate(base) diff --git a/backend/internal/tlsservice/tlsservice.go b/backend/internal/tlsservice/tlsservice.go index ba01796..ff7c344 100644 --- a/backend/internal/tlsservice/tlsservice.go +++ b/backend/internal/tlsservice/tlsservice.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "sync" "time" @@ -23,6 +24,8 @@ var domainPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?: type configuration struct { domain string + scheme string + port int mode string dataDir string cacheDir string @@ -36,36 +39,123 @@ var state struct { } type CertificateStatus struct { - Enabled bool `json:"enabled"` - Mode string `json:"mode"` - ConfiguredMode string `json:"configured_mode"` - Domain string `json:"domain"` - ConfiguredDomain string `json:"configured_domain"` - RestartRequired bool `json:"restart_required"` - DNS []string `json:"dns"` - HTTPAddr string `json:"http_address"` - HTTPSAddr string `json:"https_address"` - Cached bool `json:"certificate_cached"` - Valid bool `json:"certificate_valid"` - Issuer string `json:"issuer,omitempty"` - NotBefore time.Time `json:"not_before,omitempty"` - NotAfter time.Time `json:"not_after,omitempty"` - Renewal string `json:"renewal_mode"` - DirectPortPublic bool `json:"management_direct_public"` + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + ConfiguredMode string `json:"configured_mode"` + Scheme string `json:"scheme"` + ConfiguredScheme string `json:"configured_scheme"` + Domain string `json:"domain"` + ConfiguredDomain string `json:"configured_domain"` + Port int `json:"port"` + ConfiguredPort int `json:"configured_port"` + PublicBase string `json:"public_base"` + ConfiguredPublicBase string `json:"configured_public_base"` + RestartRequired bool `json:"restart_required"` + DNS []string `json:"dns"` + HTTPAddr string `json:"http_address"` + HTTPSAddr string `json:"https_address"` + Cached bool `json:"certificate_cached"` + Valid bool `json:"certificate_valid"` + CertSource string `json:"cert_source,omitempty"` + Issuer string `json:"issuer,omitempty"` + NotBefore time.Time `json:"not_before,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` + Renewal string `json:"renewal_mode"` + DirectPortPublic bool `json:"management_direct_public"` +} + +// DataDir returns the active TLS data directory (empty before Start). +func DataDir() string { + state.RLock() + defer state.RUnlock() + return state.config.dataDir } func ValidDomain(domain string) bool { return len(domain) <= 253 && domainPattern.MatchString(domain) && net.ParseIP(domain) == nil } +func ValidScheme(scheme string) bool { + return scheme == "http" || scheme == "https" +} + +// EffectivePort returns the listen/public port. port<=0 means scheme default. +func EffectivePort(scheme string, port int) int { + if port > 0 { + return port + } + if scheme == "http" { + return 80 + } + return 443 +} + +// PublicBaseURL builds {scheme}://{host}[:port], omitting :80/:443 defaults. +func PublicBaseURL(scheme, host string, port int) string { + scheme = strings.ToLower(strings.TrimSpace(scheme)) + host = strings.ToLower(strings.TrimSpace(host)) + if !ValidScheme(scheme) || host == "" || !ValidDomain(host) { + return "" + } + eff := EffectivePort(scheme, port) + if (scheme == "http" && eff == 80) || (scheme == "https" && eff == 443) { + return scheme + "://" + host + } + return fmt.Sprintf("%s://%s:%d", scheme, host, eff) +} + +func normalizeScheme(scheme string) string { + scheme = strings.ToLower(strings.TrimSpace(scheme)) + if scheme == "" { + return "https" + } + return scheme +} + +func readPersisted(dataDir, name string) (string, error) { + raw, err := os.ReadFile(filepath.Join(dataDir, name)) + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + if err != nil { + return "", err + } + return strings.TrimSpace(string(raw)), nil +} + +func writePersisted(dataDir, name, value string) error { + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return err + } + temp, err := os.CreateTemp(dataDir, "."+name+"-*") + if err != nil { + return err + } + defer os.Remove(temp.Name()) + defer temp.Close() + if err := temp.Chmod(0o600); err != nil { + return err + } + if _, err := temp.WriteString(value + "\n"); err != nil { + return err + } + if err := temp.Sync(); err != nil { + return err + } + if err := temp.Close(); err != nil { + return err + } + return os.Rename(temp.Name(), filepath.Join(dataDir, name)) +} + func configFromEnvironment(dataDir string) (configuration, error) { mode := os.Getenv("VPN_TLS_MODE") if mode == "" { - if raw, err := os.ReadFile(filepath.Join(dataDir, "tls-mode")); err == nil { - mode = strings.TrimSpace(string(raw)) - } else if !errors.Is(err, os.ErrNotExist) { + raw, err := readPersisted(dataDir, "tls-mode") + if err != nil { return configuration{}, fmt.Errorf("读取 TLS 模式配置: %w", err) } + mode = raw } if mode == "" { mode = "autocert" @@ -75,56 +165,111 @@ func configFromEnvironment(dataDir string) (configuration, error) { } domain := strings.ToLower(strings.TrimSpace(os.Getenv("VPN_TLS_DOMAIN"))) if domain == "" { - if raw, err := os.ReadFile(filepath.Join(dataDir, "tls-domain")); err == nil { - domain = strings.ToLower(strings.TrimSpace(string(raw))) - } else if !errors.Is(err, os.ErrNotExist) { + raw, err := readPersisted(dataDir, "tls-domain") + if err != nil { return configuration{}, fmt.Errorf("读取 TLS 域名配置: %w", err) } + domain = strings.ToLower(raw) } if domain != "" && !ValidDomain(domain) { return configuration{}, fmt.Errorf("VPN_TLS_DOMAIN 不是有效的单个完整域名") } + schemeEnv := strings.TrimSpace(os.Getenv("VPN_TLS_SCHEME")) + scheme := normalizeScheme(schemeEnv) + if schemeEnv == "" { + raw, err := readPersisted(dataDir, "tls-scheme") + if err != nil { + return configuration{}, fmt.Errorf("读取公网协议配置: %w", err) + } + scheme = normalizeScheme(raw) + } + if !ValidScheme(scheme) { + return configuration{}, fmt.Errorf("VPN_TLS_SCHEME 仅支持 http 或 https") + } + port := 0 + if raw := strings.TrimSpace(os.Getenv("VPN_TLS_PORT")); raw != "" { + var err error + port, err = strconv.Atoi(raw) + if err != nil || port < 1 || port > 65535 { + return configuration{}, fmt.Errorf("VPN_TLS_PORT 必须是 1-65535") + } + } else { + raw, err := readPersisted(dataDir, "tls-port") + if err != nil { + return configuration{}, fmt.Errorf("读取公网端口配置: %w", err) + } + if raw != "" { + port, err = strconv.Atoi(raw) + if err != nil || port < 1 || port > 65535 { + return configuration{}, fmt.Errorf("tls-port 必须是 1-65535") + } + } + } httpAddr := os.Getenv("VPN_TLS_HTTP_ADDR") if httpAddr == "" { - httpAddr = ":80" + if scheme == "http" { + httpAddr = fmt.Sprintf(":%d", EffectivePort(scheme, port)) + } else { + httpAddr = ":80" + } } httpsAddr := os.Getenv("VPN_TLS_HTTPS_ADDR") if httpsAddr == "" { - httpsAddr = ":443" + if scheme == "https" { + httpsAddr = fmt.Sprintf(":%d", EffectivePort(scheme, port)) + } else { + httpsAddr = ":443" + } } - return configuration{domain: domain, mode: mode, dataDir: dataDir, cacheDir: filepath.Join(dataDir, "acme"), httpAddr: httpAddr, httpsAddr: httpsAddr}, nil + return configuration{domain: domain, scheme: scheme, port: port, mode: mode, dataDir: dataDir, cacheDir: filepath.Join(dataDir, "acme"), httpAddr: httpAddr, httpsAddr: httpsAddr}, nil } func ConfigureDomain(dataDir, domain string) error { - if os.Getenv("VPN_TLS_DOMAIN") != "" { - return fmt.Errorf("当前由 VPN_TLS_DOMAIN 环境变量管理域名,请修改启动配置") + return ConfigurePublicEntry(dataDir, "https", domain, 0) +} + +// ConfigurePublicEntry persists scheme/host/port for the public management entry. +// port<=0 means scheme default (80 for http, 443 for https). +func ConfigurePublicEntry(dataDir, scheme, domain string, port int) error { + if os.Getenv("VPN_TLS_DOMAIN") != "" || os.Getenv("VPN_TLS_SCHEME") != "" || os.Getenv("VPN_TLS_PORT") != "" { + return fmt.Errorf("当前由 VPN_TLS_* 环境变量管理公网入口,请修改启动配置") } + scheme = normalizeScheme(scheme) domain = strings.ToLower(strings.TrimSpace(domain)) + if !ValidScheme(scheme) { + return fmt.Errorf("协议仅支持 http 或 https") + } if !ValidDomain(domain) { return fmt.Errorf("请输入单个有效的完整域名") } - if err := os.MkdirAll(dataDir, 0o700); err != nil { - return err + if port < 0 || port > 65535 { + return fmt.Errorf("端口必须是 0-65535(0 表示协议默认端口)") } - temp, err := os.CreateTemp(dataDir, ".tls-domain-*") - if err != nil { - return err + if scheme == "https" && EffectivePort(scheme, port) == 80 { + return fmt.Errorf("HTTPS 不能使用 HTTP-01 验证端口 80") } - defer os.Remove(temp.Name()) - defer temp.Close() - if err := temp.Chmod(0o600); err != nil { - return err + portValue := "" + if port > 0 { + portValue = fmt.Sprintf("%d", port) } - if _, err := temp.WriteString(domain + "\n"); err != nil { - return err + values := []struct{ name, value string }{{"tls-domain", domain}, {"tls-scheme", scheme}, {"tls-port", portValue}} + old := make([]string, len(values)) + for i, entry := range values { + var err error + old[i], err = readPersisted(dataDir, entry.name) + if err != nil { + return err + } } - if err := temp.Sync(); err != nil { - return err + for i, entry := range values { + if err := writePersisted(dataDir, entry.name, entry.value); err != nil { + for j := i - 1; j >= 0; j-- { + _ = writePersisted(dataDir, values[j].name, old[j]) + } + return err + } } - if err := temp.Close(); err != nil { - return err - } - return os.Rename(temp.Name(), filepath.Join(dataDir, "tls-domain")) + return nil } func ConfigureMode(dataDir, mode string) error { @@ -134,28 +279,7 @@ func ConfigureMode(dataDir, mode string) error { if mode != "autocert" && mode != "nginx" { return fmt.Errorf("入口模式仅支持 autocert 或 nginx") } - if err := os.MkdirAll(dataDir, 0o700); err != nil { - return err - } - temp, err := os.CreateTemp(dataDir, ".tls-mode-*") - if err != nil { - return err - } - defer os.Remove(temp.Name()) - defer temp.Close() - if err := temp.Chmod(0o600); err != nil { - return err - } - if _, err := temp.WriteString(mode + "\n"); err != nil { - return err - } - if err := temp.Sync(); err != nil { - return err - } - if err := temp.Close(); err != nil { - return err - } - return os.Rename(temp.Name(), filepath.Join(dataDir, "tls-mode")) + return writePersisted(dataDir, "tls-mode", mode) } // Start keeps certificates in the persistent data directory. Issuance occurs @@ -165,7 +289,8 @@ func Start(handler http.Handler, dataDir string) error { if err != nil { return err } - if config.domain == "" || config.mode == "nginx" { + if config.domain == "" || config.mode == "nginx" || normalizeScheme(config.scheme) == "http" { + // Plain HTTP public entry is terminated by Nginx (or left unset); Go autocert is HTTPS-only. state.Lock() state.config = config state.Unlock() @@ -188,8 +313,12 @@ func Start(handler http.Handler, dataDir string) error { httpListener.Close() return fmt.Errorf("监听 HTTPS 端口 %s: %w", config.httpsAddr, err) } + publicBase := PublicBaseURL("https", config.domain, config.port) redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Host != config.domain && r.Host != config.domain+":80" { + eff := EffectivePort("https", config.port) + allowedHost := config.domain + allowedWithPort := fmt.Sprintf("%s:%d", config.domain, eff) + if r.Host != allowedHost && r.Host != allowedWithPort && r.Host != config.domain+":80" { http.NotFound(w, r) return } @@ -197,7 +326,7 @@ func Start(handler http.Handler, dataDir string) error { http.Error(w, "Use HTTPS", http.StatusBadRequest) return } - http.Redirect(w, r, "https://"+config.domain+r.URL.RequestURI(), http.StatusPermanentRedirect) + http.Redirect(w, r, publicBase+r.URL.RequestURI(), http.StatusPermanentRedirect) }) httpServer := &http.Server{Handler: manager.HTTPHandler(redirect), ReadHeaderTimeout: 10 * time.Second} httpsServer := &http.Server{Handler: handler, TLSConfig: manager.TLSConfig(), ReadHeaderTimeout: 10 * time.Second} @@ -222,7 +351,17 @@ func Status() CertificateStatus { state.RLock() config := state.config state.RUnlock() - status := CertificateStatus{Enabled: config.domain != "" && config.mode == "autocert", Domain: config.domain, Mode: config.mode, DNS: []string{}, HTTPAddr: config.httpAddr, HTTPSAddr: config.httpsAddr, Renewal: config.mode} + scheme := normalizeScheme(config.scheme) + status := CertificateStatus{ + Enabled: config.domain != "" && config.mode == "autocert", + Domain: config.domain, + Scheme: scheme, + Port: config.port, + Mode: config.mode, + DNS: []string{}, + HTTPAddr: config.httpAddr, HTTPSAddr: config.httpsAddr, + Renewal: config.mode, + } if config.mode == "nginx" { listenAddress := os.Getenv("VPN_LISTEN_ADDR") if listenAddress == "" { @@ -235,21 +374,38 @@ func Status() CertificateStatus { if desired, err := configFromEnvironment(config.dataDir); err == nil { status.ConfiguredDomain = desired.domain status.ConfiguredMode = desired.mode - status.RestartRequired = desired.domain != config.domain || desired.mode != config.mode + status.ConfiguredScheme = normalizeScheme(desired.scheme) + status.ConfiguredPort = desired.port + status.ConfiguredPublicBase = PublicBaseURL(status.ConfiguredScheme, desired.domain, desired.port) + status.RestartRequired = desired.domain != config.domain || desired.mode != config.mode || + normalizeScheme(desired.scheme) != scheme || desired.port != config.port } if config.mode == "nginx" { - // Certbot owns this certificate. Read only the public certificate and - // never mistake a configured domain for a listening HTTPS gateway. + // Certbot or uploaded PEMs own HTTPS certificates. HTTP needs no leaf. status.Domain = "" - if config.domain != "" { - readCertificate(filepath.Join("/etc/letsencrypt/live", config.domain, "fullchain.pem"), config.domain, &status) - if status.Valid { - status.Domain = config.domain + domain := config.domain + if domain == "" { + domain = status.ConfiguredDomain + } + if domain != "" { + if scheme == "http" { + status.Domain = domain + } else { + fullchain, _, source := CertificateFiles(domain) + readCertificate(fullchain, domain, &status) + if status.Cached || status.Valid { + status.CertSource = source + } + if status.Valid { + status.Domain = domain + } } } + status.PublicBase = PublicBaseURL(scheme, status.Domain, config.port) return status } if !status.Enabled { + status.PublicBase = PublicBaseURL(scheme, status.Domain, config.port) return status } if ips, err := net.LookupIP(config.domain); err == nil { @@ -261,10 +417,15 @@ func Status() CertificateStatus { // Parse only the public leaf metadata; never return private key material. data, err := os.ReadFile(filepath.Join(config.cacheDir, config.domain)) if err != nil { + status.PublicBase = PublicBaseURL(scheme, "", config.port) return status } _, data = pem.Decode(data) readCertificateBytes(data, config.domain, &status) + if !status.Valid { + status.Domain = "" + } + status.PublicBase = PublicBaseURL(scheme, status.Domain, config.port) return status } diff --git a/backend/internal/tlsservice/tlsservice_test.go b/backend/internal/tlsservice/tlsservice_test.go index 4228d89..09fe9dd 100644 --- a/backend/internal/tlsservice/tlsservice_test.go +++ b/backend/internal/tlsservice/tlsservice_test.go @@ -88,3 +88,84 @@ func TestNginxModeDoesNotBindGoListeners(t *testing.T) { t.Fatal("loopback management listener must not be marked public") } } + +func TestPublicBaseURL(t *testing.T) { + cases := []struct { + scheme, host string + port int + want string + }{ + {"https", "vpn.example.com", 0, "https://vpn.example.com"}, + {"https", "vpn.example.com", 443, "https://vpn.example.com"}, + {"https", "vpn.example.com", 8443, "https://vpn.example.com:8443"}, + {"http", "vpn.example.com", 0, "http://vpn.example.com"}, + {"http", "vpn.example.com", 80, "http://vpn.example.com"}, + {"http", "vpn.example.com", 8080, "http://vpn.example.com:8080"}, + {"HTTP", "VPN.Example.Com", 8080, "http://vpn.example.com:8080"}, + {"https", "", 443, ""}, + {"ftp", "vpn.example.com", 0, ""}, + } + for _, tc := range cases { + got := PublicBaseURL(tc.scheme, tc.host, tc.port) + if got != tc.want { + t.Fatalf("PublicBaseURL(%q,%q,%d)=%q want %q", tc.scheme, tc.host, tc.port, got, tc.want) + } + } +} + +func TestConfigurePublicEntryHTTPAndCustomPort(t *testing.T) { + t.Setenv("VPN_TLS_DOMAIN", "") + t.Setenv("VPN_TLS_SCHEME", "") + t.Setenv("VPN_TLS_PORT", "") + t.Setenv("VPN_TLS_MODE", "") + dir := t.TempDir() + if err := ConfigureMode(dir, "nginx"); err != nil { + t.Fatal(err) + } + if err := ConfigurePublicEntry(dir, "http", "vpn.example.com", 8080); err != nil { + t.Fatal(err) + } + t.Setenv("VPN_TLS_HTTP_ADDR", "127.0.0.1:0") + t.Setenv("VPN_TLS_HTTPS_ADDR", "127.0.0.1:0") + if err := Start(http.NotFoundHandler(), dir); err != nil { + t.Fatal(err) + } + status := Status() + if status.Scheme != "http" || status.Domain != "vpn.example.com" || status.Port != 8080 { + t.Fatalf("unexpected status fields: %+v", status) + } + if status.PublicBase != "http://vpn.example.com:8080" { + t.Fatalf("public_base = %q", status.PublicBase) + } + if status.Valid { + t.Fatal("http entry must not require certificate Valid") + } + if status.ConfiguredPublicBase != "http://vpn.example.com:8080" { + t.Fatalf("configured_public_base = %q", status.ConfiguredPublicBase) + } +} + +func TestConfigurePublicEntryHTTPSOmitsDefaultPort(t *testing.T) { + t.Setenv("VPN_TLS_DOMAIN", "") + t.Setenv("VPN_TLS_SCHEME", "") + t.Setenv("VPN_TLS_PORT", "") + t.Setenv("VPN_TLS_MODE", "nginx") + dir := t.TempDir() + if err := ConfigurePublicEntry(dir, "https", "vpn.example.com", 443); err != nil { + t.Fatal(err) + } + t.Setenv("VPN_TLS_HTTP_ADDR", "127.0.0.1:0") + t.Setenv("VPN_TLS_HTTPS_ADDR", "127.0.0.1:0") + if err := Start(http.NotFoundHandler(), dir); err != nil { + t.Fatal(err) + } + // Without a leaf cert, https nginx Domain stays empty; configured base still omits :443. + status := Status() + if status.ConfiguredPublicBase != "https://vpn.example.com" { + t.Fatalf("configured_public_base = %q", status.ConfiguredPublicBase) + } + if status.ConfiguredPort != 443 && status.ConfiguredPort != 0 { + // persisted 443 is fine; EffectivePort still omits in PublicBaseURL + t.Logf("configured port = %d", status.ConfiguredPort) + } +} diff --git a/backend/internal/tlsservice/upload.go b/backend/internal/tlsservice/upload.go new file mode 100644 index 0000000..11cfa63 --- /dev/null +++ b/backend/internal/tlsservice/upload.go @@ -0,0 +1,203 @@ +package tlsservice + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + CertSourceUpload = "upload" + CertSourceHTTP01 = "http01" + certSourceFile = "tls-cert-source" +) + +var uploadMu sync.Mutex + +func uploadedCertDir(dataDir, domain string) string { + return filepath.Join(dataDir, "tls", "certs", strings.ToLower(strings.TrimSpace(domain))) +} + +// UploadedCertificatePaths returns on-disk paths for an uploaded material pair. +func UploadedCertificatePaths(dataDir, domain string) (fullchain, privkey string) { + dir := uploadedCertDir(dataDir, domain) + return filepath.Join(dir, "current", "fullchain.pem"), filepath.Join(dir, "current", "privkey.pem") +} + +func LetsEncryptPaths(domain string) (fullchain, privkey string) { + domain = strings.ToLower(strings.TrimSpace(domain)) + base := filepath.Join("/etc/letsencrypt/live", domain) + return filepath.Join(base, "fullchain.pem"), filepath.Join(base, "privkey.pem") +} + +func readCertSource(dataDir string) string { + raw, err := readPersisted(dataDir, certSourceFile) + if err != nil || raw == "" { + return "" + } + raw = strings.ToLower(strings.TrimSpace(raw)) + if raw == CertSourceUpload || raw == CertSourceHTTP01 { + return raw + } + return "" +} + +func MarkCertSource(dataDir, source string) error { + source = strings.ToLower(strings.TrimSpace(source)) + if source != CertSourceUpload && source != CertSourceHTTP01 { + return fmt.Errorf("证书来源仅支持 upload 或 http01") + } + return writePersisted(dataDir, certSourceFile, source) +} + +// CertificateFiles picks nginx ssl_certificate paths for domain. +// Upload material in the data dir wins when marked upload or when files exist +// and http01 has not been explicitly marked. +func CertificateFiles(domain string) (fullchain, privkey, source string) { + domain = strings.ToLower(strings.TrimSpace(domain)) + state.RLock() + dataDir := state.config.dataDir + state.RUnlock() + if dataDir == "" || domain == "" { + fc, pk := LetsEncryptPaths(domain) + return fc, pk, CertSourceHTTP01 + } + marked := readCertSource(dataDir) + upFull, upKey := UploadedCertificatePaths(dataDir, domain) + _, errFull := os.Stat(upFull) + _, errKey := os.Stat(upKey) + uploadPresent := errFull == nil && errKey == nil + leFull, leKey := LetsEncryptPaths(domain) + switch { + case marked == CertSourceUpload && uploadPresent: + return upFull, upKey, CertSourceUpload + case marked == CertSourceHTTP01: + return leFull, leKey, CertSourceHTTP01 + case uploadPresent: + return upFull, upKey, CertSourceUpload + default: + return leFull, leKey, CertSourceHTTP01 + } +} + +func leafCertificateFromPEM(certPEM []byte) (*x509.Certificate, error) { + rest := certPEM + for len(rest) > 0 { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("证书 PEM 无法解析") + } + return cert, nil + } + return nil, fmt.Errorf("未找到 CERTIFICATE PEM 块") +} + +// InstallUploadedCertificate validates that certificate_pem and private_key_pem +// form a matching pair for domain, writes them under the data directory, and +// marks cert_source=upload. +func InstallUploadedCertificate(dataDir, domain, certificatePEM, privateKeyPEM string) error { + return InstallUploadedCertificateWithApply(dataDir, domain, certificatePEM, privateKeyPEM, nil) +} + +// InstallUploadedCertificateWithApply switches the certificate pair atomically +// and restores the previous source and pair when the gateway reload fails. +func InstallUploadedCertificateWithApply(dataDir, domain, certificatePEM, privateKeyPEM string, apply func() error) error { + uploadMu.Lock() + defer uploadMu.Unlock() + domain = strings.ToLower(strings.TrimSpace(domain)) + if !ValidDomain(domain) { + return fmt.Errorf("请输入有效的单个完整域名") + } + certPEM := []byte(strings.TrimSpace(certificatePEM) + "\n") + keyPEM := []byte(strings.TrimSpace(privateKeyPEM) + "\n") + if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil { + return fmt.Errorf("证书与私钥不匹配或 PEM 无效") + } + leaf, err := leafCertificateFromPEM(certPEM) + if err != nil { + return err + } + if err := leaf.VerifyHostname(domain); err != nil { + return fmt.Errorf("证书与域名 %s 不匹配", domain) + } + now := time.Now() + if now.Before(leaf.NotBefore) || !now.Before(leaf.NotAfter) { + return fmt.Errorf("证书不在有效期内") + } + dir := uploadedCertDir(dataDir, domain) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + version, err := os.MkdirTemp(dir, ".certificate-*") + if err != nil { + return err + } + defer func() { + if version != "" { + _ = os.RemoveAll(version) + } + }() + if err := os.WriteFile(filepath.Join(version, "fullchain.pem"), certPEM, 0o600); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(version, "privkey.pem"), keyPEM, 0o600); err != nil { + return err + } + current := filepath.Join(dir, "current") + previous, linkErr := os.Readlink(current) + if linkErr != nil && !os.IsNotExist(linkErr) { + return fmt.Errorf("当前证书路径不是托管链接: %w", linkErr) + } + oldSource, err := readPersisted(dataDir, certSourceFile) + if err != nil { + return err + } + link := filepath.Join(dir, ".next-certificate") + if err := os.Symlink(filepath.Base(version), link); err != nil { + return err + } + defer os.Remove(link) + if err := os.Rename(link, current); err != nil { + return err + } + rollback := func() { + if linkErr == nil { + _ = os.Symlink(previous, link) + _ = os.Rename(link, current) + } else { + _ = os.Remove(current) + } + if oldSource == "" { + _ = os.Remove(filepath.Join(dataDir, certSourceFile)) + } else { + _ = writePersisted(dataDir, certSourceFile, oldSource) + } + } + if err := MarkCertSource(dataDir, CertSourceUpload); err != nil { + rollback() + return err + } + if apply != nil { + if err := apply(); err != nil { + rollback() + return fmt.Errorf("刷新公网入口失败: %w", err) + } + } + // The version must remain available through the current symlink. + version = "" + return nil +} diff --git a/backend/internal/tlsservice/upload_test.go b/backend/internal/tlsservice/upload_test.go new file mode 100644 index 0000000..f30af23 --- /dev/null +++ b/backend/internal/tlsservice/upload_test.go @@ -0,0 +1,155 @@ +package tlsservice + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "math/big" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func testPEMPair(t *testing.T, dnsNames []string) (certPEM, keyPEM string, otherKeyPEM string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + other, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + serial, err := rand.Int(rand.Reader, big.NewInt(1<<62)) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: dnsNames[0], Organization: []string{"opsx-vpn-test"}}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: dnsNames, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + certPEMBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + otherDER, err := x509.MarshalECPrivateKey(other) + if err != nil { + t.Fatal(err) + } + return string(certPEMBytes), + string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})), + string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: otherDER})) +} + +func TestInstallUploadedCertificateSuccess(t *testing.T) { + t.Setenv("VPN_TLS_DOMAIN", "") + t.Setenv("VPN_TLS_SCHEME", "") + t.Setenv("VPN_TLS_PORT", "") + t.Setenv("VPN_TLS_MODE", "") + dir := t.TempDir() + certPEM, keyPEM, _ := testPEMPair(t, []string{"vpn.example.com"}) + if err := ConfigureMode(dir, "nginx"); err != nil { + t.Fatal(err) + } + if err := ConfigurePublicEntry(dir, "https", "vpn.example.com", 0); err != nil { + t.Fatal(err) + } + if err := InstallUploadedCertificate(dir, "vpn.example.com", certPEM, keyPEM); err != nil { + t.Fatal(err) + } + fullchain, privkey := UploadedCertificatePaths(dir, "vpn.example.com") + if st, err := os.Stat(fullchain); err != nil || st.Mode().Perm() != 0o600 { + t.Fatalf("fullchain perms: %v %v", st, err) + } + if st, err := os.Stat(privkey); err != nil || st.Mode().Perm() != 0o600 { + t.Fatalf("privkey perms: %v %v", st, err) + } + if got := readCertSource(dir); got != CertSourceUpload { + t.Fatalf("cert_source=%q", got) + } + t.Setenv("VPN_TLS_HTTP_ADDR", "127.0.0.1:0") + t.Setenv("VPN_TLS_HTTPS_ADDR", "127.0.0.1:0") + if err := Start(http.NotFoundHandler(), dir); err != nil { + t.Fatal(err) + } + status := Status() + if !status.Valid || status.CertSource != CertSourceUpload || status.Domain != "vpn.example.com" { + t.Fatalf("status after upload: %+v", status) + } + if status.Issuer == "" || status.NotAfter.IsZero() { + t.Fatalf("missing issuer/expiry: %+v", status) + } + if status.PublicBase != "https://vpn.example.com" { + t.Fatalf("public_base=%q", status.PublicBase) + } + fc, pk, source := CertificateFiles("vpn.example.com") + if source != CertSourceUpload || fc != fullchain || pk != privkey { + t.Fatalf("CertificateFiles = %q %q %q", fc, pk, source) + } +} + +func TestInstallUploadedCertificateKeyMismatch(t *testing.T) { + dir := t.TempDir() + certPEM, _, otherKey := testPEMPair(t, []string{"vpn.example.com"}) + err := InstallUploadedCertificate(dir, "vpn.example.com", certPEM, otherKey) + if err == nil { + t.Fatal("expected mismatch error") + } + if _, statErr := os.Stat(filepath.Join(dir, "tls", "certs", "vpn.example.com", "fullchain.pem")); !os.IsNotExist(statErr) { + t.Fatal("mismatch must not write certificate files") + } + if readCertSource(dir) != "" { + t.Fatal("mismatch must not mark cert_source") + } +} + +func TestInstallUploadedCertificateDomainMismatch(t *testing.T) { + dir := t.TempDir() + certPEM, keyPEM, _ := testPEMPair(t, []string{"other.example.com"}) + if err := InstallUploadedCertificate(dir, "vpn.example.com", certPEM, keyPEM); err == nil { + t.Fatal("expected hostname mismatch") + } +} + +func TestUploadRollbackPreservesPreviousCertificate(t *testing.T) { + dir := t.TempDir() + firstCert, firstKey, _ := testPEMPair(t, []string{"vpn.example.com"}) + secondCert, secondKey, _ := testPEMPair(t, []string{"vpn.example.com"}) + if err := InstallUploadedCertificate(dir, "vpn.example.com", firstCert, firstKey); err != nil { + t.Fatal(err) + } + fullchain, privkey := UploadedCertificatePaths(dir, "vpn.example.com") + if err := InstallUploadedCertificateWithApply(dir, "vpn.example.com", secondCert, secondKey, func() error { + return errors.New("nginx reload failed") + }); err == nil { + t.Fatal("expected reload error") + } + gotCert, err := os.ReadFile(fullchain) + if err != nil || strings.TrimSpace(string(gotCert)) != strings.TrimSpace(firstCert) { + t.Fatalf("old certificate not restored: %v", err) + } + gotKey, err := os.ReadFile(privkey) + if err != nil || strings.TrimSpace(string(gotKey)) != strings.TrimSpace(firstKey) { + t.Fatalf("old key not restored: %v", err) + } + if readCertSource(dir) != CertSourceUpload { + t.Fatal("certificate source not restored") + } +} diff --git a/docs/specs/SDD-004-public-entry-scheme-port-upload.md b/docs/specs/SDD-004-public-entry-scheme-port-upload.md new file mode 100644 index 0000000..c06f371 --- /dev/null +++ b/docs/specs/SDD-004-public-entry-scheme-port-upload.md @@ -0,0 +1,62 @@ +# SDD-004:公网入口协议、端口与证书上传 + +| 字段 | 内容 | +|---|---| +| 版本 / 变更级别 | 0.1 / S3 | +| 设计状态 | 已形成,随发布核验更新 | +| 实现状态 | 待本地与生产验证 | +| 负责人 / 日期 | opsx-vpn / 2026-10-26 | +| 关联需求 | 用户调整 HTTP/HTTPS 公网入口及内网相关实现,要求提交并部署 | +| 批准状态 | 用户已授权提交和部署 | + +## 1. 背景、目标与范围 + +- 当前行为与证据:`tlsservice` 读取 `data/tls-*`,`nginxconfig` 生成托管站点,`nginxgateway` 校验并重载;`identityprovider` 和 OpenVPN OIDC 引用公网入口。生产实例为 Nginx HTTPS:443,管理服务监听 :18080,原证书为 Let's Encrypt。 +- 目标:支持保存 HTTP/HTTPS 域名及自定义端口,HTTP 不申请证书;HTTPS 保留 HTTP-01 路径;证书上传验证并可安全切换;身份回调使用实际入口地址。 +- 非目标:变更现有生产入口为 HTTP、修改现有证书、切换管理服务监听地址或安装 VPN 依赖。 +- 保留:旧配置缺省 HTTPS:443,当前 `/id`、`/oauth2` 和管理 API 的路由及已建立的 VPN 进程。 + +## 2. 需求与约束 + +- FR-01:管理员设置协议、域名、端口,状态及预览展示准确的公网地址;旧配置维持 HTTPS:443。 +- FR-02:Nginx HTTP 入口仅代理现有白名单;HTTPS 保留 80 端口供 HTTP-01,拒绝端口冲突和未知路径。 +- FR-03:身份发行者和 OpenVPN OIDC 回调使用相同公网地址,避免端口或协议不一致。 +- FR-04:上传证书必须匹配域名和私钥且在有效期内;成对写入,入口重载失败后恢复原证书并报告失败。 +- NFR-01:私钥不可进入 Git/响应,落盘权限 0600;安全相关配置错误须阻止切换。 + +## 3. 架构与方案 + +`PUT /api/system/tls/domain` 保存协议、域名、端口;重启后 `tlsservice.Status` 为页面、身份服务和 Nginx 入口提供公网地址。Nginx 依协议生成 HTTP 或 HTTPS 站点;证书上传先写私有版本目录,再原子切换 `current` 链接并尝试重载,失败恢复前一版本及来源标记。生产部署只替换应用程序与前端资源,不自动调用入口切换接口。 + +## 4. 接口、数据与配置契约 + +- 管理接口:`PUT /api/system/tls/domain` 增加 `scheme` 与 `port`;证书上传 `POST /api/system/tls/certificate` 接收 `host`, `certificate_pem`, `private_key_pem`。认证边界沿用 `backend/internal/api/api.go`。 +- 数据:新增 `data/tls-scheme`, `data/tls-port`, `data/tls-cert-source` 及 `data/tls/certs//current`,无数据库迁移;旧文件缺失时采用 HTTPS:443。 +- 环境变量 `VPN_TLS_*` 优先;身份发行者遵循公网地址。页面不回显私钥。 + +## 5. 风险与验收 + +| 风险或失败场景 | 防护 / 恢复 | 可观察信号 | +|---|---|---| +| 无效域名、占用 80 或内部端口 | 保存或渲染时拒绝 | API 错误及预览失败 | +| 证书不匹配、过期、Nginx 重载失败 | 拒绝上传或回滚原证书及来源 | API 错误、旧证书仍可用 | +| 生产重启失败 | 恢复 `/tmp` 中旧二进制 | 健康检查、进程及 Nginx 检查 | +| HTTP 传输凭据明文 | 默认保留 HTTPS:443;仅管理员显式配置 HTTP | 状态协议和公网探测 | + +| AC | 对应需求 | 前置条件与操作 | 预期结果 | 验证方式 | 结果 | +|---|---|---|---|---|---| +| AC-01 | FR-01 | 旧配置及新协议端口 | 状态、页面和预览一致,旧配置为 HTTPS | Go 单测、前端构建 | 待验证 | +| AC-02 | FR-02, NFR-01 | 渲染 HTTP/HTTPS、冲突端口 | 白名单与 HTTP-01 正确,冲突被拒绝 | Go 单测、线上 nginx -t | 待验证 | +| AC-03 | FR-03 | 使用 HTTP/HTTPS 公网地址 | 发行者与回调地址一致 | Go 单测、HTTPS discovery | 待验证 | +| AC-04 | FR-04, NFR-01 | 上传匹配、不匹配和刷新失败证书 | 正常切换,异常保留旧证书与来源 | Go 单测 | 待验证 | +| AC-05 | FR-01, FR-02 | 部署后请求生产入口 | 仍为 HTTPS,健康检查成功,VPN 进程存活 | curl、nginx -t、进程检查 | 待验证 | + +## 6. 发布与恢复 + +本地测试和构建通过后,仅提交本次相关文件,推送 `origin/master`;服务器确认工作树干净并快进拉取。备份二进制与数据库到 `/tmp/opsx-vpn-backups`,仅保留最近两份。先构建前端再构建内嵌资源的 Go 二进制,核对 PID 后停止并替换管理进程,保留 OpenVPN 进程和 `data/`。检查本地及公网健康、HTTPS 证书与发现文档、Nginx 语法和 VPN 进程。失败时恢复旧二进制并重启;不改动已有证书或数据库。无计划内不可逆操作。 + +## 7. 待决问题 + +| 问题 | 是否阻塞 | 推荐方案 | +|---|---|---| +| 是否需要将 :18080 收敛为仅回环 | 不阻塞本次发布 | 单独评估客户端迁移与防火墙后执行 |