distributed-state-network 0.3.0__tar.gz → 0.4.0__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.
Files changed (34) hide show
  1. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/PKG-INFO +2 -1
  2. distributed_state_network-0.4.0/documentation/protocol.md +114 -0
  3. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/pyproject.toml +5 -4
  4. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/dsnode.py +64 -75
  5. distributed_state_network-0.4.0/src/distributed_state_network/handler.py +156 -0
  6. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/test.py +49 -43
  7. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/tests/connections.py +1 -1
  8. distributed_state_network-0.3.0/documentation/protocol.md +0 -66
  9. distributed_state_network-0.3.0/src/distributed_state_network/handler.py +0 -145
  10. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/.github/workflows/publish.yml +0 -0
  11. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/.gitignore +0 -0
  12. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/LICENSE +0 -0
  13. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/README.md +0 -0
  14. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/coverage.sh +0 -0
  15. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/documentation/ds-node-config.md +0 -0
  16. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/documentation/ds-node-server.md +0 -0
  17. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/documentation/ds-node.md +0 -0
  18. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/documentation/usage.md +0 -0
  19. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/__init__.py +0 -0
  20. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/create_key.py +0 -0
  21. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/objects/config.py +0 -0
  22. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/objects/endpoint.py +0 -0
  23. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/objects/hello_packet.py +0 -0
  24. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/objects/msg_types.py +0 -0
  25. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/objects/peers_packet.py +0 -0
  26. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/objects/signed_packet.py +0 -0
  27. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/objects/state_packet.py +0 -0
  28. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/util/__init__.py +0 -0
  29. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/util/aes.py +0 -0
  30. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/util/byte_helper.py +0 -0
  31. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/util/ecdsa.py +0 -0
  32. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/util/https.py +0 -0
  33. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/src/distributed_state_network/util/key_manager.py +0 -0
  34. {distributed_state_network-0.3.0 → distributed_state_network-0.4.0}/technical.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: distributed-state-network
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A tool to distribute the state of a network device to other devices on the network
5
5
  Project-URL: Homepage, https://github.com/erinclemmer/distributed_state_network
6
6
  Project-URL: Issues, https://github.com/erinclemmer/distributed_state_network/issues
@@ -18,6 +18,7 @@ Classifier: Typing :: Typed
18
18
  Requires-Python: >=3.10
19
19
  Requires-Dist: cryptography
20
20
  Requires-Dist: ecdsa
21
+ Requires-Dist: flask
21
22
  Requires-Dist: ipaddress
22
23
  Requires-Dist: logging
23
24
  Requires-Dist: requests
