From 4906ca7652ce3af7eabe817f0fba224f62eb49a7 Mon Sep 17 00:00:00 2001 From: Md Imran Hossain Date: Sat, 5 Sep 2026 10:01:44 +0600 Subject: [PATCH] Add HTTP bridge so the CoAP server can be checked in a browser Browsers speak HTTP over TCP and cannot open coap:// URLs, which made the server awkward to try without the CLI client. The bridge accepts an ordinary HTTP request, reissues it as CoAP over UDP, and maps the response code back onto an HTTP status (the raw code is also returned in X-Coap-Code). It serves a test console at / with a name field and a button per route, so GET, POST, 4.04 and 4.05 can all be exercised by clicking. The CoAP request logic moves from src/bin/client.rs into src/lib.rs so the client and the bridge share one implementation. The bridge is a development convenience: a small HTTP/1.1 subset with no keep-alive, chunked bodies or TLS. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 7 ++ Cargo.toml | 2 +- README.md | 51 +++++++++++-- src/bin/bridge.rs | 181 ++++++++++++++++++++++++++++++++++++++++++++++ src/bin/client.rs | 68 ++--------------- src/lib.rs | 104 ++++++++++++++++++++++++++ static/index.html | 134 ++++++++++++++++++++++++++++++++++ 7 files changed, 478 insertions(+), 69 deletions(-) create mode 100644 src/bin/bridge.rs create mode 100644 src/lib.rs create mode 100644 static/index.html diff --git a/Cargo.lock b/Cargo.lock index 791ed64..eeb9f95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + [[package]] name = "coap-hello-udp" version = "0.1.0" @@ -145,6 +151,7 @@ version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", diff --git a/Cargo.toml b/Cargo.toml index 5700b04..9134b97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] coap-lite = "0.13.3" -tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "macros", "signal", "time"] } +tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "macros", "signal", "time", "io-util"] } [profile.release] opt-level = 3 diff --git a/README.md b/README.md index 56d2c62..b6505a2 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,44 @@ Listens on `udp://0.0.0.0:5683` (CoAP's IANA-assigned port). (`{"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 +## Check it in a browser + +Browsers speak HTTP over TCP; this server speaks CoAP over UDP, so `coap://` +URLs cannot be opened directly. `src/bin/bridge.rs` is a small HTTP -> CoAP +proxy that closes the gap. Run both: + +```bash +cargo run --release --bin coap-hello-udp +``` + +```bash +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. + +These are now ordinary URLs — clickable, curl-able, bookmarkable: + +| URL | Response | +|--------------------------------------------------|------------------------------| +| `http://localhost:8080/` | the test console | +| `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: + +```bash +curl -X POST -d "Imran" http://localhost:8080/hello +``` + +The bridge is a development convenience: a deliberately small HTTP/1.1 subset +with no keep-alive, no chunked bodies and no TLS. Do not expose it. Set +`HTTP_PORT` if 8080 is taken. + +## Try it from the CLI With the bundled client: @@ -64,15 +101,19 @@ decoding. | Path | What it is | |---------------------|---------------------------------------------------------| | `src/main.rs` | The server: socket, read loop, route table, helpers. | +| `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 `/`. | | `nodejs/` | The earlier Node.js version, kept for reference. | ## Config -| Env var | Default | -|-------------|-----------| -| `COAP_PORT` | `5683` | -| `COAP_HOST` | `0.0.0.0` | +| Env var | Default | Used by | +|-------------|-----------|----------------| +| `COAP_PORT` | `5683` | server, client, bridge | +| `COAP_HOST` | `0.0.0.0` | server, client, bridge | +| `HTTP_PORT` | `8080` | bridge | ## Design notes diff --git a/src/bin/bridge.rs b/src/bin/bridge.rs new file mode 100644 index 0000000..b21661c --- /dev/null +++ b/src/bin/bridge.rs @@ -0,0 +1,181 @@ +//! HTTP -> CoAP bridge, so a browser can exercise the CoAP server. +//! +//! Browsers speak HTTP over TCP; the server speaks CoAP over UDP. This sits in +//! the middle: it accepts an ordinary HTTP request, reissues it as CoAP, and +//! returns the reply with the CoAP response code mapped onto an HTTP status +//! (and echoed verbatim in the `X-Coap-Code` header). +//! +//! Development convenience only — it is a deliberately small HTTP/1.1 subset +//! (no keep-alive, no chunked bodies, no TLS). Do not expose it. + +use std::net::SocketAddr; + +use coap_hello_udp::{parse_method, request}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +const MAX_REQUEST: usize = 64 * 1024; +const PAGE: &str = include_str!("../../static/index.html"); + +#[tokio::main] +async fn main() -> std::io::Result<()> { + let http_port = std::env::var("HTTP_PORT").unwrap_or_else(|_| "8080".into()); + let coap_host = std::env::var("COAP_HOST").unwrap_or_else(|_| "127.0.0.1".into()); + let coap_port = std::env::var("COAP_PORT").unwrap_or_else(|_| "5683".into()); + + let listener = TcpListener::bind(format!("0.0.0.0:{http_port}")).await?; + println!("HTTP -> CoAP bridge on http://localhost:{http_port}"); + println!(" forwarding to coap://{coap_host}:{coap_port}"); + println!(" open http://localhost:{http_port}/ in a browser"); + + loop { + let (stream, peer) = match listener.accept().await { + Ok(v) => v, + Err(e) => { + eprintln!("accept error: {e}"); + continue; + } + }; + + // Each connection blocks on a CoAP round trip, so give it its own task. + let (host, port) = (coap_host.clone(), coap_port.clone()); + tokio::spawn(async move { + if let Err(e) = serve(stream, peer, &host, &port).await { + eprintln!("connection error from {peer}: {e}"); + } + }); + } +} + +async fn serve( + mut stream: TcpStream, + peer: SocketAddr, + coap_host: &str, + coap_port: &str, +) -> std::io::Result<()> { + let Some((method, target, body)) = read_request(&mut stream).await? else { + return Ok(()); + }; + + println!("{method} {target} from {peer}"); + + let (path, query) = match target.split_once('?') { + Some((p, q)) => (p, Some(q)), + None => (target.as_str(), None), + }; + + let response = match (method.as_str(), path) { + ("GET", "/") => http_response(200, "text/html; charset=utf-8", PAGE, None), + (_, "/favicon.ico") => http_response(204, "text/plain", "", None), + + // Anything else is proxied straight through to the CoAP server. + _ => match parse_method(&method) { + Err(e) => http_response(405, "text/plain; charset=utf-8", &e, None), + Ok(coap_method) => { + match request(coap_host, coap_port, coap_method, path, query, &body).await { + Ok(reply) => { + let status = reply.http_status(); + let content_type = if reply.payload.starts_with('{') { + "application/json" + } else { + "text/plain; charset=utf-8" + }; + http_response(status, content_type, &reply.payload, Some(&reply.code)) + } + Err(e) => http_response( + 502, + "text/plain; charset=utf-8", + &format!("Bad Gateway: {e}\nIs the CoAP server running on {coap_host}:{coap_port}?"), + None, + ), + } + } + }, + }; + + stream.write_all(&response).await?; + stream.flush().await +} + +/// Read one HTTP/1.1 request: the request line, the headers, and a +/// `Content-Length` body if present. Returns `(method, target, body)`. +async fn read_request( + stream: &mut TcpStream, +) -> std::io::Result)>> { + let mut buf = Vec::with_capacity(1024); + let mut chunk = [0u8; 1024]; + + // Read until the blank line that ends the headers. + let header_end = loop { + if let Some(pos) = find(&buf, b"\r\n\r\n") { + break pos + 4; + } + if buf.len() > MAX_REQUEST { + return Ok(None); + } + match stream.read(&mut chunk).await? { + 0 => return Ok(None), // client hung up + n => buf.extend_from_slice(&chunk[..n]), + } + }; + + let head = String::from_utf8_lossy(&buf[..header_end]).into_owned(); + let mut lines = head.lines(); + + let mut request_line = lines.next().unwrap_or("").split_whitespace(); + let (Some(method), Some(target)) = (request_line.next(), request_line.next()) else { + return Ok(None); + }; + + let content_length: usize = lines + .find(|l| l.to_ascii_lowercase().starts_with("content-length:")) + .and_then(|l| l.split(':').nth(1)) + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(0); + + let mut body = buf[header_end..].to_vec(); + while body.len() < content_length.min(MAX_REQUEST) { + match stream.read(&mut chunk).await? { + 0 => break, + n => body.extend_from_slice(&chunk[..n]), + } + } + body.truncate(content_length); + + Ok(Some((method.to_uppercase(), target.to_string(), body))) +} + +fn http_response(status: u16, content_type: &str, body: &str, coap_code: Option<&str>) -> Vec { + let reason = match status { + 200 => "OK", + 201 => "Created", + 204 => "No Content", + 400 => "Bad Request", + 404 => "Not Found", + 405 => "Method Not Allowed", + 502 => "Bad Gateway", + _ => "Unknown", + }; + + let coap_header = coap_code + .map(|c| format!("X-Coap-Code: {c}\r\n")) + .unwrap_or_default(); + + let mut response = format!( + "HTTP/1.1 {status} {reason}\r\n\ + Content-Type: {content_type}\r\n\ + Content-Length: {}\r\n\ + {coap_header}\ + Connection: close\r\n\ + \r\n", + body.len() + ) + .into_bytes(); + + response.extend_from_slice(body.as_bytes()); + response +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} diff --git a/src/bin/client.rs b/src/bin/client.rs index f55afd6..e4a71b7 100644 --- a/src/bin/client.rs +++ b/src/bin/client.rs @@ -4,83 +4,25 @@ //! 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; +use coap_hello_udp::{parse_method, request}; #[tokio::main] -async fn main() -> Result<(), Box> { +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 method = parse_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) - ); + let reply = request(&host, &port, method, path, query, payload.as_bytes()).await?; + println!("<- {} {}", reply.code, reply.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/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ba078e0 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,104 @@ +//! Shared CoAP client used by the CLI (`src/bin/client.rs`) and the HTTP +//! bridge (`src/bin/bridge.rs`). + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use coap_lite::{CoapOption, MessageClass, MessageType, Packet, RequestType}; +use tokio::net::UdpSocket; + +/// What a CoAP server sent back. +pub struct CoapReply { + /// Response code in CoAP's `class.detail` form, e.g. `"2.05"`. + pub code: String, + pub payload: String, +} + +impl CoapReply { + /// Map a CoAP code onto the nearest HTTP status, for the bridge. + /// CoAP's `4.04` and HTTP's `404` are the same idea in different notation. + pub fn http_status(&self) -> u16 { + let (class, detail) = match self.code.split_once('.') { + Some((c, d)) => ( + c.parse::().unwrap_or(5), + d.parse::().unwrap_or(0), + ), + None => (5, 0), + }; + match class { + 2 => match detail { + 1 => 201, // Created + 4 => 204, // Changed -> No Content is the closest fit + _ => 200, // Content / Valid / Deleted + }, + 4 => 400 + detail, + _ => 500, + } + } +} + +pub fn parse_method(name: &str) -> Result { + match name.to_ascii_lowercase().as_str() { + "get" => Ok(RequestType::Get), + "post" => Ok(RequestType::Post), + "put" => Ok(RequestType::Put), + "delete" => Ok(RequestType::Delete), + other => Err(format!("unsupported method: {other}")), + } +} + +/// Send one confirmable CoAP request and wait for the reply. +/// +/// A CoAP URI is carried as repeated options rather than one string: each path +/// segment becomes its own Uri-Path option, each `k=v` its own Uri-Query. +pub async fn request( + host: &str, + port: &str, + method: RequestType, + path: &str, + query: Option<&str>, + payload: &[u8], +) -> Result> { + 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.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 CoAP response".into()), + }; + + let response = Packet::from_bytes(&buf[..len])?; + let raw: u8 = response.header.code.into(); + + Ok(CoapReply { + code: format!("{}.{:02}", raw >> 5, raw & 0x1f), + payload: String::from_utf8_lossy(&response.payload).into_owned(), + }) +} + +fn nonce() -> u32 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(1) +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..3b6b202 --- /dev/null +++ b/static/index.html @@ -0,0 +1,134 @@ + + + + + +CoAP Hello — Test Console + + + +
+

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.

+ +
+ + +
+ + + + + +
+

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

+
+ +
+

Response

+

—

+
Pick an action above.
+
+
+ + + +