From 17eb255dc2c91040a5ad5b6ee7fc0d6df66be44f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0tetiar?= Date: Thu, 8 Oct 2026 07:19:30 +0200 Subject: [PATCH 1/2] feat: take camera snapshots from the camera's RTSP stream, without Connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_camera_snapshot` and `prusactl camera` needed Prusa Connect. The PrusaLink of Buddy firmware has no camera endpoint. The Buddy3D camera is a separate Wi-Fi device that serves RTSP at `rtsp:///live`. A printer that was reached only directly therefore had no picture. `prusactl setup --camera URL` (or `PRUSACTL_CAMERA_URL`) saves the address beside the printer. ffmpeg grabs one frame. A snapshot comes from the first source that works: 1. the `/api/v1/cameras/snap` endpoint of the printer 2. the RTSP stream 3. Prusa Connect If the camera fails, the Connect fallback is used. A printer without any camera still says so. `setup --no-camera` forgets only the camera. `--forget` removes it with the printer, also when it was saved without one. `status` shows the camera. The address can carry a password. prusactl masks it wherever it shows the address, including in the error for an address that does not parse, and in `internal/redact`. Only plain `rtsp://` is accepted. With ffmpeg 7.1.5, a self-signed certificate completes the TLS handshake and ffmpeg sends the credentials, even with `-tls_verify 1`. Therefore `rtsps://` is refused. A picture that was cut short is refused. A scheme in capitals is lowercased, because ffmpeg needs this. The config file is now read in one place (`readFile`). Saving the printer keeps the camera, and saving the camera keeps the printer. The Connect snapshot moved into `connectSnapshot`. Both routes share one reader for the image they get back. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Petr Štetiar --- CHANGELOG.md | 10 ++ README.md | 53 +++++-- cmd/prusactl/help.go | 18 ++- cmd/prusactl/main.go | 72 +++++++++- cmd/prusactl/setup_test.go | 73 ++++++++++ internal/camera/camera.go | 168 ++++++++++++++++++++++ internal/camera/camera_test.go | 196 ++++++++++++++++++++++++++ internal/link/config.go | 107 +++++++++----- internal/redact/redact.go | 10 +- internal/redact/redact_test.go | 23 +++ internal/server/camera.go | 173 +++++++++++++++++++++++ internal/server/camera_test.go | 153 ++++++++++++++++++++ internal/server/connect_tools_test.go | 11 +- internal/server/server.go | 8 +- internal/server/tools_printers.go | 148 +++++++++---------- internal/server/tools_status.go | 12 +- internal/server/tools_test.go | 14 +- 17 files changed, 1121 insertions(+), 128 deletions(-) create mode 100644 internal/camera/camera.go create mode 100644 internal/camera/camera_test.go create mode 100644 internal/server/camera.go create mode 100644 internal/server/camera_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd524c..06ce148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- **The camera works without Prusa Connect.** The Buddy3D camera is a separate + Wi-Fi device that talks to Connect itself and serves RTSP at + `rtsp:///live`; Buddy firmware's PrusaLink has no camera endpoint, + which is why `get_camera_snapshot` and `prusactl camera` used to need + Connect. `prusactl setup --camera rtsp://...` (or `PRUSACTL_CAMERA_URL`) saves + the address and prusactl grabs a frame with ffmpeg. A PrusaLink that serves + `/api/v1/cameras/snap` is used first, and Connect is still the fallback. + `prusactl status` shows the saved camera. Only plain `rtsp://` addresses are + accepted (ffmpeg doesn't verify the certificate of `rtsps://`), and a password + in the address is masked wherever prusactl shows it. - `prusactl login --manual` signs in without a password passing through the terminal, so an AI agent can run it. It prints the Prusa Account link on its own line, then reads the address the browser ends on diff --git a/README.md b/README.md index 7319031..73d3a8a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ `prusactl mcp` gives an agent such as Claude hands on your printer. It can: -- **Watch:** check state, temperatures, job progress, and (with Connect) the camera. +- **Watch:** check state, temperatures, job progress, and the camera (over RTSP with + `setup --camera`, or through Connect). - **Print:** upload files, start them, pause, resume, and stop, and (with Connect) queue them. - **Control:** heat, home, move, load and unload filament, and level the bed: @@ -41,8 +42,9 @@ It is one Go binary; no browser is needed. Setup is two terminal prompts. > > What did I print this week, and how many failed? -The camera, cancelling one object, reading a dialog, and print history go -through Prusa Connect, so those need `prusactl login`; the rest works directly. +Cancelling one object, reading a dialog, and print history go through Prusa +Connect, so those need `prusactl login`; the rest works directly. So does the +camera, once you tell prusactl where it is (see [The camera](#the-camera)). ## Install @@ -103,6 +105,7 @@ Linux, `%AppData%` on Windows); delete it too if you like. prusactl setup 192.168.1.50 # the printer's address; asks for its PrusaLink password prusactl status +prusactl setup --camera rtsp://192.168.1.60/live # optional: the camera, no Connect needed prusactl login # optional: Prusa Connect, for remote access, camera, dialogs ``` @@ -166,13 +169,43 @@ prusactl reaches the printer two ways, and each tool picks one: | Status, files, upload, print, pause/resume/stop | ✅ | ✅ | | Heat, move, filament, leveling | ✅ via `run_gcode` (printer idle) | ✅ via firmware commands | | Any G-code | ✅ | ❌ | -| Camera, on-screen dialogs, queue, history, events | ❌ | ✅ | +| Camera | ✅ via RTSP and ffmpeg, once its address is saved | ✅ | +| On-screen dialogs, queue, history, events | ❌ | ✅ | The direct route is used whenever the printer answers. Otherwise, or for Connect-only features, the tool goes through Connect. Results from tools that can take either route say which one they used (`via`); the rest always use the route their feature needs. +### The camera + +The Buddy3D camera is not part of the printer. It is a separate Wi-Fi device that +talks to Prusa Connect on its own and serves a video stream at +`rtsp:///live`; the printer's PrusaLink has no camera endpoint (Buddy +firmware serves none), so a direct connection alone can't show it. Tell prusactl +where the camera is and it grabs one frame at a time with +[ffmpeg](https://ffmpeg.org), which must be on your `PATH`: + +```sh +prusactl setup --camera rtsp://192.168.1.60/live # find the camera's IP in your router +prusactl camera snapshot.jpg +``` + +A snapshot comes from the first source that works: the printer's own +`/api/v1/cameras/snap`, if its PrusaLink has one (other PrusaLink builds do); +the saved RTSP address; then Prusa Connect. `via: "direct"` limits it to the +first two, `via: "connect"` to Connect. `PRUSACTL_CAMERA_URL` overrides the saved +address, and a password in the address is masked wherever prusactl shows it. + +Only plain `rtsp://` addresses are accepted. `rtsps://` is refused on purpose: +FFmpeg 7.1.5 completes the TLS handshake with a self-signed certificate and sends +the camera's credentials even with `-tls_verify 1` and `-ca_file`, because its RTSP +code never hands those options to the TLS layer. The Buddy3D camera serves plain +RTSP, so this costs nothing there. Residual risk: plain RTSP sends the password +(and the video) unencrypted, so anyone on your network can read it; use a camera +account that only has access to the picture, and keep the camera off untrusted +networks. + ### Signing in to Prusa Connect `prusactl login` asks for your Prusa Account email and password, plus a 2FA @@ -219,7 +252,7 @@ The printer's PrusaLink password is kept in the same keychain. | `control_print` | both | Pause, resume, or stop | | `get_transfers` | both | File transfers in progress | | `run_gcode` | direct | Run G-code, such as heating, homing, moving, or filament changes, while the printer is idle | -| `get_camera_snapshot` | Connect | Latest camera image, with how old it is | +| `get_camera_snapshot` | both | A camera image: from the camera's RTSP stream if its address is saved, else Connect's latest, with how old it is | | `respond_to_dialog` | Connect | Press a button on the printer's screen | | `list_supported_commands`, `send_command`, `get_command` | Connect | Every firmware command Connect exposes, with arguments and allowed states | | `get_queue`, `add_to_queue`, `remove_from_queue` | Connect | The print queue | @@ -254,7 +287,7 @@ prusactl jobs [JOB-ID] print history, or one job prusactl events the printer's recent events prusactl telemetry recorded temperatures and speeds prusactl transfers file transfers in progress -prusactl camera [FILE] save a snapshot from the printer's camera +prusactl camera [FILE] save a snapshot from the printer's camera (RTSP or Connect) prusactl cmd ls|send|status run a firmware command through Prusa Connect prusactl api [METHOD] PATH [JSON] /api/... to the printer, /app/... to Prusa Connect @@ -279,9 +312,10 @@ flags, and flags work before or after the arguments. Tab completion covers commands, flags, and their values: Homebrew installs it for bash, zsh, and fish, and `prusactl help completion` shows how to add it otherwise. -`prusactl setup --forget` removes the saved printer. `--api-key` uses a PrusaLink -API key instead of the password, and `--password-stdin` reads the secret from a -pipe. +`prusactl setup --forget` removes the saved printer and camera. `--api-key` uses +a PrusaLink API key instead of the password, `--password-stdin` reads the secret +from a pipe, and `--camera URL` saves the camera's RTSP address (on its own it +leaves the printer alone; `--no-camera` forgets just the camera). `prusactl api` masks API keys and tokens in responses (Connect's printer record carries the PrusaLink and Connect keys), so its output is safe to paste or hand @@ -317,6 +351,7 @@ to an agent. `--raw` shows them. | `PRUSACTL_HOST`, `PRUSACTL_USER`, `PRUSACTL_AUTH` | Override the saved printer address, username, or `digest`/`api-key` | | `PRUSACTL_PASSWORD`, `PRUSACTL_API_KEY` | Supply the printer secret instead of the keychain | | `PRUSACTL_KEYRING=file` | Keep credentials in `secrets.json` (mode 0600) instead of the OS keychain | +| `PRUSACTL_CAMERA_URL` | Override the saved camera's RTSP address, e.g. `rtsp://192.168.1.60/live` | | `PRUSACTL_CONFIG` | Alternate config file (default: `prusactl/config.json` in the OS config dir) | | `PRUSA_CONNECT_URL`, `PRUSA_ACCOUNT_URL` | Connect and Prusa Account origins | | `PRUSA_CLIENT_ID`, `PRUSA_REDIRECT_URI` | The OAuth client (default: the Connect web app's) | diff --git a/cmd/prusactl/help.go b/cmd/prusactl/help.go index ee20809..9bc2d9f 100644 --- a/cmd/prusactl/help.go +++ b/cmd/prusactl/help.go @@ -50,12 +50,19 @@ var commands = []*command{ password (or API key) in your keychain, or in a private file on machines without one. ADDRESS is its IP address or hostname, e.g. 192.168.1.50; you're asked for it if you leave it out. The password is on the printer under -Settings > Network > PrusaLink.`, +Settings > Network > PrusaLink. + +--camera saves the address of the printer's camera (rtsp:///live), so +the camera works without Prusa Connect. Frames are grabbed with ffmpeg, which +must be installed. Only plain rtsp:// is accepted. On its own, without an +ADDRESS, --camera only saves the camera and leaves the printer setup alone.`, Flags: []flagSpec{ {Name: "user", Value: "NAME", Default: "maker", Usage: "PrusaLink username"}, {Name: "api-key", Usage: "use a PrusaLink API key instead of the password"}, {Name: "password-stdin", Usage: "read the password or API key from stdin"}, - {Name: "forget", Usage: "remove the saved printer and its password"}, + {Name: "camera", Value: "URL", Usage: "save the camera's RTSP address, e.g. rtsp://192.168.1.60/live"}, + {Name: "no-camera", Usage: "forget the saved camera address and nothing else"}, + {Name: "forget", Usage: "remove the saved printer, its password, and the saved camera"}, }, Pos: []posArg{{Label: "printer address", Kind: "hosts"}}, }, @@ -263,7 +270,12 @@ used; with several, name one with --printer, or give --team directly.`, Args: "[FILE]", Summary: "Save a snapshot from the printer's camera", Help: `Writes snapshot.jpg unless you name a file, or pipe it somewhere. -Needs Prusa Connect and a camera.`, + +The picture comes from the first of these that works: the printer's own +camera API (Buddy firmware has none), the camera's RTSP stream if you saved +its address with ` + "`prusactl setup --camera`" + ` (or PRUSACTL_CAMERA_URL; needs ffmpeg), +or Prusa Connect. --via direct allows only the first two, --via connect only +the last.`, Flags: flags(printerFlags, []flagSpec{{Name: "camera", Value: "ID", Usage: "which camera, when there are several"}}), Pos: []posArg{{Label: "file", Kind: "files"}}, }, diff --git a/cmd/prusactl/main.go b/cmd/prusactl/main.go index b8c5f5c..7f2d71f 100644 --- a/cmd/prusactl/main.go +++ b/cmd/prusactl/main.go @@ -13,6 +13,7 @@ import ( "flag" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -26,6 +27,7 @@ import ( "golang.org/x/term" "github.com/trevin-lee/prusactl/internal/auth" + "github.com/trevin-lee/prusactl/internal/camera" "github.com/trevin-lee/prusactl/internal/compat" "github.com/trevin-lee/prusactl/internal/connect" "github.com/trevin-lee/prusactl/internal/link" @@ -211,10 +213,47 @@ func setup(ctx context.Context, args []string) error { return errors.New("setup: usage: prusactl setup [flags] [ADDRESS] (see `prusactl help setup`)") } user, apiKey, fromStdin := opts.str("user"), opts.on("api-key"), opts.on("password-stdin") + cameraURL := opts.str("camera") + if cameraURL != "" { + var err error + if cameraURL, err = camera.Validate(cameraURL); err != nil { + return err + } + } + if opts.on("no-camera") { + if cameraURL != "" || opts.on("forget") || len(pos) > 0 { + return errors.New("setup: --no-camera stands alone; it forgets the camera and changes nothing else") + } + if err := link.SaveCamera(""); err != nil { + return err + } + fmt.Println("Forgot the saved camera address.") + return nil + } + if cameraURL != "" && len(pos) == 0 && !opts.on("forget") { + // Only the camera: leave the printer, and its password, alone. + if err := link.SaveCamera(cameraURL); err != nil { + return err + } + reportCamera(cameraURL) + return nil + } if opts.on("forget") { cfg, err := link.SavedConfig() // what setup saved, not PRUSACTL_* overrides if errors.Is(err, link.ErrNotConfigured) { - fmt.Println("No printer is set up.") + // A camera saved on its own, with its credentials, goes too. + saved, cerr := link.LoadCamera() + if cerr != nil { + return cerr + } + if err := link.SaveCamera(""); err != nil { + return err + } + if saved != "" && os.Getenv("PRUSACTL_CAMERA_URL") == "" { + fmt.Println("No printer is set up; forgot the saved camera address.") + } else { + fmt.Println("No printer is set up.") + } return nil } if err != nil { @@ -299,9 +338,36 @@ func setup(ctx context.Context, args []string) error { if prevErr == nil && prev.Host != cfg.Host { fmt.Printf("This replaces %s: prusactl reaches one printer directly (others work through Prusa Connect).\n", prev.Host) } + if cameraURL != "" { + if err := link.SaveCamera(cameraURL); err != nil { + return err + } + reportCamera(cameraURL) + } return nil } +// reportCamera confirms a saved camera address, and warns about what would +// stop it working: ffmpeg missing, or nothing listening at the address. +func reportCamera(addr string) { + fmt.Printf("Saved the camera at %s.\n", camera.Mask(addr)) + if _, err := exec.LookPath("ffmpeg"); err != nil { + fmt.Fprintln(os.Stderr, "warning: ffmpeg isn't on PATH; it grabs the pictures, so install it before using the camera") + } + if u, err := url.Parse(addr); err == nil { + port := u.Port() + if port == "" { + port = "554" + } + c, err := net.DialTimeout("tcp", net.JoinHostPort(u.Hostname(), port), 3*time.Second) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: nothing answered at %s:%s: %v\n", u.Hostname(), port, err) + return + } + c.Close() + } +} + // terminalPrompter asks for Prusa Account details on the terminal. host is // where the password will be sent, which PRUSA_ACCOUNT_URL can change. type terminalPrompter struct{ host string } @@ -412,6 +478,10 @@ func status(ctx context.Context, srv *server.Server, lc *link.Client) error { fmt.Printf("Prusa Connect: not signed in (%v)\n", orText(cloud["error"], cloud["setup"])) } + if cam, _ := st["camera"].(map[string]any); cam["configured"] == true { + fmt.Printf("Camera (RTSP): %v\n", cam["rtsp"]) + } + // Without the direct route, show the printers as Prusa Connect sees them. if direct["reachable"] != true && cloud["signed_in"] == true { printers, err := srv.ConnectPrinters(ctx) diff --git a/cmd/prusactl/setup_test.go b/cmd/prusactl/setup_test.go index c296444..9f44d05 100644 --- a/cmd/prusactl/setup_test.go +++ b/cmd/prusactl/setup_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/trevin-lee/prusactl/internal/appdir/appdirtest" + "github.com/trevin-lee/prusactl/internal/link" ) // standIn answers the PrusaLink info endpoint like a printer would. @@ -85,3 +86,75 @@ func contains(s, sub string) bool { } return false } + +// The camera is saved beside the printer, not instead of it: re-running setup +// keeps the camera, --camera alone keeps the printer, and PRUSACTL_CAMERA_URL +// wins over what was saved. +func TestSetupSavesTheCameraBesideThePrinter(t *testing.T) { + home := appdirtest.Use(t) + t.Setenv("PRUSACTL_KEYRING", "file") + t.Setenv("PRUSACTL_CONFIG", filepath.Join(home, "config.json")) + srv := standIn(t) + + // Only the camera: no printer address, no password asked. + if err := setup(context.Background(), []string{"--camera", "rtsp://127.0.0.1:1/live"}); err != nil { + t.Fatal(err) + } + if got, _ := link.LoadCamera(); got != "rtsp://127.0.0.1:1/live" { + t.Fatalf("saved camera %q", got) + } + if _, err := link.SavedConfig(); err == nil { + t.Fatal("saving only the camera set up a printer") + } + + runSetup(t, "pw", srv.URL) + if got, _ := link.LoadCamera(); got != "rtsp://127.0.0.1:1/live" { + t.Errorf("setup dropped the camera: %q", got) + } + if cfg, err := link.SavedConfig(); err != nil || cfg.Host != srv.URL { + t.Errorf("printer %v, %v", cfg, err) + } + + t.Setenv("PRUSACTL_CAMERA_URL", "rtsp://127.0.0.1:2/live") + if got, _ := link.LoadCamera(); got != "rtsp://127.0.0.1:2/live" { + t.Errorf("the environment should win: %q", got) + } + t.Setenv("PRUSACTL_CAMERA_URL", "") + + if err := setup(context.Background(), []string{"--no-camera"}); err != nil { + t.Fatal(err) + } + if got, _ := link.LoadCamera(); got != "" { + t.Errorf("--no-camera left %q", got) + } + if _, err := link.SavedConfig(); err != nil { + t.Errorf("--no-camera forgot the printer: %v", err) + } + + for _, bad := range [][]string{{"--camera", "http://x/live"}, {"--camera", "x", "--no-camera"}, {"--no-camera", srv.URL}} { + if err := setup(context.Background(), bad); err == nil { + t.Errorf("setup %v should be refused", bad) + } + } +} + +// --forget with no printer set up still removes a camera saved on its own. +func TestSetupForgetRemovesACameraSavedAlone(t *testing.T) { + home := appdirtest.Use(t) + t.Setenv("PRUSACTL_KEYRING", "file") + cfgPath := filepath.Join(home, "config.json") + t.Setenv("PRUSACTL_CONFIG", cfgPath) + + if err := setup(context.Background(), []string{"--camera", "rtsp://cam:pw@127.0.0.1:1/live"}); err != nil { + t.Fatal(err) + } + if err := setup(context.Background(), []string{"--forget"}); err != nil { + t.Fatal(err) + } + if got, _ := link.LoadCamera(); got != "" { + t.Errorf("--forget left the camera %q", got) + } + if _, err := os.Stat(cfgPath); !os.IsNotExist(err) { + t.Errorf("config file should be gone: %v", err) + } +} diff --git a/internal/camera/camera.go b/internal/camera/camera.go new file mode 100644 index 0000000..435a736 --- /dev/null +++ b/internal/camera/camera.go @@ -0,0 +1,168 @@ +// Package camera grabs one picture from a camera that serves RTSP, such as the +// Prusa Buddy3D camera (rtsp:///live). The camera is a separate +// device on the network, not part of the printer, so PrusaLink can't hand out +// its pictures; ffmpeg does the grabbing. +package camera + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/url" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/trevin-lee/prusactl/internal/redact" +) + +// Timeout bounds one grab: connecting, waiting for a key frame, decoding. +const Timeout = 30 * time.Second + +// maxImage caps the picture read from ffmpeg. +const maxImage = 16 << 20 + +// ErrNoFFmpeg means the grab needs ffmpeg and it isn't installed. +var ErrNoFFmpeg = errors.New("grabbing a frame from the camera needs ffmpeg, which isn't on PATH; install it (e.g. `brew install ffmpeg` or `apt install ffmpeg`)") + +// Grabber runs ffmpeg. The zero value looks ffmpeg up on PATH. +type Grabber struct { + // Binary is the ffmpeg to run; empty means "ffmpeg" from PATH. Tests point + // it at a stand-in. + Binary string + // Env, when set, is the environment ffmpeg runs in (in addition to the + // parent's). + Env []string +} + +// ErrRTSPS means the address asks for TLS, which ffmpeg can't be made to check. +var ErrRTSPS = errors.New("rtsps:// cameras aren't supported: ffmpeg does not verify the camera's TLS certificate (its RTSP code never passes -tls_verify or -ca_file on to TLS, tested on 7.1.5), so anyone on the network could pose as the camera and read the password; use the camera's plain rtsp:// address") + +// Validate checks that raw is an rtsp:// address, and returns it with the +// scheme in lowercase. An rtsps:// +// address is refused, see ErrRTSPS. +func Validate(raw string) (string, error) { + raw = strings.TrimSpace(raw) + u, err := url.Parse(raw) + if err == nil && u.Host != "" && strings.EqualFold(u.Scheme, "rtsps") { + return "", ErrRTSPS + } + if err != nil || u.Host == "" || !strings.EqualFold(u.Scheme, "rtsp") { + return "", fmt.Errorf("not a camera address: %q (expected rtsp:///live)", maskAny(raw)) + } + // ffmpeg only knows the lowercase scheme. + return "rtsp" + raw[len("rtsp"):], nil +} + +// userinfo finds everything between "rtsp://" and the last @ on the line. It is +// greedier than redact's pattern on purpose: an address url.Parse refuses (a +// space, a quote or a slash in the password) has no reliable end for the +// password, so all of it goes, at the cost of hiding some of the host. +var userinfo = regexp.MustCompile(`(?is)(rtsps?://).*@`) + +// anyUserinfo is userinfo for whatever the person typed: any scheme, or none. +var anyUserinfo = regexp.MustCompile(`(?is)^([a-z][a-z0-9+.-]*://)?.*@`) + +// maskAny hides what comes before the last @ in an address that may not be an +// RTSP address at all (a typo in the scheme must not leak the password). +func maskAny(s string) string { + return Mask(anyUserinfo.ReplaceAllString(s, "${1}"+redact.Mask+"@")) +} + +// Mask hides a password in an RTSP address, for anything shown to a person or +// a model. It works on text that isn't a valid URL too, since the address came +// from a person or a config file and may be malformed. +func Mask(s string) string { + return redact.Text(userinfo.ReplaceAllString(s, "${1}"+redact.Mask+"@")) +} + +// args is ffmpeg's command line: one frame over TCP (UDP loses packets on +// Wi-Fi), as a JPEG on stdout. It is a list, never a shell command. The address +// is plain rtsp://; rtsps:// never gets this far (see ErrRTSPS). +func args(rtsp string) []string { + return []string{ + "-nostdin", "-hide_banner", "-loglevel", "error", + "-rtsp_transport", "tcp", + "-i", rtsp, + "-frames:v", "1", "-q:v", "3", + "-f", "image2pipe", "-c:v", "mjpeg", "pipe:1", + } +} + +// completeJPEG reports whether img starts with a JPEG's start marker and ends +// with its end marker, so a picture cut short isn't passed on. +func completeJPEG(img []byte) bool { + n := len(img) + return n >= 4 && img[0] == 0xFF && img[1] == 0xD8 && img[n-2] == 0xFF && img[n-1] == 0xD9 +} + +// Snapshot returns one JPEG frame from the RTSP stream at rtsp. +func (g Grabber) Snapshot(ctx context.Context, rtsp string) ([]byte, error) { + rtsp, err := Validate(rtsp) + if err != nil { + return nil, err + } + bin := g.Binary + if bin == "" { + if bin, err = exec.LookPath("ffmpeg"); err != nil { + return nil, ErrNoFFmpeg + } + } + ctx, cancel := context.WithTimeout(ctx, Timeout) + defer cancel() + cmd := exec.CommandContext(ctx, bin, args(rtsp)...) + if len(g.Env) > 0 { + cmd.Env = append(cmd.Environ(), g.Env...) + } + var out, stderr bytes.Buffer + lw := &limitWriter{w: &out, n: maxImage} + cmd.Stdout = lw + cmd.Stderr = &limitWriter{w: &stderr, n: 4 << 10} + runErr := cmd.Run() + img := out.Bytes() + // ffmpeg complains about the frames before the first key frame yet still + // delivers the picture, so what counts is whether a whole JPEG came out. + if !lw.over && completeJPEG(img) { + return img, nil + } + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("the camera at %s sent no picture within %s", Mask(rtsp), Timeout) + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + msg := strings.TrimSpace(Mask(strings.ReplaceAll(stderr.String(), rtsp, Mask(rtsp)))) + if msg == "" && lw.over { + msg = fmt.Sprintf("the picture is over %d MB", maxImage>>20) + } + if msg == "" && len(img) > 1 && img[0] == 0xFF && img[1] == 0xD8 { + msg = "ffmpeg's picture was cut short" + } + if msg == "" && runErr != nil { + msg = runErr.Error() + } + if msg == "" { + msg = "ffmpeg produced no picture" + } + return nil, fmt.Errorf("couldn't grab a frame from %s: %s", Mask(rtsp), msg) +} + +// limitWriter stops accepting bytes after n, so a runaway child can't fill +// memory; the surplus is dropped without an error so the child isn't killed by +// a broken pipe mid-frame. +type limitWriter struct { + w *bytes.Buffer + n int + over bool // bytes were dropped +} + +func (l *limitWriter) Write(p []byte) (int, error) { + room := max(l.n-l.w.Len(), 0) + l.w.Write(p[:min(len(p), room)]) + if len(p) > room { + l.over = true + } + return len(p), nil +} diff --git a/internal/camera/camera_test.go b/internal/camera/camera_test.go new file mode 100644 index 0000000..2f9726c --- /dev/null +++ b/internal/camera/camera_test.go @@ -0,0 +1,196 @@ +package camera + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" +) + +// The test binary doubles as a stand-in ffmpeg: Grabber.Binary points at it and +// FAKE_FFMPEG says how to behave, so the real exec path is what runs. +func TestMain(m *testing.M) { + switch os.Getenv("FAKE_FFMPEG") { + case "": + os.Exit(m.Run()) + case "ok": + // Record the arguments on stderr (ffmpeg's log channel) and answer a + // JPEG, with the noise real ffmpeg makes before the first key frame. + fmt.Fprintln(os.Stderr, "DecodeFrame failed") + os.Stdout.Write([]byte{0xFF, 0xD8, 0xFF, 0xE0, 'j', 'f', 'i', 'f', 0xFF, 0xD9}) + if os.Getenv("FAKE_ARGS_FILE") != "" { + os.WriteFile(os.Getenv("FAKE_ARGS_FILE"), []byte(strings.Join(os.Args[1:], "\n")), 0o600) + } + case "fail": + fmt.Fprintln(os.Stderr, "rtsp://cam:hunter2@10.0.0.9/live: Connection refused") + os.Exit(1) + case "garbage": + os.Stdout.Write([]byte("not an image")) + case "truncated": + os.Stdout.Write([]byte{0xFF, 0xD8, 0xFF, 0xE0, 'j', 'f', 'i', 'f'}) + case "huge": + os.Stdout.Write([]byte{0xFF, 0xD8}) + os.Stdout.Write(make([]byte, maxImage)) + os.Stdout.Write([]byte{0xFF, 0xD9}) + case "hang": + select {} + } + os.Exit(0) +} + +func TestSnapshotRunsFFmpegWithAnArgumentList(t *testing.T) { + argsFile := t.TempDir() + "/args" + g := Grabber{Binary: os.Args[0], Env: []string{"FAKE_FFMPEG=ok", "FAKE_ARGS_FILE=" + argsFile}} + img, err := g.Snapshot(context.Background(), "rtsp://10.0.0.9/live") + if err != nil { + t.Fatal(err) + } + if len(img) < 3 || img[0] != 0xFF || img[1] != 0xD8 { + t.Fatalf("not a JPEG: %x", img) + } + raw, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + got := strings.Split(string(raw), "\n") + want := []string{"-rtsp_transport", "tcp", "-i", "rtsp://10.0.0.9/live", "-frames:v", "1"} + for i := range got { + if got[i] == want[0] { + if strings.Join(got[i:i+len(want)], " ") != strings.Join(want, " ") { + t.Errorf("args %v, want ...%v", got, want) + } + return + } + } + t.Errorf("args %v lack %v", got, want) +} + +func TestSnapshotErrors(t *testing.T) { + for _, tc := range []struct { + name, mode, url, want string + }{ + {"ffmpeg fails", "fail", "rtsp://cam:hunter2@10.0.0.9/live", "Connection refused"}, + {"not an image", "garbage", "rtsp://10.0.0.9/live", "ffmpeg produced no picture"}, + {"cut short", "truncated", "rtsp://10.0.0.9/live", "cut short"}, + {"over the cap", "huge", "rtsp://10.0.0.9/live", "over 16 MB"}, + {"not rtsp", "ok", "http://10.0.0.9/live", "not a camera address"}, + {"flag lookalike", "ok", "-i rtsp://x", "not a camera address"}, + } { + g := Grabber{Binary: os.Args[0], Env: []string{"FAKE_FFMPEG=" + tc.mode}} + _, err := g.Snapshot(context.Background(), tc.url) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: %v, want %q", tc.name, err, tc.want) + } + if err != nil && strings.Contains(err.Error(), "hunter2") { + t.Errorf("%s: the camera password leaked: %v", tc.name, err) + } + } +} + +func TestSnapshotNeedsFFmpeg(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + _, err := Grabber{}.Snapshot(context.Background(), "rtsp://10.0.0.9/live") + if !errors.Is(err, ErrNoFFmpeg) { + t.Fatalf("got %v, want ErrNoFFmpeg", err) + } +} + +func TestSnapshotGivesUp(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + g := Grabber{Binary: os.Args[0], Env: []string{"FAKE_FFMPEG=hang"}} + if _, err := g.Snapshot(ctx, "rtsp://10.0.0.9/live"); !errors.Is(err, context.Canceled) { + t.Fatalf("got %v, want the cancellation", err) + } +} + +// FFmpeg 7.1.5 doesn't check the certificate of an rtsps:// camera even with +// -tls_verify 1 and -ca_file: its RTSP code doesn't pass them on to TLS. So +// rtsps:// is refused before ffmpeg runs. +func TestRTSPSIsRefused(t *testing.T) { + for _, in := range []string{"rtsps://10.0.0.9/live", "RTSPS://cam:pw@10.0.0.9/live"} { + if _, err := Validate(in); !errors.Is(err, ErrRTSPS) { + t.Errorf("Validate(%q) = %v, want ErrRTSPS", in, err) + } + if strings.Contains(fmt.Sprint(Validate(in)), "pw") { + t.Errorf("the refusal shows the password") + } + // The fake ffmpeg leaves its arguments in a file when it runs. + ran := t.TempDir() + "/ran" + g := Grabber{Binary: os.Args[0], Env: []string{"FAKE_FFMPEG=ok", "FAKE_ARGS_FILE=" + ran}} + if _, err := g.Snapshot(context.Background(), in); !errors.Is(err, ErrRTSPS) { + t.Errorf("Snapshot(%q) = %v, want ErrRTSPS", in, err) + } + if _, err := os.Stat(ran); err == nil { + t.Errorf("Snapshot(%q) ran ffmpeg", in) + } + } + if _, err := Validate("rtsp://10.0.0.9/live"); err != nil { + t.Errorf("plain rtsp refused: %v", err) + } +} + +// ffmpeg doesn't know an uppercase scheme ("Protocol not found"), so Validate +// lowercases it and leaves the rest alone. +func TestValidateLowercasesTheScheme(t *testing.T) { + for in, want := range map[string]string{ + "RTSP://10.0.0.9/live": "rtsp://10.0.0.9/live", + " RtSp://cam:PassWord@10.0.0.9/Live ": "rtsp://cam:PassWord@10.0.0.9/Live", + } { + if got, err := Validate(in); err != nil || got != want { + t.Errorf("Validate(%q) = %q, %v, want %q", in, got, err, want) + } + } +} + +// An address url.Parse refuses must not leak its password through the error, +// whatever the password holds or the scheme is. A leak shows up as the secret +// itself or as its tail. +func TestValidateErrorsNeverShowAPassword(t *testing.T) { + for _, in := range []string{ + "rtsp://cam:my secret@10.0.0.9/live", + `rtsp://cam:pa"ss@10.0.0.9/live`, + "rtsp://cam:pa/ss@10.0.0.9/live", + "rtsp://cam:pa\x7fss@10.0.0.9/live", + "rtsps://cam:se cret@10.0.0.9", + "http://cam:secret@10.0.0.9/live", + "cam:secret@10.0.0.9/live", + "rtsp://cam:secret%zz@10.0.0.9/live", + "rtsp://cam:pa\nss@10.0.0.9/live", + "rtsp://cam:pa\r\nss@10.0.0.9/live", + "rtsp://cam:pa\rss@10.0.0.9/live", + } { + _, err := Validate(in) + if err == nil { + t.Errorf("Validate(%q) accepted a bad address", in) + continue + } + for _, leak := range []string{"secret", "my sec", "ss@", "pa\"", "pa/", "hun", "ter2", "cam:"} { + if strings.Contains(err.Error(), leak) { + t.Errorf("Validate(%q) error shows %q: %v", in, leak, err) + } + } + if !strings.Contains(err.Error(), "10.0.0.9") { + t.Errorf("Validate(%q) error lost the host: %v", in, err) + } + } +} + +// Mask is used on saved addresses and on ffmpeg's output, neither of which is +// guaranteed to be a clean URL. +func TestMaskHidesPasswordsInMalformedAddresses(t *testing.T) { + for in, want := range map[string]string{ + "rtsp://cam:my secret@10.0.0.9/live": "rtsp://[redacted]@10.0.0.9/live", + "rtsp://cam:pa/ss@10.0.0.9/live: failed": "rtsp://[redacted]@10.0.0.9/live: failed", + `rtsp://cam:pa"ss@10.0.0.9/live`: "rtsp://[redacted]@10.0.0.9/live", + "rtsp://10.0.0.9/live": "rtsp://10.0.0.9/live", + "rtsp://cam:pa\nss@10.0.0.9/live": "rtsp://[redacted]@10.0.0.9/live", + "rtsp://cam:pa\r\nss@10.0.0.9/live": "rtsp://[redacted]@10.0.0.9/live", + } { + if got := Mask(in); got != want { + t.Errorf("Mask(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/link/config.go b/internal/link/config.go index 163bcfe..1348b46 100644 --- a/internal/link/config.go +++ b/internal/link/config.go @@ -35,6 +35,66 @@ type Config struct { type fileFormat struct { Printer *Config `json:"printer,omitempty"` + // Camera is the RTSP address of the printer's camera, e.g. + // rtsp://192.168.8.60/live. It is a separate device, so it is saved apart + // from the printer. + Camera string `json:"camera,omitempty"` +} + +// readFile reads the config file; a missing one is empty. +func readFile() (fileFormat, string, error) { + var f fileFormat + path, err := configPath() + if err != nil { + return f, "", err + } + b, err := os.ReadFile(path) + switch { + case errors.Is(err, fs.ErrNotExist): + return f, path, nil + case err != nil: + return f, path, err + } + if err := json.Unmarshal(b, &f); err != nil { + return f, path, fmt.Errorf("reading %s: %w", path, err) + } + return f, path, nil +} + +func writeFile(path string, f fileFormat) error { + b, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(b, '\n'), 0o600) +} + +// LoadCamera returns the camera's RTSP address: PRUSACTL_CAMERA_URL if set, +// else the one saved by `prusactl setup --camera`, else "". +func LoadCamera() (string, error) { + if v := env("PRUSACTL_CAMERA_URL"); v != "" { + return v, nil + } + f, _, err := readFile() + return f.Camera, err +} + +// SaveCamera saves the camera's address, or forgets it when addr is empty, +// leaving the saved printer alone. +func SaveCamera(addr string) error { + f, path, err := readFile() + if err != nil { + return err + } + f.Camera = addr + if f.Printer == nil && addr == "" { + // Nothing left to keep. + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil + } + return writeFile(path, f) } // NormalizeHost turns "192.168.8.162" or "prusa.local:8080" into an origin. @@ -77,24 +137,13 @@ func configPath() (string, error) { // LoadConfig reads the saved printer, applying PRUSACTL_HOST / PRUSACTL_USER / // PRUSACTL_AUTH overrides. It returns ErrNotConfigured if there is none. func LoadConfig() (Config, error) { - var cfg Config - path, err := configPath() + f, _, err := readFile() if err != nil { - return cfg, err + return Config{}, err } - b, err := os.ReadFile(path) - switch { - case errors.Is(err, fs.ErrNotExist): - case err != nil: - return cfg, err - default: - var f fileFormat - if err := json.Unmarshal(b, &f); err != nil { - return cfg, fmt.Errorf("reading %s: %w", path, err) - } - if f.Printer != nil { - cfg = *f.Printer - } + var cfg Config + if f.Printer != nil { + cfg = *f.Printer } if v := env("PRUSACTL_HOST"); v != "" { cfg.Host = v @@ -122,35 +171,23 @@ func LoadConfig() (Config, error) { // SaveConfig writes the printer address (never the secret). func SaveConfig(cfg Config) error { - path, err := configPath() - if err != nil { + f, path, err := readFile() + if path == "" { return err } - b, err := json.MarshalIndent(fileFormat{Printer: &cfg}, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, append(b, '\n'), 0o600) + // An unreadable file is replaced, as before; a readable one keeps the + // camera saved beside the printer. + f.Printer = &cfg + return writeFile(path, f) } // SavedConfig returns the printer saved by `prusactl setup`, ignoring the // PRUSACTL_* overrides, or ErrNotConfigured. func SavedConfig() (Config, error) { - path, err := configPath() - if err != nil { - return Config{}, err - } - b, err := os.ReadFile(path) - if errors.Is(err, fs.ErrNotExist) { - return Config{}, ErrNotConfigured - } + f, _, err := readFile() if err != nil { return Config{}, err } - var f fileFormat - if err := json.Unmarshal(b, &f); err != nil { - return Config{}, fmt.Errorf("reading %s: %w", path, err) - } if f.Printer == nil || f.Printer.Host == "" { return Config{}, ErrNotConfigured } diff --git a/internal/redact/redact.go b/internal/redact/redact.go index 7a1b975..1a56f4c 100644 --- a/internal/redact/redact.go +++ b/internal/redact/redact.go @@ -19,6 +19,13 @@ var key = regexp.MustCompile(`(?i)api_?key|token|password|secret`) // truncated response: group 1 is the name and colon, group 2 the value. var pair = regexp.MustCompile(`(?i)("[^"]*(?:api_?key|token|password|secret)[^"]*"\s*:\s*)("(?:[^"\\]|\\.)*"?|[^,}\]\s]+)`) +// rtspUser finds the user:password in a camera address such as +// rtsp://user:password@192.168.1.5/live. The password may hold an @, so the +// userinfo runs to the last @ before the path, and it may hold spaces, a line break or a +// JSON-escaped quote, which is how a malformed address that url.Parse refuses +// shows up in an error. +var rtspUser = regexp.MustCompile(`(?is)(rtsps?://)(?:[^/"\\]|\\.)+@`) + // Value masks credential fields in a decoded JSON value, in place, and // returns it. func Value(v any) any { @@ -67,8 +74,9 @@ func JSON(raw []byte) ([]byte, bool) { // error message. func Text(s string) string { if out, changed := JSON([]byte(s)); changed { - return string(out) + return rtspUser.ReplaceAllString(string(out), "${1}"+Mask+"@") } + s = rtspUser.ReplaceAllString(s, "${1}"+Mask+"@") if json.Valid([]byte(s)) { return s } diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go index 1e3cea1..1ac22f1 100644 --- a/internal/redact/redact_test.go +++ b/internal/redact/redact_test.go @@ -60,3 +60,26 @@ func TestTextCoversTruncatedAndEmbeddedJSON(t *testing.T) { t.Errorf("clean text changed: %s", Text(clean)) } } + +// A camera address can carry a password; the host and path stay readable. The +// exact output is compared, since a pattern that stops at the first @ leaves +// the tail of the password behind and still hides "hunter2" as a whole word. +func TestTextMasksCameraPassword(t *testing.T) { + for in, want := range map[string]string{ + "couldn't open rtsp://cam:hunter2@192.168.1.5/live: refused": "couldn't open rtsp://[redacted]@192.168.1.5/live: refused", + "couldn't open rtsp://cam:hun@ter2@192.168.1.5/live: refused": "couldn't open rtsp://[redacted]@192.168.1.5/live: refused", + "bad rtsp://cam:pa\nss@192.168.1.5/live": "bad rtsp://[redacted]@192.168.1.5/live", + "bad rtsp://cam:pa\r\nss@192.168.1.5/live": "bad rtsp://[redacted]@192.168.1.5/live", + "bad rtsp://cam:my secret@192.168.1.5/live": "bad rtsp://[redacted]@192.168.1.5/live", + `{"url":"rtsp://cam:hunter2@192.168.1.5/live","token":"x"}`: `{"token":"[redacted]","url":"rtsp://[redacted]@192.168.1.5/live"}`, + `{"url":"rtsp://cam:hun@ter2@192.168.1.5/live","e":"a@b.example"}`: `{"url":"rtsp://[redacted]@192.168.1.5/live","e":"a@b.example"}`, + `{"url":"rtsp://cam:pa\"ss@192.168.1.5/live"}`: `{"url":"rtsp://[redacted]@192.168.1.5/live"}`, + } { + if got := Text(in); got != want { + t.Errorf("Text(%q)\n got %q\nwant %q", in, got, want) + } + } + if got := Text("rtsp://192.168.1.5/live"); got != "rtsp://192.168.1.5/live" { + t.Errorf("an address without a password changed: %q", got) + } +} diff --git a/internal/server/camera.go b/internal/server/camera.go new file mode 100644 index 0000000..44cb8c8 --- /dev/null +++ b/internal/server/camera.go @@ -0,0 +1,173 @@ +package server + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/trevin-lee/prusactl/internal/camera" + "github.com/trevin-lee/prusactl/internal/hint" + "github.com/trevin-lee/prusactl/internal/link" +) + +// snapshotter grabs one picture from an RTSP camera; camera.Grabber, with +// ffmpeg, in production. +type snapshotter interface { + Snapshot(ctx context.Context, rtsp string) ([]byte, error) +} + +// cameraSnapshot picks where a picture comes from, the way route picks where +// any other call goes: the direct route when it can serve the picture, Prusa +// Connect otherwise or when asked (via=connect). +// +// Buddy firmware's PrusaLink has no camera endpoint: the Buddy3D camera is a +// separate Wi-Fi device that talks to Prusa Connect itself and serves RTSP at +// rtsp:///live. So locally there are two sources, tried in order: +// the printer's /api/v1/cameras/snap (other PrusaLink builds have one), then +// the saved RTSP address, grabbed with ffmpeg. +func (s *Server) cameraSnapshot(ctx context.Context, in snapshotInput) (*mcp.CallToolResult, error) { + via := strings.ToLower(strings.TrimSpace(in.Via)) + if via != "" && via != "direct" && via != "connect" { + return nil, fmt.Errorf("via must be direct or connect (got %q)", in.Via) + } + if via == "connect" { + return s.connectSnapshot(ctx, in) + } + + // Is the printer the user means the directly connected one? Resolve it as + // direct only, so a Connect lookup doesn't happen before it's needed. + ref := in.printerRef + ref.Via = "direct" + t, rerr := s.route(ctx, ref) + var rtsp string + if s.cameraURL != nil { + var cerr error + if rtsp, cerr = s.cameraURL(); cerr != nil { + return nil, cerr + } + } + + var reason error // why the local sources can't answer + switch { + case rerr == nil: + if res, ok, err := s.printerCameraSnap(ctx, in.CameraID, t.name); err != nil { + return nil, err + } else if ok { + return res, nil + } + if rtsp != "" && in.CameraID == "" { + return s.rtspOrConnect(ctx, in, via, rtsp, t.name) + } + reason = noLocalCamera(in.CameraID) + case rtsp != "" && in.Printer == "" && in.CameraID == "": + // The camera is its own device on the network: the printer being + // unreachable says nothing about it. + return s.rtspOrConnect(ctx, in, via, rtsp, "the printer") + default: + reason = rerr + } + + if via == "direct" { + return nil, fmt.Errorf("no local camera: %w", reason) + } + if !s.session.SignedIn() { + return nil, fmt.Errorf("no camera to use: %v; or sign in to Prusa Connect, which has the camera (%s)", reason, hint.Connect()) + } + return s.connectSnapshot(ctx, in) +} + +// rtspOrConnect grabs from the camera's RTSP stream and, when that fails and +// the caller didn't insist on a direct source, falls back to Prusa Connect: an +// offline camera or a missing ffmpeg shouldn't hide a picture Connect has. +func (s *Server) rtspOrConnect(ctx context.Context, in snapshotInput, via, rtsp, printerName string) (*mcp.CallToolResult, error) { + res, err := s.rtspSnapshot(ctx, rtsp, printerName) + if err == nil || via == "direct" || !s.session.SignedIn() || ctx.Err() != nil { + return res, err + } + res, cerr := s.connectSnapshot(ctx, in) + if cerr != nil { + return nil, fmt.Errorf("%w; Prusa Connect didn't have a picture either: %v", err, cerr) + } + return res, nil +} + +// noLocalCamera says how to give prusactl a camera it can reach locally. +func noLocalCamera(cameraID string) error { + if cameraID != "" { + return fmt.Errorf("the printer has no camera API, and camera_id only names cameras on it or on Prusa Connect") + } + return errors.New("the printer has no camera API (Buddy firmware doesn't; the Buddy3D camera is a separate device) " + + "and no camera address is saved: " + cameraAdvice()) +} + +func cameraAdvice() string { + if hint.Managed() { + return "set PRUSACTL_CAMERA_URL to the camera's RTSP address (rtsp:///live)" + } + return "run `prusactl setup --camera rtsp:///live`" +} + +// imageResult is a picture and the sentence that goes with it. +func imageResult(img []byte, mime, note string) *mcp.CallToolResult { + return &mcp.CallToolResult{Content: []mcp.Content{ + &mcp.TextContent{Text: note}, + &mcp.ImageContent{Data: img, MIMEType: mime}, + }} +} + +// readImage reads a picture from a response, and names its type when the +// server doesn't. +func readImage(resp *http.Response) ([]byte, string, error) { + img, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if err != nil { + return nil, "", err + } + mime := resp.Header.Get("Content-Type") + if mime == "" || mime == "application/octet-stream" { + mime = http.DetectContentType(img) + } + return img, mime, nil +} + +// printerCameraSnap asks PrusaLink for a picture. ok is false, without an +// error, when the printer has no such endpoint or no camera; an unreachable or +// unauthorized printer is an error. +func (s *Server) printerCameraSnap(ctx context.Context, cameraID, printerName string) (*mcp.CallToolResult, bool, error) { + lc := s.direct() + path := "/api/v1/cameras/snap" + if cameraID != "" { + path = "/api/v1/cameras/" + url.PathEscape(cameraID) + "/snap" + } + resp, err := lc.Do(ctx, link.Request{Method: http.MethodGet, Path: path, Header: http.Header{"Accept": {"image/jpeg"}}}) + switch { + case link.IsStatus(err, http.StatusNotFound), link.IsStatus(err, http.StatusMethodNotAllowed): + return nil, false, nil + case err != nil: + return nil, false, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNoContent { + return nil, false, nil + } + img, mime, err := readImage(resp) + if err != nil { + return nil, false, err + } + return imageResult(img, mime, fmt.Sprintf("Camera on %s, from the printer's own camera API.", printerName)), true, nil +} + +// rtspSnapshot grabs one frame from the camera's RTSP stream. +func (s *Server) rtspSnapshot(ctx context.Context, rtsp, printerName string) (*mcp.CallToolResult, error) { + img, err := s.grabber.Snapshot(ctx, rtsp) + if err != nil { + return nil, err + } + note := fmt.Sprintf("Camera for %s, one frame just grabbed from %s with ffmpeg.", printerName, camera.Mask(rtsp)) + return imageResult(img, "image/jpeg", note), nil +} diff --git a/internal/server/camera_test.go b/internal/server/camera_test.go new file mode 100644 index 0000000..83e5fea --- /dev/null +++ b/internal/server/camera_test.go @@ -0,0 +1,153 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/trevin-lee/prusactl/internal/camera" +) + +// fakeGrabber stands in for ffmpeg and records the address it was asked for. +type fakeGrabber struct { + urls []string + err error +} + +func (g *fakeGrabber) Snapshot(_ context.Context, rtsp string) ([]byte, error) { + g.urls = append(g.urls, rtsp) + if g.err != nil { + return nil, g.err + } + return []byte{0xFF, 0xD8, 0xFF, 0xD9}, nil +} + +func withCamera(g *fakeGrabber, rtsp string) func(*Server) { + return func(s *Server) { + s.grabber = g + s.cameraURL = func() (string, error) { return rtsp, nil } + } +} + +// snapshot calls the tool and splits the answer into its text and image. +func snapshot(t *testing.T, cs *mcp.ClientSession, args map[string]any) (text string, image []byte, isErr bool) { + t.Helper() + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "get_camera_snapshot", Arguments: args}) + if err != nil { + return err.Error(), nil, true + } + for _, c := range res.Content { + switch c := c.(type) { + case *mcp.TextContent: + text += c.Text + case *mcp.ImageContent: + image = c.Data + } + } + return text, image, res.IsError +} + +// The Buddy printer's PrusaLink has no camera endpoint, so a saved RTSP +// address is what serves the picture. +func TestSnapshotFromRTSPWhenThePrinterHasNoCameraAPI(t *testing.T) { + g := &fakeGrabber{} + fp := &fakePrinter{state: "IDLE"} + cs := connectToolsWith(t, fp, withCamera(g, "rtsp://cam:hunter2@10.0.0.9/live")) + for i := 0; i < 2; i++ { + text, img, isErr := snapshot(t, cs, nil) + if isErr || len(img) == 0 { + t.Fatalf("call %d: %q (error=%v)", i, text, isErr) + } + if strings.Contains(text, "hunter2") || !strings.Contains(text, "10.0.0.9/live") { + t.Errorf("the note should name the camera without its password: %q", text) + } + } + if len(g.urls) != 2 || g.urls[0] != "rtsp://cam:hunter2@10.0.0.9/live" { + t.Errorf("grabbed %v", g.urls) + } +} + +// A PrusaLink that does serve pictures wins over the saved address. +func TestSnapshotPrefersThePrintersOwnCameraAPI(t *testing.T) { + g := &fakeGrabber{} + fp := &fakePrinter{state: "IDLE", handler: func(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path != "/api/v1/cameras/snap" { + return false + } + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte{0xFF, 0xD8, 0x01, 0xFF, 0xD9}) + return true + }} + cs := connectToolsWith(t, fp, withCamera(g, "rtsp://10.0.0.9/live")) + text, img, isErr := snapshot(t, cs, nil) + if isErr || len(img) != 5 || len(g.urls) != 0 { + t.Fatalf("%q image=%d grabbed=%v error=%v", text, len(img), g.urls, isErr) + } + if !strings.Contains(text, "printer's own camera API") { + t.Errorf("the note should say where the picture came from: %q", text) + } +} + +// With nothing local and no Connect, the error says how to get a camera, and +// via=direct says so without suggesting Connect. +func TestSnapshotWithoutACamera(t *testing.T) { + cs := connectTools(t, &fakePrinter{state: "IDLE"}) + for _, via := range []string{"", "direct"} { + text, _, isErr := snapshot(t, cs, map[string]any{"via": via}) + if !isErr || !strings.Contains(text, "--camera rtsp://") { + t.Errorf("via=%q: %q", via, text) + } + if via == "" && !strings.Contains(text, "Prusa Connect") { + t.Errorf("without a route, Connect should be offered: %q", text) + } + } + text, _, isErr := snapshot(t, cs, map[string]any{"via": "connect"}) + if !isErr || !strings.Contains(text, "Prusa Connect") { + t.Errorf("via=connect: %q", text) + } +} + +func TestSnapshotReportsFFmpegProblems(t *testing.T) { + g := &fakeGrabber{err: camera.ErrNoFFmpeg} + cs := connectToolsWith(t, &fakePrinter{state: "IDLE"}, withCamera(g, "rtsp://10.0.0.9/live")) + text, _, isErr := snapshot(t, cs, nil) + if !isErr || !strings.Contains(text, "needs ffmpeg") { + t.Errorf("%q", text) + } +} + +// The camera is its own device: the printer being off doesn't stop the picture. +func TestSnapshotWhenThePrinterIsUnreachable(t *testing.T) { + g := &fakeGrabber{} + fp := &fakePrinter{handler: func(w http.ResponseWriter, r *http.Request) bool { + w.WriteHeader(http.StatusServiceUnavailable) + return true + }} + cs := connectToolsWith(t, fp, withCamera(g, "rtsp://10.0.0.9/live")) + if text, img, isErr := snapshot(t, cs, nil); isErr || len(img) == 0 { + t.Fatalf("%q", text) + } + // But a named printer isn't assumed to be the one the camera watches. + if text, _, isErr := snapshot(t, cs, map[string]any{"printer": "another"}); !isErr { + t.Errorf("another printer got the saved camera: %q", text) + } +} + +// A camera that can't be grabbed doesn't hide Prusa Connect's picture: the +// automatic source order goes on to Connect, and via=direct stops. +func TestSnapshotFallsBackToConnectWhenTheCameraFails(t *testing.T) { + g := &fakeGrabber{err: errors.New("Connection refused")} + cs := connectConnectTools(t, &fakeConnect{state: "IDLE"}, withCamera(g, "rtsp://10.0.0.9/live")) + text, _, isErr := snapshot(t, cs, nil) + if !isErr || !strings.Contains(text, "Connection refused") || !strings.Contains(text, "Prusa Connect didn't have a picture") { + t.Errorf("the local error and Connect's should both show: %q", text) + } + text, _, isErr = snapshot(t, cs, map[string]any{"via": "direct"}) + if !isErr || !strings.Contains(text, "Connection refused") || strings.Contains(text, "Prusa Connect didn't") { + t.Errorf("via=direct must not try Connect: %q", text) + } +} diff --git a/internal/server/connect_tools_test.go b/internal/server/connect_tools_test.go index 9be74a2..439de79 100644 --- a/internal/server/connect_tools_test.go +++ b/internal/server/connect_tools_test.go @@ -136,7 +136,7 @@ func (f *fakeConnect) sentCommands() []string { return append([]string(nil), f.sent...) } -func connectConnectTools(t *testing.T, fc *fakeConnect) *mcp.ClientSession { +func connectConnectTools(t *testing.T, fc http.Handler, tweaks ...func(*Server)) *mcp.ClientSession { t.Helper() appdirtest.Use(t) // the refresh lock file lives under the config dir srv := httptest.NewServer(fc) @@ -145,9 +145,16 @@ func connectConnectTools(t *testing.T, fc *fakeConnect) *mcp.ClientSession { cc := connect.New(session, "test") cc.BaseURL = srv.URL cc.GraphQLURL = srv.URL + "/graphql" - fc.base = srv.URL + // The camera-service fake needs its own base URL; other handlers don't. + if f, ok := fc.(*fakeConnect); ok { + f.base = srv.URL + } s := New(session, cc, nil, link.ErrNotConfigured, "test") s.openLink = func() (*link.Client, error) { return nil, link.ErrNotConfigured } + s.cameraURL = func() (string, error) { return "", nil } + for _, tweak := range tweaks { + tweak(s) + } ct, st := mcp.NewInMemoryTransports() ctx := context.Background() diff --git a/internal/server/server.go b/internal/server/server.go index 1ea0ace..ddf6412 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -15,6 +15,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/trevin-lee/prusactl/internal/auth" + "github.com/trevin-lee/prusactl/internal/camera" "github.com/trevin-lee/prusactl/internal/connect" "github.com/trevin-lee/prusactl/internal/link" "github.com/trevin-lee/prusactl/internal/redact" @@ -59,6 +60,11 @@ type Server struct { mcp *mcp.Server + // The camera: its RTSP address (re-read on each use, like the direct + // route) and what grabs a frame from it. + cameraURL func() (string, error) + grabber snapshotter + probeMu sync.Mutex probed time.Time info *linkInfo @@ -68,7 +74,7 @@ type Server struct { // New builds the MCP server and registers every tool. lc may be nil, with // lcErr explaining why. func New(session *auth.Session, cc *connect.Client, lc *link.Client, lcErr error, version string) *Server { - s := &Server{session: session, connect: cc, link: lc, linkErr: lcErr, openLink: link.Open} + s := &Server{session: session, connect: cc, link: lc, linkErr: lcErr, openLink: link.Open, cameraURL: link.LoadCamera, grabber: camera.Grabber{}} s.mcp = mcp.NewServer( &mcp.Implementation{Name: "prusactl", Title: "Prusa printer", Version: version}, &mcp.ServerOptions{Instructions: instructionsFor()}, diff --git a/internal/server/tools_printers.go b/internal/server/tools_printers.go index 7fea2ed..3d389b1 100644 --- a/internal/server/tools_printers.go +++ b/internal/server/tools_printers.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "net/url" "strconv" @@ -154,80 +153,17 @@ func (s *Server) addPrinterTools() { mcp.AddTool(s.mcp, &mcp.Tool{ Name: "get_camera_snapshot", - Description: "The latest image from the printer's camera (via Prusa Connect), so you can see the print, " + - "the bed, and the nozzle. Use it before starting a print or moving anything (is the plate clear?) and " + - "to watch a print for failures. Reports how old the image is.", + Description: "The latest image from the printer's camera, so you can see the print, the bed, and the nozzle. " + + "Use it before starting a print or moving anything (is the plate clear?) and to watch a print for " + + "failures. " + + "Where the picture comes from: the printer's own camera API if it has one; else " + + "the camera's RTSP stream, if its address is saved (prusactl setup --camera rtsp:///live, or " + + "PRUSACTL_CAMERA_URL), grabbed with ffmpeg, which must be installed; else Prusa Connect. via=direct " + + "forces the first two, via=connect forces Connect. Reports where the image came from and how old it is.", Annotations: readOnly("Camera snapshot"), }, func(ctx context.Context, _ *mcp.CallToolRequest, in snapshotInput) (*mcp.CallToolResult, any, error) { - p, err := s.connectPrinter(ctx, in.printerRef) - if err != nil { - return nil, nil, err - } - var cams struct { - Cameras *[]map[string]any `json:"cameras"` - } - if err := s.connect.Get(ctx, printerPath(p.UUID, "cameras"), nil, &cams); err != nil { - return nil, nil, err - } - if cams.Cameras == nil { - return nil, nil, connect.Missing("GET", printerPath(p.UUID, "cameras"), "cameras") - } - if len(*cams.Cameras) == 0 { - return nil, nil, fmt.Errorf("%s has no camera in Prusa Connect", p.Name) - } - cam := (*cams.Cameras)[0] - if in.CameraID != "" { - cam = nil - for _, c := range *cams.Cameras { - if fmt.Sprint(c["id"]) == in.CameraID || fmt.Sprint(c["name"]) == in.CameraID { - cam = c - } - } - if cam == nil { - return nil, nil, fmt.Errorf("no camera %q on %s", in.CameraID, p.Name) - } - } - // Since Prusa moved cameras to its camera service (2026-09), snapshots - // come from the URL Connect's GraphQL API gives for the camera, found - // by its token. The older endpoints stay as a fallback. - var resp *http.Response - if token, _ := cam["token"].(string); token != "" { - if urls, err := s.connect.SnapshotURLs(ctx, p.UUID); err == nil && urls[token] != "" { - if r, err := s.connect.Do(ctx, connect.Request{Method: http.MethodGet, Path: urls[token]}); err == nil { - resp = r - } - } - } - if resp == nil { - id := connect.PathEscape(jsonID(cam["id"])) - query := url.Values{"printer_uuid": {p.UUID}} - resp, err = s.connect.Do(ctx, connect.Request{Method: http.MethodGet, Path: "/app/cameras/" + id + "/snapshots/last", Query: query}) - if connect.IsStatus(err, http.StatusNotFound) { - // WebRTC cameras (Buddy3D) may never have pushed a full snapshot; - // the web app falls back to the thumbnail endpoint. - resp, err = s.connect.Do(ctx, connect.Request{Method: http.MethodGet, Path: "/thumbnail/camera/" + id, Query: query}) - } - if err != nil { - return nil, nil, err - } - } - defer resp.Body.Close() - img, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) - if err != nil { - return nil, nil, err - } - mime := resp.Header.Get("Content-Type") - if mime == "" || mime == "application/octet-stream" { - mime = http.DetectContentType(img) - } - note := fmt.Sprintf("Camera %q on %s.", fmt.Sprint(cam["name"]), p.Name) - if lm, err := http.ParseTime(resp.Header.Get("Last-Modified")); err == nil { - note += fmt.Sprintf(" Taken %s (%s ago).", lm.Local().Format(time.DateTime), time.Since(lm).Round(time.Second)) - } - return &mcp.CallToolResult{Content: []mcp.Content{ - &mcp.TextContent{Text: note}, - &mcp.ImageContent{Data: img, MIMEType: mime}, - }}, nil, nil + res, err := s.cameraSnapshot(ctx, in) + return res, nil, err }) mcp.AddTool(s.mcp, &mcp.Tool{ @@ -336,3 +272,69 @@ func jsonID(v any) string { } return fmt.Sprint(v) } + +// connectSnapshot fetches the camera's last picture from Prusa Connect. +func (s *Server) connectSnapshot(ctx context.Context, in snapshotInput) (*mcp.CallToolResult, error) { + p, err := s.connectPrinter(ctx, in.printerRef) + if err != nil { + return nil, err + } + var cams struct { + Cameras *[]map[string]any `json:"cameras"` + } + if err := s.connect.Get(ctx, printerPath(p.UUID, "cameras"), nil, &cams); err != nil { + return nil, err + } + if cams.Cameras == nil { + return nil, connect.Missing("GET", printerPath(p.UUID, "cameras"), "cameras") + } + if len(*cams.Cameras) == 0 { + return nil, fmt.Errorf("%s has no camera in Prusa Connect", p.Name) + } + cam := (*cams.Cameras)[0] + if in.CameraID != "" { + cam = nil + for _, c := range *cams.Cameras { + if fmt.Sprint(c["id"]) == in.CameraID || fmt.Sprint(c["name"]) == in.CameraID { + cam = c + } + } + if cam == nil { + return nil, fmt.Errorf("no camera %q on %s", in.CameraID, p.Name) + } + } + // Since Prusa moved cameras to its camera service (2026-09), snapshots + // come from the URL Connect's GraphQL API gives for the camera, found + // by its token. The older endpoints stay as a fallback. + var resp *http.Response + if token, _ := cam["token"].(string); token != "" { + if urls, err := s.connect.SnapshotURLs(ctx, p.UUID); err == nil && urls[token] != "" { + if r, err := s.connect.Do(ctx, connect.Request{Method: http.MethodGet, Path: urls[token]}); err == nil { + resp = r + } + } + } + if resp == nil { + id := connect.PathEscape(jsonID(cam["id"])) + query := url.Values{"printer_uuid": {p.UUID}} + resp, err = s.connect.Do(ctx, connect.Request{Method: http.MethodGet, Path: "/app/cameras/" + id + "/snapshots/last", Query: query}) + if connect.IsStatus(err, http.StatusNotFound) { + // WebRTC cameras (Buddy3D) may never have pushed a full snapshot; + // the web app falls back to the thumbnail endpoint. + resp, err = s.connect.Do(ctx, connect.Request{Method: http.MethodGet, Path: "/thumbnail/camera/" + id, Query: query}) + } + if err != nil { + return nil, err + } + } + defer resp.Body.Close() + img, mime, err := readImage(resp) + if err != nil { + return nil, err + } + note := fmt.Sprintf("Camera %q on %s.", fmt.Sprint(cam["name"]), p.Name) + if lm, err := http.ParseTime(resp.Header.Get("Last-Modified")); err == nil { + note += fmt.Sprintf(" Taken %s (%s ago).", lm.Local().Format(time.DateTime), time.Since(lm).Round(time.Second)) + } + return imageResult(img, mime, note), nil +} diff --git a/internal/server/tools_status.go b/internal/server/tools_status.go index 068f1b8..151e78f 100644 --- a/internal/server/tools_status.go +++ b/internal/server/tools_status.go @@ -8,6 +8,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/trevin-lee/prusactl/internal/auth" + "github.com/trevin-lee/prusactl/internal/camera" "github.com/trevin-lee/prusactl/internal/link" "github.com/trevin-lee/prusactl/internal/hint" @@ -45,7 +46,13 @@ func (s *Server) Status(ctx context.Context) map[string]any { if cloud["signed_in"] != true { cloud["setup"] = "optional, for remote access, camera, dialogs, queue and history: " + hint.Connect() } - return map[string]any{"direct": direct, "connect": cloud} + cam := map[string]any{"configured": false} + if s.cameraURL != nil { + if addr, err := s.cameraURL(); err == nil && addr != "" { + cam["configured"], cam["rtsp"] = true, camera.Mask(addr) + } + } + return map[string]any{"direct": direct, "connect": cloud, "camera": cam} } // ConnectPrinters returns the account's printers as Prusa Connect lists them @@ -69,7 +76,8 @@ func (s *Server) addStatusTools() { mcp.AddTool(s.mcp, &mcp.Tool{ Name: "connection_status", Description: "How the printer can be reached right now: directly on the local network (PrusaLink) " + - "and/or through Prusa Connect, and which account is signed in. Says what to set up if neither works.", + "and/or through Prusa Connect, which account is signed in, and whether a local camera (RTSP) is saved. " + + "Says what to set up if neither route works.", Annotations: readOnly("Connection status"), }, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { return jsonResult(s.Status(ctx)) diff --git a/internal/server/tools_test.go b/internal/server/tools_test.go index 21e8ac9..223e142 100644 --- a/internal/server/tools_test.go +++ b/internal/server/tools_test.go @@ -85,6 +85,13 @@ func (f *fakePrinter) called(prefix string) bool { // connectTools runs the real MCP server, direct route only, against fp and // returns a client session to call its tools with. func connectTools(t *testing.T, fp *fakePrinter) *mcp.ClientSession { + t.Helper() + return connectToolsWith(t, fp, nil) +} + +// connectToolsWith is connectTools with a chance to adjust the server before +// it starts, e.g. to give it a camera. +func connectToolsWith(t *testing.T, fp *fakePrinter, tweak func(*Server)) *mcp.ClientSession { t.Helper() srv := httptest.NewServer(fp) t.Cleanup(srv.Close) @@ -93,6 +100,11 @@ func connectTools(t *testing.T, fp *fakePrinter) *mcp.ClientSession { s := New(session, connect.New(session, "test"), lc, nil, "test") // Never read the real saved setup, which would point at a real printer. s.openLink = func() (*link.Client, error) { return lc, nil } + // Nor the saved camera. + s.cameraURL = func() (string, error) { return "", nil } + if tweak != nil { + tweak(s) + } ct, st := mcp.NewInMemoryTransports() ctx := context.Background() @@ -247,7 +259,7 @@ func TestViaIsRefusedWhenImpossible(t *testing.T) { // Absolute means something different on Windows, and the path is // checked before the route is. args: map[string]any{"path": "/usb/x.bgcode", "local_path": filepath.Join(t.TempDir(), "x.bgcode")}}, - {tool: "get_camera_snapshot", via: "direct", want: "only works through Prusa Connect"}, + {tool: "get_camera_snapshot", via: "direct", want: "no local camera"}, {tool: "get_queue", via: "direct", want: "only works through Prusa Connect"}, {tool: "respond_to_dialog", via: "direct", want: "only works through Prusa Connect", args: map[string]any{"button": "Yes"}}, {tool: "get_printer", via: "bogus", want: "via must be direct or connect"}, From 3bec1805b0dbddd87d125595619a85732085bfc6 Mon Sep 17 00:00:00 2001 From: Trevin Lee <134039857+trevin-lee@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:02:11 -0700 Subject: [PATCH 2/2] fix: refuse credentials in the camera address, and say when the camera was skipped A password in the RTSP address went two places it shouldn't: onto ffmpeg's command line, where any local user can read it with ps for the length of the grab, and into config.json in plain text while the printer's own password goes to the keychain. The Buddy3D camera takes no credentials, so rather than carry a password carefully the address is refused, with the plain address to use instead. The saved address is checked again before every grab, so PRUSACTL_CAMERA_URL is checked too. connection_status printed the raw value when the scheme was wrong: the mask only knew rtsp://, and a one-character typo in the environment variable showed the password in the first tool an agent calls. It validates first and never prints the raw value. A camera that couldn't be grabbed fell back to Connect's picture with nothing said, so the likely cause, ffmpeg on the terminal's PATH but not the MCP server's, would never be noticed. The note says the camera was skipped and why. A printer that errored on its camera endpoint ended the call, though the camera is its own device; the RTSP source is tried unless there is nothing else local to try. setup refuses --camera with --forget and the printer-only flags on a camera-only run instead of ignoring them, and --forget says when it took the camera address too. The bundle gains the camera setting its own advice pointed at. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 +++ README.md | 9 +++-- cmd/prusactl/main.go | 22 +++++++++-- cmd/prusactl/setup_test.go | 2 +- internal/camera/camera.go | 15 ++++++-- internal/camera/camera_test.go | 34 +++++++++++++++-- internal/server/camera.go | 36 ++++++++++++++++-- internal/server/camera_test.go | 55 ++++++++++++++++++++++++--- internal/server/connect_tools_test.go | 5 +++ internal/server/tools_status.go | 9 ++++- mcpb/manifest.json | 9 ++++- 11 files changed, 174 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ce148..cdaa94f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ the address and prusactl grabs a frame with ffmpeg. A PrusaLink that serves `/api/v1/cameras/snap` is used first, and Connect is still the fallback. `prusactl status` shows the saved camera. Only plain `rtsp://` addresses are +## Unreleased + + accepted (ffmpeg doesn't verify the certificate of `rtsps://`), and one with a + user or password is refused: ffmpeg would get it on its command line, readable + by any local user, and it would be stored in plain text. The Buddy3D takes none. accepted (ffmpeg doesn't verify the certificate of `rtsps://`), and a password in the address is masked wherever prusactl shows it. - `prusactl login --manual` signs in without a password passing through the diff --git a/README.md b/README.md index 73d3a8a..1ca0c19 100644 --- a/README.md +++ b/README.md @@ -195,15 +195,16 @@ A snapshot comes from the first source that works: the printer's own `/api/v1/cameras/snap`, if its PrusaLink has one (other PrusaLink builds do); the saved RTSP address; then Prusa Connect. `via: "direct"` limits it to the first two, `via: "connect"` to Connect. `PRUSACTL_CAMERA_URL` overrides the saved -address, and a password in the address is masked wherever prusactl shows it. +address. An address with a user or password is refused: ffmpeg would get the +password on its command line, where any local user can read it with `ps`, and it +would sit in plain text in the config. The Buddy3D camera takes none. Only plain `rtsp://` addresses are accepted. `rtsps://` is refused on purpose: FFmpeg 7.1.5 completes the TLS handshake with a self-signed certificate and sends the camera's credentials even with `-tls_verify 1` and `-ca_file`, because its RTSP code never hands those options to the TLS layer. The Buddy3D camera serves plain -RTSP, so this costs nothing there. Residual risk: plain RTSP sends the password -(and the video) unencrypted, so anyone on your network can read it; use a camera -account that only has access to the picture, and keep the camera off untrusted +RTSP, so this costs nothing there. Residual risk: plain RTSP sends the video unencrypted, so anyone on your network +can watch it; keep the camera off untrusted networks. ### Signing in to Prusa Connect diff --git a/cmd/prusactl/main.go b/cmd/prusactl/main.go index 7f2d71f..6e64a6a 100644 --- a/cmd/prusactl/main.go +++ b/cmd/prusactl/main.go @@ -219,6 +219,12 @@ func setup(ctx context.Context, args []string) error { if cameraURL, err = camera.Validate(cameraURL); err != nil { return err } + if opts.on("forget") { + return errors.New("setup: --forget removes the camera too, so --camera with it would be lost; run them separately") + } + if len(pos) == 0 && (opts.given("user") || opts.given("api-key") || opts.given("password-stdin")) { + return errors.New("setup: --camera on its own saves only the camera; --user, --api-key and --password-stdin go with a printer ADDRESS") + } } if opts.on("no-camera") { if cameraURL != "" || opts.on("forget") || len(pos) > 0 { @@ -259,14 +265,18 @@ func setup(ctx context.Context, args []string) error { if err != nil { return err } + savedCamera, _ := link.LoadCamera() if err := link.RemoveConfig(cfg); err != nil { return err } - kind := "password" + what := "its saved password" if cfg.Auth == link.AuthAPIKey { - kind = "API key" + what = "its saved API key" } - fmt.Printf("Forgot %s and its saved %s.\n", cfg.Host, kind) + if savedCamera != "" && os.Getenv("PRUSACTL_CAMERA_URL") == "" { + what += ", and the camera address" + } + fmt.Printf("Forgot %s and %s.\n", cfg.Host, what) return nil } @@ -479,7 +489,11 @@ func status(ctx context.Context, srv *server.Server, lc *link.Client) error { } if cam, _ := st["camera"].(map[string]any); cam["configured"] == true { - fmt.Printf("Camera (RTSP): %v\n", cam["rtsp"]) + if e, ok := cam["error"]; ok { + fmt.Printf("Camera (RTSP): not usable: %v\n", e) + } else { + fmt.Printf("Camera (RTSP): %v\n", cam["rtsp"]) + } } // Without the direct route, show the printers as Prusa Connect sees them. diff --git a/cmd/prusactl/setup_test.go b/cmd/prusactl/setup_test.go index 9f44d05..63058a3 100644 --- a/cmd/prusactl/setup_test.go +++ b/cmd/prusactl/setup_test.go @@ -145,7 +145,7 @@ func TestSetupForgetRemovesACameraSavedAlone(t *testing.T) { cfgPath := filepath.Join(home, "config.json") t.Setenv("PRUSACTL_CONFIG", cfgPath) - if err := setup(context.Background(), []string{"--camera", "rtsp://cam:pw@127.0.0.1:1/live"}); err != nil { + if err := setup(context.Background(), []string{"--camera", "rtsp://127.0.0.1:1/live"}); err != nil { t.Fatal(err) } if err := setup(context.Background(), []string{"--forget"}); err != nil { diff --git a/internal/camera/camera.go b/internal/camera/camera.go index 435a736..e0badcf 100644 --- a/internal/camera/camera.go +++ b/internal/camera/camera.go @@ -40,9 +40,15 @@ type Grabber struct { // ErrRTSPS means the address asks for TLS, which ffmpeg can't be made to check. var ErrRTSPS = errors.New("rtsps:// cameras aren't supported: ffmpeg does not verify the camera's TLS certificate (its RTSP code never passes -tls_verify or -ca_file on to TLS, tested on 7.1.5), so anyone on the network could pose as the camera and read the password; use the camera's plain rtsp:// address") -// Validate checks that raw is an rtsp:// address, and returns it with the -// scheme in lowercase. An rtsps:// -// address is refused, see ErrRTSPS. +// ErrCredentials means the address carries a user or password. ffmpeg would get +// it on its command line, where any local user can read it with ps, and it +// would sit in plain text in prusactl's config; the Buddy3D camera takes none, +// so rather than handle a password carefully the address is refused. +var ErrCredentials = errors.New("credentials in the camera address aren't supported: ffmpeg would get the password on its command line, readable by any local user with ps, and it would be stored in plain text; the Buddy3D camera takes none") + +// Validate checks that raw is an rtsp:// address without credentials, and +// returns it with the scheme in lowercase. An rtsps:// address is refused, see +// ErrRTSPS; one with a user or password too, see ErrCredentials. func Validate(raw string) (string, error) { raw = strings.TrimSpace(raw) u, err := url.Parse(raw) @@ -52,6 +58,9 @@ func Validate(raw string) (string, error) { if err != nil || u.Host == "" || !strings.EqualFold(u.Scheme, "rtsp") { return "", fmt.Errorf("not a camera address: %q (expected rtsp:///live)", maskAny(raw)) } + if u.User != nil { + return "", fmt.Errorf("%w; use rtsp://%s%s", ErrCredentials, u.Host, u.Path) + } // ffmpeg only knows the lowercase scheme. return "rtsp" + raw[len("rtsp"):], nil } diff --git a/internal/camera/camera_test.go b/internal/camera/camera_test.go index 2f9726c..3167027 100644 --- a/internal/camera/camera_test.go +++ b/internal/camera/camera_test.go @@ -71,7 +71,7 @@ func TestSnapshotErrors(t *testing.T) { for _, tc := range []struct { name, mode, url, want string }{ - {"ffmpeg fails", "fail", "rtsp://cam:hunter2@10.0.0.9/live", "Connection refused"}, + {"ffmpeg fails", "fail", "rtsp://10.0.0.9/live", "Connection refused"}, // the fake echoes a password; Mask must catch it {"not an image", "garbage", "rtsp://10.0.0.9/live", "ffmpeg produced no picture"}, {"cut short", "truncated", "rtsp://10.0.0.9/live", "cut short"}, {"over the cap", "huge", "rtsp://10.0.0.9/live", "over 16 MB"}, @@ -136,8 +136,8 @@ func TestRTSPSIsRefused(t *testing.T) { // lowercases it and leaves the rest alone. func TestValidateLowercasesTheScheme(t *testing.T) { for in, want := range map[string]string{ - "RTSP://10.0.0.9/live": "rtsp://10.0.0.9/live", - " RtSp://cam:PassWord@10.0.0.9/Live ": "rtsp://cam:PassWord@10.0.0.9/Live", + "RTSP://10.0.0.9/live": "rtsp://10.0.0.9/live", + " RtSp://10.0.0.9/Live ": "rtsp://10.0.0.9/Live", } { if got, err := Validate(in); err != nil || got != want { t.Errorf("Validate(%q) = %q, %v, want %q", in, got, err, want) @@ -194,3 +194,31 @@ func TestMaskHidesPasswordsInMalformedAddresses(t *testing.T) { } } } + +// Credentials in the address are refused rather than handled: a password would +// reach ffmpeg as an argument, readable by any local user with ps, and sit in +// plain text in the config. The Buddy3D needs none. The refusal says what to +// use instead and never repeats the credentials. +func TestValidateRefusesCredentials(t *testing.T) { + for _, in := range []string{"rtsp://cam:hunter2@10.0.0.9/live", "rtsp://cam@10.0.0.9/live"} { + _, err := Validate(in) + if !errors.Is(err, ErrCredentials) { + t.Errorf("Validate(%q) = %v, want ErrCredentials", in, err) + continue + } + if strings.Contains(err.Error(), "hunter2") || strings.Contains(err.Error(), "cam:") || strings.Contains(err.Error(), "cam@") { + t.Errorf("the refusal shows the credentials: %v", err) + } + if !strings.Contains(err.Error(), "rtsp://10.0.0.9/live") { + t.Errorf("the refusal should say what to use instead: %v", err) + } + ran := t.TempDir() + "/ran" + g := Grabber{Binary: os.Args[0], Env: []string{"FAKE_FFMPEG=ok", "FAKE_ARGS_FILE=" + ran}} + if _, err := g.Snapshot(context.Background(), in); !errors.Is(err, ErrCredentials) { + t.Errorf("Snapshot(%q) = %v, want ErrCredentials", in, err) + } + if _, err := os.Stat(ran); err == nil { + t.Errorf("Snapshot(%q) ran ffmpeg", in) + } + } +} diff --git a/internal/server/camera.go b/internal/server/camera.go index 44cb8c8..2c90989 100644 --- a/internal/server/camera.go +++ b/internal/server/camera.go @@ -51,16 +51,29 @@ func (s *Server) cameraSnapshot(ctx context.Context, in snapshotInput) (*mcp.Cal if rtsp, cerr = s.cameraURL(); cerr != nil { return nil, cerr } + // Checked here, not only when it was saved: PRUSACTL_CAMERA_URL is + // never checked at all otherwise, and what is allowed can change + // between versions. + if rtsp != "" { + if rtsp, cerr = camera.Validate(rtsp); cerr != nil { + return nil, cerr + } + } } var reason error // why the local sources can't answer switch { case rerr == nil: - if res, ok, err := s.printerCameraSnap(ctx, in.CameraID, t.name); err != nil { - return nil, err - } else if ok { + res, ok, err := s.printerCameraSnap(ctx, in.CameraID, t.name) + if ok { return res, nil } + // The camera is its own device: a printer that errors on its camera + // endpoint (rebooting, busy) says nothing about it. The error only + // ends the call when there is nothing else local to try. + if err != nil && (rtsp == "" || in.CameraID != "") { + return nil, err + } if rtsp != "" && in.CameraID == "" { return s.rtspOrConnect(ctx, in, via, rtsp, t.name) } @@ -94,9 +107,24 @@ func (s *Server) rtspOrConnect(ctx context.Context, in snapshotInput, via, rtsp, if cerr != nil { return nil, fmt.Errorf("%w; Prusa Connect didn't have a picture either: %v", err, cerr) } + // Say so. A camera that was skipped looks exactly like one that worked, + // and the likely cause (ffmpeg on the terminal's PATH but not on the MCP + // server's) would otherwise never be noticed. + prefixNote(res, fmt.Sprintf("The RTSP camera was skipped (%v), so this is Prusa Connect's picture. ", err)) return res, nil } +// prefixNote puts text in front of the sentence that goes with a picture. +func prefixNote(res *mcp.CallToolResult, text string) { + for _, c := range res.Content { + if t, ok := c.(*mcp.TextContent); ok { + t.Text = text + t.Text + return + } + } + res.Content = append([]mcp.Content{&mcp.TextContent{Text: strings.TrimSpace(text)}}, res.Content...) +} + // noLocalCamera says how to give prusactl a camera it can reach locally. func noLocalCamera(cameraID string) error { if cameraID != "" { @@ -108,7 +136,7 @@ func noLocalCamera(cameraID string) error { func cameraAdvice() string { if hint.Managed() { - return "set PRUSACTL_CAMERA_URL to the camera's RTSP address (rtsp:///live)" + return "enter the camera's RTSP address (rtsp:///live) in the extension's settings; ffmpeg has to be installed where the app can find it" } return "run `prusactl setup --camera rtsp:///live`" } diff --git a/internal/server/camera_test.go b/internal/server/camera_test.go index 83e5fea..61ae1ae 100644 --- a/internal/server/camera_test.go +++ b/internal/server/camera_test.go @@ -56,21 +56,55 @@ func snapshot(t *testing.T, cs *mcp.ClientSession, args map[string]any) (text st func TestSnapshotFromRTSPWhenThePrinterHasNoCameraAPI(t *testing.T) { g := &fakeGrabber{} fp := &fakePrinter{state: "IDLE"} - cs := connectToolsWith(t, fp, withCamera(g, "rtsp://cam:hunter2@10.0.0.9/live")) + cs := connectToolsWith(t, fp, withCamera(g, "rtsp://10.0.0.9/live")) for i := 0; i < 2; i++ { text, img, isErr := snapshot(t, cs, nil) if isErr || len(img) == 0 { t.Fatalf("call %d: %q (error=%v)", i, text, isErr) } - if strings.Contains(text, "hunter2") || !strings.Contains(text, "10.0.0.9/live") { - t.Errorf("the note should name the camera without its password: %q", text) + if !strings.Contains(text, "10.0.0.9/live") { + t.Errorf("the note should name the camera: %q", text) } } - if len(g.urls) != 2 || g.urls[0] != "rtsp://cam:hunter2@10.0.0.9/live" { + if len(g.urls) != 2 || g.urls[0] != "rtsp://10.0.0.9/live" { t.Errorf("grabbed %v", g.urls) } } +// An address with credentials is refused wherever it came from, the saved +// config or PRUSACTL_CAMERA_URL, and the refusal repeats neither. +func TestSnapshotRefusesASavedAddressWithCredentials(t *testing.T) { + g := &fakeGrabber{} + cs := connectToolsWith(t, &fakePrinter{state: "IDLE"}, withCamera(g, "rtsp://cam:hunter2@10.0.0.9/live")) + text, _, isErr := snapshot(t, cs, nil) + if !isErr || !strings.Contains(text, "credentials") || strings.Contains(text, "hunter2") { + t.Errorf("%q (error=%v)", text, isErr) + } + if len(g.urls) != 0 { + t.Errorf("the address reached ffmpeg: %v", g.urls) + } +} + +// A printer that errors on its camera endpoint (rebooting, busy) says nothing +// about the camera, which is its own device: the RTSP source is still tried. +func TestSnapshotFallsThroughWhenThePrinterCameraErrors(t *testing.T) { + g := &fakeGrabber{} + fp := &fakePrinter{state: "IDLE", handler: func(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path != "/api/v1/cameras/snap" { + return false + } + w.WriteHeader(http.StatusServiceUnavailable) + return true + }} + cs := connectToolsWith(t, fp, withCamera(g, "rtsp://10.0.0.9/live")) + if text, img, isErr := snapshot(t, cs, nil); isErr || len(img) == 0 { + t.Fatalf("%q (error=%v)", text, isErr) + } + if len(g.urls) != 1 { + t.Errorf("RTSP wasn't tried: %v", g.urls) + } +} + // A PrusaLink that does serve pictures wins over the saved address. func TestSnapshotPrefersThePrintersOwnCameraAPI(t *testing.T) { g := &fakeGrabber{} @@ -142,8 +176,17 @@ func TestSnapshotWhenThePrinterIsUnreachable(t *testing.T) { func TestSnapshotFallsBackToConnectWhenTheCameraFails(t *testing.T) { g := &fakeGrabber{err: errors.New("Connection refused")} cs := connectConnectTools(t, &fakeConnect{state: "IDLE"}, withCamera(g, "rtsp://10.0.0.9/live")) - text, _, isErr := snapshot(t, cs, nil) - if !isErr || !strings.Contains(text, "Connection refused") || !strings.Contains(text, "Prusa Connect didn't have a picture") { + text, img, isErr := snapshot(t, cs, nil) + if isErr || len(img) == 0 { + t.Fatalf("Connect's picture should have come through: %q", text) + } + // A camera that was skipped must not look like one that worked. + if !strings.Contains(text, "Connection refused") || !strings.Contains(text, "Prusa Connect") { + t.Errorf("the note should say the camera was skipped, and why: %q", text) + } + // With nothing on Connect either, both reasons show. + none := connectConnectTools(t, &fakeConnect{state: "IDLE", noCameras: true}, withCamera(g, "rtsp://10.0.0.9/live")) + if text, _, isErr := snapshot(t, none, nil); !isErr || !strings.Contains(text, "Connection refused") || !strings.Contains(text, "Prusa Connect didn't have a picture") { t.Errorf("the local error and Connect's should both show: %q", text) } text, _, isErr = snapshot(t, cs, map[string]any{"via": "direct"}) diff --git a/internal/server/connect_tools_test.go b/internal/server/connect_tools_test.go index 439de79..b81be74 100644 --- a/internal/server/connect_tools_test.go +++ b/internal/server/connect_tools_test.go @@ -50,6 +50,7 @@ type fakeConnect struct { serviceDown bool // GraphQL answers CameraServiceError snapshotURL string // overrides the snapshot URL GraphQL hands out snapshotAuths []string + noCameras bool // the account has no camera on this printer } func (f *fakeConnect) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -88,6 +89,10 @@ func (f *fakeConnect) ServeHTTP(w http.ResponseWriter, r *http.Request) { case r.URL.Path == "/app/printers/u1/queue": _ = json.NewEncoder(w).Encode(map[string]any{"queue": f.queue}) case r.URL.Path == "/app/printers/u1/cameras": + if f.noCameras { + fmt.Fprint(w, `{"cameras":[]}`) + return + } fmt.Fprint(w, `{"cameras":[{"id":601734,"name":"Buddy3D Camera","token":"cam-token"}]}`) case r.URL.Path == "/graphql" && r.Method == http.MethodPost: conn := map[string]any{"__typename": "CameraServiceError", "errorCode": "UNAVAILABLE"} diff --git a/internal/server/tools_status.go b/internal/server/tools_status.go index 151e78f..31921b4 100644 --- a/internal/server/tools_status.go +++ b/internal/server/tools_status.go @@ -49,7 +49,14 @@ func (s *Server) Status(ctx context.Context) map[string]any { cam := map[string]any{"configured": false} if s.cameraURL != nil { if addr, err := s.cameraURL(); err == nil && addr != "" { - cam["configured"], cam["rtsp"] = true, camera.Mask(addr) + cam["configured"] = true + // Never the raw value: an environment variable with a typo in the + // scheme isn't an RTSP address, and the mask only knows those. + if valid, verr := camera.Validate(addr); verr != nil { + cam["error"] = verr.Error() + } else { + cam["rtsp"] = valid + } } } return map[string]any{"direct": direct, "connect": cloud, "camera": cam} diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 4392cad..673434c 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -33,7 +33,8 @@ "env": { "PRUSACTL_HOST": "${user_config.printer_address}", "PRUSACTL_PASSWORD": "${user_config.printer_password}", - "PRUSACTL_MANAGED": "mcpb" + "PRUSACTL_MANAGED": "mcpb", + "PRUSACTL_CAMERA_URL": "${user_config.camera_url}" }, "platform_overrides": { "win32": { @@ -74,6 +75,12 @@ "description": "On the printer: Settings > Network > PrusaLink (username maker).", "sensitive": true, "required": false + }, + "camera_url": { + "type": "string", + "title": "Camera RTSP address", + "description": "The Buddy3D camera's stream, rtsp:///live, with no user or password. Needs ffmpeg installed where the app can find it. Leave empty to use Prusa Connect's snapshots.", + "required": false } }, "compatibility": {