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.
+
+
+ + + +