net: support IPv6 in the host netdev and TCP/UDP plumbing

Make the net package address-family aware instead of IPv4-only: DialTCP,
listenTCP and DialUDP now choose AF_INET or AF_INET6 from the target address
(socketFamily), the "only ipv4 supported" guard is replaced by a 4-or-16 byte
check, and "tcp6"/"udp6" network names are accepted.

In the host netdev, sockaddrFromParts builds a SockaddrInet6 for IPv6 addresses
(SockaddrInet4 otherwise), Accept decodes both families, GetHostByName and the
/etc/hosts lookup accept IPv6, and the stub resolver now queries AAAA after A.
Name resolution still prefers IPv4, so the "4"/"6" suffix is advisory for host
names; this is documented on Dial/Listen. Link-local IPv6 zones are not mapped
to a scope id.

Verified on linux/amd64 with tinygo: IPv6 loopback Listen/Accept/Dial over
[::1], AAAA fallback for an IPv6-only host name, and IPv4 behaviour unchanged.
This commit is contained in:
Moses Narrow
2026-06-23 18:19:40 -05:00
committed by Ron Evans
parent 7bf471a0a9
commit 2be2e34090
5 changed files with 105 additions and 53 deletions
+9 -6
View File
@@ -101,8 +101,11 @@ type Dialer struct {
// See Go "net" package Dial() for more information.
//
// Note: Tinygo Dial supports a subset of networks supported by Go Dial,
// specifically: "tcp", "tcp4", "udp", and "udp4". IP and unix networks are
// not supported.
// specifically: "tcp", "tcp4", "tcp6", "udp", "udp4", and "udp6". IP and unix
// networks are not supported. IPv6 addresses are supported, but when dialing a
// host name the resolver prefers an IPv4 (A) address and only falls back to
// IPv6 (AAAA), so the "4"/"6" suffix does not force the address family for name
// resolution.
func Dial(network, address string) (Conn, error) {
var d Dialer
return d.Dial(network, address)
@@ -157,13 +160,13 @@ func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn
// TINYGO: Ignoring context
switch network {
case "tcp", "tcp4":
case "tcp", "tcp4", "tcp6":
raddr, err := ResolveTCPAddr(network, address)
if err != nil {
return nil, err
}
return DialTCP(network, nil, raddr)
case "udp", "udp4":
case "udp", "udp4", "udp6":
raddr, err := ResolveUDPAddr(network, address)
if err != nil {
return nil, err
@@ -271,12 +274,12 @@ func parseNetwork(ctx context.Context, network string, needsProto bool) (afnet s
// See Go "net" package Listen() for more information.
//
// Note: Tinygo Listen supports a subset of networks supported by Go Listen,
// specifically: "tcp", "tcp4". "tcp6" and unix networks are not supported.
// specifically: "tcp", "tcp4", and "tcp6". unix networks are not supported.
func Listen(network, address string) (Listener, error) {
// println("Listen", address)
switch network {
case "tcp", "tcp4":
case "tcp", "tcp4", "tcp6":
default:
return nil, fmt.Errorf("Network %s not supported", network)
}
+11
View File
@@ -10,6 +10,7 @@ import (
const (
_AF_INET = 0x2
_AF_INET6 = 0xa
_SOCK_STREAM = 0x1
_SOCK_DGRAM = 0x2
_SOL_SOCKET = 0x1
@@ -36,6 +37,16 @@ func useNetdev(dev netdever) {
netdev = dev
}
// socketFamily returns the address family (_AF_INET or _AF_INET6) to use for a
// socket targeting ip. A nil/zero-length IP (e.g. a wildcard listen address)
// defaults to IPv4.
func socketFamily(ip IP) int {
if len(ip) == 16 && ip.To4() == nil {
return _AF_INET6
}
return _AF_INET
}
// netdever is TinyGo's OSI L3/L4 network/transport layer interface. Network
// drivers implement the netdever interface, providing a common network L3/L4
// interface to TinyGo's "net" package. net.Conn implementations (TCPConn,
+74 -38
View File
@@ -138,8 +138,11 @@ func (*hostNetdev) Accept(sockfd int) (int, netip.AddrPort, error) {
return -1, netip.AddrPort{}, err
}
var raddr netip.AddrPort
if sa4, ok := sa.(*syscall.SockaddrInet4); ok {
raddr = netip.AddrPortFrom(netip.AddrFrom4(sa4.Addr), uint16(sa4.Port))
switch s := sa.(type) {
case *syscall.SockaddrInet4:
raddr = netip.AddrPortFrom(netip.AddrFrom4(s.Addr), uint16(s.Port))
case *syscall.SockaddrInet6:
raddr = netip.AddrPortFrom(netip.AddrFrom16(s.Addr), uint16(s.Port))
}
return nfd, raddr, nil
}
@@ -243,17 +246,21 @@ func toInt(value interface{}) int {
}
}
// sockaddr builds a SockaddrInet4 from an AddrPort, mapping an invalid/zero
// address to 0.0.0.0 (the wildcard used when binding).
func sockaddr(ip netip.AddrPort) *syscall.SockaddrInet4 {
// sockaddr builds a syscall.Sockaddr from an AddrPort: a SockaddrInet6 for an
// IPv6 address, otherwise a SockaddrInet4 (an invalid/zero address maps to the
// 0.0.0.0 wildcard used when binding).
func sockaddr(ip netip.AddrPort) syscall.Sockaddr {
return sockaddrFromParts(ip.Addr(), ip.Port())
}
func sockaddrFromParts(addr netip.Addr, port uint16) *syscall.SockaddrInet4 {
func sockaddrFromParts(addr netip.Addr, port uint16) syscall.Sockaddr {
// As4/As16 panic on a wrongly-sized address, so dispatch on the family.
if addr = addr.Unmap(); addr.Is6() {
// Link-local zones (%zone) are not resolved to a scope id.
return &syscall.SockaddrInet6{Port: int(port), Addr: addr.As16()}
}
sa := &syscall.SockaddrInet4{Port: int(port)}
// As4 panics on a non-4-byte address; only an IPv4 address is valid here.
// A zero/unspecified address leaves Addr as 0.0.0.0 (the bind wildcard).
if addr = addr.Unmap(); addr.Is4() {
if addr.Is4() {
sa.Addr = addr.As4()
}
return sa
@@ -289,11 +296,7 @@ func hostLookup(name string) (netip.Addr, error) {
// IP literal?
if addr, err := netip.ParseAddr(name); err == nil {
addr = addr.Unmap()
if !addr.Is4() {
return netip.Addr{}, &DNSError{Err: "only IPv4 is supported", Name: name}
}
return addr, nil
return addr.Unmap(), nil
}
// /etc/hosts
@@ -309,12 +312,15 @@ func hostLookup(name string) (netip.Addr, error) {
return dnsLookup(name)
}
// lookupStaticHost scans /etc/hosts for an IPv4 address matching name.
// lookupStaticHost scans /etc/hosts for an address matching name, preferring an
// IPv4 match but falling back to IPv6.
func lookupStaticHost(name string) (netip.Addr, bool) {
data, err := os.ReadFile("/etc/hosts")
if err != nil {
return netip.Addr{}, false
}
var v6 netip.Addr
var haveV6 bool
for _, line := range strings.Split(string(data), "\n") {
if i := strings.IndexByte(line, '#'); i >= 0 {
line = line[:i]
@@ -324,16 +330,22 @@ func lookupStaticHost(name string) (netip.Addr, bool) {
continue
}
addr, err := netip.ParseAddr(fields[0])
if err != nil || !addr.Unmap().Is4() {
if err != nil {
continue
}
addr = addr.Unmap()
for _, h := range fields[1:] {
if strings.EqualFold(h, name) {
return addr.Unmap(), true
if addr.Is4() {
return addr, true
}
if !haveV6 {
v6, haveV6 = addr, true
}
}
}
}
return netip.Addr{}, false
return v6, haveV6
}
// resolvConfServers returns the nameservers from /etc/resolv.conf as
@@ -359,13 +371,29 @@ func resolvConfServers() []string {
return servers
}
// dnsLookup resolves name to an IPv4 address by querying the system
// nameservers over UDP.
const (
dnsTypeA = 1
dnsTypeAAAA = 28
)
// dnsLookup resolves name by querying the system nameservers over UDP,
// preferring an IPv4 (A) answer and falling back to IPv6 (AAAA).
func dnsLookup(name string) (netip.Addr, error) {
id, query := buildDNSQuery(name)
if addr, err := dnsLookupType(name, dnsTypeA); err == nil {
return addr, nil
}
if addr, err := dnsLookupType(name, dnsTypeAAAA); err == nil {
return addr, nil
}
return netip.Addr{}, &DNSError{Err: "no address found", Name: name}
}
// dnsLookupType resolves name for a single DNS record type (A or AAAA).
func dnsLookupType(name string, qtype uint16) (netip.Addr, error) {
id, query := buildDNSQuery(name, qtype)
var lastErr error
for _, server := range resolvConfServers() {
addr, err := dnsQuery(server, query, id)
addr, err := dnsQuery(server, query, id, qtype)
if err == nil {
return addr, nil
}
@@ -374,7 +402,7 @@ func dnsLookup(name string) (netip.Addr, error) {
if lastErr == nil {
lastErr = &DNSError{Err: "no answer", Name: name}
}
return netip.Addr{}, &DNSError{Err: lastErr.Error(), Name: name}
return netip.Addr{}, lastErr
}
// dnsID derives a non-secret 16-bit query ID. It need not be cryptographically
@@ -383,9 +411,10 @@ func dnsID() uint16 {
return uint16(time.Now().UnixNano())
}
// buildDNSQuery builds a standard recursive A-record query for name and returns
// it together with the query ID, so the reply can be matched against it.
func buildDNSQuery(name string) (uint16, []byte) {
// buildDNSQuery builds a standard recursive query for name of the given record
// type and returns it together with the query ID, so the reply can be matched
// against it.
func buildDNSQuery(name string, qtype uint16) (uint16, []byte) {
id := dnsID()
msg := []byte{
byte(id >> 8), byte(id), // ID
@@ -404,15 +433,15 @@ func buildDNSQuery(name string) (uint16, []byte) {
}
msg = append(msg, 0x00) // root label
msg = append(msg,
0x00, 0x01, // QTYPE = A
byte(qtype>>8), byte(qtype), // QTYPE
0x00, 0x01, // QCLASS = IN
)
return id, msg
}
// dnsQuery sends query to server (an IPv4 string) on port 53 and returns the
// first A record from the response that matches id.
func dnsQuery(server string, query []byte, id uint16) (netip.Addr, error) {
// first record of type qtype from the response that matches id.
func dnsQuery(server string, query []byte, id uint16, qtype uint16) (netip.Addr, error) {
srv, err := netip.ParseAddr(server)
if err != nil {
return netip.Addr{}, err
@@ -441,12 +470,12 @@ func dnsQuery(server string, query []byte, id uint16) (netip.Addr, error) {
if err != nil {
return netip.Addr{}, err
}
return parseDNSResponse(resp[:n], id)
return parseDNSResponse(resp[:n], id, qtype)
}
// parseDNSResponse extracts the first A record from a DNS response message,
// after validating that it is a non-error reply to query id.
func parseDNSResponse(msg []byte, id uint16) (netip.Addr, error) {
// parseDNSResponse extracts the first record of type qtype from a DNS response
// message, after validating that it is a non-error reply to query id.
func parseDNSResponse(msg []byte, id uint16, qtype uint16) (netip.Addr, error) {
if len(msg) < 12 {
return netip.Addr{}, &DNSError{Err: "short DNS response"}
}
@@ -488,14 +517,21 @@ func parseDNSResponse(msg []byte, id uint16) (netip.Addr, error) {
if off+rdlength > len(msg) {
return netip.Addr{}, &DNSError{Err: "malformed DNS rdata"}
}
if rrtype == 1 && rdlength == 4 { // A record
return netip.AddrFrom4([4]byte{
msg[off], msg[off+1], msg[off+2], msg[off+3],
}), nil
if rrtype == int(qtype) {
if qtype == dnsTypeA && rdlength == 4 {
var b [4]byte
copy(b[:], msg[off:off+4])
return netip.AddrFrom4(b), nil
}
if qtype == dnsTypeAAAA && rdlength == 16 {
var b [16]byte
copy(b[:], msg[off:off+16])
return netip.AddrFrom16(b), nil
}
}
off += rdlength
}
return netip.Addr{}, &DNSError{Err: "no A record in DNS response", IsNotFound: true}
return netip.Addr{}, &DNSError{Err: "no matching record in DNS response", IsNotFound: true}
}
// skipName advances past a (possibly compressed) DNS name and returns the
+6 -6
View File
@@ -94,7 +94,7 @@ func (a *TCPAddr) opAddr() Addr {
func ResolveTCPAddr(network, address string) (*TCPAddr, error) {
switch network {
case "tcp", "tcp4":
case "tcp", "tcp4", "tcp6":
default:
return nil, fmt.Errorf("Network '%s' not supported", network)
}
@@ -161,7 +161,7 @@ type TCPConn struct {
func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error) {
switch network {
case "tcp", "tcp4":
case "tcp", "tcp4", "tcp6":
default:
return nil, errors.New("Network not supported: '" + network + "'")
}
@@ -174,11 +174,11 @@ func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error) {
if raddr.IP.IsUnspecified() {
return nil, errors.New("Sorry, localhost isn't available on Tinygo")
} else if len(raddr.IP) != 4 {
return nil, errors.New("only ipv4 supported")
} else if len(raddr.IP) != 4 && len(raddr.IP) != 16 {
return nil, errors.New("invalid IP address")
}
fd, err := netdev.Socket(_AF_INET, _SOCK_STREAM, _IPPROTO_TCP)
fd, err := netdev.Socket(socketFamily(raddr.IP), _SOCK_STREAM, _IPPROTO_TCP)
if err != nil {
return nil, err
}
@@ -379,7 +379,7 @@ func (l *listener) Addr() Addr {
}
func listenTCP(laddr *TCPAddr) (Listener, error) {
fd, err := netdev.Socket(_AF_INET, _SOCK_STREAM, _IPPROTO_TCP)
fd, err := netdev.Socket(socketFamily(laddr.IP), _SOCK_STREAM, _IPPROTO_TCP)
if err != nil {
return nil, err
}
+5 -3
View File
@@ -83,7 +83,7 @@ func (a *UDPAddr) opAddr() Addr {
func ResolveUDPAddr(network, address string) (*UDPAddr, error) {
switch network {
case "udp", "udp4":
case "udp", "udp4", "udp6":
default:
return nil, fmt.Errorf("Network '%s' not supported", network)
}
@@ -156,7 +156,7 @@ func ephemeralPort() int {
// local system is assumed.
func DialUDP(network string, laddr, raddr *UDPAddr) (*UDPConn, error) {
switch network {
case "udp", "udp4":
case "udp", "udp4", "udp6":
default:
return nil, fmt.Errorf("Network '%s' not supported", network)
}
@@ -173,6 +173,8 @@ func DialUDP(network string, laddr, raddr *UDPAddr) (*UDPConn, error) {
if raddr.IP.IsUnspecified() {
return nil, fmt.Errorf("Sorry, localhost isn't available on Tinygo")
} else if len(raddr.IP) != 4 && len(raddr.IP) != 16 {
return nil, fmt.Errorf("invalid IP address")
}
// If no port was given, grab an ephemeral port
@@ -180,7 +182,7 @@ func DialUDP(network string, laddr, raddr *UDPAddr) (*UDPConn, error) {
laddr.Port = ephemeralPort()
}
fd, err := netdev.Socket(_AF_INET, _SOCK_DGRAM, _IPPROTO_UDP)
fd, err := netdev.Socket(socketFamily(raddr.IP), _SOCK_DGRAM, _IPPROTO_UDP)
if err != nil {
return nil, err
}