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/.gitignore b/.gitignore index 920562b..20907d5 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 @@ -44,3 +48,5 @@ go.work.sum .captures/ captures/ +server.toml.[0-9]* + diff --git a/CLAUDE.md b/CLAUDE.md index 31f2744..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 | @@ -69,8 +73,21 @@ 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/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 | +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/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Makefile b/Makefile index 1836b16..ba75150 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,34 @@ TAGS ?= all -.PHONY: build test test-race test-tags lint vuln gosec fuzz clean +# 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 -build: +# 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 +build-svc: + go build -tags "$(TAGS)" -o $(SVC_BIN) $(SVC_PKG) + test: go test -tags "$(TAGS)" ./... @@ -17,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 \ @@ -30,5 +70,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 4dffff1..efd8af7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,19 @@ +
ClassicStack +![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/obsoletemadness/classicstack/release-main.yml) +[![CodeFactor](https://www.codefactor.io/repository/github/obsoletemadness/classicstack/badge)](https://www.codefactor.io/repository/github/obsoletemadness/classicstack) +![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/obsoletemadness/classicstack) +![GitHub License](https://img.shields.io/github/license/obsoletemadness/classicstack) +![GitHub repo size](https://img.shields.io/github/repo-size/obsoletemadness/classicstack) +[![GitHub Repo stars](https://img.shields.io/github/stars/obsoletemadness/classicstack)](https://github.com/obsoletemadness/classicstack/stargazers) +[![WARN-LLM GENERATED](https://img.shields.io/badge/WARN-LLM%20GENERATED-FF6347)](https://github.com/40ants/ai-badges) + # ClassicStack -ClassicStack is an AppleTalk router and classic LAN services stack that bridges legacy Macintosh networking into modern environments. +ClassicStack is an AppleTalk router and classic LAN services stack that bridges legacy Macintosh and DOS networking into modern environments. Always in beta.
@@ -13,9 +22,21 @@ ClassicStack is an AppleTalk router and classic LAN services stack that bridges - AppleTalk Phase 2 routing across EtherTalk and LocalTalk transports. - AFP file server over both classic DDP and modern TCP transports. - MacIP gateway for IP-over-AppleTalk clients. +- MacIPX gateway for IPX-over-AppleTalk clients. - Optional IPX, NetBEUI, NetBIOS, and SMB1 services (build-tag gated). - Shared raw-link bridge settings for EtherTalk, MacIP, IPX, and NetBEUI. +## Releases +Grab the latest release from Github Releases [releases](https://github.com/ObsoleteMadness/ClassicStack/releases/latest). + +## Screenshots + +![WebUI](./img/webui.png) +The web interface. + +![Doom](./img/doom.png) +Doom running over MacIPX over AppleTalk over LtOUDP through Snow, back to IPX on 86box. + ## Build Requirements: @@ -99,6 +120,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 | @@ -239,6 +281,92 @@ 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, read-only +diagnostics (zone/network enumeration), and a live **log viewer**. + +[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 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). 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 +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 4a2af91..90c8a3e 100644 --- a/cmd/classicstack/main.go +++ b/cmd/classicstack/main.go @@ -1,732 +1,15 @@ package main -import ( - "context" - "flag" - "fmt" - "log" - "net" - "os" - "os/signal" - "runtime" - "strings" - "syscall" - - "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" +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)") - - 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, - }) - } - - 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}}, - }) - logging.SetDefault(rootLogger) - netlog.SetLogger(rootLogger) - - if cfg.LogTraffic { - netlog.SetLogFunc(func(s string) { netlog.Debug("%s", s) }) - } - - 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) - } - } - - 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, - }) - if err != nil { - log.Fatalf("SMB wiring failed: %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 - - 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) - } - - 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) - } - } - - <-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 { - 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 -// 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 -} - -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() + 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/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..889f042 --- /dev/null +++ b/config/fromsource.go @@ -0,0 +1,216 @@ +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) + + 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) + 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.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") { + 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.NetBEUI.Custom = loadCustomInterface(k, "NetBEUI") + + 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 +} + +// 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 + } + 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..ea6fa22 --- /dev/null +++ b/config/marshal.go @@ -0,0 +1,60 @@ +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.Router.Ports = cloneStrings(m.Router.Ports) + 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..ed550d8 --- /dev/null +++ b/config/model.go @@ -0,0 +1,242 @@ +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 +// 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" 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"` + 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" 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"` +} + +// 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 + +// 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"` + 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" 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" 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" 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" 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"` + // 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. +type IPXModel struct { + 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"` + // 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. +type IPXGWModel struct { + 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" 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. +type NetBIOSModel struct { + 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" 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" 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" 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" 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" 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" 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/config/model_test.go b/config/model_test.go new file mode 100644 index 0000000..4404dba --- /dev/null +++ b/config/model_test.go @@ -0,0 +1,158 @@ +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) + } +} + +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/config/save.go b/config/save.go new file mode 100644 index 0000000..8dccd9e --- /dev/null +++ b/config/save.go @@ -0,0 +1,100 @@ +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 +} + +// 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) { + 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 func() { _ = 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/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/go.mod b/go.mod index 31b40ba..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 @@ -11,8 +9,9 @@ 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 - golang.org/x/net v0.33.0 - golang.org/x/sys v0.32.0 + github.com/pelletier/go-toml/v2 v2.2.4 + 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 ) @@ -35,15 +34,14 @@ 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 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/img/doom.png b/img/doom.png new file mode 100644 index 0000000..a1d24e3 Binary files /dev/null and b/img/doom.png differ diff --git a/img/webui.png b/img/webui.png new file mode 100644 index 0000000..d1a6edb Binary files /dev/null and b/img/webui.png differ 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 81% rename from cmd/classicstack/afp_enabled.go rename to internal/app/afp_enabled.go index e0a5212..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" @@ -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/internal/app/afp_hook.go similarity index 86% rename from cmd/classicstack/afp_hook.go rename to internal/app/afp_hook.go index 3e063fc..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" @@ -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/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 88% rename from cmd/classicstack/config_flags.go rename to internal/app/config_flags.go index 24e6061..8cda73f 100644 --- a/cmd/classicstack/config_flags.go +++ b/internal/app/config_flags.go @@ -1,4 +1,4 @@ -package main +package app import ( "strings" @@ -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 @@ -117,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, @@ -192,6 +205,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/internal/app/config_ini.go similarity index 83% rename from cmd/classicstack/config_ini.go rename to internal/app/config_ini.go index ae450ef..21c41b8 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,22 @@ 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 + // 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 @@ -72,6 +88,8 @@ type appConfig struct { ShortnameWindowsShortnames bool ShortnameBackend string ShortnameDBPath string + + WebUI WebUIConfigOptions } const ( @@ -89,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", @@ -97,6 +120,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 @@ -146,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 { @@ -213,6 +249,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 @@ -246,6 +286,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/internal/app/config_model.go b/internal/app/config_model.go new file mode 100644 index 0000000..a5fd241 --- /dev/null +++ b/internal/app/config_model.go @@ -0,0 +1,333 @@ +package app + +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, + } + // 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, + 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, + } + // 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, + 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 +} + +// 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. +// 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.Router.Ports = routerPortsModel(cfg) + + 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.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 { + 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.NetBEUI.Custom = customIfDiffers(cfg.NetBEUIBridge, cfg.Bridge, cfg.NetBEUIInterface) + + 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 +} + +// 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] == ':' { + return []string{s[:i], s[i+1:]} + } + } + return []string{s} +} 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/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..7e30b06 --- /dev/null +++ b/internal/app/ddp_service_hook_test.go @@ -0,0 +1,169 @@ +//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, []string{"Router"}) + + 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) + } + 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 { + for _, s := range r.Services { + if s == target { + return true + } + } + return false +} diff --git a/internal/app/diagnostics_impl.go b/internal/app/diagnostics_impl.go new file mode 100644 index 0000000..19c92d3 --- /dev/null +++ b/internal/app/diagnostics_impl.go @@ -0,0 +1,121 @@ +package app + +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 +} + +// 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) { + 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 +} + +// 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/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 74% rename from cmd/classicstack/extension_map.go rename to internal/app/extension_map.go index f04a7d4..5af21d5 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" @@ -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/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 65% rename from cmd/classicstack/ipx_enabled.go rename to internal/app/ipx_enabled.go index cb0376f..fef4b32 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" @@ -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" @@ -22,14 +23,40 @@ 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 } 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 + // 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 +104,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 78% rename from cmd/classicstack/macip_enabled.go rename to internal/app/macip_enabled.go index 98c302d..3773ca8 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" @@ -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 @@ -131,12 +158,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 +187,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 85% rename from cmd/classicstack/macip_hook.go rename to internal/app/macip_hook.go index ce9e204..741bf5c 100644 --- a/cmd/classicstack/macip_hook.go +++ b/internal/app/macip_hook.go @@ -1,6 +1,7 @@ -package main +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/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/internal/app/mainwiring.go b/internal/app/mainwiring.go new file mode 100644 index 0000000..a21773a --- /dev/null +++ b/internal/app/mainwiring.go @@ -0,0 +1,103 @@ +package app + +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" +) + +// 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, + Logs: logbuf.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/internal/app/metered_port.go b/internal/app/metered_port.go new file mode 100644 index 0000000..1b5bfe4 --- /dev/null +++ b/internal/app/metered_port.go @@ -0,0 +1,89 @@ +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 + } + 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 +} + +// 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/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..108b99e --- /dev/null +++ b/internal/app/netbeui_enabled.go @@ -0,0 +1,116 @@ +//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" + "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 +} + +// 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 { + 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..e7148f5 --- /dev/null +++ b/internal/app/netbios_enabled.go @@ -0,0 +1,109 @@ +//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: 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/internal/app/netutil.go b/internal/app/netutil.go new file mode 100644 index 0000000..d7c4d19 --- /dev/null +++ b/internal/app/netutil.go @@ -0,0 +1,87 @@ +package app + +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/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/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/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/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/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 72% rename from cmd/classicstack/smb_shares.go rename to internal/app/smb_shares.go index 991fd26..b8289a8 100644 --- a/cmd/classicstack/smb_shares.go +++ b/internal/app/smb_shares.go @@ -1,4 +1,4 @@ -package main +package app import ( "strings" @@ -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/internal/app/supervisor.go b/internal/app/supervisor.go new file mode 100644 index 0000000..e50c673 --- /dev/null +++ b/internal/app/supervisor.go @@ -0,0 +1,1128 @@ +package app + +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" + "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" + "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 // 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 + // 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 + + // Cross-wired components kept so hooks/services can reference them. + shortHook ShortnameHook + 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{} + + // 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 } + +// 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), + portHooks: make(map[string]*portHook), + } + 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 + } + + // 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. + 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 { + 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. 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, 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, cfg.TashTalkAttachRouter, 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) + // 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") + } + + 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) + } + + // 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 +} + +// 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) + 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 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(), + 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, + "parse_packets": boolStr(cfg.ParsePackets), + "log_traffic": boolStr(cfg.LogTraffic), + "captures": s.appleTalkCaptureSummary(), + }) + + macIP, err := wireMacIP(MacIPConfig{ + Enabled: cfg.MacIPEnabled, + 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, + 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 { + s.addDDPServiceGroup("MacIP", macIP.Service()) + s.registerMacIPStatus(cfg.MacIPEnabled) + } + + 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 { + s.addDDPServiceGroup("IPXGW", 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 + + // 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: false, + 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}) + } + s.addDDPServiceGroup("AFP", afpHook.Services()...) + s.registerAFPStatus() + + 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 + 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: extMap, + DecomposedNames: m.AFP.UseDecomposedNames, + CNIDBackend: m.AFP.CNIDBackend, + AppleDoubleMode: m.AFP.AppleDoubleMode, + VolumeModels: vols, + } +} + +// 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 + + // 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, + BridgeMode: cfg.IPXBridge.Mode, + BridgeFrameMode: cfg.IPXBridge.BridgeMode, + Interface: ipxResolvedIface, + BridgeHWAddress: cfg.IPXBridge.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.NetBEUIBridge.Mode, + BridgeFrameMode: cfg.NetBEUIBridge.BridgeMode, + Interface: nbeuiResolvedIface, + BridgeHWAddress: cfg.NetBEUIBridge.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) + } + + // 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, + 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 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, 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) + + // 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 +// 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 + // 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 != "" { + iface = cfg.EtherTalk.Device + } + return iface +} + +func (s *Supervisor) resolveNetBEUIInterface() string { + cfg := s.cfg + iface := cfg.NetBEUIBridge.Device + if cfg.NetBEUIEnabled && strings.TrimSpace(iface) == "" && cfg.EtherTalk.Device != "" { + iface = cfg.EtherTalk.Device + } + 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 + } +} + +// 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) +// 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) + // 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. 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 + } + u.Kind = status.KindHook + u.Enabled = enabled + u.Running = false + u.DependsOn = dependsOn + 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 { + 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, 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, + Enabled: enabled, + Binding: p.ShortString(), + 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) { + s.reg.Set(status.Unit{ + Name: name, + Kind: status.KindService, + Enabled: enabled, + Properties: props, + }) +} + +// 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. +// 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)) + 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, + 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() + // 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 { + 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, + }) +} + +// 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.KindHook, + 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 { + 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{ + "capture": captureLabel(s.cfg.Capture.NetBEUI), + }, + }) +} + +// 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, + }) +} + +// 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 +// 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}) +} + +// boolStr renders a bool as "on"/"off" for status properties. +func boolStr(b bool) string { + if b { + return "on" + } + return "off" +} + +// 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, + } { + if strings.TrimSpace(path) != "" { + active = append(active, name) + } + } + if len(active) == 0 { + return "none" + } + sort.Strings(active) + 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() + } + s.captureSinks = nil + if s.parseCleanup != nil { + s.parseCleanup() + s.parseCleanup = nil + } +} diff --git a/internal/app/supervisor_control.go b/internal/app/supervisor_control.go new file mode 100644 index 0000000..6f93f51 --- /dev/null +++ b/internal/app/supervisor_control.go @@ -0,0 +1,234 @@ +package app + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "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" +) + +// The methods here adapt the Supervisor to the control.Supervisor +// interface the management plane drives. RestartService is already +// implemented in supervisor_lifecycle.go. + +// 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 { + 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, 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) + } + + 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) + } + netlog.Info("[SUP] configuration applied") + 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 { + 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. +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.portRouted = other.portRouted + s.portHooks = other.portHooks + s.routerHook = other.routerHook + s.meters = other.meters + 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 + s.ipxGW = other.ipxGW + s.netbios = other.netbios + s.transportBindings = other.transportBindings + s.started = false +} + +// 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() +} + +// 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/internal/app/supervisor_lifecycle.go b/internal/app/supervisor_lifecycle.go new file mode 100644 index 0000000..0692aed --- /dev/null +++ b/internal/app/supervisor_lifecycle.go @@ -0,0 +1,272 @@ +package app + +import ( + "context" + "fmt" + "time" + + "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") + } + + // 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] { + // 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 + + // 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 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(time.Second) + defer t.Stop() + tick := 0 + for { + select { + case <-stop: + return + case <-t.C: + for _, m := range s.meters { + m.publish() + } + if tick%5 == 0 && s.macIP != nil { + s.refreshMacIPStatus() + } + tick++ + } + } +} + +// 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 + } + if s.statusTickerStop != nil { + 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) + } + 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) + } + 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 +// (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) + } + // 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) + } + s.stopHookLocked(name) + 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 { + 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, 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, 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) + } + } + 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) + s.onHookStateChanged(name) + 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) + 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 { + 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 +} 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/internal/app/webui_config.go b/internal/app/webui_config.go new file mode 100644 index 0000000..ca36107 --- /dev/null +++ b/internal/app/webui_config.go @@ -0,0 +1,57 @@ +package app + +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/internal/app/webui_disabled.go b/internal/app/webui_disabled.go new file mode 100644 index 0000000..89c79bc --- /dev/null +++ b/internal/app/webui_disabled.go @@ -0,0 +1,23 @@ +//go:build !webui && !all + +package app + +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/internal/app/webui_enabled.go b/internal/app/webui_enabled.go new file mode 100644 index 0000000..b5b73ea --- /dev/null +++ b/internal/app/webui_enabled.go @@ -0,0 +1,51 @@ +//go:build webui || all + +package app + +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/internal/app/webui_hook.go b/internal/app/webui_hook.go new file mode 100644 index 0000000..fd311e5 --- /dev/null +++ b/internal/app/webui_hook.go @@ -0,0 +1,24 @@ +package app + +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/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..d09ceb5 --- /dev/null +++ b/pkg/control/control.go @@ -0,0 +1,199 @@ +// 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/logbuf" + "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 + // 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 + // 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 + // 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 + // 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 +// 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. +// 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 + logs *logbuf.Buffer + + 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 + Logs *logbuf.Buffer // defaults to logbuf.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 + } + 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, + } +} + +// 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 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() +} + +// 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 { + 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 { + return ErrNoSupervisor + } + 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 new file mode 100644 index 0000000..a294b9a --- /dev/null +++ b/pkg/control/control_test.go @@ -0,0 +1,214 @@ +package control + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/ObsoleteMadness/ClassicStack/pkg/logbuf" +) + +// 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 + restartAll int + extMapWritten []byte +} + +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 +} +func (s *fakeSup) RestartAll(_ context.Context) error { s.restartAll++; return nil } +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{}}) + + 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 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 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) { + t.Errorf("ExtMap without supervisor = %v, want ErrNoSupervisor", err) + } + if _, err := p.SaveExtMap(nil); !errors.Is(err, ErrNoSupervisor) { + t.Errorf("SaveExtMap without supervisor = %v, want ErrNoSupervisor", err) + } +} + +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(); !errors.Is(err, ErrNoConfigPath) { + t.Errorf("Save without path = %v, want ErrNoConfigPath", err) + } +} + +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()); !errors.Is(err, ErrDiagUnavailable) { + t.Errorf("unset diagnostics = %v, want ErrDiagUnavailable", err) + } + if _, err := p.Diagnostics().MacIPLeases(context.Background()); !errors.Is(err, ErrDiagUnavailable) { + t.Errorf("unset MacIPLeases = %v, want ErrDiagUnavailable", err) + } +} diff --git a/pkg/control/diagnostics.go b/pkg/control/diagnostics.go new file mode 100644 index 0000000..c52aee5 --- /dev/null +++ b/pkg/control/diagnostics.go @@ -0,0 +1,108 @@ +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"` +} + +// 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"` + 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"` +} + +// 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 +// 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) + // 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) + // 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. +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..4c792b2 --- /dev/null +++ b/pkg/control/diagnostics_unavailable.go @@ -0,0 +1,42 @@ +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) RTMPTable(context.Context) ([]RTMPEntry, error) { + return nil, ErrDiagUnavailable +} + +func (unavailableDiagnostics) SMBBrowse(context.Context) ([]ServerInfo, error) { + return nil, ErrDiagUnavailable +} + +func (unavailableDiagnostics) MacIPLeases(context.Context) ([]LeaseInfo, error) { + return nil, 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/control/stats.go b/pkg/control/stats.go new file mode 100644 index 0000000..4b764e7 --- /dev/null +++ b/pkg/control/stats.go @@ -0,0 +1,131 @@ +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; +// 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"` +} + +// 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)), + 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 { + 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/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/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..4c2e08a --- /dev/null +++ b/pkg/serialport/serialport_windows.go @@ -0,0 +1,74 @@ +//go:build windows + +package serialport + +import ( + "errors" + "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. 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 { + // No serial ports present: the key is absent. Treat as empty. + if errors.Is(err, registry.ErrNotExist) { + return nil, nil + } + return nil, err + } + defer func() { _ = 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 + } + // 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/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/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/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..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" ) @@ -77,18 +78,35 @@ 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 + obs port.TrafficObserver + 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 +118,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 } @@ -140,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) @@ -163,13 +233,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) { @@ -184,21 +258,49 @@ 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(). -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 } @@ -291,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/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/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/netbeui/port.go b/port/netbeui/port.go index e927fd8..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" ) @@ -102,50 +103,117 @@ 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 + obs port.TrafficObserver + 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 } @@ -168,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) @@ -193,14 +282,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 +318,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 +358,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 +386,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 { @@ -305,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 @@ -328,19 +418,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 } @@ -430,6 +525,7 @@ func (p *portImpl) handleFrame(raw []byte) { if err != nil { return } + p.observeTraffic(port.Rx, len(nbfPayload)) cb(srcMAC, dstMAC, decoded) } return @@ -490,6 +586,7 @@ func (p *portImpl) handleFrame(raw []byte) { if err != nil { return } + p.observeTraffic(port.Rx, len(nbfPayload)) cb(srcMAC, dstMAC, decoded) } } 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/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/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/dynamic.go b/router/dynamic.go new file mode 100644 index 0000000..2cefebd --- /dev/null +++ b/router/dynamic.go @@ -0,0 +1,119 @@ +package router + +import ( + "context" + "slices" + + "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.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 { + r.Ports = append(r.Ports[:i], r.Ports[i+1:]...) + break + } + } + r.membership.Unlock() + // Withdraw routes/zones for the port so dispatch no longer selects it as a + // next hop. + r.RoutingTable.RemoveEntriesForPort(p) +} 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/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/router/router.go b/router/router.go index 1f409c5..990cd93 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,107 @@ 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 { + 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 } @@ -116,14 +216,14 @@ func (r *Router) Start(ctx context.Context) error { return err } } - for _, p := range r.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 { + 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 } @@ -136,26 +236,31 @@ func (r *Router) Start(ctx context.Context) error { return nil } -func (r *Router) Stop() error { +// 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 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 { - 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 +// 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) { @@ -281,7 +386,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 { @@ -321,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 87b9fba..e26d69b 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() @@ -176,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/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/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/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 b/server.toml index 9a7dec7..3f2ff74 100644 --- a/server.toml +++ b/server.toml @@ -1,122 +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 - -# [Shortname] -# enabled = false -# backend = "memory" # memory|sqlite -# db_path = "shortname.db" - -# [VFSBus] -# subscriber_buffer = 256 -# drop_warn_interval = "30s" +name = 'Public' +path = 'C:\Mac' +fs_type = 'local_fs' + +[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] +enabled = true +bind = '127.0.0.1:8089' +tls = true diff --git a/server.toml.example b/server.toml.example index 6fcd646..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 @@ -132,3 +142,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 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/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/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:
+
+
+
+
+ +
+ + 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/macip.go b/service/macip/macip.go index da0accb..ba9e690 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) @@ -232,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..e94bcac 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) @@ -90,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) { 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/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 { 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 new file mode 100644 index 0000000..1bd0d2f --- /dev/null +++ b/service/webui/api.go @@ -0,0 +1,206 @@ +//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/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) + 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/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) + s.mux.HandleFunc("/api/logs/download", s.handleLogDownload) + + 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) 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{}) + 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) +} + +// 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 { + 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 == "" { + writeError(w, http.StatusNotFound, errNotFound) + return + } + 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{"ok": true, "service": name, "action": action}) +} diff --git a/service/webui/assets/app.css b/service/webui/assets/app.css new file mode 100644 index 0000000..561854a --- /dev/null +++ b/service/webui/assets/app.css @@ -0,0 +1,327 @@ +: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; } +.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); } + +/* 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; } + +.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; } + +.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; +} + +/* 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; + 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"], .share-table select { + width: 100%; + padding: 0.25rem 0.4rem; + border: 1px solid var(--border); + 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; + 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); } +button.danger { background: #6e1f1f; color: #fff; border-color: #8a2a2a; } + +.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; } + +/* ---- 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 { + 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; + 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 new file mode 100644 index 0000000..b4520fc --- /dev/null +++ b/service/webui/assets/app.js @@ -0,0 +1,1223 @@ +"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 (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 +// 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) => { + 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(); + if (btn.dataset.tab === "logs") startLogs(); + else stopLogs(); + }); +}); + +// ---- 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) { + lastUnits = units; // cache for immediate re-render (e.g. pending-state change) + 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(", ")); + + // 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" : ""; + 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"}`; + + // 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)}${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) { + return `
${esc(k)}: ${esc(String(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); + } 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); + } +} + +// ---- 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 || {}; + latestTotals = frame.totals || {}; + latestGauges = frame.gauges || {}; + renderMetrics(); + } catch (_) {} + }; + es.onerror = () => { + /* browser auto-reconnects */ + }; +} + +// 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. 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 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 hasTraffic = + `unit:${unit}:rx.packets` in latestTotals || `unit:${unit}:tx.packets` in latestTotals; + if (hasTraffic) { + 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.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 +// 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 ---- +// 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 = ""; +}); + +$("#btn-log-download").addEventListener("click", () => { + window.location.href = "/api/logs/download"; +}); + +// ---- 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; + } +} + +// 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 = [ + { + 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" }, + { label: "HW Address", path: "Bridge.hw_address", type: "text" }, + { label: "Bridge Mode", path: "Bridge.bridge_mode", type: "select", options: BRIDGE_MODES }, + ], + }, + { + 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" }, + { label: "Framing", path: "IPX.framing", type: "select", options: IPX_FRAMINGS }, + { label: "Network", path: "IPX.internal_network", type: "text" }, + ], + }, + { + 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." }, + { + 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", + units: ["MacIP"], + 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", 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)." }, + ], + }, + { + title: "AFP File Server", + units: ["AFP"], + 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" }, + { label: "Zone", path: "AFP.zone", type: "text" }, + { 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", + 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" }, + { label: "Workgroup", path: "SMB.workgroup", type: "text" }, + { 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", + 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}] +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; +} + +// 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) => 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 +// "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); + }); + + 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. 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 nested"; + 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 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"; + 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; +} + +// 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"; + if (f.hint) row.title = f.hint; + 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 === "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); + setDirty(true); + }); + } else if (f.type === "serial") { + input = document.createElement("select"); + const blank = document.createElement("option"); + blank.value = ""; + blank.textContent = "(none)"; + input.appendChild(blank); + serialList.forEach((s) => { + const o = document.createElement("option"); + 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 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"; + 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; +} + +// 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); +} +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); + setConfigStatus("Applied live. Changes are running but not yet saved to disk."); + loadStatus(); + } catch (e) { + setConfigStatus("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); + setConfigStatus("Saved. Backup written to " + (r.backup || "(no previous file)") + "."); + } catch (e) { + setConfigStatus("Save failed: " + e.message); + } +}); + +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 +// 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 () => { + 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; + } + }); +}); + +// 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); + 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..e3f28b2 --- /dev/null +++ b/service/webui/assets/index.html @@ -0,0 +1,115 @@ + + + + + + ClassicStack + + + +
+

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. +