@@ -0,0 +1,114 @@
1
+ ## Network Protocol
2
+
3
+ ### Transport Layer
4
+ The network now uses **HTTP (HyperText Transfer Protocol)** with a Flask server for all communication. This provides:
5
+ - Standard HTTP semantics for request/response patterns
6
+ - Better compatibility with firewalls and proxies
7
+ - Built-in error handling with HTTP status codes
8
+ - Easy integration with monitoring and debugging tools
9
+ - More flexible for future enhancements
10
+
11
+ ### Message Types and Endpoints
12
+ All HTTP requests use specific endpoints to identify the packet type:
13
+
14
+ - **POST /hello**: Exchange node information and credentials
15
+ - **POST /peers**: Request/share peer list
16
+ - **POST /update**: Send/receive state updates
17
+ - **POST /ping**: Connectivity check
18
+
19
+ ### Packet Structure
20
+ Each HTTP request follows this structure:
21
+ 1. **HTTP POST request** to the appropriate endpoint
22
+ 2. **Request Body**: AES Encrypted Payload containing:
23
+ - Message type (1 byte) - for verification
24
+ - Message payload (variable length)
25
+ 3. **Response Body**: AES Encrypted Payload containing:
26
+ - Message type (1 byte) - matches request type
27
+ - Response payload (variable length)
28
+
29
+ ### Message Types
30
+ Internal message type constants (used for verification):
31
+ - **Type 1 (HELLO)**: Exchange node information and credentials
32
+ - **Type 2 (PEERS)**: Request/share peer list
33
+ - **Type 3 (UPDATE)**: Send/receive state updates
34
+ - **Type 4 (PING)**: Connectivity check
35
+
36
+ ### Security
37
+ - All communication is encrypted using AES with a shared key
38
+ - Messages are signed using ECDSA for authentication
39
+ - HTTP request/response bodies are encrypted end-to-end
40
+ - HTTPS/TLS can be added by placing a reverse proxy (nginx, apache) in front of the Flask server
41
+
42
+ ### State Synchronization
43
+ - Nodes maintain a copy of all peers' states
44
+ - Updates are broadcast to all connected peers
45
+ - Timestamps prevent older updates from overwriting newer ones
46
+
47
+ ### HTTP Server Configuration
48
+ - Flask server runs on specified port with threading enabled
49
+ - Default timeout is 5 seconds for requests
50
+ - Maximum retry attempts: 3 with 0.5 second delay between retries
51
+ - Server runs in a separate daemon thread
52
+
53
+ ### HTTP Status Codes
54
+ - **200 OK**: Successful request with response data
55
+ - **204 No Content**: Successful request with no response data (e.g., some HELLO responses)
56
+ - **400 Bad Request**: Malformed request or message type mismatch
57
+ - **401 Unauthorized**: Signature verification failed
58
+ - **406 Not Acceptable**: Invalid data, stale update, or other validation error
59
+ - **500 Internal Server Error**: Unexpected server error
60
+ - **505 HTTP Version Not Supported**: Used for version mismatch errors
61
+
62
+ ## Important Notes
63
+
64
+ 1. **Shared AES Key**: All nodes in the network must use the same AES key file
65
+ 2. **Unique Node IDs**: Each node must have a unique node_id
66
+ 3. **Port Availability**: Ensure the specified HTTP port is available before starting
67
+ 4. **Bootstrap Nodes**: At least one bootstrap node is required to join an existing network
68
+ 5. **Network Tick**: The network performs maintenance checks every 3 seconds
69
+ 6. **Credential Management**: ECDSA keys are automatically generated and stored in `credentials/` directory
70
+ 7. **HTTP Reliability**: The protocol implements retry logic (up to 3 attempts) for failed requests
71
+ 8. **HTTPS Support**: While the base implementation uses HTTP, you can add HTTPS by:
72
+ - Using a reverse proxy (nginx, apache) with SSL certificates
73
+ - Configuring Flask with SSL context (requires certificate files)
74
+
75
+ ## Error Handling
76
+
77
+ Common error codes:
78
+ - **401**: Not Authorized (signature verification failed)
79
+ - **406**: Not Acceptable (invalid data, stale update, or version mismatch)
80
+ - **505**: Version not supported
81
+
82
+ ## Migration from UDP
83
+
84
+ If you're migrating from the UDP-based version:
85
+ 1. Install Flask: The dependency is now included in pyproject.toml
86
+ 2. Update firewall rules to allow HTTP traffic on your ports instead of UDP
87
+ 3. The API remains largely the same, but transport is now HTTP
88
+ 4. HTTP endpoints replace UDP message types for routing
89
+ 5. Consider adding HTTPS via reverse proxy for production deployments
90
+
91
+ ## Example URLs
92
+
93
+ For a node running on `192.168.1.100:8080`:
94
+ - HELLO endpoint: `http://192.168.1.100:8080/hello`
95
+ - PEERS endpoint: `http://192.168.1.100:8080/peers`
96
+ - UPDATE endpoint: `http://192.168.1.100:8080/update`
97
+ - PING endpoint: `http://192.168.1.100:8080/ping`
98
+
99
+ ## Request Flow
100
+
101
+ 1. **Client prepares message**: Message type byte + payload
102
+ 2. **Client encrypts**: AES encryption applied to entire message
103
+ 3. **Client sends**: HTTP POST to appropriate endpoint
104
+ 4. **Server receives**: Flask routes to appropriate handler
105
+ 5. **Server decrypts**: AES decryption applied
106
+ 6. **Server validates**: Message type verification, signature checks
107
+ 7. **Server processes**: Business logic executed
108
+ 8. **Server prepares response**: Message type byte + response payload
109
+ 9. **Server encrypts**: AES encryption applied
110
+ 10. **Server sends**: HTTP response with encrypted body
111
+ 11. **Client receives**: Response status and body
112
+ 12. **Client decrypts**: AES decryption applied
113
+ 13. **Client validates**: Message type verification
114
+ 14. **Client processes**: Response data used
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["hatchling", "cryptography", "logging", "ipaddress", "requests", "ecdsa"]
2
+ requires = ["hatchling", "cryptography", "logging", "ipaddress", "requests", "ecdsa", "flask"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "distributed-state-network"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  authors = [{name="Erin Clemmer", email="erin.c.clemmer@gmail.com"}]
9
9
  description = "A tool to distribute the state of a network device to other devices on the network"
10
10
  keywords = ["distributed", "networking"]
@@ -26,7 +26,8 @@ dependencies = [
26
26
  "logging",
27
27
  "ipaddress",
28
28
  "requests",
29
- "ecdsa"
29
+ "ecdsa",
30
+ "flask"
30
31
  ]
31
32
 
32
33
  [project.urls]
@@ -34,4 +35,4 @@ Homepage = "https://github.com/erinclemmer/distributed_state_network"
34
35
  Issues = "https://github.com/erinclemmer/distributed_state_network/issues"
35
36
 
36
37
  [tool.hatch.build.targets.wheel]
37
- packages = ["src/distributed_state_network"]
38
+ packages = ["src/distributed_state_network"]
@@ -1,8 +1,8 @@
1
1
  import os
2
2
  import time
3
- import socket
4
3
  import logging
5
4
  import threading
5
+ import requests
6
6
  from typing import Dict, List, Optional, Callable
7
7
 
8
8
  from distributed_state_network.objects.endpoint import Endpoint
@@ -16,7 +16,7 @@ from distributed_state_network.util.key_manager import CredentialManager
16
16
  from distributed_state_network.util.aes import aes_encrypt, aes_decrypt
17
17
 
18
18
  TICK_INTERVAL = 3
19
- UDP_TIMEOUT = 2 # seconds
19
+ HTTP_TIMEOUT = 2 # seconds
20
20
 
21
21
  # Message type constants (must match handler.py)
22
22
  MSG_HELLO = 1
@@ -24,6 +24,14 @@ MSG_PEERS = 2
24
24
  MSG_UPDATE = 3
25
25
  MSG_PING = 4
26
26
 
27
+ # Map message types to endpoint paths
28
+ MSG_TYPE_TO_PATH = {
29
+ MSG_HELLO: '/hello',
30
+ MSG_PEERS: '/peers',
31
+ MSG_UPDATE: '/update',
32
+ MSG_PING: '/ping'
33
+ }
34
+
27
35
  class DSNode:
28
36
  config: DSNodeConfig
29
37
  address_book: Dict[str, Endpoint]
@@ -34,13 +42,12 @@ class DSNode:
34
42
  self,
35
43
  config: DSNodeConfig,
36
44
  version: str,
37
- sock: Optional[socket.socket] = None,
38
45
  disconnect_callback: Optional[Callable] = None,
39
46
  update_callback: Optional[Callable] = None
40
47
  ):
