Compare commits

..
10 Commits
Author SHA1 Message Date
octoturgeandClaude Sonnet 5.5 2bbe8af9a1 Build discover_printers for Windows on the Gitea runner
Windows discovery tool / windows (push) Failing after 7m1s
SSDP can only be tried on a machine that shares the printers' network
segment, and the development container is a router hop away. This builds
the existing discover_printers example as a Windows .exe and uploads it as
a CI artifact, with a README for whoever runs it and a SHA-256 file.

Uses the windows-latest self-hosted runner continuum-cad already uses
(Windows PowerShell 5.1, MSVC present, upload-artifact@v3 only). The proxy
links OpenSSL directly and Windows has none, so the job builds it with
vcpkg (x64-windows-static-md, linked into the .exe) in a directory of its
own, away from the CAD pipeline's. The job smoke-runs the exe, then runs
the discovery tests on Windows after the upload, so a Windows-only test
failure turns the job red without withholding the executable.

Not run anywhere yet: this container cannot build for Windows (no MSVC or
mingw). The first CI run is the first real build.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:54:07 +00:00
octoturgeandClaude Sonnet 5.5 b9d36b9b87 Join the SSDP group on every IPv4 interface, not the OS's pick
bind_ssdp passed UNSPECIFIED as the interface, which leaves the choice to
the OS. On a machine with several adapters - a Windows PC with Wi-Fi plus a
VPN, Hyper-V or WSL adapter is the ordinary case - it joins on one and then
hears nothing from printers on the others, while looking healthy. That is
the worst way for a tool whose first real run is the test to fail.

It now joins on each non-loopback IPv4 interface (if-addrs, MIT OR
BSD-3-Clause, one libc dependency), skips an adapter that refuses, fails
only if none joined, and logs which were joined. Falls back to the OS's
choice if the interfaces cannot be listed.

Checked on Linux with a single interface only; the multi-adapter case is
the reason for the change and is not exercised here.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:52:50 +00:00
octoturgeandClaude Sonnet 5.5 c6e364e9df Ignore the pid file the go2rtc supervisor writes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:48:57 +00:00
octoturgeandClaude Sonnet 5.5 987a7b4708 Stop a go2rtc that outlived a hard-killed proxy
SIGTERM already stops go2rtc (the signal handler), but SIGKILL and the OOM
killer give the proxy no chance to, and the orphan keeps :1984/:8555 bound
and a printer's access code in memory.

Two layers, neither needing unsafe or a new dependency:

- The watchdog records go2rtc's pid next to its config and, on startup,
  stops a leftover. It acts only if /proc confirms the pid is still *our*
  go2rtc - the configured binary on our own `-config` path - so a reused pid
  or another proxy's go2rtc is never touched; without /proc it does nothing.
  The pid file is removed when the supervisor goes away.
- deploy/continuum-proxy.service, whose KillMode=control-group (written out,
  with the reason) makes systemd kill the whole cgroup when the proxy dies.
  Syntax-checked with systemd-analyze only; not run under a real systemd.

Tests: the identity check, and a real stand-in process - a leftover is
terminated while an unrelated `sleep` survives. Making the identity check
always say yes fails both, so they do guard the behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:47:26 +00:00
octoturgeandClaude Sonnet 5.5 88bf1d1c2f Pin a real H2C's SSDP reply as a test fixture
The SSDP parser's header spelling had never been checked against a real
printer, because multicast does not reach this container. It turns out a
printer answers a *unicast* M-SEARCH sent straight at it, on both 1990 and
2021, so the reply can be captured across the router.

The H2C's 200 OK uses exactly the spelling the parser guessed
(DevModel/DevName/DevBind.bambu.com, USN as the serial, a bare-IP
Location), so no parsing change was needed - this adds the real shape as a
regression test (serial, name and address made up) and corrects the doc
comment that said the spelling was unchecked.

The P1P did not answer a unicast M-SEARCH on any of 1990/2021/1900. An
unsolicited NOTIFY over multicast is still unseen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:30:09 +00:00
octoturgeandClaude Sonnet 5.5 e90c6f54e8 Stop orphaning go2rtc, serve cameras sooner, keep its config private
Three defects found by running a real WebRTC viewer through the whole
stack for the first time (continuum-backend/scripts/e2e-camera.sh).

1. go2rtc was orphaned on every ordinary stop. `kill_on_drop(true)` was
   set, but main handled no signals, so SIGTERM - what `kill`, `docker
   stop` and a service manager send - ended the process on the spot with
   no destructor run and the child was never dropped. The orphan keeps
   :1984 and :8555 bound, so the restarted proxy cannot start its own,
   and it keeps a printer's access code in memory. One had been running
   for 4.5 days. main now selects on SIGTERM/SIGINT so it returns
   normally and the supervisor is dropped. A SIGKILL or OOM kill still
   orphans it; PR_SET_PDEATHSIG would cover that but needs unsafe and a
   libc dependency, so it is left for a decision.

2. A camera was advertised before go2rtc could serve it. The comment on
   REBUILD_INTERVAL said the first useful rebuild is "a few seconds
   after startup", but a tokio interval ticks immediately - with the
   fleet still empty - and the next tick was 30s away. For that window
   the cloud offered a camera and go2rtc answered "stream not found".
   Polling every 5s costs nothing: it reads in-memory reports and only
   restarts go2rtc when the rendered config changes.

3. The generated go2rtc.yaml was created 0644 by tokio::fs::write. It
   holds the printer's access code and go2rtc's API password, so any
   local user could read both. It is now created owner-only and fresh,
   because a temp file left by a crash would otherwise keep its old
   mode. The new test pre-creates a stale 0644 temp file; against the
   old implementation it fails with mode 420 (0o644) where 384 (0o600)
   is expected.

cargo test: 193 lib tests and all integration tests pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 16:01:34 +00:00
octoturgeandClaude Opus 5 1967aae42c Report discovered printers to the cloud, off by default
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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 08:21:30 +00:00
octoturgeandClaude Opus 5 062686b1a7 Authenticate config bundles: Ed25519, per-box identity, no plaintext
The hash in a bundle only proved it was undamaged. Anyone who could write
the stick could write both files, so a Proxy Box would install a config
handed to it by whoever got physical access - including a config pointing the
box at a different cloud.

Encryption alone does not fix that either. While plaintext was accepted an
attacker simply did not encrypt, and the box took it. So a Box now refuses
plaintext outright, and the bundle carries a signature.

A third entry, `config.toml.sig`: strict TOML with `box_id`, `config_version`
and a raw Ed25519 signature. What is signed is `signed_message()` - a domain
tag, the length-prefixed box id, the version big-endian, then the raw
`config.toml` bytes. Raw bytes rather than the archive, so the signature is
independent of ZIP framing, compression, entry order and encryption. The box
id and version are inside the signed message rather than beside it, so
neither can be edited without breaking it, and the length prefix stops a
byte-shifting collision between the id and the config.

Past decryption the order is hash, signature, box id, version, then parse and
validate. The claims in the `.sig` are attacker-controlled text until the
signature over them verifies, so nothing is concluded from them before then -
and nothing about this box's identity is revealed before then either, which
is why unsigned junk cannot be used to probe which box it is talking to.

Which error wins when a box cannot even open a bundle: a signature over the
plaintext is only checkable after decryption, so a box with the wrong key
cannot distinguish "meant for another box" from "forged". Wrong key wins.
Neither bundle installs either way, it is nearly always the true cause, and
calling something forged that we could not open would be an accusation we
cannot support.

Replay is enforced rather than documented away: `config_version` must not go
backwards, recorded in a sidecar beside the installed config. An equal
version is accepted on purpose, because the version is written before the
config, so a power cut between the two leaves "new version, old config" and
re-inserting the same stick repairs it. The other write order would leave a
replay window instead.

Both key sets are lists, so the cloud's signing key and a box's own key can
be rotated. `load_provisioning()` is the single seam where a box's identity
and keys come from; a TPM or secure element replaces that function and
nothing else. A missing file means unprovisioned, and the box then says
loudly what it cannot check - but an empty or malformed one is an error,
because quietly reading a broken key file as absent would turn a
provisioning mistake into a box that accepts unsigned bundles.

Not verified: interop with the real cloud signer. These tests prove the box
accepts what this code defines as correct, not that the cloud will produce
the same bytes - the message layout, the pure-Ed25519-no-prehash choice and
the `.sig` shape all need a cross-check against the configurator's own crypto
library once it exists. A compromised cloud signing key still breaks every
box trusting it; revocation is not built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 08:08:27 +00:00
octoturgeandClaude Opus 5 1cd05d9120 Accept an AES-encrypted config bundle from the cloud
The bundle is authored by Continuum Cloud's configurator and downloaded by
the customer, so it travels over the internet and then on a USB stick while
holding the gateway token and possibly a wifi PSK. That makes it a secret at
rest, and confidentiality is the benefit encryption actually delivers here.

WinZip AES-256 ("AE-x"), via the zip crate's `aes-crypto` feature. That
pulls in aes, hmac, pbkdf2, sha1, constant_time_eq and zeroize - all
permissive (MIT/Apache-2.0, and CC0-1.0 OR MIT-0 OR Apache-2.0 for
constant_time_eq), each checked against docs/LICENSES.md, and
`cargo deny check licenses` passes.

**Legacy ZipCrypto is refused outright**, whatever key is configured: it
falls to a known-plaintext attack. The test is on metadata, not on a failed
decrypt - `encrypted()` is set for ZipCrypto and AES alike, but only AES
carries the AE-x extra field, so encrypted-without-it is ZipCrypto and the
whole bundle is rejected. Judged in the first pass, before any decrypt call,
so the crate's ZipCrypto path never runs on our input. Mixed bundles - one
entry encrypted, the other not - are rejected too.

The caps now bound *decrypted* output, so a small ciphertext that inflates
is capped exactly as a plaintext bomb is. Everything else holds unchanged
for an encrypted bundle: zip-slip, the inner config.toml.sha256, the
no-environment validation, atomic install, and a rejected bundle never
touching the installed config.

Failures are distinguished, because "wrong key" and "damaged" need different
answers: no key on the box and a wrong key both say "not for this box", while
tampered or damaged ciphertext says it failed its AES integrity check. A
wrong key passes AES's 16-bit password check 1 time in 65536 and then fails
the HMAC, so that message names a wrong key as a possibility.

**The limitation to be clear about: with plaintext bundles still accepted,
encryption does nothing against tampering.** An attacker supplies a
plaintext bundle and never touches the crypto. The tamper goal only begins to
hold once `accept_unencrypted` is false, which is one line in main.rs and is
deliberately not flipped yet - doing so breaks every stick made before the
configurator encrypts, and every hand-made development stick. That is a
product decision, not a code one.

More generally, encryption is not authenticity: the AES HMAC detects
modification only by someone without the password, and the password has to
live on the box, so extracting it from one image breaks every box sharing it.
It raises the bar from "anyone with a text editor" to "anyone who can read a
firmware image". The module docs say exactly that and do not call it
tamper-proof. `check_authenticity` is a named no-op where an Ed25519 check
over the config, verified against a *public* key in the box image, belongs -
it verifies nothing and says so, so the missing authenticity is a searchable
gap rather than an implicit one.

`load_bundle_key` is the single marked seam where provisioning lands. Its
current `$CONTINUUM_BUNDLE_KEY_FILE` / `/etc/continuum/bundle.key` lookup is
a placeholder, not a decision: shared-vs-per-box keys, where the key lives,
and rotation are open product questions and the mechanism depends on none of
them.

Encrypted fixtures are genuine AES ZIPs written by the zip crate itself, and
one test confirms the config text is absent from the ciphertext. The
ZipCrypto fixture sets the encrypted flag without an AE-x field, which is the
metadata shape this must refuse. Unproven: interop with whatever ZIP writer
the cloud actually uses (only archives written by this same crate version
were read back), AES-128/192, and `load_bundle_key` itself, which reads
process env and so has no test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 07:56:25 +00:00
octoturgeandClaude Opus 5 47c48ee02d Load proxy settings from a file, and import them from a pendrive
Everything came from CONTINUUM_* environment variables, which suits neither
shape the product ships in. A Proxy Box is an appliance whose settings
arrive on a USB stick; a proxy on someone's PC is a config file they edit.

Precedence is stated once, in config.rs: the file is the base, env overrides
it, defaults fill the rest. `Config::from_env` keeps its behaviour and the
struct is unchanged, so existing callers and tests are untouched. Layering
is `Config::layered(file, env_lookup)` with the environment injected as a
closure, so tests do not have to mutate process env to cover it.

The bootstrap is read from $CONTINUUM_BOOTSTRAP, then
/etc/continuum/bootstrap.toml, then nowhere - and nowhere means PC mode with
./continuum.toml, so an env-only deployment behaves exactly as before. The
working directory is deliberately not searched, and the bootstrap itself
must never come from a stick: it is what says whether sticks are trusted at
all.

Bundle format is continuum-config.zip holding config.toml and
config.toml.sha256. The hash is checked before the bytes are parsed - the
TOML parser should not be the first thing to read possibly-corrupt input -
and **the comments say plainly what that buys: corruption and change, not
forgery.** Anyone who can write the stick writes both files. It is an
integrity check under a recorded trust assumption, that physical access to
the box is trusted; a signature with the key in the box image is named as
the follow-up that would give authenticity. sha256 via openssl, already a
direct dependency, so no new crate and `cargo deny check licenses` still
passes.

The hazards of ingesting a file off removable media, all tested:

Zip-slip is refused on absolute paths, `..` components, backslash
separators, drive prefixes, NUL and non-UTF-8 names, and one bad entry
rejects the whole bundle rather than being skipped. The stronger guarantee
is structural though: entries are only ever read **by index, matched against
two literal names**, and no write path is ever derived from an archive name,
so a hole in the name check still writes nothing. A test confirms a bundle
carrying `../evil.toml` alongside a valid config writes nothing outside the
target and leaves the installed config alone.

Zip bombs are capped at 16 entries, 256 KiB per entry and a 1 MiB file on
the stick. Declared sizes are refused in a first pass that decompresses
nothing, and a bounded reader then caps actual output so a lying header
gains nothing.

Installing is atomic: temp file in the destination directory - not /tmp,
since rename is only atomic within a filesystem - created 0600 from the
first byte because the config holds the gateway token, fsync, rename, then a
best-effort directory fsync. A write that fails halfway leaves the previous
config intact and no temp file behind.

A bad stick never costs the box its config: a bad hash, bad TOML, an unsafe
entry or missing settings all return Rejected and are logged as keeping the
existing config. A correctly-hashed config is also validated **with no
environment at all**, so a stick cannot install something that parses now
and fails to start on the next boot - which would be a bricked box caused by
exactly the stick we were being careful about.

PC mode ignores pendrives twice over, by mode and by an emptied directory
list, because on a general-purpose machine importing settings from a USB
stick would be a way to change them behind the user's back.

A [network] block is parsed, validated and exposed, and stops there. main.rs
logs a warning that this build does not apply it, so a box given network
settings cannot look like it acted on them. Applying them needs root, is
specific to the appliance image, and a wrong write strands the box - it wants
its own decision, not a side effect of reading a file.

