diff --git a/docs/automation/API-SPEC.md b/docs/automation/API-SPEC.md index d175f8bdb..c124f6b31 100644 --- a/docs/automation/API-SPEC.md +++ b/docs/automation/API-SPEC.md @@ -72,15 +72,22 @@ MCP advertise the whole surface; feature detection is `capabilities` (§4). `OpenCADStudio --http 8090` runs a private, window-less session behind a resource-oriented REST surface at `http://127.0.0.1:/api/v1` (loopback -bind only, permissive CORS, JSON bodies, one request per connection). A +bind only, JSON bodies, one request per connection, no CORS headers). A machine-readable OpenAPI 3 description of the whole surface is served at `GET /api/v1/openapi`. +Every request carries `Authorization: Bearer `: the value of +`OCS_API_TOKEN` when the launcher set it, otherwise a random token printed on +stderr at startup. A request without it is answered **401** +`{"code":"unauthorized"}`. Loopback is shared by every process and user on +the machine, so the port alone is no credential. + | Status | Meaning | |---|---| | `200` | Success (read or update) | | `201` | A resource was created (document, entities, block, group, selection set, layout) | | `400` | Validation failure — the body keeps the symbolic `code` and message | +| `401` | Missing or wrong bearer token (`code:"unauthorized"`) | | `404` | Unknown route or unknown handle (`code:"entity_absent"`) | | `409` | State conflict (`stale_state`, `document_not_active`) or GUI required (`gui_required`) | | `503` | Dispatcher busy | @@ -126,7 +133,7 @@ Differences worth knowing: - Unknown POST paths are treated as op attempts and forwarded (any op of the surface works — including `operation` to poll a pending pick and `cancel` to retract it); truly unrouted paths answer `404 {"code":"unknown_route"}`. -- `OPTIONS *` answers **204** (CORS preflight). +- The same bearer token as §3.1 is required on every request. ### 3.3 stdio JSONL (`--serve`) @@ -138,6 +145,9 @@ envelope; mutations use the envelope of §1). ### 3.4 TCP (`--serve --port N`) The same JSONL protocol as §3.3 over `127.0.0.1:`, one client at a time. +Every request line carries `"token":""` (the §3.1 token); a line +without it is answered `{"ok":false,"code":"unauthorized"}` and the +connection is closed. ### 3.5 AI-tool session (`--mcp`) @@ -506,7 +516,7 @@ client's own log. ```sh # Create (missing layers auto-created; the batch is atomic). -curl -s -X POST http://127.0.0.1:8090/api/v1/entities -H "Content-Type: application/json" -d '{ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities -H "Content-Type: application/json" -d '{ "entities":[ {"type":"Line","start":[0,0],"end":[100,0],"layer":"FRAME"}, {"type":"Text","value":"PAGE-01","position":[10,50],"height":3.0} @@ -514,16 +524,16 @@ curl -s -X POST http://127.0.0.1:8090/api/v1/entities -H "Content-Type: applicat # → 201 {"result":{"handles":["63","64"],"created":2,"layers_created":["FRAME"]}} # Filter by property. -curl -s "http://127.0.0.1:8090/api/v1/entities?type=Line&where=%5B%7B%22path%22%3A%22%2Flayer%22%2C%22op%22%3A%22eq%22%2C%22value%22%3A%22FRAME%22%7D%5D" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities?type=Line&where=%5B%7B%22path%22%3A%22%2Flayer%22%2C%22op%22%3A%22eq%22%2C%22value%22%3A%22FRAME%22%7D%5D" # Mark, wrap into a reusable block, plot every sheet, save as a template. -curl -s -X PUT http://127.0.0.1:8090/api/v1/entities/64/xdata/SPEC \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X PUT http://127.0.0.1:8090/api/v1/entities/64/xdata/SPEC \ -H "Content-Type: application/json" -d '[{"code":1000,"value":"PAGE-01"}]' -curl -s -X POST http://127.0.0.1:8090/api/v1/blocks \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/blocks \ -H "Content-Type: application/json" -d '{"name":"MARK","base":[0,0,0],"handles":["63","64"]}' -curl -s -X POST http://127.0.0.1:8090/api/v1/plot \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/plot \ -H "Content-Type: application/json" -d '{"path":"C:/out/pages.pdf","layout":"all","per_page":true}' -curl -s -X POST http://127.0.0.1:8090/api/v1/save \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \ -H "Content-Type: application/json" -d '{"path":"C:/out/session.dwt"}' ``` @@ -534,14 +544,14 @@ OpenCADStudio.exe "C:/drawings/plan.dwg" --http 8090 # the editor stays intera # Ask the person to pick block references; the connection parks until they # press Enter (or Esc). No polling loop needed. -curl -s -m 1800 -X POST http://127.0.0.1:8090/api/v1/user_select \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -m 1800 -X POST http://127.0.0.1:8090/api/v1/user_select \ -H "Content-Type: application/json" \ -d '{"request_id":"sample-1","type":"INSERT","detail":"full","prompt":"Pick the title blocks"}' # → {"ok":true,"status":"completed","result":{"cancelled":false,"count":2,…}} # Read the same entities back, then continue driving the live drawing. -curl -s -X POST http://127.0.0.1:8090/api/v1/get_selection -d '{}' -curl -s -X POST http://127.0.0.1:8090/api/v1/entities/transform \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/get_selection -d '{}' +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities/transform \ -H "Content-Type: application/json" \ -d '{"handles":["6F"],"action":"move","vector":[5,0]}' ``` diff --git a/docs/automation/README.md b/docs/automation/README.md index 5a50320c5..183a8973f 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -369,13 +369,22 @@ editor, events and plotting. resource-oriented REST surface on `http://127.0.0.1:/api/v1`. Any HTTP client works — curl, Python, C# `HttpClient`, JS `fetch`; there is no SDK and nothing AI-specific. The server keeps one drawing session alive -across requests, binds loopback only, sends permissive CORS headers, and -serves its machine-readable description at `GET /api/v1/openapi` -(OpenAPI 3, embedded from `src/rest_openapi.json`). +across requests, binds loopback only, and serves its machine-readable +description at `GET /api/v1/openapi` (OpenAPI 3, embedded from +`src/rest_openapi.json`). + +Every request carries `Authorization: Bearer `; anything else is +answered `401 {"code":"unauthorized"}`. Loopback is shared by every process +and user on the machine, so the port alone proves nothing. Set the token +yourself with `OCS_API_TOKEN` before launching, or read the random one the +server prints on stderr at startup. No CORS headers are sent, so a web page +cannot drive or read the session. The same token guards `--serve --port` +(as a `"token"` field in each JSON line) and the GUI-hosted channel below. ```sh +export OCS_API_TOKEN=$(openssl rand -hex 32) OpenCADStudio --http 8090 # start the REST server (headless) -curl http://127.0.0.1:8090/api/v1/ready +curl -H "Authorization: Bearer $OCS_API_TOKEN" http://127.0.0.1:8090/api/v1/ready ``` ### GUI-hosted REST channel (`--http` + file) @@ -503,7 +512,7 @@ HTTP. ```sh # 1. Create entities (the missing layer "FRAME" is created automatically; # the whole batch commits as one undoable step or not at all). -curl -s -X POST http://127.0.0.1:8090/api/v1/entities \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities \ -H "Content-Type: application/json" \ -d '{"entities":[ {"type":"Line","start":[0,0],"end":[100,0],"layer":"FRAME"}, @@ -514,71 +523,71 @@ curl -s -X POST http://127.0.0.1:8090/api/v1/entities \ # → 201 {"ok":true,"status":"completed","result":{"handles":["63","64","65","66"],…}} # 2. Verify: query it back. -curl -s "http://127.0.0.1:8090/api/v1/entities?type=Line&detail=full" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities?type=Line&detail=full" # → {"ok":true,"entities":[{"handle":"63","start":[0,0,0],"end":[100,0,0],…},…] # 3. Move the circle 10 units right. -curl -s -X POST http://127.0.0.1:8090/api/v1/entities/transform \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities/transform \ -H "Content-Type: application/json" \ -d '{"handles":["66"],"action":"move","vector":[10,0]}' # 4. Mark the text with extended data (RegApp "SPM" registered implicitly). -curl -s -X PUT http://127.0.0.1:8090/api/v1/entities/65/xdata/SPM \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X PUT http://127.0.0.1:8090/api/v1/entities/65/xdata/SPM \ -H "Content-Type: application/json" \ -d '[{"code":1000,"value":"PAGE-01"},{"code":1070,"value":3}]' -curl -s "http://127.0.0.1:8090/api/v1/entities/65/xdata?app=SPM" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities/65/xdata?app=SPM" # 5. Turn the two frame lines into a block definition + Insert. -curl -s -X POST http://127.0.0.1:8090/api/v1/blocks \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/blocks \ -H "Content-Type: application/json" \ -d '{"name":"FRAME-MARK","base":[0,0,0],"handles":["63","67"]}' # → 201 {"result":{"block":"FRAME-MARK","insert":"6B"}} # 6. Save and prove persistence. -curl -s -X POST http://127.0.0.1:8090/api/v1/save \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \ -H "Content-Type: application/json" -d '{"path":"C:/out/session.dwg"}' # 7. Filter entities by any property (RFC 6901 pointers, SQL-ish operators). -curl -s "http://127.0.0.1:8090/api/v1/entities?type=Circle&detail=geometry&where=%5B%7B%22path%22%3A%22%2Fradius%22%2C%22op%22%3A%22gt%22%2C%22value%22%3A2%7D%5D" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/entities?type=Circle&detail=geometry&where=%5B%7B%22path%22%3A%22%2Fradius%22%2C%22op%22%3A%22gt%22%2C%22value%22%3A2%7D%5D" # 8. Set and read back drawing sysvars. -curl -s -X POST http://127.0.0.1:8090/api/v1/sysvars \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/sysvars \ -H "Content-Type: application/json" -d '{"set":{"ltscale":2.5}}' -curl -s "http://127.0.0.1:8090/api/v1/sysvars?names=ltscale,mirrtext" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/sysvars?names=ltscale,mirrtext" # 9. Provision a sheet: create the layout, write its page setup, then # plot every layout to its own PDF (one file per entry in result.files). -curl -s -X POST http://127.0.0.1:8090/api/v1/layouts \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/layouts \ -H "Content-Type: application/json" -d '{"name":"PLAN"}' -curl -s -X PUT http://127.0.0.1:8090/api/v1/layouts/PLAN/page-setup \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X PUT http://127.0.0.1:8090/api/v1/layouts/PLAN/page-setup \ -H "Content-Type: application/json" \ -d '{"paper":"ISO_A4_(210.00_x_297.00_MM)","orientation":"landscape","fit":true,"center":true}' -curl -s -X POST http://127.0.0.1:8090/api/v1/plot \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/plot \ -H "Content-Type: application/json" \ -d '{"path":"C:/out/plan.pdf","layout":"all","per_page":true}' # 10. Save the drawing as a template (a .dwt is DWG bytes — same writer, # no lock held on the file) and start a fresh drawing from it. -curl -s -X POST http://127.0.0.1:8090/api/v1/save \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/save \ -H "Content-Type: application/json" -d '{"path":"C:/out/session.dwt"}' -curl -s -X POST http://127.0.0.1:8090/api/v1/documents \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/documents \ -H "Content-Type: application/json" -d '{"template":"C:/out/session.dwt"}' # 11. Second document, then copy entities across documents. -curl -s -X POST http://127.0.0.1:8090/api/v1/documents -d '{}' # a fresh document -curl -s -X POST http://127.0.0.1:8090/api/v1/entities/copy-to \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/documents -d '{}' # a fresh document +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/entities/copy-to \ -H "Content-Type: application/json" \ -d '{"handles":["63","67"],"document_id":2}' # 12. Group the copies, save them as a named selection set, recall it. -curl -s -X POST http://127.0.0.1:8090/api/v1/groups \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/groups \ -H "Content-Type: application/json" -d '{"name":"FRAME","handles":["63","67"]}' -curl -s -X POST http://127.0.0.1:8090/api/v1/selection-sets \ +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X POST http://127.0.0.1:8090/api/v1/selection-sets \ -H "Content-Type: application/json" -d '{"name":"frame-set","handles":["63","67"]}' -curl -s "http://127.0.0.1:8090/api/v1/selection-sets/frame-set?select=true" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" "http://127.0.0.1:8090/api/v1/selection-sets/frame-set?select=true" # 13. Close a document, discarding unsaved changes. -curl -s -X DELETE "http://127.0.0.1:8090/api/v1/documents/2?discard=true" +curl -s -H "Authorization: Bearer $OCS_API_TOKEN" -X DELETE "http://127.0.0.1:8090/api/v1/documents/2?discard=true" ``` The same lifecycle — create → verify → transform → xdata → block → diff --git a/docs/automation/rest_smoke.py b/docs/automation/rest_smoke.py index aa0cda0a2..37bdcbd14 100644 --- a/docs/automation/rest_smoke.py +++ b/docs/automation/rest_smoke.py @@ -14,6 +14,8 @@ sets, and a close-with-discard. Uses only the Python standard library. """ import json +import os +import secrets import subprocess import sys import tempfile @@ -24,10 +26,13 @@ import urllib.request from pathlib import Path +TOKEN = secrets.token_hex(32) + + def call(port: int, method: str, path: str, body=None): """One HTTP call; returns (status, parsed-json-or-None).""" data = None - headers = {"Content-Type": "application/json"} + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}"} if body is not None: data = json.dumps(body).encode() request = urllib.request.Request( @@ -67,6 +72,7 @@ def main() -> None: out_dir = Path(tempfile.mkdtemp(prefix="ocs_rest_smoke_")) process = subprocess.Popen( [str(exe), "--http", str(port)], + env={**os.environ, "OCS_API_TOKEN": TOKEN}, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) diff --git a/src/app/automation.rs b/src/app/automation.rs index 2a1fcee33..d4feb67e3 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -29,7 +29,8 @@ use super::OpenCADStudio; /// Run the headless JSON server. Default transport is stdin/stdout; with /// `--port ` it instead listens on `127.0.0.1:` and serves one client at -/// a time (the document session persists across reconnects). +/// a time (the document session persists across reconnects); every socket +/// request carries `"token"` (see `rest::api_token`). #[cfg(not(target_arch = "wasm32"))] pub fn serve() { let mut app = OpenCADStudio::new(); @@ -163,6 +164,7 @@ fn serve_socket_with_idle( let bound = listener.local_addr().map(|a| a.port()).unwrap_or(port); bound_port.store(bound, std::sync::atomic::Ordering::SeqCst); eprintln!("OpenCADStudio --serve listening on 127.0.0.1:{bound}"); + crate::rest::announce_token(); for stream in listener.incoming().flatten() { let _ = stream.set_read_timeout(Some(idle)); let Ok(read_half) = stream.try_clone() else { @@ -178,7 +180,18 @@ fn serve_socket_with_idle( if line.is_empty() { continue; } - let resp = app.automation_op(line); + // Loopback is shared by every process and user on the machine: + // each request carries the session token, like the GUI's own + // channel, and a connection that fails it is dropped. + let mut request: Value = serde_json::from_str(line).unwrap_or(Value::Null); + if !crate::rest::token_matches(request["token"].as_str()) { + let _ = writeln!(writer, "{}", crate::rest::unauthorized()); + break; + } + if let Some(object) = request.as_object_mut() { + object.remove("token"); + } + let resp = app.automation_op(&request.to_string()); if writeln!(writer, "{resp}").is_err() { break; } diff --git a/src/app/control/http_bridge.rs b/src/app/control/http_bridge.rs index b62856b33..b1f387423 100644 --- a/src/app/control/http_bridge.rs +++ b/src/app/control/http_bridge.rs @@ -8,8 +8,10 @@ //! through the same `rest::plan` table as the headless server; requests are //! forwarded into the GUI's automation queue (`Envelope`, exactly like the //! native bridge in `transport`) and the JSON response is written back over -//! HTTP. The channel binds loopback only — anything that can reach the port -//! can drive the session, exactly like the headless server. +//! HTTP. The channel binds loopback only and, like the headless server, +//! answers only requests carrying the session's bearer token +//! ([`rest::api_token`]): loopback is shared by every process and user on +//! the machine, so reaching the port proves nothing. use super::{Envelope, Reply}; use crate::rest::{self, HttpRequest, Plan, RETRYABLE}; @@ -58,6 +60,7 @@ fn listen( let listener = TcpListener::bind(("127.0.0.1", port))?; bound_port.store(listener.local_addr()?.port(), Ordering::SeqCst); eprintln!("OpenCADStudio GUI REST listening on http://127.0.0.1:{port}/api/v1"); + rest::announce_token(); // One thread per connection: a `getpoint` or `user_select` request stays // open until the person answers, and must not block the other reads // (get_selection…). @@ -79,8 +82,8 @@ fn listen( let _ = stream.set_read_timeout(Some(Duration::from_secs(15))); let _ = stream.set_write_timeout(Some(Duration::from_secs(15))); if let Ok(Some(request)) = rest::read_request(&mut stream) { - if request.method == "OPTIONS" { - let _ = rest::write_response(&mut stream, 204, &Value::Null); + if !request.authorized() { + let _ = rest::write_response(&mut stream, 401, &rest::unauthorized()); } else { let (status, body) = forward(&request, &sender, &document_cache, &stream); let _ = rest::write_response(&mut stream, status, &body); @@ -353,7 +356,8 @@ mod tests { fn post(port: u16, target: &str, body: &str) -> std::net::TcpStream { let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap(); let http = format!( - "POST {target} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: {}\r\n\r\n{}", + "POST {target} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {}\r\nContent-Length: {}\r\n\r\n{}", + rest::api_token(), body.len(), body ); @@ -447,7 +451,10 @@ mod tests { /// GET without a body — the read half of the REST surface. fn get(port: u16, target: &str) -> std::net::TcpStream { let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap(); - let http = format!("GET {target} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"); + let http = format!( + "GET {target} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {}\r\n\r\n", + rest::api_token() + ); stream.write_all(http.as_bytes()).unwrap(); stream } diff --git a/src/rest.rs b/src/rest.rs index d494ad796..e387831c5 100644 --- a/src/rest.rs +++ b/src/rest.rs @@ -9,7 +9,11 @@ //! the identical handlers with identical validation, undo and idempotency. //! //! Conventions: one request per connection (`Connection: close`), JSON -//! bodies, permissive CORS for local browser clients, loopback bind only. +//! bodies, loopback bind only, and every request carries +//! `Authorization: Bearer ` (see [`api_token`]). Loopback is shared by +//! every process and user on the machine, and a web page can aim requests at +//! it too, so the port alone proves nothing; no CORS headers are sent, so a +//! browser page cannot read an answer either. //! Connections are served one thread each: a slow op (plot, open a large //! drawing) or a client that connects and stalls must never keep other //! callers — `state`, `get_selection`, `cancel`, `operation` — from being @@ -102,11 +106,62 @@ pub fn serve(port: u16) { listen(OpenCADStudio::new(), port, Arc::new(AtomicU16::new(0))); } +/// The secret every TCP automation client presents: `OCS_API_TOKEN` when the +/// launcher set one (so a script knows it in advance), otherwise 32 random +/// bytes printed once on stderr when a listener starts. The GUI's private +/// channel keeps its own per-session token in the user-only descriptor. +pub fn api_token() -> &'static str { + static TOKEN: std::sync::OnceLock = std::sync::OnceLock::new(); + TOKEN.get_or_init(|| { + if let Some(token) = std::env::var("OCS_API_TOKEN") + .ok() + .filter(|token| !token.trim().is_empty()) + { + return token.trim().to_owned(); + } + let mut secret = [0u8; 32]; + getrandom::fill(&mut secret).expect("system random source"); + secret.iter().map(|byte| format!("{byte:02x}")).collect() + }) +} + +/// Tells a listener's user how to authenticate. A token the launcher chose +/// is not echoed back. +pub(crate) fn announce_token() { + if std::env::var("OCS_API_TOKEN").map_or(true, |token| token.trim().is_empty()) { + eprintln!("API token (or set OCS_API_TOKEN before launch): {}", api_token()); + } +} + +/// Whether `presented` is the session's token, compared in constant time. +pub(crate) fn token_matches(presented: Option<&str>) -> bool { + let expected = api_token().as_bytes(); + let Some(presented) = presented.map(str::as_bytes) else { + return false; + }; + presented.len() == expected.len() + && presented + .iter() + .zip(expected) + .fold(0u8, |difference, (a, b)| difference | (a ^ b)) + == 0 +} + +/// The answer to a request without the session's token. +pub(crate) fn unauthorized() -> Value { + json!({ + "ok": false, + "code": "unauthorized", + "error": "Send the session token (printed at startup, or OCS_API_TOKEN): Authorization: Bearer over HTTP, \"token\" in each --serve --port request", + }) +} + pub(crate) struct HttpRequest { pub(crate) method: String, pub(crate) path: String, query: Vec<(String, String)>, body: Vec, + bearer: Option, } impl HttpRequest { @@ -120,6 +175,10 @@ impl HttpRequest { pub(crate) fn json(&self) -> Value { serde_json::from_slice(&self.body).unwrap_or(Value::Null) } + + pub(crate) fn authorized(&self) -> bool { + token_matches(self.bearer.as_deref()) + } } /// One request handed from a connection thread to the dispatcher that owns @@ -144,6 +203,7 @@ fn listen(mut app: OpenCADStudio, port: u16, bound_port: Arc) { let bound = listener.local_addr().map(|a| a.port()).unwrap_or(port); bound_port.store(bound, Ordering::SeqCst); eprintln!("OpenCADStudio REST listening on http://127.0.0.1:{bound}/api/v1"); + announce_token(); let (jobs, incoming) = std::sync::mpsc::channel::(); // Connections are served one thread each: a slow op (plot, open a large // drawing) or a client that connects and stalls must never keep other @@ -185,8 +245,8 @@ fn serve_connection(mut stream: TcpStream, jobs: std::sync::mpsc::Sender) { let Ok(Some(request)) = read_request(&mut stream) else { return; }; - if request.method == "OPTIONS" { - let _ = write_response(&mut stream, 204, &Value::Null); + if !request.authorized() { + let _ = write_response(&mut stream, 401, &unauthorized()); return; } let (reply, answer) = std::sync::mpsc::channel(); @@ -243,6 +303,7 @@ pub(crate) fn read_request(stream: &mut TcpStream) -> std::io::Result std::io::Result MAX_BODY { return Ok(Some(HttpRequest { @@ -272,6 +341,7 @@ pub(crate) fn read_request(stream: &mut TcpStream) -> std::io::Result std::io::Result "Created", 204 => "No Content", 400 => "Bad Request", + 401 => "Unauthorized", 403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", @@ -337,7 +409,7 @@ pub(crate) fn write_response(stream: &mut TcpStream, status: u16, body: &Value) _ => "OK", }; let header = format!( - "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\nConnection: close\r\n\r\n", + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); stream.write_all(header.as_bytes())?; @@ -733,6 +805,7 @@ mod tests { path: path.to_string(), query: Vec::new(), body: body.as_bytes().to_vec(), + bearer: None, } } @@ -763,19 +836,30 @@ mod tests { } assert_ne!(port, 0, "listener never bound a port"); + let status = |authorization: &str| { + let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .write_all( + format!("GET /api/v1/ready HTTP/1.1\r\nHost: 127.0.0.1\r\n{authorization}\r\n") + .as_bytes(), + ) + .unwrap(); + let mut status_line = String::new(); + BufReader::new(stream) + .read_line(&mut status_line) + .expect("readiness answered"); + status_line + }; // Stalls the thread that accepted it: no request line, ever. let _stalled = TcpStream::connect(("127.0.0.1", port)).unwrap(); - let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - stream - .write_all(b"GET /api/v1/ready HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") - .unwrap(); - let mut reader = BufReader::new(stream); - let mut status_line = String::new(); - reader.read_line(&mut status_line).expect("readiness answered"); - assert!(status_line.contains("200"), "{status_line}"); + let answered = status(&format!("Authorization: Bearer {}\r\n", api_token())); + assert!(answered.contains("200"), "{answered}"); + // Anything else on the machine that finds the port is turned away. + assert!(status("").contains("401")); + assert!(status("Authorization: Bearer guess\r\n").contains("401")); } #[test] diff --git a/src/rest_openapi.json b/src/rest_openapi.json index d3fae4eb1..8325140b0 100644 --- a/src/rest_openapi.json +++ b/src/rest_openapi.json @@ -6,6 +6,7 @@ "description": "REST surface over the OpenCAD Studio automation dispatcher. The server runs headless via `OpenCADStudio --http `, binds 127.0.0.1 only, and keeps one drawing session alive across requests. All bodies are JSON; failure bodies carry a symbolic `code` (see the automation README for the catalogue)." }, "servers": [{"url": "http://127.0.0.1:8090"}], + "security": [{"bearer": []}], "paths": { "/api/v1/ready": { "get": { @@ -255,5 +256,10 @@ "responses": {"200": {"description": "Operation response"}} } } + }, + "components": { + "securitySchemes": { + "bearer": {"type": "http", "scheme": "bearer", "description": "OCS_API_TOKEN, or the token printed on stderr at startup."} + } } }