41
48
  self.config = config
42
49
  self.version = version
43
- self.socket = sock # Use the server's socket for sending
50
+ self.server = None # Reference to the Flask server
44
51
 
45
52
  self.cred_manager = CredentialManager(config.node_id)
46
53
  self.cred_manager.generate_keys()
@@ -61,15 +68,11 @@ class DSNode:
61
68
  if not os.path.exists(config.aes_key_file):
62
69
  raise Exception(f"Could not find aes key file in {config.aes_key_file}")
63
70
 
64
- # Response tracking for request/response matching
65
- self.pending_responses = {}
66
- self.response_lock = threading.Lock()
67
-
68
71
  threading.Thread(target=self.network_tick, daemon=True).start()
69
72
 
70
- def set_socket(self, sock: socket.socket):
71
- """Set the socket to use for sending requests"""
72
- self.socket = sock
73
+ def set_server(self, server):
74
+ """Set reference to the Flask server"""
75
+ self.server = server
73
76
 
74
77
  def get_aes_key(self):
75
78
  with open(self.config.aes_key_file, 'rb') as f:
@@ -102,84 +105,68 @@ class DSNode:
102
105
  if self.disconnect_cb is not None:
103
106
  self.disconnect_cb()
104
107
 
105
- def send_udp_request(self, endpoint: Endpoint, msg_type: int, payload: bytes, retries: int = 0) -> bytes:
106
- """Send UDP request and wait for response using the server's socket"""
107
- if self.socket is None:
108
- raise Exception("Socket not set. Cannot send UDP request.")
109
-
108
+ def send_http_request(self, endpoint: Endpoint, msg_type: int, payload: bytes, retries: int = 0) -> bytes:
109
+ """Send HTTP request and wait for response"""
110
110
  try:
111
- # Prepend message type and response bit (0 for request) to payload
112
- data_with_type = bytes([msg_type, 0]) + payload
111
+ # Prepend message type to payload
112
+ data_with_type = bytes([msg_type]) + payload
113
113
  encrypted_data = self.encrypt_data(data_with_type)
114
114
 
115
- # Create a unique ID for this request based on endpoint and msg type
116
- request_id = (endpoint.address, endpoint.port, msg_type)
115
+ # Determine the URL path based on message type
116
+ path = MSG_TYPE_TO_PATH.get(msg_type, '/unknown')
117
+ url = f"http://{endpoint.address}:{endpoint.port}{path}"
117
118
 
118
- # Set up response tracking
119
- response_event = threading.Event()
120
- with self.response_lock:
121
- self.pending_responses[request_id] = {'event': response_event, 'data': None}
119
+ # Send HTTP POST request
120
+ response = requests.post(
121
+ url,
122
+ data=encrypted_data,
123
+ headers={'Content-Type': 'application/octet-stream'},
124
+ timeout=HTTP_TIMEOUT
125
+ )
122
126
 
123
- # Send the packet using the server's socket
124
- self.socket.sendto(encrypted_data, (endpoint.address, endpoint.port))
127
+ # Check response status
128
+ if response.status_code == 204:
129
+ # No content - valid for some responses like successful HELLO with no data
130
+ return b''
131
+ elif response.status_code != 200:
132
+ raise Exception(f"HTTP error: {response.status_code}")
125
133
 
126
- # Wait for response
127
- if response_event.wait(timeout=UDP_TIMEOUT):
128
- with self.response_lock:
129
- response_data = self.pending_responses[request_id]['data']
130
- del self.pending_responses[request_id]
131
-
132
- if response_data is None:
133
- raise Exception("Response was None")
134
-
135
- return response_data
136
- else:
137
- # Timeout
138
- with self.response_lock:
139
- if request_id in self.pending_responses:
140
- del self.pending_responses[request_id]
141
- raise socket.timeout("Request timed out")
134
+ # Decrypt the response
135
+ decrypted = self.decrypt_data(response.content)
136
+
137
+ if len(decrypted) < 1:
138
+ raise Exception("Empty response")
142
139
 
143
- except socket.timeout:
140
+ # First byte is message type
141
+ response_msg_type = decrypted[0]
142
+ if response_msg_type != msg_type:
143
+ raise Exception(f"Response message type mismatch: expected {msg_type}, got {response_msg_type}")
144
+
145
+ # Return the body (everything after the message type byte)
146
+ return decrypted[1:]
147
+
148
+ except requests.exceptions.Timeout:
144
149
  if retries < 2:
145
150
  time.sleep(0.5)
