Skip to content

1.0.0-RC: hexagonal core/adapter/compose rewrite - #20

Open
pgodwin wants to merge 298 commits into
mainfrom
feature/refactor
Open

1.0.0-RC: hexagonal core/adapter/compose rewrite#20
pgodwin wants to merge 298 commits into
mainfrom
feature/refactor

Conversation

@pgodwin

@pgodwin pgodwin commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Summary

This merges the feature/refactor branch: a ground-up rewrite of ClassicStack onto a
hexagonal (core / adapter / compose) architecture, plus everything built on top of
it since the rewrite landed. It replaces the old internal/app, port/, protocol/,
service/, router/, pkg/, netlog, capture/, config/ tree entirely.

253 commits, 2173 files changed (+393,924 / -64,367). Latest tag on main is v0.3.0;
this is proposed as 1.0.0-RC.

  • Architecture (.refactor/00-DESIGN.md, ARCHITECTURE.md): core/ holds
    protocol-pure logic with zero I/O imports (enforced by an import-graph CI gate —
    reflect, net, encoding/binary, encoding/json etc. are all forbidden in core/,
    which is what keeps a TinyGo/embedded build possible); adapter/ holds the concrete
    I/O (pcap, sqlite, http, uci, serial, dsi, smbtcp); compose/ wires components
    together via a registry + supervisor with dependency-ordered start/stop.
  • Migration was staged and merged incrementally (Phase 1 harness → Phase 2
    strangler migration, milestones A–D, M1–M11, cutover) — see .refactor/TODO.md for
    the full step-by-step log and design rationale for each seam. The cutover itself
    (deleting the legacy runtime, repointing binaries at the new run-core) landed
    2026-06-18 (21f8d1b, 511299a); everything since is feature work on the new
    architecture, not migration.
  • Since cutover, notable additions: a unified file client (csmount/csfs)
    mounting AFP/SMB/NCP/EtherDFS shares via WinFsp/macFUSE/libfuse; a Finder-style web
    admin UI (now a git submodule, third_party/classicstack-web); macOS/Windows tray
    app (cmd/classicstack-tray); TashTalk serial and LToUDP LocalTalk transports;
    direct-hosted SMB-over-IPX and NetBIOS browser/messenger services; a Windows
    installer (Inno Setup) built in CI; read-write ZIP filesystem backend.

Compatibility notes

  • Config: legacy top-level bridge identity keys are rejected; [Bridge] is now the
    only source for backend/device/MAC/frame mode (see ARCHITECTURE.md). Existing
    server.toml files from v0.3.0 will need migration — there is no automated
    upgrade path in this PR.
  • Submodule: cloning now requires git submodule update --init --recursive
    (third_party/classicstack-web). CI and README.md are already updated for this.
  • Binaries: cmd/classicstack now boots through cmd/internal/cli → the compose
    runtime instead of internal/app. Flags/behavior should be equivalent per
    .refactor/TODO.md M9/M10, but this is the highest-risk surface for regressions
    since it's the main entry point everyone runs.

Known gaps / follow-ups (not blocking, but worth tracking post-merge)

  • ARCHITECTURE.md still describes the pre-refactor runtime topology almost verbatim
    (only one line changed vs. main) — it doesn't yet describe the core/adapter/compose
    rings, the registry/supervisor model, or the new cmd/internal/cli entry point. Worth
    a follow-up doc pass.
  • Per .refactor/TODO.md, a handful of milestones are still open: M8a (share config →
    share.Manager wiring for AFP/SMB volumes), M8-spa (new-ring SPA, explicitly
    deferred/held), M11 opener-dispatch follow-ons. None of these block a build, but
    they're real scope not yet closed out.
  • scripts/ci/compute-release-metadata.sh's tag regex only accepts strict
    vMAJOR.MINOR.PATCH — a v1.0.0-rc1 tag will fail CI's release job. If you want an
    actual pre-release tag (not just merging to main, which auto-cuts a dev-<sha>
    prerelease), that script needs a pre-release-suffix case first.

CI

Refactor Harness CI is green on the current head (75db6b8, run
32545567182).
Note this PR will run under pr-ci.yml once opened against main, which hasn't
exercised this tree before — worth watching the first run closely.

Heads-up: merging this triggers a release

release-main.yml runs on every push to main and publishes a GitHub Release
(dev-<sha>, marked prerelease) automatically — merging this PR will cut a release
build across all platform/variant matrix targets. Flagging this explicitly since it's
not something a normal PR merge does in most repos.

pgodwin and others added 30 commits June 13, 2026 21:46
…X capture-replay

Finish the in-core M7 file-services command engines (the items finishable at
the command-engine altitude; §10d and legacy deletion stay gated on later
milestones — see TODO).

SMB NT_CREATE_ANDX (core/service/smb/ntcreate.go) — the NT/2000/XP
open-or-create path, the open a real Windows client uses. Over the bound
*Share's FS it honours CreateDisposition (SUPERSEDE/OPEN/CREATE/OPEN_IF/
OVERWRITE/OVERWRITE_IF, gated against existence) and the FILE_DIRECTORY_FILE /
FILE_NON_DIRECTORY_FILE CreateOptions (opens files AND directories; a directory
FID carries no open fork.File). DesiredAccess maps to a read-only/RW handle the
WRITE path enforces; the WCT=34 reply packs the four NT timestamps, ext-attrs,
alloc/EOF sizes and the Directory flag. Storage reached only via sh.FS().
ntcreate_test.go covers create/collision, open/missing, read-only-handle write
denial, directory create + dir/file mismatch statuses, bad-TID. The dispatch
not-supported probe now uses LOCKING_ANDX (genuinely unimplemented).

NetBIOS datagram + node-status paths (core/service/netbios/nbf_datagram.go) —
the NBF engine's HandleFrame now answers the two connectionless responder paths
alongside the session machine: STATUS_QUERY → STATUS_RESPONSE (node-status name
table built from the engine's own name set, truncated to the requester's
advertised buffer with the more/too-big flags) and DATAGRAM/DATAGRAM_BROADCAST
decoded to names+payload and routed to a new optional DatagramConsumer seam
(SetDatagramConsumer, the datagram analogue of SessionConsumer — a browser/
mailslot service plugs in there without touching the transport; until one does,
datagrams drop after decode). nbf_test.go covers status answer/foreign-ignore/
truncation and datagram deliver/drop.

Capture-replay (core/protocol/netbios/nbipx_capture_test.go) — three real
frames from captures/ipx.pcap decode→re-encode byte-identical: NB-IPX
name-service FIND.NAME, NMPI NAME_CLAIM (0xF1), NMPI MAILSLOT_SEND (0xFC,
carrying the \MAILSLOT\BROWSE browser announcement + embedded SMB). Exercises
the codec the M7 NBIPX session transport rides on.

Deferred and recorded in TODO: §10d same-FS AFP+SMB coordination (needs the
shared bus/FS that M8a builds — today AFP/SMB build separate FS stacks with a
nil bus); the AFP captures are link-layer (LLAP/DDP/AARP) so AFP parity stays
golden-vector tests; locking/MPX/raw stay STATUS_NOT_SUPPORTED; legacy
service/{afp,smb,netbios} deletion is blocked on the M8/M8a→M10 cutover.

gofmt + vet clean, archtest green (uncached), default + -tags all builds pass,
full go test ./... green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…bject

Correct the §10d wording: each service keeps its OWN shareFS instance (AFP
needs the AppleDouble fork engine, SMB the bare data fork, each its own codec)
even when an AFP volume and SMB share export the same host directory. What they
share is the event bus — §10d is publish-on-mutation + Origin-filtered subscribe,
one Publish per mutation, many reactors. M8a recognises two specs naming the same
host path and hands both share.Build calls one common bus.Bus. The earlier
"shared bus/FS" phrasing conflated the two.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…all transports

Break the browser out of SMB in the design. The browser (host/domain announce,
master-browser elections, GetBackupList, RAP NetServerEnum2 browse list) is a
NetBIOS *datagram*-layer service, not part of the SMB *session* protocol — the
legacy code wrongly buries it in service/smb. It is the datagram analogue of the
§3-bis command-core/session-transport split: one browser command core fed by the
DatagramConsumer seam of all three NetBIOS transports (NetBEUI/IPX/NBT), zero
per-transport browser code.

