diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..852c6d4
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "wiki"]
+path = wiki
+url = https://github.com/SamyRai/namecheap.wiki.git
diff --git a/.tool-versions b/.tool-versions
index 1b4a789..f645bcd 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1 +1 @@
-golang 1.25.0
+golang 1.25.6
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a0bdef8..aeec04b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,13 +1,13 @@
-# Contributing to Namecheap DNS Manager
+# Contributing to ZoneKit
Thank you for your interest in contributing! This document provides guidelines and instructions for contributing to the project.
## Quick Links
-- [Wiki Contributing Guide](https://github.com/SamyRai/namecheap/wiki/Contributing) - Complete contributing documentation
+- [Wiki Contributing Guide](https://github.com/SamyRai/zonekit/wiki/Contributing) - Complete contributing documentation
- [Code of Conduct](.github/CODE_OF_CONDUCT.md) - Community standards
-- [Issues](https://github.com/SamyRai/namecheap/issues) - Report bugs or request features
-- [Pull Requests](https://github.com/SamyRai/namecheap/pulls) - Submit contributions
+- [Issues](https://github.com/SamyRai/zonekit/issues) - Report bugs or request features
+- [Pull Requests](https://github.com/SamyRai/zonekit/pulls) - Submit contributions
## How to Contribute
@@ -20,7 +20,7 @@ Thank you for your interest in contributing! This document provides guidelines a
## Development Setup
-See the [Wiki Contributing Guide](https://github.com/SamyRai/namecheap/wiki/Contributing) for detailed setup instructions.
+See the [Wiki Contributing Guide](https://github.com/SamyRai/zonekit/wiki/Contributing) for detailed setup instructions.
## Code Standards
@@ -43,9 +43,9 @@ Follow [Conventional Commits](https://www.conventionalcommits.org/):
## Questions?
-- Check the [Wiki](https://github.com/SamyRai/namecheap/wiki)
-- Open an [Issue](https://github.com/SamyRai/namecheap/issues)
-- Review existing [Pull Requests](https://github.com/SamyRai/namecheap/pulls)
+- Check the [Wiki](https://github.com/SamyRai/zonekit/wiki)
+- Open an [Issue](https://github.com/SamyRai/zonekit/issues)
+- Review existing [Pull Requests](https://github.com/SamyRai/zonekit/pulls)
-For complete contributing guidelines, see the [Wiki Contributing Guide](https://github.com/SamyRai/namecheap/wiki/Contributing).
+For complete contributing guidelines, see the [Wiki Contributing Guide](https://github.com/SamyRai/zonekit/wiki/Contributing).
diff --git a/LICENSE b/LICENSE
index 49df40d..a51e9cc 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2024 Namecheap DNS Manager
+Copyright (c) 2024 ZoneKit
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/Makefile b/Makefile
index 61dbe36..f7bb4fc 100644
--- a/Makefile
+++ b/Makefile
@@ -1,9 +1,9 @@
-# Namecheap DNS Manager Makefile
+# ZoneKit Makefile
.PHONY: build test clean install lint fmt vet deps help
# Variables
-BINARY_NAME=namecheap-dns
+BINARY_NAME=zonekit
MAIN_PATH=./main.go
BUILD_DIR=build
GOFLAGS=-ldflags="-w -s"
diff --git a/QUICKSTART.md b/QUICKSTART.md
index 61f8255..6f57045 100644
--- a/QUICKSTART.md
+++ b/QUICKSTART.md
@@ -1,8 +1,8 @@
-# Namecheap DNS Manager - Quick Start
+# ZoneKit - Quick Start
## What's Been Built
-A CLI tool for managing Namecheap domains and DNS records with Migadu email hosting integration.
+A CLI tool for managing DNS zones and records across multiple providers with Migadu email hosting integration.
## Key Features Implemented
@@ -23,8 +23,8 @@ A CLI tool for managing Namecheap domains and DNS records with Migadu email host
✅ **Migadu Email Integration** (Special Feature!)
-- One-command setup: `./namecheap-dns migadu setup domain.com`
-- Verification: `./namecheap-dns migadu verify domain.com`
+- One-command setup: `./zonekit migadu setup domain.com`
+- Verification: `./zonekit migadu verify domain.com`
- Dry-run support to preview changes
- Conflict detection for existing records
- Easy cleanup/removal
@@ -40,22 +40,22 @@ A CLI tool for managing Namecheap domains and DNS records with Migadu email host
```bash
# Build the app
-go build -o namecheap-dns main.go
+go build -o zonekit main.go
# Configure (already done with your credentials)
-./namecheap-dns config show
+./zonekit config show
# List your domains
-./namecheap-dns domain list
+./zonekit domain list
# Check DNS records
-./namecheap-dns dns list mukimov.com
+./zonekit dns list mukimov.com
# Verify your existing Migadu setup
-./namecheap-dns migadu verify mukimov.com
+./zonekit migadu verify mukimov.com
# Set up Migadu on a new domain (dry run first)
-./namecheap-dns migadu setup glpx.pro --dry-run
+./zonekit migadu setup glpx.pro --dry-run
```
## Your Current Setup
diff --git a/README.md b/README.md
index a6bb42c..0c10c6c 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Namecheap DNS Manager
+# ZoneKit
@@ -7,9 +7,9 @@


-A command-line interface for managing Namecheap domains and DNS records with **multi-account support**.
+A command-line interface for managing DNS zones and records across multiple providers with **multi-account support**.
-[Installation](#-quick-start) • [Documentation](https://github.com/SamyRai/namecheap/wiki) • [Issues](https://github.com/SamyRai/namecheap/issues) • [Releases](https://github.com/SamyRai/namecheap/releases)
+[Installation](#-quick-start) • [Documentation](https://github.com/SamyRai/zonekit/wiki) • [Issues](https://github.com/SamyRai/zonekit/issues) • [Releases](https://github.com/SamyRai/zonekit/releases)
@@ -19,7 +19,7 @@ A command-line interface for managing Namecheap domains and DNS records with **m
> **Warning**
>
-> **This is NOT an official Namecheap tool.** This is an independent, community-maintained project.
+> **This is an independent, community-maintained project.**
>
> **Current Status: Pre-1.0.0 Release (v0.1.0)**
>
@@ -40,7 +40,8 @@ A command-line interface for managing Namecheap domains and DNS records with **m
| Feature | Description |
|---------|-------------|
-| **Multi-Account Management** | Configure and switch between multiple Namecheap accounts |
+| **Multi-Provider Support** | Support for multiple DNS providers (Namecheap, Cloudflare, and more) |
+| **Multi-Account Management** | Configure and switch between multiple provider accounts |
| **Domain Management** | List, check, and manage your domains |
| **DNS Management** | Create, update, and delete DNS records |
| **Bulk Operations** | Perform multiple DNS operations at once |
@@ -57,14 +58,14 @@ A command-line interface for managing Namecheap domains and DNS records with **m
```bash
# Clone the repository
-git clone https://github.com/SamyRai/namecheap.git
+git clone https://github.com/SamyRai/zonekit.git
cd namecheap
# Build the binary
make build
# Or build directly
-go build -o namecheap-dns ./main.go
+go build -o zonekit ./main.go
```
### 2. Configuration
@@ -73,33 +74,33 @@ The tool automatically detects configuration files in this priority order:
| Priority | Location | Use Case |
|----------|----------|----------|
-| **1** | `./configs/.namecheap-dns.yaml` | Development |
-| **2** | `~/.namecheap-dns.yaml` | Production |
+| **1** | `./configs/.zonekit.yaml` | Development |
+| **2** | `~/.zonekit.yaml` | Production |
```bash
# Initialize configuration
-./namecheap-dns config init
+./zonekit config init
# Or add account interactively
-./namecheap-dns account add
+./zonekit account add
```
### 3. Test Your Setup
```bash
# List accounts
-./namecheap-dns account list
+./zonekit account list
# List domains
-./namecheap-dns domain list
+./zonekit domain list
# Use specific account
-./namecheap-dns --account work domain list
+./zonekit --account work domain list
```
-> **For detailed documentation, see the [Wiki](https://github.com/SamyRai/namecheap/wiki)**
+> **For detailed documentation, see the [Wiki](https://github.com/SamyRai/zonekit/wiki)**
## Commands
@@ -148,7 +149,7 @@ The tool automatically detects configuration files in this priority order:
-> **For complete command reference, see [Usage Guide](https://github.com/SamyRai/namecheap/wiki/Usage)**
+> **For complete command reference, see [Usage Guide](https://github.com/SamyRai/zonekit/wiki/Usage)**
## Security
@@ -162,15 +163,15 @@ The tool automatically detects configuration files in this priority order:
The tool automatically detects configuration files in this priority order:
1. **Project Directory** (Recommended for development):
- - `./configs/.namecheap-dns.yaml`
+ - `./configs/.zonekit.yaml`
- Automatically found when running from project directory
2. **Home Directory** (Fallback):
- - `~/.namecheap-dns.yaml`
+ - `~/.zonekit.yaml`
- Used when no project config is found
3. **Custom Location**:
- - `./namecheap-dns --config /path/to/config.yaml`
+ - `./zonekit --config /path/to/config.yaml`
## Pro Tips
@@ -178,20 +179,20 @@ The tool automatically detects configuration files in this priority order:
```bash
# 1. Add multiple accounts
-./namecheap-dns account add personal
-./namecheap-dns account add work
-./namecheap-dns account add client1
+./zonekit account add personal
+./zonekit account add work
+./zonekit account add client1
# 2. Switch between accounts
-./namecheap-dns account switch work
-./namecheap-dns domain list
+./zonekit account switch work
+./zonekit domain list
-./namecheap-dns account switch personal
-./namecheap-dns domain list
+./zonekit account switch personal
+./zonekit domain list
# 3. Use specific account for one-off commands
-./namecheap-dns --account work dns list example.com
-./namecheap-dns --account personal domain check newdomain.com
+./zonekit --account work dns list example.com
+./zonekit --account personal domain check newdomain.com
```
### Account Organization
@@ -206,11 +207,11 @@ The tool automatically detects configuration files in this priority order:
### Common Issues
1. **"No config file found"**
- - Run `./namecheap-dns config init` to create a config file
+ - Run `./zonekit config init` to create a config file
- Ensure the config file is in the correct location
2. **"Account not found"**
- - Check available accounts with `./namecheap-dns account list`
+ - Check available accounts with `./zonekit account list`
- Verify account names are correct
3. **API Connection Errors**
@@ -222,12 +223,12 @@ The tool automatically detects configuration files in this priority order:
```bash
# Help
-./namecheap-dns help
+./zonekit help
# Command-specific help
-./namecheap-dns account --help
-./namecheap-dns domain --help
-./namecheap-dns dns --help
+./zonekit account --help
+./zonekit domain --help
+./zonekit dns --help
```
## Migration from Legacy Config
@@ -255,7 +256,7 @@ namecheap/
```bash
# Development build
-go build -o namecheap-dns cmd/main.go
+go build -o zonekit cmd/main.go
# Production build
make build
@@ -280,5 +281,5 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
For issues and questions:
- Check the troubleshooting section above
-- Review the help command: `./namecheap-dns help`
+- Review the help command: `./zonekit help`
- Open an issue on GitHub
diff --git a/REPO_MIGRATION.md b/REPO_MIGRATION.md
new file mode 100644
index 0000000..b3fd810
--- /dev/null
+++ b/REPO_MIGRATION.md
@@ -0,0 +1,151 @@
+# Repository strategy: keep Namecheap vs. switch to multi-provider (ZoneKit)
+
+**Summary**
+
+Two viable paths:
+
+1. **Keep this repo as Namecheap-only** (maintenance-only here) and create a **new repository** for the multi-provider implementation.
+2. **Rename and rebrand this repo** (e.g., `zonekit`), update module path and docs, and continue building the provider-agnostic code here.
+
+Recommendation: Given the current state (large refactor already present on the feature branch and low adoption/stars), the simplest path is to **rename/rebrand this repository to `zonekit` and continue here**, provided you are comfortable with a breaking change / major-version bump and will publish migration guidance. If *backwards compatibility* is critical for existing users, prefer Option 1 (split into a new `zonekit` repository and keep this repo as `namecheap` maintenance).
+
+---
+
+## Findings
+
+- There is 1 open PR: `feature/generic-dns-provider-plugin` (3 commits, adds provider-agnostic packages, adds OpenAPI providers, rebranding touches like `.gitmodules`, docs and `go.mod` module set to `zonekit`). ✅
+- Local branch is up to date with remote; branch is 3 commits ahead of `master` and contains the multi-provider work. ✅
+- Repo adoption is small (stars/watchers low), so breaking changes are low-risk for wide users. ✅
+
+---
+
+## Option A — Keep this repo as Namecheap-only (create a new repo for ZoneKit)
+
+Pros:
+
+- Preserves stable public interface and name for existing users.
+- Clear separation of responsibility: this repo becomes a maintenance-only place for Namecheap-specific functionality.
+- Less risk of confusing users who expect Namecheap-only tooling.
+
+Cons:
+
+- Extra overhead to maintain two repositories.
+- Requires copying or preserving multi-provider work in a new repo while retaining history.
+
+Steps (high level):
+
+1. Create new repo `zonekit`.
+2. From this local repo, push the feature branch into the new repo preserving history:
+ - `git remote add zonekit git@github.com:SamyRai/zonekit.git`
+ - `git push zonekit feature/generic-dns-provider-plugin:master`
+3. In this repo, revert/clean the feature commits (or create a `maintenance` branch that excludes them):
+ - Use `git revert` for the 3 commits or reset master to the previous commit and push a `maintenance` branch for the Namecheap-focused work.
+4. Update `README.md` in both repos with cross-links and migration notes.
+5. Tag and release a maintenance version for `namecheap` and create a new initial release for `zonekit` (major version as needed).
+6. Add a deprecation/migration policy and timeline in `RELEASES.md` (e.g., 3–6 months grace period).
+
+Risk mitigation:
+
+- Keep a `legacy` branch with the current Namecheap behavior and tests.
+- Publish migration steps and add automated tests in `zonekit` that verify Namecheap adapter still works.
+
+---
+
+## Option B — Rename repo and continue multi-provider here (recommended if few users rely on current API)
+
+Pros:
+
+- Single source of truth; no repository split overhead.
+- The feature branch is already in this repository (minimal friction to adopt the refactor).
+- Keeps commit history intact and reduces complexity.
+
+Cons:
+
+- Breaking change: module path and imports will change (requires major version bump and coordination with users).
+- Need to update CI, badges, modules, README, docs and possibly change package names.
+
+Steps (high level):
+
+1. Rename the GitHub repository (Settings → Rename) to `zonekit` or another chosen name.
+2. Update `go.mod` module to a canonical import path: e.g. `module github.com/SamyRai/zonekit`, run `go mod tidy`.
+3. Merge or fast-forward the `feature/generic-dns-provider-plugin` branch into `master` once tests and CI pass.
+4. Update all docs, `README.md`, release notes and project description to reflect the new scope.
+5. Publish a **major release** (v2.0.0 or v1.0.0 if new) and create a clear migration guide from `namecheap` → `zonekit`.
+6. Keep a `legacy` branch or tag for the Namecheap-only code and maintain it for critical fixes for a defined period.
+
+Risk mitigation:
+
+- Add clear migration docs and scripts to help users migrate imports.
+- Keep CI to run tests for Namecheap adapter using fixtures.
+- Announce the change across README, GitHub release notes, and the issue tracker.
+
+---
+
+## Concrete commands — split repo (Option A)
+
+- Create new repo and push branch as master:
+
+```bash
+# create repo on GitHub (web or gh cli)
+# locally:
+git remote add zonekit git@github.com:SamyRai/zonekit.git
+git push zonekit feature/generic-dns-provider-plugin:master
+```
+
+- In current repo: revert or create maintenance branch:
+
+```bash
+# make a maintenance branch that remains Namecheap-only
+git checkout master
+git branch maintenance
+# reset master to the commit before the provider refactors (use commit SHA):
+git reset --hard
+git push --force origin master # be careful: only if you intend to rewrite remote history
+git push origin maintenance
+```
+
+(Alternative: use `git revert` for the 3 refactor commits instead of history rewrite.)
+
+---
+
+## Concrete commands — rename repo (Option B)
+
+1. Rename repository in GitHub UI (or via API), then locally:
+
+```bash
+# update origin URL if your repo URL changed
+git remote set-url origin git@github.com:SamyRai/zonekit.git
+# update module path
+# edit go.mod: module github.com/SamyRai/zonekit
+go mod tidy
+# run tests and linters
+make test || go test ./...
+# merge feature branch after validation
+git checkout master
+git merge origin/feature/generic-dns-provider-plugin
+git push origin master
+```
+
+1. Publish a major release and create migration instructions for users (how to update imports and commands).
+
+---
+
+## Recommendation & next steps (short)
+
+- If you want to move forward quickly with multi-provider (and are OK with a breaking change), **rename** the repo to `zonekit` and continue (Option B). This is faster and keeps the refactor in place.
+- If you must preserve the existing Namecheap public API without breaking existing users, then **split**: create a new `zonekit` repo and make this one maintenance-only (Option A).
+
+Suggested immediate actions (pick one):
+
+1. If renaming: test the feature branch fully, update `go.mod` to `github.com/SamyRai/zonekit`, run CI locally and merge; then rename repo on GitHub and publish a major release.
+2. If splitting: create the `zonekit` repo and push the feature branch there (preserve history), then revert the feature commits here and create a `maintenance` branch for Namecheap.
+
+---
+
+If you want, I can:
+
+- create the `REPO_MIGRATION.md` file (done),
+- open PRs with the minimal revert (if you choose Option A),
+- or prepare a `go.mod` / README / release checklist for Option B so you can rename and release safely.
+
+Tell me which option you prefer and I will prepare the next PR or list of commands to run.
diff --git a/VERSIONING.md b/VERSIONING.md
index 6236d72..501e00d 100644
--- a/VERSIONING.md
+++ b/VERSIONING.md
@@ -48,12 +48,12 @@ Use GitHub Actions workflow to bump versions:
Check the current version:
```bash
-./namecheap-dns --version
+./zonekit --version
```
Or programmatically:
```go
-import "namecheap-dns-manager/pkg/version"
+import "zonekit/pkg/version"
fmt.Println(version.Version)
fmt.Println(version.String())
diff --git a/cmd/account.go b/cmd/account.go
index 922d20e..0fe43cd 100644
--- a/cmd/account.go
+++ b/cmd/account.go
@@ -5,21 +5,21 @@ import (
"strings"
"github.com/spf13/cobra"
- "namecheap-dns-manager/pkg/config"
+ "zonekit/pkg/config"
)
// accountCmd represents the account command
var accountCmd = &cobra.Command{
Use: "account",
- Short: "Manage multiple Namecheap accounts",
- Long: `Commands for managing multiple Namecheap account configurations.`,
+ Short: "Manage multiple DNS provider accounts",
+ Long: `Commands for managing multiple DNS provider account configurations.`,
}
// accountListCmd represents the account list command
var accountListCmd = &cobra.Command{
Use: "list",
Short: "List all configured accounts",
- Long: `Display all configured Namecheap accounts and show which one is currently active.`,
+ Long: `Display all configured DNS provider accounts and show which one is currently active.`,
RunE: func(cmd *cobra.Command, args []string) error {
configManager, err := config.NewManager()
if err != nil {
@@ -29,7 +29,7 @@ var accountListCmd = &cobra.Command{
accounts := configManager.ListAccounts()
if len(accounts) == 0 {
fmt.Println("No accounts configured.")
- fmt.Println("Run 'namecheap-dns account add' to add your first account.")
+ fmt.Println("Run 'zonekit account add' to add your first account.")
return nil
}
@@ -74,7 +74,7 @@ var accountListCmd = &cobra.Command{
var accountAddCmd = &cobra.Command{
Use: "add [account-name]",
Short: "Add a new account configuration",
- Long: `Add a new Namecheap account configuration with an interactive prompt.`,
+ Long: `Add a new DNS provider account configuration with an interactive prompt.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
configManager, err := config.NewManager()
@@ -100,7 +100,7 @@ var accountAddCmd = &cobra.Command{
// Interactive input
account := &config.AccountConfig{}
- fmt.Print("Namecheap Username: ")
+ fmt.Print("Provider Username: ")
fmt.Scanln(&account.Username)
fmt.Print("API User: ")
@@ -151,7 +151,7 @@ var accountAddCmd = &cobra.Command{
var accountSwitchCmd = &cobra.Command{
Use: "switch [account-name]",
Short: "Switch to a different account",
- Long: `Switch to a different configured Namecheap account.`,
+ Long: `Switch to a different configured DNS provider account.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
accountName := args[0]
@@ -191,7 +191,7 @@ var accountSwitchCmd = &cobra.Command{
var accountRemoveCmd = &cobra.Command{
Use: "remove [account-name]",
Short: "Remove an account configuration",
- Long: `Remove a Namecheap account configuration. Cannot remove the last remaining account.`,
+ Long: `Remove a DNS provider account configuration. Cannot remove the last remaining account.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
accountName := args[0]
@@ -244,7 +244,7 @@ var accountRemoveCmd = &cobra.Command{
var accountShowCmd = &cobra.Command{
Use: "show [account-name]",
Short: "Show details of a specific account",
- Long: `Display detailed information about a specific Namecheap account configuration.`,
+ Long: `Display detailed information about a specific DNS provider account configuration.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
configManager, err := config.NewManager()
@@ -293,7 +293,7 @@ var accountShowCmd = &cobra.Command{
var accountEditCmd = &cobra.Command{
Use: "edit [account-name]",
Short: "Edit an existing account configuration",
- Long: `Edit an existing Namecheap account configuration with an interactive prompt.`,
+ Long: `Edit an existing DNS provider account configuration with an interactive prompt.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
configManager, err := config.NewManager()
@@ -322,7 +322,7 @@ var accountEditCmd = &cobra.Command{
// Interactive input with current values as defaults
account := &config.AccountConfig{}
- fmt.Printf("Namecheap Username [%s]: ", existingAccount.Username)
+ fmt.Printf("Provider Username [%s]: ", existingAccount.Username)
var input string
fmt.Scanln(&input)
if input != "" {
diff --git a/cmd/config.go b/cmd/config.go
index 442e478..09b74a5 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -5,10 +5,13 @@ import (
"os"
"path/filepath"
+ "zonekit/internal/cmdutil"
+ "zonekit/pkg/config"
+ "zonekit/pkg/domain"
+
"github.com/spf13/cobra"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
- "namecheap-dns-manager/pkg/config"
)
// configCmd represents the config command
@@ -33,12 +36,12 @@ var configSetCmd = &cobra.Command{
clientIP := viper.GetString("client-ip")
sandbox := viper.GetBool("sandbox")
- fmt.Println("Namecheap Configuration Setup")
- fmt.Println("=============================")
+ fmt.Println("DNS Provider Configuration Setup")
+ fmt.Println("=================================")
fmt.Println()
// Username
- fmt.Print("Namecheap Username")
+ fmt.Print("Provider Username")
if username != "" {
fmt.Printf(" [%s]", username)
}
@@ -149,7 +152,7 @@ var configShowCmd = &cobra.Command{
fmt.Println()
if username == "" || apiUser == "" || apiKey == "" || clientIP == "" {
fmt.Println("⚠️ Some required configuration values are missing.")
- fmt.Println(" Run 'namecheap-dns config set' to configure them.")
+ fmt.Println(" Run 'zonekit config set' to configure them.")
} else {
fmt.Println("✅ Configuration appears complete.")
}
@@ -169,7 +172,7 @@ var configInitCmd = &cobra.Command{
return fmt.Errorf("failed to get home directory: %w", err)
}
- configPath := filepath.Join(home, ".namecheap-dns.yaml")
+ configPath := filepath.Join(home, ".zonekit.yaml")
// Check if file already exists
if _, err := os.Stat(configPath); err == nil {
@@ -185,7 +188,7 @@ var configInitCmd = &cobra.Command{
// Create example config
config := map[string]interface{}{
- "username": "your-namecheap-username",
+ "username": "your-provider-username",
"api_user": "your-api-username",
"api_key": "your-api-key",
"client_ip": "your.public.ip.address",
@@ -204,7 +207,7 @@ var configInitCmd = &cobra.Command{
fmt.Printf("Configuration file created at %s\n", configPath)
fmt.Println("Please edit the file with your actual values, then run:")
- fmt.Println(" namecheap-dns config show")
+ fmt.Println(" zonekit config show")
return nil
},
@@ -214,7 +217,7 @@ var configInitCmd = &cobra.Command{
var configValidateCmd = &cobra.Command{
Use: "validate",
Short: "Validate configuration and test API connection",
- Long: `Validate the current configuration and test the connection to Namecheap API.`,
+ Long: `Validate the current configuration and test the connection to DNS provider API.`,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Validating configuration...")
@@ -242,13 +245,27 @@ var configValidateCmd = &cobra.Command{
// Test API connection
fmt.Println("Testing API connection...")
- // TODO: Implement actual API test
- // This would involve creating a client and making a simple API call
- // For now, just validate the configuration format
+ // Create a test client to validate credentials
+ testClient, err := cmdutil.CreateClient(&config.AccountConfig{
+ Username: username,
+ APIUser: apiUser,
+ APIKey: apiKey,
+ ClientIP: clientIP,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to create test client: %w", err)
+ }
+
+ // Test the connection by making a simple API call
+ domainService := domain.NewService(testClient)
+ _, err = domainService.ListDomains()
+ if err != nil {
+ return fmt.Errorf("API connection test failed: %w", err)
+ }
- fmt.Println("✅ Configuration appears valid")
+ fmt.Printf("✅ API connection successful - Account: %s\n", testClient.GetAccountName())
fmt.Println()
- fmt.Println("Note: Run 'namecheap-dns domain list' to test the actual API connection.")
+ fmt.Println("Note: Run 'zonekit domain list' to test the actual API connection.")
return nil
},
@@ -260,7 +277,7 @@ func saveConfig(config map[string]interface{}) error {
return fmt.Errorf("failed to get home directory: %w", err)
}
- configPath := filepath.Join(home, ".namecheap-dns.yaml")
+ configPath := filepath.Join(home, ".zonekit.yaml")
data, err := yaml.Marshal(config)
if err != nil {
diff --git a/cmd/dns.go b/cmd/dns.go
index 928b644..dc1bb74 100644
--- a/cmd/dns.go
+++ b/cmd/dns.go
@@ -7,9 +7,12 @@ import (
"strings"
"text/tabwriter"
+ "zonekit/internal/cmdutil"
+ "zonekit/pkg/dns"
+ "zonekit/pkg/dnsrecord"
+
"github.com/spf13/cobra"
- "namecheap-dns-manager/internal/cmdutil"
- "namecheap-dns-manager/pkg/dns"
+ "gopkg.in/yaml.v3"
)
// dnsCmd represents the dns command
@@ -50,7 +53,7 @@ var dnsListCmd = &cobra.Command{
dnsService := dns.NewService(client)
- var records []dns.Record
+ var records []dnsrecord.Record
if recordType != "" {
records, err = dnsService.GetRecordsByType(domainName, strings.ToUpper(recordType))
} else {
@@ -130,7 +133,7 @@ var dnsAddCmd = &cobra.Command{
}
cmdutil.DisplayAccountInfo(accountConfig)
- record := dns.Record{
+ record := dnsrecord.Record{
HostName: hostname,
RecordType: recordType,
Address: value,
@@ -191,7 +194,7 @@ var dnsUpdateCmd = &cobra.Command{
}
cmdutil.DisplayAccountInfo(accountConfig)
- newRecord := dns.Record{
+ newRecord := dnsrecord.Record{
HostName: hostname,
RecordType: recordType,
Address: newValue,
@@ -326,17 +329,57 @@ operations:
return fmt.Errorf("failed to get account configuration: %w", err)
}
- // Show which account is being used
- fmt.Printf("Using account: %s (%s)\n", accountConfig.Username, accountConfig.Description)
+ // Create client and display account info
+ client, err := cmdutil.CreateClient(accountConfig)
+ if err != nil {
+ return err
+ }
+ cmdutil.DisplayAccountInfo(accountConfig)
+
+ dnsService := dns.NewService(client)
+
+ // Parse the operations file
+ operations, err := parseBulkOperationsFile(operationsFile)
+ if err != nil {
+ return fmt.Errorf("failed to parse operations file: %w", err)
+ }
+
+ if len(operations) == 0 {
+ return fmt.Errorf("no operations found in file %s", operationsFile)
+ }
+
+ // Show what will be done
+ fmt.Printf("Applying %d bulk operations to %s\n", len(operations), domainName)
+ fmt.Println("=====================================")
+
+ for i, op := range operations {
+ action := strings.Title(op.Action)
+ fmt.Printf("%d. %s %s %s → %s", i+1, action, op.Record.HostName, op.Record.RecordType, op.Record.Address)
+ if op.Record.TTL > 0 {
+ fmt.Printf(" (TTL: %d)", op.Record.TTL)
+ }
+ if op.Record.MXPref > 0 {
+ fmt.Printf(" (Priority: %d)", op.Record.MXPref)
+ }
+ fmt.Println()
+ }
fmt.Println()
- // TODO: Implement bulk operations from file
- // This would involve:
- // 1. Reading and parsing the YAML file
- // 2. Converting to BulkOperation structs
- // 3. Calling dnsService.BulkUpdate()
+ // Confirm before proceeding
+ confirm, _ := cmd.Flags().GetBool("confirm")
+ if !confirm {
+ fmt.Println("Use --confirm to apply these changes.")
+ return nil
+ }
+
+ // Apply the operations
+ err = dnsService.BulkUpdate(domainName, operations)
+ if err != nil {
+ return fmt.Errorf("failed to apply bulk operations: %w", err)
+ }
- return fmt.Errorf("bulk operations not yet implemented - TODO: parse %s and apply to %s", operationsFile, domainName)
+ fmt.Printf("✅ Successfully applied %d bulk operations to %s\n", len(operations), domainName)
+ return nil
},
}
@@ -402,17 +445,22 @@ var dnsExportCmd = &cobra.Command{
return fmt.Errorf("failed to get DNS records: %w", err)
}
- // TODO: Implement zone file export
- // This would involve:
- // 1. Converting records to zone file format
- // 2. Writing to file or stdout
+ // Convert records to zone file format
+ zoneContent := formatAsZoneFile(domainName, records)
- fmt.Printf("Export %d records from %s", len(records), domainName)
if outputFile != "" {
- fmt.Printf(" to %s", outputFile)
+ // Write to file
+ err = os.WriteFile(outputFile, []byte(zoneContent), 0644)
+ if err != nil {
+ return fmt.Errorf("failed to write zone file: %w", err)
+ }
+ fmt.Printf("✅ Exported %d records from %s to %s\n", len(records), domainName, outputFile)
+ } else {
+ // Write to stdout
+ fmt.Printf("Zone file for %s:\n", domainName)
+ fmt.Println("=====================================")
+ fmt.Print(zoneContent)
}
- fmt.Println()
- fmt.Println("TODO: Implement zone file export")
return nil
},
@@ -442,4 +490,138 @@ func init() {
// Flags for dns clear
dnsClearCmd.Flags().BoolP("confirm", "y", false, "Confirm deletion of all records")
+
+ // Flags for dns bulk
+ dnsBulkCmd.Flags().BoolP("confirm", "y", false, "Confirm the bulk operations")
+}
+
+// formatAsZoneFile converts DNS records to BIND zone file format
+func formatAsZoneFile(domainName string, records []dnsrecord.Record) string {
+ var sb strings.Builder
+
+ // Write SOA record (placeholder - would need proper SOA data)
+ sb.WriteString(fmt.Sprintf("$ORIGIN %s.\n", domainName))
+ sb.WriteString(fmt.Sprintf("@ IN SOA ns1.namecheap.com. admin.%s. (\n", domainName))
+ sb.WriteString("\t1 ; serial\n")
+ sb.WriteString("\t3600 ; refresh\n")
+ sb.WriteString("\t1800 ; retry\n")
+ sb.WriteString("\t604800 ; expire\n")
+ sb.WriteString("\t3600 ; minimum TTL\n")
+ sb.WriteString(")\n\n")
+
+ // Write NS records (placeholder)
+ sb.WriteString("; Name servers\n")
+ sb.WriteString("@ IN NS ns1.namecheap.com.\n")
+ sb.WriteString("@ IN NS ns2.namecheap.com.\n\n")
+
+ // Write other records
+ for _, record := range records {
+ hostname := record.HostName
+ if hostname == "@" {
+ hostname = ""
+ }
+
+ ttl := ""
+ if record.TTL > 0 {
+ ttl = fmt.Sprintf("\t%d", record.TTL)
+ } else {
+ ttl = "\t3600" // default TTL
+ }
+
+ switch record.RecordType {
+ case dnsrecord.RecordTypeA:
+ sb.WriteString(fmt.Sprintf("%s%s IN A %s\n", hostname, ttl, record.Address))
+ case dnsrecord.RecordTypeAAAA:
+ sb.WriteString(fmt.Sprintf("%s%s IN AAAA %s\n", hostname, ttl, record.Address))
+ case dnsrecord.RecordTypeCNAME:
+ sb.WriteString(fmt.Sprintf("%s%s IN CNAME %s\n", hostname, ttl, record.Address))
+ case dnsrecord.RecordTypeMX:
+ mxPref := record.MXPref
+ if mxPref == 0 {
+ mxPref = 10 // default priority
+ }
+ sb.WriteString(fmt.Sprintf("%s%s IN MX %d %s\n", hostname, ttl, mxPref, record.Address))
+ case dnsrecord.RecordTypeTXT:
+ // Handle long TXT records by splitting if necessary
+ txtValue := record.Address
+ if !strings.HasPrefix(txtValue, "\"") {
+ txtValue = fmt.Sprintf("\"%s\"", txtValue)
+ }
+ sb.WriteString(fmt.Sprintf("%s%s IN TXT %s\n", hostname, ttl, txtValue))
+ case dnsrecord.RecordTypeNS:
+ sb.WriteString(fmt.Sprintf("%s%s IN NS %s\n", hostname, ttl, record.Address))
+ case dnsrecord.RecordTypeSRV:
+ // SRV records need special parsing, for now just output as-is
+ sb.WriteString(fmt.Sprintf("%s%s IN SRV %s\n", hostname, ttl, record.Address))
+ default:
+ // For unknown types, output as generic record
+ sb.WriteString(fmt.Sprintf("%s%s IN %s %s\n", hostname, ttl, record.RecordType, record.Address))
+ }
+ }
+
+ return sb.String()
+}
+
+// parseBulkOperationsFile parses a YAML file containing bulk DNS operations
+func parseBulkOperationsFile(filePath string) ([]dns.BulkOperation, error) {
+ data, err := os.ReadFile(filePath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read operations file: %w", err)
+ }
+
+ // Define the structure for parsing
+ type OperationInput struct {
+ Action string `yaml:"action"`
+ Hostname string `yaml:"hostname"`
+ Type string `yaml:"type"`
+ Value string `yaml:"value"`
+ TTL int `yaml:"ttl,omitempty"`
+ MXPref int `yaml:"mx_pref,omitempty"`
+ }
+
+ var inputs []OperationInput
+ if err := yaml.Unmarshal(data, &inputs); err != nil {
+ return nil, fmt.Errorf("failed to parse YAML: %w", err)
+ }
+
+ var operations []dns.BulkOperation
+ for _, input := range inputs {
+ // Validate required fields
+ if input.Action == "" {
+ return nil, fmt.Errorf("operation missing required field: action")
+ }
+ if input.Hostname == "" {
+ return nil, fmt.Errorf("operation missing required field: hostname")
+ }
+ if input.Type == "" {
+ return nil, fmt.Errorf("operation missing required field: type")
+ }
+ if input.Value == "" {
+ return nil, fmt.Errorf("operation missing required field: value")
+ }
+
+ // Validate action
+ action := strings.ToLower(input.Action)
+ if action != dns.BulkActionAdd && action != dns.BulkActionUpdate && action != dns.BulkActionDelete {
+ return nil, fmt.Errorf("invalid action '%s', must be one of: %s, %s, %s", input.Action, dns.BulkActionAdd, dns.BulkActionUpdate, dns.BulkActionDelete)
+ }
+
+ // Create the record
+ record := dnsrecord.Record{
+ HostName: input.Hostname,
+ RecordType: input.Type,
+ Address: input.Value,
+ TTL: input.TTL,
+ MXPref: input.MXPref,
+ }
+
+ operation := dns.BulkOperation{
+ Action: action,
+ Record: record,
+ }
+
+ operations = append(operations, operation)
+ }
+
+ return operations, nil
}
diff --git a/cmd/domain.go b/cmd/domain.go
index 7f9e655..8ba5beb 100644
--- a/cmd/domain.go
+++ b/cmd/domain.go
@@ -6,22 +6,22 @@ import (
"text/tabwriter"
"github.com/spf13/cobra"
- "namecheap-dns-manager/internal/cmdutil"
- "namecheap-dns-manager/pkg/domain"
+ "zonekit/internal/cmdutil"
+ "zonekit/pkg/domain"
)
// domainCmd represents the domain command
var domainCmd = &cobra.Command{
Use: "domain",
- Short: "Manage Namecheap domains",
- Long: `Commands for managing Namecheap domains including listing, checking availability, and basic domain operations.`,
+ Short: "Manage domains",
+ Long: `Commands for managing domains including listing, checking availability, and basic domain operations.`,
}
// domainListCmd represents the domain list command
var domainListCmd = &cobra.Command{
Use: "list",
Short: "List all domains",
- Long: `List all domains in your Namecheap account with their details.`,
+ Long: `List all domains in your account with their details.`,
RunE: func(cmd *cobra.Command, args []string) error {
// Get current account configuration
accountConfig, err := GetCurrentAccount()
@@ -62,7 +62,7 @@ var domainListCmd = &cobra.Command{
}
dns := "External"
if d.IsOurDNS {
- dns = "Namecheap"
+ dns = "Provider"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
@@ -115,7 +115,7 @@ var domainInfoCmd = &cobra.Command{
fmt.Printf("Locked: %t\n", domainInfo.IsLocked)
fmt.Printf("WhoisGuard: %s\n", domainInfo.WhoisGuard)
fmt.Printf("Premium: %t\n", domainInfo.IsPremium)
- fmt.Printf("Using Namecheap DNS: %t\n", domainInfo.IsOurDNS)
+ fmt.Printf("Using Provider DNS: %t\n", domainInfo.IsOurDNS)
return nil
},
@@ -254,8 +254,8 @@ var domainNameserversSetCmd = &cobra.Command{
// domainNameserversDefaultCmd represents the domain nameservers default command
var domainNameserversDefaultCmd = &cobra.Command{
Use: "default ",
- Short: "Set domain to use Namecheap DNS",
- Long: `Set the domain to use Namecheap's default DNS servers.`,
+ Short: "Set domain to use provider DNS",
+ Long: `Set the domain to use the provider's default DNS servers.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
domainName := args[0]
@@ -281,10 +281,10 @@ var domainNameserversDefaultCmd = &cobra.Command{
domainService := domain.NewService(client)
err = domainService.SetToNamecheapDNS(domainName)
if err != nil {
- return fmt.Errorf("failed to set to Namecheap DNS: %w", err)
+ return fmt.Errorf("failed to set to provider DNS: %w", err)
}
- fmt.Printf("Successfully set %s to use Namecheap DNS servers.\n", domainName)
+ fmt.Printf("Successfully set %s to use provider DNS servers.\n", domainName)
return nil
},
}
diff --git a/cmd/help.go b/cmd/help.go
index 7679a1f..ece8bf5 100644
--- a/cmd/help.go
+++ b/cmd/help.go
@@ -12,85 +12,86 @@ var helpCmd = &cobra.Command{
Short: "Show help and examples",
Long: `Show help information including multi-account usage examples.`,
RunE: func(cmd *cobra.Command, args []string) error {
- fmt.Println("Namecheap DNS Manager - Multi-Account CLI Tool")
- fmt.Println("==============================================")
+ fmt.Println("ZoneKit - Multi-Provider DNS Management CLI")
+ fmt.Println("==========================================")
fmt.Println()
fmt.Println("🎯 Key Features:")
- fmt.Println("• Manage multiple Namecheap accounts")
+ fmt.Println("• Support for multiple DNS providers (Namecheap, Cloudflare, and more)")
+ fmt.Println("• Manage multiple provider accounts")
fmt.Println("• Easy account switching")
fmt.Println("• Domain and DNS management")
fmt.Println("• Secure configuration storage")
fmt.Println()
fmt.Println("📋 Account Management Commands:")
- fmt.Println(" namecheap-dns account list - List all configured accounts")
- fmt.Println(" namecheap-dns account add [name] - Add a new account")
- fmt.Println(" namecheap-dns account switch - Switch to a different account")
- fmt.Println(" namecheap-dns account show [name] - Show account details")
- fmt.Println(" namecheap-dns account edit [name] - Edit account configuration")
- fmt.Println(" namecheap-dns account remove - Remove an account")
+ fmt.Println(" zonekit account list - List all configured accounts")
+ fmt.Println(" zonekit account add [name] - Add a new account")
+ fmt.Println(" zonekit account switch - Switch to a different account")
+ fmt.Println(" zonekit account show [name] - Show account details")
+ fmt.Println(" zonekit account edit [name] - Edit account configuration")
+ fmt.Println(" zonekit account remove - Remove an account")
fmt.Println()
fmt.Println("🌐 Domain Management Commands:")
- fmt.Println(" namecheap-dns domain list - List all domains")
- fmt.Println(" namecheap-dns domain info - Get domain details")
- fmt.Println(" namecheap-dns domain check - Check domain availability")
- fmt.Println(" namecheap-dns domain renew [years] - Renew a domain")
- fmt.Println(" namecheap-dns domain nameservers get - Get nameservers")
- fmt.Println(" namecheap-dns domain nameservers set [ns2] [ns3] [ns4]")
- fmt.Println(" namecheap-dns domain nameservers default ")
+ fmt.Println(" zonekit domain list - List all domains")
+ fmt.Println(" zonekit domain info - Get domain details")
+ fmt.Println(" zonekit domain check - Check domain availability")
+ fmt.Println(" zonekit domain renew [years] - Renew a domain")
+ fmt.Println(" zonekit domain nameservers get - Get nameservers")
+ fmt.Println(" zonekit domain nameservers set [ns2] [ns3] [ns4]")
+ fmt.Println(" zonekit domain nameservers default ")
fmt.Println()
fmt.Println("🔧 DNS Management Commands:")
- fmt.Println(" namecheap-dns dns list - List DNS records")
- fmt.Println(" namecheap-dns dns add ")
- fmt.Println(" namecheap-dns dns update ")
- fmt.Println(" namecheap-dns dns delete ")
- fmt.Println(" namecheap-dns dns clear - Clear all records")
- fmt.Println(" namecheap-dns dns bulk - Bulk operations")
- fmt.Println(" namecheap-dns dns import - Import zone file")
- fmt.Println(" namecheap-dns dns export [file] - Export zone file")
+ fmt.Println(" zonekit dns list - List DNS records")
+ fmt.Println(" zonekit dns add ")
+ fmt.Println(" zonekit dns update ")
+ fmt.Println(" zonekit dns delete ")
+ fmt.Println(" zonekit dns clear - Clear all records")
+ fmt.Println(" zonekit dns bulk - Bulk operations")
+ fmt.Println(" zonekit dns import - Import zone file")
+ fmt.Println(" zonekit dns export [file] - Export zone file")
fmt.Println()
fmt.Println("⚙️ Configuration Commands:")
- fmt.Println(" namecheap-dns config init - Initialize config file")
- fmt.Println(" namecheap-dns config set - Set configuration (legacy)")
- fmt.Println(" namecheap-dns config show - Show configuration (legacy)")
- fmt.Println(" namecheap-dns config validate - Validate configuration")
+ fmt.Println(" zonekit config init - Initialize config file")
+ fmt.Println(" zonekit config set - Set configuration (legacy)")
+ fmt.Println(" zonekit config show - Show configuration (legacy)")
+ fmt.Println(" zonekit config validate - Validate configuration")
fmt.Println()
fmt.Println("🚀 Quick Start Examples:")
fmt.Println()
fmt.Println("1. First-time setup:")
- fmt.Println(" namecheap-dns config init")
- fmt.Println(" # Edit ~/.namecheap-dns.yaml with your credentials")
- fmt.Println(" namecheap-dns account list")
+ fmt.Println(" zonekit config init")
+ fmt.Println(" # Edit ~/.zonekit.yaml with your credentials")
+ fmt.Println(" zonekit account list")
fmt.Println()
fmt.Println("2. Add multiple accounts:")
- fmt.Println(" namecheap-dns account add personal")
- fmt.Println(" namecheap-dns account add work")
- fmt.Println(" namecheap-dns account add client1")
+ fmt.Println(" zonekit account add personal")
+ fmt.Println(" zonekit account add work")
+ fmt.Println(" zonekit account add client1")
fmt.Println()
fmt.Println("3. Switch between accounts:")
- fmt.Println(" namecheap-dns account switch work")
- fmt.Println(" namecheap-dns domain list")
- fmt.Println(" namecheap-dns account switch personal")
- fmt.Println(" namecheap-dns domain list")
+ fmt.Println(" zonekit account switch work")
+ fmt.Println(" zonekit domain list")
+ fmt.Println(" zonekit account switch personal")
+ fmt.Println(" zonekit domain list")
fmt.Println()
fmt.Println("4. Use specific account for a command:")
- fmt.Println(" namecheap-dns --account work domain list")
- fmt.Println(" namecheap-dns --account personal dns list example.com")
+ fmt.Println(" zonekit --account work domain list")
+ fmt.Println(" zonekit --account personal dns list example.com")
fmt.Println()
fmt.Println("5. Manage DNS records:")
- fmt.Println(" namecheap-dns dns add example.com www A 192.168.1.1")
- fmt.Println(" namecheap-dns dns add example.com mail MX 192.168.1.2 --mx-pref 10")
- fmt.Println(" namecheap-dns dns list example.com")
+ fmt.Println(" zonekit dns add example.com www A 192.168.1.1")
+ fmt.Println(" zonekit dns add example.com mail MX 192.168.1.2 --mx-pref 10")
+ fmt.Println(" zonekit dns list example.com")
fmt.Println()
fmt.Println("🔐 Security Features:")
@@ -100,7 +101,7 @@ var helpCmd = &cobra.Command{
fmt.Println()
fmt.Println("📁 Configuration File:")
- fmt.Println("• Location: ~/.namecheap-dns.yaml")
+ fmt.Println("• Location: ~/.zonekit.yaml")
fmt.Println("• Format: YAML with multi-account support")
fmt.Println("• Automatic migration from legacy format")
fmt.Println()
@@ -109,13 +110,13 @@ var helpCmd = &cobra.Command{
fmt.Println("• Use descriptive account names (e.g., 'personal', 'work', 'client1')")
fmt.Println("• Add descriptions to accounts for better organization")
fmt.Println("• Use the --account flag for one-off commands with different accounts")
- fmt.Println("• Check 'namecheap-dns account list' to see all available accounts")
- fmt.Println("• Use 'namecheap-dns account show' to verify current account details")
+ fmt.Println("• Check 'zonekit account list' to see all available accounts")
+ fmt.Println("• Use 'zonekit account show' to verify current account details")
fmt.Println()
fmt.Println("🆘 Need Help?")
- fmt.Println("• Run 'namecheap-dns --help' for command overview")
- fmt.Println("• Run 'namecheap-dns --help' for specific command help")
+ fmt.Println("• Run 'zonekit --help' for command overview")
+ fmt.Println("• Run 'zonekit --help' for specific command help")
fmt.Println("• Check the README.md file for detailed documentation")
fmt.Println()
diff --git a/cmd/migadu.go b/cmd/migadu.go
deleted file mode 100644
index 1f40217..0000000
--- a/cmd/migadu.go
+++ /dev/null
@@ -1,421 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "strings"
-
- "github.com/spf13/cobra"
- "namecheap-dns-manager/internal/cmdutil"
- "namecheap-dns-manager/pkg/dns"
-)
-
-// migaduCmd represents the migadu command
-var migaduCmd = &cobra.Command{
- Use: "migadu",
- Short: "Migadu email hosting setup helpers",
- Long: `Commands for easily setting up Migadu email hosting DNS records.`,
-}
-
-// migaduSetupCmd represents the migadu setup command
-var migaduSetupCmd = &cobra.Command{
- Use: "setup ",
- Short: "Set up Migadu DNS records for a domain",
- Long: `Set up all necessary DNS records for Migadu email hosting.
-This will add:
-- MX records for mail routing
-- SPF record for sender authentication
-- DKIM CNAMEs for email signing
-- DMARC record for email policy
-- Autoconfig CNAME for email client setup`,
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- domainName := args[0]
- dryRun, _ := cmd.Flags().GetBool("dry-run")
- replace, _ := cmd.Flags().GetBool("replace")
-
- // Get current account configuration
- accountConfig, err := GetCurrentAccount()
- if err != nil {
- return fmt.Errorf("failed to get account configuration: %w", err)
- }
-
- // Create client and display account info
- client, err := cmdutil.CreateClient(accountConfig)
- if err != nil {
- return err
- }
- cmdutil.DisplayAccountInfo(accountConfig)
-
- dnsService := dns.NewService(client)
-
- // Get current records if not replacing
- var existingRecords []dns.Record
- if !replace {
- existingRecords, err = dnsService.GetRecords(domainName)
- if err != nil {
- return fmt.Errorf("failed to get existing records: %w", err)
- }
- }
-
- // Define Migadu DNS records
- migaduRecords := []dns.Record{
- // MX Records
- {
- HostName: "@",
- RecordType: "MX",
- Address: "aspmx1.migadu.com.",
- TTL: 1800,
- MXPref: 10,
- },
- {
- HostName: "@",
- RecordType: "MX",
- Address: "aspmx2.migadu.com.",
- TTL: 1800,
- MXPref: 20,
- },
- // SPF Record
- {
- HostName: "@",
- RecordType: "TXT",
- Address: "v=spf1 include:spf.migadu.com -all",
- TTL: 1800,
- },
- // DMARC Record
- {
- HostName: "@",
- RecordType: "TXT",
- Address: "v=DMARC1; p=quarantine;",
- TTL: 1800,
- },
- // DKIM CNAMEs
- {
- HostName: "key1._domainkey",
- RecordType: "CNAME",
- Address: fmt.Sprintf("key1.%s._domainkey.migadu.com.", domainName),
- TTL: 1800,
- },
- {
- HostName: "key2._domainkey",
- RecordType: "CNAME",
- Address: fmt.Sprintf("key2.%s._domainkey.migadu.com.", domainName),
- TTL: 1800,
- },
- {
- HostName: "key3._domainkey",
- RecordType: "CNAME",
- Address: fmt.Sprintf("key3.%s._domainkey.migadu.com.", domainName),
- TTL: 1800,
- },
- // Autoconfig for email clients
- {
- HostName: "autoconfig",
- RecordType: "CNAME",
- Address: "autoconfig.migadu.com.",
- TTL: 1800,
- },
- }
-
- fmt.Printf("Setting up Migadu DNS records for %s\n", domainName)
- fmt.Println("=====================================")
-
- if dryRun {
- fmt.Println("DRY RUN MODE - No changes will be made")
- fmt.Println()
- }
-
- // Check for conflicts if not replacing
- var conflicts []string
- if !replace && len(existingRecords) > 0 {
- for _, migaduRecord := range migaduRecords {
- for _, existing := range existingRecords {
- if existing.HostName == migaduRecord.HostName && existing.RecordType == migaduRecord.RecordType {
- conflicts = append(conflicts, fmt.Sprintf("%s %s", existing.HostName, existing.RecordType))
- }
- }
- }
- }
-
- if len(conflicts) > 0 && !replace {
- fmt.Println("⚠️ Conflicting records found:")
- for _, conflict := range conflicts {
- fmt.Printf(" - %s\n", conflict)
- }
- fmt.Println()
- fmt.Println("Use --replace to overwrite existing records or resolve conflicts manually.")
- return nil
- }
-
- // Show what will be added
- fmt.Println("Records to be added:")
- for _, record := range migaduRecords {
- mxPref := ""
- if record.MXPref > 0 {
- mxPref = fmt.Sprintf(" (priority: %d)", record.MXPref)
- }
- fmt.Printf(" %s %s → %s%s\n", record.HostName, record.RecordType, record.Address, mxPref)
- }
- fmt.Println()
-
- if dryRun {
- fmt.Println("Dry run completed. Use without --dry-run to apply changes.")
- return nil
- }
-
- // Apply changes
- var allRecords []dns.Record
- if replace {
- allRecords = migaduRecords
- } else {
- // Keep existing records and add Migadu records
- allRecords = existingRecords
- allRecords = append(allRecords, migaduRecords...)
- }
-
- err = dnsService.SetRecords(domainName, allRecords)
- if err != nil {
- return fmt.Errorf("failed to set DNS records: %w", err)
- }
-
- fmt.Printf("✅ Successfully set up Migadu DNS records for %s\n", domainName)
- fmt.Println()
- fmt.Println("Next steps:")
- fmt.Printf("1. Add %s to your Migadu account\n", domainName)
- fmt.Println("2. Verify domain ownership in Migadu dashboard")
- fmt.Println("3. Create email accounts in Migadu")
- fmt.Println("4. Test email sending/receiving")
-
- return nil
- },
-}
-
-// migaduVerifyCmd represents the migadu verify command
-var migaduVerifyCmd = &cobra.Command{
- Use: "verify ",
- Short: "Verify Migadu DNS records for a domain",
- Long: `Check if all required Migadu DNS records are properly configured.`,
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- domainName := args[0]
-
- // Get current account configuration
- accountConfig, err := GetCurrentAccount()
- if err != nil {
- return fmt.Errorf("failed to get account configuration: %w", err)
- }
-
- // Create client and display account info
- client, err := cmdutil.CreateClient(accountConfig)
- if err != nil {
- return err
- }
- cmdutil.DisplayAccountInfo(accountConfig)
-
- dnsService := dns.NewService(client)
- records, err := dnsService.GetRecords(domainName)
- if err != nil {
- return fmt.Errorf("failed to get DNS records: %w", err)
- }
-
- fmt.Printf("Verifying Migadu setup for %s\n", domainName)
- fmt.Println("=====================================")
-
- // Required records for verification
- requiredChecks := []struct {
- name string
- hostname string
- recordType string
- valueCheck func(string) bool
- found bool
- }{
- {
- name: "MX Record (Primary)",
- hostname: "@",
- recordType: "MX",
- valueCheck: func(value string) bool {
- return strings.Contains(value, "aspmx1.migadu.com")
- },
- },
- {
- name: "MX Record (Secondary)",
- hostname: "@",
- recordType: "MX",
- valueCheck: func(value string) bool {
- return strings.Contains(value, "aspmx2.migadu.com")
- },
- },
- {
- name: "SPF Record",
- hostname: "@",
- recordType: "TXT",
- valueCheck: func(value string) bool {
- return strings.Contains(value, "include:spf.migadu.com")
- },
- },
- {
- name: "DMARC Record",
- hostname: "@",
- recordType: "TXT",
- valueCheck: func(value string) bool {
- return strings.HasPrefix(value, "v=DMARC1")
- },
- },
- {
- name: "DKIM Key 1",
- hostname: "key1._domainkey",
- recordType: "CNAME",
- valueCheck: func(value string) bool {
- return strings.Contains(value, "migadu.com")
- },
- },
- {
- name: "DKIM Key 2",
- hostname: "key2._domainkey",
- recordType: "CNAME",
- valueCheck: func(value string) bool {
- return strings.Contains(value, "migadu.com")
- },
- },
- {
- name: "DKIM Key 3",
- hostname: "key3._domainkey",
- recordType: "CNAME",
- valueCheck: func(value string) bool {
- return strings.Contains(value, "migadu.com")
- },
- },
- {
- name: "Autoconfig",
- hostname: "autoconfig",
- recordType: "CNAME",
- valueCheck: func(value string) bool {
- return strings.Contains(value, "autoconfig.migadu.com")
- },
- },
- }
-
- // Check each required record
- for i := range requiredChecks {
- check := &requiredChecks[i]
- for _, record := range records {
- if record.HostName == check.hostname && record.RecordType == check.recordType {
- if check.valueCheck(record.Address) {
- check.found = true
- break
- }
- }
- }
- }
-
- // Display results
- allGood := true
- for _, check := range requiredChecks {
- status := "❌"
- if check.found {
- status = "✅"
- } else {
- allGood = false
- }
- fmt.Printf("%s %s\n", status, check.name)
- }
-
- fmt.Println()
- if allGood {
- fmt.Println("🎉 All Migadu DNS records are properly configured!")
- } else {
- fmt.Println("⚠️ Some required records are missing or incorrect.")
- fmt.Println(" Run 'namecheap-dns migadu setup " + domainName + "' to fix issues.")
- }
-
- return nil
- },
-}
-
-// migaduRemoveCmd represents the migadu remove command
-var migaduRemoveCmd = &cobra.Command{
- Use: "remove ",
- Short: "Remove Migadu DNS records from a domain",
- Long: `Remove all Migadu-related DNS records from the specified domain.`,
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- domainName := args[0]
- confirm, _ := cmd.Flags().GetBool("confirm")
-
- if !confirm {
- fmt.Printf("This will remove all Migadu DNS records from %s.\n", domainName)
- fmt.Println("Use --confirm to proceed.")
- return nil
- }
-
- // Get current account configuration
- accountConfig, err := GetCurrentAccount()
- if err != nil {
- return fmt.Errorf("failed to get account configuration: %w", err)
- }
-
- // Create client and display account info
- client, err := cmdutil.CreateClient(accountConfig)
- if err != nil {
- return err
- }
- cmdutil.DisplayAccountInfo(accountConfig)
-
- dnsService := dns.NewService(client)
- records, err := dnsService.GetRecords(domainName)
- if err != nil {
- return fmt.Errorf("failed to get DNS records: %w", err)
- }
-
- // Filter out Migadu records
- var filteredRecords []dns.Record
- removedCount := 0
-
- for _, record := range records {
- isMigaduRecord := false
-
- // Check if this is a Migadu-related record
- if (record.RecordType == "MX" && strings.Contains(record.Address, "migadu.com")) ||
- (record.RecordType == "TXT" && strings.Contains(record.Address, "spf.migadu.com")) ||
- (record.RecordType == "TXT" && strings.HasPrefix(record.Address, "v=DMARC1")) ||
- (record.RecordType == "CNAME" && strings.Contains(record.Address, "migadu.com")) ||
- (record.HostName == "autoconfig" && record.RecordType == "CNAME") ||
- (strings.Contains(record.HostName, "_domainkey")) {
- isMigaduRecord = true
- removedCount++
- }
-
- if !isMigaduRecord {
- filteredRecords = append(filteredRecords, record)
- }
- }
-
- if removedCount == 0 {
- fmt.Printf("No Migadu DNS records found for %s\n", domainName)
- return nil
- }
-
- err = dnsService.SetRecords(domainName, filteredRecords)
- if err != nil {
- return fmt.Errorf("failed to update DNS records: %w", err)
- }
-
- fmt.Printf("✅ Successfully removed %d Migadu DNS records from %s\n", removedCount, domainName)
- return nil
- },
-}
-
-func init() {
- // Migadu is now available as a plugin via: namecheap-dns plugin migadu
- // Keeping this for backward compatibility, but it's deprecated
- rootCmd.AddCommand(migaduCmd)
- migaduCmd.AddCommand(migaduSetupCmd)
- migaduCmd.AddCommand(migaduVerifyCmd)
- migaduCmd.AddCommand(migaduRemoveCmd)
-
- // Flags for migadu setup
- migaduSetupCmd.Flags().Bool("dry-run", false, "Show what would be done without making changes")
- migaduSetupCmd.Flags().Bool("replace", false, "Replace all existing DNS records (use with caution)")
-
- // Flags for migadu remove
- migaduRemoveCmd.Flags().BoolP("confirm", "y", false, "Confirm removal of Migadu records")
-}
diff --git a/cmd/plugin.go b/cmd/plugin.go
index 52ceedd..0e132c5 100644
--- a/cmd/plugin.go
+++ b/cmd/plugin.go
@@ -4,9 +4,10 @@ import (
"fmt"
"github.com/spf13/cobra"
- "namecheap-dns-manager/internal/cmdutil"
- "namecheap-dns-manager/pkg/dns"
- "namecheap-dns-manager/pkg/plugin"
+ "zonekit/internal/cmdutil"
+ "zonekit/pkg/dns"
+ "zonekit/pkg/dnsrecord"
+ "zonekit/pkg/plugin"
)
// pluginCmd represents the plugin command
@@ -188,23 +189,23 @@ type dnsServiceWrapper struct {
service *dns.Service
}
-func (w *dnsServiceWrapper) GetRecords(domainName string) ([]dns.Record, error) {
+func (w *dnsServiceWrapper) GetRecords(domainName string) ([]dnsrecord.Record, error) {
return w.service.GetRecords(domainName)
}
-func (w *dnsServiceWrapper) GetRecordsByType(domainName string, recordType string) ([]dns.Record, error) {
+func (w *dnsServiceWrapper) GetRecordsByType(domainName string, recordType string) ([]dnsrecord.Record, error) {
return w.service.GetRecordsByType(domainName, recordType)
}
-func (w *dnsServiceWrapper) SetRecords(domainName string, records []dns.Record) error {
+func (w *dnsServiceWrapper) SetRecords(domainName string, records []dnsrecord.Record) error {
return w.service.SetRecords(domainName, records)
}
-func (w *dnsServiceWrapper) AddRecord(domainName string, record dns.Record) error {
+func (w *dnsServiceWrapper) AddRecord(domainName string, record dnsrecord.Record) error {
return w.service.AddRecord(domainName, record)
}
-func (w *dnsServiceWrapper) UpdateRecord(domainName string, hostname, recordType string, newRecord dns.Record) error {
+func (w *dnsServiceWrapper) UpdateRecord(domainName string, hostname, recordType string, newRecord dnsrecord.Record) error {
return w.service.UpdateRecord(domainName, hostname, recordType, newRecord)
}
@@ -216,7 +217,7 @@ func (w *dnsServiceWrapper) DeleteAllRecords(domainName string) error {
return w.service.DeleteAllRecords(domainName)
}
-func (w *dnsServiceWrapper) ValidateRecord(record dns.Record) error {
+func (w *dnsServiceWrapper) ValidateRecord(record dnsrecord.Record) error {
return w.service.ValidateRecord(record)
}
diff --git a/cmd/root.go b/cmd/root.go
index a1a5287..98f49bc 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -3,13 +3,15 @@ package cmd
import (
"fmt"
"os"
+ "path/filepath"
"github.com/spf13/cobra"
"github.com/spf13/viper"
- "namecheap-dns-manager/pkg/config"
- "namecheap-dns-manager/pkg/plugin"
- "namecheap-dns-manager/pkg/plugin/migadu"
- "namecheap-dns-manager/pkg/version"
+ "zonekit/pkg/config"
+ "zonekit/pkg/dns/provider/autodiscover"
+ "zonekit/pkg/plugin"
+ "zonekit/pkg/plugin/service"
+ "zonekit/pkg/version"
)
var cfgFile string
@@ -17,17 +19,17 @@ var accountName string
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
- Use: "namecheap-dns",
- Short: "A CLI tool for managing Namecheap domains and DNS records",
- Long: `A command-line interface for managing Namecheap domains and DNS records.
+ Use: "zonekit",
+ Short: "A CLI tool for managing DNS zones and records across multiple providers",
+ Long: `A command-line interface for managing DNS zones and records across multiple providers.
This tool allows you to:
- List and manage your domains
- Create, update, and delete DNS records
- Bulk operations on DNS records
- Domain registration and management
-- Manage multiple Namecheap accounts
+- Manage multiple DNS provider accounts
+- Support for multiple DNS providers (Namecheap, Cloudflare, and more)
-⚠️ WARNING: This is NOT an official Namecheap tool. Use at your own risk.
Current version: ` + version.Version + ` (pre-1.0.0)`,
Version: version.String(),
}
@@ -39,10 +41,10 @@ func Execute() error {
}
func init() {
- cobra.OnInitialize(initConfig, initPlugins)
+ cobra.OnInitialize(initConfig, initProviders, initPlugins)
// Here you will define your flags and configuration settings.
- rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.namecheap-dns.yaml)")
+ rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.zonekit.yaml)")
rootCmd.PersistentFlags().StringVar(&accountName, "account", "", "use specific account (default: current account)")
// Legacy flags for backward compatibility (deprecated)
@@ -75,10 +77,10 @@ func initConfig() {
home, err := os.UserHomeDir()
cobra.CheckErr(err)
- // Search config in home directory with name ".namecheap-dns" (without extension).
+ // Search config in home directory with name ".zonekit" (without extension).
viper.AddConfigPath(home)
viper.SetConfigType("yaml")
- viper.SetConfigName(".namecheap-dns")
+ viper.SetConfigName(".zonekit")
}
}
@@ -115,12 +117,88 @@ func GetCurrentAccount() (*config.AccountConfig, error) {
return configManager.GetCurrentAccount()
}
+// initProviders registers all available DNS providers
+func initProviders() {
+ // Auto-discover and register all REST-based providers from subdirectories
+ // OpenAPI-only approach: providers must have openapi.yaml file
+ if err := autodiscover.DiscoverAndRegister(""); err != nil {
+ // Log but don't fail - some providers might not have OpenAPI specs
+ // This is expected in development or if providers aren't configured
+ }
+}
+
// initPlugins registers all built-in plugins
func initPlugins() {
- // Register Migadu plugin
- if err := plugin.Register(migadu.New()); err != nil {
- // Log error but don't fail - plugins are optional
- fmt.Fprintf(os.Stderr, "Warning: failed to register Migadu plugin: %v\n", err)
+ // Register generic service plugin with config-based service integrations
+ serviceConfigs, err := loadServiceConfigs()
+ if err != nil {
+ // Log error but don't fail - service plugin is optional
+ fmt.Fprintf(os.Stderr, "Warning: failed to load service configs: %v\n", err)
+ } else if len(serviceConfigs) > 0 {
+ servicePlugin := service.NewServicePlugin(serviceConfigs)
+ if err := plugin.Register(servicePlugin); err != nil {
+ fmt.Fprintf(os.Stderr, "Warning: failed to register service plugin: %v\n", err)
+ }
+ }
+}
+
+// loadServiceConfigs loads all service integration configurations
+func loadServiceConfigs() (map[string]*service.Config, error) {
+ // Try to find services directory relative to executable or project root
+ servicesDir := findServicesDirectory()
+ if servicesDir == "" {
+ return nil, fmt.Errorf("services directory not found")
+ }
+
+ configs, err := service.LoadAllConfigs(servicesDir)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load service configs: %w", err)
}
- // Add more plugin registrations here as needed
+
+ return configs, nil
+}
+
+// findServicesDirectory finds the services configuration directory
+func findServicesDirectory() string {
+ // Try multiple locations in order of preference:
+
+ // 1. Project directory (for development)
+ if projectConfigPath := config.FindProjectConfigPath(); projectConfigPath != "" {
+ projectRoot := filepath.Dir(filepath.Dir(projectConfigPath))
+ servicesPath := filepath.Join(projectRoot, "pkg", "plugin", "service", "services")
+ if _, err := os.Stat(servicesPath); err == nil {
+ return servicesPath
+ }
+ }
+
+ // 2. Relative to executable (for installed binaries)
+ if exe, err := os.Executable(); err == nil {
+ exeDir := filepath.Dir(exe)
+ servicesPath := filepath.Join(exeDir, "services")
+ if _, err := os.Stat(servicesPath); err == nil {
+ return servicesPath
+ }
+ // Also try embedded location
+ servicesPath = filepath.Join(exeDir, "pkg", "plugin", "service", "services")
+ if _, err := os.Stat(servicesPath); err == nil {
+ return servicesPath
+ }
+ }
+
+ // 3. Current working directory
+ cwd, _ := os.Getwd()
+ servicesPath := filepath.Join(cwd, "pkg", "plugin", "service", "services")
+ if _, err := os.Stat(servicesPath); err == nil {
+ return servicesPath
+ }
+
+ // 4. Home directory
+ if home, err := os.UserHomeDir(); err == nil {
+ servicesPath := filepath.Join(home, ".zonekit", "services")
+ if _, err := os.Stat(servicesPath); err == nil {
+ return servicesPath
+ }
+ }
+
+ return ""
}
diff --git a/cmd/service.go b/cmd/service.go
new file mode 100644
index 0000000..0ac0ee6
--- /dev/null
+++ b/cmd/service.go
@@ -0,0 +1,272 @@
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+ "zonekit/internal/cmdutil"
+ "zonekit/pkg/dns"
+ "zonekit/pkg/plugin"
+)
+
+// serviceCmd represents the service command
+var serviceCmd = &cobra.Command{
+ Use: "service",
+ Short: "Service integration setup and management",
+ Long: `Commands for setting up DNS records for various service integrations (email, CDN, hosting, etc.) using config-based templates.`,
+}
+
+// serviceListCmd lists all available service integrations
+var serviceListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List all available service integrations",
+ Long: `Display all configured service integrations.`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ // Get service plugin
+ p, err := plugin.Get("service")
+ if err != nil {
+ return fmt.Errorf("service plugin not found: %w", err)
+ }
+
+ // Create context for list command
+ ctx := &plugin.Context{
+ Domain: "",
+ DNS: nil,
+ Args: []string{},
+ Flags: make(map[string]interface{}),
+ Output: &outputWriter{},
+ }
+
+ // Find and execute list command
+ for _, pluginCmd := range p.Commands() {
+ if pluginCmd.Name == "list" {
+ return pluginCmd.Execute(ctx)
+ }
+ }
+
+ return fmt.Errorf("list command not found in service plugin")
+ },
+}
+
+// serviceInfoCmd shows information about a specific service integration
+var serviceInfoCmd = &cobra.Command{
+ Use: "info ",
+ Short: "Show service integration information",
+ Long: `Display detailed information about a specific service integration.`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ serviceName := args[0]
+
+ // Get service plugin
+ p, err := plugin.Get("service")
+ if err != nil {
+ return fmt.Errorf("service plugin not found: %w", err)
+ }
+
+ // Create context for info command
+ ctx := &plugin.Context{
+ Domain: "",
+ DNS: nil,
+ Args: []string{serviceName},
+ Flags: make(map[string]interface{}),
+ Output: &outputWriter{},
+ }
+
+ // Find and execute info command
+ for _, pluginCmd := range p.Commands() {
+ if pluginCmd.Name == "info" {
+ return pluginCmd.Execute(ctx)
+ }
+ }
+
+ return fmt.Errorf("info command not found in service plugin")
+ },
+}
+
+// serviceSetupCmd sets up DNS records for a service integration
+var serviceSetupCmd = &cobra.Command{
+ Use: "setup ",
+ Short: "Set up DNS records for a service integration",
+ Long: `Set up all necessary DNS records for a configured service integration.`,
+ Args: cobra.ExactArgs(2),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ serviceName := args[0]
+ domainName := args[1]
+
+ // Get current account configuration
+ accountConfig, err := GetCurrentAccount()
+ if err != nil {
+ return fmt.Errorf("failed to get account configuration: %w", err)
+ }
+
+ // Create client and display account info
+ client, err := cmdutil.CreateClient(accountConfig)
+ if err != nil {
+ return err
+ }
+ cmdutil.DisplayAccountInfo(accountConfig)
+
+ // Create DNS service
+ dnsService := dns.NewService(client)
+
+ // Build flags map
+ flags := make(map[string]interface{})
+ if cmd.Flags().Changed("dry-run") {
+ val, _ := cmd.Flags().GetBool("dry-run")
+ flags["dry-run"] = val
+ }
+ if cmd.Flags().Changed("replace") {
+ val, _ := cmd.Flags().GetBool("replace")
+ flags["replace"] = val
+ }
+
+ // Get service plugin
+ p, err := plugin.Get("service")
+ if err != nil {
+ return fmt.Errorf("service plugin not found: %w", err)
+ }
+
+ // Create context
+ ctx := &plugin.Context{
+ Domain: domainName,
+ DNS: &dnsServiceWrapper{service: dnsService},
+ Args: []string{serviceName, domainName},
+ Flags: flags,
+ Output: &outputWriter{},
+ }
+
+ // Find and execute setup command
+ for _, pluginCmd := range p.Commands() {
+ if pluginCmd.Name == "setup" {
+ return pluginCmd.Execute(ctx)
+ }
+ }
+
+ return fmt.Errorf("setup command not found in service plugin")
+ },
+}
+
+// serviceVerifyCmd verifies DNS records for a service integration
+var serviceVerifyCmd = &cobra.Command{
+ Use: "verify ",
+ Short: "Verify DNS records for a service integration",
+ Long: `Check if all required DNS records for a service integration are properly configured.`,
+ Args: cobra.ExactArgs(2),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ serviceName := args[0]
+ domainName := args[1]
+
+ // Get current account configuration
+ accountConfig, err := GetCurrentAccount()
+ if err != nil {
+ return fmt.Errorf("failed to get account configuration: %w", err)
+ }
+
+ // Create client and display account info
+ client, err := cmdutil.CreateClient(accountConfig)
+ if err != nil {
+ return err
+ }
+ cmdutil.DisplayAccountInfo(accountConfig)
+
+ // Create DNS service
+ dnsService := dns.NewService(client)
+
+ // Get service plugin
+ p, err := plugin.Get("service")
+ if err != nil {
+ return fmt.Errorf("service plugin not found: %w", err)
+ }
+
+ // Create context
+ ctx := &plugin.Context{
+ Domain: domainName,
+ DNS: &dnsServiceWrapper{service: dnsService},
+ Args: []string{serviceName, domainName},
+ Flags: make(map[string]interface{}),
+ Output: &outputWriter{},
+ }
+
+ // Find and execute verify command
+ for _, pluginCmd := range p.Commands() {
+ if pluginCmd.Name == "verify" {
+ return pluginCmd.Execute(ctx)
+ }
+ }
+
+ return fmt.Errorf("verify command not found in service plugin")
+ },
+}
+
+// serviceRemoveCmd removes DNS records for a service integration
+var serviceRemoveCmd = &cobra.Command{
+ Use: "remove ",
+ Short: "Remove DNS records for a service integration",
+ Long: `Remove all service-related DNS records from the specified domain.`,
+ Args: cobra.ExactArgs(2),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ serviceName := args[0]
+ domainName := args[1]
+
+ // Get current account configuration
+ accountConfig, err := GetCurrentAccount()
+ if err != nil {
+ return fmt.Errorf("failed to get account configuration: %w", err)
+ }
+
+ // Create client and display account info
+ client, err := cmdutil.CreateClient(accountConfig)
+ if err != nil {
+ return err
+ }
+ cmdutil.DisplayAccountInfo(accountConfig)
+
+ // Create DNS service
+ dnsService := dns.NewService(client)
+
+ // Build flags map
+ flags := make(map[string]interface{})
+ if cmd.Flags().Changed("confirm") {
+ val, _ := cmd.Flags().GetBool("confirm")
+ flags["confirm"] = val
+ }
+
+ // Get service plugin
+ p, err := plugin.Get("service")
+ if err != nil {
+ return fmt.Errorf("service plugin not found: %w", err)
+ }
+
+ // Create context
+ ctx := &plugin.Context{
+ Domain: domainName,
+ DNS: &dnsServiceWrapper{service: dnsService},
+ Args: []string{serviceName, domainName},
+ Flags: flags,
+ Output: &outputWriter{},
+ }
+
+ // Find and execute remove command
+ for _, pluginCmd := range p.Commands() {
+ if pluginCmd.Name == "remove" {
+ return pluginCmd.Execute(ctx)
+ }
+ }
+
+ return fmt.Errorf("remove command not found in service plugin")
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(serviceCmd)
+ serviceCmd.AddCommand(serviceListCmd)
+ serviceCmd.AddCommand(serviceInfoCmd)
+ serviceCmd.AddCommand(serviceSetupCmd)
+ serviceCmd.AddCommand(serviceVerifyCmd)
+ serviceCmd.AddCommand(serviceRemoveCmd)
+
+ // Flags
+ serviceSetupCmd.Flags().Bool("dry-run", false, "Show what would be done without making changes")
+ serviceSetupCmd.Flags().Bool("replace", false, "Replace existing records")
+ serviceRemoveCmd.Flags().BoolP("confirm", "y", false, "Confirm the operation")
+}
diff --git a/configs/config.example.yaml b/configs/config.example.yaml
index edd77de..ba65eff 100644
--- a/configs/config.example.yaml
+++ b/configs/config.example.yaml
@@ -1,20 +1,22 @@
-# Namecheap DNS Manager Configuration
-# Copy this file to ~/.namecheap-dns.yaml and fill in your credentials
+# ZoneKit Configuration
+# Copy this file to ~/.zonekit.yaml and fill in your credentials
# Multi-account configuration
# You can have multiple accounts and easily switch between them
accounts:
# Default account (used when no specific account is selected)
default:
+ provider: "namecheap" # DNS provider: "namecheap", "cloudflare", etc.
username: "your-namecheap-username"
api_user: "your-api-username"
api_key: "your-api-key-here"
client_ip: "your.public.ip.address"
use_sandbox: false
description: "My main Namecheap account"
-
+
# Additional accounts (optional)
# work:
+ # provider: "namecheap"
# username: "work-username"
# api_user: "work-api-username"
# api_key: "work-api-key"
@@ -22,9 +24,16 @@ accounts:
# use_sandbox: false
# description: "Work account for company domains"
+ # Example: Cloudflare account (when Cloudflare provider is implemented)
+ # cloudflare:
+ # provider: "cloudflare"
+ # api_key: "your-cloudflare-api-key"
+ # email: "your-email@example.com"
+ # description: "Cloudflare account"
+
# Legacy single-account configuration (for backward compatibility)
# These will be automatically migrated to the new format
-username: "your-namecheap-username"
+username: "your-provider-username"
api_user: "your-api-username"
api_key: "your-api-key-here"
client_ip: "your.public.ip.address"
diff --git a/go.mod b/go.mod
index f5c9488..ce0d2b5 100644
--- a/go.mod
+++ b/go.mod
@@ -1,4 +1,4 @@
-module namecheap-dns-manager
+module zonekit
go 1.23.0
diff --git a/internal/cmdutil/client.go b/internal/cmdutil/client.go
index 9ca7e97..ed68428 100644
--- a/internal/cmdutil/client.go
+++ b/internal/cmdutil/client.go
@@ -3,8 +3,8 @@ package cmdutil
import (
"fmt"
- "namecheap-dns-manager/pkg/client"
- "namecheap-dns-manager/pkg/config"
+ "zonekit/pkg/client"
+ "zonekit/pkg/config"
)
// CreateClient creates a client from an account configuration.
diff --git a/internal/testutil/fixtures.go b/internal/testutil/fixtures.go
index 93e7022..20ea865 100644
--- a/internal/testutil/fixtures.go
+++ b/internal/testutil/fixtures.go
@@ -74,4 +74,3 @@ func ValidDomainFixture() string {
func ValidSubdomainFixture() string {
return "www.example.com"
}
-
diff --git a/internal/testutil/helpers.go b/internal/testutil/helpers.go
index 9c67882..e1e35f4 100644
--- a/internal/testutil/helpers.go
+++ b/internal/testutil/helpers.go
@@ -34,4 +34,3 @@ func CleanupTestDir(t *testing.T, path string) {
t.Logf("Failed to cleanup test directory %s: %v", path, err)
}
}
-
diff --git a/internal/testutil/suite.go b/internal/testutil/suite.go
index 13b2dc2..f0b2cf8 100644
--- a/internal/testutil/suite.go
+++ b/internal/testutil/suite.go
@@ -101,4 +101,3 @@ func (s *Suite) AssertEmpty(object interface{}, msgAndArgs ...interface{}) {
func (s *Suite) AssertNotEmpty(object interface{}, msgAndArgs ...interface{}) {
s.Require().NotEmpty(object, msgAndArgs...)
}
-
diff --git a/main.go b/main.go
index b1ec116..e14cf11 100644
--- a/main.go
+++ b/main.go
@@ -4,7 +4,7 @@ import (
"fmt"
"os"
- "namecheap-dns-manager/cmd"
+ "zonekit/cmd"
)
func main() {
diff --git a/pkg/client/client.go b/pkg/client/client.go
index 1d17a26..f38998e 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -4,7 +4,7 @@ import (
"fmt"
"github.com/namecheap/go-namecheap-sdk/v2/namecheap"
- "namecheap-dns-manager/pkg/config"
+ "zonekit/pkg/config"
)
// Client wraps the Namecheap SDK client with additional functionality
diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go
index 76efd4a..99cc78c 100644
--- a/pkg/client/client_test.go
+++ b/pkg/client/client_test.go
@@ -4,8 +4,8 @@ import (
"testing"
"github.com/stretchr/testify/suite"
- "namecheap-dns-manager/internal/testutil"
- "namecheap-dns-manager/pkg/config"
+ "zonekit/internal/testutil"
+ "zonekit/pkg/config"
)
// ClientTestSuite is a test suite for client package
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 86dde8c..b001db0 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -8,8 +8,11 @@ import (
"gopkg.in/yaml.v3"
)
-// AccountConfig represents a single Namecheap account configuration
+// AccountConfig represents a single DNS provider account configuration
type AccountConfig struct {
+ // Provider specifies which DNS provider to use (e.g., "namecheap", "cloudflare")
+ // Defaults to "namecheap" if not specified for backward compatibility
+ Provider string `yaml:"provider,omitempty" mapstructure:"provider,omitempty"`
Username string `yaml:"username" mapstructure:"username"`
APIUser string `yaml:"api_user" mapstructure:"api_user"`
APIKey string `yaml:"api_key" mapstructure:"api_key"`
@@ -87,7 +90,7 @@ func findHomeConfigPath() string {
if err != nil {
return ""
}
- return filepath.Join(home, ".namecheap-dns.yaml")
+ return filepath.Join(home, ".zonekit.yaml")
}
// Load reads the configuration from file
@@ -240,9 +243,9 @@ func (m *Manager) GetConfigPath() string {
// GetConfigLocation returns a human-readable description of where the config is located
func (m *Manager) GetConfigLocation() string {
if filepath.Dir(m.configPath) == filepath.Join(os.Getenv("HOME"), "configs") {
- return "project directory (configs/.namecheap-dns.yaml)"
+ return "project directory (configs/.zonekit.yaml)"
}
- return "home directory (~/.namecheap-dns.yaml)"
+ return "home directory (~/.zonekit.yaml)"
}
// GetCurrentAccountName returns the name of the currently selected account
@@ -255,7 +258,8 @@ func (m *Manager) createDefaultConfig() *Config {
return &Config{
Accounts: map[string]*AccountConfig{
"default": {
- Username: "your-namecheap-username",
+ Provider: "namecheap", // Default provider for backward compatibility
+ Username: "your-provider-username",
APIUser: "your-api-username",
APIKey: "your-api-key-here",
ClientIP: "your.public.ip.address",
@@ -275,6 +279,7 @@ func (m *Manager) migrateLegacyConfig() error {
// Create default account from legacy fields
defaultAccount := &AccountConfig{
+ Provider: "namecheap", // Default provider for migrated legacy configs
Username: m.config.Username,
APIUser: m.config.APIUser,
APIKey: m.config.APIKey,
@@ -308,6 +313,14 @@ func (m *Manager) migrateLegacyConfig() error {
return nil
}
+// GetProvider returns the provider name for an account, defaulting to "namecheap" for backward compatibility
+func (a *AccountConfig) GetProvider() string {
+ if a.Provider == "" {
+ return "namecheap" // Default provider for backward compatibility
+ }
+ return a.Provider
+}
+
// ValidateAccount validates an account configuration
func (m *Manager) ValidateAccount(account *AccountConfig) error {
if account.Username == "" {
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index cdad4b3..1668bb6 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/suite"
"gopkg.in/yaml.v3"
- "namecheap-dns-manager/internal/testutil"
+ "zonekit/internal/testutil"
)
// ConfigTestSuite is a test suite for config package
diff --git a/pkg/config/path.go b/pkg/config/path.go
index b5a4f7e..cc90adf 100644
--- a/pkg/config/path.go
+++ b/pkg/config/path.go
@@ -15,7 +15,7 @@ func FindProjectConfigPath() string {
// Look for config in current directory and parent directories
for {
- configPath := filepath.Join(cwd, "configs", ".namecheap-dns.yaml")
+ configPath := filepath.Join(cwd, "configs", ".zonekit.yaml")
if _, err := os.Stat(configPath); err == nil {
return configPath
}
@@ -30,4 +30,3 @@ func FindProjectConfigPath() string {
return ""
}
-
diff --git a/pkg/dns/provider/README.md b/pkg/dns/provider/README.md
new file mode 100644
index 0000000..13bea5f
--- /dev/null
+++ b/pkg/dns/provider/README.md
@@ -0,0 +1,183 @@
+# DNS Provider Infrastructure
+
+This package provides a pluggable architecture for supporting multiple DNS providers with a generic, reusable infrastructure.
+
+## Architecture
+
+### Core Components
+
+1. **Provider Interface** (`provider.go`) - Standard interface all providers implement
+2. **Registry** (`registry.go`) - Thread-safe provider registry
+3. **HTTP Client** (`http/client.go`) - Generic HTTP client with retry, timeout, error handling
+4. **REST Provider** (`rest/rest.go`) - Generic REST-based provider implementation
+5. **Builder** (`builder/builder.go`) - Factory to create providers from config
+6. **Authentication** (`auth/auth.go`) - Authentication handlers (API key, Bearer, Basic, OAuth)
+7. **Field Mapper** (`mapper/mapper.go`) - Maps between our format and provider formats
+8. **Config Loader** (`config/config.go`) - Loads provider configurations from YAML files
+
+### Directory Structure
+
+```
+pkg/dns/provider/
+├── provider.go # Provider interface
+├── registry.go # Provider registry
+│
+├── http/ # Generic HTTP client
+│ └── client.go
+│
+├── rest/ # Generic REST provider
+│ └── rest.go
+│
+├── builder/ # Provider builder/factory
+│ └── builder.go
+│
+├── auth/ # Authentication handlers
+│ └── auth.go
+│
+├── mapper/ # Field mapping utilities
+│ └── mapper.go
+│
+├── config/ # Config loading
+│ └── config.go
+│
+├── namecheap/ # Namecheap provider (SOAP, custom)
+│ ├── adapter.go
+│ └── config.yaml.example
+│
+└── cloudflare/ # Cloudflare provider (REST, config-based)
+ └── config.yaml.example
+```
+
+## Adding a New REST Provider
+
+**OpenAPI-Only Approach** - Just create an OpenAPI spec file!
+
+### Step 1: Create Provider Directory
+
+```bash
+mkdir -p pkg/dns/provider/newprovider
+```
+
+### Step 2: Create OpenAPI Specification
+
+Create `pkg/dns/provider/newprovider/openapi.yaml`:
+
+```yaml
+openapi: 3.0.0
+info:
+ title: New Provider DNS API
+ version: 1.0.0
+servers:
+ - url: https://api.newprovider.com/v1
+paths:
+ /domains/{domain}/records:
+ get:
+ operationId: listDNSRecords
+ # ... endpoint definition
+ post:
+ operationId: createDNSRecord
+ # ... endpoint definition
+components:
+ securitySchemes:
+ BearerAuth:
+ type: http
+ scheme: bearer
+ schemas:
+ DNSRecord:
+ type: object
+ properties:
+ name: {type: string} # Maps to hostname
+ type: {type: string} # Maps to record_type
+ data: {type: string} # Maps to address
+ ttl: {type: integer} # Maps to ttl
+ priority: {type: integer} # Maps to mx_pref
+```
+
+### Step 3: Done!
+
+**That's it!** The provider will be automatically discovered and registered on startup.
+
+No code needed - just the OpenAPI spec file. The auto-discovery system will:
+1. Scan `pkg/dns/provider/*/` directories
+2. Find `openapi.yaml` files
+3. Parse spec and generate provider config automatically
+4. Register providers automatically
+
+### Step 4: Use Provider
+
+```go
+dnsService, err := dns.NewServiceWithProviderName("newprovider")
+if err != nil {
+ return err
+}
+
+records, err := dnsService.GetRecords("example.com")
+```
+
+## Adding a Custom Provider (Non-REST)
+
+For providers that don't fit the REST pattern (like Namecheap with SOAP):
+
+1. Create provider directory: `pkg/dns/provider/customprovider/`
+2. Implement the `Provider` interface directly
+3. Register in your initialization code
+
+Example:
+
+```go
+package customprovider
+
+type CustomProvider struct {
+ // provider-specific fields
+}
+
+func (p *CustomProvider) Name() string {
+ return "customprovider"
+}
+
+func (p *CustomProvider) GetRecords(domainName string) ([]dnsrecord.Record, error) {
+ // Custom implementation
+}
+
+func (p *CustomProvider) SetRecords(domainName string, records []dnsrecord.Record) error {
+ // Custom implementation
+}
+
+func (p *CustomProvider) Validate() error {
+ // Validation logic
+}
+```
+
+## Authentication Methods
+
+Supported authentication methods:
+
+- **api_key**: API key authentication (with optional email)
+- **bearer**: Bearer token authentication
+- **basic**: Basic authentication
+- **oauth**: OAuth token (treated as Bearer)
+- **custom**: Custom headers
+
+## Field Mappings
+
+Field mappings allow you to translate between our standard format and provider-specific formats:
+
+- **Request mappings**: Our format → Provider format
+- **Response mappings**: Provider format → Our format
+- **List path**: JSON path to records array in response
+
+## Benefits
+
+- **Standardized Interface**: All providers implement the same interface
+- **Easy to Add**: REST providers just need a config file
+- **Config-based**: Simple REST providers are mostly configuration
+- **Well-tested Infrastructure**: Generic components are tested and reliable
+- **Sync/Migration Ready**: All providers available for cross-provider operations
+
+## Future Enhancements
+
+1. **Auto-discovery**: Automatically load all provider configs from directory
+2. **Provider Testing**: Standardized test suite for providers
+3. **OAuth Flow**: Full OAuth implementation for providers requiring it
+4. **Rate Limiting**: Built-in rate limiting support
+5. **Caching**: Optional response caching
diff --git a/pkg/dns/provider/auth/auth.go b/pkg/dns/provider/auth/auth.go
new file mode 100644
index 0000000..2522cb6
--- /dev/null
+++ b/pkg/dns/provider/auth/auth.go
@@ -0,0 +1,242 @@
+package auth
+
+import (
+ "fmt"
+ "os"
+ "strings"
+)
+
+// Method represents authentication method
+type Method string
+
+const (
+ MethodAPIKey Method = "api_key"
+ MethodOAuth Method = "oauth"
+ MethodBasic Method = "basic"
+ MethodBearer Method = "bearer"
+ MethodCustom Method = "custom"
+)
+
+// Credentials holds authentication credentials
+type Credentials map[string]interface{}
+
+// Authenticator handles authentication for HTTP requests
+type Authenticator interface {
+ // GetHeaders returns headers to add to requests
+ GetHeaders() map[string]string
+ // Validate checks if credentials are valid
+ Validate() error
+}
+
+// NewAuthenticator creates an authenticator based on method and credentials
+func NewAuthenticator(method string, credentials Credentials) (Authenticator, error) {
+ switch Method(method) {
+ case MethodAPIKey:
+ return NewAPIKeyAuthenticator(credentials)
+ case MethodBearer:
+ return NewBearerAuthenticator(credentials)
+ case MethodBasic:
+ return NewBasicAuthenticator(credentials)
+ case MethodOAuth:
+ return NewOAuthAuthenticator(credentials)
+ case MethodCustom:
+ return NewCustomAuthenticator(credentials)
+ default:
+ return nil, fmt.Errorf("unsupported authentication method: %s", method)
+ }
+}
+
+// APIKeyAuthenticator handles API key authentication
+type APIKeyAuthenticator struct {
+ APIKey string
+ Email string // Some providers use email + API key
+ Header string // Header name (e.g., "X-API-Key", "Authorization")
+}
+
+// NewAPIKeyAuthenticator creates an API key authenticator
+func NewAPIKeyAuthenticator(credentials Credentials) (*APIKeyAuthenticator, error) {
+ apiKey, ok := credentials["api_key"].(string)
+ if !ok {
+ apiKey = getEnvOrValue(credentials["api_key"])
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("api_key is required for api_key authentication")
+ }
+
+ email := getEnvOrValue(credentials["email"])
+ header := getStringValue(credentials["header"], "X-API-Key")
+
+ return &APIKeyAuthenticator{
+ APIKey: apiKey,
+ Email: email,
+ Header: header,
+ }, nil
+}
+
+func (a *APIKeyAuthenticator) GetHeaders() map[string]string {
+ headers := make(map[string]string)
+
+ if a.Email != "" {
+ headers["X-Auth-Email"] = a.Email
+ }
+
+ if a.Header == "Authorization" {
+ headers["Authorization"] = "Bearer " + a.APIKey
+ } else {
+ headers[a.Header] = a.APIKey
+ }
+
+ return headers
+}
+
+func (a *APIKeyAuthenticator) Validate() error {
+ if a.APIKey == "" {
+ return fmt.Errorf("API key is empty")
+ }
+ return nil
+}
+
+// BearerAuthenticator handles Bearer token authentication
+type BearerAuthenticator struct {
+ Token string
+}
+
+// NewBearerAuthenticator creates a Bearer token authenticator
+func NewBearerAuthenticator(credentials Credentials) (*BearerAuthenticator, error) {
+ token, ok := credentials["token"].(string)
+ if !ok {
+ token = getEnvOrValue(credentials["token"])
+ }
+ if token == "" {
+ return nil, fmt.Errorf("token is required for bearer authentication")
+ }
+
+ return &BearerAuthenticator{Token: token}, nil
+}
+
+func (a *BearerAuthenticator) GetHeaders() map[string]string {
+ return map[string]string{
+ "Authorization": "Bearer " + a.Token,
+ }
+}
+
+func (a *BearerAuthenticator) Validate() error {
+ if a.Token == "" {
+ return fmt.Errorf("bearer token is empty")
+ }
+ return nil
+}
+
+// BasicAuthenticator handles Basic authentication
+type BasicAuthenticator struct {
+ Username string
+ Password string
+}
+
+// NewBasicAuthenticator creates a Basic authenticator
+func NewBasicAuthenticator(credentials Credentials) (*BasicAuthenticator, error) {
+ username := getEnvOrValue(credentials["username"])
+ password := getEnvOrValue(credentials["password"])
+
+ if username == "" || password == "" {
+ return nil, fmt.Errorf("username and password are required for basic authentication")
+ }
+
+ return &BasicAuthenticator{
+ Username: username,
+ Password: password,
+ }, nil
+}
+
+func (a *BasicAuthenticator) GetHeaders() map[string]string {
+ // Basic auth is typically handled by http.Client, but we can add it here if needed
+ // For now, return empty - caller should use http.Client's Transport
+ return map[string]string{}
+}
+
+func (a *BasicAuthenticator) Validate() error {
+ if a.Username == "" || a.Password == "" {
+ return fmt.Errorf("username or password is empty")
+ }
+ return nil
+}
+
+// OAuthAuthenticator handles OAuth authentication (placeholder for future)
+type OAuthAuthenticator struct {
+ AccessToken string
+}
+
+// NewOAuthAuthenticator creates an OAuth authenticator
+func NewOAuthAuthenticator(credentials Credentials) (*OAuthAuthenticator, error) {
+ // OAuth implementation would go here
+ // For now, treat it like Bearer token
+ token := getEnvOrValue(credentials["access_token"])
+ if token == "" {
+ return nil, fmt.Errorf("access_token is required for oauth authentication")
+ }
+
+ return &OAuthAuthenticator{AccessToken: token}, nil
+}
+
+func (a *OAuthAuthenticator) GetHeaders() map[string]string {
+ return map[string]string{
+ "Authorization": "Bearer " + a.AccessToken,
+ }
+}
+
+func (a *OAuthAuthenticator) Validate() error {
+ if a.AccessToken == "" {
+ return fmt.Errorf("OAuth access token is empty")
+ }
+ return nil
+}
+
+// CustomAuthenticator handles custom authentication methods
+type CustomAuthenticator struct {
+ Headers map[string]string
+}
+
+// NewCustomAuthenticator creates a custom authenticator
+func NewCustomAuthenticator(credentials Credentials) (*CustomAuthenticator, error) {
+ headers := make(map[string]string)
+
+ if headersMap, ok := credentials["headers"].(map[string]interface{}); ok {
+ for k, v := range headersMap {
+ headers[k] = getEnvOrValue(v)
+ }
+ }
+
+ return &CustomAuthenticator{Headers: headers}, nil
+}
+
+func (a *CustomAuthenticator) GetHeaders() map[string]string {
+ return a.Headers
+}
+
+func (a *CustomAuthenticator) Validate() error {
+ if len(a.Headers) == 0 {
+ return fmt.Errorf("custom authenticator requires at least one header")
+ }
+ return nil
+}
+
+// Helper functions
+
+func getEnvOrValue(value interface{}) string {
+ if str, ok := value.(string); ok {
+ // Check if it's an environment variable reference
+ if strings.HasPrefix(str, "${") && strings.HasSuffix(str, "}") {
+ envVar := strings.TrimPrefix(strings.TrimSuffix(str, "}"), "${")
+ return os.Getenv(envVar)
+ }
+ return str
+ }
+ return ""
+}
+
+func getStringValue(value interface{}, defaultValue string) string {
+ if str := getEnvOrValue(value); str != "" {
+ return str
+ }
+ return defaultValue
+}
diff --git a/pkg/dns/provider/autodiscover/autodiscover.go b/pkg/dns/provider/autodiscover/autodiscover.go
new file mode 100644
index 0000000..c5a3bbf
--- /dev/null
+++ b/pkg/dns/provider/autodiscover/autodiscover.go
@@ -0,0 +1,114 @@
+package autodiscover
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ dnsprovider "zonekit/pkg/dns/provider"
+ "zonekit/pkg/dns/provider/builder"
+ "zonekit/pkg/dns/provider/openapi"
+)
+
+// DiscoverAndRegister discovers all providers from subdirectories and registers them
+// Scans pkg/dns/provider/*/ directories for openapi.yaml files (OpenAPI-only approach)
+func DiscoverAndRegister(baseDir string) error {
+ if baseDir == "" {
+ baseDir = findProviderDirectory()
+ if baseDir == "" {
+ return fmt.Errorf("provider directory not found")
+ }
+ }
+
+ entries, err := os.ReadDir(baseDir)
+ if err != nil {
+ return fmt.Errorf("failed to read provider directory: %w", err)
+ }
+
+ var errors []error
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+
+ // Skip hidden directories and special directories
+ name := entry.Name()
+ if name[0] == '.' || name == "auth" || name == "builder" || name == "config" ||
+ name == "http" || name == "mapper" || name == "rest" || name == "autodiscover" {
+ continue
+ }
+
+ // Skip namecheap - it's registered separately via namecheap.Register()
+ if name == "namecheap" {
+ continue
+ }
+
+ providerDir := filepath.Join(baseDir, name)
+
+ // OpenAPI-only approach: require openapi.yaml
+ specPath, err := openapi.FindSpecFile(providerDir)
+ if err != nil {
+ // No OpenAPI spec found, skip this provider
+ // (OpenAPI-only approach - no fallback to config.yaml)
+ continue
+ }
+
+ // Load OpenAPI spec and convert to config
+ spec, err := openapi.LoadSpec(specPath)
+ if err != nil {
+ errors = append(errors, fmt.Errorf("failed to load OpenAPI spec for %s: %w", name, err))
+ continue
+ }
+
+ cfg, err := spec.ToProviderConfig(name)
+ if err != nil {
+ errors = append(errors, fmt.Errorf("failed to convert OpenAPI spec for %s: %w", name, err))
+ continue
+ }
+
+ provider, err := builder.BuildProvider(cfg)
+ if err != nil {
+ errors = append(errors, fmt.Errorf("failed to build %s provider: %w", name, err))
+ continue
+ }
+
+ if err := dnsprovider.Register(provider); err != nil {
+ // Provider might already be registered, that's okay
+ continue
+ }
+ }
+
+ // Return first error if any, but don't fail completely
+ if len(errors) > 0 {
+ return errors[0]
+ }
+
+ return nil
+}
+
+// findProviderDirectory finds the provider directory
+func findProviderDirectory() string {
+ // Try multiple locations
+ locations := []string{
+ "pkg/dns/provider", // From project root
+ "./pkg/dns/provider", // Relative to current dir
+ filepath.Join("..", "pkg", "dns", "provider"), // From internal dirs
+ }
+
+ // Also try to find it relative to the executable
+ if execPath, err := os.Executable(); err == nil {
+ execDir := filepath.Dir(execPath)
+ locations = append(locations,
+ filepath.Join(execDir, "pkg", "dns", "provider"),
+ filepath.Join(filepath.Dir(execDir), "pkg", "dns", "provider"),
+ )
+ }
+
+ for _, loc := range locations {
+ if info, err := os.Stat(loc); err == nil && info.IsDir() {
+ return loc
+ }
+ }
+
+ return ""
+}
diff --git a/pkg/dns/provider/builder/builder.go b/pkg/dns/provider/builder/builder.go
new file mode 100644
index 0000000..a1decfc
--- /dev/null
+++ b/pkg/dns/provider/builder/builder.go
@@ -0,0 +1,201 @@
+package builder
+
+import (
+ "fmt"
+ "time"
+
+ dnsprovider "zonekit/pkg/dns/provider"
+ "zonekit/pkg/dns/provider/auth"
+ httpprovider "zonekit/pkg/dns/provider/http"
+ "zonekit/pkg/dns/provider/mapper"
+ "zonekit/pkg/dns/provider/rest"
+)
+
+// BuildProvider creates a DNS provider from configuration
+func BuildProvider(config *dnsprovider.Config) (dnsprovider.Provider, error) {
+ if err := validateConfig(config); err != nil {
+ return nil, fmt.Errorf("invalid provider config: %w", err)
+ }
+
+ // Create authenticator
+ authenticator, err := auth.NewAuthenticator(config.Auth.Method, config.Auth.Credentials)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create authenticator: %w", err)
+ }
+
+ if err := authenticator.Validate(); err != nil {
+ return nil, fmt.Errorf("authenticator validation failed: %w", err)
+ }
+
+ // Get auth headers
+ authHeaders := authenticator.GetHeaders()
+
+ // Merge with configured headers
+ headers := make(map[string]string)
+ for k, v := range config.API.Headers {
+ headers[k] = v
+ }
+ for k, v := range authHeaders {
+ headers[k] = v
+ }
+
+ // Create HTTP client
+ httpClient := httpprovider.NewClient(httpprovider.ClientConfig{
+ BaseURL: config.API.BaseURL,
+ Headers: headers,
+ Timeout: time.Duration(config.API.Timeout) * time.Second,
+ Retries: config.API.Retries,
+ })
+
+ // Build provider based on type
+ switch config.Type {
+ case "rest":
+ return buildRESTProvider(config, httpClient)
+ case "namecheap":
+ // Namecheap uses SOAP, handled separately
+ return nil, fmt.Errorf("namecheap provider must be created using namecheap.New()")
+ default:
+ return nil, fmt.Errorf("unsupported provider type: %s", config.Type)
+ }
+}
+
+// buildRESTProvider creates a REST-based provider
+func buildRESTProvider(config *dnsprovider.Config, client *httpprovider.Client) (dnsprovider.Provider, error) {
+ // Build mappings
+ mappings := buildMappings(config.Mappings)
+
+ // Create REST provider
+ provider := rest.NewRESTProvider(
+ config.Name,
+ client,
+ mappings,
+ config.API.Endpoints,
+ config.Settings,
+ )
+
+ if err := provider.Validate(); err != nil {
+ return nil, fmt.Errorf("provider validation failed: %w", err)
+ }
+
+ return provider, nil
+}
+
+// buildMappings builds field mappings from config
+func buildMappings(configMappings *dnsprovider.FieldMappings) mapper.Mappings {
+ if configMappings == nil {
+ return mapper.DefaultMappings()
+ }
+
+ m := mapper.Mappings{
+ ListPath: configMappings.ListPath,
+ }
+
+ // Request mappings
+ if configMappings.Request.HostName != "" {
+ m.Request.HostName = configMappings.Request.HostName
+ } else {
+ m.Request.HostName = "hostname"
+ }
+
+ if configMappings.Request.RecordType != "" {
+ m.Request.RecordType = configMappings.Request.RecordType
+ } else {
+ m.Request.RecordType = "record_type"
+ }
+
+ if configMappings.Request.Address != "" {
+ m.Request.Address = configMappings.Request.Address
+ } else {
+ m.Request.Address = "address"
+ }
+
+ if configMappings.Request.TTL != "" {
+ m.Request.TTL = configMappings.Request.TTL
+ } else {
+ m.Request.TTL = "ttl"
+ }
+
+ if configMappings.Request.MXPref != "" {
+ m.Request.MXPref = configMappings.Request.MXPref
+ } else {
+ m.Request.MXPref = "mx_pref"
+ }
+
+ if configMappings.Request.ID != "" {
+ m.Request.ID = configMappings.Request.ID
+ } else {
+ m.Request.ID = ""
+ }
+
+ // Response mappings
+ if configMappings.Response.HostName != "" {
+ m.Response.HostName = configMappings.Response.HostName
+ } else {
+ m.Response.HostName = "hostname"
+ }
+
+ if configMappings.Response.RecordType != "" {
+ m.Response.RecordType = configMappings.Response.RecordType
+ } else {
+ m.Response.RecordType = "record_type"
+ }
+
+ if configMappings.Response.Address != "" {
+ m.Response.Address = configMappings.Response.Address
+ } else {
+ m.Response.Address = "address"
+ }
+
+ if configMappings.Response.TTL != "" {
+ m.Response.TTL = configMappings.Response.TTL
+ } else {
+ m.Response.TTL = "ttl"
+ }
+
+ if configMappings.Response.MXPref != "" {
+ m.Response.MXPref = configMappings.Response.MXPref
+ } else {
+ m.Response.MXPref = "mx_pref"
+ }
+
+ if configMappings.Response.ID != "" {
+ m.Response.ID = configMappings.Response.ID
+ } else {
+ m.Response.ID = ""
+ }
+
+ return m
+}
+
+// validateConfig validates provider configuration
+func validateConfig(config *dnsprovider.Config) error {
+ if config == nil {
+ return fmt.Errorf("config is nil")
+ }
+
+ if config.Name == "" {
+ return fmt.Errorf("provider name is required")
+ }
+
+ if config.Type == "" {
+ return fmt.Errorf("provider type is required")
+ }
+
+ if config.API.BaseURL == "" {
+ return fmt.Errorf("API base URL is required")
+ }
+
+ if len(config.API.Endpoints) == 0 {
+ return fmt.Errorf("at least one API endpoint is required")
+ }
+
+ if config.Auth.Method == "" {
+ return fmt.Errorf("authentication method is required")
+ }
+
+ if len(config.Auth.Credentials) == 0 {
+ return fmt.Errorf("authentication credentials are required")
+ }
+
+ return nil
+}
diff --git a/pkg/dns/provider/builder/builder_test.go b/pkg/dns/provider/builder/builder_test.go
new file mode 100644
index 0000000..1a7abf7
--- /dev/null
+++ b/pkg/dns/provider/builder/builder_test.go
@@ -0,0 +1,25 @@
+package builder
+
+import (
+ "testing"
+
+ "zonekit/pkg/dns/provider/openapi"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildProvider_FromCloudflareSpec(t *testing.T) {
+ specPath := "../cloudflare/openapi.yaml"
+ spec, err := openapi.LoadSpec(specPath)
+ require.NoError(t, err)
+
+ cfg, err := spec.ToProviderConfig("cloudflare")
+ require.NoError(t, err)
+
+ prov, err := BuildProvider(cfg)
+ require.NoError(t, err)
+ require.NotNil(t, prov)
+
+ // Validate provider
+ require.NoError(t, prov.Validate())
+}
diff --git a/pkg/dns/provider/cloudflare/OPENAPI_SOURCE.md b/pkg/dns/provider/cloudflare/OPENAPI_SOURCE.md
new file mode 100644
index 0000000..c927447
--- /dev/null
+++ b/pkg/dns/provider/cloudflare/OPENAPI_SOURCE.md
@@ -0,0 +1,50 @@
+# Cloudflare OpenAPI Schema
+
+## Source Information
+
+- **Official Repository**: https://github.com/cloudflare/api-schemas
+- **Direct Download URL**: https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml
+- **Documentation**: https://developers.cloudflare.com/api/
+- **Last Downloaded**: $(date +%Y-%m-%d)
+
+## Schema Details
+
+- **Size**: ~16MB (full Cloudflare API, includes all services)
+- **OpenAPI Version**: 3.0+
+- **Includes**: All Cloudflare API endpoints including DNS, CDN, Security, etc.
+
+## DNS Endpoints
+
+The schema includes DNS-related endpoints under:
+- `/zones/{zone_id}/dns_records` - DNS record management operations
+
+## Usage
+
+This schema is automatically discovered by the auto-discovery system. The system will:
+1. Parse the OpenAPI spec
+2. Extract DNS-related endpoints
+3. Extract authentication methods
+4. Extract field mappings from schemas
+5. Generate provider configuration automatically
+
+## Updating
+
+To update to the latest schema:
+
+```bash
+./scripts/download-openapi-schemas.sh
+```
+
+Or manually:
+
+```bash
+curl -L https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml \
+ -o pkg/dns/provider/cloudflare/openapi.yaml
+```
+
+## Notes
+
+- The full spec is large because it includes all Cloudflare services
+- For DNS-only usage, you could extract just DNS paths (see script comments)
+- The auto-discovery system handles the full spec efficiently
+
diff --git a/pkg/dns/provider/cloudflare/config.yaml.example b/pkg/dns/provider/cloudflare/config.yaml.example
new file mode 100644
index 0000000..91086d6
--- /dev/null
+++ b/pkg/dns/provider/cloudflare/config.yaml.example
@@ -0,0 +1,47 @@
+# Example Cloudflare DNS Provider Configuration
+# This shows how a REST-based provider could be configured
+
+name: cloudflare
+display_name: Cloudflare
+type: rest # Uses generic REST adapter
+
+auth:
+ method: api_key
+ credentials:
+ api_key: "${CLOUDFLARE_API_KEY}"
+ email: "${CLOUDFLARE_EMAIL}"
+
+api:
+ base_url: "https://api.cloudflare.com/client/v4"
+ endpoints:
+ get_records: "/zones/{zone_id}/dns_records"
+ create_record: "/zones/{zone_id}/dns_records"
+ update_record: "/zones/{zone_id}/dns_records/{record_id}"
+ delete_record: "/zones/{zone_id}/dns_records/{record_id}"
+ headers:
+ X-Auth-Email: "${CLOUDFLARE_EMAIL}"
+ X-Auth-Key: "${CLOUDFLARE_API_KEY}"
+ Content-Type: "application/json"
+ timeout: 30
+ retries: 3
+
+mappings:
+ request:
+ hostname: "name"
+ record_type: "type"
+ address: "content"
+ ttl: "ttl"
+ mx_pref: "priority"
+ response:
+ hostname: "name"
+ record_type: "type"
+ address: "content"
+ ttl: "ttl"
+ mx_pref: "priority"
+ list_path: "result"
+
+settings:
+ # Cloudflare-specific settings
+ zone_id_required: true # Need zone_id for API calls
+ proxied: false # Default proxy setting
+
diff --git a/pkg/dns/provider/cloudflare/openapi.yaml b/pkg/dns/provider/cloudflare/openapi.yaml
new file mode 100644
index 0000000..fdb2423
--- /dev/null
+++ b/pkg/dns/provider/cloudflare/openapi.yaml
@@ -0,0 +1,228 @@
+openapi: 3.0.0
+info:
+ title: Cloudflare DNS API
+ version: 1.0.0
+ description: Minimal OpenAPI spec for Cloudflare DNS operations only
+servers:
+ - url: https://api.cloudflare.com/client/v4
+ description: Cloudflare API v4
+paths:
+ /zones/{zone_id}/dns_records:
+ get:
+ summary: List DNS records
+ operationId: listDNSRecords
+ tags:
+ - DNS Records
+ parameters:
+ - name: zone_id
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Zone identifier
+ responses:
+ '200':
+ description: List of DNS records
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecordsResponse'
+ post:
+ summary: Create DNS record
+ operationId: createDNSRecord
+ tags:
+ - DNS Records
+ parameters:
+ - name: zone_id
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecordRequest'
+ responses:
+ '200':
+ description: DNS record created
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecordResponse'
+ /zones/{zone_id}/dns_records/{dns_record_id}:
+ get:
+ summary: Get DNS record
+ operationId: getDNSRecord
+ tags:
+ - DNS Records
+ parameters:
+ - name: zone_id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: dns_record_id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: DNS record
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecordResponse'
+ put:
+ summary: Update DNS record
+ operationId: updateDNSRecord
+ tags:
+ - DNS Records
+ parameters:
+ - name: zone_id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: dns_record_id
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecordRequest'
+ responses:
+ '200':
+ description: DNS record updated
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecordResponse'
+ delete:
+ summary: Delete DNS record
+ operationId: deleteDNSRecord
+ tags:
+ - DNS Records
+ parameters:
+ - name: zone_id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: dns_record_id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: DNS record deleted
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecordResponse'
+components:
+ securitySchemes:
+ ApiKeyAuth:
+ type: apiKey
+ in: header
+ name: X-Auth-Key
+ description: Cloudflare API key
+ EmailAuth:
+ type: apiKey
+ in: header
+ name: X-Auth-Email
+ description: Cloudflare account email
+ schemas:
+ DNSRecordRequest:
+ type: object
+ required:
+ - type
+ - content
+ properties:
+ type:
+ type: string
+ description: DNS record type (A, AAAA, CNAME, MX, TXT, etc.)
+ example: "A"
+ name:
+ type: string
+ description: DNS record name (hostname)
+ example: "@"
+ content:
+ type: string
+ description: DNS record content (address/value)
+ example: "192.0.2.1"
+ ttl:
+ type: integer
+ description: Time to live in seconds
+ example: 3600
+ priority:
+ type: integer
+ description: Priority for MX records
+ example: 10
+ proxied:
+ type: boolean
+ description: Whether the record is proxied through Cloudflare
+ example: false
+ DNSRecord:
+ type: object
+ properties:
+ id:
+ type: string
+ description: DNS record identifier
+ type:
+ type: string
+ description: DNS record type
+ name:
+ type: string
+ description: DNS record name (hostname)
+ content:
+ type: string
+ description: DNS record content (address/value)
+ ttl:
+ type: integer
+ description: Time to live in seconds
+ priority:
+ type: integer
+ description: Priority for MX records
+ proxied:
+ type: boolean
+ description: Whether the record is proxied
+ created_on:
+ type: string
+ format: date-time
+ modified_on:
+ type: string
+ format: date-time
+ DNSRecordsResponse:
+ type: object
+ properties:
+ result:
+ type: array
+ items:
+ $ref: '#/components/schemas/DNSRecord'
+ result_info:
+ type: object
+ properties:
+ page:
+ type: integer
+ per_page:
+ type: integer
+ count:
+ type: integer
+ total_count:
+ type: integer
+ DNSRecordResponse:
+ type: object
+ properties:
+ result:
+ $ref: '#/components/schemas/DNSRecord'
+security:
+ - ApiKeyAuth: []
+ - EmailAuth: []
diff --git a/pkg/dns/provider/config/config.go b/pkg/dns/provider/config/config.go
new file mode 100644
index 0000000..4b84b1e
--- /dev/null
+++ b/pkg/dns/provider/config/config.go
@@ -0,0 +1,8 @@
+package config
+
+// This package is kept for potential future use or for loading provider configs
+// from external sources. Currently, all providers use OpenAPI-only approach
+// and configs are generated automatically from OpenAPI specs.
+
+// Note: The OpenAPI-only approach means we no longer load manual config.yaml files.
+// All provider configurations are generated from openapi.yaml files automatically.
diff --git a/pkg/dns/provider/digitalocean/OPENAPI_SOURCE.md b/pkg/dns/provider/digitalocean/OPENAPI_SOURCE.md
new file mode 100644
index 0000000..c391a63
--- /dev/null
+++ b/pkg/dns/provider/digitalocean/OPENAPI_SOURCE.md
@@ -0,0 +1,61 @@
+# DigitalOcean OpenAPI Schema
+
+## Source Information
+
+- **Official Repository**: https://github.com/digitalocean/openapi
+- **Direct Download URL**: https://raw.githubusercontent.com/digitalocean/openapi/main/specification.yaml
+- **Documentation**: https://docs.digitalocean.com/reference/api/api-reference/#tag/Domains
+- **Last Updated**: 2024-11-22
+- **Status**: Custom spec created from official API documentation
+
+## Schema Details
+
+- **OpenAPI Version**: 3.0.0
+- **Base URL**: https://api.digitalocean.com/v2
+- **Authentication**: Bearer token (API token)
+
+## DNS Endpoints
+
+- `GET /domains/{domain_name}/records` - List all DNS records
+- `POST /domains/{domain_name}/records` - Create DNS record
+- `GET /domains/{domain_name}/records/{record_id}` - Get specific record
+- `PUT /domains/{domain_name}/records/{record_id}` - Update record
+- `DELETE /domains/{domain_name}/records/{record_id}` - Delete record
+
+## Field Mappings
+
+- `name` → hostname
+- `type` → record_type
+- `data` → address
+- `ttl` → ttl
+- `priority` → mx_pref
+
+## Response Structure
+
+DigitalOcean wraps records in a `domain_records` object:
+```json
+{
+ "domain_records": [
+ {
+ "id": 123,
+ "type": "A",
+ "name": "@",
+ "data": "192.0.2.1",
+ "ttl": 3600
+ }
+ ]
+}
+```
+
+## Notes
+
+This is a custom OpenAPI spec created from DigitalOcean's official API documentation. DigitalOcean has an official OpenAPI spec repository, but this minimal spec focuses on DNS operations only.
+
+## Updating
+
+To update this spec:
+1. Check DigitalOcean's official OpenAPI repository
+2. Extract DNS-related paths if needed
+3. Update the spec accordingly
+4. Test with the auto-discovery system
+
diff --git a/pkg/dns/provider/digitalocean/config.yaml.example b/pkg/dns/provider/digitalocean/config.yaml.example
new file mode 100644
index 0000000..87d5a75
--- /dev/null
+++ b/pkg/dns/provider/digitalocean/config.yaml.example
@@ -0,0 +1,42 @@
+# DigitalOcean DNS Provider Configuration
+
+name: digitalocean
+display_name: DigitalOcean
+type: rest
+
+auth:
+ method: bearer
+ credentials:
+ token: "${DIGITALOCEAN_API_TOKEN}"
+ # Get your API token from: https://cloud.digitalocean.com/account/api/tokens
+
+api:
+ base_url: "https://api.digitalocean.com/v2"
+ endpoints:
+ get_records: "/domains/{domain}/records"
+ create_record: "/domains/{domain}/records"
+ update_record: "/domains/{domain}/records/{record_id}"
+ delete_record: "/domains/{domain}/records/{record_id}"
+ headers:
+ Content-Type: "application/json"
+ timeout: 30
+ retries: 3
+
+mappings:
+ request:
+ hostname: "name"
+ record_type: "type"
+ address: "data" # DigitalOcean uses "data" instead of "address"
+ ttl: "ttl"
+ mx_pref: "priority"
+ response:
+ hostname: "name"
+ record_type: "type"
+ address: "data"
+ ttl: "ttl"
+ mx_pref: "priority"
+ list_path: "domain_records" # DigitalOcean wraps in "domain_records" object
+
+settings:
+ zone_id_required: false
+
diff --git a/pkg/dns/provider/digitalocean/openapi.yaml b/pkg/dns/provider/digitalocean/openapi.yaml
new file mode 100644
index 0000000..0277e36
--- /dev/null
+++ b/pkg/dns/provider/digitalocean/openapi.yaml
@@ -0,0 +1,207 @@
+openapi: 3.0.0
+info:
+ title: DigitalOcean API
+ version: 2.0.0
+ description: DigitalOcean DNS API - OpenAPI specification for DNS record management
+servers:
+ - url: https://api.digitalocean.com/v2
+ description: DigitalOcean API v2
+paths:
+ /domains/{domain_name}/records:
+ get:
+ summary: List all DNS records for a domain
+ operationId: listDNSRecords
+ tags:
+ - DNS Records
+ parameters:
+ - name: domain_name
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Domain name
+ responses:
+ '200':
+ description: List of DNS records
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecordsResponse'
+ post:
+ summary: Create a new DNS record
+ operationId: createDNSRecord
+ tags:
+ - DNS Records
+ parameters:
+ - name: domain_name
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecordRequest'
+ responses:
+ '201':
+ description: DNS record created
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecordResponse'
+ /domains/{domain_name}/records/{record_id}:
+ get:
+ summary: Get a specific DNS record
+ operationId: getDNSRecord
+ tags:
+ - DNS Records
+ parameters:
+ - name: domain_name
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: record_id
+ in: path
+ required: true
+ schema:
+ type: integer
+ responses:
+ '200':
+ description: DNS record
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecordResponse'
+ put:
+ summary: Update a DNS record
+ operationId: updateDNSRecord
+ tags:
+ - DNS Records
+ parameters:
+ - name: domain_name
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: record_id
+ in: path
+ required: true
+ schema:
+ type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecordRequest'
+ responses:
+ '200':
+ description: DNS record updated
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DNSRecordResponse'
+ delete:
+ summary: Delete a DNS record
+ operationId: deleteDNSRecord
+ tags:
+ - DNS Records
+ parameters:
+ - name: domain_name
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: record_id
+ in: path
+ required: true
+ schema:
+ type: integer
+ responses:
+ '204':
+ description: DNS record deleted
+components:
+ securitySchemes:
+ BearerAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: Token
+ description: DigitalOcean API token
+ schemas:
+ DNSRecordRequest:
+ type: object
+ required:
+ - type
+ - data
+ properties:
+ type:
+ type: string
+ description: DNS record type (A, AAAA, CNAME, MX, TXT, NS, SRV, etc.)
+ example: "A"
+ name:
+ type: string
+ description: DNS record name (hostname)
+ example: "@"
+ data:
+ type: string
+ description: DNS record data (address/value)
+ example: "192.0.2.1"
+ priority:
+ type: integer
+ description: Priority for MX and SRV records
+ example: 10
+ port:
+ type: integer
+ description: Port for SRV records
+ weight:
+ type: integer
+ description: Weight for SRV records
+ ttl:
+ type: integer
+ description: Time to live in seconds
+ example: 3600
+ DNSRecord:
+ type: object
+ properties:
+ id:
+ type: integer
+ description: Unique identifier for the DNS record
+ type:
+ type: string
+ description: DNS record type
+ name:
+ type: string
+ description: DNS record name (hostname)
+ data:
+ type: string
+ description: DNS record data (address/value)
+ priority:
+ type: integer
+ description: Priority for MX and SRV records
+ port:
+ type: integer
+ description: Port for SRV records
+ weight:
+ type: integer
+ description: Weight for SRV records
+ ttl:
+ type: integer
+ description: Time to live in seconds
+ DNSRecordsResponse:
+ type: object
+ properties:
+ domain_records:
+ type: array
+ items:
+ $ref: '#/components/schemas/DNSRecord'
+ DNSRecordResponse:
+ type: object
+ properties:
+ domain_record:
+ $ref: '#/components/schemas/DNSRecord'
+security:
+ - BearerAuth: []
+
diff --git a/pkg/dns/provider/godaddy/OPENAPI_SOURCE.md b/pkg/dns/provider/godaddy/OPENAPI_SOURCE.md
new file mode 100644
index 0000000..2656f38
--- /dev/null
+++ b/pkg/dns/provider/godaddy/OPENAPI_SOURCE.md
@@ -0,0 +1,44 @@
+# GoDaddy OpenAPI Schema
+
+## Source Information
+
+- **Official Documentation**: https://developer.godaddy.com/doc/endpoint/domains
+- **OpenAPI Spec URL**: https://developer.godaddy.com/doc/openapi.json
+- **Last Updated**: 2024-11-22
+- **Status**: Custom spec created from official API documentation
+
+## Schema Details
+
+- **OpenAPI Version**: 3.0.0
+- **Base URL**: https://api.godaddy.com/v1
+- **Authentication**: Bearer token (API key used as Bearer token)
+
+## DNS Endpoints
+
+- `GET /domains/{domain}/records` - List DNS records
+- `PATCH /domains/{domain}/records` - Replace all DNS records
+- `POST /domains/{domain}/records` - Add DNS records
+- `GET /domains/{domain}/records/{type}/{name}` - Get specific record
+- `PUT /domains/{domain}/records/{type}/{name}` - Update record
+- `DELETE /domains/{domain}/records/{type}/{name}` - Delete record
+
+## Field Mappings
+
+- `name` → hostname
+- `type` → record_type
+- `data` → address
+- `ttl` → ttl
+- `priority` → mx_pref
+
+## Notes
+
+This is a custom OpenAPI spec created from GoDaddy's official API documentation. GoDaddy provides API documentation but the official OpenAPI spec may not be publicly available or may be incomplete.
+
+## Updating
+
+To update this spec:
+1. Check GoDaddy's official API documentation
+2. Verify endpoint changes
+3. Update the OpenAPI spec accordingly
+4. Test with the auto-discovery system
+
diff --git a/pkg/dns/provider/godaddy/config.yaml.example b/pkg/dns/provider/godaddy/config.yaml.example
new file mode 100644
index 0000000..0b9f69e
--- /dev/null
+++ b/pkg/dns/provider/godaddy/config.yaml.example
@@ -0,0 +1,43 @@
+# GoDaddy DNS Provider Configuration
+
+name: godaddy
+display_name: GoDaddy
+type: rest
+
+auth:
+ method: bearer
+ credentials:
+ token: "${GODADDY_API_KEY}"
+ # GoDaddy uses API key + API secret, but API key is used as Bearer token
+ # Get your API key from: https://developer.godaddy.com/
+
+api:
+ base_url: "https://api.godaddy.com/v1"
+ endpoints:
+ get_records: "/domains/{domain}/records"
+ create_record: "/domains/{domain}/records"
+ update_record: "/domains/{domain}/records/{record_type}/{hostname}"
+ delete_record: "/domains/{domain}/records/{record_type}/{hostname}"
+ headers:
+ Content-Type: "application/json"
+ timeout: 30
+ retries: 3
+
+mappings:
+ request:
+ hostname: "name"
+ record_type: "type"
+ address: "data" # GoDaddy uses "data" instead of "address"
+ ttl: "ttl"
+ mx_pref: "priority"
+ response:
+ hostname: "name"
+ record_type: "type"
+ address: "data"
+ ttl: "ttl"
+ mx_pref: "priority"
+ list_path: "" # GoDaddy returns array directly
+
+settings:
+ zone_id_required: false
+
diff --git a/pkg/dns/provider/godaddy/openapi.yaml b/pkg/dns/provider/godaddy/openapi.yaml
new file mode 100644
index 0000000..c5ec224
--- /dev/null
+++ b/pkg/dns/provider/godaddy/openapi.yaml
@@ -0,0 +1,209 @@
+openapi: 3.0.0
+info:
+ title: GoDaddy API
+ version: 1.0.0
+ description: GoDaddy DNS API - OpenAPI specification for DNS record management
+servers:
+ - url: https://api.godaddy.com/v1
+ description: GoDaddy API v1
+paths:
+ /domains/{domain}/records:
+ get:
+ summary: List DNS records for a domain
+ operationId: listDNSRecords
+ tags:
+ - DNS Records
+ parameters:
+ - name: domain
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Domain name
+ - name: type
+ in: query
+ schema:
+ type: string
+ description: DNS record type filter
+ - name: name
+ in: query
+ schema:
+ type: string
+ description: DNS record name filter
+ responses:
+ '200':
+ description: List of DNS records
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/DNSRecord'
+ patch:
+ summary: Replace all DNS records for a domain
+ operationId: replaceDNSRecords
+ tags:
+ - DNS Records
+ parameters:
+ - name: domain
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/DNSRecord'
+ responses:
+ '200':
+ description: Records replaced successfully
+ post:
+ summary: Add DNS records to domain
+ operationId: addDNSRecords
+ tags:
+ - DNS Records
+ parameters:
+ - name: domain
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/DNSRecord'
+ responses:
+ '200':
+ description: Records added successfully
+ /domains/{domain}/records/{type}/{name}:
+ get:
+ summary: Get specific DNS record
+ operationId: getDNSRecord
+ tags:
+ - DNS Records
+ parameters:
+ - name: domain
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: type
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: DNS record
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/DNSRecord'
+ put:
+ summary: Update DNS record
+ operationId: updateDNSRecord
+ tags:
+ - DNS Records
+ parameters:
+ - name: domain
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: type
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/DNSRecord'
+ responses:
+ '200':
+ description: Record updated successfully
+ delete:
+ summary: Delete DNS record
+ operationId: deleteDNSRecord
+ tags:
+ - DNS Records
+ parameters:
+ - name: domain
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: type
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Record deleted successfully
+components:
+ securitySchemes:
+ ApiKeyAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: JWT
+ description: GoDaddy API key (used as Bearer token)
+ schemas:
+ DNSRecord:
+ type: object
+ required:
+ - type
+ - data
+ properties:
+ name:
+ type: string
+ description: DNS record name (hostname)
+ example: "@"
+ type:
+ type: string
+ description: DNS record type (A, AAAA, CNAME, MX, TXT, etc.)
+ example: "A"
+ data:
+ type: string
+ description: DNS record data (address/value)
+ example: "192.0.2.1"
+ ttl:
+ type: integer
+ description: Time to live in seconds
+ example: 3600
+ priority:
+ type: integer
+ description: Priority for MX records
+ example: 10
+security:
+ - ApiKeyAuth: []
+
diff --git a/pkg/dns/provider/http/client.go b/pkg/dns/provider/http/client.go
new file mode 100644
index 0000000..857dc78
--- /dev/null
+++ b/pkg/dns/provider/http/client.go
@@ -0,0 +1,222 @@
+package http
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "zonekit/pkg/errors"
+)
+
+// Client is a generic HTTP client for DNS provider APIs
+type Client struct {
+ httpClient *http.Client
+ baseURL string
+ headers map[string]string
+ timeout time.Duration
+ retries int
+}
+
+// ClientConfig configures the HTTP client
+type ClientConfig struct {
+ BaseURL string
+ Headers map[string]string
+ Timeout time.Duration // in seconds
+ Retries int
+}
+
+// NewClient creates a new HTTP client with the given configuration
+func NewClient(config ClientConfig) *Client {
+ timeout := config.Timeout
+ if timeout == 0 {
+ timeout = 30 * time.Second
+ }
+
+ retries := config.Retries
+ if retries == 0 {
+ retries = 3
+ }
+
+ return &Client{
+ httpClient: &http.Client{
+ Timeout: timeout,
+ },
+ baseURL: config.BaseURL,
+ headers: config.Headers,
+ timeout: timeout,
+ retries: retries,
+ }
+}
+
+// RequestOptions contains options for making HTTP requests
+type RequestOptions struct {
+ Method string
+ Path string
+ Headers map[string]string
+ Body interface{}
+ Query map[string]string
+}
+
+// Do performs an HTTP request with retry logic
+func (c *Client) Do(ctx context.Context, opts RequestOptions) (*http.Response, error) {
+ url := c.baseURL + opts.Path
+
+ // Build request body
+ var bodyReader io.Reader
+ if opts.Body != nil {
+ bodyBytes, err := json.Marshal(opts.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
+ }
+ bodyReader = bytes.NewReader(bodyBytes)
+ }
+
+ // Create request
+ req, err := http.NewRequestWithContext(ctx, opts.Method, url, bodyReader)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Set default headers
+ for key, value := range c.headers {
+ req.Header.Set(key, value)
+ }
+
+ // Set request-specific headers
+ for key, value := range opts.Headers {
+ req.Header.Set(key, value)
+ }
+
+ // Set content-type if body is present
+ if opts.Body != nil && req.Header.Get("Content-Type") == "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ // Add query parameters
+ if len(opts.Query) > 0 {
+ q := req.URL.Query()
+ for key, value := range opts.Query {
+ q.Add(key, value)
+ }
+ req.URL.RawQuery = q.Encode()
+ }
+
+ // Perform request with retry logic
+ var lastErr error
+ for attempt := 0; attempt <= c.retries; attempt++ {
+ if attempt > 0 {
+ // Exponential backoff: 1s, 2s, 4s
+ backoff := time.Duration(1<= 300 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, errors.NewAPI(
+ opts.Method,
+ fmt.Sprintf("request failed with status %d: %s", resp.StatusCode, string(body)),
+ fmt.Errorf("HTTP %d", resp.StatusCode),
+ )
+ }
+
+ return resp, nil
+ }
+
+ return nil, errors.NewAPI(
+ opts.Method,
+ fmt.Sprintf("request failed after %d retries", c.retries),
+ lastErr,
+ )
+}
+
+// Get performs a GET request
+func (c *Client) Get(ctx context.Context, path string, query map[string]string) (*http.Response, error) {
+ return c.Do(ctx, RequestOptions{
+ Method: http.MethodGet,
+ Path: path,
+ Query: query,
+ })
+}
+
+// Post performs a POST request
+func (c *Client) Post(ctx context.Context, path string, body interface{}) (*http.Response, error) {
+ return c.Do(ctx, RequestOptions{
+ Method: http.MethodPost,
+ Path: path,
+ Body: body,
+ })
+}
+
+// Put performs a PUT request
+func (c *Client) Put(ctx context.Context, path string, body interface{}) (*http.Response, error) {
+ return c.Do(ctx, RequestOptions{
+ Method: http.MethodPut,
+ Path: path,
+ Body: body,
+ })
+}
+
+// Patch performs a PATCH request
+func (c *Client) Patch(ctx context.Context, path string, body interface{}) (*http.Response, error) {
+ return c.Do(ctx, RequestOptions{
+ Method: http.MethodPatch,
+ Path: path,
+ Body: body,
+ })
+}
+
+// Delete performs a DELETE request
+func (c *Client) Delete(ctx context.Context, path string) (*http.Response, error) {
+ return c.Do(ctx, RequestOptions{
+ Method: http.MethodDelete,
+ Path: path,
+ })
+}
+
+// shouldRetry determines if a status code indicates a retryable error
+func shouldRetry(statusCode int) bool {
+ return statusCode == http.StatusTooManyRequests ||
+ statusCode == http.StatusInternalServerError ||
+ statusCode == http.StatusBadGateway ||
+ statusCode == http.StatusServiceUnavailable ||
+ statusCode == http.StatusGatewayTimeout
+}
+
+// ParseJSONResponse parses a JSON response into the given struct
+func ParseJSONResponse(resp *http.Response, target interface{}) error {
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ if err := json.Unmarshal(body, target); err != nil {
+ return fmt.Errorf("failed to unmarshal JSON response: %w", err)
+ }
+
+ return nil
+}
diff --git a/pkg/dns/provider/mapper/mapper.go b/pkg/dns/provider/mapper/mapper.go
new file mode 100644
index 0000000..639463e
--- /dev/null
+++ b/pkg/dns/provider/mapper/mapper.go
@@ -0,0 +1,207 @@
+package mapper
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+
+ "zonekit/pkg/dnsrecord"
+)
+
+// Mappings defines field mappings between our format and provider format
+type Mappings struct {
+ Request FieldMapping
+ Response FieldMapping
+ ListPath string // JSON path to records array (e.g., "result" or "data.records")
+}
+
+// FieldMapping defines how to map fields
+type FieldMapping struct {
+ HostName string
+ RecordType string
+ Address string
+ TTL string
+ MXPref string
+ ID string
+}
+
+// DefaultMappings returns default mappings (no transformation needed)
+func DefaultMappings() Mappings {
+ return Mappings{
+ Request: FieldMapping{
+ HostName: "hostname",
+ RecordType: "record_type",
+ Address: "address",
+ TTL: "ttl",
+ MXPref: "mx_pref",
+ ID: "",
+ },
+ Response: FieldMapping{
+ HostName: "hostname",
+ RecordType: "record_type",
+ Address: "address",
+ TTL: "ttl",
+ MXPref: "mx_pref",
+ ID: "",
+ },
+ ListPath: "records",
+ }
+}
+
+// ToProviderFormat converts a dnsrecord.Record to provider's format
+func ToProviderFormat(record dnsrecord.Record, mapping FieldMapping) map[string]interface{} {
+ result := make(map[string]interface{})
+
+ if mapping.HostName != "" {
+ result[mapping.HostName] = record.HostName
+ }
+ if mapping.RecordType != "" {
+ result[mapping.RecordType] = record.RecordType
+ }
+ if mapping.Address != "" {
+ result[mapping.Address] = record.Address
+ }
+ if mapping.TTL != "" && record.TTL > 0 {
+ result[mapping.TTL] = record.TTL
+ }
+ if mapping.MXPref != "" && record.MXPref > 0 {
+ result[mapping.MXPref] = record.MXPref
+ }
+ if mapping.ID != "" && record.ID != "" {
+ result[mapping.ID] = record.ID
+ }
+
+ return result
+}
+
+// FromProviderFormat converts provider's format to dnsrecord.Record
+func FromProviderFormat(data map[string]interface{}, mapping FieldMapping) (dnsrecord.Record, error) {
+ record := dnsrecord.Record{}
+
+ // Helper to get string value
+ getString := func(key string) string {
+ if val, ok := data[key]; ok {
+ if str, ok := val.(string); ok {
+ return str
+ }
+ return fmt.Sprintf("%v", val)
+ }
+ return ""
+ }
+
+ // Helper to get int value
+ getInt := func(key string) int {
+ if val, ok := data[key]; ok {
+ switch v := val.(type) {
+ case int:
+ return v
+ case int64:
+ return int(v)
+ case float64:
+ return int(v)
+ }
+ }
+ return 0
+ }
+
+ if mapping.HostName != "" {
+ record.HostName = getString(mapping.HostName)
+ }
+ if mapping.RecordType != "" {
+ record.RecordType = getString(mapping.RecordType)
+ }
+ if mapping.Address != "" {
+ record.Address = getString(mapping.Address)
+ }
+ if mapping.TTL != "" {
+ record.TTL = getInt(mapping.TTL)
+ }
+ if mapping.MXPref != "" {
+ record.MXPref = getInt(mapping.MXPref)
+ }
+ if mapping.ID != "" {
+ record.ID = getString(mapping.ID)
+ }
+
+ return record, nil
+}
+
+// ExtractRecords extracts records from a JSON response using the list path
+func ExtractRecords(data interface{}, listPath string) ([]map[string]interface{}, error) {
+ if listPath == "" {
+ // Default: assume data is an array
+ if arr, ok := data.([]interface{}); ok {
+ return convertArrayToMaps(arr)
+ }
+ return nil, fmt.Errorf("no list path specified and data is not an array")
+ }
+
+ // Navigate through the path (e.g., "result" or "data.records")
+ parts := strings.Split(listPath, ".")
+ current := reflect.ValueOf(data)
+
+ for _, part := range parts {
+ if current.Kind() == reflect.Interface {
+ current = current.Elem()
+ }
+
+ switch current.Kind() {
+ case reflect.Map:
+ key := reflect.ValueOf(part)
+ current = current.MapIndex(key)
+ if !current.IsValid() {
+ return nil, fmt.Errorf("path '%s' not found in response", listPath)
+ }
+ case reflect.Slice, reflect.Array:
+ // If we hit an array/slice, we're done navigating
+ break
+ default:
+ return nil, fmt.Errorf("invalid path '%s': cannot navigate through %v", listPath, current.Kind())
+ }
+ }
+
+ // Convert to array of maps
+ if current.Kind() == reflect.Interface {
+ current = current.Elem()
+ }
+
+ if current.Kind() != reflect.Slice && current.Kind() != reflect.Array {
+ return nil, fmt.Errorf("path '%s' does not point to an array", listPath)
+ }
+
+ arr := make([]interface{}, current.Len())
+ for i := 0; i < current.Len(); i++ {
+ arr[i] = current.Index(i).Interface()
+ }
+
+ return convertArrayToMaps(arr)
+}
+
+// convertArrayToMaps converts an array of interfaces to array of maps
+func convertArrayToMaps(arr []interface{}) ([]map[string]interface{}, error) {
+ result := make([]map[string]interface{}, 0, len(arr))
+
+ for _, item := range arr {
+ if m, ok := item.(map[string]interface{}); ok {
+ result = append(result, m)
+ } else {
+ // Try to convert using reflection
+ val := reflect.ValueOf(item)
+ if val.Kind() == reflect.Interface {
+ val = val.Elem()
+ }
+
+ if val.Kind() == reflect.Map {
+ m := make(map[string]interface{})
+ for _, key := range val.MapKeys() {
+ m[key.String()] = val.MapIndex(key).Interface()
+ }
+ result = append(result, m)
+ } else {
+ return nil, fmt.Errorf("cannot convert item to map: %v", item)
+ }
+ }
+ }
+
+ return result, nil
+}
diff --git a/pkg/dns/provider/mapper/mapper_test.go b/pkg/dns/provider/mapper/mapper_test.go
new file mode 100644
index 0000000..97c5550
--- /dev/null
+++ b/pkg/dns/provider/mapper/mapper_test.go
@@ -0,0 +1,50 @@
+package mapper
+
+import (
+ "testing"
+
+ "zonekit/pkg/dnsrecord"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestFromProviderFormat_IncludesID(t *testing.T) {
+ data := map[string]interface{}{
+ "hostname": "www",
+ "record_type": "A",
+ "address": "1.2.3.4",
+ "id": "abc123",
+ }
+
+ mapping := FieldMapping{
+ HostName: "hostname",
+ RecordType: "record_type",
+ Address: "address",
+ ID: "id",
+ }
+
+ rec, err := FromProviderFormat(data, mapping)
+ require.NoError(t, err)
+ require.Equal(t, "abc123", rec.ID)
+ require.Equal(t, "www", rec.HostName)
+}
+
+func TestToProviderFormat_IncludesID(t *testing.T) {
+ rec := dnsrecord.Record{
+ ID: "abc123",
+ HostName: "www",
+ RecordType: "A",
+ Address: "1.2.3.4",
+ }
+
+ mapping := FieldMapping{
+ HostName: "hostname",
+ RecordType: "record_type",
+ Address: "address",
+ ID: "id",
+ }
+
+ m := ToProviderFormat(rec, mapping)
+ require.Equal(t, "abc123", m["id"])
+ require.Equal(t, "www", m["hostname"])
+}
diff --git a/pkg/dns/provider/namecheap/adapter.go b/pkg/dns/provider/namecheap/adapter.go
new file mode 100644
index 0000000..46d7c99
--- /dev/null
+++ b/pkg/dns/provider/namecheap/adapter.go
@@ -0,0 +1,123 @@
+package namecheap
+
+import (
+ "fmt"
+
+ "github.com/namecheap/go-namecheap-sdk/v2/namecheap"
+ "zonekit/pkg/client"
+ dnsprovider "zonekit/pkg/dns/provider"
+ "zonekit/pkg/dnsrecord"
+ "zonekit/pkg/errors"
+ "zonekit/pkg/pointer"
+)
+
+// NamecheapProvider implements the DNS Provider interface for Namecheap
+type NamecheapProvider struct {
+ client *client.Client
+}
+
+// New creates a new Namecheap DNS provider
+func New(client *client.Client) *NamecheapProvider {
+ return &NamecheapProvider{
+ client: client,
+ }
+}
+
+// Name returns the provider name
+func (p *NamecheapProvider) Name() string {
+ return "namecheap"
+}
+
+// GetRecords retrieves all DNS records for a domain
+func (p *NamecheapProvider) GetRecords(domainName string) ([]dnsrecord.Record, error) {
+ nc := p.client.GetNamecheapClient()
+
+ resp, err := nc.DomainsDNS.GetHosts(domainName)
+ if err != nil {
+ return nil, errors.NewAPI("GetHosts", fmt.Sprintf("failed to get DNS records for %s", domainName), err)
+ }
+
+ // Safety check for nil response
+ if resp == nil || resp.DomainDNSGetHostsResult == nil || resp.DomainDNSGetHostsResult.Hosts == nil {
+ return []dnsrecord.Record{}, nil
+ }
+
+ records := make([]dnsrecord.Record, 0, len(*resp.DomainDNSGetHostsResult.Hosts))
+ for _, host := range *resp.DomainDNSGetHostsResult.Hosts {
+ record := dnsrecord.Record{
+ HostName: pointer.String(host.Name),
+ RecordType: pointer.String(host.Type),
+ Address: pointer.String(host.Address),
+ TTL: pointer.Int(host.TTL),
+ MXPref: pointer.Int(host.MXPref),
+ }
+ records = append(records, record)
+ }
+
+ return records, nil
+}
+
+// SetRecords sets DNS records for a domain (replaces all existing records)
+func (p *NamecheapProvider) SetRecords(domainName string, records []dnsrecord.Record) error {
+ nc := p.client.GetNamecheapClient()
+
+ // Convert records to Namecheap format
+ hostRecords := make([]namecheap.DomainsDNSHostRecord, len(records))
+ hasMXRecords := false
+ for i, record := range records {
+ hostRecord := namecheap.DomainsDNSHostRecord{
+ HostName: namecheap.String(record.HostName),
+ RecordType: namecheap.String(record.RecordType),
+ Address: namecheap.String(record.Address),
+ }
+
+ if record.TTL > 0 {
+ hostRecord.TTL = namecheap.Int(record.TTL)
+ }
+
+ if record.MXPref > 0 {
+ hostRecord.MXPref = namecheap.UInt8(uint8(record.MXPref))
+ }
+
+ // Check if this is an MX record
+ if record.RecordType == dnsrecord.RecordTypeMX {
+ hasMXRecords = true
+ }
+
+ hostRecords[i] = hostRecord
+ }
+
+ // Build SetHostsArgs
+ args := &namecheap.DomainsDNSSetHostsArgs{
+ Domain: namecheap.String(domainName),
+ Records: &hostRecords,
+ }
+
+ // Set EmailType to MX if there are any MX records
+ // This is required by the Namecheap API when MX records are present
+ if hasMXRecords {
+ args.EmailType = namecheap.String("MX")
+ }
+
+ _, err := nc.DomainsDNS.SetHosts(args)
+ if err != nil {
+ return errors.NewAPI("SetHosts", fmt.Sprintf("failed to set DNS records for %s", domainName), err)
+ }
+
+ return nil
+}
+
+// Validate checks if the provider is properly configured
+func (p *NamecheapProvider) Validate() error {
+ if p.client == nil {
+ return fmt.Errorf("namecheap client is not initialized")
+ }
+ // Additional validation can be added here
+ return nil
+}
+
+// Register registers the Namecheap provider
+func Register(client *client.Client) error {
+ provider := New(client)
+ return dnsprovider.Register(provider)
+}
diff --git a/pkg/dns/provider/namecheap/config.yaml.example b/pkg/dns/provider/namecheap/config.yaml.example
new file mode 100644
index 0000000..38c91ab
--- /dev/null
+++ b/pkg/dns/provider/namecheap/config.yaml.example
@@ -0,0 +1,17 @@
+# Example Namecheap DNS Provider Configuration
+# This is for reference - Namecheap provider uses Go adapter, not config-based
+# Note: This is provider-specific configuration for the Namecheap provider adapter
+
+name: namecheap
+display_name: Namecheap
+type: namecheap
+
+# Namecheap uses SOAP API, so it requires a Go adapter
+# Authentication is handled via account config (username, api_key, client_ip)
+# This config file is optional and mainly for documentation
+
+settings:
+ # Namecheap-specific settings
+ use_sandbox: false # Use sandbox environment
+ email_type_required: true # MX records require EmailType parameter
+
diff --git a/pkg/dns/provider/openapi/openapi.go b/pkg/dns/provider/openapi/openapi.go
new file mode 100644
index 0000000..3d9b7da
--- /dev/null
+++ b/pkg/dns/provider/openapi/openapi.go
@@ -0,0 +1,349 @@
+package openapi
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ dnsprovider "zonekit/pkg/dns/provider"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Spec represents a parsed OpenAPI specification
+type Spec struct {
+ OpenAPI string `yaml:"openapi" json:"openapi"`
+ Info Info `yaml:"info" json:"info"`
+ Servers []Server `yaml:"servers" json:"servers"`
+ Paths map[string]interface{} `yaml:"paths" json:"paths"`
+ Components *Components `yaml:"components" json:"components"`
+}
+
+// Info contains API metadata
+type Info struct {
+ Title string `yaml:"title" json:"title"`
+ Version string `yaml:"version" json:"version"`
+}
+
+// Server represents an API server
+type Server struct {
+ URL string `yaml:"url" json:"url"`
+}
+
+// Components contains reusable OpenAPI components
+type Components struct {
+ Schemas map[string]interface{} `yaml:"schemas" json:"schemas"`
+ SecuritySchemes map[string]interface{} `yaml:"securitySchemes" json:"securitySchemes"`
+}
+
+// LoadSpec loads an OpenAPI specification from a file
+func LoadSpec(filePath string) (*Spec, error) {
+ data, err := os.ReadFile(filePath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read OpenAPI spec: %w", err)
+ }
+
+ var spec Spec
+ if err := yaml.Unmarshal(data, &spec); err != nil {
+ // Try JSON if YAML fails
+ if err := json.Unmarshal(data, &spec); err != nil {
+ return nil, fmt.Errorf("failed to parse OpenAPI spec: %w", err)
+ }
+ }
+
+ return &spec, nil
+}
+
+// FindSpecFile looks for OpenAPI spec files in a directory
+func FindSpecFile(dirPath string) (string, error) {
+ possibleNames := []string{
+ "openapi.yaml",
+ "openapi.yml",
+ "openapi.json",
+ "swagger.yaml",
+ "swagger.yml",
+ "swagger.json",
+ }
+
+ for _, name := range possibleNames {
+ path := filepath.Join(dirPath, name)
+ if _, err := os.Stat(path); err == nil {
+ return path, nil
+ }
+ }
+
+ return "", fmt.Errorf("no OpenAPI spec file found in %s", dirPath)
+}
+
+// ToProviderConfig converts an OpenAPI spec to a provider config
+func (s *Spec) ToProviderConfig(providerName string) (*dnsprovider.Config, error) {
+ cfg := &dnsprovider.Config{
+ Name: providerName,
+ DisplayName: s.Info.Title,
+ Type: "rest",
+ }
+
+ // Extract base URL from servers
+ if len(s.Servers) > 0 {
+ cfg.API.BaseURL = s.Servers[0].URL
+ }
+
+ // Extract endpoints from paths
+ cfg.API.Endpoints = s.extractEndpoints()
+
+ // Extract authentication from security schemes
+ if s.Components != nil && s.Components.SecuritySchemes != nil {
+ authMethod, credentials := s.extractAuthentication()
+ cfg.Auth.Method = authMethod
+ cfg.Auth.Credentials = credentials
+ }
+
+ // Extract field mappings from schemas
+ if s.Components != nil && s.Components.Schemas != nil {
+ cfg.Mappings = s.extractMappings()
+ }
+
+ // Set defaults
+ if cfg.API.Timeout == 0 {
+ cfg.API.Timeout = 30
+ }
+ if cfg.API.Retries == 0 {
+ cfg.API.Retries = 3
+ }
+
+ return cfg, nil
+}
+
+// extractEndpoints extracts DNS operation endpoints from OpenAPI paths
+func (s *Spec) extractEndpoints() map[string]string {
+ endpoints := make(map[string]string)
+
+ for path, pathItem := range s.Paths {
+ pathMap, ok := pathItem.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ // Map HTTP methods to DNS operations
+ for method, operation := range pathMap {
+ opMap, ok := operation.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ operationID, _ := opMap["operationId"].(string)
+ endpointKey := s.mapOperationToEndpoint(method, operationID, path)
+ if endpointKey != "" {
+ // Avoid overwriting existing endpoints with single-item paths (prefer list endpoints)
+ if existing, ok := endpoints[endpointKey]; ok && existing != "" {
+ // Prefer the endpoint without path parameters
+ if strings.Contains(existing, "{") && !strings.Contains(path, "{") {
+ endpoints[endpointKey] = path
+ }
+ // otherwise keep existing
+ } else {
+ endpoints[endpointKey] = path
+ }
+ }
+ }
+ }
+
+ return endpoints
+}
+
+// mapOperationToEndpoint maps OpenAPI operations to our endpoint keys
+func (s *Spec) mapOperationToEndpoint(method, operationID, path string) string {
+ method = strings.ToLower(method)
+ operationID = strings.ToLower(operationID)
+ path = strings.ToLower(path)
+
+ // Try to infer from operation ID
+ if strings.Contains(operationID, "list") || strings.Contains(operationID, "get") {
+ if strings.Contains(path, "record") || strings.Contains(path, "dns") {
+ return "get_records"
+ }
+ }
+ if strings.Contains(operationID, "create") || strings.Contains(operationID, "add") {
+ if strings.Contains(path, "record") || strings.Contains(path, "dns") {
+ return "create_record"
+ }
+ }
+ if strings.Contains(operationID, "update") || strings.Contains(operationID, "modify") {
+ if strings.Contains(path, "record") || strings.Contains(path, "dns") {
+ return "update_record"
+ }
+ }
+ if strings.Contains(operationID, "delete") || strings.Contains(operationID, "remove") {
+ if strings.Contains(path, "record") || strings.Contains(path, "dns") {
+ return "delete_record"
+ }
+ }
+
+ // Fallback to HTTP method
+ switch method {
+ case "get":
+ if strings.Contains(path, "record") || strings.Contains(path, "dns") {
+ return "get_records"
+ }
+ case "post":
+ if strings.Contains(path, "record") || strings.Contains(path, "dns") {
+ return "create_record"
+ }
+ case "put", "patch":
+ if strings.Contains(path, "record") || strings.Contains(path, "dns") {
+ return "update_record"
+ }
+ case "delete":
+ if strings.Contains(path, "record") || strings.Contains(path, "dns") {
+ return "delete_record"
+ }
+ }
+
+ return ""
+}
+
+// extractAuthentication extracts authentication method from OpenAPI security schemes
+func (s *Spec) extractAuthentication() (string, map[string]interface{}) {
+ if s.Components == nil || s.Components.SecuritySchemes == nil {
+ return "", nil
+ }
+
+ credentials := make(map[string]interface{})
+
+ for name, scheme := range s.Components.SecuritySchemes {
+ schemeMap, ok := scheme.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ schemeType, _ := schemeMap["type"].(string)
+ schemeType = strings.ToLower(schemeType)
+
+ switch schemeType {
+ case "apikey":
+ // API Key authentication
+ in, _ := schemeMap["in"].(string)
+ keyName, _ := schemeMap["name"].(string)
+
+ if in == "header" {
+ credentials["api_key"] = fmt.Sprintf("${%s_API_KEY}", strings.ToUpper(name))
+ if keyName != "" {
+ credentials["header_name"] = keyName
+ }
+ return "api_key", credentials
+ }
+
+ case "http":
+ // HTTP authentication (Bearer, Basic)
+ scheme, _ := schemeMap["scheme"].(string)
+ scheme = strings.ToLower(scheme)
+
+ if scheme == "bearer" {
+ credentials["token"] = fmt.Sprintf("${%s_API_TOKEN}", strings.ToUpper(name))
+ return "bearer", credentials
+ }
+ if scheme == "basic" {
+ credentials["username"] = fmt.Sprintf("${%s_USERNAME}", strings.ToUpper(name))
+ credentials["password"] = fmt.Sprintf("${%s_PASSWORD}", strings.ToUpper(name))
+ return "basic", credentials
+ }
+
+ case "oauth2":
+ // OAuth2 authentication
+ credentials["token"] = fmt.Sprintf("${%s_OAUTH_TOKEN}", strings.ToUpper(name))
+ return "oauth", credentials
+ }
+ }
+
+ return "", nil
+}
+
+// extractMappings extracts field mappings from OpenAPI schemas
+func (s *Spec) extractMappings() *dnsprovider.FieldMappings {
+ if s.Components == nil || s.Components.Schemas == nil {
+ return nil
+ }
+
+ mappings := &dnsprovider.FieldMappings{}
+
+ // Look for DNS record schema
+ for schemaName, schema := range s.Components.Schemas {
+ origName := schemaName
+ schemaName = strings.ToLower(schemaName)
+ if !strings.Contains(schemaName, "record") && !strings.Contains(schemaName, "dns") {
+ continue
+ }
+
+ schemaMap, ok := schema.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ properties, ok := schemaMap["properties"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ // Map common DNS record fields
+ for propName := range properties {
+ propLower := strings.ToLower(propName)
+ switch propLower {
+ case "name", "hostname", "host":
+ mappings.Request.HostName = propName
+ mappings.Response.HostName = propName
+ case "type", "recordtype", "record_type":
+ mappings.Request.RecordType = propName
+ mappings.Response.RecordType = propName
+ case "content", "data", "value", "address":
+ mappings.Request.Address = propName
+ mappings.Response.Address = propName
+ case "ttl":
+ mappings.Request.TTL = propName
+ mappings.Response.TTL = propName
+ case "priority", "preference", "mxpref", "mx_pref":
+ mappings.Request.MXPref = propName
+ mappings.Response.MXPref = propName
+ case "id", "recordid", "record_id", "_id":
+ mappings.Request.ID = propName
+ mappings.Response.ID = propName
+ }
+ }
+
+ // Try to find list path by inspecting other schemas for arrays of this schema
+ for _, otherSchema := range s.Components.Schemas {
+ otherSchemaMap, ok := otherSchema.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ // Look for properties that are arrays with items referencing this schema
+ if props, ok := otherSchemaMap["properties"].(map[string]interface{}); ok {
+ for propName, prop := range props {
+ propMap, ok := prop.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ if propMap["type"] == "array" {
+ if items, ok := propMap["items"].(map[string]interface{}); ok {
+ if ref, ok := items["$ref"].(string); ok {
+ refLower := strings.ToLower(ref)
+ if strings.Contains(refLower, strings.ToLower(origName)) || strings.HasSuffix(refLower, "/"+strings.ToLower(origName)) {
+ mappings.ListPath = propName
+ break
+ }
+ }
+ }
+ }
+ }
+ }
+ if mappings.ListPath != "" {
+ break
+ }
+ }
+ }
+
+ return mappings
+}
diff --git a/pkg/dns/provider/openapi/openapi_test.go b/pkg/dns/provider/openapi/openapi_test.go
new file mode 100644
index 0000000..d9b9104
--- /dev/null
+++ b/pkg/dns/provider/openapi/openapi_test.go
@@ -0,0 +1,32 @@
+package openapi
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestCloudflareSpec_ToProviderConfig(t *testing.T) {
+ specPath := "../cloudflare/openapi.yaml"
+ spec, err := LoadSpec(specPath)
+ require.NoError(t, err)
+
+ cfg, err := spec.ToProviderConfig("cloudflare")
+ require.NoError(t, err)
+ require.Equal(t, "https://api.cloudflare.com/client/v4", cfg.API.BaseURL)
+
+ // Endpoints
+ require.Contains(t, cfg.API.Endpoints, "get_records")
+ t.Logf("endpoints: %+v", cfg.API.Endpoints)
+ require.Equal(t, "/zones/{zone_id}/dns_records", cfg.API.Endpoints["get_records"])
+
+ require.Contains(t, cfg.API.Endpoints, "delete_record")
+ require.Equal(t, "/zones/{zone_id}/dns_records/{dns_record_id}", cfg.API.Endpoints["delete_record"])
+
+ // Mappings
+ require.NotNil(t, cfg.Mappings)
+ require.Equal(t, "id", cfg.Mappings.Response.ID)
+
+ // List path should be detected
+ require.Equal(t, "result", cfg.Mappings.ListPath)
+}
diff --git a/pkg/dns/provider/provider.go b/pkg/dns/provider/provider.go
new file mode 100644
index 0000000..3fb1964
--- /dev/null
+++ b/pkg/dns/provider/provider.go
@@ -0,0 +1,80 @@
+package provider
+
+import (
+ "zonekit/pkg/dnsrecord"
+)
+
+// Provider defines the interface that all DNS providers must implement
+type Provider interface {
+ // Name returns the provider name (e.g., "namecheap", "cloudflare", "godaddy")
+ Name() string
+
+ // GetRecords retrieves all DNS records for a domain
+ GetRecords(domainName string) ([]dnsrecord.Record, error)
+
+ // SetRecords sets DNS records for a domain (replaces all existing records)
+ SetRecords(domainName string, records []dnsrecord.Record) error
+
+ // Validate checks if the provider is properly configured
+ Validate() error
+}
+
+// Config represents provider-specific configuration
+type Config struct {
+ // Provider name (e.g., "namecheap", "cloudflare")
+ Name string `yaml:"name"`
+
+ // Display name for UI
+ DisplayName string `yaml:"display_name"`
+
+ // Provider type determines which adapter to use
+ Type string `yaml:"type"` // "namecheap", "cloudflare", "godaddy", "rest", etc.
+
+ // Authentication configuration
+ Auth struct {
+ Method string `yaml:"method"` // "api_key", "oauth", "basic", etc.
+ // Fields vary by method - stored as map for flexibility
+ Credentials map[string]interface{} `yaml:"credentials"`
+ } `yaml:"auth"`
+
+ // API configuration
+ API struct {
+ BaseURL string `yaml:"base_url"`
+ Endpoints map[string]string `yaml:"endpoints"` // e.g., "get_records": "/api/v1/dns/records"
+ Headers map[string]string `yaml:"headers,omitempty"`
+ Timeout int `yaml:"timeout,omitempty"` // seconds
+ Retries int `yaml:"retries,omitempty"`
+ } `yaml:"api"`
+
+ // Provider-specific settings
+ Settings map[string]interface{} `yaml:"settings,omitempty"`
+
+ // Field mappings for REST providers (optional, for generic REST adapter)
+ Mappings *FieldMappings `yaml:"mappings,omitempty"`
+}
+
+// FieldMappings defines how to map between our Record structure and provider's API format
+type FieldMappings struct {
+ // Request mappings (our format -> provider format)
+ Request struct {
+ HostName string `yaml:"hostname,omitempty"` // e.g., "name" or "host"
+ RecordType string `yaml:"record_type,omitempty"` // e.g., "type" or "rtype"
+ Address string `yaml:"address,omitempty"` // e.g., "value" or "content"
+ TTL string `yaml:"ttl,omitempty"`
+ MXPref string `yaml:"mx_pref,omitempty"` // e.g., "priority" or "preference"
+ ID string `yaml:"id,omitempty"` // provider record ID field
+ } `yaml:"request,omitempty"`
+
+ // Response mappings (provider format -> our format)
+ Response struct {
+ HostName string `yaml:"hostname,omitempty"`
+ RecordType string `yaml:"record_type,omitempty"`
+ Address string `yaml:"address,omitempty"`
+ TTL string `yaml:"ttl,omitempty"`
+ MXPref string `yaml:"mx_pref,omitempty"`
+ ID string `yaml:"id,omitempty"` // provider record ID field
+ } `yaml:"response,omitempty"`
+
+ // List response structure (for REST providers)
+ ListPath string `yaml:"list_path,omitempty"` // JSON path to records array, e.g., "data.records"
+}
diff --git a/pkg/dns/provider/registry.go b/pkg/dns/provider/registry.go
new file mode 100644
index 0000000..691b691
--- /dev/null
+++ b/pkg/dns/provider/registry.go
@@ -0,0 +1,84 @@
+package provider
+
+import (
+ "fmt"
+ "sync"
+)
+
+var (
+ registry = make(map[string]Provider)
+ registryLock sync.RWMutex
+)
+
+// Register registers a DNS provider
+func Register(provider Provider) error {
+ registryLock.Lock()
+ defer registryLock.Unlock()
+
+ name := provider.Name()
+ if name == "" {
+ return fmt.Errorf("provider name cannot be empty")
+ }
+
+ if _, exists := registry[name]; exists {
+ return fmt.Errorf("provider %s is already registered", name)
+ }
+
+ registry[name] = provider
+ return nil
+}
+
+// Get retrieves a provider by name
+func Get(name string) (Provider, error) {
+ registryLock.RLock()
+ defer registryLock.RUnlock()
+
+ provider, exists := registry[name]
+ if !exists {
+ return nil, fmt.Errorf("DNS provider %s not found", name)
+ }
+
+ return provider, nil
+}
+
+// List returns all registered providers
+func List() []Provider {
+ registryLock.RLock()
+ defer registryLock.RUnlock()
+
+ providers := make([]Provider, 0, len(registry))
+ for _, provider := range registry {
+ providers = append(providers, provider)
+ }
+
+ return providers
+}
+
+// Names returns the names of all registered providers
+func Names() []string {
+ registryLock.RLock()
+ defer registryLock.RUnlock()
+
+ names := make([]string, 0, len(registry))
+ for name := range registry {
+ names = append(names, name)
+ }
+
+ return names
+}
+
+// Unregister removes a provider (mainly for testing)
+func Unregister(name string) {
+ registryLock.Lock()
+ defer registryLock.Unlock()
+
+ delete(registry, name)
+}
+
+// Clear removes all providers (mainly for testing)
+func Clear() {
+ registryLock.Lock()
+ defer registryLock.Unlock()
+
+ registry = make(map[string]Provider)
+}
diff --git a/pkg/dns/provider/registry_test.go b/pkg/dns/provider/registry_test.go
new file mode 100644
index 0000000..790b8d8
--- /dev/null
+++ b/pkg/dns/provider/registry_test.go
@@ -0,0 +1,229 @@
+package provider
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "zonekit/pkg/dnsrecord"
+)
+
+// mockProviderForRegistry is a mock implementation of the Provider interface for registry testing
+type mockProviderForRegistry struct {
+ name string
+ records map[string][]dnsrecord.Record
+ getRecordsError error
+ setRecordsError error
+ validateError error
+}
+
+func newMockProviderForRegistry(name string) *mockProviderForRegistry {
+ return &mockProviderForRegistry{
+ name: name,
+ records: make(map[string][]dnsrecord.Record),
+ }
+}
+
+func (m *mockProviderForRegistry) Name() string {
+ return m.name
+}
+
+func (m *mockProviderForRegistry) GetRecords(domainName string) ([]dnsrecord.Record, error) {
+ if m.getRecordsError != nil {
+ return nil, m.getRecordsError
+ }
+ return m.records[domainName], nil
+}
+
+func (m *mockProviderForRegistry) SetRecords(domainName string, records []dnsrecord.Record) error {
+ if m.setRecordsError != nil {
+ return m.setRecordsError
+ }
+ m.records[domainName] = records
+ return nil
+}
+
+func (m *mockProviderForRegistry) Validate() error {
+ return m.validateError
+}
+
+// RegistryTestSuite is a test suite for provider registry
+type RegistryTestSuite struct {
+ suite.Suite
+}
+
+// TestRegistrySuite runs the provider registry test suite
+func TestRegistrySuite(t *testing.T) {
+ suite.Run(t, new(RegistryTestSuite))
+}
+
+func (s *RegistryTestSuite) SetupTest() {
+ // Clear registry before each test
+ Clear()
+}
+
+func (s *RegistryTestSuite) TearDownTest() {
+ // Clear registry after each test
+ Clear()
+}
+
+func (s *RegistryTestSuite) TestRegister_Success() {
+ provider := newMockProviderForRegistry("test-provider")
+ err := Register(provider)
+ s.Require().NoError(err)
+
+ // Verify provider was registered
+ retrieved, err := Get("test-provider")
+ s.Require().NoError(err)
+ s.Require().Equal(provider, retrieved)
+}
+
+func (s *RegistryTestSuite) TestRegister_Duplicate() {
+ provider1 := newMockProviderForRegistry("test-provider")
+ provider2 := newMockProviderForRegistry("test-provider")
+
+ err := Register(provider1)
+ s.Require().NoError(err)
+
+ err = Register(provider2)
+ s.Require().Error(err)
+ s.Require().Contains(err.Error(), "already registered")
+}
+
+func (s *RegistryTestSuite) TestRegister_EmptyName() {
+ provider := newMockProviderForRegistry("")
+ err := Register(provider)
+ s.Require().Error(err)
+ s.Require().Contains(err.Error(), "name cannot be empty")
+}
+
+func (s *RegistryTestSuite) TestGet_Success() {
+ provider := newMockProviderForRegistry("test-provider")
+ err := Register(provider)
+ s.Require().NoError(err)
+
+ retrieved, err := Get("test-provider")
+ s.Require().NoError(err)
+ s.Require().Equal(provider, retrieved)
+}
+
+func (s *RegistryTestSuite) TestGet_NotFound() {
+ _, err := Get("non-existent-provider")
+ s.Require().Error(err)
+ s.Require().Contains(err.Error(), "not found")
+}
+
+func (s *RegistryTestSuite) TestList_Empty() {
+ providers := List()
+ s.Require().Empty(providers)
+}
+
+func (s *RegistryTestSuite) TestList_MultipleProviders() {
+ provider1 := newMockProviderForRegistry("provider1")
+ provider2 := newMockProviderForRegistry("provider2")
+ provider3 := newMockProviderForRegistry("provider3")
+
+ err := Register(provider1)
+ s.Require().NoError(err)
+ err = Register(provider2)
+ s.Require().NoError(err)
+ err = Register(provider3)
+ s.Require().NoError(err)
+
+ providers := List()
+ s.Require().Len(providers, 3)
+
+ // Verify all providers are in the list
+ providerMap := make(map[string]Provider)
+ for _, p := range providers {
+ providerMap[p.Name()] = p
+ }
+
+ s.Require().Contains(providerMap, "provider1")
+ s.Require().Contains(providerMap, "provider2")
+ s.Require().Contains(providerMap, "provider3")
+}
+
+func (s *RegistryTestSuite) TestNames_Empty() {
+ names := Names()
+ s.Require().Empty(names)
+}
+
+func (s *RegistryTestSuite) TestNames_MultipleProviders() {
+ provider1 := newMockProviderForRegistry("provider1")
+ provider2 := newMockProviderForRegistry("provider2")
+ provider3 := newMockProviderForRegistry("provider3")
+
+ err := Register(provider1)
+ s.Require().NoError(err)
+ err = Register(provider2)
+ s.Require().NoError(err)
+ err = Register(provider3)
+ s.Require().NoError(err)
+
+ names := Names()
+ s.Require().Len(names, 3)
+ s.Require().Contains(names, "provider1")
+ s.Require().Contains(names, "provider2")
+ s.Require().Contains(names, "provider3")
+}
+
+func (s *RegistryTestSuite) TestUnregister_Success() {
+ provider := newMockProviderForRegistry("test-provider")
+ err := Register(provider)
+ s.Require().NoError(err)
+
+ Unregister("test-provider")
+
+ // Verify provider was removed
+ _, err = Get("test-provider")
+ s.Require().Error(err)
+}
+
+func (s *RegistryTestSuite) TestUnregister_NonExistent() {
+ // Should not panic when unregistering non-existent provider
+ Unregister("non-existent-provider")
+}
+
+func (s *RegistryTestSuite) TestClear() {
+ provider1 := newMockProviderForRegistry("provider1")
+ provider2 := newMockProviderForRegistry("provider2")
+
+ err := Register(provider1)
+ s.Require().NoError(err)
+ err = Register(provider2)
+ s.Require().NoError(err)
+
+ Clear()
+
+ // Verify all providers were removed
+ providers := List()
+ s.Require().Empty(providers)
+
+ names := Names()
+ s.Require().Empty(names)
+}
+
+func (s *RegistryTestSuite) TestConcurrentAccess() {
+ // Test that registry is thread-safe
+ provider1 := newMockProviderForRegistry("provider1")
+ provider2 := newMockProviderForRegistry("provider2")
+
+ // Register providers concurrently
+ done := make(chan bool, 2)
+ go func() {
+ _ = Register(provider1)
+ done <- true
+ }()
+ go func() {
+ _ = Register(provider2)
+ done <- true
+ }()
+
+ // Wait for both to complete
+ <-done
+ <-done
+
+ // Verify both were registered
+ providers := List()
+ s.Require().Len(providers, 2)
+}
diff --git a/pkg/dns/provider/rest/rest.go b/pkg/dns/provider/rest/rest.go
new file mode 100644
index 0000000..25c6b04
--- /dev/null
+++ b/pkg/dns/provider/rest/rest.go
@@ -0,0 +1,301 @@
+package rest
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ dnsprovider "zonekit/pkg/dns/provider"
+ httpprovider "zonekit/pkg/dns/provider/http"
+ "zonekit/pkg/dns/provider/mapper"
+ "zonekit/pkg/dnsrecord"
+ "zonekit/pkg/errors"
+)
+
+// RESTProvider is a generic REST-based DNS provider
+type RESTProvider struct {
+ name string
+ client *httpprovider.Client
+ mappings mapper.Mappings
+ endpoints map[string]string
+ settings map[string]interface{}
+}
+
+// NewRESTProvider creates a new REST-based DNS provider
+func NewRESTProvider(
+ name string,
+ client *httpprovider.Client,
+ mappings mapper.Mappings,
+ endpoints map[string]string,
+ settings map[string]interface{},
+) *RESTProvider {
+ return &RESTProvider{
+ name: name,
+ client: client,
+ mappings: mappings,
+ endpoints: endpoints,
+ settings: settings,
+ }
+}
+
+// Name returns the provider name
+func (p *RESTProvider) Name() string {
+ return p.name
+}
+
+// GetRecords retrieves all DNS records for a domain
+func (p *RESTProvider) GetRecords(domainName string) ([]dnsrecord.Record, error) {
+ endpoint, ok := p.endpoints["get_records"]
+ if !ok {
+ return nil, fmt.Errorf("get_records endpoint not configured")
+ }
+
+ // Replace placeholders in endpoint (e.g., {zone_id}, {domain})
+ endpoint = p.replacePlaceholders(endpoint, domainName)
+
+ // Get zone ID if required
+ zoneID, err := p.getZoneID(domainName)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get zone ID: %w", err)
+ }
+ if zoneID != "" {
+ endpoint = strings.ReplaceAll(endpoint, "{zone_id}", zoneID)
+ }
+
+ ctx := context.Background()
+ resp, err := p.client.Get(ctx, endpoint, nil)
+ if err != nil {
+ return nil, errors.NewAPI("GetRecords", fmt.Sprintf("failed to get DNS records for %s", domainName), err)
+ }
+
+ // Parse response (this will close the body)
+ var responseData interface{}
+ if err := httpprovider.ParseJSONResponse(resp, &responseData); err != nil {
+ return nil, fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ // Extract records using list path
+ recordMaps, err := mapper.ExtractRecords(responseData, p.mappings.ListPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to extract records: %w", err)
+ }
+
+ // Convert to dnsrecord.Record
+ records := make([]dnsrecord.Record, 0, len(recordMaps))
+ for _, recordMap := range recordMaps {
+ record, err := mapper.FromProviderFormat(recordMap, p.mappings.Response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to convert record: %w", err)
+ }
+ records = append(records, record)
+ }
+
+ return records, nil
+}
+
+// SetRecords sets DNS records for a domain (replaces all existing records)
+func (p *RESTProvider) SetRecords(domainName string, records []dnsrecord.Record) error {
+ // Most REST APIs don't support bulk replace, so we need to:
+ // 1. Get existing records
+ // 2. Delete all existing records
+ // 3. Create new records
+
+ existingRecords, err := p.GetRecords(domainName)
+ if err != nil {
+ return fmt.Errorf("failed to get existing records: %w", err)
+ }
+
+ ctx := context.Background()
+
+ // Delete existing records
+ for _, record := range existingRecords {
+ if err := p.deleteRecord(ctx, domainName, record); err != nil {
+ // Log but continue - some records might not exist
+ continue
+ }
+ }
+
+ // Create new records
+ for _, record := range records {
+ if err := p.createRecord(ctx, domainName, record); err != nil {
+ return fmt.Errorf("failed to create record: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// createRecord creates a single DNS record
+func (p *RESTProvider) createRecord(ctx context.Context, domainName string, record dnsrecord.Record) error {
+ endpoint, ok := p.endpoints["create_record"]
+ if !ok {
+ return fmt.Errorf("create_record endpoint not configured")
+ }
+
+ endpoint = p.replacePlaceholders(endpoint, domainName)
+ zoneID, _ := p.getZoneID(domainName)
+ if zoneID != "" {
+ endpoint = strings.ReplaceAll(endpoint, "{zone_id}", zoneID)
+ }
+
+ // Convert record to provider format
+ body := mapper.ToProviderFormat(record, p.mappings.Request)
+
+ resp, err := p.client.Post(ctx, endpoint, body)
+ if err != nil {
+ return errors.NewAPI("CreateRecord", "failed to create DNS record", err)
+ }
+ defer resp.Body.Close()
+
+ return nil
+}
+
+// deleteRecord deletes a single DNS record
+func (p *RESTProvider) deleteRecord(ctx context.Context, domainName string, record dnsrecord.Record) error {
+ endpoint, ok := p.endpoints["delete_record"]
+ if !ok {
+ // If delete endpoint not configured, try to use record ID
+ // For now, skip if not configured
+ return nil
+ }
+
+ endpoint = p.replacePlaceholders(endpoint, domainName)
+ zoneID, _ := p.getZoneID(domainName)
+ if zoneID != "" {
+ endpoint = strings.ReplaceAll(endpoint, "{zone_id}", zoneID)
+ }
+
+ // Replace {record_id} or {id} placeholders with the record's ID if provided
+ if strings.Contains(endpoint, "{record_id}") || strings.Contains(endpoint, "{id}") || strings.Contains(endpoint, "{recordId}") {
+ // Prefer record.ID
+ if record.ID == "" {
+ return fmt.Errorf("delete_record requires record_id - record is missing ID")
+ }
+ endpoint = strings.ReplaceAll(endpoint, "{record_id}", record.ID)
+ endpoint = strings.ReplaceAll(endpoint, "{id}", record.ID)
+ endpoint = strings.ReplaceAll(endpoint, "{recordId}", record.ID)
+ }
+
+ resp, err := p.client.Delete(ctx, endpoint)
+ if err != nil {
+ return errors.NewAPI("DeleteRecord", "failed to delete DNS record", err)
+ }
+ defer resp.Body.Close()
+
+ return nil
+}
+
+// Validate checks if the provider is properly configured
+func (p *RESTProvider) Validate() error {
+ if p.client == nil {
+ return fmt.Errorf("HTTP client is not initialized")
+ }
+ if p.name == "" {
+ return fmt.Errorf("provider name is empty")
+ }
+ if len(p.endpoints) == 0 {
+ return fmt.Errorf("no endpoints configured")
+ }
+ return nil
+}
+
+// Helper methods
+
+func (p *RESTProvider) replacePlaceholders(endpoint, domainName string) string {
+ endpoint = strings.ReplaceAll(endpoint, "{domain}", domainName)
+ return endpoint
+}
+
+func (p *RESTProvider) getZoneID(domainName string) (string, error) {
+ // 1. Check if zone_id is in settings
+ if zoneID, ok := p.settings["zone_id"].(string); ok && zoneID != "" {
+ return zoneID, nil
+ }
+
+ // 2. Try configured endpoints that may list or get zones
+ candidates := []string{"get_zone", "get_zone_by_name", "list_zones", "zones", "search_zones"}
+ for _, key := range candidates {
+ if path, ok := p.endpoints[key]; ok && path != "" {
+ // Replace placeholders
+ endpoint := p.replacePlaceholders(path, domainName)
+
+ ctx := context.Background()
+ // If endpoint does not include domain placeholder, try passing domain as query param 'name'
+ query := map[string]string{}
+ if !strings.Contains(endpoint, "{domain}") {
+ query["name"] = domainName
+ }
+
+ resp, err := p.client.Get(ctx, endpoint, query)
+ if err != nil {
+ // Try next candidate
+ continue
+ }
+
+ var data interface{}
+ if err := httpprovider.ParseJSONResponse(resp, &data); err != nil {
+ continue
+ }
+
+ // Search for matching zone object
+ // Check for object with 'result' array (Cloudflare style)
+ if m, ok := data.(map[string]interface{}); ok {
+ // Search arrays at top level
+ for _, v := range m {
+ switch arr := v.(type) {
+ case []interface{}:
+ for _, item := range arr {
+ if id := extractIDForDomain(item, domainName); id != "" {
+ return id, nil
+ }
+ }
+ case map[string]interface{}:
+ if id := extractIDForDomain(arr, domainName); id != "" {
+ return id, nil
+ }
+ }
+ }
+ }
+ // As fallback, try top-level array
+ if arr, ok := data.([]interface{}); ok {
+ for _, item := range arr {
+ if id := extractIDForDomain(item, domainName); id != "" {
+ return id, nil
+ }
+ }
+ }
+ }
+ }
+
+ // 3. Not found
+ return "", nil
+}
+
+// extractIDForDomain tries to extract an 'id' field from an object if it matches the provided domain name
+func extractIDForDomain(item interface{}, domainName string) string {
+ obj, ok := item.(map[string]interface{})
+ if !ok {
+ return ""
+ }
+
+ // Check common name fields
+ nameCandidates := []string{"name", "zone", "domain", "zone_name"}
+ for _, nc := range nameCandidates {
+ if v, ok := obj[nc]; ok {
+ if vs, ok := v.(string); ok && strings.EqualFold(strings.TrimSuffix(vs, "."), domainName) {
+ // Found matching name; extract id
+ for _, idc := range []string{"id", "zone_id", "dns_record_id"} {
+ if idv, ok := obj[idc]; ok {
+ return fmt.Sprintf("%v", idv)
+ }
+ }
+ }
+ }
+ }
+
+ // Only return an ID if it was found alongside a matching name; otherwise, no match
+ return ""
+}
+
+// Ensure RESTProvider implements Provider interface
+var _ dnsprovider.Provider = (*RESTProvider)(nil)
diff --git a/pkg/dns/provider/rest/rest_test.go b/pkg/dns/provider/rest/rest_test.go
new file mode 100644
index 0000000..aa0e26f
--- /dev/null
+++ b/pkg/dns/provider/rest/rest_test.go
@@ -0,0 +1,43 @@
+package rest
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ httpclient "zonekit/pkg/dns/provider/http"
+ "zonekit/pkg/dns/provider/mapper"
+ "zonekit/pkg/dnsrecord"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDeleteRecord_ByID_Success(t *testing.T) {
+ // Start test server expecting DELETE /records/abc123
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodDelete && r.URL.Path == "/records/abc123" {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer ts.Close()
+
+ client := httpclient.NewClient(httpclient.ClientConfig{BaseURL: ts.URL})
+ mappings := mapper.DefaultMappings()
+ p := NewRESTProvider("test", client, mappings, map[string]string{"delete_record": "/records/{record_id}"}, nil)
+
+ err := p.deleteRecord(context.Background(), "example.com", dnsrecord.Record{ID: "abc123"})
+ require.NoError(t, err)
+}
+
+func TestDeleteRecord_MissingID_Error(t *testing.T) {
+ client := httpclient.NewClient(httpclient.ClientConfig{BaseURL: "http://example.invalid"})
+ mappings := mapper.DefaultMappings()
+ p := NewRESTProvider("test", client, mappings, map[string]string{"delete_record": "/records/{record_id}"}, nil)
+
+ err := p.deleteRecord(context.Background(), "example.com", dnsrecord.Record{})
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "requires record_id")
+}
diff --git a/pkg/dns/provider/rest/zoneid_test.go b/pkg/dns/provider/rest/zoneid_test.go
new file mode 100644
index 0000000..d764bfe
--- /dev/null
+++ b/pkg/dns/provider/rest/zoneid_test.go
@@ -0,0 +1,53 @@
+package rest
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ httpclient "zonekit/pkg/dns/provider/http"
+ "zonekit/pkg/dns/provider/mapper"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetZoneID_FromSettings(t *testing.T) {
+ client := httpclient.NewClient(httpclient.ClientConfig{BaseURL: "http://example.invalid"})
+ p := NewRESTProvider("test", client, mapper.DefaultMappings(), map[string]string{}, map[string]interface{}{"zone_id": "z-123"})
+
+ id, err := p.getZoneID("example.com")
+ require.NoError(t, err)
+ require.Equal(t, "z-123", id)
+}
+
+func TestGetZoneID_FromListEndpoint(t *testing.T) {
+ // Test server returns zone list
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"result":[{"id":"z-1","name":"example.com"}]}`))
+ }))
+ defer ts.Close()
+
+ client := httpclient.NewClient(httpclient.ClientConfig{BaseURL: ts.URL})
+ p := NewRESTProvider("test", client, mapper.DefaultMappings(), map[string]string{"list_zones": "/zones"}, nil)
+
+ id, err := p.getZoneID("example.com")
+ require.NoError(t, err)
+ require.Equal(t, "z-1", id)
+}
+
+func TestGetZoneID_NoMatch_ReturnsEmpty(t *testing.T) {
+ // Test server returns unrelated zone
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"result":[{"id":"z-1","name":"other.com"}]}`))
+ }))
+ defer ts.Close()
+
+ client := httpclient.NewClient(httpclient.ClientConfig{BaseURL: ts.URL})
+ p := NewRESTProvider("test", client, mapper.DefaultMappings(), map[string]string{"list_zones": "/zones"}, nil)
+
+ id, err := p.getZoneID("example.com")
+ require.NoError(t, err)
+ require.Equal(t, "", id)
+}
diff --git a/pkg/dns/service.go b/pkg/dns/service.go
index fce6b45..0fa9e4f 100644
--- a/pkg/dns/service.go
+++ b/pkg/dns/service.go
@@ -4,125 +4,67 @@ import (
"fmt"
"strings"
- "github.com/namecheap/go-namecheap-sdk/v2/namecheap"
- "namecheap-dns-manager/pkg/client"
- "namecheap-dns-manager/pkg/errors"
- "namecheap-dns-manager/pkg/pointer"
+ "zonekit/pkg/client"
+ "zonekit/pkg/dns/provider"
+ "zonekit/pkg/dns/provider/namecheap"
+ "zonekit/pkg/dnsrecord"
+ "zonekit/pkg/errors"
)
// Service provides DNS record management operations
type Service struct {
- client *client.Client
+ provider provider.Provider
}
-// NewService creates a new DNS service
+// NewService creates a new DNS service with Namecheap provider
func NewService(client *client.Client) *Service {
+ // Register Namecheap provider
+ _ = namecheap.Register(client)
+
+ // Get the provider from registry
+ dnsProvider, _ := provider.Get("namecheap")
+
return &Service{
- client: client,
+ provider: dnsProvider,
}
}
-// Record represents a DNS record
-type Record struct {
- HostName string
- RecordType string
- Address string
- TTL int
- MXPref int
+// NewServiceWithProvider creates a new DNS service with a specific provider
+func NewServiceWithProvider(dnsProvider provider.Provider) *Service {
+ return &Service{
+ provider: dnsProvider,
+ }
}
-// RecordType constants
-const (
- RecordTypeA = "A"
- RecordTypeAAAA = "AAAA"
- RecordTypeCNAME = "CNAME"
- RecordTypeMX = "MX"
- RecordTypeTXT = "TXT"
- RecordTypeNS = "NS"
- RecordTypeSRV = "SRV"
-)
-
-// GetRecords retrieves all DNS records for a domain
-func (s *Service) GetRecords(domainName string) ([]Record, error) {
- nc := s.client.GetNamecheapClient()
-
- resp, err := nc.DomainsDNS.GetHosts(domainName)
+// NewServiceWithProviderName creates a new DNS service using a provider by name
+func NewServiceWithProviderName(providerName string) (*Service, error) {
+ dnsProvider, err := provider.Get(providerName)
if err != nil {
- return nil, errors.NewAPI("GetHosts", fmt.Sprintf("failed to get DNS records for %s", domainName), err)
- }
-
- // Safety check for nil response
- if resp == nil || resp.DomainDNSGetHostsResult == nil || resp.DomainDNSGetHostsResult.Hosts == nil {
- return []Record{}, nil
+ return nil, fmt.Errorf("failed to get DNS provider %s: %w", providerName, err)
}
- records := make([]Record, 0, len(*resp.DomainDNSGetHostsResult.Hosts))
- for _, host := range *resp.DomainDNSGetHostsResult.Hosts {
- record := Record{
- HostName: pointer.String(host.Name),
- RecordType: pointer.String(host.Type),
- Address: pointer.String(host.Address),
- TTL: pointer.Int(host.TTL),
- MXPref: pointer.Int(host.MXPref),
- }
- records = append(records, record)
- }
+ return &Service{
+ provider: dnsProvider,
+ }, nil
+}
- return records, nil
+// GetRecords retrieves all DNS records for a domain
+func (s *Service) GetRecords(domainName string) ([]dnsrecord.Record, error) {
+ return s.provider.GetRecords(domainName)
}
// SetRecords sets DNS records for a domain (replaces all existing records)
-func (s *Service) SetRecords(domainName string, records []Record) error {
- nc := s.client.GetNamecheapClient()
-
- // Convert records to Namecheap format
- hostRecords := make([]namecheap.DomainsDNSHostRecord, len(records))
- hasMXRecords := false
- for i, record := range records {
- hostRecord := namecheap.DomainsDNSHostRecord{
- HostName: namecheap.String(record.HostName),
- RecordType: namecheap.String(record.RecordType),
- Address: namecheap.String(record.Address),
- }
-
- if record.TTL > 0 {
- hostRecord.TTL = namecheap.Int(record.TTL)
- }
-
- if record.MXPref > 0 {
- hostRecord.MXPref = namecheap.UInt8(uint8(record.MXPref))
- }
-
- // Check if this is an MX record
- if record.RecordType == RecordTypeMX {
- hasMXRecords = true
- }
-
- hostRecords[i] = hostRecord
- }
-
- // Build SetHostsArgs
- args := &namecheap.DomainsDNSSetHostsArgs{
- Domain: namecheap.String(domainName),
- Records: &hostRecords,
- }
-
- // Set EmailType to MX if there are any MX records
- // This is required by the Namecheap API when MX records are present
- if hasMXRecords {
- args.EmailType = namecheap.String(EmailTypeMX)
- }
-
- _, err := nc.DomainsDNS.SetHosts(args)
- if err != nil {
- return errors.NewAPI("SetHosts", fmt.Sprintf("failed to set DNS records for %s", domainName), err)
- }
-
- return nil
+func (s *Service) SetRecords(domainName string, records []dnsrecord.Record) error {
+ return s.provider.SetRecords(domainName, records)
}
// AddRecord adds a single DNS record to a domain
-func (s *Service) AddRecord(domainName string, record Record) error {
+func (s *Service) AddRecord(domainName string, record dnsrecord.Record) error {
+ // Validate record before adding
+ if err := s.ValidateRecord(record); err != nil {
+ return fmt.Errorf("invalid record: %w", err)
+ }
+
// Get existing records
existingRecords, err := s.GetRecords(domainName)
if err != nil {
@@ -137,7 +79,7 @@ func (s *Service) AddRecord(domainName string, record Record) error {
}
// UpdateRecord updates a DNS record by hostname and type
-func (s *Service) UpdateRecord(domainName string, hostname, recordType string, newRecord Record) error {
+func (s *Service) UpdateRecord(domainName string, hostname, recordType string, newRecord dnsrecord.Record) error {
// Get existing records
existingRecords, err := s.GetRecords(domainName)
if err != nil {
@@ -171,7 +113,7 @@ func (s *Service) DeleteRecord(domainName string, hostname, recordType string) e
}
// Filter out the record to delete
- var filteredRecords []Record
+ var filteredRecords []dnsrecord.Record
found := false
for _, record := range existingRecords {
if record.HostName == hostname && record.RecordType == recordType {
@@ -191,17 +133,17 @@ func (s *Service) DeleteRecord(domainName string, hostname, recordType string) e
// DeleteAllRecords removes all DNS records for a domain
func (s *Service) DeleteAllRecords(domainName string) error {
- return s.SetRecords(domainName, []Record{})
+ return s.SetRecords(domainName, []dnsrecord.Record{})
}
// GetRecordsByType filters records by type
-func (s *Service) GetRecordsByType(domainName string, recordType string) ([]Record, error) {
+func (s *Service) GetRecordsByType(domainName string, recordType string) ([]dnsrecord.Record, error) {
allRecords, err := s.GetRecords(domainName)
if err != nil {
return nil, err
}
- var filteredRecords []Record
+ var filteredRecords []dnsrecord.Record
for _, record := range allRecords {
if record.RecordType == recordType {
filteredRecords = append(filteredRecords, record)
@@ -212,7 +154,7 @@ func (s *Service) GetRecordsByType(domainName string, recordType string) ([]Reco
}
// ValidateRecord validates a DNS record before adding/updating
-func (s *Service) ValidateRecord(record Record) error {
+func (s *Service) ValidateRecord(record dnsrecord.Record) error {
if record.HostName == "" {
return errors.NewInvalidInput("hostname", "cannot be empty")
}
@@ -226,7 +168,7 @@ func (s *Service) ValidateRecord(record Record) error {
}
// Validate record type
- validTypes := []string{RecordTypeA, RecordTypeAAAA, RecordTypeCNAME, RecordTypeMX, RecordTypeTXT, RecordTypeNS, RecordTypeSRV}
+ validTypes := []string{dnsrecord.RecordTypeA, dnsrecord.RecordTypeAAAA, dnsrecord.RecordTypeCNAME, dnsrecord.RecordTypeMX, dnsrecord.RecordTypeTXT, dnsrecord.RecordTypeNS, dnsrecord.RecordTypeSRV}
isValid := false
for _, validType := range validTypes {
if record.RecordType == validType {
@@ -261,15 +203,15 @@ func (s *Service) ValidateRecord(record Record) error {
// Type-specific validation
switch record.RecordType {
- case RecordTypeA:
+ case dnsrecord.RecordTypeA:
if err := ValidateIPv4(record.Address); err != nil {
return errors.NewInvalidInput("address", fmt.Sprintf("A record must have valid IPv4 address: %v", err))
}
- case RecordTypeAAAA:
+ case dnsrecord.RecordTypeAAAA:
if err := ValidateIPv6(record.Address); err != nil {
return errors.NewInvalidInput("address", fmt.Sprintf("AAAA record must have valid IPv6 address: %v", err))
}
- case RecordTypeMX:
+ case dnsrecord.RecordTypeMX:
if record.MXPref <= 0 {
return errors.NewInvalidInput("mx_pref", "MX records must have a priority value")
}
@@ -277,12 +219,12 @@ func (s *Service) ValidateRecord(record Record) error {
if err := ValidateHostname(record.Address); err != nil {
return errors.NewInvalidInput("address", fmt.Sprintf("MX record must have valid hostname: %v", err))
}
- case RecordTypeCNAME:
+ case dnsrecord.RecordTypeCNAME:
// CNAME address should be a valid hostname
if err := ValidateHostname(record.Address); err != nil {
return errors.NewInvalidInput("address", fmt.Sprintf("CNAME record must have valid hostname: %v", err))
}
- case RecordTypeNS:
+ case dnsrecord.RecordTypeNS:
// NS address should be a valid hostname
if err := ValidateHostname(record.Address); err != nil {
return errors.NewInvalidInput("address", fmt.Sprintf("NS record must have valid hostname: %v", err))
@@ -295,7 +237,7 @@ func (s *Service) ValidateRecord(record Record) error {
// BulkOperation represents a bulk DNS operation
type BulkOperation struct {
Action string // Use BulkActionAdd, BulkActionUpdate, or BulkActionDelete constants
- Record Record
+ Record dnsrecord.Record
}
// BulkUpdate performs multiple DNS operations in a single API call
@@ -306,7 +248,7 @@ func (s *Service) BulkUpdate(domainName string, operations []BulkOperation) erro
return fmt.Errorf("failed to get existing records: %w", err)
}
- records := make([]Record, len(existingRecords))
+ records := make([]dnsrecord.Record, len(existingRecords))
copy(records, existingRecords)
// Apply operations
@@ -335,7 +277,7 @@ func (s *Service) BulkUpdate(domainName string, operations []BulkOperation) erro
}
case BulkActionDelete:
- var filteredRecords []Record
+ var filteredRecords []dnsrecord.Record
found := false
for _, record := range records {
if record.HostName == op.Record.HostName && record.RecordType == op.Record.RecordType {
diff --git a/pkg/dns/service_test.go b/pkg/dns/service_test.go
index dcd7923..3e5956f 100644
--- a/pkg/dns/service_test.go
+++ b/pkg/dns/service_test.go
@@ -1,18 +1,59 @@
package dns
import (
+ "errors"
"testing"
"github.com/stretchr/testify/suite"
- "namecheap-dns-manager/internal/testutil"
- "namecheap-dns-manager/pkg/client"
- "namecheap-dns-manager/pkg/config"
+ "zonekit/internal/testutil"
+ "zonekit/pkg/dns/provider"
+ "zonekit/pkg/dnsrecord"
)
+// mockProvider is a mock implementation of the Provider interface for testing
+type mockProvider struct {
+ name string
+ records map[string][]dnsrecord.Record
+ getRecordsError error
+ setRecordsError error
+ validateError error
+}
+
+func newMockProvider(name string) *mockProvider {
+ return &mockProvider{
+ name: name,
+ records: make(map[string][]dnsrecord.Record),
+ }
+}
+
+func (m *mockProvider) Name() string {
+ return m.name
+}
+
+func (m *mockProvider) GetRecords(domainName string) ([]dnsrecord.Record, error) {
+ if m.getRecordsError != nil {
+ return nil, m.getRecordsError
+ }
+ return m.records[domainName], nil
+}
+
+func (m *mockProvider) SetRecords(domainName string, records []dnsrecord.Record) error {
+ if m.setRecordsError != nil {
+ return m.setRecordsError
+ }
+ m.records[domainName] = records
+ return nil
+}
+
+func (m *mockProvider) Validate() error {
+ return m.validateError
+}
+
// ServiceTestSuite is a test suite for DNS service
type ServiceTestSuite struct {
suite.Suite
service *Service
+ mock *mockProvider
}
// TestServiceSuite runs the DNS service test suite
@@ -21,44 +62,34 @@ func TestServiceSuite(t *testing.T) {
}
func (s *ServiceTestSuite) SetupTest() {
- fixture := testutil.AccountConfigFixture()
- accountConfig := &config.AccountConfig{
- Username: fixture.Username,
- APIUser: fixture.APIUser,
- APIKey: fixture.APIKey,
- ClientIP: fixture.ClientIP,
- UseSandbox: fixture.UseSandbox,
- Description: fixture.Description,
- }
- c, err := client.NewClient(accountConfig)
- s.Require().NoError(err)
- s.service = NewService(c)
+ s.mock = newMockProvider("mock")
+ s.service = NewServiceWithProvider(s.mock)
}
func (s *ServiceTestSuite) TestService_ValidateRecord_ValidRecords() {
tests := []struct {
name string
- record Record
+ record dnsrecord.Record
}{
{
- name: "valid A record",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", RecordTypeA, "192.168.1.1", 1800, 0)),
+ name: "valid A record",
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
},
{
- name: "valid AAAA record",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", RecordTypeAAAA, "2001:0db8:85a3:0000:0000:8a2e:0370:7334", 1800, 0)),
+ name: "valid AAAA record",
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeAAAA, "2001:0db8:85a3:0000:0000:8a2e:0370:7334", 1800, 0)),
},
{
- name: "valid MX record",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", RecordTypeMX, "mail.example.com", 1800, 10)),
+ name: "valid MX record",
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeMX, "mail.example.com", 1800, 10)),
},
{
- name: "valid CNAME record",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", RecordTypeCNAME, "example.com", 1800, 0)),
+ name: "valid CNAME record",
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeCNAME, "example.com", 1800, 0)),
},
{
- name: "valid TXT record",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", RecordTypeTXT, "v=spf1 include:_spf.example.com ~all", 1800, 0)),
+ name: "valid TXT record",
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeTXT, "v=spf1 include:_spf.example.com ~all", 1800, 0)),
},
}
@@ -73,11 +104,11 @@ func (s *ServiceTestSuite) TestService_ValidateRecord_ValidRecords() {
func (s *ServiceTestSuite) TestService_ValidateRecord_InvalidRecords() {
tests := []struct {
name string
- record Record
+ record dnsrecord.Record
}{
{
name: "empty hostname",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("", RecordTypeA, "192.168.1.1", 0, 0)),
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("", dnsrecord.RecordTypeA, "192.168.1.1", 0, 0)),
},
{
name: "empty record type",
@@ -85,7 +116,7 @@ func (s *ServiceTestSuite) TestService_ValidateRecord_InvalidRecords() {
},
{
name: "empty address",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", RecordTypeA, "", 0, 0)),
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "", 0, 0)),
},
{
name: "invalid record type",
@@ -93,43 +124,43 @@ func (s *ServiceTestSuite) TestService_ValidateRecord_InvalidRecords() {
},
{
name: "TTL too low",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", RecordTypeA, "192.168.1.1", 30, 0)),
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 30, 0)),
},
{
name: "TTL too high",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", RecordTypeA, "192.168.1.1", 100000, 0)),
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 100000, 0)),
},
{
name: "A record with invalid IPv4",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", RecordTypeA, "invalid.ip", 1800, 0)),
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "invalid.ip", 1800, 0)),
},
{
name: "AAAA record with invalid IPv6",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", RecordTypeAAAA, "192.168.1.1", 1800, 0)),
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeAAAA, "192.168.1.1", 1800, 0)),
},
{
name: "MX record without preference",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", RecordTypeMX, "mail.example.com", 1800, 0)),
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeMX, "mail.example.com", 1800, 0)),
},
{
name: "MX record with invalid hostname",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", RecordTypeMX, "invalid..hostname", 1800, 10)),
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeMX, "invalid..hostname", 1800, 10)),
},
{
name: "CNAME record with invalid hostname",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", RecordTypeCNAME, "invalid..hostname", 1800, 0)),
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeCNAME, "invalid..hostname", 1800, 0)),
},
{
name: "NS record with invalid hostname",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", RecordTypeNS, "invalid..hostname", 1800, 0)),
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeNS, "invalid..hostname", 1800, 0)),
},
{
name: "MX preference too low",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", RecordTypeMX, "mail.example.com", 1800, -1)),
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeMX, "mail.example.com", 1800, -1)),
},
{
name: "MX preference too high",
- record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", RecordTypeMX, "mail.example.com", 1800, 70000)),
+ record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeMX, "mail.example.com", 1800, 70000)),
},
}
@@ -141,15 +172,494 @@ func (s *ServiceTestSuite) TestService_ValidateRecord_InvalidRecords() {
}
}
+func (s *ServiceTestSuite) TestService_GetRecords() {
+ domain := testutil.ValidDomainFixture()
+
+ // Setup mock records
+ mockRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0)),
+ }
+ s.mock.records[domain] = mockRecords
+
+ // Test GetRecords
+ records, err := s.service.GetRecords(domain)
+ s.Require().NoError(err)
+ s.Require().Len(records, 2)
+ s.Require().Equal(mockRecords, records)
+}
+
+func (s *ServiceTestSuite) TestService_GetRecords_Error() {
+ domain := testutil.ValidDomainFixture()
+ expectedError := errors.New("provider error")
+ s.mock.getRecordsError = expectedError
+
+ records, err := s.service.GetRecords(domain)
+ s.Require().Error(err)
+ s.Require().Equal(expectedError, err)
+ s.Require().Nil(records)
+}
+
+func (s *ServiceTestSuite) TestService_SetRecords() {
+ domain := testutil.ValidDomainFixture()
+ records := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ }
+
+ err := s.service.SetRecords(domain, records)
+ s.Require().NoError(err)
+ s.Require().Equal(records, s.mock.records[domain])
+}
+
+func (s *ServiceTestSuite) TestService_SetRecords_Error() {
+ domain := testutil.ValidDomainFixture()
+ expectedError := errors.New("provider error")
+ s.mock.setRecordsError = expectedError
+
+ records := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ }
+
+ err := s.service.SetRecords(domain, records)
+ s.Require().Error(err)
+ s.Require().Equal(expectedError, err)
+}
+
+func (s *ServiceTestSuite) TestService_AddRecord() {
+ domain := testutil.ValidDomainFixture()
+
+ // Setup existing records
+ existingRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ }
+ s.mock.records[domain] = existingRecords
+
+ // Add new record
+ newRecord := convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0))
+ err := s.service.AddRecord(domain, newRecord)
+ s.Require().NoError(err)
+
+ // Verify record was added
+ records, err := s.service.GetRecords(domain)
+ s.Require().NoError(err)
+ s.Require().Len(records, 2)
+ s.Require().Contains(records, newRecord)
+}
+
+func (s *ServiceTestSuite) TestService_UpdateRecord() {
+ domain := testutil.ValidDomainFixture()
+
+ // Setup existing records
+ existingRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ }
+ s.mock.records[domain] = existingRecords
+
+ // Update record
+ updatedRecord := convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.100", 3600, 0))
+ err := s.service.UpdateRecord(domain, "@", dnsrecord.RecordTypeA, updatedRecord)
+ s.Require().NoError(err)
+
+ // Verify record was updated
+ records, err := s.service.GetRecords(domain)
+ s.Require().NoError(err)
+ s.Require().Len(records, 1)
+ s.Require().Equal(updatedRecord.Address, records[0].Address)
+}
+
+func (s *ServiceTestSuite) TestService_UpdateRecord_NotFound() {
+ domain := testutil.ValidDomainFixture()
+
+ // Setup existing records
+ existingRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ }
+ s.mock.records[domain] = existingRecords
+
+ // Try to update non-existent record
+ updatedRecord := convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0))
+ err := s.service.UpdateRecord(domain, "www", dnsrecord.RecordTypeA, updatedRecord)
+ s.Require().Error(err)
+}
+
+func (s *ServiceTestSuite) TestService_DeleteRecord() {
+ domain := testutil.ValidDomainFixture()
+
+ // Setup existing records
+ existingRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0)),
+ }
+ s.mock.records[domain] = existingRecords
+
+ // Delete record
+ err := s.service.DeleteRecord(domain, "@", dnsrecord.RecordTypeA)
+ s.Require().NoError(err)
+
+ // Verify record was deleted
+ records, err := s.service.GetRecords(domain)
+ s.Require().NoError(err)
+ s.Require().Len(records, 1)
+ s.Require().Equal("www", records[0].HostName)
+}
+
+func (s *ServiceTestSuite) TestService_DeleteRecord_NotFound() {
+ domain := testutil.ValidDomainFixture()
+
+ // Setup existing records
+ existingRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ }
+ s.mock.records[domain] = existingRecords
+
+ // Try to delete non-existent record
+ err := s.service.DeleteRecord(domain, "www", dnsrecord.RecordTypeA)
+ s.Require().Error(err)
+}
+
+func (s *ServiceTestSuite) TestService_DeleteAllRecords() {
+ domain := testutil.ValidDomainFixture()
+
+ // Setup existing records
+ existingRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0)),
+ }
+ s.mock.records[domain] = existingRecords
+
+ // Delete all records
+ err := s.service.DeleteAllRecords(domain)
+ s.Require().NoError(err)
+
+ // Verify all records were deleted
+ records, err := s.service.GetRecords(domain)
+ s.Require().NoError(err)
+ s.Require().Empty(records)
+}
+
func (s *ServiceTestSuite) TestService_GetRecordsByType() {
- // Test that the service is created correctly
- s.Require().NotNil(s.service)
- s.Require().NotNil(s.service.client)
+ domain := testutil.ValidDomainFixture()
+
+ // Setup mock records
+ mockRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0)),
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeMX, "mail.example.com", 1800, 10)),
+ }
+ s.mock.records[domain] = mockRecords
+
+ // Test GetRecordsByType
+ aRecords, err := s.service.GetRecordsByType(domain, dnsrecord.RecordTypeA)
+ s.Require().NoError(err)
+ s.Require().Len(aRecords, 2)
+ s.Require().Equal(dnsrecord.RecordTypeA, aRecords[0].RecordType)
+ s.Require().Equal(dnsrecord.RecordTypeA, aRecords[1].RecordType)
+
+ mxRecords, err := s.service.GetRecordsByType(domain, dnsrecord.RecordTypeMX)
+ s.Require().NoError(err)
+ s.Require().Len(mxRecords, 1)
+ s.Require().Equal(dnsrecord.RecordTypeMX, mxRecords[0].RecordType)
+}
+
+func (s *ServiceTestSuite) TestService_BulkUpdate_Add() {
+ domain := testutil.ValidDomainFixture()
+
+ // Setup existing records
+ existingRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ }
+ s.mock.records[domain] = existingRecords
+
+ // Bulk add records
+ operations := []BulkOperation{
+ {
+ Action: BulkActionAdd,
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0)),
+ },
+ {
+ Action: BulkActionAdd,
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeMX, "mail.example.com", 1800, 10)),
+ },
+ }
+
+ err := s.service.BulkUpdate(domain, operations)
+ s.Require().NoError(err)
+
+ // Verify records were added
+ records, err := s.service.GetRecords(domain)
+ s.Require().NoError(err)
+ s.Require().Len(records, 3)
+}
+
+func (s *ServiceTestSuite) TestService_BulkUpdate_Update() {
+ domain := testutil.ValidDomainFixture()
+
+ // Setup existing records
+ existingRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0)),
+ }
+ s.mock.records[domain] = existingRecords
+
+ // Bulk update records
+ operations := []BulkOperation{
+ {
+ Action: BulkActionUpdate,
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.100", 3600, 0)),
+ },
+ {
+ Action: BulkActionUpdate,
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.200", 3600, 0)),
+ },
+ }
+
+ err := s.service.BulkUpdate(domain, operations)
+ s.Require().NoError(err)
+
+ // Verify records were updated
+ records, err := s.service.GetRecords(domain)
+ s.Require().NoError(err)
+ s.Require().Len(records, 2)
+ s.Require().Equal("192.168.1.100", records[0].Address)
+ s.Require().Equal("192.168.1.200", records[1].Address)
+}
+
+func (s *ServiceTestSuite) TestService_BulkUpdate_Delete() {
+ domain := testutil.ValidDomainFixture()
+
+ // Setup existing records
+ existingRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0)),
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("mail", dnsrecord.RecordTypeA, "192.168.1.3", 1800, 0)),
+ }
+ s.mock.records[domain] = existingRecords
+
+ // Bulk delete records
+ operations := []BulkOperation{
+ {
+ Action: BulkActionDelete,
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ },
+ {
+ Action: BulkActionDelete,
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0)),
+ },
+ }
+
+ err := s.service.BulkUpdate(domain, operations)
+ s.Require().NoError(err)
+
+ // Verify records were deleted
+ records, err := s.service.GetRecords(domain)
+ s.Require().NoError(err)
+ s.Require().Len(records, 1)
+ s.Require().Equal("mail", records[0].HostName)
+}
+
+func (s *ServiceTestSuite) TestService_BulkUpdate_MixedOperations() {
+ domain := testutil.ValidDomainFixture()
+
+ // Setup existing records
+ existingRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0)),
+ }
+ s.mock.records[domain] = existingRecords
+
+ // Mixed operations: add, update, delete
+ operations := []BulkOperation{
+ {
+ Action: BulkActionAdd,
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("mail", dnsrecord.RecordTypeA, "192.168.1.3", 1800, 0)),
+ },
+ {
+ Action: BulkActionUpdate,
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.100", 3600, 0)),
+ },
+ {
+ Action: BulkActionDelete,
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0)),
+ },
+ }
+
+ err := s.service.BulkUpdate(domain, operations)
+ s.Require().NoError(err)
+
+ // Verify final state
+ records, err := s.service.GetRecords(domain)
+ s.Require().NoError(err)
+ s.Require().Len(records, 2)
+
+ // Check that @ was updated
+ var rootRecord *dnsrecord.Record
+ for i := range records {
+ if records[i].HostName == "@" {
+ rootRecord = &records[i]
+ break
+ }
+ }
+ s.Require().NotNil(rootRecord)
+ s.Require().Equal("192.168.1.100", rootRecord.Address)
+
+ // Check that mail was added
+ var mailRecord *dnsrecord.Record
+ for i := range records {
+ if records[i].HostName == "mail" {
+ mailRecord = &records[i]
+ break
+ }
+ }
+ s.Require().NotNil(mailRecord)
+ s.Require().Equal("192.168.1.3", mailRecord.Address)
+}
+
+func (s *ServiceTestSuite) TestService_BulkUpdate_InvalidAction() {
+ domain := testutil.ValidDomainFixture()
+
+ operations := []BulkOperation{
+ {
+ Action: "invalid_action",
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ },
+ }
+
+ err := s.service.BulkUpdate(domain, operations)
+ s.Require().Error(err)
+}
+
+func (s *ServiceTestSuite) TestService_BulkUpdate_InvalidRecord() {
+ domain := testutil.ValidDomainFixture()
+
+ operations := []BulkOperation{
+ {
+ Action: BulkActionAdd,
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ },
+ }
+
+ err := s.service.BulkUpdate(domain, operations)
+ s.Require().Error(err)
+}
+
+func (s *ServiceTestSuite) TestService_BulkUpdate_UpdateNotFound() {
+ domain := testutil.ValidDomainFixture()
+
+ // Setup existing records
+ existingRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ }
+ s.mock.records[domain] = existingRecords
+
+ // Try to update non-existent record
+ operations := []BulkOperation{
+ {
+ Action: BulkActionUpdate,
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0)),
+ },
+ }
+
+ err := s.service.BulkUpdate(domain, operations)
+ s.Require().Error(err)
+}
+
+func (s *ServiceTestSuite) TestService_BulkUpdate_DeleteNotFound() {
+ domain := testutil.ValidDomainFixture()
+
+ // Setup existing records
+ existingRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ }
+ s.mock.records[domain] = existingRecords
+
+ // Try to delete non-existent record
+ operations := []BulkOperation{
+ {
+ Action: BulkActionDelete,
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0)),
+ },
+ }
+
+ err := s.service.BulkUpdate(domain, operations)
+ s.Require().Error(err)
+}
+
+func (s *ServiceTestSuite) TestService_BulkUpdate_GetRecordsError() {
+ domain := testutil.ValidDomainFixture()
+ expectedError := errors.New("provider error")
+ s.mock.getRecordsError = expectedError
+
+ operations := []BulkOperation{
+ {
+ Action: BulkActionAdd,
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ },
+ }
+
+ err := s.service.BulkUpdate(domain, operations)
+ s.Require().Error(err)
+ // BulkUpdate wraps the error, so check that it contains the original error
+ s.Require().Contains(err.Error(), "failed to get existing records")
+ s.Require().Contains(err.Error(), "provider error")
+}
+
+func (s *ServiceTestSuite) TestService_BulkUpdate_SetRecordsError() {
+ domain := testutil.ValidDomainFixture()
+
+ // Setup existing records
+ existingRecords := []dnsrecord.Record{
+ convertDNSRecord(testutil.DNSRecordFixtureWithValues("@", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0)),
+ }
+ s.mock.records[domain] = existingRecords
+
+ expectedError := errors.New("provider error")
+ s.mock.setRecordsError = expectedError
+
+ operations := []BulkOperation{
+ {
+ Action: BulkActionAdd,
+ Record: convertDNSRecord(testutil.DNSRecordFixtureWithValues("www", dnsrecord.RecordTypeA, "192.168.1.2", 1800, 0)),
+ },
+ }
+
+ err := s.service.BulkUpdate(domain, operations)
+ s.Require().Error(err)
+ s.Require().Equal(expectedError, err)
+}
+
+func (s *ServiceTestSuite) TestService_AddRecord_ValidationError() {
+ domain := testutil.ValidDomainFixture()
+
+ // Try to add invalid record (empty hostname)
+ invalidRecord := convertDNSRecord(testutil.DNSRecordFixtureWithValues("", dnsrecord.RecordTypeA, "192.168.1.1", 1800, 0))
+ err := s.service.AddRecord(domain, invalidRecord)
+ s.Require().Error(err)
+ s.Require().Contains(err.Error(), "invalid record")
+}
+
+func (s *ServiceTestSuite) TestService_NewServiceWithProviderName() {
+ // Register a mock provider
+ mock := newMockProvider("test-provider")
+ err := provider.Register(mock)
+ s.Require().NoError(err)
+ defer provider.Unregister("test-provider")
+
+ // Create service with provider name
+ service, err := NewServiceWithProviderName("test-provider")
+ s.Require().NoError(err)
+ s.Require().NotNil(service)
+}
+
+func (s *ServiceTestSuite) TestService_NewServiceWithProviderName_NotFound() {
+ // Try to create service with non-existent provider
+ service, err := NewServiceWithProviderName("non-existent-provider")
+ s.Require().Error(err)
+ s.Require().Nil(service)
}
-// convertDNSRecord converts testutil DNS record to dns.Record
-func convertDNSRecord(fixture testutil.TestDNSRecord) Record {
- return Record{
+// convertDNSRecord converts testutil DNS record to dnsrecord.Record
+func convertDNSRecord(fixture testutil.TestDNSRecord) dnsrecord.Record {
+ return dnsrecord.Record{
HostName: fixture.HostName,
RecordType: fixture.RecordType,
Address: fixture.Address,
diff --git a/pkg/dns/validation.go b/pkg/dns/validation.go
index f6c36da..2654b08 100644
--- a/pkg/dns/validation.go
+++ b/pkg/dns/validation.go
@@ -5,7 +5,7 @@ import (
"net"
"strings"
- "namecheap-dns-manager/pkg/validation"
+ "zonekit/pkg/validation"
)
// ValidateDomain validates a domain name format.
diff --git a/pkg/dnsrecord/record.go b/pkg/dnsrecord/record.go
new file mode 100644
index 0000000..46d405d
--- /dev/null
+++ b/pkg/dnsrecord/record.go
@@ -0,0 +1,22 @@
+package dnsrecord
+
+// Record represents a DNS record
+type Record struct {
+ ID string
+ HostName string
+ RecordType string
+ Address string
+ TTL int
+ MXPref int
+}
+
+// RecordType constants
+const (
+ RecordTypeA = "A"
+ RecordTypeAAAA = "AAAA"
+ RecordTypeCNAME = "CNAME"
+ RecordTypeMX = "MX"
+ RecordTypeTXT = "TXT"
+ RecordTypeNS = "NS"
+ RecordTypeSRV = "SRV"
+)
diff --git a/pkg/domain/service.go b/pkg/domain/service.go
index f94efb1..ee8248b 100644
--- a/pkg/domain/service.go
+++ b/pkg/domain/service.go
@@ -4,9 +4,10 @@ import (
"fmt"
"strings"
+ "zonekit/pkg/client"
+ "zonekit/pkg/pointer"
+
"github.com/namecheap/go-namecheap-sdk/v2/namecheap"
- "namecheap-dns-manager/pkg/client"
- "namecheap-dns-manager/pkg/pointer"
)
// Service provides domain management operations
@@ -97,9 +98,10 @@ func (s *Service) GetDomainInfo(domainName string) (*Domain, error) {
// CheckAvailability checks if a domain is available for registration
func (s *Service) CheckAvailability(domainName string) (bool, error) {
- // TODO: Implement domain availability check
- // The SDK doesn't seem to have a direct check method in the current version
- return false, fmt.Errorf("domain availability check not yet implemented - TODO: add domains.check API")
+ // Note: The current Namecheap SDK (v2.4.1) doesn't implement domain availability checking
+ // This would require implementing a direct API call to the domains.check endpoint
+ // For now, return an informative error
+ return false, fmt.Errorf("domain availability check not supported by current SDK version - please check manually at namecheap.com")
}
// RegisterDomain registers a new domain (placeholder - needs contact info)
@@ -127,9 +129,7 @@ func (s *Service) GetNameservers(domainName string) ([]string, error) {
}
nameservers := make([]string, 0, len(*resp.DomainDNSGetListResult.Nameservers))
- for _, ns := range *resp.DomainDNSGetListResult.Nameservers {
- nameservers = append(nameservers, ns)
- }
+ nameservers = append(nameservers, *resp.DomainDNSGetListResult.Nameservers...)
return nameservers, nil
}
diff --git a/pkg/domain/validation.go b/pkg/domain/validation.go
index 27b1230..947e1c4 100644
--- a/pkg/domain/validation.go
+++ b/pkg/domain/validation.go
@@ -1,7 +1,7 @@
package domain
import (
- "namecheap-dns-manager/pkg/validation"
+ "zonekit/pkg/validation"
)
// ValidateDomain validates a domain name format.
diff --git a/pkg/errors/errors_test.go b/pkg/errors/errors_test.go
new file mode 100644
index 0000000..56b829c
--- /dev/null
+++ b/pkg/errors/errors_test.go
@@ -0,0 +1,98 @@
+package errors
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+)
+
+// ErrorsTestSuite is a test suite for error types
+type ErrorsTestSuite struct {
+ suite.Suite
+}
+
+// TestErrorsSuite runs the errors test suite
+func TestErrorsSuite(t *testing.T) {
+ suite.Run(t, new(ErrorsTestSuite))
+}
+
+func (s *ErrorsTestSuite) TestErrInvalidInput_WithField() {
+ err := NewInvalidInput("username", "cannot be empty")
+ s.Require().NotNil(err)
+ s.Require().Equal("username", err.Field)
+ s.Require().Equal("cannot be empty", err.Message)
+ s.Require().Contains(err.Error(), "invalid input username")
+ s.Require().Contains(err.Error(), "cannot be empty")
+}
+
+func (s *ErrorsTestSuite) TestErrInvalidInput_WithoutField() {
+ err := NewInvalidInput("", "invalid configuration")
+ s.Require().NotNil(err)
+ s.Require().Equal("", err.Field)
+ s.Require().Equal("invalid configuration", err.Message)
+ s.Require().Contains(err.Error(), "invalid input")
+ s.Require().Contains(err.Error(), "invalid configuration")
+ s.Require().NotContains(err.Error(), "invalid input :")
+}
+
+func (s *ErrorsTestSuite) TestErrNotFound_WithID() {
+ err := NewNotFound("DNS record", "example.com")
+ s.Require().NotNil(err)
+ s.Require().Equal("DNS record", err.Resource)
+ s.Require().Equal("example.com", err.ID)
+ s.Require().Contains(err.Error(), "DNS record")
+ s.Require().Contains(err.Error(), "example.com")
+}
+
+func (s *ErrorsTestSuite) TestErrNotFound_WithoutID() {
+ err := NewNotFound("DNS record", "")
+ s.Require().NotNil(err)
+ s.Require().Equal("DNS record", err.Resource)
+ s.Require().Equal("", err.ID)
+ s.Require().Contains(err.Error(), "DNS record")
+ s.Require().Contains(err.Error(), "not found")
+ s.Require().NotContains(err.Error(), "'")
+}
+
+func (s *ErrorsTestSuite) TestErrConfiguration() {
+ err := NewConfiguration("missing required field")
+ s.Require().NotNil(err)
+ s.Require().Equal("missing required field", err.Message)
+ s.Require().Contains(err.Error(), "configuration error")
+ s.Require().Contains(err.Error(), "missing required field")
+}
+
+func (s *ErrorsTestSuite) TestErrAPI_WithOperation() {
+ originalErr := NewConfiguration("underlying error")
+ err := NewAPI("GetRecords", "failed to fetch records", originalErr)
+ s.Require().NotNil(err)
+ s.Require().Equal("GetRecords", err.Operation)
+ s.Require().Equal("failed to fetch records", err.Message)
+ s.Require().Equal(originalErr, err.Err)
+ s.Require().Contains(err.Error(), "API error in GetRecords")
+ s.Require().Contains(err.Error(), "failed to fetch records")
+
+ // Test Unwrap
+ unwrapped := err.Unwrap()
+ s.Require().Equal(originalErr, unwrapped)
+}
+
+func (s *ErrorsTestSuite) TestErrAPI_WithoutOperation() {
+ originalErr := NewConfiguration("underlying error")
+ err := NewAPI("", "failed to fetch records", originalErr)
+ s.Require().NotNil(err)
+ s.Require().Equal("", err.Operation)
+ s.Require().Equal("failed to fetch records", err.Message)
+ s.Require().Contains(err.Error(), "API error")
+ s.Require().NotContains(err.Error(), "API error in")
+}
+
+func (s *ErrorsTestSuite) TestErrAPI_WithoutUnderlyingError() {
+ err := NewAPI("GetRecords", "failed to fetch records", nil)
+ s.Require().NotNil(err)
+ s.Require().Nil(err.Err)
+ s.Require().Contains(err.Error(), "API error")
+
+ unwrapped := err.Unwrap()
+ s.Require().Nil(unwrapped)
+}
diff --git a/pkg/plugin/README.md b/pkg/plugin/README.md
index 4e6567f..e3101a9 100644
--- a/pkg/plugin/README.md
+++ b/pkg/plugin/README.md
@@ -1,7 +1,7 @@
# Plugin System
-> **Note:** This documentation has been moved to the [Wiki](https://github.com/SamyRai/namecheap/wiki/Plugin-Development).
+> **Note:** This documentation has been moved to the [Wiki](https://github.com/SamyRai/zonekit/wiki/Plugin-Development).
>
> For plugin development guides, examples, and best practices, please visit:
-> - [Plugin Development Guide](https://github.com/SamyRai/namecheap/wiki/Plugin-Development)
-> - [Plugins Overview](https://github.com/SamyRai/namecheap/wiki/Plugins)
+> - [Plugin Development Guide](https://github.com/SamyRai/zonekit/wiki/Plugin-Development)
+> - [Plugins Overview](https://github.com/SamyRai/zonekit/wiki/Plugins)
diff --git a/pkg/plugin/migadu/migadu.go b/pkg/plugin/migadu/migadu.go
deleted file mode 100644
index 65bb3f8..0000000
--- a/pkg/plugin/migadu/migadu.go
+++ /dev/null
@@ -1,390 +0,0 @@
-package migadu
-
-import (
- "fmt"
- "strings"
-
- "namecheap-dns-manager/pkg/dns"
- "namecheap-dns-manager/pkg/plugin"
-)
-
-const (
- pluginName = "migadu"
- pluginVersion = "1.0.0"
- pluginDescription = "Migadu email hosting setup and management"
-)
-
-// MigaduPlugin implements the plugin.Plugin interface for Migadu email hosting
-type MigaduPlugin struct{}
-
-// New creates a new Migadu plugin instance
-func New() *MigaduPlugin {
- return &MigaduPlugin{}
-}
-
-// Name returns the plugin name
-func (p *MigaduPlugin) Name() string {
- return pluginName
-}
-
-// Description returns the plugin description
-func (p *MigaduPlugin) Description() string {
- return pluginDescription
-}
-
-// Version returns the plugin version
-func (p *MigaduPlugin) Version() string {
- return pluginVersion
-}
-
-// Commands returns the list of commands this plugin provides
-func (p *MigaduPlugin) Commands() []plugin.Command {
- return []plugin.Command{
- {
- Name: "setup",
- Description: "Set up Migadu DNS records for a domain",
- LongDescription: `Set up all necessary DNS records for Migadu email hosting.
-This will add:
-- MX records for mail routing
-- SPF record for sender authentication
-- DKIM CNAMEs for email signing
-- DMARC record for email policy
-- Autoconfig CNAME for email client setup`,
- Execute: func(ctx *plugin.Context) error { return p.setup(ctx) },
- },
- {
- Name: "verify",
- Description: "Verify Migadu DNS records for a domain",
- LongDescription: "Check if all required Migadu DNS records are properly configured.",
- Execute: p.verify,
- },
- {
- Name: "remove",
- Description: "Remove Migadu DNS records from a domain",
- LongDescription: "Remove all Migadu-related DNS records from the specified domain.",
- Execute: func(ctx *plugin.Context) error { return p.remove(ctx) },
- },
- }
-}
-
-// setup implements the setup command
-func (p *MigaduPlugin) setup(ctx *plugin.Context) error {
- dryRun, _ := ctx.Flags["dry-run"].(bool)
- replace, _ := ctx.Flags["replace"].(bool)
-
- // Get current records if not replacing
- var existingRecords []dns.Record
- var err error
- if !replace {
- existingRecords, err = ctx.DNS.GetRecords(ctx.Domain)
- if err != nil {
- return fmt.Errorf("failed to get existing records: %w", err)
- }
- }
-
- // Generate Migadu DNS records
- migaduRecords := p.generateRecords(ctx.Domain)
-
- ctx.Output.Printf("Setting up Migadu DNS records for %s\n", ctx.Domain)
- ctx.Output.Println("=====================================")
-
- if dryRun {
- ctx.Output.Println("DRY RUN MODE - No changes will be made")
- ctx.Output.Println()
- }
-
- // Check for conflicts if not replacing
- var conflicts []string
- if !replace && len(existingRecords) > 0 {
- for _, migaduRecord := range migaduRecords {
- for _, existing := range existingRecords {
- if existing.HostName == migaduRecord.HostName && existing.RecordType == migaduRecord.RecordType {
- conflicts = append(conflicts, fmt.Sprintf("%s %s", existing.HostName, existing.RecordType))
- }
- }
- }
- }
-
- if len(conflicts) > 0 && !replace {
- ctx.Output.Println("⚠️ Conflicting records found:")
- for _, conflict := range conflicts {
- ctx.Output.Printf(" - %s\n", conflict)
- }
- ctx.Output.Println()
- ctx.Output.Println("Use --replace to overwrite existing records or resolve conflicts manually.")
- return nil
- }
-
- // Show what will be added
- ctx.Output.Println("Records to be added:")
- for _, record := range migaduRecords {
- mxPref := ""
- if record.MXPref > 0 {
- mxPref = fmt.Sprintf(" (priority: %d)", record.MXPref)
- }
- ctx.Output.Printf(" %s %s → %s%s\n", record.HostName, record.RecordType, record.Address, mxPref)
- }
- ctx.Output.Println()
-
- if dryRun {
- ctx.Output.Println("Dry run completed. Use without --dry-run to apply changes.")
- return nil
- }
-
- // Apply changes
- var allRecords []dns.Record
- if replace {
- allRecords = migaduRecords
- } else {
- allRecords = existingRecords
- allRecords = append(allRecords, migaduRecords...)
- }
-
- err = ctx.DNS.SetRecords(ctx.Domain, allRecords)
- if err != nil {
- return fmt.Errorf("failed to set DNS records: %w", err)
- }
-
- ctx.Output.Printf("✅ Successfully set up Migadu DNS records for %s\n", ctx.Domain)
- ctx.Output.Println()
- ctx.Output.Println("Next steps:")
- ctx.Output.Printf("1. Add %s to your Migadu account\n", ctx.Domain)
- ctx.Output.Println("2. Verify domain ownership in Migadu dashboard")
- ctx.Output.Println("3. Create email accounts in Migadu")
- ctx.Output.Println("4. Test email sending/receiving")
-
- return nil
-}
-
-// verify implements the verify command
-func (p *MigaduPlugin) verify(ctx *plugin.Context) error {
- records, err := ctx.DNS.GetRecords(ctx.Domain)
- if err != nil {
- return fmt.Errorf("failed to get DNS records: %w", err)
- }
-
- ctx.Output.Printf("Verifying Migadu setup for %s\n", ctx.Domain)
- ctx.Output.Println("=====================================")
-
- // Required records for verification
- requiredChecks := []struct {
- name string
- hostname string
- recordType string
- valueCheck func(string) bool
- found bool
- }{
- {
- name: "MX Record (Primary)",
- hostname: "@",
- recordType: "MX",
- valueCheck: func(value string) bool {
- return strings.Contains(value, "aspmx1.migadu.com")
- },
- },
- {
- name: "MX Record (Secondary)",
- hostname: "@",
- recordType: "MX",
- valueCheck: func(value string) bool {
- return strings.Contains(value, "aspmx2.migadu.com")
- },
- },
- {
- name: "SPF Record",
- hostname: "@",
- recordType: "TXT",
- valueCheck: func(value string) bool {
- return strings.Contains(value, "include:spf.migadu.com")
- },
- },
- {
- name: "DMARC Record",
- hostname: "@",
- recordType: "TXT",
- valueCheck: func(value string) bool {
- return strings.HasPrefix(value, "v=DMARC1")
- },
- },
- {
- name: "DKIM Key 1",
- hostname: "key1._domainkey",
- recordType: "CNAME",
- valueCheck: func(value string) bool {
- return strings.Contains(value, "migadu.com")
- },
- },
- {
- name: "DKIM Key 2",
- hostname: "key2._domainkey",
- recordType: "CNAME",
- valueCheck: func(value string) bool {
- return strings.Contains(value, "migadu.com")
- },
- },
- {
- name: "DKIM Key 3",
- hostname: "key3._domainkey",
- recordType: "CNAME",
- valueCheck: func(value string) bool {
- return strings.Contains(value, "migadu.com")
- },
- },
- {
- name: "Autoconfig",
- hostname: "autoconfig",
- recordType: "CNAME",
- valueCheck: func(value string) bool {
- return strings.Contains(value, "autoconfig.migadu.com")
- },
- },
- }
-
- // Check each required record
- for i := range requiredChecks {
- check := &requiredChecks[i]
- for _, record := range records {
- if record.HostName == check.hostname && record.RecordType == check.recordType {
- if check.valueCheck(record.Address) {
- check.found = true
- break
- }
- }
- }
- }
-
- // Display results
- allGood := true
- for _, check := range requiredChecks {
- status := "❌"
- if check.found {
- status = "✅"
- } else {
- allGood = false
- }
- ctx.Output.Printf("%s %s\n", status, check.name)
- }
-
- ctx.Output.Println()
- if allGood {
- ctx.Output.Println("🎉 All Migadu DNS records are properly configured!")
- } else {
- ctx.Output.Println("⚠️ Some required records are missing or incorrect.")
- ctx.Output.Printf(" Run 'namecheap-dns plugin migadu setup %s' to fix issues.\n", ctx.Domain)
- }
-
- return nil
-}
-
-// remove implements the remove command
-func (p *MigaduPlugin) remove(ctx *plugin.Context) error {
- confirm, _ := ctx.Flags["confirm"].(bool)
-
- if !confirm {
- ctx.Output.Printf("This will remove all Migadu DNS records from %s.\n", ctx.Domain)
- ctx.Output.Println("Use --confirm to proceed.")
- return nil
- }
-
- records, err := ctx.DNS.GetRecords(ctx.Domain)
- if err != nil {
- return fmt.Errorf("failed to get DNS records: %w", err)
- }
-
- // Filter out Migadu records
- var filteredRecords []dns.Record
- removedCount := 0
-
- for _, record := range records {
- isMigaduRecord := false
-
- // Check if this is a Migadu-related record
- if (record.RecordType == "MX" && strings.Contains(record.Address, "migadu.com")) ||
- (record.RecordType == "TXT" && strings.Contains(record.Address, "spf.migadu.com")) ||
- (record.RecordType == "TXT" && strings.HasPrefix(record.Address, "v=DMARC1")) ||
- (record.RecordType == "CNAME" && strings.Contains(record.Address, "migadu.com")) ||
- (record.HostName == "autoconfig" && record.RecordType == "CNAME") ||
- (strings.Contains(record.HostName, "_domainkey")) {
- isMigaduRecord = true
- removedCount++
- }
-
- if !isMigaduRecord {
- filteredRecords = append(filteredRecords, record)
- }
- }
-
- if removedCount == 0 {
- ctx.Output.Printf("No Migadu DNS records found for %s\n", ctx.Domain)
- return nil
- }
-
- err = ctx.DNS.SetRecords(ctx.Domain, filteredRecords)
- if err != nil {
- return fmt.Errorf("failed to update DNS records: %w", err)
- }
-
- ctx.Output.Printf("✅ Successfully removed %d Migadu DNS records from %s\n", removedCount, ctx.Domain)
- return nil
-}
-
-// generateRecords generates the DNS records required for Migadu
-func (p *MigaduPlugin) generateRecords(domainName string) []dns.Record {
- return []dns.Record{
- // MX Records
- {
- HostName: "@",
- RecordType: dns.RecordTypeMX,
- Address: "aspmx1.migadu.com.",
- TTL: dns.DefaultTTL,
- MXPref: 10,
- },
- {
- HostName: "@",
- RecordType: dns.RecordTypeMX,
- Address: "aspmx2.migadu.com.",
- TTL: dns.DefaultTTL,
- MXPref: 20,
- },
- // SPF Record
- {
- HostName: "@",
- RecordType: dns.RecordTypeTXT,
- Address: "v=spf1 include:spf.migadu.com -all",
- TTL: dns.DefaultTTL,
- },
- // DMARC Record
- {
- HostName: "@",
- RecordType: dns.RecordTypeTXT,
- Address: "v=DMARC1; p=quarantine;",
- TTL: dns.DefaultTTL,
- },
- // DKIM CNAMEs
- {
- HostName: "key1._domainkey",
- RecordType: dns.RecordTypeCNAME,
- Address: fmt.Sprintf("key1.%s._domainkey.migadu.com.", domainName),
- TTL: dns.DefaultTTL,
- },
- {
- HostName: "key2._domainkey",
- RecordType: dns.RecordTypeCNAME,
- Address: fmt.Sprintf("key2.%s._domainkey.migadu.com.", domainName),
- TTL: dns.DefaultTTL,
- },
- {
- HostName: "key3._domainkey",
- RecordType: dns.RecordTypeCNAME,
- Address: fmt.Sprintf("key3.%s._domainkey.migadu.com.", domainName),
- TTL: dns.DefaultTTL,
- },
- // Autoconfig for email clients
- {
- HostName: "autoconfig",
- RecordType: dns.RecordTypeCNAME,
- Address: "autoconfig.migadu.com.",
- TTL: dns.DefaultTTL,
- },
- }
-}
diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go
index c6863e7..56ea357 100644
--- a/pkg/plugin/plugin.go
+++ b/pkg/plugin/plugin.go
@@ -1,19 +1,20 @@
package plugin
import (
- "namecheap-dns-manager/pkg/dns"
+ "zonekit/pkg/dns"
+ "zonekit/pkg/dnsrecord"
)
// Service defines the DNS service interface for plugins
type Service interface {
- GetRecords(domainName string) ([]dns.Record, error)
- GetRecordsByType(domainName string, recordType string) ([]dns.Record, error)
- SetRecords(domainName string, records []dns.Record) error
- AddRecord(domainName string, record dns.Record) error
- UpdateRecord(domainName string, hostname, recordType string, newRecord dns.Record) error
+ GetRecords(domainName string) ([]dnsrecord.Record, error)
+ GetRecordsByType(domainName string, recordType string) ([]dnsrecord.Record, error)
+ SetRecords(domainName string, records []dnsrecord.Record) error
+ AddRecord(domainName string, record dnsrecord.Record) error
+ UpdateRecord(domainName string, hostname, recordType string, newRecord dnsrecord.Record) error
DeleteRecord(domainName string, hostname, recordType string) error
DeleteAllRecords(domainName string) error
- ValidateRecord(record dns.Record) error
+ ValidateRecord(record dnsrecord.Record) error
BulkUpdate(domainName string, operations []dns.BulkOperation) error
}
@@ -77,7 +78,7 @@ type OutputWriter interface {
// SetupResult represents the result of a setup operation
type SetupResult struct {
- Records []dns.Record
+ Records []dnsrecord.Record
Conflicts []Conflict
NextSteps []string
DryRun bool
@@ -88,8 +89,8 @@ type SetupResult struct {
type Conflict struct {
HostName string
RecordType string
- Existing dns.Record
- New dns.Record
+ Existing dnsrecord.Record
+ New dnsrecord.Record
}
// VerificationResult represents the result of a verification operation
diff --git a/pkg/plugin/service/config.go b/pkg/plugin/service/config.go
new file mode 100644
index 0000000..72030f0
--- /dev/null
+++ b/pkg/plugin/service/config.go
@@ -0,0 +1,214 @@
+package service
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Config represents a service integration configuration
+type Config struct {
+ Name string `yaml:"name"`
+ DisplayName string `yaml:"display_name"`
+ Description string `yaml:"description"`
+ Category string `yaml:"category"` // email, cdn, hosting, etc.
+ Records Records `yaml:"records"`
+ Verification *Verification `yaml:"verification,omitempty"`
+}
+
+// Records defines all DNS records for a service integration
+type Records struct {
+ MX []MXRecord `yaml:"mx,omitempty"`
+ SPF *TXTRecord `yaml:"spf,omitempty"`
+ DKIM []DKIMRecord `yaml:"dkim,omitempty"`
+ DMARC *TXTRecord `yaml:"dmarc,omitempty"`
+ Autodiscover *AutodiscoverRecord `yaml:"autodiscover,omitempty"`
+ Custom []CustomRecord `yaml:"custom,omitempty"`
+}
+
+// MXRecord represents an MX record
+type MXRecord struct {
+ Hostname string `yaml:"hostname"`
+ Server string `yaml:"server"`
+ Priority int `yaml:"priority"`
+ TTL int `yaml:"ttl,omitempty"`
+}
+
+// TXTRecord represents a TXT record
+type TXTRecord struct {
+ Hostname string `yaml:"hostname"`
+ Value string `yaml:"value"`
+ TTL int `yaml:"ttl,omitempty"`
+}
+
+// DKIMRecord represents a DKIM record (can be CNAME or TXT)
+type DKIMRecord struct {
+ Hostname string `yaml:"hostname"`
+ Type string `yaml:"type"` // CNAME or TXT
+ Value string `yaml:"value"`
+ TTL int `yaml:"ttl,omitempty"`
+}
+
+// AutodiscoverRecord represents autodiscover configuration
+type AutodiscoverRecord struct {
+ Type string `yaml:"type"` // SRV or CNAME
+ Hostname string `yaml:"hostname"`
+ // For SRV
+ Service string `yaml:"service,omitempty"`
+ Target string `yaml:"target,omitempty"`
+ Port int `yaml:"port,omitempty"`
+ Priority int `yaml:"priority,omitempty"`
+ Weight int `yaml:"weight,omitempty"`
+ // For CNAME
+ CNAME string `yaml:"cname,omitempty"`
+ TTL int `yaml:"ttl,omitempty"`
+}
+
+// CustomRecord represents a custom DNS record
+type CustomRecord struct {
+ Hostname string `yaml:"hostname"`
+ Type string `yaml:"type"` // A, AAAA, CNAME, TXT, NS, SRV
+ Value string `yaml:"value"`
+ TTL int `yaml:"ttl,omitempty"`
+ MXPref int `yaml:"mx_pref,omitempty"`
+}
+
+// Verification defines how to verify provider setup
+type Verification struct {
+ RequiredRecords []VerificationCheck `yaml:"required_records,omitempty"`
+}
+
+// VerificationCheck defines a single verification check
+type VerificationCheck struct {
+ Type string `yaml:"type"`
+ Hostname string `yaml:"hostname"`
+ Contains string `yaml:"contains,omitempty"`
+ Equals string `yaml:"equals,omitempty"`
+ StartsWith string `yaml:"starts_with,omitempty"`
+}
+
+// LoadConfig loads a service integration configuration from a YAML file
+func LoadConfig(filePath string) (*Config, error) {
+ data, err := os.ReadFile(filePath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read config file: %w", err)
+ }
+
+ var config Config
+ if err := yaml.Unmarshal(data, &config); err != nil {
+ return nil, fmt.Errorf("failed to parse config file: %w", err)
+ }
+
+ // Validate config
+ if err := config.Validate(); err != nil {
+ return nil, fmt.Errorf("invalid config: %w", err)
+ }
+
+ return &config, nil
+}
+
+// LoadAllConfigs loads all service integration configurations from a directory
+func LoadAllConfigs(dirPath string) (map[string]*Config, error) {
+ configs := make(map[string]*Config)
+
+ entries, err := os.ReadDir(dirPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read providers directory: %w", err)
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+
+ if !strings.HasSuffix(entry.Name(), ".yaml") && !strings.HasSuffix(entry.Name(), ".yml") {
+ continue
+ }
+
+ filePath := filepath.Join(dirPath, entry.Name())
+ config, err := LoadConfig(filePath)
+ if err != nil {
+ // Log error but continue loading other configs
+ continue
+ }
+
+ configs[config.Name] = config
+ }
+
+ return configs, nil
+}
+
+// Validate validates the configuration
+func (c *Config) Validate() error {
+ if c.Name == "" {
+ return fmt.Errorf("name is required")
+ }
+
+ if c.DisplayName == "" {
+ return fmt.Errorf("display_name is required")
+ }
+
+ // Validate MX records
+ for i, mx := range c.Records.MX {
+ if mx.Hostname == "" {
+ return fmt.Errorf("mx[%d].hostname is required", i)
+ }
+ if mx.Server == "" {
+ return fmt.Errorf("mx[%d].server is required", i)
+ }
+ if mx.Priority < 0 {
+ return fmt.Errorf("mx[%d].priority must be non-negative", i)
+ }
+ }
+
+ // Validate SPF
+ if c.Records.SPF != nil {
+ if c.Records.SPF.Hostname == "" {
+ return fmt.Errorf("spf.hostname is required")
+ }
+ if c.Records.SPF.Value == "" {
+ return fmt.Errorf("spf.value is required")
+ }
+ }
+
+ // Validate DKIM records
+ for i, dkim := range c.Records.DKIM {
+ if dkim.Hostname == "" {
+ return fmt.Errorf("dkim[%d].hostname is required", i)
+ }
+ if dkim.Type != "CNAME" && dkim.Type != "TXT" {
+ return fmt.Errorf("dkim[%d].type must be CNAME or TXT", i)
+ }
+ if dkim.Value == "" {
+ return fmt.Errorf("dkim[%d].value is required", i)
+ }
+ }
+
+ // Validate DMARC
+ if c.Records.DMARC != nil {
+ if c.Records.DMARC.Hostname == "" {
+ return fmt.Errorf("dmarc.hostname is required")
+ }
+ if c.Records.DMARC.Value == "" {
+ return fmt.Errorf("dmarc.value is required")
+ }
+ }
+
+ // Validate custom records
+ for i, custom := range c.Records.Custom {
+ if custom.Hostname == "" {
+ return fmt.Errorf("custom[%d].hostname is required", i)
+ }
+ if custom.Type == "" {
+ return fmt.Errorf("custom[%d].type is required", i)
+ }
+ if custom.Value == "" {
+ return fmt.Errorf("custom[%d].value is required", i)
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/plugin/service/service.go b/pkg/plugin/service/service.go
new file mode 100644
index 0000000..9bf5ea4
--- /dev/null
+++ b/pkg/plugin/service/service.go
@@ -0,0 +1,553 @@
+package service
+
+import (
+ "fmt"
+ "strings"
+
+ "zonekit/pkg/dns"
+ "zonekit/pkg/dnsrecord"
+ "zonekit/pkg/plugin"
+)
+
+// ServicePlugin is a generic plugin that loads service integration configurations
+type ServicePlugin struct {
+ configs map[string]*Config
+}
+
+// NewServicePlugin creates a new service plugin
+func NewServicePlugin(configs map[string]*Config) *ServicePlugin {
+ return &ServicePlugin{
+ configs: configs,
+ }
+}
+
+// Name returns the plugin name
+func (p *ServicePlugin) Name() string {
+ return "service"
+}
+
+// Description returns the plugin description
+func (p *ServicePlugin) Description() string {
+ return "DNS record templates for service integrations (email, CDN, hosting, etc.)"
+}
+
+// Version returns the plugin version
+func (p *ServicePlugin) Version() string {
+ return "1.0.0"
+}
+
+// Commands returns the list of commands this plugin provides
+func (p *ServicePlugin) Commands() []plugin.Command {
+ return []plugin.Command{
+ {
+ Name: "setup",
+ Description: "Set up DNS records for a service integration",
+ LongDescription: `Set up all necessary DNS records for a configured service integration.
+Usage: service setup
+
+Available services can be listed with: service list`,
+ Execute: p.setup,
+ },
+ {
+ Name: "verify",
+ Description: "Verify DNS records for a service integration",
+ LongDescription: "Check if all required DNS records for a service integration are properly configured.",
+ Execute: p.verify,
+ },
+ {
+ Name: "remove",
+ Description: "Remove DNS records for a service integration",
+ LongDescription: "Remove all service-related DNS records from the specified domain.",
+ Execute: p.remove,
+ },
+ {
+ Name: "list",
+ Description: "List all available service integrations",
+ LongDescription: "List all configured service integrations.",
+ Execute: p.list,
+ },
+ {
+ Name: "info",
+ Description: "Show service integration information",
+ LongDescription: "Display detailed information about a specific service integration.",
+ Execute: p.info,
+ },
+ }
+}
+
+// setup implements the setup command
+func (p *ServicePlugin) setup(ctx *plugin.Context) error {
+ if len(ctx.Args) < 2 {
+ return fmt.Errorf("usage: service setup ")
+ }
+
+ serviceName := ctx.Args[0]
+ domain := ctx.Args[1]
+
+ config, exists := p.configs[serviceName]
+ if !exists {
+ return fmt.Errorf("service '%s' not found. Use 'service list' to see available services", serviceName)
+ }
+
+ dryRun, _ := ctx.Flags["dry-run"].(bool)
+ replace, _ := ctx.Flags["replace"].(bool)
+
+ // Get current records if not replacing
+ var existingRecords []dnsrecord.Record
+ var err error
+ if !replace {
+ existingRecords, err = ctx.DNS.GetRecords(domain)
+ if err != nil {
+ return fmt.Errorf("failed to get existing records: %w", err)
+ }
+ }
+
+ // Generate DNS records from config
+ records := p.generateRecords(config, domain)
+
+ ctx.Output.Printf("Setting up %s DNS records for %s\n", config.DisplayName, domain)
+ ctx.Output.Println("=====================================")
+
+ if dryRun {
+ ctx.Output.Println("DRY RUN MODE - No changes will be made")
+ ctx.Output.Println()
+ }
+
+ // Check for conflicts if not replacing
+ var conflicts []string
+ if !replace && len(existingRecords) > 0 {
+ for _, newRecord := range records {
+ for _, existing := range existingRecords {
+ if existing.HostName == newRecord.HostName && existing.RecordType == newRecord.RecordType {
+ conflicts = append(conflicts, fmt.Sprintf("%s %s", existing.HostName, existing.RecordType))
+ break
+ }
+ }
+ }
+ }
+
+ if len(conflicts) > 0 && !replace {
+ ctx.Output.Println("Conflicting records found:")
+ for _, conflict := range conflicts {
+ ctx.Output.Printf(" - %s\n", conflict)
+ }
+ ctx.Output.Println()
+ ctx.Output.Println("Use --replace to overwrite existing records or resolve conflicts manually.")
+ return nil
+ }
+
+ // Show what will be added
+ ctx.Output.Println("Records to be added:")
+ for _, record := range records {
+ mxPref := ""
+ if record.MXPref > 0 {
+ mxPref = fmt.Sprintf(" (priority: %d)", record.MXPref)
+ }
+ ctx.Output.Printf(" %s %s → %s%s\n", record.HostName, record.RecordType, record.Address, mxPref)
+ }
+ ctx.Output.Println()
+
+ if dryRun {
+ ctx.Output.Println("Dry run completed. Use without --dry-run to apply changes.")
+ return nil
+ }
+
+ // Apply changes
+ var allRecords []dnsrecord.Record
+ if replace {
+ allRecords = records
+ } else {
+ allRecords = existingRecords
+ allRecords = append(allRecords, records...)
+ }
+
+ err = ctx.DNS.SetRecords(domain, allRecords)
+ if err != nil {
+ return fmt.Errorf("failed to set DNS records: %w", err)
+ }
+
+ ctx.Output.Printf("Successfully set up %s DNS records for %s\n", config.DisplayName, domain)
+ ctx.Output.Println()
+ ctx.Output.Println("Next steps:")
+ ctx.Output.Printf("1. Configure %s in your %s account\n", domain, config.DisplayName)
+ ctx.Output.Println("2. Verify domain ownership if required")
+ ctx.Output.Println("3. Test the configuration")
+
+ return nil
+}
+
+// verify implements the verify command
+func (p *ServicePlugin) verify(ctx *plugin.Context) error {
+ if len(ctx.Args) < 2 {
+ return fmt.Errorf("usage: service verify ")
+ }
+
+ serviceName := ctx.Args[0]
+ domain := ctx.Args[1]
+
+ config, exists := p.configs[serviceName]
+ if !exists {
+ return fmt.Errorf("service '%s' not found. Use 'service list' to see available services", serviceName)
+ }
+
+ records, err := ctx.DNS.GetRecords(domain)
+ if err != nil {
+ return fmt.Errorf("failed to get DNS records: %w", err)
+ }
+
+ ctx.Output.Printf("Verifying %s setup for %s\n", config.DisplayName, domain)
+ ctx.Output.Println("=====================================")
+
+ // Perform verification checks
+ allGood := true
+ if config.Verification != nil && len(config.Verification.RequiredRecords) > 0 {
+ for _, check := range config.Verification.RequiredRecords {
+ found := false
+ for _, record := range records {
+ if record.HostName == check.Hostname && record.RecordType == check.Type {
+ // Check value matches
+ matches := false
+ if check.Contains != "" {
+ matches = strings.Contains(record.Address, check.Contains)
+ } else if check.Equals != "" {
+ matches = record.Address == check.Equals
+ } else if check.StartsWith != "" {
+ matches = strings.HasPrefix(record.Address, check.StartsWith)
+ } else {
+ matches = true // Just check existence
+ }
+
+ if matches {
+ found = true
+ break
+ }
+ }
+ }
+
+ status := "FAIL"
+ if found {
+ status = "PASS"
+ } else {
+ allGood = false
+ }
+
+ ctx.Output.Printf("%s %s %s (%s)\n", status, check.Type, check.Hostname, check.Type)
+ }
+ } else {
+ // Generic verification - check if generated records exist
+ expectedRecords := p.generateRecords(config, domain)
+ for _, expected := range expectedRecords {
+ found := false
+ for _, actual := range records {
+ if actual.HostName == expected.HostName &&
+ actual.RecordType == expected.RecordType &&
+ strings.Contains(actual.Address, strings.TrimSuffix(expected.Address, ".")) {
+ found = true
+ break
+ }
+ }
+
+ status := "FAIL"
+ if found {
+ status = "PASS"
+ } else {
+ allGood = false
+ }
+
+ ctx.Output.Printf("%s %s %s\n", status, expected.RecordType, expected.HostName)
+ }
+ }
+
+ ctx.Output.Println()
+ if allGood {
+ ctx.Output.Printf("All %s DNS records are properly configured!\n", config.DisplayName)
+ } else {
+ ctx.Output.Println("Some required records are missing or incorrect.")
+ ctx.Output.Printf("Run 'zonekit service setup %s %s' to fix issues.\n", serviceName, domain)
+ }
+
+ return nil
+}
+
+// remove implements the remove command
+func (p *ServicePlugin) remove(ctx *plugin.Context) error {
+ if len(ctx.Args) < 2 {
+ return fmt.Errorf("usage: service remove ")
+ }
+
+ serviceName := ctx.Args[0]
+ domain := ctx.Args[1]
+
+ config, exists := p.configs[serviceName]
+ if !exists {
+ return fmt.Errorf("service '%s' not found. Use 'service list' to see available services", serviceName)
+ }
+
+ confirm, _ := ctx.Flags["confirm"].(bool)
+
+ if !confirm {
+ ctx.Output.Printf("This will remove all %s DNS records from %s.\n", config.DisplayName, domain)
+ ctx.Output.Println("Use --confirm to proceed.")
+ return nil
+ }
+
+ records, err := ctx.DNS.GetRecords(domain)
+ if err != nil {
+ return fmt.Errorf("failed to get DNS records: %w", err)
+ }
+
+ // Generate expected records to identify what to remove
+ expectedRecords := p.generateRecords(config, domain)
+ expectedMap := make(map[string]bool)
+ for _, record := range expectedRecords {
+ key := fmt.Sprintf("%s:%s:%s", record.HostName, record.RecordType, record.Address)
+ expectedMap[key] = true
+ }
+
+ // Filter out service records
+ var filteredRecords []dnsrecord.Record
+ removedCount := 0
+
+ for _, record := range records {
+ key := fmt.Sprintf("%s:%s:%s", record.HostName, record.RecordType, record.Address)
+ if expectedMap[key] {
+ removedCount++
+ continue
+ }
+
+ // Also check by pattern matching for dynamic values
+ shouldRemove := false
+ for _, expected := range expectedRecords {
+ if record.HostName == expected.HostName && record.RecordType == expected.RecordType {
+ // Check if address matches pattern
+ if strings.Contains(record.Address, strings.TrimSuffix(strings.TrimSuffix(expected.Address, "."), domain)) {
+ shouldRemove = true
+ break
+ }
+ }
+ }
+
+ if !shouldRemove {
+ filteredRecords = append(filteredRecords, record)
+ } else {
+ removedCount++
+ }
+ }
+
+ if removedCount == 0 {
+ ctx.Output.Printf("No %s DNS records found for %s\n", config.DisplayName, domain)
+ return nil
+ }
+
+ err = ctx.DNS.SetRecords(domain, filteredRecords)
+ if err != nil {
+ return fmt.Errorf("failed to update DNS records: %w", err)
+ }
+
+ ctx.Output.Printf("Successfully removed %d %s DNS records from %s\n", removedCount, config.DisplayName, domain)
+ return nil
+}
+
+// list implements the list command
+func (p *ServicePlugin) list(ctx *plugin.Context) error {
+ if len(p.configs) == 0 {
+ ctx.Output.Println("No service integrations configured.")
+ return nil
+ }
+
+ ctx.Output.Println("Available Service Integrations:")
+ ctx.Output.Println("===============================")
+
+ // Group by category
+ categories := make(map[string][]*Config)
+ for _, config := range p.configs {
+ category := config.Category
+ if category == "" {
+ category = "other"
+ }
+ categories[category] = append(categories[category], config)
+ }
+
+ for category, configs := range categories {
+ ctx.Output.Printf("\n%s:\n", strings.Title(category))
+ for _, config := range configs {
+ ctx.Output.Printf(" %s - %s\n", config.Name, config.DisplayName)
+ if config.Description != "" {
+ ctx.Output.Printf(" %s\n", config.Description)
+ }
+ }
+ }
+
+ return nil
+}
+
+// info implements the info command
+func (p *ServicePlugin) info(ctx *plugin.Context) error {
+ if len(ctx.Args) < 1 {
+ return fmt.Errorf("usage: service info ")
+ }
+
+ serviceName := ctx.Args[0]
+ config, exists := p.configs[serviceName]
+ if !exists {
+ return fmt.Errorf("service '%s' not found. Use 'service list' to see available services", serviceName)
+ }
+
+ ctx.Output.Printf("Service: %s\n", config.DisplayName)
+ ctx.Output.Printf("Name: %s\n", config.Name)
+ if config.Description != "" {
+ ctx.Output.Printf("Description: %s\n", config.Description)
+ }
+ if config.Category != "" {
+ ctx.Output.Printf("Category: %s\n", config.Category)
+ }
+
+ ctx.Output.Println("\nDNS Records:")
+ records := p.generateRecords(config, "example.com")
+ for _, record := range records {
+ mxPref := ""
+ if record.MXPref > 0 {
+ mxPref = fmt.Sprintf(" (priority: %d)", record.MXPref)
+ }
+ ctx.Output.Printf(" %s %s → %s%s\n", record.HostName, record.RecordType, record.Address, mxPref)
+ }
+
+ return nil
+}
+
+// generateRecords generates DNS records from a service integration configuration
+func (p *ServicePlugin) generateRecords(config *Config, domainName string) []dnsrecord.Record {
+ var records []dnsrecord.Record
+
+ // MX Records
+ for _, mx := range config.Records.MX {
+ ttl := mx.TTL
+ if ttl == 0 {
+ ttl = dns.DefaultTTL
+ }
+ records = append(records, dnsrecord.Record{
+ HostName: mx.Hostname,
+ RecordType: dnsrecord.RecordTypeMX,
+ Address: ensureTrailingDot(mx.Server),
+ TTL: ttl,
+ MXPref: mx.Priority,
+ })
+ }
+
+ // SPF Record
+ if config.Records.SPF != nil {
+ ttl := config.Records.SPF.TTL
+ if ttl == 0 {
+ ttl = dns.DefaultTTL
+ }
+ records = append(records, dnsrecord.Record{
+ HostName: config.Records.SPF.Hostname,
+ RecordType: dnsrecord.RecordTypeTXT,
+ Address: config.Records.SPF.Value,
+ TTL: ttl,
+ })
+ }
+
+ // DKIM Records
+ for _, dkim := range config.Records.DKIM {
+ ttl := dkim.TTL
+ if ttl == 0 {
+ ttl = dns.DefaultTTL
+ }
+ value := dkim.Value
+ // Replace {domain} placeholder if present
+ value = strings.ReplaceAll(value, "{domain}", domainName)
+
+ recordType := dnsrecord.RecordTypeCNAME
+ if dkim.Type == "TXT" {
+ recordType = dnsrecord.RecordTypeTXT
+ }
+
+ records = append(records, dnsrecord.Record{
+ HostName: dkim.Hostname,
+ RecordType: recordType,
+ Address: ensureTrailingDot(value),
+ TTL: ttl,
+ })
+ }
+
+ // DMARC Record
+ if config.Records.DMARC != nil {
+ ttl := config.Records.DMARC.TTL
+ if ttl == 0 {
+ ttl = dns.DefaultTTL
+ }
+ records = append(records, dnsrecord.Record{
+ HostName: config.Records.DMARC.Hostname,
+ RecordType: dnsrecord.RecordTypeTXT,
+ Address: config.Records.DMARC.Value,
+ TTL: ttl,
+ })
+ }
+
+ // Autodiscover
+ if config.Records.Autodiscover != nil {
+ ttl := config.Records.Autodiscover.TTL
+ if ttl == 0 {
+ ttl = dns.DefaultTTL
+ }
+
+ switch config.Records.Autodiscover.Type {
+ case "CNAME":
+ records = append(records, dnsrecord.Record{
+ HostName: config.Records.Autodiscover.Hostname,
+ RecordType: dnsrecord.RecordTypeCNAME,
+ Address: ensureTrailingDot(config.Records.Autodiscover.CNAME),
+ TTL: ttl,
+ })
+ case "SRV":
+ // SRV records are more complex, for now we'll use CNAME
+ // Full SRV support can be added later
+ if config.Records.Autodiscover.Target != "" {
+ records = append(records, dnsrecord.Record{
+ HostName: config.Records.Autodiscover.Hostname,
+ RecordType: dnsrecord.RecordTypeCNAME,
+ Address: ensureTrailingDot(config.Records.Autodiscover.Target),
+ TTL: ttl,
+ })
+ }
+ }
+ }
+
+ // Custom Records
+ for _, custom := range config.Records.Custom {
+ ttl := custom.TTL
+ if ttl == 0 {
+ ttl = dns.DefaultTTL
+ }
+ value := custom.Value
+ // Replace {domain} placeholder if present
+ value = strings.ReplaceAll(value, "{domain}", domainName)
+ value = ensureTrailingDot(value)
+
+ records = append(records, dnsrecord.Record{
+ HostName: custom.Hostname,
+ RecordType: custom.Type,
+ Address: value,
+ TTL: ttl,
+ MXPref: custom.MXPref,
+ })
+ }
+
+ return records
+}
+
+// ensureTrailingDot ensures a hostname has a trailing dot if it's a FQDN
+func ensureTrailingDot(hostname string) string {
+ if hostname == "" {
+ return hostname
+ }
+ // Don't add dot to IP addresses or special values
+ if strings.Contains(hostname, " ") || strings.Contains(hostname, "v=") {
+ return hostname
+ }
+ if !strings.HasSuffix(hostname, ".") && strings.Contains(hostname, ".") {
+ return hostname + "."
+ }
+ return hostname
+}
diff --git a/pkg/plugin/service/services/google-workspace.yaml b/pkg/plugin/service/services/google-workspace.yaml
new file mode 100644
index 0000000..580209b
--- /dev/null
+++ b/pkg/plugin/service/services/google-workspace.yaml
@@ -0,0 +1,54 @@
+name: google-workspace
+display_name: Google Workspace
+description: Google Workspace (Gmail) email hosting setup
+category: email
+
+records:
+ mx:
+ - hostname: "@"
+ server: aspmx.l.google.com
+ priority: 1
+ - hostname: "@"
+ server: alt1.aspmx.l.google.com
+ priority: 5
+ - hostname: "@"
+ server: alt2.aspmx.l.google.com
+ priority: 5
+ - hostname: "@"
+ server: alt3.aspmx.l.google.com
+ priority: 10
+ - hostname: "@"
+ server: alt4.aspmx.l.google.com
+ priority: 10
+
+ spf:
+ hostname: "@"
+ value: "v=spf1 include:_spf.google.com ~all"
+
+ dkim:
+ - hostname: google._domainkey
+ type: TXT
+ value: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC8kGa1Gy2h1F4T5Nd1g..."
+
+ dmarc:
+ hostname: "_dmarc"
+ value: "v=DMARC1; p=none; rua=mailto:dmarc@{domain}"
+
+ autodiscover:
+ type: SRV
+ hostname: "@"
+ service: "_autodiscover._tcp"
+ target: autodiscover.google.com
+ port: 443
+ priority: 0
+ weight: 0
+
+verification:
+ required_records:
+ - type: MX
+ hostname: "@"
+ contains: aspmx.l.google.com
+ - type: TXT
+ hostname: "@"
+ contains: include:_spf.google.com
+
diff --git a/pkg/plugin/service/services/mailgun.yaml b/pkg/plugin/service/services/mailgun.yaml
new file mode 100644
index 0000000..6c05aff
--- /dev/null
+++ b/pkg/plugin/service/services/mailgun.yaml
@@ -0,0 +1,36 @@
+name: mailgun
+display_name: Mailgun
+description: Mailgun email API service setup
+category: email
+
+records:
+ mx:
+ - hostname: "@"
+ server: mxa.mailgun.org
+ priority: 10
+ - hostname: "@"
+ server: mxb.mailgun.org
+ priority: 10
+
+ spf:
+ hostname: "@"
+ value: "v=spf1 include:mailgun.org ~all"
+
+ dkim:
+ - hostname: k1._domainkey
+ type: TXT
+ value: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC..."
+
+ dmarc:
+ hostname: "_dmarc"
+ value: "v=DMARC1; p=none; rua=mailto:dmarc@{domain}"
+
+verification:
+ required_records:
+ - type: MX
+ hostname: "@"
+ contains: mailgun.org
+ - type: TXT
+ hostname: "@"
+ contains: include:mailgun.org
+
diff --git a/pkg/plugin/service/services/microsoft-365.yaml b/pkg/plugin/service/services/microsoft-365.yaml
new file mode 100644
index 0000000..d201c71
--- /dev/null
+++ b/pkg/plugin/service/services/microsoft-365.yaml
@@ -0,0 +1,52 @@
+name: microsoft-365
+display_name: Microsoft 365
+description: Microsoft 365 (Exchange Online) email hosting setup
+category: email
+
+records:
+ mx:
+ - hostname: "@"
+ server: "{domain}.mail.protection.outlook.com"
+ priority: 0
+
+ spf:
+ hostname: "@"
+ value: "v=spf1 include:spf.protection.outlook.com -all"
+
+ dkim:
+ - hostname: selector1._domainkey
+ type: CNAME
+ value: "selector1-{domain}._domainkey.{domain}.onmicrosoft.com"
+ - hostname: selector2._domainkey
+ type: CNAME
+ value: "selector2-{domain}._domainkey.{domain}.onmicrosoft.com"
+
+ dmarc:
+ hostname: "_dmarc"
+ value: "v=DMARC1; p=none; pct=100; rua=mailto:dmarcreports@{domain}"
+
+ autodiscover:
+ type: CNAME
+ hostname: autodiscover
+ cname: autodiscover.outlook.com
+
+ custom:
+ - hostname: "@"
+ type: TXT
+ value: "MS=ms12345678"
+ - hostname: "@"
+ type: CNAME
+ value: "{domain}.mail.protection.outlook.com"
+
+verification:
+ required_records:
+ - type: MX
+ hostname: "@"
+ contains: mail.protection.outlook.com
+ - type: TXT
+ hostname: "@"
+ contains: include:spf.protection.outlook.com
+ - type: CNAME
+ hostname: autodiscover
+ contains: autodiscover.outlook.com
+
diff --git a/pkg/plugin/service/services/migadu.yaml b/pkg/plugin/service/services/migadu.yaml
new file mode 100644
index 0000000..b1efe6a
--- /dev/null
+++ b/pkg/plugin/service/services/migadu.yaml
@@ -0,0 +1,59 @@
+name: migadu
+display_name: Migadu
+description: Migadu email hosting setup and management
+category: email
+
+records:
+ mx:
+ - hostname: "@"
+ server: aspmx1.migadu.com
+ priority: 10
+ - hostname: "@"
+ server: aspmx2.migadu.com
+ priority: 20
+
+ spf:
+ hostname: "@"
+ value: "v=spf1 include:spf.migadu.com -all"
+
+ dkim:
+ - hostname: key1._domainkey
+ type: CNAME
+ value: key1.{domain}._domainkey.migadu.com
+ - hostname: key2._domainkey
+ type: CNAME
+ value: key2.{domain}._domainkey.migadu.com
+ - hostname: key3._domainkey
+ type: CNAME
+ value: key3.{domain}._domainkey.migadu.com
+
+ dmarc:
+ hostname: "@"
+ value: "v=DMARC1; p=quarantine;"
+
+ autodiscover:
+ type: CNAME
+ hostname: autoconfig
+ cname: autoconfig.migadu.com
+
+verification:
+ required_records:
+ - type: MX
+ hostname: "@"
+ contains: aspmx1.migadu.com
+ - type: MX
+ hostname: "@"
+ contains: aspmx2.migadu.com
+ - type: TXT
+ hostname: "@"
+ contains: include:spf.migadu.com
+ - type: TXT
+ hostname: "@"
+ starts_with: "v=DMARC1"
+ - type: CNAME
+ hostname: key1._domainkey
+ contains: migadu.com
+ - type: CNAME
+ hostname: autoconfig
+ contains: autoconfig.migadu.com
+
diff --git a/pkg/plugin/service/services/sendgrid.yaml b/pkg/plugin/service/services/sendgrid.yaml
new file mode 100644
index 0000000..abc4029
--- /dev/null
+++ b/pkg/plugin/service/services/sendgrid.yaml
@@ -0,0 +1,31 @@
+name: sendgrid
+display_name: SendGrid
+description: SendGrid transactional email service setup
+category: email
+
+records:
+ spf:
+ hostname: "@"
+ value: "v=spf1 include:sendgrid.net ~all"
+
+ dkim:
+ - hostname: s1._domainkey
+ type: CNAME
+ value: s1.domainkey.u1234567.wl123.sendgrid.net
+ - hostname: s2._domainkey
+ type: CNAME
+ value: s2.domainkey.u1234567.wl123.sendgrid.net
+
+ dmarc:
+ hostname: "_dmarc"
+ value: "v=DMARC1; p=none; rua=mailto:dmarc@{domain}"
+
+verification:
+ required_records:
+ - type: TXT
+ hostname: "@"
+ contains: include:sendgrid.net
+ - type: CNAME
+ hostname: s1._domainkey
+ contains: sendgrid.net
+
diff --git a/pkg/pointer/pointer.go b/pkg/pointer/pointer.go
index 655e57c..8b43d54 100644
--- a/pkg/pointer/pointer.go
+++ b/pkg/pointer/pointer.go
@@ -23,4 +23,3 @@ func Bool(b *bool) bool {
}
return *b
}
-
diff --git a/pkg/pointer/pointer_test.go b/pkg/pointer/pointer_test.go
index 86015e3..4b7654b 100644
--- a/pkg/pointer/pointer_test.go
+++ b/pkg/pointer/pointer_test.go
@@ -131,4 +131,3 @@ func intPtr(i int) *int {
func boolPtr(b bool) *bool {
return &b
}
-
diff --git a/pkg/validation/domain.go b/pkg/validation/domain.go
index 03a9c5d..634f99f 100644
--- a/pkg/validation/domain.go
+++ b/pkg/validation/domain.go
@@ -32,4 +32,3 @@ func ValidateDomain(domain string) error {
return nil
}
-
diff --git a/pkg/version/version.go b/pkg/version/version.go
index 69812ad..ab41ed0 100644
--- a/pkg/version/version.go
+++ b/pkg/version/version.go
@@ -97,4 +97,3 @@ func indexOf(s, substr string) int {
}
return -1
}
-
diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go
index d417cf8..55c85d9 100644
--- a/pkg/version/version_test.go
+++ b/pkg/version/version_test.go
@@ -46,4 +46,3 @@ func (s *VersionTestSuite) TestIsMajorRelease() {
result := IsMajorRelease()
s.Require().False(result, "Version 0.1.0 should not be considered major release")
}
-
diff --git a/renovate.json b/renovate.json
deleted file mode 100644
index 6448ff6..0000000
--- a/renovate.json
+++ /dev/null
@@ -1,75 +0,0 @@
-{
- "extends": [
- "config:base",
- ":dependencyDashboard",
- ":semanticPrefixFixDepsChoreOthers",
- "group:recommended"
- ],
- "timezone": "UTC",
- "schedule": [
- "before 6am on monday"
- ],
- "labels": [
- "dependencies"
- ],
- "assignees": [
- "@yourusername"
- ],
- "reviewers": [
- "@yourusername"
- ],
- "lock_file_maintenance": {
- "enabled": true,
- "schedule": [
- "before 6am on first day of month"
- ]
- },
- "package_rules": [
- {
- "description": "Group Go modules",
- "matchManagers": [
- "gomod"
- ],
- "groupName": "Go modules",
- "automerge": false
- },
- {
- "description": "Update golangci-lint",
- "matchPackageNames": [
- "github.com/golangci/golangci-lint"
- ],
- "groupName": "golangci-lint",
- "automerge": false,
- "labels": [
- "dependencies",
- "linting"
- ]
- }
- ],
- "vulnerability_alerts": {
- "enabled": true,
- "schedule": [
- "at any time"
- ],
- "labels": [
- "security"
- ]
- },
- "osv_vulnerability_alerts": true,
- "pr_concurrent_limit": 10,
- "pr_hourly_limit": 5,
- "branch_prefix": "renovate/",
- "commit_message_prefix": "\ud83d\udd27",
- "semantic_commits": "enabled",
- "separate_minor_patch": true,
- "separate_major_minor": false,
- "master_issue": true,
- "suppress_notifications": [
- "prEditedNotification"
- ],
- "dependency_dashboard": true,
- "dependency_dashboard_title": "\ud83d\udce6 Dependency Updates Dashboard",
- "dependency_dashboard_header": "This dashboard shows all pending dependency updates for this repository.",
- "dependency_dashboard_footer": "To approve a PR, comment with `@renovate merge` or use the GitHub review UI.",
- "platform_automerge": true
-}
diff --git a/scripts/download-openapi-schemas.sh b/scripts/download-openapi-schemas.sh
new file mode 100755
index 0000000..54da88e
--- /dev/null
+++ b/scripts/download-openapi-schemas.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+# Script to download official OpenAPI schemas from DNS providers
+
+set -e
+
+PROVIDER_DIR="pkg/dns/provider"
+TEMP_DIR="/tmp/zonekit-openapi"
+
+mkdir -p "$TEMP_DIR"
+
+echo "Downloading official OpenAPI schemas..."
+
+# Cloudflare
+echo "📥 Downloading Cloudflare OpenAPI spec..."
+CLOUDFLARE_URL="https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml"
+CLOUDFLARE_OUTPUT="$PROVIDER_DIR/cloudflare/openapi.yaml"
+
+if curl -s -L -f "$CLOUDFLARE_URL" -o "$CLOUDFLARE_OUTPUT"; then
+ CLOUDFLARE_SIZE=$(ls -lh "$CLOUDFLARE_OUTPUT" | awk '{print $5}')
+ echo "✅ Cloudflare: Downloaded ($CLOUDFLARE_SIZE)"
+ echo " Source: $CLOUDFLARE_URL"
+ echo " Saved to: $CLOUDFLARE_OUTPUT"
+else
+ echo "❌ Cloudflare: Failed to download"
+fi
+
+echo ""
+echo "📝 Note: The Cloudflare spec is large (~16MB) and includes all services."
+echo " For DNS-only usage, consider extracting just DNS-related paths."
+echo ""
+echo "✅ Download complete!"
+echo ""
+echo "To extract DNS-only paths (optional):"
+echo " yq eval '.paths | with_entries(select(.key | contains(\"dns\")))' \\"
+echo " $CLOUDFLARE_OUTPUT > $PROVIDER_DIR/cloudflare/openapi-dns-only.yaml"
+
diff --git a/wiki b/wiki
index 3ec7c80..1405bb9 160000
--- a/wiki
+++ b/wiki
@@ -1 +1 @@
-Subproject commit 3ec7c8097c17f5c204fe0c6e6c8fc7bc4ceeee8c
+Subproject commit 1405bb9f6df0471112ccebdf94e7ab28a142fa51