146
- return self.send_udp_request(endpoint, msg_type, payload, retries + 1)
151
+ return self.send_http_request(endpoint, msg_type, payload, retries + 1)
147
152
  else:
148
- raise Exception(f"UDP request to {endpoint.to_string()} timed out")
149
- except Exception as e:
153
+ raise Exception(f"HTTP request to {endpoint.to_string()} timed out")
154
+ except requests.exceptions.RequestException as e:
150
155
  if retries < 2:
151
156
  time.sleep(0.5)
152
- return self.send_udp_request(endpoint, msg_type, payload, retries + 1)
157
+ return self.send_http_request(endpoint, msg_type, payload, retries + 1)
153
158
  else:
154
- raise Exception(f"UDP request to {endpoint.to_string()} failed: {e}")
155
-
156
- def handle_response(self, data: bytes, addr: tuple):
157
- """Handle incoming response and match it to pending request"""
158
- try:
159
- # Decrypt the data
160
- decrypted = self.decrypt_data(data)
161
-
162
- if len(decrypted) < 2:
163
- return
164
-
165
- # First byte is message type, second byte is response bit
166
- msg_type = decrypted[0]
167
- is_response = decrypted[1]
168
- body = decrypted[2:]
169
-
170
- # Find matching pending request
171
- request_id = (addr[0], addr[1], msg_type)
172
-
173
- with self.response_lock:
174
- if request_id in self.pending_responses:
175
- self.pending_responses[request_id]['data'] = body
176
- self.pending_responses[request_id]['event'].set()
159
+ raise Exception(f"HTTP request to {endpoint.to_string()} failed: {e}")
177
160
  except Exception as e:
178
- self.logger.error(f"Error handling response from {addr}: {e}")
161
+ if retries < 2:
162
+ time.sleep(0.5)
163
+ return self.send_http_request(endpoint, msg_type, payload, retries + 1)
164
+ else:
165
+ raise Exception(f"HTTP request to {endpoint.to_string()} failed: {e}")
179
166
 
180
167
  def send_request_to_node(self, node_id: str, msg_type: int, payload: bytes) -> bytes:
181
168
  con = self.connection_from_node(node_id)
182
- return self.send_udp_request(con, msg_type, payload)
169
+ return self.send_http_request(con, msg_type, payload)
183
170
 
184
171
  def encrypt_data(self, data: bytes) -> bytes:
185
172
  return aes_encrypt(self.get_aes_key(), data)
@@ -227,7 +214,7 @@ class DSNode:
227
214
  self.logger.info(f"HELLO => {con.to_string()}")
228
215
 
229
216
  payload = self.my_hello_packet().to_bytes()
230
- content = self.send_udp_request(con, MSG_HELLO, payload)
217
+ content = self.send_http_request(con, MSG_HELLO, payload)
231
218
 
232
219
  # Get the response packet
233
220
  pkt = HelloPacket.from_bytes(content)
@@ -257,7 +244,7 @@ class DSNode:
257
244
 
258
245
  return pkt.node_id
259
246
 
260
- def handle_hello(self, data: bytes, detected_address: str = None) -> bytes:
247
+ def handle_hello(self, data: bytes, detected_address: str = None) -> bytes | None:
261
248
  pkt = HelloPacket.from_bytes(data)
262
249
  self.logger.info(f"Received HELLO from {pkt.node_id}")
263
250
  if pkt.version != self.version:
@@ -269,6 +256,8 @@ class DSNode:
269
256
 
270
257
  if pkt.node_id not in self.address_book:
271
258
  self.address_book[pkt.node_id] = pkt.connection
259
+ else:
260
+ return None
272
261
 
273
262
  if pkt.node_id not in self.node_states:
274
263
  self.node_states[pkt.node_id] = StatePacket(pkt.node_id, 0, b'', { })
@@ -285,7 +274,7 @@ class DSNode:
285
274
  self.my_con(),
286
275
  self.cred_manager.my_public(),
287
276
  None,
288
- None # No certificate for UDP
277
+ None # No certificate for HTTP
289
278
  )
290
279
  pkt.sign(self.cred_manager.my_private())
291
280
  return pkt