00-DESIGN.md: new §3-ter (the browser service + its DatagramConsumer plug-in, the
read-only BrowseList() seam SMB's IPC$ \PIPE\LANMAN handler consumes, optional via
the §8 registry); package layout adds core/service/browser and adapter/netbios-tcp.

02-PHASE-migration.md: M7's TCP-transport bullet split — smbtcp = direct-TCP :445
only; new adapter/netbios-tcp = NBT (RFC1001/1002, name/datagram/session) feeding
the SAME NetBIOS Session+Datagram seams as NBF/NBIPX (most vintage TCP clients use
:139, not :445). New M7d step migrates the browser out of service/smb.

TODO.md: M7b re-scoped to direct-TCP :445; new M7b2 (NBT adapter) and M7d (browser
service) rows.

No code changed — design/plan only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…config value

The NetBIOS computer name is consumed by three services (NetBIOS claims it, SMB
advertises it, the browser announces it), so it must have ONE source of truth.
Today NetBIOS takes serverName via constructor while SMB carries an independent
workgroup and has no server-name field at all — nothing connects them, so config
could let them drift.

Fix is single ownership, not divergence detection: new §4-bis makes server
identity a top-level config.Identity{Hostname, Workgroup} section (alongside
Logging/Router/Bridge), NOT a field on any component section. The registry reads
it once and hands the same Hostname to NetBIOS + SMB (new SetServerName,
advertised in NEGOTIATE — today SMB only has SetWorkgroup) + browser. With no
per-service hostname field, "SMB and NetBIOS names differ" is unrepresentable —
stronger than a cross-section equality check. The model Validate backstops any
externally-surfaced second name (e.g. a hand-edited UCI key) with a clear error —
the requested "error if they vary" guard, as defence-in-depth not the primary
mechanism. Hostname change is restart-grade for NetBIOS (re-claim per transport).

Lands in M8a with the config sections (none exist before then); the disconnect is
known and deliberately not patched piecemeal ahead of the config layer.

00-DESIGN.md §4-bis; 02-PHASE-migration.md M8a identity-wiring bullet; TODO M8a row.
No code changed — design/plan only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d (SMB runs without NetBIOS)

Correct the §4-bis framing: SMB runs without NetBIOS (direct-TCP :445 has no
NetBIOS layer; a deployment can be AFP-only or SMB-:445-only with NetBIOS off),
so the hostname is a SERVER-level property consumed by NetBIOS/SMB/browser, owned
by none — not a "NetBIOS computer name" SMB borrows.

Consequences captured: the registry hands Hostname to whichever consumers are
enabled (SMB always; NetBIOS only if enabled; browser if linked); a NetBIOS-less
server still drives SMB's advertised name from the same field. Validation is
layered — a baseline hostname check always applies, but the NetBIOS ≤15-byte /
upper-case rule is a CONSUMER constraint enforced only when NetBIOS is enabled
(a 20-char name is legal for an SMB-:445 / AFP-only server, rejected once NetBIOS
turns on, with NetBIOS named as the constraint source). Hostname change is
restart-grade for NetBIOS AND for direct-TCP SMB's advertised name.

00-DESIGN.md §4-bis; 02-PHASE-migration.md M8a bullet; TODO M8a row. Design only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… AND direct/NetBIOS-less)

