This shows you the differences between two versions of the page.
| — |
iothings:laboratoare:2025_code:lab4_proxy [2025/10/21 11:36] (current) dan.tudose created |
||
|---|---|---|---|
| Line 1: | Line 1: | ||
| + | <code python http_coap_proxy.py> | ||
| + | #!/usr/bin/env python3 | ||
| + | """ | ||
| + | Minimal HTTP ↔ CoAP proxy using aiohttp + a handcrafted UDP CoAP client. | ||
| + | Expose REST-ish endpoints that translate browser-friendly HTTP requests to | ||
| + | CoAP messages for the ESP32 server. | ||
| + | |||
| + | Install dependencies: | ||
| + | python3 -m pip install aiohttp | ||
| + | |||
| + | Run (defaults shown): | ||
| + | HTTP_PORT=8080 COAP_HOST=192.168.4.1 COAP_PORT=5683 python3 tools/http_coap_proxy.py | ||
| + | |||
| + | Available endpoints: | ||
| + | - GET /api/coap -> query params: host, port, method, path | ||
| + | - POST /api/coap -> JSON body: {method, path, payload?, host?, port?} | ||
| + | - GET /api/discover -> queries /.well-known/core | ||
| + | - GET /healthz -> simple liveness probe | ||
| + | """ | ||
| + | |||
| + | import asyncio | ||
| + | import json | ||
| + | import os | ||
| + | import random | ||
| + | import signal | ||
| + | import socket | ||
| + | import struct | ||
| + | import time | ||
| + | from typing import Dict, List, Optional, Tuple | ||
| + | |||
| + | from aiohttp import web | ||
| + | |||
| + | HTTP_HOST = os.environ.get("HTTP_HOST", "0.0.0.0") | ||
| + | HTTP_PORT = int(os.environ.get("HTTP_PORT", "8080")) | ||
| + | DEFAULT_COAP_HOST = os.environ.get("COAP_HOST", "10.41.35.27") | ||
| + | DEFAULT_COAP_PORT = int(os.environ.get("COAP_PORT", "5683")) | ||
| + | REQUEST_TIMEOUT = float(os.environ.get("PROXY_TIMEOUT", "9.0")) | ||
| + | COAP_VERSION = 1 | ||
| + | |||
| + | METHOD_CODES: Dict[str, int] = { | ||
| + | "GET": 0x01, | ||
| + | "POST": 0x02, | ||
| + | "PUT": 0x03, | ||
| + | "DELETE": 0x04, | ||
| + | "FETCH": 0x05, | ||
| + | "PATCH": 0x06, | ||
| + | "IPATCH": 0x07, | ||
| + | } | ||
| + | |||
| + | OPTION_NAMES: Dict[int, str] = { | ||
| + | 3: "Uri-Host", | ||
| + | 6: "Observe", | ||
| + | 7: "Uri-Port", | ||
| + | 8: "Location-Path", | ||
| + | 11: "Uri-Path", | ||
| + | 12: "Content-Format", | ||
| + | 14: "Max-Age", | ||
| + | 15: "Uri-Query", | ||
| + | 17: "Accept", | ||
| + | 20: "Location-Query", | ||
| + | } | ||
| + | |||
| + | |||
| + | def _resolve_target(request: web.Request) -> Dict[str, str]: | ||
| + | host = (request.query.get("host") or request.match_info.get("host") or DEFAULT_COAP_HOST).strip() | ||
| + | port_raw = request.query.get("port") or request.match_info.get("port") or str(DEFAULT_COAP_PORT) | ||
| + | if request.can_read_body and request.content_type == "application/json": | ||
| + | try: | ||
| + | body = request["json_body"] | ||
| + | except KeyError: | ||
| + | body = {} | ||
| + | if isinstance(body, dict): | ||
| + | host = body.get("host", host).strip() | ||
| + | port_raw = str(body.get("port", port_raw)) | ||
| + | if not host: | ||
| + | raise web.HTTPBadRequest(text="Missing CoAP host") | ||
| + | try: | ||
| + | port = int(port_raw) | ||
| + | except ValueError: | ||
| + | raise web.HTTPBadRequest(text="Invalid CoAP port") | ||
| + | if port <= 0 or port > 65535: | ||
| + | raise web.HTTPBadRequest(text="Invalid CoAP port range") | ||
| + | return {"host": host, "port": port} | ||
| + | |||
| + | |||
| + | def _normalize_path(path: str) -> str: | ||
| + | if not path: | ||
| + | return "/" | ||
| + | return path if path.startswith("/") else "/" + path | ||
| + | |||
| + | |||
| + | async def _coap_request(method: str, host: str, port: int, path: str, payload: Optional[str]) -> Dict[str, str]: | ||
| + | loop = asyncio.get_running_loop() | ||
| + | return await loop.run_in_executor( | ||
| + | None, | ||
| + | lambda: _coap_request_sync(method, host, port, path, payload), | ||
| + | ) | ||
| + | |||
| + | |||
| + | def _encode_extended(value: int) -> Tuple[int, bytes]: | ||
| + | if value < 13: | ||
| + | return value, b"" | ||
| + | if value < 269: | ||
| + | return 13, bytes([value - 13]) | ||
| + | if value < 65805: | ||
| + | return 14, struct.pack("!H", value - 269) | ||
| + | raise ValueError("Option delta/length too large") | ||
| + | |||
| + | |||
| + | def _decode_extended(nibble: int, data: bytes, index: int) -> Tuple[int, int]: | ||
| + | if nibble < 13: | ||
| + | return nibble, index | ||
| + | if nibble == 13: | ||
| + | return 13 + data[index], index + 1 | ||
| + | if nibble == 14: | ||
| + | return 269 + struct.unpack("!H", data[index:index + 2])[0], index + 2 | ||
| + | raise ValueError("Unsupported extended field (value 15)") | ||
| + | |||
| + | |||
| + | def _build_options(path: str, payload: Optional[str], method_code: int) -> bytes: | ||
| + | options: List[Tuple[int, bytes]] = [] | ||
| + | clean_path, _, query = path.partition('?') | ||
| + | segments = [seg for seg in clean_path.split('/') if seg] | ||
| + | for segment in segments: | ||
| + | options.append((11, segment.encode("utf-8"))) | ||
| + | if query: | ||
| + | for item in query.split('&'): | ||
| + | if item: | ||
| + | options.append((15, item.encode("utf-8"))) | ||
| + | if payload and method_code in (0x02, 0x03, 0x05, 0x06, 0x07): | ||
| + | options.append((12, b"\x00")) # Content-Format: text/plain | ||
| + | |||
| + | options.sort(key=lambda item: item[0]) | ||
| + | encoded = bytearray() | ||
| + | last_number = 0 | ||
| + | for number, value in options: | ||
| + | delta = number - last_number | ||
| + | last_number = number | ||
| + | delta_encoded, delta_bytes = _encode_extended(delta) | ||
| + | length_encoded, length_bytes = _encode_extended(len(value)) | ||
| + | encoded.append((delta_encoded << 4) | length_encoded) | ||
| + | encoded.extend(delta_bytes) | ||
| + | encoded.extend(length_bytes) | ||
| + | encoded.extend(value) | ||
| + | return bytes(encoded) | ||
| + | |||
| + | |||
| + | def _parse_options_and_payload(data: bytes, start: int) -> Tuple[List[Dict[str, str]], str]: | ||
| + | options: List[Dict[str, str]] = [] | ||
| + | current_opt = 0 | ||
| + | i = start | ||
| + | length = len(data) | ||
| + | while i < length: | ||
| + | if data[i] == 0xFF: | ||
| + | payload_bytes = data[i + 1 :] | ||
| + | return options, payload_bytes.decode("utf-8", errors="replace") | ||
| + | byte = data[i] | ||
| + | i += 1 | ||
| + | delta_raw = (byte >> 4) & 0x0F | ||
| + | length_raw = byte & 0x0F | ||
| + | delta, i = _decode_extended(delta_raw, data, i) | ||
| + | opt_length, i = _decode_extended(length_raw, data, i) | ||
| + | current_opt += delta | ||
| + | value = data[i : i + opt_length] | ||
| + | i += opt_length | ||
| + | |||
| + | entry: Dict[str, str] = {"number": current_opt} | ||
| + | name = OPTION_NAMES.get(current_opt) | ||
| + | if name: | ||
| + | entry["name"] = name | ||
| + | try: | ||
| + | entry["value"] = value.decode("utf-8") | ||
| + | except UnicodeDecodeError: | ||
| + | entry["value_hex"] = value.hex() | ||
| + | options.append(entry) | ||
| + | |||
| + | return options, "" | ||
| + | |||
| + | |||
| + | def _format_coap_code(code: int) -> str: | ||
| + | cls = (code >> 5) & 0x07 | ||
| + | detail = code & 0x1F | ||
| + | return f"{cls}.{detail:02d}" | ||
| + | |||
| + | |||
| + | def _coap_request_sync(method: str, host: str, port: int, path: str, payload: Optional[str]) -> Dict[str, str]: | ||
| + | method_upper = method.upper() | ||
| + | method_code = METHOD_CODES.get(method_upper) | ||
| + | if method_code is None: | ||
| + | raise web.HTTPBadRequest(text=f"Unsupported CoAP method {method}") | ||
| + | |||
| + | message_id = random.randint(0, 0xFFFF) | ||
| + | token = os.urandom(4) | ||
| + | options = _build_options(path, payload, method_code) | ||
| + | payload_bytes = payload.encode("utf-8") if payload and method_code != 0x01 else b"" | ||
| + | |||
| + | token_length = len(token) | ||
| + | if token_length > 8: | ||
| + | raise web.HTTPBadRequest(text="Token length must be <= 8 bytes") | ||
| + | |||
| + | header = bytearray(4) | ||
| + | header[0] = (COAP_VERSION << 6) | (0 << 4) | token_length # Confirmable | ||
| + | header[1] = method_code | ||
| + | header[2:4] = struct.pack("!H", message_id) | ||
| + | |||
| + | packet = bytes(header) + token + options | ||
| + | if payload_bytes: | ||
| + | packet += b"\xFF" + payload_bytes | ||
| + | |||
| + | uri = f"coap://{host}:{port}{_normalize_path(path)}" | ||
| + | deadline = time.monotonic() + REQUEST_TIMEOUT | ||
| + | with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: | ||
| + | sock.settimeout(REQUEST_TIMEOUT) | ||
| + | sock.sendto(packet, (host, port)) | ||
| + | print(f"[proxy] -> {method_upper} {uri} token={token.hex()} mid={message_id}", flush=True) | ||
| + | |||
| + | while True: | ||
| + | remaining = deadline - time.monotonic() | ||
| + | if remaining <= 0: | ||
| + | raise web.HTTPGatewayTimeout(text="Timed out waiting for CoAP response") | ||
| + | sock.settimeout(remaining) | ||
| + | try: | ||
| + | data, remote = sock.recvfrom(2048) | ||
| + | except socket.timeout as exc: | ||
| + | raise web.HTTPGatewayTimeout(text="Timed out waiting for CoAP response") from exc | ||
| + | |||
| + | if len(data) < 4: | ||
| + | print("[proxy] !! received too-short datagram", flush=True) | ||
| + | continue | ||
| + | |||
| + | ver = (data[0] >> 6) & 0x03 | ||
| + | msg_type = (data[0] >> 4) & 0x03 | ||
| + | token_len = data[0] & 0x0F | ||
| + | resp_code = data[1] | ||
| + | resp_mid = struct.unpack("!H", data[2:4])[0] | ||
| + | resp_token = data[4:4 + token_len] | ||
| + | |||
| + | debug_info = ( | ||
| + | f"[proxy] ?? from {remote} raw_len={len(data)} ver={ver} type={msg_type} " | ||
| + | f"token_len={token_len} code_byte={resp_code} mid={resp_mid} token={resp_token.hex()}" | ||
| + | ) | ||
| + | |||
| + | if ver != COAP_VERSION: | ||
| + | print(debug_info + " (ignored: wrong version)", flush=True) | ||
| + | continue | ||
| + | if resp_mid != message_id: | ||
| + | print(debug_info + f" (ignored: mid mismatch != {message_id})", flush=True) | ||
| + | continue | ||
| + | if resp_token != token[:token_len]: | ||
| + | print(debug_info + f" (ignored: token mismatch expected {token.hex()[:token_len*2]})", flush=True) | ||
| + | continue | ||
| + | |||
| + | payload_index = 4 + token_len | ||
| + | |||
| + | # Some lightweight CoAP stacks (eg. Arduino coap-simple) return Decimal codes (e.g. 205) | ||
| + | # instead of properly encoded 5-bit class/detail. Fix that up here. | ||
| + | if resp_code >= 200: | ||
| + | resp_code = ((resp_code // 100) << 5) | (resp_code % 100) | ||
| + | |||
| + | options_list, payload_text = _parse_options_and_payload(data, payload_index) | ||
| + | code_string = _format_coap_code(resp_code) | ||
| + | print(f"[proxy] <- {code_string} type={msg_type} token={resp_token.hex()} mid={resp_mid}", flush=True) | ||
| + | |||
| + | # Acknowledge separate CON responses to stop retransmissions | ||
| + | if msg_type == 0: # CON | ||
| + | ack = bytearray(4) | ||
| + | ack[0] = (COAP_VERSION << 6) | (2 << 4) # ACK | ||
| + | ack[1] = 0 | ||
| + | ack[2:4] = struct.pack("!H", resp_mid) | ||
| + | sock.sendto(bytes(ack), remote) | ||
| + | |||
| + | return { | ||
| + | "coap_code": code_string, | ||
| + | "payload": payload_text, | ||
| + | "options": options_list, | ||
| + | } | ||
| + | |||
| + | |||
| + | async def prepare_json_body(request: web.Request): | ||
| + | if request.can_read_body and request.content_type == "application/json": | ||
| + | try: | ||
| + | request["json_body"] = await request.json() | ||
| + | except json.JSONDecodeError: | ||
| + | raise web.HTTPBadRequest(text="Invalid JSON body") | ||
| + | |||
| + | |||
| + | async def handle_get_coap(request: web.Request) -> web.Response: | ||
| + | await prepare_json_body(request) | ||
| + | target = _resolve_target(request) | ||
| + | method = request.query.get("method", "GET").upper() | ||
| + | path = request.query.get("path", "/") | ||
| + | result = await _coap_request(method, target["host"], target["port"], path, payload=None) | ||
| + | return web.json_response(result) | ||
| + | |||
| + | |||
| + | async def handle_post_coap(request: web.Request) -> web.Response: | ||
| + | await prepare_json_body(request) | ||
| + | target = _resolve_target(request) | ||
| + | |||
| + | body = request.get("json_body", {}) | ||
| + | if not isinstance(body, dict): | ||
| + | raise web.HTTPBadRequest(text="Body must be JSON object") | ||
| + | method = str(body.get("method", request.query.get("method", "PUT"))).upper() | ||
| + | path = str(body.get("path", request.query.get("path", "/"))) | ||
| + | payload = body.get("payload", "") | ||
| + | if payload is None: | ||
| + | payload = "" | ||
| + | if not isinstance(payload, str): | ||
| + | payload = str(payload) | ||
| + | |||
| + | result = await _coap_request(method, target["host"], target["port"], path, payload) | ||
| + | return web.json_response(result) | ||
| + | |||
| + | |||
| + | async def handle_discover(request: web.Request) -> web.Response: | ||
| + | await prepare_json_body(request) | ||
| + | target = _resolve_target(request) | ||
| + | result = await _coap_request("GET", target["host"], target["port"], "/.well-known/core", payload=None) | ||
| + | return web.json_response(result) | ||
| + | |||
| + | |||
| + | async def handle_health(_: web.Request) -> web.Response: | ||
| + | return web.json_response({"ok": True}) | ||
| + | |||
| + | |||
| + | @web.middleware | ||
| + | async def cors_middleware(request: web.Request, handler): | ||
| + | if request.method == "OPTIONS": | ||
| + | resp = web.Response() | ||
| + | else: | ||
| + | try: | ||
| + | resp = await handler(request) | ||
| + | except web.HTTPException as exc: | ||
| + | resp = exc | ||
| + | resp.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin", "*") | ||
| + | resp.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS" | ||
| + | resp.headers["Access-Control-Allow-Headers"] = "Content-Type" | ||
| + | resp.headers["Access-Control-Allow-Credentials"] = "false" | ||
| + | return resp | ||
| + | |||
| + | |||
| + | def create_app() -> web.Application: | ||
| + | app = web.Application(middlewares=[cors_middleware]) | ||
| + | app.router.add_get("/api/coap", handle_get_coap) | ||
| + | app.router.add_post("/api/coap", handle_post_coap) | ||
| + | app.router.add_options("/api/coap", lambda _: web.Response()) | ||
| + | app.router.add_get("/api/discover", handle_discover) | ||
| + | app.router.add_options("/api/discover", lambda _: web.Response()) | ||
| + | app.router.add_get("/healthz", handle_health) | ||
| + | return app | ||
| + | |||
| + | |||
| + | def main(): | ||
| + | app = create_app() | ||
| + | print(f"HTTP↔CoAP proxy listening on http://{HTTP_HOST}:{HTTP_PORT}") | ||
| + | print(f"Default CoAP target: coap://{DEFAULT_COAP_HOST}:{DEFAULT_COAP_PORT}") | ||
| + | web.run_app(app, host=HTTP_HOST, port=HTTP_PORT, print=None) | ||
| + | |||
| + | |||
| + | if __name__ == "__main__": | ||
| + | main() | ||
| + | |||
| + | </code> | ||