feat: configure HTTP/HTTPS public entry and managed certificates
This commit is contained in:
1 parent
35596cda9d
commit
88ea6b7854
21 files changed
+1309
-149
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+88
-11
@@ -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()})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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") == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user