mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-31 02:01:44 +08:00
feat: add local CA certificate management for the console
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"SamWaf/common/zlog"
|
||||
"SamWaf/global"
|
||||
"SamWaf/model/common/response"
|
||||
"SamWaf/utils"
|
||||
"SamWaf/utils/localca"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 管理端本地证书:用本机长效 CA 给管理端签一张服务器证书。
|
||||
//
|
||||
// 面向"没有域名、或有域名但不想走 ACME"的部署。有域名的用户更推荐 DNS-01 + 证书夹绑定
|
||||
// (不开 80 端口、不公网暴露、自动续期),本接口是那条路走不通时的兜底。
|
||||
//
|
||||
// 权限与上传证书同级(系统管理员),因为落地效果一样:都会覆盖管理端的证书文件。
|
||||
|
||||
// maxSANItems 单张证书里的访问地址数量上限。放开没有意义,还给了个把证书撑大的口子。
|
||||
const maxSANItems = 32
|
||||
|
||||
// GenerateLocalCertApi 生成(或重新签发)管理端本地证书
|
||||
func (w *WafVpConfigApi) GenerateLocalCertApi(c *gin.Context) {
|
||||
var req struct {
|
||||
// SANs 用户实际用来访问管理端的地址,逗号分隔:域名、内网IP、公网IP 可混填。
|
||||
// 现代浏览器只认 SAN 不认 CN,漏掉任何一个访问方式,用那个方式访问就会报错。
|
||||
SANs string `json:"sans"`
|
||||
// ValidDays 留空用默认值;上限由 localca 兜(Apple 平台 825 天硬限制)
|
||||
ValidDays int `json:"valid_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage("解析请求失败", c)
|
||||
return
|
||||
}
|
||||
|
||||
items := splitSANInput(req.SANs)
|
||||
if len(items) == 0 {
|
||||
response.FailWithMessage("请至少填写一个访问地址(域名或IP)", c)
|
||||
return
|
||||
}
|
||||
if len(items) > maxSANItems {
|
||||
response.FailWithMessage("访问地址过多,最多支持 32 个", c)
|
||||
return
|
||||
}
|
||||
|
||||
paths := localca.DefaultPaths(utils.GetCurrentDir())
|
||||
summary, err := localca.IssueServerCert(paths, items, req.ValidDays)
|
||||
if err != nil {
|
||||
response.FailWithMessage("生成本地证书失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
zlog.Info("已生成管理端本地证书", "subject", summary.Subject, "not_after", summary.NotAfter)
|
||||
|
||||
response.OkWithDetailed(gin.H{
|
||||
"cert": summary,
|
||||
"ca": localca.CASummary(paths),
|
||||
"sans": localca.SANsOf(summary),
|
||||
"message": "生成成功,需要启用SSL并重启管理端生效;浏览器信任需要导入根证书",
|
||||
}, "生成本地证书成功", c)
|
||||
}
|
||||
|
||||
// RotateLocalCaApi 作废当前本地 CA 并用新 CA 重新签发管理端证书。
|
||||
//
|
||||
// 自建 CA 没有 CRL/OCSP,"吊销"实际就是两步:服务端换一把 CA(本接口),
|
||||
// 客户端把旧根证书从信任库里删掉(只能由管理员在自己电脑上做)。
|
||||
// 这是破坏性操作:所有导入过旧根证书的电脑都会立刻报"不安全",必须重新导入新的。
|
||||
func (w *WafVpConfigApi) RotateLocalCaApi(c *gin.Context) {
|
||||
var req struct {
|
||||
SANs string `json:"sans"`
|
||||
ValidDays int `json:"valid_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage("解析请求失败", c)
|
||||
return
|
||||
}
|
||||
|
||||
items := splitSANInput(req.SANs)
|
||||
if len(items) > maxSANItems {
|
||||
response.FailWithMessage("访问地址过多,最多支持 32 个", c)
|
||||
return
|
||||
}
|
||||
|
||||
paths := localca.DefaultPaths(utils.GetCurrentDir())
|
||||
summary, err := localca.RotateCA(paths, items, req.ValidDays)
|
||||
if err != nil {
|
||||
response.FailWithMessage("重建本地CA失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
zlog.Info("已重建管理端本地CA", "subject", summary.Subject, "not_after", summary.NotAfter)
|
||||
|
||||
response.OkWithDetailed(gin.H{
|
||||
"cert": summary,
|
||||
"ca": localca.CASummary(paths),
|
||||
"sans": localca.SANsOf(summary),
|
||||
"message": "已换用新的根证书。旧根证书随即失效,请在所有访问用的电脑上删除旧的并导入新的",
|
||||
}, "重建本地CA成功", c)
|
||||
}
|
||||
|
||||
// ClearLocalCertApi 删除本地 CA 与它签发的管理端证书。
|
||||
//
|
||||
// SSL 还开着时拒绝执行——证书文件被删掉后管理端重启会加载不到证书,HTTPS 直接起不来,
|
||||
// 那是个自己把自己锁在门外的操作,宁可让用户先显式关掉 SSL 或换到别的证书来源。
|
||||
func (w *WafVpConfigApi) ClearLocalCertApi(c *gin.Context) {
|
||||
paths := localca.DefaultPaths(utils.GetCurrentDir())
|
||||
|
||||
if global.GWAF_SSL_ENABLE && localca.IsIssuedByLocalCA(paths) {
|
||||
response.FailWithMessage("管理端正在使用本地证书,请先关闭SSL或改用其它证书来源,再执行清除", c)
|
||||
return
|
||||
}
|
||||
if err := localca.ClearLocal(paths); err != nil {
|
||||
response.FailWithMessage("清除本地证书失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
zlog.Info("已清除管理端本地CA与本地证书")
|
||||
response.OkWithMessage("已清除。请记得在导入过根证书的电脑上把它删掉", c)
|
||||
}
|
||||
|
||||
// GetLocalCertStatusApi 查询本地 CA 与当前管理端证书的状态
|
||||
func (w *WafVpConfigApi) GetLocalCertStatusApi(c *gin.Context) {
|
||||
paths := localca.DefaultPaths(utils.GetCurrentDir())
|
||||
current := localca.CurrentServerCert(paths)
|
||||
|
||||
// CA 是公钥证书、本身不含秘密,直接随状态回给前端,
|
||||
// 前端据此在浏览器本地存成 .crt 文件——省掉一个只为下载而开的路由,
|
||||
// 也避开"window.open 带不了鉴权头"这个坑(查询串取令牌只对下载日志那条路径生效)
|
||||
caPEM := ""
|
||||
if raw, err := localca.ReadCACert(paths); err == nil {
|
||||
caPEM = string(raw)
|
||||
}
|
||||
|
||||
response.OkWithDetailed(gin.H{
|
||||
"has_ca": localca.CASummary(paths) != nil,
|
||||
"ca": localca.CASummary(paths),
|
||||
"ca_pem": caPEM,
|
||||
// 当前管理端证书是不是本地 CA 签的:手工上传与 ACME 绑定的证书不该被这条路径接管
|
||||
"is_local": localca.IsIssuedByLocalCA(paths),
|
||||
"cert": current,
|
||||
"sans": localca.SANsOf(current),
|
||||
}, "获取本地证书状态成功", c)
|
||||
}
|
||||
|
||||
// splitSANInput 把用户填的一行地址拆开。换行、逗号、分号都当分隔符——
|
||||
// 用户从别处粘贴过来的多半带换行,不必强求格式。
|
||||
func splitSANInput(raw string) []string {
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == '\r' || r == ';' || r == ' ' || r == '\t'
|
||||
})
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if s := strings.TrimSpace(f); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -504,6 +504,7 @@ func (m *wafSystenService) run() {
|
||||
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_SSL_PATH_LOAD, waftask.SSLReload)
|
||||
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_BATCH, waftask.BatchTask)
|
||||
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_SSL_EXPIRE_CHECK, waftask.SSLExpireCheck)
|
||||
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_MANAGER_CERT_CHECK, waftask.ManagerCertCheck)
|
||||
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_NOTICE, waftask.TaskStatusNotify)
|
||||
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_HEALTH, waftask.TaskHealth)
|
||||
globalobj.GWAF_RUNTIME_OBJ_WAF_TaskRegistry.RegisterTask(enums.TASK_CLEAR_CC_WINDOWS, waftask.TaskCC)
|
||||
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
TASK_SSL_PATH_LOAD = "task_ssl_path_load" //SSL证书可自动加载路径下的证书
|
||||
TASK_BATCH = "task_batch" //批量任务
|
||||
TASK_SSL_EXPIRE_CHECK = "task_ssl_expire_check" //SSL证书到期检测
|
||||
TASK_MANAGER_CERT_CHECK = "task_manager_cert_check" //管理端证书到期检测与本地证书自动续期
|
||||
TASK_NOTICE = "task_notice" //通知信息
|
||||
TASK_HEALTH = "task_health" //健康检测
|
||||
TASK_CLEAR_CC_WINDOWS = "task_clear_cc_windows" //清除ccWindows记录
|
||||
|
||||
@@ -25,6 +25,7 @@ func (receiver *WafVpConfigRouter) InitWafVpConfigRouter(group *gin.RouterGroup)
|
||||
router.GET("/api/v1/vipconfig/getDomainWhitelist", wafVpConfigApi.GetDomainWhitelistApi)
|
||||
router.GET("/api/v1/vipconfig/getSslForceHttps", wafVpConfigApi.GetSslForceHttpsApi)
|
||||
router.GET("/api/v1/vipconfig/getSslBindCert", wafVpConfigApi.GetSslBindCertApi)
|
||||
router.GET("/api/v1/vipconfig/localCertStatus", wafVpConfigApi.GetLocalCertStatusApi)
|
||||
|
||||
// N7:写接口仅系统管理员(或超管)可操作。这些是系统级访问控制/证书/重启等高危配置,
|
||||
// 尤其含 P0-3 的 IP 白名单与可信代理网段——低权限角色(审计/安全)不得篡改。
|
||||
@@ -38,6 +39,11 @@ func (receiver *WafVpConfigRouter) InitWafVpConfigRouter(group *gin.RouterGroup)
|
||||
writeRouter.POST("/api/v1/vipconfig/updateCorsAllowOrigins", wafVpConfigApi.UpdateCorsAllowOriginsApi)
|
||||
writeRouter.POST("/api/v1/vipconfig/updateSslEnable", wafVpConfigApi.UpdateSslEnableApi)
|
||||
writeRouter.POST("/api/v1/vipconfig/uploadSslCert", wafVpConfigApi.UploadSslCertApi)
|
||||
// 本地证书:与上传证书同权限——落地效果一样(都会覆盖管理端证书文件)
|
||||
writeRouter.POST("/api/v1/vipconfig/generateLocalCert", wafVpConfigApi.GenerateLocalCertApi)
|
||||
// 重建CA与清除属破坏性操作,权限同上(都会覆盖/删除管理端证书文件)
|
||||
writeRouter.POST("/api/v1/vipconfig/rotateLocalCa", wafVpConfigApi.RotateLocalCaApi)
|
||||
writeRouter.POST("/api/v1/vipconfig/clearLocalCert", wafVpConfigApi.ClearLocalCertApi)
|
||||
writeRouter.POST("/api/v1/vipconfig/restartManager", wafVpConfigApi.RestartManagerApi)
|
||||
writeRouter.POST("/api/v1/vipconfig/updateSecurityEntry", wafVpConfigApi.UpdateSecurityEntryApi)
|
||||
writeRouter.POST("/api/v1/vipconfig/updateNoticeTitle", wafVpConfigApi.UpdateNoticeTitleApi)
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
package localca
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 管理端本地证书签发。
|
||||
//
|
||||
// 面向"没有域名、或有域名但不走 ACME"的部署:本机生成一个长效 CA,用它签发管理端
|
||||
// 服务器证书。管理员把 CA 根证书导入自己电脑一次,之后换 SAN、续期都只重签服务器证书,
|
||||
// 信任关系不用重新建立——这也是不用单张裸自签的原因。
|
||||
//
|
||||
// 为什么自己用标准库签而不是集成 mkcert 之类的工具:签证书本身 crypto/x509 就够了,
|
||||
// 而那类工具真正值钱的是"自动把根证书装进系统信任库"——SamWaf 跑在服务器上、管理员
|
||||
// 用自己的电脑访问,在服务器上装信任库对访问方毫无用处,那部分能力在这个部署形态下拿不到。
|
||||
//
|
||||
// 密钥落盘口径与 wafsec 的 DEK 一致:CA 私钥 0600、不进数据库、不经 API 回显。
|
||||
|
||||
const (
|
||||
// caValidYears CA 自身长效:换 SAN / 续期都不动它,用户只需导入一次
|
||||
caValidYears = 10
|
||||
// serverCertValidDays 服务器证书有效期。**不得超过 825 天**——
|
||||
// Apple 平台对任意 CA 签发的 TLS 证书都卡这个上限,超了 Safari/iOS 直接拒绝。
|
||||
serverCertValidDays = 397
|
||||
// maxServerCertValidDays 硬上限,防止调用方传入更大的值
|
||||
maxServerCertValidDays = 825
|
||||
)
|
||||
|
||||
// Paths 描述本地 CA 与管理端证书涉及的文件位置
|
||||
type Paths struct {
|
||||
CAKey string // CA 私钥(0600,不下发)
|
||||
CACert string // CA 根证书(可下载给管理员导入)
|
||||
SrvCert string // 管理端服务器证书
|
||||
SrvKey string // 管理端服务器私钥
|
||||
}
|
||||
|
||||
// DefaultPaths 按安装目录给出默认路径。
|
||||
// CA 私钥与 wafsec 的密钥同放 data/.keys;证书是公开信息,放 data/ssl/manager。
|
||||
func DefaultPaths(baseDir string) Paths {
|
||||
return Paths{
|
||||
CAKey: filepath.Join(baseDir, "data", ".keys", "manager_ca_key"),
|
||||
CACert: filepath.Join(baseDir, "data", "ssl", "manager", "ca.crt"),
|
||||
SrvCert: filepath.Join(baseDir, "data", "ssl", "manager", "domain.crt"),
|
||||
SrvKey: filepath.Join(baseDir, "data", "ssl", "manager", "domain.key"),
|
||||
}
|
||||
}
|
||||
|
||||
// CertSummary 证书摘要,供接口回显(不含任何私钥内容)
|
||||
type CertSummary struct {
|
||||
Subject string `json:"subject"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
DNSNames []string `json:"dns_names"`
|
||||
IPs []string `json:"ips"`
|
||||
// Fingerprint 是证书 DER 的 SHA-256,冒号分隔大写十六进制——与 Windows 证书管理器、
|
||||
// macOS 钥匙串、Firefox 里显示的格式一致。换过 CA 之后信任库里会存在多张同名的
|
||||
// "SamWaf Local CA",只有指纹能区分该删哪张、留哪张,所以必须回显。
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
// Serial 十六进制序列号,Windows 证书管理器按它排序查找更快
|
||||
Serial string `json:"serial"`
|
||||
}
|
||||
|
||||
// EnsureCA 加载本地 CA;不存在则生成一个新的(10 年有效期)。
|
||||
func EnsureCA(p Paths) (*x509.Certificate, *ecdsa.PrivateKey, error) {
|
||||
cert, key, err := loadCA(p)
|
||||
if err == nil {
|
||||
return cert, key, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil, err
|
||||
}
|
||||
return createCA(p)
|
||||
}
|
||||
|
||||
func loadCA(p Paths) (*x509.Certificate, *ecdsa.PrivateKey, error) {
|
||||
certPEM, err := os.ReadFile(p.CACert)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
keyPEM, err := os.ReadFile(p.CAKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cert, err := parseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("解析本地CA证书失败: %w", err)
|
||||
}
|
||||
key, err := parseECKeyPEM(keyPEM)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("解析本地CA私钥失败: %w", err)
|
||||
}
|
||||
return cert, key, nil
|
||||
}
|
||||
|
||||
func createCA(p Paths) (*x509.Certificate, *ecdsa.PrivateKey, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("生成本地CA密钥失败: %w", err)
|
||||
}
|
||||
serial, err := randomSerial()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
// 名字里带上"本地"字样,管理员在系统信任库里一眼能认出这是哪来的
|
||||
CommonName: "SamWaf Local CA",
|
||||
Organization: []string{"SamWaf"},
|
||||
},
|
||||
NotBefore: now.Add(-5 * time.Minute), // 容忍轻微时钟偏差
|
||||
NotAfter: now.AddDate(caValidYears, 0, 0),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLenZero: true, // 只签叶子证书,不再签下级 CA
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("签发本地CA证书失败: %w", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err := writeKeyFile(p.CAKey, key); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := writePEMFile(p.CACert, "CERTIFICATE", der, 0644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return cert, key, nil
|
||||
}
|
||||
|
||||
// IssueServerCert 用本地 CA 给管理端签一张服务器证书,写入 SrvCert / SrvKey。
|
||||
// sans 是用户实际会用来访问管理端的名字:域名走 DNS SAN、IP 走 IP SAN。
|
||||
// 现代浏览器只认 SAN 不认 CN,所以漏掉任何一个访问方式都会直接报错。
|
||||
func IssueServerCert(p Paths, sans []string, validDays int) (*CertSummary, error) {
|
||||
dnsNames, ips, err := splitSANs(sans)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(dnsNames) == 0 && len(ips) == 0 {
|
||||
return nil, errors.New("至少需要一个访问地址(域名或IP)")
|
||||
}
|
||||
if validDays <= 0 {
|
||||
validDays = serverCertValidDays
|
||||
}
|
||||
if validDays > maxServerCertValidDays {
|
||||
return nil, fmt.Errorf("服务器证书有效期不得超过 %d 天(Apple 平台的硬性上限,超出会被 Safari/iOS 拒绝)", maxServerCertValidDays)
|
||||
}
|
||||
|
||||
caCert, caKey, err := EnsureCA(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成服务器证书密钥失败: %w", err)
|
||||
}
|
||||
serial, err := randomSerial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// CommonName 只是给人看的:现代浏览器完全不看它,校验全靠 SAN
|
||||
commonName := "SamWaf Manager"
|
||||
if len(dnsNames) > 0 {
|
||||
commonName = dnsNames[0]
|
||||
} else if len(ips) > 0 {
|
||||
commonName = ips[0].String()
|
||||
}
|
||||
now := time.Now()
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: commonName, Organization: []string{"SamWaf"}},
|
||||
NotBefore: now.Add(-5 * time.Minute),
|
||||
NotAfter: now.AddDate(0, 0, validDays),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: dnsNames,
|
||||
IPAddresses: ips,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, &key.PublicKey, caKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("签发管理端证书失败: %w", err)
|
||||
}
|
||||
|
||||
// 证书链里带上 CA:有些客户端不会主动去找签发者
|
||||
chain := append(pemBlock("CERTIFICATE", der), pemBlock("CERTIFICATE", caCert.Raw)...)
|
||||
if err := writeFileAtomic(p.SrvCert, chain, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeKeyFile(p.SrvKey, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summarize(cert), nil
|
||||
}
|
||||
|
||||
// RotateCA 作废当前本地 CA 并用新 CA 重新签发管理端证书。
|
||||
//
|
||||
// 用途:CA 私钥疑似泄露、或就是想换一把。**这是破坏性操作**——新 CA 与旧的没有任何关系,
|
||||
// 所有导入过旧根证书的电脑都会立刻对管理端报不安全,必须删旧的、导新的。
|
||||
// 自建 CA 没有 CRL/OCSP,所谓吊销实际就是这两步:服务端换 CA + 客户端删旧根证书。
|
||||
func RotateCA(p Paths, sans []string, validDays int) (*CertSummary, error) {
|
||||
if len(sans) == 0 {
|
||||
// 没显式给就沿用当前证书护住的那些地址,避免换完 CA 反而漏了访问方式
|
||||
sans = SANsOf(CurrentServerCert(p))
|
||||
}
|
||||
if len(sans) == 0 {
|
||||
return nil, errors.New("至少需要一个访问地址(域名或IP)")
|
||||
}
|
||||
// 先删旧 CA,IssueServerCert 里的 EnsureCA 就会生成新的
|
||||
if err := removeIfExists(p.CAKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := removeIfExists(p.CACert); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return IssueServerCert(p, sans, validDays)
|
||||
}
|
||||
|
||||
// ClearLocal 删除本地 CA 与它签发的管理端证书。
|
||||
//
|
||||
// 调用方必须先确认管理端没在用这张证书(SSL 已关或已切到别的来源),
|
||||
// 否则重启后加载不到证书文件,HTTPS 直接起不来。
|
||||
func ClearLocal(p Paths) error {
|
||||
// 只在确实是本地 CA 签的时候才动服务器证书——手工上传/ACME 的证书不归这里管
|
||||
if IsIssuedByLocalCA(p) {
|
||||
if err := removeIfExists(p.SrvCert); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeIfExists(p.SrvKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := removeIfExists(p.CAKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return removeIfExists(p.CACert)
|
||||
}
|
||||
|
||||
func removeIfExists(path string) error {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("删除 %s 失败: %w", filepath.Base(path), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CASummary 返回本地 CA 的摘要;CA 不存在返回 nil(不视为错误——用户可能压根没用这条路)
|
||||
func CASummary(p Paths) *CertSummary {
|
||||
certPEM, err := os.ReadFile(p.CACert)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
cert, err := parseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return summarize(cert)
|
||||
}
|
||||
|
||||
// ReadCACert 读取 CA 根证书 PEM,供下载给管理员导入
|
||||
func ReadCACert(p Paths) ([]byte, error) {
|
||||
return os.ReadFile(p.CACert)
|
||||
}
|
||||
|
||||
// IsIssuedByLocalCA 判断当前管理端证书是不是本地 CA 签的——
|
||||
// 续期任务据此决定要不要接管:手工上传的证书和 ACME 证书都不该被我们重签。
|
||||
func IsIssuedByLocalCA(p Paths) bool {
|
||||
caCertPEM, err := os.ReadFile(p.CACert)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
caCert, err := parseCertPEM(caCertPEM)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
srvPEM, err := os.ReadFile(p.SrvCert)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
srvCert, err := parseCertPEM(srvPEM)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// 比签发者与签名,避免只比名字被同名证书糊弄
|
||||
if srvCert.Issuer.CommonName != caCert.Subject.CommonName {
|
||||
return false
|
||||
}
|
||||
return srvCert.CheckSignatureFrom(caCert) == nil
|
||||
}
|
||||
|
||||
// CurrentServerCert 返回当前管理端证书的摘要(不存在返回 nil)
|
||||
func CurrentServerCert(p Paths) *CertSummary {
|
||||
pemBytes, err := os.ReadFile(p.SrvCert)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
cert, err := parseCertPEM(pemBytes)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return summarize(cert)
|
||||
}
|
||||
|
||||
// SANsOf 取出证书里的全部访问名,续期时原样沿用
|
||||
func SANsOf(s *CertSummary) []string {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(s.DNSNames)+len(s.IPs))
|
||||
out = append(out, s.DNSNames...)
|
||||
out = append(out, s.IPs...)
|
||||
return out
|
||||
}
|
||||
|
||||
// fingerprintOf 返回 SHA-256 指纹,格式与各平台证书管理器一致(冒号分隔大写十六进制)
|
||||
func fingerprintOf(cert *x509.Certificate) string {
|
||||
sum := sha256.Sum256(cert.Raw)
|
||||
parts := make([]string, 0, len(sum))
|
||||
for _, b := range sum {
|
||||
parts = append(parts, fmt.Sprintf("%02X", b))
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
func summarize(cert *x509.Certificate) *CertSummary {
|
||||
ips := make([]string, 0, len(cert.IPAddresses))
|
||||
for _, ip := range cert.IPAddresses {
|
||||
ips = append(ips, ip.String())
|
||||
}
|
||||
return &CertSummary{
|
||||
Subject: cert.Subject.CommonName,
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
DNSNames: cert.DNSNames,
|
||||
IPs: ips,
|
||||
Fingerprint: fingerprintOf(cert),
|
||||
Serial: strings.ToUpper(cert.SerialNumber.Text(16)),
|
||||
}
|
||||
}
|
||||
|
||||
// splitSANs 把用户填的一串访问地址分成 DNS 名与 IP 两类
|
||||
func splitSANs(sans []string) ([]string, []net.IP, error) {
|
||||
var dnsNames []string
|
||||
var ips []net.IP
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, raw := range sans {
|
||||
item := strings.TrimSpace(raw)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
// 容忍用户填 [::1] 或 https://host 这类形式
|
||||
item = strings.TrimPrefix(strings.TrimPrefix(item, "https://"), "http://")
|
||||
item = strings.TrimSuffix(item, "/")
|
||||
if h, _, err := net.SplitHostPort(item); err == nil && h != "" {
|
||||
item = h
|
||||
}
|
||||
item = strings.Trim(item, "[]")
|
||||
if item == "" || seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
|
||||
if ip := net.ParseIP(item); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
continue
|
||||
}
|
||||
if !isValidDNSName(item) {
|
||||
return nil, nil, fmt.Errorf("访问地址格式不正确: %s", raw)
|
||||
}
|
||||
dnsNames = append(dnsNames, item)
|
||||
}
|
||||
return dnsNames, ips, nil
|
||||
}
|
||||
|
||||
// isValidDNSName 只做基本形态校验:证书 SAN 里塞进奇怪字符没有意义,
|
||||
// 而且这串内容来自管理端输入,先收一道口。允许通配符前缀。
|
||||
func isValidDNSName(name string) bool {
|
||||
if len(name) == 0 || len(name) > 253 {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(name, "*.") {
|
||||
name = name[2:]
|
||||
}
|
||||
for _, label := range strings.Split(name, ".") {
|
||||
if len(label) == 0 || len(label) > 63 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(label); i++ {
|
||||
c := label[i]
|
||||
isAlnum := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
|
||||
if !isAlnum && c != '-' && c != '_' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func randomSerial() (*big.Int, error) {
|
||||
limit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
serial, err := rand.Int(rand.Reader, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成证书序列号失败: %w", err)
|
||||
}
|
||||
return serial, nil
|
||||
}
|
||||
|
||||
func parseCertPEM(data []byte) (*x509.Certificate, error) {
|
||||
for {
|
||||
block, rest := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, errors.New("没有找到证书 PEM 块")
|
||||
}
|
||||
if block.Type == "CERTIFICATE" {
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
data = rest
|
||||
}
|
||||
}
|
||||
|
||||
func parseECKeyPEM(data []byte) (*ecdsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, errors.New("没有找到私钥 PEM 块")
|
||||
}
|
||||
key, err := x509.ParseECPrivateKey(block.Bytes)
|
||||
if err == nil {
|
||||
return key, nil
|
||||
}
|
||||
// 兼容 PKCS#8 封装
|
||||
anyKey, err8 := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err8 != nil {
|
||||
return nil, err
|
||||
}
|
||||
ecKey, ok := anyKey.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("私钥不是 ECDSA 类型")
|
||||
}
|
||||
return ecKey, nil
|
||||
}
|
||||
|
||||
func pemBlock(blockType string, der []byte) []byte {
|
||||
return pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der})
|
||||
}
|
||||
|
||||
func writePEMFile(path, blockType string, der []byte, mode os.FileMode) error {
|
||||
return writeFileAtomic(path, pemBlock(blockType, der), mode)
|
||||
}
|
||||
|
||||
func writeKeyFile(path string, key *ecdsa.PrivateKey) error {
|
||||
der, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化私钥失败: %w", err)
|
||||
}
|
||||
return writePEMFile(path, "EC PRIVATE KEY", der, 0600)
|
||||
}
|
||||
|
||||
// writeFileAtomic 先写临时文件再改名:证书与私钥是成对使用的,
|
||||
// 写到一半被读走会让管理端加载出一个残缺的证书直接起不来。
|
||||
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpName, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package localca
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testPaths(t *testing.T) Paths {
|
||||
t.Helper()
|
||||
return DefaultPaths(t.TempDir())
|
||||
}
|
||||
|
||||
// 最要紧的一条:签出来的东西必须能真的被 TLS 用起来,
|
||||
// 而不是"看起来像证书"。这里直接走 tls.X509KeyPair + 校验链。
|
||||
func TestIssuedCertIsUsableAndChainsToCA(t *testing.T) {
|
||||
p := testPaths(t)
|
||||
|
||||
summary, err := IssueServerCert(p, []string{"waf.example.com", "192.168.1.10", "127.0.0.1"}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("签发失败: %v", err)
|
||||
}
|
||||
|
||||
certPEM, err := os.ReadFile(p.SrvCert)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyPEM, err := os.ReadFile(p.SrvKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 证书与私钥必须配对,否则管理端启动时加载会直接失败
|
||||
if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil {
|
||||
t.Fatalf("证书与私钥不配对: %v", err)
|
||||
}
|
||||
|
||||
caPEM, err := ReadCACert(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
t.Fatal("CA 证书无法解析")
|
||||
}
|
||||
leaf, err := parseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 导入根证书后,浏览器就是这样校验的:链能验通 + 访问名在 SAN 里
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool, DNSName: "waf.example.com"}); err != nil {
|
||||
t.Fatalf("按域名校验失败: %v", err)
|
||||
}
|
||||
if err := leaf.VerifyHostname("192.168.1.10"); err != nil {
|
||||
t.Fatalf("按IP校验失败: %v", err)
|
||||
}
|
||||
if err := leaf.VerifyHostname("127.0.0.1"); err != nil {
|
||||
t.Fatalf("按回环IP校验失败: %v", err)
|
||||
}
|
||||
|
||||
// 摘要要如实反映 SAN 分类
|
||||
if len(summary.DNSNames) != 1 || summary.DNSNames[0] != "waf.example.com" {
|
||||
t.Fatalf("DNS SAN 不对: %v", summary.DNSNames)
|
||||
}
|
||||
if len(summary.IPs) != 2 {
|
||||
t.Fatalf("IP SAN 数量不对: %v", summary.IPs)
|
||||
}
|
||||
}
|
||||
|
||||
// 漏掉访问方式是这类证书最常见的坑:只写域名的话用 IP 访问必报错。
|
||||
func TestHostnameNotInSANIsRejected(t *testing.T) {
|
||||
p := testPaths(t)
|
||||
if _, err := IssueServerCert(p, []string{"waf.example.com"}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPEM, _ := os.ReadFile(p.SrvCert)
|
||||
leaf, err := parseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := leaf.VerifyHostname("192.168.1.10"); err == nil {
|
||||
t.Fatal("没写进 SAN 的 IP 竟然校验通过了")
|
||||
}
|
||||
}
|
||||
|
||||
// 重签只换服务器证书,CA 必须原样不动——否则用户导入过的信任会作废
|
||||
func TestReissueKeepsSameCA(t *testing.T) {
|
||||
p := testPaths(t)
|
||||
|
||||
if _, err := IssueServerCert(p, []string{"a.example.com"}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caFirst, err := ReadCACert(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := IssueServerCert(p, []string{"b.example.com", "10.0.0.5"}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caSecond, err := ReadCACert(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(caFirst) != string(caSecond) {
|
||||
t.Fatal("重签之后 CA 变了——用户导入过的根证书会失效")
|
||||
}
|
||||
|
||||
// 新证书要能用新的 SAN 校验,且仍挂在同一个 CA 下
|
||||
certPEM, _ := os.ReadFile(p.SrvCert)
|
||||
leaf, err := parseCertPEM(certPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := leaf.VerifyHostname("b.example.com"); err != nil {
|
||||
t.Fatalf("新 SAN 校验失败: %v", err)
|
||||
}
|
||||
if !IsIssuedByLocalCA(p) {
|
||||
t.Fatal("重签后没被认成本地 CA 签发")
|
||||
}
|
||||
}
|
||||
|
||||
// 825 天是 Apple 平台的硬上限,必须在签发前就挡住
|
||||
func TestValidDaysUpperBound(t *testing.T) {
|
||||
p := testPaths(t)
|
||||
if _, err := IssueServerCert(p, []string{"a.example.com"}, 826); err == nil {
|
||||
t.Fatal("超过 825 天的有效期竟然被接受")
|
||||
}
|
||||
if _, err := IssueServerCert(p, []string{"a.example.com"}, maxServerCertValidDays); err != nil {
|
||||
t.Fatalf("恰好 %d 天应当允许: %v", maxServerCertValidDays, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 默认有效期要落在上限内,且 CA 必须显著长于服务器证书
|
||||
func TestDefaultValidity(t *testing.T) {
|
||||
p := testPaths(t)
|
||||
summary, err := IssueServerCert(p, []string{"a.example.com"}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
days := summary.NotAfter.Sub(summary.NotBefore).Hours() / 24
|
||||
if days > maxServerCertValidDays {
|
||||
t.Fatalf("默认有效期 %.0f 天超过上限", days)
|
||||
}
|
||||
ca := CASummary(p)
|
||||
if ca == nil {
|
||||
t.Fatal("CA 摘要为空")
|
||||
}
|
||||
if !ca.NotAfter.After(summary.NotAfter.AddDate(1, 0, 0)) {
|
||||
t.Fatal("CA 有效期没有显著长于服务器证书,会导致续期时链先过期")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSANParsing(t *testing.T) {
|
||||
dns, ips, err := splitSANs([]string{
|
||||
" waf.example.com ", // 前后空格
|
||||
"https://a.example.com/", // 带协议与斜杠
|
||||
"192.168.1.10:26666", // 带端口
|
||||
"[::1]", // IPv6 字面量
|
||||
"waf.example.com", // 重复
|
||||
"", // 空项
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if len(dns) != 2 {
|
||||
t.Fatalf("期望 2 个域名,实际 %v", dns)
|
||||
}
|
||||
if len(ips) != 2 {
|
||||
t.Fatalf("期望 2 个IP,实际 %v", ips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSANRejectsGarbage(t *testing.T) {
|
||||
for _, bad := range []string{"a b.com", "-bad.com", "bad-.com", "有中文.com"} {
|
||||
if _, _, err := splitSANs([]string{bad}); err == nil {
|
||||
t.Fatalf("非法访问地址被接受: %q", bad)
|
||||
}
|
||||
}
|
||||
if _, err := IssueServerCert(testPaths(t), []string{" ", ""}, 0); err == nil {
|
||||
t.Fatal("空 SAN 列表竟然签发成功")
|
||||
}
|
||||
}
|
||||
|
||||
// 手工上传/ACME 的证书不能被误判成本地 CA 签的,否则续期任务会去重签别人的证书
|
||||
func TestIsIssuedByLocalCARejectsForeignCert(t *testing.T) {
|
||||
p := testPaths(t)
|
||||
if _, err := IssueServerCert(p, []string{"a.example.com"}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsIssuedByLocalCA(p) {
|
||||
t.Fatal("自己签的没被认出来")
|
||||
}
|
||||
|
||||
// 另起一套 CA 签一张,冒充放到管理端证书位置
|
||||
other := testPaths(t)
|
||||
if _, err := IssueServerCert(other, []string{"a.example.com"}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foreign, err := os.ReadFile(other.SrvCert)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p.SrvCert, foreign, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if IsIssuedByLocalCA(p) {
|
||||
t.Fatal("别的 CA 签的证书被误判为本地 CA 签发(只比名字就会这样)")
|
||||
}
|
||||
}
|
||||
|
||||
// CA 私钥必须 0600,且证书是公开的可以放宽
|
||||
func TestKeyFilePermission(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows 的文件模式位不反映真实 ACL,跳过")
|
||||
}
|
||||
p := testPaths(t)
|
||||
if _, err := IssueServerCert(p, []string{"a.example.com"}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{p.CAKey, p.SrvKey} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("%s 权限应为 0600,实际 %o", path, info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 没有证书时各查询接口要安静地返回空,不能报错或 panic
|
||||
func TestSummariesWhenAbsent(t *testing.T) {
|
||||
p := testPaths(t)
|
||||
if CASummary(p) != nil {
|
||||
t.Fatal("不存在的 CA 应返回 nil")
|
||||
}
|
||||
if CurrentServerCert(p) != nil {
|
||||
t.Fatal("不存在的服务器证书应返回 nil")
|
||||
}
|
||||
if IsIssuedByLocalCA(p) {
|
||||
t.Fatal("什么都没有时不该判为本地 CA 签发")
|
||||
}
|
||||
if got := SANsOf(nil); got != nil {
|
||||
t.Fatalf("nil 摘要应返回 nil,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 时钟略有偏差时刚签的证书也应立即可用
|
||||
func TestNotBeforeToleratesClockSkew(t *testing.T) {
|
||||
p := testPaths(t)
|
||||
summary, err := IssueServerCert(p, []string{"a.example.com"}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !summary.NotBefore.Before(time.Now()) {
|
||||
t.Fatal("NotBefore 不早于当前时间,时钟稍有偏差就会\"证书尚未生效\"")
|
||||
}
|
||||
}
|
||||
|
||||
// 轮换必须真的换掉 CA——否则"吊销"就是假的
|
||||
func TestRotateCAReplacesCA(t *testing.T) {
|
||||
p := testPaths(t)
|
||||
if _, err := IssueServerCert(p, []string{"a.example.com", "10.0.0.5"}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caOld, _ := ReadCACert(p)
|
||||
certOld, _ := os.ReadFile(p.SrvCert)
|
||||
|
||||
// 不传 SAN:应沿用当前证书护住的地址,避免换完 CA 反而漏了访问方式
|
||||
summary, err := RotateCA(p, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("轮换失败: %v", err)
|
||||
}
|
||||
caNew, _ := ReadCACert(p)
|
||||
if string(caOld) == string(caNew) {
|
||||
t.Fatal("轮换后 CA 没变——旧根证书仍然有效,等于没吊销")
|
||||
}
|
||||
certNew, _ := os.ReadFile(p.SrvCert)
|
||||
if string(certOld) == string(certNew) {
|
||||
t.Fatal("轮换后服务器证书没变")
|
||||
}
|
||||
if len(summary.DNSNames) != 1 || len(summary.IPs) != 1 {
|
||||
t.Fatalf("没沿用原有的访问地址: dns=%v ips=%v", summary.DNSNames, summary.IPs)
|
||||
}
|
||||
|
||||
// 新证书必须挂在新 CA 下,且用旧 CA 验不过
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caOld) {
|
||||
t.Fatal("旧 CA 解析失败")
|
||||
}
|
||||
leaf, err := parseCertPEM(certNew)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool, DNSName: "a.example.com"}); err == nil {
|
||||
t.Fatal("新证书竟然能用旧 CA 验通过")
|
||||
}
|
||||
if !IsIssuedByLocalCA(p) {
|
||||
t.Fatal("轮换后没被认成本地 CA 签发")
|
||||
}
|
||||
}
|
||||
|
||||
// 清除要把 CA 与它签的证书一并删掉,且可重复执行不报错
|
||||
func TestClearLocalRemovesEverything(t *testing.T) {
|
||||
p := testPaths(t)
|
||||
if _, err := IssueServerCert(p, []string{"a.example.com"}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ClearLocal(p); err != nil {
|
||||
t.Fatalf("清除失败: %v", err)
|
||||
}
|
||||
for _, f := range []string{p.CAKey, p.CACert, p.SrvCert, p.SrvKey} {
|
||||
if _, err := os.Stat(f); !os.IsNotExist(err) {
|
||||
t.Fatalf("%s 没被删掉", f)
|
||||
}
|
||||
}
|
||||
// 幂等:再清一次不应报错
|
||||
if err := ClearLocal(p); err != nil {
|
||||
t.Fatalf("重复清除应当无害: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 手工上传/ACME 的证书不归本地 CA 管,清除时绝不能连它一起删
|
||||
func TestClearLocalKeepsForeignServerCert(t *testing.T) {
|
||||
p := testPaths(t)
|
||||
if _, err := IssueServerCert(p, []string{"a.example.com"}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 用另一套 CA 签一张放到管理端证书位置,模拟"用户改用了手工上传的证书"
|
||||
other := testPaths(t)
|
||||
if _, err := IssueServerCert(other, []string{"a.example.com"}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foreign, _ := os.ReadFile(other.SrvCert)
|
||||
if err := os.WriteFile(p.SrvCert, foreign, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ClearLocal(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(p.SrvCert); os.IsNotExist(err) {
|
||||
t.Fatal("把不属于本地 CA 的管理端证书也删掉了")
|
||||
}
|
||||
if _, err := os.Stat(p.CACert); !os.IsNotExist(err) {
|
||||
t.Fatal("本地 CA 没被删掉")
|
||||
}
|
||||
}
|
||||
@@ -384,6 +384,7 @@ func RunTaskInitMigrations(db *gorm.DB) error {
|
||||
enums.TASK_SSL_PATH_LOAD,
|
||||
enums.TASK_BATCH,
|
||||
enums.TASK_SSL_EXPIRE_CHECK,
|
||||
enums.TASK_MANAGER_CERT_CHECK,
|
||||
enums.TASK_NOTICE,
|
||||
enums.TASK_CLEAR_WEBCACHE,
|
||||
}
|
||||
@@ -623,6 +624,45 @@ func RunTaskInitMigrations(db *gorm.DB) error {
|
||||
return tx.Where("task_method = ?", enums.TASK_HOSTGUARD_CLEAN_EXPIRED).Delete(&model.Task{}).Error
|
||||
},
|
||||
},
|
||||
// 管理端证书检测:本地CA签的自动续期,绑定证书夹的交给既有ACME链路,手工上传的按阈值提醒。
|
||||
// 单独一个任务是因为既有的 SSL 到期检测只覆盖被防护站点的域名列表,管理端自己的证书不在其中。
|
||||
{
|
||||
ID: "202608250001_add_manager_cert_task",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
zlog.Info("迁移 202608250001: 创建管理端证书检测任务")
|
||||
|
||||
var count int64
|
||||
tx.Model(&model.Task{}).Where("task_method = ?", enums.TASK_MANAGER_CERT_CHECK).Count(&count)
|
||||
if count > 0 {
|
||||
zlog.Info("管理端证书检测任务已存在,跳过", "task_method", enums.TASK_MANAGER_CERT_CHECK)
|
||||
return nil
|
||||
}
|
||||
|
||||
task := model.Task{
|
||||
BaseOrm: baseorm.BaseOrm{
|
||||
Id: uuid.GenUUID(),
|
||||
USER_CODE: global.GWAF_USER_CODE,
|
||||
Tenant_ID: global.GWAF_TENANT_ID,
|
||||
CREATE_TIME: customtype.JsonTime(time.Now()),
|
||||
UPDATE_TIME: customtype.JsonTime(time.Now()),
|
||||
},
|
||||
TaskName: "每天05:30检测管理端证书(本地证书自动续期)",
|
||||
TaskUnit: enums.TASK_DAY,
|
||||
TaskValue: 1,
|
||||
TaskAt: "05:30",
|
||||
TaskMethod: enums.TASK_MANAGER_CERT_CHECK,
|
||||
}
|
||||
if err := tx.Create(&task).Error; err != nil {
|
||||
return fmt.Errorf("创建管理端证书检测任务失败: %w", err)
|
||||
}
|
||||
zlog.Info("管理端证书检测任务创建成功")
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
zlog.Info("回滚 202608250001: 删除管理端证书检测任务")
|
||||
return tx.Where("task_method = ?", enums.TASK_MANAGER_CERT_CHECK).Delete(&model.Task{}).Error
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 执行迁移
|
||||
|
||||
+71
-26
@@ -392,9 +392,13 @@ func (web *WafWebManager) StartLocalServer() error {
|
||||
},
|
||||
}
|
||||
|
||||
// 开启了仅HTTPS但未启用SSL/无证书时,提示该开关不会生效(不强制,避免锁死)
|
||||
// 「仅允许HTTPS」开着却没启用 SSL 属矛盾配置:既然根本不打算提供 HTTPS,
|
||||
// 这个开关就无从谈起。这里只告警不拒绝——拒绝会把"曾经开过 force_https、后来关掉 SSL"
|
||||
// 的存量部署直接挡在门外,而这种组合多半是遗留值而非真实意图。
|
||||
// 真正需要 fail-closed 的是"打算走 HTTPS 却拿不到证书",那条在 serveHTTPFallback 里处理。
|
||||
if global.GWAF_SSL_FORCE_HTTPS && !global.GWAF_SSL_ENABLE {
|
||||
zlog.Warn(web.LogName, "已开启「仅允许HTTPS」但未启用SSL,将照常以HTTP提供服务(开关不生效)")
|
||||
zlog.Warn(web.LogName, "已开启「仅允许HTTPS」但未启用SSL,该开关不生效,仍以HTTP提供服务;"+
|
||||
"如需强制HTTPS请先启用SSL并配置证书")
|
||||
}
|
||||
err := waf_service.WafTokenInfoServiceApp.ReloadAllValidTokensToCache()
|
||||
if err != nil {
|
||||
@@ -416,40 +420,19 @@ func (web *WafWebManager) StartLocalServer() error {
|
||||
// 检查证书文件是否存在
|
||||
if _, err := os.Stat(certPath); err != nil {
|
||||
zlog.Warn(web.LogName, "SSL证书文件不存在,降级使用 HTTP: ", certPath)
|
||||
// 降级到 HTTP
|
||||
zlog.Info(web.LogName, "启动 HTTP 管理端(降级模式), port:", global.GWAF_LOCAL_SERVER_PORT)
|
||||
if err := web.listenAndServeHTTPReuse(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errMsg := fmt.Sprintf("启动管理界面失败: %s", err.Error())
|
||||
zlog.Error(web.LogName, errMsg)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return web.serveHTTPFallback("管理端证书文件不存在")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(keyPath); err != nil {
|
||||
zlog.Warn(web.LogName, "SSL私钥文件不存在,降级使用 HTTP: ", keyPath)
|
||||
// 降级到 HTTP
|
||||
zlog.Info(web.LogName, "启动 HTTP 管理端(降级模式), port:", global.GWAF_LOCAL_SERVER_PORT)
|
||||
if err := web.listenAndServeHTTPReuse(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errMsg := fmt.Sprintf("启动管理界面失败: %s", err.Error())
|
||||
zlog.Error(web.LogName, errMsg)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return web.serveHTTPFallback("管理端私钥文件不存在")
|
||||
}
|
||||
|
||||
// 尝试加载证书
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
zlog.Warn(web.LogName, "SSL证书加载失败,降级使用 HTTP: ", err.Error())
|
||||
// 降级到 HTTP
|
||||
zlog.Info(web.LogName, "启动 HTTP 管理端(降级模式), port:", global.GWAF_LOCAL_SERVER_PORT)
|
||||
if err := web.listenAndServeHTTPReuse(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errMsg := fmt.Sprintf("启动管理界面失败: %s", err.Error())
|
||||
zlog.Error(web.LogName, errMsg)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return web.serveHTTPFallback("管理端证书加载失败:" + err.Error())
|
||||
}
|
||||
|
||||
// 使用混合监听器,同时支持 HTTPS 和 HTTP(降级方案)
|
||||
@@ -472,6 +455,68 @@ func (web *WafWebManager) StartLocalServer() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpsRequiredNotice 是 fail-closed 时回给访问者的全部内容。
|
||||
// 单独成函数是为了能被单测直接断言——这段文案是运维在管理端打不开时唯一的线索,
|
||||
// 少一条恢复路径就意味着有人得去翻源码。
|
||||
func httpsRequiredNotice(reason string) string {
|
||||
return "管理端已开启「仅允许HTTPS」,但当前无法提供 HTTPS 服务。\n" +
|
||||
"原因:" + reason + "\n\n" +
|
||||
"为避免管理端以明文提供服务,已拒绝全部 HTTP 请求。\n" +
|
||||
"恢复方式(任选其一,改完需重启 SamWaf):\n" +
|
||||
" 1. 修复管理端证书(data/ssl/manager/domain.crt 与 domain.key)\n" +
|
||||
" 2. conf/config.yml 设 security.ssl_force_https: false —— 保留 HTTPS,同时允许 HTTP 访问\n" +
|
||||
" 3. conf/config.yml 设 security.ssl_enable: false —— 关闭 HTTPS,回到纯 HTTP\n"
|
||||
}
|
||||
|
||||
// httpsRequiredHandler 占住端口但不挂管理端路由:任何请求都只回 503 与修复指引。
|
||||
func httpsRequiredHandler(reason string) http.Handler {
|
||||
notice := httpsRequiredNotice(reason)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(notice))
|
||||
})
|
||||
}
|
||||
|
||||
// serveHTTPFallback 处理"启用了 SSL 但拿不到可用证书"的情形。
|
||||
//
|
||||
// 分两种走向,取决于「仅允许HTTPS」:
|
||||
// - 关闭(默认):降级为纯 HTTP,保证管理端还能进——可用性优先,与历史行为一致。
|
||||
// - 开启:**拒绝以明文提供管理端服务**。运维显式声明过"只走 HTTPS",而证书过期/损坏
|
||||
// 恰恰是最常见的失败;此时若静默降级,管理端会在运维毫不知情的情况下以明文接受登录,
|
||||
// 口令明文过网。这里改为 fail-closed。
|
||||
//
|
||||
// fail-closed 不等于把端口一关了事——那样运维只看到"连接被拒绝",无从判断原因。
|
||||
// 端口照常监听,但**不挂管理端路由**,任何请求只回 503 与修复指引:既不可能泄露任何东西,
|
||||
// 也不会让人误以为进程挂了。
|
||||
func (web *WafWebManager) serveHTTPFallback(reason string) error {
|
||||
if !global.GWAF_SSL_FORCE_HTTPS {
|
||||
zlog.Info(web.LogName, "启动 HTTP 管理端(降级模式), port:", global.GWAF_LOCAL_SERVER_PORT)
|
||||
if err := web.listenAndServeHTTPReuse(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errMsg := fmt.Sprintf("启动管理界面失败: %s", err.Error())
|
||||
zlog.Error(web.LogName, errMsg)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
zlog.Error(web.LogName, "「仅允许HTTPS」已开启但无法提供 HTTPS("+reason+"),已拒绝明文服务;"+
|
||||
"请修复证书,或在 conf/config.yml 关闭 security.ssl_force_https / security.ssl_enable 后重启")
|
||||
|
||||
// 换掉 Handler:管理端路由不再挂载,明文端口上不会出现登录框或任何接口。
|
||||
// 复用同一个 HttpServer 是为了让既有的优雅关闭/重启链路照常生效。
|
||||
web.HttpServer.Handler = httpsRequiredHandler(reason)
|
||||
|
||||
zlog.Info(web.LogName, "启动 HTTP 管理端(仅回 503 提示,不提供管理功能), port:", global.GWAF_LOCAL_SERVER_PORT)
|
||||
if err := web.listenAndServeHTTPReuse(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errMsg := fmt.Sprintf("启动管理界面失败: %s", err.Error())
|
||||
zlog.Error(web.LogName, errMsg)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listenAndServeHybrid 混合监听器,同时支持 HTTPS 和 HTTP
|
||||
func (web *WafWebManager) listenAndServeHybrid(cert tls.Certificate) error {
|
||||
addr := web.HttpServer.Addr
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package wafmangeweb
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 「仅允许HTTPS」在拿不到证书时的行为。
|
||||
//
|
||||
// 这条是安全语义,不是可用性偏好:运维显式声明过"只走 HTTPS",而证书过期/损坏又是最常见的
|
||||
// 失败方式。若此时静默降级成明文,管理端会在运维毫不知情的情况下以明文接受登录、口令明文过网——
|
||||
// 开关名字承诺的和实际做的就对不上了。这几条用例把这个语义钉住,防止后来被改回"总是降级"。
|
||||
|
||||
// fail-closed 时端口照常监听,但管理端路由不挂载:任何路径都只回 503。
|
||||
func TestHTTPSRequiredHandlerRefusesEverything(t *testing.T) {
|
||||
h := httpsRequiredHandler("管理端证书文件不存在")
|
||||
|
||||
// 登录接口、静态页、随便什么路径,一律 503
|
||||
for _, path := range []string{"/", "/api/v1/public/login", "/api/v1/hosts/list", "/whatever"} {
|
||||
for _, method := range []string{http.MethodGet, http.MethodPost} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("%s %s 期望 503,实际 %d", method, path, rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "仅允许HTTPS") {
|
||||
t.Fatalf("%s %s 的响应没说明原因,运维无从排查: %s", method, path, body)
|
||||
}
|
||||
// 纯文本 + nosniff:别让浏览器把它当 HTML 渲染,也不给任何可交互的东西
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
|
||||
t.Fatalf("%s %s 的 Content-Type 应为 text/plain,实际 %s", method, path, ct)
|
||||
}
|
||||
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("%s %s 缺少 nosniff", method, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fail-closed 最怕"锁死了还不知道怎么开":恢复指引必须是照着就能做的
|
||||
func TestHTTPSRequiredNoticeIsActionable(t *testing.T) {
|
||||
notice := httpsRequiredNotice("管理端证书加载失败:x509: 证书与私钥不配对")
|
||||
|
||||
for _, must := range []string{
|
||||
"x509: 证书与私钥不配对", // 具体原因要带出来,不能只说"失败了"
|
||||
"conf/config.yml", // 改哪个文件
|
||||
"ssl_force_https", // 改哪个开关
|
||||
"ssl_enable", // 另一条退路
|
||||
"domain.crt", // 证书在哪
|
||||
"重启", // 改完要重启,否则改了也不生效
|
||||
} {
|
||||
if !strings.Contains(notice, must) {
|
||||
t.Fatalf("恢复指引里缺少 %q,运维照着做不出来:\n%s", must, notice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提示里不能出现任何可被当成入口的东西——它就是一段纯文本
|
||||
func TestHTTPSRequiredNoticeHasNoMarkupOrLink(t *testing.T) {
|
||||
notice := httpsRequiredNotice("管理端证书文件不存在")
|
||||
for _, bad := range []string{"<", ">", "http://", "https://"} {
|
||||
if strings.Contains(notice, bad) {
|
||||
t.Fatalf("提示文案里不该出现 %q:它是明文端口上唯一的响应,应当只是一段纯文本\n%s", bad, notice)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package waftask
|
||||
|
||||
import (
|
||||
"SamWaf/common/zlog"
|
||||
"SamWaf/global"
|
||||
"SamWaf/innerbean"
|
||||
"SamWaf/utils"
|
||||
"SamWaf/utils/localca"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 管理端证书的到期处理。
|
||||
//
|
||||
// 为什么单独一个任务:既有的 SSLExpireCheck 面向"被防护站点的域名列表",
|
||||
// 管理端自己的证书(data/ssl/manager/domain.crt)根本不在那张表里,
|
||||
// 到期了不会有任何人提醒——管理员往往是某天打不开后台才发现。
|
||||
//
|
||||
// 三类来源,处理方式各不相同:
|
||||
// - 本地 CA 签发:能自己重签,剩余天数不足时**自动续期**,CA 不变故已导入的信任不受影响
|
||||
// - 证书夹绑定(ACME):既有链路已自动续期并刷新,这里不插手,只在日志里留一句
|
||||
// - 手工上传:代不了劳,只能按阈值提醒
|
||||
const (
|
||||
// managerCertRenewDays 本地证书剩余天数低于此值即自动重签
|
||||
managerCertRenewDays = 30
|
||||
)
|
||||
|
||||
// ManagerCertCheck 检查管理端证书:本地 CA 签的自动续期,其余按阈值提醒。
|
||||
func ManagerCertCheck() {
|
||||
innerLogName := "ManagerCertCheck"
|
||||
paths := localca.DefaultPaths(utils.GetCurrentDir())
|
||||
|
||||
current := localca.CurrentServerCert(paths)
|
||||
if current == nil {
|
||||
// 没配管理端证书是正常状态(默认就是 HTTP),不该刷日志
|
||||
return
|
||||
}
|
||||
|
||||
daysLeft := int(time.Until(current.NotAfter).Hours() / 24)
|
||||
|
||||
if localca.IsIssuedByLocalCA(paths) {
|
||||
if daysLeft > managerCertRenewDays {
|
||||
return
|
||||
}
|
||||
// 沿用原证书的访问地址重签:用户当初填了哪些名字就继续保哪些
|
||||
sans := localca.SANsOf(current)
|
||||
if len(sans) == 0 {
|
||||
zlog.Error(innerLogName, "管理端本地证书没有可用的访问地址,无法自动续期,请到系统配置页重新生成")
|
||||
return
|
||||
}
|
||||
summary, err := localca.IssueServerCert(paths, sans, 0)
|
||||
if err != nil {
|
||||
zlog.Error(innerLogName, fmt.Sprintf("管理端本地证书自动续期失败: %v", err))
|
||||
notifyManagerCertExpire(current.Subject, current.NotAfter, daysLeft, "自动续期失败,请手动处理")
|
||||
return
|
||||
}
|
||||
zlog.Info(innerLogName, fmt.Sprintf("管理端本地证书已自动续期,新到期时间 %s;需要重启管理端后生效",
|
||||
summary.NotAfter.Format("2006-01-02 15:04:05")))
|
||||
global.GQEQUE_MESSAGE_DB.Enqueue(innerbean.OperatorMessageInfo{
|
||||
BaseMessageInfo: innerbean.BaseMessageInfo{
|
||||
OperaType: "管理端证书续期",
|
||||
Server: global.GWAF_CUSTOM_SERVER_NAME,
|
||||
},
|
||||
OperaCnt: fmt.Sprintf("管理端本地证书已自动续期至 %s,重启管理端后生效",
|
||||
summary.NotAfter.Format("2006-01-02")),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 绑定证书夹的证书由既有 ACME 链路续期并刷新,这里不重复提醒,避免和站点证书的提醒撞车
|
||||
if global.GWAF_SSL_BIND_CERT_ID != "" {
|
||||
if daysLeft <= 7 {
|
||||
zlog.Info(innerLogName, fmt.Sprintf("管理端绑定的证书夹证书剩余 %d 天,等待证书夹自动续期后刷新", daysLeft))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 手工上传:只能提醒
|
||||
for _, threshold := range []int{30, 7, 1} {
|
||||
if daysLeft == threshold {
|
||||
notifyManagerCertExpire(current.Subject, current.NotAfter, daysLeft, "请及时更换管理端证书")
|
||||
return
|
||||
}
|
||||
}
|
||||
if daysLeft <= 0 {
|
||||
notifyManagerCertExpire(current.Subject, current.NotAfter, daysLeft, "管理端证书已过期")
|
||||
}
|
||||
}
|
||||
|
||||
func notifyManagerCertExpire(subject string, expireAt time.Time, daysLeft int, hint string) {
|
||||
zlog.Info("ManagerCertCheck", fmt.Sprintf("管理端证书 %s 剩余 %d 天(%s):%s",
|
||||
subject, daysLeft, expireAt.Format("2006-01-02"), hint))
|
||||
global.GQEQUE_MESSAGE_DB.Enqueue(innerbean.SSLExpireMessageInfo{
|
||||
BaseMessageInfo: innerbean.BaseMessageInfo{
|
||||
OperaType: "管理端证书过期提醒",
|
||||
Server: global.GWAF_CUSTOM_SERVER_NAME,
|
||||
},
|
||||
Domain: subject,
|
||||
ExpireTime: expireAt.Format("2006-01-02 15:04:05"),
|
||||
DaysLeft: daysLeft,
|
||||
})
|
||||
}
|
||||
@@ -176,3 +176,33 @@ notes:
|
||||
en:
|
||||
title: Console traffic now uses a key negotiated per session
|
||||
detail: Messages between the console page and the backend are now encrypted with a key negotiated per instance and per browser tab. A comm_key file is generated under data/.keys on first start and no configuration is required. Separately deployed older frontends and the mobile app keep using the previous channel and are unaffected. Note that this key protects the messages themselves and prevents offline recovery from captured traffic, but it is not a replacement for HTTPS - protecting against an active man-in-the-middle still requires enabling TLS on the console. Advanced - security.comm_key_file in conf/config.yml points to a self-managed key file, and once no older frontend or app connects any more you can set security.comm_legacy_key to false to close the previous channel (it stays enabled by default and is never turned off automatically).
|
||||
|
||||
- id: v1_3_24_manager_local_cert
|
||||
version: v1.3.24
|
||||
kind: notice
|
||||
level: normal
|
||||
page: ''
|
||||
doc: https://doc.samwaf.com/guide/VpConfig.html
|
||||
apply:
|
||||
type: none
|
||||
zh:
|
||||
title: 管理端可一键生成本地证书并自动续期
|
||||
detail: 「系统配置 - 管理端 SSL」新增「本地证书」:没有域名(或不想走证书申请)时,可由本机生成长效 CA 并给管理端签发证书,把根证书导入日常访问的电脑一次即可长期使用。填写访问地址时请把实际用到的域名与 IP 全部列出,浏览器只认列表里的地址。证书剩余不足 30 天会自动重签,CA 保持不变,已导入根证书的电脑无需再次导入。有域名的部署更推荐用 DNS 验证申请证书后绑定证书夹,同样不需要开放 80 端口。另外,以 HTTP 访问管理端时页面顶部会出现提示条(本机 localhost/127.0.0.1 访问不提示),可本次收起,下次登录重新出现。
|
||||
en:
|
||||
title: The console can now generate a local certificate and renew it automatically
|
||||
detail: System Settings - Management SSL gains a Local certificate section. When you have no domain (or prefer not to use ACME), SamWaf can generate a long-lived CA on the machine and sign a console certificate with it; importing the root CA once on the computers you browse from is enough for the long run. List every domain and IP you actually use when filling in the access addresses - browsers only accept the addresses in that list. The certificate is reissued automatically when fewer than 30 days remain, and the CA stays the same so computers that already imported it need no further action. Deployments that do have a domain are better served by requesting a certificate with DNS validation and binding the certificate folder, which also avoids opening port 80. In addition, accessing the console over plain HTTP now shows a banner at the top of the page (local access via localhost or 127.0.0.1 is exempt); it can be hidden for the current session and reappears at the next login.
|
||||
|
||||
- id: v1_3_24_force_https_fail_closed
|
||||
version: v1.3.24
|
||||
kind: notice
|
||||
level: high
|
||||
page: ''
|
||||
doc: https://doc.samwaf.com/faq/#_3-4
|
||||
apply:
|
||||
type: none
|
||||
zh:
|
||||
title: 开启「仅允许HTTPS」后,证书不可用时将不再降级为HTTP
|
||||
detail: 以往管理端证书缺失或加载失败时会自动降级成纯HTTP,即使「仅允许HTTPS」处于开启状态。自本版本起,开启该开关后遇到证书不可用将拒绝以明文提供管理端服务:端口仍在监听,但不挂载任何管理功能,访问只会得到一段说明如何恢复的503提示。这样做是因为证书过期或损坏是最常见的情况,而此时静默改用HTTP会让管理端在无人察觉时以明文接受登录。未开启该开关的部署行为不变,仍然自动降级。若升级后管理端打不开并看到该503提示,按提示三选一处理:修复证书;或在conf/config.yml把security.ssl_force_https改为false;或把security.ssl_enable改为false,改完重启即可。
|
||||
en:
|
||||
title: With Force HTTPS enabled, an unusable certificate no longer falls back to HTTP
|
||||
detail: Previously the console fell back to plain HTTP whenever its certificate was missing or failed to load, even with Force HTTPS enabled. From this version on, that switch means the console refuses to be served in the clear - the port keeps listening but no console functionality is mounted, and every request returns a 503 notice explaining how to recover. The reason is that an expired or broken certificate is the most common failure, and silently switching to HTTP would let the console accept logins in the clear without anyone noticing. Deployments that do not enable the switch are unaffected and still fall back automatically. If the console does not open after upgrading and shows that 503 notice, follow one of the three options it lists - fix the certificate, set security.ssl_force_https to false in conf/config.yml, or set security.ssl_enable to false - then restart.
|
||||
|
||||
Reference in New Issue
Block a user