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 @@
+
+
+[](https://www.codefactor.io/repository/github/obsoletemadness/classicstack)
+
+
+
+[](https://github.com/obsoletemadness/classicstack/stargazers)
+[](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
+
+
+The web interface.
+
+
+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(`
-
-