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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user