tailscale/tailscale · error
ListenFunnel(%q, %q): host must be empty
Error message
ListenFunnel(%q, %q): host must be empty
What it means
Funnel listeners cannot pin a specific local address because Funnel traffic arrives for the node's certificate domain, not an interface IP. After SplitHostPort, tsnet requires the host part of addr to be empty — i.e. exactly ":443", ":8443", or ":10000". This error fires when a host was supplied (the network check and SplitHostPort already succeeded).
Source
Thrown at tsnet/tsnet.go:1521
// Currently (2023-03-10), Funnel only supports TCP on ports 443, 8443, and 10000.
// The supported host name is limited to that configured for the tsnet.Server.
// As such, the standard way to create funnel is:
//
// s.ListenFunnel("tcp", ":443")
//
// and the only other supported addrs currently are ":8443" and ":10000".
//
// It will start the server if it has not been started yet.
func (s *Server) ListenFunnel(network, addr string, opts ...FunnelOption) (net.Listener, error) {
if network != "tcp" {
return nil, fmt.Errorf("ListenFunnel(%q, %q): only tcp is supported", network, addr)
}
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
if host != "" {
return nil, fmt.Errorf("ListenFunnel(%q, %q): host must be empty", network, addr)
}
port, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return nil, err
}
// Process, validate opts.
lnOn := listenOnBoth
var tlsConfig *tls.Config
for _, opt := range opts {
switch v := opt.(type) {
case funnelTLSConfig:
if v.conf == nil {
return nil, errors.New("invalid nil FunnelTLSConfig")
}
tlsConfig = v.conf
case funnelOnly:
lnOn = listenOnFunnelView on GitHub (pinned to 57c3357fdb)
Solutions
- Drop the host: s.ListenFunnel("tcp", ":443").
- Sanitize configured addresses by stripping the host before calling ListenFunnel (keep only the port).
- Remember the hostname is fixed to the node's cert domain anyway, so a host adds nothing.
Example fix
// before
ln, err := s.ListenFunnel("tcp", "0.0.0.0:443")
// after
ln, err := s.ListenFunnel("tcp", ":443") Defensive patterns
Strategy: validation
Validate before calling
if host, _, err := net.SplitHostPort(addr); err != nil || host != "" {
return nil, fmt.Errorf("ListenFunnel addr must be \":port\" (got %q)", addr)
} Type guard
func isFunnelAddrValid(addr string) bool {
host, _, err := net.SplitHostPort(addr)
return err == nil && host == ""
} Prevention
- Strip the host from operator-provided address strings before ListenFunnel (keep ':port').
- Lint configs for funnel addresses not matching '^:(443|8443|10000)$'.
When it happens
Trigger: Calling s.ListenFunnel("tcp", "0.0.0.0:443"), ("tcp", "localhost:443"), or ("tcp", "100.101.102.103:443") — any addr with a non-empty host component.
Common situations: Reusing a listen address string from a standard net.Listen config (commonly "0.0.0.0:443") ; templates that always join a host; copying the Funnel example but adding the node's IP.
Related errors
- tsnet.ListenPacket(%q, %q): address must be a valid IP
- ListenTLS(%q, %q): only tcp is supported
- ListenFunnel(%q, %q): only tcp is supported
- cleaning config changes: %w
- ts Server is not running
AI-assisted analysis of tailscale/tailscale@57c3357fdb (2026-08-18).
Data as JSON: /api/errors/97f4ef76b935122e.
Report an issue: GitHub.