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 <noreply@anthropic.com>
This commit is contained in:
181
src/bin/bridge.rs
Normal file
181
src/bin/bridge.rs
Normal file
@@ -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<Option<(String, String, Vec<u8>)>> {
|
||||
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<u8> {
|
||||
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<usize> {
|
||||
haystack.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let args: Vec<String> = 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user