feat: configure HTTP/HTTPS public entry and managed certificates

This commit is contained in:
Codex committed 2026-10-26 14:06:41 +08:00
1 parent 35596cda9d
commit 88ea6b7854
21 files changed
+1309 -149

No files matched your search

+1
View File
@@ -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)
+40 -10
View File
@@ -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 {
+15 -2
View File
@@ -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
View File
@@ -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)
}
}