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:
2026-07-24 18:56:03 +08:00
parent adebf82aaa
commit bdb3ca856f
16 changed files with 1032 additions and 139 deletions
+1
View File
@@ -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"`
+1
View File
@@ -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
+226
View File
@@ -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
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>
+21
View File
@@ -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,
+182
View File
@@ -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)
}
+9
View File
@@ -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)