The gateway could already find printers; only a human reading a terminal could see the result. So adding a printer in the web console still began with walking to a machine to read its IP address off the screen. Now a background task scans on a timer and sends a `discovered_printers` frame. It cannot finish the job - the credential is on that screen and no probe can read it - but it removes the address, the serial and the guesswork about which vendor the thing is. OFF by default, which is the load-bearing part of this commit. A /24 sweep is 254 TLS handshakes and up to three HTTP requests per host, and nothing watching can tell that from a port scan; on a corporate network, being indistinguishable from a port scan is itself the problem. A gateway that did this by default would scan networks its operator never meant to point it at, and the first they would hear of it is from their own security team. So: - `discovery.enabled` is false unless set, per gateway, in that gateway's own config. The cloud cannot switch it on. A setting whose whole purpose is to bound what this process does to the local network has to be decided where that network is understood, which is not in a data centre. - `subnets` is explicit, never derived from this host's interfaces, so a second NIC, a VPN or a container bridge cannot widen the sweep. Anything wider than a /24 is still refused, now at config-import time rather than when the timer first fires. - A flag that is neither `true` nor `false` is an error, not a shrug. The two readings of a typo are not equally safe. - Naming a subnet without setting `enabled` scans nothing - the safe reading of an incomplete instruction - and `enabled` with nowhere to look is refused as the mistake it is. - The env var can switch it *off* over a file that enables it, which is the direction that has to work: a Proxy Box's config is signed in the cloud, so an operator who finds their gateway probing cannot just edit it. What a scheduled scan will not do, unlike the example CLI: send `M115`. Opening a serial port toggles DTR, which reboots an Arduino-based board and ruins a print in progress. The example can do it behind `--yes-reset-the-printer` because a human is standing there; a timer has nobody to ask, so it does not get the option and reads sysfs instead. Two things that took more care than expected: `Scan::looked()`. An empty report means "this gateway looked and its network has no unconfigured printers", so sending one after every probe failed would state something untrue about the farm. A partial failure is still evidence, though - withholding a good sweep because a container can never bind SSDP would suppress it forever - so the test is "did anything succeed", not "did everything". `fleet::configured()` reads the fleet file at parse level rather than through `load`, because the entry it most needs to recognise is the one `load` refuses: a printer discovery suggested, pasted in, with the access code not yet typed in. Re-offering that printer as a fresh find is noise at the one moment the operator is mid-way through adding it. Verified against the real LAN: a sweep found both printers by TLS certificate with correct serials and no credentials, and the frame it produced validates against the backend's EdgeClientMessageSchema. The `wire` subcommand on the example prints exactly that frame, and the capture is pinned as a backend fixture. SSDP remains untestable here - this container is a router hop from the printers and multicast does not cross one.
134 lines
5.4 KiB
Rust
134 lines
5.4 KiB
Rust
//! Does a command that outlives the answer already given for it report back?
|
|
//!
|
|
//! A `start_print` is acknowledged the moment it is accepted, because the
|
|
//! transfer takes minutes and the cloud gives up after ten seconds. So its
|
|
//! real outcome can only ever arrive late, and before the outbound queue
|
|
//! existed there was nowhere for it to arrive from: the uplink's `select!`
|
|
//! loop was the only writer and a spawned task could not reach the socket.
|
|
//! A failed start therefore reported success and the reason stayed in this
|
|
//! gateway's log, on a machine in someone else's workshop.
|
|
//!
|
|
//! This stands up a real websocket server and runs the real `uplink::run`
|
|
//! against it. **It touches no printer and makes no outbound request**: the
|
|
//! fleet is empty, and `dispatch` checks the fleet for the printer id
|
|
//! *before* downloading anything, so the command fails on a lookup.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use serde_json::{json, Value};
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::Mutex;
|
|
use tokio_tungstenite::WebSocketStream;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
|
|
use continuum_proxy::config::Config;
|
|
use continuum_proxy::uplink;
|
|
|
|
/// Long enough for a loopback round trip, short enough that a regression
|
|
/// fails the test instead of hanging the suite.
|
|
const WAIT: Duration = Duration::from_secs(10);
|
|
|
|
fn config(port: u16) -> Config {
|
|
Config {
|
|
gateway_id: "test-gw".into(),
|
|
farm_id: "test-farm".into(),
|
|
gateway_token: "test-token".into(),
|
|
uplink_url: format!("ws://127.0.0.1:{port}/ws/edge/v1"),
|
|
// Nothing here starts go2rtc or reads the fleet file - `uplink::run`
|
|
// is handed its fleet rather than loading one - but Config has no
|
|
// Default, so these have to say something.
|
|
go2rtc_bin: "/nonexistent-go2rtc".into(),
|
|
go2rtc_config: "/nonexistent/go2rtc.yaml".into(),
|
|
go2rtc_api_listen: "127.0.0.1:1984".into(),
|
|
stream_base_url: None,
|
|
printers_path: "/nonexistent/printers.toml".into(),
|
|
// Off, which is the default and also the only safe thing in a test:
|
|
// an enabled one would sweep whatever network the test host is on.
|
|
discovery: Default::default(),
|
|
}
|
|
}
|
|
|
|
/// The next frame as JSON. Heartbeats and telemetry share this socket, so
|
|
/// callers filter; `interval` fires on its first tick, which means a
|
|
/// heartbeat arrives immediately after the hello.
|
|
async fn next_json<S>(ws: &mut WebSocketStream<S>) -> Value
|
|
where
|
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
|
{
|
|
loop {
|
|
let frame = tokio::time::timeout(WAIT, ws.next())
|
|
.await
|
|
.expect("timed out waiting for a frame from the gateway")
|
|
.expect("the gateway closed the connection")
|
|
.expect("websocket error");
|
|
if let Message::Text(text) = frame {
|
|
return serde_json::from_str(&text).expect("the gateway sent invalid JSON");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_long_command_that_fails_reports_its_reason_after_the_fact() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
|
|
let fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new()));
|
|
let (outbound, inbox) = uplink::channel();
|
|
tokio::spawn(uplink::run(config(port), fleet, outbound, inbox));
|
|
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
|
|
|
|
let hello = next_json(&mut ws).await;
|
|
assert_eq!(hello["type"], "hello", "a gateway introduces itself first");
|
|
|
|
ws.send(Message::Text(
|
|
json!({
|
|
"type": "command",
|
|
"command_id": "cmd-1",
|
|
"printer_id": "not-in-this-fleet",
|
|
"command": {
|
|
"action": "start_print",
|
|
"job_id": "7c9e6679-0000-4000-8000-000000000000",
|
|
"file_name": "cube.gcode.3mf",
|
|
// Never fetched: the fleet lookup fails first. Port 1 so a
|
|
// regression that *did* reach the network fails fast and
|
|
// loudly rather than hanging on a real host.
|
|
"url": "http://127.0.0.1:1/never-fetched",
|
|
}
|
|
})
|
|
.to_string(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut results = Vec::new();
|
|
while results.len() < 2 {
|
|
let msg = next_json(&mut ws).await;
|
|
if msg["type"] == "command_result" {
|
|
results.push(msg);
|
|
}
|
|
}
|
|
|
|
// Two results for one command, in this order, and that is the contract -
|
|
// not a bug to be deduplicated away. The first is "accepted, working on
|
|
// it", which is all the cloud ever trusted a start reply to mean; the
|
|
// second is what actually happened.
|
|
assert_eq!(results[0]["command_id"], "cmd-1");
|
|
assert_eq!(results[0]["ok"], true, "a start is acknowledged on acceptance");
|
|
assert!(results[0]["error"].is_null());
|
|
|
|
assert_eq!(results[1]["command_id"], "cmd-1", "the late result reuses the id");
|
|
assert_eq!(results[1]["ok"], false);
|
|
|
|
// The reason is the entire point of the queue: without it the cloud sees
|
|
// a job that never started printing and can say nothing about why.
|
|
let reason = results[1]["error"].as_str().expect("a failure carries its reason");
|
|
assert!(
|
|
reason.contains("not-in-this-fleet"),
|
|
"the reason should name the printer that could not be found, got: {reason}"
|
|
);
|
|
}
|