@@ -0,0 +1,156 @@
1
+ import sys
2
+ import threading
3
+ import logging
4
+ from typing import Callable, Optional
5
+ from flask import Flask, request, Response
6
+ from distributed_state_network.dsnode import DSNode
7
+ from distributed_state_network.objects.config import DSNodeConfig
8
+ from distributed_state_network.util.aes import generate_aes_key
9
+ from distributed_state_network.util import stop_thread
10
+
11
+ VERSION = "0.4.0"
12
+ logging.basicConfig(level=logging.INFO)
13
+
14
+ # Silence Flask and Werkzeug logging
15
+ logging.getLogger('werkzeug').setLevel(logging.ERROR)
16
+
17
+ # Message type constants
18
+ MSG_HELLO = 1
19
+ MSG_PEERS = 2
20
+ MSG_UPDATE = 3
21
+ MSG_PING = 4
22
+
23
+ class DSNodeServer:
24
+ def __init__(
25
+ self,
26
+ config: DSNodeConfig,
27
+ disconnect_callback: Optional[Callable] = None,
28
+ update_callback: Optional[Callable] = None
29
+ ):
30
+ self.config = config
31
+ self.running = False
32
+
33
+ # Create Flask app
34
+ self.app = Flask(__name__)
35
+ self.app.logger.setLevel(logging.ERROR) # Reduce Flask's logging noise
36
+
37
+ # Create DSNode
38
+ self.node = DSNode(config, VERSION, disconnect_callback, update_callback)
39
+ self.node.set_server(self) # Give node reference to server for making HTTP requests
40
+
41
+ # Set up Flask routes
42
+ self._setup_routes()
43
+
44
+ def _setup_routes(self):
45
+ """Set up Flask routes for each message type"""
46
+
47
+ @self.app.route('/hello', methods=['POST'])
48
+ def handle_hello_route():
49
+ return self._handle_request(MSG_HELLO, request.data, request.remote_addr)
50
+
51
+ @self.app.route('/peers', methods=['POST'])
52
+ def handle_peers_route():
53
+ return self._handle_request(MSG_PEERS, request.data, request.remote_addr)
54
+
55
+ @self.app.route('/update', methods=['POST'])
56
+ def handle_update_route():
57
+ return self._handle_request(MSG_UPDATE, request.data, request.remote_addr)
58
+
59
+ @self.app.route('/ping', methods=['POST'])
60
+ def handle_ping_route():
61
+ return self._handle_request(MSG_PING, request.data, request.remote_addr)
62
+
63
+ def _handle_request(self, msg_type: int, data: bytes, remote_addr: str) -> Response:
64
+ if not self.running:
65
+ return Response(status=500)
66
+ """Handle incoming HTTP request"""
67
+ try:
68
+ # Decrypt the data
69
+ decrypted = self.node.decrypt_data(data)
70
+
71
+ if len(decrypted) < 1:
72
+ return Response(status=400)
73
+
74
+ # First byte should be message type (for verification)
75
+ received_msg_type = decrypted[0]
76
+ body = decrypted[1:]
77
+
78
+ if received_msg_type != msg_type:
79
+ self.node.logger.error(f"Message type mismatch: expected {msg_type}, got {received_msg_type}")
80
+ return Response(status=400)
81
+
82
+ response_data = None
83
+
84
+ if msg_type == MSG_HELLO:
85
+ # Pass the detected IP address to handle_hello
86
+ response_data = self.node.handle_hello(body, remote_addr)
87
+
88
+ elif msg_type == MSG_PEERS:
89
+ response_data = self.node.handle_peers(body)
90
+
91
+ elif msg_type == MSG_UPDATE:
92
+ response_data = self.node.handle_update(body)
93
+
94
+ elif msg_type == MSG_PING:
95
+ response_data = b''
96
+
97
+ # Send response if handler returned data
98
+ if response_data is not None:
99
+ # Prepend message type to response
100
+ response_with_type = bytes([msg_type]) + response_data
101
+ encrypted_response = self.node.encrypt_data(response_with_type)
102
+ return Response(encrypted_response, status=200, mimetype='application/octet-stream')
103
+ else:
104
+ return Response(status=204) # No content
105
+
106
+ except Exception as e:
107
+ if len(e.args) >= 2 and isinstance(e.args[0], int):
108
+ # Error with HTTP status code
109
+ self.node.logger.error(f"Error handling {msg_type} from {remote_addr}: {e.args[1]}")
110
+ return Response(status=e.args[0])
111
+ else:
112
+ self.node.logger.error(f"Error handling {msg_type} from {remote_addr}: {e}")
113
+ return Response(status=500)
114
+
115
+ def stop(self):
116
+ self.node.shutting_down = True
117
+ self.running = False
118
+ if hasattr(self, 'thread'):
119
+ stop_thread(self.thread)
120
+
121
+ def serve_forever(self, port: int):
122
+ if self.running:
123
+ return
124
+ # Suppress Flask startup messages
125
+ cli = sys.modules['flask.cli']
126
+ cli.show_server_banner = lambda *x: None
127
+
128
+ self.running = True
129
+ self.app.run(host='0.0.0.0', port=port, threaded=True, use_reloader=False)
130
+ self.node.logger.info(f'Started DSNode on HTTP port {port}')
131
+
132
+ @staticmethod
133
+ def generate_key(out_file_path: str):
134
+ key = generate_aes_key()
135
+ with open(out_file_path, 'wb') as f:
136
+ f.write(key)
137
+
138
+ @staticmethod
139
+ def start(
140
+ config: DSNodeConfig,
141
+ disconnect_callback: Optional[Callable] = None,
142
+ update_callback: Optional[Callable] = None
143
+ ) -> 'DSNodeServer':
144
+ n = DSNodeServer(config, disconnect_callback, update_callback)
145
+ n.thread = threading.Thread(target=n.serve_forever, daemon=True, args=(config.port, ))
146
+ n.thread.start()
147
+
148
+ if n.config.bootstrap_nodes is not None and len(n.config.bootstrap_nodes) > 0:
149
+ for bs in n.config.bootstrap_nodes:
150
+ try:
151
+ n.node.bootstrap(bs)
152
+ break # Throws exception if connection is not made
153
+ except Exception as e:
154
+ print(e)
155
+
156
+ return n
@@ -3,9 +3,8 @@ import sys
3
3
  import time
4
4
  import random
5
5
  import shutil
6
- import logging
7
6
  import unittest
8
- import socket
7
+ import requests
9
8
  from typing import List, Dict
