diff --git a/Cargo.toml b/Cargo.toml index 9134b97..0a1b178 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "coap-hello-udp" version = "0.1.0" edition = "2024" +default-run = "coap-hello-udp" [dependencies] coap-lite = "0.13.3" diff --git a/README.md b/README.md index b6505a2..d5d010b 100644 --- a/README.md +++ b/README.md @@ -46,20 +46,19 @@ cargo run --release --bin coap-hello-udp cargo run --release --bin bridge ``` -Then open for a test console with a name field and a -button per route. Every response shows both the CoAP code and the HTTP status -it was mapped to; the raw CoAP code is also returned in an `X-Coap-Code` header. +Every response shows the CoAP code mapped onto an HTTP status, with the raw +CoAP code returned verbatim in an `X-Coap-Code` header. These are now ordinary URLs — clickable, curl-able, bookmarkable: | URL | Response | |--------------------------------------------------|------------------------------| -| `http://localhost:8080/` | the test console | +| `http://localhost:8080/` | the CoAP reference page | | `http://localhost:8080/hello` | `Hello, World!` | | `http://localhost:8080/hello?name=Imran` | `Hello, Imran!` | | `http://localhost:8080/health` | `{"status":"ok","uptime":12}`| -POST needs a body, so it is easiest from the console page or curl: +POST needs a body, so use curl: ```bash curl -X POST -d "Imran" http://localhost:8080/hello @@ -104,7 +103,7 @@ decoding. | `src/lib.rs` | Shared CoAP client used by the CLI and the bridge. | | `src/bin/client.rs` | CLI client for smoke tests. | | `src/bin/bridge.rs` | HTTP -> CoAP proxy so a browser can reach the server. | -| `static/index.html` | The browser test console the bridge serves at `/`. | +| `static/index.html` | CoAP reference page: routes, codes, copyable commands. | | `nodejs/` | The earlier Node.js version, kept for reference. | ## Config diff --git a/__pycache__/coap-inspect.cpython-312.pyc b/__pycache__/coap-inspect.cpython-312.pyc new file mode 100644 index 0000000..43758c5 Binary files /dev/null and b/__pycache__/coap-inspect.cpython-312.pyc differ diff --git a/coap-inspect.py b/coap-inspect.py new file mode 100755 index 0000000..2387137 --- /dev/null +++ b/coap-inspect.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Send one CoAP request over UDP and show both datagrams byte by byte. + + ./coap-inspect.py get "/hello?name=Imran" + ./coap-inspect.py post /hello Imran + ./coap-inspect.py get /health + +No CoAP library: the packet is built and decoded right here, so every byte on +screen is accounted for. COAP_HOST / COAP_PORT retarget it (127.0.0.1:5683). +""" +import os +import random +import socket +import sys +import time + +TYPES = {0: "CON confirmable", 1: "NON non-confirmable", 2: "ACK acknowledgement", 3: "RST reset"} +METHODS = {"get": 1, "post": 2, "put": 3, "delete": 4} +CODES = { + "0.00": "Empty", "0.01": "GET", "0.02": "POST", "0.03": "PUT", "0.04": "DELETE", + "2.01": "Created", "2.02": "Deleted", "2.03": "Valid", "2.04": "Changed", "2.05": "Content", + "4.00": "Bad Request", "4.04": "Not Found", "4.05": "Method Not Allowed", + "5.00": "Internal Server Error", +} +OPTIONS = {3: "Uri-Host", 7: "Uri-Port", 11: "Uri-Path", 12: "Content-Format", 15: "Uri-Query", 17: "Accept"} +FORMATS = {0: "text/plain", 50: "application/json"} + +TTY = sys.stdout.isatty() + + +def bold(text): + return f"\033[1m{text}\033[0m" if TTY else text + + +def encode_option(delta, value): + """One option: a byte of 4-bit delta + 4-bit length, then any extended bytes, then the value.""" + def nibble(n): + if n < 13: + return n, b"" + if n < 269: + return 13, bytes([n - 13]) + return 14, (n - 269).to_bytes(2, "big") + + d, d_ext = nibble(delta) + l, l_ext = nibble(len(value)) + return bytes([(d << 4) | l]) + d_ext + l_ext + value + + +def build_request(method, path, query, payload): + token = os.urandom(4) + header = bytes([(1 << 6) | (0 << 4) | len(token), METHODS[method]]) # ver 1, CON, TKL, code + header += random.randrange(1 << 16).to_bytes(2, "big") + token # message id, token + + # Options must be in ascending number order; each stores the gap from the previous one. + options = [(11, s.encode()) for s in path.split("/") if s] + options += [(15, q.encode()) for q in query.split("&") if q] + body, last = b"", 0 + for number, value in options: + body += encode_option(number - last, value) + last = number + if payload: + body += b"\xff" + payload + return header + body + + +def decode(pkt): + """Return (rows, meta). Each row is (start, end, field, meaning) over pkt[start:end].""" + if len(pkt) < 4: + raise ValueError(f"{len(pkt)} bytes is shorter than the 4-byte CoAP header") + + rows = [] + b0 = pkt[0] + ver, typ, tkl = b0 >> 6, (b0 >> 4) & 0b11, b0 & 0b1111 + rows.append((0, 1, "ver|type|tkl", + f"{ver:02b} {typ:02b} {tkl:04b} -> version {ver}, {TYPES[typ]}, token {tkl} bytes")) + + cls, detail = pkt[1] >> 5, pkt[1] & 0b11111 + code = f"{cls}.{detail:02d}" + rows.append((1, 2, "code", f"{cls:03b} {detail:05b} -> {code} {CODES.get(code, '')}")) + + mid = int.from_bytes(pkt[2:4], "big") + rows.append((2, 4, "message id", f"{mid} (0x{mid:04x})")) + + token = pkt[4:4 + tkl] + if tkl: + rows.append((4, 4 + tkl, "token", f"0x{token.hex()}")) + + i, number = 4 + tkl, 0 + while i < len(pkt): + if pkt[i] == 0xFF: + body = pkt[i + 1:] + rows.append((i, i + 1, "payload marker", "0xff -> the rest is the body")) + rows.append((i + 1, len(pkt), "payload", + f"{len(body)} bytes: {body.decode('utf-8', 'replace')}")) + break + + start = i + delta, length = pkt[i] >> 4, pkt[i] & 0b1111 + i += 1 + extended = [] + for which, n in (("delta", delta), ("length", length)): + if n == 13: + n, i = pkt[i] + 13, i + 1 + elif n == 14: + n, i = int.from_bytes(pkt[i:i + 2], "big") + 269, i + 2 + extended.append(n) + delta, length = extended + number += delta + value = pkt[i:i + length] + i += length + + if number in (12, 17): + n = int.from_bytes(value, "big") if value else 0 + shown = f"{n} = {FORMATS.get(n, 'unknown format')}" + else: + shown = f'"{value.decode("utf-8", "replace")}"' + rows.append((start, i, OPTIONS.get(number, f"option {number}"), + f"delta {delta} -> option {number}, {length} bytes: {shown}")) + + return rows, {"type": typ, "code": code, "mid": mid, "token": token} + + +def show(title, pkt): + print(bold(f"\n{title} ({len(pkt)} bytes)")) + + print(bold("\n hex + ascii")) + for off in range(0, len(pkt), 16): + chunk = pkt[off:off + 16] + hexes = " ".join(f"{b:02x}" for b in chunk) + text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + print(f" {off:04x} {hexes:<47} |{text}|") + + print(bold("\n binary")) + for off in range(0, len(pkt), 4): + chunk = pkt[off:off + 4] + bits = " ".join(f"{b:08b}" for b in chunk) + text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + print(f" {off:04x} {bits:<35} |{text}|") + + rows, meta = decode(pkt) + print(bold("\n fields")) + print(f" {'byte':<7} {'hex':<24} {'field':<15} meaning") + for start, end, field, meaning in rows: + span = f"{start}" if end - start == 1 else f"{start}-{end - 1}" + hexes = pkt[start:end].hex(" ") + if len(hexes) > 23: + hexes = hexes[:20] + "..." + print(f" {span:<7} {hexes:<24} {field:<15} {meaning}") + return meta + + +def main(): + args = sys.argv[1:] + if not args or args[0].lower() not in METHODS: + print(__doc__.strip()) + return 2 + + method = args[0].lower() + target = args[1] if len(args) > 1 else "/hello" + payload = args[2].encode() if len(args) > 2 else b"" + path, _, query = target.partition("?") + host = os.environ.get("COAP_HOST", "127.0.0.1") + port = int(os.environ.get("COAP_PORT", "5683")) + + request = build_request(method, path, query, payload) + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # SOCK_DGRAM = UDP + sock.settimeout(3) + sock.connect((host, port)) + local = "%s:%d" % sock.getsockname() + + req = show(f">>> REQUEST udp {local} -> {host}:{port}", request) + sent_at = time.perf_counter() + sock.send(request) + try: + response = sock.recv(1152) + except socket.timeout: + print(f"\nno reply within 3s - is the CoAP server running on {host}:{port}?") + return 1 + except ConnectionRefusedError: + print(f"\nport unreachable - nothing is listening on udp {host}:{port}") + return 1 + elapsed = (time.perf_counter() - sent_at) * 1000 + + res = show(f"<<< RESPONSE udp {host}:{port} -> {local}", response) + + print(bold("\n exchange")) + print(f" request {TYPES[req['type']].split()[0]} {req['code']} mid 0x{req['mid']:04x} " + f"token 0x{req['token'].hex()} {len(request)} bytes") + print(f" response {TYPES[res['type']].split()[0]} {res['code']} mid 0x{res['mid']:04x} " + f"token 0x{res['token'].hex()} {len(response)} bytes after {elapsed:.2f} ms") + same_mid = "yes" if req["mid"] == res["mid"] else "NO" + same_token = "yes" if req["token"] == res["token"] else "NO" + kind = " (piggybacked: the ACK carries the answer)" if res["type"] == 2 else "" + print(f" message id matches: {same_mid} token matches: {same_token}{kind}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/healthcheck.sh b/healthcheck.sh new file mode 100755 index 0000000..628ba1f --- /dev/null +++ b/healthcheck.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Liveness check for the CoAP server. Exits 0 if healthy, 1 if not. +# ./healthcheck.sh quiet, for cron/systemd/monitoring +# ./healthcheck.sh -v print what it found +# Retargets with COAP_HOST / COAP_PORT. + +CLIENT=${CLIENT:-$(dirname "$0")/target/release/client} +verbose=false; [[ $1 == -v ]] && verbose=true +say() { $verbose && echo "$@"; } + +host=${COAP_HOST:-127.0.0.1}; port=${COAP_PORT:-5683} + +[[ -x $CLIENT ]] || { echo "healthcheck: no client binary at $CLIENT (cargo build --release)"; exit 1; } + +reply=$("$CLIENT" get /health 2>&1) + +# A bound socket proves nothing on UDP - the round trip is the real test. +if [[ $reply != "<- 2.05 "* ]]; then + echo "UNHEALTHY: coap://$host:$port/health -> ${reply:-no reply}" + exit 1 +fi + +body=${reply#<- 2.05 } +if [[ $body != *'"status":"ok"'* ]]; then + echo "UNHEALTHY: unexpected body from coap://$host:$port/health -> $body" + exit 1 +fi + +uptime=$(sed -n 's/.*"uptime":\([0-9]*\).*/\1/p' <<<"$body") +say "HEALTHY: coap://$host:$port up ${uptime}s (code 2.05)" +exit 0 diff --git a/nodejs/client.js b/nodejs/client.js deleted file mode 100644 index d9d8fb1..0000000 --- a/nodejs/client.js +++ /dev/null @@ -1,32 +0,0 @@ -'use strict'; - -// Tiny CLI client: -// node client.js get /hello?name=Imran -// node client.js post /hello Imran -// node client.js get /health - -const coap = require('coap'); - -const [, , methodArg = 'get', pathArg = '/hello', payload] = process.argv; - -const req = coap.request({ - host: process.env.COAP_HOST || 'localhost', - port: Number(process.env.COAP_PORT) || 5683, - pathname: pathArg.split('?')[0], - query: pathArg.split('?')[1] || '', - method: methodArg.toUpperCase(), - confirmable: true, -}); - -req.on('response', (res) => { - console.log(`<- ${res.code} ${res.payload.toString()}`); - process.exit(0); -}); - -req.on('error', (err) => { - console.error('request failed:', err.message); - process.exit(1); -}); - -if (payload) req.write(payload); -req.end(); diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json deleted file mode 100644 index 8b84f61..0000000 --- a/nodejs/package-lock.json +++ /dev/null @@ -1,309 +0,0 @@ -{ - "name": "coapwithudp", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "coapwithudp", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "coap": "^1.6.0" - } - }, - "node_modules/@types/node": { - "version": "26.4.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", - "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } - }, - "node_modules/@types/readable-stream": { - "version": "4.0.24", - "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz", - "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bl": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", - "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", - "license": "MIT", - "dependencies": { - "@types/readable-stream": "^4.0.0", - "buffer": "^6.0.3", - "inherits": "^2.0.4", - "readable-stream": "^4.2.0" - } - }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/capitalize": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/capitalize/-/capitalize-2.0.4.tgz", - "integrity": "sha512-wcSyiFqXRYyCoqu0o0ekXzJAKCLMkqWS5QWGlgTJFJKwRmI6pzcN2hBl5VPq9RzLW5Uf4FF/V/lcFfjCtVak2w==", - "license": "MIT" - }, - "node_modules/cbor": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/cbor/-/cbor-9.0.2.tgz", - "integrity": "sha512-JPypkxsB10s9QOWwa6zwPzqE1Md3vqpPc+cai4sAecuCsRyAtAl/pMyhPlMbT/xtPnm2dznJZYRLui57qiRhaQ==", - "license": "MIT", - "dependencies": { - "nofilter": "^3.1.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/coap": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/coap/-/coap-1.6.0.tgz", - "integrity": "sha512-AvPHT2yQet0wOhlmIqFk11NQPnsKt30yRk6IGp9Yhb6Xnp3ozExPfklJXiE28Xb47tjU0M2OV2W++h8f350Mjg==", - "license": "MIT", - "dependencies": { - "@types/readable-stream": "^4.0.14", - "bl": "^6.0.12", - "capitalize": "^2.0.4", - "coap-oscore": "^2.2.3", - "coap-packet": "^1.1.1", - "debug": "^4.3.5", - "fastseries": "^2.0.0", - "lru-cache": "^11.0.2", - "readable-stream": "^4.5.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/coap-oscore": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/coap-oscore/-/coap-oscore-2.2.3.tgz", - "integrity": "sha512-uF592/QIDgxbwhvAHCiWJat0xIoWsc8K7ubzYK1jXM4ihe50sHLAM7pl361NzZXLOMkkYnHCBSq07DA56OIs/A==", - "license": "MIT", - "dependencies": { - "cbor": "^9.0.2" - } - }, - "node_modules/coap-packet": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/coap-packet/-/coap-packet-1.1.1.tgz", - "integrity": "sha512-Bkz2ZKI/7hU2gm6nUuo5l+MBSkdFJx7My1ZgNEhKUC7K2yYfQYVbBPRa64BBYLcEcYgaSlau4A1Uw5xfM2I0zw==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/fastseries": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-2.0.0.tgz", - "integrity": "sha512-XBU9RXeoYc2/VnvMhplAxEmZLfIk7cvTBu+xwoBuTI8pL19E03cmca17QQycKIdxgwCeFA/a4u27gv1h3ya5LQ==", - "license": "ISC" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/nofilter": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz", - "integrity": "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==", - "license": "MIT", - "engines": { - "node": ">=12.19" - } - }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "license": "MIT" - } - } -} diff --git a/nodejs/package.json b/nodejs/package.json deleted file mode 100644 index ee0fe54..0000000 --- a/nodejs/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "coap-hello-udp", - "version": "1.0.0", - "main": "server.js", - "scripts": { - "start": "node server.js", - "client": "node client.js" - }, - "keywords": [], - "author": "", - "license": "ISC", - "description": "Minimal CoAP-over-UDP hello world server with GET/POST routes", - "dependencies": { - "coap": "^1.6.0" - } -} diff --git a/nodejs/server.js b/nodejs/server.js deleted file mode 100644 index 74cdc6b..0000000 --- a/nodejs/server.js +++ /dev/null @@ -1,102 +0,0 @@ -'use strict'; - -const coap = require('coap'); - -const PORT = Number(process.env.COAP_PORT) || 5683; -const HOST = process.env.COAP_HOST || '0.0.0.0'; - -// --- helpers --------------------------------------------------------------- - -const TEXT = 'text/plain'; -const JSON_FMT = 'application/json'; - -/** Split "/hello?name=Bob" into ["/hello", "name=Bob"]. */ -function splitUrl(url) { - const i = url.indexOf('?'); - const path = (i === -1 ? url : url.slice(0, i)).replace(/\/+$/, '') || '/'; - return [path, i === -1 ? '' : url.slice(i + 1)]; -} - -/** Pull a name out of "?name=Bob" style queries. */ -function nameFromQuery(query) { - return new URLSearchParams(query || '').get('name'); -} - -/** Pull a name out of a POST body: raw text or {"name":"Bob"}. */ -function nameFromBody(buf) { - const body = buf.toString('utf8').trim(); - if (!body) return null; - if (body.startsWith('{')) { - try { - const parsed = JSON.parse(body); - return typeof parsed.name === 'string' ? parsed.name.trim() : null; - } catch { - return null; - } - } - return body; -} - -function greet(name) { - return `Hello, ${name && name.length ? name : 'World'}!`; -} - -function send(res, code, payload, format = TEXT) { - res.code = code; - res.setOption('Content-Format', format); - res.end(typeof payload === 'string' ? payload : JSON.stringify(payload)); -} - -// --- routes ---------------------------------------------------------------- -// key: "METHOD /path" - -const routes = { - 'GET /hello': (req, res) => { - send(res, '2.05', greet(nameFromQuery(req.query))); - }, - - 'POST /hello': (req, res) => { - const name = nameFromBody(req.payload) || nameFromQuery(req.query); - if (!name) { - return send(res, '4.00', 'Bad Request: send a name as the payload'); - } - send(res, '2.01', greet(name)); - }, - - 'GET /health': (_req, res) => { - send(res, '2.05', { status: 'ok', uptime: Math.round(process.uptime()) }, JSON_FMT); - }, -}; - -// --- server ---------------------------------------------------------------- - -const server = coap.createServer({ type: 'udp4' }); - -server.on('request', (req, res) => { - const [path, query] = splitUrl(req.url); - req.query = query; // node-coap only gives us req.url on the server side - const key = `${req.method} ${path}`; - const handler = routes[key]; - - console.log(`${req.method} ${req.url} from ${req.rsinfo.address}:${req.rsinfo.port}`); - - if (!handler) return send(res, '4.04', `Not Found: ${path}`); - - try { - handler(req, res); - } catch (err) { - console.error('handler error:', err); - send(res, '5.00', 'Internal Server Error'); - } -}); - -server.on('error', (err) => console.error('server error:', err)); - -server.listen(PORT, HOST, () => { - console.log(`CoAP server (UDP) listening on coap://${HOST}:${PORT}`); - console.log(' GET /hello?name=Imran'); - console.log(' POST /hello (payload: Imran | {"name":"Imran"})'); - console.log(' GET /health'); -}); - -process.on('SIGINT', () => server.close(() => process.exit(0))); diff --git a/src/main.rs b/src/main.rs index 51fcc9a..b344299 100644 --- a/src/main.rs +++ b/src/main.rs @@ -269,6 +269,7 @@ mod tests { } #[test] + fn reads_name_from_text_and_json_payloads() { assert_eq!(name_from_payload(b"Imran").as_deref(), Some("Imran")); assert_eq!( diff --git a/static/index.html b/static/index.html index 3b6b202..b9e7d22 100644 --- a/static/index.html +++ b/static/index.html @@ -3,7 +3,7 @@ -CoAP Hello — Test Console +CoAP Hello — UDP Reference
-

