1.0.0-RC: hexagonal core/adapter/compose rewrite - #20
Open
pgodwin wants to merge 308 commits into
Open
Conversation
…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>
Extract the AFP command core from the ASP/ATP session spine, the AFP analogue of SMB's conn.go (§3-bis command-core / session-transport split). A future adapter/dsi (AFP-over-TCP :548) can now drive the same command engine over its own framing without touching ASP/ATP/DDP. - core/service/afp/conn.go: Conn wraps one afpSession; Command(block) → (reply, result), Close() drains forks. CommandHandler/CommandCircuit interfaces + HandlerAdapter (GetServerInfo is the sessionless ASPGetStatus/DSIGetStatus path). Mirrors SMB's NewConn/ServeMessage/ SessionConsumer/SessionCircuit. - dispatchAFP now takes *afpSession (the transport-neutral per-circuit state), not the ASP *session — command dispatch carries no transport knowledge. - asp.go: session holds a *Conn; OpenSession builds it, CloseSession closes it, Command/Write and the two-phase data path route through sess.conn.Command. - conn_test.go: drives GetServerInfo (sessionless), login gate, login→OpenVol→OpenFork, Close-drains-forks, and circuit independence entirely over the seam — no router, no ASP. Verification: gofmt, go vet, go test ./... , -tags all build, core archtest gate, and the cs-tinygo embedded gate all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pull the front half of the M10 cutover (service<->router and transport<->service cross-wiring) forward into its own M-ng milestone: a classicstack-ng whose whole stack runs over inmem links so integration tests can inject DDP/SMB/IPX frames and assert protocol replies across the real router+services — no pcap, no NICs. Today ng is the D5 skeleton that moves zero packets (ports inert, no service wired to the router). Sub-steps: M-ng1 service<->router wiring (do first, lowest risk), M-ng2 transport<->service seams, M-ng3 inmem assembly + integration tests (the exit criterion). Real device links (pcap), TOML-driven config, and the logging cutover stay in M8/M9/M10; M10 now depends on M-ng. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
M8 bundled the logging cutover with the control front-ends, but the cutover (retire netlog/logbuf/metrics) cannot run while internal/app is the live runtime — so M8 could never close before M10, yet M10 depended on M8. Split it: M8 is now the front-ends/codecs/bus-sink/http-auth work (DONE, 🟡 only because the SPA is the held M8-spa), and the logging cutover moves to M10 where it actually executes (it dies with internal/app). New-ring code already logs via core/log; only legacy internal/app still uses netlog. Net effect: the "full M10 in order" path is now executable as M9 -> M-ng -> M10, with M10 the step that unlocks real-client testing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-express the D5 skeleton main's inline assembly loop as a reusable
compose/runtime package: the single load-build-supervise root the
interactive binary, the Windows service wrapper, and the Unix daemon
will all share (the foundation M9 and the M10 cutover both consume).
- runtime.Load(store, codec): build a config.Model (missing file ->
defaults, present -> codec-decoded). The read half of the config path.
- runtime.Build(Options{Model, Telemetry}): construct every registered
non-stub component, register each with the supervisor under filtered
hard-dependency edges (an edge whose target isn't built is dropped, so
a minimal build doesn't fail the topo sort). Returns a Runtime exposing
Start/Stop/Supervisor()/Model()/Built().
- Store+Codec are injected (not chosen by the root) so TOML/file, UCI/ubus,
or in-mem builds pick adapters at the cmd edge; the component set sits
behind an unexported componentSource seam so tests inject a fake instead
of polluting the global registry singleton.
- cmd/classicstack-ng now boots through it (smoke-verified: builds
[AFP MacIP NetBIOS Router SMB], Router-before-AFP, all running, clean stop).
NOT in this slice (kept for M10): real device-link injection (ports stay
inert), TOML-load wired into the ng main, flag parsing. The data-path
cross-wiring hook (service<->router, transport<->service) lands here at M-ng.
Verification: gofmt, go vet, go test ./..., -tags all ng build, core
archtest gate, cs-tinygo gate, and an ng smoke-run all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greenfield fix to the registry factory contract so components are born
fully wired instead of inert. The Factory signature changes from
func(*config.Model) to func(*registry.BuildContext) — a context carrying
the model PLUS the shared collaborators a component binds to (Router,
Telemetry). The model-only signature could only build unrouted ports and
placeholder services (a port came up with a nil router; macip returned a
no-op), which is why everything was inert; a factory now receives its
collaborators directly.
- registry.BuildContext{Model, Router, Telemetry}; Factory + Build take it.
A nil collaborator => the factory builds the inert/standalone form
(graceful degradation preserved for unit/standalone builds).
- compose/runtime.Build builds the shared Router FIRST, threads it into the
BuildContext for every other factory, then runs a cross-wire pass:
each built router.Service is RegisterService'd on its DDP socket and each
router.RoutedPort is Attach'd. AppleTalk services (AFP) are now reachable
through the shared router.
- All 10 reg_* factories + the stub moved to the new signature. EtherTalk/
LocalTalk now receive ctx.Router (inert-but-routed until the device link
lands in slice B); IPX/NetBEUI take none (own mini-routers); AFP gets
SetRouter; macip stays a placeholder (needs NBP/egress seams too).
- runtime gains crossWireRouter + an unexported router() accessor; new test
proves a datagram through the router reaches a cross-wired service.
NOT in this slice (slice B / M10): real port config schema (MAC/framing/
seed-net) + pcap device-link injection (live NIC), TOML-load into ng main.
Verification: gofmt, go vet -tags all, go test ./... (default + all tags),
core archtest gate, cs-tinygo gate, -tags all ng build + smoke-run, all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on (live on a NIC)
Extend core/port.Section with the per-transport fields a live link needs (MAC,
seed network range, seed zone) + a hand-rolled ParseMAC (keeps core free of net),
Validate, and TOML round-trip. Each port reg_*.go now also config.Registers its
section schema so a codec round-trips it.
Add BuildContext.Opener (LinkOpener = func(iface) (link.FrameLink, error)) as the
device-link seam: the cmd edge (classicstack-ng) selects pcap.Open under -tags pcap
(stub otherwise) and injects it via runtime.Options.Opener, so compose/runtime carry
no cgo. The EtherTalk factory builds a per-Start opener + framing.EtherTalk{SrcMAC}
and calls the new ethertalk.NewFromOpener, which reopens the device on every Start
(a closed libpcap handle is terminal — survives a UI Stop→Start). ng main loads
server.toml via runtime.Load(file.Store, toml.Codec).
End-to-end: EtherTalk enabled in server.toml -> built set [AFP EtherTalk MacIP
NetBIOS Router SMB], cross-wired to the router, Start reaches pcap.Open("eth0")
(clean ErrUnavailable on a tagless build — proof the injection is real, not inert).
LocalTalk schema+factory are wired but stay inert (no LLAP framer yet); IPX/NetBEUI
take a section MAC but still no FrameLink — both noted as slice-B follow-ons.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dge (§4/§9d) The EtherTalk factory was binding the opener to Section.Iface raw, bypassing the shared-Bridge virtual-interface concept. Make *port.Section a config.InterfaceProvider (its Iface is a per-port OVERRIDE) and resolve the NIC via Model.EffectiveInterface (new registry.effectiveIface helper). A port with no iface of its own now inherits the global [bridge] NIC — several ports share one interface — and only a port that names its own iface diverges. Verified end-to-end: [bridge] name="en0" + an iface-less [EtherTalk] opens en0. Unit tests cover inherit-vs-override at both the config layer (EffectiveInterface) and the factory layer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ns short/long choice Add adapter/link/framing.LocalTalk: the LLAP<->DDP Framer for the LocalTalk transports (spec/09). A 3-byte LLAP header (dst/src node, type) wraps a short-header (0x01, intra-network) or long-header (0x02, inter-network) DDP datagram. Short+long encode/decode are real and round-trip-tested; LLAP control frames (ENQ/ACK) are skipped on read and node-claim is deferred (the EtherTalk-AARP analogue). The short-vs-long header choice is a ROUTING decision the AppleTalk router already made (it sets Dest/SrcNetwork when it routes to a port via Route -> Unicast/Broadcast), so the framer reads the datagram's own network fields (DestNetwork == SrcNetwork -> short) rather than re-judging against the port's claimed network. The framer takes a small Addr (live network/node) only for what is genuinely not in the datagram: stamping the LLAP source node outbound, and supplying the network to reconstruct an inbound short-header datagram (whose header omits it). A test asserts the header choice tracks the datagram, not the port. LocalTalk stays inert at the factory: it is not NIC-bound (LToUDP multicast / serial, not the pcap opener), so there is no FrameLink to frame yet — the LToUDP + serial FrameLink adapters are the next piece and will wire framing.LocalTalk into the factory. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
adapter/link/ltoudp is the LToUDP core/link.FrameLink: LocalTalk frames
tunnelled over IPv4 multicast 239.192.76.84:1954. The 4-byte per-process
sender ID is the adapter's concern — prepended on Write, stripped on Read,
own-echo dropped inside Read (loopback is on) — so the LLAP framer above sees
clean peer frames. Ported the legacy socket setup (SO_REUSEADDR via an
OS-split setSockOptReuseAddr, TTL 1, loopback on, fat buffers, join-on-any)
onto the FrameLink seam; Read uses a deadline -> link.ErrTimeout so the runport
loop can poll Stop. Pure-Go (net + x/net/ipv4, no cgo): sits outside the
cs-tinygo gate like pcap, but needs no pcap tag.
Wired live in the localtalk factory: framing.LocalTalk{Addr: live} where live
is a new framing.LiveAddr (late-bound, concurrency-safe) Set to the constructed
port — the port's runport Network()/Node() IS the framer's Addr shape. The
LToUDP transport opens via a swappable ltoudpOpen seam per Start, so the port
survives Stop->Start. LocalTalk does NOT consult the shared Bridge or
ctx.Opener for its link (it opens LToUDP directly); ctx.Opener is read only as
the "device backends enabled" switch (nil -> inert, honouring the conformance /
graceful-degradation contract). The Section's Iface for LToUDP is the local
IPv4 address to bind/join on, not a NIC name.
Tests: factory go-live / ignores-bridge / nil-opener-inert / reopen-on-restart;
adapter round-trip + own-echo-drop + closed-terminal (graceful skip with no
multicast NIC); LiveAddr unit test. gofmt, vet -tags all, build -tags all,
archtest, cs-tinygo amd64 gate, full default+all test suites all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
adapter/link/tashtalk is the second LocalTalk core/link.FrameLink: a LocalTalk
segment via TashTalk hardware over USB serial at 1 Mbit/s (spec/08). The
host<->device framing is this adapter's concern — Read runs the
IDLE/IN_FRAME/ESCAPED state machine (state on the frameLink so a frame, or even
a lone escape prefix, split across serial reads reassembles) + a CRC-16/X-25 FCS
check and hands up a clean LLAP frame; Write prepends the 0x01 start marker and
appends the FCS (outbound is not escape-encoded, per spec). So the SAME LLAP
framer + LiveAddr seam drive it, unchanged from LToUDP. Open sends the
1024-null + 0x02 reset init; a Windows-only normalizeSerialPortName adds the
\.\COMn prefix. Imports jacobsa/go-serial, so it sits outside the cs-tinygo
gate like pcap/ltoudp. Host-side node-claim stays deferred with the core's M3
node-claim, same as LToUDP/AARP.
Transport selection: a new Section.Transport field ("" | "ltoudp" | "serial",
validated; default LToUDP) — an explicit field rather than inferring from iface.
The localtalk factory dispatches on it via localTalkOpener(sec), with a second
swappable tashtalkOpen seam beside ltoudpOpen; for serial sec.Iface is the
device path, for LToUDP the IPv4 bind addr. LocalTalk is now fully live over
both transports.
Tests: adapter Write-framing / Read-decode / reassemble-across-chunks /
bad-FCS-discarded / short-discarded / closed-terminal / init-sequence (over an
in-memory fake serial, no hardware); factory serial-dispatch (TashTalk seam
called, LToUDP not); Section transport-validate; TOML round-trip carries the new
field. gofmt, vet -tags all, build all+default, archtest, cs-tinygo amd64 gate,
full default+all suites all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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).
NJRoadfan
reviewed
Aug 25, 2026
There was a problem hiding this comment.
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
Contributor
Author
There was a problem hiding this comment.
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!
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>
Pulls in dea577d, adding multi-file selection (⌘/Ctrl-click, Shift-click range) to the web Finder, with Cut/Copy/Delete/Download Zip/Expand updated to act on the whole selection. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The sidebar's Mounted group only rendered entries with a mountpoint, so a volume opened by another tab/session (browse-only, no FUSE/WinFsp host mount) never appeared even though the server already tracks it in GET /finder/mounted. Show every open session, matching the backend's own "Finder browse sessions and host mounts share one list" contract. Also detect when a session's underlying client transport has actually died (ATP timeout, transport-closed sentinels, raw net errors — not the benign io.EOF client/afp's fork reader returns at end of a fork) and drop that session from the table when it surfaces through any /finder/* request, so a stale "connected" entry does not linger until eject/idle-reap.
… host
Mounted was showing every open session, host-mounted or not, which the
backend's own comment invited ("Finder browse sessions and host mounts
share one list") but conflates two different things in the UI: an OS-level
FUSE/WinFsp mount versus a plain browse connection. Restrict Mounted back
to entries with a mountpoint.
A plain connection (open elsewhere, no host mount) now attaches to its
server's RemoteEndpoint as knownVolumes, so it renders as a child under
that host in its own protocol group (AppleTalk/SMB/NetWare/EtherDFS) —
the same slot a volume from this tab's own live login fills — with a
friendly server+volume path shown on the row. Clicking it reuses the
existing session server-side (finder.Connect/OpenVolume already dedupe by
target), it doesn't open a second login. When discovery hasn't (re)found
the server, a minimal row is synthesized from the mounted data so the
connection stays visible under its protocol group instead of vanishing.
Bumps the classicstack-web submodule for the RemoteEndpoint.knownVolumes
field and the sidebar rendering it needs.
… live Discovery only ever ran once at Client start, plus whenever an operator clicked the sidebar's refresh button (POST /finder/discover) — a server that came online afterward stayed invisible until someone refreshed by hand, even though remember() already publishes an SSE "networks" event the web UI is listening for (telemetry.onFinder -> GoFinderHost.watchNetworks -> composeSidebar -> finder.setServers) whenever a scan's result changes. Add scanLoop, a 30s ticker alongside the existing reapLoop/autoMountAll background loops, re-running the same scanAll used at startup. The push plumbing already existed end-to-end; this just keeps feeding it after the first scan, on both link-layer discovery (AFP/EtherDFS NBP-ish browse) and SMB/NCP host browsing. Also fixes remember()'s change detection to compare the server set instead of position: discoverAFP/SMB/NCP/EtherDFS fan results in from concurrent per-interface/zone goroutines, so an unchanged network can still come back in a different order between two scans. A positional compare would have called that "changed" and fired a spurious SSE event + sidebar re-render on every 30s tick even with nothing new on the wire.
The registry builds each of these components whether or not its section is enabled (so the dashboard shows it Disabled and the web UI can configure it live), so Volumes()/Shares() kept reporting the configured share list even with the service administratively turned off — GET /finder/local (and the underlying resolveLocalFS opener) never checked. A disabled share stayed fully visible and openable in the web Finder. Add componentEnabled(), which checks component.Enableable — already implemented by AFP/SMB/NCP directly and inherited by EtherDFS through its embedded port (etherdfs.Service -> etherport.Port -> frameport.Port, whose Enabled() reads the port.Section it was built with) — and gate both LocalVolumes() (hide) and resolveLocalFS() (reject with a new ErrLocalServiceDisabled, mapped to 403 like the other Finder gate errors) on it. A component with no Enableable capability defaults to visible, matching Supervisor.Status()'s own default, so this only tightens the four services that actually opt into the capability.
make spa always preferred the third_party/classicstack-web submodule pin once populated, so testing a Finder UI change meant committing+pushing to ClassicStack-web and bumping the submodule before it could be built here — exactly the round-trip a local sibling checkout exists to avoid. spa-sib is WEB_DIR pointed at ../ClassicStack-web, reusing spa.sh's own existing escape hatch (and its error message when that path isn't a checkout) rather than adding new resolution logic.
…on drops Add mountOfflineGate to dim/lock the admin app and close open windows when telemetry reports the SSE connection as offline, restoring them on reconnect. Let vite.config pick the web root via WEB_DIR, then a sibling ClassicStack-web checkout, before falling back to the submodule pin. Bump the classicstack-web submodule to e999f0b (classic protocol glyphs in the Finder sidebar, per-fork preview downloads). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
reconcileInterfaceRefsLocked resolved each component's section via model.Get, which only looks in Sections (singletons). NetBEUI, IPX, EtherTalk, and LocalTalk are all Repeated schemas whose sections live in Lists instead, so Get always missed them and SetInterface/RemoveInterface silently skipped reconfiguring them — a live bridge-device change (e.g. en5 -> en1) never reached an already-built NetBEUI port, which then failed to activate the stale device on its next start. Add sectionForComponentLocked to also search Lists by the instance's node name (InstanceName, falling back to the schema key for the unnamed default instance), and use it in the reconcile loop. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Persisted by the running server after the live web-UI interface edit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every component the runtime builds was registered with the supervisor via plain Add, so node.rebuild was always nil. A restart-driven reconfigure (ApplyConfig returning component.ErrNeedsRestart, which every NIC-bound port returns on any config change) then just stopped and restarted the SAME component object -- one that had resolved its interface/device once, at initial process build, and baked it into a closure. It never picked up a later change no matter how many times the interface was edited live. This is what made the earlier supervisor fix (dc5a58b) insufficient on its own: that fix got NetBEUI/IPX/EtherTalk correctly discovered and queued for reconfigure again, but discovery fed into a restart path with nothing to actually rebuild against the current model. Capture a Rebuilder per component at Build time (re-invoking the same componentSource.Build the component came from, against the live model) and register it via AddBuildable instead of Add. Router (reused from the up-front build) and Client (built in a later pass) get no rebuilder, which is the same as the previous Add behaviour for them. Add TestSetInterface_RebuildsNICPort, which reproduces the reported symptom directly: a NIC-bound Configurable component keeps activating the OLD device after SetInterface without this fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This merges the
feature/refactorbranch: a ground-up rewrite of ClassicStack onto ahexagonal (
core/adapter/compose) architecture, plus everything built on top ofit 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
mainisv0.3.0;this is proposed as 1.0.0-RC.
.refactor/00-DESIGN.md,ARCHITECTURE.md):core/holdsprotocol-pure logic with zero I/O imports (enforced by an import-graph CI gate —
reflect,net,encoding/binary,encoding/jsonetc. are all forbidden incore/,which is what keeps a TinyGo/embedded build possible);
adapter/holds the concreteI/O (pcap, sqlite, http, uci, serial, dsi, smbtcp);
compose/wires componentstogether via a registry + supervisor with dependency-ordered start/stop.
strangler migration, milestones A–D, M1–M11, cutover) — see
.refactor/TODO.mdforthe 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 newarchitecture, not migration.
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 trayapp (
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
[Bridge]is now theonly source for backend/device/MAC/frame mode (see
ARCHITECTURE.md). Existingserver.tomlfiles fromv0.3.0will need migration — there is no automatedupgrade path in this PR.
git submodule update --init --recursive(
third_party/classicstack-web). CI andREADME.mdare already updated for this.cmd/classicstacknow boots throughcmd/internal/cli→ the composeruntime instead of
internal/app. Flags/behavior should be equivalent per.refactor/TODO.mdM9/M10, but this is the highest-risk surface for regressionssince it's the main entry point everyone runs.
Known gaps / follow-ups (not blocking, but worth tracking post-merge)
ARCHITECTURE.mdstill describes the pre-refactor runtime topology almost verbatim(only one line changed vs.
main) — it doesn't yet describe the core/adapter/composerings, the registry/supervisor model, or the new cmd/internal/cli entry point. Worth
a follow-up doc pass.
.refactor/TODO.md, a handful of milestones are still open:M8a(share config →share.Managerwiring for AFP/SMB volumes),M8-spa(new-ring SPA, explicitlydeferred/held),
M11opener-dispatch follow-ons. None of these block a build, butthey're real scope not yet closed out.
scripts/ci/compute-release-metadata.sh's tag regex only accepts strictvMAJOR.MINOR.PATCH— av1.0.0-rc1tag will fail CI's release job. If you want anactual pre-release tag (not just merging to
main, which auto-cuts adev-<sha>prerelease), that script needs a pre-release-suffix case first.
CI
Refactor Harness CIis green on the current head (75db6b8, run32545567182).
Note this PR will run under
pr-ci.ymlonce opened againstmain, which hasn'texercised this tree before — worth watching the first run closely.
Heads-up: merging this triggers a release
release-main.ymlruns on every push tomainand publishes a GitHub Release(
dev-<sha>, marked prerelease) automatically — merging this PR will cut a releasebuild across all platform/variant matrix targets. Flagging this explicitly since it's
not something a normal PR merge does in most repos.