Compare commits
410 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
97353d430b
|
|||
|
1d3a969742
|
|||
|
bad92193a3
|
|||
|
6560949ec4
|
|||
|
d6152ccd85
|
|||
|
e6fd7188d2
|
|||
|
bc44ed2aaa
|
|||
|
e5ac206e5c
|
|||
|
b74012099c
|
|||
|
ae1d290fa7
|
|||
|
562f850b8f
|
|||
|
ff0088644e
|
|||
|
aee52bf56c
|
|||
|
fd951a10f8
|
|||
|
11d4c6407e
|
|||
|
1be94dc0ba
|
|||
|
b30a1ba3eb
|
|||
|
9f755aec21
|
|||
|
1a579bc716
|
|||
|
8124d95192
|
|||
|
a59dca45a7
|
|||
|
1106215241
|
|||
|
ee61747e20
|
|||
|
4c1c819e42
|
|||
|
078fa0f17d
|
|||
|
876476cff5
|
|||
|
899b08e92e
|
|||
|
eec73d2d93
|
|||
|
6888eccc62
|
|||
|
6fa0187ae1
|
|||
|
2ba3f059a1
|
|||
|
63454b3bbb
|
|||
|
9f36e37f94
|
|||
|
73b982c6e0
|
|||
|
22c54f2252
|
|||
|
d68a6cfb9c
|
|||
|
8267123fb5
|
|||
|
a540b64331
|
|||
|
b0d2d4778f
|
|||
|
6cb90d3c4b
|
|||
|
009755c981
|
|||
|
595430c808
|
|||
|
e9b647d5a7
|
|||
|
7d57888696
|
|||
|
cbe2df02ad
|
|||
|
ff893945e9
|
|||
|
b705427bc9
|
|||
|
88083be84e
|
|||
|
4ea1fd1f28
|
|||
|
1ad1f3cfd2
|
|||
|
1281731a81
|
|||
|
8d97c29b19
|
|||
|
f8712b35b8
|
|||
|
0051405033
|
|||
|
3a14394640
|
|||
|
2faf1fb5a2
|
|||
|
e51baa8673
|
|||
|
21a0dafae6
|
|||
|
156d642fe6
|
|||
|
20a1da6a56
|
|||
|
0a3d0c9440
|
|||
|
04bd23e753
|
|||
|
a01b253473
|
|||
|
53b981cb21
|
|||
|
6cc82159cf
|
|||
|
5a9bfd74d3
|
|||
|
1c3de51913
|
|||
|
25e0719954
|
|||
|
cbff6e4bc4
|
|||
|
4d6eda36c0
|
|||
|
18200213f0
|
|||
|
3cb375e7f2
|
|||
|
53d418d753
|
|||
|
ac0407c954
|
|||
|
83abefcf43
|
|||
|
3d4b77bee3
|
|||
|
71b6032ea4
|
|||
|
3675d63418
|
|||
|
37652b2d33
|
|||
|
5c19b76367
|
|||
|
f96af89269
|
|||
|
6704c620c8
|
|||
|
738aa9528a
|
|||
|
ff1589216b
|
|||
|
3af2be27d8
|
|||
|
8e777bef03
|
|||
|
220912989e
|
|||
|
177dc3a099
|
|||
|
df3497b725
|
|||
|
e3d65525b8
|
|||
|
474bb2ff33
|
|||
|
5707c230e2
|
|||
|
4ae59c9716
|
|||
|
59060d6002
|
|||
|
ed413b62a1
|
|||
|
b60d91aa17
|
|||
|
d465f103ec
|
|||
|
fcfd04c0c2
|
|||
|
b25f2c2bdc
|
|||
|
7bb127526c
|
|||
|
48f8288577
|
|||
|
7086926839
|
|||
|
6c3bdaa743
|
|||
|
d8d38fdfe4
|
|||
|
82dad74ba8
|
|||
|
b630122d78
|
|||
|
99e4d92e8e
|
|||
|
9fa712c0b1
|
|||
|
87fc514f32
|
|||
|
1f6f8580a8
|
|||
|
5a3fed8f8b
|
|||
|
5e4b21a431
|
|||
|
aea98d4cae
|
|||
|
8aa30a39e4
|
|||
|
c3893eb33d
|
|||
|
53e98c73af
|
|||
|
2fc9446687
|
|||
|
49aee4818b
|
|||
|
430290deaf
|
|||
|
2ae1143b19
|
|||
|
a34c211872
|
|||
|
fda77ba10d
|
|||
|
9a93a08b85
|
|||
|
25fa49ffe2
|
|||
|
96c68f2aff
|
|||
|
30210d0714
|
|||
|
e8fc291a01
|
|||
|
ea0797de68
|
|||
|
3cacfadf27
|
|||
|
45878f0666
|
|||
|
483234eee0
|
|||
|
82523cc7df
|
|||
|
634ff693de
|
|||
|
ea36ba7a65
|
|||
|
8e243a7c8b
|
|||
|
dd4383a526
|
|||
|
75ddddd537
|
|||
|
6b3fae179f
|
|||
|
a1834f35f7
|
|||
|
804e8ed997
|
|||
|
c46d15fc90
|
|||
|
06ac57c677
|
|||
|
ea5c9147da
|
|||
|
49bc7fe7f6
|
|||
|
9b1f45ff77
|
|||
|
0efd83f0e8
|
|||
|
7dc9f94771
|
|||
|
ae729c2ca7
|
|||
|
3b3389118f
|
|||
|
66b11b4f39
|
|||
|
afc0779edf
|
|||
|
a6546d3566
|
|||
|
e59fee8e60
|
|||
| 395b180757 | |||
| b743f57690 | |||
| bd0c94109e | |||
| 6896672562 | |||
| fb69af9bf4 | |||
|
dd87da4a51
|
|||
|
c8d231556c
|
|||
|
b489135c5b
|
|||
|
1d83e7f539
|
|||
|
565b13a0eb
|
|||
|
52f8e21da0
|
|||
|
ad5b6ed83a
|
|||
|
cced3f5092
|
|||
|
3b2a8591a7
|
|||
|
f513c6abcd
|
|||
|
e83bc31ccc
|
|||
|
1e67bc56b6
|
|||
|
b0e6ce93f9
|
|||
|
0d2239be83
|
|||
|
cfcdb62168
|
|||
|
d14692b19b
|
|||
|
ca3bef0635
|
|||
|
59486330ec
|
|||
|
1133e9755d
|
|||
|
b450aa8569
|
|||
|
6aef9e9337
|
|||
|
678ab32ee6
|
|||
|
4e37112aee
|
|||
|
af046370db
|
|||
|
eb40fd6451
|
|||
|
763fb4a962
|
|||
|
673f19b1ff
|
|||
|
4a20551e9a
|
|||
|
1ac5696c80
|
|||
|
5c2ea259b8
|
|||
|
7573e942f1
|
|||
|
d3cf775394
|
|||
|
5e19f6f802
|
|||
|
72d70b2141
|
|||
|
06da42a148
|
|||
|
ec8b843cd4
|
|||
|
9e7e9a71ca
|
|||
|
b0669954a4
|
|||
|
ded5853026
|
|||
|
426422413c
|
|||
|
50c8546344
|
|||
|
b0fad14504
|
|||
|
24aa4fa88b
|
|||
|
189645940c
|
|||
|
4f37df2fc0
|
|||
|
4859f5513a
|
|||
|
2a2d6d6515
|
|||
|
d0c111d2f5
|
|||
|
3d943aaaef
|
|||
|
d5d7fbdb79
|
|||
|
fdfe895d2d
|
|||
|
01e639133b
|
|||
|
6c6953e664
|
|||
|
b4039dc148
|
|||
|
ba18fba43e
|
|||
|
f4a929ce3a
|
|||
|
fe66163ef7
|
|||
|
2cc34172c8
|
|||
|
9331c4edbd
|
|||
|
f097bb3241
|
|||
|
22fc5093db
|
|||
|
fc95e54b2e
|
|||
|
636d400f1e
|
|||
| fd5eb65bc0 | |||
| 4e13fe523b | |||
| dd2cc3e3d9 | |||
| 353e9c6d9b | |||
| 088ba3337d | |||
| 4cd2338095 | |||
| c6cc1d8ca8 | |||
| 0afb0e9ade | |||
| feeaa72102 | |||
| bb964445f3 | |||
| 5369037a74 | |||
| bb98248830 | |||
| 575657bbc5 | |||
| 8da4a759f5 | |||
| dff1489ee5 | |||
| 30c97bc9dd | |||
| 005e2566aa | |||
| cc10830df3 | |||
| b548e5711e | |||
| cc89bfef6e | |||
| 45a3ac1e87 | |||
| e39936ac30 | |||
| b601ae1c51 | |||
| 7d7a022736 | |||
| 0ac2a8d200 | |||
| f3808a73e1 | |||
| cb908fb143 | |||
| f53194be25 | |||
| ad732d1465 | |||
| b70a7d03af | |||
| 911fe3ea8e | |||
| b59bb349dc | |||
| 08cbacd69f | |||
| 9a70a92261 | |||
| be34168a1b | |||
| cebab6b2f3 | |||
| fdcb371582 | |||
| f01b1f8bac | |||
| a0eca36884 | |||
| 972d00df92 | |||
| 483b6e562b | |||
| cbb5ffa970 | |||
| b7cc0c39b4 | |||
| 982c173760 | |||
| 49ca73ab3a | |||
| 43b224b4d7 | |||
| 456a95d569 | |||
| 53b2d18a79 | |||
| 8d7f86e15a | |||
| 40213eeac9 | |||
| 5cb8b12a0f | |||
| 2f165186d1 | |||
| 6cd3b15d78 | |||
| 98c8d35f1e | |||
| 064b2b10b8 | |||
| a8d78d2784 | |||
| 5a0c70190f | |||
| d5bf7dc720 | |||
| 8b4bca7939 | |||
| c004ff1a97 | |||
| 38323da57d | |||
| 2ffd12b3e1 | |||
| 069d4163eb | |||
| 93e1317789 | |||
| 3b270e05c4 | |||
| a05818b3a7 | |||
| df2b0a0079 | |||
| c507e9125b | |||
| 767110f3d0 | |||
|
|
8e5f193caf | ||
| fed33aadff | |||
| d0c83ec1a2 | |||
| aa94bee606 | |||
| 745609423f | |||
| 16e1c7e4eb | |||
| aec3672228 | |||
| aace3abd6d | |||
| ca3fefaae8 | |||
| d4f89735f6 | |||
| b37d393286 | |||
| 5e0c829cf6 | |||
| a80f2bb2ac | |||
| 7de206447a | |||
| f740514e2b | |||
| b907dd93f1 | |||
| 011a6303eb | |||
| 12f487d937 | |||
| b9aebc8406 | |||
| ffb3c3d4f4 | |||
| f291ba74e9 | |||
| 6e87fc9bcd | |||
| cb402e2bb6 | |||
| fe5101340a | |||
| dfac66e8bc | |||
| bc05835dae | |||
|
|
26371cdb6a | ||
|
|
41db0500af | ||
|
|
8114c3bda4 | ||
|
|
3f141bf93b | ||
|
|
a9bf658b03 | ||
|
|
ae9a35e3bb | ||
|
|
32d32380d8 | ||
|
|
5e40f0bfe8 | ||
| 315b35fc81 | |||
| 54dec6aa89 | |||
| 92c8faec11 | |||
| 2aff4989e5 | |||
| f1d2a31be6 | |||
| f604d1a3c8 | |||
| 26a54436f7 | |||
| 2fd85a1034 | |||
| c8e81cd9f0 | |||
| 2f61ce9bf3 | |||
| b647e7c6c2 | |||
| 6b3990d399 | |||
| 041b439a66 | |||
| 534982b99d | |||
| 7379d07aba | |||
| 03345bc256 | |||
| e486923e8f | |||
| d7f41b785f | |||
| 15303a21dc | |||
| 4d4863aeeb | |||
| 76a4103a56 | |||
| 96348ce349 | |||
|
|
322711ba20 | ||
|
|
772248b31f | ||
|
|
fa1c80169e | ||
|
|
cb1e4a1115 | ||
|
|
836e97b17d | ||
|
|
87d3b4a58b | ||
|
|
77729e07e1 | ||
|
|
79e1caa815 | ||
|
|
a5b905bbaf | ||
|
|
c870406244 | ||
|
|
ea8daf6bb2 | ||
|
|
d79406e354 | ||
|
|
f9b8d29780 | ||
|
|
0cebfb2193 | ||
|
|
9e229287e8 | ||
|
|
9508e6e195 | ||
|
|
5acbef454f | ||
|
|
0862830431 | ||
|
|
6cdc02346f | ||
|
|
3ffd5b72a1 | ||
|
|
73af84e24f | ||
|
|
ae40d2879c | ||
|
|
a2499e4a15 | ||
|
|
30ea1dd0c7 | ||
|
|
785bc7d782 | ||
|
|
144f5bea6a | ||
|
|
a3c701e205 | ||
|
|
a8a7607eb6 | ||
|
|
a2947a3adb | ||
|
|
2cb37102fb | ||
|
|
54c401e2a5 | ||
|
|
8df4039b18 | ||
|
|
12156adae9 | ||
|
|
a34e3d274e | ||
|
|
f3d22dfcd4 | ||
|
|
99d8e44182 | ||
|
|
083991c997 | ||
|
|
9ca24d96ab | ||
|
|
b478ca346e | ||
|
|
20b532e005 | ||
|
|
80eac50632 | ||
|
|
f15d8f6a84 | ||
|
|
c523d6f542 | ||
|
|
8a175e3051 | ||
|
|
28d46921d3 | ||
|
|
613ceddb0b | ||
|
|
599dd91979 | ||
|
|
e724886578 | ||
|
|
3034c0b0b4 | ||
|
|
3ed2c67742 | ||
|
|
f2c146b7c5 | ||
|
|
59cef5e56a | ||
|
|
ef613cc873 | ||
|
|
7a7ce84778 | ||
|
|
7ef7e60a87 | ||
|
|
73349d4a28 | ||
|
|
31128a6758 | ||
|
|
566ce5da96 | ||
|
|
139926be05 | ||
|
|
decbd8f29a | ||
|
|
0f5f5cbb13 | ||
|
|
a2476c9551 | ||
|
|
bfc75a2290 | ||
|
|
2e01fa565d |
36
.dockerignore
Normal file
36
.dockerignore
Normal file
@@ -0,0 +1,36 @@
|
||||
# Binaries and build folders
|
||||
bin/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Go modules' cache
|
||||
vendor/
|
||||
|
||||
# Local test/coverage/log artifacts
|
||||
*.test
|
||||
*.out
|
||||
*.log
|
||||
logs/
|
||||
coverage.out
|
||||
|
||||
# Environment and secret files
|
||||
.env
|
||||
|
||||
# User/IDE/Editor config
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Example and generated files
|
||||
examples/
|
||||
*.json
|
||||
|
||||
# SBOM and analysis artifacts
|
||||
bom.json
|
||||
dependency-results.sbom.json
|
||||
*.sbom.json
|
||||
|
||||
27
.gitea/workflows/bearer.yml
Normal file
27
.gitea/workflows/bearer.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Bearer
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Source
|
||||
uses: https://git.quad4.io/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Run Bearer Security Scanner
|
||||
uses: https://git.quad4.io/actions/bearer-action@828eeb928ce2f4a7ca5ed57fb8b59508cb8c79bc # v2
|
||||
with:
|
||||
path: ./
|
||||
|
||||
105
.gitea/workflows/build.yml
Normal file
105
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,105 @@
|
||||
name: Go Build Multi-Platform
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "master" ]
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches: [ "main", "master" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [linux, windows, darwin, freebsd]
|
||||
goarch: [amd64, arm64, arm]
|
||||
include:
|
||||
- goos: js
|
||||
goarch: wasm
|
||||
exclude:
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
build_complete: ${{ steps.build_step.outcome == 'success' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: https://git.quad4.io/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: https://git.quad4.io/actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Setup Task
|
||||
uses: https://git.quad4.io/actions/setup-task@0ab1b2a65bc55236a3bc64cde78f80e20e8885c2 # v1
|
||||
with:
|
||||
version: '3.46.3'
|
||||
|
||||
- name: Build
|
||||
id: build_step
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarch == 'arm' && '6' || '' }}
|
||||
CGO_ENABLED: '0'
|
||||
run: |
|
||||
output_name="reticulum-go-${GOOS}-${GOARCH}"
|
||||
if [ "$GOOS" = "js" ] && [ "$GOARCH" = "wasm" ]; then
|
||||
task build-wasm
|
||||
output_name+=".wasm"
|
||||
mv bin/reticulum-go.wasm "${output_name}"
|
||||
else
|
||||
task build
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
output_name+=".exe"
|
||||
fi
|
||||
mv bin/reticulum-go "${output_name}"
|
||||
fi
|
||||
echo "Built: ${output_name}"
|
||||
|
||||
- name: Calculate SHA256 Checksum
|
||||
run: |
|
||||
output_name="reticulum-go-${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
output_name+=".exe"
|
||||
elif [ "${{ matrix.goos }}" = "js" ] && [ "${{ matrix.goarch }}" = "wasm" ]; then
|
||||
output_name+=".wasm"
|
||||
fi
|
||||
BINARY_PATH="${output_name}" task checksum
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: https://git.quad4.io/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3.2.1
|
||||
with:
|
||||
name: reticulum-go-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: |
|
||||
reticulum-go-${{ matrix.goos }}-${{ matrix.goarch }}*
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download All Build Artifacts
|
||||
uses: https://git.quad4.io/actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
|
||||
with:
|
||||
path: ./release-assets
|
||||
|
||||
- name: List downloaded files (for debugging)
|
||||
run: ls -R ./release-assets
|
||||
|
||||
- name: Create Gitea Release
|
||||
uses: https://git.quad4.io/actions/gitea-release-action@4875285c0950474efb7ca2df55233c51333eeb74
|
||||
with:
|
||||
files: ./release-assets/*/*
|
||||
105
.gitea/workflows/go-test.yml
Normal file
105
.gitea/workflows/go-test.yml
Normal file
@@ -0,0 +1,105 @@
|
||||
name: Go Test Multi-Platform
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test (${{ matrix.os }}, ${{ matrix.goarch }})
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# AMD64 testing on Linux
|
||||
- os: ubuntu-latest
|
||||
goarch: amd64
|
||||
# ARM64 testing on Linux
|
||||
- os: ubuntu-latest
|
||||
goarch: arm64
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Source
|
||||
uses: https://git.quad4.io/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up Go 1.25
|
||||
uses: https://git.quad4.io/actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Setup Task
|
||||
uses: https://git.quad4.io/actions/setup-task@0ab1b2a65bc55236a3bc64cde78f80e20e8885c2 # v1
|
||||
with:
|
||||
version: '3.46.3'
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: https://git.quad4.io/actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: ${{ runner.os }}-go-${{ matrix.goarch }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-${{ matrix.goarch }}-
|
||||
|
||||
- name: Set up Node.js
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.goarch == 'amd64'
|
||||
uses: https://git.quad4.io/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Run tests
|
||||
run: task test
|
||||
|
||||
- name: Run tests with race detector (Linux AMD64 only)
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.goarch == 'amd64'
|
||||
run: task test-race
|
||||
|
||||
- name: Run WebAssembly tests (Linux AMD64 only)
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.goarch == 'amd64'
|
||||
run: |
|
||||
chmod +x misc/wasm/go_js_wasm_exec
|
||||
task test-wasm
|
||||
|
||||
- name: Test build (ensure compilation works)
|
||||
run: |
|
||||
echo "Testing build for current platform (${{ matrix.os }}, ${{ matrix.goarch }})..."
|
||||
task build
|
||||
|
||||
- name: Test WebAssembly build (Linux AMD64 only)
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.goarch == 'amd64'
|
||||
run: task build-wasm
|
||||
|
||||
- name: Test binary execution
|
||||
run: |
|
||||
echo "Testing binary execution on (${{ matrix.os }}, ${{ matrix.goarch }})..."
|
||||
timeout 5s ./bin/reticulum-go || echo "Binary started successfully (timeout expected)"
|
||||
|
||||
- name: Test cross-compilation (AMD64 runners only)
|
||||
if: matrix.goarch == 'amd64'
|
||||
run: |
|
||||
echo "Testing ARM64 cross-compilation from AMD64..."
|
||||
GOOS=linux GOARCH=arm64 task build
|
||||
env:
|
||||
GOOS: linux
|
||||
GOARCH: arm64
|
||||
|
||||
- name: Test ARMv6 cross-compilation (AMD64 runners only)
|
||||
if: matrix.goarch == 'amd64'
|
||||
run: |
|
||||
echo "Testing ARMv6 cross-compilation from AMD64..."
|
||||
GOOS=linux GOARCH=arm GOARM=6 task build
|
||||
env:
|
||||
GOOS: linux
|
||||
GOARCH: arm
|
||||
GOARM: 6
|
||||
27
.gitea/workflows/gosec.yml
Normal file
27
.gitea/workflows/gosec.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Run Gosec
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GO111MODULE: on
|
||||
steps:
|
||||
- name: Checkout Source
|
||||
uses: https://git.quad4.io/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Run Gosec Security Scanner
|
||||
uses: https://git.quad4.io/actions/gosec@c073629009897d89e03229bc81232c7375892086
|
||||
with:
|
||||
args: ./...
|
||||
33
.gitea/workflows/revive.yml
Normal file
33
.gitea/workflows/revive.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Go Revive Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "master" ]
|
||||
pull_request:
|
||||
branches: [ "main", "master" ]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: https://git.quad4.io/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: https://git.quad4.io/actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Setup Task
|
||||
uses: https://git.quad4.io/actions/setup-task@0ab1b2a65bc55236a3bc64cde78f80e20e8885c2 # v1
|
||||
with:
|
||||
version: '3.46.3'
|
||||
|
||||
- name: Install revive
|
||||
run: go install github.com/mgechev/revive@latest
|
||||
|
||||
- name: Run lint
|
||||
run: task lint
|
||||
53
.gitea/workflows/sbom.yml
Normal file
53
.gitea/workflows/sbom.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Generate SBOM
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
generate-sbom:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: https://git.quad4.io/actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.25.5'
|
||||
|
||||
- name: Setup Task
|
||||
uses: https://git.quad4.io/actions/setup-task@0ab1b2a65bc55236a3bc64cde78f80e20e8885c2 # v1
|
||||
with:
|
||||
version: '3.46.3'
|
||||
|
||||
- name: Install dependencies
|
||||
run: task deps
|
||||
|
||||
- name: Install Trivy
|
||||
run: task trivy:install
|
||||
|
||||
- name: Generate SBOM
|
||||
run: task sbom
|
||||
|
||||
- name: Commit and Push Changes
|
||||
run: |
|
||||
git config --global user.name "Gitea Action"
|
||||
git config --global user.email "actions@noreply.quad4.io"
|
||||
git remote set-url origin https://${{ secrets.GITEA_TOKEN }}@git.quad4.io/${{ github.repository }}.git
|
||||
git fetch origin main || git fetch origin master
|
||||
git checkout main || git checkout master
|
||||
git add sbom/
|
||||
if ! git diff --quiet || ! git diff --staged --quiet; then
|
||||
git commit -m "Auto-update SBOM [skip ci]"
|
||||
git push origin main || git push origin master
|
||||
fi
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
64
.gitea/workflows/tinygo.yml
Normal file
64
.gitea/workflows/tinygo.yml
Normal file
@@ -0,0 +1,64 @@
|
||||
name: TinyGo Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "tinygo" ]
|
||||
pull_request:
|
||||
branches: [ "tinygo" ]
|
||||
|
||||
jobs:
|
||||
tinygo-build:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: tinygo-default
|
||||
target: ""
|
||||
output: reticulum-go-tinygo
|
||||
make_target: tinygo-build
|
||||
- name: tinygo-wasm
|
||||
target: wasm
|
||||
output: reticulum-go.wasm
|
||||
make_target: tinygo-wasm
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
build_complete: ${{ steps.build_step.outcome == 'success' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: https://git.quad4.io/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: https://git.quad4.io/actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00
|
||||
with:
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Install TinyGo
|
||||
run: |
|
||||
wget https://github.com/tinygo-org/tinygo/releases/download/v0.37.0/tinygo_0.37.0_amd64.deb
|
||||
sudo dpkg -i tinygo_0.37.0_amd64.deb
|
||||
|
||||
- name: Build with TinyGo
|
||||
id: build_step
|
||||
run: |
|
||||
make ${{ matrix.make_target }}
|
||||
output_name="${{ matrix.output }}"
|
||||
if [ -f "bin/${output_name}" ]; then
|
||||
sha256sum "bin/${output_name}" | cut -d' ' -f1 > "bin/${output_name}.sha256"
|
||||
echo "Built: ${output_name}"
|
||||
echo "Generated checksum: bin/${output_name}.sha256"
|
||||
else
|
||||
echo "Build output not found: bin/${output_name}"
|
||||
ls -la bin/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: https://git.quad4.io/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
|
||||
with:
|
||||
name: ${{ matrix.name }}
|
||||
path: bin/${{ matrix.output }}*
|
||||
31
.gitignore
vendored
31
.gitignore
vendored
@@ -1,8 +1,31 @@
|
||||
reticulum-client
|
||||
reticulum-server
|
||||
|
||||
# Build artifacts
|
||||
bin/
|
||||
|
||||
# Test coverage reports
|
||||
coverage.out
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Local environment variables
|
||||
.env
|
||||
.json
|
||||
|
||||
# JSON assets and auto-generated exports
|
||||
*.json
|
||||
|
||||
# Example files, not adding them just yet.
|
||||
/examples/*
|
||||
!/examples/wasm/
|
||||
|
||||
# OS / Editor files
|
||||
.DS_Store # macOS Finder metadata
|
||||
Thumbs.db # Windows Explorer thumbnail cache
|
||||
|
||||
# IDE / Editor config directories
|
||||
.idea/ # JetBrains IDEs
|
||||
.vscode/ # Visual Studio Code
|
||||
|
||||
# Swap and test binaries
|
||||
*.swp # Swap files (e.g. vim)
|
||||
*.test # Go test binaries
|
||||
7
CONTRIBUTING.md
Normal file
7
CONTRIBUTING.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Contributing
|
||||
|
||||
Send issues, suggestions, `.patch` files, or any feedback to one of the preferred methods:
|
||||
|
||||
1. Reticulum LXMF: `7cc8d66b4f6a0e0e49d34af7f6077b5a` - Ivan (main developer)
|
||||
2. XMPP: `ivan@chat.quad4.io` - Ivan (main developer)
|
||||
3. Email: `team@quad4.io` - Quad4 Team
|
||||
14
CONTRIBUTORS
Normal file
14
CONTRIBUTORS
Normal file
@@ -0,0 +1,14 @@
|
||||
CONTRIBUTORS
|
||||
|
||||
This file lists all contributors to the Reticulum-Go project.
|
||||
|
||||
Sudo-Ivan
|
||||
Total commits: 442
|
||||
First contribution: 2024-12-30
|
||||
Last contribution: 2026-01-01
|
||||
|
||||
|
||||
Mike Coles
|
||||
Total commits: 1
|
||||
First contribution: 2025-08-07
|
||||
Last contribution: 2025-08-07
|
||||
12
LICENSE
Normal file
12
LICENSE
Normal file
@@ -0,0 +1,12 @@
|
||||
Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
213
README.md
213
README.md
@@ -1,4 +1,215 @@
|
||||
# Reticulum-Go
|
||||
|
||||
Reticulum Network Stack in Go.
|
||||
[](https://git.quad4.io/Networks/Reticulum-Go/actions/workflows/revive.yml)
|
||||
[](https://git.quad4.io/Networks/Reticulum-Go/actions/workflows/build.yml)
|
||||
[](https://git.quad4.io/Networks/Reticulum-Go/actions/workflows/go-test.yml)
|
||||
[](https://git.quad4.io/Networks/Reticulum-Go/actions/workflows/gosec.yml)
|
||||
[](https://git.quad4.io/Networks/Reticulum-Go/actions/workflows/bearer.yml)
|
||||
|
||||
A high-performance Go implementation of the [Reticulum Network Stack](https://github.com/markqvist/Reticulum)
|
||||
|
||||
## Project Goals:
|
||||
|
||||
- **Full Protocol Compatibility**: Maintain complete interoperability with the Python reference implementation
|
||||
- **Cross-Platform Support**: Support for legacy and modern platforms across multiple architectures
|
||||
- **Performance**: Leverage Go's concurrency model and runtime for improved throughput and latency
|
||||
- **More Privacy and Security**: Additional privacy and security features beyond the base specification
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.24 or later
|
||||
- [Task](https://taskfile.dev/) for build automation
|
||||
|
||||
Note: You may need to set `alias task='go-task'` in your shell configuration to use `task` instead of `go-task`.
|
||||
|
||||
### Nix
|
||||
|
||||
If you have Nix installed, you can use the development shell which automatically provides all dependencies including Task:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
|
||||
This will enter a development environment with Go and Task pre-configured.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Building the Binary
|
||||
|
||||
```bash
|
||||
task build
|
||||
```
|
||||
|
||||
The compiled binary will be located in `bin/reticulum-go`.
|
||||
|
||||
### Running the Application
|
||||
|
||||
```bash
|
||||
task run
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
task test
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Code Quality
|
||||
|
||||
Format code:
|
||||
|
||||
```bash
|
||||
task fmt
|
||||
```
|
||||
|
||||
Run static analysis checks (formatting, vet, linting):
|
||||
|
||||
```bash
|
||||
task check
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Run all tests:
|
||||
|
||||
```bash
|
||||
task test
|
||||
```
|
||||
|
||||
Run short tests only:
|
||||
|
||||
```bash
|
||||
task test-short
|
||||
```
|
||||
|
||||
Generate coverage report:
|
||||
|
||||
```bash
|
||||
task coverage
|
||||
```
|
||||
|
||||
### Benchmarking
|
||||
|
||||
Run benchmarks with standard GC:
|
||||
|
||||
```bash
|
||||
task bench
|
||||
```
|
||||
|
||||
Run benchmarks with experimental Green Tea GC:
|
||||
|
||||
```bash
|
||||
task bench-experimental
|
||||
```
|
||||
|
||||
Compare both GC implementations:
|
||||
|
||||
```bash
|
||||
task bench-compare
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
The project uses [Task](https://taskfile.dev/) for all development and build operations.
|
||||
|
||||
```
|
||||
| Task | Description |
|
||||
|---------------------|------------------------------------------------------|
|
||||
| default | Show available tasks |
|
||||
| all | Clean, download dependencies, build and test |
|
||||
| build | Build release binary (stripped, static) |
|
||||
| debug | Build debug binary |
|
||||
| build-experimental | Build with experimental Green Tea GC (Go 1.25+) |
|
||||
| experimental | Alias for build-experimental |
|
||||
| release | Build stripped static binary for release |
|
||||
| fmt | Format Go code |
|
||||
| fmt-check | Check if code is formatted (CI-friendly) |
|
||||
| vet | Run go vet |
|
||||
| lint | Run revive linter |
|
||||
| scan | Run gosec security scanner |
|
||||
| check | Run fmt-check, vet, and lint |
|
||||
| bench | Run benchmarks with standard GC |
|
||||
| bench-experimental | Run benchmarks with experimental GC |
|
||||
| bench-compare | Run benchmarks with both GC settings |
|
||||
| clean | Remove build artifacts |
|
||||
| test | Run all tests |
|
||||
| test-short | Run short tests only |
|
||||
| test-race | Run tests with race detector |
|
||||
| coverage | Generate test coverage report |
|
||||
| checksum | Generate SHA256 checksum for binary |
|
||||
| deps | Download and verify dependencies |
|
||||
| mod-tidy | Tidy go.mod file |
|
||||
| mod-verify | Verify dependencies |
|
||||
| build-linux | Build for Linux (amd64, arm64, arm, riscv64) |
|
||||
| build-all | Build for all Linux architectures |
|
||||
| build-wasm | Build WebAssembly binary with standard Go compiler |
|
||||
| test-wasm | Run WebAssembly tests using Node.js |
|
||||
| run | Run with go run |
|
||||
| tinygo-build | Build binary with TinyGo compiler |
|
||||
| tinygo-wasm | Build WebAssembly binary with TinyGo |
|
||||
| install | Install dependencies |
|
||||
|
||||
example: task build
|
||||
```
|
||||
|
||||
## Cross-Platform Builds
|
||||
|
||||
### Linux Builds
|
||||
|
||||
Build for all Linux architectures:
|
||||
|
||||
```bash
|
||||
task build-all
|
||||
```
|
||||
|
||||
Build for specific Linux architecture:
|
||||
|
||||
```bash
|
||||
task build-linux
|
||||
```
|
||||
|
||||
## Embedded Systems and WebAssembly
|
||||
|
||||
For building for embedded systems, see the [tinygo branch](https://git.quad4.io/Networks/Reticulum-Go/src/branch/tinygo/). Requires TinyGo 0.37.0+.
|
||||
|
||||
Build WebAssembly binary with standard Go compiler:
|
||||
|
||||
```bash
|
||||
task build-wasm
|
||||
```
|
||||
|
||||
Run WebAssembly unit tests (requires Node.js):
|
||||
|
||||
```bash
|
||||
task test-wasm
|
||||
```
|
||||
|
||||
Build with TinyGo:
|
||||
|
||||
```bash
|
||||
task tinygo-build
|
||||
```
|
||||
|
||||
Build WebAssembly binary with TinyGo:
|
||||
|
||||
```bash
|
||||
task tinygo-wasm
|
||||
```
|
||||
|
||||
## Experimental Features
|
||||
|
||||
### Green Tea Garbage Collector
|
||||
|
||||
Build with experimental Green Tea GC (requires Go 1.25+):
|
||||
|
||||
```bash
|
||||
task build-experimental
|
||||
```
|
||||
|
||||
This enables the experimental garbage collector for performance evaluation and testing.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [0BSD](LICENSE) license.
|
||||
24
SECURITY.md
Normal file
24
SECURITY.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Security Policy
|
||||
|
||||
## Supply Chain Security
|
||||
|
||||
- All actions are pinned to a full-length commit hash and have been forked to my Gitea instance in https://git.quad4.io/actions
|
||||
- BOM generation using CycloneDX
|
||||
|
||||
## Cryptography Dependencies
|
||||
|
||||
- golang.org/x/crypto `v0.46.0` for core cryptographic primitives
|
||||
- hkdf
|
||||
- curve25519
|
||||
|
||||
- go/crypto
|
||||
- ed25519
|
||||
- sha256
|
||||
- rand
|
||||
- aes
|
||||
- cipher
|
||||
- hmac
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Refer to [https://quad4.io/security](https://quad4.io/security) for how to report vulnerabilities.
|
||||
11
TODO.md
Normal file
11
TODO.md
Normal file
@@ -0,0 +1,11 @@
|
||||
Working on creating a project and issues to better track things. Check out https://git.quad4.io/Networks/Reticulum-Go/projects/2
|
||||
|
||||
## Todo
|
||||
|
||||
- Created dedicated constants.go for each section.
|
||||
- Link Request/Response System (in-progress)
|
||||
- Resource Transfer System (in-progress)
|
||||
- Link Keep-Alive & Timeout (in-progress)
|
||||
- Examples (in-progress)
|
||||
- Tests
|
||||
- Documentation
|
||||
683
Taskfile.yml
Normal file
683
Taskfile.yml
Normal file
@@ -0,0 +1,683 @@
|
||||
version: '3'
|
||||
env:
|
||||
GOPRIVATE: git.quad4.io
|
||||
|
||||
vars:
|
||||
GOCMD: go
|
||||
BINARY_NAME: reticulum-go
|
||||
BUILD_DIR: bin
|
||||
MAIN_PACKAGE: ./cmd/reticulum-go
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: Show available tasks
|
||||
cmds:
|
||||
- task --list
|
||||
|
||||
all:
|
||||
desc: Clean, download dependencies, build and test
|
||||
deps: [clean, deps, build, test]
|
||||
|
||||
build:
|
||||
desc: Build release binary (no debug symbols, static)
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
cmds:
|
||||
- mkdir -p {{.BUILD_DIR}}
|
||||
- '{{.GOCMD}} build -ldflags="-s -w" -o {{.BUILD_DIR}}/{{.BINARY_NAME}} {{.MAIN_PACKAGE}}'
|
||||
|
||||
debug:
|
||||
desc: Build debug binary
|
||||
cmds:
|
||||
- mkdir -p {{.BUILD_DIR}}
|
||||
- '{{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}} {{.MAIN_PACKAGE}}'
|
||||
|
||||
build-experimental:
|
||||
desc: Build binary with experimental features (GOEXPERIMENT=greenteagc)
|
||||
env:
|
||||
GOEXPERIMENT: greenteagc
|
||||
cmds:
|
||||
- mkdir -p {{.BUILD_DIR}}
|
||||
- '{{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-experimental {{.MAIN_PACKAGE}}'
|
||||
|
||||
experimental:
|
||||
desc: Alias for build-experimental
|
||||
cmds:
|
||||
- task: build-experimental
|
||||
|
||||
release:
|
||||
desc: Build stripped static binary for release (alias for build)
|
||||
cmds:
|
||||
- task: build
|
||||
|
||||
fmt:
|
||||
desc: Format Go code
|
||||
cmds:
|
||||
- '{{.GOCMD}} fmt ./...'
|
||||
|
||||
fmt-check:
|
||||
desc: Check if code is formatted (useful for CI)
|
||||
cmds:
|
||||
- '{{.GOCMD}} fmt -d ./... > fmt.diff 2>&1 || true'
|
||||
- 'test -s fmt.diff && (echo "Code is not formatted. Run ''task fmt'' to fix." && cat fmt.diff && rm -f fmt.diff && exit 1) || (rm -f fmt.diff && exit 0)'
|
||||
|
||||
vet:
|
||||
desc: Run go vet
|
||||
cmds:
|
||||
- '{{.GOCMD}} vet ./...'
|
||||
|
||||
lint:
|
||||
desc: Run revive linter
|
||||
cmds:
|
||||
- revive -config revive.toml -formatter friendly ./pkg/* ./cmd/* ./internal/*
|
||||
|
||||
scan:
|
||||
desc: Run gosec security scanner
|
||||
cmds:
|
||||
- gosec ./...
|
||||
|
||||
check:
|
||||
desc: Run fmt-check, vet, lint, test-short, and scan with summary
|
||||
cmds:
|
||||
- |
|
||||
FAILED_TASKS=""
|
||||
FAIL_COUNT=0
|
||||
TOTAL_TASKS=5
|
||||
|
||||
echo "--- Running all checks ---"
|
||||
task fmt-check || { FAILED_TASKS="$FAILED_TASKS fmt-check"; FAIL_COUNT=$((FAIL_COUNT + 1)); }
|
||||
task vet || { FAILED_TASKS="$FAILED_TASKS vet"; FAIL_COUNT=$((FAIL_COUNT + 1)); }
|
||||
task lint || { FAILED_TASKS="$FAILED_TASKS lint"; FAIL_COUNT=$((FAIL_COUNT + 1)); }
|
||||
task test-short || { FAILED_TASKS="$FAILED_TASKS test-short"; FAIL_COUNT=$((FAIL_COUNT + 1)); }
|
||||
task scan || { FAILED_TASKS="$FAILED_TASKS scan"; FAIL_COUNT=$((FAIL_COUNT + 1)); }
|
||||
|
||||
echo "------------------------------------------"
|
||||
if [ $FAIL_COUNT -eq 0 ]; then
|
||||
echo "OK: All checks passed!"
|
||||
elif [ $FAIL_COUNT -eq $TOTAL_TASKS ]; then
|
||||
echo "ERROR: All tasks failed!"
|
||||
echo "Failed tasks:$FAILED_TASKS"
|
||||
exit 1
|
||||
else
|
||||
echo "ERROR: $FAIL_COUNT task(s) failed out of $TOTAL_TASKS!"
|
||||
echo "Failed tasks:$FAILED_TASKS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bench:
|
||||
desc: Run benchmarks with standard GC
|
||||
cmds:
|
||||
- '{{.GOCMD}} test -bench=. -benchmem ./...'
|
||||
|
||||
bench-experimental:
|
||||
desc: Run benchmarks with experimental GC
|
||||
env:
|
||||
GOEXPERIMENT: greenteagc
|
||||
cmds:
|
||||
- '{{.GOCMD}} test -bench=. -benchmem ./...'
|
||||
|
||||
bench-compare:
|
||||
desc: Run benchmarks with both GC settings
|
||||
deps: [bench, bench-experimental]
|
||||
|
||||
clean:
|
||||
desc: Remove build artifacts
|
||||
cmds:
|
||||
- '{{.GOCMD}} clean'
|
||||
- rm -rf {{.BUILD_DIR}}
|
||||
|
||||
test:
|
||||
desc: Run tests
|
||||
cmds:
|
||||
- '{{.GOCMD}} test -v ./...'
|
||||
|
||||
test-short:
|
||||
desc: Run short tests
|
||||
cmds:
|
||||
- '{{.GOCMD}} test -short -v ./...'
|
||||
|
||||
test-race:
|
||||
desc: Run tests with race detector
|
||||
cmds:
|
||||
- '{{.GOCMD}} test -race -v ./...'
|
||||
|
||||
coverage:
|
||||
desc: Generate test coverage report
|
||||
cmds:
|
||||
- '{{.GOCMD}} test -coverprofile=coverage.out ./...'
|
||||
- '{{.GOCMD}} tool cover -html=coverage.out'
|
||||
|
||||
deps:
|
||||
desc: Download and verify dependencies
|
||||
env:
|
||||
GOPROXY: '{{.GOPROXY | default "https://proxy.golang.org,direct"}}'
|
||||
cmds:
|
||||
- '{{.GOCMD}} mod download'
|
||||
- '{{.GOCMD}} mod verify'
|
||||
|
||||
mod-tidy:
|
||||
desc: Tidy go.mod file
|
||||
cmds:
|
||||
- '{{.GOCMD}} mod tidy'
|
||||
|
||||
mod-verify:
|
||||
desc: Verify dependencies
|
||||
cmds:
|
||||
- '{{.GOCMD}} mod verify'
|
||||
|
||||
build-linux:
|
||||
desc: Build for Linux (amd64, arm64, arm, riscv64)
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
cmds:
|
||||
- mkdir -p {{.BUILD_DIR}}
|
||||
- 'GOOS=linux GOARCH=amd64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-amd64 {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=linux GOARCH=arm64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-arm64 {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=linux GOARCH=arm {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-arm {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=linux GOARCH=riscv64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-riscv64 {{.MAIN_PACKAGE}}'
|
||||
|
||||
build-windows:
|
||||
desc: Build for Windows (amd64, arm64)
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
cmds:
|
||||
- mkdir -p {{.BUILD_DIR}}
|
||||
- 'GOOS=windows GOARCH=amd64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-windows-amd64.exe {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=windows GOARCH=arm64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-windows-arm64.exe {{.MAIN_PACKAGE}}'
|
||||
|
||||
build-darwin:
|
||||
desc: Build for MacOS (amd64, arm64)
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
cmds:
|
||||
- mkdir -p {{.BUILD_DIR}}
|
||||
- 'GOOS=darwin GOARCH=amd64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-darwin-amd64 {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=darwin GOARCH=arm64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-darwin-arm64 {{.MAIN_PACKAGE}}'
|
||||
|
||||
build-freebsd:
|
||||
desc: Build for FreeBSD (amd64, 386, arm64, arm, riscv64)
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
cmds:
|
||||
- mkdir -p {{.BUILD_DIR}}
|
||||
- 'GOOS=freebsd GOARCH=amd64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-freebsd-amd64 {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=freebsd GOARCH=386 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-freebsd-386 {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=freebsd GOARCH=arm64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-freebsd-arm64 {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=freebsd GOARCH=arm {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-freebsd-arm {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=freebsd GOARCH=riscv64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-freebsd-riscv64 {{.MAIN_PACKAGE}}'
|
||||
|
||||
build-openbsd:
|
||||
desc: Build for OpenBSD (amd64, 386, arm64, arm, ppc64, riscv64)
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
cmds:
|
||||
- mkdir -p {{.BUILD_DIR}}
|
||||
- 'GOOS=openbsd GOARCH=amd64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-openbsd-amd64 {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=openbsd GOARCH=386 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-openbsd-386 {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=openbsd GOARCH=arm64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-openbsd-arm64 {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=openbsd GOARCH=arm {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-openbsd-arm {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=openbsd GOARCH=ppc64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-openbsd-ppc64 {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=openbsd GOARCH=riscv64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-openbsd-riscv64 {{.MAIN_PACKAGE}}'
|
||||
|
||||
build-netbsd:
|
||||
desc: Build for NetBSD (amd64, 386, arm64, arm)
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
cmds:
|
||||
- mkdir -p {{.BUILD_DIR}}
|
||||
- 'GOOS=netbsd GOARCH=amd64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-netbsd-amd64 {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=netbsd GOARCH=386 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-netbsd-386 {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=netbsd GOARCH=arm64 {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-netbsd-arm64 {{.MAIN_PACKAGE}}'
|
||||
- 'GOOS=netbsd GOARCH=arm {{.GOCMD}} build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-netbsd-arm {{.MAIN_PACKAGE}}'
|
||||
|
||||
build-all:
|
||||
desc: Build for all platforms and architectures
|
||||
deps: [build-linux, build-windows, build-darwin, build-freebsd, build-openbsd, build-netbsd]
|
||||
|
||||
run:
|
||||
desc: Run with go run
|
||||
cmds:
|
||||
- '{{.GOCMD}} run {{.MAIN_PACKAGE}}'
|
||||
|
||||
tinygo-build:
|
||||
desc: Build binary with TinyGo compiler
|
||||
cmds:
|
||||
- mkdir -p {{.BUILD_DIR}}
|
||||
- tinygo build -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-tinygo -size short {{.MAIN_PACKAGE}}
|
||||
|
||||
tinygo-wasm:
|
||||
desc: Build WebAssembly binary with TinyGo compiler
|
||||
cmds:
|
||||
- mkdir -p {{.BUILD_DIR}}
|
||||
- tinygo build -target wasm -o {{.BUILD_DIR}}/{{.BINARY_NAME}}.wasm ./cmd/reticulum-wasm
|
||||
|
||||
test-wasm:
|
||||
desc: Run WebAssembly tests using Node.js
|
||||
vars:
|
||||
ROOT_DIR:
|
||||
sh: pwd
|
||||
env:
|
||||
GOOS: js
|
||||
GOARCH: wasm
|
||||
cmds:
|
||||
- chmod +x {{.ROOT_DIR}}/misc/wasm/go_js_wasm_exec
|
||||
- PATH="$PATH:{{.ROOT_DIR}}/misc/wasm" {{.GOCMD}} test -v ./pkg/wasm/ ./cmd/reticulum-wasm/
|
||||
- |
|
||||
export PATH="$PATH:{{.ROOT_DIR}}/misc/wasm"
|
||||
cd examples/wasm && {{.GOCMD}} test -v .
|
||||
|
||||
build-wasm:
|
||||
desc: Build WebAssembly binary with standard Go compiler
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
GOOS: js
|
||||
GOARCH: wasm
|
||||
cmds:
|
||||
- mkdir -p {{.BUILD_DIR}}
|
||||
- '{{.GOCMD}} build -ldflags="-s -w" -o {{.BUILD_DIR}}/{{.BINARY_NAME}}.wasm ./cmd/reticulum-wasm'
|
||||
|
||||
example:wasm:build:
|
||||
desc: Build WebAssembly example
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
cmds:
|
||||
- mkdir -p examples/wasm/public/static examples/wasm/public/js
|
||||
- 'cd examples/wasm && GOOS=js GOARCH=wasm {{.GOCMD}} build -o public/static/reticulum-go.wasm .'
|
||||
- |
|
||||
GOROOT=$({{.GOCMD}} env GOROOT)
|
||||
if [ -f "$GOROOT/lib/wasm/wasm_exec.js" ]; then
|
||||
cp "$GOROOT/lib/wasm/wasm_exec.js" examples/wasm/public/js/
|
||||
echo "wasm_exec.js copied successfully from $GOROOT/lib/wasm/"
|
||||
elif [ -f "$GOROOT/misc/wasm/wasm_exec.js" ]; then
|
||||
cp "$GOROOT/misc/wasm/wasm_exec.js" examples/wasm/public/js/
|
||||
echo "wasm_exec.js copied successfully from $GOROOT/misc/wasm/"
|
||||
else
|
||||
echo "Warning: wasm_exec.js not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
example:wasm:run:
|
||||
desc: Run WebAssembly example using a simple HTTP server
|
||||
deps: [example:wasm:build]
|
||||
cmds:
|
||||
- echo "Starting server at http://localhost:8080"
|
||||
- echo "Press Ctrl+C to stop"
|
||||
- 'cd examples/wasm/public && python3 -m http.server 8080'
|
||||
|
||||
example:wasm:test:
|
||||
desc: Run tests for WASM example
|
||||
cmds:
|
||||
- task: test-wasm
|
||||
|
||||
install:
|
||||
desc: Install dependencies
|
||||
cmds:
|
||||
- '{{.GOCMD}} mod download'
|
||||
|
||||
checksum:
|
||||
desc: Generate SHA256 checksum for binary (uses BINARY_PATH env var if set, otherwise defaults to bin/reticulum-go)
|
||||
cmds:
|
||||
- |
|
||||
BINARY_PATH="${BINARY_PATH:-{{.BUILD_DIR}}/{{.BINARY_NAME}}}"
|
||||
if [ -f "$BINARY_PATH" ]; then
|
||||
sha256sum "$BINARY_PATH" > "${BINARY_PATH}.sha256"
|
||||
echo "Generated checksum: ${BINARY_PATH}.sha256"
|
||||
else
|
||||
echo "Error: Binary not found at $BINARY_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
example:announce:
|
||||
desc: Run announce example
|
||||
cmds:
|
||||
- 'cd examples/announce && {{.GOCMD}} run .'
|
||||
|
||||
example:minimal:
|
||||
desc: Run minimal example
|
||||
cmds:
|
||||
- 'cd examples/minimal && {{.GOCMD}} run .'
|
||||
|
||||
example:pageserver:
|
||||
desc: Run pageserver example
|
||||
cmds:
|
||||
- 'cd examples/pageserver && {{.GOCMD}} run .'
|
||||
|
||||
example:echo-listen:
|
||||
desc: Run echo example (waits for incoming connections, P2P peer)
|
||||
cmds:
|
||||
- 'cd examples/echo && {{.GOCMD}} run . --server'
|
||||
|
||||
example:echo-connect:
|
||||
desc: Run echo example (initiates connection to peer, requires DESTINATION env var)
|
||||
cmds:
|
||||
- |
|
||||
if [ -z "${DESTINATION}" ]; then
|
||||
echo "Error: DESTINATION environment variable required (hexadecimal hash of peer)"
|
||||
echo "Example: DESTINATION=abc123... task example:echo-connect"
|
||||
exit 1
|
||||
fi
|
||||
cd examples/echo && {{.GOCMD}} run . --destination="${DESTINATION}"
|
||||
|
||||
example:link-listen:
|
||||
desc: Run link example (waits for incoming link requests, P2P peer)
|
||||
cmds:
|
||||
- 'cd examples/link && {{.GOCMD}} run . --server'
|
||||
|
||||
example:link-connect:
|
||||
desc: Run link example (initiates link to peer, requires DESTINATION env var)
|
||||
cmds:
|
||||
- |
|
||||
if [ -z "${DESTINATION}" ]; then
|
||||
echo "Error: DESTINATION environment variable required (hexadecimal hash of peer)"
|
||||
echo "Example: DESTINATION=abc123... task example:link-connect"
|
||||
exit 1
|
||||
fi
|
||||
cd examples/link && {{.GOCMD}} run . --destination="${DESTINATION}"
|
||||
|
||||
example:filetransfer-share:
|
||||
desc: Run filetransfer example (shares files from directory, P2P peer)
|
||||
cmds:
|
||||
- |
|
||||
if [ -z "${SERVE_PATH}" ]; then
|
||||
echo "Error: SERVE_PATH environment variable required (directory to share)"
|
||||
echo "Example: SERVE_PATH=/path/to/files task example:filetransfer-share"
|
||||
exit 1
|
||||
fi
|
||||
cd examples/filetransfer && {{.GOCMD}} run . --server --serve="${SERVE_PATH}"
|
||||
|
||||
example:filetransfer-fetch:
|
||||
desc: Run filetransfer example (fetches files from peer, requires DESTINATION env var)
|
||||
cmds:
|
||||
- |
|
||||
if [ -z "${DESTINATION}" ]; then
|
||||
echo "Error: DESTINATION environment variable required (hexadecimal hash of peer)"
|
||||
echo "Example: DESTINATION=abc123... task example:filetransfer-fetch"
|
||||
exit 1
|
||||
fi
|
||||
cd examples/filetransfer && {{.GOCMD}} run . --destination="${DESTINATION}"
|
||||
|
||||
trivy:install:
|
||||
desc: Install Trivy scanner
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
curl -L -o /tmp/trivy.deb https://git.quad4.io/Quad4-Extra/assets/raw/commit/90fdcea1bb71d91df2de6ff2e3897f278413f300/bin/trivy_0.68.2_Linux-64bit.deb
|
||||
sudo dpkg -i /tmp/trivy.deb || sudo apt-get install -f -y
|
||||
else
|
||||
echo "Trivy is already installed: $(trivy --version)"
|
||||
fi
|
||||
|
||||
trivy:scan:
|
||||
desc: Run Trivy vulnerability scan
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
echo "Error: Trivy not found. Run 'task trivy:install' first."
|
||||
exit 1
|
||||
fi
|
||||
trivy fs --scanners vuln --severity HIGH,CRITICAL --timeout 90m .
|
||||
|
||||
trivy:scan-all:
|
||||
desc: Run Trivy full scan (vulnerabilities, secrets, misconfig)
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
echo "Error: Trivy not found. Run 'task trivy:install' first."
|
||||
exit 1
|
||||
fi
|
||||
trivy fs --scanners vuln,secret,misconfig .
|
||||
|
||||
sbom:
|
||||
desc: Generate SBOM files (SPDX and CycloneDX formats)
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
echo "Error: Trivy not found. Run 'task trivy:install' first."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p sbom
|
||||
trivy fs --format spdx-json --include-dev-deps --output sbom/sbom.spdx.json .
|
||||
trivy fs --format cyclonedx --include-dev-deps --output sbom/sbom.cyclonedx.json .
|
||||
echo "SBOM files generated in sbom/ directory"
|
||||
|
||||
sbom:spdx:
|
||||
desc: Generate SPDX JSON SBOM
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
echo "Error: Trivy not found. Run 'task trivy:install' first."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p sbom
|
||||
trivy fs --format spdx-json --include-dev-deps --output sbom/sbom.spdx.json .
|
||||
echo "SPDX SBOM generated: sbom/sbom.spdx.json"
|
||||
|
||||
sbom:cyclonedx:
|
||||
desc: Generate CycloneDX SBOM
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
echo "Error: Trivy not found. Run 'task trivy:install' first."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p sbom
|
||||
trivy fs --format cyclonedx --include-dev-deps --output sbom/sbom.cyclonedx.json .
|
||||
echo "CycloneDX SBOM generated: sbom/sbom.cyclonedx.json"
|
||||
|
||||
trivy:scan:json:
|
||||
desc: Run Trivy vulnerability scan with JSON output
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
echo "Error: Trivy not found. Run 'task trivy:install' first."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p reports
|
||||
trivy fs --scanners vuln --format json --output reports/trivy-vuln.json --timeout 90m .
|
||||
|
||||
trivy:scan:sarif:
|
||||
desc: Run Trivy scan with SARIF output (for GitHub/GitLab integration)
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
echo "Error: Trivy not found. Run 'task trivy:install' first."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p reports
|
||||
trivy fs --scanners vuln,secret --format sarif --output reports/trivy.sarif --timeout 90m .
|
||||
|
||||
trivy:scan:secrets:
|
||||
desc: Scan for hardcoded secrets
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
echo "Error: Trivy not found. Run 'task trivy:install' first."
|
||||
exit 1
|
||||
fi
|
||||
trivy fs --scanners secret .
|
||||
|
||||
trivy:scan:licenses:
|
||||
desc: Scan for licenses in dependencies
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
echo "Error: Trivy not found. Run 'task trivy:install' first."
|
||||
exit 1
|
||||
fi
|
||||
trivy fs --scanners license .
|
||||
|
||||
trivy:scan:misconfig:
|
||||
desc: Scan for misconfigurations in config files
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
echo "Error: Trivy not found. Run 'task trivy:install' first."
|
||||
exit 1
|
||||
fi
|
||||
trivy fs --scanners misconfig .
|
||||
|
||||
trivy:db-update:
|
||||
desc: Update Trivy vulnerability database
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
echo "Error: Trivy not found. Run 'task trivy:install' first."
|
||||
exit 1
|
||||
fi
|
||||
trivy image --download-db-only
|
||||
|
||||
trivy:cache-clean:
|
||||
desc: Clean Trivy cache
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
echo "Error: Trivy not found. Run 'task trivy:install' first."
|
||||
exit 1
|
||||
fi
|
||||
trivy clean --cache
|
||||
|
||||
trivy:compliance:
|
||||
desc: "Generate compliance report (specify COMPLIANCE env var: docker-bench-cis, k8s-nsa, etc.)"
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
echo "Error: Trivy not found. Run 'task trivy:install' first."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${COMPLIANCE}" ]; then
|
||||
echo "Error: COMPLIANCE environment variable required"
|
||||
echo "Example: COMPLIANCE=docker-bench-cis task trivy:compliance"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p reports
|
||||
trivy fs --compliance "${COMPLIANCE}" --format json --output "reports/compliance-${COMPLIANCE}.json" .
|
||||
|
||||
trivy:ci:
|
||||
desc: Run Trivy scan for CI (exits with non-zero code on findings)
|
||||
cmds:
|
||||
- |
|
||||
if ! command -v trivy &> /dev/null; then
|
||||
echo "Error: Trivy not found. Run 'task trivy:install' first."
|
||||
exit 1
|
||||
fi
|
||||
trivy fs --scanners vuln --severity HIGH,CRITICAL --exit-code 1 --timeout 90m .
|
||||
|
||||
docker:build:
|
||||
desc: Build Docker image (runtime image)
|
||||
vars:
|
||||
IMAGE_NAME: reticulum-go
|
||||
IMAGE_TAG: latest
|
||||
cmds:
|
||||
- docker build -f docker/Dockerfile -t {{.IMAGE_NAME}}:{{.IMAGE_TAG}} .
|
||||
|
||||
docker:build:tag:
|
||||
desc: Build Docker image with custom tag (use IMAGE_TAG env var)
|
||||
vars:
|
||||
IMAGE_NAME: reticulum-go
|
||||
IMAGE_TAG: ${IMAGE_TAG:-latest}
|
||||
cmds:
|
||||
- docker build -f docker/Dockerfile -t {{.IMAGE_NAME}}:{{.IMAGE_TAG}} .
|
||||
|
||||
docker:build:build:
|
||||
desc: Build Docker image for building binaries only
|
||||
vars:
|
||||
IMAGE_NAME: reticulum-go-build
|
||||
IMAGE_TAG: latest
|
||||
cmds:
|
||||
- docker build -f docker/Dockerfile.build -t {{.IMAGE_NAME}}:{{.IMAGE_TAG}} .
|
||||
|
||||
docker:run:
|
||||
desc: Run Docker container (runtime image)
|
||||
vars:
|
||||
IMAGE_NAME: reticulum-go
|
||||
IMAGE_TAG: latest
|
||||
CONTAINER_NAME: reticulum-go
|
||||
cmds:
|
||||
- |
|
||||
docker run --rm -it \
|
||||
--name {{.CONTAINER_NAME}} \
|
||||
-p 4242:4242 \
|
||||
{{.IMAGE_NAME}}:{{.IMAGE_TAG}}
|
||||
|
||||
docker:run:detached:
|
||||
desc: Run Docker container in detached mode
|
||||
vars:
|
||||
IMAGE_NAME: reticulum-go
|
||||
IMAGE_TAG: latest
|
||||
CONTAINER_NAME: reticulum-go
|
||||
cmds:
|
||||
- |
|
||||
docker run -d \
|
||||
--name {{.CONTAINER_NAME}} \
|
||||
-p 4242:4242 \
|
||||
{{.IMAGE_NAME}}:{{.IMAGE_TAG}}
|
||||
|
||||
docker:stop:
|
||||
desc: Stop running Docker container
|
||||
vars:
|
||||
CONTAINER_NAME: reticulum-go
|
||||
cmds:
|
||||
- docker stop {{.CONTAINER_NAME}} || true
|
||||
- docker rm {{.CONTAINER_NAME}} || true
|
||||
|
||||
docker:extract:
|
||||
desc: Extract binary from build container
|
||||
vars:
|
||||
IMAGE_NAME: reticulum-go-build
|
||||
IMAGE_TAG: latest
|
||||
BINARY_NAME: reticulum-go
|
||||
cmds:
|
||||
- |
|
||||
CONTAINER_ID=$(docker create {{.IMAGE_NAME}}:{{.IMAGE_TAG}})
|
||||
docker cp $CONTAINER_ID:/dist/{{.BINARY_NAME}} {{.BUILD_DIR}}/{{.BINARY_NAME}}
|
||||
docker rm $CONTAINER_ID
|
||||
echo "Binary extracted to {{.BUILD_DIR}}/{{.BINARY_NAME}}"
|
||||
|
||||
docker:buildx:setup:
|
||||
desc: Setup Docker buildx for multi-platform builds
|
||||
cmds:
|
||||
- docker buildx create --name reticulum-builder --use || docker buildx use reticulum-builder
|
||||
- docker buildx inspect --bootstrap
|
||||
|
||||
docker:buildx:build:
|
||||
desc: Build multi-platform Docker image
|
||||
vars:
|
||||
IMAGE_NAME: reticulum-go
|
||||
IMAGE_TAG: latest
|
||||
PLATFORMS: linux/amd64,linux/arm64,linux/arm/v7
|
||||
cmds:
|
||||
- |
|
||||
docker buildx build \
|
||||
--platform {{.PLATFORMS}} \
|
||||
-f docker/Dockerfile \
|
||||
-t {{.IMAGE_NAME}}:{{.IMAGE_TAG}} \
|
||||
--load \
|
||||
.
|
||||
|
||||
docker:buildx:build:push:
|
||||
desc: Build and push multi-platform Docker image
|
||||
vars:
|
||||
IMAGE_NAME: reticulum-go
|
||||
IMAGE_TAG: latest
|
||||
PLATFORMS: linux/amd64,linux/arm64,linux/arm/v7
|
||||
cmds:
|
||||
- |
|
||||
if [ -z "${DOCKER_REGISTRY}" ]; then
|
||||
echo "Error: DOCKER_REGISTRY environment variable required"
|
||||
echo "Example: DOCKER_REGISTRY=registry.example.com task docker:buildx:build:push"
|
||||
exit 1
|
||||
fi
|
||||
docker buildx build \
|
||||
--platform {{.PLATFORMS}} \
|
||||
-f docker/Dockerfile \
|
||||
-t ${DOCKER_REGISTRY}/{{.IMAGE_NAME}}:{{.IMAGE_TAG}} \
|
||||
--push \
|
||||
.
|
||||
|
||||
docker:clean:
|
||||
desc: Clean Docker images and containers
|
||||
cmds:
|
||||
- docker stop reticulum-go || true
|
||||
- docker rm reticulum-go || true
|
||||
- docker rmi reticulum-go:latest || true
|
||||
- docker rmi reticulum-go-build:latest || true
|
||||
102
To-Do
102
To-Do
@@ -1,102 +0,0 @@
|
||||
To-Do List
|
||||
|
||||
Core Components
|
||||
[âś“] Basic Configuration System
|
||||
[âś“] Basic config structure
|
||||
[âś“] Default settings
|
||||
[âś“] Config file loading/saving
|
||||
[âś“] Path management
|
||||
|
||||
[âś“] Constants Definition
|
||||
[âś“] Packet constants
|
||||
[âś“] MTU constants
|
||||
[âś“] Header types
|
||||
[âś“] Additional protocol constants
|
||||
|
||||
[âś“] Identity Management
|
||||
[âś“] Identity creation
|
||||
[âś“] Key pair generation
|
||||
[âś“] Identity storage/recall
|
||||
|
||||
[âś“] Packet Handling
|
||||
[âś“] Packet creation
|
||||
[âś“] Packet validation
|
||||
[âś“] Basic proof system
|
||||
|
||||
[âś“] Crypto Implementation
|
||||
[âś“] Basic encryption
|
||||
[âś“] Key exchange
|
||||
[âś“] Hash functions
|
||||
[âś“] Ratchet implementation
|
||||
|
||||
[âś“] Transport Layer
|
||||
[âś“] Path management
|
||||
[âś“] Basic packet routing
|
||||
[âś“] Announce handling
|
||||
[âś“] Link management
|
||||
[âś“] Resource cleanup
|
||||
[âś“] Network layer integration
|
||||
|
||||
[âś“] Destination System
|
||||
[âś“] Destination creation
|
||||
[âś“] Destination types (IN/OUT)
|
||||
[âś“] Destination aspects
|
||||
[âś“] Announce implementation
|
||||
[âś“] Ratchet support
|
||||
[âś“] Request handlers
|
||||
|
||||
[âś“] Link System
|
||||
[âś“] Link establishment
|
||||
[âś“] Link teardown
|
||||
[âś“] Basic packet transfer
|
||||
[âś“] Encryption/Decryption
|
||||
[âś“] Identity verification
|
||||
[âś“] Request/Response handling
|
||||
|
||||
[âś“] Resource System
|
||||
[âś“] Resource creation
|
||||
[âś“] Resource transfer
|
||||
[âś“] Compression
|
||||
[âś“] Progress tracking
|
||||
[âś“] Segmentation
|
||||
[âś“] Cleanup routines
|
||||
|
||||
Basic Features
|
||||
[âś“] Network Interface
|
||||
[âś“] Basic UDP transport
|
||||
[âś“] TCP transport
|
||||
[ ] Interface discovery
|
||||
[ ] Connection management
|
||||
[âś“] Packet framing
|
||||
[âś“] Transport integration
|
||||
|
||||
[âś“] Announce System
|
||||
[âś“] Announce creation
|
||||
[âś“] Announce propagation
|
||||
[âś“] Path requests
|
||||
|
||||
[âś“] Resource Management
|
||||
[âś“] Resource tracking
|
||||
[âś“] Memory management
|
||||
[âś“] Cleanup routines
|
||||
|
||||
[âś“] Client Implementation
|
||||
[âś“] Basic client structure
|
||||
[âś“] Configuration handling
|
||||
[âś“] Interactive mode
|
||||
[âś“] Link establishment
|
||||
[âś“] Message sending/receiving
|
||||
|
||||
Next Immediate Tasks:
|
||||
1. [âś“] Fix import cycles by creating common package
|
||||
2. [ ] Implement Interface discovery
|
||||
3. [ ] Implement Connection management
|
||||
4. [ ] Test network layer integration end-to-end
|
||||
5. [ ] Add error handling for network failures
|
||||
6. [ ] Implement interface auto-configuration
|
||||
7. [ ] Complete NetworkInterface implementation
|
||||
8. [ ] Add comprehensive interface tests
|
||||
9. [ ] Implement connection retry logic
|
||||
10. [ ] Add metrics collection for interfaces
|
||||
11. [ ] Add client reconnection handling
|
||||
12. [ ] Implement client-side path caching
|
||||
@@ -1,137 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/Sudo-Ivan/reticulum-go/internal/config"
|
||||
"github.com/Sudo-Ivan/reticulum-go/pkg/common"
|
||||
"github.com/Sudo-Ivan/reticulum-go/pkg/identity"
|
||||
"github.com/Sudo-Ivan/reticulum-go/pkg/transport"
|
||||
"github.com/Sudo-Ivan/reticulum-go/pkg/destination"
|
||||
)
|
||||
|
||||
var (
|
||||
configPath = flag.String("config", "", "Path to config file")
|
||||
targetHash = flag.String("target", "", "Target destination hash")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
var cfg *common.ReticulumConfig
|
||||
var err error
|
||||
|
||||
if *configPath == "" {
|
||||
cfg, err = config.InitConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize config: %v", err)
|
||||
}
|
||||
} else {
|
||||
cfg, err = config.LoadConfig(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Enable transport by default for client
|
||||
cfg.EnableTransport = true
|
||||
|
||||
// Initialize transport
|
||||
transport, err := transport.NewTransport(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize transport: %v", err)
|
||||
}
|
||||
defer transport.Close()
|
||||
|
||||
// If target specified, establish connection
|
||||
if *targetHash != "" {
|
||||
destHash, err := identity.HashFromHex(*targetHash)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid destination hash: %v", err)
|
||||
}
|
||||
|
||||
// Request path if needed
|
||||
if !transport.HasPath(destHash) {
|
||||
fmt.Println("Requesting path to destination...")
|
||||
if err := transport.RequestPath(destHash, "", nil, true); err != nil {
|
||||
log.Fatalf("Failed to request path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get destination identity
|
||||
destIdentity, err := identity.Recall(destHash)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to recall identity: %v", err)
|
||||
}
|
||||
|
||||
// Create destination
|
||||
dest, err := destination.New(
|
||||
destIdentity,
|
||||
destination.OUT,
|
||||
destination.SINGLE,
|
||||
"client",
|
||||
"direct",
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create destination: %v", err)
|
||||
}
|
||||
|
||||
// Enable and configure ratchets
|
||||
dest.SetRetainedRatchets(destination.RATCHET_COUNT)
|
||||
dest.SetRatchetInterval(destination.RATCHET_INTERVAL)
|
||||
dest.EnforceRatchets()
|
||||
|
||||
// Create link
|
||||
link := transport.NewLink(dest.Hash(), func() {
|
||||
fmt.Println("Link established")
|
||||
}, func() {
|
||||
fmt.Println("Link closed")
|
||||
})
|
||||
|
||||
defer link.Teardown()
|
||||
|
||||
// Set packet callback
|
||||
link.SetPacketCallback(func(data []byte) {
|
||||
fmt.Printf("Received: %s\n", string(data))
|
||||
})
|
||||
|
||||
// Start interactive loop
|
||||
go interactiveLoop(link)
|
||||
} else {
|
||||
fmt.Println("No target specified. Use -target <hash> to connect to a destination")
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for interrupt
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
}
|
||||
|
||||
func interactiveLoop(link *transport.Link) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
fmt.Print("> ")
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading input: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "quit" || input == "exit" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := link.Send([]byte(input)); err != nil {
|
||||
fmt.Printf("Failed to send: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
642
cmd/reticulum-go/main.go
Normal file
642
cmd/reticulum-go/main.go
Normal file
@@ -0,0 +1,642 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/internal/config"
|
||||
"git.quad4.io/Networks/Reticulum-Go/internal/storage"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/buffer"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/channel"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/destination"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/identity"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/interfaces"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/packet"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/transport"
|
||||
)
|
||||
|
||||
var (
|
||||
interceptPackets = flag.Bool("intercept-packets", false, "Enable packet interception")
|
||||
interceptOutput = flag.String("intercept-output", "packets.log", "Output file for intercepted packets")
|
||||
)
|
||||
|
||||
const (
|
||||
ANNOUNCE_RATE_TARGET = 3600 // Default target time between announces (1 hour)
|
||||
ANNOUNCE_RATE_GRACE = 3 // Number of grace announces before enforcing rate
|
||||
ANNOUNCE_RATE_PENALTY = 7200 // Additional penalty time for rate violations
|
||||
MAX_ANNOUNCE_HOPS = 128 // Maximum number of hops for announces
|
||||
APP_NAME = "Reticulum-Go Test Node"
|
||||
APP_ASPECT = "node" // Always use "node" for node announces
|
||||
)
|
||||
|
||||
type Reticulum struct {
|
||||
config *common.ReticulumConfig
|
||||
transport *transport.Transport
|
||||
interfaces []interfaces.Interface
|
||||
channels map[string]*channel.Channel
|
||||
buffers map[string]*buffer.Buffer
|
||||
pathRequests map[string]*common.PathRequest
|
||||
announceHistory map[string]announceRecord
|
||||
announceHistoryMu sync.RWMutex
|
||||
identity *identity.Identity
|
||||
destination *destination.Destination
|
||||
storage *storage.Manager
|
||||
|
||||
// Node-specific information
|
||||
maxTransferSize int16 // Max transfer size in KB
|
||||
nodeEnabled bool // Whether this node is enabled
|
||||
nodeTimestamp int64 // Last node announcement timestamp
|
||||
}
|
||||
|
||||
type announceRecord struct {
|
||||
timestamp int64
|
||||
appData []byte
|
||||
}
|
||||
|
||||
func NewReticulum(cfg *common.ReticulumConfig) (*Reticulum, error) {
|
||||
if cfg == nil {
|
||||
cfg = config.DefaultConfig()
|
||||
}
|
||||
|
||||
// Set default app name and aspect if not provided
|
||||
if cfg.AppName == "" {
|
||||
cfg.AppName = APP_NAME
|
||||
}
|
||||
if cfg.AppAspect == "" {
|
||||
cfg.AppAspect = APP_ASPECT // Always use "node" for node announcements
|
||||
}
|
||||
|
||||
if err := initializeDirectories(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize directories: %v", err)
|
||||
}
|
||||
debug.Log(debug.DEBUG_INFO, "Directories initialized")
|
||||
|
||||
// Initialize storage manager
|
||||
storageMgr, err := storage.NewManager()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize storage manager: %v", err)
|
||||
}
|
||||
debug.Log(debug.DEBUG_INFO, "Storage manager initialized")
|
||||
|
||||
t := transport.NewTransport(cfg)
|
||||
debug.Log(debug.DEBUG_INFO, "Transport initialized")
|
||||
|
||||
// Load or create identity
|
||||
identityPath := storageMgr.GetIdentityPath()
|
||||
|
||||
var ident *identity.Identity
|
||||
|
||||
if _, err := os.Stat(identityPath); err == nil {
|
||||
// Identity file exists, load it
|
||||
ident, err = identity.FromFile(identityPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load identity: %v", err)
|
||||
}
|
||||
debug.Log(debug.DEBUG_ERROR, "Loaded existing identity", common.STR_HASH, fmt.Sprintf(common.STR_FMT_HEX_LOW, ident.Hash()))
|
||||
} else {
|
||||
// Create new identity
|
||||
ident, err = identity.NewIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create identity: %v", err)
|
||||
}
|
||||
debug.Log(debug.DEBUG_ERROR, "Created new identity", common.STR_HASH, fmt.Sprintf(common.STR_FMT_HEX_LOW, ident.Hash()))
|
||||
|
||||
// Save it to disk
|
||||
if err := ident.ToFile(identityPath); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to save identity to file", common.STR_ERROR, err)
|
||||
} else {
|
||||
debug.Log(debug.DEBUG_INFO, "Identity saved to file", "path", identityPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Create destination
|
||||
debug.Log(debug.DEBUG_INFO, "Creating destination...")
|
||||
dest, err := destination.New(
|
||||
ident,
|
||||
destination.IN,
|
||||
destination.SINGLE,
|
||||
"nomadnetwork",
|
||||
t,
|
||||
"node",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create destination: %v", err)
|
||||
}
|
||||
debug.Log(debug.DEBUG_INFO, "Created destination with hash", common.STR_HASH, fmt.Sprintf(common.STR_FMT_HEX_LOW, dest.GetHash()))
|
||||
|
||||
// Set node metadata
|
||||
nodeTimestamp := time.Now().Unix()
|
||||
|
||||
r := &Reticulum{
|
||||
config: cfg,
|
||||
transport: t,
|
||||
interfaces: make([]interfaces.Interface, 0),
|
||||
channels: make(map[string]*channel.Channel),
|
||||
buffers: make(map[string]*buffer.Buffer),
|
||||
pathRequests: make(map[string]*common.PathRequest),
|
||||
announceHistory: make(map[string]announceRecord),
|
||||
identity: ident,
|
||||
destination: dest,
|
||||
storage: storageMgr,
|
||||
|
||||
// Node-specific information
|
||||
maxTransferSize: common.NUM_500, // Default 500KB
|
||||
nodeEnabled: true, // Enabled by default
|
||||
nodeTimestamp: nodeTimestamp,
|
||||
}
|
||||
|
||||
// Enable destination features
|
||||
dest.AcceptsLinks(true)
|
||||
// Enable ratchets and point to a file for persistence.
|
||||
// The actual path should probably be configurable.
|
||||
ratchetPath := ".git.quad4.io/Networks/Reticulum-Go/storage/ratchets/" + r.identity.GetHexHash()
|
||||
dest.EnableRatchets(ratchetPath)
|
||||
dest.SetProofStrategy(destination.PROVE_APP)
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Configured destination features")
|
||||
|
||||
// Initialize interfaces from config
|
||||
for name, ifaceConfig := range cfg.Interfaces {
|
||||
if !ifaceConfig.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
var iface interfaces.Interface
|
||||
var err error
|
||||
|
||||
switch ifaceConfig.Type {
|
||||
case common.STR_TCP_CLIENT:
|
||||
iface, err = interfaces.NewTCPClientInterface(
|
||||
name,
|
||||
ifaceConfig.TargetHost,
|
||||
ifaceConfig.TargetPort,
|
||||
ifaceConfig.KISSFraming,
|
||||
ifaceConfig.I2PTunneled,
|
||||
ifaceConfig.Enabled,
|
||||
)
|
||||
case "UDPInterface":
|
||||
iface, err = interfaces.NewUDPInterface(
|
||||
name,
|
||||
ifaceConfig.Address,
|
||||
ifaceConfig.TargetHost,
|
||||
ifaceConfig.Enabled,
|
||||
)
|
||||
case "AutoInterface":
|
||||
iface, err = interfaces.NewAutoInterface(name, ifaceConfig)
|
||||
case "WebSocketInterface":
|
||||
wsURL := ifaceConfig.Address
|
||||
if wsURL == "" {
|
||||
wsURL = ifaceConfig.TargetHost
|
||||
}
|
||||
debug.Log(debug.DEBUG_INFO, "Creating WebSocket interface", common.STR_NAME, name, "url", wsURL, "enabled", ifaceConfig.Enabled)
|
||||
iface, err = interfaces.NewWebSocketInterface(name, wsURL, ifaceConfig.Enabled)
|
||||
if err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to create WebSocket interface", common.STR_NAME, name, common.STR_ERROR, err)
|
||||
} else {
|
||||
debug.Log(debug.DEBUG_INFO, "WebSocket interface created successfully", common.STR_NAME, name)
|
||||
}
|
||||
default:
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Unknown interface type", common.STR_TYPE, ifaceConfig.Type)
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if cfg.PanicOnInterfaceErr {
|
||||
return nil, fmt.Errorf("failed to create interface %s: %v", name, err)
|
||||
}
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Error creating interface", common.STR_NAME, name, common.STR_ERROR, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set packet callback
|
||||
iface.SetPacketCallback(func(data []byte, ni common.NetworkInterface) {
|
||||
debug.Log(debug.DEBUG_INFO, "Packet callback called for interface", common.STR_NAME, ni.GetName(), "data_len", len(data))
|
||||
if r.transport != nil {
|
||||
r.transport.HandlePacket(data, ni)
|
||||
} else {
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Transport is nil in packet callback")
|
||||
}
|
||||
})
|
||||
|
||||
debug.Log(debug.DEBUG_ERROR, "Configuring interface", common.STR_NAME, name, common.STR_TYPE, ifaceConfig.Type)
|
||||
r.interfaces = append(r.interfaces, iface)
|
||||
debug.Log(debug.DEBUG_INFO, "Interface started successfully", common.STR_NAME, name)
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *Reticulum) handleInterface(iface common.NetworkInterface) {
|
||||
debug.Log(debug.DEBUG_INFO, "Setting up interface", common.STR_NAME, iface.GetName(), common.STR_TYPE, fmt.Sprintf("%T", iface))
|
||||
|
||||
ch := channel.NewChannel(&transportWrapper{r.transport})
|
||||
r.channels[iface.GetName()] = ch
|
||||
|
||||
rw := buffer.CreateBidirectionalBuffer(
|
||||
1,
|
||||
2,
|
||||
ch,
|
||||
func(size int) {
|
||||
data := make([]byte, size)
|
||||
debug.Log(debug.DEBUG_PACKETS, "Interface reading bytes from buffer", common.STR_NAME, iface.GetName(), "size", size)
|
||||
iface.ProcessIncoming(data)
|
||||
|
||||
if len(data) > common.ZERO {
|
||||
debug.Log(debug.DEBUG_TRACE, "Interface received packet type", common.STR_NAME, iface.GetName(), common.STR_TYPE, fmt.Sprintf("0x%02x", data[0]))
|
||||
r.transport.HandlePacket(data, iface)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
r.buffers[iface.GetName()] = &buffer.Buffer{
|
||||
ReadWriter: rw,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reticulum) monitorInterfaces() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
for _, iface := range r.interfaces {
|
||||
if tcpClient, ok := iface.(*interfaces.TCPClientInterface); ok {
|
||||
stats := fmt.Sprintf("Interface %s status - Connected: %v, TX: %d bytes (%.2f Kbps), RX: %d bytes (%.2f Kbps)",
|
||||
iface.GetName(),
|
||||
tcpClient.IsConnected(),
|
||||
tcpClient.GetTxBytes(),
|
||||
float64(tcpClient.GetTxBytes()*8)/(5*1024),
|
||||
tcpClient.GetRxBytes(),
|
||||
float64(tcpClient.GetRxBytes()*8)/(5*1024),
|
||||
)
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
stats = fmt.Sprintf("%s, RTT: %v", stats, tcpClient.GetRTT())
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Interface status", "stats", stats)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
debug.Init()
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Initializing Reticulum", "debug_level", debug.GetDebugLevel())
|
||||
|
||||
cfg, err := config.InitConfig()
|
||||
if err != nil {
|
||||
debug.GetLogger().Error("Failed to initialize config", common.STR_ERROR, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
debug.Log(debug.DEBUG_ERROR, "Configuration loaded", "path", cfg.ConfigPath)
|
||||
|
||||
r, err := NewReticulum(cfg)
|
||||
if err != nil {
|
||||
debug.GetLogger().Error("Failed to create Reticulum instance", common.STR_ERROR, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Start monitoring interfaces
|
||||
go r.monitorInterfaces()
|
||||
|
||||
// Register announce handler
|
||||
handler := NewAnnounceHandler(r, []string{"*"})
|
||||
r.transport.RegisterAnnounceHandler(handler)
|
||||
|
||||
// Start Reticulum
|
||||
if err := r.Start(); err != nil {
|
||||
debug.GetLogger().Error("Failed to start Reticulum", common.STR_ERROR, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Shutting down...")
|
||||
if err := r.Stop(); err != nil {
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Error during shutdown", common.STR_ERROR, err)
|
||||
}
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Goodbye!")
|
||||
}
|
||||
|
||||
type transportWrapper struct {
|
||||
*transport.Transport
|
||||
}
|
||||
|
||||
func (tw *transportWrapper) GetRTT() float64 {
|
||||
return 0.1
|
||||
}
|
||||
|
||||
func (tw *transportWrapper) RTT() float64 {
|
||||
return tw.GetRTT()
|
||||
}
|
||||
|
||||
func (tw *transportWrapper) GetStatus() byte {
|
||||
return transport.STATUS_ACTIVE
|
||||
}
|
||||
|
||||
func (tw *transportWrapper) Send(data []byte) interface{} {
|
||||
p := &packet.Packet{
|
||||
PacketType: packet.PacketTypeData,
|
||||
Hops: 0,
|
||||
Data: data,
|
||||
HeaderType: packet.HeaderType1,
|
||||
}
|
||||
|
||||
err := tw.Transport.SendPacket(p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (tw *transportWrapper) Resend(p interface{}) error {
|
||||
if pkt, ok := p.(*packet.Packet); ok {
|
||||
return tw.Transport.SendPacket(pkt)
|
||||
}
|
||||
return fmt.Errorf("invalid packet type")
|
||||
}
|
||||
|
||||
func (tw *transportWrapper) SetPacketTimeout(packet interface{}, callback func(interface{}), timeout time.Duration) {
|
||||
time.AfterFunc(timeout, func() {
|
||||
callback(packet)
|
||||
})
|
||||
}
|
||||
|
||||
func (tw *transportWrapper) SetPacketDelivered(packet interface{}, callback func(interface{})) {
|
||||
callback(packet)
|
||||
}
|
||||
|
||||
func (tw *transportWrapper) GetLinkID() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tw *transportWrapper) HandleInbound(pkt *packet.Packet) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tw *transportWrapper) ValidateLinkProof(pkt *packet.Packet, networkIface common.NetworkInterface) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeDirectories() error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %v", err)
|
||||
}
|
||||
|
||||
basePath := filepath.Join(homeDir, ".reticulum-go")
|
||||
dirs := []string{
|
||||
basePath,
|
||||
filepath.Join(basePath, common.STR_STORAGE),
|
||||
filepath.Join(basePath, common.STR_STORAGE, "destinations"),
|
||||
filepath.Join(basePath, common.STR_STORAGE, "identities"),
|
||||
filepath.Join(basePath, common.STR_STORAGE, "ratchets"),
|
||||
filepath.Join(basePath, common.STR_STORAGE, "cache"),
|
||||
filepath.Join(basePath, common.STR_STORAGE, "cache", "announces"),
|
||||
filepath.Join(basePath, common.STR_STORAGE, "resources"),
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, common.NUM_0700); err != nil { // #nosec G301
|
||||
return fmt.Errorf("failed to create directory %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reticulum) Start() error {
|
||||
debug.Log(debug.DEBUG_ERROR, "Starting Reticulum...")
|
||||
|
||||
if err := r.transport.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start transport: %v", err)
|
||||
}
|
||||
debug.Log(debug.DEBUG_INFO, "Transport started successfully")
|
||||
|
||||
// Start interfaces
|
||||
for _, iface := range r.interfaces {
|
||||
debug.Log(debug.DEBUG_ERROR, "Starting interface", "name", iface.GetName())
|
||||
if err := iface.Start(); err != nil {
|
||||
if r.config.PanicOnInterfaceErr {
|
||||
return fmt.Errorf("failed to start interface %s: %v", iface.GetName(), err)
|
||||
}
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Error starting interface", "name", iface.GetName(), "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if netIface, ok := iface.(common.NetworkInterface); ok {
|
||||
// Register interface with transport
|
||||
if err := r.transport.RegisterInterface(iface.GetName(), netIface); err != nil {
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Failed to register interface with transport", "name", iface.GetName(), "error", err)
|
||||
} else {
|
||||
debug.Log(debug.DEBUG_INFO, "Registered interface with transport", "name", iface.GetName())
|
||||
}
|
||||
r.handleInterface(netIface)
|
||||
}
|
||||
debug.Log(debug.DEBUG_INFO, "Interface started successfully", "name", iface.GetName())
|
||||
}
|
||||
|
||||
// Wait for interfaces to initialize
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Send initial announce
|
||||
debug.Log(debug.DEBUG_ERROR, "Sending initial announce")
|
||||
nodeName := "Reticulum-Go Test Node"
|
||||
r.destination.SetDefaultAppData([]byte(nodeName))
|
||||
if err := r.destination.Announce(false, nil, nil); err != nil {
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Failed to send initial announce", "error", err)
|
||||
}
|
||||
|
||||
// Start periodic announce goroutine
|
||||
go func() {
|
||||
// Wait a bit before the first announce
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
for {
|
||||
debug.Log(debug.DEBUG_INFO, "Announcing destination...")
|
||||
err := r.destination.Announce(false, nil, nil)
|
||||
if err != nil {
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Could not send announce", "error", err)
|
||||
}
|
||||
|
||||
time.Sleep(60 * time.Second)
|
||||
}
|
||||
}()
|
||||
|
||||
go r.monitorInterfaces()
|
||||
|
||||
debug.Log(debug.DEBUG_ERROR, "Reticulum started successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reticulum) Stop() error {
|
||||
debug.Log(debug.DEBUG_ERROR, "Stopping Reticulum...")
|
||||
|
||||
for _, buf := range r.buffers {
|
||||
if err := buf.Close(); err != nil {
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Error closing buffer", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ch := range r.channels {
|
||||
if err := ch.Close(); err != nil {
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Error closing channel", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, iface := range r.interfaces {
|
||||
if err := iface.Stop(); err != nil {
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Error stopping interface", "name", iface.GetName(), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.transport.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close transport: %v", err)
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_ERROR, "Reticulum stopped successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
type AnnounceHandler struct {
|
||||
aspectFilter []string
|
||||
reticulum *Reticulum
|
||||
}
|
||||
|
||||
func NewAnnounceHandler(r *Reticulum, aspectFilter []string) *AnnounceHandler {
|
||||
return &AnnounceHandler{
|
||||
aspectFilter: aspectFilter,
|
||||
reticulum: r,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AnnounceHandler) AspectFilter() []string {
|
||||
return h.aspectFilter
|
||||
}
|
||||
|
||||
func (h *AnnounceHandler) ReceivedAnnounce(destHash []byte, id interface{}, appData []byte, hops uint8) error {
|
||||
debug.Log(debug.DEBUG_INFO, "Received announce", "hash", fmt.Sprintf("%x", destHash), "hops", hops)
|
||||
debug.Log(debug.DEBUG_PACKETS, "Raw announce data", "data", fmt.Sprintf("%x", appData))
|
||||
debug.Log(debug.DEBUG_INFO, "MAIN HANDLER: Received announce", "hash", fmt.Sprintf("%x", destHash), "appData_len", len(appData), "hops", hops)
|
||||
|
||||
var isNode bool
|
||||
var nodeEnabled bool
|
||||
var nodeTimestamp int64
|
||||
var nodeMaxSize int16
|
||||
|
||||
// Parse msgpack appData from transport announce format
|
||||
if len(appData) > common.ZERO {
|
||||
// appData is msgpack array [name, customData]
|
||||
if appData[0] == common.HEX_0x92 { // array of 2 elements
|
||||
// Skip array header and first element (name)
|
||||
pos := common.ONE
|
||||
if pos < len(appData) && appData[pos] == common.HEX_0xC4 { // bin 8
|
||||
nameLen := int(appData[pos+1])
|
||||
pos += common.TWO + nameLen
|
||||
if pos < len(appData) && appData[pos] == common.HEX_0xC4 { // bin 8
|
||||
dataLen := int(appData[pos+1])
|
||||
if pos+2+dataLen <= len(appData) {
|
||||
customData := appData[pos+2 : pos+2+dataLen]
|
||||
nodeName := string(customData)
|
||||
debug.Log(debug.DEBUG_INFO, "Parsed node name", "name", nodeName)
|
||||
debug.Log(debug.DEBUG_INFO, "Announced node", "name", nodeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: treat as raw node name
|
||||
nodeName := string(appData)
|
||||
debug.Log(debug.DEBUG_INFO, "Raw node name", "name", nodeName)
|
||||
debug.Log(debug.DEBUG_INFO, "Announced node", "name", nodeName)
|
||||
}
|
||||
} else {
|
||||
debug.Log(debug.DEBUG_INFO, "No appData (empty announce)")
|
||||
}
|
||||
|
||||
// Type assert and log identity details
|
||||
if identity, ok := id.(*identity.Identity); ok {
|
||||
debug.Log(debug.DEBUG_ALL, "Identity details")
|
||||
debug.Log(debug.DEBUG_ALL, "Identity hash", "hash", identity.GetHexHash())
|
||||
debug.Log(debug.DEBUG_ALL, "Identity public key", "key", fmt.Sprintf("%x", identity.GetPublicKey()))
|
||||
|
||||
ratchets := identity.GetRatchets()
|
||||
debug.Log(debug.DEBUG_ALL, "Active ratchets", "count", len(ratchets))
|
||||
|
||||
if len(ratchets) > 0 {
|
||||
ratchetKey := identity.GetCurrentRatchetKey()
|
||||
if ratchetKey != nil {
|
||||
ratchetID := identity.GetRatchetID(ratchetKey)
|
||||
debug.Log(debug.DEBUG_ALL, "Current ratchet ID", "id", fmt.Sprintf("%x", ratchetID))
|
||||
}
|
||||
}
|
||||
|
||||
// Create a better record with more info
|
||||
recordType := "peer"
|
||||
if isNode {
|
||||
recordType = "node"
|
||||
debug.Log(debug.DEBUG_INFO, "Storing node in announce history", "enabled", nodeEnabled, "timestamp", nodeTimestamp, "maxsize", fmt.Sprintf("%dKB", nodeMaxSize))
|
||||
}
|
||||
|
||||
h.reticulum.announceHistoryMu.Lock()
|
||||
h.reticulum.announceHistory[identity.GetHexHash()] = announceRecord{
|
||||
timestamp: time.Now().Unix(),
|
||||
appData: appData,
|
||||
}
|
||||
h.reticulum.announceHistoryMu.Unlock()
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Stored announce in history", "type", recordType, "identity", identity.GetHexHash())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AnnounceHandler) ReceivePathResponses() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Reticulum) GetDestination() *destination.Destination {
|
||||
return r.destination
|
||||
}
|
||||
|
||||
func (r *Reticulum) createNodeAppData() []byte {
|
||||
// Create a msgpack array with 3 elements
|
||||
// [Bool, Int32, Int16] for [enable, timestamp, max_transfer_size]
|
||||
appData := []byte{common.HEX_0x93} // Array with 3 elements
|
||||
|
||||
// Element 0: Boolean for enable/disable peer
|
||||
if r.nodeEnabled {
|
||||
appData = append(appData, common.HEX_0xC3) // true
|
||||
} else {
|
||||
appData = append(appData, common.HEX_0xC2) // false
|
||||
}
|
||||
|
||||
// Element 1: Int32 timestamp (current time)
|
||||
r.nodeTimestamp = time.Now().Unix()
|
||||
appData = append(appData, common.HEX_0xD2) // int32 format
|
||||
timeBytes := make([]byte, common.FOUR)
|
||||
binary.BigEndian.PutUint32(timeBytes, uint32(r.nodeTimestamp)) // #nosec G115
|
||||
appData = append(appData, timeBytes...)
|
||||
|
||||
// Element 2: Int16 max transfer size in KB
|
||||
appData = append(appData, common.HEX_0xD1) // int16 format
|
||||
sizeBytes := make([]byte, common.TWO)
|
||||
binary.BigEndian.PutUint16(sizeBytes, uint16(r.maxTransferSize)) // #nosec G115
|
||||
appData = append(appData, sizeBytes...)
|
||||
|
||||
debug.Log(debug.DEBUG_ALL, "Created node appData", "enable", r.nodeEnabled, "timestamp", r.nodeTimestamp, "maxsize", r.maxTransferSize, "data", fmt.Sprintf("%x", appData))
|
||||
return appData
|
||||
}
|
||||
61
cmd/reticulum-go/reticulum_test.go
Normal file
61
cmd/reticulum-go/reticulum_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/internal/config"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
)
|
||||
|
||||
func TestNewReticulum(t *testing.T) {
|
||||
// Set up a temporary home directory for testing
|
||||
tmpDir := t.TempDir()
|
||||
originalHome := os.Getenv(common.STR_HOME)
|
||||
os.Setenv(common.STR_HOME, tmpDir)
|
||||
defer os.Setenv(common.STR_HOME, originalHome)
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
// Disable interfaces for simple test
|
||||
cfg.Interfaces = make(map[string]*common.InterfaceConfig)
|
||||
|
||||
r, err := NewReticulum(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewReticulum failed: %v", err)
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("NewReticulum returned nil")
|
||||
}
|
||||
|
||||
if r.identity == nil {
|
||||
t.Error("Reticulum identity should not be nil")
|
||||
}
|
||||
if r.destination == nil {
|
||||
t.Error("Reticulum destination should not be nil")
|
||||
}
|
||||
|
||||
// Verify directories were created
|
||||
basePath := filepath.Join(tmpDir, ".reticulum-go")
|
||||
if _, err := os.Stat(basePath); os.IsNotExist(err) {
|
||||
t.Error("Base directory not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeAppData(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
os.Setenv(common.STR_HOME, tmpDir)
|
||||
|
||||
r := &Reticulum{
|
||||
nodeEnabled: true,
|
||||
maxTransferSize: common.NUM_500,
|
||||
}
|
||||
|
||||
data := r.createNodeAppData()
|
||||
if len(data) == common.ZERO {
|
||||
t.Error("createNodeAppData returned empty data")
|
||||
}
|
||||
if data[0] != common.HEX_0x93 {
|
||||
t.Errorf("Expected array header 0x93, got 0x%x", data[0])
|
||||
}
|
||||
}
|
||||
30
cmd/reticulum-wasm/main.go
Normal file
30
cmd/reticulum-wasm/main.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
//go:build js && wasm
|
||||
// +build js,wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/wasm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
run()
|
||||
// Keep the Go program running
|
||||
select {}
|
||||
}
|
||||
|
||||
func run() {
|
||||
debug.Init()
|
||||
debug.SetDebugLevel(debug.DEBUG_INFO)
|
||||
|
||||
wasm.RegisterJSFunctions()
|
||||
|
||||
// Notify JS that reticulum is ready
|
||||
js.Global().Call("reticulumReady")
|
||||
}
|
||||
|
||||
29
cmd/reticulum-wasm/main_test.go
Normal file
29
cmd/reticulum-wasm/main_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
//go:build js && wasm
|
||||
// +build js,wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall/js"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRun(t *testing.T) {
|
||||
readyCalled := false
|
||||
js.Global().Set("reticulumReady", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
readyCalled = true
|
||||
return nil
|
||||
}))
|
||||
|
||||
run()
|
||||
|
||||
if !readyCalled {
|
||||
t.Error("reticulumReady was not called by run()")
|
||||
}
|
||||
|
||||
reticulum := js.Global().Get("reticulum")
|
||||
if reticulum.IsUndefined() {
|
||||
t.Error("reticulum functions were not registered")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/Sudo-Ivan/reticulum-go/internal/config"
|
||||
"github.com/Sudo-Ivan/reticulum-go/pkg/transport"
|
||||
"github.com/Sudo-Ivan/reticulum-go/pkg/interfaces"
|
||||
)
|
||||
|
||||
type Reticulum struct {
|
||||
config *config.ReticulumConfig
|
||||
transport *transport.Transport
|
||||
}
|
||||
|
||||
func NewReticulum(cfg *config.ReticulumConfig) (*Reticulum, error) {
|
||||
if cfg == nil {
|
||||
cfg = config.DefaultConfig()
|
||||
}
|
||||
|
||||
// Initialize transport
|
||||
t, err := transport.NewTransport(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Reticulum{
|
||||
config: cfg,
|
||||
transport: t,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Reticulum) Start() error {
|
||||
// Initialize interfaces based on config
|
||||
for _, ifaceConfig := range r.config.Interfaces {
|
||||
var iface interfaces.Interface
|
||||
|
||||
switch ifaceConfig.Type {
|
||||
case "tcp":
|
||||
client, err := interfaces.NewTCPClient(
|
||||
ifaceConfig.Name,
|
||||
ifaceConfig.Address,
|
||||
ifaceConfig.Port,
|
||||
ifaceConfig.KISSFraming,
|
||||
ifaceConfig.I2PTunneled,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create TCP interface %s: %v", ifaceConfig.Name, err)
|
||||
continue
|
||||
}
|
||||
iface = client
|
||||
|
||||
case "tcpserver":
|
||||
server, err := interfaces.NewTCPServer(
|
||||
ifaceConfig.Name,
|
||||
ifaceConfig.Address,
|
||||
ifaceConfig.Port,
|
||||
ifaceConfig.PreferIPv6,
|
||||
ifaceConfig.I2PTunneled,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create TCP server interface %s: %v", ifaceConfig.Name, err)
|
||||
continue
|
||||
}
|
||||
iface = server
|
||||
|
||||
default:
|
||||
log.Printf("Unknown interface type: %s", ifaceConfig.Type)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set packet callback to transport
|
||||
iface.SetPacketCallback(r.transport.HandlePacket)
|
||||
}
|
||||
|
||||
log.Printf("Reticulum initialized with config at: %s", r.config.ConfigPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reticulum) Stop() error {
|
||||
if err := r.transport.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Initialize configuration
|
||||
cfg, err := config.InitConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize config: %v", err)
|
||||
}
|
||||
|
||||
// Create new reticulum instance
|
||||
r, err := NewReticulum(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create Reticulum instance: %v", err)
|
||||
}
|
||||
|
||||
// Start reticulum
|
||||
if err := r.Start(); err != nil {
|
||||
log.Fatalf("Failed to start Reticulum: %v", err)
|
||||
}
|
||||
|
||||
// Wait for interrupt signal
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
|
||||
// Clean shutdown
|
||||
if err := r.Stop(); err != nil {
|
||||
log.Printf("Error during shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
enable_transport = true
|
||||
share_instance = true
|
||||
shared_instance_port = 37428
|
||||
instance_control_port = 37429
|
||||
panic_on_interface_error = false
|
||||
loglevel = 4
|
||||
|
||||
[interfaces]
|
||||
[interfaces."Local TCP"]
|
||||
type = "TCPClientInterface"
|
||||
enabled = true
|
||||
target_host = "127.0.0.1"
|
||||
target_port = 4242
|
||||
|
||||
[interfaces."Local UDP"]
|
||||
type = "UDPInterface"
|
||||
enabled = true
|
||||
interface = "lo"
|
||||
@@ -1,18 +0,0 @@
|
||||
enable_transport = true
|
||||
share_instance = true
|
||||
shared_instance_port = 37430
|
||||
instance_control_port = 37431
|
||||
panic_on_interface_error = false
|
||||
loglevel = 4
|
||||
|
||||
[interfaces]
|
||||
[interfaces."Local TCP"]
|
||||
type = "TCPClientInterface"
|
||||
enabled = true
|
||||
target_host = "127.0.0.1"
|
||||
target_port = 4243
|
||||
|
||||
[interfaces."Local UDP"]
|
||||
type = "UDPInterface"
|
||||
enabled = true
|
||||
interface = "lo"
|
||||
39
docker/Dockerfile
Normal file
39
docker/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
ARG GO_VERSION=1.25
|
||||
FROM golang:${GO_VERSION}-alpine AS builder
|
||||
|
||||
ENV CGO_ENABLED=0
|
||||
ENV GOOS=linux
|
||||
ENV GOARCH=amd64
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd/ cmd/
|
||||
COPY internal/ internal/
|
||||
COPY pkg/ pkg/
|
||||
|
||||
RUN go build \
|
||||
-ldflags='-w -s -extldflags "-static"' \
|
||||
-a -installsuffix cgo \
|
||||
-o reticulum-go \
|
||||
./cmd/reticulum-go
|
||||
|
||||
FROM busybox:1.37.0@sha256:870e815c3a50dd0f6b40efddb319c72c32c3ee340b5a3e8945904232ccd12f44
|
||||
|
||||
RUN adduser -D -s /bin/sh app
|
||||
|
||||
COPY --from=builder /build/reticulum-go /usr/local/bin/reticulum-go
|
||||
|
||||
RUN chmod +x /usr/local/bin/reticulum-go
|
||||
RUN mkdir -p /app && chown app:app /app
|
||||
|
||||
USER app
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 4242
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/reticulum-go"]
|
||||
30
docker/Dockerfile.build
Normal file
30
docker/Dockerfile.build
Normal file
@@ -0,0 +1,30 @@
|
||||
ARG GO_VERSION=1.25
|
||||
FROM golang:${GO_VERSION}-alpine
|
||||
|
||||
ENV CGO_ENABLED=0
|
||||
ENV GOOS=linux
|
||||
ENV GOARCH=amd64
|
||||
|
||||
RUN apk add --no-cache git && \
|
||||
adduser -D -s /bin/sh builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
USER builder
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd/ cmd/
|
||||
COPY internal/ internal/
|
||||
COPY pkg/ pkg/
|
||||
|
||||
ARG BINARY_NAME=reticulum-go
|
||||
ARG BUILD_PATH=./cmd/reticulum-go
|
||||
|
||||
RUN mkdir -p /dist && \
|
||||
go build \
|
||||
-ldflags='-w -s -extldflags "-static"' \
|
||||
-a -installsuffix cgo \
|
||||
-o /dist/${BINARY_NAME} \
|
||||
${BUILD_PATH}
|
||||
16
examples/wasm/go.mod
Normal file
16
examples/wasm/go.mod
Normal file
@@ -0,0 +1,16 @@
|
||||
module git.quad4.io/Networks/Reticulum-Go/examples/wasm
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
git.quad4.io/Networks/Reticulum-Go v0.6.0
|
||||
git.quad4.io/RNS-Things/reticulum-go-mf v0.0.0-20251231170406-60b810424de0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
)
|
||||
|
||||
replace git.quad4.io/Networks/Reticulum-Go => ../../
|
||||
16
examples/wasm/go.sum
Normal file
16
examples/wasm/go.sum
Normal file
@@ -0,0 +1,16 @@
|
||||
git.quad4.io/RNS-Things/reticulum-go-mf v0.0.0-20251231170406-60b810424de0 h1:Yne2IbESHud2fmsj9kjsTYR3QBj+vY9fTqvsEzaKfy8=
|
||||
git.quad4.io/RNS-Things/reticulum-go-mf v0.0.0-20251231170406-60b810424de0/go.mod h1:vhZm1vAMuWJtoFGGAHPlnFsVqTzHkBuYWDMGo6KjVPk=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
80
examples/wasm/main.go
Normal file
80
examples/wasm/main.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
//go:build js && wasm
|
||||
// +build js,wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"syscall/js"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/wasm"
|
||||
"git.quad4.io/RNS-Things/reticulum-go-mf/pkg/mf"
|
||||
)
|
||||
|
||||
var messenger *mf.Messenger
|
||||
|
||||
func main() {
|
||||
// Register the generic WASM bridge functions first
|
||||
wasm.RegisterJSFunctions()
|
||||
|
||||
// Add chat-specific functions to the "reticulum" JS object
|
||||
reticulum := js.Global().Get("reticulum")
|
||||
reticulum.Set("sendMessage", js.FuncOf(SendMessage))
|
||||
reticulum.Set("sendAnnounce", js.FuncOf(SendAnnounce))
|
||||
|
||||
// Keep the Go program running
|
||||
select {}
|
||||
}
|
||||
|
||||
func SendMessage(this js.Value, args []js.Value) interface{} {
|
||||
if len(args) < 2 {
|
||||
return js.ValueOf(map[string]interface{}{
|
||||
"error": "Destination hash and message required",
|
||||
})
|
||||
}
|
||||
|
||||
destHashHex := args[0].String()
|
||||
message := args[1].String()
|
||||
|
||||
destHash, err := hex.DecodeString(destHashHex)
|
||||
if err != nil {
|
||||
return js.ValueOf(map[string]interface{}{
|
||||
"error": fmt.Sprintf("Invalid destination hash: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize messenger if not already done
|
||||
if messenger == nil {
|
||||
t := wasm.GetTransport()
|
||||
d := wasm.GetDestinationPointer()
|
||||
if t == nil || d == nil {
|
||||
return js.ValueOf(map[string]interface{}{
|
||||
"error": "Reticulum not initialized",
|
||||
})
|
||||
}
|
||||
messenger = mf.NewMessenger(t, d)
|
||||
}
|
||||
|
||||
// Use the high-level Messenger from mf package
|
||||
if err := messenger.SendMessage(destHash, message); err != nil {
|
||||
return js.ValueOf(map[string]interface{}{
|
||||
"error": fmt.Sprintf("Send failed: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return js.ValueOf(map[string]interface{}{
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
func SendAnnounce(this js.Value, args []js.Value) interface{} {
|
||||
var appData []byte
|
||||
if len(args) >= 1 && args[0].String() != "" {
|
||||
appData = []byte(args[0].String())
|
||||
}
|
||||
|
||||
return wasm.SendAnnounce(appData)
|
||||
}
|
||||
33
examples/wasm/main_test.go
Normal file
33
examples/wasm/main_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
//go:build js && wasm
|
||||
// +build js,wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall/js"
|
||||
"testing"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/wasm"
|
||||
)
|
||||
|
||||
func TestRegisterFunctions(t *testing.T) {
|
||||
// Register functions
|
||||
wasm.RegisterJSFunctions()
|
||||
|
||||
reticulum := js.Global().Get("reticulum")
|
||||
if reticulum.IsUndefined() {
|
||||
t.Fatal("reticulum object not registered")
|
||||
}
|
||||
|
||||
// Manually register chat functions since main() has select{}
|
||||
reticulum.Set("sendMessage", js.FuncOf(SendMessage))
|
||||
reticulum.Set("sendAnnounce", js.FuncOf(SendAnnounce))
|
||||
|
||||
tests := []string{"sendMessage", "sendAnnounce", "init", "getIdentity", "sendData"}
|
||||
for _, name := range tests {
|
||||
if reticulum.Get(name).Type() != js.TypeFunction {
|
||||
t.Errorf("function %s not registered correctly", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
43
examples/wasm/public/index.html
Normal file
43
examples/wasm/public/index.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Reticulum WASM Chat Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="js/wasm_exec.js"></script>
|
||||
<script>
|
||||
if (!WebAssembly.instantiateStreaming) { // polyfill
|
||||
WebAssembly.instantiateStreaming = async (resp, importObject) => {
|
||||
const source = await (await resp).arrayBuffer();
|
||||
return await WebAssembly.instantiate(source, importObject);
|
||||
};
|
||||
}
|
||||
|
||||
const go = new Go();
|
||||
let mod, inst;
|
||||
WebAssembly.instantiateStreaming(fetch("static/reticulum-go.wasm"), go.importObject).then(async (result) => {
|
||||
mod = result.module;
|
||||
inst = result.instance;
|
||||
console.log("WASM loaded");
|
||||
await go.run(inst);
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
// Basic chat interface helper
|
||||
window.onChatMessage = (msg) => {
|
||||
console.log("Chat message received:", msg);
|
||||
};
|
||||
window.onPeerDiscovered = (peer) => {
|
||||
console.log("Peer discovered:", peer);
|
||||
};
|
||||
window.log = (msg, level) => {
|
||||
console.log(`[${level}] ${msg}`);
|
||||
};
|
||||
</script>
|
||||
<h1>Reticulum WASM Chat</h1>
|
||||
<p>Open console to see output.</p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
575
examples/wasm/public/js/wasm_exec.js
Normal file
575
examples/wasm/public/js/wasm_exec.js
Normal file
@@ -0,0 +1,575 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
"use strict";
|
||||
|
||||
(() => {
|
||||
const enosys = () => {
|
||||
const err = new Error("not implemented");
|
||||
err.code = "ENOSYS";
|
||||
return err;
|
||||
};
|
||||
|
||||
if (!globalThis.fs) {
|
||||
let outputBuf = "";
|
||||
globalThis.fs = {
|
||||
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||
writeSync(fd, buf) {
|
||||
outputBuf += decoder.decode(buf);
|
||||
const nl = outputBuf.lastIndexOf("\n");
|
||||
if (nl != -1) {
|
||||
console.log(outputBuf.substring(0, nl));
|
||||
outputBuf = outputBuf.substring(nl + 1);
|
||||
}
|
||||
return buf.length;
|
||||
},
|
||||
write(fd, buf, offset, length, position, callback) {
|
||||
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||
callback(enosys());
|
||||
return;
|
||||
}
|
||||
const n = this.writeSync(fd, buf);
|
||||
callback(null, n);
|
||||
},
|
||||
chmod(path, mode, callback) { callback(enosys()); },
|
||||
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||
close(fd, callback) { callback(enosys()); },
|
||||
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||
fstat(fd, callback) { callback(enosys()); },
|
||||
fsync(fd, callback) { callback(null); },
|
||||
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||
link(path, link, callback) { callback(enosys()); },
|
||||
lstat(path, callback) { callback(enosys()); },
|
||||
mkdir(path, perm, callback) { callback(enosys()); },
|
||||
open(path, flags, mode, callback) { callback(enosys()); },
|
||||
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||
readdir(path, callback) { callback(enosys()); },
|
||||
readlink(path, callback) { callback(enosys()); },
|
||||
rename(from, to, callback) { callback(enosys()); },
|
||||
rmdir(path, callback) { callback(enosys()); },
|
||||
stat(path, callback) { callback(enosys()); },
|
||||
symlink(path, link, callback) { callback(enosys()); },
|
||||
truncate(path, length, callback) { callback(enosys()); },
|
||||
unlink(path, callback) { callback(enosys()); },
|
||||
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.process) {
|
||||
globalThis.process = {
|
||||
getuid() { return -1; },
|
||||
getgid() { return -1; },
|
||||
geteuid() { return -1; },
|
||||
getegid() { return -1; },
|
||||
getgroups() { throw enosys(); },
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
umask() { throw enosys(); },
|
||||
cwd() { throw enosys(); },
|
||||
chdir() { throw enosys(); },
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.path) {
|
||||
globalThis.path = {
|
||||
resolve(...pathSegments) {
|
||||
return pathSegments.join("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||
}
|
||||
|
||||
if (!globalThis.performance) {
|
||||
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||
}
|
||||
|
||||
if (!globalThis.TextEncoder) {
|
||||
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
if (!globalThis.TextDecoder) {
|
||||
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder("utf-8");
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
globalThis.Go = class {
|
||||
constructor() {
|
||||
this.argv = ["js"];
|
||||
this.env = {};
|
||||
this.exit = (code) => {
|
||||
if (code !== 0) {
|
||||
console.warn("exit code:", code);
|
||||
}
|
||||
};
|
||||
this._exitPromise = new Promise((resolve) => {
|
||||
this._resolveExitPromise = resolve;
|
||||
});
|
||||
this._pendingEvent = null;
|
||||
this._scheduledTimeouts = new Map();
|
||||
this._nextCallbackTimeoutID = 1;
|
||||
|
||||
const setInt64 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||
}
|
||||
|
||||
const setInt32 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
}
|
||||
|
||||
const getInt64 = (addr) => {
|
||||
const low = this.mem.getUint32(addr + 0, true);
|
||||
const high = this.mem.getInt32(addr + 4, true);
|
||||
return low + high * 4294967296;
|
||||
}
|
||||
|
||||
const loadValue = (addr) => {
|
||||
const f = this.mem.getFloat64(addr, true);
|
||||
if (f === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isNaN(f)) {
|
||||
return f;
|
||||
}
|
||||
|
||||
const id = this.mem.getUint32(addr, true);
|
||||
return this._values[id];
|
||||
}
|
||||
|
||||
const storeValue = (addr, v) => {
|
||||
const nanHead = 0x7FF80000;
|
||||
|
||||
if (typeof v === "number" && v !== 0) {
|
||||
if (isNaN(v)) {
|
||||
this.mem.setUint32(addr + 4, nanHead, true);
|
||||
this.mem.setUint32(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
this.mem.setFloat64(addr, v, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (v === undefined) {
|
||||
this.mem.setFloat64(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = this._ids.get(v);
|
||||
if (id === undefined) {
|
||||
id = this._idPool.pop();
|
||||
if (id === undefined) {
|
||||
id = this._values.length;
|
||||
}
|
||||
this._values[id] = v;
|
||||
this._goRefCounts[id] = 0;
|
||||
this._ids.set(v, id);
|
||||
}
|
||||
this._goRefCounts[id]++;
|
||||
let typeFlag = 0;
|
||||
switch (typeof v) {
|
||||
case "object":
|
||||
if (v !== null) {
|
||||
typeFlag = 1;
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
typeFlag = 2;
|
||||
break;
|
||||
case "symbol":
|
||||
typeFlag = 3;
|
||||
break;
|
||||
case "function":
|
||||
typeFlag = 4;
|
||||
break;
|
||||
}
|
||||
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||
this.mem.setUint32(addr, id, true);
|
||||
}
|
||||
|
||||
const loadSlice = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||
}
|
||||
|
||||
const loadSliceOfValues = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
const a = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
a[i] = loadValue(array + i * 8);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const loadString = (addr) => {
|
||||
const saddr = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||
}
|
||||
|
||||
const testCallExport = (a, b) => {
|
||||
this._inst.exports.testExport0();
|
||||
return this._inst.exports.testExport(a, b);
|
||||
}
|
||||
|
||||
const timeOrigin = Date.now() - performance.now();
|
||||
this.importObject = {
|
||||
_gotest: {
|
||||
add: (a, b) => a + b,
|
||||
callExport: testCallExport,
|
||||
},
|
||||
gojs: {
|
||||
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||
|
||||
// func wasmExit(code int32)
|
||||
"runtime.wasmExit": (sp) => {
|
||||
sp >>>= 0;
|
||||
const code = this.mem.getInt32(sp + 8, true);
|
||||
this.exited = true;
|
||||
delete this._inst;
|
||||
delete this._values;
|
||||
delete this._goRefCounts;
|
||||
delete this._ids;
|
||||
delete this._idPool;
|
||||
this.exit(code);
|
||||
},
|
||||
|
||||
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||
"runtime.wasmWrite": (sp) => {
|
||||
sp >>>= 0;
|
||||
const fd = getInt64(sp + 8);
|
||||
const p = getInt64(sp + 16);
|
||||
const n = this.mem.getInt32(sp + 24, true);
|
||||
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||
},
|
||||
|
||||
// func resetMemoryDataView()
|
||||
"runtime.resetMemoryDataView": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
},
|
||||
|
||||
// func nanotime1() int64
|
||||
"runtime.nanotime1": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||
},
|
||||
|
||||
// func walltime() (sec int64, nsec int32)
|
||||
"runtime.walltime": (sp) => {
|
||||
sp >>>= 0;
|
||||
const msec = (new Date).getTime();
|
||||
setInt64(sp + 8, msec / 1000);
|
||||
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||
},
|
||||
|
||||
// func scheduleTimeoutEvent(delay int64) int32
|
||||
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this._nextCallbackTimeoutID;
|
||||
this._nextCallbackTimeoutID++;
|
||||
this._scheduledTimeouts.set(id, setTimeout(
|
||||
() => {
|
||||
this._resume();
|
||||
while (this._scheduledTimeouts.has(id)) {
|
||||
// for some reason Go failed to register the timeout event, log and try again
|
||||
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||
this._resume();
|
||||
}
|
||||
},
|
||||
getInt64(sp + 8),
|
||||
));
|
||||
this.mem.setInt32(sp + 16, id, true);
|
||||
},
|
||||
|
||||
// func clearTimeoutEvent(id int32)
|
||||
"runtime.clearTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getInt32(sp + 8, true);
|
||||
clearTimeout(this._scheduledTimeouts.get(id));
|
||||
this._scheduledTimeouts.delete(id);
|
||||
},
|
||||
|
||||
// func getRandomData(r []byte)
|
||||
"runtime.getRandomData": (sp) => {
|
||||
sp >>>= 0;
|
||||
crypto.getRandomValues(loadSlice(sp + 8));
|
||||
},
|
||||
|
||||
// func finalizeRef(v ref)
|
||||
"syscall/js.finalizeRef": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getUint32(sp + 8, true);
|
||||
this._goRefCounts[id]--;
|
||||
if (this._goRefCounts[id] === 0) {
|
||||
const v = this._values[id];
|
||||
this._values[id] = null;
|
||||
this._ids.delete(v);
|
||||
this._idPool.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
// func stringVal(value string) ref
|
||||
"syscall/js.stringVal": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, loadString(sp + 8));
|
||||
},
|
||||
|
||||
// func valueGet(v ref, p string) ref
|
||||
"syscall/js.valueGet": (sp) => {
|
||||
sp >>>= 0;
|
||||
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 32, result);
|
||||
},
|
||||
|
||||
// func valueSet(v ref, p string, x ref)
|
||||
"syscall/js.valueSet": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||
},
|
||||
|
||||
// func valueDelete(v ref, p string)
|
||||
"syscall/js.valueDelete": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||
},
|
||||
|
||||
// func valueIndex(v ref, i int) ref
|
||||
"syscall/js.valueIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||
},
|
||||
|
||||
// valueSetIndex(v ref, i int, x ref)
|
||||
"syscall/js.valueSetIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||
},
|
||||
|
||||
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||
"syscall/js.valueCall": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const m = Reflect.get(v, loadString(sp + 16));
|
||||
const args = loadSliceOfValues(sp + 32);
|
||||
const result = Reflect.apply(m, v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, result);
|
||||
this.mem.setUint8(sp + 64, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, err);
|
||||
this.mem.setUint8(sp + 64, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueInvoke": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.apply(v, undefined, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueNew(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueNew": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.construct(v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueLength(v ref) int
|
||||
"syscall/js.valueLength": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||
},
|
||||
|
||||
// valuePrepareString(v ref) (ref, int)
|
||||
"syscall/js.valuePrepareString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||
storeValue(sp + 16, str);
|
||||
setInt64(sp + 24, str.length);
|
||||
},
|
||||
|
||||
// valueLoadString(v ref, b []byte)
|
||||
"syscall/js.valueLoadString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = loadValue(sp + 8);
|
||||
loadSlice(sp + 16).set(str);
|
||||
},
|
||||
|
||||
// func valueInstanceOf(v ref, t ref) bool
|
||||
"syscall/js.valueInstanceOf": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||
},
|
||||
|
||||
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||
"syscall/js.copyBytesToGo": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadSlice(sp + 8);
|
||||
const src = loadValue(sp + 32);
|
||||
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||
"syscall/js.copyBytesToJS": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadValue(sp + 8);
|
||||
const src = loadSlice(sp + 16);
|
||||
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
"debug": (value) => {
|
||||
console.log(value);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async run(instance) {
|
||||
if (!(instance instanceof WebAssembly.Instance)) {
|
||||
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||
}
|
||||
this._inst = instance;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||
NaN,
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
globalThis,
|
||||
this,
|
||||
];
|
||||
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||
this._ids = new Map([ // mapping from JS values to reference ids
|
||||
[0, 1],
|
||||
[null, 2],
|
||||
[true, 3],
|
||||
[false, 4],
|
||||
[globalThis, 5],
|
||||
[this, 6],
|
||||
]);
|
||||
this._idPool = []; // unused ids that have been garbage collected
|
||||
this.exited = false; // whether the Go program has exited
|
||||
|
||||
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||
let offset = 4096;
|
||||
|
||||
const strPtr = (str) => {
|
||||
const ptr = offset;
|
||||
const bytes = encoder.encode(str + "\0");
|
||||
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||
offset += bytes.length;
|
||||
if (offset % 8 !== 0) {
|
||||
offset += 8 - (offset % 8);
|
||||
}
|
||||
return ptr;
|
||||
};
|
||||
|
||||
const argc = this.argv.length;
|
||||
|
||||
const argvPtrs = [];
|
||||
this.argv.forEach((arg) => {
|
||||
argvPtrs.push(strPtr(arg));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const keys = Object.keys(this.env).sort();
|
||||
keys.forEach((key) => {
|
||||
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const argv = offset;
|
||||
argvPtrs.forEach((ptr) => {
|
||||
this.mem.setUint32(offset, ptr, true);
|
||||
this.mem.setUint32(offset + 4, 0, true);
|
||||
offset += 8;
|
||||
});
|
||||
|
||||
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||
const wasmMinDataAddr = 4096 + 8192;
|
||||
if (offset >= wasmMinDataAddr) {
|
||||
throw new Error("total length of command line and environment variables exceeds limit");
|
||||
}
|
||||
|
||||
this._inst.exports.run(argc, argv);
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
await this._exitPromise;
|
||||
}
|
||||
|
||||
_resume() {
|
||||
if (this.exited) {
|
||||
throw new Error("Go program has already exited");
|
||||
}
|
||||
this._inst.exports.resume();
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
}
|
||||
|
||||
_makeFuncWrapper(id) {
|
||||
const go = this;
|
||||
return function () {
|
||||
const event = { id: id, this: this, args: arguments };
|
||||
go._pendingEvent = event;
|
||||
go._resume();
|
||||
return event.result;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1766902085,
|
||||
"narHash": "sha256-coBu0ONtFzlwwVBzmjacUQwj3G+lybcZ1oeNSQkgC0M=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c0b0e0fddf73fd517c3471e546c0df87a42d53f4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
50
flake.nix
Normal file
50
flake.nix
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
description = "Reticulum-Go development environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
};
|
||||
|
||||
go = pkgs.go_1_25;
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
go
|
||||
go-task
|
||||
revive
|
||||
gosec
|
||||
gnumake
|
||||
tinygo
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "Reticulum-Go development environment"
|
||||
echo "Go version: $(go version)"
|
||||
echo "Task version: $(task --version 2>/dev/null || echo 'not available')"
|
||||
echo "Revive version: $(revive --version 2>/dev/null || echo 'not available')"
|
||||
echo "Gosec version: $(gosec --version 2>/dev/null || echo 'not available')"
|
||||
echo "TinyGo version: $(tinygo version 2>/dev/null || echo 'not available')"
|
||||
'';
|
||||
};
|
||||
|
||||
packages.default = pkgs.buildGoModule {
|
||||
pname = "reticulum-go";
|
||||
version = "dev";
|
||||
src = ./.;
|
||||
vendorHash = "";
|
||||
subPackages = [ "cmd/reticulum-go" ];
|
||||
ldflags = [ "-s" "-w" ];
|
||||
CGO_ENABLED = "0";
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
11
go.mod
11
go.mod
@@ -1,9 +1,10 @@
|
||||
module github.com/Sudo-Ivan/reticulum-go
|
||||
module git.quad4.io/Networks/Reticulum-Go
|
||||
|
||||
go 1.23.4
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/pelletier/go-toml v1.9.5
|
||||
golang.org/x/crypto v0.31.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
golang.org/x/crypto v0.46.0
|
||||
)
|
||||
|
||||
require github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
|
||||
22
go.sum
22
go.sum
@@ -1,8 +1,14 @@
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pelletier/go-toml"
|
||||
"github.com/Sudo-Ivan/reticulum-go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultSharedInstancePort = 37428
|
||||
DefaultInstanceControlPort = 37429
|
||||
DefaultLogLevel = 4
|
||||
DefaultLogLevel = 4
|
||||
)
|
||||
|
||||
func DefaultConfig() *common.ReticulumConfig {
|
||||
return &common.ReticulumConfig{
|
||||
EnableTransport: false,
|
||||
ShareInstance: true,
|
||||
SharedInstancePort: DefaultSharedInstancePort,
|
||||
InstanceControlPort: DefaultInstanceControlPort,
|
||||
EnableTransport: true,
|
||||
ShareInstance: true,
|
||||
SharedInstancePort: DefaultSharedInstancePort,
|
||||
InstanceControlPort: DefaultInstanceControlPort,
|
||||
PanicOnInterfaceErr: false,
|
||||
LogLevel: DefaultLogLevel,
|
||||
Interfaces: make(map[string]common.InterfaceConfig),
|
||||
Interfaces: make(map[string]*common.InterfaceConfig),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +36,7 @@ func GetConfigPath() (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(homeDir, ".reticulum", "config"), nil
|
||||
return filepath.Join(homeDir, ".reticulum-go", "config"), nil
|
||||
}
|
||||
|
||||
func EnsureConfigDir() error {
|
||||
@@ -40,65 +45,211 @@ func EnsureConfigDir() error {
|
||||
return err
|
||||
}
|
||||
|
||||
configDir := filepath.Join(homeDir, ".reticulum")
|
||||
return os.MkdirAll(configDir, 0755)
|
||||
configDir := filepath.Join(homeDir, ".reticulum-go")
|
||||
return os.MkdirAll(configDir, 0700) // #nosec G301
|
||||
}
|
||||
|
||||
// parseValue parses string values into appropriate types
|
||||
func parseValue(value string) interface{} {
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Try bool
|
||||
if value == "true" {
|
||||
return true
|
||||
}
|
||||
if value == "false" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try int
|
||||
if i, err := strconv.Atoi(value); err == nil {
|
||||
return i
|
||||
}
|
||||
|
||||
// Return as string
|
||||
return value
|
||||
}
|
||||
|
||||
// LoadConfig loads the configuration from the specified path
|
||||
func LoadConfig(path string) (*common.ReticulumConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
// bearer:disable go_gosec_filesystem_filereadtaint
|
||||
file, err := os.Open(path) // #nosec G304
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
if err := toml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
cfg.ConfigPath = path
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
var currentInterface *common.InterfaceConfig
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Skip comments and empty lines
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle interface sections
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
name := strings.Trim(line, "[]")
|
||||
currentInterface = &common.InterfaceConfig{Name: name}
|
||||
cfg.Interfaces[name] = currentInterface
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
if currentInterface != nil {
|
||||
// Parse interface config
|
||||
switch key {
|
||||
case "type":
|
||||
currentInterface.Type = value
|
||||
case "enabled":
|
||||
currentInterface.Enabled = value == "true"
|
||||
case "address":
|
||||
currentInterface.Address = value
|
||||
case "port":
|
||||
currentInterface.Port, _ = strconv.Atoi(value)
|
||||
case "target_host":
|
||||
currentInterface.TargetHost = value
|
||||
case "target_port":
|
||||
currentInterface.TargetPort, _ = strconv.Atoi(value)
|
||||
case "discovery_port":
|
||||
currentInterface.DiscoveryPort, _ = strconv.Atoi(value)
|
||||
case "data_port":
|
||||
currentInterface.DataPort, _ = strconv.Atoi(value)
|
||||
case "discovery_scope":
|
||||
currentInterface.DiscoveryScope = value
|
||||
case "group_id":
|
||||
currentInterface.GroupID = value
|
||||
}
|
||||
} else {
|
||||
// Parse global config
|
||||
switch key {
|
||||
case "enable_transport":
|
||||
cfg.EnableTransport = value == "true"
|
||||
case "share_instance":
|
||||
cfg.ShareInstance = value == "true"
|
||||
case "shared_instance_port":
|
||||
cfg.SharedInstancePort, _ = strconv.Atoi(value)
|
||||
case "instance_control_port":
|
||||
cfg.InstanceControlPort, _ = strconv.Atoi(value)
|
||||
case "panic_on_interface_error":
|
||||
cfg.PanicOnInterfaceErr = value == "true"
|
||||
case "loglevel":
|
||||
cfg.LogLevel, _ = strconv.Atoi(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg.ConfigPath = path
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveConfig saves the configuration to the specified path
|
||||
func SaveConfig(cfg *common.ReticulumConfig) error {
|
||||
data, err := toml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
if cfg.ConfigPath == "" {
|
||||
return fmt.Errorf("config path not set")
|
||||
}
|
||||
|
||||
return os.WriteFile(cfg.ConfigPath, data, 0644)
|
||||
var builder strings.Builder
|
||||
|
||||
// Write global config
|
||||
builder.WriteString("# Reticulum Configuration\n")
|
||||
builder.WriteString(fmt.Sprintf("enable_transport = %v\n", cfg.EnableTransport))
|
||||
builder.WriteString(fmt.Sprintf("share_instance = %v\n", cfg.ShareInstance))
|
||||
builder.WriteString(fmt.Sprintf("shared_instance_port = %d\n", cfg.SharedInstancePort))
|
||||
builder.WriteString(fmt.Sprintf("instance_control_port = %d\n", cfg.InstanceControlPort))
|
||||
builder.WriteString(fmt.Sprintf("panic_on_interface_error = %v\n", cfg.PanicOnInterfaceErr))
|
||||
builder.WriteString(fmt.Sprintf("loglevel = %d\n\n", cfg.LogLevel))
|
||||
|
||||
// Write interface configs
|
||||
for name, iface := range cfg.Interfaces {
|
||||
builder.WriteString(fmt.Sprintf("[%s]\n", name))
|
||||
builder.WriteString(fmt.Sprintf("type = %s\n", iface.Type))
|
||||
builder.WriteString(fmt.Sprintf("enabled = %v\n", iface.Enabled))
|
||||
|
||||
if iface.Address != "" {
|
||||
builder.WriteString(fmt.Sprintf("address = %s\n", iface.Address))
|
||||
}
|
||||
if iface.Port != 0 {
|
||||
builder.WriteString(fmt.Sprintf("port = %d\n", iface.Port))
|
||||
}
|
||||
if iface.TargetHost != "" {
|
||||
builder.WriteString(fmt.Sprintf("target_host = %s\n", iface.TargetHost))
|
||||
}
|
||||
if iface.TargetPort != 0 {
|
||||
builder.WriteString(fmt.Sprintf("target_port = %d\n", iface.TargetPort))
|
||||
}
|
||||
if iface.DiscoveryPort != 0 {
|
||||
builder.WriteString(fmt.Sprintf("discovery_port = %d\n", iface.DiscoveryPort))
|
||||
}
|
||||
if iface.DataPort != 0 {
|
||||
builder.WriteString(fmt.Sprintf("data_port = %d\n", iface.DataPort))
|
||||
}
|
||||
if iface.DiscoveryScope != "" {
|
||||
builder.WriteString(fmt.Sprintf("discovery_scope = %s\n", iface.DiscoveryScope))
|
||||
}
|
||||
if iface.GroupID != "" {
|
||||
builder.WriteString(fmt.Sprintf("group_id = %s\n", iface.GroupID))
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
return os.WriteFile(cfg.ConfigPath, []byte(builder.String()), 0600) // #nosec G306
|
||||
}
|
||||
|
||||
// CreateDefaultConfig creates a default configuration file
|
||||
func CreateDefaultConfig(path string) error {
|
||||
cfg := DefaultConfig()
|
||||
cfg.ConfigPath = path
|
||||
|
||||
// Add default interface
|
||||
cfg.Interfaces["Default Interface"] = common.InterfaceConfig{
|
||||
Type: "AutoInterface",
|
||||
Enabled: false,
|
||||
cfg.Interfaces["Auto Discovery"] = &common.InterfaceConfig{
|
||||
Type: "AutoInterface",
|
||||
Enabled: true,
|
||||
GroupID: "reticulum",
|
||||
DiscoveryScope: "link",
|
||||
DiscoveryPort: 29716,
|
||||
DataPort: 42671,
|
||||
}
|
||||
|
||||
// Add default quad4net interface
|
||||
cfg.Interfaces["quad4net tcp"] = common.InterfaceConfig{
|
||||
cfg.Interfaces["Go-RNS-Testnet"] = &common.InterfaceConfig{
|
||||
Type: "TCPClientInterface",
|
||||
Enabled: true,
|
||||
TargetHost: "127.0.0.1",
|
||||
TargetPort: 4242,
|
||||
Name: "Go-RNS-Testnet",
|
||||
}
|
||||
|
||||
cfg.Interfaces["Quad4 TCP"] = &common.InterfaceConfig{
|
||||
Type: "TCPClientInterface",
|
||||
Enabled: true,
|
||||
TargetHost: "rns.quad4.io",
|
||||
TargetPort: 4242,
|
||||
Name: "Quad4 TCP",
|
||||
}
|
||||
|
||||
data, err := toml.Marshal(cfg)
|
||||
if err != nil {
|
||||
cfg.Interfaces["Local UDP"] = &common.InterfaceConfig{
|
||||
Type: "UDPInterface",
|
||||
Enabled: false,
|
||||
Address: "0.0.0.0",
|
||||
Port: 37696,
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { // #nosec G301
|
||||
return err
|
||||
}
|
||||
|
||||
// Create config directory if it doesn't exist
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0644)
|
||||
return SaveConfig(cfg)
|
||||
}
|
||||
|
||||
// InitConfig initializes the configuration system
|
||||
@@ -118,4 +269,4 @@ func InitConfig() (*common.ReticulumConfig, error) {
|
||||
|
||||
// Load config
|
||||
return LoadConfig(configPath)
|
||||
}
|
||||
}
|
||||
|
||||
136
internal/config/config_test.go
Normal file
136
internal/config/config_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg == nil {
|
||||
t.Fatal("DefaultConfig() returned nil")
|
||||
}
|
||||
if !cfg.EnableTransport {
|
||||
t.Error("EnableTransport should be true by default")
|
||||
}
|
||||
if cfg.LogLevel != DefaultLogLevel {
|
||||
t.Errorf("LogLevel should be %d, got %d", DefaultLogLevel, cfg.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected interface{}
|
||||
}{
|
||||
{"true", true},
|
||||
{"false", false},
|
||||
{"123", 123},
|
||||
{"hello", "hello"},
|
||||
{" 456 ", 456},
|
||||
{" world ", "world"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := parseValue(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("parseValue(%q) = %v; want %v", tt.input, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSaveConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.ConfigPath = configPath
|
||||
cfg.LogLevel = 1
|
||||
cfg.EnableTransport = false
|
||||
cfg.Interfaces["TestInterface"] = &common.InterfaceConfig{
|
||||
Name: "TestInterface",
|
||||
Type: "UDPInterface",
|
||||
Enabled: true,
|
||||
Address: "1.2.3.4",
|
||||
Port: 1234,
|
||||
}
|
||||
|
||||
err := SaveConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig failed: %v", err)
|
||||
}
|
||||
|
||||
loadedCfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if loadedCfg.LogLevel != 1 {
|
||||
t.Errorf("Expected LogLevel 1, got %d", loadedCfg.LogLevel)
|
||||
}
|
||||
if loadedCfg.EnableTransport {
|
||||
t.Error("Expected EnableTransport false")
|
||||
}
|
||||
|
||||
iface, ok := loadedCfg.Interfaces["TestInterface"]
|
||||
if !ok {
|
||||
t.Fatal("TestInterface not found in loaded config")
|
||||
}
|
||||
if iface.Type != "UDPInterface" {
|
||||
t.Errorf("Expected type UDPInterface, got %s", iface.Type)
|
||||
}
|
||||
if iface.Address != "1.2.3.4" {
|
||||
t.Errorf("Expected address 1.2.3.4, got %s", iface.Address)
|
||||
}
|
||||
if iface.Port != 1234 {
|
||||
t.Errorf("Expected port 1234, got %d", iface.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDefaultConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config")
|
||||
|
||||
err := CreateDefaultConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDefaultConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
t.Fatal("Config file was not created")
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := cfg.Interfaces["Auto Discovery"]; !ok {
|
||||
t.Error("Auto Discovery interface missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigPath(t *testing.T) {
|
||||
path, err := GetConfigPath()
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfigPath failed: %v", err)
|
||||
}
|
||||
if path == "" {
|
||||
t.Error("GetConfigPath returned empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureConfigDir(t *testing.T) {
|
||||
// This might modify the actual home directory if not careful,
|
||||
// but EnsureConfigDir uses os.UserHomeDir().
|
||||
// For testing purposes, we can't easily mock os.UserHomeDir() without
|
||||
// changing the code or environment variables.
|
||||
// Since we are in a sandbox, it should be fine.
|
||||
err := EnsureConfigDir()
|
||||
if err != nil {
|
||||
t.Errorf("EnsureConfigDir failed: %v", err)
|
||||
}
|
||||
}
|
||||
191
internal/storage/storage.go
Normal file
191
internal/storage/storage.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
"github.com/vmihailenco/msgpack/v5"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
basePath string
|
||||
ratchetsPath string
|
||||
identitiesPath string
|
||||
destinationTable string
|
||||
knownDestinations string
|
||||
transportIdentity string
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
type RatchetData struct {
|
||||
RatchetKey []byte `msgpack:"ratchet_key"`
|
||||
Received int64 `msgpack:"received"`
|
||||
}
|
||||
|
||||
func NewManager() (*Manager, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
basePath := filepath.Join(homeDir, ".reticulum-go", "storage")
|
||||
|
||||
m := &Manager{
|
||||
basePath: basePath,
|
||||
ratchetsPath: filepath.Join(basePath, "ratchets"),
|
||||
identitiesPath: filepath.Join(basePath, "identities"),
|
||||
destinationTable: filepath.Join(basePath, "destination_table"),
|
||||
knownDestinations: filepath.Join(basePath, "known_destinations"),
|
||||
transportIdentity: filepath.Join(basePath, "transport_identity"),
|
||||
}
|
||||
|
||||
if err := m.initializeDirectories(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Manager) initializeDirectories() error {
|
||||
dirs := []string{
|
||||
m.basePath,
|
||||
m.ratchetsPath,
|
||||
m.identitiesPath,
|
||||
filepath.Join(m.basePath, "cache"),
|
||||
filepath.Join(m.basePath, "cache", "announces"),
|
||||
filepath.Join(m.basePath, "resources"),
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) SaveRatchet(identityHash []byte, ratchetKey []byte) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
hexHash := hex.EncodeToString(identityHash)
|
||||
ratchetDir := filepath.Join(m.ratchetsPath, hexHash)
|
||||
|
||||
if err := os.MkdirAll(ratchetDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create ratchet directory: %w", err)
|
||||
}
|
||||
|
||||
ratchetData := RatchetData{
|
||||
RatchetKey: ratchetKey,
|
||||
Received: time.Now().Unix(),
|
||||
}
|
||||
|
||||
data, err := msgpack.Marshal(ratchetData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal ratchet data: %w", err)
|
||||
}
|
||||
|
||||
ratchetHash := hex.EncodeToString(ratchetKey[:16])
|
||||
outPath := filepath.Join(ratchetDir, ratchetHash+".out")
|
||||
finalPath := filepath.Join(ratchetDir, ratchetHash)
|
||||
|
||||
if err := os.WriteFile(outPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write ratchet file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(outPath, finalPath); err != nil {
|
||||
_ = os.Remove(outPath)
|
||||
return fmt.Errorf("failed to move ratchet file: %w", err)
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Saved ratchet to storage", "identity", hexHash, "ratchet", ratchetHash)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) LoadRatchets(identityHash []byte) (map[string][]byte, error) {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
|
||||
hexHash := hex.EncodeToString(identityHash)
|
||||
ratchetDir := filepath.Join(m.ratchetsPath, hexHash)
|
||||
|
||||
ratchets := make(map[string][]byte)
|
||||
|
||||
if _, err := os.Stat(ratchetDir); os.IsNotExist(err) {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "No ratchet directory found", "identity", hexHash)
|
||||
return ratchets, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(ratchetDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read ratchet directory: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
expiry := int64(2592000) // 30 days
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(ratchetDir, entry.Name())
|
||||
// bearer:disable go_gosec_filesystem_filereadtaint
|
||||
data, err := os.ReadFile(filePath) // #nosec G304 - reading from controlled directory
|
||||
if err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to read ratchet file", "file", entry.Name(), "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
var ratchetData RatchetData
|
||||
if err := msgpack.Unmarshal(data, &ratchetData); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Corrupted ratchet data", "file", entry.Name(), "error", err)
|
||||
_ = os.Remove(filePath)
|
||||
continue
|
||||
}
|
||||
|
||||
if now > ratchetData.Received+expiry {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Removing expired ratchet", "file", entry.Name())
|
||||
_ = os.Remove(filePath)
|
||||
continue
|
||||
}
|
||||
|
||||
ratchetHash := entry.Name()
|
||||
ratchets[ratchetHash] = ratchetData.RatchetKey
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Loaded ratchets from storage", "identity", hexHash, "count", len(ratchets))
|
||||
return ratchets, nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetBasePath() string {
|
||||
return m.basePath
|
||||
}
|
||||
|
||||
func (m *Manager) GetRatchetsPath() string {
|
||||
return m.ratchetsPath
|
||||
}
|
||||
|
||||
func (m *Manager) GetIdentityPath() string {
|
||||
return filepath.Join(m.basePath, "identity")
|
||||
}
|
||||
|
||||
func (m *Manager) GetTransportIdentityPath() string {
|
||||
return m.transportIdentity
|
||||
}
|
||||
|
||||
func (m *Manager) GetDestinationTablePath() string {
|
||||
return m.destinationTable
|
||||
}
|
||||
|
||||
func (m *Manager) GetKnownDestinationsPath() string {
|
||||
return m.knownDestinations
|
||||
}
|
||||
117
internal/storage/storage_test.go
Normal file
117
internal/storage/storage_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewManager(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
m, err := NewManager()
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager failed: %v", err)
|
||||
}
|
||||
if m == nil {
|
||||
t.Fatal("NewManager returned nil")
|
||||
}
|
||||
|
||||
expectedBase := filepath.Join(tmpDir, ".reticulum-go", "storage")
|
||||
if m.basePath != expectedBase {
|
||||
t.Errorf("Expected basePath %s, got %s", expectedBase, m.basePath)
|
||||
}
|
||||
|
||||
// Verify directories were created
|
||||
dirs := []string{
|
||||
m.basePath,
|
||||
m.ratchetsPath,
|
||||
m.identitiesPath,
|
||||
filepath.Join(m.basePath, "cache"),
|
||||
filepath.Join(m.basePath, "cache", "announces"),
|
||||
filepath.Join(m.basePath, "resources"),
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
t.Errorf("Directory %s was not created", dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoadRatchets(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
m, err := NewManager()
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager failed: %v", err)
|
||||
}
|
||||
|
||||
identityHash := []byte("test-identity-hash")
|
||||
ratchetKey := make([]byte, 32)
|
||||
for i := range ratchetKey {
|
||||
ratchetKey[i] = byte(i)
|
||||
}
|
||||
|
||||
err = m.SaveRatchet(identityHash, ratchetKey)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRatchet failed: %v", err)
|
||||
}
|
||||
|
||||
ratchets, err := m.LoadRatchets(identityHash)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRatchets failed: %v", err)
|
||||
}
|
||||
|
||||
if len(ratchets) != 1 {
|
||||
t.Errorf("Expected 1 ratchet, got %d", len(ratchets))
|
||||
}
|
||||
|
||||
// The key in the map is the hex of first 16 bytes of ratchetKey
|
||||
found := false
|
||||
for _, key := range ratchets {
|
||||
if bytes.Equal(key, ratchetKey) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("Saved ratchet key not found in loaded ratchets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetters(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
m, _ := NewManager()
|
||||
|
||||
if m.GetBasePath() == "" {
|
||||
t.Error("GetBasePath returned empty string")
|
||||
}
|
||||
if m.GetRatchetsPath() == "" {
|
||||
t.Error("GetRatchetsPath returned empty string")
|
||||
}
|
||||
if m.GetIdentityPath() == "" {
|
||||
t.Error("GetIdentityPath returned empty string")
|
||||
}
|
||||
if m.GetTransportIdentityPath() == "" {
|
||||
t.Error("GetTransportIdentityPath returned empty string")
|
||||
}
|
||||
if m.GetDestinationTablePath() == "" {
|
||||
t.Error("GetDestinationTablePath returned empty string")
|
||||
}
|
||||
if m.GetKnownDestinationsPath() == "" {
|
||||
t.Error("GetKnownDestinationsPath returned empty string")
|
||||
}
|
||||
}
|
||||
17
misc/wasm/go_js_wasm_exec
Executable file
17
misc/wasm/go_js_wasm_exec
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2018 The Go Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
SOURCE="${BASH_SOURCE[0]}"
|
||||
while [ -h "$SOURCE" ]; do
|
||||
DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
|
||||
SOURCE="$(readlink "$SOURCE")"
|
||||
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
|
||||
done
|
||||
DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )"
|
||||
|
||||
# Increase the V8 stack size from the default of 984K
|
||||
# to 8192K to ensure all tests can pass without hitting
|
||||
# stack size limits.
|
||||
exec node --stack-size=8192 "$DIR/wasm_exec_node.js" "$@"
|
||||
575
misc/wasm/wasm_exec.js
Normal file
575
misc/wasm/wasm_exec.js
Normal file
@@ -0,0 +1,575 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
"use strict";
|
||||
|
||||
(() => {
|
||||
const enosys = () => {
|
||||
const err = new Error("not implemented");
|
||||
err.code = "ENOSYS";
|
||||
return err;
|
||||
};
|
||||
|
||||
if (!globalThis.fs) {
|
||||
let outputBuf = "";
|
||||
globalThis.fs = {
|
||||
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||
writeSync(fd, buf) {
|
||||
outputBuf += decoder.decode(buf);
|
||||
const nl = outputBuf.lastIndexOf("\n");
|
||||
if (nl != -1) {
|
||||
console.log(outputBuf.substring(0, nl));
|
||||
outputBuf = outputBuf.substring(nl + 1);
|
||||
}
|
||||
return buf.length;
|
||||
},
|
||||
write(fd, buf, offset, length, position, callback) {
|
||||
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||
callback(enosys());
|
||||
return;
|
||||
}
|
||||
const n = this.writeSync(fd, buf);
|
||||
callback(null, n);
|
||||
},
|
||||
chmod(path, mode, callback) { callback(enosys()); },
|
||||
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||
close(fd, callback) { callback(enosys()); },
|
||||
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||
fstat(fd, callback) { callback(enosys()); },
|
||||
fsync(fd, callback) { callback(null); },
|
||||
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||
link(path, link, callback) { callback(enosys()); },
|
||||
lstat(path, callback) { callback(enosys()); },
|
||||
mkdir(path, perm, callback) { callback(enosys()); },
|
||||
open(path, flags, mode, callback) { callback(enosys()); },
|
||||
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||
readdir(path, callback) { callback(enosys()); },
|
||||
readlink(path, callback) { callback(enosys()); },
|
||||
rename(from, to, callback) { callback(enosys()); },
|
||||
rmdir(path, callback) { callback(enosys()); },
|
||||
stat(path, callback) { callback(enosys()); },
|
||||
symlink(path, link, callback) { callback(enosys()); },
|
||||
truncate(path, length, callback) { callback(enosys()); },
|
||||
unlink(path, callback) { callback(enosys()); },
|
||||
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.process) {
|
||||
globalThis.process = {
|
||||
getuid() { return -1; },
|
||||
getgid() { return -1; },
|
||||
geteuid() { return -1; },
|
||||
getegid() { return -1; },
|
||||
getgroups() { throw enosys(); },
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
umask() { throw enosys(); },
|
||||
cwd() { throw enosys(); },
|
||||
chdir() { throw enosys(); },
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.path) {
|
||||
globalThis.path = {
|
||||
resolve(...pathSegments) {
|
||||
return pathSegments.join("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||
}
|
||||
|
||||
if (!globalThis.performance) {
|
||||
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||
}
|
||||
|
||||
if (!globalThis.TextEncoder) {
|
||||
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
if (!globalThis.TextDecoder) {
|
||||
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder("utf-8");
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
globalThis.Go = class {
|
||||
constructor() {
|
||||
this.argv = ["js"];
|
||||
this.env = {};
|
||||
this.exit = (code) => {
|
||||
if (code !== 0) {
|
||||
console.warn("exit code:", code);
|
||||
}
|
||||
};
|
||||
this._exitPromise = new Promise((resolve) => {
|
||||
this._resolveExitPromise = resolve;
|
||||
});
|
||||
this._pendingEvent = null;
|
||||
this._scheduledTimeouts = new Map();
|
||||
this._nextCallbackTimeoutID = 1;
|
||||
|
||||
const setInt64 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||
}
|
||||
|
||||
const setInt32 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
}
|
||||
|
||||
const getInt64 = (addr) => {
|
||||
const low = this.mem.getUint32(addr + 0, true);
|
||||
const high = this.mem.getInt32(addr + 4, true);
|
||||
return low + high * 4294967296;
|
||||
}
|
||||
|
||||
const loadValue = (addr) => {
|
||||
const f = this.mem.getFloat64(addr, true);
|
||||
if (f === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isNaN(f)) {
|
||||
return f;
|
||||
}
|
||||
|
||||
const id = this.mem.getUint32(addr, true);
|
||||
return this._values[id];
|
||||
}
|
||||
|
||||
const storeValue = (addr, v) => {
|
||||
const nanHead = 0x7FF80000;
|
||||
|
||||
if (typeof v === "number" && v !== 0) {
|
||||
if (isNaN(v)) {
|
||||
this.mem.setUint32(addr + 4, nanHead, true);
|
||||
this.mem.setUint32(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
this.mem.setFloat64(addr, v, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (v === undefined) {
|
||||
this.mem.setFloat64(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = this._ids.get(v);
|
||||
if (id === undefined) {
|
||||
id = this._idPool.pop();
|
||||
if (id === undefined) {
|
||||
id = this._values.length;
|
||||
}
|
||||
this._values[id] = v;
|
||||
this._goRefCounts[id] = 0;
|
||||
this._ids.set(v, id);
|
||||
}
|
||||
this._goRefCounts[id]++;
|
||||
let typeFlag = 0;
|
||||
switch (typeof v) {
|
||||
case "object":
|
||||
if (v !== null) {
|
||||
typeFlag = 1;
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
typeFlag = 2;
|
||||
break;
|
||||
case "symbol":
|
||||
typeFlag = 3;
|
||||
break;
|
||||
case "function":
|
||||
typeFlag = 4;
|
||||
break;
|
||||
}
|
||||
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||
this.mem.setUint32(addr, id, true);
|
||||
}
|
||||
|
||||
const loadSlice = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||
}
|
||||
|
||||
const loadSliceOfValues = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
const a = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
a[i] = loadValue(array + i * 8);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const loadString = (addr) => {
|
||||
const saddr = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||
}
|
||||
|
||||
const testCallExport = (a, b) => {
|
||||
this._inst.exports.testExport0();
|
||||
return this._inst.exports.testExport(a, b);
|
||||
}
|
||||
|
||||
const timeOrigin = Date.now() - performance.now();
|
||||
this.importObject = {
|
||||
_gotest: {
|
||||
add: (a, b) => a + b,
|
||||
callExport: testCallExport,
|
||||
},
|
||||
gojs: {
|
||||
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||
|
||||
// func wasmExit(code int32)
|
||||
"runtime.wasmExit": (sp) => {
|
||||
sp >>>= 0;
|
||||
const code = this.mem.getInt32(sp + 8, true);
|
||||
this.exited = true;
|
||||
delete this._inst;
|
||||
delete this._values;
|
||||
delete this._goRefCounts;
|
||||
delete this._ids;
|
||||
delete this._idPool;
|
||||
this.exit(code);
|
||||
},
|
||||
|
||||
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||
"runtime.wasmWrite": (sp) => {
|
||||
sp >>>= 0;
|
||||
const fd = getInt64(sp + 8);
|
||||
const p = getInt64(sp + 16);
|
||||
const n = this.mem.getInt32(sp + 24, true);
|
||||
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||
},
|
||||
|
||||
// func resetMemoryDataView()
|
||||
"runtime.resetMemoryDataView": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
},
|
||||
|
||||
// func nanotime1() int64
|
||||
"runtime.nanotime1": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||
},
|
||||
|
||||
// func walltime() (sec int64, nsec int32)
|
||||
"runtime.walltime": (sp) => {
|
||||
sp >>>= 0;
|
||||
const msec = (new Date).getTime();
|
||||
setInt64(sp + 8, msec / 1000);
|
||||
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||
},
|
||||
|
||||
// func scheduleTimeoutEvent(delay int64) int32
|
||||
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this._nextCallbackTimeoutID;
|
||||
this._nextCallbackTimeoutID++;
|
||||
this._scheduledTimeouts.set(id, setTimeout(
|
||||
() => {
|
||||
this._resume();
|
||||
while (this._scheduledTimeouts.has(id)) {
|
||||
// for some reason Go failed to register the timeout event, log and try again
|
||||
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||
this._resume();
|
||||
}
|
||||
},
|
||||
getInt64(sp + 8),
|
||||
));
|
||||
this.mem.setInt32(sp + 16, id, true);
|
||||
},
|
||||
|
||||
// func clearTimeoutEvent(id int32)
|
||||
"runtime.clearTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getInt32(sp + 8, true);
|
||||
clearTimeout(this._scheduledTimeouts.get(id));
|
||||
this._scheduledTimeouts.delete(id);
|
||||
},
|
||||
|
||||
// func getRandomData(r []byte)
|
||||
"runtime.getRandomData": (sp) => {
|
||||
sp >>>= 0;
|
||||
crypto.getRandomValues(loadSlice(sp + 8));
|
||||
},
|
||||
|
||||
// func finalizeRef(v ref)
|
||||
"syscall/js.finalizeRef": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getUint32(sp + 8, true);
|
||||
this._goRefCounts[id]--;
|
||||
if (this._goRefCounts[id] === 0) {
|
||||
const v = this._values[id];
|
||||
this._values[id] = null;
|
||||
this._ids.delete(v);
|
||||
this._idPool.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
// func stringVal(value string) ref
|
||||
"syscall/js.stringVal": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, loadString(sp + 8));
|
||||
},
|
||||
|
||||
// func valueGet(v ref, p string) ref
|
||||
"syscall/js.valueGet": (sp) => {
|
||||
sp >>>= 0;
|
||||
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 32, result);
|
||||
},
|
||||
|
||||
// func valueSet(v ref, p string, x ref)
|
||||
"syscall/js.valueSet": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||
},
|
||||
|
||||
// func valueDelete(v ref, p string)
|
||||
"syscall/js.valueDelete": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||
},
|
||||
|
||||
// func valueIndex(v ref, i int) ref
|
||||
"syscall/js.valueIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||
},
|
||||
|
||||
// valueSetIndex(v ref, i int, x ref)
|
||||
"syscall/js.valueSetIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||
},
|
||||
|
||||
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||
"syscall/js.valueCall": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const m = Reflect.get(v, loadString(sp + 16));
|
||||
const args = loadSliceOfValues(sp + 32);
|
||||
const result = Reflect.apply(m, v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, result);
|
||||
this.mem.setUint8(sp + 64, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, err);
|
||||
this.mem.setUint8(sp + 64, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueInvoke": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.apply(v, undefined, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueNew(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueNew": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.construct(v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueLength(v ref) int
|
||||
"syscall/js.valueLength": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||
},
|
||||
|
||||
// valuePrepareString(v ref) (ref, int)
|
||||
"syscall/js.valuePrepareString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||
storeValue(sp + 16, str);
|
||||
setInt64(sp + 24, str.length);
|
||||
},
|
||||
|
||||
// valueLoadString(v ref, b []byte)
|
||||
"syscall/js.valueLoadString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = loadValue(sp + 8);
|
||||
loadSlice(sp + 16).set(str);
|
||||
},
|
||||
|
||||
// func valueInstanceOf(v ref, t ref) bool
|
||||
"syscall/js.valueInstanceOf": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||
},
|
||||
|
||||
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||
"syscall/js.copyBytesToGo": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadSlice(sp + 8);
|
||||
const src = loadValue(sp + 32);
|
||||
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||
"syscall/js.copyBytesToJS": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadValue(sp + 8);
|
||||
const src = loadSlice(sp + 16);
|
||||
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
"debug": (value) => {
|
||||
console.log(value);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async run(instance) {
|
||||
if (!(instance instanceof WebAssembly.Instance)) {
|
||||
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||
}
|
||||
this._inst = instance;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||
NaN,
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
globalThis,
|
||||
this,
|
||||
];
|
||||
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||
this._ids = new Map([ // mapping from JS values to reference ids
|
||||
[0, 1],
|
||||
[null, 2],
|
||||
[true, 3],
|
||||
[false, 4],
|
||||
[globalThis, 5],
|
||||
[this, 6],
|
||||
]);
|
||||
this._idPool = []; // unused ids that have been garbage collected
|
||||
this.exited = false; // whether the Go program has exited
|
||||
|
||||
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||
let offset = 4096;
|
||||
|
||||
const strPtr = (str) => {
|
||||
const ptr = offset;
|
||||
const bytes = encoder.encode(str + "\0");
|
||||
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||
offset += bytes.length;
|
||||
if (offset % 8 !== 0) {
|
||||
offset += 8 - (offset % 8);
|
||||
}
|
||||
return ptr;
|
||||
};
|
||||
|
||||
const argc = this.argv.length;
|
||||
|
||||
const argvPtrs = [];
|
||||
this.argv.forEach((arg) => {
|
||||
argvPtrs.push(strPtr(arg));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const keys = Object.keys(this.env).sort();
|
||||
keys.forEach((key) => {
|
||||
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const argv = offset;
|
||||
argvPtrs.forEach((ptr) => {
|
||||
this.mem.setUint32(offset, ptr, true);
|
||||
this.mem.setUint32(offset + 4, 0, true);
|
||||
offset += 8;
|
||||
});
|
||||
|
||||
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||
const wasmMinDataAddr = 4096 + 8192;
|
||||
if (offset >= wasmMinDataAddr) {
|
||||
throw new Error("total length of command line and environment variables exceeds limit");
|
||||
}
|
||||
|
||||
this._inst.exports.run(argc, argv);
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
await this._exitPromise;
|
||||
}
|
||||
|
||||
_resume() {
|
||||
if (this.exited) {
|
||||
throw new Error("Go program has already exited");
|
||||
}
|
||||
this._inst.exports.resume();
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
}
|
||||
|
||||
_makeFuncWrapper(id) {
|
||||
const go = this;
|
||||
return function () {
|
||||
const event = { id: id, this: this, args: arguments };
|
||||
go._pendingEvent = event;
|
||||
go._resume();
|
||||
return event.result;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
41
misc/wasm/wasm_exec_node.js
Normal file
41
misc/wasm/wasm_exec_node.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright 2021 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
"use strict";
|
||||
|
||||
if (process.argv.length < 3) {
|
||||
console.error("usage: go_js_wasm_exec [wasm binary] [arguments]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
globalThis.require = require;
|
||||
globalThis.fs = require("fs");
|
||||
globalThis.path = require("path");
|
||||
globalThis.TextEncoder = require("util").TextEncoder;
|
||||
globalThis.TextDecoder = require("util").TextDecoder;
|
||||
|
||||
globalThis.performance ??= require("performance");
|
||||
|
||||
globalThis.crypto ??= require("crypto");
|
||||
|
||||
require("./wasm_exec");
|
||||
|
||||
const go = new Go();
|
||||
go.argv = process.argv.slice(2);
|
||||
go.env = Object.assign({ TMPDIR: require("os").tmpdir() }, process.env);
|
||||
go.exit = process.exit;
|
||||
WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then((result) => {
|
||||
process.on("exit", (code) => { // Node.js exits if no event handler is pending
|
||||
if (code === 0 && !go.exited) {
|
||||
// deadlock, make Go print error and stack traces
|
||||
go._pendingEvent = { id: 0 };
|
||||
go._resume();
|
||||
}
|
||||
});
|
||||
return go.run(result.instance);
|
||||
}).catch((err) => {
|
||||
// bearer:disable javascript_lang_logger_leak
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,109 +1,164 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package announce
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Sudo-Ivan/reticulum-go/pkg/identity"
|
||||
"github.com/Sudo-Ivan/reticulum-go/pkg/transport"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/identity"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
|
||||
const (
|
||||
PACKET_TYPE_DATA = 0x00
|
||||
PACKET_TYPE_ANNOUNCE = 0x01
|
||||
PACKET_TYPE_LINK = 0x02
|
||||
PACKET_TYPE_PROOF = 0x03
|
||||
|
||||
// Announce Types
|
||||
ANNOUNCE_NONE = 0x00
|
||||
ANNOUNCE_PATH = 0x01
|
||||
ANNOUNCE_IDENTITY = 0x02
|
||||
|
||||
// Header Types
|
||||
HEADER_TYPE_1 = 0x00 // One address field
|
||||
HEADER_TYPE_2 = 0x01 // Two address fields
|
||||
|
||||
// Propagation Types
|
||||
PROP_TYPE_BROADCAST = 0x00
|
||||
PROP_TYPE_TRANSPORT = 0x01
|
||||
|
||||
DEST_TYPE_SINGLE = 0x00
|
||||
DEST_TYPE_GROUP = 0x01
|
||||
DEST_TYPE_PLAIN = 0x02
|
||||
DEST_TYPE_LINK = 0x03
|
||||
|
||||
// IFAC Flag
|
||||
IFAC_NONE = 0x00
|
||||
IFAC_AUTH = 0x80
|
||||
|
||||
MAX_HOPS = 128
|
||||
PROPAGATION_RATE = 0.02 // 2% of interface bandwidth
|
||||
RETRY_INTERVAL = 300 // 5 minutes
|
||||
MAX_RETRIES = 3
|
||||
)
|
||||
|
||||
type AnnounceHandler interface {
|
||||
AspectFilter() []string
|
||||
ReceivedAnnounce(destinationHash []byte, announcedIdentity *identity.Identity, appData []byte) error
|
||||
ReceivePathResponses() bool
|
||||
}
|
||||
|
||||
type Announce struct {
|
||||
mutex sync.RWMutex
|
||||
mutex *sync.RWMutex
|
||||
destinationHash []byte
|
||||
identity *identity.Identity
|
||||
appData []byte
|
||||
hops uint8
|
||||
timestamp int64
|
||||
signature []byte
|
||||
pathResponse bool
|
||||
retries int
|
||||
handlers []AnnounceHandler
|
||||
destinationName string
|
||||
identity *identity.Identity
|
||||
appData []byte
|
||||
config *common.ReticulumConfig
|
||||
hops uint8
|
||||
timestamp int64
|
||||
signature []byte
|
||||
pathResponse bool
|
||||
retries int
|
||||
handlers []Handler
|
||||
ratchetID []byte
|
||||
packet []byte
|
||||
hash []byte
|
||||
}
|
||||
|
||||
func New(dest *identity.Identity, appData []byte, pathResponse bool) (*Announce, error) {
|
||||
a := &Announce{
|
||||
identity: dest,
|
||||
appData: appData,
|
||||
hops: 0,
|
||||
timestamp: time.Now().Unix(),
|
||||
pathResponse: pathResponse,
|
||||
retries: 0,
|
||||
handlers: make([]AnnounceHandler, 0),
|
||||
func New(dest *identity.Identity, destinationHash []byte, destinationName string, appData []byte, pathResponse bool, config *common.ReticulumConfig) (*Announce, error) {
|
||||
if dest == nil {
|
||||
return nil, errors.New("destination identity required")
|
||||
}
|
||||
|
||||
// Generate destination hash
|
||||
hash := sha256.New()
|
||||
hash.Write(dest.GetPublicKey())
|
||||
a.destinationHash = hash.Sum(nil)[:16] // Truncated hash
|
||||
if len(destinationHash) == 0 {
|
||||
return nil, errors.New("destination hash required")
|
||||
}
|
||||
|
||||
// Sign the announce
|
||||
if destinationName == "" {
|
||||
return nil, errors.New("destination name required")
|
||||
}
|
||||
|
||||
a := &Announce{
|
||||
mutex: &sync.RWMutex{},
|
||||
identity: dest,
|
||||
destinationHash: destinationHash,
|
||||
destinationName: destinationName,
|
||||
appData: appData,
|
||||
config: config,
|
||||
hops: 0,
|
||||
timestamp: time.Now().Unix(),
|
||||
pathResponse: pathResponse,
|
||||
retries: 0,
|
||||
handlers: make([]Handler, 0),
|
||||
}
|
||||
|
||||
// Get current ratchet ID if enabled
|
||||
currentRatchet := dest.GetCurrentRatchetKey()
|
||||
if currentRatchet != nil {
|
||||
ratchetPub, err := curve25519.X25519(currentRatchet, curve25519.Basepoint)
|
||||
if err == nil {
|
||||
a.ratchetID = dest.GetRatchetID(ratchetPub)
|
||||
}
|
||||
}
|
||||
|
||||
// Sign announce data
|
||||
signData := append(a.destinationHash, a.appData...)
|
||||
if a.ratchetID != nil {
|
||||
signData = append(signData, a.ratchetID...)
|
||||
}
|
||||
a.signature = dest.Sign(signData)
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *Announce) Propagate(interfaces []transport.Interface) error {
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
func (a *Announce) Propagate(interfaces []common.NetworkInterface) error {
|
||||
a.mutex.RLock()
|
||||
defer a.mutex.RUnlock()
|
||||
|
||||
if a.hops >= MAX_HOPS {
|
||||
return errors.New("maximum hop count reached")
|
||||
debug.Log(debug.DEBUG_TRACE, "Propagating announce across interfaces", "count", len(interfaces))
|
||||
|
||||
var packet []byte
|
||||
if a.packet != nil {
|
||||
debug.Log(debug.DEBUG_TRACE, "Using cached packet", "bytes", len(a.packet))
|
||||
packet = a.packet
|
||||
} else {
|
||||
debug.Log(debug.DEBUG_TRACE, "Creating new packet")
|
||||
packet = a.CreatePacket()
|
||||
a.packet = packet
|
||||
}
|
||||
|
||||
// Increment hop count
|
||||
a.hops++
|
||||
|
||||
// Create announce packet
|
||||
packet := make([]byte, 0)
|
||||
packet = append(packet, a.destinationHash...)
|
||||
packet = append(packet, a.identity.GetPublicKey()...)
|
||||
packet = append(packet, byte(a.hops))
|
||||
|
||||
if a.appData != nil {
|
||||
packet = append(packet, a.appData...)
|
||||
}
|
||||
|
||||
packet = append(packet, a.signature...)
|
||||
|
||||
// Propagate to all interfaces
|
||||
for _, iface := range interfaces {
|
||||
if err := iface.SendAnnounce(packet, a.pathResponse); err != nil {
|
||||
return err
|
||||
if !iface.IsEnabled() {
|
||||
debug.Log(debug.DEBUG_TRACE, "Skipping disabled interface", "name", iface.GetName())
|
||||
continue
|
||||
}
|
||||
if !iface.GetBandwidthAvailable() {
|
||||
debug.Log(debug.DEBUG_TRACE, "Skipping interface with insufficient bandwidth", "name", iface.GetName())
|
||||
continue
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_TRACE, "Sending announce on interface", "name", iface.GetName())
|
||||
if err := iface.Send(packet, ""); err != nil {
|
||||
debug.Log(debug.DEBUG_TRACE, "Failed to send on interface", "name", iface.GetName(), "error", err)
|
||||
return fmt.Errorf("failed to propagate on interface %s: %w", iface.GetName(), err)
|
||||
}
|
||||
debug.Log(debug.DEBUG_TRACE, "Successfully sent announce on interface", "name", iface.GetName())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Announce) RegisterHandler(handler AnnounceHandler) {
|
||||
func (a *Announce) RegisterHandler(handler Handler) {
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
a.handlers = append(a.handlers, handler)
|
||||
}
|
||||
|
||||
func (a *Announce) DeregisterHandler(handler AnnounceHandler) {
|
||||
func (a *Announce) DeregisterHandler(handler Handler) {
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
for i, h := range a.handlers {
|
||||
@@ -118,28 +173,113 @@ func (a *Announce) HandleAnnounce(data []byte) error {
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
|
||||
// Validate announce data
|
||||
if len(data) < 16+32+1 { // Min size: hash + pubkey + hops
|
||||
return errors.New("invalid announce data")
|
||||
debug.Log(debug.DEBUG_TRACE, "Handling announce packet", "bytes", len(data))
|
||||
|
||||
// Minimum packet size validation
|
||||
// header(2) + desthash(16) + context(1) + enckey(32) + signkey(32) + namehash(10) +
|
||||
// randomhash(10) + signature(64) + min app data(3)
|
||||
if len(data) < 170 {
|
||||
debug.Log(debug.DEBUG_TRACE, "Invalid announce data length", "bytes", len(data), "minimum", 170)
|
||||
return errors.New("invalid announce data length")
|
||||
}
|
||||
|
||||
// Extract fields
|
||||
destHash := data[:16]
|
||||
pubKey := data[16:48]
|
||||
hops := data[48]
|
||||
appData := data[49 : len(data)-64]
|
||||
signature := data[len(data)-64:]
|
||||
// Extract header and check packet type
|
||||
header := data[:2]
|
||||
if header[0]&0x03 != PACKET_TYPE_ANNOUNCE {
|
||||
return errors.New("not an announce packet")
|
||||
}
|
||||
|
||||
// Get hop count
|
||||
hopCount := header[1]
|
||||
if hopCount > MAX_HOPS {
|
||||
debug.Log(debug.DEBUG_TRACE, "Announce exceeded max hops", "hops", hopCount)
|
||||
return errors.New("announce exceeded maximum hop count")
|
||||
}
|
||||
|
||||
// Parse the packet based on header type
|
||||
headerType := (header[0] & 0b01000000) >> 6
|
||||
var contextByte byte
|
||||
var packetData []byte
|
||||
|
||||
if headerType == HEADER_TYPE_2 {
|
||||
// Header type 2 format: header(2) + desthash(16) + transportid(16) + context(1) + data
|
||||
if len(data) < 35 {
|
||||
return errors.New("header type 2 packet too short")
|
||||
}
|
||||
destHash := data[2:18]
|
||||
transportID := data[18:34]
|
||||
contextByte = data[34]
|
||||
packetData = data[35:]
|
||||
|
||||
debug.Log(debug.DEBUG_TRACE, "Header type 2 announce", "destHash", fmt.Sprintf("%x", destHash), "transportID", fmt.Sprintf("%x", transportID), "context", contextByte)
|
||||
} else {
|
||||
// Header type 1 format: header(2) + desthash(16) + context(1) + data
|
||||
if len(data) < 19 {
|
||||
return errors.New("header type 1 packet too short")
|
||||
}
|
||||
destHash := data[2:18]
|
||||
contextByte = data[18]
|
||||
packetData = data[19:]
|
||||
|
||||
debug.Log(debug.DEBUG_TRACE, "Header type 1 announce", "destHash", fmt.Sprintf("%x", destHash), "context", contextByte)
|
||||
}
|
||||
|
||||
// Now parse the data portion according to the spec
|
||||
// Public Key (32) + Signing Key (32) + Name Hash (10) + Random Hash (10) + Ratchet (32) + Signature (64) + App Data
|
||||
|
||||
if len(packetData) < 180 { // 32 + 32 + 10 + 10 + 32 + 64
|
||||
return errors.New("announce data too short")
|
||||
}
|
||||
|
||||
// Extract the components
|
||||
encKey := packetData[:32]
|
||||
signKey := packetData[32:64]
|
||||
nameHash := packetData[64:74]
|
||||
randomHash := packetData[74:84]
|
||||
ratchetData := packetData[84:116]
|
||||
signature := packetData[116:180]
|
||||
appData := packetData[180:]
|
||||
|
||||
debug.Log(debug.DEBUG_TRACE, "Announce fields", "encKey", fmt.Sprintf("%x", encKey), "signKey", fmt.Sprintf("%x", signKey))
|
||||
debug.Log(debug.DEBUG_TRACE, "Name and random hash", "nameHash", fmt.Sprintf("%x", nameHash), "randomHash", fmt.Sprintf("%x", randomHash))
|
||||
debug.Log(debug.DEBUG_TRACE, "Ratchet data", "ratchet", fmt.Sprintf("%x", ratchetData[:8]))
|
||||
debug.Log(debug.DEBUG_TRACE, "Signature and app data", "signature", fmt.Sprintf("%x", signature[:8]), "appDataLen", len(appData))
|
||||
|
||||
// Get the destination hash from header
|
||||
var destHash []byte
|
||||
if headerType == HEADER_TYPE_2 {
|
||||
destHash = data[2:18]
|
||||
} else {
|
||||
destHash = data[2:18]
|
||||
}
|
||||
|
||||
// Combine public keys
|
||||
pubKey := append(encKey, signKey...)
|
||||
|
||||
// Create announced identity from public keys
|
||||
announcedIdentity := identity.FromPublicKey(pubKey)
|
||||
if announcedIdentity == nil {
|
||||
return errors.New("invalid identity public key")
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
signData := append(destHash, appData...)
|
||||
if !a.identity.Verify(signData, signature) {
|
||||
signedData := make([]byte, 0)
|
||||
signedData = append(signedData, destHash...)
|
||||
signedData = append(signedData, encKey...)
|
||||
signedData = append(signedData, signKey...)
|
||||
signedData = append(signedData, nameHash...)
|
||||
signedData = append(signedData, randomHash...)
|
||||
signedData = append(signedData, ratchetData...)
|
||||
signedData = append(signedData, appData...)
|
||||
|
||||
if !announcedIdentity.Verify(signedData, signature) {
|
||||
return errors.New("invalid announce signature")
|
||||
}
|
||||
|
||||
// Process announce with registered handlers
|
||||
// Process with handlers
|
||||
for _, handler := range a.handlers {
|
||||
if handler.ReceivePathResponses() || !a.pathResponse {
|
||||
if err := handler.ReceivedAnnounce(destHash, a.identity, appData); err != nil {
|
||||
if err := handler.ReceivedAnnounce(destHash, announcedIdentity, appData, hopCount); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -148,7 +288,7 @@ func (a *Announce) HandleAnnounce(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Announce) RequestPath(destHash []byte, onInterface transport.Interface) error {
|
||||
func (a *Announce) RequestPath(destHash []byte, onInterface common.NetworkInterface) error {
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
|
||||
@@ -158,9 +298,225 @@ func (a *Announce) RequestPath(destHash []byte, onInterface transport.Interface)
|
||||
packet = append(packet, byte(0)) // Initial hop count
|
||||
|
||||
// Send path request
|
||||
if err := onInterface.SendPathRequest(packet); err != nil {
|
||||
return err
|
||||
return onInterface.Send(packet, "")
|
||||
}
|
||||
|
||||
// CreateHeader creates a Reticulum packet header according to spec
|
||||
func CreateHeader(ifacFlag byte, headerType byte, contextFlag byte, propType byte, destType byte, packetType byte, hops byte) []byte {
|
||||
header := make([]byte, 2)
|
||||
|
||||
// First byte: [IFAC Flag], [Header Type], [Context Flag], [Propagation Type], [Destination Type] and [Packet Type]
|
||||
header[0] = ifacFlag | (headerType << 6) | (contextFlag << 5) |
|
||||
(propType << 4) | (destType << 2) | packetType
|
||||
|
||||
// Second byte: Number of hops
|
||||
header[1] = hops
|
||||
|
||||
return header
|
||||
}
|
||||
|
||||
func (a *Announce) CreatePacket() []byte {
|
||||
// This function creates the complete announce packet according to the Reticulum specification.
|
||||
// Announce Packet Structure:
|
||||
// [Header (2 bytes)][Dest Hash (16 bytes)][Context (1 byte)][Announce Data]
|
||||
// Announce Data Structure:
|
||||
// [Public Key (64 bytes)][Name Hash (10 bytes)][Random Hash (10 bytes)][Ratchet (32 bytes optional)][Signature (64 bytes)][App Data]
|
||||
|
||||
// 2. Destination Hash
|
||||
destHash := a.destinationHash
|
||||
if len(destHash) > 16 {
|
||||
destHash = destHash[:16]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
// 3. Announce Data
|
||||
// 3.1 Public Key (full 64 bytes - not split into enc/sign keys in packet)
|
||||
pubKey := a.identity.GetPublicKey()
|
||||
if len(pubKey) != 64 {
|
||||
debug.Log(debug.DEBUG_TRACE, "Invalid public key length", "expected", 64, "got", len(pubKey))
|
||||
}
|
||||
|
||||
// 3.2 Name Hash
|
||||
nameHash := sha256.Sum256([]byte(a.destinationName))
|
||||
nameHash10 := nameHash[:10]
|
||||
|
||||
// 3.3 Random Hash (5 bytes random + 5 bytes timestamp)
|
||||
randomHash := make([]byte, 10)
|
||||
_, err := rand.Read(randomHash[:5])
|
||||
if err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to read random bytes for announce", "error", err)
|
||||
}
|
||||
// Add 5 bytes of timestamp
|
||||
timeBytes := make([]byte, 8)
|
||||
// #nosec G115 - Unix timestamp is always positive, no overflow risk
|
||||
binary.BigEndian.PutUint64(timeBytes, uint64(time.Now().Unix()))
|
||||
copy(randomHash[5:], timeBytes[:5])
|
||||
|
||||
// 3.4 Ratchet (only include if exists)
|
||||
var ratchetData []byte
|
||||
currentRatchetKey := a.identity.GetCurrentRatchetKey()
|
||||
if currentRatchetKey != nil {
|
||||
ratchetPub, err := curve25519.X25519(currentRatchetKey, curve25519.Basepoint)
|
||||
if err == nil {
|
||||
ratchetData = make([]byte, 32)
|
||||
copy(ratchetData, ratchetPub)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine context flag based on whether ratchet exists
|
||||
contextFlag := byte(0)
|
||||
if len(ratchetData) > 0 {
|
||||
contextFlag = 1 // FLAG_SET
|
||||
}
|
||||
|
||||
// 1. Create Header - Use HEADER_TYPE_1
|
||||
header := CreateHeader(
|
||||
IFAC_NONE,
|
||||
HEADER_TYPE_1,
|
||||
contextFlag,
|
||||
PROP_TYPE_BROADCAST,
|
||||
DEST_TYPE_SINGLE,
|
||||
PACKET_TYPE_ANNOUNCE,
|
||||
a.hops,
|
||||
)
|
||||
|
||||
// 4. Context Byte
|
||||
contextByte := byte(0)
|
||||
if a.pathResponse {
|
||||
contextByte = 0x0B // PATH_RESPONSE context
|
||||
}
|
||||
|
||||
// 3.5 Signature
|
||||
// The signature is calculated over: Dest Hash + Public Key (64 bytes) + Name Hash + Random Hash + Ratchet (if exists) + App Data
|
||||
validationData := make([]byte, 0)
|
||||
validationData = append(validationData, destHash...)
|
||||
validationData = append(validationData, pubKey...)
|
||||
validationData = append(validationData, nameHash10...)
|
||||
validationData = append(validationData, randomHash...)
|
||||
if len(ratchetData) > 0 {
|
||||
validationData = append(validationData, ratchetData...)
|
||||
}
|
||||
validationData = append(validationData, a.appData...)
|
||||
signature := a.identity.Sign(validationData)
|
||||
|
||||
debug.Log(debug.DEBUG_TRACE, "Creating announce packet", "destHash", fmt.Sprintf("%x", destHash), "pubKeyLen", len(pubKey), "nameHash", fmt.Sprintf("%x", nameHash10), "randomHash", fmt.Sprintf("%x", randomHash), "ratchetLen", len(ratchetData), "sigLen", len(signature), "appDataLen", len(a.appData))
|
||||
|
||||
// 5. Assemble the packet (HEADER_TYPE_1 format)
|
||||
packet := make([]byte, 0)
|
||||
packet = append(packet, header...)
|
||||
packet = append(packet, destHash...)
|
||||
packet = append(packet, contextByte)
|
||||
packet = append(packet, pubKey...)
|
||||
packet = append(packet, nameHash10...)
|
||||
packet = append(packet, randomHash...)
|
||||
if len(ratchetData) > 0 {
|
||||
packet = append(packet, ratchetData...)
|
||||
}
|
||||
packet = append(packet, signature...)
|
||||
packet = append(packet, a.appData...)
|
||||
|
||||
debug.Log(debug.DEBUG_TRACE, "Final announce packet", "totalBytes", len(packet), "ratchetLen", len(ratchetData), "appDataLen", len(a.appData))
|
||||
|
||||
return packet
|
||||
}
|
||||
|
||||
type AnnouncePacket struct {
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func NewAnnouncePacket(pubKey []byte, appData []byte, announceID []byte) *AnnouncePacket {
|
||||
packet := &AnnouncePacket{}
|
||||
|
||||
// Build packet data
|
||||
packet.Data = make([]byte, 0, len(pubKey)+len(appData)+len(announceID)+4)
|
||||
|
||||
// Add header
|
||||
packet.Data = append(packet.Data, PACKET_TYPE_ANNOUNCE)
|
||||
packet.Data = append(packet.Data, ANNOUNCE_IDENTITY)
|
||||
|
||||
// Add public key
|
||||
packet.Data = append(packet.Data, pubKey...)
|
||||
|
||||
// Add app data length and content
|
||||
appDataLen := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(appDataLen, uint16(len(appData))) // #nosec G115
|
||||
packet.Data = append(packet.Data, appDataLen...)
|
||||
packet.Data = append(packet.Data, appData...)
|
||||
|
||||
// Add announce ID
|
||||
packet.Data = append(packet.Data, announceID...)
|
||||
|
||||
return packet
|
||||
}
|
||||
|
||||
// NewAnnounce creates a new announce packet for a destination
|
||||
func NewAnnounce(identity *identity.Identity, destinationHash []byte, appData []byte, ratchetID []byte, pathResponse bool, config *common.ReticulumConfig) (*Announce, error) {
|
||||
debug.Log(debug.DEBUG_TRACE, "Creating new announce", "destHash", fmt.Sprintf("%x", destinationHash), "appDataLen", len(appData), "hasRatchet", ratchetID != nil, "pathResponse", pathResponse)
|
||||
|
||||
if identity == nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Nil identity provided")
|
||||
return nil, errors.New("identity cannot be nil")
|
||||
}
|
||||
|
||||
if config == nil {
|
||||
return nil, errors.New("config cannot be nil")
|
||||
}
|
||||
|
||||
if len(destinationHash) == 0 {
|
||||
return nil, errors.New("destination hash cannot be empty")
|
||||
}
|
||||
|
||||
destHash := destinationHash
|
||||
debug.Log(debug.DEBUG_TRACE, "Using provided destination hash", "destHash", fmt.Sprintf("%x", destHash))
|
||||
|
||||
a := &Announce{
|
||||
identity: identity,
|
||||
appData: appData,
|
||||
ratchetID: ratchetID,
|
||||
pathResponse: pathResponse,
|
||||
destinationHash: destHash,
|
||||
hops: 0,
|
||||
mutex: &sync.RWMutex{},
|
||||
handlers: make([]Handler, 0),
|
||||
config: config,
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_TRACE, "Created announce object", "destHash", fmt.Sprintf("%x", a.destinationHash), "hops", a.hops)
|
||||
|
||||
// Create initial packet
|
||||
packet := a.CreatePacket()
|
||||
a.packet = packet
|
||||
|
||||
// Generate hash
|
||||
hash := a.Hash()
|
||||
debug.Log(debug.DEBUG_TRACE, "Generated announce hash", "hash", fmt.Sprintf("%x", hash))
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *Announce) Hash() []byte {
|
||||
if a.hash == nil {
|
||||
// Generate hash from announce data
|
||||
h := sha256.New()
|
||||
h.Write(a.destinationHash)
|
||||
h.Write(a.identity.GetPublicKey())
|
||||
h.Write([]byte{a.hops})
|
||||
h.Write(a.appData)
|
||||
if a.ratchetID != nil {
|
||||
h.Write(a.ratchetID)
|
||||
}
|
||||
a.hash = h.Sum(nil)
|
||||
}
|
||||
return a.hash
|
||||
}
|
||||
|
||||
func (a *Announce) GetPacket() []byte {
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
|
||||
if a.packet == nil {
|
||||
// Use CreatePacket to generate the packet
|
||||
a.packet = a.CreatePacket()
|
||||
}
|
||||
|
||||
return a.packet
|
||||
}
|
||||
|
||||
123
pkg/announce/announce_test.go
Normal file
123
pkg/announce/announce_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package announce
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/identity"
|
||||
)
|
||||
|
||||
type mockAnnounceHandler struct {
|
||||
received bool
|
||||
}
|
||||
|
||||
func (m *mockAnnounceHandler) AspectFilter() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAnnounceHandler) ReceivedAnnounce(destinationHash []byte, announcedIdentity interface{}, appData []byte, hops uint8) error {
|
||||
m.received = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAnnounceHandler) ReceivePathResponses() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type mockInterface struct {
|
||||
common.BaseInterface
|
||||
sent bool
|
||||
}
|
||||
|
||||
func (m *mockInterface) Send(data []byte, address string) error {
|
||||
m.sent = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockInterface) GetBandwidthAvailable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *mockInterface) IsEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func TestNewAnnounce(t *testing.T) {
|
||||
id, _ := identity.New()
|
||||
destHash := make([]byte, 16)
|
||||
config := &common.ReticulumConfig{}
|
||||
|
||||
ann, err := New(id, destHash, "testapp", []byte("appdata"), false, config)
|
||||
if err != nil {
|
||||
t.Fatalf("New failed: %v", err)
|
||||
}
|
||||
if ann == nil {
|
||||
t.Fatal("New returned nil")
|
||||
}
|
||||
|
||||
if !bytes.Equal(ann.destinationHash, destHash) {
|
||||
t.Error("Destination hash doesn't match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndHandleAnnounce(t *testing.T) {
|
||||
id, _ := identity.New()
|
||||
destHash := make([]byte, 16)
|
||||
config := &common.ReticulumConfig{}
|
||||
|
||||
ann, _ := New(id, destHash, "testapp", []byte("appdata"), false, config)
|
||||
packet := ann.CreatePacket()
|
||||
|
||||
handler := &mockAnnounceHandler{}
|
||||
ann.RegisterHandler(handler)
|
||||
|
||||
err := ann.HandleAnnounce(packet)
|
||||
if err != nil {
|
||||
t.Fatalf("HandleAnnounce failed: %v", err)
|
||||
}
|
||||
|
||||
if !handler.received {
|
||||
t.Error("Handler did not receive announce")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropagate(t *testing.T) {
|
||||
id, _ := identity.New()
|
||||
destHash := make([]byte, 16)
|
||||
config := &common.ReticulumConfig{}
|
||||
|
||||
ann, _ := New(id, destHash, "testapp", []byte("appdata"), false, config)
|
||||
|
||||
iface := &mockInterface{}
|
||||
iface.Name = "testiface"
|
||||
iface.Online = true
|
||||
iface.Enabled = true
|
||||
|
||||
err := ann.Propagate([]common.NetworkInterface{iface})
|
||||
if err != nil {
|
||||
t.Fatalf("Propagate failed: %v", err)
|
||||
}
|
||||
|
||||
if !iface.sent {
|
||||
t.Error("Packet was not sent on interface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRegistration(t *testing.T) {
|
||||
ann := &Announce{
|
||||
mutex: &sync.RWMutex{},
|
||||
}
|
||||
handler := &mockAnnounceHandler{}
|
||||
|
||||
ann.RegisterHandler(handler)
|
||||
if len(ann.handlers) != 1 {
|
||||
t.Errorf("Expected 1 handler, got %d", len(ann.handlers))
|
||||
}
|
||||
|
||||
ann.DeregisterHandler(handler)
|
||||
if len(ann.handlers) != 0 {
|
||||
t.Errorf("Expected 0 handlers, got %d", len(ann.handlers))
|
||||
}
|
||||
}
|
||||
9
pkg/announce/handler.go
Normal file
9
pkg/announce/handler.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package announce
|
||||
|
||||
type Handler interface {
|
||||
AspectFilter() []string
|
||||
ReceivedAnnounce(destHash []byte, identity interface{}, appData []byte, hops uint8) error
|
||||
ReceivePathResponses() bool
|
||||
}
|
||||
266
pkg/buffer/buffer.go
Normal file
266
pkg/buffer/buffer.go
Normal file
@@ -0,0 +1,266 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package buffer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/bzip2"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/channel"
|
||||
)
|
||||
|
||||
const (
|
||||
StreamIDMax = 0x3fff // 16383
|
||||
MaxChunkLen = 16 * 1024
|
||||
MaxDataLen = 457 // MDU - 2 - 6 (2 for stream header, 6 for channel envelope)
|
||||
CompressTries = 4
|
||||
|
||||
// Stream header flags
|
||||
StreamHeaderEOF = 0x8000
|
||||
StreamHeaderCompressed = 0x4000
|
||||
|
||||
// Message type
|
||||
StreamDataMessageType = 0x01
|
||||
|
||||
// Header size
|
||||
StreamHeaderSize = 2
|
||||
|
||||
// Compression threshold
|
||||
CompressThreshold = 32
|
||||
)
|
||||
|
||||
type StreamDataMessage struct {
|
||||
StreamID uint16
|
||||
Data []byte
|
||||
EOF bool
|
||||
Compressed bool
|
||||
}
|
||||
|
||||
func (m *StreamDataMessage) Pack() ([]byte, error) {
|
||||
headerVal := uint16(m.StreamID & StreamIDMax)
|
||||
if m.EOF {
|
||||
headerVal |= StreamHeaderEOF
|
||||
}
|
||||
if m.Compressed {
|
||||
headerVal |= StreamHeaderCompressed
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if err := binary.Write(buf, binary.BigEndian, headerVal); err != nil { // #nosec G104
|
||||
return nil, err // Or handle the error appropriately
|
||||
}
|
||||
buf.Write(m.Data)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (m *StreamDataMessage) GetType() uint16 {
|
||||
return StreamDataMessageType
|
||||
}
|
||||
|
||||
func (m *StreamDataMessage) Unpack(data []byte) error {
|
||||
if len(data) < StreamHeaderSize {
|
||||
return io.ErrShortBuffer
|
||||
}
|
||||
|
||||
header := binary.BigEndian.Uint16(data[:StreamHeaderSize])
|
||||
m.StreamID = header & StreamIDMax
|
||||
m.EOF = (header & StreamHeaderEOF) != 0
|
||||
m.Compressed = (header & StreamHeaderCompressed) != 0
|
||||
m.Data = data[StreamHeaderSize:]
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type RawChannelReader struct {
|
||||
streamID int
|
||||
channel *channel.Channel
|
||||
buffer *bytes.Buffer
|
||||
eof bool
|
||||
callbacks map[int]func(int)
|
||||
nextCallbackID int
|
||||
messageHandlerID int
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
func NewRawChannelReader(streamID int, ch *channel.Channel) *RawChannelReader {
|
||||
reader := &RawChannelReader{
|
||||
streamID: streamID,
|
||||
channel: ch,
|
||||
buffer: bytes.NewBuffer(nil),
|
||||
callbacks: make(map[int]func(int)),
|
||||
}
|
||||
|
||||
reader.messageHandlerID = ch.AddMessageHandler(reader.HandleMessage)
|
||||
return reader
|
||||
}
|
||||
|
||||
func (r *RawChannelReader) AddReadyCallback(cb func(int)) int {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
id := r.nextCallbackID
|
||||
r.nextCallbackID++
|
||||
r.callbacks[id] = cb
|
||||
return id
|
||||
}
|
||||
|
||||
func (r *RawChannelReader) RemoveReadyCallback(id int) {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
delete(r.callbacks, id)
|
||||
}
|
||||
|
||||
func (r *RawChannelReader) Read(p []byte) (n int, err error) {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
|
||||
if r.buffer.Len() == 0 && r.eof {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
n, err = r.buffer.Read(p)
|
||||
if err == io.EOF && !r.eof {
|
||||
err = nil
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *RawChannelReader) HandleMessage(msg channel.MessageBase) bool { // #nosec G115
|
||||
if streamMsg, ok := msg.(*StreamDataMessage); ok && streamMsg.StreamID == uint16(r.streamID) {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
|
||||
if streamMsg.Compressed {
|
||||
decompressed := decompressData(streamMsg.Data)
|
||||
r.buffer.Write(decompressed)
|
||||
} else {
|
||||
r.buffer.Write(streamMsg.Data)
|
||||
}
|
||||
|
||||
if streamMsg.EOF {
|
||||
r.eof = true
|
||||
}
|
||||
|
||||
// Notify callbacks
|
||||
for _, cb := range r.callbacks {
|
||||
cb(r.buffer.Len())
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type RawChannelWriter struct {
|
||||
streamID int
|
||||
channel *channel.Channel
|
||||
eof bool
|
||||
}
|
||||
|
||||
func NewRawChannelWriter(streamID int, ch *channel.Channel) *RawChannelWriter {
|
||||
return &RawChannelWriter{
|
||||
streamID: streamID,
|
||||
channel: ch,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RawChannelWriter) Write(p []byte) (n int, err error) {
|
||||
if len(p) > MaxChunkLen {
|
||||
p = p[:MaxChunkLen]
|
||||
}
|
||||
|
||||
msg := &StreamDataMessage{
|
||||
StreamID: uint16(w.streamID), // #nosec G115
|
||||
Data: p,
|
||||
EOF: w.eof,
|
||||
}
|
||||
|
||||
if len(p) > CompressThreshold {
|
||||
for try := 1; try < CompressTries; try++ {
|
||||
chunkLen := len(p) / try
|
||||
compressed := compressData(p[:chunkLen])
|
||||
if len(compressed) < MaxDataLen && len(compressed) < chunkLen {
|
||||
msg.Data = compressed
|
||||
msg.Compressed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := w.channel.Send(msg); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (w *RawChannelWriter) Close() error {
|
||||
w.eof = true
|
||||
_, err := w.Write(nil)
|
||||
return err
|
||||
}
|
||||
|
||||
type Buffer struct {
|
||||
ReadWriter *bufio.ReadWriter
|
||||
}
|
||||
|
||||
func (b *Buffer) Write(p []byte) (n int, err error) {
|
||||
return b.ReadWriter.Write(p)
|
||||
}
|
||||
|
||||
func (b *Buffer) Read(p []byte) (n int, err error) {
|
||||
return b.ReadWriter.Read(p)
|
||||
}
|
||||
|
||||
func (b *Buffer) Close() error {
|
||||
return b.ReadWriter.Writer.Flush()
|
||||
}
|
||||
|
||||
func CreateReader(streamID int, ch *channel.Channel, readyCallback func(int)) *bufio.Reader {
|
||||
raw := NewRawChannelReader(streamID, ch)
|
||||
if readyCallback != nil {
|
||||
raw.AddReadyCallback(readyCallback)
|
||||
}
|
||||
return bufio.NewReader(raw)
|
||||
}
|
||||
|
||||
func CreateWriter(streamID int, ch *channel.Channel) *bufio.Writer {
|
||||
raw := NewRawChannelWriter(streamID, ch)
|
||||
return bufio.NewWriter(raw)
|
||||
}
|
||||
|
||||
func CreateBidirectionalBuffer(receiveStreamID, sendStreamID int, ch *channel.Channel, readyCallback func(int)) *bufio.ReadWriter {
|
||||
reader := CreateReader(receiveStreamID, ch, readyCallback)
|
||||
writer := CreateWriter(sendStreamID, ch)
|
||||
return bufio.NewReadWriter(reader, writer)
|
||||
}
|
||||
|
||||
func compressData(data []byte) []byte {
|
||||
var compressed bytes.Buffer
|
||||
w := bytes.NewBuffer(data)
|
||||
r := bzip2.NewReader(w)
|
||||
// bearer:disable go_gosec_filesystem_decompression_bomb
|
||||
_, err := io.Copy(&compressed, r) // #nosec G104 #nosec G110
|
||||
if err != nil {
|
||||
// Handle error, e.g., log it or return an error
|
||||
return nil
|
||||
}
|
||||
return compressed.Bytes()
|
||||
}
|
||||
|
||||
func decompressData(data []byte) []byte {
|
||||
reader := bzip2.NewReader(bytes.NewReader(data))
|
||||
var decompressed bytes.Buffer
|
||||
// Limit the amount of data read to prevent decompression bombs
|
||||
limitedReader := io.LimitReader(reader, MaxChunkLen) // #nosec G110
|
||||
// bearer:disable go_gosec_filesystem_decompression_bomb
|
||||
_, err := io.Copy(&decompressed, limitedReader)
|
||||
if err != nil {
|
||||
// Handle error, e.g., log it or return an error
|
||||
return nil
|
||||
}
|
||||
return decompressed.Bytes()
|
||||
}
|
||||
449
pkg/buffer/buffer_test.go
Normal file
449
pkg/buffer/buffer_test.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package buffer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/channel"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/packet"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/transport"
|
||||
)
|
||||
|
||||
func TestStreamDataMessage_Pack(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
streamID uint16
|
||||
data []byte
|
||||
eof bool
|
||||
compressed bool
|
||||
}{
|
||||
{
|
||||
name: "NormalMessage",
|
||||
streamID: 123,
|
||||
data: []byte("test data"),
|
||||
eof: false,
|
||||
compressed: false,
|
||||
},
|
||||
{
|
||||
name: "EOFMessage",
|
||||
streamID: 456,
|
||||
data: []byte("final data"),
|
||||
eof: true,
|
||||
compressed: false,
|
||||
},
|
||||
{
|
||||
name: "CompressedMessage",
|
||||
streamID: 789,
|
||||
data: []byte("compressed data"),
|
||||
eof: false,
|
||||
compressed: true,
|
||||
},
|
||||
{
|
||||
name: "EmptyData",
|
||||
streamID: 0,
|
||||
data: []byte{},
|
||||
eof: false,
|
||||
compressed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
msg := &StreamDataMessage{
|
||||
StreamID: tt.streamID,
|
||||
Data: tt.data,
|
||||
EOF: tt.eof,
|
||||
Compressed: tt.compressed,
|
||||
}
|
||||
|
||||
packed, err := msg.Pack()
|
||||
if err != nil {
|
||||
t.Fatalf("Pack() failed: %v", err)
|
||||
}
|
||||
|
||||
if len(packed) < 2 {
|
||||
t.Error("Packed message too short")
|
||||
}
|
||||
|
||||
unpacked := &StreamDataMessage{}
|
||||
if err := unpacked.Unpack(packed); err != nil {
|
||||
t.Fatalf("Unpack() failed: %v", err)
|
||||
}
|
||||
|
||||
if unpacked.StreamID != tt.streamID {
|
||||
t.Errorf("StreamID = %d, want %d", unpacked.StreamID, tt.streamID)
|
||||
}
|
||||
if unpacked.EOF != tt.eof {
|
||||
t.Errorf("EOF = %v, want %v", unpacked.EOF, tt.eof)
|
||||
}
|
||||
if unpacked.Compressed != tt.compressed {
|
||||
t.Errorf("Compressed = %v, want %v", unpacked.Compressed, tt.compressed)
|
||||
}
|
||||
if !bytes.Equal(unpacked.Data, tt.data) {
|
||||
t.Errorf("Data = %v, want %v", unpacked.Data, tt.data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamDataMessage_Unpack(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "ValidMessage",
|
||||
data: []byte{0x00, 0x7B, 'h', 'e', 'l', 'l', 'o'},
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "TooShort",
|
||||
data: []byte{0x00},
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "Empty",
|
||||
data: []byte{},
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
msg := &StreamDataMessage{}
|
||||
err := msg.Unpack(tt.data)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("Unpack() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamDataMessage_GetType(t *testing.T) {
|
||||
msg := &StreamDataMessage{}
|
||||
if msg.GetType() != 0x01 {
|
||||
t.Errorf("GetType() = %d, want 0x01", msg.GetType())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawChannelReader_AddCallback(t *testing.T) {
|
||||
reader := &RawChannelReader{
|
||||
streamID: 1,
|
||||
buffer: bytes.NewBuffer(nil),
|
||||
callbacks: make(map[int]func(int)),
|
||||
}
|
||||
|
||||
cb := func(int) {}
|
||||
|
||||
reader.AddReadyCallback(cb)
|
||||
if len(reader.callbacks) != 1 {
|
||||
t.Error("Callback should be added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuffer_Write(t *testing.T) {
|
||||
buf := &Buffer{
|
||||
ReadWriter: bufio.NewReadWriter(bufio.NewReader(bytes.NewBuffer(nil)), bufio.NewWriter(bytes.NewBuffer(nil))),
|
||||
}
|
||||
|
||||
data := []byte("test")
|
||||
n, err := buf.Write(data)
|
||||
if err != nil {
|
||||
t.Errorf("Write() error = %v", err)
|
||||
}
|
||||
if n != len(data) {
|
||||
t.Errorf("Write() = %d bytes, want %d", n, len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuffer_Read(t *testing.T) {
|
||||
buf := &Buffer{
|
||||
ReadWriter: bufio.NewReadWriter(bufio.NewReader(bytes.NewBuffer([]byte("test data"))), bufio.NewWriter(bytes.NewBuffer(nil))),
|
||||
}
|
||||
|
||||
data := make([]byte, 10)
|
||||
n, err := buf.Read(data)
|
||||
if err != nil && err != io.EOF {
|
||||
t.Errorf("Read() error = %v", err)
|
||||
}
|
||||
if n <= 0 {
|
||||
t.Errorf("Read() = %d bytes, want > 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuffer_Close(t *testing.T) {
|
||||
buf := &Buffer{
|
||||
ReadWriter: bufio.NewReadWriter(bufio.NewReader(bytes.NewBuffer(nil)), bufio.NewWriter(bytes.NewBuffer(nil))),
|
||||
}
|
||||
|
||||
if err := buf.Close(); err != nil {
|
||||
t.Errorf("Close() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamIDMax(t *testing.T) {
|
||||
if StreamIDMax != 0x3fff {
|
||||
t.Errorf("StreamIDMax = %d, want %d", StreamIDMax, 0x3fff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxChunkLen(t *testing.T) {
|
||||
if MaxChunkLen != 16*1024 {
|
||||
t.Errorf("MaxChunkLen = %d, want %d", MaxChunkLen, 16*1024)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxDataLen(t *testing.T) {
|
||||
if MaxDataLen != 457 {
|
||||
t.Errorf("MaxDataLen = %d, want %d", MaxDataLen, 457)
|
||||
}
|
||||
}
|
||||
|
||||
type mockLink struct {
|
||||
status byte
|
||||
rtt float64
|
||||
}
|
||||
|
||||
func (m *mockLink) GetStatus() byte { return m.status }
|
||||
func (m *mockLink) GetRTT() float64 { return m.rtt }
|
||||
func (m *mockLink) RTT() float64 { return m.rtt }
|
||||
func (m *mockLink) GetLinkID() []byte { return []byte("testlink") }
|
||||
func (m *mockLink) Send(data []byte) interface{} { return &packet.Packet{Raw: data} }
|
||||
func (m *mockLink) Resend(p interface{}) error { return nil }
|
||||
func (m *mockLink) SetPacketTimeout(p interface{}, cb func(interface{}), t time.Duration) {}
|
||||
func (m *mockLink) SetPacketDelivered(p interface{}, cb func(interface{})) {}
|
||||
func (m *mockLink) HandleInbound(pkt *packet.Packet) error { return nil }
|
||||
func (m *mockLink) ValidateLinkProof(pkt *packet.Packet, networkIface common.NetworkInterface) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewRawChannelReader(t *testing.T) {
|
||||
link := &mockLink{status: transport.STATUS_ACTIVE}
|
||||
ch := channel.NewChannel(link)
|
||||
reader := NewRawChannelReader(123, ch)
|
||||
|
||||
if reader.streamID != 123 {
|
||||
t.Errorf("streamID = %d, want %d", reader.streamID, 123)
|
||||
}
|
||||
if reader.channel != ch {
|
||||
t.Error("channel not set correctly")
|
||||
}
|
||||
if reader.buffer == nil {
|
||||
t.Error("buffer is nil")
|
||||
}
|
||||
if reader.callbacks == nil {
|
||||
t.Error("callbacks is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawChannelReader_RemoveReadyCallback(t *testing.T) {
|
||||
reader := &RawChannelReader{
|
||||
streamID: 1,
|
||||
buffer: bytes.NewBuffer(nil),
|
||||
callbacks: make(map[int]func(int)),
|
||||
}
|
||||
|
||||
cb1 := func(int) {}
|
||||
cb2 := func(int) {}
|
||||
|
||||
id1 := reader.AddReadyCallback(cb1)
|
||||
reader.AddReadyCallback(cb2)
|
||||
|
||||
if len(reader.callbacks) != 2 {
|
||||
t.Errorf("callbacks length = %d, want 2", len(reader.callbacks))
|
||||
}
|
||||
|
||||
reader.RemoveReadyCallback(id1)
|
||||
|
||||
if len(reader.callbacks) != 1 {
|
||||
t.Errorf("RemoveReadyCallback did not remove callback, length = %d", len(reader.callbacks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawChannelReader_Read(t *testing.T) {
|
||||
reader := &RawChannelReader{
|
||||
streamID: 1,
|
||||
buffer: bytes.NewBuffer([]byte("test data")),
|
||||
eof: false,
|
||||
}
|
||||
|
||||
data := make([]byte, 10)
|
||||
n, err := reader.Read(data)
|
||||
if err != nil {
|
||||
t.Errorf("Read() error = %v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Error("Read() returned 0 bytes")
|
||||
}
|
||||
|
||||
reader.eof = true
|
||||
reader.buffer = bytes.NewBuffer(nil)
|
||||
n, err = reader.Read(data)
|
||||
if err != io.EOF {
|
||||
t.Errorf("Read() error = %v, want io.EOF", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("Read() = %d bytes, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawChannelReader_HandleMessage(t *testing.T) {
|
||||
reader := &RawChannelReader{
|
||||
streamID: 1,
|
||||
buffer: bytes.NewBuffer(nil),
|
||||
callbacks: make(map[int]func(int)),
|
||||
}
|
||||
|
||||
msg := &StreamDataMessage{
|
||||
StreamID: 1,
|
||||
Data: []byte("test"),
|
||||
EOF: false,
|
||||
Compressed: false,
|
||||
}
|
||||
|
||||
called := false
|
||||
reader.AddReadyCallback(func(int) {
|
||||
called = true
|
||||
})
|
||||
|
||||
result := reader.HandleMessage(msg)
|
||||
if !result {
|
||||
t.Error("HandleMessage() = false, want true")
|
||||
}
|
||||
if !called {
|
||||
t.Error("callback was not called")
|
||||
}
|
||||
if reader.buffer.Len() == 0 {
|
||||
t.Error("buffer is empty after HandleMessage")
|
||||
}
|
||||
|
||||
msg.StreamID = 2
|
||||
result = reader.HandleMessage(msg)
|
||||
if result {
|
||||
t.Error("HandleMessage() = true, want false for different streamID")
|
||||
}
|
||||
|
||||
msg.StreamID = 1
|
||||
msg.EOF = true
|
||||
reader.HandleMessage(msg)
|
||||
if !reader.eof {
|
||||
t.Error("EOF not set after HandleMessage with EOF flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRawChannelWriter(t *testing.T) {
|
||||
link := &mockLink{status: transport.STATUS_ACTIVE}
|
||||
ch := channel.NewChannel(link)
|
||||
writer := NewRawChannelWriter(456, ch)
|
||||
|
||||
if writer.streamID != 456 {
|
||||
t.Errorf("streamID = %d, want %d", writer.streamID, 456)
|
||||
}
|
||||
if writer.channel != ch {
|
||||
t.Error("channel not set correctly")
|
||||
}
|
||||
if writer.eof {
|
||||
t.Error("eof should be false initially")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawChannelWriter_Write(t *testing.T) {
|
||||
link := &mockLink{status: transport.STATUS_ACTIVE}
|
||||
ch := channel.NewChannel(link)
|
||||
writer := NewRawChannelWriter(1, ch)
|
||||
|
||||
data := []byte("test data")
|
||||
n, err := writer.Write(data)
|
||||
if err != nil {
|
||||
t.Errorf("Write() error = %v", err)
|
||||
}
|
||||
if n != len(data) {
|
||||
t.Errorf("Write() = %d bytes, want %d", n, len(data))
|
||||
}
|
||||
|
||||
largeData := make([]byte, MaxChunkLen+100)
|
||||
n, err = writer.Write(largeData)
|
||||
if err != nil {
|
||||
t.Errorf("Write() error = %v", err)
|
||||
}
|
||||
if n != MaxChunkLen {
|
||||
t.Errorf("Write() = %d bytes, want %d", n, MaxChunkLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawChannelWriter_Close(t *testing.T) {
|
||||
link := &mockLink{status: transport.STATUS_ACTIVE}
|
||||
ch := channel.NewChannel(link)
|
||||
writer := NewRawChannelWriter(1, ch)
|
||||
|
||||
if writer.eof {
|
||||
t.Error("EOF should be false before Close()")
|
||||
}
|
||||
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() error = %v", err)
|
||||
}
|
||||
if !writer.eof {
|
||||
t.Error("EOF should be true after Close()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateReader(t *testing.T) {
|
||||
link := &mockLink{status: transport.STATUS_ACTIVE}
|
||||
ch := channel.NewChannel(link)
|
||||
callback := func(int) {}
|
||||
reader := CreateReader(789, ch, callback)
|
||||
|
||||
if reader == nil {
|
||||
t.Error("CreateReader() returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWriter(t *testing.T) {
|
||||
link := &mockLink{status: transport.STATUS_ACTIVE}
|
||||
ch := channel.NewChannel(link)
|
||||
writer := CreateWriter(101, ch)
|
||||
|
||||
if writer == nil {
|
||||
t.Error("CreateWriter() returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBidirectionalBuffer(t *testing.T) {
|
||||
link := &mockLink{status: transport.STATUS_ACTIVE}
|
||||
ch := channel.NewChannel(link)
|
||||
callback := func(int) {}
|
||||
buf := CreateBidirectionalBuffer(1, 2, ch, callback)
|
||||
|
||||
if buf == nil {
|
||||
t.Error("CreateBidirectionalBuffer() returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompressData(t *testing.T) {
|
||||
data := []byte("test data for compression")
|
||||
compressed := compressData(data)
|
||||
|
||||
if compressed == nil {
|
||||
t.Skip("compressData() returned nil (compression implementation may be incomplete)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecompressData(t *testing.T) {
|
||||
data := []byte("test data")
|
||||
compressed := compressData(data)
|
||||
if compressed == nil {
|
||||
t.Skip("compression not working, skipping decompression test")
|
||||
}
|
||||
|
||||
decompressed := decompressData(compressed)
|
||||
if decompressed == nil {
|
||||
t.Error("decompressData() returned nil")
|
||||
}
|
||||
}
|
||||
304
pkg/channel/channel.go
Normal file
304
pkg/channel/channel.go
Normal file
@@ -0,0 +1,304 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package channel
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/transport"
|
||||
)
|
||||
|
||||
const (
|
||||
// Window sizes and thresholds
|
||||
WindowInitial = 2
|
||||
WindowMin = 2
|
||||
WindowMinSlow = 2
|
||||
WindowMinMedium = 5
|
||||
WindowMinFast = 16
|
||||
WindowMaxSlow = 5
|
||||
WindowMaxMedium = 12
|
||||
WindowMaxFast = 48
|
||||
WindowMax = WindowMaxFast
|
||||
WindowFlexibility = 4
|
||||
|
||||
// RTT thresholds
|
||||
RTTFast = 0.18
|
||||
RTTMedium = 0.75
|
||||
RTTSlow = 1.45
|
||||
|
||||
// Sequence numbers
|
||||
SeqMax uint16 = 0xFFFF
|
||||
SeqModulus uint16 = SeqMax
|
||||
|
||||
FastRateThreshold = 10
|
||||
|
||||
// Timeout calculation constants
|
||||
RTTMinThreshold = 0.025
|
||||
TimeoutBaseMultiplier = 1.5
|
||||
TimeoutRingMultiplier = 2.5
|
||||
TimeoutRingOffset = 2
|
||||
|
||||
// Packet header constants
|
||||
ChannelHeaderSize = 6
|
||||
ChannelHeaderBits = 8
|
||||
|
||||
// Default retry count
|
||||
DefaultMaxTries = 3
|
||||
)
|
||||
|
||||
// MessageState represents the state of a message
|
||||
type MessageState int
|
||||
|
||||
const (
|
||||
MsgStateNew MessageState = iota
|
||||
MsgStateSent
|
||||
MsgStateDelivered
|
||||
MsgStateFailed
|
||||
)
|
||||
|
||||
// MessageBase defines the interface for messages that can be sent over a channel
|
||||
type MessageBase interface {
|
||||
Pack() ([]byte, error)
|
||||
Unpack([]byte) error
|
||||
GetType() uint16
|
||||
}
|
||||
|
||||
// Channel manages reliable message delivery over a transport link
|
||||
type Channel struct {
|
||||
link transport.LinkInterface
|
||||
mutex sync.RWMutex
|
||||
txRing []*Envelope
|
||||
rxRing []*Envelope
|
||||
window int
|
||||
windowMax int
|
||||
windowMin int
|
||||
windowFlex int
|
||||
nextSequence uint16
|
||||
nextRxSequence uint16
|
||||
maxTries int
|
||||
fastRateRounds int
|
||||
medRateRounds int
|
||||
messageHandlers []messageHandlerEntry
|
||||
nextHandlerID int
|
||||
}
|
||||
|
||||
type messageHandlerEntry struct {
|
||||
id int
|
||||
handler func(MessageBase) bool
|
||||
}
|
||||
|
||||
// Envelope wraps a message with metadata for transmission
|
||||
type Envelope struct {
|
||||
Sequence uint16
|
||||
Message MessageBase
|
||||
Raw []byte
|
||||
Packet interface{}
|
||||
Tries int
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// NewChannel creates a new Channel instance
|
||||
func NewChannel(link transport.LinkInterface) *Channel {
|
||||
return &Channel{
|
||||
link: link,
|
||||
messageHandlers: make([]messageHandlerEntry, 0),
|
||||
mutex: sync.RWMutex{},
|
||||
windowMax: WindowMaxSlow,
|
||||
windowMin: WindowMinSlow,
|
||||
window: WindowInitial,
|
||||
maxTries: DefaultMaxTries,
|
||||
}
|
||||
}
|
||||
|
||||
// Send transmits a message over the channel
|
||||
func (c *Channel) Send(msg MessageBase) error {
|
||||
if c.link.GetStatus() != transport.STATUS_ACTIVE {
|
||||
return errors.New("link not ready")
|
||||
}
|
||||
|
||||
env := &Envelope{
|
||||
Sequence: c.nextSequence,
|
||||
Message: msg,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
c.mutex.Lock()
|
||||
c.nextSequence = (c.nextSequence + common.ONE) % SeqModulus
|
||||
c.txRing = append(c.txRing, env)
|
||||
c.mutex.Unlock()
|
||||
|
||||
data, err := msg.Pack()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
env.Raw = data
|
||||
packet := c.link.Send(data)
|
||||
env.Packet = packet
|
||||
env.Tries++
|
||||
|
||||
timeout := c.getPacketTimeout(env.Tries)
|
||||
c.link.SetPacketTimeout(packet, c.handleTimeout, timeout)
|
||||
c.link.SetPacketDelivered(packet, c.handleDelivered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleTimeout handles packet timeout events
|
||||
func (c *Channel) handleTimeout(packet interface{}) {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
for _, env := range c.txRing {
|
||||
if env.Packet == packet {
|
||||
if env.Tries >= c.maxTries {
|
||||
// Remove from ring and notify failure
|
||||
return
|
||||
}
|
||||
env.Tries++
|
||||
if err := c.link.Resend(packet); err != nil { // #nosec G104
|
||||
// Handle resend error, e.g., log it or mark envelope as failed
|
||||
debug.Log(debug.DEBUG_INFO, "Failed to resend packet", "error", err)
|
||||
// Optionally, mark the envelope as failed or remove it from txRing
|
||||
// env.State = MsgStateFailed
|
||||
// c.txRing = append(c.txRing[:i], c.txRing[i+1:]...)
|
||||
return
|
||||
}
|
||||
timeout := c.getPacketTimeout(env.Tries)
|
||||
c.link.SetPacketTimeout(packet, c.handleTimeout, timeout)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleDelivered handles packet delivery confirmations
|
||||
func (c *Channel) handleDelivered(packet interface{}) {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
for i, env := range c.txRing {
|
||||
if env.Packet == packet {
|
||||
c.txRing = append(c.txRing[:i], c.txRing[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Channel) getPacketTimeout(tries int) time.Duration {
|
||||
rtt := c.link.GetRTT()
|
||||
if rtt < RTTMinThreshold {
|
||||
rtt = RTTMinThreshold
|
||||
}
|
||||
|
||||
timeout := math.Pow(TimeoutBaseMultiplier, float64(tries-common.ONE)) * rtt * TimeoutRingMultiplier * float64(len(c.txRing)+TimeoutRingOffset)
|
||||
return time.Duration(timeout * float64(time.Second))
|
||||
}
|
||||
|
||||
func (c *Channel) AddMessageHandler(handler func(MessageBase) bool) int {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
id := c.nextHandlerID
|
||||
c.nextHandlerID++
|
||||
c.messageHandlers = append(c.messageHandlers, messageHandlerEntry{id: id, handler: handler})
|
||||
return id
|
||||
}
|
||||
|
||||
func (c *Channel) RemoveMessageHandler(id int) {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
for i, entry := range c.messageHandlers {
|
||||
if entry.id == id {
|
||||
c.messageHandlers = append(c.messageHandlers[:i], c.messageHandlers[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Channel) updateRateThresholds() {
|
||||
rtt := c.link.RTT()
|
||||
|
||||
if rtt > RTTFast {
|
||||
c.fastRateRounds = common.ZERO
|
||||
|
||||
if rtt > RTTMedium {
|
||||
c.medRateRounds = common.ZERO
|
||||
} else {
|
||||
c.medRateRounds++
|
||||
if c.windowMax < WindowMaxMedium && c.medRateRounds == FastRateThreshold {
|
||||
c.windowMax = WindowMaxMedium
|
||||
c.windowMin = WindowMinMedium
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c.fastRateRounds++
|
||||
if c.windowMax < WindowMaxFast && c.fastRateRounds == FastRateThreshold {
|
||||
c.windowMax = WindowMaxFast
|
||||
c.windowMin = WindowMinFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Channel) HandleInbound(data []byte) error {
|
||||
if len(data) < ChannelHeaderSize {
|
||||
return errors.New("channel packet too short")
|
||||
}
|
||||
|
||||
msgType := uint16(data[0])<<ChannelHeaderBits | uint16(data[1])
|
||||
sequence := uint16(data[2])<<ChannelHeaderBits | uint16(data[3])
|
||||
length := uint16(data[4])<<ChannelHeaderBits | uint16(data[5])
|
||||
|
||||
if len(data) < ChannelHeaderSize+int(length) {
|
||||
return errors.New("channel packet incomplete")
|
||||
}
|
||||
|
||||
msgData := data[ChannelHeaderSize : ChannelHeaderSize+length]
|
||||
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
for _, entry := range c.messageHandlers {
|
||||
if entry.handler != nil {
|
||||
msg := &GenericMessage{
|
||||
Type: msgType,
|
||||
Data: msgData,
|
||||
Seq: sequence,
|
||||
}
|
||||
if entry.handler(msg) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type GenericMessage struct {
|
||||
Type uint16
|
||||
Data []byte
|
||||
Seq uint16
|
||||
}
|
||||
|
||||
func (g *GenericMessage) Pack() ([]byte, error) {
|
||||
return g.Data, nil
|
||||
}
|
||||
|
||||
func (g *GenericMessage) Unpack(data []byte) error {
|
||||
g.Data = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GenericMessage) GetType() uint16 {
|
||||
return g.Type
|
||||
}
|
||||
|
||||
func (c *Channel) Close() error {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
// Cleanup resources
|
||||
return nil
|
||||
}
|
||||
130
pkg/channel/channel_test.go
Normal file
130
pkg/channel/channel_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/packet"
|
||||
)
|
||||
|
||||
type mockLink struct {
|
||||
status byte
|
||||
rtt float64
|
||||
sent [][]byte
|
||||
timeouts map[interface{}]func(interface{})
|
||||
delivered map[interface{}]func(interface{})
|
||||
}
|
||||
|
||||
func (m *mockLink) GetStatus() byte { return m.status }
|
||||
func (m *mockLink) GetRTT() float64 { return m.rtt }
|
||||
func (m *mockLink) RTT() float64 { return m.rtt }
|
||||
func (m *mockLink) GetLinkID() []byte { return []byte("testlink") }
|
||||
func (m *mockLink) Send(data []byte) interface{} {
|
||||
m.sent = append(m.sent, data)
|
||||
p := &packet.Packet{Raw: data}
|
||||
return p
|
||||
}
|
||||
func (m *mockLink) Resend(p interface{}) error { return nil }
|
||||
func (m *mockLink) SetPacketTimeout(p interface{}, cb func(interface{}), t time.Duration) {
|
||||
if m.timeouts == nil {
|
||||
m.timeouts = make(map[interface{}]func(interface{}))
|
||||
}
|
||||
m.timeouts[p] = cb
|
||||
}
|
||||
func (m *mockLink) SetPacketDelivered(p interface{}, cb func(interface{})) {
|
||||
if m.delivered == nil {
|
||||
m.delivered = make(map[interface{}]func(interface{}))
|
||||
}
|
||||
m.delivered[p] = cb
|
||||
}
|
||||
func (m *mockLink) HandleInbound(pkt *packet.Packet) error { return nil }
|
||||
func (m *mockLink) ValidateLinkProof(pkt *packet.Packet, networkIface common.NetworkInterface) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type testMessage struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (m *testMessage) Pack() ([]byte, error) { return m.data, nil }
|
||||
func (m *testMessage) Unpack(data []byte) error { m.data = data; return nil }
|
||||
func (m *testMessage) GetType() uint16 { return 1 }
|
||||
|
||||
func TestNewChannel(t *testing.T) {
|
||||
link := &mockLink{}
|
||||
c := NewChannel(link)
|
||||
if c == nil {
|
||||
t.Fatal("NewChannel returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelSend(t *testing.T) {
|
||||
link := &mockLink{status: 1} // STATUS_ACTIVE
|
||||
c := NewChannel(link)
|
||||
|
||||
msg := &testMessage{data: []byte("test")}
|
||||
err := c.Send(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("Send failed: %v", err)
|
||||
}
|
||||
|
||||
if len(link.sent) != 1 {
|
||||
t.Errorf("Expected 1 packet sent, got %d", len(link.sent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInbound(t *testing.T) {
|
||||
link := &mockLink{}
|
||||
c := NewChannel(link)
|
||||
|
||||
received := false
|
||||
c.AddMessageHandler(func(m MessageBase) bool {
|
||||
received = true
|
||||
return true
|
||||
})
|
||||
|
||||
// Packet format: [type 2][seq 2][len 2][data]
|
||||
data := []byte{0, 1, 0, 1, 0, 4, 't', 'e', 's', 't'}
|
||||
err := c.HandleInbound(data)
|
||||
if err != nil {
|
||||
t.Fatalf("HandleInbound failed: %v", err)
|
||||
}
|
||||
|
||||
if !received {
|
||||
t.Error("Message handler was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageHandlers(t *testing.T) {
|
||||
c := &Channel{
|
||||
messageHandlers: make([]messageHandlerEntry, 0),
|
||||
}
|
||||
h := func(m MessageBase) bool { return true }
|
||||
|
||||
id := c.AddMessageHandler(h)
|
||||
if len(c.messageHandlers) != 1 {
|
||||
t.Errorf("Expected 1 handler, got %d", len(c.messageHandlers))
|
||||
}
|
||||
|
||||
c.RemoveMessageHandler(id)
|
||||
if len(c.messageHandlers) != 0 {
|
||||
t.Errorf("Expected 0 handlers, got %d", len(c.messageHandlers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericMessage(t *testing.T) {
|
||||
msg := &GenericMessage{Type: 1, Data: []byte("test")}
|
||||
if msg.GetType() != 1 {
|
||||
t.Error("Wrong type")
|
||||
}
|
||||
p, _ := msg.Pack()
|
||||
if !bytes.Equal(p, []byte("test")) {
|
||||
t.Error("Pack failed")
|
||||
}
|
||||
msg.Unpack([]byte("new"))
|
||||
if !bytes.Equal(msg.Data, []byte("new")) {
|
||||
t.Error("Unpack failed")
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,95 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
DEFAULT_SHARED_INSTANCE_PORT = 37428
|
||||
DEFAULT_INSTANCE_CONTROL_PORT = 37429
|
||||
DEFAULT_LOG_LEVEL = 20
|
||||
)
|
||||
|
||||
// ConfigProvider interface for accessing configuration
|
||||
type ConfigProvider interface {
|
||||
GetConfigPath() string
|
||||
GetLogLevel() int
|
||||
GetInterfaces() map[string]InterfaceConfig
|
||||
GetConfigPath() string
|
||||
GetLogLevel() int
|
||||
GetInterfaces() map[string]InterfaceConfig
|
||||
}
|
||||
|
||||
// InterfaceConfig represents interface configuration
|
||||
type InterfaceConfig struct {
|
||||
Type string `toml:"type"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
TargetHost string `toml:"target_host,omitempty"`
|
||||
TargetPort int `toml:"target_port,omitempty"`
|
||||
Interface string `toml:"interface,omitempty"`
|
||||
Name string
|
||||
Type string
|
||||
Enabled bool
|
||||
Address string
|
||||
Port int
|
||||
TargetHost string
|
||||
TargetPort int
|
||||
TargetAddress string
|
||||
Interface string
|
||||
KISSFraming bool
|
||||
I2PTunneled bool
|
||||
PreferIPv6 bool
|
||||
MaxReconnTries int
|
||||
Bitrate int64
|
||||
MTU int
|
||||
GroupID string
|
||||
DiscoveryScope string
|
||||
DiscoveryPort int
|
||||
DataPort int
|
||||
}
|
||||
|
||||
// ReticulumConfig represents the main configuration structure
|
||||
type ReticulumConfig struct {
|
||||
EnableTransport bool `toml:"enable_transport"`
|
||||
ShareInstance bool `toml:"share_instance"`
|
||||
SharedInstancePort int `toml:"shared_instance_port"`
|
||||
InstanceControlPort int `toml:"instance_control_port"`
|
||||
PanicOnInterfaceErr bool `toml:"panic_on_interface_error"`
|
||||
LogLevel int `toml:"loglevel"`
|
||||
ConfigPath string `toml:"-"`
|
||||
Interfaces map[string]InterfaceConfig
|
||||
}
|
||||
ConfigPath string
|
||||
EnableTransport bool
|
||||
ShareInstance bool
|
||||
SharedInstancePort int
|
||||
InstanceControlPort int
|
||||
PanicOnInterfaceErr bool
|
||||
LogLevel int
|
||||
Interfaces map[string]*InterfaceConfig
|
||||
AppName string
|
||||
AppAspect string
|
||||
}
|
||||
|
||||
// NewReticulumConfig creates a new ReticulumConfig with default values
|
||||
func NewReticulumConfig() *ReticulumConfig {
|
||||
return &ReticulumConfig{
|
||||
EnableTransport: true,
|
||||
ShareInstance: false,
|
||||
SharedInstancePort: DEFAULT_SHARED_INSTANCE_PORT,
|
||||
InstanceControlPort: DEFAULT_INSTANCE_CONTROL_PORT,
|
||||
PanicOnInterfaceErr: false,
|
||||
LogLevel: DEFAULT_LOG_LEVEL,
|
||||
Interfaces: make(map[string]*InterfaceConfig),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks if the configuration is valid
|
||||
func (c *ReticulumConfig) Validate() error {
|
||||
if c.SharedInstancePort < 1 || c.SharedInstancePort > 65535 {
|
||||
return fmt.Errorf("invalid shared instance port: %d", c.SharedInstancePort)
|
||||
}
|
||||
if c.InstanceControlPort < 1 || c.InstanceControlPort > 65535 {
|
||||
return fmt.Errorf("invalid instance control port: %d", c.InstanceControlPort)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DefaultConfig() *ReticulumConfig {
|
||||
return &ReticulumConfig{
|
||||
EnableTransport: true,
|
||||
ShareInstance: false,
|
||||
SharedInstancePort: DEFAULT_SHARED_INSTANCE_PORT,
|
||||
InstanceControlPort: DEFAULT_INSTANCE_CONTROL_PORT,
|
||||
PanicOnInterfaceErr: false,
|
||||
LogLevel: DEFAULT_LOG_LEVEL,
|
||||
Interfaces: make(map[string]*InterfaceConfig),
|
||||
AppName: "Go Client",
|
||||
AppAspect: "node",
|
||||
}
|
||||
}
|
||||
|
||||
94
pkg/common/config_test.go
Normal file
94
pkg/common/config_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewReticulumConfig(t *testing.T) {
|
||||
cfg := NewReticulumConfig()
|
||||
|
||||
if !cfg.EnableTransport {
|
||||
t.Errorf("NewReticulumConfig() EnableTransport = %v; want true", cfg.EnableTransport)
|
||||
}
|
||||
if cfg.ShareInstance {
|
||||
t.Errorf("NewReticulumConfig() ShareInstance = %v; want false", cfg.ShareInstance)
|
||||
}
|
||||
if cfg.SharedInstancePort != DEFAULT_SHARED_INSTANCE_PORT {
|
||||
t.Errorf("NewReticulumConfig() SharedInstancePort = %d; want %d", cfg.SharedInstancePort, DEFAULT_SHARED_INSTANCE_PORT)
|
||||
}
|
||||
if cfg.InstanceControlPort != DEFAULT_INSTANCE_CONTROL_PORT {
|
||||
t.Errorf("NewReticulumConfig() InstanceControlPort = %d; want %d", cfg.InstanceControlPort, DEFAULT_INSTANCE_CONTROL_PORT)
|
||||
}
|
||||
if cfg.PanicOnInterfaceErr {
|
||||
t.Errorf("NewReticulumConfig() PanicOnInterfaceErr = %v; want false", cfg.PanicOnInterfaceErr)
|
||||
}
|
||||
if cfg.LogLevel != DEFAULT_LOG_LEVEL {
|
||||
t.Errorf("NewReticulumConfig() LogLevel = %d; want %d", cfg.LogLevel, DEFAULT_LOG_LEVEL)
|
||||
}
|
||||
if len(cfg.Interfaces) != 0 {
|
||||
t.Errorf("NewReticulumConfig() Interfaces length = %d; want 0", len(cfg.Interfaces))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if !cfg.EnableTransport {
|
||||
t.Errorf("DefaultConfig() EnableTransport = %v; want true", cfg.EnableTransport)
|
||||
}
|
||||
if cfg.ShareInstance {
|
||||
t.Errorf("DefaultConfig() ShareInstance = %v; want false", cfg.ShareInstance)
|
||||
}
|
||||
if cfg.SharedInstancePort != DEFAULT_SHARED_INSTANCE_PORT {
|
||||
t.Errorf("DefaultConfig() SharedInstancePort = %d; want %d", cfg.SharedInstancePort, DEFAULT_SHARED_INSTANCE_PORT)
|
||||
}
|
||||
if cfg.InstanceControlPort != DEFAULT_INSTANCE_CONTROL_PORT {
|
||||
t.Errorf("DefaultConfig() InstanceControlPort = %d; want %d", cfg.InstanceControlPort, DEFAULT_INSTANCE_CONTROL_PORT)
|
||||
}
|
||||
if cfg.PanicOnInterfaceErr {
|
||||
t.Errorf("DefaultConfig() PanicOnInterfaceErr = %v; want false", cfg.PanicOnInterfaceErr)
|
||||
}
|
||||
if cfg.LogLevel != DEFAULT_LOG_LEVEL {
|
||||
t.Errorf("DefaultConfig() LogLevel = %d; want %d", cfg.LogLevel, DEFAULT_LOG_LEVEL)
|
||||
}
|
||||
if len(cfg.Interfaces) != 0 {
|
||||
t.Errorf("DefaultConfig() Interfaces length = %d; want 0", len(cfg.Interfaces))
|
||||
}
|
||||
if cfg.AppName != "Go Client" {
|
||||
t.Errorf("DefaultConfig() AppName = %q; want %q", cfg.AppName, "Go Client")
|
||||
}
|
||||
if cfg.AppAspect != "node" {
|
||||
t.Errorf("DefaultConfig() AppAspect = %q; want %q", cfg.AppAspect, "node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReticulumConfig_Validate(t *testing.T) {
|
||||
validConfig := DefaultConfig()
|
||||
if err := validConfig.Validate(); err != nil {
|
||||
t.Errorf("Validate() on default config failed: %v", err)
|
||||
}
|
||||
|
||||
invalidPortConfig1 := DefaultConfig()
|
||||
invalidPortConfig1.SharedInstancePort = 0
|
||||
if err := invalidPortConfig1.Validate(); err == nil {
|
||||
t.Errorf("Validate() did not return error for invalid SharedInstancePort 0")
|
||||
}
|
||||
|
||||
invalidPortConfig2 := DefaultConfig()
|
||||
invalidPortConfig2.SharedInstancePort = 65536
|
||||
if err := invalidPortConfig2.Validate(); err == nil {
|
||||
t.Errorf("Validate() did not return error for invalid SharedInstancePort 65536")
|
||||
}
|
||||
|
||||
invalidPortConfig3 := DefaultConfig()
|
||||
invalidPortConfig3.InstanceControlPort = 0
|
||||
if err := invalidPortConfig3.Validate(); err == nil {
|
||||
t.Errorf("Validate() did not return error for invalid InstanceControlPort 0")
|
||||
}
|
||||
|
||||
invalidPortConfig4 := DefaultConfig()
|
||||
invalidPortConfig4.InstanceControlPort = 65536
|
||||
if err := invalidPortConfig4.Validate(); err == nil {
|
||||
t.Errorf("Validate() did not return error for invalid InstanceControlPort 65536")
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,146 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package common
|
||||
|
||||
const (
|
||||
// Interface Types
|
||||
IF_TYPE_UDP InterfaceType = iota
|
||||
IF_TYPE_TCP
|
||||
IF_TYPE_UNIX
|
||||
// Interface Types
|
||||
IF_TYPE_NONE InterfaceType = iota
|
||||
IF_TYPE_UDP
|
||||
IF_TYPE_TCP
|
||||
IF_TYPE_UNIX
|
||||
IF_TYPE_I2P
|
||||
IF_TYPE_BLUETOOTH
|
||||
IF_TYPE_SERIAL
|
||||
IF_TYPE_AUTO
|
||||
|
||||
// Interface Modes
|
||||
IF_MODE_FULL InterfaceMode = iota
|
||||
IF_MODE_POINT
|
||||
IF_MODE_GATEWAY
|
||||
// Interface Modes
|
||||
IF_MODE_FULL InterfaceMode = iota
|
||||
IF_MODE_POINT
|
||||
IF_MODE_GATEWAY
|
||||
IF_MODE_ACCESS_POINT
|
||||
IF_MODE_ROAMING
|
||||
IF_MODE_BOUNDARY
|
||||
|
||||
// Transport Modes
|
||||
TRANSPORT_MODE_DIRECT TransportMode = iota
|
||||
TRANSPORT_MODE_RELAY
|
||||
TRANSPORT_MODE_GATEWAY
|
||||
// Transport Modes
|
||||
TRANSPORT_MODE_DIRECT TransportMode = iota
|
||||
TRANSPORT_MODE_RELAY
|
||||
TRANSPORT_MODE_GATEWAY
|
||||
|
||||
// Path Status
|
||||
PATH_STATUS_UNKNOWN PathStatus = iota
|
||||
PATH_STATUS_DIRECT
|
||||
PATH_STATUS_RELAY
|
||||
PATH_STATUS_FAILED
|
||||
// Path Status
|
||||
PATH_STATUS_UNKNOWN PathStatus = iota
|
||||
PATH_STATUS_DIRECT
|
||||
PATH_STATUS_RELAY
|
||||
PATH_STATUS_FAILED
|
||||
|
||||
// Common Constants
|
||||
DEFAULT_MTU = 1500
|
||||
MAX_PACKET_SIZE = 65535
|
||||
)
|
||||
// Resource Status
|
||||
RESOURCE_STATUS_PENDING = 0x00
|
||||
RESOURCE_STATUS_ACTIVE = 0x01
|
||||
RESOURCE_STATUS_COMPLETE = 0x02
|
||||
RESOURCE_STATUS_FAILED = 0x03
|
||||
RESOURCE_STATUS_CANCELLED = 0x04
|
||||
|
||||
// Link Status
|
||||
LINK_STATUS_PENDING = 0x00
|
||||
LINK_STATUS_ACTIVE = 0x01
|
||||
LINK_STATUS_CLOSED = 0x02
|
||||
LINK_STATUS_FAILED = 0x03
|
||||
|
||||
// Direction Constants
|
||||
IN = 0x01
|
||||
OUT = 0x02
|
||||
|
||||
// Common Constants
|
||||
DEFAULT_MTU = 1500
|
||||
MAX_PACKET_SIZE = 65535
|
||||
BITRATE_MINIMUM = 5
|
||||
|
||||
// Timeouts and Intervals
|
||||
ESTABLISH_TIMEOUT = 6
|
||||
KEEPALIVE_INTERVAL = 360
|
||||
STALE_TIME = 720
|
||||
PATH_REQUEST_TTL = 300
|
||||
ANNOUNCE_TIMEOUT = 15
|
||||
|
||||
// Common Numeric Constants
|
||||
ZERO = 0
|
||||
ONE = 1
|
||||
TWO = 2
|
||||
THREE = 3
|
||||
FOUR = 4
|
||||
FIVE = 5
|
||||
SIX = 6
|
||||
SEVEN = 7
|
||||
EIGHT = 8
|
||||
FIFTEEN = 15
|
||||
|
||||
// Common Size Constants
|
||||
SIZE_16 = 16
|
||||
SIZE_32 = 32
|
||||
SIZE_48 = 48
|
||||
SIZE_64 = 64
|
||||
SIXTY_SEVEN = 67
|
||||
TOKEN_OVERHEAD = 48
|
||||
|
||||
// Common Hex Constants
|
||||
HEX_0x00 = 0x00
|
||||
HEX_0x01 = 0x01
|
||||
HEX_0x02 = 0x02
|
||||
HEX_0x03 = 0x03
|
||||
HEX_0x04 = 0x04
|
||||
HEX_0x92 = 0x92
|
||||
HEX_0x93 = 0x93
|
||||
HEX_0xC2 = 0xC2
|
||||
HEX_0xC3 = 0xC3
|
||||
HEX_0xC4 = 0xC4
|
||||
HEX_0xD1 = 0xD1
|
||||
HEX_0xD2 = 0xD2
|
||||
HEX_0xFE = 0xFE
|
||||
HEX_0xFF = 0xFF
|
||||
|
||||
// Common Numeric Constants
|
||||
NUM_11 = 11
|
||||
NUM_100 = 100
|
||||
NUM_500 = 500
|
||||
NUM_1024 = 1024
|
||||
NUM_1064 = 1064
|
||||
NUM_4242 = 4242
|
||||
NUM_0700 = 0700
|
||||
|
||||
// Common Float Constants
|
||||
FLOAT_ZERO = 0.0
|
||||
FLOAT_0_001 = 0.001
|
||||
FLOAT_0_025 = 0.025
|
||||
FLOAT_0_1 = 0.1
|
||||
FLOAT_1_0 = 1.0
|
||||
FLOAT_1_75 = 1.75
|
||||
FLOAT_5_0 = 5.0
|
||||
FLOAT_1E9 = 1e9
|
||||
|
||||
// Common String Constants
|
||||
STR_LINK_ID = "link_id"
|
||||
STR_BYTES = "bytes"
|
||||
STR_FMT_HEX = "0x%02x"
|
||||
STR_FMT_HEX_LOW = "%x"
|
||||
STR_FMT_DEC = "%d"
|
||||
STR_TEST = "test"
|
||||
STR_LINK = "link"
|
||||
STR_ERROR = "error"
|
||||
STR_HASH = "hash"
|
||||
STR_NAME = "name"
|
||||
STR_TYPE = "type"
|
||||
STR_STORAGE = "storage"
|
||||
STR_PATH = "path"
|
||||
STR_COUNT = "count"
|
||||
STR_HOME = "HOME"
|
||||
STR_PUBLIC_KEY = "public_key"
|
||||
STR_TCP_CLIENT = "TCPClientInterface"
|
||||
STR_UDP = "udp"
|
||||
STR_UDP6 = "udp6"
|
||||
STR_TCP = "tcp"
|
||||
STR_ETH0 = "eth0"
|
||||
STR_INTERFACE = "interface"
|
||||
STR_PEER = "peer"
|
||||
STR_ADDR = "addr"
|
||||
STR_LINK_NOT_ACTIVE = "link not active"
|
||||
STR_INTERFACE_OFFLINE = "interface offline or detached"
|
||||
)
|
||||
|
||||
@@ -1,57 +1,211 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package common
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NetworkInterface combines both low-level and high-level interface requirements
|
||||
// NetworkInterface defines the interface for all network communication methods
|
||||
type NetworkInterface interface {
|
||||
// Low-level network operations
|
||||
Start() error
|
||||
Stop() error
|
||||
Send(data []byte, address string) error
|
||||
Receive() ([]byte, string, error)
|
||||
GetType() InterfaceType
|
||||
GetMode() InterfaceMode
|
||||
GetMTU() int
|
||||
|
||||
// High-level packet operations
|
||||
ProcessIncoming([]byte)
|
||||
ProcessOutgoing([]byte) error
|
||||
SendPathRequest([]byte) error
|
||||
SendLinkPacket([]byte, []byte, time.Time) error
|
||||
Detach()
|
||||
SetPacketCallback(PacketCallback)
|
||||
|
||||
// Additional required fields
|
||||
GetName() string
|
||||
GetConn() net.Conn
|
||||
IsEnabled() bool
|
||||
// Core interface operations
|
||||
Start() error
|
||||
Stop() error
|
||||
Enable()
|
||||
Disable()
|
||||
Detach()
|
||||
|
||||
// Network operations
|
||||
Send(data []byte, address string) error
|
||||
GetConn() net.Conn
|
||||
GetMTU() int
|
||||
GetName() string
|
||||
|
||||
// Interface properties
|
||||
GetType() InterfaceType
|
||||
GetMode() InterfaceMode
|
||||
IsEnabled() bool
|
||||
IsOnline() bool
|
||||
IsDetached() bool
|
||||
GetBandwidthAvailable() bool
|
||||
|
||||
// Packet handling
|
||||
ProcessIncoming([]byte)
|
||||
ProcessOutgoing([]byte) error
|
||||
SendPathRequest([]byte) error
|
||||
SendLinkPacket([]byte, []byte, time.Time) error
|
||||
SetPacketCallback(PacketCallback)
|
||||
GetPacketCallback() PacketCallback
|
||||
}
|
||||
|
||||
type PacketCallback func([]byte, interface{})
|
||||
|
||||
// BaseInterface provides common implementation
|
||||
// BaseInterface provides common implementation for network interfaces
|
||||
type BaseInterface struct {
|
||||
Name string
|
||||
Mode InterfaceMode
|
||||
Type InterfaceType
|
||||
|
||||
Online bool
|
||||
Detached bool
|
||||
|
||||
IN bool
|
||||
OUT bool
|
||||
|
||||
MTU int
|
||||
Bitrate int64
|
||||
|
||||
TxBytes uint64
|
||||
RxBytes uint64
|
||||
|
||||
mutex sync.RWMutex
|
||||
owner interface{}
|
||||
packetCallback PacketCallback
|
||||
}
|
||||
Name string
|
||||
Mode InterfaceMode
|
||||
Type InterfaceType
|
||||
Online bool
|
||||
Enabled bool
|
||||
Detached bool
|
||||
|
||||
IN bool
|
||||
OUT bool
|
||||
|
||||
MTU int
|
||||
Bitrate int64
|
||||
|
||||
TxBytes uint64
|
||||
RxBytes uint64
|
||||
lastTx time.Time
|
||||
|
||||
Mutex sync.RWMutex
|
||||
Owner interface{}
|
||||
PacketCallback PacketCallback
|
||||
}
|
||||
|
||||
// NewBaseInterface creates a new BaseInterface instance
|
||||
func NewBaseInterface(name string, ifaceType InterfaceType, enabled bool) BaseInterface {
|
||||
return BaseInterface{
|
||||
Name: name,
|
||||
Type: ifaceType,
|
||||
Mode: IF_MODE_FULL,
|
||||
Enabled: enabled,
|
||||
MTU: DEFAULT_MTU,
|
||||
Bitrate: BITRATE_MINIMUM,
|
||||
lastTx: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Default implementations for BaseInterface
|
||||
func (i *BaseInterface) GetType() InterfaceType {
|
||||
return i.Type
|
||||
}
|
||||
|
||||
func (i *BaseInterface) GetMode() InterfaceMode {
|
||||
return i.Mode
|
||||
}
|
||||
|
||||
func (i *BaseInterface) GetMTU() int {
|
||||
return i.MTU
|
||||
}
|
||||
|
||||
func (i *BaseInterface) GetName() string {
|
||||
return i.Name
|
||||
}
|
||||
|
||||
func (i *BaseInterface) IsEnabled() bool {
|
||||
i.Mutex.RLock()
|
||||
defer i.Mutex.RUnlock()
|
||||
return i.Enabled && i.Online && !i.Detached
|
||||
}
|
||||
|
||||
func (i *BaseInterface) IsOnline() bool {
|
||||
i.Mutex.RLock()
|
||||
defer i.Mutex.RUnlock()
|
||||
return i.Online
|
||||
}
|
||||
|
||||
func (i *BaseInterface) IsDetached() bool {
|
||||
i.Mutex.RLock()
|
||||
defer i.Mutex.RUnlock()
|
||||
return i.Detached
|
||||
}
|
||||
|
||||
func (i *BaseInterface) SetPacketCallback(callback PacketCallback) {
|
||||
i.Mutex.Lock()
|
||||
defer i.Mutex.Unlock()
|
||||
i.PacketCallback = callback
|
||||
}
|
||||
|
||||
func (i *BaseInterface) GetPacketCallback() PacketCallback {
|
||||
i.Mutex.RLock()
|
||||
defer i.Mutex.RUnlock()
|
||||
return i.PacketCallback
|
||||
}
|
||||
|
||||
func (i *BaseInterface) Detach() {
|
||||
i.Mutex.Lock()
|
||||
defer i.Mutex.Unlock()
|
||||
i.Detached = true
|
||||
i.Online = false
|
||||
}
|
||||
|
||||
func (i *BaseInterface) Enable() {
|
||||
i.Mutex.Lock()
|
||||
defer i.Mutex.Unlock()
|
||||
i.Enabled = true
|
||||
i.Online = true
|
||||
}
|
||||
|
||||
func (i *BaseInterface) Disable() {
|
||||
i.Mutex.Lock()
|
||||
defer i.Mutex.Unlock()
|
||||
i.Enabled = false
|
||||
i.Online = false
|
||||
}
|
||||
|
||||
// Default implementations that should be overridden by specific interfaces
|
||||
func (i *BaseInterface) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *BaseInterface) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *BaseInterface) GetConn() net.Conn {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *BaseInterface) Send(data []byte, address string) error {
|
||||
return i.ProcessOutgoing(data)
|
||||
}
|
||||
|
||||
func (i *BaseInterface) ProcessIncoming(data []byte) {
|
||||
if i.PacketCallback != nil {
|
||||
i.PacketCallback(data, i)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *BaseInterface) ProcessOutgoing(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *BaseInterface) SendPathRequest(data []byte) error {
|
||||
return i.Send(data, "")
|
||||
}
|
||||
|
||||
func (i *BaseInterface) SendLinkPacket(dest []byte, data []byte, timestamp time.Time) error {
|
||||
// Create link packet
|
||||
packet := make([]byte, 0, len(dest)+len(data)+9) // 1 byte type + dest + 8 byte timestamp
|
||||
packet = append(packet, 0x02) // Link packet type
|
||||
packet = append(packet, dest...)
|
||||
|
||||
ts := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(ts, uint64(timestamp.Unix())) // #nosec G115
|
||||
packet = append(packet, ts...)
|
||||
|
||||
packet = append(packet, data...)
|
||||
|
||||
return i.Send(packet, "")
|
||||
}
|
||||
|
||||
func (i *BaseInterface) GetBandwidthAvailable() bool {
|
||||
i.Mutex.RLock()
|
||||
defer i.Mutex.RUnlock()
|
||||
|
||||
// If no transmission in last second, bandwidth is available
|
||||
if time.Since(i.lastTx) > time.Second {
|
||||
return true
|
||||
}
|
||||
|
||||
// Calculate current bandwidth usage
|
||||
bytesPerSec := float64(i.TxBytes) / time.Since(i.lastTx).Seconds()
|
||||
currentUsage := bytesPerSec * 8 // Convert to bits/sec
|
||||
|
||||
// Check if usage is below threshold (2% of total bitrate)
|
||||
maxUsage := float64(i.Bitrate) * 0.02 // 2% propagation rate
|
||||
return currentUsage < maxUsage
|
||||
}
|
||||
|
||||
288
pkg/common/interfaces_test.go
Normal file
288
pkg/common/interfaces_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewBaseInterface(t *testing.T) {
|
||||
iface := NewBaseInterface("test0", IF_TYPE_UDP, true)
|
||||
|
||||
if iface.Name != "test0" {
|
||||
t.Errorf("Name = %q, want %q", iface.Name, "test0")
|
||||
}
|
||||
if iface.Type != IF_TYPE_UDP {
|
||||
t.Errorf("Type = %v, want %v", iface.Type, IF_TYPE_UDP)
|
||||
}
|
||||
if iface.Mode != IF_MODE_FULL {
|
||||
t.Errorf("Mode = %v, want %v", iface.Mode, IF_MODE_FULL)
|
||||
}
|
||||
if !iface.Enabled {
|
||||
t.Errorf("Enabled = %v, want true", iface.Enabled)
|
||||
}
|
||||
if iface.MTU != DEFAULT_MTU {
|
||||
t.Errorf("MTU = %d, want %d", iface.MTU, DEFAULT_MTU)
|
||||
}
|
||||
if iface.Bitrate != BITRATE_MINIMUM {
|
||||
t.Errorf("Bitrate = %d, want %d", iface.Bitrate, BITRATE_MINIMUM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_GetType(t *testing.T) {
|
||||
iface := NewBaseInterface("test1", IF_TYPE_TCP, true)
|
||||
if iface.GetType() != IF_TYPE_TCP {
|
||||
t.Errorf("GetType() = %v, want %v", iface.GetType(), IF_TYPE_TCP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_GetMode(t *testing.T) {
|
||||
iface := NewBaseInterface("test2", IF_TYPE_UDP, true)
|
||||
if iface.GetMode() != IF_MODE_FULL {
|
||||
t.Errorf("GetMode() = %v, want %v", iface.GetMode(), IF_MODE_FULL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_GetMTU(t *testing.T) {
|
||||
iface := NewBaseInterface("test3", IF_TYPE_UDP, true)
|
||||
if iface.GetMTU() != DEFAULT_MTU {
|
||||
t.Errorf("GetMTU() = %d, want %d", iface.GetMTU(), DEFAULT_MTU)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_GetName(t *testing.T) {
|
||||
iface := NewBaseInterface("test4", IF_TYPE_UDP, true)
|
||||
if iface.GetName() != "test4" {
|
||||
t.Errorf("GetName() = %q, want %q", iface.GetName(), "test4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_IsEnabled(t *testing.T) {
|
||||
iface := NewBaseInterface("test5", IF_TYPE_UDP, true)
|
||||
iface.Online = true
|
||||
iface.Detached = false
|
||||
|
||||
if !iface.IsEnabled() {
|
||||
t.Error("IsEnabled() = false, want true")
|
||||
}
|
||||
|
||||
iface.Enabled = false
|
||||
if iface.IsEnabled() {
|
||||
t.Error("IsEnabled() = true, want false when disabled")
|
||||
}
|
||||
|
||||
iface.Enabled = true
|
||||
iface.Online = false
|
||||
if iface.IsEnabled() {
|
||||
t.Error("IsEnabled() = true, want false when offline")
|
||||
}
|
||||
|
||||
iface.Online = true
|
||||
iface.Detached = true
|
||||
if iface.IsEnabled() {
|
||||
t.Error("IsEnabled() = true, want false when detached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_IsOnline(t *testing.T) {
|
||||
iface := NewBaseInterface("test6", IF_TYPE_UDP, true)
|
||||
iface.Online = true
|
||||
|
||||
if !iface.IsOnline() {
|
||||
t.Error("IsOnline() = false, want true")
|
||||
}
|
||||
|
||||
iface.Online = false
|
||||
if iface.IsOnline() {
|
||||
t.Error("IsOnline() = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_IsDetached(t *testing.T) {
|
||||
iface := NewBaseInterface("test7", IF_TYPE_UDP, true)
|
||||
iface.Detached = true
|
||||
|
||||
if !iface.IsDetached() {
|
||||
t.Error("IsDetached() = false, want true")
|
||||
}
|
||||
|
||||
iface.Detached = false
|
||||
if iface.IsDetached() {
|
||||
t.Error("IsDetached() = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_SetPacketCallback(t *testing.T) {
|
||||
iface := NewBaseInterface("test8", IF_TYPE_UDP, true)
|
||||
|
||||
callback := func(data []byte, ni NetworkInterface) {}
|
||||
iface.SetPacketCallback(callback)
|
||||
|
||||
if iface.GetPacketCallback() == nil {
|
||||
t.Error("GetPacketCallback() = nil, want callback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_GetPacketCallback(t *testing.T) {
|
||||
iface := NewBaseInterface("test9", IF_TYPE_UDP, true)
|
||||
|
||||
if iface.GetPacketCallback() != nil {
|
||||
t.Error("GetPacketCallback() != nil, want nil")
|
||||
}
|
||||
|
||||
callback := func(data []byte, ni NetworkInterface) {}
|
||||
iface.SetPacketCallback(callback)
|
||||
|
||||
if iface.GetPacketCallback() == nil {
|
||||
t.Error("GetPacketCallback() = nil, want callback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_Detach(t *testing.T) {
|
||||
iface := NewBaseInterface("test10", IF_TYPE_UDP, true)
|
||||
iface.Online = true
|
||||
iface.Detached = false
|
||||
|
||||
iface.Detach()
|
||||
|
||||
if !iface.IsDetached() {
|
||||
t.Error("IsDetached() = false, want true after Detach()")
|
||||
}
|
||||
if iface.IsOnline() {
|
||||
t.Error("IsOnline() = true, want false after Detach()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_Enable(t *testing.T) {
|
||||
iface := NewBaseInterface("test11", IF_TYPE_UDP, false)
|
||||
iface.Online = false
|
||||
|
||||
iface.Enable()
|
||||
|
||||
if !iface.Enabled {
|
||||
t.Error("Enabled = false, want true after Enable()")
|
||||
}
|
||||
if !iface.IsOnline() {
|
||||
t.Error("IsOnline() = false, want true after Enable()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_Disable(t *testing.T) {
|
||||
iface := NewBaseInterface("test12", IF_TYPE_UDP, true)
|
||||
iface.Online = true
|
||||
|
||||
iface.Disable()
|
||||
|
||||
if iface.Enabled {
|
||||
t.Error("Enabled = true, want false after Disable()")
|
||||
}
|
||||
if iface.IsOnline() {
|
||||
t.Error("IsOnline() = true, want false after Disable()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_Start(t *testing.T) {
|
||||
iface := NewBaseInterface("test13", IF_TYPE_UDP, true)
|
||||
if err := iface.Start(); err != nil {
|
||||
t.Errorf("Start() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_Stop(t *testing.T) {
|
||||
iface := NewBaseInterface("test14", IF_TYPE_UDP, true)
|
||||
if err := iface.Stop(); err != nil {
|
||||
t.Errorf("Stop() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_GetConn(t *testing.T) {
|
||||
iface := NewBaseInterface("test15", IF_TYPE_UDP, true)
|
||||
if iface.GetConn() != nil {
|
||||
t.Error("GetConn() != nil, want nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_Send(t *testing.T) {
|
||||
iface := NewBaseInterface("test16", IF_TYPE_UDP, true)
|
||||
data := []byte("test data")
|
||||
|
||||
if err := iface.Send(data, ""); err != nil {
|
||||
t.Errorf("Send() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_ProcessIncoming(t *testing.T) {
|
||||
iface := NewBaseInterface("test17", IF_TYPE_UDP, true)
|
||||
|
||||
called := false
|
||||
callback := func(data []byte, ni NetworkInterface) {
|
||||
called = true
|
||||
}
|
||||
iface.SetPacketCallback(callback)
|
||||
|
||||
data := []byte("test")
|
||||
iface.ProcessIncoming(data)
|
||||
|
||||
if !called {
|
||||
t.Error("ProcessIncoming() did not call callback")
|
||||
}
|
||||
|
||||
iface.SetPacketCallback(nil)
|
||||
iface.ProcessIncoming(data)
|
||||
}
|
||||
|
||||
func TestBaseInterface_ProcessOutgoing(t *testing.T) {
|
||||
iface := NewBaseInterface("test18", IF_TYPE_UDP, true)
|
||||
data := []byte("test data")
|
||||
|
||||
if err := iface.ProcessOutgoing(data); err != nil {
|
||||
t.Errorf("ProcessOutgoing() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_SendPathRequest(t *testing.T) {
|
||||
iface := NewBaseInterface("test19", IF_TYPE_UDP, true)
|
||||
data := []byte("path request")
|
||||
|
||||
if err := iface.SendPathRequest(data); err != nil {
|
||||
t.Errorf("SendPathRequest() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_SendLinkPacket(t *testing.T) {
|
||||
iface := NewBaseInterface("test20", IF_TYPE_UDP, true)
|
||||
dest := []byte("destination")
|
||||
data := []byte("link data")
|
||||
timestamp := time.Now()
|
||||
|
||||
if err := iface.SendLinkPacket(dest, data, timestamp); err != nil {
|
||||
t.Errorf("SendLinkPacket() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterface_GetBandwidthAvailable(t *testing.T) {
|
||||
iface := NewBaseInterface("test21", IF_TYPE_UDP, true)
|
||||
|
||||
if !iface.GetBandwidthAvailable() {
|
||||
t.Error("GetBandwidthAvailable() = false, want true when no recent transmission")
|
||||
}
|
||||
|
||||
iface.lastTx = time.Now()
|
||||
iface.TxBytes = 0
|
||||
if !iface.GetBandwidthAvailable() {
|
||||
t.Error("GetBandwidthAvailable() = false, want true when TxBytes is 0")
|
||||
}
|
||||
|
||||
iface.lastTx = time.Now().Add(-500 * time.Millisecond)
|
||||
iface.TxBytes = 1000
|
||||
iface.Bitrate = 1000000
|
||||
|
||||
if !iface.GetBandwidthAvailable() {
|
||||
t.Error("GetBandwidthAvailable() = false, want true when usage is below threshold")
|
||||
}
|
||||
|
||||
iface.TxBytes = 10000000
|
||||
iface.Bitrate = 1000
|
||||
if iface.GetBandwidthAvailable() {
|
||||
t.Error("GetBandwidthAvailable() = true, want false when usage exceeds threshold")
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,84 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package common
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Interface related types
|
||||
type InterfaceMode byte
|
||||
type InterfaceType byte
|
||||
// Destination type constants
|
||||
const (
|
||||
DESTINATION_SINGLE = 0x00
|
||||
DESTINATION_GROUP = 0x01
|
||||
DESTINATION_PLAIN = 0x02
|
||||
)
|
||||
|
||||
// Transport related types
|
||||
type TransportMode byte
|
||||
type PathStatus byte
|
||||
|
||||
// Common structs
|
||||
// Path represents routing information for a destination
|
||||
type Path struct {
|
||||
Interface NetworkInterface
|
||||
Address string
|
||||
Status PathStatus
|
||||
LastSeen time.Time
|
||||
NextHop []byte
|
||||
Hops uint8
|
||||
LastUpdated time.Time
|
||||
Interface NetworkInterface
|
||||
LastSeen time.Time
|
||||
NextHop []byte
|
||||
Hops uint8
|
||||
LastUpdated time.Time
|
||||
HopCount uint8
|
||||
}
|
||||
|
||||
// Common callbacks
|
||||
type ProofRequestedCallback func(interface{}) bool
|
||||
type ProofRequestedCallback func([]byte, []byte)
|
||||
type LinkEstablishedCallback func(interface{})
|
||||
type PacketCallback func([]byte, NetworkInterface)
|
||||
|
||||
// Request handler
|
||||
// RequestHandler manages path requests and responses
|
||||
type RequestHandler struct {
|
||||
Path string
|
||||
ResponseGenerator func(path string, data []byte, requestID []byte, linkID []byte, remoteIdentity interface{}, requestedAt int64) []byte
|
||||
AllowMode byte
|
||||
AllowedList [][]byte
|
||||
}
|
||||
}
|
||||
|
||||
// Interface types
|
||||
type InterfaceMode byte
|
||||
type InterfaceType byte
|
||||
|
||||
// RatchetIDReceiver holds ratchet ID information
|
||||
type RatchetIDReceiver struct {
|
||||
LatestRatchetID []byte
|
||||
}
|
||||
|
||||
// NetworkStats holds interface statistics
|
||||
type NetworkStats struct {
|
||||
BytesSent uint64
|
||||
BytesReceived uint64
|
||||
PacketsSent uint64
|
||||
PacketsReceived uint64
|
||||
LastUpdated time.Time
|
||||
}
|
||||
|
||||
// LinkStatus represents the current state of a link
|
||||
type LinkStatus struct {
|
||||
Established bool
|
||||
LastSeen time.Time
|
||||
RTT time.Duration
|
||||
Quality float64
|
||||
Hops uint8
|
||||
}
|
||||
|
||||
// PathRequest represents a path discovery request
|
||||
type PathRequest struct {
|
||||
DestinationHash []byte
|
||||
Tag []byte
|
||||
TTL int
|
||||
Recursive bool
|
||||
}
|
||||
|
||||
// PathResponse represents a path discovery response
|
||||
type PathResponse struct {
|
||||
DestinationHash []byte
|
||||
NextHop []byte
|
||||
Hops uint8
|
||||
Tag []byte
|
||||
}
|
||||
|
||||
@@ -1,49 +1,272 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package config
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"gopkg.in/yaml.v3"
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Identity struct {
|
||||
Name string `yaml:"name"`
|
||||
StoragePath string `yaml:"storage_path"`
|
||||
} `yaml:"identity"`
|
||||
Identity struct {
|
||||
Name string
|
||||
StoragePath string
|
||||
}
|
||||
|
||||
Interfaces []struct {
|
||||
Name string `yaml:"name"`
|
||||
Type string `yaml:"type"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
ListenPort int `yaml:"listen_port"`
|
||||
ListenIP string `yaml:"listen_ip"`
|
||||
KissFraming bool `yaml:"kiss_framing"`
|
||||
I2PTunneled bool `yaml:"i2p_tunneled"`
|
||||
} `yaml:"interfaces"`
|
||||
Interfaces []struct {
|
||||
Name string
|
||||
Type string
|
||||
Enabled bool
|
||||
ListenPort int
|
||||
ListenIP string
|
||||
KissFraming bool
|
||||
I2PTunneled bool
|
||||
}
|
||||
|
||||
Transport struct {
|
||||
AnnounceInterval int `yaml:"announce_interval"`
|
||||
PathRequestTimeout int `yaml:"path_request_timeout"`
|
||||
MaxHops int `yaml:"max_hops"`
|
||||
BitrateLimit int64 `yaml:"bitrate_limit"`
|
||||
} `yaml:"transport"`
|
||||
Transport struct {
|
||||
AnnounceInterval int
|
||||
PathRequestTimeout int
|
||||
MaxHops int
|
||||
BitrateLimit int64
|
||||
}
|
||||
|
||||
Logging struct {
|
||||
Level string `yaml:"level"`
|
||||
File string `yaml:"file"`
|
||||
} `yaml:"logging"`
|
||||
Logging struct {
|
||||
Level string
|
||||
File string
|
||||
}
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
data, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// bearer:disable go_gosec_filesystem_filereadtaint
|
||||
file, err := os.Open(path) // #nosec G304
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &Config{}
|
||||
scanner := bufio.NewScanner(file)
|
||||
var currentSection string
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Skip comments and empty lines
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle sections
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
currentSection = strings.Trim(line, "[]")
|
||||
|
||||
// If this is an interface section, append new interface
|
||||
if strings.HasPrefix(currentSection, "interface ") {
|
||||
cfg.Interfaces = append(cfg.Interfaces, struct {
|
||||
Name string
|
||||
Type string
|
||||
Enabled bool
|
||||
ListenPort int
|
||||
ListenIP string
|
||||
KissFraming bool
|
||||
I2PTunneled bool
|
||||
}{})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse key-value pairs
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
switch currentSection {
|
||||
case "identity":
|
||||
switch key {
|
||||
case "name":
|
||||
cfg.Identity.Name = value
|
||||
case "storage_path":
|
||||
cfg.Identity.StoragePath = value
|
||||
}
|
||||
|
||||
case "transport":
|
||||
switch key {
|
||||
case "announce_interval":
|
||||
cfg.Transport.AnnounceInterval, _ = strconv.Atoi(value)
|
||||
case "path_request_timeout":
|
||||
cfg.Transport.PathRequestTimeout, _ = strconv.Atoi(value)
|
||||
case "max_hops":
|
||||
cfg.Transport.MaxHops, _ = strconv.Atoi(value)
|
||||
case "bitrate_limit":
|
||||
cfg.Transport.BitrateLimit, _ = strconv.ParseInt(value, 10, 64)
|
||||
}
|
||||
|
||||
case "logging":
|
||||
switch key {
|
||||
case "level":
|
||||
cfg.Logging.Level = value
|
||||
case "file":
|
||||
cfg.Logging.File = value
|
||||
}
|
||||
|
||||
default:
|
||||
// Handle interface sections
|
||||
if strings.HasPrefix(currentSection, "interface ") && len(cfg.Interfaces) > 0 {
|
||||
iface := &cfg.Interfaces[len(cfg.Interfaces)-1]
|
||||
switch key {
|
||||
case "name":
|
||||
iface.Name = value
|
||||
case "type":
|
||||
iface.Type = value
|
||||
case "enabled":
|
||||
iface.Enabled = value == "true"
|
||||
case "listen_port":
|
||||
iface.ListenPort, _ = strconv.Atoi(value)
|
||||
case "listen_ip":
|
||||
iface.ListenIP = value
|
||||
case "kiss_framing":
|
||||
iface.KissFraming = value == "true"
|
||||
case "i2p_tunneled":
|
||||
iface.I2PTunneled = value == "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func SaveConfig(cfg *Config, path string) error {
|
||||
var builder strings.Builder
|
||||
|
||||
// Write Identity section
|
||||
builder.WriteString("[identity]\n")
|
||||
builder.WriteString(fmt.Sprintf("name = %s\n", cfg.Identity.Name))
|
||||
builder.WriteString(fmt.Sprintf("storage_path = %s\n\n", cfg.Identity.StoragePath))
|
||||
|
||||
// Write Transport section
|
||||
builder.WriteString("[transport]\n")
|
||||
builder.WriteString(fmt.Sprintf("announce_interval = %d\n", cfg.Transport.AnnounceInterval))
|
||||
builder.WriteString(fmt.Sprintf("path_request_timeout = %d\n", cfg.Transport.PathRequestTimeout))
|
||||
builder.WriteString(fmt.Sprintf("max_hops = %d\n", cfg.Transport.MaxHops))
|
||||
builder.WriteString(fmt.Sprintf("bitrate_limit = %d\n\n", cfg.Transport.BitrateLimit))
|
||||
|
||||
// Write Logging section
|
||||
builder.WriteString("[logging]\n")
|
||||
builder.WriteString(fmt.Sprintf("level = %s\n", cfg.Logging.Level))
|
||||
builder.WriteString(fmt.Sprintf("file = %s\n\n", cfg.Logging.File))
|
||||
|
||||
// Write Interface sections
|
||||
for _, iface := range cfg.Interfaces {
|
||||
builder.WriteString(fmt.Sprintf("[interface %s]\n", iface.Name))
|
||||
builder.WriteString(fmt.Sprintf("type = %s\n", iface.Type))
|
||||
builder.WriteString(fmt.Sprintf("enabled = %v\n", iface.Enabled))
|
||||
builder.WriteString(fmt.Sprintf("listen_port = %d\n", iface.ListenPort))
|
||||
builder.WriteString(fmt.Sprintf("listen_ip = %s\n", iface.ListenIP))
|
||||
builder.WriteString(fmt.Sprintf("kiss_framing = %v\n", iface.KissFraming))
|
||||
builder.WriteString(fmt.Sprintf("i2p_tunneled = %v\n\n", iface.I2PTunneled))
|
||||
}
|
||||
|
||||
return os.WriteFile(path, []byte(builder.String()), 0600) // #nosec G306
|
||||
}
|
||||
|
||||
func GetConfigDir() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
// Fallback to current directory if home directory cannot be determined
|
||||
return ".reticulum-go"
|
||||
}
|
||||
return filepath.Join(homeDir, ".reticulum-go")
|
||||
}
|
||||
|
||||
func GetDefaultConfigPath() string {
|
||||
return filepath.Join(GetConfigDir(), "config")
|
||||
}
|
||||
|
||||
func EnsureConfigDir() error {
|
||||
configDir := GetConfigDir()
|
||||
return os.MkdirAll(configDir, 0700) // #nosec G301
|
||||
}
|
||||
|
||||
func InitConfig() (*Config, error) {
|
||||
// Ensure config directory exists
|
||||
if err := EnsureConfigDir(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create config directory: %v", err)
|
||||
}
|
||||
|
||||
configPath := GetDefaultConfigPath()
|
||||
|
||||
// Check if config file exists
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
// Create default config
|
||||
cfg := &Config{}
|
||||
|
||||
// Set default values
|
||||
cfg.Identity.Name = "reticulum-node"
|
||||
cfg.Identity.StoragePath = filepath.Join(GetConfigDir(), "storage")
|
||||
|
||||
cfg.Transport.AnnounceInterval = 300
|
||||
cfg.Transport.PathRequestTimeout = 15
|
||||
cfg.Transport.MaxHops = 8
|
||||
cfg.Transport.BitrateLimit = 1000000
|
||||
|
||||
cfg.Logging.Level = "info"
|
||||
cfg.Logging.File = filepath.Join(GetConfigDir(), "reticulum.log")
|
||||
|
||||
cfg.Interfaces = append(cfg.Interfaces, struct {
|
||||
Name string
|
||||
Type string
|
||||
Enabled bool
|
||||
ListenPort int
|
||||
ListenIP string
|
||||
KissFraming bool
|
||||
I2PTunneled bool
|
||||
}{
|
||||
Name: "Local UDP",
|
||||
Type: "UDPInterface",
|
||||
Enabled: true,
|
||||
ListenPort: 37697,
|
||||
ListenIP: "0.0.0.0",
|
||||
})
|
||||
|
||||
cfg.Interfaces = append(cfg.Interfaces, struct {
|
||||
Name string
|
||||
Type string
|
||||
Enabled bool
|
||||
ListenPort int
|
||||
ListenIP string
|
||||
KissFraming bool
|
||||
I2PTunneled bool
|
||||
}{
|
||||
Name: "Auto Discovery",
|
||||
Type: "AutoInterface",
|
||||
Enabled: true,
|
||||
ListenPort: 29717,
|
||||
})
|
||||
|
||||
// Save default config
|
||||
if err := SaveConfig(cfg, configPath); err != nil {
|
||||
return nil, fmt.Errorf("failed to save default config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load config
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
192
pkg/config/config_test.go
Normal file
192
pkg/config/config_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "test_config")
|
||||
|
||||
configContent := `[identity]
|
||||
name = test-identity
|
||||
storage_path = /tmp/test-storage
|
||||
|
||||
[transport]
|
||||
announce_interval = 300
|
||||
path_request_timeout = 15
|
||||
max_hops = 8
|
||||
bitrate_limit = 1000000
|
||||
|
||||
[logging]
|
||||
level = info
|
||||
file = /tmp/test.log
|
||||
|
||||
[interface test-interface]
|
||||
type = UDPInterface
|
||||
enabled = true
|
||||
listen_ip = 127.0.0.1
|
||||
listen_port = 37696
|
||||
`
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
t.Fatal("LoadConfig() returned nil")
|
||||
}
|
||||
|
||||
if len(cfg.Interfaces) == 0 {
|
||||
t.Error("No interfaces loaded")
|
||||
}
|
||||
|
||||
iface := cfg.Interfaces[0]
|
||||
if iface.Type != "UDPInterface" {
|
||||
t.Errorf("Interface type = %s, want UDPInterface", iface.Type)
|
||||
}
|
||||
if !iface.Enabled {
|
||||
t.Error("Interface should be enabled")
|
||||
}
|
||||
if iface.ListenIP != "127.0.0.1" {
|
||||
t.Errorf("Interface ListenIP = %s, want 127.0.0.1", iface.ListenIP)
|
||||
}
|
||||
if iface.ListenPort != 37696 {
|
||||
t.Errorf("Interface ListenPort = %d, want 37696", iface.ListenPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_NonexistentFile(t *testing.T) {
|
||||
_, err := LoadConfig("/nonexistent/path/config")
|
||||
if err == nil {
|
||||
t.Error("LoadConfig() should return error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_EmptyFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "empty_config")
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(""), 0600); err != nil {
|
||||
t.Fatalf("Failed to write empty config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
t.Fatal("LoadConfig() returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_CommentsAndEmptyLines(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "test_config")
|
||||
|
||||
configContent := `# Comment line
|
||||
|
||||
[identity]
|
||||
name = test
|
||||
# Another comment
|
||||
|
||||
[interface test-interface]
|
||||
# Interface comment
|
||||
type = UDPInterface
|
||||
enabled = true
|
||||
`
|
||||
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
|
||||
t.Fatalf("Failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
t.Fatal("LoadConfig() returned nil")
|
||||
}
|
||||
|
||||
if cfg.Identity.Name != "test" {
|
||||
t.Errorf("Identity.Name = %s, want test", cfg.Identity.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "test_config")
|
||||
|
||||
cfg := &Config{}
|
||||
cfg.Identity.Name = "test-identity"
|
||||
cfg.Identity.StoragePath = "/tmp/test"
|
||||
cfg.Transport.AnnounceInterval = 600
|
||||
cfg.Logging.Level = "debug"
|
||||
cfg.Logging.File = "/tmp/test.log"
|
||||
|
||||
if err := SaveConfig(cfg, configPath); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if loaded.Identity.Name != "test-identity" {
|
||||
t.Errorf("Identity.Name = %s, want test-identity", loaded.Identity.Name)
|
||||
}
|
||||
if loaded.Transport.AnnounceInterval != 600 {
|
||||
t.Errorf("Transport.AnnounceInterval = %d, want 600", loaded.Transport.AnnounceInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigDir(t *testing.T) {
|
||||
dir := GetConfigDir()
|
||||
if dir == "" {
|
||||
t.Error("GetConfigDir() returned empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDefaultConfigPath(t *testing.T) {
|
||||
path := GetDefaultConfigPath()
|
||||
if path == "" {
|
||||
t.Error("GetDefaultConfigPath() returned empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureConfigDir(t *testing.T) {
|
||||
if err := EnsureConfigDir(); err != nil {
|
||||
t.Fatalf("EnsureConfigDir() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
originalHome := os.Getenv("HOME")
|
||||
defer func() {
|
||||
if originalHome != "" {
|
||||
os.Setenv("HOME", originalHome)
|
||||
}
|
||||
}()
|
||||
|
||||
os.Setenv("HOME", tmpDir)
|
||||
|
||||
cfg, err := InitConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("InitConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
t.Fatal("InitConfig() returned nil")
|
||||
}
|
||||
}
|
||||
112
pkg/cryptography/aes.go
Normal file
112
pkg/cryptography/aes.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package cryptography
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
// AES256KeySize is the size of an AES-256 key in bytes.
|
||||
AES256KeySize = 32 // 256 bits
|
||||
)
|
||||
|
||||
// GenerateAES256Key generates a random AES-256 key.
|
||||
func GenerateAES256Key() ([]byte, error) {
|
||||
key := make([]byte, AES256KeySize)
|
||||
if _, err := io.ReadFull(rand.Reader, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// EncryptAES256CBC encrypts data using AES-256 in CBC mode.
|
||||
// The IV is prepended to the ciphertext.
|
||||
func EncryptAES256CBC(key, plaintext []byte) ([]byte, error) {
|
||||
if len(key) != AES256KeySize {
|
||||
return nil, errors.New("invalid key size: must be 32 bytes for AES-256")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate a random IV.
|
||||
iv := make([]byte, aes.BlockSize)
|
||||
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add PKCS7 padding.
|
||||
padding := aes.BlockSize - len(plaintext)%aes.BlockSize
|
||||
padtext := make([]byte, len(plaintext)+padding)
|
||||
copy(padtext, plaintext)
|
||||
for i := len(plaintext); i < len(padtext); i++ {
|
||||
padtext[i] = byte(padding)
|
||||
}
|
||||
|
||||
// Encrypt the data.
|
||||
mode := cipher.NewCBCEncrypter(block, iv) // #nosec G407
|
||||
ciphertext := make([]byte, len(padtext))
|
||||
mode.CryptBlocks(ciphertext, padtext)
|
||||
|
||||
// Prepend the IV to the ciphertext.
|
||||
return append(iv, ciphertext...), nil
|
||||
}
|
||||
|
||||
// DecryptAES256CBC decrypts data using AES-256 in CBC mode.
|
||||
// It assumes the IV is prepended to the ciphertext.
|
||||
func DecryptAES256CBC(key, ciphertext []byte) ([]byte, error) {
|
||||
if len(key) != AES256KeySize {
|
||||
return nil, errors.New("invalid key size: must be 32 bytes for AES-256")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ciphertext) < aes.BlockSize {
|
||||
return nil, errors.New("ciphertext is too short")
|
||||
}
|
||||
|
||||
// Extract the IV from the beginning of the ciphertext.
|
||||
iv := ciphertext[:aes.BlockSize]
|
||||
ciphertext = ciphertext[aes.BlockSize:]
|
||||
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, errors.New("ciphertext is not a multiple of the block size")
|
||||
}
|
||||
|
||||
// Decrypt the data.
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
mode.CryptBlocks(plaintext, ciphertext)
|
||||
|
||||
// Remove PKCS7 padding.
|
||||
if len(plaintext) == 0 {
|
||||
return nil, errors.New("invalid padding: plaintext is empty")
|
||||
}
|
||||
|
||||
padding := int(plaintext[len(plaintext)-1])
|
||||
if padding > aes.BlockSize || padding == 0 {
|
||||
return nil, errors.New("invalid padding size")
|
||||
}
|
||||
if len(plaintext) < padding {
|
||||
return nil, errors.New("invalid padding: padding size is larger than plaintext")
|
||||
}
|
||||
|
||||
// Verify the padding bytes.
|
||||
for i := len(plaintext) - padding; i < len(plaintext); i++ {
|
||||
if plaintext[i] != byte(padding) {
|
||||
return nil, errors.New("invalid padding bytes")
|
||||
}
|
||||
}
|
||||
|
||||
return plaintext[:len(plaintext)-padding], nil
|
||||
}
|
||||
199
pkg/cryptography/aes_test.go
Normal file
199
pkg/cryptography/aes_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package cryptography
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateAES256Key(t *testing.T) {
|
||||
key, err := GenerateAES256Key()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAES256Key failed: %v", err)
|
||||
}
|
||||
if len(key) != AES256KeySize {
|
||||
t.Errorf("Expected key size %d, got %d", AES256KeySize, len(key))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAES256CBCEncryptionDecryption(t *testing.T) {
|
||||
key, err := GenerateAES256Key()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate AES-256 key: %v", err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
plaintext []byte
|
||||
}{
|
||||
{"ShortMessage", []byte("Hello")},
|
||||
{"BlockSizeMessage", []byte("This is 16 bytes")},
|
||||
{"LongMessage", []byte("This is a longer message that spans multiple AES blocks and tests the padding.")},
|
||||
{"EmptyMessage", []byte("")},
|
||||
{"SingleByte", []byte("A")},
|
||||
{"ExactlyTwoBlocks", []byte("This is exactly 32 bytes long!!!")},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ciphertext, err := EncryptAES256CBC(key, tc.plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptAES256CBC failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := DecryptAES256CBC(key, ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptAES256CBC failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(tc.plaintext, decrypted) {
|
||||
t.Errorf("Decrypted text does not match original plaintext.\nGot: %q (%x)\nWant: %q (%x)",
|
||||
decrypted, decrypted, tc.plaintext, tc.plaintext)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAES256CBC_InvalidKeySize(t *testing.T) {
|
||||
plaintext := []byte("test message")
|
||||
|
||||
invalidKeys := [][]byte{
|
||||
make([]byte, 16), // AES-128
|
||||
make([]byte, 24), // AES-192
|
||||
make([]byte, 15), // Too short
|
||||
make([]byte, 33), // Too long
|
||||
nil, // Nil key
|
||||
}
|
||||
|
||||
for i, key := range invalidKeys {
|
||||
t.Run(fmt.Sprintf("InvalidKey_%d", i), func(t *testing.T) {
|
||||
_, err := EncryptAES256CBC(key, plaintext)
|
||||
if err == nil {
|
||||
t.Error("EncryptAES256CBC should have failed with invalid key size")
|
||||
}
|
||||
|
||||
// Test with some dummy ciphertext
|
||||
dummyCiphertext := make([]byte, 32) // Just enough for IV + one block
|
||||
rand.Read(dummyCiphertext)
|
||||
_, err = DecryptAES256CBC(key, dummyCiphertext)
|
||||
if err == nil {
|
||||
t.Error("DecryptAES256CBC should have failed with invalid key size")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptAES256CBCErrorCases(t *testing.T) {
|
||||
key, err := GenerateAES256Key()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key: %v", err)
|
||||
}
|
||||
|
||||
t.Run("CiphertextTooShort", func(t *testing.T) {
|
||||
shortCiphertext := []byte{0x01, 0x02, 0x03} // Less than AES block size
|
||||
_, err := DecryptAES256CBC(key, shortCiphertext)
|
||||
if err == nil {
|
||||
t.Error("DecryptAES256CBC should have failed for ciphertext shorter than block size")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CiphertextNotMultipleOfBlockSize", func(t *testing.T) {
|
||||
iv := make([]byte, aes.BlockSize)
|
||||
rand.Read(iv)
|
||||
invalidCiphertext := append(iv, []byte{0x01, 0x02, 0x03}...) // IV + data not multiple of block size
|
||||
_, err := DecryptAES256CBC(key, invalidCiphertext)
|
||||
if err == nil {
|
||||
t.Error("DecryptAES256CBC should have failed for ciphertext not multiple of block size")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidPadding", func(t *testing.T) {
|
||||
// Create a valid ciphertext first
|
||||
plaintext := []byte("valid data")
|
||||
ciphertext, err := EncryptAES256CBC(key, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test ciphertext: %v", err)
|
||||
}
|
||||
|
||||
// Corrupt the byte that XORs with the last padding byte.
|
||||
// In CBC, P[i] = D(C[i]) ^ C[i-1].
|
||||
// The last byte of plaintext P[len-1] depends on C[len-1] and C[len-1-BlockSize].
|
||||
// If we modify C[len-1-BlockSize], we flip the bits of P[len-1] predictably.
|
||||
// If we modify C[len-1] (the last byte of ciphertext), we scramble the whole block D(C[len-1]),
|
||||
// which might accidentally result in valid padding (e.g. 0x01).
|
||||
// So we corrupt the IV (or previous block) corresponding to the last byte.
|
||||
corruptedCiphertext := make([]byte, len(ciphertext))
|
||||
copy(corruptedCiphertext, ciphertext)
|
||||
corruptedCiphertext[len(ciphertext)-aes.BlockSize-1] ^= 0xFF
|
||||
|
||||
_, err = DecryptAES256CBC(key, corruptedCiphertext)
|
||||
if err == nil {
|
||||
t.Error("DecryptAES256CBC should have failed for corrupted padding")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EmptyPlaintextAfterDecryption", func(t *testing.T) {
|
||||
// This creates a ciphertext that decrypts to just padding
|
||||
key, _ := GenerateAES256Key()
|
||||
iv := make([]byte, aes.BlockSize)
|
||||
// A block of padding bytes
|
||||
paddedBlock := bytes.Repeat([]byte{byte(aes.BlockSize)}, aes.BlockSize)
|
||||
|
||||
block, _ := aes.NewCipher(key)
|
||||
mode := cipher.NewCBCEncrypter(block, iv)
|
||||
ciphertext := make([]byte, len(paddedBlock))
|
||||
mode.CryptBlocks(ciphertext, paddedBlock)
|
||||
|
||||
// Prepend IV
|
||||
fullCiphertext := append(iv, ciphertext...)
|
||||
|
||||
// This should decrypt to an empty slice, which is valid
|
||||
decrypted, err := DecryptAES256CBC(key, fullCiphertext)
|
||||
if err != nil {
|
||||
t.Errorf("DecryptAES256CBC failed for empty plaintext case: %v", err)
|
||||
}
|
||||
if len(decrypted) != 0 {
|
||||
t.Errorf("Expected empty plaintext, got %q", decrypted)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConstants(t *testing.T) {
|
||||
if AES256KeySize != 32 {
|
||||
t.Errorf("AES256KeySize should be 32, got %d", AES256KeySize)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAES256CBC(b *testing.B) {
|
||||
key, err := GenerateAES256Key()
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to generate key: %v", err)
|
||||
}
|
||||
|
||||
data := make([]byte, 1024) // 1KB of data
|
||||
rand.Read(data)
|
||||
|
||||
b.Run("Encrypt", func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := EncryptAES256CBC(key, data)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ciphertext, _ := EncryptAES256CBC(key, data)
|
||||
b.Run("Decrypt", func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := DecryptAES256CBC(key, ciphertext)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
24
pkg/cryptography/constants.go
Normal file
24
pkg/cryptography/constants.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package cryptography
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
|
||||
const (
|
||||
SHA256Size = 32
|
||||
)
|
||||
|
||||
// GetBasepoint returns the standard Curve25519 basepoint
|
||||
func GetBasepoint() []byte {
|
||||
return curve25519.Basepoint
|
||||
}
|
||||
|
||||
func Hash(data []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
27
pkg/cryptography/curve25519.go
Normal file
27
pkg/cryptography/curve25519.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package cryptography
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
|
||||
func GenerateKeyPair() (privateKey, publicKey []byte, err error) {
|
||||
privateKey = make([]byte, curve25519.ScalarSize)
|
||||
if _, err := rand.Read(privateKey); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
publicKey, err = curve25519.X25519(privateKey, curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return privateKey, publicKey, nil
|
||||
}
|
||||
|
||||
func DeriveSharedSecret(privateKey, peerPublicKey []byte) ([]byte, error) {
|
||||
return curve25519.X25519(privateKey, peerPublicKey)
|
||||
}
|
||||
63
pkg/cryptography/curve25519_test.go
Normal file
63
pkg/cryptography/curve25519_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package cryptography
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
|
||||
func TestGenerateKeyPair(t *testing.T) {
|
||||
priv1, pub1, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
if len(priv1) != curve25519.ScalarSize {
|
||||
t.Errorf("Private key length is %d, want %d", len(priv1), curve25519.ScalarSize)
|
||||
}
|
||||
if len(pub1) != curve25519.PointSize {
|
||||
t.Errorf("Public key length is %d, want %d", len(pub1), curve25519.PointSize)
|
||||
}
|
||||
|
||||
// Generate another pair, should be different
|
||||
priv2, pub2, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("Second GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
if bytes.Equal(priv1, priv2) {
|
||||
t.Error("Generated private keys are identical")
|
||||
}
|
||||
if bytes.Equal(pub1, pub2) {
|
||||
t.Error("Generated public keys are identical")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveSharedSecret(t *testing.T) {
|
||||
privA, pubA, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair A failed: %v", err)
|
||||
}
|
||||
privB, pubB, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair B failed: %v", err)
|
||||
}
|
||||
|
||||
secretA, err := DeriveSharedSecret(privA, pubB)
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveSharedSecret (A perspective) failed: %v", err)
|
||||
}
|
||||
|
||||
secretB, err := DeriveSharedSecret(privB, pubA)
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveSharedSecret (B perspective) failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(secretA, secretB) {
|
||||
t.Errorf("Derived shared secrets do not match:\nSecret A: %x\nSecret B: %x", secretA, secretB)
|
||||
}
|
||||
|
||||
if len(secretA) != curve25519.PointSize { // Shared secret length
|
||||
t.Errorf("Shared secret length is %d, want %d", len(secretA), curve25519.PointSize)
|
||||
}
|
||||
}
|
||||
20
pkg/cryptography/ed25519.go
Normal file
20
pkg/cryptography/ed25519.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package cryptography
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
)
|
||||
|
||||
func GenerateSigningKeyPair() (ed25519.PublicKey, ed25519.PrivateKey, error) {
|
||||
return ed25519.GenerateKey(rand.Reader)
|
||||
}
|
||||
|
||||
func Sign(privateKey ed25519.PrivateKey, message []byte) []byte {
|
||||
return ed25519.Sign(privateKey, message)
|
||||
}
|
||||
|
||||
func Verify(publicKey ed25519.PublicKey, message, signature []byte) bool {
|
||||
return ed25519.Verify(publicKey, message, signature)
|
||||
}
|
||||
79
pkg/cryptography/ed25519_test.go
Normal file
79
pkg/cryptography/ed25519_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package cryptography
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateSigningKeyPair(t *testing.T) {
|
||||
pub1, priv1, err := GenerateSigningKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSigningKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
if len(pub1) != ed25519.PublicKeySize {
|
||||
t.Errorf("Public key length is %d, want %d", len(pub1), ed25519.PublicKeySize)
|
||||
}
|
||||
if len(priv1) != ed25519.PrivateKeySize {
|
||||
t.Errorf("Private key length is %d, want %d", len(priv1), ed25519.PrivateKeySize)
|
||||
}
|
||||
|
||||
// Generate another pair, should be different
|
||||
pub2, priv2, err := GenerateSigningKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("Second GenerateSigningKeyPair failed: %v", err)
|
||||
}
|
||||
if pub1.Equal(pub2) {
|
||||
t.Error("Generated public keys are identical")
|
||||
}
|
||||
if priv1.Equal(priv2) {
|
||||
t.Error("Generated private keys are identical")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignAndVerify(t *testing.T) {
|
||||
pub, priv, err := GenerateSigningKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSigningKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
message := []byte("This message needs to be signed.")
|
||||
|
||||
signature := Sign(priv, message)
|
||||
if len(signature) != ed25519.SignatureSize {
|
||||
t.Errorf("Signature length is %d, want %d", len(signature), ed25519.SignatureSize)
|
||||
}
|
||||
|
||||
// Verify correct signature
|
||||
if !Verify(pub, message, signature) {
|
||||
t.Errorf("Verify failed for a valid signature")
|
||||
}
|
||||
|
||||
// Verify with tampered message
|
||||
tamperedMessage := append(message, '!')
|
||||
if Verify(pub, tamperedMessage, signature) {
|
||||
t.Errorf("Verify succeeded for a tampered message")
|
||||
}
|
||||
|
||||
// Verify with tampered signature
|
||||
tamperedSignature := append(signature[:len(signature)-1], ^signature[len(signature)-1])
|
||||
if Verify(pub, message, tamperedSignature) {
|
||||
t.Errorf("Verify succeeded for a tampered signature")
|
||||
}
|
||||
|
||||
// Verify with wrong public key
|
||||
wrongPub, _, _ := GenerateSigningKeyPair()
|
||||
if Verify(wrongPub, message, signature) {
|
||||
t.Errorf("Verify succeeded with the wrong public key")
|
||||
}
|
||||
|
||||
// Verify empty message
|
||||
emptyMessage := []byte("")
|
||||
emptySig := Sign(priv, emptyMessage)
|
||||
if !Verify(pub, emptyMessage, emptySig) {
|
||||
t.Errorf("Verify failed for an empty message")
|
||||
}
|
||||
if Verify(pub, message, emptySig) {
|
||||
t.Errorf("Verify succeeded comparing non-empty message with empty signature")
|
||||
}
|
||||
}
|
||||
50
pkg/cryptography/hkdf.go
Normal file
50
pkg/cryptography/hkdf.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package cryptography
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"math"
|
||||
)
|
||||
|
||||
func DeriveKey(secret, salt, info []byte, length int) ([]byte, error) {
|
||||
hashLen := 32
|
||||
|
||||
if length < 1 {
|
||||
return nil, errors.New("invalid output key length")
|
||||
}
|
||||
|
||||
if len(secret) == 0 {
|
||||
return nil, errors.New("cannot derive key from empty input material")
|
||||
}
|
||||
|
||||
if len(salt) == 0 {
|
||||
salt = make([]byte, hashLen)
|
||||
}
|
||||
|
||||
if info == nil {
|
||||
info = []byte{}
|
||||
}
|
||||
|
||||
pseudorandomKey := hmac.New(sha256.New, salt)
|
||||
pseudorandomKey.Write(secret)
|
||||
prk := pseudorandomKey.Sum(nil)
|
||||
|
||||
block := []byte{}
|
||||
derived := []byte{}
|
||||
|
||||
iterations := int(math.Ceil(float64(length) / float64(hashLen)))
|
||||
for i := 0; i < iterations; i++ {
|
||||
h := hmac.New(sha256.New, prk)
|
||||
h.Write(block)
|
||||
h.Write(info)
|
||||
counter := byte((i + 1) % (0xFF + 1))
|
||||
h.Write([]byte{counter})
|
||||
block = h.Sum(nil)
|
||||
derived = append(derived, block...)
|
||||
}
|
||||
|
||||
return derived[:length], nil
|
||||
}
|
||||
105
pkg/cryptography/hkdf_test.go
Normal file
105
pkg/cryptography/hkdf_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package cryptography
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveKey(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
salt := []byte("test-salt")
|
||||
info := []byte("test-info")
|
||||
length := 32 // Desired key length
|
||||
|
||||
key1, err := DeriveKey(secret, salt, info, length)
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKey failed: %v", err)
|
||||
}
|
||||
|
||||
if len(key1) != length {
|
||||
t.Errorf("DeriveKey returned key of length %d; want %d", len(key1), length)
|
||||
}
|
||||
|
||||
// Derive another key with the same parameters, should be identical
|
||||
key2, err := DeriveKey(secret, salt, info, length)
|
||||
if err != nil {
|
||||
t.Fatalf("Second DeriveKey failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(key1, key2) {
|
||||
t.Errorf("DeriveKey is not deterministic. Got %x and %x for the same inputs", key1, key2)
|
||||
}
|
||||
|
||||
// Derive a key with different info, should be different
|
||||
differentInfo := []byte("different-info")
|
||||
key3, err := DeriveKey(secret, salt, differentInfo, length)
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKey with different info failed: %v", err)
|
||||
}
|
||||
if bytes.Equal(key1, key3) {
|
||||
t.Errorf("DeriveKey produced the same key for different info strings")
|
||||
}
|
||||
|
||||
// Derive a key with different salt, should be different
|
||||
differentSalt := []byte("different-salt")
|
||||
key4, err := DeriveKey(secret, differentSalt, info, length)
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKey with different salt failed: %v", err)
|
||||
}
|
||||
if bytes.Equal(key1, key4) {
|
||||
t.Errorf("DeriveKey produced the same key for different salts")
|
||||
}
|
||||
|
||||
// Derive a key with different secret, should be different
|
||||
differentSecret := []byte("different-secret")
|
||||
key5, err := DeriveKey(differentSecret, salt, info, length)
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKey with different secret failed: %v", err)
|
||||
}
|
||||
if bytes.Equal(key1, key5) {
|
||||
t.Errorf("DeriveKey produced the same key for different secrets")
|
||||
}
|
||||
|
||||
// Derive a key with different length
|
||||
differentLength := 64
|
||||
key6, err := DeriveKey(secret, salt, info, differentLength)
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKey with different length failed: %v", err)
|
||||
}
|
||||
if len(key6) != differentLength {
|
||||
t.Errorf("DeriveKey returned key of length %d; want %d", len(key6), differentLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveKeyEdgeCases(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
salt := []byte("test-salt")
|
||||
info := []byte("test-info")
|
||||
|
||||
t.Run("EmptySecret", func(t *testing.T) {
|
||||
_, err := DeriveKey([]byte{}, salt, info, 32)
|
||||
if err == nil {
|
||||
t.Errorf("DeriveKey should fail with empty secret")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EmptySalt", func(t *testing.T) {
|
||||
_, err := DeriveKey(secret, []byte{}, info, 32)
|
||||
if err != nil {
|
||||
t.Errorf("DeriveKey failed with empty salt: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EmptyInfo", func(t *testing.T) {
|
||||
_, err := DeriveKey(secret, salt, []byte{}, 32)
|
||||
if err != nil {
|
||||
t.Errorf("DeriveKey failed with empty info: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ZeroLength", func(t *testing.T) {
|
||||
_, err := DeriveKey(secret, salt, info, 0)
|
||||
if err == nil {
|
||||
t.Errorf("DeriveKey should fail with zero length")
|
||||
}
|
||||
})
|
||||
}
|
||||
28
pkg/cryptography/hmac.go
Normal file
28
pkg/cryptography/hmac.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package cryptography
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
)
|
||||
|
||||
func GenerateHMACKey(size int) ([]byte, error) {
|
||||
key := make([]byte, size)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func ComputeHMAC(key, message []byte) []byte {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write(message)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func ValidateHMAC(key, message, messageHMAC []byte) bool {
|
||||
expectedHMAC := ComputeHMAC(key, message)
|
||||
return hmac.Equal(messageHMAC, expectedHMAC)
|
||||
}
|
||||
80
pkg/cryptography/hmac_test.go
Normal file
80
pkg/cryptography/hmac_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package cryptography
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateHMACKey(t *testing.T) {
|
||||
testSizes := []int{16, 32, 64}
|
||||
for _, size := range testSizes {
|
||||
t.Run("Size"+string(rune(size)), func(t *testing.T) { // Simple name conversion
|
||||
key, err := GenerateHMACKey(size)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateHMACKey(%d) failed: %v", size, err)
|
||||
}
|
||||
if len(key) != size {
|
||||
t.Errorf("GenerateHMACKey(%d) returned key of length %d; want %d", size, len(key), size)
|
||||
}
|
||||
|
||||
// Check if key is not all zeros (basic check for randomness)
|
||||
isZero := true
|
||||
for _, b := range key {
|
||||
if b != 0 {
|
||||
isZero = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isZero {
|
||||
t.Errorf("GenerateHMACKey(%d) returned an all-zero key", size)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAndValidateHMAC(t *testing.T) {
|
||||
key, err := GenerateHMACKey(32) // Use SHA256 key size
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate HMAC key: %v", err)
|
||||
}
|
||||
|
||||
message := []byte("This is a test message.")
|
||||
|
||||
// Compute HMAC
|
||||
computedHMAC := ComputeHMAC(key, message)
|
||||
if len(computedHMAC) != 32 { // SHA256 output size
|
||||
t.Errorf("ComputeHMAC returned HMAC of length %d; want 32", len(computedHMAC))
|
||||
}
|
||||
|
||||
// Validate correct HMAC
|
||||
if !ValidateHMAC(key, message, computedHMAC) {
|
||||
t.Errorf("ValidateHMAC failed for correctly computed HMAC")
|
||||
}
|
||||
|
||||
// Validate incorrect HMAC (tampered message)
|
||||
tamperedMessage := append(message, byte('!'))
|
||||
if ValidateHMAC(key, tamperedMessage, computedHMAC) {
|
||||
t.Errorf("ValidateHMAC succeeded for tampered message")
|
||||
}
|
||||
|
||||
// Validate incorrect HMAC (tampered key)
|
||||
wrongKey, _ := GenerateHMACKey(32)
|
||||
if ValidateHMAC(wrongKey, message, computedHMAC) {
|
||||
t.Errorf("ValidateHMAC succeeded for incorrect key")
|
||||
}
|
||||
|
||||
// Validate incorrect HMAC (tampered HMAC)
|
||||
tamperedHMAC := append(computedHMAC[:len(computedHMAC)-1], ^computedHMAC[len(computedHMAC)-1])
|
||||
if ValidateHMAC(key, message, tamperedHMAC) {
|
||||
t.Errorf("ValidateHMAC succeeded for tampered HMAC")
|
||||
}
|
||||
|
||||
// Validate empty message
|
||||
emptyMessage := []byte("")
|
||||
emptyHMAC := ComputeHMAC(key, emptyMessage)
|
||||
if !ValidateHMAC(key, emptyMessage, emptyHMAC) {
|
||||
t.Errorf("ValidateHMAC failed for empty message")
|
||||
}
|
||||
if ValidateHMAC(key, message, emptyHMAC) {
|
||||
t.Errorf("ValidateHMAC succeeded comparing non-empty message with empty HMAC")
|
||||
}
|
||||
}
|
||||
117
pkg/debug/debug.go
Normal file
117
pkg/debug/debug.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package debug
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
const (
|
||||
DEBUG_CRITICAL = 1
|
||||
DEBUG_ERROR = 2
|
||||
DEBUG_INFO = 3
|
||||
DEBUG_VERBOSE = 4
|
||||
DEBUG_TRACE = 5
|
||||
DEBUG_PACKETS = 6
|
||||
DEBUG_ALL = 7
|
||||
)
|
||||
|
||||
var (
|
||||
debugLevel = flag.Int("debug", 3, "debug level (1-7)")
|
||||
logger *slog.Logger
|
||||
initialized bool
|
||||
)
|
||||
|
||||
func Init() {
|
||||
if initialized {
|
||||
return
|
||||
}
|
||||
initialized = true
|
||||
|
||||
var level slog.Level
|
||||
switch {
|
||||
case *debugLevel >= DEBUG_ALL:
|
||||
level = slog.LevelDebug
|
||||
case *debugLevel >= DEBUG_PACKETS:
|
||||
level = slog.LevelDebug
|
||||
case *debugLevel >= DEBUG_TRACE:
|
||||
level = slog.LevelDebug
|
||||
case *debugLevel >= DEBUG_VERBOSE:
|
||||
level = slog.LevelDebug
|
||||
case *debugLevel >= DEBUG_INFO:
|
||||
level = slog.LevelInfo
|
||||
case *debugLevel >= DEBUG_ERROR:
|
||||
level = slog.LevelWarn
|
||||
case *debugLevel >= DEBUG_CRITICAL:
|
||||
level = slog.LevelError
|
||||
default:
|
||||
level = slog.LevelError
|
||||
}
|
||||
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: level,
|
||||
}
|
||||
logger = slog.New(slog.NewTextHandler(os.Stderr, opts))
|
||||
slog.SetDefault(logger)
|
||||
}
|
||||
|
||||
func GetLogger() *slog.Logger {
|
||||
if !initialized {
|
||||
Init()
|
||||
}
|
||||
return logger
|
||||
}
|
||||
|
||||
func Log(level int, msg string, args ...interface{}) {
|
||||
if !initialized {
|
||||
Init()
|
||||
}
|
||||
|
||||
if *debugLevel < level {
|
||||
return
|
||||
}
|
||||
|
||||
var slogLevel slog.Level
|
||||
switch {
|
||||
case level >= DEBUG_ALL:
|
||||
slogLevel = slog.LevelDebug
|
||||
case level >= DEBUG_PACKETS:
|
||||
slogLevel = slog.LevelDebug
|
||||
case level >= DEBUG_TRACE:
|
||||
slogLevel = slog.LevelDebug
|
||||
case level >= DEBUG_VERBOSE:
|
||||
slogLevel = slog.LevelDebug
|
||||
case level >= DEBUG_INFO:
|
||||
slogLevel = slog.LevelInfo
|
||||
case level >= DEBUG_ERROR:
|
||||
slogLevel = slog.LevelWarn
|
||||
case level >= DEBUG_CRITICAL:
|
||||
slogLevel = slog.LevelError
|
||||
default:
|
||||
slogLevel = slog.LevelError
|
||||
}
|
||||
|
||||
if !logger.Enabled(context.TODO(), slogLevel) {
|
||||
return
|
||||
}
|
||||
|
||||
allArgs := make([]interface{}, len(args)+2)
|
||||
copy(allArgs, args)
|
||||
allArgs[len(args)] = "debug_level"
|
||||
allArgs[len(args)+1] = level
|
||||
logger.Log(context.TODO(), slogLevel, msg, allArgs...)
|
||||
}
|
||||
|
||||
func SetDebugLevel(level int) {
|
||||
*debugLevel = level
|
||||
if initialized {
|
||||
Init()
|
||||
}
|
||||
}
|
||||
|
||||
func GetDebugLevel() int {
|
||||
return *debugLevel
|
||||
}
|
||||
185
pkg/debug/debug_test.go
Normal file
185
pkg/debug/debug_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInit(t *testing.T) {
|
||||
originalFlag := flag.CommandLine
|
||||
defer func() {
|
||||
flag.CommandLine = originalFlag
|
||||
initialized = false
|
||||
}()
|
||||
|
||||
flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
debugLevel = flag.Int("debug", 3, "debug level")
|
||||
|
||||
Init()
|
||||
|
||||
if !initialized {
|
||||
t.Error("Init() should set initialized to true")
|
||||
}
|
||||
|
||||
if GetLogger() == nil {
|
||||
t.Error("GetLogger() should return non-nil logger after Init()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLogger(t *testing.T) {
|
||||
originalFlag := flag.CommandLine
|
||||
defer func() {
|
||||
flag.CommandLine = originalFlag
|
||||
initialized = false
|
||||
}()
|
||||
|
||||
flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
debugLevel = flag.Int("debug", 3, "debug level")
|
||||
initialized = false
|
||||
|
||||
logger := GetLogger()
|
||||
if logger == nil {
|
||||
t.Error("GetLogger() should return non-nil logger")
|
||||
}
|
||||
|
||||
if !initialized {
|
||||
t.Error("GetLogger() should initialize if not already initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog(t *testing.T) {
|
||||
originalFlag := flag.CommandLine
|
||||
defer func() {
|
||||
flag.CommandLine = originalFlag
|
||||
initialized = false
|
||||
}()
|
||||
|
||||
flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
debugLevel = flag.Int("debug", 7, "debug level")
|
||||
initialized = false
|
||||
|
||||
Log(DEBUG_INFO, "test message", "key", "value")
|
||||
}
|
||||
|
||||
func TestSetDebugLevel(t *testing.T) {
|
||||
originalFlag := flag.CommandLine
|
||||
defer func() {
|
||||
flag.CommandLine = originalFlag
|
||||
initialized = false
|
||||
}()
|
||||
|
||||
flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
debugLevel = flag.Int("debug", 3, "debug level")
|
||||
initialized = false
|
||||
|
||||
SetDebugLevel(5)
|
||||
if GetDebugLevel() != 5 {
|
||||
t.Errorf("SetDebugLevel(5) did not set level correctly, got %d", GetDebugLevel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDebugLevel(t *testing.T) {
|
||||
originalFlag := flag.CommandLine
|
||||
defer func() {
|
||||
flag.CommandLine = originalFlag
|
||||
initialized = false
|
||||
}()
|
||||
|
||||
flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
debugLevel = flag.Int("debug", 4, "debug level")
|
||||
|
||||
level := GetDebugLevel()
|
||||
if level != 4 {
|
||||
t.Errorf("GetDebugLevel() = %d, want 4", level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_LevelFiltering(t *testing.T) {
|
||||
originalFlag := flag.CommandLine
|
||||
defer func() {
|
||||
flag.CommandLine = originalFlag
|
||||
initialized = false
|
||||
}()
|
||||
|
||||
flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
debugLevel = flag.Int("debug", 3, "debug level")
|
||||
initialized = false
|
||||
|
||||
Log(DEBUG_TRACE, "trace message")
|
||||
Log(DEBUG_INFO, "info message")
|
||||
Log(DEBUG_ERROR, "error message")
|
||||
}
|
||||
|
||||
func TestConstants(t *testing.T) {
|
||||
if DEBUG_CRITICAL != 1 {
|
||||
t.Errorf("DEBUG_CRITICAL = %d, want 1", DEBUG_CRITICAL)
|
||||
}
|
||||
if DEBUG_ERROR != 2 {
|
||||
t.Errorf("DEBUG_ERROR = %d, want 2", DEBUG_ERROR)
|
||||
}
|
||||
if DEBUG_INFO != 3 {
|
||||
t.Errorf("DEBUG_INFO = %d, want 3", DEBUG_INFO)
|
||||
}
|
||||
if DEBUG_VERBOSE != 4 {
|
||||
t.Errorf("DEBUG_VERBOSE = %d, want 4", DEBUG_VERBOSE)
|
||||
}
|
||||
if DEBUG_TRACE != 5 {
|
||||
t.Errorf("DEBUG_TRACE = %d, want 5", DEBUG_TRACE)
|
||||
}
|
||||
if DEBUG_PACKETS != 6 {
|
||||
t.Errorf("DEBUG_PACKETS = %d, want 6", DEBUG_PACKETS)
|
||||
}
|
||||
if DEBUG_ALL != 7 {
|
||||
t.Errorf("DEBUG_ALL = %d, want 7", DEBUG_ALL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_WithArgs(t *testing.T) {
|
||||
originalFlag := flag.CommandLine
|
||||
defer func() {
|
||||
flag.CommandLine = originalFlag
|
||||
initialized = false
|
||||
}()
|
||||
|
||||
flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
debugLevel = flag.Int("debug", 7, "debug level")
|
||||
initialized = false
|
||||
|
||||
Log(DEBUG_INFO, "test message", "key1", "value1", "key2", "value2")
|
||||
}
|
||||
|
||||
func TestInit_MultipleCalls(t *testing.T) {
|
||||
originalFlag := flag.CommandLine
|
||||
defer func() {
|
||||
flag.CommandLine = originalFlag
|
||||
initialized = false
|
||||
}()
|
||||
|
||||
flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
debugLevel = flag.Int("debug", 3, "debug level")
|
||||
initialized = false
|
||||
|
||||
Init()
|
||||
firstLogger := GetLogger()
|
||||
|
||||
Init()
|
||||
secondLogger := GetLogger()
|
||||
|
||||
if firstLogger != secondLogger {
|
||||
t.Error("Multiple Init() calls should not create new loggers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_DisabledLevel(t *testing.T) {
|
||||
originalFlag := flag.CommandLine
|
||||
defer func() {
|
||||
flag.CommandLine = originalFlag
|
||||
initialized = false
|
||||
}()
|
||||
|
||||
flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
debugLevel = flag.Int("debug", 1, "debug level")
|
||||
initialized = false
|
||||
|
||||
Log(DEBUG_TRACE, "this should be filtered")
|
||||
}
|
||||
@@ -1,21 +1,35 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package destination
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Sudo-Ivan/reticulum-go/pkg/common"
|
||||
"github.com/Sudo-Ivan/reticulum-go/pkg/identity"
|
||||
"github.com/Sudo-Ivan/reticulum-go/pkg/transport"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/announce"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/identity"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/packet"
|
||||
"github.com/vmihailenco/msgpack/v5"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
|
||||
const (
|
||||
// Destination direction types
|
||||
// The IN bit specifies that the destination can receive traffic.
|
||||
// The OUT bit specifies that the destination can send traffic.
|
||||
// A destination can be both IN and OUT.
|
||||
IN = 0x01
|
||||
OUT = 0x02
|
||||
|
||||
// Destination types
|
||||
SINGLE = 0x00
|
||||
GROUP = 0x01
|
||||
PLAIN = 0x02
|
||||
@@ -41,42 +55,60 @@ type RequestHandler struct {
|
||||
ResponseGenerator func(path string, data []byte, requestID []byte, linkID []byte, remoteIdentity *identity.Identity, requestedAt int64) []byte
|
||||
AllowMode byte
|
||||
AllowedList [][]byte
|
||||
AutoCompress bool
|
||||
}
|
||||
|
||||
type Transport interface {
|
||||
GetConfig() *common.ReticulumConfig
|
||||
GetInterfaces() map[string]common.NetworkInterface
|
||||
RegisterDestination(hash []byte, dest interface{})
|
||||
}
|
||||
|
||||
type IncomingLinkHandler func(pkt *packet.Packet, dest *Destination, transport interface{}, networkIface common.NetworkInterface) (interface{}, error)
|
||||
|
||||
var incomingLinkHandler IncomingLinkHandler
|
||||
|
||||
func RegisterIncomingLinkHandler(handler IncomingLinkHandler) {
|
||||
incomingLinkHandler = handler
|
||||
}
|
||||
|
||||
type Destination struct {
|
||||
identity *identity.Identity
|
||||
direction byte
|
||||
destType byte
|
||||
appName string
|
||||
aspects []string
|
||||
hash []byte
|
||||
|
||||
acceptsLinks bool
|
||||
proofStrategy byte
|
||||
|
||||
packetCallback PacketCallback
|
||||
proofCallback ProofRequestedCallback
|
||||
linkCallback LinkEstablishedCallback
|
||||
|
||||
ratchetsEnabled bool
|
||||
ratchetPath string
|
||||
ratchetCount int
|
||||
ratchetInterval int
|
||||
enforceRatchets bool
|
||||
|
||||
defaultAppData []byte
|
||||
identity *identity.Identity
|
||||
direction byte
|
||||
destType byte
|
||||
appName string
|
||||
aspects []string
|
||||
hashValue []byte
|
||||
transport Transport
|
||||
|
||||
acceptsLinks bool
|
||||
proofStrategy byte
|
||||
|
||||
packetCallback PacketCallback
|
||||
proofCallback ProofRequestedCallback
|
||||
linkCallback LinkEstablishedCallback
|
||||
|
||||
ratchetsEnabled bool
|
||||
ratchetPath string
|
||||
ratchetCount int
|
||||
ratchetInterval int
|
||||
enforceRatchets bool
|
||||
latestRatchetTime time.Time
|
||||
latestRatchetID []byte
|
||||
ratchets [][]byte
|
||||
ratchetFileLock sync.Mutex
|
||||
|
||||
defaultAppData []byte
|
||||
mutex sync.RWMutex
|
||||
|
||||
requestHandlers map[string]*RequestHandler
|
||||
callbacks struct {
|
||||
packetReceived common.PacketCallback
|
||||
proofRequested common.ProofRequestedCallback
|
||||
linkEstablished common.LinkEstablishedCallback
|
||||
}
|
||||
}
|
||||
|
||||
func New(id *identity.Identity, direction byte, destType byte, appName string, aspects ...string) (*Destination, error) {
|
||||
func New(id *identity.Identity, direction byte, destType byte, appName string, transport Transport, aspects ...string) (*Destination, error) {
|
||||
debug.Log(debug.DEBUG_INFO, "Creating new destination", "app", appName, "type", destType, "direction", direction)
|
||||
|
||||
if id == nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Cannot create destination: identity is nil")
|
||||
return nil, errors.New("identity cannot be nil")
|
||||
}
|
||||
|
||||
@@ -86,6 +118,7 @@ func New(id *identity.Identity, direction byte, destType byte, appName string, a
|
||||
destType: destType,
|
||||
appName: appName,
|
||||
aspects: aspects,
|
||||
transport: transport,
|
||||
acceptsLinks: false,
|
||||
proofStrategy: PROVE_NONE,
|
||||
ratchetCount: RATCHET_COUNT,
|
||||
@@ -94,19 +127,69 @@ func New(id *identity.Identity, direction byte, destType byte, appName string, a
|
||||
}
|
||||
|
||||
// Generate destination hash
|
||||
d.hash = d.Hash()
|
||||
|
||||
d.hashValue = d.calculateHash()
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Created destination with hash", "hash", fmt.Sprintf("%x", d.hashValue))
|
||||
|
||||
// Auto-register with transport if direction is IN
|
||||
if (direction & IN) != 0 {
|
||||
transport.RegisterDestination(d.hashValue, d)
|
||||
debug.Log(debug.DEBUG_INFO, "Destination auto-registered with transport", "hash", fmt.Sprintf("%x", d.hashValue))
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *Destination) Hash() []byte {
|
||||
nameHash := sha256.Sum256([]byte(d.ExpandName()))
|
||||
identityHash := sha256.Sum256(d.identity.GetPublicKey())
|
||||
|
||||
combined := append(nameHash[:], identityHash[:]...)
|
||||
finalHash := sha256.Sum256(combined)
|
||||
|
||||
return finalHash[:16] // Truncated to 128 bits
|
||||
// FromHash creates a destination from a known hash (e.g., from an announce).
|
||||
// This is used by clients to create destination objects for servers they've discovered.
|
||||
func FromHash(hash []byte, id *identity.Identity, destType byte, transport Transport) (*Destination, error) {
|
||||
debug.Log(debug.DEBUG_INFO, "Creating destination from hash", "hash", fmt.Sprintf("%x", hash))
|
||||
|
||||
if id == nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Cannot create destination: identity is nil")
|
||||
return nil, errors.New("identity cannot be nil")
|
||||
}
|
||||
|
||||
d := &Destination{
|
||||
identity: id,
|
||||
direction: OUT,
|
||||
destType: destType,
|
||||
hashValue: hash,
|
||||
transport: transport,
|
||||
acceptsLinks: false,
|
||||
proofStrategy: PROVE_NONE,
|
||||
ratchetCount: RATCHET_COUNT,
|
||||
ratchetInterval: RATCHET_INTERVAL,
|
||||
requestHandlers: make(map[string]*RequestHandler),
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Created destination from hash", "hash", fmt.Sprintf("%x", hash))
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *Destination) calculateHash() []byte {
|
||||
debug.Log(debug.DEBUG_TRACE, "Calculating hash for destination", "name", d.ExpandName())
|
||||
|
||||
// destination_hash = SHA256(name_hash_10bytes + identity_hash_16bytes)[:16]
|
||||
// Identity hash is the truncated hash of the public key (16 bytes)
|
||||
identityHash := identity.TruncatedHash(d.identity.GetPublicKey())
|
||||
|
||||
// Name hash is the FULL 32-byte SHA256, then we take first 10 bytes for concatenation
|
||||
nameHashFull := sha256.Sum256([]byte(d.ExpandName()))
|
||||
nameHash10 := nameHashFull[:10] // Only use 10 bytes
|
||||
|
||||
debug.Log(debug.DEBUG_ALL, "Identity hash", "hash", fmt.Sprintf("%x", identityHash))
|
||||
debug.Log(debug.DEBUG_ALL, "Name hash (10 bytes)", "hash", fmt.Sprintf("%x", nameHash10))
|
||||
|
||||
// Concatenate name_hash (10 bytes) + identity_hash (16 bytes) = 26 bytes
|
||||
combined := append(nameHash10, identityHash...)
|
||||
|
||||
// Then hash again and truncate to 16 bytes
|
||||
finalHashFull := sha256.Sum256(combined)
|
||||
finalHash := finalHashFull[:16]
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Calculated destination hash", "hash", fmt.Sprintf("%x", finalHash))
|
||||
|
||||
return finalHash
|
||||
}
|
||||
|
||||
func (d *Destination) ExpandName() string {
|
||||
@@ -117,80 +200,68 @@ func (d *Destination) ExpandName() string {
|
||||
return name
|
||||
}
|
||||
|
||||
func (d *Destination) Announce(appData []byte) error {
|
||||
func (d *Destination) Announce(pathResponse bool, tag []byte, attachedInterface common.NetworkInterface) error {
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
|
||||
// If no specific appData provided, use default
|
||||
if appData == nil {
|
||||
appData = d.defaultAppData
|
||||
}
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Announcing destination", "name", d.ExpandName(), "path_response", pathResponse)
|
||||
|
||||
// Create announce packet
|
||||
packet := make([]byte, 0)
|
||||
appData := d.defaultAppData
|
||||
|
||||
// Add destination hash
|
||||
packet = append(packet, d.hash...)
|
||||
|
||||
// Add identity public key
|
||||
packet = append(packet, d.identity.GetPublicKey()...)
|
||||
|
||||
// Add flags byte
|
||||
flags := byte(0)
|
||||
if d.acceptsLinks {
|
||||
flags |= 0x01
|
||||
}
|
||||
if d.ratchetsEnabled {
|
||||
flags |= 0x02
|
||||
}
|
||||
packet = append(packet, flags)
|
||||
|
||||
// Add proof strategy
|
||||
packet = append(packet, d.proofStrategy)
|
||||
|
||||
// Add app data length and data if present
|
||||
if appData != nil {
|
||||
appDataLen := uint16(len(appData))
|
||||
lenBytes := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(lenBytes, appDataLen)
|
||||
packet = append(packet, lenBytes...)
|
||||
packet = append(packet, appData...)
|
||||
} else {
|
||||
// No app data
|
||||
packet = append(packet, 0x00, 0x00)
|
||||
}
|
||||
|
||||
// Add ratchet data if enabled
|
||||
if d.ratchetsEnabled {
|
||||
// Add ratchet interval
|
||||
intervalBytes := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(intervalBytes, uint32(d.ratchetInterval))
|
||||
packet = append(packet, intervalBytes...)
|
||||
|
||||
// Add current ratchet key
|
||||
ratchetKey := d.identity.GetCurrentRatchetKey()
|
||||
if ratchetKey == nil {
|
||||
return errors.New("failed to get current ratchet key")
|
||||
}
|
||||
packet = append(packet, ratchetKey...)
|
||||
}
|
||||
|
||||
// Sign the announce packet
|
||||
signature, err := d.Sign(packet)
|
||||
// Create announce packet using announce package
|
||||
announceObj, err := announce.New(d.identity, d.hashValue, d.ExpandName(), appData, pathResponse, d.transport.GetConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign announce packet: %w", err)
|
||||
return fmt.Errorf("failed to create announce: %w", err)
|
||||
}
|
||||
packet = append(packet, signature...)
|
||||
|
||||
// Send announce packet through transport layer
|
||||
// This will need to be implemented in the transport package
|
||||
return transport.SendAnnounce(packet)
|
||||
packet := announceObj.GetPacket()
|
||||
if packet == nil {
|
||||
return errors.New("failed to create announce packet")
|
||||
}
|
||||
|
||||
if pathResponse && tag != nil {
|
||||
debug.Log(debug.DEBUG_INFO, "Sending path response announce", "tag", fmt.Sprintf("%x", tag))
|
||||
}
|
||||
|
||||
if d.transport == nil {
|
||||
return errors.New("transport not initialized")
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
if attachedInterface != nil {
|
||||
if attachedInterface.IsEnabled() && attachedInterface.IsOnline() {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Sending announce to attached interface", "name", attachedInterface.GetName())
|
||||
if err := attachedInterface.Send(packet, ""); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to send announce on attached interface", "error", err)
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
interfaces := d.transport.GetInterfaces()
|
||||
for name, iface := range interfaces {
|
||||
if iface.IsEnabled() && iface.IsOnline() {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Sending announce to interface", "name", name)
|
||||
if err := iface.Send(packet, ""); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to send announce on interface", "name", name, "error", err)
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (d *Destination) AcceptsLinks(accepts bool) {
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
d.acceptsLinks = accepts
|
||||
|
||||
// Register with transport if accepting links
|
||||
if accepts && d.transport != nil {
|
||||
d.transport.RegisterDestination(d.hashValue, d)
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Destination registered with transport for link requests", "hash", fmt.Sprintf("%x", d.hashValue))
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Destination) SetLinkEstablishedCallback(callback common.LinkEstablishedCallback) {
|
||||
@@ -199,12 +270,72 @@ func (d *Destination) SetLinkEstablishedCallback(callback common.LinkEstablished
|
||||
d.linkCallback = callback
|
||||
}
|
||||
|
||||
func (d *Destination) GetLinkCallback() common.LinkEstablishedCallback {
|
||||
d.mutex.RLock()
|
||||
defer d.mutex.RUnlock()
|
||||
return d.linkCallback
|
||||
}
|
||||
|
||||
func (d *Destination) HandleIncomingLinkRequest(pkt interface{}, transport interface{}, networkIface common.NetworkInterface) error {
|
||||
debug.Log(debug.DEBUG_INFO, "Handling incoming link request for destination", "hash", fmt.Sprintf("%x", d.GetHash()))
|
||||
|
||||
pktObj, ok := pkt.(*packet.Packet)
|
||||
if !ok {
|
||||
return errors.New("invalid packet type")
|
||||
}
|
||||
|
||||
if incomingLinkHandler == nil {
|
||||
return errors.New("no incoming link handler registered")
|
||||
}
|
||||
|
||||
linkIface, err := incomingLinkHandler(pktObj, d, transport, networkIface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to handle link request: %w", err)
|
||||
}
|
||||
|
||||
if d.linkCallback != nil && linkIface != nil {
|
||||
debug.Log(debug.DEBUG_INFO, "Calling link established callback")
|
||||
d.linkCallback(linkIface)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Destination) SetPacketCallback(callback common.PacketCallback) {
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
d.packetCallback = callback
|
||||
}
|
||||
|
||||
func (d *Destination) Receive(pkt *packet.Packet, iface common.NetworkInterface) {
|
||||
d.mutex.RLock()
|
||||
callback := d.packetCallback
|
||||
d.mutex.RUnlock()
|
||||
|
||||
if callback == nil {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "No packet callback set for destination")
|
||||
return
|
||||
}
|
||||
|
||||
if pkt.PacketType == packet.PacketTypeLinkReq {
|
||||
debug.Log(debug.DEBUG_INFO, "Received link request for destination")
|
||||
if err := d.HandleIncomingLinkRequest(pkt, d.transport, iface); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to handle incoming link request", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
plaintext, err := d.Decrypt(pkt.Data)
|
||||
if err != nil {
|
||||
debug.Log(debug.DEBUG_INFO, "Failed to decrypt packet data", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "Destination received packet", "bytes", len(plaintext))
|
||||
|
||||
callback(plaintext, iface)
|
||||
}
|
||||
|
||||
func (d *Destination) SetProofRequestedCallback(callback common.ProofRequestedCallback) {
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
@@ -220,9 +351,28 @@ func (d *Destination) SetProofStrategy(strategy byte) {
|
||||
func (d *Destination) EnableRatchets(path string) bool {
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
|
||||
|
||||
if path == "" {
|
||||
debug.Log(debug.DEBUG_ERROR, "No ratchet file path specified")
|
||||
return false
|
||||
}
|
||||
|
||||
d.ratchetsEnabled = true
|
||||
d.ratchetPath = path
|
||||
d.latestRatchetTime = time.Time{} // Zero time to force rotation
|
||||
|
||||
// Load or initialize ratchets
|
||||
if err := d.reloadRatchets(); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to load ratchets", "error", err)
|
||||
// Initialize empty ratchet list
|
||||
d.ratchets = make([][]byte, 0)
|
||||
if err := d.persistRatchets(); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to create initial ratchet file", "error", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "Ratchets enabled", "path", path)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -236,7 +386,7 @@ func (d *Destination) SetRetainedRatchets(count int) bool {
|
||||
if count < 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
d.ratchetCount = count
|
||||
@@ -247,7 +397,7 @@ func (d *Destination) SetRatchetInterval(interval int) bool {
|
||||
if interval < 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
d.ratchetInterval = interval
|
||||
@@ -275,7 +425,7 @@ func (d *Destination) RegisterRequestHandler(path string, responseGen func(strin
|
||||
return errors.New("invalid allow mode")
|
||||
}
|
||||
|
||||
if allow == ALLOW_LIST && (allowedList == nil || len(allowedList) == 0) {
|
||||
if allow == ALLOW_LIST && len(allowedList) == 0 {
|
||||
return errors.New("allowed list required for ALLOW_LIST mode")
|
||||
}
|
||||
|
||||
@@ -303,21 +453,88 @@ func (d *Destination) DeregisterRequestHandler(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *Destination) GetRequestHandler(pathHash []byte) func([]byte, []byte, []byte, []byte, *identity.Identity, time.Time) interface{} {
|
||||
d.mutex.RLock()
|
||||
defer d.mutex.RUnlock()
|
||||
|
||||
for _, handler := range d.requestHandlers {
|
||||
handlerPathHash := identity.TruncatedHash([]byte(handler.Path))
|
||||
if string(handlerPathHash) == string(pathHash) {
|
||||
return func(pathHash []byte, data []byte, requestID []byte, linkID []byte, remoteIdentity *identity.Identity, requestedAt time.Time) interface{} {
|
||||
allowed := false
|
||||
if handler.AllowMode == ALLOW_ALL {
|
||||
allowed = true
|
||||
} else if handler.AllowMode == ALLOW_LIST && remoteIdentity != nil {
|
||||
remoteHash := remoteIdentity.Hash()
|
||||
for _, allowedHash := range handler.AllowedList {
|
||||
if string(remoteHash) == string(allowedHash) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := handler.ResponseGenerator(handler.Path, data, requestID, linkID, remoteIdentity, requestedAt.Unix())
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Destination) HandleRequest(path string, data []byte, requestID []byte, linkID []byte, remoteIdentity *identity.Identity, requestedAt int64) []byte {
|
||||
d.mutex.RLock()
|
||||
handler, exists := d.requestHandlers[path]
|
||||
d.mutex.RUnlock()
|
||||
|
||||
if !exists {
|
||||
debug.Log(debug.DEBUG_INFO, "No handler registered for path", "path", path)
|
||||
return []byte(">Not Found\n\nThe requested resource was not found.")
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Calling request handler", "path", path)
|
||||
result := handler.ResponseGenerator(path, data, requestID, linkID, remoteIdentity, requestedAt)
|
||||
if result == nil {
|
||||
return []byte(">Not Found\n\nThe requested resource was not found.")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (d *Destination) Encrypt(plaintext []byte) ([]byte, error) {
|
||||
if d.destType == PLAIN {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Using plaintext transmission for PLAIN destination")
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
if d.identity == nil {
|
||||
debug.Log(debug.DEBUG_INFO, "Cannot encrypt: no identity available")
|
||||
return nil, errors.New("no identity available for encryption")
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Encrypting bytes for destination", "bytes", len(plaintext), "destType", d.destType)
|
||||
|
||||
switch d.destType {
|
||||
case SINGLE:
|
||||
return d.identity.Encrypt(plaintext, nil)
|
||||
recipientKey := d.identity.GetEncryptionKey()
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Encrypting for single recipient", "key", fmt.Sprintf("%x", recipientKey[:8]))
|
||||
return d.identity.Encrypt(plaintext, recipientKey)
|
||||
case GROUP:
|
||||
return d.identity.EncryptSymmetric(plaintext)
|
||||
key := d.identity.GetCurrentRatchetKey()
|
||||
if key == nil {
|
||||
debug.Log(debug.DEBUG_INFO, "Cannot encrypt: no ratchet key available")
|
||||
return nil, errors.New("no ratchet key available")
|
||||
}
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Encrypting for group with ratchet key", "key", fmt.Sprintf("%x", key[:8]))
|
||||
return d.identity.EncryptWithHMAC(plaintext, key)
|
||||
default:
|
||||
debug.Log(debug.DEBUG_INFO, "Unsupported destination type for encryption", "destType", d.destType)
|
||||
return nil, errors.New("unsupported destination type for encryption")
|
||||
}
|
||||
}
|
||||
@@ -331,14 +548,15 @@ func (d *Destination) Decrypt(ciphertext []byte) ([]byte, error) {
|
||||
return nil, errors.New("no identity available for decryption")
|
||||
}
|
||||
|
||||
switch d.destType {
|
||||
case SINGLE:
|
||||
return d.identity.Decrypt(ciphertext, nil)
|
||||
case GROUP:
|
||||
return d.identity.DecryptSymmetric(ciphertext)
|
||||
default:
|
||||
return nil, errors.New("unsupported destination type for decryption")
|
||||
}
|
||||
// Create empty ratchet receiver to get latest ratchet ID if available
|
||||
ratchetReceiver := &common.RatchetIDReceiver{}
|
||||
|
||||
// Call Decrypt with full parameter list:
|
||||
// - ciphertext: the encrypted data
|
||||
// - ratchets: nil since we're not providing specific ratchets
|
||||
// - enforceRatchets: false to allow fallback to normal decryption
|
||||
// - ratchetIDReceiver: to receive the latest ratchet ID used
|
||||
return d.identity.Decrypt(ciphertext, nil, false, ratchetReceiver)
|
||||
}
|
||||
|
||||
func (d *Destination) Sign(data []byte) ([]byte, error) {
|
||||
@@ -347,4 +565,216 @@ func (d *Destination) Sign(data []byte) ([]byte, error) {
|
||||
}
|
||||
signature := d.identity.Sign(data)
|
||||
return signature, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Destination) GetPublicKey() []byte {
|
||||
if d.identity == nil {
|
||||
return nil
|
||||
}
|
||||
return d.identity.GetPublicKey()
|
||||
}
|
||||
|
||||
func (d *Destination) GetIdentity() *identity.Identity {
|
||||
return d.identity
|
||||
}
|
||||
|
||||
func (d *Destination) GetType() byte {
|
||||
return d.destType
|
||||
}
|
||||
|
||||
func (d *Destination) GetHash() []byte {
|
||||
d.mutex.RLock()
|
||||
defer d.mutex.RUnlock()
|
||||
if d.hashValue == nil {
|
||||
d.mutex.RUnlock()
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
if d.hashValue == nil {
|
||||
d.hashValue = d.calculateHash()
|
||||
}
|
||||
}
|
||||
return d.hashValue
|
||||
}
|
||||
|
||||
func (d *Destination) persistRatchets() error {
|
||||
d.ratchetFileLock.Lock()
|
||||
defer d.ratchetFileLock.Unlock()
|
||||
|
||||
if !d.ratchetsEnabled || d.ratchetPath == "" {
|
||||
return errors.New("ratchets not enabled or no path specified")
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_PACKETS, "Persisting ratchets", "count", len(d.ratchets), "path", d.ratchetPath)
|
||||
|
||||
// Pack ratchets using msgpack
|
||||
packedRatchets, err := msgpack.Marshal(d.ratchets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pack ratchets: %w", err)
|
||||
}
|
||||
|
||||
// Sign the packed ratchets
|
||||
signature, err := d.Sign(packedRatchets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign ratchets: %w", err)
|
||||
}
|
||||
|
||||
// Create structure
|
||||
persistedData := map[string][]byte{
|
||||
"signature": signature,
|
||||
"ratchets": packedRatchets,
|
||||
}
|
||||
|
||||
// Pack the entire structure
|
||||
finalData, err := msgpack.Marshal(persistedData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pack ratchet data: %w", err)
|
||||
}
|
||||
|
||||
// Write to temporary file first, then rename (atomic operation)
|
||||
tempPath := d.ratchetPath + ".tmp"
|
||||
file, err := os.Create(tempPath) // #nosec G304
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp ratchet file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := file.Write(finalData); err != nil {
|
||||
// #nosec G104 - Error already being handled, cleanup errors are non-critical
|
||||
file.Close()
|
||||
// #nosec G104 - Error already being handled, cleanup errors are non-critical
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to write ratchet data: %w", err)
|
||||
}
|
||||
// #nosec G104 - File is being closed after successful write, error is non-critical
|
||||
file.Close()
|
||||
|
||||
// Remove old file if exists
|
||||
if _, err := os.Stat(d.ratchetPath); err == nil {
|
||||
// #nosec G104 - Removing old file, error is non-critical if it doesn't exist
|
||||
os.Remove(d.ratchetPath)
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
if err := os.Rename(tempPath, d.ratchetPath); err != nil {
|
||||
// #nosec G104 - Error already being handled, cleanup errors are non-critical
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to rename ratchet file: %w", err)
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_PACKETS, "Ratchets persisted successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Destination) reloadRatchets() error {
|
||||
d.ratchetFileLock.Lock()
|
||||
defer d.ratchetFileLock.Unlock()
|
||||
|
||||
if _, err := os.Stat(d.ratchetPath); os.IsNotExist(err) {
|
||||
debug.Log(debug.DEBUG_INFO, "No existing ratchet data found, initializing new ratchet file")
|
||||
d.ratchets = make([][]byte, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(d.ratchetPath) // #nosec G304
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open ratchet file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read all data
|
||||
fileData, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read ratchet file: %w", err)
|
||||
}
|
||||
|
||||
// Unpack outer structure
|
||||
var persistedData map[string][]byte
|
||||
if err := msgpack.Unmarshal(fileData, &persistedData); err != nil {
|
||||
return fmt.Errorf("failed to unpack ratchet data: %w", err)
|
||||
}
|
||||
|
||||
signature, hasSignature := persistedData["signature"]
|
||||
packedRatchets, hasRatchets := persistedData["ratchets"]
|
||||
|
||||
if !hasSignature || !hasRatchets {
|
||||
return fmt.Errorf("invalid ratchet file format")
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if !d.identity.Verify(packedRatchets, signature) {
|
||||
return fmt.Errorf("invalid ratchet file signature")
|
||||
}
|
||||
|
||||
// Unpack ratchet list
|
||||
if err := msgpack.Unmarshal(packedRatchets, &d.ratchets); err != nil {
|
||||
return fmt.Errorf("failed to unpack ratchet list: %w", err)
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "Ratchets reloaded successfully", "count", len(d.ratchets))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Destination) RotateRatchets() error {
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
|
||||
if !d.ratchetsEnabled {
|
||||
return errors.New("ratchets not enabled")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if !d.latestRatchetTime.IsZero() && now.Before(d.latestRatchetTime.Add(time.Duration(d.ratchetInterval)*time.Second)) {
|
||||
debug.Log(debug.DEBUG_TRACE, "Ratchet rotation interval not reached")
|
||||
return nil
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "Rotating ratchets", "destination", d.ExpandName())
|
||||
|
||||
// Generate new ratchet key (32 bytes for X25519 private key)
|
||||
newRatchet := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, newRatchet); err != nil {
|
||||
return fmt.Errorf("failed to generate new ratchet: %w", err)
|
||||
}
|
||||
|
||||
// Insert at beginning (most recent first)
|
||||
d.ratchets = append([][]byte{newRatchet}, d.ratchets...)
|
||||
d.latestRatchetTime = now
|
||||
|
||||
// Get ratchet public key for ID
|
||||
ratchetPub, err := curve25519.X25519(newRatchet, curve25519.Basepoint)
|
||||
if err == nil {
|
||||
d.latestRatchetID = identity.TruncatedHash(ratchetPub)[:identity.NAME_HASH_LENGTH/8]
|
||||
}
|
||||
|
||||
// Clean old ratchets
|
||||
d.cleanRatchets()
|
||||
|
||||
// Persist to disk
|
||||
if err := d.persistRatchets(); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to persist ratchets after rotation", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "Ratchet rotation completed", "total_ratchets", len(d.ratchets))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Destination) cleanRatchets() {
|
||||
if len(d.ratchets) > d.ratchetCount {
|
||||
debug.Log(debug.DEBUG_TRACE, "Cleaning old ratchets", "before", len(d.ratchets), "keeping", d.ratchetCount)
|
||||
d.ratchets = d.ratchets[:d.ratchetCount]
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Destination) GetRatchets() [][]byte {
|
||||
d.mutex.RLock()
|
||||
defer d.mutex.RUnlock()
|
||||
|
||||
if !d.ratchetsEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return copy to prevent external modification
|
||||
ratchetsCopy := make([][]byte, len(d.ratchets))
|
||||
copy(ratchetsCopy, d.ratchets)
|
||||
return ratchetsCopy
|
||||
}
|
||||
|
||||
152
pkg/destination/destination_test.go
Normal file
152
pkg/destination/destination_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package destination
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/identity"
|
||||
)
|
||||
|
||||
type mockTransport struct {
|
||||
config *common.ReticulumConfig
|
||||
interfaces map[string]common.NetworkInterface
|
||||
}
|
||||
|
||||
func (m *mockTransport) GetConfig() *common.ReticulumConfig {
|
||||
return m.config
|
||||
}
|
||||
|
||||
func (m *mockTransport) GetInterfaces() map[string]common.NetworkInterface {
|
||||
return m.interfaces
|
||||
}
|
||||
|
||||
func (m *mockTransport) RegisterDestination(hash []byte, dest interface{}) {
|
||||
}
|
||||
|
||||
type mockInterface struct {
|
||||
common.BaseInterface
|
||||
}
|
||||
|
||||
func (m *mockInterface) Send(data []byte, address string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewDestination(t *testing.T) {
|
||||
id, _ := identity.New()
|
||||
transport := &mockTransport{config: &common.ReticulumConfig{}}
|
||||
|
||||
dest, err := New(id, IN|OUT, SINGLE, "testapp", transport, "testaspect")
|
||||
if err != nil {
|
||||
t.Fatalf("New failed: %v", err)
|
||||
}
|
||||
if dest == nil {
|
||||
t.Fatal("New returned nil")
|
||||
}
|
||||
|
||||
if dest.ExpandName() != "testapp.testaspect" {
|
||||
t.Errorf("Expected name testapp.testaspect, got %s", dest.ExpandName())
|
||||
}
|
||||
|
||||
hash := dest.GetHash()
|
||||
if len(hash) != 16 {
|
||||
t.Errorf("Expected hash length 16, got %d", len(hash))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromHash(t *testing.T) {
|
||||
id, _ := identity.New()
|
||||
transport := &mockTransport{}
|
||||
hash := make([]byte, 16)
|
||||
|
||||
dest, err := FromHash(hash, id, SINGLE, transport)
|
||||
if err != nil {
|
||||
t.Fatalf("FromHash failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(dest.GetHash(), hash) {
|
||||
t.Error("Hashes don't match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestHandlers(t *testing.T) {
|
||||
id, _ := identity.New()
|
||||
dest, _ := New(id, IN, SINGLE, "test", &mockTransport{})
|
||||
|
||||
path := "test/path"
|
||||
response := []byte("hello")
|
||||
|
||||
err := dest.RegisterRequestHandler(path, func(p string, d []byte, rid []byte, lid []byte, ri *identity.Identity, ra int64) []byte {
|
||||
return response
|
||||
}, ALLOW_ALL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RegisterRequestHandler failed: %v", err)
|
||||
}
|
||||
|
||||
result := dest.HandleRequest(path, nil, nil, nil, nil, 0)
|
||||
if !bytes.Equal(result, response) {
|
||||
t.Errorf("Expected response %q, got %q", response, result)
|
||||
}
|
||||
|
||||
if !dest.DeregisterRequestHandler(path) {
|
||||
t.Error("DeregisterRequestHandler failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt(t *testing.T) {
|
||||
id, _ := identity.New()
|
||||
dest, _ := New(id, IN|OUT, SINGLE, "test", &mockTransport{})
|
||||
|
||||
plaintext := []byte("hello world")
|
||||
ciphertext, err := dest.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := dest.Decrypt(ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(plaintext, decrypted) {
|
||||
t.Errorf("Decrypted data doesn't match: %q vs %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRatchets(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ratchetPath := filepath.Join(tmpDir, "ratchets")
|
||||
|
||||
id, _ := identity.New()
|
||||
dest, _ := New(id, IN|OUT, SINGLE, "test", &mockTransport{})
|
||||
|
||||
if !dest.EnableRatchets(ratchetPath) {
|
||||
t.Fatal("EnableRatchets failed")
|
||||
}
|
||||
|
||||
err := dest.RotateRatchets()
|
||||
if err != nil {
|
||||
t.Fatalf("RotateRatchets failed: %v", err)
|
||||
}
|
||||
|
||||
ratchets := dest.GetRatchets()
|
||||
if len(ratchets) != 1 {
|
||||
t.Errorf("Expected 1 ratchet, got %d", len(ratchets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainDestination(t *testing.T) {
|
||||
id, _ := identity.New()
|
||||
dest, _ := New(id, IN|OUT, PLAIN, "test", &mockTransport{})
|
||||
|
||||
plaintext := []byte("plain text")
|
||||
ciphertext, _ := dest.Encrypt(plaintext)
|
||||
if !bytes.Equal(plaintext, ciphertext) {
|
||||
t.Error("Plain destination should not encrypt")
|
||||
}
|
||||
|
||||
decrypted, _ := dest.Decrypt(ciphertext)
|
||||
if !bytes.Equal(plaintext, decrypted) {
|
||||
t.Error("Plain destination should not decrypt")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
148
pkg/identity/identity_test.go
Normal file
148
pkg/identity/identity_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewIdentity(t *testing.T) {
|
||||
id, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
if id == nil {
|
||||
t.Fatal("New() returned nil")
|
||||
}
|
||||
|
||||
pubKey := id.GetPublicKey()
|
||||
if len(pubKey) != 64 {
|
||||
t.Errorf("Expected public key length 64, got %d", len(pubKey))
|
||||
}
|
||||
|
||||
privKey := id.GetPrivateKey()
|
||||
if len(privKey) != 64 {
|
||||
t.Errorf("Expected private key length 64, got %d", len(privKey))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignVerify(t *testing.T) {
|
||||
id, _ := New()
|
||||
data := []byte("test data")
|
||||
sig := id.Sign(data)
|
||||
|
||||
if !id.Verify(data, sig) {
|
||||
t.Error("Verification failed for valid signature")
|
||||
}
|
||||
|
||||
if id.Verify([]byte("wrong data"), sig) {
|
||||
t.Error("Verification succeeded for wrong data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt(t *testing.T) {
|
||||
id, _ := New()
|
||||
plaintext := []byte("secret message")
|
||||
|
||||
ciphertext, err := id.Encrypt(plaintext, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := id.Decrypt(ciphertext, nil, false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(plaintext, decrypted) {
|
||||
t.Errorf("Decrypted data doesn't match plaintext: %q vs %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityHash(t *testing.T) {
|
||||
id, _ := New()
|
||||
h := id.Hash()
|
||||
if len(h) != TRUNCATED_HASHLENGTH/8 {
|
||||
t.Errorf("Expected hash length %d, got %d", TRUNCATED_HASHLENGTH/8, len(h))
|
||||
}
|
||||
|
||||
hexHash := id.Hex()
|
||||
if len(hexHash) != TRUNCATED_HASHLENGTH/4 {
|
||||
t.Errorf("Expected hex hash length %d, got %d", TRUNCATED_HASHLENGTH/4, len(hexHash))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileOperations(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
idPath := filepath.Join(tmpDir, "identity")
|
||||
|
||||
id, _ := New()
|
||||
err := id.ToFile(idPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ToFile failed: %v", err)
|
||||
}
|
||||
|
||||
loadedID, err := FromFile(idPath)
|
||||
if err != nil {
|
||||
t.Fatalf("FromFile failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(id.GetPublicKey(), loadedID.GetPublicKey()) {
|
||||
t.Error("Loaded identity public key doesn't match original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRatchets(t *testing.T) {
|
||||
id, _ := New()
|
||||
|
||||
ratchet, err := id.RotateRatchet()
|
||||
if err != nil {
|
||||
t.Fatalf("RotateRatchet failed: %v", err)
|
||||
}
|
||||
if len(ratchet) != RATCHETSIZE/8 {
|
||||
t.Errorf("Expected ratchet size %d, got %d", RATCHETSIZE/8, len(ratchet))
|
||||
}
|
||||
|
||||
ratchets := id.GetRatchets()
|
||||
if len(ratchets) != 1 {
|
||||
t.Errorf("Expected 1 ratchet, got %d", len(ratchets))
|
||||
}
|
||||
|
||||
id.CleanupExpiredRatchets()
|
||||
// Should still be there since it's not expired
|
||||
if len(id.GetRatchets()) != 1 {
|
||||
t.Error("Ratchet unexpectedly cleaned up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallIdentity(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
idPath := filepath.Join(tmpDir, "identity_recall")
|
||||
|
||||
id, _ := New()
|
||||
_ = id.ToFile(idPath)
|
||||
|
||||
recalledID, err := RecallIdentity(idPath)
|
||||
if err != nil {
|
||||
t.Fatalf("RecallIdentity failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(id.GetPublicKey(), recalledID.GetPublicKey()) {
|
||||
t.Error("Recalled identity public key doesn't match original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncatedHash(t *testing.T) {
|
||||
data := []byte("some data")
|
||||
h := TruncatedHash(data)
|
||||
if len(h) != TRUNCATED_HASHLENGTH/8 {
|
||||
t.Errorf("Expected length %d, got %d", TRUNCATED_HASHLENGTH/8, len(h))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRandomHash(t *testing.T) {
|
||||
h := GetRandomHash()
|
||||
if len(h) != TRUNCATED_HASHLENGTH/8 {
|
||||
t.Errorf("Expected length %d, got %d", TRUNCATED_HASHLENGTH/8, len(h))
|
||||
}
|
||||
}
|
||||
605
pkg/interfaces/auto.go
Normal file
605
pkg/interfaces/auto.go
Normal file
@@ -0,0 +1,605 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
)
|
||||
|
||||
const (
|
||||
HW_MTU = 1196
|
||||
DEFAULT_DISCOVERY_PORT = 29716
|
||||
DEFAULT_DATA_PORT = 42671
|
||||
DEFAULT_GROUP_ID = "reticulum"
|
||||
BITRATE_GUESS = 10 * 1000 * 1000
|
||||
PEERING_TIMEOUT = 22 * time.Second
|
||||
ANNOUNCE_INTERVAL = 1600 * time.Millisecond
|
||||
PEER_JOB_INTERVAL = 4 * time.Second
|
||||
MCAST_ECHO_TIMEOUT = 6500 * time.Millisecond
|
||||
|
||||
SCOPE_LINK = "2"
|
||||
SCOPE_ADMIN = "4"
|
||||
SCOPE_SITE = "5"
|
||||
SCOPE_ORGANISATION = "8"
|
||||
SCOPE_GLOBAL = "e"
|
||||
|
||||
MCAST_ADDR_TYPE_PERMANENT = "0"
|
||||
MCAST_ADDR_TYPE_TEMPORARY = "1"
|
||||
)
|
||||
|
||||
type AutoInterface struct {
|
||||
BaseInterface
|
||||
groupID []byte
|
||||
groupHash []byte
|
||||
discoveryPort int
|
||||
dataPort int
|
||||
discoveryScope string
|
||||
multicastAddrType string
|
||||
mcastDiscoveryAddr string
|
||||
ifacNetname string
|
||||
peers map[string]*Peer
|
||||
linkLocalAddrs []string
|
||||
adoptedInterfaces map[string]*AdoptedInterface
|
||||
interfaceServers map[string]*net.UDPConn
|
||||
discoveryServers map[string]*net.UDPConn
|
||||
multicastEchoes map[string]time.Time
|
||||
timedOutInterfaces map[string]time.Time
|
||||
allowedInterfaces []string
|
||||
ignoredInterfaces []string
|
||||
outboundConn *net.UDPConn
|
||||
announceInterval time.Duration
|
||||
peerJobInterval time.Duration
|
||||
peeringTimeout time.Duration
|
||||
mcastEchoTimeout time.Duration
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
type AdoptedInterface struct {
|
||||
name string
|
||||
linkLocalAddr string
|
||||
index int
|
||||
}
|
||||
|
||||
type Peer struct {
|
||||
ifaceName string
|
||||
lastHeard time.Time
|
||||
addr *net.UDPAddr
|
||||
}
|
||||
|
||||
func NewAutoInterface(name string, config *common.InterfaceConfig) (*AutoInterface, error) {
|
||||
groupID := DEFAULT_GROUP_ID
|
||||
if config.GroupID != "" {
|
||||
groupID = config.GroupID
|
||||
}
|
||||
|
||||
discoveryScope := SCOPE_LINK
|
||||
if config.DiscoveryScope != "" {
|
||||
discoveryScope = normalizeScope(config.DiscoveryScope)
|
||||
}
|
||||
|
||||
multicastAddrType := MCAST_ADDR_TYPE_TEMPORARY
|
||||
|
||||
discoveryPort := DEFAULT_DISCOVERY_PORT
|
||||
if config.DiscoveryPort != 0 {
|
||||
discoveryPort = config.DiscoveryPort
|
||||
}
|
||||
|
||||
dataPort := DEFAULT_DATA_PORT
|
||||
if config.DataPort != 0 {
|
||||
dataPort = config.DataPort
|
||||
}
|
||||
|
||||
groupHash := sha256.Sum256([]byte(groupID))
|
||||
|
||||
ifacNetname := hex.EncodeToString(groupHash[:])[:16]
|
||||
mcastAddr := fmt.Sprintf("ff%s%s::%s", discoveryScope, multicastAddrType, ifacNetname)
|
||||
|
||||
ai := &AutoInterface{
|
||||
BaseInterface: BaseInterface{
|
||||
Name: name,
|
||||
Mode: common.IF_MODE_FULL,
|
||||
Type: common.IF_TYPE_AUTO,
|
||||
Online: false,
|
||||
Enabled: config.Enabled,
|
||||
Detached: false,
|
||||
IN: true,
|
||||
OUT: false,
|
||||
MTU: HW_MTU,
|
||||
Bitrate: BITRATE_GUESS,
|
||||
},
|
||||
groupID: []byte(groupID),
|
||||
groupHash: groupHash[:],
|
||||
discoveryPort: discoveryPort,
|
||||
dataPort: dataPort,
|
||||
discoveryScope: discoveryScope,
|
||||
multicastAddrType: multicastAddrType,
|
||||
mcastDiscoveryAddr: mcastAddr,
|
||||
ifacNetname: ifacNetname,
|
||||
peers: make(map[string]*Peer),
|
||||
linkLocalAddrs: make([]string, 0),
|
||||
adoptedInterfaces: make(map[string]*AdoptedInterface),
|
||||
interfaceServers: make(map[string]*net.UDPConn),
|
||||
discoveryServers: make(map[string]*net.UDPConn),
|
||||
multicastEchoes: make(map[string]time.Time),
|
||||
timedOutInterfaces: make(map[string]time.Time),
|
||||
allowedInterfaces: make([]string, 0),
|
||||
ignoredInterfaces: make([]string, 0),
|
||||
announceInterval: ANNOUNCE_INTERVAL,
|
||||
peerJobInterval: PEER_JOB_INTERVAL,
|
||||
peeringTimeout: PEERING_TIMEOUT,
|
||||
mcastEchoTimeout: MCAST_ECHO_TIMEOUT,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "AutoInterface configured", "name", name, "group", groupID, "mcast_addr", mcastAddr)
|
||||
return ai, nil
|
||||
}
|
||||
|
||||
func normalizeScope(scope string) string {
|
||||
switch scope {
|
||||
case "link", "2":
|
||||
return SCOPE_LINK
|
||||
case "admin", "4":
|
||||
return SCOPE_ADMIN
|
||||
case "site", "5":
|
||||
return SCOPE_SITE
|
||||
case "organisation", "organization", "8":
|
||||
return SCOPE_ORGANISATION
|
||||
case "global", "e":
|
||||
return SCOPE_GLOBAL
|
||||
default:
|
||||
return SCOPE_LINK
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMulticastType(mtype string) string {
|
||||
switch mtype {
|
||||
case "permanent", "0":
|
||||
return MCAST_ADDR_TYPE_PERMANENT
|
||||
case "temporary", "1":
|
||||
return MCAST_ADDR_TYPE_TEMPORARY
|
||||
default:
|
||||
return MCAST_ADDR_TYPE_TEMPORARY
|
||||
}
|
||||
}
|
||||
|
||||
func (ai *AutoInterface) Start() error {
|
||||
ai.Mutex.Lock()
|
||||
// Only recreate done if it's nil or was closed
|
||||
select {
|
||||
case <-ai.done:
|
||||
ai.done = make(chan struct{})
|
||||
ai.stopOnce = sync.Once{}
|
||||
default:
|
||||
if ai.done == nil {
|
||||
ai.done = make(chan struct{})
|
||||
ai.stopOnce = sync.Once{}
|
||||
}
|
||||
}
|
||||
ai.Mutex.Unlock()
|
||||
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list interfaces: %v", err)
|
||||
}
|
||||
|
||||
for _, iface := range interfaces {
|
||||
if ai.shouldIgnoreInterface(iface.Name) {
|
||||
debug.Log(debug.DEBUG_TRACE, "Ignoring interface", "name", iface.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(ai.allowedInterfaces) > 0 && !ai.isAllowedInterface(iface.Name) {
|
||||
debug.Log(debug.DEBUG_TRACE, "Interface not in allowed list", "name", iface.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
ifaceCopy := iface
|
||||
// bearer:disable go_gosec_memory_memory_aliasing
|
||||
if err := ai.configureInterface(&ifaceCopy); err != nil {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Failed to configure interface", "name", iface.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if len(ai.adoptedInterfaces) == 0 {
|
||||
return fmt.Errorf("no suitable interfaces found")
|
||||
}
|
||||
|
||||
ai.Online = true
|
||||
ai.IN = true
|
||||
ai.OUT = true
|
||||
|
||||
go ai.peerJobs()
|
||||
go ai.announceLoop()
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "AutoInterface started", "adopted", len(ai.adoptedInterfaces))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ai *AutoInterface) shouldIgnoreInterface(name string) bool {
|
||||
ignoreList := []string{"lo", "lo0", "tun0", "awdl0", "llw0", "en5", "dummy0"}
|
||||
|
||||
for _, ignored := range ai.ignoredInterfaces {
|
||||
if name == ignored {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, ignored := range ignoreList {
|
||||
if name == ignored {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (ai *AutoInterface) isAllowedInterface(name string) bool {
|
||||
for _, allowed := range ai.allowedInterfaces {
|
||||
if name == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (ai *AutoInterface) configureInterface(iface *net.Interface) error {
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
return fmt.Errorf("interface is down")
|
||||
}
|
||||
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
return fmt.Errorf("loopback interface")
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var linkLocalAddr string
|
||||
for _, addr := range addrs {
|
||||
if ipnet, ok := addr.(*net.IPNet); ok {
|
||||
if ipnet.IP.To4() == nil && ipnet.IP.IsLinkLocalUnicast() {
|
||||
linkLocalAddr = ipnet.IP.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if linkLocalAddr == "" {
|
||||
return fmt.Errorf("no link-local IPv6 address found")
|
||||
}
|
||||
|
||||
ai.Mutex.Lock()
|
||||
ai.adoptedInterfaces[iface.Name] = &AdoptedInterface{
|
||||
name: iface.Name,
|
||||
linkLocalAddr: linkLocalAddr,
|
||||
index: iface.Index,
|
||||
}
|
||||
ai.linkLocalAddrs = append(ai.linkLocalAddrs, linkLocalAddr)
|
||||
ai.multicastEchoes[iface.Name] = time.Now()
|
||||
ai.Mutex.Unlock()
|
||||
|
||||
if err := ai.startDiscoveryListener(iface); err != nil {
|
||||
return fmt.Errorf("failed to start discovery listener: %v", err)
|
||||
}
|
||||
|
||||
if err := ai.startDataListener(iface); err != nil {
|
||||
return fmt.Errorf("failed to start data listener: %v", err)
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "Configured interface", "name", iface.Name, "addr", linkLocalAddr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ai *AutoInterface) startDiscoveryListener(iface *net.Interface) error {
|
||||
addr := &net.UDPAddr{
|
||||
IP: net.ParseIP(ai.mcastDiscoveryAddr),
|
||||
Port: ai.discoveryPort,
|
||||
Zone: iface.Name,
|
||||
}
|
||||
|
||||
conn, err := net.ListenMulticastUDP("udp6", iface, addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := conn.SetReadBuffer(common.NUM_1024); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to set discovery read buffer", "error", err)
|
||||
}
|
||||
|
||||
ai.Mutex.Lock()
|
||||
ai.discoveryServers[iface.Name] = conn
|
||||
ai.Mutex.Unlock()
|
||||
|
||||
go ai.handleDiscovery(conn, iface.Name)
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Discovery listener started", "interface", iface.Name, "addr", ai.mcastDiscoveryAddr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ai *AutoInterface) startDataListener(iface *net.Interface) error {
|
||||
adoptedIface, exists := ai.adoptedInterfaces[iface.Name]
|
||||
if !exists {
|
||||
return fmt.Errorf("interface not adopted")
|
||||
}
|
||||
|
||||
addr := &net.UDPAddr{
|
||||
IP: net.ParseIP(adoptedIface.linkLocalAddr),
|
||||
Port: ai.dataPort,
|
||||
Zone: iface.Name,
|
||||
}
|
||||
|
||||
conn, err := net.ListenUDP("udp6", addr)
|
||||
if err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to listen on data port", "addr", addr, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := conn.SetReadBuffer(ai.MTU); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to set data read buffer", "error", err)
|
||||
}
|
||||
|
||||
ai.Mutex.Lock()
|
||||
ai.interfaceServers[iface.Name] = conn
|
||||
ai.Mutex.Unlock()
|
||||
|
||||
go ai.handleData(conn, iface.Name)
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Data listener started", "interface", iface.Name, "addr", addr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ai *AutoInterface) handleDiscovery(conn *net.UDPConn, ifaceName string) {
|
||||
buf := make([]byte, common.NUM_1024)
|
||||
for {
|
||||
ai.Mutex.RLock()
|
||||
done := ai.done
|
||||
ai.Mutex.RUnlock()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
n, remoteAddr, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
if ai.IsOnline() {
|
||||
debug.Log(debug.DEBUG_ERROR, "Discovery read error", "interface", ifaceName, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if n >= len(ai.groupHash) {
|
||||
receivedHash := buf[:len(ai.groupHash)]
|
||||
if bytes.Equal(receivedHash, ai.groupHash) {
|
||||
ai.handlePeerAnnounce(remoteAddr, ifaceName)
|
||||
} else {
|
||||
debug.Log(debug.DEBUG_TRACE, "Received discovery with mismatched group hash", "interface", ifaceName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ai *AutoInterface) handleData(conn *net.UDPConn, ifaceName string) {
|
||||
buf := make([]byte, ai.GetMTU())
|
||||
for {
|
||||
ai.Mutex.RLock()
|
||||
done := ai.done
|
||||
ai.Mutex.RUnlock()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
n, _, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
if ai.IsOnline() {
|
||||
debug.Log(debug.DEBUG_ERROR, "Data read error", "interface", ifaceName, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if callback := ai.GetPacketCallback(); callback != nil {
|
||||
callback(buf[:n], ai)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ai *AutoInterface) handlePeerAnnounce(addr *net.UDPAddr, ifaceName string) {
|
||||
ai.Mutex.Lock()
|
||||
defer ai.Mutex.Unlock()
|
||||
|
||||
peerIP := addr.IP.String()
|
||||
|
||||
for _, localAddr := range ai.linkLocalAddrs {
|
||||
if peerIP == localAddr {
|
||||
ai.multicastEchoes[ifaceName] = time.Now()
|
||||
debug.Log(debug.DEBUG_TRACE, "Received own multicast echo", "interface", ifaceName)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
peerKey := peerIP + "%" + ifaceName
|
||||
|
||||
if peer, exists := ai.peers[peerKey]; exists {
|
||||
peer.lastHeard = time.Now()
|
||||
debug.Log(debug.DEBUG_TRACE, "Updated peer", "peer", peerIP, "interface", ifaceName)
|
||||
} else {
|
||||
ai.peers[peerKey] = &Peer{
|
||||
ifaceName: ifaceName,
|
||||
lastHeard: time.Now(),
|
||||
addr: addr,
|
||||
}
|
||||
debug.Log(debug.DEBUG_INFO, "Discovered new peer", "peer", peerIP, "interface", ifaceName)
|
||||
}
|
||||
}
|
||||
|
||||
func (ai *AutoInterface) announceLoop() {
|
||||
ticker := time.NewTicker(ai.announceInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if !ai.IsOnline() {
|
||||
return
|
||||
}
|
||||
ai.sendPeerAnnounce()
|
||||
case <-ai.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ai *AutoInterface) sendPeerAnnounce() {
|
||||
ai.Mutex.RLock()
|
||||
defer ai.Mutex.RUnlock()
|
||||
|
||||
for ifaceName, adoptedIface := range ai.adoptedInterfaces {
|
||||
mcastAddr := &net.UDPAddr{
|
||||
IP: net.ParseIP(ai.mcastDiscoveryAddr),
|
||||
Port: ai.discoveryPort,
|
||||
Zone: ifaceName,
|
||||
}
|
||||
|
||||
if ai.outboundConn == nil {
|
||||
var err error
|
||||
ai.outboundConn, err = net.ListenUDP("udp6", &net.UDPAddr{Port: 0})
|
||||
if err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to create outbound socket", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := ai.outboundConn.WriteToUDP(ai.groupHash, mcastAddr); err != nil {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Failed to send peer announce", "interface", ifaceName, "error", err)
|
||||
} else {
|
||||
debug.Log(debug.DEBUG_TRACE, "Sent peer announce", "interface", adoptedIface.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ai *AutoInterface) peerJobs() {
|
||||
ticker := time.NewTicker(ai.peerJobInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if !ai.IsOnline() {
|
||||
return
|
||||
}
|
||||
|
||||
ai.Mutex.Lock()
|
||||
now := time.Now()
|
||||
|
||||
for peerKey, peer := range ai.peers {
|
||||
if now.Sub(peer.lastHeard) > ai.peeringTimeout {
|
||||
delete(ai.peers, peerKey)
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Removed timed out peer", "peer", peerKey)
|
||||
}
|
||||
}
|
||||
|
||||
for ifaceName, echoTime := range ai.multicastEchoes {
|
||||
if now.Sub(echoTime) > ai.mcastEchoTimeout {
|
||||
if _, exists := ai.timedOutInterfaces[ifaceName]; !exists {
|
||||
debug.Log(debug.DEBUG_INFO, "Interface timed out", "interface", ifaceName)
|
||||
ai.timedOutInterfaces[ifaceName] = now
|
||||
}
|
||||
} else {
|
||||
delete(ai.timedOutInterfaces, ifaceName)
|
||||
}
|
||||
}
|
||||
|
||||
ai.Mutex.Unlock()
|
||||
case <-ai.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ai *AutoInterface) Send(data []byte, address string) error {
|
||||
if !ai.IsOnline() {
|
||||
return fmt.Errorf("interface offline")
|
||||
}
|
||||
|
||||
ai.Mutex.RLock()
|
||||
defer ai.Mutex.RUnlock()
|
||||
|
||||
if len(ai.peers) == 0 {
|
||||
debug.Log(debug.DEBUG_TRACE, "No peers available for sending")
|
||||
return nil
|
||||
}
|
||||
|
||||
if ai.outboundConn == nil {
|
||||
var err error
|
||||
ai.outboundConn, err = net.ListenUDP("udp6", &net.UDPAddr{Port: 0})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create outbound socket: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
sentCount := 0
|
||||
for _, peer := range ai.peers {
|
||||
targetAddr := &net.UDPAddr{
|
||||
IP: peer.addr.IP,
|
||||
Port: ai.dataPort,
|
||||
Zone: peer.ifaceName,
|
||||
}
|
||||
|
||||
if _, err := ai.outboundConn.WriteToUDP(data, targetAddr); err != nil {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Failed to send to peer", "interface", peer.ifaceName, "error", err)
|
||||
continue
|
||||
}
|
||||
sentCount++
|
||||
}
|
||||
|
||||
if sentCount > 0 {
|
||||
debug.Log(debug.DEBUG_TRACE, "Sent data to peers", "count", sentCount, "bytes", len(data))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ai *AutoInterface) Stop() error {
|
||||
ai.Mutex.Lock()
|
||||
ai.Online = false
|
||||
ai.IN = false
|
||||
ai.OUT = false
|
||||
|
||||
for _, server := range ai.interfaceServers {
|
||||
server.Close() // #nosec G104
|
||||
}
|
||||
|
||||
for _, server := range ai.discoveryServers {
|
||||
server.Close() // #nosec G104
|
||||
}
|
||||
|
||||
if ai.outboundConn != nil {
|
||||
ai.outboundConn.Close() // #nosec G104
|
||||
}
|
||||
ai.Mutex.Unlock()
|
||||
|
||||
ai.stopOnce.Do(func() {
|
||||
if ai.done != nil {
|
||||
close(ai.done)
|
||||
}
|
||||
})
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "AutoInterface stopped")
|
||||
return nil
|
||||
}
|
||||
295
pkg/interfaces/auto_test.go
Normal file
295
pkg/interfaces/auto_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
)
|
||||
|
||||
func TestNewAutoInterface(t *testing.T) {
|
||||
t.Run("DefaultConfig", func(t *testing.T) {
|
||||
config := &common.InterfaceConfig{Enabled: true}
|
||||
ai, err := NewAutoInterface("autoDefault", config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAutoInterface failed with default config: %v", err)
|
||||
}
|
||||
if ai == nil {
|
||||
t.Fatal("NewAutoInterface returned nil with default config")
|
||||
}
|
||||
|
||||
if ai.GetName() != "autoDefault" {
|
||||
t.Errorf("GetName() = %s; want autoDefault", ai.GetName())
|
||||
}
|
||||
if ai.GetType() != common.IF_TYPE_AUTO {
|
||||
t.Errorf("GetType() = %v; want %v", ai.GetType(), common.IF_TYPE_AUTO)
|
||||
}
|
||||
if ai.discoveryPort != DEFAULT_DISCOVERY_PORT {
|
||||
t.Errorf("discoveryPort = %d; want %d", ai.discoveryPort, DEFAULT_DISCOVERY_PORT)
|
||||
}
|
||||
if ai.dataPort != DEFAULT_DATA_PORT {
|
||||
t.Errorf("dataPort = %d; want %d", ai.dataPort, DEFAULT_DATA_PORT)
|
||||
}
|
||||
if string(ai.groupID) != "reticulum" {
|
||||
t.Errorf("groupID = %s; want reticulum", string(ai.groupID))
|
||||
}
|
||||
if ai.discoveryScope != SCOPE_LINK {
|
||||
t.Errorf("discoveryScope = %s; want %s", ai.discoveryScope, SCOPE_LINK)
|
||||
}
|
||||
if len(ai.peers) != 0 {
|
||||
t.Errorf("peers map not empty initially")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CustomConfig", func(t *testing.T) {
|
||||
config := &common.InterfaceConfig{
|
||||
Enabled: true,
|
||||
DiscoveryPort: 12345,
|
||||
DataPort: 54321,
|
||||
GroupID: "customGroup",
|
||||
}
|
||||
ai, err := NewAutoInterface("autoCustom", config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAutoInterface failed with custom config: %v", err)
|
||||
}
|
||||
if ai == nil {
|
||||
t.Fatal("NewAutoInterface returned nil with custom config")
|
||||
}
|
||||
|
||||
if ai.discoveryPort != 12345 {
|
||||
t.Errorf("discoveryPort = %d; want 12345", ai.discoveryPort)
|
||||
}
|
||||
if ai.dataPort != 54321 {
|
||||
t.Errorf("dataPort = %d; want 54321", ai.dataPort)
|
||||
}
|
||||
if string(ai.groupID) != "customGroup" {
|
||||
t.Errorf("groupID = %s; want customGroup", string(ai.groupID))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// mockAutoInterface embeds AutoInterface but overrides methods that start goroutines
|
||||
type mockAutoInterface struct {
|
||||
*AutoInterface
|
||||
}
|
||||
|
||||
func newMockAutoInterface(name string, config *common.InterfaceConfig) (*mockAutoInterface, error) {
|
||||
ai, err := NewAutoInterface(name, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize maps that would normally be initialized in Start()
|
||||
ai.peers = make(map[string]*Peer)
|
||||
ai.linkLocalAddrs = make([]string, 0)
|
||||
ai.adoptedInterfaces = make(map[string]*AdoptedInterface)
|
||||
ai.interfaceServers = make(map[string]*net.UDPConn)
|
||||
ai.discoveryServers = make(map[string]*net.UDPConn)
|
||||
ai.multicastEchoes = make(map[string]time.Time)
|
||||
ai.timedOutInterfaces = make(map[string]time.Time)
|
||||
|
||||
return &mockAutoInterface{AutoInterface: ai}, nil
|
||||
}
|
||||
|
||||
func (m *mockAutoInterface) Start() error {
|
||||
// Don't start any goroutines
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAutoInterface) Stop() error {
|
||||
// Don't try to close connections that were never opened
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockHandlePeerAnnounce is a test-only method that doesn't handle its own locking
|
||||
func (m *mockAutoInterface) mockHandlePeerAnnounce(addr *net.UDPAddr, ifaceName string) {
|
||||
peerAddr := addr.IP.String() + "%" + addr.Zone
|
||||
|
||||
for _, localAddr := range m.linkLocalAddrs {
|
||||
if peerAddr == localAddr {
|
||||
m.multicastEchoes[ifaceName] = time.Now()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if _, exists := m.peers[peerAddr]; !exists {
|
||||
m.peers[peerAddr] = &Peer{
|
||||
ifaceName: ifaceName,
|
||||
lastHeard: time.Now(),
|
||||
}
|
||||
} else {
|
||||
m.peers[peerAddr].lastHeard = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoInterfacePeerManagement(t *testing.T) {
|
||||
// Use a shorter timeout for testing
|
||||
testTimeout := 100 * time.Millisecond
|
||||
|
||||
config := &common.InterfaceConfig{Enabled: true}
|
||||
ai, err := newMockAutoInterface("autoPeerTest", config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create mock interface: %v", err)
|
||||
}
|
||||
|
||||
// Create a done channel to signal goroutine cleanup
|
||||
done := make(chan struct{})
|
||||
|
||||
// Start peer management with done channel
|
||||
go func() {
|
||||
ticker := time.NewTicker(testTimeout)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
ai.Mutex.Lock()
|
||||
now := time.Now()
|
||||
for addr, peer := range ai.peers {
|
||||
if now.Sub(peer.lastHeard) > testTimeout {
|
||||
delete(ai.peers, addr)
|
||||
}
|
||||
}
|
||||
ai.Mutex.Unlock()
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Ensure cleanup
|
||||
defer func() {
|
||||
close(done)
|
||||
ai.Stop()
|
||||
}()
|
||||
|
||||
// Simulate receiving peer announces
|
||||
peer1AddrStr := "fe80::1%eth0"
|
||||
peer2AddrStr := "fe80::2%eth0"
|
||||
localAddrStr := "fe80::aaaa%eth0" // Simulate a local address
|
||||
|
||||
peer1Addr := &net.UDPAddr{IP: net.ParseIP("fe80::1"), Zone: "eth0"}
|
||||
peer2Addr := &net.UDPAddr{IP: net.ParseIP("fe80::2"), Zone: "eth0"}
|
||||
localAddr := &net.UDPAddr{IP: net.ParseIP("fe80::aaaa"), Zone: "eth0"}
|
||||
|
||||
ai.Mutex.Lock()
|
||||
ai.linkLocalAddrs = append(ai.linkLocalAddrs, localAddrStr)
|
||||
ai.Mutex.Unlock()
|
||||
|
||||
t.Run("AddPeer1", func(t *testing.T) {
|
||||
ai.Mutex.Lock()
|
||||
ai.mockHandlePeerAnnounce(peer1Addr, "eth0")
|
||||
ai.Mutex.Unlock()
|
||||
|
||||
// Give a small amount of time for the peer to be processed
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
ai.Mutex.RLock()
|
||||
count := len(ai.peers)
|
||||
peer, exists := ai.peers[peer1AddrStr]
|
||||
var ifaceName string
|
||||
if exists {
|
||||
ifaceName = peer.ifaceName
|
||||
}
|
||||
ai.Mutex.RUnlock()
|
||||
|
||||
if count != 1 {
|
||||
t.Fatalf("Expected 1 peer, got %d", count)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatalf("Peer %s not found in map", peer1AddrStr)
|
||||
}
|
||||
if ifaceName != "eth0" {
|
||||
t.Errorf("Peer %s interface name = %s; want eth0", peer1AddrStr, ifaceName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AddPeer2", func(t *testing.T) {
|
||||
ai.Mutex.Lock()
|
||||
ai.mockHandlePeerAnnounce(peer2Addr, "eth0")
|
||||
ai.Mutex.Unlock()
|
||||
|
||||
// Give a small amount of time for the peer to be processed
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
ai.Mutex.RLock()
|
||||
count := len(ai.peers)
|
||||
_, exists := ai.peers[peer2AddrStr]
|
||||
ai.Mutex.RUnlock()
|
||||
|
||||
if count != 2 {
|
||||
t.Fatalf("Expected 2 peers, got %d", count)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatalf("Peer %s not found in map", peer2AddrStr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IgnoreLocalAnnounce", func(t *testing.T) {
|
||||
ai.Mutex.Lock()
|
||||
ai.mockHandlePeerAnnounce(localAddr, "eth0")
|
||||
ai.Mutex.Unlock()
|
||||
|
||||
// Give a small amount of time for the peer to be processed
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
ai.Mutex.RLock()
|
||||
count := len(ai.peers)
|
||||
ai.Mutex.RUnlock()
|
||||
|
||||
if count != 2 {
|
||||
t.Fatalf("Expected 2 peers after local announce, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UpdatePeerTimestamp", func(t *testing.T) {
|
||||
ai.Mutex.RLock()
|
||||
peer, exists := ai.peers[peer1AddrStr]
|
||||
var initialTime time.Time
|
||||
if exists {
|
||||
initialTime = peer.lastHeard
|
||||
}
|
||||
ai.Mutex.RUnlock()
|
||||
|
||||
if !exists {
|
||||
t.Fatalf("Peer %s not found before timestamp update", peer1AddrStr)
|
||||
}
|
||||
|
||||
ai.Mutex.Lock()
|
||||
ai.mockHandlePeerAnnounce(peer1Addr, "eth0")
|
||||
ai.Mutex.Unlock()
|
||||
|
||||
// Give a small amount of time for the peer to be processed
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
ai.Mutex.RLock()
|
||||
peer, exists = ai.peers[peer1AddrStr]
|
||||
var updatedTime time.Time
|
||||
if exists {
|
||||
updatedTime = peer.lastHeard
|
||||
}
|
||||
ai.Mutex.RUnlock()
|
||||
|
||||
if !exists {
|
||||
t.Fatalf("Peer %s not found after timestamp update", peer1AddrStr)
|
||||
}
|
||||
|
||||
if !updatedTime.After(initialTime) {
|
||||
t.Errorf("Peer timestamp was not updated after receiving another announce")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PeerTimeout", func(t *testing.T) {
|
||||
// Wait for peer timeout
|
||||
time.Sleep(testTimeout * 2)
|
||||
|
||||
ai.Mutex.RLock()
|
||||
count := len(ai.peers)
|
||||
ai.Mutex.RUnlock()
|
||||
|
||||
if count != 0 {
|
||||
t.Errorf("Expected all peers to timeout, got %d peers", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,51 +1,135 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
"encoding/binary"
|
||||
|
||||
"github.com/Sudo-Ivan/reticulum-go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
)
|
||||
|
||||
const (
|
||||
BITRATE_MINIMUM = 5 // Minimum required bitrate in bits/sec
|
||||
BITRATE_MINIMUM = 1200 // Minimum bitrate in bits/second
|
||||
MODE_FULL = 0x01
|
||||
|
||||
// Interface modes
|
||||
MODE_GATEWAY = 0x02
|
||||
MODE_ACCESS_POINT = 0x03
|
||||
MODE_ROAMING = 0x04
|
||||
MODE_BOUNDARY = 0x05
|
||||
|
||||
// Interface types
|
||||
TYPE_UDP = 0x01
|
||||
TYPE_TCP = 0x02
|
||||
|
||||
PROPAGATION_RATE = 0.02 // 2% of interface bandwidth
|
||||
)
|
||||
|
||||
// BaseInterface embeds common.BaseInterface and implements common.Interface
|
||||
type Interface interface {
|
||||
GetName() string
|
||||
GetType() common.InterfaceType
|
||||
GetMode() common.InterfaceMode
|
||||
IsOnline() bool
|
||||
IsDetached() bool
|
||||
IsEnabled() bool
|
||||
Detach()
|
||||
Enable()
|
||||
Disable()
|
||||
Send(data []byte, addr string) error
|
||||
SetPacketCallback(common.PacketCallback)
|
||||
GetPacketCallback() common.PacketCallback
|
||||
ProcessIncoming([]byte)
|
||||
ProcessOutgoing([]byte) error
|
||||
SendPathRequest([]byte) error
|
||||
SendLinkPacket([]byte, []byte, time.Time) error
|
||||
Start() error
|
||||
Stop() error
|
||||
GetMTU() int
|
||||
GetConn() net.Conn
|
||||
GetBandwidthAvailable() bool
|
||||
common.NetworkInterface
|
||||
}
|
||||
|
||||
type BaseInterface struct {
|
||||
common.BaseInterface
|
||||
Name string
|
||||
Mode common.InterfaceMode
|
||||
Type common.InterfaceType
|
||||
Online bool
|
||||
Enabled bool
|
||||
Detached bool
|
||||
IN bool
|
||||
OUT bool
|
||||
MTU int
|
||||
Bitrate int64
|
||||
TxBytes uint64
|
||||
RxBytes uint64
|
||||
lastTx time.Time
|
||||
lastRx time.Time
|
||||
|
||||
Mutex sync.RWMutex
|
||||
packetCallback common.PacketCallback
|
||||
}
|
||||
|
||||
func NewBaseInterface(name string, ifType common.InterfaceType, enabled bool) BaseInterface {
|
||||
return BaseInterface{
|
||||
Name: name,
|
||||
Mode: common.IF_MODE_FULL,
|
||||
Type: ifType,
|
||||
Online: false,
|
||||
Enabled: enabled,
|
||||
Detached: false,
|
||||
IN: false,
|
||||
OUT: false,
|
||||
MTU: common.DEFAULT_MTU,
|
||||
Bitrate: BITRATE_MINIMUM,
|
||||
lastTx: time.Now(),
|
||||
lastRx: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (i *BaseInterface) SetPacketCallback(callback common.PacketCallback) {
|
||||
i.mutex.Lock()
|
||||
defer i.mutex.Unlock()
|
||||
i.Mutex.Lock()
|
||||
defer i.Mutex.Unlock()
|
||||
i.packetCallback = callback
|
||||
}
|
||||
|
||||
func (i *BaseInterface) GetPacketCallback() common.PacketCallback {
|
||||
i.Mutex.RLock()
|
||||
defer i.Mutex.RUnlock()
|
||||
return i.packetCallback
|
||||
}
|
||||
|
||||
func (i *BaseInterface) ProcessIncoming(data []byte) {
|
||||
i.mutex.RLock()
|
||||
i.Mutex.Lock()
|
||||
i.RxBytes += uint64(len(data))
|
||||
i.Mutex.Unlock()
|
||||
|
||||
i.Mutex.RLock()
|
||||
callback := i.packetCallback
|
||||
i.mutex.RUnlock()
|
||||
|
||||
i.Mutex.RUnlock()
|
||||
|
||||
if callback != nil {
|
||||
callback(data, i)
|
||||
}
|
||||
|
||||
i.RxBytes += uint64(len(data))
|
||||
}
|
||||
|
||||
func (i *BaseInterface) ProcessOutgoing(data []byte) error {
|
||||
i.TxBytes += uint64(len(data))
|
||||
return nil
|
||||
}
|
||||
if !i.Online || i.Detached {
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Interface cannot process outgoing packet - interface offline or detached", "name", i.Name)
|
||||
return fmt.Errorf("interface offline or detached")
|
||||
}
|
||||
|
||||
func (i *BaseInterface) Detach() {
|
||||
i.mutex.Lock()
|
||||
defer i.mutex.Unlock()
|
||||
i.Detached = true
|
||||
i.Online = false
|
||||
i.Mutex.Lock()
|
||||
i.TxBytes += uint64(len(data))
|
||||
i.Mutex.Unlock()
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Interface processed outgoing packet", "name", i.Name, "bytes", len(data), "total_tx", i.TxBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *BaseInterface) SendPathRequest(packet []byte) error {
|
||||
@@ -53,8 +137,8 @@ func (i *BaseInterface) SendPathRequest(packet []byte) error {
|
||||
return fmt.Errorf("interface offline or detached")
|
||||
}
|
||||
|
||||
frame := make([]byte, 0, len(packet)+2)
|
||||
frame = append(frame, 0x01)
|
||||
frame := make([]byte, 0, len(packet)+1)
|
||||
frame = append(frame, common.HEX_0x01)
|
||||
frame = append(frame, packet...)
|
||||
|
||||
return i.ProcessOutgoing(frame)
|
||||
@@ -66,14 +150,158 @@ func (i *BaseInterface) SendLinkPacket(dest []byte, data []byte, timestamp time.
|
||||
}
|
||||
|
||||
frame := make([]byte, 0, len(dest)+len(data)+9)
|
||||
frame = append(frame, 0x02)
|
||||
frame = append(frame, common.HEX_0x02)
|
||||
frame = append(frame, dest...)
|
||||
|
||||
|
||||
ts := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(ts, uint64(timestamp.Unix()))
|
||||
binary.BigEndian.PutUint64(ts, uint64(timestamp.Unix())) // #nosec G115
|
||||
frame = append(frame, ts...)
|
||||
|
||||
frame = append(frame, data...)
|
||||
|
||||
return i.ProcessOutgoing(frame)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *BaseInterface) Detach() {
|
||||
i.Mutex.Lock()
|
||||
defer i.Mutex.Unlock()
|
||||
i.Detached = true
|
||||
i.Online = false
|
||||
}
|
||||
|
||||
func (i *BaseInterface) IsEnabled() bool {
|
||||
i.Mutex.RLock()
|
||||
defer i.Mutex.RUnlock()
|
||||
return i.Enabled && i.Online && !i.Detached
|
||||
}
|
||||
|
||||
func (i *BaseInterface) Enable() {
|
||||
i.Mutex.Lock()
|
||||
defer i.Mutex.Unlock()
|
||||
|
||||
prevState := i.Enabled
|
||||
i.Enabled = true
|
||||
i.Online = true
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "Interface state changed", "name", i.Name, "enabled_prev", prevState, "enabled", i.Enabled, "online_prev", !i.Online, "online", i.Online)
|
||||
}
|
||||
|
||||
func (i *BaseInterface) Disable() {
|
||||
i.Mutex.Lock()
|
||||
defer i.Mutex.Unlock()
|
||||
i.Enabled = false
|
||||
i.Online = false
|
||||
debug.Log(debug.DEBUG_ERROR, "Interface disabled and offline", "name", i.Name)
|
||||
}
|
||||
|
||||
func (i *BaseInterface) GetName() string {
|
||||
return i.Name
|
||||
}
|
||||
|
||||
func (i *BaseInterface) GetType() common.InterfaceType {
|
||||
return i.Type
|
||||
}
|
||||
|
||||
func (i *BaseInterface) GetMode() common.InterfaceMode {
|
||||
return i.Mode
|
||||
}
|
||||
|
||||
func (i *BaseInterface) GetMTU() int {
|
||||
return i.MTU
|
||||
}
|
||||
|
||||
func (i *BaseInterface) IsOnline() bool {
|
||||
i.Mutex.RLock()
|
||||
defer i.Mutex.RUnlock()
|
||||
return i.Online
|
||||
}
|
||||
|
||||
func (i *BaseInterface) IsDetached() bool {
|
||||
i.Mutex.RLock()
|
||||
defer i.Mutex.RUnlock()
|
||||
return i.Detached
|
||||
}
|
||||
|
||||
func (i *BaseInterface) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *BaseInterface) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *BaseInterface) Send(data []byte, address string) error {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Interface sending bytes", "name", i.Name, "bytes", len(data), "address", address)
|
||||
|
||||
err := i.ProcessOutgoing(data)
|
||||
if err != nil {
|
||||
debug.Log(debug.DEBUG_CRITICAL, "Interface failed to send data", "name", i.Name, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
i.updateBandwidthStats(uint64(len(data)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *BaseInterface) GetConn() net.Conn {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *BaseInterface) GetBandwidthAvailable() bool {
|
||||
i.Mutex.RLock()
|
||||
defer i.Mutex.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
timeSinceLastTx := now.Sub(i.lastTx)
|
||||
|
||||
if timeSinceLastTx > time.Second {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Interface bandwidth available", "name", i.Name, "idle_seconds", timeSinceLastTx.Seconds())
|
||||
return true
|
||||
}
|
||||
|
||||
bytesPerSec := float64(i.TxBytes) / timeSinceLastTx.Seconds()
|
||||
currentUsage := bytesPerSec * 8
|
||||
maxUsage := float64(i.Bitrate) * PROPAGATION_RATE
|
||||
|
||||
available := currentUsage < maxUsage
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Interface bandwidth stats", "name", i.Name, "current_bps", currentUsage, "max_bps", maxUsage, "usage_percent", (currentUsage/maxUsage)*100, "available", available)
|
||||
|
||||
return available
|
||||
}
|
||||
|
||||
func (i *BaseInterface) updateBandwidthStats(bytes uint64) {
|
||||
i.Mutex.Lock()
|
||||
defer i.Mutex.Unlock()
|
||||
|
||||
i.TxBytes += bytes
|
||||
i.lastTx = time.Now()
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Interface updated bandwidth stats", "name", i.Name, "tx_bytes", i.TxBytes, "last_tx", i.lastTx)
|
||||
}
|
||||
|
||||
type InterceptedInterface struct {
|
||||
Interface
|
||||
interceptor func([]byte, common.NetworkInterface) error
|
||||
originalSend func([]byte, string) error
|
||||
}
|
||||
|
||||
// Create constructor for intercepted interface
|
||||
func NewInterceptedInterface(base Interface, interceptor func([]byte, common.NetworkInterface) error) *InterceptedInterface {
|
||||
return &InterceptedInterface{
|
||||
Interface: base,
|
||||
interceptor: interceptor,
|
||||
originalSend: base.Send,
|
||||
}
|
||||
}
|
||||
|
||||
// Implement Send method for intercepted interface
|
||||
func (i *InterceptedInterface) Send(data []byte, addr string) error {
|
||||
// Call interceptor if provided
|
||||
if i.interceptor != nil && len(data) > 0 {
|
||||
if err := i.interceptor(data, i); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to intercept outgoing packet", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Call original send
|
||||
return i.originalSend(data, addr)
|
||||
}
|
||||
|
||||
229
pkg/interfaces/interface_test.go
Normal file
229
pkg/interfaces/interface_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
)
|
||||
|
||||
func TestBaseInterfaceStateChanges(t *testing.T) {
|
||||
bi := NewBaseInterface("test", common.IF_TYPE_TCP, false) // Start disabled
|
||||
|
||||
if bi.IsEnabled() {
|
||||
t.Error("Newly created disabled interface reports IsEnabled() == true")
|
||||
}
|
||||
if bi.IsOnline() {
|
||||
t.Error("Newly created disabled interface reports IsOnline() == true")
|
||||
}
|
||||
if bi.IsDetached() {
|
||||
t.Error("Newly created interface reports IsDetached() == true")
|
||||
}
|
||||
|
||||
bi.Enable()
|
||||
if !bi.IsEnabled() {
|
||||
t.Error("After Enable(), IsEnabled() == false")
|
||||
}
|
||||
if !bi.IsOnline() {
|
||||
t.Error("After Enable(), IsOnline() == false")
|
||||
}
|
||||
if bi.IsDetached() {
|
||||
t.Error("After Enable(), IsDetached() == true")
|
||||
}
|
||||
|
||||
bi.Detach()
|
||||
if bi.IsEnabled() {
|
||||
t.Error("After Detach(), IsEnabled() == true")
|
||||
}
|
||||
if bi.IsOnline() {
|
||||
t.Error("After Detach(), IsOnline() == true")
|
||||
}
|
||||
if !bi.IsDetached() {
|
||||
t.Error("After Detach(), IsDetached() == false")
|
||||
}
|
||||
|
||||
// Reset for Disable test
|
||||
bi = NewBaseInterface("test2", common.IF_TYPE_UDP, true) // Start enabled
|
||||
if !bi.Enabled { // Check the Enabled field directly first
|
||||
t.Error("Newly created enabled interface reports Enabled == false")
|
||||
}
|
||||
if bi.IsEnabled() { // IsEnabled should still be false because Online is false
|
||||
t.Error("Newly created enabled interface reports IsEnabled() == true before Enable() is called")
|
||||
}
|
||||
|
||||
bi.Enable() // Explicitly enable to set Online = true
|
||||
if !bi.IsEnabled() { // Now IsEnabled should be true
|
||||
t.Error("After Enable() on initially enabled interface, IsEnabled() == false")
|
||||
}
|
||||
|
||||
bi.Disable()
|
||||
if bi.Enabled { // Check Enabled field after Disable()
|
||||
t.Error("After Disable(), Enabled == true")
|
||||
}
|
||||
if bi.IsOnline() {
|
||||
t.Error("After Disable(), IsOnline() == true")
|
||||
}
|
||||
if bi.IsDetached() { // Disable doesn't detach
|
||||
t.Error("After Disable(), IsDetached() == true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterfaceGetters(t *testing.T) {
|
||||
bi := NewBaseInterface("getterTest", common.IF_TYPE_AUTO, true)
|
||||
|
||||
if bi.GetName() != "getterTest" {
|
||||
t.Errorf("GetName() = %s; want getterTest", bi.GetName())
|
||||
}
|
||||
if bi.GetType() != common.IF_TYPE_AUTO {
|
||||
t.Errorf("GetType() = %v; want %v", bi.GetType(), common.IF_TYPE_AUTO)
|
||||
}
|
||||
if bi.GetMode() != common.IF_MODE_FULL {
|
||||
t.Errorf("GetMode() = %v; want %v", bi.GetMode(), common.IF_MODE_FULL)
|
||||
}
|
||||
if bi.GetMTU() != common.DEFAULT_MTU { // Assuming default MTU
|
||||
t.Errorf("GetMTU() = %d; want %d", bi.GetMTU(), common.DEFAULT_MTU)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterfaceCallbacks(t *testing.T) {
|
||||
bi := NewBaseInterface("callbackTest", common.IF_TYPE_TCP, true)
|
||||
var wg sync.WaitGroup
|
||||
var callbackCalled bool
|
||||
|
||||
callback := func(data []byte, iface common.NetworkInterface) {
|
||||
if len(data) != 5 {
|
||||
t.Errorf("Callback received data length %d; want 5", len(data))
|
||||
}
|
||||
if iface.GetName() != "callbackTest" {
|
||||
t.Errorf("Callback received interface name %s; want callbackTest", iface.GetName())
|
||||
}
|
||||
callbackCalled = true
|
||||
wg.Done()
|
||||
}
|
||||
|
||||
bi.SetPacketCallback(callback)
|
||||
if bi.GetPacketCallback() == nil { // Cannot directly compare functions
|
||||
t.Error("GetPacketCallback() returned nil after SetPacketCallback()")
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go bi.ProcessIncoming([]byte{1, 2, 3, 4, 5}) // Run in goroutine as callback might block
|
||||
|
||||
// Wait for callback or timeout
|
||||
waitTimeout(&wg, 1*time.Second, t)
|
||||
|
||||
if !callbackCalled {
|
||||
t.Error("Packet callback was not called after ProcessIncoming")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseInterfaceStats(t *testing.T) {
|
||||
bi := NewBaseInterface("statsTest", common.IF_TYPE_UDP, true)
|
||||
bi.Enable() // Need to be Online for ProcessOutgoing
|
||||
|
||||
data1 := []byte{1, 2, 3}
|
||||
data2 := []byte{4, 5, 6, 7, 8}
|
||||
|
||||
bi.ProcessIncoming(data1)
|
||||
if bi.RxBytes != uint64(len(data1)) {
|
||||
t.Errorf("RxBytes = %d; want %d after first ProcessIncoming", bi.RxBytes, len(data1))
|
||||
}
|
||||
|
||||
bi.ProcessIncoming(data2)
|
||||
if bi.RxBytes != uint64(len(data1)+len(data2)) {
|
||||
t.Errorf("RxBytes = %d; want %d after second ProcessIncoming", bi.RxBytes, len(data1)+len(data2))
|
||||
}
|
||||
|
||||
// ProcessOutgoing only updates TxBytes in BaseInterface
|
||||
err := bi.ProcessOutgoing(data1)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessOutgoing failed: %v", err)
|
||||
}
|
||||
if bi.TxBytes != uint64(len(data1)) {
|
||||
t.Errorf("TxBytes = %d; want %d after first ProcessOutgoing", bi.TxBytes, len(data1))
|
||||
}
|
||||
|
||||
err = bi.ProcessOutgoing(data2)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessOutgoing failed: %v", err)
|
||||
}
|
||||
if bi.TxBytes != uint64(len(data1)+len(data2)) {
|
||||
t.Errorf("TxBytes = %d; want %d after second ProcessOutgoing", bi.TxBytes, len(data1)+len(data2))
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to wait for a WaitGroup with a timeout
|
||||
func waitTimeout(wg *sync.WaitGroup, timeout time.Duration, t *testing.T) {
|
||||
c := make(chan struct{})
|
||||
go func() {
|
||||
defer close(c)
|
||||
wg.Wait()
|
||||
}()
|
||||
select {
|
||||
case <-c:
|
||||
// Completed normally
|
||||
case <-time.After(timeout):
|
||||
t.Fatal("Timed out waiting for WaitGroup")
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal mock interface for InterceptedInterface test
|
||||
type mockInterface struct {
|
||||
BaseInterface
|
||||
sendCalled bool
|
||||
sendData []byte
|
||||
}
|
||||
|
||||
func (m *mockInterface) Send(data []byte, addr string) error {
|
||||
m.sendCalled = true
|
||||
m.sendData = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockInterface) GetType() common.InterfaceType { return common.IF_TYPE_NONE }
|
||||
func (m *mockInterface) GetMode() common.InterfaceMode { return common.IF_MODE_FULL }
|
||||
func (m *mockInterface) ProcessIncoming(data []byte) {}
|
||||
func (m *mockInterface) ProcessOutgoing(data []byte) error { return nil }
|
||||
func (m *mockInterface) SendPathRequest([]byte) error { return nil }
|
||||
func (m *mockInterface) SendLinkPacket([]byte, []byte, time.Time) error { return nil }
|
||||
func (m *mockInterface) Start() error { return nil }
|
||||
func (m *mockInterface) Stop() error { return nil }
|
||||
func (m *mockInterface) GetConn() net.Conn { return nil }
|
||||
func (m *mockInterface) GetBandwidthAvailable() bool { return true }
|
||||
|
||||
func TestInterceptedInterface(t *testing.T) {
|
||||
mockBase := &mockInterface{}
|
||||
var interceptorCalled bool
|
||||
var interceptedData []byte
|
||||
|
||||
interceptor := func(data []byte, iface common.NetworkInterface) error {
|
||||
interceptorCalled = true
|
||||
interceptedData = data
|
||||
return nil
|
||||
}
|
||||
|
||||
intercepted := NewInterceptedInterface(mockBase, interceptor)
|
||||
|
||||
testData := []byte("intercept me")
|
||||
err := intercepted.Send(testData, "dummy_addr")
|
||||
if err != nil {
|
||||
t.Fatalf("Intercepted Send failed: %v", err)
|
||||
}
|
||||
|
||||
if !interceptorCalled {
|
||||
t.Error("Interceptor function was not called")
|
||||
}
|
||||
if !bytes.Equal(interceptedData, testData) {
|
||||
t.Errorf("Interceptor received data %x; want %x", interceptedData, testData)
|
||||
}
|
||||
|
||||
if !mockBase.sendCalled {
|
||||
t.Error("Original Send function was not called")
|
||||
}
|
||||
if !bytes.Equal(mockBase.sendData, testData) {
|
||||
t.Errorf("Original Send received data %x; want %x", mockBase.sendData, testData)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -17,103 +23,148 @@ const (
|
||||
KISS_TFEND = 0xDC
|
||||
KISS_TFESC = 0xDD
|
||||
|
||||
TCP_USER_TIMEOUT = 24
|
||||
TCP_PROBE_AFTER = 5
|
||||
TCP_PROBE_INTERVAL = 2
|
||||
TCP_PROBES = 12
|
||||
RECONNECT_WAIT = 5
|
||||
INITIAL_TIMEOUT = 5
|
||||
DEFAULT_MTU = 1064
|
||||
BITRATE_GUESS_VAL = 10 * 1000 * 1000
|
||||
RECONNECT_WAIT = 5
|
||||
INITIAL_TIMEOUT = 5
|
||||
INITIAL_BACKOFF = time.Second
|
||||
MAX_BACKOFF = time.Minute * 5
|
||||
|
||||
TCP_USER_TIMEOUT_SEC = 24
|
||||
TCP_PROBE_AFTER_SEC = 5
|
||||
TCP_PROBE_INTERVAL_SEC = 2
|
||||
TCP_PROBES_COUNT = 12
|
||||
TCP_CONNECT_TIMEOUT = 10 * time.Second
|
||||
TCP_MILLISECONDS = 1000
|
||||
|
||||
I2P_USER_TIMEOUT_SEC = 45
|
||||
I2P_PROBE_AFTER_SEC = 10
|
||||
I2P_PROBE_INTERVAL_SEC = 9
|
||||
I2P_PROBES_COUNT = 5
|
||||
|
||||
SO_KEEPALIVE_ENABLE = 1
|
||||
)
|
||||
|
||||
type TCPClientInterface struct {
|
||||
Interface
|
||||
conn net.Conn
|
||||
targetAddr string
|
||||
targetPort int
|
||||
kissFraming bool
|
||||
i2pTunneled bool
|
||||
initiator bool
|
||||
reconnecting bool
|
||||
neverConnected bool
|
||||
writing bool
|
||||
BaseInterface
|
||||
conn net.Conn
|
||||
targetAddr string
|
||||
targetPort int
|
||||
kissFraming bool
|
||||
i2pTunneled bool
|
||||
initiator bool
|
||||
reconnecting bool
|
||||
neverConnected bool
|
||||
writing bool
|
||||
maxReconnectTries int
|
||||
packetBuffer []byte
|
||||
packetType byte
|
||||
packetBuffer []byte
|
||||
packetType byte
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
func NewTCPClient(name string, targetAddr string, targetPort int, kissFraming bool, i2pTunneled bool) (*TCPClientInterface, error) {
|
||||
func NewTCPClientInterface(name string, targetHost string, targetPort int, kissFraming bool, i2pTunneled bool, enabled bool) (*TCPClientInterface, error) {
|
||||
tc := &TCPClientInterface{
|
||||
Interface: Interface{
|
||||
Name: name,
|
||||
Mode: MODE_FULL,
|
||||
MTU: 1064,
|
||||
Bitrate: 10000000, // 10Mbps estimate
|
||||
},
|
||||
targetAddr: targetAddr,
|
||||
targetPort: targetPort,
|
||||
kissFraming: kissFraming,
|
||||
i2pTunneled: i2pTunneled,
|
||||
initiator: true,
|
||||
BaseInterface: NewBaseInterface(name, common.IF_TYPE_TCP, enabled),
|
||||
targetAddr: targetHost,
|
||||
targetPort: targetPort,
|
||||
kissFraming: kissFraming,
|
||||
i2pTunneled: i2pTunneled,
|
||||
initiator: true,
|
||||
maxReconnectTries: RECONNECT_WAIT * TCP_PROBES_COUNT,
|
||||
packetBuffer: make([]byte, 0),
|
||||
neverConnected: true,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
if err := tc.connect(true); err != nil {
|
||||
go tc.reconnect()
|
||||
} else {
|
||||
if enabled {
|
||||
addr := net.JoinHostPort(targetHost, fmt.Sprintf("%d", targetPort))
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tc.conn = conn
|
||||
tc.Online = true
|
||||
go tc.readLoop()
|
||||
}
|
||||
|
||||
return tc, nil
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) connect(initial bool) error {
|
||||
addr := fmt.Sprintf("%s:%d", tc.targetAddr, tc.targetPort)
|
||||
conn, err := net.DialTimeout("tcp", addr, time.Second*INITIAL_TIMEOUT)
|
||||
if err != nil {
|
||||
if initial {
|
||||
return fmt.Errorf("initial connection failed: %v", err)
|
||||
func (tc *TCPClientInterface) Start() error {
|
||||
tc.Mutex.Lock()
|
||||
if !tc.Enabled || tc.Detached {
|
||||
tc.Mutex.Unlock()
|
||||
return fmt.Errorf("interface not enabled or detached")
|
||||
}
|
||||
|
||||
if tc.conn != nil {
|
||||
tc.Online = true
|
||||
go tc.readLoop()
|
||||
tc.Mutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only recreate done if it's nil or was closed
|
||||
select {
|
||||
case <-tc.done:
|
||||
tc.done = make(chan struct{})
|
||||
tc.stopOnce = sync.Once{}
|
||||
default:
|
||||
if tc.done == nil {
|
||||
tc.done = make(chan struct{})
|
||||
tc.stopOnce = sync.Once{}
|
||||
}
|
||||
}
|
||||
tc.Mutex.Unlock()
|
||||
|
||||
addr := net.JoinHostPort(tc.targetAddr, fmt.Sprintf("%d", tc.targetPort))
|
||||
conn, err := net.DialTimeout("tcp", addr, TCP_CONNECT_TIMEOUT)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tc.Mutex.Lock()
|
||||
tc.conn = conn
|
||||
tc.Online = true
|
||||
tc.writing = false
|
||||
tc.neverConnected = false
|
||||
tc.Mutex.Unlock()
|
||||
|
||||
// Set TCP options
|
||||
if tcpConn, ok := conn.(*net.TCPConn); ok {
|
||||
tcpConn.SetNoDelay(true)
|
||||
tcpConn.SetKeepAlive(true)
|
||||
tcpConn.SetKeepAlivePeriod(time.Second * TCP_PROBE_INTERVAL)
|
||||
// Set platform-specific timeouts
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
if err := tc.setTimeoutsLinux(); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to set Linux TCP timeouts", "error", err)
|
||||
}
|
||||
case "darwin":
|
||||
if err := tc.setTimeoutsOSX(); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to set OSX TCP timeouts", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
tc.Mutex.Lock()
|
||||
tc.Online = true
|
||||
tc.Mutex.Unlock()
|
||||
|
||||
go tc.readLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) reconnect() {
|
||||
if tc.initiator && !tc.reconnecting {
|
||||
tc.reconnecting = true
|
||||
attempts := 0
|
||||
|
||||
for !tc.Online {
|
||||
time.Sleep(time.Second * RECONNECT_WAIT)
|
||||
attempts++
|
||||
|
||||
if tc.maxReconnectTries > 0 && attempts > tc.maxReconnectTries {
|
||||
tc.teardown()
|
||||
break
|
||||
}
|
||||
|
||||
if err := tc.connect(false); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
go tc.readLoop()
|
||||
break
|
||||
}
|
||||
|
||||
tc.reconnecting = false
|
||||
func (tc *TCPClientInterface) Stop() error {
|
||||
tc.Mutex.Lock()
|
||||
tc.Enabled = false
|
||||
tc.Online = false
|
||||
if tc.conn != nil {
|
||||
_ = tc.conn.Close()
|
||||
tc.conn = nil
|
||||
}
|
||||
tc.Mutex.Unlock()
|
||||
|
||||
tc.stopOnce.Do(func() {
|
||||
if tc.done != nil {
|
||||
close(tc.done)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) readLoop() {
|
||||
@@ -123,10 +174,30 @@ func (tc *TCPClientInterface) readLoop() {
|
||||
dataBuffer := make([]byte, 0)
|
||||
|
||||
for {
|
||||
n, err := tc.conn.Read(buffer)
|
||||
tc.Mutex.RLock()
|
||||
conn := tc.conn
|
||||
done := tc.done
|
||||
tc.Mutex.RUnlock()
|
||||
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
n, err := conn.Read(buffer)
|
||||
if err != nil {
|
||||
tc.Mutex.Lock()
|
||||
tc.Online = false
|
||||
if tc.initiator && !tc.Detached {
|
||||
detached := tc.Detached
|
||||
initiator := tc.initiator
|
||||
tc.Mutex.Unlock()
|
||||
|
||||
if initiator && !detached {
|
||||
go tc.reconnect()
|
||||
} else {
|
||||
tc.teardown()
|
||||
@@ -134,51 +205,29 @@ func (tc *TCPClientInterface) readLoop() {
|
||||
return
|
||||
}
|
||||
|
||||
tc.UpdateStats(uint64(n), true) // #nosec G115
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
b := buffer[i]
|
||||
|
||||
if tc.kissFraming {
|
||||
// KISS framing logic
|
||||
if inFrame && b == KISS_FEND {
|
||||
inFrame = false
|
||||
if b == HDLC_FLAG {
|
||||
if inFrame && len(dataBuffer) > 0 {
|
||||
tc.handlePacket(dataBuffer)
|
||||
dataBuffer = dataBuffer[:0]
|
||||
} else if b == KISS_FEND {
|
||||
inFrame = true
|
||||
} else if inFrame {
|
||||
if b == KISS_FESC {
|
||||
escape = true
|
||||
} else {
|
||||
if escape {
|
||||
if b == KISS_TFEND {
|
||||
b = KISS_FEND
|
||||
}
|
||||
if b == KISS_TFESC {
|
||||
b = KISS_FESC
|
||||
}
|
||||
escape = false
|
||||
}
|
||||
dataBuffer = append(dataBuffer, b)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// HDLC framing logic
|
||||
if inFrame && b == HDLC_FLAG {
|
||||
inFrame = false
|
||||
tc.handlePacket(dataBuffer)
|
||||
dataBuffer = dataBuffer[:0]
|
||||
} else if b == HDLC_FLAG {
|
||||
inFrame = true
|
||||
} else if inFrame {
|
||||
if b == HDLC_ESC {
|
||||
escape = true
|
||||
} else {
|
||||
if escape {
|
||||
b ^= HDLC_ESC_MASK
|
||||
escape = false
|
||||
}
|
||||
dataBuffer = append(dataBuffer, b)
|
||||
inFrame = !inFrame
|
||||
continue
|
||||
}
|
||||
|
||||
if inFrame {
|
||||
if b == HDLC_ESC {
|
||||
escape = true
|
||||
} else {
|
||||
if escape {
|
||||
b ^= HDLC_ESC_MASK
|
||||
escape = false
|
||||
}
|
||||
dataBuffer = append(dataBuffer, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,50 +236,75 @@ func (tc *TCPClientInterface) readLoop() {
|
||||
|
||||
func (tc *TCPClientInterface) handlePacket(data []byte) {
|
||||
if len(data) < 1 {
|
||||
debug.Log(debug.DEBUG_ALL, "Received invalid packet: empty")
|
||||
return
|
||||
}
|
||||
|
||||
packetType := data[0]
|
||||
payload := data[1:]
|
||||
tc.Mutex.Lock()
|
||||
tc.RxBytes += uint64(len(data))
|
||||
lastRx := time.Now()
|
||||
tc.lastRx = lastRx
|
||||
callback := tc.packetCallback
|
||||
tc.Mutex.Unlock()
|
||||
|
||||
switch packetType {
|
||||
case 0x01: // Path request
|
||||
tc.Interface.ProcessIncoming(payload)
|
||||
case 0x02: // Link packet
|
||||
if len(payload) < 40 { // minimum size for link packet
|
||||
return
|
||||
}
|
||||
tc.Interface.ProcessIncoming(payload)
|
||||
default:
|
||||
// Unknown packet type
|
||||
return
|
||||
debug.Log(debug.DEBUG_ALL, "Received packet", "type", fmt.Sprintf("0x%02x", data[0]), "size", len(data))
|
||||
|
||||
// For RNS packets, call the packet callback directly
|
||||
if callback != nil {
|
||||
debug.Log(debug.DEBUG_ALL, "Calling packet callback for RNS packet")
|
||||
callback(data, tc)
|
||||
} else {
|
||||
debug.Log(debug.DEBUG_ALL, "No packet callback set for TCP interface")
|
||||
}
|
||||
}
|
||||
|
||||
// Send implements the interface Send method for TCP interface
|
||||
func (tc *TCPClientInterface) Send(data []byte, address string) error {
|
||||
debug.Log(debug.DEBUG_ALL, "TCP interface sending bytes", "name", tc.Name, "bytes", len(data))
|
||||
|
||||
if !tc.IsEnabled() || !tc.IsOnline() {
|
||||
return fmt.Errorf("TCP interface %s is not online", tc.Name)
|
||||
}
|
||||
|
||||
// Send data directly - packet type is already in the first byte of data
|
||||
// TCP interface uses HDLC framing around the raw packet
|
||||
return tc.ProcessOutgoing(data)
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) ProcessOutgoing(data []byte) error {
|
||||
if !tc.Online {
|
||||
tc.Mutex.RLock()
|
||||
online := tc.Online
|
||||
tc.Mutex.RUnlock()
|
||||
|
||||
if !online {
|
||||
return fmt.Errorf("interface offline")
|
||||
}
|
||||
|
||||
tc.writing = true
|
||||
defer func() { tc.writing = false }()
|
||||
|
||||
// For TCP connections, use HDLC framing
|
||||
var frame []byte
|
||||
if tc.kissFraming {
|
||||
frame = append([]byte{KISS_FEND}, escapeKISS(data)...)
|
||||
frame = append(frame, KISS_FEND)
|
||||
} else {
|
||||
frame = append([]byte{HDLC_FLAG}, escapeHDLC(data)...)
|
||||
frame = append(frame, HDLC_FLAG)
|
||||
frame = append([]byte{HDLC_FLAG}, escapeHDLC(data)...)
|
||||
frame = append(frame, HDLC_FLAG)
|
||||
|
||||
tc.UpdateStats(uint64(len(frame)), false) // #nosec G115
|
||||
|
||||
debug.Log(debug.DEBUG_ALL, "TCP interface writing to network", "name", tc.Name, "bytes", len(frame))
|
||||
|
||||
tc.Mutex.RLock()
|
||||
conn := tc.conn
|
||||
tc.Mutex.RUnlock()
|
||||
|
||||
if conn == nil {
|
||||
return fmt.Errorf("connection closed")
|
||||
}
|
||||
|
||||
if _, err := tc.conn.Write(frame); err != nil {
|
||||
tc.teardown()
|
||||
return fmt.Errorf("write failed: %v", err)
|
||||
_, err := conn.Write(frame)
|
||||
if err != nil {
|
||||
debug.Log(debug.DEBUG_CRITICAL, "TCP interface write failed", "name", tc.Name, "error", err)
|
||||
}
|
||||
|
||||
tc.Interface.ProcessOutgoing(data)
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) teardown() {
|
||||
@@ -238,7 +312,7 @@ func (tc *TCPClientInterface) teardown() {
|
||||
tc.IN = false
|
||||
tc.OUT = false
|
||||
if tc.conn != nil {
|
||||
tc.conn.Close()
|
||||
_ = tc.conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,130 +343,203 @@ func escapeKISS(data []byte) []byte {
|
||||
return escaped
|
||||
}
|
||||
|
||||
type TCPServerInterface struct {
|
||||
Interface
|
||||
server net.Listener
|
||||
bindAddr string
|
||||
bindPort int
|
||||
i2pTunneled bool
|
||||
preferIPv6 bool
|
||||
spawned []*TCPClientInterface
|
||||
spawnedMutex sync.RWMutex
|
||||
func (tc *TCPClientInterface) SetPacketCallback(cb common.PacketCallback) {
|
||||
tc.packetCallback = cb
|
||||
}
|
||||
|
||||
func NewTCPServer(name string, bindAddr string, bindPort int, i2pTunneled bool, preferIPv6 bool) (*TCPServerInterface, error) {
|
||||
ts := &TCPServerInterface{
|
||||
Interface: Interface{
|
||||
Name: name,
|
||||
Mode: MODE_FULL,
|
||||
MTU: 1064,
|
||||
Bitrate: 10000000, // 10Mbps estimate
|
||||
},
|
||||
bindAddr: bindAddr,
|
||||
bindPort: bindPort,
|
||||
i2pTunneled: i2pTunneled,
|
||||
preferIPv6: preferIPv6,
|
||||
spawned: make([]*TCPClientInterface, 0),
|
||||
}
|
||||
|
||||
// Resolve bind address
|
||||
var addr string
|
||||
if ts.bindAddr == "" {
|
||||
if ts.preferIPv6 {
|
||||
addr = fmt.Sprintf("[::0]:%d", ts.bindPort)
|
||||
} else {
|
||||
addr = fmt.Sprintf("0.0.0.0:%d", ts.bindPort)
|
||||
}
|
||||
} else {
|
||||
addr = fmt.Sprintf("%s:%d", ts.bindAddr, ts.bindPort)
|
||||
}
|
||||
|
||||
// Create listener
|
||||
var err error
|
||||
ts.server, err = net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create TCP listener: %v", err)
|
||||
}
|
||||
|
||||
ts.Online = true
|
||||
ts.IN = true
|
||||
|
||||
// Start accept loop
|
||||
go ts.acceptLoop()
|
||||
|
||||
return ts, nil
|
||||
func (tc *TCPClientInterface) IsEnabled() bool {
|
||||
tc.Mutex.RLock()
|
||||
defer tc.Mutex.RUnlock()
|
||||
return tc.Enabled && tc.Online && !tc.Detached
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) acceptLoop() {
|
||||
for {
|
||||
conn, err := ts.server.Accept()
|
||||
if err != nil {
|
||||
if !ts.Detached {
|
||||
// Log error and continue accepting
|
||||
continue
|
||||
}
|
||||
func (tc *TCPClientInterface) GetName() string {
|
||||
return tc.Name
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) GetPacketCallback() common.PacketCallback {
|
||||
tc.Mutex.RLock()
|
||||
defer tc.Mutex.RUnlock()
|
||||
return tc.packetCallback
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) IsDetached() bool {
|
||||
tc.Mutex.RLock()
|
||||
defer tc.Mutex.RUnlock()
|
||||
return tc.Detached
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) IsOnline() bool {
|
||||
tc.Mutex.RLock()
|
||||
defer tc.Mutex.RUnlock()
|
||||
return tc.Online
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) reconnect() {
|
||||
tc.Mutex.Lock()
|
||||
if tc.reconnecting {
|
||||
tc.Mutex.Unlock()
|
||||
return
|
||||
}
|
||||
tc.reconnecting = true
|
||||
tc.Mutex.Unlock()
|
||||
|
||||
backoff := time.Second
|
||||
maxBackoff := time.Minute * 5
|
||||
retries := 0
|
||||
|
||||
for retries < tc.maxReconnectTries {
|
||||
tc.teardown()
|
||||
|
||||
addr := net.JoinHostPort(tc.targetAddr, fmt.Sprintf("%d", tc.targetPort))
|
||||
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
if err == nil {
|
||||
tc.Mutex.Lock()
|
||||
tc.conn = conn
|
||||
tc.Online = true
|
||||
|
||||
tc.neverConnected = false
|
||||
tc.reconnecting = false
|
||||
tc.Mutex.Unlock()
|
||||
|
||||
go tc.readLoop()
|
||||
return
|
||||
}
|
||||
|
||||
// Create new client interface for this connection
|
||||
client := &TCPClientInterface{
|
||||
Interface: Interface{
|
||||
Name: fmt.Sprintf("Client-%s-%s", ts.Name, conn.RemoteAddr()),
|
||||
Mode: ts.Mode,
|
||||
MTU: ts.MTU,
|
||||
},
|
||||
conn: conn,
|
||||
i2pTunneled: ts.i2pTunneled,
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Failed to reconnect", "target", net.JoinHostPort(tc.targetAddr, fmt.Sprintf("%d", tc.targetPort)), "attempt", retries+1, "maxTries", tc.maxReconnectTries, "error", err)
|
||||
|
||||
// Wait with exponential backoff
|
||||
time.Sleep(backoff)
|
||||
|
||||
// Increase backoff time exponentially
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
|
||||
// Configure TCP options
|
||||
if tcpConn, ok := conn.(*net.TCPConn); ok {
|
||||
tcpConn.SetNoDelay(true)
|
||||
tcpConn.SetKeepAlive(true)
|
||||
tcpConn.SetKeepAlivePeriod(time.Duration(TCP_PROBE_INTERVAL) * time.Second)
|
||||
retries++
|
||||
}
|
||||
|
||||
tc.Mutex.Lock()
|
||||
tc.reconnecting = false
|
||||
tc.Mutex.Unlock()
|
||||
|
||||
tc.teardown()
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to reconnect after all attempts", "target", net.JoinHostPort(tc.targetAddr, fmt.Sprintf("%d", tc.targetPort)), "maxTries", tc.maxReconnectTries)
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) Enable() {
|
||||
tc.Mutex.Lock()
|
||||
defer tc.Mutex.Unlock()
|
||||
tc.Online = true
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) Disable() {
|
||||
tc.Mutex.Lock()
|
||||
defer tc.Mutex.Unlock()
|
||||
tc.Online = false
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) IsConnected() bool {
|
||||
tc.Mutex.RLock()
|
||||
defer tc.Mutex.RUnlock()
|
||||
return tc.conn != nil && tc.Online && !tc.reconnecting
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) GetRTT() time.Duration {
|
||||
tc.Mutex.RLock()
|
||||
defer tc.Mutex.RUnlock()
|
||||
|
||||
if !tc.IsConnected() {
|
||||
return 0
|
||||
}
|
||||
|
||||
if tcpConn, ok := tc.conn.(*net.TCPConn); ok {
|
||||
var rtt time.Duration
|
||||
if runtime.GOOS == "linux" {
|
||||
if info, err := tcpConn.SyscallConn(); err == nil {
|
||||
if err := info.Control(func(fd uintptr) { // #nosec G104
|
||||
rtt = platformGetRTT(fd)
|
||||
}); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Error in SyscallConn Control", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return rtt
|
||||
}
|
||||
|
||||
client.Online = true
|
||||
client.IN = ts.IN
|
||||
client.OUT = ts.OUT
|
||||
return 0
|
||||
}
|
||||
|
||||
// Add to spawned interfaces
|
||||
ts.spawnedMutex.Lock()
|
||||
ts.spawned = append(ts.spawned, client)
|
||||
ts.spawnedMutex.Unlock()
|
||||
func (tc *TCPClientInterface) GetTxBytes() uint64 {
|
||||
tc.Mutex.RLock()
|
||||
defer tc.Mutex.RUnlock()
|
||||
return tc.TxBytes
|
||||
}
|
||||
|
||||
// Start client read loop
|
||||
go client.readLoop()
|
||||
func (tc *TCPClientInterface) GetRxBytes() uint64 {
|
||||
tc.Mutex.RLock()
|
||||
defer tc.Mutex.RUnlock()
|
||||
return tc.RxBytes
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) UpdateStats(bytes uint64, isRx bool) {
|
||||
tc.Mutex.Lock()
|
||||
defer tc.Mutex.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if isRx {
|
||||
tc.RxBytes += bytes
|
||||
tc.lastRx = now
|
||||
debug.Log(debug.DEBUG_TRACE, "Interface RX stats", "name", tc.Name, "bytes", bytes, "total", tc.RxBytes, "last", tc.lastRx)
|
||||
} else {
|
||||
tc.TxBytes += bytes
|
||||
tc.lastTx = now
|
||||
debug.Log(debug.DEBUG_TRACE, "Interface TX stats", "name", tc.Name, "bytes", bytes, "total", tc.TxBytes, "last", tc.lastTx)
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) Detach() {
|
||||
ts.Interface.Detach()
|
||||
|
||||
if ts.server != nil {
|
||||
ts.server.Close()
|
||||
}
|
||||
|
||||
ts.spawnedMutex.Lock()
|
||||
for _, client := range ts.spawned {
|
||||
client.Detach()
|
||||
}
|
||||
ts.spawned = nil
|
||||
ts.spawnedMutex.Unlock()
|
||||
func (tc *TCPClientInterface) GetStats() (tx uint64, rx uint64, lastTx time.Time, lastRx time.Time) {
|
||||
tc.Mutex.RLock()
|
||||
defer tc.Mutex.RUnlock()
|
||||
return tc.TxBytes, tc.RxBytes, tc.lastTx, tc.lastRx
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) ProcessOutgoing(data []byte) error {
|
||||
ts.spawnedMutex.RLock()
|
||||
defer ts.spawnedMutex.RUnlock()
|
||||
type TCPServerInterface struct {
|
||||
BaseInterface
|
||||
connections map[string]net.Conn
|
||||
listener net.Listener
|
||||
bindAddr string
|
||||
bindPort int
|
||||
preferIPv6 bool
|
||||
kissFraming bool
|
||||
i2pTunneled bool
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, client := range ts.spawned {
|
||||
if err := client.ProcessOutgoing(data); err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
func NewTCPServerInterface(name string, bindAddr string, bindPort int, kissFraming bool, i2pTunneled bool, preferIPv6 bool) (*TCPServerInterface, error) {
|
||||
ts := &TCPServerInterface{
|
||||
BaseInterface: BaseInterface{
|
||||
Name: name,
|
||||
Mode: common.IF_MODE_FULL,
|
||||
Type: common.IF_TYPE_TCP,
|
||||
Online: false,
|
||||
MTU: common.DEFAULT_MTU,
|
||||
Enabled: true,
|
||||
Detached: false,
|
||||
},
|
||||
connections: make(map[string]net.Conn),
|
||||
bindAddr: bindAddr,
|
||||
bindPort: bindPort,
|
||||
preferIPv6: preferIPv6,
|
||||
kissFraming: kissFraming,
|
||||
i2pTunneled: i2pTunneled,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
return lastErr
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) String() string {
|
||||
@@ -401,8 +548,228 @@ func (ts *TCPServerInterface) String() string {
|
||||
if ts.preferIPv6 {
|
||||
addr = "[::0]"
|
||||
} else {
|
||||
addr = "0.0.0.0"
|
||||
addr = "0.0.0.0"
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("TCPServerInterface[%s/%s:%d]", ts.Name, addr, ts.bindPort)
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) SetPacketCallback(callback common.PacketCallback) {
|
||||
ts.Mutex.Lock()
|
||||
defer ts.Mutex.Unlock()
|
||||
ts.packetCallback = callback
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) GetPacketCallback() common.PacketCallback {
|
||||
ts.Mutex.RLock()
|
||||
defer ts.Mutex.RUnlock()
|
||||
return ts.packetCallback
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) IsEnabled() bool {
|
||||
ts.Mutex.RLock()
|
||||
defer ts.Mutex.RUnlock()
|
||||
return ts.Enabled && ts.Online && !ts.Detached
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) GetName() string {
|
||||
return ts.Name
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) IsDetached() bool {
|
||||
ts.Mutex.RLock()
|
||||
defer ts.Mutex.RUnlock()
|
||||
return ts.Detached
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) IsOnline() bool {
|
||||
ts.Mutex.RLock()
|
||||
defer ts.Mutex.RUnlock()
|
||||
return ts.Online
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) Enable() {
|
||||
ts.Mutex.Lock()
|
||||
defer ts.Mutex.Unlock()
|
||||
ts.Online = true
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) Disable() {
|
||||
ts.Mutex.Lock()
|
||||
defer ts.Mutex.Unlock()
|
||||
ts.Online = false
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) Start() error {
|
||||
ts.Mutex.Lock()
|
||||
if ts.listener != nil {
|
||||
ts.Mutex.Unlock()
|
||||
return fmt.Errorf("TCP server already started")
|
||||
}
|
||||
// Only recreate done if it's nil or was closed
|
||||
select {
|
||||
case <-ts.done:
|
||||
ts.done = make(chan struct{})
|
||||
ts.stopOnce = sync.Once{}
|
||||
default:
|
||||
if ts.done == nil {
|
||||
ts.done = make(chan struct{})
|
||||
ts.stopOnce = sync.Once{}
|
||||
}
|
||||
}
|
||||
ts.Mutex.Unlock()
|
||||
|
||||
addr := net.JoinHostPort(ts.bindAddr, fmt.Sprintf("%d", ts.bindPort))
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start TCP server: %w", err)
|
||||
}
|
||||
|
||||
ts.Mutex.Lock()
|
||||
ts.listener = listener
|
||||
ts.Online = true
|
||||
ts.Mutex.Unlock()
|
||||
|
||||
// Accept connections in a goroutine
|
||||
go func() {
|
||||
for {
|
||||
ts.Mutex.RLock()
|
||||
done := ts.done
|
||||
ts.Mutex.RUnlock()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
ts.Mutex.RLock()
|
||||
online := ts.Online
|
||||
ts.Mutex.RUnlock()
|
||||
if !online {
|
||||
return // Normal shutdown
|
||||
}
|
||||
debug.Log(debug.DEBUG_ERROR, "Error accepting connection", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle each connection in a separate goroutine
|
||||
go ts.handleConnection(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) Stop() error {
|
||||
ts.Mutex.Lock()
|
||||
ts.Online = false
|
||||
if ts.listener != nil {
|
||||
_ = ts.listener.Close()
|
||||
ts.listener = nil
|
||||
}
|
||||
// Close all client connections
|
||||
for addr, conn := range ts.connections {
|
||||
_ = conn.Close()
|
||||
delete(ts.connections, addr)
|
||||
}
|
||||
ts.Mutex.Unlock()
|
||||
|
||||
ts.stopOnce.Do(func() {
|
||||
if ts.done != nil {
|
||||
close(ts.done)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) GetTxBytes() uint64 {
|
||||
ts.Mutex.RLock()
|
||||
defer ts.Mutex.RUnlock()
|
||||
return ts.TxBytes
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) GetRxBytes() uint64 {
|
||||
ts.Mutex.RLock()
|
||||
defer ts.Mutex.RUnlock()
|
||||
return ts.RxBytes
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) handleConnection(conn net.Conn) {
|
||||
addr := conn.RemoteAddr().String()
|
||||
ts.Mutex.Lock()
|
||||
ts.connections[addr] = conn
|
||||
ts.Mutex.Unlock()
|
||||
|
||||
defer func() {
|
||||
ts.Mutex.Lock()
|
||||
delete(ts.connections, addr)
|
||||
ts.Mutex.Unlock()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
buffer := make([]byte, ts.MTU)
|
||||
for {
|
||||
ts.Mutex.RLock()
|
||||
done := ts.done
|
||||
ts.Mutex.RUnlock()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
n, err := conn.Read(buffer)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ts.Mutex.Lock()
|
||||
ts.RxBytes += uint64(n) // #nosec G115
|
||||
callback := ts.packetCallback
|
||||
ts.Mutex.Unlock()
|
||||
|
||||
if callback != nil {
|
||||
callback(buffer[:n], ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TCPServerInterface) ProcessOutgoing(data []byte) error {
|
||||
ts.Mutex.RLock()
|
||||
online := ts.Online
|
||||
ts.Mutex.RUnlock()
|
||||
|
||||
if !online {
|
||||
return fmt.Errorf("interface offline")
|
||||
}
|
||||
|
||||
var frame []byte
|
||||
if ts.kissFraming {
|
||||
frame = append([]byte{KISS_FEND}, escapeKISS(data)...)
|
||||
frame = append(frame, KISS_FEND)
|
||||
} else {
|
||||
frame = append([]byte{HDLC_FLAG}, escapeHDLC(data)...)
|
||||
frame = append(frame, HDLC_FLAG)
|
||||
}
|
||||
|
||||
ts.Mutex.Lock()
|
||||
ts.TxBytes += uint64(len(frame)) // #nosec G115
|
||||
conns := make([]net.Conn, 0, len(ts.connections))
|
||||
for _, conn := range ts.connections {
|
||||
conns = append(conns, conn)
|
||||
}
|
||||
ts.Mutex.Unlock()
|
||||
|
||||
for _, conn := range conns {
|
||||
if _, err := conn.Write(frame); err != nil {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Error writing to connection", "address", conn.RemoteAddr(), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
16
pkg/interfaces/tcp_common.go
Normal file
16
pkg/interfaces/tcp_common.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
//go:build !linux
|
||||
// +build !linux
|
||||
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// platformGetRTT is defined in OS-specific files
|
||||
// Default implementation for non-Linux platforms
|
||||
func platformGetRTT(fd uintptr) time.Duration {
|
||||
return 0
|
||||
}
|
||||
61
pkg/interfaces/tcp_darwin.go
Normal file
61
pkg/interfaces/tcp_darwin.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"syscall"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
)
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsLinux() error {
|
||||
return tc.setTimeoutsOSX()
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsOSX() error {
|
||||
tcpConn, ok := tc.conn.(*net.TCPConn)
|
||||
if !ok {
|
||||
return fmt.Errorf("not a TCP connection")
|
||||
}
|
||||
|
||||
rawConn, err := tcpConn.SyscallConn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get raw connection: %v", err)
|
||||
}
|
||||
|
||||
var sockoptErr error
|
||||
err = rawConn.Control(func(fd uintptr) {
|
||||
const TCP_KEEPALIVE = 0x10
|
||||
|
||||
var probeAfter int
|
||||
if tc.i2pTunneled {
|
||||
probeAfter = I2P_PROBE_AFTER_SEC
|
||||
} else {
|
||||
probeAfter = TCP_PROBE_AFTER_SEC
|
||||
}
|
||||
|
||||
if err := syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_KEEPALIVE, SO_KEEPALIVE_ENABLE); err != nil {
|
||||
sockoptErr = fmt.Errorf("failed to enable SO_KEEPALIVE: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := syscall.SetsockoptInt(int(fd), syscall.IPPROTO_TCP, TCP_KEEPALIVE, probeAfter); err != nil {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Failed to set TCP_KEEPALIVE", "error", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("control failed: %v", err)
|
||||
}
|
||||
if sockoptErr != nil {
|
||||
return sockoptErr
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "TCP keepalive configured (OSX)", "i2p", tc.i2pTunneled)
|
||||
return nil
|
||||
}
|
||||
41
pkg/interfaces/tcp_freebsd.go
Normal file
41
pkg/interfaces/tcp_freebsd.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
//go:build freebsd
|
||||
// +build freebsd
|
||||
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
)
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsLinux() error {
|
||||
tcpConn, ok := tc.conn.(*net.TCPConn)
|
||||
if !ok {
|
||||
return fmt.Errorf("not a TCP connection")
|
||||
}
|
||||
|
||||
if err := tcpConn.SetKeepAlive(true); err != nil {
|
||||
return fmt.Errorf("failed to enable keepalive: %v", err)
|
||||
}
|
||||
|
||||
keepalivePeriod := TCP_PROBE_INTERVAL_SEC * time.Second
|
||||
if tc.i2pTunneled {
|
||||
keepalivePeriod = I2P_PROBE_INTERVAL_SEC * time.Second
|
||||
}
|
||||
|
||||
if err := tcpConn.SetKeepAlivePeriod(keepalivePeriod); err != nil {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Failed to set keepalive period", "error", err)
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "TCP keepalive configured (FreeBSD)", "i2p", tc.i2pTunneled)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsOSX() error {
|
||||
return tc.setTimeoutsLinux()
|
||||
}
|
||||
111
pkg/interfaces/tcp_linux.go
Normal file
111
pkg/interfaces/tcp_linux.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
)
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsLinux() error {
|
||||
tcpConn, ok := tc.conn.(*net.TCPConn)
|
||||
if !ok {
|
||||
return fmt.Errorf("not a TCP connection")
|
||||
}
|
||||
|
||||
rawConn, err := tcpConn.SyscallConn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get raw connection: %v", err)
|
||||
}
|
||||
|
||||
var sockoptErr error
|
||||
err = rawConn.Control(func(fd uintptr) {
|
||||
var userTimeout, probeAfter, probeInterval, probeCount int
|
||||
|
||||
if tc.i2pTunneled {
|
||||
userTimeout = I2P_USER_TIMEOUT_SEC * TCP_MILLISECONDS
|
||||
probeAfter = I2P_PROBE_AFTER_SEC
|
||||
probeInterval = I2P_PROBE_INTERVAL_SEC
|
||||
probeCount = I2P_PROBES_COUNT
|
||||
} else {
|
||||
userTimeout = TCP_USER_TIMEOUT_SEC * TCP_MILLISECONDS
|
||||
probeAfter = TCP_PROBE_AFTER_SEC
|
||||
probeInterval = TCP_PROBE_INTERVAL_SEC
|
||||
probeCount = TCP_PROBES_COUNT
|
||||
}
|
||||
|
||||
const TCP_USER_TIMEOUT = 18
|
||||
const TCP_KEEPIDLE = 4
|
||||
const TCP_KEEPINTVL = 5
|
||||
const TCP_KEEPCNT = 6
|
||||
|
||||
if err := syscall.SetsockoptInt(int(fd), syscall.IPPROTO_TCP, TCP_USER_TIMEOUT, userTimeout); err != nil {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Failed to set TCP_USER_TIMEOUT", "error", err)
|
||||
}
|
||||
|
||||
if err := syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_KEEPALIVE, SO_KEEPALIVE_ENABLE); err != nil {
|
||||
sockoptErr = fmt.Errorf("failed to enable SO_KEEPALIVE: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := syscall.SetsockoptInt(int(fd), syscall.IPPROTO_TCP, TCP_KEEPIDLE, probeAfter); err != nil {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Failed to set TCP_KEEPIDLE", "error", err)
|
||||
}
|
||||
|
||||
if err := syscall.SetsockoptInt(int(fd), syscall.IPPROTO_TCP, TCP_KEEPINTVL, probeInterval); err != nil {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Failed to set TCP_KEEPINTVL", "error", err)
|
||||
}
|
||||
|
||||
if err := syscall.SetsockoptInt(int(fd), syscall.IPPROTO_TCP, TCP_KEEPCNT, probeCount); err != nil {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Failed to set TCP_KEEPCNT", "error", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("control failed: %v", err)
|
||||
}
|
||||
if sockoptErr != nil {
|
||||
return sockoptErr
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "TCP keepalive configured (Linux)", "i2p", tc.i2pTunneled)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsOSX() error {
|
||||
return tc.setTimeoutsLinux()
|
||||
}
|
||||
|
||||
func platformGetRTT(fd uintptr) time.Duration {
|
||||
var info syscall.TCPInfo
|
||||
// bearer:disable go_gosec_unsafe_unsafe
|
||||
infoLen := uint32(unsafe.Sizeof(info))
|
||||
|
||||
const TCP_INFO = 11
|
||||
// #nosec G103
|
||||
_, _, errno := syscall.Syscall6(
|
||||
syscall.SYS_GETSOCKOPT,
|
||||
fd,
|
||||
syscall.IPPROTO_TCP,
|
||||
TCP_INFO,
|
||||
// bearer:disable go_gosec_unsafe_unsafe
|
||||
uintptr(unsafe.Pointer(&info)),
|
||||
// bearer:disable go_gosec_unsafe_unsafe
|
||||
uintptr(unsafe.Pointer(&infoLen)),
|
||||
0,
|
||||
)
|
||||
|
||||
if errno != 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return time.Duration(info.Rtt) * time.Microsecond
|
||||
}
|
||||
41
pkg/interfaces/tcp_netbsd.go
Normal file
41
pkg/interfaces/tcp_netbsd.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
//go:build netbsd
|
||||
// +build netbsd
|
||||
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
)
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsLinux() error {
|
||||
tcpConn, ok := tc.conn.(*net.TCPConn)
|
||||
if !ok {
|
||||
return fmt.Errorf("not a TCP connection")
|
||||
}
|
||||
|
||||
if err := tcpConn.SetKeepAlive(true); err != nil {
|
||||
return fmt.Errorf("failed to enable keepalive: %v", err)
|
||||
}
|
||||
|
||||
keepalivePeriod := TCP_PROBE_INTERVAL_SEC * time.Second
|
||||
if tc.i2pTunneled {
|
||||
keepalivePeriod = I2P_PROBE_INTERVAL_SEC * time.Second
|
||||
}
|
||||
|
||||
if err := tcpConn.SetKeepAlivePeriod(keepalivePeriod); err != nil {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Failed to set keepalive period", "error", err)
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "TCP keepalive configured (NetBSD)", "i2p", tc.i2pTunneled)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsOSX() error {
|
||||
return tc.setTimeoutsLinux()
|
||||
}
|
||||
41
pkg/interfaces/tcp_openbsd.go
Normal file
41
pkg/interfaces/tcp_openbsd.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
//go:build openbsd
|
||||
// +build openbsd
|
||||
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
)
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsLinux() error {
|
||||
tcpConn, ok := tc.conn.(*net.TCPConn)
|
||||
if !ok {
|
||||
return fmt.Errorf("not a TCP connection")
|
||||
}
|
||||
|
||||
if err := tcpConn.SetKeepAlive(true); err != nil {
|
||||
return fmt.Errorf("failed to enable keepalive: %v", err)
|
||||
}
|
||||
|
||||
keepalivePeriod := TCP_PROBE_INTERVAL_SEC * time.Second
|
||||
if tc.i2pTunneled {
|
||||
keepalivePeriod = I2P_PROBE_INTERVAL_SEC * time.Second
|
||||
}
|
||||
|
||||
if err := tcpConn.SetKeepAlivePeriod(keepalivePeriod); err != nil {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Failed to set keepalive period", "error", err)
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "TCP keepalive configured (OpenBSD)", "i2p", tc.i2pTunneled)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsOSX() error {
|
||||
return tc.setTimeoutsLinux()
|
||||
}
|
||||
52
pkg/interfaces/tcp_test.go
Normal file
52
pkg/interfaces/tcp_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEscapeHDLC(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input []byte
|
||||
expected []byte
|
||||
}{
|
||||
{"NoEscape", []byte{0x01, 0x02, 0x03}, []byte{0x01, 0x02, 0x03}},
|
||||
{"EscapeFlag", []byte{0x01, HDLC_FLAG, 0x03}, []byte{0x01, HDLC_ESC, HDLC_FLAG ^ HDLC_ESC_MASK, 0x03}},
|
||||
{"EscapeEsc", []byte{0x01, HDLC_ESC, 0x03}, []byte{0x01, HDLC_ESC, HDLC_ESC ^ HDLC_ESC_MASK, 0x03}},
|
||||
{"EscapeBoth", []byte{HDLC_FLAG, HDLC_ESC}, []byte{HDLC_ESC, HDLC_FLAG ^ HDLC_ESC_MASK, HDLC_ESC, HDLC_ESC ^ HDLC_ESC_MASK}},
|
||||
{"Empty", []byte{}, []byte{}},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := escapeHDLC(tc.input)
|
||||
if !bytes.Equal(result, tc.expected) {
|
||||
t.Errorf("escapeHDLC(%x) = %x; want %x", tc.input, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeKISS(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input []byte
|
||||
expected []byte
|
||||
}{
|
||||
{"NoEscape", []byte{0x01, 0x02, 0x03}, []byte{0x01, 0x02, 0x03}},
|
||||
{"EscapeFEND", []byte{0x01, KISS_FEND, 0x03}, []byte{0x01, KISS_FESC, KISS_TFEND, 0x03}},
|
||||
{"EscapeFESC", []byte{0x01, KISS_FESC, 0x03}, []byte{0x01, KISS_FESC, KISS_TFESC, 0x03}},
|
||||
{"EscapeBoth", []byte{KISS_FEND, KISS_FESC}, []byte{KISS_FESC, KISS_TFEND, KISS_FESC, KISS_TFESC}},
|
||||
{"Empty", []byte{}, []byte{}},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := escapeKISS(tc.input)
|
||||
if !bytes.Equal(result, tc.expected) {
|
||||
t.Errorf("escapeKISS(%x) = %x; want %x", tc.input, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
14
pkg/interfaces/tcp_wasm.go
Normal file
14
pkg/interfaces/tcp_wasm.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
//go:build js && wasm
|
||||
// +build js,wasm
|
||||
|
||||
package interfaces
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsLinux() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsOSX() error {
|
||||
return nil
|
||||
}
|
||||
42
pkg/interfaces/tcp_windows.go
Normal file
42
pkg/interfaces/tcp_windows.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
)
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsLinux() error {
|
||||
return tc.setTimeoutsWindows()
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsOSX() error {
|
||||
return tc.setTimeoutsWindows()
|
||||
}
|
||||
|
||||
func (tc *TCPClientInterface) setTimeoutsWindows() error {
|
||||
tcpConn, ok := tc.conn.(*net.TCPConn)
|
||||
if !ok {
|
||||
return fmt.Errorf("not a TCP connection")
|
||||
}
|
||||
|
||||
if err := tcpConn.SetKeepAlive(true); err != nil {
|
||||
return fmt.Errorf("failed to enable keepalive: %v", err)
|
||||
}
|
||||
|
||||
keepalivePeriod := TCP_PROBE_INTERVAL_SEC * time.Second
|
||||
if tc.i2pTunneled {
|
||||
keepalivePeriod = I2P_PROBE_INTERVAL_SEC * time.Second
|
||||
}
|
||||
|
||||
if err := tcpConn.SetKeepAlivePeriod(keepalivePeriod); err != nil {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Failed to set keepalive period", "error", err)
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "TCP keepalive configured (Windows)", "i2p", tc.i2pTunneled)
|
||||
return nil
|
||||
}
|
||||
@@ -1,88 +1,141 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
)
|
||||
|
||||
type UDPInterface struct {
|
||||
Interface
|
||||
conn *net.UDPConn
|
||||
listenAddr *net.UDPAddr
|
||||
BaseInterface
|
||||
conn *net.UDPConn
|
||||
addr *net.UDPAddr
|
||||
targetAddr *net.UDPAddr
|
||||
readBuffer []byte
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
func NewUDPInterface(name string, listenAddr string, targetAddr string) (*UDPInterface, error) {
|
||||
ui := &UDPInterface{
|
||||
Interface: Interface{
|
||||
Name: name,
|
||||
Mode: MODE_FULL,
|
||||
MTU: 1500,
|
||||
Bitrate: 100000000, // 100Mbps estimate for UDP
|
||||
},
|
||||
readBuffer: make([]byte, 65535),
|
||||
}
|
||||
|
||||
// Parse listen address
|
||||
laddr, err := net.ResolveUDPAddr("udp", listenAddr)
|
||||
func NewUDPInterface(name string, addr string, target string, enabled bool) (*UDPInterface, error) {
|
||||
udpAddr, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid listen address: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
ui.listenAddr = laddr
|
||||
|
||||
// Parse target address if provided
|
||||
if targetAddr != "" {
|
||||
taddr, err := net.ResolveUDPAddr("udp", targetAddr)
|
||||
var targetAddr *net.UDPAddr
|
||||
if target != "" {
|
||||
targetAddr, err = net.ResolveUDPAddr("udp", target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid target address: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
ui.targetAddr = taddr
|
||||
ui.OUT = true
|
||||
}
|
||||
|
||||
// Create UDP connection
|
||||
conn, err := net.ListenUDP("udp", ui.listenAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to listen on UDP: %v", err)
|
||||
ui := &UDPInterface{
|
||||
BaseInterface: NewBaseInterface(name, common.IF_TYPE_UDP, enabled),
|
||||
addr: udpAddr,
|
||||
targetAddr: targetAddr,
|
||||
readBuffer: make([]byte, common.NUM_1064),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
ui.conn = conn
|
||||
ui.IN = true
|
||||
ui.Online = true
|
||||
|
||||
// Start read loop
|
||||
go ui.readLoop()
|
||||
ui.MTU = common.NUM_1064
|
||||
|
||||
return ui, nil
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) readLoop() {
|
||||
for {
|
||||
if !ui.Online {
|
||||
return
|
||||
func (ui *UDPInterface) GetName() string {
|
||||
return ui.Name
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) GetType() common.InterfaceType {
|
||||
return ui.Type
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) GetMode() common.InterfaceMode {
|
||||
return ui.Mode
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) IsOnline() bool {
|
||||
ui.Mutex.RLock()
|
||||
defer ui.Mutex.RUnlock()
|
||||
return ui.Online
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) IsDetached() bool {
|
||||
ui.Mutex.RLock()
|
||||
defer ui.Mutex.RUnlock()
|
||||
return ui.Detached
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) Detach() {
|
||||
ui.Mutex.Lock()
|
||||
defer ui.Mutex.Unlock()
|
||||
ui.Detached = true
|
||||
ui.Online = false
|
||||
if ui.conn != nil {
|
||||
ui.conn.Close() // #nosec G104
|
||||
}
|
||||
ui.stopOnce.Do(func() {
|
||||
if ui.done != nil {
|
||||
close(ui.done)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
n, addr, err := ui.conn.ReadFromUDP(ui.readBuffer)
|
||||
if err != nil {
|
||||
if !ui.Detached {
|
||||
// Log error
|
||||
}
|
||||
continue
|
||||
}
|
||||
func (ui *UDPInterface) Send(data []byte, addr string) error {
|
||||
debug.Log(debug.DEBUG_ALL, "UDP interface sending bytes", "name", ui.Name, "bytes", len(data))
|
||||
|
||||
// Copy received data
|
||||
data := make([]byte, n)
|
||||
copy(data, ui.readBuffer[:n])
|
||||
if !ui.IsEnabled() {
|
||||
return fmt.Errorf("interface not enabled")
|
||||
}
|
||||
|
||||
// Process packet
|
||||
ui.ProcessIncoming(data)
|
||||
if ui.targetAddr == nil {
|
||||
return fmt.Errorf("no target address configured")
|
||||
}
|
||||
|
||||
ui.Mutex.Lock()
|
||||
ui.TxBytes += uint64(len(data))
|
||||
ui.Mutex.Unlock()
|
||||
|
||||
_, err := ui.conn.WriteTo(data, ui.targetAddr)
|
||||
if err != nil {
|
||||
debug.Log(debug.DEBUG_CRITICAL, "UDP interface write failed", "name", ui.Name, "error", err)
|
||||
} else {
|
||||
debug.Log(debug.DEBUG_ALL, "UDP interface sent bytes successfully", "name", ui.Name, "bytes", len(data))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) SetPacketCallback(callback common.PacketCallback) {
|
||||
ui.Mutex.Lock()
|
||||
defer ui.Mutex.Unlock()
|
||||
ui.packetCallback = callback
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) GetPacketCallback() common.PacketCallback {
|
||||
ui.Mutex.RLock()
|
||||
defer ui.Mutex.RUnlock()
|
||||
return ui.packetCallback
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) ProcessIncoming(data []byte) {
|
||||
if callback := ui.GetPacketCallback(); callback != nil {
|
||||
callback(data, ui)
|
||||
}
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) ProcessOutgoing(data []byte) error {
|
||||
if !ui.Online || ui.targetAddr == nil {
|
||||
return fmt.Errorf("interface offline or no target address configured")
|
||||
if !ui.IsOnline() {
|
||||
return fmt.Errorf("interface offline")
|
||||
}
|
||||
|
||||
if ui.targetAddr == nil {
|
||||
return fmt.Errorf("no target address configured")
|
||||
}
|
||||
|
||||
_, err := ui.conn.WriteToUDP(data, ui.targetAddr)
|
||||
@@ -90,13 +143,151 @@ func (ui *UDPInterface) ProcessOutgoing(data []byte) error {
|
||||
return fmt.Errorf("UDP write failed: %v", err)
|
||||
}
|
||||
|
||||
ui.Interface.ProcessOutgoing(data)
|
||||
ui.Mutex.Lock()
|
||||
ui.TxBytes += uint64(len(data))
|
||||
ui.Mutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) Detach() {
|
||||
ui.Interface.Detach()
|
||||
func (ui *UDPInterface) GetConn() net.Conn {
|
||||
return ui.conn
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) GetTxBytes() uint64 {
|
||||
ui.Mutex.RLock()
|
||||
defer ui.Mutex.RUnlock()
|
||||
return ui.TxBytes
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) GetRxBytes() uint64 {
|
||||
ui.Mutex.RLock()
|
||||
defer ui.Mutex.RUnlock()
|
||||
return ui.RxBytes
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) GetMTU() int {
|
||||
return ui.MTU
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) GetBitrate() int {
|
||||
return int(ui.Bitrate)
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) Enable() {
|
||||
ui.Mutex.Lock()
|
||||
defer ui.Mutex.Unlock()
|
||||
ui.Online = true
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) Disable() {
|
||||
ui.Mutex.Lock()
|
||||
defer ui.Mutex.Unlock()
|
||||
ui.Online = false
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) Start() error {
|
||||
ui.Mutex.Lock()
|
||||
if ui.conn != nil {
|
||||
ui.conn.Close()
|
||||
ui.Mutex.Unlock()
|
||||
return fmt.Errorf("UDP interface already started")
|
||||
}
|
||||
}
|
||||
// Only recreate done if it's nil or was closed
|
||||
select {
|
||||
case <-ui.done:
|
||||
ui.done = make(chan struct{})
|
||||
ui.stopOnce = sync.Once{}
|
||||
default:
|
||||
if ui.done == nil {
|
||||
ui.done = make(chan struct{})
|
||||
ui.stopOnce = sync.Once{}
|
||||
}
|
||||
}
|
||||
ui.Mutex.Unlock()
|
||||
|
||||
conn, err := net.ListenUDP("udp", ui.addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ui.conn = conn
|
||||
|
||||
// Enable broadcast mode if we have a target address
|
||||
if ui.targetAddr != nil {
|
||||
// Get the raw connection file descriptor to set SO_BROADCAST
|
||||
if err := conn.SetReadBuffer(common.NUM_1064); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to set read buffer size", "error", err)
|
||||
}
|
||||
if err := conn.SetWriteBuffer(common.NUM_1064); err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to set write buffer size", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
ui.Mutex.Lock()
|
||||
ui.Online = true
|
||||
ui.Mutex.Unlock()
|
||||
|
||||
// Start the read loop in a goroutine
|
||||
go ui.readLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) Stop() error {
|
||||
ui.Detach()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) readLoop() {
|
||||
buffer := make([]byte, common.NUM_1064)
|
||||
for {
|
||||
ui.Mutex.RLock()
|
||||
online := ui.Online
|
||||
detached := ui.Detached
|
||||
conn := ui.conn
|
||||
done := ui.done
|
||||
ui.Mutex.RUnlock()
|
||||
|
||||
if !online || detached || conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
n, remoteAddr, err := conn.ReadFromUDP(buffer)
|
||||
if err != nil {
|
||||
ui.Mutex.RLock()
|
||||
stillOnline := ui.Online
|
||||
ui.Mutex.RUnlock()
|
||||
if stillOnline {
|
||||
debug.Log(debug.DEBUG_ERROR, "Error reading from UDP interface", "name", ui.Name, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ui.Mutex.Lock()
|
||||
// #nosec G115 - Network read sizes are always positive and within safe range
|
||||
ui.RxBytes += uint64(n)
|
||||
|
||||
// Auto-discover target address from first packet if not set
|
||||
if ui.targetAddr == nil {
|
||||
debug.Log(debug.DEBUG_ALL, "UDP interface discovered peer", "name", ui.Name, "peer", remoteAddr.String())
|
||||
ui.targetAddr = remoteAddr
|
||||
}
|
||||
callback := ui.packetCallback
|
||||
ui.Mutex.Unlock()
|
||||
|
||||
if callback != nil {
|
||||
callback(buffer[:n], ui)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ui *UDPInterface) IsEnabled() bool {
|
||||
ui.Mutex.RLock()
|
||||
defer ui.Mutex.RUnlock()
|
||||
return ui.Enabled && ui.Online && !ui.Detached
|
||||
}
|
||||
|
||||
87
pkg/interfaces/udp_test.go
Normal file
87
pkg/interfaces/udp_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
)
|
||||
|
||||
func TestNewUDPInterface(t *testing.T) {
|
||||
validAddr := "127.0.0.1:0" // Use port 0 for OS to assign a free port
|
||||
validTarget := "127.0.0.1:8080"
|
||||
invalidAddr := "invalid-address"
|
||||
|
||||
t.Run("ValidConfig", func(t *testing.T) {
|
||||
ui, err := NewUDPInterface("udpValid", validAddr, validTarget, true)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUDPInterface failed with valid config: %v", err)
|
||||
}
|
||||
if ui == nil {
|
||||
t.Fatal("NewUDPInterface returned nil interface with valid config")
|
||||
}
|
||||
if ui.GetName() != "udpValid" {
|
||||
t.Errorf("GetName() = %s; want udpValid", ui.GetName())
|
||||
}
|
||||
if ui.GetType() != common.IF_TYPE_UDP {
|
||||
t.Errorf("GetType() = %v; want %v", ui.GetType(), common.IF_TYPE_UDP)
|
||||
}
|
||||
if ui.targetAddr.String() != validTarget {
|
||||
t.Errorf("Resolved targetAddr = %s; want %s", ui.targetAddr.String(), validTarget)
|
||||
}
|
||||
if !ui.Enabled { // BaseInterface field
|
||||
t.Error("Interface not enabled by default when requested")
|
||||
}
|
||||
if ui.IsOnline() { // Should be offline initially
|
||||
t.Error("Interface online initially")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ValidConfigNoTarget", func(t *testing.T) {
|
||||
ui, err := NewUDPInterface("udpNoTarget", validAddr, "", true)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUDPInterface failed with valid config (no target): %v", err)
|
||||
}
|
||||
if ui == nil {
|
||||
t.Fatal("NewUDPInterface returned nil interface with valid config (no target)")
|
||||
}
|
||||
if ui.targetAddr != nil {
|
||||
t.Errorf("targetAddr = %v; want nil", ui.targetAddr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidAddress", func(t *testing.T) {
|
||||
_, err := NewUDPInterface("udpInvalidAddr", invalidAddr, validTarget, true)
|
||||
if err == nil {
|
||||
t.Error("NewUDPInterface succeeded with invalid address")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidTarget", func(t *testing.T) {
|
||||
_, err := NewUDPInterface("udpInvalidTarget", validAddr, invalidAddr, true)
|
||||
if err == nil {
|
||||
t.Error("NewUDPInterface succeeded with invalid target address")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUDPInterfaceState(t *testing.T) {
|
||||
// Basic state tests are covered by BaseInterface tests
|
||||
addr := "127.0.0.1:0"
|
||||
ui, _ := NewUDPInterface("udpState", addr, "", true)
|
||||
|
||||
if ui.conn != nil {
|
||||
t.Error("conn field is not nil before Start()")
|
||||
}
|
||||
|
||||
// We don't call Start() here because it requires actual network binding
|
||||
// Testing Send requires Start() and a listener, which is too complex for unit tests here
|
||||
|
||||
// Test Detach
|
||||
ui.Detach()
|
||||
if !ui.IsDetached() {
|
||||
t.Error("IsDetached() is false after Detach()")
|
||||
}
|
||||
|
||||
// Further tests on Send/ProcessOutgoing/readLoop would require mocking net.UDPConn
|
||||
// or setting up a local listener.
|
||||
}
|
||||
714
pkg/interfaces/websocket_native.go
Normal file
714
pkg/interfaces/websocket_native.go
Normal file
@@ -0,0 +1,714 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
//go:build !js
|
||||
// +build !js
|
||||
|
||||
// WebSocketInterface is a native implementation of the WebSocket interface.
|
||||
// It is used to connect to the WebSocket server and send/receive data.
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
// bearer:disable go_gosec_blocklist_sha1
|
||||
"crypto/sha1" // #nosec G505
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
)
|
||||
|
||||
const (
|
||||
wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
|
||||
WS_BUFFER_SIZE = 4096
|
||||
WS_MTU = 1064
|
||||
WS_BITRATE = 10000000
|
||||
WS_HTTPS_PORT = 443
|
||||
WS_HTTP_PORT = 80
|
||||
WS_VERSION = "13"
|
||||
WS_CONNECT_TIMEOUT = 10 * time.Second
|
||||
WS_RECONNECT_DELAY = 2 * time.Second
|
||||
WS_KEY_SIZE = 16
|
||||
WS_MASK_KEY_SIZE = 4
|
||||
WS_HEADER_SIZE = 2
|
||||
WS_PAYLOAD_LEN_16BIT = 126
|
||||
WS_PAYLOAD_LEN_64BIT = 127
|
||||
WS_MAX_PAYLOAD_16BIT = 65536
|
||||
WS_FRAME_HEADER_FIN = 0x80
|
||||
WS_FRAME_HEADER_OPCODE = 0x0F
|
||||
WS_FRAME_HEADER_MASKED = 0x80
|
||||
WS_FRAME_HEADER_LEN = 0x7F
|
||||
WS_OPCODE_CONTINUATION = 0x00
|
||||
WS_OPCODE_TEXT = 0x01
|
||||
WS_OPCODE_BINARY = 0x02
|
||||
WS_OPCODE_CLOSE = 0x08
|
||||
WS_OPCODE_PING = 0x09
|
||||
WS_OPCODE_PONG = 0x0A
|
||||
)
|
||||
|
||||
type WebSocketInterface struct {
|
||||
BaseInterface
|
||||
wsURL string
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
connected bool
|
||||
messageQueue [][]byte
|
||||
readBuffer []byte
|
||||
writeBuffer []byte
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
func NewWebSocketInterface(name string, wsURL string, enabled bool) (*WebSocketInterface, error) {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "NewWebSocketInterface called", "name", name, "url", wsURL, "enabled", enabled)
|
||||
ws := &WebSocketInterface{
|
||||
BaseInterface: NewBaseInterface(name, common.IF_TYPE_UDP, enabled),
|
||||
wsURL: wsURL,
|
||||
messageQueue: make([][]byte, 0),
|
||||
readBuffer: make([]byte, WS_BUFFER_SIZE),
|
||||
writeBuffer: make([]byte, WS_BUFFER_SIZE),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
ws.MTU = WS_MTU
|
||||
ws.Bitrate = WS_BITRATE
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "WebSocket interface initialized", "name", name, "mtu", ws.MTU, "bitrate", ws.Bitrate)
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) GetName() string {
|
||||
return wsi.Name
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) GetType() common.InterfaceType {
|
||||
return wsi.Type
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) GetMode() common.InterfaceMode {
|
||||
return wsi.Mode
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) IsOnline() bool {
|
||||
wsi.Mutex.RLock()
|
||||
defer wsi.Mutex.RUnlock()
|
||||
return wsi.Online && wsi.connected
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) IsDetached() bool {
|
||||
wsi.Mutex.RLock()
|
||||
defer wsi.Mutex.RUnlock()
|
||||
return wsi.Detached
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) Detach() {
|
||||
wsi.Mutex.Lock()
|
||||
defer wsi.Mutex.Unlock()
|
||||
wsi.Detached = true
|
||||
wsi.Online = false
|
||||
wsi.closeWebSocketLocked()
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) Enable() {
|
||||
wsi.Mutex.Lock()
|
||||
defer wsi.Mutex.Unlock()
|
||||
wsi.Enabled = true
|
||||
wsi.Online = true
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) Disable() {
|
||||
wsi.Mutex.Lock()
|
||||
defer wsi.Mutex.Unlock()
|
||||
wsi.Enabled = false
|
||||
wsi.closeWebSocketLocked()
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) Start() error {
|
||||
wsi.Mutex.Lock()
|
||||
if !wsi.Enabled || wsi.Detached {
|
||||
wsi.Mutex.Unlock()
|
||||
debug.Log(debug.DEBUG_INFO, "WebSocket interface not enabled or detached", "name", wsi.Name)
|
||||
return fmt.Errorf("interface not enabled or detached")
|
||||
}
|
||||
if wsi.conn != nil {
|
||||
wsi.Mutex.Unlock()
|
||||
debug.Log(debug.DEBUG_INFO, "WebSocket already started", "name", wsi.Name)
|
||||
return fmt.Errorf("WebSocket already started")
|
||||
}
|
||||
// Only recreate done if it's nil or was closed
|
||||
select {
|
||||
case <-wsi.done:
|
||||
wsi.done = make(chan struct{})
|
||||
wsi.stopOnce = sync.Once{}
|
||||
default:
|
||||
if wsi.done == nil {
|
||||
wsi.done = make(chan struct{})
|
||||
wsi.stopOnce = sync.Once{}
|
||||
}
|
||||
}
|
||||
wsi.Mutex.Unlock()
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "Starting WebSocket connection", "name", wsi.Name, "url", wsi.wsURL)
|
||||
|
||||
u, err := url.Parse(wsi.wsURL)
|
||||
if err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Invalid WebSocket URL", "name", wsi.Name, "url", wsi.wsURL, "error", err)
|
||||
return fmt.Errorf("invalid WebSocket URL: %v", err)
|
||||
}
|
||||
|
||||
var conn net.Conn
|
||||
var host string
|
||||
|
||||
if u.Scheme == "wss" {
|
||||
host = u.Host
|
||||
if !strings.Contains(host, ":") {
|
||||
host += fmt.Sprintf(":%d", WS_HTTPS_PORT)
|
||||
}
|
||||
tcpConn, err := net.DialTimeout("tcp", host, WS_CONNECT_TIMEOUT)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %v", err)
|
||||
}
|
||||
tlsConn := tls.Client(tcpConn, &tls.Config{
|
||||
ServerName: u.Hostname(),
|
||||
InsecureSkipVerify: false,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
_ = tcpConn.Close()
|
||||
debug.Log(debug.DEBUG_ERROR, "TLS handshake failed", "name", wsi.Name, "host", host, "error", err)
|
||||
return fmt.Errorf("TLS handshake failed: %v", err)
|
||||
}
|
||||
conn = tlsConn
|
||||
} else if u.Scheme == "ws" {
|
||||
host = u.Host
|
||||
if !strings.Contains(host, ":") {
|
||||
host += fmt.Sprintf(":%d", WS_HTTP_PORT)
|
||||
}
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Connecting to WebSocket server", "name", wsi.Name, "host", host)
|
||||
tcpConn, err := net.DialTimeout("tcp", host, WS_CONNECT_TIMEOUT)
|
||||
if err != nil {
|
||||
debug.Log(debug.DEBUG_ERROR, "Failed to connect to WebSocket server", "name", wsi.Name, "host", host, "error", err)
|
||||
return fmt.Errorf("failed to connect: %v", err)
|
||||
}
|
||||
conn = tcpConn
|
||||
} else {
|
||||
debug.Log(debug.DEBUG_ERROR, "Unsupported WebSocket scheme", "name", wsi.Name, "scheme", u.Scheme)
|
||||
return fmt.Errorf("unsupported scheme: %s (use ws:// or wss://)", u.Scheme)
|
||||
}
|
||||
|
||||
key, err := generateWebSocketKey()
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return fmt.Errorf("failed to generate key: %v", err)
|
||||
}
|
||||
|
||||
path := u.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
path += "?" + u.RawQuery
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
|
||||
req.Host = u.Host
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
req.Header.Set("Sec-WebSocket-Key", key)
|
||||
req.Header.Set("Sec-WebSocket-Version", WS_VERSION)
|
||||
req.Header.Set("User-Agent", "Reticulum-Go/1.0")
|
||||
|
||||
if err := req.Write(conn); err != nil {
|
||||
_ = conn.Close()
|
||||
return fmt.Errorf("failed to send handshake: %v", err)
|
||||
}
|
||||
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), req)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return fmt.Errorf("failed to read handshake response: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
_ = conn.Close()
|
||||
debug.Log(debug.DEBUG_ERROR, "WebSocket handshake failed", "name", wsi.Name, "status", resp.StatusCode)
|
||||
return fmt.Errorf("handshake failed: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if strings.ToLower(resp.Header.Get("Upgrade")) != "websocket" {
|
||||
_ = conn.Close()
|
||||
return fmt.Errorf("invalid upgrade header")
|
||||
}
|
||||
|
||||
accept := resp.Header.Get("Sec-WebSocket-Accept")
|
||||
expectedAccept := computeAcceptKey(key)
|
||||
if accept != expectedAccept {
|
||||
_ = conn.Close()
|
||||
return fmt.Errorf("invalid accept key")
|
||||
}
|
||||
|
||||
wsi.Mutex.Lock()
|
||||
wsi.conn = conn
|
||||
wsi.reader = bufio.NewReader(conn)
|
||||
wsi.connected = true
|
||||
wsi.Online = true
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "WebSocket connected", "name", wsi.Name, "url", wsi.wsURL)
|
||||
|
||||
queue := make([][]byte, len(wsi.messageQueue))
|
||||
copy(queue, wsi.messageQueue)
|
||||
wsi.messageQueue = wsi.messageQueue[:0]
|
||||
wsi.Mutex.Unlock() // Unlock after copying queue, before I/O
|
||||
|
||||
for _, msg := range queue {
|
||||
_ = wsi.sendWebSocketMessage(msg)
|
||||
}
|
||||
|
||||
go wsi.readLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) Stop() error {
|
||||
wsi.Mutex.Lock()
|
||||
defer wsi.Mutex.Unlock()
|
||||
|
||||
wsi.Enabled = false
|
||||
wsi.Online = false
|
||||
|
||||
wsi.stopOnce.Do(func() {
|
||||
if wsi.done != nil {
|
||||
close(wsi.done)
|
||||
}
|
||||
})
|
||||
|
||||
wsi.closeWebSocketLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) closeWebSocket() {
|
||||
wsi.Mutex.Lock()
|
||||
defer wsi.Mutex.Unlock()
|
||||
wsi.closeWebSocketLocked()
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) closeWebSocketLocked() {
|
||||
if wsi.conn != nil {
|
||||
wsi.sendCloseFrameLocked()
|
||||
_ = wsi.conn.Close()
|
||||
wsi.conn = nil
|
||||
wsi.reader = nil
|
||||
}
|
||||
wsi.connected = false
|
||||
wsi.Online = false
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) readLoop() {
|
||||
for {
|
||||
wsi.Mutex.RLock()
|
||||
conn := wsi.conn
|
||||
reader := wsi.reader
|
||||
done := wsi.done
|
||||
wsi.Mutex.RUnlock()
|
||||
|
||||
if conn == nil || reader == nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
data, err := wsi.readFrame()
|
||||
if err != nil {
|
||||
wsi.Mutex.Lock()
|
||||
wsi.connected = false
|
||||
wsi.Online = false
|
||||
if wsi.conn != nil {
|
||||
_ = wsi.conn.Close()
|
||||
wsi.conn = nil
|
||||
wsi.reader = nil
|
||||
}
|
||||
wsi.Mutex.Unlock()
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "WebSocket closed", "name", wsi.Name, "error", err)
|
||||
|
||||
time.Sleep(WS_RECONNECT_DELAY)
|
||||
|
||||
wsi.Mutex.RLock()
|
||||
stillEnabled := wsi.Enabled && !wsi.Detached
|
||||
wsi.Mutex.RUnlock()
|
||||
|
||||
if stillEnabled {
|
||||
go wsi.Start()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(data) > 0 {
|
||||
wsi.Mutex.Lock()
|
||||
wsi.RxBytes += uint64(len(data))
|
||||
wsi.Mutex.Unlock()
|
||||
|
||||
wsi.ProcessIncoming(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) readFrame() ([]byte, error) {
|
||||
wsi.Mutex.RLock()
|
||||
reader := wsi.reader
|
||||
wsi.Mutex.RUnlock()
|
||||
|
||||
if reader == nil {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
header := make([]byte, WS_HEADER_SIZE)
|
||||
if _, err := io.ReadFull(reader, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fin := (header[0] & WS_FRAME_HEADER_FIN) != 0
|
||||
opcode := header[0] & WS_FRAME_HEADER_OPCODE
|
||||
masked := (header[1] & WS_FRAME_HEADER_MASKED) != 0
|
||||
payloadLen := int(header[1] & WS_FRAME_HEADER_LEN)
|
||||
|
||||
if opcode == WS_OPCODE_CLOSE {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
if opcode == WS_OPCODE_PING {
|
||||
return wsi.handlePingFrame(reader, payloadLen, masked)
|
||||
}
|
||||
|
||||
if opcode == WS_OPCODE_PONG {
|
||||
return wsi.handlePongFrame(reader, payloadLen, masked)
|
||||
}
|
||||
|
||||
if opcode != WS_OPCODE_BINARY {
|
||||
return nil, fmt.Errorf("unsupported opcode: %d", opcode)
|
||||
}
|
||||
|
||||
if payloadLen == WS_PAYLOAD_LEN_16BIT {
|
||||
lenBytes := make([]byte, 2)
|
||||
if _, err := io.ReadFull(reader, lenBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payloadLen = int(binary.BigEndian.Uint16(lenBytes))
|
||||
} else if payloadLen == WS_PAYLOAD_LEN_64BIT {
|
||||
lenBytes := make([]byte, 8)
|
||||
if _, err := io.ReadFull(reader, lenBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
val := binary.BigEndian.Uint64(lenBytes)
|
||||
if val > uint64(math.MaxInt) {
|
||||
return nil, fmt.Errorf("payload length exceeds maximum integer value")
|
||||
}
|
||||
payloadLen = int(val) // #nosec G115
|
||||
}
|
||||
|
||||
maskKey := make([]byte, WS_MASK_KEY_SIZE)
|
||||
if masked {
|
||||
if _, err := io.ReadFull(reader, maskKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
payload := make([]byte, payloadLen)
|
||||
if _, err := io.ReadFull(reader, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if masked {
|
||||
for i := 0; i < payloadLen; i++ {
|
||||
payload[i] ^= maskKey[i%WS_MASK_KEY_SIZE]
|
||||
}
|
||||
}
|
||||
|
||||
if !fin {
|
||||
nextFrame, err := wsi.readFrame()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(payload, nextFrame...), nil
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) Send(data []byte, addr string) error {
|
||||
wsi.Mutex.RLock()
|
||||
enabled := wsi.Enabled
|
||||
detached := wsi.Detached
|
||||
connected := wsi.connected
|
||||
wsi.Mutex.RUnlock()
|
||||
|
||||
if !enabled || detached {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "WebSocket interface not enabled or detached, dropping packet", "name", wsi.Name, "bytes", len(data))
|
||||
return fmt.Errorf("interface not enabled")
|
||||
}
|
||||
|
||||
wsi.Mutex.Lock()
|
||||
wsi.TxBytes += uint64(len(data))
|
||||
wsi.Mutex.Unlock()
|
||||
|
||||
if !connected {
|
||||
debug.Log(debug.DEBUG_VERBOSE, "WebSocket not connected, queuing packet", "name", wsi.Name, "bytes", len(data), "queue_size", len(wsi.messageQueue))
|
||||
wsi.Mutex.Lock()
|
||||
wsi.messageQueue = append(wsi.messageQueue, data)
|
||||
wsi.Mutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
packetType := "unknown"
|
||||
if len(data) > 0 {
|
||||
switch data[0] {
|
||||
case 0x01:
|
||||
packetType = "announce"
|
||||
case 0x02:
|
||||
packetType = "link"
|
||||
default:
|
||||
packetType = fmt.Sprintf("0x%02x", data[0])
|
||||
}
|
||||
}
|
||||
debug.Log(debug.DEBUG_INFO, "Sending packet over WebSocket", "name", wsi.Name, "bytes", len(data), "packet_type", packetType)
|
||||
return wsi.sendWebSocketMessage(data)
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) sendWebSocketMessage(data []byte) error {
|
||||
wsi.Mutex.RLock()
|
||||
conn := wsi.conn
|
||||
wsi.Mutex.RUnlock()
|
||||
|
||||
if conn == nil {
|
||||
return fmt.Errorf("WebSocket not initialized")
|
||||
}
|
||||
|
||||
frame := wsi.createFrame(data, WS_OPCODE_BINARY, true)
|
||||
wsi.Mutex.Lock()
|
||||
_, err := conn.Write(frame)
|
||||
wsi.Mutex.Unlock()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send: %v", err)
|
||||
}
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "WebSocket sent packet successfully", "name", wsi.Name, "bytes", len(data), "frame_bytes", len(frame))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) sendCloseFrame() {
|
||||
wsi.Mutex.RLock()
|
||||
defer wsi.Mutex.RUnlock()
|
||||
wsi.sendCloseFrameLocked()
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) sendCloseFrameLocked() {
|
||||
conn := wsi.conn
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
frame := wsi.createFrame(nil, WS_OPCODE_CLOSE, true)
|
||||
_, _ = conn.Write(frame)
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) handlePingFrame(reader *bufio.Reader, payloadLen int, masked bool) ([]byte, error) {
|
||||
if payloadLen == WS_PAYLOAD_LEN_16BIT {
|
||||
lenBytes := make([]byte, 2)
|
||||
if _, err := io.ReadFull(reader, lenBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payloadLen = int(binary.BigEndian.Uint16(lenBytes))
|
||||
} else if payloadLen == WS_PAYLOAD_LEN_64BIT {
|
||||
lenBytes := make([]byte, 8)
|
||||
if _, err := io.ReadFull(reader, lenBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
val := binary.BigEndian.Uint64(lenBytes)
|
||||
if val > uint64(math.MaxInt) {
|
||||
return nil, fmt.Errorf("payload length exceeds maximum integer value")
|
||||
}
|
||||
payloadLen = int(val) // #nosec G115
|
||||
}
|
||||
|
||||
maskKey := make([]byte, WS_MASK_KEY_SIZE)
|
||||
if masked {
|
||||
if _, err := io.ReadFull(reader, maskKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
payload := make([]byte, payloadLen)
|
||||
if payloadLen > 0 {
|
||||
if _, err := io.ReadFull(reader, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if masked {
|
||||
for i := 0; i < payloadLen; i++ {
|
||||
payload[i] ^= maskKey[i%WS_MASK_KEY_SIZE]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wsi.sendPongFrame(payload)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) handlePongFrame(reader *bufio.Reader, payloadLen int, masked bool) ([]byte, error) {
|
||||
if payloadLen == WS_PAYLOAD_LEN_16BIT {
|
||||
lenBytes := make([]byte, 2)
|
||||
if _, err := io.ReadFull(reader, lenBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payloadLen = int(binary.BigEndian.Uint16(lenBytes))
|
||||
} else if payloadLen == WS_PAYLOAD_LEN_64BIT {
|
||||
lenBytes := make([]byte, 8)
|
||||
if _, err := io.ReadFull(reader, lenBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
val := binary.BigEndian.Uint64(lenBytes)
|
||||
if val > uint64(math.MaxInt) {
|
||||
return nil, fmt.Errorf("payload length exceeds maximum integer value")
|
||||
}
|
||||
payloadLen = int(val) // #nosec G115
|
||||
}
|
||||
|
||||
maskKey := make([]byte, WS_MASK_KEY_SIZE)
|
||||
if masked {
|
||||
if _, err := io.ReadFull(reader, maskKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if payloadLen > 0 {
|
||||
payload := make([]byte, payloadLen)
|
||||
if _, err := io.ReadFull(reader, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) sendPongFrame(data []byte) {
|
||||
wsi.Mutex.RLock()
|
||||
conn := wsi.conn
|
||||
wsi.Mutex.RUnlock()
|
||||
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
frame := wsi.createFrame(data, WS_OPCODE_PONG, true)
|
||||
wsi.Mutex.Lock()
|
||||
_, _ = conn.Write(frame)
|
||||
wsi.Mutex.Unlock()
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) createFrame(data []byte, opcode byte, fin bool) []byte {
|
||||
payloadLen := len(data)
|
||||
frame := make([]byte, WS_HEADER_SIZE)
|
||||
|
||||
if fin {
|
||||
frame[0] |= WS_FRAME_HEADER_FIN
|
||||
}
|
||||
frame[0] |= opcode
|
||||
|
||||
if payloadLen < WS_PAYLOAD_LEN_16BIT {
|
||||
frame[1] = byte(payloadLen)
|
||||
frame = append(frame, data...)
|
||||
} else if payloadLen < WS_MAX_PAYLOAD_16BIT {
|
||||
frame[1] = WS_PAYLOAD_LEN_16BIT // #nosec G602
|
||||
lenBytes := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(lenBytes, uint16(payloadLen)) // #nosec G115
|
||||
frame = append(frame, lenBytes...)
|
||||
frame = append(frame, data...)
|
||||
} else {
|
||||
frame[1] = WS_PAYLOAD_LEN_64BIT // #nosec G602
|
||||
lenBytes := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(lenBytes, uint64(payloadLen)) // #nosec G115
|
||||
frame = append(frame, lenBytes...)
|
||||
frame = append(frame, data...)
|
||||
}
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) ProcessOutgoing(data []byte) error {
|
||||
return wsi.Send(data, "")
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) GetConn() net.Conn {
|
||||
wsi.Mutex.RLock()
|
||||
defer wsi.Mutex.RUnlock()
|
||||
return wsi.conn
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) GetMTU() int {
|
||||
return wsi.MTU
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) IsEnabled() bool {
|
||||
wsi.Mutex.RLock()
|
||||
defer wsi.Mutex.RUnlock()
|
||||
return wsi.Enabled && wsi.Online && !wsi.Detached
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) SendPathRequest(packet []byte) error {
|
||||
return wsi.Send(packet, "")
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) SendLinkPacket(dest []byte, data []byte, timestamp time.Time) error {
|
||||
frame := make([]byte, 0, len(dest)+len(data)+9)
|
||||
frame = append(frame, WS_OPCODE_BINARY)
|
||||
frame = append(frame, dest...)
|
||||
ts := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(ts, uint64(timestamp.Unix())) // #nosec G115
|
||||
frame = append(frame, ts...)
|
||||
frame = append(frame, data...)
|
||||
return wsi.Send(frame, "")
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) GetBandwidthAvailable() bool {
|
||||
return wsi.BaseInterface.GetBandwidthAvailable()
|
||||
}
|
||||
|
||||
func generateWebSocketKey() (string, error) {
|
||||
key := make([]byte, WS_KEY_SIZE)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(key), nil
|
||||
}
|
||||
|
||||
func computeAcceptKey(key string) string {
|
||||
// bearer:disable go_gosec_crypto_weak_crypto
|
||||
h := sha1.New() // #nosec G401
|
||||
h.Write([]byte(key))
|
||||
h.Write([]byte(wsGUID))
|
||||
// bearer:disable go_lang_weak_hash_sha1
|
||||
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
280
pkg/interfaces/websocket_native_test.go
Normal file
280
pkg/interfaces/websocket_native_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
)
|
||||
|
||||
func TestWebSocketGUID(t *testing.T) {
|
||||
if wsGUID != "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" {
|
||||
t.Errorf("wsGUID mismatch: expected RFC 6455 GUID, got %s", wsGUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWebSocketKey(t *testing.T) {
|
||||
key1, err := generateWebSocketKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key: %v", err)
|
||||
}
|
||||
|
||||
key2, err := generateWebSocketKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate key: %v", err)
|
||||
}
|
||||
|
||||
if key1 == key2 {
|
||||
t.Error("Generated keys should be unique")
|
||||
}
|
||||
|
||||
if len(key1) != 24 {
|
||||
t.Errorf("Expected base64-encoded key length 24, got %d", len(key1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAcceptKey(t *testing.T) {
|
||||
testKey := "dGhlIHNhbXBsZSBub25jZQ=="
|
||||
expectedAccept := "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
|
||||
|
||||
accept := computeAcceptKey(testKey)
|
||||
if accept != expectedAccept {
|
||||
t.Errorf("Accept key mismatch: expected %s, got %s", expectedAccept, accept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWebSocketInterface(t *testing.T) {
|
||||
ws, err := NewWebSocketInterface("test", "wss://socket.quad4.io/ws", true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create WebSocket interface: %v", err)
|
||||
}
|
||||
|
||||
if ws.GetName() != "test" {
|
||||
t.Errorf("Expected name 'test', got %s", ws.GetName())
|
||||
}
|
||||
|
||||
if ws.GetType() != common.IF_TYPE_UDP {
|
||||
t.Errorf("Expected type IF_TYPE_UDP, got %v", ws.GetType())
|
||||
}
|
||||
|
||||
if ws.GetMTU() != 1064 {
|
||||
t.Errorf("Expected MTU 1064, got %d", ws.GetMTU())
|
||||
}
|
||||
|
||||
if ws.IsOnline() {
|
||||
t.Error("Interface should not be online before Start()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketConnection(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping network test in short mode")
|
||||
}
|
||||
|
||||
ws, err := NewWebSocketInterface("test", "wss://socket.quad4.io/ws", true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create WebSocket interface: %v", err)
|
||||
}
|
||||
|
||||
ws.SetPacketCallback(func(data []byte, ni common.NetworkInterface) {
|
||||
t.Logf("Received packet: %d bytes", len(data))
|
||||
})
|
||||
|
||||
err = ws.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start WebSocket: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
if !ws.IsOnline() {
|
||||
t.Error("WebSocket should be online after Start()")
|
||||
}
|
||||
|
||||
testData := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
err = ws.Send(testData, "")
|
||||
if err != nil {
|
||||
t.Errorf("Failed to send data: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
if err := ws.Stop(); err != nil {
|
||||
t.Errorf("Failed to stop WebSocket: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if ws.IsOnline() {
|
||||
t.Error("WebSocket should be offline after Stop()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketReconnection(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping network test in short mode")
|
||||
}
|
||||
|
||||
ws, err := NewWebSocketInterface("test", "wss://socket.quad4.io/ws", true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create WebSocket interface: %v", err)
|
||||
}
|
||||
|
||||
err = ws.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start WebSocket: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
if !ws.IsOnline() {
|
||||
t.Error("WebSocket should be online")
|
||||
}
|
||||
|
||||
conn := ws.GetConn()
|
||||
if conn == nil {
|
||||
t.Error("GetConn() should return a connection")
|
||||
}
|
||||
|
||||
conn.Close()
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
if ws.IsOnline() {
|
||||
t.Log("WebSocket reconnected successfully")
|
||||
}
|
||||
|
||||
if err := ws.Stop(); err != nil {
|
||||
t.Errorf("Failed to stop WebSocket: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestWebSocketMessageQueue(t *testing.T) {
|
||||
ws, err := NewWebSocketInterface("test", "wss://socket.quad4.io/ws", true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create WebSocket interface: %v", err)
|
||||
}
|
||||
|
||||
ws.Enable()
|
||||
|
||||
testData := []byte{0x01, 0x02, 0x03}
|
||||
err = ws.Send(testData, "")
|
||||
if err != nil {
|
||||
t.Errorf("Send should queue message when offline, got error: %v", err)
|
||||
}
|
||||
|
||||
if testing.Short() {
|
||||
return
|
||||
}
|
||||
|
||||
err = ws.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start WebSocket: %v", err)
|
||||
}
|
||||
|
||||
// Wait for interface to be online (up to 10 seconds)
|
||||
for i := 0; i < 100; i++ {
|
||||
if ws.IsOnline() {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
if !ws.IsOnline() {
|
||||
t.Error("WebSocket should be online")
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
if err := ws.Stop(); err != nil {
|
||||
t.Errorf("Failed to stop WebSocket: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestWebSocketFrameEncoding(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping frame encoding test in short mode")
|
||||
}
|
||||
|
||||
ws, err := NewWebSocketInterface("test", "wss://socket.quad4.io/ws", true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create WebSocket interface: %v", err)
|
||||
}
|
||||
|
||||
err = ws.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start WebSocket: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{"small frame", []byte{0x01, 0x02, 0x03}},
|
||||
{"medium frame", make([]byte, 200)},
|
||||
{"large frame", make([]byte, 1000)},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ws.Send(tc.data, "")
|
||||
if err != nil {
|
||||
t.Errorf("Failed to send %s: %v", tc.name, err)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
})
|
||||
}
|
||||
|
||||
if err := ws.Stop(); err != nil {
|
||||
t.Errorf("Failed to stop WebSocket: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestWebSocketEnableDisable(t *testing.T) {
|
||||
ws, err := NewWebSocketInterface("test", "wss://socket.quad4.io/ws", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create WebSocket interface: %v", err)
|
||||
}
|
||||
|
||||
if ws.IsEnabled() {
|
||||
t.Error("Interface should not be enabled initially")
|
||||
}
|
||||
|
||||
ws.Enable()
|
||||
if !ws.IsEnabled() {
|
||||
t.Error("Interface should be enabled after Enable()")
|
||||
}
|
||||
|
||||
ws.Disable()
|
||||
if ws.IsEnabled() {
|
||||
t.Error("Interface should not be enabled after Disable()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketDetach(t *testing.T) {
|
||||
ws, err := NewWebSocketInterface("test", "wss://socket.quad4.io/ws", true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create WebSocket interface: %v", err)
|
||||
}
|
||||
|
||||
if ws.IsDetached() {
|
||||
t.Error("Interface should not be detached initially")
|
||||
}
|
||||
|
||||
ws.Detach()
|
||||
if !ws.IsDetached() {
|
||||
t.Error("Interface should be detached after Detach()")
|
||||
}
|
||||
|
||||
if ws.IsOnline() {
|
||||
t.Error("Interface should be offline after Detach()")
|
||||
}
|
||||
}
|
||||
253
pkg/interfaces/websocket_wasm.go
Normal file
253
pkg/interfaces/websocket_wasm.go
Normal file
@@ -0,0 +1,253 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
//go:build js && wasm
|
||||
// +build js,wasm
|
||||
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"syscall/js"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
)
|
||||
|
||||
const (
|
||||
WS_MTU = 1064
|
||||
WS_BITRATE = 10000000
|
||||
WS_RECONNECT_DELAY = 2 * time.Second
|
||||
)
|
||||
|
||||
type WebSocketInterface struct {
|
||||
BaseInterface
|
||||
wsURL string
|
||||
ws js.Value
|
||||
connected bool
|
||||
messageQueue [][]byte
|
||||
}
|
||||
|
||||
func NewWebSocketInterface(name string, wsURL string, enabled bool) (*WebSocketInterface, error) {
|
||||
ws := &WebSocketInterface{
|
||||
BaseInterface: NewBaseInterface(name, common.IF_TYPE_UDP, enabled),
|
||||
wsURL: wsURL,
|
||||
messageQueue: make([][]byte, 0),
|
||||
}
|
||||
|
||||
ws.MTU = WS_MTU
|
||||
ws.Bitrate = WS_BITRATE
|
||||
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) GetName() string {
|
||||
return wsi.Name
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) GetType() common.InterfaceType {
|
||||
return wsi.Type
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) GetMode() common.InterfaceMode {
|
||||
return wsi.Mode
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) IsOnline() bool {
|
||||
wsi.Mutex.RLock()
|
||||
defer wsi.Mutex.RUnlock()
|
||||
return wsi.Online && wsi.connected
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) IsDetached() bool {
|
||||
wsi.Mutex.RLock()
|
||||
defer wsi.Mutex.RUnlock()
|
||||
return wsi.Detached
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) Detach() {
|
||||
wsi.Mutex.Lock()
|
||||
defer wsi.Mutex.Unlock()
|
||||
wsi.Detached = true
|
||||
wsi.Online = false
|
||||
wsi.closeWebSocket()
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) Enable() {
|
||||
wsi.Mutex.Lock()
|
||||
defer wsi.Mutex.Unlock()
|
||||
wsi.Enabled = true
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) Disable() {
|
||||
wsi.Mutex.Lock()
|
||||
defer wsi.Mutex.Unlock()
|
||||
wsi.Enabled = false
|
||||
wsi.closeWebSocket()
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) Start() error {
|
||||
wsi.Mutex.Lock()
|
||||
defer wsi.Mutex.Unlock()
|
||||
|
||||
if wsi.ws.Truthy() {
|
||||
return fmt.Errorf("WebSocket already started")
|
||||
}
|
||||
|
||||
ws := js.Global().Get("WebSocket").New(wsi.wsURL)
|
||||
ws.Set("binaryType", "arraybuffer")
|
||||
|
||||
ws.Set("onopen", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
wsi.Mutex.Lock()
|
||||
wsi.connected = true
|
||||
wsi.Online = true
|
||||
wsi.Mutex.Unlock()
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "WebSocket connected", "name", wsi.Name, "url", wsi.wsURL)
|
||||
|
||||
wsi.Mutex.Lock()
|
||||
queue := make([][]byte, len(wsi.messageQueue))
|
||||
copy(queue, wsi.messageQueue)
|
||||
wsi.messageQueue = wsi.messageQueue[:0]
|
||||
wsi.Mutex.Unlock()
|
||||
|
||||
for _, msg := range queue {
|
||||
wsi.sendWebSocketMessage(msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}))
|
||||
|
||||
ws.Set("onmessage", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
if len(args) < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
event := args[0]
|
||||
data := event.Get("data")
|
||||
|
||||
var packet []byte
|
||||
if data.Type() == js.TypeString {
|
||||
packet = []byte(data.String())
|
||||
} else if data.Type() == js.TypeObject {
|
||||
array := js.Global().Get("Uint8Array").New(data)
|
||||
length := array.Get("length").Int()
|
||||
packet = make([]byte, length)
|
||||
js.CopyBytesToGo(packet, array)
|
||||
} else {
|
||||
debug.Log(debug.DEBUG_ERROR, "Unknown WebSocket message type", "type", data.Type().String())
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(packet) < 1 {
|
||||
debug.Log(debug.DEBUG_ERROR, "WebSocket message empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
wsi.Mutex.Lock()
|
||||
wsi.RxBytes += uint64(len(packet))
|
||||
wsi.Mutex.Unlock()
|
||||
|
||||
wsi.ProcessIncoming(packet)
|
||||
|
||||
return nil
|
||||
}))
|
||||
|
||||
ws.Set("onerror", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
debug.Log(debug.DEBUG_ERROR, "WebSocket error", "name", wsi.Name)
|
||||
return nil
|
||||
}))
|
||||
|
||||
ws.Set("onclose", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
wsi.Mutex.Lock()
|
||||
wsi.connected = false
|
||||
wsi.Online = false
|
||||
wsi.Mutex.Unlock()
|
||||
|
||||
debug.Log(debug.DEBUG_INFO, "WebSocket closed", "name", wsi.Name)
|
||||
|
||||
if wsi.Enabled && !wsi.Detached {
|
||||
time.Sleep(WS_RECONNECT_DELAY)
|
||||
go wsi.Start()
|
||||
}
|
||||
|
||||
return nil
|
||||
}))
|
||||
|
||||
wsi.ws = ws
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) Stop() error {
|
||||
wsi.Mutex.Lock()
|
||||
defer wsi.Mutex.Unlock()
|
||||
wsi.Enabled = false
|
||||
wsi.closeWebSocket()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) closeWebSocket() {
|
||||
if wsi.ws.Truthy() {
|
||||
wsi.ws.Call("close")
|
||||
wsi.ws = js.Value{}
|
||||
}
|
||||
wsi.connected = false
|
||||
wsi.Online = false
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) Send(data []byte, addr string) error {
|
||||
if !wsi.IsEnabled() {
|
||||
return fmt.Errorf("interface not enabled")
|
||||
}
|
||||
|
||||
wsi.Mutex.Lock()
|
||||
wsi.TxBytes += uint64(len(data))
|
||||
wsi.Mutex.Unlock()
|
||||
|
||||
if !wsi.connected {
|
||||
wsi.Mutex.Lock()
|
||||
wsi.messageQueue = append(wsi.messageQueue, data)
|
||||
wsi.Mutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
return wsi.sendWebSocketMessage(data)
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) sendWebSocketMessage(data []byte) error {
|
||||
if !wsi.ws.Truthy() {
|
||||
return fmt.Errorf("WebSocket not initialized")
|
||||
}
|
||||
|
||||
if wsi.ws.Get("readyState").Int() != 1 {
|
||||
return fmt.Errorf("WebSocket not open")
|
||||
}
|
||||
|
||||
array := js.Global().Get("Uint8Array").New(len(data))
|
||||
js.CopyBytesToJS(array, data)
|
||||
|
||||
wsi.ws.Call("send", array)
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "WebSocket sent packet", "name", wsi.Name, "bytes", len(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) ProcessOutgoing(data []byte) error {
|
||||
return wsi.Send(data, "")
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) GetConn() net.Conn {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) GetMTU() int {
|
||||
return wsi.MTU
|
||||
}
|
||||
|
||||
func (wsi *WebSocketInterface) IsEnabled() bool {
|
||||
wsi.Mutex.RLock()
|
||||
defer wsi.Mutex.RUnlock()
|
||||
return wsi.Enabled && wsi.Online && !wsi.Detached
|
||||
}
|
||||
364
pkg/link/establishment_test.go
Normal file
364
pkg/link/establishment_test.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/destination"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/identity"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/packet"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/transport"
|
||||
)
|
||||
|
||||
func TestEphemeralKeyGeneration(t *testing.T) {
|
||||
link := &Link{}
|
||||
|
||||
if err := link.generateEphemeralKeys(); err != nil {
|
||||
t.Fatalf("Failed to generate ephemeral keys: %v", err)
|
||||
}
|
||||
|
||||
if len(link.prv) != KEYSIZE {
|
||||
t.Errorf("Expected private key length %d, got %d", KEYSIZE, len(link.prv))
|
||||
}
|
||||
|
||||
if len(link.pub) != KEYSIZE {
|
||||
t.Errorf("Expected public key length %d, got %d", KEYSIZE, len(link.pub))
|
||||
}
|
||||
|
||||
if len(link.sigPriv) != 64 {
|
||||
t.Errorf("Expected signing private key length 64, got %d", len(link.sigPriv))
|
||||
}
|
||||
|
||||
if len(link.sigPub) != 32 {
|
||||
t.Errorf("Expected signing public key length 32, got %d", len(link.sigPub))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignallingBytes(t *testing.T) {
|
||||
mtu := 500
|
||||
mode := byte(MODE_AES256_CBC)
|
||||
|
||||
bytes := signallingBytes(mtu, mode)
|
||||
|
||||
if len(bytes) != LINK_MTU_SIZE {
|
||||
t.Errorf("Expected signalling bytes length %d, got %d", LINK_MTU_SIZE, len(bytes))
|
||||
}
|
||||
|
||||
extractedMTU := (int(bytes[0]&0x1F) << 16) | (int(bytes[1]) << 8) | int(bytes[2])
|
||||
if extractedMTU != mtu {
|
||||
t.Errorf("Expected MTU %d, got %d", mtu, extractedMTU)
|
||||
}
|
||||
|
||||
extractedMode := (bytes[0] & MODE_BYTEMASK) >> 5
|
||||
if extractedMode != mode {
|
||||
t.Errorf("Expected mode %d, got %d", mode, extractedMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkIDGeneration(t *testing.T) {
|
||||
responderIdent, err := identity.NewIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create responder identity: %v", err)
|
||||
}
|
||||
|
||||
cfg := &common.ReticulumConfig{}
|
||||
transportInstance := transport.NewTransport(cfg)
|
||||
|
||||
dest, err := destination.New(responderIdent, destination.IN, destination.SINGLE, "test", transportInstance, "link")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create destination: %v", err)
|
||||
}
|
||||
|
||||
link := &Link{
|
||||
destination: dest,
|
||||
transport: transportInstance,
|
||||
initiator: true,
|
||||
}
|
||||
|
||||
if err := link.generateEphemeralKeys(); err != nil {
|
||||
t.Fatalf("Failed to generate keys: %v", err)
|
||||
}
|
||||
|
||||
link.mode = MODE_DEFAULT
|
||||
link.mtu = 500
|
||||
|
||||
signalling := signallingBytes(link.mtu, link.mode)
|
||||
requestData := make([]byte, 0, ECPUBSIZE+LINK_MTU_SIZE)
|
||||
requestData = append(requestData, link.pub...)
|
||||
requestData = append(requestData, link.sigPub...)
|
||||
requestData = append(requestData, signalling...)
|
||||
|
||||
pkt := &packet.Packet{
|
||||
HeaderType: packet.HeaderType1,
|
||||
PacketType: packet.PacketTypeLinkReq,
|
||||
TransportType: 0,
|
||||
Context: packet.ContextNone,
|
||||
ContextFlag: packet.FlagUnset,
|
||||
Hops: 0,
|
||||
DestinationType: dest.GetType(),
|
||||
DestinationHash: dest.GetHash(),
|
||||
Data: requestData,
|
||||
}
|
||||
|
||||
if err := pkt.Pack(); err != nil {
|
||||
t.Fatalf("Failed to pack packet: %v", err)
|
||||
}
|
||||
|
||||
linkID := linkIDFromPacket(pkt)
|
||||
|
||||
if len(linkID) != 16 {
|
||||
t.Errorf("Expected link ID length 16, got %d", len(linkID))
|
||||
}
|
||||
|
||||
t.Logf("Generated link ID: %x", linkID)
|
||||
}
|
||||
|
||||
func TestHandshake(t *testing.T) {
|
||||
link1 := &Link{}
|
||||
link2 := &Link{}
|
||||
|
||||
if err := link1.generateEphemeralKeys(); err != nil {
|
||||
t.Fatalf("Failed to generate keys for link1: %v", err)
|
||||
}
|
||||
|
||||
if err := link2.generateEphemeralKeys(); err != nil {
|
||||
t.Fatalf("Failed to generate keys for link2: %v", err)
|
||||
}
|
||||
|
||||
link1.peerPub = link2.pub
|
||||
link2.peerPub = link1.pub
|
||||
|
||||
link1.linkID = []byte("test-link-id-abc")
|
||||
link2.linkID = []byte("test-link-id-abc")
|
||||
|
||||
link1.mode = MODE_AES256_CBC
|
||||
link2.mode = MODE_AES256_CBC
|
||||
|
||||
if err := link1.performHandshake(); err != nil {
|
||||
t.Fatalf("Link1 handshake failed: %v", err)
|
||||
}
|
||||
|
||||
if err := link2.performHandshake(); err != nil {
|
||||
t.Fatalf("Link2 handshake failed: %v", err)
|
||||
}
|
||||
|
||||
if string(link1.sharedKey) != string(link2.sharedKey) {
|
||||
t.Error("Shared keys do not match")
|
||||
}
|
||||
|
||||
if string(link1.derivedKey) != string(link2.derivedKey) {
|
||||
t.Error("Derived keys do not match")
|
||||
}
|
||||
|
||||
if link1.status != STATUS_HANDSHAKE {
|
||||
t.Errorf("Expected link1 status HANDSHAKE, got %d", link1.status)
|
||||
}
|
||||
|
||||
if link2.status != STATUS_HANDSHAKE {
|
||||
t.Errorf("Expected link2 status HANDSHAKE, got %d", link2.status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkEstablishment(t *testing.T) {
|
||||
responderIdent, err := identity.NewIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create responder identity: %v", err)
|
||||
}
|
||||
|
||||
cfg := &common.ReticulumConfig{}
|
||||
transportInstance := transport.NewTransport(cfg)
|
||||
|
||||
dest, err := destination.New(responderIdent, destination.IN, destination.SINGLE, "test", transportInstance, "link")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create destination: %v", err)
|
||||
}
|
||||
|
||||
initiatorLink := &Link{
|
||||
destination: dest,
|
||||
transport: transportInstance,
|
||||
initiator: true,
|
||||
}
|
||||
|
||||
responderLink := &Link{
|
||||
transport: transportInstance,
|
||||
initiator: false,
|
||||
}
|
||||
|
||||
if err := initiatorLink.generateEphemeralKeys(); err != nil {
|
||||
t.Fatalf("Failed to generate initiator keys: %v", err)
|
||||
}
|
||||
|
||||
initiatorLink.mode = MODE_DEFAULT
|
||||
initiatorLink.mtu = 500
|
||||
|
||||
signalling := signallingBytes(initiatorLink.mtu, initiatorLink.mode)
|
||||
requestData := make([]byte, 0, ECPUBSIZE+LINK_MTU_SIZE)
|
||||
requestData = append(requestData, initiatorLink.pub...)
|
||||
requestData = append(requestData, initiatorLink.sigPub...)
|
||||
requestData = append(requestData, signalling...)
|
||||
|
||||
linkRequestPkt := &packet.Packet{
|
||||
HeaderType: packet.HeaderType1,
|
||||
PacketType: packet.PacketTypeLinkReq,
|
||||
TransportType: 0,
|
||||
Context: packet.ContextNone,
|
||||
ContextFlag: packet.FlagUnset,
|
||||
Hops: 0,
|
||||
DestinationType: dest.GetType(),
|
||||
DestinationHash: dest.GetHash(),
|
||||
Data: requestData,
|
||||
}
|
||||
|
||||
if err := linkRequestPkt.Pack(); err != nil {
|
||||
t.Fatalf("Failed to pack link request: %v", err)
|
||||
}
|
||||
|
||||
initiatorLink.linkID = linkIDFromPacket(linkRequestPkt)
|
||||
initiatorLink.requestTime = time.Now()
|
||||
initiatorLink.status = STATUS_PENDING
|
||||
|
||||
t.Logf("Initiator link request created, link_id=%x", initiatorLink.linkID)
|
||||
|
||||
responderLink.peerPub = linkRequestPkt.Data[0:KEYSIZE]
|
||||
responderLink.peerSigPub = linkRequestPkt.Data[KEYSIZE:ECPUBSIZE]
|
||||
responderLink.linkID = linkIDFromPacket(linkRequestPkt)
|
||||
responderLink.initiator = false
|
||||
|
||||
t.Logf("Responder link ID=%x (len=%d)", responderLink.linkID, len(responderLink.linkID))
|
||||
|
||||
if len(responderLink.linkID) == 0 {
|
||||
t.Fatal("Responder link ID is empty!")
|
||||
}
|
||||
|
||||
if len(linkRequestPkt.Data) >= ECPUBSIZE+LINK_MTU_SIZE {
|
||||
mtuBytes := linkRequestPkt.Data[ECPUBSIZE : ECPUBSIZE+LINK_MTU_SIZE]
|
||||
responderLink.mtu = (int(mtuBytes[0]&0x1F) << 16) | (int(mtuBytes[1]) << 8) | int(mtuBytes[2])
|
||||
responderLink.mode = (mtuBytes[0] & MODE_BYTEMASK) >> 5
|
||||
}
|
||||
|
||||
if err := responderLink.generateEphemeralKeys(); err != nil {
|
||||
t.Fatalf("Failed to generate responder keys: %v", err)
|
||||
}
|
||||
|
||||
if err := responderLink.performHandshake(); err != nil {
|
||||
t.Fatalf("Responder handshake failed: %v", err)
|
||||
}
|
||||
|
||||
responderLink.status = STATUS_ACTIVE
|
||||
responderLink.establishedAt = time.Now()
|
||||
|
||||
if string(responderLink.linkID) != string(initiatorLink.linkID) {
|
||||
t.Error("Link IDs do not match between initiator and responder")
|
||||
}
|
||||
|
||||
t.Logf("Responder handshake successful, shared_key_len=%d", len(responderLink.sharedKey))
|
||||
}
|
||||
|
||||
func TestLinkProofValidation(t *testing.T) {
|
||||
responderIdent, err := identity.NewIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create responder identity: %v", err)
|
||||
}
|
||||
|
||||
cfg := &common.ReticulumConfig{}
|
||||
transportInstance := transport.NewTransport(cfg)
|
||||
|
||||
dest, err := destination.New(responderIdent, destination.IN, destination.SINGLE, "test", transportInstance, "link")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create destination: %v", err)
|
||||
}
|
||||
|
||||
initiatorLink := &Link{
|
||||
destination: dest,
|
||||
transport: transportInstance,
|
||||
initiator: true,
|
||||
}
|
||||
|
||||
responderLink := &Link{
|
||||
transport: transportInstance,
|
||||
initiator: false,
|
||||
}
|
||||
|
||||
if err := initiatorLink.generateEphemeralKeys(); err != nil {
|
||||
t.Fatalf("Failed to generate initiator keys: %v", err)
|
||||
}
|
||||
|
||||
initiatorLink.mode = MODE_DEFAULT
|
||||
initiatorLink.mtu = 500
|
||||
|
||||
signalling := signallingBytes(initiatorLink.mtu, initiatorLink.mode)
|
||||
requestData := make([]byte, 0, ECPUBSIZE+LINK_MTU_SIZE)
|
||||
requestData = append(requestData, initiatorLink.pub...)
|
||||
requestData = append(requestData, initiatorLink.sigPub...)
|
||||
requestData = append(requestData, signalling...)
|
||||
|
||||
linkRequestPkt := &packet.Packet{
|
||||
HeaderType: packet.HeaderType1,
|
||||
PacketType: packet.PacketTypeLinkReq,
|
||||
TransportType: 0,
|
||||
Context: packet.ContextNone,
|
||||
ContextFlag: packet.FlagUnset,
|
||||
Hops: 0,
|
||||
DestinationType: dest.GetType(),
|
||||
DestinationHash: dest.GetHash(),
|
||||
Data: requestData,
|
||||
}
|
||||
|
||||
if err := linkRequestPkt.Pack(); err != nil {
|
||||
t.Fatalf("Failed to pack link request: %v", err)
|
||||
}
|
||||
|
||||
initiatorLink.linkID = linkIDFromPacket(linkRequestPkt)
|
||||
initiatorLink.requestTime = time.Now()
|
||||
initiatorLink.status = STATUS_PENDING
|
||||
|
||||
responderLink.peerPub = linkRequestPkt.Data[0:KEYSIZE]
|
||||
responderLink.peerSigPub = linkRequestPkt.Data[KEYSIZE:ECPUBSIZE]
|
||||
responderLink.linkID = linkIDFromPacket(linkRequestPkt)
|
||||
responderLink.initiator = false
|
||||
|
||||
if len(linkRequestPkt.Data) >= ECPUBSIZE+LINK_MTU_SIZE {
|
||||
mtuBytes := linkRequestPkt.Data[ECPUBSIZE : ECPUBSIZE+LINK_MTU_SIZE]
|
||||
responderLink.mtu = (int(mtuBytes[0]&0x1F) << 16) | (int(mtuBytes[1]) << 8) | int(mtuBytes[2])
|
||||
responderLink.mode = (mtuBytes[0] & MODE_BYTEMASK) >> 5
|
||||
} else {
|
||||
responderLink.mtu = 500
|
||||
responderLink.mode = MODE_DEFAULT
|
||||
}
|
||||
|
||||
if err := responderLink.generateEphemeralKeys(); err != nil {
|
||||
t.Fatalf("Failed to generate responder keys: %v", err)
|
||||
}
|
||||
|
||||
if err := responderLink.performHandshake(); err != nil {
|
||||
t.Fatalf("Responder handshake failed: %v", err)
|
||||
}
|
||||
|
||||
proofPkt, err := responderLink.GenerateLinkProof(responderIdent)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate link proof: %v", err)
|
||||
}
|
||||
|
||||
if err := initiatorLink.ValidateLinkProof(proofPkt, nil); err != nil {
|
||||
t.Fatalf("Initiator failed to validate link proof: %v", err)
|
||||
}
|
||||
|
||||
if initiatorLink.status != STATUS_ACTIVE {
|
||||
t.Errorf("Expected initiator status ACTIVE, got %d", initiatorLink.status)
|
||||
}
|
||||
|
||||
if string(initiatorLink.sharedKey) != string(responderLink.sharedKey) {
|
||||
t.Error("Shared keys do not match after full handshake")
|
||||
}
|
||||
|
||||
if string(initiatorLink.derivedKey) != string(responderLink.derivedKey) {
|
||||
t.Error("Derived keys do not match after full handshake")
|
||||
}
|
||||
|
||||
t.Logf("Full link establishment successful")
|
||||
t.Logf("Link ID: %x", initiatorLink.linkID)
|
||||
t.Logf("Shared key length: %d", len(initiatorLink.sharedKey))
|
||||
t.Logf("Derived key length: %d", len(initiatorLink.derivedKey))
|
||||
t.Logf("RTT: %.3f seconds", initiatorLink.rtt)
|
||||
}
|
||||
2086
pkg/link/link.go
2086
pkg/link/link.go
File diff suppressed because it is too large
Load Diff
218
pkg/link/link_test.go
Normal file
218
pkg/link/link_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/common"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/destination"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/identity"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/packet"
|
||||
)
|
||||
|
||||
type mockTransport struct {
|
||||
sentPackets []*packet.Packet
|
||||
}
|
||||
|
||||
func (m *mockTransport) SendPacket(pkt *packet.Packet) error {
|
||||
m.sentPackets = append(m.sentPackets, pkt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockTransport) RegisterLink(linkID []byte, link interface{}) {
|
||||
}
|
||||
|
||||
func (m *mockTransport) GetConfig() *common.ReticulumConfig {
|
||||
return &common.ReticulumConfig{}
|
||||
}
|
||||
|
||||
func (m *mockTransport) GetInterfaces() map[string]common.NetworkInterface {
|
||||
return make(map[string]common.NetworkInterface)
|
||||
}
|
||||
|
||||
func (m *mockTransport) RegisterDestination(hash []byte, dest interface{}) {
|
||||
}
|
||||
|
||||
type mockInterface struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (m *mockInterface) GetName() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
func (m *mockInterface) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockInterface) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockInterface) Send(data []byte, address string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockInterface) ProcessIncoming(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockInterface) SetPacketCallback(cb func([]byte, common.NetworkInterface)) {
|
||||
}
|
||||
|
||||
func (m *mockInterface) GetType() string {
|
||||
return "mock"
|
||||
}
|
||||
|
||||
func (m *mockInterface) GetMTU() int {
|
||||
return 500
|
||||
}
|
||||
|
||||
func (m *mockInterface) Detach() {
|
||||
}
|
||||
|
||||
func (m *mockInterface) Enable() {
|
||||
}
|
||||
|
||||
func (m *mockInterface) Disable() {
|
||||
}
|
||||
|
||||
func (m *mockInterface) IsEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *mockInterface) IsOnline() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *mockInterface) IsDetached() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *mockInterface) GetPacketCallback() func([]byte, common.NetworkInterface) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockInterface) GetConn() interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockInterface) ProcessOutgoing(data []byte) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (m *mockInterface) SendPathRequest(destHash []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockInterface) SendLinkPacket(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockInterface) GetBandwidthAvailable() float64 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
func TestLinkRequestResponse(t *testing.T) {
|
||||
serverIdent, err := identity.New()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create server identity: %v", err)
|
||||
}
|
||||
|
||||
clientIdent, err := identity.New()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create client identity: %v", err)
|
||||
}
|
||||
|
||||
mockTrans := &mockTransport{
|
||||
sentPackets: make([]*packet.Packet, 0),
|
||||
}
|
||||
|
||||
serverDest, err := destination.New(serverIdent, destination.IN, destination.SINGLE, "testapp", mockTrans, "server")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create server destination: %v", err)
|
||||
}
|
||||
|
||||
expectedResponse := []byte("response data")
|
||||
testPath := "/test/path"
|
||||
|
||||
err = serverDest.RegisterRequestHandler(testPath, func(path string, data []byte, requestID []byte, linkID []byte, remoteIdentity *identity.Identity, requestedAt int64) []byte {
|
||||
if path != testPath {
|
||||
t.Errorf("Expected path %s, got %s", testPath, path)
|
||||
}
|
||||
return expectedResponse
|
||||
}, destination.ALLOW_ALL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to register request handler: %v", err)
|
||||
}
|
||||
|
||||
// Test the handler is registered correctly
|
||||
pathHash := identity.TruncatedHash([]byte(testPath))
|
||||
handler := serverDest.GetRequestHandler(pathHash)
|
||||
if handler == nil {
|
||||
t.Fatal("Handler not found after registration")
|
||||
}
|
||||
|
||||
// Call the handler
|
||||
testLinkID := make([]byte, 16)
|
||||
result := handler(pathHash, []byte("test data"), []byte("request-id"), testLinkID, clientIdent, time.Now())
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Handler returned nil")
|
||||
}
|
||||
|
||||
responseBytes, ok := result.([]byte)
|
||||
if !ok {
|
||||
t.Fatalf("Handler returned unexpected type: %T", result)
|
||||
}
|
||||
|
||||
if !bytes.Equal(responseBytes, expectedResponse) {
|
||||
t.Errorf("Expected response %q, got %q", expectedResponse, responseBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkRequestHandlerNotFound(t *testing.T) {
|
||||
serverIdent, _ := identity.New()
|
||||
mockTrans := &mockTransport{sentPackets: make([]*packet.Packet, 0)}
|
||||
|
||||
serverDest, _ := destination.New(serverIdent, destination.IN, destination.SINGLE, "testapp", mockTrans, "server")
|
||||
|
||||
nonExistentPath := "/does/not/exist"
|
||||
pathHash := identity.TruncatedHash([]byte(nonExistentPath))
|
||||
|
||||
handler := serverDest.GetRequestHandler(pathHash)
|
||||
if handler != nil {
|
||||
t.Error("Expected no handler for non-existent path, but found one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkResponseHandling(t *testing.T) {
|
||||
// This test verifies the basic structure for response handling
|
||||
// Full integration testing would require a proper transport setup
|
||||
|
||||
requestID := []byte("test-request-id-")
|
||||
responseData := []byte("response payload")
|
||||
|
||||
receipt := &RequestReceipt{
|
||||
requestID: requestID,
|
||||
status: STATUS_PENDING,
|
||||
}
|
||||
|
||||
// Verify initial state
|
||||
if receipt.status != STATUS_PENDING {
|
||||
t.Errorf("Expected initial status PENDING, got %d", receipt.status)
|
||||
}
|
||||
|
||||
// Simulate setting response
|
||||
receipt.response = responseData
|
||||
receipt.status = STATUS_ACTIVE
|
||||
|
||||
if !bytes.Equal(receipt.response, responseData) {
|
||||
t.Errorf("Expected response %q, got %q", responseData, receipt.response)
|
||||
}
|
||||
|
||||
if receipt.status != STATUS_ACTIVE {
|
||||
t.Errorf("Expected status ACTIVE after response, got %d", receipt.status)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package packet
|
||||
|
||||
const (
|
||||
// MTU constants
|
||||
EncryptedMDU = 383 // Maximum size of payload data in encrypted packet
|
||||
PlainMDU = 464 // Maximum size of payload data in unencrypted packet
|
||||
|
||||
// Header Types
|
||||
HeaderType1 = 0 // Two byte header, one 16 byte address field
|
||||
HeaderType2 = 1 // Two byte header, two 16 byte address fields
|
||||
PlainMDU = 464 // Maximum size of payload data in unencrypted packet
|
||||
|
||||
// Propagation Types
|
||||
PropagationBroadcast = 0
|
||||
@@ -19,9 +17,7 @@ const (
|
||||
DestinationPlain = 2
|
||||
DestinationLink = 3
|
||||
|
||||
// Packet Types
|
||||
PacketData = 0
|
||||
PacketAnnounce = 1
|
||||
PacketLinkRequest = 2
|
||||
PacketProof = 3
|
||||
)
|
||||
// Minimum packet sizes
|
||||
MinAnnounceSize = 170 // header(2) + desthash(16) + context(1) + enckey(32) + signkey(32) +
|
||||
// namehash(10) + randomhash(10) + signature(64) + min appdata(3)
|
||||
)
|
||||
|
||||
@@ -1,171 +1,356 @@
|
||||
// SPDX-License-Identifier: 0BSD
|
||||
// Copyright (c) 2024-2026 Sudo-Ivan / Quad4.io
|
||||
package packet
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/debug"
|
||||
"git.quad4.io/Networks/Reticulum-Go/pkg/identity"
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderSize = 2
|
||||
AddressSize = 16
|
||||
ContextSize = 1
|
||||
MaxDataSize = 465 // Maximum size of payload data
|
||||
)
|
||||
// Packet Types
|
||||
PacketTypeData = 0x00
|
||||
PacketTypeAnnounce = 0x01
|
||||
PacketTypeLinkReq = 0x02
|
||||
PacketTypeProof = 0x03
|
||||
|
||||
// Header flags and types
|
||||
const (
|
||||
// First byte flags
|
||||
IFACFlag = 0x80 // Interface authentication code flag
|
||||
HeaderTypeFlag = 0x40 // Header type flag
|
||||
ContextFlag = 0x20 // Context flag
|
||||
PropagationFlags = 0x18 // Propagation type flags (bits 3-4)
|
||||
DestinationFlags = 0x06 // Destination type flags (bits 1-2)
|
||||
PacketTypeFlags = 0x01 // Packet type flags (bit 0)
|
||||
// Header Types
|
||||
HeaderType1 = 0x00
|
||||
HeaderType2 = 0x01
|
||||
|
||||
// Second byte
|
||||
HopsField = 0xFF // Number of hops (entire byte)
|
||||
// Context Types
|
||||
ContextNone = 0x00
|
||||
ContextResource = 0x01
|
||||
ContextResourceAdv = 0x02
|
||||
ContextResourceReq = 0x03
|
||||
ContextResourceHMU = 0x04
|
||||
ContextResourcePRF = 0x05
|
||||
ContextResourceICL = 0x06
|
||||
ContextResourceRCL = 0x07
|
||||
ContextCacheReq = 0x08
|
||||
ContextRequest = 0x09
|
||||
ContextResponse = 0x0A
|
||||
ContextPathResponse = 0x0B
|
||||
ContextCommand = 0x0C
|
||||
ContextCmdStatus = 0x0D
|
||||
ContextChannel = 0x0E
|
||||
ContextKeepalive = 0xFA
|
||||
ContextLinkIdentify = 0xFB
|
||||
ContextLinkClose = 0xFC
|
||||
ContextLinkProof = 0xFD
|
||||
ContextLRRTT = 0xFE
|
||||
ContextLRProof = 0xFF
|
||||
|
||||
// Flag Values
|
||||
FlagSet = 0x01
|
||||
FlagUnset = 0x00
|
||||
|
||||
// Header sizes
|
||||
HeaderMaxSize = 64
|
||||
MTU = 500
|
||||
|
||||
AddressSize = 32 // Size of address/hash fields in bytes
|
||||
)
|
||||
|
||||
type Packet struct {
|
||||
Header [2]byte
|
||||
Addresses []byte // Either 16 or 32 bytes depending on header type
|
||||
Context byte
|
||||
Data []byte
|
||||
AccessCode []byte // Optional: Only present if IFAC flag is set
|
||||
HeaderType byte
|
||||
PacketType byte
|
||||
TransportType byte
|
||||
Context byte
|
||||
ContextFlag byte
|
||||
Hops byte
|
||||
|
||||
DestinationType byte
|
||||
DestinationHash []byte
|
||||
Destination interface{}
|
||||
TransportID []byte
|
||||
Data []byte
|
||||
|
||||
Raw []byte
|
||||
Packed bool
|
||||
Sent bool
|
||||
CreateReceipt bool
|
||||
FromPacked bool
|
||||
|
||||
SentAt time.Time
|
||||
PacketHash []byte
|
||||
RatchetID []byte
|
||||
|
||||
RSSI *float64
|
||||
SNR *float64
|
||||
Q *float64
|
||||
|
||||
Addresses []byte
|
||||
Link interface{}
|
||||
|
||||
receipt *PacketReceipt
|
||||
}
|
||||
|
||||
func NewPacket(headerType, propagationType, destinationType, packetType byte, hops byte) *Packet {
|
||||
p := &Packet{
|
||||
Header: [2]byte{0, hops},
|
||||
Addresses: make([]byte, 0),
|
||||
Data: make([]byte, 0),
|
||||
type PacketConfig struct {
|
||||
DestType byte
|
||||
Data []byte
|
||||
PacketType byte
|
||||
Context byte
|
||||
TransportType byte
|
||||
HeaderType byte
|
||||
TransportID []byte
|
||||
CreateReceipt bool
|
||||
ContextFlag byte
|
||||
}
|
||||
|
||||
func NewPacket(destType byte, data []byte, packetType byte, context byte,
|
||||
transportType byte, headerType byte, transportID []byte, createReceipt bool,
|
||||
contextFlag byte) *Packet {
|
||||
|
||||
return &Packet{
|
||||
HeaderType: headerType,
|
||||
PacketType: packetType,
|
||||
TransportType: transportType,
|
||||
Context: context,
|
||||
ContextFlag: contextFlag,
|
||||
Hops: 0,
|
||||
DestinationType: destType,
|
||||
Data: data,
|
||||
TransportID: transportID,
|
||||
CreateReceipt: createReceipt,
|
||||
Packed: false,
|
||||
Sent: false,
|
||||
FromPacked: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Packet) Pack() error {
|
||||
if p.Packed {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set header type
|
||||
if headerType == HeaderType2 {
|
||||
p.Header[0] |= HeaderTypeFlag
|
||||
p.Addresses = make([]byte, 2*AddressSize) // Two address fields
|
||||
debug.Log(debug.DEBUG_PACKETS, "Packing packet", "type", p.PacketType, "header", p.HeaderType)
|
||||
|
||||
// Create header byte (Corrected order)
|
||||
flags := byte(0)
|
||||
flags |= (p.HeaderType << 6) & 0b01000000
|
||||
flags |= (p.ContextFlag << 5) & 0b00100000
|
||||
flags |= (p.TransportType << 4) & 0b00010000
|
||||
flags |= (p.DestinationType << 2) & 0b00001100
|
||||
flags |= p.PacketType & 0b00000011
|
||||
|
||||
header := []byte{flags, p.Hops}
|
||||
debug.Log(debug.DEBUG_TRACE, "Created packet header", "flags", fmt.Sprintf("%08b", flags), "hops", p.Hops)
|
||||
|
||||
header = append(header, p.DestinationHash...)
|
||||
|
||||
if p.HeaderType == HeaderType2 {
|
||||
if p.TransportID == nil {
|
||||
return errors.New("transport ID required for header type 2")
|
||||
}
|
||||
header = append(header, p.TransportID...)
|
||||
debug.Log(debug.DEBUG_ALL, "Added transport ID to header", "transport_id", fmt.Sprintf("%x", p.TransportID))
|
||||
}
|
||||
|
||||
header = append(header, p.Context)
|
||||
debug.Log(debug.DEBUG_PACKETS, "Final header length", "bytes", len(header))
|
||||
|
||||
p.Raw = append(header, p.Data...)
|
||||
debug.Log(debug.DEBUG_TRACE, "Final packet size", "bytes", len(p.Raw))
|
||||
|
||||
if len(p.Raw) > MTU {
|
||||
return errors.New("packet size exceeds MTU")
|
||||
}
|
||||
|
||||
p.Packed = true
|
||||
p.updateHash()
|
||||
debug.Log(debug.DEBUG_ALL, "Packet hash", "hash", fmt.Sprintf("%x", p.PacketHash))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Packet) Unpack() error {
|
||||
if len(p.Raw) < 3 {
|
||||
return errors.New("packet too short")
|
||||
}
|
||||
|
||||
flags := p.Raw[0]
|
||||
p.Hops = p.Raw[1]
|
||||
|
||||
p.HeaderType = (flags & 0b01000000) >> 6
|
||||
p.ContextFlag = (flags & 0b00100000) >> 5
|
||||
p.TransportType = (flags & 0b00010000) >> 4
|
||||
p.DestinationType = (flags & 0b00001100) >> 2
|
||||
p.PacketType = flags & 0b00000011
|
||||
|
||||
dstLen := 16 // Truncated hash length
|
||||
|
||||
if p.HeaderType == HeaderType2 {
|
||||
// Header Type 2: Header(2) + DestHash(16) + TransportID(16) + Context(1) + Data
|
||||
if len(p.Raw) < 2*dstLen+3 {
|
||||
return errors.New("packet too short for header type 2")
|
||||
}
|
||||
p.DestinationHash = p.Raw[2 : dstLen+2] // Destination hash first
|
||||
p.TransportID = p.Raw[dstLen+2 : 2*dstLen+2] // Transport ID second
|
||||
p.Context = p.Raw[2*dstLen+2]
|
||||
p.Data = p.Raw[2*dstLen+3:]
|
||||
} else {
|
||||
p.Addresses = make([]byte, AddressSize) // One address field
|
||||
// Header Type 1: Header(2) + DestHash(16) + Context(1) + Data
|
||||
if len(p.Raw) < dstLen+3 {
|
||||
return errors.New("packet too short for header type 1")
|
||||
}
|
||||
p.TransportID = nil
|
||||
p.DestinationHash = p.Raw[2 : dstLen+2]
|
||||
p.Context = p.Raw[dstLen+2]
|
||||
p.Data = p.Raw[dstLen+3:]
|
||||
}
|
||||
|
||||
// Set propagation type
|
||||
p.Header[0] |= (propagationType << 3) & PropagationFlags
|
||||
|
||||
// Set destination type
|
||||
p.Header[0] |= (destinationType << 1) & DestinationFlags
|
||||
|
||||
// Set packet type
|
||||
p.Header[0] |= packetType & PacketTypeFlags
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Packet) SetAccessCode(code []byte) {
|
||||
p.AccessCode = code
|
||||
p.Header[0] |= IFACFlag
|
||||
}
|
||||
|
||||
func (p *Packet) SetContext(context byte) {
|
||||
p.Context = context
|
||||
p.Header[0] |= ContextFlag
|
||||
}
|
||||
|
||||
func (p *Packet) SetData(data []byte) error {
|
||||
if len(data) > MaxDataSize {
|
||||
return errors.New("data exceeds maximum allowed size")
|
||||
}
|
||||
p.Data = data
|
||||
p.Packed = false
|
||||
p.updateHash()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Packet) SetAddress(index int, address []byte) error {
|
||||
if len(address) != AddressSize {
|
||||
return errors.New("invalid address size")
|
||||
func (p *Packet) GetHash() []byte {
|
||||
hashable := p.getHashablePart()
|
||||
hash := sha256.Sum256(hashable)
|
||||
return hash[:]
|
||||
}
|
||||
|
||||
func (p *Packet) getHashablePart() []byte {
|
||||
hashable := []byte{p.Raw[0] & 0b00001111} // Lower 4 bits of flags
|
||||
if p.HeaderType == HeaderType2 {
|
||||
// Start hash from DestHash (index 18), skipping TransportID
|
||||
dstLen := 16 // RNS.Identity.TRUNCATED_HASHLENGTH / 8
|
||||
startIndex := dstLen + 2
|
||||
if len(p.Raw) > startIndex {
|
||||
hashable = append(hashable, p.Raw[startIndex:]...)
|
||||
}
|
||||
} else {
|
||||
// Start hash from DestHash (index 2)
|
||||
if len(p.Raw) > 2 {
|
||||
hashable = append(hashable, p.Raw[2:]...)
|
||||
}
|
||||
}
|
||||
|
||||
offset := index * AddressSize
|
||||
if offset+AddressSize > len(p.Addresses) {
|
||||
return errors.New("address index out of range")
|
||||
return hashable
|
||||
}
|
||||
|
||||
func (p *Packet) updateHash() {
|
||||
p.PacketHash = p.GetHash()
|
||||
}
|
||||
|
||||
func (p *Packet) Hash() []byte {
|
||||
return p.GetHash()
|
||||
}
|
||||
|
||||
func (p *Packet) TruncatedHash() []byte {
|
||||
hash := p.GetHash()
|
||||
if len(hash) >= 16 {
|
||||
return hash[:16]
|
||||
}
|
||||
|
||||
copy(p.Addresses[offset:], address)
|
||||
return nil
|
||||
return hash
|
||||
}
|
||||
|
||||
func (p *Packet) Serialize() ([]byte, error) {
|
||||
totalSize := HeaderSize + len(p.Addresses) + ContextSize + len(p.Data)
|
||||
if p.AccessCode != nil {
|
||||
totalSize += len(p.AccessCode)
|
||||
if !p.Packed {
|
||||
if err := p.Pack(); err != nil {
|
||||
return nil, fmt.Errorf("failed to pack packet: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
buffer := make([]byte, totalSize)
|
||||
offset := 0
|
||||
p.Addresses = p.DestinationHash
|
||||
|
||||
// Write header
|
||||
copy(buffer[offset:], p.Header[:])
|
||||
offset += HeaderSize
|
||||
|
||||
// Write access code if present
|
||||
if p.AccessCode != nil {
|
||||
copy(buffer[offset:], p.AccessCode)
|
||||
offset += len(p.AccessCode)
|
||||
}
|
||||
|
||||
// Write addresses
|
||||
copy(buffer[offset:], p.Addresses)
|
||||
offset += len(p.Addresses)
|
||||
|
||||
// Write context
|
||||
buffer[offset] = p.Context
|
||||
offset += ContextSize
|
||||
|
||||
// Write data
|
||||
copy(buffer[offset:], p.Data)
|
||||
|
||||
return buffer, nil
|
||||
return p.Raw, nil
|
||||
}
|
||||
|
||||
func ParsePacket(data []byte) (*Packet, error) {
|
||||
if len(data) < HeaderSize {
|
||||
return nil, errors.New("packet data too short")
|
||||
func NewAnnouncePacket(destHash []byte, identity *identity.Identity, appData []byte, transportID []byte) (*Packet, error) {
|
||||
debug.Log(debug.DEBUG_ALL, "Creating new announce packet", "dest_hash", fmt.Sprintf("%x", destHash), "app_data", fmt.Sprintf("%x", appData))
|
||||
|
||||
// Get public key separated into encryption and signing keys
|
||||
pubKey := identity.GetPublicKey()
|
||||
encKey := pubKey[:32]
|
||||
signKey := pubKey[32:]
|
||||
debug.Log(debug.DEBUG_PACKETS, "Using public keys", "enc_key", fmt.Sprintf("%x", encKey), "sign_key", fmt.Sprintf("%x", signKey))
|
||||
|
||||
// Parse app name from first msgpack element if possible
|
||||
// For nodes, we'll use "reticulum.node" as the name hash
|
||||
var appName string
|
||||
if len(appData) > 2 && appData[0] == 0x93 {
|
||||
// This is a node announce, use standard node name
|
||||
appName = "reticulum.node"
|
||||
} else if len(appData) > 3 && appData[0] == 0x92 && appData[1] == 0xc4 {
|
||||
// Try to extract name from peer announce appData
|
||||
nameLen := int(appData[2])
|
||||
if 3+nameLen <= len(appData) {
|
||||
appName = string(appData[3 : 3+nameLen])
|
||||
} else {
|
||||
// Default fallback
|
||||
appName = "reticulum-go.node"
|
||||
}
|
||||
} else {
|
||||
// Default fallback
|
||||
appName = "reticulum-go.node"
|
||||
}
|
||||
|
||||
// Create name hash (10 bytes)
|
||||
nameHash := sha256.Sum256([]byte(appName))
|
||||
nameHash10 := nameHash[:10]
|
||||
debug.Log(debug.DEBUG_PACKETS, "Using name hash", "name", appName, "hash", fmt.Sprintf("%x", nameHash10))
|
||||
|
||||
// Create random hash (10 bytes) - 5 bytes random + 5 bytes time
|
||||
randomHash := make([]byte, 10)
|
||||
_, err := rand.Read(randomHash[:5]) // #nosec G104
|
||||
if err != nil {
|
||||
debug.Log(debug.DEBUG_PACKETS, "Failed to read random bytes for hash", "error", err)
|
||||
return nil, err // Or handle the error appropriately
|
||||
}
|
||||
timeBytes := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(timeBytes, uint64(time.Now().Unix())) // #nosec G115
|
||||
copy(randomHash[5:], timeBytes[:5])
|
||||
debug.Log(debug.DEBUG_PACKETS, "Generated random hash", "hash", fmt.Sprintf("%x", randomHash))
|
||||
|
||||
// Prepare ratchet ID if available (not yet implemented)
|
||||
var ratchetID []byte
|
||||
|
||||
// Prepare data for signature
|
||||
// Signature consists of destination hash, public keys, name hash, random hash, and app data
|
||||
signedData := make([]byte, 0, len(destHash)+len(encKey)+len(signKey)+len(nameHash10)+len(randomHash)+len(appData))
|
||||
signedData = append(signedData, destHash...)
|
||||
signedData = append(signedData, encKey...)
|
||||
signedData = append(signedData, signKey...)
|
||||
signedData = append(signedData, nameHash10...)
|
||||
signedData = append(signedData, randomHash...)
|
||||
signedData = append(signedData, appData...)
|
||||
debug.Log(debug.DEBUG_TRACE, "Created signed data", "bytes", len(signedData))
|
||||
|
||||
// Sign the data
|
||||
signature := identity.Sign(signedData)
|
||||
debug.Log(debug.DEBUG_PACKETS, "Generated signature", "signature", fmt.Sprintf("%x", signature))
|
||||
|
||||
// Combine all fields according to spec
|
||||
// Data structure: Public Key (32) + Signing Key (32) + Name Hash (10) + Random Hash (10) + Ratchet (optional) + Signature (64) + App Data
|
||||
data := make([]byte, 0, 32+32+10+10+64+len(appData))
|
||||
data = append(data, encKey...) // Encryption key (32 bytes)
|
||||
data = append(data, signKey...) // Signing key (32 bytes)
|
||||
data = append(data, nameHash10...) // Name hash (10 bytes)
|
||||
data = append(data, randomHash...) // Random hash (10 bytes)
|
||||
if ratchetID != nil {
|
||||
data = append(data, ratchetID...) // Ratchet ID (32 bytes if present)
|
||||
}
|
||||
data = append(data, signature...) // Signature (64 bytes)
|
||||
data = append(data, appData...) // Application data (variable)
|
||||
|
||||
debug.Log(debug.DEBUG_TRACE, "Combined packet data", "bytes", len(data))
|
||||
|
||||
// Create the packet with header type 2 (two address fields)
|
||||
p := &Packet{
|
||||
Header: [2]byte{data[0], data[1]},
|
||||
HeaderType: HeaderType2,
|
||||
PacketType: PacketTypeAnnounce,
|
||||
TransportID: transportID,
|
||||
DestinationHash: destHash,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
offset := HeaderSize
|
||||
|
||||
// Handle access code if present
|
||||
if p.Header[0]&IFACFlag != 0 {
|
||||
// Access code handling would go here
|
||||
// For now, we'll assume no access code
|
||||
return nil, errors.New("access code handling not implemented")
|
||||
}
|
||||
|
||||
// Determine address size based on header type
|
||||
addrLen := AddressSize
|
||||
if p.Header[0]&HeaderTypeFlag != 0 {
|
||||
addrLen = 2 * AddressSize
|
||||
}
|
||||
|
||||
if len(data[offset:]) < addrLen+ContextSize {
|
||||
return nil, errors.New("packet data too short for addresses and context")
|
||||
}
|
||||
|
||||
// Copy addresses
|
||||
p.Addresses = make([]byte, addrLen)
|
||||
copy(p.Addresses, data[offset:offset+addrLen])
|
||||
offset += addrLen
|
||||
|
||||
// Copy context
|
||||
p.Context = data[offset]
|
||||
offset++
|
||||
|
||||
// Copy remaining data
|
||||
p.Data = make([]byte, len(data)-offset)
|
||||
copy(p.Data, data[offset:])
|
||||
|
||||
debug.Log(debug.DEBUG_VERBOSE, "Created announce packet", "type", p.PacketType, "header", p.HeaderType)
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user