CoAP Hello — Test Console

-

This page talks HTTP to a local bridge, which reissues each - call as CoAP over UDP and shows you the raw response code.

+

CoAP Hello — UDP Reference

+

This server speaks CoAP over UDP only, so there is nothing here + for a browser to call. Type a name and copy the command for the client you + use — the page is static and works straight off disk.

- -
- - - - - -
-

Or hit an endpoint directly: - /hello?name=Imran · - /health

+ + +

Bundled client — cargo build --release first

+
+
+
+
+ +

Any other CoAP client, e.g. coap-cli

+
+ +

Set COAP_HOST / COAP_PORT to point at + another server; both default to 127.0.0.1:5683.

-

Response

-

—

-
Pick an action above.
+

Routes

+ + + + + + + + + + + +
RequestCodeResponse
GET /hello2.05Hello, World!
GET /hello?name=…2.05Hello, …!
POST /hello2.01Hello, <payload>!
GET /health2.05{"status":"ok","uptime":12}
POST /hello (empty)4.00Bad Request
GET /nope4.04Not Found
PUT /hello4.05Method Not Allowed
+

CoAP writes response codes as class.detail: + 2.05 is HTTP's 200, 4.04 its 404. + POST /hello takes raw text or {"name":"…"}.

+
+ +
+

Scripts

+

Full request/response suite against a running server

+
./test-coap.sh
+

Liveness check — exit 0 healthy, 1 not

+
./healthcheck.sh -v
+

Start the server

+
cargo run --release