Files
headscale/hscontrol/policy/v2/utils.go
T
Kristoffer Dalby 4cca63155d all: apply godoc [Name] link conventions across comments
Every Go-identifier reference in // and /* */ comments now uses
godoc's [Name] linking syntax so pkg.go.dev and `go doc` render
them as clickable cross-references. No behaviour change.

Pattern applied across the tree:
  In-package         [Foo], [Foo.Bar]
  Cross-package      [pkg.Foo], [pkg.Foo.Bar]
  Stdlib             [netip.Prefix], [errors.Is], [context.Context]
  Tailscale          [tailcfg.MapResponse], [tailcfg.Node.CapMap],
                     [tailcfg.NodeAttrSuggestExitNode]

Skip rules:
  - File:line refs left as plain text
  - HuJSON wire keys inside backtick raw strings untouched
  - ACL/policy syntax tokens (tag:foo, autogroup:self, ...) not Go
    symbols, left as plain text
  - JSON/OIDC wire keys, gorm tags, RFC IPv6 placeholders, markdown
    link tags, decorative dividers — all left as-is
2026-05-19 09:55:22 +02:00

151 lines
4.3 KiB
Go

package v2
import (
"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, "]")
if closeBracket == -1 {
return "", "", ErrBracketsNotIPv6
}
host := input[1:closeBracket]
addr, err := netip.ParseAddr(host)
if err != nil || !addr.Is6() {
return "", "", fmt.Errorf("%w: %q", ErrBracketsNotIPv6, host)
}
rest := input[closeBracket+1:]
if len(rest) == 0 || (rest[0] != ':' && rest[0] != '/') {
return "", "", fmt.Errorf("%w: %q", ErrBracketsNotIPv6, input)
}
// Strip brackets: "[addr]:port" → "addr:port",
// "[addr]/prefix:port" → "addr/prefix:port".
input = host + rest
}
// Find the last occurrence of the colon character
lastColonIndex := strings.LastIndex(input, ":")
// Check if the colon character is present and not at the beginning or end of the string
if lastColonIndex == -1 {
return "", "", ErrInputMissingColon
}
if lastColonIndex == 0 {
return "", "", ErrInputStartsWithColon
}
if lastColonIndex == len(input)-1 {
return "", "", ErrInputEndsWithColon
}
// Split the string into destination and port based on the last colon
destination := input[:lastColonIndex]
port := input[lastColonIndex+1:]
return destination, port, nil
}
// parsePortRange parses a port definition string and returns a slice of [tailcfg.PortRange] structs.
func parsePortRange(portDef string) ([]tailcfg.PortRange, error) {
if portDef == "*" {
return []tailcfg.PortRange{tailcfg.PortRangeAny}, nil
}
var portRanges []tailcfg.PortRange
parts := strings.SplitSeq(portDef, ",")
for part := range parts {
if strings.Contains(part, "-") {
rangeParts := strings.Split(part, "-")
rangeParts = slices.DeleteFunc(rangeParts, func(e string) bool {
return e == ""
})
if len(rangeParts) != 2 {
return nil, ErrInvalidPortRangeFormat
}
first, err := parsePort(rangeParts[0])
if err != nil {
return nil, err
}
last, err := parsePort(rangeParts[1])
if err != nil {
return nil, err
}
if first > last {
return nil, ErrPortRangeInverted
}
portRanges = append(portRanges, tailcfg.PortRange{First: first, Last: last})
} else {
port, err := parsePort(part)
if err != nil {
return nil, err
}
if port < 1 {
return nil, ErrPortMustBePositive
}
portRanges = append(portRanges, tailcfg.PortRange{First: port, Last: port})
}
}
return portRanges, nil
}
// parsePort parses a single port number from a string.
func parsePort(portStr string) (uint16, error) {
port, err := strconv.Atoi(portStr)
if err != nil {
return 0, ErrInvalidPortNumber
}
if port < 0 || port > 65535 {
return 0, ErrPortNumberOutOfRange
}
return uint16(port), nil
}