From 137b9d95473a4c8b2d17ee17b7cf42cd027757e5 Mon Sep 17 00:00:00 2001 From: Md Imran Hossain Date: Thu, 3 Sep 2026 10:28:24 +0600 Subject: [PATCH] Add CoAP-over-UDP hello server in Rust A minimal CoAP (RFC 7252) server built on coap-lite for message encoding and a tokio UdpSocket for transport. The socket, read loop and routing are kept in src/main.rs rather than behind a framework. Routes: GET /hello[?name=X] -> 2.05 "Hello, X!" (defaults to World) POST /hello -> 2.01, name from a raw text or JSON body GET /health -> 2.05 status + uptime Unknown paths return 4.04, known paths with the wrong method 4.05. Includes a CLI client (src/bin/client.rs) for smoke tests and unit tests covering the greeting, payload parsing and query decoding. Verified against node-coap as an independent implementation to confirm wire compatibility. The earlier Node.js prototype is kept under nodejs/ for reference. Co-Authored-By: Claude Opus 5 --- .gitignore | 2 + Cargo.lock | 193 ++++++++++++++++++++++++ Cargo.toml | 14 ++ README.md | 105 +++++++++++++ nodejs/client.js | 32 ++++ nodejs/package-lock.json | 309 +++++++++++++++++++++++++++++++++++++++ nodejs/package.json | 16 ++ nodejs/server.js | 102 +++++++++++++ src/bin/client.rs | 86 +++++++++++ src/main.rs | 308 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 1167 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 nodejs/client.js create mode 100644 nodejs/package-lock.json create mode 100644 nodejs/package.json create mode 100644 nodejs/server.js create mode 100644 src/bin/client.rs create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5bcc8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +nodejs/node_modules/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..791ed64 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,193 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "coap-hello-udp" +version = "0.1.0" +dependencies = [ + "coap-lite", + "tokio", +] + +[[package]] +name = "coap-lite" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87d56a0c1bcd331e7758fd3dd95ed7dd87d118a3b7dc57d60332e17bc97c3f91" +dependencies = [ + "coap-message 0.2.3", + "coap-message 0.3.7", + "lru_time_cache", +] + +[[package]] +name = "coap-message" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc888e15f6be910b58fda4f056f673f58fb9348ea45da014283a14e6d2b58ec" + +[[package]] +name = "coap-message" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5716ce4b1742dcb19c3a0afa4837a7052b4a7f13a2bd71cd28a05e90877377d4" +dependencies = [ + "num-traits", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lru_time_cache" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9106e1d747ffd48e6be5bb2d97fa706ed25b144fbee4d5c02eae110cd8d6badd" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5700b04 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "coap-hello-udp" +version = "0.1.0" +edition = "2024" + +[dependencies] +coap-lite = "0.13.3" +tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "macros", "signal", "time"] } + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..56d2c62 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# CoAP Hello (UDP) — Rust MVP + +A minimal CoAP server over UDP in Rust, built on [`coap-lite`](https://crates.io/crates/coap-lite) +(the RFC 7252 binary codec) and a [`tokio`](https://tokio.rs) `UdpSocket`. + +CoAP is "HTTP semantics on a UDP datagram" — the same methods, paths and query +strings, but each exchange is one compact binary datagram. Response codes are +written `class.detail`: `2.05 Content`, `2.01 Created`, `4.00 Bad Request`, +`4.04 Not Found`, `4.05 Method Not Allowed`. + +`coap-lite` does message encoding only — the socket, the read loop and the +routing are all in `src/main.rs`, so nothing about the UDP layer is hidden. + +## Run + +```bash +cargo run --release +``` + +Listens on `udp://0.0.0.0:5683` (CoAP's IANA-assigned port). + +## Routes + +| Method | Path | Response | +|--------|---------------------|--------------------------------------| +| GET | `/hello` | `2.05` `Hello, World!` | +| GET | `/hello?name=Imran` | `2.05` `Hello, Imran!` | +| POST | `/hello` | `2.01` `Hello, !` | +| GET | `/health` | `2.05` `{"status":"ok","uptime":12}` | + +`POST /hello` takes either a raw text payload (`Imran`) or JSON +(`{"name":"Imran"}`). An empty payload returns `4.00`. An unknown path returns +`4.04`; a known path with the wrong method returns `4.05`. + +## Try it + +With the bundled client: + +```bash +cargo run --bin client -- get "/hello?name=Imran" +``` + +```bash +cargo run --bin client -- post /hello "Imran" +``` + +Or with any other CoAP client, e.g. [`coap-cli`](https://www.npmjs.com/package/coap-cli): + +```bash +coap get "coap://localhost:5683/hello?name=Imran" +``` + +## Test + +```bash +cargo test +``` + +Five unit tests cover the greeting, payload parsing (text + JSON) and query +decoding. + +## Layout + +| Path | What it is | +|---------------------|---------------------------------------------------------| +| `src/main.rs` | The server: socket, read loop, route table, helpers. | +| `src/bin/client.rs` | CLI client for smoke tests. | +| `nodejs/` | The earlier Node.js version, kept for reference. | + +## Config + +| Env var | Default | +|-------------|-----------| +| `COAP_PORT` | `5683` | +| `COAP_HOST` | `0.0.0.0` | + +## Design notes + +- **One task, no per-request spawn.** Handling a datagram is pure CPU work with + no `.await` inside, so it runs inline on the receive loop — no task-spawn + overhead, and replies keep their arrival order. Spawn per datagram once a + handler does real I/O (a database, an upstream call). +- **A fixed 1152-byte receive buffer**, reused across requests, so the steady + state allocates only for the response body. 1152 is the RFC 7252 guidance for + staying inside an unfragmented IPv6 datagram. +- **Hand-rolled JSON field read.** `json_string_field` handles a flat + `{"name":"..."}` and nothing more; it exists to avoid a `serde_json` + dependency for one field. Swap it out the moment bodies get real structure. +- **Lenient percent-decoding.** RFC 7252 §6.4 says the *client* decodes before + splitting a URI into options, so a strict client sends `name=Md Imran` + already decoded. Not all clients do, so the server decodes again — meaning a + literal `%` in a name would be misread. Drop `percent_decode` if your clients + are known-conformant. + +## Next steps + +- **DTLS.** Plaintext CoAP has no encryption or authentication. Production uses + `coaps://` on port 5684 — see `webrtc-dtls` or a gateway like `libcoap`. +- **Block-wise transfer** (RFC 7959) for payloads past ~1 KB; `coap-lite` ships + a `BlockHandler` for this. +- **Observe** (RFC 7641) for server-pushed updates; `coap-lite` provides + `Subject` and `create_notification`. +- **Deduplication.** A CoAP server should track recent message IDs and replay + the cached response, since UDP clients retransmit. Fine to skip for + idempotent greetings, needed once a POST has side effects. diff --git a/nodejs/client.js b/nodejs/client.js new file mode 100644 index 0000000..d9d8fb1 --- /dev/null +++ b/nodejs/client.js @@ -0,0 +1,32 @@ +'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 new file mode 100644 index 0000000..8b84f61 --- /dev/null +++ b/nodejs/package-lock.json @@ -0,0 +1,309 @@ +{ + "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 new file mode 100644 index 0000000..ee0fe54 --- /dev/null +++ b/nodejs/package.json @@ -0,0 +1,16 @@ +{ + "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 new file mode 100644 index 0000000..74cdc6b --- /dev/null +++ b/nodejs/server.js @@ -0,0 +1,102 @@ +'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/bin/client.rs b/src/bin/client.rs new file mode 100644 index 0000000..f55afd6 --- /dev/null +++ b/src/bin/client.rs @@ -0,0 +1,86 @@ +//! Tiny CoAP client for smoke-testing the server. +//! +//! cargo run --bin client -- get "/hello?name=Imran" +//! cargo run --bin client -- post /hello Imran +//! cargo run --bin client -- get /health + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use coap_lite::{CoapOption, MessageClass, MessageType, Packet, RequestType}; +use tokio::net::UdpSocket; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + let method = args.first().map(String::as_str).unwrap_or("get"); + let target = args.get(1).map(String::as_str).unwrap_or("/hello"); + let payload = args.get(2).map(String::as_str).unwrap_or(""); + + let host = std::env::var("COAP_HOST").unwrap_or_else(|_| "127.0.0.1".into()); + let port = std::env::var("COAP_PORT").unwrap_or_else(|_| "5683".into()); + + let method = match method.to_ascii_lowercase().as_str() { + "get" => RequestType::Get, + "post" => RequestType::Post, + "put" => RequestType::Put, + "delete" => RequestType::Delete, + other => return Err(format!("unsupported method: {other}").into()), + }; + + // A CoAP URI is carried as repeated options, not one string: each path + // segment is its own Uri-Path option, each `k=v` its own Uri-Query. + let (path, query) = match target.split_once('?') { + Some((p, q)) => (p, Some(q)), + None => (target, None), + }; + + let mut packet = Packet::new(); + packet.header.set_version(1); + packet.header.set_type(MessageType::Confirmable); + packet.header.code = MessageClass::Request(method); + packet.header.message_id = nonce() as u16; + packet.set_token(nonce().to_be_bytes()[..4].to_vec()); + + for segment in path.split('/').filter(|s| !s.is_empty()) { + packet.add_option(CoapOption::UriPath, segment.as_bytes().to_vec()); + } + for pair in query.unwrap_or("").split('&').filter(|s| !s.is_empty()) { + packet.add_option(CoapOption::UriQuery, pair.as_bytes().to_vec()); + } + if !payload.is_empty() { + packet.payload = payload.as_bytes().to_vec(); + } + + let socket = UdpSocket::bind("0.0.0.0:0").await?; + socket.connect(format!("{host}:{port}")).await?; + socket.send(&packet.to_bytes()?).await?; + + let mut buf = [0u8; 1152]; + // CoAP's default response window for a confirmable request is ~2s. + let len = match tokio::time::timeout(Duration::from_secs(3), socket.recv(&mut buf)).await { + Ok(result) => result?, + Err(_) => return Err("timed out waiting for a response".into()), + }; + + let response = Packet::from_bytes(&buf[..len])?; + println!( + "<- {} {}", + status_text(&response), + String::from_utf8_lossy(&response.payload) + ); + + Ok(()) +} + +/// Render the response code in CoAP's `class.detail` form, e.g. `2.05`. +fn status_text(packet: &Packet) -> String { + let raw: u8 = packet.header.code.into(); + format!("{}.{:02}", raw >> 5, raw & 0x1f) +} + +fn nonce() -> u32 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(1) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..51fcc9a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,308 @@ +//! A minimal CoAP server over UDP. +//! +//! CoAP (RFC 7252) is "HTTP semantics on a UDP datagram": same methods, paths +//! and query strings, but each request/response is a single datagram carrying a +//! compact binary header. `coap-lite` handles that binary codec; the UDP socket +//! and the routing are right here so nothing is hidden. + +use std::net::SocketAddr; +use std::time::Instant; + +use coap_lite::{ + CoapOption, CoapRequest, CoapResponse, ContentFormat, Packet, RequestType, + ResponseType, +}; +use tokio::net::UdpSocket; + +/// Max UDP payload we accept. RFC 7252 recommends staying under the ~1152 B +/// that fits an unfragmented IPv6 datagram; anything larger needs block-wise +/// transfer (RFC 7959). +const MAX_DATAGRAM: usize = 1152; + +#[tokio::main] +async fn main() -> std::io::Result<()> { + let host = std::env::var("COAP_HOST").unwrap_or_else(|_| "0.0.0.0".into()); + let port = std::env::var("COAP_PORT").unwrap_or_else(|_| "5683".into()); + let addr = format!("{host}:{port}"); + + let socket = UdpSocket::bind(&addr).await?; + let started = Instant::now(); + + println!("CoAP server (UDP) listening on coap://{addr}"); + println!(" GET /hello?name=Imran"); + println!(" POST /hello (payload: Imran)"); + println!(" GET /health"); + + let mut buf = [0u8; MAX_DATAGRAM]; + + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + println!("\nshutting down"); + return Ok(()); + } + result = socket.recv_from(&mut buf) => { + let (len, peer) = match result { + Ok(v) => v, + Err(e) => { eprintln!("recv error: {e}"); continue; } + }; + + // Handling is pure CPU work with no awaits, so doing it inline + // keeps the hot path allocation-light and ordered. Spawn a task + // here instead once a handler needs to do real I/O. + if let Some(reply) = handle(&buf[..len], peer, started) + && let Err(e) = socket.send_to(&reply, peer).await + { + eprintln!("send error to {peer}: {e}"); + } + } + } + } +} + +/// Decode one datagram, route it, and encode the reply. +/// Returns `None` when no reply should be sent (unparsable or non-request). +fn handle(datagram: &[u8], peer: SocketAddr, started: Instant) -> Option> { + let packet = match Packet::from_bytes(datagram) { + Ok(p) => p, + Err(e) => { + eprintln!("malformed packet from {peer}: {e}"); + return None; + } + }; + + let mut request = CoapRequest::from_packet(packet, peer); + + // `None` means the datagram was an ACK/RST, not something we answer. + let mut response = CoapResponse::new(&request.message)?; + + let path = format!("/{}", request.get_path()); + let method = *request.get_method(); + println!("{method:?} {}{} from {peer}", path, query_string(&request.message)); + + route(method, &path, &mut request, &mut response, started); + + match response.message.to_bytes() { + Ok(bytes) => Some(bytes), + Err(e) => { + eprintln!("encode error: {e}"); + None + } + } +} + +fn route( + method: RequestType, + path: &str, + request: &mut CoapRequest, + response: &mut CoapResponse, + started: Instant, +) { + match (method, path) { + (RequestType::Get, "/hello") => { + let name = query_param(&request.message, "name"); + reply(response, ResponseType::Content, ContentFormat::TextPlain, greet(name.as_deref())); + } + + (RequestType::Post, "/hello") => { + let name = name_from_payload(&request.message.payload) + .or_else(|| query_param(&request.message, "name")); + + match name { + Some(name) => reply( + response, + ResponseType::Created, + ContentFormat::TextPlain, + greet(Some(&name)), + ), + None => reply( + response, + ResponseType::BadRequest, + ContentFormat::TextPlain, + "Bad Request: send a name as the payload".into(), + ), + } + } + + (RequestType::Get, "/health") => { + let body = format!( + r#"{{"status":"ok","uptime":{}}}"#, + started.elapsed().as_secs() + ); + reply(response, ResponseType::Content, ContentFormat::ApplicationJSON, body); + } + + // Path exists but the method doesn't: 4.05, per RFC 7252. + (_, "/hello") | (_, "/health") => reply( + response, + ResponseType::MethodNotAllowed, + ContentFormat::TextPlain, + format!("Method Not Allowed: {method:?} {path}"), + ), + + _ => reply( + response, + ResponseType::NotFound, + ContentFormat::TextPlain, + format!("Not Found: {path}"), + ), + } +} + +fn greet(name: Option<&str>) -> String { + let name = name.map(str::trim).filter(|n| !n.is_empty()).unwrap_or("World"); + format!("Hello, {name}!") +} + +fn reply( + response: &mut CoapResponse, + status: ResponseType, + format: ContentFormat, + body: String, +) { + response.set_status(status); + response.message.set_content_format(format); + response.message.payload = body.into_bytes(); +} + +/// Re-join the URI-Query options for logging, e.g. `?name=Imran`. +fn query_string(packet: &Packet) -> String { + match packet.get_option(CoapOption::UriQuery) { + Some(options) if !options.is_empty() => { + let pairs: Vec = options + .iter() + .map(|o| String::from_utf8_lossy(o).into_owned()) + .collect(); + format!("?{}", pairs.join("&")) + } + _ => String::new(), + } +} + +/// Read one `?key=value` pair out of the URI-Query options. +fn query_param(packet: &Packet, key: &str) -> Option { + let options = packet.get_option(CoapOption::UriQuery)?; + for option in options.iter() { + let pair = String::from_utf8_lossy(option); + if let Some(value) = pair.strip_prefix(&format!("{key}=")) { + return Some(percent_decode(value)); + } + } + None +} + +/// Accept a raw-text payload, or `{"name":"Imran"}`. +fn name_from_payload(payload: &[u8]) -> Option { + let body = String::from_utf8_lossy(payload).trim().to_string(); + if body.is_empty() { + return None; + } + if body.starts_with('{') { + return json_string_field(&body, "name"); + } + Some(body) +} + +/// Pull one top-level string field out of flat JSON. Enough for an MVP body +/// like `{"name":"Imran"}`; swap in `serde_json` when the shape grows. +fn json_string_field(body: &str, field: &str) -> Option { + let after_key = body.split_once(&format!("\"{field}\""))?.1; + let after_colon = after_key.split_once(':')?.1; + let start = after_colon.find('"')? + 1; + let value = &after_colon[start..]; + let end = value.find('"')?; + Some(value[..end].to_string()) +} + +/// A CoAP client is supposed to percent-decode before splitting a URI into +/// options, but not all of them do — decode leniently so `?name=Md%20Imran` +/// works either way. Invalid escapes are left as-is. +fn percent_decode(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + + while i < bytes.len() { + match bytes[i] { + b'%' if i + 2 < bytes.len() => { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok(); + match hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { + Some(byte) => { + out.push(byte); + i += 3; + } + None => { + out.push(bytes[i]); + i += 1; + } + } + } + b'+' => { + out.push(b' '); + i += 1; + } + byte => { + out.push(byte); + i += 1; + } + } + } + + String::from_utf8_lossy(&out).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn greets_world_when_no_name() { + assert_eq!(greet(None), "Hello, World!"); + assert_eq!(greet(Some("")), "Hello, World!"); + assert_eq!(greet(Some(" ")), "Hello, World!"); + } + + #[test] + fn greets_the_given_name() { + assert_eq!(greet(Some("Imran")), "Hello, Imran!"); + assert_eq!(greet(Some(" Imran ")), "Hello, Imran!"); + } + + #[test] + fn reads_name_from_text_and_json_payloads() { + assert_eq!(name_from_payload(b"Imran").as_deref(), Some("Imran")); + assert_eq!( + name_from_payload(br#"{"name":"Imran"}"#).as_deref(), + Some("Imran") + ); + assert_eq!( + name_from_payload(br#"{ "name" : "Md Imran" }"#).as_deref(), + Some("Md Imran") + ); + assert_eq!(name_from_payload(b""), None); + assert_eq!(name_from_payload(b" "), None); + assert_eq!(name_from_payload(br#"{"other":"x"}"#), None); + } + + #[test] + fn decodes_percent_escapes() { + assert_eq!(percent_decode("Md%20Imran"), "Md Imran"); + assert_eq!(percent_decode("Md+Imran"), "Md Imran"); + assert_eq!(percent_decode("Imran"), "Imran"); + // Invalid escapes are passed through rather than dropped. + assert_eq!(percent_decode("100%"), "100%"); + assert_eq!(percent_decode("%zz"), "%zz"); + } + + #[test] + fn reads_query_param_from_uri_query_options() { + let mut packet = Packet::new(); + packet.add_option(CoapOption::UriQuery, b"name=Md%20Imran".to_vec()); + packet.add_option(CoapOption::UriQuery, b"lang=en".to_vec()); + + assert_eq!(query_param(&packet, "name").as_deref(), Some("Md Imran")); + assert_eq!(query_param(&packet, "lang").as_deref(), Some("en")); + assert_eq!(query_param(&packet, "missing"), None); + assert_eq!(query_string(&packet), "?name=Md%20Imran&lang=en"); + } +}