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
|
||||
}
|
||||
Reference in New Issue
Block a user