Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions internal/config/validators/hosts_validator.go
Original file line number Diff line number Diff line change
@@ -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] == '*'
}
123 changes: 123 additions & 0 deletions internal/config/validators/hosts_validator_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
})
}
88 changes: 88 additions & 0 deletions internal/helpers/hosts_reader.go
Original file line number Diff line number Diff line change
@@ -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)
}
124 changes: 124 additions & 0 deletions internal/helpers/hosts_reader_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading