12 Commits

Author SHA1 Message Date
Your Name 9bae6b1d9b youhua 2026-07-25 00:09:24 +08:00
cnbugs 9a315e1404 chore: update go.sum for lego v4.22.0 upgrade 2026-07-24 19:45:54 +08:00
cnbugs 6498903a7c fix: credential data corruption - Create handler mutating store pointer
- CreateCredential was setting cred.Data = cred.Masked directly on the
  store pointer, corrupting stored credentials with masked data
- Fixed by using a value copy for API response
- Also fix nextID starts at 1 to avoid ID=0 issue
2026-07-24 19:16:35 +08:00
cnbugs 471fe76384 fix: credential ID starts from 1 to avoid ID=0 issue
- InitCredentialStore nextID defaults to 1
- Auto-migrate existing ID=0 credentials on load
- Add public Save() method for migration persistence
2026-07-24 19:13:26 +08:00
cnbugs 285df5a12f fix: cert create form credential selection validation
- Explicitly set selectedCredId in onCredSelected
- Separate credential validation from manual input validation
2026-07-24 19:10:34 +08:00
cnbugs 3a2c494d64 fix: increase DNS-01 timeout and add DNSTimeout option
- Add dns01.AddDNSTimeout(15s) for more reliable DNS propagation check
- Improve error resilience for Cloudflare DNS-01 challenges
2026-07-24 19:07:39 +08:00
cnbugs bdb3ca856f feat: add DNS credential management with Web UI
- New config/credential_store.go: persistent credential storage (credentials.json)
- New API: CRUD for DNS credentials + credential-types endpoint
- Certificate model now supports credential_id reference
- Create certificate auto-resolves credential_id to DNS config
- New Vue page: Credentials.vue for managing saved DNS keys
- CertCreate.vue: select existing credential or manual input + save as new
- Secrets masked in API responses, never exposed in list
2026-07-24 18:56:03 +08:00
cnbugs adebf82aaa chore: remove pre-compiled binary from repo 2026-07-23 16:37:39 +08:00
cnbugs 3cb2178c44 chore: add pre-compiled linux amd64 binary for no-go environments 2026-07-23 16:36:16 +08:00
cnbugs b1fd3a8b9b fix: include frontend dist assets in repo, fix deploy.sh syntax
- Remove .gitignore exclusion for dist/assets/ and frontend/dist/
- Fix deploy.sh: add missing quote on echo line
- Fix deploy.sh: correct ADMIN_PASSWORD=*** syntax
- Make deploy.sh auto-detect go/npm availability
2026-07-23 16:35:57 +08:00
cnbugs bbf09fbc8d feat: add Web UI for notification config (feishu + email)
- New config/notify_store.go: persistent notification config (notify.json)
- New API: GET/PUT /api/notify/config, POST /api/notify/test-feishu, POST /api/notify/test-email
- New Vue page: NotifyConfig.vue with feishu webhook and SMTP settings
- Updated cron/init to read from persistent store instead of env vars
- Password masked as ******** in API responses, preserved on updates
2026-07-23 16:31:04 +08:00
cnbugs 2d70a15307 feat: add login authentication and certificate expiry notifications
- JWT-based login with ADMIN_USER/ADMIN_PASSWORD env config
- Login page with auth guard for Vue frontend
- Feishu bot webhook notification for expiring certificates
- SMTP email notification for expiring certificates
- Daily 8:00 AM cron for expiry checks
- Startup health check notification
2026-07-23 16:25:02 +08:00
42 changed files with 4950 additions and 222 deletions
+2
View File
@@ -0,0 +1,2 @@
PORT=8080
ADMIN_USER=admin
-2
View File
@@ -6,8 +6,6 @@ backend/data/
# 编译产物 # 编译产物
backend/autossl backend/autossl
backend/dist/assets/
frontend/dist/
node_modules/ node_modules/
# IDE # IDE
+50 -13
View File
@@ -9,7 +9,8 @@
- ✅ HTTP-01(端口 80)和 DNS-01 验证方式 - ✅ HTTP-01(端口 80)和 DNS-01 验证方式
- ✅ 支持阿里云 DNS / Cloudflare / DNSPod - ✅ 支持阿里云 DNS / Cloudflare / DNSPod
- ✅ Web 管理界面(仪表盘、证书列表、申请、查看、续期、删除) - ✅ Web 管理界面(仪表盘、证书列表、申请、查看、续期、删除)
-SQLite 数据存储 -**用户登录认证**JWT Token
-**证书到期通知**(飞书机器人和邮件)
- ✅ Docker 一键部署 - ✅ Docker 一键部署
## 快速开始 ## 快速开始
@@ -38,23 +39,59 @@ cd backend && go build -o autossl .
| 变量 | 默认值 | 说明 | | 变量 | 默认值 | 说明 |
|------|--------|------| |------|--------|------|
| PORT | 8080 | Web 服务端口 | | PORT | 8080 | Web 服务端口 |
| DB_PATH | ./data/autossl.db | SQLite 数据库路径 | | DATA_DIR | ./data | 数据存储目录 |
| CERT_DIR | ./data/certs | 证书文件存储目录 | | CERT_DIR | ./data/certs | 证书文件存储目录 |
| ACCOUNTS_DIR | ./data/accounts | ACME 账号存储目录 | | ACCOUNTS_DIR | ./data/accounts | ACME 账号存储目录 |
| ACME_PORT | 8082 | HTTP-01 挑战端口 |
| ADMIN_USER | admin | 管理员用户名 |
| ADMIN_PASSWORD | admin123 | 管理员密码 |
| JWT_SECRET | (内置默认值) | JWT 签名密钥(请务必在生产环境修改) |
| FEISHU_WEBHOOK_URL | - | 飞书机器人 Webhook 地址(用于证书到期通知) |
| SMTP_HOST | - | SMTP 服务器地址(用于邮件通知) |
| SMTP_PORT | - | SMTP 端口 |
| SMTP_USER | - | SMTP 用户名 |
| SMTP_PASSWORD | - | SMTP 密码 |
| SMTP_FROM | - | 发件人地址 |
| NOTIFY_TO | - | 收件人邮箱(多个用逗号分隔) |
## API 接口 ## API 接口
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 | 需要认证 |
|------|------|------| |------|------|------|---------|
| GET | /api/stats | 统计信息 | | GET | /api/health | 健康检查 | 否 |
| GET | /api/certificates | 证书列表 | | POST | /api/login | 用户登录 | 否 |
| GET | /api/certificates/:id | 证书详情 | | GET | /api/stats | 统计信息 | 是 |
| POST | /api/certificates | 申请证书 | | GET | /api/certificates | 证书列表 | 是 |
| PUT | /api/certificates/:id | 更新证书配置 | | GET | /api/certificates/:id | 证书详情 | 是 |
| DELETE | /api/certificates/:id | 删除证书 | | POST | /api/certificates | 申请证书 | 是 |
| POST | /api/certificates/:id/renew | 手动续期 | | PUT | /api/certificates/:id | 更新证书配置 | 是 |
| GET | /api/certificates/:id/files | 查看证书文件 | | DELETE | /api/certificates/:id | 删除证书 | 是 |
| GET | /api/renewals/check | 触发续期检查 | | POST | /api/certificates/:id/renew | 手动续期 | 是 |
| GET | /api/certificates/:id/files | 查看证书文件 | 是 |
| GET | /api/renewals/check | 触发续期检查 | 是 |
## 证书到期通知
系统支持两种通知方式:
### 飞书机器人
1. 在飞书群中添加 Webhook 机器人
2. 设置环境变量 `FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx`
### 邮件通知
设置 SMTP 相关环境变量:
```
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=user@example.com
SMTP_PASSWORD=your-password
SMTP_FROM=autossl@example.com
NOTIFY_TO=admin@example.com,ops@example.com
```
通知将在每天早上 8:00 自动检查并发送。30 天内到期的证书会触发通知。
## 证书文件位置 ## 证书文件位置
Executable
BIN
View File
Binary file not shown.
+12
View File
@@ -10,6 +10,18 @@ WorkingDirectory={{DEPLOY_DIR}}/backend
Environment="PORT=9090" Environment="PORT=9090"
Environment="ACME_PORT=8082" Environment="ACME_PORT=8082"
Environment="GIN_MODE=release" Environment="GIN_MODE=release"
Environment="ADMIN_USER=admin"
Environment="ADMIN_PASSWORD=您的密码"
Environment="JWT_SECRET=请修改为随机字符串"
# 飞书通知(可选)
# Environment="FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
# 邮件通知(可选)
# Environment="SMTP_HOST=smtp.example.com"
# Environment="SMTP_PORT=587"
# Environment="SMTP_USER=user@example.com"
# Environment="SMTP_PASSWORD=password"
# Environment="SMTP_FROM=autossl@example.com"
# Environment="NOTIFY_TO=admin@example.com"
ExecStart={{DEPLOY_DIR}}/backend/autossl ExecStart={{DEPLOY_DIR}}/backend/autossl
Restart=always Restart=always
RestartSec=3 RestartSec=3
+122
View File
@@ -0,0 +1,122 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrInvalidToken = errors.New("invalid or expired token")
)
type User struct {
Username string `json:"username"`
Password string `json:"password"` // plaintext only used at login
}
// defaultAdmin returns the admin user from env vars
func defaultAdmin() User {
username := os.Getenv("ADMIN_USER")
if username == "" {
username = "admin"
}
password := os.Getenv("ADMIN_PASSWORD")
if password == "" {
password = "admin123"
}
return User{Username: username, Password: password}
}
// LoginRequest is the JSON body for login
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// LoginResponse is the JSON response for login
type LoginResponse struct {
Token string `json:"token"`
Username string `json:"username"`
ExpiresAt int64 `json:"expires_at"`
}
// Claims represents JWT claims
type Claims struct {
Username string `json:"username"`
jwt.RegisteredClaims
}
// secretKey returns the JWT signing key (env or default)
func secretKey() []byte {
key := os.Getenv("JWT_SECRET")
if key == "" {
key = "autossl-default-secret-change-me"
}
return []byte(key)
}
// TokenExpiry returns token lifetime
func TokenExpiry() time.Duration {
return 24 * time.Hour
}
// Authenticate checks credentials against environment config
func Authenticate(username, password string) bool {
admin := defaultAdmin()
// Constant-time comparison to prevent timing attacks
mac1 := hmac.New(sha256.New, []byte(admin.Username))
mac1.Write([]byte(admin.Password))
expected := hex.EncodeToString(mac1.Sum(nil))
mac2 := hmac.New(sha256.New, []byte(username))
mac2.Write([]byte(password))
actual := hex.EncodeToString(mac2.Sum(nil))
return hmac.Equal([]byte(expected), []byte(actual))
}
// GenerateToken creates a JWT token for the given username
func GenerateToken(username string) (string, int64, error) {
expiresAt := time.Now().Add(TokenExpiry())
claims := Claims{
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "autossl",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(secretKey())
if err != nil {
return "", 0, err
}
return tokenString, expiresAt.Unix(), nil
}
// ValidateToken parses and validates a JWT token, returning claims
func ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return secretKey(), nil
})
if err != nil {
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
+1
View File
@@ -13,6 +13,7 @@ type Certificate struct {
ChallengeType string `json:"challenge_type"` // http, dns ChallengeType string `json:"challenge_type"` // http, dns
DNSProvider string `json:"dns_provider,omitempty"` // alidns, cloudflare, etc. DNSProvider string `json:"dns_provider,omitempty"` // alidns, cloudflare, etc.
DNSConfig string `json:"dns_config,omitempty"` // JSON config for DNS provider DNSConfig string `json:"dns_config,omitempty"` // JSON config for DNS provider
CredentialID *uint `json:"credential_id,omitempty"` // references saved credential
Status string `json:"status"` // pending, active, expired, error Status string `json:"status"` // pending, active, expired, error
CertURL string `json:"cert_url,omitempty"` CertURL string `json:"cert_url,omitempty"`
+7
View File
@@ -72,6 +72,13 @@ func InitStore(cfg *Config) {
log.Println("Cert store initialized successfully") log.Println("Cert store initialized successfully")
} }
// InitAll initializes all stores
func InitAll(cfg *Config) {
InitStore(cfg)
InitNotifyStore(cfg)
InitCredentialStore(cfg)
}
// Load reads certificates from JSON file into memory // Load reads certificates from JSON file into memory
func (s *CertStore) Load() error { func (s *CertStore) Load() error {
s.mu.Lock() s.mu.Lock()
+257
View File
@@ -0,0 +1,257 @@
package config
import (
"encoding/json"
"log"
"os"
"sync"
"time"
)
// CredentialType represents supported DNS provider types
type CredentialType string
const (
CredentialAliDNS CredentialType = "alidns"
CredentialCloudflare CredentialType = "cloudflare"
CredentialDNSPod CredentialType = "dnspod"
)
// CredentialField describes a single field of a credential type
type CredentialField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // text, password
Required bool `json:"required"`
}
// CredentialTypeMeta returns field definitions for each provider type
func CredentialTypeMeta(ctype CredentialType) []CredentialField {
switch ctype {
case CredentialAliDNS:
return []CredentialField{
{Key: "ali_key", Label: "AccessKey ID", Type: "text", Required: true},
{Key: "ali_secret", Label: "AccessKey Secret", Type: "password", Required: true},
}
case CredentialCloudflare:
return []CredentialField{
{Key: "cf_api_token", Label: "API Token", Type: "password", Required: false},
{Key: "cf_email", Label: "Email (Global API Key 方式)", Type: "text", Required: false},
{Key: "cf_api_key", Label: "Global API Key", Type: "password", Required: false},
}
case CredentialDNSPod:
return []CredentialField{
{Key: "dnspod_id", Label: "ID", Type: "text", Required: true},
{Key: "dnspod_key", Label: "Key", Type: "password", Required: true},
}
default:
return nil
}
}
// Credential holds saved DNS provider credentials
type Credential struct {
ID uint `json:"id"`
Name string `json:"name"` // user-friendly name
Type CredentialType `json:"type"` // alidns, cloudflare, dnspod
Data string `json:"data"` // JSON string of key-value pairs
Masked string `json:"masked"` // masked display version of data
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CredentialStore manages credential persistence
type CredentialStore struct {
mu sync.RWMutex
data map[uint]*Credential
path string
nextID uint
}
var CredStore *CredentialStore
// InitCredentialStore initializes the credential store
func InitCredentialStore(cfg *Config) {
CredStore = &CredentialStore{
data: make(map[uint]*Credential),
path: cfg.DataDir + "/credentials.json",
nextID: 1,
}
if err := CredStore.Load(); err != nil {
log.Printf("No existing credential store: %v, starting fresh", err)
}
// Check if load triggered ID migration and persist
CredStore.mu.RLock()
needsSave := false
for _, c := range CredStore.data {
if c.ID == 0 {
needsSave = true
break
}
}
CredStore.mu.RUnlock()
if needsSave {
if err := CredStore.Save(); err != nil {
log.Printf("Failed to persist credential migration: %v", err)
}
}
log.Println("Credential store initialized successfully")
}
// Load reads credentials from JSON file
func (s *CredentialStore) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.path)
if err != nil {
return err
}
var creds []*Credential
if err := json.Unmarshal(data, &creds); err != nil {
return err
}
s.data = make(map[uint]*Credential)
s.nextID = 1
for _, c := range creds {
// Auto-migrate legacy ID=0 entries
if c.ID == 0 {
c.ID = s.nextID
s.nextID++
}
s.data[c.ID] = c
if c.ID >= s.nextID {
s.nextID = c.ID + 1
}
}
return nil
}
func (s *CredentialStore) save() error {
creds := make([]*Credential, 0, len(s.data))
for _, c := range s.data {
creds = append(creds, c)
}
data, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0600)
}
// Save persists credential data to file (public, acquires write lock)
func (s *CredentialStore) Save() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.save()
}
// GetAll returns all credentials
func (s *CredentialStore) GetAll() []*Credential {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]*Credential, 0, len(s.data))
for _, c := range s.data {
clone := *c
clone.Data = "" // don't expose secrets in list
result = append(result, &clone)
}
// Sort by ID descending (newest first)
for i := 0; i < len(result)-1; i++ {
for j := i + 1; j < len(result); j++ {
if result[i].ID < result[j].ID {
result[i], result[j] = result[j], result[i]
}
}
}
return result
}
// GetByID returns a credential with secrets (for internal use)
func (s *CredentialStore) GetByID(id uint) *Credential {
s.mu.RLock()
defer s.mu.RUnlock()
c := s.data[id]
if c == nil {
return nil
}
clone := *c
return &clone
}
// Create adds a new credential
func (s *CredentialStore) Create(cred *Credential) error {
s.mu.Lock()
defer s.mu.Unlock()
cred.ID = s.nextID
s.nextID++
now := time.Now()
cred.CreatedAt = now
cred.UpdatedAt = now
cred.Masked = maskCredentialData(cred.Type, cred.Data)
s.data[cred.ID] = cred
return s.save()
}
// Update modifies an existing credential, keeping data if masked
func (s *CredentialStore) Update(cred *Credential) error {
s.mu.Lock()
defer s.mu.Unlock()
existing, ok := s.data[cred.ID]
if !ok {
return nil
}
// If data is masked, keep the existing one
if cred.Data == "********" {
cred.Data = existing.Data
}
cred.CreatedAt = existing.CreatedAt
cred.UpdatedAt = time.Now()
cred.Masked = maskCredentialData(cred.Type, cred.Data)
s.data[cred.ID] = cred
return s.save()
}
// Delete removes a credential
func (s *CredentialStore) Delete(id uint) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.data, id)
return s.save()
}
// maskCredentialData creates a masked version for API display
func maskCredentialData(ctype CredentialType, dataJSON string) string {
var raw map[string]string
if err := json.Unmarshal([]byte(dataJSON), &raw); err != nil {
return "{}"
}
masked := make(map[string]string)
for k, v := range raw {
if len(v) > 4 {
masked[k] = v[:4] + "****"
} else {
masked[k] = "****"
}
}
b, _ := json.Marshal(masked)
return string(b)
}
// GetDataMap parses the credential data JSON into a map
func (c *Credential) GetDataMap() map[string]string {
result := make(map[string]string)
json.Unmarshal([]byte(c.Data), &result)
return result
}
+182
View File
@@ -0,0 +1,182 @@
package config
import (
"encoding/json"
"log"
"os"
"sync"
"time"
)
// NotifyConfig holds notification configuration that can be set via Web UI
type NotifyConfig struct {
FeishuEnabled bool `json:"feishu_enabled"`
FeishuURL string `json:"feishu_url"`
SMTPEnabled bool `json:"smtp_enabled"`
SMTPHost string `json:"smtp_host"`
SMTPPort string `json:"smtp_port"`
SMTPUser string `json:"smtp_user"`
SMTPPassword string `json:"smtp_password,omitempty"`
SMTPFrom string `json:"smtp_from"`
NotifyTo string `json:"notify_to"`
// Notification thresholds
ExpiryDays int `json:"expiry_days"` // Notify when cert expires within this many days
// Internal
UpdatedAt time.Time `json:"updated_at"`
}
// DefaultNotifyConfig returns default notification settings
func DefaultNotifyConfig() *NotifyConfig {
return &NotifyConfig{
FeishuEnabled: false,
SMTPEnabled: false,
ExpiryDays: 30,
}
}
// NotifyStore manages notification config persistence
type NotifyStore struct {
mu sync.RWMutex
path string
Data *NotifyConfig
}
var NotifyCfg *NotifyStore
// InitNotifyStore initializes the notification config store
func InitNotifyStore(cfg *Config) {
NotifyCfg = &NotifyStore{
path: cfg.DataDir + "/notify.json",
Data: DefaultNotifyConfig(),
}
if err := NotifyCfg.Load(); err != nil {
log.Printf("No existing notify config: %v, using defaults", err)
}
// Environment variables override file config (for backward compatibility)
envOverrideNotifyConfig()
log.Println("Notify config store initialized successfully")
}
func envOverrideNotifyConfig() {
store := NotifyCfg
if url := os.Getenv("FEISHU_WEBHOOK_URL"); url != "" {
store.Data.FeishuEnabled = true
store.Data.FeishuURL = url
}
if host := os.Getenv("SMTP_HOST"); host != "" {
store.Data.SMTPEnabled = true
store.Data.SMTPHost = host
}
if port := os.Getenv("SMTP_PORT"); port != "" {
store.Data.SMTPPort = port
}
if user := os.Getenv("SMTP_USER"); user != "" {
store.Data.SMTPUser = user
}
if pass := os.Getenv("SMTP_PASSWORD"); pass != "" {
store.Data.SMTPPassword = pass
}
if from := os.Getenv("SMTP_FROM"); from != "" {
store.Data.SMTPFrom = from
}
if to := os.Getenv("NOTIFY_TO"); to != "" {
store.Data.NotifyTo = to
}
}
// Load reads notification config from JSON file
func (s *NotifyStore) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.path)
if err != nil {
return err
}
var nc NotifyConfig
if err := json.Unmarshal(data, &nc); err != nil {
return err
}
// Keep existing password if not provided in file (security)
if nc.SMTPPassword == "" && s.Data.SMTPPassword != "" {
nc.SMTPPassword = s.Data.SMTPPassword
}
s.Data = &nc
return nil
}
// Save persists the notification config to JSON file
func (s *NotifyStore) Save() error {
s.mu.Lock()
defer s.mu.Unlock()
s.Data.UpdatedAt = time.Now()
data, err := json.MarshalIndent(s.Data, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0600)
}
// Get returns a copy of the current notify config (without password for API responses)
func (s *NotifyStore) Get() *NotifyConfig {
s.mu.RLock()
defer s.mu.RUnlock()
// Return a copy
nc := *s.Data
// Hide password for API responses
if nc.SMTPPassword != "" {
nc.SMTPPassword = "********"
}
return &nc
}
// Update saves new notification config, keeping password if masked
func (s *NotifyStore) Update(nc *NotifyConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
// If password is masked, keep the existing one
if nc.SMTPPassword == "********" {
nc.SMTPPassword = s.Data.SMTPPassword
}
nc.UpdatedAt = time.Now()
s.Data = nc
data, err := json.MarshalIndent(s.Data, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0600)
}
// GetConfig returns the raw config (for internal use, with real password)
func (s *NotifyStore) GetConfig() *NotifyConfig {
s.mu.RLock()
defer s.mu.RUnlock()
nc := *s.Data
return &nc
}
// IsConfigured returns true if at least one notification channel is configured
func (s *NotifyStore) IsConfigured() bool {
s.mu.RLock()
defer s.mu.RUnlock()
if s.Data.FeishuEnabled && s.Data.FeishuURL != "" {
return true
}
if s.Data.SMTPEnabled && s.Data.SMTPHost != "" && s.Data.NotifyTo != "" {
return true
}
// Also check env fallback
return false
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoSSL 证书管理</title>
<script type="module" crossorigin src="/assets/index-CN1uS6R9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BZSCWQUg.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+22 -20
View File
@@ -1,52 +1,54 @@
module auto-ssl module auto-ssl
go 1.21 go 1.22.0
require ( require (
github.com/gin-contrib/cors v1.5.0 github.com/gin-contrib/cors v1.5.0
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/go-acme/lego/v4 v4.14.2 github.com/go-acme/lego/v4 v4.22.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
) )
require ( require (
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1755 // indirect github.com/aliyun/alibaba-cloud-sdk-go v1.63.72 // indirect
github.com/bytedance/sonic v1.10.1 // indirect github.com/bytedance/sonic v1.10.1 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.0 // indirect github.com/chenzhuoyu/iasm v0.9.0 // indirect
github.com/cloudflare/cloudflare-go v0.70.0 // indirect github.com/cloudflare/cloudflare-go v0.112.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.0 // indirect github.com/go-jose/go-jose/v4 v4.0.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.15.5 // indirect github.com/go-playground/validator/v10 v10.16.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.4 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/miekg/dns v1.1.55 // indirect github.com/miekg/dns v1.1.62 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nrdcg/dnspod-go v0.4.0 // indirect github.com/nrdcg/dnspod-go v0.4.0 // indirect
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.5.0 // indirect golang.org/x/arch v0.5.0 // indirect
golang.org/x/crypto v0.14.0 // indirect golang.org/x/crypto v0.31.0 // indirect
golang.org/x/mod v0.11.0 // indirect golang.org/x/mod v0.22.0 // indirect
golang.org/x/net v0.16.0 // indirect golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.13.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.13.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/text v0.21.0 // indirect
golang.org/x/tools v0.10.0 // indirect golang.org/x/time v0.8.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect golang.org/x/tools v0.28.0 // indirect
google.golang.org/protobuf v1.35.2 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+96 -67
View File
@@ -1,24 +1,29 @@
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1755 h1:J45/QHgrzUdqe/Vco/Vxk0wRvdS2nKUxmf/zLgvfass= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1755/go.mod h1:RcDobYh8k5VP6TNybz9m++gL3ijVI5wueVr0EM10VsU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.72 h1:HvFZUzEbNvfe8F2Mg0wBGv90bPhWDxgVtDHR5zoBOU0=
github.com/aliyun/alibaba-cloud-sdk-go v1.63.72/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.1 h1:7a1wuFXL1cMy7a3f7/VFcEtriuXQnUBhtoVfOZiaysc= github.com/bytedance/sonic v1.10.1 h1:7a1wuFXL1cMy7a3f7/VFcEtriuXQnUBhtoVfOZiaysc=
github.com/bytedance/sonic v1.10.1/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= github.com/bytedance/sonic v1.10.1/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/cloudflare/cloudflare-go v0.70.0 h1:4opGbUygM8DjirUuaz23jn3akuAcnOCEx+0nQtQEcFo= github.com/cloudflare/cloudflare-go v0.112.0 h1:caFwqXdGJCl3rjVMgbPEn8iCYAg9JsRYV3dIVQE5d7g=
github.com/cloudflare/cloudflare-go v0.70.0/go.mod h1:VW6GuazkaZ4xEDkFt24lkXQUsE8q7BiGqDniC2s8WEM= github.com/cloudflare/cloudflare-go v0.112.0/go.mod h1:QB55kuJ5ZTeLNFcLJePfMuBilhu/LDKpLBmKFQIoSZ0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/cors v1.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk= github.com/gin-contrib/cors v1.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk=
@@ -27,75 +32,74 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-acme/lego/v4 v4.14.2 h1:/D/jqRgLi8Cbk33sLGtu2pX2jEg3bGJWHyV8kFuUHGM= github.com/go-acme/lego/v4 v4.22.0 h1:2XgjXhivSiNla7Y84o2kzbtz+9a1RYOBqZpGkhlGNWA=
github.com/go-acme/lego/v4 v4.14.2/go.mod h1:kBXxbeTg0x9AgaOYjPSwIeJy3Y33zTz+tMD16O4MO6c= github.com/go-acme/lego/v4 v4.22.0/go.mod h1:E2FndyI3Ekv0usNJt46mFb9LVpV/XBYT+4E3tz02Tzo=
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24= github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE=
github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM=
github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-retryablehttp v0.7.4 h1:ZQgVdpTdAL7WpMIwLzCfbalOcSUdkDZnpUv3/+BxzFA=
github.com/hashicorp/go-retryablehttp v0.7.4/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo=
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nrdcg/dnspod-go v0.4.0 h1:c/jn1mLZNKF3/osJ6mz3QPxTudvPArXTjpkmYj0uK6U= github.com/nrdcg/dnspod-go v0.4.0 h1:c/jn1mLZNKF3/osJ6mz3QPxTudvPArXTjpkmYj0uK6U=
github.com/nrdcg/dnspod-go v0.4.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ= github.com/nrdcg/dnspod-go v0.4.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
@@ -103,63 +107,88 @@ github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o=
github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg=
github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y= golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM= golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+21
View File
@@ -26,6 +26,7 @@ type CreateCertRequest struct {
ChallengeType string `json:"challenge_type"` ChallengeType string `json:"challenge_type"`
DNSProvider string `json:"dns_provider"` DNSProvider string `json:"dns_provider"`
DNSConfig string `json:"dns_config"` DNSConfig string `json:"dns_config"`
CredentialID *uint `json:"credential_id"`
AutoRenew *bool `json:"auto_renew"` AutoRenew *bool `json:"auto_renew"`
RenewDays *int `json:"renew_days"` RenewDays *int `json:"renew_days"`
} }
@@ -51,6 +52,19 @@ func (h *CertHandler) GetCertificate(c *gin.Context) {
c.JSON(http.StatusOK, cert) c.JSON(http.StatusOK, cert)
} }
// resolveCredential fills DNSConfig from credential reference if credential_id is set
func resolveCredential(req *CreateCertRequest) error {
if req.CredentialID != nil && *req.CredentialID > 0 {
cred := config.CredStore.GetByID(*req.CredentialID)
if cred == nil {
return fmt.Errorf("credential not found")
}
req.DNSProvider = string(cred.Type)
req.DNSConfig = cred.Data
}
return nil
}
// CreateCertificate creates a new certificate entry and starts issuance // CreateCertificate creates a new certificate entry and starts issuance
func (h *CertHandler) CreateCertificate(c *gin.Context) { func (h *CertHandler) CreateCertificate(c *gin.Context) {
var req CreateCertRequest var req CreateCertRequest
@@ -68,6 +82,12 @@ func (h *CertHandler) CreateCertificate(c *gin.Context) {
req.Domain = strings.TrimSpace(req.Domain) req.Domain = strings.TrimSpace(req.Domain)
// Resolve credential reference
if err := resolveCredential(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
autoRenew := true autoRenew := true
renewDays := 30 renewDays := 30
if req.AutoRenew != nil { if req.AutoRenew != nil {
@@ -120,6 +140,7 @@ func (h *CertHandler) CreateCertificate(c *gin.Context) {
ChallengeType: req.ChallengeType, ChallengeType: req.ChallengeType,
DNSProvider: req.DNSProvider, DNSProvider: req.DNSProvider,
DNSConfig: req.DNSConfig, DNSConfig: req.DNSConfig,
CredentialID: req.CredentialID,
Status: "pending", Status: "pending",
AutoRenew: autoRenew, AutoRenew: autoRenew,
RenewDays: renewDays, RenewDays: renewDays,
+214
View File
@@ -0,0 +1,214 @@
package handlers
import (
"auto-ssl/config"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
type CredentialHandler struct{}
func NewCredentialHandler() *CredentialHandler {
return &CredentialHandler{}
}
type CreateCredentialRequest struct {
Name string `json:"name" binding:"required"`
Type config.CredentialType `json:"type" binding:"required"`
Data string `json:"data" binding:"required"` // JSON key-value pairs
}
type UpdateCredentialRequest struct {
Name string `json:"name"`
Type config.CredentialType `json:"type"`
Data string `json:"data"` // JSON key-value pairs (or "********" to keep)
}
// ListCredentials returns all credentials (without secrets)
func (h *CredentialHandler) ListCredentials(c *gin.Context) {
creds := config.CredStore.GetAll()
c.JSON(http.StatusOK, creds)
}
// GetCredential returns a single credential
func (h *CredentialHandler) GetCredential(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
cred := config.CredStore.GetByID(uint(id))
if cred == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
return
}
// Never expose raw secrets in API
cred.Data = cred.Masked
c.JSON(http.StatusOK, cred)
}
// CreateCredential creates a new credential
func (h *CredentialHandler) CreateCredential(c *gin.Context) {
var req CreateCredentialRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate JSON data
var testData map[string]string
if err := json.Unmarshal([]byte(req.Data), &testData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "data must be valid JSON key-value pairs"})
return
}
// Trim whitespace from all values
for k, v := range testData {
testData[k] = strings.TrimSpace(v)
}
// Validate fields for the provider type
fields := config.CredentialTypeMeta(req.Type)
for _, f := range fields {
if f.Required {
if _, ok := testData[f.Key]; !ok || testData[f.Key] == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": f.Label + " 是必填项"})
return
}
}
}
// Cloudflare: require either API Token or Email+Key
if req.Type == config.CredentialCloudflare {
hasToken := testData["cf_api_token"] != ""
hasEmailKey := testData["cf_email"] != "" && testData["cf_api_key"] != ""
if !hasToken && !hasEmailKey {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写 API Token,或者同时填写 Email 和 Global API Key"})
return
}
}
// Re-serialize trimmed data
trimmedData, _ := json.Marshal(testData)
cred := &config.Credential{
Name: req.Name,
Type: req.Type,
Data: string(trimmedData),
}
if err := config.CredStore.Create(cred); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Return without secrets (use a copy to avoid mutating store)
resp := *cred
resp.Data = cred.Masked
c.JSON(http.StatusCreated, &resp)
}
// UpdateCredential updates a credential
func (h *CredentialHandler) UpdateCredential(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
existing := config.CredStore.GetByID(uint(id))
if existing == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
return
}
var req UpdateCredentialRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != "" {
existing.Name = req.Name
}
if req.Type != "" {
existing.Type = req.Type
}
if req.Data != "" {
// If it's the masked placeholder, keep existing data unchanged
if req.Data != "********" {
var testData map[string]string
if err := json.Unmarshal([]byte(req.Data), &testData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "data must be valid JSON"})
return
}
// Trim whitespace from all values
for k, v := range testData {
testData[k] = strings.TrimSpace(v)
}
// Cloudflare: require either API Token or Email+Key
if existing.Type == config.CredentialCloudflare {
hasToken := testData["cf_api_token"] != ""
hasEmailKey := testData["cf_email"] != "" && testData["cf_api_key"] != ""
if !hasToken && !hasEmailKey {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写 API Token,或者同时填写 Email 和 Global API Key"})
return
}
}
trimmedData, _ := json.Marshal(testData)
existing.Data = string(trimmedData)
}
}
if err := config.CredStore.Update(existing); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
existing.Data = existing.Masked
c.JSON(http.StatusOK, existing)
}
// DeleteCredential deletes a credential
func (h *CredentialHandler) DeleteCredential(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := config.CredStore.Delete(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "credential deleted"})
}
// GetCredentialTypes returns available credential types and their fields
func (h *CredentialHandler) GetCredentialTypes(c *gin.Context) {
types := []gin.H{
{
"type": config.CredentialAliDNS,
"label": "阿里云 DNS (Aliyun)",
"fields": config.CredentialTypeMeta(config.CredentialAliDNS),
},
{
"type": config.CredentialCloudflare,
"label": "Cloudflare",
"fields": config.CredentialTypeMeta(config.CredentialCloudflare),
},
{
"type": config.CredentialDNSPod,
"label": "DNSPod",
"fields": config.CredentialTypeMeta(config.CredentialDNSPod),
},
}
c.JSON(http.StatusOK, types)
}
+247 -10
View File
@@ -1,9 +1,12 @@
package main package main
import ( import (
"auto-ssl/auth"
"auto-ssl/config" "auto-ssl/config"
"auto-ssl/handlers" "auto-ssl/handlers"
"auto-ssl/notify"
"auto-ssl/services" "auto-ssl/services"
"fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
@@ -18,8 +21,8 @@ import (
func main() { func main() {
cfg := config.Load() cfg := config.Load()
// Initialize certificate store (JSON file persistence) // Initialize all stores (certificates + notify config)
config.InitStore(cfg) config.InitAll(cfg)
// Setup Gin // Setup Gin
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
@@ -39,10 +42,18 @@ func main() {
r.StaticFile("/favicon.svg", "./dist/favicon.svg") r.StaticFile("/favicon.svg", "./dist/favicon.svg")
r.StaticFile("/", "./dist/index.html") r.StaticFile("/", "./dist/index.html")
// API routes // Public routes (no auth required)
r.POST("/api/login", handleLogin)
r.GET("/api/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// API routes with auth
api := r.Group("/api") api := r.Group("/api")
api.Use(authMiddleware())
{ {
certHandler := handlers.NewCertHandler(cfg) certHandler := handlers.NewCertHandler(cfg)
credHandler := handlers.NewCredentialHandler()
// Certificate management // Certificate management
api.GET("/certificates", certHandler.ListCertificates) api.GET("/certificates", certHandler.ListCertificates)
@@ -53,6 +64,20 @@ func main() {
api.POST("/certificates/:id/renew", certHandler.RenewCertificate) api.POST("/certificates/:id/renew", certHandler.RenewCertificate)
api.GET("/certificates/:id/files", certHandler.GetCertFiles) api.GET("/certificates/:id/files", certHandler.GetCertFiles)
// Notification configuration
api.GET("/notify/config", handleGetNotifyConfig)
api.PUT("/notify/config", handleUpdateNotifyConfig)
api.POST("/notify/test-feishu", handleTestFeishu)
api.POST("/notify/test-email", handleTestEmail)
// Credential management
api.GET("/credentials", credHandler.ListCredentials)
api.GET("/credentials/:id", credHandler.GetCredential)
api.POST("/credentials", credHandler.CreateCredential)
api.PUT("/credentials/:id", credHandler.UpdateCredential)
api.DELETE("/credentials/:id", credHandler.DeleteCredential)
api.GET("/credential-types", credHandler.GetCredentialTypes)
// Utility // Utility
api.GET("/renewals/check", certHandler.CheckRenewals) api.GET("/renewals/check", certHandler.CheckRenewals)
api.GET("/stats", certHandler.Stats) api.GET("/stats", certHandler.Stats)
@@ -61,11 +86,11 @@ func main() {
r.NoRoute(func(c *gin.Context) { r.NoRoute(func(c *gin.Context) {
// 静态资源请求直接返回404,避免返回html导致MIME类型错误 // 静态资源请求直接返回404,避免返回html导致MIME类型错误
if strings.HasPrefix(c.Request.URL.Path, "/assets/") || if strings.HasPrefix(c.Request.URL.Path, "/assets/") ||
strings.HasSuffix(c.Request.URL.Path, ".js") || strings.HasSuffix(c.Request.URL.Path, ".js") ||
strings.HasSuffix(c.Request.URL.Path, ".css") || strings.HasSuffix(c.Request.URL.Path, ".css") ||
strings.HasSuffix(c.Request.URL.Path, ".png") || strings.HasSuffix(c.Request.URL.Path, ".png") ||
strings.HasSuffix(c.Request.URL.Path, ".svg") || strings.HasSuffix(c.Request.URL.Path, ".svg") ||
strings.HasSuffix(c.Request.URL.Path, ".ico") { strings.HasSuffix(c.Request.URL.Path, ".ico") {
c.AbortWithStatus(http.StatusNotFound) c.AbortWithStatus(http.StatusNotFound)
return return
} }
@@ -73,8 +98,10 @@ func main() {
c.File("./dist/index.html") c.File("./dist/index.html")
}) })
// Setup cron for auto-renewal (runs daily at 3:00 AM) // Setup cron scheduler
c := cron.New() c := cron.New()
// Daily renewal check at 3:00 AM
c.AddFunc("0 3 * * *", func() { c.AddFunc("0 3 * * *", func() {
log.Println("Running scheduled certificate renewal check...") log.Println("Running scheduled certificate renewal check...")
certs := config.Store.GetActiveWithAutoRenew() certs := config.Store.GetActiveWithAutoRenew()
@@ -93,10 +120,21 @@ func main() {
} }
} }
}) })
// Certificate expiry notification check at 8:00 AM daily
c.AddFunc("0 8 * * *", func() {
nc := config.NotifyCfg.GetConfig()
notifyCfg := notify.ConfigFromStore(nc)
if !notifyCfg.IsConfigured() {
log.Println("Notification not configured, skipping expiry check notification")
return
}
runExpiryNotification(notifyCfg)
})
c.Start() c.Start()
// Set ACME HTTP-01 challenge port from env (default 8082) // Set ACME HTTP-01 challenge port from env (default 8082)
// Nginx on port 80 should proxy .well-known/acme-challenge/ to this port
acmePort := os.Getenv("ACME_PORT") acmePort := os.Getenv("ACME_PORT")
if acmePort == "" { if acmePort == "" {
acmePort = "8082" acmePort = "8082"
@@ -109,3 +147,202 @@ func main() {
log.Fatalf("Failed to start server: %v", err) log.Fatalf("Failed to start server: %v", err)
} }
} }
// ===================== Login Handler =====================
func handleLogin(c *gin.Context) {
var req auth.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请输入用户名和密码"})
return
}
if !auth.Authenticate(req.Username, req.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
token, expiresAt, err := auth.GenerateToken(req.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成token失败"})
return
}
c.JSON(http.StatusOK, auth.LoginResponse{
Token: token,
Username: req.Username,
ExpiresAt: expiresAt,
})
}
// ===================== Auth Middleware =====================
func authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
authHeader = c.Query("token")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未授权,请先登录"})
return
}
claims, err := auth.ValidateToken(authHeader)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token已过期或无效"})
return
}
c.Set("username", claims.Username)
c.Next()
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
tokenString = authHeader
}
claims, err := auth.ValidateToken(tokenString)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token已过期或无效"})
return
}
c.Set("username", claims.Username)
c.Next()
}
}
// ===================== Notification Config Handlers =====================
func handleGetNotifyConfig(c *gin.Context) {
nc := config.NotifyCfg.Get()
c.JSON(http.StatusOK, nc)
}
func handleUpdateNotifyConfig(c *gin.Context) {
var req config.NotifyConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := config.NotifyCfg.Update(&req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "通知配置已更新"})
}
func handleTestFeishu(c *gin.Context) {
nc := config.NotifyCfg.GetConfig()
if !nc.FeishuEnabled || nc.FeishuURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "飞书通知未配置,请先保存配置"})
return
}
notifyCfg := notify.ConfigFromStore(nc)
testCerts := []notify.CertInfo{
{
Domain: "test.example.com",
Status: "active",
DaysLeft: 15,
},
}
if err := notifyCfg.NotifyCertExpiring(testCerts); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "飞书测试消息发送失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "飞书测试消息发送成功"})
}
func handleTestEmail(c *gin.Context) {
nc := config.NotifyCfg.GetConfig()
if !nc.SMTPEnabled || nc.SMTPHost == "" || nc.NotifyTo == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "邮件通知未配置,请先保存配置"})
return
}
notifyCfg := notify.ConfigFromStore(nc)
testCerts := []notify.CertInfo{
{
Domain: "test.example.com",
Status: "active",
DaysLeft: 15,
},
}
if err := notifyCfg.NotifyCertExpiring(testCerts); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "邮件测试消息发送失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "邮件测试消息发送成功"})
}
// ===================== Expiry Notification =====================
func runExpiryNotification(notifyCfg *notify.Config) {
certs := config.Store.GetAll()
var expiringCerts []notify.CertInfo
nc := config.NotifyCfg.GetConfig()
expiryDays := nc.ExpiryDays
if expiryDays <= 0 {
expiryDays = 30
}
for _, cert := range certs {
if cert.Status != "active" || cert.ExpiresAt == nil {
continue
}
daysLeft := int(time.Until(*cert.ExpiresAt).Hours() / 24)
if daysLeft <= expiryDays {
info := notify.CertInfo{
Domain: cert.Domain,
Status: cert.Status,
ExpiresAt: cert.ExpiresAt,
DaysLeft: daysLeft,
}
if daysLeft < 0 {
info.Status = "expired"
}
expiringCerts = append(expiringCerts, info)
}
}
if len(expiringCerts) > 0 {
log.Printf("Found %d expiring certificates, sending notifications", len(expiringCerts))
if err := notifyCfg.NotifyCertExpiring(expiringCerts); err != nil {
log.Printf("Failed to send expiry notifications: %v", err)
}
} else {
log.Println("No expiring certificates found, skipping notification")
}
}
func init() {
// Run startup certificate expiry check after 10 seconds
time.AfterFunc(10*time.Second, func() {
if config.NotifyCfg != nil {
nc := config.NotifyCfg.GetConfig()
notifyCfg := notify.ConfigFromStore(nc)
if notifyCfg.IsConfigured() {
log.Println("Running startup certificate expiry check...")
runExpiryNotification(notifyCfg)
}
}
})
// Validate ADMIN_USER / ADMIN_PASSWORD env setting
adminUser := os.Getenv("ADMIN_USER")
adminPass := os.Getenv("ADMIN_PASSWORD")
if adminUser != "" || adminPass != "" {
log.Printf("Admin credentials configured via environment (user: %s)", adminUser)
}
_ = fmt.Sprintf
}
+313
View File
@@ -0,0 +1,313 @@
package notify
import (
"auto-ssl/config"
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"net/smtp"
"os"
"strings"
"time"
"github.com/jordan-wright/email"
)
// Config holds notification configuration
type Config struct {
FeishuWebhookURL string
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPassword string
SMTPFrom string
NotifyTo string
ExpiryDays int
}
// ConfigFromStore converts store config to notify config
func ConfigFromStore(nc *config.NotifyConfig) *Config {
return &Config{
FeishuWebhookURL: nc.FeishuURL,
SMTPHost: nc.SMTPHost,
SMTPPort: nc.SMTPPort,
SMTPUser: nc.SMTPUser,
SMTPPassword: nc.SMTPPassword,
SMTPFrom: nc.SMTPFrom,
NotifyTo: nc.NotifyTo,
ExpiryDays: nc.ExpiryDays,
}
}
// LoadNotifyConfig reads notification config from environment (legacy fallback)
func LoadNotifyConfig() *Config {
return &Config{
FeishuWebhookURL: os.Getenv("FEISHU_WEBHOOK_URL"),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: os.Getenv("SMTP_PORT"),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
SMTPFrom: os.Getenv("SMTP_FROM"),
NotifyTo: os.Getenv("NOTIFY_TO"),
}
}
// IsConfigured returns true if at least one notification channel is configured
func (c *Config) IsConfigured() bool {
return c.FeishuWebhookURL != "" || (c.SMTPHost != "" && c.NotifyTo != "")
}
// CertInfo holds certificate information for notifications
type CertInfo struct {
Domain string `json:"domain"`
Status string `json:"status"`
ExpiresAt *time.Time `json:"expires_at"`
DaysLeft int `json:"days_left"`
Error string `json:"error,omitempty"`
}
// NotifyCertExpiring sends notifications for expiring certificates
func (c *Config) NotifyCertExpiring(certs []CertInfo) error {
if len(certs) == 0 {
return nil
}
if !c.IsConfigured() {
log.Println("Notification not configured, skipping")
return nil
}
var errs []string
if c.FeishuWebhookURL != "" {
if err := c.sendFeishu(certs); err != nil {
errs = append(errs, fmt.Sprintf("feishu: %v", err))
}
}
if c.SMTPHost != "" && c.NotifyTo != "" {
if err := c.sendEmail(certs); err != nil {
errs = append(errs, fmt.Sprintf("email: %v", err))
}
}
if len(errs) > 0 {
return fmt.Errorf("notification errors: %s", strings.Join(errs, "; "))
}
return nil
}
// Feishu message format
type feishuCard struct {
MsgType string `json:"msg_type"`
Card *feishuCardContent `json:"card"`
}
type feishuCardContent struct {
Header feishuHeader `json:"header"`
Elems []feishuElement `json:"elements"`
}
type feishuHeader struct {
Title feishuText `json:"title"`
Template string `json:"template"`
}
type feishuText struct {
Tag string `json:"tag"`
Content string `json:"content"`
}
type feishuElement struct {
Tag string `json:"tag"`
Text *feishuText `json:"text,omitempty"`
Fields *[]feishuField `json:"fields,omitempty"`
}
type feishuField struct {
IsShort bool `json:"is_short"`
Text feishuText `json:"text"`
}
func (c *Config) sendFeishu(certs []CertInfo) error {
elements := []feishuElement{}
for _, cert := range certs {
domainText := fmt.Sprintf("**域名:** %s", cert.Domain)
statusText := fmt.Sprintf("**状态:** %s", certStatusLabel(cert.Status))
daysText := fmt.Sprintf("**剩余天数:** %d", cert.DaysLeft)
expiresText := "**过期时间:** "
if cert.ExpiresAt != nil {
expiresText += cert.ExpiresAt.Format("2006-01-02 15:04")
} else {
expiresText += "未知"
}
elements = append(elements,
feishuElement{
Tag: "div",
Fields: &[]feishuField{
{IsShort: true, Text: feishuText{Tag: "lark_md", Content: domainText}},
{IsShort: true, Text: feishuText{Tag: "lark_md", Content: statusText}},
},
},
feishuElement{
Tag: "div",
Fields: &[]feishuField{
{IsShort: true, Text: feishuText{Tag: "lark_md", Content: daysText}},
{IsShort: true, Text: feishuText{Tag: "lark_md", Content: expiresText}},
},
},
feishuElement{Tag: "hr"},
)
}
expiringCount := 0
for _, cert := range certs {
if cert.DaysLeft <= 7 {
expiringCount++
}
}
template := "blue"
if expiringCount > 0 {
template = "red"
}
card := &feishuCardContent{
Header: feishuHeader{
Title: feishuText{
Tag: "lark_md",
Content: fmt.Sprintf("🔐 证书到期提醒 (%d 个证书即将到期)", len(certs)),
},
Template: template,
},
Elems: elements,
}
payload := feishuCard{
MsgType: "interactive",
Card: card,
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal feishu message failed: %v", err)
}
resp, err := http.Post(c.FeishuWebhookURL, "application/json", bytes.NewReader(data))
if err != nil {
return fmt.Errorf("send feishu message failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
return fmt.Errorf("feishu webhook returned %d: %v", resp.StatusCode, result)
}
log.Printf("Feishu notification sent for %d certificates", len(certs))
return nil
}
func (c *Config) sendEmail(certs []CertInfo) error {
var sb strings.Builder
sb.WriteString(`
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; padding: 20px; background: #f5f5f5;">
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<h2 style="color: #1a1a2e;">🔐 证书到期提醒</h2>
<p style="color: #666;">以下证书即将到期或已过期,请及时处理:</p>
<table style="width: 100%; border-collapse: collapse; margin-top: 16px;">
<tr style="background: #1890ff; color: white;">
<th style="padding: 10px; text-align: left;">域名</th>
<th style="padding: 10px; text-align: left;">状态</th>
<th style="padding: 10px; text-align: left;">剩余天数</th>
<th style="padding: 10px; text-align: left;">过期时间</th>
</tr>
`)
for i, cert := range certs {
bgColor := "#fff"
if i%2 == 0 {
bgColor = "#f8f9fa"
}
color := "#333"
if cert.DaysLeft <= 7 {
color = "#f5222d"
} else if cert.DaysLeft <= 30 {
color = "#fa8c16"
}
expiresStr := "未知"
if cert.ExpiresAt != nil {
expiresStr = cert.ExpiresAt.Format("2006-01-02 15:04")
}
statusStr := certStatusLabel(cert.Status)
sb.WriteString(fmt.Sprintf(`
<tr style="background: %s;">
<td style="padding: 10px; border-bottom: 1px solid #eee; font-weight: 600; color: %s;">%s</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">%s</td>
<td style="padding: 10px; border-bottom: 1px solid #eee; color: %s; font-weight: 700;">%d 天</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">%s</td>
</tr>
`, bgColor, color, cert.Domain, statusStr, color, cert.DaysLeft, expiresStr))
}
sb.WriteString(`
</table>
<p style="color: #999; font-size: 12px; margin-top: 24px;">
此邮件由 AutoSSL 证书管理系统自动发送,请勿回复。
</p>
</div>
</body>
</html>
`)
htmlContent := sb.String()
subject := fmt.Sprintf("🔐 [AutoSSL] %d 个证书即将到期 - 请及时处理", len(certs))
e := email.NewEmail()
e.From = c.SMTPFrom
if e.From == "" {
e.From = c.SMTPUser
}
e.To = strings.Split(c.NotifyTo, ",")
e.Subject = subject
e.HTML = []byte(htmlContent)
addr := fmt.Sprintf("%s:%s", c.SMTPHost, c.SMTPPort)
var auth smtp.Auth
if c.SMTPUser != "" && c.SMTPPassword != "" {
auth = smtp.PlainAuth("", c.SMTPUser, c.SMTPPassword, c.SMTPHost)
}
if err := e.Send(addr, auth); err != nil {
return fmt.Errorf("send email failed: %v", err)
}
log.Printf("Email notification sent to %s for %d certificates", c.NotifyTo, len(certs))
return nil
}
func certStatusLabel(status string) string {
switch status {
case "active":
return "有效"
case "expired":
return "已过期"
case "error":
return "错误"
case "renewing":
return "续期中"
case "pending":
return "申请中"
default:
return status
}
}
+18 -4
View File
@@ -52,6 +52,8 @@ type DNSConfig struct {
// Cloudflare // Cloudflare
CFAPIToken string `json:"cf_api_token,omitempty"` CFAPIToken string `json:"cf_api_token,omitempty"`
CFEmail string `json:"cf_email,omitempty"`
CFAPIKey string `json:"cf_api_key,omitempty"`
// DNSPod // DNSPod
DNSPodID string `json:"dnspod_id,omitempty"` DNSPodID string `json:"dnspod_id,omitempty"`
@@ -81,8 +83,11 @@ func GetACMECertificate(cert *config.Certificate, cfg *config.Config) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to create DNS provider: %v", err) return fmt.Errorf("failed to create DNS provider: %v", err)
} }
// Use custom DNS timeout for better reliability
if err := client.Challenge.SetDNS01Provider(provider, if err := client.Challenge.SetDNS01Provider(provider,
dns01.AddRecursiveNameservers(dns01.ParseNameservers([]string{"8.8.8.8:53", "1.1.1.1:53"})), dns01.AddRecursiveNameservers(dns01.ParseNameservers([]string{"223.5.5.5:53", "119.29.29.29:53", "8.8.8.8:53"})),
dns01.AddDNSTimeout(120*time.Second),
); err != nil { ); err != nil {
return fmt.Errorf("failed to set DNS-01 provider: %v", err) return fmt.Errorf("failed to set DNS-01 provider: %v", err)
} }
@@ -149,8 +154,11 @@ func RenewCertificate(cert *config.Certificate, cfg *config.Config) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to create DNS provider: %v", err) return fmt.Errorf("failed to create DNS provider: %v", err)
} }
// Use custom DNS timeout for better reliability
if err := client.Challenge.SetDNS01Provider(provider, if err := client.Challenge.SetDNS01Provider(provider,
dns01.AddRecursiveNameservers(dns01.ParseNameservers([]string{"8.8.8.8:53", "1.1.1.1:53"})), dns01.AddRecursiveNameservers(dns01.ParseNameservers([]string{"223.5.5.5:53", "119.29.29.29:53", "8.8.8.8:53"})),
dns01.AddDNSTimeout(120*time.Second),
); err != nil { ); err != nil {
return fmt.Errorf("failed to set DNS-01 provider: %v", err) return fmt.Errorf("failed to set DNS-01 provider: %v", err)
} }
@@ -321,8 +329,14 @@ func getDNSProvider(cert *config.Certificate) (challenge.Provider, error) {
case "cloudflare": case "cloudflare":
cfg := cloudflareprov.NewDefaultConfig() cfg := cloudflareprov.NewDefaultConfig()
if dnsCfg.CFAPIToken != "" { token := strings.TrimSpace(dnsCfg.CFAPIToken)
cfg.AuthToken = dnsCfg.CFAPIToken if token != "" {
cfg.AuthToken = token
}
// Also support email + Global API Key auth
if dnsCfg.CFEmail != "" && dnsCfg.CFAPIKey != "" {
cfg.AuthEmail = strings.TrimSpace(dnsCfg.CFEmail)
cfg.AuthKey = strings.TrimSpace(dnsCfg.CFAPIKey)
} }
provider, err := cloudflareprov.NewDNSProviderConfig(cfg) provider, err := cloudflareprov.NewDNSProviderConfig(cfg)
if err != nil { if err != nil {
@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIAvBPT8bw9RlsWdzY+Ga/bZcaGoRC1o9KQylxwMt2xpwoAoGCCqGSM49
AwEHoUQDQgAEtsNoIrvUk9RE8/tXhzlGTqOZH2oQjxrrlyvnVV7wj2YWo2Q5diNp
oL1EDmDWVBMBO6enEgGkU3OtNyqCmTFBKA==
-----END EC PRIVATE KEY-----
@@ -0,0 +1,6 @@
{
"body": {
"status": "valid"
},
"uri": "https://acme-v02.api.letsencrypt.org/acme/acct/3565382145"
}
@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIF9G4xzfZxxH/8Zp+EabrGlDlH8s5ftlLFXqd5di4wIYoAoGCCqGSM49
AwEHoUQDQgAEo4RIUksl7FCK3+QnlsUSWEBSWuVPwWWsqGeQxXdGu8qqiNGtypXE
ESu0EWaXjQcbZE476xH67DDmtHsEcz0q8g==
-----END EC PRIVATE KEY-----
@@ -0,0 +1,6 @@
{
"body": {
"status": "valid"
},
"uri": "https://acme-v02.api.letsencrypt.org/acme/acct/3565389605"
}
+36
View File
@@ -0,0 +1,36 @@
[
{
"id": 3,
"created_at": "2026-07-25T00:06:36.635806194+08:00",
"updated_at": "2026-07-25T00:08:43.014181606+08:00",
"domain": "harbor.yunwei.blog",
"email": "a@qq.com",
"provider": "letsencrypt",
"challenge_type": "dns",
"dns_provider": "cloudflare",
"dns_config": "{\"cf_api_token\":\"cfut_VLxUPTOTAWBwTmY8WM4uuZYQW0iztWSdXEWhQYB348f7e25a\"}",
"credential_id": 1,
"status": "error",
"error_message": "failed to obtain certificate: error: one or more domains had a problem:\n[harbor.yunwei.blog] propagation: time limit exceeded: last error: authoritative nameservers: NS blakely.ns.cloudflare.com.:53 did not return the expected TXT record [fqdn: _acme-challenge.harbor.yunwei.blog., value: iLtFsQrgk5mSpKlWk69-KKxf0kBHCUK4PivtBRC-vNI]: \n",
"auto_renew": true,
"renew_days": 30
},
{
"id": 1,
"created_at": "2026-07-24T23:10:52.210274514+08:00",
"updated_at": "2026-07-25T00:02:51.1655217+08:00",
"domain": "*.k8s.yunwei.blog",
"email": "a@qq.com",
"provider": "letsencrypt",
"challenge_type": "dns",
"dns_provider": "cloudflare",
"dns_config": "{\"cf_api_key\":\"6eabf25cb487d39f166c3ff4dc3145b5c5fb3\",\"cf_email\":\"mrtom0506@gmail.com\"}",
"credential_id": 1,
"status": "active",
"cert_url": "https://acme-v02.api.letsencrypt.org/acme/cert/06f366c19b0ae2a6bfdedfbfcdecbc5a0f4a",
"expires_at": "2026-10-22T15:04:18Z",
"last_renewed_at": "2026-07-25T00:02:51.165423851+08:00",
"auto_renew": true,
"renew_days": 30
}
]
@@ -0,0 +1,64 @@
-----BEGIN CERTIFICATE-----
MIIE2jCCAsKgAwIBAgIQTr0klH4k05SALYSlL9WzGTANBgkqhkiG9w0BAQsFADAu
MQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQMA4GA1UEAxMHUm9vdCBZUjAe
Fw0yNTA5MDMwMDAwMDBaFw0yODA5MDIyMzU5NTlaMDMxCzAJBgNVBAYTAlVTMRYw
FAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQDEwNZUjIwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQDZ0LxwBppqh84luqMerV/eeL/fXQ7mLQQv1Lnp
WKZbyvGpx6wh6AfnslAnF6ewTkcHA+gSOoBvm3Dfm06AuGiF+KRut4fAcowqnAQQ
CW98+QPP/eOv/wug7Iyk4NkOxf2I6g2f55T6nJoOTLFcukeRq80JGQEYan+dPFr9
OGUgQK2hGKgNkW87pappsOAuUJcroYhRt5uUis4qaZireiseu32gzDJNBAiKtsvd
6HX4v25bpkRNcS/B/Gtc9kVbUpD+2PLPxdei3Tim55k4tfAEXwD2qyiPTxrTNq6l
N+AMr5g2c1dNqkOTwjxeV6L5lpP1rGiYvLnRaPlOqyZRPW+5AgMBAAGjge4wgesw
DgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBIGA1UdEwEB/wQI
MAYBAf8CAQAwHQYDVR0OBBYEFEAVLSZ57TIgnt+ach3WMh+BDIEMMB8GA1UdIwQY
MBaAFN7nW2DQIm1AKH0/DQH+pLVStFGUMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEF
BQcwAoYWaHR0cDovL3lyLmkubGVuY3Iub3JnLzATBgNVHSAEDDAKMAgGBmeBDAEC
ATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veXIuYy5sZW5jci5vcmcvMA0GCSqG
SIb3DQEBCwUAA4ICAQB0ZUQWZ9/Yn9COEpo+JfecMnB0h0vwDm/M66IqXqw3LoaL
mx9lZvRTeDIS67PUeI3yCA2W6PKRD0/FE/G57lOmS+Xy5AaaL00ICGOqjNcCaMWW
8o8nevHOd4i4lqgtznE/28QwlcdJyF8yBiWHpnyjhEpmNWJURgOCOg2xpwRMBCsj
MScqYPtOhBeuYQvSwAEeTML2Ukh6uGuX4E14q65Ja8cdjF5bAldnP1eE4FBaAwsZ
G2fOqqrKV03Y85Nw2btedP1AtliQuJZs/Jo/gXxXdc7LrH3McgnpnbTiAncX7yES
hP6kzQejllqMCIt52HOjxDGWafS7Xw+DKwqmH+Eqy8dcbOuag/1AYlQoKNVK3F5q
Hh6tEDiMqQcLIibGKteE6iHo4A/bIScbzrhXUYuism42ZYzmc48FMVIH3qy4L84E
TdAH2gtxw0PAhvRVXp8HP7wfngpzsN/8xOTpeRSbM4+Qbc56G6+Bifmv6sk1ieQb
NA3wJdl4DDUuQSV8hBgx6zoI1ZSGORprDFux7c6rhc77QZMSRrEgomBeklervEve
86ylWmZ3WWHV6RLMi8xNvjd71r4EPIGgY7BZU/VPBkq+uA7Gb6mbJnFgV43uh3xy
LRFgxIAphIukwTGSMZZR+AI+Qnp0BYTWovHXozOf3H8r6hozEoT02JHn0AeTfA==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIF9DCCA9ygAwIBAgIRAPJLbRf52a18scn+p4eCaZ8wDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjYwNTEzMDAwMDAw
WhcNMzIwOTAyMjM1OTU5WjAuMQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQ
MA4GA1UEAxMHUm9vdCBZUjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
ANvGJnN78CTJdWL3+eGfsLN5TrNBJs+VH9hRXqRbwxu9sGNiB0BD1fcOxbSUQCJI
M1xE13Db+5Cw1w0s0EBYsvuIP/6joF0w8cuImbgR1OGgYbSQ4OpzI+DG8SGuTlcE
873OCS+kh3srlo6vl43M5OJg4Aeo1sfHp6kTJDoIiFBNJAY+OKfX/FUvYKuhjT+n
o49lmqmupSBI5PkBQiqrEGtWU5uxU/cQWHGu8jSjFBznZqvbNPLMXMLFxCb3WTfr
JBXXjqvWG+v4bjzxjjeAtOlU7qarRDvNOyAuQYLln904M+faKx8hnLCpJ15ZqaEg
cNlY+9MMWcC5yvL2A2j3l9+2buggZX+dOE91zYmIdawTvSZuVvlbRrAlLxIB6pwM
BjneXCjYQ8+3BCCjssbSNpZU3hTcBDdhfAlEDlYr6pEatnMdmDT5BqnKC92bd0Eh
M1fbLHioLccLCuievT8ZkPhZrq7Mii7gNXAcUEAR8+lzYal+9zTg7C5DALyVOeG/
CqfRAMn1KSHCR0NSA6P8tn/mGRlnCct5rtVCLnVySVpU6H1qGg3DgTOuskf8eahT
MiYbI5ezPJmO5ertalskQ1utp74+eDy92PI4ftHKTbq9IWhH4YZKh3WnJEIt+oQv
lYZbY8tpEroKrFB6PFGzrJIDRyts4HqvuH52RFj2zv/BAgMBAAGjgeswgegwDgYD
VR0PAQH/BAQDAgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA8GA1UdEwEB/wQFMAMB
Af8wHQYDVR0OBBYEFN7nW2DQIm1AKH0/DQH+pLVStFGUMB8GA1UdIwQYMBaAFHm0
WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcwAoYW
aHR0cDovL3gxLmkubGVuY3Iub3JnLzATBgNVHSAEDDAKMAgGBmeBDAECATAnBgNV
HR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5jci5vcmcvMA0GCSqGSIb3DQEB
CwUAA4ICAQA8spSI95KKfn2W6GMmDpHBJSPaLbsS3W93cijJCRCYAc1fsJgL1FIL
7C0C9ecPOdcwB2fi0Dk2p94j9iTJCxmt5CFSKLRWwnXT2MMSXexVxqoVB79BdWPx
VXETkVme/qYSAuKVHh5Ps+5BixgmwS1JkjSAc+MfrUbNssVEEnH0aEiAh+rotXAV
JSP/Ye7LJPEwD9DWG72vVWbhAcuOf5OLjz57Ctk7MgQHynZ7+PlHJtajroCaIbtC
r6tcZZaAwUQm+jQyeWdV+2hv9deOYFmKeQyjjcSrN5Nadrw+L9DZJLbA1HqeNvLh
BgqpP0fvJq2N6EtD574N6eMI7uMsJTnji2UDz9el5XLSv9fqJMuDQtYVb2oTNoKp
oUqhxPVC0aq4eG5MESaIdn8b5ZGSSeAJLMHXljEdlNza+ncfkviXk1POLnnFdvx8
/gk6M374WbLWFXw8N141B/Rl/tINGfl1TxOIiqtiMYkL02RSGb1kq34BL9NPP27z
RGMuHGnzS3hFIrRTfKxrzUZ9RzQWzEG3K6fJ3r2nqSltkeytis9DIBoFY9VmVyjL
M71DMi+y1+TRSJVClEMwvA4yL++7q9XZx5r5wBRWB4kQTKH5qyoZnDw7iiuh1lID
yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
-----END CERTIFICATE-----
@@ -0,0 +1,93 @@
-----BEGIN CERTIFICATE-----
MIIE/TCCA+WgAwIBAgISBvNmwZsK4qa/3t+/zey8Wg9KMA0GCSqGSIb3DQEBCwUA
MDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD
EwNZUjIwHhcNMjYwNzI0MTUwNDE5WhcNMjYxMDIyMTUwNDE4WjAcMRowGAYDVQQD
DBEqLms4cy55dW53ZWkuYmxvZzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
ggEBANMWZF5U34pLTRMceClLMSZhlGasXb6/25sO6aRUJ8gPIT/pKydq2f2tyXTd
GSD7XljUYJLDHzlEKQykzfFmVqwGoEEMLiycwZvynnIqhG+xKb3v7cKC6ZYRu8HJ
kzfn654owO7zUAPgBJj0OZxKz7xNf4ezclzX4F6rY+rg0L/B7U8hHSwuoyJclSvX
8bdy/AQ0TGS3+DPU6JKO1teub3yJtgh+s/37Pag99msL6nR/0Yy/aAdUn/9g8jLz
QotNIFNy4kD8D209Z2BmUHkssQUt+9Q9QShfO09M/fCRUeq5WU5OPpDku7cBS83X
/nfE6G4oL1+ho2a7xVwfz4bk9LcCAwEAAaOCAiAwggIcMA4GA1UdDwEB/wQEAwIF
oDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBRi
t3Gdgnf4n0DVXGx/A6TPpo5W/DAfBgNVHSMEGDAWgBRAFS0mee0yIJ7fmnId1jIf
gQyBDDAzBggrBgEFBQcBAQQnMCUwIwYIKwYBBQUHMAKGF2h0dHA6Ly95cjIuaS5s
ZW5jci5vcmcvMBwGA1UdEQQVMBOCESouazhzLnl1bndlaS5ibG9nMBMGA1UdIAQM
MAowCAYGZ4EMAQIBMC4GA1UdHwQnMCUwI6AhoB+GHWh0dHA6Ly95cjIuYy5sZW5j
ci5vcmcvNzUuY3JsMIIBDQYKKwYBBAHWeQIEAgSB/gSB+wD5AHYA1219ENGn9XfC
x+lf1wC/+YLJM1pl4dCzAXMXwMjFaXcAAAGflN14PgAABAMARzBFAiAvOKW3dS0K
rOjEFyssS6V75ZIQzDFFu7VhUXuvpI/iUAIhAIq95wCyjIan2CFVVGMbeVsIITtF
MIG7yKUO/pFJoufvAH8AbP5QGUOoXqkWvFLRM+TcyR7xQRx9JYQg0XOAnhgY6zoA
AAGflN15ewAIAAAFABc0C34EAwBIMEYCIQCuzaieKwTF6J6fImxKxDk7Rt/xxDGM
sBTDNxke3FM0XgIhAPZgnXt1Y1ikndEY77Rf13C5iN1iIVcr0YrQxO6wmVIDMA0G
CSqGSIb3DQEBCwUAA4IBAQBDXJJa77KhUucsvKuMm+GKSJb5uDQIlh7JEiD/Hijx
2Nor+Yz2SlE4feht5Zt7cDRjacm51SjjySFJ4hqntZGHpCsNYF+VJaE93fFSSVVd
YjqG22XWYcXImChoJ3jseTwLff5m8Pm8umA2Z/TnvpwiQZf08ccLxQ3Qdb5W4GDL
9qzDrkRT1PL1M0WdNeWc+0wRlmYq12dJirn6mGxTQedyfQMqhchX8iGkZ33KiNd/
kcgbURVttvw60hEjPbcURz2Rwl9dZz0qGPdG9bTpnpPtyBHNolNMfRRm5SuPDqQh
X1Wyfhmu3yj2P6SokmVH+lXaMd1vGbZhvLvz1TUou5iG
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIE2jCCAsKgAwIBAgIQTr0klH4k05SALYSlL9WzGTANBgkqhkiG9w0BAQsFADAu
MQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQMA4GA1UEAxMHUm9vdCBZUjAe
Fw0yNTA5MDMwMDAwMDBaFw0yODA5MDIyMzU5NTlaMDMxCzAJBgNVBAYTAlVTMRYw
FAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQDEwNZUjIwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQDZ0LxwBppqh84luqMerV/eeL/fXQ7mLQQv1Lnp
WKZbyvGpx6wh6AfnslAnF6ewTkcHA+gSOoBvm3Dfm06AuGiF+KRut4fAcowqnAQQ
CW98+QPP/eOv/wug7Iyk4NkOxf2I6g2f55T6nJoOTLFcukeRq80JGQEYan+dPFr9
OGUgQK2hGKgNkW87pappsOAuUJcroYhRt5uUis4qaZireiseu32gzDJNBAiKtsvd
6HX4v25bpkRNcS/B/Gtc9kVbUpD+2PLPxdei3Tim55k4tfAEXwD2qyiPTxrTNq6l
N+AMr5g2c1dNqkOTwjxeV6L5lpP1rGiYvLnRaPlOqyZRPW+5AgMBAAGjge4wgesw
DgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBIGA1UdEwEB/wQI
MAYBAf8CAQAwHQYDVR0OBBYEFEAVLSZ57TIgnt+ach3WMh+BDIEMMB8GA1UdIwQY
MBaAFN7nW2DQIm1AKH0/DQH+pLVStFGUMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEF
BQcwAoYWaHR0cDovL3lyLmkubGVuY3Iub3JnLzATBgNVHSAEDDAKMAgGBmeBDAEC
ATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veXIuYy5sZW5jci5vcmcvMA0GCSqG
SIb3DQEBCwUAA4ICAQB0ZUQWZ9/Yn9COEpo+JfecMnB0h0vwDm/M66IqXqw3LoaL
mx9lZvRTeDIS67PUeI3yCA2W6PKRD0/FE/G57lOmS+Xy5AaaL00ICGOqjNcCaMWW
8o8nevHOd4i4lqgtznE/28QwlcdJyF8yBiWHpnyjhEpmNWJURgOCOg2xpwRMBCsj
MScqYPtOhBeuYQvSwAEeTML2Ukh6uGuX4E14q65Ja8cdjF5bAldnP1eE4FBaAwsZ
G2fOqqrKV03Y85Nw2btedP1AtliQuJZs/Jo/gXxXdc7LrH3McgnpnbTiAncX7yES
hP6kzQejllqMCIt52HOjxDGWafS7Xw+DKwqmH+Eqy8dcbOuag/1AYlQoKNVK3F5q
Hh6tEDiMqQcLIibGKteE6iHo4A/bIScbzrhXUYuism42ZYzmc48FMVIH3qy4L84E
TdAH2gtxw0PAhvRVXp8HP7wfngpzsN/8xOTpeRSbM4+Qbc56G6+Bifmv6sk1ieQb
NA3wJdl4DDUuQSV8hBgx6zoI1ZSGORprDFux7c6rhc77QZMSRrEgomBeklervEve
86ylWmZ3WWHV6RLMi8xNvjd71r4EPIGgY7BZU/VPBkq+uA7Gb6mbJnFgV43uh3xy
LRFgxIAphIukwTGSMZZR+AI+Qnp0BYTWovHXozOf3H8r6hozEoT02JHn0AeTfA==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIF9DCCA9ygAwIBAgIRAPJLbRf52a18scn+p4eCaZ8wDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjYwNTEzMDAwMDAw
WhcNMzIwOTAyMjM1OTU5WjAuMQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQ
MA4GA1UEAxMHUm9vdCBZUjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
ANvGJnN78CTJdWL3+eGfsLN5TrNBJs+VH9hRXqRbwxu9sGNiB0BD1fcOxbSUQCJI
M1xE13Db+5Cw1w0s0EBYsvuIP/6joF0w8cuImbgR1OGgYbSQ4OpzI+DG8SGuTlcE
873OCS+kh3srlo6vl43M5OJg4Aeo1sfHp6kTJDoIiFBNJAY+OKfX/FUvYKuhjT+n
o49lmqmupSBI5PkBQiqrEGtWU5uxU/cQWHGu8jSjFBznZqvbNPLMXMLFxCb3WTfr
JBXXjqvWG+v4bjzxjjeAtOlU7qarRDvNOyAuQYLln904M+faKx8hnLCpJ15ZqaEg
cNlY+9MMWcC5yvL2A2j3l9+2buggZX+dOE91zYmIdawTvSZuVvlbRrAlLxIB6pwM
BjneXCjYQ8+3BCCjssbSNpZU3hTcBDdhfAlEDlYr6pEatnMdmDT5BqnKC92bd0Eh
M1fbLHioLccLCuievT8ZkPhZrq7Mii7gNXAcUEAR8+lzYal+9zTg7C5DALyVOeG/
CqfRAMn1KSHCR0NSA6P8tn/mGRlnCct5rtVCLnVySVpU6H1qGg3DgTOuskf8eahT
MiYbI5ezPJmO5ertalskQ1utp74+eDy92PI4ftHKTbq9IWhH4YZKh3WnJEIt+oQv
lYZbY8tpEroKrFB6PFGzrJIDRyts4HqvuH52RFj2zv/BAgMBAAGjgeswgegwDgYD
VR0PAQH/BAQDAgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA8GA1UdEwEB/wQFMAMB
Af8wHQYDVR0OBBYEFN7nW2DQIm1AKH0/DQH+pLVStFGUMB8GA1UdIwQYMBaAFHm0
WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcwAoYW
aHR0cDovL3gxLmkubGVuY3Iub3JnLzATBgNVHSAEDDAKMAgGBmeBDAECATAnBgNV
HR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5jci5vcmcvMA0GCSqGSIb3DQEB
CwUAA4ICAQA8spSI95KKfn2W6GMmDpHBJSPaLbsS3W93cijJCRCYAc1fsJgL1FIL
7C0C9ecPOdcwB2fi0Dk2p94j9iTJCxmt5CFSKLRWwnXT2MMSXexVxqoVB79BdWPx
VXETkVme/qYSAuKVHh5Ps+5BixgmwS1JkjSAc+MfrUbNssVEEnH0aEiAh+rotXAV
JSP/Ye7LJPEwD9DWG72vVWbhAcuOf5OLjz57Ctk7MgQHynZ7+PlHJtajroCaIbtC
r6tcZZaAwUQm+jQyeWdV+2hv9deOYFmKeQyjjcSrN5Nadrw+L9DZJLbA1HqeNvLh
BgqpP0fvJq2N6EtD574N6eMI7uMsJTnji2UDz9el5XLSv9fqJMuDQtYVb2oTNoKp
oUqhxPVC0aq4eG5MESaIdn8b5ZGSSeAJLMHXljEdlNza+ncfkviXk1POLnnFdvx8
/gk6M374WbLWFXw8N141B/Rl/tINGfl1TxOIiqtiMYkL02RSGb1kq34BL9NPP27z
RGMuHGnzS3hFIrRTfKxrzUZ9RzQWzEG3K6fJ3r2nqSltkeytis9DIBoFY9VmVyjL
M71DMi+y1+TRSJVClEMwvA4yL++7q9XZx5r5wBRWB4kQTKH5qyoZnDw7iiuh1lID
yDFx8r7i9vIJU5HS3moZLkYWAOilMaV9N56A9Bgb6dNcHkvg3NoaYA==
-----END CERTIFICATE-----
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA0xZkXlTfiktNExx4KUsxJmGUZqxdvr/bmw7ppFQnyA8hP+kr
J2rZ/a3JdN0ZIPteWNRgksMfOUQpDKTN8WZWrAagQQwuLJzBm/KeciqEb7Epve/t
woLplhG7wcmTN+frnijA7vNQA+AEmPQ5nErPvE1/h7NyXNfgXqtj6uDQv8HtTyEd
LC6jIlyVK9fxt3L8BDRMZLf4M9Toko7W165vfIm2CH6z/fs9qD32awvqdH/RjL9o
B1Sf/2DyMvNCi00gU3LiQPwPbT1nYGZQeSyxBS371D1BKF87T0z98JFR6rlZTk4+
kOS7twFLzdf+d8TobigvX6GjZrvFXB/PhuT0twIDAQABAoIBABTfEe2B1tiFuYk6
N2dd1+5Dl7jPMjW6IqyiPNfSulw9orv/AhVTq1o55MF5WxK3yAxgc2wPfvbalnR5
lyNXSxqXTSEhYy9mEH6q7p+7NH0Wt1ngb1HcP9xmxHnUxImylMl+nhUCWdcIP2wg
OdIKU5/6ybFRPO2RKs4xhB+lOv66sAsZzMjLl94ufKg9CC/yXxaoP00wDi/aIUPR
SLrf7f5pOipeYTtKeahFlYe4tgtTABTCyJi6sh/lhznFFkHav3a10n9cl1DLoMsv
pjq62ZsNLlZBoca+jvCquWI2MDUvr9eMWhydX/wkha8QPyRlJb7nUbB6mXKFBRb3
KuHXuoECgYEA8ZbzhllP81+O7HgfC5yTyh0NrvST7vKMRFjHiR3OY93Zv2G6AH/o
jd9wLorgZdu1N4wkJPlpnif2/gAtZP6D2tODI9sC71fW3ZYt3F1pZHQfcf0h+qRi
TO4I+rL9ZngtrPgE7NlpV2lqC1Sjog4l/L9UB9wHWL1+TFj3KGCHoRsCgYEA362s
9isIYP6aB+IdYr9uuC7rmCqio32i/Mz083zaAZJkZ6ex6XiNwBxFVZJ9x3Cco88w
iqim8Z7a0XXrylAQpYVqo6U4EkfaJkf9nMjqUWPo36t+MpbwEIs8B8lJ3lspf83n
Rz+4NSba0HNIBuVXuZZK9yqflqBziFdgNGJPkJUCgYA1PcofYpFvFjEeRf65hWFA
mpSIaRgtAcEN0u7udeYlNiAlwDjIoWez1HkZmLuJVxkhy72vdFPlafNR2OIoLrsK
P7DX9w03iAkvPkiPFvsCC2FesqWJYi7Tu2W5MjcMHRIb23Dibat8exvGsXl6Xlna
0KOJM+Femiw6USTu/g8F3wKBgQCSfWL+YZZ+N6BXM149ctQnags/wAR+AGVDHVP7
NzRrvR1myWv6VMq5G4rJDUS03mqn2aSX2vZi3b+/WHRRLniIfsUlYapERqx3T8Sn
7umO3lkAS4c9yJ52p0lsbAalypx2KDqk7fVNUiISTL3KTl9SmzyDfXXBJ1oi296U
0CH9tQKBgA4dX9/5GoWWZ3/5dRny3lb4z8AQQFoCj2fTKIN5usNUzsAOUMZp+p24
h/BzmUtcGUlKNPwg4LYjndIuK4Eembuyaiif5OdaDWWePacsP5pudIYlibXUyZqb
Ad3K4WIp0pVjzqt7HGXwDluBS3y1YApX/kky1o3b4vxoh8me8AX4
-----END RSA PRIVATE KEY-----
+20
View File
@@ -0,0 +1,20 @@
[
{
"id": 1,
"name": "cloudflare_yunweiblog",
"type": "cloudflare",
"data": "{\"cf_api_token\":\"cfut_VLxUPTOTAWBwTmY8WM4uuZYQW0iztWSdXEWhQYB348f7e25a\"}",
"masked": "{\"cf_api_token\":\"cfut****\"}",
"created_at": "2026-07-24T23:08:11.019112775+08:00",
"updated_at": "2026-07-24T23:08:11.019112775+08:00"
},
{
"id": 3,
"name": "cloudflare_globalcloudflare_global",
"type": "cloudflare",
"data": "{\"cf_api_key\":\"6eabf25cb487d39f166c3ff4dc3145b5c5fb3\",\"cf_email\":\"mrtom0506@gmail.com\"}",
"masked": "{\"cf_api_key\":\"6eab****\",\"cf_email\":\"mrto****\"}",
"created_at": "2026-07-24T23:52:19.826553941+08:00",
"updated_at": "2026-07-24T23:52:19.826553941+08:00"
}
]
+58 -25
View File
@@ -1,35 +1,53 @@
#!/bin/bash #!/bin/bash
set -e set -e
DEPLOY_DIR="$PWD"
echo "=== Auto-SSL 一键部署脚本 ===" echo "=== Auto-SSL 一键部署脚本 ==="
# 1. 拉取最新代码 # 1. 拉取最新代码
echo "1. 拉取最新代码..." echo "1. 拉取最新代码..."
git pull origin 0.01 git pull origin master 2>/dev/null || git pull origin 0.0.2 2>/dev/null || echo " (跳过git拉取)"
# 检查是否有构建环境
HAS_GO=$(command -v go &>/dev/null && echo "yes" || echo "no")
HAS_NPM=$(command -v npm &>/dev/null && echo "yes" || echo "no")
# 2. 编译后端 # 2. 编译后端
echo "2. 编译Go后端..." if [ "$HAS_GO" = "yes" ]; then
cd backend echo "2. 编译Go后端..."
go build -o autossl . cd backend
cd .. go build -o autossl .
cd "$DEPLOY_DIR"
else
echo "2. 跳过Go编译(未安装go),使用预编译二进制"
fi
# 3. 编译前端 # 3. 编译前端
echo "3. 编译Vue前端..." if [ "$HAS_NPM" = "yes" ]; then
cd frontend echo "3. 编译Vue前端..."
npm install --include=dev cd frontend
npm run build npm install --include=dev
cd .. npm run build
cd "$DEPLOY_DIR"
else
echo "3. 跳过前端编译(未安装npm),使用预构建dist"
fi
# 4. 部署前端资源... # 4. 部署前端资源
echo "4. 部署前端资源..." echo "4. 部署前端资源..."
rm -rf backend/dist if [ -d "frontend/dist" ] && [ -n "$(ls -A frontend/dist 2>/dev/null)" ]; then
mkdir -p backend/dist rm -rf backend/dist
cp -rf frontend/dist/* backend/dist/ mkdir -p backend/dist
cp -rf frontend/dist/* backend/dist/
echo " 前端资源已部署"
else
echo " frontend/dist 为空或不存,跳过"
fi
# 5. 创建systemd服务 # 5. 安装systemd服务
echo "5. 安装systemd服务..." echo "5. 安装systemd服务..."
# 直接生成正确路径的service文件,避免sed替换失败 cat > /tmp/autossl.service << EOSERVICE
cat > /tmp/autossl.service << EOF
[Unit] [Unit]
Description=Auto-SSL 证书管理服务 Description=Auto-SSL 证书管理服务
After=network.target After=network.target
@@ -38,11 +56,23 @@ Wants=network.target
[Service] [Service]
Type=simple Type=simple
User=root User=root
WorkingDirectory=$PWD/backend WorkingDirectory=${DEPLOY_DIR}/backend
Environment="PORT=9090" Environment="PORT=9090"
Environment="ACME_PORT=8082" Environment="ACME_PORT=8082"
Environment="GIN_MODE=release" Environment="GIN_MODE=release"
ExecStart=$PWD/backend/autossl Environment="ADMIN_USER=admin"
Environment="ADMIN_PASSWORD=admin123"
Environment="JWT_SECRET="
# 通知配置可在 Web 界面中设置,无需环境变量
# 环境变量会覆盖 Web 界面配置(向后兼容)
# Environment="FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx"
# Environment="SMTP_HOST=smtp.example.com"
# Environment="SMTP_PORT=587"
# Environment="SMTP_USER=user@example.com"
# Environment="SMTP_PASSWORD=password"
# Environment="SMTP_FROM=user@example.com"
# Environment="NOTIFY_TO=admin@example.com"
ExecStart=${DEPLOY_DIR}/backend/autossl
Restart=always Restart=always
RestartSec=3 RestartSec=3
StandardOutput=journal+console StandardOutput=journal+console
@@ -51,21 +81,24 @@ SyslogIdentifier=autossl
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOSERVICE
sudo cp /tmp/autossl.service /etc/systemd/system/autossl.service cp /tmp/autossl.service /etc/systemd/system/autossl.service
sudo systemctl daemon-reload systemctl daemon-reload
sudo systemctl enable autossl systemctl enable autossl
# 6. 重启服务 # 6. 重启服务
echo "6. 重启服务..." echo "6. 重启服务..."
sudo systemctl restart autossl systemctl restart autossl
# 7. 检查状态 # 7. 检查状态
sleep 2 sleep 2
echo "7. 检查服务状态..." echo "7. 检查服务状态..."
if curl -s http://127.0.0.1:9090/api/stats > /dev/null; then if curl -s http://127.0.0.1:9090/api/health > /dev/null; then
echo "✅ 部署成功!服务运行在 http://0.0.0.0:9090" echo "✅ 部署成功!服务运行在 http://0.0.0.0:9090"
echo "📝 日志查看:journalctl -u autossl -f" echo "📝 日志查看:journalctl -u autossl -f"
echo ""
echo "默认登录账号: admin / admin123"
echo "请通过环境变量 ADMIN_USER / ADMIN_PASSWORD 修改密码"
else else
echo "❌ 部署失败,请检查日志:journalctl -u autossl -n 50" echo "❌ 部署失败,请检查日志:journalctl -u autossl -n 50"
exit 1 exit 1
Symlink
+1
View File
@@ -0,0 +1 @@
frontend/dist
+12
View File
@@ -9,5 +9,17 @@ services:
environment: environment:
- PORT=8080 - PORT=8080
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
# 登录认证
- ADMIN_USER=admin
- ADMIN_PASSWORD=admin123
- JWT_SECRET=change-this-to-a-random-secret
# 飞书通知(可选)
# - FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx
# 邮件通知(可选)
# - SMTP_HOST=smtp.example.com
# - SMTP_PORT=587
# - SMTP_USER=user@example.com
# - SMTP_PASSWORD=your-p...(可忽略)xxxxxxxxxxxxx
# 可选:重置本文其他环境变量
volumes: volumes:
- ./data:/app/data - ./data:/app/data
+1753
View File
File diff suppressed because it is too large Load Diff
+100 -38
View File
@@ -1,49 +1,91 @@
<template> <template>
<div id="app"> <div id="app">
<el-container style="min-height: 100vh"> <template v-if="route.path === '/login'">
<el-header class="app-header"> <router-view />
<div class="header-left"> </template>
<el-icon :size="24"><Link /></el-icon> <template v-else>
<span class="header-title">AutoSSL 证书管理</span> <el-container style="min-height: 100vh">
</div> <el-header class="app-header">
<div class="header-right"> <div class="header-left">
<el-tag type="success" size="small">运行中</el-tag> <el-icon :size="24"><Link /></el-icon>
</div> <span class="header-title">AutoSSL 证书管理</span>
</el-header> </div>
<el-container> <div class="header-right">
<el-aside width="200px"> <el-tag type="success" size="small" style="margin-right: 12px">运行中</el-tag>
<el-menu <el-dropdown trigger="click" @command="handleCommand">
:default-active="route.path" <span class="user-dropdown">
router <el-icon><UserFilled /></el-icon>
background-color="#001529" {{ username }}
text-color="#ffffffb3" <el-icon><ArrowDown /></el-icon>
active-text-color="#fff" </span>
> <template #dropdown>
<el-menu-item index="/"> <el-dropdown-menu>
<el-icon><HomeFilled /></el-icon> <el-dropdown-item command="logout">
<span>仪表盘</span> <el-icon><SwitchButton /></el-icon> 退出登录
</el-menu-item> </el-dropdown-item>
<el-menu-item index="/certificates"> </el-dropdown-menu>
<el-icon><Document /></el-icon> </template>
<span>证书列表</span> </el-dropdown>
</el-menu-item> </div>
<el-menu-item index="/create"> </el-header>
<el-icon><Plus /></el-icon> <el-container>
<span>申请证书</span> <el-aside width="200px">
</el-menu-item> <el-menu
</el-menu> :default-active="route.path"
</el-aside> router
<el-main> background-color="#001529"
<router-view /> text-color="#ffffffb3"
</el-main> active-text-color="#fff"
>
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/certificates">
<el-icon><Document /></el-icon>
<span>证书列表</span>
</el-menu-item>
<el-menu-item index="/create">
<el-icon><Plus /></el-icon>
<span>申请证书</span>
</el-menu-item>
<el-menu-item index="/notify">
<el-icon><Bell /></el-icon>
<span>通知配置</span>
</el-menu-item>
<el-menu-item index="/credentials">
<el-icon><Key /></el-icon>
<span>DNS 凭证</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container> </el-container>
</el-container> </template>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from 'vue-router' import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowDown, SwitchButton } from '@element-plus/icons-vue'
const route = useRoute() const route = useRoute()
const router = useRouter()
const username = ref(localStorage.getItem('username') || 'admin')
const handleCommand = (cmd: string) => {
if (cmd === 'logout') {
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('token_expires_at')
router.push('/login')
}
}
</script> </script>
<style> <style>
@@ -81,6 +123,26 @@ body {
letter-spacing: 1px; letter-spacing: 1px;
} }
.header-right {
display: flex;
align-items: center;
}
.user-dropdown {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 4px 12px;
border-radius: 4px;
transition: background 0.2s;
font-size: 14px;
}
.user-dropdown:hover {
background: rgba(255, 255, 255, 0.15);
}
.el-aside { .el-aside {
background-color: #001529; background-color: #001529;
min-height: calc(100vh - 60px); min-height: calc(100vh - 60px);
+116
View File
@@ -5,6 +5,35 @@ const api = axios.create({
timeout: 300000, timeout: 300000,
}) })
// Request interceptor: attach JWT token to all requests
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor: handle 401 unauthorized (redirect to login)
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('token_expires_at')
// Only redirect if not already on login page
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
export interface Certificate { export interface Certificate {
id: number id: number
domain: string domain: string
@@ -42,6 +71,75 @@ export interface CreateCertRequest {
renew_days?: number renew_days?: number
} }
export interface LoginResponse {
token: string
username: string
expires_at: number
}
export interface NotifyConfig {
feishu_enabled: boolean
feishu_url: string
smtp_enabled: boolean
smtp_host: string
smtp_port: string
smtp_user: string
smtp_password: string
smtp_from: string
notify_to: string
expiry_days: number
updated_at: string
}
export interface Credential {
id: number
name: string
type: string
data: string
masked: string
created_at: string
updated_at: string
}
export interface CredentialField {
key: string
label: string
type: string
required: boolean
}
export interface CredentialType {
type: string
label: string
fields: CredentialField[]
}
// Auth API
export const authApi = {
login: (username: string, password: string) =>
api.post<LoginResponse>('/login', { username, password }),
health: () => api.get('/health'),
}
// Check if user is logged in
export const isLoggedIn = (): boolean => {
const token = localStorage.getItem('token')
if (!token) return false
const expiresAt = localStorage.getItem('token_expires_at')
if (expiresAt) {
const now = Math.floor(Date.now() / 1000)
if (now >= Number(expiresAt)) {
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('token_expires_at')
return false
}
}
return true
}
// Certificate API
export const certApi = { export const certApi = {
list: () => api.get<Certificate[]>('/certificates'), list: () => api.get<Certificate[]>('/certificates'),
get: (id: number) => api.get<Certificate>(`/certificates/${id}`), get: (id: number) => api.get<Certificate>(`/certificates/${id}`),
@@ -53,3 +151,21 @@ export const certApi = {
checkRenewals: () => api.get('/renewals/check'), checkRenewals: () => api.get('/renewals/check'),
stats: () => api.get<Stats>('/stats'), stats: () => api.get<Stats>('/stats'),
} }
// Notification config API
export const notifyApi = {
get: () => api.get<NotifyConfig>('/notify/config'),
update: (data: Partial<NotifyConfig>) => api.put('/notify/config', data),
testFeishu: () => api.post('/notify/test-feishu'),
testEmail: () => api.post('/notify/test-email'),
}
// Credential API
export const credApi = {
list: () => api.get<Credential[]>('/credentials'),
get: (id: number) => api.get<Credential>(`/credentials/${id}`),
create: (data: { name: string; type: string; data: string }) => api.post<Credential>('/credentials', data),
update: (id: number, data: { name?: string; type?: string; data?: string }) => api.put<Credential>(`/credentials/${id}`, data),
delete: (id: number) => api.delete(`/credentials/${id}`),
types: () => api.get<CredentialType[]>('/credential-types'),
}
+60 -3
View File
@@ -2,14 +2,71 @@ import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '../views/Dashboard.vue' import Dashboard from '../views/Dashboard.vue'
import CertList from '../views/CertList.vue' import CertList from '../views/CertList.vue'
import CertCreate from '../views/CertCreate.vue' import CertCreate from '../views/CertCreate.vue'
import NotifyConfig from '../views/NotifyConfig.vue'
import Credentials from '../views/Credentials.vue'
import Login from '../views/Login.vue'
import { isLoggedIn } from '../api'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/', name: 'Dashboard', component: Dashboard }, {
{ path: '/certificates', name: 'CertList', component: CertList }, path: '/login',
{ path: '/create', name: 'CertCreate', component: CertCreate }, name: 'Login',
component: Login,
meta: { requiresAuth: false },
},
{
path: '/',
name: 'Dashboard',
component: Dashboard,
meta: { requiresAuth: true },
},
{
path: '/certificates',
name: 'CertList',
component: CertList,
meta: { requiresAuth: true },
},
{
path: '/create',
name: 'CertCreate',
component: CertCreate,
meta: { requiresAuth: true },
},
{
path: '/notify',
name: 'NotifyConfig',
component: NotifyConfig,
meta: { requiresAuth: true },
},
{
path: '/credentials',
name: 'Credentials',
component: Credentials,
meta: { requiresAuth: true },
},
], ],
}) })
// Navigation guard
router.beforeEach((to, _, next) => {
const requiresAuth = to.meta.requiresAuth !== false
if (requiresAuth) {
if (isLoggedIn()) {
next()
} else {
next({ name: 'Login', query: { redirect: to.fullPath } })
}
} else {
// If already logged in and going to login, redirect to dashboard
if (to.name === 'Login' && isLoggedIn()) {
next({ name: 'Dashboard' })
} else {
next()
}
}
})
export default router export default router
+225 -39
View File
@@ -8,7 +8,7 @@
:model="form" :model="form"
:rules="rules" :rules="rules"
label-width="140px" label-width="140px"
style="max-width: 600px" style="max-width: 620px"
> >
<el-form-item label="域名" prop="domain"> <el-form-item label="域名" prop="domain">
<el-input v-model="form.domain" placeholder="example.com" /> <el-input v-model="form.domain" placeholder="example.com" />
@@ -32,39 +32,84 @@
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="DNS 提供商" v-if="form.challenge_type === 'dns'" prop="dns_provider"> <!-- DNS mode: credential or manual -->
<el-select v-model="form.dns_provider" placeholder="选择 DNS 提供商" style="width: 100%"> <template v-if="form.challenge_type === 'dns'">
<el-option label="阿里云 DNS (Aliyun)" value="alidns" /> <el-form-item label="使用方式">
<el-option label="Cloudflare" value="cloudflare" /> <el-radio-group v-model="credMode">
<el-option label="DNSPod" value="dnspod" /> <el-radio value="credential">选择已有凭证</el-radio>
</el-select> <el-radio value="manual">手动输入</el-radio>
</el-form-item> </el-radio-group>
</el-form-item>
<!-- Aliyun DNS Config --> <!-- Select existing credential -->
<template v-if="form.challenge_type === 'dns' && form.dns_provider === 'alidns'"> <template v-if="credMode === 'credential'">
<el-form-item label="AccessKey ID" prop="ali_key"> <el-form-item label="选择凭证">
<el-input v-model="dnsConfig.ali_key" placeholder="Aliyun AccessKey ID" /> <el-select v-model="selectedCredId" placeholder="选择已保存的 DNS 凭证" style="width: 100%" @change="onCredSelected">
</el-form-item> <el-option
<el-form-item label="AccessKey Secret" prop="ali_secret"> v-for="cred in credentials"
<el-input v-model="dnsConfig.ali_secret" type="password" show-password placeholder="Aliyun AccessKey Secret" /> :key="cred.id"
</el-form-item> :label="cred.name + ' (' + typeLabel(cred.type) + ')'"
</template> :value="cred.id"
/>
</el-select>
<div class="form-tip" v-if="credentials.length === 0">
暂无凭证请先在 <router-link to="/credentials">DNS 凭证</router-link> 中添加
</div>
</el-form-item>
</template>
<!-- Cloudflare Config --> <!-- Manual input -->
<template v-if="form.challenge_type === 'dns' && form.dns_provider === 'cloudflare'"> <template v-if="credMode === 'manual'">
<el-form-item label="API Token"> <el-form-item label="DNS 提供商" prop="dns_provider">
<el-input v-model="dnsConfig.cf_api_token" type="password" show-password placeholder="Cloudflare API Token" /> <el-select v-model="form.dns_provider" placeholder="选择 DNS 提供商" style="width: 100%" @change="onProviderChange">
</el-form-item> <el-option label="阿里云 DNS (Aliyun)" value="alidns" />
</template> <el-option label="Cloudflare" value="cloudflare" />
<el-option label="DNSPod" value="dnspod" />
</el-select>
</el-form-item>
<!-- DNSPod Config --> <!-- Aliyun DNS Config -->
<template v-if="form.challenge_type === 'dns' && form.dns_provider === 'dnspod'"> <template v-if="form.dns_provider === 'alidns'">
<el-form-item label="ID"> <el-form-item label="AccessKey ID">
<el-input v-model="dnsConfig.dnspod_id" placeholder="DNSPod ID" /> <el-input v-model="manualConfig.ali_key" placeholder="Aliyun AccessKey ID" />
</el-form-item> </el-form-item>
<el-form-item label="Key"> <el-form-item label="AccessKey Secret">
<el-input v-model="dnsConfig.dnspod_key" type="password" show-password placeholder="DNSPod Key" /> <el-input v-model="manualConfig.ali_secret" type="password" show-password placeholder="Aliyun AccessKey Secret" />
</el-form-item> </el-form-item>
</template>
<!-- Cloudflare Config -->
<template v-if="form.dns_provider === 'cloudflare'">
<el-form-item label="API Token">
<el-input v-model="manualConfig.cf_api_token" type="password" show-password placeholder="Cloudflare API Token(推荐)" />
<div class="form-tip">需要 Zone:Read + DNS:Edit 权限也可留空改用下方 Email + Global API Key 方式</div>
</el-form-item>
<el-form-item label="Email">
<el-input v-model="manualConfig.cf_email" placeholder="Cloudflare 账号邮箱(Global API Key 方式)" />
</el-form-item>
<el-form-item label="Global API Key">
<el-input v-model="manualConfig.cf_api_key" type="password" show-password placeholder="Global API Key(与 Email 配合使用)" />
</el-form-item>
</template>
<!-- DNSPod Config -->
<template v-if="form.dns_provider === 'dnspod'">
<el-form-item label="ID">
<el-input v-model="manualConfig.dnspod_id" placeholder="DNSPod ID" />
</el-form-item>
<el-form-item label="Key">
<el-input v-model="manualConfig.dnspod_key" type="password" show-password placeholder="DNSPod Key" />
</el-form-item>
</template>
<!-- Save as credential option -->
<el-form-item v-if="form.dns_provider">
<el-checkbox v-model="saveAsCredential">保存为新凭证</el-checkbox>
</el-form-item>
<el-form-item v-if="saveAsCredential && form.dns_provider" label="凭证名称">
<el-input v-model="newCredName" placeholder="例如:阿里云生产环境" />
</el-form-item>
</template>
</template> </template>
<el-form-item label="自动续期"> <el-form-item label="自动续期">
@@ -87,14 +132,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { certApi } from '../api' import { certApi, credApi } from '../api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
interface Credential {
id: number
name: string
type: string
masked: string
}
const router = useRouter() const router = useRouter()
const formRef = ref() const formRef = ref()
const submitting = ref(false) const submitting = ref(false)
const credentials = ref<Credential[]>([])
// Credential mode: select existing or manual input
const credMode = ref<'credential' | 'manual'>('credential')
const selectedCredId = ref<number | null>(null)
const saveAsCredential = ref(false)
const newCredName = ref('')
const form = reactive({ const form = reactive({
domain: '', domain: '',
@@ -106,10 +165,12 @@ const form = reactive({
renew_days: 30, renew_days: 30,
}) })
const dnsConfig = reactive({ const manualConfig = reactive({
ali_key: '', ali_key: '',
ali_secret: '', ali_secret: '',
cf_api_token: '', cf_api_token: '',
cf_email: '',
cf_api_key: '',
dnspod_id: '', dnspod_id: '',
dnspod_key: '', dnspod_key: '',
}) })
@@ -122,15 +183,99 @@ const rules = {
], ],
} }
const typeLabel = (type: string) => {
const labels: Record<string, string> = {
alidns: '阿里云 DNS',
cloudflare: 'Cloudflare',
dnspod: 'DNSPod',
}
return labels[type] || type
}
const loadCredentials = async () => {
try {
const res = await credApi.list()
credentials.value = res.data
} catch {
// Ignore errors loading credentials
}
}
const onChallengeChange = () => { const onChallengeChange = () => {
// Reset DNS config when switching // Reset DNS config when switching challenge type
Object.keys(dnsConfig).forEach(k => (dnsConfig as any)[k] = '') Object.keys(manualConfig).forEach(k => (manualConfig as any)[k] = '')
selectedCredId.value = null
credMode.value = 'credential'
saveAsCredential.value = false
newCredName.value = ''
}
const onProviderChange = () => {
Object.keys(manualConfig).forEach(k => (manualConfig as any)[k] = '')
}
const onCredSelected = (credId: number) => {
// Just store the selection, backend resolves DNS config
selectedCredId.value = credId
}
const buildDnsConfigJson = (): string => {
switch (form.dns_provider) {
case 'alidns':
return JSON.stringify({
ali_key: manualConfig.ali_key,
ali_secret: manualConfig.ali_secret,
})
case 'cloudflare':
return JSON.stringify({
cf_api_token: manualConfig.cf_api_token,
cf_email: manualConfig.cf_email,
cf_api_key: manualConfig.cf_api_key,
})
case 'dnspod':
return JSON.stringify({
dnspod_id: manualConfig.dnspod_id,
dnspod_key: manualConfig.dnspod_key,
})
default:
return '{}'
}
} }
const handleSubmit = async () => { const handleSubmit = async () => {
const valid = await formRef.value.validate().catch(() => false) const valid = await formRef.value.validate().catch(() => false)
if (!valid) return if (!valid) return
// Validate manual fields
if (form.challenge_type === 'dns' && credMode.value === 'manual') {
if (!form.dns_provider) {
ElMessage.warning('请选择 DNS 提供商')
return
}
// Check required fields
if (form.dns_provider === 'alidns' && (!manualConfig.ali_key || !manualConfig.ali_secret)) {
ElMessage.warning('请填写阿里云 AccessKey')
return
}
if (form.dns_provider === 'cloudflare') {
const hasToken = !!manualConfig.cf_api_token
const hasEmailKey = !!manualConfig.cf_email && !!manualConfig.cf_api_key
if (!hasToken && !hasEmailKey) {
ElMessage.warning('请填写 Cloudflare API Token,或者同时填写 Email 和 Global API Key')
return
}
}
if (form.dns_provider === 'dnspod' && (!manualConfig.dnspod_id || !manualConfig.dnspod_key)) {
ElMessage.warning('请填写 DNSPod 凭证')
return
}
if (saveAsCredential.value && !newCredName.value) {
ElMessage.warning('请输入凭证名称')
return
}
}
submitting.value = true submitting.value = true
try { try {
const payload: any = { const payload: any = {
@@ -143,12 +288,36 @@ const handleSubmit = async () => {
} }
if (form.challenge_type === 'dns') { if (form.challenge_type === 'dns') {
payload.dns_provider = form.dns_provider if (credMode.value === 'credential') {
payload.dns_config = JSON.stringify(dnsConfig) if (!selectedCredId.value) {
ElMessage.warning('请选择一个 DNS 凭证')
submitting.value = false
return
}
// Use existing credential
payload.credential_id = selectedCredId.value
} else if (credMode.value === 'manual') {
// Manual input
payload.dns_provider = form.dns_provider
payload.dns_config = buildDnsConfigJson()
// Optionally save as new credential
if (saveAsCredential.value && newCredName.value) {
await credApi.create({
name: newCredName.value,
type: form.dns_provider,
data: payload.dns_config,
})
}
} else {
ElMessage.warning('请选择 DNS 凭证或选择手动输入')
submitting.value = false
return
}
} }
await certApi.create(payload) await certApi.create(payload)
ElMessage.success(`证书申请已提交,后台处理中`) ElMessage.success('证书申请已提交,后台处理中')
router.push('/certificates') router.push('/certificates')
} catch (e: any) { } catch (e: any) {
ElMessage.error('提交失败: ' + (e.response?.data?.error || e.message)) ElMessage.error('提交失败: ' + (e.response?.data?.error || e.message))
@@ -156,6 +325,8 @@ const handleSubmit = async () => {
submitting.value = false submitting.value = false
} }
} }
onMounted(loadCredentials)
</script> </script>
<style scoped> <style scoped>
@@ -164,4 +335,19 @@ const handleSubmit = async () => {
font-size: 24px; font-size: 24px;
color: #1a1a2e; color: #1a1a2e;
} }
.form-tip {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.form-tip a {
color: #1890ff;
text-decoration: none;
}
.form-tip a:hover {
text-decoration: underline;
}
</style> </style>
+246
View File
@@ -0,0 +1,246 @@
<template>
<div class="credentials-page">
<div class="page-header">
<h2 class="page-title">DNS 凭证管理</h2>
<el-button type="primary" @click="showCreateDialog">
<el-icon><Plus /></el-icon> 新增凭证
</el-button>
</div>
<el-table :data="credentials" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="180" />
<el-table-column label="类型" width="200">
<template #default="{ row }">
<el-tag :type="row.type === 'alidns' ? 'success' : row.type === 'cloudflare' ? 'warning' : 'info'">
{{ typeLabel(row.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="密钥预览" min-width="250">
<template #default="{ row }">
<code style="font-size: 12px; color: #999">{{ row.masked || '{}' }}</code>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showEditDialog(row)">编辑</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- Create / Edit Dialog -->
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑凭证' : '新增凭证'"
width="550px"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="例如:阿里云生产环境" />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="form.type" placeholder="选择 DNS 提供商" @change="onTypeChange" style="width: 100%">
<el-option
v-for="t in credTypes"
:key="t.type"
:label="t.label"
:value="t.type"
/>
</el-select>
</el-form-item>
<template v-if="form.type && currentFields.length > 0">
<div class="cred-fields-title">密钥配置</div>
<el-form-item
v-for="field in currentFields"
:key="field.key"
:label="field.label"
:prop="'data.' + field.key"
:rules="field.required ? [{ required: true, message: '请输入' + field.label, trigger: 'blur' }] : []"
>
<el-input
v-model="form.data[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:show-password="field.type === 'password'"
:placeholder="'请输入' + field.label"
/>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { credApi, type Credential, type CredentialType } from '../api'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const credentials = ref<Credential[]>([])
const credTypes = ref<CredentialType[]>([])
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
const formRef = ref()
const form = reactive({
name: '',
type: '',
data: {} as Record<string, string>,
})
const rules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
}
const currentFields = computed(() => {
const ct = credTypes.value.find(t => t.type === form.type)
return ct?.fields || []
})
const typeLabel = (type: string) => {
const ct = credTypes.value.find(t => t.type === type)
return ct?.label || type
}
const formatTime = (t: string) => {
if (!t) return ''
return t.replace('T', ' ').substring(0, 19)
}
const loadData = async () => {
loading.value = true
try {
const [credRes, typesRes] = await Promise.all([
credApi.list(),
credApi.types(),
])
credentials.value = credRes.data
credTypes.value = typesRes.data
} catch (e: any) {
ElMessage.error('加载失败: ' + (e.response?.data?.error || e.message))
} finally {
loading.value = false
}
}
const showCreateDialog = () => {
editingId.value = null
form.name = ''
form.type = ''
form.data = {}
dialogVisible.value = true
}
const showEditDialog = (cred: Credential) => {
editingId.value = cred.id
form.name = cred.name
form.type = cred.type
// Parse masked data for display (secrets not returned by API)
try {
const parsed = JSON.parse(cred.masked || '{}')
form.data = parsed
} catch {
form.data = {}
}
dialogVisible.value = true
}
const onTypeChange = () => {
form.data = {}
}
const handleSave = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const dataPayload = JSON.stringify(form.data)
if (editingId.value) {
await credApi.update(editingId.value, {
name: form.name,
data: dataPayload,
})
ElMessage.success('凭证已更新')
} else {
await credApi.create({
name: form.name,
type: form.type,
data: dataPayload,
})
ElMessage.success('凭证已创建')
}
dialogVisible.value = false
await loadData()
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.response?.data?.error || e.message))
} finally {
saving.value = false
}
}
const handleDelete = async (cred: Credential) => {
try {
await ElMessageBox.confirm(`确定删除凭证「${cred.name}」?`, '确认删除', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
await credApi.delete(cred.id)
ElMessage.success('凭证已删除')
await loadData()
} catch {
// cancelled
}
}
onMounted(loadData)
</script>
<style scoped>
.credentials-page {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
font-size: 24px;
color: #1a1a2e;
margin: 0;
}
.cred-fields-title {
font-weight: 600;
color: #333;
padding: 12px 0 8px;
border-top: 1px solid #eee;
margin-bottom: 8px;
}
:deep(.el-table code) {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
}
</style>
+145
View File
@@ -0,0 +1,145 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<el-icon :size="32" color="#1890ff"><Link /></el-icon>
<h2>AutoSSL 证书管理</h2>
<p class="login-desc">请登录以继续</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="0"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名"
size="large"
:prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
size="large"
show-password
:prefix-icon="Lock"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
style="width: 100%"
:loading="loading"
@click="handleLogin"
>
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { User, Lock } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { authApi } from '../api'
const router = useRouter()
const formRef = ref()
const loading = ref(false)
const errorMsg = ref('')
const form = reactive({
username: '',
password: '',
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
const handleLogin = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
errorMsg.value = ''
try {
const res = await authApi.login(form.username, form.password)
const { token, username, expires_at } = res.data
localStorage.setItem('token', token)
localStorage.setItem('username', username)
localStorage.setItem('token_expires_at', String(expires_at))
ElMessage.success(`欢迎回来, ${username}!`)
router.push('/')
} catch (e: any) {
errorMsg.value = e.response?.data?.error || '登录失败,请检查用户名和密码'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-size: cover;
}
.login-card {
width: 400px;
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header h2 {
margin-top: 12px;
font-size: 24px;
color: #1a1a2e;
}
.login-desc {
margin-top: 8px;
color: #999;
font-size: 14px;
}
.login-footer {
text-align: center;
margin-top: 8px;
}
.error-msg {
color: #f56c6c;
font-size: 13px;
}
:deep(.el-input__prefix) {
font-size: 18px;
}
</style>
+272
View File
@@ -0,0 +1,272 @@
<template>
<div class="notify-settings">
<h2 class="page-title">通知配置</h2>
<el-row :gutter="20">
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span><el-icon style="margin-right: 8px"><ChatDotSquare /></el-icon>飞书机器人</span>
<el-switch v-model="form.feishu_enabled" />
</div>
</template>
<el-form label-width="120px">
<el-form-item label="Webhook URL">
<el-input
v-model="form.feishu_url"
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx"
:disabled="!form.feishu_enabled"
/>
</el-form-item>
<el-form-item>
<el-button
type="success"
size="small"
:disabled="!form.feishu_enabled || !form.feishu_url"
:loading="testingFeishu"
@click="handleTestFeishu"
>
发送测试
</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span><el-icon style="margin-right: 8px"><Message /></el-icon>邮件通知</span>
<el-switch v-model="form.smtp_enabled" />
</div>
</template>
<el-form label-width="120px">
<el-form-item label="SMTP 主机">
<el-input v-model="form.smtp_host" placeholder="smtp.example.com" :disabled="!form.smtp_enabled" />
</el-form-item>
<el-form-item label="SMTP 端口">
<el-input-number v-model="smtpPortNumber" :min="25" :max="587" :disabled="!form.smtp_enabled" />
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="form.smtp_user" placeholder="user@example.com" :disabled="!form.smtp_enabled" />
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="form.smtp_password"
type="password"
show-password
:placeholder="hasPassword ? '留空保持不变' : '输入SMTP密码'"
:disabled="!form.smtp_enabled"
/>
</el-form-item>
<el-form-item label="发件人">
<el-input v-model="form.smtp_from" placeholder="AutoSSL <noreply@example.com>" :disabled="!form.smtp_enabled" />
</el-form-item>
<el-form-item label="收件人">
<el-input
v-model="form.notify_to"
placeholder="admin@example.com,ops@example.com"
:disabled="!form.smtp_enabled"
/>
<div class="form-tip">多个邮箱用逗号分隔</div>
</el-form-item>
<el-form-item>
<el-button
type="success"
size="small"
:disabled="!form.smtp_enabled || !form.smtp_host || !form.notify_to"
:loading="testingEmail"
@click="handleTestEmail"
>
发送测试
</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
</el-row>
<el-card style="margin-top: 20px">
<template #header>
<div class="card-header">
<span><el-icon style="margin-right: 8px"><Setting /></el-icon>通知规则</span>
</div>
</template>
<el-form :inline="true" label-width="140px">
<el-form-item label="到期前提醒天数">
<el-input-number v-model="form.expiry_days" :min="7" :max="90" />
</el-form-item>
</el-form>
</el-card>
<div style="margin-top: 20px; text-align: center">
<el-button type="primary" size="large" :loading="saving" :icon="Check" @click="handleSave">
保存配置
</el-button>
</div>
<!-- 通知状态说明 -->
<el-card style="margin-top: 20px">
<template #header>
<span><el-icon style="margin-right: 8px"><InfoFilled /></el-icon>通知说明</span>
</template>
<div class="notify-info">
<p>1. 配置保存后自动持久化到 <code>data/notify.json</code> 文件</p>
<p>2. 系统每天上午 <strong>8:00</strong> 自动检查证书到期情况并发送通知</p>
<p>3. 启动后 <strong>10 </strong> 也会执行一次初始检查</p>
<p>4. 环境变量 <code>FEISHU_WEBHOOK_URL</code> / <code>SMTP_*</code> 会覆盖文件配置向后兼容</p>
<p>5. 点击"发送测试"可验证通知通道是否配置正确</p>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { notifyApi } from '../api'
import { ElMessage } from 'element-plus'
import { Check, ChatDotSquare, Message, Setting, InfoFilled } from '@element-plus/icons-vue'
const saving = ref(false)
const testingFeishu = ref(false)
const testingEmail = ref(false)
const loading = ref(true)
const hasPassword = ref(false)
const form = reactive({
feishu_enabled: false,
feishu_url: '',
smtp_enabled: false,
smtp_host: '',
smtp_port: '587',
smtp_user: '',
smtp_password: '',
smtp_from: '',
notify_to: '',
expiry_days: 30,
})
const smtpPortNumber = computed({
get: () => parseInt(form.smtp_port || '587'),
set: (v: number) => { form.smtp_port = String(v) },
})
const loadConfig = async () => {
try {
const res = await notifyApi.get()
const data = res.data as any
form.feishu_enabled = data.feishu_enabled || false
form.feishu_url = data.feishu_url || ''
form.smtp_enabled = data.smtp_enabled || false
form.smtp_host = data.smtp_host || ''
form.smtp_port = data.smtp_port || '587'
form.smtp_user = data.smtp_user || ''
form.smtp_password = data.smtp_password || ''
form.smtp_from = data.smtp_from || ''
form.notify_to = data.notify_to || ''
form.expiry_days = data.expiry_days || 30
hasPassword.value = form.smtp_password === '********'
// Reset password field to empty if it's masked - user can leave blank to keep
if (form.smtp_password === '********') {
form.smtp_password = ''
}
} catch (e: any) {
ElMessage.error('加载配置失败: ' + (e.response?.data?.error || e.message))
} finally {
loading.value = false
}
}
const handleSave = async () => {
saving.value = true
try {
const payload = {
feishu_enabled: form.feishu_enabled,
feishu_url: form.feishu_url,
smtp_enabled: form.smtp_enabled,
smtp_host: form.smtp_host,
smtp_port: form.smtp_port,
smtp_user: form.smtp_user,
smtp_password: form.smtp_password || '********',
smtp_from: form.smtp_from,
notify_to: form.notify_to,
expiry_days: form.expiry_days,
}
await notifyApi.update(payload)
ElMessage.success('通知配置已保存')
// Reload to refresh masked state
await loadConfig()
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.response?.data?.error || e.message))
} finally {
saving.value = false
}
}
const handleTestFeishu = async () => {
testingFeishu.value = true
try {
await notifyApi.testFeishu()
ElMessage.success('飞书测试消息发送成功')
} catch (e: any) {
ElMessage.error('飞书测试失败: ' + (e.response?.data?.error || e.message))
} finally {
testingFeishu.value = false
}
}
const handleTestEmail = async () => {
testingEmail.value = true
try {
await notifyApi.testEmail()
ElMessage.success('邮件测试消息发送成功')
} catch (e: any) {
ElMessage.error('邮件测试失败: ' + (e.response?.data?.error || e.message))
} finally {
testingEmail.value = false
}
}
onMounted(loadConfig)
</script>
<style scoped>
.notify-settings {
max-width: 1200px;
margin: 0 auto;
}
.page-title {
margin-bottom: 20px;
font-size: 24px;
color: #1a1a2e;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.form-tip {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.notify-info {
line-height: 2;
color: #555;
}
.notify-info code {
background: #f5f5f5;
padding: 2px 8px;
border-radius: 3px;
font-size: 13px;
color: #1890ff;
}
</style>