ldls: a network, in a process

docs/plan/ldls.md L3a -- the class bridge, and the first of the three pieces
L3 turned out to be.

/dev/netdev is /dev/pci with the arrows reversed. That hands a device to a
process; this lets a process be one. The kernel side is netdev_ops and nothing
else -- transmit, receive, mac, link_up, the same four entries e1000
publishes -- so a process claims the device, supplies a MAC, and from then on
read() gives it frames the stack wants sent and write() hands it frames that
arrived.

cmd/netecho proves it and is deliberately not a driver: no hardware, none
claimed, every frame answered by the program itself. DHCP so the interface
gets an address at all, ARP so the stack can find it, ICMP so it can be
pinged. All built by hand from RFCs 826, 791, 792 and 2131.

    # netecho &
    netuser: ldls0 is a process now, 2:4c:44:4c:53:1
    netecho: I am 10.9.0.1 and will offer 10.9.0.2
    netecho: answered DHCP, offering an address

    # cat /proc/netif
    interface ldls0   driver netuser   address 10.9.0.2   gateway 10.9.0.1

    # ping 10.9.0.1 3
    netecho: answered an ARP
    netecho: answered a ping
    3 packets transmitted, 3 received, 0% packet loss

The interface got its address by DHCP from a process and then answered ping
from one. lwIP never learns that the thing behind netdev_ops is not a driver,
which is the whole property the bridge exists to have.

It dies the way it should, too. kill the process and link_up() answers false
immediately, so the stack refuses to send -- "ping: sendto: -5" -- rather than
swallowing packets into a driver that is not there. Start another and the
*same* interface comes back, keeping its address. That came free:
netuser_release_all() sits beside pciuser_release_all() in exit() for exactly
the same reason, and the reason is that a process holding a device is one that
may crash.

One thing the kernel needed: netinit() starts DHCP on the devices that exist
when it runs, so an interface created later -- which is how an LDLS-hosted
driver arrives -- would have come up with no address and no way to get one.
net_attach_late() fixes that, and lives in kernel/net/net.c because that is
the only file allowed to include lwIP's headers.

Recorded as not done, because it is not: this proves the bridge, not a driver.
Nothing in that test loads a module. That is why it was done first -- if ping
had not worked here the bridge would be wrong, and now a failure with a real
driver can only be the driver's side. L3b is the SchOS-side host, L3c is the
fifteen symbols for real, and L3 is the two joined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
Nexoniarz 2026-09-14 08:31:06 +02:00
commit 2a441af3c6
14 changed files with 953 additions and 5 deletions

325
cmd/netecho/main.c Normal file
View file

