mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
优化
优化
This commit is contained in:
@@ -49,6 +49,17 @@ type AppConfig struct {
|
||||
Env string `mapstructure:"env"`
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
WebDir string `mapstructure:"web_dir"`
|
||||
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
|
||||
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
|
||||
HTTPSEnabled bool `mapstructure:"https_enabled"`
|
||||
// SSLCert 是 PEM 编码的 SSL 证书内容。
|
||||
SSLCert string `mapstructure:"ssl_cert"`
|
||||
// SSLKey 是 PEM 编码的 SSL 私钥内容。
|
||||
SSLKey string `mapstructure:"ssl_key"`
|
||||
// SSLCertPath 是 SSL 证书文件路径;非空时优先于 SSLCert 从文件读取。
|
||||
SSLCertPath string `mapstructure:"ssl_cert_path"`
|
||||
// SSLKeyPath 是 SSL 私钥文件路径;非空时优先于 SSLKey 从文件读取。
|
||||
SSLKeyPath string `mapstructure:"ssl_key_path"`
|
||||
FFmpegPath string `mapstructure:"ffmpeg_path"`
|
||||
FFprobePath string `mapstructure:"ffprobe_path"`
|
||||
// FFprobeMaxConcurrent limits concurrent ffprobe/ffmpeg metadata probes.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
@@ -50,6 +51,10 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
_ = svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.User{}).Where("hide_adult = ?", false).Update("hide_adult", true).Error
|
||||
}
|
||||
service.ApplyRuntimeSetting(svc.Cfg, req.Key, req.Value)
|
||||
if err := applyHTTPSetting(svc, req.Key, req.Value); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if svc.FFprobe != nil && (req.Key == "ffprobe.max_concurrent" || req.Key == "app.ffprobe_max_concurrent") {
|
||||
svc.FFprobe.SetMaxConcurrent(svc.Cfg.App.FFprobeMaxConcurrent)
|
||||
}
|
||||
@@ -63,6 +68,83 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// applyHTTPSetting 校验 HTTPS 相关设置,并在可行时热重载监听。
|
||||
// 必须在 ApplyRuntimeSetting 之后调用,这样 svc.Cfg 已反映刚保存的值。
|
||||
//
|
||||
// 规则:
|
||||
// - https.enabled=true 时强制要求证书与私钥都已配置(内容或路径均可)且匹配,
|
||||
// 否则返回错误("如果启用就必须配置 SSL 证书和密钥");
|
||||
// - 证书/私钥(内容或路径)单独保存时只校验格式;若 HTTPS 已开启且新的整体
|
||||
// 配置可解析匹配才触发重载,避免"只存了新证书、私钥还没保存"时用旧私钥带
|
||||
// 新证书对外提供服务。
|
||||
func applyHTTPSetting(svc *service.Container, key, value string) error {
|
||||
skipReload := func(reason string) {
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("https setting saved but not applied yet", zap.String("key", key), zap.String("reason", reason))
|
||||
}
|
||||
}
|
||||
switch key {
|
||||
case "https.enabled":
|
||||
if svc.Cfg.App.HTTPSEnabled {
|
||||
if _, err := service.ResolveSSLKeyPair(svc.Cfg.App.SSLCert, svc.Cfg.App.SSLCertPath, svc.Cfg.App.SSLKey, svc.Cfg.App.SSLKeyPath); err != nil {
|
||||
return fmt.Errorf("启用 HTTPS 失败:%v", err)
|
||||
}
|
||||
}
|
||||
case "https.cert", "https.cert_path", "https.key", "https.key_path":
|
||||
if err := validateSSLMaterialSource(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
if !svc.Cfg.App.HTTPSEnabled {
|
||||
return nil
|
||||
}
|
||||
if !httpsPairReady(svc) {
|
||||
skipReload("证书与私钥尚未匹配,等待另一半保存后生效")
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if svc.ReloadHTTPServer != nil {
|
||||
return svc.ReloadHTTPServer()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSSLMaterialSource 校验刚保存的证书/私钥来源(内容或路径)本身格式合法。
|
||||
func validateSSLMaterialSource(key, value string) error {
|
||||
switch key {
|
||||
case "https.cert":
|
||||
return service.ValidateSSLCert(value)
|
||||
case "https.cert_path":
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil // 清空路径也允许,启用时由整体校验把关
|
||||
}
|
||||
pemStr, err := service.ResolveSSLMaterial("", value, "证书")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return service.ValidateSSLCert(pemStr)
|
||||
case "https.key":
|
||||
return service.ValidateSSLKey(value)
|
||||
case "https.key_path":
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
pemStr, err := service.ResolveSSLMaterial("", value, "私钥")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return service.ValidateSSLKey(pemStr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpsPairReady 判断基于当前配置解析出的证书/私钥是否完整且匹配。
|
||||
func httpsPairReady(svc *service.Container) bool {
|
||||
_, err := service.ResolveSSLKeyPair(svc.Cfg.App.SSLCert, svc.Cfg.App.SSLCertPath, svc.Cfg.App.SSLKey, svc.Cfg.App.SSLKeyPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
type testAdultScraperReq struct {
|
||||
Engine string `json:"engine"`
|
||||
ServerURL string `json:"server_url"`
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResolveSSLMaterial 解析一份 SSL 材料(证书或私钥)的 PEM 内容:
|
||||
// 优先读取 path 指向的文件,其次使用内容;两者都为空时返回错误。
|
||||
// what 用于错误提示("证书" / "私钥")。
|
||||
func ResolveSSLMaterial(content, path, what string) (string, error) {
|
||||
p := strings.TrimSpace(path)
|
||||
if p != "" {
|
||||
if info, err := os.Stat(p); err != nil {
|
||||
return "", fmt.Errorf("SSL %s文件不可访问:%s(%v)", what, p, err)
|
||||
} else if info.IsDir() {
|
||||
return "", fmt.Errorf("SSL %s路径指向的是目录,请填写文件路径:%s", what, p)
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取 SSL %s文件失败:%s(%v)", what, p, err)
|
||||
}
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
c := strings.TrimSpace(content)
|
||||
if c == "" {
|
||||
return "", fmt.Errorf("SSL %s未配置:请填写内容或文件路径", what)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ResolveSSLKeyPair 解析证书与私钥(各自支持 内容或路径),校验格式与匹配后
|
||||
// 返回可用的 tls.Certificate。任何一步失败都会给出明确错误。
|
||||
func ResolveSSLKeyPair(certContent, certPath, keyContent, keyPath string) (*tls.Certificate, error) {
|
||||
certPEM, err := ResolveSSLMaterial(certContent, certPath, "证书")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyPEM, err := ResolveSSLMaterial(keyContent, keyPath, "私钥")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSL 证书/私钥无效:%v", err)
|
||||
}
|
||||
if !sslKeyMatchesCert(cert) {
|
||||
return nil, errors.New("SSL 证书与私钥不匹配")
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// PathFingerprint 返回文件路径的内容指纹(路径 + 大小 + 修改时间),用于检测
|
||||
// 文件是否被替换过;文件不存在时返回 ("", false)。
|
||||
func PathFingerprint(p string) (string, bool) {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "", false
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("path:%s|size:%d|mtime:%d", filepath.Clean(p), info.Size(), info.ModTime().UnixNano()), true
|
||||
}
|
||||
|
||||
// ValidateSSLCert 校验 s 是一个可解析的 PEM 编码 X.509 证书。
|
||||
func ValidateSSLCert(s string) error {
|
||||
block, _ := pem.Decode([]byte(strings.TrimSpace(s)))
|
||||
if block == nil {
|
||||
return errors.New("SSL 证书格式无效:未找到 PEM 数据")
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return fmt.Errorf("SSL 证书格式无效:期望 CERTIFICATE,实际为 %s", block.Type)
|
||||
}
|
||||
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
|
||||
return fmt.Errorf("SSL 证书解析失败:%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSSLKey 校验 s 是一个可解析的 PEM 编码私钥。
|
||||
func ValidateSSLKey(s string) error {
|
||||
block, _ := pem.Decode([]byte(strings.TrimSpace(s)))
|
||||
if block == nil {
|
||||
return errors.New("SSL 私钥格式无效:未找到 PEM 数据")
|
||||
}
|
||||
if _, err := parsePrivateKeyBlock(block); err != nil {
|
||||
return fmt.Errorf("SSL 私钥解析失败:%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSSLKeyPair 校验证书与私钥都存在、可解析且相互匹配。
|
||||
func ValidateSSLKeyPair(certPEM, keyPEM string) error {
|
||||
cert, err := tls.X509KeyPair([]byte(strings.TrimSpace(certPEM)), []byte(strings.TrimSpace(keyPEM)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSL 证书/私钥无效:%v", err)
|
||||
}
|
||||
if !sslKeyMatchesCert(cert) {
|
||||
return errors.New("SSL 证书与私钥不匹配")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sslKeyMatchesCert 通过公钥是否一致来判断私钥确实对应证书。
|
||||
func sslKeyMatchesCert(cert tls.Certificate) bool {
|
||||
if len(cert.Certificate) == 0 || cert.PrivateKey == nil {
|
||||
return false
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
privPub := publicKeyOf(cert.PrivateKey)
|
||||
if privPub == nil {
|
||||
return false
|
||||
}
|
||||
eq, ok := leaf.PublicKey.(interface {
|
||||
Equal(x crypto.PublicKey) bool
|
||||
})
|
||||
return ok && eq.Equal(privPub)
|
||||
}
|
||||
|
||||
// publicKeyOf 从各类私钥中提取对应的公钥。
|
||||
func publicKeyOf(priv crypto.PrivateKey) crypto.PublicKey {
|
||||
switch k := priv.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
return &k.PublicKey
|
||||
case *ecdsa.PrivateKey:
|
||||
return &k.PublicKey
|
||||
case ed25519.PrivateKey:
|
||||
return k.Public()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePrivateKeyBlock 支持 PKCS#8 / PKCS#1 RSA / EC 三种常见私钥格式。
|
||||
func parsePrivateKeyBlock(block *pem.Block) (crypto.PrivateKey, error) {
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
return nil, errors.New("无法解析私钥(支持 PKCS#8 / PKCS#1 RSA / EC)")
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func makeTestKeyPair(t *testing.T) (certPEM, keyPEM string) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPEM = strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})))
|
||||
keyPEM = strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})))
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
func TestResolveSSLMaterial(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPEM, _ := makeTestKeyPair(t)
|
||||
path := filepath.Join(dir, "cert.pem")
|
||||
if err := os.WriteFile(path, []byte(certPEM), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
path string
|
||||
want string
|
||||
err bool
|
||||
}{
|
||||
{name: "content only", content: certPEM, want: certPEM},
|
||||
{name: "path only", path: path, want: certPEM},
|
||||
{name: "path wins over content", content: "bogus", path: path, want: certPEM},
|
||||
{name: "both empty", err: true},
|
||||
{name: "missing file", path: filepath.Join(dir, "missing.pem"), err: true},
|
||||
{name: "path is dir", path: dir, err: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := ResolveSSLMaterial(tc.content, tc.path, "证书")
|
||||
if tc.err {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSSLKeyPair(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPEM, keyPEM := makeTestKeyPair(t)
|
||||
certPath := filepath.Join(dir, "cert.pem")
|
||||
keyPath := filepath.Join(dir, "key.pem")
|
||||
if err := os.WriteFile(certPath, []byte(certPEM), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, []byte(keyPEM), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ResolveSSLKeyPair(certPEM, "", keyPEM, ""); err != nil {
|
||||
t.Fatalf("content pair: %v", err)
|
||||
}
|
||||
if _, err := ResolveSSLKeyPair("", certPath, "", keyPath); err != nil {
|
||||
t.Fatalf("path pair: %v", err)
|
||||
}
|
||||
if _, err := ResolveSSLKeyPair(certPEM, "", "", keyPath); err != nil {
|
||||
t.Fatalf("mixed pair: %v", err)
|
||||
}
|
||||
|
||||
otherCert, _ := makeTestKeyPair(t)
|
||||
if _, err := ResolveSSLKeyPair(otherCert, "", keyPEM, ""); err == nil {
|
||||
t.Fatal("expected mismatch error")
|
||||
}
|
||||
|
||||
if got, ok := PathFingerprint(certPath); !ok || got == "" {
|
||||
t.Fatalf("PathFingerprint failed: got=%q ok=%v", got, ok)
|
||||
}
|
||||
if _, ok := PathFingerprint(filepath.Join(dir, "missing.pem")); ok {
|
||||
t.Fatal("PathFingerprint should report missing file")
|
||||
}
|
||||
if _, ok := PathFingerprint(" "); ok {
|
||||
t.Fatal("empty PathFingerprint should not be ok")
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,16 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
|
||||
}
|
||||
case "transcode.video_bitrate", "transcoder.video_bitrate":
|
||||
cfg.Transcoder.VideoBitrate = value
|
||||
case "https.enabled":
|
||||
cfg.App.HTTPSEnabled = parseBoolSetting(value, false)
|
||||
case "https.cert":
|
||||
cfg.App.SSLCert = value
|
||||
case "https.key":
|
||||
cfg.App.SSLKey = value
|
||||
case "https.cert_path":
|
||||
cfg.App.SSLCertPath = strings.TrimSpace(value)
|
||||
case "https.key_path":
|
||||
cfg.App.SSLKeyPath = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,10 @@ type Container struct {
|
||||
|
||||
stopCtx context.Context
|
||||
stopCancel context.CancelFunc
|
||||
|
||||
// ReloadHTTPServer 由 cmd/server 注入。HTTPS 相关设置保存后,handler
|
||||
// 会调用它把 HTTP/HTTPS 监听热切换到最新配置;nil 表示未注入(测试环境)。
|
||||
ReloadHTTPServer func() error
|
||||
}
|
||||
|
||||
// New 构建服务容器。
|
||||
|
||||
Reference in New Issue
Block a user