SMB runs over IPX two ways, and more broadly its session transports split into
two families that SMB itself does not distinguish (all drive the one
transport-agnostic SessionConsumer seam in conn.go):

  NetBIOS-based: NBF (NetBEUI), NBIPX (IPX socket 0x0455), NBT (TCP 139).
  Direct (NetBIOS-less): SMB direct-hosted over IPX (socket 0x0550, MS "NWLink
    direct host"); direct-TCP (:445).

The direct-IPX path (legacy service/smb/over_ipx_direct, socket 0x0550) is a CORE
transport — no net, no NetBIOS layer — driving the same NewConn/ServeMessage/Close
seam as NBF/NBIPX. So SMB-over-IPX exists both with NetBIOS (NBIPX 0x0455) and
without (direct 0x0550). This is also why server identity is not NetBIOS-owned
(§4-bis): SMB has live transports that never touch NetBIOS.

00-DESIGN.md §3-bis: SMB transports listed as two families; cross-link to §4-bis.
conn.go: seam doc + SessionConsumer comment corrected from "a NetBIOS transport"
to "any session transport (NetBIOS-based or direct)" — comment-only, builds+tests
green. 02-PHASE-migration.md: M7 in-core transport list adds direct-IPX 0x0550.
TODO: new M7e row (re-home over_ipx_direct as a core transport).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sionConsumer seam

Add the Microsoft "NWLink direct host" transport: SMB framed straight onto IPX
socket 0x0550 (type-4 PEP) with NO NetBIOS layer — the NetBIOS-less sibling of
NBIPX (which rides the NetBIOS session engine on 0x0455). So SMB-over-IPX now
exists BOTH ways: with NetBIOS (NBIPX 0x0455) and without (direct 0x0550).

core/service/smb/directipx.go — *Service.NewDirectIPX(sender) builds the
transport. It is connectionless (each IPX datagram carries one whole SMB message,
no reassembly) and drives the SAME transport-agnostic SMB SessionConsumer seam
(conn.go NewConn/ServeMessage/Close) that NBF/NBIPX use. It keeps one Conn
(smbSession) per remote IPX endpoint plus a server-assigned CID
([MS-CIFS] §2.2.1.6.4) allocated on NEGOTIATE, stamped into the SMB header
SecurityFeatures field of every response with the request's SequenceNumber
mirrored; SMB_COM_ECHO multi-response (N datagrams, incrementing seq) honoured.

It reaches the IPX wire only through a local DirectIPXSender seam (the
core/router/ipx mini-router's Send satisfies it structurally), so SMB never
imports the mini-router — the same acyclicity discipline as the NetBIOS engines
(go list -deps ./core/router/ipx carries no service/smb). The SMB Service now
tracks transports it owns directly as a circuitCloser set, torn down on Stop.

Re-home of legacy service/smb/over_ipx_direct, stripped of the netbios
SessionContext coupling and encoding/binary (uses core/binaryprimitives).

directipx_test.go drives NEGOTIATE→CID-allocation, circuit-shared-across-messages,
ECHO multi-response, response-ingress-drop, non-SMB-drop, and Stop-closes-circuits
over the REAL IPX mini-router with a recording port (compile-asserting *DirectIPX
satisfies ipxrouter.SocketHandler). Compose registration is M8a (mirrors NBF/NBIPX).

gofmt + vet clean, archtest green (uncached), default + -tags all builds pass,
full go test ./... green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the project rule that protocol structs self-serialise/deserialise
(request.Unmarshal(data) over decoding in the function body), rather than
manipulating bytes inline in protocol call sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… transports

Break the NetBIOS browser out of the legacy SMB service into a standalone
datagram-layer service (§3-ter), the datagram analogue of the SMB session
command core. One browser, fed by the NetBIOS DatagramConsumer seam, common to
NetBEUI/IPX/NBT — SMB carries no browser logic.

core/protocol/browser — the [MS-BRWS] wire codec as self-serialising DTOs
(CLAUDE.md rule #10): MailslotTransaction (the SMB_COM_TRANSACTION
\MAILSLOT\BROWSE envelope), Announcement (host/local-master), DomainAnnouncement,
Election (+ Compare: criteria→uptime→lower-name ordering), GetBackupList
request/response, AnnouncementRequest, and UnwrapPayload (tolerates the Win9x
2-byte preamble). Reflection-free, core/binaryprimitives-based, round-trip tested.

core/service/browser — the command core: a component.Component that IS the
NetBIOS DatagramConsumer. HandleDatagram unwraps the mailslot, drops self-sourced
loop-backs (the announce/election storm guard), records observed servers (browse
list) + machine-group masters, answers AnnouncementRequest, runs the master-
browser election (lose→potential+silent; win→transmit loop→after 3 uncontested
retransmits become local master + emit a local-master announcement), and answers
GetBackupList only while local master (token echoed, sourced from our <1D> name).
Exposes the read-only BrowseList()/BackupList() query API SMB's IPC$ \PIPE\LANMAN
NetServerEnum2 will consume. Election timers are injectable so the machine is
race-tested without real-time sleeps.

Outbound seam added to core/service/netbios: Service.SendDatagram fans a Datagram
to every transport's datagramEgress; the NBF engine emits a CmdDatagram[Broadcast]
UI frame — the outbound mirror of DatagramConsumer. The browser imports
core/service/netbios only for the two seam types; go list -deps
./core/service/netbios carries no service/browser (acyclic). cs-tinygo
blank-imports both new packages.

NBIPX datagram-egress (NMPI mailslot send) and the SMB-side IPC$ NetServerEnum2
consumer (re-home of legacy command_rap_lanman.go calling BrowseList()) are
follow-ons.

gofmt + vet clean, archtest green (uncached; new core pkgs are reflection/net/
binary-clean), default + -tags all builds pass, full go test ./... green
(browser race-tested).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…\PIPE\LANMAN

Wire the SMB side of the browser query: the RAP NetServerEnum2 ("get server
list") clients send over the IPC$ \PIPE\LANMAN pipe inside an SMB_COM_TRANSACTION.
This is the one place the SMB session layer meets the datagram-layer browser
service (§3-ter) — SMB asks the browser for the list and packs the RAP reply; SMB
holds no browser/election logic.

core/service/smb/lanman.go — the SMB_COM_TRANSACTION dispatch case. A TRANSACTION
on the IPC$ pipe whose byte area names \PIPE\LANMAN + RAP function NetServerEnum2
(0x0068) is answered from the browse list via a BrowseProvider seam
(Available() + ServerEntries() []BrowseServer, SetBrowseProvider). BrowseServer is
a small local type the browser satisfies structurally (browser.Available()/
ServerEntries()), so SMB imports no browser package — the
[]browser.ServerEntry→[]smb.BrowseServer adapter is M8a compose wiring, alongside
SetDatagramConsumer/SetSessionConsumer. A potential browser → ERROR_REQ_NOT_ACCEP;
DOMAIN_ENUM mixed with other bits → ERROR_INVALID_FUNCTION; the reply packs
SERVER_INFO_1 records + comment heap. A TRANSACTION on a non-IPC$ tree, or with no
browser wired, answers STATUS_NOT_SUPPORTED / empty-success rather than dropping.

core/service/browser — gains the typed ServerEntries() []ServerEntry +
Available() accessors the SMB consumer needs (BrowseList() kept as a name-only
convenience over them).

lanman_test.go covers the browse-list reply, the potential-browser + domain-enum
gates, no-provider empty success, and the non-IPC$ refusal.

gofmt + vet clean, archtest green (uncached), default + -tags all builds pass,
full go test ./... green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…PE\LANMAN

Add the share-list RAP call (function 0x0000) over the same IPC$ \PIPE\LANMAN
pipe as NetServerEnum2. Unlike the browse list, NetShareEnum is answered straight
from SMB's own state — every bound disk share plus the virtual IPC$ pipe — with
no browser involved.

core/service/smb/lanman.go — the TRANSACTION dispatch now switches on the RAP
function: NetServerEnum2 → browse list (browser), NetShareEnum → share list.
handleNetShareEnum packs a SHARE_INFO_1 record (Name(13)+Pad(1)+Type(2)+
RemarkOff(4)=20) per share: each disk share as STYPE_DISKTREE with its
Description() as the remark, then IPC$ as STYPE_IPC, with a trailing remark heap.

core/service/smb/share.go — Share gains a Description() accessor over the held
*share.Share, for the NetShareEnum remark.

lanman_test.go — proves both records (PUBLIC + IPC$) with their names/types in the
data block.

So the IPC$ RAP layer now answers both queries a client makes: the inter-server
browse list (NetServerEnum2) and the per-server share list (NetShareEnum).

gofmt + vet clean, archtest green (uncached), default + -tags all builds pass,
full go test ./... green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The browser now broadcasts over IPX, not just NetBEUI. *IPXEngine gains
emitDatagram and registers as a datagramEgress, so Service.SendDatagram fans the
browser's HostAnnounce / election / backup-list traffic to NBF AND NBIPX at once.

The NBIPX egress wraps the browser's SMB mailslot payload in an NMPI MailslotSend
(opcode 0xFC), IPX type-20 broadcast on the NB-IPX datagram socket (0x0553), with
the source/destination NetBIOS names in the NMPI header (a group destination maps
to the workgroup name-type). Like the NBF egress it fans to the IPX broadcast node
— the engine has no name→node binding for an out-of-band send. Re-home of the
legacy service/netbios/over_ipx sendNMPIDatagram, stripped of the router import
(the broadcast node + datagram socket are local consts).

nbipx_test.go proves SendDatagram emits the NMPI MailslotSend with the names +
payload round-tripped on the IPX wire.

The browser is now transport-complete: it observes/announces/elects and serves its
list over both NetBEUI and IPX.

gofmt + vet clean, archtest green (uncached), default + -tags all builds pass,
full go test ./... green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…only browser frames

Review correction: the browser should sit entirely on top of NetBIOS via a shared
mailslot layer, with NO per-protocol and NO mailslot-envelope code. The
per-NetBIOS-transport framing (NBF UI-frame / NBIPX NMPI-MailslotSend / NBT
UDP-138) already lives correctly in core/service/netbios — that part stands. What
was mis-layered: the M7d browser marshals/unmarshals the \MAILSLOT\*
SMB_COM_TRANSACTION envelope itself, coupling it to a shared mailslot framing.

Mailslots are a general second-class NetBIOS datagram-delivery mechanism with
several consumers — \MAILSLOT\BROWSE (browser), \MAILSLOT\LANMAN (RAP datagram
form), \MAILSLOT\MESSNGR (messenger / net send, a flagged future want), room for
more (DirectPlay emulation). So the envelope is its own seam.

New §3-quater: core/protocol/mailslot (the envelope codec, lifted out of
protocol/browser) + a mailslot dispatch layer (Consumer registered by mailslot
name + SendMailslot) that plugs into the NetBIOS DatagramConsumer/SendDatagram
seams. Consumers (browser, future messenger) see/send only their own inner frame.
Layering top-to-bottom: consumer frame → mailslot envelope → netbios.Datagram →
per-transport wire framing. The IPC$ \PIPE\LANMAN RAP calls (session path) stay
where they are (§3-ter) — distinct from the datagram-path mailslot announcements.

§3-ter amended (browser holds neither transport nor mailslot-envelope code);
package layout adds protocol/mailslot + service/mailslot + service/messenger
(future). TODO: M7f (the reshape) + M7g (messenger) rows; M7d note records the
correction. Code reshape is M7f (design-first, per request). No code changed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ilslot layer

Reshape (review correction): the browser must hold NO mailslot-envelope code and
NO transport code. The \MAILSLOT\* SMB_COM_TRANSACTION envelope is a SHARED
mailslot framing (browser, LANMAN, future \MAILSLOT\MESSNGR net-send, …), not
browser-protocol — so it becomes its own seam (§3-quater).

core/protocol/mailslot — the envelope codec (self-serialising Write DTO +
NameBrowse/NameLANMAN/NameMessenger consts), lifted verbatim out of
core/protocol/browser. The lift surfaced and fixed a latent bug: the data offset
was a fixed 86, which overran for any mailslot name longer than \MAILSLOT\BROWSE
(e.g. \MAILSLOT\MESSNGR); it now tracks the name length.

core/service/mailslot — the dispatch layer: a Router that IS the NetBIOS
DatagramConsumer (unwraps the envelope, routes the bare body by mailslot name,
case-insensitive, to the registered Consumer) and exposes
SendMailslot(name, src, dest, body, broadcast) (wraps + SendDatagram).

core/service/browser — reworked: now a mailslot.Consumer (HandleMailslot,
registered for \MAILSLOT\BROWSE) sending through a MailslotSink. It holds zero
mailslot-envelope and zero transport code; MailslotTransaction is deleted from
protocol/browser. The per-NetBIOS-transport wire framing (NBF UI-frame / NBIPX
NMPI-MailslotSend) stays in core/service/netbios — that part of M7d/M7d-d stands.

Layering top-to-bottom: browser frame → mailslot envelope → netbios.Datagram →
per-transport wire framing. Each layer owns one concern; nothing reaches around
another. A future \MAILSLOT\MESSNGR messenger (M7g) plugs into the same Router as a
second consumer with no browser/SMB coupling.

go list -deps ./core/service/netbios carries neither service/mailslot nor
service/browser (acyclic). All four packages race-tested green; cs-tinygo
blank-imports both new packages. gofmt + vet clean, archtest green (uncached),
default + -tags all builds pass, full go test ./... green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second mailslot consumer (§3-quater), proving the seam is multi-consumer:
the browser and the messenger both register on the mailslot router and hold
zero envelope/transport code.

- core/protocol/messenger: the [MS-MSRP] single-block "net send"/WinPopup
  frame codec (Message{From,To,Text}, type 0x01 + three NUL-terminated OEM
  strings). No live capture exists, so per CLAUDE.md rule 6 the layout is
  documented from [MS-MSRP] + the stable WinPopup form; parser tolerates a
  missing trailing NUL.
- core/service/messenger: registers for \MAILSLOT\MESSNGR; on receive it
  decodes, logs at Info, and publishes bus.MessageReceived on the new
  bus.TopicMessage so the web UI can show net-send events. Send half
  (Service.SendMessage) is the core a future cmd/csnetsend (T1) wraps.
- core/bus: TopicMessage + MessageReceived event.
- core/protocol/netbios: NameTypeMessenger (<03>).

cs-tinygo blank-imports both new packages; archtest green; go list -deps
./core/service/netbios carries neither messenger package (acyclic). gofmt/
vet clean, default + -tags all builds pass, full suite green (race-clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the authentication/user-store seam the design lacked. Both file
services previously hardcoded guest; now identity is established at
login and filters which shares are enumerable and bindable.

core/auth: reflection-free contract (Authenticator, UserStore) plus a
hand-rolled PBKDF2-HMAC-SHA256 credential codec (salt taken as a param;
no crypto/rand or encoding/hex in core, both pull reflect). AuthSection
carries backend+path only — never secrets.

adapter/auth/local: smbpasswd-style file store (name:salt:hash:flags),
atomic writes at 0600, case-insensitive. Lives in the adapter ring
because salt generation needs crypto/rand. No build tag — always built.

core/share: Permissions gains AllowedUsers (empty = guest/world);
plumbed through fs.ShareSpec and share.Manager.Info.

AFP: FPLogin parses the cleartext user/pass it previously dropped,
validates via SetAuthenticator (nil/empty = guest), filters
FPGetSrvrParms and gates FPOpenVol. SMB: SESSION_SETUP_ANDX parses the
account name, validates cleartext (hashed accepted-as-guest), filters
NetShareEnum/NetServerEnum2 and gates TREE_CONNECT. Restricted shares
report as non-existent, not access-denied, to avoid a presence oracle.

control.Plane gains Users/SetUser/SetUserDisabled/RemoveUser backed by
an optional control.UserAdmin (nil store -> ErrUnavailable), satisfied
by the supervisor — the surface the web UI Users panel will bind to.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ue fix

The TOML/UCI codecs and file/UCI stores were already built (B6/D4/D6);
this slice adds the missing real-section round-trip coverage and fixes
a latent codec bug it surfaced.

The M8a Auth section now round-trips through both codecs, plus an
end-to-end codec -> file.Store -> codec persistence test (the path the
control plane's config-apply drives), proving the store selector a user
writes is what auth.SectionFromModel reads back.

Fix: the UCI tokenizer dropped an empty quoted value (option key ''),
so an option whose string field is unset parsed to too few tokens and
failed the whole Unmarshal. A default config.Model — whose well-known
Logging.Level is "" — could therefore not be reloaded through UCI; only
models that set every string field round-tripped. The tokenizer now
emits an empty token when a quote was opened. TOML was unaffected.

Documented in spec/errata.md "UCI empty-quoted-value tokenizer".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
adapter/log/bus is a core/log.Sink that republishes each log Record as
a bus.LogRecord on bus.TopicLog, translating core/log.Field -> bus.Field
(typed, no reflection). This is what the control plane's Subscribe("log")
relays to the SSE / ubus log viewer — the new-ring equivalent of the
legacy pkg/logbuf broadcaster.

It lives in the adapter ring by design (§6c: "the bus sink is just one
sink — the logger does not depend on the bus"), so core/log stays
bus-free (go list -deps ./core/log carries no core/bus) and a CLI or
embedded build can log to stderr/UART with no bus, SSE, or control plane
linked. This adapter is the sole bridge between the two.

Correctness: the logger reuses one scratch Record and a shared Field
backing array across calls, so the sink copies fields into the published
event (translateFields allocates fresh) — the same defensive copy
ringSink.Write makes. Threshold retunes live via a *LevelVar; a nil bus
makes Write a no-op so wiring code needs no guard. Race-tested.

The actual logging cutover (pointing the live runtime at this sink,
retiring netlog/pkg/logging/pkg/logbuf) stays gated on the M8/M10
compose cutover; this slice delivers the sink that cutover installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…config→ShareSpec mapper

The config→[]fs.ShareSpec mapper for AFP lands as repeated named sections,
the idiomatic UCI/TOML form (one block per volume).

core/config gains a MultiSection concept: a SectionSchema may set
Repeated=true; instances live in a new Model.Lists[key][]Section parallel to
singleton Sections, each keyed by a NamedSection.InstanceName(). Model gains
List/SetList/AddInstance (replace-by-name, order-preserving)/Instance/
RemoveInstance; Clone deep-copies the lists. Pure stdlib — archtest + the
TinyGo amd64 gates stay green (reflection stays in the adapter-ring codecs).

Both codecs round-trip repeated sections: TOML as an array-of-tables under the
lowercased key ([[afpvolumes]]); UCI as repeated `config <type> '<name>'`
blocks, with the UCI block name authoritative on read (a divergent inner
`option name` is reconciled to it).

core/service/afp: VolumeSection — a flat, codec-friendly NamedSection view of
fs.ShareSpec (typed path/fs_type/fork_backend/filename_codec/name_engine/
metastore/read_only/allowed_users, plus an `options` list of key=value entries
mapped into ShareSpec.Extra for backend-specific params) — with Spec()/
SpecsFromModel and RegisterVolumes(). reg_afp.go now builds one Volume per
configured section (ids 1..N) via NewWithVolumes, failing loudly on a bad spec;
a model with no volumes yields the historical zero-volume service. The AFP
share.Manager surface (Add/Update/RemoveShare) was already in from M7c.

Tests: core/config repeated-section API (add/replace/lookup/remove/clone);
AFP VolumeSection field mapping + options→Extra + SpecsFromModel order +
NewWithVolumes from mapped specs; TOML + UCI repeated-section round-trips +
the UCI block-name-authoritative case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ec mapper

The SMB-side mirror of the AFP-volume slice, reusing the core/config
repeated-section machinery (NamedSection + Model.Lists, Repeated schema flag,
TOML array-of-tables / UCI repeated blocks).

core/service/smb: ShareSection — a flat, codec-friendly NamedSection with the
same field shape as afp.VolumeSection (typed path/fs_type/fork_backend/
filename_codec/name_engine/metastore/read_only/allowed_users + an `options`
key=value list mapped into ShareSpec.Extra), plus one SMB-specific field:
`description`, the NetShareEnum remark (AFP volumes have no equivalent). Adds
Spec()/SpecsFromModel and RegisterShares().

smb.ShareSpec gains a Description field; NewShare applies it via
built.SetDescription (description is SMB-specific, not carried on
fs.ShareSpec). reg_smb.go now builds one Share per configured section via
NewWithShares, failing loudly on a bad spec; a model with no shares yields the
historical zero-share service. The SMB share.Manager surface (Add/Update/
RemoveShare) was already in from M7c.

Both file services are now config-driven through the same repeated-section
mechanism.

Tests: ShareSection field mapping + description + options→Extra + clone +
validate + SpecsFromModel order; NewWithShares applies the description.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… share set

Both file services now implement component.Configurable. ApplyConfig ignores
the passed section (a file service's config is the SET of repeated volume/share
sections in Model.Lists, not a singleton) and re-resolves the whole desired set
from the model, reconciling it against the live shares via share.Manager:
afp.Service.ReconcileVolumes / smb.Service.ReconcileShares — name-keyed
(case-insensitive for SMB, as tree-connect matches): add new, update changed
(AFP preserves the volume id across an update), remove dropped. All-or-nothing:
the full desired set is built before swapping, so a bad triple/param aborts the
reconcile leaving the live shares untouched.

The model->spec closure is wired by the registry (SetVolumeResolver/
SetShareResolver in reg_{afp,smb}.go); with no resolver wired ApplyConfig
returns ErrNeedsRestart so the supervisor falls back to its rebuild path.
Editing one share in the UI now reconciles live (no service restart, in-flight
sessions undisturbed) per DESIGN §11b.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…scription

Server hostname/workgroup/description is one well-known top-level config.Identity
field owned by no service (alongside Logging/Router/Bridge), not a per-service
name field — so SMB and NetBIOS cannot diverge. Adds Description (the free-text
server comment a Windows browse list shows next to the name).

core/config/identity.go: Identity{Hostname,Workgroup,Description}; Validate()
baseline (no path/control chars); ValidateForNetBIOS() the <=15-byte rule as a
CONSUMER constraint run only when NetBIOS is enabled; NetBIOSName() upper-cases
(over-length is a validate failure, not silent truncation). Both TOML and UCI
codecs round-trip an identity well-known section.

Consumers wired once by the registry: reg_smb.go SetServerName/SetWorkgroup/
SetDescription (SMB now self-reports name+comment in NetServerEnum2 even with no
browser/NetBIOS — covers direct-TCP :445); reg_netbios.go NewService with the
NetBIOS name when set; browser carries Description on its self ServerEntry via a
new SetDescription.

No central Model.Validate() Apply hook exists yet, so ValidateForNetBIOS is
provided but not yet called by an Apply path (wire it when Apply validation lands).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…red)

When an AFP volume and an SMB share back the same host path, a mutation by one
now reaches the other through one shared FS-mutation bus.

(A) Shared bus per host path: the registry fsBusBroker hands one fs.Bus per
distinct host path; both file-service factories resolve through it via
SetBusResolver(fsBus.busFor). Threaded through share.Build by NewVolumeWithBus /
NewShareWithBus (bus-less constructors kept for tests/zero-config); the registry
builds the initial set through the reconcile path so the shared bus applies from
boot. fs.OriginBus(b, origin) stamps afp/smb onto each event, forwarding to the
same underlying bus.

(B) local_fs publishes fs.Event: OpCreate (CreateDir/CreateFile), OpModify
(write-then-Close, coalesced; a read-only open is silent), OpRename (+OldPath),
OpDelete, with the absolute host path. memfs does not publish (no shared store).

(C) share.Reactor subscribes per distinct bus, drops its own Origin
(fs.SkipOrigin), resolves the affected share(s) by host-path prefix (rename
matches either end), and delivers (share, event) to a notify sink. Each service
builds one in New, subscribes in Start, stops in Stop; ReactorDelivered() is the
observable.

DEFERRED to its own slice: the wire push. The notify sink is a no-op counter; it
does NOT emit AFP attention or SMB CHANGE_NOTIFY frames. SMB conn.go is
request->response with no server-initiated channel (real CHANGE_NOTIFY needs a
new async-push contract across NBF/NBIPX/NBT/direct), and classic AFP has no
per-directory change-notify. The coordination plumbing is complete and tested
end-to-end (AFP creates -> SMB notified, AFP self-event filtered).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…luded)

The deferred wire-push half of §10d, SMB only. A same-host-path FS mutation now
reaches an SMB client as a real CHANGE_NOTIFY completion.

Server-push seam: Conn.SetPushWriter(func([]byte)) on both the smb and netbios
SessionCircuit interfaces. Each transport installs a push closure after NewConn —
NBF via sendSessionData, NB-IPX via a new pushData over the circuit's retained
net/node/sock+conn-ids, direct-IPX via a new pushResponse stamping the circuit
CID. A transport that cannot push never calls it (a held watch then times out).

NT_TRANSACT (0xA0) NOTIFY_CHANGE (Function 0x0004): parse the Setup, register a
held pendingNotify on the session (request ids + bound share), and return nil —
the request is held open, not answered. IPC$/unbound trees are refused, not held.
The reactor sink notifyFSChange (now wired into share.Reactor in place of the
no-op) completes every held watch for the changed share by pushing one
FILE_NOTIFY_INFORMATION record (FILE_ACTION_* from the fs.Op + the changed leaf
in UTF-16LE) over the circuit; one-shot per [MS-CIFS], share-coarse (client
re-reads). The SMB service tracks live sessions so the reactor fans completions
to every watching circuit.

AFP is excluded by protocol: classic AFP has no per-directory change-notify push
(clients poll the volume mod-date; the only ASP attention codes are
shutdown/crash/message). Its reactor sink stays nil — ReactorDelivered is the
observable, no wire frame.

Tests: notify_test.go (NT_TRANSACT parse, held-then-completed, one-shot,
no-watch-no-push, IPC$-refused) and nbf_test.go server-push delivery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The inbound mirror of §10d: an out-of-band change (an editor mutates a file under
a share root OUTSIDE ClassicStack) now publishes onto the same shared FS-mutation
bus, so the SMB reactor completes a held NOTIFY_CHANGE and the client refreshes.

adapter/fswatch (build-tagged fswatch || all, with a no-tag stub so compose links
unconditionally and a tag-less build carries no fsnotify dependency). The Watcher
is a component.Component: Start opens an fsnotify.Watcher, walks each host root
adding every subdirectory (fsnotify watches dirs, not trees; a newly-created dir
is added on its OpCreate), and the loop maps each fsnotify op to an fs.Op
(Remove>Rename>Create>Write>Chmod) and publishes fs.Event{Origin:"fsnotify"} (new
const fs.OriginFSNotify) on the bus for the event host path. Origin is neither
afp nor smb, so BOTH services reactors fire (no SkipOrigin match) — an external
edit notifies every connected client.

Wiring: config.HostPathProvider + Model.HostPaths() (implemented by
afp.VolumeSection / smb.ShareSection — decoupled, untagged) collect the distinct
host roots; registry.BuildHostWatcher builds the watcher over fsBus.busForPath
(the same per-host-path bus a same-path share holds, keyed identically). fsbus.go
tag widened to afp || smb || fswatch || all so the broker exists for a
fswatch-only build. cs-tinygo confirmed to exclude fsnotify (build-tag isolation).

Tests: adapter/fswatch (mapOp precedence, real-fsnotify publish with
Origin/HostPath/Op, idempotent Start/Stop, missing-root-skipped) and core/config
HostPaths dedup. The §10d/§10e coordination pair is now complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion gap

config.Model.Validate(config.ValidateOptions) is the whole-model check the commit
path runs: Identity.Validate (baseline), then every registered section Validate
(singletons in Sections + each repeated instance in Lists, via the schema Validate
when registered else the section own — codecs do not call schema.Validate, so this
is the real validation entry point), then Identity.ValidateForNetBIOS only when
opts.NetBIOSEnabled.

ValidateOptions carries the cross-cutting facts the model cannot infer — NetBIOS
has no config section (it is enabled by being built/wired), so the caller supplies
whether it is in play. The zero value validates with no consumer constraints (the
right default for an SMB-over-:445 / AFP-only server).

control.Plane.Save calls Validate before codec.Marshal, deriving NetBIOSEnabled
from the supervisor Status() (a NetBIOS unit Enabled; matched by the string
"NetBIOS" so core/control imports no service package). An invalid section, or an
over-length hostname once NetBIOS is enabled, is now rejected before it reaches
the store — closing the gap where ValidateForNetBIOS was defined but never called.

Tests: core/config (Validate happy / bad-identity / bad-section / bad-repeated;
NetBIOS-gated rule) and core/control (Save rejects a bad hostname; the NetBIOS
rule gated on enabled / disabled / absent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tp/ubus/inproc

The http/ubus/inproc control adapters had drifted behind the control.Plane
contract (only status/start/stop/restart/reconfigure/list_fs_types/subscribe).
This brings all three up to the full surface: Config, Save, ListInterfaces,
ListZones (the Diagnostics probe), and the Users CRUD (Users/SetUser/
SetUserDisabled/RemoveUser).

The shared inproc.Client interface — the contract the E3 parity test drives all
three through — gains those methods; inproc forwards straight to the Plane, http
adds routes+handlers+client methods, ubus adds JSON-RPC method cases+client calls.
Save now runs Model.Validate server-side (the M8a hook), so an invalid config is
rejected at the front-end.

control.ErrUnavailable round-trips as a recognisable sentinel: http maps it to
HTTP 501 (client reconstitutes via errForStatus), ubus matches the error string
(errFromUbus), so a UI can errors.Is(err, control.ErrUnavailable) the same way
over every transport — the "not in this build / no store wired" shape the
Users/Diagnostics methods carry.

Tests: parity_test gains TestMultiFrontEndParity_NewMethods (Config/ListFSTypes/
ListZones/Users ErrUnavailable parity across all three) and
TestMultiFrontEndParity_UserCRUD (full add->list->disable->remove round-tripped
across http+ubus+inproc against a user-store-bearing supervisor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… control boundary

The last core M8a item: fs.Param.Secret now actually redacts on the
management boundary, the one seam every front-end goes through.

- config.SecretMasker capability (MaskedClone/Unmask) + config.RedactedSecret
  sentinel; Model.MaskSecrets clones-and-masks every SecretMasker section.
- control.Plane.Config() returns MaskSecrets() (secret never leaves in clear);
  Reconfigure unmasks the inbound section against the live one before applying,
  so a blind UI round-trip restores the stored secret and a real edit is kept.
- afp.VolumeSection + smb.ShareSection implement SecretMasker via two core/fs
  helpers (Mask/UnmaskSecretOptions) that consult fs.ParamsFor for Secret keys.
  core/config and core/control carry no fs-type knowledge (structural interface,
  like HostPathProvider); reflection-free, archtest + TinyGo amd64 gates green.

Tests: core/fs mask/unmask/round-trip/no-secrets/no-prior; core/service/{afp,smb}
section MaskedClone/Unmask (+ edit-kept); core/control Config masks (live model
untouched) and Reconfigure unmasks a blind round-trip while passing an edit through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rol adapter

The new-ring HTTP control adapter was world-open. It is now gated by a single
web-admin credential over HTTP Basic auth (no sessions/JWT — honest-security
posture), with a first-run setup prompt that persists the credential to config.

- config.AdminAuth{User,SaltHex,HashHex} (§4-ter): a new well-known typed Model
  field (peer of Identity), round-tripping TOML+UCI into server.toml. Stores a
  salted PBKDF2-SHA256 hash, never plaintext; deliberately NOT a SecretMasker
  field (a hash is not a reversible secret + masking would break Verify on a
  Config() round-trip). Verify uses pure core/auth helpers (no crypto/rand).
- control.Plane.SetAdmin / AdminConfigured + Supervisor.SetAdminAuth: the plane
  stamps a hash-only DTO into the model and auto-saves via the existing Save path.
- adapter/control/http.authGate + handleSetup: first-run → every route but
  POST /setup returns 409 {"setup_required":true}; post-setup → /setup sealed,
  all routes require Basic creds (401 + WWW-Authenticate on miss, constant-time).
  Salt generation lives here (adapter ring owns crypto/rand). Client gained
  NewClientWithAuth (Basic-auth RoundTripper covering SSE) + Setup/SetupRequired.
- Cycle break: core/config now imports core/auth pure crypto, so the file-service
  Auth config section moved core/auth -> core/auth/authsection (it imports
  core/config). core/auth contract+PBKDF2 stay config-free and TinyGo-clean.

Caveat documented: Basic auth is base64 not encrypted and the adapter has no TLS,
so it must run over loopback or behind TLS termination. Legacy service/webui stays
unauthenticated (old ring, retired at M10).

Tests: AdminAuth Verify/Validate/Configured/Clone; TOML+UCI [adminauth] round-trip;
plane SetAdmin stamps+persists + rejects-invalid; HTTP first-run 409, /setup writes
server.toml with hash & no plaintext, post-setup 401/200, /setup-refused-once-set,
authed client round-trip; parity tests seed an admin + use the authed client.
Full gauntlet green: gofmt, vet, archtest, build -tags all, test -tags all, both
TinyGo amd64 gates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… HTTP front-end

The adapter/control/http bullet in the control front-ends section now notes it is
gated by the web-admin credential (§4-ter), and records why ubus/in-process front-ends
carry no Basic-auth gate (ubus.sock unix perms / in-process call locality).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pgodwin
pgodwin marked this pull request as ready for review August 23, 2026 04:15
pgodwin and others added 15 commits August 23, 2026 14:24
Failure observed: "gap between write 1 and 2 = 17.254149ms, want ≥
~20ms" -- a real but rare flake, not a pacer bug. paceLink.Write
schedules each node's next send against an absolute target time
(now + wait + gap, computed once per write), so ordinary scheduler
jitter can only push a measured gap LATER, never earlier, under
time.Sleep's documented "at least the duration" guarantee; confirmed
this by re-deriving the schedule algebraically and by 100+ local runs
(including under synthetic CPU load and -race) that never reproduced
it. The test's own tolerance was just too tight for a loaded/
virtualized CI runner: a flat 2ms against a 20ms target (10%) is
easily exceeded by ordinary timer/scheduling jitter.

Widened both the per-pair gap check and the aggregate elapsed check
to a proportional 25% tolerance (gap/4 = 5ms here) instead of a flat
2ms / zero tolerance, so the test still meaningfully catches "pacing
isn't happening at all" (near-zero gaps) without flaking on jitter
within a normal range.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Runtime.Start attaches and seeds each [Router].members port synchronously
right after StartAll returns, but a real EtherTalk/LToUDP/TashTalk port's
node-address claim (AARP/LLAP) finishes in a background goroutine and Start
never waits for it. When the claim lands after Attach already ran with
NetworkMin()==0, router.Attach's own nonzero guard skips installing the
directly-connected route and seedZone's zero-range guard skips the ZIT too
— and nothing ever retried either install.

The port still announces its claimed range correctly over RTMP and answers
same-network traffic fine (Inbound's same-network fast path needs no
routing-table entry), but any service reply that must round-trip through
router.Reply->Route (ZIP's ATP zone queries, AFP's ASP session reads) does
RoutingTable.GetByNetwork and gets a silent, permanent nil — the reply is
dropped with no error, forever. This reproduced as: Chooser showing only
the AFP server's own zone via GetLocalZones (expected) but never all zones
via GetZoneList, and AFP connections stalling on ASP GetStatus with zero
replies.

Poll briefly (bounded, cancelled on Stop) for a late claim after Attach and
re-run the same route/zone install once it lands; both installs are
idempotent so this is a no-op on the fast path where the claim already
beat Attach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
handleBrRq resolved a zone=* request to the rx port's single zone
(routeZone) to pick routing targets, but buildCommonPayload was still
called with the original unresolved `zone` — so the re-broadcast LkUp
still carried a literal "*" in its tuple. Real NBP responders on other
member networks echo that back verbatim, so their replies never match
a zone-scoped Chooser/Finder query, which is why Finder only listed
servers on the querier's own network segment (multiple AFP servers
being invisible in the Chooser despite csclient's NBP discovery
finding them all).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dhcp_relay=true always sources client addresses via real DHCP relay
(adapter/macipgw/macipgw.go, core/service/macip/macip.go assigner()),
regardless of `mode`. Combined with mode='nat' it silently bypassed
the 192.168.100.0/24 static pool, handing out addresses from the relayed
DHCP server instead. Per server.toml.example, dhcp_relay is meant to
pair only with bridge mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
nat mode + dhcp_relay=true previously silently relayed real DHCP onto
the IP-side network instead of using the NAT static pool, handing MacIP
clients addresses outside the configured 192.168.100.0/24 range with no
indication anything was wrong.

Enforce the invariant at the actual point of use (Egress.New), so it
holds regardless of how the Config was assembled: when both flags are
set, log a warning and clear DHCPRelay before it's consulted anywhere
else in New (natOnly, the DHCP client, the BPF filter). Covered by
TestNewNATModeForcesDHCPRelayOff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
handleOpenAndX handed out a FID over os.OpenFile(dir) without checking
IsDir first, unlike OPEN/CREATE. The open itself succeeded (attrs
correctly reported Directory), so a directory-copy client only found
out on the follow-up READ, which failed with the generic
statusUnsuccessful (ERRSRV/ERRerror) — a code CORE-dialect clients
can't act on, so the whole copy aborted (seen client-side as Windows
"System error 1026" against an IPX SMB share).

Reject with STATUS_FILE_IS_A_DIRECTORY at open time instead, matching
handleOpen/handleCreate/handleNTCreateAndX.
…ports

router.Inbound() unconditionally backfilled a zero source network from
the rx port. That's correct for a short-header LocalTalk-style port
(a zero network there just means "this segment"), but on an extended
port (EtherTalk, LToUDP) a zero source network means the sender is
still in AARP startup range with no claimed address at all — exactly
the case ClassicStack's own probe clients (client/link.NewOpener,
used by both csclient and the web UI's AFP discovery) intentionally
produce rather than running a full AARP claim. Backfilling it there
manufactured a network.node nothing owns and erased the signal
Reply() needs to broadcast instead of unicast, so ZIP/NBP/etc. replies
silently vanished into an unresolvable AARP target.

NBP had the same bug independently: handlePacket defaulted a zero
NBP-tuple network to from.Network() unconditionally, and replyMatches
always unicast via Route() rather than ever using Reply()'s broadcast
path, so even after the router fix NBP still fabricated an address.

Net effect: an unnumbered client's NBP/ZIP queries over EtherTalk/pcap
got no replies at all (confirmed via packet capture — ClassicStack's
router never answered a GetZoneList or BrRq from such a client, while
the same queries over LToUDP worked only because LToUDP unicast is a
multicast-to-everyone no-op on addressing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s contents

resolveSearchPath special-cased a non-wildcard last path element that named
a directory by listing that directory's own children, instead of matching
the name against its parent like every other exact-name lookup. [MS-CIFS]
§2.2.6.2 describes FIND_FIRST2 as a search "within a directory or for a
directory" — a bare "\DRIVER" asks whether an entry named DRIVER exists,
answered with that one entry (Directory attrs set), confirmed against a
real Windows 98 server in spec/captures/nwlink-win98.pcap frames 182-183.

A directory-copy client relies on exactly this to tell files from
directories without opening them first. Getting the child listing instead
meant it never saw the entry it asked about, then failed trying to open the
directory directly ("Cannot find the specified path") once OPEN_ANDX
correctly started rejecting directories.
ReservedSet.unescape restored every "0xNN" store token to its raw rune
unconditionally, regardless of which wire the name was headed for. Control
characters are always-reserved (every backend escapes them on the way in,
independent of ReservedPOSIX/ReservedNTFS), so a classic Mac "Icon\r"
custom-icon marker — its name is literally "Icon" plus a raw CR byte —
round-trips through the store as "Icon0x0D" and then, on the way back out,
got the raw CR restored on EVERY wire, AFP and SMB alike.

That's correct for AFP's Mac clients (WireMacRoman/WireUTF8): a raw CR in a
filename is normal on HFS and they handle it natively. It's wrong for
SMB/NCP's DOS and Windows clients (WireANSI/WireUTF16): Win32 filenames can
never contain a control character under any encoding. A real capture showed
Explorer refusing to copy the file ("The filename you specified is invalid
or too long"), and NT 3.51 File Manager crashing just listing the share.

unescape now takes the destination WireEncoding and leaves a token for a
windowsIllegal rune (control chars, plus the NTFS/FAT reserved punctuation)
as literal "0xNN" text when dst is WireANSI/WireUTF16 — which happens to
already be a stable, round-trippable name, since it's exactly what's on
disk when the backend needed to escape the character for storage too.
SMB clients are always DOS/Windows redirectors, but a share's storage
escaping previously defaulted (with every other protocol) to
ReservedPOSIX — only escaping what the POSIX store itself can't hold. A
Mac-originated name containing an NTFS/FAT-reserved character (';?*<>:"|\'
— all legal on HFS) would sit raw in storage and flow straight to an SMB
client unescaped, a byte Windows could never have created locally.

Add "windows-safe" (NewWindowsSafeFilenameCodec): identical to "identity"
but with ReservedNTFS in place of ReservedPOSIX, so those characters are
escaped in storage the moment a name is written, not just filtered when
read back. ShareSection.fsSpec now defaults an unset FilenameCodec to
"windows-safe" instead of falling through to fs.withDefaults' generic
"identity". Complements, not replaces, the prior Encode-time DOS-wire
unescape guard: that guard is what stops an already-escaped control
character (always-reserved under either set, e.g. a classic Mac "Icon\r"
marker's raw CR) from being restored onto the wire regardless of which
codec a share is on; defaulting SMB to windows-safe additionally escapes
the wider NTFS punctuation set at write time.
…ar state

- adapter/link/framing/aarp: trim Ethernet zero-padding using the 802.3
  length field before ddp.Decode, which rejects anything past the DDP
  header's declared length. Short DDP payloads (ATP TReq, ZIP/ASP
  GetZoneList/GetNetInfo/GetStatus) are always padded on a real NIC and
  were silently dropped as ErrBadLength, while longer packets (NBP, most
  AEP) happened to clear the padding and decoded fine — this is why
  ZIP/ASP looked dead while NBP/AEP worked. Mirrors framing.go's existing
  plain-framer trim.
- compose/supervisor + cmd/internal/cli: a component whose Stop doesn't
  select on ctx could hang StopAll past its deadline, and a second
  Ctrl-C/SIGTERM during that hang was silently dropped (NotifyContext's
  handler goroutine only reads one signal). stopWithDeadline abandons a
  component that misses its deadline instead of blocking the rest of
  teardown; a second interrupt now forces immediate exit.
- adapter/control/finder/local.go + go-finder-host.ts: LocalVolumes now
  returns [] instead of nil (GET /finder/local feeds an array spread in
  the web UI). The web UI's sidebar also now tracks [Client] enablement
  live via the state SSE topic, hiding a scheme's group when its service
  is disabled instead of only reflecting it after a reload.

Excludes server.toml (local test-rig device paths/zone, not a code
change) and the runtime-overwritten *.pcap capture files.
Builds the Win32 (native MSVC 1.2) and Win16 (MSVC 1.5 via otvdm) SMB
test clients on windows-latest, and the macOS AFP test client via the
official Retro68 Docker image on ubuntu-latest, publishing each disk
image (SMBE2E1.img x2, AFPE2E.dsk) as a workflow artifact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Retro68's default "multiversal" interfaces don't implement pre-System-7
APIs like AppleTalk.h yet, which the AFP e2e client needs. Vendor
MPW-GM.img.bin (Apple's real Universal Interfaces, MacBinary DiskCopy
image) under tools/end-to-end/tools/mpw/ alongside the pinned MSVC
kits, and point the Retro68 container at it via INTERFACES=universal +
INTERFACESFILE.

Source: https://ftp.zx.net.nz/pub/micro/macintosh/developer/Tool_Chest/Core_Mac_OS_Tools/MPW_etc/MPW-GM_Images/MPW-GM.img.bin

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
NewWindowsSafeFilenameCodec (SMB's new default) reused ReservedNTFS, which
escapes '*' and '?' along with the genuinely-always-illegal Win32
punctuation. Those two are also FIND_FIRST2/SMB_COM_SEARCH wildcard
metacharacters on the wire, and a search pattern is decoded through the
same per-element codec.Decode as any other path text — resolveSearchPath's
wildcard/pattern split (trans2.go) runs on the string Decode already
produced. So every "*" pattern decoded to the inert token "0x2A" before the
split ever saw a wildcard, and resolveSearchPath treated it as an exact-name
lookup for a file literally called "0x2A" — no share has one, so every
FIND_FIRST2 came back status-success with zero entries. SMB clients saw a
share with no files at all.

Add ReservedSMBWire (ReservedNTFS minus '*'/'?') and use it for
windows-safe instead. ReservedNTFS itself is untouched — that set is about
what an actual NTFS disk can hold, not about parsing a request, so it still
escapes both.

Regression tests at both layers: TestWindowsSafeCodecLeavesWildcardsAlone
(codec-level) and TestTrans2_FindFirst2WildcardWorksOnWindowsSafeCodec
(end-to-end through the real share-build path, not the synthetic
"identity" fixture every other trans2 test uses — which is exactly why
this one slipped past the test suite the first time).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wireshark has LLAP support built into its AppleTalk packet dissector. After dissecting the Sender ID, you can hand off the rest of the packet to the built-in dissector. See: wireshark/wireshark@c0e48a1

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting - that code hasn't been upstreamed yet? Otherwise I might be using Wireshark wrong (which I wouldn't rule out). I just found your thread on https://68kmla.org/bb/threads/wireshark-appletalk-dissector-improvements.52636/ thanks for that!

pgodwin and others added 13 commits August 26, 2026 08:02
docs/cli.md documents every flag, subcommand, exit code, and example for
each cmd/ binary in detail (manual.md's §2 stays the short tour and now
links to it). man/man1/*.1 adds traditional man(1) pages for the
Unix/macOS-relevant tools, installable via `make install-man`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cli.md is the flag/subcommand reference; man pages are a separate
deliverable and don't need inline callouts there. Simplify the
classicstack-svc heading to just "Windows only".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
These 7 capture files were deleted as an unintended side effect of the
previous commit: they were already staged as deleted in the index (likely
by other in-progress work in this shared working tree) when an unrelated
`git add`+`git commit` for docs/cli.md swept them in. Restoring the
content from 771a69b~1 (aefc4c2) where they were last intact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Document the fork-storage backends (appledouble/applesingle/macbinary/
derez/ads/xattr/hfs/native/passthrough/nofork), DOS attribute storage,
extmap.conf type/creator defaults, and the wire<->store filename codec
seam (MacRoman/UTF-8/ANSI/UTF-16, reserved-character escaping, per-protocol
charset rules, and 8.3/31-char name derivation) across AFP/SMB/NCP/EtherDFS.
Cross-link both from cli.md's -fork flag descriptions and from manual.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Remove old OMNITALK references that were missed.
…Roadfan

Document why ltoudp.lua defers to Wireshark's built-in "llap" dissector
instead of re-parsing LLAP/DDP itself, referencing the draft native
packet-ltoudp.c dissector (wireshark/wireshark@c0e48a1) which resolves
"llap" via find_dissector_add_dependency()/call_dissector() the same
way. Credit to @NJRoadfan for flagging that built-in entry point.

Also fixes a stray "fuldl" -> "full" typo introduced in the working tree.
Adds a ChainDisk.a -> ChainDisk.bin rule alongside the existing
Bootstrap/BootWrapper/ChainLoader targets, and resolves vasmm68k_mot from
third_party/vasm (the .exe on Windows, the unix binary otherwise). If the
unix binary isn't checked in, it's built on demand via `make CPU=m68k
SYNTAX=mot` in the vasm source tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
[Logging] gained a Path field (TOML/UCI: path) that, when set, appends
process log lines to a file in addition to stderr — wired into every
component logger via compose/runtime.Build the same way Client.LogFile
already feeds the in-process client's logger. Exposed in the web-admin
Logging settings panel with the path browse widget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A workstation whose aspDataWrite pull is running slow can abandon it and
re-issue the write (a fresh ASP seqNum, so it isn't caught as a duplicate
retransmission) before the server has finished retrying the abandoned one.
handleWrite had no per-session limit, so each abandon-and-reissue left its
own independent retryDataWrite loop running forever alongside the new one.

Traced on ltoudp-netboot.pcap: copying two files into a "Spectre" folder
produced 729 concurrent FPAddIcon writes and 7000+ Write Continue
retransmissions over ~200s, saturating the LToUDP link and eventually
killing the session (no FPWrite/FPCloseFork ever completed).

session now tracks activeWrite, the tid of its one live two-phase write; a
new phase-1 aspWrite supersedes (and immediately drops) whatever previously
held that slot instead of letting it retry independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A capture of an AFP file copy over LToUDP turned out to be 61% unparseable
records: 51,041 of 83,239. The junk is stale send-buffer material — a real
frame header followed by leftovers from earlier frames — which LToUDP, unlike
real LocalTalk, has no CRC to catch.

The framer already refused to DECODE those frames, so nothing downstream was
at risk. But it dropped them silently, and it reads a FrameLink rather than a
socket, so it could not say who sent them. The result was a fault that was
invisible in the log and that filled every capture taken to diagnose it.

llap.Validate checks what a frame asserts about itself: known type byte, DDP
header present for that type, reserved length bits clear, and the declared DDP
length equal to the payload carried. All arithmetic on bytes already in hand.
Replayed over the capture it drops all 51,041 and accepts 32,198, with zero
false positives across the 24,701 frames belonging to complete, correctly
fragmented ATP transactions.

The LToUDP Read path runs it before returning, so junk never reaches the
framer or the capture tee that wraps this link, and reports the source address
of a peer that sends it — first bad frame immediately, then at most one line
per 30s per peer, with running good/bad counts.

Note the limit: a frame corrupted WITHIN its declared length still passes, and
must be caught by whatever reads the payload. One such frame in the capture
reached AFP and drew a -5019 parameter error.

Two tests used deliberately malformed byte strings as stand-in frames and now
use well-formed ones; readWithin also waits for the frame under test rather
than the first thing on a shared multicast group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every shutdown in classicstack.log ends with 15 components reporting "did not
stop before deadline; abandoning" — Router, AEP, NBP, AFP, Browser, DSI,
EtherDFS and the rest. Two separate faults stacked up to produce that.

The first is the wedged component. TashTalk is the first to fail and the only
one genuinely at fault: adapter/serial opened the port with VMIN=1, and
go-serial documents that VTIME is then an INTER-character timer that does not
start until the first byte arrives. On an idle wire Read blocks forever.
Nothing could rescue it — closing the fd does not unblock a POSIX read, and
the SetReadDeadline nudge in tashtalk's Close is a silent no-op on a serial
tty, which is not registered with the runtime poller. So the read loop never
exited and runport.Stop's loopWG.Wait sat there until the budget ran out.

VMIN=0 makes Read return after interCharTimeoutMs whether or not data arrived,
which is what the Open doc comment already claimed it did ("a short
inter-character read timeout so a blocked Read surfaces periodically"). The
framer needed no change: its read loop already maps a zero-byte read to
link.ErrTimeout so it can poll for Stop. The only other caller of
adapter/serial is the client's TashTalk path, through the same framer; SLIP
and PPP are stubs.

The second fault is why one wedged component produced thirty error lines.
StopAll passed the caller's single 5s context to every component in turn, so
once TashTalk had spent it, each of the fifteen components behind it was
handed an already-expired context and recorded as a deadline failure it had
not caused — burying the one component that was actually stuck. Each now gets
its own share of the time left (stopShare), floored at 250ms so a late
component still gets a real chance to stop and ceilinged at 2s so a short
teardown order does not wait around on its first component. A component that
stops promptly returns its share to the pool, so the ordinary case still
finishes at once and the budget only binds when something is genuinely stuck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The VMIN=0 switch in the previous commit was incomplete. On a quiet line the
driver returns zero bytes after the read timeout, and the os.File underneath
reports that as (0, io.EOF) — indistinguishable, to a caller, from a stream
that has genuinely ended. The TashTalk framer maps io.EOF to link.ErrClosed,
so the fix for a port that never stopped would have become a port that tore
itself down roughly four times a second on an idle wire.

A tty held open has no end-of-stream, so a zero-byte EOF is always the
timeout. Open now wraps the port to report it as (0, nil) and let the caller's
own zero-byte handling decide; the framer already maps that to
link.ErrTimeout. A read that returns data keeps its EOF, and a device that
actually goes away fails with a real errno (EIO, ENXIO) that passes through
untouched.

Verified against the hardware at /dev/tty.usbserial-1140: reads on an idle
line return (0, nil) after ~301ms, where before this pair of commits they
never returned at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants