feat(tls): manage ACME domain and add desktop client settings
This commit is contained in:
1 parent
003f755b37
commit
00f6c876cf
85 files changed
+7276
-1
No files matched your search
@@ -284,6 +284,8 @@ func (s *Server) RegisterRoutes(r *gin.Engine) {
|
||||
// 主机环境体检与一键初始化(仅 admin)
|
||||
admin.GET("/system/env", s.systemEnv)
|
||||
admin.POST("/system/fix", s.systemFix)
|
||||
admin.GET("/system/tls", s.tlsStatus)
|
||||
admin.PUT("/system/tls/domain", s.setTLSDomain)
|
||||
admin.GET("/users", s.listUsers)
|
||||
admin.POST("/users", s.createUser)
|
||||
admin.PUT("/users/:id", s.updateUser)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
"vpn-platform/backend/internal/tlsservice"
|
||||
)
|
||||
|
||||
func (s *Server) tlsStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, tlsservice.Status())
|
||||
}
|
||||
|
||||
func (s *Server) setTLSDomain(c *gin.Context) {
|
||||
var req struct {
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请输入域名"})
|
||||
return
|
||||
}
|
||||
if err := tlsservice.ConfigureDomain(service.BaseDir(), req.Domain); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
store.Audit(c.GetString("username"), "tls_domain_configured", req.Domain, "需重启后生效")
|
||||
status := tlsservice.Status()
|
||||
c.JSON(http.StatusOK, gin.H{"status": status, "restart_required": status.RestartRequired})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestTLSStatusRequiresAdmin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/system/tls", func(c *gin.Context) {
|
||||
c.Set("role", c.GetHeader("X-Test-Role"))
|
||||
c.Next()
|
||||
}, requireRole("admin"), (&Server{}).tlsStatus)
|
||||
|
||||
for _, role := range []string{"", "viewer", "operator"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/system/tls", nil)
|
||||
req.Header.Set("X-Test-Role", role)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusForbidden {
|
||||
t.Errorf("role %q received HTTP %d", role, resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/system/tls", nil)
|
||||
req.Header.Set("X-Test-Role", "admin")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("admin received HTTP %d", resp.Code)
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := data["configured_domain"]; !ok {
|
||||
t.Fatal("admin response missing configured domain")
|
||||
}
|
||||
for _, secret := range []string{"private_key", "account_key", "certificate_pem"} {
|
||||
if _, ok := data[secret]; ok {
|
||||
t.Errorf("response exposes %s", secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package tlsservice
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
var domainPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`)
|
||||
|
||||
type configuration struct {
|
||||
domain string
|
||||
dataDir string
|
||||
cacheDir string
|
||||
httpAddr string
|
||||
httpsAddr string
|
||||
}
|
||||
|
||||
var state struct {
|
||||
sync.RWMutex
|
||||
config configuration
|
||||
}
|
||||
|
||||
type CertificateStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func ValidDomain(domain string) bool {
|
||||
return len(domain) <= 253 && domainPattern.MatchString(domain) && net.ParseIP(domain) == nil
|
||||
}
|
||||
|
||||
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) {
|
||||
return configuration{}, fmt.Errorf("读取 TLS 域名配置: %w", err)
|
||||
}
|
||||
}
|
||||
if domain != "" && !ValidDomain(domain) {
|
||||
return configuration{}, fmt.Errorf("VPN_TLS_DOMAIN 不是有效的单个完整域名")
|
||||
}
|
||||
httpAddr := os.Getenv("VPN_TLS_HTTP_ADDR")
|
||||
if httpAddr == "" {
|
||||
httpAddr = ":80"
|
||||
}
|
||||
httpsAddr := os.Getenv("VPN_TLS_HTTPS_ADDR")
|
||||
if httpsAddr == "" {
|
||||
httpsAddr = ":443"
|
||||
}
|
||||
return configuration{domain: domain, 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 环境变量管理域名,请修改启动配置")
|
||||
}
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if !ValidDomain(domain) {
|
||||
return fmt.Errorf("请输入单个有效的完整域名")
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
temp, err := os.CreateTemp(dataDir, ".tls-domain-*")
|
||||
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(domain + "\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-domain"))
|
||||
}
|
||||
|
||||
// Start keeps certificates in the persistent data directory. Issuance occurs
|
||||
// on the first TLS handshake, and autocert handles renewal.
|
||||
func Start(handler http.Handler, dataDir string) error {
|
||||
config, err := configFromEnvironment(dataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.domain == "" {
|
||||
state.Lock()
|
||||
state.config = config
|
||||
state.Unlock()
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(config.cacheDir, 0o700); err != nil {
|
||||
return fmt.Errorf("建立 ACME 缓存目录: %w", err)
|
||||
}
|
||||
manager := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(config.domain),
|
||||
Cache: autocert.DirCache(config.cacheDir),
|
||||
}
|
||||
httpListener, err := net.Listen("tcp", config.httpAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("监听 HTTP-01 端口 %s: %w", config.httpAddr, err)
|
||||
}
|
||||
httpsListener, err := net.Listen("tcp", config.httpsAddr)
|
||||
if err != nil {
|
||||
httpListener.Close()
|
||||
return fmt.Errorf("监听 HTTPS 端口 %s: %w", config.httpsAddr, err)
|
||||
}
|
||||
redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Host != config.domain && r.Host != config.domain+":80" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "Use HTTPS", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "https://"+config.domain+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}
|
||||
state.Lock()
|
||||
state.config = config
|
||||
state.Unlock()
|
||||
go func() {
|
||||
if err := httpServer.Serve(httpListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Printf("HTTP-01 监听失败: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
if err := httpsServer.Serve(tls.NewListener(httpsListener, httpsServer.TLSConfig)); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Printf("HTTPS 监听失败: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Printf("HTTPS/ACME 已启用:%s,HTTP %s,HTTPS %s", config.domain, config.httpAddr, config.httpsAddr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Status() CertificateStatus {
|
||||
state.RLock()
|
||||
config := state.config
|
||||
state.RUnlock()
|
||||
status := CertificateStatus{Enabled: config.domain != "", Domain: config.domain, DNS: []string{}, HTTPAddr: config.httpAddr, HTTPSAddr: config.httpsAddr, Renewal: "autocert"}
|
||||
if desired, err := configFromEnvironment(config.dataDir); err == nil {
|
||||
status.ConfiguredDomain = desired.domain
|
||||
status.RestartRequired = desired.domain != config.domain
|
||||
}
|
||||
if !status.Enabled {
|
||||
return status
|
||||
}
|
||||
if ips, err := net.LookupIP(config.domain); err == nil {
|
||||
for _, ip := range ips {
|
||||
status.DNS = append(status.DNS, ip.String())
|
||||
}
|
||||
}
|
||||
// autocert caches a private key PEM followed by a certificate chain.
|
||||
// Parse only the public leaf metadata; never return private key material.
|
||||
data, err := os.ReadFile(filepath.Join(config.cacheDir, config.domain))
|
||||
if err != nil {
|
||||
return status
|
||||
}
|
||||
_, data = pem.Decode(data)
|
||||
for len(data) > 0 {
|
||||
block, rest := pem.Decode(data)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
data = rest
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil || cert.VerifyHostname(config.domain) != nil {
|
||||
break
|
||||
}
|
||||
status.Cached = true
|
||||
status.Issuer = cert.Issuer.CommonName
|
||||
status.NotBefore = cert.NotBefore
|
||||
status.NotAfter = cert.NotAfter
|
||||
status.Valid = time.Now().After(cert.NotBefore) && time.Now().Before(cert.NotAfter)
|
||||
break
|
||||
}
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package tlsservice
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDomainValidationAndRestart(t *testing.T) {
|
||||
for _, domain := range []string{"test-link.appx365.com", "a.b"} {
|
||||
if !ValidDomain(domain) {
|
||||
t.Errorf("valid domain rejected: %s", domain)
|
||||
}
|
||||
}
|
||||
for _, domain := range []string{"", "localhost", "127.0.0.1", "-a.example.com", "a..example.com", "a.example.com:80", "*.example.com", "a.example.com/evil"} {
|
||||
if ValidDomain(domain) {
|
||||
t.Errorf("invalid domain accepted: %s", domain)
|
||||
}
|
||||
}
|
||||
|
||||
t.Setenv("VPN_TLS_DOMAIN", "")
|
||||
dir := t.TempDir()
|
||||
if err := Start(http.NotFoundHandler(), dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ConfigureDomain(dir, "Test-Link.Appx365.Com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status := Status()
|
||||
if status.Enabled || status.Domain != "" || status.ConfiguredDomain != "test-link.appx365.com" || !status.RestartRequired {
|
||||
t.Fatalf("saved domain must wait for restart: %+v", status)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dir, "tls-domain"))
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("domain config permissions: %v, %v", info, err)
|
||||
}
|
||||
t.Setenv("VPN_TLS_DOMAIN", "other.example.com")
|
||||
if err := ConfigureDomain(dir, "test-link.appx365.com"); err == nil {
|
||||
t.Fatal("environment-managed domain must not be overwritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedHTTPSBindReleasesHTTPPort(t *testing.T) {
|
||||
t.Setenv("VPN_TLS_DOMAIN", "test-link.appx365.com")
|
||||
occupied, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer occupied.Close()
|
||||
httpPort, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpAddr := httpPort.Addr().String()
|
||||
httpPort.Close()
|
||||
t.Setenv("VPN_TLS_HTTP_ADDR", httpAddr)
|
||||
t.Setenv("VPN_TLS_HTTPS_ADDR", occupied.Addr().String())
|
||||
if err := Start(http.NotFoundHandler(), t.TempDir()); err == nil || !strings.Contains(err.Error(), "HTTPS") {
|
||||
t.Fatalf("expected HTTPS port conflict: %v", err)
|
||||
}
|
||||
rebound, err := net.Listen("tcp", httpAddr)
|
||||
if err != nil {
|
||||
t.Fatalf("HTTP listener leaked after HTTPS bind failed: %v", err)
|
||||
}
|
||||
rebound.Close()
|
||||
if Status().Enabled {
|
||||
t.Fatal("failed start reported active TLS")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user