Compare commits
14 Commits
0.01
...
3ce7c99bfe
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ce7c99bfe | |||
| 9bae6b1d9b | |||
| a2923bdfc0 | |||
| 9a315e1404 | |||
| 6498903a7c | |||
| 471fe76384 | |||
| 285df5a12f | |||
| 3a2c494d64 | |||
| bdb3ca856f | |||
| adebf82aaa | |||
| 3cb2178c44 | |||
| b1fd3a8b9b | |||
| bbf09fbc8d | |||
| 2d70a15307 |
@@ -6,8 +6,6 @@ backend/data/
|
||||
|
||||
# 编译产物
|
||||
backend/autossl
|
||||
backend/dist/assets/
|
||||
frontend/dist/
|
||||
node_modules/
|
||||
|
||||
# IDE
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
- ✅ HTTP-01(端口 80)和 DNS-01 验证方式
|
||||
- ✅ 支持阿里云 DNS / Cloudflare / DNSPod
|
||||
- ✅ Web 管理界面(仪表盘、证书列表、申请、查看、续期、删除)
|
||||
- ✅ SQLite 数据存储
|
||||
- ✅ **用户登录认证**(JWT Token)
|
||||
- ✅ **证书到期通知**(飞书机器人和邮件)
|
||||
- ✅ Docker 一键部署
|
||||
|
||||
## 快速开始
|
||||
@@ -38,23 +39,59 @@ cd backend && go build -o autossl .
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| PORT | 8080 | Web 服务端口 |
|
||||
| DB_PATH | ./data/autossl.db | SQLite 数据库路径 |
|
||||
| DATA_DIR | ./data | 数据存储目录 |
|
||||
| CERT_DIR | ./data/certs | 证书文件存储目录 |
|
||||
| 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 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/stats | 统计信息 |
|
||||
| GET | /api/certificates | 证书列表 |
|
||||
| GET | /api/certificates/:id | 证书详情 |
|
||||
| POST | /api/certificates | 申请证书 |
|
||||
| PUT | /api/certificates/:id | 更新证书配置 |
|
||||
| DELETE | /api/certificates/:id | 删除证书 |
|
||||
| POST | /api/certificates/:id/renew | 手动续期 |
|
||||
| GET | /api/certificates/:id/files | 查看证书文件 |
|
||||
| GET | /api/renewals/check | 触发续期检查 |
|
||||
| 方法 | 路径 | 说明 | 需要认证 |
|
||||
|------|------|------|---------|
|
||||
| GET | /api/health | 健康检查 | 否 |
|
||||
| POST | /api/login | 用户登录 | 否 |
|
||||
| GET | /api/stats | 统计信息 | 是 |
|
||||
| GET | /api/certificates | 证书列表 | 是 |
|
||||
| GET | /api/certificates/:id | 证书详情 | 是 |
|
||||
| POST | /api/certificates | 申请证书 | 是 |
|
||||
| PUT | /api/certificates/:id | 更新证书配置 | 是 |
|
||||
| DELETE | /api/certificates/:id | 删除证书 | 是 |
|
||||
| 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 天内到期的证书会触发通知。
|
||||
|
||||
## 证书文件位置
|
||||
|
||||
|
||||
@@ -10,6 +10,18 @@ WorkingDirectory={{DEPLOY_DIR}}/backend
|
||||
Environment="PORT=9090"
|
||||
Environment="ACME_PORT=8082"
|
||||
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
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
@@ -13,6 +13,7 @@ type Certificate struct {
|
||||
ChallengeType string `json:"challenge_type"` // http, dns
|
||||
DNSProvider string `json:"dns_provider,omitempty"` // alidns, cloudflare, etc.
|
||||
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
|
||||
CertURL string `json:"cert_url,omitempty"`
|
||||
|
||||
@@ -72,6 +72,13 @@ func InitStore(cfg *Config) {
|
||||
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
|
||||
func (s *CertStore) Load() error {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+1
File diff suppressed because one or more lines are too long
+95
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
Vendored
+24
@@ -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 |
Vendored
+14
@@ -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
@@ -1,52 +1,54 @@
|
||||
module auto-ssl
|
||||
|
||||
go 1.21
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.5.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/go-acme/lego/v4 v4.14.2
|
||||
github.com/go-acme/lego/v4 v4.23.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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1755 // indirect
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.100 // 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/iasm v0.9.0 // indirect
|
||||
github.com/cloudflare/cloudflare-go v0.70.0 // indirect
|
||||
github.com/cloudflare/cloudflare-go v0.115.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // 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.5 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.15.5 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/go-playground/validator/v10 v10.16.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // 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/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/miekg/dns v1.1.55 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/miekg/dns v1.1.64 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.5.0 // indirect
|
||||
golang.org/x/crypto v0.14.0 // indirect
|
||||
golang.org/x/mod v0.11.0 // indirect
|
||||
golang.org/x/net v0.16.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
golang.org/x/text v0.13.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
golang.org/x/tools v0.10.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/mod v0.23.0 // indirect
|
||||
golang.org/x/net v0.37.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
golang.org/x/tools v0.30.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+96
-67
@@ -1,24 +1,29 @@
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1755 h1:J45/QHgrzUdqe/Vco/Vxk0wRvdS2nKUxmf/zLgvfass=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1755/go.mod h1:RcDobYh8k5VP6TNybz9m++gL3ijVI5wueVr0EM10VsU=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
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.100 h1:yUkCbrSM1cWtgBfRVKMQtdt22KhDvKY7g4V+92eG9wA=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.100/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
|
||||
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.1 h1:7a1wuFXL1cMy7a3f7/VFcEtriuXQnUBhtoVfOZiaysc=
|
||||
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.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
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-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/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
|
||||
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.70.0/go.mod h1:VW6GuazkaZ4xEDkFt24lkXQUsE8q7BiGqDniC2s8WEM=
|
||||
github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM=
|
||||
github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU=
|
||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
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/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
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-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
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.14.2/go.mod h1:kBXxbeTg0x9AgaOYjPSwIeJy3Y33zTz+tMD16O4MO6c=
|
||||
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
|
||||
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
|
||||
github.com/go-acme/lego/v4 v4.23.0 h1:g1EzepiCr/8ktmFmw8zB0fua6StAZHrWsKDoIHIJEY4=
|
||||
github.com/go-acme/lego/v4 v4.23.0/go.mod h1:7UMVR7oQbIYw6V7mTgGwi4Er7B6Ww0c+c8feiBM0EgI=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
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/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/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/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24=
|
||||
github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE=
|
||||
github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
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/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
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.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
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/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
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/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/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/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.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
|
||||
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/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/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
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.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/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo=
|
||||
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/miekg/dns v1.1.64 h1:wuZgD9wwCE6XMT05UU/mlSko71eRSXEAm2EbjQXLKnQ=
|
||||
github.com/miekg/dns v1.1.64/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
|
||||
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/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/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/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/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.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/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
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.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.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.1/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.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/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/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.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
|
||||
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-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
|
||||
golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
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.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
|
||||
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
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.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
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.5.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.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg=
|
||||
golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
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.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
|
||||
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
|
||||
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=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
|
||||
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.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/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 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/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/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
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/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-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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
|
||||
@@ -26,6 +26,7 @@ type CreateCertRequest struct {
|
||||
ChallengeType string `json:"challenge_type"`
|
||||
DNSProvider string `json:"dns_provider"`
|
||||
DNSConfig string `json:"dns_config"`
|
||||
CredentialID *uint `json:"credential_id"`
|
||||
AutoRenew *bool `json:"auto_renew"`
|
||||
RenewDays *int `json:"renew_days"`
|
||||
}
|
||||
@@ -51,6 +52,19 @@ func (h *CertHandler) GetCertificate(c *gin.Context) {
|
||||
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
|
||||
func (h *CertHandler) CreateCertificate(c *gin.Context) {
|
||||
var req CreateCertRequest
|
||||
@@ -68,6 +82,12 @@ func (h *CertHandler) CreateCertificate(c *gin.Context) {
|
||||
|
||||
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
|
||||
renewDays := 30
|
||||
if req.AutoRenew != nil {
|
||||
@@ -86,6 +106,7 @@ func (h *CertHandler) CreateCertificate(c *gin.Context) {
|
||||
existing.ChallengeType = req.ChallengeType
|
||||
existing.DNSProvider = req.DNSProvider
|
||||
existing.DNSConfig = req.DNSConfig
|
||||
existing.CredentialID = req.CredentialID
|
||||
existing.Status = "pending"
|
||||
existing.ErrorMessage = ""
|
||||
existing.AutoRenew = autoRenew
|
||||
@@ -120,6 +141,7 @@ func (h *CertHandler) CreateCertificate(c *gin.Context) {
|
||||
ChallengeType: req.ChallengeType,
|
||||
DNSProvider: req.DNSProvider,
|
||||
DNSConfig: req.DNSConfig,
|
||||
CredentialID: req.CredentialID,
|
||||
Status: "pending",
|
||||
AutoRenew: autoRenew,
|
||||
RenewDays: renewDays,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+242
-5
@@ -1,9 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"auto-ssl/auth"
|
||||
"auto-ssl/config"
|
||||
"auto-ssl/handlers"
|
||||
"auto-ssl/notify"
|
||||
"auto-ssl/services"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -18,8 +21,8 @@ import (
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
// Initialize certificate store (JSON file persistence)
|
||||
config.InitStore(cfg)
|
||||
// Initialize all stores (certificates + notify config)
|
||||
config.InitAll(cfg)
|
||||
|
||||
// Setup Gin
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
@@ -39,10 +42,18 @@ func main() {
|
||||
r.StaticFile("/favicon.svg", "./dist/favicon.svg")
|
||||
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.Use(authMiddleware())
|
||||
{
|
||||
certHandler := handlers.NewCertHandler(cfg)
|
||||
credHandler := handlers.NewCredentialHandler()
|
||||
|
||||
// Certificate management
|
||||
api.GET("/certificates", certHandler.ListCertificates)
|
||||
@@ -53,6 +64,20 @@ func main() {
|
||||
api.POST("/certificates/:id/renew", certHandler.RenewCertificate)
|
||||
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
|
||||
api.GET("/renewals/check", certHandler.CheckRenewals)
|
||||
api.GET("/stats", certHandler.Stats)
|
||||
@@ -73,8 +98,10 @@ func main() {
|
||||
c.File("./dist/index.html")
|
||||
})
|
||||
|
||||
// Setup cron for auto-renewal (runs daily at 3:00 AM)
|
||||
// Setup cron scheduler
|
||||
c := cron.New()
|
||||
|
||||
// Daily renewal check at 3:00 AM
|
||||
c.AddFunc("0 3 * * *", func() {
|
||||
log.Println("Running scheduled certificate renewal check...")
|
||||
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()
|
||||
|
||||
// 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")
|
||||
if acmePort == "" {
|
||||
acmePort = "8082"
|
||||
@@ -109,3 +147,202 @@ func main() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ type DNSConfig struct {
|
||||
|
||||
// Cloudflare
|
||||
CFAPIToken string `json:"cf_api_token,omitempty"`
|
||||
CFEmail string `json:"cf_email,omitempty"`
|
||||
CFAPIKey string `json:"cf_api_key,omitempty"`
|
||||
|
||||
// DNSPod
|
||||
DNSPodID string `json:"dnspod_id,omitempty"`
|
||||
@@ -81,8 +83,11 @@ func GetACMECertificate(cert *config.Certificate, cfg *config.Config) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create DNS provider: %v", err)
|
||||
}
|
||||
|
||||
// Use custom DNS timeout for better reliability
|
||||
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 {
|
||||
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 {
|
||||
return fmt.Errorf("failed to create DNS provider: %v", err)
|
||||
}
|
||||
|
||||
// Use custom DNS timeout for better reliability
|
||||
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 {
|
||||
return fmt.Errorf("failed to set DNS-01 provider: %v", err)
|
||||
}
|
||||
@@ -321,8 +329,19 @@ func getDNSProvider(cert *config.Certificate) (challenge.Provider, error) {
|
||||
|
||||
case "cloudflare":
|
||||
cfg := cloudflareprov.NewDefaultConfig()
|
||||
if dnsCfg.CFAPIToken != "" {
|
||||
cfg.AuthToken = dnsCfg.CFAPIToken
|
||||
token := strings.TrimSpace(dnsCfg.CFAPIToken)
|
||||
if token != "" {
|
||||
prefix := token
|
||||
if len(prefix) > 8 {
|
||||
prefix = prefix[:8]
|
||||
}
|
||||
log.Printf("Cloudflare token length: %d, prefix: %s, has_****: %v", len(token), prefix, strings.Contains(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)
|
||||
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"
|
||||
}
|
||||
@@ -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-----
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -1,35 +1,53 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DEPLOY_DIR="$PWD"
|
||||
|
||||
echo "=== Auto-SSL 一键部署脚本 ==="
|
||||
|
||||
# 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. 编译后端
|
||||
echo "2. 编译Go后端..."
|
||||
cd backend
|
||||
go build -o autossl .
|
||||
cd ..
|
||||
if [ "$HAS_GO" = "yes" ]; then
|
||||
echo "2. 编译Go后端..."
|
||||
cd backend
|
||||
go build -o autossl .
|
||||
cd "$DEPLOY_DIR"
|
||||
else
|
||||
echo "2. 跳过Go编译(未安装go),使用预编译二进制"
|
||||
fi
|
||||
|
||||
# 3. 编译前端
|
||||
echo "3. 编译Vue前端..."
|
||||
cd frontend
|
||||
npm install --include=dev
|
||||
npm run build
|
||||
cd ..
|
||||
if [ "$HAS_NPM" = "yes" ]; then
|
||||
echo "3. 编译Vue前端..."
|
||||
cd frontend
|
||||
npm install --include=dev
|
||||
npm run build
|
||||
cd "$DEPLOY_DIR"
|
||||
else
|
||||
echo "3. 跳过前端编译(未安装npm),使用预构建dist"
|
||||
fi
|
||||
|
||||
# 4. 部署前端资源...
|
||||
# 4. 部署前端资源
|
||||
echo "4. 部署前端资源..."
|
||||
rm -rf backend/dist
|
||||
mkdir -p backend/dist
|
||||
cp -rf frontend/dist/* backend/dist/
|
||||
if [ -d "frontend/dist" ] && [ -n "$(ls -A frontend/dist 2>/dev/null)" ]; then
|
||||
rm -rf 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服务..."
|
||||
# 直接生成正确路径的service文件,避免sed替换失败
|
||||
cat > /tmp/autossl.service << EOF
|
||||
cat > /tmp/autossl.service << EOSERVICE
|
||||
[Unit]
|
||||
Description=Auto-SSL 证书管理服务
|
||||
After=network.target
|
||||
@@ -38,11 +56,23 @@ Wants=network.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=$PWD/backend
|
||||
WorkingDirectory=${DEPLOY_DIR}/backend
|
||||
Environment="PORT=9090"
|
||||
Environment="ACME_PORT=8082"
|
||||
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
|
||||
RestartSec=3
|
||||
StandardOutput=journal+console
|
||||
@@ -51,21 +81,24 @@ SyslogIdentifier=autossl
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
sudo cp /tmp/autossl.service /etc/systemd/system/autossl.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable autossl
|
||||
EOSERVICE
|
||||
cp /tmp/autossl.service /etc/systemd/system/autossl.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable autossl
|
||||
|
||||
# 6. 重启服务
|
||||
echo "6. 重启服务..."
|
||||
sudo systemctl restart autossl
|
||||
systemctl restart autossl
|
||||
|
||||
# 7. 检查状态
|
||||
sleep 2
|
||||
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 "📝 日志查看:journalctl -u autossl -f"
|
||||
echo ""
|
||||
echo "默认登录账号: admin / admin123"
|
||||
echo "请通过环境变量 ADMIN_USER / ADMIN_PASSWORD 修改密码"
|
||||
else
|
||||
echo "❌ 部署失败,请检查日志:journalctl -u autossl -n 50"
|
||||
exit 1
|
||||
|
||||
@@ -9,5 +9,17 @@ services:
|
||||
environment:
|
||||
- PORT=8080
|
||||
- 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:
|
||||
- ./data:/app/data
|
||||
|
||||
Generated
+1753
File diff suppressed because it is too large
Load Diff
+64
-2
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<template v-if="route.path === '/login'">
|
||||
<router-view />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-container style="min-height: 100vh">
|
||||
<el-header class="app-header">
|
||||
<div class="header-left">
|
||||
@@ -7,7 +11,21 @@
|
||||
<span class="header-title">AutoSSL 证书管理</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-tag type="success" size="small">运行中</el-tag>
|
||||
<el-tag type="success" size="small" style="margin-right: 12px">运行中</el-tag>
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<span class="user-dropdown">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
{{ username }}
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">
|
||||
<el-icon><SwitchButton /></el-icon> 退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-container>
|
||||
@@ -31,6 +49,14 @@
|
||||
<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>
|
||||
@@ -38,12 +64,28 @@
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 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>
|
||||
|
||||
<style>
|
||||
@@ -81,6 +123,26 @@ body {
|
||||
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 {
|
||||
background-color: #001529;
|
||||
min-height: calc(100vh - 60px);
|
||||
|
||||
@@ -5,6 +5,35 @@ const api = axios.create({
|
||||
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 {
|
||||
id: number
|
||||
domain: string
|
||||
@@ -42,6 +71,75 @@ export interface CreateCertRequest {
|
||||
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 = {
|
||||
list: () => api.get<Certificate[]>('/certificates'),
|
||||
get: (id: number) => api.get<Certificate>(`/certificates/${id}`),
|
||||
@@ -53,3 +151,21 @@ export const certApi = {
|
||||
checkRenewals: () => api.get('/renewals/check'),
|
||||
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'),
|
||||
}
|
||||
|
||||
@@ -2,14 +2,71 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Dashboard from '../views/Dashboard.vue'
|
||||
import CertList from '../views/CertList.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({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'Dashboard', component: Dashboard },
|
||||
{ path: '/certificates', name: 'CertList', component: CertList },
|
||||
{ path: '/create', name: 'CertCreate', component: CertCreate },
|
||||
{
|
||||
path: '/login',
|
||||
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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="140px"
|
||||
style="max-width: 600px"
|
||||
style="max-width: 620px"
|
||||
>
|
||||
<el-form-item label="域名" prop="domain">
|
||||
<el-input v-model="form.domain" placeholder="example.com" />
|
||||
@@ -32,8 +32,36 @@
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="DNS 提供商" v-if="form.challenge_type === 'dns'" prop="dns_provider">
|
||||
<el-select v-model="form.dns_provider" placeholder="选择 DNS 提供商" style="width: 100%">
|
||||
<!-- DNS mode: credential or manual -->
|
||||
<template v-if="form.challenge_type === 'dns'">
|
||||
<el-form-item label="使用方式">
|
||||
<el-radio-group v-model="credMode">
|
||||
<el-radio value="credential">选择已有凭证</el-radio>
|
||||
<el-radio value="manual">手动输入</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- Select existing credential -->
|
||||
<template v-if="credMode === 'credential'">
|
||||
<el-form-item label="选择凭证">
|
||||
<el-select v-model="selectedCredId" placeholder="选择已保存的 DNS 凭证" style="width: 100%" @change="onCredSelected">
|
||||
<el-option
|
||||
v-for="cred in credentials"
|
||||
:key="cred.id"
|
||||
:label="cred.name + ' (' + typeLabel(cred.type) + ')'"
|
||||
: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>
|
||||
|
||||
<!-- Manual input -->
|
||||
<template v-if="credMode === 'manual'">
|
||||
<el-form-item label="DNS 提供商" prop="dns_provider">
|
||||
<el-select v-model="form.dns_provider" placeholder="选择 DNS 提供商" style="width: 100%" @change="onProviderChange">
|
||||
<el-option label="阿里云 DNS (Aliyun)" value="alidns" />
|
||||
<el-option label="Cloudflare" value="cloudflare" />
|
||||
<el-option label="DNSPod" value="dnspod" />
|
||||
@@ -41,32 +69,49 @@
|
||||
</el-form-item>
|
||||
|
||||
<!-- Aliyun DNS Config -->
|
||||
<template v-if="form.challenge_type === 'dns' && form.dns_provider === 'alidns'">
|
||||
<el-form-item label="AccessKey ID" prop="ali_key">
|
||||
<el-input v-model="dnsConfig.ali_key" placeholder="Aliyun AccessKey ID" />
|
||||
<template v-if="form.dns_provider === 'alidns'">
|
||||
<el-form-item label="AccessKey ID">
|
||||
<el-input v-model="manualConfig.ali_key" placeholder="Aliyun AccessKey ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="AccessKey Secret" prop="ali_secret">
|
||||
<el-input v-model="dnsConfig.ali_secret" type="password" show-password placeholder="Aliyun AccessKey Secret" />
|
||||
<el-form-item label="AccessKey Secret">
|
||||
<el-input v-model="manualConfig.ali_secret" type="password" show-password placeholder="Aliyun AccessKey Secret" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- Cloudflare Config -->
|
||||
<template v-if="form.challenge_type === 'dns' && form.dns_provider === 'cloudflare'">
|
||||
<template v-if="form.dns_provider === 'cloudflare'">
|
||||
<el-form-item label="API Token">
|
||||
<el-input v-model="dnsConfig.cf_api_token" type="password" show-password placeholder="Cloudflare 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.challenge_type === 'dns' && form.dns_provider === 'dnspod'">
|
||||
<template v-if="form.dns_provider === 'dnspod'">
|
||||
<el-form-item label="ID">
|
||||
<el-input v-model="dnsConfig.dnspod_id" placeholder="DNSPod ID" />
|
||||
<el-input v-model="manualConfig.dnspod_id" placeholder="DNSPod ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Key">
|
||||
<el-input v-model="dnsConfig.dnspod_key" type="password" show-password placeholder="DNSPod 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>
|
||||
|
||||
<el-form-item label="自动续期">
|
||||
<el-switch v-model="form.auto_renew" />
|
||||
</el-form-item>
|
||||
@@ -87,14 +132,28 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { certApi } from '../api'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { certApi, credApi } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
interface Credential {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
masked: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref()
|
||||
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({
|
||||
domain: '',
|
||||
@@ -106,10 +165,12 @@ const form = reactive({
|
||||
renew_days: 30,
|
||||
})
|
||||
|
||||
const dnsConfig = reactive({
|
||||
const manualConfig = reactive({
|
||||
ali_key: '',
|
||||
ali_secret: '',
|
||||
cf_api_token: '',
|
||||
cf_email: '',
|
||||
cf_api_key: '',
|
||||
dnspod_id: '',
|
||||
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 = () => {
|
||||
// Reset DNS config when switching
|
||||
Object.keys(dnsConfig).forEach(k => (dnsConfig as any)[k] = '')
|
||||
// Reset DNS config when switching challenge type
|
||||
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 valid = await formRef.value.validate().catch(() => false)
|
||||
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
|
||||
try {
|
||||
const payload: any = {
|
||||
@@ -143,12 +288,36 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
if (form.challenge_type === 'dns') {
|
||||
if (credMode.value === 'credential') {
|
||||
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 = JSON.stringify(dnsConfig)
|
||||
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)
|
||||
ElMessage.success(`证书申请已提交,后台处理中`)
|
||||
ElMessage.success('证书申请已提交,后台处理中')
|
||||
router.push('/certificates')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('提交失败: ' + (e.response?.data?.error || e.message))
|
||||
@@ -156,6 +325,8 @@ const handleSubmit = async () => {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCredentials)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -164,4 +335,19 @@ const handleSubmit = async () => {
|
||||
font-size: 24px;
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user