From a94048d7c05bf69584590feca03ed0839621ea50 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Thu, 4 Jun 2026 15:16:00 +1000 Subject: [PATCH 01/23] @ webui: scaffold management control plane, config model, and web UI adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the foundation for an operator web UI gated behind a new `webui` build tag (joined into `all`): - config.Model: an in-memory, serialisable representation of the whole server.toml, with deep Clone for staged edits, ToTOML marshalling (go-toml/v2, now a direct dep), FromSource koanf loading, Defaults, and Save with numbered backups (server.toml.NNNN) + atomic write. - pkg/status: concurrency-safe per-unit status registry (enabled/running, binding, hostnames/zones/shares, dependency edges) for the dashboard. - pkg/metrics: streaming stats hub with fan-out sinks; expvar sink keeps counters visible. Per-second rate broadcasting lives in the control plane. - pkg/control: transport-agnostic management API (status, config stage/apply/save/export with a dirty flag, service restart, interface and serial-port enumeration, SSE-style Subscribe, diagnostics facade). This is the single implementation every UI front-end shares, so a future text/telnet UI reuses it without HTTP. - pkg/serialport: per-OS serial-port enumeration (Windows SERIALCOMM registry; Unix /dev/tty* globs) for the TashTalk dropdown. - service/webui (//go:build webui || all): thin HTTPS adapter over pkg/control — JSON API, SSE stats stream, embedded vanilla-JS SPA (dashboard + config editor + diagnostics), and TLS with an in-memory self-signed certificate fallback. - [WebUI] config section + -webui-* flags (enabled, bind, tls, cert/key). The dynamic router, supervisor, and main.go wiring land in follow-up commits; the web UI hook is not yet driven from main. Both the no-tags (router) and -tags all variants build; config and control plane have unit tests. Co-Authored-By: Claude Opus 4.8 @ --- cmd/classicstack/config_flags.go | 14 + cmd/classicstack/config_ini.go | 7 + cmd/classicstack/main.go | 14 + cmd/classicstack/webui_config.go | 57 ++++ cmd/classicstack/webui_disabled.go | 23 ++ cmd/classicstack/webui_enabled.go | 51 ++++ cmd/classicstack/webui_hook.go | 24 ++ config/defaults.go | 56 ++++ config/fromsource.go | 193 +++++++++++++ config/marshal.go | 59 ++++ config/model.go | 184 ++++++++++++ config/model_test.go | 109 +++++++ config/save.go | 80 ++++++ go.mod | 2 +- pkg/control/config.go | 117 ++++++++ pkg/control/control.go | 111 ++++++++ pkg/control/control_test.go | 93 ++++++ pkg/control/diagnostics.go | 67 +++++ pkg/control/diagnostics_unavailable.go | 34 +++ pkg/control/stats.go | 127 +++++++++ pkg/metrics/expvar_sink.go | 44 +++ pkg/metrics/hub.go | 69 +++++ pkg/serialport/serialport.go | 22 ++ pkg/serialport/serialport_unix.go | 52 ++++ pkg/serialport/serialport_windows.go | 38 +++ pkg/status/registry.go | 111 ++++++++ service/webui/api.go | 162 +++++++++++ service/webui/assets/app.css | 130 +++++++++ service/webui/assets/app.js | 374 +++++++++++++++++++++++++ service/webui/assets/index.html | 56 ++++ service/webui/diagnostics.go | 94 +++++++ service/webui/embed.go | 49 ++++ service/webui/http_util.go | 45 +++ service/webui/plane.go | 28 ++ service/webui/server.go | 134 +++++++++ service/webui/stream.go | 56 ++++ service/webui/tls.go | 81 ++++++ 37 files changed, 2966 insertions(+), 1 deletion(-) create mode 100644 cmd/classicstack/webui_config.go create mode 100644 cmd/classicstack/webui_disabled.go create mode 100644 cmd/classicstack/webui_enabled.go create mode 100644 cmd/classicstack/webui_hook.go create mode 100644 config/defaults.go create mode 100644 config/fromsource.go create mode 100644 config/marshal.go create mode 100644 config/model.go create mode 100644 config/model_test.go create mode 100644 config/save.go create mode 100644 pkg/control/config.go create mode 100644 pkg/control/control.go create mode 100644 pkg/control/control_test.go create mode 100644 pkg/control/diagnostics.go create mode 100644 pkg/control/diagnostics_unavailable.go create mode 100644 pkg/control/stats.go create mode 100644 pkg/metrics/expvar_sink.go create mode 100644 pkg/metrics/hub.go create mode 100644 pkg/serialport/serialport.go create mode 100644 pkg/serialport/serialport_unix.go create mode 100644 pkg/serialport/serialport_windows.go create mode 100644 pkg/status/registry.go create mode 100644 service/webui/api.go create mode 100644 service/webui/assets/app.css create mode 100644 service/webui/assets/app.js create mode 100644 service/webui/assets/index.html create mode 100644 service/webui/diagnostics.go create mode 100644 service/webui/embed.go create mode 100644 service/webui/http_util.go create mode 100644 service/webui/plane.go create mode 100644 service/webui/server.go create mode 100644 service/webui/stream.go create mode 100644 service/webui/tls.go diff --git a/cmd/classicstack/config_flags.go b/cmd/classicstack/config_flags.go index 24e6061..b4e13b3 100644 --- a/cmd/classicstack/config_flags.go +++ b/cmd/classicstack/config_flags.go @@ -85,6 +85,12 @@ type flagInputs struct { ShortnameWindowsShortnames bool ShortnameBackend string ShortnameDBPath string + + WebUIEnabled bool + WebUIBind string + WebUITLS bool + WebUICertPEM string + WebUIKeyPEM string } // flagsToConfig builds an appConfig from CLI flag values. It is the @@ -192,6 +198,14 @@ func flagsToConfig(in flagInputs) appConfig { } cfg.ShortnameDBPath = in.ShortnameDBPath + cfg.WebUI = WebUIConfigOptions{ + Enabled: in.WebUIEnabled, + Bind: firstNonBlank(in.WebUIBind, cfg.WebUI.Bind), + TLS: in.WebUITLS, + CertPEM: in.WebUICertPEM, + KeyPEM: in.WebUIKeyPEM, + } + normalizeSMBIdentity(&cfg) syncBridgeToEtherTalk(&cfg) diff --git a/cmd/classicstack/config_ini.go b/cmd/classicstack/config_ini.go index ae450ef..36f3fc0 100644 --- a/cmd/classicstack/config_ini.go +++ b/cmd/classicstack/config_ini.go @@ -72,6 +72,8 @@ type appConfig struct { ShortnameWindowsShortnames bool ShortnameBackend string ShortnameDBPath string + + WebUI WebUIConfigOptions } const ( @@ -97,6 +99,7 @@ func defaultAppConfig() appConfig { SMBServerName: defaultSMBServerName, SMBWorkgroup: defaultSMBWorkgroup, ShortnameBackend: "memory", + WebUI: DefaultWebUIConfig(), // On Windows the host filesystem already has authoritative 8.3 // names (NTFS short names, when not disabled) — using them // avoids generating ~N suffixes for names that are already @@ -213,6 +216,10 @@ func resolveAppConfig(src config.Source) (appConfig, error) { cfg.ShortnameBackend = stringWithDefault(k, "Shortname.backend", cfg.ShortnameBackend) cfg.ShortnameDBPath = stringWithDefault(k, "Shortname.db_path", cfg.ShortnameDBPath) + if err := loadSection(k, "WebUI", &cfg.WebUI); err != nil { + return cfg, err + } + normalizeSMBIdentity(&cfg) return cfg, nil diff --git a/cmd/classicstack/main.go b/cmd/classicstack/main.go index 4a2af91..60efb81 100644 --- a/cmd/classicstack/main.go +++ b/cmd/classicstack/main.go @@ -132,6 +132,14 @@ func main() { shortBackend := flag.String("shortname-backend", "memory", "Shortname store backend: memory or sqlite") shortDB := flag.String("shortname-db", "", "Shortname store DB path (sqlite backend)") + // Web UI flags. The HTTP server lives behind -tags webui; the + // disabled stub warns if -webui-enabled is set without the tag. + webuiEnable := flag.Bool("webui-enabled", false, "Enable the management web UI (requires -tags webui)") + webuiBind := flag.String("webui-bind", "127.0.0.1:8080", "Web UI listen address (IP:PORT)") + webuiTLS := flag.Bool("webui-tls", true, "Serve the web UI over HTTPS (self-signed when no cert/key given)") + webuiCert := flag.String("webui-cert-pem", "", "Path to PEM certificate for the web UI (blank: self-signed)") + webuiKey := flag.String("webui-key-pem", "", "Path to PEM private key for the web UI (blank: self-signed)") + flag.Parse() if *showVersion { @@ -247,6 +255,12 @@ func main() { ShortnameWindowsShortnames: *shortWindows, ShortnameBackend: *shortBackend, ShortnameDBPath: *shortDB, + + WebUIEnabled: *webuiEnable, + WebUIBind: *webuiBind, + WebUITLS: *webuiTLS, + WebUICertPEM: *webuiCert, + WebUIKeyPEM: *webuiKey, }) } diff --git a/cmd/classicstack/webui_config.go b/cmd/classicstack/webui_config.go new file mode 100644 index 0000000..f22a999 --- /dev/null +++ b/cmd/classicstack/webui_config.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + "strings" +) + +// WebUIConfigOptions is the user-facing configuration for the management +// web UI. It is populated from the [WebUI] TOML section or the +// -webui-* flags. The HTTP server itself lives behind //go:build webui +// (service/webui); this struct is always compiled so the disabled stub +// can still report a misconfiguration. +type WebUIConfigOptions struct { + // Enabled turns the web UI listener on. When the binary was built + // without -tags webui, setting this only produces a warning. + Enabled bool `koanf:"enabled"` + // Bind is the listen address for the web UI, e.g. "127.0.0.1:8080". + Bind string `koanf:"bind"` + // TLS enables HTTPS. When true and CertPEM/KeyPEM are blank a + // self-signed certificate is generated at startup. + TLS bool `koanf:"tls"` + // CertPEM is the path to a PEM-encoded certificate. Blank selects + // the self-signed certificate. + CertPEM string `koanf:"cert_pem"` + // KeyPEM is the path to a PEM-encoded private key. Blank selects the + // self-signed certificate. + KeyPEM string `koanf:"key_pem"` +} + +// DefaultWebUIConfig returns the built-in defaults. The UI is disabled by +// default and, when enabled, binds to loopback with TLS on so a fresh +// install is not exposed to the network in plaintext. +func DefaultWebUIConfig() WebUIConfigOptions { + return WebUIConfigOptions{ + Enabled: false, + Bind: "127.0.0.1:8080", + TLS: true, + } +} + +// Validate enforces logical rules the type system cannot express. +func (c *WebUIConfigOptions) Validate() error { + if !c.Enabled { + return nil + } + if strings.TrimSpace(c.Bind) == "" { + return fmt.Errorf("WebUI.bind must not be empty when WebUI is enabled") + } + // Cert and key are an all-or-nothing pair: supplying one without the + // other is a configuration mistake rather than a self-signed fallback. + hasCert := strings.TrimSpace(c.CertPEM) != "" + hasKey := strings.TrimSpace(c.KeyPEM) != "" + if hasCert != hasKey { + return fmt.Errorf("WebUI.cert_pem and WebUI.key_pem must be set together (or both left blank for a self-signed certificate)") + } + return nil +} diff --git a/cmd/classicstack/webui_disabled.go b/cmd/classicstack/webui_disabled.go new file mode 100644 index 0000000..980f796 --- /dev/null +++ b/cmd/classicstack/webui_disabled.go @@ -0,0 +1,23 @@ +//go:build !webui && !all + +package main + +import ( + "context" + + "github.com/ObsoleteMadness/ClassicStack/netlog" +) + +type webUIHookDisabled struct{} + +func (webUIHookDisabled) Start(_ context.Context) error { return nil } +func (webUIHookDisabled) Stop() error { return nil } + +// wireWebUI is the no-op build. It warns if the operator asked for the +// web UI but the binary was built without -tags webui. +func wireWebUI(w WebUIWiring) (WebUIHook, error) { + if w.Options.Enabled { + netlog.Warn("[MAIN][WebUI] -webui-enabled set but binary was built without -tags webui; ignoring") + } + return webUIHookDisabled{}, nil +} diff --git a/cmd/classicstack/webui_enabled.go b/cmd/classicstack/webui_enabled.go new file mode 100644 index 0000000..b697a66 --- /dev/null +++ b/cmd/classicstack/webui_enabled.go @@ -0,0 +1,51 @@ +//go:build webui || all + +package main + +import ( + "context" + + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/service/webui" +) + +type webUIHookEnabled struct { + srv *webui.Server +} + +func (h *webUIHookEnabled) Start(ctx context.Context) error { + if h.srv == nil { + return nil + } + return h.srv.Start(ctx) +} + +func (h *webUIHookEnabled) Stop() error { + if h.srv == nil { + return nil + } + return h.srv.Stop() +} + +// wireWebUI constructs the HTTPS management server when the web UI is +// enabled. The control plane (passed via WebUIWiring.Plane) is the single +// management API the server adapts onto HTTP/SSE. When the UI is disabled +// a hook with a nil server is returned so Start/Stop are no-ops. +func wireWebUI(w WebUIWiring) (WebUIHook, error) { + if !w.Options.Enabled { + return &webUIHookEnabled{}, nil + } + plane, _ := w.Plane.(webui.ControlPlane) + srv, err := webui.NewServer(webui.Options{ + Bind: w.Options.Bind, + TLS: w.Options.TLS, + CertPEM: w.Options.CertPEM, + KeyPEM: w.Options.KeyPEM, + Plane: plane, + }) + if err != nil { + return nil, err + } + netlog.Info("[MAIN][WebUI] enabled on %s (tls=%t)", w.Options.Bind, w.Options.TLS) + return &webUIHookEnabled{srv: srv}, nil +} diff --git a/cmd/classicstack/webui_hook.go b/cmd/classicstack/webui_hook.go new file mode 100644 index 0000000..95d5af5 --- /dev/null +++ b/cmd/classicstack/webui_hook.go @@ -0,0 +1,24 @@ +package main + +import "context" + +// WebUIHook is the cmd-layer abstraction over the optional management web +// UI. Like SMB, the web UI is not a DDP service; main.go drives Start/Stop +// on it directly. The concrete implementation lives behind //go:build +// webui (webui_enabled.go); the disabled stub satisfies the same contract +// so the rest of main.go is tag-agnostic. +type WebUIHook interface { + Start(ctx context.Context) error + Stop() error +} + +// WebUIWiring collects everything wireWebUI needs. The control plane is +// passed as an interface{} so this neutral file does not depend on the +// pkg/control types (which the disabled build still links). The enabled +// build type-asserts it back to *control.Plane. +type WebUIWiring struct { + Options WebUIConfigOptions + // Plane is the *control.Plane the UI drives. Typed as any so the + // disabled stub (which ignores it) need not import pkg/control. + Plane any +} diff --git a/config/defaults.go b/config/defaults.go new file mode 100644 index 0000000..1144f25 --- /dev/null +++ b/config/defaults.go @@ -0,0 +1,56 @@ +package config + +import "runtime" + +// Defaults returns a Model seeded with ClassicStack's built-in defaults. +// These mirror the flag/DefaultConfig defaults in cmd/classicstack so a +// Model built from an empty source matches a default flag-driven run. +func Defaults() *Model { + return &Model{ + Logging: LoggingModel{Level: "info"}, + Bridge: BridgeModel{Mode: "pcap", BridgeMode: "auto", HWAddress: "DE:AD:BE:EF:CA:FE"}, + LToUDP: LToUDPModel{ + Enabled: true, + Interface: "0.0.0.0", + SeedNetwork: 1, + SeedZone: "LToUDP Network", + }, + TashTalk: TashTalkModel{ + SeedNetwork: 2, + SeedZone: "TashTalk Network", + }, + EtherTalk: EtherTalkModel{ + SeedNetworkMin: 3, + SeedNetworkMax: 5, + SeedZone: "EtherTalk Network", + DesiredNetwork: 3, + DesiredNode: 253, + }, + Capture: CaptureModel{Snaplen: 65535}, + MacIP: MacIPModel{NATSubnet: "192.168.100.0/24"}, + IPX: IPXModel{Framing: "ethernet_ii"}, + NetBIOS: NetBIOSModel{Transports: []string{"tcp"}}, + SMB: SMBModel{ + NBTBinding: ":139", + ServerName: "CLASSICSTACK", + Workgroup: "WORKGROUP", + }, + AFP: AFPModel{ + Enabled: true, + Name: "Go File Server", + Protocols: "tcp,ddp", + Binding: ":548", + CNIDBackend: "sqlite", + UseDecomposedNames: true, + AppleDoubleMode: "modern", + }, + Shortname: ShortnameModel{ + Backend: "memory", + WindowsShortnames: runtime.GOOS == "windows", + }, + WebUI: WebUIModel{ + Bind: "127.0.0.1:8080", + TLS: true, + }, + } +} diff --git a/config/fromsource.go b/config/fromsource.go new file mode 100644 index 0000000..a6d7e76 --- /dev/null +++ b/config/fromsource.go @@ -0,0 +1,193 @@ +package config + +import ( + "strings" + + "github.com/knadh/koanf/v2" +) + +// FromSource builds a Model from a parsed koanf Source. It reads the same +// keys the cmd-layer loader consumes so a Model produced here is equivalent +// to the running configuration. Unknown keys are ignored; missing keys keep +// the Model's zero values (callers seed defaults via Defaults first). +func FromSource(src Source) *Model { + m := Defaults() + k := src.K + if k == nil { + return m + } + + m.Logging.Level = str(k, "Logging.level", m.Logging.Level) + m.Logging.ParsePackets = boolv(k, "Logging.parse_packets", m.Logging.ParsePackets) + m.Logging.LogTraffic = boolv(k, "Logging.log_traffic", m.Logging.LogTraffic) + m.Logging.ParseOutput = str(k, "Logging.parse_output", m.Logging.ParseOutput) + + m.Bridge.Mode = str(k, "Bridge.mode", m.Bridge.Mode) + m.Bridge.Device = str(k, "Bridge.device", m.Bridge.Device) + m.Bridge.HWAddress = str(k, "Bridge.hw_address", m.Bridge.HWAddress) + m.Bridge.BridgeMode = str(k, "Bridge.bridge_mode", m.Bridge.BridgeMode) + + m.LToUDP.Enabled = boolv(k, "LToUdp.enabled", m.LToUDP.Enabled) + m.LToUDP.Interface = str(k, "LToUdp.interface", m.LToUDP.Interface) + m.LToUDP.SeedNetwork = uintv(k, "LToUdp.seed_network", m.LToUDP.SeedNetwork) + m.LToUDP.SeedZone = str(k, "LToUdp.seed_zone", m.LToUDP.SeedZone) + + m.TashTalk.Port = str(k, "TashTalk.port", m.TashTalk.Port) + m.TashTalk.SeedNetwork = uintv(k, "TashTalk.seed_network", m.TashTalk.SeedNetwork) + m.TashTalk.SeedZone = str(k, "TashTalk.seed_zone", m.TashTalk.SeedZone) + + m.EtherTalk.BridgeHostMAC = str(k, "EtherTalk.bridge_host_mac", m.EtherTalk.BridgeHostMAC) + m.EtherTalk.Filter = str(k, "EtherTalk.filter", m.EtherTalk.Filter) + m.EtherTalk.SeedNetworkMin = uintv(k, "EtherTalk.seed_network_min", m.EtherTalk.SeedNetworkMin) + m.EtherTalk.SeedNetworkMax = uintv(k, "EtherTalk.seed_network_max", m.EtherTalk.SeedNetworkMax) + m.EtherTalk.SeedZone = str(k, "EtherTalk.seed_zone", m.EtherTalk.SeedZone) + m.EtherTalk.DesiredNetwork = uintv(k, "EtherTalk.desired_network", m.EtherTalk.DesiredNetwork) + m.EtherTalk.DesiredNode = uintv(k, "EtherTalk.desired_node", m.EtherTalk.DesiredNode) + + m.Capture.LocalTalk = str(k, "Capture.localtalk", m.Capture.LocalTalk) + m.Capture.EtherTalk = str(k, "Capture.ethertalk", m.Capture.EtherTalk) + m.Capture.IPX = str(k, "Capture.ipx", m.Capture.IPX) + m.Capture.NetBEUI = str(k, "Capture.netbeui", m.Capture.NetBEUI) + if k.Exists("Capture.snaplen") { + m.Capture.Snaplen = uint32(k.Int64("Capture.snaplen")) + } + + m.MacIP.Enabled = boolv(k, "MacIP.enabled", m.MacIP.Enabled) + m.MacIP.Mode = str(k, "MacIP.mode", m.MacIP.Mode) + m.MacIP.Zone = str(k, "MacIP.zone", m.MacIP.Zone) + m.MacIP.NATSubnet = str(k, "MacIP.nat_subnet", m.MacIP.NATSubnet) + m.MacIP.NATGW = str(k, "MacIP.nat_gw", m.MacIP.NATGW) + m.MacIP.LeaseFile = str(k, "MacIP.lease_file", m.MacIP.LeaseFile) + m.MacIP.IPGateway = str(k, "MacIP.ip_gateway", m.MacIP.IPGateway) + m.MacIP.DHCPRelay = boolv(k, "MacIP.dhcp_relay", m.MacIP.DHCPRelay) + m.MacIP.Nameserver = str(k, "MacIP.nameserver", m.MacIP.Nameserver) + m.MacIP.Filter = str(k, "MacIP.filter", m.MacIP.Filter) + + m.IPX.Enabled = boolv(k, "IPX.enabled", m.IPX.Enabled) + m.IPX.Interface = str(k, "IPX.interface", m.IPX.Interface) + m.IPX.Framing = str(k, "IPX.framing", m.IPX.Framing) + m.IPX.InternalNetwork = str(k, "IPX.internal_network", m.IPX.InternalNetwork) + m.IPX.Filter = str(k, "IPX.filter", m.IPX.Filter) + + m.IPXGW.Enabled = boolv(k, "IPXGW.enabled", m.IPXGW.Enabled) + if k.Exists("IPXGW.bindings") { + m.IPXGW.Bindings = k.Strings("IPXGW.bindings") + } + + m.NetBEUI.Enabled = boolv(k, "NetBEUI.enabled", m.NetBEUI.Enabled) + m.NetBEUI.Interface = str(k, "NetBEUI.interface", m.NetBEUI.Interface) + m.NetBEUI.Filter = str(k, "NetBEUI.filter", m.NetBEUI.Filter) + + m.NetBIOS.Enabled = boolv(k, "NetBIOS.enabled", m.NetBIOS.Enabled) + if k.Exists("NetBIOS.transports") { + m.NetBIOS.Transports = k.Strings("NetBIOS.transports") + } + m.NetBIOS.ScopeID = str(k, "NetBIOS.scope_id", m.NetBIOS.ScopeID) + + m.SMB.Enabled = boolv(k, "SMB.enabled", m.SMB.Enabled) + m.SMB.NBTBinding = str(k, "SMB.nbt_binding", m.SMB.NBTBinding) + m.SMB.DirectBinding = str(k, "SMB.direct_binding", m.SMB.DirectBinding) + m.SMB.GuestOk = boolv(k, "SMB.guest_ok", m.SMB.GuestOk) + m.SMB.ServerName = str(k, "SMB.server_name", m.SMB.ServerName) + m.SMB.Workgroup = str(k, "SMB.workgroup", m.SMB.Workgroup) + m.SMB.Volumes = loadShares(k) + + m.AFP.Enabled = boolv(k, "AFP.enabled", m.AFP.Enabled) + m.AFP.Name = str(k, "AFP.name", m.AFP.Name) + m.AFP.Zone = str(k, "AFP.zone", m.AFP.Zone) + m.AFP.Protocols = str(k, "AFP.protocols", m.AFP.Protocols) + m.AFP.Binding = str(k, "AFP.binding", m.AFP.Binding) + m.AFP.ExtensionMap = str(k, "AFP.extension_map", m.AFP.ExtensionMap) + m.AFP.CNIDBackend = str(k, "AFP.cnid_backend", m.AFP.CNIDBackend) + m.AFP.UseDecomposedNames = boolv(k, "AFP.use_decomposed_names", m.AFP.UseDecomposedNames) + m.AFP.AppleDoubleMode = str(k, "AFP.appledouble_mode", m.AFP.AppleDoubleMode) + m.AFP.Volumes = loadVolumes(k) + + m.Shortname.WindowsShortnames = boolv(k, "Shortname.windows_shortnames", m.Shortname.WindowsShortnames) + m.Shortname.Backend = str(k, "Shortname.backend", m.Shortname.Backend) + m.Shortname.DBPath = str(k, "Shortname.db_path", m.Shortname.DBPath) + + m.WebUI.Enabled = boolv(k, "WebUI.enabled", m.WebUI.Enabled) + m.WebUI.Bind = str(k, "WebUI.bind", m.WebUI.Bind) + m.WebUI.TLS = boolv(k, "WebUI.tls", m.WebUI.TLS) + m.WebUI.CertPEM = str(k, "WebUI.cert_pem", m.WebUI.CertPEM) + m.WebUI.KeyPEM = str(k, "WebUI.key_pem", m.WebUI.KeyPEM) + + return m +} + +func loadShares(k *koanf.Koanf) map[string]ShareModel { + prefix := "" + switch { + case k.Exists("SMB.Volumes"): + prefix = "SMB.Volumes" + case k.Exists("SMB.Shares"): + prefix = "SMB.Shares" + default: + return nil + } + keys := k.MapKeys(prefix) + if len(keys) == 0 { + return nil + } + out := make(map[string]ShareModel, len(keys)) + for _, key := range keys { + base := prefix + "." + key + out[key] = ShareModel{ + Name: str(k, base+".name", key), + Path: str(k, base+".path", ""), + FSType: str(k, base+".fs_type", "local_fs"), + ReadOnly: boolv(k, base+".read_only", false), + } + } + return out +} + +func loadVolumes(k *koanf.Koanf) map[string]VolumeModel { + if !k.Exists("AFP.Volumes") { + return nil + } + keys := k.MapKeys("AFP.Volumes") + if len(keys) == 0 { + return nil + } + out := make(map[string]VolumeModel, len(keys)) + for _, key := range keys { + base := "AFP.Volumes." + key + out[key] = VolumeModel{ + Name: str(k, base+".name", key), + Path: str(k, base+".path", ""), + FSType: str(k, base+".fs_type", ""), + Password: str(k, base+".password", ""), + ReadOnly: boolv(k, base+".read_only", false), + RebuildDesktopDB: boolv(k, base+".rebuild_desktop_db", false), + AppleDoubleMode: str(k, base+".appledouble_mode", ""), + } + } + return out +} + +func str(k *koanf.Koanf, path, def string) string { + if !k.Exists(path) { + return def + } + v := strings.TrimSpace(k.String(path)) + if v == "" { + return def + } + return v +} + +func boolv(k *koanf.Koanf, path string, def bool) bool { + if !k.Exists(path) { + return def + } + return k.Bool(path) +} + +func uintv(k *koanf.Koanf, path string, def uint) uint { + if !k.Exists(path) { + return def + } + return uint(k.Int64(path)) +} diff --git a/config/marshal.go b/config/marshal.go new file mode 100644 index 0000000..b757f81 --- /dev/null +++ b/config/marshal.go @@ -0,0 +1,59 @@ +package config + +import ( + "maps" + + "github.com/pelletier/go-toml/v2" +) + +// ToTOML serialises the model to TOML bytes. Comments and the original key +// ordering of any source file are not preserved; callers warn operators +// before overwriting a hand-edited file. +func (m *Model) ToTOML() ([]byte, error) { + return toml.Marshal(m) +} + +// Clone returns a deep copy of the model so edits can be staged without +// mutating the live configuration. The map-valued sections (AFP/SMB +// volumes, IPXGW/NetBIOS slices) are copied element-by-element. +func (m *Model) Clone() *Model { + if m == nil { + return nil + } + cp := *m // shallow copy of all value fields + + cp.IPXGW.Bindings = cloneStrings(m.IPXGW.Bindings) + cp.NetBIOS.Transports = cloneStrings(m.NetBIOS.Transports) + + cp.SMB.Volumes = cloneShareMap(m.SMB.Volumes) + cp.AFP.Volumes = cloneVolumeMap(m.AFP.Volumes) + + return &cp +} + +func cloneStrings(in []string) []string { + if in == nil { + return nil + } + out := make([]string, len(in)) + copy(out, in) + return out +} + +func cloneShareMap(in map[string]ShareModel) map[string]ShareModel { + if in == nil { + return nil + } + out := make(map[string]ShareModel, len(in)) + maps.Copy(out, in) + return out +} + +func cloneVolumeMap(in map[string]VolumeModel) map[string]VolumeModel { + if in == nil { + return nil + } + out := make(map[string]VolumeModel, len(in)) + maps.Copy(out, in) + return out +} diff --git a/config/model.go b/config/model.go new file mode 100644 index 0000000..7879642 --- /dev/null +++ b/config/model.go @@ -0,0 +1,184 @@ +package config + +// Model is the in-memory, mutable, serialisable representation of the whole +// ClassicStack configuration. It is the source of truth the management +// plane stages edits against and writes back to server.toml. Field names +// and `toml` tags mirror the section/key layout of server.toml so a +// round-trip through ToTOML reproduces an equivalent file (comments are not +// preserved — the UI warns about this before saving). +// +// Model lives in package config (untagged) and uses neutral volume/share +// types rather than importing service/afp or service/smb (which are behind +// build tags); the cmd-layer wiring converts between Model and those +// packages' own config structs. +type Model struct { + Logging LoggingModel `toml:"Logging"` + Bridge BridgeModel `toml:"Bridge"` + LToUDP LToUDPModel `toml:"LToUdp"` + TashTalk TashTalkModel `toml:"TashTalk"` + EtherTalk EtherTalkModel `toml:"EtherTalk"` + Capture CaptureModel `toml:"Capture"` + MacIP MacIPModel `toml:"MacIP"` + IPX IPXModel `toml:"IPX"` + IPXGW IPXGWModel `toml:"IPXGW"` + NetBEUI NetBEUIModel `toml:"NetBEUI"` + NetBIOS NetBIOSModel `toml:"NetBIOS"` + SMB SMBModel `toml:"SMB"` + AFP AFPModel `toml:"AFP"` + Shortname ShortnameModel `toml:"Shortname"` + WebUI WebUIModel `toml:"WebUI"` +} + +// LoggingModel is the [Logging] section. +type LoggingModel struct { + Level string `toml:"level"` + ParsePackets bool `toml:"parse_packets"` + LogTraffic bool `toml:"log_traffic"` + ParseOutput string `toml:"parse_output,omitempty"` +} + +// BridgeModel is the [Bridge] section. +type BridgeModel struct { + Mode string `toml:"mode,omitempty"` + Device string `toml:"device,omitempty"` + HWAddress string `toml:"hw_address,omitempty"` + BridgeMode string `toml:"bridge_mode,omitempty"` +} + +// LToUDPModel is the [LToUdp] section. +type LToUDPModel struct { + Enabled bool `toml:"enabled"` + Interface string `toml:"interface,omitempty"` + SeedNetwork uint `toml:"seed_network"` + SeedZone string `toml:"seed_zone"` +} + +// TashTalkModel is the [TashTalk] section. +type TashTalkModel struct { + Port string `toml:"port"` + SeedNetwork uint `toml:"seed_network"` + SeedZone string `toml:"seed_zone"` +} + +// EtherTalkModel is the [EtherTalk] section (bridge keys live in [Bridge]). +type EtherTalkModel struct { + BridgeHostMAC string `toml:"bridge_host_mac,omitempty"` + Filter string `toml:"filter,omitempty"` + SeedNetworkMin uint `toml:"seed_network_min"` + SeedNetworkMax uint `toml:"seed_network_max"` + SeedZone string `toml:"seed_zone"` + DesiredNetwork uint `toml:"desired_network,omitempty"` + DesiredNode uint `toml:"desired_node,omitempty"` +} + +// CaptureModel is the [Capture] section. +type CaptureModel struct { + LocalTalk string `toml:"localtalk,omitempty"` + EtherTalk string `toml:"ethertalk,omitempty"` + IPX string `toml:"ipx,omitempty"` + NetBEUI string `toml:"netbeui,omitempty"` + Snaplen uint32 `toml:"snaplen,omitempty"` +} + +// MacIPModel is the [MacIP] section. +type MacIPModel struct { + Enabled bool `toml:"enabled"` + Mode string `toml:"mode,omitempty"` // pcap or nat + Zone string `toml:"zone,omitempty"` + NATSubnet string `toml:"nat_subnet,omitempty"` + NATGW string `toml:"nat_gw,omitempty"` + LeaseFile string `toml:"lease_file,omitempty"` + IPGateway string `toml:"ip_gateway,omitempty"` + DHCPRelay bool `toml:"dhcp_relay,omitempty"` + Nameserver string `toml:"nameserver,omitempty"` + Filter string `toml:"filter,omitempty"` +} + +// IPXModel is the [IPX] section. +type IPXModel struct { + Enabled bool `toml:"enabled"` + Interface string `toml:"interface,omitempty"` + Framing string `toml:"framing,omitempty"` + InternalNetwork string `toml:"internal_network,omitempty"` + Filter string `toml:"filter,omitempty"` +} + +// IPXGWModel is the [IPXGW] section. +type IPXGWModel struct { + Enabled bool `toml:"enabled"` + Bindings []string `toml:"bindings,omitempty"` // "Object:Zone" entries +} + +// NetBEUIModel is the [NetBEUI] section. +type NetBEUIModel struct { + Enabled bool `toml:"enabled"` + Interface string `toml:"interface,omitempty"` + Filter string `toml:"filter,omitempty"` +} + +// NetBIOSModel is the [NetBIOS] section. +type NetBIOSModel struct { + Enabled bool `toml:"enabled"` + Transports []string `toml:"transports,omitempty"` + ScopeID string `toml:"scope_id,omitempty"` +} + +// SMBModel is the [SMB] section, including [SMB.Volumes.*] shares. +type SMBModel struct { + Enabled bool `toml:"enabled"` + NBTBinding string `toml:"nbt_binding,omitempty"` + DirectBinding string `toml:"direct_binding,omitempty"` + GuestOk bool `toml:"guest_ok,omitempty"` + ServerName string `toml:"server_name,omitempty"` + Workgroup string `toml:"workgroup,omitempty"` + Volumes map[string]ShareModel `toml:"Volumes,omitempty"` +} + +// ShareModel is one [SMB.Volumes.] entry. +type ShareModel struct { + Name string `toml:"name,omitempty"` + Path string `toml:"path"` + FSType string `toml:"fs_type,omitempty"` + ReadOnly bool `toml:"read_only,omitempty"` +} + +// AFPModel is the [AFP] section, including [AFP.Volumes.*] volumes. +type AFPModel struct { + Enabled bool `toml:"enabled"` + Name string `toml:"name,omitempty"` + Zone string `toml:"zone,omitempty"` + Protocols string `toml:"protocols,omitempty"` + Binding string `toml:"binding,omitempty"` + ExtensionMap string `toml:"extension_map,omitempty"` + CNIDBackend string `toml:"cnid_backend,omitempty"` + UseDecomposedNames bool `toml:"use_decomposed_names,omitempty"` + AppleDoubleMode string `toml:"appledouble_mode,omitempty"` + Volumes map[string]VolumeModel `toml:"Volumes,omitempty"` +} + +// VolumeModel is one [AFP.Volumes.] entry. +type VolumeModel struct { + Name string `toml:"name,omitempty"` + Path string `toml:"path,omitempty"` + FSType string `toml:"fs_type,omitempty"` + Password string `toml:"password,omitempty"` + ReadOnly bool `toml:"read_only,omitempty"` + RebuildDesktopDB bool `toml:"rebuild_desktop_db,omitempty"` + AppleDoubleMode string `toml:"appledouble_mode,omitempty"` +} + +// ShortnameModel is the [Shortname] section. +type ShortnameModel struct { + WindowsShortnames bool `toml:"windows_shortnames,omitempty"` + Backend string `toml:"backend,omitempty"` + DBPath string `toml:"db_path,omitempty"` +} + +// WebUIModel is the [WebUI] section. +type WebUIModel struct { + Enabled bool `toml:"enabled"` + Bind string `toml:"bind,omitempty"` + TLS bool `toml:"tls"` + CertPEM string `toml:"cert_pem,omitempty"` + KeyPEM string `toml:"key_pem,omitempty"` +} diff --git a/config/model_test.go b/config/model_test.go new file mode 100644 index 0000000..f9b0c16 --- /dev/null +++ b/config/model_test.go @@ -0,0 +1,109 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestModelTOMLRoundTrip(t *testing.T) { + m := Defaults() + m.LToUDP.SeedZone = "Custom Zone" + m.AFP.Volumes = map[string]VolumeModel{ + "TestVol": {Name: "Test Vol", Path: `C:\Mac\Test`, FSType: "local_fs"}, + } + m.WebUI.Enabled = true + m.WebUI.Bind = "127.0.0.1:9000" + + data, err := m.ToTOML() + if err != nil { + t.Fatalf("ToTOML: %v", err) + } + + // Reload through the koanf source path and confirm key fields survive. + dir := t.TempDir() + path := filepath.Join(dir, "server.toml") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + src, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + got := FromSource(src) + + if got.LToUDP.SeedZone != "Custom Zone" { + t.Errorf("LToUDP.SeedZone = %q, want %q", got.LToUDP.SeedZone, "Custom Zone") + } + if !got.WebUI.Enabled || got.WebUI.Bind != "127.0.0.1:9000" { + t.Errorf("WebUI round-trip lost data: %+v", got.WebUI) + } + if v, ok := got.AFP.Volumes["TestVol"]; !ok || v.Path != `C:\Mac\Test` { + t.Errorf("AFP volume round-trip lost data: %+v", got.AFP.Volumes) + } +} + +func TestCloneIsDeep(t *testing.T) { + m := Defaults() + m.AFP.Volumes = map[string]VolumeModel{"A": {Path: "/a"}} + m.NetBIOS.Transports = []string{"tcp"} + + cp := m.Clone() + cp.AFP.Volumes["A"] = VolumeModel{Path: "/changed"} + cp.NetBIOS.Transports[0] = "ipx" + + if m.AFP.Volumes["A"].Path != "/a" { + t.Errorf("Clone shared volume map: original mutated to %q", m.AFP.Volumes["A"].Path) + } + if m.NetBIOS.Transports[0] != "tcp" { + t.Errorf("Clone shared slice: original mutated to %q", m.NetBIOS.Transports[0]) + } +} + +func TestSaveCreatesNumberedBackup(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "server.toml") + if err := os.WriteFile(path, []byte("# original\n"), 0o600); err != nil { + t.Fatal(err) + } + + m := Defaults() + backup, err := Save(path, m) + if err != nil { + t.Fatalf("Save: %v", err) + } + want := path + ".0001" + if backup != want { + t.Errorf("backup path = %q, want %q", backup, want) + } + if b, _ := os.ReadFile(backup); string(b) != "# original\n" { + t.Errorf("backup content = %q, want original", string(b)) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("new config not written: %v", err) + } + + // A second save bumps to .0002. + backup2, err := Save(path, m) + if err != nil { + t.Fatalf("Save 2: %v", err) + } + if backup2 != path+".0002" { + t.Errorf("second backup = %q, want .0002", backup2) + } +} + +func TestSaveNoBackupWhenAbsent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "server.toml") + backup, err := Save(path, Defaults()) + if err != nil { + t.Fatalf("Save: %v", err) + } + if backup != "" { + t.Errorf("backup = %q, want empty when no prior file", backup) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("config not written: %v", err) + } +} diff --git a/config/save.go b/config/save.go new file mode 100644 index 0000000..00f5b29 --- /dev/null +++ b/config/save.go @@ -0,0 +1,80 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" +) + +// Save writes the model to path as TOML. If path already exists it is first +// duplicated to the next free numbered backup (e.g. server.toml.0001, +// server.toml.0002, …) so a hand-edited file is never lost. The new file +// is written atomically via a temp file in the same directory followed by +// a rename. It returns the backup path created (empty when path did not +// previously exist). +func Save(path string, m *Model) (backupPath string, err error) { + data, err := m.ToTOML() + if err != nil { + return "", fmt.Errorf("marshal config: %w", err) + } + + if _, statErr := os.Stat(path); statErr == nil { + backupPath, err = backupExisting(path) + if err != nil { + return "", err + } + } else if !os.IsNotExist(statErr) { + return "", statErr + } + + if err := atomicWrite(path, data); err != nil { + return "", err + } + return backupPath, nil +} + +// backupExisting copies path to the next free path.NNNN and returns the +// backup path. +func backupExisting(path string) (string, error) { + src, err := os.ReadFile(path) + if err != nil { + return "", err + } + for i := 1; i <= 9999; i++ { + candidate := fmt.Sprintf("%s.%04d", path, i) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + if err := os.WriteFile(candidate, src, 0o600); err != nil { + return "", err + } + return candidate, nil + } else if err != nil { + return "", err + } + } + return "", fmt.Errorf("config: exhausted backup slots for %s", path) +} + +// atomicWrite writes data to a temp file in path's directory and renames it +// over path so a crash mid-write cannot leave a truncated config. +func atomicWrite(path string, data []byte) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".classicstack-config-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // no-op once renamed + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} diff --git a/go.mod b/go.mod index 31b40ba..874ab2a 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/knadh/koanf/parsers/toml/v2 v2.2.0 github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.3.4 + github.com/pelletier/go-toml/v2 v2.2.4 golang.org/x/net v0.33.0 golang.org/x/sys v0.32.0 modernc.org/sqlite v1.35.0 @@ -35,7 +36,6 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/stretchr/testify v1.11.1 // indirect go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect diff --git a/pkg/control/config.go b/pkg/control/config.go new file mode 100644 index 0000000..89c7627 --- /dev/null +++ b/pkg/control/config.go @@ -0,0 +1,117 @@ +package control + +import ( + "context" + "errors" +) + +// ErrNoSupervisor is returned by lifecycle methods when the plane was +// constructed without a Supervisor (e.g. a read-only diagnostic build). +var ErrNoSupervisor = errors.New("control: no supervisor configured") + +// ErrNoConfigPath is returned by Save when the plane has no backing file. +var ErrNoConfigPath = errors.New("control: no config path configured; use Export to download") + +// Config returns the current effective config and whether there are +// unsaved edits. When edits have been staged the staged model is returned +// so the UI reflects what the operator is editing; otherwise the live +// model is returned. dirty is true whenever staged edits have not been +// written to disk. +func (p *Plane) Config() (cfg ConfigModel, dirty bool) { + p.mu.Lock() + defer p.mu.Unlock() + if p.staged != nil { + return p.staged, p.dirty + } + return p.live, p.dirty +} + +// Stage records an edited config model in memory without touching disk or +// the running stack. It marks the plane dirty. +func (p *Plane) Stage(edit ConfigModel) { + p.mu.Lock() + defer p.mu.Unlock() + p.staged = edit + p.dirty = true +} + +// Apply re-wires the running stack to the staged config. On success the +// staged model becomes the live model. The dirty flag is NOT cleared — +// only writing to disk (Save) clears it — so the UI keeps warning about +// unsaved changes even after a live apply. +func (p *Plane) Apply(ctx context.Context) error { + p.mu.Lock() + staged := p.staged + p.mu.Unlock() + if staged == nil { + return nil // nothing staged; no-op + } + if p.sup == nil { + return ErrNoSupervisor + } + if err := p.sup.Apply(ctx, staged); err != nil { + return err + } + p.mu.Lock() + p.live = staged + p.mu.Unlock() + return nil +} + +// Dirty reports whether there are unsaved edits. +func (p *Plane) Dirty() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.dirty +} + +// Export serialises the current (staged-or-live) config to TOML for +// download/backup, regardless of whether a backing file is configured. +func (p *Plane) Export() ([]byte, error) { + cfg, _ := p.Config() + if cfg == nil { + return nil, errors.New("control: no config to export") + } + return cfg.ToTOML() +} + +// Saver writes a config model to disk and returns the backup path it +// created. config.Save satisfies this; it is injected so pkg/control need +// not import package config's file I/O. +type Saver func(path string, cfg ConfigModel) (backupPath string, err error) + +var saveFn Saver + +// SetSaver installs the function Save uses to persist config. main.go wires +// config.Save here at startup. +func SetSaver(s Saver) { saveFn = s } + +// Save writes the current config to the backing file (backing up the +// previous file first) and clears the dirty flag. The staged model, if +// any, becomes live. +func (p *Plane) Save() (backupPath string, err error) { + p.mu.Lock() + cfg := p.staged + if cfg == nil { + cfg = p.live + } + path := p.path + p.mu.Unlock() + + if path == "" { + return "", ErrNoConfigPath + } + if saveFn == nil { + return "", errors.New("control: no saver configured") + } + backupPath, err = saveFn(path, cfg) + if err != nil { + return "", err + } + p.mu.Lock() + p.live = cfg + p.staged = nil + p.dirty = false + p.mu.Unlock() + return backupPath, nil +} diff --git a/pkg/control/control.go b/pkg/control/control.go new file mode 100644 index 0000000..3cbe918 --- /dev/null +++ b/pkg/control/control.go @@ -0,0 +1,111 @@ +// Package control is ClassicStack's transport-agnostic management API: the +// single implementation of every operator action (status, live stats, +// config staging/apply/save, service restart, diagnostics). UIs are thin +// adapters over it — the web UI maps HTTP/SSE onto these methods, and a +// future text/telnet UI can call them directly — so management logic is +// never duplicated per front-end. +// +// The package is untagged and depends only on neutral packages (config +// model, status, metrics), keeping it linkable in every build variant. +package control + +import ( + "context" + "sync" + + "github.com/ObsoleteMadness/ClassicStack/pkg/metrics" + "github.com/ObsoleteMadness/ClassicStack/pkg/serialport" + "github.com/ObsoleteMadness/ClassicStack/pkg/status" +) + +// Supervisor is the lifecycle controller the plane drives. It is satisfied +// by cmd/classicstack's *Supervisor; declaring it here as an interface +// keeps pkg/control free of the cmd package and its build tags. +type Supervisor interface { + // Apply re-wires the running stack to match cfg, restarting only the + // units whose configuration changed. + Apply(ctx context.Context, cfg ConfigModel) error + // RestartService restarts a single named unit (and its dependents). + RestartService(ctx context.Context, name string) error + // ListInterfaces returns the host's network interface names for the + // EtherTalk/IPX/NetBEUI dropdowns. + ListInterfaces() ([]string, error) +} + +// ConfigModel is the in-memory configuration the plane stages and applies. +// It is an opaque handle from the plane's perspective: defined as an +// interface so pkg/control does not depend on the concrete config.Model +// (which lives in package config and is satisfied by *config.Model). The +// plane only needs to serialise it for download/save; cloning for staged +// edits is the caller's responsibility (the UI clones before mutating). +type ConfigModel interface { + ToTOML() ([]byte, error) +} + +// Plane is the management API. It owns the live and staged config models +// and the dirty flag, and delegates lifecycle actions to the Supervisor. +type Plane struct { + sup Supervisor + reg *status.Registry + hub *metrics.Hub + + mu sync.Mutex + live ConfigModel + staged ConfigModel + dirty bool + path string // backing file path for Save; "" disables Save + diag Diagnostics + stats *statsBroadcaster +} + +// Deps bundles the plane's collaborators. +type Deps struct { + Supervisor Supervisor + Registry *status.Registry // defaults to status.Default when nil + Hub *metrics.Hub // defaults to metrics.Default when nil + Config ConfigModel // the live config at startup + ConfigPath string // file Save writes to ("" = Save disabled) +} + +// New constructs a Plane. +func New(d Deps) *Plane { + reg := d.Registry + if reg == nil { + reg = status.Default + } + hub := d.Hub + if hub == nil { + hub = metrics.Default + } + return &Plane{ + sup: d.Supervisor, + reg: reg, + hub: hub, + live: d.Config, + path: d.ConfigPath, + } +} + +// Status returns a snapshot of all registered service/port/hook units. +func (p *Plane) Status() []status.Unit { return p.reg.Snapshot() } + +// ListInterfaces returns host network interface names. +func (p *Plane) ListInterfaces() ([]string, error) { + if p.sup == nil { + return nil, nil + } + return p.sup.ListInterfaces() +} + +// ListSerialPorts returns the host's serial ports for the TashTalk dropdown. +func (p *Plane) ListSerialPorts() ([]serialport.Info, error) { + return serialport.List() +} + +// RestartService restarts a single named unit (and its dependents). +func (p *Plane) RestartService(ctx context.Context, name string) error { + if p.sup == nil { + return ErrNoSupervisor + } + return p.sup.RestartService(ctx, name) +} diff --git a/pkg/control/control_test.go b/pkg/control/control_test.go new file mode 100644 index 0000000..9215e65 --- /dev/null +++ b/pkg/control/control_test.go @@ -0,0 +1,93 @@ +package control + +import ( + "context" + "testing" +) + +// fakeModel is a minimal ConfigModel for lifecycle tests. +type fakeModel struct{ toml string } + +func (f *fakeModel) ToTOML() ([]byte, error) { return []byte(f.toml), nil } + +// fakeSup records Apply/Restart calls. +type fakeSup struct { + applied int + restarts []string +} + +func (s *fakeSup) Apply(_ context.Context, _ ConfigModel) error { s.applied++; return nil } +func (s *fakeSup) RestartService(_ context.Context, name string) error { + s.restarts = append(s.restarts, name) + return nil +} +func (s *fakeSup) ListInterfaces() ([]string, error) { return []string{"eth0"}, nil } + +func TestDirtyLifecycle(t *testing.T) { + sup := &fakeSup{} + live := &fakeModel{toml: "live"} + p := New(Deps{Supervisor: sup, Config: live, ConfigPath: ""}) + + if p.Dirty() { + t.Fatal("new plane should not be dirty") + } + + // Stage marks dirty and Config returns the staged model. + staged := &fakeModel{toml: "staged"} + p.Stage(staged) + if !p.Dirty() { + t.Fatal("plane should be dirty after Stage") + } + cfg, dirty := p.Config() + if !dirty || cfg != staged { + t.Fatalf("Config after Stage = (%v, %v), want (staged, true)", cfg, dirty) + } + + // Apply pushes to supervisor and promotes staged to live but stays dirty. + if err := p.Apply(context.Background()); err != nil { + t.Fatalf("Apply: %v", err) + } + if sup.applied != 1 { + t.Errorf("supervisor Apply called %d times, want 1", sup.applied) + } + if !p.Dirty() { + t.Error("plane should remain dirty after Apply (only Save clears it)") + } +} + +func TestSaveClearsDirty(t *testing.T) { + sup := &fakeSup{} + p := New(Deps{Supervisor: sup, Config: &fakeModel{}, ConfigPath: "/tmp/x.toml"}) + p.Stage(&fakeModel{toml: "edited"}) + + var savedPath string + SetSaver(func(path string, _ ConfigModel) (string, error) { + savedPath = path + return path + ".0001", nil + }) + + backup, err := p.Save() + if err != nil { + t.Fatalf("Save: %v", err) + } + if savedPath != "/tmp/x.toml" || backup != "/tmp/x.toml.0001" { + t.Errorf("Save path=%q backup=%q unexpected", savedPath, backup) + } + if p.Dirty() { + t.Error("plane should be clean after Save") + } +} + +func TestSaveWithoutPath(t *testing.T) { + p := New(Deps{Config: &fakeModel{}}) + if _, err := p.Save(); err != ErrNoConfigPath { + t.Errorf("Save without path = %v, want ErrNoConfigPath", err) + } +} + +func TestDiagnosticsFallback(t *testing.T) { + p := New(Deps{Config: &fakeModel{}}) + if _, err := p.Diagnostics().ListZones(context.Background()); err != ErrDiagUnavailable { + t.Errorf("unset diagnostics = %v, want ErrDiagUnavailable", err) + } +} diff --git a/pkg/control/diagnostics.go b/pkg/control/diagnostics.go new file mode 100644 index 0000000..874aa7f --- /dev/null +++ b/pkg/control/diagnostics.go @@ -0,0 +1,67 @@ +package control + +import "context" + +// ZoneInfo is one AppleTalk zone reported by ListZones. +type ZoneInfo struct { + Name string `json:"name"` +} + +// NetworkInfo is one routing-table network reported by DDPEnumerate. +type NetworkInfo struct { + NetworkMin uint16 `json:"network_min"` + NetworkMax uint16 `json:"network_max"` + Distance uint8 `json:"distance"` + Port string `json:"port"` +} + +// EchoResult is the outcome of an AEP (AppleTalk Echo Protocol) probe. +type EchoResult struct { + Network uint16 `json:"network"` + Node uint8 `json:"node"` + OK bool `json:"ok"` + RTTMS int64 `json:"rtt_ms"` + Err string `json:"err,omitempty"` +} + +// ServerInfo is one host reported by SMBBrowse. +type ServerInfo struct { + Name string `json:"name"` + Comment string `json:"comment,omitempty"` +} + +// Diagnostics is the set of read-only network probes the UI exposes. The +// concrete implementation is provided by the supervisor at wire time +// (some probes — e.g. SMB browse — are only available when that subsystem +// is built in); an unset probe returns ErrDiagUnavailable. +type Diagnostics interface { + // ListZones returns the AppleTalk zones known to the router/ZIP. + ListZones(ctx context.Context) ([]ZoneInfo, error) + // AEPEcho sends an Echo request to net/node and reports the round trip. + AEPEcho(ctx context.Context, network uint16, node uint8) (EchoResult, error) + // ZIPEnumerate walks zones via ZIP GetZoneList. + ZIPEnumerate(ctx context.Context) ([]ZoneInfo, error) + // DDPEnumerate lists networks/nodes from the routing table. + DDPEnumerate(ctx context.Context) ([]NetworkInfo, error) + // SMBBrowse returns the SMB/NetBIOS browse list of servers. Only + // available in SMB-enabled builds. + SMBBrowse(ctx context.Context) ([]ServerInfo, error) +} + +// SetDiagnostics installs the diagnostics implementation. +func (p *Plane) SetDiagnostics(d Diagnostics) { + p.mu.Lock() + defer p.mu.Unlock() + p.diag = d +} + +// Diagnostics returns the installed diagnostics implementation, or a +// no-op that reports every probe as unavailable when none is set. +func (p *Plane) Diagnostics() Diagnostics { + p.mu.Lock() + defer p.mu.Unlock() + if p.diag == nil { + return unavailableDiagnostics{} + } + return p.diag +} diff --git a/pkg/control/diagnostics_unavailable.go b/pkg/control/diagnostics_unavailable.go new file mode 100644 index 0000000..c65d7a1 --- /dev/null +++ b/pkg/control/diagnostics_unavailable.go @@ -0,0 +1,34 @@ +package control + +import ( + "context" + "errors" +) + +// ErrDiagUnavailable is returned by probes that are not compiled into this +// build (e.g. SMBBrowse without the smb tag) or not wired up. +var ErrDiagUnavailable = errors.New("control: diagnostic unavailable in this build") + +// unavailableDiagnostics is the fallback used when no Diagnostics +// implementation is installed. Every probe reports ErrDiagUnavailable. +type unavailableDiagnostics struct{} + +func (unavailableDiagnostics) ListZones(context.Context) ([]ZoneInfo, error) { + return nil, ErrDiagUnavailable +} + +func (unavailableDiagnostics) AEPEcho(context.Context, uint16, uint8) (EchoResult, error) { + return EchoResult{}, ErrDiagUnavailable +} + +func (unavailableDiagnostics) ZIPEnumerate(context.Context) ([]ZoneInfo, error) { + return nil, ErrDiagUnavailable +} + +func (unavailableDiagnostics) DDPEnumerate(context.Context) ([]NetworkInfo, error) { + return nil, ErrDiagUnavailable +} + +func (unavailableDiagnostics) SMBBrowse(context.Context) ([]ServerInfo, error) { + return nil, ErrDiagUnavailable +} diff --git a/pkg/control/stats.go b/pkg/control/stats.go new file mode 100644 index 0000000..b3b9373 --- /dev/null +++ b/pkg/control/stats.go @@ -0,0 +1,127 @@ +package control + +import ( + "sync" + "time" + + "github.com/ObsoleteMadness/ClassicStack/pkg/metrics" +) + +// Frame is a per-second snapshot of streamed statistics pushed to UI +// subscribers. Rates holds derived per-second deltas for counter metrics; +// Gauges holds the latest absolute value for gauge metrics. +type Frame struct { + UnixMilli int64 `json:"t"` + Rates map[string]int64 `json:"rates,omitempty"` + Gauges map[string]int64 `json:"gauges,omitempty"` +} + +// statsBroadcaster is a metrics.Sink that accumulates samples and, once per +// tick, computes counter rates and fans a Frame out to all subscribers. It +// is the server-side half of the SSE stream; the web UI's SSE handler is a +// subscriber. +type statsBroadcaster struct { + mu sync.Mutex + counters map[string]int64 // latest absolute counter values + prev map[string]int64 // previous tick's counter values + gauges map[string]int64 + subs map[int]chan Frame + nextSubID int + stop chan struct{} +} + +func newStatsBroadcaster() *statsBroadcaster { + return &statsBroadcaster{ + counters: make(map[string]int64), + prev: make(map[string]int64), + gauges: make(map[string]int64), + subs: make(map[int]chan Frame), + stop: make(chan struct{}), + } +} + +// Write records the latest value for a metric (metrics.Sink). +func (b *statsBroadcaster) Write(s metrics.Sample) { + b.mu.Lock() + defer b.mu.Unlock() + if s.Kind == metrics.KindGauge { + b.gauges[s.Name] = s.Value + return + } + b.counters[s.Name] = s.Value +} + +// run ticks every second, builds a Frame, and broadcasts it. It returns +// when stop is closed. +func (b *statsBroadcaster) run() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-b.stop: + return + case <-ticker.C: + b.broadcast() + } + } +} + +func (b *statsBroadcaster) broadcast() { + b.mu.Lock() + frame := Frame{ + UnixMilli: time.Now().UnixMilli(), + Rates: make(map[string]int64, len(b.counters)), + Gauges: make(map[string]int64, len(b.gauges)), + } + for name, v := range b.counters { + frame.Rates[name] = v - b.prev[name] + b.prev[name] = v + } + for name, v := range b.gauges { + frame.Gauges[name] = v + } + subs := make([]chan Frame, 0, len(b.subs)) + for _, ch := range b.subs { + subs = append(subs, ch) + } + b.mu.Unlock() + + for _, ch := range subs { + select { + case ch <- frame: + default: // drop for slow subscribers; next tick carries fresh data + } + } +} + +func (b *statsBroadcaster) subscribe() (<-chan Frame, func()) { + b.mu.Lock() + defer b.mu.Unlock() + id := b.nextSubID + b.nextSubID++ + ch := make(chan Frame, 4) + b.subs[id] = ch + return ch, func() { + b.mu.Lock() + defer b.mu.Unlock() + if c, ok := b.subs[id]; ok { + delete(b.subs, id) + close(c) + } + } +} + +// Subscribe registers a stats subscriber and returns the receive channel +// plus a cancel func that unsubscribes and closes the channel. The first +// call lazily starts the broadcaster and attaches it to the metrics hub. +func (p *Plane) Subscribe() (<-chan Frame, func()) { + p.mu.Lock() + if p.stats == nil { + p.stats = newStatsBroadcaster() + p.hub.AddSink(p.stats) + go p.stats.run() + } + b := p.stats + p.mu.Unlock() + return b.subscribe() +} diff --git a/pkg/metrics/expvar_sink.go b/pkg/metrics/expvar_sink.go new file mode 100644 index 0000000..18129ef --- /dev/null +++ b/pkg/metrics/expvar_sink.go @@ -0,0 +1,44 @@ +package metrics + +import ( + "sync" + + "github.com/ObsoleteMadness/ClassicStack/pkg/telemetry" +) + +// ExpvarSink forwards samples to the telemetry package's expvar-backed +// counters and gauges so they remain visible at /debug/vars and to any +// telemetry backend. Counters are set to the latest absolute value (the +// sample carries the running total, not a delta). +type ExpvarSink struct { + mu sync.Mutex + counters map[string]telemetry.Gauge // counters tracked as set-able gauges + gauges map[string]telemetry.Gauge +} + +// NewExpvarSink returns a ready sink. +func NewExpvarSink() *ExpvarSink { + return &ExpvarSink{ + counters: make(map[string]telemetry.Gauge), + gauges: make(map[string]telemetry.Gauge), + } +} + +// Write publishes the sample's current value under its name. Both counter +// and gauge samples carry an absolute value, so each maps onto a +// set-able expvar gauge; the Prometheus-style _total naming on counter +// names preserves the semantic distinction for scrapers. +func (s *ExpvarSink) Write(sample Sample) { + s.mu.Lock() + defer s.mu.Unlock() + table := s.gauges + if sample.Kind == KindCounter { + table = s.counters + } + g, ok := table[sample.Name] + if !ok { + g = telemetry.NewGauge(sample.Name) + table[sample.Name] = g + } + g.Set(sample.Value) +} diff --git a/pkg/metrics/hub.go b/pkg/metrics/hub.go new file mode 100644 index 0000000..435cdb0 --- /dev/null +++ b/pkg/metrics/hub.go @@ -0,0 +1,69 @@ +// Package metrics is ClassicStack's streaming-stats layer. Services push +// Samples into a Hub, which fans them out to registered Sinks. Two sinks +// ship today: an expvar/telemetry sink (so counters stay visible at +// /debug/vars and to the existing telemetry backend) and — when the web UI +// is built — an SSE sink that computes per-second rates and broadcasts +// them to dashboard clients. +// +// The hub is untagged so the core can always publish samples; only the SSE +// consumer is gated behind the webui build tag. +package metrics + +import "sync" + +// SampleKind distinguishes a monotonic counter from an instantaneous gauge. +type SampleKind int + +const ( + // KindCounter is a monotonically increasing total (e.g. bytes + // transferred). Sinks may derive a per-second rate from successive + // values. + KindCounter SampleKind = iota + // KindGauge is a point-in-time value (e.g. active sessions). + KindGauge +) + +// Sample is a single metric observation pushed by a service. +type Sample struct { + Name string `json:"name"` + Value int64 `json:"value"` + Kind SampleKind `json:"kind"` +} + +// Sink consumes Samples. Implementations must be safe for concurrent use; +// the hub serialises calls per Push but multiple Push callers may run +// concurrently, so the hub holds a lock around fan-out. +type Sink interface { + Write(Sample) +} + +// Hub fans Samples out to all registered Sinks. +type Hub struct { + mu sync.RWMutex + sinks []Sink +} + +// NewHub returns an empty hub. +func NewHub() *Hub { return &Hub{} } + +// Default is the process-global hub services push into. +var Default = NewHub() + +// AddSink registers a sink to receive future samples. +func (h *Hub) AddSink(s Sink) { + h.mu.Lock() + defer h.mu.Unlock() + h.sinks = append(h.sinks, s) +} + +// Push delivers a sample to every sink. +func (h *Hub) Push(s Sample) { + h.mu.RLock() + defer h.mu.RUnlock() + for _, sink := range h.sinks { + sink.Write(s) + } +} + +// Push is a convenience wrapper over the default hub. +func Push(s Sample) { Default.Push(s) } diff --git a/pkg/serialport/serialport.go b/pkg/serialport/serialport.go new file mode 100644 index 0000000..91e0ffa --- /dev/null +++ b/pkg/serialport/serialport.go @@ -0,0 +1,22 @@ +// Package serialport enumerates the host's serial ports so the management +// UI can offer a TashTalk port dropdown (COM* on Windows, /dev/tty* on +// Unix). It deliberately avoids a serial-library dependency — listing is a +// thin per-OS lookup. The package is untagged so any front-end can call it. +package serialport + +// Info describes a single serial port. +type Info struct { + // Name is the OS device path used to open the port, e.g. "COM3" or + // "/dev/ttyUSB0". + Name string `json:"name"` + // Description is a human-friendly label when the OS provides one; + // otherwise it equals Name. + Description string `json:"description"` +} + +// List returns the serial ports currently present on the host. The result +// is best-effort: an empty slice (not an error) is returned when none are +// found. Errors are reserved for failures querying the OS. +func List() ([]Info, error) { + return list() +} diff --git a/pkg/serialport/serialport_unix.go b/pkg/serialport/serialport_unix.go new file mode 100644 index 0000000..7531e23 --- /dev/null +++ b/pkg/serialport/serialport_unix.go @@ -0,0 +1,52 @@ +//go:build !windows + +package serialport + +import ( + "path/filepath" + "sort" +) + +// serialGlobs are the device-node patterns that typically correspond to +// serial ports across Linux and macOS. /dev/ttyS* are 16550 UARTs, +// ttyUSB*/ttyACM* are USB adaptors, ttyAMA* is the Raspberry Pi PL011 +// UART (a common TashTalk host), and tty.*/cu.* are the macOS callout and +// dial-in nodes. +var serialGlobs = []string{ + "/dev/ttyS*", + "/dev/ttyUSB*", + "/dev/ttyACM*", + "/dev/ttyAMA*", + "/dev/tty.*", + "/dev/cu.*", +} + +// list globs the well-known serial device-node patterns. Missing patterns +// simply contribute no matches; the result is de-duplicated and sorted for +// stable UI ordering. +func list() ([]Info, error) { + seen := make(map[string]struct{}) + var names []string + for _, pattern := range serialGlobs { + matches, err := filepath.Glob(pattern) + if err != nil { + // Only ErrBadPattern is possible here, and our patterns are + // static, so this should never happen; skip defensively. + continue + } + for _, m := range matches { + if _, ok := seen[m]; ok { + continue + } + seen[m] = struct{}{} + names = append(names, m) + } + } + sort.Strings(names) + + out := make([]Info, 0, len(names)) + for _, n := range names { + out = append(out, Info{Name: n, Description: n}) + } + return out, nil +} diff --git a/pkg/serialport/serialport_windows.go b/pkg/serialport/serialport_windows.go new file mode 100644 index 0000000..f88ef4f --- /dev/null +++ b/pkg/serialport/serialport_windows.go @@ -0,0 +1,38 @@ +//go:build windows + +package serialport + +import ( + "golang.org/x/sys/windows/registry" +) + +// list reads the COM port names from the Windows serial device map at +// HKLM\HARDWARE\DEVICEMAP\SERIALCOMM. Each value's data is the port name +// (e.g. "COM3"); the value name is the underlying driver device path, +// which we surface as the description. +func list() ([]Info, error) { + key, err := registry.OpenKey(registry.LOCAL_MACHINE, `HARDWARE\DEVICEMAP\SERIALCOMM`, registry.QUERY_VALUE) + if err != nil { + // No serial ports present: the key is absent. Treat as empty. + if err == registry.ErrNotExist { + return nil, nil + } + return nil, err + } + defer key.Close() + + names, err := key.ReadValueNames(0) + if err != nil { + return nil, err + } + + out := make([]Info, 0, len(names)) + for _, valueName := range names { + port, _, err := key.GetStringValue(valueName) + if err != nil || port == "" { + continue + } + out = append(out, Info{Name: port, Description: valueName}) + } + return out, nil +} diff --git a/pkg/status/registry.go b/pkg/status/registry.go new file mode 100644 index 0000000..3497dc6 --- /dev/null +++ b/pkg/status/registry.go @@ -0,0 +1,111 @@ +// Package status is ClassicStack's in-process service-status registry. +// Every port, service, and hook reports a Unit describing whether it is +// enabled and running, what it is bound to, and service-specific detail +// (hostnames, zones, shares). The management plane (pkg/control) reads a +// snapshot to render the dashboard. The registry is untagged so it is +// available to any front-end, including a future text/telnet UI. +package status + +import "sync" + +// Kind classifies a Unit for grouping in the dashboard. +const ( + KindPort = "port" + KindService = "service" + KindHook = "hook" + KindRouter = "router" +) + +// ShareInfo describes a single shared resource (SMB share or AFP volume). +type ShareInfo struct { + Name string `json:"name"` + Path string `json:"path"` + ReadOnly bool `json:"read_only"` +} + +// Unit is the status of a single managed component. +type Unit struct { + Name string `json:"name"` + Kind string `json:"kind"` + Enabled bool `json:"enabled"` + // Running reflects live lifecycle state (IsRunning) and is updated by + // SetRunning as the supervisor starts/stops the unit. + Running bool `json:"running"` + // Binding is the interface or address the unit is bound to, e.g. + // "COM1", ":548", "239.192.76.84:1954". + Binding string `json:"binding,omitempty"` + // Properties holds generic key/value detail (zone, seed range, …). + Properties map[string]string `json:"properties,omitempty"` + // Service-specific structured detail; only the relevant fields are set. + Hostnames []string `json:"hostnames,omitempty"` + Zones []string `json:"zones,omitempty"` + Shares []ShareInfo `json:"shares,omitempty"` + // DependsOn names units that must be (re)started around this one, e.g. + // SMB depends on NetBIOS. Used for dependency-aware restart. + DependsOn []string `json:"depends_on,omitempty"` +} + +// Registry is a concurrency-safe collection of Units keyed by Name. +type Registry struct { + mu sync.RWMutex + units map[string]Unit + order []string // preserves registration order for stable snapshots +} + +// NewRegistry returns an empty registry. +func NewRegistry() *Registry { + return &Registry{units: make(map[string]Unit)} +} + +// Default is the process-global registry. Wiring code registers Units here +// without threading a pointer through every constructor, mirroring the +// expvar/telemetry global style. +var Default = NewRegistry() + +// Set inserts or replaces the Unit named u.Name. +func (r *Registry) Set(u Unit) { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.units[u.Name]; !ok { + r.order = append(r.order, u.Name) + } + r.units[u.Name] = u +} + +// SetRunning updates only the Running flag of an existing unit. It is a +// no-op if the unit is not registered. +func (r *Registry) SetRunning(name string, running bool) { + r.mu.Lock() + defer r.mu.Unlock() + if u, ok := r.units[name]; ok { + u.Running = running + r.units[name] = u + } +} + +// Remove deletes a unit by name. +func (r *Registry) Remove(name string) { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.units[name]; !ok { + return + } + delete(r.units, name) + for i, n := range r.order { + if n == name { + r.order = append(r.order[:i], r.order[i+1:]...) + break + } + } +} + +// Snapshot returns a copy of all units in registration order. +func (r *Registry) Snapshot() []Unit { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]Unit, 0, len(r.order)) + for _, name := range r.order { + out = append(out, r.units[name]) + } + return out +} diff --git a/service/webui/api.go b/service/webui/api.go new file mode 100644 index 0000000..a3b5d93 --- /dev/null +++ b/service/webui/api.go @@ -0,0 +1,162 @@ +//go:build webui || all + +package webui + +import ( + "encoding/json" + "net/http" + + "github.com/ObsoleteMadness/ClassicStack/config" +) + +// routes registers all HTTP handlers. Static assets are served from the +// embedded SPA; everything under /api delegates to the control plane. +func (s *Server) routes() { + s.mux.Handle("/", s.staticHandler()) + + s.mux.HandleFunc("/api/status", s.handleStatus) + s.mux.HandleFunc("/api/interfaces", s.handleInterfaces) + s.mux.HandleFunc("/api/serial-ports", s.handleSerialPorts) + s.mux.HandleFunc("/api/config", s.handleConfig) + s.mux.HandleFunc("/api/config/apply", s.handleApply) + s.mux.HandleFunc("/api/config/save", s.handleSave) + s.mux.HandleFunc("/api/config/download", s.handleDownload) + s.mux.HandleFunc("/api/services/", s.handleServiceAction) + s.mux.HandleFunc("/api/stats/stream", s.handleStatsStream) + + s.registerDiagnosticRoutes() +} + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeJSON(w, http.StatusOK, []any{}) + return + } + writeJSON(w, http.StatusOK, s.opts.Plane.Status()) +} + +func (s *Server) handleInterfaces(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeJSON(w, http.StatusOK, []string{}) + return + } + names, err := s.opts.Plane.ListInterfaces() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, names) +} + +func (s *Server) handleSerialPorts(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeJSON(w, http.StatusOK, []any{}) + return + } + ports, err := s.opts.Plane.ListSerialPorts() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, ports) +} + +// configResponse is the GET /api/config payload. +type configResponse struct { + Config *config.Model `json:"config"` + Dirty bool `json:"dirty"` +} + +func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + switch r.Method { + case http.MethodGet: + cfg, dirty := s.opts.Plane.Config() + model, _ := cfg.(*config.Model) + writeJSON(w, http.StatusOK, configResponse{Config: model, Dirty: dirty}) + case http.MethodPut: + var edit config.Model + if err := json.NewDecoder(r.Body).Decode(&edit); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + s.opts.Plane.Stage(&edit) + writeJSON(w, http.StatusOK, map[string]any{"dirty": true}) + default: + w.Header().Set("Allow", "GET, PUT") + writeError(w, http.StatusMethodNotAllowed, errMethod) + } +} + +func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, errMethod) + return + } + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + if err := s.opts.Plane.Apply(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"applied": true}) +} + +func (s *Server) handleSave(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, errMethod) + return + } + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + backup, err := s.opts.Plane.Save() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"saved": true, "backup": backup}) +} + +func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + data, err := s.opts.Plane.Export() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + w.Header().Set("Content-Type", "application/toml") + w.Header().Set("Content-Disposition", `attachment; filename="server.toml"`) + _, _ = w.Write(data) +} + +// handleServiceAction handles POST /api/services/{name}/restart. +func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, errMethod) + return + } + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + name, action := parseServicePath(r.URL.Path) + if name == "" || action != "restart" { + writeError(w, http.StatusNotFound, errNotFound) + return + } + if err := s.opts.Plane.RestartService(r.Context(), name); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"restarted": name}) +} diff --git a/service/webui/assets/app.css b/service/webui/assets/app.css new file mode 100644 index 0000000..010ad4f --- /dev/null +++ b/service/webui/assets/app.css @@ -0,0 +1,130 @@ +:root { + --bg: #f4f4f7; + --panel: #ffffff; + --border: #c9c9d2; + --accent: #2b7de9; + --accent-text: #ffffff; + --muted: #6b6b76; + --ok: #2e9e4f; + --off: #b0b0b8; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: #1c1c22; +} + +.topbar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.6rem 1rem; + background: var(--panel); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} + +.topbar h1 { font-size: 1.1rem; margin: 0; } + +nav { display: flex; gap: 0.25rem; } + +.tab { + border: 1px solid var(--border); + background: var(--bg); + padding: 0.35rem 0.8rem; + border-radius: 6px; + cursor: pointer; +} +.tab.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); } + +.dirty { + margin-left: auto; + color: #b5530a; + font-weight: 600; + font-size: 0.85rem; +} +.hidden { display: none; } + +main { padding: 1rem; } + +.panel-view { display: none; } +.panel-view.active { display: block; } + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.9rem; +} +.card h3 { margin: 0 0 0.4rem; display: flex; align-items: center; gap: 0.5rem; } + +.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; background: var(--off); } +.dot.running { background: var(--ok); } + +.kv { font-size: 0.85rem; color: var(--muted); margin: 0.15rem 0; } +.kv b { color: #1c1c22; font-weight: 600; } + +.card .metric { font-variant-numeric: tabular-nums; } + +.card button { margin-top: 0.5rem; } + +.config-panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.8rem 1rem; + margin-bottom: 1rem; +} +.config-panel legend { font-weight: 600; color: var(--accent); padding: 0 0.4rem; } +.field { display: flex; align-items: center; gap: 0.6rem; margin: 0.4rem 0; } +.field label { width: 150px; color: var(--muted); } +.field input[type="text"], .field input[type="number"], .field select { + padding: 0.3rem 0.5rem; + border: 1px solid var(--border); + border-radius: 6px; + min-width: 200px; +} + +.banner { + background: #fff7e6; + border: 1px solid #f0d28a; + border-radius: 8px; + padding: 0.6rem 0.9rem; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.actions { display: flex; gap: 0.6rem; margin-top: 1rem; } +button { + border: 1px solid var(--border); + background: var(--bg); + padding: 0.45rem 1rem; + border-radius: 6px; + cursor: pointer; +} +button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); } + +.status-line { + margin-top: 0.8rem; + background: #0e1116; + color: #cfe8ff; + padding: 0.7rem; + border-radius: 8px; + white-space: pre-wrap; + min-height: 1.5rem; +} + +.diag-tools { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } +.diag-tools .aep input { width: 70px; } diff --git a/service/webui/assets/app.js b/service/webui/assets/app.js new file mode 100644 index 0000000..0c6f759 --- /dev/null +++ b/service/webui/assets/app.js @@ -0,0 +1,374 @@ +"use strict"; + +// ClassicStack management SPA. A deliberately dependency-free vanilla-JS +// app: it talks to the control-plane JSON API and the SSE stats stream. +// The HTTP layer in service/webui owns no logic; everything here maps UI +// actions onto control-plane endpoints. + +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + +let currentConfig = null; // last-loaded config model (edited in place) +let latestRates = {}; // metric name -> per-second rate from SSE + +// ---- tab switching ---- +$$(".tab").forEach((btn) => { + btn.addEventListener("click", () => { + $$(".tab").forEach((b) => b.classList.remove("active")); + $$(".panel-view").forEach((v) => v.classList.remove("active")); + btn.classList.add("active"); + $("#" + btn.dataset.tab).classList.add("active"); + if (btn.dataset.tab === "config") loadConfig(); + }); +}); + +// ---- dashboard ---- +async function loadStatus() { + try { + const units = await fetchJSON("/api/status"); + renderStatus(units); + } catch (e) { + $("#service-grid").textContent = "Failed to load status: " + e.message; + } +} + +function renderStatus(units) { + const grid = $("#service-grid"); + grid.innerHTML = ""; + units.forEach((u) => { + const card = document.createElement("div"); + card.className = "card"; + const props = u.properties || {}; + let detail = ""; + if (u.binding) detail += kv("Binding", u.binding); + Object.keys(props).forEach((k) => (detail += kv(k, props[k]))); + if (u.zones && u.zones.length) detail += kv("Zones", u.zones.join(", ")); + if (u.hostnames && u.hostnames.length) detail += kv("Hostnames", u.hostnames.join(", ")); + if (u.shares && u.shares.length) + detail += kv("Shares", u.shares.map((s) => s.name).join(", ")); + + card.innerHTML = ` +

${esc(u.name)}

+
${u.enabled ? "Enabled" : "Disabled"} · ${u.running ? "Running" : "Stopped"}
+ ${detail} +
+ + `; + card.querySelector("[data-restart]").addEventListener("click", () => restart(u.name)); + grid.appendChild(card); + }); +} + +function kv(k, v) { + return `
${esc(k)}: ${esc(String(v))}
`; +} + +async function restart(name) { + try { + await postJSON(`/api/services/${encodeURIComponent(name)}/restart`, null); + setTimeout(loadStatus, 300); + } catch (e) { + alert("Restart failed: " + e.message); + } +} + +// ---- live stats via SSE ---- +function startStats() { + const es = new EventSource("/api/stats/stream"); + es.onmessage = (ev) => { + try { + const frame = JSON.parse(ev.data); + latestRates = frame.rates || {}; + $$("[data-metric-for]").forEach((el) => { + const name = el.getAttribute("data-metric-for"); + const total = sumRatesForUnit(name); + if (total !== null) el.textContent = `${total}/s`; + }); + } catch (_) {} + }; + es.onerror = () => { + /* browser auto-reconnects */ + }; +} + +// sumRatesForUnit matches metric names that embed the unit name. Naming is +// best-effort until services publish per-unit metric labels. +function sumRatesForUnit(unit) { + const key = unit.toLowerCase().replace(/[^a-z0-9]/g, ""); + let sum = 0; + let found = false; + Object.keys(latestRates).forEach((m) => { + if (m.toLowerCase().replace(/[^a-z0-9]/g, "").includes(key)) { + sum += latestRates[m]; + found = true; + } + }); + return found ? sum : null; +} + +// ---- configuration editor ---- +async function loadConfig() { + try { + const resp = await fetchJSON("/api/config"); + currentConfig = resp.config; + setDirty(resp.dirty); + renderConfig(currentConfig); + } catch (e) { + $("#config-panels").textContent = "Failed to load config: " + e.message; + } +} + +// Panels mirror the classic control-panel layout. Each field binds to a +// dotted path in the config model. +const CONFIG_PANELS = [ + { + title: "LocalTalk over UDP", + fields: [ + { label: "Enabled", path: "LToUdp.enabled", type: "bool" }, + { label: "Interface", path: "LToUdp.interface", type: "text" }, + { label: "Zone Name", path: "LToUdp.seed_zone", type: "text" }, + { label: "Seed Network", path: "LToUdp.seed_network", type: "number" }, + ], + }, + { + title: "TashTalk (LocalTalk)", + fields: [ + { label: "Serial Port", path: "TashTalk.port", type: "serial" }, + { label: "Zone Name", path: "TashTalk.seed_zone", type: "text" }, + { label: "Seed Network", path: "TashTalk.seed_network", type: "number" }, + ], + }, + { + title: "EtherTalk", + fields: [ + { label: "Interface", path: "Bridge.device", type: "iface" }, + { label: "Bridge Mode", path: "Bridge.mode", type: "text" }, + { label: "Zone Name", path: "EtherTalk.seed_zone", type: "text" }, + { label: "Seed Net Min", path: "EtherTalk.seed_network_min", type: "number" }, + { label: "Seed Net Max", path: "EtherTalk.seed_network_max", type: "number" }, + ], + }, + { + title: "NetBEUI (NBF)", + fields: [ + { label: "Enabled", path: "NetBEUI.enabled", type: "bool" }, + { label: "Interface", path: "NetBEUI.interface", type: "iface" }, + ], + }, + { + title: "IPX", + fields: [ + { label: "Enabled", path: "IPX.enabled", type: "bool" }, + { label: "Interface", path: "IPX.interface", type: "iface" }, + { label: "Framing", path: "IPX.framing", type: "text" }, + { label: "Network", path: "IPX.internal_network", type: "text" }, + ], + }, + { + title: "AFP File Server", + fields: [ + { label: "Enabled", path: "AFP.enabled", type: "bool" }, + { label: "Server Name", path: "AFP.name", type: "text" }, + { label: "Zone", path: "AFP.zone", type: "text" }, + { label: "Binding", path: "AFP.binding", type: "text" }, + ], + }, + { + title: "SMB Server", + fields: [ + { label: "Enabled", path: "SMB.enabled", type: "bool" }, + { label: "Server Name", path: "SMB.server_name", type: "text" }, + { label: "Workgroup", path: "SMB.workgroup", type: "text" }, + { label: "NBT Binding", path: "SMB.nbt_binding", type: "text" }, + ], + }, + { + title: "Web UI", + fields: [ + { label: "Enabled", path: "WebUI.enabled", type: "bool" }, + { label: "Bind", path: "WebUI.bind", type: "text" }, + { label: "TLS", path: "WebUI.tls", type: "bool" }, + ], + }, +]; + +let interfaceList = []; +let serialList = []; + +async function renderConfig(cfg) { + [interfaceList, serialList] = await Promise.all([ + fetchJSON("/api/interfaces").catch(() => []), + fetchJSON("/api/serial-ports").catch(() => []), + ]); + + const root = $("#config-panels"); + root.innerHTML = ""; + CONFIG_PANELS.forEach((panel) => { + const fs = document.createElement("fieldset"); + fs.className = "config-panel"; + const legend = document.createElement("legend"); + legend.textContent = panel.title; + fs.appendChild(legend); + panel.fields.forEach((f) => fs.appendChild(renderField(cfg, f))); + root.appendChild(fs); + }); +} + +function renderField(cfg, f) { + const row = document.createElement("div"); + row.className = "field"; + const label = document.createElement("label"); + label.textContent = f.label; + row.appendChild(label); + + const val = getPath(cfg, f.path); + let input; + if (f.type === "bool") { + input = document.createElement("input"); + input.type = "checkbox"; + input.checked = !!val; + input.addEventListener("change", () => { + setPath(cfg, f.path, input.checked); + setDirty(true); + }); + } else if (f.type === "iface" || f.type === "serial") { + input = document.createElement("select"); + const options = f.type === "iface" ? interfaceList : serialList.map((s) => s.name); + const blank = document.createElement("option"); + blank.value = ""; + blank.textContent = "(none)"; + input.appendChild(blank); + options.forEach((opt) => { + const o = document.createElement("option"); + o.value = opt; + o.textContent = opt; + if (opt === val) o.selected = true; + input.appendChild(o); + }); + input.addEventListener("change", () => { + setPath(cfg, f.path, input.value); + setDirty(true); + }); + } else { + input = document.createElement("input"); + input.type = f.type === "number" ? "number" : "text"; + input.value = val == null ? "" : val; + input.addEventListener("input", () => { + setPath(cfg, f.path, f.type === "number" ? Number(input.value) : input.value); + setDirty(true); + }); + } + row.appendChild(input); + return row; +} + +function getPath(obj, path) { + return path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj); +} +function setPath(obj, path, value) { + const keys = path.split("."); + const last = keys.pop(); + let o = obj; + keys.forEach((k) => { + if (o[k] == null) o[k] = {}; + o = o[k]; + }); + o[last] = value; +} + +function setDirty(d) { + $("#dirty-indicator").classList.toggle("hidden", !d); +} + +// ---- config actions ---- +$("#btn-download").addEventListener("click", () => { + window.location.href = "/api/config/download"; +}); + +$("#btn-apply").addEventListener("click", async () => { + try { + await putJSON("/api/config", currentConfig); + await postJSON("/api/config/apply", null); + status("Applied live. Changes are running but not yet saved to disk."); + loadStatus(); + } catch (e) { + status("Apply failed: " + e.message); + } +}); + +$("#btn-save").addEventListener("click", async () => { + if (!confirm("Saving rewrites server.toml and removes comments. Continue?")) return; + try { + await putJSON("/api/config", currentConfig); + const r = await postJSON("/api/config/save", null); + setDirty(false); + status("Saved. Backup written to " + (r.backup || "(no previous file)") + "."); + } catch (e) { + status("Save failed: " + e.message); + } +}); + +function status(msg) { + $("#config-status").textContent = msg; +} + +// ---- diagnostics ---- +$$("[data-diag]").forEach((btn) => { + btn.addEventListener("click", async () => { + const kind = btn.dataset.diag; + const out = $("#diag-output"); + out.textContent = "Running " + kind + "…"; + try { + let url = "/api/diag/" + kind; + if (kind === "aep-echo") { + url += `?network=${$("#aep-net").value}&node=${$("#aep-node").value}`; + } + const data = kind === "aep-echo" ? await fetchJSON(url) : await fetchJSON(url); + out.textContent = JSON.stringify(data, null, 2); + } catch (e) { + out.textContent = kind + " failed: " + e.message; + } + }); +}); + +// ---- fetch helpers ---- +async function fetchJSON(url) { + const r = await fetch(url); + if (!r.ok) throw new Error((await safeErr(r)) || r.statusText); + return r.json(); +} +async function postJSON(url, body) { + const r = await fetch(url, { + method: "POST", + headers: body ? { "Content-Type": "application/json" } : {}, + body: body ? JSON.stringify(body) : null, + }); + if (!r.ok) throw new Error((await safeErr(r)) || r.statusText); + return r.json(); +} +async function putJSON(url, body) { + const r = await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error((await safeErr(r)) || r.statusText); + return r.json(); +} +async function safeErr(r) { + try { + const j = await r.json(); + return j.error; + } catch (_) { + return null; + } +} + +function esc(s) { + return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); +} + +// ---- boot ---- +loadStatus(); +startStats(); +setInterval(loadStatus, 5000); diff --git a/service/webui/assets/index.html b/service/webui/assets/index.html new file mode 100644 index 0000000..5ce2618 --- /dev/null +++ b/service/webui/assets/index.html @@ -0,0 +1,56 @@ + + + + + + ClassicStack + + + +
+

ClassicStack

+ + +
+ +
+
+
+
+ +
+ +
+
+ + + +
+

+    
+ +
+
+ + + + + + AEP Echo net + node + + +
+

+    
+
+ + + + diff --git a/service/webui/diagnostics.go b/service/webui/diagnostics.go new file mode 100644 index 0000000..7878df9 --- /dev/null +++ b/service/webui/diagnostics.go @@ -0,0 +1,94 @@ +//go:build webui || all + +package webui + +import ( + "net/http" + "strconv" +) + +// registerDiagnosticRoutes wires the read-only network-probe endpoints. +// Each delegates to the control plane's Diagnostics facade, which reports +// ErrDiagUnavailable for probes not compiled into this build. +func (s *Server) registerDiagnosticRoutes() { + s.mux.HandleFunc("/api/diag/zones", s.handleDiagZones) + s.mux.HandleFunc("/api/diag/zip", s.handleDiagZIP) + s.mux.HandleFunc("/api/diag/ddp", s.handleDiagDDP) + s.mux.HandleFunc("/api/diag/aep-echo", s.handleDiagAEPEcho) + s.mux.HandleFunc("/api/diag/smb-browse", s.handleDiagSMBBrowse) +} + +func (s *Server) handleDiagZones(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + zones, err := s.opts.Plane.Diagnostics().ListZones(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, zones) +} + +func (s *Server) handleDiagZIP(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + zones, err := s.opts.Plane.Diagnostics().ZIPEnumerate(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, zones) +} + +func (s *Server) handleDiagDDP(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + nets, err := s.opts.Plane.Diagnostics().DDPEnumerate(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, nets) +} + +func (s *Server) handleDiagAEPEcho(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + net64, err := strconv.ParseUint(r.URL.Query().Get("network"), 10, 16) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + node64, err := strconv.ParseUint(r.URL.Query().Get("node"), 10, 8) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + res, err := s.opts.Plane.Diagnostics().AEPEcho(r.Context(), uint16(net64), uint8(node64)) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, res) +} + +func (s *Server) handleDiagSMBBrowse(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + servers, err := s.opts.Plane.Diagnostics().SMBBrowse(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, servers) +} diff --git a/service/webui/embed.go b/service/webui/embed.go new file mode 100644 index 0000000..ba1da3b --- /dev/null +++ b/service/webui/embed.go @@ -0,0 +1,49 @@ +//go:build webui || all + +package webui + +import ( + "embed" + "io/fs" + "net/http" +) + +// assetsFS holds the pre-built single-page app. The committed assets/ tree +// is what ships; service/webui/web/ holds the (optional) source with a +// documented rebuild step. +// +//go:embed assets +var assetsFS embed.FS + +// staticHandler serves the embedded SPA, falling back to index.html for +// unknown paths so client-side routing works. +func (s *Server) staticHandler() http.Handler { + sub, err := fs.Sub(assetsFS, "assets") + if err != nil { + // Embedding guarantees assets/ exists; this is unreachable in a + // correctly built binary. + panic(err) + } + fileServer := http.FileServer(http.FS(sub)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := fs.Stat(sub, trimLeadingSlash(r.URL.Path)); err != nil && r.URL.Path != "/" { + // Unknown path: serve the SPA shell. + r2 := new(http.Request) + *r2 = *r + r2.URL.Path = "/" + fileServer.ServeHTTP(w, r2) + return + } + fileServer.ServeHTTP(w, r) + }) +} + +func trimLeadingSlash(p string) string { + if p == "/" || p == "" { + return "index.html" + } + if p[0] == '/' { + return p[1:] + } + return p +} diff --git a/service/webui/http_util.go b/service/webui/http_util.go new file mode 100644 index 0000000..884aef6 --- /dev/null +++ b/service/webui/http_util.go @@ -0,0 +1,45 @@ +//go:build webui || all + +package webui + +import ( + "encoding/json" + "errors" + "net/http" + "strings" +) + +var ( + errNoPlane = errors.New("management plane unavailable") + errMethod = errors.New("method not allowed") + errNotFound = errors.New("not found") + errNoFlush = errors.New("streaming unsupported") +) + +// writeJSON encodes v as the response body with the given status. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if v != nil { + _ = json.NewEncoder(w).Encode(v) + } +} + +// writeError encodes a JSON error envelope. +func writeError(w http.ResponseWriter, status int, err error) { + writeJSON(w, status, map[string]string{"error": err.Error()}) +} + +// parseServicePath extracts {name} and {action} from +// /api/services/{name}/{action}. +func parseServicePath(path string) (name, action string) { + rest := strings.TrimPrefix(path, "/api/services/") + if rest == path { // prefix not present + return "", "" + } + parts := strings.SplitN(strings.Trim(rest, "/"), "/", 2) + if len(parts) != 2 { + return "", "" + } + return parts[0], parts[1] +} diff --git a/service/webui/plane.go b/service/webui/plane.go new file mode 100644 index 0000000..19cbf80 --- /dev/null +++ b/service/webui/plane.go @@ -0,0 +1,28 @@ +//go:build webui || all + +package webui + +import ( + "context" + + "github.com/ObsoleteMadness/ClassicStack/pkg/control" + "github.com/ObsoleteMadness/ClassicStack/pkg/serialport" + "github.com/ObsoleteMadness/ClassicStack/pkg/status" +) + +// ControlPlane is the subset of *control.Plane the web UI drives. Declaring +// it as an interface (satisfied by *control.Plane) keeps the HTTP adapter +// decoupled from the plane's construction and lets tests inject a fake. +type ControlPlane interface { + Status() []status.Unit + Config() (cfg control.ConfigModel, dirty bool) + Stage(edit control.ConfigModel) + Apply(ctx context.Context) error + Save() (backupPath string, err error) + Export() ([]byte, error) + RestartService(ctx context.Context, name string) error + ListInterfaces() ([]string, error) + ListSerialPorts() ([]serialport.Info, error) + Subscribe() (<-chan control.Frame, func()) + Diagnostics() control.Diagnostics +} diff --git a/service/webui/server.go b/service/webui/server.go new file mode 100644 index 0000000..c9e5cc8 --- /dev/null +++ b/service/webui/server.go @@ -0,0 +1,134 @@ +//go:build webui || all + +// Package webui is a thin HTTP/SSE adapter over the transport-agnostic +// management API in pkg/control. It owns no management logic of its own: +// every handler delegates to the ControlPlane it is given, so a future +// text/telnet UI can drive the same operations without HTTP. +package webui + +import ( + "context" + "crypto/tls" + "errors" + "net" + "net/http" + "sync" + "time" + + "github.com/ObsoleteMadness/ClassicStack/netlog" +) + +// Options configures the web UI server. +type Options struct { + // Bind is the listen address, e.g. "127.0.0.1:8080". + Bind string + // TLS enables HTTPS. When CertPEM/KeyPEM are blank a self-signed + // certificate is generated for the lifetime of the process. + TLS bool + CertPEM string + KeyPEM string + // Plane is the management API the server adapts. May be nil in + // degraded/diagnostic configurations; handlers guard for it. + Plane ControlPlane +} + +// Server is the web UI HTTP(S) listener. +type Server struct { + opts Options + mux *http.ServeMux + + mu sync.Mutex + httpd *http.Server + ln net.Listener + closed bool +} + +// NewServer constructs the server and wires its routes. It does not bind a +// socket until Start. +func NewServer(opts Options) (*Server, error) { + if opts.Bind == "" { + return nil, errors.New("webui: bind address is required") + } + s := &Server{opts: opts, mux: http.NewServeMux()} + s.routes() + return s, nil +} + +// Start binds the listener and serves in a background goroutine. It +// returns once the socket is open (or immediately on bind failure). +func (s *Server) Start(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return errors.New("webui: server already stopped") + } + + ln, err := net.Listen("tcp", s.opts.Bind) + if err != nil { + return err + } + + httpd := &http.Server{ + Handler: s.mux, + ReadHeaderTimeout: 10 * time.Second, + BaseContext: func(net.Listener) context.Context { return ctx }, + } + + if s.opts.TLS { + tlsCfg, err := s.tlsConfig() + if err != nil { + _ = ln.Close() + return err + } + httpd.TLSConfig = tlsCfg + ln = tls.NewListener(ln, tlsCfg) + } + + s.httpd = httpd + s.ln = ln + + go func() { + if err := httpd.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + netlog.Warn("[WebUI] serve error: %v", err) + } + }() + + scheme := "http" + if s.opts.TLS { + scheme = "https" + } + netlog.Info("[WebUI] listening on %s://%s", scheme, s.opts.Bind) + return nil +} + +// Stop gracefully shuts down the server. +func (s *Server) Stop() error { + s.mu.Lock() + httpd := s.httpd + s.closed = true + s.mu.Unlock() + if httpd == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return httpd.Shutdown(ctx) +} + +// tlsConfig loads the configured cert/key, or generates a self-signed +// certificate when both are blank. +func (s *Server) tlsConfig() (*tls.Config, error) { + if s.opts.CertPEM != "" && s.opts.KeyPEM != "" { + cert, err := tls.LoadX509KeyPair(s.opts.CertPEM, s.opts.KeyPEM) + if err != nil { + return nil, err + } + return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil + } + cert, err := selfSignedCert(s.opts.Bind) + if err != nil { + return nil, err + } + netlog.Info("[WebUI] using generated self-signed certificate") + return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil +} diff --git a/service/webui/stream.go b/service/webui/stream.go new file mode 100644 index 0000000..05c751a --- /dev/null +++ b/service/webui/stream.go @@ -0,0 +1,56 @@ +//go:build webui || all + +package webui + +import ( + "encoding/json" + "net/http" +) + +// handleStatsStream is a Server-Sent Events endpoint that pushes a stats +// Frame to the client every second. It subscribes to the control plane's +// broadcaster and unsubscribes when the client disconnects. +func (s *Server) handleStatsStream(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, errNoFlush) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + frames, cancel := s.opts.Plane.Subscribe() + defer cancel() + + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case frame, ok := <-frames: + if !ok { + return + } + payload, err := json.Marshal(frame) + if err != nil { + continue + } + if _, err := w.Write([]byte("data: ")); err != nil { + return + } + if _, err := w.Write(payload); err != nil { + return + } + if _, err := w.Write([]byte("\n\n")); err != nil { + return + } + flusher.Flush() + } + } +} diff --git a/service/webui/tls.go b/service/webui/tls.go new file mode 100644 index 0000000..5850d37 --- /dev/null +++ b/service/webui/tls.go @@ -0,0 +1,81 @@ +//go:build webui || all + +package webui + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "strings" + "time" +) + +// selfSignedCert generates an in-memory, self-signed certificate suitable +// for the web UI's loopback/trusted-network deployment. The SANs include +// localhost and the bind host (when it is an IP literal) so browsers on +// the same machine can validate the hostname. The certificate lives only +// for the lifetime of the process. +func selfSignedCert(bind string) (tls.Certificate, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, err + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, err + } + + tmpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "ClassicStack Web UI"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().AddDate(1, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + } + + if host := bindHost(bind); host != "" { + if ip := net.ParseIP(host); ip != nil { + tmpl.IPAddresses = append(tmpl.IPAddresses, ip) + } else { + tmpl.DNSNames = append(tmpl.DNSNames, host) + } + } + + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + if err != nil { + return tls.Certificate{}, err + } + + keyDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return tls.Certificate{}, err + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + return tls.X509KeyPair(certPEM, keyPEM) +} + +// bindHost extracts the host portion of an "ip:port" bind address. A bare +// or wildcard host returns "". +func bindHost(bind string) string { + host, _, err := net.SplitHostPort(bind) + if err != nil { + return strings.TrimSpace(bind) + } + if host == "" || host == "0.0.0.0" || host == "::" { + return "" + } + return host +} From 62f339acdf55e4253d72f5f1787cb50ff7aead1c Mon Sep 17 00:00:00 2001 From: pgodwin Date: Thu, 4 Jun 2026 17:52:35 +1000 Subject: [PATCH 02/23] @ router: support dynamic add/remove of ports and services at runtime Make the router membership (ports, services, socket dispatch map) mutable while running so the management plane can enable or disable a transport or service without restarting the process. - Guard Ports/Services/servicesBySAS with an RWMutex; the receive path (deliver) and lifecycle iteration (Start/Stop) take it for reading, mutators for writing, so dispatch never sees a half-updated map. - AddService/RemoveService start/stop the service and register/unregister its socket; socket registration is extracted from New into registerServiceSocket/unregisterServiceSocket. - AddPort/RemovePort start/stop the port and bind the LLAP link manager; bindLLAPManager is refactored into a per-port bindPortLLAP helper. - RemovePort performs real route reconciliation via RoutingTable.RemoveEntriesForPort, withdrawing every route reachable through the port and dropping its zone associations (mirrors the cleanup SetPortRange/Age already do), so disabling e.g. LToUDP drops its networks instead of leaving stale routes. Race-tested: concurrent deliver during AddService/RemoveService churn, plus add/remove bookkeeping and route withdrawal on port removal. Co-Authored-By: Claude Opus 4.8 @ --- router/dynamic.go | 85 +++++++++++++++++++++++ router/dynamic_test.go | 145 ++++++++++++++++++++++++++++++++++++++++ router/router.go | 117 ++++++++++++++++++++++++-------- router/routing_table.go | 41 ++++++++++++ 4 files changed, 359 insertions(+), 29 deletions(-) create mode 100644 router/dynamic.go create mode 100644 router/dynamic_test.go diff --git a/router/dynamic.go b/router/dynamic.go new file mode 100644 index 0000000..b1ccd43 --- /dev/null +++ b/router/dynamic.go @@ -0,0 +1,85 @@ +package router + +import ( + "context" + + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/service" +) + +// The methods in this file mutate the router's membership (ports and +// services) while it is running, so the management plane can enable or +// disable a transport or service without restarting the whole process. +// They take r.membership for writing; the receive path (deliver/Inbound) +// takes it for reading, so dispatch never observes a half-updated map. + +// AddService starts s, registers the socket it listens on, and adds it to +// the active service set. If Start fails the service is not added. +func (r *Router) AddService(ctx context.Context, s service.Service) error { + netlog.Info("%s adding service %T", r.ShortString(), s) + if err := s.Start(ctx, r); err != nil { + return err + } + r.membership.Lock() + r.Services = append(r.Services, s) + r.registerServiceSocket(s) + r.membership.Unlock() + return nil +} + +// RemoveService stops s and removes it from the active service set and the +// socket dispatch map. The service's Stop error is returned but removal +// happens regardless so a failing Stop cannot wedge the membership. +func (r *Router) RemoveService(s service.Service) error { + netlog.Info("%s removing service %T", r.ShortString(), s) + r.membership.Lock() + r.unregisterServiceSocket(s) + for i, svc := range r.Services { + if svc == s { + r.Services = append(r.Services[:i], r.Services[i+1:]...) + break + } + } + r.membership.Unlock() + return s.Stop() +} + +// AddPort starts p, binds the LLAP link manager to it (for LocalTalk-style +// ports), and adds it to the active port set. RTMP's seed-network handling +// during Start advertises the port's networks/zones, so no explicit route +// injection is needed here. +func (r *Router) AddPort(_ context.Context, p port.Port) error { + netlog.Info("%s adding port %T", r.ShortString(), p) + r.bindPortLLAP(p) + if err := p.Start(r); err != nil { + return err + } + r.membership.Lock() + r.Ports = append(r.Ports, p) + r.membership.Unlock() + return nil +} + +// RemovePort stops p, removes it from the active port set, and reconciles +// the routing and zone tables by withdrawing every route reachable through +// p. This is the live counterpart to a port disappearing: disabling e.g. +// LToUDP drops its seed network and any networks learned over it so the +// router stops advertising and forwarding to them. +func (r *Router) RemovePort(p port.Port) error { + netlog.Info("%s removing port %T", r.ShortString(), p) + r.membership.Lock() + for i, pt := range r.Ports { + if pt == p { + r.Ports = append(r.Ports[:i], r.Ports[i+1:]...) + break + } + } + r.membership.Unlock() + + // Withdraw routes/zones for the port before stopping it so dispatch no + // longer selects it as a next hop. + r.RoutingTable.RemoveEntriesForPort(p) + + return p.Stop() +} diff --git a/router/dynamic_test.go b/router/dynamic_test.go new file mode 100644 index 0000000..af4784c --- /dev/null +++ b/router/dynamic_test.go @@ -0,0 +1,145 @@ +package router + +import ( + "context" + "sync" + "sync/atomic" + "testing" + + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/service" +) + +// fakePort is a minimal port.Port for membership tests. It records +// start/stop and reports a fixed directly-connected network range. +type fakePort struct { + name string + netMin uint16 + netMax uint16 + started atomic.Bool + stopped atomic.Bool +} + +func (p *fakePort) ShortString() string { return p.name } +func (p *fakePort) Start(port.RouterHooks) error { p.started.Store(true); return nil } +func (p *fakePort) Stop() error { p.stopped.Store(true); return nil } +func (p *fakePort) Unicast(uint16, uint8, ddp.Datagram) {} +func (p *fakePort) Broadcast(ddp.Datagram) {} +func (p *fakePort) Multicast([]byte, ddp.Datagram) {} +func (p *fakePort) SetNetworkRange(uint16, uint16) error { return nil } +func (p *fakePort) Network() uint16 { return p.netMin } +func (p *fakePort) Node() uint8 { return 1 } +func (p *fakePort) NetworkMin() uint16 { return p.netMin } +func (p *fakePort) NetworkMax() uint16 { return p.netMax } +func (p *fakePort) ExtendedNetwork() bool { return p.netMin != p.netMax } + +// fakeService is a minimal service.Service that listens on a fixed socket. +type fakeService struct { + socket uint8 + started atomic.Bool + stopped atomic.Bool +} + +func (s *fakeService) Socket() uint8 { return s.socket } +func (s *fakeService) Start(context.Context, service.Router) error { + s.started.Store(true) + return nil +} +func (s *fakeService) Stop() error { s.stopped.Store(true); return nil } +func (s *fakeService) Inbound(ddp.Datagram, port.Port) {} + +func newTestRouter() *Router { + return New("test", nil, []service.Service{}) +} + +func TestAddRemoveServiceSocketBookkeeping(t *testing.T) { + r := newTestRouter() + svc := &fakeService{socket: 99} + + if err := r.AddService(context.Background(), svc); err != nil { + t.Fatalf("AddService: %v", err) + } + if !svc.started.Load() { + t.Error("service not started on AddService") + } + r.membership.RLock() + got := r.servicesBySAS[99] + r.membership.RUnlock() + if got != svc { + t.Error("socket 99 not registered to service") + } + + if err := r.RemoveService(svc); err != nil { + t.Fatalf("RemoveService: %v", err) + } + if !svc.stopped.Load() { + t.Error("service not stopped on RemoveService") + } + r.membership.RLock() + _, ok := r.servicesBySAS[99] + r.membership.RUnlock() + if ok { + t.Error("socket 99 still registered after RemoveService") + } +} + +func TestRemovePortWithdrawsRoutes(t *testing.T) { + r := newTestRouter() + p := &fakePort{name: "fake", netMin: 10, netMax: 12} + + if err := r.AddPort(context.Background(), p); err != nil { + t.Fatalf("AddPort: %v", err) + } + if !p.started.Load() { + t.Error("port not started on AddPort") + } + + // Seed a directly-connected route for the port, as RTMP would. + r.RoutingTable.SetPortRange(p, 10, 12) + if e, _ := r.RoutingTable.GetByNetwork(11); e == nil { + t.Fatal("expected route for network 11 after SetPortRange") + } + + if err := r.RemovePort(p); err != nil { + t.Fatalf("RemovePort: %v", err) + } + if !p.stopped.Load() { + t.Error("port not stopped on RemovePort") + } + if e, _ := r.RoutingTable.GetByNetwork(11); e != nil { + t.Errorf("route for network 11 still present after RemovePort: %+v", e) + } +} + +func TestConcurrentDispatchDuringMembershipChange(t *testing.T) { + r := newTestRouter() + var wg sync.WaitGroup + + // Reader: hammer deliver via the dispatch map while services churn. + stop := make(chan struct{}) + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + r.deliver(ddp.Datagram{DestinationSocket: 50}, nil) + } + } + }() + + for range 200 { + svc := &fakeService{socket: 50} + if err := r.AddService(context.Background(), svc); err != nil { + t.Fatalf("AddService: %v", err) + } + if err := r.RemoveService(svc); err != nil { + t.Fatalf("RemoveService: %v", err) + } + } + close(stop) + wg.Wait() +} diff --git a/router/router.go b/router/router.go index 1f409c5..9c9ff1e 100644 --- a/router/router.go +++ b/router/router.go @@ -3,6 +3,7 @@ package router import ( "context" "errors" + "sync" "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" @@ -20,7 +21,12 @@ import ( var framesInTotal = telemetry.NewCounter("classicstack_router_frames_in_total") type Router struct { - shortStr string + shortStr string + // membership guards Ports, Services, and servicesBySAS against + // concurrent mutation (AddPort/RemovePort/AddService/RemoveService) + // while the receive path reads them. The routing and zone tables hold + // their own locks; this protects only the membership collections. + membership sync.RWMutex Ports []port.Port Services []service.Service servicesBySAS map[uint8]service.Service @@ -49,24 +55,42 @@ func New(shortStr string, ports []port.Port, services []service.Service) *Router r.Services = services r.bindLLAPManager() for _, s := range services { - switch v := s.(type) { - case *aep.Service: - r.servicesBySAS[aep.Socket] = s - case *zip.NameInformationService: - r.servicesBySAS[zip.NBPSASSocket] = s - case interface{ Socket() uint8 }: - r.servicesBySAS[v.Socket()] = s - case *rtmp.RespondingService: - r.servicesBySAS[rtmp.SAS] = s - case *zip.RespondingService: - r.servicesBySAS[zip.SAS] = s - case *rtmp.RoutingTableAgingService: - // RoutingTableAgingService doesn't work on socket basis - } + r.registerServiceSocket(s) } return r } +// registerServiceSocket records the static socket a service listens on, if +// any. Services that do not bind a socket (e.g. the RTMP aging timer) are +// ignored. Callers must hold r.membership when mutating at runtime; New +// runs before the router is shared so it calls this without the lock. +func (r *Router) registerServiceSocket(s service.Service) { + switch v := s.(type) { + case *aep.Service: + r.servicesBySAS[aep.Socket] = s + case *zip.NameInformationService: + r.servicesBySAS[zip.NBPSASSocket] = s + case interface{ Socket() uint8 }: + r.servicesBySAS[v.Socket()] = s + case *rtmp.RespondingService: + r.servicesBySAS[rtmp.SAS] = s + case *zip.RespondingService: + r.servicesBySAS[zip.SAS] = s + case *rtmp.RoutingTableAgingService: + // RoutingTableAgingService doesn't work on socket basis + } +} + +// unregisterServiceSocket drops s from the socket dispatch map. Callers +// must hold r.membership. +func (r *Router) unregisterServiceSocket(s service.Service) { + for sock, svc := range r.servicesBySAS { + if svc == s { + delete(r.servicesBySAS, sock) + } + } +} + func (r *Router) ShortString() string { return r.shortStr } func defaultServices() []service.Service { @@ -83,31 +107,47 @@ func defaultServices() []service.Service { } func (r *Router) bindLLAPManager() { - var llapSvc *llap.Service + for _, p := range r.Ports { + r.bindPortLLAP(p) + } +} + +// llapManager returns the registered LLAP service, or nil if none is +// present in the service set. +func (r *Router) llapManager() *llap.Service { for _, svc := range r.Services { if candidate, ok := svc.(*llap.Service); ok { - llapSvc = candidate - break + return candidate } } + return nil +} + +// bindPortLLAP wires the LLAP link manager into a single port if the port +// is LocalTalk-style (implements SetLLAPLinkManager) and an LLAP service is +// present. Used both at construction and when a port is added at runtime. +func (r *Router) bindPortLLAP(p port.Port) { + llapSvc := r.llapManager() if llapSvc == nil { return } - for _, p := range r.Ports { - if managed, ok := p.(interface{ SetLLAPLinkManager(localtalk.LinkManager) }); ok { - managed.SetLLAPLinkManager(llapSvc) - } + if managed, ok := p.(interface{ SetLLAPLinkManager(localtalk.LinkManager) }); ok { + managed.SetLLAPLinkManager(llapSvc) } } func (r *Router) deliver(datagram ddp.Datagram, rxPort port.Port) { - if svc, ok := r.servicesBySAS[datagram.DestinationSocket]; ok { + r.membership.RLock() + svc, ok := r.servicesBySAS[datagram.DestinationSocket] + r.membership.RUnlock() + if ok { svc.Inbound(datagram, rxPort) } } func (r *Router) Start(ctx context.Context) error { - for _, s := range r.Services { + services, ports := r.snapshotMembership() + for _, s := range services { if _, ok := s.(*llap.Service); !ok { continue } @@ -116,14 +156,14 @@ func (r *Router) Start(ctx context.Context) error { return err } } - for _, p := range r.Ports { + for _, p := range ports { netlog.Info("starting %T...", p) if err := p.Start(r); err != nil { return err } } netlog.Info("all ports started!") - for _, s := range r.Services { + for _, s := range services { if _, ok := s.(*llap.Service); ok { continue } @@ -137,15 +177,16 @@ func (r *Router) Start(ctx context.Context) error { } func (r *Router) Stop() error { + services, ports := r.snapshotMembership() var errs []error - for _, s := range r.Services { + for _, s := range services { netlog.Info("stopping %T...", s) if err := s.Stop(); err != nil { errs = append(errs, err) } } netlog.Info("all services stopped!") - for _, p := range r.Ports { + for _, p := range ports { netlog.Info("stopping %T...", p) if err := p.Stop(); err != nil { errs = append(errs, err) @@ -158,6 +199,18 @@ func (r *Router) Stop() error { return nil } +// snapshotMembership returns copies of the current service and port slices +// so lifecycle iteration is unaffected by concurrent Add/Remove. +func (r *Router) snapshotMembership() ([]service.Service, []port.Port) { + r.membership.RLock() + defer r.membership.RUnlock() + services := make([]service.Service, len(r.Services)) + copy(services, r.Services) + ports := make([]port.Port, len(r.Ports)) + copy(ports, r.Ports) + return services, ports +} + func (r *Router) Inbound(datagram ddp.Datagram, rxPort port.Port) { framesInTotal.Inc() if rxPort.Network() != 0 { @@ -281,7 +334,13 @@ func (r *Router) RoutingTableAge() { r.RoutingTable.Age() } -func (r *Router) PortsList() []port.Port { return r.Ports } +func (r *Router) PortsList() []port.Port { + r.membership.RLock() + defer r.membership.RUnlock() + out := make([]port.Port, len(r.Ports)) + copy(out, r.Ports) + return out +} func asServiceEntry(e *RoutingTableEntry) *service.RouteEntry { if e == nil { diff --git a/router/routing_table.go b/router/routing_table.go index 87b9fba..a2ba0eb 100644 --- a/router/routing_table.go +++ b/router/routing_table.go @@ -145,6 +145,47 @@ func (t *RoutingTable) MarkBad(networkMin, networkMax uint16) bool { return true } +// RemoveEntriesForPort withdraws every routing-table entry reachable via p +// — both the port's directly-connected networks and any remote networks +// learned through it — and drops their zone associations. It is called when +// a port is removed at runtime (e.g. the operator disables LToUDP) so the +// router stops advertising and routing to networks that no longer have a +// backing interface. It mirrors the cleanup SetPortRange/Age already do for +// a single entry, applied across all of p's entries at once. +func (t *RoutingTable) RemoveEntriesForPort(p port.Port) { + t.mu.Lock() + defer t.mu.Unlock() + + // Collect the distinct entries owned by p first; entryByKey is the + // authoritative set and dedupes the per-network fan-out. + var removed []*RoutingTableEntry + for k, e := range t.entryByKey { + if e.Port != p { + continue + } + netlog.Debug("%s removing entry for port %s: %+v", t.router.ShortString(), p.ShortString(), *e) + delete(t.stateByKey, k) + delete(t.entryByKey, k) + removed = append(removed, e) + } + + // Drop the per-network index entries pointing at any removed entry. + for n, e := range t.entryByNetwork { + if e.Port == p { + delete(t.entryByNetwork, n) + } + } + + // Withdraw the corresponding zone associations. + for _, e := range removed { + nmax := e.NetworkMax + if err := t.router.ZoneInformationTable.RemoveNetworks(e.NetworkMin, &nmax); err != nil { + netlog.Warn("%s couldn't remove networks from zone information table: %v", + t.router.ShortString(), err) + } + } +} + func (t *RoutingTable) Age() { t.mu.Lock() defer t.mu.Unlock() From 9b6bf8b5fc9e9513ff2b6acb53ea727a71df5231 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Thu, 4 Jun 2026 21:35:49 +1000 Subject: [PATCH 03/23] @ cmd: extract Supervisor; reduce main.go to flags + config + run main.go is now a thin entrypoint: parse flags / load TOML, project the resolved config into a config.Model, build the Supervisor, construct the management plane, wire the web UI, run, and tear down. All component construction and lifecycle moved into the Supervisor. - Supervisor owns ports, the AppleTalk router (and its DDP service set), and the standalone hooks (IPX/NetBEUI/NetBIOS/SMB/WebUI). It registers each as a named status unit and drives Start/Stop with the existing ordering (transports before the layers that consume them). - Per-service lifecycle from the UI: StartService/StopService/ RestartService act on a named hook and honour declared dependencies (stopping NetBIOS stops SMB; restarting brings dependents back around it). - Control-plane integration: Apply performs an atomic whole-stack rebuild from the edited model (finer-grained application can layer on later via the dynamic-router primitives); RestartService and ListInterfaces satisfy control.Supervisor. config.Save is installed as the planes --- cmd/classicstack/config_model.go | 231 +++++++++++ cmd/classicstack/diagnostics_impl.go | 82 ++++ cmd/classicstack/main.go | 411 ++------------------ cmd/classicstack/mainwiring.go | 101 +++++ cmd/classicstack/netutil.go | 87 +++++ cmd/classicstack/supervisor.go | 469 +++++++++++++++++++++++ cmd/classicstack/supervisor_control.go | 80 ++++ cmd/classicstack/supervisor_lifecycle.go | 172 +++++++++ 8 files changed, 1245 insertions(+), 388 deletions(-) create mode 100644 cmd/classicstack/config_model.go create mode 100644 cmd/classicstack/diagnostics_impl.go create mode 100644 cmd/classicstack/mainwiring.go create mode 100644 cmd/classicstack/netutil.go create mode 100644 cmd/classicstack/supervisor.go create mode 100644 cmd/classicstack/supervisor_control.go create mode 100644 cmd/classicstack/supervisor_lifecycle.go diff --git a/cmd/classicstack/config_model.go b/cmd/classicstack/config_model.go new file mode 100644 index 0000000..caacb7b --- /dev/null +++ b/cmd/classicstack/config_model.go @@ -0,0 +1,231 @@ +package main + +import ( + "github.com/ObsoleteMadness/ClassicStack/config" + "github.com/ObsoleteMadness/ClassicStack/port/ethertalk" + "github.com/ObsoleteMadness/ClassicStack/port/localtalk" +) + +// appConfigFromModel converts a config.Model (the UI/serialisation view) +// into the cmd-local appConfig that the wiring functions consume. It is the +// inverse of modelFromAppConfig and lets the supervisor rebuild the stack +// from an edited model. +func appConfigFromModel(m *config.Model) (appConfig, error) { + cfg := defaultAppConfig() + + cfg.LogLevel = m.Logging.Level + cfg.LogTraffic = m.Logging.LogTraffic + cfg.ParsePackets = m.Logging.ParsePackets + cfg.ParseOutput = m.Logging.ParseOutput + + cfg.Bridge = BridgeConfig{ + Mode: m.Bridge.Mode, + Device: m.Bridge.Device, + HWAddress: m.Bridge.HWAddress, + BridgeMode: m.Bridge.BridgeMode, + } + + cfg.LToUDP = localtalk.LToUDPConfig{ + Enabled: m.LToUDP.Enabled, + Interface: m.LToUDP.Interface, + SeedNetwork: m.LToUDP.SeedNetwork, + SeedZone: m.LToUDP.SeedZone, + } + cfg.TashTalk = localtalk.TashTalkConfig{ + Port: m.TashTalk.Port, + SeedNetwork: m.TashTalk.SeedNetwork, + SeedZone: m.TashTalk.SeedZone, + } + cfg.EtherTalk = ethertalk.Config{ + BridgeHostMAC: m.EtherTalk.BridgeHostMAC, + Filter: m.EtherTalk.Filter, + SeedNetworkMin: m.EtherTalk.SeedNetworkMin, + SeedNetworkMax: m.EtherTalk.SeedNetworkMax, + SeedZone: m.EtherTalk.SeedZone, + DesiredNetwork: m.EtherTalk.DesiredNetwork, + DesiredNode: m.EtherTalk.DesiredNode, + } + + cfg.Capture.LocalTalk = m.Capture.LocalTalk + cfg.Capture.EtherTalk = m.Capture.EtherTalk + cfg.Capture.IPX = m.Capture.IPX + cfg.Capture.NetBEUI = m.Capture.NetBEUI + if m.Capture.Snaplen != 0 { + cfg.Capture.Snaplen = m.Capture.Snaplen + } + + cfg.MacIPEnabled = m.MacIP.Enabled + cfg.MacIPNAT = m.MacIP.Mode == "nat" + cfg.MacIPSubnet = orDefault(m.MacIP.NATSubnet, cfg.MacIPSubnet) + cfg.MacIPGWIP = m.MacIP.NATGW + cfg.MacIPNameserver = m.MacIP.Nameserver + cfg.MacIPGatewayIP = m.MacIP.IPGateway + cfg.MacIPDHCPRelay = m.MacIP.DHCPRelay + cfg.MacIPLeaseFile = m.MacIP.LeaseFile + cfg.MacIPZone = m.MacIP.Zone + cfg.MacIPFilter = m.MacIP.Filter + + cfg.IPXEnabled = m.IPX.Enabled + cfg.IPXInterface = m.IPX.Interface + cfg.IPXFraming = orDefault(m.IPX.Framing, cfg.IPXFraming) + cfg.IPXInternalNetwork = m.IPX.InternalNetwork + cfg.IPXFilter = m.IPX.Filter + + cfg.IPXGWEnabled = m.IPXGW.Enabled + cfg.IPXGWBindings = parseIPXGWBindings(m.IPXGW.Bindings) + + cfg.NetBEUIEnabled = m.NetBEUI.Enabled + cfg.NetBEUIInterface = m.NetBEUI.Interface + cfg.NetBEUIFilter = m.NetBEUI.Filter + + cfg.NetBIOSEnabled = m.NetBIOS.Enabled + if len(m.NetBIOS.Transports) > 0 { + cfg.NetBIOSTransports = m.NetBIOS.Transports + } + cfg.NetBIOSScopeID = m.NetBIOS.ScopeID + + cfg.SMBEnabled = m.SMB.Enabled + cfg.SMBNBTBinding = orDefault(m.SMB.NBTBinding, cfg.SMBNBTBinding) + cfg.SMBDirectBinding = m.SMB.DirectBinding + cfg.SMBGuestOk = m.SMB.GuestOk + cfg.SMBServerName = orDefault(m.SMB.ServerName, cfg.SMBServerName) + cfg.SMBWorkgroup = orDefault(m.SMB.Workgroup, cfg.SMBWorkgroup) + + cfg.ShortnameWindowsShortnames = m.Shortname.WindowsShortnames + cfg.ShortnameBackend = orDefault(m.Shortname.Backend, cfg.ShortnameBackend) + cfg.ShortnameDBPath = m.Shortname.DBPath + + cfg.WebUI = WebUIConfigOptions{ + Enabled: m.WebUI.Enabled, + Bind: orDefault(m.WebUI.Bind, cfg.WebUI.Bind), + TLS: m.WebUI.TLS, + CertPEM: m.WebUI.CertPEM, + KeyPEM: m.WebUI.KeyPEM, + } + + normalizeSMBIdentity(&cfg) + syncBridgeToEtherTalk(&cfg) + return cfg, nil +} + +// modelFromAppConfig is the inverse of appConfigFromModel: it projects the +// resolved cmd-local appConfig back into a config.Model so the management +// plane has a serialisable, editable view that matches what is running. +// AFP/SMB volume maps are sourced from the model the caller already holds +// (when loaded from file) since appConfig does not carry them. +func modelFromAppConfig(cfg appConfig) *config.Model { + m := config.Defaults() + + m.Logging.Level = cfg.LogLevel + m.Logging.LogTraffic = cfg.LogTraffic + m.Logging.ParsePackets = cfg.ParsePackets + m.Logging.ParseOutput = cfg.ParseOutput + + m.Bridge.Mode = cfg.Bridge.Mode + m.Bridge.Device = cfg.Bridge.Device + m.Bridge.HWAddress = cfg.Bridge.HWAddress + m.Bridge.BridgeMode = cfg.Bridge.BridgeMode + + m.LToUDP.Enabled = cfg.LToUDP.Enabled + m.LToUDP.Interface = cfg.LToUDP.Interface + m.LToUDP.SeedNetwork = cfg.LToUDP.SeedNetwork + m.LToUDP.SeedZone = cfg.LToUDP.SeedZone + + m.TashTalk.Port = cfg.TashTalk.Port + m.TashTalk.SeedNetwork = cfg.TashTalk.SeedNetwork + m.TashTalk.SeedZone = cfg.TashTalk.SeedZone + + m.EtherTalk.BridgeHostMAC = cfg.EtherTalk.BridgeHostMAC + m.EtherTalk.Filter = cfg.EtherTalk.Filter + m.EtherTalk.SeedNetworkMin = cfg.EtherTalk.SeedNetworkMin + m.EtherTalk.SeedNetworkMax = cfg.EtherTalk.SeedNetworkMax + m.EtherTalk.SeedZone = cfg.EtherTalk.SeedZone + m.EtherTalk.DesiredNetwork = cfg.EtherTalk.DesiredNetwork + m.EtherTalk.DesiredNode = cfg.EtherTalk.DesiredNode + + m.Capture.LocalTalk = cfg.Capture.LocalTalk + m.Capture.EtherTalk = cfg.Capture.EtherTalk + m.Capture.IPX = cfg.Capture.IPX + m.Capture.NetBEUI = cfg.Capture.NetBEUI + m.Capture.Snaplen = cfg.Capture.Snaplen + + m.MacIP.Enabled = cfg.MacIPEnabled + if cfg.MacIPNAT { + m.MacIP.Mode = "nat" + } else { + m.MacIP.Mode = "pcap" + } + m.MacIP.NATSubnet = cfg.MacIPSubnet + m.MacIP.NATGW = cfg.MacIPGWIP + m.MacIP.Nameserver = cfg.MacIPNameserver + m.MacIP.IPGateway = cfg.MacIPGatewayIP + m.MacIP.DHCPRelay = cfg.MacIPDHCPRelay + m.MacIP.LeaseFile = cfg.MacIPLeaseFile + m.MacIP.Zone = cfg.MacIPZone + m.MacIP.Filter = cfg.MacIPFilter + + m.IPX.Enabled = cfg.IPXEnabled + m.IPX.Interface = cfg.IPXInterface + m.IPX.Framing = cfg.IPXFraming + m.IPX.InternalNetwork = cfg.IPXInternalNetwork + m.IPX.Filter = cfg.IPXFilter + + m.IPXGW.Enabled = cfg.IPXGWEnabled + for _, b := range cfg.IPXGWBindings { + m.IPXGW.Bindings = append(m.IPXGW.Bindings, b.Object+":"+b.Zone) + } + + m.NetBEUI.Enabled = cfg.NetBEUIEnabled + m.NetBEUI.Interface = cfg.NetBEUIInterface + m.NetBEUI.Filter = cfg.NetBEUIFilter + + m.NetBIOS.Enabled = cfg.NetBIOSEnabled + m.NetBIOS.Transports = cfg.NetBIOSTransports + m.NetBIOS.ScopeID = cfg.NetBIOSScopeID + + m.SMB.Enabled = cfg.SMBEnabled + m.SMB.NBTBinding = cfg.SMBNBTBinding + m.SMB.DirectBinding = cfg.SMBDirectBinding + m.SMB.GuestOk = cfg.SMBGuestOk + m.SMB.ServerName = cfg.SMBServerName + m.SMB.Workgroup = cfg.SMBWorkgroup + + m.Shortname.WindowsShortnames = cfg.ShortnameWindowsShortnames + m.Shortname.Backend = cfg.ShortnameBackend + m.Shortname.DBPath = cfg.ShortnameDBPath + + m.WebUI.Enabled = cfg.WebUI.Enabled + m.WebUI.Bind = cfg.WebUI.Bind + m.WebUI.TLS = cfg.WebUI.TLS + m.WebUI.CertPEM = cfg.WebUI.CertPEM + m.WebUI.KeyPEM = cfg.WebUI.KeyPEM + + return m +} + +func parseIPXGWBindings(raw []string) []IPXGWZoneBinding { + var out []IPXGWZoneBinding + for _, b := range raw { + parts := splitColon(b) + if len(parts) == 2 { + out = append(out, IPXGWZoneBinding{Object: parts[0], Zone: parts[1]}) + } + } + return out +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} + +func splitColon(s string) []string { + for i := 0; i < len(s); i++ { + if s[i] == ':' { + return []string{s[:i], s[i+1:]} + } + } + return []string{s} +} diff --git a/cmd/classicstack/diagnostics_impl.go b/cmd/classicstack/diagnostics_impl.go new file mode 100644 index 0000000..6d07552 --- /dev/null +++ b/cmd/classicstack/diagnostics_impl.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + + "github.com/ObsoleteMadness/ClassicStack/pkg/control" + "github.com/ObsoleteMadness/ClassicStack/router" +) + +// routerDiagnostics implements control.Diagnostics against the live +// router's routing and zone tables. The read-only probes (ListZones, +// DDPEnumerate) are served directly from those tables; the active probes +// (AEPEcho, ZIPEnumerate) and SMBBrowse are reported as unavailable until +// their protocol-level implementations are wired in. +type routerDiagnostics struct { + sup *Supervisor +} + +// wireDiagnostics installs the diagnostics implementation onto the plane. +func wireDiagnostics(plane *control.Plane, sup *Supervisor) { + plane.SetDiagnostics(&routerDiagnostics{sup: sup}) +} + +func (d *routerDiagnostics) router() *router.Router { return d.sup.Router() } + +// ListZones returns the AppleTalk zones known to the router. +func (d *routerDiagnostics) ListZones(context.Context) ([]control.ZoneInfo, error) { + r := d.router() + if r == nil { + return nil, control.ErrDiagUnavailable + } + zones := r.Zones() + out := make([]control.ZoneInfo, 0, len(zones)) + for _, z := range zones { + out = append(out, control.ZoneInfo{Name: string(z)}) + } + return out, nil +} + +// DDPEnumerate lists the networks the router can reach, from its routing +// table. +func (d *routerDiagnostics) DDPEnumerate(context.Context) ([]control.NetworkInfo, error) { + r := d.router() + if r == nil { + return nil, control.ErrDiagUnavailable + } + entries := r.RoutingEntries() + out := make([]control.NetworkInfo, 0, len(entries)) + for _, e := range entries { + if e.Entry == nil { + continue + } + portName := "" + if e.Entry.Port != nil { + portName = e.Entry.Port.ShortString() + } + out = append(out, control.NetworkInfo{ + NetworkMin: e.Entry.NetworkMin, + NetworkMax: e.Entry.NetworkMax, + Distance: e.Entry.Distance, + Port: portName, + }) + } + return out, nil +} + +// ZIPEnumerate currently mirrors ListZones; a dedicated ZIP GetZoneList +// walk can replace this when wired. +func (d *routerDiagnostics) ZIPEnumerate(ctx context.Context) ([]control.ZoneInfo, error) { + return d.ListZones(ctx) +} + +// AEPEcho is not yet wired to an AEP requester. +func (d *routerDiagnostics) AEPEcho(context.Context, uint16, uint8) (control.EchoResult, error) { + return control.EchoResult{}, control.ErrDiagUnavailable +} + +// SMBBrowse depends on the SMB subsystem exposing a browser walk; until +// that is wired through, the probe reports unavailable rather than guessing. +func (d *routerDiagnostics) SMBBrowse(context.Context) ([]control.ServerInfo, error) { + return nil, control.ErrDiagUnavailable +} diff --git a/cmd/classicstack/main.go b/cmd/classicstack/main.go index 60efb81..a23ee39 100644 --- a/cmd/classicstack/main.go +++ b/cmd/classicstack/main.go @@ -5,7 +5,6 @@ import ( "flag" "fmt" "log" - "net" "os" "os/signal" "runtime" @@ -14,18 +13,8 @@ import ( "github.com/ObsoleteMadness/ClassicStack/config" "github.com/ObsoleteMadness/ClassicStack/netlog" - "github.com/ObsoleteMadness/ClassicStack/pkg/hwaddr" "github.com/ObsoleteMadness/ClassicStack/pkg/logging" - "github.com/ObsoleteMadness/ClassicStack/port" - "github.com/ObsoleteMadness/ClassicStack/port/ethertalk" - "github.com/ObsoleteMadness/ClassicStack/port/localtalk" "github.com/ObsoleteMadness/ClassicStack/port/rawlink" - "github.com/ObsoleteMadness/ClassicStack/router" - "github.com/ObsoleteMadness/ClassicStack/service" - "github.com/ObsoleteMadness/ClassicStack/service/aep" - "github.com/ObsoleteMadness/ClassicStack/service/llap" - "github.com/ObsoleteMadness/ClassicStack/service/rtmp" - "github.com/ObsoleteMadness/ClassicStack/service/zip" ) func main() { @@ -337,337 +326,47 @@ func main() { } } - var ports []port.Port - if cfg.LToUDP.Enabled { - ports = append(ports, localtalk.NewLtoudpPort(cfg.LToUDP.Interface, uint16(cfg.LToUDP.SeedNetwork), []byte(cfg.LToUDP.SeedZone))) - } - if cfg.TashTalk.Port != "" { - ports = append(ports, localtalk.NewTashTalkPort(cfg.TashTalk.Port, uint16(cfg.TashTalk.SeedNetwork), []byte(cfg.TashTalk.SeedZone))) - } - if cfg.EtherTalk.Device != "" { - hwAddr, err := hwaddr.ParseEthernet(cfg.EtherTalk.HWAddress) - if err != nil { - log.Fatalf("invalid -ethertalk-hw-address: %v", err) - } - opts := ethertalk.Options{ - InterfaceName: cfg.EtherTalk.Device, - HWAddr: hwAddr.Bytes(), - SeedNetworkMin: uint16(cfg.EtherTalk.SeedNetworkMin), - SeedNetworkMax: uint16(cfg.EtherTalk.SeedNetworkMax), - DesiredNetwork: uint16(cfg.EtherTalk.DesiredNetwork), - DesiredNode: uint8(cfg.EtherTalk.DesiredNode), - SeedZoneNames: [][]byte{[]byte(cfg.EtherTalk.SeedZone)}, - BridgeMode: cfg.EtherTalk.BridgeMode, - Filter: cfg.EtherTalk.Filter, - } - if cfg.EtherTalk.BridgeHostMAC != "" { - hostMAC, err := hwaddr.ParseEthernet(cfg.EtherTalk.BridgeHostMAC) - if err != nil { - log.Fatalf("invalid -ethertalk-bridge-host-mac: %v", err) - } - opts.BridgeHostMAC = hostMAC.Bytes() - } - var ep port.Port - switch cfg.EtherTalk.Backend { - case "", "pcap": - ep, err = ethertalk.NewPcapPort(opts) - case "tap", "tun": - ep, err = ethertalk.NewTapPort(opts) - default: - log.Fatalf("unsupported EtherTalk backend: %q", cfg.EtherTalk.Backend) - } - if err != nil { - log.Fatalf("failed creating EtherTalk port (%s): %v", cfg.EtherTalk.Backend, err) - } - ports = append(ports, ep) - } - if len(ports) == 0 { - log.Fatal("no ports configured") - } - - if err := cfg.Capture.Validate(); err != nil { - log.Fatalf("capture config: %v", err) - } - captureSinks := attachCaptureSinks(ports, cfg.Capture) - defer func() { - for _, s := range captureSinks { - _ = s.Close() - } - }() - - // Build the service list explicitly so we can share the NBP service reference - // with the MacIP gateway. - nbpSvc := zip.NewNameInformationService() - services := []service.Service{ - llap.New(), - aep.New(), - nbpSvc, - rtmp.NewRoutingTableAgingService(), - rtmp.NewRespondingService(), - rtmp.NewSendingService(), - zip.NewRespondingService(), - zip.NewSendingService(), - } - - macIP, err := wireMacIP(MacIPConfig{ - Enabled: cfg.MacIPEnabled, - BridgeMode: cfg.Bridge.Mode, - BridgeDevice: cfg.Bridge.Device, - BridgeHWAddress: cfg.Bridge.HWAddress, - BridgeFrameMode: cfg.Bridge.BridgeMode, - NATGatewayIP: cfg.MacIPGWIP, - NATSubnet: cfg.MacIPSubnet, - Nameserver: cfg.MacIPNameserver, - Zone: cfg.MacIPZone, - IPGateway: cfg.MacIPGatewayIP, - NAT: cfg.MacIPNAT, - DHCPRelay: cfg.MacIPDHCPRelay, - StateFile: cfg.MacIPLeaseFile, - Filter: cfg.MacIPFilter, - EtherTalkZone: cfg.EtherTalk.SeedZone, - NBP: nbpSvc, - }) - if err != nil { - for _, s := range captureSinks { - _ = s.Close() - } - log.Fatalf("MacIP wiring failed: %v", err) //nolint:gocritic // captureSinks closed manually above - } - if macIP != nil { - services = append(services, macIP.Service()) - } - - ipxGW, err := wireIPXGW(IPXGWConfig{ - Enabled: cfg.IPXGWEnabled, - Bindings: cfg.IPXGWBindings, - NBP: nbpSvc, - }) - if err != nil { - for _, s := range captureSinks { - _ = s.Close() - } - log.Fatalf("IPXGW wiring failed: %v", err) //nolint:gocritic // captureSinks closed manually above - } - if ipxGW != nil { - services = append(services, ipxGW.Service()) - } - - shortHook, err := wireShortname(ShortnameConfig{ - WindowsShortnames: cfg.ShortnameWindowsShortnames, - Backend: cfg.ShortnameBackend, - DBPath: cfg.ShortnameDBPath, - }) - if err != nil { - log.Fatalf("Shortname wiring failed: %v", err) - } - - afpHook, err := wireAFP(AFPWiring{ - Source: configSource, - FromConfig: fromConfigFile, - NBP: nbpSvc, - Shortname: shortHook, - Flags: AFPFlagInputs{ - ServerName: *afpServerName, - Zone: *afpZone, - Protocols: *afpProtocols, - TCPAddr: *afpTCPAddr, - ExtensionMap: *afpExtensionMap, - DecomposedNames: *afpDecomposedFilenames, - CNIDBackend: *afpCNIDBackend, - AppleDoubleMode: *afpAppleDoubleMode, - VolumeFlagValues: []string(afpVolumes), - }, - }) - if err != nil { - log.Fatalf("AFP wiring failed: %v", err) - } - if macIP != nil { - afpHook.AttachMacIP(macIPAFPHooks{macIP}) - } - services = append(services, afpHook.Services()...) - - // IPX and NetBEUI each open their own pcap rawlink in wireIPX / - // wireNetBEUI. They don't share with EtherTalk; the kernel filter - // per handle keeps the cross-protocol traffic isolated. When no - // interface is configured for them explicitly, fall back to - // EtherTalk's — the typical deployment runs every protocol on - // the same physical NIC. - ipxResolvedIface := cfg.IPXInterface - if cfg.IPXEnabled && strings.TrimSpace(ipxResolvedIface) == "" && cfg.EtherTalk.Device != "" { - if cfg.Bridge.Device != "" { - ipxResolvedIface = cfg.Bridge.Device - netlog.Info("[MAIN][IPX] no -ipx-interface set; reusing Bridge interface %s", ipxResolvedIface) - } else { - ipxResolvedIface = cfg.EtherTalk.Device - netlog.Info("[MAIN][IPX] no -ipx-interface set; reusing EtherTalk interface %s", ipxResolvedIface) - } - } - ipxHook, err := wireIPX(IPXConfig{ - Enabled: cfg.IPXEnabled, - BridgeMode: cfg.Bridge.Mode, - BridgeFrameMode: cfg.Bridge.BridgeMode, - Interface: ipxResolvedIface, - BridgeHWAddress: cfg.Bridge.HWAddress, - Framing: cfg.IPXFraming, - InternalNetwork: cfg.IPXInternalNetwork, - Filter: cfg.IPXFilter, - CapturePath: cfg.Capture.IPX, - CaptureSnaplen: cfg.Capture.Snaplen, - }) - if err != nil { - log.Fatalf("IPX wiring failed: %v", err) - } - if ipxGW != nil && ipxHook != nil { - ipxGW.AttachIPXRouter(ipxHook.Router()) - } - netbeuiResolvedIface := cfg.NetBEUIInterface - if cfg.NetBEUIEnabled && strings.TrimSpace(netbeuiResolvedIface) == "" && cfg.EtherTalk.Device != "" { - if cfg.Bridge.Device != "" { - netbeuiResolvedIface = cfg.Bridge.Device - netlog.Info("[MAIN][NetBEUI] no -netbeui-interface set; reusing Bridge interface %s", netbeuiResolvedIface) - } else { - netbeuiResolvedIface = cfg.EtherTalk.Device - netlog.Info("[MAIN][NetBEUI] no -netbeui-interface set; reusing EtherTalk interface %s", netbeuiResolvedIface) - } - } - nbeuiHook, err := wireNetBEUI(NetBEUIConfig{ - Enabled: cfg.NetBEUIEnabled, - BridgeMode: cfg.Bridge.Mode, - BridgeFrameMode: cfg.Bridge.BridgeMode, - Interface: netbeuiResolvedIface, - BridgeHWAddress: cfg.Bridge.HWAddress, - Filter: cfg.NetBEUIFilter, - CapturePath: cfg.Capture.NetBEUI, - CaptureSnaplen: cfg.Capture.Snaplen, - }) - if err != nil { - log.Fatalf("NetBEUI wiring failed: %v", err) - } - nbHook, err := wireNetBIOS(NetBIOSConfig{ - Enabled: cfg.NetBIOSEnabled, - Transports: cfg.NetBIOSTransports, - ScopeID: cfg.NetBIOSScopeID, - ServerName: cfg.NetBIOSServerName, - Workgroup: cfg.NetBIOSWorkgroup, - IPX: ipxHook, - NetBEUI: nbeuiHook, - }) - if err != nil { - log.Fatalf("NetBIOS wiring failed: %v", err) - } - - smbShareConfigs := loadSMBShares(configSource, fromConfigFile, cfg.SMBShareFlags) - smbHook, err := wireSMB(SMBConfig{ - Enabled: cfg.SMBEnabled, - NBTBinding: cfg.SMBNBTBinding, - DirectBinding: cfg.SMBDirectBinding, - GuestOk: cfg.SMBGuestOk, - Workgroup: cfg.SMBWorkgroup, - ServerName: cfg.SMBServerName, - Shares: smbShareConfigs, - NetBIOS: nbHook, - IPX: ipxHook, - Shortname: shortHook, + // From here on, the build and lifecycle of every component lives in the + // Supervisor. main.go's remaining job is to project the resolved config + // into a config.Model, construct the supervisor and the management + // plane, wire the (optional) web UI on top, run, and tear down. + model := buildModel(cfg, configSource, fromConfigFile, afpFlagOptions{ + ServerName: *afpServerName, + Zone: *afpZone, + Protocols: *afpProtocols, + Binding: *afpTCPAddr, + ExtensionMap: *afpExtensionMap, + DecomposedNames: *afpDecomposedFilenames, + CNIDBackend: *afpCNIDBackend, + AppleDoubleMode: *afpAppleDoubleMode, + Volumes: []string(afpVolumes), }) + sup, err := NewSupervisor(cfg, configSource, model) if err != nil { - log.Fatalf("SMB wiring failed: %v", err) + log.Fatalf("failed to build stack: %v", err) } - // SMB rides on NetBIOS and is not a DDP service either, so it - // lives outside the AppleTalk service set. Its lifecycle is - // driven directly below alongside IPX/NetBEUI/NetBIOS. The - // shortname mapper is consumed via wireSMB; no lifecycle of - // its own. - _ = shortHook + plane := newControlPlane(sup, model, selectedConfig) + wireDiagnostics(plane, sup) - r := router.New("router", ports, services) - - if cfg.ParsePackets { - dumper, cleanup, err := newPacketDumper(cfg.ParseOutput) - if err != nil { - log.Fatalf("parse-packets: %v", err) - } - defer cleanup() - for _, svc := range services { - if aware, ok := svc.(service.PacketDumpAware); ok { - aware.SetPacketDumper(dumper) - } - } - netlog.Info("[MAIN] parse-packets enabled; output=%q", cfg.ParseOutput) + if err := installWebUI(sup, cfg.WebUI, plane); err != nil { + log.Fatalf("failed to wire web UI: %v", err) } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - if err := r.Start(ctx); err != nil { - log.Fatalf("failed to start router: %v", err) - } - netlog.Info("[MAIN] router away!") - - // IPX, NetBEUI, and NetBIOS each own their own router/port and are - // not members of the AppleTalk service set, so their lifecycles are - // driven independently from main.go in start order: transports - // (IPX, NetBEUI) first, then the layers that consume them. - if ipxHook != nil { - if err := ipxHook.Start(ctx); err != nil { - netlog.Warn("[MAIN][IPX] start failed: %v", err) - } - } - if nbeuiHook != nil { - if err := nbeuiHook.Start(ctx); err != nil { - netlog.Warn("[MAIN][NetBEUI] start failed: %v", err) - } - } - if nbHook != nil { - if err := nbHook.Start(ctx); err != nil { - netlog.Warn("[MAIN][NetBIOS] start failed: %v", err) - } - } - if smbHook != nil { - if err := smbHook.Start(ctx); err != nil { - netlog.Warn("[MAIN][SMB] start failed: %v", err) - } + if err := sup.Start(ctx); err != nil { + log.Fatalf("failed to start stack: %v", err) } <-ctx.Done() - // Stop in reverse start order so consumers tear down before the - // transports they sit on. - if smbHook != nil { - if err := smbHook.Stop(); err != nil { - netlog.Warn("[MAIN][SMB] stop warning: %v", err) - } - } - if nbHook != nil { - if err := nbHook.Stop(); err != nil { - netlog.Warn("[MAIN][NetBIOS] stop warning: %v", err) - } - } - if nbeuiHook != nil { - if err := nbeuiHook.Stop(); err != nil { - netlog.Warn("[MAIN][NetBEUI] stop warning: %v", err) - } - } - if ipxHook != nil { - if err := ipxHook.Stop(); err != nil { - netlog.Warn("[MAIN][IPX] stop warning: %v", err) - } - } - if err := r.Stop(); err != nil { + if err := sup.Stop(); err != nil { netlog.Warn("[MAIN] stop warning: %v", err) } } -// broadcastAddr computes the broadcast address of an IP network. -func broadcastAddr(n *net.IPNet) net.IP { - ip := n.IP.To4() - bcast := make(net.IP, 4) - for i := range bcast { - bcast[i] = ip[i] | ^n.Mask[i] - } - return bcast -} - // volumeFlags is a repeatable -afp-volume flag. The raw "Name:Path" // strings are forwarded to wireAFP, where the //go:build afp side // parses them via afp.ParseVolumeFlag. Keeping this neutral lets @@ -680,67 +379,3 @@ func (v *volumeFlags) Set(s string) error { *v = append(*v, s) return nil } - -func detectPcapInterfaceIPv4(interfaceName string) (string, bool) { - if strings.TrimSpace(interfaceName) == "" { - return "", false - } - - devs, err := rawlink.ListPcapDevices() - if err != nil { - return "", false - } - - for _, d := range devs { - if d.Name != interfaceName { - continue - } - return selectPreferredIPv4(d.Addresses) - } - - return "", false -} - -func selectPreferredIPv4(addrs []string) (string, bool) { - var linkLocal string - for _, addr := range addrs { - ip := net.ParseIP(strings.TrimSpace(addr)).To4() - if ip == nil || ip.IsUnspecified() || ip.IsLoopback() { - continue - } - if ip[0] == 169 && ip[1] == 254 { - if linkLocal == "" { - linkLocal = ip.String() - } - continue - } - return ip.String(), true - } - - if linkLocal != "" { - return linkLocal, true - } - - return "", false -} - -func firstUsableIPv4(n *net.IPNet) net.IP { - if n == nil { - return nil - } - base := n.IP.To4() - if base == nil || len(n.Mask) != net.IPv4len { - return nil - } - candidate := append(net.IP(nil), base...) - for i := len(candidate) - 1; i >= 0; i-- { - candidate[i]++ - if candidate[i] != 0 { - break - } - } - if !n.Contains(candidate) || candidate.Equal(broadcastAddr(n)) { - return nil - } - return candidate.To4() -} diff --git a/cmd/classicstack/mainwiring.go b/cmd/classicstack/mainwiring.go new file mode 100644 index 0000000..dd88ff3 --- /dev/null +++ b/cmd/classicstack/mainwiring.go @@ -0,0 +1,101 @@ +package main + +import ( + "github.com/ObsoleteMadness/ClassicStack/config" + "github.com/ObsoleteMadness/ClassicStack/pkg/control" + "github.com/ObsoleteMadness/ClassicStack/pkg/metrics" + "github.com/ObsoleteMadness/ClassicStack/pkg/status" +) + +// afpFlagOptions carries the AFP values from the CLI flags so buildModel can +// fold them into the config model on the flag-driven path (where the model +// is not loaded from a TOML source). +type afpFlagOptions struct { + ServerName string + Zone string + Protocols string + Binding string + ExtensionMap string + DecomposedNames bool + CNIDBackend string + AppleDoubleMode string + Volumes []string // raw "Name:Path" entries +} + +// buildModel produces the serialisable config.Model that the management +// plane edits. When the configuration came from a TOML file, the model is +// loaded directly from the source so it captures everything (including AFP +// and SMB volume maps). On the flag-driven path the model is projected from +// the resolved appConfig and the AFP flag values are folded in. +func buildModel(cfg appConfig, src config.Source, fromConfigFile bool, afp afpFlagOptions) *config.Model { + if fromConfigFile && src.K != nil { + return config.FromSource(src) + } + m := modelFromAppConfig(cfg) + applyAFPFlags(m, afp) + return m +} + +// applyAFPFlags folds CLI AFP flag values into the model's [AFP] section. +func applyAFPFlags(m *config.Model, afp afpFlagOptions) { + m.AFP.Name = orDefault(afp.ServerName, m.AFP.Name) + m.AFP.Zone = orDefault(afp.Zone, m.AFP.Zone) + m.AFP.Protocols = orDefault(afp.Protocols, m.AFP.Protocols) + m.AFP.Binding = orDefault(afp.Binding, m.AFP.Binding) + m.AFP.ExtensionMap = orDefault(afp.ExtensionMap, m.AFP.ExtensionMap) + m.AFP.CNIDBackend = orDefault(afp.CNIDBackend, m.AFP.CNIDBackend) + m.AFP.UseDecomposedNames = afp.DecomposedNames + m.AFP.AppleDoubleMode = orDefault(afp.AppleDoubleMode, m.AFP.AppleDoubleMode) + if len(afp.Volumes) > 0 { + if m.AFP.Volumes == nil { + m.AFP.Volumes = map[string]config.VolumeModel{} + } + for _, raw := range afp.Volumes { + parts := splitColon(raw) + if len(parts) == 2 { + m.AFP.Volumes[parts[0]] = config.VolumeModel{Name: parts[0], Path: parts[1], FSType: "local_fs"} + } + } + } +} + +// newControlPlane constructs the management plane over the supervisor and +// installs config.Save as the plane's saver. configPath may be empty (flag +// runs), in which case Save is disabled and the UI offers Download only. +func newControlPlane(sup *Supervisor, model *config.Model, configPath string) *control.Plane { + // Tee the metrics hub into the expvar sink so streamed counters remain + // visible at /debug/vars in addition to the SSE stream. + metrics.Default.AddSink(metrics.NewExpvarSink()) + + control.SetSaver(func(path string, cfg control.ConfigModel) (string, error) { + m, ok := cfg.(*config.Model) + if !ok { + return "", control.ErrNoConfigPath + } + return config.Save(path, m) + }) + + return control.New(control.Deps{ + Supervisor: sup, + Registry: status.Default, + Hub: metrics.Default, + Config: model, + ConfigPath: configPath, + }) +} + +// installWebUI constructs the web UI hook (a no-op stub in builds without +// -tags webui) and registers it with the supervisor so it shares the +// stack's lifecycle. The hook is added even when disabled so a future +// enable-via-UI can start it; the hook itself no-ops when off. +func installWebUI(sup *Supervisor, opts WebUIConfigOptions, plane *control.Plane) error { + if err := opts.Validate(); err != nil { + return err + } + h, err := wireWebUI(WebUIWiring{Options: opts, Plane: plane}) + if err != nil { + return err + } + sup.AddExternalHook("WebUI", h, opts.Enabled) + return nil +} diff --git a/cmd/classicstack/netutil.go b/cmd/classicstack/netutil.go new file mode 100644 index 0000000..e549754 --- /dev/null +++ b/cmd/classicstack/netutil.go @@ -0,0 +1,87 @@ +package main + +import ( + "net" + "strings" + + "github.com/ObsoleteMadness/ClassicStack/port/rawlink" +) + +// broadcastAddr computes the broadcast address of an IP network. +func broadcastAddr(n *net.IPNet) net.IP { + ip := n.IP.To4() + bcast := make(net.IP, 4) + for i := range bcast { + bcast[i] = ip[i] | ^n.Mask[i] + } + return bcast +} + +// detectPcapInterfaceIPv4 returns the preferred IPv4 address bound to the +// named pcap interface, if any. +func detectPcapInterfaceIPv4(interfaceName string) (string, bool) { + if strings.TrimSpace(interfaceName) == "" { + return "", false + } + devs, err := rawlink.ListPcapDevices() + if err != nil { + return "", false + } + for _, d := range devs { + if d.Name != interfaceName { + continue + } + return selectPreferredIPv4(d.Addresses) + } + return "", false +} + +// firstUsableIPv4 returns the first host address in n (network address + 1), +// or nil when n has no usable host address. +func firstUsableIPv4(n *net.IPNet) net.IP { + if n == nil { + return nil + } + base := n.IP.To4() + if base == nil || len(n.Mask) != net.IPv4len { + return nil + } + candidate := append(net.IP(nil), base...) + for i := len(candidate) - 1; i >= 0; i-- { + candidate[i]++ + if candidate[i] != 0 { + break + } + } + if !n.Contains(candidate) || candidate.Equal(broadcastAddr(n)) { + return nil + } + return candidate.To4() +} + +// selectPreferredIPv4 picks the most useful IPv4 address from a list, +// preferring a routable address over an APIPA link-local one and skipping +// unspecified/loopback addresses. Used when resolving an interface's +// address for MacIP and diagnostics. +func selectPreferredIPv4(addrs []string) (string, bool) { + var linkLocal string + for _, addr := range addrs { + ip := net.ParseIP(strings.TrimSpace(addr)).To4() + if ip == nil || ip.IsUnspecified() || ip.IsLoopback() { + continue + } + if ip[0] == 169 && ip[1] == 254 { + if linkLocal == "" { + linkLocal = ip.String() + } + continue + } + return ip.String(), true + } + + if linkLocal != "" { + return linkLocal, true + } + + return "", false +} diff --git a/cmd/classicstack/supervisor.go b/cmd/classicstack/supervisor.go new file mode 100644 index 0000000..15ecf85 --- /dev/null +++ b/cmd/classicstack/supervisor.go @@ -0,0 +1,469 @@ +package main + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/ObsoleteMadness/ClassicStack/config" + "github.com/ObsoleteMadness/ClassicStack/pkg/hwaddr" + "github.com/ObsoleteMadness/ClassicStack/pkg/status" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/port/ethertalk" + "github.com/ObsoleteMadness/ClassicStack/port/localtalk" + "github.com/ObsoleteMadness/ClassicStack/router" + "github.com/ObsoleteMadness/ClassicStack/service" + "github.com/ObsoleteMadness/ClassicStack/service/aep" + "github.com/ObsoleteMadness/ClassicStack/service/llap" + "github.com/ObsoleteMadness/ClassicStack/service/rtmp" + "github.com/ObsoleteMadness/ClassicStack/service/zip" +) + +// hook is the common lifecycle of the standalone (non-DDP) subsystems — +// IPX, NetBEUI, NetBIOS, SMB, and the Web UI. They each own their own +// listener/router and are driven directly by the supervisor rather than +// through the AppleTalk router's service set. +type hook interface { + Start(ctx context.Context) error + Stop() error +} + +// Supervisor owns the whole running stack: the ports, the AppleTalk router +// (and its DDP service set), and the standalone hooks. main.go is reduced +// to building configuration and handing it here; everything that +// constructs, starts, or stops a component lives in this file so the same +// logic is reachable from process startup and from the management UI. +type Supervisor struct { + cfg appConfig + source config.Source + model *config.Model + reg *status.Registry + + mu sync.Mutex + ctx context.Context + router *router.Router + ports []port.Port + portNames []string // status-unit name per entry in ports + hooks map[string]hook // name -> standalone hook (ipx, netbeui, …) + order []string // hook start order; stop walks it in reverse + started bool + + // captureSinks are closed on Stop. + captureSinks []closer + // parseCleanup closes the parse-packets output file, if any. + parseCleanup func() + + // nbp is shared between several services; kept so restarts can re-wire. + nbp *zip.NameInformationService + + // Cross-wired components kept so hooks/services can reference them. + shortHook ShortnameHook + macIP MacIPHook + ipxGW IPXGWHook +} + +type closer interface{ Close() error } + +// NewSupervisor builds the full stack from cfg (and the raw config source +// for subsystems that read their own sections lazily, like AFP/SMB). It +// constructs but does not start anything; call Start to bring it up. +func NewSupervisor(cfg appConfig, source config.Source, model *config.Model) (*Supervisor, error) { + s := &Supervisor{ + cfg: cfg, + source: source, + model: model, + reg: status.Default, + hooks: make(map[string]hook), + } + if err := s.build(); err != nil { + return nil, err + } + return s, nil +} + +// Router exposes the AppleTalk router for diagnostics wiring. +func (s *Supervisor) Router() *router.Router { return s.router } + +// build constructs ports, the router with its DDP service set, and the +// standalone hooks. It mirrors the wiring that previously lived inline in +// main.go. +func (s *Supervisor) build() error { + ports, sinks, err := s.buildPorts() + if err != nil { + return err + } + s.ports = ports + s.captureSinks = sinks + + services, err := s.buildServices() + if err != nil { + s.closeSinks() + return err + } + + s.router = router.New("router", ports, services) + + if s.cfg.ParsePackets { + dumper, cleanup, err := newPacketDumper(s.cfg.ParseOutput) + if err != nil { + s.closeSinks() + return fmt.Errorf("parse-packets: %w", err) + } + s.parseCleanup = cleanup + for _, svc := range services { + if aware, ok := svc.(service.PacketDumpAware); ok { + aware.SetPacketDumper(dumper) + } + } + } + + if err := s.buildHooks(); err != nil { + s.closeSinks() + return err + } + return nil +} + +// buildPorts constructs the configured ports and attaches capture sinks. +func (s *Supervisor) buildPorts() ([]port.Port, []closer, error) { + cfg := s.cfg + var ports []port.Port + if cfg.LToUDP.Enabled { + p := localtalk.NewLtoudpPort(cfg.LToUDP.Interface, uint16(cfg.LToUDP.SeedNetwork), []byte(cfg.LToUDP.SeedZone)) + ports = append(ports, p) + s.registerPortStatus("LToUDP", p, true, map[string]string{"seed_zone": cfg.LToUDP.SeedZone}) + } + if cfg.TashTalk.Port != "" { + p := localtalk.NewTashTalkPort(cfg.TashTalk.Port, uint16(cfg.TashTalk.SeedNetwork), []byte(cfg.TashTalk.SeedZone)) + ports = append(ports, p) + s.registerPortStatus("TashTalk", p, true, map[string]string{"seed_zone": cfg.TashTalk.SeedZone}) + } + if cfg.EtherTalk.Device != "" { + ep, err := s.buildEtherTalkPort() + if err != nil { + return nil, nil, err + } + ports = append(ports, ep) + s.registerPortStatus("EtherTalk", ep, true, map[string]string{"device": cfg.EtherTalk.Device, "seed_zone": cfg.EtherTalk.SeedZone}) + } + if len(ports) == 0 { + return nil, nil, fmt.Errorf("no ports configured") + } + + if err := cfg.Capture.Validate(); err != nil { + return nil, nil, fmt.Errorf("capture config: %w", err) + } + sinks := make([]closer, 0) + for _, snk := range attachCaptureSinks(ports, cfg.Capture) { + sinks = append(sinks, snk) + } + return ports, sinks, nil +} + +func (s *Supervisor) buildEtherTalkPort() (port.Port, error) { + cfg := s.cfg + hwAddr, err := hwaddr.ParseEthernet(cfg.EtherTalk.HWAddress) + if err != nil { + return nil, fmt.Errorf("invalid ethertalk hw-address: %w", err) + } + opts := ethertalk.Options{ + InterfaceName: cfg.EtherTalk.Device, + HWAddr: hwAddr.Bytes(), + SeedNetworkMin: uint16(cfg.EtherTalk.SeedNetworkMin), + SeedNetworkMax: uint16(cfg.EtherTalk.SeedNetworkMax), + DesiredNetwork: uint16(cfg.EtherTalk.DesiredNetwork), + DesiredNode: uint8(cfg.EtherTalk.DesiredNode), + SeedZoneNames: [][]byte{[]byte(cfg.EtherTalk.SeedZone)}, + BridgeMode: cfg.EtherTalk.BridgeMode, + Filter: cfg.EtherTalk.Filter, + } + if cfg.EtherTalk.BridgeHostMAC != "" { + hostMAC, err := hwaddr.ParseEthernet(cfg.EtherTalk.BridgeHostMAC) + if err != nil { + return nil, fmt.Errorf("invalid ethertalk bridge-host-mac: %w", err) + } + opts.BridgeHostMAC = hostMAC.Bytes() + } + switch cfg.EtherTalk.Backend { + case "", "pcap": + return ethertalk.NewPcapPort(opts) + case "tap", "tun": + return ethertalk.NewTapPort(opts) + default: + return nil, fmt.Errorf("unsupported EtherTalk backend: %q", cfg.EtherTalk.Backend) + } +} + +// buildServices constructs the AppleTalk DDP service set plus the optional +// DDP services (MacIP, IPXGW, AFP) that ride the router. +func (s *Supervisor) buildServices() ([]service.Service, error) { + cfg := s.cfg + s.nbp = zip.NewNameInformationService() + services := []service.Service{ + llap.New(), + aep.New(), + s.nbp, + rtmp.NewRoutingTableAgingService(), + rtmp.NewRespondingService(), + rtmp.NewSendingService(), + zip.NewRespondingService(), + zip.NewSendingService(), + } + s.registerServiceStatus("Router", true, map[string]string{"zone": cfg.EtherTalk.SeedZone}) + + macIP, err := wireMacIP(MacIPConfig{ + Enabled: cfg.MacIPEnabled, + BridgeMode: cfg.Bridge.Mode, + BridgeDevice: cfg.Bridge.Device, + BridgeHWAddress: cfg.Bridge.HWAddress, + BridgeFrameMode: cfg.Bridge.BridgeMode, + NATGatewayIP: cfg.MacIPGWIP, + NATSubnet: cfg.MacIPSubnet, + Nameserver: cfg.MacIPNameserver, + Zone: cfg.MacIPZone, + IPGateway: cfg.MacIPGatewayIP, + NAT: cfg.MacIPNAT, + DHCPRelay: cfg.MacIPDHCPRelay, + StateFile: cfg.MacIPLeaseFile, + Filter: cfg.MacIPFilter, + EtherTalkZone: cfg.EtherTalk.SeedZone, + NBP: s.nbp, + }) + if err != nil { + return nil, fmt.Errorf("MacIP wiring failed: %w", err) + } + if macIP != nil { + services = append(services, macIP.Service()) + s.registerServiceStatus("MacIP", cfg.MacIPEnabled, nil) + } + + ipxGW, err := wireIPXGW(IPXGWConfig{ + Enabled: cfg.IPXGWEnabled, + Bindings: cfg.IPXGWBindings, + NBP: s.nbp, + }) + if err != nil { + return nil, fmt.Errorf("IPXGW wiring failed: %w", err) + } + if ipxGW != nil { + services = append(services, ipxGW.Service()) + s.registerServiceStatus("IPXGW", cfg.IPXGWEnabled, nil) + } + + shortHook, err := wireShortname(ShortnameConfig{ + WindowsShortnames: cfg.ShortnameWindowsShortnames, + Backend: cfg.ShortnameBackend, + DBPath: cfg.ShortnameDBPath, + }) + if err != nil { + return nil, fmt.Errorf("Shortname wiring failed: %w", err) + } + s.shortHook = shortHook + + afpHook, err := wireAFP(AFPWiring{ + Source: s.source, + FromConfig: s.source.K != nil, + NBP: s.nbp, + Shortname: shortHook, + Flags: s.afpFlagInputs(), + }) + if err != nil { + return nil, fmt.Errorf("AFP wiring failed: %w", err) + } + if macIP != nil { + afpHook.AttachMacIP(macIPAFPHooks{macIP}) + } + services = append(services, afpHook.Services()...) + s.registerServiceStatus("AFP", s.model.AFP.Enabled, map[string]string{"name": s.model.AFP.Name, "zone": s.model.AFP.Zone}) + + s.macIP = macIP + s.ipxGW = ipxGW + return services, nil +} + +// afpFlagInputs derives AFP flag inputs from the config model so AFP wiring +// works whether the config came from a file or flags. +func (s *Supervisor) afpFlagInputs() AFPFlagInputs { + m := s.model + return AFPFlagInputs{ + ServerName: m.AFP.Name, + Zone: m.AFP.Zone, + Protocols: m.AFP.Protocols, + TCPAddr: m.AFP.Binding, + ExtensionMap: m.AFP.ExtensionMap, + DecomposedNames: m.AFP.UseDecomposedNames, + CNIDBackend: m.AFP.CNIDBackend, + AppleDoubleMode: m.AFP.AppleDoubleMode, + } +} + +// buildHooks constructs the standalone hooks (IPX, NetBEUI, NetBIOS, SMB, +// WebUI) and records them as named units in start order. +func (s *Supervisor) buildHooks() error { + cfg := s.cfg + + ipxResolvedIface := s.resolveIPXInterface() + ipxHook, err := wireIPX(IPXConfig{ + Enabled: cfg.IPXEnabled, + BridgeMode: cfg.Bridge.Mode, + BridgeFrameMode: cfg.Bridge.BridgeMode, + Interface: ipxResolvedIface, + BridgeHWAddress: cfg.Bridge.HWAddress, + Framing: cfg.IPXFraming, + InternalNetwork: cfg.IPXInternalNetwork, + Filter: cfg.IPXFilter, + CapturePath: cfg.Capture.IPX, + CaptureSnaplen: cfg.Capture.Snaplen, + }) + if err != nil { + return fmt.Errorf("IPX wiring failed: %w", err) + } + if s.ipxGW != nil && ipxHook != nil { + s.ipxGW.AttachIPXRouter(ipxHook.Router()) + } + + nbeuiResolvedIface := s.resolveNetBEUIInterface() + nbeuiHook, err := wireNetBEUI(NetBEUIConfig{ + Enabled: cfg.NetBEUIEnabled, + BridgeMode: cfg.Bridge.Mode, + BridgeFrameMode: cfg.Bridge.BridgeMode, + Interface: nbeuiResolvedIface, + BridgeHWAddress: cfg.Bridge.HWAddress, + Filter: cfg.NetBEUIFilter, + CapturePath: cfg.Capture.NetBEUI, + CaptureSnaplen: cfg.Capture.Snaplen, + }) + if err != nil { + return fmt.Errorf("NetBEUI wiring failed: %w", err) + } + + nbHook, err := wireNetBIOS(NetBIOSConfig{ + Enabled: cfg.NetBIOSEnabled, + Transports: cfg.NetBIOSTransports, + ScopeID: cfg.NetBIOSScopeID, + ServerName: cfg.NetBIOSServerName, + Workgroup: cfg.NetBIOSWorkgroup, + IPX: ipxHook, + NetBEUI: nbeuiHook, + }) + if err != nil { + return fmt.Errorf("NetBIOS wiring failed: %w", err) + } + + smbShareConfigs := loadSMBShares(s.source, s.source.K != nil, cfg.SMBShareFlags) + smbHook, err := wireSMB(SMBConfig{ + Enabled: cfg.SMBEnabled, + NBTBinding: cfg.SMBNBTBinding, + DirectBinding: cfg.SMBDirectBinding, + GuestOk: cfg.SMBGuestOk, + Workgroup: cfg.SMBWorkgroup, + ServerName: cfg.SMBServerName, + Shares: smbShareConfigs, + NetBIOS: nbHook, + IPX: ipxHook, + Shortname: s.shortHook, + }) + if err != nil { + return fmt.Errorf("SMB wiring failed: %w", err) + } + + // Register hooks in dependency/start order. NetBIOS depends on the + // transports (IPX/NetBEUI); SMB depends on NetBIOS. + s.addHook("IPX", ipxHook, cfg.IPXEnabled, nil) + s.addHook("NetBEUI", nbeuiHook, cfg.NetBEUIEnabled, nil) + s.addHook("NetBIOS", nbHook, cfg.NetBIOSEnabled, []string{"IPX", "NetBEUI"}) + s.addHook("SMB", smbHook, cfg.SMBEnabled, []string{"NetBIOS"}) + return nil +} + +func (s *Supervisor) resolveIPXInterface() string { + cfg := s.cfg + iface := cfg.IPXInterface + if cfg.IPXEnabled && strings.TrimSpace(iface) == "" && cfg.EtherTalk.Device != "" { + if cfg.Bridge.Device != "" { + iface = cfg.Bridge.Device + } else { + iface = cfg.EtherTalk.Device + } + } + return iface +} + +func (s *Supervisor) resolveNetBEUIInterface() string { + cfg := s.cfg + iface := cfg.NetBEUIInterface + if cfg.NetBEUIEnabled && strings.TrimSpace(iface) == "" && cfg.EtherTalk.Device != "" { + if cfg.Bridge.Device != "" { + iface = cfg.Bridge.Device + } else { + iface = cfg.EtherTalk.Device + } + } + return iface +} + +// addHook records a standalone hook as a named, restartable unit. +func (s *Supervisor) addHook(name string, h hook, enabled bool, dependsOn []string) { + if h == nil { + return + } + s.hooks[name] = h + s.order = append(s.order, name) + s.reg.Set(status.Unit{ + Name: name, + Kind: status.KindHook, + Enabled: enabled, + DependsOn: dependsOn, + }) +} + +func (s *Supervisor) registerPortStatus(name string, p port.Port, enabled bool, props map[string]string) { + if props == nil { + props = map[string]string{} + } + props["range"] = fmt.Sprintf("%d-%d", p.NetworkMin(), p.NetworkMax()) + s.reg.Set(status.Unit{ + Name: name, + Kind: status.KindPort, + Enabled: enabled, + Binding: p.ShortString(), + Properties: props, + }) + s.portNames = append(s.portNames, name) +} + +func (s *Supervisor) registerServiceStatus(name string, enabled bool, props map[string]string) { + s.reg.Set(status.Unit{ + Name: name, + Kind: status.KindService, + Enabled: enabled, + Properties: props, + }) +} + +// AddExternalHook registers an additional named hook (e.g. the Web UI) +// built outside the standard wiring, so the supervisor starts and stops it +// with the rest of the stack. enabled records its configured state for the +// status dashboard. +func (s *Supervisor) AddExternalHook(name string, h hook, enabled bool) { + if h == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.hooks[name] = h + s.order = append(s.order, name) + s.reg.Set(status.Unit{Name: name, Kind: status.KindHook, Enabled: enabled}) +} + +func (s *Supervisor) closeSinks() { + for _, c := range s.captureSinks { + _ = c.Close() + } + s.captureSinks = nil + if s.parseCleanup != nil { + s.parseCleanup() + s.parseCleanup = nil + } +} diff --git a/cmd/classicstack/supervisor_control.go b/cmd/classicstack/supervisor_control.go new file mode 100644 index 0000000..5bb8b79 --- /dev/null +++ b/cmd/classicstack/supervisor_control.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "fmt" + + "github.com/ObsoleteMadness/ClassicStack/config" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/control" + "github.com/ObsoleteMadness/ClassicStack/port/rawlink" +) + +// The methods here adapt the Supervisor to the control.Supervisor +// interface the management plane drives. RestartService is already +// implemented in supervisor_lifecycle.go. + +// Apply re-wires the running stack to match the supplied config model. For +// now this is an atomic whole-stack rebuild: the entire stack is stopped, +// reconstructed from the new model, and started again. Finer-grained +// per-service application can layer on later using the dynamic-router +// primitives; the control-plane contract (and the UI) is unchanged by that +// evolution. +func (s *Supervisor) Apply(ctx context.Context, cfg control.ConfigModel) error { + model, ok := cfg.(*config.Model) + if !ok { + return fmt.Errorf("supervisor: unexpected config type %T", cfg) + } + + newCfg, err := appConfigFromModel(model) + if err != nil { + return fmt.Errorf("supervisor: invalid config: %w", err) + } + + netlog.Info("[SUP] applying new configuration (atomic rebuild)") + if err := s.Stop(); err != nil { + netlog.Warn("[SUP] stop during apply: %v", err) + } + + // Rebuild a fresh supervisor state from the new model, then graft its + // freshly constructed components onto this instance so the control + // plane keeps pointing at the same Supervisor. + rebuilt, err := NewSupervisor(newCfg, s.source, model) + if err != nil { + return fmt.Errorf("supervisor: rebuild failed: %w", err) + } + s.adoptFrom(rebuilt) + + if err := s.Start(ctx); err != nil { + return fmt.Errorf("supervisor: restart failed: %w", err) + } + netlog.Info("[SUP] configuration applied") + return nil +} + +// adoptFrom replaces this supervisor's built components with those from a +// freshly constructed one (used by Apply after Stop). The caller must hold +// no locks; Apply runs Stop/Start which lock internally. +func (s *Supervisor) adoptFrom(other *Supervisor) { + s.mu.Lock() + defer s.mu.Unlock() + s.cfg = other.cfg + s.model = other.model + s.router = other.router + s.ports = other.ports + s.portNames = other.portNames + s.hooks = other.hooks + s.order = other.order + s.captureSinks = other.captureSinks + s.nbp = other.nbp + s.shortHook = other.shortHook + s.macIP = other.macIP + s.ipxGW = other.ipxGW + s.started = false +} + +// ListInterfaces returns the host's network interface names for the UI +// dropdowns. +func (s *Supervisor) ListInterfaces() ([]string, error) { + return rawlink.InterfaceNames() +} diff --git a/cmd/classicstack/supervisor_lifecycle.go b/cmd/classicstack/supervisor_lifecycle.go new file mode 100644 index 0000000..b246b01 --- /dev/null +++ b/cmd/classicstack/supervisor_lifecycle.go @@ -0,0 +1,172 @@ +package main + +import ( + "context" + "fmt" + + "github.com/ObsoleteMadness/ClassicStack/netlog" +) + +// Start brings the whole stack up: the AppleTalk router (ports + DDP +// services) first, then the standalone hooks in registration order +// (transports before the layers that consume them). +func (s *Supervisor) Start(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.started { + return fmt.Errorf("supervisor already started") + } + + if err := s.router.Start(ctx); err != nil { + return fmt.Errorf("router start: %w", err) + } + netlog.Info("[SUP] router away!") + s.reg.SetRunning("Router", true) + s.markServiceRunning(true) + for _, name := range s.portNames { + s.reg.SetRunning(name, true) + } + + s.ctx = ctx + for _, name := range s.order { + if err := s.startHookLocked(ctx, name); err != nil { + netlog.Warn("[SUP][%s] start failed: %v", name, err) + } + } + s.started = true + return nil +} + +// Stop tears the stack down in reverse order: hooks first (reverse of +// start), then the router. +func (s *Supervisor) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + if !s.started { + return nil + } + for i := len(s.order) - 1; i >= 0; i-- { + name := s.order[i] + s.stopHookLocked(name) + } + if err := s.router.Stop(); err != nil { + netlog.Warn("[SUP] router stop warning: %v", err) + } + s.reg.SetRunning("Router", false) + s.markServiceRunning(false) + for _, name := range s.portNames { + s.reg.SetRunning(name, false) + } + s.closeSinks() + s.started = false + return nil +} + +// StartService starts a single named hook (and, transitively, nothing — its +// dependencies are expected to already be running). It is the UI's "start" +// action. +func (s *Supervisor) StartService(ctx context.Context, name string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.hooks[name]; !ok { + return fmt.Errorf("unknown service %q", name) + } + return s.startHookLocked(ctx, name) +} + +// StopService stops a single named hook and any hooks that depend on it +// (e.g. stopping NetBIOS first stops SMB). It is the UI's "stop" action. +func (s *Supervisor) StopService(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.hooks[name]; !ok { + return fmt.Errorf("unknown service %q", name) + } + // Stop dependents first. + for _, dep := range s.dependentsOf(name) { + s.stopHookLocked(dep) + } + s.stopHookLocked(name) + return nil +} + +// RestartService stops then starts a named hook, restarting its dependents +// around it so they re-attach to the freshly started instance. +func (s *Supervisor) RestartService(ctx context.Context, name string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.hooks[name]; !ok { + return fmt.Errorf("unknown service %q", name) + } + deps := s.dependentsOf(name) + // Stop dependents (reverse) then the target. + for i := len(deps) - 1; i >= 0; i-- { + s.stopHookLocked(deps[i]) + } + s.stopHookLocked(name) + // Start the target then its dependents. + if err := s.startHookLocked(ctx, name); err != nil { + return err + } + for _, dep := range deps { + if err := s.startHookLocked(ctx, dep); err != nil { + netlog.Warn("[SUP][%s] dependent start failed: %v", dep, err) + } + } + return nil +} + +func (s *Supervisor) startHookLocked(ctx context.Context, name string) error { + h := s.hooks[name] + if h == nil { + return nil + } + if err := h.Start(ctx); err != nil { + return err + } + s.reg.SetRunning(name, true) + netlog.Info("[SUP][%s] started", name) + return nil +} + +func (s *Supervisor) stopHookLocked(name string) { + h := s.hooks[name] + if h == nil { + return + } + if err := h.Stop(); err != nil { + netlog.Warn("[SUP][%s] stop warning: %v", name, err) + } + s.reg.SetRunning(name, false) + netlog.Info("[SUP][%s] stopped", name) +} + +// dependentsOf returns the hooks that declare name in their DependsOn, +// transitively, in start order. +func (s *Supervisor) dependentsOf(name string) []string { + var out []string + for _, candidate := range s.order { + if candidate == name { + continue + } + for _, u := range s.reg.Snapshot() { + if u.Name != candidate { + continue + } + for _, dep := range u.DependsOn { + if dep == name { + out = append(out, candidate) + } + } + } + } + return out +} + +// markServiceRunning flips the running flag on the DDP service units that +// live inside the router set (they share the router's lifecycle). +func (s *Supervisor) markServiceRunning(running bool) { + for _, name := range []string{"MacIP", "IPXGW", "AFP"} { + s.reg.SetRunning(name, running) + } +} From 25c09fba9759f199319dd73c6293a961648290d9 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Thu, 4 Jun 2026 21:39:59 +1000 Subject: [PATCH 04/23] @ control: add per-service start/stop from the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose StartService/StopService alongside RestartService on the control plane and the supervisor interface, add /api/services/{name}/{start,stop, restart} endpoints, and render Start/Stop/Restart buttons on each controllable (hook) card in the dashboard. Stops are dependency-aware: stopping NetBIOS cascades to SMB. Verified end-to-end against a running binary — SMB stop/start toggles its running state and stopping NetBIOS brings SMB down with it. Co-Authored-By: Claude Opus 4.8 @ --- pkg/control/control.go | 20 ++++++++++++++++++++ pkg/control/control_test.go | 4 +++- service/webui/api.go | 18 +++++++++++++++--- service/webui/assets/app.css | 3 ++- service/webui/assets/app.js | 33 +++++++++++++++++++++++---------- service/webui/plane.go | 2 ++ 6 files changed, 65 insertions(+), 15 deletions(-) diff --git a/pkg/control/control.go b/pkg/control/control.go index 3cbe918..d976851 100644 --- a/pkg/control/control.go +++ b/pkg/control/control.go @@ -25,6 +25,10 @@ type Supervisor interface { // Apply re-wires the running stack to match cfg, restarting only the // units whose configuration changed. Apply(ctx context.Context, cfg ConfigModel) error + // StartService starts a single named unit. + StartService(ctx context.Context, name string) error + // StopService stops a single named unit (and its dependents). + StopService(name string) error // RestartService restarts a single named unit (and its dependents). RestartService(ctx context.Context, name string) error // ListInterfaces returns the host's network interface names for the @@ -102,6 +106,22 @@ func (p *Plane) ListSerialPorts() ([]serialport.Info, error) { return serialport.List() } +// StartService starts a single named unit. +func (p *Plane) StartService(ctx context.Context, name string) error { + if p.sup == nil { + return ErrNoSupervisor + } + return p.sup.StartService(ctx, name) +} + +// StopService stops a single named unit (and any units depending on it). +func (p *Plane) StopService(name string) error { + if p.sup == nil { + return ErrNoSupervisor + } + return p.sup.StopService(name) +} + // RestartService restarts a single named unit (and its dependents). func (p *Plane) RestartService(ctx context.Context, name string) error { if p.sup == nil { diff --git a/pkg/control/control_test.go b/pkg/control/control_test.go index 9215e65..f5f9668 100644 --- a/pkg/control/control_test.go +++ b/pkg/control/control_test.go @@ -16,7 +16,9 @@ type fakeSup struct { restarts []string } -func (s *fakeSup) Apply(_ context.Context, _ ConfigModel) error { s.applied++; return nil } +func (s *fakeSup) Apply(_ context.Context, _ ConfigModel) error { s.applied++; return nil } +func (s *fakeSup) StartService(_ context.Context, _ string) error { return nil } +func (s *fakeSup) StopService(_ string) error { return nil } func (s *fakeSup) RestartService(_ context.Context, name string) error { s.restarts = append(s.restarts, name) return nil diff --git a/service/webui/api.go b/service/webui/api.go index a3b5d93..5d2414f 100644 --- a/service/webui/api.go +++ b/service/webui/api.go @@ -150,13 +150,25 @@ func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) { return } name, action := parseServicePath(r.URL.Path) - if name == "" || action != "restart" { + if name == "" { writeError(w, http.StatusNotFound, errNotFound) return } - if err := s.opts.Plane.RestartService(r.Context(), name); err != nil { + var err error + switch action { + case "start": + err = s.opts.Plane.StartService(r.Context(), name) + case "stop": + err = s.opts.Plane.StopService(name) + case "restart": + err = s.opts.Plane.RestartService(r.Context(), name) + default: + writeError(w, http.StatusNotFound, errNotFound) + return + } + if err != nil { writeError(w, http.StatusInternalServerError, err) return } - writeJSON(w, http.StatusOK, map[string]any{"restarted": name}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "service": name, "action": action}) } diff --git a/service/webui/assets/app.css b/service/webui/assets/app.css index 010ad4f..398918e 100644 --- a/service/webui/assets/app.css +++ b/service/webui/assets/app.css @@ -78,7 +78,8 @@ main { padding: 1rem; } .card .metric { font-variant-numeric: tabular-nums; } -.card button { margin-top: 0.5rem; } +.card-actions { display: flex; gap: 0.4rem; margin-top: 0.6rem; } +.card-actions button { margin-top: 0; } .config-panel { background: var(--panel); diff --git a/service/webui/assets/app.js b/service/webui/assets/app.js index 0c6f759..3230f95 100644 --- a/service/webui/assets/app.js +++ b/service/webui/assets/app.js @@ -47,14 +47,27 @@ function renderStatus(units) { if (u.shares && u.shares.length) detail += kv("Shares", u.shares.map((s) => s.name).join(", ")); + // Only standalone hooks (IPX/NetBEUI/NetBIOS/SMB/WebUI) are individually + // start/stoppable; ports and the router-set share the stack lifecycle. + const controllable = u.kind === "hook"; + let controls = ""; + if (controllable) { + controls = u.running + ? ` + ` + : ``; + } + card.innerHTML = `

${esc(u.name)}

${u.enabled ? "Enabled" : "Disabled"} · ${u.running ? "Running" : "Stopped"}
${detail}
- +
${controls}
`; - card.querySelector("[data-restart]").addEventListener("click", () => restart(u.name)); + card.querySelectorAll("[data-action]").forEach((btn) => + btn.addEventListener("click", () => serviceAction(btn.dataset.svc, btn.dataset.action)) + ); grid.appendChild(card); }); } @@ -63,12 +76,12 @@ function kv(k, v) { return `
${esc(k)}: ${esc(String(v))}
`; } -async function restart(name) { +async function serviceAction(name, action) { try { - await postJSON(`/api/services/${encodeURIComponent(name)}/restart`, null); + await postJSON(`/api/services/${encodeURIComponent(name)}/${action}`, null); setTimeout(loadStatus, 300); } catch (e) { - alert("Restart failed: " + e.message); + alert(`${action} failed: ` + e.message); } } @@ -289,10 +302,10 @@ $("#btn-apply").addEventListener("click", async () => { try { await putJSON("/api/config", currentConfig); await postJSON("/api/config/apply", null); - status("Applied live. Changes are running but not yet saved to disk."); + setConfigStatus("Applied live. Changes are running but not yet saved to disk."); loadStatus(); } catch (e) { - status("Apply failed: " + e.message); + setConfigStatus("Apply failed: " + e.message); } }); @@ -302,13 +315,13 @@ $("#btn-save").addEventListener("click", async () => { await putJSON("/api/config", currentConfig); const r = await postJSON("/api/config/save", null); setDirty(false); - status("Saved. Backup written to " + (r.backup || "(no previous file)") + "."); + setConfigStatus("Saved. Backup written to " + (r.backup || "(no previous file)") + "."); } catch (e) { - status("Save failed: " + e.message); + setConfigStatus("Save failed: " + e.message); } }); -function status(msg) { +function setConfigStatus(msg) { $("#config-status").textContent = msg; } diff --git a/service/webui/plane.go b/service/webui/plane.go index 19cbf80..5dd989c 100644 --- a/service/webui/plane.go +++ b/service/webui/plane.go @@ -20,6 +20,8 @@ type ControlPlane interface { Apply(ctx context.Context) error Save() (backupPath string, err error) Export() ([]byte, error) + StartService(ctx context.Context, name string) error + StopService(name string) error RestartService(ctx context.Context, name string) error ListInterfaces() ([]string, error) ListSerialPorts() ([]serialport.Info, error) From d1d864944b39e9ce36657a4273d08a5924725d5b Mon Sep 17 00:00:00 2001 From: pgodwin Date: Thu, 4 Jun 2026 21:42:31 +1000 Subject: [PATCH 05/23] @ docs: document the web UI; add [WebUI] example and CI tag coverage - server.toml.example: commented [WebUI] stanza (enabled/bind/tls/cert/key). - scripts/ci/test.{sh,ps1}: add the webui tag set to the test matrix. - README: a Web UI section covering config keys, flags, live start/stop, and the stage/apply/save/download flow. - CLAUDE.md: package table entries for service/webui, pkg/control, pkg/status, pkg/metrics, pkg/serialport, config; note that the Supervisor owns the runtime and main.go is just flags + config. Co-Authored-By: Claude Opus 4.8 @ --- CLAUDE.md | 12 ++++++++++++ README.md | 27 +++++++++++++++++++++++++++ scripts/ci/test.ps1 | 1 + scripts/ci/test.sh | 1 + server.toml.example | 11 +++++++++++ 5 files changed, 52 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 31f2744..7f006ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,8 +69,20 @@ cmd/classicstack/main.go → Ports → Router → Services | `service/atp/` | AppleTalk Transaction Protocol — reliable messaging | | `service/dsi/` | Data Stream Interface — AFP transport over TCP | | `service/macip/` | IP-over-AppleTalk gateway with NAT and DHCP relay | +| `service/webui/` | Management web UI (`-tags webui`): HTTPS adapter over `pkg/control` — JSON API, SSE stats stream, embedded SPA | +| `pkg/control/` | Transport-agnostic management API (status, config stage/apply/save, service start/stop/restart, diagnostics); the single contract every UI front-end shares | +| `pkg/status/` | In-process service-status registry read by the dashboard | +| `pkg/metrics/` | Streaming stats hub (expvar + SSE sinks) | +| `pkg/serialport/` | Per-OS serial-port enumeration for the TashTalk dropdown | +| `config/` | Config loader plus `Model` (in-memory, editable, serialisable view of `server.toml` with numbered-backup Save) | | `netlog/` | Structured logger with debug/info/warn levels | +The `cmd/classicstack` `Supervisor` owns the whole runtime: it builds ports, the +router (and its DDP service set), and the standalone hooks from the config +`Model`, and exposes per-service Start/Stop/Restart (dependency-aware) that the +web UI drives through `pkg/control`. `main.go` only parses flags / loads TOML, +builds the `Model`, and hands off to the supervisor. + ### AFP Architecture AFP supports two transport stacks simultaneously: diff --git a/README.md b/README.md index 4dffff1..3cf4c86 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,33 @@ AFP volumes are configured as [AFP.Volumes.] sections. - localtalk, ethertalk, ipx capture output paths - snaplen for capture truncation length +## Web UI + +A management web UI is available in builds that include `-tags webui` (which +`-tags all` does). It serves a dashboard showing per-service status, bindings, +and live (SSE-streamed) statistics, plus a configuration editor and read-only +diagnostics (zone/network enumeration). + +[WebUI]: + +- enabled: turn the listener on (default off) +- bind: `IP:PORT` to listen on (default `127.0.0.1:8080`, loopback) +- tls: serve HTTPS (default true); a self-signed certificate is generated at + startup when `cert_pem`/`key_pem` are blank +- cert_pem / key_pem: paths to a PEM certificate and key (supply both, or + leave both blank for the self-signed certificate) + +Equivalent flags: `-webui-enabled`, `-webui-bind`, `-webui-tls`, +`-webui-cert-pem`, `-webui-key-pem`. + +From the dashboard you can **start, stop, and restart** the standalone services +(IPX, NetBEUI, NetBIOS, SMB) live; stops are dependency-aware (stopping NetBIOS +also stops SMB). The configuration editor stages edits in memory; **Apply** +re-wires the running stack, **Save** writes `server.toml` (backing up the prior +file to `server.toml.NNNN` and dropping comments), and **Download backup** +exports the current config. The same operations are exposed by the +transport-agnostic `pkg/control` API, so a future text/telnet UI can reuse them. + ## Useful commands List pcap devices: diff --git a/scripts/ci/test.ps1 b/scripts/ci/test.ps1 index 9b8bede..8e4f6ee 100644 --- a/scripts/ci/test.ps1 +++ b/scripts/ci/test.ps1 @@ -9,6 +9,7 @@ $tagSets = @( 'afp sqlite_cnid' 'all' 'ipx netbeui smb' + 'webui' ) foreach ($tags in $tagSets) { diff --git a/scripts/ci/test.sh b/scripts/ci/test.sh index 1ccef67..e41a3a9 100644 --- a/scripts/ci/test.sh +++ b/scripts/ci/test.sh @@ -13,6 +13,7 @@ tag_sets=( "afp sqlite_cnid" "all" "ipx netbeui smb" + "webui" ) for tags in "${tag_sets[@]}"; do diff --git a/server.toml.example b/server.toml.example index 6fcd646..f235db1 100644 --- a/server.toml.example +++ b/server.toml.example @@ -132,3 +132,14 @@ snaplen = 65535 # per-frame snap length # [VFSBus] # subscriber_buffer = 256 # drop_warn_interval = "30s" + +# [WebUI] +# Management web UI: a dashboard showing per-service status/statistics and a +# configuration editor. Requires a binary built with -tags webui (included in +# -tags all). Saving from the UI rewrites this file and removes comments, +# backing up the previous version to server.toml.NNNN first. +# enabled = false +# bind = "127.0.0.1:8080" # IP:PORT to listen on; loopback by default +# tls = true # serve HTTPS; self-signed when no cert/key given +# cert_pem = "" # path to PEM certificate; blank: self-signed +# key_pem = "" # path to PEM private key; blank: self-signed From 8befa0435d04103346c82b05437a8ebc21671c2a Mon Sep 17 00:00:00 2001 From: pgodwin Date: Thu, 4 Jun 2026 22:07:15 +1000 Subject: [PATCH 06/23] @ webui: edit AFP volumes and SMB shares; source them from the config model The config editor gains table editors to add, update, and remove AFP volumes and SMB shares. They mutate the keyed maps in the config model (AFP.Volumes / SMB.Volumes); the supervisor now builds AFP and SMB from the editable model rather than re-reading the TOML file, so edits take effect on Apply (the AFP config is self-contained, including its volumes). - AFPFlagInputs.VolumeModels carries structured volumes from the model; applyAFPFlagsToConfig prefers them over the raw -afp-volume flag strings. smbSharesFromModel builds SMB shares from the model. The supervisor passes FromConfig:false for AFP and uses the model for SMB shares. - AFP/SMB status units now report their advertised name(s) and share list. - config.Model fields gain json tags mirroring their toml tags, so the API JSON keys match the lowercase TOML names the SPA editor binds to. This also fixes the scalar config editor, whose field paths previously did not match the PascalCase Go-default JSON. - Apply preserves the running Web UI server across the atomic rebuild: it is detached before the stack stops and re-attached (already running) to the rebuilt stack, so the in-flight Apply request and the operator connection survive. Documented the remaining TIME_WAIT rebind limitation of the atomic rebuild for fixed-port services (AFP/DSI, SMB). Verified end-to-end: adding an AFP volume via the API and applying rebuilds AFP with the new volume (CNID db opened at the new path, volumes=1), the Web UI stays up across Apply, and the volume appears in status and the downloaded TOML. Co-Authored-By: Claude Opus 4.8 @ --- cmd/classicstack/afp_enabled.go | 34 ++++ cmd/classicstack/afp_hook.go | 5 + cmd/classicstack/smb_shares.go | 32 ++++ cmd/classicstack/supervisor.go | 84 +++++++++- cmd/classicstack/supervisor_control.go | 73 ++++++-- cmd/classicstack/supervisor_lifecycle.go | 7 + config/model.go | 202 +++++++++++------------ service/webui/assets/app.css | 16 ++ service/webui/assets/app.js | 119 +++++++++++++ 9 files changed, 457 insertions(+), 115 deletions(-) diff --git a/cmd/classicstack/afp_enabled.go b/cmd/classicstack/afp_enabled.go index e0a5212..d24fa70 100644 --- a/cmd/classicstack/afp_enabled.go +++ b/cmd/classicstack/afp_enabled.go @@ -154,6 +154,26 @@ func applyAFPFlagsToConfig(f AFPFlagInputs, cfg *afp.Config) { if f.AppleDoubleMode != "" { cfg.AppleDoubleMode = f.AppleDoubleMode } + // Structured volumes from the config model take precedence; this is the + // path the supervisor uses so volume edits made in the web UI apply. + if len(f.VolumeModels) > 0 { + if cfg.Volumes == nil { + cfg.Volumes = make(map[string]afp.VolumeConfig) + } + for key, vm := range volumeModelsByKey(f.VolumeModels) { + cfg.Volumes[key] = afp.VolumeConfig{ + Name: firstNonBlank(vm.Name, key), + Path: vm.Path, + FSType: vm.FSType, + Password: vm.Password, + ReadOnly: vm.ReadOnly, + RebuildDesktopDB: vm.RebuildDesktopDB, + AppleDoubleMode: afp.AppleDoubleMode(vm.AppleDoubleMode), + } + } + return + } + if len(f.VolumeFlagValues) == 0 { return } @@ -170,6 +190,20 @@ func applyAFPFlagsToConfig(f AFPFlagInputs, cfg *afp.Config) { } } +// volumeModelsByKey indexes the model volumes by a stable key (their Name, +// or a positional fallback) for insertion into the AFP volume map. +func volumeModelsByKey(vols []config.VolumeModel) map[string]config.VolumeModel { + out := make(map[string]config.VolumeModel, len(vols)) + for i, v := range vols { + key := v.Name + if key == "" { + key = fmt.Sprintf("Volume%d", i+1) + } + out[key] = v + } + return out +} + func splitAFPProtocols(s string) (ddp, tcp bool) { for _, p := range strings.Split(s, ",") { switch strings.ToLower(strings.TrimSpace(p)) { diff --git a/cmd/classicstack/afp_hook.go b/cmd/classicstack/afp_hook.go index 3e063fc..4c8243e 100644 --- a/cmd/classicstack/afp_hook.go +++ b/cmd/classicstack/afp_hook.go @@ -39,6 +39,11 @@ type AFPFlagInputs struct { CNIDBackend string AppleDoubleMode string VolumeFlagValues []string // raw "Name:Path" flag entries + // VolumeModels carries structured volumes from the config model (the + // path used when the supervisor builds AFP from the editable model, so + // UI edits to volumes take effect). When non-empty it supersedes + // VolumeFlagValues. + VolumeModels []config.VolumeModel } // AFPWiring is the input bundle for wireAFP. diff --git a/cmd/classicstack/smb_shares.go b/cmd/classicstack/smb_shares.go index 991fd26..3fde0e7 100644 --- a/cmd/classicstack/smb_shares.go +++ b/cmd/classicstack/smb_shares.go @@ -8,6 +8,38 @@ import ( "github.com/ObsoleteMadness/ClassicStack/service/smb" ) +// smbSharesFromModel builds the SMB share list from the editable config +// model. This is the path the supervisor uses so share add/update/remove +// done in the web UI take effect on Apply (the file-source loaders below +// remain for the legacy startup path). +func smbSharesFromModel(shares map[string]config.ShareModel) []smb.ShareConfig { + if len(shares) == 0 { + return nil + } + out := make([]smb.ShareConfig, 0, len(shares)) + for key, sh := range shares { + name := sh.Name + if name == "" { + name = key + } + if strings.TrimSpace(sh.Path) == "" { + netlog.Warn("[MAIN][SMB] share %q missing path; skipping", key) + continue + } + fsType := sh.FSType + if fsType == "" { + fsType = "local_fs" + } + out = append(out, smb.ShareConfig{ + Name: name, + Path: sh.Path, + FSType: fsType, + ReadOnly: sh.ReadOnly, + }) + } + return out +} + // loadSMBShares assembles the SMB share list from whichever source is // active. In TOML mode it reads [SMB.Volumes.] sections; in flag // mode it parses "Name:Path" entries from -smb-share. The two sources diff --git a/cmd/classicstack/supervisor.go b/cmd/classicstack/supervisor.go index 15ecf85..45554fa 100644 --- a/cmd/classicstack/supervisor.go +++ b/cmd/classicstack/supervisor.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "path/filepath" "strings" "sync" @@ -53,6 +54,10 @@ type Supervisor struct { captureSinks []closer // parseCleanup closes the parse-packets output file, if any. parseCleanup func() + // alreadyRunning marks hooks that are live before Start is called (the + // Web UI preserved across an Apply rebuild), so Start does not restart + // them. Cleared after the first Start. + alreadyRunning map[string]bool // nbp is shared between several services; kept so restarts can re-wire. nbp *zip.NameInformationService @@ -261,9 +266,11 @@ func (s *Supervisor) buildServices() ([]service.Service, error) { } s.shortHook = shortHook + // AFP is built from the editable config model (not re-read from the + // TOML source) so volume edits made in the web UI take effect on Apply. afpHook, err := wireAFP(AFPWiring{ Source: s.source, - FromConfig: s.source.K != nil, + FromConfig: false, NBP: s.nbp, Shortname: shortHook, Flags: s.afpFlagInputs(), @@ -275,7 +282,7 @@ func (s *Supervisor) buildServices() ([]service.Service, error) { afpHook.AttachMacIP(macIPAFPHooks{macIP}) } services = append(services, afpHook.Services()...) - s.registerServiceStatus("AFP", s.model.AFP.Enabled, map[string]string{"name": s.model.AFP.Name, "zone": s.model.AFP.Zone}) + s.registerAFPStatus() s.macIP = macIP s.ipxGW = ipxGW @@ -286,15 +293,27 @@ func (s *Supervisor) buildServices() ([]service.Service, error) { // works whether the config came from a file or flags. func (s *Supervisor) afpFlagInputs() AFPFlagInputs { m := s.model + extMap := m.AFP.ExtensionMap + if extMap != "" && !filepath.IsAbs(extMap) && s.source.ConfigDir != "" { + extMap = filepath.Join(s.source.ConfigDir, extMap) + } + vols := make([]config.VolumeModel, 0, len(m.AFP.Volumes)) + for key, v := range m.AFP.Volumes { + if v.Name == "" { + v.Name = key + } + vols = append(vols, v) + } return AFPFlagInputs{ ServerName: m.AFP.Name, Zone: m.AFP.Zone, Protocols: m.AFP.Protocols, TCPAddr: m.AFP.Binding, - ExtensionMap: m.AFP.ExtensionMap, + ExtensionMap: extMap, DecomposedNames: m.AFP.UseDecomposedNames, CNIDBackend: m.AFP.CNIDBackend, AppleDoubleMode: m.AFP.AppleDoubleMode, + VolumeModels: vols, } } @@ -351,7 +370,11 @@ func (s *Supervisor) buildHooks() error { return fmt.Errorf("NetBIOS wiring failed: %w", err) } - smbShareConfigs := loadSMBShares(s.source, s.source.K != nil, cfg.SMBShareFlags) + // SMB shares come from the editable model so UI edits apply on Apply. + smbShareConfigs := smbSharesFromModel(s.model.SMB.Volumes) + if len(smbShareConfigs) == 0 { + smbShareConfigs = loadSMBShares(s.source, s.source.K != nil, cfg.SMBShareFlags) + } smbHook, err := wireSMB(SMBConfig{ Enabled: cfg.SMBEnabled, NBTBinding: cfg.SMBNBTBinding, @@ -374,6 +397,9 @@ func (s *Supervisor) buildHooks() error { s.addHook("NetBEUI", nbeuiHook, cfg.NetBEUIEnabled, nil) s.addHook("NetBIOS", nbHook, cfg.NetBIOSEnabled, []string{"IPX", "NetBEUI"}) s.addHook("SMB", smbHook, cfg.SMBEnabled, []string{"NetBIOS"}) + if smbHook != nil { + s.registerSMBStatus(cfg.SMBEnabled) // enrich the SMB unit with shares/identity + } return nil } @@ -442,6 +468,56 @@ func (s *Supervisor) registerServiceStatus(name string, enabled bool, props map[ }) } +// registerAFPStatus records AFP's status including its advertised name, +// zone, and the list of shared volumes for the dashboard. +func (s *Supervisor) registerAFPStatus() { + m := s.model.AFP + shares := make([]status.ShareInfo, 0, len(m.Volumes)) + for key, v := range m.Volumes { + name := v.Name + if name == "" { + name = key + } + shares = append(shares, status.ShareInfo{Name: name, Path: v.Path, ReadOnly: v.ReadOnly}) + } + s.reg.Set(status.Unit{ + Name: "AFP", + Kind: status.KindService, + Enabled: m.Enabled, + Binding: m.Binding, + Properties: map[string]string{"zone": m.Zone}, + Hostnames: []string{m.Name}, + Shares: shares, + }) +} + +// registerSMBStatus records SMB's identity and shares for the dashboard. +func (s *Supervisor) registerSMBStatus(enabled bool) { + m := s.model.SMB + shares := make([]status.ShareInfo, 0, len(m.Volumes)) + for key, sh := range m.Volumes { + name := sh.Name + if name == "" { + name = key + } + shares = append(shares, status.ShareInfo{Name: name, Path: sh.Path, ReadOnly: sh.ReadOnly}) + } + hostnames := []string{} + if m.ServerName != "" { + hostnames = append(hostnames, m.ServerName) + } + s.reg.Set(status.Unit{ + Name: "SMB", + Kind: status.KindHook, + Enabled: enabled, + Binding: m.NBTBinding, + Properties: map[string]string{"workgroup": m.Workgroup}, + Hostnames: hostnames, + Shares: shares, + DependsOn: []string{"NetBIOS"}, + }) +} + // AddExternalHook registers an additional named hook (e.g. the Web UI) // built outside the standard wiring, so the supervisor starts and stops it // with the rest of the stack. enabled records its configured state for the diff --git a/cmd/classicstack/supervisor_control.go b/cmd/classicstack/supervisor_control.go index 5bb8b79..18e352f 100644 --- a/cmd/classicstack/supervisor_control.go +++ b/cmd/classicstack/supervisor_control.go @@ -7,6 +7,7 @@ import ( "github.com/ObsoleteMadness/ClassicStack/config" "github.com/ObsoleteMadness/ClassicStack/netlog" "github.com/ObsoleteMadness/ClassicStack/pkg/control" + "github.com/ObsoleteMadness/ClassicStack/pkg/status" "github.com/ObsoleteMadness/ClassicStack/port/rawlink" ) @@ -14,12 +15,24 @@ import ( // interface the management plane drives. RestartService is already // implemented in supervisor_lifecycle.go. -// Apply re-wires the running stack to match the supplied config model. For -// now this is an atomic whole-stack rebuild: the entire stack is stopped, -// reconstructed from the new model, and started again. Finer-grained -// per-service application can layer on later using the dynamic-router -// primitives; the control-plane contract (and the UI) is unchanged by that -// evolution. +// webUIUnitName is the reserved status/hook name for the management UI. +const webUIUnitName = "WebUI" + +// Apply re-wires the running stack to match the supplied config model. It is +// an atomic whole-stack rebuild — the stack is stopped, reconstructed from +// the new model, and started — with one exception: the Web UI server is +// preserved across the rebuild. The UI must outlive a reconfiguration +// because Apply is itself driven by an in-flight UI request; tearing the +// server down here would drop that request and the operator's connection. +// Finer-grained per-service application can layer on later using the +// dynamic-router primitives without changing the control-plane contract. +// +// Known limitation of the atomic rebuild: services that bind a fixed TCP +// port (AFP/DSI on :548, SMB on :139) are torn down and re-bound on every +// Apply. On some platforms the OS holds the port briefly in TIME_WAIT, so a +// rebind immediately after stop can fail. Per-service application (rebuild +// only what changed) is the planned remedy; until then, an Apply that only +// touched, say, AFP volumes still cycles every listener. func (s *Supervisor) Apply(ctx context.Context, cfg control.ConfigModel) error { model, ok := cfg.(*config.Model) if !ok { @@ -31,20 +44,25 @@ func (s *Supervisor) Apply(ctx context.Context, cfg control.ConfigModel) error { return fmt.Errorf("supervisor: invalid config: %w", err) } - netlog.Info("[SUP] applying new configuration (atomic rebuild)") + netlog.Info("[SUP] applying new configuration (atomic rebuild, web UI preserved)") + + // Detach the live Web UI hook so the stack stop does not tear it down. + webui := s.detachWebUI() + if err := s.Stop(); err != nil { netlog.Warn("[SUP] stop during apply: %v", err) } - // Rebuild a fresh supervisor state from the new model, then graft its - // freshly constructed components onto this instance so the control - // plane keeps pointing at the same Supervisor. rebuilt, err := NewSupervisor(newCfg, s.source, model) if err != nil { return fmt.Errorf("supervisor: rebuild failed: %w", err) } s.adoptFrom(rebuilt) + // Re-attach the preserved Web UI so it remains a managed (already + // running) unit of the rebuilt stack. + s.reattachWebUI(webui) + if err := s.Start(ctx); err != nil { return fmt.Errorf("supervisor: restart failed: %w", err) } @@ -52,6 +70,40 @@ func (s *Supervisor) Apply(ctx context.Context, cfg control.ConfigModel) error { return nil } +// detachWebUI removes the Web UI hook from the running stack without +// stopping it, returning it so Apply can re-attach it to the rebuilt stack. +func (s *Supervisor) detachWebUI() hook { + s.mu.Lock() + defer s.mu.Unlock() + h := s.hooks[webUIUnitName] + if h == nil { + return nil + } + delete(s.hooks, webUIUnitName) + for i, name := range s.order { + if name == webUIUnitName { + s.order = append(s.order[:i], s.order[i+1:]...) + break + } + } + return h +} + +// reattachWebUI registers a preserved, already-running Web UI hook on the +// rebuilt stack and marks it running in the status registry. The hook is +// recorded in s.started-tracking via the order slice but is not (re)started +// by Start, since it never stopped. +func (s *Supervisor) reattachWebUI(h hook) { + if h == nil { + return + } + s.mu.Lock() + s.hooks[webUIUnitName] = h + s.alreadyRunning = map[string]bool{webUIUnitName: true} + s.mu.Unlock() + s.reg.Set(status.Unit{Name: webUIUnitName, Kind: status.KindHook, Enabled: true, Running: true}) +} + // adoptFrom replaces this supervisor's built components with those from a // freshly constructed one (used by Apply after Stop). The caller must hold // no locks; Apply runs Stop/Start which lock internally. @@ -66,6 +118,7 @@ func (s *Supervisor) adoptFrom(other *Supervisor) { s.hooks = other.hooks s.order = other.order s.captureSinks = other.captureSinks + s.parseCleanup = other.parseCleanup s.nbp = other.nbp s.shortHook = other.shortHook s.macIP = other.macIP diff --git a/cmd/classicstack/supervisor_lifecycle.go b/cmd/classicstack/supervisor_lifecycle.go index b246b01..83e4490 100644 --- a/cmd/classicstack/supervisor_lifecycle.go +++ b/cmd/classicstack/supervisor_lifecycle.go @@ -29,10 +29,17 @@ func (s *Supervisor) Start(ctx context.Context) error { s.ctx = ctx for _, name := range s.order { + if s.alreadyRunning[name] { + // Preserved across an Apply rebuild (e.g. the Web UI); it is + // already serving, so do not restart it. + s.reg.SetRunning(name, true) + continue + } if err := s.startHookLocked(ctx, name); err != nil { netlog.Warn("[SUP][%s] start failed: %v", name, err) } } + s.alreadyRunning = nil s.started = true return nil } diff --git a/config/model.go b/config/model.go index 7879642..fd99135 100644 --- a/config/model.go +++ b/config/model.go @@ -12,173 +12,173 @@ package config // build tags); the cmd-layer wiring converts between Model and those // packages' own config structs. type Model struct { - Logging LoggingModel `toml:"Logging"` - Bridge BridgeModel `toml:"Bridge"` - LToUDP LToUDPModel `toml:"LToUdp"` - TashTalk TashTalkModel `toml:"TashTalk"` - EtherTalk EtherTalkModel `toml:"EtherTalk"` - Capture CaptureModel `toml:"Capture"` - MacIP MacIPModel `toml:"MacIP"` - IPX IPXModel `toml:"IPX"` - IPXGW IPXGWModel `toml:"IPXGW"` - NetBEUI NetBEUIModel `toml:"NetBEUI"` - NetBIOS NetBIOSModel `toml:"NetBIOS"` - SMB SMBModel `toml:"SMB"` - AFP AFPModel `toml:"AFP"` - Shortname ShortnameModel `toml:"Shortname"` - WebUI WebUIModel `toml:"WebUI"` + Logging LoggingModel `toml:"Logging" json:"Logging"` + Bridge BridgeModel `toml:"Bridge" json:"Bridge"` + LToUDP LToUDPModel `toml:"LToUdp" json:"LToUdp"` + TashTalk TashTalkModel `toml:"TashTalk" json:"TashTalk"` + EtherTalk EtherTalkModel `toml:"EtherTalk" json:"EtherTalk"` + Capture CaptureModel `toml:"Capture" json:"Capture"` + MacIP MacIPModel `toml:"MacIP" json:"MacIP"` + IPX IPXModel `toml:"IPX" json:"IPX"` + IPXGW IPXGWModel `toml:"IPXGW" json:"IPXGW"` + NetBEUI NetBEUIModel `toml:"NetBEUI" json:"NetBEUI"` + NetBIOS NetBIOSModel `toml:"NetBIOS" json:"NetBIOS"` + SMB SMBModel `toml:"SMB" json:"SMB"` + AFP AFPModel `toml:"AFP" json:"AFP"` + Shortname ShortnameModel `toml:"Shortname" json:"Shortname"` + WebUI WebUIModel `toml:"WebUI" json:"WebUI"` } // LoggingModel is the [Logging] section. type LoggingModel struct { - Level string `toml:"level"` - ParsePackets bool `toml:"parse_packets"` - LogTraffic bool `toml:"log_traffic"` - ParseOutput string `toml:"parse_output,omitempty"` + Level string `toml:"level" json:"level"` + ParsePackets bool `toml:"parse_packets" json:"parse_packets"` + LogTraffic bool `toml:"log_traffic" json:"log_traffic"` + ParseOutput string `toml:"parse_output,omitempty" json:"parse_output,omitempty"` } // BridgeModel is the [Bridge] section. type BridgeModel struct { - Mode string `toml:"mode,omitempty"` - Device string `toml:"device,omitempty"` - HWAddress string `toml:"hw_address,omitempty"` - BridgeMode string `toml:"bridge_mode,omitempty"` + Mode string `toml:"mode,omitempty" json:"mode,omitempty"` + Device string `toml:"device,omitempty" json:"device,omitempty"` + HWAddress string `toml:"hw_address,omitempty" json:"hw_address,omitempty"` + BridgeMode string `toml:"bridge_mode,omitempty" json:"bridge_mode,omitempty"` } // LToUDPModel is the [LToUdp] section. type LToUDPModel struct { - Enabled bool `toml:"enabled"` - Interface string `toml:"interface,omitempty"` - SeedNetwork uint `toml:"seed_network"` - SeedZone string `toml:"seed_zone"` + Enabled bool `toml:"enabled" json:"enabled"` + Interface string `toml:"interface,omitempty" json:"interface,omitempty"` + SeedNetwork uint `toml:"seed_network" json:"seed_network"` + SeedZone string `toml:"seed_zone" json:"seed_zone"` } // TashTalkModel is the [TashTalk] section. type TashTalkModel struct { - Port string `toml:"port"` - SeedNetwork uint `toml:"seed_network"` - SeedZone string `toml:"seed_zone"` + Port string `toml:"port" json:"port"` + SeedNetwork uint `toml:"seed_network" json:"seed_network"` + SeedZone string `toml:"seed_zone" json:"seed_zone"` } // EtherTalkModel is the [EtherTalk] section (bridge keys live in [Bridge]). type EtherTalkModel struct { - BridgeHostMAC string `toml:"bridge_host_mac,omitempty"` - Filter string `toml:"filter,omitempty"` - SeedNetworkMin uint `toml:"seed_network_min"` - SeedNetworkMax uint `toml:"seed_network_max"` - SeedZone string `toml:"seed_zone"` - DesiredNetwork uint `toml:"desired_network,omitempty"` - DesiredNode uint `toml:"desired_node,omitempty"` + BridgeHostMAC string `toml:"bridge_host_mac,omitempty" json:"bridge_host_mac,omitempty"` + Filter string `toml:"filter,omitempty" json:"filter,omitempty"` + SeedNetworkMin uint `toml:"seed_network_min" json:"seed_network_min"` + SeedNetworkMax uint `toml:"seed_network_max" json:"seed_network_max"` + SeedZone string `toml:"seed_zone" json:"seed_zone"` + DesiredNetwork uint `toml:"desired_network,omitempty" json:"desired_network,omitempty"` + DesiredNode uint `toml:"desired_node,omitempty" json:"desired_node,omitempty"` } // CaptureModel is the [Capture] section. type CaptureModel struct { - LocalTalk string `toml:"localtalk,omitempty"` - EtherTalk string `toml:"ethertalk,omitempty"` - IPX string `toml:"ipx,omitempty"` - NetBEUI string `toml:"netbeui,omitempty"` - Snaplen uint32 `toml:"snaplen,omitempty"` + LocalTalk string `toml:"localtalk,omitempty" json:"localtalk,omitempty"` + EtherTalk string `toml:"ethertalk,omitempty" json:"ethertalk,omitempty"` + IPX string `toml:"ipx,omitempty" json:"ipx,omitempty"` + NetBEUI string `toml:"netbeui,omitempty" json:"netbeui,omitempty"` + Snaplen uint32 `toml:"snaplen,omitempty" json:"snaplen,omitempty"` } // MacIPModel is the [MacIP] section. type MacIPModel struct { - Enabled bool `toml:"enabled"` - Mode string `toml:"mode,omitempty"` // pcap or nat - Zone string `toml:"zone,omitempty"` - NATSubnet string `toml:"nat_subnet,omitempty"` - NATGW string `toml:"nat_gw,omitempty"` - LeaseFile string `toml:"lease_file,omitempty"` - IPGateway string `toml:"ip_gateway,omitempty"` - DHCPRelay bool `toml:"dhcp_relay,omitempty"` - Nameserver string `toml:"nameserver,omitempty"` - Filter string `toml:"filter,omitempty"` + Enabled bool `toml:"enabled" json:"enabled"` + Mode string `toml:"mode,omitempty" json:"mode,omitempty"` // pcap or nat + Zone string `toml:"zone,omitempty" json:"zone,omitempty"` + NATSubnet string `toml:"nat_subnet,omitempty" json:"nat_subnet,omitempty"` + NATGW string `toml:"nat_gw,omitempty" json:"nat_gw,omitempty"` + LeaseFile string `toml:"lease_file,omitempty" json:"lease_file,omitempty"` + IPGateway string `toml:"ip_gateway,omitempty" json:"ip_gateway,omitempty"` + DHCPRelay bool `toml:"dhcp_relay,omitempty" json:"dhcp_relay,omitempty"` + Nameserver string `toml:"nameserver,omitempty" json:"nameserver,omitempty"` + Filter string `toml:"filter,omitempty" json:"filter,omitempty"` } // IPXModel is the [IPX] section. type IPXModel struct { - Enabled bool `toml:"enabled"` - Interface string `toml:"interface,omitempty"` - Framing string `toml:"framing,omitempty"` - InternalNetwork string `toml:"internal_network,omitempty"` - Filter string `toml:"filter,omitempty"` + Enabled bool `toml:"enabled" json:"enabled"` + Interface string `toml:"interface,omitempty" json:"interface,omitempty"` + Framing string `toml:"framing,omitempty" json:"framing,omitempty"` + InternalNetwork string `toml:"internal_network,omitempty" json:"internal_network,omitempty"` + Filter string `toml:"filter,omitempty" json:"filter,omitempty"` } // IPXGWModel is the [IPXGW] section. type IPXGWModel struct { - Enabled bool `toml:"enabled"` - Bindings []string `toml:"bindings,omitempty"` // "Object:Zone" entries + Enabled bool `toml:"enabled" json:"enabled"` + Bindings []string `toml:"bindings,omitempty" json:"bindings,omitempty"` // "Object:Zone" entries } // NetBEUIModel is the [NetBEUI] section. type NetBEUIModel struct { - Enabled bool `toml:"enabled"` - Interface string `toml:"interface,omitempty"` - Filter string `toml:"filter,omitempty"` + Enabled bool `toml:"enabled" json:"enabled"` + Interface string `toml:"interface,omitempty" json:"interface,omitempty"` + Filter string `toml:"filter,omitempty" json:"filter,omitempty"` } // NetBIOSModel is the [NetBIOS] section. type NetBIOSModel struct { - Enabled bool `toml:"enabled"` - Transports []string `toml:"transports,omitempty"` - ScopeID string `toml:"scope_id,omitempty"` + Enabled bool `toml:"enabled" json:"enabled"` + Transports []string `toml:"transports,omitempty" json:"transports,omitempty"` + ScopeID string `toml:"scope_id,omitempty" json:"scope_id,omitempty"` } // SMBModel is the [SMB] section, including [SMB.Volumes.*] shares. type SMBModel struct { - Enabled bool `toml:"enabled"` - NBTBinding string `toml:"nbt_binding,omitempty"` - DirectBinding string `toml:"direct_binding,omitempty"` - GuestOk bool `toml:"guest_ok,omitempty"` - ServerName string `toml:"server_name,omitempty"` - Workgroup string `toml:"workgroup,omitempty"` - Volumes map[string]ShareModel `toml:"Volumes,omitempty"` + Enabled bool `toml:"enabled" json:"enabled"` + NBTBinding string `toml:"nbt_binding,omitempty" json:"nbt_binding,omitempty"` + DirectBinding string `toml:"direct_binding,omitempty" json:"direct_binding,omitempty"` + GuestOk bool `toml:"guest_ok,omitempty" json:"guest_ok,omitempty"` + ServerName string `toml:"server_name,omitempty" json:"server_name,omitempty"` + Workgroup string `toml:"workgroup,omitempty" json:"workgroup,omitempty"` + Volumes map[string]ShareModel `toml:"Volumes,omitempty" json:"Volumes,omitempty"` } // ShareModel is one [SMB.Volumes.] entry. type ShareModel struct { - Name string `toml:"name,omitempty"` - Path string `toml:"path"` - FSType string `toml:"fs_type,omitempty"` - ReadOnly bool `toml:"read_only,omitempty"` + Name string `toml:"name,omitempty" json:"name,omitempty"` + Path string `toml:"path" json:"path"` + FSType string `toml:"fs_type,omitempty" json:"fs_type,omitempty"` + ReadOnly bool `toml:"read_only,omitempty" json:"read_only,omitempty"` } // AFPModel is the [AFP] section, including [AFP.Volumes.*] volumes. type AFPModel struct { - Enabled bool `toml:"enabled"` - Name string `toml:"name,omitempty"` - Zone string `toml:"zone,omitempty"` - Protocols string `toml:"protocols,omitempty"` - Binding string `toml:"binding,omitempty"` - ExtensionMap string `toml:"extension_map,omitempty"` - CNIDBackend string `toml:"cnid_backend,omitempty"` - UseDecomposedNames bool `toml:"use_decomposed_names,omitempty"` - AppleDoubleMode string `toml:"appledouble_mode,omitempty"` - Volumes map[string]VolumeModel `toml:"Volumes,omitempty"` + Enabled bool `toml:"enabled" json:"enabled"` + Name string `toml:"name,omitempty" json:"name,omitempty"` + Zone string `toml:"zone,omitempty" json:"zone,omitempty"` + Protocols string `toml:"protocols,omitempty" json:"protocols,omitempty"` + Binding string `toml:"binding,omitempty" json:"binding,omitempty"` + ExtensionMap string `toml:"extension_map,omitempty" json:"extension_map,omitempty"` + CNIDBackend string `toml:"cnid_backend,omitempty" json:"cnid_backend,omitempty"` + UseDecomposedNames bool `toml:"use_decomposed_names,omitempty" json:"use_decomposed_names,omitempty"` + AppleDoubleMode string `toml:"appledouble_mode,omitempty" json:"appledouble_mode,omitempty"` + Volumes map[string]VolumeModel `toml:"Volumes,omitempty" json:"Volumes,omitempty"` } // VolumeModel is one [AFP.Volumes.] entry. type VolumeModel struct { - Name string `toml:"name,omitempty"` - Path string `toml:"path,omitempty"` - FSType string `toml:"fs_type,omitempty"` - Password string `toml:"password,omitempty"` - ReadOnly bool `toml:"read_only,omitempty"` - RebuildDesktopDB bool `toml:"rebuild_desktop_db,omitempty"` - AppleDoubleMode string `toml:"appledouble_mode,omitempty"` + Name string `toml:"name,omitempty" json:"name,omitempty"` + Path string `toml:"path,omitempty" json:"path,omitempty"` + FSType string `toml:"fs_type,omitempty" json:"fs_type,omitempty"` + Password string `toml:"password,omitempty" json:"password,omitempty"` + ReadOnly bool `toml:"read_only,omitempty" json:"read_only,omitempty"` + RebuildDesktopDB bool `toml:"rebuild_desktop_db,omitempty" json:"rebuild_desktop_db,omitempty"` + AppleDoubleMode string `toml:"appledouble_mode,omitempty" json:"appledouble_mode,omitempty"` } // ShortnameModel is the [Shortname] section. type ShortnameModel struct { - WindowsShortnames bool `toml:"windows_shortnames,omitempty"` - Backend string `toml:"backend,omitempty"` - DBPath string `toml:"db_path,omitempty"` + WindowsShortnames bool `toml:"windows_shortnames,omitempty" json:"windows_shortnames,omitempty"` + Backend string `toml:"backend,omitempty" json:"backend,omitempty"` + DBPath string `toml:"db_path,omitempty" json:"db_path,omitempty"` } // WebUIModel is the [WebUI] section. type WebUIModel struct { - Enabled bool `toml:"enabled"` - Bind string `toml:"bind,omitempty"` - TLS bool `toml:"tls"` - CertPEM string `toml:"cert_pem,omitempty"` - KeyPEM string `toml:"key_pem,omitempty"` + Enabled bool `toml:"enabled" json:"enabled"` + Bind string `toml:"bind,omitempty" json:"bind,omitempty"` + TLS bool `toml:"tls" json:"tls"` + CertPEM string `toml:"cert_pem,omitempty" json:"cert_pem,omitempty"` + KeyPEM string `toml:"key_pem,omitempty" json:"key_pem,omitempty"` } diff --git a/service/webui/assets/app.css b/service/webui/assets/app.css index 398918e..4f2c4dd 100644 --- a/service/webui/assets/app.css +++ b/service/webui/assets/app.css @@ -98,6 +98,22 @@ main { padding: 1rem; } min-width: 200px; } +.share-table { width: 100%; border-collapse: collapse; margin: 0.4rem 0 0.6rem; } +.share-table th { + text-align: left; + font-size: 0.8rem; + color: var(--muted); + padding: 0.2rem 0.4rem; + border-bottom: 1px solid var(--border); +} +.share-table td { padding: 0.2rem 0.4rem; } +.share-table input[type="text"] { + width: 100%; + padding: 0.25rem 0.4rem; + border: 1px solid var(--border); + border-radius: 5px; +} + .banner { background: #fff7e6; border: 1px solid #f0d28a; diff --git a/service/webui/assets/app.js b/service/webui/assets/app.js index 3230f95..70ba233 100644 --- a/service/webui/assets/app.js +++ b/service/webui/assets/app.js @@ -225,6 +225,125 @@ async function renderConfig(cfg) { panel.fields.forEach((f) => fs.appendChild(renderField(cfg, f))); root.appendChild(fs); }); + + // Volume / share table editors. These mutate the keyed maps in the + // config model (AFP.Volumes / SMB.Volumes); the supervisor rebuilds the + // service from the model on Apply, so add/update/remove take effect. + root.appendChild( + renderShareEditor(cfg, "AFP Volumes", "AFP", [ + { key: "name", label: "Name", type: "text" }, + { key: "path", label: "Path", type: "text" }, + { key: "fs_type", label: "FS Type", type: "text", default: "local_fs" }, + { key: "read_only", label: "Read-only", type: "bool" }, + ]) + ); + root.appendChild( + renderShareEditor(cfg, "SMB Shares", "SMB", [ + { key: "name", label: "Name", type: "text" }, + { key: "path", label: "Path", type: "text" }, + { key: "fs_type", label: "FS Type", type: "text", default: "local_fs" }, + { key: "read_only", label: "Read-only", type: "bool" }, + ]) + ); +} + +// renderShareEditor builds a table editor over cfg[section].Volumes (a +// name-keyed map of share/volume objects) with add and remove controls. +function renderShareEditor(cfg, title, section, columns) { + const fs = document.createElement("fieldset"); + fs.className = "config-panel"; + const legend = document.createElement("legend"); + legend.textContent = title; + fs.appendChild(legend); + + if (!cfg[section]) cfg[section] = {}; + if (!cfg[section].Volumes) cfg[section].Volumes = {}; + const volumes = cfg[section].Volumes; + + const table = document.createElement("table"); + table.className = "share-table"; + const head = document.createElement("tr"); + columns.forEach((c) => { + const th = document.createElement("th"); + th.textContent = c.label; + head.appendChild(th); + }); + head.appendChild(document.createElement("th")); // remove column + table.appendChild(head); + + function addRow(mapKey, entry) { + const tr = document.createElement("tr"); + columns.forEach((c) => { + const td = document.createElement("td"); + let input; + if (c.type === "bool") { + input = document.createElement("input"); + input.type = "checkbox"; + input.checked = !!entry[c.key]; + input.addEventListener("change", () => { + entry[c.key] = input.checked; + setDirty(true); + }); + } else { + input = document.createElement("input"); + input.type = "text"; + input.value = entry[c.key] == null ? "" : entry[c.key]; + input.addEventListener("input", () => { + entry[c.key] = input.value; + // Keep the map key in sync with the Name field so the TOML + // table key matches what the operator typed. + if (c.key === "name") rekey(input.value, entry, tr); + setDirty(true); + }); + } + td.appendChild(input); + tr.appendChild(td); + }); + const rmTd = document.createElement("td"); + const rm = document.createElement("button"); + rm.textContent = "Remove"; + rm.addEventListener("click", () => { + delete volumes[tr.dataset.key]; + tr.remove(); + setDirty(true); + }); + rmTd.appendChild(rm); + tr.appendChild(rmTd); + tr.dataset.key = mapKey; + table.appendChild(tr); + } + + function rekey(newName, entry, tr) { + const key = newName.trim(); + if (!key || key === tr.dataset.key) return; + delete volumes[tr.dataset.key]; + volumes[key] = entry; + tr.dataset.key = key; + } + + Object.keys(volumes).forEach((k) => { + const entry = volumes[k]; + if (!entry.name) entry.name = k; + addRow(k, entry); + }); + + const add = document.createElement("button"); + add.textContent = "Add " + (section === "AFP" ? "volume" : "share"); + add.addEventListener("click", () => { + let key = "New" + (Object.keys(volumes).length + 1); + while (volumes[key]) key += "_"; + const entry = { name: key }; + columns.forEach((c) => { + if (c.default !== undefined) entry[c.key] = c.default; + }); + volumes[key] = entry; + addRow(key, entry); + setDirty(true); + }); + + fs.appendChild(table); + fs.appendChild(add); + return fs; } function renderField(cfg, f) { From b0211d06f9f86a29973b2f94d05225731cc2fda1 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Thu, 4 Jun 2026 22:14:45 +1000 Subject: [PATCH 07/23] @ webui: add packet-dump and pcap capture options to the config editor The config editor gains a Packet Dump & Capture panel: parse-packets, log-traffic, parse-output file, and per-transport pcap capture paths (localtalk/ethertalk/ipx/netbeui) plus snap length. These already live in the config model, so editing and Apply rebuilds the stack with the new settings. - Traffic logging (LogTraffic) is now wired by the Supervisor from config instead of once in main, so toggling it from the UI takes effect on Apply (disabling clears the netlog sink). - The Router status unit reports parse_packets / log_traffic state and a summary of which transports have an active capture. Verified end-to-end: toggling parse_packets on and adding an ethertalk capture path via the API + Apply flips the Router status and attaches the capture sink; the web UI stays up across Apply. Co-Authored-By: Claude Opus 4.8 @ --- README.md | 14 ++++++---- cmd/classicstack/main.go | 5 ++-- cmd/classicstack/supervisor.go | 47 +++++++++++++++++++++++++++++++++- service/webui/assets/app.js | 13 ++++++++++ 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3cf4c86..b75d81e 100644 --- a/README.md +++ b/README.md @@ -260,11 +260,15 @@ Equivalent flags: `-webui-enabled`, `-webui-bind`, `-webui-tls`, From the dashboard you can **start, stop, and restart** the standalone services (IPX, NetBEUI, NetBIOS, SMB) live; stops are dependency-aware (stopping NetBIOS -also stops SMB). The configuration editor stages edits in memory; **Apply** -re-wires the running stack, **Save** writes `server.toml` (backing up the prior -file to `server.toml.NNNN` and dropping comments), and **Download backup** -exports the current config. The same operations are exposed by the -transport-agnostic `pkg/control` API, so a future text/telnet UI can reuse them. +also stops SMB). The configuration editor can edit scalar settings, **add / +update / remove AFP volumes and SMB shares**, and toggle **packet-dump and pcap +capture** options (parse-packets, traffic logging, and per-transport capture +file paths). Edits stage in memory; **Apply** re-wires the running stack (the web +UI server is preserved across the rebuild), **Save** writes `server.toml` +(backing up the prior file to `server.toml.NNNN` and dropping comments), and +**Download backup** exports the current config. The same operations are exposed +by the transport-agnostic `pkg/control` API, so a future text/telnet UI can +reuse them. ## Useful commands diff --git a/cmd/classicstack/main.go b/cmd/classicstack/main.go index a23ee39..a64511e 100644 --- a/cmd/classicstack/main.go +++ b/cmd/classicstack/main.go @@ -270,9 +270,8 @@ func main() { logging.SetDefault(rootLogger) netlog.SetLogger(rootLogger) - if cfg.LogTraffic { - netlog.SetLogFunc(func(s string) { netlog.Debug("%s", s) }) - } + // Traffic logging (LogTraffic) is wired by the Supervisor from config so + // it can be toggled live from the UI; main only sets up the logger. cfg.Bridge.Mode = strings.ToLower(strings.TrimSpace(cfg.Bridge.Mode)) switch cfg.Bridge.Mode { diff --git a/cmd/classicstack/supervisor.go b/cmd/classicstack/supervisor.go index 45554fa..880c525 100644 --- a/cmd/classicstack/supervisor.go +++ b/cmd/classicstack/supervisor.go @@ -4,10 +4,12 @@ import ( "context" "fmt" "path/filepath" + "sort" "strings" "sync" "github.com/ObsoleteMadness/ClassicStack/config" + "github.com/ObsoleteMadness/ClassicStack/netlog" "github.com/ObsoleteMadness/ClassicStack/pkg/hwaddr" "github.com/ObsoleteMadness/ClassicStack/pkg/status" "github.com/ObsoleteMadness/ClassicStack/port" @@ -109,6 +111,14 @@ func (s *Supervisor) build() error { s.router = router.New("router", ports, services) + // Traffic logging is driven by config so toggling it from the UI takes + // effect on Apply. Disabling clears the sink. + if s.cfg.LogTraffic { + netlog.SetLogFunc(func(line string) { netlog.Debug("%s", line) }) + } else { + netlog.SetLogFunc(nil) + } + if s.cfg.ParsePackets { dumper, cleanup, err := newPacketDumper(s.cfg.ParseOutput) if err != nil { @@ -215,7 +225,12 @@ func (s *Supervisor) buildServices() ([]service.Service, error) { zip.NewRespondingService(), zip.NewSendingService(), } - s.registerServiceStatus("Router", true, map[string]string{"zone": cfg.EtherTalk.SeedZone}) + s.registerServiceStatus("Router", true, map[string]string{ + "zone": cfg.EtherTalk.SeedZone, + "parse_packets": boolStr(cfg.ParsePackets), + "log_traffic": boolStr(cfg.LogTraffic), + "captures": s.activeCaptureSummary(), + }) macIP, err := wireMacIP(MacIPConfig{ Enabled: cfg.MacIPEnabled, @@ -533,6 +548,36 @@ func (s *Supervisor) AddExternalHook(name string, h hook, enabled bool) { s.reg.Set(status.Unit{Name: name, Kind: status.KindHook, Enabled: enabled}) } +// boolStr renders a bool as "on"/"off" for status properties. +func boolStr(b bool) string { + if b { + return "on" + } + return "off" +} + +// activeCaptureSummary lists the transports with an active pcap capture +// path configured, for the dashboard's packet-dump status. +func (s *Supervisor) activeCaptureSummary() string { + c := s.cfg.Capture + var active []string + for name, path := range map[string]string{ + "localtalk": c.LocalTalk, + "ethertalk": c.EtherTalk, + "ipx": c.IPX, + "netbeui": c.NetBEUI, + } { + if strings.TrimSpace(path) != "" { + active = append(active, name) + } + } + if len(active) == 0 { + return "none" + } + sort.Strings(active) + return strings.Join(active, ",") +} + func (s *Supervisor) closeSinks() { for _, c := range s.captureSinks { _ = c.Close() diff --git a/service/webui/assets/app.js b/service/webui/assets/app.js index 70ba233..ea05b4a 100644 --- a/service/webui/assets/app.js +++ b/service/webui/assets/app.js @@ -195,6 +195,19 @@ const CONFIG_PANELS = [ { label: "NBT Binding", path: "SMB.nbt_binding", type: "text" }, ], }, + { + title: "Packet Dump & Capture", + fields: [ + { label: "Parse packets", path: "Logging.parse_packets", type: "bool" }, + { label: "Log traffic", path: "Logging.log_traffic", type: "bool" }, + { label: "Parse output file", path: "Logging.parse_output", type: "text" }, + { label: "LocalTalk pcap", path: "Capture.localtalk", type: "text" }, + { label: "EtherTalk pcap", path: "Capture.ethertalk", type: "text" }, + { label: "IPX pcap", path: "Capture.ipx", type: "text" }, + { label: "NetBEUI pcap", path: "Capture.netbeui", type: "text" }, + { label: "Snap length", path: "Capture.snaplen", type: "number" }, + ], + }, { title: "Web UI", fields: [ From c33a70592fa1da5eed3c391cdac3a055814d74d7 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Thu, 4 Jun 2026 22:45:42 +1000 Subject: [PATCH 08/23] webui: add live log viewer (ring buffer + SSE) Add a Logs tab to the management UI that replays recent log history on open then streams new lines live, with a client-side level filter. - pkg/logbuf: in-memory ring buffer that is both a slog.Handler and a fan-out broadcaster (mirrors the stats broadcaster); untagged so a future TUI can read logs too. - pkg/logging: Options.Extra appends extra slog.Handlers to the fanout, letting the root logger tee records into the ring buffer. - pkg/control: LogHistory()/SubscribeLogs() on the plane, Logs dep (defaults to logbuf.Default). - service/webui: GET /api/logs (history) and GET /api/logs/stream (SSE), reusing the stats-stream SSE pattern; Logs tab in the SPA. - cmd/classicstack: install logbuf.NewHandler on the root logger. Builds clean for no-tags / webui / all; go vet -tags all and full test suite pass. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 1 + README.md | 8 +- cmd/classicstack/main.go | 5 + cmd/classicstack/mainwiring.go | 2 + pkg/control/control.go | 14 ++- pkg/control/control_test.go | 33 ++++++ pkg/control/logs.go | 13 +++ pkg/logbuf/logbuf.go | 184 ++++++++++++++++++++++++++++++++ pkg/logbuf/logbuf_test.go | 106 ++++++++++++++++++ pkg/logging/logging.go | 8 +- pkg/logging/logging_test.go | 30 ++++++ service/webui/api.go | 2 + service/webui/assets/app.css | 28 +++++ service/webui/assets/app.js | 66 ++++++++++++ service/webui/assets/index.html | 18 ++++ service/webui/logs.go | 84 +++++++++++++++ service/webui/plane.go | 3 + 17 files changed, 598 insertions(+), 7 deletions(-) create mode 100644 pkg/control/logs.go create mode 100644 pkg/logbuf/logbuf.go create mode 100644 pkg/logbuf/logbuf_test.go create mode 100644 service/webui/logs.go diff --git a/CLAUDE.md b/CLAUDE.md index 7f006ef..9520cfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,7 @@ cmd/classicstack/main.go → Ports → Router → Services | `pkg/control/` | Transport-agnostic management API (status, config stage/apply/save, service start/stop/restart, diagnostics); the single contract every UI front-end shares | | `pkg/status/` | In-process service-status registry read by the dashboard | | `pkg/metrics/` | Streaming stats hub (expvar + SSE sinks) | +| `pkg/logbuf/` | In-memory log ring buffer + `slog.Handler` + broadcaster feeding the web UI log viewer (installed via `logging.Options.Extra`) | | `pkg/serialport/` | Per-OS serial-port enumeration for the TashTalk dropdown | | `config/` | Config loader plus `Model` (in-memory, editable, serialisable view of `server.toml` with numbered-backup Save) | | `netlog/` | Structured logger with debug/info/warn levels | diff --git a/README.md b/README.md index b75d81e..fa3c924 100644 --- a/README.md +++ b/README.md @@ -243,8 +243,8 @@ AFP volumes are configured as [AFP.Volumes.] sections. A management web UI is available in builds that include `-tags webui` (which `-tags all` does). It serves a dashboard showing per-service status, bindings, -and live (SSE-streamed) statistics, plus a configuration editor and read-only -diagnostics (zone/network enumeration). +and live (SSE-streamed) statistics, plus a configuration editor, read-only +diagnostics (zone/network enumeration), and a live **log viewer**. [WebUI]: @@ -263,7 +263,9 @@ From the dashboard you can **start, stop, and restart** the standalone services also stops SMB). The configuration editor can edit scalar settings, **add / update / remove AFP volumes and SMB shares**, and toggle **packet-dump and pcap capture** options (parse-packets, traffic logging, and per-transport capture -file paths). Edits stage in memory; **Apply** re-wires the running stack (the web +file paths). The **Logs** tab streams the server's log output live (recent +history is replayed on open, then new lines append) with a client-side level +filter. Edits stage in memory; **Apply** re-wires the running stack (the web UI server is preserved across the rebuild), **Save** writes `server.toml` (backing up the prior file to `server.toml.NNNN` and dropping comments), and **Download backup** exports the current config. The same operations are exposed diff --git a/cmd/classicstack/main.go b/cmd/classicstack/main.go index a64511e..eaacb44 100644 --- a/cmd/classicstack/main.go +++ b/cmd/classicstack/main.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "log" + "log/slog" "os" "os/signal" "runtime" @@ -13,6 +14,7 @@ import ( "github.com/ObsoleteMadness/ClassicStack/config" "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/logbuf" "github.com/ObsoleteMadness/ClassicStack/pkg/logging" "github.com/ObsoleteMadness/ClassicStack/port/rawlink" ) @@ -266,6 +268,9 @@ func main() { slogLevel, _ := logging.ParseLevel(cfg.LogLevel) rootLogger := logging.New("ClassicStack", logging.Options{ Sinks: []logging.Sink{{Writer: os.Stderr, Format: logging.FormatConsole, Level: slogLevel}}, + // Tee every record into the in-memory ring buffer so the management + // plane / web UI log viewer can replay recent history and stream live. + Extra: []slog.Handler{logbuf.NewHandler(logbuf.Default, slogLevel)}, }) logging.SetDefault(rootLogger) netlog.SetLogger(rootLogger) diff --git a/cmd/classicstack/mainwiring.go b/cmd/classicstack/mainwiring.go index dd88ff3..71592ff 100644 --- a/cmd/classicstack/mainwiring.go +++ b/cmd/classicstack/mainwiring.go @@ -3,6 +3,7 @@ package main import ( "github.com/ObsoleteMadness/ClassicStack/config" "github.com/ObsoleteMadness/ClassicStack/pkg/control" + "github.com/ObsoleteMadness/ClassicStack/pkg/logbuf" "github.com/ObsoleteMadness/ClassicStack/pkg/metrics" "github.com/ObsoleteMadness/ClassicStack/pkg/status" ) @@ -79,6 +80,7 @@ func newControlPlane(sup *Supervisor, model *config.Model, configPath string) *c Supervisor: sup, Registry: status.Default, Hub: metrics.Default, + Logs: logbuf.Default, Config: model, ConfigPath: configPath, }) diff --git a/pkg/control/control.go b/pkg/control/control.go index d976851..eb29ef4 100644 --- a/pkg/control/control.go +++ b/pkg/control/control.go @@ -13,6 +13,7 @@ import ( "context" "sync" + "github.com/ObsoleteMadness/ClassicStack/pkg/logbuf" "github.com/ObsoleteMadness/ClassicStack/pkg/metrics" "github.com/ObsoleteMadness/ClassicStack/pkg/serialport" "github.com/ObsoleteMadness/ClassicStack/pkg/status" @@ -49,9 +50,10 @@ type ConfigModel interface { // Plane is the management API. It owns the live and staged config models // and the dirty flag, and delegates lifecycle actions to the Supervisor. type Plane struct { - sup Supervisor - reg *status.Registry - hub *metrics.Hub + sup Supervisor + reg *status.Registry + hub *metrics.Hub + logs *logbuf.Buffer mu sync.Mutex live ConfigModel @@ -67,6 +69,7 @@ type Deps struct { Supervisor Supervisor Registry *status.Registry // defaults to status.Default when nil Hub *metrics.Hub // defaults to metrics.Default when nil + Logs *logbuf.Buffer // defaults to logbuf.Default when nil Config ConfigModel // the live config at startup ConfigPath string // file Save writes to ("" = Save disabled) } @@ -81,10 +84,15 @@ func New(d Deps) *Plane { if hub == nil { hub = metrics.Default } + logs := d.Logs + if logs == nil { + logs = logbuf.Default + } return &Plane{ sup: d.Supervisor, reg: reg, hub: hub, + logs: logs, live: d.Config, path: d.ConfigPath, } diff --git a/pkg/control/control_test.go b/pkg/control/control_test.go index f5f9668..acf7401 100644 --- a/pkg/control/control_test.go +++ b/pkg/control/control_test.go @@ -3,6 +3,9 @@ package control import ( "context" "testing" + "time" + + "github.com/ObsoleteMadness/ClassicStack/pkg/logbuf" ) // fakeModel is a minimal ConfigModel for lifecycle tests. @@ -87,6 +90,36 @@ func TestSaveWithoutPath(t *testing.T) { } } +func TestLogHistoryAndSubscribe(t *testing.T) { + buf := logbuf.New(8) + p := New(Deps{Config: &fakeModel{}, Logs: buf}) + + buf.Append(logbuf.Entry{Message: "first"}) + + hist := p.LogHistory() + if len(hist) != 1 || hist[0].Message != "first" { + t.Fatalf("LogHistory = %+v, want [first]", hist) + } + + ch, cancel := p.SubscribeLogs() + defer cancel() + buf.Append(logbuf.Entry{Message: "live"}) + select { + case e := <-ch: + if e.Message != "live" { + t.Fatalf("subscriber got %q, want live", e.Message) + } + case <-time.After(time.Second): + t.Fatal("log subscriber did not receive entry") + } +} + +func TestLogHistoryDefaultsToGlobal(t *testing.T) { + p := New(Deps{Config: &fakeModel{}}) + // Should not panic and should return the global buffer's snapshot. + _ = p.LogHistory() +} + func TestDiagnosticsFallback(t *testing.T) { p := New(Deps{Config: &fakeModel{}}) if _, err := p.Diagnostics().ListZones(context.Background()); err != ErrDiagUnavailable { diff --git a/pkg/control/logs.go b/pkg/control/logs.go new file mode 100644 index 0000000..56a732f --- /dev/null +++ b/pkg/control/logs.go @@ -0,0 +1,13 @@ +package control + +import "github.com/ObsoleteMadness/ClassicStack/pkg/logbuf" + +// LogHistory returns the retained recent log entries oldest-first, for the +// initial load of a log viewer. +func (p *Plane) LogHistory() []logbuf.Entry { return p.logs.Snapshot() } + +// SubscribeLogs registers a log subscriber and returns the receive channel +// plus a cancel func that unsubscribes and closes the channel. New entries +// are pushed as they are logged; the caller typically sends LogHistory() +// first, then streams these. +func (p *Plane) SubscribeLogs() (<-chan logbuf.Entry, func()) { return p.logs.Subscribe() } diff --git a/pkg/logbuf/logbuf.go b/pkg/logbuf/logbuf.go new file mode 100644 index 0000000..877c56e --- /dev/null +++ b/pkg/logbuf/logbuf.go @@ -0,0 +1,184 @@ +// Package logbuf is an in-memory ring buffer of recent log records plus a +// live broadcaster, used by the management plane to serve a log viewer. +// +// A Buffer is both a slog.Handler (installed alongside the console sink via +// pkg/logging's Options.Extra) and a fan-out source: it retains the most +// recent entries for an initial history load and pushes new entries to any +// SSE/TUI subscribers. It is untagged so the control plane (and a future +// text UI) can read logs in every build variant; only the HTTP front-end is +// build-tag gated. +package logbuf + +import ( + "context" + "log/slog" + "strings" + "sync" + "time" +) + +// DefaultCapacity is the number of entries Default retains. +const DefaultCapacity = 500 + +// Entry is a single captured log record. +type Entry struct { + UnixMilli int64 `json:"t"` + Level string `json:"level"` + Message string `json:"msg"` +} + +// Buffer retains the most recent log entries in a ring and fans new entries +// out to subscribers. The zero value is not usable; construct with New. +type Buffer struct { + mu sync.Mutex + ring []Entry // len == cap once filled; head/count track the window + head int // index of the oldest entry + count int // number of valid entries in ring + subs map[int]chan Entry + nextSubID int +} + +// New returns a Buffer retaining up to capacity entries (clamped to >= 1). +func New(capacity int) *Buffer { + if capacity < 1 { + capacity = 1 + } + return &Buffer{ + ring: make([]Entry, capacity), + subs: make(map[int]chan Entry), + } +} + +// Default is the process-global buffer the control plane reads by default. +var Default = New(DefaultCapacity) + +// Append stores e in the ring (evicting the oldest entry when full) and +// fans it out to subscribers without blocking; entries are dropped for slow +// subscribers, matching the stats broadcaster. +func (b *Buffer) Append(e Entry) { + b.mu.Lock() + if b.count < len(b.ring) { + b.ring[(b.head+b.count)%len(b.ring)] = e + b.count++ + } else { + b.ring[b.head] = e + b.head = (b.head + 1) % len(b.ring) + } + subs := make([]chan Entry, 0, len(b.subs)) + for _, ch := range b.subs { + subs = append(subs, ch) + } + b.mu.Unlock() + + for _, ch := range subs { + select { + case ch <- e: + default: // drop for slow subscribers + } + } +} + +// Snapshot returns the retained entries oldest-first. +func (b *Buffer) Snapshot() []Entry { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]Entry, b.count) + for i := range b.count { + out[i] = b.ring[(b.head+i)%len(b.ring)] + } + return out +} + +// Subscribe registers a subscriber and returns its receive channel plus a +// cancel func that unsubscribes and closes the channel. +func (b *Buffer) Subscribe() (<-chan Entry, func()) { + b.mu.Lock() + defer b.mu.Unlock() + id := b.nextSubID + b.nextSubID++ + ch := make(chan Entry, 64) + b.subs[id] = ch + return ch, func() { + b.mu.Lock() + defer b.mu.Unlock() + if c, ok := b.subs[id]; ok { + delete(b.subs, id) + close(c) + } + } +} + +// Handler is a slog.Handler that records each emitted log record into a +// Buffer. Install it on the root logger via pkg/logging's Options.Extra so +// every line is captured for the log viewer in addition to its normal sink. +type Handler struct { + buf *Buffer + level slog.Level + attrs []slog.Attr +} + +// NewHandler returns a Handler appending records at or above level to buf. +func NewHandler(buf *Buffer, level slog.Level) *Handler { + return &Handler{buf: buf, level: level} +} + +// Enabled reports whether records at l should be captured. +func (h *Handler) Enabled(_ context.Context, l slog.Level) bool { + return l >= h.level +} + +// Handle records r into the buffer. The "source" attribute is lifted into a +// bracketed prefix (matching the console handler); remaining attributes are +// rendered as key=value pairs appended to the message. +func (h *Handler) Handle(_ context.Context, r slog.Record) error { + source := "" + var sb strings.Builder + + emit := func(a slog.Attr) { + if a.Key == "source" { + source = a.Value.String() + return + } + sb.WriteByte(' ') + sb.WriteString(a.Key) + sb.WriteByte('=') + sb.WriteString(a.Value.String()) + } + for _, a := range h.attrs { + emit(a) + } + r.Attrs(func(a slog.Attr) bool { + emit(a) + return true + }) + + var msg strings.Builder + if source != "" { + msg.WriteByte('[') + msg.WriteString(source) + msg.WriteString("] ") + } + msg.WriteString(r.Message) + msg.WriteString(sb.String()) + + ts := r.Time + if ts.IsZero() { + ts = time.Now() + } + h.buf.Append(Entry{ + UnixMilli: ts.UnixMilli(), + Level: r.Level.String(), + Message: msg.String(), + }) + return nil +} + +// WithAttrs returns a handler that prepends attrs to every record it handles. +func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { + clone := *h + clone.attrs = append(append([]slog.Attr{}, h.attrs...), attrs...) + return &clone +} + +// WithGroup is a no-op for this flat handler; groups are not rendered. +func (h *Handler) WithGroup(string) slog.Handler { return h } diff --git a/pkg/logbuf/logbuf_test.go b/pkg/logbuf/logbuf_test.go new file mode 100644 index 0000000..b86cf3a --- /dev/null +++ b/pkg/logbuf/logbuf_test.go @@ -0,0 +1,106 @@ +package logbuf + +import ( + "context" + "log/slog" + "testing" + "time" +) + +func TestSnapshotOrderingAndEviction(t *testing.T) { + b := New(3) + for i := range 5 { + b.Append(Entry{UnixMilli: int64(i), Message: string(rune('a' + i))}) + } + got := b.Snapshot() + if len(got) != 3 { + t.Fatalf("snapshot len = %d, want 3", len(got)) + } + // Oldest two ("a","b") evicted; expect c, d, e oldest-first. + want := []string{"c", "d", "e"} + for i, e := range got { + if e.Message != want[i] { + t.Errorf("entry %d = %q, want %q", i, e.Message, want[i]) + } + } +} + +func TestSubscribeReceivesAppended(t *testing.T) { + b := New(8) + ch, cancel := b.Subscribe() + defer cancel() + + b.Append(Entry{Message: "hello"}) + select { + case e := <-ch: + if e.Message != "hello" { + t.Fatalf("got %q, want hello", e.Message) + } + case <-time.After(time.Second): + t.Fatal("subscriber did not receive entry") + } +} + +func TestSlowSubscriberDoesNotBlock(t *testing.T) { + b := New(8) + // Subscribe but never drain; Append must not block once the channel fills. + _, cancel := b.Subscribe() + defer cancel() + + done := make(chan struct{}) + go func() { + for range 1000 { + b.Append(Entry{Message: "x"}) + } + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Append blocked on a slow subscriber") + } +} + +func TestCancelUnsubscribes(t *testing.T) { + b := New(8) + ch, cancel := b.Subscribe() + cancel() + b.Append(Entry{Message: "after-cancel"}) + if _, ok := <-ch; ok { + t.Fatal("channel should be closed and drained after cancel") + } +} + +func TestHandlerCapturesRecords(t *testing.T) { + b := New(8) + h := NewHandler(b, slog.LevelInfo) + l := slog.New(h).With("source", "AFP") + l.Info("volume opened", "name", "Public") + + got := b.Snapshot() + if len(got) != 1 { + t.Fatalf("snapshot len = %d, want 1", len(got)) + } + e := got[0] + if e.Level != slog.LevelInfo.String() { + t.Errorf("level = %q, want %q", e.Level, slog.LevelInfo.String()) + } + if want := "[AFP] volume opened name=Public"; e.Message != want { + t.Errorf("message = %q, want %q", e.Message, want) + } +} + +func TestHandlerRespectsLevel(t *testing.T) { + b := New(8) + h := NewHandler(b, slog.LevelWarn) + if h.Enabled(context.Background(), slog.LevelInfo) { + t.Fatal("info should be disabled at warn level") + } + l := slog.New(h) + l.Info("dropped") + l.Warn("kept") + got := b.Snapshot() + if len(got) != 1 || got[0].Message != "kept" { + t.Fatalf("snapshot = %+v, want only 'kept'", got) + } +} diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go index 9bcd0ab..2a96333 100644 --- a/pkg/logging/logging.go +++ b/pkg/logging/logging.go @@ -45,6 +45,11 @@ type Options struct { // Sinks listed here receive every record the root logger emits. If // empty, a single console sink at LevelInfo on stderr is used. Sinks []Sink + // Extra are additional handlers appended to the fanout alongside the + // sink-derived ones. Use this to tee records into in-process consumers + // such as the management log buffer (pkg/logbuf) without writing to an + // io.Writer. A nil slice preserves the prior behaviour. + Extra []slog.Handler // Color enables ANSI colouring of the level tag in console output. The // zero value is "off"; callers that want auto-detection should pass // term.IsTerminal(int(os.Stderr.Fd())). @@ -60,10 +65,11 @@ func New(source string, opts Options) *slog.Logger { if len(sinks) == 0 { sinks = []Sink{{Writer: os.Stderr, Format: FormatConsole, Level: slog.LevelInfo}} } - handlers := make([]slog.Handler, 0, len(sinks)) + handlers := make([]slog.Handler, 0, len(sinks)+len(opts.Extra)) for _, s := range sinks { handlers = append(handlers, newHandler(s, opts.Color)) } + handlers = append(handlers, opts.Extra...) var h slog.Handler if len(handlers) == 1 { h = handlers[0] diff --git a/pkg/logging/logging_test.go b/pkg/logging/logging_test.go index acac9c1..8b33c99 100644 --- a/pkg/logging/logging_test.go +++ b/pkg/logging/logging_test.go @@ -106,6 +106,36 @@ func TestChildReplacesSource(t *testing.T) { } } +// captureHandler is a minimal slog.Handler that records messages, used to +// verify Options.Extra tees records into additional consumers. +type captureHandler struct{ msgs *[]string } + +func (h captureHandler) Enabled(context.Context, slog.Level) bool { return true } +func (h captureHandler) Handle(_ context.Context, r slog.Record) error { + *h.msgs = append(*h.msgs, r.Message) + return nil +} +func (h captureHandler) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h captureHandler) WithGroup(string) slog.Handler { return h } + +func TestExtraHandlerReceivesRecords(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var captured []string + l := New("Router", Options{ + Sinks: []Sink{{Writer: &buf, Format: FormatConsole, Level: slog.LevelInfo}}, + Extra: []slog.Handler{captureHandler{msgs: &captured}}, + }) + l.Info("route added") + + if !strings.Contains(buf.String(), "route added") { + t.Fatalf("normal sink missed record: %q", buf.String()) + } + if len(captured) != 1 || captured[0] != "route added" { + t.Fatalf("extra handler captured = %v, want [route added]", captured) + } +} + func TestParseLevel(t *testing.T) { t.Parallel() cases := map[string]slog.Level{ diff --git a/service/webui/api.go b/service/webui/api.go index 5d2414f..98d0411 100644 --- a/service/webui/api.go +++ b/service/webui/api.go @@ -23,6 +23,8 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/config/download", s.handleDownload) s.mux.HandleFunc("/api/services/", s.handleServiceAction) s.mux.HandleFunc("/api/stats/stream", s.handleStatsStream) + s.mux.HandleFunc("/api/logs", s.handleLogHistory) + s.mux.HandleFunc("/api/logs/stream", s.handleLogStream) s.registerDiagnosticRoutes() } diff --git a/service/webui/assets/app.css b/service/webui/assets/app.css index 4f2c4dd..4b1c51c 100644 --- a/service/webui/assets/app.css +++ b/service/webui/assets/app.css @@ -145,3 +145,31 @@ button.primary { background: var(--accent); color: var(--accent-text); border-co .diag-tools { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } .diag-tools .aep input { width: 70px; } + +/* ---- logs ---- */ +.log-controls { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; + align-items: center; + margin-bottom: 0.6rem; +} +.log-status { color: #8aa0b6; font-size: 0.85rem; } +.log-follow { font-size: 0.9rem; } +.log-output { + background: #0e1116; + color: #cfe8ff; + padding: 0.6rem 0.8rem; + border-radius: 8px; + height: 70vh; + overflow-y: auto; + font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.82rem; + line-height: 1.4; +} +.log-line { white-space: pre-wrap; word-break: break-word; } +.log-line.hidden { display: none; } +.log-debug { color: #8a93a0; } +.log-info { color: #cfe8ff; } +.log-warn { color: #f0c674; } +.log-error { color: #ff6b6b; } diff --git a/service/webui/assets/app.js b/service/webui/assets/app.js index ea05b4a..658f664 100644 --- a/service/webui/assets/app.js +++ b/service/webui/assets/app.js @@ -19,6 +19,8 @@ $$(".tab").forEach((btn) => { btn.classList.add("active"); $("#" + btn.dataset.tab).classList.add("active"); if (btn.dataset.tab === "config") loadConfig(); + if (btn.dataset.tab === "logs") startLogs(); + else stopLogs(); }); }); @@ -119,6 +121,70 @@ function sumRatesForUnit(unit) { return found ? sum : null; } +// ---- logs ---- +// The log viewer opens an SSE stream when its tab is active and closes it on +// leave. The server replays recent history first, then streams live lines. +// Rendering is capped to keep the DOM bounded; level filtering is client-side. +const LOG_MAX_LINES = 1000; +const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }; +let logSource = null; // active EventSource, or null when the tab is inactive + +function startLogs() { + if (logSource) return; // already streaming + const out = $("#log-output"); + out.textContent = ""; + setLogStatus("connecting…"); + logSource = new EventSource("/api/logs/stream"); + logSource.onopen = () => setLogStatus("streaming"); + logSource.onmessage = (ev) => { + try { + appendLogEntry(JSON.parse(ev.data)); + } catch (_) {} + }; + logSource.onerror = () => setLogStatus("reconnecting…"); +} + +function stopLogs() { + if (!logSource) return; + logSource.close(); + logSource = null; + setLogStatus("disconnected"); +} + +function setLogStatus(s) { + $("#log-status").textContent = s; +} + +function appendLogEntry(entry) { + const out = $("#log-output"); + const minLevel = LOG_LEVELS[$("#log-level-filter").value] ?? 0; + const level = (entry.level || "INFO").toUpperCase(); + const line = document.createElement("div"); + line.className = "log-line log-" + level.toLowerCase(); + line.dataset.level = level; + const ts = entry.t ? new Date(entry.t).toLocaleTimeString() : ""; + line.textContent = `${ts} ${level.padEnd(5)} ${entry.msg || ""}`; + if ((LOG_LEVELS[level] ?? 1) < minLevel) line.classList.add("hidden"); + out.appendChild(line); + + while (out.childElementCount > LOG_MAX_LINES) out.removeChild(out.firstChild); + + if ($("#log-follow").checked) out.scrollTop = out.scrollHeight; +} + +// Re-apply the level filter to already-rendered lines. +$("#log-level-filter").addEventListener("change", () => { + const minLevel = LOG_LEVELS[$("#log-level-filter").value] ?? 0; + $$("#log-output .log-line").forEach((el) => { + const lvl = LOG_LEVELS[el.dataset.level] ?? 1; + el.classList.toggle("hidden", lvl < minLevel); + }); +}); + +$("#btn-log-clear").addEventListener("click", () => { + $("#log-output").textContent = ""; +}); + // ---- configuration editor ---- async function loadConfig() { try { diff --git a/service/webui/assets/index.html b/service/webui/assets/index.html index 5ce2618..8951bdd 100644 --- a/service/webui/assets/index.html +++ b/service/webui/assets/index.html @@ -13,6 +13,7 @@

ClassicStack

+ @@ -49,6 +50,23 @@

ClassicStack


     
+
+    
+
+ + disconnected + + +
+

+    
diff --git a/service/webui/logs.go b/service/webui/logs.go new file mode 100644 index 0000000..7d76a36 --- /dev/null +++ b/service/webui/logs.go @@ -0,0 +1,84 @@ +//go:build webui || all + +package webui + +import ( + "encoding/json" + "net/http" +) + +// handleLogHistory returns the retained recent log entries (oldest-first) as +// a JSON array, for clients that want a one-shot fetch rather than the stream. +func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeJSON(w, http.StatusOK, []any{}) + return + } + writeJSON(w, http.StatusOK, s.opts.Plane.LogHistory()) +} + +// handleLogStream is a Server-Sent Events endpoint that first replays the +// retained log history, then streams new entries as they are logged. It +// mirrors handleStatsStream: subscribe up front so no entry is missed between +// the snapshot and the live stream, then drain history, then forward. +func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, errNoFlush) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + // Subscribe before snapshotting so an entry logged in the gap is captured + // by the live channel rather than lost. The subscriber's buffer absorbs + // any overlap; duplicates are harmless for a log view. + entries, cancel := s.opts.Plane.SubscribeLogs() + defer cancel() + + for _, e := range s.opts.Plane.LogHistory() { + if !writeLogEvent(w, e) { + return + } + } + flusher.Flush() + + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case e, ok := <-entries: + if !ok { + return + } + if !writeLogEvent(w, e) { + return + } + flusher.Flush() + } + } +} + +// writeLogEvent marshals one entry as an SSE "data:" frame, returning false +// on write error so the caller can stop. +func writeLogEvent(w http.ResponseWriter, e any) bool { + payload, err := json.Marshal(e) + if err != nil { + return true // skip this entry, keep the stream alive + } + if _, err := w.Write([]byte("data: ")); err != nil { + return false + } + if _, err := w.Write(payload); err != nil { + return false + } + _, err = w.Write([]byte("\n\n")) + return err == nil +} diff --git a/service/webui/plane.go b/service/webui/plane.go index 5dd989c..363307e 100644 --- a/service/webui/plane.go +++ b/service/webui/plane.go @@ -6,6 +6,7 @@ import ( "context" "github.com/ObsoleteMadness/ClassicStack/pkg/control" + "github.com/ObsoleteMadness/ClassicStack/pkg/logbuf" "github.com/ObsoleteMadness/ClassicStack/pkg/serialport" "github.com/ObsoleteMadness/ClassicStack/pkg/status" ) @@ -26,5 +27,7 @@ type ControlPlane interface { ListInterfaces() ([]string, error) ListSerialPorts() ([]serialport.Info, error) Subscribe() (<-chan control.Frame, func()) + LogHistory() []logbuf.Entry + SubscribeLogs() (<-chan logbuf.Entry, func()) Diagnostics() control.Diagnostics } From 4f73678d49bcd7332445085cb326659f09370424 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Fri, 5 Jun 2026 16:53:50 +1000 Subject: [PATCH 09/23] webui: daemon/service wrappers, restart-safe lifecycle, and config editor Relocate the run-core from cmd/classicstack (package main) into internal/app so the interactive binary, the Windows service wrapper (cmd/classicstack-svc), and the Unix/macOS daemon (cmd/classicstackd) share one runtime. Make UI-driven stop/start of pcap-backed protocols safe and restartable: - rawlink: guard pcapLink with a closed flag + ErrClosed so calls after Close return an error instead of a use-after-free (0xC0000005) in pcap_compile. - ports (IPX/NetBEUI/EtherTalk/MacIP): open a fresh link per Start via a LinkFactory and recreate the read-loop channels each cycle, fixing the "close of closed channel" panic on the second stop/start. - ipx router: add UnregisterSocket; RIP/SAP/NetBIOS-over-IPX/SMB-direct now release their sockets on Stop, fixing "ipx: socket already registered". Model NetBEUI/IPX as detachable NetBIOS transports rather than hard dependencies: stopping one detaches just its binding while NetBIOS and SMB keep serving. Add AddTransport/RemoveTransport/Transports to the NetBIOS service and a transport-binding registry in the supervisor. Status summaries are running-aware; remove SMB's phantom :139 (no TCP listener exists) in favour of the real served transports. Web UI: spinner + disabled buttons during start/stop/restart. Config editor: group AFP volumes / SMB shares under their service panels; FS-type and interface dropdowns; friendly interface labels (pcap description, GUID stored) via /api/interfaces + /api/fs-types. Treat [Bridge] as a reusable virtual interface (InterfaceModel) that protocols inherit or override with a per- protocol [Section.Custom] interface; absent Custom keeps existing configs working unchanged. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 + CLAUDE.md | 8 +- Makefile | 22 +- README.md | 53 +++ cmd/classicstack-svc/doc.go | 18 + cmd/classicstack-svc/handler_windows.go | 98 +++++ cmd/classicstack-svc/main_windows.go | 318 ++++++++++++++ cmd/classicstack-svc/stub_other.go | 18 + cmd/classicstack-svc/version.go | 9 + cmd/classicstack/doc.go | 9 +- cmd/classicstack/main.go | 388 +---------------- cmd/classicstack/netbeui_enabled.go | 88 ---- cmd/classicstack/netbios_disabled.go | 24 - cmd/classicstack/netbios_enabled.go | 63 --- cmd/classicstack/version.go | 8 - cmd/classicstackd/doc.go | 22 + cmd/classicstackd/launchd_darwin.go | 109 +++++ cmd/classicstackd/launchd_other.go | 23 + cmd/classicstackd/main_unix.go | 253 +++++++++++ cmd/classicstackd/stub_windows.go | 17 + cmd/classicstackd/version.go | 9 + config/fromsource.go | 19 + config/model.go | 33 +- .../app}/afp_disabled.go | 2 +- .../app}/afp_enabled.go | 2 +- .../classicstack => internal/app}/afp_hook.go | 2 +- .../app}/bridge_config.go | 12 +- {cmd/classicstack => internal/app}/capture.go | 2 +- .../app}/config_afp_test.go | 2 +- .../app}/config_flags.go | 9 +- .../app}/config_ini.go | 23 +- .../app}/config_model.go | 61 ++- .../app}/config_test.go | 2 +- .../app}/diagnostics_impl.go | 2 +- internal/app/doc.go | 13 + .../app}/extension_map.go | 2 +- .../app}/extension_map_test.go | 2 +- internal/app/fstypes_disabled.go | 9 + internal/app/fstypes_enabled.go | 12 + internal/app/interface_resolve_test.go | 85 ++++ .../app}/ipx_disabled.go | 2 +- .../app}/ipx_enabled.go | 65 ++- .../app}/ipx_enabled_test.go | 6 +- .../classicstack => internal/app}/ipx_hook.go | 2 +- .../app}/ipxgw_disabled.go | 2 +- .../app}/ipxgw_enabled.go | 2 +- .../app}/ipxgw_hook.go | 2 +- .../app}/macgarden_register.go | 2 +- .../app}/macip_disabled.go | 2 +- .../app}/macip_enabled.go | 24 +- .../app}/macip_hook.go | 2 +- .../app}/macip_test.go | 2 +- .../app}/main_macip_test.go | 2 +- .../app}/mainwiring.go | 2 +- .../app}/netbeui_disabled.go | 2 +- internal/app/netbeui_enabled.go | 106 +++++ .../app}/netbeui_hook.go | 2 +- internal/app/netbios_disabled.go | 25 ++ internal/app/netbios_enabled.go | 111 +++++ .../app}/netbios_hook.go | 7 +- {cmd/classicstack => internal/app}/netutil.go | 2 +- .../app}/packetdump.go | 2 +- .../app}/rawlink_open.go | 2 +- internal/app/run.go | 412 ++++++++++++++++++ .../app}/shortname_hook.go | 2 +- .../app}/shortname_wire.go | 2 +- .../app}/smb_disabled.go | 3 +- .../app}/smb_enabled.go | 12 +- .../classicstack => internal/app}/smb_hook.go | 15 +- .../app}/smb_shares.go | 2 +- .../app}/supervisor.go | 304 +++++++++++-- .../app}/supervisor_control.go | 40 +- .../app}/supervisor_lifecycle.go | 83 +++- internal/app/transport_bindings_test.go | 189 ++++++++ internal/app/version.go | 11 + .../app}/webui_config.go | 2 +- .../app}/webui_disabled.go | 2 +- .../app}/webui_enabled.go | 2 +- .../app}/webui_hook.go | 2 +- pkg/control/control.go | 33 +- pkg/control/control_test.go | 22 +- port/ethertalk/pcap.go | 38 +- port/ipx/port.go | 114 ++++- port/ipx/port_test.go | 80 ++++ port/netbeui/port.go | 111 ++++- port/netbeui/port_test.go | 34 ++ port/rawlink/pcap.go | 36 +- port/rawlink/pcap_closed_test.go | 37 ++ port/rawlink/rawlink.go | 7 +- router/ipx/router.go | 13 + scripts/ci/build.ps1 | 9 + scripts/ci/build.sh | 9 + scripts/ci/package-release.ps1 | 4 + scripts/ci/package-release.sh | 10 + server.toml | 11 +- service/afp/fs.go | 8 + service/ipx/rip.go | 4 +- service/ipx/sap.go | 4 +- service/ipx/sap_test.go | 30 +- service/macip/macip.go | 36 ++ service/netbios/over_ipx/transport.go | 27 +- service/netbios/over_ipx/transport_test.go | 27 ++ service/netbios/service.go | 160 ++++++- service/netbios/service_test.go | 94 +++- service/smb/over_ipx_direct/transport.go | 16 +- service/webui/api.go | 9 + service/webui/assets/app.css | 34 +- service/webui/assets/app.js | 326 ++++++++++++-- service/webui/plane.go | 3 +- 109 files changed, 3835 insertions(+), 825 deletions(-) create mode 100644 cmd/classicstack-svc/doc.go create mode 100644 cmd/classicstack-svc/handler_windows.go create mode 100644 cmd/classicstack-svc/main_windows.go create mode 100644 cmd/classicstack-svc/stub_other.go create mode 100644 cmd/classicstack-svc/version.go delete mode 100644 cmd/classicstack/netbeui_enabled.go delete mode 100644 cmd/classicstack/netbios_disabled.go delete mode 100644 cmd/classicstack/netbios_enabled.go delete mode 100644 cmd/classicstack/version.go create mode 100644 cmd/classicstackd/doc.go create mode 100644 cmd/classicstackd/launchd_darwin.go create mode 100644 cmd/classicstackd/launchd_other.go create mode 100644 cmd/classicstackd/main_unix.go create mode 100644 cmd/classicstackd/stub_windows.go create mode 100644 cmd/classicstackd/version.go rename {cmd/classicstack => internal/app}/afp_disabled.go (98%) rename {cmd/classicstack => internal/app}/afp_enabled.go (99%) rename {cmd/classicstack => internal/app}/afp_hook.go (99%) rename {cmd/classicstack => internal/app}/bridge_config.go (74%) rename {cmd/classicstack => internal/app}/capture.go (99%) rename {cmd/classicstack => internal/app}/config_afp_test.go (99%) rename {cmd/classicstack => internal/app}/config_flags.go (93%) rename {cmd/classicstack => internal/app}/config_ini.go (92%) rename {cmd/classicstack => internal/app}/config_model.go (74%) rename {cmd/classicstack => internal/app}/config_test.go (99%) rename {cmd/classicstack => internal/app}/diagnostics_impl.go (99%) create mode 100644 internal/app/doc.go rename {cmd/classicstack => internal/app}/extension_map.go (98%) rename {cmd/classicstack => internal/app}/extension_map_test.go (98%) create mode 100644 internal/app/fstypes_disabled.go create mode 100644 internal/app/fstypes_enabled.go create mode 100644 internal/app/interface_resolve_test.go rename {cmd/classicstack => internal/app}/ipx_disabled.go (98%) rename {cmd/classicstack => internal/app}/ipx_enabled.go (69%) rename {cmd/classicstack => internal/app}/ipx_enabled_test.go (93%) rename {cmd/classicstack => internal/app}/ipx_hook.go (98%) rename {cmd/classicstack => internal/app}/ipxgw_disabled.go (95%) rename {cmd/classicstack => internal/app}/ipxgw_enabled.go (98%) rename {cmd/classicstack => internal/app}/ipxgw_hook.go (99%) rename {cmd/classicstack => internal/app}/macgarden_register.go (89%) rename {cmd/classicstack => internal/app}/macip_disabled.go (97%) rename {cmd/classicstack => internal/app}/macip_enabled.go (86%) rename {cmd/classicstack => internal/app}/macip_hook.go (99%) rename {cmd/classicstack => internal/app}/macip_test.go (98%) rename {cmd/classicstack => internal/app}/main_macip_test.go (98%) rename {cmd/classicstack => internal/app}/mainwiring.go (99%) rename {cmd/classicstack => internal/app}/netbeui_disabled.go (98%) create mode 100644 internal/app/netbeui_enabled.go rename {cmd/classicstack => internal/app}/netbeui_hook.go (98%) create mode 100644 internal/app/netbios_disabled.go create mode 100644 internal/app/netbios_enabled.go rename {cmd/classicstack => internal/app}/netbios_hook.go (70%) rename {cmd/classicstack => internal/app}/netutil.go (99%) rename {cmd/classicstack => internal/app}/packetdump.go (98%) rename {cmd/classicstack => internal/app}/rawlink_open.go (99%) create mode 100644 internal/app/run.go rename {cmd/classicstack => internal/app}/shortname_hook.go (94%) rename {cmd/classicstack => internal/app}/shortname_wire.go (97%) rename {cmd/classicstack => internal/app}/smb_disabled.go (87%) rename {cmd/classicstack => internal/app}/smb_enabled.go (86%) rename {cmd/classicstack => internal/app}/smb_hook.go (55%) rename {cmd/classicstack => internal/app}/smb_shares.go (99%) rename {cmd/classicstack => internal/app}/supervisor.go (64%) rename {cmd/classicstack => internal/app}/supervisor_control.go (78%) rename {cmd/classicstack => internal/app}/supervisor_lifecycle.go (60%) create mode 100644 internal/app/transport_bindings_test.go create mode 100644 internal/app/version.go rename {cmd/classicstack => internal/app}/webui_config.go (99%) rename {cmd/classicstack => internal/app}/webui_disabled.go (97%) rename {cmd/classicstack => internal/app}/webui_enabled.go (98%) rename {cmd/classicstack => internal/app}/webui_hook.go (98%) create mode 100644 port/rawlink/pcap_closed_test.go diff --git a/.gitignore b/.gitignore index 920562b..d4eaa77 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ *.so *.dylib +# Locally built command binaries (extensionless on Unix) +/classicstack +/classicstackd + # Test binary, built with `go test -c` *.test diff --git a/CLAUDE.md b/CLAUDE.md index 9520cfc..73ffb7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,10 +46,10 @@ go test ./service/afp/... ### Core Data Flow ``` -cmd/classicstack/main.go → Ports → Router → Services +cmd/classicstack/main.go → internal/app (run-core) → Ports → Router → Services ``` -1. **Entry point** (`cmd/classicstack/`) parses CLI flags and `server.toml`, constructs ports, wires them to the router, and starts services. +1. **Entry point** (`cmd/classicstack/`) is a thin `main()` that calls `internal/app`, which parses CLI flags and `server.toml`, constructs ports, wires them to the router, and starts services. Two sibling commands wrap the same run-core for background operation: `cmd/classicstack-svc` (Windows service) and `cmd/classicstackd` (Unix/macOS daemon). 2. **Router** (`router/`) receives DDP datagrams from all ports, maintains the `RoutingTable` and `ZoneInformationTable`, and dispatches to services by socket number or forwards to other ports. 3. **Ports** (`port/`) abstract network interfaces. All implement `port.Port` (Unicast/Broadcast/Multicast). Implementations: `ethertalk`, `localtalk/ltoudp`, `localtalk/tashtalk`, `localtalk/virtual`. 4. **Services** (`service/`) plug into the router by registering socket numbers. Each implements `service.Service`. @@ -58,6 +58,10 @@ cmd/classicstack/main.go → Ports → Router → Services | Package | Role | |---|---| +| `internal/app/` | The run-core (formerly `cmd/classicstack` package `main`): flag/TOML parsing, the `Supervisor`, every `wireXxx` hook, control-plane + web UI wiring. Exposes `Main(Version)` and `Run(ctx, args, Version)` so the interactive binary and the service/daemon wrappers all share one runtime. | +| `cmd/classicstack/` | Thin interactive entry point (`main()` → `app.Main`); holds the link-time `Build*` vars (`-ldflags -X main.Build...`). | +| `cmd/classicstack-svc/` | Windows service wrapper (SCM via `golang.org/x/sys/windows/svc`); `install`/`uninstall`/`start`/`stop`/`status`/`run`. Stub on non-Windows. | +| `cmd/classicstackd/` | Unix/macOS background daemon (self-daemonize via fork+`Setsid`, PID file); `start`/`stop`/`status`/`run`, plus macOS LaunchAgent `install`/`uninstall`. Stub on Windows. | | `appletalk/` | DDP datagram struct, encode/decode, MacRoman codec | | `router/` | Core routing engine, routing table aging, zone info | | `port/ethertalk/` | EtherTalk over raw Ethernet using libpcap/Npcap, includes AARP | diff --git a/Makefile b/Makefile index 1836b16..0d63e40 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,24 @@ TAGS ?= all -.PHONY: build test test-race test-tags lint vuln gosec fuzz clean - -build: +# The service/daemon wrapper is a different command per OS: a Windows service +# (classicstack-svc.exe) or a Unix daemon (classicstackd). +GOOS ?= $(shell go env GOOS) +ifeq ($(GOOS),windows) +SVC_PKG := ./cmd/classicstack-svc +SVC_BIN := classicstack-svc.exe +else +SVC_PKG := ./cmd/classicstackd +SVC_BIN := classicstackd +endif + +.PHONY: build build-svc test test-race test-tags lint vuln gosec fuzz clean + +build: build-svc go build -tags "$(TAGS)" -o classicstack ./cmd/classicstack +build-svc: + go build -tags "$(TAGS)" -o $(SVC_BIN) $(SVC_PKG) + test: go test -tags "$(TAGS)" ./... @@ -30,5 +44,5 @@ fuzz: done clean: - rm -f classicstack classicstack.exe + rm -f classicstack classicstack.exe classicstackd classicstack-svc.exe rm -rf out dist diff --git a/README.md b/README.md index fa3c924..d0d4988 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,59 @@ UI server is preserved across the rebuild), **Save** writes `server.toml` by the transport-agnostic `pkg/control` API, so a future text/telnet UI can reuse them. +## Running as a service / daemon + +ClassicStack ships a wrapper binary so it can run in the background and start +automatically. It shares the same runtime as `classicstack`, so the config and +behaviour are identical — it just manages the process lifecycle. The wrapper is a +different command per platform: + +### Windows service — `classicstack-svc.exe` + +Run from an **elevated** (Administrator) prompt: + +~~~powershell +# Register the service (auto-start at boot) pointing at a config file: +.\classicstack-svc.exe install -config C:\ProgramData\ClassicStack\server.toml + +.\classicstack-svc.exe start # start it now +.\classicstack-svc.exe status # query the state +.\classicstack-svc.exe stop # stop it +.\classicstack-svc.exe uninstall # remove it +~~~ + +The service is named `ClassicStack` (visible in `services.msc` and +`Get-Service ClassicStack`) and writes start/stop entries to the Application event +log. `classicstack-svc.exe run -config ...` runs the stack in the current console +for debugging. + +### Linux / macOS daemon — `classicstackd` + +`classicstackd` self-daemonizes — it needs no systemd or other init system: + +~~~bash +# Start detached in the background (writes a PID file and logs to a file): +classicstackd start -config /etc/classicstack/server.toml \ + -pidfile /var/run/classicstack.pid -log /var/log/classicstack.log + +classicstackd status # report whether it is running +classicstackd stop # stop it gracefully (SIGTERM) +classicstackd run -config /etc/classicstack/server.toml # foreground (Ctrl-C to stop) +~~~ + +`-pidfile` and `-log` default to `/var/run/classicstack.pid` and +`/var/log/classicstack.log`. For boot persistence, point your init system's +`ExecStart` at `classicstackd run -config `. + +On **macOS**, `install`/`uninstall` additionally manage a LaunchAgent so the daemon +runs as a login item (headless): + +~~~bash +classicstackd install -config ~/Library/Application\ Support/ClassicStack/server.toml +# writes ~/Library/LaunchAgents/com.obsoletemadness.classicstack.plist and loads it +classicstackd uninstall # unload + remove the LaunchAgent +~~~ + ## Useful commands List pcap devices: diff --git a/cmd/classicstack-svc/doc.go b/cmd/classicstack-svc/doc.go new file mode 100644 index 0000000..744cf1a --- /dev/null +++ b/cmd/classicstack-svc/doc.go @@ -0,0 +1,18 @@ +/* +Command classicstack-svc runs ClassicStack as a Windows service. + +It registers with the Service Control Manager and runs the same stack as the +interactive classicstack binary, in-process, sharing the run-core in +internal/app. Subcommands: + + classicstack-svc install -config register the service (auto-start) + classicstack-svc uninstall remove the service + classicstack-svc start start the registered service + classicstack-svc stop stop the registered service + classicstack-svc status report the service state + classicstack-svc run -config run under the SCM (invoked by Windows) + +On non-Windows platforms this binary is a stub; use the classicstackd daemon +instead. +*/ +package main diff --git a/cmd/classicstack-svc/handler_windows.go b/cmd/classicstack-svc/handler_windows.go new file mode 100644 index 0000000..65653d4 --- /dev/null +++ b/cmd/classicstack-svc/handler_windows.go @@ -0,0 +1,98 @@ +//go:build windows + +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" + + "github.com/ObsoleteMadness/ClassicStack/internal/app" +) + +// acceptedControls are the SCM control requests the service responds to: +// Stop and Shutdown both trigger a graceful teardown. +const acceptedControls = svc.AcceptStop | svc.AcceptShutdown + +// serviceHandler implements svc.Handler. It runs the ClassicStack run-core +// (internal/app) in a goroutine and translates SCM Stop/Shutdown requests +// into context cancellation so the existing graceful shutdown path runs. +type serviceHandler struct { + cfgPath string + version app.Version + elog *eventlog.Log +} + +// Execute is invoked by svc.Run. It reports StartPending → Running, launches +// app.Run, and waits for either the stack to exit on its own or an SCM +// Stop/Shutdown, in which case it cancels the context and waits for app.Run +// to return before reporting Stopped. +func (h *serviceHandler) Execute(_ []string, r <-chan svc.ChangeRequest, s chan<- svc.Status) (bool, uint32) { + const cmdsAccepted = acceptedControls + + s <- svc.Status{State: svc.StartPending} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + runErr := make(chan error, 1) + go func() { + runErr <- app.Run(ctx, runArgs(h.cfgPath), h.version) + }() + + s <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} + h.info(1, "ClassicStack service running") + + for { + select { + case err := <-runErr: + // The stack exited on its own (a fatal build/config error, or it + // returned after ctx was cancelled). Report the outcome to the SCM. + if err != nil { + h.error(1, "ClassicStack exited with error: "+err.Error()) + s <- svc.Status{State: svc.Stopped, Win32ExitCode: 1} + return false, 1 + } + s <- svc.Status{State: svc.Stopped} + return false, 0 + + case c := <-r: + switch c.Cmd { + case svc.Interrogate: + s <- c.CurrentStatus + case svc.Stop, svc.Shutdown: + h.info(1, "ClassicStack service stopping") + s <- svc.Status{State: svc.StopPending} + cancel() + // Wait for app.Run to finish its graceful Supervisor.Stop. + <-runErr + s <- svc.Status{State: svc.Stopped} + return false, 0 + default: + // Ignore controls we did not advertise. + } + } + } +} + +func (h *serviceHandler) info(eid uint32, msg string) { + if h.elog != nil { + _ = h.elog.Info(eid, msg) + } +} + +func (h *serviceHandler) error(eid uint32, msg string) { + if h.elog != nil { + _ = h.elog.Error(eid, msg) + } +} + +// signalContext returns a context cancelled on Ctrl-C / SIGTERM, for the +// console-run fallback in runService. +func signalContext() (context.Context, context.CancelFunc) { + return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) +} diff --git a/cmd/classicstack-svc/main_windows.go b/cmd/classicstack-svc/main_windows.go new file mode 100644 index 0000000..dfc9ce9 --- /dev/null +++ b/cmd/classicstack-svc/main_windows.go @@ -0,0 +1,318 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" + "golang.org/x/sys/windows/svc/mgr" + + "github.com/ObsoleteMadness/ClassicStack/internal/app" +) + +const ( + // serviceName is the SCM key and eventlog source name. + serviceName = "ClassicStack" + // serviceDisplay is the friendly name shown in services.msc. + serviceDisplay = "ClassicStack AppleTalk Router" + // serviceDesc is the SCM description text. + serviceDesc = "AppleTalk Phase 2 router and classic LAN services (AFP, SMB, NetBIOS)." +) + +func main() { + version := app.Version{Version: BuildVersion, Commit: BuildCommit, Date: BuildDate} + + // When the SCM launches the service it runs the binary with no extra + // arguments; svc.IsWindowsService() detects that session so a bare + // invocation does the right thing. + if isService, err := svc.IsWindowsService(); err == nil && isService { + if rerr := runService("", version); rerr != nil { + fmt.Fprintf(os.Stderr, "classicstack-svc: %v\n", rerr) + os.Exit(1) + } + return + } + + args := os.Args[1:] + if len(args) == 0 { + usage() + os.Exit(2) + } + + cmd := strings.ToLower(args[0]) + rest := args[1:] + if err := dispatch(cmd, rest, version); err != nil { + fmt.Fprintf(os.Stderr, "classicstack-svc %s: %v\n", cmd, err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `classicstack-svc — run ClassicStack as a Windows service + +Usage: + classicstack-svc install -config register the service (auto-start) + classicstack-svc uninstall remove the service + classicstack-svc start start the registered service + classicstack-svc stop stop the registered service + classicstack-svc status report the service state + classicstack-svc run -config run in this console (debugging) +`) +} + +// dispatch routes a verb to its handler. -config is parsed inline (only +// install/run consume it). +func dispatch(cmd string, args []string, version app.Version) error { + switch cmd { + case "install": + cfg, err := configArg(args) + if err != nil { + return err + } + return install(cfg) + case "uninstall", "remove": + return uninstall() + case "start": + return controlStart() + case "stop": + return controlStop() + case "status": + return status() + case "run": + cfg, _ := configArg(args) // empty is allowed (server.toml auto-load) + return runService(cfg, version) + case "-h", "--help", "help": + usage() + return nil + default: + usage() + return fmt.Errorf("unknown command %q", cmd) + } +} + +// configArg extracts the value of a "-config " pair from args and +// returns it as an absolute path. +func configArg(args []string) (string, error) { + for i := range args { + a := args[i] + switch { + case a == "-config" || a == "--config": + if i+1 >= len(args) { + return "", fmt.Errorf("-config requires a path") + } + return filepath.Abs(args[i+1]) + case strings.HasPrefix(a, "-config="): + return filepath.Abs(strings.TrimPrefix(a, "-config=")) + case strings.HasPrefix(a, "--config="): + return filepath.Abs(strings.TrimPrefix(a, "--config=")) + } + } + return "", nil +} + +// install registers the service with the SCM, pointing its image at this +// executable's "run -config " so the SCM restarts the right binary. +func install(cfgPath string) error { + if cfgPath == "" { + return fmt.Errorf("install requires -config ") + } + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("locating executable: %w", err) + } + exePath, err = filepath.Abs(exePath) + if err != nil { + return err + } + + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("connecting to service manager (run as Administrator): %w", err) + } + defer func() { _ = m.Disconnect() }() + + if s, err := m.OpenService(serviceName); err == nil { + _ = s.Close() + return fmt.Errorf("service %q already exists", serviceName) + } + + s, err := m.CreateService(serviceName, exePath, mgr.Config{ + DisplayName: serviceDisplay, + Description: serviceDesc, + StartType: mgr.StartAutomatic, + ErrorControl: mgr.ErrorNormal, + }, "run", "-config", cfgPath) + if err != nil { + return fmt.Errorf("creating service: %w", err) + } + defer func() { _ = s.Close() }() + + // Register an eventlog source so Execute can write start/stop entries. + if err := eventlog.InstallAsEventCreate(serviceName, eventlog.Info|eventlog.Warning|eventlog.Error); err != nil { + // Non-fatal: the service still runs, it just logs to stderr only. + fmt.Fprintf(os.Stderr, "warning: registering eventlog source: %v\n", err) + } + + fmt.Printf("installed service %q (config %s)\n", serviceName, cfgPath) + return nil +} + +// uninstall stops (if running) and removes the service and its eventlog +// source. +func uninstall() error { + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("connecting to service manager (run as Administrator): %w", err) + } + defer func() { _ = m.Disconnect() }() + + s, err := m.OpenService(serviceName) + if err != nil { + return fmt.Errorf("service %q is not installed", serviceName) + } + defer func() { _ = s.Close() }() + + // Best-effort stop before delete so the binary is not in use. + if st, err := s.Query(); err == nil && st.State != svc.Stopped { + _, _ = s.Control(svc.Stop) + } + if err := s.Delete(); err != nil { + return fmt.Errorf("deleting service: %w", err) + } + _ = eventlog.Remove(serviceName) + fmt.Printf("removed service %q\n", serviceName) + return nil +} + +func controlStart() error { + s, m, err := openService() + if err != nil { + return err + } + defer func() { _ = m.Disconnect() }() + defer func() { _ = s.Close() }() + if err := s.Start(); err != nil { + return fmt.Errorf("starting service: %w", err) + } + fmt.Printf("started service %q\n", serviceName) + return nil +} + +func controlStop() error { + s, m, err := openService() + if err != nil { + return err + } + defer func() { _ = m.Disconnect() }() + defer func() { _ = s.Close() }() + st, err := s.Control(svc.Stop) + if err != nil { + return fmt.Errorf("stopping service: %w", err) + } + // Wait briefly for the stop to take effect. + timeout := time.Now().Add(20 * time.Second) + for st.State != svc.Stopped { + if time.Now().After(timeout) { + return fmt.Errorf("timed out waiting for service to stop") + } + time.Sleep(300 * time.Millisecond) + if st, err = s.Query(); err != nil { + return fmt.Errorf("querying service: %w", err) + } + } + fmt.Printf("stopped service %q\n", serviceName) + return nil +} + +func status() error { + s, m, err := openService() + if err != nil { + return err + } + defer func() { _ = m.Disconnect() }() + defer func() { _ = s.Close() }() + st, err := s.Query() + if err != nil { + return fmt.Errorf("querying service: %w", err) + } + fmt.Printf("service %q: %s\n", serviceName, stateString(st.State)) + return nil +} + +func openService() (*mgr.Service, *mgr.Mgr, error) { + m, err := mgr.Connect() + if err != nil { + return nil, nil, fmt.Errorf("connecting to service manager (run as Administrator): %w", err) + } + s, err := m.OpenService(serviceName) + if err != nil { + _ = m.Disconnect() + return nil, nil, fmt.Errorf("service %q is not installed", serviceName) + } + return s, m, nil +} + +func stateString(s svc.State) string { + switch s { + case svc.Stopped: + return "stopped" + case svc.StartPending: + return "start pending" + case svc.StopPending: + return "stop pending" + case svc.Running: + return "running" + case svc.ContinuePending: + return "continue pending" + case svc.PausePending: + return "pause pending" + case svc.Paused: + return "paused" + default: + return fmt.Sprintf("state %d", uint32(s)) + } +} + +// runService runs the stack under the SCM via svc.Run. cfgPath may be empty +// (server.toml auto-load). When not running under the SCM (console run for +// debugging) svc.Run fails, so we fall back to running the stack directly. +func runService(cfgPath string, version app.Version) error { + h := &serviceHandler{cfgPath: cfgPath, version: version} + + elog, err := eventlog.Open(serviceName) + if err == nil { + h.elog = elog + defer func() { _ = elog.Close() }() + } + + if err := svc.Run(serviceName, h); err != nil { + // Likely launched from a console rather than the SCM: run the stack + // in the foreground so `run` is still useful for debugging. + fmt.Fprintf(os.Stderr, "not started by the SCM (%v); running in the foreground\n", err) + return runForeground(cfgPath, version) + } + return nil +} + +// runForeground runs the stack with a signal-cancelled context. It is split +// out so the os.Exit in runService's caller does not skip the signal-context +// cleanup (the deferred stop runs when this function returns). +func runForeground(cfgPath string, version app.Version) error { + ctx, stop := signalContext() + defer stop() + return app.Run(ctx, runArgs(cfgPath), version) +} + +// runArgs builds the argument slice handed to app.Run from the config path. +func runArgs(cfgPath string) []string { + if cfgPath == "" { + return nil + } + return []string{"-config", cfgPath} +} diff --git a/cmd/classicstack-svc/stub_other.go b/cmd/classicstack-svc/stub_other.go new file mode 100644 index 0000000..62f53cf --- /dev/null +++ b/cmd/classicstack-svc/stub_other.go @@ -0,0 +1,18 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" +) + +// On non-Windows platforms this binary does nothing useful: the Windows +// service integration is Windows-only. Operators on Linux/macOS should use +// the classicstackd daemon. The stub keeps the package buildable in a +// cross-platform `go build ./...` so CI matrices do not trip over a missing +// main. +func main() { + fmt.Fprintln(os.Stderr, "classicstack-svc is a Windows-only service wrapper; use classicstackd on this platform") + os.Exit(1) +} diff --git a/cmd/classicstack-svc/version.go b/cmd/classicstack-svc/version.go new file mode 100644 index 0000000..3946849 --- /dev/null +++ b/cmd/classicstack-svc/version.go @@ -0,0 +1,9 @@ +package main + +// Build metadata injected at link time via -ldflags +// -X main.BuildVersion=... -X main.BuildCommit=... -X main.BuildDate=... +var ( + BuildVersion = "0.0.0-dev" + BuildCommit = "unknown" + BuildDate = "unknown" +) diff --git a/cmd/classicstack/doc.go b/cmd/classicstack/doc.go index 9dcd51b..5d91ba8 100644 --- a/cmd/classicstack/doc.go +++ b/cmd/classicstack/doc.go @@ -8,8 +8,11 @@ flags and an optional TOML file; build tags (afp, macgarden, macip, sqlite_cnid) gate the optional subsystems so a router-only binary shrinks accordingly. -This package is the wiring layer only — protocol logic lives under -protocol/, link-layer transports under port/, and stateful services -under service/. +This package is a thin entry point: it holds the link-time build vars and +hands off to internal/app, which owns the run-core (flag/TOML parsing, the +Supervisor, and all service wiring) shared with the service/daemon wrappers +(cmd/classicstack-svc, cmd/classicstackd). Protocol logic lives under +protocol/, link-layer transports under port/, and stateful services under +service/. */ package main diff --git a/cmd/classicstack/main.go b/cmd/classicstack/main.go index eaacb44..90c8a3e 100644 --- a/cmd/classicstack/main.go +++ b/cmd/classicstack/main.go @@ -1,385 +1,15 @@ package main -import ( - "context" - "flag" - "fmt" - "log" - "log/slog" - "os" - "os/signal" - "runtime" - "strings" - "syscall" - - "github.com/ObsoleteMadness/ClassicStack/config" - "github.com/ObsoleteMadness/ClassicStack/netlog" - "github.com/ObsoleteMadness/ClassicStack/pkg/logbuf" - "github.com/ObsoleteMadness/ClassicStack/pkg/logging" - "github.com/ObsoleteMadness/ClassicStack/port/rawlink" +import "github.com/ObsoleteMadness/ClassicStack/internal/app" + +// Build metadata injected at link time via -ldflags +// -X main.BuildVersion=... -X main.BuildCommit=... -X main.BuildDate=... +var ( + BuildVersion = "0.0.0-dev" + BuildCommit = "unknown" + BuildDate = "unknown" ) func main() { - log.SetFlags(log.LstdFlags | log.Lmicroseconds) - - configPath := flag.String("config", "", "Path to TOML config file (cannot be combined with other flags)") - showVersion := flag.Bool("version", false, "Print ClassicStack version information and exit") - - logLevel := flag.String("log-level", "info", "Minimum log level: debug, info, warn") - logTraffic := flag.Bool("log-traffic", false, "Log network traffic at debug level (requires -log-level debug)") - - ltoudp := flag.Bool("ltoudp-enabled", true, "Enable LToUDP LocalTalk port") - ltIface := flag.String("ltoudp-interface", "0.0.0.0", "Local IPv4 interface/address for LToUDP multicast join and send (0.0.0.0 = auto)") - ltNet := flag.Uint("ltoudp-seed-network", 1, "LToUDP seed network") - ltZone := flag.String("ltoudp-seed-zone", "LToUDP Network", "LToUDP seed zone") - tashtalkSerial := flag.String("tashtalk-port", "", "TashTalk serial port (empty to disable)") - ttNet := flag.Uint("tashtalk-seed-network", 2, "TashTalk seed network") - ttZone := flag.String("tashtalk-seed-zone", "TashTalk Network", "TashTalk seed zone") - - pcapDev := flag.String("ethertalk-device", "", "EtherTalk pcap device (required for EtherTalk)") - etBackend := flag.String("ethertalk-backend", "pcap", "EtherTalk backend: pcap, tap, or tun") - pcapHWAddr := flag.String("ethertalk-hw-address", "DE:AD:BE:EF:CA:FE", "EtherTalk hardware address (6-byte MAC)") - etBridgeMode := flag.String("ethertalk-bridge-mode", "auto", "EtherTalk bridge mode: auto, ethernet, wifi") - etBridgeHostMAC := flag.String("ethertalk-bridge-host-mac", "", "Host adapter MAC used for Wi-Fi bridge shim (default: ethertalk-hw-address)") - etFilter := flag.String("ethertalk-filter", "", "pcap BPF filter override for EtherTalk") - bridgeMode := flag.String("bridge-mode", "", "Shared raw-link backend mode: pcap, tap, or tun (overrides ethertalk-backend)") - bridgeDevice := flag.String("bridge-device", "", "Shared raw-link device/interface (overrides ethertalk-device)") - bridgeHWAddr := flag.String("bridge-hw-address", "", "Shared raw-link host MAC (overrides ethertalk-hw-address)") - bridgeFrameMode := flag.String("bridge-frame-mode", "", "Shared frame mode for bridge adaptation: auto, ethernet, wifi (overrides ethertalk-bridge-mode)") - listPcap := flag.Bool("list-pcap-devices", false, "List pcap devices and exit") - etNetMin := flag.Uint("ethertalk-seed-network-min", 3, "EtherTalk seed network min") - etNetMax := flag.Uint("ethertalk-seed-network-max", 5, "EtherTalk seed network max") - etZone := flag.String("ethertalk-seed-zone", "EtherTalk Network", "EtherTalk seed zone name") - etDesiredNet := flag.Uint("ethertalk-desired-network", 3, "EtherTalk desired network") - etDesiredNode := flag.Uint("ethertalk-desired-node", 253, "EtherTalk desired node") - - // MacIP gateway flags. - // By default the IP side reuses the same pcap device as EtherTalk (-ethertalk-device). - // A separate interface can be specified with -macip-interface if needed. - macipEnable := flag.Bool("macip-enabled", false, "Enable MacIP IP-over-AppleTalk gateway (intended for NAT mode)") - macipGWIP := flag.String("macip-nat-gw", "", "MacIP gateway IP for NAT mode (ignored in pcap mode; blank uses an APIPA-style address)") - macipSubnet := flag.String("macip-nat-subnet", "192.168.100.0/24", "MacIP NAT subnet in CIDR notation") - macipNameserver := flag.String("macip-nameserver", "", "Nameserver IP for MacIP clients (default: IP-side gateway)") - macipZone := flag.String("macip-zone", "", "AppleTalk zone for NBP registration (default: use -ethertalk-seed-zone if set, otherwise first zone found)") - macipIPGW := flag.String("macip-ip-gateway", "", "Default gateway IP on the IP-side network (auto-detected when omitted)") - macipNAT := flag.Bool("macip-nat", false, "Enable NAPT: rewrite Mac client source IPs to the gateway IP on the physical network") - macipDHCP := flag.Bool("macip-dhcp-relay", false, "Use DHCP to assign IPs to MacIP clients instead of the static pool (non-NAT mode)") - macipStateFile := flag.String("macip-lease-file", "", "File to persist MacIP lease state across restarts (empty to disable)") - macipFilter := flag.String("macip-filter", "", "pcap BPF filter override for MacIP (default is auto-generated)") - - // Packet parsing / capture flags. - parsePackets := flag.Bool("parse-packets", false, "Decode and log every inbound DDP packet (ATP/ASP/AFP layers)") - parseOutput := flag.String("parse-output", "", "File path to write parsed packet log (appended; empty = stdout only)") - - captureLocalTalk := flag.String("capture-localtalk", "", "Write LocalTalk frames (LToUDP/TashTalk/Virtual) to a pcap file at this path (empty disables)") - captureEtherTalk := flag.String("capture-ethertalk", "", "Write EtherTalk frames to a pcap file at this path (empty disables)") - captureSnaplen := flag.Uint("capture-snaplen", 65535, "Per-frame snap length for pcap captures") - - // AFP file sharing flags. Schemas live in service/afp; cmd-side - // wiring is split between afp_enabled.go and afp_disabled.go. - afpServerName := flag.String("afp-name", "Go File Server", "AFP server name advertised to clients") - afpZone := flag.String("afp-zone", "", "AppleTalk zone for AFP NBP registration (default: first zone found)") - afpProtocols := flag.String("afp-protocols", "tcp,ddp", "AFP protocols to enable: tcp, ddp, or tcp,ddp") - afpTCPAddr := flag.String("afp-binding", ":548", "Address and port for AFP over TCP (DSI) to listen on") - afpExtensionMap := flag.String("afp-extension-map", "", "Netatalk-compatible extension map file for Macintosh type/creator fallback") - afpDecomposedFilenames := flag.Bool("afp-use-decomposed-names", true, "Encode host-reserved filename characters using 0xNN tokens when mapping AFP paths") - afpCNIDBackend := flag.String("afp-cnid-backend", "sqlite", "CNID backend to use for AFP object IDs (sqlite or memory)") - afpAppleDoubleMode := flag.String("afp-appledouble-mode", "modern", "AppleDouble metadata mode: modern or legacy") - var afpVolumes volumeFlags - flag.Var(&afpVolumes, "afp-volume", `AFP volume to share, format: "Name:Path" (repeatable, e.g. -afp-volume "Mac Share:c:\mac")`) - - // IPX flags. Real packet handling lands behind //go:build ipx; the - // disabled stub logs a warning if -ipx-enabled is set without the tag. - ipxEnable := flag.Bool("ipx-enabled", false, "Enable IPX router (requires -tags ipx)") - ipxIface := flag.String("ipx-interface", "", "Rawlink/pcap interface for IPX (default: reuse -ethertalk-device)") - ipxFraming := flag.String("ipx-framing", "ethernet_ii", "IPX framing: ethernet_ii, raw_802_3, llc, snap") - ipxInternal := flag.String("ipx-internal-network", "", "IPX internal network number (8-hex-digit, e.g. DEADBEEF)") - ipxFilter := flag.String("ipx-filter", "", "pcap BPF filter override for IPX (default: ipx)") - - // NetBEUI flags. - netbeuiEnable := flag.Bool("netbeui-enabled", false, "Enable NetBEUI port (requires -tags netbeui)") - netbeuiIface := flag.String("netbeui-interface", "", "Rawlink/pcap interface for NetBEUI (default: reuse -ethertalk-device)") - netbeuiFilter := flag.String("netbeui-filter", "", "pcap BPF filter override for NetBEUI (default: llc)") - - // NetBIOS flags. - netbiosEnable := flag.Bool("netbios-enabled", false, "Enable NetBIOS service (requires -tags netbios)") - netbiosTransports := flag.String("netbios-transports", "tcp", "Comma-separated NetBIOS transports: any of tcp, netbeui, ipx") - netbiosScopeID := flag.String("netbios-scope-id", "", "NetBIOS scope ID (RFC 1001/1002)") - netbiosServerName := flag.String("netbios-server-name", "", "Deprecated: NetBIOS identity derives from SMB server/workgroup") - netbiosWorkgroup := flag.String("netbios-workgroup", "", "Deprecated: NetBIOS identity derives from SMB server/workgroup") - - // SMB flags. - smbEnable := flag.Bool("smb-enabled", false, "Enable SMB 1.0 server (requires -tags smb)") - smbNBT := flag.String("smb-nbt-binding", ":139", "SMB NBT (NetBIOS over TCP) listen address") - smbDirect := flag.String("smb-direct-binding", "", "SMB direct (TCP/445) listen address; empty disables direct SMB") - smbGuest := flag.Bool("smb-guest-ok", false, "Accept unauthenticated SMB sessions") - smbServerName := flag.String("smb-server-name", "CLASSICSTACK", "SMB/NetBIOS computer name") - smbWorkgroup := flag.String("smb-workgroup", "WORKGROUP", "SMB/NetBIOS workgroup name") - var smbShares volumeFlags - flag.Var(&smbShares, "smb-share", `SMB share, format: "Name:Path" (repeatable)`) - - // Shortname flags. - shortWindows := flag.Bool("shortname-windows-shortnames", false, "Enable Windows native shortnames") - shortBackend := flag.String("shortname-backend", "memory", "Shortname store backend: memory or sqlite") - shortDB := flag.String("shortname-db", "", "Shortname store DB path (sqlite backend)") - - // Web UI flags. The HTTP server lives behind -tags webui; the - // disabled stub warns if -webui-enabled is set without the tag. - webuiEnable := flag.Bool("webui-enabled", false, "Enable the management web UI (requires -tags webui)") - webuiBind := flag.String("webui-bind", "127.0.0.1:8080", "Web UI listen address (IP:PORT)") - webuiTLS := flag.Bool("webui-tls", true, "Serve the web UI over HTTPS (self-signed when no cert/key given)") - webuiCert := flag.String("webui-cert-pem", "", "Path to PEM certificate for the web UI (blank: self-signed)") - webuiKey := flag.String("webui-key-pem", "", "Path to PEM private key for the web UI (blank: self-signed)") - - flag.Parse() - - if *showVersion { - fmt.Printf("classicstack %s\n", BuildVersion) - fmt.Printf("commit: %s\n", BuildCommit) - fmt.Printf("built: %s\n", BuildDate) - fmt.Printf("go: %s\n", runtime.Version()) - return - } - - nonConfigFlags := 0 - flag.Visit(func(f *flag.Flag) { - if f.Name != "config" && f.Name != "version" { - nonConfigFlags++ - } - }) - - if *configPath != "" && nonConfigFlags > 0 { - log.Fatal("-config cannot be combined with other flags") - } - - selectedConfig := *configPath - if selectedConfig == "" && flag.NFlag() == 0 { - if _, err := os.Stat("server.toml"); err == nil { - selectedConfig = "server.toml" - } else if os.IsNotExist(err) { - flag.Usage() - return - } else { - log.Fatalf("failed checking default config file server.toml: %v", err) - } - } - - var ( - cfg appConfig - configSource config.Source - ) - fromConfigFile := selectedConfig != "" - if fromConfigFile { - loaded, src, err := loadConfigFromFile(selectedConfig) - if err != nil { - log.Fatalf("failed loading config file %q: %v", selectedConfig, err) - } - cfg = loaded - configSource = src - } else { - cfg = flagsToConfig(flagInputs{ - LogLevel: *logLevel, - LogTraffic: *logTraffic, - ParsePackets: *parsePackets, - ParseOutput: *parseOutput, - LToUDPEnabled: *ltoudp, - LToUDPInterface: *ltIface, - LToUDPSeedNetwork: *ltNet, - LToUDPSeedZone: *ltZone, - TashTalkPort: *tashtalkSerial, - TashTalkSeedNetwork: *ttNet, - TashTalkSeedZone: *ttZone, - BridgeMode: *bridgeMode, - BridgeDevice: *bridgeDevice, - BridgeHWAddress: *bridgeHWAddr, - BridgeBridgeMode: *bridgeFrameMode, - - EtherTalkDevice: *pcapDev, - EtherTalkBackend: *etBackend, - EtherTalkHWAddress: *pcapHWAddr, - EtherTalkBridgeMode: *etBridgeMode, - EtherTalkBridgeHostMAC: *etBridgeHostMAC, - EtherTalkFilter: *etFilter, - EtherTalkSeedNetworkMin: *etNetMin, - EtherTalkSeedNetworkMax: *etNetMax, - EtherTalkSeedZone: *etZone, - EtherTalkDesiredNetwork: *etDesiredNet, - EtherTalkDesiredNode: *etDesiredNode, - MacIPEnabled: *macipEnable, - MacIPGWIP: *macipGWIP, - MacIPSubnet: *macipSubnet, - MacIPNameserver: *macipNameserver, - MacIPZone: *macipZone, - MacIPGatewayIP: *macipIPGW, - MacIPNAT: *macipNAT, - MacIPDHCPRelay: *macipDHCP, - MacIPLeaseFile: *macipStateFile, - MacIPFilter: *macipFilter, - CaptureLocalTalk: *captureLocalTalk, - CaptureEtherTalk: *captureEtherTalk, - CaptureSnaplen: *captureSnaplen, - - IPXEnabled: *ipxEnable, - IPXInterface: *ipxIface, - IPXFraming: *ipxFraming, - IPXInternalNetwork: *ipxInternal, - IPXFilter: *ipxFilter, - - NetBEUIEnabled: *netbeuiEnable, - NetBEUIInterface: *netbeuiIface, - NetBEUIFilter: *netbeuiFilter, - - NetBIOSEnabled: *netbiosEnable, - NetBIOSTransports: *netbiosTransports, - NetBIOSScopeID: *netbiosScopeID, - NetBIOSServerName: *netbiosServerName, - NetBIOSWorkgroup: *netbiosWorkgroup, - - SMBEnabled: *smbEnable, - SMBNBTBinding: *smbNBT, - SMBDirectBinding: *smbDirect, - SMBGuestOk: *smbGuest, - SMBServerName: *smbServerName, - SMBWorkgroup: *smbWorkgroup, - SMBShareValues: []string(smbShares), - - ShortnameWindowsShortnames: *shortWindows, - ShortnameBackend: *shortBackend, - ShortnameDBPath: *shortDB, - - WebUIEnabled: *webuiEnable, - WebUIBind: *webuiBind, - WebUITLS: *webuiTLS, - WebUICertPEM: *webuiCert, - WebUIKeyPEM: *webuiKey, - }) - } - - if level, ok := netlog.ParseLevel(cfg.LogLevel); ok { - netlog.SetLevel(level) - } else { - log.Fatalf("unknown -log-level %q (want debug, info, or warn)", cfg.LogLevel) - } - - // Install a pkg/logging root logger as the netlog shim's target so - // output flows through slog with source tagging and structured - // attributes. Each service will eventually take a *slog.Logger - // directly; until then, netlog.* calls forward here. - slogLevel, _ := logging.ParseLevel(cfg.LogLevel) - rootLogger := logging.New("ClassicStack", logging.Options{ - Sinks: []logging.Sink{{Writer: os.Stderr, Format: logging.FormatConsole, Level: slogLevel}}, - // Tee every record into the in-memory ring buffer so the management - // plane / web UI log viewer can replay recent history and stream live. - Extra: []slog.Handler{logbuf.NewHandler(logbuf.Default, slogLevel)}, - }) - logging.SetDefault(rootLogger) - netlog.SetLogger(rootLogger) - - // Traffic logging (LogTraffic) is wired by the Supervisor from config so - // it can be toggled live from the UI; main only sets up the logger. - - cfg.Bridge.Mode = strings.ToLower(strings.TrimSpace(cfg.Bridge.Mode)) - switch cfg.Bridge.Mode { - case "", "pcap", "tap", "tun": - default: - log.Fatalf("invalid bridge mode %q (want pcap, tap, or tun)", cfg.Bridge.Mode) - } - syncBridgeToEtherTalk(&cfg) - - if *listPcap { - names, err := rawlink.InterfaceNames() - if err != nil { - log.Fatalf("failed listing pcap interface names: %v", err) - } - netlog.Info("[MAIN] available interfaces: %v", names) - devs, err := rawlink.ListPcapDevices() - if err != nil { - log.Fatalf("failed listing pcap devices: %v", err) - } - if len(devs) == 0 { - netlog.Info("[MAIN] no pcap devices found") - return - } - for _, d := range devs { - netlog.Info("[MAIN] pcap device: %s", d.Name) - if d.Description != "" { - netlog.Info("[MAIN] desc: %s", d.Description) - } - for _, addr := range d.Addresses { - netlog.Info("[MAIN] addr: %s", addr) - } - } - return - } - - if cfg.EtherTalk.Device == "" && cfg.Bridge.Mode == "pcap" { - if detected, ok := rawlink.DetectDefaultPcapInterface(); ok { - netlog.Info("[MAIN] auto-detected pcap interface: %s", detected) - cfg.Bridge.Device = detected - syncBridgeToEtherTalk(&cfg) - } - } - if cfg.EtherTalk.Device != "" && cfg.Bridge.Mode == "pcap" && strings.TrimSpace(cfg.EtherTalk.BridgeHostMAC) == "" { - if hostMAC, ok := rawlink.DetectHostMACForPcapInterface(cfg.EtherTalk.Device); ok { - cfg.EtherTalk.BridgeHostMAC = hostMAC - if strings.TrimSpace(cfg.Bridge.HWAddress) == "" { - cfg.Bridge.HWAddress = hostMAC - syncBridgeToEtherTalk(&cfg) - } - netlog.Info("[MAIN] auto-detected bridge host MAC for %s: %s", cfg.EtherTalk.Device, hostMAC) - } - } - - // From here on, the build and lifecycle of every component lives in the - // Supervisor. main.go's remaining job is to project the resolved config - // into a config.Model, construct the supervisor and the management - // plane, wire the (optional) web UI on top, run, and tear down. - model := buildModel(cfg, configSource, fromConfigFile, afpFlagOptions{ - ServerName: *afpServerName, - Zone: *afpZone, - Protocols: *afpProtocols, - Binding: *afpTCPAddr, - ExtensionMap: *afpExtensionMap, - DecomposedNames: *afpDecomposedFilenames, - CNIDBackend: *afpCNIDBackend, - AppleDoubleMode: *afpAppleDoubleMode, - Volumes: []string(afpVolumes), - }) - sup, err := NewSupervisor(cfg, configSource, model) - if err != nil { - log.Fatalf("failed to build stack: %v", err) - } - - plane := newControlPlane(sup, model, selectedConfig) - wireDiagnostics(plane, sup) - - if err := installWebUI(sup, cfg.WebUI, plane); err != nil { - log.Fatalf("failed to wire web UI: %v", err) - } - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - if err := sup.Start(ctx); err != nil { - log.Fatalf("failed to start stack: %v", err) - } - - <-ctx.Done() - - if err := sup.Stop(); err != nil { - netlog.Warn("[MAIN] stop warning: %v", err) - } -} - -// volumeFlags is a repeatable -afp-volume flag. The raw "Name:Path" -// strings are forwarded to wireAFP, where the //go:build afp side -// parses them via afp.ParseVolumeFlag. Keeping this neutral lets -// minimal-build users still pass -afp-volume and get a clean warning. -type volumeFlags []string - -func (v *volumeFlags) String() string { return "" } - -func (v *volumeFlags) Set(s string) error { - *v = append(*v, s) - return nil + app.Main(app.Version{Version: BuildVersion, Commit: BuildCommit, Date: BuildDate}) } diff --git a/cmd/classicstack/netbeui_enabled.go b/cmd/classicstack/netbeui_enabled.go deleted file mode 100644 index 5efdb14..0000000 --- a/cmd/classicstack/netbeui_enabled.go +++ /dev/null @@ -1,88 +0,0 @@ -//go:build netbeui || all - -package main - -import ( - "context" - "fmt" - "strings" - - "github.com/ObsoleteMadness/ClassicStack/capture" - "github.com/ObsoleteMadness/ClassicStack/netlog" - "github.com/ObsoleteMadness/ClassicStack/pkg/hwaddr" - "github.com/ObsoleteMadness/ClassicStack/port/netbeui" - "github.com/ObsoleteMadness/ClassicStack/port/rawlink" -) - -type netbeuiHookEnabled struct { - port netbeui.Port - mac [6]byte - sink *capture.PcapSink -} - -func (h *netbeuiHookEnabled) Start(_ context.Context) error { - if h.port != nil { - if err := h.port.Start(); err != nil { - return err - } - } - netlog.Info("[MAIN][NetBEUI] port up") - return nil -} -func (h *netbeuiHookEnabled) Stop() error { - if h.port != nil { - _ = h.port.Stop() - } - if h.sink != nil { - _ = h.sink.Close() - h.sink = nil - } - return nil -} -func (h *netbeuiHookEnabled) Port() netbeui.Port { return h.port } -func (h *netbeuiHookEnabled) MAC() [6]byte { return h.mac } - -func wireNetBEUI(cfg NetBEUIConfig) (NetBEUIHook, error) { - if !cfg.Enabled { - return nil, nil - } - link := cfg.Rawlink - if link == nil && strings.TrimSpace(cfg.Interface) != "" { - opened, err := openRawlink(cfg.BridgeMode, cfg.Interface, rawlinkProfileNetBEUI) - if err != nil { - return nil, fmt.Errorf("opening NetBEUI rawlink on %q: %w", cfg.Interface, err) - } - link = applyRawlinkBridgeFrameMode(opened, cfg.BridgeMode, cfg.BridgeFrameMode, cfg.Interface, cfg.BridgeHWAddress, "NetBEUI") - applyRawlinkFilter(link, cfg.BridgeMode, cfg.Interface, cfg.Filter, "llc", "NetBEUI") - } - if link == nil { - netlog.Warn("[MAIN][NetBEUI] enabled but no -netbeui-interface configured; NetBEUI idle") - return &netbeuiHookEnabled{}, nil - } - netlog.Info("[MAIN][NetBEUI] pcap interface=%s", cfg.Interface) - p := netbeui.NewPort(link) - var mac [6]byte - if macStr, ok := rawlink.DetectHostMACForPcapInterface(cfg.Interface); ok { - if parsed, err := hwaddr.ParseEthernet(macStr); err == nil { - mac = [6]byte(parsed) - p.SetSourceMAC(mac) - } - } else if parsed, err := hwaddr.ParseEthernet(strings.TrimSpace(cfg.BridgeHWAddress)); err == nil { - mac = [6]byte(parsed) - p.SetSourceMAC(mac) - } - - hook := &netbeuiHookEnabled{port: p, mac: mac} - - if strings.TrimSpace(cfg.CapturePath) != "" { - sink, err := capture.NewPcapSink(cfg.CapturePath, capture.LinkTypeEthernet, cfg.CaptureSnaplen) - if err != nil { - return nil, fmt.Errorf("opening NetBEUI capture sink %q: %w", cfg.CapturePath, err) - } - hook.sink = sink - p.SetCaptureSink(sink) - netlog.Info("[CAPTURE] NetBEUI frames -> %s", cfg.CapturePath) - } - - return hook, nil -} diff --git a/cmd/classicstack/netbios_disabled.go b/cmd/classicstack/netbios_disabled.go deleted file mode 100644 index f8b83cf..0000000 --- a/cmd/classicstack/netbios_disabled.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build !netbios && !all - -package main - -import ( - "context" - - "github.com/ObsoleteMadness/ClassicStack/netlog" - "github.com/ObsoleteMadness/ClassicStack/service/netbios" -) - -type netbiosHookDisabled struct{} - -func (netbiosHookDisabled) Start(_ context.Context) error { return nil } -func (netbiosHookDisabled) Stop() error { return nil } -func (netbiosHookDisabled) NameService() netbios.NameService { return nil } -func (netbiosHookDisabled) Service() *netbios.Service { return nil } - -func wireNetBIOS(cfg NetBIOSConfig) (NetBIOSHook, error) { - if cfg.Enabled { - netlog.Warn("[MAIN][NetBIOS] -netbios-enabled set but binary was built without -tags netbios; ignoring") - } - return netbiosHookDisabled{}, nil -} diff --git a/cmd/classicstack/netbios_enabled.go b/cmd/classicstack/netbios_enabled.go deleted file mode 100644 index 2004868..0000000 --- a/cmd/classicstack/netbios_enabled.go +++ /dev/null @@ -1,63 +0,0 @@ -//go:build netbios || all - -package main - -import ( - "context" - - "github.com/ObsoleteMadness/ClassicStack/netlog" - netbiosproto "github.com/ObsoleteMadness/ClassicStack/protocol/netbios" - "github.com/ObsoleteMadness/ClassicStack/service/netbios" - "github.com/ObsoleteMadness/ClassicStack/service/netbios/over_ipx" - "github.com/ObsoleteMadness/ClassicStack/service/netbios/over_netbeui" - "github.com/ObsoleteMadness/ClassicStack/service/netbios/over_tcp" -) - -type netbiosHookEnabled struct { - svc *netbios.Service -} - -func (h *netbiosHookEnabled) Start(ctx context.Context) error { return h.svc.Start(ctx) } -func (h *netbiosHookEnabled) Stop() error { return h.svc.Stop() } -func (h *netbiosHookEnabled) NameService() netbios.NameService { return h.svc.NameService() } -func (h *netbiosHookEnabled) Service() *netbios.Service { return h.svc } - -func wireNetBIOS(cfg NetBIOSConfig) (NetBIOSHook, error) { - if !cfg.Enabled { - return nil, nil - } - transports := selectNetBIOSTransports(cfg) - svc := netbios.NewService(cfg.ServerName, cfg.ScopeID, transports) - netlog.Info("[MAIN][NetBIOS] server=%q scope=%q transports=%d (stub)", - cfg.ServerName, cfg.ScopeID, len(transports)) - return &netbiosHookEnabled{svc: svc}, nil -} - -// selectNetBIOSTransports turns the config's transport name list into -// concrete Transport instances, skipping any whose underlying hook is -// not available (e.g. "ipx" requested but binary built without -tags ipx). -func selectNetBIOSTransports(cfg NetBIOSConfig) []netbios.Transport { - var out []netbios.Transport - for _, name := range cfg.Transports { - switch name { - case "tcp": - out = append(out, over_tcp.NewTransport()) - case "netbeui": - if cfg.NetBEUI != nil && cfg.NetBEUI.Port() != nil { - out = append(out, over_netbeui.NewTransport(cfg.NetBEUI.Port(), cfg.NetBEUI.MAC())) - } else { - netlog.Warn("[MAIN][NetBIOS] transport %q skipped: NetBEUI port not available", name) - } - case "ipx": - if cfg.IPX != nil && cfg.IPX.Router() != nil && cfg.IPX.SAP() != nil { - nbName := netbiosproto.NewName(cfg.ServerName, netbiosproto.NameTypeFileServer) - out = append(out, over_ipx.NewTransport(cfg.IPX.Router(), cfg.IPX.SAP(), nbName)) - } else { - netlog.Warn("[MAIN][NetBIOS] transport %q skipped: IPX router/SAP not available", name) - } - default: - netlog.Warn("[MAIN][NetBIOS] unknown transport %q, ignoring", name) - } - } - return out -} diff --git a/cmd/classicstack/version.go b/cmd/classicstack/version.go deleted file mode 100644 index d4cc304..0000000 --- a/cmd/classicstack/version.go +++ /dev/null @@ -1,8 +0,0 @@ -package main - -// Build metadata injected at link time via -ldflags. -var ( - BuildVersion = "0.0.0-dev" - BuildCommit = "unknown" - BuildDate = "unknown" -) diff --git a/cmd/classicstackd/doc.go b/cmd/classicstackd/doc.go new file mode 100644 index 0000000..3d2311b --- /dev/null +++ b/cmd/classicstackd/doc.go @@ -0,0 +1,22 @@ +/* +Command classicstackd runs ClassicStack as a background daemon on Unix. + +It shares the same run-core as the interactive classicstack binary +(internal/app). It does not depend on any init system: `start` re-execs +itself detached into a new session, writes a PID file, and redirects output +to a log file; `stop` signals that PID; `run` stays in the foreground. + + classicstackd start -config [-pidfile

] [-log

] daemonize + classicstackd stop [-pidfile

] signal the daemon + classicstackd status [-pidfile

] report liveness + classicstackd run -config run in the foreground + +On macOS, `install`/`uninstall` additionally manage a LaunchAgent plist so +the daemon auto-starts at login (headless): + + classicstackd install -config [-log

] write + load the LaunchAgent + classicstackd uninstall unload + remove the LaunchAgent + +On Windows this binary is a stub; use classicstack-svc instead. +*/ +package main diff --git a/cmd/classicstackd/launchd_darwin.go b/cmd/classicstackd/launchd_darwin.go new file mode 100644 index 0000000..f9a37e9 --- /dev/null +++ b/cmd/classicstackd/launchd_darwin.go @@ -0,0 +1,109 @@ +//go:build darwin + +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// launchAgentLabel is the LaunchAgent label / reverse-DNS identifier. +const launchAgentLabel = "com.obsoletemadness.classicstack" + +// launchAgentPath returns the per-user LaunchAgent plist path. +func launchAgentPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "LaunchAgents", launchAgentLabel+".plist"), nil +} + +// cmdInstall writes a LaunchAgent plist that runs `classicstackd run -config +// ` at login (headless) and loads it with launchctl. +func cmdInstall(args []string) error { + f, err := parseFlags("install", args, true) + if err != nil { + return err + } + self, err := os.Executable() + if err != nil { + return fmt.Errorf("locating executable: %w", err) + } + self, err = filepath.Abs(self) + if err != nil { + return err + } + + plistPath, err := launchAgentPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil { + return fmt.Errorf("creating LaunchAgents directory: %w", err) + } + + plist := renderPlist(self, f.config, f.logFile) + if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil { + return fmt.Errorf("writing %s: %w", plistPath, err) + } + + // Reload to pick up changes if it was already loaded, then load. + _ = exec.Command("launchctl", "unload", plistPath).Run() + if out, err := exec.Command("launchctl", "load", "-w", plistPath).CombinedOutput(); err != nil { + return fmt.Errorf("launchctl load: %v: %s", err, string(out)) + } + + fmt.Printf("installed LaunchAgent %s (config %s)\n", plistPath, f.config) + return nil +} + +// cmdUninstall unloads and removes the LaunchAgent plist. +func cmdUninstall(_ []string) error { + plistPath, err := launchAgentPath() + if err != nil { + return err + } + if _, err := os.Stat(plistPath); err != nil { + return fmt.Errorf("no LaunchAgent installed at %s", plistPath) + } + if out, err := exec.Command("launchctl", "unload", "-w", plistPath).CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "warning: launchctl unload: %v: %s\n", err, string(out)) + } + if err := os.Remove(plistPath); err != nil { + return fmt.Errorf("removing %s: %w", plistPath, err) + } + fmt.Printf("removed LaunchAgent %s\n", plistPath) + return nil +} + +// renderPlist builds the LaunchAgent plist XML. RunAtLoad starts it at login; +// KeepAlive restarts it if it exits. Output is appended to the log file. +func renderPlist(exePath, cfgPath, logPath string) string { + return fmt.Sprintf(` + + + + Label + %s + ProgramArguments + + %s + run + -config + %s + + RunAtLoad + + KeepAlive + + StandardOutPath + %s + StandardErrorPath + %s + + +`, launchAgentLabel, exePath, cfgPath, logPath, logPath) +} diff --git a/cmd/classicstackd/launchd_other.go b/cmd/classicstackd/launchd_other.go new file mode 100644 index 0000000..69e055a --- /dev/null +++ b/cmd/classicstackd/launchd_other.go @@ -0,0 +1,23 @@ +//go:build !windows && !darwin + +package main + +import ( + "fmt" + "os" +) + +// On Linux (and other non-darwin Unix) there is no LaunchAgent. The daemon +// itself needs no init-system integration — use start/stop/status. For boot +// persistence, point your existing init system at `classicstackd run`. These +// stubs make that explicit rather than coupling to systemd. +func cmdInstall(_ []string) error { + fmt.Fprintln(os.Stderr, "install is not required on this platform: use `classicstackd start` to run in the background.") + fmt.Fprintln(os.Stderr, "For boot persistence, add a unit/init script with ExecStart pointing at `classicstackd run -config `.") + return nil +} + +func cmdUninstall(_ []string) error { + fmt.Fprintln(os.Stderr, "uninstall is not applicable on this platform: stop the daemon with `classicstackd stop`.") + return nil +} diff --git a/cmd/classicstackd/main_unix.go b/cmd/classicstackd/main_unix.go new file mode 100644 index 0000000..53d49b3 --- /dev/null +++ b/cmd/classicstackd/main_unix.go @@ -0,0 +1,253 @@ +//go:build !windows + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/ObsoleteMadness/ClassicStack/internal/app" +) + +const ( + // defaultPIDFile is where the daemon records its child PID. + defaultPIDFile = "/var/run/classicstack.pid" + // defaultLogFile receives the detached daemon's stdout/stderr. + defaultLogFile = "/var/log/classicstack.log" +) + +func main() { + version := app.Version{Version: BuildVersion, Commit: BuildCommit, Date: BuildDate} + + args := os.Args[1:] + if len(args) == 0 { + usage() + os.Exit(2) + } + + cmd := strings.ToLower(args[0]) + if err := dispatch(cmd, args[1:], version); err != nil { + fmt.Fprintf(os.Stderr, "classicstackd %s: %v\n", cmd, err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `classicstackd — run ClassicStack as a background daemon + +Usage: + classicstackd start -config [-pidfile

] [-log

] daemonize + classicstackd stop [-pidfile

] stop the daemon + classicstackd status [-pidfile

] report liveness + classicstackd run -config run in the foreground + classicstackd install -config [-log

] macOS: login item (LaunchAgent) + classicstackd uninstall macOS: remove the LaunchAgent +`) +} + +// dispatch routes a verb to its handler. +func dispatch(cmd string, args []string, version app.Version) error { + switch cmd { + case "start": + return cmdStart(args) + case "stop": + return cmdStop(args) + case "status": + return cmdStatus(args) + case "run": + return cmdRun(args, version) + case "install": + return cmdInstall(args) + case "uninstall", "remove": + return cmdUninstall(args) + case "-h", "--help", "help": + usage() + return nil + default: + usage() + return fmt.Errorf("unknown command %q", cmd) + } +} + +// startFlags parses the flags shared by start/install. +type daemonFlags struct { + config string + pidFile string + logFile string +} + +func parseFlags(name string, args []string, withConfig bool) (daemonFlags, error) { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + cfg := fs.String("config", "", "Path to the TOML config file") + pid := fs.String("pidfile", defaultPIDFile, "Path to the PID file") + logf := fs.String("log", defaultLogFile, "Path to the daemon log file") + if err := fs.Parse(args); err != nil { + return daemonFlags{}, err + } + out := daemonFlags{config: *cfg, pidFile: *pid, logFile: *logf} + if withConfig && strings.TrimSpace(out.config) == "" { + return daemonFlags{}, errors.New("-config is required") + } + if out.config != "" { + abs, err := filepath.Abs(out.config) + if err != nil { + return daemonFlags{}, err + } + out.config = abs + } + return out, nil +} + +// cmdRun runs the stack in the foreground, exactly like `classicstack +// -config `, stopping gracefully on SIGINT/SIGTERM. +func cmdRun(args []string, version app.Version) error { + f, err := parseFlags("run", args, true) + if err != nil { + return err + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return app.Run(ctx, []string{"-config", f.config}, version) +} + +// cmdStart re-execs this binary as `run -config ` in a new session, +// detached from the controlling terminal, with output redirected to the log +// file, and records the child PID. +func cmdStart(args []string) error { + f, err := parseFlags("start", args, true) + if err != nil { + return err + } + + if pid, alive := readPID(f.pidFile); alive { + return fmt.Errorf("already running (pid %d, %s)", pid, f.pidFile) + } + + self, err := os.Executable() + if err != nil { + return fmt.Errorf("locating executable: %w", err) + } + + logFD, err := os.OpenFile(f.logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("opening log file %s: %w", f.logFile, err) + } + defer func() { _ = logFD.Close() }() + + cmd := exec.Command(self, "run", "-config", f.config) + cmd.Stdin = nil + cmd.Stdout = logFD + cmd.Stderr = logFD + // New session so the child has no controlling terminal and survives the + // parent shell exiting (the classic daemonize step). + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + + if err := cmd.Start(); err != nil { + return fmt.Errorf("starting daemon: %w", err) + } + + // Capture the PID before Release: os.Process.Release sets Pid to -1. + childPID := cmd.Process.Pid + + if err := writePID(f.pidFile, childPID); err != nil { + // Best effort: kill the child we just spawned since we cannot track it. + _ = cmd.Process.Kill() + return fmt.Errorf("writing PID file %s: %w", f.pidFile, err) + } + + // Release the child so it keeps running after this process exits. + _ = cmd.Process.Release() + fmt.Printf("started classicstackd (pid %d), logging to %s\n", childPID, f.logFile) + return nil +} + +// cmdStop sends SIGTERM to the recorded PID and waits briefly for exit. +func cmdStop(args []string) error { + f, err := parseFlags("stop", args, false) + if err != nil { + return err + } + pid, alive := readPID(f.pidFile) + if pid == 0 { + return fmt.Errorf("no PID file at %s", f.pidFile) + } + if !alive { + _ = os.Remove(f.pidFile) + return fmt.Errorf("not running (stale PID %d removed)", pid) + } + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + return fmt.Errorf("signalling pid %d: %w", pid, err) + } + // Wait for the process to exit, up to a timeout. + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + if !pidAlive(pid) { + _ = os.Remove(f.pidFile) + fmt.Printf("stopped classicstackd (pid %d)\n", pid) + return nil + } + time.Sleep(300 * time.Millisecond) + } + return fmt.Errorf("timed out waiting for pid %d to exit", pid) +} + +// cmdStatus reports whether the recorded PID is alive. +func cmdStatus(args []string) error { + f, err := parseFlags("status", args, false) + if err != nil { + return err + } + pid, alive := readPID(f.pidFile) + switch { + case pid == 0: + fmt.Println("classicstackd: not running (no PID file)") + case alive: + fmt.Printf("classicstackd: running (pid %d)\n", pid) + default: + fmt.Printf("classicstackd: not running (stale PID %d)\n", pid) + } + return nil +} + +// readPID returns the PID recorded in the file and whether that process is +// alive. A missing/empty file yields (0, false). +func readPID(path string) (int, bool) { + data, err := os.ReadFile(path) + if err != nil { + return 0, false + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || pid <= 0 { + return 0, false + } + return pid, pidAlive(pid) +} + +// pidAlive reports whether a process with the given PID exists, using the +// signal-0 liveness probe. +func pidAlive(pid int) bool { + if pid <= 0 { + return false + } + // On Unix, signal 0 performs error checking without sending a signal. + err := syscall.Kill(pid, 0) + if err == nil { + return true + } + // EPERM means the process exists but we lack permission to signal it. + return errors.Is(err, syscall.EPERM) +} + +func writePID(path string, pid int) error { + return os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o644) +} diff --git a/cmd/classicstackd/stub_windows.go b/cmd/classicstackd/stub_windows.go new file mode 100644 index 0000000..0fd1daf --- /dev/null +++ b/cmd/classicstackd/stub_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" +) + +// The Unix daemon model (fork/setsid + PID file) does not apply on Windows, +// which has its own Service Control Manager. The stub keeps the package +// buildable in a cross-platform `go build ./...`; use classicstack-svc on +// Windows. +func main() { + fmt.Fprintln(os.Stderr, "classicstackd is a Unix daemon; use classicstack-svc on Windows") + os.Exit(1) +} diff --git a/cmd/classicstackd/version.go b/cmd/classicstackd/version.go new file mode 100644 index 0000000..3946849 --- /dev/null +++ b/cmd/classicstackd/version.go @@ -0,0 +1,9 @@ +package main + +// Build metadata injected at link time via -ldflags +// -X main.BuildVersion=... -X main.BuildCommit=... -X main.BuildDate=... +var ( + BuildVersion = "0.0.0-dev" + BuildCommit = "unknown" + BuildDate = "unknown" +) diff --git a/config/fromsource.go b/config/fromsource.go index a6d7e76..92d4f41 100644 --- a/config/fromsource.go +++ b/config/fromsource.go @@ -62,12 +62,14 @@ func FromSource(src Source) *Model { m.MacIP.DHCPRelay = boolv(k, "MacIP.dhcp_relay", m.MacIP.DHCPRelay) m.MacIP.Nameserver = str(k, "MacIP.nameserver", m.MacIP.Nameserver) m.MacIP.Filter = str(k, "MacIP.filter", m.MacIP.Filter) + m.MacIP.Custom = loadCustomInterface(k, "MacIP") m.IPX.Enabled = boolv(k, "IPX.enabled", m.IPX.Enabled) m.IPX.Interface = str(k, "IPX.interface", m.IPX.Interface) m.IPX.Framing = str(k, "IPX.framing", m.IPX.Framing) m.IPX.InternalNetwork = str(k, "IPX.internal_network", m.IPX.InternalNetwork) m.IPX.Filter = str(k, "IPX.filter", m.IPX.Filter) + m.IPX.Custom = loadCustomInterface(k, "IPX") m.IPXGW.Enabled = boolv(k, "IPXGW.enabled", m.IPXGW.Enabled) if k.Exists("IPXGW.bindings") { @@ -77,6 +79,7 @@ func FromSource(src Source) *Model { m.NetBEUI.Enabled = boolv(k, "NetBEUI.enabled", m.NetBEUI.Enabled) m.NetBEUI.Interface = str(k, "NetBEUI.interface", m.NetBEUI.Interface) m.NetBEUI.Filter = str(k, "NetBEUI.filter", m.NetBEUI.Filter) + m.NetBEUI.Custom = loadCustomInterface(k, "NetBEUI") m.NetBIOS.Enabled = boolv(k, "NetBIOS.enabled", m.NetBIOS.Enabled) if k.Exists("NetBIOS.transports") { @@ -167,6 +170,22 @@ func loadVolumes(k *koanf.Koanf) map[string]VolumeModel { return out } +// loadCustomInterface reads a protocol's [

.Custom] sub-table into an +// InterfaceModel. It returns nil when the sub-table is absent, meaning the +// protocol inherits the shared [Bridge] interface. +func loadCustomInterface(k *koanf.Koanf, section string) *InterfaceModel { + base := section + ".Custom" + if !k.Exists(base) { + return nil + } + return &InterfaceModel{ + Mode: str(k, base+".mode", ""), + Device: str(k, base+".device", ""), + HWAddress: str(k, base+".hw_address", ""), + BridgeMode: str(k, base+".bridge_mode", ""), + } +} + func str(k *koanf.Koanf, path, def string) string { if !k.Exists(path) { return def diff --git a/config/model.go b/config/model.go index fd99135..7c87992 100644 --- a/config/model.go +++ b/config/model.go @@ -37,13 +37,22 @@ type LoggingModel struct { ParseOutput string `toml:"parse_output,omitempty" json:"parse_output,omitempty"` } -// BridgeModel is the [Bridge] section. -type BridgeModel struct { - Mode string `toml:"mode,omitempty" json:"mode,omitempty"` - Device string `toml:"device,omitempty" json:"device,omitempty"` - HWAddress string `toml:"hw_address,omitempty" json:"hw_address,omitempty"` - BridgeMode string `toml:"bridge_mode,omitempty" json:"bridge_mode,omitempty"` -} +// InterfaceModel is a virtual/physical interface definition: the link backend +// (Mode), the device it binds to, an optional hardware address, and — for the +// pcap backend — the bridge mode. It is reused by the shared [Bridge] section +// and by any protocol that defines its own [Section.Custom] interface instead +// of inheriting [Bridge]. +type InterfaceModel struct { + Mode string `toml:"mode,omitempty" json:"mode,omitempty"` // pcap | tap | tun (link backend) + Device string `toml:"device,omitempty" json:"device,omitempty"` // pcap device name / tap device + HWAddress string `toml:"hw_address,omitempty" json:"hw_address,omitempty"` // virtual hardware address + BridgeMode string `toml:"bridge_mode,omitempty" json:"bridge_mode,omitempty"` // pcap only: auto | ethernet | wifi +} + +// BridgeModel is the [Bridge] section: the shared virtual interface protocols +// inherit unless they define their own. It is an InterfaceModel; the alias +// keeps the [Bridge] section name and TOML keys unchanged. +type BridgeModel = InterfaceModel // LToUDPModel is the [LToUdp] section. type LToUDPModel struct { @@ -92,6 +101,10 @@ type MacIPModel struct { DHCPRelay bool `toml:"dhcp_relay,omitempty" json:"dhcp_relay,omitempty"` Nameserver string `toml:"nameserver,omitempty" json:"nameserver,omitempty"` Filter string `toml:"filter,omitempty" json:"filter,omitempty"` + // Custom, when set, is MacIP's own [MacIP.Custom] IP-side interface; nil + // means inherit the shared [Bridge] interface. (Distinct from Mode above, + // which selects the gateway behaviour — pcap vs nat.) + Custom *InterfaceModel `toml:"Custom,omitempty" json:"Custom,omitempty"` } // IPXModel is the [IPX] section. @@ -101,6 +114,9 @@ type IPXModel struct { Framing string `toml:"framing,omitempty" json:"framing,omitempty"` InternalNetwork string `toml:"internal_network,omitempty" json:"internal_network,omitempty"` Filter string `toml:"filter,omitempty" json:"filter,omitempty"` + // Custom, when set, is the protocol's own [IPX.Custom] interface; when nil + // the protocol inherits the shared [Bridge] interface. + Custom *InterfaceModel `toml:"Custom,omitempty" json:"Custom,omitempty"` } // IPXGWModel is the [IPXGW] section. @@ -114,6 +130,9 @@ type NetBEUIModel struct { Enabled bool `toml:"enabled" json:"enabled"` Interface string `toml:"interface,omitempty" json:"interface,omitempty"` Filter string `toml:"filter,omitempty" json:"filter,omitempty"` + // Custom, when set, is the protocol's own [NetBEUI.Custom] interface; nil + // means inherit the shared [Bridge] interface. + Custom *InterfaceModel `toml:"Custom,omitempty" json:"Custom,omitempty"` } // NetBIOSModel is the [NetBIOS] section. diff --git a/cmd/classicstack/afp_disabled.go b/internal/app/afp_disabled.go similarity index 98% rename from cmd/classicstack/afp_disabled.go rename to internal/app/afp_disabled.go index 553c796..f0c446d 100644 --- a/cmd/classicstack/afp_disabled.go +++ b/internal/app/afp_disabled.go @@ -1,6 +1,6 @@ //go:build !afp && !all -package main +package app import ( "github.com/ObsoleteMadness/ClassicStack/netlog" diff --git a/cmd/classicstack/afp_enabled.go b/internal/app/afp_enabled.go similarity index 99% rename from cmd/classicstack/afp_enabled.go rename to internal/app/afp_enabled.go index d24fa70..1cbf636 100644 --- a/cmd/classicstack/afp_enabled.go +++ b/internal/app/afp_enabled.go @@ -1,6 +1,6 @@ //go:build afp || all -package main +package app import ( "fmt" diff --git a/cmd/classicstack/afp_hook.go b/internal/app/afp_hook.go similarity index 99% rename from cmd/classicstack/afp_hook.go rename to internal/app/afp_hook.go index 4c8243e..3f098e0 100644 --- a/cmd/classicstack/afp_hook.go +++ b/internal/app/afp_hook.go @@ -1,4 +1,4 @@ -package main +package app import ( "github.com/ObsoleteMadness/ClassicStack/config" diff --git a/cmd/classicstack/bridge_config.go b/internal/app/bridge_config.go similarity index 74% rename from cmd/classicstack/bridge_config.go rename to internal/app/bridge_config.go index 3f7521f..69c249b 100644 --- a/cmd/classicstack/bridge_config.go +++ b/internal/app/bridge_config.go @@ -1,4 +1,4 @@ -package main +package app import ( "fmt" @@ -16,6 +16,16 @@ type BridgeConfig struct { BridgeMode string `koanf:"bridge_mode"` } +// bridgeWithDevice returns a copy of base with Device overridden by iface when +// iface is non-empty. Used to apply a protocol's scalar interface override to +// the shared bridge in the CLI/flag path. +func bridgeWithDevice(base BridgeConfig, iface string) BridgeConfig { + if strings.TrimSpace(iface) != "" { + base.Device = iface + } + return base +} + func defaultBridgeConfig() BridgeConfig { et := ethertalk.DefaultConfig() return BridgeConfig{ diff --git a/cmd/classicstack/capture.go b/internal/app/capture.go similarity index 99% rename from cmd/classicstack/capture.go rename to internal/app/capture.go index dc0b24a..b454ed9 100644 --- a/cmd/classicstack/capture.go +++ b/internal/app/capture.go @@ -1,4 +1,4 @@ -package main +package app import ( "log" diff --git a/cmd/classicstack/config_afp_test.go b/internal/app/config_afp_test.go similarity index 99% rename from cmd/classicstack/config_afp_test.go rename to internal/app/config_afp_test.go index 704721f..281dbb1 100644 --- a/cmd/classicstack/config_afp_test.go +++ b/internal/app/config_afp_test.go @@ -1,6 +1,6 @@ //go:build afp || all -package main +package app import ( "os" diff --git a/cmd/classicstack/config_flags.go b/internal/app/config_flags.go similarity index 93% rename from cmd/classicstack/config_flags.go rename to internal/app/config_flags.go index b4e13b3..8cda73f 100644 --- a/cmd/classicstack/config_flags.go +++ b/internal/app/config_flags.go @@ -1,4 +1,4 @@ -package main +package app import ( "strings" @@ -123,6 +123,13 @@ func flagsToConfig(in flagInputs) appConfig { HWAddress: firstNonBlank(in.BridgeHWAddress, in.EtherTalkHWAddress), BridgeMode: firstNonBlank(in.BridgeBridgeMode, in.EtherTalkBridgeMode), } + // The CLI/flag path has no per-protocol [
.Custom] interface, so + // each protocol shares the bridge, with only its scalar interface flag + // overriding the device. (The UI/Model path computes these in + // appConfigFromModel via resolveProtocolInterface.) + cfg.IPXBridge = bridgeWithDevice(cfg.Bridge, in.IPXInterface) + cfg.NetBEUIBridge = bridgeWithDevice(cfg.Bridge, in.NetBEUIInterface) + cfg.MacIPBridge = cfg.Bridge cfg.EtherTalk = ethertalk.Config{ Device: cfg.Bridge.Device, diff --git a/cmd/classicstack/config_ini.go b/internal/app/config_ini.go similarity index 92% rename from cmd/classicstack/config_ini.go rename to internal/app/config_ini.go index 36f3fc0..911fc2a 100644 --- a/cmd/classicstack/config_ini.go +++ b/internal/app/config_ini.go @@ -1,4 +1,4 @@ -package main +package app import ( "fmt" @@ -31,6 +31,14 @@ type appConfig struct { EtherTalk ethertalk.Config Capture capture.Config + // Per-protocol effective interfaces. Each defaults to the shared Bridge + // and is overridden when the protocol defines its own [
.Custom] + // interface. buildHooks passes these (not the raw Bridge) into the + // wireXxx calls so a protocol can bind to its own device/mode/MAC. + IPXBridge BridgeConfig + NetBEUIBridge BridgeConfig + MacIPBridge BridgeConfig + MacIPEnabled bool MacIPNAT bool MacIPSubnet string @@ -253,6 +261,19 @@ func syncBridgeToEtherTalk(cfg *appConfig) { if cfg.EtherTalk.Backend == "" { cfg.EtherTalk.Device = "" } + + // Default any per-protocol interface that was not set by a config path + // (e.g. the INI loader) to the shared bridge, so every path leaves the + // per-protocol bridges populated for buildHooks. + if cfg.IPXBridge == (BridgeConfig{}) { + cfg.IPXBridge = bridgeWithDevice(cfg.Bridge, cfg.IPXInterface) + } + if cfg.NetBEUIBridge == (BridgeConfig{}) { + cfg.NetBEUIBridge = bridgeWithDevice(cfg.Bridge, cfg.NetBEUIInterface) + } + if cfg.MacIPBridge == (BridgeConfig{}) { + cfg.MacIPBridge = cfg.Bridge + } } // normalizeSMBIdentity makes SMB identity canonical and keeps NetBIOS diff --git a/cmd/classicstack/config_model.go b/internal/app/config_model.go similarity index 74% rename from cmd/classicstack/config_model.go rename to internal/app/config_model.go index caacb7b..f7e72cc 100644 --- a/cmd/classicstack/config_model.go +++ b/internal/app/config_model.go @@ -1,4 +1,4 @@ -package main +package app import ( "github.com/ObsoleteMadness/ClassicStack/config" @@ -24,6 +24,12 @@ func appConfigFromModel(m *config.Model) (appConfig, error) { HWAddress: m.Bridge.HWAddress, BridgeMode: m.Bridge.BridgeMode, } + // Each interface-bound protocol inherits the shared Bridge unless it + // defines its own [
.Custom] interface. The scalar `interface` + // string still overrides just the device for back-compat. + cfg.IPXBridge = resolveProtocolInterface(cfg.Bridge, m.IPX.Custom, m.IPX.Interface) + cfg.NetBEUIBridge = resolveProtocolInterface(cfg.Bridge, m.NetBEUI.Custom, m.NetBEUI.Interface) + cfg.MacIPBridge = resolveProtocolInterface(cfg.Bridge, m.MacIP.Custom, "") cfg.LToUDP = localtalk.LToUDPConfig{ Enabled: m.LToUDP.Enabled, @@ -108,6 +114,56 @@ func appConfigFromModel(m *config.Model) (appConfig, error) { return cfg, nil } +// resolveProtocolInterface computes a protocol's effective interface. When +// custom is nil the protocol inherits the shared bridge; the scalar iface +// string (the legacy `
.interface` key) still overrides the device for +// back-compat. When custom is set, its non-empty fields override the bridge, +// and iface is the device fallback when custom.Device is empty. +func resolveProtocolInterface(bridge BridgeConfig, custom *config.InterfaceModel, iface string) BridgeConfig { + out := bridge + if custom != nil { + if custom.Mode != "" { + out.Mode = custom.Mode + } + if custom.Device != "" { + out.Device = custom.Device + } + if custom.HWAddress != "" { + out.HWAddress = custom.HWAddress + } + if custom.BridgeMode != "" { + out.BridgeMode = custom.BridgeMode + } + } + if iface != "" && (custom == nil || custom.Device == "") { + out.Device = iface + } + return out +} + +// customIfDiffers reconstructs a protocol's [
.Custom] interface for +// the Model projection. It returns nil when the protocol's effective interface +// matches the shared bridge (only the device possibly overridden by the scalar +// iface, which is serialised separately) — so a Bridge-inheriting protocol +// stays clean. Otherwise it returns the differing interface as Custom. +func customIfDiffers(proto, bridge BridgeConfig, iface string) *config.InterfaceModel { + // Account for the scalar interface override: a proto that only differs by a + // device equal to iface is still "Bridge + scalar interface", not Custom. + cmp := proto + if iface != "" && cmp.Device == iface { + cmp.Device = bridge.Device + } + if cmp == bridge { + return nil + } + return &config.InterfaceModel{ + Mode: proto.Mode, + Device: proto.Device, + HWAddress: proto.HWAddress, + BridgeMode: proto.BridgeMode, + } +} + // modelFromAppConfig is the inverse of appConfigFromModel: it projects the // resolved cmd-local appConfig back into a config.Model so the management // plane has a serialisable, editable view that matches what is running. @@ -163,12 +219,14 @@ func modelFromAppConfig(cfg appConfig) *config.Model { m.MacIP.LeaseFile = cfg.MacIPLeaseFile m.MacIP.Zone = cfg.MacIPZone m.MacIP.Filter = cfg.MacIPFilter + m.MacIP.Custom = customIfDiffers(cfg.MacIPBridge, cfg.Bridge, "") m.IPX.Enabled = cfg.IPXEnabled m.IPX.Interface = cfg.IPXInterface m.IPX.Framing = cfg.IPXFraming m.IPX.InternalNetwork = cfg.IPXInternalNetwork m.IPX.Filter = cfg.IPXFilter + m.IPX.Custom = customIfDiffers(cfg.IPXBridge, cfg.Bridge, cfg.IPXInterface) m.IPXGW.Enabled = cfg.IPXGWEnabled for _, b := range cfg.IPXGWBindings { @@ -178,6 +236,7 @@ func modelFromAppConfig(cfg appConfig) *config.Model { m.NetBEUI.Enabled = cfg.NetBEUIEnabled m.NetBEUI.Interface = cfg.NetBEUIInterface m.NetBEUI.Filter = cfg.NetBEUIFilter + m.NetBEUI.Custom = customIfDiffers(cfg.NetBEUIBridge, cfg.Bridge, cfg.NetBEUIInterface) m.NetBIOS.Enabled = cfg.NetBIOSEnabled m.NetBIOS.Transports = cfg.NetBIOSTransports diff --git a/cmd/classicstack/config_test.go b/internal/app/config_test.go similarity index 99% rename from cmd/classicstack/config_test.go rename to internal/app/config_test.go index 106d9cb..3ac16da 100644 --- a/cmd/classicstack/config_test.go +++ b/internal/app/config_test.go @@ -1,4 +1,4 @@ -package main +package app import ( "os" diff --git a/cmd/classicstack/diagnostics_impl.go b/internal/app/diagnostics_impl.go similarity index 99% rename from cmd/classicstack/diagnostics_impl.go rename to internal/app/diagnostics_impl.go index 6d07552..24fef42 100644 --- a/cmd/classicstack/diagnostics_impl.go +++ b/internal/app/diagnostics_impl.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" diff --git a/internal/app/doc.go b/internal/app/doc.go new file mode 100644 index 0000000..b328097 --- /dev/null +++ b/internal/app/doc.go @@ -0,0 +1,13 @@ +// Package app is the ClassicStack run-core: it parses CLI flags and the +// optional TOML config, builds the Supervisor (ports, the AppleTalk router and +// its DDP service set, and the standalone IPX/NetBEUI/NetBIOS/SMB/WebUI hooks), +// wires the management plane, and runs the stack until its context is +// cancelled. +// +// It exposes two entry points so the interactive binary and the +// service/daemon wrappers share one runtime: Main(Version) for foreground use +// (Ctrl-C / SIGTERM) and Run(ctx, args, Version) for callers that drive the +// lifecycle themselves (the Windows service and the Unix daemon). Build tags +// gate the optional subsystems exactly as before the package was split out of +// cmd/classicstack. +package app diff --git a/cmd/classicstack/extension_map.go b/internal/app/extension_map.go similarity index 98% rename from cmd/classicstack/extension_map.go rename to internal/app/extension_map.go index f04a7d4..53f7914 100644 --- a/cmd/classicstack/extension_map.go +++ b/internal/app/extension_map.go @@ -1,6 +1,6 @@ //go:build afp || all -package main +package app import ( "fmt" diff --git a/cmd/classicstack/extension_map_test.go b/internal/app/extension_map_test.go similarity index 98% rename from cmd/classicstack/extension_map_test.go rename to internal/app/extension_map_test.go index a98c7a8..421287a 100644 --- a/cmd/classicstack/extension_map_test.go +++ b/internal/app/extension_map_test.go @@ -1,6 +1,6 @@ //go:build afp || all -package main +package app import "testing" diff --git a/internal/app/fstypes_disabled.go b/internal/app/fstypes_disabled.go new file mode 100644 index 0000000..350bf34 --- /dev/null +++ b/internal/app/fstypes_disabled.go @@ -0,0 +1,9 @@ +//go:build !afp && !all + +package app + +// registeredFSTypes returns the default FS-type list when the binary is built +// without AFP. Only the local filesystem backend is meaningful in that case. +func registeredFSTypes() []string { + return []string{"local_fs"} +} diff --git a/internal/app/fstypes_enabled.go b/internal/app/fstypes_enabled.go new file mode 100644 index 0000000..d2ab562 --- /dev/null +++ b/internal/app/fstypes_enabled.go @@ -0,0 +1,12 @@ +//go:build afp || all + +package app + +import "github.com/ObsoleteMadness/ClassicStack/service/afp" + +// registeredFSTypes returns the AFP filesystem types registered in this build +// (local_fs, plus macgarden when built with that tag). Used by the management +// plane to populate the volume/share FS-type dropdown. +func registeredFSTypes() []string { + return afp.RegisteredFSTypes() +} diff --git a/internal/app/interface_resolve_test.go b/internal/app/interface_resolve_test.go new file mode 100644 index 0000000..0c7ca5b --- /dev/null +++ b/internal/app/interface_resolve_test.go @@ -0,0 +1,85 @@ +package app + +import ( + "testing" + + "github.com/ObsoleteMadness/ClassicStack/config" +) + +// TestResolveProtocolInterface_BridgeInheritance verifies the Bridge vs Custom +// model: a protocol with no Custom interface inherits the shared Bridge; the +// legacy scalar interface string overrides only the device. +func TestResolveProtocolInterface_BridgeInheritance(t *testing.T) { + bridge := BridgeConfig{Mode: "pcap", Device: "br0", HWAddress: "aa:bb", BridgeMode: "auto"} + + // No custom, no scalar iface -> exactly the bridge. + if got := resolveProtocolInterface(bridge, nil, ""); got != bridge { + t.Fatalf("inherit: got %+v, want %+v", got, bridge) + } + + // Scalar iface overrides only the device. + got := resolveProtocolInterface(bridge, nil, "eth9") + want := bridge + want.Device = "eth9" + if got != want { + t.Fatalf("scalar override: got %+v, want %+v", got, want) + } +} + +// TestResolveProtocolInterface_Custom verifies a Custom interface overrides the +// bridge field-by-field, with the scalar iface as device fallback. +func TestResolveProtocolInterface_Custom(t *testing.T) { + bridge := BridgeConfig{Mode: "pcap", Device: "br0", HWAddress: "aa:bb", BridgeMode: "auto"} + + got := resolveProtocolInterface(bridge, &config.InterfaceModel{ + Mode: "tap", + Device: "tap0", + HWAddress: "cc:dd", + BridgeMode: "ethernet", + }, "") + want := BridgeConfig{Mode: "tap", Device: "tap0", HWAddress: "cc:dd", BridgeMode: "ethernet"} + if got != want { + t.Fatalf("custom: got %+v, want %+v", got, want) + } + + // Empty custom fields fall back to bridge; empty Custom.Device falls back + // to the scalar iface. + got = resolveProtocolInterface(bridge, &config.InterfaceModel{Mode: "tun"}, "eth5") + want = BridgeConfig{Mode: "tun", Device: "eth5", HWAddress: "aa:bb", BridgeMode: "auto"} + if got != want { + t.Fatalf("custom partial: got %+v, want %+v", got, want) + } +} + +// TestInterfaceRoundTrip verifies a Model with a Custom IPX interface survives +// appConfigFromModel -> modelFromAppConfig, and that a Bridge-only protocol +// stays Custom-free (clean config). +func TestInterfaceRoundTrip(t *testing.T) { + m := config.Defaults() + m.Bridge = config.InterfaceModel{Mode: "pcap", Device: "br0", HWAddress: "aa:bb", BridgeMode: "auto"} + m.IPX.Enabled = true + m.IPX.Custom = &config.InterfaceModel{Mode: "pcap", Device: "ipx0", BridgeMode: "wifi"} + m.NetBEUI.Enabled = true // Bridge-inheriting (no Custom) + + cfg, err := appConfigFromModel(m) + if err != nil { + t.Fatalf("appConfigFromModel: %v", err) + } + if cfg.IPXBridge.Device != "ipx0" || cfg.IPXBridge.BridgeMode != "wifi" { + t.Fatalf("IPX resolved interface = %+v, want device ipx0 / bridge_mode wifi", cfg.IPXBridge) + } + if cfg.NetBEUIBridge.Device != "br0" { + t.Fatalf("NetBEUI should inherit bridge device br0, got %q", cfg.NetBEUIBridge.Device) + } + + back := modelFromAppConfig(cfg) + if back.IPX.Custom == nil { + t.Fatal("round-trip lost IPX.Custom") + } + if back.IPX.Custom.Device != "ipx0" { + t.Fatalf("round-trip IPX.Custom.Device = %q, want ipx0", back.IPX.Custom.Device) + } + if back.NetBEUI.Custom != nil { + t.Fatalf("Bridge-inheriting NetBEUI should have no Custom, got %+v", back.NetBEUI.Custom) + } +} diff --git a/cmd/classicstack/ipx_disabled.go b/internal/app/ipx_disabled.go similarity index 98% rename from cmd/classicstack/ipx_disabled.go rename to internal/app/ipx_disabled.go index 74961f8..9ca3776 100644 --- a/cmd/classicstack/ipx_disabled.go +++ b/internal/app/ipx_disabled.go @@ -1,6 +1,6 @@ //go:build !ipx && !all -package main +package app import ( "context" diff --git a/cmd/classicstack/ipx_enabled.go b/internal/app/ipx_enabled.go similarity index 69% rename from cmd/classicstack/ipx_enabled.go rename to internal/app/ipx_enabled.go index cb0376f..313f254 100644 --- a/cmd/classicstack/ipx_enabled.go +++ b/internal/app/ipx_enabled.go @@ -1,6 +1,6 @@ //go:build ipx || all -package main +package app import ( "context" @@ -22,7 +22,13 @@ type ipxHookEnabled struct { port ipx.Port rip *ipxsvc.RIPService sap *ipxsvc.SAPService - sink *capture.PcapSink + + // capturePath/captureSnaplen describe the optional frame-capture sink. + // The sink is opened on each Start and closed on each Stop so a + // UI-driven restart reopens it alongside the port's fresh rawlink. + capturePath string + captureSnaplen uint32 + sink *capture.PcapSink } func (h *ipxHookEnabled) Router() routeripx.Router { return h.router } @@ -30,6 +36,17 @@ func (h *ipxHookEnabled) SAP() *ipxsvc.SAPService { return h.sap } func (h *ipxHookEnabled) Start(ctx context.Context) error { if h.port != nil { + // (Re)open the capture sink before the port starts reading so no + // frames are missed between Start and the first write. + if h.capturePath != "" && h.sink == nil { + sink, err := capture.NewPcapSink(h.capturePath, capture.LinkTypeEthernet, h.captureSnaplen) + if err != nil { + return fmt.Errorf("opening IPX capture sink %q: %w", h.capturePath, err) + } + h.sink = sink + h.port.SetCaptureSink(sink) + netlog.Info("[CAPTURE] IPX frames -> %s", h.capturePath) + } if err := h.port.Start(); err != nil { return err } @@ -77,27 +94,35 @@ func wireIPX(cfg IPXConfig) (IPXHook, error) { return nil, fmt.Errorf("parsing -ipx-internal-network: %w", err) } - link := cfg.Rawlink - if link == nil && strings.TrimSpace(cfg.Interface) != "" { - opened, err := openRawlink(cfg.BridgeMode, cfg.Interface, rawlinkProfileIPX) - if err != nil { - return nil, fmt.Errorf("opening IPX rawlink on %q: %w", cfg.Interface, err) - } - link = applyRawlinkBridgeFrameMode(opened, cfg.BridgeMode, cfg.BridgeFrameMode, cfg.Interface, cfg.BridgeHWAddress, "IPX") - applyRawlinkFilter(link, cfg.BridgeMode, cfg.Interface, cfg.Filter, "ipx", "IPX") - } - if link != nil { - framing := parseIPXFraming(cfg.Framing) - hook.port = ipx.NewPortWithFraming(link, framing) - if strings.TrimSpace(cfg.CapturePath) != "" { - sink, err := capture.NewPcapSink(cfg.CapturePath, capture.LinkTypeEthernet, cfg.CaptureSnaplen) + // openLink lazily produces a rawlink. For a configured interface it + // opens a fresh libpcap handle on every call so the port can be stopped + // and restarted from the UI: each Stop frees the C handle and each Start + // reopens the interface. A pre-built cfg.Rawlink (tests, in-process + // transports) is reused as-is. A nil factory means "no link configured". + var openLink ipx.LinkFactory + switch { + case cfg.Rawlink != nil: + prebuilt := cfg.Rawlink + openLink = func() (rawlink.RawLink, error) { return prebuilt, nil } + case strings.TrimSpace(cfg.Interface) != "": + openLink = func() (rawlink.RawLink, error) { + opened, err := openRawlink(cfg.BridgeMode, cfg.Interface, rawlinkProfileIPX) if err != nil { - return nil, fmt.Errorf("opening IPX capture sink %q: %w", cfg.CapturePath, err) + return nil, fmt.Errorf("opening IPX rawlink on %q: %w", cfg.Interface, err) } - hook.sink = sink - hook.port.SetCaptureSink(sink) - netlog.Info("[CAPTURE] IPX frames -> %s", cfg.CapturePath) + link := applyRawlinkBridgeFrameMode(opened, cfg.BridgeMode, cfg.BridgeFrameMode, cfg.Interface, cfg.BridgeHWAddress, "IPX") + applyRawlinkFilter(link, cfg.BridgeMode, cfg.Interface, cfg.Filter, "ipx", "IPX") + return link, nil } + } + + if openLink != nil { + framing := parseIPXFraming(cfg.Framing) + hook.port = ipx.NewPortWithLinkFactory(openLink, framing) + // The sink itself is opened on each Start (see Start) so it is + // reopened across UI restarts; here we just record its config. + hook.capturePath = strings.TrimSpace(cfg.CapturePath) + hook.captureSnaplen = cfg.CaptureSnaplen router.AddPort(hook.port) node, ok := resolveIPXNodeFromInterface(cfg.Interface) diff --git a/cmd/classicstack/ipx_enabled_test.go b/internal/app/ipx_enabled_test.go similarity index 93% rename from cmd/classicstack/ipx_enabled_test.go rename to internal/app/ipx_enabled_test.go index ed1dcd4..adef708 100644 --- a/cmd/classicstack/ipx_enabled_test.go +++ b/internal/app/ipx_enabled_test.go @@ -1,6 +1,6 @@ //go:build ipx || all -package main +package app import ( "testing" @@ -32,9 +32,9 @@ func TestParseIPXNetwork(t *testing.T) { func TestParseIPXNetworkErrors(t *testing.T) { for _, in := range []string{ - "DEAD", // too short + "DEAD", // too short "DEADBEEFCC", // too long - "GHIJKLMN", // non-hex + "GHIJKLMN", // non-hex } { if _, err := parseIPXNetwork(in); err == nil { t.Errorf("parseIPXNetwork(%q) accepted invalid input", in) diff --git a/cmd/classicstack/ipx_hook.go b/internal/app/ipx_hook.go similarity index 98% rename from cmd/classicstack/ipx_hook.go rename to internal/app/ipx_hook.go index dc76861..118a83d 100644 --- a/cmd/classicstack/ipx_hook.go +++ b/internal/app/ipx_hook.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" diff --git a/cmd/classicstack/ipxgw_disabled.go b/internal/app/ipxgw_disabled.go similarity index 95% rename from cmd/classicstack/ipxgw_disabled.go rename to internal/app/ipxgw_disabled.go index f0d33ce..f335b56 100644 --- a/cmd/classicstack/ipxgw_disabled.go +++ b/internal/app/ipxgw_disabled.go @@ -1,6 +1,6 @@ //go:build !ipxgw && !all -package main +package app import "github.com/ObsoleteMadness/ClassicStack/netlog" diff --git a/cmd/classicstack/ipxgw_enabled.go b/internal/app/ipxgw_enabled.go similarity index 98% rename from cmd/classicstack/ipxgw_enabled.go rename to internal/app/ipxgw_enabled.go index 1f4d4d4..56ce3c4 100644 --- a/cmd/classicstack/ipxgw_enabled.go +++ b/internal/app/ipxgw_enabled.go @@ -1,6 +1,6 @@ //go:build ipxgw || all -package main +package app import ( "github.com/ObsoleteMadness/ClassicStack/netlog" diff --git a/cmd/classicstack/ipxgw_hook.go b/internal/app/ipxgw_hook.go similarity index 99% rename from cmd/classicstack/ipxgw_hook.go rename to internal/app/ipxgw_hook.go index ee33a67..d8bf34b 100644 --- a/cmd/classicstack/ipxgw_hook.go +++ b/internal/app/ipxgw_hook.go @@ -1,4 +1,4 @@ -package main +package app import ( routeripx "github.com/ObsoleteMadness/ClassicStack/router/ipx" diff --git a/cmd/classicstack/macgarden_register.go b/internal/app/macgarden_register.go similarity index 89% rename from cmd/classicstack/macgarden_register.go rename to internal/app/macgarden_register.go index 4a03def..5d3329f 100644 --- a/cmd/classicstack/macgarden_register.go +++ b/internal/app/macgarden_register.go @@ -1,5 +1,5 @@ //go:build (afp && macgarden) || all -package main +package app import _ "github.com/ObsoleteMadness/ClassicStack/service/afpfs/macgarden" diff --git a/cmd/classicstack/macip_disabled.go b/internal/app/macip_disabled.go similarity index 97% rename from cmd/classicstack/macip_disabled.go rename to internal/app/macip_disabled.go index 81c75fc..9d366a5 100644 --- a/cmd/classicstack/macip_disabled.go +++ b/internal/app/macip_disabled.go @@ -1,6 +1,6 @@ //go:build !macip && !all -package main +package app import "github.com/ObsoleteMadness/ClassicStack/netlog" diff --git a/cmd/classicstack/macip_enabled.go b/internal/app/macip_enabled.go similarity index 86% rename from cmd/classicstack/macip_enabled.go rename to internal/app/macip_enabled.go index 98c302d..5d97e8c 100644 --- a/cmd/classicstack/macip_enabled.go +++ b/internal/app/macip_enabled.go @@ -1,6 +1,6 @@ //go:build macip || all -package main +package app import ( "fmt" @@ -131,12 +131,24 @@ func wireMacIP(cfg MacIPConfig) (MacIPHook, error) { chosenZone = []byte(cfg.EtherTalkZone) } - ipLink, err := openRawlink(bridgeMode, ipIface, rawlinkProfileMacIP) + // openIPLink opens and BPF-filters a fresh MacIP rawlink. It is used + // both for the initial link and (via SetLinkFactory) on every restart, + // so a UI stop/start reopens the interface instead of reusing the freed + // handle. + openIPLink := func() (rawlink.RawLink, error) { + link, err := openRawlink(bridgeMode, ipIface, rawlinkProfileMacIP) + if err != nil { + return nil, fmt.Errorf("failed opening MacIP rawlink on %s: %w", ipIface, err) + } + link = applyRawlinkBridgeFrameMode(link, bridgeMode, cfg.BridgeFrameMode, ipIface, cfg.BridgeHWAddress, "MacIP") + applyRawlinkFilter(link, bridgeMode, ipIface, cfg.Filter, macipBPFFilter(ipNet, cfg.DHCPRelay), "MacIP") + return link, nil + } + + ipLink, err := openIPLink() if err != nil { - return nil, fmt.Errorf("failed opening MacIP rawlink on %s: %w", ipIface, err) + return nil, err } - ipLink = applyRawlinkBridgeFrameMode(ipLink, bridgeMode, cfg.BridgeFrameMode, ipIface, cfg.BridgeHWAddress, "MacIP") - applyRawlinkFilter(ipLink, bridgeMode, ipIface, cfg.Filter, macipBPFFilter(ipNet, cfg.DHCPRelay), "MacIP") svc := macip.New( gwIP, ipNet.IP, ipNet.Mask, @@ -148,6 +160,8 @@ func wireMacIP(cfg MacIPConfig) (MacIPHook, error) { cfg.DHCPRelay, cfg.StateFile, ) + // On Stop the service closes ipLink; reopen it on each subsequent Start. + svc.SetLinkFactory(openIPLink) netlog.Info("[MAIN][MacIP] gw=%s subnet=%s iface=%s host-ip=%s ip-gw=%s zone=%q nat=%t dhcp_relay=%t", gwIP, cfg.NATSubnet, ipIface, hostIP, ipGW, string(chosenZone), cfg.NAT, cfg.DHCPRelay) return &macipHook{svc: svc}, nil diff --git a/cmd/classicstack/macip_hook.go b/internal/app/macip_hook.go similarity index 99% rename from cmd/classicstack/macip_hook.go rename to internal/app/macip_hook.go index ce9e204..d20b06d 100644 --- a/cmd/classicstack/macip_hook.go +++ b/internal/app/macip_hook.go @@ -1,4 +1,4 @@ -package main +package app import ( "github.com/ObsoleteMadness/ClassicStack/service" diff --git a/cmd/classicstack/macip_test.go b/internal/app/macip_test.go similarity index 98% rename from cmd/classicstack/macip_test.go rename to internal/app/macip_test.go index 5c827e2..8fff5e7 100644 --- a/cmd/classicstack/macip_test.go +++ b/internal/app/macip_test.go @@ -1,6 +1,6 @@ //go:build macip || all -package main +package app import ( "net" diff --git a/cmd/classicstack/main_macip_test.go b/internal/app/main_macip_test.go similarity index 98% rename from cmd/classicstack/main_macip_test.go rename to internal/app/main_macip_test.go index 374cae8..0561fc8 100644 --- a/cmd/classicstack/main_macip_test.go +++ b/internal/app/main_macip_test.go @@ -1,4 +1,4 @@ -package main +package app import "testing" diff --git a/cmd/classicstack/mainwiring.go b/internal/app/mainwiring.go similarity index 99% rename from cmd/classicstack/mainwiring.go rename to internal/app/mainwiring.go index 71592ff..a21773a 100644 --- a/cmd/classicstack/mainwiring.go +++ b/internal/app/mainwiring.go @@ -1,4 +1,4 @@ -package main +package app import ( "github.com/ObsoleteMadness/ClassicStack/config" diff --git a/cmd/classicstack/netbeui_disabled.go b/internal/app/netbeui_disabled.go similarity index 98% rename from cmd/classicstack/netbeui_disabled.go rename to internal/app/netbeui_disabled.go index 10dad17..01cc350 100644 --- a/cmd/classicstack/netbeui_disabled.go +++ b/internal/app/netbeui_disabled.go @@ -1,6 +1,6 @@ //go:build !netbeui && !all -package main +package app import ( "context" diff --git a/internal/app/netbeui_enabled.go b/internal/app/netbeui_enabled.go new file mode 100644 index 0000000..43d1a3b --- /dev/null +++ b/internal/app/netbeui_enabled.go @@ -0,0 +1,106 @@ +//go:build netbeui || all + +package app + +import ( + "context" + "fmt" + "strings" + + "github.com/ObsoleteMadness/ClassicStack/capture" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/hwaddr" + "github.com/ObsoleteMadness/ClassicStack/port/netbeui" + "github.com/ObsoleteMadness/ClassicStack/port/rawlink" +) + +type netbeuiHookEnabled struct { + port netbeui.Port + mac [6]byte + + // capture sink config; reopened on each Start so a UI restart reopens + // it alongside the port's fresh rawlink. + capturePath string + captureSnaplen uint32 + sink *capture.PcapSink +} + +func (h *netbeuiHookEnabled) Start(_ context.Context) error { + if h.port != nil { + if h.capturePath != "" && h.sink == nil { + sink, err := capture.NewPcapSink(h.capturePath, capture.LinkTypeEthernet, h.captureSnaplen) + if err != nil { + return fmt.Errorf("opening NetBEUI capture sink %q: %w", h.capturePath, err) + } + h.sink = sink + h.port.SetCaptureSink(sink) + netlog.Info("[CAPTURE] NetBEUI frames -> %s", h.capturePath) + } + if err := h.port.Start(); err != nil { + return err + } + } + netlog.Info("[MAIN][NetBEUI] port up") + return nil +} +func (h *netbeuiHookEnabled) Stop() error { + if h.port != nil { + _ = h.port.Stop() + } + if h.sink != nil { + _ = h.sink.Close() + h.sink = nil + } + return nil +} +func (h *netbeuiHookEnabled) Port() netbeui.Port { return h.port } +func (h *netbeuiHookEnabled) MAC() [6]byte { return h.mac } + +func wireNetBEUI(cfg NetBEUIConfig) (NetBEUIHook, error) { + if !cfg.Enabled { + return nil, nil + } + // openLink opens a fresh rawlink per Start (see the IPX hook) so the + // port can be stopped and restarted from the UI. A pre-built + // cfg.Rawlink is reused as-is. + var openLink netbeui.LinkFactory + switch { + case cfg.Rawlink != nil: + prebuilt := cfg.Rawlink + openLink = func() (rawlink.RawLink, error) { return prebuilt, nil } + case strings.TrimSpace(cfg.Interface) != "": + openLink = func() (rawlink.RawLink, error) { + opened, err := openRawlink(cfg.BridgeMode, cfg.Interface, rawlinkProfileNetBEUI) + if err != nil { + return nil, fmt.Errorf("opening NetBEUI rawlink on %q: %w", cfg.Interface, err) + } + link := applyRawlinkBridgeFrameMode(opened, cfg.BridgeMode, cfg.BridgeFrameMode, cfg.Interface, cfg.BridgeHWAddress, "NetBEUI") + applyRawlinkFilter(link, cfg.BridgeMode, cfg.Interface, cfg.Filter, "llc", "NetBEUI") + return link, nil + } + } + if openLink == nil { + netlog.Warn("[MAIN][NetBEUI] enabled but no -netbeui-interface configured; NetBEUI idle") + return &netbeuiHookEnabled{}, nil + } + netlog.Info("[MAIN][NetBEUI] pcap interface=%s", cfg.Interface) + p := netbeui.NewPortWithLinkFactory(openLink) + var mac [6]byte + if macStr, ok := rawlink.DetectHostMACForPcapInterface(cfg.Interface); ok { + if parsed, err := hwaddr.ParseEthernet(macStr); err == nil { + mac = [6]byte(parsed) + p.SetSourceMAC(mac) + } + } else if parsed, err := hwaddr.ParseEthernet(strings.TrimSpace(cfg.BridgeHWAddress)); err == nil { + mac = [6]byte(parsed) + p.SetSourceMAC(mac) + } + + hook := &netbeuiHookEnabled{ + port: p, + mac: mac, + capturePath: strings.TrimSpace(cfg.CapturePath), + captureSnaplen: cfg.CaptureSnaplen, + } + return hook, nil +} diff --git a/cmd/classicstack/netbeui_hook.go b/internal/app/netbeui_hook.go similarity index 98% rename from cmd/classicstack/netbeui_hook.go rename to internal/app/netbeui_hook.go index 6822d48..4d3abd4 100644 --- a/cmd/classicstack/netbeui_hook.go +++ b/internal/app/netbeui_hook.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" diff --git a/internal/app/netbios_disabled.go b/internal/app/netbios_disabled.go new file mode 100644 index 0000000..60b19a1 --- /dev/null +++ b/internal/app/netbios_disabled.go @@ -0,0 +1,25 @@ +//go:build !netbios && !all + +package app + +import ( + "context" + + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/service/netbios" +) + +type netbiosHookDisabled struct{} + +func (netbiosHookDisabled) Start(_ context.Context) error { return nil } +func (netbiosHookDisabled) Stop() error { return nil } +func (netbiosHookDisabled) NameService() netbios.NameService { return nil } +func (netbiosHookDisabled) Service() *netbios.Service { return nil } +func (netbiosHookDisabled) BuildTransport(_ string) netbios.Transport { return nil } + +func wireNetBIOS(cfg NetBIOSConfig) (NetBIOSHook, error) { + if cfg.Enabled { + netlog.Warn("[MAIN][NetBIOS] -netbios-enabled set but binary was built without -tags netbios; ignoring") + } + return netbiosHookDisabled{}, nil +} diff --git a/internal/app/netbios_enabled.go b/internal/app/netbios_enabled.go new file mode 100644 index 0000000..3a24663 --- /dev/null +++ b/internal/app/netbios_enabled.go @@ -0,0 +1,111 @@ +//go:build netbios || all + +package app + +import ( + "context" + + "github.com/ObsoleteMadness/ClassicStack/netlog" + netbiosproto "github.com/ObsoleteMadness/ClassicStack/protocol/netbios" + "github.com/ObsoleteMadness/ClassicStack/service/netbios" + "github.com/ObsoleteMadness/ClassicStack/service/netbios/over_ipx" + "github.com/ObsoleteMadness/ClassicStack/service/netbios/over_netbeui" + "github.com/ObsoleteMadness/ClassicStack/service/netbios/over_tcp" +) + +type netbiosHookEnabled struct { + svc *netbios.Service + builders []netbiosNamedBuilder +} + +// Start binds every configured transport by name, then brings the service +// up (which starts the bound transports). Binding before Start means the +// service starts each transport exactly once. +func (h *netbiosHookEnabled) Start(ctx context.Context) error { + for _, b := range h.builders { + if err := h.svc.AddTransport(b.name, b.build()); err != nil { + netlog.Warn("[MAIN][NetBIOS] bind transport %q: %v", b.name, err) + } + } + return h.svc.Start(ctx) +} + +func (h *netbiosHookEnabled) Stop() error { return h.svc.Stop() } +func (h *netbiosHookEnabled) NameService() netbios.NameService { return h.svc.NameService() } +func (h *netbiosHookEnabled) Service() *netbios.Service { return h.svc } + +// BuildTransport returns a freshly built transport bound under the canonical +// protocol name, or nil if that protocol is not a configured NetBIOS +// transport. The supervisor uses it to re-attach a transport when its +// underlying protocol is started again from the UI. +func (h *netbiosHookEnabled) BuildTransport(name string) netbios.Transport { + for _, b := range h.builders { + if b.name == name { + return b.build() + } + } + return nil +} + +func wireNetBIOS(cfg NetBIOSConfig) (NetBIOSHook, error) { + if !cfg.Enabled { + return nil, nil + } + builders := netbiosTransportBuilders(cfg) + svc := netbios.NewService(cfg.ServerName, cfg.ScopeID, nil) + netlog.Info("[MAIN][NetBIOS] server=%q scope=%q transports=%d", + cfg.ServerName, cfg.ScopeID, len(builders)) + return &netbiosHookEnabled{svc: svc, builders: builders}, nil +} + +// netbiosTransportBuilder constructs a fresh Transport for a single bound +// protocol. It is invoked at NetBIOS startup and again when the underlying +// protocol is restarted from the UI (so the transport re-attaches to the +// freshly started port/router). +type netbiosTransportBuilder func() netbios.Transport + +// netbiosTransportBuilders maps each configured, available transport to a +// builder keyed by the canonical protocol name ("ipx", "netbeui", "tcp"). +// Transports whose underlying hook is unavailable (e.g. "ipx" requested but +// the IPX router/SAP not wired) are skipped with a warning. The order of +// cfg.Transports is preserved so status reporting is stable. +func netbiosTransportBuilders(cfg NetBIOSConfig) []netbiosNamedBuilder { + var out []netbiosNamedBuilder + for _, name := range cfg.Transports { + switch name { + case "tcp": + out = append(out, netbiosNamedBuilder{name: "tcp", build: func() netbios.Transport { + return over_tcp.NewTransport() + }}) + case "netbeui": + if cfg.NetBEUI != nil && cfg.NetBEUI.Port() != nil { + nb := cfg.NetBEUI + out = append(out, netbiosNamedBuilder{name: "netbeui", build: func() netbios.Transport { + return over_netbeui.NewTransport(nb.Port(), nb.MAC()) + }}) + } else { + netlog.Warn("[MAIN][NetBIOS] transport %q skipped: NetBEUI port not available", name) + } + case "ipx": + if cfg.IPX != nil && cfg.IPX.Router() != nil && cfg.IPX.SAP() != nil { + ipxHook := cfg.IPX + server := cfg.ServerName + out = append(out, netbiosNamedBuilder{name: "ipx", build: func() netbios.Transport { + nbName := netbiosproto.NewName(server, netbiosproto.NameTypeFileServer) + return over_ipx.NewTransport(ipxHook.Router(), ipxHook.SAP(), nbName) + }}) + } else { + netlog.Warn("[MAIN][NetBIOS] transport %q skipped: IPX router/SAP not available", name) + } + default: + netlog.Warn("[MAIN][NetBIOS] unknown transport %q, ignoring", name) + } + } + return out +} + +// netbiosNamedBuilder pairs a transport's canonical name with its builder. +type netbiosNamedBuilder struct { + name string + build netbiosTransportBuilder +} diff --git a/cmd/classicstack/netbios_hook.go b/internal/app/netbios_hook.go similarity index 70% rename from cmd/classicstack/netbios_hook.go rename to internal/app/netbios_hook.go index 1aeb37e..71b4210 100644 --- a/cmd/classicstack/netbios_hook.go +++ b/internal/app/netbios_hook.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" @@ -15,6 +15,11 @@ type NetBIOSHook interface { Stop() error NameService() netbios.NameService Service() *netbios.Service + // BuildTransport returns a freshly built NetBIOS transport for the named + // protocol ("ipx", "netbeui", "tcp"), or nil if that protocol is not a + // configured transport. The supervisor uses it to re-attach a transport + // when its underlying protocol is restarted from the UI. + BuildTransport(name string) netbios.Transport } // NetBIOSConfig collects every value wireNetBIOS needs. IPX and diff --git a/cmd/classicstack/netutil.go b/internal/app/netutil.go similarity index 99% rename from cmd/classicstack/netutil.go rename to internal/app/netutil.go index e549754..d7c4d19 100644 --- a/cmd/classicstack/netutil.go +++ b/internal/app/netutil.go @@ -1,4 +1,4 @@ -package main +package app import ( "net" diff --git a/cmd/classicstack/packetdump.go b/internal/app/packetdump.go similarity index 98% rename from cmd/classicstack/packetdump.go rename to internal/app/packetdump.go index 8f26be4..bf03837 100644 --- a/cmd/classicstack/packetdump.go +++ b/internal/app/packetdump.go @@ -1,4 +1,4 @@ -package main +package app import ( "fmt" diff --git a/cmd/classicstack/rawlink_open.go b/internal/app/rawlink_open.go similarity index 99% rename from cmd/classicstack/rawlink_open.go rename to internal/app/rawlink_open.go index 3118451..9903c8f 100644 --- a/cmd/classicstack/rawlink_open.go +++ b/internal/app/rawlink_open.go @@ -1,4 +1,4 @@ -package main +package app import ( "fmt" diff --git a/internal/app/run.go b/internal/app/run.go new file mode 100644 index 0000000..dece270 --- /dev/null +++ b/internal/app/run.go @@ -0,0 +1,412 @@ +package app + +import ( + "context" + "flag" + "fmt" + "log" + "log/slog" + "os" + "os/signal" + "runtime" + "strings" + "syscall" + + "github.com/ObsoleteMadness/ClassicStack/config" + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/logbuf" + "github.com/ObsoleteMadness/ClassicStack/pkg/logging" + "github.com/ObsoleteMadness/ClassicStack/port/rawlink" +) + +// Main is the interactive entry point. It derives a context cancelled on +// SIGINT/SIGTERM (preserving the foreground Ctrl-C behaviour) and runs the +// stack until that context is done. Both cmd/classicstack and the service +// wrapper share this package; the wrapper instead calls Run directly with a +// context it cancels on the SCM/daemon stop signal. +func Main(v Version) { + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + + if err := runWithSignals(v); err != nil { + log.Fatal(err) + } +} + +// runWithSignals builds a context cancelled on SIGINT/SIGTERM and runs the +// stack. It is split from Main so the deferred signal cleanup runs before any +// log.Fatal in the caller (which would otherwise os.Exit past the defer). +func runWithSignals(v Version) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return Run(ctx, os.Args[1:], v) +} + +// Run parses args, builds the stack, starts it, and blocks until ctx is +// cancelled, then tears it down. It is the shared run-core invoked from the +// interactive Main and from the service/daemon wrapper. Fatal configuration +// errors are returned (the caller decides how to report them); -version and +// -list-pcap-devices short-circuit with a nil error after printing. +func Run(ctx context.Context, args []string, v Version) error { + fs := flag.NewFlagSet("classicstack", flag.ContinueOnError) + + configPath := fs.String("config", "", "Path to TOML config file (cannot be combined with other flags)") + showVersion := fs.Bool("version", false, "Print ClassicStack version information and exit") + + logLevel := fs.String("log-level", "info", "Minimum log level: debug, info, warn") + logTraffic := fs.Bool("log-traffic", false, "Log network traffic at debug level (requires -log-level debug)") + + ltoudp := fs.Bool("ltoudp-enabled", true, "Enable LToUDP LocalTalk port") + ltIface := fs.String("ltoudp-interface", "0.0.0.0", "Local IPv4 interface/address for LToUDP multicast join and send (0.0.0.0 = auto)") + ltNet := fs.Uint("ltoudp-seed-network", 1, "LToUDP seed network") + ltZone := fs.String("ltoudp-seed-zone", "LToUDP Network", "LToUDP seed zone") + tashtalkSerial := fs.String("tashtalk-port", "", "TashTalk serial port (empty to disable)") + ttNet := fs.Uint("tashtalk-seed-network", 2, "TashTalk seed network") + ttZone := fs.String("tashtalk-seed-zone", "TashTalk Network", "TashTalk seed zone") + + pcapDev := fs.String("ethertalk-device", "", "EtherTalk pcap device (required for EtherTalk)") + etBackend := fs.String("ethertalk-backend", "pcap", "EtherTalk backend: pcap, tap, or tun") + pcapHWAddr := fs.String("ethertalk-hw-address", "DE:AD:BE:EF:CA:FE", "EtherTalk hardware address (6-byte MAC)") + etBridgeMode := fs.String("ethertalk-bridge-mode", "auto", "EtherTalk bridge mode: auto, ethernet, wifi") + etBridgeHostMAC := fs.String("ethertalk-bridge-host-mac", "", "Host adapter MAC used for Wi-Fi bridge shim (default: ethertalk-hw-address)") + etFilter := fs.String("ethertalk-filter", "", "pcap BPF filter override for EtherTalk") + bridgeMode := fs.String("bridge-mode", "", "Shared raw-link backend mode: pcap, tap, or tun (overrides ethertalk-backend)") + bridgeDevice := fs.String("bridge-device", "", "Shared raw-link device/interface (overrides ethertalk-device)") + bridgeHWAddr := fs.String("bridge-hw-address", "", "Shared raw-link host MAC (overrides ethertalk-hw-address)") + bridgeFrameMode := fs.String("bridge-frame-mode", "", "Shared frame mode for bridge adaptation: auto, ethernet, wifi (overrides ethertalk-bridge-mode)") + listPcap := fs.Bool("list-pcap-devices", false, "List pcap devices and exit") + etNetMin := fs.Uint("ethertalk-seed-network-min", 3, "EtherTalk seed network min") + etNetMax := fs.Uint("ethertalk-seed-network-max", 5, "EtherTalk seed network max") + etZone := fs.String("ethertalk-seed-zone", "EtherTalk Network", "EtherTalk seed zone name") + etDesiredNet := fs.Uint("ethertalk-desired-network", 3, "EtherTalk desired network") + etDesiredNode := fs.Uint("ethertalk-desired-node", 253, "EtherTalk desired node") + + // MacIP gateway flags. + // By default the IP side reuses the same pcap device as EtherTalk (-ethertalk-device). + // A separate interface can be specified with -macip-interface if needed. + macipEnable := fs.Bool("macip-enabled", false, "Enable MacIP IP-over-AppleTalk gateway (intended for NAT mode)") + macipGWIP := fs.String("macip-nat-gw", "", "MacIP gateway IP for NAT mode (ignored in pcap mode; blank uses an APIPA-style address)") + macipSubnet := fs.String("macip-nat-subnet", "192.168.100.0/24", "MacIP NAT subnet in CIDR notation") + macipNameserver := fs.String("macip-nameserver", "", "Nameserver IP for MacIP clients (default: IP-side gateway)") + macipZone := fs.String("macip-zone", "", "AppleTalk zone for NBP registration (default: use -ethertalk-seed-zone if set, otherwise first zone found)") + macipIPGW := fs.String("macip-ip-gateway", "", "Default gateway IP on the IP-side network (auto-detected when omitted)") + macipNAT := fs.Bool("macip-nat", false, "Enable NAPT: rewrite Mac client source IPs to the gateway IP on the physical network") + macipDHCP := fs.Bool("macip-dhcp-relay", false, "Use DHCP to assign IPs to MacIP clients instead of the static pool (non-NAT mode)") + macipStateFile := fs.String("macip-lease-file", "", "File to persist MacIP lease state across restarts (empty to disable)") + macipFilter := fs.String("macip-filter", "", "pcap BPF filter override for MacIP (default is auto-generated)") + + // Packet parsing / capture flags. + parsePackets := fs.Bool("parse-packets", false, "Decode and log every inbound DDP packet (ATP/ASP/AFP layers)") + parseOutput := fs.String("parse-output", "", "File path to write parsed packet log (appended; empty = stdout only)") + + captureLocalTalk := fs.String("capture-localtalk", "", "Write LocalTalk frames (LToUDP/TashTalk/Virtual) to a pcap file at this path (empty disables)") + captureEtherTalk := fs.String("capture-ethertalk", "", "Write EtherTalk frames to a pcap file at this path (empty disables)") + captureSnaplen := fs.Uint("capture-snaplen", 65535, "Per-frame snap length for pcap captures") + + // AFP file sharing flags. Schemas live in service/afp; cmd-side + // wiring is split between afp_enabled.go and afp_disabled.go. + afpServerName := fs.String("afp-name", "Go File Server", "AFP server name advertised to clients") + afpZone := fs.String("afp-zone", "", "AppleTalk zone for AFP NBP registration (default: first zone found)") + afpProtocols := fs.String("afp-protocols", "tcp,ddp", "AFP protocols to enable: tcp, ddp, or tcp,ddp") + afpTCPAddr := fs.String("afp-binding", ":548", "Address and port for AFP over TCP (DSI) to listen on") + afpExtensionMap := fs.String("afp-extension-map", "", "Netatalk-compatible extension map file for Macintosh type/creator fallback") + afpDecomposedFilenames := fs.Bool("afp-use-decomposed-names", true, "Encode host-reserved filename characters using 0xNN tokens when mapping AFP paths") + afpCNIDBackend := fs.String("afp-cnid-backend", "sqlite", "CNID backend to use for AFP object IDs (sqlite or memory)") + afpAppleDoubleMode := fs.String("afp-appledouble-mode", "modern", "AppleDouble metadata mode: modern or legacy") + var afpVolumes volumeFlags + fs.Var(&afpVolumes, "afp-volume", `AFP volume to share, format: "Name:Path" (repeatable, e.g. -afp-volume "Mac Share:c:\mac")`) + + // IPX flags. Real packet handling lands behind //go:build ipx; the + // disabled stub logs a warning if -ipx-enabled is set without the tag. + ipxEnable := fs.Bool("ipx-enabled", false, "Enable IPX router (requires -tags ipx)") + ipxIface := fs.String("ipx-interface", "", "Rawlink/pcap interface for IPX (default: reuse -ethertalk-device)") + ipxFraming := fs.String("ipx-framing", "ethernet_ii", "IPX framing: ethernet_ii, raw_802_3, llc, snap") + ipxInternal := fs.String("ipx-internal-network", "", "IPX internal network number (8-hex-digit, e.g. DEADBEEF)") + ipxFilter := fs.String("ipx-filter", "", "pcap BPF filter override for IPX (default: ipx)") + + // NetBEUI flags. + netbeuiEnable := fs.Bool("netbeui-enabled", false, "Enable NetBEUI port (requires -tags netbeui)") + netbeuiIface := fs.String("netbeui-interface", "", "Rawlink/pcap interface for NetBEUI (default: reuse -ethertalk-device)") + netbeuiFilter := fs.String("netbeui-filter", "", "pcap BPF filter override for NetBEUI (default: llc)") + + // NetBIOS flags. + netbiosEnable := fs.Bool("netbios-enabled", false, "Enable NetBIOS service (requires -tags netbios)") + netbiosTransports := fs.String("netbios-transports", "tcp", "Comma-separated NetBIOS transports: any of tcp, netbeui, ipx") + netbiosScopeID := fs.String("netbios-scope-id", "", "NetBIOS scope ID (RFC 1001/1002)") + netbiosServerName := fs.String("netbios-server-name", "", "Deprecated: NetBIOS identity derives from SMB server/workgroup") + netbiosWorkgroup := fs.String("netbios-workgroup", "", "Deprecated: NetBIOS identity derives from SMB server/workgroup") + + // SMB flags. + smbEnable := fs.Bool("smb-enabled", false, "Enable SMB 1.0 server (requires -tags smb)") + smbNBT := fs.String("smb-nbt-binding", ":139", "SMB NBT (NetBIOS over TCP) listen address") + smbDirect := fs.String("smb-direct-binding", "", "SMB direct (TCP/445) listen address; empty disables direct SMB") + smbGuest := fs.Bool("smb-guest-ok", false, "Accept unauthenticated SMB sessions") + smbServerName := fs.String("smb-server-name", "CLASSICSTACK", "SMB/NetBIOS computer name") + smbWorkgroup := fs.String("smb-workgroup", "WORKGROUP", "SMB/NetBIOS workgroup name") + var smbShares volumeFlags + fs.Var(&smbShares, "smb-share", `SMB share, format: "Name:Path" (repeatable)`) + + // Shortname flags. + shortWindows := fs.Bool("shortname-windows-shortnames", false, "Enable Windows native shortnames") + shortBackend := fs.String("shortname-backend", "memory", "Shortname store backend: memory or sqlite") + shortDB := fs.String("shortname-db", "", "Shortname store DB path (sqlite backend)") + + // Web UI flags. The HTTP server lives behind -tags webui; the + // disabled stub warns if -webui-enabled is set without the tag. + webuiEnable := fs.Bool("webui-enabled", false, "Enable the management web UI (requires -tags webui)") + webuiBind := fs.String("webui-bind", "127.0.0.1:8080", "Web UI listen address (IP:PORT)") + webuiTLS := fs.Bool("webui-tls", true, "Serve the web UI over HTTPS (self-signed when no cert/key given)") + webuiCert := fs.String("webui-cert-pem", "", "Path to PEM certificate for the web UI (blank: self-signed)") + webuiKey := fs.String("webui-key-pem", "", "Path to PEM private key for the web UI (blank: self-signed)") + + if err := fs.Parse(args); err != nil { + return err + } + + if *showVersion { + fmt.Printf("classicstack %s\n", v.Version) + fmt.Printf("commit: %s\n", v.Commit) + fmt.Printf("built: %s\n", v.Date) + fmt.Printf("go: %s\n", runtime.Version()) + return nil + } + + nonConfigFlags := 0 + fs.Visit(func(f *flag.Flag) { + if f.Name != "config" && f.Name != "version" { + nonConfigFlags++ + } + }) + + if *configPath != "" && nonConfigFlags > 0 { + return fmt.Errorf("-config cannot be combined with other flags") + } + + selectedConfig := *configPath + if selectedConfig == "" && fs.NFlag() == 0 { + if _, err := os.Stat("server.toml"); err == nil { + selectedConfig = "server.toml" + } else if os.IsNotExist(err) { + fs.Usage() + return nil + } else { + return fmt.Errorf("failed checking default config file server.toml: %w", err) + } + } + + var ( + cfg appConfig + configSource config.Source + ) + fromConfigFile := selectedConfig != "" + if fromConfigFile { + loaded, src, err := loadConfigFromFile(selectedConfig) + if err != nil { + return fmt.Errorf("failed loading config file %q: %w", selectedConfig, err) + } + cfg = loaded + configSource = src + } else { + cfg = flagsToConfig(flagInputs{ + LogLevel: *logLevel, + LogTraffic: *logTraffic, + ParsePackets: *parsePackets, + ParseOutput: *parseOutput, + LToUDPEnabled: *ltoudp, + LToUDPInterface: *ltIface, + LToUDPSeedNetwork: *ltNet, + LToUDPSeedZone: *ltZone, + TashTalkPort: *tashtalkSerial, + TashTalkSeedNetwork: *ttNet, + TashTalkSeedZone: *ttZone, + BridgeMode: *bridgeMode, + BridgeDevice: *bridgeDevice, + BridgeHWAddress: *bridgeHWAddr, + BridgeBridgeMode: *bridgeFrameMode, + + EtherTalkDevice: *pcapDev, + EtherTalkBackend: *etBackend, + EtherTalkHWAddress: *pcapHWAddr, + EtherTalkBridgeMode: *etBridgeMode, + EtherTalkBridgeHostMAC: *etBridgeHostMAC, + EtherTalkFilter: *etFilter, + EtherTalkSeedNetworkMin: *etNetMin, + EtherTalkSeedNetworkMax: *etNetMax, + EtherTalkSeedZone: *etZone, + EtherTalkDesiredNetwork: *etDesiredNet, + EtherTalkDesiredNode: *etDesiredNode, + MacIPEnabled: *macipEnable, + MacIPGWIP: *macipGWIP, + MacIPSubnet: *macipSubnet, + MacIPNameserver: *macipNameserver, + MacIPZone: *macipZone, + MacIPGatewayIP: *macipIPGW, + MacIPNAT: *macipNAT, + MacIPDHCPRelay: *macipDHCP, + MacIPLeaseFile: *macipStateFile, + MacIPFilter: *macipFilter, + CaptureLocalTalk: *captureLocalTalk, + CaptureEtherTalk: *captureEtherTalk, + CaptureSnaplen: *captureSnaplen, + + IPXEnabled: *ipxEnable, + IPXInterface: *ipxIface, + IPXFraming: *ipxFraming, + IPXInternalNetwork: *ipxInternal, + IPXFilter: *ipxFilter, + + NetBEUIEnabled: *netbeuiEnable, + NetBEUIInterface: *netbeuiIface, + NetBEUIFilter: *netbeuiFilter, + + NetBIOSEnabled: *netbiosEnable, + NetBIOSTransports: *netbiosTransports, + NetBIOSScopeID: *netbiosScopeID, + NetBIOSServerName: *netbiosServerName, + NetBIOSWorkgroup: *netbiosWorkgroup, + + SMBEnabled: *smbEnable, + SMBNBTBinding: *smbNBT, + SMBDirectBinding: *smbDirect, + SMBGuestOk: *smbGuest, + SMBServerName: *smbServerName, + SMBWorkgroup: *smbWorkgroup, + SMBShareValues: []string(smbShares), + + ShortnameWindowsShortnames: *shortWindows, + ShortnameBackend: *shortBackend, + ShortnameDBPath: *shortDB, + + WebUIEnabled: *webuiEnable, + WebUIBind: *webuiBind, + WebUITLS: *webuiTLS, + WebUICertPEM: *webuiCert, + WebUIKeyPEM: *webuiKey, + }) + } + + if level, ok := netlog.ParseLevel(cfg.LogLevel); ok { + netlog.SetLevel(level) + } else { + return fmt.Errorf("unknown -log-level %q (want debug, info, or warn)", cfg.LogLevel) + } + + // Install a pkg/logging root logger as the netlog shim's target so + // output flows through slog with source tagging and structured + // attributes. Each service will eventually take a *slog.Logger + // directly; until then, netlog.* calls forward here. + slogLevel, _ := logging.ParseLevel(cfg.LogLevel) + rootLogger := logging.New("ClassicStack", logging.Options{ + Sinks: []logging.Sink{{Writer: os.Stderr, Format: logging.FormatConsole, Level: slogLevel}}, + // Tee every record into the in-memory ring buffer so the management + // plane / web UI log viewer can replay recent history and stream live. + Extra: []slog.Handler{logbuf.NewHandler(logbuf.Default, slogLevel)}, + }) + logging.SetDefault(rootLogger) + netlog.SetLogger(rootLogger) + + // Traffic logging (LogTraffic) is wired by the Supervisor from config so + // it can be toggled live from the UI; main only sets up the logger. + + cfg.Bridge.Mode = strings.ToLower(strings.TrimSpace(cfg.Bridge.Mode)) + switch cfg.Bridge.Mode { + case "", "pcap", "tap", "tun": + default: + return fmt.Errorf("invalid bridge mode %q (want pcap, tap, or tun)", cfg.Bridge.Mode) + } + syncBridgeToEtherTalk(&cfg) + + if *listPcap { + names, err := rawlink.InterfaceNames() + if err != nil { + return fmt.Errorf("failed listing pcap interface names: %w", err) + } + netlog.Info("[MAIN] available interfaces: %v", names) + devs, err := rawlink.ListPcapDevices() + if err != nil { + return fmt.Errorf("failed listing pcap devices: %w", err) + } + if len(devs) == 0 { + netlog.Info("[MAIN] no pcap devices found") + return nil + } + for _, d := range devs { + netlog.Info("[MAIN] pcap device: %s", d.Name) + if d.Description != "" { + netlog.Info("[MAIN] desc: %s", d.Description) + } + for _, addr := range d.Addresses { + netlog.Info("[MAIN] addr: %s", addr) + } + } + return nil + } + + if cfg.EtherTalk.Device == "" && cfg.Bridge.Mode == "pcap" { + if detected, ok := rawlink.DetectDefaultPcapInterface(); ok { + netlog.Info("[MAIN] auto-detected pcap interface: %s", detected) + cfg.Bridge.Device = detected + syncBridgeToEtherTalk(&cfg) + } + } + if cfg.EtherTalk.Device != "" && cfg.Bridge.Mode == "pcap" && strings.TrimSpace(cfg.EtherTalk.BridgeHostMAC) == "" { + if hostMAC, ok := rawlink.DetectHostMACForPcapInterface(cfg.EtherTalk.Device); ok { + cfg.EtherTalk.BridgeHostMAC = hostMAC + if strings.TrimSpace(cfg.Bridge.HWAddress) == "" { + cfg.Bridge.HWAddress = hostMAC + syncBridgeToEtherTalk(&cfg) + } + netlog.Info("[MAIN] auto-detected bridge host MAC for %s: %s", cfg.EtherTalk.Device, hostMAC) + } + } + + // From here on, the build and lifecycle of every component lives in the + // Supervisor. Run's remaining job is to project the resolved config + // into a config.Model, construct the supervisor and the management + // plane, wire the (optional) web UI on top, run, and tear down. + model := buildModel(cfg, configSource, fromConfigFile, afpFlagOptions{ + ServerName: *afpServerName, + Zone: *afpZone, + Protocols: *afpProtocols, + Binding: *afpTCPAddr, + ExtensionMap: *afpExtensionMap, + DecomposedNames: *afpDecomposedFilenames, + CNIDBackend: *afpCNIDBackend, + AppleDoubleMode: *afpAppleDoubleMode, + Volumes: []string(afpVolumes), + }) + sup, err := NewSupervisor(cfg, configSource, model) + if err != nil { + return fmt.Errorf("failed to build stack: %w", err) + } + + plane := newControlPlane(sup, model, selectedConfig) + wireDiagnostics(plane, sup) + + if err := installWebUI(sup, cfg.WebUI, plane); err != nil { + return fmt.Errorf("failed to wire web UI: %w", err) + } + + if err := sup.Start(ctx); err != nil { + return fmt.Errorf("failed to start stack: %w", err) + } + + <-ctx.Done() + + if err := sup.Stop(); err != nil { + netlog.Warn("[MAIN] stop warning: %v", err) + } + return nil +} + +// volumeFlags is a repeatable -afp-volume flag. The raw "Name:Path" +// strings are forwarded to wireAFP, where the //go:build afp side +// parses them via afp.ParseVolumeFlag. Keeping this neutral lets +// minimal-build users still pass -afp-volume and get a clean warning. +type volumeFlags []string + +func (v *volumeFlags) String() string { return "" } + +func (v *volumeFlags) Set(s string) error { + *v = append(*v, s) + return nil +} diff --git a/cmd/classicstack/shortname_hook.go b/internal/app/shortname_hook.go similarity index 94% rename from cmd/classicstack/shortname_hook.go rename to internal/app/shortname_hook.go index 05bcbcd..3467af9 100644 --- a/cmd/classicstack/shortname_hook.go +++ b/internal/app/shortname_hook.go @@ -1,4 +1,4 @@ -package main +package app import "github.com/ObsoleteMadness/ClassicStack/pkg/vfs" diff --git a/cmd/classicstack/shortname_wire.go b/internal/app/shortname_wire.go similarity index 97% rename from cmd/classicstack/shortname_wire.go rename to internal/app/shortname_wire.go index 9bbdbac..eee86c5 100644 --- a/cmd/classicstack/shortname_wire.go +++ b/internal/app/shortname_wire.go @@ -1,4 +1,4 @@ -package main +package app import ( "github.com/ObsoleteMadness/ClassicStack/pkg/shortname" diff --git a/cmd/classicstack/smb_disabled.go b/internal/app/smb_disabled.go similarity index 87% rename from cmd/classicstack/smb_disabled.go rename to internal/app/smb_disabled.go index a20b162..bd93864 100644 --- a/cmd/classicstack/smb_disabled.go +++ b/internal/app/smb_disabled.go @@ -1,6 +1,6 @@ //go:build !smb && !all -package main +package app import ( "context" @@ -14,6 +14,7 @@ type smbHookDisabled struct{} func (smbHookDisabled) Start(_ context.Context) error { return nil } func (smbHookDisabled) Stop() error { return nil } func (smbHookDisabled) Service() *smb.Service { return nil } +func (smbHookDisabled) IPXDirect() startStopper { return nil } func wireSMB(cfg SMBConfig) (SMBHook, error) { if cfg.Enabled { diff --git a/cmd/classicstack/smb_enabled.go b/internal/app/smb_enabled.go similarity index 86% rename from cmd/classicstack/smb_enabled.go rename to internal/app/smb_enabled.go index d5985a5..6a8c489 100644 --- a/cmd/classicstack/smb_enabled.go +++ b/internal/app/smb_enabled.go @@ -1,6 +1,6 @@ //go:build smb || all -package main +package app import ( "context" @@ -39,6 +39,16 @@ func (h *smbHookEnabled) Stop() error { func (h *smbHookEnabled) Service() *smb.Service { return h.svc } +// IPXDirect returns the SMB-over-direct-IPX transport, or nil when IPX is not +// wired. Returning a typed nil through the interface would be non-nil, so we +// return an untyped nil explicitly. +func (h *smbHookEnabled) IPXDirect() startStopper { + if h.ipxDirect == nil { + return nil + } + return h.ipxDirect +} + func wireSMB(cfg SMBConfig) (SMBHook, error) { if !cfg.Enabled { return nil, nil diff --git a/cmd/classicstack/smb_hook.go b/internal/app/smb_hook.go similarity index 55% rename from cmd/classicstack/smb_hook.go rename to internal/app/smb_hook.go index d0cdf86..fd35072 100644 --- a/cmd/classicstack/smb_hook.go +++ b/internal/app/smb_hook.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" @@ -13,6 +13,19 @@ type SMBHook interface { Start(ctx context.Context) error Stop() error Service() *smb.Service + // IPXDirect returns the SMB-over-direct-IPX transport, or nil when SMB + // has no IPX transport (IPX disabled). The supervisor binds it so that + // stopping IPX detaches it and starting IPX re-attaches it. It is a + // minimal lifecycle handle to keep this interface free of build-tagged + // transport types. + IPXDirect() startStopper +} + +// startStopper is the minimal lifecycle surface the supervisor needs to +// attach/detach a sub-transport binding. +type startStopper interface { + Start(ctx context.Context) error + Stop() error } // SMBConfig collects every value wireSMB needs. diff --git a/cmd/classicstack/smb_shares.go b/internal/app/smb_shares.go similarity index 99% rename from cmd/classicstack/smb_shares.go rename to internal/app/smb_shares.go index 3fde0e7..b8289a8 100644 --- a/cmd/classicstack/smb_shares.go +++ b/internal/app/smb_shares.go @@ -1,4 +1,4 @@ -package main +package app import ( "strings" diff --git a/cmd/classicstack/supervisor.go b/internal/app/supervisor.go similarity index 64% rename from cmd/classicstack/supervisor.go rename to internal/app/supervisor.go index 880c525..6056ae7 100644 --- a/cmd/classicstack/supervisor.go +++ b/internal/app/supervisor.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" @@ -68,6 +68,28 @@ type Supervisor struct { shortHook ShortnameHook macIP MacIPHook ipxGW IPXGWHook + + // netbios is the NetBIOS hook so the lifecycle can attach/detach + // transports as their underlying protocol starts/stops. nil when NetBIOS + // is disabled. + netbios NetBIOSHook + // transportBindings maps a transport-protocol hook name ("IPX", + // "NetBEUI") to the NetBIOS/SMB bindings it feeds, so stopping that hook + // detaches only its bindings rather than cascading a full teardown. See + // supervisor_lifecycle.go. + transportBindings map[string][]transportBinding +} + +// transportBinding describes one runtime binding a transport-protocol hook +// contributes to a higher layer (NetBIOS or SMB). When the hook stops, detach +// is called; when it starts, attach re-establishes the binding against the +// freshly started protocol. +type transportBinding struct { + // owner is the status-unit name of the layer this binding belongs to + // ("NetBIOS" or "SMB"), used to refresh that unit's displayed transports. + owner string + attach func() error + detach func() } type closer interface{ Close() error } @@ -234,10 +256,10 @@ func (s *Supervisor) buildServices() ([]service.Service, error) { macIP, err := wireMacIP(MacIPConfig{ Enabled: cfg.MacIPEnabled, - BridgeMode: cfg.Bridge.Mode, - BridgeDevice: cfg.Bridge.Device, - BridgeHWAddress: cfg.Bridge.HWAddress, - BridgeFrameMode: cfg.Bridge.BridgeMode, + BridgeMode: cfg.MacIPBridge.Mode, + BridgeDevice: cfg.MacIPBridge.Device, + BridgeHWAddress: cfg.MacIPBridge.HWAddress, + BridgeFrameMode: cfg.MacIPBridge.BridgeMode, NATGatewayIP: cfg.MacIPGWIP, NATSubnet: cfg.MacIPSubnet, Nameserver: cfg.MacIPNameserver, @@ -277,7 +299,7 @@ func (s *Supervisor) buildServices() ([]service.Service, error) { DBPath: cfg.ShortnameDBPath, }) if err != nil { - return nil, fmt.Errorf("Shortname wiring failed: %w", err) + return nil, fmt.Errorf("shortname wiring failed: %w", err) } s.shortHook = shortHook @@ -340,10 +362,10 @@ func (s *Supervisor) buildHooks() error { ipxResolvedIface := s.resolveIPXInterface() ipxHook, err := wireIPX(IPXConfig{ Enabled: cfg.IPXEnabled, - BridgeMode: cfg.Bridge.Mode, - BridgeFrameMode: cfg.Bridge.BridgeMode, + BridgeMode: cfg.IPXBridge.Mode, + BridgeFrameMode: cfg.IPXBridge.BridgeMode, Interface: ipxResolvedIface, - BridgeHWAddress: cfg.Bridge.HWAddress, + BridgeHWAddress: cfg.IPXBridge.HWAddress, Framing: cfg.IPXFraming, InternalNetwork: cfg.IPXInternalNetwork, Filter: cfg.IPXFilter, @@ -360,10 +382,10 @@ func (s *Supervisor) buildHooks() error { nbeuiResolvedIface := s.resolveNetBEUIInterface() nbeuiHook, err := wireNetBEUI(NetBEUIConfig{ Enabled: cfg.NetBEUIEnabled, - BridgeMode: cfg.Bridge.Mode, - BridgeFrameMode: cfg.Bridge.BridgeMode, + BridgeMode: cfg.NetBEUIBridge.Mode, + BridgeFrameMode: cfg.NetBEUIBridge.BridgeMode, Interface: nbeuiResolvedIface, - BridgeHWAddress: cfg.Bridge.HWAddress, + BridgeHWAddress: cfg.NetBEUIBridge.HWAddress, Filter: cfg.NetBEUIFilter, CapturePath: cfg.Capture.NetBEUI, CaptureSnaplen: cfg.Capture.Snaplen, @@ -406,40 +428,99 @@ func (s *Supervisor) buildHooks() error { return fmt.Errorf("SMB wiring failed: %w", err) } - // Register hooks in dependency/start order. NetBIOS depends on the - // transports (IPX/NetBEUI); SMB depends on NetBIOS. + // Register hooks in start order. NetBIOS is NOT a hard dependent of the + // transports: IPX/NetBEUI are bindings into NetBIOS, so stopping one + // detaches just that transport (see transportBindings) rather than + // tearing NetBIOS (and SMB) down. SMB does depend on NetBIOS. s.addHook("IPX", ipxHook, cfg.IPXEnabled, nil) s.addHook("NetBEUI", nbeuiHook, cfg.NetBEUIEnabled, nil) - s.addHook("NetBIOS", nbHook, cfg.NetBIOSEnabled, []string{"IPX", "NetBEUI"}) + s.addHook("NetBIOS", nbHook, cfg.NetBIOSEnabled, nil) s.addHook("SMB", smbHook, cfg.SMBEnabled, []string{"NetBIOS"}) + + if cfg.NetBIOSEnabled && nbHook != nil { + s.netbios = nbHook + } + s.registerIPXStatus(ipxHook, cfg.IPXEnabled) + s.registerNetBEUIStatus(nbeuiHook, cfg.NetBEUIEnabled) + if nbHook != nil { + s.refreshNetBIOSStatus(cfg.NetBIOSEnabled) + } if smbHook != nil { s.registerSMBStatus(cfg.SMBEnabled) // enrich the SMB unit with shares/identity } + s.registerTransportBindings(ipxHook, nbeuiHook, smbHook) return nil } +// registerTransportBindings records, for each transport-protocol hook, the +// runtime bindings it contributes to NetBIOS (and SMB's direct-IPX path), so +// the lifecycle can detach/reattach them when that protocol is stopped or +// started from the UI without cascading a full teardown. +func (s *Supervisor) registerTransportBindings(ipxHook IPXHook, nbeuiHook NetBEUIHook, smbHook SMBHook) { + s.transportBindings = map[string][]transportBinding{} + + // NetBEUI -> NetBIOS "netbeui" transport. + if nbeuiHook != nil && s.netbios != nil { + s.transportBindings["NetBEUI"] = append(s.transportBindings["NetBEUI"], + s.netbiosTransportBinding("netbeui")) + } + // IPX -> NetBIOS "ipx" transport. + if ipxHook != nil && s.netbios != nil { + s.transportBindings["IPX"] = append(s.transportBindings["IPX"], + s.netbiosTransportBinding("ipx")) + } + // IPX -> SMB direct-IPX transport. + if ipxHook != nil && smbHook != nil { + if d := smbHook.IPXDirect(); d != nil { + s.transportBindings["IPX"] = append(s.transportBindings["IPX"], transportBinding{ + owner: "SMB", + attach: func() error { return d.Start(s.ctx) }, + detach: func() { _ = d.Stop() }, + }) + } + } +} + +// netbiosTransportBinding builds a transportBinding that adds/removes the +// named NetBIOS transport (rebuilding it from the NetBIOS hook so it re-binds +// to the freshly started protocol). +func (s *Supervisor) netbiosTransportBinding(name string) transportBinding { + return transportBinding{ + owner: "NetBIOS", + attach: func() error { + if s.netbios == nil { + return nil + } + t := s.netbios.BuildTransport(name) + if t == nil { + return nil + } + return s.netbios.Service().AddTransport(name, t) + }, + detach: func() { + if s.netbios != nil { + _ = s.netbios.Service().RemoveTransport(name) + } + }, + } +} + func (s *Supervisor) resolveIPXInterface() string { cfg := s.cfg - iface := cfg.IPXInterface + // cfg.IPXBridge.Device already folds in the protocol's own [IPX.Custom] + // device, the legacy scalar interface, and the shared bridge device. + iface := cfg.IPXBridge.Device if cfg.IPXEnabled && strings.TrimSpace(iface) == "" && cfg.EtherTalk.Device != "" { - if cfg.Bridge.Device != "" { - iface = cfg.Bridge.Device - } else { - iface = cfg.EtherTalk.Device - } + iface = cfg.EtherTalk.Device } return iface } func (s *Supervisor) resolveNetBEUIInterface() string { cfg := s.cfg - iface := cfg.NetBEUIInterface + iface := cfg.NetBEUIBridge.Device if cfg.NetBEUIEnabled && strings.TrimSpace(iface) == "" && cfg.EtherTalk.Device != "" { - if cfg.Bridge.Device != "" { - iface = cfg.Bridge.Device - } else { - iface = cfg.EtherTalk.Device - } + iface = cfg.EtherTalk.Device } return iface } @@ -507,6 +588,9 @@ func (s *Supervisor) registerAFPStatus() { } // registerSMBStatus records SMB's identity and shares for the dashboard. +// SMB has no TCP listener today (NBT :139 / direct :445 are unimplemented), so +// the displayed binding is the set of transports it is actually served over: +// NetBIOS (and which NetBIOS transports are live) plus the direct-IPX path. func (s *Supervisor) registerSMBStatus(enabled bool) { m := s.model.SMB shares := make([]status.ShareInfo, 0, len(m.Volumes)) @@ -522,17 +606,173 @@ func (s *Supervisor) registerSMBStatus(enabled bool) { hostnames = append(hostnames, m.ServerName) } s.reg.Set(status.Unit{ - Name: "SMB", + Name: "SMB", + Kind: status.KindHook, + Enabled: enabled, + Properties: map[string]string{ + "workgroup": m.Workgroup, + "transports": s.smbTransportSummary(), + }, + Hostnames: hostnames, + Shares: shares, + DependsOn: []string{"NetBIOS"}, + }) +} + +// smbTransportSummary describes the transports SMB is currently served over, +// e.g. "NetBIOS (IPX, NetBEUI), IPX-direct". It reflects live state: NetBIOS is +// only listed while it is running, and only the transports it currently has +// bound are shown. The direct-IPX path is listed only while IPX is running. +func (s *Supervisor) smbTransportSummary() string { + var parts []string + if s.netbios != nil && s.unitRunning("NetBIOS") { + if names := s.netbios.Service().Transports(); len(names) > 0 { + parts = append(parts, "NetBIOS ("+strings.Join(prettyTransportNames(names), ", ")+")") + } else { + parts = append(parts, "NetBIOS") + } + } + if smb, ok := s.hooks["SMB"].(SMBHook); ok && smb != nil && smb.IPXDirect() != nil && s.unitRunning("IPX") { + parts = append(parts, "IPX-direct") + } + if len(parts) == 0 { + return "none" + } + return strings.Join(parts, ", ") +} + +// unitRunning reports whether the named status unit is currently marked +// running. Unknown units are treated as not running. +func (s *Supervisor) unitRunning(name string) bool { + for _, u := range s.reg.Snapshot() { + if u.Name == name { + return u.Running + } + } + return false +} + +// prettyTransportNames maps canonical transport keys to display labels. +func prettyTransportNames(names []string) []string { + out := make([]string, 0, len(names)) + for _, n := range names { + switch n { + case "ipx": + out = append(out, "IPX") + case "netbeui": + out = append(out, "NetBEUI") + case "tcp": + out = append(out, "TCP") + default: + out = append(out, n) + } + } + return out +} + +// ipxFramingLabel maps the configured IPX framing name to a display label, +// defaulting to Ethernet II when unset/unknown (matching parseIPXFraming). +func ipxFramingLabel(name string) string { + switch strings.ToLower(strings.TrimSpace(name)) { + case "raw_802_3", "raw-802-3", "raw802.3": + return "Raw 802.3" + case "llc", "802.2": + return "802.2 LLC" + case "snap": + return "SNAP" + default: + return "Ethernet II" + } +} + +// registerIPXStatus records the IPX hook's bound device, network number, and +// framing for the dashboard. +func (s *Supervisor) registerIPXStatus(h IPXHook, enabled bool) { + if h == nil { + return + } + cfg := s.cfg + iface := s.resolveIPXInterface() + props := map[string]string{"device": iface, "framing": ipxFramingLabel(cfg.IPXFraming)} + // The IPX router carries the resolved network number (the configured + // internal network, or the router default when unset). + if r := h.Router(); r != nil { + net := r.Network() + props["network"] = fmt.Sprintf("%02x%02x%02x%02x", net[0], net[1], net[2], net[3]) + } + s.reg.Set(status.Unit{ + Name: "IPX", + Kind: status.KindHook, + Enabled: enabled, + Binding: iface, + Properties: props, + }) +} + +// registerNetBEUIStatus records the NetBEUI hook's bound device. +func (s *Supervisor) registerNetBEUIStatus(h NetBEUIHook, enabled bool) { + if h == nil { + return + } + iface := s.resolveNetBEUIInterface() + s.reg.Set(status.Unit{ + Name: "NetBEUI", Kind: status.KindHook, Enabled: enabled, - Binding: m.NBTBinding, - Properties: map[string]string{"workgroup": m.Workgroup}, + Binding: iface, + Properties: map[string]string{"device": iface}, + }) +} + +// refreshNetBIOSStatus re-publishes the NetBIOS unit with its current bound +// transports, so the dashboard reflects detach/attach without a full rebuild. +func (s *Supervisor) refreshNetBIOSStatus(enabled bool) { + if s.netbios == nil { + return + } + // Preserve the live running flag across the re-Set. Transports are only + // shown while running — a stopped NetBIOS serves nothing even though the + // bindings are still recorded for the next start. + running := s.unitRunning("NetBIOS") + transports := "none" + if running { + if names := prettyTransportNames(s.netbios.Service().Transports()); len(names) > 0 { + transports = strings.Join(names, ", ") + } + } + hostnames := []string{} + if s.cfg.NetBIOSServerName != "" { + hostnames = append(hostnames, s.cfg.NetBIOSServerName) + } + s.reg.Set(status.Unit{ + Name: "NetBIOS", + Kind: status.KindHook, + Enabled: enabled, + Running: running, + Properties: map[string]string{"transports": transports}, Hostnames: hostnames, - Shares: shares, - DependsOn: []string{"NetBIOS"}, }) } +// refreshSMBStatus re-publishes SMB's status (its transport summary changes as +// NetBIOS transports come and go). It preserves the running flag. +func (s *Supervisor) refreshSMBStatus() { + if _, ok := s.hooks["SMB"]; !ok { + return + } + running := false + enabled := false + for _, u := range s.reg.Snapshot() { + if u.Name == "SMB" { + running = u.Running + enabled = u.Enabled + break + } + } + s.registerSMBStatus(enabled) + s.reg.SetRunning("SMB", running) +} + // AddExternalHook registers an additional named hook (e.g. the Web UI) // built outside the standard wiring, so the supervisor starts and stops it // with the rest of the stack. enabled records its configured state for the diff --git a/cmd/classicstack/supervisor_control.go b/internal/app/supervisor_control.go similarity index 78% rename from cmd/classicstack/supervisor_control.go rename to internal/app/supervisor_control.go index 18e352f..47af724 100644 --- a/cmd/classicstack/supervisor_control.go +++ b/internal/app/supervisor_control.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" @@ -123,11 +123,41 @@ func (s *Supervisor) adoptFrom(other *Supervisor) { s.shortHook = other.shortHook s.macIP = other.macIP s.ipxGW = other.ipxGW + s.netbios = other.netbios + s.transportBindings = other.transportBindings s.started = false } -// ListInterfaces returns the host's network interface names for the UI -// dropdowns. -func (s *Supervisor) ListInterfaces() ([]string, error) { - return rawlink.InterfaceNames() +// ListInterfaces returns the host's network interfaces for the UI dropdowns, +// each with the pcap device name (stored in config) plus a friendly +// description and addresses. On Windows the device name is a GUID, so the +// description is what makes the dropdown legible. Falls back to bare names +// when device enumeration (which needs Npcap/libpcap) is unavailable. +func (s *Supervisor) ListInterfaces() ([]control.InterfaceInfo, error) { + devs, err := rawlink.ListPcapDevices() + if err != nil { + names, nerr := rawlink.InterfaceNames() + if nerr != nil { + return nil, nerr + } + out := make([]control.InterfaceInfo, 0, len(names)) + for _, n := range names { + out = append(out, control.InterfaceInfo{Name: n}) + } + return out, nil + } + out := make([]control.InterfaceInfo, 0, len(devs)) + for _, d := range devs { + out = append(out, control.InterfaceInfo{ + Name: d.Name, + Description: d.Description, + Addresses: d.Addresses, + }) + } + return out, nil +} + +// ListFSTypes returns the AFP filesystem types registered in this build. +func (s *Supervisor) ListFSTypes() []string { + return registeredFSTypes() } diff --git a/cmd/classicstack/supervisor_lifecycle.go b/internal/app/supervisor_lifecycle.go similarity index 60% rename from cmd/classicstack/supervisor_lifecycle.go rename to internal/app/supervisor_lifecycle.go index 83e4490..cb79c01 100644 --- a/cmd/classicstack/supervisor_lifecycle.go +++ b/internal/app/supervisor_lifecycle.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" @@ -78,7 +78,14 @@ func (s *Supervisor) StartService(ctx context.Context, name string) error { if _, ok := s.hooks[name]; !ok { return fmt.Errorf("unknown service %q", name) } - return s.startHookLocked(ctx, name) + if err := s.startHookLocked(ctx, name); err != nil { + return err + } + // If this hook is a transport provider (IPX/NetBEUI), re-attach its + // bindings into the higher layers (NetBIOS transports, SMB direct-IPX) + // now that its protocol is freshly started. + s.attachTransportBindings(name) + return nil } // StopService stops a single named hook and any hooks that depend on it @@ -89,6 +96,11 @@ func (s *Supervisor) StopService(name string) error { if _, ok := s.hooks[name]; !ok { return fmt.Errorf("unknown service %q", name) } + // Detach this hook's transport bindings before stopping it, so the + // bound transport releases its port/sockets while they are still open — + // and the higher layer (NetBIOS/SMB) keeps running on its remaining + // bindings instead of being torn down. + s.detachTransportBindings(name) // Stop dependents first. for _, dep := range s.dependentsOf(name) { s.stopHookLocked(dep) @@ -97,6 +109,48 @@ func (s *Supervisor) StopService(name string) error { return nil } +// attachTransportBindings re-establishes the bindings the named transport hook +// contributes to higher layers and refreshes the affected units' status. +func (s *Supervisor) attachTransportBindings(name string) { + bindings := s.transportBindings[name] + owners := map[string]bool{} + for _, b := range bindings { + if b.attach != nil { + if err := b.attach(); err != nil { + netlog.Warn("[SUP][%s] attach binding to %s: %v", name, b.owner, err) + } + } + owners[b.owner] = true + } + s.refreshBindingOwners(owners) +} + +// detachTransportBindings tears down the bindings the named transport hook +// contributes and refreshes the affected units' status. +func (s *Supervisor) detachTransportBindings(name string) { + bindings := s.transportBindings[name] + owners := map[string]bool{} + for _, b := range bindings { + if b.detach != nil { + b.detach() + } + owners[b.owner] = true + } + s.refreshBindingOwners(owners) +} + +// refreshBindingOwners re-publishes status for the layers whose bindings just +// changed, so the dashboard reflects the current transport set. +func (s *Supervisor) refreshBindingOwners(owners map[string]bool) { + if owners["NetBIOS"] { + s.refreshNetBIOSStatus(s.cfg.NetBIOSEnabled) + } + if owners["NetBIOS"] || owners["SMB"] { + // SMB's transport summary derives from NetBIOS's transports too. + s.refreshSMBStatus() + } +} + // RestartService stops then starts a named hook, restarting its dependents // around it so they re-attach to the freshly started instance. func (s *Supervisor) RestartService(ctx context.Context, name string) error { @@ -106,15 +160,18 @@ func (s *Supervisor) RestartService(ctx context.Context, name string) error { return fmt.Errorf("unknown service %q", name) } deps := s.dependentsOf(name) - // Stop dependents (reverse) then the target. + // Stop dependents (reverse) then the target, detaching the target's + // transport bindings first so its bound transports release cleanly. for i := len(deps) - 1; i >= 0; i-- { s.stopHookLocked(deps[i]) } + s.detachTransportBindings(name) s.stopHookLocked(name) - // Start the target then its dependents. + // Start the target, re-attach its bindings, then its dependents. if err := s.startHookLocked(ctx, name); err != nil { return err } + s.attachTransportBindings(name) for _, dep := range deps { if err := s.startHookLocked(ctx, dep); err != nil { netlog.Warn("[SUP][%s] dependent start failed: %v", dep, err) @@ -132,6 +189,7 @@ func (s *Supervisor) startHookLocked(ctx context.Context, name string) error { return err } s.reg.SetRunning(name, true) + s.onHookStateChanged(name) netlog.Info("[SUP][%s] started", name) return nil } @@ -145,9 +203,26 @@ func (s *Supervisor) stopHookLocked(name string) { netlog.Warn("[SUP][%s] stop warning: %v", name, err) } s.reg.SetRunning(name, false) + s.onHookStateChanged(name) netlog.Info("[SUP][%s] stopped", name) } +// onHookStateChanged refreshes the transport-summary status of layers whose +// displayed bindings depend on the hook that just started/stopped. NetBIOS +// (de)activating changes both its own transport list and SMB's served set; +// IPX/NetBEUI are handled via their transport bindings, but their running flag +// also affects the summaries, so refresh on those too. +func (s *Supervisor) onHookStateChanged(name string) { + switch name { + case "NetBIOS": + s.refreshNetBIOSStatus(s.cfg.NetBIOSEnabled) + s.refreshSMBStatus() + case "IPX", "NetBEUI": + s.refreshNetBIOSStatus(s.cfg.NetBIOSEnabled) + s.refreshSMBStatus() + } +} + // dependentsOf returns the hooks that declare name in their DependsOn, // transitively, in start order. func (s *Supervisor) dependentsOf(name string) []string { diff --git a/internal/app/transport_bindings_test.go b/internal/app/transport_bindings_test.go new file mode 100644 index 0000000..a38ca08 --- /dev/null +++ b/internal/app/transport_bindings_test.go @@ -0,0 +1,189 @@ +//go:build all + +package app + +import ( + "context" + "strings" + "testing" + + "github.com/ObsoleteMadness/ClassicStack/config" + "github.com/ObsoleteMadness/ClassicStack/pkg/status" + netbiosproto "github.com/ObsoleteMadness/ClassicStack/protocol/netbios" + "github.com/ObsoleteMadness/ClassicStack/service/netbios" +) + +// fakeBindingNetBIOS is a minimal NetBIOSHook whose Service() is a real +// netbios.Service, so transport attach/detach exercises the live add/remove +// path while BuildTransport hands back inert fake transports. +type fakeBindingNetBIOS struct { + svc *netbios.Service +} + +func (f *fakeBindingNetBIOS) Start(_ context.Context) error { return nil } +func (f *fakeBindingNetBIOS) Stop() error { return nil } +func (f *fakeBindingNetBIOS) NameService() netbios.NameService { return f.svc.NameService() } +func (f *fakeBindingNetBIOS) Service() *netbios.Service { return f.svc } +func (f *fakeBindingNetBIOS) BuildTransport(string) netbios.Transport { + return &bindingFakeTransport{} +} + +// bindingFakeTransport is an inert netbios.Transport for binding tests. +type bindingFakeTransport struct{} + +func (*bindingFakeTransport) Start(_ context.Context) error { return nil } +func (*bindingFakeTransport) Stop() error { return nil } +func (*bindingFakeTransport) SendName(_ netbiosproto.Name) error { return nil } +func (*bindingFakeTransport) SendDatagram(_ *netbiosproto.Datagram) error { return nil } +func (*bindingFakeTransport) SendSession(_ *netbiosproto.SessionPacket) error { return nil } +func (*bindingFakeTransport) SetCommandHandler(_ netbios.CommandHandler) {} + +// TestDetachAttachTransportBindings verifies the supervisor's binding helpers: +// detaching the NetBEUI binding removes only that transport from NetBIOS and +// refreshes the NetBIOS status; attaching re-adds it. This is the unit-level +// proof of "stopping NetBEUI just removes the NetBEUI binding". +func TestDetachAttachTransportBindings(t *testing.T) { + reg := status.NewRegistry() + svc := netbios.NewService("CLASSICSTACK", "", nil) + nb := &fakeBindingNetBIOS{svc: svc} + + s := &Supervisor{ + reg: reg, + hooks: map[string]hook{}, + netbios: nb, + } + s.cfg.NetBIOSEnabled = true + s.transportBindings = map[string][]transportBinding{ + "NetBEUI": {s.netbiosTransportBinding("netbeui")}, + "IPX": {s.netbiosTransportBinding("ipx")}, + } + reg.Set(status.Unit{Name: "NetBIOS", Kind: status.KindHook, Enabled: true, Running: true}) + + // Start with both transports bound. + s.attachTransportBindings("NetBEUI") + s.attachTransportBindings("IPX") + if got := svc.Transports(); len(got) != 2 { + t.Fatalf("after attach: Transports()=%v, want 2", got) + } + + // Detach NetBEUI: only "netbeui" leaves; "ipx" stays. + s.detachTransportBindings("NetBEUI") + got := svc.Transports() + if len(got) != 1 || got[0] != "ipx" { + t.Fatalf("after detach NetBEUI: Transports()=%v, want [ipx]", got) + } + // NetBIOS status must reflect the reduced transport set and stay running. + nbUnit := unitByName(reg, "NetBIOS") + if nbUnit.Properties["transports"] != "IPX" { + t.Fatalf("NetBIOS transports property=%q, want %q", nbUnit.Properties["transports"], "IPX") + } + if !nbUnit.Running { + t.Fatal("NetBIOS must stay running after a transport detach") + } + + // Re-attach NetBEUI. + s.attachTransportBindings("NetBEUI") + if got := svc.Transports(); len(got) != 2 { + t.Fatalf("after re-attach: Transports()=%v, want 2", got) + } +} + +// TestSMBStatusShowsTransportsNotPhantomPort verifies SMB's status no longer +// advertises the unimplemented NBT :139 binding and instead lists the real +// served transports sourced from NetBIOS. +func TestSMBStatusShowsTransportsNotPhantomPort(t *testing.T) { + reg := status.NewRegistry() + svc := netbios.NewService("CLASSICSTACK", "", nil) + _ = svc.AddTransport("ipx", &bindingFakeTransport{}) + _ = svc.AddTransport("netbeui", &bindingFakeTransport{}) + + model := &config.Model{} + model.SMB.NBTBinding = ":139" + model.SMB.Workgroup = "WORKGROUP" + model.SMB.ServerName = "CLASSICSTACK" + + s := &Supervisor{ + reg: reg, + hooks: map[string]hook{}, + model: model, + netbios: &fakeBindingNetBIOS{svc: svc}, + } + // SMB only lists NetBIOS as a transport while NetBIOS is running. + reg.Set(status.Unit{Name: "NetBIOS", Kind: status.KindHook, Running: true}) + s.registerSMBStatus(true) + + u := unitByName(reg, "SMB") + if u.Binding == ":139" { + t.Fatalf("SMB binding still shows phantom :139") + } + transports := u.Properties["transports"] + if !strings.Contains(transports, "NetBIOS") || !strings.Contains(transports, "IPX") || !strings.Contains(transports, "NetBEUI") { + t.Fatalf("SMB transports property = %q, want it to name NetBIOS/IPX/NetBEUI", transports) + } +} + +// TestSMBDropsNetBIOSWhenStopped verifies that when NetBIOS is not running, +// SMB no longer lists NetBIOS as a served transport (the reported bug: SMB +// kept showing NetBIOS after NetBIOS was stopped). +func TestSMBDropsNetBIOSWhenStopped(t *testing.T) { + reg := status.NewRegistry() + svc := netbios.NewService("CLASSICSTACK", "", nil) + _ = svc.AddTransport("ipx", &bindingFakeTransport{}) + + model := &config.Model{} + s := &Supervisor{ + reg: reg, + hooks: map[string]hook{}, + model: model, + netbios: &fakeBindingNetBIOS{svc: svc}, + } + // NetBIOS stopped. + reg.Set(status.Unit{Name: "NetBIOS", Kind: status.KindHook, Running: false}) + s.registerSMBStatus(true) + + transports := unitByName(reg, "SMB").Properties["transports"] + if strings.Contains(transports, "NetBIOS") { + t.Fatalf("SMB still lists NetBIOS while NetBIOS is stopped: %q", transports) + } + if transports != "none" { + t.Fatalf("SMB transports = %q, want \"none\" with no other transports running", transports) + } +} + +// TestNetBIOSStatusShowsTransportsAfterStart verifies the reported bug fix: +// after NetBIOS starts with transports bound, its status lists them (rather +// than the stale empty set captured at wire time). onHookStateChanged drives +// the refresh; here we call refreshNetBIOSStatus with NetBIOS marked running. +func TestNetBIOSStatusShowsTransportsAfterStart(t *testing.T) { + reg := status.NewRegistry() + svc := netbios.NewService("CLASSICSTACK", "", nil) + _ = svc.AddTransport("ipx", &bindingFakeTransport{}) + _ = svc.AddTransport("netbeui", &bindingFakeTransport{}) + + s := &Supervisor{reg: reg, hooks: map[string]hook{}, netbios: &fakeBindingNetBIOS{svc: svc}} + s.cfg.NetBIOSEnabled = true + + // Before start: not running -> "none". + reg.Set(status.Unit{Name: "NetBIOS", Kind: status.KindHook, Running: false}) + s.refreshNetBIOSStatus(true) + if got := unitByName(reg, "NetBIOS").Properties["transports"]; got != "none" { + t.Fatalf("stopped NetBIOS transports = %q, want none", got) + } + + // After start: running -> lists IPX, NetBEUI. + reg.SetRunning("NetBIOS", true) + s.refreshNetBIOSStatus(true) + got := unitByName(reg, "NetBIOS").Properties["transports"] + if !strings.Contains(got, "IPX") || !strings.Contains(got, "NetBEUI") { + t.Fatalf("running NetBIOS transports = %q, want IPX and NetBEUI", got) + } +} + +func unitByName(reg *status.Registry, name string) status.Unit { + for _, u := range reg.Snapshot() { + if u.Name == name { + return u + } + } + return status.Unit{} +} diff --git a/internal/app/version.go b/internal/app/version.go new file mode 100644 index 0000000..4d8dba9 --- /dev/null +++ b/internal/app/version.go @@ -0,0 +1,11 @@ +package app + +// Version carries the link-time build metadata into the run-core. Each +// command binary (cmd/classicstack, cmd/classicstackd, cmd/classicstack-svc) +// holds its own `-ldflags -X main.Build*` vars and passes them in, so the +// ldflags target stays `main.*` regardless of which binary is built. +type Version struct { + Version string + Commit string + Date string +} diff --git a/cmd/classicstack/webui_config.go b/internal/app/webui_config.go similarity index 99% rename from cmd/classicstack/webui_config.go rename to internal/app/webui_config.go index f22a999..ca36107 100644 --- a/cmd/classicstack/webui_config.go +++ b/internal/app/webui_config.go @@ -1,4 +1,4 @@ -package main +package app import ( "fmt" diff --git a/cmd/classicstack/webui_disabled.go b/internal/app/webui_disabled.go similarity index 97% rename from cmd/classicstack/webui_disabled.go rename to internal/app/webui_disabled.go index 980f796..89c79bc 100644 --- a/cmd/classicstack/webui_disabled.go +++ b/internal/app/webui_disabled.go @@ -1,6 +1,6 @@ //go:build !webui && !all -package main +package app import ( "context" diff --git a/cmd/classicstack/webui_enabled.go b/internal/app/webui_enabled.go similarity index 98% rename from cmd/classicstack/webui_enabled.go rename to internal/app/webui_enabled.go index b697a66..b5b73ea 100644 --- a/cmd/classicstack/webui_enabled.go +++ b/internal/app/webui_enabled.go @@ -1,6 +1,6 @@ //go:build webui || all -package main +package app import ( "context" diff --git a/cmd/classicstack/webui_hook.go b/internal/app/webui_hook.go similarity index 98% rename from cmd/classicstack/webui_hook.go rename to internal/app/webui_hook.go index 95d5af5..fd311e5 100644 --- a/cmd/classicstack/webui_hook.go +++ b/internal/app/webui_hook.go @@ -1,4 +1,4 @@ -package main +package app import "context" diff --git a/pkg/control/control.go b/pkg/control/control.go index eb29ef4..a54a564 100644 --- a/pkg/control/control.go +++ b/pkg/control/control.go @@ -32,9 +32,24 @@ type Supervisor interface { StopService(name string) error // RestartService restarts a single named unit (and its dependents). RestartService(ctx context.Context, name string) error - // ListInterfaces returns the host's network interface names for the - // EtherTalk/IPX/NetBEUI dropdowns. - ListInterfaces() ([]string, error) + // ListInterfaces returns the host's network interfaces for the + // EtherTalk/IPX/NetBEUI/MacIP dropdowns. Each entry carries the device + // Name pcap opens plus a human-friendly Description and addresses so the + // UI can show a readable label (the raw Name is a GUID on Windows). + ListInterfaces() ([]InterfaceInfo, error) + // ListFSTypes returns the AFP filesystem-type names registered in this + // build (e.g. "local_fs", and "macgarden" when built with that tag), for + // the volume/share FS-type dropdown. + ListFSTypes() []string +} + +// InterfaceInfo describes one network interface for the UI dropdowns. Name is +// the value stored in config (the pcap device name); Description and Addresses +// drive a friendly label. +type InterfaceInfo struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Addresses []string `json:"addresses,omitempty"` } // ConfigModel is the in-memory configuration the plane stages and applies. @@ -101,14 +116,22 @@ func New(d Deps) *Plane { // Status returns a snapshot of all registered service/port/hook units. func (p *Plane) Status() []status.Unit { return p.reg.Snapshot() } -// ListInterfaces returns host network interface names. -func (p *Plane) ListInterfaces() ([]string, error) { +// ListInterfaces returns host network interfaces with friendly labels. +func (p *Plane) ListInterfaces() ([]InterfaceInfo, error) { if p.sup == nil { return nil, nil } return p.sup.ListInterfaces() } +// ListFSTypes returns the AFP filesystem types registered in this build. +func (p *Plane) ListFSTypes() []string { + if p.sup == nil { + return nil + } + return p.sup.ListFSTypes() +} + // ListSerialPorts returns the host's serial ports for the TashTalk dropdown. func (p *Plane) ListSerialPorts() ([]serialport.Info, error) { return serialport.List() diff --git a/pkg/control/control_test.go b/pkg/control/control_test.go index acf7401..9bb4a99 100644 --- a/pkg/control/control_test.go +++ b/pkg/control/control_test.go @@ -26,7 +26,27 @@ func (s *fakeSup) RestartService(_ context.Context, name string) error { s.restarts = append(s.restarts, name) return nil } -func (s *fakeSup) ListInterfaces() ([]string, error) { return []string{"eth0"}, nil } +func (s *fakeSup) ListInterfaces() ([]InterfaceInfo, error) { + return []InterfaceInfo{{Name: "eth0", Description: "Ethernet"}}, nil +} +func (s *fakeSup) ListFSTypes() []string { return []string{"local_fs"} } + +func TestListInterfacesAndFSTypes(t *testing.T) { + p := New(Deps{Supervisor: &fakeSup{}}) + + ifaces, err := p.ListInterfaces() + if err != nil { + t.Fatalf("ListInterfaces: %v", err) + } + if len(ifaces) != 1 || ifaces[0].Name != "eth0" || ifaces[0].Description != "Ethernet" { + t.Fatalf("ListInterfaces = %+v, want one eth0/Ethernet", ifaces) + } + + fsTypes := p.ListFSTypes() + if len(fsTypes) != 1 || fsTypes[0] != "local_fs" { + t.Fatalf("ListFSTypes = %v, want [local_fs]", fsTypes) + } +} func TestDirtyLifecycle(t *testing.T) { sup := &fakeSup{} diff --git a/port/ethertalk/pcap.go b/port/ethertalk/pcap.go index 9e3484a..6b4c17f 100644 --- a/port/ethertalk/pcap.go +++ b/port/ethertalk/pcap.go @@ -159,6 +159,16 @@ func (p *PcapPort) Start(r port.RouterHooks) error { } p.link = link + // Recreate the lifecycle channels on every Start so the port survives a + // Stop/Start cycle: Stop closes these channels, and closing them a + // second time (or a goroutine's deferred close of an already-closed + // done channel) panics. The UI drives exactly this restart path. + p.readerStop = make(chan struct{}) + p.readerDone = make(chan struct{}) + p.writerStop = make(chan struct{}) + p.writerDone = make(chan struct{}) + p.writerQueue = make(chan []byte, 1024) + // Detect physical medium and resolve bridge mode. if mr, ok := link.(rawlink.MediumReporter); ok { p.medium = mr.Medium() @@ -185,8 +195,10 @@ func (p *PcapPort) Start(r port.RouterHooks) error { if err := p.Port.Start(r); err != nil { return err } - go p.readRun() - go p.writeRun() + // Bind each goroutine to this cycle's link and channels so a later + // Start (which reassigns the fields) cannot race with them. + go p.readRun(link, p.readerStop, p.readerDone) + go p.writeRun(link, p.writerQueue, p.writerStop, p.writerDone) return nil } @@ -197,19 +209,23 @@ func (p *PcapPort) Stop() error { <-p.writerDone if p.link != nil { _ = p.link.Close() + p.link = nil } return p.Port.Stop() } -func (p *PcapPort) readRun() { - defer close(p.readerDone) +func (p *PcapPort) readRun(link rawlink.RawLink, stop, done chan struct{}) { + defer close(done) for { select { - case <-p.readerStop: + case <-stop: return default: - data, err := p.link.ReadFrame() + data, err := link.ReadFrame() if err != nil { + if errors.Is(err, rawlink.ErrClosed) { + return + } if !errors.Is(err, rawlink.ErrTimeout) { netlog.Warn("pcap read error on %s: %v", p.interfaceName, err) } @@ -234,20 +250,20 @@ func (p *PcapPort) sendFrame(frameData []byte) { } } -func (p *PcapPort) writeRun() { - defer close(p.writerDone) +func (p *PcapPort) writeRun(link rawlink.RawLink, queue chan []byte, stop, done chan struct{}) { + defer close(done) for { select { - case <-p.writerStop: + case <-stop: return - case frameData := <-p.writerQueue: + case frameData := <-queue: prepared, err := p.adapter.outboundFrame(frameData) if err != nil { netlog.Warn("failed to prepare outbound frame on %s: %v", p.interfaceName, err) continue } p.capture(prepared) - if err := p.link.WriteFrame(prepared); err != nil { + if err := link.WriteFrame(prepared); err != nil { netlog.Warn("couldn't send packet: %v", err) } } diff --git a/port/ipx/port.go b/port/ipx/port.go index 29570af..22590cb 100644 --- a/port/ipx/port.go +++ b/port/ipx/port.go @@ -77,18 +77,34 @@ type Port interface { SetCaptureSink(sink capture.Sink) } +// LinkFactory opens a fresh rawlink for the port. It is called once per +// Start so the port can be stopped and started again: Stop frees the +// previous rawlink (which, for a libpcap-backed link, releases the C +// handle), and the next Start opens a new one. A factory that returns +// the same link on every call yields a single-shot port — fine for +// in-process links that survive Close, but a libpcap link must hand back +// a freshly opened handle each time. +type LinkFactory func() (rawlink.RawLink, error) + // portImpl is the rawlink-backed IPX port. type portImpl struct { - link rawlink.RawLink - framing Framing + openLink LinkFactory + framing Framing - mu sync.RWMutex - cb DeliveryCallback - cs capture.Sink + mu sync.RWMutex + cb DeliveryCallback + cs capture.Sink + link rawlink.RawLink // current rawlink; nil while stopped. dedupMu sync.Mutex recentFrames map[uint64]time.Time + // running guards the Start/Stop lifecycle. The read-loop channels and + // stopOnce are recreated on each Start so the port is fully + // restartable; the prior implementation closed them once and panicked + // on the second cycle. + lifeMu sync.Mutex + running bool stopOnce sync.Once readerStop chan struct{} readerDone chan struct{} @@ -100,38 +116,89 @@ const inboundFrameDedupTTL = 100 * time.Millisecond // NewPort opens an IPX port on link using the default Ethernet II // framing for outbound transmit. Inbound frames are accepted in all // three documented framings. +// +// The supplied link is reused across Stop/Start cycles, so this +// constructor suits in-process links (tests, virtual transports) whose +// Close does not free unrecoverable resources. For a libpcap link that +// must be reopened on restart, use NewPortWithLinkFactory. func NewPort(link rawlink.RawLink) Port { return NewPortWithFraming(link, FramingEthernetII) } // NewPortWithFraming opens an IPX port on link with the given outbound -// framing. +// framing. The link is reused across restarts; see NewPort. func NewPortWithFraming(link rawlink.RawLink, framing Framing) Port { + return newPort(func() (rawlink.RawLink, error) { return link, nil }, framing) +} + +// NewPortWithLinkFactory builds a restartable port that opens a fresh +// rawlink from open on every Start and closes it on every Stop. This is +// the constructor a libpcap-backed deployment uses so that a UI-driven +// stop/start reopens the interface instead of touching a freed handle. +func NewPortWithLinkFactory(open LinkFactory, framing Framing) Port { + return newPort(open, framing) +} + +func newPort(open LinkFactory, framing Framing) *portImpl { return &portImpl{ - link: link, + openLink: open, framing: framing, recentFrames: make(map[uint64]time.Time), - readerStop: make(chan struct{}), - readerDone: make(chan struct{}), } } func (p *portImpl) Start() error { - if fl, ok := p.link.(rawlink.FilterableLink); ok { + p.lifeMu.Lock() + defer p.lifeMu.Unlock() + if p.running { + return nil + } + + link, err := p.openLink() + if err != nil { + return err + } + if fl, ok := link.(rawlink.FilterableLink); ok { if err := fl.SetFilter(IPXBPFFilter); err != nil { netlog.Warn("[IPX] could not set BPF filter: %v", err) } } - go p.readLoop() + + // Fresh channels and stopOnce per cycle so the port can be restarted + // after a Stop without closing an already-closed channel. + p.readerStop = make(chan struct{}) + p.readerDone = make(chan struct{}) + p.stopOnce = sync.Once{} + + p.mu.Lock() + p.link = link + p.mu.Unlock() + + p.running = true + // Bind the loop to this cycle's channels and link so a later Start + // (which reassigns the fields) cannot race with this goroutine. + go p.readLoop(link, p.readerStop, p.readerDone) return nil } func (p *portImpl) Stop() error { + p.lifeMu.Lock() + defer p.lifeMu.Unlock() + if !p.running { + return nil + } p.stopOnce.Do(func() { close(p.readerStop) <-p.readerDone - _ = p.link.Close() + p.mu.Lock() + link := p.link + p.link = nil + p.mu.Unlock() + if link != nil { + _ = link.Close() + } }) + p.running = false return nil } @@ -163,13 +230,17 @@ func (p *portImpl) sendEthernetII(d *protocol.Datagram, payload []byte) error { copy(frame[14:], payload) p.mu.RLock() sink := p.cs + link := p.link p.mu.RUnlock() + if link == nil { + return rawlink.ErrClosed + } // Pre-register the outbound frame so any loopback copy the kernel // surfaces back through readLoop is suppressed by isDuplicateFrame — // otherwise our own frames are captured (and decoded) twice. p.markFrameSeen(frame) capture.Write(sink, time.Now(), frame) - return p.link.WriteFrame(frame) + return link.WriteFrame(frame) } func (p *portImpl) SetDeliveryCallback(cb DeliveryCallback) { @@ -185,20 +256,27 @@ func (p *portImpl) SetCaptureSink(sink capture.Sink) { } // readLoop is the single inbound reader. It demultiplexes by EtherType -// / length / LLC SAP and hands the IPX body to deliver(). -func (p *portImpl) readLoop() { - defer close(p.readerDone) +// / length / LLC SAP and hands the IPX body to deliver(). The link and +// stop/done channels are passed in so the loop is bound to the Start +// cycle that spawned it, immune to a later Start reassigning the fields. +func (p *portImpl) readLoop(link rawlink.RawLink, stop, done chan struct{}) { + defer close(done) for { select { - case <-p.readerStop: + case <-stop: return default: } - frame, err := p.link.ReadFrame() + frame, err := link.ReadFrame() if err != nil { if errors.Is(err, rawlink.ErrTimeout) { continue } + if errors.Is(err, rawlink.ErrClosed) { + // Link was closed out from under us; stop reading rather + // than spin on a permanently-failing handle. + return + } netlog.Warn("[IPX] read error: %v", err) continue } diff --git a/port/ipx/port_test.go b/port/ipx/port_test.go index c4ecab8..393f1f0 100644 --- a/port/ipx/port_test.go +++ b/port/ipx/port_test.go @@ -142,6 +142,86 @@ func TestIPXEthernetIIRoundTrip(t *testing.T) { } } +// TestIPXPortRestart exercises the UI stop/start lifecycle that previously +// crashed: the first cycle ran fine, the second panicked with "close of +// closed channel" because the read-loop channels were closed once and never +// recreated. With NewPortWithLinkFactory each Start opens a fresh link and +// resets the channels, so repeated Stop/Start must work and deliver frames. +func TestIPXPortRestart(t *testing.T) { + var mu sync.Mutex + var links []*fakeRawLink + open := func() (rawlink.RawLink, error) { + l := newFakeRawLink() + mu.Lock() + links = append(links, l) + mu.Unlock() + return l, nil + } + p := NewPortWithLinkFactory(open, FramingEthernetII) + defer p.Stop() + + delivered := make(chan *protocol.Datagram, 4) + p.SetDeliveryCallback(func(d *protocol.Datagram) { delivered <- d }) + + for cycle := range 3 { + if err := p.Start(); err != nil { + t.Fatalf("cycle %d Start: %v", cycle, err) + } + mu.Lock() + link := links[len(links)-1] + mu.Unlock() + + link.Push(buildEthernetIIIPX(makeIPXBytes(t, []byte("hi")))) + select { + case got := <-delivered: + if string(got.Payload) != "hi" { + t.Fatalf("cycle %d payload: got %q want %q", cycle, got.Payload, "hi") + } + case <-time.After(time.Second): + t.Fatalf("cycle %d: no delivery", cycle) + } + + if err := p.Stop(); err != nil { + t.Fatalf("cycle %d Stop: %v", cycle, err) + } + } + + mu.Lock() + n := len(links) + mu.Unlock() + if n != 3 { + t.Fatalf("link factory called %d times, want 3 (one fresh link per Start)", n) + } +} + +// TestIPXPortDoubleStartStop verifies Start and Stop are individually +// idempotent: a redundant Start does not spawn a second reader, and a +// redundant Stop does not close an already-closed channel. +func TestIPXPortDoubleStartStop(t *testing.T) { + calls := 0 + open := func() (rawlink.RawLink, error) { + calls++ + return newFakeRawLink(), nil + } + p := NewPortWithLinkFactory(open, FramingEthernetII) + + if err := p.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + if err := p.Start(); err != nil { // redundant + t.Fatalf("second Start: %v", err) + } + if calls != 1 { + t.Fatalf("link opened %d times across redundant Start, want 1", calls) + } + if err := p.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if err := p.Stop(); err != nil { // redundant; must not panic + t.Fatalf("second Stop: %v", err) + } +} + func TestIPXRaw8023Decoded(t *testing.T) { link := newFakeRawLink() p := NewPort(link) diff --git a/port/netbeui/port.go b/port/netbeui/port.go index e927fd8..9fbe67b 100644 --- a/port/netbeui/port.go +++ b/port/netbeui/port.go @@ -102,50 +102,116 @@ type llcConn struct { nR uint8 // expected next from remote (N(R) we put in our ACKs) } +// LinkFactory opens a fresh rawlink for the port, called once per Start. +// See ipx.LinkFactory: a libpcap-backed link must hand back a freshly +// opened handle each time so the port can be stopped and restarted. +type LinkFactory func() (rawlink.RawLink, error) + type portImpl struct { - link rawlink.RawLink + openLink LinkFactory mu sync.RWMutex src [6]byte hasSrc bool cb DeliveryCallback cs capture.Sink + link rawlink.RawLink // current rawlink; nil while stopped. connsMu sync.RWMutex conns map[[6]byte]*llcConn + // lifeMu guards the Start/Stop lifecycle. Channels and stopOnce are + // recreated on each Start so the port survives a Stop/Start cycle. + lifeMu sync.Mutex + running bool stopOnce sync.Once readerStop chan struct{} readerDone chan struct{} } -// NewPort returns a NetBEUI port bound to link. Start must be called -// before inbound frames are delivered. +// NewPort returns a NetBEUI port bound to link. The link is reused across +// restarts, so this constructor suits in-process links; for a libpcap link +// that must reopen on restart use NewPortWithLinkFactory. Start must be +// called before inbound frames are delivered. func NewPort(link rawlink.RawLink) Port { + return NewPortWithLinkFactory(func() (rawlink.RawLink, error) { return link, nil }) +} + +// NewPortWithLinkFactory builds a restartable NetBEUI port that opens a +// fresh rawlink from open on every Start and closes it on every Stop. +func NewPortWithLinkFactory(open LinkFactory) Port { return &portImpl{ - link: link, - conns: make(map[[6]byte]*llcConn), - readerStop: make(chan struct{}), - readerDone: make(chan struct{}), + openLink: open, + conns: make(map[[6]byte]*llcConn), + } +} + +// currentLink returns the active rawlink, or nil if the port is stopped. +func (p *portImpl) currentLink() rawlink.RawLink { + p.mu.RLock() + defer p.mu.RUnlock() + return p.link +} + +// writeFrame sends out on the active link, returning ErrClosed if the port +// is stopped. All outbound paths funnel through here so none touch a freed +// handle after Stop. +func (p *portImpl) writeFrame(out []byte) error { + link := p.currentLink() + if link == nil { + return rawlink.ErrClosed } + return link.WriteFrame(out) } func (p *portImpl) Start() error { - if fl, ok := p.link.(rawlink.FilterableLink); ok { + p.lifeMu.Lock() + defer p.lifeMu.Unlock() + if p.running { + return nil + } + + link, err := p.openLink() + if err != nil { + return err + } + if fl, ok := link.(rawlink.FilterableLink); ok { if err := fl.SetFilter(NetBEUIBPFFilter); err != nil { netlog.Warn("[NetBEUI] could not set BPF filter: %v", err) } } - go p.readLoop() + + p.readerStop = make(chan struct{}) + p.readerDone = make(chan struct{}) + p.stopOnce = sync.Once{} + + p.mu.Lock() + p.link = link + p.mu.Unlock() + + p.running = true + go p.readLoop(link, p.readerStop, p.readerDone) return nil } func (p *portImpl) Stop() error { + p.lifeMu.Lock() + defer p.lifeMu.Unlock() + if !p.running { + return nil + } p.stopOnce.Do(func() { close(p.readerStop) <-p.readerDone - _ = p.link.Close() + p.mu.Lock() + link := p.link + p.link = nil + p.mu.Unlock() + if link != nil { + _ = link.Close() + } }) + p.running = false return nil } @@ -193,14 +259,14 @@ func (p *portImpl) sendLLCUA(dstMAC [6]byte) { copy(out[6:12], src[:]) out[12] = 0x00 out[13] = llcLen - out[14] = 0xF0 // DSAP - out[15] = 0xF1 // SSAP with C/R = response + out[14] = 0xF0 // DSAP + out[15] = 0xF1 // SSAP with C/R = response out[16] = llcControlUAF // UA with F=1 p.mu.RLock() sink := p.cs p.mu.RUnlock() capture.Write(sink, time.Now(), out) - if err := p.link.WriteFrame(out); err != nil { + if err := p.writeFrame(out); err != nil { netlog.Warn("[NetBEUI] LLC UA send error: %v", err) } } @@ -229,7 +295,7 @@ func (p *portImpl) sendLLCRR(dstMAC [6]byte, nR uint8) { sink := p.cs p.mu.RUnlock() capture.Write(sink, time.Now(), out) - if err := p.link.WriteFrame(out); err != nil { + if err := p.writeFrame(out); err != nil { netlog.Warn("[NetBEUI] LLC RR send error: %v", err) } } @@ -269,7 +335,7 @@ func (p *portImpl) sendIFrame(dstMAC [6]byte, body []byte, conn *llcConn) error sink := p.cs p.mu.RUnlock() capture.Write(sink, time.Now(), out) - return p.link.WriteFrame(out) + return p.writeFrame(out) } // sendUI transmits body as an LLC UI (unnumbered information) frame to dstMAC. @@ -297,7 +363,7 @@ func (p *portImpl) sendUI(dstMAC [6]byte, body []byte) error { sink := p.cs p.mu.RUnlock() capture.Write(sink, time.Now(), out) - return p.link.WriteFrame(out) + return p.writeFrame(out) } func (p *portImpl) Send(dstMAC [6]byte, frame *netbeui.Frame) error { @@ -328,19 +394,24 @@ func (p *portImpl) SendBroadcast(frame *netbeui.Frame) error { // already discarded everything that isn't an 802.3 NetBIOS LLC frame; // software then strips the variable-length LLC header and decodes the // NBF body. -func (p *portImpl) readLoop() { - defer close(p.readerDone) +func (p *portImpl) readLoop(link rawlink.RawLink, stop, done chan struct{}) { + defer close(done) for { select { - case <-p.readerStop: + case <-stop: return default: } - frame, err := p.link.ReadFrame() + frame, err := link.ReadFrame() if err != nil { if errors.Is(err, rawlink.ErrTimeout) { continue } + if errors.Is(err, rawlink.ErrClosed) { + // Link closed out from under us; stop reading rather than + // spin on a permanently-failing handle. + return + } netlog.Warn("[NetBEUI] read error: %v", err) continue } diff --git a/port/netbeui/port_test.go b/port/netbeui/port_test.go index 3948c93..62ccccc 100644 --- a/port/netbeui/port_test.go +++ b/port/netbeui/port_test.go @@ -89,6 +89,40 @@ func buildLLCNBFAddressed(dst, src [6]byte, body []byte, control ...byte) []byte return frame } +// TestNetBEUIPortRestart exercises the UI stop/start lifecycle that +// previously panicked with "close of closed channel" on the second cycle. +// NewPortWithLinkFactory opens a fresh link and resets the channels on each +// Start, so repeated Stop/Start must work without panicking. +func TestNetBEUIPortRestart(t *testing.T) { + var mu sync.Mutex + var links []*fakeRawLink + open := func() (rawlink.RawLink, error) { + l := newFakeRawLink() + mu.Lock() + links = append(links, l) + mu.Unlock() + return l, nil + } + p := NewPortWithLinkFactory(open) + defer p.Stop() + + for cycle := range 3 { + if err := p.Start(); err != nil { + t.Fatalf("cycle %d Start: %v", cycle, err) + } + if err := p.Stop(); err != nil { + t.Fatalf("cycle %d Stop: %v", cycle, err) + } + } + + mu.Lock() + n := len(links) + mu.Unlock() + if n != 3 { + t.Fatalf("link factory called %d times, want 3 (one fresh link per Start)", n) + } +} + func TestNetBEUIInboundDecodesNBFBody(t *testing.T) { link := newFakeRawLink() p := NewPort(link) diff --git a/port/rawlink/pcap.go b/port/rawlink/pcap.go index 8b84b81..d24ec37 100644 --- a/port/rawlink/pcap.go +++ b/port/rawlink/pcap.go @@ -3,6 +3,7 @@ package rawlink import ( "errors" "fmt" + "sync" "time" "github.com/google/gopacket/layers" @@ -74,6 +75,16 @@ func DefaultNetBEUIConfig(iface string) PcapConfig { type pcapLink struct { handle *pcap.Handle // handle is the underlying libpcap handle used for I/O. medium PhysicalMedium // medium reports the detected physical medium for the handle. + + // mu guards closed so that a Close on the supervisor goroutine cannot free + // the libpcap handle while a read/write/filter call on another goroutine is + // inside the cgo boundary. Once closed is set, the handle must never be + // touched again: libpcap frees the C-side handle in pcap_close, and calling + // pcap_compile/pcap_next on it is a use-after-free (a 0xC0000005 access + // violation on Windows). The lock is held only around the closed check and + // the cgo call, never across blocking work, so it does not serialize reads. + mu sync.RWMutex + closed bool } // PcapDeviceInfo summarizes a discovered pcap device. @@ -170,6 +181,11 @@ func OpenPcapSimple(iface string, snapLen int, promisc bool, timeout time.Durati // ReadFrame reads the next raw packet from the pcap handle. // It returns ErrTimeout when the underlying libpcap read times out. func (l *pcapLink) ReadFrame() ([]byte, error) { + l.mu.RLock() + defer l.mu.RUnlock() + if l.closed { + return nil, ErrClosed + } data, _, err := l.handle.ReadPacketData() if err != nil { if errors.Is(err, pcap.NextErrorTimeoutExpired) { @@ -182,11 +198,24 @@ func (l *pcapLink) ReadFrame() ([]byte, error) { // WriteFrame writes a raw packet to the link via the pcap handle. func (l *pcapLink) WriteFrame(frame []byte) error { + l.mu.RLock() + defer l.mu.RUnlock() + if l.closed { + return ErrClosed + } return l.handle.WritePacketData(frame) } -// Close closes the underlying pcap handle and releases resources. +// Close closes the underlying pcap handle and releases resources. It is +// idempotent and takes the write lock so it cannot free the handle while a +// concurrent ReadFrame/WriteFrame/SetFilter is mid-call. func (l *pcapLink) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + return nil + } + l.closed = true l.handle.Close() return nil } @@ -196,6 +225,11 @@ func (l *pcapLink) Medium() PhysicalMedium { return l.medium } // SetFilter implements FilterableLink. func (l *pcapLink) SetFilter(expr string) error { + l.mu.RLock() + defer l.mu.RUnlock() + if l.closed { + return ErrClosed + } return l.handle.SetBPFFilter(expr) } diff --git a/port/rawlink/pcap_closed_test.go b/port/rawlink/pcap_closed_test.go new file mode 100644 index 0000000..8ae4e8e --- /dev/null +++ b/port/rawlink/pcap_closed_test.go @@ -0,0 +1,37 @@ +package rawlink + +import ( + "errors" + "testing" +) + +// TestPcapLinkClosedGuards verifies that a closed pcapLink returns ErrClosed +// from ReadFrame, WriteFrame, and SetFilter instead of touching the freed +// libpcap handle. Reusing a port across a UI stop/start cycle previously drove +// SetFilter into pcap_compile on a closed handle, a use-after-free that +// surfaced as a 0xC0000005 access violation on Windows. The closed flag is +// checked before handle is dereferenced, so a nil handle here is intentional: +// if any guard is removed, the nil deref panics and fails the test. +func TestPcapLinkClosedGuards(t *testing.T) { + l := &pcapLink{closed: true} + + if _, err := l.ReadFrame(); !errors.Is(err, ErrClosed) { + t.Errorf("ReadFrame after close = %v, want ErrClosed", err) + } + if err := l.WriteFrame([]byte{0x00}); !errors.Is(err, ErrClosed) { + t.Errorf("WriteFrame after close = %v, want ErrClosed", err) + } + if err := l.SetFilter("ip"); !errors.Is(err, ErrClosed) { + t.Errorf("SetFilter after close = %v, want ErrClosed", err) + } +} + +// TestPcapLinkCloseIdempotent verifies Close can be called repeatedly without +// double-freeing the underlying handle. The first Close sets closed, so the +// second returns early before reaching handle.Close (which is nil here). +func TestPcapLinkCloseIdempotent(t *testing.T) { + l := &pcapLink{closed: true} // simulate already-closed; handle never touched. + if err := l.Close(); err != nil { + t.Errorf("second Close = %v, want nil", err) + } +} diff --git a/port/rawlink/rawlink.go b/port/rawlink/rawlink.go index e1d1798..4217612 100644 --- a/port/rawlink/rawlink.go +++ b/port/rawlink/rawlink.go @@ -26,6 +26,12 @@ const ( // as the sentinel so callers have no pcap dependency. var ErrTimeout = errors.New("rawlink: read timeout") +// ErrClosed is returned by ReadFrame, WriteFrame, and SetFilter when they are +// called after Close. The underlying libpcap handle has been freed, so the +// call must fail cleanly rather than dereference released C memory. This +// upholds the RawLink contract that operations after Close return errors. +var ErrClosed = errors.New("rawlink: link closed") + // RawLink is the minimal interface for reading and writing raw Ethernet frames // to a network medium. Implementations must be safe for concurrent use from // a single reader goroutine and a single writer goroutine simultaneously. @@ -66,4 +72,3 @@ type FilterableLink interface { // the expression is invalid or unsupported by the backend. SetFilter(expr string) error } - diff --git a/router/ipx/router.go b/router/ipx/router.go index c7f28c5..2b7c490 100644 --- a/router/ipx/router.go +++ b/router/ipx/router.go @@ -70,6 +70,9 @@ type Router interface { // destination socket matches. Returns an error when socket is // already registered. RegisterSocket(socket [2]byte, handler SocketHandler) error + // UnregisterSocket removes a RegisterSocket binding so the socket + // can be claimed again (e.g. on a service restart). Idempotent. + UnregisterSocket(socket [2]byte) // RegisterNode attaches handler to every inbound datagram whose // destination node matches. Returns an error when the node is // already registered. The address filter accepts the node even @@ -150,6 +153,16 @@ func (r *routerImpl) RegisterSocket(socket [2]byte, handler SocketHandler) error return nil } +func (r *routerImpl) UnregisterSocket(socket [2]byte) { + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.sockets[socket]; !exists { + return + } + delete(r.sockets, socket) + netlog.Debug("[IPX][Router] unregistered socket=%02x%02x", socket[0], socket[1]) +} + func (r *routerImpl) RegisterNode(node [6]byte, handler NodeHandler) error { r.mu.Lock() defer r.mu.Unlock() diff --git a/scripts/ci/build.ps1 b/scripts/ci/build.ps1 index d159716..ce47a97 100644 --- a/scripts/ci/build.ps1 +++ b/scripts/ci/build.ps1 @@ -87,3 +87,12 @@ if ($tags) { } else { go build -trimpath -ldflags $ldflags -o $output ./cmd/classicstack } + +# Build the Windows service wrapper (classicstack-svc.exe) alongside the main +# binary, next to it in the output directory, sharing the same tags/ldflags. +$svcOutput = Join-Path (Split-Path -Parent $output) 'classicstack-svc.exe' +if ($tags) { + go build -trimpath -tags $tags -ldflags $ldflags -o $svcOutput ./cmd/classicstack-svc +} else { + go build -trimpath -ldflags $ldflags -o $svcOutput ./cmd/classicstack-svc +} diff --git a/scripts/ci/build.sh b/scripts/ci/build.sh index 9db9af1..022afce 100644 --- a/scripts/ci/build.sh +++ b/scripts/ci/build.sh @@ -32,3 +32,12 @@ if [[ -n "$tags" ]]; then else go build -trimpath -ldflags "$ldflags" -o "$output" ./cmd/classicstack fi + +# Build the Unix daemon wrapper (classicstackd) alongside the main binary, +# next to it in the output directory, sharing the same tags/ldflags. +daemon_output="$(dirname "$output")/classicstackd" +if [[ -n "$tags" ]]; then + go build -trimpath -tags "$tags" -ldflags "$ldflags" -o "$daemon_output" ./cmd/classicstackd +else + go build -trimpath -ldflags "$ldflags" -o "$daemon_output" ./cmd/classicstackd +fi diff --git a/scripts/ci/package-release.ps1 b/scripts/ci/package-release.ps1 index b56435b..1ef2478 100644 --- a/scripts/ci/package-release.ps1 +++ b/scripts/ci/package-release.ps1 @@ -16,6 +16,10 @@ $archiveName = "classicstack$variantSlug-$releaseTag-windows-amd64.zip" New-Item -ItemType Directory -Path $stage -Force | Out-Null Copy-Item "out/$exeName" "$stage/$exeName" +# Ship the Windows service wrapper alongside the main binary when built. +if (Test-Path 'out/classicstack-svc.exe') { + Copy-Item 'out/classicstack-svc.exe' "$stage/classicstack-svc.exe" +} Copy-Item README.md,server.toml.example,extmap.conf $stage Get-ChildItem -Path dist -Force | Copy-Item -Destination $stage -Recurse -Force Compress-Archive -Path $stage -DestinationPath $archiveName -Force diff --git a/scripts/ci/package-release.sh b/scripts/ci/package-release.sh index d0f4182..53dc18d 100644 --- a/scripts/ci/package-release.sh +++ b/scripts/ci/package-release.sh @@ -25,6 +25,10 @@ if [[ "$target_os" == "linux" ]]; then mkdir -p "$stage" cp "out/${exe_name}" "$stage/${exe_name}" + # Ship the daemon wrapper alongside the main binary when it was built. + if [[ -f "out/classicstackd" ]]; then + cp "out/classicstackd" "$stage/classicstackd" + fi cp README.md server.toml.example extmap.conf "$stage/" cp -a dist/. "$stage/" tar -C release -czf "$archive_name" "$(basename "$stage")" @@ -45,6 +49,12 @@ if [[ "$target_os" == "macos" ]]; then mkdir -p "$app_root/MacOS" "$app_root/Resources" cp "out/${exe_name}" "$app_root/MacOS/classicstack" chmod +x "$app_root/MacOS/classicstack" + # Ship the daemon wrapper inside the bundle when it was built, so the + # LaunchAgent (login-item) install flow is available on macOS. + if [[ -f "out/classicstackd" ]]; then + cp "out/classicstackd" "$app_root/MacOS/classicstackd" + chmod +x "$app_root/MacOS/classicstackd" + fi cp icons/classicstack.icns "$app_root/Resources/classicstack.icns" if [[ "$build_variant" == "all" ]]; then diff --git a/server.toml b/server.toml index 9a7dec7..b9b9647 100644 --- a/server.toml +++ b/server.toml @@ -117,6 +117,11 @@ read_only = false # backend = "memory" # memory|sqlite # db_path = "shortname.db" -# [VFSBus] -# subscriber_buffer = 256 -# drop_warn_interval = "30s" + +[WebUI] +# Management web UI: a dashboard showing per-service status/statistics and a +# configuration editor. Requires a binary built with -tags webui (included in +# -tags all). Saving from the UI rewrites this file and removes comments, +# backing up the previous version to server.toml.NNNN first. +enabled = true +bind = "127.0.0.1:8089" # IP:PORT to listen on; loopback by default \ No newline at end of file diff --git a/service/afp/fs.go b/service/afp/fs.go index 7a7b1e1..740ee8a 100644 --- a/service/afp/fs.go +++ b/service/afp/fs.go @@ -54,6 +54,14 @@ func registeredFSNames() []string { return slices.Sorted(maps.Keys(fsRegistry)) } +// RegisteredFSTypes returns the filesystem-type names registered in this +// build (e.g. "local_fs", plus "macgarden" when built with that tag). It is +// the exported view of the FS registry for UI/config consumers that need to +// offer an fs_type choice. +func RegisteredFSTypes() []string { + return registeredFSNames() +} + type FileSystem interface { ReadDir(path string) ([]fs.DirEntry, error) Stat(path string) (fs.FileInfo, error) diff --git a/service/ipx/rip.go b/service/ipx/rip.go index 619c714..e1997de 100644 --- a/service/ipx/rip.go +++ b/service/ipx/rip.go @@ -63,7 +63,8 @@ func (s *RIPService) Start(ctx context.Context) error { return nil } -// Stop cancels the broadcaster and waits for it to exit. +// Stop cancels the broadcaster, waits for it to exit, and releases the +// RIP socket so the service can be started again. func (s *RIPService) Stop() error { s.mu.Lock() cancel := s.cancel @@ -76,6 +77,7 @@ func (s *RIPService) Stop() error { if done != nil { <-done } + s.router.UnregisterSocket(RIPSocket) return nil } diff --git a/service/ipx/sap.go b/service/ipx/sap.go index b585b69..cd8a285 100644 --- a/service/ipx/sap.go +++ b/service/ipx/sap.go @@ -129,7 +129,8 @@ func (s *SAPService) Start(ctx context.Context) error { return nil } -// Stop cancels the broadcaster and waits for the goroutine to exit. +// Stop cancels the broadcaster, waits for the goroutine to exit, and +// releases the SAP socket so the service can be started again. func (s *SAPService) Stop() error { s.mu.Lock() cancel := s.cancel @@ -142,6 +143,7 @@ func (s *SAPService) Stop() error { if done != nil { <-done } + s.router.UnregisterSocket(SAPSocket) return nil } diff --git a/service/ipx/sap_test.go b/service/ipx/sap_test.go index 3413a7f..e8b0bde 100644 --- a/service/ipx/sap_test.go +++ b/service/ipx/sap_test.go @@ -10,6 +10,30 @@ import ( routeripx "github.com/ObsoleteMadness/ClassicStack/router/ipx" ) +// TestRIPSAPRestartReleasesSockets verifies the RIP and SAP services free +// their router sockets on Stop, so a Stop/Start cycle (the UI restart path) +// does not fail with "ipx: socket already registered". +func TestRIPSAPRestartReleasesSockets(t *testing.T) { + r, _ := setupRIPRouter(t) + rip := NewRIPService(r) + sap := NewSAPService(r) + + for cycle := range 3 { + if err := rip.Start(context.Background()); err != nil { + t.Fatalf("cycle %d RIP Start: %v", cycle, err) + } + if err := sap.Start(context.Background()); err != nil { + t.Fatalf("cycle %d SAP Start: %v", cycle, err) + } + if err := rip.Stop(); err != nil { + t.Fatalf("cycle %d RIP Stop: %v", cycle, err) + } + if err := sap.Stop(); err != nil { + t.Fatalf("cycle %d SAP Stop: %v", cycle, err) + } + } +} + func TestSAPRegisterFillsIdentityFromRouter(t *testing.T) { r, _ := setupRIPRouter(t) // reuses helpers from rip_test.go svc := NewSAPService(r) @@ -237,11 +261,11 @@ func TestSAPPeriodicBroadcast(t *testing.T) { } defer svc.Stop() - waitForSend(t, port, 1) // startup + waitForSend(t, port, 1) // startup tickCh <- time.Now() - waitForSend(t, port, 2) // tick 1 + waitForSend(t, port, 2) // tick 1 tickCh <- time.Now() - waitForSend(t, port, 3) // tick 2 + waitForSend(t, port, 3) // tick 2 port.mu.Lock() defer port.mu.Unlock() diff --git a/service/macip/macip.go b/service/macip/macip.go index da0accb..440deef 100644 --- a/service/macip/macip.go +++ b/service/macip/macip.go @@ -14,6 +14,7 @@ package macip import ( "context" "encoding/binary" + "fmt" "net" "sync" "time" @@ -70,6 +71,7 @@ type Service struct { // IP-side link parameters (set at construction). ipLink rawlink.RawLink + ipLinkOpen LinkFactory // optional; reopens ipLink on each Start (UI restart). ipOurMAC net.HardwareAddr ipHostIP net.IP ipDefaultGW net.IP @@ -99,6 +101,19 @@ type inboundPkt struct { p port.Port } +// LinkFactory opens a fresh IP-side rawlink. When set via SetLinkFactory it +// is called on each Start so the service can be stopped and restarted from +// the UI: each Stop frees the libpcap handle and each Start reopens (and +// re-BPF-filters) the interface. Without a factory the pre-built ipLink +// passed to New is reused, which is single-shot once Stop has closed it. +type LinkFactory func() (rawlink.RawLink, error) + +// SetLinkFactory installs an optional factory used to (re)open the IP-side +// rawlink on every Start. The caller's factory is responsible for applying +// the same bridge-frame-mode and BPF filter it would apply to a one-shot +// link. Call before the first Start. +func (s *Service) SetLinkFactory(f LinkFactory) { s.ipLinkOpen = f } + // New returns a MacIP gateway service. // // - gwIP: gateway IP advertised to MacIP clients @@ -145,6 +160,19 @@ func (s *Service) Socket() uint8 { return Socket } func (s *Service) Start(ctx context.Context, r service.Router) error { s.router = r s.ctx, s.ctxCancel = context.WithCancel(ctx) + // Recreate the stop channel each Start so a Stop/Start cycle does not + // close an already-closed channel. + s.stop = make(chan struct{}) + + // Reopen the IP-side link when a factory is configured, so a UI restart + // gets a fresh libpcap handle instead of reusing the freed one. + if s.ipLinkOpen != nil { + link, err := s.ipLinkOpen() + if err != nil { + return fmt.Errorf("macip: reopening IP link: %w", err) + } + s.ipLink = link + } // Resolve zone name if not supplied. if len(s.zoneName) == 0 { @@ -209,6 +237,14 @@ func (s *Service) Stop() error { } if s.link != nil { s.link.close() + s.link = nil + } + // The etherIPLink closed the underlying rawlink; drop our reference so a + // restart with a link factory reopens a fresh handle rather than reusing + // the freed one. Without a factory, Start would fail fast on the closed + // link instead of crashing. + if s.ipLinkOpen != nil { + s.ipLink = nil } s.wg.Wait() s.pool.saveToFile(s.stateFile) diff --git a/service/netbios/over_ipx/transport.go b/service/netbios/over_ipx/transport.go index f1dab18..08f7781 100644 --- a/service/netbios/over_ipx/transport.go +++ b/service/netbios/over_ipx/transport.go @@ -95,8 +95,8 @@ func NewTransport(r ipx.Router, sap SAPRegistrar, name protocol.Name) netbios.Tr claimRetries: DefaultNameClaimRetries, claimInterval: DefaultNameClaimInterval, sleep: time.After, - objection: make(chan struct{}, 1), - stopped: make(chan struct{}), + objection: make(chan struct{}, 1), + stopped: make(chan struct{}), } } @@ -106,12 +106,24 @@ func NewTransport(r ipx.Router, sap SAPRegistrar, name protocol.Name) netbios.Tr // but no SAP advertisement appears. Errors here would prevent the // rest of NetBIOS from starting; we'd rather log and continue. func (t *transport) Start(ctx context.Context) error { - for _, sock := range Sockets { + for i, sock := range Sockets { if err := t.router.RegisterSocket(sock, t); err != nil { + // Roll back the sockets we already claimed so a partial + // failure does not leak registrations and block a retry. + for _, done := range Sockets[:i] { + t.router.UnregisterSocket(done) + } return err } } + // Reset the per-run lifecycle state so the transport can be restarted + // after a Stop: stopOnce/stopped were consumed by the previous Stop. + t.mu.Lock() + t.stopOnce = sync.Once{} + t.stopped = make(chan struct{}) + t.mu.Unlock() + if t.shouldClaimName() { go t.claimAndAdvertise(ctx) } @@ -206,8 +218,10 @@ func (t *transport) broadcastNMPIClaim() error { return t.router.Send(out) } -// Stop unregisters the SAP advertisement (if any) and stops further -// inbound dispatch. +// Stop unregisters the SAP advertisement (if any), releases the IPX +// sockets, and stops further inbound dispatch. Releasing the sockets is +// what lets the transport be started again — otherwise the next Start's +// RegisterSocket fails with "socket already registered". func (t *transport) Stop() error { t.stopOnce.Do(func() { close(t.stopped) @@ -218,6 +232,9 @@ func (t *transport) Stop() error { if cancel != nil { cancel() } + for _, sock := range Sockets { + t.router.UnregisterSocket(sock) + } }) return nil } diff --git a/service/netbios/over_ipx/transport_test.go b/service/netbios/over_ipx/transport_test.go index 5ce1fa8..bf002d1 100644 --- a/service/netbios/over_ipx/transport_test.go +++ b/service/netbios/over_ipx/transport_test.go @@ -110,6 +110,33 @@ func waitForSend(t *testing.T, port *recordingPort, n int) { t.Fatalf("waited for %d sends, only got %d", n, len(port.sent)) } +// TestTransportRestart reproduces the UI stop/start path that failed with +// "ipx: socket already registered": Stop must release the IPX sockets so a +// subsequent Start can re-register them. A zero name skips the name claim so +// the test is deterministic. +func TestTransportRestart(t *testing.T) { + r, _, sap := setupTransport(t) + var zeroName netbiosproto.Name + tr := NewTransport(r, sap, zeroName) + + for cycle := range 3 { + if err := tr.Start(context.Background()); err != nil { + t.Fatalf("cycle %d Start: %v", cycle, err) + } + if err := tr.Stop(); err != nil { + t.Fatalf("cycle %d Stop: %v", cycle, err) + } + } + + // After the final Stop the sockets must be free: a fresh registration + // of every NB-IPX socket should succeed. + for _, sock := range Sockets { + if err := r.RegisterSocket(sock, tr.(*transport)); err != nil { + t.Fatalf("socket %02x%02x still registered after Stop: %v", sock[0], sock[1], err) + } + } +} + func TestUncontestedNameClaimRegistersWithSAP(t *testing.T) { r, port, sap := setupTransport(t) name := netbiosproto.NewName("CLASSICSTACK", netbiosproto.NameTypeFileServer) diff --git a/service/netbios/service.go b/service/netbios/service.go index df4f942..d1488b0 100644 --- a/service/netbios/service.go +++ b/service/netbios/service.go @@ -95,43 +95,80 @@ type NameService interface { Release(name string) error } +// namedTransport pairs a Transport with the operator-facing name the +// supervisor binds it under (e.g. "ipx", "netbeui"), so transports can be +// added and removed at runtime as their underlying protocol is started or +// stopped from the UI. +type namedTransport struct { + name string + t Transport +} + // Service composes a set of transports under a common NetBIOS name. type Service struct { serverName string scopeID string - transports []Transport + transports []namedTransport names map[protocol.Name]struct{} mu sync.Mutex started bool + ctx context.Context // start context, captured in Start for late AddTransport handler CommandHandler } // NewService creates a NetBIOS service whose name layer is reachable // over the given transports. transports may be empty for a name-only -// service that does not accept incoming sessions. +// service that does not accept incoming sessions. Transports passed here +// are bound under positional names ("t0", "t1", …); callers that need +// removable, named transports should pass nil and use AddTransport. func NewService(serverName, scopeID string, transports []Transport) *Service { defaultNames := map[protocol.Name]struct{}{} if serverName != "" { defaultNames[protocol.NewName(serverName, protocol.NameTypeFileServer)] = struct{}{} defaultNames[protocol.NewName(serverName, protocol.NameTypeWorkstation)] = struct{}{} } + named := make([]namedTransport, 0, len(transports)) + for i, t := range transports { + named = append(named, namedTransport{name: fmt.Sprintf("t%d", i), t: t}) + } return &Service{ serverName: serverName, scopeID: scopeID, - transports: transports, + transports: named, names: defaultNames, } } +// transportList returns a snapshot of the current Transport values, dropping +// the names. Callers must not hold s.mu (it takes the lock). +func (s *Service) transportList() []Transport { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]Transport, 0, len(s.transports)) + for _, nt := range s.transports { + out = append(out, nt.t) + } + return out +} + +// snapshotNames returns the registered NetBIOS names. Callers must hold s.mu. +func (s *Service) snapshotNamesLocked() []protocol.Name { + names := make([]protocol.Name, 0, len(s.names)) + for n := range s.names { + names = append(names, n) + } + return names +} + // SetCommandHandler installs an inbound-command handler (typically an // SMB server). Idempotent; later calls replace earlier ones. Each // transport receives the handler so it can deliver decoded packets. func (s *Service) SetCommandHandler(h CommandHandler) { s.mu.Lock() s.handler = h - for _, t := range s.transports { - t.SetCommandHandler(h) + for _, nt := range s.transports { + nt.t.SetCommandHandler(h) } s.mu.Unlock() } @@ -145,11 +182,12 @@ func (s *Service) Start(ctx context.Context) error { return nil } s.started = true - transports := append([]Transport(nil), s.transports...) - names := make([]protocol.Name, 0, len(s.names)) - for n := range s.names { - names = append(names, n) + s.ctx = ctx + transports := make([]Transport, 0, len(s.transports)) + for _, nt := range s.transports { + transports = append(transports, nt.t) } + names := s.snapshotNamesLocked() s.mu.Unlock() for i, t := range transports { if err := t.Start(ctx); err != nil { @@ -186,7 +224,10 @@ func (s *Service) Stop() error { return nil } s.started = false - transports := append([]Transport(nil), s.transports...) + transports := make([]Transport, 0, len(s.transports)) + for _, nt := range s.transports { + transports = append(transports, nt.t) + } s.mu.Unlock() for _, t := range transports { _ = t.Stop() @@ -194,13 +235,97 @@ func (s *Service) Stop() error { return nil } +// AddTransport binds t under name. If the service is already started, t is +// wired with the current command handler, started, and given the registered +// names — so a transport whose underlying protocol comes up after NetBIOS +// (e.g. NetBEUI started from the UI) joins the live service. Re-adding an +// existing name replaces the prior transport (the old one is left as-is; +// callers RemoveTransport first if they need it stopped). +func (s *Service) AddTransport(name string, t Transport) error { + if t == nil { + return fmt.Errorf("netbios: nil transport for %q", name) + } + s.mu.Lock() + // Replace any existing binding with the same name, stopping the old + // transport so it does not leak its goroutine/socket registrations. + var replaced Transport + for i, nt := range s.transports { + if nt.name == name { + replaced = nt.t + s.transports[i].t = t + goto bind + } + } + s.transports = append(s.transports, namedTransport{name: name, t: t}) +bind: + handler := s.handler + started := s.started + ctx := s.ctx + names := s.snapshotNamesLocked() + s.mu.Unlock() + + if replaced != nil && replaced != t { + _ = replaced.Stop() + } + + if handler != nil { + t.SetCommandHandler(handler) + } + if !started { + return nil + } + if err := t.Start(ctx); err != nil { + return fmt.Errorf("netbios: start transport %q: %w", name, err) + } + for _, n := range names { + if err := t.SendName(n); err != nil && !errors.Is(err, ErrNotImplemented) { + return fmt.Errorf("netbios: register name %q on %q: %w", n.String(), name, err) + } + } + return nil +} + +// RemoveTransport stops and unbinds the transport registered under name. +// It is idempotent: removing an unknown name is a no-op. The rest of the +// service (other transports, the name layer) keeps running, so stopping one +// underlying protocol detaches only its binding. +func (s *Service) RemoveTransport(name string) error { + s.mu.Lock() + var found Transport + kept := s.transports[:0] + for _, nt := range s.transports { + if nt.name == name && found == nil { + found = nt.t + continue + } + kept = append(kept, nt) + } + s.transports = kept + s.mu.Unlock() + + if found == nil { + return nil + } + return found.Stop() +} + +// Transports returns the names of the currently bound transports, in bind +// order, for status reporting. +func (s *Service) Transports() []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, 0, len(s.transports)) + for _, nt := range s.transports { + out = append(out, nt.name) + } + return out +} + // SendDatagram broadcasts a NetBIOS datagram through every active // transport. If one or more transports fail, the first error is // returned after attempting all sends. func (s *Service) SendDatagram(d *protocol.Datagram) error { - s.mu.Lock() - transports := append([]Transport(nil), s.transports...) - s.mu.Unlock() + transports := s.transportList() var firstErr error for _, t := range transports { @@ -219,9 +344,7 @@ func (s *Service) SendDatagram(d *protocol.Datagram) error { // delivery. ErrNotImplemented is returned when no configured // transport exposes directed routing. func (s *Service) SendDirectedDatagram(d *protocol.Datagram, remote DatagramEndpoint) error { - s.mu.Lock() - transports := append([]Transport(nil), s.transports...) - s.mu.Unlock() + transports := s.transportList() var firstErr error attempted := false @@ -259,7 +382,10 @@ func (s *Service) Register(name string) error { } s.names[n] = struct{}{} started := s.started - transports := append([]Transport(nil), s.transports...) + transports := make([]Transport, 0, len(s.transports)) + for _, nt := range s.transports { + transports = append(transports, nt.t) + } s.mu.Unlock() if !started { diff --git a/service/netbios/service_test.go b/service/netbios/service_test.go index 07279a7..57297e4 100644 --- a/service/netbios/service_test.go +++ b/service/netbios/service_test.go @@ -24,17 +24,23 @@ func (f *fakeTransport) Start(_ context.Context) error { f.started.Store(true) return nil } -func (f *fakeTransport) Stop() error { f.stopped.Store(true); return nil } +func (f *fakeTransport) Stop() error { f.stopped.Store(true); return nil } func (f *fakeTransport) SendName(n protocol.Name) error { f.sendNameCalls = append(f.sendNameCalls, n) return f.sendNameErr } -func (f *fakeTransport) SendDatagram(_ *protocol.Datagram) error { return nil } +func (f *fakeTransport) SendDatagram(_ *protocol.Datagram) error { return nil } func (f *fakeTransport) SendSession(_ *protocol.SessionPacket) error { return nil } func (f *fakeTransport) SetCommandHandler(h CommandHandler) { f.handler = h } +// recordingHandler is a no-op CommandHandler used to assert handler wiring. +type recordingHandler struct{} + +func (*recordingHandler) HandleSession(_ *protocol.SessionPacket) error { return nil } +func (*recordingHandler) HandleDatagram(_ *protocol.Datagram) error { return nil } + func TestServiceStartStopAcrossTransports(t *testing.T) { a, b := &fakeTransport{}, &fakeTransport{} svc := NewService("CLASSICSTACK", "", []Transport{a, b}) @@ -70,6 +76,90 @@ func TestServiceRollsBackOnFailedTransport(t *testing.T) { } } +// TestRemoveTransportKeepsServiceRunning is the core of the "stopping NetBEUI +// should just remove the NetBEUI binding from NetBIOS" requirement: removing +// one transport stops only that transport and leaves the rest serving. +func TestRemoveTransportKeepsServiceRunning(t *testing.T) { + ipx, nbf := &fakeTransport{}, &fakeTransport{} + svc := NewService("CLASSICSTACK", "", nil) + if err := svc.AddTransport("ipx", ipx); err != nil { + t.Fatalf("AddTransport ipx: %v", err) + } + if err := svc.AddTransport("netbeui", nbf); err != nil { + t.Fatalf("AddTransport netbeui: %v", err) + } + if err := svc.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + if !ipx.started.Load() || !nbf.started.Load() { + t.Fatal("both transports should be started") + } + + if err := svc.RemoveTransport("netbeui"); err != nil { + t.Fatalf("RemoveTransport: %v", err) + } + if !nbf.stopped.Load() { + t.Fatal("removed transport should be stopped") + } + if ipx.stopped.Load() { + t.Fatal("remaining transport must keep running") + } + if got := svc.Transports(); len(got) != 1 || got[0] != "ipx" { + t.Fatalf("Transports() = %v, want [ipx]", got) + } + + // Removing an unknown name is a no-op. + if err := svc.RemoveTransport("does-not-exist"); err != nil { + t.Fatalf("RemoveTransport unknown: %v", err) + } +} + +// TestAddTransportWhileStartedStartsIt verifies a transport added after the +// service is running is wired with the handler, started, and given the names — +// the path used when NetBEUI comes up after NetBIOS from the UI. +func TestAddTransportWhileStartedStartsIt(t *testing.T) { + svc := NewService("CLASSICSTACK", "", nil) + handler := &recordingHandler{} + svc.SetCommandHandler(handler) + if err := svc.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + + late := &fakeTransport{} + if err := svc.AddTransport("netbeui", late); err != nil { + t.Fatalf("AddTransport: %v", err) + } + if !late.started.Load() { + t.Fatal("late-added transport should be started") + } + if late.handler != handler { + t.Fatal("late-added transport should receive the command handler") + } + if len(late.sendNameCalls) == 0 { + t.Fatal("late-added transport should be given the registered names") + } +} + +// TestAddTransportReplacesAndStopsOld verifies re-adding the same name stops +// the previous transport so it does not leak. +func TestAddTransportReplacesAndStopsOld(t *testing.T) { + svc := NewService("X", "", nil) + old := &fakeTransport{} + if err := svc.AddTransport("ipx", old); err != nil { + t.Fatalf("AddTransport old: %v", err) + } + newer := &fakeTransport{} + if err := svc.AddTransport("ipx", newer); err != nil { + t.Fatalf("AddTransport newer: %v", err) + } + if !old.stopped.Load() { + t.Fatal("replaced transport should be stopped") + } + if got := svc.Transports(); len(got) != 1 { + t.Fatalf("Transports() = %v, want one entry", got) + } +} + func TestServiceRegisterDuringRuntimeSendsName(t *testing.T) { f := &fakeTransport{} svc := NewService("CLASSICSTACK", "", []Transport{f}) diff --git a/service/smb/over_ipx_direct/transport.go b/service/smb/over_ipx_direct/transport.go index 0e43d66..3110e06 100644 --- a/service/smb/over_ipx_direct/transport.go +++ b/service/smb/over_ipx_direct/transport.go @@ -39,9 +39,9 @@ type Transport struct { router ipx.Router handler sessionHandler - cidMu sync.Mutex - cids map[[10]byte]uint16 // remote endpoint (network+node) → CID - nextCID uint16 + cidMu sync.Mutex + cids map[[10]byte]uint16 // remote endpoint (network+node) → CID + nextCID uint16 } func New(router ipx.Router, handler sessionHandler) *Transport { @@ -85,7 +85,15 @@ func (t *Transport) Start(_ context.Context) error { return t.router.RegisterSocket(directSMBSocket, t) } -func (t *Transport) Stop() error { return nil } +// Stop releases the direct-SMB IPX socket so the transport can be +// started again after a Stop. +func (t *Transport) Stop() error { + if t == nil || t.router == nil { + return nil + } + t.router.UnregisterSocket(directSMBSocket) + return nil +} func (t *Transport) HandleDatagram(d *ipxproto.Datagram) { if t == nil || d == nil || t.handler == nil { diff --git a/service/webui/api.go b/service/webui/api.go index 98d0411..3b98824 100644 --- a/service/webui/api.go +++ b/service/webui/api.go @@ -16,6 +16,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/status", s.handleStatus) s.mux.HandleFunc("/api/interfaces", s.handleInterfaces) + s.mux.HandleFunc("/api/fs-types", s.handleFSTypes) s.mux.HandleFunc("/api/serial-ports", s.handleSerialPorts) s.mux.HandleFunc("/api/config", s.handleConfig) s.mux.HandleFunc("/api/config/apply", s.handleApply) @@ -50,6 +51,14 @@ func (s *Server) handleInterfaces(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, names) } +func (s *Server) handleFSTypes(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeJSON(w, http.StatusOK, []string{}) + return + } + writeJSON(w, http.StatusOK, s.opts.Plane.ListFSTypes()) +} + func (s *Server) handleSerialPorts(w http.ResponseWriter, r *http.Request) { if s.opts.Plane == nil { writeJSON(w, http.StatusOK, []any{}) diff --git a/service/webui/assets/app.css b/service/webui/assets/app.css index 4b1c51c..b384e20 100644 --- a/service/webui/assets/app.css +++ b/service/webui/assets/app.css @@ -73,6 +73,22 @@ main { padding: 1rem; } .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; background: var(--off); } .dot.running { background: var(--ok); } +/* In-flight start/stop/restart indicator shown in place of the status dot. */ +.spinner { + width: 11px; height: 11px; + display: inline-block; + vertical-align: middle; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +button:disabled { opacity: 0.5; cursor: not-allowed; } + +.card h3 .dot, .card h3 .spinner { margin-right: 0.4rem; } + .kv { font-size: 0.85rem; color: var(--muted); margin: 0.15rem 0; } .kv b { color: #1c1c22; font-weight: 600; } @@ -98,6 +114,22 @@ main { padding: 1rem; } min-width: 200px; } +/* Nested volume/share editor inside its parent service panel. */ +.config-panel.nested { + margin: 0.6rem 0 0.2rem; + background: transparent; + border-style: dashed; +} +.config-panel.nested legend { color: var(--muted); font-size: 0.9rem; } + +/* Per-service Bridge/Custom interface chooser. */ +.iface-chooser { margin: 0.5rem 0 0.2rem; padding-top: 0.4rem; border-top: 1px dashed var(--border); } +.iface-heading { font-weight: 600; color: var(--muted); margin-bottom: 0.3rem; } +.iface-radio { display: flex; gap: 1rem; margin-bottom: 0.4rem; } +.iface-radio label.radio { display: inline-flex; align-items: center; gap: 0.3rem; color: inherit; width: auto; cursor: pointer; } +.iface-subform { margin-left: 1.2rem; padding-left: 0.6rem; border-left: 2px solid var(--border); } +.kv.muted { color: var(--muted); font-style: italic; } + .share-table { width: 100%; border-collapse: collapse; margin: 0.4rem 0 0.6rem; } .share-table th { text-align: left; @@ -107,7 +139,7 @@ main { padding: 1rem; } border-bottom: 1px solid var(--border); } .share-table td { padding: 0.2rem 0.4rem; } -.share-table input[type="text"] { +.share-table input[type="text"], .share-table select { width: 100%; padding: 0.25rem 0.4rem; border: 1px solid var(--border); diff --git a/service/webui/assets/app.js b/service/webui/assets/app.js index 658f664..f10225a 100644 --- a/service/webui/assets/app.js +++ b/service/webui/assets/app.js @@ -10,6 +10,11 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel)); let currentConfig = null; // last-loaded config model (edited in place) let latestRates = {}; // metric name -> per-second rate from SSE +// pendingServices holds the names of services with an in-flight start/stop/ +// restart action. While pending, the card shows a spinner and its action +// buttons are disabled so the operator can't double-fire a transition. +const pendingServices = new Set(); +let lastUnits = []; // last status payload, for immediate re-render on pending change // ---- tab switching ---- $$(".tab").forEach((btn) => { @@ -35,6 +40,7 @@ async function loadStatus() { } function renderStatus(units) { + lastUnits = units; // cache for immediate re-render (e.g. pending-state change) const grid = $("#service-grid"); grid.innerHTML = ""; units.forEach((u) => { @@ -52,17 +58,28 @@ function renderStatus(units) { // Only standalone hooks (IPX/NetBEUI/NetBIOS/SMB/WebUI) are individually // start/stoppable; ports and the router-set share the stack lifecycle. const controllable = u.kind === "hook"; + const pending = pendingServices.has(u.name); + const dis = pending ? " disabled" : ""; let controls = ""; if (controllable) { controls = u.running - ? ` - ` - : ``; + ? ` + ` + : ``; } + // While an action is in flight show a spinner instead of the status dot, + // and a "Working…" state line, so the transition is visible. + const indicator = pending + ? `` + : ``; + const stateLine = pending + ? "Working…" + : `${u.enabled ? "Enabled" : "Disabled"} · ${u.running ? "Running" : "Stopped"}`; + card.innerHTML = ` -

${esc(u.name)}

-
${u.enabled ? "Enabled" : "Disabled"} · ${u.running ? "Running" : "Stopped"}
+

${indicator}${esc(u.name)}

+
${stateLine}
${detail}
${controls}
@@ -79,11 +96,19 @@ function kv(k, v) { } async function serviceAction(name, action) { + if (pendingServices.has(name)) return; // already transitioning + pendingServices.add(name); + renderStatus(lastUnits); // immediately reflect the spinner/disabled state try { await postJSON(`/api/services/${encodeURIComponent(name)}/${action}`, null); - setTimeout(loadStatus, 300); } catch (e) { alert(`${action} failed: ` + e.message); + } finally { + // Clear pending and refresh once the action has settled. The brief delay + // lets the supervisor finish the (possibly multi-step) transition before + // we re-read status. + pendingServices.delete(name); + setTimeout(loadStatus, 300); } } @@ -197,6 +222,11 @@ async function loadConfig() { } } +// Dropdown option sets shared by the config panels. +const IFACE_MODES = ["pcap", "tap", "tun"]; // link backend +const BRIDGE_MODES = ["auto", "ethernet", "wifi"]; // pcap bridge mode +const IPX_FRAMINGS = ["ethernet_ii", "raw_802_3", "llc", "snap"]; + // Panels mirror the classic control-panel layout. Each field binds to a // dotted path in the config model. const CONFIG_PANELS = [ @@ -217,11 +247,20 @@ const CONFIG_PANELS = [ { label: "Seed Network", path: "TashTalk.seed_network", type: "number" }, ], }, + { + // The shared virtual interface protocols inherit unless they go Custom. + title: "Bridge (shared interface)", + fields: [ + { label: "Mode", path: "Bridge.mode", type: "select", options: IFACE_MODES }, + { label: "Device", path: "Bridge.device", type: "iface" }, + { label: "HW Address", path: "Bridge.hw_address", type: "text" }, + { label: "Bridge Mode", path: "Bridge.bridge_mode", type: "select", options: BRIDGE_MODES }, + ], + }, { title: "EtherTalk", + interfaceFor: "EtherTalk", fields: [ - { label: "Interface", path: "Bridge.device", type: "iface" }, - { label: "Bridge Mode", path: "Bridge.mode", type: "text" }, { label: "Zone Name", path: "EtherTalk.seed_zone", type: "text" }, { label: "Seed Net Min", path: "EtherTalk.seed_network_min", type: "number" }, { label: "Seed Net Max", path: "EtherTalk.seed_network_max", type: "number" }, @@ -229,22 +268,42 @@ const CONFIG_PANELS = [ }, { title: "NetBEUI (NBF)", - fields: [ - { label: "Enabled", path: "NetBEUI.enabled", type: "bool" }, - { label: "Interface", path: "NetBEUI.interface", type: "iface" }, - ], + interfaceFor: "NetBEUI", + fields: [{ label: "Enabled", path: "NetBEUI.enabled", type: "bool" }], }, { title: "IPX", + interfaceFor: "IPX", fields: [ { label: "Enabled", path: "IPX.enabled", type: "bool" }, - { label: "Interface", path: "IPX.interface", type: "iface" }, - { label: "Framing", path: "IPX.framing", type: "text" }, + { label: "Framing", path: "IPX.framing", type: "select", options: IPX_FRAMINGS }, { label: "Network", path: "IPX.internal_network", type: "text" }, ], }, + { + title: "MacIP Gateway", + interfaceFor: "MacIP", + fields: [ + { label: "Enabled", path: "MacIP.enabled", type: "bool" }, + { label: "Gateway Mode", path: "MacIP.mode", type: "select", options: ["pcap", "nat"] }, + { label: "Zone", path: "MacIP.zone", type: "text" }, + { label: "NAT Subnet", path: "MacIP.nat_subnet", type: "text" }, + { label: "IP Gateway", path: "MacIP.ip_gateway", type: "text" }, + { label: "DHCP Relay", path: "MacIP.dhcp_relay", type: "bool" }, + ], + }, { title: "AFP File Server", + editor: { + title: "AFP Volumes", + section: "AFP", + columns: [ + { key: "name", label: "Name", type: "text" }, + { key: "path", label: "Path", type: "text" }, + { key: "fs_type", label: "FS Type", type: "select", options: "fsTypes", default: "local_fs" }, + { key: "read_only", label: "Read-only", type: "bool" }, + ], + }, fields: [ { label: "Enabled", path: "AFP.enabled", type: "bool" }, { label: "Server Name", path: "AFP.name", type: "text" }, @@ -254,6 +313,16 @@ const CONFIG_PANELS = [ }, { title: "SMB Server", + editor: { + title: "SMB Shares", + section: "SMB", + columns: [ + { key: "name", label: "Name", type: "text" }, + { key: "path", label: "Path", type: "text" }, + { key: "fs_type", label: "FS Type", type: "select", options: "fsTypes", default: "local_fs" }, + { key: "read_only", label: "Read-only", type: "bool" }, + ], + }, fields: [ { label: "Enabled", path: "SMB.enabled", type: "bool" }, { label: "Server Name", path: "SMB.server_name", type: "text" }, @@ -284,14 +353,26 @@ const CONFIG_PANELS = [ }, ]; -let interfaceList = []; +let interfaceList = []; // [{name, description, addresses}] let serialList = []; +let fsTypeList = []; // registered AFP fs_type names + +// ifaceLabel builds a friendly dropdown label for an interface: the pcap +// Description (or the device name on the rare host without one) plus any IPs. +// On Windows the device name is a GUID, so the description is what's legible. +function ifaceLabel(i) { + let label = i.description || i.name; + if (i.addresses && i.addresses.length) label += " (" + i.addresses.join(", ") + ")"; + return label; +} async function renderConfig(cfg) { - [interfaceList, serialList] = await Promise.all([ + [interfaceList, serialList, fsTypeList] = await Promise.all([ fetchJSON("/api/interfaces").catch(() => []), fetchJSON("/api/serial-ports").catch(() => []), + fetchJSON("/api/fs-types").catch(() => ["local_fs"]), ]); + if (!fsTypeList || !fsTypeList.length) fsTypeList = ["local_fs"]; const root = $("#config-panels"); root.innerHTML = ""; @@ -302,35 +383,126 @@ async function renderConfig(cfg) { legend.textContent = panel.title; fs.appendChild(legend); panel.fields.forEach((f) => fs.appendChild(renderField(cfg, f))); + // A per-service Bridge/Custom interface chooser, when the panel declares one. + if (panel.interfaceFor) fs.appendChild(renderInterfaceChooser(cfg, panel.interfaceFor)); + // A grouped volume/share editor, when the panel declares one. + if (panel.editor) fs.appendChild(renderShareEditor(cfg, panel.editor.title, panel.editor.section, panel.editor.columns)); root.appendChild(fs); }); +} + +// renderInterfaceChooser renders the per-service interface selector: a +// "Bridge" / "Custom" radio. Bridge means the service inherits the shared +// [Bridge] interface (no
.Custom). Custom reveals a sub-form +// (Mode, Device, HW Address, and — for pcap — Bridge Mode) bound to +// cfg[section].Custom. EtherTalk is the bridge consumer itself, so it only +// shows an informational note. +function renderInterfaceChooser(cfg, section) { + const wrap = document.createElement("div"); + wrap.className = "iface-chooser"; + const heading = document.createElement("div"); + heading.className = "iface-heading"; + heading.textContent = "Interface"; + wrap.appendChild(heading); + + if (section === "EtherTalk") { + const note = document.createElement("div"); + note.className = "kv muted"; + note.textContent = "Uses the shared Bridge interface (configure it in the Bridge panel)."; + wrap.appendChild(note); + return wrap; + } + + if (!cfg[section]) cfg[section] = {}; + const isCustom = () => !!cfg[section].Custom; + + const radioRow = document.createElement("div"); + radioRow.className = "iface-radio"; + const sub = document.createElement("div"); + sub.className = "iface-subform"; + + function rebuildSub() { + sub.innerHTML = ""; + if (!isCustom()) { + const bridgeDev = (cfg.Bridge && cfg.Bridge.device) || "(none)"; + const note = document.createElement("div"); + note.className = "kv muted"; + note.textContent = "Inherits the shared Bridge (" + bridgeDev + ")."; + sub.appendChild(note); + return; + } + const c = cfg[section].Custom; + const subFields = [ + { label: "Mode", path: "mode", type: "select", options: IFACE_MODES }, + { label: "Device", path: "device", type: "iface" }, + { label: "HW Address", path: "hw_address", type: "text" }, + ]; + if ((c.mode || "pcap") === "pcap") { + subFields.push({ label: "Bridge Mode", path: "bridge_mode", type: "select", options: BRIDGE_MODES }); + } + subFields.forEach((f) => { + const row = document.createElement("div"); + row.className = "field"; + const label = document.createElement("label"); + label.textContent = f.label; + row.appendChild(label); + let input; + if (f.type === "iface") { + input = buildInterfaceSelect(c[f.path] || "", (v) => { c[f.path] = v; setDirty(true); }); + } else if (f.type === "select") { + input = buildSelect(f.options, c[f.path] || "", (v) => { + c[f.path] = v; + setDirty(true); + if (f.path === "mode") rebuildSub(); // toggling pcap shows/hides bridge mode + }); + } else { + input = document.createElement("input"); + input.type = "text"; + input.value = c[f.path] == null ? "" : c[f.path]; + input.addEventListener("input", () => { c[f.path] = input.value; setDirty(true); }); + } + row.appendChild(input); + sub.appendChild(row); + }); + } + + [["bridge", "Bridge"], ["custom", "Custom"]].forEach(([val, lbl]) => { + const id = "iface-" + section + "-" + val; + const label = document.createElement("label"); + label.className = "radio"; + const radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "iface-" + section; + radio.id = id; + radio.checked = val === "custom" ? isCustom() : !isCustom(); + radio.addEventListener("change", () => { + if (!radio.checked) return; + if (val === "custom") { + if (!cfg[section].Custom) cfg[section].Custom = { mode: "pcap" }; + } else { + delete cfg[section].Custom; + } + setDirty(true); + rebuildSub(); + }); + label.appendChild(radio); + label.appendChild(document.createTextNode(" " + lbl)); + radioRow.appendChild(label); + }); - // Volume / share table editors. These mutate the keyed maps in the - // config model (AFP.Volumes / SMB.Volumes); the supervisor rebuilds the - // service from the model on Apply, so add/update/remove take effect. - root.appendChild( - renderShareEditor(cfg, "AFP Volumes", "AFP", [ - { key: "name", label: "Name", type: "text" }, - { key: "path", label: "Path", type: "text" }, - { key: "fs_type", label: "FS Type", type: "text", default: "local_fs" }, - { key: "read_only", label: "Read-only", type: "bool" }, - ]) - ); - root.appendChild( - renderShareEditor(cfg, "SMB Shares", "SMB", [ - { key: "name", label: "Name", type: "text" }, - { key: "path", label: "Path", type: "text" }, - { key: "fs_type", label: "FS Type", type: "text", default: "local_fs" }, - { key: "read_only", label: "Read-only", type: "bool" }, - ]) - ); + wrap.appendChild(radioRow); + wrap.appendChild(sub); + rebuildSub(); + return wrap; } // renderShareEditor builds a table editor over cfg[section].Volumes (a -// name-keyed map of share/volume objects) with add and remove controls. +// name-keyed map of share/volume objects) with add and remove controls. It +// renders as a nested group so it can sit inside its parent service panel +// (AFP volumes under AFP, SMB shares under SMB). function renderShareEditor(cfg, title, section, columns) { const fs = document.createElement("fieldset"); - fs.className = "config-panel"; + fs.className = "config-panel nested"; const legend = document.createElement("legend"); legend.textContent = title; fs.appendChild(legend); @@ -363,6 +535,12 @@ function renderShareEditor(cfg, title, section, columns) { entry[c.key] = input.checked; setDirty(true); }); + } else if (c.type === "select") { + const opts = c.options === "fsTypes" ? fsTypeList : c.options || []; + input = buildSelect(opts, entry[c.key] || c.default || "", (v) => { + entry[c.key] = v; + setDirty(true); + }); } else { input = document.createElement("input"); input.type = "text"; @@ -425,6 +603,59 @@ function renderShareEditor(cfg, title, section, columns) { return fs; } +// buildSelect creates a with friendly labels. The +// stored value is the device name; a "(none)" blank is offered, and a stored +// device not present in the enumerated list (e.g. saved on another host) is +// preserved as its own option. +function buildInterfaceSelect(current, onChange) { + const sel = document.createElement("select"); + const blank = document.createElement("option"); + blank.value = ""; + blank.textContent = "(none)"; + sel.appendChild(blank); + let matched = !current; + interfaceList.forEach((i) => { + const o = document.createElement("option"); + o.value = i.name; + o.textContent = ifaceLabel(i); + if (i.name === current) { + o.selected = true; + matched = true; + } + sel.appendChild(o); + }); + if (!matched) { + const o = document.createElement("option"); + o.value = current; + o.textContent = current + " (saved)"; + o.selected = true; + sel.appendChild(o); + } + sel.addEventListener("change", () => onChange(sel.value)); + return sel; +} + function renderField(cfg, f) { const row = document.createElement("div"); row.className = "field"; @@ -442,24 +673,33 @@ function renderField(cfg, f) { setPath(cfg, f.path, input.checked); setDirty(true); }); - } else if (f.type === "iface" || f.type === "serial") { + } else if (f.type === "iface") { + input = buildInterfaceSelect(val, (v) => { + setPath(cfg, f.path, v); + setDirty(true); + }); + } else if (f.type === "serial") { input = document.createElement("select"); - const options = f.type === "iface" ? interfaceList : serialList.map((s) => s.name); const blank = document.createElement("option"); blank.value = ""; blank.textContent = "(none)"; input.appendChild(blank); - options.forEach((opt) => { + serialList.forEach((s) => { const o = document.createElement("option"); - o.value = opt; - o.textContent = opt; - if (opt === val) o.selected = true; + o.value = s.name; + o.textContent = s.description || s.name; + if (s.name === val) o.selected = true; input.appendChild(o); }); input.addEventListener("change", () => { setPath(cfg, f.path, input.value); setDirty(true); }); + } else if (f.type === "select") { + input = buildSelect(f.options || [], val, (v) => { + setPath(cfg, f.path, v); + setDirty(true); + }); } else { input = document.createElement("input"); input.type = f.type === "number" ? "number" : "text"; diff --git a/service/webui/plane.go b/service/webui/plane.go index 363307e..4a480bc 100644 --- a/service/webui/plane.go +++ b/service/webui/plane.go @@ -24,7 +24,8 @@ type ControlPlane interface { StartService(ctx context.Context, name string) error StopService(name string) error RestartService(ctx context.Context, name string) error - ListInterfaces() ([]string, error) + ListInterfaces() ([]control.InterfaceInfo, error) + ListFSTypes() []string ListSerialPorts() ([]serialport.Info, error) Subscribe() (<-chan control.Frame, func()) LogHistory() []logbuf.Entry From 77b29c0706d56aaa3f4e1fc4245b0fe2b904998a Mon Sep 17 00:00:00 2001 From: pgodwin Date: Fri, 5 Jun 2026 20:02:34 +1000 Subject: [PATCH 10/23] webui: add MacIP lease diagnostics and live dashboard state Surface the MacIP gateway's runtime state in the management UI: Diagnostics: new read-only "MacIP Leases" probe lists current static and DHCP-relayed leases (IP, AppleTalk net.node, source, last-seen) via /api/diag/macip-leases. Backed by (*ipPool).snapshot() -> Service.Leases() -> MacIPHook.Leases() -> routerDiagnostics.MacIPLeases, returning ErrDiagUnavailable when MacIP is not built/enabled. Dashboard: the MacIP status unit now reports gateway mode (nat/bridge), DHCP-relay, zone, and live counts (active leases, pinned ASP sessions) via Service.GatewayStats() -> MacIPHook.State() -> control.MacIPState. A 5s status-refresh ticker (runStatusRefresh) re-publishes the live counts while the stack runs; started in Start when MacIP is enabled, stopped/nilled in Stop. Types stay tag-clean: macip shapes live behind //go:build macip while the hook/control layer uses neutral control.LeaseInfo/MacIPState, so untagged diagnostics_impl.go/supervisor.go never import service/macip. Tests: TestIPPoolStatsAndSnapshot covers stats()/snapshot() over mixed static+dhcp leases and a pinned session; TestDiagnosticsFallback now asserts MacIPLeases returns ErrDiagUnavailable. Co-Authored-By: Claude Opus 4.8 --- internal/app/diagnostics_impl.go | 9 +++ internal/app/macip_enabled.go | 27 +++++++++ internal/app/macip_hook.go | 5 ++ internal/app/supervisor.go | 76 +++++++++++++++++++++++++- internal/app/supervisor_lifecycle.go | 29 ++++++++++ pkg/control/control_test.go | 3 + pkg/control/diagnostics.go | 23 ++++++++ pkg/control/diagnostics_unavailable.go | 4 ++ service/macip/macip.go | 48 ++++++++++++++++ service/macip/pool_test.go | 31 +++++++++++ service/macip/state.go | 32 +++++++++++ service/webui/assets/index.html | 1 + service/webui/diagnostics.go | 14 +++++ 13 files changed, 301 insertions(+), 1 deletion(-) diff --git a/internal/app/diagnostics_impl.go b/internal/app/diagnostics_impl.go index 24fef42..6c9a5f4 100644 --- a/internal/app/diagnostics_impl.go +++ b/internal/app/diagnostics_impl.go @@ -80,3 +80,12 @@ func (d *routerDiagnostics) AEPEcho(context.Context, uint16, uint8) (control.Ech func (d *routerDiagnostics) SMBBrowse(context.Context) ([]control.ServerInfo, error) { return nil, control.ErrDiagUnavailable } + +// MacIPLeases returns the MacIP gateway's current IP leases, or unavailable +// when MacIP is not built in / not enabled. +func (d *routerDiagnostics) MacIPLeases(context.Context) ([]control.LeaseInfo, error) { + if d.sup == nil || d.sup.macIP == nil { + return nil, control.ErrDiagUnavailable + } + return d.sup.macIP.Leases(), nil +} diff --git a/internal/app/macip_enabled.go b/internal/app/macip_enabled.go index 5d97e8c..3773ca8 100644 --- a/internal/app/macip_enabled.go +++ b/internal/app/macip_enabled.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/pkg/control" "github.com/ObsoleteMadness/ClassicStack/pkg/hwaddr" "github.com/ObsoleteMadness/ClassicStack/port/rawlink" "github.com/ObsoleteMadness/ClassicStack/service" @@ -25,6 +26,32 @@ func (h *macipHook) PinLeaseToSession(net uint16, node, sess uint8) { func (h *macipHook) UnpinLeaseFromSession(sess uint8) { h.svc.UnpinLeaseFromSession(sess) } func (h *macipHook) MarkSessionActivity(sess uint8) { h.svc.MarkSessionActivity(sess) } +func (h *macipHook) Leases() []control.LeaseInfo { + src := h.svc.Leases() + out := make([]control.LeaseInfo, 0, len(src)) + for _, l := range src { + out = append(out, control.LeaseInfo{ + IP: l.IP, + ATNetwork: l.ATNetwork, + ATNode: l.ATNode, + Source: l.Source, + LastSeenUnix: l.LastSeenUnix, + }) + } + return out +} + +func (h *macipHook) State() control.MacIPState { + s := h.svc.GatewayStats() + return control.MacIPState{ + Mode: s.Mode, + DHCPRelay: s.DHCPRelay, + Zone: s.Zone, + ActiveLeases: s.ActiveLeases, + Sessions: s.Sessions, + } +} + func wireMacIP(cfg MacIPConfig) (MacIPHook, error) { if !cfg.Enabled { return nil, nil diff --git a/internal/app/macip_hook.go b/internal/app/macip_hook.go index d20b06d..741bf5c 100644 --- a/internal/app/macip_hook.go +++ b/internal/app/macip_hook.go @@ -1,6 +1,7 @@ package app import ( + "github.com/ObsoleteMadness/ClassicStack/pkg/control" "github.com/ObsoleteMadness/ClassicStack/service" "github.com/ObsoleteMadness/ClassicStack/service/zip" ) @@ -13,6 +14,10 @@ type MacIPHook interface { PinLeaseToSession(net uint16, node, sessID uint8) UnpinLeaseFromSession(sessID uint8) MarkSessionActivity(sessID uint8) + // Leases returns the gateway's current IP leases for the diagnostics view. + Leases() []control.LeaseInfo + // State returns a point-in-time MacIP summary for the dashboard. + State() control.MacIPState } // macIPAFPHooks adapts a MacIPHook to the AFPSessionHooks interface diff --git a/internal/app/supervisor.go b/internal/app/supervisor.go index 6056ae7..8932c96 100644 --- a/internal/app/supervisor.go +++ b/internal/app/supervisor.go @@ -69,6 +69,10 @@ type Supervisor struct { macIP MacIPHook ipxGW IPXGWHook + // statusTickerStop stops the periodic dashboard-status refresher (live + // MacIP lease/session counts). Closed and nilled on Stop. + statusTickerStop chan struct{} + // netbios is the NetBIOS hook so the lifecycle can attach/detach // transports as their underlying protocol starts/stops. nil when NetBIOS // is disabled. @@ -277,7 +281,7 @@ func (s *Supervisor) buildServices() ([]service.Service, error) { } if macIP != nil { services = append(services, macIP.Service()) - s.registerServiceStatus("MacIP", cfg.MacIPEnabled, nil) + s.registerMacIPStatus(cfg.MacIPEnabled) } ipxGW, err := wireIPXGW(IPXGWConfig{ @@ -709,6 +713,76 @@ func (s *Supervisor) registerIPXStatus(h IPXHook, enabled bool) { }) } +// macIPStatusProps builds the MacIP dashboard properties from the gateway's +// live state. Returns a base set (mode/dhcp/zone) plus live counts when the +// service is reachable. +func (s *Supervisor) macIPStatusProps() map[string]string { + props := map[string]string{ + "mode": boolStrMode(s.cfg.MacIPNAT), + "dhcp_relay": boolStr(s.cfg.MacIPDHCPRelay), + } + if z := strings.TrimSpace(s.cfg.MacIPZone); z != "" { + props["zone"] = z + } + if s.macIP != nil { + st := s.macIP.State() + props["mode"] = st.Mode + props["dhcp_relay"] = boolStr(st.DHCPRelay) + if st.Zone != "" { + props["zone"] = st.Zone + } + props["leases"] = fmt.Sprintf("%d", st.ActiveLeases) + props["sessions"] = fmt.Sprintf("%d", st.Sessions) + } + return props +} + +// registerMacIPStatus records the MacIP gateway's mode, options, and live +// lease/session counts for the dashboard. +func (s *Supervisor) registerMacIPStatus(enabled bool) { + binding := strings.TrimSpace(s.cfg.MacIPGatewayIP) + if binding == "" { + binding = strings.TrimSpace(s.cfg.MacIPSubnet) + } + s.reg.Set(status.Unit{ + Name: "MacIP", + Kind: status.KindService, + Enabled: enabled, + Binding: binding, + Properties: s.macIPStatusProps(), + }) +} + +// refreshMacIPStatus re-publishes MacIP's live counts (leases/sessions change +// at runtime), preserving the Running flag. +func (s *Supervisor) refreshMacIPStatus() { + if s.macIP == nil { + return + } + running := s.unitRunning("MacIP") + binding := strings.TrimSpace(s.cfg.MacIPGatewayIP) + if binding == "" { + binding = strings.TrimSpace(s.cfg.MacIPSubnet) + } + s.reg.Set(status.Unit{ + Name: "MacIP", + Kind: status.KindService, + Enabled: s.cfg.MacIPEnabled, + Running: running, + Binding: binding, + Properties: s.macIPStatusProps(), + }) +} + +// boolStrMode renders the MacIP gateway mode for the base (pre-service) +// status when the live State is not yet available. +func boolStrMode(nat bool) string { + if nat { + return "nat" + } + return "bridge" +} + // registerNetBEUIStatus records the NetBEUI hook's bound device. func (s *Supervisor) registerNetBEUIStatus(h NetBEUIHook, enabled bool) { if h == nil { diff --git a/internal/app/supervisor_lifecycle.go b/internal/app/supervisor_lifecycle.go index cb79c01..10ee63f 100644 --- a/internal/app/supervisor_lifecycle.go +++ b/internal/app/supervisor_lifecycle.go @@ -3,6 +3,7 @@ package app import ( "context" "fmt" + "time" "github.com/ObsoleteMadness/ClassicStack/netlog" ) @@ -41,9 +42,33 @@ func (s *Supervisor) Start(ctx context.Context) error { } s.alreadyRunning = nil s.started = true + + // Periodically refresh live dashboard counts (MacIP leases/sessions), + // which change at runtime and so cannot be captured once at wire time. + if s.macIP != nil { + stop := make(chan struct{}) + s.statusTickerStop = stop + go s.runStatusRefresh(stop) + } return nil } +// runStatusRefresh re-publishes time-varying dashboard status (MacIP live +// counts) on a fixed cadence until stop is closed. It does not hold s.mu — it +// reads stable post-Start fields and the independently-locked status registry. +func (s *Supervisor) runStatusRefresh(stop chan struct{}) { + t := time.NewTicker(5 * time.Second) + defer t.Stop() + for { + select { + case <-stop: + return + case <-t.C: + s.refreshMacIPStatus() + } + } +} + // Stop tears the stack down in reverse order: hooks first (reverse of // start), then the router. func (s *Supervisor) Stop() error { @@ -52,6 +77,10 @@ func (s *Supervisor) Stop() error { if !s.started { return nil } + if s.statusTickerStop != nil { + close(s.statusTickerStop) + s.statusTickerStop = nil + } for i := len(s.order) - 1; i >= 0; i-- { name := s.order[i] s.stopHookLocked(name) diff --git a/pkg/control/control_test.go b/pkg/control/control_test.go index 9bb4a99..eab23d6 100644 --- a/pkg/control/control_test.go +++ b/pkg/control/control_test.go @@ -145,4 +145,7 @@ func TestDiagnosticsFallback(t *testing.T) { if _, err := p.Diagnostics().ListZones(context.Background()); err != ErrDiagUnavailable { t.Errorf("unset diagnostics = %v, want ErrDiagUnavailable", err) } + if _, err := p.Diagnostics().MacIPLeases(context.Background()); err != ErrDiagUnavailable { + t.Errorf("unset MacIPLeases = %v, want ErrDiagUnavailable", err) + } } diff --git a/pkg/control/diagnostics.go b/pkg/control/diagnostics.go index 874aa7f..78b5a4b 100644 --- a/pkg/control/diagnostics.go +++ b/pkg/control/diagnostics.go @@ -30,6 +30,26 @@ type ServerInfo struct { Comment string `json:"comment,omitempty"` } +// LeaseInfo is one MacIP IP lease reported by MacIPLeases. Source is +// "static" (pool-assigned) or "dhcp" (relayed from the network's DHCP server). +type LeaseInfo struct { + IP string `json:"ip"` + ATNetwork uint16 `json:"at_network"` + ATNode uint8 `json:"at_node"` + Source string `json:"source"` + LastSeenUnix int64 `json:"last_seen_unix"` +} + +// MacIPState is a point-in-time summary of the MacIP gateway for the +// dashboard: its mode, options, and live counts. +type MacIPState struct { + Mode string `json:"mode"` // "nat" or "bridge" + DHCPRelay bool `json:"dhcp_relay"` + Zone string `json:"zone,omitempty"` + ActiveLeases int `json:"active_leases"` + Sessions int `json:"sessions"` +} + // Diagnostics is the set of read-only network probes the UI exposes. The // concrete implementation is provided by the supervisor at wire time // (some probes — e.g. SMB browse — are only available when that subsystem @@ -46,6 +66,9 @@ type Diagnostics interface { // SMBBrowse returns the SMB/NetBIOS browse list of servers. Only // available in SMB-enabled builds. SMBBrowse(ctx context.Context) ([]ServerInfo, error) + // MacIPLeases returns the MacIP gateway's current IP leases. Only + // available when MacIP is built in and enabled. + MacIPLeases(ctx context.Context) ([]LeaseInfo, error) } // SetDiagnostics installs the diagnostics implementation. diff --git a/pkg/control/diagnostics_unavailable.go b/pkg/control/diagnostics_unavailable.go index c65d7a1..ab04443 100644 --- a/pkg/control/diagnostics_unavailable.go +++ b/pkg/control/diagnostics_unavailable.go @@ -32,3 +32,7 @@ func (unavailableDiagnostics) DDPEnumerate(context.Context) ([]NetworkInfo, erro func (unavailableDiagnostics) SMBBrowse(context.Context) ([]ServerInfo, error) { return nil, ErrDiagUnavailable } + +func (unavailableDiagnostics) MacIPLeases(context.Context) ([]LeaseInfo, error) { + return nil, ErrDiagUnavailable +} diff --git a/service/macip/macip.go b/service/macip/macip.go index 440deef..ba9e690 100644 --- a/service/macip/macip.go +++ b/service/macip/macip.go @@ -268,6 +268,54 @@ func (s *Service) MarkSessionActivity(sessionID uint8) { s.pool.markSessionActivity(sessionID) } +// LeaseInfo is one IP lease for the diagnostics/dashboard view. Source is +// "static" (pool-assigned) or "dhcp" (relayed). +type LeaseInfo struct { + IP string + ATNetwork uint16 + ATNode uint8 + Source string + LastSeenUnix int64 +} + +// Leases returns a point-in-time copy of all non-expired IP leases. +func (s *Service) Leases() []LeaseInfo { + st := s.pool.snapshot() + out := make([]LeaseInfo, 0, len(st.Static)+len(st.DHCP)) + for _, l := range st.Static { + out = append(out, LeaseInfo{IP: l.IP, ATNetwork: l.ATNetwork, ATNode: l.ATNode, Source: "static", LastSeenUnix: l.LastSeen}) + } + for _, l := range st.DHCP { + out = append(out, LeaseInfo{IP: l.IP, ATNetwork: l.ATNetwork, ATNode: l.ATNode, Source: "dhcp", LastSeenUnix: l.LastSeen}) + } + return out +} + +// Stats is a point-in-time summary of the gateway for the dashboard. +type Stats struct { + Mode string // "nat" or "bridge" + DHCPRelay bool + Zone string + ActiveLeases int + Sessions int +} + +// GatewayStats returns the current MacIP gateway state and live counts. +func (s *Service) GatewayStats() Stats { + mode := "bridge" + if s.natEnabled { + mode = "nat" + } + ps := s.pool.stats() + return Stats{ + Mode: mode, + DHCPRelay: s.dhcpMode, + Zone: string(s.zoneName), + ActiveLeases: ps.activeLeases, + Sessions: ps.sessions, + } +} + // Inbound is called by the router for every DDP datagram addressed to socket 72. func (s *Service) Inbound(d ddp.Datagram, p port.Port) { select { diff --git a/service/macip/pool_test.go b/service/macip/pool_test.go index ebd9eb2..4cfbfc0 100644 --- a/service/macip/pool_test.go +++ b/service/macip/pool_test.go @@ -22,6 +22,37 @@ func TestIPPoolRejectsInvalidATEndpointOnAssign(t *testing.T) { } } +// TestIPPoolStatsAndSnapshot verifies the live-count and lease-list views used +// by the dashboard and the leases diagnostics: an assigned static lease and a +// relayed DHCP lease are both counted and reported. +func TestIPPoolStatsAndSnapshot(t *testing.T) { + p := newIPPool(net.ParseIP("192.168.100.0"), net.CIDRMask(24, 32)) + + if _, err := p.assign(nil, 1, 10); err != nil { + t.Fatalf("assign static: %v", err) + } + p.registerDHCP(net.ParseIP("192.168.100.77"), 2, 20) + + ps := p.stats() + if ps.activeLeases != 2 { + t.Fatalf("activeLeases = %d, want 2 (1 static + 1 dhcp)", ps.activeLeases) + } + if ps.sessions != 0 { + t.Fatalf("sessions = %d, want 0", ps.sessions) + } + + // A pinned ASP session is counted. + p.pinSessionLease(1, 10, 5) + if ps := p.stats(); ps.sessions != 1 { + t.Fatalf("sessions after pin = %d, want 1", ps.sessions) + } + + st := p.snapshot() + if len(st.Static) != 1 || len(st.DHCP) != 1 { + t.Fatalf("snapshot static/dhcp = %d/%d, want 1/1", len(st.Static), len(st.DHCP)) + } +} + func TestIPPoolIgnoresInvalidDHCPRegistrations(t *testing.T) { p := newIPPool(net.ParseIP("192.168.100.0"), net.CIDRMask(24, 32)) ip := net.ParseIP("192.168.100.50") diff --git a/service/macip/state.go b/service/macip/state.go index 28baa9e..9a639b6 100644 --- a/service/macip/state.go +++ b/service/macip/state.go @@ -45,6 +45,38 @@ func (p *ipPool) saveToFile(path string) { } } +// poolStats holds live counts for the dashboard. +type poolStats struct { + activeLeases int // non-expired static + DHCP leases + sessions int // active ASP-pinned sessions +} + +// stats returns live counts of leases and pinned sessions. +func (p *ipPool) stats() poolStats { + cutoff := time.Now().Add(-leaseDuration) + var ps poolStats + + p.mu.Lock() + for i := 1; i < len(p.entries); i++ { + e := &p.entries[i] + if e.used && !e.lastSeen.Before(cutoff) { + ps.activeLeases++ + } + } + ps.sessions = len(p.pinBySession) + p.mu.Unlock() + + p.dhcpMu.Lock() + for atKey := range p.dhcpByAT { + if !p.dhcpSeen[atKey].Before(cutoff) { + ps.activeLeases++ + } + } + p.dhcpMu.Unlock() + + return ps +} + // snapshot returns a point-in-time copy of all non-expired leases. func (p *ipPool) snapshot() savedState { cutoff := time.Now().Add(-leaseDuration) diff --git a/service/webui/assets/index.html b/service/webui/assets/index.html index 8951bdd..3643ccc 100644 --- a/service/webui/assets/index.html +++ b/service/webui/assets/index.html @@ -42,6 +42,7 @@

ClassicStack

+ AEP Echo net node diff --git a/service/webui/diagnostics.go b/service/webui/diagnostics.go index 7878df9..02c9a11 100644 --- a/service/webui/diagnostics.go +++ b/service/webui/diagnostics.go @@ -16,6 +16,7 @@ func (s *Server) registerDiagnosticRoutes() { s.mux.HandleFunc("/api/diag/ddp", s.handleDiagDDP) s.mux.HandleFunc("/api/diag/aep-echo", s.handleDiagAEPEcho) s.mux.HandleFunc("/api/diag/smb-browse", s.handleDiagSMBBrowse) + s.mux.HandleFunc("/api/diag/macip-leases", s.handleDiagMacIPLeases) } func (s *Server) handleDiagZones(w http.ResponseWriter, r *http.Request) { @@ -92,3 +93,16 @@ func (s *Server) handleDiagSMBBrowse(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, servers) } + +func (s *Server) handleDiagMacIPLeases(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + leases, err := s.opts.Plane.Diagnostics().MacIPLeases(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, leases) +} From a7606d8fd502c0f3da905bcd4011144466f846b7 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Fri, 5 Jun 2026 20:08:03 +1000 Subject: [PATCH 11/23] webui: add log download and friendly Windows serial-port labels Log download: a "Download" button on the Logs tab fetches /api/logs/download, which serves the retained ring-buffer history as a plain-text attachment (one entry per line: timestamp LEVEL message, filename classicstack-.log). Reuses Plane.LogHistory(); no new control-plane surface. Serial dropdown: the Windows enumerator now labels each port with its COM name first (e.g. "COM3" or "COM3 (\Device\Serial0)") instead of surfacing the raw driver device path as the option text, and sorts the list numerically (COM1, COM2, COM10) rather than in registry order. The stored value remains the bare COM name. Adds TestComNumber. Co-Authored-By: Claude Opus 4.8 --- pkg/serialport/serialport_windows.go | 41 +++++++++++++++++++++-- pkg/serialport/serialport_windows_test.go | 26 ++++++++++++++ service/webui/api.go | 1 + service/webui/assets/app.js | 4 +++ service/webui/assets/index.html | 1 + service/webui/logs.go | 23 +++++++++++++ 6 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 pkg/serialport/serialport_windows_test.go diff --git a/pkg/serialport/serialport_windows.go b/pkg/serialport/serialport_windows.go index f88ef4f..0413f51 100644 --- a/pkg/serialport/serialport_windows.go +++ b/pkg/serialport/serialport_windows.go @@ -3,13 +3,19 @@ package serialport import ( + "sort" + "strconv" + "strings" + "golang.org/x/sys/windows/registry" ) // list reads the COM port names from the Windows serial device map at // HKLM\HARDWARE\DEVICEMAP\SERIALCOMM. Each value's data is the port name -// (e.g. "COM3"); the value name is the underlying driver device path, -// which we surface as the description. +// (e.g. "COM3"); the value name is the underlying driver device path. We +// surface the COM name as the human-friendly label (e.g. "COM3"), appending +// the driver path only when it adds context, and sort numerically so the +// dropdown reads COM1, COM2, COM3 rather than driver-path order. func list() ([]Info, error) { key, err := registry.OpenKey(registry.LOCAL_MACHINE, `HARDWARE\DEVICEMAP\SERIALCOMM`, registry.QUERY_VALUE) if err != nil { @@ -32,7 +38,36 @@ func list() ([]Info, error) { if err != nil || port == "" { continue } - out = append(out, Info{Name: port, Description: valueName}) + // Label with the COM name first so the dropdown reads "COM3" rather + // than the underlying \Device\... driver path. Keep the driver path + // as trailing context when it differs and looks informative. + desc := port + if driver := strings.TrimSpace(valueName); driver != "" && !strings.EqualFold(driver, port) { + desc = port + " (" + driver + ")" + } + out = append(out, Info{Name: port, Description: desc}) } + + // Order COM1, COM2, COM10 numerically rather than by registry/driver order. + sort.Slice(out, func(i, j int) bool { + ni, oki := comNumber(out[i].Name) + nj, okj := comNumber(out[j].Name) + if oki && okj { + return ni < nj + } + return out[i].Name < out[j].Name + }) return out, nil } + +// comNumber extracts the numeric suffix of a "COM" name for sorting. +func comNumber(name string) (int, bool) { + if !strings.HasPrefix(strings.ToUpper(name), "COM") { + return 0, false + } + n, err := strconv.Atoi(name[3:]) + if err != nil { + return 0, false + } + return n, true +} diff --git a/pkg/serialport/serialport_windows_test.go b/pkg/serialport/serialport_windows_test.go new file mode 100644 index 0000000..0f079e5 --- /dev/null +++ b/pkg/serialport/serialport_windows_test.go @@ -0,0 +1,26 @@ +//go:build windows + +package serialport + +import "testing" + +func TestComNumber(t *testing.T) { + cases := []struct { + name string + want int + ok bool + }{ + {"COM3", 3, true}, + {"com10", 10, true}, + {"COM1", 1, true}, + {"/dev/ttyS0", 0, false}, + {"COMx", 0, false}, + {"", 0, false}, + } + for _, c := range cases { + n, ok := comNumber(c.name) + if ok != c.ok || (ok && n != c.want) { + t.Errorf("comNumber(%q) = (%d, %v), want (%d, %v)", c.name, n, ok, c.want, c.ok) + } + } +} diff --git a/service/webui/api.go b/service/webui/api.go index 3b98824..c07f2b1 100644 --- a/service/webui/api.go +++ b/service/webui/api.go @@ -26,6 +26,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/stats/stream", s.handleStatsStream) s.mux.HandleFunc("/api/logs", s.handleLogHistory) s.mux.HandleFunc("/api/logs/stream", s.handleLogStream) + s.mux.HandleFunc("/api/logs/download", s.handleLogDownload) s.registerDiagnosticRoutes() } diff --git a/service/webui/assets/app.js b/service/webui/assets/app.js index f10225a..f89508d 100644 --- a/service/webui/assets/app.js +++ b/service/webui/assets/app.js @@ -210,6 +210,10 @@ $("#btn-log-clear").addEventListener("click", () => { $("#log-output").textContent = ""; }); +$("#btn-log-download").addEventListener("click", () => { + window.location.href = "/api/logs/download"; +}); + // ---- configuration editor ---- async function loadConfig() { try { diff --git a/service/webui/assets/index.html b/service/webui/assets/index.html index 3643ccc..52c582c 100644 --- a/service/webui/assets/index.html +++ b/service/webui/assets/index.html @@ -64,6 +64,7 @@

ClassicStack

disconnected +

diff --git a/service/webui/logs.go b/service/webui/logs.go
index 7d76a36..cc14b8a 100644
--- a/service/webui/logs.go
+++ b/service/webui/logs.go
@@ -4,7 +4,10 @@ package webui
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
+	"strings"
+	"time"
 )
 
 // handleLogHistory returns the retained recent log entries (oldest-first) as
@@ -17,6 +20,26 @@ func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, s.opts.Plane.LogHistory())
 }
 
+// handleLogDownload serves the retained log history as a plain-text file
+// attachment (one entry per line: "2006-01-02 15:04:05.000 LEVEL message"),
+// for users who want to save or share the recent log without copying from the
+// viewer.
+func (s *Server) handleLogDownload(w http.ResponseWriter, r *http.Request) {
+	if s.opts.Plane == nil {
+		writeError(w, http.StatusServiceUnavailable, errNoPlane)
+		return
+	}
+	var b strings.Builder
+	for _, e := range s.opts.Plane.LogHistory() {
+		ts := time.UnixMilli(e.UnixMilli).Format("2006-01-02 15:04:05.000")
+		fmt.Fprintf(&b, "%s %-5s %s\n", ts, e.Level, e.Message)
+	}
+	filename := "classicstack-" + time.Now().Format("20060102-150405") + ".log"
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
+	_, _ = w.Write([]byte(b.String()))
+}
+
 // handleLogStream is a Server-Sent Events endpoint that first replays the
 // retained log history, then streams new entries as they are logged. It
 // mirrors handleStatsStream: subscribe up front so no entry is missed between

From 73aaf0d1c65b8c5015e023240c4fae81e2a230a1 Mon Sep 17 00:00:00 2001
From: pgodwin 
Date: Fri, 5 Jun 2026 20:58:42 +1000
Subject: [PATCH 12/23] webui: add an editor for the AFP extension map
 (type/creator conf)

Adds a raw-text editor for the Netatalk-style extension_map file on the
Configuration tab. The file is edited verbatim (comments and ordering
preserved) rather than parsed into a grid; the server validates it with
the existing parseAFPExtensionMap before writing and reports the
offending line on failure.

Plumbing:
- config.SaveBytes writes an arbitrary text file with the same
  numbered-backup + atomic-rename guarantees as config.Save.
- Supervisor.ReadExtMap/WriteExtMap resolve the configured
  AFP.ExtensionMap path (relative to the config dir, as AFP wiring
  does), read it, and validate+save edits. validateExtMap is behind
  //go:build afp (with a disabled stub) so untagged code stays clean.
- control.Plane.ExtMap/SaveExtMap delegate to the supervisor; webui
  serves them at GET/PUT /api/extmap.

The saved map loads on the next config Apply (WriteExtMap does not
restart AFP). A collapsible "Extension map" section lazy-loads the file
on first expand, with Reload and Save actions.

Tests: TestExtMapDelegates and TestExtMapWithoutSupervisor cover the
plane delegation and the no-supervisor guard.

Co-Authored-By: Claude Opus 4.8 
---
 config/save.go                         | 20 ++++++++++
 internal/app/extension_map.go          |  9 +++++
 internal/app/extension_map_disabled.go | 11 ++++++
 internal/app/supervisor_control.go     | 50 +++++++++++++++++++++++
 pkg/control/control.go                 | 26 ++++++++++++
 pkg/control/control_test.go            | 46 ++++++++++++++++++++-
 service/webui/api.go                   |  1 +
 service/webui/assets/app.css           | 24 +++++++++++
 service/webui/assets/app.js            | 45 +++++++++++++++++++++
 service/webui/assets/index.html        | 17 ++++++++
 service/webui/extmap.go                | 55 ++++++++++++++++++++++++++
 service/webui/plane.go                 |  2 +
 12 files changed, 304 insertions(+), 2 deletions(-)
 create mode 100644 internal/app/extension_map_disabled.go
 create mode 100644 service/webui/extmap.go

diff --git a/config/save.go b/config/save.go
index 00f5b29..bca15cd 100644
--- a/config/save.go
+++ b/config/save.go
@@ -33,6 +33,26 @@ func Save(path string, m *Model) (backupPath string, err error) {
 	return backupPath, nil
 }
 
+// SaveBytes writes data to path, first duplicating any existing file to the
+// next free numbered backup (path.NNNN), exactly like Save but for an
+// arbitrary text file (e.g. the AFP extension map) rather than the TOML model.
+// The write is atomic via a temp file + rename. It returns the backup path
+// created (empty when path did not previously exist).
+func SaveBytes(path string, data []byte) (backupPath string, err error) {
+	if _, statErr := os.Stat(path); statErr == nil {
+		backupPath, err = backupExisting(path)
+		if err != nil {
+			return "", err
+		}
+	} else if !os.IsNotExist(statErr) {
+		return "", statErr
+	}
+	if err := atomicWrite(path, data); err != nil {
+		return "", err
+	}
+	return backupPath, nil
+}
+
 // backupExisting copies path to the next free path.NNNN and returns the
 // backup path.
 func backupExisting(path string) (string, error) {
diff --git a/internal/app/extension_map.go b/internal/app/extension_map.go
index 53f7914..5af21d5 100644
--- a/internal/app/extension_map.go
+++ b/internal/app/extension_map.go
@@ -21,6 +21,15 @@ func loadAFPExtensionMap(path string) (*afp.ExtensionMap, error) {
 	return parseAFPExtensionMap(data)
 }
 
+// validateExtMap reports whether data is a parseable extension-map file,
+// returning a descriptive error (with the offending line) otherwise. The
+// management plane calls it before saving an edited map so a typo cannot
+// produce a file AFP fails to load on the next Apply.
+func validateExtMap(data []byte) error {
+	_, err := parseAFPExtensionMap(data)
+	return err
+}
+
 func parseAFPExtensionMap(data []byte) (*afp.ExtensionMap, error) {
 	entries := make(map[string]afp.ExtensionMapping)
 	lines := strings.Split(string(data), "\n")
diff --git a/internal/app/extension_map_disabled.go b/internal/app/extension_map_disabled.go
new file mode 100644
index 0000000..002e7ee
--- /dev/null
+++ b/internal/app/extension_map_disabled.go
@@ -0,0 +1,11 @@
+//go:build !afp && !all
+
+package app
+
+import "errors"
+
+// validateExtMap is unavailable in builds without AFP; the extension map is an
+// AFP-only concept, so editing it has no meaning here.
+func validateExtMap([]byte) error {
+	return errors.New("extension map editing requires an AFP-enabled build")
+}
diff --git a/internal/app/supervisor_control.go b/internal/app/supervisor_control.go
index 47af724..cf61b90 100644
--- a/internal/app/supervisor_control.go
+++ b/internal/app/supervisor_control.go
@@ -2,7 +2,10 @@ package app
 
 import (
 	"context"
+	"errors"
 	"fmt"
+	"os"
+	"path/filepath"
 
 	"github.com/ObsoleteMadness/ClassicStack/config"
 	"github.com/ObsoleteMadness/ClassicStack/netlog"
@@ -161,3 +164,50 @@ func (s *Supervisor) ListInterfaces() ([]control.InterfaceInfo, error) {
 func (s *Supervisor) ListFSTypes() []string {
 	return registeredFSTypes()
 }
+
+// extMapPath resolves the configured AFP extension-map file path, resolving a
+// relative path against the config directory exactly as AFP wiring does. It
+// returns "" when no extension map is configured.
+func (s *Supervisor) extMapPath() string {
+	if s.model == nil {
+		return ""
+	}
+	p := s.model.AFP.ExtensionMap
+	if p != "" && !filepath.IsAbs(p) && s.source.ConfigDir != "" {
+		p = filepath.Join(s.source.ConfigDir, p)
+	}
+	return p
+}
+
+// ReadExtMap returns the configured extension-map path and its current file
+// contents. It is used by the management UI's extension-map editor. The path
+// is returned even on read error so the UI can show what it tried to open;
+// a missing file yields empty content and no error (the operator can create
+// it by saving).
+func (s *Supervisor) ReadExtMap() (path string, data []byte, err error) {
+	path = s.extMapPath()
+	if path == "" {
+		return "", nil, fmt.Errorf("no AFP extension_map configured")
+	}
+	data, err = os.ReadFile(path)
+	if errors.Is(err, os.ErrNotExist) {
+		return path, nil, nil
+	}
+	return path, data, err
+}
+
+// WriteExtMap validates data as an extension-map file and, if it parses,
+// writes it to the configured path (creating a numbered backup of any
+// existing file). It returns the backup path created (empty when none).
+// The change takes effect on the next configuration Apply, which reloads the
+// map; WriteExtMap itself does not restart AFP.
+func (s *Supervisor) WriteExtMap(data []byte) (backup string, err error) {
+	path := s.extMapPath()
+	if path == "" {
+		return "", fmt.Errorf("no AFP extension_map configured")
+	}
+	if err := validateExtMap(data); err != nil {
+		return "", err
+	}
+	return config.SaveBytes(path, data)
+}
diff --git a/pkg/control/control.go b/pkg/control/control.go
index a54a564..341d1f8 100644
--- a/pkg/control/control.go
+++ b/pkg/control/control.go
@@ -41,6 +41,14 @@ type Supervisor interface {
 	// build (e.g. "local_fs", and "macgarden" when built with that tag), for
 	// the volume/share FS-type dropdown.
 	ListFSTypes() []string
+	// ReadExtMap returns the configured AFP extension-map file path and its
+	// current contents, for the extension-map editor. A missing file yields
+	// empty contents and no error.
+	ReadExtMap() (path string, data []byte, err error)
+	// WriteExtMap validates and saves edited extension-map contents (creating
+	// a numbered backup), returning the backup path. The change takes effect
+	// on the next Apply.
+	WriteExtMap(data []byte) (backup string, err error)
 }
 
 // InterfaceInfo describes one network interface for the UI dropdowns. Name is
@@ -137,6 +145,24 @@ func (p *Plane) ListSerialPorts() ([]serialport.Info, error) {
 	return serialport.List()
 }
 
+// ExtMap returns the configured extension-map file path and its contents for
+// the editor.
+func (p *Plane) ExtMap() (path string, data []byte, err error) {
+	if p.sup == nil {
+		return "", nil, ErrNoSupervisor
+	}
+	return p.sup.ReadExtMap()
+}
+
+// SaveExtMap validates and writes edited extension-map contents, returning the
+// numbered backup path of any pre-existing file.
+func (p *Plane) SaveExtMap(data []byte) (backup string, err error) {
+	if p.sup == nil {
+		return "", ErrNoSupervisor
+	}
+	return p.sup.WriteExtMap(data)
+}
+
 // StartService starts a single named unit.
 func (p *Plane) StartService(ctx context.Context, name string) error {
 	if p.sup == nil {
diff --git a/pkg/control/control_test.go b/pkg/control/control_test.go
index eab23d6..d04efcc 100644
--- a/pkg/control/control_test.go
+++ b/pkg/control/control_test.go
@@ -15,8 +15,9 @@ func (f *fakeModel) ToTOML() ([]byte, error) { return []byte(f.toml), nil }
 
 // fakeSup records Apply/Restart calls.
 type fakeSup struct {
-	applied  int
-	restarts []string
+	applied       int
+	restarts      []string
+	extMapWritten []byte
 }
 
 func (s *fakeSup) Apply(_ context.Context, _ ConfigModel) error   { s.applied++; return nil }
@@ -30,6 +31,13 @@ func (s *fakeSup) ListInterfaces() ([]InterfaceInfo, error) {
 	return []InterfaceInfo{{Name: "eth0", Description: "Ethernet"}}, nil
 }
 func (s *fakeSup) ListFSTypes() []string { return []string{"local_fs"} }
+func (s *fakeSup) ReadExtMap() (string, []byte, error) {
+	return "/etc/extmap.conf", []byte(".txt \"TEXT\" \"ttxt\"\n"), nil
+}
+func (s *fakeSup) WriteExtMap(data []byte) (string, error) {
+	s.extMapWritten = data
+	return "/etc/extmap.conf.0001", nil
+}
 
 func TestListInterfacesAndFSTypes(t *testing.T) {
 	p := New(Deps{Supervisor: &fakeSup{}})
@@ -48,6 +56,40 @@ func TestListInterfacesAndFSTypes(t *testing.T) {
 	}
 }
 
+func TestExtMapDelegates(t *testing.T) {
+	sup := &fakeSup{}
+	p := New(Deps{Supervisor: sup})
+
+	path, data, err := p.ExtMap()
+	if err != nil {
+		t.Fatalf("ExtMap: %v", err)
+	}
+	if path != "/etc/extmap.conf" || len(data) == 0 {
+		t.Fatalf("ExtMap = (%q, %d bytes), want path + non-empty", path, len(data))
+	}
+
+	backup, err := p.SaveExtMap([]byte(".dat \"BINA\" \"hDmp\"\n"))
+	if err != nil {
+		t.Fatalf("SaveExtMap: %v", err)
+	}
+	if backup != "/etc/extmap.conf.0001" {
+		t.Errorf("SaveExtMap backup = %q, want /etc/extmap.conf.0001", backup)
+	}
+	if string(sup.extMapWritten) == "" {
+		t.Error("SaveExtMap did not forward data to supervisor")
+	}
+}
+
+func TestExtMapWithoutSupervisor(t *testing.T) {
+	p := New(Deps{Config: &fakeModel{}})
+	if _, _, err := p.ExtMap(); err != ErrNoSupervisor {
+		t.Errorf("ExtMap without supervisor = %v, want ErrNoSupervisor", err)
+	}
+	if _, err := p.SaveExtMap(nil); err != ErrNoSupervisor {
+		t.Errorf("SaveExtMap without supervisor = %v, want ErrNoSupervisor", err)
+	}
+}
+
 func TestDirtyLifecycle(t *testing.T) {
 	sup := &fakeSup{}
 	live := &fakeModel{toml: "live"}
diff --git a/service/webui/api.go b/service/webui/api.go
index c07f2b1..bbcc1af 100644
--- a/service/webui/api.go
+++ b/service/webui/api.go
@@ -22,6 +22,7 @@ func (s *Server) routes() {
 	s.mux.HandleFunc("/api/config/apply", s.handleApply)
 	s.mux.HandleFunc("/api/config/save", s.handleSave)
 	s.mux.HandleFunc("/api/config/download", s.handleDownload)
+	s.mux.HandleFunc("/api/extmap", s.handleExtMap)
 	s.mux.HandleFunc("/api/services/", s.handleServiceAction)
 	s.mux.HandleFunc("/api/stats/stream", s.handleStatsStream)
 	s.mux.HandleFunc("/api/logs", s.handleLogHistory)
diff --git a/service/webui/assets/app.css b/service/webui/assets/app.css
index b384e20..c1fb4a2 100644
--- a/service/webui/assets/app.css
+++ b/service/webui/assets/app.css
@@ -178,6 +178,30 @@ button.primary { background: var(--accent); color: var(--accent-text); border-co
 .diag-tools { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
 .diag-tools .aep input { width: 70px; }
 
+/* ---- extension-map editor ---- */
+.extmap { margin-top: 1.4rem; border-top: 1px solid #243042; padding-top: 1rem; }
+.extmap > summary {
+  cursor: pointer;
+  font-weight: 600;
+  color: #cfe8ff;
+  user-select: none;
+}
+.extmap-path { margin: 0.6rem 0 0.2rem; color: #8aa0b6; font-size: 0.85rem; }
+.extmap-hint { margin: 0.2rem 0 0.6rem; color: #8aa0b6; font-size: 0.85rem; }
+.extmap-text {
+  width: 100%;
+  box-sizing: border-box;
+  background: #0e1116;
+  color: #cfe8ff;
+  border: 1px solid #243042;
+  border-radius: 8px;
+  padding: 0.6rem 0.8rem;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 0.82rem;
+  line-height: 1.4;
+  resize: vertical;
+}
+
 /* ---- logs ---- */
 .log-controls {
   display: flex;
diff --git a/service/webui/assets/app.js b/service/webui/assets/app.js
index f89508d..642195e 100644
--- a/service/webui/assets/app.js
+++ b/service/webui/assets/app.js
@@ -767,6 +767,51 @@ function setConfigStatus(msg) {
   $("#config-status").textContent = msg;
 }
 
+// ---- extension-map editor ----
+// A raw text editor for the Netatalk-style type/creator file. We edit the
+// file verbatim (preserving comments/order) rather than parsing it into a
+// grid; the server validates on save and reports the offending line.
+let extMapLoaded = false;
+
+async function loadExtMap() {
+  try {
+    const r = await fetchJSON("/api/extmap");
+    $("#extmap-path").textContent = r.path || "(unset)";
+    $("#extmap-text").value = r.content || "";
+    setExtMapStatus("");
+    extMapLoaded = true;
+  } catch (e) {
+    $("#extmap-path").textContent = "(unavailable)";
+    $("#extmap-text").value = "";
+    setExtMapStatus("Could not load extension map: " + e.message);
+  }
+}
+
+function setExtMapStatus(msg) {
+  $("#extmap-status").textContent = msg;
+}
+
+const extMapEditor = $("#extmap-editor");
+if (extMapEditor) {
+  // Lazily load the file the first time the section is expanded.
+  extMapEditor.addEventListener("toggle", () => {
+    if (extMapEditor.open && !extMapLoaded) loadExtMap();
+  });
+  $("#btn-extmap-reload").addEventListener("click", loadExtMap);
+  $("#btn-extmap-save").addEventListener("click", async () => {
+    try {
+      const r = await putJSON("/api/extmap", { content: $("#extmap-text").value });
+      setExtMapStatus(
+        "Saved. Backup written to " +
+          (r.backup || "(no previous file)") +
+          ". Applies on next Apply.",
+      );
+    } catch (e) {
+      setExtMapStatus("Save failed: " + e.message);
+    }
+  });
+}
+
 // ---- diagnostics ----
 $$("[data-diag]").forEach((btn) => {
   btn.addEventListener("click", async () => {
diff --git a/service/webui/assets/index.html b/service/webui/assets/index.html
index 52c582c..a208222 100644
--- a/service/webui/assets/index.html
+++ b/service/webui/assets/index.html
@@ -34,6 +34,23 @@ 

ClassicStack


+
+      
+ Extension map (type/creator) +

File:

+

+ Netatalk-style .ext "TYPE" "CRTR" lines map file + extensions to classic Mac OS type/creator codes. Changes take effect + on the next Apply. +

+ +
+ + +
+

+      
diff --git a/service/webui/extmap.go b/service/webui/extmap.go new file mode 100644 index 0000000..052edb4 --- /dev/null +++ b/service/webui/extmap.go @@ -0,0 +1,55 @@ +//go:build webui || all + +package webui + +import ( + "encoding/json" + "net/http" +) + +// extMapResponse is the GET /api/extmap payload: the resolved file path and +// its current text contents. +type extMapResponse struct { + Path string `json:"path"` + Content string `json:"content"` +} + +// extMapSaveRequest is the PUT /api/extmap body. +type extMapSaveRequest struct { + Content string `json:"content"` +} + +// handleExtMap serves the AFP extension-map editor: GET returns the current +// file, PUT validates and saves edited contents (returning the backup path). +// Save does not restart AFP; the new map loads on the next config Apply. +func (s *Server) handleExtMap(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + switch r.Method { + case http.MethodGet: + path, data, err := s.opts.Plane.ExtMap() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, extMapResponse{Path: path, Content: string(data)}) + case http.MethodPut: + var req extMapSaveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + backup, err := s.opts.Plane.SaveExtMap([]byte(req.Content)) + if err != nil { + // A parse failure is the operator's mistake, not a server fault. + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"saved": true, "backup": backup}) + default: + w.Header().Set("Allow", "GET, PUT") + writeError(w, http.StatusMethodNotAllowed, errMethod) + } +} diff --git a/service/webui/plane.go b/service/webui/plane.go index 4a480bc..19fb90b 100644 --- a/service/webui/plane.go +++ b/service/webui/plane.go @@ -27,6 +27,8 @@ type ControlPlane interface { ListInterfaces() ([]control.InterfaceInfo, error) ListFSTypes() []string ListSerialPorts() ([]serialport.Info, error) + ExtMap() (path string, data []byte, err error) + SaveExtMap(data []byte) (backup string, err error) Subscribe() (<-chan control.Frame, func()) LogHistory() []logbuf.Entry SubscribeLogs() (<-chan logbuf.Entry, func()) From a23444bcee43a6464de338a10c2c36cd760a2189 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Fri, 5 Jun 2026 22:15:52 +1000 Subject: [PATCH 13/23] webui: config editor for remaining MacIP options and MacIPX gateway MacIP gateway panel gains the previously-unexposed fields: NAT gateway IP, lease file, nameserver, and BPF filter override, plus inline hints on the existing zone/subnet/gateway/DHCP-relay fields. Add an "IPX Gateway (MacIPX)" config panel (enabled toggle + editable "Object:Zone" zone-binding list) backed by a new "stringlist" field type and buildStringList() editor, with matching .stringlist CSS. Co-Authored-By: Claude Opus 4.8 --- service/webui/assets/app.css | 12 +++++ service/webui/assets/app.js | 91 ++++++++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/service/webui/assets/app.css b/service/webui/assets/app.css index c1fb4a2..5514199 100644 --- a/service/webui/assets/app.css +++ b/service/webui/assets/app.css @@ -146,6 +146,18 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } border-radius: 5px; } +/* Editable free-text list (e.g. IPX gateway zone bindings). */ +.stringlist { display: flex; flex-direction: column; gap: 0.3rem; } +.stringlist-rows { display: flex; flex-direction: column; gap: 0.3rem; } +.stringlist-row { display: flex; gap: 0.4rem; align-items: center; } +.stringlist-row input[type="text"] { flex: 1; min-width: 200px; } +.stringlist-del { + padding: 0.2rem 0.5rem; + line-height: 1; + color: var(--muted); +} +.stringlist-add { align-self: flex-start; } + .banner { background: #fff7e6; border: 1px solid #f0d28a; diff --git a/service/webui/assets/app.js b/service/webui/assets/app.js index 642195e..b0f4f6f 100644 --- a/service/webui/assets/app.js +++ b/service/webui/assets/app.js @@ -284,16 +284,33 @@ const CONFIG_PANELS = [ { label: "Network", path: "IPX.internal_network", type: "text" }, ], }, + { + title: "IPX Gateway (MacIPX)", + fields: [ + { label: "Enabled", path: "IPXGW.enabled", type: "bool", hint: "Register an 'IPX Gateway' NBP name so MacIPX clients can discover us." }, + { + label: "Zone Bindings", + path: "IPXGW.bindings", + type: "stringlist", + placeholder: "Object:Zone", + hint: "Optional 'Object:Zone' pairs. Leave empty to register one binding per zone the router knows.", + }, + ], + }, { title: "MacIP Gateway", interfaceFor: "MacIP", fields: [ { label: "Enabled", path: "MacIP.enabled", type: "bool" }, { label: "Gateway Mode", path: "MacIP.mode", type: "select", options: ["pcap", "nat"] }, - { label: "Zone", path: "MacIP.zone", type: "text" }, - { label: "NAT Subnet", path: "MacIP.nat_subnet", type: "text" }, - { label: "IP Gateway", path: "MacIP.ip_gateway", type: "text" }, - { label: "DHCP Relay", path: "MacIP.dhcp_relay", type: "bool" }, + { label: "Zone", path: "MacIP.zone", type: "text", hint: "MacIP gateway zone; defaults to the EtherTalk zone." }, + { label: "NAT Subnet", path: "MacIP.nat_subnet", type: "text", hint: "NAT mode: subnet to hand out, e.g. 192.168.100.0/24." }, + { label: "NAT Gateway IP", path: "MacIP.nat_gw", type: "text", hint: "NAT mode: the gateway's own IP on the NAT subnet." }, + { label: "Lease File", path: "MacIP.lease_file", type: "text", hint: "NAT mode: file to persist DHCP leases across restarts." }, + { label: "IP Gateway", path: "MacIP.ip_gateway", type: "text", hint: "Upstream/default gateway on the IP-side network." }, + { label: "DHCP Relay", path: "MacIP.dhcp_relay", type: "bool", hint: "Convert MacTCP auto-config to DHCP requests." }, + { label: "Nameserver", path: "MacIP.nameserver", type: "text", hint: "DNS server advertised to MacIP clients, e.g. 1.1.1.1." }, + { label: "BPF Filter", path: "MacIP.filter", type: "text", hint: "Optional pcap BPF filter override (advanced)." }, ], }, { @@ -611,6 +628,64 @@ function renderShareEditor(cfg, title, section, columns) { // label}), preselecting current. If current is not among the options it is // added so a stored value is never silently dropped. onChange(value) fires on // selection. +// buildStringList renders an editable list of free-text rows (one per array +// element) with per-row remove buttons and an Add button. onChange receives +// the current array (trimmed of empty entries) whenever it mutates. +function buildStringList(values, placeholder, onChange) { + const wrap = document.createElement("div"); + wrap.className = "stringlist"; + const rows = document.createElement("div"); + rows.className = "stringlist-rows"; + wrap.appendChild(rows); + + const items = values.slice(); + + function emit() { + onChange(items.map((s) => s.trim()).filter((s) => s !== "")); + } + function render() { + rows.innerHTML = ""; + items.forEach((val, i) => { + const row = document.createElement("div"); + row.className = "stringlist-row"; + const input = document.createElement("input"); + input.type = "text"; + input.value = val; + input.placeholder = placeholder; + input.addEventListener("input", () => { + items[i] = input.value; + emit(); + }); + const del = document.createElement("button"); + del.type = "button"; + del.className = "stringlist-del"; + del.textContent = "✕"; + del.title = "Remove"; + del.addEventListener("click", () => { + items.splice(i, 1); + render(); + emit(); + }); + row.appendChild(input); + row.appendChild(del); + rows.appendChild(row); + }); + } + + const add = document.createElement("button"); + add.type = "button"; + add.className = "stringlist-add"; + add.textContent = "Add"; + add.addEventListener("click", () => { + items.push(""); + render(); + }); + wrap.appendChild(add); + + render(); + return wrap; +} + function buildSelect(options, current, onChange) { const sel = document.createElement("select"); const norm = options.map((o) => (typeof o === "string" ? { value: o, label: o } : o)); @@ -663,6 +738,7 @@ function buildInterfaceSelect(current, onChange) { function renderField(cfg, f) { const row = document.createElement("div"); row.className = "field"; + if (f.hint) row.title = f.hint; const label = document.createElement("label"); label.textContent = f.label; row.appendChild(label); @@ -704,6 +780,13 @@ function renderField(cfg, f) { setPath(cfg, f.path, v); setDirty(true); }); + } else if (f.type === "stringlist") { + input = buildStringList(Array.isArray(val) ? val : [], f.placeholder || "", (list) => { + // Store undefined for an empty list so the omitempty field drops out of + // the TOML entirely rather than serialising an empty array. + setPath(cfg, f.path, list.length ? list : undefined); + setDirty(true); + }); } else { input = document.createElement("input"); input.type = f.type === "number" ? "number" : "text"; From b76d387ae26e852b8438e8ea43f31d6b6b7d2215 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Fri, 5 Jun 2026 22:26:26 +1000 Subject: [PATCH 14/23] control: make AFP/MacIP/IPXGW individually start/stoppable from the UI The optional AppleTalk DDP subsystems (AFP, MacIP, the IPX gateway) used to ride the AppleTalk router's initial service set, so their only lifecycle was the whole-stack one and the dashboard showed no start/stop/restart controls (markServiceRunning just force-toggled them with the router). Wrap each subsystem's DDP services in a ddpServiceHook that drives the router's runtime AddService/RemoveService primitives, register it as a standalone hook, and promote its status unit to KindHook so the UI surfaces lifecycle controls. buildServices now collects these groups instead of appending them to the router's initial set; buildHooks wraps them once the router exists (they depend only on the router, which starts first). Start rolls back partially-added services on failure; Stop removes them in reverse. Disabled subsystems contribute no services, so they register no hook and remain uncontrollable, as before. markServiceRunning is removed. Adds ddp_service_hook_test.go covering start/stop, start-rollback, the empty-group nil hook, and KindService->KindHook promotion preserving detail. Co-Authored-By: Claude Opus 4.8 --- internal/app/ddp_service_hook.go | 70 +++++++++++ internal/app/ddp_service_hook_test.go | 166 ++++++++++++++++++++++++++ internal/app/supervisor.go | 95 ++++++++++++++- internal/app/supervisor_lifecycle.go | 10 -- service/webui/assets/app.js | 7 +- 5 files changed, 330 insertions(+), 18 deletions(-) create mode 100644 internal/app/ddp_service_hook.go create mode 100644 internal/app/ddp_service_hook_test.go diff --git a/internal/app/ddp_service_hook.go b/internal/app/ddp_service_hook.go new file mode 100644 index 0000000..b02c13d --- /dev/null +++ b/internal/app/ddp_service_hook.go @@ -0,0 +1,70 @@ +package app + +import ( + "context" + "errors" + + "github.com/ObsoleteMadness/ClassicStack/router" + "github.com/ObsoleteMadness/ClassicStack/service" +) + +// ddpServiceHook adapts a group of DDP services (the ones a single optional +// subsystem — AFP, MacIP, or the IPX gateway — registers with the AppleTalk +// router) to the standalone hook lifecycle. Unlike the transport hooks that +// own their own listener, these services ride the shared router; the hook +// drives them with the router's runtime AddService/RemoveService primitives +// so the management UI can start and stop each subsystem independently +// without rebuilding the whole stack. +// +// The router must already be running before Start is called: AddService +// starts each service against the live router. The supervisor guarantees +// this by starting the router before walking the hook order. +type ddpServiceHook struct { + router *router.Router + services []service.Service + running bool +} + +// newDDPServiceHook returns a hook over svcs, or nil when svcs is empty so the +// supervisor records no unit for a subsystem that contributed no services. +func newDDPServiceHook(r *router.Router, svcs []service.Service) *ddpServiceHook { + if len(svcs) == 0 { + return nil + } + return &ddpServiceHook{router: r, services: svcs} +} + +// Start registers (and starts) each managed service against the router. On +// the first failure it rolls back the services already added so a partial +// start does not leave half the subsystem live. +func (h *ddpServiceHook) Start(ctx context.Context) error { + if h.running { + return nil + } + for i, svc := range h.services { + if err := h.router.AddService(ctx, svc); err != nil { + for j := i - 1; j >= 0; j-- { + _ = h.router.RemoveService(h.services[j]) + } + return err + } + } + h.running = true + return nil +} + +// Stop removes (and stops) each managed service from the router in reverse +// registration order, joining any teardown errors. +func (h *ddpServiceHook) Stop() error { + if !h.running { + return nil + } + var errs []error + for i := len(h.services) - 1; i >= 0; i-- { + if err := h.router.RemoveService(h.services[i]); err != nil { + errs = append(errs, err) + } + } + h.running = false + return errors.Join(errs...) +} diff --git a/internal/app/ddp_service_hook_test.go b/internal/app/ddp_service_hook_test.go new file mode 100644 index 0000000..d93d1bd --- /dev/null +++ b/internal/app/ddp_service_hook_test.go @@ -0,0 +1,166 @@ +//go:build all + +package app + +import ( + "context" + "sync/atomic" + "testing" + + "github.com/ObsoleteMadness/ClassicStack/pkg/status" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/router" + "github.com/ObsoleteMadness/ClassicStack/service" +) + +// fakeDDPService is a minimal router service that records Start/Stop calls and +// the socket it binds, so ddpServiceHook lifecycle can be observed without a +// real subsystem. +type fakeDDPService struct { + socket uint8 + starts int32 + stops int32 + failNth int32 // 1-based call index whose Start should fail; 0 = never +} + +func (f *fakeDDPService) Socket() uint8 { return f.socket } + +func (f *fakeDDPService) Start(_ context.Context, _ service.Router) error { + n := atomic.AddInt32(&f.starts, 1) + if f.failNth != 0 && n == f.failNth { + return errFakeStart + } + return nil +} + +func (f *fakeDDPService) Stop() error { + atomic.AddInt32(&f.stops, 1) + return nil +} + +func (f *fakeDDPService) Inbound(_ ddp.Datagram, _ port.Port) {} + +// errFakeStart is returned by fakeDDPService.Start on its configured failure. +var errFakeStart = fakeErr("forced start failure") + +type fakeErr string + +func (e fakeErr) Error() string { return string(e) } + +// newTestRouter returns a router with no ports and only the default core +// services, started so AddService/RemoveService operate on a live router. +func newTestRouter(t *testing.T) *router.Router { + t.Helper() + r := router.New("test", nil, []service.Service{}) + if err := r.Start(context.Background()); err != nil { + t.Fatalf("router start: %v", err) + } + t.Cleanup(func() { _ = r.Stop() }) + return r +} + +// TestDDPServiceHookStartStop verifies the hook adds its services to the live +// router on Start and removes them on Stop, and that the router's dispatch map +// reflects the change. +func TestDDPServiceHookStartStop(t *testing.T) { + r := newTestRouter(t) + svc := &fakeDDPService{socket: 200} + h := newDDPServiceHook(r, []service.Service{svc}) + if h == nil { + t.Fatal("newDDPServiceHook returned nil for a non-empty group") + } + + if err := h.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + if got := atomic.LoadInt32(&svc.starts); got != 1 { + t.Fatalf("service Start calls = %d, want 1", got) + } + if !routerHasService(r, svc) { + t.Fatal("service not present in router after hook Start") + } + + // Start again is idempotent (no second AddService). + if err := h.Start(context.Background()); err != nil { + t.Fatalf("second Start: %v", err) + } + if got := atomic.LoadInt32(&svc.starts); got != 1 { + t.Fatalf("service Start calls after re-Start = %d, want 1", got) + } + + if err := h.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if got := atomic.LoadInt32(&svc.stops); got != 1 { + t.Fatalf("service Stop calls = %d, want 1", got) + } + if routerHasService(r, svc) { + t.Fatal("service still present in router after hook Stop") + } +} + +// TestDDPServiceHookStartRollback verifies that if one service in the group +// fails to start, the services already added are rolled back so the subsystem +// is not left half-up. +func TestDDPServiceHookStartRollback(t *testing.T) { + r := newTestRouter(t) + ok := &fakeDDPService{socket: 201} + bad := &fakeDDPService{socket: 202, failNth: 1} + h := newDDPServiceHook(r, []service.Service{ok, bad}) + + if err := h.Start(context.Background()); err == nil { + t.Fatal("Start: expected error from failing service") + } + if routerHasService(r, ok) { + t.Fatal("first service must be rolled back when a later one fails") + } + if got := atomic.LoadInt32(&ok.stops); got != 1 { + t.Fatalf("rolled-back service Stop calls = %d, want 1", got) + } +} + +// TestNewDDPServiceHookEmpty verifies an empty group yields a nil hook so the +// supervisor registers no unit for a subsystem with no services. +func TestNewDDPServiceHookEmpty(t *testing.T) { + if h := newDDPServiceHook(nil, nil); h != nil { + t.Fatalf("newDDPServiceHook(nil, nil) = %v, want nil", h) + } +} + +// TestPromoteUnitToHook verifies a KindService unit is re-published as a +// KindHook (so the dashboard shows lifecycle controls) while preserving its +// binding and properties. +func TestPromoteUnitToHook(t *testing.T) { + reg := status.NewRegistry() + reg.Set(status.Unit{ + Name: "AFP", + Kind: status.KindService, + Enabled: true, + Running: true, + Binding: ":548", + Properties: map[string]string{"zone": "MyZone"}, + }) + s := &Supervisor{reg: reg} + s.promoteUnitToHook("AFP", true) + + u := unitByName(reg, "AFP") + if u.Kind != status.KindHook { + t.Fatalf("Kind = %q, want %q", u.Kind, status.KindHook) + } + if u.Running { + t.Fatal("promoted unit should start not-running") + } + if u.Binding != ":548" || u.Properties["zone"] != "MyZone" { + t.Fatalf("promotion lost detail: binding=%q props=%v", u.Binding, u.Properties) + } +} + +func routerHasService(r *router.Router, target service.Service) bool { + for _, s := range r.Services { + if s == target { + return true + } + } + return false +} diff --git a/internal/app/supervisor.go b/internal/app/supervisor.go index 8932c96..55fc228 100644 --- a/internal/app/supervisor.go +++ b/internal/app/supervisor.go @@ -69,6 +69,14 @@ type Supervisor struct { macIP MacIPHook ipxGW IPXGWHook + // ddpServiceGroups holds the optional DDP subsystems' services (AFP, + // MacIP, IPXGW) keyed by status-unit name. They are NOT part of the + // router's initial service set; buildHooks wraps each in a + // ddpServiceHook so the UI can start/stop it via router AddService/ + // RemoveService. Populated in buildServices, consumed in buildHooks. + ddpServiceGroups map[string][]service.Service + ddpServiceOrder []string // registration order of ddpServiceGroups + // statusTickerStop stops the periodic dashboard-status refresher (live // MacIP lease/session counts). Closed and nilled on Stop. statusTickerStop chan struct{} @@ -236,10 +244,14 @@ func (s *Supervisor) buildEtherTalkPort() (port.Port, error) { } } -// buildServices constructs the AppleTalk DDP service set plus the optional -// DDP services (MacIP, IPXGW, AFP) that ride the router. +// buildServices constructs the always-on AppleTalk DDP core service set. The +// optional DDP subsystems (MacIP, IPXGW, AFP) are NOT returned here: their +// services are collected into s.ddpServiceGroups so buildHooks can wrap each +// as an independently start/stoppable hook over the live router. func (s *Supervisor) buildServices() ([]service.Service, error) { cfg := s.cfg + s.ddpServiceGroups = map[string][]service.Service{} + s.ddpServiceOrder = nil s.nbp = zip.NewNameInformationService() services := []service.Service{ llap.New(), @@ -280,7 +292,7 @@ func (s *Supervisor) buildServices() ([]service.Service, error) { return nil, fmt.Errorf("MacIP wiring failed: %w", err) } if macIP != nil { - services = append(services, macIP.Service()) + s.addDDPServiceGroup("MacIP", macIP.Service()) s.registerMacIPStatus(cfg.MacIPEnabled) } @@ -293,7 +305,7 @@ func (s *Supervisor) buildServices() ([]service.Service, error) { return nil, fmt.Errorf("IPXGW wiring failed: %w", err) } if ipxGW != nil { - services = append(services, ipxGW.Service()) + s.addDDPServiceGroup("IPXGW", ipxGW.Service()) s.registerServiceStatus("IPXGW", cfg.IPXGWEnabled, nil) } @@ -322,7 +334,7 @@ func (s *Supervisor) buildServices() ([]service.Service, error) { if macIP != nil { afpHook.AttachMacIP(macIPAFPHooks{macIP}) } - services = append(services, afpHook.Services()...) + s.addDDPServiceGroup("AFP", afpHook.Services()...) s.registerAFPStatus() s.macIP = macIP @@ -363,6 +375,12 @@ func (s *Supervisor) afpFlagInputs() AFPFlagInputs { func (s *Supervisor) buildHooks() error { cfg := s.cfg + // Wrap the optional DDP subsystems (MacIP, IPXGW, AFP) as hooks over the + // live router so the UI can start/stop each one independently. They are + // recorded ahead of the transport hooks: they depend only on the + // AppleTalk router, which is started before any hook. + s.buildDDPServiceHooks() + ipxResolvedIface := s.resolveIPXInterface() ipxHook, err := wireIPX(IPXConfig{ Enabled: cfg.IPXEnabled, @@ -529,6 +547,71 @@ func (s *Supervisor) resolveNetBEUIInterface() string { return iface } +// addDDPServiceGroup records the DDP services an optional subsystem +// contributes, keyed by its status-unit name, so buildHooks can wrap them in a +// ddpServiceHook once the router exists. Empty groups are ignored so a +// disabled subsystem registers no hook. +func (s *Supervisor) addDDPServiceGroup(name string, svcs ...service.Service) { + if len(svcs) == 0 { + return + } + if _, ok := s.ddpServiceGroups[name]; !ok { + s.ddpServiceOrder = append(s.ddpServiceOrder, name) + } + s.ddpServiceGroups[name] = append(s.ddpServiceGroups[name], svcs...) +} + +// ddpServiceEnabled reports the configured-enabled flag for a DDP subsystem +// unit, used when registering its hook so the dashboard shows the right +// enabled state. +func (s *Supervisor) ddpServiceEnabled(name string) bool { + switch name { + case "MacIP": + return s.cfg.MacIPEnabled + case "IPXGW": + return s.cfg.IPXGWEnabled + case "AFP": + return s.model.AFP.Enabled + default: + return true + } +} + +// buildDDPServiceHooks wraps each recorded DDP service group in a +// ddpServiceHook and registers it as a restartable unit. It re-Sets the unit's +// status to KindHook (preserving the enriched properties registered earlier) +// so the dashboard surfaces start/stop/restart controls. The router-set no +// longer force-toggles these services, so their running flag now tracks the +// hook lifecycle. +func (s *Supervisor) buildDDPServiceHooks() { + for _, name := range s.ddpServiceOrder { + h := newDDPServiceHook(s.router, s.ddpServiceGroups[name]) + if h == nil { + continue + } + s.hooks[name] = h + s.order = append(s.order, name) + s.promoteUnitToHook(name, s.ddpServiceEnabled(name)) + } +} + +// promoteUnitToHook re-publishes an already-registered status unit as a +// KindHook (so the UI shows lifecycle controls) while preserving its binding, +// properties, and other detail. The unit starts not-running; the hook +// lifecycle sets the running flag. +func (s *Supervisor) promoteUnitToHook(name string, enabled bool) { + for _, u := range s.reg.Snapshot() { + if u.Name != name { + continue + } + u.Kind = status.KindHook + u.Enabled = enabled + u.Running = false + s.reg.Set(u) + return + } +} + // addHook records a standalone hook as a named, restartable unit. func (s *Supervisor) addHook(name string, h hook, enabled bool, dependsOn []string) { if h == nil { @@ -766,7 +849,7 @@ func (s *Supervisor) refreshMacIPStatus() { } s.reg.Set(status.Unit{ Name: "MacIP", - Kind: status.KindService, + Kind: status.KindHook, Enabled: s.cfg.MacIPEnabled, Running: running, Binding: binding, diff --git a/internal/app/supervisor_lifecycle.go b/internal/app/supervisor_lifecycle.go index 10ee63f..30175b1 100644 --- a/internal/app/supervisor_lifecycle.go +++ b/internal/app/supervisor_lifecycle.go @@ -23,7 +23,6 @@ func (s *Supervisor) Start(ctx context.Context) error { } netlog.Info("[SUP] router away!") s.reg.SetRunning("Router", true) - s.markServiceRunning(true) for _, name := range s.portNames { s.reg.SetRunning(name, true) } @@ -89,7 +88,6 @@ func (s *Supervisor) Stop() error { netlog.Warn("[SUP] router stop warning: %v", err) } s.reg.SetRunning("Router", false) - s.markServiceRunning(false) for _, name := range s.portNames { s.reg.SetRunning(name, false) } @@ -273,11 +271,3 @@ func (s *Supervisor) dependentsOf(name string) []string { } return out } - -// markServiceRunning flips the running flag on the DDP service units that -// live inside the router set (they share the router's lifecycle). -func (s *Supervisor) markServiceRunning(running bool) { - for _, name := range []string{"MacIP", "IPXGW", "AFP"} { - s.reg.SetRunning(name, running) - } -} diff --git a/service/webui/assets/app.js b/service/webui/assets/app.js index b0f4f6f..efd1d5e 100644 --- a/service/webui/assets/app.js +++ b/service/webui/assets/app.js @@ -55,8 +55,11 @@ function renderStatus(units) { if (u.shares && u.shares.length) detail += kv("Shares", u.shares.map((s) => s.name).join(", ")); - // Only standalone hooks (IPX/NetBEUI/NetBIOS/SMB/WebUI) are individually - // start/stoppable; ports and the router-set share the stack lifecycle. + // Hooks are individually start/stoppable: the transport/service hooks + // (IPX/NetBEUI/NetBIOS/SMB/WebUI) and the AppleTalk DDP subsystems + // (AFP/MacIP/IPXGW) the supervisor now drives via the router's runtime + // AddService/RemoveService. Ports and the core router-set share the stack + // lifecycle and so are not controllable. const controllable = u.kind === "hook"; const pending = pendingServices.has(u.name); const dis = pending ? " disabled" : ""; From 339110062b4403bc97fec0868c4907bcf82d9d0d Mon Sep 17 00:00:00 2001 From: pgodwin Date: Fri, 5 Jun 2026 22:57:36 +1000 Subject: [PATCH 15/23] webui: live per-port throughput on the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The metrics hub, SSE broadcaster, and expvar sink were all wired but nothing pushed samples, so the dashboard cards never showed live stats. Add the first producers: per-port rx/tx packet and byte counters for the AppleTalk ports (EtherTalk, LToUDP, TashTalk). Ports report traffic through a new optional port.TrafficMetered interface (SetTrafficObserver) so no port implementation depends on pkg/metrics — the data path only calls a plain observer func. The EtherTalk and LocalTalk ports emit Rx on inbound DDP delivery and Tx in Unicast/Broadcast/Multicast. The supervisor attaches a portMeter per port (carrying the status-unit name), accumulates counts, and publishes them once a second from the refresh ticker under "unit::" so each sample attributes to exactly one card. The earlier wrapper approach was abandoned: ports self-register into the router's tables as their raw selves, so a wrapping port would have caught inbound but missed almost all forwarded tx. The observer hook sits inside the port where every send actually originates. SPA: replace the fuzzy substring rate-matching with exact "unit::" prefix lookups, and render a per-card line — "↓ N pkt/s (bytes/s) · ↑ N pkt/s (bytes/s)" plus any gauge value (e.g. sessions) — hidden until live data arrives. Gauge frames are now consumed too. Remaining for full coverage (deferred): IPX/NetBEUI port metering (different frame type / observer point) and AFP/SMB active-connection gauges (needs session-table accessors). Co-Authored-By: Claude Opus 4.8 --- internal/app/metered_port.go | 82 +++++++++++++++++++++ internal/app/metered_port_test.go | 103 +++++++++++++++++++++++++++ internal/app/supervisor.go | 13 ++++ internal/app/supervisor_control.go | 1 + internal/app/supervisor_lifecycle.go | 33 +++++---- port/ethertalk/ethertalk.go | 30 ++++++++ port/localtalk/localtalk.go | 28 ++++++++ port/port.go | 24 +++++++ service/webui/assets/app.js | 63 +++++++++++----- 9 files changed, 347 insertions(+), 30 deletions(-) create mode 100644 internal/app/metered_port.go create mode 100644 internal/app/metered_port_test.go diff --git a/internal/app/metered_port.go b/internal/app/metered_port.go new file mode 100644 index 0000000..4c96cc1 --- /dev/null +++ b/internal/app/metered_port.go @@ -0,0 +1,82 @@ +package app + +import ( + "sync/atomic" + + "github.com/ObsoleteMadness/ClassicStack/pkg/metrics" + "github.com/ObsoleteMadness/ClassicStack/port" +) + +// portMeter accumulates per-port rx/tx packet and byte counts from a port's +// TrafficObserver and publishes them to the metrics hub. One meter is created +// per metered port; the supervisor's refresh ticker calls publish() each +// second so the SSE broadcaster can derive per-second rates. +// +// Ports report traffic through the optional port.TrafficMetered interface, so +// no port implementation depends on pkg/metrics — the data path only calls a +// plain observer func, and the metrics wiring lives here in internal/app. +type portMeter struct { + unit string + + rxPackets atomic.Int64 + rxBytes atomic.Int64 + txPackets atomic.Int64 + txBytes atomic.Int64 +} + +// newPortMeter returns a meter publishing under the given status-unit name +// (e.g. "EtherTalk", "LToUDP", "TashTalk"). +func newPortMeter(unit string) *portMeter { + return &portMeter{unit: unit} +} + +// observe is the port.TrafficObserver installed on the port; it runs on the +// data path so it only does atomic adds. +func (m *portMeter) observe(dir port.Direction, bytes int) { + switch dir { + case port.Rx: + m.rxPackets.Add(1) + m.rxBytes.Add(int64(bytes)) + case port.Tx: + m.txPackets.Add(1) + m.txBytes.Add(int64(bytes)) + } +} + +// publish pushes the current counter totals to the metrics hub under the +// "unit::" namespace the dashboard reads. +func (m *portMeter) publish() { + pushUnitCounter(m.unit, "rx.packets", m.rxPackets.Load()) + pushUnitCounter(m.unit, "rx.bytes", m.rxBytes.Load()) + pushUnitCounter(m.unit, "tx.packets", m.txPackets.Load()) + pushUnitCounter(m.unit, "tx.bytes", m.txBytes.Load()) +} + +// attachPortMeter installs a meter on p when it supports traffic metering, +// returning the meter so the supervisor can publish it each tick. Ports that +// do not implement port.TrafficMetered (e.g. test ports) yield a nil meter and +// simply report no throughput. +func attachPortMeter(unit string, p port.Port) *portMeter { + tm, ok := p.(port.TrafficMetered) + if !ok { + return nil + } + m := newPortMeter(unit) + tm.SetTrafficObserver(m.observe) + return m +} + +// pushUnitCounter publishes a counter sample under the "unit::" +// namespace shared with the dashboard. +func pushUnitCounter(unit, metric string, value int64) { + metrics.Push(metrics.Sample{ + Name: unitMetricName(unit, metric), + Value: value, + Kind: metrics.KindCounter, + }) +} + +// unitMetricName builds the namespaced metric name the SPA matches per card. +func unitMetricName(unit, metric string) string { + return "unit:" + unit + ":" + metric +} diff --git a/internal/app/metered_port_test.go b/internal/app/metered_port_test.go new file mode 100644 index 0000000..62b52b5 --- /dev/null +++ b/internal/app/metered_port_test.go @@ -0,0 +1,103 @@ +package app + +import ( + "testing" + + "github.com/ObsoleteMadness/ClassicStack/pkg/metrics" + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" +) + +// fakeMeteredPort is a minimal port.Port that also implements +// port.TrafficMetered so attachPortMeter installs an observer it can drive. +type fakeMeteredPort struct { + obs port.TrafficObserver +} + +func (f *fakeMeteredPort) ShortString() string { return "fake" } +func (f *fakeMeteredPort) Start(port.RouterHooks) error { return nil } +func (f *fakeMeteredPort) Stop() error { return nil } +func (f *fakeMeteredPort) Unicast(uint16, uint8, ddp.Datagram) {} +func (f *fakeMeteredPort) Broadcast(ddp.Datagram) {} +func (f *fakeMeteredPort) Multicast([]byte, ddp.Datagram) {} +func (f *fakeMeteredPort) SetNetworkRange(uint16, uint16) error { return nil } +func (f *fakeMeteredPort) Network() uint16 { return 0 } +func (f *fakeMeteredPort) Node() uint8 { return 0 } +func (f *fakeMeteredPort) NetworkMin() uint16 { return 0 } +func (f *fakeMeteredPort) NetworkMax() uint16 { return 0 } +func (f *fakeMeteredPort) ExtendedNetwork() bool { return false } + +func (f *fakeMeteredPort) SetTrafficObserver(obs port.TrafficObserver) { f.obs = obs } + +// plainPort implements port.Port but NOT port.TrafficMetered, to verify +// attachPortMeter returns nil for un-meterable ports. +type plainPort struct{} + +func (plainPort) ShortString() string { return "plain" } +func (plainPort) Start(port.RouterHooks) error { return nil } +func (plainPort) Stop() error { return nil } +func (plainPort) Unicast(uint16, uint8, ddp.Datagram) {} +func (plainPort) Broadcast(ddp.Datagram) {} +func (plainPort) Multicast([]byte, ddp.Datagram) {} +func (plainPort) SetNetworkRange(uint16, uint16) error { return nil } +func (plainPort) Network() uint16 { return 0 } +func (plainPort) Node() uint8 { return 0 } +func (plainPort) NetworkMin() uint16 { return 0 } +func (plainPort) NetworkMax() uint16 { return 0 } +func (plainPort) ExtendedNetwork() bool { return false } + +// collectSink records every sample written to it for assertion. +type collectSink struct{ samples map[string]metrics.Sample } + +func (s *collectSink) Write(sample metrics.Sample) { s.samples[sample.Name] = sample } + +// TestPortMeterCountsTxRx verifies the meter accumulates sent and received +// traffic from the observer and publishes the namespaced counter metrics the +// dashboard reads. +func TestPortMeterCountsTxRx(t *testing.T) { + sink := &collectSink{samples: map[string]metrics.Sample{}} + metrics.Default.AddSink(sink) + + p := &fakeMeteredPort{} + m := attachPortMeter("EtherTalk", p) + if m == nil { + t.Fatal("attachPortMeter returned nil for a TrafficMetered port") + } + if p.obs == nil { + t.Fatal("observer was not installed on the port") + } + + // Two sent datagrams (30 + 40 wire bytes) and one received (18 wire bytes). + p.obs(port.Tx, 30) + p.obs(port.Tx, 40) + p.obs(port.Rx, 18) + + m.publish() + + want := map[string]int64{ + "unit:EtherTalk:tx.packets": 2, + "unit:EtherTalk:tx.bytes": 70, + "unit:EtherTalk:rx.packets": 1, + "unit:EtherTalk:rx.bytes": 18, + } + for name, v := range want { + got, ok := sink.samples[name] + if !ok { + t.Fatalf("missing sample %q", name) + } + if got.Value != v { + t.Fatalf("%s = %d, want %d", name, got.Value, v) + } + if got.Kind != metrics.KindCounter { + t.Fatalf("%s kind = %v, want counter", name, got.Kind) + } + } +} + +// TestAttachPortMeterPlainPort verifies a port without TrafficMetered yields a +// nil meter (it simply reports no throughput). +func TestAttachPortMeterPlainPort(t *testing.T) { + if m := attachPortMeter("X", &plainPort{}); m != nil { + t.Fatal("attachPortMeter should return nil for a non-metered port") + } +} diff --git a/internal/app/supervisor.go b/internal/app/supervisor.go index 55fc228..6a8bbc9 100644 --- a/internal/app/supervisor.go +++ b/internal/app/supervisor.go @@ -48,6 +48,7 @@ type Supervisor struct { router *router.Router ports []port.Port portNames []string // status-unit name per entry in ports + meters []*portMeter // per-port traffic meters (nil entries skipped) hooks map[string]hook // name -> standalone hook (ipx, netbeui, …) order []string // hook start order; stop walks it in reverse started bool @@ -207,6 +208,18 @@ func (s *Supervisor) buildPorts() ([]port.Port, []closer, error) { for _, snk := range attachCaptureSinks(ports, cfg.Capture) { sinks = append(sinks, snk) } + + // Attach a traffic meter to each port so the dashboard gets live per-port + // rx/tx throughput. Ports report via the optional port.TrafficMetered + // interface, so this neither wraps the port nor disturbs the concrete-type + // assertions capture-sink attachment relies on. s.portNames is parallel to + // ports (set by registerPortStatus above), giving each meter its unit name. + s.meters = nil + for i := range ports { + if m := attachPortMeter(s.portNames[i], ports[i]); m != nil { + s.meters = append(s.meters, m) + } + } return ports, sinks, nil } diff --git a/internal/app/supervisor_control.go b/internal/app/supervisor_control.go index cf61b90..ece42cc 100644 --- a/internal/app/supervisor_control.go +++ b/internal/app/supervisor_control.go @@ -118,6 +118,7 @@ func (s *Supervisor) adoptFrom(other *Supervisor) { s.router = other.router s.ports = other.ports s.portNames = other.portNames + s.meters = other.meters s.hooks = other.hooks s.order = other.order s.captureSinks = other.captureSinks diff --git a/internal/app/supervisor_lifecycle.go b/internal/app/supervisor_lifecycle.go index 30175b1..2952c18 100644 --- a/internal/app/supervisor_lifecycle.go +++ b/internal/app/supervisor_lifecycle.go @@ -42,28 +42,37 @@ func (s *Supervisor) Start(ctx context.Context) error { s.alreadyRunning = nil s.started = true - // Periodically refresh live dashboard counts (MacIP leases/sessions), - // which change at runtime and so cannot be captured once at wire time. - if s.macIP != nil { - stop := make(chan struct{}) - s.statusTickerStop = stop - go s.runStatusRefresh(stop) - } + // Drive the periodic refresher: it publishes per-port throughput metrics + // every second (the SSE broadcaster derives per-second rates from + // successive counter values) and refreshes live MacIP lease/session + // counts every few seconds. Always run it — metered ports exist whenever + // any transport is configured. + stop := make(chan struct{}) + s.statusTickerStop = stop + go s.runStatusRefresh(stop) return nil } -// runStatusRefresh re-publishes time-varying dashboard status (MacIP live -// counts) on a fixed cadence until stop is closed. It does not hold s.mu — it -// reads stable post-Start fields and the independently-locked status registry. +// runStatusRefresh publishes time-varying dashboard data until stop is closed: +// per-port traffic counters each tick (1s, matching the rate window) and the +// MacIP live status every fifth tick. It does not hold s.mu — it reads stable +// post-Start fields and the independently-locked status registry/metrics hub. func (s *Supervisor) runStatusRefresh(stop chan struct{}) { - t := time.NewTicker(5 * time.Second) + t := time.NewTicker(time.Second) defer t.Stop() + tick := 0 for { select { case <-stop: return case <-t.C: - s.refreshMacIPStatus() + for _, m := range s.meters { + m.publish() + } + if tick%5 == 0 && s.macIP != nil { + s.refreshMacIPStatus() + } + tick++ } } } diff --git a/port/ethertalk/ethertalk.go b/port/ethertalk/ethertalk.go index c0dc236..66d09aa 100644 --- a/port/ethertalk/ethertalk.go +++ b/port/ethertalk/ethertalk.go @@ -76,6 +76,32 @@ type Port struct { wg sync.WaitGroup stop chan struct{} + + obsMu sync.RWMutex + trafficObs port.TrafficObserver +} + +// ddpHeaderBytes is the DDP long-header overhead added to a datagram's data +// length to estimate on-wire bytes for traffic metering. +const ddpHeaderBytes = 13 + +// SetTrafficObserver installs an observer notified of each datagram sent or +// received, for dashboard throughput metrics (port.TrafficMetered). +func (p *Port) SetTrafficObserver(obs port.TrafficObserver) { + p.obsMu.Lock() + p.trafficObs = obs + p.obsMu.Unlock() +} + +// observeTraffic reports one datagram's direction and estimated wire size to +// the installed observer, if any. +func (p *Port) observeTraffic(dir port.Direction, d ddp.Datagram) { + p.obsMu.RLock() + obs := p.trafficObs + p.obsMu.RUnlock() + if obs != nil { + obs(dir, len(d.Data)+ddpHeaderBytes) + } } func New(hwAddr []byte, seedNetworkMin, seedNetworkMax, desiredNetwork uint16, desiredNode uint8, seedZoneNames [][]byte) *Port { @@ -499,12 +525,14 @@ func (p *Port) InboundFrame(frame []byte) { bytes.Equal(dstMAC, elapBroadcast) || (bytes.Equal(dstMAC[0:5], elapMCprefix) && dstMAC[5] <= 0xFC) { netlog.LogDatagramInbound(p.Network(), p.Node(), d, p) + p.observeTraffic(port.Rx, d) p.router.Inbound(d, p) } } } func (p *Port) Unicast(network uint16, node uint8, d ddp.Datagram) { + p.observeTraffic(port.Tx, d) netlog.LogDatagramUnicast(network, node, d, p) key := [2]uint16{network, uint16(node)} p.tableMu.Lock() @@ -526,6 +554,7 @@ func (p *Port) Unicast(network uint16, node uint8, d ddp.Datagram) { } func (p *Port) Broadcast(d ddp.Datagram) { + p.observeTraffic(port.Tx, d) if d.DestinationNetwork != 0 || d.DestinationNode != 0xFF { d.DestinationNetwork = 0 d.DestinationNode = 0xFF @@ -535,6 +564,7 @@ func (p *Port) Broadcast(d ddp.Datagram) { } func (p *Port) Multicast(zoneName []byte, d ddp.Datagram) { + p.observeTraffic(port.Tx, d) netlog.LogDatagramMulticast(zoneName, d, p) // Use the EtherTalk-wide broadcast (09:00:07:FF:FF:FF) rather than the // zone-specific multicast. All Phase 2 nodes must accept this address, whereas diff --git a/port/localtalk/localtalk.go b/port/localtalk/localtalk.go index 64c53d8..8a25a94 100644 --- a/port/localtalk/localtalk.go +++ b/port/localtalk/localtalk.go @@ -52,6 +52,30 @@ type Port struct { sendFrameFunc func(frame []byte) error linkManager LinkManager captureSink capture.Sink + trafficObs port.TrafficObserver +} + +// ddpHeaderBytes is the DDP long-header overhead added to a datagram's data +// length to estimate on-wire bytes for traffic metering. +const ddpHeaderBytes = 13 + +// SetTrafficObserver installs an observer notified of each datagram sent or +// received, for dashboard throughput metrics (port.TrafficMetered). +func (p *Port) SetTrafficObserver(obs port.TrafficObserver) { + p.mu.Lock() + p.trafficObs = obs + p.mu.Unlock() +} + +// observeTraffic reports one datagram's direction and estimated wire size to +// the installed observer, if any. +func (p *Port) observeTraffic(dir port.Direction, d ddp.Datagram) { + p.mu.Lock() + obs := p.trafficObs + p.mu.Unlock() + if obs != nil { + obs(dir, len(d.Data)+ddpHeaderBytes) + } } // SetCaptureSink installs (or clears, if nil) a pcap-style capture @@ -339,6 +363,7 @@ func (p *Port) InboundFrame(frame []byte) { netlog.Debug("%s failed to parse short-header AppleTalk datagram from LocalTalk frame: %v", p.ShortString(), err) } else { netlog.LogDatagramInbound(p.Network(), p.Node(), d, p) + p.observeTraffic(port.Rx, d) p.router.Inbound(d, p) } case llapAppleTalkLongHeader: @@ -347,6 +372,7 @@ func (p *Port) InboundFrame(frame []byte) { netlog.Debug("%s failed to parse long-header AppleTalk datagram from LocalTalk frame: %v", p.ShortString(), err) } else { netlog.LogDatagramInbound(p.Network(), p.Node(), d, p) + p.observeTraffic(port.Rx, d) p.router.Inbound(d, p) } case llapENQ: @@ -372,6 +398,7 @@ func (p *Port) InboundFrame(frame []byte) { } func (p *Port) Unicast(network uint16, node uint8, d ddp.Datagram) { + p.observeTraffic(port.Tx, d) if p.linkManager != nil { p.linkManager.TransmitUnicast(p, network, node, d) return @@ -397,6 +424,7 @@ func (p *Port) Unicast(network uint16, node uint8, d ddp.Datagram) { } func (p *Port) Broadcast(d ddp.Datagram) { + p.observeTraffic(port.Tx, d) if p.linkManager != nil { p.linkManager.TransmitBroadcast(p, d) return diff --git a/port/port.go b/port/port.go index 6956023..f814ba3 100644 --- a/port/port.go +++ b/port/port.go @@ -22,6 +22,30 @@ type Port interface { ExtendedNetwork() bool } +// Direction labels a metered traffic observation as received or transmitted. +type Direction int + +const ( + // Rx is traffic received by the port (handed up to the router). + Rx Direction = iota + // Tx is traffic the port sent (unicast/broadcast/multicast). + Tx +) + +// TrafficObserver is notified of each datagram a port sends or receives, with +// the on-wire byte estimate, so a front-end can derive per-port throughput. +// It must be safe for concurrent use and fast (it runs on the data path). +type TrafficObserver func(dir Direction, bytes int) + +// TrafficMetered is the optional interface a port implements to report rx/tx +// traffic. The supervisor injects an observer that publishes per-port metrics; +// ports that do not implement it simply report no throughput. Keeping it out +// of the core Port interface means transports that never need metering (test +// ports, future raw transports) need no stub. +type TrafficMetered interface { + SetTrafficObserver(obs TrafficObserver) +} + // BridgeConfigurable is implemented by ports that participate in an // Ethernet-style bridge and need operator control over bridge mode and // host-MAC synthesis. It is optional — callers type-assert on a Port to diff --git a/service/webui/assets/app.js b/service/webui/assets/app.js index efd1d5e..f594f1e 100644 --- a/service/webui/assets/app.js +++ b/service/webui/assets/app.js @@ -9,7 +9,8 @@ const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.from(document.querySelectorAll(sel)); let currentConfig = null; // last-loaded config model (edited in place) -let latestRates = {}; // metric name -> per-second rate from SSE +let latestRates = {}; // metric name -> per-second rate from SSE (counters) +let latestGauges = {}; // metric name -> latest absolute value from SSE (gauges) // pendingServices holds the names of services with an in-flight start/stop/ // restart action. While pending, the card shows a spinner and its action // buttons are disabled so the operator can't double-fire a transition. @@ -84,7 +85,7 @@ function renderStatus(units) {

${indicator}${esc(u.name)}

${stateLine}
${detail} -
+
${controls}
`; card.querySelectorAll("[data-action]").forEach((btn) => @@ -122,10 +123,11 @@ function startStats() { try { const frame = JSON.parse(ev.data); latestRates = frame.rates || {}; + latestGauges = frame.gauges || {}; $$("[data-metric-for]").forEach((el) => { - const name = el.getAttribute("data-metric-for"); - const total = sumRatesForUnit(name); - if (total !== null) el.textContent = `${total}/s`; + const text = metricsForUnit(el.getAttribute("data-metric-for")); + el.textContent = text; + el.classList.toggle("hidden", text === ""); }); } catch (_) {} }; @@ -134,19 +136,44 @@ function startStats() { }; } -// sumRatesForUnit matches metric names that embed the unit name. Naming is -// best-effort until services publish per-unit metric labels. -function sumRatesForUnit(unit) { - const key = unit.toLowerCase().replace(/[^a-z0-9]/g, ""); - let sum = 0; - let found = false; - Object.keys(latestRates).forEach((m) => { - if (m.toLowerCase().replace(/[^a-z0-9]/g, "").includes(key)) { - sum += latestRates[m]; - found = true; - } - }); - return found ? sum : null; +// Producers publish samples named "unit::" so each sample +// attributes to exactly one dashboard card. unitMetric reads the per-second +// rate (counters) or latest value (gauges) for one such metric, or 0. +function unitRate(unit, metric) { + return latestRates[`unit:${unit}:${metric}`] || 0; +} +function unitGauge(unit, metric) { + return latestGauges[`unit:${unit}:${metric}`]; +} + +// metricsForUnit renders a one-line live summary for a card: rx/tx throughput +// for ports (packets and bytes per second) plus any gauge value the unit +// publishes (e.g. active sessions). Returns "" when the unit has no live +// metrics, so the line is hidden rather than showing a bare "0/s". +function metricsForUnit(unit) { + const parts = []; + const rxp = unitRate(unit, "rx.packets"); + const txp = unitRate(unit, "tx.packets"); + const rxb = unitRate(unit, "rx.bytes"); + const txb = unitRate(unit, "tx.bytes"); + const hasTraffic = [`unit:${unit}:rx.packets`, `unit:${unit}:tx.packets`].some( + (n) => n in latestRates + ); + if (hasTraffic) { + parts.push(`↓ ${rxp} pkt/s (${fmtBytes(rxb)}/s)`); + parts.push(`↑ ${txp} pkt/s (${fmtBytes(txb)}/s)`); + } + const sessions = unitGauge(unit, "sessions"); + if (sessions !== undefined) parts.push(`${sessions} session${sessions === 1 ? "" : "s"}`); + return parts.join(" · "); +} + +// fmtBytes renders a byte count as B/KB/MB with one decimal for the larger +// units, matching the compact per-second throughput display. +function fmtBytes(n) { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; } // ---- logs ---- From 398f7f660c3be84a8b6f30f875fd3e5f77b7a045 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Fri, 5 Jun 2026 23:15:29 +1000 Subject: [PATCH 16/23] webui: meter IPX and NetBEUI port throughput too Extend the per-port traffic metering added for the AppleTalk ports to the standalone IPX and NetBEUI ports so their dashboard cards also show live rx/tx packet and byte rates. The IPX/NetBEUI portImpls gain a SetTrafficObserver (an optional method, not added to their Port interfaces, so the existing test fakes need no stub) and emit Rx on inbound decode and Tx in Send. Their enabled hooks forward SetTrafficObserver to the underlying port; the supervisor installs a portMeter through that optional interface and publishes it on the same one-second ticker as the AppleTalk ports. Disabled-protocol hooks and ports that do not meter are skipped. Co-Authored-By: Claude Opus 4.8 --- internal/app/ipx_enabled.go | 10 ++++++++++ internal/app/metered_port.go | 7 +++++++ internal/app/netbeui_enabled.go | 10 ++++++++++ internal/app/supervisor.go | 22 ++++++++++++++++++++++ port/ipx/port.go | 25 +++++++++++++++++++++++++ port/netbeui/port.go | 26 ++++++++++++++++++++++++++ 6 files changed, 100 insertions(+) diff --git a/internal/app/ipx_enabled.go b/internal/app/ipx_enabled.go index 313f254..fef4b32 100644 --- a/internal/app/ipx_enabled.go +++ b/internal/app/ipx_enabled.go @@ -11,6 +11,7 @@ import ( "github.com/ObsoleteMadness/ClassicStack/capture" "github.com/ObsoleteMadness/ClassicStack/netlog" "github.com/ObsoleteMadness/ClassicStack/pkg/hwaddr" + "github.com/ObsoleteMadness/ClassicStack/port" "github.com/ObsoleteMadness/ClassicStack/port/ipx" "github.com/ObsoleteMadness/ClassicStack/port/rawlink" routeripx "github.com/ObsoleteMadness/ClassicStack/router/ipx" @@ -34,6 +35,15 @@ type ipxHookEnabled struct { func (h *ipxHookEnabled) Router() routeripx.Router { return h.router } func (h *ipxHookEnabled) SAP() *ipxsvc.SAPService { return h.sap } +// SetTrafficObserver forwards traffic metering to the underlying IPX port when +// it supports it, so the supervisor can publish per-port throughput +// (port.TrafficMetered). +func (h *ipxHookEnabled) SetTrafficObserver(obs port.TrafficObserver) { + if tm, ok := h.port.(port.TrafficMetered); ok { + tm.SetTrafficObserver(obs) + } +} + func (h *ipxHookEnabled) Start(ctx context.Context) error { if h.port != nil { // (Re)open the capture sink before the port starts reading so no diff --git a/internal/app/metered_port.go b/internal/app/metered_port.go index 4c96cc1..1b5bfe4 100644 --- a/internal/app/metered_port.go +++ b/internal/app/metered_port.go @@ -61,6 +61,13 @@ func attachPortMeter(unit string, p port.Port) *portMeter { if !ok { return nil } + return attachMeterTo(unit, tm) +} + +// attachMeterTo installs a meter on any value that supports traffic metering. +// It serves the standalone-protocol ports (IPX, NetBEUI) whose interfaces do +// not embed port.Port but expose SetTrafficObserver as an optional method. +func attachMeterTo(unit string, tm port.TrafficMetered) *portMeter { m := newPortMeter(unit) tm.SetTrafficObserver(m.observe) return m diff --git a/internal/app/netbeui_enabled.go b/internal/app/netbeui_enabled.go index 43d1a3b..108b99e 100644 --- a/internal/app/netbeui_enabled.go +++ b/internal/app/netbeui_enabled.go @@ -10,6 +10,7 @@ import ( "github.com/ObsoleteMadness/ClassicStack/capture" "github.com/ObsoleteMadness/ClassicStack/netlog" "github.com/ObsoleteMadness/ClassicStack/pkg/hwaddr" + "github.com/ObsoleteMadness/ClassicStack/port" "github.com/ObsoleteMadness/ClassicStack/port/netbeui" "github.com/ObsoleteMadness/ClassicStack/port/rawlink" ) @@ -25,6 +26,15 @@ type netbeuiHookEnabled struct { sink *capture.PcapSink } +// SetTrafficObserver forwards traffic metering to the underlying NetBEUI port +// when it supports it, so the supervisor can publish per-port throughput +// (port.TrafficMetered). +func (h *netbeuiHookEnabled) SetTrafficObserver(obs port.TrafficObserver) { + if tm, ok := h.port.(port.TrafficMetered); ok { + tm.SetTrafficObserver(obs) + } +} + func (h *netbeuiHookEnabled) Start(_ context.Context) error { if h.port != nil { if h.capturePath != "" && h.sink == nil { diff --git a/internal/app/supervisor.go b/internal/app/supervisor.go index 6a8bbc9..4fa6eb4 100644 --- a/internal/app/supervisor.go +++ b/internal/app/supervisor.go @@ -484,9 +484,31 @@ func (s *Supervisor) buildHooks() error { s.registerSMBStatus(cfg.SMBEnabled) // enrich the SMB unit with shares/identity } s.registerTransportBindings(ipxHook, nbeuiHook, smbHook) + + // Meter IPX/NetBEUI port throughput for the dashboard. The hooks forward + // SetTrafficObserver to their underlying port when it supports metering; + // nil hooks (disabled protocols) are skipped. + s.attachHookMeter("IPX", ipxHook) + s.attachHookMeter("NetBEUI", nbeuiHook) return nil } +// attachHookMeter attaches a traffic meter to a transport hook that supports +// metering (port.TrafficMetered), recording it for periodic publishing. A nil +// hook or one whose port does not meter is skipped. +func (s *Supervisor) attachHookMeter(unit string, h any) { + if h == nil { + return + } + tm, ok := h.(port.TrafficMetered) + if !ok { + return + } + if m := attachMeterTo(unit, tm); m != nil { + s.meters = append(s.meters, m) + } +} + // registerTransportBindings records, for each transport-protocol hook, the // runtime bindings it contributes to NetBIOS (and SMB's direct-IPX path), so // the lifecycle can detach/reattach them when that protocol is stopped or diff --git a/port/ipx/port.go b/port/ipx/port.go index 22590cb..26b94af 100644 --- a/port/ipx/port.go +++ b/port/ipx/port.go @@ -15,6 +15,7 @@ import ( "github.com/ObsoleteMadness/ClassicStack/capture" "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port" "github.com/ObsoleteMadness/ClassicStack/port/rawlink" protocol "github.com/ObsoleteMadness/ClassicStack/protocol/ipx" ) @@ -94,6 +95,7 @@ type portImpl struct { mu sync.RWMutex cb DeliveryCallback cs capture.Sink + obs port.TrafficObserver link rawlink.RawLink // current rawlink; nil while stopped. dedupMu sync.Mutex @@ -207,6 +209,7 @@ func (p *portImpl) Send(d *protocol.Datagram) error { if err != nil { return err } + p.observeTraffic(port.Tx, len(payload)) switch p.framing { case FramingEthernetII: return p.sendEthernetII(d, payload) @@ -255,6 +258,27 @@ func (p *portImpl) SetCaptureSink(sink capture.Sink) { p.mu.Unlock() } +// SetTrafficObserver installs an observer notified of each IPX datagram sent +// or received, for dashboard throughput metrics (the supervisor type-asserts +// this optional method; it is not part of the Port interface so test fakes +// need not implement it). +func (p *portImpl) SetTrafficObserver(obs port.TrafficObserver) { + p.mu.Lock() + p.obs = obs + p.mu.Unlock() +} + +// observeTraffic reports one frame's direction and byte size to the installed +// observer, if any. +func (p *portImpl) observeTraffic(dir port.Direction, bytes int) { + p.mu.RLock() + obs := p.obs + p.mu.RUnlock() + if obs != nil { + obs(dir, bytes) + } +} + // readLoop is the single inbound reader. It demultiplexes by EtherType // / length / LLC SAP and hands the IPX body to deliver(). The link and // stop/done channels are passed in so the loop is bound to the Start @@ -369,5 +393,6 @@ func (p *portImpl) deliver(payload []byte) { if err != nil { return } + p.observeTraffic(port.Rx, len(payload)) cb(d) } diff --git a/port/netbeui/port.go b/port/netbeui/port.go index 9fbe67b..371a637 100644 --- a/port/netbeui/port.go +++ b/port/netbeui/port.go @@ -19,6 +19,7 @@ import ( "github.com/ObsoleteMadness/ClassicStack/capture" "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/port" "github.com/ObsoleteMadness/ClassicStack/port/rawlink" "github.com/ObsoleteMadness/ClassicStack/protocol/netbeui" ) @@ -115,6 +116,7 @@ type portImpl struct { hasSrc bool cb DeliveryCallback cs capture.Sink + obs port.TrafficObserver link rawlink.RawLink // current rawlink; nil while stopped. connsMu sync.RWMutex @@ -234,6 +236,27 @@ func (p *portImpl) SetCaptureSink(sink capture.Sink) { p.mu.Unlock() } +// SetTrafficObserver installs an observer notified of each NBF frame sent or +// received, for dashboard throughput metrics. It is an optional method the +// supervisor type-asserts, not part of the Port interface, so test fakes need +// not implement it. +func (p *portImpl) SetTrafficObserver(obs port.TrafficObserver) { + p.mu.Lock() + p.obs = obs + p.mu.Unlock() +} + +// observeTraffic reports one frame's direction and byte size to the installed +// observer, if any. +func (p *portImpl) observeTraffic(dir port.Direction, bytes int) { + p.mu.RLock() + obs := p.obs + p.mu.RUnlock() + if obs != nil { + obs(dir, bytes) + } +} + // LLC unnumbered frame control values. const ( llcControlSABME = 0x7F // Set Asynchronous Balanced Mode Extended (P=1) @@ -371,6 +394,7 @@ func (p *portImpl) Send(dstMAC [6]byte, frame *netbeui.Frame) error { if err != nil { return err } + p.observeTraffic(port.Tx, len(body)) // Session-layer commands (SESSION_INITIALIZE, DATA_*, SESSION_CONFIRM, etc.) // use LLC Type-2 I-framing when a connection is established. Non-session // frames (NAME_RECOGNIZED, ADD_NAME_RESPONSE, DATAGRAM, etc.) always use @@ -501,6 +525,7 @@ func (p *portImpl) handleFrame(raw []byte) { if err != nil { return } + p.observeTraffic(port.Rx, len(nbfPayload)) cb(srcMAC, dstMAC, decoded) } return @@ -561,6 +586,7 @@ func (p *portImpl) handleFrame(raw []byte) { if err != nil { return } + p.observeTraffic(port.Rx, len(nbfPayload)) cb(srcMAC, dstMAC, decoded) } } From fcf0c9699a82968de068573d44de904eb4aada40 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Fri, 5 Jun 2026 23:47:18 +1000 Subject: [PATCH 17/23] ci: fix golangci-lint failures on the web UI branch The PR CI golangci-lint step (run with --build-tags=all on Linux) flagged: - errcheck: unchecked os.Remove / tmp.Close / key.Close returns in config/save.go (atomicWrite) and pkg/serialport/serialport_windows.go. - errorlint: direct == / != comparisons against sentinel errors in pkg/control/control_test.go and serialport_windows.go; switched to errors.Is. - unlambda: redundant func() wrapper around over_tcp.NewTransport (which already returns netbios.Transport) in internal/app/netbios_enabled.go. - ineffassign: dead final n += copy(...) in service/smb/command_fs_search.go. No behaviour change; all builds and tests still pass. Co-Authored-By: Claude Opus 4.8 --- config/save.go | 6 +++--- internal/app/netbios_enabled.go | 4 +--- pkg/control/control_test.go | 11 ++++++----- pkg/serialport/serialport_windows.go | 5 +++-- service/smb/command_fs_search.go | 5 ++--- 5 files changed, 15 insertions(+), 16 deletions(-) diff --git a/config/save.go b/config/save.go index bca15cd..8dccd9e 100644 --- a/config/save.go +++ b/config/save.go @@ -83,14 +83,14 @@ func atomicWrite(path string, data []byte) error { return err } tmpName := tmp.Name() - defer os.Remove(tmpName) // no-op once renamed + defer func() { _ = os.Remove(tmpName) }() // no-op once renamed if _, err := tmp.Write(data); err != nil { - tmp.Close() + _ = tmp.Close() return err } if err := tmp.Sync(); err != nil { - tmp.Close() + _ = tmp.Close() return err } if err := tmp.Close(); err != nil { diff --git a/internal/app/netbios_enabled.go b/internal/app/netbios_enabled.go index 3a24663..e7148f5 100644 --- a/internal/app/netbios_enabled.go +++ b/internal/app/netbios_enabled.go @@ -74,9 +74,7 @@ func netbiosTransportBuilders(cfg NetBIOSConfig) []netbiosNamedBuilder { for _, name := range cfg.Transports { switch name { case "tcp": - out = append(out, netbiosNamedBuilder{name: "tcp", build: func() netbios.Transport { - return over_tcp.NewTransport() - }}) + out = append(out, netbiosNamedBuilder{name: "tcp", build: over_tcp.NewTransport}) case "netbeui": if cfg.NetBEUI != nil && cfg.NetBEUI.Port() != nil { nb := cfg.NetBEUI diff --git a/pkg/control/control_test.go b/pkg/control/control_test.go index d04efcc..a3175e7 100644 --- a/pkg/control/control_test.go +++ b/pkg/control/control_test.go @@ -2,6 +2,7 @@ package control import ( "context" + "errors" "testing" "time" @@ -82,10 +83,10 @@ func TestExtMapDelegates(t *testing.T) { func TestExtMapWithoutSupervisor(t *testing.T) { p := New(Deps{Config: &fakeModel{}}) - if _, _, err := p.ExtMap(); err != ErrNoSupervisor { + if _, _, err := p.ExtMap(); !errors.Is(err, ErrNoSupervisor) { t.Errorf("ExtMap without supervisor = %v, want ErrNoSupervisor", err) } - if _, err := p.SaveExtMap(nil); err != ErrNoSupervisor { + if _, err := p.SaveExtMap(nil); !errors.Is(err, ErrNoSupervisor) { t.Errorf("SaveExtMap without supervisor = %v, want ErrNoSupervisor", err) } } @@ -147,7 +148,7 @@ func TestSaveClearsDirty(t *testing.T) { func TestSaveWithoutPath(t *testing.T) { p := New(Deps{Config: &fakeModel{}}) - if _, err := p.Save(); err != ErrNoConfigPath { + if _, err := p.Save(); !errors.Is(err, ErrNoConfigPath) { t.Errorf("Save without path = %v, want ErrNoConfigPath", err) } } @@ -184,10 +185,10 @@ func TestLogHistoryDefaultsToGlobal(t *testing.T) { func TestDiagnosticsFallback(t *testing.T) { p := New(Deps{Config: &fakeModel{}}) - if _, err := p.Diagnostics().ListZones(context.Background()); err != ErrDiagUnavailable { + if _, err := p.Diagnostics().ListZones(context.Background()); !errors.Is(err, ErrDiagUnavailable) { t.Errorf("unset diagnostics = %v, want ErrDiagUnavailable", err) } - if _, err := p.Diagnostics().MacIPLeases(context.Background()); err != ErrDiagUnavailable { + if _, err := p.Diagnostics().MacIPLeases(context.Background()); !errors.Is(err, ErrDiagUnavailable) { t.Errorf("unset MacIPLeases = %v, want ErrDiagUnavailable", err) } } diff --git a/pkg/serialport/serialport_windows.go b/pkg/serialport/serialport_windows.go index 0413f51..4c2e08a 100644 --- a/pkg/serialport/serialport_windows.go +++ b/pkg/serialport/serialport_windows.go @@ -3,6 +3,7 @@ package serialport import ( + "errors" "sort" "strconv" "strings" @@ -20,12 +21,12 @@ func list() ([]Info, error) { key, err := registry.OpenKey(registry.LOCAL_MACHINE, `HARDWARE\DEVICEMAP\SERIALCOMM`, registry.QUERY_VALUE) if err != nil { // No serial ports present: the key is absent. Treat as empty. - if err == registry.ErrNotExist { + if errors.Is(err, registry.ErrNotExist) { return nil, nil } return nil, err } - defer key.Close() + defer func() { _ = key.Close() }() names, err := key.ReadValueNames(0) if err != nil { diff --git a/service/smb/command_fs_search.go b/service/smb/command_fs_search.go index ade020c..e4e65bd 100644 --- a/service/smb/command_fs_search.go +++ b/service/smb/command_fs_search.go @@ -283,9 +283,9 @@ func formatSearchFileName(name string) []byte { if ext != "" { out[n] = '.' n++ - n += copy(out[n:], ext) + copy(out[n:], ext) } - // Bytes n..12 are already zero from make(). + // Remaining bytes are already zero from make(). return out } @@ -474,7 +474,6 @@ func dosTimeDate(t time.Time) uint32 { return uint32(dosTime) | (uint32(dosDate) << 16) } - func parseTreeConnectShareName(req []byte) (string, bool) { bytesArea, ok := smbBytesArea(req) if !ok || len(bytesArea) == 0 { From cdbf596a07a2f11340d8770942cbe01ffd092216 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Sat, 6 Jun 2026 00:21:45 +1000 Subject: [PATCH 18/23] deps: bump Go to 1.25.11 and x/net to v0.55.0 to clear govulncheck CVEs The PR CI govulncheck step flagged stdlib CVEs (net/textproto, crypto/x509 at go1.23.4) and golang.org/x/net@v0.33.0 advisories (GO-2026-5025..5039). Bump the go directive/toolchain to 1.25.11 and x/net to v0.55.0 (pulling x/crypto, x/sys, x/sync, x/text forward via go mod tidy). govulncheck now reports no called vulnerabilities. The x/net bump updates goquery's transitive x/net/html parser, which changed how the macgarden client tests' hand-written fixtures parsed and broke the pagination-count assertions. Replace those fixtures with real HTML captured from macintoshgarden.org (testdata/category_antivirus_page{1,5}.html) and a loadCapturedPage helper that reroutes the captured root-relative links to the test server. The two affected tests now assert the live site's real structure (10 items/page, last page ?page=5 with 8 items, 58 total). Co-Authored-By: Claude Opus 4.8 --- go.mod | 14 +- go.sum | 28 +- scripts/ci/quality.sh | 47 +++ service/macgarden/client_test.go | 77 +++-- .../testdata/category_antivirus_page1.html | 320 ++++++++++++++++++ .../testdata/category_antivirus_page5.html | 246 ++++++++++++++ 6 files changed, 674 insertions(+), 58 deletions(-) create mode 100644 scripts/ci/quality.sh create mode 100644 service/macgarden/testdata/category_antivirus_page1.html create mode 100644 service/macgarden/testdata/category_antivirus_page5.html diff --git a/go.mod b/go.mod index 874ab2a..a1686d4 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/ObsoleteMadness/ClassicStack -go 1.23.0 - -toolchain go1.23.4 +go 1.25.11 require ( github.com/PuerkitoBio/goquery v1.10.0 @@ -12,8 +10,8 @@ require ( github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.3.4 github.com/pelletier/go-toml/v2 v2.2.4 - golang.org/x/net v0.33.0 - golang.org/x/sys v0.32.0 + golang.org/x/net v0.55.0 + golang.org/x/sys v0.45.0 modernc.org/sqlite v1.35.0 tailscale.com v1.64.2 ) @@ -40,10 +38,10 @@ require ( github.com/stretchr/testify v1.11.1 // indirect go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.31.0 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect modernc.org/libc v1.61.13 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 3f2ea6a..d526d46 100644 --- a/go.sum +++ b/go.sum @@ -74,29 +74,29 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/W golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= -golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -107,8 +107,8 @@ golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepC golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -118,15 +118,15 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= -golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= diff --git a/scripts/ci/quality.sh b/scripts/ci/quality.sh new file mode 100644 index 0000000..1903932 --- /dev/null +++ b/scripts/ci/quality.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +# quality.sh runs the static-analysis gates of the CI "Quality" job — vet, +# golangci-lint, govulncheck, and gosec — so the exact same checks (including +# the vulnerability scan) can be run locally before pushing. The race-enabled +# test pass is run separately (scripts/ci/test.sh / `make test-race`) because +# it is slow; CI keeps it as its own step too. +# +# govulncheck and gosec are installed on demand, matching how the CI job +# bootstraps them, so this works on a fresh checkout. + +# gosec scans only the packages that handle untrusted external input. +GOSEC_PKGS=( + ./service/macip/... + ./service/macgarden/... + ./service/afpfs/macgarden/... +) + +echo "=== go vet ===" +go vet ./... + +# CI runs golangci-lint through its dedicated action (for caching and the +# GitHub UI) and sets SKIP_LINT=1 so we don't lint twice; locally the script +# runs it directly when present. +if [[ "${SKIP_LINT:-0}" == "1" ]]; then + echo "=== golangci-lint (skipped: SKIP_LINT=1) ===" +elif command -v golangci-lint >/dev/null 2>&1; then + echo "=== golangci-lint ===" + golangci-lint run --build-tags=all --timeout=5m +else + echo "=== golangci-lint (not on PATH; install from https://golangci-lint.run) ===" >&2 +fi + +echo "=== govulncheck ===" +command -v govulncheck >/dev/null 2>&1 || go install golang.org/x/vuln/cmd/govulncheck@latest +govulncheck -tags all ./... + +echo "=== gosec (untrusted-input paths) ===" +command -v gosec >/dev/null 2>&1 || go install github.com/securego/gosec/v2/cmd/gosec@latest +# G115 (integer overflow on conversions) is excluded: the flagged conversions +# operate on values already bounded by the protocol/wire formats (DHCP option +# lengths, IPv4 octet extraction, AppleTalk node/socket bytes), so the +# "overflow" cannot occur in practice and the rule is pure noise here. +gosec -tags all -exclude=G115 "${GOSEC_PKGS[@]}" + +echo "=== quality checks passed ===" diff --git a/service/macgarden/client_test.go b/service/macgarden/client_test.go index b502d0a..52abcf0 100644 --- a/service/macgarden/client_test.go +++ b/service/macgarden/client_test.go @@ -25,6 +25,25 @@ func requireLiveTests(t *testing.T) { } } +// loadCapturedPage reads a captured macintoshgarden.org HTML page from +// testdata and rewrites its root-relative hrefs ("/apps/...", pager links) to +// absolute URLs under serverURL, so a test http server can serve the real +// markup while the client's allowedHost check (keyed on the server host) still +// passes. Using captured HTML keeps these tests faithful to the live site's +// structure rather than hand-written fixtures that can drift from how the +// goquery/x-net HTML parser actually treats the page. +func loadCapturedPage(t *testing.T, name, serverURL string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("read captured page %s: %v", name, err) + } + // Rewrite href="/path" -> href="/path". The captured pages use + // root-relative links throughout (items and pager), so a single prefix + // rewrite reroutes every link to the test server. + return strings.ReplaceAll(string(raw), `href="/`, `href="`+serverURL+`/`) +} + type headErrorRoundTripper struct { hits int } @@ -228,18 +247,10 @@ func TestCountCategoryItems_UsesFirstAndLastPages(t *testing.T) { _, _ = fmt.Fprint(w, body) })) defer server.Close() - pages["/apps/utilities/antivirus"] = fmt.Sprintf(` - -

Anti-Virus Boot Disk

-

ClamAV upgrade for Leopard Server

- 1 - 2 - last » - `, server.URL, server.URL, server.URL, server.URL, server.URL) - pages["/apps/utilities/antivirus?page=2"] = fmt.Sprintf(` - -

SecureInit

- `, server.URL) + // Real captured pages: the antivirus category has 6 pages (last is + // ?page=5), 10 items on page 1 and 8 on the last page. + pages["/apps/utilities/antivirus"] = loadCapturedPage(t, "category_antivirus_page1.html", server.URL) + pages["/apps/utilities/antivirus?page=5"] = loadCapturedPage(t, "category_antivirus_page5.html", server.URL) c := NewClient() c.httpClient = server.Client() @@ -251,8 +262,9 @@ func TestCountCategoryItems_UsesFirstAndLastPages(t *testing.T) { if err != nil { t.Fatalf("CountCategoryItems: %v", err) } - if count != 5 { - t.Fatalf("count = %d, want 5", count) + // firstPageCount*(pageCount-1) + lastPageCount = 10*5 + 8. + if count != 58 { + t.Fatalf("count = %d, want 58", count) } } @@ -271,18 +283,9 @@ func TestGetCategoryPageInfo_UsesFirstAndLastPages(t *testing.T) { _, _ = fmt.Fprint(w, body) })) defer server.Close() - pages["/apps/utilities/antivirus"] = fmt.Sprintf(` - -

Anti-Virus Boot Disk

-

ClamAV upgrade for Leopard Server

- 1 - 2 - last » - `, server.URL, server.URL, server.URL, server.URL, server.URL) - pages["/apps/utilities/antivirus?page=2"] = fmt.Sprintf(` - -

SecureInit

- `, server.URL) + // Real captured antivirus category pages (first page + last page ?page=5). + pages["/apps/utilities/antivirus"] = loadCapturedPage(t, "category_antivirus_page1.html", server.URL) + pages["/apps/utilities/antivirus?page=5"] = loadCapturedPage(t, "category_antivirus_page5.html", server.URL) c := NewClient() c.httpClient = server.Client() @@ -294,20 +297,22 @@ func TestGetCategoryPageInfo_UsesFirstAndLastPages(t *testing.T) { if err != nil { t.Fatalf("GetCategoryPageInfo: %v", err) } - if info.TotalCount != 5 { - t.Fatalf("TotalCount = %d, want 5", info.TotalCount) + // Page 1 lists 10 items; the last page (?page=5) lists 8. Pagination is + // zero-based, so a last query of page=5 means 6 pages: 10*5 + 8 = 58. + if info.TotalCount != 58 { + t.Fatalf("TotalCount = %d, want 58", info.TotalCount) } - if info.FirstPageCount != 2 { - t.Fatalf("FirstPageCount = %d, want 2", info.FirstPageCount) + if info.FirstPageCount != 10 { + t.Fatalf("FirstPageCount = %d, want 10", info.FirstPageCount) } - if info.LastPageNumber != 2 { - t.Fatalf("LastPageNumber = %d, want 2", info.LastPageNumber) + if info.LastPageNumber != 5 { + t.Fatalf("LastPageNumber = %d, want 5", info.LastPageNumber) } - if len(info.LastPage) != 1 || info.LastPage[0].Name != "SecureInit" { - t.Fatalf("LastPage = %+v, want SecureInit only", info.LastPage) + if len(info.LastPage) != 8 || info.LastPage[len(info.LastPage)-1].Name != "VirusDetective" { + t.Fatalf("LastPage = %+v, want 8 items ending in VirusDetective", info.LastPage) } - if info.PageSize != 2 { - t.Fatalf("PageSize = %d, want 2", info.PageSize) + if info.PageSize != 10 { + t.Fatalf("PageSize = %d, want 10", info.PageSize) } } diff --git a/service/macgarden/testdata/category_antivirus_page1.html b/service/macgarden/testdata/category_antivirus_page1.html new file mode 100644 index 0000000..210ed19 --- /dev/null +++ b/service/macgarden/testdata/category_antivirus_page1.html @@ -0,0 +1,320 @@ + + + + + + + + + + + + + + + + Antivirus - Macintosh Garden + + + +
+ + +
+
+
+
+

Anti-Virus Boot Disk

+
+ CD Contents
+
+

A small Bootable CD Image that combines Virex 6.1, Disinfectant 3.7.1 & Agax 1.3.2. +Also included are a number of ut...

+ + + + + +
Rating:
+
5
+
Your rating: None Average: 5 (1 vote)
+
+
Category:
Year released:
Author:
+
+
+
+
+

AntiToxin

+
+ AntiToxin 1.4
+
+

AntiToxin virus-removal software for the Macintosh. +Treats the following viruses: +• Scores +• nVIR A +• nVIR B +• H...

+ + + + + +
Rating:
+
5
+
Your rating: None Average: 5 (1 vote)
+
+
Category:
Year released:
Author:
+
+
+
+
+

ClamAV upgrade for Leopard Server

+
+
+
+

Highest database/definition upgrades applicable to clamav (command line anti virus) in the Leopard Server 10.5.8 +...

+ + + + + +
Rating:
+
0
+
Your rating: None
+
+
Category:
Year released:
Author:
+
+
+
+
+

HyperCard Virus Compendium

+
+ About Screenshot
+
+

HyperCard Virus Compendium +Eliminating and preventing viruses + +Vaccine is a free HyperCard virus utility written by Bill...

+ + + + + +
Rating:
+
5
+
Your rating: None Average: 5 (1 vote)
+
+
Category:
Year released:
Author:
+
+
+
+
+

McAfee Security for Mac 1.1

+
+ McAfee Security
+
+

From the installer: +"What’s new in McAfee Security 1.1 +McAfee Security 1.1 has an enhanced graphical user interface an...

+ + + + + +
Rating:
+
5
+
Your rating: None Average: 5 (2 votes)
+
+
Category:
Year released:
Author:
+
+
+
+
+

SecureInit

+
+ Application Screenshot
+
+

+SecureInit is capable of eradicating the WDEF, ZUC, and GARFIELD virus. +Every time one of these viruses tries to get in...

+ + + + + +
Rating:
+
0
+
Your rating: None
+
+
Category:
Year released:
Author:
+
+
+
+
+

SteveScan

+
+
+
+

SteveScan allows you to continue to use Virex 7 on systems incompatible with it, such as MacOS X Tiger, by accessing the...

+ + + + + +
Rating:
+
5
+
Your rating: None Average: 5 (1 vote)
+
+
Category:
Year released:
Author:
+
+
+
+
+

VirusBlockade

+
+ Game screenshot
+
+

+ + +Control Panel that allows you to foil attempts to modify your hard disk or floppy. You never have to worry again abou...

+ + + + + +
Rating:
+
0
+
Your rating: None
+
+
Category:
Year released:
Author:
+
+
+
+
+

avast! Mac Edition

+
+ Avast 7.0 on Mac OS X Snow Leopard.
+
+

Various Releases of the Avast! antivirus. +Included are: +1 - avast! 2.74 (2007) (PowerPC + Intel, 10.4+) [DL 1] +2 - avas...

+ + + + + +
Rating:
+
5
+
Your rating: None Average: 5 (1 vote)
+
+
Category:
Year released:
Author:
+
+
+
+
+

iAntiVirus

+
+ Application Screenshot
+
+

iAntivirus is an antivirus for Intel Macs using Mac OS X 10.5 and above developed by PC Tools from 2008-2009. + +#1: iAnti...

+ + + + + +
Rating:
+
0
+
Your rating: None
+
+
Category:
Year released:
Author:
+
+
+
+
+ +
+ + diff --git a/service/macgarden/testdata/category_antivirus_page5.html b/service/macgarden/testdata/category_antivirus_page5.html new file mode 100644 index 0000000..3ab72aa --- /dev/null +++ b/service/macgarden/testdata/category_antivirus_page5.html @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + Antivirus - Macintosh Garden + + + +
+ + +
+
+
+
+

BugScan

+
+ Game screenshot
+
+

BugScan will detect all files for all strains of the AutoStart 9805 Worm, current as of 10/23/98, as well the Graphics A...

+ + + + + +
Rating:
+
0
+
Your rating: None
+
+
Category:
Year released:
Author:
+
+
+
+
+

Disinfectant

+
+ Disinfectant's desktop UI
+
+

Disinfectant was the Mac's best free antivirus software, until it was retired by its creator (WA) due to the surge of Mi...

+ + + + + +
Rating:
+
5
+
Your rating: None Average: 5 (14 votes)
+
+
Category:
Year released:
Author:
+
+
+
+
+

Interferon

+
+ Game screenshot
+
+

Interferon is one of the first Macintosh antivirus programs, written by Robert Woodhead of Wizardry fame. He later took ...

+ + + + + +
Rating:
+
2
+
Your rating: None Average: 2 (1 vote)
+
+
Category:
Year released:
Author:
+
+
+
+
+

Norton SystemWorks v1.0.1

+
+ Game screenshot
+
+

Norton's last utilities suite for the classic Mac OS. Version 1.0.1. +Norton SystemWorks for Macintosh includes: +- Norton...

+ + + + + +
Rating:
+
4.09091
+
Your rating: None Average: 4.1 (11 votes)
+
+
Category:
Year released:
Author:
+
+
+
+
+

Symantec AntiVirus for Macintosh 3.x

+
+ Game screenshot
+
+

Symantec Antivirus 3.5 is yet another installment of Symantec's anti-virus suite. This version of SAM once again consist...

+ + + + + +
Rating:
+
4.6
+
Your rating: None Average: 4.6 (10 votes)
+
+
Category:
Year released:
Author:
+
+
+
+
+

Vaccine

+
+ Game screenshot
+
+

System 6-era antivirus program. +...

+ + + + + +
Rating:
+
0
+
Your rating: None
+
+
Category:
Year released:
Author:
+
+
+
+
+

Virus Rx

+
+ Game screenshot
+
+

Virus Rx is a virus detection program made available by Apple Computer, Inc. to assist in the detection of various virus...

+ + + + + +
Rating:
+
0
+
Your rating: None
+
+
Category:
Year released:
Author:
+
+
+
+
+

VirusDetective

+
+ Game screenshot
+
+

VirusDetective is an early antivirus desk accessory. By default, it looks for nVIR, among a couple of other resource-bas...

+ + + + + +
Rating:
+
0
+
Your rating: None
+
+
Category:
Year released:
Author:
+
+
+
+
+ +
+ + From 1f5447dc1bae72f71148d3a9bdbcc396f5b16bc5 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Sat, 6 Jun 2026 00:22:06 +1000 Subject: [PATCH 19/23] ci: run the same vet/govulncheck/gosec gates locally; clear gosec findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add scripts/ci/quality.sh — a single source for the CI Quality job's static gates (vet + golangci-lint + govulncheck + gosec) that also runs locally via `make quality`. The PR CI Quality job and the Makefile both call it, so local development runs the exact same vulnerability scan as CI. govulncheck and gosec self-install on demand, matching how CI bootstraps them; SKIP_LINT=1 lets CI skip the script's lint pass since it runs golangci-lint via its own action. The Makefile gains `quality`/`vet` targets and self-installing `vuln`/`gosec`. Clear the gosec gate (it never completed before because govulncheck failed first): exclude G115 (integer-overflow conversions on values already bounded by the wire formats — pure noise here), and add justified #nosec annotations to the 11 remaining intentional findings in the macgarden client (SHA-1 cache filenames, relaxed TLS for the abandonware mirror, world-readable public caches) and macip (DHCP xid via math/rand, operator-configured lease file). gosec now reports 0 issues over the scanned packages. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/pr-ci.yml | 22 ++++++++++------------ Makefile | 30 ++++++++++++++++++++++++++++-- service/macgarden/client.go | 18 ++++++++++++++++-- service/macip/dhcp_client.go | 2 ++ service/macip/state.go | 2 ++ 5 files changed, 58 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index e89eb92..c2610c1 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -48,27 +48,25 @@ jobs: sudo apt-get update sudo apt-get install -y libpcap-dev - - name: go vet - run: go vet ./... - - name: Race-enabled tests run: go test -tags all -race -count=1 ./... + # golangci-lint runs via its dedicated action (caching, problem matchers) + # rather than from quality.sh, so it can hook into the GitHub UI; the + # script tolerates it being absent so a local run still covers vet/vuln/ + # gosec. The vet + govulncheck + gosec gates are shared with local + # development via scripts/ci/quality.sh / `make quality`. - name: golangci-lint uses: golangci/golangci-lint-action@v8 with: version: latest args: --build-tags=all - - name: govulncheck - run: | - go install golang.org/x/vuln/cmd/govulncheck@latest - govulncheck -tags all ./... - - - name: gosec (untrusted-input paths) - run: | - go install github.com/securego/gosec/v2/cmd/gosec@latest - gosec -tags all ./service/macip/... ./service/macgarden/... ./service/afpfs/macgarden/... + - name: Quality gates (vet + govulncheck + gosec) + shell: bash + env: + SKIP_LINT: "1" # golangci-lint already ran via its action above + run: bash scripts/ci/quality.sh build-tags: name: Build-tag matrix diff --git a/Makefile b/Makefile index 0d63e40..ba75150 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,17 @@ SVC_PKG := ./cmd/classicstackd SVC_BIN := classicstackd endif -.PHONY: build build-svc test test-race test-tags lint vuln gosec fuzz clean +# Versions of the quality tools to install when absent. Kept here so a local +# `make vuln`/`make gosec` installs the same way the CI Quality job does +# (`go install ...@latest`). Pin if reproducibility becomes important. +GOVULNCHECK_PKG := golang.org/x/vuln/cmd/govulncheck@latest +GOSEC_PKG := github.com/securego/gosec/v2/cmd/gosec@latest + +# gosec scans only the packages that handle untrusted external input, matching +# the CI Quality job exactly. +GOSEC_PKGS := ./service/macip/... ./service/macgarden/... ./service/afpfs/macgarden/... + +.PHONY: build build-svc test test-race test-tags lint quality vet vuln gosec fuzz clean build: build-svc go build -tags "$(TAGS)" -o classicstack ./cmd/classicstack @@ -31,11 +41,27 @@ test-tags: lint: golangci-lint run --build-tags=all --timeout=5m +vet: + go vet ./... + +# quality runs the same static-analysis gates as the CI "Quality" job +# (vet + golangci-lint + govulncheck + gosec) from the shared script, so local +# and CI vulnerability scanning stay identical. Run `make test-race` separately +# for the race pass. +quality: + bash scripts/ci/quality.sh + +# vuln runs the same govulncheck invocation as CI, installing it on demand so +# `make vuln` works on a fresh checkout exactly as the CI step does. vuln: + @command -v govulncheck >/dev/null 2>&1 || go install $(GOVULNCHECK_PKG) govulncheck -tags all ./... +# gosec runs the same scan as CI over the untrusted-input packages, installing +# the tool on demand. gosec: - gosec -tags all ./service/macip/... ./service/macgarden/... ./service/afpfs/macgarden/... + @command -v gosec >/dev/null 2>&1 || go install $(GOSEC_PKG) + gosec -tags all -exclude=G115 $(GOSEC_PKGS) fuzz: @for dir in protocol/ddp protocol/atp protocol/asp protocol/nbp protocol/llap; do \ diff --git a/service/macgarden/client.go b/service/macgarden/client.go index 70de112..88124fb 100644 --- a/service/macgarden/client.go +++ b/service/macgarden/client.go @@ -3,7 +3,7 @@ package macgarden import ( "bytes" "context" - "crypto/sha1" + "crypto/sha1" // #nosec G505 -- SHA-1 only names cache files, not a security primitive "crypto/tls" "encoding/hex" "encoding/json" @@ -130,7 +130,11 @@ func NewClient() *Client { return nil }, Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + // #nosec G402 -- macintoshgarden.org and its mirrors serve + // abandonware over certs that are frequently expired/self-signed; + // this client fetches public, non-sensitive files from a fixed + // allow-list of hosts, so TLS verification is intentionally relaxed. + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec }, }, allowedHost: map[string]struct{}{ @@ -253,6 +257,8 @@ func (c *Client) loadItemCache() { c.itemCacheMu.Lock() defer c.itemCacheMu.Unlock() cachePath := c.itemCachePath() + // #nosec G304 -- cachePath is built from a constant relative path under the + // client's own cache dir, not from external input. body, err := os.ReadFile(cachePath) if err != nil { if os.IsNotExist(err) { @@ -272,12 +278,14 @@ func (c *Client) saveItemCache() { defer c.itemCacheMu.RUnlock() cachePath := c.itemCachePath() cacheDir := filepath.Dir(cachePath) + // #nosec G301 -- a public read-only abandonware cache; world-readable is intentional. _ = os.MkdirAll(cacheDir, 0o755) body, err := json.MarshalIndent(c.itemCache, "", " ") if err != nil { return } tmpPath := cachePath + ".tmp" + // #nosec G306 -- cached public file listing; world-readable is intentional. if err := os.WriteFile(tmpPath, body, 0o644); err != nil { return } @@ -979,6 +987,8 @@ func (c *Client) fetchDocument(urlStr string) (*goquery.Document, error) { func (c *Client) readDocumentFromCache(urlStr string) (*goquery.Document, bool, error) { cachePath := c.cachePathForURL(urlStr) + // #nosec G304 -- cachePath is a SHA-1 digest of the URL under the client's + // own cache dir (see cachePathForURL), never raw external input. body, err := os.ReadFile(cachePath) if err != nil { if os.IsNotExist(err) { @@ -997,10 +1007,12 @@ func (c *Client) readDocumentFromCache(urlStr string) (*goquery.Document, bool, func (c *Client) writeDocumentToCache(urlStr string, body []byte) error { cachePath := c.cachePathForURL(urlStr) cacheDir := filepath.Dir(cachePath) + // #nosec G301 -- public read-only page cache; world-readable is intentional. if err := os.MkdirAll(cacheDir, 0o755); err != nil { return err } tmpPath := cachePath + ".tmp" + // #nosec G306 -- cached public HTML page; world-readable is intentional. if err := os.WriteFile(tmpPath, body, 0o644); err != nil { return err } @@ -1015,6 +1027,8 @@ func (c *Client) writeDocumentToCache(urlStr string, body []byte) error { } func (c *Client) cachePathForURL(urlStr string) string { + // #nosec G401 -- SHA-1 is used only to derive a stable cache filename from + // the URL, not for any security purpose; collision resistance is irrelevant. sum := sha1.Sum([]byte(strings.TrimSpace(urlStr))) file := hex.EncodeToString(sum[:]) + ".html" cacheDir := c.cacheDir diff --git a/service/macip/dhcp_client.go b/service/macip/dhcp_client.go index 5e3965d..f0e721f 100644 --- a/service/macip/dhcp_client.go +++ b/service/macip/dhcp_client.go @@ -138,6 +138,8 @@ func fabricateMACForAT(atNet uint16, atNode uint8) net.HardwareAddr { // the given AppleTalk node. If preferredIP is non-nil it is sent as option 50. // Returns nil if DHCP fails, times out, the service stops, or ctx is cancelled. func (c *dhcpClient) RequestIP(ctx context.Context, atNet uint16, atNode uint8, preferredIP net.IP) *dhcpResult { + // #nosec G404 -- the DHCP transaction ID just needs to be unpredictable + // enough to correlate replies on a trusted LAN, not cryptographically random. xid := rand.Uint32() fabMAC := fabricateMACForAT(atNet, atNode) p := &pendingDHCP{ diff --git a/service/macip/state.go b/service/macip/state.go index 9a639b6..e94bcac 100644 --- a/service/macip/state.go +++ b/service/macip/state.go @@ -122,6 +122,8 @@ func (p *ipPool) loadFromFile(path string) { if path == "" { return } + // #nosec G304 -- path is the operator-configured lease-state file, not + // untrusted external input. data, err := os.ReadFile(path) if err != nil { if !os.IsNotExist(err) { From 609b54cff7386312156073220187957a17c78690 Mon Sep 17 00:00:00 2001 From: pgodwin Date: Sun, 7 Jun 2026 21:23:04 +1000 Subject: [PATCH 20/23] webui: surface routing table and dynamic routing in the management plane Add port/router supervisor hooks, routing-table snapshots and dynamic routing wiring, plus diagnostics and web UI surfaces (API, SSE stats, SPA assets) to inspect the router state. Excludes screenshot images. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 2 + README.md | 21 ++ config/fromsource.go | 4 + config/marshal.go | 1 + config/model.go | 39 +++ config/model_test.go | 49 ++++ dist/server.toml | 59 ++++- internal/app/config_ini.go | 25 ++ internal/app/config_model.go | 43 ++++ internal/app/ddp_service_hook_test.go | 5 +- internal/app/diagnostics_impl.go | 30 +++ internal/app/port_hook.go | 110 +++++++++ internal/app/port_hook_test.go | 142 +++++++++++ internal/app/router_attach_test.go | 96 ++++++++ internal/app/router_hook.go | 62 +++++ internal/app/supervisor.go | 178 +++++++++++--- internal/app/supervisor_control.go | 20 ++ internal/app/supervisor_lifecycle.go | 22 +- pkg/control/control.go | 11 + pkg/control/control_test.go | 20 ++ pkg/control/diagnostics.go | 18 ++ pkg/control/diagnostics_unavailable.go | 4 + pkg/control/stats.go | 6 +- router/dynamic.go | 44 +++- router/router.go | 100 ++++++-- router/routing_table.go | 39 +++ router/routing_table_snapshot_test.go | 58 +++++ server.toml | 177 +++++++------- server.toml.example | 10 + service/webui/api.go | 19 ++ service/webui/assets/app.css | 84 +++++++ service/webui/assets/app.js | 320 +++++++++++++++++++++---- service/webui/assets/index.html | 22 ++ service/webui/diagnostics.go | 14 ++ service/webui/embed.go | 6 + service/webui/plane.go | 1 + 36 files changed, 1636 insertions(+), 225 deletions(-) create mode 100644 internal/app/port_hook.go create mode 100644 internal/app/port_hook_test.go create mode 100644 internal/app/router_attach_test.go create mode 100644 internal/app/router_hook.go create mode 100644 router/routing_table_snapshot_test.go diff --git a/.gitignore b/.gitignore index d4eaa77..20907d5 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ go.work.sum .captures/ captures/ +server.toml.[0-9]* + diff --git a/README.md b/README.md index d0d4988..6d2c1af 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,27 @@ These filters apply only in pcap mode. ## Transport and service sections +### [Router] + +Declares which transports the AppleTalk router binds to. An enabled transport +that is **not** bound runs *standalone*: it still comes up and receives frames +(and can be captured), but it is not part of the AppleTalk router — no RTMP/ZIP +and no inter-port forwarding. This lets you run, say, TashTalk on its own +segment without it joining the router. + +| Key | Default | Notes | +|---|---|---| +| ports | (empty) | Transport section names the router binds to (`"LToUdp"`, `"TashTalk"`, `"EtherTalk"`). Empty (or section omitted) binds every enabled transport; a non-empty list binds only those named, so any enabled-but-unlisted transport runs standalone. | + +```toml +[Router] +ports = ["LToUdp", "EtherTalk"] # TashTalk, if enabled, runs standalone +``` + +The dashboard shows each port's `routed: on/off` so you can see at a glance +which transports are part of the router. The same allow-list is editable from +the web UI via the "Attach to AppleTalk router" checkbox on each transport. + ### [LToUdp] | Key | Default | Notes | diff --git a/config/fromsource.go b/config/fromsource.go index 92d4f41..889f042 100644 --- a/config/fromsource.go +++ b/config/fromsource.go @@ -22,6 +22,10 @@ func FromSource(src Source) *Model { m.Logging.LogTraffic = boolv(k, "Logging.log_traffic", m.Logging.LogTraffic) m.Logging.ParseOutput = str(k, "Logging.parse_output", m.Logging.ParseOutput) + if k.Exists("Router.ports") { + m.Router.Ports = k.Strings("Router.ports") + } + m.Bridge.Mode = str(k, "Bridge.mode", m.Bridge.Mode) m.Bridge.Device = str(k, "Bridge.device", m.Bridge.Device) m.Bridge.HWAddress = str(k, "Bridge.hw_address", m.Bridge.HWAddress) diff --git a/config/marshal.go b/config/marshal.go index b757f81..ea6fa22 100644 --- a/config/marshal.go +++ b/config/marshal.go @@ -22,6 +22,7 @@ func (m *Model) Clone() *Model { } cp := *m // shallow copy of all value fields + cp.Router.Ports = cloneStrings(m.Router.Ports) cp.IPXGW.Bindings = cloneStrings(m.IPXGW.Bindings) cp.NetBIOS.Transports = cloneStrings(m.NetBIOS.Transports) diff --git a/config/model.go b/config/model.go index 7c87992..ed550d8 100644 --- a/config/model.go +++ b/config/model.go @@ -1,5 +1,7 @@ package config +import "strings" + // Model is the in-memory, mutable, serialisable representation of the whole // ClassicStack configuration. It is the source of truth the management // plane stages edits against and writes back to server.toml. Field names @@ -13,6 +15,7 @@ package config // packages' own config structs. type Model struct { Logging LoggingModel `toml:"Logging" json:"Logging"` + Router RouterModel `toml:"Router" json:"Router"` Bridge BridgeModel `toml:"Bridge" json:"Bridge"` LToUDP LToUDPModel `toml:"LToUdp" json:"LToUdp"` TashTalk TashTalkModel `toml:"TashTalk" json:"TashTalk"` @@ -54,6 +57,42 @@ type InterfaceModel struct { // keeps the [Bridge] section name and TOML keys unchanged. type BridgeModel = InterfaceModel +// RouterModel is the [Router] section. It declares which transports the +// AppleTalk router binds to. Ports lists the transport section names +// ("LToUdp", "TashTalk", "EtherTalk") the router participates in; an enabled +// transport that is NOT listed runs standalone (it comes up and receives but is +// not part of the router — no RTMP/ZIP, no inter-port forwarding). An empty/ +// unset Ports means "bind every enabled transport", which is the sensible +// default — a config that omits [Router] gets the full router it expects. +type RouterModel struct { + Ports []string `toml:"ports,omitempty" json:"ports,omitempty"` +} + +// Canonical [Router].ports transport names. These match the TOML section names +// so a config author lists the same identifier they configure the transport +// under. +const ( + RouterPortLToUDP = "LToUdp" + RouterPortTashTalk = "TashTalk" + RouterPortEtherTalk = "EtherTalk" +) + +// BindsPort reports whether the router should attach the named transport. With +// an empty Ports list every enabled transport attaches (the default); otherwise +// only listed transports attach. Matching is case-insensitive so "ethertalk" +// and "EtherTalk" are equivalent. +func (r RouterModel) BindsPort(name string) bool { + if len(r.Ports) == 0 { + return true + } + for _, p := range r.Ports { + if strings.EqualFold(strings.TrimSpace(p), name) { + return true + } + } + return false +} + // LToUDPModel is the [LToUdp] section. type LToUDPModel struct { Enabled bool `toml:"enabled" json:"enabled"` diff --git a/config/model_test.go b/config/model_test.go index f9b0c16..4404dba 100644 --- a/config/model_test.go +++ b/config/model_test.go @@ -107,3 +107,52 @@ func TestSaveNoBackupWhenAbsent(t *testing.T) { t.Errorf("config not written: %v", err) } } + +func TestRouterBindsPort(t *testing.T) { + // Empty list binds everything (the default). + empty := RouterModel{} + for _, name := range []string{RouterPortLToUDP, RouterPortTashTalk, RouterPortEtherTalk} { + if !empty.BindsPort(name) { + t.Errorf("empty Ports: BindsPort(%q) = false, want true", name) + } + } + + // A non-empty list binds only the named transports; matching is + // case-insensitive and whitespace-tolerant. + r := RouterModel{Ports: []string{"LToUdp", " ethertalk "}} + if !r.BindsPort(RouterPortLToUDP) { + t.Errorf("BindsPort(LToUdp) = false, want true") + } + if !r.BindsPort(RouterPortEtherTalk) { + t.Errorf("BindsPort(EtherTalk) = false, want true (case-insensitive)") + } + if r.BindsPort(RouterPortTashTalk) { + t.Errorf("BindsPort(TashTalk) = true, want false (not listed)") + } +} + +func TestRouterPortsTOMLRoundTrip(t *testing.T) { + m := Defaults() + m.Router.Ports = []string{RouterPortLToUDP, RouterPortEtherTalk} + + data, err := m.ToTOML() + if err != nil { + t.Fatalf("ToTOML: %v", err) + } + dir := t.TempDir() + path := filepath.Join(dir, "server.toml") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + src, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + got := FromSource(src) + if got.Router.BindsPort(RouterPortTashTalk) { + t.Errorf("after round-trip TashTalk still bound; Ports=%v", got.Router.Ports) + } + if !got.Router.BindsPort(RouterPortLToUDP) || !got.Router.BindsPort(RouterPortEtherTalk) { + t.Errorf("after round-trip lost a bound port; Ports=%v", got.Router.Ports) + } +} diff --git a/dist/server.toml b/dist/server.toml index b5b2340..ff284fa 100644 --- a/dist/server.toml +++ b/dist/server.toml @@ -1,3 +1,10 @@ +[Bridge] +# Shared raw-link settings used by EtherTalk, MacIP, IPX, and NetBEUI. +mode = "pcap" # pcap, tap, or tun +device = "" # PCap device name. Use -list-pcap-devices to see candidates. +hw_address = "DE:AD:BE:EF:CA:FE" # host/bridge MAC used by raw-link consumers +bridge_mode = "auto" # auto, ethernet, or wifi frame adaptation mode + [LToUdp] # LocalTalk over UDP Settings (used by Mini vMac UDP builds and SNOW emu) enabled = true # Enable LToUDP - true for on, false for off @@ -11,15 +18,11 @@ seed_network = 2 seed_zone = "TashTalk Network" [EtherTalk] -# EtherTalk is a pcap-based network bridge -backend = "pcap" # supported: pcap, tap, tun. Leave blank to disable. -device = "" # PCap device name. Use -list-pcap-devices to see candidates. -hw_address = "DE:AD:BE:EF:CA:FE" +# EtherTalk is a pcap-based network bridge (link/device live in [Bridge]). +bridge_host_mac = "" # optional host adapter MAC for Wi-Fi bridge shim. Defaults to hw_address when blank. seed_network_min = 3 seed_network_max = 5 seed_zone = "EtherTalk Network" -bridge_mode = "auto" # auto (default), ethernet, or wifi -bridge_host_mac = "" [MacIP] # MacIP Gateway Settings. Allows TCP over DDP. @@ -40,12 +43,13 @@ zone = "EtherTalk Network" protocols = "ddp,tcp" binding = ":548" extension_map = "extmap.conf" +cnid_backend = "sqlite" +appledouble_mode = "modern" # AFP Volume Configuration — each volume gets an [AFP.Volumes.] section. [AFP.Volumes.Default] name = "Welcome" path = "./Sample Volume" -read_only = true rebuild_desktop_db = true [AFP.Volumes.Shared] @@ -53,6 +57,47 @@ name = "Shared" path = "./shared" rebuild_desktop_db = false +[IPX] +# IPX raw-link transport. Carries NetBIOS (and the MacIPX gateway). Blank +# interface reuses the auto-detected [Bridge] device. +enabled = true +interface = "" # blank: reuse [Bridge] device +framing = "ethernet_ii" # ethernet_ii|raw_802_3|llc|snap +internal_network = "" # 8 hex digits; blank: 00000001 + +[NetBEUI] +# NetBEUI raw-link transport. Carries NetBIOS over LLC frames. Blank interface +# reuses the auto-detected [Bridge] device. +enabled = true +interface = "" # blank: reuse [Bridge] device + +[NetBIOS] +# NetBIOS session layer. Runs over the listed transports; netbeui and ipx are +# the detachable raw-link transports configured above. +enabled = true +transports = ["netbeui", "ipx"] + +[SMB] +# SMB/CIFS file server, served over NetBIOS (the netbeui + ipx transports). +enabled = true +server_name = "CLASSICSTACK" +workgroup = "WORKGROUP" +guest_ok = true + +# Each SMB share gets an [SMB.Volumes.] section. +[SMB.Volumes.Shared] +name = "Shared" +path = "./SMB" +fs_type = "local_fs" +read_only = false + +[WebUI] +# Management web UI: dashboard + configuration editor. Requires a binary built +# with -tags webui (included in -tags all). +enabled = true # serve the web UI +bind = "127.0.0.1:8080" # IP:PORT to listen on; loopback by default +tls = false # serve plain HTTP (no TLS) + [Logging] level = "warn" parse_packets = false diff --git a/internal/app/config_ini.go b/internal/app/config_ini.go index 911fc2a..21c41b8 100644 --- a/internal/app/config_ini.go +++ b/internal/app/config_ini.go @@ -31,6 +31,14 @@ type appConfig struct { EtherTalk ethertalk.Config Capture capture.Config + // Per-transport router attachment. When true (the default) the transport's + // port joins the AppleTalk router (RTMP/ZIP, inter-port forwarding); when + // false the port runs standalone — it comes up and receives, but is not part + // of the router. + LToUDPAttachRouter bool + TashTalkAttachRouter bool + EtherTalkAttachRouter bool + // Per-protocol effective interfaces. Each defaults to the shared Bridge // and is overridden when the protocol defines its own [
.Custom] // interface. buildHooks passes these (not the raw Bridge) into the @@ -99,6 +107,11 @@ func defaultAppConfig() appConfig { EtherTalk: ethertalk.DefaultConfig(), Capture: capture.DefaultConfig(), + // Transports join the AppleTalk router by default; standalone is opt-in. + LToUDPAttachRouter: true, + TashTalkAttachRouter: true, + EtherTalkAttachRouter: true, + MacIPSubnet: "192.168.100.0/24", IPXFraming: "ethernet_ii", @@ -157,6 +170,18 @@ func resolveAppConfig(src config.Source) (appConfig, error) { } syncBridgeToEtherTalk(&cfg) + // [Router].ports declares which transports the AppleTalk router binds to. + // An empty/absent list binds every enabled transport; a non-empty list + // binds only the named ones, so an enabled-but-unlisted transport runs + // standalone. + var rm config.RouterModel + if k.Exists("Router.ports") { + rm.Ports = k.Strings("Router.ports") + } + cfg.LToUDPAttachRouter = rm.BindsPort(config.RouterPortLToUDP) + cfg.TashTalkAttachRouter = rm.BindsPort(config.RouterPortTashTalk) + cfg.EtherTalkAttachRouter = rm.BindsPort(config.RouterPortEtherTalk) + cfg.MacIPEnabled = boolWithDefault(k, "MacIP.enabled", cfg.MacIPEnabled) mode := strings.ToLower(stringWithDefault(k, "MacIP.mode", "")) switch mode { diff --git a/internal/app/config_model.go b/internal/app/config_model.go index f7e72cc..a5fd241 100644 --- a/internal/app/config_model.go +++ b/internal/app/config_model.go @@ -42,6 +42,12 @@ func appConfigFromModel(m *config.Model) (appConfig, error) { SeedNetwork: m.TashTalk.SeedNetwork, SeedZone: m.TashTalk.SeedZone, } + // Router attachment lives in [Router].ports: an empty list binds every + // enabled transport (the historical default); a non-empty list binds only + // the named transports, so an enabled-but-unlisted one runs standalone. + cfg.LToUDPAttachRouter = m.Router.BindsPort(config.RouterPortLToUDP) + cfg.TashTalkAttachRouter = m.Router.BindsPort(config.RouterPortTashTalk) + cfg.EtherTalkAttachRouter = m.Router.BindsPort(config.RouterPortEtherTalk) cfg.EtherTalk = ethertalk.Config{ BridgeHostMAC: m.EtherTalk.BridgeHostMAC, Filter: m.EtherTalk.Filter, @@ -199,6 +205,8 @@ func modelFromAppConfig(cfg appConfig) *config.Model { m.EtherTalk.DesiredNetwork = cfg.EtherTalk.DesiredNetwork m.EtherTalk.DesiredNode = cfg.EtherTalk.DesiredNode + m.Router.Ports = routerPortsModel(cfg) + m.Capture.LocalTalk = cfg.Capture.LocalTalk m.Capture.EtherTalk = cfg.Capture.EtherTalk m.Capture.IPX = cfg.Capture.IPX @@ -280,6 +288,41 @@ func orDefault(v, def string) string { return v } +// routerPortsModel projects router attachment back into [Router].ports. It +// returns nil (the key stays absent) when every *configured* transport is +// attached, so a stack with the default full router serialises no [Router] +// section. When at least one configured transport is detached it emits the +// explicit allow-list of the attached, configured transports so the round-trip +// is faithful. +func routerPortsModel(cfg appConfig) []string { + type entry struct { + name string + configured bool + attached bool + } + entries := []entry{ + {config.RouterPortLToUDP, cfg.LToUDP.Enabled, cfg.LToUDPAttachRouter}, + {config.RouterPortTashTalk, cfg.TashTalk.Port != "", cfg.TashTalkAttachRouter}, + {config.RouterPortEtherTalk, cfg.EtherTalk.Device != "", cfg.EtherTalkAttachRouter}, + } + anyDetached := false + var attached []string + for _, e := range entries { + if !e.configured { + continue + } + if e.attached { + attached = append(attached, e.name) + } else { + anyDetached = true + } + } + if !anyDetached { + return nil + } + return attached +} + func splitColon(s string) []string { for i := 0; i < len(s); i++ { if s[i] == ':' { diff --git a/internal/app/ddp_service_hook_test.go b/internal/app/ddp_service_hook_test.go index d93d1bd..7e30b06 100644 --- a/internal/app/ddp_service_hook_test.go +++ b/internal/app/ddp_service_hook_test.go @@ -142,7 +142,7 @@ func TestPromoteUnitToHook(t *testing.T) { Properties: map[string]string{"zone": "MyZone"}, }) s := &Supervisor{reg: reg} - s.promoteUnitToHook("AFP", true) + s.promoteUnitToHook("AFP", true, []string{"Router"}) u := unitByName(reg, "AFP") if u.Kind != status.KindHook { @@ -154,6 +154,9 @@ func TestPromoteUnitToHook(t *testing.T) { if u.Binding != ":548" || u.Properties["zone"] != "MyZone" { t.Fatalf("promotion lost detail: binding=%q props=%v", u.Binding, u.Properties) } + if len(u.DependsOn) != 1 || u.DependsOn[0] != "Router" { + t.Fatalf("DependsOn = %v, want [Router]", u.DependsOn) + } } func routerHasService(r *router.Router, target service.Service) bool { diff --git a/internal/app/diagnostics_impl.go b/internal/app/diagnostics_impl.go index 6c9a5f4..19c92d3 100644 --- a/internal/app/diagnostics_impl.go +++ b/internal/app/diagnostics_impl.go @@ -64,6 +64,36 @@ func (d *routerDiagnostics) DDPEnumerate(context.Context) ([]control.NetworkInfo return out, nil } +// RTMPTable returns the full RTMP routing table with each entry's aging +// state, for the management UI's RTMP table view. +func (d *routerDiagnostics) RTMPTable(context.Context) ([]control.RTMPEntry, error) { + r := d.router() + if r == nil { + return nil, control.ErrDiagUnavailable + } + snap := r.RTMPSnapshot() + out := make([]control.RTMPEntry, 0, len(snap)) + for _, s := range snap { + if s.Entry == nil { + continue + } + portName := "" + if s.Entry.Port != nil { + portName = s.Entry.Port.ShortString() + } + out = append(out, control.RTMPEntry{ + NetworkMin: s.Entry.NetworkMin, + NetworkMax: s.Entry.NetworkMax, + Distance: s.Entry.Distance, + Port: portName, + NextNetwork: s.Entry.NextNetwork, + NextNode: s.Entry.NextNode, + State: s.State, + }) + } + return out, nil +} + // ZIPEnumerate currently mirrors ListZones; a dedicated ZIP GetZoneList // walk can replace this when wired. func (d *routerDiagnostics) ZIPEnumerate(ctx context.Context) ([]control.ZoneInfo, error) { diff --git a/internal/app/port_hook.go b/internal/app/port_hook.go new file mode 100644 index 0000000..b58f629 --- /dev/null +++ b/internal/app/port_hook.go @@ -0,0 +1,110 @@ +package app + +import ( + "context" + + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/router" +) + +// portHook adapts a single transport port to the standalone hook lifecycle so +// the management UI can start and stop each port independently, without +// rebuilding the whole stack. +// +// Ports are independent of the router: a port can run while the router is +// stopped (its frames simply go nowhere) and the router can run without a given +// port. A routed port (one bound to the AppleTalk router) therefore couples to +// the router only when both are running: +// +// - Start brings the port up. When the port is routed and the router is +// running, it is attached to the router so its frames are routed; otherwise +// it comes up detached (the router hook adopts running routed ports when it +// later starts). +// - Stop detaches a routed port from the running router (withdrawing its +// routes) before stopping the port itself. +// +// A standalone port (router-attach off) is driven directly with a no-op +// router-hooks sink the whole time, exactly as the supervisor drove it before. +type portHook struct { + port port.Port + router *router.Router + routed bool + // routerRunning reports whether the AppleTalk router's services are live, + // so a routed port knows whether to attach on Start / detach on Stop. + routerRunning func() bool + running bool +} + +// newPortHook returns a hook over p. routed marks the port as one that +// participates in the AppleTalk router (vs. a standalone port driven detached). +func newPortHook(p port.Port, r *router.Router, routed bool, routerRunning func() bool) *portHook { + return &portHook{port: p, router: r, routed: routed, routerRunning: routerRunning} +} + +// Start brings the port up. A routed port is attached to the router when the +// router is already running (AddPort starts it against the live router); +// otherwise — and for standalone ports — it starts detached with a no-op +// router-hooks sink so it still receives (capture/metering keep working). +func (h *portHook) Start(ctx context.Context) error { + if h.running { + return nil + } + if h.routed && h.routerRunning != nil && h.routerRunning() { + if err := h.router.AddPort(ctx, h.port); err != nil { + return err + } + h.running = true + return nil + } + if err := h.port.Start(noopRouterHooks{}); err != nil { + return err + } + h.running = true + return nil +} + +// Stop tears the port down. A routed port that is part of the running router is +// removed from it first (RemovePort withdraws its routes and stops it); +// otherwise the port is stopped directly. +func (h *portHook) Stop() error { + if !h.running { + return nil + } + h.running = false + if h.routed && h.routerRunning != nil && h.routerRunning() && h.router.HasPort(h.port) { + return h.router.RemovePort(h.port) + } + return h.port.Stop() +} + +// attachToRouter brings an already-running routed port into the freshly +// started router so it routes again. It is called by the router hook's Start +// for each running routed port; stopped or standalone ports are left alone. +// +// A detached running port was started with a no-op router-hooks sink (the +// router was down), so its inbound frames currently go nowhere. Merely adding +// it to the router's membership would not redirect those frames, so the port is +// restarted against the live router (Stop then AddPort) — the pcap/serial port +// lifecycle is restart-safe. A port already in the router's set is only +// (re)bound to the LLAP link manager. +func (h *portHook) attachToRouter(ctx context.Context) error { + if !h.running || !h.routed { + return nil + } + if h.router.HasPort(h.port) { + h.router.AttachStartedPort(h.port) // idempotent; re-binds LLAP + return nil + } + if err := h.port.Stop(); err != nil { + return err + } + return h.router.AddPort(ctx, h.port) +} + +// detachFromRouter removes a running routed port from the router that is about +// to stop, leaving the port itself running. Called by the router hook's Stop. +func (h *portHook) detachFromRouter() { + if h.running && h.routed { + h.router.DetachPort(h.port) + } +} diff --git a/internal/app/port_hook_test.go b/internal/app/port_hook_test.go new file mode 100644 index 0000000..a8a6026 --- /dev/null +++ b/internal/app/port_hook_test.go @@ -0,0 +1,142 @@ +//go:build all + +package app + +import ( + "context" + "sync/atomic" + "testing" + + "github.com/ObsoleteMadness/ClassicStack/port" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" + "github.com/ObsoleteMadness/ClassicStack/router" + "github.com/ObsoleteMadness/ClassicStack/service" +) + +// fakePort is a minimal port.Port that records Start/Stop calls so the +// portHook/routerHook lifecycle can be exercised without a real transport. +type fakePort struct { + starts int32 + stops int32 + running atomic.Bool +} + +func (f *fakePort) ShortString() string { return "fake" } +func (f *fakePort) Start(port.RouterHooks) error { + atomic.AddInt32(&f.starts, 1) + f.running.Store(true) + return nil +} +func (f *fakePort) Stop() error { + atomic.AddInt32(&f.stops, 1) + f.running.Store(false) + return nil +} +func (f *fakePort) Unicast(uint16, uint8, ddp.Datagram) {} +func (f *fakePort) Broadcast(ddp.Datagram) {} +func (f *fakePort) Multicast([]byte, ddp.Datagram) {} +func (f *fakePort) SetNetworkRange(uint16, uint16) error { return nil } +func (f *fakePort) Network() uint16 { return 0 } +func (f *fakePort) Node() uint8 { return 0 } +func (f *fakePort) NetworkMin() uint16 { return 0 } +func (f *fakePort) NetworkMax() uint16 { return 0 } +func (f *fakePort) ExtendedNetwork() bool { return false } + +// TestPortHook_StandaloneLifecycle verifies a standalone (non-routed) port hook +// starts and stops the port directly, independent of the router, and never adds +// it to the router's port set. +func TestPortHook_StandaloneLifecycle(t *testing.T) { + r := router.New("test", nil, []service.Service{}) + p := &fakePort{} + h := newPortHook(p, r, false, func() bool { return false }) + + if err := h.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + if !p.running.Load() { + t.Fatal("standalone port should be running after Start") + } + if r.HasPort(p) { + t.Fatal("standalone port must not join the router set") + } + if err := h.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if p.running.Load() { + t.Fatal("standalone port should be stopped after Stop") + } +} + +// TestPortHook_RoutedAttachesToRunningRouter verifies a routed port hook joins +// the router (via AddPort) when the router is already running, and is removed +// from it on Stop. +func TestPortHook_RoutedAttachesToRunningRouter(t *testing.T) { + r := newTestRouter(t) // started + p := &fakePort{} + h := newPortHook(p, r, true, func() bool { return true }) + + if err := h.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + if !r.HasPort(p) { + t.Fatal("routed port should join the running router") + } + if !p.running.Load() { + t.Fatal("routed port should be running") + } + if err := h.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if r.HasPort(p) { + t.Fatal("routed port should leave the router on Stop") + } + if p.running.Load() { + t.Fatal("routed port should be stopped after Stop") + } +} + +// TestRouterHook_StopLeavesPortsRunning verifies that stopping the router hook +// detaches the routed ports from the router but leaves them running — ports are +// independent of the router (their frames simply go nowhere). +func TestRouterHook_StopLeavesPortsRunning(t *testing.T) { + r := router.New("test", nil, []service.Service{}) + p := &fakePort{} + + var rh *routerHook + ph := newPortHook(p, r, true, func() bool { return rh.IsRunning() }) + rh = newRouterHook(r, func() []*portHook { return []*portHook{ph} }) + + ctx := context.Background() + // Bring the router up first, then the (routed) port — mirrors start order. + if err := rh.Start(ctx); err != nil { + t.Fatalf("router Start: %v", err) + } + if err := ph.Start(ctx); err != nil { + t.Fatalf("port Start: %v", err) + } + if !r.HasPort(p) { + t.Fatal("routed port should be attached while router runs") + } + + // Stop the router: the port must stay up but leave the router set. + if err := rh.Stop(); err != nil { + t.Fatalf("router Stop: %v", err) + } + if r.HasPort(p) { + t.Fatal("router stop should detach the port from the router set") + } + if !p.running.Load() { + t.Fatal("router stop must NOT stop the port") + } + + // Restart the router: it should re-adopt the still-running port. + if err := rh.Start(ctx); err != nil { + t.Fatalf("router restart: %v", err) + } + if !r.HasPort(p) { + t.Fatal("router restart should re-attach the running routed port") + } + if !p.running.Load() { + t.Fatal("port should still be running after router restart") + } +} diff --git a/internal/app/router_attach_test.go b/internal/app/router_attach_test.go new file mode 100644 index 0000000..35f743e --- /dev/null +++ b/internal/app/router_attach_test.go @@ -0,0 +1,96 @@ +package app + +import ( + "testing" + + "github.com/ObsoleteMadness/ClassicStack/config" +) + +// TestAppConfigFromModel_RouterAttach verifies that [Router].ports drives the +// per-transport AttachRouter flags: an empty list binds every transport, while +// a non-empty list binds only the named ones (others run standalone). +func TestAppConfigFromModel_RouterAttach(t *testing.T) { + t.Run("empty list binds all", func(t *testing.T) { + m := config.Defaults() + cfg, err := appConfigFromModel(m) + if err != nil { + t.Fatalf("appConfigFromModel: %v", err) + } + if !cfg.LToUDPAttachRouter || !cfg.TashTalkAttachRouter || !cfg.EtherTalkAttachRouter { + t.Fatalf("empty Ports should attach all, got LToUDP=%v TashTalk=%v EtherTalk=%v", + cfg.LToUDPAttachRouter, cfg.TashTalkAttachRouter, cfg.EtherTalkAttachRouter) + } + }) + + t.Run("explicit list detaches the unlisted", func(t *testing.T) { + m := config.Defaults() + m.Router.Ports = []string{config.RouterPortLToUDP, config.RouterPortEtherTalk} + cfg, err := appConfigFromModel(m) + if err != nil { + t.Fatalf("appConfigFromModel: %v", err) + } + if !cfg.LToUDPAttachRouter || !cfg.EtherTalkAttachRouter { + t.Errorf("listed transports should attach; got LToUDP=%v EtherTalk=%v", + cfg.LToUDPAttachRouter, cfg.EtherTalkAttachRouter) + } + if cfg.TashTalkAttachRouter { + t.Errorf("unlisted TashTalk should be standalone, got attached") + } + }) +} + +// TestRouterPortsModel_Projection verifies modelFromAppConfig projects the +// resolved attach flags back into [Router].ports: nil when every configured +// transport is attached, and an explicit allow-list when one is detached. +func TestRouterPortsModel_Projection(t *testing.T) { + base := defaultAppConfig() + // Configure all three transports so they count as present. + base.LToUDP.Enabled = true + base.TashTalk.Port = "COM1" + base.EtherTalk.Device = "eth0" + + t.Run("all attached projects no [Router] section", func(t *testing.T) { + cfg := base + if ports := routerPortsModel(cfg); ports != nil { + t.Errorf("all attached should project nil Ports, got %v", ports) + } + }) + + t.Run("one detached projects the attached allow-list", func(t *testing.T) { + cfg := base + cfg.TashTalkAttachRouter = false + ports := routerPortsModel(cfg) + want := []string{config.RouterPortLToUDP, config.RouterPortEtherTalk} + if len(ports) != len(want) { + t.Fatalf("Ports = %v, want %v", ports, want) + } + for i := range want { + if ports[i] != want[i] { + t.Fatalf("Ports = %v, want %v", ports, want) + } + } + }) + + t.Run("detached-but-unconfigured transport has no effect", func(t *testing.T) { + cfg := defaultAppConfig() + cfg.LToUDP.Enabled = true // only LToUDP configured + cfg.LToUDPAttachRouter = true + // TashTalk is "detached" but has no serial port -> not configured, so it + // must not force an explicit allow-list. Every *configured* transport is + // attached, so the projection stays nil (no [Router] section). + cfg.TashTalkAttachRouter = false + if ports := routerPortsModel(cfg); ports != nil { + t.Fatalf("unconfigured detached transport should project nil, got %v", ports) + } + }) + + t.Run("real detached transport among configured forces the list", func(t *testing.T) { + cfg := base + cfg.EtherTalkAttachRouter = false + ports := routerPortsModel(cfg) + want := []string{config.RouterPortLToUDP, config.RouterPortTashTalk} + if len(ports) != len(want) || ports[0] != want[0] || ports[1] != want[1] { + t.Fatalf("Ports = %v, want %v", ports, want) + } + }) +} diff --git a/internal/app/router_hook.go b/internal/app/router_hook.go new file mode 100644 index 0000000..8f85e33 --- /dev/null +++ b/internal/app/router_hook.go @@ -0,0 +1,62 @@ +package app + +import ( + "context" + + "github.com/ObsoleteMadness/ClassicStack/netlog" + "github.com/ObsoleteMadness/ClassicStack/router" +) + +// routerHook adapts the AppleTalk router's service set (RTMP, ZIP, NBP, AEP, +// LLAP, …) to the standalone hook lifecycle, so the management UI can stop and +// start the routing engine on its own. It deliberately does NOT own port +// lifecycle: ports are independent hooks (see portHook). Instead, on Start it +// adopts every running routed port into the freshly started router, and on Stop +// it detaches them (leaving them running, their frames simply unrouted). +type routerHook struct { + router *router.Router + // routedPorts returns the port hooks for the router-attached ports, so the + // router hook can adopt/detach them as it starts/stops. Evaluated lazily so + // it always sees the current set. + routedPorts func() []*portHook + running bool +} + +func newRouterHook(r *router.Router, routedPorts func() []*portHook) *routerHook { + return &routerHook{router: r, routedPorts: routedPorts} +} + +// Start brings the routing services up and adopts any already-running routed +// ports so their frames route immediately. +func (h *routerHook) Start(ctx context.Context) error { + if h.running { + return nil + } + if err := h.router.StartServices(ctx); err != nil { + return err + } + for _, p := range h.routedPorts() { + if err := p.attachToRouter(ctx); err != nil { + netlog.Warn("[SUP][Router] attaching port: %v", err) + } + } + h.running = true + return nil +} + +// Stop detaches the running routed ports (leaving them up) and stops the +// routing services. +func (h *routerHook) Stop() error { + if !h.running { + return nil + } + for _, p := range h.routedPorts() { + p.detachFromRouter() + } + h.running = false + return h.router.StopServices() +} + +// IsRunning reports whether the routing services are live. Port hooks consult +// it to decide whether to attach on Start / detach on Stop. +func (h *routerHook) IsRunning() bool { return h.running } diff --git a/internal/app/supervisor.go b/internal/app/supervisor.go index 4fa6eb4..e50c673 100644 --- a/internal/app/supervisor.go +++ b/internal/app/supervisor.go @@ -15,6 +15,7 @@ import ( "github.com/ObsoleteMadness/ClassicStack/port" "github.com/ObsoleteMadness/ClassicStack/port/ethertalk" "github.com/ObsoleteMadness/ClassicStack/port/localtalk" + "github.com/ObsoleteMadness/ClassicStack/protocol/ddp" "github.com/ObsoleteMadness/ClassicStack/router" "github.com/ObsoleteMadness/ClassicStack/service" "github.com/ObsoleteMadness/ClassicStack/service/aep" @@ -43,15 +44,23 @@ type Supervisor struct { model *config.Model reg *status.Registry - mu sync.Mutex - ctx context.Context - router *router.Router - ports []port.Port - portNames []string // status-unit name per entry in ports - meters []*portMeter // per-port traffic meters (nil entries skipped) - hooks map[string]hook // name -> standalone hook (ipx, netbeui, …) - order []string // hook start order; stop walks it in reverse - started bool + mu sync.Mutex + ctx context.Context + router *router.Router + ports []port.Port // all built ports (routed + standalone) + portNames []string // status-unit name per entry in ports + portRouted []bool // routed flag per entry in ports (parallel to portNames) + meters []*portMeter // per-port traffic meters (nil entries skipped) + hooks map[string]hook // name -> standalone hook (ipx, netbeui, …) + order []string // hook start order; stop walks it in reverse + started bool + + // portHooks maps each port's status-unit name to the hook that owns its + // lifecycle, so the router hook can adopt/detach routed ports as it + // starts/stops. routerHook is the hook over the AppleTalk routing services. + // Both are also recorded in hooks/order like any other unit. + portHooks map[string]*portHook + routerHook *routerHook // captureSinks are closed on Stop. captureSinks []closer @@ -112,11 +121,12 @@ type closer interface{ Close() error } // constructs but does not start anything; call Start to bring it up. func NewSupervisor(cfg appConfig, source config.Source, model *config.Model) (*Supervisor, error) { s := &Supervisor{ - cfg: cfg, - source: source, - model: model, - reg: status.Default, - hooks: make(map[string]hook), + cfg: cfg, + source: source, + model: model, + reg: status.Default, + hooks: make(map[string]hook), + portHooks: make(map[string]*portHook), } if err := s.build(); err != nil { return nil, err @@ -144,7 +154,16 @@ func (s *Supervisor) build() error { return err } - s.router = router.New("router", ports, services) + // The router is built with NO ports in its set: ports are independent units + // driven by their own hooks. Routed ports attach themselves to the router + // when both are running (see buildPortAndRouterHooks / portHook). + s.router = router.New("router", nil, services) + + // Wrap the router and each port in lifecycle hooks so the management UI can + // start/stop them individually, recording them as the first units in start + // order (the router, then the ports, then — via buildHooks — the DDP + // subsystems that ride it). + s.buildPortAndRouterHooks() // Traffic logging is driven by config so toggling it from the UI takes // effect on Apply. Disabling clears the sink. @@ -175,19 +194,23 @@ func (s *Supervisor) build() error { return nil } -// buildPorts constructs the configured ports and attaches capture sinks. +// buildPorts constructs the configured ports and attaches capture sinks. Each +// port records, via registerPortStatus, whether it is router-attached (the +// routed flag, derived from the [Router].ports allow-list — a router-config +// setting, not a per-port one). The port hooks built later consult that flag to +// decide whether a running port attaches to the router (see portHook). func (s *Supervisor) buildPorts() ([]port.Port, []closer, error) { cfg := s.cfg var ports []port.Port if cfg.LToUDP.Enabled { p := localtalk.NewLtoudpPort(cfg.LToUDP.Interface, uint16(cfg.LToUDP.SeedNetwork), []byte(cfg.LToUDP.SeedZone)) ports = append(ports, p) - s.registerPortStatus("LToUDP", p, true, map[string]string{"seed_zone": cfg.LToUDP.SeedZone}) + s.registerPortStatus("LToUDP", p, true, cfg.LToUDPAttachRouter, map[string]string{"seed_zone": cfg.LToUDP.SeedZone}) } if cfg.TashTalk.Port != "" { p := localtalk.NewTashTalkPort(cfg.TashTalk.Port, uint16(cfg.TashTalk.SeedNetwork), []byte(cfg.TashTalk.SeedZone)) ports = append(ports, p) - s.registerPortStatus("TashTalk", p, true, map[string]string{"seed_zone": cfg.TashTalk.SeedZone}) + s.registerPortStatus("TashTalk", p, true, cfg.TashTalkAttachRouter, map[string]string{"seed_zone": cfg.TashTalk.SeedZone}) } if cfg.EtherTalk.Device != "" { ep, err := s.buildEtherTalkPort() @@ -195,7 +218,9 @@ func (s *Supervisor) buildPorts() ([]port.Port, []closer, error) { return nil, nil, err } ports = append(ports, ep) - s.registerPortStatus("EtherTalk", ep, true, map[string]string{"device": cfg.EtherTalk.Device, "seed_zone": cfg.EtherTalk.SeedZone}) + // The bound interface is carried by Binding (the port's ShortString); + // don't duplicate it as a "device" property. + s.registerPortStatus("EtherTalk", ep, true, cfg.EtherTalkAttachRouter, map[string]string{"seed_zone": cfg.EtherTalk.SeedZone}) } if len(ports) == 0 { return nil, nil, fmt.Errorf("no ports configured") @@ -223,6 +248,14 @@ func (s *Supervisor) buildPorts() ([]port.Port, []closer, error) { return ports, sinks, nil } +// noopRouterHooks is the port.RouterHooks sink given to standalone ports. A +// standalone port is detached from the router: it still comes up, acquires its +// node, and feeds capture sinks / traffic meters / the observer, but its decoded +// inbound datagrams are intentionally dropped here rather than routed. +type noopRouterHooks struct{} + +func (noopRouterHooks) Inbound(ddp.Datagram, port.Port) {} + func (s *Supervisor) buildEtherTalkPort() (port.Port, error) { cfg := s.cfg hwAddr, err := hwaddr.ParseEthernet(cfg.EtherTalk.HWAddress) @@ -280,7 +313,7 @@ func (s *Supervisor) buildServices() ([]service.Service, error) { "zone": cfg.EtherTalk.SeedZone, "parse_packets": boolStr(cfg.ParsePackets), "log_traffic": boolStr(cfg.LogTraffic), - "captures": s.activeCaptureSummary(), + "captures": s.appleTalkCaptureSummary(), }) macIP, err := wireMacIP(MacIPConfig{ @@ -612,6 +645,52 @@ func (s *Supervisor) ddpServiceEnabled(name string) bool { } } +// buildPortAndRouterHooks wraps the AppleTalk router and each configured port +// in a lifecycle hook and records them as the first restartable units, in start +// order: the router first, then every port. Starting the router before the +// ports lets each routed port join the live router with the clean AddPort path +// (rather than coming up detached and being re-attached). The two are otherwise +// loosely coupled — a port runs whether or not the router is up, and the router +// routes whatever ports happen to be up — so no dependency edges are declared +// between them. The DDP subsystems (built later) do depend on the router. +func (s *Supervisor) buildPortAndRouterHooks() { + s.routerHook = newRouterHook(s.router, s.routedPortHooks) + s.hooks["Router"] = s.routerHook + s.order = append(s.order, "Router") + // Promote the Router unit (registered in buildServices) to a hook so the + // dashboard surfaces lifecycle controls. It declares no DependsOn (loosely + // coupled to ports); the DDP subsystems depend on it, not the reverse. + s.promoteUnitToHook("Router", true, nil) + + routerRunning := func() bool { return s.routerHook != nil && s.routerHook.IsRunning() } + for i, p := range s.ports { + name := s.portNames[i] + routed := s.portRouted[i] + h := newPortHook(p, s.router, routed, routerRunning) + s.portHooks[name] = h + s.hooks[name] = h + s.order = append(s.order, name) + // Promote the already-registered port unit to a hook so the dashboard + // surfaces start/stop/restart controls; the hook lifecycle drives its + // Running flag. Ports are independent of the router (no DependsOn). + s.promoteUnitToHook(name, true, nil) + } +} + +// routedPortHooks returns the port hooks for the router-attached ports, in +// registration order, for the router hook to adopt/detach on start/stop. +func (s *Supervisor) routedPortHooks() []*portHook { + out := make([]*portHook, 0, len(s.portNames)) + for i, name := range s.portNames { + if s.portRouted[i] { + if h := s.portHooks[name]; h != nil { + out = append(out, h) + } + } + } + return out +} + // buildDDPServiceHooks wraps each recorded DDP service group in a // ddpServiceHook and registers it as a restartable unit. It re-Sets the unit's // status to KindHook (preserving the enriched properties registered earlier) @@ -626,15 +705,19 @@ func (s *Supervisor) buildDDPServiceHooks() { } s.hooks[name] = h s.order = append(s.order, name) - s.promoteUnitToHook(name, s.ddpServiceEnabled(name)) + // DDP subsystems ride the AppleTalk router's service set, so they depend + // on the Router: stopping the router stops them (and they restart with + // it), and the UI surfaces that ordering. + s.promoteUnitToHook(name, s.ddpServiceEnabled(name), []string{"Router"}) } } // promoteUnitToHook re-publishes an already-registered status unit as a // KindHook (so the UI shows lifecycle controls) while preserving its binding, -// properties, and other detail. The unit starts not-running; the hook -// lifecycle sets the running flag. -func (s *Supervisor) promoteUnitToHook(name string, enabled bool) { +// properties, and other detail. dependsOn records lifecycle ordering for the +// dashboard and the dependents-of cascade. The unit starts not-running; the +// hook lifecycle sets the running flag. +func (s *Supervisor) promoteUnitToHook(name string, enabled bool, dependsOn []string) { for _, u := range s.reg.Snapshot() { if u.Name != name { continue @@ -642,6 +725,7 @@ func (s *Supervisor) promoteUnitToHook(name string, enabled bool) { u.Kind = status.KindHook u.Enabled = enabled u.Running = false + u.DependsOn = dependsOn s.reg.Set(u) return } @@ -662,11 +746,14 @@ func (s *Supervisor) addHook(name string, h hook, enabled bool, dependsOn []stri }) } -func (s *Supervisor) registerPortStatus(name string, p port.Port, enabled bool, props map[string]string) { +func (s *Supervisor) registerPortStatus(name string, p port.Port, enabled, routed bool, props map[string]string) { if props == nil { props = map[string]string{} } props["range"] = fmt.Sprintf("%d-%d", p.NetworkMin(), p.NetworkMax()) + // routed=on means the port is part of the AppleTalk router; off means it + // runs standalone (no RTMP/ZIP/forwarding). + props["routed"] = boolStr(routed) s.reg.Set(status.Unit{ Name: name, Kind: status.KindPort, @@ -675,6 +762,7 @@ func (s *Supervisor) registerPortStatus(name string, p port.Port, enabled bool, Properties: props, }) s.portNames = append(s.portNames, name) + s.portRouted = append(s.portRouted, routed) } func (s *Supervisor) registerServiceStatus(name string, enabled bool, props map[string]string) { @@ -815,7 +903,12 @@ func (s *Supervisor) registerIPXStatus(h IPXHook, enabled bool) { } cfg := s.cfg iface := s.resolveIPXInterface() - props := map[string]string{"device": iface, "framing": ipxFramingLabel(cfg.IPXFraming)} + // The bound interface is carried by Binding (below); don't duplicate it as a + // "device" property. + props := map[string]string{ + "framing": ipxFramingLabel(cfg.IPXFraming), + "capture": captureLabel(cfg.Capture.IPX), + } // The IPX router carries the resolved network number (the configured // internal network, or the router default when unset). if r := h.Router(); r != nil { @@ -907,12 +1000,15 @@ func (s *Supervisor) registerNetBEUIStatus(h NetBEUIHook, enabled bool) { return } iface := s.resolveNetBEUIInterface() + // The bound interface is carried by Binding; don't duplicate it as "device". s.reg.Set(status.Unit{ - Name: "NetBEUI", - Kind: status.KindHook, - Enabled: enabled, - Binding: iface, - Properties: map[string]string{"device": iface}, + Name: "NetBEUI", + Kind: status.KindHook, + Enabled: enabled, + Binding: iface, + Properties: map[string]string{ + "capture": captureLabel(s.cfg.Capture.NetBEUI), + }, }) } @@ -988,16 +1084,17 @@ func boolStr(b bool) string { return "off" } -// activeCaptureSummary lists the transports with an active pcap capture -// path configured, for the dashboard's packet-dump status. -func (s *Supervisor) activeCaptureSummary() string { +// appleTalkCaptureSummary lists the AppleTalk transports with an active pcap +// capture path configured, for the Router unit's packet-dump status. Only the +// transports the AppleTalk router actually carries belong here — LocalTalk +// (LToUDP/TashTalk) and EtherTalk. IPX and NetBEUI are separate, non-DDP +// protocols; their captures surface on their own units (see captureLabel). +func (s *Supervisor) appleTalkCaptureSummary() string { c := s.cfg.Capture var active []string for name, path := range map[string]string{ "localtalk": c.LocalTalk, "ethertalk": c.EtherTalk, - "ipx": c.IPX, - "netbeui": c.NetBEUI, } { if strings.TrimSpace(path) != "" { active = append(active, name) @@ -1010,6 +1107,15 @@ func (s *Supervisor) activeCaptureSummary() string { return strings.Join(active, ",") } +// captureLabel renders a single transport's capture path for its status unit: +// the configured path, or "off" when no capture is configured. +func captureLabel(path string) string { + if strings.TrimSpace(path) == "" { + return "off" + } + return path +} + func (s *Supervisor) closeSinks() { for _, c := range s.captureSinks { _ = c.Close() diff --git a/internal/app/supervisor_control.go b/internal/app/supervisor_control.go index ece42cc..6f93f51 100644 --- a/internal/app/supervisor_control.go +++ b/internal/app/supervisor_control.go @@ -73,6 +73,23 @@ func (s *Supervisor) Apply(ctx context.Context, cfg control.ConfigModel) error { return nil } +// RestartAll restarts the whole running stack — every port, the AppleTalk +// router, and all hooks — without changing the configuration. It is the +// diagnostics screen's "Restart" action. Like Apply it is an atomic +// stop/rebuild/start that preserves the Web UI server (the restart is driven by +// an in-flight UI request, so the server must outlive it); it simply rebuilds +// from the current model rather than a new one. +func (s *Supervisor) RestartAll(ctx context.Context) error { + s.mu.Lock() + model := s.model + s.mu.Unlock() + if model == nil { + return fmt.Errorf("supervisor: no config model to restart from") + } + netlog.Info("[SUP] restarting whole stack (web UI preserved)") + return s.Apply(ctx, model) +} + // detachWebUI removes the Web UI hook from the running stack without // stopping it, returning it so Apply can re-attach it to the rebuilt stack. func (s *Supervisor) detachWebUI() hook { @@ -118,6 +135,9 @@ func (s *Supervisor) adoptFrom(other *Supervisor) { s.router = other.router s.ports = other.ports s.portNames = other.portNames + s.portRouted = other.portRouted + s.portHooks = other.portHooks + s.routerHook = other.routerHook s.meters = other.meters s.hooks = other.hooks s.order = other.order diff --git a/internal/app/supervisor_lifecycle.go b/internal/app/supervisor_lifecycle.go index 2952c18..0692aed 100644 --- a/internal/app/supervisor_lifecycle.go +++ b/internal/app/supervisor_lifecycle.go @@ -18,15 +18,10 @@ func (s *Supervisor) Start(ctx context.Context) error { return fmt.Errorf("supervisor already started") } - if err := s.router.Start(ctx); err != nil { - return fmt.Errorf("router start: %w", err) - } - netlog.Info("[SUP] router away!") - s.reg.SetRunning("Router", true) - for _, name := range s.portNames { - s.reg.SetRunning(name, true) - } - + // Ports and the AppleTalk router are now hooks in s.order (ports first, then + // the router, then the DDP subsystems that ride it), so the single walk + // below brings the whole stack up in dependency order. Nothing is started + // inline here any more. s.ctx = ctx for _, name := range s.order { if s.alreadyRunning[name] { @@ -89,17 +84,12 @@ func (s *Supervisor) Stop() error { close(s.statusTickerStop) s.statusTickerStop = nil } + // Tear the stack down in reverse start order: DDP subsystems, then the + // router, then the ports — all driven through the hook lifecycle. for i := len(s.order) - 1; i >= 0; i-- { name := s.order[i] s.stopHookLocked(name) } - if err := s.router.Stop(); err != nil { - netlog.Warn("[SUP] router stop warning: %v", err) - } - s.reg.SetRunning("Router", false) - for _, name := range s.portNames { - s.reg.SetRunning(name, false) - } s.closeSinks() s.started = false return nil diff --git a/pkg/control/control.go b/pkg/control/control.go index 341d1f8..d09ceb5 100644 --- a/pkg/control/control.go +++ b/pkg/control/control.go @@ -32,6 +32,9 @@ type Supervisor interface { StopService(name string) error // RestartService restarts a single named unit (and its dependents). RestartService(ctx context.Context, name string) error + // RestartAll restarts the whole stack (all ports, the router, and every + // hook) without a configuration change. + RestartAll(ctx context.Context) error // ListInterfaces returns the host's network interfaces for the // EtherTalk/IPX/NetBEUI/MacIP dropdowns. Each entry carries the device // Name pcap opens plus a human-friendly Description and addresses so the @@ -186,3 +189,11 @@ func (p *Plane) RestartService(ctx context.Context, name string) error { } return p.sup.RestartService(ctx, name) } + +// RestartAll restarts the whole stack without a configuration change. +func (p *Plane) RestartAll(ctx context.Context) error { + if p.sup == nil { + return ErrNoSupervisor + } + return p.sup.RestartAll(ctx) +} diff --git a/pkg/control/control_test.go b/pkg/control/control_test.go index a3175e7..a294b9a 100644 --- a/pkg/control/control_test.go +++ b/pkg/control/control_test.go @@ -18,6 +18,7 @@ func (f *fakeModel) ToTOML() ([]byte, error) { return []byte(f.toml), nil } type fakeSup struct { applied int restarts []string + restartAll int extMapWritten []byte } @@ -28,6 +29,7 @@ func (s *fakeSup) RestartService(_ context.Context, name string) error { s.restarts = append(s.restarts, name) return nil } +func (s *fakeSup) RestartAll(_ context.Context) error { s.restartAll++; return nil } func (s *fakeSup) ListInterfaces() ([]InterfaceInfo, error) { return []InterfaceInfo{{Name: "eth0", Description: "Ethernet"}}, nil } @@ -81,6 +83,24 @@ func TestExtMapDelegates(t *testing.T) { } } +func TestRestartAllDelegates(t *testing.T) { + sup := &fakeSup{} + p := New(Deps{Supervisor: sup}) + if err := p.RestartAll(context.Background()); err != nil { + t.Fatalf("RestartAll: %v", err) + } + if sup.restartAll != 1 { + t.Errorf("RestartAll forwarded %d times, want 1", sup.restartAll) + } +} + +func TestRestartAllWithoutSupervisor(t *testing.T) { + p := New(Deps{Config: &fakeModel{}}) + if err := p.RestartAll(context.Background()); !errors.Is(err, ErrNoSupervisor) { + t.Errorf("RestartAll without supervisor = %v, want ErrNoSupervisor", err) + } +} + func TestExtMapWithoutSupervisor(t *testing.T) { p := New(Deps{Config: &fakeModel{}}) if _, _, err := p.ExtMap(); !errors.Is(err, ErrNoSupervisor) { diff --git a/pkg/control/diagnostics.go b/pkg/control/diagnostics.go index 78b5a4b..c52aee5 100644 --- a/pkg/control/diagnostics.go +++ b/pkg/control/diagnostics.go @@ -15,6 +15,21 @@ type NetworkInfo struct { Port string `json:"port"` } +// RTMPEntry is one routing-table entry reported by RTMPTable. State is the +// RTMP aging state ("good" | "suspect" | "bad" | "worst") — RTMP's notion of an +// entry's age, advanced on each aging tick and reset when the route is heard +// again. Distance 0 means a directly-connected network reached via Port; for +// learned networks NextNetwork/NextNode is the next-hop router. +type RTMPEntry struct { + NetworkMin uint16 `json:"network_min"` + NetworkMax uint16 `json:"network_max"` + Distance uint8 `json:"distance"` + Port string `json:"port"` + NextNetwork uint16 `json:"next_network"` + NextNode uint8 `json:"next_node"` + State string `json:"state"` +} + // EchoResult is the outcome of an AEP (AppleTalk Echo Protocol) probe. type EchoResult struct { Network uint16 `json:"network"` @@ -63,6 +78,9 @@ type Diagnostics interface { ZIPEnumerate(ctx context.Context) ([]ZoneInfo, error) // DDPEnumerate lists networks/nodes from the routing table. DDPEnumerate(ctx context.Context) ([]NetworkInfo, error) + // RTMPTable returns the full RTMP routing table including each entry's + // aging state. + RTMPTable(ctx context.Context) ([]RTMPEntry, error) // SMBBrowse returns the SMB/NetBIOS browse list of servers. Only // available in SMB-enabled builds. SMBBrowse(ctx context.Context) ([]ServerInfo, error) diff --git a/pkg/control/diagnostics_unavailable.go b/pkg/control/diagnostics_unavailable.go index ab04443..4c792b2 100644 --- a/pkg/control/diagnostics_unavailable.go +++ b/pkg/control/diagnostics_unavailable.go @@ -29,6 +29,10 @@ func (unavailableDiagnostics) DDPEnumerate(context.Context) ([]NetworkInfo, erro return nil, ErrDiagUnavailable } +func (unavailableDiagnostics) RTMPTable(context.Context) ([]RTMPEntry, error) { + return nil, ErrDiagUnavailable +} + func (unavailableDiagnostics) SMBBrowse(context.Context) ([]ServerInfo, error) { return nil, ErrDiagUnavailable } diff --git a/pkg/control/stats.go b/pkg/control/stats.go index b3b9373..4b764e7 100644 --- a/pkg/control/stats.go +++ b/pkg/control/stats.go @@ -9,10 +9,12 @@ import ( // Frame is a per-second snapshot of streamed statistics pushed to UI // subscribers. Rates holds derived per-second deltas for counter metrics; -// Gauges holds the latest absolute value for gauge metrics. +// Totals holds the latest cumulative value for those same counters; Gauges +// holds the latest absolute value for gauge metrics. type Frame struct { UnixMilli int64 `json:"t"` Rates map[string]int64 `json:"rates,omitempty"` + Totals map[string]int64 `json:"totals,omitempty"` Gauges map[string]int64 `json:"gauges,omitempty"` } @@ -71,10 +73,12 @@ func (b *statsBroadcaster) broadcast() { frame := Frame{ UnixMilli: time.Now().UnixMilli(), Rates: make(map[string]int64, len(b.counters)), + Totals: make(map[string]int64, len(b.counters)), Gauges: make(map[string]int64, len(b.gauges)), } for name, v := range b.counters { frame.Rates[name] = v - b.prev[name] + frame.Totals[name] = v b.prev[name] = v } for name, v := range b.gauges { diff --git a/router/dynamic.go b/router/dynamic.go index b1ccd43..2cefebd 100644 --- a/router/dynamic.go +++ b/router/dynamic.go @@ -2,6 +2,7 @@ package router import ( "context" + "slices" "github.com/ObsoleteMadness/ClassicStack/netlog" "github.com/ObsoleteMadness/ClassicStack/port" @@ -68,6 +69,42 @@ func (r *Router) AddPort(_ context.Context, p port.Port) error { // router stops advertising and forwarding to them. func (r *Router) RemovePort(p port.Port) error { netlog.Info("%s removing port %T", r.ShortString(), p) + r.DetachPort(p) + return p.Stop() +} + +// AttachStartedPort adds an already-started port to the active port set and +// binds the LLAP link manager to it, without starting the port. It is the +// membership-only counterpart to AddPort: the port's own lifecycle owner (the +// supervisor's port hook) has already brought the port up, so the router only +// needs to begin routing through it. Idempotent: a port already in the set is +// not added twice. RTMP's periodic advertisement picks up the port's networks +// and zones from its next cycle. +func (r *Router) AttachStartedPort(p port.Port) { + netlog.Info("%s attaching started port %T", r.ShortString(), p) + r.bindPortLLAP(p) + r.membership.Lock() + defer r.membership.Unlock() + if slices.Contains(r.Ports, p) { + return + } + r.Ports = append(r.Ports, p) +} + +// HasPort reports whether p is currently in the active port set. +func (r *Router) HasPort(p port.Port) bool { + r.membership.RLock() + defer r.membership.RUnlock() + return slices.Contains(r.Ports, p) +} + +// DetachPort removes p from the active port set and withdraws every route and +// zone reachable through it, without stopping the port. It is the +// membership-only counterpart to RemovePort: the port keeps running (its +// frames simply stop being routed) while its lifecycle owner decides whether +// to also stop it. Detaching a port the router does not hold is a no-op. +func (r *Router) DetachPort(p port.Port) { + netlog.Info("%s detaching port %T", r.ShortString(), p) r.membership.Lock() for i, pt := range r.Ports { if pt == p { @@ -76,10 +113,7 @@ func (r *Router) RemovePort(p port.Port) error { } } r.membership.Unlock() - - // Withdraw routes/zones for the port before stopping it so dispatch no - // longer selects it as a next hop. + // Withdraw routes/zones for the port so dispatch no longer selects it as a + // next hop. r.RoutingTable.RemoveEntriesForPort(p) - - return p.Stop() } diff --git a/router/router.go b/router/router.go index 9c9ff1e..990cd93 100644 --- a/router/router.go +++ b/router/router.go @@ -146,7 +146,67 @@ func (r *Router) deliver(datagram ddp.Datagram, rxPort port.Port) { } func (r *Router) Start(ctx context.Context) error { - services, ports := r.snapshotMembership() + if err := r.startLLAPServices(ctx); err != nil { + return err + } + _, ports := r.snapshotMembership() + for _, p := range ports { + netlog.Info("starting %T...", p) + if err := p.Start(r); err != nil { + return err + } + } + netlog.Info("all ports started!") + return r.startNonLLAPServices(ctx) +} + +func (r *Router) Stop() error { + errs := r.stopServices() + _, ports := r.snapshotMembership() + for _, p := range ports { + netlog.Info("stopping %T...", p) + if err := p.Stop(); err != nil { + errs = append(errs, err) + } + } + netlog.Info("all ports stopped!") + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +// StartServices starts only the router's DDP service set (LLAP first, so the +// LocalTalk link manager is live before the rest), leaving port lifecycle to +// the ports' own owners. It is the service-only counterpart to Start, used by +// the supervisor's router hook so the router can be stopped and started while +// its ports keep running. Already-attached ports are (re)bound to the LLAP +// link manager so LocalTalk ports route again after a router restart. +func (r *Router) StartServices(ctx context.Context) error { + if err := r.startLLAPServices(ctx); err != nil { + return err + } + // Re-bind any ports already in the set to the freshly started LLAP manager. + _, ports := r.snapshotMembership() + for _, p := range ports { + r.bindPortLLAP(p) + } + return r.startNonLLAPServices(ctx) +} + +// StopServices stops only the router's DDP service set, leaving the ports +// running. It is the service-only counterpart to Stop. +func (r *Router) StopServices() error { + if errs := r.stopServices(); len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +// startLLAPServices starts the LLAP service(s) ahead of ports and other +// services: LocalTalk-style ports bind to its link manager at Start. +func (r *Router) startLLAPServices(ctx context.Context) error { + services, _ := r.snapshotMembership() for _, s := range services { if _, ok := s.(*llap.Service); !ok { continue @@ -156,13 +216,13 @@ func (r *Router) Start(ctx context.Context) error { return err } } - for _, p := range ports { - netlog.Info("starting %T...", p) - if err := p.Start(r); err != nil { - return err - } - } - netlog.Info("all ports started!") + return nil +} + +// startNonLLAPServices starts every service except LLAP (which +// startLLAPServices brings up first). +func (r *Router) startNonLLAPServices(ctx context.Context) error { + services, _ := r.snapshotMembership() for _, s := range services { if _, ok := s.(*llap.Service); ok { continue @@ -176,8 +236,10 @@ func (r *Router) Start(ctx context.Context) error { return nil } -func (r *Router) Stop() error { - services, ports := r.snapshotMembership() +// stopServices stops every service, collecting (not returning early on) +// errors so a single failing Stop cannot strand the rest. +func (r *Router) stopServices() []error { + services, _ := r.snapshotMembership() var errs []error for _, s := range services { netlog.Info("stopping %T...", s) @@ -186,17 +248,7 @@ func (r *Router) Stop() error { } } netlog.Info("all services stopped!") - for _, p := range ports { - netlog.Info("stopping %T...", p) - if err := p.Stop(); err != nil { - errs = append(errs, err) - } - } - netlog.Info("all ports stopped!") - if len(errs) > 0 { - return errors.Join(errs...) - } - return nil + return errs } // snapshotMembership returns copies of the current service and port slices @@ -380,6 +432,12 @@ func (r *Router) RoutingEntries() []struct { return out } +// RTMPSnapshot returns the full routing table with each entry's RTMP aging +// state, for read-only diagnostics (the management UI's RTMP table view). +func (r *Router) RTMPSnapshot() []RoutingTableSnapshotEntry { + return r.RoutingTable.Snapshot() +} + func (r *Router) RoutingConsider(entry *service.RouteEntry) bool { return r.RoutingTable.Consider(&RoutingTableEntry{ ExtendedNetwork: entry.ExtendedNetwork, diff --git a/router/routing_table.go b/router/routing_table.go index a2ba0eb..e26d69b 100644 --- a/router/routing_table.go +++ b/router/routing_table.go @@ -217,6 +217,45 @@ func (t *RoutingTable) Age() { } } +// stateName maps an internal RTMP aging state to a human label. RTMP routers +// age entries through Good → Suspect → Bad → (removed) on successive aging +// ticks; receiving the route again resets it to Good. This validity state is +// RTMP's notion of an entry's "age" — there is no wall-clock timestamp. +func stateName(s int) string { + switch s { + case stateGood: + return "good" + case stateSus: + return "suspect" + case stateBad: + return "bad" + case stateWorst: + return "worst" + default: + return "unknown" + } +} + +// RoutingTableSnapshotEntry is one routing-table entry plus its RTMP aging +// state, for read-only diagnostics. +type RoutingTableSnapshotEntry struct { + Entry *RoutingTableEntry + State string // RTMP aging state: good | suspect | bad | worst +} + +// Snapshot returns every distinct routing-table entry with its RTMP aging +// state. Directly-connected entries (Distance 0) are always "good"; learned +// entries carry the state the aging machine has reached. +func (t *RoutingTable) Snapshot() []RoutingTableSnapshotEntry { + t.mu.RLock() + defer t.mu.RUnlock() + out := make([]RoutingTableSnapshotEntry, 0, len(t.entryByKey)) + for k, e := range t.entryByKey { + out = append(out, RoutingTableSnapshotEntry{Entry: e, State: stateName(t.stateByKey[k])}) + } + return out +} + func (t *RoutingTable) Entries() []struct { Entry *RoutingTableEntry Bad bool diff --git a/router/routing_table_snapshot_test.go b/router/routing_table_snapshot_test.go new file mode 100644 index 0000000..02dbdc9 --- /dev/null +++ b/router/routing_table_snapshot_test.go @@ -0,0 +1,58 @@ +package router + +import "testing" + +// TestRoutingTableSnapshot verifies Snapshot reports each entry with its RTMP +// aging state: a directly-connected route is "good", and the aging machine +// advances a learned route good → suspect → bad → worst on successive ticks. +func TestRoutingTableSnapshot(t *testing.T) { + r := newTestRouter() + p := &fakePort{name: "fake", netMin: 10, netMax: 12} + + // Directly-connected route (Distance 0) is always good. + r.RoutingTable.SetPortRange(p, 10, 12) + // A learned route (Distance > 0) ages. + if !r.RoutingTable.Consider(&RoutingTableEntry{ + NetworkMin: 20, NetworkMax: 20, Distance: 1, Port: p, NextNetwork: 10, NextNode: 2, + }) { + t.Fatal("Consider rejected the learned route") + } + + stateFor := func(netMin uint16) string { + t.Helper() + for _, e := range r.RoutingTable.Snapshot() { + if e.Entry != nil && e.Entry.NetworkMin == netMin { + return e.State + } + } + t.Fatalf("no snapshot entry for network %d", netMin) + return "" + } + + if got := stateFor(10); got != "good" { + t.Errorf("connected route state = %q, want good", got) + } + if got := stateFor(20); got != "good" { + t.Errorf("fresh learned route state = %q, want good", got) + } + + // One aging tick demotes a good learned route to suspect; the connected + // route stays good. + r.RoutingTable.Age() + if got := stateFor(20); got != "suspect" { + t.Errorf("after 1 Age: learned route = %q, want suspect", got) + } + if got := stateFor(10); got != "good" { + t.Errorf("after 1 Age: connected route = %q, want good", got) + } + + // Further ticks walk suspect → bad → worst. + r.RoutingTable.Age() + if got := stateFor(20); got != "bad" { + t.Errorf("after 2 Age: learned route = %q, want bad", got) + } + r.RoutingTable.Age() + if got := stateFor(20); got != "worst" { + t.Errorf("after 3 Age: learned route = %q, want worst", got) + } +} diff --git a/server.toml b/server.toml index b9b9647..3f2ff74 100644 --- a/server.toml +++ b/server.toml @@ -1,127 +1,112 @@ +[Logging] +level = 'debug' +parse_packets = true +log_traffic = false + +[Router] + [Bridge] -mode = "pcap" -#device = '\Device\NPF_{B7D4E073-2185-4912-BBE8-3948C6636D02}' -device = '\Device\NPF_{7A63BBB0-EBC1-4FA7-A397-8E7F42E39A73}' -#device = '\Device\NPF_{9354BA7F-DE41-4A33-88F4-408A0F4A3C02}' -hw_address = "DE:AD:BE:EF:CA:FE" -bridge_mode = "auto" +mode = 'pcap' +device = '\Device\NPF_{9354BA7F-DE41-4A33-88F4-408A0F4A3C02}' +hw_address = 'DE:AD:BE:EF:CA:FE' +bridge_mode = 'auto' [LToUdp] -# LocalTalk over UDP Settings (used by Mini vMac UDP builds and SNOW emu) -enabled = true # Enable LToUDP - true for on, false for off -seed_network = 1 # LToUDP seed network number -seed_zone = "LToUDP Network" # LToUDP seed zone name +enabled = true +interface = '0.0.0.0' +seed_network = 1 +seed_zone = 'LToUDP Network' [TashTalk] -# TashTalk is a PIC-based RS422 LocalTalk to serial adaptor -# port = "COM6" # blank to disable, otherwise the serial port to use (eg COM1, /dev/ttyAMA0) -seed_network = 2 # TashTalk seed network number -seed_zone = "TashTalk Network" +port = '' +seed_network = 2 +seed_zone = 'TashTalk Network' [EtherTalk] -# EtherTalk is a pcap-based network bridge seed_network_min = 3 seed_network_max = 5 -seed_zone = "EtherTalk Network" -bridge_host_mac = "" # optional host adapter MAC for Wi-Fi bridge shim +seed_zone = 'EtherTalk Network' +desired_network = 3 +desired_node = 253 -[MacIP] -# MacIP Gateway Settings. Allows TCP over DDP. -enabled = true # true to enable MacIP gateway -mode = "pcap" # pcap or nat -zone = "" # MacIP gateway zone, defaults to EtherTalk zone -nat_subnet = "" # in NAT mode, the subnet to use -nat_gw = "" # in NAT mode, the gateway IP -lease_file = "leases.txt" # in NAT mode, persist DHCP leases here -ip_gateway = "" # upstream/default gateway on the IP-side network -dhcp_relay = true # convert MacTCP auto-config to DHCP requests -nameserver = "1.1.1.1" # DNS nameserver +[Capture] +localtalk = './captures/afp-localtalk.pcap' +ethertalk = './captures/afp-ethertalk.pcap' +ipx = './captures/ipx.pcap' +netbeui = './captures/netbeui.pcap' +snaplen = 65535 -[AFP] -# Apple Filing Protocol server settings +[MacIP] enabled = true -name = "ClassicStack" # Server name. Max 31 characters. -zone = "EtherTalk Network" -protocols = "ddp,tcp" # Comma-separated: ddp, tcp, or both -binding = ":548" -extension_map = "extmap.conf" # Netatalk-compatible extension mapping file - -[AFP.Volumes.Default] -name = "Welcome" -path = "./dist/Sample Volume" -read_only = true - -[AFP.Volumes.TestVolume] -name = "Test Volume" # Volume name. Max 31 characters. -path = 'C:\Mac\Test' -read_only = false -appledouble_mode = "modern" # per-volume override; "modern" (._ sidecars) or "legacy" (.appledouble folder) -rebuild_desktop_db = false - -[AFP.Volumes.Volume68k] -name = "Volume 68K" -path = 'C:\Mac\Volume68K' -appledouble_mode = "legacy" -rebuild_desktop_db = false - -[AFP.Volumes.MacGarden] -name = "Mac Garden" -fs_type = "macgarden" - -[Logging] -level = "debug" -parse_packets = true -log_traffic = false - -[Capture] -# Write a pcap-format capture of in-flight frames for offline analysis in -# Wireshark. Empty path disables that transport. LocalTalk captures use -# DLT_LTALK (114); EtherTalk captures use DLT_EN10MB (1). -localtalk = "./captures/afp-localtalk.pcap" # e.g. "captures/classicstack-localtalk.pcap" -ethertalk = "./captures/afp-ethertalk.pcap" # e.g. "captures/classicstack-ethertalk.pcap" -ipx = "./captures/ipx.pcap" # IPX rawlink capture (same DLT_EN10MB link type) -netbeui = "./captures/netbeui.pcap" # NBF over rawlink capture. -snaplen = 65535 # per-frame snap length +mode = 'pcap' +nat_subnet = '192.168.100.0/24' +lease_file = 'leases.txt' +dhcp_relay = true +nameserver = '1.1.1.1' [IPX] enabled = true -framing = "ethernet_ii" # ethernet_ii|raw_802_3|llc|snap -# internal_network = "" +framing = 'ethernet_ii' + +[IPXGW] +enabled = true +bindings = ['ClassicStack:EtherTalk Network'] [NetBEUI] enabled = true [NetBIOS] enabled = true -# transports = ["netbeui", "ipx", "tcp"] -transports = ["ipx", "netbeui"] -# scope_id = "" +transports = ['ipx', 'netbeui'] [SMB] enabled = true -# nbt_binding = ":139" -# direct_binding = ":445" -# guest_ok = true -server_name = "ClassicStack" -workgroup = "WORKGROUP" - +nbt_binding = ':139' +server_name = 'ClassicStack' +workgroup = 'WORKGROUP' +[SMB.Volumes] [SMB.Volumes.Public] -name = "Public" -path = "C:\\Mac" -fs_type = "local_fs" -read_only = false +name = 'Public' +path = 'C:\Mac' +fs_type = 'local_fs' -# [Shortname] -# enabled = false -# backend = "memory" # memory|sqlite -# db_path = "shortname.db" +[AFP] +enabled = true +name = 'ClassicStack' +zone = 'EtherTalk Network' +protocols = 'ddp,tcp' +binding = ':548' +extension_map = 'extmap.conf' +cnid_backend = 'sqlite' +use_decomposed_names = true +appledouble_mode = 'modern' + +[AFP.Volumes] +[AFP.Volumes.Default] +name = 'Welcome' +path = './dist/Sample Volume' +read_only = true + +[AFP.Volumes.MacGarden] +name = 'Mac Garden' +fs_type = 'macgarden' + +[AFP.Volumes.TestVolume] +name = 'Test Volume' +path = 'C:\Mac\Test' +appledouble_mode = 'modern' + +[AFP.Volumes.Volume68k] +name = 'Volume 68K' +path = 'C:\Mac\Volume68K' +appledouble_mode = 'legacy' +[Shortname] +windows_shortnames = true +backend = 'memory' [WebUI] -# Management web UI: a dashboard showing per-service status/statistics and a -# configuration editor. Requires a binary built with -tags webui (included in -# -tags all). Saving from the UI rewrites this file and removes comments, -# backing up the previous version to server.toml.NNNN first. enabled = true -bind = "127.0.0.1:8089" # IP:PORT to listen on; loopback by default \ No newline at end of file +bind = '127.0.0.1:8089' +tls = true diff --git a/server.toml.example b/server.toml.example index f235db1..e3f8643 100644 --- a/server.toml.example +++ b/server.toml.example @@ -5,6 +5,16 @@ device = '\\Device\\NPF_{1DFDAA9C-7DD4-40F8-B6D4-9298C273D654}' hw_address = "DE:AD:BE:EF:CA:FE" # host/bridge MAC used by raw-link consumers bridge_mode = "auto" # auto, ethernet, or wifi frame adaptation mode +[Router] +# Which transports the AppleTalk router binds to. List the transport section +# names ("LToUdp", "TashTalk", "EtherTalk") the router should participate in. +# An enabled transport that is NOT listed runs standalone: it still comes up and +# receives (and can be captured), but it is not part of the AppleTalk router — +# no RTMP/ZIP and no inter-port forwarding. Leave ports empty (or omit this +# whole section) to bind every enabled transport, which is the default. +# ports = ["LToUdp", "EtherTalk"] # TashTalk would then run standalone +ports = [] + [LToUdp] # LocalTalk over UDP Settings (used by Mini vMac UDP builds and SNOW emu) enabled = true # Enable LToUDP - true for on, false for off diff --git a/service/webui/api.go b/service/webui/api.go index bbcc1af..1bd0d2f 100644 --- a/service/webui/api.go +++ b/service/webui/api.go @@ -24,6 +24,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/config/download", s.handleDownload) s.mux.HandleFunc("/api/extmap", s.handleExtMap) s.mux.HandleFunc("/api/services/", s.handleServiceAction) + s.mux.HandleFunc("/api/restart-all", s.handleRestartAll) s.mux.HandleFunc("/api/stats/stream", s.handleStatsStream) s.mux.HandleFunc("/api/logs", s.handleLogHistory) s.mux.HandleFunc("/api/logs/stream", s.handleLogStream) @@ -152,6 +153,24 @@ func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(data) } +// handleRestartAll handles POST /api/restart-all: restart the whole stack +// (all ports, the router, and every hook) without a configuration change. +func (s *Server) handleRestartAll(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, errMethod) + return + } + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + if err := s.opts.Plane.RestartAll(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "action": "restart-all"}) +} + // handleServiceAction handles POST /api/services/{name}/restart. func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/service/webui/assets/app.css b/service/webui/assets/app.css index 5514199..561854a 100644 --- a/service/webui/assets/app.css +++ b/service/webui/assets/app.css @@ -69,6 +69,20 @@ main { padding: 1rem; } padding: 0.9rem; } .card h3 { margin: 0 0 0.4rem; display: flex; align-items: center; gap: 0.5rem; } +.card h3 .card-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } + +/* Per-card configuration cog. Sits at the right of the card header. */ +.cog { + border: none; + background: transparent; + padding: 0.1rem 0.3rem; + font-size: 1rem; + line-height: 1; + color: var(--muted); + cursor: pointer; + border-radius: 6px; +} +.cog:hover { color: var(--accent); background: var(--bg); } .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; background: var(--off); } .dot.running { background: var(--ok); } @@ -93,6 +107,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .kv b { color: #1c1c22; font-weight: 600; } .card .metric { font-variant-numeric: tabular-nums; } +/* Collapse the live-stats line on cards that publish no traffic counters, + so it adds no stray spacing while staying always-on for ports. */ +.card .metric:empty { display: none; } .card-actions { display: flex; gap: 0.4rem; margin-top: 0.6rem; } .card-actions button { margin-top: 0; } @@ -176,6 +193,7 @@ button { cursor: pointer; } button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); } +button.danger { background: #6e1f1f; color: #fff; border-color: #8a2a2a; } .status-line { margin-top: 0.8rem; @@ -190,6 +208,72 @@ button.primary { background: var(--accent); color: var(--accent-text); border-co .diag-tools { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } .diag-tools .aep input { width: 70px; } +/* ---- per-service config modal ---- */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 100; + background: rgba(20, 22, 28, 0.55); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 3rem 1rem; + overflow-y: auto; +} +/* `.modal-overlay` and the generic `.hidden` are both single-class selectors, + so the one declared later wins on equal specificity. As `.modal-overlay` + (display:flex) comes after `.hidden` (display:none), the modal would stay + visible when hidden. This two-class rule has higher specificity and keeps it + hidden. */ +.modal-overlay.hidden { display: none; } +.modal { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + width: min(640px, 100%); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; +} +.modal-head { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.9rem 1.1rem; + border-bottom: 1px solid var(--border); +} +.modal-head h2 { margin: 0; font-size: 1.05rem; flex: 1; } +.modal-close { + border: none; + background: transparent; + font-size: 1.1rem; + line-height: 1; + color: var(--muted); + cursor: pointer; + padding: 0.2rem 0.4rem; + border-radius: 6px; +} +.modal-close:hover { color: #1c1c22; background: var(--bg); } +.modal-note { + margin: 0.9rem 1.1rem 0; + background: #fff7e6; + border: 1px solid #f0d28a; + border-radius: 8px; + padding: 0.6rem 0.8rem; + font-size: 0.85rem; +} +.modal-body { padding: 0.4rem 1.1rem 0; } +.modal-body .config-panel:last-child { margin-bottom: 0.4rem; } +#modal-status { margin: 0.4rem 1.1rem 0; } +#modal-status:empty { display: none; } +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + padding: 0.9rem 1.1rem; + border-top: 1px solid var(--border); +} + /* ---- extension-map editor ---- */ .extmap { margin-top: 1.4rem; border-top: 1px solid #243042; padding-top: 1rem; } .extmap > summary { diff --git a/service/webui/assets/app.js b/service/webui/assets/app.js index f594f1e..b4520fc 100644 --- a/service/webui/assets/app.js +++ b/service/webui/assets/app.js @@ -10,6 +10,7 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel)); let currentConfig = null; // last-loaded config model (edited in place) let latestRates = {}; // metric name -> per-second rate from SSE (counters) +let latestTotals = {}; // metric name -> cumulative total from SSE (counters) let latestGauges = {}; // metric name -> latest absolute value from SSE (gauges) // pendingServices holds the names of services with an in-flight start/stop/ // restart action. While pending, the card shows a spinner and its action @@ -56,11 +57,11 @@ function renderStatus(units) { if (u.shares && u.shares.length) detail += kv("Shares", u.shares.map((s) => s.name).join(", ")); - // Hooks are individually start/stoppable: the transport/service hooks - // (IPX/NetBEUI/NetBIOS/SMB/WebUI) and the AppleTalk DDP subsystems - // (AFP/MacIP/IPXGW) the supervisor now drives via the router's runtime - // AddService/RemoveService. Ports and the core router-set share the stack - // lifecycle and so are not controllable. + // Every unit the supervisor drives as a hook is individually + // start/stoppable: the ports/transports (LToUDP/TashTalk/EtherTalk), the + // AppleTalk router, the DDP subsystems (AFP/MacIP/IPXGW), the + // NetBIOS-family hooks (IPX/NetBEUI/NetBIOS/SMB), and the Web UI. Ports run + // independently of the router; the DDP subsystems depend on it. const controllable = u.kind === "hook"; const pending = pendingServices.has(u.name); const dis = pending ? " disabled" : ""; @@ -81,18 +82,28 @@ function renderStatus(units) { ? "Working…" : `${u.enabled ? "Enabled" : "Disabled"} · ${u.running ? "Running" : "Stopped"}`; + // A cog opens this unit's config modal — shown only for units that have at + // least one config panel mapped to them. + const hasConfig = panelsForUnit(u.name).length > 0; + const cog = hasConfig + ? `` + : ""; + card.innerHTML = ` -

${indicator}${esc(u.name)}

+

${indicator}${esc(u.name)}${cog}

${stateLine}
${detail} - +
${controls}
`; card.querySelectorAll("[data-action]").forEach((btn) => btn.addEventListener("click", () => serviceAction(btn.dataset.svc, btn.dataset.action)) ); + const cogBtn = card.querySelector("[data-config]"); + if (cogBtn) cogBtn.addEventListener("click", () => openServiceConfig(cogBtn.dataset.config)); grid.appendChild(card); }); + renderMetrics(); // populate the just-built cards from the last SSE frame } function kv(k, v) { @@ -123,12 +134,9 @@ function startStats() { try { const frame = JSON.parse(ev.data); latestRates = frame.rates || {}; + latestTotals = frame.totals || {}; latestGauges = frame.gauges || {}; - $$("[data-metric-for]").forEach((el) => { - const text = metricsForUnit(el.getAttribute("data-metric-for")); - el.textContent = text; - el.classList.toggle("hidden", text === ""); - }); + renderMetrics(); } catch (_) {} }; es.onerror = () => { @@ -136,36 +144,58 @@ function startStats() { }; } +// renderMetrics writes each card's live-stats line from the latest SSE frame. +// Called on every frame and on each status re-render so a freshly built card +// shows the last-known stats immediately rather than waiting for the next tick. +function renderMetrics() { + $$("[data-metric-for]").forEach((el) => { + el.innerHTML = metricsForUnit(el.getAttribute("data-metric-for")); + }); +} + // Producers publish samples named "unit::" so each sample -// attributes to exactly one dashboard card. unitMetric reads the per-second -// rate (counters) or latest value (gauges) for one such metric, or 0. +// attributes to exactly one dashboard card. These read the per-second rate, +// the cumulative total (counters) or the latest value (gauges) for one such +// metric. function unitRate(unit, metric) { return latestRates[`unit:${unit}:${metric}`] || 0; } +function unitTotal(unit, metric) { + return latestTotals[`unit:${unit}:${metric}`] || 0; +} function unitGauge(unit, metric) { return latestGauges[`unit:${unit}:${metric}`]; } -// metricsForUnit renders a one-line live summary for a card: rx/tx throughput -// for ports (packets and bytes per second) plus any gauge value the unit -// publishes (e.g. active sessions). Returns "" when the unit has no live -// metrics, so the line is hidden rather than showing a bare "0/s". +// metricsForUnit renders the live summary for a card: cumulative rx/tx packet +// totals plus current throughput for ports, and any gauge value the unit +// publishes (e.g. active sessions). The traffic line is always shown for units +// that report traffic counters (even when idle, so the totals stay visible); +// returns "" only for units that publish no metrics at all. function metricsForUnit(unit) { const parts = []; - const rxp = unitRate(unit, "rx.packets"); - const txp = unitRate(unit, "tx.packets"); - const rxb = unitRate(unit, "rx.bytes"); - const txb = unitRate(unit, "tx.bytes"); - const hasTraffic = [`unit:${unit}:rx.packets`, `unit:${unit}:tx.packets`].some( - (n) => n in latestRates - ); + const hasTraffic = + `unit:${unit}:rx.packets` in latestTotals || `unit:${unit}:tx.packets` in latestTotals; if (hasTraffic) { - parts.push(`↓ ${rxp} pkt/s (${fmtBytes(rxb)}/s)`); - parts.push(`↑ ${txp} pkt/s (${fmtBytes(txb)}/s)`); + const rxt = unitTotal(unit, "rx.packets"); + const txt = unitTotal(unit, "tx.packets"); + const rxp = unitRate(unit, "rx.packets"); + const txp = unitRate(unit, "tx.packets"); + const rxb = unitRate(unit, "rx.bytes"); + const txb = unitRate(unit, "tx.bytes"); + parts.push( + `↓ ${fmtCount(rxt)} pkt (${rxp}/s, ${fmtBytes(rxb)}/s)`, + `↑ ${fmtCount(txt)} pkt (${txp}/s, ${fmtBytes(txb)}/s)`, + ); } const sessions = unitGauge(unit, "sessions"); if (sessions !== undefined) parts.push(`${sessions} session${sessions === 1 ? "" : "s"}`); - return parts.join(" · "); + return parts.map(esc).join(" · "); +} + +// fmtCount renders a packet count with thousands separators for readability. +function fmtCount(n) { + return Number(n).toLocaleString(); } // fmtBytes renders a byte count as B/KB/MB with one decimal for the larger @@ -266,24 +296,30 @@ const IPX_FRAMINGS = ["ethernet_ii", "raw_802_3", "llc", "snap"]; const CONFIG_PANELS = [ { title: "LocalTalk over UDP", + units: ["LToUDP"], fields: [ { label: "Enabled", path: "LToUdp.enabled", type: "bool" }, { label: "Interface", path: "LToUdp.interface", type: "text" }, { label: "Zone Name", path: "LToUdp.seed_zone", type: "text" }, { label: "Seed Network", path: "LToUdp.seed_network", type: "number" }, + { label: "Attach to AppleTalk router", path: "LToUdp", type: "router-port", port: "LToUdp" }, ], }, { title: "TashTalk (LocalTalk)", + units: ["TashTalk"], fields: [ { label: "Serial Port", path: "TashTalk.port", type: "serial" }, { label: "Zone Name", path: "TashTalk.seed_zone", type: "text" }, { label: "Seed Network", path: "TashTalk.seed_network", type: "number" }, + { label: "Attach to AppleTalk router", path: "TashTalk", type: "router-port", port: "TashTalk" }, ], }, { // The shared virtual interface protocols inherit unless they go Custom. title: "Bridge (shared interface)", + // EtherTalk (and other bridge consumers) edit the shared Bridge too. + units: ["EtherTalk"], fields: [ { label: "Mode", path: "Bridge.mode", type: "select", options: IFACE_MODES }, { label: "Device", path: "Bridge.device", type: "iface" }, @@ -293,20 +329,24 @@ const CONFIG_PANELS = [ }, { title: "EtherTalk", + units: ["EtherTalk"], interfaceFor: "EtherTalk", fields: [ { label: "Zone Name", path: "EtherTalk.seed_zone", type: "text" }, { label: "Seed Net Min", path: "EtherTalk.seed_network_min", type: "number" }, { label: "Seed Net Max", path: "EtherTalk.seed_network_max", type: "number" }, + { label: "Attach to AppleTalk router", path: "EtherTalk", type: "router-port", port: "EtherTalk" }, ], }, { title: "NetBEUI (NBF)", + units: ["NetBEUI"], interfaceFor: "NetBEUI", fields: [{ label: "Enabled", path: "NetBEUI.enabled", type: "bool" }], }, { title: "IPX", + units: ["IPX"], interfaceFor: "IPX", fields: [ { label: "Enabled", path: "IPX.enabled", type: "bool" }, @@ -316,6 +356,7 @@ const CONFIG_PANELS = [ }, { title: "IPX Gateway (MacIPX)", + units: ["IPXGW"], fields: [ { label: "Enabled", path: "IPXGW.enabled", type: "bool", hint: "Register an 'IPX Gateway' NBP name so MacIPX clients can discover us." }, { @@ -329,6 +370,7 @@ const CONFIG_PANELS = [ }, { title: "MacIP Gateway", + units: ["MacIP"], interfaceFor: "MacIP", fields: [ { label: "Enabled", path: "MacIP.enabled", type: "bool" }, @@ -345,6 +387,7 @@ const CONFIG_PANELS = [ }, { title: "AFP File Server", + units: ["AFP"], editor: { title: "AFP Volumes", section: "AFP", @@ -362,8 +405,24 @@ const CONFIG_PANELS = [ { label: "Binding", path: "AFP.binding", type: "text" }, ], }, + { + title: "NetBIOS", + units: ["NetBIOS"], + fields: [ + { label: "Enabled", path: "NetBIOS.enabled", type: "bool" }, + { + label: "Transports", + path: "NetBIOS.transports", + type: "stringlist", + placeholder: "ipx | netbeui", + hint: "Transports NetBIOS binds (e.g. ipx, netbeui). Leave empty for the defaults.", + }, + { label: "Scope ID", path: "NetBIOS.scope_id", type: "text" }, + ], + }, { title: "SMB Server", + units: ["SMB"], editor: { title: "SMB Shares", section: "SMB", @@ -396,12 +455,24 @@ const CONFIG_PANELS = [ }, { title: "Web UI", + units: ["WebUI"], fields: [ { label: "Enabled", path: "WebUI.enabled", type: "bool" }, { label: "Bind", path: "WebUI.bind", type: "text" }, { label: "TLS", path: "WebUI.tls", type: "bool" }, ], }, + { + // The AppleTalk router has no parameters of its own beyond which transports + // it binds; surface those toggles here so the Router card's cog is useful. + title: "AppleTalk Router", + units: ["Router"], + fields: [ + { label: "Bind LToUDP", path: "LToUdp", type: "router-port", port: "LToUdp" }, + { label: "Bind TashTalk", path: "TashTalk", type: "router-port", port: "TashTalk" }, + { label: "Bind EtherTalk", path: "EtherTalk", type: "router-port", port: "EtherTalk" }, + ], + }, ]; let interfaceList = []; // [{name, description, addresses}] @@ -417,29 +488,48 @@ function ifaceLabel(i) { return label; } -async function renderConfig(cfg) { +// loadConfigLists fetches the dropdown option sets (interfaces, serial ports, +// fs-types) the config fields need. Shared by the full editor and the +// per-service modal so both render the same friendly selectors. +async function loadConfigLists() { [interfaceList, serialList, fsTypeList] = await Promise.all([ fetchJSON("/api/interfaces").catch(() => []), fetchJSON("/api/serial-ports").catch(() => []), fetchJSON("/api/fs-types").catch(() => ["local_fs"]), ]); if (!fsTypeList || !fsTypeList.length) fsTypeList = ["local_fs"]; +} + +// renderPanel builds one config panel (a
) bound to cfg, including +// its fields, optional interface chooser, and optional share/volume editor. It +// is the unit of reuse shared by the full Configuration tab and the per-service +// modal opened from a dashboard card's cog. +function renderPanel(cfg, panel) { + const fs = document.createElement("fieldset"); + fs.className = "config-panel"; + const legend = document.createElement("legend"); + legend.textContent = panel.title; + fs.appendChild(legend); + panel.fields.forEach((f) => fs.appendChild(renderField(cfg, f))); + // A per-service Bridge/Custom interface chooser, when the panel declares one. + if (panel.interfaceFor) fs.appendChild(renderInterfaceChooser(cfg, panel.interfaceFor)); + // A grouped volume/share editor, when the panel declares one. + if (panel.editor) fs.appendChild(renderShareEditor(cfg, panel.editor.title, panel.editor.section, panel.editor.columns)); + return fs; +} +async function renderConfig(cfg) { + await loadConfigLists(); const root = $("#config-panels"); root.innerHTML = ""; - CONFIG_PANELS.forEach((panel) => { - const fs = document.createElement("fieldset"); - fs.className = "config-panel"; - const legend = document.createElement("legend"); - legend.textContent = panel.title; - fs.appendChild(legend); - panel.fields.forEach((f) => fs.appendChild(renderField(cfg, f))); - // A per-service Bridge/Custom interface chooser, when the panel declares one. - if (panel.interfaceFor) fs.appendChild(renderInterfaceChooser(cfg, panel.interfaceFor)); - // A grouped volume/share editor, when the panel declares one. - if (panel.editor) fs.appendChild(renderShareEditor(cfg, panel.editor.title, panel.editor.section, panel.editor.columns)); - root.appendChild(fs); - }); + CONFIG_PANELS.forEach((panel) => root.appendChild(renderPanel(cfg, panel))); +} + +// panelsForUnit returns the config panels that edit the given dashboard unit, +// matched by the panel's `units` tag (a unit may span several panels, e.g. +// EtherTalk edits both its own panel and the shared Bridge panel). +function panelsForUnit(unit) { + return CONFIG_PANELS.filter((p) => Array.isArray(p.units) && p.units.includes(unit)); } // renderInterfaceChooser renders the per-service interface selector: a @@ -783,6 +873,19 @@ function renderField(cfg, f) { setPath(cfg, f.path, input.checked); setDirty(true); }); + } else if (f.type === "router-port") { + // Router attachment lives in the [Router].ports allow-list, not on the + // transport. The checkbox reflects/edits membership: an empty/absent list + // means "bind every transport" (the default), so an unset list shows + // checked. Toggling off switches the list to an explicit allow-list of the + // other transports; toggling the last one back on clears it to empty again. + input = document.createElement("input"); + input.type = "checkbox"; + input.checked = routerBindsPort(cfg, f.port); + input.addEventListener("change", () => { + setRouterPort(cfg, f.port, input.checked); + setDirty(true); + }); } else if (f.type === "iface") { input = buildInterfaceSelect(val, (v) => { setPath(cfg, f.path, v); @@ -830,6 +933,39 @@ function renderField(cfg, f) { return row; } +// The transports the [Router].ports allow-list can name. Mirrors the Go +// RouterPort* constants (config/model.go) and the TOML section names. +const ROUTER_PORTS = ["LToUdp", "TashTalk", "EtherTalk"]; + +// routerBindsPort mirrors config.RouterModel.BindsPort: an empty/absent list +// binds every transport; otherwise only listed ones (case-insensitive). +function routerBindsPort(cfg, name) { + const ports = (cfg.Router && cfg.Router.ports) || []; + if (ports.length === 0) return true; + return ports.some((p) => String(p).trim().toLowerCase() === name.toLowerCase()); +} + +// setRouterPort toggles a transport's membership in [Router].ports while +// preserving the "empty = all" convention: the list is only made explicit when +// some transport is detached, and collapses back to empty once all are +// attached again. +function setRouterPort(cfg, name, attached) { + if (!cfg.Router) cfg.Router = {}; + // Start from the effective attached set (empty list ⇒ everything). + let set = routerBindsPort(cfg, "") + ? new Set(ROUTER_PORTS) + : new Set(ROUTER_PORTS.filter((p) => routerBindsPort(cfg, p))); + if (attached) set.add(name); + else set.delete(name); + // All attached ⇒ collapse to empty (the clean default); otherwise emit the + // explicit allow-list in canonical order. + if (ROUTER_PORTS.every((p) => set.has(p))) { + delete cfg.Router.ports; + } else { + cfg.Router.ports = ROUTER_PORTS.filter((p) => set.has(p)); + } +} + function getPath(obj, path) { return path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj); } @@ -880,6 +1016,85 @@ function setConfigStatus(msg) { $("#config-status").textContent = msg; } +// ---- per-service config modal ---- +// A dashboard card's cog opens a modal showing just that service's config +// panels (the same fields as the Configuration tab). Apply stages the edited +// model and runs a live Apply, which the supervisor handles as an atomic +// whole-stack rebuild — so the edited service restarts with the new config. +// Edits are NOT written to disk; the modal makes that explicit. +let modalConfig = null; // deep clone of the config edited inside the modal +let modalUnit = null; // unit name the modal is currently editing + +async function openServiceConfig(unit) { + const panels = panelsForUnit(unit); + if (!panels.length) return; + try { + await loadConfigLists(); + const resp = await fetchJSON("/api/config"); + // Edit a deep clone so closing without Apply discards the changes and the + // dashboard's own config view is untouched. + modalConfig = JSON.parse(JSON.stringify(resp.config || {})); + modalUnit = unit; + } catch (e) { + alert("Could not load config: " + e.message); + return; + } + + $("#modal-title").textContent = "Configure " + unit; + const body = $("#modal-body"); + body.innerHTML = ""; + panels.forEach((p) => body.appendChild(renderPanel(modalConfig, p))); + setModalStatus(""); + $("#service-modal").classList.remove("hidden"); +} + +function closeServiceConfig() { + $("#service-modal").classList.add("hidden"); + modalConfig = null; + modalUnit = null; +} + +function setModalStatus(msg) { + $("#modal-status").textContent = msg; +} + +// Wire the modal's static controls once at load. +(function initServiceModal() { + const modal = $("#service-modal"); + if (!modal) return; + $("#modal-close").addEventListener("click", closeServiceConfig); + $("#modal-cancel").addEventListener("click", closeServiceConfig); + // Click on the dimmed backdrop (outside the dialog) closes the modal. + modal.addEventListener("click", (e) => { + if (e.target === modal) closeServiceConfig(); + }); + // Escape closes it too. + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !modal.classList.contains("hidden")) closeServiceConfig(); + }); + $("#modal-apply").addEventListener("click", applyServiceConfig); +})(); + +async function applyServiceConfig() { + if (!modalConfig || !modalUnit) return; + const applyBtn = $("#modal-apply"); + applyBtn.disabled = true; + setModalStatus("Applying…"); + try { + await putJSON("/api/config", modalConfig); + await postJSON("/api/config/apply", null); + // The whole-stack Apply restarts the affected service; reflect it on the + // dashboard and mark the live config dirty (applied but not saved). + setDirty(true); + closeServiceConfig(); + loadStatus(); + } catch (e) { + setModalStatus("Apply failed: " + e.message); + } finally { + applyBtn.disabled = false; + } +} + // ---- extension-map editor ---- // A raw text editor for the Netatalk-style type/creator file. We edit the // file verbatim (preserving comments/order) rather than parsing it into a @@ -944,6 +1159,27 @@ $$("[data-diag]").forEach((btn) => { }); }); +// Restart the whole stack (all ports, the router, and every hook). The Web UI +// server is preserved across the rebuild, so this connection survives. +const restartAllBtn = $("#btn-restart-all"); +if (restartAllBtn) { + restartAllBtn.addEventListener("click", async () => { + if (!confirm("Restart the whole stack? Active sessions will be dropped.")) return; + const out = $("#diag-output"); + restartAllBtn.disabled = true; + out.textContent = "Restarting stack…"; + try { + await postJSON("/api/restart-all", null); + out.textContent = "Stack restarted."; + loadStatus(); + } catch (e) { + out.textContent = "Restart failed: " + e.message; + } finally { + restartAllBtn.disabled = false; + } + }); +} + // ---- fetch helpers ---- async function fetchJSON(url) { const r = await fetch(url); diff --git a/service/webui/assets/index.html b/service/webui/assets/index.html index a208222..e3f28b2 100644 --- a/service/webui/assets/index.html +++ b/service/webui/assets/index.html @@ -58,6 +58,7 @@

ClassicStack

+ @@ -65,10 +66,31 @@

ClassicStack

node
+

     
+ +