+ +
+ + +
+

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

+    
+ + + +
+
+ + disconnected + + + +
+

+    
+
+ + + + diff --git a/service/webui/diagnostics.go b/service/webui/diagnostics.go new file mode 100644 index 0000000..4a2567f --- /dev/null +++ b/service/webui/diagnostics.go @@ -0,0 +1,122 @@ +//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/rtmp", s.handleDiagRTMP) + 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) { + 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) handleDiagRTMP(w http.ResponseWriter, r *http.Request) { + if s.opts.Plane == nil { + writeError(w, http.StatusServiceUnavailable, errNoPlane) + return + } + entries, err := s.opts.Plane.Diagnostics().RTMPTable(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, entries) +} + +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) +} + +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) +} diff --git a/service/webui/embed.go b/service/webui/embed.go new file mode 100644 index 0000000..935a9ce --- /dev/null +++ b/service/webui/embed.go @@ -0,0 +1,55 @@ +//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) { + // Embedded files carry a zero modtime, so http.FileServer emits no + // useful Last-Modified/ETag and browsers may cache them indefinitely. + // After a binary upgrade that leaves a stale app.js running against a + // fresh index.html (the two fall out of lockstep). Tell the browser to + // always revalidate the SPA shell so the assets stay consistent. + w.Header().Set("Cache-Control", "no-cache") + 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/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/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/logs.go b/service/webui/logs.go new file mode 100644 index 0000000..cc14b8a --- /dev/null +++ b/service/webui/logs.go @@ -0,0 +1,107 @@ +//go:build webui || all + +package webui + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// 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()) +} + +// 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 +// 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 new file mode 100644 index 0000000..01466a8 --- /dev/null +++ b/service/webui/plane.go @@ -0,0 +1,37 @@ +//go:build webui || all + +package webui + +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" +) + +// 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) + StartService(ctx context.Context, name string) error + StopService(name string) error + RestartService(ctx context.Context, name string) error + RestartAll(ctx context.Context) error + 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()) + 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 +}