Nightly backups to a mounted NAS share ran from May and then stopped, failing
with [Errno 30] Read-only file system. The reporter checked folder permissions
-- correctly: the mount is gid=backup,dir_mode=0775, the service user is in that
group, and his own shell writes to the share fine.
Errno 30 is EROFS. A permission problem is errno 13. EROFS means the filesystem
refused the write, and it refused because we told it to: our systemd unit ships
ProtectSystem=strict, which mounts everything read-only inside the service's
mount namespace and carves back out only ReadWritePaths=<install> <data> <logs>.
A NAS share is not one of those three. Reads are unaffected -- which is why the
UI happily listed his existing backups from the share while being unable to
write a new one -- and his shell is outside the namespace entirely, so every
check he could think to run said the directory was fine.
Both installers write the unit file wholesale, so a ReadWritePaths line added by
hand disappeared on the next install, taking the backups with it. They now back
the old unit up (.bak-<timestamp>) and carry the operator's extra writable paths
forward, reporting which ones they kept. The unit template documents the
carve-out.
The output directory is probed with a real write when it is saved and when the
backup card loads, so an unwritable path is caught there rather than at 03:00
for a week. On failure the card names the cause and hands over the fix with the
operator's path already in it (systemctl edit bambuddy -> ReadWritePaths=...),
and a failed run reports the same diagnosis rather than the raw OSError. EROFS
outside systemd, permission-denied, out-of-space, not-a-directory and missing are
told apart, in all 11 locales.
Docker: a backup path that is not bind-mounted is writable -- the write lands in
the container's ephemeral layer and is lost on the next compose up. The probe
compares the directory's device against the container root and warns, with the
compose snippet that mounts it properly.
Two defects, both invisible until you ask the app to stop.
Docker never shut down gracefully at all. CMD ["sh","-c","uvicorn ..."] left
the shell as PID 1 with uvicorn as its child, and dash does not forward
signals, so docker stop SIGTERMed the shell and uvicorn never heard about it.
Measured on the shipped image: the full 10s grace period, exit 137, and no
"Shutting down" line in the log. Every stop, restart and image update was a
hard kill -- no WAL checkpoint, no MQTT disconnect, no virtual-printer
teardown. `exec` makes uvicorn PID 1; the rebuilt image now stops in 1s with
exit 0 and checkpoints the WAL.
Separately, uvicorn's timeout_graceful_shutdown defaults to None -- wait
forever for in-flight requests. An MJPEG camera stream is a response that
never completes (httptools' connection shutdown() only flips keep_alive on an
in-flight cycle, it never closes the transport), so one open camera tile
pinned the process until systemd SIGKILLed at 90s. The ordering makes it
unfixable from inside the app: uvicorn fires the lifespan shutdown -- the code
that tears the streams down -- only after connections drain.
All six launchers now pass --timeout-graceful-shutdown 5: Dockerfile,
deploy/bambuddy.service, the systemd unit and launchd plist from
install/install.sh, the SpoolBuddy installer's unit, and the Windows NSSM
registration. On timeout uvicorn cancels the request tasks; the camera
generators already unwind cleanly on CancelledError.
TimeoutStopSec raised to 30s on the units and stop_grace_period: 30s added to
compose, as backstops rather than the mechanism. On Windows NSSM's default
1500ms AppStopMethodConsole was force-killing uvicorn mid-teardown; raised to
15s, with the WM_CLOSE and thread-message stages skipped (uvicorn is a console
app with neither a window nor a message loop).
macOS mixed root-only steps (default /opt path, sudo git clone) with steps
that must not run as root: brew refuses to run as root, and a root-owned
venv/node_modules can't be managed by the launchd agent. The installer now
refuses sudo on macOS, defaults to ~/bambuddy, and drops sudo from the
download/venv/frontend/env/dir steps. A --path under a root-owned parent still
works via a single elevate-and-chown. Linux (service user + systemd) unchanged.
Native (non-Docker) installs launched uvicorn without --loop asyncio, so
uvicorn[standard] auto-selected uvloop. uvloop's SSL layer drops
already-received but still-buffered data when the client closes the data
connection without a TLS close_notify while the reader is flow-control
paused on slow storage. cmd_STOR writes each chunk to disk inside the read
loop, so a slow consumer falls behind, the tail is lost, read() returns a
clean EOF, and the loop exits with no exception -- the server acked 226 for
a file it truncated itself, then archived, queued, and forwarded the corrupt
3MF to the real printer.
Fix in two independent layers:
1. Remove the trigger: add --loop asyncio to every native launch path,
matching the Dockerfile -- deploy/bambuddy.service, install/install.sh
(systemd + launchd), spoolbuddy/install/install.sh, the Windows NSSM
service, README, CONTRIBUTING dev command.
2. Defense in depth (loop-independent): cmd_STOR now validates that a
received .3mf opens as a ZIP (reads the central directory, no
decompression) before replying 226. A truncated/corrupt file is dropped
and answered with 426, and on_file_received never runs -- so a broken
upload surfaces as an immediate slicer-side send error instead of being
archived and pushed to the printer. Scoped to .3mf; other filetypes pass
through unchanged.
install/docker-install.sh::create_install_dir ran `mkdir -p
"$INSTALL_PATH"` without sudo while DEFAULT_INSTALL_PATH was
/opt/bambuddy, root-owned on every Linux distro. set -e then
aborted the whole script before docker compose could pull the
image — anyone running the documented `curl ... | bash` flow as
a normal user hit this on first install.
Fix: try the unprivileged `mkdir -p ... 2>/dev/null` first so
--path ~/bambuddy, /srv/bambuddy and other writable targets don't
trigger a needless password prompt, then fall back to
`sudo mkdir -p` + `sudo chown -R "$USER:$USER"` only when the
first attempt failed. The chown is load-bearing: without it the
script would later try to write docker-compose.yml + .env into a
root-owned dir as the invoking user and cascade further EACCES
failures.
Not changing the default path: install/update.sh and
install/update_macos.sh both default INSTALL_DIR to /opt/bambuddy,
and install/README.md's update flow documents the same — flipping
the install default to ~/bambuddy without coordinating those
would silently break self-service updates for anyone following
the docs verbatim. The default stays /opt/bambuddy; only the
escalation gap closes.
set -e survives the redirected stderr because the `if !` form is
the documented escape hatch for an expected-failure check.
Smoke-tested writable-target, idempotent-rerun, and the
failing-mkdir-then-sudo-fallback branches.
bambuddy.service shipped with ProtectHome=true, which makes /home/* invisible
to the service namespace. Installing into /home/bambuddy/ (instead of the
default /opt/bambuddy/) made ExecStart=/home/bambuddy/venv/bin/uvicorn fail
with status=203/EXEC because systemd couldn't resolve the binary path.
ReadWritePaths=$INSTALL_PATH does not reliably re-expose /home/* subpaths for
exec resolution.
install/install.sh now detects /home/* INSTALL_PATH and emits ProtectHome=read-only;
default /opt/bambuddy installs keep ProtectHome=true. The manual deploy template
defaults to read-only with a comment on when to tighten it.
read-only keeps /home immutable to the service - no security regression, since
ReadWritePaths still gates writes to the install/data/log dirs only.
entries
PR #1529 added the Windows installer but the rendered README sections
had a stray blank line inside the install one-liner's code fence and
the cross-platform Service Management / Updating / Troubleshooting
sections still only covered Linux + macOS + Docker. Fold in Windows
entries (Start-Service, Get-NetTCPConnection, NSSM runtime log path)
and link the README description to the Windows Installer wiki page so
users know where the parameter reference and unattended examples live.
Strip @mentions from changelog text in docker-publish-daily-beta.sh
so GitHub doesn't auto-generate a "Contributors" section in release
notes. Add --generate-notes=false for extra safety. Also add ports
2024-2026 (A1/P1S proprietary) to the docker-compose.yml bridge-mode
port mapping and update the install script comment.
The closed-source bambu_networking DLL validates TLS connection parameters
and rejects connections where the certificate doesn't match the printer's
real BBL CA certificate. The TLS-terminating proxy presented Bambuddy's
own certificate, causing X1C/X1 prints to silently fail after verify_job.
Switch to transparent TCP proxying for FTP, FileTransfer, Camera, and FTP
data — only MQTT remains TLS-terminated (required for IP rewriting). The
slicer now gets end-to-end TLS directly with the printer's real certificate.
Changes:
- SlicerProxyManager uses TCPProxy for FTP (990), FileTransfer (6000),
Camera (322), and pre-listens on FTP data ports (50000-50100)
- Only MQTT (8883) uses TLSProxy for IP rewriting
- Remove debug logging from MQTT and FTP proxy code
- Fix install.sh missing AmbientCapabilities=CAP_NET_BIND_SERVICE
- Update module docstring, migration docs, README proxy description
- Add tests verifying transparent proxy architecture
When running multiple virtual printers with different access codes on
separate bind IPs, FTP connections were always routed to the wrong VP.
Root cause: the iptables REDIRECT rule (990→9990) rewrites the
destination IP to the incoming interface's primary address. With Linux's
weak host model (arp_filter=0), packets for secondary IPs arrive on the
primary interface, and REDIRECT sends them all to the first VP's FTP
server. MQTT was unaffected because port 8883 had no redirect.
Fix: FTP server now binds directly to port 990 (standard implicit FTPS),
eliminating the iptables redirect entirely. Requires CAP_NET_BIND_SERVICE
(already set in the systemd service file and Docker image).
Also removed a global asyncio set_exception_handler() in the MQTT server
that was overwritten by each VP instance, causing spurious "Unhandled
exception in client_connected_cb" errors on startup.
Changes:
- FTP_PORT: 9990 → 990 (ftp_server.py)
- Removed set_exception_handler() from MQTT server
- Updated Dockerfile, docker-compose.yml port mappings
- Deprecated --redirect-990 in install script
- Updated wiki: removed iptables instructions for all platforms
- Added migration guide (docs/migration-vp-ftp-port.md)
- Added unit tests for port constant and no-global-state invariant
The install script hardcoded origin/main, so beta testers told to
install from a dev branch silently got the stable release instead.
Add a --branch CLI option and interactive prompt (defaults to main).
Invalid branch names are validated via git ls-remote before any work
is done, showing available branches on failure.
The install script hardcoded origin/main, so beta testers told to
install from a dev branch silently got the stable release instead.
Add a --branch CLI option and interactive prompt (defaults to main).
Fresh installs use git clone --branch, existing installs checkout
and reset to the selected branch.
enum.StrEnum was added in Python 3.11, but the documented minimum is
3.10. Add a compatibility shim in backend/app/core/compat.py that falls
back to (str, Enum) on older versions. Updated all 5 import sites and
lowered pyproject.toml target-version to py310.
The Python hash verification in start_bambuddy.bat used a multi-line
`for /f "usebackq"` with a backtick-delimited command split across
lines. Windows CMD cannot parse line breaks inside backtick-delimited
for /f commands, causing "The syntax of the command is incorrect" at
step 1/6. Removed the entire redundant verification block — the
verify_sha256 subroutine already checks the archive against the
pinned hash. The removed block also had a secondary bug: it always
downloaded the amd64 checksum from python.org even on arm64 systems.