mctx-core 0.3.0

Runtime-agnostic and portable IPv4 and IPv6 multicast sender library.
Documentation
# Raw IP Control Transmit

The optional `raw-ip` feature sends a complete caller-supplied IPv4 or IPv6
datagram toward an explicitly selected local interface. It is intended for
transport/control traffic such as ICMPv4 Fragmentation Needed and ICMPv6 Packet
Too Big generated by a higher-level AMT gateway.

`mctx-core` does not construct ICMP messages, infer MTUs, inspect embedded
packets, or apply AMT policy. The caller owns those protocol decisions and
provides the complete validated IP datagram.

This feature is deliberately separate from `raw-packets`:

- `raw-packets` is multicast forwarding with multicast-destination validation
  and AMT-oriented source/group preservation.
- `raw-ip` accepts unicast or multicast destinations and has no multicast or
  ICMP policy.
- Enabling either feature does not alter the normal UDP publication API.

## Enablement

```bash
cargo add mctx-core --features raw-ip
```

## API

The feature exports:

- `RawIpSocketConfig` and its `RawIpPublicationConfig` compatibility alias
- `RawIpContext`
- `RawIpPublication` and `RawIpPublicationId`
- `RawIpSendReport`
- `raw_ip_capabilities()` and `RawIpCapabilities`

An egress selector is mandatory. Use one or both of:

- `with_interface_addr(local_ip)` to resolve and pin an interface from a local
  address
- `with_interface_index(index)` to pin an interface by operating-system index
- `with_bind_addr(local_ip)` to bind the raw socket to an exact local address;
  this also resolves that address to an interface when no separate selector is
  supplied

When both an address and index are supplied, `mctx-core` resolves the address
and rejects any mismatch. It never silently routes an explicitly selected raw
IP publication through an unrelated default interface.

## IPv4 Example

```rust,no_run
use mctx_core::{RawIpContext, RawIpSocketConfig};
use std::net::Ipv4Addr;

let local = Ipv4Addr::new(192, 0, 2, 10);
let mut ctx = RawIpContext::new();
let id = ctx.add_publication(
    RawIpSocketConfig::ipv4()
        .with_bind_addr(local)
        .with_interface_addr(local),
)?;

// Construct and validate this complete IPv4 + ICMPv4 Fragmentation Needed
// packet in the caller. Its destination may be unicast.
let complete_ipv4_icmp_packet: Vec<u8> = todo!("caller-built complete IPv4 datagram");
let report = ctx.send_ip_datagram(id, &complete_ipv4_icmp_packet)?;
assert_eq!(report.source_ip, local.into());
# Ok::<(), Box<dyn std::error::Error>>(())
```

IPv4 transmit uses an `IP_HDRINCL`-style socket. The supplied datagram must
have a valid IPv4 header checksum, exact IHL/total-length consistency, and
non-unspecified source and destination addresses. Linux may normalize the IPv4
header checksum and total length as part of its documented `IP_HDRINCL`
implementation. macOS requires a temporary host-order conversion of `ip_len`
and `ip_off` and rebuilds the IPv4 header checksum; the caller slice is never
mutated. Windows applies its raw-socket policy and can reject non-local source
addresses; such failures are returned directly.

## IPv6 Example

```rust,no_run
use mctx_core::{RawIpContext, RawIpSocketConfig};
use std::net::Ipv6Addr;

let local: Ipv6Addr = "2001:db8:10::1".parse()?;
let mut ctx = RawIpContext::new();
let id = ctx.add_publication(
    RawIpSocketConfig::ipv6()
        .with_bind_addr(local)
        .with_interface_index(7),
)?;

// Construct and validate this complete IPv6 + ICMPv6 Packet Too Big packet
// in the caller. The source must equal `local`.
let complete_ipv6_icmp_packet: Vec<u8> = todo!("caller-built complete IPv6 datagram");
let report = ctx.send_ip_datagram(id, &complete_ipv6_icmp_packet)?;
assert_eq!(report.source_ip, local.into());
# Ok::<(), Box<dyn std::error::Error>>(())
```

There is no portable IPv6 equivalent of IPv4 `IP_HDRINCL`. On Linux and macOS,
the raw socket receives the caller payload after the IPv6 base header and the
kernel rebuilds that base header. To avoid source ambiguity, `raw-ip` requires
`with_bind_addr(...)` for IPv6 and rejects a supplied header source that does
not exactly match it. The supplied traffic class (including DSCP/ECN) and hop
limit select a protocol socket configured with those exact values. IPv6
flow-label and transport-checksum handling remain OS controlled; callers
should provide a correct transport checksum and treat the capability as a
kernel-header path, not byte-for-byte IPv6 header injection.

Protocol sockets are cached by next-header, traffic class, and hop limit, so
those values are not mutated on a socket shared by concurrent sends. Each
publication retains at most 16 protocol sockets; older combinations are
evicted as new combinations are used.

For a link-local IPv6 destination, the configured interface index is carried
in the destination scope ID. Wider-scope unicast and multicast destinations use
scope ID zero.

## Validation And Errors

`send_ip_datagram()` is allocation-light before platform send. It validates:

- IPv4/IPv6 version and minimum header size
- IPv4 IHL, total length, and header checksum
- IPv6 payload length against the provided buffer
- non-unspecified header source and destination addresses
- datagram family against the configured socket family

It intentionally does not parse or construct transport protocols. The report
contains the parsed IP protocol/next-header byte and source/destination values.

Privilege, socket-create, bind, interface-option, routing, and `EMSGSIZE`
errors are returned as their typed `MctxError` wrappers (`RawSocketCreateFailed`,
`RawSocketBindFailed`, `SocketOptionFailed`, or `RawSendFailed`) with the
original `io::Error`. Unsupported platform/family paths return
`RawPacketTransmitUnsupported`; index-only configurations without a family
return the same typed error with an explicit reason, and a zero index returns
`RawInterfaceRequired`. The existing unsupported variant is deliberately
shared with `raw-packets` to keep the public `MctxError` variant set
source-compatible. No raw-IP send falls back to UDP.

## Capability Matrix

Use `raw_ip_capabilities()` when selecting a runtime path:

| Platform | IPv4 | IPv6 |
|----------|------|------|
| Linux | `FullIpDatagram` | `KernelRebuiltIpv6Header` |
| macOS | `FullIpDatagram` | `KernelRebuiltIpv6Header` |
| Windows | `FullIpDatagram` | `Unsupported` |
| Other | `Unsupported` | `Unsupported` |

On Linux, interface pinning uses `SO_BINDTOIFINDEX`; on macOS it uses
`IP_BOUND_IF`; and on Windows IPv4 it uses `IP_UNICAST_IF` with the required
network-byte-order interface index. These options prevent an explicitly
selected publication from taking an unrelated default route.

Raw IPv4 usually requires `CAP_NET_RAW` on Linux and Administrator privileges
on Windows. The Linux interface-binding and namespace test also require
`CAP_NET_ADMIN`. macOS normally requires root. Windows raw IPv6 is explicitly
unsupported rather than emulated or silently rewritten.

## Privileged Linux Namespace Test

The ignored integration test builds an isolated network namespace and veth
pair, sends complete ICMPv4 Fragmentation Needed and ICMPv6 Packet Too Big
datagrams, and observes both at the intended peer:

```bash
sudo cargo test --no-default-features --features raw-ip \
  --test raw_ip_linux_namespace -- --ignored --exact --nocapture
```

It requires `iproute2`, `CAP_NET_ADMIN`, and `CAP_NET_RAW`; it is deliberately
not part of normal CI.