Compare commits
71 Commits
experiment
...
master
Author | SHA1 | Date | |
---|---|---|---|
2b35112566 | |||
a80820b9a8 | |||
c0bc0e3dfe | |||
2d593e4107 | |||
c1bd6f09a9 | |||
0ff27e6de9 | |||
59906bbd2f | |||
7871dc2d3d | |||
a0dce8e604 | |||
04ffde421d | |||
9545ca28cf | |||
4098358233 | |||
f2d8330ccc | |||
1032eebbbe | |||
93ab8ddf91 | |||
6ce1b576ee | |||
99e98827f7 | |||
26564b6081 | |||
9ddb1209c9 | |||
b68232653a | |||
2e4f551e8e | |||
40b8723132 | |||
4d178d951f | |||
f6fbf8e58e | |||
3c915a8f58 | |||
515278c264 | |||
7100fa5f76 | |||
77e293bef2 | |||
d6f257de14 | |||
8a21a836e5 | |||
439e229294 | |||
ffcdcf7350 | |||
26d1f3522f | |||
326b92142d | |||
18c878dfb5 | |||
1f7e7f88fa | |||
e0b6dea168 | |||
26db60a211 | |||
7c0edfb898 | |||
5df6143c75 | |||
38fbca955c | |||
825c0bd5f7 | |||
0d3a8ae95c | |||
4f6bc3c77e | |||
38d800c7ac | |||
ae624ee051 | |||
3dd1654f70 | |||
9660379891 | |||
51873aa9b1 | |||
93dc66acc5 | |||
c0ca802a30 | |||
745eb9ccdf | |||
192f7e135f | |||
3a56e67c24 | |||
ae3af68dab | |||
67c0009b5b | |||
7042e41d70 | |||
e619b45485 | |||
e7abbe6d1d | |||
c6ac6f5907 | |||
f02b390ed4 | |||
f92431a281 | |||
96edbb2040 | |||
bcf0140edb | |||
4e2748f2ae | |||
814263c11d | |||
0c05cce13b | |||
75e91f8092 | |||
1814572a04 | |||
fe29bec1b0 | |||
8fc24567f0 |
11
.gitignore
vendored
11
.gitignore
vendored
@ -5,6 +5,7 @@ build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
.idea/modules.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/compiler.xml
|
||||
@ -23,8 +24,7 @@ out/
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
bin/
|
||||
.sts4-caches
|
||||
!**/src/main/**/bin/
|
||||
!**/src/test/**/bin/
|
||||
|
||||
@ -39,4 +39,9 @@ bin/
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
# More
|
||||
.kotlin
|
||||
/.idea/workspace.xml
|
||||
/.gigaide/gigaide.properties
|
||||
|
8
.idea/.gitignore
generated
vendored
8
.idea/.gitignore
generated
vendored
@ -1,8 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
6
.idea/GitLink.xml
generated
Normal file
6
.idea/GitLink.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="uk.co.ben_gibson.git.link.SettingsState">
|
||||
<option name="host" value="e0f86390-1091-4871-8aeb-f534fbc99cf0" />
|
||||
</component>
|
||||
</project>
|
6
.idea/artifacts/kiloparsec_js_0_1_2_SNAPSHOT.xml
generated
Normal file
6
.idea/artifacts/kiloparsec_js_0_1_2_SNAPSHOT.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.1.2-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.1.2-SNAPSHOT.jar" />
|
||||
</artifact>
|
||||
</component>
|
@ -1,7 +1,7 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.1.0-SNAPSHOT">
|
||||
<artifact type="jar" name="kiloparsec-js-0.2.1-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.1.0-SNAPSHOT.jar">
|
||||
<root id="archive" name="kiloparsec-js-0.2.1-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
8
.idea/artifacts/kiloparsec_js_0_2_2_SNAPSHOT.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_js_0_2_2_SNAPSHOT.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.2.2-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.2.2-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_js_0_2_3.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_js_0_2_3.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.2.3">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.2.3.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_js_0_2_4.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_js_0_2_4.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.2.4">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.2.4.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_js_0_2_5.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_js_0_2_5.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.2.5">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.2.5.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_js_0_2_5_SNAPSHOT.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_js_0_2_5_SNAPSHOT.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.2.5-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.2.5-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
6
.idea/artifacts/kiloparsec_js_0_2_6.xml
generated
Normal file
6
.idea/artifacts/kiloparsec_js_0_2_6.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.2.6">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.2.6.jar" />
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_js_0_3_1.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_js_0_3_1.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.3.1">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.3.1.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_js_0_3_1_SNAPSHOT.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_js_0_3_1_SNAPSHOT.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.3.1-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.3.1-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_js_0_3_2.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_js_0_3_2.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.3.2">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.3.2.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_js_0_3_3.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_js_0_3_3.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.3.3">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.3.3.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
6
.idea/artifacts/kiloparsec_jvm_0_1_2_SNAPSHOT.xml
generated
Normal file
6
.idea/artifacts/kiloparsec_jvm_0_1_2_SNAPSHOT.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.1.2-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.1.2-SNAPSHOT.jar" />
|
||||
</artifact>
|
||||
</component>
|
@ -1,7 +1,7 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.1.0-SNAPSHOT">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.2.1-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.1.0-SNAPSHOT.jar">
|
||||
<root id="archive" name="kiloparsec-jvm-0.2.1-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
8
.idea/artifacts/kiloparsec_jvm_0_2_2_SNAPSHOT.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_jvm_0_2_2_SNAPSHOT.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.2.2-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.2.2-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_jvm_0_2_3.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_jvm_0_2_3.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.2.3">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.2.3.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_jvm_0_2_4.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_jvm_0_2_4.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.2.4">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.2.4.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_jvm_0_2_5.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_jvm_0_2_5.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.2.5">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.2.5.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_jvm_0_2_5_SNAPSHOT.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_jvm_0_2_5_SNAPSHOT.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.2.5-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.2.5-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
6
.idea/artifacts/kiloparsec_jvm_0_2_6.xml
generated
Normal file
6
.idea/artifacts/kiloparsec_jvm_0_2_6.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.2.6">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.2.6.jar" />
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_jvm_0_3_1.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_jvm_0_3_1.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.3.1">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.3.1.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_jvm_0_3_1_SNAPSHOT.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_jvm_0_3_1_SNAPSHOT.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.3.1-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.3.1-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_jvm_0_3_2.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_jvm_0_3_2.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.3.2">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.3.2.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
8
.idea/artifacts/kiloparsec_jvm_0_3_3.xml
generated
Normal file
8
.idea/artifacts/kiloparsec_jvm_0_3_3.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.3.3">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.3.3.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
|
6
.idea/inspectionProfiles/Project_Default.xml
generated
6
.idea/inspectionProfiles/Project_Default.xml
generated
@ -1,6 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ReplaceUntilWithRangeUntil" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
6
.idea/kotlinc.xml
generated
6
.idea/kotlinc.xml
generated
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.20" />
|
||||
</component>
|
||||
</project>
|
6
.idea/markdown.xml
generated
Normal file
6
.idea/markdown.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MarkdownSettings">
|
||||
<option name="showProblemsInCodeBlocks" value="false" />
|
||||
</component>
|
||||
</project>
|
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
@ -1,10 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="FrameworkDetectionExcludesConfiguration">
|
||||
<file type="web" url="file://$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="17 (5)" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="corretto-17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
6
.idea/scala_compiler.xml
generated
Normal file
6
.idea/scala_compiler.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ScalaCompilerConfiguration">
|
||||
<option name="separateProdTestSources" value="false" />
|
||||
</component>
|
||||
</project>
|
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
277
README.md
Normal file
277
README.md
Normal file
@ -0,0 +1,277 @@
|
||||
# Kiloparsec
|
||||
|
||||
__Recommended version is `0.4.1`: to keep the code compatible with current and further versions we
|
||||
ask to upgrade to `0.4.2` at least.__ Starting from this version some package names are changed for
|
||||
better clarity and fast UDP endpoints are added.
|
||||
|
||||
The new generation of __PARanoid SECurity__ protocol, advanced, faster, more secure. It also allows connecting any "
|
||||
block device" transport to the same local interface. Out if the box it
|
||||
provides the following transports:
|
||||
|
||||
| name | JVM | JS | native |
|
||||
|-------------------|--------|----|--------|
|
||||
| TCP/IP server | ✓ | | 0.2.6+ |
|
||||
| TCP/IP client | ✓ | | 0.2.6+ |
|
||||
| UDP server | 0.3.2+ | | 0.3.2+ |
|
||||
| UDP client | 0.3.2+ | | 0.3.2+ |
|
||||
| Websockets server | ✓ | | |
|
||||
| Websockets client | ✓ | ✓ | ✓ |
|
||||
|
||||
### Note on version compatibility
|
||||
|
||||
Version 0.5.1 could be backward incompatible due to upgrade of the crypto2.
|
||||
|
||||
Protocols >= 0.3.0 are not binary compatible with previous version due to more compact binary
|
||||
format. The format from 0.3.0 onwards is supposed to keep compatible.
|
||||
|
||||
#### ID calculation algorithm is changed since 0.4.1
|
||||
|
||||
We recommend to upgrade to 0.4+ ASAP as public/shared key id derivation method was changed for even higher security.
|
||||
|
||||
### Supported native targets
|
||||
|
||||
- iosArm64, iosX64
|
||||
- macosArm64, macosArm64
|
||||
- linuxArm64, linuxX64
|
||||
|
||||
### Non-native targets
|
||||
|
||||
- JS (browser and Node.js)
|
||||
- JVM (android, macOS, windows, linux, everywhere where JRE is installed)
|
||||
|
||||
## TCP/IP and UDP transports
|
||||
|
||||
These are the fastest based on async socket implementation of ktor client. They works everywhere but JS target as
|
||||
there is currently no widely adopted sockets for browser javascript.
|
||||
|
||||
While UDP is faster than TCP/IP, it is less reliable, especially with commands and return values that serializes to more than 240 bytes approx, and has no retransmission facilities (use TCP!). UDP though shines when all you need is to [push](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-remote-interface/push.html) with little or no data in it.
|
||||
|
||||
## Websockets server
|
||||
|
||||
While it is much slower than TCP or UDP, it is still faster than any http-based API; it uses binary frames based on
|
||||
the Ktor server framework to easily integrate with web services. We recommend using it instead of a classic HTTP API as
|
||||
it beats it in terms of speed and server load even with HTTP/2.
|
||||
|
||||
We recommend to create the `KiloInterface<S>` instance and connect it to the websockets and tcp servers in real
|
||||
applications to get easy access from anywhere.
|
||||
|
||||
## Websocket client
|
||||
|
||||
It is slower than TCP or UDP, but it works on literally all platforms. See the sample below.
|
||||
|
||||
# Usage
|
||||
|
||||
The library should be used as maven dependency, not as source.
|
||||
|
||||
## Adding dependency
|
||||
|
||||
### Declare maven repository:
|
||||
|
||||
Add the private repository to your `build.gradle.kts`, like:
|
||||
|
||||
```kotlin
|
||||
repositories {
|
||||
maven("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
|
||||
}
|
||||
```
|
||||
|
||||
### Add dependency block
|
||||
|
||||
It could be, depending on your project structure, something like:
|
||||
|
||||
```kotlin
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
api("net.sergeych:kiloparsec:0.4.1")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Create shared interface for your server and the client
|
||||
|
||||
It could be a multiplatform library that exports it or just a shared or copied source file declaring structures
|
||||
and functions available, like:
|
||||
|
||||
```kotlin
|
||||
// Api.kt
|
||||
|
||||
@Serializable
|
||||
class FooArgs(val text: String, val number: Int = 42)
|
||||
|
||||
// Server-side interface
|
||||
val cmdSetFoo by command<FooArgs, Unit>()
|
||||
val cmdGetFoo by command<Unit, FooArgs>()
|
||||
val cmdPing by command<String, String>()
|
||||
val cmdCheckConnected by command<Unit, Boolean>()
|
||||
|
||||
// client-side interface (called from the server)
|
||||
val cmdPushClient by command<String, Unit>()
|
||||
```
|
||||
|
||||
## Call it from the client:
|
||||
|
||||
Remember, we need to implement client interface `cmdPushClient` in our example, so we need to provide
|
||||
local interface too:
|
||||
|
||||
```kotlin
|
||||
// Unit: no session on the client:
|
||||
val client = websocketClient<Unit>("wss://your.host.com/kp") {
|
||||
// This is server-callable function we export:
|
||||
on(cmdPushClient) {
|
||||
"server push: $it"
|
||||
}
|
||||
}
|
||||
|
||||
// If we want to collect connected state changes (this is optional)
|
||||
launch {
|
||||
client.connectedStateFlow.collect {
|
||||
if (it)
|
||||
println("I am connected")
|
||||
else
|
||||
println("trying to connect...")
|
||||
}
|
||||
}
|
||||
|
||||
// now we can call server's functions
|
||||
client.call(cmdSetFoo, FooArgs("bar", 117))
|
||||
assertEquals(FooArgs("bar", 117), client.call(cmdGetFoo))
|
||||
|
||||
```
|
||||
|
||||
## Create ktor-based server
|
||||
|
||||
Normally server side needs some session. It is convenient and avoid sending repeating data on each request speeding up
|
||||
the protocol. With KILOPARSEC, it is rather basic operation:
|
||||
|
||||
~~~kotlin
|
||||
// Our session just keeps Foo for cmd{Get|Set}Foo:
|
||||
data class Session(var fooState: FooArgs? = null)
|
||||
|
||||
// Let's now provide interface we export, it will be used on each connection automatically:
|
||||
|
||||
// Note server interface uses Session:
|
||||
val serverInterface = KiloInterface<Session>().apply {
|
||||
onConnected {
|
||||
// Do some initialization
|
||||
session.fooState = null
|
||||
}
|
||||
// Exceptions are passed through the network and re-created (re-thrown) on other side:
|
||||
on(cmdGetFoo) { session.fooState ?: throw IllegalStateException("foo is not yet set") }
|
||||
on(cmdSetFoo) { session.fooState = it }
|
||||
}
|
||||
|
||||
// now create server using ktor (see ktor project for more):
|
||||
|
||||
val ns: NettyApplicationEngine = embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = {
|
||||
setupWebsocketServer(serverInterface) { Session() }
|
||||
}).start(wait = false)
|
||||
|
||||
~~~
|
||||
|
||||
## Create TCP/IP client and server
|
||||
|
||||
Using plain TCP/IP is even simpler, and it works way faster than websocket one, and is _the same
|
||||
protected as `wss://` (and `ws://`) variant above due to same kiloparsec encryption in both cases. Still, a TCP/IP
|
||||
client is not available in Javascript browser targets and custom TCP ports could often be blocked by firewalls.
|
||||
|
||||
Documentation is available in samples here:
|
||||
|
||||
- [TCP/IP server creation](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-server/index.html)
|
||||
|
||||
- [TCP/IP client](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-client/index.html)
|
||||
|
||||
In short, there are two functions that implements asynchronous TCP/IP transport on all platforms buy JS:
|
||||
|
||||
- [acceptTcpDevice](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec.adapter/accept-tcp-device.html?query=fun%20acceptTcpDevice(port:%20Int):%20Flow%3CInetTransportDevice%3E) to create a server
|
||||
|
||||
- [connectTcpDevice](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec.adapter/connect-tcp-device.html) to connect to the server
|
||||
|
||||
## UDP client and server
|
||||
|
||||
Is very much straightforward, same as with TCP/IP:
|
||||
|
||||
- [UDP server creation](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec.adapter/accept-udp-device.html)
|
||||
- [Connect UDP client](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec.adapter/connect-udp-device.html)
|
||||
|
||||
### UDP specifics
|
||||
|
||||
#### Command size
|
||||
|
||||
Each command invocation and result are packed in a separate UDP diagram using effective binary packing.
|
||||
Thus, for the best results commands and results should be relatively short, best to fit into 240 bytes. While bigger datagrams are often transmitted successfully, the probability of the effective transmission drops with the size.
|
||||
|
||||
Kiloparsec UDP transport does not retransmit not delivered packets. Use TCP/IP or websocket if it is a concern.
|
||||
|
||||
For the best results we recommend using [push](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-remote-interface/index.html#1558240250%2FFunctions%2F788909594) for remote interfaces with UDP.
|
||||
|
||||
#### Timeouts
|
||||
|
||||
As Datagrams do not form protocol itself, kiloparsec issues pings when no data is circulated between parties.
|
||||
When no pings are received long enough, kiloparsec connection is closed. There are `maxInactivityTimeout` in all
|
||||
relevant functions and constructors.
|
||||
|
||||
Client should not issue pings manually.
|
||||
|
||||
## Reusing code between servers
|
||||
|
||||
The same instance of the [KiloInterface](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-interface/index.html?query=open%20class%20KiloInterface%3CS%3E%20:%20LocalInterface%3CKiloScope%3CS%3E%3E) could easily be reused with all instances of servers with different protocols.
|
||||
|
||||
This is a common proactive to create a business logic in a `KiloInterface`, then create a TCP/IP and Websocket servers passing the same instance of the logic to both.
|
||||
|
||||
## Note on the server identification
|
||||
|
||||
We do not recommend to rely on TLS (HTTPS://, WSS://) host identification solely, in the modern world there is
|
||||
a high probability of attacks on unfriendly (in respect to at least some of your users) states to the SSL certificates
|
||||
chain, in which case the [MITM attack] and spoofing will be undetected. Check the [remoteId](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-client/remote-id.html?query=suspend%20fun%20remoteId():%20VerifyingPublicKey?) in your client on each connection and provide the safe [serverSecretKey](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-server/index.html) when creating a server.
|
||||
|
||||
This will effectively protect against certificate chain spoofing in the case of the application installed from the trusted source.
|
||||
|
||||
__Important note__. The web application could not be completely secured this way unless is loaded from the IP-address, as the DNS could be spoofed the same, especially when used with `Cloudflare` or other CDN that can transparently substitute the whole site. For applications, we strongly recommend not to use CDN except your own, controlled ones. You generally can't neither detect nor repel [MITM attack] performed from _any single cloudflare 'ray'_.
|
||||
|
||||
## See also:
|
||||
|
||||
- [Source documentation](https://code.sergeych.net/docs/kiloparsec/)
|
||||
- [Project's WIKI](https://gitea.sergeych.net/SergeychWorks/kiloparsec/wiki)
|
||||
|
||||
# Details
|
||||
|
||||
It is not compatible with parsec family and no more based on an Universa crypto library. To better fit
|
||||
the modern state of threats and rate of cyber crimes, KiloParsec uses more encryption and random key exchange on each
|
||||
and every connection (while parsec caches session keys to avoid time-consuming keys exchange). For the same reason,
|
||||
keys cryptography for session is shifted to use ed25519 curves which are supposed to provide agreeable strength with
|
||||
enough speed to protect every connection with a unique new keys. Also, we completely get rid of SHA2.
|
||||
|
||||
Kiloparsec also uses a denser binary format [bipack](https://gitea.sergeych.net/SergeychWorks/mp_bintools), no more
|
||||
key-values,
|
||||
which reveals much less on the inner data structure, providing advanced
|
||||
typed RPC interfaces with kotlinx.serialization. There is also Rust
|
||||
implementation [bipack_ru](https://gitea.sergeych.net/DiWAN/bipack_ru).
|
||||
The architecture allows connecting same functional interfaces to several various type channels at once.
|
||||
|
||||
Also, the difference from parsecs is that there are no more unencrypted layer commands available to users.
|
||||
All RPC is performed over the encrypted connection.
|
||||
|
||||
# Technical description
|
||||
|
||||
Kiloparsec is a full-duplex fully async (coroutine based) Remote Procedure Call protocol with typed parameters
|
||||
and support for serializing exceptions (e.g. exception thrown while executing remote command will be caught and
|
||||
rethrown at the caller context).
|
||||
|
||||
Kiloparsec is not REST, it _has advanced session mechanisms_ and built-in authentication based on the same curve keys.
|
||||
Integrated tools to prevent MITM attacks include also non-transferred independently generated token that is calculated
|
||||
independently on the ends and is never transferred with the network. Comparing it somehow (visually, with QR code, etc.)
|
||||
could add a very robust guarantee of the connection safety and ingenuity.
|
||||
|
||||
Kiloparsec has built-in completely asynchronous (coroutine based top-down) transport layer based on TCP (JVM only as for
|
||||
now) and the same async Websocket-based transport based on KTOR. Websocket client is multiplatform, though the server is
|
||||
JVM only insofar.
|
||||
|
||||
# Licensing
|
||||
|
||||
This is work in progress, not yet moved to public domain;
|
||||
you need to obtain a license from https://8-rays.dev or [Sergey Chernov]. For open source projects it will most be free on some special terms.
|
||||
|
||||
It will be moved to open source; we also guarantee that it will be moved to open source immediately if the software export restrictions will be lifted. We do not support such practices here at 8-rays.dev.
|
||||
|
||||
[MITM]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack
|
||||
[Sergey Chernov]: https://t.me/real_sergeych
|
14
bin/pubdocs
Executable file
14
bin/pubdocs
Executable file
@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
#
|
||||
# You may use, distribute and modify this code under the
|
||||
# terms of the private license, which you must obtain from the author
|
||||
#
|
||||
# To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
# real dot sergeych at gmail.
|
||||
#
|
||||
|
||||
set -e
|
||||
./gradlew dokkaHtml
|
||||
rsync -avz ./build/dokka/* code.sergeych.net:/bigstore/sergeych_pub/code/docs/kiloparsec
|
169
build.gradle.kts
169
build.gradle.kts
@ -1,51 +1,52 @@
|
||||
import org.jetbrains.kotlin.gradle.plugin.KotlinJsCompilerType
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform") version "1.9.20"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.20"
|
||||
kotlin("multiplatform") version "2.1.0"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0"
|
||||
`maven-publish`
|
||||
id("org.jetbrains.dokka") version "1.9.20"
|
||||
}
|
||||
|
||||
group = "net.sergeych"
|
||||
version = "0.1.0-SNAPSHOT"
|
||||
version = "0.6.1-SNAPSHOT"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
mavenLocal()
|
||||
maven("https://maven.universablockchain.com/")
|
||||
maven("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
|
||||
maven("https://gitea.sergeych.net/api/packages/YoungBlood/maven")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm {
|
||||
jvmToolchain(8)
|
||||
withJava()
|
||||
testRuns.named("test") {
|
||||
executionTask.configure {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
}
|
||||
}
|
||||
js(KotlinJsCompilerType.IR) {
|
||||
jvmToolchain(17)
|
||||
jvm()
|
||||
js {
|
||||
browser {
|
||||
// commonWebpackConfig {
|
||||
// cssSupport {
|
||||
// enabled.set(true)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
nodejs()
|
||||
}
|
||||
val hostOs = System.getProperty("os.name")
|
||||
val isArm64 = System.getProperty("os.arch") == "aarch64"
|
||||
val isMingwX64 = hostOs.startsWith("Windows")
|
||||
val nativeTarget = when {
|
||||
hostOs == "Mac OS X" && isArm64 -> macosArm64("native")
|
||||
hostOs == "Mac OS X" && !isArm64 -> macosX64("native")
|
||||
hostOs == "Linux" && isArm64 -> linuxArm64("native")
|
||||
hostOs == "Linux" && !isArm64 -> linuxX64("native")
|
||||
isMingwX64 -> mingwX64("native")
|
||||
else -> throw GradleException("Host OS is not supported in Kotlin/Native.")
|
||||
}
|
||||
// macosArm64()
|
||||
// iosX64()
|
||||
// iosArm64()
|
||||
// iosSimulatorArm64()
|
||||
linuxX64()
|
||||
linuxArm64()
|
||||
// macosX64()
|
||||
// macosX64()
|
||||
mingwX64()
|
||||
// @OptIn(ExperimentalWasmDsl::class)
|
||||
// wasmJs()
|
||||
|
||||
val ktor_version = "3.1.0"
|
||||
|
||||
sourceSets {
|
||||
all {
|
||||
@ -56,28 +57,108 @@ kotlin {
|
||||
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
|
||||
|
||||
implementation("com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings:0.9.0")
|
||||
api("com.ionspin.kotlin:bignum:0.3.8")
|
||||
|
||||
api("net.sergeych:mp_bintools:0.0.6-SNAPSHOT")
|
||||
api("net.sergeych:mp_stools:1.4.1")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.2")
|
||||
api("io.ktor:ktor-client-core:$ktor_version")
|
||||
api("net.sergeych:crypto2:0.7.2-SNAPSHOT")
|
||||
}
|
||||
}
|
||||
val ktorSocketMain by creating {
|
||||
dependsOn(commonMain)
|
||||
dependencies {
|
||||
implementation("io.ktor:ktor-network:$ktor_version")
|
||||
}
|
||||
}
|
||||
val commonTest by getting {
|
||||
dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation("org.slf4j:slf4j-simple:2.0.9")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
|
||||
}
|
||||
}
|
||||
val ktorSocketTest by creating {
|
||||
dependsOn(commonTest)
|
||||
}
|
||||
val jvmMain by getting {
|
||||
dependencies {
|
||||
implementation("io.ktor:ktor-server-core:$ktor_version")
|
||||
implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
|
||||
implementation("io.ktor:ktor-server-websockets-jvm:$ktor_version")
|
||||
implementation("io.ktor:ktor-server-netty:$ktor_version")
|
||||
api("io.ktor:ktor-client-cio:$ktor_version")
|
||||
}
|
||||
dependsOn(ktorSocketMain)
|
||||
}
|
||||
val jvmTest by getting {
|
||||
dependsOn(ktorSocketTest)
|
||||
}
|
||||
|
||||
val jsMain by getting {
|
||||
dependencies {
|
||||
implementation("io.ktor:ktor-client-js:$ktor_version")
|
||||
}
|
||||
}
|
||||
val jvmMain by getting
|
||||
val jvmTest by getting
|
||||
val jsMain by getting
|
||||
val jsTest by getting
|
||||
val nativeMain by getting
|
||||
val nativeTest by getting
|
||||
// val macosArm64Main by getting {
|
||||
// dependsOn(ktorSocketMain)
|
||||
// }
|
||||
// val macosArm64Test by getting {
|
||||
// dependsOn(ktorSocketTest)
|
||||
// }
|
||||
// val macosX64Main by getting {
|
||||
// dependsOn(ktorSocketMain)
|
||||
// }
|
||||
// val iosX64Main by getting {
|
||||
// dependsOn(ktorSocketMain)
|
||||
// }
|
||||
// val iosX64Test by getting {
|
||||
// dependsOn(ktorSocketTest)
|
||||
// }
|
||||
// val iosArm64Main by getting {
|
||||
// dependsOn(ktorSocketMain)
|
||||
// }
|
||||
// val iosArm64Test by getting {
|
||||
// dependsOn(ktorSocketTest)
|
||||
// }
|
||||
val linuxArm64Main by getting {
|
||||
dependsOn(ktorSocketMain)
|
||||
}
|
||||
val linuxArm64Test by getting {
|
||||
dependsOn(ktorSocketTest)
|
||||
}
|
||||
val linuxX64Main by getting {
|
||||
dependsOn(ktorSocketMain)
|
||||
}
|
||||
val linuxX64Test by getting {
|
||||
dependsOn(ktorSocketTest)
|
||||
}
|
||||
}
|
||||
|
||||
publishing {
|
||||
val mavenToken by lazy {
|
||||
File("${System.getProperty("user.home")}/.gitea_token").readText()
|
||||
}
|
||||
repositories {
|
||||
maven {
|
||||
credentials(HttpHeaderCredentials::class) {
|
||||
name = "Authorization"
|
||||
value = mavenToken
|
||||
}
|
||||
url = uri("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
|
||||
authentication {
|
||||
create("Authorization", HttpHeaderAuthentication::class)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.dokkaHtml.configure {
|
||||
outputDirectory.set(buildDir.resolve("dokka"))
|
||||
dokkaSourceSets {
|
||||
configureEach {
|
||||
// includes.from("docs/bipack.md")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1 +1,12 @@
|
||||
#
|
||||
# Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
#
|
||||
# You may use, distribute and modify this code under the
|
||||
# terms of the private license, which you must obtain from the author
|
||||
#
|
||||
# To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
# real dot sergeych at gmail.
|
||||
#
|
||||
|
||||
kotlin.code.style=official
|
||||
kotlin.mpp.applyDefaultHierarchyTemplate=false
|
10
gradle/wrapper/gradle-wrapper.properties
vendored
10
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,3 +1,13 @@
|
||||
#
|
||||
# Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
#
|
||||
# You may use, distribute and modify this code under the
|
||||
# terms of the private license, which you must obtain from the author
|
||||
#
|
||||
# To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
# real dot sergeych at gmail.
|
||||
#
|
||||
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
||||
|
16
gradlew
vendored
16
gradlew
vendored
@ -1,19 +1,13 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
# Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# You may use, distribute and modify this code under the
|
||||
# terms of the private license, which you must obtain from the author
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
# real dot sergeych at gmail.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
|
@ -1,3 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
|
@ -1,27 +0,0 @@
|
||||
package net.sergeych.crypto
|
||||
|
||||
import com.ionspin.kotlin.crypto.LibsodiumInitializer
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
private var isReady = false
|
||||
private val readyAccess = Mutex()
|
||||
|
||||
/**
|
||||
* Library initialization: should be called before all other calls.
|
||||
* It is safe and with little performance penalty to call it multiple times.
|
||||
*/
|
||||
suspend fun initCrypto() {
|
||||
// faster to check with no lock
|
||||
if( !isReady) {
|
||||
readyAccess.withLock {
|
||||
// recheck with lock, it could be ready by now
|
||||
if( !isReady ) {
|
||||
LibsodiumInitializer.initialize()
|
||||
isReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,81 +0,0 @@
|
||||
package net.sergeych.crypto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
|
||||
/**
|
||||
* Multi-signed data box. Use [SignedBox.invoke] to easily create
|
||||
* instances and [SignedBox.plus] to add more signatures (signing keys), and
|
||||
* [SignedBox.contains] to check for a specific key signature presence.
|
||||
*
|
||||
* It is serializable and checks integrity on deserialization. If any of seals does not
|
||||
* match the signed [message], it throws [IllegalSignatureException] _on deserialization_.
|
||||
* E.g., if you have it deserialized, it is ok, check it contains all needed keys among
|
||||
* signers.
|
||||
*
|
||||
* __The main constructor is used for deserializing only__. Don't use it directly unless you
|
||||
* know what you are doing as it may be dangerous.Use one of the above to create or change it.
|
||||
*/
|
||||
@Serializable
|
||||
class SignedBox(
|
||||
val message: UByteArray,
|
||||
private val seals: List<Seal>,
|
||||
@Transient
|
||||
private val checkOnInit: Boolean = true
|
||||
) {
|
||||
|
||||
/**
|
||||
* If this instance is not signed by a given key, return new instance signed also by this
|
||||
* key, or return unchanged (same) object if it is already signed by this key; you
|
||||
* _can't assume it always returns a copied object!_
|
||||
*/
|
||||
operator fun plus(key: Key.Signing): SignedBox =
|
||||
if (key.verifying in this) this
|
||||
else SignedBox(message, seals + Seal.create(key, message), false)
|
||||
|
||||
/**
|
||||
* Check that it is signed with a specified key.
|
||||
*/
|
||||
operator fun contains(verifyingKey: Key.Verifying): Boolean {
|
||||
return seals.any { it.key == verifyingKey }
|
||||
}
|
||||
|
||||
init {
|
||||
if (seals.isEmpty()) throw IllegalArgumentException("there should be at least one seal")
|
||||
if (checkOnInit) {
|
||||
if (!seals.all { it.verify(message) }) throw IllegalSignatureException()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A key + signature pair for [SignedBox] boxes, usually you don't use it
|
||||
* directly bug call [SignedBox] constructor or [SignedBox.plus] to
|
||||
* add seals.
|
||||
*/
|
||||
@Serializable
|
||||
data class Seal(val key: Key.Verifying, val signature: UByteArray) {
|
||||
|
||||
fun verify(message: UByteArray): Boolean = key.verify(signature, message)
|
||||
|
||||
companion object {
|
||||
fun create(key: Key.Signing, message: UByteArray): Seal {
|
||||
return Seal(key.verifying, key.sign(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new instance with a specific data sealed by one or more
|
||||
* keys. At least one key is required to disallow providing not-signed
|
||||
* instances, e.g. [SignedBox] is guaranteed to be properly sealed when
|
||||
* successfully instantiated.
|
||||
*
|
||||
* @param data a message to sign
|
||||
* @param keys a list of keys to sign with, should be at least one key.
|
||||
* @throws IllegalArgumentException if keys are not specified.
|
||||
*/
|
||||
operator fun invoke(data: UByteArray, vararg keys: Key.Signing): SignedBox =
|
||||
SignedBox(data, keys.map { Seal.create(it, data) }, false)
|
||||
}
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
package net.sergeych.crypto
|
||||
|
||||
import net.sergeych.bintools.CRC
|
||||
|
||||
fun isValidContrail(data: UByteArray): Boolean = CRC.crc8(data.copyOfRange(1, data.size)) == data[0]
|
||||
|
||||
fun createContrail(data: UByteArray): UByteArray = ubyteArrayOf(CRC.crc8(data)) + data
|
@ -1,75 +0,0 @@
|
||||
package net.sergeych.crypto
|
||||
|
||||
import com.ionspin.kotlin.crypto.signature.InvalidSignatureException
|
||||
import com.ionspin.kotlin.crypto.signature.Signature
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.sergeych.crypto.Key.Signing
|
||||
|
||||
/**
|
||||
* Keys in general: public, secret and later symmetric too.
|
||||
* Keys could be compared to each other for equality and used
|
||||
* as a Map keys (not sure about js).
|
||||
*
|
||||
* Use [Signing.pair] to create new keys.
|
||||
*/
|
||||
@Serializable
|
||||
sealed class Key {
|
||||
abstract val packed: UByteArray
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return other is Key && other.packed contentEquals packed
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return packed.contentHashCode()
|
||||
}
|
||||
|
||||
override fun toString(): String = packed.encodeToBase64Url()
|
||||
|
||||
/**
|
||||
* Public key to verify signatures only
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("pvk")
|
||||
class Verifying(override val packed: UByteArray) : Key() {
|
||||
/**
|
||||
* Verify the signature and return true if it is correct.
|
||||
*/
|
||||
fun verify(signature: UByteArray, message: UByteArray): Boolean = try {
|
||||
Signature.verifyDetached(signature, message, packed)
|
||||
true
|
||||
} catch (_: InvalidSignatureException) {
|
||||
false
|
||||
}
|
||||
|
||||
override fun toString(): String = "Pub:${super.toString()}"
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret key to sign only
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("ssk")
|
||||
class Signing(override val packed: UByteArray) : Key() {
|
||||
|
||||
val verifying: Verifying by lazy {
|
||||
Verifying(Signature.ed25519SkToPk(packed))
|
||||
}
|
||||
|
||||
fun sign(message: UByteArray): UByteArray = Signature.detached(message, packed)
|
||||
override fun toString(): String = "Sct:${super.toString()}"
|
||||
|
||||
companion object {
|
||||
data class Pair(val signing: Signing, val verifying: Verifying)
|
||||
|
||||
fun pair(): Pair {
|
||||
val p = Signature.keypair()
|
||||
return Pair(Signing(p.secretKey), Verifying(p.publicKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IllegalSignatureException: RuntimeException("signed data is tampered or signature is corrupted")
|
@ -1,85 +0,0 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package net.sergeych.crypto
|
||||
|
||||
import com.ionspin.kotlin.crypto.secretbox.SecretBox
|
||||
import com.ionspin.kotlin.crypto.secretbox.crypto_secretbox_NONCEBYTES
|
||||
import com.ionspin.kotlin.crypto.util.LibsodiumRandom
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.sergeych.bintools.toDataSource
|
||||
import net.sergeych.bipack.BipackDecoder
|
||||
import net.sergeych.bipack.BipackEncoder
|
||||
|
||||
class DecryptionFailedException : RuntimeException("can't encrypt: wrong key or tampered message")
|
||||
|
||||
@Serializable
|
||||
data class WithNonce(
|
||||
val cipherData: UByteArray,
|
||||
val nonce: UByteArray,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WithFill(
|
||||
val data: UByteArray,
|
||||
val safetyFill: UByteArray? = null
|
||||
) {
|
||||
constructor(data: UByteArray, fillSize: Int) : this(data, randomBytes(fillSize))
|
||||
}
|
||||
|
||||
fun randomBytes(n: Int): UByteArray = if (n > 0) LibsodiumRandom.buf(n) else ubyteArrayOf()
|
||||
|
||||
fun randomBytes(n: UInt): UByteArray = if (n > 0u) LibsodiumRandom.buf(n.toInt()) else ubyteArrayOf()
|
||||
|
||||
/**
|
||||
* Uniform random in `0 ..< max` range
|
||||
*/
|
||||
fun randomUInt(max: UInt) = LibsodiumRandom.uniform(max)
|
||||
fun randomUInt(max: Int) = LibsodiumRandom.uniform(max.toUInt())
|
||||
|
||||
fun <T: Comparable<T>>T.limit(range: ClosedRange<T>) = when {
|
||||
this < range.start -> range.start
|
||||
this > range.endInclusive -> range.endInclusive
|
||||
else -> this
|
||||
}
|
||||
|
||||
fun <T: Comparable<T>>T.limitMax(max: T) = if( this < max ) this else max
|
||||
fun <T: Comparable<T>>T.limitMin(min: T) = if( this > min ) this else min
|
||||
|
||||
fun randomNonce(): UByteArray = randomBytes(crypto_secretbox_NONCEBYTES)
|
||||
|
||||
/**
|
||||
* Secret-key encrypt with authentication.
|
||||
* Generates random nonce and add some random fill to protect
|
||||
* against some analysis attacks. Nonce is included in the result. To be
|
||||
* used with [decrypt].
|
||||
* @param secretKey a _secret_ key, see [SecretBox.keygen()] or like.
|
||||
* @param plain data to encrypt
|
||||
* @param fillSize number of random fill data to add. Use random value or default.
|
||||
*/
|
||||
fun encrypt(
|
||||
secretKey: UByteArray,
|
||||
plain: UByteArray,
|
||||
fillSize: Int = randomUInt((plain.size * 3 / 10).limitMin(3)).toInt()
|
||||
): UByteArray {
|
||||
val filled = BipackEncoder.encode(WithFill(plain, fillSize))
|
||||
val nonce = randomNonce()
|
||||
val encrypted = SecretBox.easy(filled.toUByteArray(), nonce, secretKey)
|
||||
return BipackEncoder.encode(WithNonce(encrypted, nonce)).toUByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a secret-key-based message, normally encrypted with [encrypt].
|
||||
* @throws DecryptionFailedException if the key is wrong or a message is tampered with (MAC
|
||||
* check failed).
|
||||
*/
|
||||
fun decrypt(secretKey: UByteArray, cipher: UByteArray): UByteArray {
|
||||
val wn: WithNonce = BipackDecoder.decode(cipher.toDataSource())
|
||||
try {
|
||||
return BipackDecoder.decode<WithFill>(
|
||||
SecretBox.openEasy(wn.cipherData, wn.nonce, secretKey).toDataSource()
|
||||
).data
|
||||
}
|
||||
catch(_: com.ionspin.kotlin.crypto.secretbox.SecretBoxCorruptedOrTamperedDataExceptionOrInvalidKey) {
|
||||
throw DecryptionFailedException()
|
||||
}
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
package net.sergeych.crypto
|
||||
|
||||
import net.sergeych.bintools.toDump
|
||||
import net.sergeych.mp_tools.encodeToBase64Url
|
||||
|
||||
fun UByteArray.toDump(wide: Boolean = false) = toByteArray().toDump(wide)
|
||||
|
||||
fun UByteArray.encodeToBase64Url(): String = toByteArray().encodeToBase64Url()
|
47
src/commonMain/kotlin/net/sergeych/kiloparsec/AsyncVarint.kt
Normal file
47
src/commonMain/kotlin/net/sergeych/kiloparsec/AsyncVarint.kt
Normal file
@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import io.ktor.utils.io.*
|
||||
|
||||
object AsyncVarint {
|
||||
suspend fun encodeUnsigned(value: ULong, output: ByteWriteChannel) {
|
||||
var rest = value
|
||||
do {
|
||||
val x = (rest and 127u).toInt()
|
||||
rest = rest shr 7
|
||||
if (rest > 0u)
|
||||
output.writeByte(x or 0x80)
|
||||
else
|
||||
output.writeByte(x)
|
||||
|
||||
} while (rest > 0u)
|
||||
}
|
||||
|
||||
suspend fun decodeUnsigned(source: ByteReadChannel): ULong {
|
||||
var result: ULong = 0u
|
||||
var count = 0
|
||||
while (true) {
|
||||
val x = source.readUByte().toInt()
|
||||
result = result or ((x and 0x7F).toULong() shl count)
|
||||
if ((x and 0x80) == 0)
|
||||
break
|
||||
count += 7
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ByteReadChannel.readUByte(): UByte = this.readByte().toUByte()
|
||||
|
||||
suspend fun ByteWriteChannel.writeByte(byte: Int) {
|
||||
this.writeByte(byte.toByte())
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Multiplatform atomically mutable value to be used in [kotlinx.coroutines],
|
||||
* with suspending mutating operations, see [mutate].
|
||||
*
|
||||
* Actual value can be either changed in a block of [mutate] when
|
||||
* new value _depends on the current value_ or with [reset].
|
||||
*
|
||||
* [value] getter is suspended because it waits until the mutation finishes
|
||||
*/
|
||||
open class AtomicAsyncValue<T>(initialValue: T) {
|
||||
private var actualValue = initialValue
|
||||
private val access = Mutex()
|
||||
|
||||
/**
|
||||
* Change the value: get the current and set to the returned, all in the
|
||||
* atomic suspend operation. All other mutating requests including assigning to [value]
|
||||
* will be blocked and queued.
|
||||
* @return result of the mutation. Note that immediate call to property [value]
|
||||
* could already return modified bu some other thread value!
|
||||
*/
|
||||
suspend fun mutate(mutator: suspend (T) -> T): T = access.withLock {
|
||||
actualValue = mutator(actualValue)
|
||||
actualValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic get or set the value. Atomic get means if there is a [mutate] in progress
|
||||
* it will wait until the mutation finishes and then return the correct result.
|
||||
*/
|
||||
suspend fun value() = access.withLock { actualValue }
|
||||
|
||||
/**
|
||||
* Set the new value without checking it. Shortcut to
|
||||
* ```mutate { value = newValue }```
|
||||
*/
|
||||
suspend fun reset(value: T) = mutate { value }
|
||||
}
|
@ -1,3 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
@ -5,6 +15,8 @@ import kotlinx.serialization.Serializable
|
||||
import net.sergeych.bintools.toDataSource
|
||||
import net.sergeych.bipack.BipackDecoder
|
||||
import net.sergeych.bipack.BipackEncoder
|
||||
import net.sergeych.kiloparsec.Command.Call
|
||||
import net.sergeych.kiloparsec.Command.Companion.unpackCall
|
||||
import net.sergeych.utools.unpack
|
||||
|
||||
/**
|
||||
@ -12,8 +24,20 @@ import net.sergeych.utools.unpack
|
||||
* in node-2-node protocols and client API, and most importantly in calling smart contract
|
||||
* methods. This is essentially a Kotlin binding to typesafe serialize command calls and
|
||||
* deserialize results.
|
||||
*
|
||||
* To create command instances, it is recommended to use [command] that returns [CommandDelegate].
|
||||
*
|
||||
* Use [packCall] to serialize the command call with some arguments.
|
||||
*
|
||||
* Note that `Command` instances themselves are not serialized, instead, the call is serialized,
|
||||
* in the form of [Call], containing name and properly serialized arguments.
|
||||
*
|
||||
* [unpackCall] deserialized result of the [packCall] so the proper handler for the command could
|
||||
* be used. Then the result of the execution could be packed with [exec] and then unpacked with
|
||||
* [unpackResult].
|
||||
*
|
||||
*/
|
||||
class Command<A, R>(
|
||||
open class Command<A, R>(
|
||||
val name: String,
|
||||
val argsSerializer: KSerializer<A>,
|
||||
val resultSerializer: KSerializer<R>
|
||||
@ -21,20 +45,41 @@ class Command<A, R>(
|
||||
@Serializable
|
||||
data class Call(val name: String,val serializedArgs: UByteArray)
|
||||
|
||||
fun packCall(args: A): UByteArray = BipackEncoder.encode(
|
||||
Call(name, BipackEncoder.encode(argsSerializer, args).toUByteArray())
|
||||
).toUByteArray()
|
||||
/**
|
||||
* Pack command invocation with specified arguments.
|
||||
*/
|
||||
fun packCall(args: A): UByteArray = BipackEncoder.encode(createCall(args)).toUByteArray()
|
||||
|
||||
/**
|
||||
* Create [Call] instance for specified args vy serializing it properly
|
||||
*/
|
||||
fun createCall(args: A): Call = Call(name, BipackEncoder.encode(argsSerializer, args).toUByteArray())
|
||||
|
||||
/**
|
||||
* Unpack result, obtained by [exec].
|
||||
*/
|
||||
fun unpackResult(packedResult: UByteArray): R =
|
||||
unpack(resultSerializer, packedResult)
|
||||
|
||||
/**
|
||||
* Execute a command unpacking args.
|
||||
*
|
||||
* @param packedArgs arguments, as provided by [packCall] in the [Call] instance
|
||||
* @param handler actual code to execute the command
|
||||
* @return properly serialized result to be unpacked with [unpackResult].
|
||||
*/
|
||||
suspend fun exec(packedArgs: UByteArray, handler: suspend (A) -> R): UByteArray =
|
||||
BipackEncoder.encode(
|
||||
resultSerializer,
|
||||
handler(BipackDecoder.decode(packedArgs.toDataSource(), argsSerializer))
|
||||
handler(
|
||||
BipackDecoder.decode(packedArgs.toDataSource(), argsSerializer))
|
||||
).toUByteArray()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Unpack command invocation instance from [packCall]. Use [exec] to deserialize arguments and
|
||||
* perform command.
|
||||
*/
|
||||
fun unpackCall(packedCall: UByteArray): Call = BipackDecoder.decode(packedCall.toDataSource())
|
||||
}
|
||||
}
|
@ -1,3 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
@ -7,6 +17,9 @@ import kotlin.reflect.KProperty
|
||||
/**
|
||||
* delegate returning function that creates a [Command] in the current context which by default has the name of
|
||||
* the property.
|
||||
*
|
||||
* The Default name is a property name except the "cmd" prefix if present, which will be
|
||||
* removed automatically.
|
||||
*/
|
||||
inline fun <reified A, reified R> command(overrideName: String? = null): CommandDelegate<A, R> {
|
||||
return CommandDelegate(
|
||||
@ -16,19 +29,33 @@ inline fun <reified A, reified R> command(overrideName: String? = null): Command
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare a Push: Unit-returning command usually used with [RemoteInterface.push]
|
||||
*/
|
||||
inline fun <reified A> push(overrideName: String? = null): CommandDelegate<A, Unit> = command(overrideName)
|
||||
|
||||
/**
|
||||
* Delegate to create [Command] via property
|
||||
*/
|
||||
class CommandDelegate<A, R>(
|
||||
private val argsSerializer: KSerializer<A>,
|
||||
private val resultSerializer: KSerializer<R>,
|
||||
private val overrideName: String? = null
|
||||
private val overrideName: String? = null,
|
||||
) {
|
||||
private var name: String = ""
|
||||
operator fun getValue(nothing: Nothing?, property: KProperty<*>): Command<A, R> {
|
||||
if (name.isEmpty()) {
|
||||
name = overrideName ?: removeCmd(property.name)
|
||||
}
|
||||
return Command(
|
||||
overrideName ?: property.name,
|
||||
name,
|
||||
argsSerializer,
|
||||
resultSerializer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeCmd(name: String) =
|
||||
if (name.startsWith("cmd"))
|
||||
name.substring(3)
|
||||
else name
|
||||
|
@ -1,3 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
/**
|
||||
|
@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
interface ExceptionWithCode {
|
||||
val code: String
|
||||
val message: String?
|
||||
}
|
@ -1,11 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import net.sergeych.crypto.Key
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.crypto2.VerifyingKey
|
||||
import net.sergeych.crypto2.VerifyingPublicKey
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.Loggable
|
||||
import net.sergeych.mp_logger.debug
|
||||
@ -13,18 +28,40 @@ import net.sergeych.mp_logger.exception
|
||||
import net.sergeych.mp_tools.globalLaunch
|
||||
|
||||
/**
|
||||
* The auto-connecting client that reconnects to the kiloparsec server
|
||||
* The auto-connecting client that reconnects to the kiloparsec server,
|
||||
* [KiloServer],
|
||||
* and maintain connection state flow. Client factory launches a disconnected
|
||||
* set of coroutines to support automatic reconnection, so you _must_ [close]
|
||||
* it manually when it is not needed, otherwise it will continue to reconnect.
|
||||
* it manually when it is unnecessary, otherwise it will continue to reconnect.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* Suppose we have TCP/IP server as in the [KiloServer] usage sample. Then we can connect
|
||||
* to it providing TCP/IP connector like:
|
||||
*
|
||||
* ```kotlin
|
||||
* val client = KiloClient<Unit>() {
|
||||
* connect { connectTcpDevice("localhost:$port") }
|
||||
* }
|
||||
*
|
||||
* // now we can invoke remote commands:
|
||||
* assertEquals("unknown", client.call(cmdLoad))
|
||||
*
|
||||
* client.call(cmdSave, "foobar")
|
||||
* assertEquals("foobar", client.call(cmdLoad))
|
||||
* ```
|
||||
*
|
||||
* ## See also
|
||||
*
|
||||
* [KiloServer]
|
||||
*
|
||||
*/
|
||||
class KiloClient<S>(
|
||||
localInterface: KiloInterface<S>,
|
||||
secretKey: Key.Signing? = null,
|
||||
val localInterface: KiloInterface<S>,
|
||||
secretKey: SigningKey? = null,
|
||||
connectionDataFactory: ConnectionDataFactory<S>,
|
||||
) : RemoteInterface,
|
||||
Loggable by LogTag("CLIF") {
|
||||
|
||||
val _state = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
@ -32,7 +69,15 @@ class KiloClient<S>(
|
||||
* to authenticate a client on connection restore, for example.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
val state = _state.asStateFlow()
|
||||
val connectedStateFlow = _state.asStateFlow()
|
||||
|
||||
/**
|
||||
* The verifying, or public, key identifying client sessions. It could be used to
|
||||
* restore environment on reconnection. This is what remote side, e.g. server, sees as
|
||||
* [KiloScope.remoteIdentity].
|
||||
*/
|
||||
@Suppress("unused")
|
||||
val localIdentity: VerifyingKey? = secretKey?.verifyingKey
|
||||
|
||||
private var deferredClient = CompletableDeferred<KiloClientConnection<S>>()
|
||||
|
||||
@ -44,30 +89,61 @@ class KiloClient<S>(
|
||||
debug { "getting connection" }
|
||||
val kc = connectionDataFactory()
|
||||
debug { "get device and session" }
|
||||
val client = KiloClientConnection(localInterface, kc,secretKey)
|
||||
val client = KiloClientConnection(localInterface, kc, secretKey)
|
||||
deferredClient.complete(client)
|
||||
client.run {
|
||||
_state.value = false
|
||||
_state.value = it
|
||||
}
|
||||
resetDeferredClient()
|
||||
debug { "client run finished" }
|
||||
} catch (_: RemoteInterface.ClosedException) {
|
||||
debug { "remote closed" }
|
||||
delay(1000)
|
||||
} catch (_: CancellationException) {
|
||||
debug { "cancelled" }
|
||||
break
|
||||
} catch (t: Throwable) {
|
||||
exception { "unexpected exception" to t }
|
||||
delay(1000)
|
||||
}
|
||||
_state.value = false
|
||||
if (deferredClient.isActive)
|
||||
deferredClient = CompletableDeferred()
|
||||
resetDeferredClient()
|
||||
// reconnection timeout
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
job.cancel()
|
||||
debug { "client is closed" }
|
||||
}
|
||||
|
||||
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R = deferredClient.await().call(cmd, args)
|
||||
private val defMutex = Mutex()
|
||||
private suspend fun resetDeferredClient() {
|
||||
defMutex.withLock {
|
||||
if (!deferredClient.isActive) {
|
||||
deferredClient = CompletableDeferred()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R =
|
||||
try {
|
||||
deferredClient.await().call(cmd, args)
|
||||
} catch (t: RemoteInterface.ClosedException) {
|
||||
resetDeferredClient()
|
||||
throw t
|
||||
}
|
||||
|
||||
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) {
|
||||
try {
|
||||
deferredClient.await().push(cmd, args)
|
||||
} catch (t: RemoteInterface.ClosedException) {
|
||||
resetDeferredClient()
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Current session token. This is a per-connection unique random value same on the client and server part so
|
||||
@ -77,14 +153,14 @@ class KiloClient<S>(
|
||||
suspend fun token() = deferredClient.await().token()
|
||||
|
||||
/**
|
||||
* Remote party shared key ([Key.Verifying]]), could be used ti ensure server is what we expected and
|
||||
* Remote party shared key ([VerifyingPublicKey]), could be used ti ensure server is what we expected and
|
||||
* there is no active MITM attack.
|
||||
*
|
||||
* Non-null value means the key was successfully authenticated, null means remote party did not provide
|
||||
* a key. Connection is established either with a properly authenticated key or no key at all.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
suspend fun remoteId() = deferredClient.await().remoteId()
|
||||
suspend fun remoteIdentity(): VerifyingPublicKey? = deferredClient.await().remoteId()
|
||||
|
||||
companion object {
|
||||
class Builder<S>() {
|
||||
@ -96,16 +172,42 @@ class KiloClient<S>(
|
||||
}
|
||||
private var connectionBuilder: (suspend () -> Transport.Device)? = null
|
||||
|
||||
var secretIdKey: Key.Signing? = null
|
||||
var secretIdKey: SigningKey? = null
|
||||
|
||||
/**
|
||||
* Build local command implementations (remotely callable ones), exception
|
||||
* class handlers, etc.
|
||||
* Build local command implementations, those callable from the server, exception
|
||||
* class handlers, and anything else [KiloInterface] allows. Usage sample:
|
||||
*
|
||||
* ```kotlin
|
||||
* val client = KiloClient {
|
||||
* connect { connectTcpDevice("localhost:$port") }
|
||||
* local {
|
||||
* on(cmdPing) {
|
||||
* "pong! $it"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
fun local(f: KiloInterface<S>.() -> Unit) {
|
||||
interfaceBuilder = f
|
||||
}
|
||||
|
||||
/**
|
||||
* Import exception (error) registrations from a shared LocalInterface (usually, server side one) to
|
||||
* DRY error registration on both server and client sides.
|
||||
* Normally you need to:
|
||||
*
|
||||
* - Register all your errors in server's LocalInterface
|
||||
*
|
||||
* - In the client builder call [addErrors(serverLocalInterface]
|
||||
*
|
||||
* See also [LocalInterface.registerError] for exception registration
|
||||
*/
|
||||
fun addErrors(from: LocalInterface<*>) {
|
||||
errorProviders += from
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session object, otherwise Unit session will be used
|
||||
*/
|
||||
@ -117,12 +219,15 @@ class KiloClient<S>(
|
||||
connectionBuilder = f
|
||||
}
|
||||
|
||||
val errorProviders = mutableListOf<LocalInterface<*>>()
|
||||
|
||||
internal fun build(): KiloClient<S> {
|
||||
val i = KiloInterface<S>()
|
||||
for (ep in errorProviders) i.addErrorProvider(ep)
|
||||
interfaceBuilder?.let { i.it() }
|
||||
val connector = connectionBuilder ?: throw IllegalArgumentException("connect handler was not set")
|
||||
return KiloClient(i,secretIdKey) {
|
||||
KiloConnectionData(connector(),sessionBuilder())
|
||||
return KiloClient(i, secretIdKey) {
|
||||
KiloConnectionData(connector(), sessionBuilder())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import com.ionspin.kotlin.crypto.keyexchange.KeyExchange
|
||||
import kotlinx.coroutines.*
|
||||
import net.sergeych.crypto.Key
|
||||
import net.sergeych.crypto2.SafeKeyExchange
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.crypto2.VerifyingPublicKey
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.Loggable
|
||||
import net.sergeych.mp_logger.debug
|
||||
@ -12,20 +23,20 @@ import net.sergeych.utools.pack
|
||||
private var clientIds = 0
|
||||
|
||||
class KiloClientConnection<S>(
|
||||
private val clientInterface: LocalInterface<KiloScope<S>>,
|
||||
private val clientInterface: KiloInterface<S>,
|
||||
private val device: Transport.Device,
|
||||
private val session: S,
|
||||
private val secretIdKey: Key.Signing? = null,
|
||||
private val secretIdKey: SigningKey? = null,
|
||||
) : RemoteInterface, Loggable by LogTag("KPC:${++clientIds}") {
|
||||
|
||||
constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, secretIdKey: Key.Signing? = null)
|
||||
constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, secretIdKey: SigningKey? = null)
|
||||
: this(localInterface, connection.device, connection.session, secretIdKey)
|
||||
|
||||
private val kiloRemoteInterface = CompletableDeferred<KiloRemoteInterface<S>>()
|
||||
|
||||
private val deferredParams = CompletableDeferred<KiloParams<S>>()
|
||||
|
||||
suspend fun remoteId(): Key.Verifying? = deferredParams.await().remoteIdentity
|
||||
suspend fun remoteId(): VerifyingPublicKey? = deferredParams.await().remoteIdentity
|
||||
|
||||
/**
|
||||
* Run the client, blocking until the device is closed, or some critical exception
|
||||
@ -37,7 +48,7 @@ class KiloClientConnection<S>(
|
||||
var job: Job? = null
|
||||
try {
|
||||
// in parallel: keys and connection
|
||||
val deferredKeyPair = async { KeyExchange.keypair() }
|
||||
val deferredKeyPair = async { SafeKeyExchange() }
|
||||
debug { "opening device" }
|
||||
debug { "got a transport device $device" }
|
||||
|
||||
@ -55,23 +66,21 @@ class KiloClientConnection<S>(
|
||||
|
||||
val serverHe = transport.call(L0Request, Handshake(1u, pair.publicKey))
|
||||
|
||||
val sk = KeyExchange.clientSessionKeys(pair.publicKey, pair.secretKey, serverHe.publicKey)
|
||||
val sk = pair.clientSessionKey(serverHe.publicKey)
|
||||
var params = KiloParams(false, transport, sk, session, null, this@KiloClientConnection)
|
||||
|
||||
// Check ID if any
|
||||
serverHe.serverSharedKey?.let { k ->
|
||||
if (serverHe.signature == null)
|
||||
throw RemoteInterface.SecurityException("missing signature")
|
||||
if (!k.verify(serverHe.signature, params.token))
|
||||
serverHe.signature?.let { s ->
|
||||
if (!s.isValid(params.token))
|
||||
throw RemoteInterface.SecurityException("wrong signature")
|
||||
params = params.copy(remoteIdentity = k)
|
||||
params = params.copy(remoteIdentity = s.publicKey)
|
||||
}
|
||||
|
||||
transport.call(
|
||||
L0ClientId, params.encrypt(
|
||||
pack(
|
||||
ClientIdentity(
|
||||
secretIdKey?.verifying,
|
||||
secretIdKey?.verifyingKey,
|
||||
secretIdKey?.sign(params.token)
|
||||
)
|
||||
)
|
||||
@ -81,12 +90,14 @@ class KiloClientConnection<S>(
|
||||
kiloRemoteInterface.complete(
|
||||
KiloRemoteInterface(deferredParams, clientInterface)
|
||||
)
|
||||
clientInterface.onConnectHandlers.invokeAll(params.scope)
|
||||
onConnectedStateChanged?.invoke(true)
|
||||
job.join()
|
||||
|
||||
} catch (x: CancellationException) {
|
||||
info { "client is cancelled" }
|
||||
} catch (x: RemoteInterface.ClosedException) {
|
||||
x.printStackTrace()
|
||||
info { "connection closed by remote" }
|
||||
} finally {
|
||||
onConnectedStateChanged?.invoke(false)
|
||||
@ -99,4 +110,11 @@ class KiloClientConnection<S>(
|
||||
suspend fun token() = deferredParams.await().token
|
||||
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R =
|
||||
kiloRemoteInterface.await().call(cmd, args)
|
||||
}
|
||||
|
||||
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) {
|
||||
kiloRemoteInterface.await().push(cmd, args)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun <S>Collection<KiloHandler<S>>.invokeAll(scope: KiloScope<S>) =
|
||||
forEach { runCatching { scope.it() } }
|
@ -1,16 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
typealias KiloHandler<S> = KiloScope<S>.()->Unit
|
||||
/**
|
||||
* The local interface to provice functions, register errors for Kiloparsec users. Use it
|
||||
* The local interface to provide functions, register errors for Kiloparsec users. Use it
|
||||
* with [KiloClient], [KiloClientConnection], [KiloServerConnection], etc.
|
||||
*
|
||||
* BAse implementation registers relevant exceptions.
|
||||
* Base class implementation does the following:
|
||||
*
|
||||
* - It registers common exceptions from [RemoteInterface] and kotlin/java `IllegalArgumentException` and
|
||||
* `IllegalStateException`
|
||||
* - It provides [onConnected] handler
|
||||
*
|
||||
* See [KiloServer] for usage sample.
|
||||
*/
|
||||
class KiloInterface<S> : LocalInterface<KiloScope<S>>() {
|
||||
open class KiloInterface<S> : LocalInterface<KiloScope<S>>() {
|
||||
|
||||
internal val onConnectHandlers = mutableListOf<KiloHandler<S>>()
|
||||
|
||||
/**
|
||||
* Registers handler [f] for [onConnected] event, to the head or the end of handler list.
|
||||
*
|
||||
* @param addFirst if true, [f] will be added to the beginning of the list of handlers
|
||||
*/
|
||||
fun onConnected(addFirst: Boolean = false, f: KiloScope<S>.()->Unit) {
|
||||
if( addFirst ) onConnectHandlers.add(0, f) else onConnectHandlers += f
|
||||
}
|
||||
|
||||
init {
|
||||
registerError { RemoteInterface.UnknownCommand() }
|
||||
registerError { RemoteInterface.UnknownCommand(it) }
|
||||
registerError { RemoteInterface.InternalError(it) }
|
||||
registerError { RemoteInterface.ClosedException(it) }
|
||||
registerError { RemoteInterface.SecurityException(it) }
|
||||
// registerError { RemoteInterface.SecurityException(it) }
|
||||
registerError { RemoteInterface.InvalidDataException(it) }
|
||||
registerError { RemoteInterface.RemoteException(it) }
|
||||
registerError { IllegalStateException() }
|
||||
|
@ -1,8 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
import net.sergeych.utools.pack
|
||||
|
||||
private val idCounter = AtomicCounter(0)
|
||||
|
||||
/**
|
||||
* This class is not normally used directly. This is a local interface that supports
|
||||
* secure transport command layer (encrypted calls/results) to work with [KiloRemoteInterface].
|
||||
@ -12,7 +25,10 @@ import net.sergeych.utools.pack
|
||||
internal class KiloL0Interface<T>(
|
||||
private val clientInterface: LocalInterface<KiloScope<T>>,
|
||||
private val deferredParams: CompletableDeferred<KiloParams<T>>,
|
||||
): LocalInterface<Unit>() {
|
||||
) : LocalInterface<Unit>() {
|
||||
|
||||
override var logTag: String = "KL0:${idCounter.incrementAndGet()}"
|
||||
|
||||
init {
|
||||
// local interface uses the same session as a client:
|
||||
addErrorProvider(clientInterface)
|
||||
@ -27,7 +43,10 @@ internal class KiloL0Interface<T>(
|
||||
0u,
|
||||
clientInterface.execute(params.scope, call.name, call.serializedArgs)
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
} catch(t: BreakConnectionException) {
|
||||
throw t
|
||||
}
|
||||
catch (t: Throwable) {
|
||||
clientInterface.encodeError(0u, t)
|
||||
}
|
||||
params.encrypt(pack(result))
|
||||
|
@ -1,23 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import com.ionspin.kotlin.crypto.keyexchange.KeyExchangeSessionKeyPair
|
||||
import com.ionspin.kotlin.crypto.secretbox.SecretBox
|
||||
import com.ionspin.kotlin.crypto.secretbox.crypto_secretbox_NONCEBYTES
|
||||
import com.ionspin.kotlin.crypto.util.decodeFromUByteArray
|
||||
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.sergeych.bintools.toDataSource
|
||||
import net.sergeych.bipack.BipackDecoder
|
||||
import net.sergeych.bipack.BipackEncoder
|
||||
import net.sergeych.crypto.DecryptionFailedException
|
||||
import net.sergeych.crypto.Key
|
||||
import net.sergeych.crypto.randomBytes
|
||||
import net.sergeych.crypto.randomUInt
|
||||
import net.sergeych.tools.ProtectedOp
|
||||
import net.sergeych.bipack.Unsigned
|
||||
import net.sergeych.crypto2.*
|
||||
import net.sergeych.synctools.ProtectedOp
|
||||
import net.sergeych.synctools.invoke
|
||||
import net.sergeych.utools.pack
|
||||
import net.sergeych.utools.unpack
|
||||
import org.komputing.khash.keccak.Keccak
|
||||
import org.komputing.khash.keccak.KeccakParameter
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@ -32,23 +32,18 @@ import kotlin.math.roundToInt
|
||||
data class KiloParams<S>(
|
||||
val isServer: Boolean,
|
||||
val transport: RemoteInterface,
|
||||
val sessionKeyPair: KeyExchangeSessionKeyPair,
|
||||
val sessionKey: SafeKeyExchange.SessionKey,
|
||||
val scopeSession: S,
|
||||
val remoteIdentity: Key.Verifying?,
|
||||
val remoteTransport: RemoteInterface
|
||||
val remoteIdentity: VerifyingPublicKey?,
|
||||
val remoteTransport: RemoteInterface,
|
||||
) {
|
||||
@Serializable
|
||||
data class Package(
|
||||
@Unsigned
|
||||
val nonce: ULong,
|
||||
val encryptedMessage: UByteArray,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FilledData(
|
||||
val message: UByteArray,
|
||||
val fill: UByteArray,
|
||||
)
|
||||
|
||||
private var nonce = 0UL
|
||||
|
||||
val scope: KiloScope<S> by lazy {
|
||||
@ -56,81 +51,39 @@ data class KiloParams<S>(
|
||||
override val session = scopeSession
|
||||
override val remote: RemoteInterface = remoteTransport
|
||||
override val sessionToken: UByteArray = token
|
||||
override val remoteIdentity: Key.Verifying? = this@KiloParams.remoteIdentity
|
||||
override val remoteIdentity: VerifyingPublicKey? = this@KiloParams.remoteIdentity
|
||||
}
|
||||
}
|
||||
|
||||
val token: UByteArray by lazy {
|
||||
val base = if (isServer) sessionKeyPair.sendKey + sessionKeyPair.receiveKey
|
||||
else sessionKeyPair.receiveKey + sessionKeyPair.sendKey
|
||||
Keccak.digest(
|
||||
base.toByteArray(), KeccakParameter.KECCAK_256
|
||||
).toUByteArray().sliceArray(0..<crypto_secretbox_NONCEBYTES)
|
||||
blake2b("token_".encodeToUByteArray() + sessionKey.sessionTag).sliceArray(0..<SymmetricKey.nonceLength)
|
||||
}
|
||||
|
||||
private val sendBase by lazy {
|
||||
Keccak.digest(
|
||||
sessionKeyPair.sendKey.toByteArray(), KeccakParameter.KECCAK_256
|
||||
).toUByteArray().sliceArray(0..<crypto_secretbox_NONCEBYTES)
|
||||
}
|
||||
private val numericNonce = NumericNonce(token)
|
||||
|
||||
private val receiveBase by lazy {
|
||||
Keccak.digest(
|
||||
sessionKeyPair.receiveKey.toByteArray(), KeccakParameter.KECCAK_256
|
||||
).toUByteArray().sliceArray(0..<crypto_secretbox_NONCEBYTES)
|
||||
}
|
||||
|
||||
private inline fun encodeNonce(base: UByteArray, nonce: ULong): UByteArray {
|
||||
val result = base.copyOf()
|
||||
var x = nonce
|
||||
var i = 0
|
||||
while (x > 0u) {
|
||||
result[i] = result[i] xor (x and 0xFFu).toUByte()
|
||||
x = x shr 8
|
||||
i++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private inline fun encodeSendNonce(nonce: ULong): UByteArray = encodeNonce(sendBase, nonce)
|
||||
private inline fun encodeReceiveNonce(nonce: ULong): UByteArray = encodeNonce(receiveBase, nonce)
|
||||
|
||||
|
||||
fun encrypt(plainText: String): UByteArray = encrypt(plainText.encodeToUByteArray())
|
||||
|
||||
private val proptectedOp = ProtectedOp()
|
||||
private val protectedOp = ProtectedOp()
|
||||
|
||||
/**
|
||||
* Encrypt using send keys and proper nonce
|
||||
*/
|
||||
fun encrypt(message: UByteArray, fillFactor: Float = 0f): UByteArray {
|
||||
val fill: UByteArray = if (fillFactor > 0f)
|
||||
randomBytes(randomUInt((message.size * fillFactor).roundToInt()))
|
||||
val fill = if (fillFactor > 0f)
|
||||
0..(message.size * fillFactor).roundToInt()
|
||||
else
|
||||
ubyteArrayOf()
|
||||
null
|
||||
|
||||
val withFill = BipackEncoder.encode(FilledData(message, fill)).toUByteArray()
|
||||
|
||||
val n = proptectedOp { nonce++ }
|
||||
val n = protectedOp.invoke { nonce++ }
|
||||
|
||||
return pack(
|
||||
Package(n, SecretBox.easy(withFill, encodeSendNonce(n), sessionKeyPair.sendKey))
|
||||
Package(n, sessionKey.encryptWithNonce(message, numericNonce.withULong(n), fill))
|
||||
)
|
||||
}
|
||||
|
||||
fun decryptString(cipherText: UByteArray): String = decrypt(cipherText).decodeFromUByteArray()
|
||||
fun decrypt(encryptedMessage: UByteArray): UByteArray {
|
||||
val p: Package = BipackDecoder.decode(encryptedMessage.toDataSource())
|
||||
try {
|
||||
return unpack<FilledData>(
|
||||
SecretBox.openEasy(
|
||||
p.encryptedMessage,
|
||||
encodeReceiveNonce(p.nonce),
|
||||
sessionKeyPair.receiveKey
|
||||
)
|
||||
).message
|
||||
} catch (_: com.ionspin.kotlin.crypto.secretbox.SecretBoxCorruptedOrTamperedDataExceptionOrInvalidKey) {
|
||||
throw DecryptionFailedException()
|
||||
// fun decryptString(cipherText: UByteArray): String = decrypt(cipherText).decodeFromUByteArray()
|
||||
fun decrypt(encryptedMessage: UByteArray): UByteArray =
|
||||
protectDecryption {
|
||||
val p: Package = BipackDecoder.decode(encryptedMessage.toDataSource())
|
||||
sessionKey.decryptWithNonce(p.encryptedMessage, numericNonce.withULong(p.nonce))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,3 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@ -7,7 +17,13 @@ import net.sergeych.utools.unpack
|
||||
|
||||
private var L1IdCounter = 0
|
||||
|
||||
// todo: We don't need it deferred here
|
||||
/**
|
||||
* Represents a remote interface for interacting with a Kiloparsec service.
|
||||
*
|
||||
* @param S the scope type of the Kiloparsec service which will be used to call local functions remotely
|
||||
* @property deferredParams a [CompletableDeferred] that resolves to the parameters required for making remote calls
|
||||
* @property clientInterface a [LocalInterface] used to communicate with the local client
|
||||
*/
|
||||
class KiloRemoteInterface<S>(
|
||||
private val deferredParams: CompletableDeferred<KiloParams<S>>,
|
||||
private val clientInterface: LocalInterface<KiloScope<S>>,
|
||||
@ -29,5 +45,10 @@ class KiloRemoteInterface<S>(
|
||||
else -> throw RemoteInterface.Exception("unexpected block type: $block")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) {
|
||||
val params = deferredParams.await()
|
||||
params.transport.call(L0Call, params.encrypt(cmd.packCall(args)))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import net.sergeych.crypto.Key
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.crypto2.VerifyingPublicKey
|
||||
|
||||
/**
|
||||
* Scope for Kiloparsec client/server commands execution, contain per-connection specific data. The scope
|
||||
@ -26,8 +37,8 @@ interface KiloScope<S> {
|
||||
val sessionToken: UByteArray
|
||||
|
||||
/**
|
||||
* If the remote part has provided a secret key, e.g., gave non-null [Key.Signing] on construction,
|
||||
* the kiloparsec checks it in the MITM-safe way and provides its [Key.Verifying] shared key here.
|
||||
* If the remote part has provided a secret key, e.g., gave non-null [SigningKey] on construction,
|
||||
* the kiloparsec checks it in the MITM-safe way and provides its [VerifyingPublicKey] shared key here.
|
||||
* Knowing a remote party shared key, it is possible to be sure that the connection is made directly
|
||||
* to this party with no middle point intruders.
|
||||
*
|
||||
@ -37,6 +48,6 @@ interface KiloScope<S> {
|
||||
* In spite of the above said, which means, non-null value in this field means the key is authorized, but
|
||||
* It is up to the caller to ensure it is expected key of the remote party.
|
||||
*/
|
||||
val remoteIdentity: Key.Verifying?
|
||||
val remoteIdentity: VerifyingPublicKey?
|
||||
}
|
||||
|
||||
|
153
src/commonMain/kotlin/net/sergeych/kiloparsec/KiloServer.kt
Normal file
153
src/commonMain/kotlin/net/sergeych/kiloparsec/KiloServer.kt
Normal file
@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.kiloparsec.adapter.InetTransportDevice
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.debug
|
||||
import net.sergeych.mp_logger.exception
|
||||
import net.sergeych.mp_logger.info
|
||||
import net.sergeych.mp_tools.globalLaunch
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
|
||||
private val instances = AtomicCounter()
|
||||
|
||||
/**
|
||||
* The Kiloparsec server.
|
||||
* Server accepts incoming connections and serves them using the same [clientInterface].
|
||||
*
|
||||
* ## Incoming connections
|
||||
*
|
||||
* Server collecting incoming connections provided by [connections] `Flow`. For each incoming connection
|
||||
* the Kiloparsec handshake is performed, then the session object is created, see below, and connection is
|
||||
* served with [clientInterface] until closed.
|
||||
*
|
||||
* ## Session param [S]
|
||||
*
|
||||
* After the successful handshake server creates new session for each connection calling the [sessionBuilder].
|
||||
* Then it creates a [KiloScope] so [KiloScope.session] holds this connection-specific instance. Then
|
||||
* [KiloInterface.onConnected] is called with this scope. Session can be used to hold connection state. Session
|
||||
* objects are not persistent, but could be initialized in [KiloInterface.onConnected] where the remote side
|
||||
* [KiloScope.remoteIdentity] is already verified and set.
|
||||
*
|
||||
* ## Usage:
|
||||
*
|
||||
* Create a shared library between you server and clients, to specify the interface (otherwise you can
|
||||
* share sources).
|
||||
*
|
||||
* Suppose we have session with a state:
|
||||
* ```kotlin
|
||||
* data class Session(
|
||||
* var data: String,
|
||||
* )
|
||||
*```
|
||||
*
|
||||
* And some commands to access and change it, in the shared library too:
|
||||
*
|
||||
* ```kotlin
|
||||
* val cmdSave by command<String, Unit>()
|
||||
* val cmdLoad by command<Unit, String>()
|
||||
* val cmdDrop by command<Unit, Unit>()
|
||||
* val cmdException by command<Unit, Unit>()
|
||||
* ```
|
||||
* Then the server code (TCP/IP variant) could look like:
|
||||
*
|
||||
* ```kotlin
|
||||
* // The server implementation (could be shared between server instances connected
|
||||
* // to different protocol adapters):
|
||||
*
|
||||
* val cli = KiloInterface<Session>().apply {
|
||||
* // Suppose we want to throw this exception at the caller site, so we need to register it:
|
||||
* registerError { SomeException() }
|
||||
*
|
||||
* // Session initialization. If you need a sessino to depend initially on the client's identity.
|
||||
* // you can do it here:
|
||||
* onConnected {
|
||||
* // check the remoteIdentity
|
||||
* session.data = if( remoteIdentity == somePublicVerifyingKey )
|
||||
* "known"
|
||||
* else
|
||||
* "unknown"
|
||||
* }
|
||||
* on(cmdSave) { session.data = it }
|
||||
* on(cmdLoad) {
|
||||
* session.data
|
||||
* }
|
||||
* on(cmdException) {
|
||||
* throw TestException()
|
||||
* }
|
||||
* on(cmdDrop) {
|
||||
* throw LocalInterface.BreakConnectionException()
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Create the server instance that accepts incoming TCP/IP connections on all local interfaces using the
|
||||
* // specified port:
|
||||
*
|
||||
* val server = KiloServer(cli, acceptTcpDevice(port)) {
|
||||
* // This creates a new session
|
||||
* Session("unknown")
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* See [KiloClient] to connect to the server.
|
||||
*
|
||||
* @param S the type of the server session object, returned by [sessionBuilder]. See above
|
||||
* @param clientInterface the interface available for remote calls
|
||||
* @param connections flow of incoming connections. Server stops when the flow is fully collected (normally
|
||||
* it shouldn't)
|
||||
* @param serverSecretKey the [SigningKey] used to identify this server during Kiloparsec handshake.
|
||||
* @param sessionBuilder callback that creates session objects for successful incoming connections
|
||||
*/
|
||||
class KiloServer<S>(
|
||||
private val clientInterface: KiloInterface<S>,
|
||||
private val connections: Flow<InetTransportDevice>,
|
||||
private val serverSecretKey: SigningKey? = null,
|
||||
private val sessionBuilder: () -> S,
|
||||
) : LogTag("KS:${instances.incrementAndGet()}") {
|
||||
|
||||
private val job = globalLaunch {
|
||||
connections.collect { device ->
|
||||
launch {
|
||||
try {
|
||||
info { "connected ${device}" }
|
||||
KiloServerConnection(clientInterface, device, sessionBuilder(), serverSecretKey)
|
||||
.apply { debug { "server connection is ready" } }
|
||||
.run()
|
||||
} catch (_: CancellationException) {
|
||||
} catch (cce: LocalInterface.BreakConnectionException) {
|
||||
info { "Closed exception caught, closing (${cce.flushSendQueue}" }
|
||||
} catch (t: Throwable) {
|
||||
exception { "unexpected while creating kiloclient" to t }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server and cancel all pending sessions. Unlike finishing the flow passed
|
||||
* for [KiloServer.connections], it will cancel all currently active sessions.
|
||||
*/
|
||||
fun close() {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Server is closed either if [close] was called or all [KiloServer.connections] were collected and flow was
|
||||
* closed.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
val isClosed: Boolean get() = job.isCompleted
|
||||
}
|
@ -1,8 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import com.ionspin.kotlin.crypto.keyexchange.KeyExchange
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import net.sergeych.crypto.Key
|
||||
import net.sergeych.crypto2.SafeKeyExchange
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.Loggable
|
||||
import net.sergeych.mp_logger.debug
|
||||
@ -23,15 +33,15 @@ class KiloServerConnection<S>(
|
||||
private val clientInterface: KiloInterface<S>,
|
||||
private val device: Transport.Device,
|
||||
private val session: S,
|
||||
private val serverSigningKey: Key.Signing? = null
|
||||
private val serverSigningKey: SigningKey? = null
|
||||
) : RemoteInterface, Loggable by LogTag("SRV${++serverIds}") {
|
||||
|
||||
/**
|
||||
* Shortcut to construct with [KiloConnectionData] intance
|
||||
*/
|
||||
@Suppress("unused")
|
||||
constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, serverSigningKey: Key.Signing? = null)
|
||||
: this(localInterface, connection.device, connection.session, serverSigningKey)
|
||||
constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, serverSecretKey: SigningKey? = null)
|
||||
: this(localInterface, connection.device, connection.session, serverSecretKey)
|
||||
|
||||
private val kiloRemoteInterface = CompletableDeferred<KiloRemoteInterface<S>>()
|
||||
|
||||
@ -47,9 +57,7 @@ class KiloServerConnection<S>(
|
||||
val l0Interface = KiloL0Interface(clientInterface, deferredParams).apply {
|
||||
var params: KiloParams<S>? = null
|
||||
on(L0Request) {
|
||||
val sk = KeyExchange.serverSessionKeys(
|
||||
pair.publicKey, pair.secretKey, it.publicKey
|
||||
)
|
||||
val sk = pair.serverSessionKey(it.publicKey)
|
||||
|
||||
params = KiloParams(
|
||||
true,
|
||||
@ -59,15 +67,9 @@ class KiloServerConnection<S>(
|
||||
null,
|
||||
this@KiloServerConnection
|
||||
)
|
||||
|
||||
var verifying: Key.Verifying? = null
|
||||
var signature: UByteArray? = null
|
||||
if( serverSigningKey != null ) {
|
||||
verifying = serverSigningKey.verifying
|
||||
signature = serverSigningKey.sign(params!!.token)
|
||||
}
|
||||
Handshake(1u, pair.publicKey, verifying, signature)
|
||||
Handshake(1u, pair.publicKey, serverSigningKey?.seal(params!!.token))
|
||||
}
|
||||
|
||||
on(L0ClientId) {
|
||||
var p = params ?: throw RemoteInterface.ClosedException("wrong handshake sequence")
|
||||
val ci = unpack<ClientIdentity>(p.decrypt(it))
|
||||
@ -83,22 +85,27 @@ class KiloServerConnection<S>(
|
||||
kiloRemoteInterface.complete(
|
||||
KiloRemoteInterface(deferredParams, clientInterface)
|
||||
)
|
||||
clientInterface.onConnectHandlers.invokeAll(p.scope)
|
||||
}
|
||||
}
|
||||
|
||||
val transport = Transport(device, l0Interface, Unit)
|
||||
deferredTransport.complete(transport)
|
||||
kiloRemoteInterface.complete(KiloRemoteInterface(deferredParams,clientInterface))
|
||||
debug { "starintg the transport"}
|
||||
debug { "starting the transport"}
|
||||
transport.run()
|
||||
debug { "server transport finished" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val pair = KeyExchange.keypair()
|
||||
val pair = SafeKeyExchange()
|
||||
}
|
||||
|
||||
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R {
|
||||
return kiloRemoteInterface.await().call(cmd, args)
|
||||
}
|
||||
|
||||
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) {
|
||||
kiloRemoteInterface.await().push(cmd, args)
|
||||
}
|
||||
}
|
@ -1,14 +1,51 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.Loggable
|
||||
import net.sergeych.mp_logger.exception
|
||||
import net.sergeych.mp_logger.info
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
import net.sergeych.utools.firstNonNull
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
private typealias RawCommandHandler<C> = suspend (C, UByteArray) -> UByteArray
|
||||
|
||||
open class LocalInterface<S> {
|
||||
private val idCounter = AtomicCounter()
|
||||
|
||||
open class LocalInterface<S> : Loggable by LogTag("LocalInterface${idCounter.incrementAndGet()}") {
|
||||
|
||||
private val commands = mutableMapOf<String, RawCommandHandler<S>>()
|
||||
|
||||
/**
|
||||
* Instruct the transport to immediately break the connection.
|
||||
* This exception is not passed to the remote end, instead, transport device breaks
|
||||
* connection to remote when receiving it.
|
||||
*
|
||||
* Remote interface will throw [RemoteInterface.ClosedException] as the break will be detected. As reaction time
|
||||
* it depends on the transport in use, we recommend sending some registered exception first if you need
|
||||
* to pass important data, or implement special commands on both sides.
|
||||
*
|
||||
* __Important note:__ _it is not allowed to throw [RemoteInterface.ClosedException] directly!_
|
||||
* This exception is processed internally and can't be sent over the network.
|
||||
*/
|
||||
open class BreakConnectionException(
|
||||
text: String = "break connection request",
|
||||
val flushSendQueue: Boolean = true,
|
||||
) : RuntimeException(text) {
|
||||
override val message: String?
|
||||
get() = super.message + " (flush=$flushSendQueue)"
|
||||
}
|
||||
|
||||
/**
|
||||
* New session creator. Rarely needed directlym it can be used for delegation
|
||||
* of local interfaces.
|
||||
@ -40,14 +77,18 @@ open class LocalInterface<S> {
|
||||
name: String,
|
||||
packedArgs: UByteArray,
|
||||
): UByteArray =
|
||||
(commands[name] ?: throw RemoteInterface.UnknownCommand())
|
||||
(commands[name] ?: throw RemoteInterface.UnknownCommand(name))
|
||||
.invoke(scope, packedArgs)
|
||||
|
||||
|
||||
private val errorByClass = mutableMapOf<KClass<*>, String>()
|
||||
private val errorBuilder = mutableMapOf<String, (String, UByteArray?) -> Throwable>()
|
||||
|
||||
fun <T : Throwable> registerError(
|
||||
/**
|
||||
* Register exception for automatic transmission over the kiloparsec connection using its [klass]
|
||||
* and possibly override code. In most cases it is simpler to use [registerError].
|
||||
*/
|
||||
fun <T : Throwable> registerErrorClass(
|
||||
klass: KClass<T>, code: String = klass.simpleName!!,
|
||||
exceptionBuilder: (String, UByteArray?) -> T,
|
||||
) {
|
||||
@ -55,10 +96,17 @@ open class LocalInterface<S> {
|
||||
errorBuilder[code] = exceptionBuilder
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an exception to be transmitted automatically over the kiloparsec connection.
|
||||
* Example:
|
||||
* ```kotlin
|
||||
* localInterface.registerError { IllegalArgumentException(it) }
|
||||
* ```
|
||||
*/
|
||||
inline fun <reified T : Throwable> registerError(
|
||||
noinline exceptionBuilder: (String) -> T,
|
||||
) {
|
||||
registerError(T::class) { msg, _ -> exceptionBuilder(msg) }
|
||||
registerErrorClass(T::class) { msg, _ -> exceptionBuilder(msg) }
|
||||
}
|
||||
|
||||
val errorProviders = mutableListOf<LocalInterface<*>>()
|
||||
@ -68,18 +116,26 @@ open class LocalInterface<S> {
|
||||
}
|
||||
|
||||
fun getErrorCode(t: Throwable): String? =
|
||||
errorByClass[t::class] ?: errorProviders.firstNonNull { it.getErrorCode(t) }
|
||||
(t as? ExceptionWithCode)?.code
|
||||
?: errorByClass[t::class] ?: errorProviders.firstNonNull { it.getErrorCode(t) }
|
||||
|
||||
fun encodeError(forId: UInt, t: Throwable): Transport.Block.Error =
|
||||
getErrorCode(t)?.let { Transport.Block.Error(forId, it, t.message) }
|
||||
?: Transport.Block.Error(forId, "UnknownError", t.message)
|
||||
if (t is RemoteInterface.ClosedException) {
|
||||
exception { "Illegal attempt to send ClosedException" to t }
|
||||
encodeError(forId, RemoteInterface.InternalError("TCE"))
|
||||
}
|
||||
else
|
||||
getErrorCode(t)?.let { Transport.Block.Error(forId, it, t.message) }
|
||||
?: Transport.Block.Error(forId, "UnknownError", "${t::class.simpleName}: ${t.message}")
|
||||
|
||||
open fun getErrorBuilder(code: String): ((String, UByteArray?) -> Throwable)? =
|
||||
errorBuilder[code] ?: errorProviders.firstNonNull { it.getErrorBuilder(code) }
|
||||
|
||||
fun decodeError(tbe: Transport.Block.Error): Throwable =
|
||||
getErrorBuilder(tbe.code)?.invoke(tbe.message, tbe.extra)
|
||||
?: RemoteInterface.RemoteException(tbe)
|
||||
?: RemoteInterface.RemoteException(tbe).also {
|
||||
info { "can't decode error ${tbe.code}: ${tbe.message}" }
|
||||
}
|
||||
|
||||
fun decodeAndThrow(tbe: Transport.Block.Error): Nothing {
|
||||
throw decodeError(tbe)
|
||||
|
@ -1,3 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
/**
|
||||
@ -15,11 +25,12 @@ interface RemoteInterface {
|
||||
|
||||
/**
|
||||
* Is thrown when the channel is closed, in an attempt to execute a command, also to all pending
|
||||
* calls (see [call]).
|
||||
* calls (see [call]). Client code should never throw it. If command handler needs to break connection
|
||||
* it should throw [LocalInterface.BreakConnectionException]
|
||||
*/
|
||||
open class ClosedException(t: String = "connection is closed") : Exception(t)
|
||||
|
||||
open class SecurityException(t: String = "invalid remote id and signature") : ClosedException(t)
|
||||
open class SecurityException(t: String = "invalid remote id and signature") : LocalInterface.BreakConnectionException(t)
|
||||
|
||||
|
||||
open class InvalidDataException(msg: String="invalid data, can't unpack") : Exception(msg)
|
||||
@ -39,12 +50,37 @@ interface RemoteInterface {
|
||||
/**
|
||||
* Command is not supported by the remote party
|
||||
*/
|
||||
class UnknownCommand : RemoteException("UnknownCommand")
|
||||
class UnknownCommand(commandName: String) : RemoteException("UnknownCommand: $commandName")
|
||||
|
||||
open class InternalError(code: String="0"): RemoteException("Internal error: $code")
|
||||
|
||||
suspend fun <R> call(cmd: Command<Unit, R>): R = call(cmd, Unit)
|
||||
|
||||
/**
|
||||
* Call the remote procedure with specified args and return its result
|
||||
* Call the remote procedure with specified [args] and return its result of type [R]. The calling coroutine
|
||||
* suspends until the request is performed on the remote side and the result value (also `Unit`) will be received.
|
||||
*
|
||||
* When it is not necessary to wait for the return value and/or command execution, it is recommended to
|
||||
* use [push] instead.
|
||||
*
|
||||
* @throws RemoteException if the execution caused exception on the remote size
|
||||
* @throws Exception for registered exceptions, see [LocalInterface.registerError], etc.
|
||||
*/
|
||||
suspend fun <A, R> call(cmd: Command<A, R>, args: A): R
|
||||
|
||||
/**
|
||||
* Push the notification without waiting for reception or processing.
|
||||
* It returns immediately after sending data to the transport (e.g., to the network).
|
||||
* Use [call] if it is necessary to wait until the command will be received and processed by the remote.
|
||||
*
|
||||
* Push is onlu available for commands without returned value.
|
||||
*/
|
||||
suspend fun <A> push(cmd: Command<A, Unit>, args: A)
|
||||
|
||||
/**
|
||||
* Push the command with no args.
|
||||
* It returns immediately after sending data to the transport (e.g., to the network).
|
||||
* Use [call] if it is necessary to wait until the command will be received and processed by the remote.
|
||||
*/
|
||||
suspend fun push(cmd: Command<Unit,Unit>) = push(cmd,Unit)
|
||||
}
|
@ -1,6 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.channels.ClosedSendChannelException
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@ -13,7 +25,8 @@ import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.serializer
|
||||
import net.sergeych.crypto.toDump
|
||||
import net.sergeych.bipack.Unsigned
|
||||
import net.sergeych.crypto2.toDump
|
||||
import net.sergeych.kiloparsec.Transport.Device
|
||||
import net.sergeych.mp_logger.*
|
||||
import net.sergeych.utools.pack
|
||||
@ -41,7 +54,7 @@ class Transport<S>(
|
||||
* Input blocks. When the device is disconnected, it should send one null to this channel
|
||||
* to notify the owner. When [close] is called, the channel should be closed.
|
||||
*/
|
||||
val input: ReceiveChannel<UByteArray?>
|
||||
val input: ReceiveChannel<UByteArray>
|
||||
|
||||
/**
|
||||
* Send a binary block to a remote party where it should be received and put into [input]
|
||||
@ -54,12 +67,19 @@ class Transport<S>(
|
||||
* possible. This method must not throw exceptions.
|
||||
*/
|
||||
suspend fun close()
|
||||
|
||||
suspend fun flush() {}
|
||||
}
|
||||
|
||||
@Serializable(TransportBlockSerializer::class)
|
||||
sealed class Block {
|
||||
@Serializable
|
||||
data class Call(val id: UInt, val name: String, val packedArgs: UByteArray) : Block() {
|
||||
data class Call(
|
||||
@Unsigned
|
||||
val id: UInt,
|
||||
val name: String,
|
||||
val packedArgs: UByteArray
|
||||
) : Block() {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Call) return false
|
||||
@ -79,10 +99,10 @@ class Transport<S>(
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Response(val forId: UInt, val packedResult: UByteArray) : Block()
|
||||
data class Response(@Unsigned val forId: UInt, val packedResult: UByteArray) : Block()
|
||||
|
||||
@Serializable
|
||||
data class Error(val forId: UInt, val code: String, val text: String? = null, val extra: UByteArray? = null) :
|
||||
data class Error(@Unsigned val forId: UInt, val code: String, val text: String? = null, val extra: UByteArray? = null) :
|
||||
Block() {
|
||||
val message by lazy { text ?: "remote exception: $code" }
|
||||
}
|
||||
@ -94,7 +114,8 @@ class Transport<S>(
|
||||
var isClosed: Boolean = false
|
||||
|
||||
/**
|
||||
* Send a call block for a command and packed args and return packed result if it is not an error
|
||||
* Send a call block for a command and packed args and return packed result if it is not an error. It suspends
|
||||
* until receiving answer from the remote side, even if returns `Unit`.
|
||||
* @throws RemoteInterface.RemoteException if the remote call caused an exception. Normally use [call] instead.
|
||||
* @throws RemoteInterface.ClosedException
|
||||
*/
|
||||
@ -107,18 +128,46 @@ class Transport<S>(
|
||||
// We need to shield calls and lastID with mutex, but nothing more:
|
||||
access.withLock {
|
||||
if (isClosed) throw RemoteInterface.ClosedException()
|
||||
// the order is important: first id in use MUST BE >= 1, not zero:
|
||||
b = Block.Call(++lastId, name, packedArgs)
|
||||
calls[b.id] = deferred
|
||||
}
|
||||
|
||||
// now we have mutex freed so we can call:
|
||||
val r = device.output.trySend(pack(b).also { debug { ">>>\n${it.toDump()}" } })
|
||||
if (!r.isSuccess) deferred.completeExceptionally(RemoteInterface.ClosedException())
|
||||
val r = runCatching { device.output.send(pack(b)) }
|
||||
if (!r.isSuccess) {
|
||||
r.exceptionOrNull()?.let {
|
||||
exception { "failed to send output block" to it }
|
||||
} ?: run {
|
||||
error { "It should not happen: empty exception on block send failure" }
|
||||
throw RuntimeException("unexpected failure in sending block")
|
||||
}
|
||||
deferred.completeExceptionally(RemoteInterface.ClosedException())
|
||||
}
|
||||
|
||||
// it returns packed result or throws a proper error:
|
||||
return deferred.await()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a call block for a command and packed args and return packed result if it is not an error. It suspends
|
||||
* until receiving answer from the remote side, even if returns `Unit`.
|
||||
* @throws RemoteInterface.RemoteException if the remote call caused an exception. Normally use [call] instead.
|
||||
* @throws RemoteInterface.ClosedException
|
||||
*/
|
||||
private suspend fun sendPushBlock(name: String, packedArgs: UByteArray) {
|
||||
if (isClosed) throw RemoteInterface.ClosedException()
|
||||
|
||||
// All push blocks have the same id == 0:
|
||||
val b = Block.Call(0u, name, packedArgs)
|
||||
val r = runCatching { device.output.send(pack(b).also { debug { ">>$\n${it.toDump()}" } }) }
|
||||
when(val e = r.exceptionOrNull()) {
|
||||
is RemoteInterface.ClosedException, is CancellationException, is RemoteInterface.RemoteException
|
||||
-> throw e
|
||||
else -> throw RemoteInterface.ClosedException()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the remote procedure with specified args and return its result
|
||||
*/
|
||||
@ -127,6 +176,10 @@ class Transport<S>(
|
||||
return unpack(cmd.resultSerializer, result)
|
||||
}
|
||||
|
||||
override suspend fun <A>push(cmd: Command<A,Unit>,args: A) {
|
||||
sendPushBlock(cmd.name, pack(cmd.argsSerializer, args))
|
||||
}
|
||||
|
||||
/**
|
||||
* Start running the transport. This function suspends until the transport is closed
|
||||
* normally or by error. If you need to cancel it prematurely, cancel the coroutine
|
||||
@ -137,68 +190,84 @@ class Transport<S>(
|
||||
suspend fun run() {
|
||||
coroutineScope {
|
||||
debug { "awaiting incoming blocks" }
|
||||
// todo: rewrite it to close the job with no exceptions at all, always
|
||||
while (isActive && !isClosed) {
|
||||
try {
|
||||
device.input.receive()?.let { packed ->
|
||||
debug { "<<<\n${packed.toDump()}" }
|
||||
device.input.receive().let { packed ->
|
||||
val b = unpack<Block>(packed)
|
||||
debug { "<<$ $b" }
|
||||
debug { "access state: ${access.isLocked}" }
|
||||
when (b) {
|
||||
is Block.Error -> access.withLock {
|
||||
val error = localInterface.decodeError(b)
|
||||
warning { "decoded error: ${error::class.simpleName}: $error" }
|
||||
calls.remove(b.forId)?.completeExceptionally(localInterface.decodeError(b))
|
||||
?: warning { "error handler not found for ${b.forId}" }
|
||||
info { "error processed"}
|
||||
info { "error processed" }
|
||||
}
|
||||
|
||||
is Block.Response -> access.withLock {
|
||||
calls.remove(b.forId)?.let {
|
||||
debug { "activating wait handle for ${b.forId}" }
|
||||
it.complete(b.packedResult)
|
||||
}
|
||||
calls.remove(b.forId)?.complete(b.packedResult)
|
||||
?: warning { "wait handle not found for ${b.forId}" }
|
||||
}
|
||||
|
||||
is Block.Call -> launch {
|
||||
try {
|
||||
send(
|
||||
Block.Response(
|
||||
b.id,
|
||||
localInterface.execute(commandContext, b.name, b.packedArgs)
|
||||
if (b.id == 0u)
|
||||
// Command does not waits return
|
||||
localInterface.execute(commandContext, b.name, b.packedArgs)
|
||||
else
|
||||
send(
|
||||
Block.Response(
|
||||
b.id,
|
||||
localInterface.execute(commandContext, b.name, b.packedArgs)
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (x: RemoteInterface.ClosedException) {
|
||||
// strange case: handler throws closed?
|
||||
error { "not supported: command handler for $b has thrown ClosedException" }
|
||||
send(Block.Error(b.id, "UnexpectedException", x.message))
|
||||
} catch (x: LocalInterface.BreakConnectionException) {
|
||||
// handler forced close
|
||||
warning { "handler requested closing of the connection (${x.flushSendQueue}" }
|
||||
isClosed = true
|
||||
if (x.flushSendQueue) device.flush()
|
||||
device.close()
|
||||
} catch (x: RemoteInterface.RemoteException) {
|
||||
send(Block.Error(b.id, x.code, x.text, x.extra))
|
||||
} catch (t: Throwable) {
|
||||
send(Block.Error(b.id, "UnknownError", t.message))
|
||||
}
|
||||
.also { debug { "command executed: ${b.name}" } }
|
||||
}
|
||||
}
|
||||
} ?: run {
|
||||
debug { "remote channel close received" }
|
||||
isClosed = true
|
||||
}
|
||||
// debug { "input step performed closed=$isClosed active=$isActive" }
|
||||
} catch (_: ClosedSendChannelException) {
|
||||
info { "closed send channel" }
|
||||
isClosed = true
|
||||
} catch (_: ClosedReceiveChannelException) {
|
||||
info { "closed receive channel" }
|
||||
isClosed = true
|
||||
} catch (cce: LocalInterface.BreakConnectionException) {
|
||||
info { "closing connection by local request ($cce)" }
|
||||
device.close()
|
||||
} catch (t: RemoteInterface.ClosedException) {
|
||||
// it is ok: we just exit the coroutine normally
|
||||
// and mark we're closing
|
||||
isClosed = true
|
||||
} catch (_: CancellationException) {
|
||||
info { "loop is cancelled" }
|
||||
info { "loop is cancelled with CancellationException" }
|
||||
isClosed = true
|
||||
} catch (t: Throwable) {
|
||||
exception { "channel closed on error" to t }
|
||||
info { "isa? $isActive / $isClosed" }
|
||||
runCatching { device.close() }
|
||||
isClosed = true
|
||||
}
|
||||
}
|
||||
debug { "leaving transport loop" }
|
||||
access.withLock {
|
||||
debug { "access lock obtained" }
|
||||
isClosed = true
|
||||
for (c in calls.values) c.completeExceptionally(RemoteInterface.ClosedException())
|
||||
debug { "closing device $device, calls in queue ${calls.size}" }
|
||||
runCatching { device.close() }
|
||||
for (c in calls.values)
|
||||
c.completeExceptionally(RemoteInterface.ClosedException())
|
||||
calls.clear()
|
||||
debug { "calls clear has been called" }
|
||||
}
|
||||
debug { "no more active: $isActive / ${calls.size}" }
|
||||
}
|
||||
@ -206,7 +275,11 @@ class Transport<S>(
|
||||
}
|
||||
|
||||
private suspend fun send(block: Block) {
|
||||
device.output.send(pack(block))
|
||||
try {
|
||||
device.output.send(pack(block))
|
||||
} catch (_: ClosedSendChannelException) {
|
||||
throw RemoteInterface.ClosedException()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -235,7 +308,7 @@ object TransportBlockSerializer : KSerializer<Transport.Block> {
|
||||
|
||||
|
||||
override fun deserialize(decoder: Decoder): Transport.Block =
|
||||
when( val id = decoder.decodeByte().toInt()) {
|
||||
when (val id = decoder.decodeByte().toInt()) {
|
||||
0 -> decoder.decodeSerializableValue(serializer<Transport.Block.Call>())
|
||||
1 -> decoder.decodeSerializableValue(serializer<Transport.Block.Error>())
|
||||
2 -> decoder.decodeSerializableValue(serializer<Transport.Block.Response>())
|
||||
|
@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
||||
/**
|
||||
* Transport device for inet protocol family with graceful shutdown on [close]
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class InetTransportDevice(
|
||||
inputChannel: Channel<UByteArray>,
|
||||
outputChannel: Channel<UByteArray>,
|
||||
val remoteAddress: NetworkAddress,
|
||||
doClose: (suspend ()->Unit)? = null,
|
||||
doFlush: (suspend ()->Unit)? = null,
|
||||
) : ProxyDevice(inputChannel, outputChannel, doClose, doFlush) {
|
||||
override fun toString(): String = "@$remoteAddress"
|
||||
}
|
@ -1,37 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
|
||||
/**
|
||||
* Multiplatform implementation of an internet address.
|
||||
* Notice to implementors. It must provide correct and effective [equals] and [hashCode].
|
||||
* Multiplatform internet address.
|
||||
*/
|
||||
interface NetworkAddress {
|
||||
val host: String
|
||||
data class NetworkAddress(
|
||||
val host: String,
|
||||
val port: Int
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplatform datagram abstraction
|
||||
*/
|
||||
interface Datagram {
|
||||
val message: UByteArray
|
||||
val address: NetworkAddress
|
||||
suspend fun respondWith(message: UByteArray)
|
||||
}
|
||||
interface DatagramReceiver {
|
||||
|
||||
val incoming: ReceiveChannel<Datagram>
|
||||
suspend fun send(message: UByteArray, networkAddress: NetworkAddress)
|
||||
@Suppress("unused")
|
||||
suspend fun send(message: UByteArray, datagramAddress: String) {
|
||||
send(message, networkAddressOf(datagramAddress))
|
||||
) {
|
||||
override fun toString(): String {
|
||||
return "$host:$port"
|
||||
}
|
||||
|
||||
suspend fun send(message: UByteArray,host: String,port: Int) =
|
||||
send(message, NetworkAddress(host,port))
|
||||
fun close()
|
||||
}
|
||||
|
||||
expect fun networkAddressOf(address: String): NetworkAddress
|
||||
expect fun NetworkAddress(host: String,port: Int): NetworkAddress
|
||||
|
@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
import kotlinx.coroutines.delay
|
||||
import net.sergeych.kiloparsec.Transport
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
|
||||
private val counter = AtomicCounter()
|
||||
open class ProxyDevice(
|
||||
private val inputChannel: Channel<UByteArray>,
|
||||
private val outputChannel: Channel<UByteArray>,
|
||||
private val doClose: (suspend ()->Unit)? = null,
|
||||
private val doFlush: (suspend ()->Unit)? = null,
|
||||
): Transport.Device {
|
||||
|
||||
override val input: ReceiveChannel<UByteArray> = inputChannel
|
||||
override val output: SendChannel<UByteArray> = outputChannel
|
||||
|
||||
override suspend fun close() {
|
||||
kotlin.runCatching { doClose?.invoke() }
|
||||
runCatching { inputChannel.close() }
|
||||
runCatching { outputChannel.close() }
|
||||
}
|
||||
|
||||
override suspend fun flush() {
|
||||
doFlush?.invoke()
|
||||
var cnt = 10
|
||||
while(!outputChannel.isEmpty) {
|
||||
if (cnt-- < 0) break
|
||||
delay(50)
|
||||
}
|
||||
super.flush()
|
||||
}
|
||||
|
||||
private val id = counter.incrementAndGet()
|
||||
|
||||
override fun toString(): String = "PX$id"
|
||||
}
|
@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.websocket.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.websocket.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.channels.ClosedSendChannelException
|
||||
import kotlinx.coroutines.launch
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.kiloparsec.*
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.exception
|
||||
import net.sergeych.mp_logger.info
|
||||
import net.sergeych.mp_logger.warning
|
||||
import net.sergeych.mp_tools.globalLaunch
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
|
||||
private val counter = AtomicCounter()
|
||||
|
||||
/**
|
||||
* Shortcut to create websocket client. Use [websocketTransportDevice] with [KiloClient]
|
||||
* for fine-grained control.
|
||||
*/
|
||||
fun <S> websocketClient(
|
||||
path: String,
|
||||
clientInterface: KiloInterface<S> = KiloInterface(),
|
||||
secretKey: SigningKey? = null,
|
||||
sessionMaker: () -> S = {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
Unit as S
|
||||
},
|
||||
): KiloClient<S> {
|
||||
return KiloClient(clientInterface, secretKey) {
|
||||
KiloConnectionData(websocketTransportDevice(path), sessionMaker())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create kilopaarsec transport over websocket (ws or wss).
|
||||
* @param path websocket path (must start with ws:// or wss:// and contain a path part)
|
||||
* @client use default [HttpClient], it installs [WebSockets] plugin
|
||||
*/
|
||||
fun websocketTransportDevice(
|
||||
path: String,
|
||||
client: HttpClient = HttpClient { install(WebSockets) },
|
||||
): Transport.Device {
|
||||
var u = Url(path)
|
||||
if (u.encodedPath.length <= 1)
|
||||
u = URLBuilder(u).apply {
|
||||
encodedPath = "/kp"
|
||||
}.build()
|
||||
|
||||
val input = Channel<UByteArray>()
|
||||
val output = Channel<UByteArray>()
|
||||
val closeHandle = CompletableDeferred<Boolean>()
|
||||
globalLaunch {
|
||||
val log = LogTag("KC:${counter.incrementAndGet()}")
|
||||
client.webSocket({
|
||||
url.protocol = u.protocol
|
||||
url.host = u.host
|
||||
url.port = u.port
|
||||
url.encodedPath = u.encodedPath
|
||||
url.parameters.appendAll(u.parameters)
|
||||
log.info { "kiloparsec server URL: $url" }
|
||||
}) {
|
||||
log.info { "connected to the server" }
|
||||
// println("SENDING!!!")
|
||||
// send("Helluva")
|
||||
launch {
|
||||
try {
|
||||
for (block in output) {
|
||||
send(block.toByteArray())
|
||||
}
|
||||
log.info { "input is closed, closing the websocket" }
|
||||
if (closeHandle.isActive) closeHandle.complete(true)
|
||||
} catch (_: ClosedSendChannelException) {
|
||||
log.info { "send channel closed" }
|
||||
} catch (_: CancellationException) {
|
||||
} catch (t: Throwable) {
|
||||
log.info { "unexpected exception in websock sender: ${t.stackTraceToString()}" }
|
||||
closeHandle.completeExceptionally(t)
|
||||
}
|
||||
if (closeHandle.isActive) closeHandle.complete(false)
|
||||
}
|
||||
launch {
|
||||
try {
|
||||
for (f in incoming) {
|
||||
if (f is Frame.Binary) {
|
||||
input.send(f.readBytes().toUByteArray())
|
||||
} else {
|
||||
log.warning { "ignoring unexpected frame of type ${f.frameType}" }
|
||||
}
|
||||
}
|
||||
if (closeHandle.isActive) closeHandle.complete(true)
|
||||
} catch (_: CancellationException) {
|
||||
if (closeHandle.isActive) closeHandle.complete(false)
|
||||
} catch (_: ClosedReceiveChannelException) {
|
||||
log.warning { "receive channel closed unexpectedly" }
|
||||
if (closeHandle.isActive) closeHandle.complete(false)
|
||||
} catch (t: Throwable) {
|
||||
log.exception { "unexpected error" to t }
|
||||
if (closeHandle.isActive) closeHandle.complete(false)
|
||||
}
|
||||
}
|
||||
if (!closeHandle.await()) {
|
||||
log.warning { "Client is closing with error" }
|
||||
throw RemoteInterface.ClosedException()
|
||||
}
|
||||
output.close()
|
||||
input.close()
|
||||
}
|
||||
log.info { "closing connection" }
|
||||
}
|
||||
val device = ProxyDevice(input, output) {
|
||||
// we need to explicitly close the coroutine job, or it can hang for a long time
|
||||
// leaking resources.
|
||||
closeHandle.complete(true)
|
||||
// job.cancel()
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
@ -1,16 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.sergeych.crypto.Key
|
||||
import net.sergeych.crypto2.SafeKeyExchange
|
||||
import net.sergeych.crypto2.Seal
|
||||
import net.sergeych.crypto2.VerifyingPublicKey
|
||||
|
||||
// L0 commands - key exchange and check:
|
||||
@Serializable
|
||||
data class Handshake(val version: UInt, val publicKey: UByteArray,
|
||||
val serverSharedKey: Key.Verifying? = null,
|
||||
val signature: UByteArray? = null)
|
||||
data class Handshake(val version: UInt, val publicKey: SafeKeyExchange.PublicKey,
|
||||
val signature: Seal? = null)
|
||||
|
||||
@Serializable
|
||||
data class ClientIdentity(val clientIdKey: Key.Verifying?, val signature: UByteArray?)
|
||||
data class ClientIdentity(val clientIdKey: VerifyingPublicKey?, val signature: UByteArray?)
|
||||
|
||||
// Level 0 command: request key exchange
|
||||
internal val L0Request by command<Handshake, Handshake>()
|
||||
|
34
src/commonMain/kotlin/net/sergeych/kiloparsec/tools.kt
Normal file
34
src/commonMain/kotlin/net/sergeych/kiloparsec/tools.kt
Normal file
@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
fun String.encodeToUByteArray() =
|
||||
encodeToByteArray().toUByteArray()
|
||||
|
||||
class SyncValue<T>(initialValue: T) {
|
||||
private val access = Mutex()
|
||||
|
||||
var value = initialValue
|
||||
private set
|
||||
|
||||
suspend fun mutate(f: suspend (T)->T): T = access.withLock { f(value).also { value = it } }
|
||||
|
||||
@Suppress("unused")
|
||||
suspend fun getAndSet(newValue: T): T = mutate {
|
||||
val old = value
|
||||
value = newValue
|
||||
old
|
||||
}
|
||||
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
package net.sergeych.tools
|
||||
|
||||
/**
|
||||
* Multiplatform interface to perform a regular (not suspend) operation
|
||||
* protected by a platform mutex (where necessary). Get real implementation
|
||||
* with [ProtectedOp]
|
||||
*/
|
||||
interface ProtectedOpImplementation {
|
||||
/**
|
||||
* Call [f] iin mutually exclusive mode, it means that only one invocation
|
||||
* can be active at a time, all the rest are waiting until the current operation
|
||||
* will finish.
|
||||
*/
|
||||
operator fun <T>invoke(f: ()->T): T
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the platform-depended implementation of a mutex-protected operation.
|
||||
*/
|
||||
expect fun ProtectedOp(): ProtectedOpImplementation
|
@ -1,12 +0,0 @@
|
||||
package net.sergeych.utools
|
||||
|
||||
/**
|
||||
* Scan the collection and return the first non-null result of the [predicate] on it.
|
||||
* If all the elements give null with predicate call, returns null.
|
||||
*
|
||||
* Note that collection is scanned only to the first non-null predicate result.
|
||||
*/
|
||||
fun <T,R>Collection<T>.firstNonNull(predicate: (T)->R?): R? {
|
||||
for( x in this ) predicate(x)?.let { return it }
|
||||
return null
|
||||
}
|
@ -1,46 +0,0 @@
|
||||
package net.sergeych.utools
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.serializer
|
||||
import net.sergeych.bintools.toDataSource
|
||||
import net.sergeych.bipack.BipackDecoder
|
||||
import net.sergeych.bipack.BipackEncoder
|
||||
|
||||
/**
|
||||
* Effectively pack anyk nullable object. The result could be effectively packed
|
||||
* in turn as a part of a more complex structure.
|
||||
*
|
||||
* To avoid packing non-null mark,
|
||||
* we use a zero-size array, which, if in turn encoded, packs into a single
|
||||
* zero byte. Thus, we avoid extra byte spending for unnecessary null
|
||||
* check.
|
||||
*/
|
||||
inline fun <reified T> pack(element: T?): UByteArray = pack(serializer<T>(), element)
|
||||
|
||||
/**
|
||||
* Unpack nullable data packed with [pack]
|
||||
*/
|
||||
inline fun <reified T: Any?> unpack(encoded: UByteArray): T =
|
||||
unpack(serializer<T>(), encoded)
|
||||
|
||||
/**
|
||||
* Effectively pack anyk nullable object. The result could be effectively packed
|
||||
* in turn as a part of a more complex structure.
|
||||
*
|
||||
* To avoid packing non-null mark,
|
||||
* we use a zero-size array, which, if in turn encoded, packs into a single
|
||||
* zero byte. Thus, we avoid extra byte spending for unnecessary null
|
||||
* check.
|
||||
*/
|
||||
fun <T>pack(serializer: KSerializer<T>, element: T?): UByteArray =
|
||||
if (element == null) ubyteArrayOf()
|
||||
else BipackEncoder.encode(serializer,element).toUByteArray()
|
||||
|
||||
|
||||
/**
|
||||
* Unpack nullable data packed with [pack]
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T: Any?> unpack(serializer: KSerializer<T>, encoded: UByteArray): T =
|
||||
if (encoded.isEmpty()) null as T
|
||||
else BipackDecoder.decode(encoded.toByteArray().toDataSource(),serializer)
|
@ -1,12 +0,0 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package net.sergeych.utools
|
||||
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
fun now(): Instant = Clock.System.now()
|
||||
fun nowToSeconds(): Instant = Clock.System.now().truncateToSeconds()
|
||||
|
||||
fun Instant.truncateToSeconds(): Instant =
|
||||
Instant.fromEpochSeconds(toEpochMilliseconds()/1000)
|
@ -1,183 +0,0 @@
|
||||
package org.komputing.khash.keccak
|
||||
|
||||
import com.ionspin.kotlin.bignum.integer.BigInteger
|
||||
import org.komputing.khash.keccak.extensions.fillWith
|
||||
import kotlin.math.min
|
||||
|
||||
object Keccak {
|
||||
|
||||
private val BIT_65 = BigInteger.ONE shl (64)
|
||||
private val MAX_64_BITS = BIT_65 - BigInteger.ONE
|
||||
|
||||
fun digest(value: ByteArray, parameter: KeccakParameter): ByteArray {
|
||||
val uState = IntArray(200)
|
||||
val uMessage = convertToUInt(value)
|
||||
|
||||
var blockSize = 0
|
||||
var inputOffset = 0
|
||||
|
||||
// Absorbing phase
|
||||
while (inputOffset < uMessage.size) {
|
||||
blockSize = min(uMessage.size - inputOffset, parameter.rateInBytes)
|
||||
for (i in 0 until blockSize) {
|
||||
uState[i] = uState[i] xor uMessage[i + inputOffset]
|
||||
}
|
||||
|
||||
inputOffset += blockSize
|
||||
|
||||
if (blockSize == parameter.rateInBytes) {
|
||||
doF(uState)
|
||||
blockSize = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Padding phase
|
||||
uState[blockSize] = uState[blockSize] xor parameter.d
|
||||
if (parameter.d and 0x80 != 0 && blockSize == parameter.rateInBytes - 1) {
|
||||
doF(uState)
|
||||
}
|
||||
|
||||
uState[parameter.rateInBytes - 1] = uState[parameter.rateInBytes - 1] xor 0x80
|
||||
doF(uState)
|
||||
|
||||
// Squeezing phase
|
||||
val byteResults = mutableListOf<Byte>()
|
||||
var tOutputLen = parameter.outputLengthInBytes
|
||||
while (tOutputLen > 0) {
|
||||
blockSize = min(tOutputLen, parameter.rateInBytes)
|
||||
for (i in 0 until blockSize) {
|
||||
byteResults.add(uState[i].toByte().toInt().toByte())
|
||||
}
|
||||
|
||||
tOutputLen -= blockSize
|
||||
if (tOutputLen > 0) {
|
||||
doF(uState)
|
||||
}
|
||||
}
|
||||
|
||||
return byteResults.toByteArray()
|
||||
}
|
||||
|
||||
private fun doF(uState: IntArray) {
|
||||
val lState = Array(5) { Array(5) { BigInteger.ZERO } }
|
||||
|
||||
for (i in 0..4) {
|
||||
for (j in 0..4) {
|
||||
val data = IntArray(8)
|
||||
val index = 8 * (i + 5 * j)
|
||||
uState.copyInto(data, 0, index, index + data.size)
|
||||
lState[i][j] = convertFromLittleEndianTo64(data)
|
||||
}
|
||||
}
|
||||
roundB(lState)
|
||||
|
||||
uState.fillWith(0)
|
||||
for (i in 0..4) {
|
||||
for (j in 0..4) {
|
||||
val data = convertFrom64ToLittleEndian(lState[i][j])
|
||||
data.copyInto(uState, 8 * (i + 5 * j))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permutation on the given state.
|
||||
*/
|
||||
private fun roundB(state: Array<Array<BigInteger>>) {
|
||||
var lfsrState = 1
|
||||
for (round in 0..23) {
|
||||
val c = arrayOfNulls<BigInteger>(5)
|
||||
val d = arrayOfNulls<BigInteger>(5)
|
||||
|
||||
// θ step
|
||||
for (i in 0..4) {
|
||||
c[i] = state[i][0].xor(state[i][1]).xor(state[i][2]).xor(state[i][3]).xor(state[i][4])
|
||||
}
|
||||
|
||||
for (i in 0..4) {
|
||||
d[i] = c[(i + 4) % 5]!!.xor(c[(i + 1) % 5]!!.leftRotate64(1))
|
||||
}
|
||||
|
||||
for (i in 0..4) {
|
||||
for (j in 0..4) {
|
||||
state[i][j] = state[i][j].xor(d[i]!!)
|
||||
}
|
||||
}
|
||||
|
||||
// ρ and π steps
|
||||
var x = 1
|
||||
var y = 0
|
||||
var current = state[x][y]
|
||||
for (i in 0..23) {
|
||||
val tX = x
|
||||
x = y
|
||||
y = (2 * tX + 3 * y) % 5
|
||||
|
||||
val shiftValue = current
|
||||
current = state[x][y]
|
||||
|
||||
state[x][y] = shiftValue.leftRotate64Safely((i + 1) * (i + 2) / 2)
|
||||
}
|
||||
|
||||
// χ step
|
||||
for (j in 0..4) {
|
||||
val t = arrayOfNulls<BigInteger>(5)
|
||||
for (i in 0..4) {
|
||||
t[i] = state[i][j]
|
||||
}
|
||||
|
||||
for (i in 0..4) {
|
||||
// ~t[(i + 1) % 5]
|
||||
val invertVal = t[(i + 1) % 5]!!.xor(MAX_64_BITS)
|
||||
// t[i] ^ ((~t[(i + 1) % 5]) & t[(i + 2) % 5])
|
||||
state[i][j] = t[i]!!.xor(invertVal.and(t[(i + 2) % 5]!!))
|
||||
}
|
||||
}
|
||||
|
||||
// ι step
|
||||
for (i in 0..6) {
|
||||
lfsrState = (lfsrState shl 1 xor (lfsrState shr 7) * 0x71) % 256
|
||||
// pow(2, i) - 1
|
||||
val bitPosition = (1 shl i) - 1
|
||||
if (lfsrState and 2 != 0) {
|
||||
state[0][0] = state[0][0].xor(BigInteger.ONE shl bitPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the given [data] array to an [IntArray] containing UInt values.
|
||||
*/
|
||||
private fun convertToUInt(data: ByteArray) = IntArray(data.size) {
|
||||
data[it].toInt() and 0xFF
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the given [data] array containing the little endian representation of a number to a [BigInteger].
|
||||
*/
|
||||
private fun convertFromLittleEndianTo64(data: IntArray): BigInteger {
|
||||
val value = data.map { it.toString(16) }
|
||||
.map { if (it.length == 2) it else "0$it" }
|
||||
.reversed()
|
||||
.joinToString("")
|
||||
return BigInteger.parseString(value, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the given [BigInteger] to a little endian representation as an [IntArray].
|
||||
*/
|
||||
private fun convertFrom64ToLittleEndian(uLong: BigInteger): IntArray {
|
||||
val asHex = uLong.toString(16)
|
||||
val asHexPadded = "0".repeat((8 * 2) - asHex.length) + asHex
|
||||
return IntArray(8) {
|
||||
((7 - it) * 2).let { pos ->
|
||||
asHexPadded.substring(pos, pos + 2).toInt(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun BigInteger.leftRotate64Safely(rotate: Int) = leftRotate64(rotate % 64)
|
||||
|
||||
private fun BigInteger.leftRotate64(rotate: Int) = (this shr (64 - rotate)).add(this shl rotate).mod(BIT_65)
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package org.komputing.khash.keccak
|
||||
|
||||
/**
|
||||
* Parameters defining the FIPS 202 standard.
|
||||
*/
|
||||
enum class KeccakParameter(val rateInBytes: Int,val outputLengthInBytes: Int, val d: Int) {
|
||||
|
||||
KECCAK_224(144, 28, 0x01),
|
||||
KECCAK_256(136, 32, 0x01),
|
||||
KECCAK_384(104, 48, 0x01),
|
||||
KECCAK_512(72, 64, 0x01),
|
||||
|
||||
SHA3_224(144, 28, 0x06),
|
||||
SHA3_256(136, 32, 0x06),
|
||||
SHA3_384(104, 48, 0x06),
|
||||
SHA3_512(72, 64, 0x06),
|
||||
|
||||
SHAKE128(168, 32, 0x1F),
|
||||
SHAKE256(136, 64, 0x1F)
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
package org.komputing.khash.keccak.extensions
|
||||
|
||||
/**
|
||||
* Assigns the specified int value to each element of the specified
|
||||
* range in the specified array of ints. The range to be filled
|
||||
* extends from index <tt>fromIndex</tt>, inclusive, to index
|
||||
* <tt>toIndex</tt>, exclusive. (If <tt>fromIndex==toIndex</tt>, the
|
||||
* range to be filled is empty.)
|
||||
*
|
||||
* @param fromIndex the index of the first element (inclusive) to be
|
||||
* filled with the specified value
|
||||
* @param toIndex the index of the last element (exclusive) to be
|
||||
* filled with the specified value
|
||||
* @param value the value to be stored in all elements of the array
|
||||
* @throws IllegalArgumentException if <tt>fromIndex > toIndex</tt>
|
||||
* @throws ArrayIndexOutOfBoundsException if <tt>fromIndex < 0</tt> or
|
||||
* <tt>toIndex > a.length</tt>
|
||||
*/
|
||||
internal fun IntArray.fillWith(value: Int, fromIndex: Int = 0, toIndex: Int = this.size) {
|
||||
if (fromIndex > toIndex) {
|
||||
throw IllegalArgumentException(
|
||||
"fromIndex($fromIndex) > toIndex($toIndex)"
|
||||
)
|
||||
}
|
||||
|
||||
if (fromIndex < 0) {
|
||||
throw ArrayIndexOutOfBoundsException(fromIndex)
|
||||
}
|
||||
if (toIndex > this.size) {
|
||||
throw ArrayIndexOutOfBoundsException(toIndex)
|
||||
}
|
||||
|
||||
for (i in fromIndex until toIndex)
|
||||
this[i] = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new [ArrayIndexOutOfBoundsException]
|
||||
* class with an argument indicating the illegal index.
|
||||
* @param index the illegal index.
|
||||
*/
|
||||
internal class ArrayIndexOutOfBoundsException(index: Int) : Throwable("Array index out of range: $index")
|
@ -1,19 +0,0 @@
|
||||
@file:Suppress("unused")
|
||||
package org.komputing.khash.keccak.extensions
|
||||
|
||||
import org.komputing.khash.keccak.Keccak
|
||||
import org.komputing.khash.keccak.KeccakParameter
|
||||
|
||||
/**
|
||||
* Computes the proper Keccak digest of [this] byte array based on the given [parameter]
|
||||
*/
|
||||
fun ByteArray.digestKeccak(parameter: KeccakParameter): ByteArray {
|
||||
return Keccak.digest(this, parameter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the proper Keccak digest of [this] string based on the given [parameter]
|
||||
*/
|
||||
fun String.digestKeccak(parameter: KeccakParameter): ByteArray {
|
||||
return Keccak.digest(encodeToByteArray(), parameter)
|
||||
}
|
@ -1,8 +1,19 @@
|
||||
import com.ionspin.kotlin.crypto.secretbox.SecretBox
|
||||
import com.ionspin.kotlin.crypto.util.decodeFromUByteArray
|
||||
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.sergeych.crypto.*
|
||||
import net.sergeych.crypto2.IllegalSignatureException
|
||||
import net.sergeych.crypto2.SealedBox
|
||||
import net.sergeych.crypto2.SigningSecretKey
|
||||
import net.sergeych.crypto2.initCrypto
|
||||
import net.sergeych.kiloparsec.encodeToUByteArray
|
||||
import net.sergeych.utools.pack
|
||||
import net.sergeych.utools.unpack
|
||||
import kotlin.test.*
|
||||
@ -11,47 +22,36 @@ class KeysTest {
|
||||
@Test
|
||||
fun testCreationAndMap() = runTest {
|
||||
initCrypto()
|
||||
val (stk,pbk) = Key.Signing.pair()
|
||||
val (stk,pbk) = SigningSecretKey.generatePair()
|
||||
|
||||
val x = mapOf( stk to "STK!", pbk to "PBK!")
|
||||
assertEquals("STK!", x[stk])
|
||||
val s1 = Key.Signing(stk.packed)
|
||||
val s1 = SigningSecretKey(stk.keyBytes)
|
||||
assertEquals(stk, s1)
|
||||
assertEquals("STK!", x[s1])
|
||||
assertEquals("PBK!", x[pbk])
|
||||
|
||||
val data = "8 rays dev!".encodeToUByteArray()
|
||||
val data1 = "8 rays dev!".encodeToUByteArray()
|
||||
val s = SignedBox.Seal.create(stk, data)
|
||||
assertTrue(s.verify(data))
|
||||
val s = stk.seal(data)
|
||||
assertTrue(s.isValid(data))
|
||||
|
||||
data1[0] = 0x01u
|
||||
assertFalse(s.verify(data1))
|
||||
val p2 = Key.Signing.pair()
|
||||
val p3 = Key.Signing.pair()
|
||||
assertFalse(s.isValid(data1))
|
||||
val p2 = SigningSecretKey.generatePair()
|
||||
val p3 = SigningSecretKey.generatePair()
|
||||
|
||||
val ms = SignedBox(data, s1) + p2.signing
|
||||
val ms = SealedBox(data, s1) + p2.secretKey
|
||||
|
||||
// non tampered:
|
||||
val ms1 = unpack<SignedBox>(pack(ms))
|
||||
val ms1 = unpack<SealedBox>(pack(ms))
|
||||
assertContentEquals(data, ms1.message)
|
||||
assertTrue(pbk in ms1)
|
||||
assertTrue(p2.verifying in ms1)
|
||||
assertTrue(p3.verifying !in ms1)
|
||||
assertTrue(p2.publicKey in ms1)
|
||||
assertTrue(p3.publicKey !in ms1)
|
||||
|
||||
assertThrows<IllegalSignatureException> {
|
||||
unpack<SignedBox>(pack(ms).also { it[3] = 1u })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun secretEncryptTest() = runTest {
|
||||
initCrypto()
|
||||
val key = SecretBox.keygen()
|
||||
val key1 = SecretBox.keygen()
|
||||
assertEquals("hello", decrypt(key, encrypt(key, "hello".encodeToUByteArray())).decodeFromUByteArray())
|
||||
assertThrows<DecryptionFailedException> {
|
||||
decrypt(key, encrypt(key1, "hello".encodeToUByteArray())).decodeFromUByteArray()
|
||||
unpack<SealedBox>(pack(ms).also { it[3] = 1u })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,11 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.Instant
|
||||
import net.sergeych.crypto.initCrypto
|
||||
import net.sergeych.bipack.BipackEncoder
|
||||
import net.sergeych.crypto2.initCrypto
|
||||
import net.sergeych.kiloparsec.KiloParams
|
||||
import net.sergeych.kiloparsec.Transport
|
||||
import net.sergeych.utools.nowToSeconds
|
||||
import net.sergeych.utools.pack
|
||||
import net.sergeych.utools.unpack
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.time.Duration.Companion.microseconds
|
||||
|
||||
@ -40,4 +53,10 @@ class PackTest {
|
||||
val b2 = unpack<Transport.Block>(p1)
|
||||
assertEquals(b1,b2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun packPackage() = runTest {
|
||||
val x = BipackEncoder.encode(KiloParams.Package(1u, ubyteArrayOf()))
|
||||
assertContentEquals(byteArrayOf(4, 0), x)
|
||||
}
|
||||
}
|
@ -1,20 +1,17 @@
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.sergeych.crypto.createContrail
|
||||
import net.sergeych.crypto.initCrypto
|
||||
import net.sergeych.crypto.isValidContrail
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
class ToolsTest {
|
||||
@Test
|
||||
fun testContrails() = runTest {
|
||||
initCrypto()
|
||||
val c = createContrail(ubyteArrayOf(1u, 2u, 3u, 4u, 5u))
|
||||
assertEquals(134u, c[0])
|
||||
assertTrue { isValidContrail(c) }
|
||||
c[2] = 11u
|
||||
assertFalse { isValidContrail(c) }
|
||||
}
|
||||
// @Test
|
||||
// fun testRemoceCmd() {
|
||||
// assertEquals("lalala", removeCmd("lalala"))
|
||||
// assertEquals("lalala", removeCmd("cmdlalala"))
|
||||
// }
|
||||
}
|
@ -1,15 +1,27 @@
|
||||
import com.ionspin.kotlin.crypto.keyexchange.KeyExchange
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.sergeych.crypto.Key
|
||||
import net.sergeych.crypto.initCrypto
|
||||
import net.sergeych.kiloparsec.command
|
||||
import net.sergeych.crypto2.SigningSecretKey
|
||||
import net.sergeych.crypto2.VerifyingPublicKey
|
||||
import net.sergeych.crypto2.initCrypto
|
||||
import net.sergeych.kiloparsec.*
|
||||
import net.sergeych.mp_logger.Log
|
||||
import kotlin.test.*
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.fail
|
||||
|
||||
private var dcnt = 0
|
||||
fun createTestDevice(): Pair<Transport.Device, Transport.Device> {
|
||||
@ -17,8 +29,9 @@ fun createTestDevice(): Pair<Transport.Device, Transport.Device> {
|
||||
val p2 = Channel<UByteArray>(256)
|
||||
val id = ++dcnt
|
||||
val d1 = object : Transport.Device {
|
||||
override val input: ReceiveChannel<UByteArray?> = p1
|
||||
override val input: ReceiveChannel<UByteArray> = p1
|
||||
override val output: SendChannel<UByteArray> = p2
|
||||
|
||||
override suspend fun close() {
|
||||
p2.close()
|
||||
}
|
||||
@ -28,7 +41,7 @@ fun createTestDevice(): Pair<Transport.Device, Transport.Device> {
|
||||
}
|
||||
}
|
||||
val d2 = object : Transport.Device {
|
||||
override val input: ReceiveChannel<UByteArray?> = p2
|
||||
override val input: ReceiveChannel<UByteArray> = p2
|
||||
override val output: SendChannel<UByteArray> = p1
|
||||
override suspend fun close() {
|
||||
p1.close()
|
||||
@ -70,29 +83,29 @@ class TransportTest {
|
||||
val t1 = Transport(d1, l1, Unit)
|
||||
val t2 = Transport(d2, l2, Unit)
|
||||
|
||||
val clip = KeyExchange.keypair()
|
||||
val serp = KeyExchange.keypair()
|
||||
val clisk = KeyExchange.clientSessionKeys(clip.publicKey, clip.secretKey, serp.publicKey)
|
||||
val sersk = KeyExchange.serverSessionKeys(serp.publicKey, serp.secretKey, clip.publicKey)
|
||||
val pser = KiloParams(true, t1, sersk, Unit, null, t1)
|
||||
val pcli = KiloParams(false, t2, clisk, Unit, null, t2)
|
||||
|
||||
assertContentEquals(pcli.token, pser.token)
|
||||
assertEquals(pser.decryptString(pcli.encrypt("hello!")), "hello!")
|
||||
assertEquals(pser.decryptString(pcli.encrypt("hello!")), "hello!")
|
||||
assertEquals(pser.decryptString(pcli.encrypt("hello2!")), "hello2!")
|
||||
assertEquals(pser.decryptString(pcli.encrypt("hello3!")), "hello3!")
|
||||
assertEquals(pser.decryptString(pcli.encrypt("hello!")), "hello!")
|
||||
// val clip = KeyExchange.keypair()
|
||||
// val serp = KeyExchange.keypair()
|
||||
// val clisk = KeyExchange.clientSessionKeys(clip.publicKey, clip.secretKey, serp.publicKey)
|
||||
// val sersk = KeyExchange.serverSessionKeys(serp.publicKey, serp.secretKey, clip.publicKey)
|
||||
// val pser = KiloParams(true, t1, sersk, Unit, null, t1)
|
||||
// val pcli = KiloParams(false, t2, clisk, Unit, null, t2)
|
||||
|
||||
// assertContentEquals(pcli.token, pser.token)
|
||||
// assertEquals(pser.decryptString(pcli.encrypt("hello!")), "hello!")
|
||||
// assertEquals(pser.decryptString(pcli.encrypt("hello!")), "hello!")
|
||||
// assertEquals(pser.decryptString(pcli.encrypt("hello2!")), "hello2!")
|
||||
// assertEquals(pser.decryptString(pcli.encrypt("hello3!")), "hello3!")
|
||||
// assertEquals(pser.decryptString(pcli.encrypt("hello!")), "hello!")
|
||||
//
|
||||
// test nonce increment
|
||||
assertFalse { pcli.encrypt("once") contentEquals pcli.encrypt("once") }
|
||||
// assertFalse { pcli.encrypt("once") contentEquals pcli.encrypt("once") }
|
||||
|
||||
assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!")
|
||||
assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!")
|
||||
assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!")
|
||||
assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!")
|
||||
assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!")
|
||||
assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!")
|
||||
// assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!")
|
||||
// assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!")
|
||||
// assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!")
|
||||
// assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!")
|
||||
// assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!")
|
||||
// assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!")
|
||||
|
||||
|
||||
coroutineScope {
|
||||
@ -156,6 +169,8 @@ class TransportTest {
|
||||
d2.close()
|
||||
}
|
||||
|
||||
class TestException(text: String) : Exception(text)
|
||||
|
||||
@Test
|
||||
fun testClient() = runTest {
|
||||
initCrypto()
|
||||
@ -163,7 +178,7 @@ class TransportTest {
|
||||
val cmdPing by command<String, String>()
|
||||
val cmdPush by command<String, String>()
|
||||
val cmdGetToken by command<Unit, UByteArray>()
|
||||
val cmdGetClientId by command<Unit, Key.Verifying?>()
|
||||
val cmdGetClientId by command<Unit, VerifyingPublicKey?>()
|
||||
val cmdChainCallServer1 by command<String, String>()
|
||||
val cmdChainCallClient1 by command<String, String>()
|
||||
val cmdChainCallServer2 by command<String, String>()
|
||||
@ -172,13 +187,23 @@ class TransportTest {
|
||||
// Log.defaultLevel = Log.Level.DEBUG
|
||||
val (d1, d2) = createTestDevice()
|
||||
|
||||
val serverId = Key.Signing.pair()
|
||||
val clientId = Key.Signing.pair()
|
||||
val serverId = SigningSecretKey.generatePair()
|
||||
val clientId = SigningSecretKey.generatePair()
|
||||
|
||||
val cmdException by command<Unit, Unit>()
|
||||
val cmdRemoteExceptionTest by command<Unit, String>()
|
||||
val cmdBreak by command<Unit, Unit>()
|
||||
|
||||
val cmdPushServer by push<String>()
|
||||
val pushedFromServer = CompletableDeferred<String>()
|
||||
|
||||
val serverInterface = KiloInterface<String>().apply {
|
||||
on(cmdPing) {
|
||||
"pong! [$it]"
|
||||
}
|
||||
on(cmdPushServer) {
|
||||
pushedFromServer.complete(it)
|
||||
}
|
||||
on(cmdGetToken) {
|
||||
sessionToken
|
||||
}
|
||||
@ -191,16 +216,28 @@ class TransportTest {
|
||||
on(cmdChainCallServer2) {
|
||||
remote.call(cmdChainCallClient2, "$it-s2")
|
||||
}
|
||||
registerError { IllegalStateException() }
|
||||
registerError { IllegalArgumentException(it) }
|
||||
on(cmdException) { throw TestException("te1") }
|
||||
on(cmdRemoteExceptionTest) {
|
||||
try {
|
||||
remote.call(cmdException)
|
||||
"error!"
|
||||
} catch (e: TestException) {
|
||||
"ok: ${e.message}"
|
||||
}
|
||||
}
|
||||
on(cmdBreak) { throw LocalInterface.BreakConnectionException() }
|
||||
registerError { TestException(it) }
|
||||
}
|
||||
val kiloServerConnection = KiloServerConnection(serverInterface, d1, "server session", serverId.signing)
|
||||
val kiloServerConnection = KiloServerConnection(
|
||||
serverInterface, d1, "server session", serverId.secretKey
|
||||
)
|
||||
launch { kiloServerConnection.run() }
|
||||
|
||||
var cnt = 0
|
||||
val client = KiloClient {
|
||||
addErrors(serverInterface)
|
||||
session { "client session!" }
|
||||
secretIdKey = clientId.signing
|
||||
secretIdKey = clientId.secretKey
|
||||
local {
|
||||
on(cmdPush) {
|
||||
"server push: $it"
|
||||
@ -209,11 +246,13 @@ class TransportTest {
|
||||
"client pong: $it"
|
||||
}
|
||||
on(cmdChainCallClient1) {
|
||||
remote.call(cmdChainCallServer2,"$it-c1")
|
||||
remote.call(cmdChainCallServer2, "$it-c1")
|
||||
}
|
||||
on(cmdChainCallClient2) { "$it-c2" }
|
||||
on(cmdException) { throw TestException("te-local") }
|
||||
}
|
||||
connect {
|
||||
println("Called connect: $cnt")
|
||||
if (cnt++ > 0) {
|
||||
cancel()
|
||||
fail("connect called once again")
|
||||
@ -226,7 +265,25 @@ class TransportTest {
|
||||
assertEquals("client pong: foo", kiloServerConnection.call(cmdPing, "foo"))
|
||||
assertEquals("server push: bar", kiloServerConnection.call(cmdPush, "bar"))
|
||||
|
||||
client.push(cmdPushServer, "42")
|
||||
|
||||
assertEquals("**-s1-c1-s2-c2", client.call(cmdChainCallServer1, "**"))
|
||||
|
||||
|
||||
assertThrows<TestException> { client.call(cmdException) }
|
||||
assertEquals("ok: te-local", client.call(cmdRemoteExceptionTest))
|
||||
|
||||
// wait for push to be received and check
|
||||
assertEquals("42", pushedFromServer.await())
|
||||
|
||||
assertThrows<RemoteInterface.ClosedException> {
|
||||
client.call(cmdBreak)
|
||||
}
|
||||
|
||||
// Note that current transport test is too simple,
|
||||
// therefore, we can't test reconnecting, also we need server and client instances
|
||||
// not connections, so that's all
|
||||
|
||||
d1.close()
|
||||
d2.close()
|
||||
client.close()
|
||||
|
@ -1,3 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
import kotlin.test.fail
|
||||
|
||||
inline fun <reified T: Throwable>assertThrows(f: ()->Unit): T {
|
||||
@ -8,6 +18,7 @@ inline fun <reified T: Throwable>assertThrows(f: ()->Unit): T {
|
||||
}
|
||||
catch(x: Throwable) {
|
||||
if( x is T ) return x
|
||||
fail("expected to throw $name but instead threw ${x::class.simpleName}: $x")
|
||||
println("expected to throw $name but instead threw ${x::class.simpleName}: $x\b\n${x.stackTraceToString()}")
|
||||
fail("expected to throw $name but instead threw ${x::class.simpleName}: $x\b\n${x.stackTraceToString()}")
|
||||
}
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
actual fun networkAddressOf(address: String): NetworkAddress {
|
||||
TODO("Not yet implemented")
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
actual fun NetworkAddress(host: String, port: Int): NetworkAddress {
|
||||
TODO("Not yet implemented")
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
package net.sergeych.tools
|
||||
|
||||
actual fun ProtectedOp(): ProtectedOpImplementation = object : ProtectedOpImplementation {
|
||||
// JS targets are inherently single-threaded, so we do noting:
|
||||
override fun <T> invoke(f: () -> T): T = f()
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import java.net.InetAddress
|
||||
|
||||
actual fun networkAddressOf(address: String): NetworkAddress {
|
||||
val (host,port) = address.split(":")
|
||||
return JvmNetworkAddress(InetAddress.getByName(host), port.toInt())
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import java.net.InetAddress
|
||||
|
||||
actual fun NetworkAddress(host: String, port: Int): NetworkAddress =
|
||||
JvmNetworkAddress(InetAddress.getByName(host), port)
|
@ -1,135 +0,0 @@
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.exception
|
||||
import net.sergeych.mp_logger.info
|
||||
import net.sergeych.mp_logger.warning
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
private val counter = AtomicInteger(0)
|
||||
|
||||
class JvmNetworkAddress(val inetAddress: InetAddress, override val port: Int) : NetworkAddress {
|
||||
override val host: String by lazy { inetAddress.hostName }
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is JvmNetworkAddress) return false
|
||||
|
||||
if (inetAddress != other.inetAddress) return false
|
||||
if (port != other.port) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = inetAddress.hashCode()
|
||||
result = 31 * result + port
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class UdpDatagram(override val message: UByteArray, val inetAddress: InetAddress, val port: Int) : Datagram {
|
||||
|
||||
override val address: NetworkAddress by lazy {
|
||||
JvmNetworkAddress(inetAddress, port)
|
||||
}
|
||||
|
||||
private val access = Mutex()
|
||||
|
||||
private var socket: DatagramSocket? = null
|
||||
override suspend fun respondWith(message: UByteArray) {
|
||||
withContext(Dispatchers.IO) {
|
||||
access.withLock {
|
||||
if (socket == null) socket = DatagramSocket()
|
||||
val packet = DatagramPacket(
|
||||
message.toByteArray(),
|
||||
message.size,
|
||||
inetAddress,
|
||||
port
|
||||
)
|
||||
socket!!.send(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
class UdpServer(val port: Int) :
|
||||
DatagramReceiver, LogTag("UDPS:${counter.incrementAndGet()}") {
|
||||
private var isClosed = false
|
||||
|
||||
|
||||
private val deferredSocket = CompletableDeferred<DatagramSocket>()
|
||||
private var job: Job? = null
|
||||
|
||||
private suspend fun start() = try {
|
||||
coroutineScope {
|
||||
val socket = DatagramSocket(port)
|
||||
val buffer = ByteArray(16384)
|
||||
val packet = DatagramPacket(buffer, buffer.size)
|
||||
deferredSocket.complete(socket)
|
||||
while (isActive && !isClosed) {
|
||||
try {
|
||||
socket.receive(packet)
|
||||
val data = packet.data.sliceArray(0..<packet.length)
|
||||
val datagram = UdpDatagram(data.toUByteArray(), packet.address, packet.port)
|
||||
if (!channel.trySend(datagram).isSuccess) {
|
||||
warning { "packet lost!" }
|
||||
// and we cause overflow that overwrites the oldest
|
||||
channel.send(datagram)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (!isClosed)
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
info { "closing socket and reception loop" }
|
||||
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
info { "server is closed" }
|
||||
} catch (t: Throwable) {
|
||||
exception { "unexpected end of server" to t }
|
||||
}
|
||||
|
||||
init {
|
||||
job = GlobalScope.launch(Dispatchers.IO) {
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!isClosed) {
|
||||
if (deferredSocket.isCompleted) {
|
||||
runCatching {
|
||||
deferredSocket.getCompleted().close()
|
||||
}
|
||||
}
|
||||
isClosed = true
|
||||
job?.cancel(); job = null
|
||||
}
|
||||
}
|
||||
|
||||
private val channel = Channel<Datagram>(2048, BufferOverflow.DROP_OLDEST)
|
||||
override val incoming = channel
|
||||
|
||||
override suspend fun send(message: UByteArray, networkAddress: NetworkAddress) {
|
||||
networkAddress as JvmNetworkAddress
|
||||
withContext(Dispatchers.IO) {
|
||||
val packet = DatagramPacket(
|
||||
message.toByteArray(), message.size,
|
||||
networkAddress.inetAddress, networkAddress.port
|
||||
)
|
||||
deferredSocket.await().send(packet)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.websocket.*
|
||||
import io.ktor.websocket.*
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.kiloparsec.KiloInterface
|
||||
import net.sergeych.kiloparsec.KiloServerConnection
|
||||
import net.sergeych.kiloparsec.RemoteInterface
|
||||
import net.sergeych.mp_logger.*
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Create a ktor-based websocket server.
|
||||
* This call install Routing and WebSockets with proper configuration.
|
||||
*
|
||||
* The course of action is:
|
||||
*
|
||||
* - create LocalInterface and populate it with functionality
|
||||
* - call this method with localInterface
|
||||
* - optionally, connect the same interface with TCP or UDP providers on supported platforms,
|
||||
* in which case it might be useful to hae session creating function [createSession] separate.
|
||||
*
|
||||
* _Note_: [KiloInterface] as for now does not contain session creation in it as we suggest
|
||||
* session could be transport specific.
|
||||
*
|
||||
* @param localInterface where the actual work is performed.
|
||||
* @param path default http path to the websocket.
|
||||
* @param serverKey optional key to authenticate the connection. If the client specify expected
|
||||
* server key it should match of connection will not be established.
|
||||
* @param createSession function to create a server session.
|
||||
*/
|
||||
fun <S> Application.setupWebsocketServer(
|
||||
localInterface: KiloInterface<S>,
|
||||
path: String = "/kp",
|
||||
serverKey: SigningKey? = null,
|
||||
createSession: () -> S,
|
||||
) {
|
||||
install(WebSockets) {
|
||||
pingPeriod = 60.seconds //Duration.ofSeconds(15)
|
||||
timeout = 45.seconds
|
||||
maxFrameSize = Long.MAX_VALUE
|
||||
masking = false
|
||||
}
|
||||
val counter = AtomicCounter()
|
||||
routing {
|
||||
webSocket(path) {
|
||||
val log = LogTag("KWS:${counter.incrementAndGet()}")
|
||||
log.debug { "opening the connection" }
|
||||
val input = Channel<UByteArray>(256)
|
||||
val output = Channel<UByteArray>(256)
|
||||
launch {
|
||||
log.debug { "starting output pump" }
|
||||
while (isActive) {
|
||||
try {
|
||||
send(output.receive().toByteArray())
|
||||
}
|
||||
catch(_: ClosedReceiveChannelException) {
|
||||
log.debug { "closing output pump as output channel is closed" }
|
||||
break
|
||||
}
|
||||
}
|
||||
log.debug { "closing output pump" }
|
||||
}
|
||||
val server = KiloServerConnection(
|
||||
localInterface,
|
||||
ProxyDevice(input, output),
|
||||
createSession(),
|
||||
serverKey
|
||||
)
|
||||
launch {
|
||||
server.run()
|
||||
close()
|
||||
}
|
||||
log.debug { "KSC started, looking for incoming frames" }
|
||||
for (f in incoming) {
|
||||
if (f is Frame.Binary)
|
||||
try {
|
||||
input.send(f.readBytes().toUByteArray())
|
||||
} catch (_: RemoteInterface.ClosedException) {
|
||||
log.warning { "caught local closed exception (strange!), closing" }
|
||||
break
|
||||
} catch (_: ClosedReceiveChannelException) {
|
||||
log.info { "receive channel is closed, closing connection" }
|
||||
break
|
||||
} catch (t: Throwable) {
|
||||
log.exception { "unexpected exception, server connection will close" to t }
|
||||
break
|
||||
}
|
||||
else
|
||||
log.warning { "unknown frame type ${f.frameType}, ignoring" }
|
||||
}
|
||||
log.debug { "closing the server" }
|
||||
close()
|
||||
cancel()
|
||||
log.debug { "server wbesock processing done" }
|
||||
}
|
||||
}
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
package net.sergeych.tools
|
||||
|
||||
actual fun ProtectedOp(): ProtectedOpImplementation = object : ProtectedOpImplementation {
|
||||
private val lock = Object()
|
||||
override fun <T> invoke(f: () -> T): T {
|
||||
synchronized(lock) { return f() }
|
||||
}
|
||||
}
|
@ -1,10 +1,107 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import assertThrows
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.netty.*
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.sergeych.crypto2.initCrypto
|
||||
import net.sergeych.kiloparsec.adapter.setupWebsocketServer
|
||||
import net.sergeych.kiloparsec.adapter.websocketClient
|
||||
import net.sergeych.mp_logger.Log
|
||||
import java.net.InetAddress
|
||||
import kotlin.random.Random
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ClientTest {
|
||||
|
||||
@Test
|
||||
fun testClient() {
|
||||
// Todo
|
||||
fun testAddresses() {
|
||||
println(InetAddress.getLocalHost())
|
||||
println(InetAddress.getByName("localhost"))
|
||||
println(InetAddress.getByName("127.0.0.1"))
|
||||
println(InetAddress.getByName("mail.ru"))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun webSocketTest() = runTest {
|
||||
initCrypto()
|
||||
// fun Application.
|
||||
val cmdClose by command<Unit,Unit>()
|
||||
val cmdGetFoo by command<Unit,String>()
|
||||
val cmdSetFoo by command<String,Unit>()
|
||||
val cmdCheckConnected by command<Unit,Boolean>()
|
||||
|
||||
Log.connectConsole(Log.Level.DEBUG)
|
||||
|
||||
data class Session(var foo: String="not set")
|
||||
var closeCounter = 0
|
||||
val serverInterface = KiloInterface<Session>().apply {
|
||||
var connectedCalled = false
|
||||
onConnected { connectedCalled = true }
|
||||
on(cmdGetFoo) { session.foo }
|
||||
on(cmdSetFoo) { session.foo = it }
|
||||
on(cmdCheckConnected) { connectedCalled }
|
||||
on(cmdClose) {
|
||||
throw LocalInterface.BreakConnectionException()
|
||||
}
|
||||
}
|
||||
|
||||
val port = Random.nextInt(8080,9090)
|
||||
val ns = embeddedServer(Netty, port = port, host = "0.0.0.0", module = {
|
||||
setupWebsocketServer(serverInterface) { Session() }
|
||||
}).start(wait = false)
|
||||
|
||||
val client = websocketClient<Unit>("ws://localhost:$port/kp")
|
||||
val states = mutableListOf<Boolean>()
|
||||
val collector = launch {
|
||||
client.connectedStateFlow.collect {
|
||||
println("got: $closeCounter/$it")
|
||||
states += it
|
||||
if( !it) { closeCounter++ }
|
||||
}
|
||||
}
|
||||
assertEquals(true, client.call(cmdCheckConnected))
|
||||
assertTrue { client.connectedStateFlow.value }
|
||||
assertEquals("not set", client.call(cmdGetFoo))
|
||||
client.call(cmdSetFoo, "foo")
|
||||
assertEquals("foo", client.call(cmdGetFoo))
|
||||
|
||||
assertTrue { client.connectedStateFlow.value }
|
||||
assertThrows<RemoteInterface.ClosedException> {
|
||||
client.call(cmdClose)
|
||||
}
|
||||
|
||||
// connection should now be closed
|
||||
// the problem is: it needs some unspecified time to close
|
||||
// as it is async process.
|
||||
delay(100)
|
||||
assertFalse { client.connectedStateFlow.value }
|
||||
|
||||
// this should be run on automatically reopen connection
|
||||
client.call(cmdSetFoo,"superbar")
|
||||
assertTrue { client.connectedStateFlow.value }
|
||||
assertEquals("superbar", client.call(cmdGetFoo))
|
||||
client.close()
|
||||
ns.stop()
|
||||
collector.cancel()
|
||||
// println("----= states: $states")
|
||||
// println("stopped server")
|
||||
// println("closed client")
|
||||
}
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
package net.sergeych.kiloparsec.adapters
|
||||
|
||||
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.sergeych.kiloparsec.adapter.UdpServer
|
||||
import net.sergeych.mp_logger.Log
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import kotlin.test.Test
|
||||
|
||||
class UServerTest {
|
||||
|
||||
@Test
|
||||
fun udpProvider() = runTest {
|
||||
Log.connectConsole(Log.Level.DEBUG)
|
||||
val s1 = UdpServer(17120)
|
||||
val s2 = UdpServer(17121)
|
||||
s1.send("Hello".encodeToUByteArray(), "localhost",17121)
|
||||
val d1 = s2.incoming.receive()
|
||||
assertEquals(d1.address.port, 17120)
|
||||
assertEquals("Hello", d1.message.toByteArray().decodeToString())
|
||||
d1.respondWith("world".encodeToUByteArray())
|
||||
assertEquals("world", s1.incoming.receive().message.toByteArray().decodeToString())
|
||||
// println("s1: ${s1.bindAddress()}")
|
||||
|
||||
}
|
||||
}
|
@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import io.ktor.network.selector.*
|
||||
import io.ktor.network.sockets.*
|
||||
import io.ktor.utils.io.*
|
||||
import io.ktor.utils.io.writeByte
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.datetime.Clock
|
||||
import net.sergeych.kiloparsec.*
|
||||
import net.sergeych.mp_logger.*
|
||||
import net.sergeych.mp_tools.globalLaunch
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private val logCounter = AtomicCounter(0)
|
||||
|
||||
class ProtocolException(text: String, cause: Throwable? = null) : RuntimeException(text, cause)
|
||||
|
||||
const val MAX_TCP_BLOCK_SIZE = 16776216
|
||||
internal val PING_INACTIVITY_TIME = 30.seconds
|
||||
|
||||
/**
|
||||
* Listen for incoming TCP/IP connections on all local interfaces and the specified [port]
|
||||
* anc create flow of [InetTransportDevice] suitable for [KiloClient].
|
||||
*/
|
||||
fun acceptTcpDevice(port: Int, localInterface: String = "0.0.0.0"): Flow<InetTransportDevice> {
|
||||
val selectorManager = SelectorManager(Dispatchers.IO)
|
||||
return flow {
|
||||
val serverSocket = aSocket(selectorManager).tcp().bind(localInterface, port)
|
||||
while (true) {
|
||||
serverSocket.accept().let { sock ->
|
||||
emit(inetTransportDevice(sock, "srv"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectTcpDevice(address: String) = connectTcpDevice(address.toNetworkAddress())
|
||||
|
||||
/**
|
||||
* Connect to the TCP/IP server (see [KiloServer]) at the specified address and provide th compatible
|
||||
* [InetTransportDevice] to use with [KiloClient].
|
||||
*/
|
||||
suspend fun connectTcpDevice(address: NetworkAddress): InetTransportDevice {
|
||||
val selectorManager = SelectorManager(Dispatchers.IO)
|
||||
val socket = aSocket(selectorManager).tcp().connect(address.host, address.port)
|
||||
return inetTransportDevice(socket)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `host:port` string into the [NetworkAddress]
|
||||
*/
|
||||
fun String.toNetworkAddress(): NetworkAddress {
|
||||
val (host, port) = this.split(":").map { it.trim() }
|
||||
return NetworkAddress(host, port.toInt())
|
||||
}
|
||||
|
||||
private fun inetTransportDevice(
|
||||
sock: Socket,
|
||||
suffix: String = "cli",
|
||||
): InetTransportDevice {
|
||||
val networkAddress = (sock.remoteAddress as InetSocketAddress).let { NetworkAddress(it.hostname, it.port) }
|
||||
val inputBlocks = Channel<UByteArray>(4096)
|
||||
val outputBlocks = Channel<UByteArray>(4096)
|
||||
|
||||
val log = LogTag("TCPT${logCounter.incrementAndGet()}:$suffix:$networkAddress")
|
||||
val job = AtomicAsyncValue<Job?>(null)
|
||||
|
||||
val sockOutput = sock.openWriteChannel()
|
||||
val sockInput = runCatching { sock.openReadChannel() }.getOrElse {
|
||||
log.warning { "failed to open read channel $it" }
|
||||
throw IllegalStateException("failed to open read channel")
|
||||
}
|
||||
|
||||
suspend fun stop() {
|
||||
job.mutate {
|
||||
if (it != null) {
|
||||
log.debug { "stopping" }
|
||||
runCatching { inputBlocks.close() }
|
||||
runCatching { outputBlocks.close() }
|
||||
// The problem: on mac platofrms closing the socket does not close its input
|
||||
// and output channels!
|
||||
runCatching { sockInput.cancel() }
|
||||
runCatching { sockOutput.flushAndClose() }
|
||||
if (!sock.isClosed)
|
||||
runCatching {
|
||||
log.debug { "closing socket by stop" }
|
||||
sock.close()
|
||||
}
|
||||
else
|
||||
log.debug { "socket is already closed when stop is called" }
|
||||
it.cancel()
|
||||
log.debug { "implementation job cancel called" }
|
||||
} else
|
||||
log.debug { "already stopped" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
var lastActiveAt = Clock.System.now()
|
||||
globalLaunch {
|
||||
job.reset(globalLaunch {
|
||||
launch {
|
||||
|
||||
log.debug { "opening read channel" }
|
||||
|
||||
while (isActive && sock.isActive) {
|
||||
try {
|
||||
val size = AsyncVarint.decodeUnsigned(sockInput).toInt()
|
||||
if (size > MAX_TCP_BLOCK_SIZE) // 16M is a max command block
|
||||
throw ProtocolException("Illegal block size: $size should be < $MAX_TCP_BLOCK_SIZE")
|
||||
val data = ByteArray(size)
|
||||
if (size == 0) {
|
||||
log.debug { "ping received" }
|
||||
lastActiveAt = Clock.System.now()
|
||||
} else {
|
||||
sockInput.readFully(data, 0, size)
|
||||
inputBlocks.send(data.toUByteArray())
|
||||
}
|
||||
} catch (e: ClosedReceiveChannelException) {
|
||||
log.error { "closed receive channel " }
|
||||
stop()
|
||||
break
|
||||
} catch (_: CancellationException) {
|
||||
log.error { "cancellation exception " }
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
log.exception { "unexpected exception in TCP socket read" to e }
|
||||
stop()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
launch {
|
||||
val outAccess = Mutex()
|
||||
var lastSentAt = Clock.System.now()
|
||||
launch {
|
||||
while (isActive && sock.isActive) {
|
||||
delay(500)
|
||||
val activityTime = if (lastSentAt > lastActiveAt) lastSentAt else lastActiveAt
|
||||
if (Clock.System.now() - activityTime > PING_INACTIVITY_TIME) {
|
||||
log.debug { "pinging for inactivity" }
|
||||
val repeat = outAccess.withLock {
|
||||
try {
|
||||
sockOutput.writeByte(0)
|
||||
sockOutput.flush()
|
||||
lastSentAt = Clock.System.now()
|
||||
true
|
||||
} catch (e: ClosedReceiveChannelException) {
|
||||
e.printStackTrace()
|
||||
stop()
|
||||
false
|
||||
} catch (_: CancellationException) {
|
||||
false
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
stop()
|
||||
false
|
||||
}
|
||||
}
|
||||
if (!repeat) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (isActive && sock.isActive) {
|
||||
try {
|
||||
val block = outputBlocks.receive()
|
||||
outAccess.withLock {
|
||||
AsyncVarint.encodeUnsigned(block.size.toULong(), sockOutput)
|
||||
sockOutput.writeFully(block.toByteArray(), 0, block.size)
|
||||
sockOutput.flush()
|
||||
lastSentAt = Clock.System.now()
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
log.debug { "cancellation exception on output" }
|
||||
stop()
|
||||
break
|
||||
} catch (_: LocalInterface.BreakConnectionException) {
|
||||
log.debug { "requested connection break" }
|
||||
stop()
|
||||
break
|
||||
} catch (_: ClosedReceiveChannelException) {
|
||||
log.debug { "receive block channel closed, closing the socket" }
|
||||
stop()
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
log.exception { "unexpected exception. closing." to e }
|
||||
stop()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val device = InetTransportDevice(inputBlocks, outputBlocks, networkAddress, {
|
||||
stop()
|
||||
})
|
||||
log.debug { "Transport ready" }
|
||||
return device
|
||||
}
|
@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import io.ktor.network.sockets.*
|
||||
import io.ktor.utils.io.core.*
|
||||
import kotlinx.io.readByteArray
|
||||
import net.sergeych.crypto2.toDump
|
||||
import net.sergeych.kiloparsec.adapter.UdpBlock.Companion.CANCEL_BLOCK
|
||||
import net.sergeych.kiloparsec.adapter.UdpBlock.Companion.ESCAPE_BYTE
|
||||
import net.sergeych.kiloparsec.adapter.UdpBlock.Companion.PING_BLOCK
|
||||
import net.sergeych.kiloparsec.adapter.UdpBlock.Companion.decode
|
||||
|
||||
/**
|
||||
* Encoded block for UDP datagram space-savvy. Minimum dara size is two bytes, which is fine
|
||||
* for Kiloparsec blocks.
|
||||
*
|
||||
* First byte is encoded using [ESCAPE_BYTE] depending on the second byte:
|
||||
*
|
||||
* | 0 | 1 | meaning |
|
||||
* |---|---|---------|
|
||||
* | [ESCAPE_BYTE] | [ESCAPE_BYTE] | Data block, dropping first byte |
|
||||
* | [ESCAPE_BYTE] | [PING_BLOCK] | Ping block, reset timers |
|
||||
* | [ESCAPE_BYTE] | [CANCEL_BLOCK] | close connection |
|
||||
* | any other | * | data block, all bytes |
|
||||
*
|
||||
* Use [encoded] and [toDatagram] to create binary or the datagram from a block, and [decode] to restore.
|
||||
*
|
||||
* We do not use serialization to speed up the transport layer.
|
||||
*/
|
||||
sealed class UdpBlock {
|
||||
/**
|
||||
* Block to show that the connection is closed and should also be closed on the other side
|
||||
*/
|
||||
object Cancel : UdpBlock()
|
||||
|
||||
/**
|
||||
* Parties show pings if there is no activity to keep it alive, detect connection loss and in some
|
||||
* cases revive NAT/Proxy state in routers.
|
||||
*/
|
||||
object Ping : UdpBlock()
|
||||
|
||||
/**
|
||||
* Parsec data block. Could not be smaller than two bytes.
|
||||
*/
|
||||
class Data(val data: UByteArray) : UdpBlock() {
|
||||
override fun toString(): String {
|
||||
return "UDP Data (${data.size}):\n${data.toDump()}"
|
||||
}
|
||||
init {
|
||||
if( data.size < 2) throw IllegalArgumentException("data must be at least 2 bytes")
|
||||
}
|
||||
}
|
||||
|
||||
val encoded: UByteArray by lazy {
|
||||
when(this) {
|
||||
is Data -> {
|
||||
// Do we need escaping?
|
||||
if( data[0] == ESCAPE_BYTE )
|
||||
escapeAsArray + data
|
||||
else
|
||||
data
|
||||
}
|
||||
is Cancel -> cancelAsArray
|
||||
is Ping -> pingAsArray
|
||||
}
|
||||
}
|
||||
|
||||
fun toDatagram(address: SocketAddress): Datagram {
|
||||
val encoded = encoded.toByteArray()
|
||||
return Datagram(ByteReadPacket(encoded, 0, encoded.size), address)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ESCAPE_BYTE = 255.toUByte()
|
||||
val PING_BLOCK = 0.toUByte()
|
||||
val CANCEL_BLOCK = 1.toUByte()
|
||||
|
||||
private val escapeAsArray = ubyteArrayOf(ESCAPE_BYTE)
|
||||
private val pingAsArray = ubyteArrayOf(ESCAPE_BYTE, PING_BLOCK)
|
||||
private val cancelAsArray = ubyteArrayOf(ESCAPE_BYTE, CANCEL_BLOCK)
|
||||
|
||||
fun decode(data: UByteArray): UdpBlock {
|
||||
if (data.size < 2)
|
||||
throw UdpTransportException("block too short: ${data.size}")
|
||||
return if( data[0] != ESCAPE_BYTE )
|
||||
// plain data
|
||||
Data(data)
|
||||
else {
|
||||
when(val b2 = data[1]) {
|
||||
ESCAPE_BYTE -> {
|
||||
// Escaped first byte, then plain data
|
||||
Data(data.sliceArray(1 ..< data.size))
|
||||
}
|
||||
PING_BLOCK -> Ping
|
||||
CANCEL_BLOCK -> Cancel
|
||||
else -> throw UdpTransportException("invalid block type: $b2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun decode(datagram: Datagram) =
|
||||
decode(datagram.packet.readByteArray().toUByteArray())
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import io.ktor.network.sockets.*
|
||||
|
||||
/**
|
||||
* The interface for common UDP connector shared by UDP components
|
||||
*/
|
||||
internal interface UdpConnector {
|
||||
/**
|
||||
* Called when client connection is done so the provider could free resources
|
||||
*/
|
||||
suspend fun disconnectClient(address: SocketAddress)
|
||||
|
||||
/**
|
||||
* Send a block from a proper UDP socket
|
||||
*/
|
||||
suspend fun sendBlock(block: UdpBlock, toAddress: SocketAddress)
|
||||
}
|
@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import io.ktor.network.selector.*
|
||||
import io.ktor.network.sockets.*
|
||||
import io.ktor.utils.io.CancellationException
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import net.sergeych.kiloparsec.KiloClient
|
||||
import net.sergeych.kiloparsec.KiloServer
|
||||
import net.sergeych.kiloparsec.RemoteInterface
|
||||
import net.sergeych.mp_tools.globalLaunch
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
internal val udpCounter = AtomicCounter(0)
|
||||
|
||||
class UdpTransportException(override val message: String) : RemoteInterface.InvalidDataException(message)
|
||||
|
||||
/**
|
||||
* Listen for incoming UDP connections and provide transport flow for it. See also [UdpServer.transportFlow]
|
||||
* for another way to create a server. Use it with [KiloServer]:
|
||||
* ```kotlin
|
||||
* // Whatever server session data we might need:
|
||||
* data class Session(
|
||||
* var data: String,
|
||||
* )
|
||||
*
|
||||
* // declare some commands (normally in a shared module):
|
||||
* val cmdSave by command<String, Unit>()
|
||||
* val cmdLoad by command<Unit, String>()
|
||||
* val cmdDrop by command<Unit, Unit>()
|
||||
* val cmdException by command<Unit, Unit>()
|
||||
*
|
||||
* // Interface using the session above, can be shared between many
|
||||
* // server types and instances (different ports and protocols):
|
||||
* val cli = KiloInterface<Session>().apply {
|
||||
* onConnected { session.data = "start" }
|
||||
* on(cmdSave) { session.data = it }
|
||||
* on(cmdLoad) {
|
||||
* session.data
|
||||
* }
|
||||
* on(cmdException) {
|
||||
* throw TestException()
|
||||
* }
|
||||
* on(cmdDrop) {
|
||||
* throw LocalInterface.BreakConnectionException()
|
||||
* }
|
||||
* }
|
||||
* // Now create a server to accept incoming UDPs on our port:
|
||||
* val server = KiloServer(cli, acceptUdpDevice(port)) {
|
||||
* // This initializes new session for each incoming command
|
||||
* Session("unknown")
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* See [connectUdpDevice] for the client sample.
|
||||
*
|
||||
* When it is necessary to stop listening to some port, use [UdpServer] instead.
|
||||
*
|
||||
* @param port port to listen
|
||||
* @param localInterface string form local interface to listen
|
||||
* @param maxInactivityTimeout maximum silence time after which the connection is supposed to be lost.
|
||||
* the module automatically issues pings on inactivity when there is no data often enough
|
||||
* to maintain the connection open.
|
||||
*/
|
||||
fun acceptUdpDevice(
|
||||
port: Int,
|
||||
localInterface: String = "0.0.0.0",
|
||||
maxInactivityTimeout: Duration = 2.minutes,
|
||||
): Flow<InetTransportDevice> =
|
||||
UdpServer(port, localInterface,maxInactivityTimeout).transportFlow
|
||||
|
||||
/**
|
||||
* Connect to UDP server (see [acceptUdpDevice] or [UdpServer]) and return a [InetTransportDevice] for it. It
|
||||
* should be used with [KiloClient] as connection provider:
|
||||
* ```kotlin
|
||||
* val client = KiloClient<Unit>() {
|
||||
* connect { connectUdpDevice("localhost:$port") }
|
||||
* }
|
||||
* // now we can execute remote commands:
|
||||
* assertEquals("start", client.call(cmdLoad))
|
||||
* ```
|
||||
*
|
||||
* @param hostPort "host:port" string address of the remote UDP port to connect to
|
||||
* @param maxInactivityTimeout maximum silence time after which the connection is supposed to be lost.
|
||||
* the module automatically issues pings on inactivity when there is no data often enough
|
||||
* to maintain the connection open.
|
||||
*/
|
||||
suspend fun connectUdpDevice(
|
||||
hostPort: String,
|
||||
maxInactivityTimeout: Duration = 2.minutes,
|
||||
) = connectUdpDevice(hostPort.toNetworkAddress(), maxInactivityTimeout)
|
||||
|
||||
/**
|
||||
* Connect to UDP server (see [acceptUdpDevice]) and return a [InetTransportDevice] for it. It
|
||||
* should be used with [KiloClient] as connection provider:
|
||||
* ```kotlin
|
||||
* val client = KiloClient<Unit>() {
|
||||
* connect { connectUdpDevice("localhost:$port") }
|
||||
* }
|
||||
* // now we can execute remote commands:
|
||||
* assertEquals("start", client.call(cmdLoad))
|
||||
* ```
|
||||
* @param addr the network address where to connect to
|
||||
* @param maxInactivityTimeout maximum silence time after which the connection is supposed to be lost.
|
||||
* the module automatically issues pings on inactivity when there is no data often enough
|
||||
* to maintain the connection open.
|
||||
*/
|
||||
suspend fun connectUdpDevice(
|
||||
addr: NetworkAddress,
|
||||
maxInactivityTimeout: Duration = 2.minutes,
|
||||
): InetTransportDevice {
|
||||
val selectorManager = SelectorManager(Dispatchers.IO)
|
||||
val remoteAddress = InetSocketAddress(addr.host, addr.port)
|
||||
|
||||
val done = CompletableDeferred<Unit>()
|
||||
|
||||
val socket = aSocket(selectorManager).udp().connect(remoteAddress)
|
||||
val transport = UdpSocketTransport(object : UdpConnector {
|
||||
override suspend fun sendBlock(block: UdpBlock, toAddress: SocketAddress) {
|
||||
socket.send(block.toDatagram(remoteAddress))
|
||||
}
|
||||
|
||||
override suspend fun disconnectClient(address: SocketAddress) {
|
||||
done.complete(Unit)
|
||||
}
|
||||
|
||||
}, remoteAddress, false, maxInactivityTimeout)
|
||||
|
||||
globalLaunch {
|
||||
launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
transport.processIncoming(UdpBlock.decode(socket.receive()))
|
||||
} catch (_: CancellationException) {
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
transport.close()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
done.await()
|
||||
}
|
||||
|
||||
return transport.transportDevice
|
||||
}
|
@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import io.ktor.network.selector.*
|
||||
import io.ktor.network.sockets.*
|
||||
import io.ktor.utils.io.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import net.sergeych.kiloparsec.KiloServer
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.Loggable
|
||||
import net.sergeych.mp_logger.debug
|
||||
import net.sergeych.mp_logger.exception
|
||||
import net.sergeych.mp_tools.globalDefer
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* UDP server for kiloparsec. Unlike [acceptUdpDevice], it allow stopping listening
|
||||
* to the port when need with [close]. Use [transportFlow] with [KiloServer], here is the
|
||||
* basic sample:
|
||||
*
|
||||
* ```kotlin
|
||||
* val uServer = UdpServer(port)
|
||||
* KiloServer(cli, uServer.transportFlow()) {
|
||||
* Session("unknown")
|
||||
* }
|
||||
*
|
||||
* // server is now active and accepts connections
|
||||
* // ...
|
||||
*
|
||||
* // close and stop listening to the port:
|
||||
* uServer.close()
|
||||
* ```
|
||||
*
|
||||
* See [acceptUdpDevice] for more information.
|
||||
*
|
||||
* @param port port to listen to
|
||||
* @param localInterface string form of local interface to listen to
|
||||
* @param maxInactivityTimeout maximum silence time after which the connection is supposed to be lost.
|
||||
* the module automatically issues pings on inactivity when there is no data often enough
|
||||
* to maintain the connection open.
|
||||
|
||||
*/
|
||||
class UdpServer(val port: Int, localInterface: String = "0.0.0.0", maxInactivityTimeout: Duration = 2.minutes) :
|
||||
Loggable by LogTag("UDPS${udpCounter.incrementAndGet()}"), UdpConnector {
|
||||
|
||||
private val sessions = mutableMapOf<SocketAddress, UdpSocketTransport>()
|
||||
private val access = Mutex()
|
||||
|
||||
private val selectorManager = SelectorManager(Dispatchers.IO)
|
||||
private val serverSocket = globalDefer {
|
||||
aSocket(selectorManager).udp().bind(InetSocketAddress(localInterface, port))
|
||||
}
|
||||
|
||||
override suspend fun disconnectClient(address: SocketAddress) {
|
||||
access.withLock { sessions.remove(address) }
|
||||
}
|
||||
|
||||
/**
|
||||
* a transport flow of [InetTransportDevice] suitable to be used with [KiloServer], see [UdpServer] for the
|
||||
* usage sample.
|
||||
*/
|
||||
val transportFlow by lazy {
|
||||
flow {
|
||||
while (true) {
|
||||
try {
|
||||
val datagram = serverSocket.await().receive()
|
||||
val block = UdpBlock.decode(datagram)
|
||||
val remoteAddress = datagram.address
|
||||
|
||||
access.withLock {
|
||||
if (block == UdpBlock.Cancel) {
|
||||
// if the cancel comes to already closed transport, do nothing
|
||||
sessions.remove(remoteAddress)?.processIncoming(block)
|
||||
} else {
|
||||
sessions.getOrPut(remoteAddress) {
|
||||
// new connection: create transport
|
||||
debug { "Creating new connection to $remoteAddress" }
|
||||
UdpSocketTransport(this@UdpServer, remoteAddress, true, maxInactivityTimeout)
|
||||
// and emit it:
|
||||
.also { emit(it.transportDevice) }
|
||||
}.processIncoming(block)
|
||||
}
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
break
|
||||
} catch (_: ClosedReceiveChannelException) {
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
exception { "unexpected exception in incoming datagram processing" to e }
|
||||
close()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendBlock(block: UdpBlock, toAddress: SocketAddress) {
|
||||
serverSocket.await().send(block.toDatagram(toAddress))
|
||||
}
|
||||
|
||||
suspend fun isClosed(): Boolean = serverSocket.await().isClosed
|
||||
|
||||
/**
|
||||
* Close the UDP server. Calling it will cause:
|
||||
*
|
||||
* - Closing nound UDP socket on [port]
|
||||
* - Closing all pending connections
|
||||
* - cancelling the [transportFlow], which will cause Kiloparsec server to also stop
|
||||
*
|
||||
* Call suspends until socket and all sessions are closed. Later calls do nothing.
|
||||
*/
|
||||
suspend fun close() {
|
||||
access.withLock {
|
||||
if (!isClosed()) {
|
||||
runCatching { serverSocket.await().close() }
|
||||
}
|
||||
}
|
||||
while (sessions.isNotEmpty()) {
|
||||
runCatching {
|
||||
access.withLock { sessions.values.firstOrNull() }
|
||||
?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import io.ktor.network.sockets.*
|
||||
import io.ktor.utils.io.*
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.channels.ClosedSendChannelException
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Clock
|
||||
import net.sergeych.kiloparsec.SyncValue
|
||||
import net.sergeych.mp_logger.Log
|
||||
import net.sergeych.mp_logger.Loggable
|
||||
import net.sergeych.mp_logger.debug
|
||||
import net.sergeych.mp_logger.exception
|
||||
import net.sergeych.mp_tools.globalLaunch
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* This is a common part of UDP transport shared between client and server connections.
|
||||
* It should not be used directly but bu the [UdpServer], [acceptUdpDevice] and [connectUdpDevice]
|
||||
* respectively.
|
||||
*/
|
||||
internal class UdpSocketTransport(
|
||||
private val server: UdpConnector,
|
||||
val socketAddress: SocketAddress,
|
||||
val isServer: Boolean,
|
||||
val maxInactivityTimeout: Duration
|
||||
) :
|
||||
Loggable {
|
||||
|
||||
// IMPORTANT! Log stuff must be the first (or you shot your leg):
|
||||
val address = (socketAddress as InetSocketAddress).let { NetworkAddress(it.hostname, it.port) }
|
||||
override var logTag: String = "UDPT:$address${if (isServer) ":server" else ":client"}"
|
||||
override var logLevel: Log.Level? = Log.Level.DEBUG
|
||||
|
||||
// Pinger params: keep them first!
|
||||
private var lastSendAt = Clock.System.now()
|
||||
private var lastReceived = Clock.System.now()
|
||||
private val pingTimeout = maxInactivityTimeout / 3
|
||||
private val pingSleep = pingTimeout / 3
|
||||
private val pingMinTimeout = pingTimeout * 2 / 3
|
||||
|
||||
val inputDataBlocks = Channel<UByteArray>(256, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val outputDataBlocks = Channel<UByteArray>(256, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
val inputUdpBlocks = Channel<UdpBlock>(256, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
private val job = globalLaunch {
|
||||
coroutineScope {
|
||||
launch { convertOutput() }
|
||||
launch { convertInput() }
|
||||
launch { pinger() }
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// This is iverly important: it requires that
|
||||
// all members are initialized before use. Otherwise kotlin
|
||||
// may execute class members pr
|
||||
debug { "initialization done" }
|
||||
}
|
||||
|
||||
|
||||
val transportDevice: InetTransportDevice by lazy {
|
||||
InetTransportDevice(inputDataBlocks, outputDataBlocks, address, { close() }, {})
|
||||
}
|
||||
|
||||
private val closedFlag = SyncValue(false)
|
||||
|
||||
val isClosed: Boolean = closedFlag.value
|
||||
|
||||
suspend fun close() {
|
||||
closedFlag.mutate {
|
||||
if (!it) {
|
||||
runCatching { server.sendBlock(UdpBlock.Cancel, socketAddress) }
|
||||
server.disconnectClient(socketAddress)
|
||||
runCatching { inputDataBlocks.close() }
|
||||
runCatching { outputDataBlocks.close() }
|
||||
job.cancel()
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private suspend fun send(block: UdpBlock) {
|
||||
server.sendBlock(block, socketAddress)
|
||||
lastSendAt = Clock.System.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the block recoded by the server. Note that it should properly process all
|
||||
* block types, e.g. close on [UdpBlock.Cancel], etc. Server will not close us!
|
||||
*
|
||||
* __Important: it should not block, instead, server expects it to return ASAP__, so it
|
||||
* executes in a local coroutine context.
|
||||
*
|
||||
* Also it should not throw exceptions.
|
||||
*/
|
||||
fun processIncoming(block: UdpBlock) {
|
||||
inputUdpBlocks.trySend(block)
|
||||
}
|
||||
|
||||
suspend fun convertInput() {
|
||||
while(!isClosed) {
|
||||
when (val block = inputUdpBlocks.receiveCatching().getOrNull()) {
|
||||
|
||||
null -> break
|
||||
|
||||
is UdpBlock.Cancel -> globalLaunch {
|
||||
debug { "received cancel block, requesting close" }
|
||||
kotlin.runCatching { close() }
|
||||
}
|
||||
|
||||
is UdpBlock.Data -> {
|
||||
// input does not block, it uses DROP_OLDEST policy
|
||||
lastReceived = Clock.System.now()
|
||||
val result = kotlin.runCatching { inputDataBlocks.send(block.data) }
|
||||
when (val e = result.exceptionOrNull()) {
|
||||
null -> {}
|
||||
is ClosedSendChannelException -> {
|
||||
debug { "received close channel" }
|
||||
close()
|
||||
}
|
||||
|
||||
is CancellationException -> {}
|
||||
else -> {
|
||||
exception { "unexpected exception" to e }
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UdpBlock.Ping -> {
|
||||
lastReceived = Clock.System.now()
|
||||
if (lastSendAt - lastReceived > pingMinTimeout) send(UdpBlock.Ping)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pinger() {
|
||||
while (!isClosed) {
|
||||
delay(pingSleep)
|
||||
val inactivity = Clock.System.now() - lastSendAt
|
||||
if( inactivity > maxInactivityTimeout) {
|
||||
debug { "inactivity timout: closing the connection" }
|
||||
close()
|
||||
}
|
||||
if (inactivity >= pingTimeout) {
|
||||
debug { "pinger sends a ping on timeout" }
|
||||
send(UdpBlock.Ping)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun convertOutput() {
|
||||
while (!isClosed) {
|
||||
try {
|
||||
server.sendBlock(UdpBlock.Data(outputDataBlocks.receive()), socketAddress)
|
||||
} catch (e: CancellationException) {
|
||||
// this is ok
|
||||
break
|
||||
} catch (e: ClosedReceiveChannelException) {
|
||||
debug { "input channel is closed, closing" }
|
||||
close()
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
exception { "unexpected exception in convertOutput" to e }
|
||||
close()
|
||||
break
|
||||
}
|
||||
}
|
||||
debug { "exiting convertOutput" }
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user