diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 300930f..ad35bed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,5 +61,25 @@ jobs: if: runner.os == 'macOS' run: sudo ifconfig lo0 alias 127.0.0.2 + - name: Create isolated BPF test interfaces + if: runner.os == 'macOS' + run: | + sudo ifconfig feth0 create + sudo ifconfig feth1 create + sudo ifconfig feth1 peer feth0 + sudo ifconfig feth0 up + sudo ifconfig feth1 up + # Only this ephemeral runner gets direct BPF access. No physical NIC, + # IP address, bridge, or Internet sharing is used by these tests. + sudo chown "$(id -u):$(id -g)" /dev/bpf* + echo "ETHERCAT_BPF_INTERFACE=feth0" >> "$GITHUB_ENV" + echo "ETHERCAT_BPF_PEER_INTERFACE=feth1" >> "$GITHUB_ENV" + - name: Run CI run: mix ci + + - name: Remove BPF test interfaces + if: always() && runner.os == 'macOS' + run: | + sudo ifconfig feth0 destroy + sudo ifconfig feth1 destroy diff --git a/CHANGELOG.md b/CHANGELOG.md index 726d794..36c284b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Raw Ethernet on macOS: `{:raw, %{interface: "en7"}}` uses the new + `EtherCAT.Bus.Transport.Bpf`, which sends and receives EtherCAT frames through + a BPF device with a small C port program (`priv/bpf/ethercat_bpf.c`), compiled + with the system's C compiler on first use. `EtherCAT.Bus.Transport.raw/0` + gives the raw transport of the operating system. Linux is unchanged (`1be936c`). + Helper builds use owner-only temporary directories and a VM-local cache rather + than trusting pre-existing executables; helper exits are reported as closed + transports (`d8d59e3`). + ### Fixed - CoE mailbox response polling uses a one-second monotonic deadline rather than a poll count, preventing scheduler and bus delays from prolonging polling or diff --git a/README.md b/README.md index aee8d70..9310469 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,13 @@ Restart IEx/the application after granting the capability. Replace `eth0` with your interface. Scan **before** opening a master session; scanning assigns station addresses and must not run against an active ring. +On macOS, for a rack on a desk, the same `{:raw, %{interface: "en7"}}` backend +works through a BPF device and a small C port program, which is compiled with +the system's C compiler the first time it is used. It needs read and write +access to `/dev/bpf*`: root, or the `access_bpf` group that Wireshark's ChmodBPF +sets up. See `EtherCAT.Bus.Transport.Bpf`. The raw simulator and redundant +rings are Linux only. + ```elixir backend = {:raw, %{interface: "eth0"}} {:ok, scan} = EtherCAT.Scan.scan(backend) @@ -193,6 +200,29 @@ The driver comparison demonstrates a shared signal catalogue without changing the public callbacks. The command above explicitly excludes raw transports. Hardware and raw-socket tests need separate setup. +### macOS BPF tests without hardware + +With the Xcode command line tools and read/write access to `/dev/bpf*` (see +`EtherCAT.Bus.Transport.Bpf`), create an isolated virtual Ethernet pair: + +```bash +sudo ifconfig feth0 create +sudo ifconfig feth1 create +sudo ifconfig feth1 peer feth0 +sudo ifconfig feth0 up +sudo ifconfig feth1 up +ETHERCAT_BPF_INTERFACE=feth0 ETHERCAT_BPF_PEER_INTERFACE=feth1 mix ci +sudo ifconfig feth0 destroy +sudo ifconfig feth1 destroy +``` + +Do not attach these interfaces to a bridge or use physical adapters. These tests +send raw frames and check actual receive filtering, VLANs, padding, no self-receive, +and helper lifecycle. CI provisions this pair automatically and runs the same +`mix ci` on Linux and macOS. Without the BPF environment variables, wire tests are +excluded; once configured, setup or helper errors fail the suite. Portable helper +build-security and port-liveness tests run on both platforms. + ## Pick your next task - **Write a driver:** [callback contract and catalogue pattern](https://hexdocs.pm/ethercat/0.5.0/EtherCAT.Driver.html). diff --git a/lib/ethercat/bus/link/redundant.ex b/lib/ethercat/bus/link/redundant.ex index 21a9a15..4b239ae 100644 --- a/lib/ethercat/bus/link/redundant.ex +++ b/lib/ethercat/bus/link/redundant.ex @@ -90,7 +90,7 @@ defmodule EtherCAT.Bus.Link.Redundant do alias EtherCAT.Bus.Frame alias EtherCAT.Bus.Link alias EtherCAT.Bus.Link.{RedundantMerge, Submission} - alias EtherCAT.Bus.Transport.RawSocket + alias EtherCAT.Bus.Transport alias EtherCAT.Telemetry @max_dispatch_errors 3 @@ -1086,6 +1086,6 @@ defmodule EtherCAT.Bus.Link.Redundant do end defp resolve_transport_mod(opts) do - opts[:transport_mod] || RawSocket + opts[:transport_mod] || Transport.raw() end end diff --git a/lib/ethercat/bus/link/single.ex b/lib/ethercat/bus/link/single.ex index 81926ba..864841a 100644 --- a/lib/ethercat/bus/link/single.ex +++ b/lib/ethercat/bus/link/single.ex @@ -19,7 +19,8 @@ defmodule EtherCAT.Bus.Link.Single do alias EtherCAT.Bus.{Datagram, Frame} alias EtherCAT.Bus.Link alias EtherCAT.Bus.Link.Submission - alias EtherCAT.Bus.Transport.{RawSocket, UdpSocket} + alias EtherCAT.Bus.Transport + alias EtherCAT.Bus.Transport.UdpSocket alias EtherCAT.Telemetry @max_dispatch_errors 3 @@ -476,7 +477,7 @@ defmodule EtherCAT.Bus.Link.Single do opts[:transport_mod] || case opts[:transport] do :udp -> UdpSocket - _ -> RawSocket + _ -> Transport.raw() end end end diff --git a/lib/ethercat/bus/transport.ex b/lib/ethercat/bus/transport.ex index aa60c89..59156cc 100644 --- a/lib/ethercat/bus/transport.ex +++ b/lib/ethercat/bus/transport.ex @@ -7,7 +7,8 @@ defmodule EtherCAT.Bus.Transport do ## Implementations - - `EtherCAT.Bus.Transport.RawSocket` — AF_PACKET raw Ethernet + - `EtherCAT.Bus.Transport.RawSocket` — AF_PACKET raw Ethernet, on Linux + - `EtherCAT.Bus.Transport.Bpf` — BPF raw Ethernet through a port program, on macOS - `EtherCAT.Bus.Transport.UdpSocket` — UDP/IP encapsulation (spec §2.6) ## Async receive flow @@ -25,6 +26,18 @@ defmodule EtherCAT.Bus.Transport do @type t :: struct() + @doc """ + The raw Ethernet transport of this operating system: `EtherCAT.Bus.Transport.Bpf` on macOS, + which has no `AF_PACKET` sockets, and `EtherCAT.Bus.Transport.RawSocket` everywhere else. + """ + @spec raw() :: module() + def raw do + case :os.type() do + {:unix, :darwin} -> EtherCAT.Bus.Transport.Bpf + _other -> EtherCAT.Bus.Transport.RawSocket + end + end + @doc "Open a transport from keyword options." @callback open(keyword()) :: {:ok, t()} | {:error, term()} diff --git a/lib/ethercat/bus/transport/bpf.ex b/lib/ethercat/bus/transport/bpf.ex new file mode 100644 index 0000000..d510563 --- /dev/null +++ b/lib/ethercat/bus/transport/bpf.ex @@ -0,0 +1,265 @@ +defmodule EtherCAT.Bus.Transport.Bpf do + @moduledoc """ + Raw Ethernet transport for EtherCAT on macOS, through a BPF device. + + macOS has no `AF_PACKET` sockets, which `EtherCAT.Bus.Transport.RawSocket` uses on Linux. Whole + Ethernet frames are sent and received through a BPF device (`/dev/bpfN`) instead, with `ioctl` + calls the BEAM cannot make. So this transport starts a small C program as an Erlang port, a + process of its own, which opens the device and passes frames of EtherType 0x88A4 both ways. It is + what `{:raw, %{interface: "en7"}}` uses on macOS; see `EtherCAT.Bus.Transport.raw/0`. + + Implements the `EtherCAT.Bus.Transport` behaviour. A frame that arrives is in the mailbox of the + process that opened the transport at once, so `set_active_once/1` and `rearm/1` have nothing to + arm, and `drain/1` takes the frames that are waiting there out of it. + + ## The port program + + The program is `priv/bpf/ethercat_bpf.c`. It is compiled with the system's C compiler the first + time a transport is opened in each VM, and again when its source changes. It needs the Xcode + command line tools (`xcode-select --install`) and nothing at build time, on any platform. + Each build uses a new owner-only temporary directory; existing on-disk executables are never + trusted as a cache. Successful builds remain in the OS temporary directory for reuse by this VM. + + ## Access + + Opening a BPF device needs read and write access to `/dev/bpf*`: root, or membership of the group + `access_bpf`, which Wireshark's ChmodBPF sets up. + + ## Timing + + A received frame is stamped when the BEAM takes it from the port, not by the kernel as on Linux, + so the time it took to come through the port program, some tens of microseconds, is part of + every round trip. macOS is for a rack on a desk: for timing that matters, run on Linux. + """ + + @behaviour EtherCAT.Bus.Transport + + # Suppress conflict with Kernel.send/2 — this module defines its own send/2 + import Kernel, except: [send: 2] + + alias EtherCAT.Telemetry + + @ethertype 0x88A4 + @broadcast_mac <<0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF>> + @zero_mac <<0, 0, 0, 0, 0, 0>> + @min_frame_size 60 + @open_timeout_ms 5_000 + + defstruct [:port, :interface, :src_mac] + + @type t :: %__MODULE__{ + port: port() | nil, + interface: String.t(), + src_mac: <<_::48>> + } + + @impl true + @doc "Start the port program on the given interface, and wait until it has the device open." + @spec open(keyword()) :: {:ok, t()} | {:error, term()} + def open(opts) do + interface = Keyword.fetch!(opts, :interface) + + with {:ok, program} <- program() do + port = + Port.open({:spawn_executable, program}, [ + :binary, + :exit_status, + {:packet, 2}, + args: [interface] + ]) + + receive do + {^port, {:data, <>}} -> + {:ok, %__MODULE__{port: port, interface: interface, src_mac: src_mac}} + + {^port, {:data, <>}} -> + await_exit(port) + {:error, {:bpf, reason}} + + {^port, {:exit_status, status}} -> + {:error, {:bpf_port_exited, status}} + after + @open_timeout_ms -> + close_port(port) + {:error, :bpf_port_timeout} + end + end + end + + @impl true + @doc "Send an EtherCAT payload wrapped in an Ethernet frame." + @spec send(t(), binary()) :: {:ok, integer()} | {:error, term()} + def send(%__MODULE__{port: nil}, _ecat_payload), do: {:error, :closed} + + def send(%__MODULE__{port: port, src_mac: src}, ecat_payload) do + tx_at = System.monotonic_time() + + frame_body = << + @broadcast_mac::binary, + src::binary-size(6), + @ethertype::big-unsigned-16, + ecat_payload::binary + >> + + pad_needed = max(0, @min_frame_size - byte_size(frame_body)) + frame = <> + + try do + Port.command(port, frame) + {:ok, tx_at} + rescue + ArgumentError -> {:error, :closed} + end + end + + @impl true + @doc "Nothing to arm: the port program delivers every frame as it comes." + @spec set_active_once(t()) :: :ok + def set_active_once(%__MODULE__{}), do: :ok + + @impl true + @doc "Nothing to re-arm: a frame behind the one just read is in the mailbox already." + @spec rearm(t()) :: :ok + def rearm(%__MODULE__{}), do: :ok + + @impl true + @doc """ + Match a message from this transport's port program. + + A received frame has its Ethernet headers stripped, and comes back as the EtherCAT payload with + an rx timestamp and the frame's source MAC address (6 bytes). A frame that could not be sent, or + a port program that has ended, is an error. Returns `:ignore` for all other messages. + """ + @spec match(t(), term()) :: {:ok, binary(), integer(), binary()} | {:error, term()} | :ignore + def match(%__MODULE__{port: port} = transport, {port, {:data, <>}}) + when is_port(port) do + rx_at = System.monotonic_time() + + case strip_ethernet_headers(frame) do + {:ok, ecat_payload, frame_src_mac} -> + {:ok, ecat_payload, rx_at, frame_src_mac} + + {:error, reason} -> + Telemetry.frame_dropped(transport.interface, byte_size(frame), reason) + :ignore + end + end + + def match(%__MODULE__{port: port}, {port, {:data, <>}}) when is_port(port), + do: {:error, {:send_failed, reason}} + + def match(%__MODULE__{port: port}, {port, {:exit_status, status}}) when is_port(port), + do: {:error, {:bpf_port_exited, status}} + + def match(%__MODULE__{}, _msg), do: :ignore + + @impl true + @doc "Returns this NIC's source MAC address (6 bytes)." + @spec src_mac(t()) :: <<_::48>> + def src_mac(%__MODULE__{src_mac: mac}), do: mac + + @impl true + @doc "Take the frames that have arrived out of the mailbox." + @spec drain(t()) :: :ok + def drain(%__MODULE__{port: nil}), do: :ok + + def drain(%__MODULE__{port: port} = transport) do + receive do + {^port, {:data, <>}} -> drain(transport) + after + 0 -> :ok + end + end + + @impl true + @doc "Stop the port program. Returns the struct with `port` set to `nil`." + @spec close(t()) :: t() + def close(%__MODULE__{port: nil} = transport), do: transport + + def close(%__MODULE__{port: port} = transport) do + close_port(port) + %{transport | port: nil} + end + + @impl true + @doc "Returns `true` when the port program is running." + @spec open?(t()) :: boolean() + def open?(%__MODULE__{port: nil}), do: false + def open?(%__MODULE__{port: port}), do: Port.info(port) != nil + + @impl true + @doc "Returns the network interface name for telemetry and link monitoring." + @spec name(t()) :: String.t() + def name(%__MODULE__{interface: interface}), do: interface + + @impl true + @doc "Returns the interface name used by the bus link monitor." + @spec interface(t()) :: String.t() + def interface(%__MODULE__{interface: interface}), do: interface + + @doc """ + The port program, compiled securely once per VM and source revision. + + Returns `{:ok, path}`, or `{:error, reason}` when there is no C compiler or it failed. + """ + @spec program() :: {:ok, String.t()} | {:error, term()} + def program do + source = Application.app_dir(:ethercat, "priv/bpf/ethercat_bpf.c") + + EtherCAT.Bus.Transport.Bpf.Program.ensure(source) + end + + # -- private ---------------------------------------------------------------- + + defp close_port(port) do + try do + Port.close(port) + rescue + ArgumentError -> :ok + end + + flush(port) + end + + defp await_exit(port) do + receive do + {^port, {:exit_status, _status}} -> :ok + after + 1_000 -> close_port(port) + end + end + + # What a port that is closed had still sent. + defp flush(port) do + receive do + {^port, _message} -> flush(port) + after + 0 -> :ok + end + end + + defp strip_ethernet_headers(<< + _dst::binary-size(6), + src::binary-size(6), + 0x88A4::big-unsigned-16, + rest::binary + >>) do + if valid_source_mac?(src), do: {:ok, rest, src}, else: {:error, :invalid_source_mac} + end + + # VLAN-tagged frame (802.1Q) + defp strip_ethernet_headers(<< + _dst::binary-size(6), + src::binary-size(6), + 0x8100::big-unsigned-16, + _vlan::binary-size(2), + 0x88A4::big-unsigned-16, + rest::binary + >>) do + if valid_source_mac?(src), do: {:ok, rest, src}, else: {:error, :invalid_source_mac} + end + + defp strip_ethernet_headers(_), do: {:error, :not_ethercat} + + defp valid_source_mac?(mac), do: mac not in [@broadcast_mac, @zero_mac] +end diff --git a/lib/ethercat/bus/transport/bpf/program.ex b/lib/ethercat/bus/transport/bpf/program.ex new file mode 100644 index 0000000..70f6301 --- /dev/null +++ b/lib/ethercat/bus/transport/bpf/program.ex @@ -0,0 +1,84 @@ +defmodule EtherCAT.Bus.Transport.Bpf.Program do + @moduledoc false + + # Cache only builds created by this VM, never a path discovered on disk. The + # lock keeps concurrent opens from compiling or publishing the same revision. + @spec ensure(String.t()) :: {:ok, String.t()} | {:error, term()} + def ensure(source) do + with {:ok, contents} <- File.read(source) do + :global.trans( + {{__MODULE__, source}, self()}, + fn -> + cached_or_build(source, contents) + end, + [node()] + ) + end + end + + defp cached_or_build(source, contents) do + key = {__MODULE__, source} + + case :persistent_term.get(key, nil) do + {^contents, program} -> + if File.regular?(program), do: {:ok, program}, else: build_and_cache(key, contents) + + _missing_or_changed -> + build_and_cache(key, contents) + end + end + + defp build_and_cache(key, contents) do + with {:ok, program} <- build(contents) do + :persistent_term.put(key, {contents, program}) + {:ok, program} + end + end + + defp build(contents) do + case System.find_executable("cc") do + nil -> {:error, :no_c_compiler} + cc -> build_in_private_directory(cc, contents) + end + end + + defp build_in_private_directory(cc, contents) do + # mktemp creates the directory atomically with mode 0700. mkdir followed by + # chmod leaves a window in which another user can plant files or symlinks. + template = Path.join(System.tmp_dir!(), "ethercat-bpf.XXXXXXXXXX") + + case System.cmd("mktemp", ["-d", template], stderr_to_stdout: true) do + {directory, 0} -> compile(cc, String.trim(directory), contents) + {output, _status} -> {:error, {:bpf_build_failed, String.trim(output)}} + end + rescue + error in ErlangError -> {:error, {:bpf_build_failed, error.original}} + end + + defp compile(cc, directory, contents) do + source = Path.join(directory, "ethercat_bpf.c") + program = Path.join(directory, "ethercat_bpf") + + result = + with :ok <- File.write(source, contents), + {_output, 0} <- System.cmd(cc, ["-O2", "-o", program, source], stderr_to_stdout: true) do + {:ok, program} + else + {:error, reason} -> {:error, {:bpf_build_failed, reason}} + {output, _status} -> {:error, {:bpf_build_failed, String.trim(output)}} + end + + case result do + {:ok, _program} -> + result + + {:error, _reason} -> + File.rm_rf(directory) + result + end + rescue + error in ErlangError -> + File.rm_rf(directory) + {:error, {:bpf_build_failed, error.original}} + end +end diff --git a/mix.exs b/mix.exs index 3abbb0c..37ee176 100644 --- a/mix.exs +++ b/mix.exs @@ -61,7 +61,7 @@ defmodule EtherCAT.MixProject do [ licenses: ["Apache-2.0"], links: %{"GitHub" => @source_url}, - files: ~w(lib mix.exs README.md LICENSE CHANGELOG.md usage-rules.md) + files: ~w(lib priv/bpf/ethercat_bpf.c mix.exs README.md LICENSE CHANGELOG.md usage-rules.md) ] end diff --git a/priv/bpf/ethercat_bpf.c b/priv/bpf/ethercat_bpf.c new file mode 100644 index 0000000..9f152e2 --- /dev/null +++ b/priv/bpf/ethercat_bpf.c @@ -0,0 +1,262 @@ +/* + * ethercat_bpf: raw EtherCAT frames for the BEAM on macOS. + * + * macOS has no AF_PACKET sockets. A program sends and receives whole Ethernet frames through a BPF + * device, /dev/bpfN, which takes ioctl calls the BEAM cannot make. This program does that as an + * Erlang port, a process of its own: EtherCAT.Bus.Transport.Bpf starts it with the name of the + * interface, and the two talk over standard input and output in packets with a two-byte length. + * + * to the BEAM: 'R' mac[6] ready, with the hardware address of the interface + * 'F' frame a frame that was received, from its Ethernet header on + * 'S' text a frame could not be sent + * 'E' text it could not start; it then ends with status 1 + * from the BEAM: frame a frame to send, from its Ethernet header on + * + * Only frames of EtherType 0x88A4 are received, with or without one VLAN tag, and never the frames + * this machine sent itself. It ends when its standard input is closed. + * + * Opening a BPF device needs read and write access to /dev/bpf*: root, or the group access_bpf that + * Wireshark's ChmodBPF sets up. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define ETHERTYPE_ETHERCAT 0x88A4 +#define ETHERTYPE_VLAN 0x8100 +#define MAX_PACKET 65535 + +static int write_all(int fd, const void *data, size_t size) { + const uint8_t *at = data; + + while (size > 0) { + ssize_t written = write(fd, at, size); + + if (written < 0) { + if (errno == EINTR) continue; + return -1; + } + + at += written; + size -= (size_t)written; + } + + return 0; +} + +/* 1 when all of it was read, 0 at the end of the input, -1 on an error. */ +static int read_all(int fd, void *data, size_t size) { + uint8_t *at = data; + + while (size > 0) { + ssize_t got = read(fd, at, size); + + if (got == 0) return 0; + + if (got < 0) { + if (errno == EINTR) continue; + return -1; + } + + at += got; + size -= (size_t)got; + } + + return 1; +} + +/* One packet to the BEAM: a tag and what goes with it. The BEAM is gone when it cannot be written. */ +static void emit(char tag, const void *data, size_t size) { + uint8_t head[3]; + + if (size > MAX_PACKET - 1) size = MAX_PACKET - 1; + + head[0] = (uint8_t)((size + 1) >> 8); + head[1] = (uint8_t)((size + 1) & 0xFF); + head[2] = (uint8_t)tag; + + if (write_all(STDOUT_FILENO, head, sizeof head) < 0 || + write_all(STDOUT_FILENO, data, size) < 0) + exit(0); +} + +static void fail(const char *what) { + char text[256]; + int length = snprintf(text, sizeof text, "%s: %s", what, strerror(errno)); + + emit('E', text, (size_t)length); + exit(1); +} + +static int open_bpf(void) { + char path[32]; + int denied = 0; + + for (int n = 0; n < 256; n++) { + snprintf(path, sizeof path, "/dev/bpf%d", n); + + int fd = open(path, O_RDWR); + if (fd >= 0) return fd; + + if (errno == EACCES || errno == EPERM) denied = 1; + if (errno == ENOENT) break; + } + + errno = denied ? EACCES : EBUSY; + return -1; +} + +/* The hardware address of the interface, 0 when it has none of six bytes. */ +static int hardware_address(const char *name, uint8_t mac[6]) { + struct ifaddrs *all; + int found = 0; + + if (getifaddrs(&all) < 0) return 0; + + for (struct ifaddrs *each = all; each != NULL; each = each->ifa_next) { + if (each->ifa_addr == NULL || each->ifa_addr->sa_family != AF_LINK) continue; + if (strcmp(each->ifa_name, name) != 0) continue; + + struct sockaddr_dl *link = (struct sockaddr_dl *)each->ifa_addr; + + if (link->sdl_alen == 6) { + memcpy(mac, LLADDR(link), 6); + found = 1; + } + + break; + } + + freeifaddrs(all); + return found; +} + +int main(int argc, char **argv) { + if (argc != 2) { + errno = EINVAL; + fail("usage: ethercat_bpf INTERFACE"); + } + + const char *name = argv[1]; + + if (strlen(name) >= IFNAMSIZ) { + errno = ENAMETOOLONG; + fail(name); + } + + int bpf = open_bpf(); + if (bpf < 0) fail("cannot open a /dev/bpf device"); + + /* The size of the buffer is set before the device is bound to an interface, and read back. */ + u_int size = 1 << 17; + ioctl(bpf, BIOCSBLEN, &size); + + struct ifreq interface; + memset(&interface, 0, sizeof interface); + strlcpy(interface.ifr_name, name, sizeof interface.ifr_name); + if (ioctl(bpf, BIOCSETIF, &interface) < 0) fail(name); + + u_int kind; + if (ioctl(bpf, BIOCGDLT, &kind) < 0) fail("BIOCGDLT"); + + if (kind != DLT_EN10MB) { + errno = EPROTOTYPE; + fail("not an Ethernet interface"); + } + + u_int on = 1, off = 0; + /* A read gives a frame as soon as it has come, and not when the buffer is full. */ + if (ioctl(bpf, BIOCIMMEDIATE, &on) < 0) fail("BIOCIMMEDIATE"); + /* Never receive this interface's own transmissions. Tests use a separate feth peer. */ + if (ioctl(bpf, BIOCSSEESENT, &off) < 0) fail("BIOCSSEESENT"); + /* The source address of a frame is sent as it was written. */ + if (ioctl(bpf, BIOCSHDRCMPLT, &on) < 0) fail("BIOCSHDRCMPLT"); + + struct bpf_insn only_ethercat[] = { + BPF_STMT(BPF_LD + BPF_H + BPF_ABS, 12), + BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, ETHERTYPE_ETHERCAT, 3, 0), + BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, ETHERTYPE_VLAN, 0, 3), + BPF_STMT(BPF_LD + BPF_H + BPF_ABS, 16), + BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, ETHERTYPE_ETHERCAT, 0, 1), + BPF_STMT(BPF_RET + BPF_K, (u_int)-1), + BPF_STMT(BPF_RET + BPF_K, 0), + }; + + struct bpf_program filter = { + .bf_len = sizeof only_ethercat / sizeof only_ethercat[0], + .bf_insns = only_ethercat, + }; + + if (ioctl(bpf, BIOCSETF, &filter) < 0) fail("BIOCSETF"); + if (ioctl(bpf, BIOCGBLEN, &size) < 0) fail("BIOCGBLEN"); + + uint8_t mac[6]; + + if (!hardware_address(name, mac)) { + errno = EADDRNOTAVAIL; + fail("the interface has no hardware address"); + } + + uint8_t *received = malloc(size); + uint8_t *sending = malloc(MAX_PACKET); + + if (received == NULL || sending == NULL) fail("malloc"); + + emit('R', mac, sizeof mac); + + for (;;) { + fd_set ready; + FD_ZERO(&ready); + FD_SET(STDIN_FILENO, &ready); + FD_SET(bpf, &ready); + + if (select(bpf + 1, &ready, NULL, NULL, NULL) < 0) { + if (errno == EINTR) continue; + return 2; + } + + if (FD_ISSET(bpf, &ready)) { + ssize_t got = read(bpf, received, size); + + if (got < 0 && errno != EINTR && errno != EAGAIN) return 2; + + /* One read may give several frames, each after a header that says how long it is. */ + for (uint8_t *at = received; got > 0 && at < received + got;) { + struct bpf_hdr *head = (struct bpf_hdr *)at; + + emit('F', at + head->bh_hdrlen, head->bh_caplen); + at += BPF_WORDALIGN(head->bh_hdrlen + head->bh_caplen); + } + } + + if (FD_ISSET(STDIN_FILENO, &ready)) { + uint8_t length[2]; + + if (read_all(STDIN_FILENO, length, sizeof length) <= 0) return 0; + + size_t frame = ((size_t)length[0] << 8) | length[1]; + + if (read_all(STDIN_FILENO, sending, frame) <= 0) return 0; + + if (write(bpf, sending, frame) < 0) { + const char *why = strerror(errno); + emit('S', why, strlen(why)); + } + } + } +} diff --git a/test/ethercat/bus/transport/bpf_lifecycle_test.exs b/test/ethercat/bus/transport/bpf_lifecycle_test.exs new file mode 100644 index 0000000..2b29579 --- /dev/null +++ b/test/ethercat/bus/transport/bpf_lifecycle_test.exs @@ -0,0 +1,21 @@ +defmodule EtherCAT.Bus.Transport.BpfLifecycleTest do + use ExUnit.Case, async: true + + alias EtherCAT.Bus.Transport.Bpf + + test "a helper that exits is no longer open, before explicit close" do + port = + Port.open({:spawn_executable, System.find_executable("sh")}, [ + :exit_status, + args: ["-c", "exit 7"] + ]) + + transport = %Bpf{port: port, interface: "test", src_mac: <<2, 0, 0, 0, 0, 1>>} + + assert_receive {^port, {:exit_status, 7}} = message, 2_000 + assert Bpf.match(transport, message) == {:error, {:bpf_port_exited, 7}} + refute Bpf.open?(transport) + assert Bpf.send(transport, <<1>>) == {:error, :closed} + assert %Bpf{port: nil} = Bpf.close(transport) + end +end diff --git a/test/ethercat/bus/transport/bpf_program_test.exs b/test/ethercat/bus/transport/bpf_program_test.exs new file mode 100644 index 0000000..fcd68bb --- /dev/null +++ b/test/ethercat/bus/transport/bpf_program_test.exs @@ -0,0 +1,72 @@ +defmodule EtherCAT.Bus.Transport.Bpf.ProgramTest do + use ExUnit.Case, async: false + + alias EtherCAT.Bus.Transport.Bpf.Program + + @moduletag :tmp_dir + + setup %{tmp_dir: directory} do + source = Path.join(directory, "helper.c") + File.write!(source, "#include \nint main(void) { puts(\"trusted\"); return 0; }\n") + %{source: source} + end + + test "builds in an owner-only directory and ignores planted executables", %{source: source} do + beside_source = Path.rootname(source) + + predictable = + Path.join(System.tmp_dir!(), "ethercat_bpf-#{:erlang.phash2(File.read!(source))}") + + planted = "#!/bin/sh\necho untrusted\n" + File.write!(beside_source, planted) + File.ln_s!(beside_source, predictable) + on_exit(fn -> File.rm(predictable) end) + + assert {:ok, program} = Program.ensure(source) + on_exit(fn -> File.rm_rf(Path.dirname(program)) end) + refute program in [beside_source, predictable] + assert {"trusted\n", 0} = System.cmd(program, []) + assert File.read!(beside_source) == planted + assert {:ok, ^beside_source} = File.read_link(predictable) + assert %{type: :directory, mode: mode} = File.lstat!(Path.dirname(program)) + assert String.ends_with?(Integer.to_string(mode, 8), "700") + assert {:ok, ^program} = Program.ensure(source) + end + + test "concurrent callers reuse only a completed build", %{source: source} do + results = + 1..8 + |> Task.async_stream(fn _ -> Program.ensure(source) end, max_concurrency: 8) + |> Enum.to_list() + + assert [{:ok, {:ok, program}}] = Enum.uniq(results) + on_exit(fn -> File.rm_rf(Path.dirname(program)) end) + assert {"trusted\n", 0} = System.cmd(program, []) + end + + test "changed source is rebuilt even with the same modification time", %{source: source} do + assert {:ok, first} = Program.ensure(source) + %{mtime: mtime} = File.stat!(source) + File.write!(source, "#include \nint main(void) { puts(\"changed\"); return 0; }\n") + File.touch!(source, mtime) + assert {:ok, second} = Program.ensure(source) + on_exit(fn -> Enum.each([first, second], &File.rm_rf(Path.dirname(&1))) end) + refute second == first + assert {"changed\n", 0} = System.cmd(second, []) + end + + test "compiler failures are returned and not cached", %{source: source} do + File.write!(source, "this is not C") + assert {:error, {:bpf_build_failed, message}} = Program.ensure(source) + assert is_binary(message) + File.write!(source, "int main(void) { return 0; }\n") + assert {:ok, program} = Program.ensure(source) + on_exit(fn -> File.rm_rf(Path.dirname(program)) end) + assert {"", 0} = System.cmd(program, []) + end + + test "missing sources are returned as errors", %{source: source} do + File.rm!(source) + assert Program.ensure(source) == {:error, :enoent} + end +end diff --git a/test/ethercat/bus/transport/bpf_test.exs b/test/ethercat/bus/transport/bpf_test.exs new file mode 100644 index 0000000..3e4c07f --- /dev/null +++ b/test/ethercat/bus/transport/bpf_test.exs @@ -0,0 +1,145 @@ +defmodule EtherCAT.Bus.Transport.BpfTest do + use ExUnit.Case, async: false + + alias EtherCAT.Bus.Transport + alias EtherCAT.Bus.Transport.Bpf + + @moduletag :bpf + + @ethertype 0x88A4 + @broadcast <<0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF>> + + setup_all do + # Use an isolated feth pair, never a physical NIC or the runner's network. + %{ + interface: System.fetch_env!("ETHERCAT_BPF_INTERFACE"), + peer: System.fetch_env!("ETHERCAT_BPF_PEER_INTERFACE") + } + end + + # Writes a whole Ethernet frame through the peer, so it arrives on the + # transport's real receive path with the production BIOCSSEESENT setting. + defp inject(port, src, ethertype, payload) do + body = <<@broadcast::binary, src::binary, ethertype::16, payload::binary>> + Port.command(port, <>) + end + + test "it is the raw transport of macOS" do + assert Transport.raw() == Bpf + end + + test "the port program is built once, and found again" do + assert {:ok, program} = Bpf.program() + assert File.regular?(program) + %{mtime: built} = File.stat!(program, time: :posix) + assert {:ok, ^program} = Bpf.program() + assert %{mtime: ^built} = File.stat!(program, time: :posix) + end + + test "it opens the interface with its hardware address, and closes", %{interface: interface} do + assert {:ok, %Bpf{interface: ^interface, src_mac: <<_::48>> = mac} = transport} = + Bpf.open(interface: interface) + + assert Bpf.open?(transport) + assert Bpf.src_mac(transport) == mac + assert Bpf.name(transport) == interface + assert Bpf.interface(transport) == interface + assert :ok = Bpf.set_active_once(transport) + assert :ok = Bpf.rearm(transport) + assert :ok = Bpf.drain(transport) + + closed = Bpf.close(transport) + refute Bpf.open?(closed) + assert Bpf.send(closed, <<1, 2, 3>>) == {:error, :closed} + assert Bpf.close(closed) == closed + assert Bpf.drain(closed) == :ok + refute_receive {_port, {:exit_status, _status}}, 50 + end + + test "an interface that is not there is an error, with nothing left running" do + assert {:error, {:bpf, reason}} = Bpf.open(interface: "nope0") + assert reason =~ "nope0" + refute_receive {_port, _message}, 50 + end + + test "a payload goes out as an EtherCAT frame, and the transport does not hear itself", + context do + {:ok, listener} = Bpf.open(interface: context.peer) + {:ok, transport} = Bpf.open(interface: context.interface) + payloads = for n <- 1..5, do: <> <> :binary.copy(<>, n * 20) + listener_port = listener.port + + for payload <- payloads do + assert {:ok, tx_at} = Bpf.send(transport, payload) + assert is_integer(tx_at) + end + + for payload <- payloads do + assert_receive {^listener_port, {:data, <>}}, 2_000 + <> = frame + assert {dst, src, type} == {@broadcast, transport.src_mac, @ethertype} + assert String.starts_with?(rest, payload) + assert byte_size(frame) == max(60, 14 + byte_size(payload)) + end + + port = transport.port + refute_receive {^port, _message}, 100 + Bpf.close(transport) + Bpf.close(listener) + end + + test "incoming frames are filtered and matched, including VLAN and invalid sources", context do + {:ok, transport} = Bpf.open(interface: context.interface) + {:ok, peer} = Bpf.open(interface: context.peer) + port = transport.port + station = <<0x02, 0x00, 0x5E, 0x10, 0x00, 0x01>> + + inject(peer.port, station, 0x0806, <<1, 2, 3, 4>>) + inject(peer.port, station, @ethertype, <<0xAA, 0xBB, 0xCC>>) + inject(peer.port, station, 0x8100, <<0, 5, @ethertype::16, 0xDD, 0xEE>>) + inject(peer.port, <<0::48>>, @ethertype, <<0x11>>) + + assert_receive {^port, {:data, <>}} = first, 2_000 + + assert {:ok, <<0xAA, 0xBB, 0xCC, _pad::binary>>, rx_at, ^station} = + Bpf.match(transport, first) + + assert is_integer(rx_at) + + assert_receive {^port, {:data, <>}} = tagged, 2_000 + assert {:ok, <<0xDD, 0xEE, _pad::binary>>, _rx_at, ^station} = Bpf.match(transport, tagged) + + # The ARP frame never passed the kernel filter; the zero source is rejected + # by the transport rather than accepted as a station response. + assert_receive {^port, {:data, <>}} = nobody, 2_000 + assert Bpf.match(transport, nobody) == :ignore + refute_receive {^port, {:data, _frame}}, 100 + assert Bpf.match(peer, first) == :ignore + assert Bpf.match(transport, :anything) == :ignore + + Bpf.close(transport) + Bpf.close(peer) + end + + test "frames that wait are drained, and a port program that ends is an error", context do + {:ok, transport} = Bpf.open(interface: context.interface) + port = transport.port + + for n <- 1..3, do: send(self(), {port, {:data, <>}}) + assert :ok = Bpf.drain(transport) + refute_receive {^port, {:data, _frame}}, 20 + + assert Bpf.match(transport, {port, {:data, <>}}) == + {:error, {:send_failed, "Network is down"}} + + {:os_pid, os_pid} = Port.info(port, :os_pid) + System.cmd("kill", ["-9", "#{os_pid}"]) + assert_receive {^port, {:exit_status, status}} = gone, 2_000 + assert Bpf.match(transport, gone) == {:error, {:bpf_port_exited, status}} + refute Bpf.open?(transport) + assert Bpf.send(transport, <<1>>) == {:error, :closed} + + closed = Bpf.close(transport) + refute Bpf.open?(closed) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 048fa33..f038c1f 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -13,6 +13,29 @@ defmodule EtherCAT.TestHelper do ) end + # BPF tests require an isolated feth pair. Once explicitly configured, setup + # or helper failures must fail the tests, never silently exclude them. + def bpf_excludes do + interface = System.get_env("ETHERCAT_BPF_INTERFACE") + + cond do + :os.type() != {:unix, :darwin} -> + [:bpf] + + interface in [nil, ""] -> + IO.puts( + :stderr, + "warning: excluding :bpf tests. Set ETHERCAT_BPF_INTERFACE and " <> + "ETHERCAT_BPF_PEER_INTERFACE to an isolated feth pair (see README.md)." + ) + + [:bpf] + + true -> + [] + end + end + def raw_socket_redundant_excludes do exclusion_for(:raw_socket_redundant, "redundant raw simulator", redundant_raw_interfaces()) end @@ -210,6 +233,7 @@ ExUnit.start( formatters: [ExUnit.CLIFormatter, EtherCAT.TestProgressFormatter], exclude: [:hardware] ++ + EtherCAT.TestHelper.bpf_excludes() ++ EtherCAT.TestHelper.raw_socket_excludes() ++ EtherCAT.TestHelper.raw_socket_redundant_excludes() ++ EtherCAT.TestHelper.raw_socket_redundant_toggle_excludes()