feat: add DNS credential management with Web UI
- New config/credential_store.go: persistent credential storage (credentials.json) - New API: CRUD for DNS credentials + credential-types endpoint - Certificate model now supports credential_id reference - Create certificate auto-resolves credential_id to DNS config - New Vue page: Credentials.vue for managing saved DNS keys - CertCreate.vue: select existing credential or manual input + save as new - Secrets masked in API responses, never exposed in list
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -76,6 +76,7 @@ func InitStore(cfg *Config) {
|
||||
func InitAll(cfg *Config) {
|
||||
InitStore(cfg)
|
||||
InitNotifyStore(cfg)
|
||||
InitCredentialStore(cfg)
|
||||
}
|
||||
|
||||
// Load reads certificates from JSON file into memory
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
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: true},
|
||||
}
|
||||
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",
|
||||
}
|
||||
|
||||
if err := CredStore.Load(); err != nil {
|
||||
log.Printf("No existing credential store: %v, starting fresh", 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 = 0
|
||||
for _, c := range creds {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
-95
File diff suppressed because one or more lines are too long
+95
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,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-D6V1aiIx.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Kwmxr6vY.css">
|
||||
<script type="module" crossorigin src="/assets/index-e3s7Ic6B.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BDJKlQw8.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -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 {
|
||||
@@ -120,6 +140,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,182 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"auto-ssl/config"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cred := &config.Credential{
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Data: req.Data,
|
||||
}
|
||||
|
||||
if err := config.CredStore.Create(cred); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Return without secrets
|
||||
cred.Data = cred.Masked
|
||||
c.JSON(http.StatusCreated, cred)
|
||||
}
|
||||
|
||||
// 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
|
||||
if req.Data == "********" {
|
||||
existing.Data = existing.Data
|
||||
} else {
|
||||
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
|
||||
}
|
||||
existing.Data = req.Data
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -53,6 +53,7 @@ func main() {
|
||||
api.Use(authMiddleware())
|
||||
{
|
||||
certHandler := handlers.NewCertHandler(cfg)
|
||||
credHandler := handlers.NewCredentialHandler()
|
||||
|
||||
// Certificate management
|
||||
api.GET("/certificates", certHandler.ListCertificates)
|
||||
@@ -69,6 +70,14 @@ func main() {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user