diff --git a/internal/config/validators/hosts_validator.go b/internal/config/validators/hosts_validator.go new file mode 100644 index 00000000..c6b81110 --- /dev/null +++ b/internal/config/validators/hosts_validator.go @@ -0,0 +1,61 @@ +package validators + +import ( + "net/url" + + "github.com/charmbracelet/log" + "github.com/evg4b/uncors/internal/config" + "github.com/evg4b/uncors/internal/helpers" + "github.com/evg4b/uncors/internal/urlparser" + "github.com/spf13/afero" +) + +// ValidateHostsFileEntries checks if all hosts from mappings are present in the hosts file. +// If a host is not found in the hosts file, it logs a warning. +func ValidateHostsFileEntries(cfg *config.UncorsConfig, fs afero.Fs) { + hosts, err := helpers.ReadHostsFile(fs) + if err != nil { + log.Warnf("Failed to read hosts file: %v. Skipping hosts validation.", err) + + return + } + + for _, mapping := range cfg.Mappings { + parsedURL, err := urlparser.Parse(mapping.From) + if err != nil { + log.Warnf("Failed to parse 'from' URL '%s': %v", mapping.From, err) + + continue + } + + hostname := getHostnameFromURL(parsedURL) + + // Skip wildcards - they can't be validated against hosts file + if containsWildcard(hostname) { + continue + } + + if !helpers.IsHostInHostsFile(hostname, hosts) { + log.Warnf( + "Host '%s' from mapping '%s' -> '%s' is not found in hosts file or does not point to localhost. "+ + "Add '127.0.0.1 %s' to %s for proper functionality.", + hostname, mapping.From, mapping.To, hostname, helpers.GetHostsFilePath(), + ) + } + } +} + +// getHostnameFromURL extracts the hostname from a URL, without port. +func getHostnameFromURL(u *url.URL) string { + host := u.Hostname() + if host == "" { + host = u.Host + } + + return host +} + +// containsWildcard checks if a hostname contains wildcard characters. +func containsWildcard(hostname string) bool { + return len(hostname) > 0 && hostname[0] == '*' +} diff --git a/internal/config/validators/hosts_validator_test.go b/internal/config/validators/hosts_validator_test.go new file mode 100644 index 00000000..3c0b74b5 --- /dev/null +++ b/internal/config/validators/hosts_validator_test.go @@ -0,0 +1,123 @@ +package validators_test + +import ( + "testing" + + "github.com/evg4b/uncors/internal/config" + "github.com/evg4b/uncors/internal/config/validators" + "github.com/evg4b/uncors/internal/helpers" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createMockHostsFile(t *testing.T, fs afero.Fs, content string) { + hostsPath := helpers.GetHostsFilePath() + err := afero.WriteFile(fs, hostsPath, []byte(content), 0o644) + require.NoError(t, err) +} + +func TestValidateHostsFileEntries(t *testing.T) { + t.Run("should not panic with valid config and existing host", func(t *testing.T) { + fs := afero.NewMemMapFs() + createMockHostsFile(t, fs, "127.0.0.1 localhost\n") + + cfg := &config.UncorsConfig{ + Mappings: []config.Mapping{ + {From: "http://localhost:8080", To: "https://example.com"}, + }, + } + + assert.NotPanics(t, func() { + validators.ValidateHostsFileEntries(cfg, fs) + }) + }) + + t.Run("should handle wildcard mappings", func(t *testing.T) { + fs := afero.NewMemMapFs() + createMockHostsFile(t, fs, "127.0.0.1 localhost\n") + + cfg := &config.UncorsConfig{ + Mappings: []config.Mapping{ + {From: "http://*.local:8080", To: "https://*.example.com"}, + }, + } + + assert.NotPanics(t, func() { + validators.ValidateHostsFileEntries(cfg, fs) + }) + }) + + t.Run("should handle empty mappings", func(t *testing.T) { + fs := afero.NewMemMapFs() + createMockHostsFile(t, fs, "127.0.0.1 localhost\n") + + cfg := &config.UncorsConfig{ + Mappings: []config.Mapping{}, + } + + assert.NotPanics(t, func() { + validators.ValidateHostsFileEntries(cfg, fs) + }) + }) + + t.Run("should handle invalid URLs gracefully", func(t *testing.T) { + fs := afero.NewMemMapFs() + createMockHostsFile(t, fs, "127.0.0.1 localhost\n") + + cfg := &config.UncorsConfig{ + Mappings: []config.Mapping{ + {From: "not-a-url", To: "https://example.com"}, + }, + } + + assert.NotPanics(t, func() { + validators.ValidateHostsFileEntries(cfg, fs) + }) + }) + + t.Run("should handle missing hosts file", func(t *testing.T) { + fs := afero.NewMemMapFs() + + cfg := &config.UncorsConfig{ + Mappings: []config.Mapping{ + {From: "http://localhost:8080", To: "https://example.com"}, + }, + } + + assert.NotPanics(t, func() { + validators.ValidateHostsFileEntries(cfg, fs) + }) + }) + + t.Run("should warn about missing hosts", func(t *testing.T) { + fs := afero.NewMemMapFs() + createMockHostsFile(t, fs, "127.0.0.1 localhost\n127.0.0.1 other.local\n") + + cfg := &config.UncorsConfig{ + Mappings: []config.Mapping{ + {From: "http://api.local:8080", To: "https://example.com"}, + }, + } + + // Should not panic, just log warning + assert.NotPanics(t, func() { + validators.ValidateHostsFileEntries(cfg, fs) + }) + }) + + t.Run("should not warn when host exists in hosts file", func(t *testing.T) { + fs := afero.NewMemMapFs() + createMockHostsFile(t, fs, "127.0.0.1 localhost api.local app.local\n") + + cfg := &config.UncorsConfig{ + Mappings: []config.Mapping{ + {From: "http://api.local:8080", To: "https://example.com"}, + }, + } + + assert.NotPanics(t, func() { + validators.ValidateHostsFileEntries(cfg, fs) + }) + }) +} diff --git a/internal/helpers/hosts_reader.go b/internal/helpers/hosts_reader.go new file mode 100644 index 00000000..ff974ce3 --- /dev/null +++ b/internal/helpers/hosts_reader.go @@ -0,0 +1,88 @@ +package helpers + +import ( + "bufio" + "net" + "runtime" + "strings" + + "github.com/spf13/afero" +) + +const minHostsFileFields = 2 // Minimum fields in hosts file: IP and at least one hostname + +// GetHostsFilePath returns the path to the system hosts file based on the operating system. +func GetHostsFilePath() string { + if runtime.GOOS == "windows" { + return `C:\Windows\System32\drivers\etc\hosts` + } + + return "/etc/hosts" +} + +// HostsEntry represents a single entry in the hosts file. +type HostsEntry struct { + IP string + Hostnames []string +} + +// ReadHostsFile reads and parses the system hosts file using the provided filesystem. +// It returns a map where the key is the hostname and the value is the IP address. +func ReadHostsFile(fs afero.Fs) (map[string]string, error) { + hostsPath := GetHostsFilePath() + file, err := fs.Open(hostsPath) + if err != nil { + return nil, err + } + defer file.Close() + + hosts := make(map[string]string) + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Split by whitespace + fields := strings.Fields(line) + if len(fields) < minHostsFileFields { + continue + } + + ip := fields[0] + // Add all hostnames from this line + for _, hostname := range fields[1:] { + hosts[hostname] = ip + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return hosts, nil +} + +// IsLocalhost checks if the given IP address is localhost (127.0.0.1, ::1, or any loopback address). +func IsLocalhost(ip string) bool { + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return false + } + + return parsedIP.IsLoopback() +} + +// IsHostInHostsFile checks if a hostname is defined in the hosts file and points to localhost. +func IsHostInHostsFile(hostname string, hosts map[string]string) bool { + ip, exists := hosts[hostname] + if !exists { + return false + } + + return IsLocalhost(ip) +} diff --git a/internal/helpers/hosts_reader_test.go b/internal/helpers/hosts_reader_test.go new file mode 100644 index 00000000..dfde6418 --- /dev/null +++ b/internal/helpers/hosts_reader_test.go @@ -0,0 +1,124 @@ +package helpers_test + +import ( + "testing" + + "github.com/evg4b/uncors/internal/helpers" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsLocalhost(t *testing.T) { + tests := []struct { + name string + ip string + expected bool + }{ + {"IPv4 loopback", "127.0.0.1", true}, + {"IPv4 loopback variant", "127.0.0.2", true}, + {"IPv6 loopback", "::1", true}, + {"IPv6 loopback full", "0:0:0:0:0:0:0:1", true}, + {"Non-localhost IPv4", "192.168.1.1", false}, + {"Non-localhost IPv6", "2001:db8::1", false}, + {"Invalid IP", "invalid", false}, + {"Empty string", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := helpers.IsLocalhost(tt.ip) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsHostInHostsFile(t *testing.T) { + hosts := map[string]string{ + "localhost": "127.0.0.1", + "api.local": "127.0.0.1", + "app.local": "::1", + "external.io": "192.168.1.100", + } + + tests := []struct { + name string + hostname string + expected bool + }{ + {"localhost with IPv4", "localhost", true}, + {"custom host with IPv4", "api.local", true}, + {"custom host with IPv6", "app.local", true}, + {"external host", "external.io", false}, + {"non-existent host", "unknown.host", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := helpers.IsHostInHostsFile(tt.hostname, hosts) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestReadHostsFile(t *testing.T) { + t.Run("should parse hosts file correctly", func(t *testing.T) { + fs := afero.NewMemMapFs() + hostsContent := `# This is a comment +127.0.0.1 localhost +127.0.0.1 api.local app.local +::1 ipv6.local + +# Another comment +192.168.1.1 external.com +` + hostsPath := helpers.GetHostsFilePath() + err := afero.WriteFile(fs, hostsPath, []byte(hostsContent), 0o644) + require.NoError(t, err) + + hosts, err := helpers.ReadHostsFile(fs) + require.NoError(t, err) + assert.NotNil(t, hosts) + + assert.Equal(t, "127.0.0.1", hosts["localhost"]) + assert.Equal(t, "127.0.0.1", hosts["api.local"]) + assert.Equal(t, "127.0.0.1", hosts["app.local"]) + assert.Equal(t, "::1", hosts["ipv6.local"]) + assert.Equal(t, "192.168.1.1", hosts["external.com"]) + }) + + t.Run("should handle non-existent file", func(t *testing.T) { + fs := afero.NewMemMapFs() + + hosts, err := helpers.ReadHostsFile(fs) + require.Error(t, err) + assert.Nil(t, hosts) + }) + + t.Run("should skip invalid lines", func(t *testing.T) { + fs := afero.NewMemMapFs() + hostsContent := `127.0.0.1 localhost +invalid-line + +127.0.0.2 test.local +` + hostsPath := helpers.GetHostsFilePath() + err := afero.WriteFile(fs, hostsPath, []byte(hostsContent), 0o644) + require.NoError(t, err) + + hosts, err := helpers.ReadHostsFile(fs) + require.NoError(t, err) + assert.NotNil(t, hosts) + + assert.Equal(t, "127.0.0.1", hosts["localhost"]) + assert.Equal(t, "127.0.0.2", hosts["test.local"]) + assert.Len(t, hosts, 2) + }) +} + +func TestGetHostsFilePath(t *testing.T) { + path := helpers.GetHostsFilePath() + assert.NotEmpty(t, path) + // Path should be either /etc/hosts (Unix) or C:\Windows\System32\drivers\etc\hosts (Windows) + assert.Contains(t, []string{"/etc/hosts", `C:\Windows\System32\drivers\etc\hosts`}, path) +} diff --git a/main.go b/main.go index 41d2aac7..74d59b19 100644 --- a/main.go +++ b/main.go @@ -100,5 +100,8 @@ func loadConfiguration(viperInstance *viper.Viper, fs afero.Fs) *config.UncorsCo log.SetLevel(log.InfoLevel) } + // Validate that hosts from mappings are present in the hosts file + validators.ValidateHostsFileEntries(uncorsConfig, fs) + return uncorsConfig }