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 @@ ![Go](https://img.shields.io/badge/Go-1.22+-00ADD8?style=flat-square&logo=go) ![License](https://img.shields.io/badge/license-MIT-green?style=flat-square) -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