diff --git a/.gitignore b/.gitignore index 801f44f..321751d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ profiles/ # Dependency directories after running `go mod vendor` vendor/ +# Wifi credentials +*.credentials # Binaries for programs and plugins *.elf diff --git a/errors.go b/errors.go index 0637e9f..284d15c 100644 --- a/errors.go +++ b/errors.go @@ -28,6 +28,7 @@ const ( // based on one or two encountered use cases, example use case included. /* - ErrUnregistered/ErrAborted // connection unregistered. i.e: ICMP client aborted during active ping, ping process returns this. + - ErrInvalidArgs // invalid func arguments i.e: different from Config since this refers to non-config arguments. usually nil values. */ ) diff --git a/examples/_import_examples/espradio-netdev/go.mod b/examples/_import_examples/espradio-netdev/go.mod new file mode 100644 index 0000000..20fb952 --- /dev/null +++ b/examples/_import_examples/espradio-netdev/go.mod @@ -0,0 +1,14 @@ +module espradio-netdev + +go 1.25.7 + +// These replace directives point to local checkouts during development. +// Remove them when using as a standalone program with published releases. +replace github.com/soypat/lneto => ../../../. + +replace tinygo.org/x/espradio => ../../../../espradio + +require ( + github.com/soypat/lneto v0.1.1-0.20260425023453-aa77403a2b32 + tinygo.org/x/espradio v0.1.0 +) diff --git a/examples/_import_examples/espradio-netdev/go.sum b/examples/_import_examples/espradio-netdev/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/examples/_import_examples/espradio-netdev/main.go b/examples/_import_examples/espradio-netdev/main.go new file mode 100644 index 0000000..ac85a7e --- /dev/null +++ b/examples/_import_examples/espradio-netdev/main.go @@ -0,0 +1,213 @@ +// Example showing how to use the ESP32 WiFi radio through lneto's netdev +// package. This mirrors the picow-netdev example and demonstrates the +// standardised DevEthernet+Netlink interface that works across hardware targets. +// +// Build and flash: +// +// tinygo flash -target xiao-esp32c3 \ +// -ldflags="-X main.ssid=YourSSID -X main.password=YourPassword" \ +// -monitor ./examples/esp32-netdev +package main + +import ( + "context" + _ "embed" + "net/netip" + "strings" + "time" + + "github.com/soypat/lneto" + "github.com/soypat/lneto/x/netdev" + "github.com/soypat/lneto/x/xnet" + "tinygo.org/x/espradio" +) + +var ( + //go:embed wifi.credentials + credentials string + // remove windows CRLF "\r\n" and trailing newline. + credentialsNormalized = strings.TrimSuffix(strings.ReplaceAll(credentials, "\r\n", "\n"), "\n") + + globConnectParams espradio.STAConfig + + poolCfg = xnet.TCPPoolConfig{ + PoolSize: 4, + QueueSize: 4, + TxBufSize: 2048, + RxBufSize: 512, + NewBackoff: func() lneto.BackoffStrategy { return backoff }, + NanoTime: func() int64 { return time.Now().UnixNano() }, + EstablishedTimeout: 5 * time.Second, + ClosingTimeout: 3 * time.Second, + } +) + +func main() { + time.Sleep(time.Second) + ssid, password, ok := strings.Cut(credentialsNormalized, "\n") + if !ok { + fail("must write newline separated ssid/password in wifi.credentials", nil) + } + globConnectParams.SSID = ssid + globConnectParams.Password = password + var dev EspDev + dev.radioConfig = espradio.Config{Logging: espradio.LogLevelError} + + // LinkConnect runs Enable+Start+Connect+StartNetDev. It must complete + // before HardwareAddr6 is called because the MAC is only readable after + // Enable() initialises the WiFi hardware. + err := dev.LinkConnect(globConnectParams) + failIfErr("wifi connect", err) + + hw, err := dev.HardwareAddr6() + failIfErr("hardware addr", err) + + var stack xnet.Netstack + err = stack.Reset(xnet.StackConfig{ + RandSeed: time.Now().UnixNano() | 1, + Hostname: "esp32-lneto", + MaxActiveTCPPorts: 4, + MaxActiveUDPPorts: 4, + ICMPQueueLimit: 1, + MTU: 1500, + PassivePeers: 4, + HardwareAddress: hw, + }, backoff, poolCfg) + failIfErr("stack reset", err) + err = stack.EnableICMP(true) + failIfErr("icmp enable", err) + dev.LinkNotify(userNotify) + var iface netdev.Interface[espradio.STAConfig] + err = iface.Init(&dev, &dev, netdev.InterfaceConfig{}) + failIfErr("init iface", err) + + var runner netdev.Runner[espradio.STAConfig] + go func() { + if err := runner.Run(context.Background(), iface, &stack, backoff); err != nil { + failIfErr("runner", err) + } + }() + + assigned, gatewayRt, subnetBits, err := stack.EnableDHCP(context.Background(), true, netip.Addr{}) + failIfErr("enable dhcp", err) + println("assigned=", assigned.String(), "gateway=", gatewayRt.String(), "subnet=", subnetBits) + + select {} +} + +// compile-time interface checks. +var _ lneto.BackoffStrategy = backoff +var _ netdev.Stack = (*xnet.Netstack)(nil) +var _ netdev.DevEthernet = (*EspDev)(nil) +var _ netdev.Netlink[espradio.STAConfig] = (*EspDev)(nil) + +func backoff(consecutiveBackoffs uint) time.Duration { + return 5 * time.Millisecond +} + +func userNotify(connected bool) (retries int, reconnectParams espradio.STAConfig) { + if !connected { + return 1, globConnectParams + } + return 0, espradio.STAConfig{} +} + +// EspDev adapts [espradio.NetDev] to [netdev.DevEthernet] and wraps the +// ESP32 WiFi bring-up sequence (Enable/Start/Connect/StartNetDev) as [netdev.Netlink]. +// +// The same struct implements both interfaces so a single pointer can be passed +// to [netdev.Interface.Init] for both the netlink and device arguments, matching +// the pattern used by the picow-netdev example. +type EspDev struct { + nd *espradio.NetDev + radioConfig espradio.Config + notifyCb netdev.NotifyCallback[espradio.STAConfig] +} + +// LinkConnect implements [netdev.Netlink]. +// Runs Enable→Start→Connect→StartNetDev. nd is nil until this returns successfully. +func (d *EspDev) LinkConnect(cfg espradio.STAConfig) error { + if err := espradio.Enable(d.radioConfig); err != nil { + return err + } + if err := espradio.Start(); err != nil { + return err + } + if err := espradio.Connect(cfg); err != nil { + return err + } + nd, err := espradio.StartNetDev() + if err != nil { + return err + } + d.nd = nd + return nil +} + +// LinkDisconnect implements [netdev.Netlink]. +func (d *EspDev) LinkDisconnect() {} + +// LinkNotify implements [netdev.Netlink]. +func (d *EspDev) LinkNotify(cb netdev.NotifyCallback[espradio.STAConfig]) { + d.notifyCb = cb +} + +// HardwareAddr6 implements [netdev.DevEthernet]. +func (d *EspDev) HardwareAddr6() ([6]byte, error) { + return d.nd.HardwareAddr6() +} + +// SendOffsetEthFrame implements [netdev.DevEthernet]. +// ESP32 has no frame prefix offset, so buf is passed directly to [espradio.NetDev.SendEthFrame]. +func (d *EspDev) SendOffsetEthFrame(buf []byte) error { + return d.nd.SendEthFrame(buf) +} + +// SetEthRecvHandler implements [netdev.DevEthernet]. +// Adapts the error-less netdev handler signature to espradio's error-returning one. +func (d *EspDev) SetEthRecvHandler(handler func(rxEthframe []byte)) { + if handler == nil { + d.nd.SetEthRecvHandler(nil) + return + } + d.nd.SetEthRecvHandler(func(pkt []byte) error { + handler(pkt) + return nil + }) +} + +// EthPoll implements [netdev.DevEthernet]. +// +// espradio.NetDev.EthPoll both pops a frame from the C ring buffer into buf +// AND synchronously calls the registered rxHandler with that frame. Returning +// the non-zero byte count here as well would trigger the Runner's "device uses +// both paths" guard. We therefore drain the ring (calling the handler) and +// return (0, 0, err) so the Runner sees only the handler-based receive path. +func (d *EspDev) EthPoll(buf []byte) (ethFrameOff, ethernetBytes int, err error) { + _, err = d.nd.EthPoll(buf) + return 0, 0, err +} + +// MaxFrameSizeAndOffset implements [netdev.DevEthernet]. +// ESP32 transmits frames with no prefix offset. +func (d *EspDev) MaxFrameSizeAndOffset() (maxFrameSize int, frameOff int) { + return d.nd.MaxFrameSize(), 0 +} + +func failIfErr(msg string, err error) { + if err != nil { + fail(msg, err) + } + println(msg, "PASS") +} + +func fail(msg string, err error) { + var errstr string + if err != nil { + errstr = err.Error() + } + for { + println("FAIL:", msg, errstr) + time.Sleep(time.Second) + } +} diff --git a/examples/_import_examples/picow-netdev/go.mod b/examples/_import_examples/picow-netdev/go.mod new file mode 100644 index 0000000..cd78555 --- /dev/null +++ b/examples/_import_examples/picow-netdev/go.mod @@ -0,0 +1,16 @@ +module piconetdev + +go 1.25.7 + +require github.com/soypat/cyw43439 v0.1.1 + +require ( + github.com/soypat/lneto v0.1.0 // indirect + github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 // indirect + github.com/tinygo-org/pio v0.2.0 // indirect + golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect +) + +// This is an example taken grom github.com/soypat/lneto +// Remove this replace directive when using as own program. +replace github.com/soypat/lneto => ../../../. \ No newline at end of file diff --git a/examples/_import_examples/picow-netdev/go.sum b/examples/_import_examples/picow-netdev/go.sum new file mode 100644 index 0000000..5dddaa6 --- /dev/null +++ b/examples/_import_examples/picow-netdev/go.sum @@ -0,0 +1,10 @@ +github.com/soypat/cyw43439 v0.1.1 h1:vcaTiVzfuz3keK7lJpVxStZ6tV8HCw7Ugzsh1k4mneE= +github.com/soypat/cyw43439 v0.1.1/go.mod h1:R2uSILRwSPmcmmKy5Z0FtK4ypgiPf5YqK+F+IKmXqxc= +github.com/soypat/lneto v0.1.0 h1:VAHCJ33hvC3wDqhM0Vm7w0k6vwNsOCAsQ8XTrXJpS7I= +github.com/soypat/lneto v0.1.0/go.mod h1:g/8Lk+hIsMZydyWDJjK2YfsCuG6jA5mWCO6U+4S7w1U= +github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710 h1:Y9fBuiR/urFY/m76+SAZTxk2xAOS2n85f+H1CugajeA= +github.com/soypat/seqs v0.0.0-20250124201400-0d65bc7c1710/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8= +github.com/tinygo-org/pio v0.2.0 h1:vo3xa6xDZ2rVtxrks/KcTZHF3qq4lyWOntvEvl2pOhU= +github.com/tinygo-org/pio v0.2.0/go.mod h1:LU7Dw00NJ+N86QkeTGjMLNkYcEYMor6wTDpTCu0EaH8= +golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= +golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= diff --git a/examples/_import_examples/picow-netdev/main.go b/examples/_import_examples/picow-netdev/main.go new file mode 100644 index 0000000..62adcfb --- /dev/null +++ b/examples/_import_examples/picow-netdev/main.go @@ -0,0 +1,177 @@ +package main + +import ( + "context" + _ "embed" + "net" + "net/netip" + "strings" + "time" + + "github.com/soypat/cyw43439" + "github.com/soypat/lneto" + "github.com/soypat/lneto/x/netdev" + "github.com/soypat/lneto/x/xnet" +) + +var ( + //go:embed wifi.credentials + credentials string + // remove windows CRLF "\r\n" and trailing newline. + credentialsNormalized = strings.TrimSuffix(strings.ReplaceAll(credentials, "\r\n", "\n"), "\n") + globConnectParams ConnectParams + poolCfg = xnet.TCPPoolConfig{ + PoolSize: 5, + QueueSize: 5, + TxBufSize: 2048, + RxBufSize: 2048, + NewBackoff: func() lneto.BackoffStrategy { return backoff }, + NanoTime: func() int64 { return time.Now().UnixNano() }, + EstablishedTimeout: 5 * time.Second, + ClosingTimeout: 3 * time.Second, + } +) + +func main() { + time.Sleep(time.Second) + ssid, password, ok := strings.Cut(credentialsNormalized, "\n") + if !ok { + fail("must write newline separated ssid/password in wifi.credentials", nil) + } + globConnectParams.SSID = ssid + globConnectParams.Passphrase = password + + dev := Netdev{ + dev: cyw43439.NewPicoWDevice(), + } + err := dev.dev.Init(cyw43439.DefaultWifiConfig()) + failIfErr("init cyw43439", err) + hw, _ := dev.HardwareAddr6() + var stack xnet.Netstack + err = stack.Reset(xnet.StackConfig{ + RandSeed: time.Now().UnixNano() | 1, + Hostname: "lneto-pico", + MaxActiveTCPPorts: 4, + MaxActiveUDPPorts: 4, + ICMPQueueLimit: 1, + MTU: 1500, + HardwareAddress: hw, + }, backoff, poolCfg) + failIfErr("stack reset", err) + err = stack.EnableICMP(true) + failIfErr("icmp enable", err) + err = dev.LinkConnect(globConnectParams) + failIfErr("wifi join", err) + + var iface netdev.Interface[ConnectParams] + var runner netdev.Runner[ConnectParams] + dev.LinkNotify(userNotify) + err = iface.Init(&dev, &dev, netdev.InterfaceConfig{}) + failIfErr("init iface", err) + + go func() { + if err := runner.Run(context.Background(), iface, &stack, backoff); err != nil { + failIfErr("runner", err) + } + }() + assigned, gatewayRt, subnetBits, err := stack.EnableDHCP(context.Background(), true, netip.Addr{}) + failIfErr("enable dhcp", err) + println("assigned=", assigned.String(), "gateway=", gatewayRt.String(), "subnet", subnetBits) + select {} +} + +// compile-time guarantee of interface implementation. +var _ lneto.BackoffStrategy = backoff +var _ netdev.Stack = (*xnet.Netstack)(nil) + +func backoff(consecutiveBackoffs uint) (sleepOrFlag time.Duration) { + return 5 * time.Millisecond +} + +func userNotify(connected bool) (retries int, connectParams ConnectParams) { + if !connected { + return 1, globConnectParams + } + return 0, ConnectParams{} +} + +var _ netdev.DevEthernet = (*Netdev)(nil) +var _ netdev.Netlink[ConnectParams] = (*Netdev)(nil) + +type Netdev struct { + dev *cyw43439.Device + notifyCb netdev.NotifyCallback[ConnectParams] +} + +type ConnectParams struct { + SSID string + cyw43439.JoinOptions +} + +func (nl *Netdev) Netflags() (flags net.Flags) { + flags |= net.FlagUp + if nl.dev.IsLinkUp() { + flags |= net.FlagRunning + } + return flags +} + +// LinkConnect implements [netdev.Netlink]. +func (nl *Netdev) LinkConnect(connectParams ConnectParams) error { + return nl.dev.Join(connectParams.SSID, connectParams.JoinOptions) +} + +// LinkDisconnect implements [netdev.Netlink]. +func (nl *Netdev) LinkDisconnect() { + // Not implemented by cyw43439 package. +} + +// LinkNotify implements [netdev.Netlink]. +func (nl *Netdev) LinkNotify(cb netdev.NotifyCallback[ConnectParams]) { + nl.notifyCb = cb +} + +// HardwareAddr6 implements [netdev.DevEthernet]. +func (d *Netdev) HardwareAddr6() ([6]byte, error) { + return d.dev.HardwareAddr6() +} + +// SendEthFrameOffset implements [netdev.DevEthernet]. +func (d *Netdev) SendOffsetEthFrame(offsetTxEthFrame []byte) error { + return d.dev.SendEth(offsetTxEthFrame) +} + +// SetRecvHandler implements [netdev.DevEthernet]. +func (d *Netdev) SetEthRecvHandler(handler func(rxEthframe []byte)) { + d.dev.RecvEthHandle(func(pkt []byte) error { + handler(pkt) + return nil + }) +} + +// EthPoll implements [netdev.DevEthernet]. +func (d *Netdev) EthPoll(buf []byte) (ethFrameOff, ethernetBytes int, err error) { + _, err = d.dev.PollOne() + return 0, 0, err +} + +// MaxFrameSizeAndOffset implements [netdev.DevEthernet]. +func (d *Netdev) MaxFrameSizeAndOffset() (maxFrameSize int, frameOff int) { + return cyw43439.MaxFrameSize, 0 +} +func failIfErr(msg string, err error) { + if err != nil { + fail(msg, err) + } + println("PASS", msg) +} +func fail(msg string, err error) { + var errstr string + if err != nil { + errstr = err.Error() + } + for { + println(msg, errstr) + time.Sleep(time.Second) + } +} diff --git a/x/netdev/gonet-tinygo.go b/x/netdev/gonet-tinygo.go new file mode 100644 index 0000000..7da4e3d --- /dev/null +++ b/x/netdev/gonet-tinygo.go @@ -0,0 +1,12 @@ +//go:build tinygo + +package netdev + +import _ "unsafe" // needed for go:linkname usage. + +// UseNetdev is the dynamic linker function +// for inserting a networking stack into the +// standard library implementation in the TinyGo compiler. +// +//go:linkname UseNetdev net.useNetdev +func UseNetdev(dev GoNet) diff --git a/x/netdev/gonet.go b/x/netdev/gonet.go new file mode 100644 index 0000000..f5b79c7 --- /dev/null +++ b/x/netdev/gonet.go @@ -0,0 +1,30 @@ +package netdev + +import ( + "net/netip" + "time" +) + +// GoNet is the networking interface expected by TinyGo compiler/standard library. +// The methods below define a networking stack as expected by the Go standard library +// when using the TinyGo compiler+stdlib. +type GoNet interface { + // GetHostByName returns the IP address of either a hostname or IPv4 + // address in standard dot notation + GetHostByName(name string) (netip.Addr, error) + + // Addr returns IP address assigned to the interface, either by + // DHCP or statically + Addr() (netip.Addr, error) + + // Berkely Sockets-like interface, Go-ified. See man page for socket(2), etc. + Socket(domain int, stype int, protocol int) (int, error) + Bind(sockfd int, ip netip.AddrPort) error + Connect(sockfd int, host string, ip netip.AddrPort) error + Listen(sockfd int, backlog int) error + Accept(sockfd int) (int, netip.AddrPort, error) + Send(sockfd int, buf []byte, flags int, deadline time.Time) (int, error) + Recv(sockfd int, buf []byte, flags int, deadline time.Time) (int, error) + Close(sockfd int) error + SetSockOpt(sockfd int, level int, opt int, value any) error +} diff --git a/x/netdev/interface.go b/x/netdev/interface.go new file mode 100644 index 0000000..655b422 --- /dev/null +++ b/x/netdev/interface.go @@ -0,0 +1,172 @@ +package netdev + +import ( + "context" + "errors" + "net/netip" + + "github.com/soypat/lneto" + "github.com/soypat/lneto/ethernet" + "github.com/soypat/lneto/internal" +) + +type Interface[C any] struct { + dev DevEthernet + netlink Netlink[C] + ip netip.Prefix + // Below are values calculated from [DevEthernet] return values to avoid recalculation. + frameSize int + frameOff int + mtu int + mac [6]byte +} + +// DevEthernet is an L2 capable device HAL. It is an abstraction +// for devices that send actively and are not polled by an external host. +// +// DevEthernet-specific initialization (WiFi join, PHY auto-negotiation, +// firmware loading) must complete BEFORE the device is used as a stack endpoint. +type DevEthernet interface { + // HardwareAddr6 returns the device's 6-byte MAC address. + // For PHY-only devices, returns the MAC provided at configuration. + HardwareAddr6() ([6]byte, error) + // SendEthFrameOffset transmits a complete Ethernet frame at offset given by [DevEthernet.MaxFrameSizeAndOffset]. + // The frame includes the Ethernet header but NOT the FCS/CRC + // trailer (device or stack handles CRC as appropriate). + // SendEthFrameOffset blocks until the transmission is queued succesfully + // or finished sending. Should not be called concurrently + // unless user is sure the driver supports it. + SendOffsetEthFrame(offsetTxEthFrame []byte) error + // SetRecvHandler registers the function called when an Ethernet + // frame is received. Buffers needed by the device to operate efficiently + // should be allocated on its side. This function is mutually exclusive with EthPoll: + // use on or the other to receive data. + SetEthRecvHandler(handler func(rxEthframe []byte)) + // EthPoll services the device. For poll-based devices (e.g. CYW43439 + // over SPI), reads from the bus and invokes the handler for each + // received frame. This method is mutually exclusive with SetEthRecvHandler: + // use one or the other to receive data but not return data via both channels. + EthPoll(buf []byte) (ethFrameOff, ethernetBytes int, err error) + // MaxFrameSizeAndOffset returns the max complete device frame size + // (including headers and any overhead) for buffer allocation. + // The second value returned is the offset at which the ethernet frame + // should be stored when being passed to [DevEthernet.SendOffsetEthFrame]. + // Buffers allocated should be maxEthernetFrameSize+frameOff where maxEthernetFrameSize + // is usually 1500 but less or equal to maxFrameSize-frameOff. + // MTU can be calculated doing: + // // mfu-(14+4+4) for: + // // ethernet header+ethernet CRC if present+ethernet VLAN overhead for VLAN support. + // mtu := dev.MaxFrameSizeAndOffset() - ethernet.MaxOverheadSize + MaxFrameSizeAndOffset() (maxFrameSize int, frameOff int) +} + +// Stack is an abstraction for a networking stack. +type Stack interface { + // Configure configures this Stack with the argument mac, ip and gateway addresses. + // The Stack must resolve the gateway hardware address if set. + // Configure(mac net.HardwareAddr, ip netip.Prefix, gw netip.Addr) error + + // EnableICMP enables responding/sending ICMP echo frames. + EnableICMP(enabled bool) error + // EnableDHCP enables DHCP on the device if enabled=true and performs a DHCP request. + EnableDHCP(ctx context.Context, enabled bool, reqIP netip.Addr) (assigned netip.Addr, routerGW netip.Addr, subnetBits int, _ error) + // Socket is a berkeley socket abstraction. Returns an [net.Listener] or [net.Conn] depending on laddr/raddr combination. + Socket(ctx context.Context, network string, family, sotype int, laddr, raddr netip.AddrPort) (c any, err error) + // EgressPackets instructs Stack to write outgoing packets into bufs and writing the sizes into sizes. + // offset can be used to tell the stack to start writing after an offset for each buffer. + // The size written into sizes includes only Ethernet frame size and so is independent of offset value. + EgressPackets(bufs [][]byte, sizes []int, offset int) error + // IngressPackets is called on incoming packets so the Stack can direct packets to + // their respective node/connection and update internal state. + // offset instructs the Stack to start reading packets at an offset. + IngressPackets(bufs [][]byte, offset int) error +} + +// Netlink represents the physical part of a network device which can connect/disconnect. +// One netlink may correspond to many network devices for interconnected systems. +type Netlink[C any] interface { + // LinkConnect attempts to connect the Netlink if it was not already connected. + // It will block until it succeeds/fails and not retry after returning. + LinkConnect(connectParams C) error + // LinkDisconnect disconnects the Netlink immediately. + LinkDisconnect() + // Link notify sets the callback to be executed after connection state + // changes for the Netlink. The callback can signal an immediate reconnect is desired + // by setting reconnectNowRetries to a positive integer. The netlink should then retry connection + // immediately with the given reconnectParams. reconnectParams should not be nil if reconnectNowRetries is positive. + LinkNotify(cb NotifyCallback[C]) +} + +// NotifyCallback is a convenience type alias that serves mostly as semantic code documentation. +// NotifyCallback is called when a [Netlink] connects or disconnects from a network. The callback returns: +// - reconnectNowRetries: Amount of times to attempt reconnection before giving up. +// - reconnectParams: parameters to use in reconnection. +type NotifyCallback[C any] = func(connected bool) (reconnectNowRetries int, reconnectParams C) + +// InterfaceConfig mostly optional configuration. +type InterfaceConfig struct { + // NetworkIP sets the network's IP range and this interface's IP address. See [netip.Prefix]. + // This field is optional if not using a networking stack. + NetworkIP netip.Prefix + // HardwareAddr6 overrides the device hardware address during Init. + // This field is optional if [DevEthernet.HardwareAddr6] returns valid MAC. + HardwareAddr6 [6]byte + // MTU is the maximum ethernet payload size. Does not include ethernet header(14b) and FCS(4b). + // If MTU is zero the default ipv4.MTU value of 1500 is used. + MTU uint16 +} + +// Init initializes the interface from scratch with a netlink and device. If Init fails all methods on Interface are unsafe to call (panic). +func (iface *Interface[C]) Init(netlink Netlink[C], dev DevEthernet, cfg InterfaceConfig) (err error) { + if netlink == nil || dev == nil { + return lneto.ErrInvalidConfig + } + maxFrameSize, frameOff := dev.MaxFrameSizeAndOffset() + maxEthFrame := maxFrameSize - frameOff + maxEthPayload := maxEthFrame - 14 + mtu := int(cfg.MTU) + if mtu == 0 { + mtu = min(1500, maxEthPayload) + } + if mtu > maxEthPayload { + return errors.New("MTU exceeds max frame size") + } else if mtu < ethernet.MinimumMTU || mtu > ethernet.MaxMTU { + return errors.New("bad DevEthernet max frame size and/or frame offset. typical is 1500,0") + } + var mac [6]byte + if internal.IsZeroed(cfg.HardwareAddr6) { + mac, err = dev.HardwareAddr6() + if err != nil { + return err + } else if internal.IsZeroed(mac) { + return lneto.ErrInvalidAddr + } + } else { + mac = cfg.HardwareAddr6 + } + *iface = Interface[C]{ + dev: dev, + netlink: netlink, + ip: cfg.NetworkIP, + frameSize: maxFrameSize, + frameOff: frameOff, + mtu: mtu, + mac: mac, + } + return nil +} + +// HardwareAddr6 returns the hardware address the [Interface] was configured with. +func (iface *Interface[C]) HardwareAddr6() [6]byte { + return iface.mac +} + +// NetworkAddr returns the IP and subnet of the network behind the interface. See [netip.Prefix]. +// May be unset/invalid. +func (iface *Interface[C]) NetworkAddr() netip.Prefix { + return iface.ip +} + +func (iface *Interface[C]) bufsize() int { + return iface.frameOff + 14 + iface.mtu +} diff --git a/x/netdev/runner.go b/x/netdev/runner.go new file mode 100644 index 0000000..7b45a0f --- /dev/null +++ b/x/netdev/runner.go @@ -0,0 +1,141 @@ +package netdev + +import ( + "context" + "errors" + "sync/atomic" + + "github.com/soypat/lneto" +) + +// Runner orchestrates an Interface and a Stack asynchronously. +type Runner[C any] struct { + running atomic.Uint32 + // buflen stores the length of data inside buf. It is used as a buffer acquisition synchronizing primitive. + buflen atomic.Uint32 + // pktlost is incremented each time an incoming packet is lost due to insufficient buffer size. + pktlost atomic.Uint64 + tx, rx atomic.Uint64 + // buf stores actual data. + buf []byte + // bufsaux is used as ana argument to stack processing so that no allocations are performed + bufsaux [1][]byte + sizesaux [1]int + handlerTriggered bool + deviceIsPollOnly bool +} + +func (r *Runner[C]) Run(ctx context.Context, iface Interface[C], stack Stack, backoff lneto.BackoffStrategy) error { + if stack == nil || backoff == nil { + return errors.New("nil arguments to Run") + } + if !r.acquire() { + return errors.New("runner currently running.") + } + defer func() { + iface.dev.SetEthRecvHandler(nil) + r.release() + }() + r.rx.Store(0) + r.tx.Store(0) + r.buflen.Store(0) + r.pktlost.Store(0) + r.handlerTriggered = false + r.deviceIsPollOnly = false + bufsize := iface.bufsize() + if cap(r.buf) < bufsize { + r.buf = make([]byte, bufsize) + } + r.buf = r.buf[:bufsize] + iface.dev.SetEthRecvHandler(r.recvEthHandler) + + // backoffs stores number of consecutive times no data was sent/received. + var backoffs uint + for ctx.Err() == nil { + n1, _ := r.processRx(stack, 0) + eoff, efrm, err := iface.dev.EthPoll(r.buf) + n2, _ := r.processRx(stack, 0) + if efrm > 0 && n2 == 0 { + r.deviceIsPollOnly = true + r.buflen.Store(uint32(eoff + efrm)) + r.processRx(stack, eoff) + } else if efrm > 0 && n2 > 0 { + return errors.New("device both returns nonzero poll read and calls, choose one") + } else if err != nil { + println("err EthPoll:", err.Error()) + } + + // Now do Tx, but first acquire buffer. + if !r.buflen.CompareAndSwap(0, 1) { + continue // Oh no, async data received, go back to Rx processing. + } + r.bufsaux = [1][]byte{r.buf} + + err = stack.EgressPackets(r.bufsaux[:], r.sizesaux[:], iface.frameOff) + n := r.sizesaux[0] + if err != nil { + println("err EgressPackets:", err.Error()) + } else if n > 0 { + if n+iface.frameOff > len(r.buf) { + return errors.New("EgressPackets returned invalid written data given frameOffset and argument buffer size") + } + err = iface.dev.SendOffsetEthFrame(r.bufsaux[0][:n+iface.frameOff]) + r.tx.Add(uint64(n + iface.frameOff)) + if err != nil { + println("err SendOffsetEthFrame:", err.Error()) + } + } + r.buflen.Store(0) // Release buffer. + if n1 > 0 || n2 > 0 || efrm > 0 || n > 0 { + backoffs = 0 + } else { + backoff.Do(backoffs) + backoffs++ + } + } + return ctx.Err() +} + +// PrintDebug +// +// Deprecated: Might be given other shape in future, but this is not how we do debugging. use freely meanwhile. +func (r *Runner[C]) PrintDebug() { + print("RUNNER: tx|rx:", r.tx.Load(), "|", r.rx.Load(), + " devpollonly:", r.deviceIsPollOnly, " pktlost:", r.pktlost.Load(), + " handles:", r.handlerTriggered, " bufsize:", len(r.buf), + "\n") +} + +func (r *Runner[C]) acquire() bool { + return r.running.CompareAndSwap(0, 1) +} + +func (r *Runner[C]) release() { + if r.running.Load()&1 == 0 { + panic("release of unacquired resource") + } + r.running.Store(0) +} + +// recvEthHandler is called asynchronously. Should be as fast as possible. Do not block inside. +func (r *Runner[C]) recvEthHandler(incomingEthernet []byte) { + if !r.buflen.CompareAndSwap(0, uint32(len(incomingEthernet))) { + // Failed to acquire buffer, packet dropped. + r.pktlost.Add(1) + return + } + copy(r.buf, incomingEthernet) +} + +// processRx is called after a packet is received asynchronously and compied to buffer via recvEthHandler +func (r *Runner[C]) processRx(stack Stack, ethFrameOff int) (int, error) { + r.handlerTriggered = true + n := r.buflen.Load() + if n == 0 { + return 0, nil + } + r.rx.Add(uint64(n)) + defer r.buflen.Store(0) + r.bufsaux = [1][]byte{r.buf[:n]} + return int(n), stack.IngressPackets(r.bufsaux[:], ethFrameOff) +} diff --git a/x/xnet/netstack.go b/x/xnet/netstack.go new file mode 100644 index 0000000..97d8a64 --- /dev/null +++ b/x/xnet/netstack.go @@ -0,0 +1,111 @@ +package xnet + +import ( + "context" + "net/netip" + "time" + + "github.com/soypat/lneto" + "github.com/soypat/lneto/x/netdev" +) + +// Netstack is a more modern outward facing API wrapper on StackAsync. +type Netstack struct { + stack StackAsync + //gstack references stack above. + gstack StackGo + awaitDHCP bool +} + +var _ netdev.Stack = (*Netstack)(nil) + +// Configure configures this Stack with the argument mac, ip and gateway addresses. +// The Stack must resolve the gateway hardware address if set. +func (netstack *Netstack) Reset(cfg StackConfig, stackBackoff lneto.BackoffStrategy, poolcfg TCPPoolConfig) error { + err := netstack.stack.Reset(cfg) + if err != nil { + return err + } + netstack.gstack = netstack.stack.StackGo(stackBackoff, StackGoConfig{ + ListenerPoolConfig: poolcfg, + }) + return nil +} + +func (netstack *Netstack) IPAddr4() [4]byte { + return netstack.stack.Addr4() +} + +// EnableICMP enables responding/sending ICMP echo frames. +func (netstack *Netstack) EnableICMP(enabled bool) error { + return netstack.stack.EnableICMP(enabled) +} + +// EnableDHCP performs a DHCPv4 request, writes the assigned address into the +// stack, and resolves the gateway hardware address via ARP. +func (netstack *Netstack) EnableDHCP(ctx context.Context, enabled bool, requestAddr netip.Addr) (assigned netip.Addr, routerGW netip.Addr, subnetBits int, err error) { + var addr [4]byte + if !enabled { + netstack.stack.dhcp.Reset() + return netip.Addr{}, netip.Addr{}, 0, nil + } else if requestAddr.Is4() { + addr = requestAddr.As4() + } else if requestAddr.IsValid() { + return netip.Addr{}, netip.Addr{}, 0, lneto.ErrUnsupported + } + timeout := 4 * time.Second + deadline, ok := ctx.Deadline() + if ok { + timeout = time.Until(deadline) + } + results, err := netstack.gstack.blk.DoDHCPv4(addr, timeout) + if err != nil { + return netip.Addr{}, netip.Addr{}, 0, err + } + // Write IP, subnet and DNS into the stack. + if err = netstack.stack.AssimilateDHCPResults(results); err != nil { + return netip.AddrFrom4(results.AssignedAddr4), results.Router, results.Subnet.Bits(), err + } + // Resolve gateway hardware address so egress packets are unicasted to the + // router rather than broadcast. Failure is non-fatal. + if results.Router.IsValid() { + gwHW, gwErr := netstack.gstack.blk.DoResolveHardwareAddress6(results.Router, 2*time.Second) + if gwErr == nil { + netstack.stack.SetGatewayHardwareAddr(gwHW) + } + } + return netip.AddrFrom4(results.AssignedAddr4), results.Router, results.Subnet.Bits(), nil +} + +// Socket is a berkeley socket abstraction. Returns an [net.Listener] or [net.Conn] depending on laddr/raddr combination. +func (netstack *Netstack) Socket(ctx context.Context, network string, family, sotype int, laddr, raddr netip.AddrPort) (c any, err error) { + return netstack.gstack.SocketNetip(ctx, network, family, sotype, laddr, raddr) +} + +// EgressPackets instructs Stack to write outgoing packets into bufs and writing the sizes into sizes not including initial offset. +// offset can be used to tell the stack to start writing after an offset for each buffer. +func (netstack *Netstack) EgressPackets(bufs [][]byte, sizes []int, offset int) (err error) { + var err0 error + for i := range bufs { + sizes[i], err0 = netstack.stack.EgressEthernet(bufs[i][offset:]) + if sizes[i] == 0 { + return err + } else if err0 != nil { + err = err0 + } + } + return err +} + +// IngressPackets is called on incoming packets so the Stack can direct packets to +// their respective node/connection and update internal state. +// offset instructs the Stack to start reading packets at an offset. +func (netstack *Netstack) IngressPackets(bufs [][]byte, offset int) (err error) { + for _, buf := range bufs { + err0 := netstack.stack.IngressEthernet(buf[offset:]) + if err0 != nil { + err = err0 + } + } + return err +} diff --git a/x/xnet/stack-async.go b/x/xnet/stack-async.go index 6b910c3..5d3cb60 100644 --- a/x/xnet/stack-async.go +++ b/x/xnet/stack-async.go @@ -119,6 +119,7 @@ func (s *StackAsync) IngressEthernet(ethernetFrame []byte) error { defer s.mu.Unlock() s.stats.TotalReceived += uint64(len(ethernetFrame)) err := s.link.Demux(ethernetFrame, 0) + debugPacket("IN ", ethernetFrame) if err == nil { s.arpt.learnFromIngressEthernet(ethernetFrame) } @@ -132,6 +133,9 @@ func (s *StackAsync) EgressEthernet(dstEthernetFrame []byte) (int, error) { defer s.mu.Unlock() n, err := s.link.Encapsulate(dstEthernetFrame, -1, 0) s.stats.TotalSent += uint64(n) + if n > 0 { + debugPacket("OUT", dstEthernetFrame[:n]) + } return n, err } diff --git a/x/xnet/xnet_debug_nopcap.go b/x/xnet/xnet_debug_nopcap.go new file mode 100644 index 0000000..2fe241b --- /dev/null +++ b/x/xnet/xnet_debug_nopcap.go @@ -0,0 +1,5 @@ +//go:build !xnetdebug + +package xnet + +func debugPacket(msg string, b []byte) {} diff --git a/x/xnet/xnet_debug_pcap.go b/x/xnet/xnet_debug_pcap.go new file mode 100644 index 0000000..c3e3df1 --- /dev/null +++ b/x/xnet/xnet_debug_pcap.go @@ -0,0 +1,17 @@ +//go:build xnetdebug + +package xnet + +import "os" + +var _pcap CapturePrinter + +func init() { + _pcap.Configure(os.Stdout, CapturePrinterConfig{ + NamespaceWidth: 3, + }) +} + +func debugPacket(msg string, b []byte) { + _pcap.PrintPacket(msg, b) +}