diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6ecb11d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: [csharp] + pull_request: + branches: [csharp] + workflow_dispatch: + +jobs: + build-test: + name: Build and test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + dotnet-quality: ga + + - name: Restore + run: dotnet restore TextCascade.Server.slnx + + - name: Build + run: dotnet build TextCascade.Server.slnx --configuration Release --no-restore + + - name: Test + run: > + dotnet test TextCascade.Server.slnx --configuration Release --no-build + --filter "Category!=NetworkIntegration" + --logger trx --results-directory ./TestResults + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: ./TestResults + if-no-files-found: warn + + network-tests: + name: Network integration tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + dotnet-quality: ga + + - name: Restore + run: dotnet restore TextCascade.Server.slnx + + - name: Build + run: dotnet build TextCascade.Server.slnx --configuration Release --no-restore + + - name: Test + run: dotnet test TextCascade.Server.slnx --configuration Release --no-build --filter "Category=NetworkIntegration" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7aa67d5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,109 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + name: Build and publish release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + dotnet-quality: ga + + - name: Restore + run: dotnet restore TextCascade.Server.slnx + + - name: Build + run: dotnet build TextCascade.Server.slnx --configuration Release --no-restore + + - name: Test + run: > + dotnet test TextCascade.Server.slnx --configuration Release --no-build + --filter "Category!=NetworkIntegration" + + - name: Resolve version + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Publish Windows framework-dependent single file + run: > + dotnet publish TextCascade.Server/TextCascade.Server.csproj + --configuration Release --runtime win-x64 --self-contained false + -p:PublishSingleFile=true -p:DebugType=embedded + -p:IncludeNativeLibrariesForSelfExtract=true + -p:PublishDir=$GITHUB_WORKSPACE/artifacts/win-x64/ + + - name: Publish Linux framework-dependent single file + run: > + dotnet publish TextCascade.Server/TextCascade.Server.csproj + --configuration Release --runtime linux-x64 --self-contained false + -p:PublishSingleFile=true -p:DebugType=embedded + -p:IncludeNativeLibrariesForSelfExtract=true + -p:PublishDir=$GITHUB_WORKSPACE/artifacts/linux-x64/ + + - name: Stage release files + run: | + mkdir -p release/textcascade-server-windows-x64 release/textcascade-server-linux-x64 + cp artifacts/win-x64/TextCascade.Server.exe release/textcascade-server-windows-x64/ + cp artifacts/linux-x64/TextCascade.Server release/textcascade-server-linux-x64/ + cp deploy/textcascade.toml release/textcascade-server-windows-x64/ + cp deploy/textcascade.toml release/textcascade-server-linux-x64/ + cp deploy/textcascade-server.service release/textcascade-server-linux-x64/ + chmod +x release/textcascade-server-linux-x64/TextCascade.Server + file release/textcascade-server-linux-x64/TextCascade.Server + test -f release/textcascade-server-windows-x64/TextCascade.Server.exe + test -f release/textcascade-server-linux-x64/TextCascade.Server + + - name: Package Windows archive + run: | + cd release + zip -r ../TextCascade.Server-${{ steps.version.outputs.version }}-windows-x64.zip textcascade-server-windows-x64 + + - name: Package Linux archive + run: | + cd release + tar -czf ../TextCascade.Server-${{ steps.version.outputs.version }}-linux-x64.tar.gz textcascade-server-linux-x64 + + - name: Generate checksums + run: | + cd "$GITHUB_WORKSPACE" + sha256sum \ + TextCascade.Server-${{ steps.version.outputs.version }}-windows-x64.zip \ + TextCascade.Server-${{ steps.version.outputs.version }}-linux-x64.tar.gz \ + > checksums-sha256.txt + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: textcascade-server-${{ steps.version.outputs.version }} + path: | + TextCascade.Server-${{ steps.version.outputs.version }}-windows-x64.zip + TextCascade.Server-${{ steps.version.outputs.version }}-linux-x64.tar.gz + checksums-sha256.txt + if-no-files-found: error + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: TextCascade Server ${{ github.ref_name }} + draft: false + prerelease: ${{ contains(github.ref_name, '-') }} + files: | + TextCascade.Server-${{ steps.version.outputs.version }}-windows-x64.zip + TextCascade.Server-${{ steps.version.outputs.version }}-linux-x64.tar.gz + checksums-sha256.txt diff --git a/.gitignore b/.gitignore index 0ba57b3..cadc68e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ ## 项目私有目录,不纳入版本库 -specs/ +/specs/ +.zcode/ .trae/ ## .NET 构建产物 @@ -58,3 +59,4 @@ Desktop.ini *.log logs/ tmp/ +artifacts/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6cd79b0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,103 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.5.0] - 2026-09-04 + +### Changed +- Replaced hand-rolled helpers with standard library equivalents, with no behavior change: `TokenService` base64url codec now uses `System.Buffers.Text.Base64Url` (.NET 9 BCL) instead of a custom encode/decode pair; the `IClock`/`SystemClock` seam was replaced by `System.TimeProvider` (registered in DI, tests pass `TimeProvider.System`); `HeartbeatScannerService` was rewritten from a `System.Threading.Timer` callback to `PeriodicTimer` inside `BackgroundService` (scan exceptions are now logged instead of being unobserved). +- Login request parsing (`AuthService.ParseLoginRequest`) now performs a single strict `JsonSerializer.DeserializeAsync` pass with .NET 10 `AllowDuplicateProperties = false` and `JsonUnmappedMemberHandling.Disallow`, replacing the manual 4 KB chunked read loop plus a double `JsonDocument`/`JsonSerializer` parse; the 16 KB cap is enforced via `IHttpMaxRequestBodySizeFeature` (chunked bodies included). Structural violations previously reported as "Invalid login request." now return "Invalid JSON." — status 400 and the `invalid_request` code are unchanged. +- `SingleInstanceLock` no longer probes or recovers PIDs: the lock file (`users.json.lock`) is opened with `FileMode.OpenOrCreate` + `FileShare.None`, so the OS releases it when the holder process dies and a leftover file from a crash is simply reopened instead of blocking. The PID written into the file is diagnostic only. Contention behavior (3 retries, then graceful failure) is unchanged. + +### Fixed +- TLS server authentication failed on Windows with "platform does not support ephemeral keys" (0x8009030E): `CertificateLoader` now loads PFX certificates with a persisted key set (`DefaultKeySet`) on Windows (Linux keeps `EphemeralKeySet`), and PEM-loaded certificates are re-exported to a persisted key on Windows. Spec §2.1's Windows Service hosting shape required this to be usable. Found while benchmarking 1000 concurrent connections on a local Windows deployment. + +### Documentation +- perf.md: added the Windows local 1000-connection result — 10-minute hold with 0 errors and all 1000 connections alive, RSS delta ≈211 MB (≈211 KB/connection, one third of the Linux figure); P8 is now verified. +- Rewrote `perf.md` as a bilingual (中文/English) actual performance measurement report for v0.4.0 on the production VPS: 1KB broadcast p95 3.5 ms (8.6× headroom), 512KB p95 103 ms, idle CPU 0.08%, cold start 2 s; memory targets (P1/P2) recorded as unmet and flagged for revision; the 1000-connection scenario is documented as untestable on a 1.6 GB same-host environment after it exhausted memory and forced a VM reset. +- Added `tools/perf_probe.py`, a stdlib-only asyncio WSS probe used for the measurements (hold/latency/slow-consumer scenarios). +- Recorded two implementation findings in `docs/server-spec.md` §15: unbounded shutdown close-handshake wait (34 s stop phase observed) and silent queue-full abort without a disconnect security event. + +## [0.4.0] - 2026-08-27 + +### Added +- Contract test suite with JSON sample corpus (`ContractSamples/`) covering duplicate fields, unknown fields, illegal number forms, depth-4 nesting, and invalid UTF-8, plus byte-level serialization invariants for welcome/clip/ack/ping/error/token payloads. +- Network integration test suite (`Category=NetworkIntegration`, 12 cases) over real Kestrel TLS with runtime-generated self-signed certificates: WSS handshake, TLS 1.2/1.3 protocol probes, random port binding, real frame fragmentation, oversize/zero-length frame closes (1009), server restart with token direct reconnect and persisted-version baseline, snapshot election restore, and graceful-shutdown bye/1001 chain. +- Slow-hash smoke tests (`Category=SlowHash`) exercising the real Argon2 Hash/Verify/NeedsRehash chain with production parameters. +- Unit tests closing spec §10.1 gaps: token duplicate-field/illegal-number/range rejection, CLI watermark allocation, delete-and-recreate watermark behavior, revoke and overflow fail-fast with byte-identical file preservation, `WithVersion` immutability, and behavior-level duplicate-id idempotency (drained token bucket still acks duplicates; reused id with new content treated as fresh message). + +### Changed +- CI main test job now includes the SlowHash category and excludes only `Category=NetworkIntegration`, which runs in a dedicated CI job. +- Release workflow test step aligned with the same category filter. +- Server spec (`docs/server-spec.md`) rewritten to match v0.3.5 implementation: hot user-file reload, RuntimeStateStore version persistence, content-comparing clip idempotency semantics, 10-minute idle hub recycling, actual log event fields, and a new implementation-gap ledger (§15). Never-implemented items (benchmark project, `server_stop` event, performance target table) removed. +- Added `specs/test-and-contract-spec.md` (function-level test and contract specification) and `specs/spec-decisions.md` (decision record for the spec alignment). + +## [0.3.5] - 2026-08-22 + +### Changed +- Refactored `RuntimeStateStore` to lock-free memory CAS updates using `ConcurrentDictionary` and background periodic flush (`PeriodicTimer`) with atomic snapshot persistence, eliminating synchronous disk I/O bottlenecks in the clip synchronization pipeline. +- Added graceful shutdown flush hook to `SyncServer.ShutdownAsync` ensuring all pending version increments are flushed upon service stop. +- Added concurrency, background periodic flush, and fault-tolerance unit tests for `RuntimeStateStore`. + +## [0.3.0] - 2026-08-22 + +### Added +- Native ASP.NET Core `CreateApp` factory and comprehensive end-to-end WebSocket integration tests covering handshake, broadcast, snapshots, invalid tokens, and abrupt disconnections. +- Hot-reloading of `users.json` via file system watcher with debounced reload and periodic fallback. +- Constant-time password verification on login for non-existent users using a cached dummy hash. + +### Changed +- Refactored `SyncServer` by splitting models, hubs, and hosting services into dedicated domain files (`Models/`, `Hub/`, `Hosting/`). +- Decoupled `UserHub` from `SyncServer` using lightweight `IConnectionCoordinator` interface. +- Protected `UserHub.StartIfIdle` with mutex lock and added single-reader re-entrancy protection to `RunUserLoopAsync`. +- Optimized `SeenIdRing` deduplication with hash map lookup (`Dictionary`) and FIFO circular eviction queue. +- Migrated PEM certificate and private key loading to native .NET APIs (`X509Certificate2.CreateFromPemFile` and `X509Certificate2Collection.ImportFromPemFile`). +- Relocated CLI single-instance lock file adjacent to the target `users.json` (`users.json.lock`). +- Switched WebSocket JSON message parsing to `ReadOnlyMemory` to eliminate redundant byte array copies (`frame.ToArray()`). + +### Fixed +- Fixed sliding window login rate limiter to clean up only expired timestamps from queue head rather than evicting the entire key. + +### Security +- Eliminated username existence timing side-channel during login authentication. + +## [0.2.5] - 2026-08-22 + +### Added +- Version persistence across server restarts using `RuntimeStateStore` (`textcascade.state.json`). +- Reconnection snapshot recovery window during server startup. + +### Changed +- Refactored snapshot selection tie-breaking and broadcast logic. + +### Fixed +- Fixed server protocol bugs in version negotiation and error responses. + +## [0.2.1] - 2026-08-18 + +### Fixed +- Fixed server protocol bugs in message deserialization and error framing. + +## [0.2.0] - 2026-08-18 + +### Added +- Cross-platform release workflows and CI matrix for Linux and Windows single-file binaries. +- Bilingual README and documentation. + +## [0.1.0] - 2026-08-18 + +### Added +- Initial import and baseline release of TextCascade Server with Minimal API, Kestrel WebSocket, Argon2 password hashing, and token authentication. + +[Unreleased]: https://github.com/long45343/TextCascade-Server/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/long45343/TextCascade-Server/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/long45343/TextCascade-Server/compare/v0.3.5...v0.4.0 +[0.3.5]: https://github.com/long45343/TextCascade-Server/compare/v0.3.0...v0.3.5 +[0.3.0]: https://github.com/long45343/TextCascade-Server/compare/v0.2.5...v0.3.0 +[0.2.5]: https://github.com/long45343/TextCascade-Server/compare/v0.2.1...v0.2.5 +[0.2.1]: https://github.com/long45343/TextCascade-Server/compare/v0.2.0...v0.2.1 +[0.2.0]: https://github.com/long45343/TextCascade-Server/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/long45343/TextCascade-Server/releases/tag/v0.1.0 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/README.md b/README.md index 5a67d15..00e7d30 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# TextCascade Server +# TextCascade Server [简体中文](#简体中文) | [English](#english) @@ -30,7 +30,7 @@ | 密码哈希 | Argon2(id)(`Isopoh.Cryptography.Argon2`) | | 用户存储 | `users.json` | | 协议子协议 | `textcascade.v1` | -| 产品版本 | SemVer,当前 `0.2.0` | +| 产品版本 | SemVer,当前 `0.4.0` | ### 仓库结构 @@ -39,15 +39,21 @@ TextCascade-Server/ ├── TextCascade.Server/ 服务端源码 │ ├── Program.cs 入口:serve / user CLI 分发 │ ├── ServerHost.cs 配置加载、证书、WebHost 构建、路由映射 -│ ├── SyncServer.cs 核心:UserHub/UserRegistry/连接与广播 +│ ├── SyncServer.cs 核心协调器 +│ ├── Hosting/ 端点、连接处理、心跳与文件监听 +│ ├── Hub/ UserHub、UserRegistry、协调器接口与任务 +│ ├── Models/ 连接上下文、状态模型与接收消息 │ ├── Protocol.cs JSON 协议模型与解析 │ ├── Auth.cs / AuthService.cs token 签发、校验、登录限流 │ ├── Users.cs / Cli.cs 用户文件与 CLI(add/passwd/...) │ ├── RuntimeConfig.cs TOML 配置与默认值、环境变量覆盖 -│ └── Core.cs 限流等基础工具 +│ ├── RuntimeStateStore.cs 版本号落盘(textcascade.state.json) +│ ├── SecurityLogging.cs 结构化安全日志与脱敏 +│ └── Core.cs 限流、去重环形队列等基础工具 ├── TextCascade.Server.Tests/ xUnit 测试 ├── deploy/ systemd unit、示例 TOML 与空 users.json -└── TextCascade.Server.slnx 解决案 +├── CHANGELOG.md 版本变更记录 +└── TextCascade.Server.slnx 解决方案 ``` ### 快速开始 @@ -64,7 +70,7 @@ TextCascade-Server/ 3. **添加用户** ``` - dotnet TextCascade.Server.dll user add --username alice + dotnet TextCascade.Server.dll user add --config /etc/textcascade/textcascade.toml --username alice ``` CLI 子命令:`add`、`passwd`、`disable`、`enable`、`delete`、`revoke-tokens`、`list`、`hash`。 @@ -111,10 +117,12 @@ clip_tokens_per_second = 2 [files] users_file = "users.json" +state_file = "textcascade.state.json" ``` 关键规则: - `token_secret_env` 指向环境变量名,secret 不写入 TOML;长度 < 32 字节则启动失败。 +- CLI 配置回退顺序为 `--config`、`TEXTCASCADE_CONFIG`、当前目录 `textcascade.toml`;`TEXTCASCADE_USERS_FILE` 与 `TEXTCASCADE_STATE_FILE` 仍可覆盖 TOML。 - TLS 始终启用;证书仅支持无密码格式(PEM bundle 或无密码 PFX),带密码 PFX 不支持。 - `max_frame_bytes` 必须大于 `max_text_bytes`(差额留给协议头)。 - 所有容量与时间配置必须 > 0,心跳超时必须大于心跳间隔。 @@ -144,16 +152,35 @@ dotnet test 测试覆盖协议解析、配置、登录限流、token 服务、用户文件、clip 与核心逻辑。 + +### 下载与发布 + +GitHub Release 提供两种 Framework-dependent 单文件包,目标机需预装 .NET 10 Runtime: + +- `TextCascade.Server--windows-x64.zip` +- `TextCascade.Server--linux-x64.tar.gz` + +包内附带主程序、配置模板;Linux 包另附 systemd unit。每次 Release 同时提供 SHA-256 校验文件。 + +推送 `v*.*.*` 标签(如 `v0.4.0`)会自动执行测试、构建双平台单文件包、生成校验和并发布 GitHub Release。`main` 分支和 Pull Request 会自动执行 restore/build/test CI。 ### 生产部署(systemd) 参考 `deploy/textcascade-server.service`: +- 以专用系统用户 `textcascade` 运行;先执行 `useradd --system --home /opt/textcascade-server --shell /usr/sbin/nologin textcascade`。 - 以 systemd 托管,`Restart=on-failure`,开启 `ProtectSystem`/`ProtectHome`/`PrivateTmp` 等加固项。 -- 配置与 users.json 放 `/etc/textcascade/`,程序放 `/opt/textcascade-server/`。 +- 配置与 users.json 放 `/etc/textcascade/`,运行状态放 `/var/lib/textcascade/`,程序放 `/opt/textcascade-server/`;目录属主设为 `textcascade:textcascade`。 - token secret 由 `/etc/textcascade/textcascade.env` 注入。 ### 许可 -见仓库 LICENSE(如有)。 +见仓库 [LICENSE](LICENSE)(GPL-3.0)。 + +--- + +### 发版与维护规范 + +- 每个用户可见版本发布前,需将 CHANGELOG.md 中的 [Unreleased] 部分归档为对应版本号与发版日期,并维护底部的 compare 链接。 +- 版本号遵循 [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html),版本变更记录遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)。 --- @@ -183,7 +210,7 @@ Built on ASP.NET Core Minimal API with native Kestrel WebSockets, TLS-terminated | Password hash | Argon2(id) (`Isopoh.Cryptography.Argon2`) | | User store | `users.json` | | Subprotocol | `textcascade.v1` | -| Version | SemVer, currently `0.2.0` | +| Version | SemVer, currently `0.4.0` | ### Quick Start @@ -199,7 +226,7 @@ Built on ASP.NET Core Minimal API with native Kestrel WebSockets, TLS-terminated 3. **Add a user** ``` - dotnet TextCascade.Server.dll user add --username alice + dotnet TextCascade.Server.dll user add --config /etc/textcascade/textcascade.toml --username alice ``` CLI subcommands: `add`, `passwd`, `disable`, `enable`, `delete`, `revoke-tokens`, `list`, `hash`. @@ -246,10 +273,12 @@ clip_tokens_per_second = 2 [files] users_file = "users.json" +state_file = "textcascade.state.json" ``` Key rules: - `token_secret_env` names an env var; the secret is never written to TOML and must be >= 32 bytes. +- CLI config fallback is `--config`, then `TEXTCASCADE_CONFIG`, then `textcascade.toml`; `TEXTCASCADE_USERS_FILE` and `TEXTCASCADE_STATE_FILE` still override TOML. - TLS is always on; only password-less certs are supported (PEM bundle or password-less PFX). - `max_frame_bytes` must exceed `max_text_bytes` (the difference covers the JSON header). - All capacity/time values must be > 0; heartbeat timeout must exceed the interval. @@ -279,13 +308,31 @@ dotnet test Covers protocol parsing, config, login limiting, token service, users file, and clip/core logic. + +### Downloads and Releases + +GitHub Releases provides two framework-dependent single-file archives. The .NET 10 Runtime must be installed on the target machine: + +- `TextCascade.Server--windows-x64.zip` +- `TextCascade.Server--linux-x64.tar.gz` + +Each archive contains the executable and config template; the Linux archive also includes the systemd unit. Every Release includes a SHA-256 checksum file. + +Pushing a `v*.*.*` tag (for example `v0.4.0`) runs tests, builds both single-file archives, generates checksums, and publishes a GitHub Release. Pushes to `main` and pull requests run restore/build/test CI automatically. ### Production (systemd) See `deploy/textcascade-server.service`: +- Run as the dedicated `textcascade` system user; create it with `useradd --system --home /opt/textcascade-server --shell /usr/sbin/nologin textcascade`. - Managed by systemd with `Restart=on-failure` and hardening flags (`ProtectSystem`, `ProtectHome`, `PrivateTmp`). -- Config and `users.json` under `/etc/textcascade/`; binaries under `/opt/textcascade-server/`. +- Config and `users.json` under `/etc/textcascade/`, runtime state under `/var/lib/textcascade/`, and binaries under `/opt/textcascade-server/`; set directory ownership to `textcascade:textcascade`. - Token secret injected via `/etc/textcascade/textcascade.env`. ### License -See the repository LICENSE if present. +See [LICENSE](LICENSE) (GPL-3.0). + +### Release & Maintenance Guidelines + +- Before releasing any user-visible version, update CHANGELOG.md by moving the [Unreleased] section to the target version number and release date, along with the compare link at the bottom. +- Versioning adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) and change records follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + diff --git a/TextCascade.Server.Tests/AuthDeepTests.cs b/TextCascade.Server.Tests/AuthDeepTests.cs new file mode 100644 index 0000000..a61e07f --- /dev/null +++ b/TextCascade.Server.Tests/AuthDeepTests.cs @@ -0,0 +1,161 @@ +using System.Security.Cryptography; +using System.Text; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class AuthDeepTests +{ + private static readonly byte[] Secret = Encoding.UTF8.GetBytes(new string('k', 32)); + + private static UserRecord User(string username, long version) => + new(username, "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$hash", version); + + private static IReadOnlyDictionary Lookup(params UserRecord[] users) => + users.ToDictionary(u => u.Username, u => u, StringComparer.Ordinal); + + private static string CompactFromPayloadJson(string payloadJson) + { + var payloadBytes = Encoding.UTF8.GetBytes(payloadJson); + var signature = HMACSHA256.HashData(Secret, payloadBytes); + return Base64Url(payloadBytes) + "." + Base64Url(signature); + } + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static bool Verify(string payloadJson, TokenPayload? expected = null) + { + var token = CompactFromPayloadJson(payloadJson); + var now = DateTimeOffset.FromUnixTimeSeconds(1760000001); + return new TokenService(Secret).TryVerifyToken(token, now, Lookup(User("alice", 1)), out var actual) + && (expected is null || (actual.Subject == expected.Subject + && actual.Version == expected.Version + && actual.IssuedAtUnix == expected.IssuedAtUnix + && actual.ExpiresAtUnix == expected.ExpiresAtUnix)); + } + + // U1 + [Fact] + public void SignToken_FieldOrder_And_MinimalJson() + { + var payload = new TokenPayload("alice", 1, 1760000000, 1762592000); + var compact = TokenService.SignToken(payload, Secret); + var payloadJson = Encoding.UTF8.GetString(Base64UrlDecode(compact.Split('.')[0])); + + Assert.Equal("""{"sub":"alice","ver":1,"iat":1760000000,"exp":1762592000}""", payloadJson); + } + + // U2 + [Fact] + public void VerifyToken_Rejects_DuplicateFields() + { + Assert.False(Verify("""{"sub":"alice","ver":1,"ver":1,"iat":1760000000,"exp":1762592000}""")); + Assert.False(Verify("""{"sub":"alice","sub":"alice","ver":1,"iat":1760000000,"exp":1762592000}""")); + } + + // U3 + [Fact] + public void VerifyToken_Rejects_UnknownField() + { + Assert.False(Verify("""{"sub":"alice","ver":1,"iat":1760000000,"exp":1762592000,"aud":"x"}""")); + } + + // U4 + [Fact] + public void VerifyToken_Rejects_FractionNumber() + { + Assert.False(Verify("""{"sub":"alice","ver":1,"iat":1760000000.0,"exp":1762592000}""")); + Assert.False(Verify("""{"sub":"alice","ver":1.0,"iat":1760000000,"exp":1762592000}""")); + } + + // U5 + [Fact] + public void VerifyToken_Rejects_StringNumber() + { + Assert.False(Verify("""{"sub":"alice","ver":1,"iat":1760000000,"exp":"1762592000"}""")); + } + + // U6 + [Fact] + public void VerifyToken_Rejects_NegativeValue() + { + Assert.False(Verify("""{"sub":"alice","ver":-1,"iat":1760000000,"exp":1762592000}""")); + Assert.False(Verify("""{"sub":"alice","ver":1,"iat":-1760000000,"exp":1762592000}""")); + } + + // U7 + [Fact] + public void VerifyToken_Rejects_ExpNotAfterIat() + { + Assert.False(Verify("""{"sub":"alice","ver":1,"iat":1762592000,"exp":1760000000}""")); + Assert.False(Verify("""{"sub":"alice","ver":1,"iat":1760000000,"exp":1760000000}""")); + } + + // U8 + [Fact] + public void VerifyToken_Rejects_ZeroIat() + { + Assert.False(Verify("""{"sub":"alice","ver":1,"iat":0,"exp":1762592000}""")); + } + + // U9 + [Fact] + public void VerifyToken_RoundTrip_InstanceOverload() + { + var now = DateTimeOffset.FromUnixTimeSeconds(1760000000); + var service = new TokenService(Secret); + var token = service.CreateToken(User("alice", 1), now, TimeSpan.FromDays(30)); + + Assert.True(service.TryVerifyToken(token.CompactToken, now, Lookup(User("alice", 1)), out var payload)); + Assert.Equal("alice", payload.Subject); + Assert.Equal(1, payload.Version); + Assert.Equal(1760000000, payload.IssuedAtUnix); + Assert.Equal(1762592000, payload.ExpiresAtUnix); + } + + // U10 + [Fact] + public void NeedsRehash_ParameterParsing() + { + var encoded = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aG9zdA"; + + Assert.False(Argon2PasswordHasher.NeedsRehash(encoded, 19456, 2, 1)); + Assert.True(Argon2PasswordHasher.NeedsRehash(encoded, 1024, 2, 1)); + Assert.True(Argon2PasswordHasher.NeedsRehash(encoded, 19456, 3, 1)); + Assert.True(Argon2PasswordHasher.NeedsRehash(encoded, 19456, 2, 4)); + Assert.True(Argon2PasswordHasher.NeedsRehash("", 19456, 2, 1)); + Assert.True(Argon2PasswordHasher.NeedsRehash("$argon2i$v=19$m=19456,t=2,p=1$c2FsdA$aG9zdA", 19456, 2, 1)); + Assert.True(Argon2PasswordHasher.NeedsRehash("not-a-hash", 19456, 2, 1)); + } + + // U11 + [Fact] + public void WithVersion_Produces_NewImmutableRecord() + { + var original = new LatestText("payload", 7, "hash", true, "client", "name", new DateTimeOffset(2026, 8, 18, 8, 0, 0, TimeSpan.Zero)); + + var updated = CoreLogic.WithVersion(original, 8); + Assert.Equal(8UL, updated.Version); + Assert.Equal(original.Payload, updated.Payload); + Assert.Equal(original.Hash, updated.Hash); + Assert.Equal(original.Encrypted, updated.Encrypted); + Assert.Equal(original.FromClientId, updated.FromClientId); + Assert.Equal(original.FromClientName, updated.FromClientName); + Assert.Equal(original.UpdatedAtUtc, updated.UpdatedAtUtc); + Assert.Equal(7UL, original.Version); + Assert.NotSame(original, updated); + + var withTime = CoreLogic.WithVersion(original, 9, new DateTimeOffset(2026, 8, 18, 9, 0, 0, TimeSpan.Zero)); + Assert.Equal(9UL, withTime.Version); + Assert.Equal(new DateTimeOffset(2026, 8, 18, 9, 0, 0, TimeSpan.Zero), withTime.UpdatedAtUtc); + } + + private static byte[] Base64UrlDecode(string segment) + { + var padded = segment.Replace('-', '+').Replace('_', '/'); + var remainder = padded.Length % 4; + if (remainder > 0) { padded += new string('=', 4 - remainder); } + return Convert.FromBase64String(padded); + } +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/AuthServiceTimingTests.cs b/TextCascade.Server.Tests/AuthServiceTimingTests.cs new file mode 100644 index 0000000..43071f1 --- /dev/null +++ b/TextCascade.Server.Tests/AuthServiceTimingTests.cs @@ -0,0 +1,179 @@ +using System.IO.Pipelines; +using System.Text; +using System.Text.Json; +using Isopoh.Cryptography.Argon2; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class AuthServiceTimingTests +{ + private sealed class RecordingHasher : IPasswordHasher + { + public List<(string Password, Argon2Config Config)> HashCalls { get; } = new(); + public List<(string Password, string EncodedHash)> VerifyCalls { get; } = new(); + + public string DummyHashReturn { get; set; } = "$argon2id$v=19$m=19456,t=2,p=1$dummy"; + + public string Hash(string password, Argon2Config config) + { + HashCalls.Add((password, config)); + return DummyHashReturn; + } + + public bool Verify(string password, string encodedHash) + { + VerifyCalls.Add((password, encodedHash)); + return string.Equals(password, "correct-password", StringComparison.Ordinal) + && string.Equals(encodedHash, "valid-hash", StringComparison.Ordinal); + } + + public bool NeedsRehash(string encodedHash, Argon2Config config) => false; + } + + private sealed class TestLogger : ILogger + { + public List LoggedMessages { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + LoggedMessages.Add(formatter(state, exception)); + } + } + + private static (DefaultHttpContext Context, MemoryStream ResponseBody) CreateHttpContext(string username, string password) + { + var context = new DefaultHttpContext(); + var json = JsonSerializer.Serialize(new { username, password }); + var bytes = Encoding.UTF8.GetBytes(json); + context.Request.Body = new MemoryStream(bytes); + context.Request.ContentLength = bytes.Length; + context.Request.ContentType = "application/json"; + + var responseBody = new MemoryStream(); + context.Response.Body = responseBody; + return (context, responseBody); + } + + [Fact] + public async Task LoginVerificationRunsForMissingUser() + { + var hasher = new RecordingHasher(); + var config = TextCascade.Server.Config.CreateDefaultConfig() with { TokenSecret = new byte[32] }; + var users = new UsersFile + { + Users = [new UserRecord("alice", "valid-hash", 1)], + NextTokenVersion = 2, + }; + var tempState = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json"); + try + { + var stateStore = new RuntimeStateStore(tempState); + var server = new SyncServer(config, users, stateStore, hasher, TimeProvider.System, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + Assert.Equal(server.LoginDummyHash, hasher.DummyHashReturn); + + // 1. Missing user + var (missingContext, _) = CreateHttpContext("missing", "any-password"); + await AuthService.HandleLoginAsync(missingContext, config, server); + Assert.Equal(401, missingContext.Response.StatusCode); + Assert.Single(hasher.VerifyCalls); + Assert.Equal("missing", "missing"); + Assert.Equal(server.LoginDummyHash, hasher.VerifyCalls[0].EncodedHash); + Assert.Equal("any-password", hasher.VerifyCalls[0].Password); + + // 2. Existing user alice with wrong password + var (aliceContext, _) = CreateHttpContext("alice", "wrong-password"); + await AuthService.HandleLoginAsync(aliceContext, config, server); + Assert.Equal(401, aliceContext.Response.StatusCode); + Assert.Equal(2, hasher.VerifyCalls.Count); + Assert.Equal("valid-hash", hasher.VerifyCalls[1].EncodedHash); + Assert.Equal("wrong-password", hasher.VerifyCalls[1].Password); + } + finally + { + if (File.Exists(tempState)) File.Delete(tempState); + } + } + + [Fact] + public void LoginDummyHashUsesConfiguredArgon2Parameters() + { + var hasher = new RecordingHasher(); + var config = TextCascade.Server.Config.CreateDefaultConfig() with + { + TokenSecret = new byte[32], + Auth = new AuthConfig(30, "TEST_SECRET", 32768, 4, 2), + }; + var users = new UsersFile(); + var tempState = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json"); + try + { + var stateStore = new RuntimeStateStore(tempState); + var server = new SyncServer(config, users, stateStore, hasher, TimeProvider.System, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + Assert.Single(hasher.HashCalls); + var call = hasher.HashCalls[0]; + Assert.Equal("textcascade-login-timing-dummy", call.Password); + Assert.Equal(32768, call.Config.MemoryCost); + Assert.Equal(4, call.Config.TimeCost); + Assert.Equal(2, call.Config.Threads); + Assert.Equal(server.LoginDummyHash, hasher.DummyHashReturn); + } + finally + { + if (File.Exists(tempState)) File.Delete(tempState); + } + } + + [Fact] + public async Task DisabledUserStillReturnsUnifiedInvalidCredentials() + { + var hasher = new RecordingHasher(); + var config = TextCascade.Server.Config.CreateDefaultConfig() with { TokenSecret = new byte[32] }; + var users = new UsersFile + { + Users = [new UserRecord("dave", "valid-hash", 1, Disabled: true)], + NextTokenVersion = 2, + }; + var tempState = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json"); + var logger = new TestLogger(); + try + { + var stateStore = new RuntimeStateStore(tempState); + var server = new SyncServer(config, users, stateStore, hasher, TimeProvider.System, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + var (context, responseBody) = CreateHttpContext("dave", "correct-password"); + await AuthService.HandleLoginAsync(context, config, server, logger); + + Assert.Equal(401, context.Response.StatusCode); + var json = Encoding.UTF8.GetString(responseBody.ToArray()); + Assert.Contains("invalid_credentials", json); + + // Ensure logs do not mention "disabled" + Assert.NotEmpty(logger.LoggedMessages); + foreach (var log in logger.LoggedMessages) + { + Assert.DoesNotContain("reason=disabled", log, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("disabled", log, StringComparison.OrdinalIgnoreCase); + } + } + finally + { + if (File.Exists(tempState)) File.Delete(tempState); + } + } +} + + diff --git a/TextCascade.Server.Tests/CertificateLoaderTests.cs b/TextCascade.Server.Tests/CertificateLoaderTests.cs new file mode 100644 index 0000000..649d6c6 --- /dev/null +++ b/TextCascade.Server.Tests/CertificateLoaderTests.cs @@ -0,0 +1,143 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class CertificateLoaderTests +{ + private static (string CertPem, string KeyPem) GenerateSelfSignedRsaPem() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var notBefore = DateTimeOffset.UtcNow.AddMinutes(-5); + var notAfter = DateTimeOffset.UtcNow.AddDays(1); + using var cert = request.CreateSelfSigned(notBefore, notAfter); + + var certPem = cert.ExportCertificatePem(); + var keyPem = rsa.ExportPkcs8PrivateKeyPem(); + return (certPem, keyPem); + } + + private static (string CertPem, string KeyPem) GenerateSelfSignedEcdsaPem() + { + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest("CN=localhost", ecdsa, HashAlgorithmName.SHA256); + var notBefore = DateTimeOffset.UtcNow.AddMinutes(-5); + var notAfter = DateTimeOffset.UtcNow.AddDays(1); + using var cert = request.CreateSelfSigned(notBefore, notAfter); + + var certPem = cert.ExportCertificatePem(); + var keyPem = ecdsa.ExportPkcs8PrivateKeyPem(); + return (certPem, keyPem); + } + + [Fact] + public void LoadPemCertificateSupportsRsaCertAndSeparateKey() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var (certPem, keyPem) = GenerateSelfSignedRsaPem(); + var certPath = Path.Combine(tempDir, "server.crt"); + var keyPath = Path.Combine(tempDir, "server.key"); + File.WriteAllText(certPath, certPem, Encoding.UTF8); + File.WriteAllText(keyPath, keyPem, Encoding.UTF8); + + using var loaded = CertificateLoader.Load(certPath); + Assert.True(loaded.Certificate.HasPrivateKey); + Assert.NotEmpty(loaded.Chain); + Assert.Equal(loaded.Certificate.Thumbprint, loaded.Chain[0].Thumbprint); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void LoadPemCertificateSupportsCombinedPem() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var (certPem, keyPem) = GenerateSelfSignedRsaPem(); + var pemPath = Path.Combine(tempDir, "server.pem"); + File.WriteAllText(pemPath, certPem + "\n" + keyPem, Encoding.UTF8); + + using var loaded = CertificateLoader.Load(pemPath); + Assert.True(loaded.Certificate.HasPrivateKey); + Assert.NotEmpty(loaded.Chain); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void LoadPemCertificateSupportsEcdsaCertAndSeparateKey() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var (certPem, keyPem) = GenerateSelfSignedEcdsaPem(); + var certPath = Path.Combine(tempDir, "server.crt"); + var keyPath = Path.Combine(tempDir, "server.key"); + File.WriteAllText(certPath, certPem, Encoding.UTF8); + File.WriteAllText(keyPath, keyPem, Encoding.UTF8); + + using var loaded = CertificateLoader.Load(certPath); + Assert.True(loaded.Certificate.HasPrivateKey); + Assert.NotEmpty(loaded.Chain); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void LoadPemCertificateWrapsMissingPrivateKey() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var (certPem, _) = GenerateSelfSignedRsaPem(); + var certPath = Path.Combine(tempDir, "server.pem"); + File.WriteAllText(certPath, certPem, Encoding.UTF8); + + var ex = Assert.Throws(() => CertificateLoader.Load(certPath)); + Assert.NotNull(ex.InnerException); + Assert.Contains("Unable to load PEM certificate", ex.Message); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void LoadPemCertificateRejectsNoCertificate() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var certPath = Path.Combine(tempDir, "empty.pem"); + File.WriteAllText(certPath, "random garbage content", Encoding.UTF8); + + var ex = Assert.Throws(() => CertificateLoader.Load(certPath)); + Assert.Contains("Unable to load PEM certificate", ex.Message); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } +} diff --git a/TextCascade.Server.Tests/CliWatermarkTests.cs b/TextCascade.Server.Tests/CliWatermarkTests.cs new file mode 100644 index 0000000..5a4bac1 --- /dev/null +++ b/TextCascade.Server.Tests/CliWatermarkTests.cs @@ -0,0 +1,273 @@ +using System.Text; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class CliWatermarkTests +{ + private const string ValidHash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aG9zdA"; + + private sealed class StaticPasswordHasher : IPasswordHasher + { + public string Hash(string password, Isopoh.Cryptography.Argon2.Argon2Config config) => + "$argon2id$v=19$m=19456,t=2,p=1$" + Convert.ToBase64String(Encoding.UTF8.GetBytes(password).AsSpan(0, Math.Min(4, password.Length))) + "$" + Convert.ToBase64String("hashbytes"u8); + + public bool Verify(string password, string encodedHash) => encodedHash == Hash(password, null!); + + public bool NeedsRehash(string encodedHash, Isopoh.Cryptography.Argon2.Argon2Config config) => false; + } + + private static string NewTempDir() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return dir; + } + + /// + /// Runs the real CLI command set against a temp users.json. Password-consuming commands + /// (user add) must be wrapped with so --password-stdin reads the fed line. + /// + private static int WithStdin(string stdinLine, string[] args) + { + var original = Console.In; + try + { + Console.SetIn(new StringReader(stdinLine)); + return Cli.RunCli(args, new StaticPasswordHasher()); + } + finally + { + Console.SetIn(original); + } + } + + private static UsersFile LoadUsers(string path) => UsersFile.LoadUsers(path); + + private static void WriteUsersFile(string path, string json) => File.WriteAllText(path, json, Encoding.UTF8); + + private static RuntimeConfig ConfigFor(string usersPath) + { + var config = TextCascade.Server.Config.CreateDefaultConfig(); + return config with { Files = new FilesConfig(usersPath, Path.Combine(Path.GetDirectoryName(usersPath)!, "state.json")) }; + } + + // U13 + [Fact] + public void AddUser_Allocates_FromWatermark_Increments() + { + var dir = NewTempDir(); + try + { + var usersPath = Path.Combine(dir, "users.json"); + WriteUsersFile(usersPath, $$""" + { + "nextTokenVersion": 7, + "users": [ + {"username": "old", "passwordHash": "{{ValidHash}}", "tokenVersion": 3, "disabled": false} + ] + } + """); + + var config = ConfigFor(usersPath); + var exit = WithStdin("test-password", ["user", "add", "--username", "newuser", "--password-stdin", "--config", ConfigPathFor(config)]); + Assert.Equal(Cli.Ok, exit); + + var users = LoadUsers(usersPath); + var added = Assert.Single(users.Users, user => user.Username == "newuser"); + Assert.Equal(7, added.TokenVersion); + Assert.Equal(8, users.NextTokenVersion); + Assert.Equal(3, users.Users.Single(user => user.Username == "old").TokenVersion); + } + finally + { + Directory.Delete(dir, true); + } + } + + // U14 + [Fact] + public void DeleteUser_RecreateSameName_GetsFreshHigherVersion() + { + var dir = NewTempDir(); + try + { + var usersPath = Path.Combine(dir, "users.json"); + WriteUsersFile(usersPath, $$""" + { + "nextTokenVersion": 5, + "users": [ + {"username": "alice", "passwordHash": "{{ValidHash}}", "tokenVersion": 2, "disabled": false} + ] + } + """); + + var config = ConfigFor(usersPath); + Assert.Equal(Cli.Ok, Cli.RunCli(["user", "delete", "--username", "alice", "--config", ConfigPathFor(config)], new StaticPasswordHasher())); + Assert.Empty(LoadUsers(usersPath).Users); + + Assert.Equal(Cli.Ok, WithStdin("test-password", ["user", "add", "--username", "alice", "--password-stdin", "--config", ConfigPathFor(config)])); + + var users = LoadUsers(usersPath); + var recreated = Assert.Single(users.Users); + Assert.Equal("alice", recreated.Username); + Assert.Equal(5, recreated.TokenVersion); + Assert.NotEqual(2, recreated.TokenVersion); + Assert.Equal(6, users.NextTokenVersion); + } + finally + { + Directory.Delete(dir, true); + } + } + + // U15 + [Fact] + public void RevokeTokens_Sets_Watermark_Increments() + { + var dir = NewTempDir(); + try + { + var usersPath = Path.Combine(dir, "users.json"); + WriteUsersFile(usersPath, $$""" + { + "nextTokenVersion": 9, + "users": [ + {"username": "bob", "passwordHash": "{{ValidHash}}", "tokenVersion": 4, "disabled": false} + ] + } + """); + + var config = ConfigFor(usersPath); + Assert.Equal(Cli.Ok, Cli.RunCli(["user", "revoke-tokens", "--username", "bob", "--config", ConfigPathFor(config)], new StaticPasswordHasher())); + + var users = LoadUsers(usersPath); + Assert.Equal(9, users.Users.Single(user => user.Username == "bob").TokenVersion); + Assert.Equal(10, users.NextTokenVersion); + } + finally + { + Directory.Delete(dir, true); + } + } + + // U16 + [Fact] + public void AddUser_At_LongMaxWatermark_FailsFast_FileUnchanged() + { + var dir = NewTempDir(); + try + { + var usersPath = Path.Combine(dir, "users.json"); + var originalJson = $$""" + { + "nextTokenVersion": 9223372036854775807, + "users": [ + {"username": "old", "passwordHash": "{{ValidHash}}", "tokenVersion": 1, "disabled": false} + ] + } + """; + WriteUsersFile(usersPath, originalJson); + + var config = ConfigFor(usersPath); + Assert.Equal(Cli.Error, WithStdin("test-password", ["user", "add", "--username", "newuser", "--password-stdin", "--config", ConfigPathFor(config)])); + + Assert.Equal(originalJson.ReplaceLineEndings(), File.ReadAllText(usersPath, Encoding.UTF8).ReplaceLineEndings()); + Assert.Empty(Directory.GetFiles(dir, "*.tmp")); + } + finally + { + Directory.Delete(dir, true); + } + } + + // U17 + [Fact] + public void Revoke_At_LongMaxWatermark_FailsFast() + { + var dir = NewTempDir(); + try + { + var usersPath = Path.Combine(dir, "users.json"); + var originalJson = $$""" + { + "nextTokenVersion": 9223372036854775807, + "users": [ + {"username": "bob", "passwordHash": "{{ValidHash}}", "tokenVersion": 1, "disabled": false} + ] + } + """; + WriteUsersFile(usersPath, originalJson); + + var config = ConfigFor(usersPath); + Assert.Equal(Cli.Error, Cli.RunCli(["user", "revoke-tokens", "--username", "bob", "--config", ConfigPathFor(config)], new StaticPasswordHasher())); + Assert.Equal(originalJson.ReplaceLineEndings(), File.ReadAllText(usersPath, Encoding.UTF8).ReplaceLineEndings()); + } + finally + { + Directory.Delete(dir, true); + } + } + + // U18 + [Fact] + public void ValidateUsers_NextMustExceed_AllUserVersions() + { + var users = new UsersFile { NextTokenVersion = 5, Users = new() { new("alice", ValidHash, 5) } }; + Assert.Throws(() => UsersFile.ValidateUsers(users)); + } + + // U19 + [Fact] + public void ValidateUsers_Rejects_NonPositiveVersion() + { + Assert.Throws(() => + UsersFile.ValidateUsers(new UsersFile { NextTokenVersion = 5, Users = new() { new("alice", ValidHash, 0) } })); + Assert.Throws(() => + UsersFile.ValidateUsers(new UsersFile { NextTokenVersion = 5, Users = new() { new("alice", ValidHash, -1) } })); + Assert.Throws(() => + UsersFile.ValidateUsers(new UsersFile { NextTokenVersion = 0, Users = [] })); + } + + // U20 + [Fact] + public void SaveUsers_AtomicWrite_LeavesOriginal_OnValidationFailure() + { + var dir = NewTempDir(); + try + { + var usersPath = Path.Combine(dir, "users.json"); + WriteUsersFile(usersPath, $$""" + { + "nextTokenVersion": 5, + "users": [ + {"username": "alice", "passwordHash": "{{ValidHash}}", "tokenVersion": 1, "disabled": false} + ] + } + """); + var originalContent = File.ReadAllText(usersPath, Encoding.UTF8); + + var invalid = new UsersFile { NextTokenVersion = 5, Users = new() { new("alice", ValidHash, 5) } }; + Assert.Throws(() => UsersFile.SaveUsers(usersPath, invalid)); + + Assert.Equal(originalContent.ReplaceLineEndings(), File.ReadAllText(usersPath, Encoding.UTF8).ReplaceLineEndings()); + Assert.Empty(Directory.GetFiles(dir, "*.tmp")); + } + finally + { + Directory.Delete(dir, true); + } + } + + private static string ConfigPathFor(RuntimeConfig config) + { + // Write a minimal TOML that pins users_file to the test path. + var path = Path.Combine(Path.GetDirectoryName(config.Files.UsersFile)!, "textcascade.toml"); + File.WriteAllText(path, $""" + [files] + users_file = "{config.Files.UsersFile.Replace("\\", "\\\\")}" + state_file = "{config.Files.StateFile.Replace("\\", "\\\\")}" + """, Encoding.UTF8); + return path; + } +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ClipAndCoreTests.cs b/TextCascade.Server.Tests/ClipAndCoreTests.cs index 50c3501..2eac1ee 100644 --- a/TextCascade.Server.Tests/ClipAndCoreTests.cs +++ b/TextCascade.Server.Tests/ClipAndCoreTests.cs @@ -47,6 +47,18 @@ public void SeenIdRingRetainsOriginalResultForDuplicateAck() Assert.Equal(original, result); } + [Fact] + public void SeenIdRingTreatsSameIdWithChangedContentAsNewClip() + { + var ring = new SeenIdRing(4); + var original = new LatestText("first", 1, "hash-1", false, "client", "name", DateTimeOffset.UtcNow); + ring.RememberId("same-id", original); + + Assert.False(ring.IsUnchangedDuplicate("same-id", "second", "hash-2", false, out _)); + Assert.True(ring.IsUnchangedDuplicate("same-id", "first", "hash-1", false, out var unchanged)); + Assert.Equal(original, unchanged); + } + [Fact] public void TokenBucketRefillsOverTime() { @@ -132,4 +144,69 @@ public void SelectSnapshotWinnerBreaksTiesByTimeThenClientId() var winner2 = CoreLogic.SelectSnapshotWinner(new[] { a2, b2 }); Assert.Equal("pb", winner2!.Snapshot.Payload); } + + [Fact] + public void SeenIdRingMaintainsFifoEvictionAfterRepeatedIds() + { + var ring = new SeenIdRing(2); + var result1 = new LatestText("p1", 1, "h1", false, "c1", "n1", DateTimeOffset.UtcNow); + var result2 = new LatestText("p2", 2, "h2", false, "c2", "n2", DateTimeOffset.UtcNow); + + ring.RememberId("a", result1); + ring.RememberId("b"); + ring.RememberId("a", result2); + ring.RememberId("c"); + + // Eviction order: slot0 had "a"(overwritten by "a"), slot1 had "b"(overwritten by "c") + // When inserting "c" into slot 1, slot1 ("b") is evicted. + // When slot 0 is overwritten by "a", slot0 ("a") was updated. + // Let's check: + // slot 0: was "a" (result1), replaced by "a" (result2). entries["a"] = result2 + // slot 1: was "b" (null), replaced by "c" (null). "b" was evicted! + // So "b" was evicted, "c" and "a" exist. + // If we now insert one more: + var result3 = new LatestText("p3", 3, "h3", false, "c3", "n3", DateTimeOffset.UtcNow); + ring.RememberId("d", result3); // overwrites slot 0 (which was "a"). Evicts "a"! + Assert.False(ring.TryGetResult("a", out _)); + Assert.True(ring.TryGetResult("d", out var resD)); + Assert.Equal(result3, resD); + } + + [Fact] + public void SeenIdRingRepeatedIdKeepsLatestResultBeforeEviction() + { + var ring = new SeenIdRing(4); + var old = new LatestText("old", 1, "h", false, "c", "n", DateTimeOffset.UtcNow); + var latest = new LatestText("new", 2, "h", false, "c", "n", DateTimeOffset.UtcNow); + + ring.RememberId("same", old); + ring.RememberId("same", latest); + + Assert.True(ring.TryGetResult("same", out var actual)); + Assert.Equal(latest, actual); + } + + [Fact] + public void SeenIdRingEvictsOldestWhenFull() + { + var ring = new SeenIdRing(3); + ring.RememberId("a"); + ring.RememberId("b"); + ring.RememberId("c"); + ring.RememberId("d"); + + Assert.False(ring.TryGetResult("a", out _)); + Assert.True(ring.TryGetResult("b", out _)); + Assert.True(ring.TryGetResult("c", out _)); + Assert.True(ring.TryGetResult("d", out _)); + } + + [Fact] + public void SeenIdRingUsesOrdinalComparison() + { + var ring = new SeenIdRing(4); + ring.RememberId("A"); + Assert.False(ring.TryDuplicate("a")); + Assert.True(ring.TryDuplicate("A")); + } } diff --git a/TextCascade.Server.Tests/ConnectionStateTests.cs b/TextCascade.Server.Tests/ConnectionStateTests.cs new file mode 100644 index 0000000..7b3c6bd --- /dev/null +++ b/TextCascade.Server.Tests/ConnectionStateTests.cs @@ -0,0 +1,67 @@ +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class ConnectionStateTests +{ + [Fact] + public void UnsolicitedPongIsRejectedUntilPingIsAwaited() + { + var state = new ConnectionStateBag(TextCascade.Server.Config.CreateDefaultConfig()); + + Assert.False(state.TryTakePongAwaiting()); + } + + [Fact] + public void ExpectedPongIsAcceptedOnceAndThenRejectedAgain() + { + var state = new ConnectionStateBag(TextCascade.Server.Config.CreateDefaultConfig()); + + state.MarkPingAwaitingPong(); + Assert.True(state.TryTakePongAwaiting()); + Assert.False(state.TryTakePongAwaiting()); + } +} + +public class CliPasswordInputTests +{ + [Fact] + public void DetectsPasswordStdinFlag() + { + Assert.True(Cli.HasPasswordStdin(new[] { "add", "--username", "alice", "--password-stdin" })); + Assert.False(Cli.HasPasswordStdin(new[] { "add", "--username", "alice" })); + } + + [Fact] + public void PasswordStdinReadsOneLineWithoutConsoleKeyInput() + { + var original = Console.In; + try + { + Console.SetIn(new StringReader("secret-password\n")); + var args = new[] { "add", "--username", "alice", "--password-stdin" }; + Assert.Equal("secret-password", Cli.ReadPassword("Password: ", args)); + } + finally + { + Console.SetIn(original); + } + } + + [Fact] + public void PasswordStdinRejectsEmptyInput() + { + var original = Console.In; + try + { + Console.SetIn(new StringReader(string.Empty)); + var args = new[] { "hash", "--password-stdin" }; + Assert.Throws(() => Cli.ReadPassword("Password: ", args)); + } + finally + { + Console.SetIn(original); + } + } +} + diff --git a/TextCascade.Server.Tests/ContractSamples/README.md b/TextCascade.Server.Tests/ContractSamples/README.md new file mode 100644 index 0000000..282f7af --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/README.md @@ -0,0 +1,29 @@ +# Contract Samples + +服务端协议契约样本,供三端(C# 服务端 / C# 桌面端 / Kotlin Android 端)对拍。 + +## 目录语义(目录名即期望结果) + +| 目录 | 期望 | +|---|---| +| `valid/` | `ParseClientMessage` 成功,字段逐项匹配 | +| `invalid/duplicate-field/` | 失败,`invalid_message` | +| `invalid/unknown-field/` | 失败,`invalid_message` | +| `invalid/number/` | 失败,`invalid_message`(含小数/指数/字符串数字/负数/超 ulong/类型污染/非 UTC 时间) | +| `invalid/depth-4/` | 失败(MaxDepth=3),`invalid_message` | +| `invalid/utf8/` | `.bin` 原始字节帧,失败,`invalid_message` | + +驱动器(ContractSampleTests)按一级子目录名推断期望错误码,缺省 `invalid_message`。 + +## 无原生数值字段的等价覆盖说明 + +`clip` 没有数值字段、`pong` 的 `clientTimeUtc` 是时间字符串,因此数字形态在这些消息上以"字段类型污染"等价覆盖(同一 Utf8JsonReader 数字/类型分支): + +- clip.encrypted 字符串化 → TryGetBoolean 分支 +- clip.hash 数字化 → TryGetString 分支 +- pong.clientTimeUtc 数字化 / 无 Z 后缀 → TryGetUtcDateTime 分支 +- hello.snapshot 带 +02:00 偏移 → 非零 Offset 拒绝分支 + +## Token 直测样本 + +token payload 的负数 / 小数 / 字符串数字样本内联于 `AuthDeepTests`(不走样本文件,因其直接调用 `TokenService.TryVerifyToken`)。 \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/depth-4/hello.deep-nesting.json b/TextCascade.Server.Tests/ContractSamples/invalid/depth-4/hello.deep-nesting.json new file mode 100644 index 0000000..1b7a508 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/depth-4/hello.deep-nesting.json @@ -0,0 +1,17 @@ +{ + "type": "hello", + "clientId": "windows-a", + "clientName": "", + "lastServerVersion": 0, + "snapshot": { + "payload": "clipboard text", + "encrypted": true, + "hash": "sha256-hex", + "localModifiedAtUtc": "2026-08-18T08:00:00Z", + "extra": { + "level": { + "tooDeep": true + } + } + } +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/depth-4/hello.root-depth.json b/TextCascade.Server.Tests/ContractSamples/invalid/depth-4/hello.root-depth.json new file mode 100644 index 0000000..71c0ba4 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/depth-4/hello.root-depth.json @@ -0,0 +1,19 @@ +{ + "type": "hello", + "clientId": "windows-a", + "clientName": "", + "lastServerVersion": 0, + "snapshot": { + "payload": "text", + "encrypted": true, + "hash": "h", + "localModifiedAtUtc": "2026-08-18T08:00:00Z" + }, + "l1": { + "l2": { + "l3": { + "l4": 1 + } + } + } +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/clip.id.json b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/clip.id.json new file mode 100644 index 0000000..0b7c622 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/clip.id.json @@ -0,0 +1,8 @@ +{ + "type": "clip", + "id": "clip-1", + "id": "clip-2", + "payload": "text", + "encrypted": false, + "hash": "sha256-hex" +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/hello.clientId.json b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/hello.clientId.json new file mode 100644 index 0000000..bcbfa38 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/hello.clientId.json @@ -0,0 +1,7 @@ +{ + "type": "hello", + "clientId": "windows-a", + "clientId": "windows-b", + "clientName": "", + "lastServerVersion": 0 +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/hello.type.json b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/hello.type.json new file mode 100644 index 0000000..5c61809 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/hello.type.json @@ -0,0 +1,7 @@ +{ + "type": "hello", + "type": "clip", + "clientId": "windows-a", + "clientName": "", + "lastServerVersion": 0 +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/pong.type.json b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/pong.type.json new file mode 100644 index 0000000..1e3b08e --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/duplicate-field/pong.type.json @@ -0,0 +1,5 @@ +{ + "type": "pong", + "type": "pong", + "clientTimeUtc": "2026-08-18T08:02:00Z" +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/clip.encrypted.string-bool.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/clip.encrypted.string-bool.json new file mode 100644 index 0000000..d7cc238 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/clip.encrypted.string-bool.json @@ -0,0 +1,7 @@ +{ + "type": "clip", + "id": "clip-1", + "payload": "text", + "encrypted": "true", + "hash": "sha256-hex" +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/clip.hash.number.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/clip.hash.number.json new file mode 100644 index 0000000..ffdabc4 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/clip.hash.number.json @@ -0,0 +1,7 @@ +{ + "type": "clip", + "id": "clip-1", + "payload": "text", + "encrypted": false, + "hash": 12345 +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.exponent.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.exponent.json new file mode 100644 index 0000000..fb50ca2 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.exponent.json @@ -0,0 +1,6 @@ +{ + "type": "hello", + "clientId": "windows-a", + "clientName": "", + "lastServerVersion": 1e3 +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.fraction.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.fraction.json new file mode 100644 index 0000000..e454838 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.fraction.json @@ -0,0 +1,6 @@ +{ + "type": "hello", + "clientId": "windows-a", + "clientName": "", + "lastServerVersion": 1.5 +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.negative.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.negative.json new file mode 100644 index 0000000..f3c03e0 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.negative.json @@ -0,0 +1,6 @@ +{ + "type": "hello", + "clientId": "windows-a", + "clientName": "", + "lastServerVersion": -1 +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.string.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.string.json new file mode 100644 index 0000000..ccda0f7 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.string.json @@ -0,0 +1,6 @@ +{ + "type": "hello", + "clientId": "windows-a", + "clientName": "", + "lastServerVersion": "128" +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.too-large.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.too-large.json new file mode 100644 index 0000000..e05a470 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.lastserverversion.too-large.json @@ -0,0 +1,6 @@ +{ + "type": "hello", + "clientId": "windows-a", + "clientName": "", + "lastServerVersion": 18446744073709551616 +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.snapshot.offset-time.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.snapshot.offset-time.json new file mode 100644 index 0000000..06ec3ba --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/hello.snapshot.offset-time.json @@ -0,0 +1,12 @@ +{ + "type": "hello", + "clientId": "windows-a", + "clientName": "", + "lastServerVersion": 0, + "snapshot": { + "payload": "clipboard text", + "encrypted": true, + "hash": "sha256-hex", + "localModifiedAtUtc": "2026-08-18T10:00:00+02:00" + } +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/pong.clienttimeutc.no-z.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/pong.clienttimeutc.no-z.json new file mode 100644 index 0000000..a1111f6 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/pong.clienttimeutc.no-z.json @@ -0,0 +1,4 @@ +{ + "type": "pong", + "clientTimeUtc": "2026-08-18T08:02:00" +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/number/pong.clienttimeutc.number.json b/TextCascade.Server.Tests/ContractSamples/invalid/number/pong.clienttimeutc.number.json new file mode 100644 index 0000000..52b10bf --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/number/pong.clienttimeutc.number.json @@ -0,0 +1,4 @@ +{ + "type": "pong", + "clientTimeUtc": 1760000000 +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/clip.version.json b/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/clip.version.json new file mode 100644 index 0000000..0a74774 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/clip.version.json @@ -0,0 +1,8 @@ +{ + "type": "clip", + "id": "clip-1", + "payload": "text", + "encrypted": false, + "hash": "sha256-hex", + "version": 12 +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/hello.extra.json b/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/hello.extra.json new file mode 100644 index 0000000..a56eac4 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/hello.extra.json @@ -0,0 +1,7 @@ +{ + "type": "hello", + "clientId": "windows-a", + "clientName": "", + "lastServerVersion": 0, + "extra": "not-allowed" +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/pong.extra.json b/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/pong.extra.json new file mode 100644 index 0000000..be5a269 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/unknown-field/pong.extra.json @@ -0,0 +1,5 @@ +{ + "type": "pong", + "clientTimeUtc": "2026-08-18T08:02:00Z", + "extra": true +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/utf8/clip.payload-invalid-bytes.bin b/TextCascade.Server.Tests/ContractSamples/invalid/utf8/clip.payload-invalid-bytes.bin new file mode 100644 index 0000000..cf9be42 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/utf8/clip.payload-invalid-bytes.bin @@ -0,0 +1 @@ +{"type":"clip","id":"clip-1","payload":"badÿþutf8","encrypted":false,"hash":"h"} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/utf8/hello.clientid-lone-surrogate.bin b/TextCascade.Server.Tests/ContractSamples/invalid/utf8/hello.clientid-lone-surrogate.bin new file mode 100644 index 0000000..5d9971d Binary files /dev/null and b/TextCascade.Server.Tests/ContractSamples/invalid/utf8/hello.clientid-lone-surrogate.bin differ diff --git a/TextCascade.Server.Tests/ContractSamples/invalid/utf8/pong.extra-invalid-bytes.bin b/TextCascade.Server.Tests/ContractSamples/invalid/utf8/pong.extra-invalid-bytes.bin new file mode 100644 index 0000000..0626fba --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/invalid/utf8/pong.extra-invalid-bytes.bin @@ -0,0 +1 @@ +{"type":"pong","clientTimeUtc":"2026-08-18T08:02:00Z","x":"Ã("} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/valid/clip.basic.json b/TextCascade.Server.Tests/ContractSamples/valid/clip.basic.json new file mode 100644 index 0000000..074dc88 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/valid/clip.basic.json @@ -0,0 +1,7 @@ +{ + "type": "clip", + "id": "clip-20260818-001", + "payload": "shared clipboard content", + "encrypted": false, + "hash": "sha256-hex" +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/valid/hello.full.json b/TextCascade.Server.Tests/ContractSamples/valid/hello.full.json new file mode 100644 index 0000000..db0c4b1 --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/valid/hello.full.json @@ -0,0 +1,12 @@ +{ + "type": "hello", + "clientId": "windows-a", + "clientName": "Windows Desktop", + "lastServerVersion": 128, + "snapshot": { + "payload": "clipboard text", + "encrypted": true, + "hash": "sha256-hex", + "localModifiedAtUtc": "2026-08-18T08:00:00Z" + } +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/valid/hello.minimal.json b/TextCascade.Server.Tests/ContractSamples/valid/hello.minimal.json new file mode 100644 index 0000000..41ac0ab --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/valid/hello.minimal.json @@ -0,0 +1,6 @@ +{ + "type": "hello", + "clientId": "android-b", + "clientName": "", + "lastServerVersion": 0 +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/valid/hello.null-snapshot.json b/TextCascade.Server.Tests/ContractSamples/valid/hello.null-snapshot.json new file mode 100644 index 0000000..6e9bdea --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/valid/hello.null-snapshot.json @@ -0,0 +1,7 @@ +{ + "type": "hello", + "clientId": "android-b", + "clientName": "Phone", + "lastServerVersion": 0, + "snapshot": null +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/valid/hello.snapshot-roundtrip-timestamp.json b/TextCascade.Server.Tests/ContractSamples/valid/hello.snapshot-roundtrip-timestamp.json new file mode 100644 index 0000000..07cb06e --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/valid/hello.snapshot-roundtrip-timestamp.json @@ -0,0 +1,12 @@ +{ + "type": "hello", + "clientId": "windows-a", + "clientName": "Windows Desktop", + "lastServerVersion": 128, + "snapshot": { + "payload": "clipboard text", + "encrypted": true, + "hash": "sha256-hex", + "localModifiedAtUtc": "2026-08-18T08:00:00.1234567Z" + } +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractSamples/valid/pong.ok.json b/TextCascade.Server.Tests/ContractSamples/valid/pong.ok.json new file mode 100644 index 0000000..4f399cd --- /dev/null +++ b/TextCascade.Server.Tests/ContractSamples/valid/pong.ok.json @@ -0,0 +1,4 @@ +{ + "type": "pong", + "clientTimeUtc": "2026-08-18T08:02:00Z" +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractTests/ContractSampleTests.cs b/TextCascade.Server.Tests/ContractTests/ContractSampleTests.cs new file mode 100644 index 0000000..1c00cd9 --- /dev/null +++ b/TextCascade.Server.Tests/ContractTests/ContractSampleTests.cs @@ -0,0 +1,172 @@ +using System.Text; +using System.Text.Json; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class ContractSampleTests +{ + private static readonly string SamplesRoot = FindSamplesRoot(); + + public static IEnumerable InvalidSamples => + Directory.GetFiles(Path.Combine(SamplesRoot, "invalid"), "*.*", SearchOption.AllDirectories) + .Select(path => new object[] { path }); + + public static IEnumerable ValidSamples => + Directory.GetFiles(Path.Combine(SamplesRoot, "valid"), "*.json", SearchOption.AllDirectories) + .Select(path => new object[] { path }); + + [Theory] + [MemberData(nameof(InvalidSamples))] + public void AllInvalidSamples_AreRejected_WithExpectedCode(string path) + { + var frame = File.ReadAllBytes(path); + var config = TextCascade.Server.Config.CreateDefaultConfig(); + + // Non-UTF8 samples may also throw while transcoding a string field; either outcome is a rejection. + try + { + var result = Protocol.ParseClientMessage(frame, config); + Assert.NotNull(result.Error); + Assert.Equal(ExpectedCode(path), result.Error!.CodeName); + } + catch (Exception exception) when (exception is DecoderFallbackException or InvalidOperationException) + { + // Transcoding failure is itself the documented rejection path for invalid UTF-8. + } + } + + [Theory] + [MemberData(nameof(ValidSamples))] + public void AllValidSamples_Parse_WithExpectedKind(string path) + { + var frame = File.ReadAllBytes(path); + var config = TextCascade.Server.Config.CreateDefaultConfig(); + + var result = Protocol.ParseClientMessage(frame, config); + + Assert.True(result.Error is null, $"Sample should parse: {path} error={result.Error?.Message}"); + var expectedKind = Path.GetFileName(path) switch + { + string n when n.StartsWith("hello.", StringComparison.Ordinal) => MessageKind.Hello, + string n when n.StartsWith("clip.", StringComparison.Ordinal) => MessageKind.Clip, + string n when n.StartsWith("pong.", StringComparison.Ordinal) => MessageKind.Pong, + _ => throw new InvalidOperationException($"Cannot infer kind from {path}"), + }; + Assert.Equal(expectedKind, result.Kind); + } + + [Fact] + public void ValidHello_Full_ParsesAllFields() + { + var frame = File.ReadAllBytes(Path.Combine(SamplesRoot, "valid", "hello.full.json")); + var result = Protocol.ParseClientMessage(frame, TextCascade.Server.Config.CreateDefaultConfig()); + + Assert.True(result.Error is null); + var hello = Assert.IsType(result.Message); + Assert.Equal("windows-a", hello.ClientId); + Assert.Equal("Windows Desktop", hello.ClientName); + Assert.Equal(128UL, hello.LastServerVersion); + + var snapshot = hello.Snapshot; + Assert.NotNull(snapshot); + Assert.Equal("clipboard text", snapshot!.Payload); + Assert.True(snapshot.Encrypted); + Assert.Equal("sha256-hex", snapshot.Hash); + Assert.Equal(new DateTimeOffset(2026, 8, 18, 8, 0, 0, TimeSpan.Zero), snapshot.LocalModifiedAtUtc); + } + + [Fact] + public void ValidHello_Minimal_HasNoSnapshot() + { + var frame = File.ReadAllBytes(Path.Combine(SamplesRoot, "valid", "hello.minimal.json")); + var result = Protocol.ParseClientMessage(frame, TextCascade.Server.Config.CreateDefaultConfig()); + + Assert.True(result.Error is null, result.Error?.Message); + var hello = Assert.IsType(result.Message); + Assert.Null(hello.Snapshot); + Assert.Equal(0UL, hello.LastServerVersion); + Assert.Equal(string.Empty, hello.ClientName); + } + + [Fact] + public void ValidHello_NullSnapshot_IsExplicitNull() + { + var frame = File.ReadAllBytes(Path.Combine(SamplesRoot, "valid", "hello.null-snapshot.json")); + var result = Protocol.ParseClientMessage(frame, TextCascade.Server.Config.CreateDefaultConfig()); + + Assert.True(result.Error is null, result.Error?.Message); + var hello = Assert.IsType(result.Message); + Assert.Null(hello.Snapshot); + } + + [Fact] + public void ValidClip_ParsesAllFields() + { + var frame = File.ReadAllBytes(Path.Combine(SamplesRoot, "valid", "clip.basic.json")); + var result = Protocol.ParseClientMessage(frame, TextCascade.Server.Config.CreateDefaultConfig()); + + var clip = Assert.IsType(result.Message); + Assert.Equal("clip-20260818-001", clip.Id); + Assert.Equal("shared clipboard content", clip.Payload); + Assert.False(clip.Encrypted); + Assert.Equal("sha256-hex", clip.Hash); + } + + [Fact] + public void ValidPong_ParsesTimestamp() + { + var frame = File.ReadAllBytes(Path.Combine(SamplesRoot, "valid", "pong.ok.json")); + var result = Protocol.ParseClientMessage(frame, TextCascade.Server.Config.CreateDefaultConfig()); + + var pong = Assert.IsType(result.Message); + Assert.Equal(new DateTimeOffset(2026, 8, 18, 8, 2, 0, TimeSpan.Zero), pong.ClientTimeUtc); + } + + [Fact] + public void ValidHello_RoundTripTimestampFormat_IsAccepted() + { + var frame = File.ReadAllBytes(Path.Combine(SamplesRoot, "valid", "hello.snapshot-roundtrip-timestamp.json")); + var result = Protocol.ParseClientMessage(frame, TextCascade.Server.Config.CreateDefaultConfig()); + + Assert.True(result.Error is null, result.Error?.Message); + var hello = Assert.IsType(result.Message); + Assert.Equal(TimeSpan.Zero, hello.Snapshot!.LocalModifiedAtUtc.Offset); + } + + private static string ExpectedCode(string path) + { + var relative = Path.GetRelativePath(Path.Combine(SamplesRoot, "invalid"), path); + var category = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)[0]; + return category switch + { + "frame_too_large" => "frame_too_large", + _ => "invalid_message", + }; + } + + private static string FindSamplesRoot() + { + // AppContext.BaseDirectory works for dotnet test with CopyToOutputDirectory; the + // upward walk covers runs where content files were not copied. + var candidate = Path.Combine(AppContext.BaseDirectory, "ContractSamples"); + if (Directory.Exists(candidate)) + { + return candidate; + } + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + candidate = Path.Combine(directory.FullName, "ContractSamples"); + if (Directory.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent!; + } + + throw new InvalidOperationException("ContractSamples directory not found."); + } +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ContractTests/ContractSchemaInvariants.cs b/TextCascade.Server.Tests/ContractTests/ContractSchemaInvariants.cs new file mode 100644 index 0000000..b0aa308 --- /dev/null +++ b/TextCascade.Server.Tests/ContractTests/ContractSchemaInvariants.cs @@ -0,0 +1,113 @@ +using System.Text; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class ContractSchemaInvariants +{ + [Fact] + public void C1_Welcome_NoLatest_OmitsKey() + { + var bytes = Protocol.SerializeWelcome(null, TextCascade.Server.Config.CreateDefaultConfig().Limits); + var json = Encoding.UTF8.GetString(bytes); + + Assert.Equal("""{"type":"welcome","protocolVersion":1}""", json); + Assert.DoesNotContain("latest", json, StringComparison.Ordinal); + } + + [Fact] + public void C2_Welcome_WithLatest_FixedFieldOrder() + { + var latest = new LatestText( + "payload-text", 128, "hash", true, "android-a", "android", + new DateTimeOffset(2026, 8, 18, 7, 59, 58, TimeSpan.Zero)); + + var bytes = Protocol.SerializeWelcome(latest, TextCascade.Server.Config.CreateDefaultConfig().Limits); + var json = Encoding.UTF8.GetString(bytes); + + Assert.Equal( + """{"type":"welcome","protocolVersion":1,"latest":{"payload":"payload-text","version":128,"hash":"hash","encrypted":true,"fromClientId":"android-a","fromClientName":"android","updatedAtUtc":"2026-08-18T07:59:58Z"}}""", + json); + } + + [Fact] + public void C3_BroadcastClip_ContainsAllEightFields() + { + var latest = new LatestText( + "payload-text", 129, "hash", false, "windows-a", "Windows Desktop", + new DateTimeOffset(2026, 8, 18, 8, 1, 0, TimeSpan.Zero)); + + var bytes = Protocol.SerializeClip("clip-id-1", latest); + var json = Encoding.UTF8.GetString(bytes); + + Assert.Equal( + """{"type":"clip","version":129,"id":"clip-id-1","payload":"payload-text","encrypted":false,"hash":"hash","fromClientId":"windows-a","fromClientName":"Windows Desktop","updatedAtUtc":"2026-08-18T08:01:00Z"}""", + json); + } + + [Fact] + public void C4_TokenPayload_MinimalFixedOrder() + { + var payload = new TokenPayload("alice", 1, 1760000000, 1762592000); + var compact = TokenService.SignToken(payload, new byte[32]); + var payloadSegment = compact.Split('.')[0]; + var payloadJson = Encoding.UTF8.GetString(Base64UrlDecode(payloadSegment)); + + Assert.Equal("""{"sub":"alice","ver":1,"iat":1760000000,"exp":1762592000}""", payloadJson); + } + + [Fact] + public void C5_ErrorResponse_IncludesReferenceId_WhenNotNull() + { + var withReference = Encoding.UTF8.GetString(Protocol.SerializeProtocolError( + new ProtocolError(ProtocolErrorCode.TextTooLarge, "Text exceeds maxTextBytes.", "clip-1"))); + Assert.Equal( + """{"type":"error","code":"text_too_large","message":"Text exceeds maxTextBytes.","referenceId":"clip-1"}""", + withReference); + + var withoutReference = Encoding.UTF8.GetString(Protocol.SerializeProtocolError( + new ProtocolError(ProtocolErrorCode.InvalidMessage, "Invalid JSON.", null))); + Assert.Equal( + """{"type":"error","code":"invalid_message","message":"Invalid JSON."}""", + withoutReference); + } + + [Fact] + public void C6_Ping_Timestamps_UtcZ_SecondPrecision() + { + var now = new DateTimeOffset(2026, 8, 18, 8, 2, 0, TimeSpan.Zero).AddMilliseconds(456); + var json = Encoding.UTF8.GetString(Protocol.SerializePing(now)); + + Assert.Equal("""{"type":"ping","serverTimeUtc":"2026-08-18T08:02:00Z"}""", json); + } + + [Fact] + public void ClipAck_Shape_MatchesContract() + { + var latest = new LatestText( + "payload", 129, "hash", false, "windows-a", "Windows Desktop", + new DateTimeOffset(2026, 8, 18, 8, 1, 0, TimeSpan.Zero)); + + var json = Encoding.UTF8.GetString(Protocol.SerializeClipAck("clip-id-1", latest)); + + Assert.Equal( + """{"type":"clip_ack","id":"clip-id-1","version":129,"updatedAtUtc":"2026-08-18T08:01:00Z"}""", + json); + } + + [Fact] + public void Bye_Shape_MatchesContract() + { + var json = Encoding.UTF8.GetString(Protocol.SerializeBye("server_shutdown")); + + Assert.Equal("""{"type":"bye","reason":"server_shutdown"}""", json); + } + + private static byte[] Base64UrlDecode(string segment) + { + var padded = segment.Replace('-', '+').Replace('_', '/'); + var remainder = padded.Length % 4; + if (remainder > 0) { padded += new string('=', 4 - remainder); } + return Convert.FromBase64String(padded); + } +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/IdempotencyBehaviorTests.cs b/TextCascade.Server.Tests/IdempotencyBehaviorTests.cs new file mode 100644 index 0000000..1d42185 --- /dev/null +++ b/TextCascade.Server.Tests/IdempotencyBehaviorTests.cs @@ -0,0 +1,203 @@ +using System.Net.WebSockets; +using System.Text.Json; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class IdempotencyBehaviorTests +{ + private sealed class RecordingCoordinator : IConnectionCoordinator + { + public List Warnings { get; } = new(); + private readonly CollectingLogger logger; + + public RecordingCoordinator() + { + logger = new CollectingLogger(Warnings); + } + + public ILogger Logger => logger; + + public void CancelConnection(ConnectionContext connection, string reason) { } + + public void RebuildHub(UserHub hub) { } + + public void RemoveEmptyHubAfterRecovery(UserHub hub) { } + + private sealed class CollectingLogger : ILogger + { + private readonly List warnings; + + public CollectingLogger(List warnings) + { + this.warnings = warnings; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var entry = formatter(state, exception); + if (logLevel == LogLevel.Warning) + { + lock (warnings) { warnings.Add(entry); } + } + } + } + } + + private static (UserHub Hub, RecordingCoordinator Coordinator) NewHub(ulong initialVersion = 0) + { + var config = TextCascade.Server.Config.CreateDefaultConfig(); + var coordinator = new RecordingCoordinator(); + var statePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json"); + var stateStore = new RuntimeStateStore(statePath, TimeSpan.Zero); + var hub = new UserHub("alice", config, DateTimeOffset.FromUnixTimeSeconds(1760000000), coordinator, stateStore, initialVersion); + return (hub, coordinator); + } + + private static ConnectionContext NewConnection(string clientId = "client-A") + { + var socket = new ClientWebSocket(); + var config = TextCascade.Server.Config.CreateDefaultConfig(); + return new ConnectionContext($"conn-{Guid.NewGuid():N}", "alice", clientId, clientId, socket, null, config); + } + + private static ClientClip Clip(string id, string payload, string hash = "h", bool encrypted = false) => + new(id, payload, encrypted, hash); + + private static (bool Acked, byte[]? Payload) DequeueAck(ConnectionContext connection) + { + while (connection.State.SendQueue.Reader.TryRead(out var payload)) + { + var text = Encoding.UTF8.GetString(payload); + if (text.Contains("clip_ack", StringComparison.Ordinal)) + { + return (true, payload); + } + + if (text.Contains("rate_limited", StringComparison.Ordinal)) + { + return (false, payload); + } + } + + return (false, null); + } + + private static void DrainQueue(ConnectionContext connection) + { + while (connection.State.SendQueue.Reader.TryRead(out _)) { } + } + + // U22 — behavior level: draining the bucket does not block duplicate-id resends. + [Fact] + public void DuplicateId_AfterBucketDrained_StillAcked() + { + var (hub, coordinator) = NewHub(); + var sender = NewConnection(); + + var now = DateTimeOffset.FromUnixTimeSeconds(1760000000); + + // Drain the burst (default 10) with distinct clips; time does not advance so refill stays 0. + for (var index = 0; index < 10; index++) + { + hub.ApplyClip(Clip($"id-{index}", $"payload-{index}"), sender, now); + } + DrainQueue(sender); + + // 11th distinct clip must be rate limited. + hub.ApplyClip(Clip("id-overflow", "payload-overflow"), sender, now); + Assert.False(DequeueAck(sender).Acked, "11th distinct clip should be rate limited"); + + // A duplicate of the very first clip must still be acked without consuming a token. + hub.ApplyClip(Clip("id-0", "payload-0"), sender, now); + var duplicate = DequeueAck(sender); + Assert.True(duplicate.Acked, "duplicate id should bypass the token bucket"); + + using var ack = JsonDocument.Parse(duplicate.Payload!); + // The duplicate ack replays the version id-0 originally received (the 1st distinct clip), and + // the hub version must not have advanced past the drained burst. + Assert.Equal(1UL, ack.RootElement.GetProperty("version").GetUInt64()); + Assert.Equal(10UL, hub.Version); + } + + // U23 + [Fact] + public void DuplicateId_NewContent_IsTreatedAsFreshMessage() + { + var (hub, coordinator) = NewHub(); + var sender = NewConnection(); + var now = DateTimeOffset.FromUnixTimeSeconds(1760000000); + + hub.ApplyClip(Clip("same-id", "first"), sender, now); + Assert.Equal(1UL, hub.Version); + DrainQueue(sender); + + coordinator.Warnings.Clear(); + hub.ApplyClip(Clip("same-id", "second", "h2"), sender, now); + + // Fresh message: consumes a token, generates a new version, logs the reuse warning. + var ack = DequeueAck(sender); + Assert.True(ack.Acked, "fresh-clip path should succeed while tokens remain"); + Assert.Equal(2UL, hub.Version); + Assert.Contains(coordinator.Warnings, entry => entry.Contains("Replacing reused clip id", StringComparison.Ordinal)); + } + + // U24 — documented dead-branch behavior of the ring itself. + [Fact] + public void IsUnchangedDuplicate_ForUnknownId_ReturnsFalse() + { + var ring = new SeenIdRing(4); + Assert.False(ring.IsUnchangedDuplicate("missing", "payload", "hash", false, out var latest)); + Assert.Null(latest); + } + + // U25 — bucket edge cases beyond the existing refill test. + [Fact] + public void TryAcquire_BoundaryCases() + { + var now = DateTimeOffset.FromUnixTimeSeconds(1760000000); + + // Burst boundary: burst acquisitions pass, the next one at the same instant fails. + var bucket = new TokenBucket(3, 2.0, now); + Assert.True(bucket.TryAcquire(now)); + Assert.True(bucket.TryAcquire(now)); + Assert.True(bucket.TryAcquire(now)); + Assert.False(bucket.TryAcquire(now)); + + // Half-second refill (2 tokens/sec) grants one token. + Assert.True(bucket.TryAcquire(now.AddMilliseconds(500))); + + // Clock moving backwards is rejected outright. + Assert.False(bucket.TryAcquire(now)); + } + + [Fact] + public void DuplicateId_RateLimitedError_CarriesReferenceId() + { + var (hub, _) = NewHub(); + var sender = NewConnection(); + var now = DateTimeOffset.FromUnixTimeSeconds(1760000000); + + for (var index = 0; index < 10; index++) + { + hub.ApplyClip(Clip($"id-{index}", $"payload-{index}"), sender, now); + } + DrainQueue(sender); + + hub.ApplyClip(Clip("id-overflow", "payload-overflow"), sender, now); + var (acked, payload) = DequeueAck(sender); + Assert.False(acked); + Assert.NotNull(payload); + + using var error = JsonDocument.Parse(payload!); + Assert.Equal("rate_limited", error.RootElement.GetProperty("code").GetString()); + Assert.Equal("id-overflow", error.RootElement.GetProperty("referenceId").GetString()); + } +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/LoginLimiterTests.cs b/TextCascade.Server.Tests/LoginLimiterTests.cs index 4e1f818..c9ccfd8 100644 --- a/TextCascade.Server.Tests/LoginLimiterTests.cs +++ b/TextCascade.Server.Tests/LoginLimiterTests.cs @@ -71,4 +71,75 @@ public void ExpiredEntriesAreLazilyRemoved() Assert.False(limiter.TryConsumeLoginLimit("1.1.1.1", "alice", now, config)); Assert.True(limiter.TryConsumeLoginLimit("1.1.1.1", "alice", later, config)); } + + [Fact] + public void RemoveExpiredKeepsUnexpiredRetryAfterOlderEntry() + { + var limiter = new SlidingWindowLoginLimiter(); + var t0 = DateTimeOffset.FromUnixTimeSeconds(1760000000); + var config = TextCascade.Server.Config.CreateDefaultConfig() with + { + RateLimit = new RateLimitConfig(3, 3, 10, 10, 2), + }; + + // Consume twice at t0 + Assert.True(limiter.TryConsumeLoginLimit("1.1.1.1", "alice", t0, config)); + Assert.True(limiter.TryConsumeLoginLimit("1.1.1.1", "alice", t0, config)); + + // Consume once at t0 + 70s (this purges entries <= t0 + 10s, but window here is at t0+70s, cutoff t0+10s) + var t70 = t0.AddSeconds(70); + Assert.True(limiter.TryConsumeLoginLimit("1.1.1.1", "alice", t70, config)); + + // In a non-monotonic test or another check: at t0 + 65s, let's test specific cutoff + // Let's test by direct consumption with another limiter to follow spec exactly: + var limiter2 = new SlidingWindowLoginLimiter(); + // limit=3; 同一 IP 在 t0 消费两次,t0+70s 消费一次。 + Assert.True(limiter2.TryConsumeLoginLimit("1.1.1.1", "alice", t0, config)); + Assert.True(limiter2.TryConsumeLoginLimit("1.1.1.1", "alice", t0, config)); + // Enqueue at t0+70s + Assert.True(limiter2.TryConsumeLoginLimit("1.1.1.1", "alice", t0.AddSeconds(70), config)); + // At t0+70s, the 2 t0 records expired, only 1 record (at 70s) remains. + // So we can consume 2 more times at t0+70s: + Assert.True(limiter2.TryConsumeLoginLimit("1.1.1.1", "alice", t0.AddSeconds(70), config)); + Assert.True(limiter2.TryConsumeLoginLimit("1.1.1.1", "alice", t0.AddSeconds(70), config)); + // Now 3 records at t0+70s exist -> next should fail + Assert.False(limiter2.TryConsumeLoginLimit("1.1.1.1", "alice", t0.AddSeconds(70), config)); + } + + [Fact] + public void RemoveExpiredDeletesKeyOnlyWhenQueueIsEmpty() + { + var limiter = new SlidingWindowLoginLimiter(); + var t0 = DateTimeOffset.FromUnixTimeSeconds(1760000000); + + limiter.EnqueueForTest("ip:1.1.1.1", t0); + limiter.EnqueueForTest("ip:1.1.1.1", t0.AddSeconds(40)); + + // Cutoff at t0 + 10s: first item expired, second item unexpired + limiter.RemoveExpiredForTest(t0.AddSeconds(10)); + Assert.True(limiter.HasWindowKey("ip:1.1.1.1")); + Assert.Equal(1, limiter.GetWindowCount("ip:1.1.1.1")); + + // Cutoff at t0 + 50s: all expired + limiter.RemoveExpiredForTest(t0.AddSeconds(50)); + Assert.False(limiter.HasWindowKey("ip:1.1.1.1")); + } + + [Fact] + public void LoginLimitsMustBePositive() + { + var baseConfig = TextCascade.Server.Config.CreateDefaultConfig() with { TokenSecret = new byte[32] }; + + var configZeroIp = baseConfig with + { + RateLimit = baseConfig.RateLimit with { LoginIpPerMinute = 0 }, + }; + Assert.Throws(() => TextCascade.Server.Config.ValidateConfig(configZeroIp)); + + var configZeroUser = baseConfig with + { + RateLimit = baseConfig.RateLimit with { LoginUserPerMinute = 0 }, + }; + Assert.Throws(() => TextCascade.Server.Config.ValidateConfig(configZeroUser)); + } } diff --git a/TextCascade.Server.Tests/NetworkIntegration/FrameFragmentationTests.cs b/TextCascade.Server.Tests/NetworkIntegration/FrameFragmentationTests.cs new file mode 100644 index 0000000..b40e620 --- /dev/null +++ b/TextCascade.Server.Tests/NetworkIntegration/FrameFragmentationTests.cs @@ -0,0 +1,255 @@ +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using TextCascade.Server; + +namespace TextCascade.Server.Tests.NetworkIntegration; + +[Trait("Category", "NetworkIntegration")] +public class FrameFragmentationTests +{ + private static async Task ConnectAndHelloAsync(string wssUrl, string token, string clientId) + { + var ws = new ClientWebSocket(); + ws.Options.AddSubProtocol("textcascade.v1"); + ws.Options.SetRequestHeader("Authorization", $"Bearer {token}"); + ws.Options.RemoteCertificateValidationCallback = (_, _, _, _) => true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await ws.ConnectAsync(new Uri(wssUrl), cts.Token); + + var hello = JsonSerializer.SerializeToUtf8Bytes(new + { + type = "hello", + clientId, + clientName = clientId, + lastServerVersion = 0, + snapshot = (object?)null, + }); + await ws.SendAsync(hello, WebSocketMessageType.Text, true, cts.Token); + await ReceiveOneAsync(ws); // welcome + return ws; + } + + private static async Task SendFragmentedAsync(ClientWebSocket ws, byte[][] fragments) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + for (var index = 0; index < fragments.Length; index++) + { + var endOfMessage = index == fragments.Length - 1; + await ws.SendAsync(fragments[index], WebSocketMessageType.Text, endOfMessage, cts.Token); + } + } + + private static async Task ReceiveOneAsync(ClientWebSocket ws) + { + var buffer = new byte[1024 * 1024]; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var result = await ws.ReceiveAsync(new ArraySegment(buffer), cts.Token); + if (result.MessageType == WebSocketMessageType.Close) + { + return []; + } + + return buffer.AsMemory(0, result.Count).ToArray(); + } + + private static async Task<(string Type, byte[] Payload)> ReceiveMessageAsync(ClientWebSocket ws) + { + // Frames may arrive fragmented from the server as well; loop until a full message. + using var message = new MemoryStream(); + var buffer = new byte[64 * 1024]; + while (true) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var result = await ws.ReceiveAsync(new ArraySegment(buffer), cts.Token); + if (result.MessageType == WebSocketMessageType.Close) + { + return ("close", []); + } + + message.Write(buffer, 0, result.Count); + if (result.EndOfMessage) + { + var payload = message.ToArray(); + return (JsonDocument.Parse(payload).RootElement.GetProperty("type").GetString()!, payload); + } + } + } + + private static async Task CloseAsync(ClientWebSocket ws) + { + if (ws.State == WebSocketState.Open) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + await ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "done", cts.Token); + } + catch { } + } + + ws.Dispose(); + } + + // N6 + [Fact] + public async Task FragmentedClip_Reassembles_AndBroadcasts() + { + await using var fixture = NetworkTestFixture.Create(); + var server = await fixture.StartAsync(); + try + { + var (tokenA, tokenB) = await LoginTwoAsync(server.Authority); + var wsA = await ConnectAndHelloAsync(server.WssUrl, tokenA, "frag-A"); + var wsB = await ConnectAndHelloAsync(server.WssUrl, tokenB, "frag-B"); + try + { + // ~300KB payload split into three fragments below single-MSS scale boundaries. + var payload = new string('x', 300_000); + var clip = JsonSerializer.SerializeToUtf8Bytes(new + { + type = "clip", + id = "frag-clip-1", + payload, + encrypted = false, + hash = "frag-hash", + }); + var fragments = new[] + { + clip.AsMemory(0, 100_000).ToArray(), + clip.AsMemory(100_000, 100_000).ToArray(), + clip.AsMemory(200_000).ToArray(), + }; + + await SendFragmentedAsync(wsA, fragments); + + var ack = await ReceiveMessageAsync(wsA); + Assert.Equal("clip_ack", ack.Type); + + var broadcast = await ReceiveMessageAsync(wsB); + Assert.Equal("clip", broadcast.Type); + using var doc = JsonDocument.Parse(broadcast.Payload); + Assert.Equal(payload, doc.RootElement.GetProperty("payload").GetString()); + } + finally + { + await CloseAsync(wsA); + await CloseAsync(wsB); + } + } + finally + { + await server.StopAsync(); + } + } + + // N7 + [Fact] + public async Task OversizeFrame_Closes1009() + { + await using var fixture = NetworkTestFixture.Create(); + var server = await fixture.StartAsync(); + try + { + var token = await LoginAsync(server.Authority); + var ws = await ConnectAndHelloAsync(server.WssUrl, token, "oversize-A"); + try + { + // Total frame exceeds max_frame_bytes (589824) via three fragments. + var oversize = new string('y', 600_000); + var clip = JsonSerializer.SerializeToUtf8Bytes(new + { + type = "clip", + id = "oversize-1", + payload = oversize, + encrypted = false, + hash = "h", + }); + await SendFragmentedAsync(ws, [clip.AsMemory(0, 300_000).ToArray(), clip.AsMemory(300_000).ToArray()]); + + // Server sends the frame_too_large error then closes with 1009 (MessageTooBig). + var closeSeen = false; + for (var attempt = 0; attempt < 2 && !closeSeen; attempt++) + { + var buffer = new byte[64 * 1024]; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var result = await ws.ReceiveAsync(new ArraySegment(buffer), cts.Token); + if (result.MessageType == WebSocketMessageType.Close) + { + closeSeen = true; + Assert.Equal(WebSocketCloseStatus.MessageTooBig, result.CloseStatus); + } + } + + Assert.True(closeSeen, "server should close the connection with 1009 after an oversize frame"); + } + finally + { + ws.Dispose(); + } + } + finally + { + await server.StopAsync(); + } + } + + // N8 + [Fact] + public async Task ZeroLengthFrame_TreatedAsFrameTooLarge() + { + await using var fixture = NetworkTestFixture.Create(); + var server = await fixture.StartAsync(); + try + { + var token = await LoginAsync(server.Authority); + var ws = await ConnectAndHelloAsync(server.WssUrl, token, "zerolen-A"); + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await ws.SendAsync(Array.Empty(), WebSocketMessageType.Text, endOfMessage: true, cts.Token); + + var closeSeen = false; + for (var attempt = 0; attempt < 2 && !closeSeen; attempt++) + { + var buffer = new byte[64 * 1024]; + var result = await ws.ReceiveAsync(new ArraySegment(buffer), cts.Token); + if (result.MessageType == WebSocketMessageType.Close) + { + closeSeen = true; + // Current implementation classifies empty frames as frame_too_large -> 1009. + Assert.Equal(WebSocketCloseStatus.MessageTooBig, result.CloseStatus); + } + } + + Assert.True(closeSeen, "zero-length frame should terminate the connection"); + } + finally + { + ws.Dispose(); + } + } + finally + { + await server.StopAsync(); + } + } + + private static async Task LoginAsync(string authority) + { + using var https = NewHttpsClient(); + var response = await https.PostAsJsonAsync($"https://{authority}/api/v1/login", new { username = "alice", password = "password123" }); + response.EnsureSuccessStatusCode(); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return doc.RootElement.GetProperty("token").GetString()!; + } + + private static async Task<(string, string)> LoginTwoAsync(string authority) => (await LoginAsync(authority), await LoginAsync(authority)); + + private static HttpClient NewHttpsClient() => new(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }); +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/NetworkIntegration/NetworkTestFixture.cs b/TextCascade.Server.Tests/NetworkIntegration/NetworkTestFixture.cs new file mode 100644 index 0000000..239f071 --- /dev/null +++ b/TextCascade.Server.Tests/NetworkIntegration/NetworkTestFixture.cs @@ -0,0 +1,189 @@ +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TextCascade.Server; + +namespace TextCascade.Server.Tests.NetworkIntegration; + +/// +/// Real-Kestrel TLS fixture for the NetworkIntegration category: self-signed certificate, +/// random port binding, and a restart-friendly handle over ServerHost.CreateApp. +/// +public sealed class NetworkTestFixture : IAsyncDisposable +{ + public string TempDir { get; } + public RuntimeConfig Config { get; } + public UsersFile Users { get; } + public string UsersPath { get; } + public string StatePath { get; } + public string PfxPath { get; } + public TestLogCollector Logs { get; } = new(); + + private NetworkTestFixture(string tempDir, RuntimeConfig config, UsersFile users, string usersPath, string statePath, string pfxPath) + { + TempDir = tempDir; + Config = config; + Users = users; + UsersPath = usersPath; + StatePath = statePath; + PfxPath = pfxPath; + } + + public static NetworkTestFixture Create( + Func? configModifier = null, + Action? usersOverride = null) + { + var tempDir = Path.Combine(Path.GetTempPath(), "textcascade-ni-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + + var certificate = SelfSignedCertificate.Create("localhost"); + var pfxPath = Path.Combine(tempDir, "server.pfx"); + File.WriteAllBytes(pfxPath, certificate.Export(X509ContentType.Pfx)); + + var usersPath = Path.Combine(tempDir, "users.json"); + var statePath = Path.Combine(tempDir, "state.json"); + var users = new UsersFile + { + Users = + [ + new UserRecord("alice", "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aG9zdA", 1), + ], + NextTokenVersion = 2, + }; + usersOverride?.Invoke(users); + UsersFile.SaveUsers(usersPath, users); + + var defaults = TextCascade.Server.Config.CreateDefaultConfig(); + var config = defaults with + { + TokenSecret = Encoding.UTF8.GetBytes("12345678901234567890123456789012"), + Files = new FilesConfig(usersPath, statePath), + Server = new ServerConfig("127.0.0.1", 0, pfxPath), + Limits = defaults.Limits, + }; + if (configModifier is not null) + { + config = configModifier(config); + } + + return new NetworkTestFixture(tempDir, config, users, usersPath, statePath, pfxPath); + } + + /// Starts a Kestrel instance bound to 127.0.0.1:0 over the self-signed PFX. + public async Task StartAsync() + { + using var loaded = CertificateLoader.Load(PfxPath); + // LoadedCertificate disposes the underlying cert; give CreateApp its own instance. + var app = ServerHost.CreateApp( + [], + Config, + Users, + new RuntimeStateStore(StatePath), + hasher: new FastPasswordHasher(), + clock: TimeProvider.System, + certificate: new LoadedCertificate( + new X509Certificate2(PfxPath), + new X509Certificate2Collection(new X509Certificate2(PfxPath)))); + + app.Services.GetRequiredService().AddProvider(Logs); + app.Urls.Add("https://127.0.0.1:0"); + await app.StartAsync(); + + var address = app.Urls.First(); + var uri = new Uri(address); + return new RunningServer(app, uri.Port, uri.Authority); + } + + public ValueTask DisposeAsync() + { + if (Directory.Exists(TempDir)) + { + try { Directory.Delete(TempDir, true); } catch { } + } + + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } + + public sealed class RunningServer + { + public WebApplication App { get; } + public int Port { get; } + public string Authority { get; } + + public RunningServer(WebApplication app, int port, string authority) + { + App = app; + Port = port; + Authority = authority; + } + + public string WssUrl => $"wss://{Authority}/api/v1/sync"; + + public async Task StopAsync() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try { await App.StopAsync(cts.Token); } catch { } + await App.DisposeAsync(); + } + } +} + +public sealed class TestLogCollector : ILoggerProvider, ILogger +{ + public List Entries { get; } = new(); + + public ILogger CreateLogger(string categoryName) => this; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (Entries) + { + Entries.Add(formatter(state, exception)); + } + } + + public void Dispose() { } +} + +public sealed class FastPasswordHasher : IPasswordHasher +{ + public const string ValidHash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aG9zdA"; + + public string Hash(string password, Isopoh.Cryptography.Argon2.Argon2Config config) => ValidHash; + + public bool Verify(string password, string encodedHash) => password == "password123" && encodedHash == ValidHash; + + public bool NeedsRehash(string encodedHash, Isopoh.Cryptography.Argon2.Argon2Config config) => false; +} + +/// Self-signed leaf certificate helpers (decision Q3: generated at test runtime). +public static class SelfSignedCertificate +{ + public static X509Certificate2 Create(string subject) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + $"CN={subject}", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + var san = new SubjectAlternativeNameBuilder(); + san.AddDnsName(subject); + san.AddIpAddress(IPAddress.Parse("127.0.0.1")); + request.CertificateExtensions.Add(san.Build()); + + return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(5)); + } +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/NetworkIntegration/RestartRecoveryTests.cs b/TextCascade.Server.Tests/NetworkIntegration/RestartRecoveryTests.cs new file mode 100644 index 0000000..913c715 --- /dev/null +++ b/TextCascade.Server.Tests/NetworkIntegration/RestartRecoveryTests.cs @@ -0,0 +1,343 @@ +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using TextCascade.Server; + +namespace TextCascade.Server.Tests.NetworkIntegration; + +[Trait("Category", "NetworkIntegration")] +public class RestartRecoveryTests +{ + private static HttpClient NewHttpsClient() => new(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }); + + private static async Task LoginAsync(string authority, string username = "alice") + { + using var https = NewHttpsClient(); + var response = await https.PostAsJsonAsync($"https://{authority}/api/v1/login", new { username, password = "password123" }); + response.EnsureSuccessStatusCode(); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return doc.RootElement.GetProperty("token").GetString()!; + } + + private static async Task ConnectAsync(string wssUrl, string token, string clientId, ulong lastServerVersion = 0, object? snapshot = null) + { + var ws = new ClientWebSocket(); + ws.Options.AddSubProtocol("textcascade.v1"); + ws.Options.SetRequestHeader("Authorization", $"Bearer {token}"); + ws.Options.RemoteCertificateValidationCallback = (_, _, _, _) => true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await ws.ConnectAsync(new Uri(wssUrl), cts.Token); + + var hello = JsonSerializer.SerializeToUtf8Bytes(new + { + type = "hello", + clientId, + clientName = clientId, + lastServerVersion, + snapshot, + }); + await ws.SendAsync(hello, WebSocketMessageType.Text, true, cts.Token); + return ws; + } + + private static async Task<(string Type, JsonDocument Doc)> ReceiveTypedAsync(ClientWebSocket ws) + { + var buffer = new byte[1024 * 1024]; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + WebSocketReceiveResult result; + try + { + result = await ws.ReceiveAsync(new ArraySegment(buffer), cts.Token); + } + catch (WebSocketException) + { + // Abrupt teardown (abort path) — surfaces as a close with no frame content. + return ("close", JsonDocument.Parse("{}")); + } + + if (result.MessageType == WebSocketMessageType.Close) + { + return ("close", JsonDocument.Parse("{}")); + } + + var payload = buffer.AsMemory(0, result.Count).ToArray(); + return (JsonDocument.Parse(payload).RootElement.GetProperty("type").GetString()!, JsonDocument.Parse(payload)); + } + + // N9 + [Fact] + public async Task Restart_KeepsTokenValid_DirectReconnect() + { + await using var fixture = NetworkTestFixture.Create( + configModifier: cfg => cfg with { Limits = cfg.Limits with { SnapshotWindowSeconds = 0 } }); + var first = await fixture.StartAsync(); + + // First instance: login and push one clip through to move the version to 1. + string token; + try + { + token = await LoginAsync(first.Authority); + var ws = await ConnectAsync(first.WssUrl, token, "restart-A"); + try + { + var welcome = await ReceiveTypedAsync(ws); + Assert.Equal("welcome", welcome.Type); + + var clip = JsonSerializer.SerializeToUtf8Bytes(new + { + type = "clip", + id = "pre-restart-1", + payload = "before restart", + encrypted = false, + hash = "h1", + }); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await ws.SendAsync(clip, WebSocketMessageType.Text, true, cts.Token); + var ack = await ReceiveTypedAsync(ws); + Assert.Equal("clip_ack", ack.Type); + Assert.Equal(1UL, ack.Doc.RootElement.GetProperty("version").GetUInt64()); + } + finally + { + ws.Dispose(); + } + } + finally + { + await first.StopAsync(); + } + + // State file must have been flushed on shutdown so the version persists. + Assert.True(File.Exists(fixture.StatePath), "state file should exist after graceful stop"); + + // Second instance: same users file + state file; old token works without re-login. + var second = await fixture.StartAsync(); + try + { + var ws = await ConnectAsync(second.WssUrl, token, "restart-B"); + try + { + var welcome = await ReceiveTypedAsync(ws); + Assert.Equal("welcome", welcome.Type); + + // Version baseline came from the persisted state: next clip continues from 2. + var clip = JsonSerializer.SerializeToUtf8Bytes(new + { + type = "clip", + id = "post-restart-1", + payload = "after restart", + encrypted = false, + hash = "h2", + }); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await ws.SendAsync(clip, WebSocketMessageType.Text, true, cts.Token); + var ack = await ReceiveTypedAsync(ws); + Assert.Equal("clip_ack", ack.Type); + Assert.Equal(2UL, ack.Doc.RootElement.GetProperty("version").GetUInt64()); + } + finally + { + ws.Dispose(); + } + } + finally + { + await second.StopAsync(); + } + } + + // N10 + [Fact] + public async Task Restart_SnapshotElection_RestoresLatest() + { + await using var fixture = NetworkTestFixture.Create( + configModifier: cfg => cfg with { Limits = cfg.Limits with { SnapshotWindowSeconds = 5 } }); + var first = await fixture.StartAsync(); + string token; + try + { + token = await LoginAsync(first.Authority); + } + finally + { + await first.StopAsync(); + } + + var second = await fixture.StartAsync(); + try + { + var modifiedTime = DateTimeOffset.UtcNow; + var snapshotOf = (ulong version, string payload) => (object)new + { + payload, + encrypted = false, + hash = $"hash-{version}", + localModifiedAtUtc = modifiedTime.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ"), + }; + + var ws128 = await ConnectAsync(second.WssUrl, token, "snap-128", 128, snapshotOf(128, "snapshot-v128")); + var ws64 = await ConnectAsync(second.WssUrl, token, "snap-64", 64, snapshotOf(64, "snapshot-v64")); + try + { + await Task.Delay(100); + + // Close the recovery window through the server to force the election now. + var syncServer = second.App.Services.GetRequiredService(); + var hub = syncServer.GetOrCreateHub("alice", fixture.Config); + hub.CloseRecoveryWindow(DateTimeOffset.UtcNow.AddMinutes(1)); + + var welcome128 = await ReceiveTypedAsync(ws128); + var welcome64 = await ReceiveTypedAsync(ws64); + foreach (var welcome in new[] { welcome128, welcome64 }) + { + Assert.Equal("welcome", welcome.Type); + Assert.Equal("snapshot-v128", welcome.Doc.RootElement.GetProperty("latest").GetProperty("payload").GetString()); + Assert.Equal(128UL, welcome.Doc.RootElement.GetProperty("latest").GetProperty("version").GetUInt64()); + } + + // The next clip continues from the restored version without +1 on restore. + var clip = JsonSerializer.SerializeToUtf8Bytes(new + { + type = "clip", + id = "post-election-1", + payload = "fresh clip", + encrypted = false, + hash = "h3", + }); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await ws64.SendAsync(clip, WebSocketMessageType.Text, true, cts.Token); + var ack = await ReceiveTypedAsync(ws64); + Assert.Equal("clip_ack", ack.Type); + Assert.Equal(129UL, ack.Doc.RootElement.GetProperty("version").GetUInt64()); + } + finally + { + ws128.Dispose(); + ws64.Dispose(); + } + } + finally + { + await second.StopAsync(); + } + } + + /// Reads from the socket until a bye frame or close/abort; never throws. + private static async Task<(string Type, string? Reason)> ReceiveByeOrCloseAsync(ClientWebSocket ws) + { + var buffer = new byte[1024 * 1024]; + while (true) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + WebSocketReceiveResult result; + try + { + result = await ws.ReceiveAsync(new ArraySegment(buffer), cts.Token); + } + catch (Exception exception) when (exception is WebSocketException or OperationCanceledException or IOException) + { + return ("close", null); + } + + if (result.MessageType == WebSocketMessageType.Close) + { + return ("close", result.CloseStatus?.ToString()); + } + + var doc = JsonDocument.Parse(buffer.AsMemory(0, result.Count).ToArray()); + var type = doc.RootElement.GetProperty("type").GetString(); + var reason = doc.RootElement.TryGetProperty("reason", out var reasonElement) ? reasonElement.GetString() : null; + doc.Dispose(); + if (type == "bye") + { + return ("bye", reason); + } + } + } + + // N11 + N12 combined chain: login -> connect -> clip flow -> graceful stop with bye/1001. + [Fact] + public async Task FullChain_Login_Connect_Send_Receive_Bye1001() + { + await using var fixture = NetworkTestFixture.Create( + configModifier: cfg => cfg with { Limits = cfg.Limits with { SnapshotWindowSeconds = 0 } }); + var server = await fixture.StartAsync(); + try + { + var tokenA = await LoginAsync(server.Authority); + var tokenB = await LoginAsync(server.Authority); + + var wsA = await ConnectAsync(server.WssUrl, tokenA, "chain-A"); + var wsB = await ConnectAsync(server.WssUrl, tokenB, "chain-B"); + try + { + var welcomeA = await ReceiveTypedAsync(wsA); + Assert.Equal("welcome", welcomeA.Type); + welcomeA.Doc.Dispose(); + var welcomeB = await ReceiveTypedAsync(wsB); + Assert.Equal("welcome", welcomeB.Type); + welcomeB.Doc.Dispose(); + + var clip = JsonSerializer.SerializeToUtf8Bytes(new + { + type = "clip", + id = "chain-clip-1", + payload = "chain payload", + encrypted = false, + hash = "hc", + }); + using var sendCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await wsA.SendAsync(clip, WebSocketMessageType.Text, true, sendCts.Token); + + var ack = await ReceiveTypedAsync(wsA); + Assert.Equal("clip_ack", ack.Type); + ack.Doc.Dispose(); + + // The two hellos racing the recovery-window close can deliver a second + // welcome to B; skip extras until the broadcast clip arrives. + JsonDocument broadcastDoc; + while (true) + { + var broadcast = await ReceiveTypedAsync(wsB); + broadcastDoc = broadcast.Doc; + if (broadcast.Type == "clip") + { + break; + } + Assert.Equal("welcome", broadcast.Type); + broadcastDoc.Dispose(); + } + + Assert.Equal("chain payload", broadcastDoc.RootElement.GetProperty("payload").GetString()); + broadcastDoc.Dispose(); + + // Graceful stop: bye then close 1001 (EndpointUnavailable). Both frames are + // best-effort by contract: a fast drain can abort the socket before they land. + var byeTask = ReceiveByeOrCloseAsync(wsB); + await server.StopAsync(); + + var (byeType, byeReason) = await byeTask; + if (byeType == "bye") + { + Assert.Equal("server_shutdown", byeReason); + } + // else: fast drain aborted before bye landed; both paths are contract-legal (spec §7). + } + finally + { + wsA.Dispose(); + wsB.Dispose(); + } + } + finally + { + // StopAsync already ran; ensure app disposal via fixture (fixture deletes temp dir). + } + } +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/NetworkIntegration/TlsAndWssHandshakeTests.cs b/TextCascade.Server.Tests/NetworkIntegration/TlsAndWssHandshakeTests.cs new file mode 100644 index 0000000..c4ee1ce --- /dev/null +++ b/TextCascade.Server.Tests/NetworkIntegration/TlsAndWssHandshakeTests.cs @@ -0,0 +1,214 @@ +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using TextCascade.Server; + +namespace TextCascade.Server.Tests.NetworkIntegration; + +[Trait("Category", "NetworkIntegration")] +public class TlsAndWssHandshakeTests +{ + private static HttpClient NewHttpsClient() + { + return new HttpClient(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }); + } + + // .NET 10 removed ClientWebSocketOptions.SslProtocols: the negotiated TLS version follows + // OS policy. Protocol-floor verification is done by direct SslStream probes (N2). + private static async Task ConnectWssAsync(string wssUrl, string token) + { + var ws = new ClientWebSocket(); + ws.Options.AddSubProtocol("textcascade.v1"); + ws.Options.SetRequestHeader("Authorization", $"Bearer {token}"); + ws.Options.RemoteCertificateValidationCallback = (_, _, _, _) => true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await ws.ConnectAsync(new Uri(wssUrl), cts.Token); + return ws; + } + + private static async Task SendHelloAsync(ClientWebSocket ws, string clientId) + { + var hello = JsonSerializer.SerializeToUtf8Bytes(new + { + type = "hello", + clientId, + clientName = clientId, + lastServerVersion = 0, + snapshot = (object?)null, + }); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await ws.SendAsync(hello, WebSocketMessageType.Text, true, cts.Token); + } + + private static async Task ReceiveJsonAsync(ClientWebSocket ws) + { + var buffer = new byte[64 * 1024]; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var result = await ws.ReceiveAsync(new ArraySegment(buffer), cts.Token); + Assert.Equal(WebSocketMessageType.Text, result.MessageType); + return JsonDocument.Parse(buffer.AsMemory(0, result.Count)); + } + + // N1 + [Fact] + public async Task Connects_WithSelfSignedPfx_OverWss() + { + await using var fixture = NetworkTestFixture.Create(); + var server = await fixture.StartAsync(); + try + { + using var https = NewHttpsClient(); + var token = await LoginAsync(https, server.Authority); + + var ws = await ConnectWssAsync(server.WssUrl, token); + try + { + Assert.Equal(WebSocketState.Open, ws.State); + await SendHelloAsync(ws, "tls-client-1"); + using var welcome = await ReceiveJsonAsync(ws); + Assert.Equal("welcome", welcome.RootElement.GetProperty("type").GetString()); + } + finally + { + ws.Dispose(); + } + } + finally + { + await server.StopAsync(); + } + } + + // N2 — the server accepts explicit TLS 1.2 and TLS 1.3 handshakes (SslStream probes; + // ClientWebSocket can no longer pin a version on .NET 10). + [Theory] + [InlineData(SslProtocols.Tls12)] + [InlineData(SslProtocols.Tls13)] + public async Task ServerHandshakes_WithExplicitTlsVersion(SslProtocols protocolVersion) + { + await using var fixture = NetworkTestFixture.Create(); + var server = await fixture.StartAsync(); + try + { + using var tcp = new TcpClient(); + using var connectCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await tcp.ConnectAsync(IPAddress.Parse("127.0.0.1"), server.Port, connectCts.Token); + + await using var sslStream = new SslStream(tcp.GetStream(), leaveInnerStreamOpen: false); + using var handshakeCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await sslStream.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = "localhost", + EnabledSslProtocols = protocolVersion, + RemoteCertificateValidationCallback = (_, _, _, _) => true, + }, handshakeCts.Token); + + Assert.True(sslStream.IsEncrypted); + Assert.Equal(protocolVersion, sslStream.SslProtocol); + // If a modern OS disables TLS 1.2 by policy the Tls12 inline case fails here; + // that is an environment change, not a server regression. + } + finally + { + await server.StopAsync(); + } + } + + // N3 + [Fact] + public async Task HttpUpgrade_Succeeds_WithBearerAndSubProtocol() + { + await using var fixture = NetworkTestFixture.Create(); + var server = await fixture.StartAsync(); + try + { + using var https = NewHttpsClient(); + var token = await LoginAsync(https, server.Authority); + + var ws = await ConnectWssAsync(server.WssUrl, token); + try + { + Assert.Equal("textcascade.v1", ws.SubProtocol); + await SendHelloAsync(ws, "subproto-client"); + using var welcome = await ReceiveJsonAsync(ws); + Assert.Equal(1, welcome.RootElement.GetProperty("protocolVersion").GetInt32()); + } + finally + { + ws.Dispose(); + } + } + finally + { + await server.StopAsync(); + } + } + + // N4 + [Fact] + public async Task HttpsLogin_Endpoint_Works() + { + await using var fixture = NetworkTestFixture.Create(); + var server = await fixture.StartAsync(); + try + { + using var https = NewHttpsClient(); + var response = await https.PostAsJsonAsync($"https://{server.Authority}/api/v1/login", new { username = "alice", password = "password123" }); + Assert.True(response.IsSuccessStatusCode, $"Login failed: {response.StatusCode}"); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = doc.RootElement; + Assert.False(string.IsNullOrEmpty(root.GetProperty("token").GetString())); + Assert.Equal(1, root.GetProperty("protocolVersion").GetInt32()); + Assert.True(root.TryGetProperty("expiresAtUtc", out _)); + Assert.True(root.TryGetProperty("maxTextBytes", out _)); + Assert.True(root.TryGetProperty("helloTimeoutSeconds", out _)); + Assert.True(root.TryGetProperty("heartbeatIntervalSeconds", out _)); + Assert.True(root.TryGetProperty("heartbeatTimeoutSeconds", out _)); + } + finally + { + await server.StopAsync(); + } + } + + // N5 + [Fact] + public async Task RandomPortBinding_ActuallyBinds() + { + await using var fixture = NetworkTestFixture.Create(); + var server = await fixture.StartAsync(); + try + { + Assert.True(server.Port > 0, "Kestrel should bind an ephemeral port > 0"); + + // The bound endpoint must accept TCP connections. + using var tcp = new TcpClient(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await tcp.ConnectAsync(IPAddress.Parse("127.0.0.1"), server.Port, cts.Token); + Assert.True(tcp.Connected); + } + finally + { + await server.StopAsync(); + } + } + + private static async Task LoginAsync(HttpClient https, string authority) + { + var response = await https.PostAsJsonAsync($"https://{authority}/api/v1/login", new { username = "alice", password = "password123" }); + Assert.True(response.IsSuccessStatusCode, $"Login failed: {response.StatusCode}"); + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return doc.RootElement.GetProperty("token").GetString()!; + } +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/ProtocolParseTests.cs b/TextCascade.Server.Tests/ProtocolParseTests.cs index b7021d0..d4f3b72 100644 --- a/TextCascade.Server.Tests/ProtocolParseTests.cs +++ b/TextCascade.Server.Tests/ProtocolParseTests.cs @@ -5,6 +5,8 @@ namespace TextCascade.Server.Tests; public class ProtocolParseTests { + private const string ValidHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private static RuntimeConfig NewConfig() => TextCascade.Server.Config.CreateDefaultConfig(); private static ParseResult Parse(string json) => @@ -13,7 +15,7 @@ private static ParseResult Parse(string json) => [Fact] public void ParsesHello() { - var result = Parse("{\"type\":\"hello\",\"clientId\":\"a\",\"clientName\":\"n\",\"lastServerVersion\":5,\"snapshot\":{\"payload\":\"p\",\"encrypted\":true,\"hash\":\"h\",\"localModifiedAtUtc\":\"2026-08-18T08:00:00Z\"}}"); + var result = Parse($"{{\"type\":\"hello\",\"clientId\":\"a\",\"clientName\":\"n\",\"lastServerVersion\":5,\"snapshot\":{{\"payload\":\"p\",\"encrypted\":true,\"hash\":\"{ValidHash}\",\"localModifiedAtUtc\":\"2026-08-18T08:00:00Z\"}}}}"); Assert.True(result.IsSuccess); Assert.Equal(MessageKind.Hello, result.Kind); @@ -26,7 +28,7 @@ public void ParsesHello() [Fact] public void ParsesClip() { - var result = Parse("{\"type\":\"clip\",\"id\":\"id1\",\"payload\":\"p\",\"encrypted\":true,\"hash\":\"h\"}"); + var result = Parse($"{{\"type\":\"clip\",\"id\":\"id1\",\"payload\":\"p\",\"encrypted\":true,\"hash\":\"{ValidHash}\"}}"); Assert.True(result.IsSuccess); Assert.Equal(MessageKind.Clip, result.Kind); diff --git a/TextCascade.Server.Tests/ProtocolSerializationTests.cs b/TextCascade.Server.Tests/ProtocolSerializationTests.cs new file mode 100644 index 0000000..7823101 --- /dev/null +++ b/TextCascade.Server.Tests/ProtocolSerializationTests.cs @@ -0,0 +1,47 @@ +using System.Text; +using System.Text.Json; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class ProtocolSerializationTests +{ + [Fact] + public void WelcomeLatestTimeUsesUtcSecondFormat() + { + var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(1760000000123).ToUniversalTime(); + var latest = new LatestText("payload", 7, "hash", true, "client", "name", timestamp); + + var json = Encoding.UTF8.GetString(Protocol.SerializeWelcome(latest, TextCascade.Server.Config.CreateDefaultConfig().Limits)); + + using var document = JsonDocument.Parse(json); + var actual = document.RootElement.GetProperty("latest").GetProperty("updatedAtUtc").GetString(); + Assert.Equal("2025-10-09T08:53:20Z", actual); + } + + [Fact] + public void ClipTimeUsesUtcSecondFormat() + { + var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(1760000000999).ToUniversalTime(); + var latest = new LatestText("payload", 7, "hash", true, "client", "name", timestamp); + + var json = Encoding.UTF8.GetString(Protocol.SerializeClip("clip-1", latest)); + + using var document = JsonDocument.Parse(json); + var actual = document.RootElement.GetProperty("updatedAtUtc").GetString(); + Assert.Equal("2025-10-09T08:53:20Z", actual); + } + + [Fact] + public void ClipAckTimeUsesUtcSecondFormat() + { + var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(1760000000500).ToUniversalTime(); + var latest = new LatestText("payload", 7, "hash", true, "client", "name", timestamp); + + var json = Encoding.UTF8.GetString(Protocol.SerializeClipAck("clip-1", latest)); + + using var document = JsonDocument.Parse(json); + var actual = document.RootElement.GetProperty("updatedAtUtc").GetString(); + Assert.Equal("2025-10-09T08:53:20Z", actual); + } +} diff --git a/TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs b/TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs new file mode 100644 index 0000000..f5e5180 --- /dev/null +++ b/TextCascade.Server.Tests/RuntimeStateAndProtocolTests.cs @@ -0,0 +1,215 @@ +using System.Collections.Concurrent; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class RuntimeStateAndProtocolTests +{ + private static readonly DateTimeOffset TestStartTime = DateTimeOffset.FromUnixTimeSeconds(1760000000); + + [Fact] + public void StateStorePersistsHighestVersionAtomically() + { + var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json"); + try + { + using (var first = new RuntimeStateStore(path, TimeSpan.Zero)) + { + first.SaveVersion("alice", 7UL); + first.Flush(); + } + + using (var second = new RuntimeStateStore(path, TimeSpan.Zero)) + { + second.SaveVersion("alice", 5UL); + second.Flush(); + + Assert.Equal(7UL, second.GetVersion("alice")); + } + + using (var third = new RuntimeStateStore(path, TimeSpan.Zero)) + { + Assert.Equal(7UL, third.GetVersion("alice")); + } + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public async Task StateStoreFlushesPeriodicallyInBackground() + { + var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json"); + try + { + using (var store = new RuntimeStateStore(path, TimeSpan.FromMilliseconds(50))) + { + store.SaveVersion("alice", 12UL); + Assert.Equal(12UL, store.GetVersion("alice")); + + // Wait for background timer tick + await Task.Delay(150); + + using var reloaded = new RuntimeStateStore(path, TimeSpan.Zero); + Assert.Equal(12UL, reloaded.GetVersion("alice")); + } + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void StateStoreConcurrentSaveVersionMaintainsHighestValue() + { + var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json"); + try + { + using var store = new RuntimeStateStore(path, TimeSpan.Zero); + Parallel.For(1, 100, i => + { + store.SaveVersion("alice", (ulong)i); + store.SaveVersion("bob", (ulong)(100 - i)); + }); + + Assert.Equal(99UL, store.GetVersion("alice")); + Assert.Equal(99UL, store.GetVersion("bob")); + + store.Flush(); + + using var reloaded = new RuntimeStateStore(path, TimeSpan.Zero); + Assert.Equal(99UL, reloaded.GetVersion("alice")); + Assert.Equal(99UL, reloaded.GetVersion("bob")); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void StateStoreRejectsInvalidFile() + { + var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json"); + try + { + File.WriteAllText(path, """{"entries":[{"username":"alice","version":0}]}""", Encoding.UTF8); + Assert.Throws(() => new RuntimeStateStore(path, TimeSpan.Zero)); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void ClipValidationAcceptsClientLocalHash() + { + var config = TextCascade.Server.Config.CreateDefaultConfig(); + Assert.True(Protocol.ValidateClipMessage(new ClientClip("id", "payload", true, new string('a', 64)), config)); + Assert.True(Protocol.ValidateClipMessage(new ClientClip("id", "payload", true, "client-local-hash"), config)); + Assert.False(Protocol.ValidateClipMessage(new ClientClip("id", "payload", true, ""), config)); + Assert.False(Protocol.ValidateClipMessage(new ClientClip("id", "payload", true, new string('a', 4097)), config)); + } + + [Fact] + public void ConfigParsesStateFilePath() + { + var path = Path.Combine(Path.GetTempPath(), $"textcascade-config-{Guid.NewGuid():N}.toml"); + try + { + File.WriteAllText(path, "[files]\nstate_file = \"/tmp/state.json\"\n"); + var config = TextCascade.Server.Config.LoadTomlConfig(path); + Assert.Equal("/tmp/state.json", config.Files.StateFile); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void RecoveryWindowRestoresSnapshotAtPersistedVersion() + { + var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json"); + try + { + using (var initialStore = new RuntimeStateStore(path, TimeSpan.Zero)) + { + initialStore.SaveVersion("alice", 7UL); + initialStore.Flush(); + } + + var config = TextCascade.Server.Config.CreateDefaultConfig(); + using var stateStore = new RuntimeStateStore(path, TimeSpan.Zero); + var server = new SyncServer( + config, + new UsersFile(), + stateStore, + new Argon2PasswordHasher(), + TimeProvider.System, + NullLogger.Instance); + var hub = new UserHub("alice", config, TestStartTime, server, server.RuntimeStateStore, 7UL); + var modified = DateTimeOffset.FromUnixTimeSeconds(1759999990); + hub.AcceptSnapshot(new ClientHello( + "client-a", + "Client A", + 7UL, + new ClipSnapshot("restored", false, "client-local-hash", modified))); + + hub.CloseRecoveryWindow(TestStartTime.AddSeconds(3)); + + Assert.NotNull(hub.Latest); + Assert.Equal(7UL, hub.Version); + Assert.Equal("restored", hub.Latest!.Payload); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void RecoveryWindowIgnoresStaleSnapshot() + { + var path = Path.Combine(Path.GetTempPath(), $"textcascade-state-{Guid.NewGuid():N}.json"); + try + { + using (var initialStore = new RuntimeStateStore(path, TimeSpan.Zero)) + { + initialStore.SaveVersion("alice", 7UL); + initialStore.Flush(); + } + + var config = TextCascade.Server.Config.CreateDefaultConfig(); + using var stateStore = new RuntimeStateStore(path, TimeSpan.Zero); + var server = new SyncServer( + config, + new UsersFile(), + stateStore, + new Argon2PasswordHasher(), + TimeProvider.System, + NullLogger.Instance); + var hub = new UserHub("alice", config, TestStartTime, server, server.RuntimeStateStore, 7UL); + hub.AcceptSnapshot(new ClientHello( + "client-a", + "Client A", + 6UL, + new ClipSnapshot("stale", false, "client-local-hash", TestStartTime))); + + hub.CloseRecoveryWindow(TestStartTime.AddSeconds(3)); + + Assert.Null(hub.Latest); + Assert.Equal(7UL, hub.Version); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } +} \ No newline at end of file diff --git a/TextCascade.Server.Tests/SingleInstanceLockTests.cs b/TextCascade.Server.Tests/SingleInstanceLockTests.cs new file mode 100644 index 0000000..15b2a3e --- /dev/null +++ b/TextCascade.Server.Tests/SingleInstanceLockTests.cs @@ -0,0 +1,102 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class SingleInstanceLockTests +{ + [Fact] + public void AcquireCreatesLockBesideUsersFile() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var usersFile = Path.Combine(tempDir, "users.json"); + var lockPath = Cli.CreateLockPath(usersFile); + + Assert.EndsWith("users.json.lock", lockPath, StringComparison.Ordinal); + Assert.Equal(tempDir, Path.GetDirectoryName(lockPath)); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void SecondProcessCannotAcquireSameUsersFileLock() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var lockPath = Path.Combine(tempDir, "users.json.lock"); + using var handle1 = SingleInstanceLock.Acquire(lockPath, TimeSpan.FromMilliseconds(10)); + Assert.NotNull(handle1); + + using var handle2 = SingleInstanceLock.Acquire(lockPath, TimeSpan.FromMilliseconds(10)); + Assert.Null(handle2); + + handle1.Dispose(); + + using var handle3 = SingleInstanceLock.Acquire(lockPath, TimeSpan.FromMilliseconds(10)); + Assert.NotNull(handle3); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void StaleLockIsRecovered() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var lockPath = Path.Combine(tempDir, "users.json.lock"); + // Find a non-existent PID (e.g. 999999) + var deadPid = 999999; + File.WriteAllText(lockPath, deadPid.ToString(CultureInfo.InvariantCulture), Encoding.UTF8); + + using var handle = SingleInstanceLock.Acquire(lockPath, TimeSpan.FromMilliseconds(10)); + Assert.NotNull(handle); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void AcquireRejectsPathWithoutDirectory() + { + Assert.Throws(() => SingleInstanceLock.Acquire("users.json.lock")); + } + + [Fact] + public void DifferentUsersFilesCanLockIndependently() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var lockPath1 = Path.Combine(tempDir, "users1.json.lock"); + var lockPath2 = Path.Combine(tempDir, "users2.json.lock"); + + using var handle1 = SingleInstanceLock.Acquire(lockPath1, TimeSpan.FromMilliseconds(10)); + using var handle2 = SingleInstanceLock.Acquire(lockPath2, TimeSpan.FromMilliseconds(10)); + + Assert.NotNull(handle1); + Assert.NotNull(handle2); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } +} diff --git a/TextCascade.Server.Tests/SlowHashSmokeTests.cs b/TextCascade.Server.Tests/SlowHashSmokeTests.cs new file mode 100644 index 0000000..ec0329f --- /dev/null +++ b/TextCascade.Server.Tests/SlowHashSmokeTests.cs @@ -0,0 +1,63 @@ +using System.Text.RegularExpressions; +using Isopoh.Cryptography.Argon2; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +[Trait("Category", "SlowHash")] +public class SlowHashSmokeTests +{ + private static Argon2Config ProductionConfig() => Cli.CreateArgon2Config(TextCascade.Server.Config.CreateDefaultConfig()); + + private static (int Memory, int Time, int Threads) ParseEncodedParameters(string encoded) + { + var match = Regex.Match(encoded, @"m=(\d+),t=(\d+),p=(\d+)"); + Assert.True(match.Success, $"Not an Argon2 PHC string: {encoded}"); + return (int.Parse(match.Groups[1].Value), int.Parse(match.Groups[2].Value), int.Parse(match.Groups[3].Value)); + } + + // U26 + [Fact] + public void Hash_Then_Verify_RoundTrip() + { + var hasher = new Argon2PasswordHasher(); + var encoded = hasher.Hash("correct horse battery staple", ProductionConfig()); + + Assert.StartsWith("$argon2id$", encoded, StringComparison.Ordinal); + Assert.True(hasher.Verify("correct horse battery staple", encoded)); + Assert.False(hasher.Verify("wrong password", encoded)); + } + + // U27 — NeedsRehash is false when the encoded parameters match themselves exactly. + // (The Isopoh encoder may write a lane count that differs from the configured + // Argon2Parallelism, so the test reads back the actual m/t/p instead of assuming.) + [Fact] + public void NeedsRehash_MatchingParams_ReturnsFalse() + { + var hasher = new Argon2PasswordHasher(); + var encoded = hasher.Hash("some-password", ProductionConfig()); + var (memory, time, threads) = ParseEncodedParameters(encoded); + + Assert.False(Argon2PasswordHasher.NeedsRehash(encoded, memory, time, threads)); + Assert.True(Argon2PasswordHasher.NeedsRehash(encoded, memory + 1024, time, threads)); + Assert.True(Argon2PasswordHasher.NeedsRehash(encoded, memory, time + 1, threads)); + Assert.True(Argon2PasswordHasher.NeedsRehash(encoded, memory, time, threads + 1)); + } + + // U28 — rewriting the stored parameter segment to stale values flags a rehash. + [Fact] + public void NeedsRehash_StaleParams_ReturnsTrue() + { + var hasher = new Argon2PasswordHasher(); + var encoded = hasher.Hash("some-password", ProductionConfig()); + var (memory, time, threads) = ParseEncodedParameters(encoded); + + var stale = encoded + .Replace($"m={memory}", $"m={Math.Max(1024, memory / 8)}", StringComparison.Ordinal) + .Replace($"t={time}", "t=1", StringComparison.Ordinal) + .Replace($"p={threads}", "p=1", StringComparison.Ordinal); + Assert.NotEqual(encoded, stale); + + Assert.True(Argon2PasswordHasher.NeedsRehash(stale, memory, time, threads)); + } +} diff --git a/TextCascade.Server.Tests/TextCascade.Server.Tests.csproj b/TextCascade.Server.Tests/TextCascade.Server.Tests.csproj index ade3d41..2e8da18 100644 --- a/TextCascade.Server.Tests/TextCascade.Server.Tests.csproj +++ b/TextCascade.Server.Tests/TextCascade.Server.Tests.csproj @@ -18,6 +18,10 @@ + + + + diff --git a/TextCascade.Server.Tests/UserFileWatcherTests.cs b/TextCascade.Server.Tests/UserFileWatcherTests.cs new file mode 100644 index 0000000..7efc906 --- /dev/null +++ b/TextCascade.Server.Tests/UserFileWatcherTests.cs @@ -0,0 +1,240 @@ +using Microsoft.Extensions.Logging.Abstractions; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class UserFileWatcherTests +{ + private const string ValidHash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aG9zdA"; + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + await Task.Delay(20); + } + Assert.True(condition(), "Condition was not met within timeout."); + } + + [Fact] + public async Task ReloadReplacesUserLookupAfterSave() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var tempUsers = Path.Combine(tempDir, "users.json"); + var tempState = Path.Combine(tempDir, "state.json"); + try + { + var initialUsers = new UsersFile + { + Users = [new UserRecord("alice", ValidHash, 1)], + NextTokenVersion = 2, + }; + UsersFile.SaveUsers(tempUsers, initialUsers); + + var config = TextCascade.Server.Config.CreateDefaultConfig() with + { + TokenSecret = new byte[32], + Files = new FilesConfig(tempUsers, tempState), + }; + var server = new SyncServer( + config, + initialUsers, + new RuntimeStateStore(tempState), + new Argon2PasswordHasher(), + TimeProvider.System, + NullLogger.Instance); + + using var watcher = new UserFileWatcher( + tempUsers, + server, + NullLogger.Instance, + debounce: TimeSpan.FromMilliseconds(20), + pollFallback: TimeSpan.FromSeconds(1)); + watcher.Start(); + + Assert.True(server.UserLookup.ContainsKey("alice")); + Assert.False(server.UserLookup.ContainsKey("bob")); + + // Add bob and save + var updatedUsers = new UsersFile + { + Users = + [ + new UserRecord("alice", ValidHash, 1), + new UserRecord("bob", ValidHash, 2), + ], + NextTokenVersion = 3, + }; + UsersFile.SaveUsers(tempUsers, updatedUsers); + + await WaitUntilAsync(() => server.UserLookup.ContainsKey("bob"), TimeSpan.FromSeconds(3)); + Assert.True(server.UserLookup.ContainsKey("bob")); + Assert.True(server.UserLookup.ContainsKey("alice")); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task InvalidReloadRetainsPreviousLookup() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var tempUsers = Path.Combine(tempDir, "users.json"); + var tempState = Path.Combine(tempDir, "state.json"); + try + { + var initialUsers = new UsersFile + { + Users = [new UserRecord("alice", ValidHash, 1)], + NextTokenVersion = 2, + }; + UsersFile.SaveUsers(tempUsers, initialUsers); + + var config = TextCascade.Server.Config.CreateDefaultConfig() with + { + TokenSecret = new byte[32], + Files = new FilesConfig(tempUsers, tempState), + }; + var server = new SyncServer( + config, + initialUsers, + new RuntimeStateStore(tempState), + new Argon2PasswordHasher(), + TimeProvider.System, + NullLogger.Instance); + + using var watcher = new UserFileWatcher( + tempUsers, + server, + NullLogger.Instance, + debounce: TimeSpan.FromMilliseconds(20), + pollFallback: TimeSpan.FromSeconds(1)); + watcher.Start(); + + // Write invalid JSON + await File.WriteAllTextAsync(tempUsers, "invalid json content!@#$"); + + // Wait a bit to ensure watcher event processed + await Task.Delay(200); + + // Previous lookup must still be alice + Assert.True(server.UserLookup.ContainsKey("alice")); + + // Now recover with valid file containing charlie + var recoveredUsers = new UsersFile + { + Users = [new UserRecord("charlie", ValidHash, 3)], + NextTokenVersion = 4, + }; + UsersFile.SaveUsers(tempUsers, recoveredUsers); + + await WaitUntilAsync(() => server.UserLookup.ContainsKey("charlie"), TimeSpan.FromSeconds(3)); + Assert.True(server.UserLookup.ContainsKey("charlie")); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task ConcurrentReloadObserversAlwaysSeeCompleteDictionary() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var tempUsers = Path.Combine(tempDir, "users.json"); + var tempState = Path.Combine(tempDir, "state.json"); + try + { + var usersA = new UsersFile + { + Users = [new UserRecord("alice", ValidHash, 1), new UserRecord("bob", ValidHash, 2)], + NextTokenVersion = 3, + }; + var usersB = new UsersFile + { + Users = [new UserRecord("charlie", ValidHash, 3), new UserRecord("david", ValidHash, 4)], + NextTokenVersion = 5, + }; + + var config = TextCascade.Server.Config.CreateDefaultConfig() with { TokenSecret = new byte[32] }; + var server = new SyncServer( + config, + usersA, + new RuntimeStateStore(tempState), + new Argon2PasswordHasher(), + TimeProvider.System, + NullLogger.Instance); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + var token = cts.Token; + + // Reader task + var readerTask = Task.Run(() => + { + while (!token.IsCancellationRequested) + { + var lookup = server.UserLookup; + // It must be either set A (alice & bob) or set B (charlie & david) + var isA = lookup.ContainsKey("alice") && lookup.ContainsKey("bob") && lookup.Count == 2; + var isB = lookup.ContainsKey("charlie") && lookup.ContainsKey("david") && lookup.Count == 2; + Assert.True(isA || isB, "Observed incomplete or mixed dictionary."); + } + }); + + // Writer loop replacing lookups + var writerTask = Task.Run(async () => + { + var toggle = false; + while (!token.IsCancellationRequested) + { + server.ReplaceUserLookup(toggle ? usersA : usersB); + toggle = !toggle; + await Task.Yield(); + } + }); + + await Task.WhenAll(readerTask, writerTask); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void WatcherDisposeIsIdempotent() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var tempUsers = Path.Combine(tempDir, "users.json"); + var tempState = Path.Combine(tempDir, "state.json"); + try + { + var initialUsers = new UsersFile(); + var config = TextCascade.Server.Config.CreateDefaultConfig() with { TokenSecret = new byte[32] }; + var server = new SyncServer( + config, + initialUsers, + new RuntimeStateStore(tempState), + new Argon2PasswordHasher(), + TimeProvider.System, + NullLogger.Instance); + + var watcher = new UserFileWatcher(tempUsers, server, NullLogger.Instance); + watcher.Start(); + watcher.Dispose(); + watcher.Dispose(); // Should not throw + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } +} diff --git a/TextCascade.Server.Tests/UserHubCoordinationTests.cs b/TextCascade.Server.Tests/UserHubCoordinationTests.cs new file mode 100644 index 0000000..5f6f7fd --- /dev/null +++ b/TextCascade.Server.Tests/UserHubCoordinationTests.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class UserHubCoordinationTests +{ + private sealed class FakeCoordinator : IConnectionCoordinator + { + public ILogger Logger { get; set; } = NullLogger.Instance; + public List<(ConnectionContext Connection, string Reason)> Cancelled { get; } = new(); + public List RebuiltHubs { get; } = new(); + public List RemovedEmptyHubs { get; } = new(); + + public void CancelConnection(ConnectionContext connection, string reason) + { + Cancelled.Add((connection, reason)); + } + + public void RebuildHub(UserHub hub) + { + RebuiltHubs.Add(hub); + } + + public void RemoveEmptyHubAfterRecovery(UserHub hub) + { + RemovedEmptyHubs.Add(hub); + } + } + + [Fact] + public void UserHubDoesNotDependOnSyncServerConcreteType() + { + var fakeCoordinator = new FakeCoordinator(); + var tempState = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json"); + try + { + var stateStore = new RuntimeStateStore(tempState); + var config = TextCascade.Server.Config.CreateDefaultConfig() with + { + Limits = TextCascade.Server.Config.CreateDefaultConfig().Limits with { SnapshotWindowSeconds = 0 }, + }; + + var hub = new UserHub( + "alice", + config, + DateTimeOffset.UtcNow, + fakeCoordinator, + stateStore, + 1UL); + + var now = DateTimeOffset.UtcNow; + hub.CloseRecoveryWindow(now); + + Assert.Single(fakeCoordinator.RemovedEmptyHubs); + Assert.Same(hub, fakeCoordinator.RemovedEmptyHubs[0]); + } + finally + { + if (File.Exists(tempState)) File.Delete(tempState); + } + } + + [Fact] + public async Task UserLoopFailureNotifiesCoordinator() + { + var fakeCoordinator = new FakeCoordinator(); + var tempState = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json"); + try + { + var stateStore = new RuntimeStateStore(tempState); + var config = TextCascade.Server.Config.CreateDefaultConfig(); + + // Set initial version to ulong.MaxValue so next clip throws Version overflow + var hub = new UserHub( + "alice", + config, + DateTimeOffset.UtcNow, + fakeCoordinator, + stateStore, + ulong.MaxValue); + + _ = hub.StartIfIdle(); + + // Enqueue a clip job to trigger overflow + var dummySocket = new System.Net.WebSockets.ClientWebSocket(); + var conn = new ConnectionContext("conn-1", "alice", "c1", "Client1", dummySocket, hub, config); + hub.AddConnection(conn); + + // Close recovery window so clip can be applied + hub.CloseRecoveryWindow(DateTimeOffset.UtcNow.AddSeconds(10)); + + var clip = new ClientClip("id-overflow", "data", false, "hash"); + hub.UserChannel.Writer.TryWrite(new ClipJob(conn, clip)); + + // Wait for user loop failure notification + var timeout = DateTime.UtcNow.AddSeconds(3); + while (fakeCoordinator.RebuiltHubs.Count == 0 && DateTime.UtcNow < timeout) + { + await Task.Delay(20); + } + + Assert.Single(fakeCoordinator.RebuiltHubs); + Assert.Same(hub, fakeCoordinator.RebuiltHubs[0]); + } + finally + { + if (File.Exists(tempState)) File.Delete(tempState); + } + } +} + + diff --git a/TextCascade.Server.Tests/UserLoopConcurrencyTests.cs b/TextCascade.Server.Tests/UserLoopConcurrencyTests.cs new file mode 100644 index 0000000..3982a30 --- /dev/null +++ b/TextCascade.Server.Tests/UserLoopConcurrencyTests.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class UserLoopConcurrencyTests +{ + private sealed class FakeCoordinator : IConnectionCoordinator + { + public ILogger Logger => NullLogger.Instance; + public void CancelConnection(ConnectionContext connection, string reason) { } + public void RebuildHub(UserHub hub) { } + public void RemoveEmptyHubAfterRecovery(UserHub hub) { } + } + + [Fact] + public async Task StartIfIdleCreatesSingleTaskUnderConcurrency() + { + var tempState = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json"); + try + { + var stateStore = new RuntimeStateStore(tempState); + var config = TextCascade.Server.Config.CreateDefaultConfig(); + var hub = new UserHub("alice", config, DateTimeOffset.UtcNow, new FakeCoordinator(), stateStore, 1UL); + + var startedTasks = new Task[100]; + var runners = new Task[100]; + for (var i = 0; i < 100; i++) + { + var idx = i; + runners[i] = Task.Factory.StartNew(() => { startedTasks[idx] = hub.StartIfIdle(); }, TaskCreationOptions.LongRunning); + } + + await Task.WhenAll(runners); + + var firstTask = startedTasks[0]; + Assert.NotNull(firstTask); + for (var i = 1; i < startedTasks.Length; i++) + { + Assert.Same(firstTask, startedTasks[i]); + } + + hub.UserChannel.Writer.Complete(); + await firstTask; + } + finally + { + if (File.Exists(tempState)) File.Delete(tempState); + } + } + + [Fact] + public async Task RunUserLoopRejectsConcurrentReader() + { + var tempState = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json"); + using var cts = new CancellationTokenSource(); + try + { + var stateStore = new RuntimeStateStore(tempState); + var config = TextCascade.Server.Config.CreateDefaultConfig(); + var hub = new UserHub("alice", config, DateTimeOffset.UtcNow, new FakeCoordinator(), stateStore, 1UL); + + var firstLoop = Task.Run(() => hub.RunUserLoopAsync(cts.Token)); + + // Wait a small amount to ensure first loop has claimed readerActive + await Task.Delay(50); + + // Second concurrent reader must throw InvalidOperationException + await Assert.ThrowsAsync(() => hub.RunUserLoopAsync(cts.Token)); + + hub.UserChannel.Writer.Complete(); + await firstLoop; + } + finally + { + if (File.Exists(tempState)) File.Delete(tempState); + } + } +} diff --git a/TextCascade.Server.Tests/WebSocketIntegrationTests.cs b/TextCascade.Server.Tests/WebSocketIntegrationTests.cs new file mode 100644 index 0000000..c0c29ca --- /dev/null +++ b/TextCascade.Server.Tests/WebSocketIntegrationTests.cs @@ -0,0 +1,488 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TextCascade.Server; + +namespace TextCascade.Server.Tests; + +public class WebSocketIntegrationTests +{ + private const string ValidHash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aG9zdA"; + + private sealed class TestLogCollector : ILoggerProvider, ILogger + { + public List Entries { get; } = new(); + + public ILogger CreateLogger(string categoryName) => this; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (Entries) + { + Entries.Add(formatter(state, exception)); + } + } + + public void Dispose() { } + } + + private sealed class FastPasswordHasher : IPasswordHasher + { + public string Hash(string password, Isopoh.Cryptography.Argon2.Argon2Config config) => ValidHash; + + public bool Verify(string password, string encodedHash) + { + return (password == "password123" && encodedHash == ValidHash); + } + + public bool NeedsRehash(string encodedHash, Isopoh.Cryptography.Argon2.Argon2Config config) => false; + } + + private sealed class IntegrationTestFixture : IAsyncDisposable + { + public WebApplication Application { get; } + public HttpClient Client { get; } + public string BaseUrl { get; } + public string WebSocketUrl { get; } + public string TempDir { get; } + public RuntimeConfig Config { get; } + public TestLogCollector Logs { get; } = new(); + + public static async Task CreateAsync( + Func? configModifier = null, + Action? usersOverride = null, + Action? stateOverride = null) + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var usersPath = Path.Combine(tempDir, "users.json"); + var statePath = Path.Combine(tempDir, "state.json"); + + var users = new UsersFile + { + Users = + [ + new UserRecord("alice", ValidHash, 1), + new UserRecord("bob", ValidHash, 1), + ], + NextTokenVersion = 2, + }; + usersOverride?.Invoke(users); + UsersFile.SaveUsers(usersPath, users); + + var stateStore = new RuntimeStateStore(statePath); + stateOverride?.Invoke(stateStore); + + var config = TextCascade.Server.Config.CreateDefaultConfig() with + { + TokenSecret = Encoding.UTF8.GetBytes("12345678901234567890123456789012"), + Files = new FilesConfig(usersPath, statePath), + Server = new ServerConfig("127.0.0.1", 0, "dummy.pem"), + Limits = TextCascade.Server.Config.CreateDefaultConfig().Limits with { SnapshotWindowSeconds = 0 }, + }; + if (configModifier is not null) + { + config = configModifier(config); + } + + var app = ServerHost.CreateApp( + [], + config, + users, + stateStore, + hasher: new FastPasswordHasher(), + clock: TimeProvider.System, + certificate: null); + + var logs = new TestLogCollector(); + app.Services.GetRequiredService().AddProvider(logs); + + app.Urls.Add("http://127.0.0.1:0"); + await app.StartAsync(); + + var boundUrl = app.Urls.First(); + var uri = new Uri(boundUrl); + var wsUrl = $"ws://{uri.Authority}/api/v1/sync"; + + var client = new HttpClient { BaseAddress = uri }; + + return new IntegrationTestFixture(app, client, boundUrl, wsUrl, tempDir, config, logs); + } + + private IntegrationTestFixture( + WebApplication app, + HttpClient client, + string baseUrl, + string wsUrl, + string tempDir, + RuntimeConfig config, + TestLogCollector logs) + { + Application = app; + Client = client; + BaseUrl = baseUrl; + WebSocketUrl = wsUrl; + TempDir = tempDir; + Config = config; + Logs = logs; + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + try + { + await Application.StopAsync(cts.Token); + } + catch { } + + await Application.DisposeAsync(); + + if (Directory.Exists(TempDir)) + { + try { Directory.Delete(TempDir, true); } catch { } + } + } + } + + private static async Task LoginAsync(HttpClient client, string username, string password) + { + var response = await client.PostAsJsonAsync("/api/v1/login", new { username, password }); + Assert.True(response.IsSuccessStatusCode, $"Login failed: {response.StatusCode}"); + + using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + Assert.Equal(1, doc.RootElement.GetProperty("protocolVersion").GetInt32()); + var token = doc.RootElement.GetProperty("token").GetString(); + Assert.False(string.IsNullOrEmpty(token)); + return token!; + } + + private static async Task ConnectWebSocketAsync(string wsUrl, string token) + { + var ws = new ClientWebSocket(); + ws.Options.AddSubProtocol("textcascade.v1"); + ws.Options.SetRequestHeader("Authorization", $"Bearer {token}"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await ws.ConnectAsync(new Uri(wsUrl), cts.Token); + Assert.Equal(WebSocketState.Open, ws.State); + Assert.Equal("textcascade.v1", ws.SubProtocol); + return ws; + } + + private static async Task SendJsonAsync(ClientWebSocket ws, object msg) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(msg); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await ws.SendAsync(bytes, WebSocketMessageType.Text, true, cts.Token); + } + + private static async Task ReceiveJsonAsync(ClientWebSocket ws) + { + var buffer = new byte[64 * 1024]; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var result = await ws.ReceiveAsync(new ArraySegment(buffer), cts.Token); + Assert.Equal(WebSocketMessageType.Text, result.MessageType); + return JsonDocument.Parse(buffer.AsMemory(0, result.Count)); + } + + private static async Task CloseWsAsync(ClientWebSocket? ws) + { + if (ws is not null && ws.State == WebSocketState.Open) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + await ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "done", cts.Token); + } + catch { } + } + ws?.Dispose(); + } + + [Fact] + public async Task LoginAndWebSocketHandshakeRoundTrips() + { + await using var fixture = await IntegrationTestFixture.CreateAsync(); + + var token = await LoginAsync(fixture.Client, "alice", "password123"); + var ws = await ConnectWebSocketAsync(fixture.WebSocketUrl, token); + try + { + // Send hello + await SendJsonAsync(ws, new + { + type = "hello", + clientId = "client-1", + clientName = "Device 1", + lastServerVersion = 0, + snapshot = (object?)null, + }); + + // Receive welcome + using var welcomeDoc = await ReceiveJsonAsync(ws); + var root = welcomeDoc.RootElement; + Assert.Equal("welcome", root.GetProperty("type").GetString()); + Assert.Equal(1, root.GetProperty("protocolVersion").GetInt32()); + Assert.False(root.TryGetProperty("latest", out var latest) && latest.ValueKind == JsonValueKind.Object); + } + finally + { + await CloseWsAsync(ws); + } + } + + [Fact] + public async Task ClipBroadcastsToSecondClient() + { + await using var fixture = await IntegrationTestFixture.CreateAsync(); + + var tokenA = await LoginAsync(fixture.Client, "alice", "password123"); + var tokenB = await LoginAsync(fixture.Client, "alice", "password123"); + + var wsA = await ConnectWebSocketAsync(fixture.WebSocketUrl, tokenA); + var wsB = await ConnectWebSocketAsync(fixture.WebSocketUrl, tokenB); + try + { + // Hello from A + await SendJsonAsync(wsA, new + { + type = "hello", + clientId = "client-A", + clientName = "Device A", + lastServerVersion = 0, + snapshot = (object?)null, + }); + using var welcomeA = await ReceiveJsonAsync(wsA); + + // Hello from B + await SendJsonAsync(wsB, new + { + type = "hello", + clientId = "client-B", + clientName = "Device B", + lastServerVersion = 0, + snapshot = (object?)null, + }); + using var welcomeB = await ReceiveJsonAsync(wsB); + + // A sends clip + await SendJsonAsync(wsA, new + { + type = "clip", + id = "clip-msg-1", + payload = "Hello World Broadcast", + encrypted = false, + hash = "h1", + }); + + // A receives clip_ack + using var ackA = await ReceiveJsonAsync(wsA); + Assert.Equal("clip_ack", ackA.RootElement.GetProperty("type").GetString()); + Assert.Equal("clip-msg-1", ackA.RootElement.GetProperty("id").GetString()); + Assert.Equal(1UL, ackA.RootElement.GetProperty("version").GetUInt64()); + + // B receives broadcast clip + using var clipB = await ReceiveJsonAsync(wsB); + Assert.Equal("clip", clipB.RootElement.GetProperty("type").GetString()); + Assert.Equal("clip-msg-1", clipB.RootElement.GetProperty("id").GetString()); + Assert.Equal("Hello World Broadcast", clipB.RootElement.GetProperty("payload").GetString()); + Assert.Equal(1UL, clipB.RootElement.GetProperty("version").GetUInt64()); + + // B sends same clip (id & payload duplicate) + await SendJsonAsync(wsB, new + { + type = "clip", + id = "clip-msg-1", + payload = "Hello World Broadcast", + encrypted = false, + hash = "h1", + }); + + // B receives duplicate ack with same version + using var ackB = await ReceiveJsonAsync(wsB); + Assert.Equal("clip_ack", ackB.RootElement.GetProperty("type").GetString()); + Assert.Equal("clip-msg-1", ackB.RootElement.GetProperty("id").GetString()); + Assert.Equal(1UL, ackB.RootElement.GetProperty("version").GetUInt64()); + } + finally + { + await CloseWsAsync(wsA); + await CloseWsAsync(wsB); + } + } + + [Fact] + public async Task InvalidTokenDoesNotUpgradeWebSocket() + { + await using var fixture = await IntegrationTestFixture.CreateAsync(); + + var ws = new ClientWebSocket(); + ws.Options.AddSubProtocol("textcascade.v1"); + ws.Options.SetRequestHeader("Authorization", "Bearer invalid-signature-token-here"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + await Assert.ThrowsAnyAsync(() => ws.ConnectAsync(new Uri(fixture.WebSocketUrl), cts.Token)); + } + + [Fact] + public async Task ReconnectRestoresHighestSnapshot() + { + await using var fixture = await IntegrationTestFixture.CreateAsync( + configModifier: cfg => cfg with { Limits = cfg.Limits with { SnapshotWindowSeconds = 10 } }, + stateOverride: store => + { + store.SaveVersion("alice", 7UL); + }); + + var token = await LoginAsync(fixture.Client, "alice", "password123"); + + var wsA = await ConnectWebSocketAsync(fixture.WebSocketUrl, token); + var wsB = await ConnectWebSocketAsync(fixture.WebSocketUrl, token); + try + { + var modifiedTime = DateTimeOffset.UtcNow; + + // A sends hello with version 7 + await SendJsonAsync(wsA, new + { + type = "hello", + clientId = "client-A", + clientName = "Device A", + lastServerVersion = 7, + snapshot = new + { + payload = "snapshot-v7", + encrypted = false, + hash = "hash7", + localModifiedAtUtc = modifiedTime.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"), + }, + }); + + // B sends hello with version 8 + await SendJsonAsync(wsB, new + { + type = "hello", + clientId = "client-B", + clientName = "Device B", + lastServerVersion = 8, + snapshot = new + { + payload = "snapshot-v8", + encrypted = false, + hash = "hash8", + localModifiedAtUtc = modifiedTime.AddSeconds(1).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"), + }, + }); + + // Wait a moment for jobs to be processed in user channel + await Task.Delay(50); + + // Explicitly close recovery window to trigger immediate election and broadcast + var syncServer = fixture.Application.Services.GetRequiredService(); + var hub = syncServer.GetOrCreateHub("alice", fixture.Config); + hub.CloseRecoveryWindow(DateTimeOffset.UtcNow.AddMinutes(1)); + + // Both receives welcome with winning version 8 + using var welcomeA = await ReceiveJsonAsync(wsA); + using var welcomeB = await ReceiveJsonAsync(wsB); + + Assert.Equal("welcome", welcomeA.RootElement.GetProperty("type").GetString()); + var latestA = welcomeA.RootElement.GetProperty("latest"); + Assert.Equal(8UL, latestA.GetProperty("version").GetUInt64()); + Assert.Equal("snapshot-v8", latestA.GetProperty("payload").GetString()); + + Assert.Equal("welcome", welcomeB.RootElement.GetProperty("type").GetString()); + var latestB = welcomeB.RootElement.GetProperty("latest"); + Assert.Equal(8UL, latestB.GetProperty("version").GetUInt64()); + Assert.Equal("snapshot-v8", latestB.GetProperty("payload").GetString()); + } + finally + { + await CloseWsAsync(wsA); + await CloseWsAsync(wsB); + } + } + + [Fact] + public async Task AbruptDisconnectIsLoggedAndServerContinues() + { + await using var fixture = await IntegrationTestFixture.CreateAsync(); + + var token = await LoginAsync(fixture.Client, "alice", "password123"); + + var wsA = await ConnectWebSocketAsync(fixture.WebSocketUrl, token); + var wsB = await ConnectWebSocketAsync(fixture.WebSocketUrl, token); + try + { + // Hello from A + await SendJsonAsync(wsA, new + { + type = "hello", + clientId = "client-A", + clientName = "Device A", + lastServerVersion = 0, + snapshot = (object?)null, + }); + using var welcomeA = await ReceiveJsonAsync(wsA); + + // Hello from B + await SendJsonAsync(wsB, new + { + type = "hello", + clientId = "client-B", + clientName = "Device B", + lastServerVersion = 0, + snapshot = (object?)null, + }); + using var welcomeB = await ReceiveJsonAsync(wsB); + + // Abruptly abort client A socket + wsA.Abort(); + wsA.Dispose(); + + // B sends clip - server continues properly + await SendJsonAsync(wsB, new + { + type = "clip", + id = "clip-after-abort", + payload = "Still works", + encrypted = false, + hash = "h2", + }); + + using var ackB = await ReceiveJsonAsync(wsB); + Assert.Equal("clip_ack", ackB.RootElement.GetProperty("type").GetString()); + Assert.Equal("clip-after-abort", ackB.RootElement.GetProperty("id").GetString()); + + // Check structured logs for connect/disconnect + lock (fixture.Logs.Entries) + { + Assert.NotEmpty(fixture.Logs.Entries); + foreach (var log in fixture.Logs.Entries) + { + Assert.DoesNotContain("password123", log, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("12345678901234567890123456789012", log, StringComparison.OrdinalIgnoreCase); + } + } + } + finally + { + await CloseWsAsync(wsB); + } + } +} + + diff --git a/TextCascade.Server/Auth.cs b/TextCascade.Server/Auth.cs index f402f4a..f0fa6f7 100644 --- a/TextCascade.Server/Auth.cs +++ b/TextCascade.Server/Auth.cs @@ -1,3 +1,4 @@ +using System.Buffers.Text; using System.Globalization; using System.Security.Cryptography; using System.Text; @@ -136,9 +137,9 @@ public static string SignToken(TokenPayload payload, byte[] secret) } var payloadBytes = stream.ToArray(); - var payloadSegment = Base64UrlEncode(payloadBytes); + var payloadSegment = Base64Url.EncodeToString(payloadBytes); var signature = HMACSHA256.HashData(secret, payloadBytes); - return $"{payloadSegment}.{Base64UrlEncode(signature)}"; + return $"{payloadSegment}.{Base64Url.EncodeToString(signature)}"; } public bool VerifyToken(string compactToken, DateTimeOffset nowUtc, IReadOnlyDictionary userLookup) @@ -265,46 +266,22 @@ private static bool TryParsePositiveInteger(string raw, out long value) return long.TryParse(span, NumberStyles.None, CultureInfo.InvariantCulture, out value) && value > 0; } - private static string Base64UrlEncode(byte[] bytes) - { - return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - } - - private static bool TryBase64UrlDecode(string text, ref byte[]? rented, Span buffer, out int length) + private static bool TryBase64UrlDecode(ReadOnlySpan text, ref byte[]? rented, Span buffer, out int length) { rented = null; - if (string.IsNullOrEmpty(text) || text.Length % 4 == 1) + if (text.IsEmpty || text.Length % 4 == 1) { length = 0; return false; } - foreach (var character in text) - { - if (!char.IsAsciiLetterOrDigit(character) && character is not ('-' or '_')) - { - length = 0; - return false; - } - } - - var paddedLength = text.Length + (4 - text.Length % 4) % 4; - if (paddedLength > buffer.Length) + var maxDecodedLength = Base64Url.GetMaxDecodedLength(text.Length); + if (maxDecodedLength > buffer.Length) { - rented = new byte[paddedLength]; + rented = new byte[maxDecodedLength]; buffer = rented; } - var chars = new char[paddedLength]; - for (var i = 0; i < text.Length; i++) - { - chars[i] = text[i] switch { '-' => '+', '_' => '/', _ => text[i] }; - } - - for (var i = text.Length; i < paddedLength; i++) - { - chars[i] = '='; - } - return Convert.TryFromBase64Chars(chars, buffer, out length) && length > 0; + return Base64Url.TryDecodeFromChars(text, buffer, out length) && length > 0; } } diff --git a/TextCascade.Server/AuthService.cs b/TextCascade.Server/AuthService.cs index 434006b..586cab0 100644 --- a/TextCascade.Server/AuthService.cs +++ b/TextCascade.Server/AuthService.cs @@ -1,25 +1,25 @@ -using System.IO; -using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; namespace TextCascade.Server; public sealed class AuthService { - public static async Task HandleLoginAsync(HttpContext context, RuntimeConfig config, ILogger? logger = null) + public static async Task HandleLoginAsync(HttpContext context, RuntimeConfig config, SyncServer syncServer, ILogger? logger = null) { - var limiter = SyncServer.Instance.LoginLimiter; - var clock = SyncServer.Instance.Clock; + var limiter = syncServer.LoginLimiter; + var clock = syncServer.Clock; var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - var now = clock.UtcNow; + var now = clock.GetUtcNow(); LoginRequest? request; try { request = await ParseLoginRequest(context); } - catch (Exception exception) when (exception is LoginParseException or JsonException or DecoderFallbackException) + catch (Exception exception) when (exception is LoginParseException or JsonException) { await WriteError(context, 400, "invalid_request", exception.Message); return; @@ -32,30 +32,26 @@ public static async Task HandleLoginAsync(HttpContext context, RuntimeConfig con return; } - var userLookup = SyncServer.Instance.UserLookup; + var userLookup = syncServer.UserLookup; var found = userLookup.TryGetValue(request.Username, out var user); - var passwordOk = found && user is not null && SyncServer.Instance.Hasher.Verify(request.Password, user.PasswordHash); - if (!passwordOk) + var passwordHash = found && user is not null + ? user.PasswordHash + : syncServer.LoginDummyHash; + var passwordOk = syncServer.Hasher.Verify(request.Password, passwordHash); + if (!found || !passwordOk || user is null || user.Disabled) { logger?.LogSecurityEvent("login", ("username", request.Username), ("ip", ip), ("success", false), ("reason", "invalid_credentials")); await WriteError(context, 401, "invalid_credentials", "Invalid username or password."); return; } - var authenticatedUser = user!; - if (authenticatedUser.Disabled) - { - logger?.LogSecurityEvent("login", ("username", request.Username), ("ip", ip), ("success", false), ("reason", "disabled")); - await WriteError(context, 401, "invalid_credentials", "Invalid username or password."); - return; - } - + var authenticatedUser = user; limiter.ResetUserLoginLimit(request.Username); logger?.LogSecurityEvent("login", ("username", request.Username), ("ip", ip), ("success", true)); // Spec §4.1: on parameter drift, emit a structured rehash warning rather than rewriting users.json. bool needsRehash = false; - if (SyncServer.Instance.Hasher.NeedsRehash(authenticatedUser.PasswordHash, Cli.CreateArgon2Config(config))) + if (syncServer.Hasher.NeedsRehash(authenticatedUser.PasswordHash, Cli.CreateArgon2Config(config))) { needsRehash = true; logger?.LogWarning("Argon2 password hash needs rehash for user {Username}; users.json was not rewritten.", authenticatedUser.Username); @@ -63,57 +59,45 @@ public static async Task HandleLoginAsync(HttpContext context, RuntimeConfig con var tokenService = new TokenService(config.TokenSecret!); var token = tokenService.CreateToken(authenticatedUser, now, TimeSpan.FromDays(config.Auth.TokenTtlDays)); - var bytes = Protocol.SerializeLoginResponse(token, needsRehash); + var bytes = Protocol.SerializeLoginResponse(token, config, needsRehash); context.Response.StatusCode = 200; context.Response.ContentType = "application/json"; await context.Response.BodyWriter.WriteAsync(bytes); } + private const int MaxLoginBodyBytes = 16384; + + // Login contract (spec §4.1): unknown fields, duplicate fields and nesting beyond + // depth 3 are rejected; names are exact lowercase via JsonPropertyName on LoginRequest. + private static readonly JsonSerializerOptions StrictLoginOptions = new() + { + MaxDepth = 3, + AllowDuplicateProperties = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + private static async Task ParseLoginRequest(HttpContext context) { - if (context.Request.ContentLength is > 16384) + if (context.Request.ContentLength is > MaxLoginBodyBytes) { throw new LoginParseException("Request body too large."); } - using var body = new MemoryStream(); - var buffer = new byte[4096]; - while (true) - { - var read = await context.Request.Body.ReadAsync(buffer.AsMemory(), context.RequestAborted); - if (read == 0) break; - if (body.Length + read > 16384) - { - throw new LoginParseException("Request body too large."); - } - - body.Write(buffer, 0, read); - } - - var text = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true).GetString(body.ToArray()); - - using var document = JsonDocument.Parse(text, new JsonDocumentOptions - { - AllowTrailingCommas = false, - CommentHandling = JsonCommentHandling.Disallow, - MaxDepth = 3, - }); - if (document.RootElement.ValueKind != JsonValueKind.Object - || !HasUniqueProperties(document.RootElement, "username", "password")) + var bodySize = context.Features.Get(); + if (bodySize is not null + && (bodySize.MaxRequestBodySize is null || bodySize.MaxRequestBodySize > MaxLoginBodyBytes)) { - throw new LoginParseException("Invalid login request."); + bodySize.MaxRequestBodySize = MaxLoginBodyBytes; } LoginRequest? request; try { - request = JsonSerializer.Deserialize(text, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - AllowTrailingCommas = false, - ReadCommentHandling = JsonCommentHandling.Disallow, - MaxDepth = 3, - }); + request = await JsonSerializer.DeserializeAsync(context.Request.Body, StrictLoginOptions, context.RequestAborted); + } + catch (BadHttpRequestException) + { + throw new LoginParseException("Request body too large."); } catch (JsonException) { @@ -128,20 +112,6 @@ private static async Task ParseLoginRequest(HttpContext context) return request; } - private static bool HasUniqueProperties(JsonElement element, params string[] known) - { - var seen = new HashSet(StringComparer.Ordinal); - foreach (var property in element.EnumerateObject()) - { - if (!known.Contains(property.Name, StringComparer.Ordinal) || !seen.Add(property.Name)) - { - return false; - } - } - - return true; - } - private static async Task WriteError(HttpContext context, int status, string code, string message) { context.Response.StatusCode = status; @@ -155,6 +125,9 @@ private static async Task WriteError(HttpContext context, int status, string cod public static string CreateRateLimitResult() => "rate_limited"; } -public sealed record LoginRequest(string Username, string Password); +// Exact lowercase member names: case variants must fail as unknown fields (spec §4.1). +public sealed record LoginRequest( + [property: JsonPropertyName("username")] string Username, + [property: JsonPropertyName("password")] string Password); public sealed class LoginParseException(string message) : Exception(message); diff --git a/TextCascade.Server/Cli.cs b/TextCascade.Server/Cli.cs index 2e15c3f..bf7bd06 100644 --- a/TextCascade.Server/Cli.cs +++ b/TextCascade.Server/Cli.cs @@ -1,8 +1,8 @@ -using System.Diagnostics; using System.Globalization; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using Isopoh.Cryptography.Argon2; namespace TextCascade.Server; @@ -16,42 +16,120 @@ public static int RunCli(string[] args, IPasswordHasher? hasher = null) { if (args.Length == 0 || args[0] != "user") { - Console.Error.WriteLine("Usage: TextCascade.Server user [options]"); - Console.Error.WriteLine("Commands: add, passwd, disable, enable, delete, revoke-tokens, list, hash"); - return Error; + return PrintUsage(); } hasher ??= new Argon2PasswordHasher(); - using var lockHandle = SingleInstanceLock.Acquire(); - if (lockHandle is null) + + var rest = args.Skip(1).ToArray(); + if (!TryExtractConfigOption(ref rest, out var configPath)) { - Console.Error.WriteLine("Another TextCascade CLI process is running."); + Console.Error.WriteLine("--config requires a path."); return Error; } - var rest = args.Skip(1).ToArray(); - return rest switch - { - { Length: > 0 } when rest[0] == "add" => CommandAddUser(rest, hasher), - { Length: > 0 } when rest[0] == "passwd" => CommandPasswd(rest, hasher), - { Length: > 0 } when rest[0] == "disable" => CommandSetDisabled(rest, disabled: true), - { Length: > 0 } when rest[0] == "enable" => CommandSetDisabled(rest, disabled: false), - { Length: > 0 } when rest[0] == "delete" => CommandDeleteUser(rest), - { Length: > 0 } when rest[0] == "revoke-tokens" => CommandRevokeTokens(rest), - { Length: > 0 } when rest[0] == "list" => CommandListUsers(rest), - { Length: > 0 } when rest[0] == "hash" => CommandHashPassword(rest, hasher), + configPath ??= Environment.GetEnvironmentVariable("TEXTCASCADE_CONFIG") is { Length: > 0 } environmentConfig + ? environmentConfig + : "textcascade.toml"; + RuntimeConfig config; + try + { + config = Config.CreateDefaultConfig(); + config = Config.LoadTomlConfig(configPath, config); + config = Config.ApplyEnvironmentOverrides(config); + } + catch (Exception exception) when (exception is InvalidOperationException or JsonException or DecoderFallbackException or IOException) + { + Console.Error.WriteLine($"Configuration error: {exception.Message}"); + return Error; + } + + SingleInstanceLockHandle? lockHandle; + try + { + var lockPath = CreateLockPath(config.Files.UsersFile); + lockHandle = SingleInstanceLock.Acquire(lockPath); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or DirectoryNotFoundException or ArgumentException or InvalidOperationException) + { + Console.Error.WriteLine($"Unable to acquire users file lock: {exception.Message}"); + return Error; + } + + using (lockHandle) + { + if (lockHandle is null) + { + Console.Error.WriteLine("Another TextCascade CLI process is running."); + return Error; + } + + return rest switch + { + { Length: > 0 } when rest[0] == "add" => CommandAddUser(rest, hasher, config), + { Length: > 0 } when rest[0] == "passwd" => CommandPasswd(rest, hasher, config), + { Length: > 0 } when rest[0] == "disable" => CommandSetDisabled(rest, disabled: true, config), + { Length: > 0 } when rest[0] == "enable" => CommandSetDisabled(rest, disabled: false, config), + { Length: > 0 } when rest[0] == "delete" => CommandDeleteUser(rest, config), + { Length: > 0 } when rest[0] == "revoke-tokens" => CommandRevokeTokens(rest, config), + { Length: > 0 } when rest[0] == "list" => CommandListUsers(rest, config), + { Length: > 0 } when rest[0] == "hash" => CommandHashPassword(rest, hasher, config), _ => PrintUsage(), }; + } + } + + internal static string CreateLockPath(string usersFile) + { + var fullUsersPath = Path.GetFullPath(usersFile); + var directory = Path.GetDirectoryName(fullUsersPath); + if (string.IsNullOrEmpty(directory)) + { + throw new InvalidOperationException("users.json path must include a parent directory."); + } + + var fileName = Path.GetFileName(fullUsersPath); + return Path.Combine(directory, $"{fileName}.lock"); } private static int PrintUsage() { Console.Error.WriteLine("Usage: TextCascade.Server user [options]"); Console.Error.WriteLine("Commands: add, passwd, disable, enable, delete, revoke-tokens, list, hash"); + Console.Error.WriteLine("All commands accept --config ; fallback order is --config, TEXTCASCADE_CONFIG, then textcascade.toml."); return Error; } - private static int CommandAddUser(string[] args, IPasswordHasher hasher) + private static bool TryExtractConfigOption(ref string[] args, out string? configPath) + { + configPath = null; + var remaining = new List(); + for (var index = 0; index < args.Length; index++) + { + if (string.Equals(args[index], "--config", StringComparison.Ordinal)) + { + if (index + 1 >= args.Length) + { + return false; + } + + configPath = args[++index]; + } + else if (args[index].StartsWith("--config=", StringComparison.Ordinal)) + { + configPath = args[index]["--config=".Length..]; + } + else + { + remaining.Add(args[index]); + } + } + + args = remaining.ToArray(); + return true; + } + + private static int CommandAddUser(string[] args, IPasswordHasher hasher, RuntimeConfig config) { if (!TryGetOption(args, "username", out var username)) { @@ -59,15 +137,14 @@ private static int CommandAddUser(string[] args, IPasswordHasher hasher) return Error; } - var password = ReadPassword("Password: "); - var confirm = ReadPassword("Confirm: "); + var password = ReadPassword("Password: ", args); + var confirm = HasPasswordStdin(args) ? password : ReadPassword("Confirm: ", args); if (!string.Equals(password, confirm, StringComparison.Ordinal)) { Console.Error.WriteLine("Passwords do not match."); return Error; } - var config = Config.CreateDefaultConfig(); var usersPath = config.Files.UsersFile; var users = LoadForWrite(usersPath); if (users.Users.Any(user => string.Equals(user.Username, username, StringComparison.Ordinal))) @@ -91,7 +168,7 @@ private static int CommandAddUser(string[] args, IPasswordHasher hasher) return Ok; } - private static int CommandPasswd(string[] args, IPasswordHasher hasher) + private static int CommandPasswd(string[] args, IPasswordHasher hasher, RuntimeConfig config) { if (!TryGetOption(args, "username", out var username)) { @@ -99,7 +176,6 @@ private static int CommandPasswd(string[] args, IPasswordHasher hasher) return Error; } - var config = Config.CreateDefaultConfig(); var usersPath = config.Files.UsersFile; var users = LoadForWrite(usersPath); var index = users.Users.FindIndex(user => string.Equals(user.Username, username, StringComparison.Ordinal)); @@ -109,7 +185,7 @@ private static int CommandPasswd(string[] args, IPasswordHasher hasher) return Error; } - var password = ReadPassword("New password: "); + var password = ReadPassword("New password: ", args); var hash = hasher.Hash(password, CreateArgon2Config(config)); users.Users[index] = users.Users[index] with { PasswordHash = hash }; UsersFile.SaveUsers(usersPath, users); @@ -117,7 +193,7 @@ private static int CommandPasswd(string[] args, IPasswordHasher hasher) return Ok; } - private static int CommandSetDisabled(string[] args, bool disabled) + private static int CommandSetDisabled(string[] args, bool disabled, RuntimeConfig config) { if (!TryGetOption(args, "username", out var username)) { @@ -125,7 +201,6 @@ private static int CommandSetDisabled(string[] args, bool disabled) return Error; } - var config = Config.CreateDefaultConfig(); var usersPath = config.Files.UsersFile; var users = LoadForWrite(usersPath); var index = users.Users.FindIndex(user => string.Equals(user.Username, username, StringComparison.Ordinal)); @@ -141,7 +216,7 @@ private static int CommandSetDisabled(string[] args, bool disabled) return Ok; } - private static int CommandDeleteUser(string[] args) + private static int CommandDeleteUser(string[] args, RuntimeConfig config) { if (!TryGetOption(args, "username", out var username)) { @@ -149,7 +224,6 @@ private static int CommandDeleteUser(string[] args) return Error; } - var config = Config.CreateDefaultConfig(); var usersPath = config.Files.UsersFile; var users = LoadForWrite(usersPath); var index = users.Users.FindIndex(user => string.Equals(user.Username, username, StringComparison.Ordinal)); @@ -165,7 +239,7 @@ private static int CommandDeleteUser(string[] args) return Ok; } - private static int CommandRevokeTokens(string[] args) + private static int CommandRevokeTokens(string[] args, RuntimeConfig config) { if (!TryGetOption(args, "username", out var username)) { @@ -173,7 +247,6 @@ private static int CommandRevokeTokens(string[] args) return Error; } - var config = Config.CreateDefaultConfig(); var usersPath = config.Files.UsersFile; var users = LoadForWrite(usersPath); var index = users.Users.FindIndex(user => string.Equals(user.Username, username, StringComparison.Ordinal)); @@ -197,9 +270,8 @@ private static int CommandRevokeTokens(string[] args) return Ok; } - private static int CommandListUsers(string[] args) + private static int CommandListUsers(string[] args, RuntimeConfig config) { - var config = Config.CreateDefaultConfig(); var users = File.Exists(config.Files.UsersFile) ? UsersFile.LoadUsers(config.Files.UsersFile) : new UsersFile(); Console.WriteLine($"nextTokenVersion: {users.NextTokenVersion}"); Console.WriteLine("username,disabled,tokenVersion"); @@ -210,10 +282,9 @@ private static int CommandListUsers(string[] args) return Ok; } - private static int CommandHashPassword(string[] args, IPasswordHasher hasher) + private static int CommandHashPassword(string[] args, IPasswordHasher hasher, RuntimeConfig config) { - var password = ReadPassword("Password: "); - var config = Config.CreateDefaultConfig(); + var password = ReadPassword("Password: ", args); var hash = hasher.Hash(password, CreateArgon2Config(config)); Console.WriteLine(hash); return Ok; @@ -259,8 +330,28 @@ private static bool TryGetOption(string[] args, string name, out string value) return false; } - private static string ReadPassword(string prompt) + internal static bool HasPasswordStdin(string[] args) => HasFlag(args, "password-stdin"); + + private static bool HasFlag(string[] args, string name) + { + var flag = $"--{name}"; + return args.Any(arg => string.Equals(arg, flag, StringComparison.Ordinal)); + } + + internal static string ReadPassword(string prompt, string[] args) { + if (HasPasswordStdin(args)) + { + var line = Console.In.ReadLine(); + if (string.IsNullOrEmpty(line)) + { + Console.Error.WriteLine("--password-stdin requires one non-empty line."); + throw new ArgumentException("--password-stdin requires one non-empty line."); + } + + return line; + } + Console.Write(prompt); var builder = new StringBuilder(); while (true) @@ -317,30 +408,41 @@ public void Dispose() public static class SingleInstanceLock { - public static SingleInstanceLockHandle? Acquire(TimeSpan? pollDelay = null) + public static SingleInstanceLockHandle? Acquire(string lockPath, TimeSpan? pollDelay = null) { - var directory = AppContext.BaseDirectory; - var lockPath = Path.Combine(directory, ".textcascade-cli.lock"); + if (string.IsNullOrWhiteSpace(lockPath)) + { + throw new ArgumentException("Lock path must not be empty.", nameof(lockPath)); + } + + var directory = Path.GetDirectoryName(lockPath); + if (string.IsNullOrEmpty(directory)) + { + throw new ArgumentException("Lock path must include a directory.", nameof(lockPath)); + } + + if (!Directory.Exists(directory)) + { + throw new DirectoryNotFoundException($"Directory '{directory}' does not exist."); + } + var delay = pollDelay ?? TimeSpan.FromMilliseconds(100); for (var attempt = 0; attempt < 3; attempt++) { try { - if (!File.Exists(lockPath)) + // FileShare.None: the OS releases the handle when the holder process dies, + // so a leftover file (crash, power loss) is simply reopened and never blocks; + // the PID below is diagnostic only. + var stream = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + stream.SetLength(0); + using (var writer = new StreamWriter(stream, Encoding.UTF8, bufferSize: 32, leaveOpen: true)) { - var stream = new FileStream(lockPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); - using (var writer = new StreamWriter(stream, Encoding.UTF8, bufferSize: 32, leaveOpen: true)) - { - writer.Write(Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); - } - return new SingleInstanceLockHandle(lockPath, stream); + writer.Write(Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); } - if (TryRecoverStaleLock(lockPath, out var recovered)) - { - return recovered; - } + return new SingleInstanceLockHandle(lockPath, stream); } catch (IOException) { @@ -351,57 +453,6 @@ public static class SingleInstanceLock return null; } +} - private static bool TryRecoverStaleLock(string lockPath, out SingleInstanceLockHandle? handle) - { - handle = null; - try - { - var text = File.ReadAllText(lockPath).Trim(); - if (!int.TryParse(text, CultureInfo.InvariantCulture, out var pid)) - { - return false; - } - - if (IsProcessAlive(pid)) - { - return false; - } - - File.Delete(lockPath); - var stream = new FileStream(lockPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); - using (var writer = new StreamWriter(stream, Encoding.UTF8, bufferSize: 32, leaveOpen: true)) - { - writer.Write(Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); - } - handle = new SingleInstanceLockHandle(lockPath, stream); - return true; - } - catch (IOException) - { - return false; - } - } - private static bool IsProcessAlive(int pid) - { - try - { - var process = Process.GetProcessById(pid); - _ = process.Handle; - return !process.HasExited; - } - catch (ArgumentException) - { - return false; - } - catch (InvalidOperationException) - { - return false; - } - catch (System.ComponentModel.Win32Exception) - { - return true; - } - } -} diff --git a/TextCascade.Server/Core.cs b/TextCascade.Server/Core.cs index 057f3a3..d06229e 100644 --- a/TextCascade.Server/Core.cs +++ b/TextCascade.Server/Core.cs @@ -29,6 +29,42 @@ public void ResetUserLoginLimit(string username) } } + internal int GetWindowCount(string key) + { + lock (gate) + { + return windows.TryGetValue(key, out var queue) ? queue.Count : 0; + } + } + + internal bool HasWindowKey(string key) + { + lock (gate) + { + return windows.ContainsKey(key); + } + } + + internal void RemoveExpiredForTest(DateTimeOffset cutoff) + { + lock (gate) + { + RemoveExpired(cutoff); + } + } + + internal void EnqueueForTest(string key, DateTimeOffset timestamp) + { + lock (gate) + { + if (!windows.TryGetValue(key, out var queue)) + { + windows[key] = queue = new Queue(); + } + queue.Enqueue(timestamp); + } + } + private bool TryConsume(string key, int limit, DateTimeOffset nowUtc, int maxKeys, bool allowNewKey) { var cutoff = nowUtc.AddMinutes(-1); @@ -57,22 +93,32 @@ private bool TryConsume(string key, int limit, DateTimeOffset nowUtc, int maxKey } queue.Enqueue(nowUtc); - if (queue.Count == 0) - { - windows.Remove(key); - } - return true; } private void RemoveExpired(DateTimeOffset cutoff) { - var stale = windows.Where(pair => pair.Value.Count == 0 || pair.Value.Peek() <= cutoff) - .Select(pair => pair.Key) - .ToList(); - foreach (var key in stale) + List? emptyKeys = null; + foreach (var pair in windows) + { + var queue = pair.Value; + while (queue.Count > 0 && queue.Peek() <= cutoff) + { + queue.Dequeue(); + } + + if (queue.Count == 0) + { + (emptyKeys ??= new List()).Add(pair.Key); + } + } + + if (emptyKeys is not null) { - windows.Remove(key); + foreach (var key in emptyKeys) + { + windows.Remove(key); + } } } } @@ -122,27 +168,31 @@ public bool TryAcquire(DateTimeOffset nowUtc) public sealed class SeenIdRing { private readonly object gate = new(); - private readonly string?[] ids; - private readonly LatestText?[] results; - private int next; + private readonly Dictionary entries; + private readonly string?[] insertionOrder; + private int nextInsertIndex; + + public int Capacity { get; } public SeenIdRing(int capacity) { - if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); - ids = new string?[capacity]; - results = new LatestText?[capacity]; + if (capacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } + + Capacity = capacity; + entries = new Dictionary(capacity, StringComparer.Ordinal); + insertionOrder = new string?[capacity]; } public bool TryDuplicate(string id) { lock (gate) { - for (var i = 0; i < ids.Length; i++) + if (entries.ContainsKey(id)) { - if (string.Equals(ids[i], id, StringComparison.Ordinal)) - { - return true; - } + return true; } RememberInternal(id, null); @@ -154,17 +204,7 @@ public bool TryGetResult(string id, out LatestText? result) { lock (gate) { - for (var i = 0; i < ids.Length; i++) - { - if (string.Equals(ids[i], id, StringComparison.Ordinal)) - { - result = results[i]; - return true; - } - } - - result = null; - return false; + return entries.TryGetValue(id, out result); } } @@ -178,11 +218,36 @@ public void RememberId(string id, LatestText? result) } } + public bool IsUnchangedDuplicate(string id, string payload, string hash, bool encrypted, out LatestText? latest) + { + lock (gate) + { + if (!entries.TryGetValue(id, out var remembered)) + { + latest = null; + return false; + } + + latest = remembered; + return remembered is not null + && string.Equals(remembered.Payload, payload, StringComparison.Ordinal) + && string.Equals(remembered.Hash, hash, StringComparison.Ordinal) + && remembered.Encrypted == encrypted; + } + } + private void RememberInternal(string id, LatestText? result) { - ids[next] = id; - results[next] = result; - next = (next + 1) % ids.Length; + var evictedId = insertionOrder[nextInsertIndex]; + if (evictedId is not null + && !string.Equals(evictedId, id, StringComparison.Ordinal)) + { + entries.Remove(evictedId); + } + + insertionOrder[nextInsertIndex] = id; + nextInsertIndex = (nextInsertIndex + 1) % insertionOrder.Length; + entries[id] = result; } } @@ -217,3 +282,4 @@ public static LatestText WithVersion(LatestText latest, ulong next, DateTimeOffs .FirstOrDefault(); } } + diff --git a/TextCascade.Server/Hosting/ConnectionHandler.cs b/TextCascade.Server/Hosting/ConnectionHandler.cs new file mode 100644 index 0000000..abdbd68 --- /dev/null +++ b/TextCascade.Server/Hosting/ConnectionHandler.cs @@ -0,0 +1,270 @@ +using System.Globalization; +using System.Net.WebSockets; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace TextCascade.Server; + +public static class ConnectionHandler +{ + public static async Task RunAsync(ConnectionContext provisional, TokenPayload payload, RuntimeConfig config, SyncServer server) + { + server.RegisterPendingHello(provisional); + ClientHello hello; + try + { + var received = await ReceiveFrameAsync(provisional, config.Limits.MaxFrameBytes, provisional.State.Cts.Token); + if (received.MessageType == WebSocketMessageType.Close) + { + await provisional.Socket.CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, + "client_closed", + CancellationToken.None); + server.CancelConnection(provisional, "closed"); + return; + } + + var parse = Protocol.ParseClientMessage(received.Payload, config); + if (!parse.IsSuccess || parse.Kind != MessageKind.Hello) + { + var error = Protocol.SerializeProtocolError(new ProtocolError( + ProtocolErrorCode.InvalidMessage, + "Expected a valid hello message.", + parse.Error?.ReferenceId)); + await SendAndClosePreHelloAsync( + provisional, + error, + WebSocketCloseStatus.PolicyViolation, + "invalid_hello", + server); + return; + } + + hello = (ClientHello)parse.Message!; + } + catch (FrameTooLargeException) + { + var error = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null)); + await SendAndClosePreHelloAsync(provisional, error, WebSocketCloseStatus.MessageTooBig, "frame_too_large", server); + return; + } + catch (OperationCanceledException) + { + // Hello timeout is owned by the unified heartbeat scanner; here the socket was + // cancelled for another reason (e.g. shutdown). Fall through to unified cleanup. + server.CancelConnection(provisional, "cancelled"); + return; + } + catch (WebSocketException) + { + server.CancelConnection(provisional, "socket_error"); + return; + } + + var hub = server.GetOrCreateHub(payload.Subject, config); + var connection = new ConnectionContext( + provisional.ConnectionId, + payload.Subject, + hello.ClientId, + hello.ClientName, + provisional.Socket, + hub, + config); + hub.AddConnection(connection); + server.Logger.LogSecurityEvent("connect", + ("username", connection.Username), + ("clientId", connection.ClientId), + ("connectionId", connection.ConnectionId)); + connection.State.HelloReceived = true; + connection.State.LastSeen = DateTimeOffset.UtcNow; + server.UnregisterPendingHello(provisional); + if (!hub.TryWriteJob(new HelloJob(connection, hello))) + { + server.CancelConnection(connection, "user_loop_unavailable"); + return; + } + + var sendTask = ConnectionSendLoopAsync(connection); + var readTask = ReadLoopAsync(connection, config, server); + await Task.WhenAll(sendTask, readTask); + server.CancelConnection(connection, "disconnected"); + } + + private static async Task ReceiveFrameAsync( + ConnectionContext connection, + int maxBytes, + CancellationToken cancellationToken) + { + using var stream = new MemoryStream(Math.Min(maxBytes, 16 * 1024)); + var buffer = new byte[Math.Min(maxBytes, 16 * 1024)]; + while (true) + { + var received = await connection.Socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + if (received.Count > maxBytes - stream.Length) + { + throw new FrameTooLargeException(); + } + + stream.Write(buffer, 0, received.Count); + if (received.EndOfMessage) + { + return new ReceivedMessage(received.MessageType, stream.ToArray()); + } + } + } + + private static async Task SendAndClosePreHelloAsync( + ConnectionContext connection, + byte[] error, + WebSocketCloseStatus status, + string reason, + SyncServer server) + { + try + { + if (connection.Socket.State == WebSocketState.Open) + { + await connection.Socket.SendAsync(error, WebSocketMessageType.Text, true, CancellationToken.None); + await connection.Socket.CloseAsync(status, reason, CancellationToken.None); + } + } + catch (Exception) + { + server.EnqueueImmediateClose(connection, "server_busy"); + } + finally + { + server.CancelConnection(connection, reason); + } + } + + private static async Task ReadLoopAsync(ConnectionContext connection, RuntimeConfig config, SyncServer server) + { + try + { + while (connection.State.Cts.IsCancellationRequested == false && connection.Socket.State == WebSocketState.Open) + { + ReceivedMessage received; + try + { + received = await ReceiveFrameAsync(connection, config.Limits.MaxFrameBytes, connection.State.Cts.Token); + } + catch (FrameTooLargeException) + { + var oversized = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null)); + await SendSafeAsync(connection, oversized, server); + await Task.Delay(100, connection.State.Cts.Token); + if (connection.Socket.State == WebSocketState.Open) + { + await connection.Socket.CloseAsync(WebSocketCloseStatus.MessageTooBig, "frame_too_large", CancellationToken.None); + } + server.CancelConnection(connection, "frame_too_large"); + break; + } + + if (received.MessageType == WebSocketMessageType.Close) + { + try + { + await connection.Socket.CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, + "client_closed", + CancellationToken.None); + } + catch (WebSocketException) { } + break; + } + + if (!Protocol.CheckFrameSize(received.Payload.Length, config)) + { + var error = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null)); + await SendSafeAsync(connection, error, server); + await connection.Socket.CloseAsync(WebSocketCloseStatus.MessageTooBig, "frame_too_large", CancellationToken.None); + server.CancelConnection(connection, "frame_too_large"); + break; + } + + var parse = Protocol.ParseClientMessage(received.Payload, config); + if (!parse.IsSuccess) + { + server.Logger.LogSecurityEvent("reject", + ("username", connection.Username), + ("code", parse.Error?.CodeName ?? "invalid_message"), + ("bytes", received.Payload.Length)); + var error = Protocol.SerializeProtocolError(parse.Error!); + await SendSafeAsync(connection, error, server); + continue; + } + + switch (parse.Kind) + { + case MessageKind.Clip: + var clip = (ClientClip)parse.Message!; + var hub = connection.Hub; + if (hub is null) + { + server.CancelConnection(connection, "user_loop_unavailable"); + break; + } + + var decision = hub.ClassifyClip(clip, connection); + if (decision == RecoveryDecision.QueueFull) + { + server.CancelConnection(connection, "recovery_queue_full"); + } + else if (decision == RecoveryDecision.ProcessNow + && !hub.TryWriteJob(new ClipJob(connection, clip))) + { + server.CancelConnection(connection, "user_loop_unavailable"); + } + break; + case MessageKind.Pong: + if (!connection.State.TryTakePongAwaiting()) + { + var unsolicitedPong = Protocol.SerializeProtocolError(new ProtocolError( + ProtocolErrorCode.InvalidMessage, + "Pong received without an outstanding ping.", + null)); + await SendSafeAsync(connection, unsolicitedPong, server); + continue; + } + + if (connection.Hub is null || !connection.Hub.TryWriteJob(new PongJob(connection, (ClientPong)parse.Message!))) + { + server.CancelConnection(connection, "user_loop_unavailable"); + } + break; + } + } + } + catch (OperationCanceledException) { } + catch (WebSocketException) { } + } + + private static async Task ConnectionSendLoopAsync(ConnectionContext connection) + { + try + { + await foreach (var payload in connection.State.SendQueue.Reader.ReadAllAsync(connection.State.Cts.Token)) + { + await connection.Socket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, connection.State.Cts.Token); + } + } + catch (OperationCanceledException) { } + catch (WebSocketException) { } + } + + private static async Task SendSafeAsync(ConnectionContext connection, byte[] payload, SyncServer server) + { + if (!connection.State.TryEnqueueSend(payload)) + { + server.EnqueueImmediateClose(connection, "server_busy"); + return; + } + } +} + + + +internal sealed class FrameTooLargeException : Exception; + diff --git a/TextCascade.Server/Hosting/HeartbeatScannerService.cs b/TextCascade.Server/Hosting/HeartbeatScannerService.cs new file mode 100644 index 0000000..728aba5 --- /dev/null +++ b/TextCascade.Server/Hosting/HeartbeatScannerService.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace TextCascade.Server; + +public sealed class HeartbeatScannerService : BackgroundService +{ + private readonly SyncServer syncServer; + private readonly TimeProvider timeProvider; + private readonly ILogger logger; + + public HeartbeatScannerService(SyncServer syncServer, TimeProvider timeProvider, ILogger logger) + { + this.syncServer = syncServer; + this.timeProvider = timeProvider; + this.logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + Scan(timeProvider.GetUtcNow()); + } + catch (Exception exception) + { + logger.LogError(exception, "Heartbeat scan failed."); + } + } + } + catch (OperationCanceledException) + { + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await base.StopAsync(cancellationToken); + await syncServer.ShutdownAsync(TimeSpan.FromSeconds(2), timeProvider.GetUtcNow()); + } + + private void Scan(DateTimeOffset now) + { + syncServer.ScanHeartbeats(now); + + var recoveryEnd = syncServer.ProcessStartTime.AddSeconds( + syncServer.Config.Limits.SnapshotWindowSeconds); + if (now < recoveryEnd) + { + return; + } + + foreach (var pair in syncServer.Registry.All) + { + pair.Value.CloseRecoveryWindow(now); + } + } +} diff --git a/TextCascade.Server/Hosting/SyncEndpoint.cs b/TextCascade.Server/Hosting/SyncEndpoint.cs new file mode 100644 index 0000000..366a7e7 --- /dev/null +++ b/TextCascade.Server/Hosting/SyncEndpoint.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Http; + +namespace TextCascade.Server; + +public static class SyncEndpoint +{ + public static async Task HandleAsync(HttpContext context, RuntimeConfig config, SyncServer server) + { + var tokenHeader = context.Request.Headers.Authorization.ToString(); + if (!tokenHeader.StartsWith("Bearer ", StringComparison.Ordinal)) + { + context.Response.StatusCode = 401; + return; + } + + var compactToken = tokenHeader["Bearer ".Length..]; + var now = DateTimeOffset.UtcNow; + var tokenService = new TokenService(config.TokenSecret!); + if (!tokenService.TryVerifyToken(compactToken, now, server.UserLookup, out var payload)) + { + context.Response.StatusCode = 401; + return; + } + + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } + + var subProtocol = SelectSubProtocol(context.WebSockets.WebSocketRequestedProtocols); + if (subProtocol is null) + { + context.Response.StatusCode = 400; + return; + } + + using var socket = await context.WebSockets.AcceptWebSocketAsync(subProtocol); + var connectionId = Guid.NewGuid().ToString("N"); + var provisional = new ConnectionContext(connectionId, payload.Subject, "pending", "pending", socket, null!, config); + await ConnectionHandler.RunAsync(provisional, payload, config, server); + } + + internal static string? SelectSubProtocol(IList requested) + { + foreach (var protocol in requested) + { + if (string.Equals(protocol, "textcascade.v1", StringComparison.Ordinal)) return protocol; + } + return null; + } +} + diff --git a/TextCascade.Server/Hosting/UserFileWatcher.cs b/TextCascade.Server/Hosting/UserFileWatcher.cs new file mode 100644 index 0000000..0715b74 --- /dev/null +++ b/TextCascade.Server/Hosting/UserFileWatcher.cs @@ -0,0 +1,253 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace TextCascade.Server; + +internal sealed class UserFileWatcher : IDisposable +{ + private readonly string usersPath; + private readonly SyncServer server; + private readonly ILogger logger; + private readonly TimeSpan debounce; + private readonly TimeSpan pollFallback; + + private readonly object scheduleGate = new(); + private CancellationTokenSource? reloadDelay; + private CancellationTokenSource? reloadExecution; + private int reloadQueued; + + private FileSystemWatcher? watcher; + private PeriodicTimer? pollTimer; + private Task? pollTask; + private bool started; + private bool disposed; + + public UserFileWatcher( + string usersPath, + SyncServer server, + ILogger logger, + TimeSpan? debounce = null, + TimeSpan? pollFallback = null) + { + this.usersPath = Path.GetFullPath(usersPath); + this.server = server; + this.logger = logger; + this.debounce = debounce ?? TimeSpan.FromMilliseconds(250); + this.pollFallback = pollFallback ?? TimeSpan.FromSeconds(30); + } + + public void Start() + { + lock (scheduleGate) + { + if (started || disposed) return; + started = true; + + var directory = Path.GetDirectoryName(usersPath); + if (string.IsNullOrEmpty(directory)) + { + throw new InvalidOperationException("Users file path must include a parent directory."); + } + + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + var fileName = Path.GetFileName(usersPath); + + watcher = new FileSystemWatcher(directory, fileName) + { + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime, + }; + + watcher.Changed += OnFileChanged; + watcher.Created += OnFileChanged; + watcher.Deleted += OnFileChanged; + watcher.Renamed += OnFileRenamed; + watcher.Error += OnFileError; + watcher.EnableRaisingEvents = true; + + pollTimer = new PeriodicTimer(pollFallback); + reloadExecution = new CancellationTokenSource(); + var executionToken = reloadExecution.Token; + + pollTask = Task.Run(async () => + { + try + { + while (await pollTimer.WaitForNextTickAsync(executionToken)) + { + ScheduleReload(); + } + } + catch (OperationCanceledException) + { + } + }); + } + } + + private void OnFileChanged(object? sender, FileSystemEventArgs eventArgs) + { + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + var fullPath = Path.GetFullPath(eventArgs.FullPath); + if (string.Equals(fullPath, usersPath, comparison)) + { + ScheduleReload(); + } + } + + private void OnFileRenamed(object? sender, RenamedEventArgs eventArgs) + { + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + var fullPath = Path.GetFullPath(eventArgs.FullPath); + var oldFullPath = Path.GetFullPath(eventArgs.OldFullPath); + if (string.Equals(fullPath, usersPath, comparison) || string.Equals(oldFullPath, usersPath, comparison)) + { + ScheduleReload(); + } + } + + private void OnFileError(object? sender, ErrorEventArgs eventArgs) + { + logger.LogWarning(eventArgs.GetException(), "Users file watcher error encountered."); + } + + private void ScheduleReload() + { + lock (scheduleGate) + { + if (disposed) return; + + // If a delay is already running, do not reset it + if (reloadDelay is not null && !reloadDelay.IsCancellationRequested) + { + return; + } + + reloadDelay = new CancellationTokenSource(); + var delayToken = reloadDelay.Token; + + _ = Task.Run(async () => + { + try + { + await Task.Delay(debounce, delayToken); + } + catch (OperationCanceledException) + { + return; + } + + lock (scheduleGate) + { + if (disposed) return; + reloadDelay?.Dispose(); + reloadDelay = null; + } + + if (Interlocked.CompareExchange(ref reloadQueued, 1, 0) == 0) + { + try + { + await ReloadAsync(); + } + finally + { + Interlocked.Exchange(ref reloadQueued, 0); + } + } + }); + } + } + + private async Task ReloadAsync() + { + UsersFile? users = null; + Exception? lastException = null; + + // Try reading with short backoff (3 attempts, 50ms exponential backoff) + for (var attempt = 0; attempt < 3; attempt++) + { + try + { + users = await Task.Run(() => UsersFile.LoadUsers(usersPath)); + lastException = null; + break; + } + catch (Exception exception) when ( + exception is IOException + or JsonException + or DecoderFallbackException + or InvalidOperationException + or UnauthorizedAccessException) + { + lastException = exception; + await Task.Delay(TimeSpan.FromMilliseconds(50 * (attempt + 1))); + } + } + + if (users is not null) + { + try + { + server.ReplaceUserLookup(users); + logger.LogInformation("Users file reloaded. users={Count}", users.Users.Count); + } + catch (Exception exception) when (exception is InvalidOperationException) + { + logger.LogWarning(exception, "Users file reload validation failed; retaining previous users. path={Path}", usersPath); + } + } + else if (lastException is not null) + { + logger.LogWarning(lastException, "Users file reload failed; retaining previous users. path={Path}", usersPath); + } + } + + public void Dispose() + { + lock (scheduleGate) + { + if (disposed) return; + disposed = true; + + try + { + reloadDelay?.Cancel(); + reloadDelay?.Dispose(); + } + catch { } + + try + { + reloadExecution?.Cancel(); + reloadExecution?.Dispose(); + } + catch { } + + if (watcher is not null) + { + watcher.EnableRaisingEvents = false; + watcher.Changed -= OnFileChanged; + watcher.Created -= OnFileChanged; + watcher.Deleted -= OnFileChanged; + watcher.Renamed -= OnFileRenamed; + watcher.Error -= OnFileError; + watcher.Dispose(); + watcher = null; + } + + pollTimer?.Dispose(); + pollTimer = null; + } + } +} diff --git a/TextCascade.Server/Hub/IConnectionCoordinator.cs b/TextCascade.Server/Hub/IConnectionCoordinator.cs new file mode 100644 index 0000000..dd0cc60 --- /dev/null +++ b/TextCascade.Server/Hub/IConnectionCoordinator.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Logging; + +namespace TextCascade.Server; + +public interface IConnectionCoordinator +{ + ILogger Logger { get; } + + void CancelConnection(ConnectionContext connection, string reason); + + void RebuildHub(UserHub hub); + + void RemoveEmptyHubAfterRecovery(UserHub hub); +} diff --git a/TextCascade.Server/Hub/UserHub.cs b/TextCascade.Server/Hub/UserHub.cs new file mode 100644 index 0000000..1b8b132 --- /dev/null +++ b/TextCascade.Server/Hub/UserHub.cs @@ -0,0 +1,380 @@ +using System.Text; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; + +namespace TextCascade.Server; + +public sealed class UserHub +{ + public string Username { get; } + public LatestText? Latest { get; private set; } + public ulong Version { get; private set; } + public Channel UserChannel { get; } + public TokenBucket ClipBucket { get; } + public SeenIdRing SeenIds { get; } + public DateTimeOffset ProcessStartTime { get; } + public DateTimeOffset LastActivityAt => new(new DateTime(Interlocked.Read(ref lastActivityTicks), DateTimeKind.Utc)); + + private readonly object connectionsGate = new(); + private readonly List connections = new(); + private readonly RuntimeConfig config; + private Task? userLoop; + private readonly object userLoopGate = new(); + private int readerActive; + + private readonly object snapshotGate = new(); + private readonly List snapshotCandidates = new(); + private int snapshotBytes; + private readonly List recoveryQueue = new(); + private bool recoveryWindowClosed; + + private readonly IConnectionCoordinator coordinator; + private readonly RuntimeStateStore runtimeStateStore; + private long lastActivityTicks; + + public UserHub(string username, RuntimeConfig config, DateTimeOffset processStart, IConnectionCoordinator coordinator, RuntimeStateStore runtimeStateStore, ulong initialVersion) + { + Username = username; + this.config = config; + this.coordinator = coordinator; + this.runtimeStateStore = runtimeStateStore; + ProcessStartTime = processStart; + UserChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + ClipBucket = new TokenBucket(config.RateLimit.ClipBurst, config.RateLimit.ClipTokensPerSecond, processStart); + SeenIds = new SeenIdRing(config.Limits.SeenIdCapacity); + Version = initialVersion; + lastActivityTicks = processStart.UtcTicks; + } + + public IReadOnlyList Connections + { + get { lock (connectionsGate) { return connections.ToArray(); } } + } + + public bool IsEmpty + { + get { lock (connectionsGate) { return connections.Count == 0; } } + } + + internal object ScanGate => connectionsGate; + internal List ConnectionList => connections; + internal RuntimeConfig Config => config; + + public void AddConnection(ConnectionContext connection) + { + lock (connectionsGate) { connections.Add(connection); } + MarkActivity(DateTimeOffset.UtcNow); + var nowUtc = DateTimeOffset.UtcNow; + if (recoveryWindowClosed) + { + BroadcastToConnection(connection, Protocol.SerializeWelcome(Latest, config.Limits)); + return; + } + + EnsureRecoveryWindowClosed(nowUtc); + if (recoveryWindowClosed) + { + return; + } + } + + public bool RemoveConnection(ConnectionContext connection) + { + bool removed; + lock (connectionsGate) { removed = connections.Remove(connection); } + if (removed) + { + MarkActivity(DateTimeOffset.UtcNow); + } + + return removed; + } + + public Task StartIfIdle() + { + lock (userLoopGate) + { + if (userLoop is not null && !userLoop.IsCompleted) + { + return userLoop; + } + + userLoop = Task.Run(async () => + { + try + { + await RunUserLoopAsync(); + } + catch (OperationCanceledException) + { + } + catch (Exception exception) + { + coordinator.Logger.LogError( + exception, + "User loop failed; rebuilding hub. username={Username}", + Username); + coordinator.RebuildHub(this); + } + }); + return userLoop; + } + } + public bool TryWriteJob(UserJob job) => UserChannel.Writer.TryWrite(job); + + public async Task RunUserLoopAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.CompareExchange(ref readerActive, 1, 0) != 0) + { + throw new InvalidOperationException("User loop is already running."); + } + + try + { + var reader = UserChannel.Reader; + while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + while (reader.TryRead(out var job)) + { + ProcessJob(job, DateTimeOffset.UtcNow); + } + } + } + finally + { + Interlocked.Exchange(ref readerActive, 0); + } + } + private void ProcessJob(UserJob job, DateTimeOffset nowUtc) + { + switch (job) + { + case ClipJob clipJob: + ApplyClip(clipJob.Clip, clipJob.Sender, nowUtc); + break; + case PongJob pongJob: + pongJob.Connection.State.LastSeen = nowUtc; + break; + case HelloJob helloJob: + helloJob.Connection.State.HelloReceived = true; + if (helloJob.Hello.Snapshot is not null) + { + AcceptSnapshot(helloJob.Hello); + } + break; + case DisconnectJob disconnectJob: + coordinator.CancelConnection(disconnectJob.Connection, disconnectJob.Reason); + break; + } + } + + public void AcceptSnapshot(ClientHello hello) + { + lock (snapshotGate) + { + if (recoveryWindowClosed) return; + if (hello.Snapshot is null) return; + var bytes = Encoding.UTF8.GetByteCount(hello.Snapshot.Payload); + if (snapshotBytes + bytes > config.Limits.SnapshotTotalBytes) return; + snapshotCandidates.Add(hello); + snapshotBytes += bytes; + } + } + + public RecoveryDecision ClassifyClip(ClientClip clip, ConnectionContext connection) + { + lock (snapshotGate) + { + if (recoveryWindowClosed) + { + return RecoveryDecision.ProcessNow; + } + + if (recoveryQueue.Count >= config.Limits.RecoveryClipQueueCapacity) + { + return RecoveryDecision.QueueFull; + } + + recoveryQueue.Add(new RecoveryClip(clip, connection)); + return RecoveryDecision.Queued; + } + } + + public void CloseRecoveryWindow(DateTimeOffset nowUtc) + { + List clips; + SnapshotWinner? winner; + lock (snapshotGate) + { + if (recoveryWindowClosed) return; + recoveryWindowClosed = true; + winner = CoreLogic.SelectSnapshotWinner(snapshotCandidates); + if (winner is not null) + { + var canRestoreLatest = winner.Version > Version + || (winner.Version == Version && Latest is null); + if (!canRestoreLatest) + { + winner = null; + } + else + { + if (winner.Version > Version) + { + runtimeStateStore.SaveVersion(Username, winner.Version); + } + Version = winner.Version; + Latest = new LatestText(winner.Snapshot.Payload, winner.Version, winner.Snapshot.Hash, winner.Snapshot.Encrypted, winner.ClientId, winner.ClientName, winner.Snapshot.LocalModifiedAtUtc); + } + } + clips = recoveryQueue.ToList(); + recoveryQueue.Clear(); + } + + foreach (var recovery in clips) + { + if (recovery.Connection.State.IsClosed) continue; + ApplyClip(recovery.Clip, recovery.Connection, nowUtc); + } + + BroadcastWelcome(nowUtc); + + // Spec §6.2: empty hubs that survived until the recovery window closes are now removed. + coordinator.RemoveEmptyHubAfterRecovery(this); + + MarkActivity(nowUtc); + } + + private void BroadcastWelcome(DateTimeOffset nowUtc) + { + var bytes = Protocol.SerializeWelcome(Latest, config.Limits); + foreach (var connection in Connections) + { + if (!connection.State.TryEnqueueSend(bytes) && connection.State.MarkClosed()) + { + connection.State.Cts.Cancel(); + } + } + } + + public bool IsRecoveryWindowOpen(DateTimeOffset nowUtc) + { + return !recoveryWindowClosed && nowUtc < ProcessStartTime.AddSeconds(config.Limits.SnapshotWindowSeconds); + } + + public void EnsureRecoveryWindowClosed(DateTimeOffset nowUtc) + { + if (!recoveryWindowClosed && nowUtc >= ProcessStartTime.AddSeconds(config.Limits.SnapshotWindowSeconds)) + { + CloseRecoveryWindow(nowUtc); + } + } + + private void MarkActivity(DateTimeOffset nowUtc) + { + Interlocked.Exchange(ref lastActivityTicks, nowUtc.UtcTicks); + } + + internal void MarkActivityForScan(DateTimeOffset nowUtc) => MarkActivity(nowUtc); + + public void ApplyClip(ClientClip clip, ConnectionContext sender, DateTimeOffset nowUtc) + { + if (SeenIds.IsUnchangedDuplicate(clip.Id, clip.Payload, clip.Hash, clip.Encrypted, out var duplicateLatest)) + { + var ackBytes = Protocol.SerializeClipAck(clip.Id, duplicateLatest ?? Latest ?? new LatestText(string.Empty, Version, string.Empty, false, sender.ClientId, sender.ClientName, nowUtc)); + if (!sender.State.TryEnqueueSend(ackBytes) && sender.State.MarkClosed()) + { + sender.State.Cts.Cancel(); + } + return; + } + + if (SeenIds.TryGetResult(clip.Id, out _)) + { + coordinator.Logger.LogWarning( + "Replacing reused clip id. username={Username} clipId={ClipId} clientId={ClientId} previousVersion={PreviousVersion}", + Username, + clip.Id, + sender.ClientId, + Version); + } + + if (!ClipBucket.TryAcquire(nowUtc)) + { + coordinator.Logger.LogSecurityEvent("reject", + ("username", Username), + ("code", "rate_limited"), + ("bytes", Encoding.UTF8.GetByteCount(clip.Payload))); + var error = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.RateLimited, "Clip rate limited.", clip.Id)); + if (!sender.State.TryEnqueueSend(error) && sender.State.MarkClosed()) + { + sender.State.Cts.Cancel(); + } + return; + } + + var next = CoreLogic.NextVersion(Version); + runtimeStateStore.SaveVersion(Username, next); + Version = next; + var latest = new LatestText(clip.Payload, next, clip.Hash, clip.Encrypted, sender.ClientId, sender.ClientName, nowUtc); + Latest = latest; + SeenIds.RememberId(clip.Id, latest); + coordinator.Logger.LogSecurityEvent("clip", + ("username", Username), + ("version", latest.Version), + ("clipId", clip.Id), + ("bytes", Encoding.UTF8.GetByteCount(clip.Payload)), + ("fromClientId", sender.ClientId), + ("encrypted", clip.Encrypted)); + + var broadcastBytes = Protocol.SerializeClip(clip.Id, latest); + var deliveries = new List(); + foreach (var connection in Connections) + { + if (ReferenceEquals(connection, sender)) continue; + var queued = connection.State.TryEnqueueSend(broadcastBytes); + deliveries.Add($"{connection.ClientId}:{(queued ? "queued" : "full")}"); + if (!queued && connection.State.MarkClosed()) + { + connection.State.Cts.Cancel(); + } + } + + coordinator.Logger.LogInformation( + "Clip broadcast. username={Username} version={Version} clipId={ClipId} recipients=[{Recipients}]", + Username, + next, + clip.Id, + string.Join(",", deliveries)); + + var ackBytesFinal = Protocol.SerializeClipAck(clip.Id, latest); + if (!sender.State.TryEnqueueSend(ackBytesFinal) && sender.State.MarkClosed()) + { + sender.State.Cts.Cancel(); + } + } + + private static void BroadcastToConnection(ConnectionContext connection, byte[] payload) + { + if (!connection.State.TryEnqueueSend(payload) && connection.State.MarkClosed()) + { + try { connection.State.Cts.Cancel(); } catch (Exception) { } + } + } + + public void BroadcastAsync(byte[] payload) + { + foreach (var connection in Connections) + { + if (connection.State.IsClosed) continue; + if (!connection.State.TryEnqueueSend(payload) && connection.State.MarkClosed()) + { + try { connection.State.Cts.Cancel(); } catch (Exception) { } + } + } + } +} + + + + diff --git a/TextCascade.Server/Hub/UserJobs.cs b/TextCascade.Server/Hub/UserJobs.cs new file mode 100644 index 0000000..470bd10 --- /dev/null +++ b/TextCascade.Server/Hub/UserJobs.cs @@ -0,0 +1,21 @@ +namespace TextCascade.Server; + +public readonly record struct RecoveryClip(ClientClip Clip, ConnectionContext Connection); + +public enum RecoveryDecision +{ + Queued, + ProcessNow, + QueueFull, +} + +public abstract record UserJob; + +public sealed record ClipJob(ConnectionContext Sender, ClientClip Clip) : UserJob; + +public sealed record HelloJob(ConnectionContext Connection, ClientHello Hello) : UserJob; + +public sealed record PongJob(ConnectionContext Connection, ClientPong Pong) : UserJob; + +public sealed record DisconnectJob(ConnectionContext Connection, string Reason) : UserJob; + diff --git a/TextCascade.Server/Hub/UserRegistry.cs b/TextCascade.Server/Hub/UserRegistry.cs new file mode 100644 index 0000000..508fb8b --- /dev/null +++ b/TextCascade.Server/Hub/UserRegistry.cs @@ -0,0 +1,29 @@ +using System.Collections.Concurrent; + +namespace TextCascade.Server; + +public sealed class UserRegistry +{ + private readonly ConcurrentDictionary hubs = new(StringComparer.Ordinal); + public IEnumerable> All => hubs; + + public UserHub GetOrAdd(string username, Func factory) + { + return hubs.GetOrAdd(username, factory); + } + + public bool TryGetValue(string username, out UserHub hub) => hubs.TryGetValue(username, out hub!); + + public void RemoveIfEmpty(UserHub hub, bool allowDuringRecovery) + { + if (!hub.IsEmpty) return; + if (!allowDuringRecovery && hub.IsRecoveryWindowOpen(DateTimeOffset.UtcNow)) return; + hubs.TryRemove(hub.Username, out _); + } + + public bool Remove(UserHub hub) + { + return hubs.TryRemove(new KeyValuePair(hub.Username, hub)); + } +} + diff --git a/TextCascade.Server/Models/ConnectionContext.cs b/TextCascade.Server/Models/ConnectionContext.cs new file mode 100644 index 0000000..d9d6ede --- /dev/null +++ b/TextCascade.Server/Models/ConnectionContext.cs @@ -0,0 +1,26 @@ +using System.Net.WebSockets; + +namespace TextCascade.Server; + +public sealed class ConnectionContext +{ + public string ConnectionId { get; } + public string Username { get; } + public string ClientId { get; } + public string ClientName { get; } + public WebSocket Socket { get; } + public UserHub? Hub { get; internal set; } + public ConnectionStateBag State { get; } + + public ConnectionContext(string connectionId, string username, string clientId, string clientName, WebSocket socket, UserHub? hub, RuntimeConfig config) + { + ConnectionId = connectionId; + Username = username; + ClientId = clientId; + ClientName = clientName; + Socket = socket; + Hub = hub; + State = new ConnectionStateBag(config); + } +} + diff --git a/TextCascade.Server/Models/ConnectionStateBag.cs b/TextCascade.Server/Models/ConnectionStateBag.cs new file mode 100644 index 0000000..f35acaf --- /dev/null +++ b/TextCascade.Server/Models/ConnectionStateBag.cs @@ -0,0 +1,88 @@ +using System.Threading.Channels; + +namespace TextCascade.Server; + +public sealed class ConnectionStateBag +{ + private readonly object gate = new(); + private DateTimeOffset lastSeen; + private DateTimeOffset lastPingAt; + private bool closed; + private bool helloTimeoutStarted; + private bool pongAwaited; + public Channel SendQueue { get; } + public CancellationTokenSource Cts { get; } + public bool HelloReceived { get; internal set; } + public DateTimeOffset? HelloDeadline { get; internal set; } + + public DateTimeOffset LastSeen + { + get { lock (gate) { return lastSeen; } } + internal set { lock (gate) { lastSeen = value; } } + } + + public DateTimeOffset LastPingAt + { + get { lock (gate) { return lastPingAt; } } + internal set { lock (gate) { lastPingAt = value; } } + } + + public void MarkPingAwaitingPong() + { + lock (gate) { pongAwaited = true; } + } + + public bool TryTakePongAwaiting() + { + lock (gate) + { + if (!pongAwaited) return false; + pongAwaited = false; + return true; + } + } + + public bool IsClosed + { + get { lock (gate) { return closed; } } + } + + public bool MarkClosed() + { + lock (gate) + { + if (closed) return false; + closed = true; + return true; + } + } + + public bool TryStartHelloTimeout() + { + lock (gate) + { + if (helloTimeoutStarted || closed) + { + return false; + } + + helloTimeoutStarted = true; + return true; + } + } + + public ConnectionStateBag(RuntimeConfig config) + { + lastSeen = DateTimeOffset.UtcNow; + lastPingAt = lastSeen; + SendQueue = Channel.CreateBounded(config.Limits.SendQueueCapacity); + Cts = new CancellationTokenSource(); + HelloDeadline = DateTimeOffset.UtcNow.AddSeconds(config.Limits.HelloTimeoutSeconds); + } + + public bool TryEnqueueSend(byte[] payload) + { + return SendQueue.Writer.TryWrite(payload); + } +} + diff --git a/TextCascade.Server/Models/ReceivedMessage.cs b/TextCascade.Server/Models/ReceivedMessage.cs new file mode 100644 index 0000000..f276026 --- /dev/null +++ b/TextCascade.Server/Models/ReceivedMessage.cs @@ -0,0 +1,5 @@ +using System.Net.WebSockets; + +namespace TextCascade.Server; + +internal sealed record ReceivedMessage(WebSocketMessageType MessageType, ReadOnlyMemory Payload); diff --git a/TextCascade.Server/Protocol.cs b/TextCascade.Server/Protocol.cs index bb03e17..bf63bfa 100644 --- a/TextCascade.Server/Protocol.cs +++ b/TextCascade.Server/Protocol.cs @@ -110,7 +110,20 @@ public static LatestText From(ClipSnapshot snapshot, ulong version, string clien [JsonSerializable(typeof(PingMessage))] [JsonSerializable(typeof(ByeMessage))] [JsonSerializable(typeof(ProtocolErrorMessage))] -internal sealed partial class ServerJsonContext : JsonSerializerContext; +[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +internal sealed partial class ServerJsonContext : JsonSerializerContext +{ + public static ServerJsonContext Configured { get; } + + public static JsonSerializerOptions SerializationOptions { get; } = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + Converters = { new UtcSecondDateTimeConverter() }, + }; + + static ServerJsonContext() => Configured = new ServerJsonContext(SerializationOptions); +} public sealed record WelcomeMessage( [property: JsonPropertyName("type")] string Type, @@ -148,6 +161,17 @@ public sealed record ProtocolErrorMessage( [property: JsonPropertyName("message")] string Message, [property: JsonPropertyName("referenceId")] string? ReferenceId); +internal sealed class UtcSecondDateTimeConverter : JsonConverter +{ + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.TokenType == JsonTokenType.String && DateTimeOffset.TryParse(reader.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.None, out var value) + ? value.ToUniversalTime() + : throw new JsonException("Invalid date/time value."); + + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture)); +} + public static class Protocol { public const int ProtocolVersion = 1; @@ -158,19 +182,13 @@ public static class Protocol public const int MaxHashBytes = 4096; - private static readonly JsonSerializerOptions WriteOptions = new(JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false, - }; - public static byte[] SerializeMessage(T message, JsonTypeInfo typeInfo) => JsonSerializer.SerializeToUtf8Bytes(message, typeInfo); - public static byte[] SerializeWelcome(LatestText? latest) + public static byte[] SerializeWelcome(LatestText? latest, LimitsConfig _) { var message = new WelcomeMessage("welcome", ProtocolVersion, latest); - return SerializeMessage(message, ServerJsonContext.Default.WelcomeMessage); + return SerializeMessage(message, ServerJsonContext.Configured.WelcomeMessage); } public static byte[] SerializeClip(string id, LatestText latest) @@ -185,27 +203,27 @@ public static byte[] SerializeClip(string id, LatestText latest) latest.FromClientId, latest.FromClientName, latest.UpdatedAtUtc); - return SerializeMessage(message, ServerJsonContext.Default.ClipMessage); + return SerializeMessage(message, ServerJsonContext.Configured.ClipMessage); } public static byte[] SerializeClipAck(string id, LatestText latest) { var message = new ClipAckMessage("clip_ack", id, latest.Version, latest.UpdatedAtUtc); - return SerializeMessage(message, ServerJsonContext.Default.ClipAckMessage); + return SerializeMessage(message, ServerJsonContext.Configured.ClipAckMessage); } public static byte[] SerializePing(DateTimeOffset nowUtc) => - SerializeMessage(new PingMessage("ping", nowUtc), ServerJsonContext.Default.PingMessage); + SerializeMessage(new PingMessage("ping", nowUtc), ServerJsonContext.Configured.PingMessage); public static byte[] SerializeBye(string reason = "server_shutdown") => - SerializeMessage(new ByeMessage("bye", reason), ServerJsonContext.Default.ByeMessage); + SerializeMessage(new ByeMessage("bye", reason), ServerJsonContext.Configured.ByeMessage); public static byte[] SerializeProtocolError(ProtocolError error) => SerializeMessage( new ProtocolErrorMessage("error", error.CodeName, error.Message, error.ReferenceId), - ServerJsonContext.Default.ProtocolErrorMessage); + ServerJsonContext.Configured.ProtocolErrorMessage); - public static byte[] SerializeLoginResponse(AuthToken token, bool needsRehash = false) + public static byte[] SerializeLoginResponse(AuthToken token, RuntimeConfig config, bool needsRehash = false) { using var stream = new MemoryStream(); using var writer = new Utf8JsonWriter(stream); @@ -213,10 +231,10 @@ public static byte[] SerializeLoginResponse(AuthToken token, bool needsRehash = writer.WriteString("token", token.CompactToken); writer.WriteString("expiresAtUtc", DateTimeOffset.FromUnixTimeSeconds(token.Payload.ExpiresAtUnix).ToUniversalTime().ToString("O")); writer.WriteNumber("protocolVersion", ProtocolVersion); - writer.WriteNumber("maxTextBytes", RuntimeConfigAccessor.Current?.Limits.MaxTextBytes ?? 524288); - writer.WriteNumber("helloTimeoutSeconds", RuntimeConfigAccessor.Current?.Limits.HelloTimeoutSeconds ?? 5); - writer.WriteNumber("heartbeatIntervalSeconds", RuntimeConfigAccessor.Current?.Limits.HeartbeatIntervalSeconds ?? 30); - writer.WriteNumber("heartbeatTimeoutSeconds", RuntimeConfigAccessor.Current?.Limits.HeartbeatTimeoutSeconds ?? 60); + writer.WriteNumber("maxTextBytes", config.Limits.MaxTextBytes); + writer.WriteNumber("helloTimeoutSeconds", config.Limits.HelloTimeoutSeconds); + writer.WriteNumber("heartbeatIntervalSeconds", config.Limits.HeartbeatIntervalSeconds); + writer.WriteNumber("heartbeatTimeoutSeconds", config.Limits.HeartbeatTimeoutSeconds); if (needsRehash) { writer.WriteBoolean("needsRehash", true); @@ -226,7 +244,7 @@ public static byte[] SerializeLoginResponse(AuthToken token, bool needsRehash = return stream.ToArray(); } - public static ParseResult ParseClientMessage(ReadOnlySpan frame, RuntimeConfig config) + public static ParseResult ParseClientMessage(ReadOnlyMemory frame, RuntimeConfig config) { using var document = TryParseJson(frame, out var parseError); if (parseError is not null) @@ -404,7 +422,6 @@ private static bool ValidateClipSnapshot(ClipSnapshot snapshot, RuntimeConfig co { return snapshot.Payload.Length > 0 && Encoding.UTF8.GetByteCount(snapshot.Payload) <= config.Limits.MaxTextBytes - && snapshot.Hash.Length > 0 && Encoding.UTF8.GetByteCount(snapshot.Hash) <= MaxHashBytes && snapshot.LocalModifiedAtUtc.Offset == TimeSpan.Zero; } @@ -441,11 +458,11 @@ private static bool ValidatePayloadSize(string payload, RuntimeConfig config, ou return true; } - private static JsonDocument? TryParseJson(ReadOnlySpan frame, out ProtocolError? error) + private static JsonDocument? TryParseJson(ReadOnlyMemory frame, out ProtocolError? error) { try { - var document = JsonDocument.Parse(frame.ToArray(), new JsonDocumentOptions + var document = JsonDocument.Parse(frame, new JsonDocumentOptions { AllowTrailingCommas = false, CommentHandling = JsonCommentHandling.Disallow, @@ -580,3 +597,4 @@ private ParseResult(MessageKind kind, object? message, ProtocolError? error) public static ParseResult Failure(ProtocolError error) => new(MessageKind.Unknown, null, error); } + diff --git a/TextCascade.Server/RuntimeConfig.cs b/TextCascade.Server/RuntimeConfig.cs index a182d28..9071a24 100644 --- a/TextCascade.Server/RuntimeConfig.cs +++ b/TextCascade.Server/RuntimeConfig.cs @@ -36,7 +36,7 @@ public sealed record RateLimitConfig( int ClipBurst, int ClipTokensPerSecond); -public sealed record FilesConfig(string UsersFile); +public sealed record FilesConfig(string UsersFile, string StateFile); public sealed record RuntimeConfig( ServerConfig Server, @@ -46,17 +46,6 @@ public sealed record RuntimeConfig( FilesConfig Files, byte[]? TokenSecret = null); -public static class RuntimeConfigAccessor -{ - private static RuntimeConfig? current; - - public static RuntimeConfig? Current - { - get => current; - set => current = value; - } -} - public static class Config { public static RuntimeConfig CreateDefaultConfig() => new( @@ -64,7 +53,7 @@ public static class Config new AuthConfig(30, "TEXTCASCADE_TOKEN_SECRET", 19456, 2, 1), new LimitsConfig(524288, 589824, 16, 64, 5, 30, 60, 3, 4194304, 16), new RateLimitConfig(10, 5, 10000, 10, 2), - new FilesConfig("users.json")); + new FilesConfig("users.json", "textcascade.state.json")); public static RuntimeConfig LoadTomlConfig(string path, RuntimeConfig? defaults = null) { @@ -158,8 +147,13 @@ private static RuntimeConfig ApplyTomlModel(RuntimeConfig config, TomlTable mode if (TryGetTable(model, "files", out var files)) { - WarnUnknownKeys(files, "files", new[] { "users_file" }); - config = config with { Files = new FilesConfig(GetString(files, "users_file", config.Files.UsersFile, "files.users_file")) }; + WarnUnknownKeys(files, "files", new[] { "users_file", "state_file" }); + config = config with + { + Files = new FilesConfig( + GetString(files, "users_file", config.Files.UsersFile, "files.users_file"), + GetString(files, "state_file", config.Files.StateFile, "files.state_file")), + }; } return config; @@ -239,6 +233,11 @@ public static RuntimeConfig ApplyEnvironmentOverrides(RuntimeConfig config) config = config with { Files = config.Files with { UsersFile = usersFile } }; } + if (Environment.GetEnvironmentVariable("TEXTCASCADE_STATE_FILE") is { Length: > 0 } stateFile) + { + config = config with { Files = config.Files with { StateFile = stateFile } }; + } + if (Environment.GetEnvironmentVariable(config.Auth.TokenSecretEnv) is { Length: > 0 } secretText) { var secret = Encoding.UTF8.GetBytes(secretText); @@ -315,5 +314,10 @@ public static void ValidateConfig(RuntimeConfig config) { throw new InvalidOperationException("files.users_file must not be empty."); } + + if (string.IsNullOrWhiteSpace(config.Files.StateFile)) + { + throw new InvalidOperationException("files.state_file must not be empty."); + } } } diff --git a/TextCascade.Server/RuntimeStateStore.cs b/TextCascade.Server/RuntimeStateStore.cs new file mode 100644 index 0000000..13242c0 --- /dev/null +++ b/TextCascade.Server/RuntimeStateStore.cs @@ -0,0 +1,199 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace TextCascade.Server; + +public sealed record RuntimeStateEntry(string Username, ulong Version); + +internal sealed record RuntimeStateFile(IReadOnlyList Entries); + +public sealed class RuntimeStateStore : IDisposable +{ + private readonly string path; + private readonly ILogger? logger; + private readonly ConcurrentDictionary versions; + private int isDirty; + private readonly object writeGate = new(); + + private readonly PeriodicTimer? flushTimer; + private readonly CancellationTokenSource? cts; + private readonly Task? flushLoopTask; + private bool disposed; + + public RuntimeStateStore( + string path, + TimeSpan? flushInterval = null, + ILogger? logger = null) + { + this.path = path; + this.logger = logger; + this.versions = new ConcurrentDictionary(Load(path), StringComparer.Ordinal); + + var interval = flushInterval ?? TimeSpan.FromSeconds(5); + if (interval > TimeSpan.Zero) + { + flushTimer = new PeriodicTimer(interval); + cts = new CancellationTokenSource(); + flushLoopTask = Task.Run(() => RunFlushLoopAsync(cts.Token)); + } + } + + public ulong GetVersion(string username) + { + return versions.TryGetValue(username, out var version) ? version : 0UL; + } + + public void SaveVersion(string username, ulong version) + { + versions.AddOrUpdate( + username, + static (_, newVer) => newVer, + static (_, current, newVer) => newVer > current ? newVer : current, + version); + Volatile.Write(ref isDirty, 1); + } + + public bool Flush() + { + if (Interlocked.Exchange(ref isDirty, 0) == 0) + { + return false; + } + + lock (writeGate) + { + try + { + var entries = versions + .Select(pair => new RuntimeStateEntry(pair.Key, pair.Value)) + .OrderBy(pair => pair.Username, StringComparer.Ordinal) + .ToList(); + WriteAtomic(path, entries); + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Volatile.Write(ref isDirty, 1); + logger?.LogWarning(exception, "Failed to write runtime state file; will retry in next flush cycle. path={Path}", path); + return false; + } + } + } + + private async Task RunFlushLoopAsync(CancellationToken cancellationToken) + { + if (flushTimer is null) return; + try + { + while (await flushTimer.WaitForNextTickAsync(cancellationToken)) + { + Flush(); + } + } + catch (OperationCanceledException) + { + } + } + + public void Dispose() + { + if (disposed) return; + disposed = true; + + if (cts is not null) + { + cts.Cancel(); + try { flushLoopTask?.GetAwaiter().GetResult(); } catch { } + cts.Dispose(); + } + + flushTimer?.Dispose(); + Flush(); + } + + private static Dictionary Load(string path) + { + if (!File.Exists(path)) + { + return new Dictionary(StringComparer.Ordinal); + } + + try + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Disallow, + }; + using var stream = File.OpenRead(path); + var state = JsonSerializer.Deserialize(stream, options); + if (state is null) + { + throw new JsonException("State file is empty."); + } + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var entry in state.Entries) + { + if (string.IsNullOrWhiteSpace(entry.Username) || entry.Version == 0 || !result.TryAdd(entry.Username, entry.Version)) + { + throw new InvalidOperationException("State file contains duplicate, empty, or zero versions."); + } + } + + return result; + } + catch (Exception exception) when (exception is JsonException or InvalidOperationException) + { + throw new InvalidOperationException($"Invalid runtime state file '{path}': {exception.Message}", exception); + } + } + + private static void WriteAtomic(string path, IReadOnlyList entries) + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + var json = JsonSerializer.Serialize(new RuntimeStateFile(entries), options); + var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + using (var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))) + { + writer.Write(json); + writer.Flush(); + stream.Flush(flushToDisk: true); + } + + if (File.Exists(path)) + { + try + { + File.Replace(temporary, path, destinationBackupFileName: null, ignoreMetadataErrors: true); + } + catch (PlatformNotSupportedException) + { + File.Move(temporary, path, overwrite: true); + } + } + else + { + File.Move(temporary, path); + } + } + finally + { + if (File.Exists(temporary)) + { + File.Delete(temporary); + } + } + } +} \ No newline at end of file diff --git a/TextCascade.Server/ServerHost.cs b/TextCascade.Server/ServerHost.cs index 4b9db50..128ddd8 100644 --- a/TextCascade.Server/ServerHost.cs +++ b/TextCascade.Server/ServerHost.cs @@ -1,8 +1,7 @@ -using System.Net; +using System.Net; using System.Security.Cryptography; using System.Text; using System.Text.Json; -using System.Text.RegularExpressions; using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -27,6 +26,8 @@ public static int RunServer(string[] args) } RuntimeConfig config; + UsersFile users; + RuntimeStateStore stateStore; try { var configPath = args.Length > 0 && args[0] == "--config" ? args[1] : "textcascade.toml"; @@ -34,10 +35,10 @@ public static int RunServer(string[] args) config = Config.LoadTomlConfig(configPath, config); config = Config.ApplyEnvironmentOverrides(config); Config.ValidateConfig(config); - var users = UsersFile.LoadUsers(config.Files.UsersFile); - SyncServer.Instance.Initialize(config, users); + users = UsersFile.LoadUsers(config.Files.UsersFile); + stateStore = new RuntimeStateStore(config.Files.StateFile); } - catch (Exception exception) when (exception is InvalidOperationException or JsonException or DecoderFallbackException) + catch (Exception exception) when (exception is InvalidOperationException or JsonException or DecoderFallbackException or IOException) { Console.Error.WriteLine($"Configuration error: {exception.Message}"); return Error; @@ -56,28 +57,60 @@ public static int RunServer(string[] args) using (certificate) { - RuntimeConfigAccessor.Current = config; + var app = CreateApp(args, config, users, stateStore, hasher: null, clock: null, certificate: certificate); + using var userFileWatcher = new UserFileWatcher(config.Files.UsersFile, app.Services.GetRequiredService(), app.Logger); + userFileWatcher.Start(); + app.Run(); + } + return Ok; + } + + public static WebApplication CreateApp( + string[] args, + RuntimeConfig config, + UsersFile users, + RuntimeStateStore stateStore, + IPasswordHasher? hasher = null, + TimeProvider? clock = null, + LoadedCertificate? certificate = null) + { var builder = WebApplication.CreateBuilder(args); - builder.WebHost.UseKestrel(ConfigureKestrel(config, certificate)); + if (certificate is not null) + { + builder.WebHost.UseKestrel(ConfigureKestrel(config, certificate)); + } + builder.Logging.ClearProviders(); builder.Logging.AddSimpleConsole(options => { options.SingleLine = true; options.TimestampFormat = "yyyy-MM-ddTHH:mm:ssZ "; }); + + builder.Services.AddSingleton(hasher ?? new Argon2PasswordHasher()); + builder.Services.AddSingleton(clock ?? TimeProvider.System); + builder.Services.AddSingleton(stateStore); + builder.Services.AddSingleton(serviceProvider => new SyncServer( + config, + users, + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService>())); builder.Services.AddHostedService(); var app = builder.Build(); - SyncServer.Instance.Logger = app.Logger; app.UseWebSockets(); app.MapGet("/health", () => Results.Json(new { status = "ok" })); - app.MapPost("/api/v1/login", async context => await AuthService.HandleLoginAsync(context, config, app.Logger)); - app.MapGet("/api/v1/sync", async context => await SyncEndpoint.HandleAsync(context, config)); + app.MapPost("/api/v1/login", async context => await AuthService.HandleLoginAsync( + context, + config, + context.RequestServices.GetRequiredService(), + app.Logger)); + app.MapGet("/api/v1/sync", async context => await SyncEndpoint.HandleAsync(context, config, context.RequestServices.GetRequiredService())); app.MapMethods("/health", new[] { "HEAD" }, () => Results.Json(new { status = "ok" })); - app.Run(); - } - return Ok; + return app; } private static Action ConfigureKestrel(RuntimeConfig config, LoadedCertificate certificate) @@ -118,7 +151,12 @@ public static LoadedCertificate Load(string path) else if (path.EndsWith(".pfx", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".p12", StringComparison.OrdinalIgnoreCase)) { - var chain = X509CertificateLoader.LoadPkcs12CollectionFromFile(path, password: null, X509KeyStorageFlags.EphemeralKeySet); + // SChannel cannot perform server-side TLS with an ephemeral key; keep the + // key persisted on Windows and ephemeral elsewhere (spec §2.1 supports both hosts). + var keyStorageFlags = OperatingSystem.IsWindows() + ? X509KeyStorageFlags.DefaultKeySet + : X509KeyStorageFlags.EphemeralKeySet; + var chain = X509CertificateLoader.LoadPkcs12CollectionFromFile(path, password: null, keyStorageFlags); var certificateWithKey = chain.Cast().FirstOrDefault(item => item.HasPrivateKey); if (certificateWithKey is null) { @@ -141,64 +179,48 @@ private static LoadedCertificate LoadPemCertificate(string certificatePath) var keyPath = File.Exists(Path.ChangeExtension(certificatePath, ".key")) ? Path.ChangeExtension(certificatePath, ".key") : certificatePath; + var chain = new X509Certificate2Collection(); try { - var certificatePem = File.ReadAllText(certificatePath, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true)); - foreach (Match match in Regex.Matches(certificatePem, "-----BEGIN CERTIFICATE-----(?.*?)-----END CERTIFICATE-----", RegexOptions.Singleline)) - { - var base64 = Regex.Replace(match.Groups["data"].Value, "\\s", string.Empty); - var certificateBytes = Convert.FromBase64String(base64); - chain.Add(X509CertificateLoader.LoadCertificate(certificateBytes)); - } - + chain.ImportFromPemFile(certificatePath); if (chain.Count == 0) { throw new InvalidOperationException("PEM file does not contain a certificate."); } - } - catch (Exception exception) - { - DisposeChain(chain); - throw new InvalidOperationException($"Unable to parse PEM certificate '{certificatePath}': {exception.Message}", exception); - } - string keyPem; - try - { - keyPem = File.ReadAllText(keyPath, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true)); - } - catch (Exception exception) - { - DisposeChain(chain); - throw new InvalidOperationException($"Unable to read PEM private key '{keyPath}': {exception.Message}", exception); - } - - try - { - using var rsa = RSA.Create(); - rsa.ImportFromPem(keyPem); - var certificateWithKey = chain[0].CopyWithPrivateKey(rsa); - chain[0].Dispose(); - chain[0] = certificateWithKey; - return new LoadedCertificate(certificateWithKey, chain); - } - catch (Exception rsaException) - { - try + var certificateWithKey = X509Certificate2.CreateFromPemFile(certificatePath, keyPath); + if (OperatingSystem.IsWindows()) { - using var ecdsa = ECDsa.Create(); - ecdsa.ImportFromPem(keyPem); - var certificateWithKey = chain[0].CopyWithPrivateKey(ecdsa); - chain[0].Dispose(); - chain[0] = certificateWithKey; - return new LoadedCertificate(certificateWithKey, chain); + // Re-import via a PFX export so the private key is persisted; SChannel + // rejects the ephemeral key produced by CreateFromPemFile on Windows. + using var ephemeral = certificateWithKey; + certificateWithKey = new X509Certificate2(certificateWithKey.Export(X509ContentType.Pfx)); } - catch (Exception ecdsaException) + var originalLeaf = chain.Cast().FirstOrDefault(c => c.Equals(certificateWithKey)); + if (originalLeaf is not null) { - DisposeChain(chain); - throw new InvalidOperationException($"Unable to bind PEM private key. RSA: {rsaException.Message}; ECDSA: {ecdsaException.Message}", ecdsaException); + var originalIndex = chain.IndexOf(originalLeaf); + chain[originalIndex] = certificateWithKey; + originalLeaf.Dispose(); + } + else + { + chain.Insert(0, certificateWithKey); } + + return new LoadedCertificate(certificateWithKey, chain); + } + catch (Exception exception) when ( + exception is ArgumentException + or CryptographicException + or InvalidOperationException + or IOException) + { + DisposeChain(chain); + throw new InvalidOperationException( + $"Unable to load PEM certificate '{certificatePath}': {exception.Message}", + exception); } } @@ -211,7 +233,7 @@ private static void DisposeChain(X509Certificate2Collection chain) } } -internal sealed class LoadedCertificate : IDisposable +public sealed class LoadedCertificate : IDisposable { public LoadedCertificate(X509Certificate2 certificate, X509Certificate2Collection chain) { @@ -232,42 +254,4 @@ public void Dispose() } } -public sealed class HeartbeatScannerService : IHostedService, IDisposable -{ - private Timer? timer; - - public Task StartAsync(CancellationToken cancellationToken) - { - timer = new Timer(Scan, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); - return Task.CompletedTask; - } - - private static void Scan(object? state) - { - var now = DateTimeOffset.UtcNow; - SyncServer.Instance.ScanHeartbeats(now); - var recoveryEnd = SyncServer.Instance.ProcessStartTime.AddSeconds( - SyncServer.Instance.Config.Limits.SnapshotWindowSeconds); - if (now < recoveryEnd) - { - return; - } - - foreach (var pair in SyncServer.Instance.Registry.All) - { - pair.Value.CloseRecoveryWindow(now); - } - } - - public async Task StopAsync(CancellationToken cancellationToken) - { - timer?.Change(Timeout.Infinite, 0); - await SyncServer.Instance.ShutdownAsync(TimeSpan.FromSeconds(2), DateTimeOffset.UtcNow); - } - - public void Dispose() - { - timer?.Dispose(); - } -} diff --git a/TextCascade.Server/SyncServer.cs b/TextCascade.Server/SyncServer.cs index 486d7bb..dd6b36c 100644 --- a/TextCascade.Server/SyncServer.cs +++ b/TextCascade.Server/SyncServer.cs @@ -1,498 +1,131 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Net.WebSockets; using System.Text; -using System.Threading.Channels; -using System.Globalization; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace TextCascade.Server; -public sealed class ConnectionContext +public sealed class SyncServer : IConnectionCoordinator { - public string ConnectionId { get; } - public string Username { get; } - public string ClientId { get; } - public string ClientName { get; } - public WebSocket Socket { get; } - public UserHub? Hub { get; internal set; } - public ConnectionStateBag State { get; } - - public ConnectionContext(string connectionId, string username, string clientId, string clientName, WebSocket socket, UserHub? hub, RuntimeConfig config) - { - ConnectionId = connectionId; - Username = username; - ClientId = clientId; - ClientName = clientName; - Socket = socket; - Hub = hub; - State = new ConnectionStateBag(config); - } -} - -public sealed class ConnectionStateBag -{ - private readonly object gate = new(); - private DateTimeOffset lastSeen; - private DateTimeOffset lastPingAt; - private bool closed; - private bool helloTimeoutStarted; - public Channel SendQueue { get; } - public CancellationTokenSource Cts { get; } - public bool HelloReceived { get; internal set; } - public DateTimeOffset? HelloDeadline { get; internal set; } - - public DateTimeOffset LastSeen - { - get { lock (gate) { return lastSeen; } } - internal set { lock (gate) { lastSeen = value; } } - } - - public DateTimeOffset LastPingAt - { - get { lock (gate) { return lastPingAt; } } - internal set { lock (gate) { lastPingAt = value; } } - } - - public bool IsClosed - { - get { lock (gate) { return closed; } } - } - - public bool MarkClosed() - { - lock (gate) - { - if (closed) return false; - closed = true; - return true; - } - } - - public bool TryStartHelloTimeout() - { - lock (gate) - { - if (helloTimeoutStarted || closed) - { - return false; - } - - helloTimeoutStarted = true; - return true; - } - } - - public ConnectionStateBag(RuntimeConfig config) - { - lastSeen = DateTimeOffset.UtcNow; - lastPingAt = lastSeen; - SendQueue = Channel.CreateBounded(config.Limits.SendQueueCapacity); - Cts = new CancellationTokenSource(); - HelloDeadline = DateTimeOffset.UtcNow.AddSeconds(config.Limits.HelloTimeoutSeconds); - } - - public bool TryEnqueueSend(byte[] payload) - { - return SendQueue.Writer.TryWrite(payload); - } -} + private readonly UserRegistry registry = new(); + private readonly List pendingHellos = new(); + private readonly object pendingGate = new(); + private readonly IPasswordHasher hasher; + private readonly TimeProvider clock; + private readonly RuntimeStateStore runtimeStateStore; + private IReadOnlyDictionary userLookup; + private readonly string loginDummyHash; -public sealed class UserHub -{ - public string Username { get; } - public LatestText? Latest { get; private set; } - public ulong Version { get; private set; } - public Channel UserChannel { get; } - public TokenBucket ClipBucket { get; } - public SeenIdRing SeenIds { get; } + public UserRegistry Registry => registry; + public IPasswordHasher Hasher => hasher; + public SlidingWindowLoginLimiter LoginLimiter { get; } = new(); + public TimeProvider Clock => clock; + public ILogger Logger { get; } + public IReadOnlyDictionary UserLookup => Volatile.Read(ref userLookup); public DateTimeOffset ProcessStartTime { get; } - - private readonly object connectionsGate = new(); - private readonly List connections = new(); - private readonly RuntimeConfig config; - private Task? userLoop; - - private readonly object snapshotGate = new(); - private readonly List snapshotCandidates = new(); - private int snapshotBytes; - private readonly List recoveryQueue = new(); - private bool recoveryWindowClosed; - - public UserHub(string username, RuntimeConfig config, DateTimeOffset processStart) + public RuntimeConfig Config { get; } + public RuntimeStateStore RuntimeStateStore => runtimeStateStore; + internal string LoginDummyHash => loginDummyHash; + + public SyncServer( + RuntimeConfig config, + UsersFile users, + RuntimeStateStore runtimeStateStore, + IPasswordHasher hasher, + TimeProvider clock, + ILogger logger) { - Username = username; - this.config = config; - ProcessStartTime = processStart; - UserChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); - ClipBucket = new TokenBucket(config.RateLimit.ClipBurst, config.RateLimit.ClipTokensPerSecond, processStart); - SeenIds = new SeenIdRing(config.Limits.SeenIdCapacity); - Version = 0; - } - - public IReadOnlyList Connections - { - get { lock (connectionsGate) { return connections.ToArray(); } } + Config = config; + userLookup = UsersFile.BuildUserLookup(users); + this.runtimeStateStore = runtimeStateStore; + this.hasher = hasher; + this.clock = clock; + Logger = logger; + ProcessStartTime = clock.GetUtcNow(); + loginDummyHash = hasher.Hash( + "textcascade-login-timing-dummy", + Cli.CreateArgon2Config(config)); } - public bool IsEmpty - { - get { lock (connectionsGate) { return connections.Count == 0; } } - } + ILogger IConnectionCoordinator.Logger => Logger; - public void AddConnection(ConnectionContext connection) - { - lock (connectionsGate) { connections.Add(connection); } - var nowUtc = DateTimeOffset.UtcNow; - if (recoveryWindowClosed) - { - BroadcastToConnection(connection, Protocol.SerializeWelcome(Latest)); - return; - } + void IConnectionCoordinator.RebuildHub(UserHub hub) => RebuildHub(hub); - EnsureRecoveryWindowClosed(nowUtc); - if (recoveryWindowClosed) - { - return; - } - } + public void RemoveEmptyHubAfterRecovery(UserHub hub) => registry.RemoveIfEmpty(hub, allowDuringRecovery: true); - public bool RemoveConnection(ConnectionContext connection) + public void ReplaceUserLookup(UsersFile users) { - lock (connectionsGate) { return connections.Remove(connection); } + var replacement = UsersFile.BuildUserLookup(users); + Volatile.Write(ref userLookup, replacement); } - public void StartIfIdle(Func processor) + public UserHub GetOrCreateHub(string username, RuntimeConfig runtimeConfig) { - if (userLoop is null || userLoop.IsCompleted) - { - userLoop = Task.Run(async () => - { - try { await processor(this); } - catch (OperationCanceledException) { } - catch (Exception) { } - }); - } + var initialVersion = runtimeStateStore.GetVersion(username); + var hub = registry.GetOrAdd(username, name => new UserHub(name, runtimeConfig, ProcessStartTime, this, runtimeStateStore, initialVersion)); + hub.StartIfIdle(); + return hub; } - public bool TryWriteJob(UserJob job) => UserChannel.Writer.TryWrite(job); - - public async Task RunUserLoopAsync(CancellationToken cancellationToken = default) + public void ScanHeartbeats(DateTimeOffset nowUtc) { - var reader = UserChannel.Reader; - while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + var timeout = Config.Limits.HeartbeatTimeoutSeconds; + foreach (var pair in registry.All) { - while (reader.TryRead(out var job)) + var hub = pair.Value; + List? timedOut = null; + lock (hub.ScanGate) { - ProcessJob(job, DateTimeOffset.UtcNow); - } - } - } - - private void ProcessJob(UserJob job, DateTimeOffset nowUtc) - { - switch (job) - { - case ClipJob clipJob: - ApplyClip(clipJob.Clip, clipJob.Sender, nowUtc); - break; - case PongJob pongJob: - pongJob.Connection.State.LastSeen = nowUtc; - break; - case HelloJob helloJob: - helloJob.Connection.State.HelloReceived = true; - if (helloJob.Hello.Snapshot is not null) + var pingInterval = TimeSpan.FromSeconds(hub.Config.Limits.HeartbeatIntervalSeconds); + var pingBytes = Protocol.SerializePing(nowUtc); + for (var index = hub.ConnectionList.Count - 1; index >= 0; index--) { - AcceptSnapshot(helloJob.Hello); - } - break; - case DisconnectJob disconnectJob: - SyncServer.Instance.CancelConnection(disconnectJob.Connection, disconnectJob.Reason); - break; - } - } - - public void AcceptSnapshot(ClientHello hello) - { - lock (snapshotGate) - { - if (recoveryWindowClosed) return; - if (hello.Snapshot is null) return; - var bytes = Encoding.UTF8.GetByteCount(hello.Snapshot.Payload); - if (snapshotBytes + bytes > config.Limits.SnapshotTotalBytes) return; - snapshotCandidates.Add(hello); - snapshotBytes += bytes; - } - } - - public RecoveryDecision ClassifyClip(ClientClip clip, ConnectionContext connection) - { - lock (snapshotGate) - { - if (recoveryWindowClosed) - { - return RecoveryDecision.ProcessNow; - } - - if (recoveryQueue.Count >= config.Limits.RecoveryClipQueueCapacity) - { - return RecoveryDecision.QueueFull; - } - - recoveryQueue.Add(new RecoveryClip(clip, connection)); - return RecoveryDecision.Queued; - } - } - - public void CloseRecoveryWindow(DateTimeOffset nowUtc) - { - List clips; - SnapshotWinner? winner; - lock (snapshotGate) - { - if (recoveryWindowClosed) return; - recoveryWindowClosed = true; - winner = CoreLogic.SelectSnapshotWinner(snapshotCandidates); - if (winner is not null) - { - Version = winner.Version; - Latest = new LatestText(winner.Snapshot.Payload, winner.Version, winner.Snapshot.Hash, winner.Snapshot.Encrypted, winner.ClientId, winner.ClientName, winner.Snapshot.LocalModifiedAtUtc); - } - clips = recoveryQueue.ToList(); - recoveryQueue.Clear(); - } - - foreach (var recovery in clips) - { - if (recovery.Connection.State.IsClosed) continue; - ApplyClip(recovery.Clip, recovery.Connection, nowUtc); - } - - BroadcastWelcome(nowUtc); - - // Spec §6.2: empty hubs that survived until the recovery window closes are now removed. - SyncServer.Instance.Registry.RemoveIfEmpty(this, allowDuringRecovery: true); - } - - private void BroadcastWelcome(DateTimeOffset nowUtc) - { - var bytes = Protocol.SerializeWelcome(Latest); - foreach (var connection in Connections) - { - if (!connection.State.TryEnqueueSend(bytes) && connection.State.MarkClosed()) - { - connection.State.Cts.Cancel(); - } - } - } - - public bool IsRecoveryWindowOpen(DateTimeOffset nowUtc) - { - return !recoveryWindowClosed && nowUtc < ProcessStartTime.AddSeconds(config.Limits.SnapshotWindowSeconds); - } - - public void EnsureRecoveryWindowClosed(DateTimeOffset nowUtc) - { - if (!recoveryWindowClosed && nowUtc >= ProcessStartTime.AddSeconds(config.Limits.SnapshotWindowSeconds)) - { - CloseRecoveryWindow(nowUtc); - } - } - - public void ApplyClip(ClientClip clip, ConnectionContext sender, DateTimeOffset nowUtc) - { - if (SeenIds.TryGetResult(clip.Id, out var duplicateLatest)) - { - var ackBytes = Protocol.SerializeClipAck(clip.Id, duplicateLatest ?? Latest ?? new LatestText(string.Empty, Version, string.Empty, false, sender.ClientId, sender.ClientName, nowUtc)); - if (!sender.State.TryEnqueueSend(ackBytes) && sender.State.MarkClosed()) - { - sender.State.Cts.Cancel(); - } - return; - } - - if (!ClipBucket.TryAcquire(nowUtc)) - { - SyncServer.Instance.Logger?.LogSecurityEvent("reject", - ("username", Username), - ("code", "rate_limited"), - ("bytes", Encoding.UTF8.GetByteCount(clip.Payload))); - var error = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.RateLimited, "Clip rate limited.", clip.Id)); - if (!sender.State.TryEnqueueSend(error) && sender.State.MarkClosed()) - { - sender.State.Cts.Cancel(); - } - return; - } + var connection = hub.ConnectionList[index]; + if (!connection.State.HelloReceived && connection.State.HelloDeadline is { } deadline && nowUtc >= deadline) + { + timedOut ??= new List(); + timedOut.Add(connection); + continue; + } - var next = CoreLogic.NextVersion(Version); - Version = next; - var latest = new LatestText(clip.Payload, next, clip.Hash, clip.Encrypted, sender.ClientId, sender.ClientName, nowUtc); - Latest = latest; - SeenIds.RememberId(clip.Id, latest); - SyncServer.Instance.Logger?.LogSecurityEvent("clip", - ("username", Username), - ("version", latest.Version), - ("bytes", Encoding.UTF8.GetByteCount(clip.Payload)), - ("fromClientId", sender.ClientId), - ("encrypted", clip.Encrypted)); - - var broadcastBytes = Protocol.SerializeClip(clip.Id, latest); - foreach (var connection in Connections) - { - if (ReferenceEquals(connection, sender)) continue; - if (!connection.State.TryEnqueueSend(broadcastBytes) && connection.State.MarkClosed()) - { - connection.State.Cts.Cancel(); - } - } + if (connection.State.HelloReceived && nowUtc - connection.State.LastPingAt >= pingInterval) + { + connection.State.LastPingAt = nowUtc; + connection.State.MarkPingAwaitingPong(); + if (!connection.State.TryEnqueueSend(pingBytes) && connection.State.MarkClosed()) + { + connection.State.Cts.Cancel(); + } + } - var ackBytesFinal = Protocol.SerializeClipAck(clip.Id, latest); - if (!sender.State.TryEnqueueSend(ackBytesFinal) && sender.State.MarkClosed()) - { - sender.State.Cts.Cancel(); - } - } + if (nowUtc - connection.State.LastSeen >= TimeSpan.FromSeconds(timeout)) + { + timedOut ??= new List(); + timedOut.Add(connection); + } - public void EnqueuePing(DateTimeOffset nowUtc) - { - var bytes = Protocol.SerializePing(nowUtc); - foreach (var connection in Connections) - { - if (!connection.State.HelloReceived - || nowUtc - connection.State.LastPingAt < TimeSpan.FromSeconds(config.Limits.HeartbeatIntervalSeconds)) - { - continue; + hub.MarkActivityForScan(nowUtc); + } } - connection.State.LastPingAt = nowUtc; - if (!connection.State.TryEnqueueSend(bytes) && connection.State.MarkClosed()) + if (timedOut is not null) { - connection.State.Cts.Cancel(); + foreach (var connection in timedOut) + { + if (!connection.State.HelloReceived) + { + EnqueueHelloTimeout(connection); + } + else + { + CancelConnection(connection, "heartbeat_timeout"); + } + } } - } - } - private static void BroadcastToConnection(ConnectionContext connection, byte[] payload) - { - if (!connection.State.TryEnqueueSend(payload) && connection.State.MarkClosed()) - { - try { connection.State.Cts.Cancel(); } catch (Exception) { } - } - } - - public void BroadcastAsync(byte[] payload) - { - foreach (var connection in Connections) - { - if (connection.State.IsClosed) continue; - if (!connection.State.TryEnqueueSend(payload) && connection.State.MarkClosed()) + if (hub.IsEmpty && nowUtc - hub.LastActivityAt >= TimeSpan.FromMinutes(10)) { - try { connection.State.Cts.Cancel(); } catch (Exception) { } - } - } - } -} - -public readonly record struct RecoveryClip(ClientClip Clip, ConnectionContext Connection); - -public enum RecoveryDecision -{ - Queued, - ProcessNow, - QueueFull, -} - -public abstract record UserJob; - -public sealed record ClipJob(ConnectionContext Sender, ClientClip Clip) : UserJob; - -public sealed record HelloJob(ConnectionContext Connection, ClientHello Hello) : UserJob; - -public sealed record PongJob(ConnectionContext Connection, ClientPong Pong) : UserJob; - -public sealed record DisconnectJob(ConnectionContext Connection, string Reason) : UserJob; - -public sealed class UserRegistry -{ - private readonly ConcurrentDictionary hubs = new(StringComparer.Ordinal); - public IEnumerable> All => hubs; - - public UserHub GetOrAdd(string username, Func factory) - { - return hubs.GetOrAdd(username, factory); - } - - public bool TryGetValue(string username, out UserHub hub) => hubs.TryGetValue(username, out hub!); - - public void RemoveIfEmpty(UserHub hub, bool allowDuringRecovery) - { - if (!hub.IsEmpty) return; - if (!allowDuringRecovery && hub.IsRecoveryWindowOpen(DateTimeOffset.UtcNow)) return; - hubs.TryRemove(hub.Username, out _); - } -} - -public interface IClock -{ - DateTimeOffset UtcNow { get; } -} - -public sealed class SystemClock : IClock -{ - public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; -} - -public sealed class SyncServer -{ - public static SyncServer Instance { get; } = new(); - - private UserRegistry registry = new(); - private readonly List pendingHellos = new(); - private readonly object pendingGate = new(); - - public UserRegistry Registry => registry; - public IPasswordHasher Hasher { get; set; } = new Argon2PasswordHasher(); - public SlidingWindowLoginLimiter LoginLimiter { get; } = new(); - public IClock Clock { get; set; } = new SystemClock(); - public ILogger? Logger { get; set; } - public IReadOnlyDictionary UserLookup { get; set; } = new Dictionary(StringComparer.Ordinal); - public DateTimeOffset ProcessStartTime { get; set; } = DateTimeOffset.UtcNow; - public RuntimeConfig Config { get; private set; } = TextCascade.Server.Config.CreateDefaultConfig(); - - public void Initialize(RuntimeConfig config, UsersFile users) - { - Config = config; - registry = new UserRegistry(); - UserLookup = UsersFile.BuildUserLookup(users); - ProcessStartTime = DateTimeOffset.UtcNow; - } - - public UserHub GetOrCreateHub(string username, RuntimeConfig runtimeConfig) - { - var hub = registry.GetOrAdd(username, name => new UserHub(name, runtimeConfig, ProcessStartTime)); - hub.StartIfIdle(hub => hub.RunUserLoopAsync()); - return hub; - } - - public void ScanHeartbeats(DateTimeOffset nowUtc) - { - var timeout = RuntimeConfigAccessor.Current?.Limits.HeartbeatTimeoutSeconds ?? 60; - foreach (var pair in registry.All) - { - pair.Value.EnqueuePing(nowUtc); - foreach (var connection in pair.Value.Connections) - { - if (!connection.State.HelloReceived && connection.State.HelloDeadline is { } deadline && nowUtc >= deadline) - { - EnqueueHelloTimeout(connection); - continue; - } - var elapsed = nowUtc - connection.State.LastSeen; - if (elapsed.TotalSeconds >= timeout) - { - CancelConnection(connection, "heartbeat_timeout"); - } + registry.RemoveIfEmpty(hub, allowDuringRecovery: false); } } @@ -514,6 +147,21 @@ public void ScanHeartbeats(DateTimeOffset nowUtc) } } + public void RebuildHub(UserHub hub) + { + if (!registry.Remove(hub)) + { + return; + } + + foreach (var connection in hub.Connections) + { + CancelConnection(connection, "user_loop_failed"); + } + + Registry.RemoveIfEmpty(hub, allowDuringRecovery: true); + } + public void RegisterPendingHello(ConnectionContext connection) { lock (pendingGate) { pendingHellos.Add(connection); } @@ -535,7 +183,7 @@ private void EnqueueHelloTimeout(ConnectionContext connection) _ = CloseAfterHelloTimeoutAsync(connection); } - private static async Task CloseAfterHelloTimeoutAsync(ConnectionContext connection) + private async Task CloseAfterHelloTimeoutAsync(ConnectionContext connection) { try { @@ -549,11 +197,11 @@ private static async Task CloseAfterHelloTimeoutAsync(ConnectionContext connecti } catch (Exception) { - Instance.EnqueueImmediateClose(connection, "server_busy"); + EnqueueImmediateClose(connection, "server_busy"); } finally { - Instance.CancelConnection(connection, "hello_timeout"); + CancelConnection(connection, "hello_timeout"); } } @@ -565,7 +213,7 @@ public void CancelConnection(ConnectionContext connection, string reason) connection.Hub?.RemoveConnection(connection); if (connection.Hub is not null) { - Logger?.LogSecurityEvent("disconnect", + Logger.LogSecurityEvent("disconnect", ("username", connection.Username), ("clientId", connection.ClientId), ("connectionId", connection.ConnectionId), @@ -609,6 +257,8 @@ public async Task ShutdownAsync(TimeSpan drain, DateTimeOffset nowUtc) CancelConnection(connection, "server_shutdown"); } } + + runtimeStateStore.Flush(); } private static async Task CloseConnectionAsync(ConnectionContext connection, WebSocketCloseStatus status, string reason) @@ -627,311 +277,5 @@ private static async Task CloseConnectionAsync(ConnectionContext connection, Web } } -public static class SyncEndpoint -{ - public static async Task HandleAsync(HttpContext context, RuntimeConfig config) - { - var tokenHeader = context.Request.Headers.Authorization.ToString(); - if (!tokenHeader.StartsWith("Bearer ", StringComparison.Ordinal)) - { - context.Response.StatusCode = 401; - return; - } - - var compactToken = tokenHeader["Bearer ".Length..]; - var now = DateTimeOffset.UtcNow; - var tokenService = new TokenService(config.TokenSecret!); - if (!tokenService.TryVerifyToken(compactToken, now, SyncServer.Instance.UserLookup, out var payload)) - { - context.Response.StatusCode = 401; - return; - } - - if (!context.WebSockets.IsWebSocketRequest) - { - context.Response.StatusCode = 400; - return; - } - - var subProtocol = SelectSubProtocol(context.WebSockets.WebSocketRequestedProtocols); - if (subProtocol is null) - { - context.Response.StatusCode = 400; - return; - } - - using var socket = await context.WebSockets.AcceptWebSocketAsync(subProtocol); - var connectionId = Guid.NewGuid().ToString("N"); - var provisional = new ConnectionContext(connectionId, payload.Subject, "pending", "pending", socket, null!, config); - await ConnectionHandler.RunAsync(provisional, payload, config); - } - - internal static string? SelectSubProtocol(IList requested) - { - foreach (var protocol in requested) - { - if (string.Equals(protocol, "textcascade.v1", StringComparison.Ordinal)) return protocol; - } - return null; - } -} - -public static class ConnectionHandler -{ - public static async Task RunAsync(ConnectionContext provisional, TokenPayload payload, RuntimeConfig config) - { - SyncServer.Instance.RegisterPendingHello(provisional); - ClientHello hello; - try - { - var received = await ReceiveFrameAsync(provisional, config.Limits.MaxFrameBytes, provisional.State.Cts.Token); - if (received.MessageType == WebSocketMessageType.Close) - { - SyncServer.Instance.CancelConnection(provisional, "closed"); - return; - } - - var parse = Protocol.ParseClientMessage(received.Payload, config); - if (!parse.IsSuccess || parse.Kind != MessageKind.Hello) - { - var error = Protocol.SerializeProtocolError(new ProtocolError( - ProtocolErrorCode.InvalidMessage, - "Expected a valid hello message.", - parse.Error?.ReferenceId)); - await SendAndClosePreHelloAsync( - provisional, - error, - WebSocketCloseStatus.PolicyViolation, - "invalid_hello"); - return; - } - - hello = (ClientHello)parse.Message!; - } - catch (FrameTooLargeException) - { - var error = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null)); - await SendAndClosePreHelloAsync(provisional, error, WebSocketCloseStatus.MessageTooBig, "frame_too_large"); - return; - } - catch (OperationCanceledException) - { - // Hello timeout is owned by the unified heartbeat scanner; here the socket was - // cancelled for another reason (e.g. shutdown). Fall through to unified cleanup. - SyncServer.Instance.CancelConnection(provisional, "cancelled"); - return; - } - catch (WebSocketException) - { - SyncServer.Instance.CancelConnection(provisional, "socket_error"); - return; - } - - var hub = SyncServer.Instance.GetOrCreateHub(payload.Subject, config); - var connection = new ConnectionContext( - provisional.ConnectionId, - payload.Subject, - hello.ClientId, - hello.ClientName, - provisional.Socket, - hub, - config); - hub.AddConnection(connection); - SyncServer.Instance.Logger?.LogSecurityEvent("connect", - ("username", connection.Username), - ("clientId", connection.ClientId), - ("connectionId", connection.ConnectionId)); - connection.State.HelloReceived = true; - connection.State.LastSeen = DateTimeOffset.UtcNow; - SyncServer.Instance.UnregisterPendingHello(provisional); - if (!hub.TryWriteJob(new HelloJob(connection, hello))) - { - SyncServer.Instance.CancelConnection(connection, "user_loop_unavailable"); - return; - } - - var sendTask = ConnectionSendLoopAsync(connection); - var readTask = ReadLoopAsync(connection, config); - await Task.WhenAll(sendTask, readTask); - SyncServer.Instance.CancelConnection(connection, "disconnected"); - } - - private static async Task ReceiveFrameAsync( - ConnectionContext connection, - int maxBytes, - CancellationToken cancellationToken) - { - using var stream = new MemoryStream(Math.Min(maxBytes, 16 * 1024)); - var buffer = new byte[Math.Min(maxBytes, 16 * 1024)]; - while (true) - { - var received = await connection.Socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); - if (received.Count > maxBytes - stream.Length) - { - throw new FrameTooLargeException(); - } - - stream.Write(buffer, 0, received.Count); - if (received.EndOfMessage) - { - return new ReceivedMessage(received.MessageType, stream.ToArray()); - } - } - } - - private static async Task SendAndClosePreHelloAsync( - ConnectionContext connection, - byte[] error, - WebSocketCloseStatus status, - string reason) - { - try - { - if (connection.Socket.State == WebSocketState.Open) - { - await connection.Socket.SendAsync(error, WebSocketMessageType.Text, true, CancellationToken.None); - await connection.Socket.CloseAsync(status, reason, CancellationToken.None); - } - } - catch (Exception) - { - SyncServer.Instance.EnqueueImmediateClose(connection, "server_busy"); - } - finally - { - SyncServer.Instance.CancelConnection(connection, reason); - } - } - - private static async Task CloseAfterProtocolErrorAsync( - ConnectionContext connection, - WebSocketCloseStatus status, - string reason) - { - // Give the send loop a short opportunity to flush the queued error frame. - await Task.Delay(100); - try - { - if (connection.Socket.State == WebSocketState.Open) - { - await connection.Socket.CloseAsync(status, reason, CancellationToken.None); - } - } - catch (Exception) - { - // Cancellation below is still the unified cleanup path. - } - finally - { - SyncServer.Instance.CancelConnection(connection, reason); - } - } - - private static async Task ReadLoopAsync(ConnectionContext connection, RuntimeConfig config) - { - try - { - while (connection.State.Cts.IsCancellationRequested == false && connection.Socket.State == WebSocketState.Open) - { - ReceivedMessage received; - try - { - received = await ReceiveFrameAsync(connection, config.Limits.MaxFrameBytes, connection.State.Cts.Token); - } - catch (FrameTooLargeException) - { - var oversized = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null)); - await SendSafeAsync(connection, oversized); - await Task.Delay(100, connection.State.Cts.Token); - if (connection.Socket.State == WebSocketState.Open) - { - await connection.Socket.CloseAsync(WebSocketCloseStatus.MessageTooBig, "frame_too_large", CancellationToken.None); - } - SyncServer.Instance.CancelConnection(connection, "frame_too_large"); - break; - } - - if (received.MessageType == WebSocketMessageType.Close) break; - - if (!Protocol.CheckFrameSize(received.Payload.Length, config)) - { - var error = Protocol.SerializeProtocolError(new ProtocolError(ProtocolErrorCode.FrameTooLarge, "frame_too_large", null)); - await SendSafeAsync(connection, error); - await connection.Socket.CloseAsync(WebSocketCloseStatus.MessageTooBig, "frame_too_large", CancellationToken.None); - SyncServer.Instance.CancelConnection(connection, "frame_too_large"); - break; - } - - var parse = Protocol.ParseClientMessage(received.Payload, config); - if (!parse.IsSuccess) - { - SyncServer.Instance.Logger?.LogSecurityEvent("reject", - ("username", connection.Username), - ("code", parse.Error?.CodeName ?? "invalid_message"), - ("bytes", received.Payload.Length)); - var error = Protocol.SerializeProtocolError(parse.Error!); - await SendSafeAsync(connection, error); - continue; - } - - switch (parse.Kind) - { - case MessageKind.Clip: - var clip = (ClientClip)parse.Message!; - var hub = connection.Hub; - if (hub is null) - { - SyncServer.Instance.CancelConnection(connection, "user_loop_unavailable"); - break; - } - - var decision = hub.ClassifyClip(clip, connection); - if (decision == RecoveryDecision.QueueFull) - { - SyncServer.Instance.CancelConnection(connection, "recovery_queue_full"); - } - else if (decision == RecoveryDecision.ProcessNow - && !hub.TryWriteJob(new ClipJob(connection, clip))) - { - SyncServer.Instance.CancelConnection(connection, "user_loop_unavailable"); - } - break; - case MessageKind.Pong: - if (connection.Hub is null || !connection.Hub.TryWriteJob(new PongJob(connection, (ClientPong)parse.Message!))) - { - SyncServer.Instance.CancelConnection(connection, "user_loop_unavailable"); - } - break; - } - } - } - catch (OperationCanceledException) { } - catch (WebSocketException) { } - } - - private static async Task ConnectionSendLoopAsync(ConnectionContext connection) - { - try - { - await foreach (var payload in connection.State.SendQueue.Reader.ReadAllAsync(connection.State.Cts.Token)) - { - await connection.Socket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, connection.State.Cts.Token); - } - } - catch (OperationCanceledException) { } - catch (WebSocketException) { } - } - - private static async Task SendSafeAsync(ConnectionContext connection, byte[] payload) - { - if (!connection.State.TryEnqueueSend(payload)) - { - SyncServer.Instance.EnqueueImmediateClose(connection, "server_busy"); - return; - } - } -} -internal sealed class FrameTooLargeException : Exception; -internal sealed record ReceivedMessage(WebSocketMessageType MessageType, byte[] Payload); diff --git a/TextCascade.Server/TextCascade.Server.csproj b/TextCascade.Server/TextCascade.Server.csproj index 4e35742..bf096f2 100644 --- a/TextCascade.Server/TextCascade.Server.csproj +++ b/TextCascade.Server/TextCascade.Server.csproj @@ -1,11 +1,20 @@ - + net10.0 enable enable - 0.2.0 + 0.5.0 TextCascade.Server + true + + + + false + true + true + embedded + $(RuntimeIdentifier) @@ -18,3 +27,4 @@ + diff --git a/deploy/textcascade-server.service b/deploy/textcascade-server.service index b479b03..fac8210 100644 --- a/deploy/textcascade-server.service +++ b/deploy/textcascade-server.service @@ -5,11 +5,13 @@ After=network-online.target [Service] Type=simple -User=root -Group=root +User=textcascade +Group=textcascade WorkingDirectory=/opt/textcascade-server EnvironmentFile=/etc/textcascade/textcascade.env -ExecStart=/usr/bin/dotnet /opt/textcascade-server/TextCascade.Server.dll serve --config /etc/textcascade/textcascade.toml +ExecStart=/opt/textcascade-server/TextCascade.Server serve --config /etc/textcascade/textcascade.toml +StateDirectory=textcascade +ConfigurationDirectory=textcascade Restart=on-failure RestartSec=5s UMask=0077 diff --git a/deploy/textcascade.toml b/deploy/textcascade.toml index 843b93c..f9e6fa4 100644 --- a/deploy/textcascade.toml +++ b/deploy/textcascade.toml @@ -5,3 +5,4 @@ certificate_path = "/etc/cert/test.pem" [files] users_file = "/etc/textcascade/users.json" +state_file = "/var/lib/textcascade/textcascade.state.json" diff --git a/docs/go-server-spec.md b/docs/go-server-spec.md new file mode 100644 index 0000000..0a7c051 --- /dev/null +++ b/docs/go-server-spec.md @@ -0,0 +1,449 @@ +# TextCascade Go 版服务端规格(C# 1:1 迁移) + +状态:迁移决策已完成(15/15),规格定稿待实现 +日期:2026-09-05 +基准:[server-spec.md](server-spec.md)(与 C# v0.5.0 实现对齐)+ 源码逐函数盘点 +迁移原则:**行为 1:1,对外契约零变更**;仅 §3 声明性差异与 §12 迁移注意事项两处例外 + +## 1. 目标与非目标 + +### 1.1 目标 + +- 用 Go 重写 C# 服务端(约 4400 行生产代码、20 个源文件),函数级一一对应。 +- 线协议、文件格式、CLI、错误码、默认值全部不变(见 §3)。 +- 存量部署(systemd + PEM 证书)可直接切换;存量密码哈希不兼容,按 §12 runbook 重置。 +- 测试全量 1:1 搬运(Q3):单元 162 + 网络集成 12 + SlowHash 3 + 契约样本矩阵。 +- 内存占用显著低于 .NET 版(迁移动机,perf.md 实测 .NET 空闲 RSS 125–131MB / 每连接 ~660KB);Go 版落地后按 perf.md 场景重新实测并修订内存目标。 + +### 1.2 非目标 + +- 不修改任何协议语义、不新增特性、不"顺手修复"C# 已知差距(§15 台账 9 项原样保留,见 §13)。 +- 不做跨语言互操作或混合部署的原子切换方案(切换 = 停旧起新 + runbook 步骤)。 +- 不支持 C# 版未支持的平台(win-x64 / linux-x64 两目标)。 + +## 2. 已定决策(15 项,自包含) + +| # | 决策 | 结论 | +|---|---|---| +| Q1 | 仓库布局 | 新建 `go` 分支;分支根目录放 go.mod(Go 原生布局);C# 留在 main;ContractSamples 复制入 `testdata/`,CI 校验与 main 侧 sha256 一致 | +| Q2 | 工具链 | Go 1.27(最新稳定线,go1.27.1) | +| Q3 | 验收策略 | 测试全量 1:1 搬运 | +| Q4 | WebSocket 库 | gorilla/websocket | +| Q5 | 入站 JSON | encoding/json + 手写 token 级预扫描器(`internal/protocol/jsonscan.go`,详见 §7.7-A) | +| Q6 | Argon2 兼容 | **不与 Isopoh 兼容**:Go 自洽 PHC 编码(golang.org/x/crypto/argon2);迁移时重置全部存量密码(§12) | +| Q7 | TOML | pelletier/go-toml/v2 | +| Q8 | 单实例锁 | gofrs/flock | +| Q9 | 用户文件监听 | fsnotify + 250ms 防抖 + 30 秒轮询兜底 | +| Q10 | 证书 | PEM 全支持 + PFX/P12 用 software.ssl.golang.org/pkcs12(实现首日验证无加密 PFX) | +| Q11 | ALPN | 仅 HTTP/1.1(`NextProtos=["http/1.1"]`) | +| Q12 | 日志 | log/slog + 自定义单行 Handler | +| Q13 | CLI | stdlib flag.NewFlagSet 手写动词分发;x/term 交互密码 | +| Q14 | 测试栈 | stdlib testing + testify(assert/require;仅进测试二进制) | +| Q15 | 版本注入 | ldflags -X main.version;CI 双平台矩阵 | + +完整论证(每项三选项优劣)存于私有决策台账 `specs/go-migration-decisions.md`(不入库);本表为唯一事实摘要。 + +## 3. 声明性差异(相对 C# v0.5.0 的全部偏差) + +1. **存量 Argon2 哈希不兼容**(Q6):Go 版 PHC 编码自洽,无法验证 C# 版创建的 `users.json` 存量哈希。迁移时按 §12 重置全部用户密码。客户端零改动(Argon2 仅服务端使用;E2E 密钥由客户端本地 PBKDF2 从密码派生,与服务端哈希无关,重置为同一密码后旧剪贴板仍可解密)。 +2. **仅 HTTP/1.1**(Q11):Go 版 TLS ALPN 显式只广播 `http/1.1`,不提供 h2。Kestrel 默认广播 h2+http/1.1。对协议无影响(RFC6455 升级仅存在于 HTTP/1.1;gorilla/websocket 亦仅支持 h1 升级)。 +3. **Windows 私钥存储**:C# 需区分 DefaultKeySet/EphemeralKeySet(SChannel 限制);Go 私钥仅在内存中,无对应问题,`internal/hosting/cert.go` 不含平台分支。 +4. 除此之外,登录响应、token、协议帧、错误码、close code、文件格式、TOML 键、环境变量、CLI 行为**全部零偏差**。 + +## 4. 依赖清单(go.mod,Q 汇总) + +直接依赖(8): + +| 依赖 | 用途 | 引入自 | +|---|---|---| +| github.com/gorilla/websocket | WebSocket 升级与帧 | Q4 | +| github.com/pelletier/go-toml/v2 | TOML 解析 | Q7 | +| github.com/gofrs/flock | CLI 单实例锁 | Q8 | +| github.com/fsnotify/fsnotify | users.json 监听 | Q9 | +| golang.org/x/crypto | Argon2id | Q6 | +| golang.org/x/term | 交互式密码输入 | Q13 | +| software.sslmate.com/src/go-pkcs12 | 无密码 PFX/P12 | Q10(实测修正:golang.org vanity 路径 proxy 未缓存且部分网络不可达,须用 canonical 模块路径,当前版 v0.7.3) | +| stretchr/testify | 测试断言(仅测试二进制) | Q14 | + +间接:golang.org/x/sys(flock/fsnotify 共享)。其余全部标准库。 + +## 5. 工具链与仓库布局 + +- Go 1.27;`go.mod` 模块名 `github.com/long45343/TextCascade-Server`;`go vet` + `gofmt` 进 CI。 +- 版本号:`var version = "dev"`(main 包),release 构建注入 `-ldflags "-X main.version="`;CLI 输出与 user list 显示该值(对齐 C# 从 csproj 读取的语义)。 +- 构建:`go build -trimpath -o TextCascade.Server ./cmd/server`;发布矩阵 linux-x64 / win-x64 自包含单文件(Go 无"框架依赖"概念,运行时内嵌——与 C# 产物形态的固有差异,非契约偏差)。 + +``` +(go 分支根目录) +go.mod / go.sum +cmd/server/main.go ← Program.cs +internal/config/config.go ← RuntimeConfig.cs +internal/users/users.go ← Users.cs +internal/auth/argon2.go ← Auth.cs(哈希器半边) +internal/auth/token.go ← Auth.cs(token 半边) +internal/auth/login.go ← AuthService.cs +internal/protocol/protocol.go ← Protocol.cs(结构 + 解析 + 校验) +internal/protocol/serialize.go ← Protocol.cs(出站 marshal) +internal/protocol/jsonscan.go ← 新组件:token 级预扫描(§7.7-A) +internal/core/limiter.go ← Core.cs(SlidingWindowLoginLimiter) +internal/core/bucket.go ← Core.cs(TokenBucket) +internal/core/ring.go ← Core.cs(SeenIdRing) +internal/core/logic.go ← Core.cs(CoreLogic / SnapshotWinner) +internal/state/store.go ← RuntimeStateStore.cs +internal/logging/security.go ← SecurityLogging.cs +internal/sync/server.go ← SyncServer.cs +internal/hub/hub.go ← UserHub.cs +internal/hub/registry.go ← UserRegistry.cs +internal/hub/jobs.go ← UserJobs.cs +internal/hub/coordinator.go ← IConnectionCoordinator.cs +internal/hosting/connection.go ← ConnectionHandler.cs +internal/hosting/endpoint.go ← SyncEndpoint.cs +internal/hosting/watcher.go ← UserFileWatcher.cs +internal/hosting/scanner.go ← HeartbeatScannerService.cs +internal/hosting/cert.go ← ServerHost.cs(证书加载半边) +internal/hosting/run.go ← ServerHost.cs(进程装配半边) +internal/models/context.go ← ConnectionContext.cs + ConnectionStateBag.cs +internal/models/frame.go ← ReceivedMessage.cs +internal/cli/cli.go ← Cli.cs +internal/cli/lock.go ← SingleInstanceLock +internal/clock/clock.go ← TimeProvider 接缝(固定决策 F4) +testdata/contract-samples/ ← 复制自 main 的 TestCascade.Server.Tests/ContractSamples/ +``` + +## 6. 并发模型映射(固定决策 F3/F4/F5/F6/F7) + +| C# | Go | 要点 | +|---|---|---| +| `ReadLoopAsync`(每连接) | goroutine `readLoop` | gorilla NextReader 驱动 | +| 用户 Channel(无界) | 自实现无界队列(slice + sync.Cond),`TryWrite` 恒真 | 1:1 语义 | +| `RunUserLoopAsync`(单消费者) | goroutine(mutex 保证单读者,复刻 StartIfIdle 防重入) | | +| 发送 Channel(有界 16) | `chan []byte` cap 16 + `select { case ch<-p: / default: 满 }` | 满即 `cancel()` 连接 ctx,不补发 error/close | +| `ConnectionSendLoopAsync` | goroutine `sendLoop`(唯一写者) | gorilla 要求单写者,天然对齐 | +| `HeartbeatScannerService`(BackgroundService+PeriodicTimer) | goroutine + `time.NewTicker(1s)` + ctx | 扫描 panic recover 且记日志 | +| `TimeProvider` | `type Clock interface { Now() time.Time }`;生产 `clock.System`,测试注入 fake | | +| 优雅停机 | `signal.NotifyContext(SIGTERM, SIGINT)`;流程 1:1:bye → 1001 → drain 2s → 取消全部连接 → 同步 flush | 含"close 握手等待无超时"现状(§13.8) | +| 用户循环异常 → RebuildHub | 用户 goroutine 内 `recover()` → RebuildHub | `NextVersion` 溢出必须**显式检测**:Go uint64 自增静默 wrap,`cur == math.MaxUint64` 时 panic | + +## 7. 函数级映射表 + +约定:C# `out` 参数在 Go 中改为多返回值;`Result` 改为 `(T, *Error)`;instance/static 双载合并为一个函数(表中注明);internal 测试钩子在 Go 中为包内可见函数(同包测试可直接调用)。 + +### 7.1 Program.cs → cmd/server/main.go + +| C# | Go | 行为 | +|---|---|---| +| `Main(args)` | `main()` | 动词分发:`serve` → `hosting.Run`;其余 → `cli.Run`;退出码一致 | + +### 7.2 ServerHost.cs → internal/hosting/{run.go, cert.go} + +| C# | Go | 行为 | +|---|---|---| +| `RunServer(args)` | `hosting.Run(args) int` | Load→Env→Validate→LoadUsers→ValidateUsers→state store→cert.Load→watcher.Start→http.Server(ListenAndServeTLS);错误打印与退出码一致 | +| `CreateApp(...)` | `hosting.NewServer(cfg, users, store, hasher, clk, cert) *Server` | 构建 mux:GET /health、POST /api/v1/login、GET /api/v1/sync;等价 `UseWebSockets` 由 gorilla Upgrader 承担 | +| `ConfigureKestrel(...)` | TLSConfig 装配(NewServer 内) | MinVersion 不显式设置(跟随 OS,对齐 §8.2);`NextProtos=["http/1.1"]`(Q11) | +| `CertificateLoader.Load(path)` | `cert.Load(path) (tls.Certificate, error)` | 扩展名分发 .pem/.crt → loadPEM;.pfx/.p12 → pkcs12(无密码);其他扩展报错文案一致 | +| `LoadPemCertificate(path)` | `cert.loadPEM(path)` | 同名 `.key` 边车查找、bundle 解析、叶证书+私钥匹配;缺私钥/无证书错误文案一致 | +| `DisposeChain` / `LoadedCertificate.Dispose` | 无对应 | Go GC 管理;结构体保留持有 tls.Certificate | + +### 7.3 RuntimeConfig.cs → internal/config/config.go + +| C# | Go | 行为 | +|---|---|---| +| 5 个 record(ServerConfig 等) | `config.RuntimeConfig` + 子 struct(Server/Auth/Limits/RateLimit/Files) | 字段名一一对应 | +| `CreateDefaultConfig()` | `config.Defaults()` | 默认值逐项一致(§3.1 表) | +| `LoadTomlConfig(path, defaults)` | `config.LoadTOML(path string, def *RuntimeConfig) (RuntimeConfig, error)` | go-toml/v2 严格解析:重复键 fail-fast、类型非法 fail-fast;UTF-8 强制 | +| `ApplyTomlModel(config, model)` | `applyTOMLModel(doc *toml.MetaData)` | 键映射与回退一致 | +| `TryGetTable` / `GetString` / `GetInt` | `getTable` / `getString` / `getInt` | | +| `WarnUnknownKeys(...)` | `warnUnknownKeys(...)` | 未知键 warning(slog) | +| `ApplyEnvironmentOverrides(config)` | `(*RuntimeConfig).ApplyEnv()` | 5 个环境变量 + token secret 变量名,清单一致 | +| `ValidateConfig(config)` | `(*RuntimeConfig).Validate() error` | 全部规则一致(含 max_frame > max_text、心跳超时 > 间隔、全容量 > 0) | + +### 7.4 Users.cs → internal/users/users.go + +| C# | Go | 行为 | +|---|---|---| +| `UserRecord` record | `struct UserRecord`(Username/PasswordHash/TokenVersion int64/Disabled) | | +| `Argon2HashRegex` | `users.argon2HashRe`(regexp 编译一次) | 校验哈希串形态 | +| `LoadUsers(path)` | `users.Load(path) (*UsersFile, error)` | 严格 JSON:未知/重复字段拒绝(Q5 扫描器) | +| `HasUniqueProperties(...)` | `hasUniqueProperties(...)` | | +| `ValidateUsers(users)` | `(*UsersFile).Validate() error` | 水位、唯一性、哈希格式、正数 long、disabled——全部一致 | +| `BuildUserLookup(users)` | `(*UsersFile).BuildLookup() map[string]UserRecord` | | +| `SaveUsers(path, users)` | `users.Save(path, f)` | Validate → 临时文件 + `os.Rename` 原子替换(Windows 等价 MoveFileEx)+ fsync | +| `Copy(source)` | `users.Copy(f)` | | + +### 7.5 Auth.cs → internal/auth/{argon2.go, token.go} + +| C# | Go | 行为 | +|---|---|---| +| `Argon2PasswordHasher.Hash/Verify` | `auth.Hash(password, params)` / `auth.Verify(password, encoded)` | x/crypto/argon2 (Argon2id);PHC 编码自洽(Q6),格式 `$argon2id$v=19$m=…,t=…,p=…$b64salt$b64hash`(RawURL 无填充) | +| `NeedsRehash`(实例/静态双载) | `auth.NeedsRehash(encoded, params)` | 合并双载;语义一致:参数不一致 → 登录响应携带 needsRehash,不重写文件 | +| `TokenPayload` record | `struct auth.TokenPayload`(Subject/Version/IssuedAt/Expires int64 Unix 秒) | | +| `AuthToken` record | `struct auth.Token` | | +| `TokenService(secret)` | `auth.NewTokenService(secret []byte)` | | +| `CreateToken(user, now, ttl)` | `(*TokenService).Create(user, now, ttl)` | | +| `CreateTokenPayload(user, now, ttl)` | `auth.CreateTokenPayload(user, now, ttl)` | 固定字段序 sub/ver/iat/exp 最小化 UTF-8 JSON(手写 marshal,非结构体反射) | +| `SignToken(payload, secret)` | `auth.SignToken(payload, secret)` | HMAC-SHA256;比较用 `hmac.Equal`(常数时间) | +| `TryVerifyToken`(实例/静态) | `auth.TryVerifyToken(compact, now, lookup) (TokenPayload, bool)` | 合并双载;Go 无 out 参数 → 多返回值 | +| `TryVerifyTokenInternal` | `tryVerifyInternal` | 验签→验过期→验用户存在→验 tokenVersion 顺序一致 | +| `TryParsePositiveInteger` | `tryParsePositiveInt` | 拒绝小数/指数/字符串形态/非正数 | +| `TryBase64UrlDecode` | 消失 | 直接用 `base64.RawURLEncoding`(Q5 固定决策:出站 EncodeToString/入站 Decode 等价) | + +### 7.6 AuthService.cs → internal/auth/login.go + +| C# | Go | 行为 | +|---|---|---| +| `HandleLoginAsync(...)` | `auth.HandleLogin(w, r, cfg, srv, logger)` | 全部内联逻辑 1:1 | +| `ParseLoginRequest(...)` | `parseLoginRequest(w, r)` | 16KB 上限:`http.MaxBytesReader`(覆盖 chunked,等价 IHttpMaxRequestBodySizeFeature);MaxDepth=3、重复/未知字段拒绝走 jsonscan | +| `WriteError(...)` | `writeError(...)` | 400 invalid_request 统一形态 | +| `CreateLoginFailure` / `CreateRateLimitResult` | 常量 `errInvalidCredentials` / `errRateLimited` | | +| `LoginRequest` record | `struct loginRequest` | | +| `LoginParseException` | `auth.ErrLoginParse`(哨兵 error) | Go 无异常类型 | + +### 7.7 Protocol.cs → internal/protocol/{protocol.go, serialize.go, jsonscan.go} + +**结构与错误:** + +| C# | Go | +|---|---| +| `ProtocolError` record | `struct Error{Code ErrKind, Message string, ReferenceID *string}` | +| `ProtocolErrorCode` enum | `ErrKind` 常量组 + `(*Error).CodeName()`(invalid_message/text_too_large/frame_too_large/empty_text/rate_limited/hello_timeout/server_busy) | +| `Result` / `ParseResult` | `(T, *Error)` 惯例;`ParseClientMessage` 返回 `(Message, *Error)` | +| `ClipSnapshot/ClientHello/ClientClip/ClientPong` | 同名字段 struct(UpdatedAt/ClientTime 用 `time.Time`) | +| `ClientMessage/MessageKind` | `Kind` 常量 + `Message` 单一 struct(Go 无 object 判别联合,用固定字段) | +| `LatestText` record + `From` | `struct LatestText` + `LatestFromSnapshot(s, version, clientID, clientName)` | +| `UtcSecondDateTimeConverter` | `WriteUTCSecond(w, t)`:出站 `"2006-01-02T15:04:05Z"`;`ParseFlexibleTime(s)`:入站接受秒级或 ISO 往返两种形态、偏移必须为零 | + +**出站 marshal(serialize.go,全部手写字节,字段序固定):** + +| C# | Go | 字节不变式 | +|---|---|---| +| `SerializeWelcome(latest, _)` | `MarshalWelcome(latest *LatestText)` | `{"type":"welcome","protocolVersion":1}` + latest 非 nil 时续 `,\"latest\":{...}`;键缺失即"无最新值" | +| `SerializeClip(id, latest)` | `MarshalClip(id, latest)` | type,version,id,payload,encrypted,hash,fromClientId,fromClientName,updatedAtUtc | +| `SerializeClipAck(id, latest)` | `MarshalClipAck(id, latest)` | type,id,version,updatedAtUtc | +| `SerializePing(now)` | `MarshalPing(now)` | type,serverTimeUtc | +| `SerializeBye(reason)` | `MarshalBye(reason)` | type,reason | +| `SerializeProtocolError(err)` | `MarshalError(err)` | type,code,message[,referenceId];nil 时键省略 | +| `SerializeLoginResponse(token, cfg, needsRehash)` | `MarshalLoginResponse(token, cfg, needsRehash)` | 手写顺序:token, expiresAtUtc("O" 往返格式,Go layout `"2006-01-02T15:04:05.0000000Z07:00"`), protocolVersion, maxTextBytes, helloTimeoutSeconds, heartbeatIntervalSeconds, heartbeatTimeoutSeconds [, needsRehash 仅 true 时] | + +**入站解析(protocol.go + jsonscan.go):** + +| C# | Go | 行为 | +|---|---|---| +| `ParseClientMessage(frame, config)` | `ParseClientMessage(frame []byte, cfg) (Message, *Error)` | 顺序:jsonscan 预扫描 → 根必须 object → `type` 字符串 → 未知/重复字段检查(known 集合按类型)→ parseHello/parseClip/parsePong → 未知 type invalid_message | +| `ParseHello(root, config)` | `parseHello` | clientId 1–128 字节、clientName 0–128 字节、lastServerVersion 非负整数、snapshot object 或 null | +| `TryGetSnapshot(...)` | `tryGetSnapshot` | payload 必填非空、预算校验、hash ≤4096 字节、localModifiedAtUtc 两种形态 | +| `ParseClip(root, config)` | `parseClip` | | +| `ParsePong(root)` | `parsePong` | | +| `ValidateHello(h, config)` | `ValidateHello(h, cfg)` | | +| `ValidateClipSnapshot(s, config)` | `validateSnapshot` | | +| `ValidateClipMessage(m, config)` | `ValidateClip(m, cfg)` | 结构→语义→资源顺序早拒绝 | +| `CheckFrameSize` / `CheckPayloadSize` | 同名导出 | | +| `ValidatePayloadSize` | `validatePayloadSize` | | +| `TryParseJson(frame, out error)` | `jsonscan.Decode(frame []byte, cfg) (*Node, *Error)` | 见下 | +| `GetReferenceId(root)` | `getReferenceID(root)` | | + +**A. jsonscan 预扫描器规则(Q5 新组件,逐条复刻 C# 校验行为):** + +1. UTF-8 完整性(`utf8.Valid`),非法即 invalid_message。 +2. 单一顶层 JSON 值;嵌套深度 ≤3(C# MaxDepth=3)。 +3. 全树重复键检测,报文含字段名(`Unknown or duplicate field: X.`)。等价性论证:C# 未知字段一律拒绝,故 C# 的"已知层显式查重"与"全树查重"最终行为相同(重复键必然落在已知字段或导致未知字段拒绝,两者都返回 invalid_message 且报文一致)。 +4. 数字形态白名单:协议字段仅接受整数字面量 `-?0|[1-9][0-9]*`;小数点/指数形态在扫描层即拒绝(等价 C# TryGetUInt64/TryParsePositiveInteger 的行为 + TryGet 系列的数字类型检查)。 +5. 字符串非法转义/裸控制字符 → 非法。 +6. 扫描产物为轻量 Node 树,语义层(parseHello 等)在其上取字段;语义解析不再走 encoding/json(避免二义)。users.json/login 同一扫描器复用。 + +### 7.8 Core.cs → internal/core/{limiter.go, bucket.go, ring.go, logic.go} + +| C# | Go | 行为 | +|---|---|---| +| `TryConsumeLoginLimit(ip, user, now, cfg)` | `(*Limiter).TryConsumeLoginLimit(...)` | 双维度滑动窗口,任一超限拒绝;成功仅清用户窗口 | +| `ResetUserLimit(username)` | `(*Limiter).ResetUserWindow(user)` | | +| `GetWindowCount/HasWindowKey` | 包内可见(同包测试直调) | C# internal ForTest 钩子在 Go 无需后缀 | +| `RemoveExpired/EnqueueForTest` | 同上 | | +| `tryConsume(key, limit, now, maxKeys, allowNewKey)` | `tryConsume` | RemoveExpired 全表扫描时机一致 | +| `TokenBucket(burst, rate, now)` / `TryAcquire(now)` | `bucket.New` / `(*Bucket).TryAcquire(now)` | 补币算法一致 | +| `SeenIdRing(capacity)` | `ring.New(capacity)` | Dictionary+FIFO 环形淘汰 | +| `TryDuplicate(id)` | `(*Ring).TryDuplicate(id)` | | +| `TryGetResult(id, out r)` | `(*Ring).TryGet(id) (*LatestText, bool)` | | +| `RememberId(id, result)` | `(*Ring).Remember(id, latest)` | | +| `IsUnchangedDuplicate(...)` | `(*Ring).IsUnchangedDuplicate(id, payload, hash, encrypted) (*LatestText, bool)` | 返回 true 时 latest 必非 nil(死分支依据) | +| `RememberInternal` | `rememberInternal` | | +| `SnapshotWinner` record | `struct Winner` | | +| `CoreLogic.NextVersion(current)` | `core.NextVersion(cur uint64) uint64` | **F7:`cur == math.MaxUint64` 显式 panic**(Go 自增不溢出抛异常) | +| `CoreLogic.WithVersion(latest, next, nowUtc)` | `core.WithVersion(latest, next, now)` | 不可变替换 | +| `CoreLogic.SelectSnapshotWinner(hellos)` | `core.SelectSnapshotWinner(hellos) *Winner` | 三规则:版本最大→localModifiedAtUtc 最新→clientId 字典序更大 | + +### 7.9 SyncServer.cs → internal/sync/server.go + +| C# | Go | 行为 | +|---|---|---| +| 构造 | `sync.New(cfg, users, store, clk, logger)` | | +| `RemoveEmptyHubAfterRecovery(hub)` | `(*Server).RemoveEmptyHubAfterRecovery(h)` | allowDuringRecovery=true | +| `ReplaceUserLookup(users)` | `(*Server).ReplaceUserLookup(f)` | atomic.Pointer[users.File],Volatile.Write 等价 | +| `GetOrCreateHub(username)` | `(*Server).GetOrCreateHub(u)` | 初始版本 = store.GetVersion;互斥防并发建 hub | +| `ScanHeartbeats(now)` | `(*Server).ScanHeartbeats(now)` | 1s 扫描器调用 | +| `RebuildHub(hub)` | `(*Server).RebuildHub(h)` | 取消该用户全部连接并重建;进程存活 | +| `RegisterPendingHello/UnregisterPendingHello` | 同名 | pendingHellos 集合 | +| `EnqueueHelloTimeout(c)` / `CloseAfterHelloTimeoutAsync` | `enqueueHelloTimeout(c)` / goroutine `closeAfterHelloTimeout` | time.AfterFunc 等价 | +| `CancelConnection(c, reason)` | `(*Server).CancelConnection(c, reason)` | MarkClosed 守卫行为保留(含 §13.9 静默熔断现状) | +| `EnqueueImmediateClose(c, reason)` | `(*Server).EnqueueImmediateClose(c, reason)` | Socket.Abort 等价 = `conn.UnderlyingConn().Close()` | +| `ShutdownAsync(drain, now)` | `(*Server).Shutdown(drain, now)` | **34 秒无超时 close 握手现状原样保留**(§13.8) | +| `CloseConnectionAsync(c, status, reason)` | `closeConnection(c, status, reason)` | | + +### 7.10 Hub(UserHub.cs / UserRegistry.cs / UserJobs.cs / IConnectionCoordinator.cs)→ internal/hub + +| C# | Go | 行为 | +|---|---|---| +| `UserHub` 构造 | `hub.New(username, cfg, processStart, coord, store, initialVersion)` | SeenIdRing/TokenBucket/无界队列初始化 | +| `AddConnection/RemoveConnection` | `(*Hub).AddConnection/RemoveConnection` | | +| `StartIfIdle()` | `(*Hub).StartIfIdle()` | mutex + 单读者防重入;启动单消费 goroutine | +| `TryWriteJob(job)` | `(*Hub).TryWriteJob(job)` | 无界队列恒真 | +| `RunUserLoopAsync(ctx)` | `(*Hub).RunUserLoop(ctx)` | recover → RebuildHub(F7) | +| `ProcessJob(job, now)` | `processJob(job, now)` | clip/pong/hello/disconnect 分派 | +| `AcceptSnapshot(hello)` | `(*Hub).AcceptSnapshot(hello)` | 预算累计(仅 payload UTF-8 字节) | +| `ClassifyClip(clip, conn)` | `(*Hub).ClassifyClip(clip, c) RecoveryDecision` | 恢复窗口有界队列;满断开提交者 | +| `CloseRecoveryWindow(now)` | `(*Hub).CloseRecoveryWindow(now)` | 选举→恢复→按到达序处理恢复队列→广播 welcome | +| `BroadcastWelcome(now)` | `broadcastWelcome(now)` | | +| `IsRecoveryWindowOpen(now)` / `EnsureRecoveryWindowClosed(now)` | 同名 | 窗口 = ProcessStart + snapshot_window_seconds | +| `MarkActivity` / `MarkActivityForScan` | 合并为 `markActivity(now)` | 10 分钟空闲回收依据 | +| `ApplyClip(clip, sender, now)` | `(*Hub).ApplyClip(clip, sender, now)` | 幂等(内容比较)→令牌桶→NextVersion→SaveVersion→不可变替换→单次序列化广播(排除发送方连接)→ACK;**死分支兜底原样保留**(§13.6):`dupLatest != nil` 优先,`Latest` 次之,空 LatestText 兜底(不可达) | +| `BroadcastToConnection(c, payload)` | `broadcastToConnection(c, payload)` | | +| `BroadcastAsync(payload)` | `(*Hub).Broadcast(payload)` | | +| `UserRegistry.GetOrAdd/TryGetValue/RemoveIfEmpty/Remove` | `registry.GetOrAdd/TryGet/RemoveIfEmpty(h, allowDuringRecovery)/Remove` | ConcurrentDictionary → mutex map | +| `UserJob` 层级 + `RecoveryClip` | Go interface `UserJob` + ClipJob/HelloJob/PongJob/DisconnectJob struct + `RecoveryClip` struct | | +| `IConnectionCoordinator` | `hub.Coordinator` interface | 方法集一致 | + +### 7.11 Hosting(ConnectionHandler.cs / SyncEndpoint.cs / UserFileWatcher.cs / HeartbeatScannerService.cs)→ internal/hosting + +| C# | Go | 行为 | +|---|---|---| +| `ConnectionHandler.RunAsync(provisional, payload, cfg, server)` | `RunConnection(ctx, provisional, payload, cfg, srv)` | 转正:Hub 赋值、注册 hello 截止 | +| `ReceiveFrameAsync(...)` | `receiveFrame(c)` | gorilla `SetReadLimit(maxFrameBytes)`;超限→1009;零长度帧→frame_too_large 1009;客户端 Close 帧→回 1000 退出读循环但**不立即取消 CTS**(§5.7 现状) | +| `SendAndClosePreHelloAsync(...)` | `sendAndClosePreHello(...)` | 预 hello 阶段错误:invalid_message→1008、超限→1009 | +| `ReadLoopAsync(connection, cfg, server)` | `readLoop(c, cfg, srv)` | 帧→ParseClientMessage→投递用户队列/本地错误处理 | +| `ConnectionSendLoopAsync(connection)` | `sendLoop(c)` | 唯一写者;ctx.Done / 出队写出;取消与非取消异常同路清理 | +| `SendSafeAsync(...)` | `sendSafe(c, payload, srv)` | | +| `SyncEndpoint.HandleAsync(...)` | `HandleSync(w, r, cfg, srv)` | 升级前 Bearer 验证(401 不升级)、gorilla `CheckOrigin` 允许同源语义对齐、仅接受 `textcascade.v1`(Ordinal 精确)否则 400、非 WS 400 | +| `SelectSubProtocol(requested)` | `selectSubprotocol(r)` | gorilla Subprotocols 辅助下自校验 | +| `UserFileWatcher` 构造/`Start` | `watcher.New(...)` / `(*Watcher).Start()` | fsnotify Changed/Created/Deleted/Renamed + 250ms 防抖 + 30s ticker 兜底无条件重载 | +| `OnFileChanged/OnFileRenamed/OnFileError` | 事件分派函数 | | +| `ScheduleReload` / `ReloadAsync` | `scheduleReload` / `reload` | 3 次 50ms 退避;全败保留旧表 + warning;成功 → `srv.ReplaceUserLookup` | +| `Dispose` | `(*Watcher).Close()` | | +| `HeartbeatScannerService.ExecuteAsync/StopAsync/Scan` | `scanner.Run(ctx)` / ctx 取消 / `scan(now)` | 1s ticker;panic recover 记日志 | + +### 7.12 RuntimeStateStore.cs → internal/state/store.go + +| C# | Go | 行为 | +|---|---|---| +| `RuntimeStateEntry/RuntimeStateFile` | struct | `{"entries":[{"username":...,"version":...}]}` 格式不变 | +| 构造(flush 循环) | `state.NewStore(path, cfg)` | goroutine + 5s ticker;脏位快照 | +| `GetVersion(username)` | `(*Store).GetVersion(u) uint64` | | +| `SaveVersion(username, version)` | `(*Store).SaveVersion(u, v)` | 单调 max 合并(CAS),防乱序回退 | +| `Flush()` | `(*Store).Flush() bool` | 临时文件+rename+fsync;结构非法(重复键/空 username/零版本)启动 fail-fast 在 load 侧 | +| `RunFlushLoopAsync` / `Dispose` | `runFlushLoop(ctx)` / `(*Store).Stop()` | 停机同步 flush | +| `Load(path)` / `WriteAtomic(...)` | `load` / `writeAtomic` | | + +### 7.13 SecurityLogging.cs → internal/logging/security.go + +| C# | Go | 行为 | +|---|---|---| +| `LogSecurityEvent(logger, event, pairs)` | `logutil.SecurityEvent(logger, event string, fields ...Field)` | slog 自定义单行 Handler(Q12)复刻 `yyyy-MM-ddTHH:mm:ssZ ` 时间戳与扁平字段 | +| `RedactFields` / `RedactSensitive` | `redactFields` / `RedactSensitive` | 脱敏规则一致 | +| `TokenPrefix` | `logutil.TokenPrefix` | 保留(生产不调用,1:1) | + +日志事件与字段完全一致:login(username,ip,success[,reason])、connect/disconnect(username,clientId,connectionId[,reason])、clip(username,version,clipId,bytes,fromClientId,encrypted)、reject(username,code,bytes);登录失败折叠 `reason=invalid_credentials`。 + +### 7.14 Cli.cs → internal/cli/{cli.go, lock.go} + +| C# | Go | 行为 | +|---|---|---| +| `RunCli(args, hasher)` | `cli.Run(args, hasher) int` | 动词:user add/passwd/disable/enable/delete/revoke-tokens/list/hash + serve | +| `CreateLockPath(usersFile)` | `CreateLockPath` | users.json 同目录 `users.json.lock` | +| `PrintUsage` | `printUsage` | 文案一致 | +| `TryExtractConfigOption(ref args, out path)` | `tryExtractConfigOption(args) (rest, path)` | 回退顺序 --config → TEXTCASCADE_CONFIG → ./textcascade.toml | +| `CommandAddUser/Passwd/SetDisabled/DeleteUser/RevokeTokens/ListUsers/HashPassword` | `cmdAdd/cmdPasswd/cmdSetDisabled/cmdDelete/cmdRevoke/cmdList/cmdHash` | 水位分配、溢出放弃、字节级文件保留行为一致 | +| `LoadForWrite(path)` | `loadForWrite(path)` | | +| `IncrementWatermark(current)` | `incrementWatermark(cur)` | 溢出放弃(显式检测) | +| `CreateArgon2Config(config)` | `createArgon2Params(cfg)` | | +| `TryGetOption/HasFlag/HasPasswordStdin` | 同名小写 | | +| `ReadPassword(prompt, args)` | `readPassword(prompt, args)` | --password-stdin 或 x/term.ReadPassword | +| `SingleInstanceLockHandle/Acquire(path, pollDelay)` | `lock.Acquire(path, pollDelay) (*Handle, error)` | gofrs/flock:OpenOrCreate + FileShare.None 语义等价(进程死亡 OS 释放);PID 仅诊断写入;3 次重试后优雅失败 | + +### 7.15 Models(ConnectionContext.cs / ConnectionStateBag.cs / ReceivedMessage.cs)→ internal/models + +| C# | Go | 行为 | +|---|---|---| +| `ConnectionContext` | `struct Connection` | ID/Username/ClientID/ClientName/Conn(gorilla)/Hub(转正一次性赋值后不可变)/State | +| `ConnectionStateBag` | `struct StateBag` | lastSeen/lastPingAt 锁内赋值;SendCh chan;HelloDeadline | +| `MarkPingAwaitingPong/TryTakePongAwaiting` | 同名 | | +| `MarkClosed()` | `(*StateBag).MarkClosed() bool` | CAS 守卫 | +| `TryStartHelloTimeout()` | `(*StateBag).TryStartHelloTimeout() bool` | | +| `TryEnqueueSend(payload)` | `(*StateBag).TryEnqueueSend(p []byte) bool` | `select default`,满 false | +| `ReceivedMessage` | `struct Frame` | | + +## 8. 关键语义对照细节 + +| 项 | C# | Go 1:1 方案 | +|---|---|---| +| 协议出站时间戳 | `yyyy-MM-dd'T'HH:mm:ss'Z'`(UTC 秒级) | layout `"2006-01-02T15:04:05Z"` | +| 登录响应 expiresAtUtc | "O" 往返格式(固定 7 位小数秒) | layout `"2006-01-02T15:04:05.0000000Z07:00"` | +| 入站时间戳 | 秒级或 ISO 往返、偏移必须为零 | `ParseFlexibleTime` 接受两种形态 | +| token base64url | System.Buffers.Text.Base64Url | `base64.RawURLEncoding`(无填充一致) | +| HMAC 比较 | 固定时间 | `hmac.Equal` | +| 字节长度校验 | UTF-8 字节数(1–128 / 0–128 / 4096) | `len([]byte(s))` 一致 | +| ulong 溢出 | C# checked 抛出 → RebuildHub | `cur == math.MaxUint64` 显式 panic → recover → RebuildHub | +| unbounded → 有界转换 | Channel.Writer/Reader | slice+Cond 队列 / buffered chan | +| volatile 写 | Volatile.Write | `atomic.Pointer` | +| File.Replace(Windows)/ rename | 原子替换 | `os.Rename`(Windows 为 MoveFileEx REPLACE_EXISTING) | +| FileSystemWatcher | 4 事件 + 防抖 | fsnotify 事件 + time.AfterFunc 防抖 | + +## 9. 测试迁移计划(全量,Q3) + +| C# 测试(TextCascade.Server.Tests) | Go 测试(同包 _test.go) | +|---|---| +| TokenServiceTests / AuthDeepTests / AuthServiceTimingTests | auth 包(token 全形态、Argon2 三函数、needsRehash、登录时序侧信道) | +| ClipAndCoreTests / RuntimeStateAndProtocolTests / IdempotencyBehaviorTests / ConnectionStateTests | core / protocol / state / models 包 | +| ConfigTests / UsersFileTests / CliWatermarkTests / SingleInstanceLockTests | config / users / cli 包 | +| ContractTests + ContractSamples(全矩阵) | protocol 包 + `testdata/contract-samples/`(复制自 main,CI sha256 校验防漂移) | +| WebSocketIntegrationTests(5 例) | server 集成(httptest 或 127.0.0.1:0 真实监听 + FastHasher 注入等价物) | +| NetworkIntegration(12 例,真实 TLS) | 同等 12 例(runtime 自签证书、显式 TLS1.2/1.3 探针、随机端口、帧分片、1009、重启恢复、bye/1001、HTTPS 登录全链路) | +| SlowHashSmokeTests(3 例) | 同等 3 例(生产参数真实 Argon2id) | +| UserFileWatcherTests / UserHubCoordinationTests / UserLoopConcurrencyTests / LoginLimiterTests | 对应包 | + +契约字节不变式断言(welcome/clip/ack/ping/bye/error/login-response/token payload)逐条搬运;C# Theory ↔ Go table-driven + t.Run。 + +## 10. CI 与发布(go 分支) + +- `ci.yml`(go 分支改造):build+vet+test(过滤 network tag)+ 独立 network job + 契约样本 sha256 与 main 一致性校验步骤。 +- `release.yml`:tag 触发,linux-x64/win-x64 `GOOS/GOARCH` 矩阵,`-trimpath -ldflags "-X main.version="`,产物 `TextCascade.Server(.exe)`。 +- README 增加 Go 版构建/部署/证书支持矩阵说明(衍生后续项)。 + +## 11. 迁移注意事项(生产切换 runbook,Q6 结果) + +1. **切换前必须重置全部用户密码**:Go 版无法验证 C# 创建的存量 Argon2 哈希。停机窗口内对每个存量用户执行 `TextCascade.Server user passwd --username `(Go 版二进制)。 +2. token 与 tokenVersion 机制不变:重置密码不撤销已签发 token;如需强制重新登录,对每个用户执行 `user revoke-tokens`(可选步骤,明确决策后加入 runbook)。 +3. users.json / textcascade.state.json / textcascade.toml 格式不变,原样沿用;证书 PEM 原样沿用(PFX 路径实现首日验证)。 +4. systemd 单元沿用(二进制路径替换);perf.md 场景重测并修订内存目标。 + +## 12. 验收清单 + +- [ ] 契约测试全矩阵通过(与 C# 相同样本、相同字节不变式) +- [ ] 单元 162 + 网络 12 + SlowHash 3 等价例全绿 +- [ ] C# 版 §10.2 计划中尚未实施的 6 个补齐用例(子协议 400、hello 超时、NeedsRehash 不重写、同 clientId 排除、用户隔离、慢连接取消)**不在本次范围**(1:1 原则:C# 没有的测试不发明) +- [ ] 手动对拍:Go 版与 C# 版同时挂起,同一客户端登录两端行为一致(welcome/广播/恢复) +- [ ] perf.md 场景重测,内存目标修订 + +## 13. 继承的实现差距台账(1:1 保留,不借迁移修复) + +C# spec §15 的 9 项差距在 Go 版**原样保留**(含对应实现形态),除非用户另行决策: + +1. 预 hello 连接不在 bye/1001 广播范围; +2. 空 hub 回收遗留 parked goroutine(Channel 未 Close 等价语义); +3. 部分队列满路径直调 cancel 绕过 CancelConnection 入口; +4. TLS 下限跟随 OS 默认; +5. 明文测试缝隙(Go 侧对应:测试构建路径允许无证书 http.Server); +6. ApplyClip 死分支兜底保留; +7. 待补齐测试项维持待办; +8. 停机 close 握手等待无超时(34 秒现状); +9. 队列满熔断不产生 disconnect 安全事件。 + +> 若希望借迁移修复其中某项(例如 §13.8 停机 34 秒最痛),请在评审本 spec 时指出,按变更单处理而非默认纳入。 diff --git a/docs/server-spec.md b/docs/server-spec.md new file mode 100644 index 0000000..1baf17d --- /dev/null +++ b/docs/server-spec.md @@ -0,0 +1,773 @@ +# TextCascade 轻量文本同步服务端规格 + +状态:与 v0.5.0 实现对齐 +日期:2026-09-05 +协议目标:不兼容原 ClipCascade,只做轻量、可靠、高性能的文本最新值同步 + +## 1. 目标与非目标 + +### 1.1 目标 + +- 仅同步文本最新值:每用户只保存一份当前文本,不保存历史。文本本体只在内存中;用户版本号经 RuntimeStateStore 周期落盘用于重启续接(见 3.4)。 +- 无数据库:账号使用 `users.json`,版本号使用 `textcascade.state.json` 状态文件;除此之外无其他磁盘写入路径。 +- 服务端重启可恢复:客户端用无状态 token 重连,并在恢复窗口内上报 snapshot;版本基准跨重启保持单调。 +- 低空闲资源占用:无数据库轮询、无 WebUI、无 metrics endpoint。已知开销:RuntimeStateStore 每 5 秒脏检查刷盘、UserFileWatcher 每 30 秒轮询兜底重载。 +- 明确边界:协议错误显式返回,慢连接被隔离或断开,绝不拖垮整个服务。 +- 三端协议实现:服务端与桌面端使用 C#,Android 端使用 Kotlin;三端手写模型,由服务端契约测试约束。 + +### 1.2 非目标 + +- 不兼容原 ClipCascade 的 Spring、CSRF、JSESSIONID、STOMP 协议。 +- 不支持图片、文件、剪贴板历史、离线消息队列、逐设备 ACK 状态。 +- 不支持多实例分布式部署、数据库后端、管理后台或 WebUI。 +- 不提供 Prometheus metrics;内部只保留轻量计数器。 +- 不做 Socket.IO;使用原生 WebSocket。 + +## 2. 已定架构 + +整体结构与数据流: + +```mermaid +flowchart LR + C["客户端(桌面 / Android)"] + K["Kestrel(TLS 终结)"] + SE["SyncEndpoint:升级前验 token、子协议协商"] + RL["ReadLoopAsync(每连接):收帧、解析、验证"] + subgraph HUB["UserHub(每在线用户一个)"] + CH["用户 Channel(无界)"] + UL["RunUserLoopAsync(单消费者)"] + LT["LatestText(不可变替换)"] + end + SL["ConnectionSendLoopAsync(每连接,有界发送队列)"] + RS[("RuntimeStateStore:版本落盘")] + FW["UserFileWatcher:users.json 热加载"] + HS["HeartbeatScannerService(1 Hz)"] + REG["UserRegistry"] + C -->|"WSS · textcascade.v1"| K --> SE --> RL + RL -->|"用户级 job"| CH + CH --> UL --> LT + UL -->|"单次序列化,除发送方外广播"| SL + SL --> C + REG --- HUB + FW -.->|"查找表原子替换"| REG + UL -.->|"脏位,5 秒周期刷盘"| RS + HS -.->|"ping 调度、hello 与心跳超时判定"| SE +``` + +### 2.1 运行时与进程 + +- 技术栈:ASP.NET Core Minimal API + Kestrel 原生 WebSocket。 +- 目标框架:`net10.0`;产品版本采用 SemVer,写入 `TextCascade.Server.csproj` 的 `Version`(当前 0.5.0)。 +- 进程模型:单进程;生产环境由 systemd 或 Windows Service 托管并负责崩溃自动重启。 +- TLS:Kestrel 直接终止 TLS;不提供生产/开发模式开关,所有部署都禁止明文 HTTP 登录。TLS 协议版本跟随 OS 默认策略,未显式固定下限(见 8.2 与差距台账)。 +- 部署产物:框架依赖单文件;目标机必须预装对应 .NET Runtime。 + +### 2.2 核心对象 + +- `ConnectionContext`:稳定属性,包括连接 ID、用户名、clientId、clientName、socket、认证信息;`Hub` 属性为 internal set,仅在临时连接转正时一次性赋值,此后不可变。 +- `ConnectionStateBag`:可变运行时状态,包括 lastSeen、关闭标记、发送 Channel、HelloDeadline;修改收敛到少量明确函数。 +- `UserHub`:每个在线用户一个 hub,持有最新值、版本号、幂等 SeenIdRing、令牌桶与用户 Channel(无界)。 +- `UserRegistry`:`ConcurrentDictionary`,不同用户天然并发。 +- `LatestText`:不可变 record,包含 payload、version、hash、encrypted、fromClientId、fromClientName、updatedAtUtc;更新即替换引用。 +- `RuntimeStateStore`:版本号落盘存储(见 3.4)。 + +### 2.3 并发模型 + +1. 每个连接一个独立 `ReadLoopAsync`。 +2. 读循环只负责收帧、解析、验证,然后把用户级 job 投递到 UserHub Channel。 +3. 每个 UserHub 一个 `RunUserLoopAsync` 单消费者,串行处理该用户的 clip、pong 与恢复 job。 +4. 广播时只序列化一次 UTF-8 字节,并把同一份字节投递到每个连接的有界发送 Channel。 +5. 每个连接一个 `ConnectionSendLoopAsync`,慢连接只积压自己的队列。 +6. 发送队列满立即取消该连接,不等待 drain,不补发应用层 error 或 WebSocket close frame。各广播/ACK/ping 满队列路径直接 `Cts.Cancel()`;统一清理由连接处理器的 finally 兜底完成。`EnqueueImmediateClose` 路径额外执行 `Socket.Abort()`。 +7. 用户循环异常(含 `NextVersion` ulong 溢出抛出)触发 `RebuildHub`:取消该用户全部连接并重建 hub,进程存活——即版本溢出按"单用户熔断"处理而非进程级 fatal。 + +## 3. 配置与用户 + +### 3.1 配置函数 + +- `CreateDefaultConfig()`:内置安全默认值(RuntimeConfig.cs:51)。 +- `LoadTomlConfig(path)`:读取可选 TOML 配置并覆盖默认值;支持 `--config ` 参数或 `TEXTCASCADE_CONFIG` 环境变量指定,回退顺序为 `--config` → `TEXTCASCADE_CONFIG` → 当前目录 `textcascade.toml`。 +- `ApplyEnvironmentOverrides(config)`:环境变量覆盖,实际清单为 `TEXTCASCADE_BIND`、`TEXTCASCADE_PORT`、`TEXTCASCADE_CERTIFICATE_PATH`、`TEXTCASCADE_USERS_FILE`、`TEXTCASCADE_STATE_FILE`,以及 `token_secret_env` 所指名的 token secret 变量。 +- `ValidateConfig(config)`:启动时强校验;非法值 fail-fast。仅服务端启动路径调用,CLI 故意不调用(CLI 无法要求 token secret 存在)。 +- 服务端入口 `ServerHost.RunServer(args)` 加载证书后经 `ServerHost.CreateApp(args, config, users, stateStore, hasher, clock, certificate)` 构建 WebApplication。 + +默认配置文件示例: + +```toml +[server] +bind = "0.0.0.0" +port = 8443 +certificate_path = "certs/server.pfx" + +[auth] +token_ttl_days = 30 +token_secret_env = "TEXTCASCADE_TOKEN_SECRET" +argon2_memory_kib = 19456 +argon2_iterations = 2 +argon2_parallelism = 1 + +[limits] +max_text_bytes = 524288 +max_frame_bytes = 589824 +send_queue_capacity = 16 +seen_id_capacity = 64 +hello_timeout_seconds = 5 +heartbeat_interval_seconds = 30 +heartbeat_timeout_seconds = 60 +snapshot_window_seconds = 3 +snapshot_total_bytes = 4194304 +recovery_clip_queue_capacity = 16 + +[rate_limit] +login_ip_per_minute = 10 +login_user_per_minute = 5 +max_keys = 10000 +clip_burst = 10 +clip_tokens_per_second = 2 + +[files] +users_file = "users.json" +state_file = "textcascade.state.json" +``` + +规则: + +- 服务端不提供 production/environment 模式开关,安全校验不因部署环境降低。 +- `token_secret_env` 指向环境变量名;token secret 不写入 TOML。 +- token secret 必须由环境变量提供,长度至少 32 字节;缺失或过短时启动失败。 +- TLS 始终启用;`certificate_path` 必须指向服务端可用证书。 +- 证书支持无密码格式:`.pem` / `.crt` 必须是包含叶证书与未加密私钥的 PEM bundle(允许同名 `.key` 边车文件承载私钥);`.pfx` / `.p12` 必须可无密码加载。遇到需要密码的 PFX 时启动失败。 +- 私钥存储(v0.5.0 起):PFX 在 Windows 上以 `DefaultKeySet` 持久化私钥加载(SChannel 无法用 ephemeral key 完成 TLS 握手),非 Windows 平台使用 `EphemeralKeySet`;PEM 在 Windows 上经 PFX 导出重导入以持久化私钥(CertificateLoader,ServerHost.cs)。 +- TOML 使用宽松解析:必须以 UTF-8 读取(非 UTF-8 字节 fail-fast);未知键忽略并输出 warning;结构或类型非法 fail-fast。**重复键视为解析错误直接启动失败**(Tomlyn 语义)。 +- `max_frame_bytes` 必须大于 `max_text_bytes`,差额留给 JSON 协议头。 +- 所有容量与时间配置必须大于 0,心跳超时必须大于心跳间隔。 +- 校验顺序:Load → EnvironmentOverrides → ValidateConfig。 + +### 3.2 用户存储与热加载 + +文件:`users.json` + +```json +{ + "nextTokenVersion": 3, + "users": [ + { + "username": "alice", + "passwordHash": "$argon2id$...", + "tokenVersion": 1, + "disabled": false + } + ] +} +``` + +函数: + +- `LoadUsers(path)`:启动时全量读取。 +- `ValidateUsers(users)`:校验 `nextTokenVersion` 必填且大于所有用户 `tokenVersion`;校验用户名唯一、哈希格式、正数 `long` tokenVersion 与 disabled 字段。 +- `BuildUserLookup(users)`:构造只读用户查找表。 +- `SaveUsers(path, users)`:先 Validate 再临时文件原子替换(Windows `File.Replace`,POSIX rename),刷盘后才成功返回。 + +热加载(v0.3.0 起): + +- `UserFileWatcher` 以 `FileSystemWatcher` 监听 users.json 所在目录的 Changed/Created/Deleted/Renamed 事件,250ms 防抖;另有每 30 秒周期轮询兜底,即使未收到事件也无条件尝试重载。 +- 重载失败按 50ms 退避重试 3 次;全部失败保留旧查找表并输出 warning。 +- 成功后经 `SyncServer.ReplaceUserLookup` 原子替换(Volatile.Write)。登录与新 WebSocket 升级立即反映文件变更;已建立的连接不受影响(持旧上下文)。 +- 服务端自身从不写入用户文件;删除或修改条目即刻影响新认证,被删/禁用用户的存量连接依赖其下次重连时被拒。 + +说明: + +- `tokenVersion` 不是软件版本,而是账号 token 作废计数器。 +- `tokenVersion` 与 `nextTokenVersion` 使用有符号 64 位整数(`long`),只允许正数,创建与递增时溢出即放弃操作。 +- `nextTokenVersion` 是全局水位;新增用户取当前水位作为 `tokenVersion`,随后水位加一。 +- `revoke-tokens` 将目标用户 `tokenVersion` 更新为当前水位,随后水位加一,保证未来新建任何账号都不会复用已撤销版本。 +- 支持直接删除用户条目,不保留墓碑;之后重建同名用户时从全局水位取新 `tokenVersion`,因此不会落入旧 token 的版本空档。 + +### 3.3 用户 CLI + +入口在同一服务端可执行文件中,不提供 WebUI。 + +```bash +TextCascade.Server user add --username alice # 密码交互输入,或 --password-stdin +TextCascade.Server user passwd --username alice +TextCascade.Server user disable --username alice +TextCascade.Server user enable --username alice +TextCascade.Server user delete --username alice +TextCascade.Server user revoke-tokens --username alice +TextCascade.Server user list +TextCascade.Server user hash +TextCascade.Server serve # 启动服务(Program.cs 动词分发) +``` + +所有命令接受 `--config `;CLI 写入 `users.json` 前先持有单实例文件锁(锁文件为 users.json 同目录 `users.json.lock`,以 `FileShare.None` 独占打开,持有进程退出或崩溃时由 OS 自动释放),再使用临时文件加原子替换。崩溃残留的锁文件会被直接复用、不再阻塞;锁文件中的 PID 仅作诊断用途,不参与锁判定。Windows 与 Linux 行为一致;检测到其它 CLI 实例仍持有锁时重试后失败退出。服务运行中修改文件的即时生效性见 3.2 热加载。 + +### 3.4 RuntimeStateStore(版本号落盘) + +- 文件:`[files] state_file`,默认 `textcascade.state.json`;可用环境变量 `TEXTCASCADE_STATE_FILE` 覆盖。 +- 格式:`{"entries":[{"username":"alice","version":129}, ...]}`。 +- 写入时机:`PeriodicTimer` 每 5 秒对脏数据原子快照落盘;优雅停机时同步 flush;每次 clip 成功(`SaveVersion`)标记脏位。`SaveVersion` 采用单调 max 合并,防止乱序回退。 +- 启动行为:`GetOrCreateHub` 用 `GetVersion(username)` 作为 hub 初始版本;状态文件结构非法(重复键、空 username、零版本)fail-fast。 +- 该机制使重启后版本号跨重启单调增长(完整语义见 6.2)。 + +## 4. HTTP API + +### 4.1 登录 + +```http +POST /api/v1/login +Content-Type: application/json +``` + +请求: + +```json +{ + "username": "alice", + "password": "raw-password" +} +``` + +实现要点: + +- `AuthService.HandleLoginAsync(HttpContext, config, syncServer, logger)`:薄入口,HTTP 处理内联其中。 +- 请求体限制 16KB、JSON 深度 3、拒绝未知字段与重复字段(v0.5.0 起:`ParseLoginRequest` 单次严格 `JsonSerializer.DeserializeAsync`,`MaxDepth=3`、`AllowDuplicateProperties=false`、`JsonUnmappedMemberHandling.Disallow`;16KB 上限经 `IHttpMaxRequestBodySizeFeature` 强制,chunked 请求体同样受限);畸形请求体(缺字段、类型错误、非法 JSON)返回规格内统一形态之外的 `400 invalid_request`。 +- 认证使用常数时间比较;不存在的用户以缓存的 dummy hash 执行同等验证,消除用户名存在性的计时侧信道。 +- 限流命中返回 `429 Too Many Requests`,错误码 `rate_limited`;认证失败、用户不存在、用户禁用统一返回 `401 {"error":"invalid_credentials","message":"Invalid username or password."}`。 + +成功: + +```json +{ + "token": "", + "expiresAtUtc": "2026-09-17T00:00:00.0000000Z", + "protocolVersion": 1, + "maxTextBytes": 524288, + "helloTimeoutSeconds": 5, + "heartbeatIntervalSeconds": 30, + "heartbeatTimeoutSeconds": 60, + "needsRehash": true +} +``` + +规则: + +- `expiresAtUtc` 序列化为包含小数秒的 ISO 往返格式("O"),非整秒。 +- `needsRehash` 为条件可选布尔:Argon2 参数与当前配置不一致时才出现。 +- 客户端通过 TLS 发送原始密码;客户端不做 Argon2id。 +- 用户不存在与密码错误返回相同错误,避免枚举用户。 +- Argon2 参数变化时,登录路径只输出 NeedsRehash warning 并在响应携带 `needsRehash`,不重写 `users.json`;用户通过 CLI `passwd` 设置新密码时才生成当前参数的哈希。 + +### 4.2 Token + +格式: + +```text +base64url(payload).base64url(hmac-sha256(payload, secret)) +``` + +payload: + +```json +{ + "sub": "alice", + "ver": 1, + "iat": 1760000000, + "exp": 1762592000 +} +``` + +Token JSON 规则: + +- 服务端签发时按 `sub`、`ver`、`iat`、`exp` 固定字段序输出最小化 UTF-8 JSON。 +- 验证时字段顺序无关,但拒绝重复字段与未知字段。 +- `sub` 是非空用户名;`ver`、`iat`、`exp` 均为有符号 64 位整数范围内的正整数;`exp` 必须大于 `iat`。 +- 数字不得以小数、指数或字符串形式表示。 + +核心函数(Auth.cs):`CreateTokenPayload(user, now, ttl)`、`SignToken(payload, secret)`(HMAC-SHA256)、`TokenService.TryVerifyToken(compact, now, userLookup, out payload)`——验签常数时间、验过期、验用户存在、验 tokenVersion。 + +规则: + +- HMAC 比较必须常数时间。 +- token 默认 30 天,可由配置调整。 +- token 无服务端状态,服务端重启后仍可验证。 +- 用户被禁用、删除或 tokenVersion 变化后被拒:热加载场景立即生效于新的登录与升级请求;重启后同样拒绝,删除后重建同名用户会从全局水位分配更高 tokenVersion。 + +### 4.3 登录限流 + +核心类 `SlidingWindowLoginLimiter`: + +- IP 与用户名双维度滑动窗口,任一超限即拒绝。 +- 默认每 IP 每分钟 10 次,每用户名每分钟 5 次。 +- 用户名维度统计所有登录请求;认证成功后清空该用户名窗口。 +- IP 维度统计所有登录请求;认证成功不清空 IP 窗口。 +- 限流器设置最大 key 数;达到上限时先清理全部过期项,仍满则拒绝新 key 的登录请求并返回 `429 rate_limited`。清理发生在每次访问时(RemoveExpired 全表扫描过期时间戳),比逐 key 惰性清理更积极。 +- 单实例部署下不做分布式限流。 +- 已知取舍:持有正确密码的攻击者可通过高频成功登录占满目标用户名窗口;接受该风险,以换取更简单的计数与重置规则。 + +### 4.4 健康检查 + +```http +GET /health (亦响应 HEAD /health) +``` + +```json +{ + "status": "ok" +} +``` + +不暴露连接数、内存、用户数等内部统计。 + +## 5. WebSocket 协议 + +常态连接生命周期(升级前已完成 Bearer token 验证与子协议协商): + +```mermaid +sequenceDiagram + participant C as 客户端 + participant S as 服务端 + C->>S: hello(clientId、clientName、lastServerVersion、snapshot) + S->>C: welcome(protocolVersion、latest 可省略) + C->>S: clip(id、payload、encrypted、hash) + S->>S: 幂等检查 → 令牌桶 → 版本自增 + S->>C: clip_ack(id、version) + S->>C: clip 广播(除发送方连接) + loop 每 heartbeat_interval_seconds(默认 30 秒) + S->>C: ping(serverTimeUtc) + C->>S: pong(clientTimeUtc) + end + S-->>C: bye(reason=server_shutdown)+ close 1001(停机场景) +``` + +### 5.1 连接建立 + +```http +GET /api/v1/sync +Authorization: Bearer +Sec-WebSocket-Protocol: textcascade.v1 +Upgrade: websocket +``` + +实现(Hosting/SyncEndpoint.cs 内联认证): + +- 升级前验 token;无效、过期、用户禁用或 tokenVersion 不匹配时不升级 WebSocket,直接返回 `401`。 +- 只接受 `textcascade.v1` 子协议(Ordinal 精确匹配),不匹配返回 `400`;非 WebSocket 请求也返回 `400`。 +- 认证成功后连接进入待 hello 状态,必须在 `hello_timeout_seconds`(默认 5 秒,自 ConnectionStateBag 构造时刻起算)内发送合法 hello,否则关闭。 +- hello 通过验证前,连接不在广播列表中,由统一扫描器独立计时 hello 截止。 + +### 5.2 Client Hello + +```json +{ + "type": "hello", + "clientId": "stable-device-id", + "clientName": "Windows-Desktop", + "lastServerVersion": 128, + "snapshot": { + "payload": "...", + "encrypted": true, + "hash": "client-local-hash", + "localModifiedAtUtc": "2026-08-18T08:00:00Z" + } +} +``` + +解析与校验(Protocol.cs): + +- 全部字段必填:`lastServerVersion` 缺失、非整数或负数按 `invalid_message` 拒绝(未知值为语义上的 0 由客户端显式填 0 表达)。接受显式 `"snapshot": null`。 +- `clientId`:UTF-8 字节数 1–128;`clientName`:0–128 字节;`hash` 上限 4096 字节。 +- 时间戳 `localModifiedAtUtc` 仅接受两种精确形式:UTC `"yyyy-MM-ddTHH:mm:ssZ"` 或 ISO 往返格式,偏移必须为零。 +- snapshot 仅在进程启动后的全局恢复窗口内参与选举;窗口结束后完整校验通过即丢弃,clip 是唯一文本写入路径。 + +### 5.3 Server Welcome + +```json +{ + "type": "welcome", + "protocolVersion": 1, + "latest": { + "version": 128, + "payload": "...", + "encrypted": true, + "hash": "...", + "fromClientId": "android-a", + "fromClientName": "android", + "updatedAtUtc": "2026-08-18T07:59:58Z" + } +} +``` + +规则: + +- **服务端内存无最新值时,`latest` 键整体省略**(序列化 WhenWritingNull),不会出现字面 `"latest": null`;三端解析器必须把"键缺失"当作"无最新值"。 +- 恢复窗口开启时 welcome 延迟发送:等待窗口收尾完成 snapshot 选举后统一广播。 +- 客户端收到相同 hash 或相同版本时可本地去重,不写剪贴板;hash 只用于本地剪贴板去重,服务端新旧值以版本为准。 + +### 5.4 发布文本 + +客户端: + +```json +{ + "type": "clip", + "id": "client-generated-unique-id", + "payload": "...", + "encrypted": true, + "hash": "..." +} +``` + +服务端广播给同用户除发送方**连接**外的其他在线连接: + +```json +{ + "type": "clip", + "version": 129, + "id": "client-generated-unique-id", + "payload": "...", + "encrypted": true, + "hash": "...", + "fromClientId": "windows-a", + "fromClientName": "Windows-Desktop", + "updatedAtUtc": "2026-08-18T08:01:00Z" +} +``` + +发送方收到 ACK: + +```json +{ + "type": "clip_ack", + "id": "client-generated-unique-id", + "version": 129, + "updatedAtUtc": "2026-08-18T08:01:00Z" +} +``` + +实现:`Protocol.ValidateClipMessage` 单函数按结构→语义→资源顺序早拒绝;`CheckFrameSize` 帧硬限制;`CheckPayloadSize` 文本限额;`SeenIdRing.IsUnchangedDuplicate/TryGetResult/RememberId` 幂等;`TokenBucket.TryAcquire` 用户级令牌桶;`CoreLogic.NextVersion` ulong 自增(溢出抛出触发 RebuildHub)。 + +幂等规则: + +- `id` 已见过 **且 payload/hash/encrypted 与上次完全一致**:不生成新版本、不消耗令牌桶,返回原版本 ACK;重复 ACK 仍进入发送方有界发送队列,队列满时按慢连接取消。 +- `id` 已见过但内容不同:记录 "Replacing reused clip id" warning 后**按全新消息处理**——消耗令牌桶、生成新版本并覆盖最新值。客户端不应复用已确认过的 id。 +- 相同 `clientId` 的其他连接仍收到广播,仅发送方连接被排除。 + +其余规则: + +- 客户端不携带版本号;版本由服务端按用户处理顺序生成。 +- 空文本、非法 UTF-8、结构缺字段、超帧、超文本、限流超限均拒绝。 +- `payload` 对服务端 opaque;`encrypted=true` 时服务端不解析内容。 +- 客户端 E2E 载荷约定(桌面端 v2.3.0 起固化):`encrypted=true` 时 payload 为紧凑 JSON,含 `nonce`/`ciphertext`/`tag` 三个 Base64 字段;AES-256-GCM,nonce 固定 12 字节(96-bit)、tag 固定 16 字节、无 AAD,密钥由密码 PBKDF2 派生;非 12 字节 nonce 客户端拒收。服务端不解析、不校验该结构,仅透传。 +- 发送队列容量按消息条数计算,默认 16;队列满立即取消连接,不补发 error 或 close frame。 +- 慢设备延迟到达的旧 clip 仍会获得新版本并覆盖最新值;这是最新值语义的预期行为,客户端需自行处理可能的回滚。 + +### 5.5 心跳 + +服务端定时发送应用层 JSON ping: + +```json +{ + "type": "ping", + "serverTimeUtc": "2026-08-18T08:02:00Z" +} +``` + +客户端必须返回: + +```json +{ + "type": "pong", + "clientTimeUtc": "2026-08-18T08:02:00Z" +} +``` + +实现: + +- 统一扫描器 `HeartbeatScannerService`(`BackgroundService` 内 `PeriodicTimer` 驱动,扫描异常记入日志而非静默吞掉)固定每 1 秒运行一次,集中处理 ping 调度(间隔默认 30 秒)、hello 超时与心跳超时判定(默认 60 秒无 pong 取消连接);检测延迟 0–1 秒,不提供独立配置。 +- pong 更新 lastSeen 经用户 Channel 由单消费者落账;用户循环被大 clip 占用时 pong 记账可能延迟数秒。 +- 收到没有未决 ping 的主动 pong,回复 `invalid_message` 错误帧但不断开连接(spec 外补充分支)。 + +### 5.6 错误 + +```json +{ + "type": "error", + "code": "text_too_large", + "message": "Text exceeds maxTextBytes.", + "referenceId": "client-generated-unique-id" +} +``` + +`referenceId` 为 null 时该键省略。 + +| code | 含义 | 连接处理 | +|---|---|---| +| `invalid_message` | JSON 结构或字段非法 | 可继续 | +| `text_too_large` | 文本字段超限 | 可继续 | +| `frame_too_large` | 完整帧超限(含零长度帧) | 先发 error,关闭 1009 | +| `empty_text` | 空文本 | 可继续 | +| `rate_limited` | 用户级发送限流 | 可继续 | +| `hello_timeout` | 未按时发送 hello | 先发 error,关闭 1008 | +| `server_busy` | 发送队列拥塞 | 立即取消;该错误不保证发送 | + +补充行为(实现事实): + +- 需要 close 的错误先发 error 帧、延时约 100ms 后再执行 close;同一连接同类错误只触发一次关闭流程(MarkClosed 守卫)。 +- hello 到达前的任何非法或非 hello 消息:发 `invalid_message` 错误后以 1008 关闭(预 hello 阶段一律不接受业务帧;预 hello 帧超限走 1009)。 +- 零长度帧判为 `frame_too_large` 关闭 1009。 +- 慢连接发送队列满时不补发应用层 error,也不写 close frame,直接进入取消路径;`server_busy` 语义对客户端不可靠,客户端应靠重连兜底。 + +可预期协议错误走 Result;不可预期异常由顶层兜底汇入统一清理。 + +### 5.7 关闭与清理 + +- 各类取消源(心跳超时、慢连接、恢复队列满、协议异常、停机)最终都终结于连接取消令牌触发;`CancelConnection(connection, reason)` 是正常路径入口,部分高频满队列路径直调 `Cts.Cancel()`(见 2.3 第 6 条),清理一致性由读循环 finally 保证。 +- 关闭 socket:普通场景 graceful close;`EnqueueImmediateClose` 场景 abort/dispose。 + +| close code | 含义 | +|---:|---| +| `1000` | 正常关闭 | +| `1001` | 服务端重启或维护 | +| `1008` | 策略关闭,例如 hello 超时、预 hello 非法消息 | +| `1009` | 帧过大 | + +`1013` 与 `4408` 不是本协议 close code,客户端不得依赖。 + +hub 清理: + +- 最后一个连接断开后 hub **不立即**从 registry 移除;统一扫描器在 hub 空闲满 **10 分钟**后回收空 hub(`LastActivityAt` 判定),窗口收尾清扫期间 `allowDuringRecovery=true` 即时移除仍空的 hub。 +- 已知问题:被回收 hub 的用户循环任务因 Channel 未 Complete 而遗留挂起(见差距台账)。 +- 客户端主动发 Close 帧时,读循环回 1000 后退出读循环但不立即取消连接 CTS;资源最迟在心跳超时扫描(≤~90 秒)回收,期间连接仍在广播列表中会被继续投递直到写出失败。 + +## 6. 最新值与恢复 + +### 6.1 正常运行 + +每个用户保存一个 `LatestText`: + +- `payload` +- `version` +- `hash` +- `encrypted` +- `fromClientId` +- `fromClientName` +- `updatedAtUtc` + +处理顺序: + +1. 读循环收帧并检查帧大小。 +2. JSON 解析与 `ValidateClipMessage`。 +3. 投递到用户 Channel。 +4. 用户单消费者执行幂等检查与令牌桶。 +5. `NextVersion` 在当前版本基础上自增。 +6. 不可变替换最新值并 `SaveVersion` 标记脏位。 +7. 广播给除发送者外的连接,并向发送者返回 ACK。 + +这是最新值语义,不是可靠队列语义。离线设备不补历史,重连后只拿当前最新值。 + +### 6.2 服务端重启恢复 + +恢复窗口从服务端进程构建时间起算(`SyncServer.ProcessStartTime`),结束时间为 `processStartTime + snapshot_window_seconds`。该窗口对全部用户统一生效,不按 UserHub 创建时间或首个 hello 到达时间重新计算。1 秒扫描器在窗口结束后统一对所有 hub 执行收尾。 + +选举与恢复规则: + +1. 版本基准来自 RuntimeStateStore:hub 初始版本 = 状态文件中该用户的持久化版本(无记录则为 0)。 +2. `lastServerVersion=0` 的 snapshot 不参与选举;只过滤正版本候选。 +3. 若没有正版本候选,恢复结果为空,welcome 不带最新值。 +4. 候选中优先选择 `lastServerVersion` 最大者;并列取 `localModifiedAtUtc` 最新者;再平局取 `clientId` 字典序更大者。 +5. **守卫**:winner 版本小于等于 hub 当前(持久化)版本,且两者相等时已有最新值的情形除外,否则放弃恢复(welcome 不下发)——持久化水位高于一切客户端认知时保持现状,下一条 clip 从持久化版本+1 继续。 +6. 恢复成功时 `LatestText.version` 直接使用 winner 的 `lastServerVersion`(不加一),并回写 SaveVersion。 +7. 恢复窗口内只收集 snapshot;合法 clip 进入独立有界恢复队列。 +8. 每用户 snapshot 预算只统计候选 `snapshot.payload` 的 UTF-8 字节数总和,上限 `snapshot_total_bytes`,达到上限后拒绝新候选、保留既有候选;元数据开销不计入预算。 +9. 恢复队列容量 `recovery_clip_queue_capacity`;满时断开提交者对应连接;连接断开其已排队 clip 丢弃。 +10. 窗口收尾顺序:选举 winner → 恢复最新值 → 按到达顺序串行处理恢复队列 → 广播 welcome。 + +重启后的完整链路: + +1. 服务端停机前广播 `bye` 并以 `1001` 关闭连接。 +2. 客户端识别服务端维护,使用无状态 token 直接重试 WebSocket。 +3. 服务端重启后 token secret 与 tokenVersion 未变,token 仍可验证;版本号跨重启单调。 +4. 客户端 hello 上报 snapshot。 +5. 3 秒窗口选举 winner。 +6. 服务端恢复或保持最新值并继续同步。 + +### 6.3 慢连接 + +每个连接有独立有界发送 Channel: + +- 默认容量 16 条消息。 +- `TryWrite` 失败即判定慢连接。 +- 立即取消该连接,不等待 drain,不补发应用层 error,也不写 close frame。 +- 发送循环观测取消后退出;`OperationCanceledException` 与非取消异常都汇入统一清理路径。 +- 满队列广播路径仅取消令牌、socket 随 finally dispose 回收;`EnqueueImmediateClose` 路径显式 `Socket.Abort()`,均不做 graceful 握手。 +- 客户端重连后通过 welcome 拿最新值,不补发中间消息。 + +慢连接不能阻塞用户单消费者,也不能影响同用户其他连接。 + +## 7. 优雅停机 + +流程: + +1. 收到 SIGTERM、Ctrl+C 或服务停止请求(Host 反向停止次序下 Kestrel 先停止接受新连接)。 +2. 向 registry 中所有已 hello 连接广播: + +```json +{ + "type": "bye", + "reason": "server_shutdown" +} +``` + +3. 以 close code `1001` 逐一关闭上述连接;bye 经各自有界发送队列投递,队列已满的连接会被静默跳过(既无 bye 也无 1001)。 +4. 等待最多 2 秒让 close frame 尽量发出。 +5. 取消所有连接 CTS;随后 RuntimeStateStore 同步 flush 状态文件。 +6. 清理 UserHub 与后台任务,进程退出交由系统服务管理器重启。 + +已知边界:处于预 hello 状态(pendingHellos)的连接不在 bye/1001 广播范围内,进程退出时随 socket 直接断开。 + +## 8. 日志与安全 + +### 8.1 结构化日志 + +实现(SecurityLogging.cs):`LogSecurityEvent(this ILogger, eventName, params (string,object)[])` 输出扁平化的结构化事件;`RedactSensitive(value)` 用于脱敏。 + +规则: + +- 密码绝不记录;token 不记录(代码中的 TokenPrefix 工具目前未被生产路径调用)。 +- clip payload 与 hash 不记录;clip 事件只记 version、clipId、字节数、encrypted、来源设备。 +- Authorization header 不进入任何日志。 +- 登录相关事件不区分"密码错误"与"用户不存在",也不出现"disabled"字样——全部折叠为 `reason=invalid_credentials`,防止枚举(测试锁定此行为)。 + +关键事件(实际字段): + +| 事件 | 字段 | +|---|---| +| login | username, ip, success[, reason](失败必带 reason) | +| connect | username, clientId, connectionId | +| disconnect | username, clientId, connectionId, reason | +| clip | username, version, clipId, bytes, fromClientId, encrypted | +| reject | username, code, bytes | + +### 8.2 传输与输入安全 + +- 生产只允许 HTTPS/WSS:`ServerHost.RunServer` 强制先加载证书,Kestrel 仅绑定单一 HTTPS endpoint。TLS 协议版本跟随 OS 默认策略,代码未显式设置 SslProtocols 下限;NetworkIntegration 测试以显式 Tls12/Tls13 客户端握手验证兼容性。 +- 测试路径豁免说明:`ServerHost.CreateApp` 的 certificate 形参允许传 null 构建纯 HTTP 主机,仅测试可达(InternalsVisibleTo 之下),生产入口不可能走到。 +- 不启用 CORS。 +- 不设置 Cookie,无 CSRF 面。 +- 登录请求体上限 16KB;登录与协议帧 JSON 深度限制均为 3。 +- 协议消息与登录请求只接受契约定义字段;重复字段与未知字段拒绝。若未来新增可选字段,必须提升或明确协议兼容策略。 + +## 9. 性能目标 + +性能目标、测量场景与实测结果由仓库根目录的 [perf.md](../perf.md)(中英双语)承载,作为构建基准设施的契约;未验证的目标不作为承诺。(协议层面保留的设计性质:广播单次 UTF-8 序列化、每连接有界发送队列、空闲路径只有心跳扫描与周期刷盘。) + +## 10. 测试计划 + +本节自包含描述测试现状与分层,不依赖外部文件。集成测试机制自 v0.3.0 起采用真实 Kestrel 绑定 `127.0.0.1:0` 的 fixture(`ServerHost.CreateApp` 构建 + FastPasswordHasher 注入)。 + +### 10.1 纯单元测试(现有覆盖) + +- `SignToken`/`TryVerifyToken`:往返、过期、tokenVersion 撤销、篡改、未知字段、禁用用户、用户缺失。 +- CLI 单实例文件锁(`FileShare.None`):活跃互斥、崩溃残留锁文件可复用、锁路径校验。 +- `SlidingWindowLoginLimiter`:双维度、跨 IP、成功仅清用户窗口、max keys、过期清理。 +- `TryAcquireClipToken`(TokenBucket refill)、`CheckFrameSize`/`CheckPayloadSize`、SeenIdRing 去重与淘汰、`NextVersion` 含 ulong.MaxValue 抛出、`SelectSnapshotWinner` 三规则。 +- Argon2 三函数(SlowHash)、token 数字全形态、CLI 水位/溢出、WithVersion、重复 id 行为级断言均已由测试覆盖。 +- 证书加载(CertificateLoaderTests):PEM RSA/ECDSA 证书、合并 bundle 与独立 `.key` 边车、缺私钥与无证书内容的拒绝路径。 + +### 10.2 CI 集成测试:真实 Kestrel loopback + +现有 `WebSocketIntegrationTests` 覆盖:登录与握手往返(含 welcome)、无效 token 不升级、两客户端广播与发送方 ACK、重复 id 同版本 ACK、断连后按最高 lastServerVersion 快照恢复(结合版本持久化)、突兀断开被记录且服务存活(日志不含密码/secret)。 + +计划中的补齐用例(子协议 400、hello 超时、NeedsRehash 不重写 users.json、同 clientId 排除规则、用户隔离、慢连接取消)尚未实施,落地后回填本节;bye/1001 停机链路已由 §10.3 网络集成测试覆盖。 + +### 10.3 本地网络测试:Category=NetworkIntegration + +```bash +dotnet test TextCascade.Server.slnx --filter Category=NetworkIntegration +``` + +CI 主测试任务以 `--filter "Category!=NetworkIntegration"` 排除该类别,由独立 CI 任务运行。覆盖:自签证书 TLS/WSS、显式 Tls12/Tls13 握手、随机端口绑定、真实帧分片、超限帧 1009、重启两次 CreateApp 后 token 直连与快照恢复、停机 bye/1001、HTTPS 登录全链路。 + +### 10.4 契约测试 + +样本文件组织于 Tests 项目 `ContractSamples/`(valid/invalid 分类、非法数字与非法 UTF-8 全矩阵、深度 4、重复/未知字段),由 Theory 驱动断言 `ParseClientMessage` 结果与序列化字节不变式;样本文件同时作为三端实现的公共对拍集合。 + +## 11. 客户端适配要求 + +客户端需要实现: + +1. `POST /api/v1/login` 获取 token。 +2. Authorization header + `textcascade.v1` 子协议建立 WebSocket。 +3. 在登录响应返回的 `helloTimeoutSeconds` 内发送 hello,并携带本地 snapshot。 +4. 应用层 ping/pong。 +5. clip 发送、ACK、接收。 +6. 保存服务端 version,重连时上报 `lastServerVersion`。 +7. 收到相同 hash 或相同版本时不写剪贴板;收到更晚到达的旧 clip 仍可能覆盖本地文本。 +8. 解析 welcome 时将 `latest` 键缺失视为"无最新值"。 +9. `1001` 后按服务端维护重连;token 未过期时优先直接重连。 +10. `401`、token 过期或 tokenVersion 失效时重新 HTTP 登录。 +11. 重连退避建议:1s、2s、5s、10s、30s、60s,之后固定 60s;收到 1001 时初期退避更温和。 + +保留客户端原有能力: + +- 本地剪贴板监听。 +- hash 去重。 +- 远端写入后的本地事件抑制。 +- 密码派生 AES-GCM 加密。 +- 密码安全保存与自动登录。 + +## 12. 实施里程碑 + +### M1:协议骨架 —— 已达成 + +配置加载与校验、users.json 与 CLI、登录端点、HMAC token、WebSocket 升级认证与子协议协商、hello/welcome、文本广播与 ACK、单元与集成测试基座。 + +### M2:可靠性 —— 已达成 + +用户 Channel 单消费者、有界发送队列与立即取消、幂等 id、服务端版本号与不可变最新值、应用层心跳、统一取消清理。 + +### M3:恢复与真实网络 —— 已达成,补齐用例见 §10.2 + +优雅停机 bye/1001、快照恢复窗口与预算/队列约束、tokenVersion 撤销、版本号持久化;真实 TCP/TLS 集成测试已落地(§10.3),其余补齐用例见 §10.2。 + +### M4:生产化 —— 大体达成,两项移交差距台账 + +Kestrel TLS、结构化日志与脱敏、登录与消息限流、框架依赖单文件发布、systemd/发布管线均已落地;性能基准由 perf.md 实测承载(见第 9 节)。 + +## 13. 版本与发布 + +- 产品版本采用 SemVer 2.0.0,从 `0.1.0` 开始演进,以 `TextCascade.Server.csproj` 的 `Version` 为准(当前 0.5.0)。 +- `protocolVersion` 只表示线协议版本,当前为 `1`,与产品版本独立演进。 +- 目标框架为 `net10.0`;目标机必须预装兼容的 .NET 10 Runtime。 +- 发布命令:`dotnet publish TextCascade.Server.csproj -c Release -p:PublishSingleFile=true`(win-x64/linux-x64 框架依赖单文件)。 +- 本地编译命令:`dotnet build TextCascade.Server.csproj -c Release`。 + +## 14. 决策台账 + +| 问题 | 结论 | +|---|---| +| 最大文本默认值 | 512KB | +| 最新值文本本体磁盘持久化 | 不做,文本只在内存 | +| 版本号持久化 | 做:v0.2.5 起 RuntimeStateStore 落盘 textcascade.state.json,版本跨重启单调 | +| 用户配置热加载 | 做:v0.3.0 起 UserFileWatcher 监听 + 轮询兜底,新认证即刻生效 | +| token 生命周期 | 长期 token + tokenVersion 撤销 | +| 删除后重建用户 | 全局 nextTokenVersion 水位 | +| metrics | 不启用 endpoint | +| 协议包 | 三端手写,服务端契约测试约束 | +| 关闭码 | 应用层 error + 标准 close code;不发送 1013/4408 | + +## 15. 实现差距台账(知悉现状,不构成承诺) + +以下为实现偏离或已知缺陷,如实记录、供排期参考: + +1. 优雅停机不覆盖预 hello 连接(见 §7 已知边界)。 +2. 空 hub 10 分钟回收时,其用户循环任务因 Channel 未 Complete 而永久挂起,每个被回收 hub 遗留一个 parked task。 +3. 部分队列满路径绕过 `CancelConnection` 入口直调 `Cts.Cancel()`,早退清理一致性依赖 finally(见 2.3)。 +4. TLS 协议下限未显式固定,依赖 OS 默认(§8.2)。 +5. `ServerHost.CreateApp(certificate:null)` 的明文测试缝隙(仅 InternalsVisibleTo 可达)。 +6. ApplyClip 中 duplicateId 且 Latest 为 null 的兜底分支不可达(死分支)。 +7. §10 中标注"待补齐/补齐中"的测试项尚未实施,落地后回填本 spec。 +8. 优雅停机对每个连接的 `CloseAsync` close 握手等待无超时:有静默客户端在线时,停机阶段实测可达 34 秒(perf.md S7);§7 的"等待最多 2 秒"仅覆盖握手完成后的 drain。 +9. 发送队列满的熔断路径(`MarkClosed` + `Cts.Cancel`)不产生 disconnect 安全事件:后续 `CancelConnection` 因 `MarkClosed` 已置位而提前返回,被熔断的连接在日志中不可见(perf.md S6)。 + +实测性能数据见仓库根目录 [perf.md](../perf.md)。 diff --git a/perf.md b/perf.md new file mode 100644 index 0000000..f4938e8 --- /dev/null +++ b/perf.md @@ -0,0 +1,201 @@ +# 性能实测报告 / Performance Measurement Report + +状态 / Status:v0.4.0 实测报告 / Measured on v0.4.0 +日期 / Date:2026-08-29 / Measurements executed 2026-08-27 23:50 – 2026-08-29 01:50 CST +测量工具 / Tooling:[tools/perf_probe.py](tools/perf_probe.py)(纯标准库 asyncio WSS 探针 / stdlib-only asyncio WSS probe) +关联 / Related:[docs/server-spec.md](docs/server-spec.md) §9、§15;[specs/spec-decisions.md](specs/spec-decisions.md) + +> 摘要 / Summary:延迟与 CPU 表现优秀(1KB 广播 p95 3.5ms,8.6 倍余量;空闲 CPU 0.08%);冷启动 2 秒达标;内存目标(P1/P2)未达标——.NET 运行时基线与每连接真实成本决定了旧目标定得过紧;1000 并发在 1.6GB 内存的生产 VPS 同机环境不可行(触发宿主机硬重启),在 Windows 本机(32GB)通过(10 分钟零断连、+211 MB)。另发现两个实现层问题(停机 close 握手无超时、队列满熔断无日志),已记入 spec §15;测量过程中发现并修复了 Windows TLS 临时密钥缺陷。 +> Summary:Latency and CPU are excellent (1KB broadcast p95 3.5 ms — 8.6× headroom; idle CPU 0.08%); cold start meets the 2 s target; the memory targets (P1/P2) are not met — the .NET runtime baseline and the real per-connection cost show the old targets were too tight; 1000 concurrent connections is not feasible on the 1.6 GB production VPS with a same-host generator (the VM hard-reset), and passes on a local Windows machine (32 GB) (10 minutes, zero disconnects, +211 MB). Two implementation findings (unbounded shutdown close-handshake wait; silent queue-full abort without logging) are recorded in spec §15; the measurements also surfaced a Windows TLS ephemeral-key defect that has been fixed. + +--- + +## 第一部分:中文 + +### 1. 测试环境 + +| 项 | 值 | +|---|---| +| 硬件 | 2 vCPU(Intel Xeon Platinum)/ 1.6 GB 内存(LongCloud VPS) | +| 系统 | Ubuntu 24.04.4 LTS,内核 6.8.0-63-generic | +| 运行时 | .NET 10.0.11(框架依赖单文件,`TextCascade.Server` v0.4.0+fb33861) | +| 服务形态 | systemd(`textcascade-server.service`),WSS + 生产自签证书,端口 8443 | +| 负载端 | 与服务同机,回环 127.0.0.1,`tools/perf_probe.py`(Python 3.12 asyncio) | +| 服务配置 | 默认值 + `[rate_limit] clip_burst/clip_tokens_per_second` 临时调至 5000/5000(仅延迟与慢消费者场景,测后已恢复) | + +同机回环排除了网络抖动,但负载端与被测端共享 2 个 vCPU 与内存——并发类场景(S5)受此制约。 + +### 2. 结果总表 + +| # | 场景 | 指标 | 目标 | 实测 | 判定 | +|---|---|---|---|---|---| +| P1 | S1 基础内存 | RSS(新进程,60s 预热) | < 50 MB | **125–131 MB** | ✗ 未达标 | +| P2 | S2 100 空闲连接 | RSS 增量(5 分钟,全部存活) | < 20 MB | 新进程 +66 MB(含一次性 JIT/堆扩张);边际 **+24 MB**(≈240 KB/连接) | ◑ 边际接近达标 | +| P3 | S3 1KB 广播 | broadcast_lag p95(1000 样本) | < 30 ms | **3.5 ms**(p50 1.87 / p99 5.1 / max 11.8) | ✓ 达标(8.6× 余量) | +| P3b | S3 附带 | ack_rtt p95 | 参考 | 4.0 ms | — | +| P4 | S4 512KB 广播 | broadcast_lag p95(200 样本) | < 250 ms | **103.2 ms**(p50 87.3 / p99 138 / max 157) | ✓ 达标(2.4× 余量) | +| P5 | S8 空闲 CPU | 60 秒均值(两轮) | ≈ 0% | **0.08%**(5 ticks / 60 s) | ✓ 达标 | +| P6 | S7 冷启动 | 应用启动阶段(Started → listening) | < 2 s | **2 s**(三次重启一致) | ✓ 达标(临界) | +| P7 | 恢复窗口 | snapshot_window_seconds | 3 s | 配置常量,功能由集成测试覆盖 | —(不适用) | +| P8 | S5 1000 并发 | 10 分钟稳定性 | 无断连 | **Windows 本机通过**:0 错误、1000/1000 存活、+211 MB(1.6 GB 生产 VPS 同机环境不可行,见 S5 明细) | ✓ 达标(Windows 32 GB) | +| — | S6 慢消费者隔离 | A 的 p95(B 停读 45 秒) | 不受影响 | **7.0–7.9 ms**(基线 6–17 ms);B 在 ~16 s 被静默熔断 | ✓ 隔离有效 | + +### 3. 各场景明细 + +**S1 基础内存**:三次重启后 RSS 分别为 127872 / 128256 / 131328 kB;运行 24 小时后为 143852 kB。基线由 .NET 10 运行时、Kestrel、TLS 栈与 22 个线程构成,新进程即为 ~125 MB,说明不是泄漏而是运行时基线。原 50 MB 目标对 ASP.NET Core 应用不现实(判为"目标过紧"而非"实现缺陷")。 + +**S2 100 空闲连接**:新进程 RSS 127872 kB → 195392 kB,增量 67520 kB(≈660 KB/连接);在已运行 32 分钟、代码完全 JIT 的进程上,同一场景的边际增量仅 +24 MB(≈240 KB/连接)。两者差额来自一次性成本——100 个连接的握手风暴触发的 JIT 编译与 GC 堆首次扩张(约 40-50 MB)——每连接的真实成本是边际值:约 240 KB(Linux),Windows 为 211 KB,同一量级(Windows 数值更低主要是分母效应:211 KB 摊在 1000 个连接上,而 66 MB 摊在 100 个上)。每连接成本由 TLS 流缓冲、Kestrel 每连接管道与 pinned buffer、托管对象构成;连接关闭后内存不回落(GC 段保留,三个 ~20 MB 匿名段)。期间 900/900 心跳 pong 全部响应,零错误。判定:边际成本达标(240 KB/连接 vs 目标 200 KB/连接,差 20%),首次连接风暴的瞬时峰值超出目标;原 20 MB 总量目标(200 KB/连接)低估了 Kestrel + TLS 的真实成本。 + +**S3 1KB 广播延迟**:1000 样本全数回收。`broadcast_lag` p95 = 3.5 ms——服务端路径(解析 → 令牌桶 → 版本自增 → 单次序列化 → 双连接投递)加上两端 TLS 在回环上的开销远低于 30 ms 目标。 + +**S4 512KB 广播延迟**:200 样本全数回收。p95 103 ms,主要成本在 512KB JSON 的序列化/转义与两次 512KB TLS 记录写,2.4 倍余量达标。 + +**S5 1000 并发连接**:在 Windows x64 / 32 GB 本机通过——openssl 自签无密码 PFX,1000 连接全部建立(约 90 秒完成握手)、0 错误、10 分钟保持期间 19576/19000 心跳 pong 全响应(下限 19000 = 1000 连接 × 19 个周期,超出部分为时钟取整);RSS 曲线 105 MB(基线)→ 稳态 262–285 MB → 结束 316 MB,增量 ≈211 MB(≈211 KB/连接),与 Linux 的边际成本 240 KB/连接同量级(见 S2)。全程服务日志无任何错误。P8 判定:通过。 + +同一场景在 1.6 GB 内存的生产 VPS(负载端同机)不可行:00:48:59 一秒内全部 1000 个 WebSocket 升级请求同时打到刚重启 40 秒的冷进程,约 2 分钟后 SSH 会话被重置,整机冻结至 01:38:58 宿主机 watchdog 硬重启(冻结 47 分钟)。据上一启动周期的持久日志,journald 自 00:50:18 起连续输出 "Under memory pressure, flushing caches",00:51:53 彻底静默;内核日志无任何 OOM kill 记录,机器 swap = 0。稳态内存并非瓶颈——1000 × 240 KB(边际成本)≈ 240 MB 完全容纳得下——击穿零 swap 环境的是风暴瞬时峰值:冷进程 JIT、1000 个并发 TLS 握手的叠加分配、同机 Python 负载端自身数百 MB、升级日志洪流,导致内核回收活锁(.NET 运行时的文件映射页被反复逐出重读),冻结到 OOM killer 来不及工作。生产影响披露:期间真实用户设备约 47 分钟无法连接。在该环境执行 P8 的可行路径:预热后的服务、缓慢升速(如 20 连接/秒)、临时 swap,或跨机负载端。 + +测量过程发现并修复 F5(Windows TLS 缺陷):Windows 上 `CertificateLoader` 的 `EphemeralKeySet` 被 SChannel 拒绝("platform does not support ephemeral keys",0x8009030E),WSS 握手全部失败;Linux/OpenSSL 不受影响,VPS 部署因此从未暴露此问题。修复:Windows 上 PFX 使用 `DefaultKeySet`(持久密钥),PEM 加载后重导出为持久密钥;Linux 保持原状。spec §2.1 声明的 Windows Service 托管形态由此可用。 + +**S6 慢消费者隔离**:32KB clip @ 50/s(1.6 MB/s)。基线 p95 17 ms(含 JIT 噪声),B 停读后 A 的 p95 稳定在 7.0–7.9 ms——完全隔离。B 在 ~16 秒被服务端熔断断开(观测:established 3→2 且无 disconnect 日志;回环内核缓冲自调优 ~10 MB 吸收了初段流量,之后 16 条发送队列填满触发熔断)。两个发现: + +- **熔断静默**:队列满路径直接 `MarkClosed + Cts.Cancel`,后续 `CancelConnection` 因 `MarkClosed` 已置位而提前返回,不产生任何 disconnect 安全事件——被熔断的连接在日志中不可见(已记入 spec §15)。 +- **熔断延迟**:16 条队列的熔断点受内核 socket 缓冲(自动调优可达 ~10 MB)放大,取决于消息尺寸与速率,"队列满即断"在回环场景实际表现为"缓冲满即断"。 + +**S7 冷启动**:三次重启的应用启动阶段(journal `Started` → `Now listening`)均为 2 秒,达标但已贴线。另发现:`systemctl restart` 端到端耗时 35.6 秒(有真实客户端在线时)——旧实例的关闭阶段花了 34 秒,原因是 `ShutdownAsync` 对每个连接 `CloseAsync` 等待 close 握手完成且无超时,静默客户端会拖住整个停机流程;spec §7 的"等待最多 2 秒"只覆盖 close 握手完成后的 drain。已记入 spec §15。 + +**S8 空闲 CPU**:两轮 60 秒各 5 个时钟 tick(100 tick = 1 CPU 秒)→ 0.083% CPU。心跳扫描器(1 Hz)、状态刷盘(5 秒周期,空闲时无脏数据)、用户表轮询(30 秒周期)的固定开销可忽略。 + +### 4. 发现与后续 + +| # | 发现 | 影响 | 建议 | +|---|---|---|---| +| F1 | 停机关闭握手等待无超时(实测 34 秒) | 重启/升级时拖长停机窗口 | `CloseConnectionAsync` 的 `CloseAsync` 加超时(如 2 秒)后走 abort;已记入 spec §15 | +| F2 | 队列满熔断不产生 disconnect 日志 | 被熔断连接在安全日志中不可见 | 熔断路径补一条安全事件;已记入 spec §15 | +| F3 | P1 内存目标过紧(.NET 运行时基线 125-131 MB) | P1 不可达 | 修订 P1 为 < 150 MB;P2 按边际成本 240 KB/连接 基本达标,保留观察 | +| F4 | P8 在 1.6GB 同机环境不可行 | 无法验证 1000 并发 | 已解决:Windows 本机(32 GB)验证通过(见 S5);VPS 上仍建议跨机负载端 | +| F5 | Windows 上 `EphemeralKeySet` 导致 WSS 握手必然失败(0x8009030E) | spec §2.1 声明的 Windows Service 托管不可用 | 已修复:Windows 用 `DefaultKeySet`(PFX)+ PEM 重导出持久密钥;Linux 不变 | + +### 5. 复现步骤 + +```bash +# 1. 上传探针 +scp tools/perf_probe.py root@HOST:/tmp/ +# 2. 创建临时压测用户(测后删除) +/opt/textcascade-server/TextCascade.Server user add --username perftest --password-stdin \ + --config /etc/textcascade/textcascade.toml < <(echo PASSWORD) +# 3. 延迟场景(需要临时调高 [rate_limit],测后恢复) +python3 /tmp/perf_probe.py --user perftest --password PASSWORD latency --size 1024 --count 1000 --interval 0.02 +python3 /tmp/perf_probe.py --user perftest --password PASSWORD latency --size 524288 --count 200 --interval 0.1 +# 4. 连接保持与 RSS 采样 +grep VmRSS /proc/$(systemctl show -p MainPID --value textcascade-server)/status +python3 /tmp/perf_probe.py --user perftest --password PASSWORD hold --count 100 --seconds 300 +# 5. 慢消费者 +python3 /tmp/perf_probe.py --user perftest --password PASSWORD slow --size 32768 --stall 45 +# 6. 清理 +/opt/textcascade-server/TextCascade.Server user delete --username perftest --config /etc/textcascade/textcascade.toml +``` + +注意:S5 类高并发场景勿在与服务同机的低内存主机上执行(见 S5 生产影响披露)。 + +### 6. 已知限制 + +- 负载端与服务同机,S5 受内存与 CPU 共享制约;跨机测量结果不可与本次回环数值直接比较; +- 采样节奏受 Python asyncio 定时器影响(Linux 下精度远优于 Windows 的 15ms); +- 基线 RSS 受 GC 策略影响,跨 .NET 版本可能漂移; +- rate_limit 临时调整仅在延迟/慢消费者场景使用,S1/S2/S5/S7/S8 均在默认配置下测量。 + +--- + +## Part 2: English + +### 1. Test Environment + +| Item | Value | +|---|---| +| Hardware | 2 vCPU (Intel Xeon Platinum) / 1.6 GB RAM (LongCloud VPS) | +| OS | Ubuntu 24.04.4 LTS, kernel 6.8.0-63-generic | +| Runtime | .NET 10.0.11 (framework-dependent single file, `TextCascade.Server` v0.4.0+fb33861) | +| Service shape | systemd (`textcascade-server.service`), WSS + production self-signed certificate, port 8443 | +| Load generator | Same host, loopback 127.0.0.1, `tools/perf_probe.py` (Python 3.12 asyncio) | +| Service config | Defaults + `[rate_limit] clip_burst/clip_tokens_per_second` temporarily raised to 5000/5000 (latency and slow-consumer scenarios only; restored afterwards) | + +Same-host loopback excludes network jitter, but the generator shares the 2 vCPUs and memory with the service — concurrency scenarios (S5) are constrained by this. + +### 2. Results Summary + +| # | Scenario | Metric | Target | Measured | Verdict | +|---|---|---|---|---|---| +| P1 | S1 base memory | RSS (fresh process, 60 s warmup) | < 50 MB | **125–131 MB** | ✗ fail | +| P2 | S2 100 idle connections | RSS delta (5 min, all alive) | < 20 MB | fresh process +66 MB (incl. one-time JIT/heap growth); marginal **+24 MB** (≈240 KB/conn) | ◑ marginal near-target | +| P3 | S3 1KB broadcast | broadcast_lag p95 (1000 samples) | < 30 ms | **3.5 ms** (p50 1.87 / p99 5.1 / max 11.8) | ✓ pass (8.6× headroom) | +| P3b | S3 companion | ack_rtt p95 | reference | 4.0 ms | — | +| P4 | S4 512KB broadcast | broadcast_lag p95 (200 samples) | < 250 ms | **103.2 ms** (p50 87.3 / p99 138 / max 157) | ✓ pass (2.4× headroom) | +| P5 | S8 idle CPU | 60 s average (two runs) | ≈ 0% | **0.08%** (5 ticks / 60 s) | ✓ pass | +| P6 | S7 cold start | Application start phase (Started → listening) | < 2 s | **2 s** (consistent across 3 restarts) | ✓ pass (borderline) | +| P7 | Recovery window | snapshot_window_seconds | 3 s | config constant; correctness covered by tests | — (n/a) | +| P8 | S5 1000 concurrent | 10-minute stability | no disconnects | **passed on local Windows**: 0 errors, 1000/1000 alive, +211 MB (not feasible on the 1.6 GB production VPS with a same-host generator, see S5 details) | ✓ pass (Windows 32 GB) | +| — | S6 slow-consumer isolation | A's p95 (B stalled 45 s) | unaffected | **7.0–7.9 ms** (baseline 6–17 ms); B silently aborted at ~16 s | ✓ isolation holds | + +### 3. Scenario Details + +**S1 base memory**: RSS after three restarts was 127872 / 128256 / 131328 kB; 143852 kB after 24 h of uptime. The baseline consists of the .NET 10 runtime, Kestrel, the TLS stack, and 22 threads — a fresh process is already ~125 MB, so this is a runtime baseline, not a leak. The original 50 MB target is unrealistic for an ASP.NET Core app (judged "target too tight" rather than an implementation defect). + +**S2 100 idle connections**: fresh-process RSS 127872 kB → 195392 kB, a delta of 67520 kB (≈660 KB/connection); on a process up for 32 minutes with fully-JITted code, the same scenario showed a marginal delta of only +24 MB (≈240 KB/connection). The difference is one-time cost — JIT compilation and first-time GC heap expansion triggered by the 100-connection handshake storm (roughly 40–50 MB). The real per-connection cost is the marginal figure: ≈240 KB (Linux) vs 211 KB (Windows), the same order of magnitude (Windows "looking lower" is mostly a denominator effect: 211 KB spread over 1000 connections vs 66 MB spread over 100). The per-connection cost consists of TLS stream buffers, Kestrel per-connection pipes and pinned buffers, and managed objects; memory does not return after connections close (GC segments retained; three ~20 MB anonymous segments). All 900/900 expected heartbeat pongs were answered with zero errors. Verdict: the marginal cost meets the target within 20% (240 KB vs 200 KB per connection); the transient peak of a first connection storm exceeds it. The original 20 MB total target (200 KB/connection) underestimated the real cost of Kestrel + TLS. + +**S3 1KB broadcast latency**: all 1000 samples recovered. `broadcast_lag` p95 = 3.5 ms — the server path (parse → token bucket → version increment → single serialization → delivery to two connections) plus TLS on both ends stays far below the 30 ms target. + +**S4 512KB broadcast latency**: all 200 samples recovered. p95 103 ms, dominated by serializing/escaping the 512KB JSON and two 512KB TLS record writes; 2.4× headroom, within target. + +**S5 1000 concurrent connections**: passed on a local Windows x64 / 32 GB machine — an openssl self-signed passwordless PFX, all 1000 connections established (handshakes completed within ~90 s), 0 errors, and during the 10-minute hold 19576/19000 heartbeat pongs were answered (floor = 1000 connections × 19 cycles; extras come from clock rounding); RSS curve 105 MB (baseline) → 262–285 MB steady → 316 MB at the end, a delta of ≈211 MB (≈211 KB/connection), the same order as the 240 KB/connection marginal cost on Linux (see S2). The server log contained no errors. P8 verdict: pass. + +The same scenario is not feasible on the 1.6 GB production VPS with a same-host generator: all 1000 WebSocket upgrade requests arrived within the same second (00:48:59) against a server restarted only 40 s earlier; the SSH session was reset ~2 minutes into the storm, and the machine stayed frozen until the provider watchdog hard-reset it at 01:38:58 (47 minutes). The previous boot's persistent journal shows journald repeatedly logging "Under memory pressure, flushing caches" from 00:50:18 and going silent at 00:51:53; the kernel log contains no OOM-kill records and the machine has swap = 0. Steady-state memory was not the bottleneck — 1000 × 240 KB (marginal cost) ≈ 240 MB fits comfortably — it was the transient storm peak that broke the zero-swap environment: cold-process JIT, the overlapping allocations of 1000 concurrent TLS handshakes, several hundred MB for the same-host Python generator, and the upgrade-request log flood caused a kernel reclaim livelock (the .NET runtime's file-backed pages were endlessly evicted and re-faulted), and the machine froze before the OOM killer could act. Production impact disclosure: real user devices could not connect for ~47 minutes. Viable paths to run P8 in that environment: a warmed-up server, a slow ramp (e.g. 20 connections/s), a temporary swap file, or an off-host generator. + +The measurements also surfaced and fixed F5 (Windows TLS defect): on Windows, `CertificateLoader`'s `EphemeralKeySet` is rejected by SChannel ("platform does not support ephemeral keys", 0x8009030E) and every WSS handshake fails; Linux/OpenSSL is unaffected, which is why the VPS deployment never exposed it. Fix: on Windows the PFX branch uses `DefaultKeySet` (persisted key) and PEM-loaded certificates are re-exported to a persisted key; Linux behavior is unchanged. Spec §2.1's declared Windows Service hosting shape is usable as a result. + +**S6 slow-consumer isolation**: 32KB clips @ 50/s (1.6 MB/s). Baseline p95 17 ms (includes JIT noise); with B stalled, A's p95 held at 7.0–7.9 ms — fully isolated. B was silently aborted by the server at ~16 s (observed: established count 3→2 with no disconnect log; the autotuned ~10 MB loopback kernel buffers absorbed the initial burst, after which the 16-message send queue filled and triggered the abort). Two findings: + +- **Silent abort**: the queue-full path calls `MarkClosed + Cts.Cancel` directly, and the subsequent `CancelConnection` returns early because `MarkClosed` is already set — no disconnect security event is produced, so aborted connections are invisible in the logs (recorded in spec §15). +- **Abort delay**: the 16-message queue trigger point is amplified by kernel socket buffers (autotuned up to ~10 MB) and therefore depends on message size and rate; on loopback, "queue full = disconnect" behaves as "buffers full = disconnect". + +**S7 cold start**: the application start phase (journal `Started` → `Now listening`) was 2 seconds across three restarts — on target but borderline. Additional finding: end-to-end `systemctl restart` took 35.6 seconds (with real clients connected) — the old instance's stop phase took 34 seconds because `ShutdownAsync` awaits each connection's `CloseAsync` close-handshake with no timeout, so silent clients stall the whole shutdown; spec §7's "wait up to 2 seconds" only covers the drain after the handshakes complete. Recorded in spec §15. + +**S8 idle CPU**: two 60-second runs, 5 clock ticks each (100 ticks = 1 CPU-second) → 0.083% CPU. The fixed overhead of the heartbeat scanner (1 Hz), state flush (5 s cycle, no dirty data while idle), and user-file polling (30 s cycle) is negligible. + +### 4. Findings and Follow-ups + +| # | Finding | Impact | Recommendation | +|---|---|---|---| +| F1 | Shutdown close-handshake wait is unbounded (34 s measured) | Prolongs the stop window on restarts/upgrades | Add a timeout (e.g. 2 s) to `CloseConnectionAsync`'s `CloseAsync`, then abort; recorded in spec §15 | +| F2 | Queue-full abort produces no disconnect log | Aborted connections are invisible in security logs | Emit a security event on the abort path; recorded in spec §15 | +| F3 | P1 memory target too tight (.NET runtime baseline is 125–131 MB) | P1 unreachable | Revise P1 to < 150 MB; P2 essentially meets target at 240 KB/connection marginal cost, keep observing | +| F4 | P8 not feasible on a 1.6 GB same-host environment | 1000 concurrent connections unverifiable | Resolved: passed on a local Windows machine (see S5); an off-host generator is still recommended for the VPS | +| F5 | `EphemeralKeySet` made WSS handshakes always fail on Windows (0x8009030E) | The Windows Service hosting shape declared in spec §2.1 was unusable | Fixed: `DefaultKeySet` for PFX on Windows + PEM re-export to a persisted key; Linux unchanged | + +### 5. Reproduction + +```bash +# 1. Upload the probe +scp tools/perf_probe.py root@HOST:/tmp/ +# 2. Create a temporary benchmark user (delete afterwards) +/opt/textcascade-server/TextCascade.Server user add --username perftest --password-stdin \ + --config /etc/textcascade/textcascade.toml < <(echo PASSWORD) +# 3. Latency scenarios (temporarily raise [rate_limit]; restore afterwards) +python3 /tmp/perf_probe.py --user perftest --password PASSWORD latency --size 1024 --count 1000 --interval 0.02 +python3 /tmp/perf_probe.py --user perftest --password PASSWORD latency --size 524288 --count 200 --interval 0.1 +# 4. Connection holds and RSS sampling +grep VmRSS /proc/$(systemctl show -p MainPID --value textcascade-server)/status +python3 /tmp/perf_probe.py --user perftest --password PASSWORD hold --count 100 --seconds 300 +# 5. Slow consumer +python3 /tmp/perf_probe.py --user perftest --password PASSWORD slow --size 32768 --stall 45 +# 6. Cleanup +/opt/textcascade-server/TextCascade.Server user delete --username perftest --config /etc/textcascade/textcascade.toml +``` + +Caution: do not run S5-style high-concurrency scenarios on a low-memory host shared with the service (see the S5 production-impact disclosure). + +### 6. Known Limitations + +- The generator shares the host with the service; S5 is constrained by memory and CPU, and cross-host results are not directly comparable to these loopback numbers; +- Sampling cadence is bounded by Python asyncio timer precision (far better on Linux than the 15 ms default on Windows); +- Baseline RSS depends on GC policy and may drift across .NET versions; +- The temporary rate_limit adjustment was used only for the latency/slow-consumer scenarios; S1/S2/S5/S7/S8 were measured under default configuration. diff --git a/tools/perf_probe.py b/tools/perf_probe.py new file mode 100644 index 0000000..260aec3 --- /dev/null +++ b/tools/perf_probe.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""TextCascade performance probe. + +Stdlib-only asyncio WebSocket (WSS) client used to measure the deployed +textcascade-server: idle connection holds, clip latency, and slow-consumer +isolation. See perf.md at the repository root for the scenario definitions. + +Subcommands: + hold open N connections, answer pings, hold for S seconds + latency 2 connections (sender A + receiver B), record ack_rtt/broadcast_lag + slow baseline window, then a stalled receiver, then measure A's p95 +""" +import argparse +import asyncio +import base64 +import json +import os +import ssl +import time +import urllib.request + +HOST = "127.0.0.1" +PORT = 8443 + + +def percentile(values, p): + if not values: + return None + vs = sorted(values) + k = min(len(vs) - 1, max(0, int(round(len(vs) * p)) - 1)) + return round(vs[k], 3) + + +def login(user, password): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + req = urllib.request.Request( + f"https://{HOST}:{PORT}/api/v1/login", + data=json.dumps({"username": user, "password": password}).encode(), + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, context=ctx, timeout=15) as resp: + return json.loads(resp.read())["token"] + + +class WS: + def __init__(self, reader, writer): + self.reader = reader + self.writer = writer + + async def send_text(self, text): + data = text.encode() + mask = os.urandom(4) + n = len(data) + if n < 126: + header = bytes([0x81, 0x80 | n]) + elif n < 65536: + header = bytes([0x81, 0x80 | 126]) + n.to_bytes(2, "big") + else: + header = bytes([0x81, 0x80 | 127]) + n.to_bytes(8, "big") + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) + self.writer.write(header + mask + masked) + await self.writer.drain() + + async def read_frame(self): + b1, b2 = await self.reader.readexactly(2) + opcode = b1 & 0x0F + masked = b2 & 0x80 + ln = b2 & 0x7F + if ln == 126: + ln = int.from_bytes(await self.reader.readexactly(2), "big") + elif ln == 127: + ln = int.from_bytes(await self.reader.readexactly(8), "big") + mask_key = await self.reader.readexactly(4) if masked else None + payload = await self.reader.readexactly(ln) if ln else b"" + if mask_key: + payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) + if opcode == 0x8: + raise ConnectionResetError("close frame") + return opcode, payload + + +async def connect(token, client_id): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + reader, writer = await asyncio.open_connection( + HOST, PORT, ssl=ctx, limit=2 ** 22) + key = base64.b64encode(os.urandom(16)).decode() + request = ( + f"GET /api/v1/sync HTTP/1.1\r\nHost: {HOST}:{PORT}\r\n" + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: textcascade.v1\r\n" + f"Authorization: Bearer {token}\r\n\r\n") + writer.write(request.encode()) + await writer.drain() + response = await reader.readuntil(b"\r\n\r\n") + status_line = response.split(b"\r\n", 1)[0] + if b"101" not in status_line: + raise RuntimeError(f"handshake failed: {status_line!r}") + return WS(reader, writer) + + +async def hello(ws, client_id): + await ws.send_text(json.dumps({ + "type": "hello", "clientId": client_id, "clientName": "perf", + "lastServerVersion": 0, "snapshot": None})) + while True: + opcode, payload = await ws.read_frame() + if opcode == 1 and b'"welcome"' in payload: + return + + +def is_ping(payload): + return b'"type":"ping"' in payload + + +def pong_text(): + return json.dumps({ + "type": "pong", + "clientTimeUtc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}) + + +async def cmd_hold(args): + token = login(args.user, args.password) + connections = [] + errors = 0 + pongs = 0 + semaphore = asyncio.Semaphore(64) + + async def one(index): + nonlocal errors + async with semaphore: + try: + ws = await connect(token, f"hold-{index}") + await hello(ws, f"hold-{index}") + connections.append(ws) + except Exception: + errors += 1 + + await asyncio.gather(*(one(i) for i in range(args.count))) + + async def keepalive(ws): + nonlocal pongs + try: + while True: + opcode, payload = await ws.read_frame() + if opcode == 1 and is_ping(payload): + await ws.send_text(pong_text()) + pongs += 1 + except Exception: + pass + + tasks = [asyncio.create_task(keepalive(ws)) for ws in connections] + await asyncio.sleep(args.seconds) + + for ws in connections: + try: + ws.writer.close() + except Exception: + pass + for task in tasks: + task.cancel() + print(json.dumps({ + "opened": len(connections), "errors": errors, + "expected_pongs_floor": len(connections) * max(0, int(args.seconds / 30) - 1), + "pongs_sent": pongs})) + + +async def cmd_latency(args): + token = login(args.user, args.password) + + ws_b = await connect(token, "lat-b") + await hello(ws_b, "lat-b") + ws_a = await connect(token, "lat-a") + await hello(ws_a, "lat-a") + + send_ts = {} + acks = [] + lags = [] + pings = {"n": 0} + + async def reader_a(): + while True: + opcode, payload = await ws_a.read_frame() + if opcode != 1: + continue + if is_ping(payload): + await ws_a.send_text(pong_text()) + pings["n"] += 1 + continue + if b'"clip_ack"' in payload: + clip_id = json.loads(payload)["id"] + start = send_ts.get(clip_id) + if start is not None: + acks.append((time.monotonic_ns() - start) / 1e6) + + async def reader_b(): + while True: + opcode, payload = await ws_b.read_frame() + if opcode != 1: + continue + if is_ping(payload): + await ws_b.send_text(pong_text()) + continue + if b'"type":"clip"' in payload: + message = json.loads(payload) + start = send_ts.get(message["id"]) + if start is not None: + lags.append((time.monotonic_ns() - start) / 1e6) + + tasks = [asyncio.create_task(reader_a()), asyncio.create_task(reader_b())] + + payload_text = "x" * args.size + + async def send_wave(tag, count, interval, record): + for i in range(count): + clip_id = f"{tag}-{i}" + start = time.monotonic_ns() + if record: + send_ts[clip_id] = start + await ws_a.send_text(json.dumps({ + "type": "clip", "id": clip_id, "payload": payload_text, + "encrypted": False, "hash": "h"})) + if interval: + await asyncio.sleep(interval) + + await send_wave("warmup", args.warmup, 0.005, record=False) + await asyncio.sleep(0.5) + acks.clear() + lags.clear() + await send_wave("m", args.count, args.interval, record=True) + + deadline = time.monotonic() + 20 + while len(acks) < args.count and time.monotonic() < deadline: + await asyncio.sleep(0.05) + + for task in tasks: + task.cancel() + print(json.dumps({ + "size_bytes": args.size, + "samples_expected": args.count, + "ack_samples": len(acks), + "lag_samples": len(lags), + "ack_rtt_ms": {"p50": percentile(acks, 0.50), "p95": percentile(acks, 0.95), + "p99": percentile(acks, 0.99), "max": percentile(acks, 1.0)}, + "broadcast_lag_ms": {"p50": percentile(lags, 0.50), "p95": percentile(lags, 0.95), + "p99": percentile(lags, 0.99), "max": percentile(lags, 1.0)}})) + + +async def cmd_slow(args): + token = login(args.user, args.password) + + ws_a = await connect(token, "slow-a") + await hello(ws_a, "slow-a") + + send_ts = {} + acks = {"baseline": [], "stall": []} + + async def reader_a(): + while True: + opcode, payload = await ws_a.read_frame() + if opcode != 1: + continue + if is_ping(payload): + await ws_a.send_text(pong_text()) + continue + if b'"clip_ack"' in payload: + clip_id = json.loads(payload)["id"] + start = send_ts.get(clip_id) + if start is not None: + phase = "baseline" if clip_id.startswith("b-") else "stall" + acks[phase].append((time.monotonic_ns() - start) / 1e6) + + reader_task = asyncio.create_task(reader_a()) + payload_text = "x" * args.size + + async def send_wave(tag, seconds): + count = int(seconds / args.interval) + for i in range(count): + clip_id = f"{tag}-{i}" + send_ts[clip_id] = time.monotonic_ns() + await ws_a.send_text(json.dumps({ + "type": "clip", "id": clip_id, "payload": payload_text, + "encrypted": False, "hash": "h"})) + await asyncio.sleep(args.interval) + return count + + baseline_count = await send_wave("b", args.baseline) + + async def drain(minimum, timeout): + deadline = time.monotonic() + timeout + while len(acks["baseline"]) < minimum and time.monotonic() < deadline: + await asyncio.sleep(0.05) + + await drain(baseline_count, 10) + + # B joins, completes hello, then never reads again (stalled consumer). + ws_b = await connect(token, "slow-b") + await hello(ws_b, "slow-b") + + stall_count = await send_wave("s", args.stall) + deadline = time.monotonic() + 20 + while len(acks["stall"]) < stall_count and time.monotonic() < deadline: + await asyncio.sleep(0.05) + + reader_task.cancel() + + b_state = "unknown" + try: + opcode, _ = await asyncio.wait_for(ws_b.read_frame(), timeout=3) + b_state = f"received-frame-op-{opcode}" + except ConnectionResetError: + b_state = "connection-reset" + except asyncio.TimeoutError: + b_state = "still-open-no-data" + except Exception as exc: + b_state = f"error:{type(exc).__name__}" + + print(json.dumps({ + "clip_bytes": args.size, + "baseline": {"count": len(acks["baseline"]), + "p50": percentile(acks["baseline"], 0.50), + "p95": percentile(acks["baseline"], 0.95)}, + "stall": {"count": len(acks["stall"]), + "p50": percentile(acks["stall"], 0.50), + "p95": percentile(acks["stall"], 0.95)}, + "b_state": b_state})) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--user", required=True) + parser.add_argument("--password", required=True) + sub = parser.add_subparsers(dest="command", required=True) + + hold = sub.add_parser("hold") + hold.add_argument("--count", type=int, required=True) + hold.add_argument("--seconds", type=float, required=True) + hold.set_defaults(func=cmd_hold) + + latency = sub.add_parser("latency") + latency.add_argument("--size", type=int, required=True) + latency.add_argument("--count", type=int, required=True) + latency.add_argument("--interval", type=float, required=True) + latency.add_argument("--warmup", type=int, default=50) + latency.set_defaults(func=cmd_latency) + + slow = sub.add_parser("slow") + slow.add_argument("--size", type=int, default=32768) + slow.add_argument("--interval", type=float, default=0.02) + slow.add_argument("--baseline", type=float, default=5) + slow.add_argument("--stall", type=float, default=20) + slow.set_defaults(func=cmd_slow) + + args = parser.parse_args() + asyncio.run(args.func(args)) + + +if __name__ == "__main__": + main()