continuum.toml is gitignored, since it holds the gateway token.

Not done, and not claimed: no signature, so no authenticity. There is no real
pendrive here, so the removable-media path was tested with ordinary
directories standing in for mount points - real udisks/udev mounts, FAT
behaviour, a genuinely yanked stick and how promptly the 5s poll notices a
mount are all unproven. The watcher's install-then-exit flow, which relies
on a supervisor restarting the process, has no test and no supervisor exists
in this environment; a stick left permanently inserted reports Unchanged
rather than looping, because the hash matches. Env still overrides on a box,
so a box is only pendrive-only to the extent its environment is trusted -
bootstrap.rs says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 07:49:46 +00:00
24 changed files with 4040 additions and 53 deletions
+145
View File
@@ -0,0 +1,145 @@
# Builds `discover_printers` as a Windows executable, for trying SSDP
# discovery on a machine that shares the printers' network segment (multicast
# does not cross a router, so the development container cannot hear them).
#
# Runs on the `windows-latest` self-hosted runner that continuum-cad's
# pipeline also uses: a host executor running as SYSTEM, Windows PowerShell
# 5.1 (no pwsh), MSVC build tools present. The notes in
# continuum-cad/.gitea/workflows/ci.yml on that machine apply here too.
# Its artifact UPLOAD works (`upload-artifact@v3`); DOWNLOAD does not, which
# is why nothing here downloads one.
#
# A job whose label matches no registered runner is skipped silently, not
# failed - if this never runs, check that the runner is online.
#
# Cargo.lock is not committed in this repo, so this resolves dependencies
# fresh; the artifact is built from whatever versions that picks today.
name: Windows discovery tool
on:
push:
paths:
- "src/**"
- "examples/discover_printers.rs"
- "Cargo.toml"
- ".gitea/workflows/discover-windows.yml"
workflow_dispatch:
env:
CARGO_TERM_COLOR: never
# Outside the runner's deep work directory, and short, for the same MAX_PATH
# reason continuum-cad gives. A directory of its own - not C:\vcpkg or
# C:\ccad-target - so the two pipelines cannot step on each other's state
# on the one machine.
CARGO_TARGET_DIR: 'C:\continuum-proxy-target'
VCPKG_ROOT: 'C:\continuum-vcpkg'
# The same release tag continuum-cad pins, known to exist.
VCPKG_REF: "2026.07.29"
jobs:
windows:
runs-on: windows-latest
defaults:
run:
shell: powershell
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Install the Rust MSVC toolchain
shell: powershell
run: |
# Genuine no-BOM UTF-8: Windows PowerShell 5.1's `-Encoding utf8`
# writes a BOM, which corrupts the first entry of GITHUB_PATH.
$NoBom = New-Object System.Text.UTF8Encoding $false
function Add-CiPath([string] $Value) {
[System.IO.File]::AppendAllText($env:GITHUB_PATH, "$Value`r`n", $NoBom)
}
if (-not (Get-Command rustup -ErrorAction SilentlyContinue)) {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -UseBasicParsing -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable-x86_64-pc-windows-msvc
if ($LASTEXITCODE -ne 0) { throw "rustup-init failed" }
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
if (-not (Test-Path (Join-Path $cargoBin "rustup.exe"))) {
throw "rustup-init reported success but rustup.exe is not in $cargoBin"
}
# GITHUB_PATH only takes effect for later steps; the in-process
# PATH is what the calls just below need.
$env:PATH = "$cargoBin;$env:PATH"
Add-CiPath $cargoBin
}
rustup default stable-x86_64-pc-windows-msvc
if ($LASTEXITCODE -ne 0) { throw "could not select the MSVC toolchain" }
rustc -vV
# The proxy links OpenSSL directly (Ed25519 verification, SHA-256, and
# reading a printer's certificate), and Windows has none. vcpkg builds
# it, fetching its own Perl and NASM. `x64-windows-static-md` links it
# into the .exe, so the tool needs no DLL beside it; openssl-sys finds
# it through VCPKG_ROOT with no further settings.
- name: Build OpenSSL with vcpkg
shell: powershell
run: |
git config --global core.longpaths true
if (-not (Test-Path "$env:VCPKG_ROOT\.git")) {
git clone --depth 1 --branch $env:VCPKG_REF https://github.com/microsoft/vcpkg $env:VCPKG_ROOT
if ($LASTEXITCODE -ne 0) { throw "could not clone vcpkg at $env:VCPKG_REF" }
}
Push-Location $env:VCPKG_ROOT
try {
if (-not (Test-Path .\vcpkg.exe)) {
.\bootstrap-vcpkg.bat -disableMetrics
if ($LASTEXITCODE -ne 0) { throw "bootstrap-vcpkg failed" }
}
.\vcpkg.exe install openssl:x64-windows-static-md
if ($LASTEXITCODE -ne 0) { throw "vcpkg failed to build OpenSSL" }
} finally {
Pop-Location
}
- name: Build discover_printers
shell: powershell
run: |
$env:VCPKGRS_TRIPLET = "x64-windows-static-md"
cargo build --release --example discover_printers
if ($LASTEXITCODE -ne 0) { throw "cargo build failed" }
New-Item -ItemType Directory -Force -Path dist | Out-Null
Copy-Item "$env:CARGO_TARGET_DIR\release\examples\discover_printers.exe" dist\
Copy-Item scripts\discover-windows-README.txt dist\README.txt
# It must run on a machine with nothing installed. No arguments
# prints the usage and exits non-zero, which proves the binary
# loads (no missing DLL) without touching the network.
$ErrorActionPreference = "Continue"
$out = cmd /c "dist\discover_printers.exe 2>&1" | Out-String
if ($out -notmatch "identify") { throw "discover_printers.exe did not print its usage: $out" }
Write-Host "usage text looks right"
# Imports of the .exe, so a DLL that a clean machine lacks shows up
# in the log (the VC++ runtime is the one that commonly does).
if (Get-Command dumpbin -ErrorAction SilentlyContinue) {
& dumpbin /dependents dist\discover_printers.exe | Select-String "\.dll" |
ForEach-Object { Write-Host $_.Line.Trim() }
}
$hash = (Get-FileHash dist\discover_printers.exe -Algorithm SHA256).Hash.ToLower()
Set-Content -Path dist\SHA256.txt -Value "$hash discover_printers.exe" -Encoding ASCII
Write-Host "sha256 $hash"
- name: Upload the executable
uses: actions/upload-artifact@v3
with:
name: continuum-discover-printers-windows
path: dist/*
if-no-files-found: error
# After the upload on purpose: the executable is useful even if a test
# that only breaks on Windows fails, and a red job still says so.
- name: Run the discovery tests on Windows
shell: powershell
run: |
$env:VCPKGRS_TRIPLET = "x64-windows-static-md"
cargo test --lib discovery
if ($LASTEXITCODE -ne 0) { throw "discovery tests failed on Windows" }
+2
View File
@@ -13,3 +13,5 @@ printers.toml
# access codes.
go2rtc.yaml
go2rtc.yaml.tmp
continuum.toml
go2rtc.yaml.pid
+6 -1
View File
@@ -75,7 +75,7 @@ rumqttc = { version = "0.25.1", default-features = false, features = ["use-nativ
# of explicit AUTH TLS. The printers do not care.
# MIT OR Apache-2.0 - checked against docs/LICENSES.md before adding.
suppaftp = { version = "12.1.1", features = ["native-tls", "deprecated"] }
zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2"] }
zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2", "aes-crypto"] }
# Reads the subject CN out of the certificate a printer presents on 8883,
# which is where its serial number is (see src/discovery.rs). Already in the
# tree - native-tls is openssl on Linux - so naming it directly adds nothing
@@ -87,3 +87,8 @@ flate2 = "1.1.10"
# src/uplink/download.rs). Features are the bare minimum: TLS via rustls, no
# openssl-sys; no json/gzip/cookies/etc, since the body is an opaque binary.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots"] }
# The local IPv4 addresses, so the SSDP listener can join the multicast group
# on every interface rather than the one the OS happens to pick (see
# `bind_ssdp`). Windows PCs routinely have VPN/Hyper-V/WSL adapters.
# MIT OR BSD-3-Clause - checked against docs/LICENSES.md before adding.
if-addrs = "0.15"
+2
View File
@@ -110,6 +110,8 @@ src/
uplink/ WebSocket client to continuum-backend: connect, heartbeat, reconnect on drop
camera.rs Writes go2rtc's config from the camera URLs the printers report
go2rtc.rs Restarts the go2rtc camera-restreaming process if it dies, or when the config changes
and stops one a hard-killed proxy left behind (pid file next to its config)
deploy/ systemd unit template; its KillMode=control-group is what stops go2rtc outliving a SIGKILL'd proxy
printer/
mod.rs GenericPrinter trait + PrinterBase + PrinterHandle enum - the trait+enum
"inheritance" pattern this project uses instead of class inheritance
+10
View File
@@ -0,0 +1,10 @@
# Says what kind of installation this is and where the real config lives.
# Read from CONTINUUM_BOOTSTRAP, else /etc/continuum/bootstrap.toml, else
# absent (which means: PC mode, ./continuum.toml). See src/bootstrap.rs.
#
# A Proxy Box image ships this root-owned. It must NOT come from a pendrive:
# whether a pendrive may change settings is what it decides.
mode = "proxy_box" # or "pc"
# config_path = "/var/lib/continuum/config.toml"
# pendrive_dirs = ["/media", "/run/media"] # ignored (and dropped) in pc mode
+77
View File
@@ -0,0 +1,77 @@
# The proxy's OWN settings - not the printers (that is printers.toml, which
# holds per-site secrets and is never shipped on a pendrive). See
# src/config.rs for how this file layers with CONTINUUM_* environment
# variables: this file is the base, an env var overrides the same key.
#
# PC / server: copy to continuum.toml next to the proxy and edit it. Nothing
# else is needed; removable media is never read.
# Proxy Box: this file is NOT written by hand. Continuum Cloud's
# configurator produces continuum-config.zip: config.toml,
# config.toml.sha256 and config.toml.sig (an Ed25519 signature
# naming this box and a config_version), every entry
# AES-256-encrypted under the box's own key. A Box refuses
# plaintext, unsigned, other-box and older-version bundles.
# See the module docs of src/config_import.rs for the exact
# format the cloud must produce.
#
# Unknown keys are rejected on purpose, so a typo cannot silently fall back to
# a default. On a Proxy Box the file must be complete by itself.
gateway_id = "REPLACE_ME"
farm_id = "REPLACE_ME"
gateway_token = "REPLACE_ME"
# uplink_url = "wss://api.continuum.local/ws/edge/v1"
# go2rtc_bin = "go2rtc"
# go2rtc_config = "./go2rtc.yaml"
# go2rtc_api_listen = ":1984"
# stream_base_url = "https://proxy.example/"
# printers_path = "/var/lib/continuum/printers.toml" # absolute on a box
# Proxy Box only. PARSED AND VALIDATED, NOT APPLIED - this build never
# touches the OS network configuration.
# [network]
# mode = "static" # or "dhcp"
# ip = "192.168.1.60"
# prefix_len = 24
# gateway = "192.168.1.1"
# dns = ["1.1.1.1"]
# [network.wifi]
# ssid = "farm"
# psk = "REPLACE_ME"
# Printer discovery: look for printers nobody has configured and report them
# to the cloud, so adding one in the web console does not start with walking
# to a machine to read its IP address off the screen.
#
# OFF by default, and the whole block is commented out here for that reason.
# A sweep is unsolicited traffic on someone else's network - 254 TLS
# handshakes and up to three HTTP requests per host, which nothing watching
# can distinguish from a port scan. Switch it on only for a network you are
# responsible for.
#
# Discovery never supplies a credential, because nothing can: a Bambu access
# code is displayed on the printer and a PrusaLink API key is in its web UI.
# It supplies the address, the serial and the vendor - the parts you would
# otherwise have to look up.
#
# `subnets` is explicit and never derived from this host's interfaces, so a
# second NIC, a VPN or a container bridge cannot widen what gets swept.
# Anything wider than a /24 is refused.
#
# `serial` reads USB descriptors from sysfs and opens nothing. It will never
# send an `M115` handshake: opening a serial port toggles DTR, which reboots
# an Arduino-based board, and on a printer mid-job that ruins the print. Use
# `cargo run --example discover_printers -- serial-probe <port>` when you want
# the handshake and are standing in front of the machine.
#
# `ssdp` listens passively for Bambu announcements. It sends nothing, but
# multicast does not cross a router, so on a real farm network a sweep is
# what actually works.
#
# [discovery]
# enabled = true
# subnets = ["192.168.0.0/24"]
# ssdp = false
# serial = false
# interval_minutes = 30
+38
View File
@@ -0,0 +1,38 @@
# Continuum proxy as a systemd service - the template for a Proxy Box or a
# Linux PC install. Adjust the paths; create the `continuum` user and put the
# settings it reads (continuum.toml, or the signed bundle on a Box) under
# /var/lib/continuum.
#
# Not exercised under a real systemd yet: this container's init is not
# systemd, so only `systemd-analyze verify` has been run against it.
[Unit]
Description=Continuum proxy (printer gateway)
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=continuum
# The proxy's relative defaults (./go2rtc.yaml, ./printers.toml, the go2rtc
# pid file) resolve here, and StateDirectory creates it owned by the user.
StateDirectory=continuum
WorkingDirectory=/var/lib/continuum
ExecStart=/usr/local/bin/continuum-proxy
# Always, including a clean exit: installing a new config from a pendrive
# deliberately ends the process and relies on this to start it on the new
# settings (src/main.rs, config_import).
Restart=always
RestartSec=2
# Do not change this to `process` or `none`. go2rtc is the proxy's child, so
# it lives in this unit's cgroup, and this is what makes systemd kill it
# whenever the proxy goes - including SIGKILL and the OOM killer, which give
# the proxy no chance to stop it itself. It is systemd's default; it is
# written out so nobody weakens it by accident. An orphaned go2rtc keeps
# :1984/:8555 bound and a printer's access code in memory.
KillMode=control-group
TimeoutStopSec=10
[Install]
WantedBy=multi-user.target
+47 -1
View File
@@ -7,6 +7,7 @@
//! cargo run --example discover_printers -- identify <host>... # one known address
//! cargo run --example discover_printers -- sweep <a.b.c.0/24> # every address on a segment
//! cargo run --example discover_printers -- serial # USB/serial ports, passive
//! cargo run --example discover_printers -- wire [subnet] [--ssdp] # the frame the daemon sends
//! cargo run --example discover_printers -- serial-probe <port> [baud] --yes-reset-the-printer
//! ```
//!
@@ -114,6 +115,50 @@ async fn main() -> anyhow::Result<()> {
println!("opening {port} at {baud} baud - this resets the board; waiting for it to boot ...");
vec![serial::probe_m115(port, baud, SERIAL_TIMEOUT).await?]
}
// Not merged with the others, because this one answers a different
// question: not "what is out there" but "what exactly would the
// daemon send about it". So it runs the real `scan` - the same
// function the background task calls, with the same fleet filtering
// and the same refusal to open a serial port - and prints the frame.
//
// The reason to have it: the gateway and the backend agree about this
// message only by hand, and a frame captured from real hardware is
// the only thing that proves the two halves match. See
// `continuum-backend/tests/edge_protocol.test.ts`, which validates
// captures like this one.
"wire" => {
let settings = continuum_proxy::config::DiscoverySettings {
enabled: true,
subnets: args.get(1).map(|s| vec![s.clone()]).unwrap_or_default(),
// On by default here and off in the daemon: a human asked for
// this one and is waiting for the output.
ssdp: args.iter().any(|a| a == "--ssdp"),
serial: true,
interval: Duration::from_secs(60),
};
let path = std::env::var("CONTINUUM_PRINTERS_PATH").unwrap_or_else(|_| "printers.toml".into());
let configured = continuum_proxy::fleet::configured(std::path::Path::new(&path))?;
let scan = discovery::scan::scan(&settings, &configured).await;
if !scan.looked() {
anyhow::bail!("every discovery source failed, so there is nothing to report");
}
let printers: Vec<_> = scan.candidates.iter().map(Candidate::to_wire).collect();
let count = printers.len();
// The real enum through the real serializer, not a hand-built
// JSON object. A hand-built one would agree with this file rather
// than with the daemon, which is the whole thing this subcommand
// exists to rule out.
let frame = continuum_proxy::uplink::ClientMessage::DiscoveredPrinters {
printers,
scanned_at: discovery::report::now_rfc3339(),
};
println!("{}", serde_json::to_string_pretty(&frame)?);
eprintln!(
"({count} candidate(s) after removing anything {path} already names; point \
CONTINUUM_PRINTERS_PATH at an empty file to see them all)"
);
return Ok(());
}
other => anyhow::bail!("unknown command {other:?}\n{}", usage()),
};
@@ -123,7 +168,8 @@ async fn main() -> anyhow::Result<()> {
fn usage() -> String {
"usage: discover_printers all [subnet] | listen [seconds] | raw [seconds] | identify <host>... \
| sweep <a.b.c.0/24> | serial | serial-probe <port> [baud] --yes-reset-the-printer"
| sweep <a.b.c.0/24> | serial | wire [subnet] [--ssdp] \
| serial-probe <port> [baud] --yes-reset-the-printer"
.to_string()
}
+31
View File
@@ -0,0 +1,31 @@
discover_printers.exe - finds Continuum-compatible 3D printers on this network
=============================================================================
Run it from PowerShell, in the folder it is in. It only LISTENS and asks
printers who they are. It sends no passwords or access codes and changes
nothing on any printer.
The test we need (Bambu printers announce themselves over SSDP multicast,
which only works if this PC is on the SAME network segment as the printers -
same Wi-Fi/Ethernet, not through a VPN, not a guest network):
.\discover_printers.exe raw 120 2>&1 | Tee-Object ssdp.txt
That listens for 2 minutes and prints EVERY datagram it hears, then what it
understood. Send back ssdp.txt. "listening for SSDP ... interfaces=[...]"
near the top lists the adapters it joined; the one on the printers' network
should be there.
Other commands:
.\discover_printers.exe listen 30 SSDP only, Bambu printers
.\discover_printers.exe sweep 192.168.0.0/24 probe every address on a subnet
.\discover_printers.exe all 192.168.0.0/24 both, merged
.\discover_printers.exe identify 192.168.0.21 one known address
Windows Firewall will ask whether to allow it on first run. Allow it on
PRIVATE networks; without that, Windows drops the multicast and the result is
"No printers found" even when printers are there.
Check the file is intact: Get-FileHash .\discover_printers.exe -Algorithm SHA256
(compare with SHA256.txt)
+274
View File
@@ -0,0 +1,274 @@
//! The tiny, fixed-location answer to "what kind of installation is this, and
//! where is my real config?" - which has to be known *before* the real config
//! can be found.
//!
//! # Why a second file at all
//!
//! The proxy's settings live in a config file (see `config.rs`), but a Proxy
//! Box replaces that file from a pendrive, and a PC user keeps it wherever
//! they like. Something has to say which of those two worlds this is, and
//! where the file is. That something cannot be in the file it locates, and it
//! must not be on the pendrive: whether removable media may change settings
//! at all is the very thing being decided, so the media cannot be the one to
//! say yes.
//!
//! # Discovery order
//!
//! 1. `CONTINUUM_BOOTSTRAP` - an explicit path; if set and unreadable that is
//! an error, not a fallthrough, because someone pointed at a file on
//! purpose.
//! 2. `/etc/continuum/bootstrap.toml` - the fixed location a Proxy Box image
//! ships. Root-owned, so a settings change on a box cannot be made by the
//! service user or by a pendrive.
//! 3. Nothing found: **PC mode with defaults**. This is what every existing
//! deployment (env vars only, no files) lands in, so adding the file layer
//! changes nothing for them.
//!
//! A file in the working directory is deliberately *not* searched. It would
//! be convenient on a PC, but "whichever directory the service happened to
//! start in can flip the box into a different mode" is a bad property for the
//! one file that gates removable-media import.
//!
//! # Overrides
//!
//! The same precedence as `config.rs`: bootstrap file, then env on top.
//! `CONTINUUM_MODE` (`proxy_box` | `pc`), `CONTINUUM_CONFIG_PATH`,
//! `CONTINUUM_PENDRIVE_DIRS` (colon-separated).
//!
//! Note what that means on a box: an environment variable can still override
//! a setting. The env of a service is set by its unit file, which is part of
//! the root-owned image, so it sits on the same side of the trust line as the
//! bootstrap file. It is not a way for a pendrive or the service user to
//! change anything.
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::config::ProxyFile;
/// Where a Proxy Box image puts its bootstrap file.
pub const BOOTSTRAP_PATH: &str = "/etc/continuum/bootstrap.toml";
/// Where a Proxy Box keeps the config it is running on. Under `/var/lib`, not
/// `/etc`: the service has to be able to replace this file atomically (which
/// needs a writable directory), and it should not need write access to `/etc`
/// to do so.
const BOX_CONFIG_PATH: &str = "/var/lib/continuum/config.toml";
/// Relative on purpose: next to wherever the user runs the proxy from, like
/// `printers.toml`.
const PC_CONFIG_PATH: &str = "continuum.toml";
/// Where desktop environments and udev rules mount removable media. Only
/// consulted on a Proxy Box.
const DEFAULT_PENDRIVE_DIRS: [&str; 2] = ["/media", "/run/media"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
/// A dedicated appliance. Settings change only by supplying a new
/// pendrive/upload.
ProxyBox,
/// A program on someone's PC or server. The config is a local file they
/// edit; removable media is never read.
Pc,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct BootstrapFile {
mode: Option<Mode>,
config_path: Option<PathBuf>,
pendrive_dirs: Option<Vec<PathBuf>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bootstrap {
pub mode: Mode,
/// The config the proxy runs on - on a box, the installed copy that
/// imports replace.
pub config_path: PathBuf,
/// Whether `config_path` was named by someone, rather than defaulted. An
/// explicit path that does not exist is an error; a defaulted one just
/// means "no file", which keeps env-only deployments working.
pub config_path_is_explicit: bool,
/// Directories searched for a config bundle. Always empty in PC mode,
/// whatever the bootstrap said - see `from_parts`.
pub pendrive_dirs: Vec<PathBuf>,
}
impl Bootstrap {
pub fn discover() -> anyhow::Result<Self> {
Self::discover_at(Path::new(BOOTSTRAP_PATH), &|key| std::env::var(key).ok())
}
/// `discover` with the fixed path and the environment injected, so tests
/// do not depend on the machine's `/etc` or mutate process-wide env.
pub fn discover_at(fixed: &Path, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
let file = match env("CONTINUUM_BOOTSTRAP") {
Some(explicit) => Self::read(Path::new(&explicit))?,
None => match std::fs::read_to_string(fixed) {
Ok(text) => Self::parse(&text, fixed)?,
// Absent is the normal case on a PC. Anything else (a file
// we cannot read) is not "absent" and must not quietly turn
// a box into a PC.
Err(err) if err.kind() == std::io::ErrorKind::NotFound => BootstrapFile::default(),
Err(err) => anyhow::bail!("failed to read bootstrap config {fixed:?}: {err}"),
},
};
Self::from_parts(file, env)
}
fn read(path: &Path) -> anyhow::Result<BootstrapFile> {
let text = std::fs::read_to_string(path)
.map_err(|err| anyhow::anyhow!("failed to read bootstrap config {path:?} (from CONTINUUM_BOOTSTRAP): {err}"))?;
Self::parse(&text, path)
}
fn parse(text: &str, path: &Path) -> anyhow::Result<BootstrapFile> {
toml::from_str(text).map_err(|err| anyhow::anyhow!("bootstrap config {path:?} is invalid: {err}"))
}
fn from_parts(file: BootstrapFile, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
let mode = match env("CONTINUUM_MODE") {
Some(value) => match value.as_str() {
"proxy_box" => Mode::ProxyBox,
"pc" => Mode::Pc,
other => anyhow::bail!("CONTINUUM_MODE must be \"proxy_box\" or \"pc\", not {other:?}"),
},
None => file.mode.unwrap_or(Mode::Pc),
};
let explicit_config = env("CONTINUUM_CONFIG_PATH").map(PathBuf::from).or(file.config_path);
let config_path_is_explicit = explicit_config.is_some();
let config_path = explicit_config.unwrap_or_else(|| match mode {
Mode::ProxyBox => PathBuf::from(BOX_CONFIG_PATH),
Mode::Pc => PathBuf::from(PC_CONFIG_PATH),
});
let requested_dirs = env("CONTINUUM_PENDRIVE_DIRS")
.map(|v| v.split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect())
.or(file.pendrive_dirs);
let pendrive_dirs = match mode {
Mode::ProxyBox => requested_dirs
.unwrap_or_else(|| DEFAULT_PENDRIVE_DIRS.iter().map(PathBuf::from).collect()),
// Dropped here, at the source, and not merely ignored later: the
// import code then has nothing to scan even if a future caller
// forgets to check the mode. In PC mode pendrive import is a way
// to change settings behind the user's back, so the safe state
// is the one where the list cannot be non-empty.
Mode::Pc => {
if requested_dirs.is_some_and(|dirs| !dirs.is_empty()) {
tracing::warn!("pendrive_dirs is set but this is PC mode; removable media will NOT be read");
}
Vec::new()
}
};
Ok(Self { mode, config_path, config_path_is_explicit, pendrive_dirs })
}
/// Reads the real config. `None` means "no file, use env and defaults" -
/// only ever for a path nobody named, so env-only deployments keep
/// working. A named path that is missing, or any file that does not
/// parse, is an error: starting on defaults because the operator's file
/// had a typo would run a proxy that looks configured and is not.
pub fn load_config_file(&self) -> anyhow::Result<Option<ProxyFile>> {
match std::fs::read_to_string(&self.config_path) {
Ok(text) => ProxyFile::parse(&text)
.map(Some)
.map_err(|err| anyhow::anyhow!("config file {:?} is invalid: {err}", self.config_path)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound && !self.config_path_is_explicit => Ok(None),
Err(err) => Err(anyhow::anyhow!("failed to read config file {:?}: {err}", self.config_path)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn no_env(_: &str) -> Option<String> {
None
}
fn from_text(text: &str, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Bootstrap> {
Bootstrap::from_parts(Bootstrap::parse(text, Path::new("test"))?, env)
}
#[test]
fn with_no_bootstrap_anywhere_the_proxy_is_a_pc_and_reads_no_pendrives() {
let missing = Path::new("/definitely/not/here/bootstrap.toml");
let b = Bootstrap::discover_at(missing, &no_env).unwrap();
assert_eq!(b.mode, Mode::Pc);
assert_eq!(b.config_path, PathBuf::from("continuum.toml"));
assert!(!b.config_path_is_explicit);
assert!(b.pendrive_dirs.is_empty());
}
#[test]
fn a_proxy_box_defaults_to_the_var_lib_config_and_the_standard_mount_points() {
let b = from_text("mode = \"proxy_box\"", &no_env).unwrap();
assert_eq!(b.config_path, PathBuf::from(BOX_CONFIG_PATH));
assert_eq!(b.pendrive_dirs.len(), 2);
}
#[test]
fn pc_mode_drops_pendrive_dirs_even_when_the_bootstrap_lists_some() {
let b = from_text("mode = \"pc\"\npendrive_dirs = [\"/media\"]", &no_env).unwrap();
assert!(b.pendrive_dirs.is_empty());
}
#[test]
fn pc_mode_drops_pendrive_dirs_supplied_through_the_environment_too() {
let env = |k: &str| (k == "CONTINUUM_PENDRIVE_DIRS").then(|| "/media:/mnt".to_string());
let b = from_text("mode = \"pc\"", &env).unwrap();
assert!(b.pendrive_dirs.is_empty());
}
#[test]
fn the_environment_overrides_the_bootstrap_file() {
let env = |k: &str| match k {
"CONTINUUM_MODE" => Some("pc".to_string()),
"CONTINUUM_CONFIG_PATH" => Some("/tmp/mine.toml".to_string()),
_ => None,
};
let b = from_text("mode = \"proxy_box\"", &env).unwrap();
assert_eq!(b.mode, Mode::Pc);
assert_eq!(b.config_path, PathBuf::from("/tmp/mine.toml"));
assert!(b.config_path_is_explicit);
}
#[test]
fn an_unknown_mode_in_the_environment_is_an_error_not_a_guess() {
let env = |k: &str| (k == "CONTINUUM_MODE").then(|| "appliance".to_string());
assert!(from_text("", &env).is_err());
}
#[test]
fn a_misspelled_bootstrap_key_is_refused() {
assert!(from_text("mod = \"proxy_box\"", &no_env).is_err());
}
#[test]
fn an_explicit_bootstrap_path_that_does_not_exist_is_an_error() {
let env = |k: &str| (k == "CONTINUUM_BOOTSTRAP").then(|| "/definitely/not/here.toml".to_string());
assert!(Bootstrap::discover_at(Path::new("/also/missing"), &env).is_err());
}
#[test]
fn a_defaulted_config_path_that_does_not_exist_means_no_file_not_an_error() {
let mut b = from_text("", &no_env).unwrap();
b.config_path = PathBuf::from("/definitely/not/here/continuum.toml");
assert!(b.load_config_file().unwrap().is_none());
}
#[test]
fn a_named_config_path_that_does_not_exist_is_an_error() {
let mut b = from_text("", &no_env).unwrap();
b.config_path = PathBuf::from("/definitely/not/here/continuum.toml");
b.config_path_is_explicit = true;
assert!(b.load_config_file().is_err());
}
}
+53 -8
View File
@@ -55,11 +55,16 @@ use crate::uplink::SharedFleet;
/// How often to rebuild the configuration from what the printers report.
///
/// A camera URL changes when a printer's address changes, which is a DHCP
/// lease event, not something that needs watching closely. The first useful
/// rebuild is the one a few seconds after startup, once the first reports
/// have arrived - before that the fleet has nothing to say.
const REBUILD_INTERVAL: Duration = Duration::from_secs(30);
/// The first tick fires immediately, before any printer has reported, so it
/// always writes a config with no streams. The one that matters is the next,
/// once the first reports have arrived - and until it runs, telemetry already
/// advertises a camera that go2rtc cannot serve, so a viewer in that window
/// gets "stream not found". At 30s that window was half a minute after every
/// start (found by scripts/e2e-camera.sh, which asked at +7s).
///
/// Cheap to poll: it reads in-memory reports and compares the rendered
/// config byte for byte, and go2rtc is only restarted when that changes.
const REBUILD_INTERVAL: Duration = Duration::from_secs(5);
/// The username every Bambu uses for its own services - MQTT, FTPS, and the
/// camera. Not a guess: it is what `bambu_mqtt` and `bambu_ftps` already
@@ -162,8 +167,8 @@ impl ApiAuth {
/// Generated once per run and then held, never re-derived: `render_config`
/// is called every `REBUILD_INTERVAL` and its output is compared
/// byte-for-byte to decide whether to restart go2rtc, so a password that
/// changed per call would restart it - and drop every live viewer - every
/// 30 seconds, forever.
/// changed per call would restart it - and drop every live viewer - on
/// every tick, forever.
///
/// A generated password is never logged. It is written into the config
/// file, which already holds every printer's access code and is already
@@ -369,8 +374,23 @@ pub async fn run(
/// on the same filesystem, so a reader sees either the old file or the new
/// one.
async fn write_atomically(path: &str, contents: &str) -> std::io::Result<()> {
use tokio::io::AsyncWriteExt;
let temp = format!("{path}.tmp");
tokio::fs::write(&temp, contents).await?;
// `tokio::fs::write` creates with 0666 minus the umask, so 0644: any local
// user could read the printer's access code and go2rtc's API password out
// of this file. Created owner-only instead, and created fresh - a temp file
// left behind by a crash would keep whatever mode it was given, because the
// mode applies only when the file is created.
let _ = tokio::fs::remove_file(&temp).await;
let mut options = tokio::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options.open(&temp).await?;
file.write_all(contents.as_bytes()).await?;
file.flush().await?;
drop(file);
tokio::fs::rename(&temp, path).await
}
@@ -538,4 +558,29 @@ mod tests {
"got:\n{config}"
);
}
/// The file holds a printer's access code and go2rtc's API password, so it
/// must not be readable by other local users. The second write matters: a
/// temp file left by a crash, created 0644, must not poison the next one.
#[cfg(unix)]
#[tokio::test]
async fn the_go2rtc_config_is_written_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("continuum-camera-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("go2rtc.yaml");
let path_str = path.to_str().unwrap();
std::fs::write(format!("{path_str}.tmp"), "stale").unwrap();
std::fs::set_permissions(format!("{path_str}.tmp"), std::fs::Permissions::from_mode(0o644)).unwrap();
write_atomically(path_str, "secret: yes\n").await.unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "secret: yes\n");
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
write_atomically(path_str, "secret: again\n").await.unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+586 -13
View File
@@ -1,4 +1,49 @@
//! The proxy's own settings: what it is called, where the cloud is, where
//! go2rtc lives, and (on a Proxy Box) how it is networked.
//!
//! # Where settings come from, in one place
//!
//! **A config file is the base; `CONTINUUM_*` environment variables override
//! it; built-in defaults fill what neither says.** That is the whole
//! precedence rule and nothing else in the crate re-states it. Env wins
//! because that is what every existing deployment, test and `.env` file
//! already relies on - `Config::from_env` predates the file, and a file that
//! silently beat the env would change the behaviour of setups that never
//! asked for a file.
//!
//! *Which* file is not decided here: that is the bootstrap's job (see
//! `bootstrap.rs`), because the proxy has to know whether it is a Proxy Box
//! or a program on someone's PC before it knows where to look. This module
//! only parses a file it is handed and layers it under the environment.
//!
//! # Not the fleet file
//!
//! `printers.toml` (see `fleet.rs`) lists the printers and holds their access
//! codes. This file configures the *proxy itself*. They are separate on
//! purpose: the proxy config is meant to be shipped to a box on a pendrive by
//! whoever provisions it, and the fleet file is full of per-site secrets that
//! should never ride along on a stick that gets passed around. The two meet
//! at exactly one field, `printers_path`, which says where the fleet file is.
//! On a Proxy Box the fleet file is therefore NOT delivered by this
//! mechanism - how it gets there is a separate, unsolved question.
//!
//! # Network settings are parsed, not applied
//!
//! `[network]` is read, validated and exposed (`ProxyFile::network`), and
//! that is where it stops. Nothing in this crate touches `/etc/network`,
//! NetworkManager or any other OS state. Applying them means choosing a
//! network stack for an image that does not exist yet, running as root, and a
//! wrong write can take the box off the network with nobody able to log in
//! to fix it - so it belongs in the appliance's provisioning layer, designed
//! together with a rollback. The boundary is `NetworkSettings` itself:
//! whatever applies it consumes that type and the proxy never will.
use std::env;
use std::fmt;
use std::net::IpAddr;
use std::time::Duration;
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct Config {
@@ -31,28 +76,556 @@ pub struct Config {
/// `BambuPrinter::ensure_trusted`) lives entirely in that file now,
/// not as a separate fleet-wide env-configured policy here.
pub printers_path: String,
/// How, and whether, to look for printers nobody has configured.
///
/// Off unless switched on - see [`DiscoverySettings`] for why that is
/// not merely a cautious default.
pub discovery: DiscoverySettings,
}
/// The file form of the proxy's settings. Every key is optional here: a key
/// the file omits falls through to the environment and then the default, so
/// a PC user can keep secrets in the environment and a file for the rest.
///
/// `deny_unknown_fields` is deliberate and the opposite of lenient. On a
/// Proxy Box nobody can edit the file afterwards, so a typo'd key
/// (`uplink_ulr`) that was silently ignored would ship a box running on the
/// default cloud URL with nothing saying so. Refusing at import time turns
/// that into a rejected pendrive the operator sees immediately.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProxyFile {
pub gateway_id: Option<String>,
pub farm_id: Option<String>,
pub gateway_token: Option<String>,
pub uplink_url: Option<String>,
pub go2rtc_bin: Option<String>,
pub go2rtc_config: Option<String>,
pub go2rtc_api_listen: Option<String>,
pub stream_base_url: Option<String>,
pub printers_path: Option<String>,
/// Only meaningful on a Proxy Box; see the module docs for why it is
/// exposed and not applied.
pub network: Option<NetworkSettings>,
/// Absent means off, which is the same as `enabled = false`. A file that
/// says nothing about discovery gets none.
pub discovery: Option<DiscoveryFile>,
}
impl ProxyFile {
pub fn parse(text: &str) -> anyhow::Result<Self> {
let file: Self = toml::from_str(text)?;
if let Some(network) = &file.network {
network.validate()?;
}
if let Some(discovery) = &file.discovery {
discovery.validate()?;
}
Ok(file)
}
}
/// Whether and how the gateway looks for printers nobody configured, and the
/// one thing it must never do while looking.
///
/// # Off by default, and not just out of caution
///
/// Discovery sends unsolicited traffic to addresses on someone else's
/// network. A `/24` sweep is 254 TLS handshakes and up to three HTTP requests
/// per host. Anything watching cannot tell that from a port scan, and 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 expose it to, and the first they would hear of
/// it is from their own security team.
///
/// So `enabled` is false until someone says otherwise, per gateway, in that
/// gateway's own config. The cloud cannot switch it on: a setting whose whole
/// purpose is to bound what the gateway does to the local network has to be
/// decided where that network is understood, which is not in a data centre.
///
/// # Serial discovery never opens a port
///
/// `serial` enumerates USB devices from sysfs and stops there. It will not
/// send `M115`, ever, and that is a correctness requirement rather than a
/// preference: opening a serial port toggles DTR, which reboots an
/// Arduino-based board. On a printer that is mid-job that ruins the print.
/// `examples/discover_printers.rs` can do the handshake because a human is
/// standing there and has typed `--yes-reset-the-printer`; a background task
/// on a schedule has nobody to ask, so it does not get the option.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoverySettings {
pub enabled: bool,
/// Subnets to sweep, as CIDR, e.g. `192.168.0.0/24`. Empty means no
/// sweep - the gateway then only listens, if `ssdp` is on.
///
/// Explicit rather than derived from the gateway's own interfaces. A box
/// with a second NIC, a VPN or a container bridge would otherwise sweep
/// a network nobody meant to point it at, and the operator naming the
/// subnet is also the operator taking responsibility for it.
pub subnets: Vec<String>,
/// Passive SSDP listening: no traffic sent, just whatever Bambu printers
/// announce themselves. Cheap and unobjectionable, but useless across a
/// router - multicast does not cross one - so a sweep is what works on a
/// real farm network.
pub ssdp: bool,
/// Enumerate USB serial devices. Reads sysfs; opens nothing. See above.
pub serial: bool,
pub interval: Duration,
}
impl Default for DiscoverySettings {
fn default() -> Self {
Self {
enabled: false,
subnets: Vec::new(),
ssdp: false,
serial: false,
// Only ever reached when `enabled` is true, so this is "how
// often a farm's hardware changes", not "how often to scan a
// network that did not ask for it". Half an hour is far more
// often than printers get bought and still negligible traffic.
interval: Duration::from_secs(30 * 60),
}
}
}
/// The file form. Every key optional, same as the rest of [`ProxyFile`].
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DiscoveryFile {
pub enabled: Option<bool>,
pub subnets: Option<Vec<String>>,
pub ssdp: Option<bool>,
pub serial: Option<bool>,
pub interval_minutes: Option<u64>,
}
impl DiscoveryFile {
/// Rejects at import time what would otherwise be found at scan time -
/// which on a Proxy Box means a stick the operator has already walked
/// away from.
pub fn validate(&self) -> anyhow::Result<()> {
if let Some(minutes) = self.interval_minutes {
// A floor, because the cost of a sweep falls on the printers and
// on whatever else answers on that network, not on us. One
// minute is already more often than anything here changes.
anyhow::ensure!(minutes >= 1, "discovery.interval_minutes must be at least 1");
}
for subnet in self.subnets.iter().flatten() {
// The same parser the sweep will use, so a `/16` typo is refused
// here rather than at 03:00 when the timer first fires. It
// refuses anything wider than a /24 - see `hosts_in_subnet`.
crate::discovery::net::hosts_in_subnet(subnet)
.map_err(|err| anyhow::anyhow!("discovery.subnets: {err}"))?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NetworkMode {
Dhcp,
Static,
}
/// What a Proxy Box should be configured to do on the network. Parsed and
/// validated only - nothing applies it (module docs).
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetworkSettings {
pub mode: NetworkMode,
pub ip: Option<IpAddr>,
pub prefix_len: Option<u8>,
pub gateway: Option<IpAddr>,
#[serde(default)]
pub dns: Vec<IpAddr>,
pub wifi: Option<WifiSettings>,
}
impl NetworkSettings {
/// Rejects a static config that could not possibly work. This is the one
/// place a bad network block is caught before it is installed: a box
/// whose stored config says "static" with no address would, once
/// something does apply it, come up unreachable - the failure we least
/// want to learn about from a customer.
pub fn validate(&self) -> anyhow::Result<()> {
if self.mode == NetworkMode::Static {
let ip = self.ip.ok_or_else(|| anyhow::anyhow!("network.mode is \"static\" but network.ip is not set"))?;
let prefix = self
.prefix_len
.ok_or_else(|| anyhow::anyhow!("network.mode is \"static\" but network.prefix_len is not set"))?;
let max = if ip.is_ipv4() { 32 } else { 128 };
anyhow::ensure!(prefix <= max, "network.prefix_len {prefix} is out of range for {ip} (max {max})");
}
if let Some(wifi) = &self.wifi {
anyhow::ensure!(!wifi.ssid.is_empty(), "network.wifi.ssid is empty");
}
Ok(())
}
}
/// `psk` is a secret, so `Debug` is written by hand: the derived one would
/// put the wifi password in any log line that prints the parsed config.
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WifiSettings {
pub ssid: String,
pub psk: Option<String>,
}
impl fmt::Debug for WifiSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WifiSettings")
.field("ssid", &self.ssid)
.field("psk", &self.psk.as_ref().map(|_| "<redacted>"))
.finish()
}
}
impl Config {
/// Environment only - the behaviour from before there was a file, kept
/// so existing callers and tests do not change.
pub fn from_env() -> anyhow::Result<Self> {
Self::layered(None, &|key| env::var(key).ok())
}
/// File as the base, environment on top (module docs).
pub fn from_file_and_env(file: Option<&ProxyFile>) -> anyhow::Result<Self> {
Self::layered(file, &|key| env::var(key).ok())
}
/// The layering itself, with the environment passed in as a lookup so
/// tests can exercise precedence without mutating the process-wide env,
/// which the parallel test runner would turn into a race.
pub fn layered(file: Option<&ProxyFile>, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
let empty = ProxyFile::default();
let file = file.unwrap_or(&empty);
let require = |key: &str, field: &Option<String>, name: &str| {
env(key).or_else(|| field.clone()).ok_or_else(|| {
anyhow::anyhow!("missing required env var {key} (or `{name}` in the proxy config file)")
})
};
let or_default = |key: &str, field: &Option<String>, default: &str| {
env(key).or_else(|| field.clone()).unwrap_or_else(|| default.to_string())
};
Ok(Self {
gateway_id: require("CONTINUUM_GATEWAY_ID")?,
farm_id: require("CONTINUUM_FARM_ID")?,
gateway_token: require("CONTINUUM_GATEWAY_TOKEN")?,
uplink_url: env_or("CONTINUUM_UPLINK_URL", "wss://api.continuum.local/ws/edge/v1"),
go2rtc_bin: env_or("CONTINUUM_GO2RTC_BIN", "go2rtc"),
go2rtc_config: env_or("CONTINUUM_GO2RTC_CONFIG", "./go2rtc.yaml"),
go2rtc_api_listen: env_or("CONTINUUM_GO2RTC_API_LISTEN", ":1984"),
stream_base_url: env::var("CONTINUUM_STREAM_BASE_URL").ok().filter(|v| !v.is_empty()),
printers_path: env_or("CONTINUUM_PRINTERS_PATH", "printers.toml"),
gateway_id: require("CONTINUUM_GATEWAY_ID", &file.gateway_id, "gateway_id")?,
farm_id: require("CONTINUUM_FARM_ID", &file.farm_id, "farm_id")?,
gateway_token: require("CONTINUUM_GATEWAY_TOKEN", &file.gateway_token, "gateway_token")?,
uplink_url: or_default("CONTINUUM_UPLINK_URL", &file.uplink_url, "wss://api.continuum.local/ws/edge/v1"),
go2rtc_bin: or_default("CONTINUUM_GO2RTC_BIN", &file.go2rtc_bin, "go2rtc"),
go2rtc_config: or_default("CONTINUUM_GO2RTC_CONFIG", &file.go2rtc_config, "./go2rtc.yaml"),
go2rtc_api_listen: or_default("CONTINUUM_GO2RTC_API_LISTEN", &file.go2rtc_api_listen, ":1984"),
// An empty value means "unset" at every layer, so an env var
// exported as empty can switch the advertisement off even when
// the file sets one.
stream_base_url: env("CONTINUUM_STREAM_BASE_URL")
.or_else(|| file.stream_base_url.clone())
.filter(|v| !v.is_empty()),
printers_path: or_default("CONTINUUM_PRINTERS_PATH", &file.printers_path, "printers.toml"),
discovery: discovery_settings(file.discovery.as_ref(), env)?,
})
}
}
fn require(key: &str) -> anyhow::Result<String> {
env::var(key).map_err(|_| anyhow::anyhow!("missing required env var {key}"))
/// Layered like everything else, except that the default is "do nothing" and
/// every layer has to say so explicitly to change that.
///
/// Note what is *not* here: no way for a missing or unparseable value to end
/// up as "on". `CONTINUUM_DISCOVERY_ENABLED=yes` is an error rather than a
/// shrug, because the two readings of a typo are not equally safe - treating
/// it as false surprises an operator who wanted scanning, and treating it as
/// true starts scanning a network nobody authorised. Refusing to start says
/// so while someone is still looking.
fn discovery_settings(
file: Option<&DiscoveryFile>,
env: &dyn Fn(&str) -> Option<String>,
) -> anyhow::Result<DiscoverySettings> {
let defaults = DiscoverySettings::default();
let empty = DiscoveryFile::default();
let file = file.unwrap_or(&empty);
let flag = |key: &str, field: Option<bool>, default: bool| -> anyhow::Result<bool> {
match env(key) {
Some(raw) => raw
.trim()
.parse::<bool>()
.map_err(|_| anyhow::anyhow!("{key} must be `true` or `false`, not {raw:?}")),
None => Ok(field.unwrap_or(default)),
}
};
let enabled = flag("CONTINUUM_DISCOVERY_ENABLED", file.enabled, defaults.enabled)?;
let subnets = match env("CONTINUUM_DISCOVERY_SUBNETS") {
// Comma-separated, and an empty value means "none" rather than one
// empty subnet - the same "empty env var switches it off" rule
// `stream_base_url` follows above.
Some(raw) => raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
None => file.subnets.clone().unwrap_or_default(),
};
let interval_minutes = match env("CONTINUUM_DISCOVERY_INTERVAL_MINUTES") {
Some(raw) => Some(
raw.trim()
.parse::<u64>()
.map_err(|_| anyhow::anyhow!("CONTINUUM_DISCOVERY_INTERVAL_MINUTES must be a whole number of minutes, not {raw:?}"))?,
),
None => file.interval_minutes,
};
let settings = DiscoverySettings {
enabled,
subnets,
ssdp: flag("CONTINUUM_DISCOVERY_SSDP", file.ssdp, defaults.ssdp)?,
serial: flag("CONTINUUM_DISCOVERY_SERIAL", file.serial, defaults.serial)?,
interval: interval_minutes
.map(|m| Duration::from_secs(m * 60))
.unwrap_or(defaults.interval),
};
// The env path bypasses `DiscoveryFile::validate`, so re-run the checks
// it makes on whatever actually came out of the layering. Only when
// enabled: a stale `CONTINUUM_DISCOVERY_SUBNETS` left in someone's shell
// profile should not stop a gateway that is not scanning anything.
if settings.enabled {
let effective = DiscoveryFile {
subnets: Some(settings.subnets.clone()),
interval_minutes,
..DiscoveryFile::default()
};
effective.validate()?;
if settings.subnets.is_empty() && !settings.ssdp && !settings.serial {
// Enabled and told to look nowhere. Almost certainly a config
// that was meant to do something, so say so instead of running a
// timer that reports an empty list forever.
anyhow::bail!(
"discovery is enabled but has nothing to look at: set `discovery.subnets`, or turn on `discovery.ssdp` or `discovery.serial`"
);
}
}
Ok(settings)
}
fn env_or(key: &str, default: &str) -> String {
env::var(key).unwrap_or_else(|_| default.to_string())
#[cfg(test)]
mod tests {
use super::*;
fn no_env(_: &str) -> Option<String> {
None
}
fn file(text: &str) -> ProxyFile {
ProxyFile::parse(text).expect("test config parses")
}
const MINIMAL: &str = "gateway_id = \"g\"\nfarm_id = \"f\"\ngateway_token = \"t\"\n";
#[test]
fn with_no_file_and_no_env_the_required_settings_are_reported_missing() {
let err = Config::layered(None, &no_env).unwrap_err().to_string();
assert!(err.contains("CONTINUUM_GATEWAY_ID"), "{err}");
}
#[test]
fn the_environment_alone_still_configures_the_proxy_as_it_did_before_the_file_existed() {
let env = |k: &str| match k {
"CONTINUUM_GATEWAY_ID" => Some("g".into()),
"CONTINUUM_FARM_ID" => Some("f".into()),
"CONTINUUM_GATEWAY_TOKEN" => Some("t".into()),
_ => None,
};
let config = Config::layered(None, &env).unwrap();
assert_eq!(config.gateway_id, "g");
assert_eq!(config.go2rtc_api_listen, ":1984");
assert_eq!(config.printers_path, "printers.toml");
}
#[test]
fn a_file_alone_is_enough_and_unset_keys_take_the_defaults() {
let config = Config::layered(Some(&file(MINIMAL)), &no_env).unwrap();
assert_eq!(config.farm_id, "f");
assert_eq!(config.go2rtc_bin, "go2rtc");
}
#[test]
fn an_environment_variable_overrides_the_same_key_in_the_file() {
let text = format!("{MINIMAL}uplink_url = \"wss://from-file\"\n");
let env = |k: &str| (k == "CONTINUUM_UPLINK_URL").then(|| "wss://from-env".to_string());
let config = Config::layered(Some(&file(&text)), &env).unwrap();
assert_eq!(config.uplink_url, "wss://from-env");
}
#[test]
fn a_file_key_the_environment_does_not_mention_survives_the_overlay() {
let text = format!("{MINIMAL}go2rtc_bin = \"/opt/go2rtc\"\n");
let env = |k: &str| (k == "CONTINUUM_UPLINK_URL").then(|| "wss://x".to_string());
let config = Config::layered(Some(&file(&text)), &env).unwrap();
assert_eq!(config.go2rtc_bin, "/opt/go2rtc");
}
#[test]
fn an_empty_stream_base_url_in_the_environment_switches_off_one_set_in_the_file() {
let text = format!("{MINIMAL}stream_base_url = \"https://cam.example\"\n");
let env = |k: &str| (k == "CONTINUUM_STREAM_BASE_URL").then(String::new);
let config = Config::layered(Some(&file(&text)), &env).unwrap();
assert_eq!(config.stream_base_url, None);
}
#[test]
fn a_misspelled_key_is_refused_rather_than_silently_ignored() {
let err = ProxyFile::parse(&format!("{MINIMAL}uplink_ulr = \"x\"\n")).unwrap_err().to_string();
assert!(err.contains("uplink_ulr"), "{err}");
}
#[test]
fn a_static_network_block_without_an_address_is_refused() {
let err = ProxyFile::parse("[network]\nmode = \"static\"\n").unwrap_err().to_string();
assert!(err.contains("network.ip"), "{err}");
}
#[test]
fn a_static_network_block_with_an_impossible_prefix_is_refused() {
let text = "[network]\nmode = \"static\"\nip = \"10.0.0.5\"\nprefix_len = 40\n";
assert!(ProxyFile::parse(text).is_err());
}
#[test]
fn a_complete_network_block_is_parsed_and_exposed_but_not_applied() {
let text = "[network]\nmode = \"static\"\nip = \"10.0.0.5\"\nprefix_len = 24\ngateway = \"10.0.0.1\"\ndns = [\"1.1.1.1\"]\n";
let network = ProxyFile::parse(text).unwrap().network.unwrap();
assert_eq!(network.ip, Some("10.0.0.5".parse().unwrap()));
assert_eq!(network.dns.len(), 1);
}
#[test]
fn the_wifi_password_never_appears_in_debug_output() {
let text = "[network]\nmode = \"dhcp\"\n[network.wifi]\nssid = \"farm\"\npsk = \"hunter2hunter2\"\n";
let parsed = ProxyFile::parse(text).unwrap();
let shown = format!("{parsed:?}");
assert!(shown.contains("farm") && !shown.contains("hunter2"), "{shown}");
}
/// The one default in this file that is a security property rather than a
/// convenience, so it is pinned from several directions.
mod discovery_is_off_unless_asked {
use super::*;
#[test]
fn with_no_config_at_all() {
let env = |k: &str| match k {
"CONTINUUM_GATEWAY_ID" => Some("g".into()),
"CONTINUUM_FARM_ID" => Some("f".into()),
"CONTINUUM_GATEWAY_TOKEN" => Some("t".into()),
_ => None,
};
assert!(!Config::layered(None, &env).unwrap().discovery.enabled);
}
#[test]
fn with_a_file_that_says_nothing_about_it() {
let config = Config::layered(Some(&file(MINIMAL)), &no_env).unwrap();
assert!(!config.discovery.enabled);
assert!(config.discovery.subnets.is_empty());
assert!(!config.discovery.ssdp);
assert!(!config.discovery.serial);
}
#[test]
fn with_a_discovery_block_that_only_sets_a_subnet() {
// Naming a subnet is not consent to scan it. Someone who wrote
// the address down and left `enabled` out gets nothing, which is
// the safe reading of an incomplete instruction.
let text = format!("{MINIMAL}[discovery]\nsubnets = [\"192.168.0.0/24\"]\n");
let config = Config::layered(Some(&file(&text)), &no_env).unwrap();
assert!(!config.discovery.enabled);
}
}
#[test]
fn a_file_can_switch_discovery_on_and_say_where_to_look() {
let text = format!(
"{MINIMAL}[discovery]\nenabled = true\nsubnets = [\"192.168.0.0/24\"]\nserial = true\ninterval_minutes = 5\n"
);
let config = Config::layered(Some(&file(&text)), &no_env).unwrap();
assert!(config.discovery.enabled);
assert_eq!(config.discovery.subnets, ["192.168.0.0/24"]);
assert!(config.discovery.serial);
assert!(!config.discovery.ssdp);
assert_eq!(config.discovery.interval, Duration::from_secs(300));
}
#[test]
fn the_environment_can_switch_discovery_off_over_a_file_that_enables_it() {
// Which direction matters: the override that must work is the one
// that stops the scanning, so an operator who finds their gateway
// probing a network can kill it without editing a file they may not
// be able to edit (a Proxy Box's config is signed in the cloud).
let text = format!("{MINIMAL}[discovery]\nenabled = true\nserial = true\n");
let env = |k: &str| (k == "CONTINUUM_DISCOVERY_ENABLED").then(|| "false".to_string());
let config = Config::layered(Some(&file(&text)), &env).unwrap();
assert!(!config.discovery.enabled);
}
#[test]
fn a_discovery_flag_that_is_neither_true_nor_false_is_an_error_rather_than_a_guess() {
// The two readings of a typo are not equally safe: false surprises
// someone who wanted scanning, true scans a network nobody
// authorised. Refusing to start says so while someone is watching.
let env = |k: &str| match k {
"CONTINUUM_GATEWAY_ID" => Some("g".into()),
"CONTINUUM_FARM_ID" => Some("f".into()),
"CONTINUUM_GATEWAY_TOKEN" => Some("t".into()),
"CONTINUUM_DISCOVERY_ENABLED" => Some("yes".into()),
_ => None,
};
let err = Config::layered(None, &env).unwrap_err().to_string();
assert!(err.contains("CONTINUUM_DISCOVERY_ENABLED"), "{err}");
}
#[test]
fn a_subnet_wider_than_a_slash_24_is_refused_when_the_file_is_read() {
// At import time, which on a Proxy Box is while the operator is still
// holding the stick - rather than at 03:00 when the timer first
// fires and tries to probe 65k addresses.
let text = format!("{MINIMAL}[discovery]\nenabled = true\nsubnets = [\"10.0.0.0/16\"]\n");
let err = ProxyFile::parse(&text).unwrap_err().to_string();
assert!(err.contains("discovery.subnets"), "{err}");
}
#[test]
fn discovery_enabled_with_nowhere_to_look_is_refused_as_the_mistake_it_is() {
let text = format!("{MINIMAL}[discovery]\nenabled = true\n");
let err = Config::layered(Some(&file(&text)), &no_env).unwrap_err().to_string();
assert!(err.contains("nothing to look at"), "{err}");
}
#[test]
fn a_misspelled_discovery_key_is_refused_like_every_other_one() {
let text = format!("{MINIMAL}[discovery]\nenabled = true\nsubnet = [\"192.168.0.0/24\"]\n");
assert!(ProxyFile::parse(&text).is_err());
}
#[test]
fn a_stale_subnet_in_the_environment_does_not_stop_a_gateway_that_is_not_scanning() {
// Validation of the subnets only runs when discovery is on. A
// leftover export in someone's shell profile should not be fatal to
// a gateway that will never read it.
let env = |k: &str| match k {
"CONTINUUM_GATEWAY_ID" => Some("g".into()),
"CONTINUUM_FARM_ID" => Some("f".into()),
"CONTINUUM_GATEWAY_TOKEN" => Some("t".into()),
"CONTINUUM_DISCOVERY_SUBNETS" => Some("not-a-subnet".into()),
_ => None,
};
let config = Config::layered(None, &env).unwrap();
assert!(!config.discovery.enabled);
}
}
+1830
View File
File diff suppressed because it is too large Load Diff
+111 -12
View File
@@ -72,13 +72,15 @@
//! shows its working rather than only its conclusion.
pub mod net;
pub mod report;
pub mod scan;
pub mod serial;
use std::net::{Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket};
use std::time::Duration;
use tokio::net::UdpSocket;
use tracing::{debug, warn};
use tracing::{debug, info, warn};
/// The SSDP multicast group, which is the standard one - Bambu's discovery is
/// ordinary SSDP with vendor headers, not a protocol of its own.
@@ -163,6 +165,21 @@ pub enum Source {
SerialHandshake,
}
impl Source {
/// The `source` value in a `discovered_printers` message - see the
/// backend's `discovered_printer.schema.json`, which is an enum there, so
/// these strings are a contract and not a label.
pub fn wire_value(self) -> &'static str {
match self {
Source::Ssdp => "ssdp",
Source::Certificate => "certificate",
Source::HttpApi => "http_api",
Source::UsbDescriptor => "usb_descriptor",
Source::SerialHandshake => "serial_handshake",
}
}
}
/// A printer found on the network or on a USB port, minus its credential.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
@@ -212,6 +229,28 @@ impl Candidate {
}
}
/// The wire form for `ClientMessage::DiscoveredPrinters`.
///
/// `bind_state` is deliberately dropped. It is a Bambu-only fact about a
/// cloud account, the cloud already knows more about its own accounts
/// than an SSDP packet does, and the one thing it is good for here -
/// explaining a printer that is visible and then refuses to connect -
/// is a diagnosis for someone standing in front of the machine, which is
/// what `examples/discover_printers.rs` prints it for.
pub fn to_wire(&self) -> crate::uplink::DiscoveredPrinter {
crate::uplink::DiscoveredPrinter {
vendor: self.vendor.toml_value(),
address: self.host.clone(),
serial: self.serial.clone(),
model: self.model.clone(),
name: self.name.clone(),
detail: self.detail.clone(),
ambiguous_with: self.ambiguous_with.map(Vendor::toml_value),
source: self.source.wire_value(),
driver_ready: self.vendor.driver_is_implemented(),
}
}
/// A `[[printer]]` block to paste into `printers.toml`.
///
/// Each vendor gets the fields `fleet.rs` actually requires of it and no
@@ -370,10 +409,10 @@ pub(crate) fn valid_serial(value: String) -> Option<String> {
/// one thing in a datagram that cannot be mistyped.
///
/// Tolerant on purpose: it extracts whatever headers are present rather than
/// requiring a full set. The exact header spelling is from Bambu's published
/// discovery format and has not been checked against a real announcement
/// here, so a missing vendor header has to mean "one fewer field filled in",
/// not "no printer found".
/// requiring a full set. The vendor header spelling was checked against a
/// real H2C's `200 OK` reply to a unicast M-SEARCH (2026-10-29); an
/// unsolicited NOTIFY has not been seen, so a missing vendor header has to
/// mean "one fewer field filled in", not "no printer found".
pub fn parse_ssdp(message: &str, from: Ipv4Addr) -> Option<Candidate> {
let mut lines = message.lines();
let start = lines.next()?.trim();
@@ -484,18 +523,47 @@ pub async fn listen_ssdp(port: u16, window: Duration, raw: bool) -> anyhow::Resu
Ok(found)
}
/// Binds the SSDP port and joins the group on every interface.
/// Binds the SSDP port and joins the group on every IPv4 interface.
///
/// `Ipv4Addr::UNSPECIFIED` as the interface asks the kernel to pick, which is
/// right for a gateway appliance: it may well have two interfaces and only
/// the operator knows which one faces the printers.
/// Passing `UNSPECIFIED` as the interface asks the OS to pick one, and that
/// is the wrong default on a machine with several: a Windows PC with Wi-Fi
/// plus a VPN, Hyper-V or WSL adapter joins on whichever it prefers, and
/// then hears nothing from printers on the other while looking perfectly
/// healthy. Only the operator knows which one faces the printers, so join on
/// all of them and say which were joined. Falls back to the OS's choice if
/// the interfaces cannot be listed.
fn bind_ssdp(port: u16) -> anyhow::Result<UdpSocket> {
let socket = StdUdpSocket::bind((Ipv4Addr::UNSPECIFIED, port)).map_err(|err| {
anyhow::anyhow!("could not bind UDP :{port} for discovery: {err} (is another process using it?)")
})?;
socket
.join_multicast_v4(&SSDP_GROUP, &Ipv4Addr::UNSPECIFIED)
.map_err(|err| anyhow::anyhow!("could not join {SSDP_GROUP} on :{port}: {err}"))?;
let mut interfaces: Vec<Ipv4Addr> = if_addrs::get_if_addrs()
.unwrap_or_default()
.into_iter()
.filter(|interface| !interface.is_loopback())
.filter_map(|interface| match interface.ip() {
std::net::IpAddr::V4(v4) => Some(v4),
std::net::IpAddr::V6(_) => None,
})
.collect();
if interfaces.is_empty() {
interfaces.push(Ipv4Addr::UNSPECIFIED);
}
let mut joined = Vec::new();
for interface in &interfaces {
match socket.join_multicast_v4(&SSDP_GROUP, interface) {
Ok(()) => joined.push(*interface),
// One adapter that refuses (down, no multicast) is not a reason to
// stop listening on the others.
Err(err) => debug!(%interface, ?err, "could not join the SSDP group on this interface"),
}
}
if joined.is_empty() {
anyhow::bail!("could not join {SSDP_GROUP} on :{port} on any of {interfaces:?}");
}
info!(port, interfaces = ?joined, "listening for SSDP");
socket.set_nonblocking(true)?;
Ok(UdpSocket::from_std(socket)?)
}
@@ -546,6 +614,37 @@ mod tests {
DevConnect.bambu.com: lan\r\n\
DevBind.bambu.com: free\r\n";
/// The reply a real H2C sent to a unicast M-SEARCH on 1990 and on 2021
/// (2026-10-29), byte for byte in shape; the serial, name and addresses
/// are made up. It is a `200 OK`, not a NOTIFY, and carries
/// `Devseclink`/`DevInf`/`DevCap`, which `NOTIFY` above does not.
#[test]
fn a_real_printers_reply_to_an_msearch_parses() {
let reply = "HTTP/1.1 200 OK\r\n\
Server: UPnP/1.0\r\n\
Date: Mon, 26 Oct 2026 17:21:14 GMT\r\n\
Location: 192.168.0.99\r\n\
ST: urn:bambulab-com:device:3dprinter:1\r\n\
EXT: \r\n\
USN: 31B8BP5A0000000\r\n\
Cache-Control: max-age=1800\r\n\
DevModel.bambu.com: O1C2\r\n\
DevName.bambu.com: Workshop\r\n\
DevConnect.bambu.com: lan\r\n\
DevBind.bambu.com: free\r\n\
Devseclink.bambu.com: secure\r\n\
DevInf.bambu.com: wlan0\r\n\
DevVersion.bambu.com: 01.08.00.00\r\n\
DevCap.bambu.com: 1\r\n\r\n";
let found = parse_ssdp(reply, Ipv4Addr::new(10, 0, 0, 1)).expect("a Bambu reply");
assert_eq!(found.host, "192.168.0.99");
assert_eq!(found.serial.as_deref(), Some("31B8BP5A0000000"));
assert_eq!(found.model.as_deref(), Some("O1C2"));
assert_eq!(found.name.as_deref(), Some("Workshop"));
assert_eq!(found.bind_state.as_deref(), Some("free"));
assert_eq!(found.detail.as_deref(), Some("Bambu firmware 01.08.00.00"));
}
#[test]
fn an_announcement_yields_every_field_it_carries() {
let found = parse_ssdp(NOTIFY, Ipv4Addr::new(10, 0, 0, 1)).expect("a Bambu announcement");
+164
View File
@@ -0,0 +1,164 @@
//! The task that tells the cloud what this gateway can see.
//!
//! Its reason for existing is small and specific: adding a printer in the web
//! console should not begin with someone walking to a machine to read its IP
//! address off the screen. It cannot finish the job - the credential is on
//! that screen and no probe can read it - but it removes the address, the
//! serial number and the guesswork about which vendor the thing is.
//!
//! # It does nothing unless asked
//!
//! `discovery.enabled` is false by default, and this task then parks forever
//! rather than looping over a disabled check. The reason is in
//! `DiscoverySettings`: a sweep is unsolicited traffic on someone else's
//! network, and a gateway that did it by default would be scanning networks
//! its operator never meant to point it at.
//!
//! Note which way the switch faces. It is read from the gateway's own config
//! and the cloud cannot set it, because a setting whose entire purpose is to
//! bound what this process does to the local network has to be decided where
//! that network is understood.
use std::time::Duration;
use tokio::sync::mpsc::Sender;
use crate::config::DiscoverySettings;
use crate::uplink::ClientMessage;
/// Scans on a timer and reports each pass, forever.
///
/// Never returns, so it sits in `main`'s `select!` alongside the other
/// long-running tasks. With discovery off it parks on a future that never
/// completes, which costs nothing and keeps the "every arm runs forever"
/// property of that `select!` true.
pub async fn run(settings: DiscoverySettings, printers_path: String, outbound: Sender<ClientMessage>) {
if !settings.enabled {
tracing::info!("printer discovery is off (discovery.enabled); not scanning this network");
std::future::pending::<()>().await;
return;
}
tracing::info!(
subnets = ?settings.subnets,
ssdp = settings.ssdp,
serial = settings.serial,
every = ?settings.interval,
"printer discovery is ON; this gateway will probe the network above"
);
let mut ticker = tokio::time::interval(settings.interval);
// A pass takes tens of seconds - an SSDP window alone is 32 - so a tick
// can come due while one is still running. `Delay` drops the ones that
// came due rather than firing them back to back afterwards, which is what
// `Burst` (the default) would do and would turn a slow scan into a
// continuous one.
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
// Fires immediately the first time, so a gateway reports what it can
// see shortly after boot rather than one interval later.
ticker.tick().await;
// Re-read every pass, not once at startup. Nothing re-reads the fleet
// file on a running daemon yet, so adding a printer still means a
// restart - but someone who has *edited* the file and not restarted
// should not keep being offered the printer they just configured.
let configured = match crate::fleet::configured(std::path::Path::new(&printers_path)) {
Ok(configured) => configured,
// Fail the pass rather than report against an empty set. The
// alternative offers every printer the farm already runs as a
// fresh find, which is worse than no report: it is a list of
// things to add that are already added.
Err(err) => {
tracing::warn!(%err, "cannot tell which printers are already configured; skipping this discovery pass");
continue;
}
};
let scan = super::scan::scan(&settings, &configured).await;
if !scan.looked() {
// Every enabled source failed. An empty report would assert that
// this network has no unconfigured printers, which is a claim
// about the farm we have no evidence for - see `Scan::looked`.
tracing::warn!(
attempted = scan.attempted,
"every discovery source failed; not reporting, because an empty report would mean 'nothing is there'"
);
continue;
}
let printers: Vec<_> = scan.candidates.iter().map(super::Candidate::to_wire).collect();
tracing::info!(count = printers.len(), "reporting discovered printers");
let message = ClientMessage::DiscoveredPrinters {
printers,
scanned_at: now_rfc3339(),
};
// `send`, not `try_send`: the queue outlives any one connection, so
// waiting here is waiting for the cloud to come back, which is
// exactly the right thing to do with a scan result. If the channel is
// ever closed the uplink is gone and so is the process.
if outbound.send(message).await.is_err() {
tracing::warn!("uplink queue closed; stopping discovery reports");
return;
}
}
}
/// RFC 3339 in UTC, which is what the schema's `date-time` means.
///
/// Public so `examples/discover_printers.rs` can print a frame with a real
/// timestamp in it - a capture is meant to be feedable straight to the
/// backend's validator, and a placeholder there would not be.
pub fn now_rfc3339() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
format_rfc3339(secs)
}
/// Formats a Unix second count, with the clock passed in so it is testable.
///
/// Hand-rolled because this crate has no date library and one field does not
/// justify adding one - `scanned_at` is read by a human looking at a "found
/// 20 minutes ago" label, not by arithmetic.
fn format_rfc3339(secs: u64) -> String {
let (days, time) = (secs / 86_400, secs % 86_400);
let (hour, minute, second) = (time / 3600, (time % 3600) / 60, time % 60);
// Civil date from a day count, by the algorithm in Howard Hinnant's
// `civil_from_days`: shift the epoch to 0000-03-01 so a leap day lands at
// the end of the cycle and the month-length pattern repeats cleanly.
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timestamps_match_what_date_u_prints() {
// Pinned against `date -u -d @N`: an off-by-one in the civil date
// arithmetic is invisible until it is a day or a year out.
assert_eq!(format_rfc3339(0), "1970-01-01T00:00:00Z");
// A leap day, which is the whole reason this arithmetic needs care.
assert_eq!(format_rfc3339(1_709_164_800), "2024-02-29T00:00:00Z");
assert_eq!(format_rfc3339(1_792_413_296), "2026-10-19T12:34:56Z");
// Past 2100, where the "divisible by 100 is not a leap year" rule
// bites and a naive every-four-years version drifts.
assert_eq!(format_rfc3339(4_102_444_800), "2100-01-01T00:00:00Z");
// The last second of a year, which catches a December/January slip.
assert_eq!(format_rfc3339(1_767_225_599), "2025-12-31T23:59:59Z");
}
}
+159
View File
@@ -0,0 +1,159 @@
//! One pass of discovery, as a background task runs it - which is a narrower
//! thing than what `examples/discover_printers.rs` can do, and deliberately.
//!
//! The example has a human standing in front of the printers who can be asked
//! for consent and can read a warning. This has a timer. So the two differ on
//! exactly the operations where that distinction matters:
//!
//! * No `M115`. Opening a serial port toggles DTR and reboots an
//! Arduino-based board, which on a machine mid-job ruins the print. The
//! example will do it behind `--yes-reset-the-printer`; nothing on a
//! schedule gets that option, because there is nobody to type it.
//! * No subnet the operator did not name. `DiscoverySettings::subnets` is
//! explicit rather than derived from the gateway's own interfaces, so a
//! second NIC or a VPN cannot quietly widen what gets swept.
//!
//! What it produces is candidates with no credential in them, which is not a
//! simplification - see `uplink::DiscoveredPrinter` for why no probe can
//! learn one.
use std::time::Duration;
use super::{Candidate, net, serial};
use crate::config::DiscoverySettings;
use crate::fleet::Configured;
/// How long to sit on the SSDP group.
///
/// Has to exceed a Bambu printer's announce interval, which is around 30
/// seconds, or a scan can open its window just after a printer announced and
/// conclude the printer is not there. Nothing here is urgent enough for that
/// to be worth shaving.
const SSDP_WINDOW: Duration = Duration::from_secs(32);
/// Per-host budget for the TLS and HTTP probes. Generous, because it is
/// spread across a batch that runs concurrently - see `net::sweep`.
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
/// The schema's `maxItems`. A gateway that somehow found more than this is
/// pointed at something that is not a printer farm, and a truncated list
/// beats a message the backend rejects whole.
const MAX_REPORTED: usize = 256;
/// The result of one pass, which is not just the candidates.
///
/// "Found nothing" and "could not look" are different answers, and an empty
/// candidate list alone cannot tell them apart. The distinction has to
/// survive as far as the cloud, because an empty report means *this gateway
/// looked and its network has no unconfigured printers* - so publishing one
/// after every probe failed would state something untrue about the farm.
/// Hence the counts: [`Scan::looked`] is what the reporter checks before it
/// sends anything.
#[derive(Debug, Default)]
pub struct Scan {
pub candidates: Vec<Candidate>,
/// How many sources were enabled - subnets, the SSDP ports, serial.
pub attempted: usize,
/// How many of them failed outright.
pub failed: usize,
}
impl Scan {
/// Whether this pass is evidence about the network at all.
///
/// False when every enabled source failed, which is the case an empty
/// report must not be sent for. A partial failure is still evidence: a
/// gateway that swept one of two subnets found what it found, and
/// withholding that until every source works would mean a container that
/// can never bind SSDP suppresses a perfectly good sweep forever.
pub fn looked(&self) -> bool {
self.attempted > 0 && self.failed < self.attempted
}
}
/// Everything enabled, merged, with the already-configured removed.
///
/// One failing source does not fail the pass: an unreachable subnet, or an
/// SSDP socket a container will not let us bind, should still let the rest
/// report what it found. Every failure is logged, and counted in the result
/// so the caller can tell a quiet network from a blind gateway.
pub async fn scan(settings: &DiscoverySettings, configured: &Configured) -> Scan {
let mut out = Scan::default();
if !settings.enabled {
return out;
}
let mut found = Vec::new();
if settings.ssdp {
// Both ports Bambu has shipped announcements on, concurrently: a
// printer uses one or the other depending on firmware, and listening
// on them in series would double the window for no reason.
let (first, second) = tokio::join!(
super::listen_ssdp(super::SSDP_PORTS[0], SSDP_WINDOW, false),
super::listen_ssdp(super::SSDP_PORTS[1], SSDP_WINDOW, false),
);
for result in [first, second] {
out.attempted += 1;
match result {
Ok(candidates) => found.extend(candidates),
// Expected on a host that cannot join a multicast group, and
// in a container whose network is a veth pair to a router -
// which is also why a sweep, not SSDP, is what works on a
// real farm network.
Err(err) => {
out.failed += 1;
tracing::warn!(%err, "SSDP listen failed");
}
}
}
}
for subnet in &settings.subnets {
out.attempted += 1;
match net::sweep(subnet, PROBE_TIMEOUT).await {
Ok(candidates) => found.extend(candidates),
Err(err) => {
out.failed += 1;
tracing::warn!(%err, subnet, "subnet sweep failed");
}
}
}
if settings.serial {
out.attempted += 1;
match serial::list_ports() {
// Every USB-attached port, not only the ones whose vendor id says
// "Prusa". The alternative misses most real Marlin printers,
// because the common boards sit behind a generic CH340 or FTDI
// bridge that is indistinguishable from any other USB serial
// device without the `M115` this task will not send. So the
// unavoidable error is made the harmless one: a candidate's
// `detail` says outright that it could be a printer or anything
// else, and an extra row in a list someone is choosing from costs
// far less than a printer that never appears.
Ok(ports) => found.extend(ports.iter().map(serial::SerialDevice::to_candidate)),
Err(err) => {
out.failed += 1;
tracing::warn!(%err, "serial port enumeration failed");
}
}
}
out.candidates = super::merge(found);
let candidates = &mut out.candidates;
// Last, not first: merging can fill in a serial that one source knew and
// another did not, and a candidate the fleet file names by serial is only
// recognisable as such once it has that serial.
candidates.retain(|candidate| {
!configured.mentions(Some(&candidate.host), candidate.serial.as_deref())
});
if candidates.len() > MAX_REPORTED {
tracing::warn!(
found = candidates.len(),
reported = MAX_REPORTED,
"truncating the discovery report"
);
candidates.truncate(MAX_REPORTED);
}
out
}
+64
View File
@@ -3,6 +3,7 @@
//! by default) is gitignored: it holds real hostnames and access codes for
//! your printers, which don't belong in version control.
use std::collections::HashSet;
use std::path::Path;
use serde::Deserialize;
@@ -71,6 +72,69 @@ pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
file.printers.into_iter().map(build_printer).collect()
}
/// Everything the fleet file already names, for telling a printer the
/// operator has dealt with from one they have never seen.
///
/// Three sets rather than one, because the three identify a printer with
/// very different confidence. A serial is the machine. A host is the address
/// it answers on today. A device path is which USB socket it is plugged
/// into. A match on any of them is enough to mean "already known", which is
/// the conservative direction: the cost of a false match is one printer
/// missing from a suggestion list, and the cost of a false miss is the
/// console offering to add a printer that is already running jobs.
#[derive(Debug, Default)]
pub struct Configured {
pub hosts: HashSet<String>,
pub serials: HashSet<String>,
pub ports: HashSet<String>,
}
impl Configured {
/// Lowercased on both sides: hostnames are case-insensitive, and a Bambu
/// serial read from a certificate has no guaranteed case either.
pub fn mentions(&self, host: Option<&str>, serial: Option<&str>) -> bool {
let known = |set: &HashSet<String>, value: Option<&str>| {
value.is_some_and(|value| set.contains(&value.to_lowercase()))
};
known(&self.hosts, host) || known(&self.serials, serial) || known(&self.ports, host)
}
}
/// What the fleet file mentions, without building a single printer client.
///
/// Parse level on purpose, and the difference matters: [`load`] refuses an
/// entry with a blank `access_code`, but that entry is exactly the case this
/// needs to catch - a printer discovery already suggested, that the operator
/// pasted in and has not yet typed the credential for. It is not a printer
/// they have never seen, so reporting it again as a fresh find would be
/// noise at the one moment they are mid-way through dealing with it.
///
/// A missing file is an empty set rather than an error: a gateway with no
/// fleet yet is the case discovery exists for.
pub fn configured(path: &Path) -> anyhow::Result<Configured> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Configured::default()),
Err(err) => return Err(anyhow::anyhow!("failed to read {path:?}: {err}")),
};
let file: FleetFile = toml::from_str(&text)?;
let mut out = Configured::default();
for entry in file.printers {
let add = |set: &mut HashSet<String>, value: Option<String>| {
if let Some(value) = value {
let value = value.trim().to_lowercase();
if !value.is_empty() {
set.insert(value);
}
}
};
add(&mut out.hosts, entry.host);
add(&mut out.serials, entry.sn);
add(&mut out.ports, entry.port);
}
Ok(out)
}
fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
match entry.vendor.as_str() {
"bambu" => build_bambu(entry, BambuProtocolPreference::Auto),
+135
View File
@@ -1,3 +1,4 @@
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
@@ -6,6 +7,76 @@ use tokio::process::Command;
use tokio::sync::Notify;
use tracing::{error, info, warn};
/// Where the running go2rtc's pid is recorded: next to its config, because
/// that is the one path this process already owns and the one a restarted
/// proxy is certain to compute again.
fn pid_path(config_path: &str) -> PathBuf {
PathBuf::from(format!("{config_path}.pid"))
}
/// Removes the pid file when the supervisor goes away, however it does:
/// the watchdog is dropped, not returned from, on a normal shutdown.
struct PidFile(PathBuf);
impl Drop for PidFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
/// Whether a `/proc/<pid>/cmdline` is the go2rtc this proxy started: the
/// configured binary as the program (or as the script an interpreter was
/// given, which is what a wrapper looks like) *and* our own config path after
/// `-config`. Both, because a pid alone proves nothing once the number has
/// been reused, and "some go2rtc" is not necessarily ours - another proxy on
/// the machine would run one against its own config.
fn is_our_go2rtc(cmdline: &[u8], bin: &str, config_path: &str) -> bool {
let args: Vec<&[u8]> = cmdline.split(|b| *b == 0).filter(|a| !a.is_empty()).collect();
let launched = args.iter().take(2).any(|a| *a == bin.as_bytes());
let configured = args.windows(2).any(|w| w[0] == b"-config" && w[1] == config_path.as_bytes());
launched && configured
}
/// Stops a go2rtc left behind by a proxy that was killed without a chance to
/// stop it (SIGKILL, the OOM killer, a power cut that kept the container).
///
/// Only acts on a pid this proxy recorded, and only after `/proc` confirms
/// that pid is still our go2rtc (`is_our_go2rtc`). Anywhere without `/proc`
/// it cannot confirm that, so it leaves the process alone: a leftover
/// go2rtc is a nuisance, killing someone else's process is not. The orphan
/// matters because it keeps :1984 and :8555 bound - so the new one cannot
/// start - and holds a printer's access code in memory.
async fn reclaim_orphan(bin: &str, config_path: &str) {
let pid_file = pid_path(config_path);
let Ok(text) = std::fs::read_to_string(&pid_file) else { return };
let _ = std::fs::remove_file(&pid_file);
let Ok(pid) = text.trim().parse::<u32>() else { return };
let ours = || {
std::fs::read(format!("/proc/{pid}/cmdline"))
.map(|cmdline| is_our_go2rtc(&cmdline, bin, config_path))
.unwrap_or(false)
};
if !ours() {
return;
}
warn!(pid, "a go2rtc left by an earlier proxy is still running; stopping it");
// The `kill` program rather than a libc call: signalling is the only
// reason this would need `unsafe` and a new dependency.
for (signal, patience) in [("-TERM", 30), ("-KILL", 30)] {
let _ = Command::new("kill").arg(signal).arg(pid.to_string()).status().await;
for _ in 0..patience {
if !ours() {
info!(pid, "the old go2rtc is gone");
return;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
error!(pid, "could not stop the old go2rtc; the new one may fail to bind its ports");
}
/// Supervises the go2rtc subprocess used for camera restreaming
/// (RTSP -> WebRTC). Restarts it with a fixed backoff whenever it exits, and
/// immediately when `restart` fires.
@@ -16,6 +87,11 @@ use tracing::{error, info, warn};
/// Before that existed this watchdog supervised a process whose config file
/// nothing wrote - it restarted go2rtc forever over a file that did not exist.
pub async fn watchdog(bin: String, config_path: String, restart: Arc<Notify>) {
// Before the first spawn: whatever the last proxy left behind owns the
// ports this one is about to ask for.
reclaim_orphan(&bin, &config_path).await;
let _pid_file = PidFile(pid_path(&config_path));
loop {
info!(%bin, %config_path, "starting go2rtc");
@@ -29,6 +105,9 @@ pub async fn watchdog(bin: String, config_path: String, restart: Arc<Notify>) {
match spawned {
Ok(mut child) => {
if let Some(Err(err)) = child.id().map(|pid| std::fs::write(pid_path(&config_path), pid.to_string())) {
warn!(?err, "could not record go2rtc's pid; a hard kill of the proxy would orphan it unnoticed");
}
tokio::select! {
// Whichever happens first. `kill_on_drop` means the
// child dies when `child` goes out of scope at the end
@@ -61,3 +140,59 @@ pub async fn watchdog(bin: String, config_path: String, restart: Arc<Notify>) {
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cmdline(args: &[&str]) -> Vec<u8> {
args.iter().flat_map(|a| a.bytes().chain([0])).collect()
}
#[test]
fn only_our_binary_on_our_config_counts() {
let ours = cmdline(&["go2rtc", "-config", "./go2rtc.yaml"]);
assert!(is_our_go2rtc(&ours, "go2rtc", "./go2rtc.yaml"));
// A wrapper script puts the interpreter first.
assert!(is_our_go2rtc(&cmdline(&["/bin/sh", "go2rtc", "-config", "./go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
// Another proxy's go2rtc, an unrelated process, and a reused pid.
assert!(!is_our_go2rtc(&cmdline(&["go2rtc", "-config", "/other.yaml"]), "go2rtc", "./go2rtc.yaml"));
assert!(!is_our_go2rtc(&cmdline(&["sleep", "-config", "./go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
assert!(!is_our_go2rtc(&cmdline(&["vim", "go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
assert!(!is_our_go2rtc(b"", "go2rtc", "./go2rtc.yaml"), "a zombie has an empty cmdline");
}
/// A stand-in go2rtc that runs until killed, started the way the real
/// one is, whose pid file is then found by a "restarted proxy".
#[cfg(target_os = "linux")]
#[tokio::test]
async fn a_leftover_go2rtc_is_stopped_and_a_stranger_is_not() {
let dir = std::env::temp_dir().join(format!("continuum-go2rtc-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("go2rtc");
// Stays alive until signalled, and (run as `sh <script> -config ..`)
// looks to /proc the way the real one does behind an interpreter.
std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 1; done\n").unwrap();
let (bin, config) = (script.to_string_lossy().into_owned(), dir.join("go2rtc.yaml").to_string_lossy().into_owned());
let mut orphan = std::process::Command::new("/bin/sh").args([&bin, "-config", &config]).spawn().unwrap();
let stranger = std::process::Command::new("sleep").arg("60").spawn().unwrap();
// The stranger's pid in the file: refused, and the file is cleared.
std::fs::write(pid_path(&config), stranger.id().to_string()).unwrap();
reclaim_orphan(&bin, &config).await;
assert!(std::fs::read(format!("/proc/{}/cmdline", stranger.id())).is_ok_and(|c| !c.is_empty()), "an unrelated process must survive");
assert!(!pid_path(&config).exists());
// The orphan's pid: stopped.
std::fs::write(pid_path(&config), orphan.id().to_string()).unwrap();
reclaim_orphan(&bin, &config).await;
let status = orphan.wait().unwrap();
assert!(!status.success(), "the leftover go2rtc was terminated by a signal");
let mut stranger = stranger;
let _ = stranger.kill();
let _ = stranger.wait();
let _ = std::fs::remove_dir_all(&dir);
}
}
+2
View File
@@ -8,9 +8,11 @@
//! should serve is not - that is `camera`, which reads the fleet and is worth
//! testing, so it lives here.
pub mod bootstrap;
pub mod camera;
pub mod completion;
pub mod config;
pub mod config_import;
pub mod discovery;
pub mod fleet;
pub mod printer;
+103 -10
View File
@@ -9,7 +9,9 @@ mod go2rtc;
use tracing::info;
use tracing_subscriber::EnvFilter;
use continuum_proxy::bootstrap::{self, Bootstrap};
use continuum_proxy::config::Config;
use continuum_proxy::config_import;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
use continuum_proxy::{camera, completion, fleet, uplink};
@@ -20,7 +22,46 @@ async fn main() -> anyhow::Result<()> {
.init();
dotenvy::dotenv().ok();
let config = Config::from_env()?;
// Order matters: import first, so a stick present at boot is already
// the installed config by the time it is read. A rejected stick is
// logged inside and does not stop startup - the box runs on what it has.
let bootstrap = Bootstrap::discover()?;
// Plaintext bundles are refused on a Proxy Box and signatures are
// mandatory once a signing key is provisioned; see the config_import
// module docs. An unprovisioned box is allowed to start - it runs on its
// installed config - but says loudly what it cannot check.
//
// Loaded only on a box: on a PC nothing imports from removable media, and
// a stray or malformed file under /etc/continuum must not stop a PC user's
// proxy starting over provisioning it never uses.
let policy = if bootstrap.mode == bootstrap::Mode::ProxyBox {
config_import::load_provisioning()?
} else {
config_import::BundlePolicy::unencrypted_only()
};
if bootstrap.mode == bootstrap::Mode::ProxyBox {
if policy.signing_keys.is_empty() {
tracing::warn!("no signing key provisioned: config bundles will NOT be authenticated");
}
if policy.box_id.is_none() {
tracing::warn!("no box id provisioned: signed config bundles cannot be accepted");
}
}
for outcome in config_import::import_from_pendrives(&bootstrap, &policy) {
config_import::log_outcome(&outcome);
}
let file = bootstrap.load_config_file()?;
let config = Config::from_file_and_env(file.as_ref())?;
info!(mode = ?bootstrap.mode, config_file = ?file.as_ref().map(|_| &bootstrap.config_path), "configuration loaded");
// BOUNDARY: network settings are parsed and validated, and stop here.
// Applying them (static IP, DNS, wifi) is the appliance image's job, not
// this process's - see the module docs of `config`. Logged so that a box
// given a network block does not look like it acted on it.
if let Some(network) = file.as_ref().and_then(|f| f.network.as_ref()) {
tracing::warn!(?network, "network settings are present but this build does NOT apply them");
}
tracing::info!(gateway_id = %config.gateway_id, farm_id = %config.farm_id, "starting continuum-proxy");
@@ -37,13 +78,18 @@ async fn main() -> anyhow::Result<()> {
// see `uplink::SharedFleet` for why that type and what it costs.
let fleet: uplink::SharedFleet = std::sync::Arc::new(tokio::sync::Mutex::new(fleet));
// Four long-running tasks: the cloud uplink, the go2rtc subprocess
// watchdog, the camera-config builder that tells it what to serve, and
// the print-completion watcher that blinks a printer's lights when it
// finishes. Every printer's MQTT session is a fifth thing running
// concurrently, but implicitly - each one has its own background poll
// task spawned by `connect()`, kept alive by the fleet above. More will
// join these later (printer discovery, the plate changer).
// Five long-running tasks: the cloud uplink, the go2rtc subprocess
// watchdog, the camera-config builder that tells it what to serve, the
// print-completion watcher that blinks a printer's lights when it
// finishes, and printer discovery. Every printer's MQTT session is a
// sixth thing running concurrently, but implicitly - each one has its own
// background poll task spawned by `connect()`, kept alive by the fleet
// above. More will join these later (the plate changer).
//
// Discovery parks on a future that never completes unless it is switched
// on, which it is not by default - see `discovery::report::run`. So this
// arm is usually a task that costs nothing, and that is the intended
// steady state, not a disabled feature waiting to be finished.
//
// The completion watcher is deliberately its own task rather than part
// of the uplink: it must keep working with the cloud connection down,
@@ -61,7 +107,7 @@ async fn main() -> anyhow::Result<()> {
let restart_go2rtc = std::sync::Arc::new(tokio::sync::Notify::new());
// Built once, here, rather than inside the rebuild loop: the config is
// re-rendered every 30s and compared byte-for-byte to decide whether to
// re-rendered every few seconds and compared byte-for-byte to decide whether to
// restart go2rtc, so a credential that changed per render would restart
// it - and drop every viewer - on every tick.
let camera_auth = camera::ApiAuth::new()?;
@@ -71,8 +117,29 @@ async fn main() -> anyhow::Result<()> {
"go2rtc's API requires basic auth; the password is in its config file"
);
// Created here rather than inside the uplink so discovery can hand it
// messages without the uplink knowing what discovery is. It outlives any
// one connection, so a scan that finishes while the cloud is unreachable
// waits for the next socket instead of being thrown away.
let (outbound, inbox) = uplink::channel();
tokio::select! {
_ = uplink::run(config.clone(), fleet.clone()) => {}
// Ends the process through a normal return, so the supervisors below
// are dropped and go2rtc's `kill_on_drop` actually fires. Without this
// arm SIGTERM - what `kill`, `docker stop` and a service manager all
// send - ends the process on the spot with no destructor run, and
// go2rtc is orphaned: it keeps :1984 and :8555 bound, so the restarted
// proxy cannot start its own, and it keeps a printer's access code in
// memory. Found when a test run left one behind.
_ = shutdown_requested() => {
info!("stop requested; exiting so go2rtc is stopped with us");
}
_ = uplink::run(config.clone(), fleet.clone(), outbound.clone(), inbox) => {}
_ = continuum_proxy::discovery::report::run(
config.discovery.clone(),
config.printers_path.clone(),
outbound.clone(),
) => {}
_ = go2rtc::watchdog(
config.go2rtc_bin.clone(),
config.go2rtc_config.clone(),
@@ -86,11 +153,37 @@ async fn main() -> anyhow::Result<()> {
restart_go2rtc.clone(),
) => {}
_ = completion::run(fleet.clone()) => {}
// Returns only after installing a new config from a stick inserted
// while running. Settings are read once at startup, so the process
// ends here and relies on its supervisor (systemd `Restart=always`
// on a box) to start it again on the new config. Not exercised end
// to end - there is no supervisor or real stick in the dev
// environment.
_ = config_import::watch(bootstrap.clone(), policy.clone()) => {
info!("exiting so the supervisor restarts the proxy on the newly installed config");
}
}
Ok(())
}
/// Resolves when the process is asked to stop: SIGTERM or SIGINT on Unix,
/// Ctrl-C elsewhere.
async fn shutdown_requested() {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
if let Ok(mut terminate) = signal(SignalKind::terminate()) {
tokio::select! {
_ = terminate.recv() => {}
_ = tokio::signal::ctrl_c() => {}
}
return;
}
}
let _ = tokio::signal::ctrl_c().await;
}
/// Connects every configured printer concurrently (not one at a time -
/// Bambu's auto-detect fallback alone can take several seconds per
/// printer if it has to time out on MQTT v5 before trying v3.1.1;
+23 -7
View File
@@ -30,7 +30,7 @@ mod download;
mod webrtc;
mod protocol;
pub use protocol::{ClientMessage, PrinterCommand, PrinterTelemetry, ServerMessage};
pub use protocol::{ClientMessage, DiscoveredPrinter, PrinterCommand, PrinterTelemetry, ServerMessage};
use std::sync::Arc;
use std::time::Duration;
@@ -119,13 +119,29 @@ fn host_header(url: &str) -> anyhow::Result<String> {
Ok(authority.to_string())
}
/// The queue a task elsewhere puts a message on to have the uplink send it.
///
/// Created by the caller rather than inside [`run`] so that a task which is
/// not the uplink - printer discovery, today - can hold the sender without
/// the uplink having to know what those tasks are. The property that matters
/// is unchanged either way: it outlives any one connection, so a message
/// produced while the socket is down waits for the next socket instead of
/// being dropped, and the sender living for the whole program is what stops
/// `recv()` from ever returning `None`.
pub fn channel() -> (
tokio::sync::mpsc::Sender<ClientMessage>,
tokio::sync::mpsc::Receiver<ClientMessage>,
) {
tokio::sync::mpsc::channel(OUTBOUND_CAPACITY)
}
/// Runs forever: connect, serve the connection until it drops, wait, retry.
pub async fn run(config: Config, fleet: SharedFleet) {
// Created out here, not per connection, so a message produced while the
// socket is down waits for the next one instead of being dropped. The
// sender is kept for the whole program's life, which is also what stops
// `recv()` below from ever returning `None`.
let (outbound, mut inbox) = tokio::sync::mpsc::channel::<ClientMessage>(OUTBOUND_CAPACITY);
pub async fn run(
config: Config,
fleet: SharedFleet,
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
mut inbox: tokio::sync::mpsc::Receiver<ClientMessage>,
) {
let mut delay = RETRY_DELAY;
loop {
+52
View File
@@ -56,6 +56,58 @@ pub enum ClientMessage {
/// waiting on this and a viewer otherwise watches a video element that
/// never loads with nothing anywhere saying why.
WebrtcError { session_id: String, message: String },
/// Printers found on this gateway's own network and USB ports that are
/// *not* in its fleet file, so the console can offer them instead of
/// making an operator walk to a machine and read its IP address off the
/// screen.
///
/// A whole scan result that replaces the last one, not a set of
/// additions: a printer that has gone is reported by its absence. An
/// empty list is therefore meaningful - it says the gateway looked and
/// found nothing, which is different from never having sent one of these
/// at all, and a gateway with discovery off (the default) never does.
DiscoveredPrinters {
printers: Vec<DiscoveredPrinter>,
scanned_at: String,
},
}
/// One candidate, carrying no credential - and it is worth being precise
/// about why not, because this is the field a reader looks for.
///
/// It is not omitted for tidiness. No probe can learn one: a Bambu access
/// code is displayed on the printer's own screen and a PrusaLink API key is
/// in its web UI, and every identity endpoint that would reveal either needs
/// the credential first. So discovery shortens the operator's job to typing
/// one secret, rather than a secret plus an address plus a serial number -
/// and the backend's schema refuses a candidate that carries a credential,
/// so this staying empty is enforced at the far end too.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DiscoveredPrinter {
/// The `vendor` string this would need in a fleet file.
pub vendor: &'static str,
/// An IP or hostname, or a device path like `/dev/ttyACM0` for a serial
/// candidate. Named `address` rather than `host` because half of them
/// are not hosts.
pub address: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub serial: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
/// A vendor this could equally be; the operator settles it. Today only
/// `prusa_link` against `octoprint`, which nothing observable separates.
#[serde(skip_serializing_if = "Option::is_none")]
pub ambiguous_with: Option<&'static str>,
pub source: &'static str,
/// Whether this build can actually drive the vendor. False is still
/// worth reporting - it answers "what is on this network?" - but the
/// console has to say so, or an entry looks finished when adding it
/// would produce a printer that connects and does nothing.
pub driver_ready: bool,
}
/// A flattened snapshot of one printer, in the terms an operator console
+121
View File
@@ -134,3 +134,124 @@ fn an_entry_with_the_credential_left_blank_is_refused_by_name() {
);
}
}
/// The other half of the loop: a printer the operator has already dealt with
/// must stop being offered as a fresh find.
///
/// Same coupling-by-a-string risk as the rest of this file, in the other
/// direction. `fleet::configured` reads the fields discovery writes, so a
/// rename on either side silently turns the filter off - and a filter that
/// silently stops filtering looks exactly like a working one, except the
/// console offers every printer the farm already runs.
mod already_configured {
use super::*;
fn configured_from(toml: &str) -> fleet::Configured {
let mut file = tempfile();
file.write_all(toml.as_bytes()).expect("write the fleet file");
fleet::configured(&file.path).expect("parse the fleet file")
}
#[test]
fn a_rendered_candidate_is_recognised_in_the_file_it_was_pasted_into() {
for vendor in [Vendor::Bambu, Vendor::Klipper, Vendor::Octoprint, Vendor::PrusaLink, Vendor::PrusaSerial] {
let mut candidate = Candidate::new("192.168.0.77", vendor, Source::HttpApi);
if vendor == Vendor::PrusaSerial {
candidate.host = "/dev/ttyACM0".to_string();
}
if vendor == Vendor::Bambu {
candidate.serial = Some("00M09A382100000".to_string());
}
let toml = with_credentials(&candidate.to_toml_entry("p"));
let configured = configured_from(&toml);
assert!(
configured.mentions(Some(&candidate.host), candidate.serial.as_deref()),
"a pasted {vendor:?} entry was not recognised as configured:\n{toml}"
);
}
}
#[test]
fn an_entry_whose_credential_is_still_blank_counts_as_configured() {
// The exact moment this matters: the operator has pasted in what
// discovery suggested and has not yet typed the access code off the
// printer's screen. `fleet::load` refuses that entry - and must -
// but re-offering the printer as a new find while they are mid-way
// through adding it is noise at the worst possible time.
let mut candidate = Candidate::new("192.168.0.78", Vendor::Bambu, Source::Certificate);
candidate.serial = Some("00M09A382100001".to_string());
let toml = candidate.to_toml_entry("p");
assert!(load(&toml).is_err(), "a blank access_code should still fail to load");
assert!(configured_from(&toml).mentions(Some("192.168.0.78"), None));
}
#[test]
fn a_printer_at_a_new_address_is_recognised_by_its_serial() {
// DHCP moved it. The serial is the machine; the address is a lease.
let configured = configured_from(
"[[printer]]\nid = \"p\"\nname = \"P1P\"\nvendor = \"bambu\"\n\
host = \"192.168.0.20\"\nsn = \"00M09A382100000\"\naccess_code = \"12345678\"\n",
);
assert!(configured.mentions(Some("192.168.0.99"), Some("00M09A382100000")));
// Case is not a difference: a serial read out of a TLS certificate
// has no guaranteed case.
assert!(configured.mentions(None, Some("00m09a382100000")));
// And something genuinely new is still new.
assert!(!configured.mentions(Some("192.168.0.99"), Some("00M09A382100002")));
}
#[test]
fn a_missing_fleet_file_means_nothing_is_configured_rather_than_an_error() {
// A gateway with no fleet yet is the case discovery exists for, so
// this is the normal path on a new box and not an edge case.
let configured = fleet::configured(std::path::Path::new("/nonexistent/printers.toml"))
.expect("a missing fleet file is not an error");
assert!(!configured.mentions(Some("192.168.0.20"), Some("00M09A382100000")));
}
}
/// The wire form a gateway reports, against the vendor and source strings the
/// backend's `discovered_printer.schema.json` declares as enums.
///
/// Those are a contract, not labels: an unrecognised value does not degrade
/// gracefully at the far end, it makes the backend reject the frame.
#[test]
fn every_vendor_and_source_renders_a_value_the_backend_schema_accepts() {
const VENDORS: [&str; 5] = ["bambu", "klipper", "prusa_link", "octoprint", "prusa_serial"];
const SOURCES: [&str; 5] = ["ssdp", "certificate", "http_api", "usb_descriptor", "serial_handshake"];
for vendor in [Vendor::Bambu, Vendor::Klipper, Vendor::PrusaLink, Vendor::Octoprint, Vendor::PrusaSerial] {
for source in [Source::Ssdp, Source::Certificate, Source::HttpApi, Source::UsbDescriptor, Source::SerialHandshake] {
let mut candidate = Candidate::new("192.168.0.20", vendor, source);
candidate.ambiguous_with = Some(Vendor::PrusaLink);
let wire = candidate.to_wire();
assert!(VENDORS.contains(&wire.vendor), "vendor {:?} renders {:?}", vendor, wire.vendor);
assert!(SOURCES.contains(&wire.source), "source {:?} renders {:?}", source, wire.source);
assert_eq!(wire.ambiguous_with, Some("prusa_link"));
assert_eq!(wire.driver_ready, vendor.driver_is_implemented());
}
}
}
/// A candidate must not carry a credential up to the cloud, and the check is
/// on the serialised bytes rather than the struct's fields.
///
/// Reading the fields would only prove what the type already says. The risk
/// is someone later adding a field to `Candidate` - an access code recovered
/// from somewhere, a pinned certificate - and it reaching the wire because
/// `to_wire` was never the place anyone thought to look.
#[test]
fn no_credential_shaped_field_reaches_the_wire() {
let mut candidate = Candidate::new("192.168.0.20", Vendor::Bambu, Source::Certificate);
candidate.serial = Some("00M09A382100000".to_string());
candidate.name = Some("P1P".to_string());
candidate.bind_state = Some("free".to_string());
let json = serde_json::to_string(&candidate.to_wire()).expect("serialise");
for forbidden in ["access_code", "api_key", "password", "token", "secret", "bind_state"] {
assert!(!json.contains(forbidden), "{forbidden} reached the wire: {json}");
}
// And the fields that should be there, are - or this test would pass on
// an empty object.
assert!(json.contains("00M09A382100000") && json.contains("\"vendor\":\"bambu\""), "{json}");
}
+5 -1
View File
@@ -44,6 +44,9 @@ fn config(port: u16) -> Config {
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(),
}
}
@@ -72,7 +75,8 @@ async fn a_long_command_that_fails_reports_its_reason_after_the_fact() {
let port = listener.local_addr().unwrap().port();
let fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new()));
tokio::spawn(uplink::run(config(port), fleet));
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();