From 330fbec53c03e1521a82e0c399b713d769589779 Mon Sep 17 00:00:00 2001 From: igor Date: Fri, 10 Jul 2026 07:09:55 +0200 Subject: [PATCH] added full URL for listening server --- internal/app/app.go | 135 ++++++++++++++++++++++++++++++++++++++- internal/app/app_test.go | 122 +++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 internal/app/app_test.go diff --git a/internal/app/app.go b/internal/app/app.go index d2f7b73..8859697 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "log" + "net" "net/http" + "sort" "time" "supervisor/internal/config" @@ -56,7 +58,7 @@ func New(cfg config.Config) (*App, error) { func (a *App) Run(ctx context.Context) error { errCh := make(chan error, 1) go func() { - a.logger.Printf("HTTP server listening on %s", a.cfg.Addr) + a.logServerURLs() errCh <- a.httpServer.ListenAndServe() }() @@ -73,3 +75,134 @@ func (a *App) Run(ctx context.Context) error { return err } } + +func (a *App) logServerURLs() { + urls, err := serverURLs(a.cfg.Addr) + if err != nil { + a.logger.Printf("warning: determine local server URLs: %v", err) + } + if len(urls) == 0 { + a.logger.Printf("HTTP server listening on %s", a.cfg.Addr) + return + } + + a.logger.Print("HTTP server available at:") + for _, url := range urls { + a.logger.Printf(" %s", url) + } +} + +func serverURLs(addr string) ([]string, error) { + return serverURLsWithLocalIPv4Addrs(addr, discoverLocalIPv4Addrs) +} + +func serverURLsWithLocalIPv4Addrs(addr string, localIPv4Addrs func() ([]string, error)) ([]string, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + + urls := make([]string, 0, 4) + seen := make(map[string]struct{}) + addURL := func(host string) { + if host == "" { + return + } + url := "http://" + net.JoinHostPort(host, port) + if _, ok := seen[url]; ok { + return + } + seen[url] = struct{}{} + urls = append(urls, url) + } + + if isWildcardHost(host) { + addURL("localhost") + addURL("127.0.0.1") + + addrs, err := localIPv4Addrs() + for _, addr := range addrs { + ip := net.ParseIP(addr) + if !isUsableLocalIPv4(ip) { + continue + } + addURL(ip.String()) + } + return urls, err + } + + ip := net.ParseIP(host) + if ip == nil { + addURL(host) + return urls, nil + } + if ip4 := ip.To4(); ip4 != nil && !ip4.IsUnspecified() { + addURL(ip4.String()) + } + return urls, nil +} + +func isWildcardHost(host string) bool { + return host == "" || host == "0.0.0.0" || host == "::" +} + +func discoverLocalIPv4Addrs() ([]string, error) { + interfaces, err := net.Interfaces() + if err != nil { + return nil, err + } + + addrs := make([]string, 0) + seen := make(map[string]struct{}) + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + + ifaceAddrs, err := iface.Addrs() + if err != nil { + return addrs, err + } + for _, ifaceAddr := range ifaceAddrs { + ip := ipFromAddr(ifaceAddr) + if !isUsableLocalIPv4(ip) { + continue + } + + addr := ip.To4().String() + if _, ok := seen[addr]; ok { + continue + } + seen[addr] = struct{}{} + addrs = append(addrs, addr) + } + } + + sort.Strings(addrs) + return addrs, nil +} + +func ipFromAddr(addr net.Addr) net.IP { + switch v := addr.(type) { + case *net.IPNet: + return v.IP + case *net.IPAddr: + return v.IP + default: + return nil + } +} + +func isUsableLocalIPv4(ip net.IP) bool { + if ip == nil { + return false + } + ip4 := ip.To4() + if ip4 == nil { + return false + } + return !ip4.IsUnspecified() && + !ip4.IsLoopback() && + !ip4.IsMulticast() && + !ip4.IsLinkLocalUnicast() +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..763324a --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,122 @@ +package app + +import ( + "errors" + "net" + "reflect" + "testing" +) + +func TestServerURLsForWildcardAddr(t *testing.T) { + urls, err := serverURLsWithLocalIPv4Addrs(":8080", func() ([]string, error) { + return []string{ + "192.168.1.100", + "127.0.0.1", + "192.168.1.100", + "10.0.0.2", + }, nil + }) + if err != nil { + t.Fatalf("serverURLsWithLocalIPv4Addrs returned error: %v", err) + } + + want := []string{ + "http://localhost:8080", + "http://127.0.0.1:8080", + "http://192.168.1.100:8080", + "http://10.0.0.2:8080", + } + if !reflect.DeepEqual(urls, want) { + t.Fatalf("urls = %#v, want %#v", urls, want) + } +} + +func TestServerURLsForZeroIPv4Addr(t *testing.T) { + urls, err := serverURLsWithLocalIPv4Addrs("0.0.0.0:8080", func() ([]string, error) { + return []string{"192.168.1.100"}, nil + }) + if err != nil { + t.Fatalf("serverURLsWithLocalIPv4Addrs returned error: %v", err) + } + + want := []string{ + "http://localhost:8080", + "http://127.0.0.1:8080", + "http://192.168.1.100:8080", + } + if !reflect.DeepEqual(urls, want) { + t.Fatalf("urls = %#v, want %#v", urls, want) + } +} + +func TestServerURLsForSpecificIPv4Addr(t *testing.T) { + urls, err := serverURLsWithLocalIPv4Addrs("192.168.1.100:8080", func() ([]string, error) { + t.Fatal("local IPv4 addrs should not be discovered for a specific bind address") + return nil, nil + }) + if err != nil { + t.Fatalf("serverURLsWithLocalIPv4Addrs returned error: %v", err) + } + + want := []string{"http://192.168.1.100:8080"} + if !reflect.DeepEqual(urls, want) { + t.Fatalf("urls = %#v, want %#v", urls, want) + } +} + +func TestServerURLsForLocalhostAddr(t *testing.T) { + urls, err := serverURLsWithLocalIPv4Addrs("localhost:8080", func() ([]string, error) { + t.Fatal("local IPv4 addrs should not be discovered for a hostname bind address") + return nil, nil + }) + if err != nil { + t.Fatalf("serverURLsWithLocalIPv4Addrs returned error: %v", err) + } + + want := []string{"http://localhost:8080"} + if !reflect.DeepEqual(urls, want) { + t.Fatalf("urls = %#v, want %#v", urls, want) + } +} + +func TestServerURLsReturnsFallbackURLsWhenDiscoveryFails(t *testing.T) { + wantErr := errors.New("interfaces unavailable") + urls, err := serverURLsWithLocalIPv4Addrs(":8080", func() ([]string, error) { + return nil, wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("serverURLsWithLocalIPv4Addrs error = %v, want %v", err, wantErr) + } + + want := []string{ + "http://localhost:8080", + "http://127.0.0.1:8080", + } + if !reflect.DeepEqual(urls, want) { + t.Fatalf("urls = %#v, want %#v", urls, want) + } +} + +func TestIsUsableLocalIPv4(t *testing.T) { + tests := []struct { + name string + ip net.IP + want bool + }{ + {name: "private", ip: net.ParseIP("192.168.1.100"), want: true}, + {name: "zero", ip: net.ParseIP("0.0.0.0"), want: false}, + {name: "loopback", ip: net.ParseIP("127.0.0.1"), want: false}, + {name: "ipv6", ip: net.ParseIP("2001:db8::1"), want: false}, + {name: "multicast", ip: net.ParseIP("224.0.0.1"), want: false}, + {name: "link local", ip: net.ParseIP("169.254.1.2"), want: false}, + {name: "nil", ip: nil, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isUsableLocalIPv4(tt.ip); got != tt.want { + t.Fatalf("isUsableLocalIPv4(%v) = %v, want %v", tt.ip, got, tt.want) + } + }) + } +}