The only way to give a gateway a printer was to edit printers.toml and
restart it. A printer added from the console now arrives as a
provision_printer message, is checked by actually connecting to it (so a
mistyped access code is reported to the operator, not discovered later),
and only then is saved to the gateway's own SQLite file and joined to the
running fleet - no restart. remove_printer undoes it. Both are answered
with a command_result carrying the request id.
The access code exists only on the gateway: the cloud relays it once and
keeps nothing, which is the platform's rule for printer credentials. The
store file is created owner-only; the message type's Debug and the fleet
entry's Debug redact the credential; and an unparseable message from the
cloud is no longer logged with its payload, because the message that
fails to parse is exactly the one carrying an access code.
The cloud may not choose a certificate path or serial port on the
gateway's disk, so provision_printer cannot carry them.
Startup now reads printers.toml (strict, as before; a missing file is fine)
and the store (a bad stored entry is skipped, not fatal), so the gateway
comes up with its fleet with the API unreachable. Discovery treats stored
printers as already configured. rusqlite (MIT, bundled SQLite) passes
cargo deny.
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.
The outbound queue had no coverage, and it is exactly the kind of code
that looks right and does nothing: the failure path only runs minutes
after the command was answered, so nothing in the ordinary flow would
notice it was missing.
Stands up a real websocket server and runs the real `uplink::run`
against it, asserting two results for one command_id in order - ok:true
on acceptance, then ok:false carrying a reason that names what went
wrong. That the id repeats is the contract, not a bug to deduplicate.
It touches no printer and makes no outbound request: the fleet is empty
and `dispatch` checks the fleet before downloading, so the command fails
on a lookup. The unreachable URL is on port 1 so a regression that did
reach the network fails fast rather than hanging on a real host.
Verified to fail without the feature, not just to pass with it: stubbing
out the send makes it time out waiting for the second result. That check
is worth doing by hand here - the go2rtc RTSP test asserted the absence
of a config key and passed the whole time the server was open.