feat: 重构面板证书逻辑

This commit is contained in:
耗子
2026-03-30 20:28:11 +08:00
parent c33c03d225
commit e799cb4031
14 changed files with 167 additions and 84 deletions
+1 -1
View File
@@ -69,7 +69,7 @@ func (r *Ace) Run() error {
serverErr := make(chan error, 1)
go func() {
fmt.Println("[HTTP] listening and serving on port", r.conf.HTTP.Port)
if r.conf.HTTP.TLS {
if r.conf.HTTP.IsHTTPS() {
if err := r.server.ListenAndServeTLS("", ""); !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
}
+2 -2
View File
@@ -31,7 +31,7 @@ func NewRouter(t *gotext.Locale, middlewares *middleware.Middlewares, http *rout
}
func NewTLSReloader(conf *config.Config) (*tlscert.Reloader, error) {
if !conf.HTTP.TLS {
if !conf.HTTP.IsHTTPS() {
return nil, nil
}
@@ -52,7 +52,7 @@ func NewHttp(conf *config.Config, mux *chi.Mux, reloader *tlscert.Reloader) (*hl
})
srv.Listen80RedirectTo443 = true
if conf.HTTP.TLS && reloader != nil {
if conf.HTTP.IsHTTPS() && reloader != nil {
srv.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: reloader.GetCertificate,
+30 -9
View File
@@ -22,6 +22,7 @@ import (
"github.com/acepanel/panel/v3/pkg/firewall"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/os"
"github.com/acepanel/panel/v3/pkg/tools"
)
const settingCacheTTL = 5 * time.Minute
@@ -254,8 +255,7 @@ func (r *settingRepo) GetPanel() (*request.SettingPanel, error) {
IPDBURL: ipdbURL,
IPDBPath: ipdbPath,
Port: r.conf.HTTP.Port,
HTTPS: r.conf.HTTP.TLS,
ACME: r.conf.HTTP.ACME,
TLS: r.conf.HTTP.TLS,
PublicIP: publicIP,
Cert: crt,
Key: key,
@@ -326,6 +326,25 @@ func (r *settingRepo) UpdatePanel(ctx context.Context, req *request.SettingPanel
// 下面是需要需要重启的设置
// 面板HTTPS
restartFlag := false
// 自签模式
if req.TLS == "self-signed" {
needGen := req.Cert == "" || req.Key == ""
if !needGen {
if _, err := cert.ParseCert([]byte(req.Cert)); err != nil {
needGen = true
}
}
if needGen {
crt, key, err := cert.GenerateSelfSigned(tools.CollectLocalNames())
if err != nil {
return false, errors.New(r.t.Get("failed to generate self-signed certificate: %v", err))
}
req.Cert = string(crt)
req.Key = string(key)
}
}
oldCert, _ := io.Read(filepath.Join(app.Root, "panel/storage/cert.pem"))
oldKey, _ := io.Read(filepath.Join(app.Root, "panel/storage/cert.key"))
if oldCert != req.Cert || oldKey != req.Key {
@@ -334,11 +353,14 @@ func (r *settingRepo) UpdatePanel(ctx context.Context, req *request.SettingPanel
}
restartFlag = true
}
if _, err := cert.ParseCert([]byte(req.Cert)); err != nil && req.HTTPS {
return false, errors.New(r.t.Get("failed to parse certificate: %v", err))
}
if _, err := cert.ParseKey([]byte(req.Key)); err != nil && req.HTTPS {
return false, errors.New(r.t.Get("failed to parse private key: %v", err))
// custom 模式需要验证证书格式
if req.TLS == "custom" {
if _, err := cert.ParseCert([]byte(req.Cert)); err != nil {
return false, errors.New(r.t.Get("failed to parse certificate: %v", err))
}
if _, err := cert.ParseKey([]byte(req.Key)); err != nil {
return false, errors.New(r.t.Get("failed to parse private key: %v", err))
}
}
if err := io.Write(filepath.Join(app.Root, "panel/storage/cert.pem"), req.Cert, 0600); err != nil {
return false, err
@@ -379,8 +401,7 @@ func (r *settingRepo) UpdatePanel(ctx context.Context, req *request.SettingPanel
conf.HTTP.Entrance = req.Entrance
conf.HTTP.EntranceError = req.EntranceError
conf.HTTP.LoginCaptcha = req.LoginCaptcha
conf.HTTP.TLS = req.HTTPS
conf.HTTP.ACME = req.ACME
conf.HTTP.TLS = req.TLS
conf.HTTP.IPHeader = req.IPHeader
conf.HTTP.BindDomain = req.BindDomain
conf.HTTP.BindIP = req.BindIP
+1 -1
View File
@@ -97,7 +97,7 @@ func MustLogin(t *gotext.Locale, conf *config.Config, session *sessions.Manager,
Expires: time.Now().Add(time.Duration(session.Lifetime) * time.Minute),
Path: "/",
HttpOnly: true,
Secure: conf.HTTP.TLS,
Secure: conf.HTTP.IsHTTPS(),
SameSite: http.SameSiteLaxMode,
})
}
+7 -8
View File
@@ -21,14 +21,13 @@ type SettingPanel struct {
BackupPath string `json:"backup_path" validate:"required"`
ProjectPath string `json:"project_path" validate:"required"`
ContainerSock string `json:"container_sock"`
HiddenMenu []string `json:"hidden_menu"` // 隐藏的菜单项
CustomLogo string `json:"custom_logo" validate:"isFullURL"` // 自定义 Logo URL
IPDBType string `json:"ipdb_type"` // IPDB 来源类型: "" / "custom" / "subscribe"
IPDBURL string `json:"ipdb_url"` // IPDB 订阅链接
IPDBPath string `json:"ipdb_path"` // IPDB 地理位置库路径
Port uint `json:"port" validate:"required|min:1|max:65535"`
HTTPS bool `json:"https"`
ACME bool `json:"acme"`
HiddenMenu []string `json:"hidden_menu"` // 隐藏的菜单项
CustomLogo string `json:"custom_logo" validate:"isFullURL"` // 自定义 Logo URL
IPDBType string `json:"ipdb_type"` // IPDB 来源类型: "" / "custom" / "subscribe"
IPDBURL string `json:"ipdb_url"` // IPDB 订阅链接
IPDBPath string `json:"ipdb_path"` // IPDB 地理位置库路径
Port uint `json:"port" validate:"required|min:1|max:65535"` // 面板端口
TLS string `json:"tls"` // 面板 TLS: off, acme, self-signed, custom
PublicIP []string `json:"public_ip"`
Cert string `json:"cert"`
Key string `json:"key"`
+33 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/acepanel/panel/v3/internal/http/request"
pkgcert "github.com/acepanel/panel/v3/pkg/cert"
"github.com/acepanel/panel/v3/pkg/config"
"github.com/acepanel/panel/v3/pkg/tools"
)
// CertRenew 证书续签
@@ -73,7 +74,38 @@ func (r *CertRenew) Run() {
}
// 面板证书续签
if r.conf.HTTP.ACME {
switch r.conf.HTTP.TLS {
case "self-signed":
// 自签证书续签
crt, _ := os.ReadFile(filepath.Join(app.Root, "panel/storage/cert.pem"))
decode, err := pkgcert.ParseCert(crt)
if err == nil {
if time.Until(decode.NotAfter) > 30*24*time.Hour {
return
}
} else {
r.log.Warn("failed to parse panel certificate", slog.String("type", biz.OperationTypeCert), slog.Uint64("operator_id", 0), slog.Any("err", err))
}
newCrt, newKey, err := pkgcert.GenerateSelfSigned(tools.CollectLocalNames())
if err != nil {
r.log.Warn("failed to generate self-signed certificate", slog.String("type", biz.OperationTypeCert), slog.Uint64("operator_id", 0), slog.Any("err", err))
return
}
if err = r.settingRepo.UpdateCert(&request.SettingCert{
Cert: string(newCrt),
Key: string(newKey),
}); err != nil {
r.log.Warn("failed to update panel certificate", slog.String("type", biz.OperationTypeCert), slog.Uint64("operator_id", 0), slog.Any("err", err))
return
}
r.log.Info("panel self-signed certificate renewed", slog.String("type", biz.OperationTypeCert), slog.Uint64("operator_id", 0))
case "off", "custom":
// off/custom 不需要自动续签
default:
// ACME 模式
crt, _ := os.ReadFile(filepath.Join(app.Root, "panel/storage/cert.pem"))
decode, err := pkgcert.ParseCert(crt)
if err == nil {
+23 -27
View File
@@ -9,7 +9,6 @@ import (
"math/rand/v2"
stdos "os"
"path/filepath"
"slices"
"strings"
"time"
@@ -178,7 +177,7 @@ func (s *CliService) Info(ctx context.Context, cmd *cli.Command) error {
}
protocol := "http"
if s.conf.HTTP.TLS {
if s.conf.HTTP.IsHTTPS() {
protocol = "https"
}
@@ -362,7 +361,7 @@ func (s *CliService) HTTPSOn(ctx context.Context, cmd *cli.Command) error {
return err
}
conf.HTTP.TLS = true
conf.HTTP.TLS = "acme"
if err = config.Save(conf); err != nil {
return err
@@ -378,7 +377,7 @@ func (s *CliService) HTTPSOff(ctx context.Context, cmd *cli.Command) error {
return err
}
conf.HTTP.TLS = false
conf.HTTP.TLS = "off"
if err = config.Save(conf); err != nil {
return err
@@ -389,32 +388,26 @@ func (s *CliService) HTTPSOff(ctx context.Context, cmd *cli.Command) error {
}
func (s *CliService) HTTPSGenerate(ctx context.Context, cmd *cli.Command) error {
var names []string
if lv4, err := tools.GetLocalIPv4(); err == nil {
if !slices.Contains(names, lv4) {
names = append(names, lv4)
}
}
if lv6, err := tools.GetLocalIPv6(); err == nil {
if !slices.Contains(names, lv6) {
names = append(names, lv6)
}
}
if rv4, err := tools.GetPublicIPv4(); err == nil {
if !slices.Contains(names, rv4) {
names = append(names, rv4)
}
}
if rv6, err := tools.GetPublicIPv6(); err == nil {
if !slices.Contains(names, rv6) {
names = append(names, rv6)
}
}
names := tools.CollectLocalNames()
var crt, key []byte
var err error
if s.conf.HTTP.ACME {
switch s.conf.HTTP.TLS {
case "self-signed":
// 自签模式
crt, key, err = cert.GenerateSelfSigned(names)
if err != nil {
return err
}
case "off", "custom":
// off/custom 不需要自动生成,回退到自签
crt, key, err = cert.GenerateSelfSigned(names)
if err != nil {
return err
}
default:
// ACME 模式
ip, err := s.settingRepo.Get(biz.SettingKeyPublicIPs)
if err != nil {
return err
@@ -440,6 +433,7 @@ func (s *CliService) HTTPSGenerate(ctx context.Context, cmd *cli.Command) error
}
}
// ACME 失败回退到自签
if crt == nil || key == nil {
crt, key, err = cert.GenerateSelfSigned(names)
if err != nil {
@@ -1080,7 +1074,9 @@ func (s *CliService) Init(ctx context.Context, cmd *cli.Command) error {
conf.App.APIEndpoint = "api.acepanel.net"
conf.App.DownloadEndpoint = "dl.acepanel.net"
conf.HTTP.Entrance = "/" + str.Random(6)
conf.HTTP.ACME = acme
if acme {
conf.HTTP.TLS = "acme"
}
// 随机默认端口
checkPort:
+26
View File
@@ -10,6 +10,8 @@ import (
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/acepanel/panel/v3/pkg/cert"
"github.com/acepanel/panel/v3/pkg/config"
"github.com/acepanel/panel/v3/pkg/tools"
)
@@ -64,6 +66,30 @@ func (s *SettingService) Update(w http.ResponseWriter, r *http.Request) {
}
func (s *SettingService) ObtainCert(w http.ResponseWriter, r *http.Request) {
// 自签模式
conf, err := config.Load()
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if conf.HTTP.TLS == "self-signed" {
crt, key, err := cert.GenerateSelfSigned(tools.CollectLocalNames())
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = s.settingRepo.UpdateCert(&request.SettingCert{
Cert: string(crt),
Key: string(key),
}); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
return
}
// ACME 模式
ip, err := s.settingRepo.Get(biz.SettingKeyPublicIPs)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
+1 -1
View File
@@ -163,7 +163,7 @@ func (s *UserService) Login(w http.ResponseWriter, r *http.Request) {
ip = r.RemoteAddr
}
if req.SafeLogin && !s.conf.HTTP.TLS {
if req.SafeLogin && !s.conf.HTTP.IsHTTPS() {
sess.Put("safe_login", true)
sess.Put("safe_client", fmt.Sprintf("%x", sha256.Sum256([]byte(ip))))
} else {
+1 -1
View File
@@ -356,7 +356,7 @@ func (s *UserPasskeyService) Delete(w http.ResponseWriter, r *http.Request) {
// isCertTrusted 检查面板证书是否由 CA 签发(非自签名)
// 用证书自身公钥验签自身签名,成功说明是自签名证书
func (s *UserPasskeyService) isCertTrusted() bool {
if !s.conf.HTTP.TLS {
if !s.conf.HTTP.IsHTTPS() {
return false
}
+7 -2
View File
@@ -33,8 +33,7 @@ type HTTPConfig struct {
Port uint `yaml:"port"`
Entrance string `yaml:"entrance"`
EntranceError string `yaml:"entrance_error"`
TLS bool `yaml:"tls"`
ACME bool `yaml:"acme"`
TLS string `yaml:"tls"` // off, acme, self-signed, custom
LoginCaptcha bool `yaml:"login_captcha"`
IPHeader string `yaml:"ip_header"`
BindDomain []string `yaml:"bind_domain"`
@@ -42,6 +41,12 @@ type HTTPConfig struct {
BindUA []string `yaml:"bind_ua"`
}
// IsHTTPS 判断是否启用 HTTPS
func (c *HTTPConfig) IsHTTPS() bool {
// TODO remove false in v4
return c.TLS != "off" && c.TLS != "false" && c.TLS != ""
}
type DatabaseConfig struct {
Debug bool `yaml:"debug"`
}
+18
View File
@@ -284,6 +284,24 @@ func GetLocalIPv6() (string, error) {
return local.IP.String(), nil
}
// CollectLocalNames 收集本机所有 IP 地址
func CollectLocalNames() []string {
var names []string
if lv4, err := GetLocalIPv4(); err == nil && !slices.Contains(names, lv4) {
names = append(names, lv4)
}
if lv6, err := GetLocalIPv6(); err == nil && !slices.Contains(names, lv6) {
names = append(names, lv6)
}
if rv4, err := GetPublicIPv4(); err == nil && !slices.Contains(names, rv4) {
names = append(names, rv4)
}
if rv6, err := GetPublicIPv6(); err == nil && !slices.Contains(names, rv6) {
names = append(names, rv6)
}
return names
}
// FormatBytes 格式化bytes
func FormatBytes(size float64) string {
units := []string{"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}
+11 -10
View File
@@ -24,18 +24,16 @@ const isObtainCert = ref(false)
const saveLoading = ref(false)
// 记录已保存的 HTTPS 相关设置,用于判断是否有未保存的修改
const savedHttpsState = ref({ https: false, acme: false, public_ip: '[]' })
const savedHttpsState = ref({ tls: 'off', public_ip: '[]' })
const httpsSettingsDirty = computed(() => {
return (
model.value.https !== savedHttpsState.value.https ||
model.value.acme !== savedHttpsState.value.acme ||
model.value.tls !== savedHttpsState.value.tls ||
JSON.stringify(model.value.public_ip) !== savedHttpsState.value.public_ip
)
})
const snapshotHttpsState = () => {
savedHttpsState.value = {
https: model.value.https,
acme: model.value.acme,
tls: model.value.tls,
public_ip: JSON.stringify(model.value.public_ip)
}
}
@@ -65,8 +63,7 @@ const { data: model } = useRequest(setting.list, {
ipdb_type: '',
ipdb_url: '',
ipdb_path: '',
https: false,
acme: false,
tls: 'off',
public_ip: [],
cert: '',
key: ''
@@ -101,7 +98,7 @@ const handleSave = () => {
if (data.restart) {
window.$message.info($gettext('Panel is restarting, page will refresh in 5 seconds'))
setTimeout(() => {
const protocol = model.value.https ? 'https:' : 'http:'
const protocol = model.value.tls !== 'off' ? 'https:' : 'http:'
const hostname = window.location.hostname
const port = model.value.port
const entrance = model.value.entrance || '/'
@@ -171,13 +168,17 @@ const handleCreate = () => {
{{ $gettext('Save') }}
</n-button>
<n-button
v-if="currentTab === 'safe' && model.https && model.acme"
v-if="currentTab === 'safe' && (model.tls === 'acme' || model.tls === 'self-signed')"
type="info"
:loading="isObtainCert"
:disabled="httpsSettingsDirty || isObtainCert"
@click="handleObtainCert"
>
{{ $gettext('Refresh Certificate') }}
{{
model.tls === 'acme'
? $gettext('Refresh Certificate')
: $gettext('Regenerate Certificate')
}}
</n-button>
</n-flex>
</n-flex>
+6 -21
View File
@@ -6,27 +6,11 @@ const { $gettext } = useGettext()
const model = defineModel<any>('model', { type: Object, required: true })
// HTTPS 模式:off / acme / custom
// HTTPS 模式:off / acme / self-signed / custom
const httpsMode = computed({
get: () => {
if (!model.value.https) return 'off'
return model.value.acme ? 'acme' : 'custom'
},
set: (value: string) => {
switch (value) {
case 'off':
model.value.https = false
model.value.acme = false
break
case 'acme':
model.value.https = true
model.value.acme = true
break
case 'custom':
model.value.https = true
model.value.acme = false
break
}
get: () => model.value.tls || 'off',
set: (v: string) => {
model.value.tls = v
}
})
</script>
@@ -251,7 +235,7 @@ const httpsMode = computed({
</template>
{{
$gettext(
'Enable HTTPS for the panel. ACME will automatically obtain and renew the certificate daily (requires panel accessible via public IP). Custom allows you to provide your own certificate'
'Enable HTTPS for the panel. ACME will automatically obtain and renew the certificate daily (requires panel accessible via public IP). Self-Signed generates a certificate automatically (browsers will show a warning). Custom allows you to provide your own certificate'
)
}}
</n-tooltip>
@@ -261,6 +245,7 @@ const httpsMode = computed({
v-for="option in [
{ label: $gettext('Disabled'), value: 'off' },
{ label: $gettext('ACME (Auto)'), value: 'acme' },
{ label: $gettext('Self-Signed'), value: 'self-signed' },
{ label: $gettext('Custom Certificate'), value: 'custom' }
]"
:key="option.value"