Differences

This shows you the differences between two versions of the page.

Link to this comparison view

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>​
iothings/laboratoare/2025_code/lab4_proxy.txt · Last modified: 2025/10/21 11:36 by dan.tudose
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0