Skip to content
Merged
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
55 changes: 51 additions & 4 deletions internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"net/url"
"os"
"os/signal"
"strconv"
"syscall"
"time"

Expand Down Expand Up @@ -76,7 +77,7 @@ func Serve(opts Options) int {
}

a := newAuth(opts.Auth, opts.Password)
localURL := "http://" + ln.Addr().String()
localURL := advertisedURL(ln, opts.Host)

// Catch both Ctrl-C and SIGTERM (kill / service stop) so the tunnel teardown
// (e.g. the pinggy ssh child) always runs and the public endpoint is freed.
Expand All @@ -96,7 +97,7 @@ func Serve(opts Options) int {
}
}

printStartup(localURL, publicURL, opts.Provider, tunnelPass, a, opts.Root)
printStartup(localURL, publicURL, opts.Provider, tunnelPass, a, opts.Root, isWildcardHost(opts.Host))

if err := serve(ctx, ln, opts, a, publicURL); err != nil && !errors.Is(err, http.ErrServerClosed) {
render.Err("csdd web: " + err.Error())
Expand Down Expand Up @@ -165,9 +166,14 @@ func serve(ctx context.Context, ln net.Listener, opts Options, a *auth, publicUR
// printStartup writes the access banner: the local URL, the auth token and a
// one-click magic link, and the public tunnel URL when enabled. provider names
// the tunnel in use; tunnelPass is the loca.lt interstitial password (this
// machine's public IP), set only for localtunnel.
func printStartup(localURL, publicURL, provider, tunnelPass string, a *auth, root string) {
// machine's public IP), set only for localtunnel. allIfaces marks a wildcard
// bind, whose remaining hop to a remote client is the host/cloud firewall — the
// one part of "expose this dashboard" csdd cannot do for the user.
func printStartup(localURL, publicURL, provider, tunnelPass string, a *auth, root string, allIfaces bool) {
render.OK("csdd web → " + localURL)
if allIfaces {
render.Info("bound to all interfaces — a remote client also needs this port opened in the host firewall (ufw/iptables) and in any cloud provider firewall or security group.")
}
if a.enabled {
render.Info("auth token: " + render.Bold(a.token))
render.Info("open authenticated: " + entryURL(localURL, a))
Expand Down Expand Up @@ -207,6 +213,47 @@ func entryURL(base string, a *auth) string {
return base
}

// advertisedURL turns the bound listener into a URL a human can actually open.
// A wildcard bind is the case that needs help: Go serves --host 0.0.0.0 from a
// dual-stack socket, so ln.Addr() reads back as "[::]:7777" — an address no
// browser can reach and, worse, the base of the printed magic link. Substitute
// the machine's own interface address, which is what a LAN or VPS client types.
func advertisedURL(ln net.Listener, host string) string {
if !isWildcardHost(host) {
return "http://" + ln.Addr().String()
}
port := strconv.Itoa(tcpPort(ln))
if ip := primaryIP(); ip != "" {
return "http://" + net.JoinHostPort(ip, port)
}
return "http://" + net.JoinHostPort("localhost", port) // no routable NIC
}

// primaryIP reports this machine's first non-loopback interface address,
// preferring IPv4 because that is the form users type. On a VPS behind NAT this
// is the private address rather than the public one, which is still far more
// useful than the wildcard it replaces. Returns "" when nothing is routable.
func primaryIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
var v6 string
for _, a := range addrs {
n, ok := a.(*net.IPNet)
if !ok || n.IP.IsLoopback() || n.IP.IsLinkLocalUnicast() {
continue
}
if v4 := n.IP.To4(); v4 != nil {
return v4.String()
}
if v6 == "" {
v6 = n.IP.String()
}
}
return v6
}

func tcpPort(ln net.Listener) int {
if t, ok := ln.Addr().(*net.TCPAddr); ok {
return t.Port
Expand Down
41 changes: 41 additions & 0 deletions internal/web/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"errors"
"net"
"net/http"
"strconv"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -94,3 +96,42 @@ func waitHealthy(t *testing.T, base string) {
}
t.Fatal("server never became healthy")
}

// A wildcard bind must not advertise the address Go reads back off its
// dual-stack socket ("[::]:port"): that URL is unopenable, and with auth on it
// is also the base of the magic link the user is told to click.
func TestAdvertisedURLRewritesWildcardBind(t *testing.T) {
ln, err := net.Listen("tcp", "0.0.0.0:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()

got := advertisedURL(ln, "0.0.0.0")
if strings.Contains(got, "[::]") || strings.Contains(got, "0.0.0.0") {
t.Fatalf("advertised an unreachable wildcard address: %s", got)
}
host, port, err := net.SplitHostPort(strings.TrimPrefix(got, "http://"))
if err != nil {
t.Fatalf("advertised URL is not host:port: %s (%v)", got, err)
}
if host == "" {
t.Fatalf("advertised URL has no host: %s", got)
}
if want := strconv.Itoa(tcpPort(ln)); port != want {
t.Errorf("advertised port = %s, want %s (the bound port)", port, want)
}
}

// A concrete bind is already reachable as-is and must be reported verbatim.
func TestAdvertisedURLKeepsExplicitHost(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()

if got, want := advertisedURL(ln, "127.0.0.1"), "http://"+ln.Addr().String(); got != want {
t.Errorf("advertisedURL = %s, want %s", got, want)
}
}
Loading