juanfont/headscale · error · ErrInvalidPortNumber

invalid first integer

Error message

invalid first integer

What it means

ErrInvalidPortNumber is returned by parsePort (hscontrol/policy/v2/utils.go:142) when a port token is not a parseable integer at all — strconv.Atoi fails. This catches service names ("http", "ssh"), alphabetic strings, trailing units ("8080tcp"), and other non-numeric garbage in the port position. Note the sentinel text says 'invalid first integer' but it applies to any port token, first or last.

Solutions

  1. Replace the service name with its numeric port (http -> 80, ssh -> 22, https -> 443)
  2. Remove any non-numeric suffixes/characters from the token
  3. Check range endpoints too — "80-https" fails with the same error

Example fix

// before
"dst": ["tag:web:http"]
// after
"dst": ["tag:web:80"]
Defensive patterns

Strategy: validation

Validate before calling

func portNumeric(tok string) bool {
    for _, p := range strings.Split(tok, ",") {
        for _, q := range strings.Split(p, "-") {
            if q == "" || q == "*" { continue }
            if _, err := strconv.Atoi(q); err != nil { return false }
        }
    }
    return true
}

Try / catch

if errors.Is(err, policyv2.ErrInvalidPortNumber) {
    // replace service names (http/ssh) with numeric ports; strip stray characters
}

Prevention

When it happens

Trigger: Dst port sections like "host:http", "host:ssh-443", or "host:8o" (letter o). Raised when strconv.Atoi(portStr) errors inside parsePort, reached for single ports and both range endpoints.

Common situations: Writing IANA service names instead of numbers (headscale requires numeric ports); OCR/typing errors (letter l vs 1, O vs 0); copy-pasting from docs that use names; localized keyboards producing odd digits.

Related errors


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/9d1b9dcb3ddefa9e. Report an issue: GitHub.

Appendix: source

Thrown at hscontrol/policy/v2/utils.go:22

	"errors"
	"fmt"
	"net/netip"
	"slices"
	"strconv"
	"strings"

	"tailscale.com/tailcfg"
)

// Port parsing errors.
var (
	ErrInputMissingColon      = errors.New("input must contain a colon character separating destination and port")
	ErrInputStartsWithColon   = errors.New("input cannot start with a colon character")
	ErrInputEndsWithColon     = errors.New("input cannot end with a colon character")
	ErrInvalidPortRangeFormat = errors.New("invalid port range format")
	ErrPortRangeInverted      = errors.New("invalid port range: first port is greater than last port")
	ErrPortMustBePositive     = errors.New("first port must be >0, or use '*' for wildcard")
	ErrInvalidPortNumber      = errors.New("invalid first integer")
	ErrPortNumberOutOfRange   = errors.New("port number out of range")
	ErrBracketsNotIPv6        = errors.New("square brackets are only valid around IPv6 addresses")
)

// splitDestinationAndPort takes an input string and returns the destination and port as a tuple, or an error if the input is invalid.
// It supports two bracketed IPv6 forms:
//   - "[addr]:port" (RFC 3986, e.g. "[::1]:80")
//   - "[addr]/prefix:port" (e.g. "[fd7a::1]/128:80,443")
//
// Brackets are only accepted around IPv6 addresses, not IPv4, hostnames, or other alias types.
// Bracket stripping reduces both forms to bare "addr:port" or "addr/prefix:port",
// which the normal [strings.LastIndex] of ":" split handles correctly because
// port strings never contain colons.
func splitDestinationAndPort(input string) (string, string, error) {
	// Handle RFC 3986 bracketed IPv6 (e.g. "[::1]:80" or "[fd7a::1]/128:80,443").
	// Strip brackets after validation and fall through to normal parsing.
	if strings.HasPrefix(input, "[") {
		closeBracket := strings.Index(input, "]")

View on GitHub (pinned to 565fd254d0)