Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 112 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,108 @@ currently active, the box's LAN IP, and the exact command to run —
`ssh <user>@<ip>`. Handy since the box normally only has a touchscreen
attached, not a keyboard.

## Remote storage sync (CIFS/SMB)

The box can mount an institutional SMB/CIFS share and copy experiment images to
it automatically as they are captured, replacing the legacy hand-run
`sudo mount -t cifs //ds.asuch.cas.cz/ueb/lhr /mnt/Shared -o user=…,pass=…`.

Settings → General → **Remote Sync** card:

- **On/off toggle**: arms syncing for the researcher currently set on the home
screen.
- **Server / share**: pre-filled with `//ds.asuch.cas.cz/ueb/lhr` and freely
editable. The value is checked against a strict allowlist
(`//host/share[/folder]`, letters, digits, dots, hyphens and underscores
only) before it can reach the mount command; anything else is rejected with
a clear message.
- **Username** and **Password** for the share account. The password field is
masked, and when a password is already set the field shows a fixed-width
placeholder — the UI is never told the real password or its length.
- **Check Connection**: enabled only once a username and password are both
present. It mounts the share (if it isn't already), proves the destination
folder is actually writable, and reports the real error text if not —
"wrong password" and "host unreachable" need different fixes.
- **Sync Entire Folder**: a one-shot bulk copy of *every* local experiment
belonging to the current researcher, not just newly captured images.
- **Status**: mounted / not mounted / credentials needed, the destination path,
the last successful sync time, and a count of files still waiting to be
copied.

**Remote layout** mirrors the legacy convention: the mounted share, then a
subfolder named after the researcher (created if missing), then one folder per
experiment:

```
/mnt/rapidboxes-remote/<researcher>/<YYYY-MM-DD>_<researcher>_<name>/
dark_00000.jpg
metadata.json
<name>.xml
```

Thumbnails are not copied — they are regenerated locally on demand.

**Sync stops** when the toggle is switched off, or when the researcher name
changes (starting an experiment under a different name switches sync off and
says so, rather than quietly writing into someone else's folder).

**The local experiment always wins.** Copying happens on a background queue,
never on the capture path, so a slow, hung or dead share cannot delay the
capture schedule. A failed copy is logged, counted as pending, and retried on
the next capture; it never aborts or errors a running experiment.

### The password is deliberately never written to disk

This is a design decision, not an oversight: the password is held in memory for
the lifetime of the backend process and is written nowhere — not to
`settings.json`, not to `remote_sync.json`, not to logs, and not to any
credentials file that outlives the mount call itself. It is also never returned
by any API endpoint (this box has no authentication and binds `0.0.0.0`, so
anything it serves is readable by anyone on the LAN); the API exposes only a
`passwordSet` boolean.

**The operational consequence: after any restart the password is gone and must
be re-entered.** That includes a reboot, a power blip, and the monthly
`rapidboxes-update.timer` OTA restart. In that state sync does not quietly
pretend to work — the Remote Sync card turns orange and reads **"Inactive —
credentials needed after restart"** until someone re-enters the password and
presses Check Connection. The same tradeoff is stated as helper text next to
the password field and confirmed by a toast when credentials are accepted, so
it is known *before* anyone leaves the box on a long unattended run. Server,
username and the on/off setting all persist normally; only the password does
not.

### What the sudoers entry grants

Mounting needs root, so `deploy/install.sh` installs
`/etc/sudoers.d/rapidboxes` (mode 0440, validated with `visudo -c` **before**
installation — a malformed sudoers file can lock the account out of `sudo`
entirely). It grants the service account exactly two commands and nothing else:

```
Cmnd_Alias RAPIDBOXES_CIFS = \
/usr/bin/mount -t cifs //* /mnt/rapidboxes-remote -o credentials=/run/rapidboxes-cifs/cred-*\,nosuid\,nodev\,noexec\,uid=1000\,gid=1000\,file_mode=0664\,dir_mode=0775, \
/usr/bin/umount /mnt/rapidboxes-remote
<user> ALL=(root) NOPASSWD: RAPIDBOXES_CIFS
```

That is: one fixed mount point, one fixed trailing option string, and no
blanket `ALL`. The hardening options (`nosuid,nodev,noexec` and the
unprivileged uid/gid) come last on purpose — mount options are last-one-wins.
`deploy/uninstall.sh` removes the rule, the mount point and the mount.

The password reaches `mount` through a `credentials=` file created 0600 with
`tempfile.mkstemp` in the service's private `/run/rapidboxes-cifs` directory
(systemd `RuntimeDirectory=`, on tmpfs), and that file is unlinked in a
`finally` the instant `mount` returns, success or failure. It is never passed
as `-o pass=…`, because `ps aux` is world-readable. Every subprocess call uses
a fixed argument list; `shell=True` is never used anywhere in the backend, and
a test enforces that.

**Simulation mode** (`RAPIDBOXES_SIMULATION=1`, i.e. laptop development) never
attempts a real mount: the share is emulated by a local directory so the whole
sync path stays exercisable with no CIFS server present.

## What the programs do

### Tropism program
Expand Down Expand Up @@ -227,9 +329,11 @@ The settings menu has two tabs:

- **Camera**: opens the full camera settings panel.
- **General**: system info (hostname, version, disk space), LED strip segment
editor, IR pin display, software update / rollback controls, and SSH access
info. See [Software updates & version rollback](#software-updates--version-rollback)
and [SSH access](#ssh-access) below.
editor, IR pin display, remote CIFS sync configuration, software update /
rollback controls, and SSH access info. See
[Software updates & version rollback](#software-updates--version-rollback),
[SSH access](#ssh-access) and
[Remote storage sync](#remote-storage-sync-cifssmb) below.

The **X** button closes the settings menu.

Expand Down Expand Up @@ -392,3 +496,8 @@ Compared with the old single-purpose UI flow, the current system now includes:
- **one-click rollback** to the previously-running version, with how-long-it-ran
tracked automatically
- an in-app **SSH access** panel (username, status, IP, ready-to-run command)
- **remote CIFS/SMB sync**: images copied to an institutional share as they are
captured, plus a one-shot bulk copy of a researcher's whole back catalogue —
off the capture path, so a dead share can never stall or fail a running
experiment. The share password is session-only and never written to disk, and
the UI says so plainly both while it is typed and after a restart clears it.
4 changes: 4 additions & 0 deletions back/rapidboxes/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from ..engine.runner import ExperimentRunner
from ..hardware.manager import HardwareManager, build_hardware
from ..models import DeviceSettings
from ..remote_sync import RemoteSyncService
from ..storage import Storage


Expand All @@ -19,6 +20,9 @@ class AppState:
storage: Storage
hw: HardwareManager
runner: ExperimentRunner
# Remote CIFS sync. Holds the session-only password in memory; see
# rapidboxes/remote_sync.py for why it lives here and nowhere else.
sync: RemoteSyncService

async def rebuild_hardware(self, settings: DeviceSettings) -> None:
"""Swap in fresh hardware after a settings change (idle only).
Expand Down
5 changes: 5 additions & 0 deletions back/rapidboxes/api/experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@

@router.post("", response_model=StartResponse)
async def start_experiment(config: ExperimentConfig, state: AppState = Depends(get_state)):
# This is how the backend learns who the active researcher is: the name is
# client-side state (localStorage, see client/lib/session.ts) that arrives
# with every experiment config. Remote sync uses it as the destination
# subfolder, and switches itself off if it changes mid-stream.
state.sync.note_active_researcher(config.username)
return await state.runner.start(config, state.settings.camera)


Expand Down
127 changes: 127 additions & 0 deletions back/rapidboxes/api/remote_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Remote CIFS sync configuration + actions (Settings -> General -> Remote Sync).

Deliberately a separate router from /api/settings: the remote-sync config is
not a "how the image was taken" device setting (it must not travel into the
per-experiment config XML), and separating it keeps the password well away
from the DeviceSettings object that GET /api/settings serialises wholesale.

The password is accepted here on PUT and nowhere else. No route in this file --
or any other -- ever returns it: RemoteSyncStatus has no field for it, only
`passwordSet`.
"""
from __future__ import annotations

from typing import Optional

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel

from ..models import (
RemoteSyncStatus,
RemoteSyncUpdate,
validate_remote_server,
validate_remote_username,
)
from .deps import AppState, get_state

router = APIRouter(prefix="/api/settings/remote-sync", tags=["remote-sync"])


class CheckConnectionResult(BaseModel):
ok: bool
message: str
status: RemoteSyncStatus


class SyncAllRequest(BaseModel):
# The researcher whose experiments to bulk-copy. Defaults to whoever sync
# is currently armed for.
researcher: Optional[str] = None


@router.get("", response_model=RemoteSyncStatus)
async def get_remote_sync(state: AppState = Depends(get_state)):
return state.sync.status()


@router.put("", response_model=RemoteSyncStatus)
async def put_remote_sync(update: RemoteSyncUpdate, state: AppState = Depends(get_state)):
"""Patch the config. Only fields actually sent are touched.

`password` is write-only: it goes into process memory and is not echoed
back, not persisted, and not logged.
"""
sync = state.sync
settings = sync.settings

if update.server is not None:
try:
settings.server = validate_remote_server(update.server)
except ValueError as e:
raise HTTPException(400, str(e))
if update.username is not None:
# An empty username is how the UI clears the field mid-edit; only
# validate something that is actually being set.
if update.username.strip():
try:
settings.username = validate_remote_username(update.username)
except ValueError as e:
raise HTTPException(400, str(e))
else:
settings.username = ""
if update.researcher is not None and update.researcher.strip():
settings.researcher = update.researcher.strip()
if update.password is not None:
sync.set_password(update.password)

if update.enabled is not None:
if update.enabled:
if not settings.username or not sync.password_set:
raise HTTPException(400, "a username and password are required to switch sync on")
if not settings.researcher:
raise HTTPException(400, "no active researcher — set a user name on the home screen first")
settings.enabled = True
else:
settings.enabled = False
# Drop the session password with the toggle: leaving it in memory
# after the user has explicitly turned sync off serves no purpose.
sync.clear_password()
await sync.unmount()

sync.persist()
return sync.status()


@router.post("/check", response_model=CheckConnectionResult)
async def check_connection(state: AppState = Depends(get_state)):
"""Mount (if needed) and prove the destination is actually writable.

Reports the real error text from mount rather than a generic failure --
"wrong password" and "host unreachable" need different fixes.
"""
sync = state.sync
if not sync.settings.username or not sync.password_set:
raise HTTPException(400, "a username and password are required")
ok, message = await sync.check_connection()
return CheckConnectionResult(ok=ok, message=message, status=sync.status())


@router.post("/sync-all", response_model=RemoteSyncStatus)
async def sync_all(request: SyncAllRequest, state: AppState = Depends(get_state)):
"""One-shot bulk copy of every local experiment belonging to this researcher.

Returns immediately; the copy runs on the same background worker as
per-capture syncing, so it can never block an experiment.
"""
sync = state.sync
researcher = (request.researcher or sync.settings.researcher or "").strip()
if not researcher:
raise HTTPException(400, "no researcher given")
if not sync.settings.enabled or not sync.password_set:
raise HTTPException(
400,
"remote sync is not active — switch it on and enter the password "
"(it is not stored and must be re-entered after a restart)",
)
sync.enqueue_bulk(researcher)
return sync.status()
6 changes: 6 additions & 0 deletions back/rapidboxes/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ class AppConfig(BaseSettings):
# convention as settings_path.
update_history_path: Path = Path.home() / "rapidboxes" / "update_history.json"

# Remote CIFS sync (Settings -> General -> Remote Sync). Only the
# non-secret half lives here -- server, CIFS username, on/off, researcher.
# The password is session-only and is never written to this (or any) file;
# see rapidboxes/remote_sync.py.
remote_sync_path: Path = Path.home() / "rapidboxes" / "remote_sync.json"

def ensure_dirs(self) -> None:
self.storage_root.mkdir(parents=True, exist_ok=True)
self.settings_path.parent.mkdir(parents=True, exist_ok=True)
Expand Down
19 changes: 19 additions & 0 deletions back/rapidboxes/engine/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import logging
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Awaitable, Callable, List, Optional, Set, Union

from .. import config_xml
Expand Down Expand Up @@ -112,12 +113,19 @@ def __init__(
now: Optional[Callable[[], float]] = None,
sleep: Optional[Callable[[float], Awaitable[None]]] = None,
tick_seconds: float = 1.0,
on_image_captured: Optional[Callable[[Path, str, str], None]] = None,
):
self._hw = hw
self._storage = storage
self._now = now or (lambda: asyncio.get_event_loop().time())
self._sleep = sleep or asyncio.sleep
self._tick = tick_seconds
# Notified (path, experiment_id, username) right after each capture, so
# remote sync can queue a copy. MUST be synchronous, non-blocking and
# non-throwing -- see _capture, where its failure is swallowed: the
# local experiment's schedule is paramount, the remote copy is
# best-effort.
self._on_image_captured = on_image_captured

self.status = ExperimentStatus()
self._task: Optional[asyncio.Task] = None
Expand Down Expand Up @@ -396,6 +404,17 @@ async def _capture(
self.status.imagesCaptured = idx + 1
self.status.lastImageId = image_id
self._write_metadata(exp)

# Hand the new image to remote sync (if configured). This only drops a
# job on an in-memory queue -- no I/O, no await, no exception escapes:
# a hung or dead network share must never delay the next capture or
# fail the run.
if self._on_image_captured is not None:
try:
self._on_image_captured(path, exp.experiment_id, config.username)
except Exception:
log.warning("remote sync notification failed; experiment continues", exc_info=True)

await self._broadcast()

def _write_metadata(self, exp: ExperimentDir) -> None:
Expand Down
Loading
Loading