@ -0,0 +1,325 @@
// SPDX-License-Identifier: Apache-2.0
//
// netecho -- a network, in a process.
//
// docs/plan/ldls.md L3a. `/dev/netdev` lets a process be a network interface;
// this is the program that proves it, and it is deliberately *not* a driver.
// It has no hardware, claims none, and answers every frame itself:
//
// DHCP so the interface gets an address at all -- lwIP asks the moment
// the link comes up, and an interface with no address cannot be
// pinged, so this is not optional
// ARP so the stack can find us
// ICMP so `ping 10.9.0.1` answers
//
// Why a fake network and not the Linux driver straight away: the two halves of
// L3 fail differently. If `ping` does not work with this, the bridge is wrong.
// If it works with this and not with a driver, the driver's side is wrong. A
// test that can only fail in one lump tells you nothing about which half.
//
// Everything here is built by hand from the RFCs -- Ethernet II framing,
// ARP (RFC 826), IPv4 (RFC 791), ICMP (RFC 792), DHCP (RFC 2131). All
// published, all specifications, which is docs/licensing.md §5.
#include "types.h"
#include "user.h"
#include "fcntl.h"
#include "netuser.h"
// Us, and the address we hand out. Numbers chosen not to collide with QEMU's
// 10.0.2.0/24 user network, so both interfaces can be up at once.
#define MY_IP 0x0A090001u /* 10.9.0.1, the "router" */
#define PEER_IP 0x0A090002u /* 10.9.0.2, offered to SchOS */
#define NETMASK 0xFFFFFF00u
static const unsigned char my_mac[6] = { 0x02, 0x4c, 0x44, 0x4c, 0x53, 0x01 };
// --- output, since lib/ has no printf ---------------------------------------
static void
puts_(const char *s)
{
int n = 0;
while (s[n]) n++;
write(1, (void *)s, n);
}
static void
putn(long long v)
{
char b[24];
int i = 0;
if (v < 0) { write(1, "-", 1); v = -v; }
if (v == 0) { write(1, "0", 1); return; }
while (v > 0) { b[i++] = (char)('0' + (v % 10)); v /= 10; }
while (i-- > 0) write(1, &b[i], 1);
}
static void
putip(unsigned int ip)
{
putn((ip >> 24) & 0xff); puts_(".");
putn((ip >> 16) & 0xff); puts_(".");
putn((ip >> 8) & 0xff); puts_(".");
putn(ip & 0xff);
}
// --- reading and writing the wire -------------------------------------------
static unsigned short
rd16(const unsigned char *p) { return (unsigned short)((p[0] << 8) | p[1]); }
static unsigned int
rd32(const unsigned char *p)
{
return ((unsigned int)p[0] << 24) | ((unsigned int)p[1] << 16) |
((unsigned int)p[2] << 8) | p[3];
}
static void
wr16(unsigned char *p, unsigned short v) { p[0] = (unsigned char)(v >> 8); p[1] = (unsigned char)v; }
static void
wr32(unsigned char *p, unsigned int v)
{
p[0] = (unsigned char)(v >> 24); p[1] = (unsigned char)(v >> 16);
p[2] = (unsigned char)(v >> 8); p[3] = (unsigned char)v;
}
// The one's-complement sum every IP protocol uses. RFC 1071.
static unsigned short
csum(const unsigned char *p, int n, unsigned int seed)
{
unsigned int s = seed;
int i;
for (i = 0; i + 1 < n; i += 2)
s += (unsigned int)((p[i] << 8) | p[i + 1]);
if (i < n)
s += (unsigned int)(p[i] << 8);
while (s >> 16)
s = (s & 0xffff) + (s >> 16);
return (unsigned short)(~s & 0xffff);
}
static void
eth_header(unsigned char *f, const unsigned char *dst, unsigned short type)
{
int i;
for (i = 0; i < 6; i++) f[i] = dst[i];
for (i = 0; i < 6; i++) f[6 + i] = my_mac[i];
wr16(f + 12, type);
}
// --- ARP (RFC 826) -----------------------------------------------------------
static int
arp(const unsigned char *in, int len, unsigned char *out)
{
const unsigned char *a = in + 14;
if (len < 14 + 28) return 0;
if (rd16(a) != 1 || rd16(a + 2) != 0x0800) return 0; /* ethernet, ipv4 */
if (rd16(a + 6) != 1) return 0; /* request */
if (rd32(a + 24) != MY_IP) return 0; /* not for us */
eth_header(out, in + 6, 0x0806);
{
unsigned char *r = out + 14;
int i;
wr16(r, 1); wr16(r + 2, 0x0800);
r[4] = 6; r[5] = 4;
wr16(r + 6, 2); /* reply */
for (i = 0; i < 6; i++) r[8 + i] = my_mac[i];
wr32(r + 14, MY_IP);
for (i = 0; i < 6; i++) r[18 + i] = a[8 + i]; /* their mac */
wr32(r + 24, rd32(a + 14)); /* their ip */
}
return 14 + 28;
}
// --- ICMP echo (RFC 792) ------------------------------------------------------
static int
icmp(const unsigned char *in, int len, unsigned char *out)
{
const unsigned char *ip = in + 14;
int ihl, iplen, icmplen;
if (len < 14 + 20) return 0;
if ((ip[0] >> 4) != 4) return 0;
ihl = (ip[0] & 0xf) * 4;
if (ihl < 20 || len < 14 + ihl + 8) return 0;
if (ip[9] != 1) return 0; /* not ICMP */
if (rd32(ip + 16) != MY_IP) return 0; /* not for us */
if (ip[ihl] != 8) return 0; /* not an echo */
iplen = rd16(ip + 2);
if (iplen > len - 14 || iplen < ihl + 8) return 0;
icmplen = iplen - ihl;
eth_header(out, in + 6, 0x0800);
{
unsigned char *o = out + 14;
int i;
// The reply is the request with the addresses swapped and the type
// changed, which is what RFC 792 asks for -- the payload has to come
// back byte for byte so the sender can match it.
for (i = 0; i < iplen; i++)
o[i] = ip[i];
wr32(o + 12, MY_IP);
wr32(o + 16, rd32(ip + 12));
o[10] = o[11] = 0;
wr16(o + 10, csum(o, ihl, 0));
o[ihl] = 0; /* echo reply */
o[ihl + 2] = o[ihl + 3] = 0;
wr16(o + ihl + 2, csum(o + ihl, icmplen, 0));
}
return 14 + iplen;
}
// --- DHCP (RFC 2131) ----------------------------------------------------------
//
// Only the two exchanges lwIP needs: DISCOVER answered with OFFER, REQUEST
// answered with ACK. Everything else about DHCP -- leases expiring, renewal,
// duplicate detection -- is real and is not what this program is for.
static int
dhcp(const unsigned char *in, int len, unsigned char *out)
{
const unsigned char *ip = in + 14;
int ihl = (len >= 14 + 20) ? (ip[0] & 0xf) * 4 : 0;
const unsigned char *udp, *bp;
int msgtype = 0, i, blen;
unsigned char *o, *b;
if (ihl < 20 || len < 14 + ihl + 8) return 0;
if (ip[9] != 17) return 0; /* not UDP */
udp = ip + ihl;
if (rd16(udp) != 68 || rd16(udp + 2) != 67) return 0; /* not to a server */
bp = udp + 8;
if (len < (int)(bp - in) + 240) return 0;
if (rd32(bp + 236) != 0x63825363u) return 0; /* the magic cookie */
// Walk the options for the message type. TLV, terminated by 255.
for (i = 240; (int)(bp - in) + i < len; ) {
unsigned char t = bp[i];
if (t == 255) break;
if (t == 0) { i++; continue; }
if ((int)(bp - in) + i + 2 > len) break;
if (t == 53) msgtype = bp[i + 2];
i += 2 + bp[i + 1];
}
if (msgtype != 1 && msgtype != 3) return 0; /* DISCOVER/REQUEST */
// Broadcast the reply. A client with no address yet cannot be reached any
// other way, which is exactly why DHCP is built on broadcast.
{
static const unsigned char bcast[6] =
{ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff };
eth_header(out, bcast, 0x0800);
}
o = out + 14;
b = o + 20 + 8;
for (i = 0; i < 240; i++) b[i] = 0;
b[0] = 2; /* BOOTREPLY */
b[1] = 1; b[2] = 6;
for (i = 0; i < 4; i++) b[4 + i] = bp[4 + i]; /* xid, echoed */
wr32(b + 16, PEER_IP); /* yiaddr */
wr32(b + 20, MY_IP); /* siaddr */
for (i = 0; i < 6; i++) b[28 + i] = bp[28 + i]; /* chaddr */
wr32(b + 236, 0x63825363u);
blen = 240;
b[blen++] = 53; b[blen++] = 1; b[blen++] = (msgtype == 1) ? 2 : 5;
b[blen++] = 54; b[blen++] = 4; wr32(b + blen, MY_IP); blen += 4;
b[blen++] = 51; b[blen++] = 4; wr32(b + blen, 86400); blen += 4;
b[blen++] = 1; b[blen++] = 4; wr32(b + blen, NETMASK); blen += 4;
b[blen++] = 3; b[blen++] = 4; wr32(b + blen, MY_IP); blen += 4;
b[blen++] = 255;
while (blen < 300) b[blen++] = 0; /* pad to a sane size */
wr16(o + 20, 67); wr16(o + 20 + 2, 68);
wr16(o + 20 + 4, (unsigned short)(8 + blen));
o[20 + 6] = o[20 + 7] = 0; /* UDP checksum: optional over IPv4 */
o[0] = 0x45; o[1] = 0;
wr16(o + 2, (unsigned short)(20 + 8 + blen));
wr16(o + 4, 0); wr16(o + 6, 0);
o[8] = 64; o[9] = 17;
o[10] = o[11] = 0;
wr32(o + 12, MY_IP);
wr32(o + 16, 0xFFFFFFFFu);
wr16(o + 10, csum(o, 20, 0));
return 14 + 20 + 8 + blen;
}
// --- main ---------------------------------------------------------------------
int
main(void)
{
struct netuser_create c;
unsigned char in[NETUSER_MTU], out[NETUSER_MTU];
int fd, i, n;
long arps = 0, pings = 0, dhcps = 0;
fd = open("/dev/netdev", O_RDWR);
if (fd < 0) { puts_("netecho: cannot open /dev/netdev\n"); exit(); }
for (i = 0; i < (int)sizeof(c.name); i++) c.name[i] = 0;
for (i = 0; i < 6; i++) c.mac[i] = my_mac[i];
c.pad[0] = c.pad[1] = 0;
c.name[0] = 'l'; c.name[1] = 'd'; c.name[2] = 'l'; c.name[3] = 's';
if (ioctl(fd, NETUSERIOC_CREATE, &c) < 0) {
puts_("netecho: /dev/netdev is already somebody else's\n");
exit();
}
puts_("netecho: this process is now an interface. I am ");
putip(MY_IP);
puts_(" and will offer ");
putip(PEER_IP);
puts_("\n");
for (;;) {
n = read(fd, (char *)in, sizeof(in));
if (n <= 0) {
// The kernel took the interface back, which happens when the
// descriptor is released -- there is nothing left to do.
puts_("netecho: the interface went away\n");
exit();
}
if (n < 14)
continue;
{
unsigned short type = rd16(in + 12);
int r = 0;
if (type == 0x0806) {
r = arp(in, n, out);
if (r) { arps++;
if (arps == 1) puts_("netecho: answered an ARP\n"); }
} else if (type == 0x0800) {
r = dhcp(in, n, out);
if (r) { dhcps++;
puts_("netecho: answered DHCP, offering an address\n"); }
if (!r) {
r = icmp(in, n, out);
if (r) { pings++;
if (pings == 1) puts_("netecho: answered a ping\n"); }
}
}
if (r > 0)
write(fd, (char *)out, r);
}
}
}