diff --git a/main.go b/main.go index fa9bc3b..7711668 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,9 @@ package main import ( "fmt" + "os" + "path/filepath" + "strings" // Load CoreDNS + p2p-forge plugins _ "github.com/ipshipyard/p2p-forge/plugins" @@ -38,6 +41,82 @@ func init() { clog.Debugf("updated directives: %v", dnsserver.Directives) } +// shouldShowUsageGuidance detects when Corefile is missing p2p-forge plugins +func shouldShowUsageGuidance() bool { + return shouldShowUsageGuidanceWithOptions(os.Args[1:], ".") +} + +// shouldShowUsageGuidanceWithOptions is a testable version that accepts custom args and working directory +func shouldShowUsageGuidanceWithOptions(args []string, workDir string) bool { + // Check if -conf flag is explicitly provided + confFile := getConfigFileFromArgs(args) + if confFile == "" { + // No explicit config, check if default Corefile exists + defaultCorefile := filepath.Join(workDir, "Corefile") + if _, err := os.Stat(defaultCorefile); os.IsNotExist(err) { + // No Corefile at all - show guidance + return true + } + confFile = defaultCorefile + } else if !filepath.IsAbs(confFile) { + // Make relative path absolute based on working directory + confFile = filepath.Join(workDir, confFile) + } + + // Check if the config file is missing required p2p-forge plugins + return isMissingP2PForgePlugins(confFile) +} + +// getConfigFile returns the config file path from command line args +func getConfigFile() string { + return getConfigFileFromArgs(os.Args[1:]) +} + +// getConfigFileFromArgs is a testable version that accepts custom args +func getConfigFileFromArgs(args []string) string { + for i, arg := range args { + if arg == "-conf" && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +// isMissingP2PForgePlugins checks if the config file is missing acme or ipparser plugins +func isMissingP2PForgePlugins(filename string) bool { + content, err := os.ReadFile(filename) + if err != nil { + // If we can't read the file, let CoreDNS handle the error + return false + } + + configStr := string(content) + + // Simple check for plugins not in comments + // Split by lines and check each line for plugins not preceded by # + hasAcme := false + hasIPParser := false + + lines := strings.Split(configStr, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + // Skip comment lines + if strings.HasPrefix(line, "#") { + continue + } + // Check for plugins (look for word boundaries to avoid partial matches) + if strings.Contains(line, "acme") { + hasAcme = true + } + if strings.Contains(line, "ipparser") { + hasIPParser = true + } + } + + // Show guidance if missing either essential plugin + return !hasAcme || !hasIPParser +} + func main() { fmt.Printf("%s %s\n", name, version) // always print version registerVersionMetric() @@ -45,6 +124,25 @@ func main() { if err == nil { fmt.Println(".env found and loaded") } + + // Check for common misconfiguration before running CoreDNS + if shouldShowUsageGuidance() { + fmt.Fprintf(os.Stderr, "\nError: Configuration issue detected.\n\n") + fmt.Fprintf(os.Stderr, "p2p-forge requires a Corefile with 'acme' and 'ipparser' plugins.\n") + fmt.Fprintf(os.Stderr, "This error occurs when running without a proper Corefile or with\n") + fmt.Fprintf(os.Stderr, "a generic CoreDNS config missing p2p-forge-specific plugins.\n\n") + fmt.Fprintf(os.Stderr, "For detailed usage instructions, see: https://github.com/ipshipyard/p2p-forge#usage\n\n") + fmt.Fprintf(os.Stderr, "Local development:\n") + fmt.Fprintf(os.Stderr, " ./p2p-forge -conf Corefile.local-dev -dns.port 5354\n\n") + fmt.Fprintf(os.Stderr, "Local Docker development:\n") + fmt.Fprintf(os.Stderr, " docker build -t p2p-forge-dev . && docker run --rm -it --net=host p2p-forge-dev\n") + fmt.Fprintf(os.Stderr, " docker run --rm -it --net=host -v ./Corefile.local-dev:/p2p-forge/Corefile.local-dev p2p-forge-dev -conf /p2p-forge/Corefile.local-dev -dns.port 5354\n\n") + fmt.Fprintf(os.Stderr, "Production deployment:\n") + fmt.Fprintf(os.Stderr, " ./p2p-forge -conf Corefile\n") + fmt.Fprintf(os.Stderr, " (Note: Use production-appropriate Corefile, not Corefile.local-dev)\n\n") + os.Exit(1) + } + coremain.Run() } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..126d7e1 --- /dev/null +++ b/main_test.go @@ -0,0 +1,260 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGetConfigFile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + expected string + }{ + { + name: "no arguments", + args: []string{}, + expected: "", + }, + { + name: "conf flag with file", + args: []string{"-conf", "Corefile.test"}, + expected: "Corefile.test", + }, + { + name: "conf flag in middle of args", + args: []string{"-dns.port", "5354", "-conf", "Corefile.local-dev", "-other", "flag"}, + expected: "Corefile.local-dev", + }, + { + name: "conf flag at end without value", + args: []string{"-dns.port", "5354", "-conf"}, + expected: "", + }, + { + name: "other flags only", + args: []string{"-dns.port", "5354", "-log.level", "debug"}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := getConfigFileFromArgs(tt.args) + if result != tt.expected { + t.Errorf("getConfigFileFromArgs() = %q, expected %q", result, tt.expected) + } + }) + } +} + +func TestIsMissingP2PForgePlugins(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + corefileContent string + expected bool + }{ + { + name: "valid corefile with both plugins", + corefileContent: `libp2p.direct { + log + errors + ipparser libp2p.direct + acme libp2p.direct { + registration-domain registration.libp2p.direct + database-type badger test.db + } +}`, + expected: false, + }, + { + name: "missing acme plugin", + corefileContent: `libp2p.direct { + log + errors + ipparser libp2p.direct +}`, + expected: true, + }, + { + name: "missing ipparser plugin", + corefileContent: `libp2p.direct { + log + errors + acme libp2p.direct { + registration-domain registration.libp2p.direct + database-type badger test.db + } +}`, + expected: true, + }, + { + name: "missing both plugins", + corefileContent: `libp2p.direct { + log + errors +}`, + expected: true, + }, + { + name: "empty file", + corefileContent: "", + expected: true, + }, + { + name: "plugins mentioned in comments only", + corefileContent: `libp2p.direct { + log + errors + # acme libp2p.direct + # ipparser libp2p.direct +}`, + expected: true, + }, + { + name: "plugins in different server blocks", + corefileContent: `server1 { + ipparser example.com +} +server2 { + acme example.com +}`, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // Create temporary file + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "Corefile.test") + + err := os.WriteFile(tmpFile, []byte(tt.corefileContent), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + result := isMissingP2PForgePlugins(tmpFile) + if result != tt.expected { + t.Errorf("isMissingP2PForgePlugins() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestIsMissingP2PForgePlugins_NonExistentFile(t *testing.T) { + t.Parallel() + + // Test with non-existent file - should return false to let CoreDNS handle the error + result := isMissingP2PForgePlugins("/path/that/does/not/exist") + if result != false { + t.Errorf("isMissingP2PForgePlugins() with non-existent file = %v, expected false", result) + } +} + +func TestShouldShowUsageGuidance(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + createCorefile bool + corefileContent string + createDefaultFile bool + expected bool + }{ + { + name: "no Corefile exists, no explicit config", + args: []string{}, + createCorefile: false, + createDefaultFile: false, + expected: true, + }, + { + name: "valid default Corefile exists", + args: []string{}, + createCorefile: true, + createDefaultFile: false, + corefileContent: "libp2p.direct {\n ipparser libp2p.direct\n acme libp2p.direct\n}", + expected: false, + }, + { + name: "invalid default Corefile exists", + args: []string{}, + createCorefile: true, + createDefaultFile: false, + corefileContent: "libp2p.direct {\n log\n errors\n}", + expected: true, + }, + { + name: "explicit valid config file", + args: []string{"-conf", "Corefile.test"}, + createCorefile: false, + createDefaultFile: true, + corefileContent: "libp2p.direct {\n ipparser libp2p.direct\n acme libp2p.direct\n}", + expected: false, + }, + { + name: "explicit invalid config file", + args: []string{"-conf", "Corefile.test"}, + createCorefile: false, + createDefaultFile: true, + corefileContent: "libp2p.direct {\n log\n}", + expected: true, + }, + { + name: "explicit non-existent config file", + args: []string{"-conf", "nonexistent.conf"}, + createCorefile: false, + createDefaultFile: false, + expected: false, // Let CoreDNS handle the missing file error + }, + { + name: "explicit absolute path config file", + args: []string{"-conf", "/absolute/path/Corefile"}, + createCorefile: false, + createDefaultFile: false, + expected: false, // Absolute paths should not be modified + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create temporary directory for test files + tmpDir := t.TempDir() + + // Create files as needed using absolute paths in temp directory + if tt.createCorefile { + corefilePath := filepath.Join(tmpDir, "Corefile") + err := os.WriteFile(corefilePath, []byte(tt.corefileContent), 0644) + if err != nil { + t.Fatalf("Failed to create Corefile: %v", err) + } + } + + if tt.createDefaultFile { + filename := filepath.Join(tmpDir, "Corefile.test") // matches the -conf argument in test cases + err := os.WriteFile(filename, []byte(tt.corefileContent), 0644) + if err != nil { + t.Fatalf("Failed to create test config file: %v", err) + } + } + + // Use the new testable function with custom working directory + result := shouldShowUsageGuidanceWithOptions(tt.args, tmpDir) + if result != tt.expected { + t.Errorf("shouldShowUsageGuidanceWithOptions() = %v, expected %v", result, tt.expected) + } + }) + } +}