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 = listenOnFunnel

View on GitHub (pinned to 57c3357fdb)

Solutions

  1. Drop the host: s.ListenFunnel("tcp", ":443").
  2. Sanitize configured addresses by stripping the host before calling ListenFunnel (keep only the port).
  3. 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

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


AI-assisted analysis of tailscale/tailscale@57c3357fdb (2026-08-18). Data as JSON: /api/errors/97f4ef76b935122e. Report an issue: GitHub.