From 6f2563f72246dc59e8b019da04377f72db874dc0 Mon Sep 17 00:00:00 2001 From: Moses Narrow <36607567+0pcom@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:43:38 -0500 Subject: [PATCH] net: add default host netdev backed by raw Linux syscalls On the native linux target the net package defaulted to a NOP netdev that returns "Netdev not set" for every operation, so net.Dial/Listen, DNS and anything built on them (net/http, tls) failed at runtime. Native linux does not override the syscall package, and the TinyGo compiler lowers syscall.Syscall/RawSyscall into real system calls, so the standard library socket functions work directly (musl's omitted network module is not needed). Register a default netdev implementing the netdever interface on top of syscall.Socket/Connect/Bind/Listen/Accept/Send/Recv/SetSockOpt, plus a small UDP DNS resolver (/etc/hosts, /etc/resolv.conf) for GetHostByName. Also restore the net.DNSError type used by resolution errors. Blocking sockets under the threads scheduler; IPv4 only. This avoids the internal/poll netpoller dependency that blocked the upstream-net approach. Refs tinygo-org/net#28 --- lookup.go | 39 ++++ netdev_native.go | 519 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 558 insertions(+) create mode 100644 netdev_native.go diff --git a/lookup.go b/lookup.go index 4eabf77..a85fa5d 100644 --- a/lookup.go +++ b/lookup.go @@ -17,3 +17,42 @@ import ( func LookupPort(network, service string) (port int, err error) { return 0, errors.New("net:LookupPort not implemented") } + +// DNSError represents a DNS lookup error. +// +// TINYGO: Trimmed copy of the standard library's net.DNSError so that name +// resolution errors keep a familiar, type-assertable shape. +type DNSError struct { + UnwrapErr error // error returned by the [DNSError.Unwrap] method, might be nil + Err string // description of the error + Name string // name looked for + Server string // server used + IsTimeout bool // if true, timed out; not all timeouts set this + IsTemporary bool // if true, error is temporary; not all errors set this + + // IsNotFound is set to true when the requested name does not + // contain any records of the requested type (data not found), + // or the name itself was not found (NXDOMAIN). + IsNotFound bool +} + +// Unwrap returns e.UnwrapErr. +func (e *DNSError) Unwrap() error { return e.UnwrapErr } + +func (e *DNSError) Error() string { + if e == nil { + return "" + } + s := "lookup " + e.Name + if e.Server != "" { + s += " on " + e.Server + } + s += ": " + e.Err + return s +} + +// Timeout reports whether the DNS lookup is known to have timed out. +func (e *DNSError) Timeout() bool { return e.IsTimeout } + +// Temporary reports whether the DNS error is known to be temporary. +func (e *DNSError) Temporary() bool { return e.IsTimeout || e.IsTemporary } diff --git a/netdev_native.go b/netdev_native.go new file mode 100644 index 0000000..202072b --- /dev/null +++ b/netdev_native.go @@ -0,0 +1,519 @@ +//go:build linux && !baremetal && !nintendoswitch && !wasm_unknown && !tinygo.wasm + +// TINYGO: Native (host) netdev for the TinyGo "linux" target. +// +// On the native linux target TinyGo does NOT override the "syscall" package, so +// the standard library's syscall.Socket/Connect/Bind/... are available and the +// TinyGo compiler lowers syscall.Syscall/RawSyscall into real inline-asm system +// calls (see compiler/syscall.go). That means we can implement the netdever +// interface directly on top of raw Linux sockets, without needing a network +// driver or musl's (omitted) src/network module. +// +// This file registers that implementation as the default netdev, so that +// net.Dial/Listen/Lookup just work on a regular Linux host. See +// https://github.com/skycoin/skycoin/issues/2902. + +package net + +import ( + "io" + "net/netip" + "os" + "strings" + "syscall" + "time" +) + +// Register the host netdev as the default. A network driver (e.g. on a board +// that also reports GOOS=linux, which doesn't happen today) could still replace +// it by calling useNetdev() from its own init/setup. +func init() { + useNetdev(&hostNetdev{}) +} + +// hostNetdev implements netdever using raw Linux sockets via the syscall +// package. The "sockfd" values it returns are plain OS file descriptors. +// +// Deadlines are implemented with the per-socket SO_RCVTIMEO/SO_SNDTIMEO +// options rather than a runtime poller. As a consequence, issuing concurrent +// reads (or concurrent writes) with different deadlines on the same connection +// is not supported; this matches the typical net.Conn usage of one reader and +// one writer goroutine. +type hostNetdev struct{} + +// timeoutError is returned from Send/Recv when a deadline expires. It satisfies +// the net.Error interface so callers (e.g. net/http) can detect timeouts. +type timeoutError struct{} + +func (timeoutError) Error() string { return "i/o timeout" } +func (timeoutError) Timeout() bool { return true } +func (timeoutError) Temporary() bool { return true } + +func (*hostNetdev) GetHostByName(name string) (netip.Addr, error) { + return hostLookup(name) +} + +func (*hostNetdev) Addr() (netip.Addr, error) { + // Determine the address of the interface that would be used to reach the + // public internet by "connecting" a UDP socket (no packets are sent) and + // reading back the chosen local address. + fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, 0) + if err != nil { + return netip.Addr{}, err + } + defer syscall.Close(fd) + + err = syscall.Connect(fd, &syscall.SockaddrInet4{ + Addr: [4]byte{8, 8, 8, 8}, + Port: 53, + }) + if err != nil { + // No route to the internet; fall back to loopback. + return netip.AddrFrom4([4]byte{127, 0, 0, 1}), nil + } + + sa, err := syscall.Getsockname(fd) + if err != nil { + return netip.Addr{}, err + } + if sa4, ok := sa.(*syscall.SockaddrInet4); ok { + return netip.AddrFrom4(sa4.Addr), nil + } + return netip.AddrFrom4([4]byte{127, 0, 0, 1}), nil +} + +func (*hostNetdev) Socket(domain, stype, protocol int) (int, error) { + // _IPPROTO_TLS is a made-up protocol used by net.DialTLS on devices with an + // offloaded TLS stack. The host has no such offload (TLS is done in Go via + // crypto/tls over a plain TCP conn), so treat it as an ordinary TCP socket. + if protocol == _IPPROTO_TLS { + protocol = syscall.IPPROTO_TCP + } + + fd, err := syscall.Socket(domain, stype, protocol) + if err != nil { + return -1, err + } + + // Allow quick rebind of listening sockets (e.g. restarting a server), + // matching the standard library's behaviour. + if stype == syscall.SOCK_STREAM { + syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) + } + + return fd, nil +} + +func (*hostNetdev) Bind(sockfd int, ip netip.AddrPort) error { + return syscall.Bind(sockfd, sockaddr(ip)) +} + +func (n *hostNetdev) Connect(sockfd int, host string, ip netip.AddrPort) error { + addr := ip.Addr() + if !addr.IsValid() || addr.IsUnspecified() { + // net.DialTLS passes the host name with a zero IP; resolve it here. + resolved, err := n.GetHostByName(host) + if err != nil { + return err + } + addr = resolved + } + sa := sockaddrFromParts(addr, ip.Port()) + for { + err := syscall.Connect(sockfd, sa) + if err == syscall.EINTR { + continue + } + return err + } +} + +func (*hostNetdev) Listen(sockfd int, backlog int) error { + return syscall.Listen(sockfd, backlog) +} + +func (*hostNetdev) Accept(sockfd int) (int, netip.AddrPort, error) { + nfd, sa, err := syscall.Accept(sockfd) + if err != nil { + 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)) + } + return nfd, raddr, nil +} + +func (*hostNetdev) Send(sockfd int, buf []byte, flags int, deadline time.Time) (int, error) { + // net.Conn.Write must send the whole buffer (or report an error), so loop + // over short writes. The send timeout is (re)programmed each iteration so a + // deadline bounds the whole operation, not each individual write. + total := 0 + for total < len(buf) { + if expired(deadline) { + return total, timeoutError{} + } + if err := setSockTimeout(sockfd, syscall.SO_SNDTIMEO, deadline); err != nil { + return total, err + } + n, err := syscall.Write(sockfd, buf[total:]) + if err != nil { + if err == syscall.EINTR { + continue + } + if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK { + return total, timeoutError{} + } + return total, err + } + if n <= 0 { + break + } + total += n + } + return total, nil +} + +func (*hostNetdev) Recv(sockfd int, buf []byte, flags int, deadline time.Time) (int, error) { + if expired(deadline) { + return 0, timeoutError{} + } + if err := setSockTimeout(sockfd, syscall.SO_RCVTIMEO, deadline); err != nil { + return 0, err + } + + for { + n, err := syscall.Read(sockfd, buf) + if err != nil { + if err == syscall.EINTR { + continue + } + if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK { + return 0, timeoutError{} + } + return n, err + } + // A read of 0 bytes on a stream socket means the peer closed the + // connection. (A zero-length UDP datagram would also report EOF here; + // the fd's socket type isn't tracked, but the net package only does + // connected-UDP reads where this is not a practical concern.) + if n == 0 && len(buf) > 0 { + return 0, io.EOF + } + return n, nil + } +} + +func (*hostNetdev) Close(sockfd int) error { + return syscall.Close(sockfd) +} + +func (*hostNetdev) SetSockOpt(sockfd int, level int, opt int, value interface{}) error { + // SO_LINGER takes a struct linger; the net package passes it the linger + // seconds as an int. + if level == syscall.SOL_SOCKET && opt == syscall.SO_LINGER { + sec := toInt(value) + l := &syscall.Linger{} + if sec >= 0 { + l.Onoff = 1 + l.Linger = int32(sec) + } + return syscall.SetsockoptLinger(sockfd, level, opt, l) + } + return syscall.SetsockoptInt(sockfd, level, opt, toInt(value)) +} + +// toInt coerces the values the net package passes to SetSockOpt (int, bool, or +// float64 durations) into an int. +func toInt(value interface{}) int { + switch v := value.(type) { + case int: + return v + case bool: + if v { + return 1 + } + return 0 + case float64: + return int(v) + case int64: + return int(v) + default: + return 0 + } +} + +// 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 { + return sockaddrFromParts(ip.Addr(), ip.Port()) +} + +func sockaddrFromParts(addr netip.Addr, port uint16) *syscall.SockaddrInet4 { + 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() { + sa.Addr = addr.As4() + } + return sa +} + +// expired reports whether a non-zero deadline is already in the past. +func expired(deadline time.Time) bool { + return !deadline.IsZero() && !deadline.After(time.Now()) +} + +// setSockTimeout programs SO_RCVTIMEO/SO_SNDTIMEO so a blocking recv/send +// returns EAGAIN at the deadline. A zero deadline disables the timeout. +func setSockTimeout(sockfd int, opt int, deadline time.Time) error { + var tv syscall.Timeval + if !deadline.IsZero() { + d := time.Until(deadline) + if d < time.Microsecond { + d = time.Microsecond + } + tv = syscall.NsecToTimeval(d.Nanoseconds()) + } + return syscall.SetsockoptTimeval(sockfd, syscall.SOL_SOCKET, opt, &tv) +} + +// --- Name resolution -------------------------------------------------------- + +// hostLookup resolves a host name (or IP literal) to a single IPv4 address, +// consulting (in order): IP literals, /etc/hosts, then DNS. +func hostLookup(name string) (netip.Addr, error) { + if name == "" { + return netip.AddrFrom4([4]byte{0, 0, 0, 0}), nil + } + + // 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 + } + + // /etc/hosts + if addr, ok := lookupStaticHost(name); ok { + return addr, nil + } + + // Well-known fallback in case /etc/hosts is missing. + if strings.EqualFold(name, "localhost") { + return netip.AddrFrom4([4]byte{127, 0, 0, 1}), nil + } + + return dnsLookup(name) +} + +// lookupStaticHost scans /etc/hosts for an IPv4 address matching name. +func lookupStaticHost(name string) (netip.Addr, bool) { + data, err := os.ReadFile("/etc/hosts") + if err != nil { + return netip.Addr{}, false + } + for _, line := range strings.Split(string(data), "\n") { + if i := strings.IndexByte(line, '#'); i >= 0 { + line = line[:i] + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + addr, err := netip.ParseAddr(fields[0]) + if err != nil || !addr.Unmap().Is4() { + continue + } + for _, h := range fields[1:] { + if strings.EqualFold(h, name) { + return addr.Unmap(), true + } + } + } + return netip.Addr{}, false +} + +// resolvConfServers returns the nameservers from /etc/resolv.conf as +// "ip:53" strings, defaulting to localhost if the file is missing/empty. +func resolvConfServers() []string { + var servers []string + if data, err := os.ReadFile("/etc/resolv.conf"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if i := strings.IndexByte(line, '#'); i >= 0 { + line = line[:i] + } + fields := strings.Fields(line) + if len(fields) >= 2 && fields[0] == "nameserver" { + if addr, err := netip.ParseAddr(fields[1]); err == nil && addr.Unmap().Is4() { + servers = append(servers, addr.Unmap().String()) + } + } + } + } + if len(servers) == 0 { + servers = []string{"127.0.0.1"} + } + return servers +} + +// dnsLookup resolves name to an IPv4 address by querying the system +// nameservers over UDP. +func dnsLookup(name string) (netip.Addr, error) { + id, query := buildDNSQuery(name) + var lastErr error + for _, server := range resolvConfServers() { + addr, err := dnsQuery(server, query, id) + if err == nil { + return addr, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = &DNSError{Err: "no answer", Name: name} + } + return netip.Addr{}, &DNSError{Err: lastErr.Error(), Name: name} +} + +// dnsID derives a non-secret 16-bit query ID. It need not be cryptographically +// random for a stub resolver on a trusted link; it just disambiguates replies. +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) { + id := dnsID() + msg := []byte{ + byte(id >> 8), byte(id), // ID + 0x01, 0x00, // flags: recursion desired + 0x00, 0x01, // QDCOUNT + 0x00, 0x00, // ANCOUNT + 0x00, 0x00, // NSCOUNT + 0x00, 0x00, // ARCOUNT + } + for _, label := range strings.Split(strings.TrimSuffix(name, "."), ".") { + if len(label) == 0 || len(label) > 63 { + continue + } + msg = append(msg, byte(len(label))) + msg = append(msg, label...) + } + msg = append(msg, 0x00) // root label + msg = append(msg, + 0x00, 0x01, // QTYPE = A + 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) { + srv, err := netip.ParseAddr(server) + if err != nil { + return netip.Addr{}, err + } + + fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, 0) + if err != nil { + return netip.Addr{}, err + } + defer syscall.Close(fd) + + if err := syscall.Connect(fd, &syscall.SockaddrInet4{Addr: srv.As4(), Port: 53}); err != nil { + return netip.Addr{}, err + } + + // Bound how long we wait for a reply. + tv := syscall.NsecToTimeval((5 * time.Second).Nanoseconds()) + syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, &tv) + + if _, err := syscall.Write(fd, query); err != nil { + return netip.Addr{}, err + } + + resp := make([]byte, 512) + n, err := syscall.Read(fd, resp) + if err != nil { + return netip.Addr{}, err + } + return parseDNSResponse(resp[:n], id) +} + +// 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) { + if len(msg) < 12 { + return netip.Addr{}, &DNSError{Err: "short DNS response"} + } + // Match the reply to our query and check it is a response, not an error. + if uint16(msg[0])<<8|uint16(msg[1]) != id { + return netip.Addr{}, &DNSError{Err: "DNS response ID mismatch"} + } + if msg[2]&0x80 == 0 { + return netip.Addr{}, &DNSError{Err: "DNS reply is not a response"} + } + switch rcode := msg[3] & 0x0f; rcode { + case 0: // NOERROR + case 3: // NXDOMAIN + return netip.Addr{}, &DNSError{Err: "host not found", IsNotFound: true} + default: + return netip.Addr{}, &DNSError{Err: "DNS server error"} + } + qdcount := int(msg[4])<<8 | int(msg[5]) + ancount := int(msg[6])<<8 | int(msg[7]) + + off := 12 + // Skip the question section. + for i := 0; i < qdcount; i++ { + off = skipName(msg, off) + if off < 0 || off+4 > len(msg) { + return netip.Addr{}, &DNSError{Err: "malformed DNS question"} + } + off += 4 // QTYPE + QCLASS + } + + for i := 0; i < ancount; i++ { + off = skipName(msg, off) + if off < 0 || off+10 > len(msg) { + return netip.Addr{}, &DNSError{Err: "malformed DNS answer"} + } + rrtype := int(msg[off])<<8 | int(msg[off+1]) + rdlength := int(msg[off+8])<<8 | int(msg[off+9]) + off += 10 + 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 + } + off += rdlength + } + return netip.Addr{}, &DNSError{Err: "no A record in DNS response", IsNotFound: true} +} + +// skipName advances past a (possibly compressed) DNS name and returns the +// offset just after it, or -1 on malformed input. +func skipName(msg []byte, off int) int { + for { + if off >= len(msg) { + return -1 + } + b := int(msg[off]) + switch { + case b == 0: + return off + 1 + case b&0xc0 == 0xc0: + // Compression pointer ends the name. + return off + 2 + default: + off += 1 + b + } + } +}