distributed-state-network 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- distributed_state_network-0.0.1/.github/workflows/publish.yml +35 -0
- distributed_state_network-0.0.1/.gitignore +10 -0
- distributed_state_network-0.0.1/LICENSE +21 -0
- distributed_state_network-0.0.1/PKG-INFO +149 -0
- distributed_state_network-0.0.1/README.md +124 -0
- distributed_state_network-0.0.1/coverage.sh +3 -0
- distributed_state_network-0.0.1/pyproject.toml +37 -0
- distributed_state_network-0.0.1/security.md +14 -0
- distributed_state_network-0.0.1/src/distributed_state_network/__init__.py +4 -0
- distributed_state_network-0.0.1/src/distributed_state_network/create_key.py +10 -0
- distributed_state_network-0.0.1/src/distributed_state_network/dsnode.py +263 -0
- distributed_state_network-0.0.1/src/distributed_state_network/handler.py +114 -0
- distributed_state_network-0.0.1/src/distributed_state_network/objects/config.py +15 -0
- distributed_state_network-0.0.1/src/distributed_state_network/objects/endpoint.py +36 -0
- distributed_state_network-0.0.1/src/distributed_state_network/objects/hello_packet.py +59 -0
- distributed_state_network-0.0.1/src/distributed_state_network/objects/peers_packet.py +41 -0
- distributed_state_network-0.0.1/src/distributed_state_network/objects/signed_packet.py +20 -0
- distributed_state_network-0.0.1/src/distributed_state_network/objects/state_packet.py +64 -0
- distributed_state_network-0.0.1/src/distributed_state_network/util/__init__.py +34 -0
- distributed_state_network-0.0.1/src/distributed_state_network/util/aes.py +45 -0
- distributed_state_network-0.0.1/src/distributed_state_network/util/byte_helper.py +36 -0
- distributed_state_network-0.0.1/src/distributed_state_network/util/ecdsa.py +21 -0
- distributed_state_network-0.0.1/src/distributed_state_network/util/https.py +54 -0
- distributed_state_network-0.0.1/src/distributed_state_network/util/key_manager.py +86 -0
- distributed_state_network-0.0.1/technical.md +95 -0
- distributed_state_network-0.0.1/test.py +366 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
deploy:
|
|
12
|
+
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
|
|
15
|
+
environment: release
|
|
16
|
+
permissions:
|
|
17
|
+
id-token: write
|
|
18
|
+
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v3
|
|
21
|
+
- name: Set up Python
|
|
22
|
+
uses: actions/setup-python@v4
|
|
23
|
+
with:
|
|
24
|
+
python-version: '3.10'
|
|
25
|
+
cache: 'pip'
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: |
|
|
28
|
+
python -m pip install --upgrade pip
|
|
29
|
+
pip install hatch
|
|
30
|
+
- name: Build package
|
|
31
|
+
run: hatch build
|
|
32
|
+
- name: Publish package distributions to PyPI
|
|
33
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
34
|
+
with:
|
|
35
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Colton Clemmer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: distributed-state-network
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A tool to distribute the state of a network device to other devices on the network
|
|
5
|
+
Project-URL: Homepage, https://github.com/erinclemmer/distributed_state_network
|
|
6
|
+
Project-URL: Issues, https://github.com/erinclemmer/distributed_state_network/issues
|
|
7
|
+
Author-email: Erin Clemmer <erin.c.clemmer@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: distributed,networking
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Telecommunications Industry
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
16
|
+
Classifier: Topic :: Communications
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: cryptography
|
|
20
|
+
Requires-Dist: ecdsa
|
|
21
|
+
Requires-Dist: ipaddress
|
|
22
|
+
Requires-Dist: logging
|
|
23
|
+
Requires-Dist: requests
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Distributed State Network
|
|
27
|
+
|
|
28
|
+
A Python framework for building distributed applications where nodes automatically share state without explicit data requests.
|
|
29
|
+
|
|
30
|
+
## Why DSN?
|
|
31
|
+
|
|
32
|
+
Traditional distributed systems require constant polling or complex pub/sub mechanisms to share state between nodes. DSN solves this by providing:
|
|
33
|
+
|
|
34
|
+
- **Automatic state synchronization** - Changes propagate instantly across the network
|
|
35
|
+
- **No single point of failure** - Every node maintains its own state
|
|
36
|
+
- **Simple key-value interface** - Read any node's data as easily as local variables
|
|
37
|
+
- **Complete Security** - Triple-layer encryption protects your network
|
|
38
|
+
|
|
39
|
+
Perfect for building distributed monitoring systems, IoT networks, or any application where multiple machines need to share state efficiently.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install distributed-state-network
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
### 1. Create Your First Node
|
|
50
|
+
|
|
51
|
+
The simplest DSN network is a single node:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from distributed_state_network import DSNodeServer, DSNodeConfig
|
|
55
|
+
|
|
56
|
+
# Generate a network key (do this once for your entire network)
|
|
57
|
+
DSNodeServer.generate_key("network.key")
|
|
58
|
+
|
|
59
|
+
# Start a node
|
|
60
|
+
node = DSNodeServer.start(DSNodeConfig(
|
|
61
|
+
node_id="my_first_node",
|
|
62
|
+
port=8000,
|
|
63
|
+
aes_key_file="network.key",
|
|
64
|
+
bootstrap_nodes=[] # Empty for the first node
|
|
65
|
+
))
|
|
66
|
+
|
|
67
|
+
# Write some data
|
|
68
|
+
node.node.update_data("status", "online")
|
|
69
|
+
node.node.update_data("temperature", "72.5")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## How It Works
|
|
73
|
+
|
|
74
|
+
DSN creates a peer-to-peer network where each node maintains its own state database:
|
|
75
|
+
|
|
76
|
+
**Key concepts:**
|
|
77
|
+
- Each node owns its state and is the only one who can modify it
|
|
78
|
+
- State changes are automatically broadcast to all connected nodes
|
|
79
|
+
- Any node can read any other node's state instantly
|
|
80
|
+
- All communication is encrypted with AES + ECDSA + HTTPS
|
|
81
|
+
|
|
82
|
+
## API Reference
|
|
83
|
+
|
|
84
|
+
### Node Methods
|
|
85
|
+
|
|
86
|
+
**update_data(key, value)** - Update a key in this node's state
|
|
87
|
+
```python
|
|
88
|
+
node.update_data("sensor_reading", "42.0")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**read_data(node_id, key)** - Read a value from any node's state
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
temperature = node.read_data("sensor_node", "temperature")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**peers()** - List all connected nodes
|
|
98
|
+
```python
|
|
99
|
+
connected_nodes = node.peers()
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Real-World Examples
|
|
103
|
+
|
|
104
|
+
### Distributed Temperature Monitoring
|
|
105
|
+
|
|
106
|
+
Create a network of temperature sensors that share readings:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
# On each Raspberry Pi with a sensor:
|
|
110
|
+
sensor_node = DSNodeServer.start(DSNodeConfig(
|
|
111
|
+
node_id=f"sensor_{location}",
|
|
112
|
+
port=8000,
|
|
113
|
+
aes_key_file="network.key",
|
|
114
|
+
bootstrap_nodes=[{"address": "coordinator.local", "port": 8000}]
|
|
115
|
+
))
|
|
116
|
+
|
|
117
|
+
# Continuously update temperature
|
|
118
|
+
while True:
|
|
119
|
+
temp = read_temperature_sensor()
|
|
120
|
+
sensor_node.node.update_data("temperature", str(temp))
|
|
121
|
+
sensor_node.node.update_data("timestamp", str(time.time()))
|
|
122
|
+
time.sleep(60)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
On the monitoring station:
|
|
126
|
+
```python
|
|
127
|
+
for node_id in monitor.node.peers():
|
|
128
|
+
if node_id.startswith("sensor_"):
|
|
129
|
+
temp = monitor.node.read_data(node_id, "temperature")
|
|
130
|
+
print(f"{node_id}: {temp}°F")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Troubleshooting
|
|
134
|
+
|
|
135
|
+
### Node Can't Connect
|
|
136
|
+
- Verify the AES key file is identical on all nodes
|
|
137
|
+
- Check firewall rules allow traffic on the configured port
|
|
138
|
+
- Ensure bootstrap node address is reachable
|
|
139
|
+
|
|
140
|
+
### State Not Updating
|
|
141
|
+
- Confirm nodes show as connected with `node.peers()`
|
|
142
|
+
- Check network latency between nodes
|
|
143
|
+
- Verify no duplicate node IDs (each must be unique)
|
|
144
|
+
|
|
145
|
+
## Performance Characteristics
|
|
146
|
+
- State updates typically propagate in <100ms on local networks
|
|
147
|
+
- Each node stores the complete state of all other nodes
|
|
148
|
+
- Suitable for networks up to ~100 nodes
|
|
149
|
+
- State values should be kept reasonably small (< 1MB per key)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Distributed State Network
|
|
2
|
+
|
|
3
|
+
A Python framework for building distributed applications where nodes automatically share state without explicit data requests.
|
|
4
|
+
|
|
5
|
+
## Why DSN?
|
|
6
|
+
|
|
7
|
+
Traditional distributed systems require constant polling or complex pub/sub mechanisms to share state between nodes. DSN solves this by providing:
|
|
8
|
+
|
|
9
|
+
- **Automatic state synchronization** - Changes propagate instantly across the network
|
|
10
|
+
- **No single point of failure** - Every node maintains its own state
|
|
11
|
+
- **Simple key-value interface** - Read any node's data as easily as local variables
|
|
12
|
+
- **Complete Security** - Triple-layer encryption protects your network
|
|
13
|
+
|
|
14
|
+
Perfect for building distributed monitoring systems, IoT networks, or any application where multiple machines need to share state efficiently.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install distributed-state-network
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
### 1. Create Your First Node
|
|
25
|
+
|
|
26
|
+
The simplest DSN network is a single node:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from distributed_state_network import DSNodeServer, DSNodeConfig
|
|
30
|
+
|
|
31
|
+
# Generate a network key (do this once for your entire network)
|
|
32
|
+
DSNodeServer.generate_key("network.key")
|
|
33
|
+
|
|
34
|
+
# Start a node
|
|
35
|
+
node = DSNodeServer.start(DSNodeConfig(
|
|
36
|
+
node_id="my_first_node",
|
|
37
|
+
port=8000,
|
|
38
|
+
aes_key_file="network.key",
|
|
39
|
+
bootstrap_nodes=[] # Empty for the first node
|
|
40
|
+
))
|
|
41
|
+
|
|
42
|
+
# Write some data
|
|
43
|
+
node.node.update_data("status", "online")
|
|
44
|
+
node.node.update_data("temperature", "72.5")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## How It Works
|
|
48
|
+
|
|
49
|
+
DSN creates a peer-to-peer network where each node maintains its own state database:
|
|
50
|
+
|
|
51
|
+
**Key concepts:**
|
|
52
|
+
- Each node owns its state and is the only one who can modify it
|
|
53
|
+
- State changes are automatically broadcast to all connected nodes
|
|
54
|
+
- Any node can read any other node's state instantly
|
|
55
|
+
- All communication is encrypted with AES + ECDSA + HTTPS
|
|
56
|
+
|
|
57
|
+
## API Reference
|
|
58
|
+
|
|
59
|
+
### Node Methods
|
|
60
|
+
|
|
61
|
+
**update_data(key, value)** - Update a key in this node's state
|
|
62
|
+
```python
|
|
63
|
+
node.update_data("sensor_reading", "42.0")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**read_data(node_id, key)** - Read a value from any node's state
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
temperature = node.read_data("sensor_node", "temperature")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**peers()** - List all connected nodes
|
|
73
|
+
```python
|
|
74
|
+
connected_nodes = node.peers()
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Real-World Examples
|
|
78
|
+
|
|
79
|
+
### Distributed Temperature Monitoring
|
|
80
|
+
|
|
81
|
+
Create a network of temperature sensors that share readings:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
# On each Raspberry Pi with a sensor:
|
|
85
|
+
sensor_node = DSNodeServer.start(DSNodeConfig(
|
|
86
|
+
node_id=f"sensor_{location}",
|
|
87
|
+
port=8000,
|
|
88
|
+
aes_key_file="network.key",
|
|
89
|
+
bootstrap_nodes=[{"address": "coordinator.local", "port": 8000}]
|
|
90
|
+
))
|
|
91
|
+
|
|
92
|
+
# Continuously update temperature
|
|
93
|
+
while True:
|
|
94
|
+
temp = read_temperature_sensor()
|
|
95
|
+
sensor_node.node.update_data("temperature", str(temp))
|
|
96
|
+
sensor_node.node.update_data("timestamp", str(time.time()))
|
|
97
|
+
time.sleep(60)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
On the monitoring station:
|
|
101
|
+
```python
|
|
102
|
+
for node_id in monitor.node.peers():
|
|
103
|
+
if node_id.startswith("sensor_"):
|
|
104
|
+
temp = monitor.node.read_data(node_id, "temperature")
|
|
105
|
+
print(f"{node_id}: {temp}°F")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Troubleshooting
|
|
109
|
+
|
|
110
|
+
### Node Can't Connect
|
|
111
|
+
- Verify the AES key file is identical on all nodes
|
|
112
|
+
- Check firewall rules allow traffic on the configured port
|
|
113
|
+
- Ensure bootstrap node address is reachable
|
|
114
|
+
|
|
115
|
+
### State Not Updating
|
|
116
|
+
- Confirm nodes show as connected with `node.peers()`
|
|
117
|
+
- Check network latency between nodes
|
|
118
|
+
- Verify no duplicate node IDs (each must be unique)
|
|
119
|
+
|
|
120
|
+
## Performance Characteristics
|
|
121
|
+
- State updates typically propagate in <100ms on local networks
|
|
122
|
+
- Each node stores the complete state of all other nodes
|
|
123
|
+
- Suitable for networks up to ~100 nodes
|
|
124
|
+
- State values should be kept reasonably small (< 1MB per key)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "cryptography", "logging", "ipaddress", "requests", "ecdsa"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "distributed-state-network"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
authors = [{name="Erin Clemmer", email="erin.c.clemmer@gmail.com"}]
|
|
9
|
+
description = "A tool to distribute the state of a network device to other devices on the network"
|
|
10
|
+
keywords = ["distributed", "networking"]
|
|
11
|
+
license = "MIT"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python",
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Intended Audience :: Telecommunications Industry",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Topic :: Communications",
|
|
21
|
+
"Typing :: Typed"
|
|
22
|
+
]
|
|
23
|
+
requires-python = ">=3.10"
|
|
24
|
+
dependencies = [
|
|
25
|
+
"cryptography",
|
|
26
|
+
"logging",
|
|
27
|
+
"ipaddress",
|
|
28
|
+
"requests",
|
|
29
|
+
"ecdsa"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/erinclemmer/distributed_state_network"
|
|
34
|
+
Issues = "https://github.com/erinclemmer/distributed_state_network/issues"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/distributed_state_network"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
## Security Architecture
|
|
2
|
+
We use a three-layer security model where each layer addresses a specific vulnerability. Understanding this design helps explain why certain setup steps are necessary and how your data stays protected.
|
|
3
|
+
The Three-Layer Defense
|
|
4
|
+
Think of our security like protecting a secret club where members share sensitive information. You need multiple types of protection because different attacks require different defenses.
|
|
5
|
+
### Layer 1: AES
|
|
6
|
+
AES Encryption acts as the network's membership card. Every message between nodes is encrypted with a shared AES key, making the network invisible to outsiders. If someone intercepts your network traffic without this key, they see only meaningless encrypted bytes. This is why you must carefully share the key file with trusted nodes - it's the fundamental gatekeeper of your network.
|
|
7
|
+
### Layer 2: ECDSA
|
|
8
|
+
ECDSA Signatures solve a critical problem that AES alone cannot address. Once someone has the network key, how do you prevent them from impersonating other nodes? Each node has its own unforgeable cryptographic identity. When node "sensor_1" shares its temperature reading, it signs that data with its private key. Other nodes verify this signature to ensure the data truly came from sensor_1, not from an attacker pretending to be sensor_1. This is why node IDs must be unique - each ID is permanently bound to a specific cryptographic identity.
|
|
9
|
+
### Layer 3: HTTPS
|
|
10
|
+
HTTPS/TLS prevents sophisticated network-level attacks. Even with encryption and signatures, an attacker positioned between two nodes could intercept messages, block updates, or redirect traffic to malicious servers. HTTPS certificates ensure you're communicating with the exact node you intend to reach. During the initial handshake, nodes exchange certificates that get verified on every subsequent connection, creating authenticated channels that can't be hijacked.
|
|
11
|
+
|
|
12
|
+
## Why This Matters
|
|
13
|
+
Each layer compensates for what the others cannot protect. AES keeps outsiders out but can't distinguish between insiders. ECDSA proves identity but doesn't hide data. HTTPS secures the transport but doesn't control network access. Together, they create a system where only authorized nodes can join, every piece of state data is cryptographically guaranteed to come from its claimed source, and all communications flow through verified channels. This isn't over-engineering - it's the minimum required for nodes to trust each other in a truly distributed system without any central authority.
|
|
14
|
+
When you see the various key files and certificates being generated during setup, you're watching these three defensive layers being constructed. Each one is essential for maintaining the integrity and privacy of your distributed application.
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
import threading
|
|
8
|
+
from requests import RequestException
|
|
9
|
+
from typing import Dict, Tuple, List, Optional
|
|
10
|
+
|
|
11
|
+
from distributed_state_network.objects.endpoint import Endpoint
|
|
12
|
+
from distributed_state_network.objects.hello_packet import HelloPacket
|
|
13
|
+
from distributed_state_network.objects.peers_packet import PeersPacket
|
|
14
|
+
from distributed_state_network.objects.state_packet import StatePacket
|
|
15
|
+
from distributed_state_network.objects.config import DSNodeConfig
|
|
16
|
+
|
|
17
|
+
from distributed_state_network.util import get_dict_hash
|
|
18
|
+
from distributed_state_network.util.key_manager import CertManager, CredentialManager
|
|
19
|
+
from distributed_state_network.util.aes import aes_encrypt, aes_decrypt, generate_aes_key
|
|
20
|
+
|
|
21
|
+
TICK_INTERVAL = 3
|
|
22
|
+
|
|
23
|
+
class DSNode:
|
|
24
|
+
config: DSNodeConfig
|
|
25
|
+
address_book: Dict[str, Endpoint]
|
|
26
|
+
node_states: Dict[str, StatePacket]
|
|
27
|
+
shutting_down: bool = False
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
config: DSNodeConfig,
|
|
32
|
+
version: str,
|
|
33
|
+
):
|
|
34
|
+
self.config = config
|
|
35
|
+
self.version = version
|
|
36
|
+
|
|
37
|
+
self.cert_manager = CertManager(config.node_id)
|
|
38
|
+
self.cred_manager = CredentialManager(config.node_id)
|
|
39
|
+
|
|
40
|
+
self.cert_manager.generate_keys()
|
|
41
|
+
self.cred_manager.generate_keys()
|
|
42
|
+
|
|
43
|
+
self.node_states = {
|
|
44
|
+
self.config.node_id: StatePacket.create(self.config.node_id, time.time(), self.cred_manager.my_private(), { })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
self.address_book = {
|
|
48
|
+
self.config.node_id: Endpoint("127.0.0.1", config.port)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
self.logger = logging.getLogger("DSN: " + config.node_id)
|
|
52
|
+
if not os.path.exists(config.aes_key_file):
|
|
53
|
+
raise Exception(f"Could not find aes key file in {config.aes_key_file}")
|
|
54
|
+
threading.Thread(target=self.network_tick).start()
|
|
55
|
+
|
|
56
|
+
def get_aes_key(self):
|
|
57
|
+
with open(self.config.aes_key_file, 'rb') as f:
|
|
58
|
+
return f.read()
|
|
59
|
+
|
|
60
|
+
def network_tick(self):
|
|
61
|
+
time.sleep(TICK_INTERVAL)
|
|
62
|
+
if self.shutting_down:
|
|
63
|
+
self.logger.info(f"Shutting down node")
|
|
64
|
+
return
|
|
65
|
+
self.test_connections()
|
|
66
|
+
threading.Thread(target=self.network_tick).start()
|
|
67
|
+
|
|
68
|
+
def test_connections(self):
|
|
69
|
+
def remove(node_id: str):
|
|
70
|
+
if node_id in self.node_states:
|
|
71
|
+
del self.node_states[node_id]
|
|
72
|
+
self.logger.info(f"PING failed for {node_id}, disconnecting...")
|
|
73
|
+
for node_id in self.node_states.copy().keys():
|
|
74
|
+
if not node_id in self.node_states or node_id == self.config.node_id:
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
if self.shutting_down:
|
|
78
|
+
return
|
|
79
|
+
self.send_ping(node_id)
|
|
80
|
+
except RequestException:
|
|
81
|
+
if node_id in self.node_states: # double check if something has changed since the ping request started
|
|
82
|
+
remove(node_id)
|
|
83
|
+
|
|
84
|
+
def send_request_to_node(self, node_id: str, path: str, payload: bytes, verify) -> Tuple[requests.Response, bytes]:
|
|
85
|
+
con = self.connection_from_node(node_id)
|
|
86
|
+
return self.send_request(con, path, payload, verify)
|
|
87
|
+
|
|
88
|
+
def send_request(self, con: Endpoint, path: str, payload: bytes, verify, retries: int = 0) -> Tuple[requests.Response, bytes]:
|
|
89
|
+
try:
|
|
90
|
+
# Always send a ping first to throw an error if https validation does not work
|
|
91
|
+
requests.post(f'https://{con.to_string()}/ping', data=self.encrypt_data(payload), verify=verify, timeout=2)
|
|
92
|
+
res = requests.post(f'https://{con.to_string()}/{path}', data=self.encrypt_data(payload), verify=verify, timeout=2)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
self.logger.error(e)
|
|
95
|
+
time.sleep(1)
|
|
96
|
+
if retries < 2:
|
|
97
|
+
return self.send_request(con, path, payload, verify, retries + 1)
|
|
98
|
+
else:
|
|
99
|
+
raise RequestException(f'{path.upper()} => {con.to_string()} (no response)')
|
|
100
|
+
return self.parse_response(con, path, res)
|
|
101
|
+
|
|
102
|
+
def parse_response(self, con: Endpoint, path: str, res: requests.Response) -> Tuple[requests.Response, bytes]:
|
|
103
|
+
if res.status_code != 200:
|
|
104
|
+
raise RequestException(f'{path.upper()} => {con.to_string()} (status code {res.status_code})')
|
|
105
|
+
|
|
106
|
+
decrypted_data = b''
|
|
107
|
+
if len(res.content) > 0:
|
|
108
|
+
try:
|
|
109
|
+
decrypted_data = self.decrypt_data(res.content)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
raise RequestException(f'{path.upper()} => {con.to_string()} (cannot decrypt response)')
|
|
112
|
+
|
|
113
|
+
return res, decrypted_data
|
|
114
|
+
|
|
115
|
+
def encrypt_data(self, data: bytes) -> bytes:
|
|
116
|
+
return aes_encrypt(self.get_aes_key(), data)
|
|
117
|
+
|
|
118
|
+
def decrypt_data(self, data: bytes) -> bytes:
|
|
119
|
+
return aes_decrypt(self.get_aes_key(), data)
|
|
120
|
+
|
|
121
|
+
def request_peers(self, node_id: str):
|
|
122
|
+
pkt = PeersPacket(self.config.node_id, None, { })
|
|
123
|
+
pkt.sign(self.cred_manager.my_private())
|
|
124
|
+
res, content = self.send_request_to_node(node_id, 'peers', pkt.to_bytes(), self.cert_manager.public_path(node_id))
|
|
125
|
+
pkt = PeersPacket.from_bytes(content)
|
|
126
|
+
if not pkt.verify_signature(self.cred_manager.read_public(node_id)):
|
|
127
|
+
raise Exception("Could not verify peers packet")
|
|
128
|
+
|
|
129
|
+
for key in pkt.connections.keys():
|
|
130
|
+
if key == self.config.node_id:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
self.address_book[key] = pkt.connections[key]
|
|
134
|
+
|
|
135
|
+
if key not in self.node_states:
|
|
136
|
+
self.send_hello(self.address_book[key])
|
|
137
|
+
|
|
138
|
+
_, node_state = self.send_update(key)
|
|
139
|
+
self.handle_update(node_state)
|
|
140
|
+
|
|
141
|
+
def handle_peers(self, data: bytes):
|
|
142
|
+
pkt = PeersPacket.from_bytes(data)
|
|
143
|
+
if pkt.node_id not in self.address_book:
|
|
144
|
+
raise Exception(401) # Not Authorized
|
|
145
|
+
|
|
146
|
+
if not pkt.verify_signature(self.cred_manager.read_public(pkt.node_id)):
|
|
147
|
+
raise Exception(406) # Not Acceptable
|
|
148
|
+
|
|
149
|
+
peers = { }
|
|
150
|
+
for key in self.address_book.keys():
|
|
151
|
+
peers[key] = self.address_book[key]
|
|
152
|
+
|
|
153
|
+
pkt = PeersPacket(self.config.node_id, None, peers)
|
|
154
|
+
pkt.sign(self.cred_manager.my_private())
|
|
155
|
+
return pkt.to_bytes()
|
|
156
|
+
|
|
157
|
+
def send_hello(self, con: Endpoint):
|
|
158
|
+
self.logger.info(f"HELLO => {con.to_string()}")
|
|
159
|
+
|
|
160
|
+
payload = self.my_hello_packet().to_bytes()
|
|
161
|
+
_, content = self.send_request(con, 'hello', payload, False)
|
|
162
|
+
self.handle_hello(content)
|
|
163
|
+
|
|
164
|
+
pkt = HelloPacket.from_bytes(content)
|
|
165
|
+
return pkt.node_id
|
|
166
|
+
|
|
167
|
+
def handle_hello(self, data: bytes) -> bytes:
|
|
168
|
+
pkt = HelloPacket.from_bytes(data)
|
|
169
|
+
self.logger.info(f"Received HELLO from {pkt.node_id}")
|
|
170
|
+
if pkt.version != self.version:
|
|
171
|
+
msg = f"HELLO => {pkt.node_id} (Version mismatch \"{pkt.version}\" != \"{self.version}\")"
|
|
172
|
+
self.logger.error(msg)
|
|
173
|
+
raise Exception(505) # Version not supported
|
|
174
|
+
|
|
175
|
+
self.cert_manager.ensure_public(pkt.node_id, pkt.https_certificate)
|
|
176
|
+
self.cred_manager.ensure_public(pkt.node_id, pkt.ecdsa_public_key)
|
|
177
|
+
|
|
178
|
+
if pkt.node_id not in self.address_book:
|
|
179
|
+
self.address_book[pkt.node_id] = pkt.connection
|
|
180
|
+
|
|
181
|
+
if pkt.node_id not in self.node_states:
|
|
182
|
+
self.node_states[pkt.node_id] = StatePacket(pkt.node_id, 0, b'', { })
|
|
183
|
+
|
|
184
|
+
return self.my_hello_packet().to_bytes()
|
|
185
|
+
|
|
186
|
+
def my_hello_packet(self) -> HelloPacket:
|
|
187
|
+
pkt = HelloPacket(
|
|
188
|
+
self.version,
|
|
189
|
+
self.config.node_id,
|
|
190
|
+
self.my_con(),
|
|
191
|
+
self.cred_manager.my_public(),
|
|
192
|
+
None,
|
|
193
|
+
self.cert_manager.my_public()
|
|
194
|
+
)
|
|
195
|
+
pkt.sign(self.cred_manager.my_private())
|
|
196
|
+
return pkt
|
|
197
|
+
|
|
198
|
+
def send_ping(self, node_id: str):
|
|
199
|
+
try:
|
|
200
|
+
self.send_request_to_node(node_id, 'ping', b' ', verify=self.cert_manager.public_path(node_id))
|
|
201
|
+
except Exception as e:
|
|
202
|
+
raise RequestException(f'PING => {node_id}: {e}')
|
|
203
|
+
|
|
204
|
+
def send_update(self, node_id: str):
|
|
205
|
+
self.logger.info(f"UPDATE => {node_id}")
|
|
206
|
+
return self.send_request_to_node(node_id, 'update', self.my_state().to_bytes(), self.cert_manager.public_path(node_id))
|
|
207
|
+
|
|
208
|
+
def handle_update(self, data: bytes):
|
|
209
|
+
pkt = StatePacket.from_bytes(data)
|
|
210
|
+
self.logger.info(f"Received UPDATE from {pkt.node_id}")
|
|
211
|
+
|
|
212
|
+
# ignore if we accidentally sent an update to ourselves
|
|
213
|
+
if pkt.node_id == self.config.node_id:
|
|
214
|
+
raise Exception(406) # Not acceptable
|
|
215
|
+
|
|
216
|
+
# don't use packets older than last update
|
|
217
|
+
if pkt.node_id in self.node_states and self.node_states[pkt.node_id].last_update > pkt.last_update:
|
|
218
|
+
raise Exception(406) # Not acceptable
|
|
219
|
+
|
|
220
|
+
if not pkt.verify_signature(self.cred_manager.read_public(pkt.node_id)):
|
|
221
|
+
raise Exception(401) # Not authorized
|
|
222
|
+
|
|
223
|
+
if pkt.node_id not in self.node_states:
|
|
224
|
+
self.node_states[pkt.node_id] = pkt
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
if get_dict_hash(self.node_states[pkt.node_id].state_data) != get_dict_hash(pkt.state_data):
|
|
228
|
+
self.node_states[pkt.node_id] = pkt
|
|
229
|
+
|
|
230
|
+
return self.my_state().to_bytes()
|
|
231
|
+
|
|
232
|
+
def my_state(self):
|
|
233
|
+
return self.node_states[self.config.node_id]
|
|
234
|
+
|
|
235
|
+
def bootstrap(self, con: Endpoint):
|
|
236
|
+
bootstrap_id = self.send_hello(con)
|
|
237
|
+
self.address_book[bootstrap_id] = con
|
|
238
|
+
_, content = self.send_update(bootstrap_id)
|
|
239
|
+
self.handle_update(content)
|
|
240
|
+
self.request_peers(bootstrap_id)
|
|
241
|
+
|
|
242
|
+
def connection_from_node(self, node_id: str) -> Endpoint:
|
|
243
|
+
if node_id not in self.address_book:
|
|
244
|
+
raise Exception(f"could not find connection for {node_id}")
|
|
245
|
+
return self.address_book[node_id]
|
|
246
|
+
|
|
247
|
+
def update_data(self, key: str, val: str):
|
|
248
|
+
self.node_states[self.config.node_id].update_state(key, val, self.cred_manager.my_private())
|
|
249
|
+
for key in list(self.node_states.keys())[:]:
|
|
250
|
+
if key == self.config.node_id:
|
|
251
|
+
continue
|
|
252
|
+
self.send_update(key)
|
|
253
|
+
|
|
254
|
+
def my_con(self) -> Endpoint:
|
|
255
|
+
return self.connection_from_node(self.config.node_id)
|
|
256
|
+
|
|
257
|
+
def read_data(self, node_id: str, key: str) -> Optional[str]:
|
|
258
|
+
if key not in self.node_states[node_id].state_data.keys():
|
|
259
|
+
return None
|
|
260
|
+
return self.node_states[node_id].state_data[key]
|
|
261
|
+
|
|
262
|
+
def peers(self) -> List[str]:
|
|
263
|
+
return list(self.node_states.keys())
|