diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1b6e455..d664174 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,4 +36,10 @@ jobs: ${{ runner.os }}-cargo- - name: Build Release - run: cargo build --release --verbose \ No newline at end of file + run: cargo build --release --verbose + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: telemt + path: target/release/telemt \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/check.yml similarity index 100% rename from .github/workflows/test.yml rename to .github/workflows/check.yml diff --git a/docs/LICENSE/LICENSE.de.md b/docs/LICENSE/LICENSE.de.md deleted file mode 100644 index 35c8dcf..0000000 --- a/docs/LICENSE/LICENSE.de.md +++ /dev/null @@ -1,92 +0,0 @@ -# Öffentliche TELEMT-Lizenz 3 - -***Alle Rechte vorbehalten (c) 2026 Telemt*** - -Hiermit wird jeder Person, die eine Kopie dieser Software und der dazugehörigen Dokumentation (nachfolgend "Software") erhält, unentgeltlich die Erlaubnis erteilt, die Software ohne Einschränkungen zu nutzen, einschließlich des Rechts, die Software zu verwenden, zu vervielfältigen, zu ändern, abgeleitete Werke zu erstellen, zu verbinden, zu veröffentlichen, zu verbreiten, zu unterlizenzieren und/oder Kopien der Software zu verkaufen sowie diese Rechte auch denjenigen einzuräumen, denen die Software zur Verfügung gestellt wird, vorausgesetzt, dass sämtliche Urheberrechtshinweise sowie die Bedingungen und Bestimmungen dieser Lizenz eingehalten werden. - -### Begriffsbestimmungen - -Für die Zwecke dieser Lizenz gelten die folgenden Definitionen: - -**"Software" (Software)** — die Telemt-Software einschließlich Quellcode, Dokumentation und sämtlicher zugehöriger Dateien, die unter den Bedingungen dieser Lizenz verbreitet werden. - -**"Contributor" (Contributor)** — jede natürliche oder juristische Person, die Code, Patches, Dokumentation oder andere Materialien eingereicht hat, die von den Maintainers des Projekts angenommen und in die Software aufgenommen wurden. - -**"Beitrag" (Contribution)** — jedes urheberrechtlich geschützte Werk, das bewusst zur Aufnahme in die Software eingereicht wurde. - -**"Modifizierte Version" (Modified Version)** — jede Version der Software, die gegenüber der ursprünglichen Software geändert, angepasst, erweitert oder anderweitig modifiziert wurde. - -**"Maintainers" (Maintainers)** — natürliche oder juristische Personen, die für das offizielle Telemt-Projekt und dessen offizielle Veröffentlichungen verantwortlich sind. - -### 1 Urheberrechtshinweis (Attribution) - -Bei der Weitergabe der Software, sowohl in Form des Quellcodes als auch in binärer Form, MÜSSEN folgende Elemente erhalten bleiben: - -- der oben genannte Urheberrechtshinweis; -- der vollständige Text dieser Lizenz; -- sämtliche bestehenden Hinweise auf Urheberschaft. - -### 2 Hinweis auf Modifikationen - -Wenn Änderungen an der Software vorgenommen werden, MUSS die Person, die diese Änderungen vorgenommen hat, eindeutig darauf hinweisen, dass die Software modifiziert wurde, und eine kurze Beschreibung der vorgenommenen Änderungen beifügen. - -Modifizierte Versionen der Software DÜRFEN NICHT als die originale Version von Telemt dargestellt werden. - -### 3 Marken und Bezeichnungen - -Diese Lizenz GEWÄHRT KEINE Rechte zur Nutzung der Bezeichnung **"Telemt"**, des Telemt-Logos oder sonstiger Marken, Kennzeichen oder Branding-Elemente von Telemt. - -Weiterverbreitete oder modifizierte Versionen der Software DÜRFEN die Bezeichnung Telemt nicht in einer Weise verwenden, die bei Nutzern den Eindruck eines offiziellen Ursprungs oder einer Billigung durch das Telemt-Projekt erwecken könnte, sofern hierfür keine ausdrückliche Genehmigung der Maintainers vorliegt. - -Die Verwendung der Bezeichnung **Telemt** zur Beschreibung einer modifizierten Version der Software ist nur zulässig, wenn diese Version eindeutig als modifiziert oder inoffiziell gekennzeichnet ist. - -Jegliche Verbreitung, die Nutzer vernünftigerweise darüber täuschen könnte, dass es sich um eine offizielle Veröffentlichung von Telemt handelt, ist untersagt. - -### 4 Transparenz bei der Verbreitung von Binärversionen - -Im Falle der Verbreitung kompilierter Binärversionen der Software wird der Verbreiter HIERMIT ERMUTIGT (encouraged), soweit dies vernünftigerweise möglich ist, Zugang zum entsprechenden Quellcode sowie zu den Build-Anweisungen bereitzustellen. - -Diese Praxis trägt zur Transparenz bei und ermöglicht es Empfängern, die Integrität und Reproduzierbarkeit der verbreiteten Builds zu überprüfen. - -## 5 Gewährung einer Patentlizenz und Beendigung von Rechten - -Jeder Contributor gewährt den Empfängern der Software eine unbefristete, weltweite, nicht-exklusive, unentgeltliche, lizenzgebührenfreie und unwiderrufliche Patentlizenz für: - -- die Herstellung, -- die Beauftragung der Herstellung, -- die Nutzung, -- das Anbieten zum Verkauf, -- den Verkauf, -- den Import, -- sowie jede sonstige Verbreitung der Software. - -Diese Patentlizenz erstreckt sich ausschließlich auf solche Patentansprüche, die notwendigerweise durch den jeweiligen Beitrag des Contributors allein oder in Kombination mit der Software verletzt würden. - -Leitet eine Person ein Patentverfahren ein oder beteiligt sich daran, einschließlich Gegenklagen oder Kreuzklagen, mit der Behauptung, dass die Software oder ein darin enthaltener Beitrag ein Patent verletzt, **erlöschen sämtliche durch diese Lizenz gewährten Rechte für diese Person unmittelbar mit Einreichung der Klage**. - -Darüber hinaus erlöschen alle durch diese Lizenz gewährten Rechte **automatisch**, wenn eine Person ein gerichtliches Verfahren einleitet, in dem behauptet wird, dass die Software selbst ein Patent oder andere Rechte des geistigen Eigentums verletzt. - -### 6 Beteiligung und Beiträge zur Entwicklung - -Sofern ein Contributor nicht ausdrücklich etwas anderes erklärt, gilt jeder Beitrag, der bewusst zur Aufnahme in die Software eingereicht wird, als unter den Bedingungen dieser Lizenz lizenziert. - -Durch die Einreichung eines Beitrags gewährt der Contributor den Maintainers des Telemt-Projekts sowie allen Empfängern der Software die in dieser Lizenz beschriebenen Rechte in Bezug auf diesen Beitrag. - -### 7 Urheberhinweis bei Netzwerk- und Servicenutzung - -Wird die Software zur Bereitstellung eines öffentlich zugänglichen Netzwerkdienstes verwendet, MUSS der Betreiber dieses Dienstes einen Hinweis auf die Urheberschaft von Telemt an mindestens einer der folgenden Stellen anbringen: - -* in der Servicedokumentation; -* in der Dienstbeschreibung; -* auf einer Seite "Über" oder einer vergleichbaren Informationsseite; -* in anderen für Nutzer zugänglichen Materialien, die in angemessenem Zusammenhang mit dem Dienst stehen. - -Ein solcher Hinweis DARF NICHT den Eindruck erwecken, dass der Dienst vom Telemt-Projekt oder dessen Maintainers unterstützt oder offiziell gebilligt wird. - -### 8 Haftungsausschluss und salvatorische Klausel - -DIE SOFTWARE WIRD "WIE BESEHEN" BEREITGESTELLT, OHNE JEGLICHE AUSDRÜCKLICHE ODER STILLSCHWEIGENDE GEWÄHRLEISTUNG, EINSCHLIESSLICH, ABER NICHT BESCHRÄNKT AUF GEWÄHRLEISTUNGEN DER MARKTGÄNGIGKEIT, DER EIGNUNG FÜR EINEN BESTIMMTEN ZWECK UND DER NICHTVERLETZUNG VON RECHTEN. - -IN KEINEM FALL HAFTEN DIE AUTOREN ODER RECHTEINHABER FÜR IRGENDWELCHE ANSPRÜCHE, SCHÄDEN ODER SONSTIGE HAFTUNG, DIE AUS VERTRAG, UNERLAUBTER HANDLUNG ODER AUF ANDERE WEISE AUS DER SOFTWARE ODER DER NUTZUNG DER SOFTWARE ENTSTEHEN. - -SOLLTE EINE BESTIMMUNG DIESER LIZENZ ALS UNWIRKSAM ODER NICHT DURCHSETZBAR ANGESEHEN WERDEN, IST DIESE BESTIMMUNG SO AUSZULEGEN, DASS SIE DEM URSPRÜNGLICHEN WILLEN DER PARTEIEN MÖGLICHST NAHEKOMMT; DIE ÜBRIGEN BESTIMMUNGEN BLEIBEN DAVON UNBERÜHRT UND IN VOLLER WIRKUNG. \ No newline at end of file diff --git a/docs/LICENSE/LICENSE.en.md b/docs/LICENSE/LICENSE.en.md deleted file mode 100644 index 77796a3..0000000 --- a/docs/LICENSE/LICENSE.en.md +++ /dev/null @@ -1,143 +0,0 @@ -###### TELEMT Public License 3 ###### -##### Copyright (c) 2026 Telemt ##### - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this Software and associated documentation files (the "Software"), -to use, reproduce, modify, prepare derivative works of, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to permit -persons to whom the Software is furnished to do so, provided that all -copyright notices, license terms, and conditions set forth in this License -are preserved and complied with. - -### Official Translations - -The canonical version of this License is the English version. - -Official translations are provided for informational purposes only -and for convenience, and do not have legal force. In case of any -discrepancy, the English version of this License shall prevail. - -Available versions: -- English in Markdown: docs/LICENSE/LICENSE.md -- German: docs/LICENSE/LICENSE.de.md -- Russian: docs/LICENSE/LICENSE.ru.md - -### Definitions - -For the purposes of this License: - -"Software" means the Telemt software, including source code, documentation, -and any associated files distributed under this License. - -"Contributor" means any person or entity that submits code, patches, -documentation, or other contributions to the Software that are accepted -into the Software by the maintainers. - -"Contribution" means any work of authorship intentionally submitted -to the Software for inclusion in the Software. - -"Modified Version" means any version of the Software that has been -changed, adapted, extended, or otherwise modified from the original -Software. - -"Maintainers" means the individuals or entities responsible for -the official Telemt project and its releases. - -#### 1 Attribution - -Redistributions of the Software, in source or binary form, MUST RETAIN the -above copyright notice, this license text, and any existing attribution -notices. - -#### 2 Modification Notice - -If you modify the Software, you MUST clearly state that the Software has been -modified and include a brief description of the changes made. - -Modified versions MUST NOT be presented as the original Telemt. - -#### 3 Trademark and Branding - -This license DOES NOT grant permission to use the name "Telemt", -the Telemt logo, or any Telemt trademarks or branding. - -Redistributed or modified versions of the Software MAY NOT use the Telemt -name in a way that suggests endorsement or official origin without explicit -permission from the Telemt maintainers. - -Use of the name "Telemt" to describe a modified version of the Software -is permitted only if the modified version is clearly identified as a -modified or unofficial version. - -Any distribution that could reasonably confuse users into believing that -the software is an official Telemt release is prohibited. - -#### 4 Binary Distribution Transparency - -If you distribute compiled binaries of the Software, -you are ENCOURAGED to provide access to the corresponding -source code and build instructions where reasonably possible. - -This helps preserve transparency and allows recipients to verify the -integrity and reproducibility of distributed builds. - -#### 5 Patent Grant and Defensive Termination Clause - -Each contributor grants you a perpetual, worldwide, non-exclusive, -no-charge, royalty-free, irrevocable patent license to make, have made, -use, offer to sell, sell, import, and otherwise transfer the Software. - -This patent license applies only to those patent claims necessarily -infringed by the contributor’s contribution alone or by combination of -their contribution with the Software. - -If you initiate or participate in any patent litigation, including -cross-claims or counterclaims, alleging that the Software or any -contribution incorporated within the Software constitutes patent -infringement, then **all rights granted to you under this license shall -terminate immediately** as of the date such litigation is filed. - -Additionally, if you initiate legal action alleging that the -Software itself infringes your patent or other intellectual -property rights, then all rights granted to you under this -license SHALL TERMINATE automatically. - -#### 6 Contributions - -Unless you explicitly state otherwise, any Contribution intentionally -submitted for inclusion in the Software shall be licensed under the terms -of this License. - -By submitting a Contribution, you grant the Telemt maintainers and all -recipients of the Software the rights described in this License with -respect to that Contribution. - -#### 7 Network Use Attribution - -If the Software is used to provide a publicly accessible network service, -the operator of such service MUST provide attribution to Telemt in at least -one of the following locations: - -- service documentation -- service description -- an "About" or similar informational page -- other user-visible materials reasonably associated with the service - -Such attribution MUST NOT imply endorsement by the Telemt project or its -maintainers. - -#### 8 Disclaimer of Warranty and Severability Clause - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE 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 - -IF ANY PROVISION OF THIS LICENSE IS HELD TO BE INVALID OR UNENFORCEABLE, -SUCH PROVISION SHALL BE INTERPRETED TO REFLECT THE ORIGINAL INTENT -OF THE PARTIES AS CLOSELY AS POSSIBLE, AND THE REMAINING PROVISIONS -SHALL REMAIN IN FULL FORCE AND EFFECT \ No newline at end of file diff --git a/docs/LICENSE/LICENSE.ru.md b/docs/LICENSE/LICENSE.ru.md deleted file mode 100644 index b88d9da..0000000 --- a/docs/LICENSE/LICENSE.ru.md +++ /dev/null @@ -1,90 +0,0 @@ -# Публичная лицензия TELEMT 3 - -***Все права защищёны (c) 2026 Telemt*** - -Настоящим любому лицу, получившему копию данного программного обеспечения и сопутствующей документации (далее — "Программное обеспечение"), безвозмездно предоставляется разрешение использовать Программное обеспечение без ограничений, включая право использовать, воспроизводить, изменять, создавать производные произведения, объединять, публиковать, распространять, сублицензировать и (или) продавать копии Программного обеспечения, а также предоставлять такие права лицам, которым предоставляется Программное обеспечение, при условии соблюдения всех уведомлений об авторских правах, условий и положений настоящей Лицензии. - -### Определения - -Для целей настоящей Лицензии применяются следующие определения: - -**"Программное обеспечение" (Software)** — программное обеспечение Telemt, включая исходный код, документацию и любые связанные файлы, распространяемые на условиях настоящей Лицензии. - -**"Контрибьютор" (Contributor)** — любое физическое или юридическое лицо, направившее код, исправления (патчи), документацию или иные материалы, которые были приняты мейнтейнерами проекта и включены в состав Программного обеспечения. - -**"Вклад" (Contribution)** — любое произведение авторского права, намеренно представленное для включения в состав Программного обеспечения. - -**"Модифицированная версия" (Modified Version)** — любая версия Программного обеспечения, которая была изменена, адаптирована, расширена или иным образом модифицирована по сравнению с исходным Программным обеспечением. - -**"Мейнтейнеры" (Maintainers)** — физические или юридические лица, ответственные за официальный проект Telemt и его официальные релизы. - -### 1 Указание авторства - -При распространении Программного обеспечения, как в форме исходного кода, так и в бинарной форме, ДОЛЖНЫ СОХРАНЯТЬСЯ: - -- указанное выше уведомление об авторских правах; -- текст настоящей Лицензии; -- любые существующие уведомления об авторстве. - -### 2 Уведомление о модификации - -В случае внесения изменений в Программное обеспечение лицо, осуществившее такие изменения, ОБЯЗАНО явно указать, что Программное обеспечение было модифицировано, а также включить краткое описание внесённых изменений. - -Модифицированные версии Программного обеспечения НЕ ДОЛЖНЫ представляться как оригинальная версия Telemt. - -### 3 Товарные знаки и обозначения - -Настоящая Лицензия НЕ ПРЕДОСТАВЛЯЕТ права использовать наименование **"Telemt"**, логотип Telemt, а также любые товарные знаки, фирменные обозначения или элементы бренда Telemt. - -Распространяемые или модифицированные версии Программного обеспечения НЕ ДОЛЖНЫ использовать наименование Telemt таким образом, который может создавать у пользователей впечатление официального происхождения либо одобрения со стороны проекта Telemt без явного разрешения мейнтейнеров проекта. - -Использование наименования **Telemt** для описания модифицированной версии Программного обеспечения допускается только при условии, что такая версия ясно обозначена как модифицированная или неофициальная. - -Запрещается любое распространение, которое может разумно вводить пользователей в заблуждение относительно того, что программное обеспечение является официальным релизом Telemt. - -### 4 Прозрачность распространения бинарных версий - -В случае распространения скомпилированных бинарных версий Программного обеспечения распространитель НАСТОЯЩИМ ПОБУЖДАЕТСЯ предоставлять доступ к соответствующему исходному коду и инструкциям по сборке, если это разумно возможно. - -Такая практика способствует прозрачности распространения и позволяет получателям проверять целостность и воспроизводимость распространяемых сборок. - -### 5 Предоставление патентной лицензии и прекращение прав - -Каждый контрибьютор предоставляет получателям Программного обеспечения бессрочную, всемирную, неисключительную, безвозмездную, не требующую выплаты роялти и безотзывную патентную лицензию на: - -- изготовление, -- поручение изготовления, -- использование, -- предложение к продаже, -- продажу, -- импорт, -- и иное распространение Программного обеспечения. - -Такая патентная лицензия распространяется исключительно на те патентные требования, которые неизбежно нарушаются соответствующим вкладом контрибьютора как таковым либо его сочетанием с Программным обеспечением. - -Если лицо инициирует либо участвует в каком-либо судебном разбирательстве по патентному спору, включая встречные или перекрёстные иски, утверждая, что Программное обеспечение либо любой вклад, включённый в него, нарушает патент, **все права, предоставленные такому лицу настоящей Лицензией, немедленно прекращаются** с даты подачи соответствующего иска. - -Кроме того, если лицо инициирует судебное разбирательство, утверждая, что само Программное обеспечение нарушает его патентные либо иные права интеллектуальной собственности, все права, предоставленные настоящей Лицензией, **автоматически прекращаются**. - -### 6 Участие и вклад в разработку - -Если контрибьютор явно не указал иное, любой Вклад, намеренно представленный для включения в Программное обеспечение, считается лицензированным на условиях настоящей Лицензии. -Путём предоставления Вклада контрибьютор предоставляет мейнтейнером проекта Telemt и всем получателям Программного обеспечения права, предусмотренные настоящей Лицензией, в отношении такого Вклада. - -### 7 Указание авторства при сетевом и сервисном использовании - -В случае использования Программного обеспечения для предоставления публично доступного сетевого сервиса оператор такого сервиса ОБЯЗАН обеспечить указание авторства Telemt как минимум в одном из следующих мест: -- документация сервиса; -- описание сервиса; -- страница "О программе" или аналогичная информационная страница; -- иные материалы, доступные пользователям и разумно связанные с данным сервисом. - -Такое указание авторства НЕ ДОЛЖНО создавать впечатление одобрения или официальной поддержки со стороны проекта Telemt либо его мейнтейнеров. - -### 8 Отказ от гарантий и делимость положений - -ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПРЕДОСТАВЛЯЕТСЯ "КАК ЕСТЬ", БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ, ЯВНЫХ ИЛИ ПОДРАЗУМЕВАЕМЫХ, ВКЛЮЧАЯ, НО НЕ ОГРАНИЧИВАЯСЬ ГАРАНТИЯМИ КОММЕРЧЕСКОЙ ПРИГОДНОСТИ, ПРИГОДНОСТИ ДЛЯ КОНКРЕТНОЙ ЦЕЛИ И НЕНАРУШЕНИЯ ПРАВ. - -НИ ПРИ КАКИХ ОБСТОЯТЕЛЬСТВАХ АВТОРЫ ИЛИ ПРАВООБЛАДАТЕЛИ НЕ НЕСУТ ОТВЕТСТВЕННОСТИ ПО КАКИМ-ЛИБО ТРЕБОВАНИЯМ, УБЫТКАМ ИЛИ ИНОЙ ОТВЕТСТВЕННОСТИ, ВОЗНИКАЮЩЕЙ В РЕЗУЛЬТАТЕ ДОГОВОРА, ДЕЛИКТА ИЛИ ИНЫМ ОБРАЗОМ, СВЯЗАННЫМ С ПРОГРАММНЫМ ОБЕСПЕЧЕНИЕМ ИЛИ ЕГО ИСПОЛЬЗОВАНИЕМ. - -В СЛУЧАЕ ЕСЛИ КАКОЕ-ЛИБО ПОЛОЖЕНИЕ НАСТОЯЩЕЙ ЛИЦЕНЗИИ ПРИЗНАЁТСЯ НЕДЕЙСТВИТЕЛЬНЫМ ИЛИ НЕПРИМЕНИМЫМ, ТАКОЕ ПОЛОЖЕНИЕ ПОДЛЕЖИТ ТОЛКОВАНИЮ МАКСИМАЛЬНО БЛИЗКО К ИСХОДНОМУ НАМЕРЕНИЮ СТОРОН, ПРИ ЭТОМ ОСТАЛЬНЫЕ ПОЛОЖЕНИЯ СОХРАНЯЮТ ПОЛНУЮ ЮРИДИЧЕСКУЮ СИЛУ. diff --git a/docs/LICENSE/TELEMT-LICENSE.en.md b/docs/LICENSE/TELEMT-LICENSE.en.md new file mode 100644 index 0000000..74e6a44 --- /dev/null +++ b/docs/LICENSE/TELEMT-LICENSE.en.md @@ -0,0 +1,120 @@ +# TELEMT License 3.3 + +***Copyright (c) 2026 Telemt*** + +Permission is hereby granted, free of charge, to any person obtaining a copy of this Software and associated documentation files (the "Software"), to use, reproduce, modify, prepare derivative works of, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, provided that all copyright notices, license terms, and conditions set forth in this License are preserved and complied with. + +### Official Translations + +The canonical version of this License is the English version. +Official translations are provided for informational purposes only and for convenience, and do not have legal force. In case of any discrepancy, the English version of this License shall prevail. + +| Language | Location | +|-------------|----------| +| English | [docs/LICENSE/TELEMT-LICENSE.en.md](https://github.com/telemt/telemt/tree/main/docs/LICENSE/TELEMT-LICENSE.en.md)| +| German | [docs/LICENSE/TELEMT-LICENSE.de.md](https://github.com/telemt/telemt/tree/main/docs/LICENSE/TELEMT-LICENSE.de.md)| +| Russian | [docs/LICENSE/TELEMT-LICENSE.ru.md](https://github.com/telemt/telemt/tree/main/docs/LICENSE/TELEMT-LICENSE.ru.md)| + +### License Versioning Policy + +This License is version 3.3 of the TELEMT License. +Each version of the Software is licensed under the License that accompanies its corresponding source code distribution. + +Future versions of the Software may be distributed under a different version of the TELEMT Public License or under a different license, as determined by the Telemt maintainers. + +Any such change of license applies only to the versions of the Software distributed with the new license and SHALL NOT retroactively affect any previously released versions of the Software. + +Recipients of the Software are granted rights only under the License provided with the version of the Software they received. + +Redistributions of the Software, including Modified Versions, MUST preserve the copyright notices, license text, and conditions of this License for all portions of the Software derived from Telemt. + +Additional terms or licenses may be applied to modifications or additional code added by a redistributor, provided that such terms do not restrict or alter the rights granted under this License for the original Telemt Software. + +Nothing in this section limits the rights granted under this License for versions of the Software already released. + +### Definitions + +For the purposes of this License: + +**"Software"** means the Telemt software, including source code, documentation, and any associated files distributed under this License. + +**"Contributor"** means any person or entity that submits code, patches, documentation, or other contributions to the Software that are accepted into the Software by the maintainers. + +**"Contribution"** means any work of authorship intentionally submitted to the Software for inclusion in the Software. + +**"Modified Version"** means any version of the Software that has been changed, adapted, extended, or otherwise modified from the original Software. + +**"Maintainers"** means the individuals or entities responsible for the official Telemt project and its releases. + +### 1 Attribution + +Redistributions of the Software, in source or binary form, MUST RETAIN: + +- the above copyright notice; +- this license text; +- any existing attribution notices. + +### 2 Modification Notice + +If you modify the Software, you MUST clearly state that the Software has been modified and include a brief description of the changes made. + +Modified versions MUST NOT be presented as the original Telemt. + +### 3 Trademark and Branding + +This license DOES NOT grant permission to use the name "Telemt", the Telemt logo, or any Telemt trademarks or branding. + +Redistributed or modified versions of the Software MAY NOT use the Telemt name in a way that suggests endorsement or official origin without explicit permission from the Telemt maintainers. + +Use of the name "Telemt" to describe a modified version of the Software is permitted only if the modified version is clearly identified as a modified or unofficial version. + +Any distribution that could reasonably confuse users into believing that the software is an official Telemt release is prohibited. + +### 4 Binary Distribution Transparency + +If you distribute compiled binaries of the Software, you are ENCOURAGED to provide access to the corresponding source code and build instructions where reasonably possible. + +This helps preserve transparency and allows recipients to verify the integrity and reproducibility of distributed builds. + +### 5 Patent Grant and Defensive Termination Clause + +Each contributor grants you a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to: + +- make, +- have made, +- use, +- offer to sell, +- sell, +- import, +- and otherwise transfer the Software. + +This patent license applies only to those patent claims necessarily infringed by the contributor’s contribution alone or by combination of their contribution with the Software. + +If you initiate or participate in any patent litigation, including cross-claims or counterclaims, alleging that the Software or any contribution incorporated within the Software constitutes patent infringement, then **all rights granted to you under this license shall terminate immediately** as of the date such litigation is filed. + +Additionally, if you initiate legal action alleging that the Software itself infringes your patent or other intellectual property rights, then all rights granted to you under this license SHALL TERMINATE automatically. + +### 6 Contributions + +Unless you explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Software shall be licensed under the terms of this License. + +By submitting a Contribution, you grant the Telemt maintainers and all recipients of the Software the rights described in this License with respect to that Contribution. + +### 7 Network Use Attribution + +If the Software is used to provide a publicly accessible network service, the operator of such service SHOULD provide attribution to Telemt in at least one of the following locations: + +- service documentation; +- service description; +- an "About" or similar informational page; +- other user-visible materials reasonably associated with the service. + +Such attribution MUST NOT imply endorsement by the Telemt project or its maintainers. + +### 8 Disclaimer of Warranty and Severability Clause + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 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. + +IF ANY PROVISION OF THIS LICENSE IS HELD TO BE INVALID OR UNENFORCEABLE, SUCH PROVISION SHALL BE INTERPRETED TO REFLECT THE ORIGINAL INTENT OF THE PARTIES AS CLOSELY AS POSSIBLE, AND THE REMAINING PROVISIONS SHALL REMAIN IN FULL FORCE AND EFFECT. \ No newline at end of file diff --git a/docs/LICENSE/TELEMT-LICENSE.ru.md b/docs/LICENSE/TELEMT-LICENSE.ru.md new file mode 100644 index 0000000..ca3395b --- /dev/null +++ b/docs/LICENSE/TELEMT-LICENSE.ru.md @@ -0,0 +1,120 @@ +# TELEMT Лицензия 3.3 + +***Copyright (c) 2026 Telemt*** + +Настоящим безвозмездно предоставляется разрешение любому лицу, получившему копию данного программного обеспечения и сопутствующей документации (далее — "Программное обеспечение"), использовать, воспроизводить, изменять, создавать производные произведения, объединять, публиковать, распространять, сублицензировать и/или продавать копии Программного обеспечения, а также разрешать лицам, которым предоставляется Программное обеспечение, осуществлять указанные действия при условии соблюдения и сохранения всех уведомлений об авторском праве, условий и положений настоящей Лицензии. + +### Официальные переводы + +Канонической версией настоящей Лицензии является версия на английском языке. +Официальные переводы предоставляются исключительно в информационных целях и для удобства и не имеют юридической силы. В случае любых расхождений приоритет имеет английская версия. + +| Язык | Расположение | +|------------|--------------| +| Русский | [docs/LICENSE/TELEMT-LICENSE.ru.md](https://github.com/telemt/telemt/tree/main/docs/LICENSE/TELEMT-LICENSE.ru.md)| +| Английский | [docs/LICENSE/TELEMT-LICENSE.en.md](https://github.com/telemt/telemt/tree/main/docs/LICENSE/TELEMT-LICENSE.en.md)| +| Немецкий | [docs/LICENSE/TELEMT-LICENSE.de.md](https://github.com/telemt/telemt/tree/main/docs/LICENSE/TELEMT-LICENSE.de.md)| + +### Политика версионирования лицензии + +Настоящая Лицензия является версией 3.3 Лицензии TELEMT. +Каждая версия Программного обеспечения лицензируется в соответствии с Лицензией, сопровождающей соответствующее распространение исходного кода. + +Будущие версии Программного обеспечения могут распространяться в соответствии с иной версией Лицензии TELEMT Public License либо под иной лицензией, определяемой мейнтейнерами Telemt. + +Любое такое изменение лицензии применяется исключительно к версиям Программного обеспечения, распространяемым с новой лицензией, и НЕ распространяется ретроактивно на ранее выпущенные версии Программного обеспечения. + +Получатели Программного обеспечения приобретают права исключительно в соответствии с Лицензией, предоставленной вместе с полученной ими версией Программного обеспечения. + +При распространении Программного обеспечения, включая Модифицированные версии, ОБЯЗАТЕЛЬНО сохранение уведомлений об авторском праве, текста лицензии и условий настоящей Лицензии в отношении всех частей Программного обеспечения, производных от Telemt. + +Дополнительные условия или лицензии могут применяться к модификациям или дополнительному коду, добавленному распространителем, при условии, что такие условия не ограничивают и не изменяют права, предоставленные настоящей Лицензией в отношении оригинального Программного обеспечения Telemt. + +Ничто в настоящем разделе не ограничивает права, предоставленные настоящей Лицензией в отношении уже выпущенных версий Программного обеспечения. + +### Определения + +Для целей настоящей Лицензии: + +**"Программное обеспечение"** означает программное обеспечение Telemt, включая исходный код, документацию и любые сопутствующие файлы, распространяемые в соответствии с настоящей Лицензией. + +**"Контрибьютор"** означает любое физическое или юридическое лицо, которое предоставляет код, исправления, документацию или иные материалы в качестве вклада в Программное обеспечение, принятые мейнтейнерами для включения в Программное обеспечение. + +**"Вклад"** означает любое произведение, сознательно представленное для включения в Программное обеспечение. + +**"Модифицированная версия"** означает любую версию Программного обеспечения, которая была изменена, адаптирована, расширена или иным образом модифицирована по сравнению с оригинальным Программным обеспечением. + +**"Мейнтейнеры"** означает физических или юридических лиц, ответственных за официальный проект Telemt и его релизы. + +### 1. Атрибуция + +При распространении Программного обеспечения, как в виде исходного кода, так и в бинарной форме, ОБЯЗАТЕЛЬНО СОХРАНЕНИЕ: + +- указанного выше уведомления об авторском праве; +- текста настоящей Лицензии; +- всех существующих уведомлений об атрибуции. + +### 2. Уведомление о модификациях + +В случае внесения изменений в Программное обеспечение вы ОБЯЗАНЫ явно указать факт модификации Программного обеспечения и включить краткое описание внесённых изменений. + +Модифицированные версии НЕ ДОЛЖНЫ представляться как оригинальное Программное обеспечение Telemt. + +### 3. Товарные знаки и брендинг + +Настоящая Лицензия НЕ предоставляет право на использование наименования "Telemt", логотипа Telemt или любых товарных знаков и элементов брендинга Telemt. + +Распространяемые или модифицированные версии Программного обеспечения НЕ МОГУТ использовать наименование Telemt таким образом, который может создавать впечатление одобрения или официального происхождения без явного разрешения мейнтейнеров Telemt. + +Использование наименования "Telemt" для описания модифицированной версии Программного обеспечения допускается только при условии, что такая версия чётко обозначена как модифицированная или неофициальная. + +Запрещается любое распространение, способное разумно ввести пользователей в заблуждение относительно того, что программное обеспечение является официальным релизом Telemt. + +### 4. Прозрачность распространения бинарных файлов + +В случае распространения скомпилированных бинарных файлов Программного обеспечения рекомендуется (ENCOURAGED) предоставлять доступ к соответствующему исходному коду и инструкциям по сборке, если это разумно возможно. + +Это способствует обеспечению прозрачности и позволяет получателям проверять целостность и воспроизводимость распространяемых сборок. + +### 5. Патентная лицензия и условие защитного прекращения + +Каждый контрибьютор предоставляет вам бессрочную, всемирную, неисключительную, безвозмездную, без лицензионных отчислений, безотзывную патентную лицензию на: + +- изготовление, +- поручение изготовления, +- использование, +- предложение к продаже, +- продажу, +- импорт, +- а также иные формы передачи Программного обеспечения. + +Данная патентная лицензия распространяется исключительно на те патентные притязания, которые неизбежно нарушаются вкладом контрибьютора отдельно либо в сочетании его вклада с Программным обеспечением. + +Если вы инициируете или участвуете в любом патентном судебном разбирательстве, включая встречные иски или требования, утверждая, что Программное обеспечение или любой Вклад, включённый в Программное обеспечение, нарушает патент, то **все предоставленные вам настоящей Лицензией права немедленно прекращаются** с даты подачи такого иска. + +Дополнительно, если вы инициируете судебное разбирательство, утверждая, что само Программное обеспечение нарушает ваш патент или иные права интеллектуальной собственности, все права, предоставленные вам настоящей Лицензией, ПРЕКРАЩАЮТСЯ автоматически. + +### 6. Вклады + +Если вы прямо не указали иное, любой Вклад, сознательно представленный для включения в Программное обеспечение, лицензируется на условиях настоящей Лицензии. + +Предоставляя Вклад, вы предоставляете мейнтейнерам Telemt и всем получателям Программного обеспечения права, предусмотренные настоящей Лицензией, в отношении такого Вклада. + +### 7. Атрибуция при сетевом использовании + +Если Программное обеспечение используется для предоставления общедоступного сетевого сервиса, оператор такого сервиса ДОЛЖЕН (SHOULD) обеспечить указание атрибуции Telemt как минимум в одном из следующих мест: + +- документация сервиса; +- описание сервиса; +- раздел "О программе" или аналогичная информационная страница; +- иные материалы, доступные пользователю и разумно связанные с сервисом. + +Такая атрибуция НЕ ДОЛЖНА подразумевать одобрение со стороны проекта Telemt или его мейнтейнеров. + +### 8. Отказ от гарантий и оговорка о делимости + +ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПРЕДОСТАВЛЯЕТСЯ "КАК ЕСТЬ", БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ, ЯВНЫХ ИЛИ ПОДРАЗУМЕВАЕМЫХ, ВКЛЮЧАЯ, В ЧАСТНОСТИ, ГАРАНТИИ ТОВАРНОЙ ПРИГОДНОСТИ, СООТВЕТСТВИЯ ОПРЕДЕЛЁННОЙ ЦЕЛИ И ОТСУТСТВИЯ НАРУШЕНИЙ ПРАВ. + +НИ ПРИ КАКИХ ОБСТОЯТЕЛЬСТВАХ АВТОРЫ ИЛИ ПРАВООБЛАДАТЕЛИ НЕ НЕСУТ ОТВЕТСТВЕННОСТИ ПО КАКИМ-ЛИБО ТРЕБОВАНИЯМ, УБЫТКАМ ИЛИ ИНОЙ ОТВЕТСТВЕННОСТИ, ВОЗНИКАЮЩИМ В РАМКАХ ДОГОВОРА, ДЕЛИКТА ИЛИ ИНЫМ ОБРАЗОМ, ИЗ, В СВЯЗИ С ИЛИ В РЕЗУЛЬТАТЕ ИСПОЛЬЗОВАНИЯ ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ ИЛИ ИНЫХ ДЕЙСТВИЙ С НИМ. + +ЕСЛИ ЛЮБОЕ ПОЛОЖЕНИЕ НАСТОЯЩЕЙ ЛИЦЕНЗИИ ПРИЗНАЁТСЯ НЕДЕЙСТВИТЕЛЬНЫМ ИЛИ НЕПРИМЕНИМЫМ, ТАКОЕ ПОЛОЖЕНИЕ ПОДЛЕЖИТ ТОЛКОВАНИЮ МАКСИМАЛЬНО БЛИЗКО К ИСХОДНОМУ НАМЕРЕНИЮ СТОРОН, А ОСТАЛЬНЫЕ ПОЛОЖЕНИЯ СОХРАНЯЮТ ПОЛНУЮ СИЛУ И ДЕЙСТВИЕ. \ No newline at end of file diff --git a/docs/QUICK_START_GUIDE.en.md b/docs/QUICK_START_GUIDE.en.md index 0f234e8..ee64dd3 100644 --- a/docs/QUICK_START_GUIDE.en.md +++ b/docs/QUICK_START_GUIDE.en.md @@ -150,7 +150,7 @@ systemctl daemon-reload **7.** To get the link(s), enter: ```bash -curl -s http://127.0.0.1:9091/v1/users | jq +curl -s http://127.0.0.1:9091/v1/users | jq -r '.data[] | "[\(.username)]", (.links.classic[]? | "classic: \(.)"), (.links.secure[]? | "secure: \(.)"), (.links.tls[]? | "tls: \(.)"), ""' ``` > Any number of people can use one link. diff --git a/docs/QUICK_START_GUIDE.ru.md b/docs/QUICK_START_GUIDE.ru.md index fcf0207..458871c 100644 --- a/docs/QUICK_START_GUIDE.ru.md +++ b/docs/QUICK_START_GUIDE.ru.md @@ -150,7 +150,7 @@ systemctl daemon-reload **7.** Для получения ссылки/ссылок введите ```bash -curl -s http://127.0.0.1:9091/v1/users | jq +curl -s http://127.0.0.1:9091/v1/users | jq -r '.data[] | "[\(.username)]", (.links.classic[]? | "classic: \(.)"), (.links.secure[]? | "secure: \(.)"), (.links.tls[]? | "tls: \(.)"), ""' ``` > Одной ссылкой может пользоваться сколько угодно человек. diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index fa42c55..5582e9b 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -540,6 +540,10 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig { cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode; cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs; + if cfg.rebuild_runtime_user_auth().is_err() { + cfg.runtime_user_auth = None; + } + cfg } diff --git a/src/config/load.rs b/src/config/load.rs index 0f0990c..32f877d 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -4,6 +4,7 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use std::hash::{DefaultHasher, Hash, Hasher}; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; +use std::sync::Arc; use rand::RngExt; use serde::{Deserialize, Serialize}; @@ -15,6 +16,8 @@ use crate::error::{ProxyError, Result}; use super::defaults::*; use super::types::*; +const ACCESS_SECRET_BYTES: usize = 16; + #[derive(Debug, Clone)] pub(crate) struct LoadedConfig { pub(crate) config: ProxyConfig, @@ -22,6 +25,111 @@ pub(crate) struct LoadedConfig { pub(crate) rendered_hash: u64, } +/// Precomputed, immutable user authentication data used by handshake hot paths. +#[derive(Debug, Clone, Default)] +pub(crate) struct UserAuthSnapshot { + entries: Vec, + by_name: HashMap, + sni_index: HashMap>, + sni_initial_index: HashMap>, +} + +#[derive(Debug, Clone)] +pub(crate) struct UserAuthEntry { + pub(crate) user: String, + pub(crate) secret: [u8; ACCESS_SECRET_BYTES], +} + +impl UserAuthSnapshot { + fn from_users(users: &HashMap) -> Result { + let mut entries = Vec::with_capacity(users.len()); + let mut by_name = HashMap::with_capacity(users.len()); + let mut sni_index = HashMap::with_capacity(users.len()); + let mut sni_initial_index = HashMap::with_capacity(users.len()); + + for (user, secret_hex) in users { + let decoded = hex::decode(secret_hex).map_err(|_| ProxyError::InvalidSecret { + user: user.clone(), + reason: "Must be 32 hex characters".to_string(), + })?; + if decoded.len() != ACCESS_SECRET_BYTES { + return Err(ProxyError::InvalidSecret { + user: user.clone(), + reason: "Must be 32 hex characters".to_string(), + }); + } + + let user_id = u32::try_from(entries.len()).map_err(|_| { + ProxyError::Config("Too many users for runtime auth snapshot".to_string()) + })?; + + let mut secret = [0u8; ACCESS_SECRET_BYTES]; + secret.copy_from_slice(&decoded); + entries.push(UserAuthEntry { + user: user.clone(), + secret, + }); + by_name.insert(user.clone(), user_id); + sni_index + .entry(Self::sni_lookup_hash(user)) + .or_insert_with(Vec::new) + .push(user_id); + if let Some(initial) = user + .as_bytes() + .first() + .map(|byte| byte.to_ascii_lowercase()) + { + sni_initial_index + .entry(initial) + .or_insert_with(Vec::new) + .push(user_id); + } + } + + Ok(Self { + entries, + by_name, + sni_index, + sni_initial_index, + }) + } + + pub(crate) fn entries(&self) -> &[UserAuthEntry] { + &self.entries + } + + pub(crate) fn user_id_by_name(&self, user: &str) -> Option { + self.by_name.get(user).copied() + } + + pub(crate) fn entry_by_id(&self, user_id: u32) -> Option<&UserAuthEntry> { + let idx = usize::try_from(user_id).ok()?; + self.entries.get(idx) + } + + pub(crate) fn sni_candidates(&self, sni: &str) -> Option<&[u32]> { + self.sni_index + .get(&Self::sni_lookup_hash(sni)) + .map(Vec::as_slice) + } + + pub(crate) fn sni_initial_candidates(&self, sni: &str) -> Option<&[u32]> { + let initial = sni + .as_bytes() + .first() + .map(|byte| byte.to_ascii_lowercase())?; + self.sni_initial_index.get(&initial).map(Vec::as_slice) + } + + fn sni_lookup_hash(value: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + for byte in value.bytes() { + hasher.write_u8(byte.to_ascii_lowercase()); + } + hasher.finish() + } +} + fn normalize_config_path(path: &Path) -> PathBuf { path.canonicalize().unwrap_or_else(|_| { if path.is_absolute() { @@ -196,6 +304,10 @@ pub struct ProxyConfig { /// If not set, defaults to 2 (matching Telegram's official `default 2;` in proxy-multi.conf). #[serde(default)] pub default_dc: Option, + + /// Precomputed authentication snapshot for handshake hot paths. + #[serde(skip)] + pub(crate) runtime_user_auth: Option>, } impl ProxyConfig { @@ -1164,6 +1276,7 @@ impl ProxyConfig { .or_insert_with(|| vec!["91.105.192.100:443".to_string()]); validate_upstreams(&config)?; + config.rebuild_runtime_user_auth()?; Ok(LoadedConfig { config, @@ -1172,6 +1285,16 @@ impl ProxyConfig { }) } + pub(crate) fn rebuild_runtime_user_auth(&mut self) -> Result<()> { + let snapshot = UserAuthSnapshot::from_users(&self.access.users)?; + self.runtime_user_auth = Some(Arc::new(snapshot)); + Ok(()) + } + + pub(crate) fn runtime_user_auth(&self) -> Option<&UserAuthSnapshot> { + self.runtime_user_auth.as_deref() + } + pub fn validate(&self) -> Result<()> { if self.access.users.is_empty() { return Err(ProxyError::Config("No users configured".to_string())); @@ -1635,6 +1758,22 @@ mod tests { cfg_mask.censorship.unknown_sni_action, UnknownSniAction::Mask ); + + let cfg_accept: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [censorship] + unknown_sni_action = "accept" + "#, + ) + .unwrap(); + assert_eq!( + cfg_accept.censorship.unknown_sni_action, + UnknownSniAction::Accept + ); } #[test] diff --git a/src/config/types.rs b/src/config/types.rs index a1ffd13..0a5af21 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1502,6 +1502,7 @@ pub enum UnknownSniAction { #[default] Drop, Mask, + Accept, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 8e2481e..6f7de16 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -339,31 +339,35 @@ fn is_process_running(pid: i32) -> bool { /// Drops privileges to the specified user and group. /// -/// This should be called after binding privileged ports but before -/// entering the main event loop. -pub fn drop_privileges(user: Option<&str>, group: Option<&str>) -> Result<(), DaemonError> { - // Look up group first (need to do this while still root) +/// This should be called after binding privileged ports but before entering +/// the main event loop. +pub fn drop_privileges( + user: Option<&str>, + group: Option<&str>, + pid_file: Option<&PidFile>, +) -> Result<(), DaemonError> { let target_gid = if let Some(group_name) = group { Some(lookup_group(group_name)?) } else if let Some(user_name) = user { - // If no group specified but user is, use user's primary group Some(lookup_user_primary_gid(user_name)?) } else { None }; - // Look up user let target_uid = if let Some(user_name) = user { Some(lookup_user(user_name)?) } else { None }; - // Drop privileges: set GID first, then UID - // (Setting UID first would prevent us from setting GID) + if (target_uid.is_some() || target_gid.is_some()) + && let Some(file) = pid_file.and_then(|pid| pid.file.as_ref()) + { + unistd::fchown(file, target_uid, target_gid).map_err(DaemonError::PrivilegeDrop)?; + } + if let Some(gid) = target_gid { unistd::setgid(gid).map_err(DaemonError::PrivilegeDrop)?; - // Also set supplementary groups to just this one unistd::setgroups(&[gid]).map_err(DaemonError::PrivilegeDrop)?; info!(gid = gid.as_raw(), "Dropped group privileges"); } @@ -371,6 +375,38 @@ pub fn drop_privileges(user: Option<&str>, group: Option<&str>) -> Result<(), Da if let Some(uid) = target_uid { unistd::setuid(uid).map_err(DaemonError::PrivilegeDrop)?; info!(uid = uid.as_raw(), "Dropped user privileges"); + + if uid.as_raw() != 0 + && let Some(pid) = pid_file + { + let parent = pid.path.parent().unwrap_or(Path::new(".")); + let probe_path = parent.join(format!( + ".telemt_pid_probe_{}_{}", + std::process::id(), + getpid().as_raw() + )); + OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&probe_path) + .map_err(|e| { + DaemonError::PidFile(format!( + "cannot create probe in PID directory {} as uid {} (pid cleanup will fail): {}", + parent.display(), + uid.as_raw(), + e + )) + })?; + fs::remove_file(&probe_path).map_err(|e| { + DaemonError::PidFile(format!( + "cannot remove probe in PID directory {} as uid {} (pid cleanup will fail): {}", + parent.display(), + uid.as_raw(), + e + )) + })?; + } } Ok(()) diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index 9211408..eed8d2e 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -763,7 +763,11 @@ async fn run_inner( // Drop privileges after binding sockets (which may require root for port < 1024) if daemon_opts.user.is_some() || daemon_opts.group.is_some() { - if let Err(e) = drop_privileges(daemon_opts.user.as_deref(), daemon_opts.group.as_deref()) { + if let Err(e) = drop_privileges( + daemon_opts.user.as_deref(), + daemon_opts.group.as_deref(), + _pid_file.as_ref(), + ) { error!(error = %e, "Failed to drop privileges"); std::process::exit(1); } diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index 16d0c5e..904b8f9 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -4,8 +4,10 @@ use dashmap::DashMap; use dashmap::mapref::entry::Entry; +use hmac::{Hmac, Mac}; #[cfg(test)] use std::collections::HashSet; +use std::collections::hash_map::DefaultHasher; #[cfg(test)] use std::collections::hash_map::RandomState; use std::hash::{BuildHasher, Hash, Hasher}; @@ -14,6 +16,7 @@ use std::net::{IpAddr, Ipv6Addr}; use std::sync::Arc; #[cfg(test)] use std::sync::Mutex; +use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tracing::{debug, info, trace, warn}; @@ -30,6 +33,8 @@ use crate::stream::{CryptoReader, CryptoWriter, FakeTlsReader, FakeTlsWriter}; use crate::tls_front::{TlsFrontCache, emulator}; #[cfg(test)] use rand::RngExt; +use sha2::Sha256; +use subtle::ConstantTimeEq; const ACCESS_SECRET_BYTES: usize = 16; const UNKNOWN_SNI_WARN_COOLDOWN_SECS: u64 = 5; @@ -46,6 +51,13 @@ const AUTH_PROBE_TRACK_MAX_ENTRIES: usize = 65_536; const AUTH_PROBE_PRUNE_SCAN_LIMIT: usize = 1_024; const AUTH_PROBE_BACKOFF_START_FAILS: u32 = 4; const AUTH_PROBE_SATURATION_GRACE_FAILS: u32 = 2; +const STICKY_HINT_MAX_ENTRIES: usize = 65_536; +const CANDIDATE_HINT_TRACK_CAP: usize = 64; +const OVERLOAD_CANDIDATE_BUDGET_HINTED: usize = 16; +const OVERLOAD_CANDIDATE_BUDGET_UNHINTED: usize = 8; +const RECENT_USER_RING_SCAN_LIMIT: usize = 32; + +type HmacSha256 = Hmac; #[cfg(test)] const AUTH_PROBE_BACKOFF_BASE_MS: u64 = 1; @@ -91,6 +103,302 @@ fn should_emit_unknown_sni_warn_in(shared: &ProxySharedState, now: Instant) -> b true } +#[derive(Clone, Copy)] +struct ParsedTlsAuthMaterial { + digest: [u8; tls::TLS_DIGEST_LEN], + session_id: [u8; 32], + session_id_len: usize, + now: i64, + ignore_time_skew: bool, + boot_time_cap_secs: u32, +} + +#[derive(Clone, Copy)] +struct TlsCandidateValidation { + digest: [u8; tls::TLS_DIGEST_LEN], + session_id: [u8; 32], + session_id_len: usize, +} + +struct MtprotoCandidateValidation { + proto_tag: ProtoTag, + dc_idx: i16, + dec_key: [u8; 32], + dec_iv: u128, + enc_key: [u8; 32], + enc_iv: u128, + decryptor: AesCtr, + encryptor: AesCtr, +} + +fn sni_hint_hash(sni: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + for byte in sni.bytes() { + hasher.write_u8(byte.to_ascii_lowercase()); + } + hasher.finish() +} + +fn ip_prefix_hint_key(peer_ip: IpAddr) -> u64 { + match peer_ip { + // Keep /24 granularity for IPv4 to avoid over-merging unrelated clients. + IpAddr::V4(ip) => { + let [a, b, c, _] = ip.octets(); + u64::from_be_bytes([0x04, a, b, c, 0, 0, 0, 0]) + } + // Keep /56 granularity for IPv6 to retain stability while limiting bucket size. + IpAddr::V6(ip) => { + let octets = ip.octets(); + u64::from_be_bytes([ + 0x06, octets[0], octets[1], octets[2], octets[3], octets[4], octets[5], octets[6], + ]) + } + } +} + +fn sticky_hint_get_by_ip(shared: &ProxySharedState, peer_ip: IpAddr) -> Option { + shared + .handshake + .sticky_user_by_ip + .get(&peer_ip) + .map(|entry| *entry) +} + +fn sticky_hint_get_by_ip_prefix(shared: &ProxySharedState, peer_ip: IpAddr) -> Option { + shared + .handshake + .sticky_user_by_ip_prefix + .get(&ip_prefix_hint_key(peer_ip)) + .map(|entry| *entry) +} + +fn sticky_hint_get_by_sni(shared: &ProxySharedState, sni: &str) -> Option { + let key = sni_hint_hash(sni); + shared + .handshake + .sticky_user_by_sni_hash + .get(&key) + .map(|entry| *entry) +} + +fn sticky_hint_record_success_in( + shared: &ProxySharedState, + peer_ip: IpAddr, + user_id: u32, + sni: Option<&str>, +) { + if shared.handshake.sticky_user_by_ip.len() > STICKY_HINT_MAX_ENTRIES { + shared.handshake.sticky_user_by_ip.clear(); + } + shared.handshake.sticky_user_by_ip.insert(peer_ip, user_id); + + if shared.handshake.sticky_user_by_ip_prefix.len() > STICKY_HINT_MAX_ENTRIES { + shared.handshake.sticky_user_by_ip_prefix.clear(); + } + shared + .handshake + .sticky_user_by_ip_prefix + .insert(ip_prefix_hint_key(peer_ip), user_id); + + if let Some(sni) = sni { + if shared.handshake.sticky_user_by_sni_hash.len() > STICKY_HINT_MAX_ENTRIES { + shared.handshake.sticky_user_by_sni_hash.clear(); + } + shared + .handshake + .sticky_user_by_sni_hash + .insert(sni_hint_hash(sni), user_id); + } +} + +fn record_recent_user_success_in(shared: &ProxySharedState, user_id: u32) { + let ring = &shared.handshake.recent_user_ring; + if ring.is_empty() { + return; + } + let seq = shared + .handshake + .recent_user_ring_seq + .fetch_add(1, Ordering::Relaxed); + let idx = (seq as usize) % ring.len(); + ring[idx].store(user_id.saturating_add(1), Ordering::Relaxed); +} + +fn mark_candidate_if_new(tried_user_ids: &mut [u32], tried_len: &mut usize, user_id: u32) -> bool { + if tried_user_ids[..*tried_len].contains(&user_id) { + return false; + } + if *tried_len < tried_user_ids.len() { + tried_user_ids[*tried_len] = user_id; + *tried_len += 1; + } + true +} + +fn budget_for_validation(total_users: usize, overload: bool, has_hint: bool) -> usize { + if total_users == 0 { + return 0; + } + if !overload { + return total_users; + } + let cap = if has_hint { + OVERLOAD_CANDIDATE_BUDGET_HINTED + } else { + OVERLOAD_CANDIDATE_BUDGET_UNHINTED + }; + total_users.min(cap.max(1)) +} + +fn parse_tls_auth_material( + handshake: &[u8], + ignore_time_skew: bool, + replay_window_secs: u64, +) -> Option { + if handshake.len() < tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 { + return None; + } + + let digest: [u8; tls::TLS_DIGEST_LEN] = handshake + [tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN] + .try_into() + .ok()?; + + let session_id_len_pos = tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN; + let session_id_len = usize::from(handshake.get(session_id_len_pos).copied()?); + if session_id_len > 32 { + return None; + } + let session_id_start = session_id_len_pos + 1; + if handshake.len() < session_id_start + session_id_len { + return None; + } + + let mut session_id = [0u8; 32]; + session_id[..session_id_len] + .copy_from_slice(&handshake[session_id_start..session_id_start + session_id_len]); + + let now = if !ignore_time_skew { + let d = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()?; + i64::try_from(d.as_secs()).ok()? + } else { + 0_i64 + }; + + let replay_window_u32 = u32::try_from(replay_window_secs).unwrap_or(u32::MAX); + let boot_time_cap_secs = if ignore_time_skew { + 0 + } else { + tls::BOOT_TIME_MAX_SECS + .min(replay_window_u32) + .min(tls::BOOT_TIME_COMPAT_MAX_SECS) + }; + + Some(ParsedTlsAuthMaterial { + digest, + session_id, + session_id_len, + now, + ignore_time_skew, + boot_time_cap_secs, + }) +} + +fn compute_tls_hmac_zeroed_digest(secret: &[u8], handshake: &[u8]) -> [u8; 32] { + let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length"); + mac.update(&handshake[..tls::TLS_DIGEST_POS]); + mac.update(&[0u8; tls::TLS_DIGEST_LEN]); + mac.update(&handshake[tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN..]); + mac.finalize().into_bytes().into() +} + +fn validate_tls_secret_candidate( + parsed: &ParsedTlsAuthMaterial, + handshake: &[u8], + secret: &[u8], +) -> Option { + let computed = compute_tls_hmac_zeroed_digest(secret, handshake); + if !bool::from(parsed.digest[..28].ct_eq(&computed[..28])) { + return None; + } + + let timestamp = u32::from_le_bytes([ + parsed.digest[28] ^ computed[28], + parsed.digest[29] ^ computed[29], + parsed.digest[30] ^ computed[30], + parsed.digest[31] ^ computed[31], + ]); + + if !parsed.ignore_time_skew { + let is_boot_time = parsed.boot_time_cap_secs > 0 && timestamp < parsed.boot_time_cap_secs; + if !is_boot_time { + let time_diff = parsed.now - i64::from(timestamp); + if !(tls::TIME_SKEW_MIN..=tls::TIME_SKEW_MAX).contains(&time_diff) { + return None; + } + } + } + + Some(TlsCandidateValidation { + digest: parsed.digest, + session_id: parsed.session_id, + session_id_len: parsed.session_id_len, + }) +} + +fn validate_mtproto_secret_candidate( + handshake: &[u8; HANDSHAKE_LEN], + dec_prekey: &[u8; PREKEY_LEN], + dec_iv: u128, + enc_prekey: &[u8; PREKEY_LEN], + enc_iv: u128, + secret: &[u8; ACCESS_SECRET_BYTES], + config: &ProxyConfig, + is_tls: bool, +) -> Option { + let mut dec_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len())); + dec_key_input.extend_from_slice(dec_prekey); + dec_key_input.extend_from_slice(secret); + let dec_key = Zeroizing::new(sha256(&dec_key_input)); + + let mut decryptor = AesCtr::new(&dec_key, dec_iv); + let mut decrypted = *handshake; + decryptor.apply(&mut decrypted); + + let tag_bytes: [u8; 4] = [ + decrypted[PROTO_TAG_POS], + decrypted[PROTO_TAG_POS + 1], + decrypted[PROTO_TAG_POS + 2], + decrypted[PROTO_TAG_POS + 3], + ]; + let proto_tag = ProtoTag::from_bytes(tag_bytes)?; + if !mode_enabled_for_proto(config, proto_tag, is_tls) { + return None; + } + + let dc_idx = i16::from_le_bytes([decrypted[DC_IDX_POS], decrypted[DC_IDX_POS + 1]]); + + let mut enc_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len())); + enc_key_input.extend_from_slice(enc_prekey); + enc_key_input.extend_from_slice(secret); + let enc_key = Zeroizing::new(sha256(&enc_key_input)); + + let encryptor = AesCtr::new(&enc_key, enc_iv); + + Some(MtprotoCandidateValidation { + proto_tag, + dc_idx, + dec_key: *dec_key, + dec_iv, + enc_key: *enc_key, + enc_iv, + decryptor, + encryptor, + }) +} + fn normalize_auth_probe_ip(peer_ip: IpAddr) -> IpAddr { match peer_ip { IpAddr::V4(ip) => IpAddr::V4(ip), @@ -813,56 +1121,276 @@ where }; if client_sni.is_some() && matched_tls_domain.is_none() && preferred_user_hint.is_none() { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; let sni = client_sni.as_deref().unwrap_or_default(); - let log_now = Instant::now(); - if should_emit_unknown_sni_warn_in(shared, log_now) { - warn!( - peer = %peer, - sni = %sni, - unknown_sni = true, - unknown_sni_action = ?config.censorship.unknown_sni_action, - "TLS handshake rejected by unknown SNI policy" - ); - } else { - info!( - peer = %peer, - sni = %sni, - unknown_sni = true, - unknown_sni_action = ?config.censorship.unknown_sni_action, - "TLS handshake rejected by unknown SNI policy" - ); + match config.censorship.unknown_sni_action { + UnknownSniAction::Accept => { + debug!( + peer = %peer, + sni = %sni, + unknown_sni = true, + unknown_sni_action = ?config.censorship.unknown_sni_action, + "TLS handshake accepted by unknown SNI policy" + ); + } + action @ (UnknownSniAction::Drop | UnknownSniAction::Mask) => { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + let log_now = Instant::now(); + if should_emit_unknown_sni_warn_in(shared, log_now) { + warn!( + peer = %peer, + sni = %sni, + unknown_sni = true, + unknown_sni_action = ?action, + "TLS handshake rejected by unknown SNI policy" + ); + } else { + info!( + peer = %peer, + sni = %sni, + unknown_sni = true, + unknown_sni_action = ?action, + "TLS handshake rejected by unknown SNI policy" + ); + } + return match action { + UnknownSniAction::Drop => HandshakeResult::Error(ProxyError::UnknownTlsSni), + UnknownSniAction::Mask => HandshakeResult::BadClient { reader, writer }, + UnknownSniAction::Accept => unreachable!(), + }; + } } - return match config.censorship.unknown_sni_action { - UnknownSniAction::Drop => HandshakeResult::Error(ProxyError::UnknownTlsSni), - UnknownSniAction::Mask => HandshakeResult::BadClient { reader, writer }, - }; } - let secrets = decode_user_secrets_in(shared, config, preferred_user_hint); + let mut validation_digest = [0u8; tls::TLS_DIGEST_LEN]; + let mut validation_session_id = [0u8; 32]; + let mut validation_session_id_len = 0usize; + let mut validated_user = String::new(); + let mut validated_secret = [0u8; ACCESS_SECRET_BYTES]; + let mut validated_user_id: Option = None; - let validation = match tls::validate_tls_handshake_with_replay_window( - handshake, - &secrets, - config.access.ignore_time_skew, - config.access.replay_window_secs, - ) { - Some(v) => v, - None => { + if let Some(snapshot) = config.runtime_user_auth() { + let parsed = match parse_tls_auth_material( + handshake, + config.access.ignore_time_skew, + config.access.replay_window_secs, + ) { + Some(parsed) => parsed, + None => { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + debug!(peer = %peer, "TLS handshake auth material parsing failed"); + return HandshakeResult::BadClient { reader, writer }; + } + }; + + let sticky_ip_hint = sticky_hint_get_by_ip(shared, peer.ip()); + let preferred_user_id = preferred_user_hint.and_then(|user| snapshot.user_id_by_name(user)); + let sticky_sni_hint = client_sni + .as_deref() + .and_then(|sni| sticky_hint_get_by_sni(shared, sni)); + let sticky_prefix_hint = sticky_hint_get_by_ip_prefix(shared, peer.ip()); + let sni_candidates = client_sni + .as_deref() + .and_then(|sni| snapshot.sni_candidates(sni)); + let sni_initial_candidates = client_sni + .as_deref() + .and_then(|sni| snapshot.sni_initial_candidates(sni)); + + let has_hint = sticky_ip_hint.is_some() + || preferred_user_id.is_some() + || sticky_sni_hint.is_some() + || sticky_prefix_hint.is_some() + || sni_candidates.is_some_and(|ids| !ids.is_empty()) + || sni_initial_candidates.is_some_and(|ids| !ids.is_empty()); + let overload = auth_probe_saturation_is_throttled_in(shared, Instant::now()); + let candidate_budget = budget_for_validation(snapshot.entries().len(), overload, has_hint); + + let mut tried_user_ids = [u32::MAX; CANDIDATE_HINT_TRACK_CAP]; + let mut tried_len = 0usize; + let mut validation_checks = 0usize; + let mut budget_exhausted = false; + + macro_rules! try_user_id { + ($user_id:expr) => {{ + if validation_checks >= candidate_budget { + budget_exhausted = true; + false + } else if !mark_candidate_if_new(&mut tried_user_ids, &mut tried_len, $user_id) { + false + } else if let Some(entry) = snapshot.entry_by_id($user_id) { + validation_checks = validation_checks.saturating_add(1); + if let Some(candidate) = + validate_tls_secret_candidate(&parsed, handshake, &entry.secret) + { + validation_digest = candidate.digest; + validation_session_id = candidate.session_id; + validation_session_id_len = candidate.session_id_len; + validated_secret.copy_from_slice(&entry.secret); + validated_user = entry.user.clone(); + validated_user_id = Some($user_id); + true + } else { + false + } + } else { + false + } + }}; + } + + let mut matched = false; + if let Some(user_id) = sticky_ip_hint { + matched = try_user_id!(user_id); + } + + if !matched && let Some(user_id) = preferred_user_id { + matched = try_user_id!(user_id); + } + + if !matched && let Some(user_id) = sticky_sni_hint { + matched = try_user_id!(user_id); + } + + if !matched && let Some(user_id) = sticky_prefix_hint { + matched = try_user_id!(user_id); + } + + if !matched + && !budget_exhausted + && let Some(candidate_ids) = sni_candidates + { + for &user_id in candidate_ids { + if try_user_id!(user_id) { + matched = true; + break; + } + if budget_exhausted { + break; + } + } + } + + if !matched + && !budget_exhausted + && let Some(candidate_ids) = sni_initial_candidates + { + for &user_id in candidate_ids { + if try_user_id!(user_id) { + matched = true; + break; + } + if budget_exhausted { + break; + } + } + } + + if !matched && !budget_exhausted { + let ring = &shared.handshake.recent_user_ring; + if !ring.is_empty() { + let next_seq = shared + .handshake + .recent_user_ring_seq + .load(Ordering::Relaxed); + let scan_limit = ring.len().min(RECENT_USER_RING_SCAN_LIMIT); + for offset in 0..scan_limit { + let idx = (next_seq as usize + ring.len() - 1 - offset) % ring.len(); + let encoded_user_id = ring[idx].load(Ordering::Relaxed); + if encoded_user_id == 0 { + continue; + } + if try_user_id!(encoded_user_id - 1) { + matched = true; + break; + } + if budget_exhausted { + break; + } + } + } + } + + if !matched && !budget_exhausted { + for idx in 0..snapshot.entries().len() { + let Some(user_id) = u32::try_from(idx).ok() else { + break; + }; + if try_user_id!(user_id) { + matched = true; + break; + } + if budget_exhausted { + break; + } + } + } + + shared + .handshake + .auth_expensive_checks_total + .fetch_add(validation_checks as u64, Ordering::Relaxed); + if budget_exhausted { + shared + .handshake + .auth_budget_exhausted_total + .fetch_add(1, Ordering::Relaxed); + } + + if !matched { auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); maybe_apply_server_hello_delay(config).await; debug!( peer = %peer, ignore_time_skew = config.access.ignore_time_skew, - "TLS handshake validation failed - no matching user or time skew" + budget_exhausted = budget_exhausted, + candidate_budget = candidate_budget, + validation_checks = validation_checks, + "TLS handshake validation failed - no matching user, time skew, or budget exhausted" ); return HandshakeResult::BadClient { reader, writer }; } - }; + } else { + let secrets = decode_user_secrets_in(shared, config, preferred_user_hint); + let validation = match tls::validate_tls_handshake_with_replay_window( + handshake, + &secrets, + config.access.ignore_time_skew, + config.access.replay_window_secs, + ) { + Some(v) => v, + None => { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + debug!( + peer = %peer, + ignore_time_skew = config.access.ignore_time_skew, + "TLS handshake validation failed - no matching user or time skew" + ); + return HandshakeResult::BadClient { reader, writer }; + } + }; + let secret = match secrets.iter().find(|(name, _)| *name == validation.user) { + Some((_, s)) if s.len() == ACCESS_SECRET_BYTES => s, + _ => { + maybe_apply_server_hello_delay(config).await; + return HandshakeResult::BadClient { reader, writer }; + } + }; + + validation_digest = validation.digest; + validation_session_id_len = validation.session_id.len(); + if validation_session_id_len > validation_session_id.len() { + maybe_apply_server_hello_delay(config).await; + return HandshakeResult::BadClient { reader, writer }; + } + validation_session_id[..validation_session_id_len].copy_from_slice(&validation.session_id); + validated_user = validation.user; + validated_secret.copy_from_slice(secret); + } // Reject known replay digests before expensive cache/domain/ALPN policy work. - let digest_half = &validation.digest[..tls::TLS_DIGEST_HALF_LEN]; + let digest_half = &validation_digest[..tls::TLS_DIGEST_HALF_LEN]; if replay_checker.check_tls_digest(digest_half) { auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); maybe_apply_server_hello_delay(config).await; @@ -870,14 +1398,6 @@ where return HandshakeResult::BadClient { reader, writer }; } - let secret = match secrets.iter().find(|(name, _)| *name == validation.user) { - Some((_, s)) => s, - None => { - maybe_apply_server_hello_delay(config).await; - return HandshakeResult::BadClient { reader, writer }; - } - }; - let cached = if config.censorship.tls_emulation { if let Some(cache) = tls_cache.as_ref() { let selected_domain = @@ -900,11 +1420,13 @@ where // Add replay digest only for policy-valid handshakes. replay_checker.add_tls_digest(digest_half); + let validation_session_id_slice = &validation_session_id[..validation_session_id_len]; + let response = if let Some((cached_entry, use_full_cert_payload)) = cached { emulator::build_emulated_server_hello( - secret, - &validation.digest, - &validation.session_id, + &validated_secret, + &validation_digest, + validation_session_id_slice, &cached_entry, use_full_cert_payload, rng, @@ -913,9 +1435,9 @@ where ) } else { tls::build_server_hello( - secret, - &validation.digest, - &validation.session_id, + &validated_secret, + &validation_digest, + validation_session_id_slice, config.censorship.fake_cert_len, rng, selected_alpn.clone(), @@ -941,16 +1463,21 @@ where debug!( peer = %peer, - user = %validation.user, + user = %validated_user, "TLS handshake successful" ); auth_probe_record_success_in(shared, peer.ip()); + if let Some(user_id) = validated_user_id { + sticky_hint_record_success_in(shared, peer.ip(), user_id, client_sni.as_deref()); + record_recent_user_success_in(shared, user_id); + } + HandshakeResult::Success(( FakeTlsReader::new(reader), FakeTlsWriter::new(writer), - validation.user, + validated_user, )) } @@ -1047,61 +1574,150 @@ where } let dec_prekey_iv = &handshake[SKIP_LEN..SKIP_LEN + PREKEY_LEN + IV_LEN]; + let mut dec_prekey = [0u8; PREKEY_LEN]; + dec_prekey.copy_from_slice(&dec_prekey_iv[..PREKEY_LEN]); + let mut dec_iv_arr = [0u8; IV_LEN]; + dec_iv_arr.copy_from_slice(&dec_prekey_iv[PREKEY_LEN..]); + let dec_iv = u128::from_be_bytes(dec_iv_arr); - let enc_prekey_iv: Vec = dec_prekey_iv.iter().rev().copied().collect(); + let mut enc_prekey_iv = [0u8; PREKEY_LEN + IV_LEN]; + for idx in 0..enc_prekey_iv.len() { + enc_prekey_iv[idx] = dec_prekey_iv[dec_prekey_iv.len() - 1 - idx]; + } + let mut enc_prekey = [0u8; PREKEY_LEN]; + enc_prekey.copy_from_slice(&enc_prekey_iv[..PREKEY_LEN]); + let mut enc_iv_arr = [0u8; IV_LEN]; + enc_iv_arr.copy_from_slice(&enc_prekey_iv[PREKEY_LEN..]); + let enc_iv = u128::from_be_bytes(enc_iv_arr); - let decoded_users = decode_user_secrets_in(shared, config, preferred_user); + if let Some(snapshot) = config.runtime_user_auth() { + let sticky_ip_hint = sticky_hint_get_by_ip(shared, peer.ip()); + let sticky_prefix_hint = sticky_hint_get_by_ip_prefix(shared, peer.ip()); + let preferred_user_id = preferred_user.and_then(|user| snapshot.user_id_by_name(user)); + let has_hint = + sticky_ip_hint.is_some() || sticky_prefix_hint.is_some() || preferred_user_id.is_some(); + let overload = auth_probe_saturation_is_throttled_in(shared, Instant::now()); + let candidate_budget = budget_for_validation(snapshot.entries().len(), overload, has_hint); - for (user, secret) in decoded_users { - let dec_prekey = &dec_prekey_iv[..PREKEY_LEN]; - let dec_iv_bytes = &dec_prekey_iv[PREKEY_LEN..]; + let mut tried_user_ids = [u32::MAX; CANDIDATE_HINT_TRACK_CAP]; + let mut tried_len = 0usize; + let mut validation_checks = 0usize; + let mut budget_exhausted = false; - let mut dec_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len())); - dec_key_input.extend_from_slice(dec_prekey); - dec_key_input.extend_from_slice(&secret); - let dec_key = Zeroizing::new(sha256(&dec_key_input)); + let mut matched_user = String::new(); + let mut matched_user_id = None; + let mut matched_validation = None; - let mut dec_iv_arr = [0u8; IV_LEN]; - dec_iv_arr.copy_from_slice(dec_iv_bytes); - let dec_iv = u128::from_be_bytes(dec_iv_arr); - - let mut decryptor = AesCtr::new(&dec_key, dec_iv); - let decrypted = decryptor.decrypt(handshake); - - let tag_bytes: [u8; 4] = [ - decrypted[PROTO_TAG_POS], - decrypted[PROTO_TAG_POS + 1], - decrypted[PROTO_TAG_POS + 2], - decrypted[PROTO_TAG_POS + 3], - ]; - - let proto_tag = match ProtoTag::from_bytes(tag_bytes) { - Some(tag) => tag, - None => continue, - }; - - let mode_ok = mode_enabled_for_proto(config, proto_tag, is_tls); - - if !mode_ok { - debug!(peer = %peer, user = %user, proto = ?proto_tag, "Mode not enabled"); - continue; + macro_rules! try_user_id { + ($user_id:expr) => {{ + if validation_checks >= candidate_budget { + budget_exhausted = true; + false + } else if !mark_candidate_if_new(&mut tried_user_ids, &mut tried_len, $user_id) { + false + } else if let Some(entry) = snapshot.entry_by_id($user_id) { + validation_checks = validation_checks.saturating_add(1); + if let Some(validation) = validate_mtproto_secret_candidate( + handshake, + &dec_prekey, + dec_iv, + &enc_prekey, + enc_iv, + &entry.secret, + config, + is_tls, + ) { + matched_user = entry.user.clone(); + matched_user_id = Some($user_id); + matched_validation = Some(validation); + true + } else { + false + } + } else { + false + } + }}; } - let dc_idx = i16::from_le_bytes([decrypted[DC_IDX_POS], decrypted[DC_IDX_POS + 1]]); + let mut matched = false; + if let Some(user_id) = sticky_ip_hint { + matched = try_user_id!(user_id); + } - let enc_prekey = &enc_prekey_iv[..PREKEY_LEN]; - let enc_iv_bytes = &enc_prekey_iv[PREKEY_LEN..]; + if !matched && let Some(user_id) = preferred_user_id { + matched = try_user_id!(user_id); + } - let mut enc_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len())); - enc_key_input.extend_from_slice(enc_prekey); - enc_key_input.extend_from_slice(&secret); - let enc_key = Zeroizing::new(sha256(&enc_key_input)); + if !matched && let Some(user_id) = sticky_prefix_hint { + matched = try_user_id!(user_id); + } - let mut enc_iv_arr = [0u8; IV_LEN]; - enc_iv_arr.copy_from_slice(enc_iv_bytes); - let enc_iv = u128::from_be_bytes(enc_iv_arr); + if !matched && !budget_exhausted { + let ring = &shared.handshake.recent_user_ring; + if !ring.is_empty() { + let next_seq = shared + .handshake + .recent_user_ring_seq + .load(Ordering::Relaxed); + let scan_limit = ring.len().min(RECENT_USER_RING_SCAN_LIMIT); + for offset in 0..scan_limit { + let idx = (next_seq as usize + ring.len() - 1 - offset) % ring.len(); + let encoded_user_id = ring[idx].load(Ordering::Relaxed); + if encoded_user_id == 0 { + continue; + } + if try_user_id!(encoded_user_id - 1) { + matched = true; + break; + } + if budget_exhausted { + break; + } + } + } + } - let encryptor = AesCtr::new(&enc_key, enc_iv); + if !matched && !budget_exhausted { + for idx in 0..snapshot.entries().len() { + let Some(user_id) = u32::try_from(idx).ok() else { + break; + }; + if try_user_id!(user_id) { + matched = true; + break; + } + if budget_exhausted { + break; + } + } + } + + shared + .handshake + .auth_expensive_checks_total + .fetch_add(validation_checks as u64, Ordering::Relaxed); + if budget_exhausted { + shared + .handshake + .auth_budget_exhausted_total + .fetch_add(1, Ordering::Relaxed); + } + + if !matched { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + debug!( + peer = %peer, + budget_exhausted = budget_exhausted, + candidate_budget = candidate_budget, + validation_checks = validation_checks, + "MTProto handshake: no matching user found" + ); + return HandshakeResult::BadClient { reader, writer }; + } + + let validation = matched_validation.expect("validation must exist when matched"); // Apply replay tracking only after successful authentication. // @@ -1112,39 +1728,125 @@ where if replay_checker.check_and_add_handshake(dec_prekey_iv) { auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); maybe_apply_server_hello_delay(config).await; - warn!(peer = %peer, user = %user, "MTProto replay attack detected"); + warn!(peer = %peer, user = %matched_user, "MTProto replay attack detected"); return HandshakeResult::BadClient { reader, writer }; } + let dec_key = Zeroizing::new(validation.dec_key); + let enc_key = Zeroizing::new(validation.enc_key); let success = HandshakeSuccess { - user: user.clone(), - dc_idx, - proto_tag, + user: matched_user.clone(), + dc_idx: validation.dc_idx, + proto_tag: validation.proto_tag, dec_key: *dec_key, - dec_iv, + dec_iv: validation.dec_iv, enc_key: *enc_key, - enc_iv, + enc_iv: validation.enc_iv, peer, is_tls, }; debug!( peer = %peer, - user = %user, - dc = dc_idx, - proto = ?proto_tag, + user = %matched_user, + dc = validation.dc_idx, + proto = ?validation.proto_tag, tls = is_tls, "MTProto handshake successful" ); auth_probe_record_success_in(shared, peer.ip()); + if let Some(user_id) = matched_user_id { + sticky_hint_record_success_in(shared, peer.ip(), user_id, None); + record_recent_user_success_in(shared, user_id); + } let max_pending = config.general.crypto_pending_buffer; return HandshakeResult::Success(( - CryptoReader::new(reader, decryptor), - CryptoWriter::new(writer, encryptor, max_pending), + CryptoReader::new(reader, validation.decryptor), + CryptoWriter::new(writer, validation.encryptor, max_pending), success, )); + } else { + let decoded_users = decode_user_secrets_in(shared, config, preferred_user); + let mut validation_checks = 0usize; + + for (user, secret) in decoded_users { + if secret.len() != ACCESS_SECRET_BYTES { + continue; + } + validation_checks = validation_checks.saturating_add(1); + + let mut secret_arr = [0u8; ACCESS_SECRET_BYTES]; + secret_arr.copy_from_slice(&secret); + let Some(validation) = validate_mtproto_secret_candidate( + handshake, + &dec_prekey, + dec_iv, + &enc_prekey, + enc_iv, + &secret_arr, + config, + is_tls, + ) else { + continue; + }; + + shared + .handshake + .auth_expensive_checks_total + .fetch_add(validation_checks as u64, Ordering::Relaxed); + + // Apply replay tracking only after successful authentication. + // + // This ordering prevents an attacker from producing invalid handshakes that + // still collide with a valid handshake's replay slot and thus evict a valid + // entry from the cache. We accept the cost of performing the full + // authentication check first to avoid poisoning the replay cache. + if replay_checker.check_and_add_handshake(dec_prekey_iv) { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + warn!(peer = %peer, user = %user, "MTProto replay attack detected"); + return HandshakeResult::BadClient { reader, writer }; + } + + let dec_key = Zeroizing::new(validation.dec_key); + let enc_key = Zeroizing::new(validation.enc_key); + let success = HandshakeSuccess { + user: user.clone(), + dc_idx: validation.dc_idx, + proto_tag: validation.proto_tag, + dec_key: *dec_key, + dec_iv: validation.dec_iv, + enc_key: *enc_key, + enc_iv: validation.enc_iv, + peer, + is_tls, + }; + + debug!( + peer = %peer, + user = %user, + dc = validation.dc_idx, + proto = ?validation.proto_tag, + tls = is_tls, + "MTProto handshake successful" + ); + + auth_probe_record_success_in(shared, peer.ip()); + + let max_pending = config.general.crypto_pending_buffer; + return HandshakeResult::Success(( + CryptoReader::new(reader, validation.decryptor), + CryptoWriter::new(writer, validation.encryptor, max_pending), + success, + )); + } + + shared + .handshake + .auth_expensive_checks_total + .fetch_add(validation_checks as u64, Ordering::Relaxed); } auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); diff --git a/src/proxy/relay.rs b/src/proxy/relay.rs index 60b432b..9fd5f3d 100644 --- a/src/proxy/relay.rs +++ b/src/proxy/relay.rs @@ -270,6 +270,7 @@ const QUOTA_NEAR_LIMIT_BYTES: u64 = 64 * 1024; const QUOTA_LARGE_CHARGE_BYTES: u64 = 16 * 1024; const QUOTA_ADAPTIVE_INTERVAL_MIN_BYTES: u64 = 4 * 1024; const QUOTA_ADAPTIVE_INTERVAL_MAX_BYTES: u64 = 64 * 1024; +const QUOTA_RESERVE_SPIN_RETRIES: usize = 64; #[inline] fn quota_adaptive_interval_bytes(remaining_before: u64) -> u64 { @@ -314,6 +315,50 @@ impl AsyncRead for StatsIo { if n > 0 { let n_to_charge = n as u64; + if let (Some(limit), Some(remaining)) = (this.quota_limit, remaining_before) { + let mut reserved_total = None; + let mut reserve_rounds = 0usize; + while reserved_total.is_none() { + for _ in 0..QUOTA_RESERVE_SPIN_RETRIES { + match this.user_stats.quota_try_reserve(n_to_charge, limit) { + Ok(total) => { + reserved_total = Some(total); + break; + } + Err(crate::stats::QuotaReserveError::LimitExceeded) => { + this.quota_exceeded.store(true, Ordering::Release); + buf.set_filled(before); + return Poll::Ready(Err(quota_io_error())); + } + Err(crate::stats::QuotaReserveError::Contended) => { + std::hint::spin_loop(); + } + } + } + reserve_rounds = reserve_rounds.saturating_add(1); + if reserved_total.is_none() && reserve_rounds >= 8 { + this.quota_exceeded.store(true, Ordering::Release); + buf.set_filled(before); + return Poll::Ready(Err(quota_io_error())); + } + } + + if should_immediate_quota_check(remaining, n_to_charge) { + this.quota_bytes_since_check = 0; + } else { + this.quota_bytes_since_check = + this.quota_bytes_since_check.saturating_add(n_to_charge); + let interval = quota_adaptive_interval_bytes(remaining); + if this.quota_bytes_since_check >= interval { + this.quota_bytes_since_check = 0; + } + } + + if reserved_total.unwrap_or(0) >= limit { + this.quota_exceeded.store(true, Ordering::Release); + } + } + // C→S: client sent data this.counters .c2s_bytes @@ -326,27 +371,6 @@ impl AsyncRead for StatsIo { this.stats .increment_user_msgs_from_handle(this.user_stats.as_ref()); - if let (Some(limit), Some(remaining)) = (this.quota_limit, remaining_before) { - this.stats - .quota_charge_post_write(this.user_stats.as_ref(), n_to_charge); - if should_immediate_quota_check(remaining, n_to_charge) { - this.quota_bytes_since_check = 0; - if this.user_stats.quota_used() >= limit { - this.quota_exceeded.store(true, Ordering::Release); - } - } else { - this.quota_bytes_since_check = - this.quota_bytes_since_check.saturating_add(n_to_charge); - let interval = quota_adaptive_interval_bytes(remaining); - if this.quota_bytes_since_check >= interval { - this.quota_bytes_since_check = 0; - if this.user_stats.quota_used() >= limit { - this.quota_exceeded.store(true, Ordering::Release); - } - } - } - } - trace!(user = %this.user, bytes = n, "C->S"); } Poll::Ready(Ok(())) @@ -368,18 +392,73 @@ impl AsyncWrite for StatsIo { } let mut remaining_before = None; + let mut reserved_bytes = 0u64; + let mut write_buf = buf; if let Some(limit) = this.quota_limit { - let used_before = this.user_stats.quota_used(); - let remaining = limit.saturating_sub(used_before); - if remaining == 0 { - this.quota_exceeded.store(true, Ordering::Release); - return Poll::Ready(Err(quota_io_error())); + if !buf.is_empty() { + let mut reserve_rounds = 0usize; + while reserved_bytes == 0 { + let used_before = this.user_stats.quota_used(); + let remaining = limit.saturating_sub(used_before); + if remaining == 0 { + this.quota_exceeded.store(true, Ordering::Release); + return Poll::Ready(Err(quota_io_error())); + } + remaining_before = Some(remaining); + + let desired = remaining.min(buf.len() as u64); + for _ in 0..QUOTA_RESERVE_SPIN_RETRIES { + match this.user_stats.quota_try_reserve(desired, limit) { + Ok(_) => { + reserved_bytes = desired; + write_buf = &buf[..desired as usize]; + break; + } + Err(crate::stats::QuotaReserveError::LimitExceeded) => { + break; + } + Err(crate::stats::QuotaReserveError::Contended) => { + std::hint::spin_loop(); + } + } + } + + reserve_rounds = reserve_rounds.saturating_add(1); + if reserved_bytes == 0 && reserve_rounds >= 8 { + this.quota_exceeded.store(true, Ordering::Release); + return Poll::Ready(Err(quota_io_error())); + } + } + } else { + let used_before = this.user_stats.quota_used(); + let remaining = limit.saturating_sub(used_before); + if remaining == 0 { + this.quota_exceeded.store(true, Ordering::Release); + return Poll::Ready(Err(quota_io_error())); + } + remaining_before = Some(remaining); } - remaining_before = Some(remaining); } - match Pin::new(&mut this.inner).poll_write(cx, buf) { + match Pin::new(&mut this.inner).poll_write(cx, write_buf) { Poll::Ready(Ok(n)) => { + if reserved_bytes > n as u64 { + let refund = reserved_bytes - n as u64; + let mut current = this.user_stats.quota_used.load(Ordering::Relaxed); + loop { + let next = current.saturating_sub(refund); + match this.user_stats.quota_used.compare_exchange_weak( + current, + next, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(observed) => current = observed, + } + } + } + if n > 0 { let n_to_charge = n as u64; @@ -396,8 +475,6 @@ impl AsyncWrite for StatsIo { .increment_user_msgs_to_handle(this.user_stats.as_ref()); if let (Some(limit), Some(remaining)) = (this.quota_limit, remaining_before) { - this.stats - .quota_charge_post_write(this.user_stats.as_ref(), n_to_charge); if should_immediate_quota_check(remaining, n_to_charge) { this.quota_bytes_since_check = 0; if this.user_stats.quota_used() >= limit { @@ -420,7 +497,42 @@ impl AsyncWrite for StatsIo { } Poll::Ready(Ok(n)) } - other => other, + Poll::Ready(Err(err)) => { + if reserved_bytes > 0 { + let mut current = this.user_stats.quota_used.load(Ordering::Relaxed); + loop { + let next = current.saturating_sub(reserved_bytes); + match this.user_stats.quota_used.compare_exchange_weak( + current, + next, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(observed) => current = observed, + } + } + } + Poll::Ready(Err(err)) + } + Poll::Pending => { + if reserved_bytes > 0 { + let mut current = this.user_stats.quota_used.load(Ordering::Relaxed); + loop { + let next = current.saturating_sub(reserved_bytes); + match this.user_stats.quota_used.compare_exchange_weak( + current, + next, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(observed) => current = observed, + } + } + } + Poll::Pending + } } } diff --git a/src/proxy/shared_state.rs b/src/proxy/shared_state.rs index dd49806..4fef497 100644 --- a/src/proxy/shared_state.rs +++ b/src/proxy/shared_state.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use std::collections::hash_map::RandomState; use std::net::{IpAddr, SocketAddr}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Instant; @@ -11,6 +11,8 @@ use tokio::sync::mpsc; use crate::proxy::handshake::{AuthProbeSaturationState, AuthProbeState}; use crate::proxy::middle_relay::{DesyncDedupRotationState, RelayIdleCandidateRegistry}; +const HANDSHAKE_RECENT_USER_RING_LEN: usize = 64; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ConntrackCloseReason { NormalEof, @@ -41,6 +43,13 @@ pub(crate) struct HandshakeSharedState { pub(crate) auth_probe_eviction_hasher: RandomState, pub(crate) invalid_secret_warned: Mutex>, pub(crate) unknown_sni_warn_next_allowed: Mutex>, + pub(crate) sticky_user_by_ip: DashMap, + pub(crate) sticky_user_by_ip_prefix: DashMap, + pub(crate) sticky_user_by_sni_hash: DashMap, + pub(crate) recent_user_ring: Box<[AtomicU32]>, + pub(crate) recent_user_ring_seq: AtomicU64, + pub(crate) auth_expensive_checks_total: AtomicU64, + pub(crate) auth_budget_exhausted_total: AtomicU64, } pub(crate) struct MiddleRelaySharedState { @@ -69,6 +78,16 @@ impl ProxySharedState { auth_probe_eviction_hasher: RandomState::new(), invalid_secret_warned: Mutex::new(HashSet::new()), unknown_sni_warn_next_allowed: Mutex::new(None), + sticky_user_by_ip: DashMap::new(), + sticky_user_by_ip_prefix: DashMap::new(), + sticky_user_by_sni_hash: DashMap::new(), + recent_user_ring: std::iter::repeat_with(|| AtomicU32::new(0)) + .take(HANDSHAKE_RECENT_USER_RING_LEN) + .collect::>() + .into_boxed_slice(), + recent_user_ring_seq: AtomicU64::new(0), + auth_expensive_checks_total: AtomicU64::new(0), + auth_budget_exhausted_total: AtomicU64::new(0), }, middle_relay: MiddleRelaySharedState { desync_dedup: DashMap::new(), diff --git a/src/proxy/tests/handshake_security_tests.rs b/src/proxy/tests/handshake_security_tests.rs index d8396b5..0f8fe03 100644 --- a/src/proxy/tests/handshake_security_tests.rs +++ b/src/proxy/tests/handshake_security_tests.rs @@ -5,6 +5,7 @@ use rand::rngs::StdRng; use rand::{RngExt, SeedableRng}; use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; +use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; use tokio::sync::Barrier; @@ -1006,6 +1007,64 @@ async fn tls_unknown_sni_mask_policy_falls_back_to_bad_client() { assert!(matches!(result, HandshakeResult::BadClient { .. })); } +#[tokio::test] +async fn tls_unknown_sni_accept_policy_continues_auth_path() { + let secret = [0x4Bu8; 16]; + let mut config = test_config_with_secret_hex("4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b"); + config.censorship.unknown_sni_action = UnknownSniAction::Accept; + + let replay_checker = ReplayChecker::new(128, Duration::from_secs(60)); + let rng = SecureRandom::new(); + let peer: SocketAddr = "198.51.100.210:44326".parse().unwrap(); + let handshake = + make_valid_tls_client_hello_with_sni_and_alpn(&secret, 0, "unknown.example", &[b"h2"]); + + let result = handle_tls_handshake( + &handshake, + tokio::io::empty(), + tokio::io::sink(), + peer, + &config, + &replay_checker, + &rng, + None, + ) + .await; + + assert!(matches!(result, HandshakeResult::Success(_))); +} + +#[tokio::test] +async fn tls_unknown_sni_accept_policy_still_requires_valid_secret() { + let mut config = test_config_with_secret_hex("4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c"); + config.censorship.unknown_sni_action = UnknownSniAction::Accept; + + let replay_checker = ReplayChecker::new(128, Duration::from_secs(60)); + let rng = SecureRandom::new(); + let peer: SocketAddr = "198.51.100.211:44326".parse().unwrap(); + let attacker_secret = [0x4Du8; 16]; + let handshake = make_valid_tls_client_hello_with_sni_and_alpn( + &attacker_secret, + 0, + "unknown.example", + &[b"h2"], + ); + + let result = handle_tls_handshake( + &handshake, + tokio::io::empty(), + tokio::io::sink(), + peer, + &config, + &replay_checker, + &rng, + None, + ) + .await; + + assert!(matches!(result, HandshakeResult::BadClient { .. })); +} + #[tokio::test] async fn tls_missing_sni_keeps_legacy_auth_path() { let secret = [0x4Au8; 16]; @@ -1032,6 +1091,170 @@ async fn tls_missing_sni_keeps_legacy_auth_path() { assert!(matches!(result, HandshakeResult::Success(_))); } +#[tokio::test] +async fn tls_runtime_snapshot_updates_sticky_and_recent_hints() { + let secret = [0x5Au8; 16]; + let mut config = test_config_with_secret_hex("5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a"); + config.rebuild_runtime_user_auth().unwrap(); + + let replay_checker = ReplayChecker::new(128, Duration::from_secs(60)); + let rng = SecureRandom::new(); + let shared = ProxySharedState::new(); + let peer: SocketAddr = "198.51.100.212:44326".parse().unwrap(); + let handshake = make_valid_tls_client_hello_with_sni_and_alpn(&secret, 0, "user", &[b"h2"]); + + let result = handle_tls_handshake_with_shared( + &handshake, + tokio::io::empty(), + tokio::io::sink(), + peer, + &config, + &replay_checker, + &rng, + None, + shared.as_ref(), + ) + .await; + + assert!(matches!(result, HandshakeResult::Success(_))); + assert_eq!( + shared + .handshake + .sticky_user_by_ip + .get(&peer.ip()) + .map(|entry| *entry), + Some(0), + "successful runtime-snapshot auth must seed sticky ip cache" + ); + assert_eq!( + shared.handshake.sticky_user_by_ip_prefix.len(), + 1, + "successful runtime-snapshot auth must seed sticky prefix cache" + ); + assert!( + shared + .handshake + .auth_expensive_checks_total + .load(Ordering::Relaxed) + >= 1, + "runtime-snapshot path must account expensive candidate checks" + ); +} + +#[tokio::test] +async fn tls_overload_budget_limits_candidate_scan_depth() { + let mut config = ProxyConfig::default(); + config.access.users.clear(); + config.access.ignore_time_skew = true; + for idx in 0..32u8 { + config.access.users.insert( + format!("user-{idx}"), + format!("{:032x}", u128::from(idx) + 1), + ); + } + config.rebuild_runtime_user_auth().unwrap(); + + let replay_checker = ReplayChecker::new(128, Duration::from_secs(60)); + let rng = SecureRandom::new(); + let shared = ProxySharedState::new(); + let now = Instant::now(); + { + let mut saturation = shared.handshake.auth_probe_saturation.lock().unwrap(); + *saturation = Some(AuthProbeSaturationState { + fail_streak: AUTH_PROBE_BACKOFF_START_FAILS, + blocked_until: now + Duration::from_millis(200), + last_seen: now, + }); + } + + let peer: SocketAddr = "198.51.100.213:44326".parse().unwrap(); + let attacker_secret = [0xEFu8; 16]; + let handshake = make_valid_tls_handshake(&attacker_secret, 0); + + let result = handle_tls_handshake_with_shared( + &handshake, + tokio::io::empty(), + tokio::io::sink(), + peer, + &config, + &replay_checker, + &rng, + None, + shared.as_ref(), + ) + .await; + + assert!(matches!(result, HandshakeResult::BadClient { .. })); + assert_eq!( + shared + .handshake + .auth_budget_exhausted_total + .load(Ordering::Relaxed), + 1, + "overload mode must account budget exhaustion when scan is capped" + ); + assert_eq!( + shared + .handshake + .auth_expensive_checks_total + .load(Ordering::Relaxed), + OVERLOAD_CANDIDATE_BUDGET_UNHINTED as u64, + "overload scan depth must stay within capped candidate budget" + ); +} + +#[tokio::test] +async fn mtproto_runtime_snapshot_prefers_preferred_user_hint() { + let mut config = ProxyConfig::default(); + config.general.modes.secure = true; + config.access.users.clear(); + config.access.ignore_time_skew = true; + config.access.users.insert( + "alpha".to_string(), + "11111111111111111111111111111111".to_string(), + ); + config.access.users.insert( + "beta".to_string(), + "22222222222222222222222222222222".to_string(), + ); + config.rebuild_runtime_user_auth().unwrap(); + + let handshake = + make_valid_mtproto_handshake("22222222222222222222222222222222", ProtoTag::Secure, 2); + let replay_checker = ReplayChecker::new(128, Duration::from_secs(60)); + let peer: SocketAddr = "198.51.100.214:44326".parse().unwrap(); + let shared = ProxySharedState::new(); + + let result = handle_mtproto_handshake_with_shared( + &handshake, + tokio::io::empty(), + tokio::io::sink(), + peer, + &config, + &replay_checker, + false, + Some("beta"), + shared.as_ref(), + ) + .await; + + match result { + HandshakeResult::Success((_, _, success)) => { + assert_eq!(success.user, "beta"); + } + _ => panic!("mtproto runtime snapshot auth must succeed for preferred user"), + } + + assert_eq!( + shared + .handshake + .auth_expensive_checks_total + .load(Ordering::Relaxed), + 1, + "preferred user hint must produce single-candidate success in snapshot path" + ); +} + #[tokio::test] async fn alpn_enforce_rejects_unsupported_client_alpn() { let secret = [0x33u8; 16];