10
9
 
11
10
  sys.path.append(os.path.join(os.path.dirname(__file__), './src'))
@@ -26,7 +25,7 @@ key_file = "src/distributed_state_network/test.key"
26
25
  if not os.path.exists(key_file):
27
26
  DSNodeServer.generate_key(key_file)
28
27
 
29
- def spawn_node(node_id: str, bootstrap_nodes: List[Dict] = [], sock: socket.socket = None):
28
+ def spawn_node(node_id: str, bootstrap_nodes: List[Dict] = []):
30
29
  global current_port
31
30
  current_port += 1
32
31
  n = DSNodeServer.start(DSNodeConfig.from_dict({
@@ -34,7 +33,7 @@ def spawn_node(node_id: str, bootstrap_nodes: List[Dict] = [], sock: socket.sock
34
33
  "port": current_port,
35
34
  "aes_key_file": key_file,
36
35
  "bootstrap_nodes": bootstrap_nodes
37
- }), sock=sock)
36
+ }))
38
37
  global nodes
39
38
  nodes.append(n)
40
39
  return n
@@ -140,10 +139,10 @@ class TestNode(unittest.TestCase):
140
139
  self.assertEqual(None, bootstrap.node.read_data("connector", "foo"))
141
140
 
142
141
  connector.node.update_data("foo", "bar")
143
- time.sleep(0.5) # Give time for UDP packet to arrive
142
+ time.sleep(0.5) # Give time for HTTP request to complete
144
143
  self.assertEqual("bar", bootstrap.node.read_data("connector", "foo"))
145
144
  bootstrap.node.update_data("bar", "baz")
146
- time.sleep(0.5) # Give time for UDP packet to arrive
145
+ time.sleep(0.5) # Give time for HTTP request to complete
147
146
  self.assertEqual("baz", connector.node.read_data("bootstrap", "bar"))
148
147
 
149
148
  def test_bad_aes_key(self):
@@ -154,38 +153,39 @@ class TestNode(unittest.TestCase):
154
153
  print(e)
155
154
 
156
155
  def test_authorization(self):
157
- """Test that unauthorized UDP packets are rejected"""
156
+ """Test that unauthorized HTTP requests are rejected"""
158
157
  n = spawn_node("node")
159
158
 
160
- # Create a UDP socket to send unauthorized data
161
- test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
162
- test_sock.settimeout(2)
159
+ # Give Flask server time to start
160
+ time.sleep(0.5)
163
161
 
164
- # Send unencrypted data - should be rejected
165
- test_sock.sendto(b'TEST', ('127.0.0.1', n.config.port))
166
-
167
- # Try to receive response (should not get one for bad data)
162
+ # Send unencrypted data - should be rejected with 500 error
168
163
  try:
169
- data, addr = test_sock.recvfrom(1024)
170
- # If we get here, check that response indicates error
171
- print(f"Received response: {data}")
172
- except socket.timeout:
173
- # This is expected - no valid response for bad data
174
- pass
164
+ response = requests.post(
165
+ f'http://127.0.0.1:{n.config.port}/ping',
166
+ data=b'TEST',
167
+ timeout=2
168
+ )
169
+ # Should get error status code
170
+ self.assertNotEqual(response.status_code, 200)
171
+ print(f"Received status code for bad data: {response.status_code}")
172
+ except Exception as e:
173
+ # Connection errors are also acceptable
174
+ print(f"Request failed as expected: {e}")
175
175
 
176
176
  # Send properly encrypted data
177
177
  encrypted_data = n.node.encrypt_data(bytes([4]) + b'TEST') # MSG_PING = 4
178
- test_sock.sendto(encrypted_data, ('127.0.0.1', n.config.port))
178
+ response = requests.post(
179
+ f'http://127.0.0.1:{n.config.port}/ping',
180
+ data=encrypted_data,
181
+ headers={'Content-Type': 'application/octet-stream'},
182
+ timeout=2
183
+ )
179
184
 
180
- # Should get a response for valid encrypted ping
181
- try:
182
- data, addr = test_sock.recvfrom(1024)
183
- decrypted = n.node.decrypt_data(data)
184
- self.assertEqual(decrypted[0], 4) # Should be MSG_PING response
185
- except socket.timeout:
186
- self.fail("Should receive response for valid encrypted ping")
187
-
188
- test_sock.close()
185
+ # Should get successful response for valid encrypted ping
186
+ self.assertEqual(response.status_code, 200)
187
+ decrypted = n.node.decrypt_data(response.content)
188
+ self.assertEqual(decrypted[0], 4) # Should be MSG_PING response
189
189
 
190
190
  def test_version_matching(self):
191
191
  bootstrap = spawn_node("bootstrap")
@@ -205,7 +205,7 @@ class TestNode(unittest.TestCase):
205
205
  connector = spawn_node("connector", [bootstrap.node.my_con().to_json()])
206
206
  try:
207
207
  # Try to send malformed data
208
- connector.node.send_udp_request(bootstrap.node.my_con(), 1, b'MALFORMED_DATA')
208
+ connector.node.send_http_request(bootstrap.node.my_con(), 1, b'MALFORMED_DATA')
209
209
  self.fail("Should throw error for malformed data")
210
210
  except Exception as e:
211
211
  print(e)
