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 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
nodejs/node_modules/
|
||||||
193
Cargo.lock
generated
Normal file
193
Cargo.lock
generated
Normal file
@@ -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",
|
||||||
|
]
|
||||||
14
Cargo.toml
Normal file
14
Cargo.toml
Normal file
@@ -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
|
||||||
105
README.md
Normal file
105
README.md
Normal file
@@ -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, <payload>!` |
|
||||||
|
| 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.
|
||||||
32
nodejs/client.js
Normal file
32
nodejs/client.js
Normal file
@@ -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();
|
||||||
309
nodejs/package-lock.json
generated
Normal file
309
nodejs/package-lock.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
nodejs/package.json
Normal file
16
nodejs/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
102
nodejs/server.js
Normal file
102
nodejs/server.js
Normal file
@@ -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)));
|
||||||
86
src/bin/client.rs
Normal file
86
src/bin/client.rs
Normal file
@@ -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<dyn std::error::Error>> {
|
||||||
|
let args: Vec<String> = 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)
|
||||||
|
}
|
||||||
308
src/main.rs
Normal file
308
src/main.rs
Normal file
@@ -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<Vec<u8>> {
|
||||||
|
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<SocketAddr>,
|
||||||
|
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<String> = 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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user