Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bbe8af9a1
|
||
|
|
b9d36b9b87
|
||
|
|
c6e364e9df
|
||
|
|
987a7b4708
|
||
|
|
88bf1d1c2f
|
||
|
|
e90c6f54e8
|
||
|
|
1967aae42c
|
||
|
|
062686b1a7
|
||
|
|
1cd05d9120
|
||
|
|
47c48ee02d
|
@@ -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" }
|
||||
@@ -13,3 +13,5 @@ printers.toml
|
||||
# access codes.
|
||||
go2rtc.yaml
|
||||
go2rtc.yaml.tmp
|
||||
continuum.toml
|
||||
go2rtc.yaml.pid
|
||||
|
||||
+6
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+111
-12
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user