@@ -352,20 +352,26 @@ class TestNode(unittest.TestCase):
352
352
  connector = spawn_node("connector", [bootstrap.node.my_con().to_json()])
353
353
  self.assertIn('connector', bootstrap.node.peers())
354
354
 
355
- def test_custom_socket(self):
356
- """Test that a custom socket can be provided"""
357
- # Create a custom UDP socket
358
- custom_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
359
- custom_sock.bind(("0.0.0.0", 9000))
360
-
361
- # Start node with custom socket
362
- n = spawn_node("custom-socket-node", sock=custom_sock)
355
+ def test_http_endpoints(self):
356
+ """Test that HTTP endpoints are accessible"""
357
+ n = spawn_node("http-test-node")
363
358
 
364
- # Verify the node is using the custom socket
365
- self.assertEqual(n.socket, custom_sock)
359
+ # Test that all endpoints exist
360
+ endpoints = ['/hello', '/peers', '/update', '/ping']
366
361
 
367
- # Clean up
368
- n.stop()
362
+ for endpoint in endpoints:
363
+ # Send a request to each endpoint (will fail due to encryption, but should return 400/500, not 404)
364
+ try:
365
+ response = requests.post(
366
+ f'http://127.0.0.1:{n.config.port}{endpoint}',
367
+ data=b'test',
368
+ timeout=2
369
+ )
370
+ # Should not be 404 (Not Found)
371
+ self.assertNotEqual(response.status_code, 404, f"Endpoint {endpoint} not found")
372
+ print(f"Endpoint {endpoint} exists (status: {response.status_code})")
373
+ except Exception as e:
374
+ print(f"Endpoint {endpoint} test failed: {e}")
369
375
 
370
376
  if __name__ == "__main__":
371
377
  unittest.main()
