Skip to content

Commit e8ee440

Browse files
pgodwinclaude
andcommitted
feat(ncp): NetWare client attach end-to-end — discovery, bindery, dir services, search, connection info
Chased a real NetWare client (Win3.1 NETX + Win98) through the whole attach sequence against captures, implementing each stall per mars_nwe: - Discovery: RIP responder on socket 0x0453 (core/protocol/rip + core/service/rip), internal-network identity derived from the MAC, router broadcast-accept, SAP advertising the internal address; NCP replies sourced from the address the client attached to. - Attach: Negotiate Buffer Size (0x21), static bindery (SUPERVISOR/GUEST/EVERYONE/server) serving 0x35/0x36/0x37, GUEST and empty-user logins always granted; login binds the connection to its bindery object (id/type/login time). - Directory/volume services: full 0x16 read family plus create/delete/ rename/alloc handles, seeded LOGIN dir handle 1 at create-connection, volume usage replies (LE u32s), lock family granted unconditionally. - Search: true NW_FILE_INFO/NW_DIR_INFO entry shapes, encoded high-bit wildcards (0xAA='*' 0xBF='?' 0xAE='.'), DOS '?' matches one-or-zero chars, FCB base/extension matching; Alloc Dir Handle length-prefixed path layout (empty = source handle's dir). - Connection info (Win98 login blocker): Get Connection Information (0x17/0x16 old, 0x1C new) — Win9x asks who is logged in on its own connection after the login verb and treats 0xFB as not-logged-in — plus Get Connection Internet Address (0x13/0x1A) and Get Object Connection List (0x15/0x1B; replaces the previous invented 0x15 shape). - Logging: always-non-nil injected logger (no call-site guards) in NCP and SAP; failed requests log named calls via names.go tables. Win3.1 confirmed e2e (login, DIR, writes); Win98 connection-info fix awaiting e2e. Spec: spec/17-ncp.md updated with all wire layouts. Reference: mars_nwe (nwconn.c, nwbind.c, connect.c) — attributed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 21b2df2 commit e8ee440

22 files changed

Lines changed: 2292 additions & 159 deletions

compose/runtime/integration_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ func TestIntegration_NetBEUICallOverInmemLink(t *testing.T) {
116116

117117
// The real cross-wire: builds the NetBEUI mini-router, attaches the port, and
118118
// registers the NBF engine for CLASSICSTACK.
119-
crossWireTransports(comps, nil)
119+
crossWireTransports(comps, nil, nil)
120120

121121
ctx := context.Background()
122122
if err := comp.Start(ctx); err != nil {
@@ -223,7 +223,7 @@ func TestIntegration_DirectIPXNegotiateOverInmemLink(t *testing.T) {
223223
smb.Name: sm,
224224
portipx.Name: comp,
225225
}
226-
crossWireTransports(comps, nil)
226+
crossWireTransports(comps, nil, nil)
227227

228228
ctx := context.Background()
229229
if err := comp.Start(ctx); err != nil {

compose/runtime/runtime.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ func Build(opts Options) (*Runtime, error) {
256256
// service is a no-op. The returned wiring is retained so a port added at RUNTIME
257257
// can be attached to its mini-router (SetTransportAttacher, below) and so the
258258
// MacIP egress lifecycle can be driven from Start/Stop.
259-
transports := crossWireTransports(comps, opts.MacIPEgress)
259+
transports := crossWireTransports(comps, opts.MacIPEgress, ctx.Logger)
260260

261261
// Wire the user store (§4): build the configured store once and hand it to the
262262
// supervisor (the web UI's user CRUD surface) AND to every built file service as

compose/runtime/transports.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,11 @@ import (
4040

4141
"github.com/ObsoleteMadness/ClassicStack/core/auth"
4242
"github.com/ObsoleteMadness/ClassicStack/core/component"
43+
"github.com/ObsoleteMadness/ClassicStack/core/log"
4344
diagproto "github.com/ObsoleteMadness/ClassicStack/core/protocol/ipx/diag"
4445
ncpproto "github.com/ObsoleteMadness/ClassicStack/core/protocol/ncp"
4546
protocol "github.com/ObsoleteMadness/ClassicStack/core/protocol/netbios"
47+
ripproto "github.com/ObsoleteMadness/ClassicStack/core/protocol/rip"
4648
"github.com/ObsoleteMadness/ClassicStack/core/service/afp"
4749
"github.com/ObsoleteMadness/ClassicStack/core/service/browser"
4850
"github.com/ObsoleteMadness/ClassicStack/core/service/ipxdiag"
@@ -53,6 +55,7 @@ import (
5355
"github.com/ObsoleteMadness/ClassicStack/core/service/nbp"
5456
"github.com/ObsoleteMadness/ClassicStack/core/service/ncp"
5557
"github.com/ObsoleteMadness/ClassicStack/core/service/netbios"
58+
"github.com/ObsoleteMadness/ClassicStack/core/service/rip"
5659
"github.com/ObsoleteMadness/ClassicStack/core/service/sap"
5760
"github.com/ObsoleteMadness/ClassicStack/core/service/smb"
5861
)
@@ -107,7 +110,7 @@ func (w *transportWiring) AttachPort(c component.Component) {
107110
// attach ports added later at runtime (AttachPort). The mini-routers are built whenever
108111
// their consuming service exists (even with ZERO ports at startup), so the first port of
109112
// a family added from the config-builder UI has a live router to join.
110-
func crossWireTransports(comps map[string]component.Component, egressOpener MacIPEgressOpener) *transportWiring {
113+
func crossWireTransports(comps map[string]component.Component, egressOpener MacIPEgressOpener, mkLogger func(scope string) log.Logger) *transportWiring {
111114
nb := netbiosService(comps)
112115
sm := smbService(comps)
113116
w := &transportWiring{}
@@ -143,7 +146,7 @@ func crossWireTransports(comps map[string]component.Component, egressOpener MacI
143146
// by the NetBIOS ipx binding, the direct-hosted leg by the SMB ipx binding.
144147
nbIPXBound := nb != nil && nb.Binds(netbios.TransportIPX)
145148
smbIPXBound := sm != nil && sm.Binds(smb.TransportIPX)
146-
w.ipx = wireIPX(nb, sm, comps, nbIPXBound, smbIPXBound)
149+
w.ipx = wireIPX(nb, sm, comps, nbIPXBound, smbIPXBound, mkLogger)
147150

148151
// The TCP family (direct-hosted SMB over :445; NBT over :139) is a supervised
149152
// adapter listener built inert in the registry; wire its SMB consumer + address
@@ -307,7 +310,7 @@ func wireNetBEUI(nb *netbios.Service, comps map[string]component.Component) *net
307310
// nbIPXBound / smbIPXBound gate the two IPX legs by the operator's transport bindings:
308311
// the NB-IPX session leg by NetBIOS's ipx binding, the direct-hosted-SMB leg by SMB's
309312
// ipx binding. A leg whose service is present but whose binding is off is not wired.
310-
func wireIPX(nb *netbios.Service, sm *smb.Service, comps map[string]component.Component, nbIPXBound, smbIPXBound bool) *ipxrouter.Router {
313+
func wireIPX(nb *netbios.Service, sm *smb.Service, comps map[string]component.Component, nbIPXBound, smbIPXBound bool, mkLogger func(scope string) log.Logger) *ipxrouter.Router {
311314
// Resolve the effective consumers after applying bindings: a service whose ipx
312315
// binding is off contributes nothing to the IPX mini-router.
313316
if nb != nil && !nbIPXBound {
@@ -353,6 +356,9 @@ func wireIPX(nb *netbios.Service, sm *smb.Service, comps map[string]component.Co
353356
if sapAdv == nil {
354357
sapAdv = sap.New(r)
355358
sapAdv.SetIdentity(r.Network(), r.Node())
359+
if mkLogger != nil {
360+
sapAdv.SetLogger(mkLogger(sap.Name))
361+
}
356362
}
357363
sapAdv.Register(e)
358364
}
@@ -408,11 +414,31 @@ func wireIPX(nb *netbios.Service, sm *smb.Service, comps map[string]component.Co
408414
// engine; its SAP entry (File Server 0x0004 @ 0x0451) is registered with the shared
409415
// advertiser so NETx/VLM discover it. The transport holds the advertiser handle for
410416
// its "sap: advertising" dashboard prop and to stop it on teardown.
417+
//
418+
// Discovery plumbing (the NetWare client attach sequence, per mars_nwe): the SAP
419+
// entry advertises the server at its INTERNAL network address (internal-net:
420+
// 00-00-00-00-00-01:0451), never the wire address — the client then broadcasts a
421+
// RIP request for that network (GetLocalTarget) and will not open an NCP connection
422+
// until it is answered, taking the answer's source MAC as the frame address. So the
423+
// mini-router is given the internal identity (it must accept datagrams addressed to
424+
// it) and a RIP responder is stood up on socket 0x0453 owning that network.
411425
if nc != nil {
426+
internalNet := ipxrouter.DeriveInternalNetwork(r.Node())
427+
r.SetInternalNetwork(internalNet)
428+
412429
t := nc.NewOverIPX(r)
413430
_ = r.RegisterSocket(ncpproto.NCPSocket, t)
414-
sapReg(nc.SAPEntry())
431+
e := nc.SAPEntry()
432+
e.Network = internalNet
433+
e.Node = ipxrouter.InternalNode
434+
sapReg(e)
415435
t.SetSAP(sapAdv)
436+
437+
responder := rip.New(r)
438+
responder.SetNetworks(internalNet)
439+
_ = r.RegisterSocket(ripproto.Socket, responder)
440+
responder.Start()
441+
t.SetRIP(responder)
416442
}
417443
// Start the shared advertiser and register it on the SAP socket once any service
418444
// registered an entry. (The NB-IPX entry may register later, from the async claim

compose/runtime/transports_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func TestCrossWireTransports_NetBEUIToSMB(t *testing.T) {
6969
browser.Name: br,
7070
}
7171

72-
crossWireTransports(comps, nil)
72+
crossWireTransports(comps, nil, nil)
7373

7474
// AddPort must have installed the inbound delivery callback on the port.
7575
if port.cb == nil {
@@ -142,7 +142,7 @@ func TestCrossWireTransports_DirectIPXWithoutNetBIOS(t *testing.T) {
142142
"IPX": port,
143143
}
144144

145-
crossWireTransports(comps, nil)
145+
crossWireTransports(comps, nil, nil)
146146

147147
if port.cb == nil {
148148
t.Fatal("direct-IPX without NetBIOS did not attach the IPX port (no delivery callback) — the mini-router was not built off the SMB consumer")
@@ -165,7 +165,7 @@ func TestTransportWiring_AttachPortNetBEUILate(t *testing.T) {
165165
netbios.Name: nb,
166166
smb.Name: sm,
167167
}
168-
w := crossWireTransports(comps, nil)
168+
w := crossWireTransports(comps, nil, nil)
169169
if w.netbeui == nil {
170170
t.Fatal("NetBEUI mini-router was not built with zero ports — a late port would have nowhere to attach")
171171
}
@@ -200,7 +200,7 @@ func TestTransportWiring_AttachPortIPXLate(t *testing.T) {
200200
sm := smb.New(nil)
201201
comps := map[string]component.Component{smb.Name: sm}
202202

203-
w := crossWireTransports(comps, nil)
203+
w := crossWireTransports(comps, nil, nil)
204204
if w.ipx == nil {
205205
t.Fatal("IPX mini-router was not built off the SMB consumer with zero ports")
206206
}
@@ -216,7 +216,7 @@ func TestTransportWiring_AttachPortIPXLate(t *testing.T) {
216216
// transport was wired (no NetBIOS/SMB consumer): the wiring holds nil mini-routers, so a
217217
// late port is simply left alone rather than attached to a phantom router.
218218
func TestTransportWiring_AttachPortNoRouters(t *testing.T) {
219-
w := crossWireTransports(map[string]component.Component{}, nil)
219+
w := crossWireTransports(map[string]component.Component{}, nil, nil)
220220
if w.ipx != nil || w.netbeui != nil {
221221
t.Fatal("mini-routers built with no consumer to drive them")
222222
}
@@ -261,7 +261,7 @@ func TestCrossWireTransports_NoNetBIOS(t *testing.T) {
261261
port := &recordingNetBEUIPort{}
262262
comps := map[string]component.Component{"NetBEUI": port}
263263

264-
crossWireTransports(comps, nil)
264+
crossWireTransports(comps, nil, nil)
265265

266266
if port.cb != nil {
267267
t.Fatal("cross-wire attached a NetBEUI port with no NetBIOS service to feed")

core/protocol/ncp/ncp.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,17 @@ const (
4444
const (
4545
CompletionSuccess uint8 = 0x00 // operation succeeded
4646
CompletionConnNotLogged uint8 = 0x7C // connection not logged in
47-
CompletionNoSuchObject uint8 = 0x96 // bindery object not found
48-
CompletionInvalidConn uint8 = 0x9B // bad connection number / station
47+
CompletionNoSuchObject uint8 = 0xFC // bindery: no such object (mars_nwe -0xfc)
48+
CompletionNoSuchVolume uint8 = 0x98 // volume does not exist (mars_nwe -0x98)
49+
CompletionInvalidConn uint8 = 0x9B // bad connection number / station / dir handle
50+
CompletionBadStation uint8 = 0xFD // bad station (target connection) number (mars_nwe 0xfd)
4951
CompletionNoFiles uint8 = 0x9C // no more matching files (scan end)
5052
CompletionInvalidPath uint8 = 0x9C // invalid path (shares 0x9C in NetWare)
5153
CompletionNoSuchFile uint8 = 0xFF // file/dir not found (generic failure)
5254
CompletionFuncNotSupp uint8 = 0xFB // requested function not supported
5355
CompletionLockFail uint8 = 0xFE // lock / busy
5456
CompletionAccessDenied uint8 = 0x8C // no privileges / access denied
57+
CompletionBadNameSpace uint8 = 0xBF // invalid name space (mars_nwe's AFP-calls reply)
5558
)
5659

5760
// Connection-status bits (the reply header's ConnectionStatus byte). Bit 0x40

core/protocol/rip/rip.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Package rip holds the Novell IPX Routing Information Protocol wire DTOs — the
2+
// request/response format riding IPX socket 0x0453 (IPX packet type 1). A NetWare
3+
// client resolves the network a SAP advertisement names via a RIP Request (the
4+
// "GetLocalTarget" step) before it will open an NCP connection: it broadcasts a
5+
// Request for the advertised network and takes the responder's node as the
6+
// immediate (MAC-level) address for that network.
7+
//
8+
// Wire format (all fields BIG-ENDIAN): a 2-byte operation followed by zero or more
9+
// 8-byte entries — network(4) hops(2) ticks(2). In a Request the hops/ticks of each
10+
// entry are 0xFFFF filler; the network 0xFFFFFFFF asks for all known routes.
11+
//
12+
// Reference: Novell RIP (IPX socket 0x0453); mars_nwe nwroute.c (handle_rip,
13+
// build_rip_buff, send_rip_buff) — the canonical open-source reference (CLAUDE.md #7).
14+
package rip
15+
16+
import "errors"
17+
18+
// Socket is the well-known IPX socket RIP rides.
19+
var Socket = [2]byte{0x04, 0x53}
20+
21+
// IPXType is the IPX packet type for RIP (type 1).
22+
const IPXType uint8 = 0x01
23+
24+
// RIP operation codes (the first two bytes of a RIP packet, big-endian).
25+
const (
26+
OpRequest uint16 = 0x0001 // route query ("GetLocalTarget" when for one net)
27+
OpResponse uint16 = 0x0002 // answer to a request / periodic broadcast
28+
)
29+
30+
// NetworkWildcard in a Request entry asks for all known routes (mars_nwe MAX_U32).
31+
var NetworkWildcard = [4]byte{0xFF, 0xFF, 0xFF, 0xFF}
32+
33+
// HopsUnreachable marks a route as down (16 = infinity). A shutdown broadcast
34+
// advertises every owned network at this metric so clients drop the route
35+
// (mars_nwe send_rip_broadcast mode 2 → hops 16).
36+
const HopsUnreachable uint16 = 16
37+
38+
// EntryLen is the fixed length of one RIP entry: network(4) hops(2) ticks(2).
39+
const EntryLen = 8
40+
41+
// headerLen is the operation field ahead of the entries.
42+
const headerLen = 2
43+
44+
// ErrShort is returned by Unmarshal for a buffer too short to hold an operation.
45+
var ErrShort = errors.New("rip: packet shorter than operation header")
46+
47+
// Entry is one route: the network and its distance in router hops and ticks
48+
// (1 tick ≈ 1/18.2 s). A directly served network is hops 1 / ticks 2 in a
49+
// response (mars_nwe ins_rip_buff(internal_net, 1, 2); a real NetWare 4 server
50+
// answers the same).
51+
type Entry struct {
52+
Network [4]byte
53+
Hops uint16
54+
Ticks uint16
55+
}
56+
57+
// Packet is a parsed RIP request or response.
58+
type Packet struct {
59+
Operation uint16
60+
Entries []Entry
61+
}
62+
63+
// Marshal appends the wire form (operation + entries) to dst and returns it.
64+
func (p *Packet) Marshal(dst []byte) []byte {
65+
dst = append(dst, byte(p.Operation>>8), byte(p.Operation))
66+
for _, e := range p.Entries {
67+
dst = append(dst, e.Network[:]...)
68+
dst = append(dst, byte(e.Hops>>8), byte(e.Hops))
69+
dst = append(dst, byte(e.Ticks>>8), byte(e.Ticks))
70+
}
71+
return dst
72+
}
73+
74+
// Unmarshal parses a RIP packet. Trailing bytes short of a whole entry are
75+
// ignored (clients pad to minimum Ethernet frame length).
76+
func Unmarshal(b []byte) (*Packet, error) {
77+
if len(b) < headerLen {
78+
return nil, ErrShort
79+
}
80+
p := &Packet{Operation: uint16(b[0])<<8 | uint16(b[1])}
81+
b = b[headerLen:]
82+
for len(b) >= EntryLen {
83+
var e Entry
84+
copy(e.Network[:], b[:4])
85+
e.Hops = uint16(b[4])<<8 | uint16(b[5])
86+
e.Ticks = uint16(b[6])<<8 | uint16(b[7])
87+
p.Entries = append(p.Entries, e)
88+
b = b[EntryLen:]
89+
}
90+
return p, nil
91+
}

core/router/ipx/ipx.go

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,25 @@ import (
2626
// NetBIOS-over-IPX name claims.
2727
var BroadcastNode = [6]byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}
2828

29+
// InternalNode is the node ID of the server on its internal network. NetWare's internal
30+
// network always hosts the server at node 00-00-00-00-00-01 (mars_nwe nwserv.c: node
31+
// defaults to 1; a real NetWare 4 server advertises the same). SAP advertises the NCP
32+
// file service at internal-net:InternalNode:0x0451.
33+
var InternalNode = [6]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x01}
34+
35+
// DeriveInternalNetwork returns the default NetWare internal network number for a
36+
// station: the low four bytes of its node ID. The internal network must be nonzero
37+
// and unique on the internetwork; deriving it from the (unique) hardware address is
38+
// the same spirit as mars_nwe's AUTO mode, which derives it from the host's IP
39+
// address. A node whose low bytes are all zero falls back to a fixed nonzero number.
40+
func DeriveInternalNetwork(node [6]byte) [4]byte {
41+
net := [4]byte{node[2], node[3], node[4], node[5]}
42+
if net == ([4]byte{}) {
43+
return [4]byte{0x00, 0x00, 0x00, 0x01}
44+
}
45+
return net
46+
}
47+
2948
// DefaultNetwork is the fall-back IPX network number when the operator has not configured one.
3049
// All-zeros ("local segment, unknown") matches what Win98/NWLink uses before a NetWare server
3150
// assigns a real number, so ClassicStack and its clients appear on the same segment.
@@ -55,14 +74,15 @@ type NodeHandler interface {
5574
// Router dispatches inbound IPX datagrams to socket/node/broadcast handlers and fills source
5675
// addresses on outbound datagrams. Implementations are safe for concurrent use.
5776
type Router struct {
58-
logger log.Logger
59-
mu sync.RWMutex
60-
network [4]byte
61-
node [6]byte
62-
sockets map[[2]byte]SocketHandler
63-
nodes map[[6]byte]NodeHandler
64-
broadcast NodeHandler
65-
ports []Port
77+
logger log.Logger
78+
mu sync.RWMutex
79+
network [4]byte
80+
node [6]byte
81+
internalNet [4]byte // NetWare internal network (zero = none); see SetInternalIdentity
82+
sockets map[[2]byte]SocketHandler
83+
nodes map[[6]byte]NodeHandler
84+
broadcast NodeHandler
85+
ports []Port
6686
}
6787

6888
// NewRouter returns a router with the default network number and a zero node ID. Callers
@@ -98,6 +118,24 @@ func (r *Router) Node() [6]byte {
98118
return r.node
99119
}
100120

121+
// SetInternalNetwork configures the NetWare internal network number. The server is
122+
// addressable on it as internal-net:InternalNode (the NCP file service's advertised
123+
// address, mars_nwe's my_server_adr): inbound datagrams so addressed pass the
124+
// destination filter and dispatch by socket as usual. Zero disables the internal
125+
// network (the default).
126+
func (r *Router) SetInternalNetwork(network [4]byte) {
127+
r.mu.Lock()
128+
r.internalNet = network
129+
r.mu.Unlock()
130+
}
131+
132+
// InternalNetwork returns the configured NetWare internal network number (zero = none).
133+
func (r *Router) InternalNetwork() [4]byte {
134+
r.mu.RLock()
135+
defer r.mu.RUnlock()
136+
return r.internalNet
137+
}
138+
101139
// RegisterSocket attaches handler to inbound datagrams whose destination socket matches.
102140
func (r *Router) RegisterSocket(socket [2]byte, handler SocketHandler) error {
103141
r.mu.Lock()
@@ -208,21 +246,29 @@ func (r *Router) Inbound(d *protocol.Datagram) {
208246
}
209247

210248
// acceptsDest reports whether (network, node) matches the router's identity or is a broadcast.
211-
// Network 0 ("local segment, unknown") is accepted because some clients send name-claim
212-
// broadcasts that way before learning the network number.
249+
// Broadcast-node datagrams are accepted regardless of destination network: we serve every
250+
// segment the port hears, and a client that has learned a real wire network number (e.g. from
251+
// a coexisting NetWare server's RIP/SAP) addresses its broadcasts to that net — a SAP
252+
// GetNearestServer so addressed must still reach the advertiser. For unicast, network 0
253+
// ("local segment, unknown") is accepted alongside our own network, and the NetWare internal
254+
// address (internal-net:InternalNode) is accepted when an internal network is configured.
213255
func (r *Router) acceptsDest(network [4]byte, node [6]byte) bool {
214256
r.mu.RLock()
215257
ours := r.network
216258
myNode := r.node
259+
internal := r.internalNet
217260
_, claimed := r.nodes[node]
218261
r.mu.RUnlock()
219262

220-
if !isZero4(network) && network != ours {
221-
return false
222-
}
223263
if node == BroadcastNode {
224264
return true
225265
}
266+
if !isZero4(internal) && network == internal && node == InternalNode {
267+
return true
268+
}
269+
if !isZero4(network) && network != ours {
270+
return false
271+
}
226272
return node == myNode || claimed
227273
}
228274

0 commit comments

Comments
 (0)