@@ -33,7 +33,7 @@ def start_node(node_id: str, bootstrap_port: int = None, disconnect_cb: Optional
33
33
  else:
34
34
  args["bootstrap_nodes"] = []
35
35
 
36
- return DSNodeServer.start(DSNodeConfig(**args), None, disconnect_cb, update_cb)
36
+ return DSNodeServer.start(DSNodeConfig(**args), disconnect_cb, update_cb)
37
37
 
38
38
  class ConnectionsTest(unittest.TestCase):
39
39
  def test_one(self):
@@ -1,66 +0,0 @@
1
- ## Network Protocol
2
-
3
- ### Transport Layer
4
- The network now uses **UDP (User Datagram Protocol)** for all communication instead of HTTP/HTTPS. This provides:
5
- - Lower latency for peer-to-peer communication
6
- - Reduced overhead compared to HTTP
7
- - Simpler connection management
8
- - More suitable for real-time distributed systems
9
-
10
- ### Message Types
11
- All UDP packets use a message type byte prefix to identify the packet type:
12
-
13
- - **Type 1 (HELLO)**: Exchange node information and credentials
14
- - **Type 2 (PEERS)**: Request/share peer list
15
- - **Type 3 (UPDATE)**: Send/receive state updates
16
- - **Type 4 (PING)**: Connectivity check
17
-
18
- ### Packet Structure
19
- Each UDP packet follows this structure:
20
- 1. **AES Encrypted Payload** containing:
21
- - Message type (1 byte)
22
- - Response bit (1 byte): 0 for requests, 1 for responses
23
- - Message payload (variable length)
24
-
25
- ### Security
26
- - All communication is encrypted using AES with a shared key
27
- - Messages are signed using ECDSA for authentication
28
- - UDP packets are encrypted end-to-end
29
- - HTTPS/TLS is not supported with UDP (certificates are no longer used)
30
-
31
- ### State Synchronization
32
- - Nodes maintain a copy of all peers' states
33
- - Updates are broadcast to all connected peers
34
- - Timestamps prevent older updates from overwriting newer ones
35
-
36
- ### Socket Management
37
- - You can provide a custom UDP socket when starting a server
38
- - If no socket is provided, one will be created and bound automatically
39
- - Socket timeout is set to 2 seconds for requests
40
- - Maximum UDP packet size is 65507 bytes
41
-
42
- ## Important Notes
43
-
44
- 1. **Shared AES Key**: All nodes in the network must use the same AES key file
45
- 2. **Unique Node IDs**: Each node must have a unique node_id
46
- 3. **Port Availability**: Ensure the specified UDP port is available before starting
47
- 4. **Bootstrap Nodes**: At least one bootstrap node is required to join an existing network
48
- 5. **Network Tick**: The network performs maintenance checks every 3 seconds
49
- 6. **Credential Management**: ECDSA keys are automatically generated and stored in `credentials/` directory
50
- 7. **UDP Reliability**: The protocol implements retry logic (up to 3 attempts) for failed requests
51
- 8. **HTTPS Not Supported**: The `https` configuration option is ignored when using UDP
52
-
53
- ## Error Handling
54
-
55
- Common error codes (embedded in exception messages):
56
- - **401**: Not Authorized (signature verification failed)
57
- - **406**: Not Acceptable (invalid data, stale update, or version mismatch)
58
- - **505**: Version not supported
59
-
60
- ## Migration from HTTP
61
-
62
- If you're migrating from the HTTP-based version:
63
- 1. Remove any HTTPS certificate dependencies
64
- 2. Update firewall rules to allow UDP traffic on your ports
65
- 3. The API remains largely the same, but transport is now UDP
66
- 4. The `https` flag in configuration is now ignored
@@ -1,145 +0,0 @@
1
- import socket
2
- import threading
3
- import logging
4
- from typing import Callable, Optional
5
- from distributed_state_network.dsnode import DSNode
6
- from distributed_state_network.objects.config import DSNodeConfig
7
- from distributed_state_network.util.aes import generate_aes_key
8
- from distributed_state_network.util import stop_thread
9
-
10
- VERSION = "0.3.0"
11
- logging.basicConfig(level=logging.INFO)
12
-
13
- # Message type constants
14
- MSG_HELLO = 1
15
- MSG_PEERS = 2
16
- MSG_UPDATE = 3
17
- MSG_PING = 4
18
-
19
- class DSNodeServer:
20
- def __init__(
21
- self,
22
- config: DSNodeConfig,
23
- sock: Optional[socket.socket] = None,
24
- disconnect_callback: Optional[Callable] = None,
25
- update_callback: Optional[Callable] = None
26
- ):
27
- self.config = config
28
- self.running = True
29
-
30
- # Use provided socket or create new one
31
- if sock is not None:
32
- self.socket = sock
33
- else:
34
- self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
35
- self.socket.bind(("0.0.0.0", config.port))
36
-
37
- # Create DSNode with the socket
38
- self.node = DSNode(config, VERSION, self.socket, disconnect_callback, update_callback)
39
- self.node.logger.info(f'Started DSNode on UDP port {config.port}')
40
-
41
- def stop(self):
42
- self.node.shutting_down = True
43
- self.running = False
44
- try:
45
- self.socket.close()
46
- except:
47
- pass
48
- stop_thread(self.thread)
49
-
50
- def handle_packet(self, data: bytes, addr: tuple):
51
- """Handle incoming UDP packet - either a request or a response"""
52
- try:
53
- # Decrypt the data
54
- decrypted = self.node.decrypt_data(data)
55
-
56
- if len(decrypted) < 2:
57
- return
58
-
59
- # First byte is message type, second byte is response bit
60
- msg_type = decrypted[0]
61
- is_response = decrypted[1]
62
- body = decrypted[2:]
63
-
64
- # Check if this is a response (either by bit flag or by pending request)
65
- request_id = (addr[0], addr[1], msg_type)
66
- with self.node.response_lock:
67
- has_pending_request = request_id in self.node.pending_responses
68
-
69
- if is_response == 1 or has_pending_request:
70
- # This is a response to our request - route to response handler
71
- self.node.handle_response(data, addr)
72
- else:
73
- # This is a new request - handle it
74
- response = None
75
-
76
- if msg_type == MSG_HELLO:
77
- # Pass the detected IP address to handle_hello
78
- response = self.node.handle_hello(body, addr[0])
79
-
80
- elif msg_type == MSG_PEERS:
81
- response = self.node.handle_peers(body)
82
-
83
- elif msg_type == MSG_UPDATE:
84
- response = self.node.handle_update(body)
85
-
86
- elif msg_type == MSG_PING:
87
- response = b''
88
-
89
- # Send response if handler returned data
90
- if response is not None:
91
- # Prepend message type and response bit (1 for response) to response
92
- response_with_type = bytes([msg_type, 1]) + response
93
- encrypted_response = self.node.encrypt_data(response_with_type)
94
- self.socket.sendto(encrypted_response, addr)
95
-
96
- except Exception as e:
97
- if len(e.args) >= 2:
98
- self.node.logger.error(f"Error handling packet from {addr}: {e.args[0]} {e.args[1]}")
99
- else:
100
- self.node.logger.error(f"Error handling packet from {addr}: {e}")
101
-
102
- def serve_forever(self):
103
- """Main UDP server loop"""
104
- while self.running:
105
- try:
106
- # Receive UDP packet (max 65507 bytes for UDP)
107
- data, addr = self.socket.recvfrom(65507)
108
- if self.running:
109
- # Handle packet in separate thread to avoid blocking
110
- threading.Thread(target=self.handle_packet, args=(data, addr), daemon=True).start()
111
- except OSError:
112
- # Socket was closed
113
- if not self.running:
114
- break
115
- except Exception as e:
116
- self.node.logger.error(f"Error in server loop: {e}")
117
- if not self.running:
118
- break
119
-
120
- @staticmethod
121
- def generate_key(out_file_path: str):
122
- key = generate_aes_key()
123
- with open(out_file_path, 'wb') as f:
124
- f.write(key)
125
-
126
- @staticmethod
127
- def start(
128
- config: DSNodeConfig,
129
- sock: Optional[socket.socket] = None,
130
- disconnect_callback: Optional[Callable] = None,
131
- update_callback: Optional[Callable] = None
132
- ) -> 'DSNodeServer':
133
- n = DSNodeServer(config, sock, disconnect_callback, update_callback)
134
- n.thread = threading.Thread(target=n.serve_forever, daemon=True)
135
- n.thread.start()
136
-
137
- if n.config.bootstrap_nodes is not None and len(n.config.bootstrap_nodes) > 0:
138
- for bs in n.config.bootstrap_nodes:
139
- try:
140
- n.node.bootstrap(bs)
141
- break # Throws exception if connection is not made
142
- except Exception as e:
143
- print(e)
144
-
145
- return n