diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-19 01:02:05 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-19 01:49:56 +0200 |
| commit | c2c49182e6d4f1f1ef3ed3ba5e8699912f9a27bd (patch) | |
| tree | 026a7e4347d4580ed4914605019db50c5794a49b /packages | |
| parent | 345a59fc128e5e4f253c277c8cee69a2b1062a4f (diff) | |
| download | meshbay-c2c49182e6d4f1f1ef3ed3ba5e8699912f9a27bd.tar.gz | |
feat(node): per-file upload ceiling is an operator setting, default 8 GB
Was a 4 GB constant in webrtc_server.py, the same on a small board and on
a machine holding a library. Now max_upload_gb in node.toml, on the Node
page and via `meshbay-node transfers max-size`, read from the transport
context per chunk so a change reaches an upload already running.
MESHBAY_DESIGN.md §6.4; §15.3 records a defect found beside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
20 files changed, 331 insertions, 13 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 9d9319d..2d925e1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -886,6 +886,8 @@ export default { 'node.setting_max_downloads': 'Max. gleichzeitige Downloads', 'node.setting_max_uploads': 'Max. gleichzeitige Uploads', 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_max_upload': 'Größter einzelner Upload', + 'node.setting_unit_gb': 'GB', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', 'node.setting_on': 'On', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index a928a52..690a70b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -1038,6 +1038,8 @@ export default { 'node.setting_max_downloads': 'Max concurrent downloads', 'node.setting_max_uploads': 'Max concurrent uploads', 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_max_upload': 'Largest single upload', + 'node.setting_unit_gb': 'GB', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', 'node.setting_on': 'On', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 2e6ea26..c48f942 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -881,6 +881,8 @@ export default { 'node.setting_max_downloads': 'Descargas simultáneas máximas', 'node.setting_max_uploads': 'Subidas simultáneas máximas', 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_max_upload': 'Subida individual máxima', + 'node.setting_unit_gb': 'GB', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', 'node.setting_on': 'On', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 5e6f258..6d148d6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -884,6 +884,8 @@ export default { 'node.setting_max_downloads': 'Téléchargements simultanés max', 'node.setting_max_uploads': 'Téléversements simultanés max', 'node.setting_transcode': 'Transcoder les vidéos incompatibles', + 'node.setting_max_upload': 'Taille maximale d\'un envoi', + 'node.setting_unit_gb': 'GB', 'node.setting_unit_hours': 'heures', 'node.setting_unit_minutes': 'min', 'node.setting_on': 'Activé', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index ba594d2..c619d61 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -883,6 +883,8 @@ export default { 'node.setting_max_downloads': 'Download simultanei massimi', 'node.setting_max_uploads': 'Caricamenti simultanei massimi', 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_max_upload': 'Caricamento singolo massimo', + 'node.setting_unit_gb': 'GB', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', 'node.setting_on': 'On', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 9bbafb2..eac90dc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -871,6 +871,8 @@ export default { 'node.setting_max_downloads': '同時ダウンロードの上限', 'node.setting_max_uploads': '同時アップロードの上限', 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_max_upload': '1ファイルあたりの上限', + 'node.setting_unit_gb': 'GB', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', 'node.setting_on': 'On', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 8f28d0c..65bf4f5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -885,6 +885,8 @@ export default { 'node.setting_max_downloads': 'Max. gelijktijdige downloads', 'node.setting_max_uploads': 'Max. gelijktijdige uploads', 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_max_upload': 'Grootste enkele upload', + 'node.setting_unit_gb': 'GB', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', 'node.setting_on': 'On', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index fadf622..264d442 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -903,6 +903,8 @@ export default { 'node.setting_max_downloads': 'Maks. równoczesnych pobierań', 'node.setting_max_uploads': 'Maks. równoczesnych wysyłek', 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_max_upload': 'Największa pojedyncza wysyłka', + 'node.setting_unit_gb': 'GB', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', 'node.setting_on': 'On', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 39da2b2..0fbfb1e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -882,6 +882,8 @@ export default { 'node.setting_max_downloads': 'Máximo de downloads simultâneos', 'node.setting_max_uploads': 'Máximo de envios simultâneos', 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_max_upload': 'Maior envio individual', + 'node.setting_unit_gb': 'GB', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', 'node.setting_on': 'On', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 8639592..ab75b11 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -858,6 +858,8 @@ export default { 'node.setting_max_downloads': '最大同时下载数', 'node.setting_max_uploads': '最大同时上传数', 'node.setting_transcode': 'Transcode incompatible video', + 'node.setting_max_upload': '单个上传的最大大小', + 'node.setting_unit_gb': 'GB', 'node.setting_unit_hours': 'hours', 'node.setting_unit_minutes': 'min', 'node.setting_on': 'On', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index 0f75edc..2edcbab 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -1068,6 +1068,14 @@ export function NodePage({ groups, token, username }) { </div> </label> <label class="node-setting"> + <span class="node-setting-label">${t('node.setting_max_upload')}</span> + <div class="node-setting-input"> + <input type="number" min="0.1" step="0.5" value=${editSettings.max_upload_gb} + onInput=${e => setEditSettings(s => ({...s, max_upload_gb: parseFloat(e.target.value) || 0.1}))} /> + <span class="node-setting-unit">${t('node.setting_unit_gb')}</span> + </div> + </label> + <label class="node-setting"> <span class="node-setting-label">${t('node.setting_transcode')}</span> <div class="node-setting-input"> <input type="checkbox" checked=${editSettings.transcode_incompatible_video} diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index 086d800..bf62639 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -67,6 +67,13 @@ max_concurrent_streams = 8 max_concurrent_downloads = 8 max_concurrent_uploads = 8 +# The largest single file a member may upload to this node, in GB. It is this +# machine's disk that fills, so the ceiling is the operator's to set: lower it +# on a small disk, raise it for a library of films. Fractions are allowed +# (0.5 = 512 MB). A file past it is refused at the chunk that crosses it and +# the partial file is deleted. +max_upload_gb = 8 + # HEVC sources have no browser decoder on most platforms, so streaming one is # transcoded to H264 rather than the usual free copy — real CPU per viewer. # Set to false only if every viewer's client is known to decode HEVC itself. @@ -181,6 +188,11 @@ class NodeConfig: # answer "server busy". See meshbay_node/transfers.py. max_concurrent_downloads: int = 8 max_concurrent_uploads: int = 8 + # The per-file upload ceiling, in GB — the one limit here that bounds a + # member's writes to the operator's disk rather than this node's own + # concurrency. There is still no aggregate quota (MESHBAY_DESIGN.md + # §15.3), so this is what stands between a writable root and a full disk. + max_upload_gb: float = 8.0 # HEVC (and any future codec in media_probe.py's # BROWSER_INCOMPATIBLE_VIDEO_CODECS) has no decoder in most browsers, so # streaming it needs a real re-encode to H264 rather than the usual free @@ -301,6 +313,28 @@ def _positive(value: object, default: int, name: str) -> int: return n +def _positive_float(value: object, default: float, name: str) -> float: + """A size that must be greater than zero, or the default with a word about it. + + Separate from `_positive` because this one is a quantity, not a count: half + a gigabyte is a legitimate ceiling on a small disk, and rounding it to zero + would refuse every upload with nothing in the log to say why. + """ + if isinstance(value, bool) or not isinstance(value, (int, float, str)): + log.warning("%s = %r is not a size — using %g", name, value, default) + return default + try: + n = float(value) + except (TypeError, ValueError): + log.warning("%s = %r is not a number — using %g", name, value, default) + return default + if n <= 0: + log.warning("%s = %g would refuse every upload — using %g", + name, n, default) + return default + return n + + def _read_roots(group: dict) -> list[RootSpec]: """ A group's roots, from `[[groups.roots]]` or from the legacy `shared_dir`. @@ -368,6 +402,9 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: cfg.node.max_concurrent_uploads = _positive( nd.get("max_concurrent_uploads", cfg.node.max_concurrent_uploads), cfg.node.max_concurrent_uploads, "max_concurrent_uploads") + cfg.node.max_upload_gb = _positive_float( + nd.get("max_upload_gb", cfg.node.max_upload_gb), + cfg.node.max_upload_gb, "max_upload_gb") cfg.node.transcode_incompatible_video = bool( nd.get("transcode_incompatible_video", cfg.node.transcode_incompatible_video)) cfg.node.hardware_video_encode = bool( diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 26f73fc..1525205 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -300,6 +300,7 @@ class NodeDaemon: "pair_ttl_hours": nd.pair_ttl_hours, "device_request_ttl_minutes": nd.device_request_ttl_minutes, "max_concurrent_streams": nd.max_concurrent_streams, + "max_upload_gb": nd.max_upload_gb, "transcode_incompatible_video": nd.transcode_incompatible_video, "stun_servers": nd.stun_servers if nd.stun_servers else list(DEFAULT_STUN_SERVERS), "ice_interfaces": nd.ice_interfaces, @@ -539,6 +540,7 @@ class NodeDaemon: max_concurrent_streams=self._config.node.max_concurrent_streams, max_concurrent_downloads=self._config.node.max_concurrent_downloads, max_concurrent_uploads=self._config.node.max_concurrent_uploads, + max_upload_gb=self._config.node.max_upload_gb, transcode_incompatible_video=self._config.node.transcode_incompatible_video, stun_servers=self._config.node.stun_servers or None, ) @@ -1971,9 +1973,10 @@ def main() -> None: "| chat status|rotate|encrypt-history|prune " "| denylist show|clear " "| stun list|add|remove|reset " - "| transfers show|set|per-member: live transfer " - "slots, the node-wide caps, and how many one " - "member may run at once in a group " + "| transfers show|set|max-size|per-member: live " + "transfer slots, the node-wide caps, the largest " + "single upload, and how many one member may run " + "at once in a group " "| reload: re-read node.toml (hot; systemd or the " "loopback API) | restart-daemon: restart the node " "(systemd unit, the Windows autostart launcher, or the " @@ -1991,13 +1994,14 @@ def main() -> None: "init|rotate for gek; " "list|rm for file; rematch for video; show|clear for " "denylist; list|add|remove|reset for stun; " - "show|set|per-member for transfers; " + "show|set|max-size|per-member for transfers; " "install|remove|start|stop|status for autostart and " "for service") parser.add_argument("target", nargs="?", help="username for member invite|revoke|unpin; group name " "for group add; file id for file rm; identifier for " - "denylist clear; download cap for transfers set") + "denylist clear; download cap for transfers set; " + "size in GB for transfers max-size") parser.add_argument("value", nargs="?", help="the second value where a verb takes two: the " "upload cap for transfers set") @@ -2733,6 +2737,14 @@ def main() -> None: for kind, pool in out.get("pools", {}).items(): print(f" {kind:<9} {pool['in_use']}/{pool['cap']} in use, " f"{pool['queued']} queued (node-wide)") + # From the daemon, not from node.toml: a change made on the Node + # page is live before it is written back, and the number to print + # is the one being enforced. The local file is the fallback rather + # than a literal, so there is no second copy of the default here. + settings = _daemon_api(cfg, "/api/node-settings") + gb = settings.get("max_upload_gb") or cfg.node.max_upload_gb + print(f"\n largest single upload: {gb:g} GB per file " + f"(meshbay-node transfers max-size <GB>)") # Per group, because that is the cap that decides how many one # person runs at once — and it is not the node-wide number. An # operator raising `transfers set 8 8` and still seeing two at a @@ -2782,6 +2794,29 @@ def main() -> None: f"(applied now, and kept in node.toml)") return + if sub == "max-size": + # The largest single file a member may upload here. Not a + # concurrency cap like `set` — it is the one limit that bounds what + # a member writes to the operator's disk, which is why it lives + # beside them rather than under a verb of its own. + if not args.target: + print("usage: meshbay-node transfers max-size <GB>") + sys.exit(1) + try: + gb = float(args.target) + except ValueError: + print("error: the size must be a number of GB (e.g. 8, or 0.5)") + sys.exit(1) + if gb <= 0: + print("error: a ceiling of zero is not 'unlimited'; it would " + "refuse every upload. Make the root read-only instead.") + sys.exit(1) + _daemon_api(cfg, "/api/node-settings", method="PUT", + body={"max_upload_gb": gb}) + print(f"largest single upload: {gb:g} GB per file " + f"(applied now, and kept in node.toml)") + return + if sub == "per-member": # How many transfers ONE member may run at once in this group. Not # the same knob as `set`, which is the machine's total — and the @@ -2815,7 +2850,7 @@ def main() -> None: return print("usage: meshbay-node transfers show|set <downloads> <uploads>|" - "per-member <downloads> <uploads> [--group NAME]") + "max-size <GB>|per-member <downloads> <uploads> [--group NAME]") sys.exit(1) if args.command == "file": diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index 2221bea..9f0e6d1 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -1301,6 +1301,7 @@ async def get_node_settings(state: dict) -> dict: "max_concurrent_streams": nd.max_concurrent_streams, "max_concurrent_downloads": nd.max_concurrent_downloads, "max_concurrent_uploads": nd.max_concurrent_uploads, + "max_upload_gb": nd.max_upload_gb, "transcode_incompatible_video": nd.transcode_incompatible_video, "stun_servers": nd.stun_servers if nd.stun_servers else list(DEFAULT_STUN_SERVERS), "ice_interfaces": nd.ice_interfaces, @@ -1323,6 +1324,7 @@ async def set_node_settings(state: dict, settings: dict) -> dict: "max_concurrent_streams": ("int", roster.SETTING_MAX_STREAMS), "max_concurrent_downloads": ("int", roster.SETTING_MAX_DOWNLOADS), "max_concurrent_uploads": ("int", roster.SETTING_MAX_UPLOADS), + "max_upload_gb": ("size", roster.SETTING_MAX_UPLOAD_GB), "transcode_incompatible_video": ("bool", roster.SETTING_TRANSCODE), "stun_servers": ("stun_list", roster.SETTING_STUN_SERVERS), "ice_interfaces": ("list", roster.SETTING_ICE_INTERFACES), @@ -1344,6 +1346,22 @@ async def set_node_settings(state: dict, settings: dict) -> dict: setattr(nd, key, v) await roster.set_node_setting(setting_key, str(v), set_by) updated[key] = v + elif kind == "size": + # A quantity, not a count: half a gigabyte is a legitimate ceiling + # on a small disk, so this one is not run through the `int` branch + # above, whose floor of 1 would round it to "refuse everything". + # bool before float, as config.py does it: `true` is not 1 GB. + if isinstance(value, bool): + raise OpError(f"{key} must be a number") + try: + fv = float(value) + except (TypeError, ValueError): + raise OpError(f"{key} must be a number") + if fv <= 0: + raise OpError(f"{key} must be greater than zero") + setattr(nd, key, fv) + await roster.set_node_setting(setting_key, repr(fv), set_by) + updated[key] = fv elif kind == "bool": v = bool(value) setattr(nd, key, v) @@ -1382,6 +1400,10 @@ async def set_node_settings(state: dict, settings: dict) -> dict: "max_concurrent_downloads"), max_concurrent_uploads=updated.get( "max_concurrent_uploads")) + if "max_upload_gb" in updated: + webrtc = state.get("webrtc") + if webrtc is not None: + webrtc.set_capacity(max_upload_gb=updated["max_upload_gb"]) if "stun_servers" in updated: webrtc = state.get("webrtc") if webrtc and hasattr(webrtc, '_stun'): diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index b0ca78b..474db31 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -983,6 +983,7 @@ class Roster: SETTING_MAX_STREAMS = "max_concurrent_streams" SETTING_MAX_DOWNLOADS = "max_concurrent_downloads" SETTING_MAX_UPLOADS = "max_concurrent_uploads" + SETTING_MAX_UPLOAD_GB = "max_upload_gb" SETTING_TRANSCODE = "transcode_incompatible_video" SETTING_STUN_SERVERS = "stun_servers" SETTING_ICE_INTERFACES = "ice_interfaces" @@ -999,11 +1000,14 @@ class Roster: ("max_concurrent_downloads", self.SETTING_MAX_DOWNLOADS), ("max_concurrent_uploads", self.SETTING_MAX_UPLOADS), ("transcode_incompatible_video", self.SETTING_TRANSCODE), + ("max_upload_gb", self.SETTING_MAX_UPLOAD_GB), ]: stored = await self.get_setting(self.NODE_WIDE_GROUP_ID, setting) if stored is not None: if key == "transcode_incompatible_video": result[key] = stored != "0" + elif key == "max_upload_gb": + result[key] = float(stored) else: result[key] = int(stored) else: diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 3bb0df7..9ea70d8 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -238,7 +238,13 @@ _CHAT_RATE_MAX_TRACKED = 1000 # a name the client chose, overwriting whatever was already there — which both violated # node sovereignty and defeated the delete authorization (overwrite a file, become its # recorded uploader, then delete it legitimately). -MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB per file +# The ceiling is the operator's to set (`max_upload_gb` in node.toml, the Node +# page and `meshbay-node transfers max-size`) because it is their disk that +# fills: this is only the default a node starts from when they have said +# nothing. It is read from the transport context on every chunk, so a change +# applies to an upload already in flight. +MAX_UPLOAD_BYTES = 8 * 1024 * 1024 * 1024 # 8 GB per file +GB_BYTES = 1024 * 1024 * 1024 # What the `tr` on a chunk request turned out to be (see `_lease_of`). LEASE_GRANTED = "granted" @@ -3516,7 +3522,21 @@ class WebRTCPeerSession: "queued": len(progress.queued), } - # ── Transfer slots ─────────────────────────────────────────────────────── + # ── Upload ceiling and transfer slots ──────────────────────────────────── + + def _max_upload_bytes(self) -> int: + """The per-file upload ceiling this node is running with, in bytes. + + Read from the transport context rather than captured once, for the same + reason the transfer pools are refreshed there: the operator can change + it from the Node page or the CLI while an upload is running, and a + ceiling that only applies after a restart is not the one they were + shown. `None` means they have said nothing and the default stands. + """ + gb = self._ctx.get("max_upload_gb") + if not gb: + return MAX_UPLOAD_BYTES + return max(1, int(float(gb) * GB_BYTES)) def _slots(self) -> "TransferSlots": """The node's transfer pools, shared across every peer and every group. @@ -5498,7 +5518,7 @@ class WebRTCPeerSession: _refuse("Unexpected chunk index", "bad_chunk_index") return - if state.bytes + len(chunk_bytes) > MAX_UPLOAD_BYTES: + if state.bytes + len(chunk_bytes) > self._max_upload_bytes(): uploads.drop(user_id, rel_dir, filename) await off_disk(roots, tmp_path.unlink, True) _refuse("Upload exceeds size limit", "too_large") @@ -6867,6 +6887,7 @@ class WebRTCTransport: max_concurrent_streams: int | None = None, max_concurrent_downloads: int | None = None, max_concurrent_uploads: int | None = None, + max_upload_gb: float | None = None, transcode_incompatible_video: bool = True, ): self._ctx: dict[str, Any] = { @@ -6883,6 +6904,9 @@ class WebRTCTransport: # the operator said nothing and transfers.py's defaults apply. "max_concurrent_downloads": max_concurrent_downloads, "max_concurrent_uploads": max_concurrent_uploads, + # The per-file upload ceiling, in GB. None means the operator said + # nothing and MAX_UPLOAD_BYTES stands. + "max_upload_gb": max_upload_gb, # Operator opt-out (node.toml) for the HEVC-etc. transcode # fallback in _stream_video_inner — real CPU cost, unlike copy. "transcode_incompatible_video": transcode_incompatible_video, @@ -6898,7 +6922,8 @@ class WebRTCTransport: def set_capacity(self, *, max_concurrent_streams: int | None = None, max_concurrent_downloads: int | None = None, - max_concurrent_uploads: int | None = None) -> dict: + max_concurrent_uploads: int | None = None, + max_upload_gb: float | None = None) -> dict: """Resize a live pool without restarting the daemon. `ops.set_node_settings` used to do this by assigning @@ -6965,6 +6990,14 @@ class WebRTCTransport: # prevent. for lease in granted: self._notify_granted(lease) + + if max_upload_gb is not None: + gb = float(max_upload_gb) + if gb <= 0: + raise ValueError("max_upload_gb must be greater than zero") + self._ctx["max_upload_gb"] = gb + changed["max_upload_gb"] = gb + log.info("upload: per-file ceiling now %g GB", gb) return changed def _notify_granted(self, lease) -> None: diff --git a/packages/meshbay-node/tests/test_chat_is_bounded.py b/packages/meshbay-node/tests/test_chat_is_bounded.py index 1af72b4..4e767bc 100644 --- a/packages/meshbay-node/tests/test_chat_is_bounded.py +++ b/packages/meshbay-node/tests/test_chat_is_bounded.py @@ -13,7 +13,7 @@ it in `chat.db` on the operator's disk, where nothing expires it — retention i a manual CLI command (§6.6) — relays it to every other connected member, and has the hub write a notification for every member of the group. Uploads, the other member-supplied write, have carried a filename allowlist, strict chunk -ordering, a no-overwrite rule and a 4 GB cap since C5a. Chat carried nothing: +ordering, a no-overwrite rule and a per-file size cap since C5a. Chat carried nothing: the only ceiling was the DataChannel frame, 64 MB once the handshake is done. One member in a loop filled the operator's disk and saturated everyone else's connection, and the node's own answer to each message was `ack`. diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index 64f96f1..ce661b5 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -51,6 +51,11 @@ VERBS = [ ["transfers", "set", "4", "2"], ["transfers", "set", "4"], # only one number: usage, then exit ["transfers", "set", "0", "2"], # zero is not "unlimited": refused + ["transfers", "max-size", "8"], + ["transfers", "max-size", "0.5"], # a fraction of a GB is legitimate + ["transfers", "max-size"], # no size: usage, then exit + ["transfers", "max-size", "0"], # zero is not "unlimited": refused + ["transfers", "max-size", "huge"], # not a number: refused ["transfers", "per-member", "4", "2"], ["transfers", "per-member", "4"], # only one number: usage, then exit ["transfers", "per-member", "0", "2"], # zero is refused here too diff --git a/packages/meshbay-node/tests/test_transfer_settings.py b/packages/meshbay-node/tests/test_transfer_settings.py index 79502e7..7f3719d 100644 --- a/packages/meshbay-node/tests/test_transfer_settings.py +++ b/packages/meshbay-node/tests/test_transfer_settings.py @@ -138,15 +138,17 @@ async def test_the_node_wide_caps_round_trip_through_the_roster(roster): **{k: None for k in ("invite_ttl_hours", "pair_ttl_hours", "device_request_ttl_minutes", "max_concurrent_streams", - "transcode_incompatible_video")}, + "transcode_incompatible_video", + "max_upload_gb")}, "stun_servers": [], "ice_interfaces": [], } await roster.set_node_setting(roster.SETTING_MAX_DOWNLOADS, "3", "op") assert (await roster.node_settings(defaults))["max_concurrent_downloads"] == 3 -def test_node_toml_carries_both_keys(): +def test_node_toml_carries_the_upload_keys(): """The template is what an operator reads before they read any document.""" from meshbay_node.config import EXAMPLE_CONFIG as tpl assert "max_concurrent_downloads" in tpl assert "max_concurrent_uploads" in tpl + assert "max_upload_gb" in tpl diff --git a/packages/meshbay-node/tests/test_upload_size_cap.py b/packages/meshbay-node/tests/test_upload_size_cap.py new file mode 100644 index 0000000..dca6e15 --- /dev/null +++ b/packages/meshbay-node/tests/test_upload_size_cap.py @@ -0,0 +1,152 @@ +""" +How large a single upload may be, and who decides. + +The cap used to be a constant: 4 GB, in `webrtc_server.py`, the same on a Pi +with a 32 GB card and on a machine holding a film library. It is the operator's +disk that fills, so the number is theirs — `max_upload_gb` under [node] in +node.toml, the Node page, and `meshbay-node transfers max-size`, with the +constant as the default when they have said nothing. + +These follow the value along the whole path rather than checking that the field +parses, for the reason `test_stream_capacity_config.py` gives: every join in +such a path has been wrong at least once, and a ceiling read from the wrong +place fails only when somebody sends a large file. +""" + +import textwrap +from pathlib import Path + +import pytest + +from meshbay_node.config import load_config +from meshbay_node.roots import RootSet +from meshbay_node.transport.webrtc_server import ( + GB_BYTES, + MAX_UPLOAD_BYTES, + WebRTCPeerSession, + WebRTCTransport, +) + + +def _cfg(tmp_path: Path, body: str): + p = tmp_path / "node.toml" + p.write_text(textwrap.dedent(body)) + return load_config(p) + + +class _FakePC: + def on(self, *a, **k): + return lambda f: f + + +def _session(gb): + t = WebRTCTransport( + sk_node=None, hub_pk_pem=b"", gek=b"\0" * 32, + roots=RootSet(), index=None, max_upload_gb=gb) + return t, WebRTCPeerSession(_FakePC(), t._ctx, peer_id="p") + + +# ── What the operator writes ───────────────────────────────────────────────── + +def test_the_default_is_eight_gb(): + assert MAX_UPLOAD_BYTES == 8 * GB_BYTES + + +def test_the_operator_sets_it(tmp_path): + cfg = _cfg(tmp_path, """ + [node] + max_upload_gb = 20 + """) + assert cfg.node.max_upload_gb == 20 + + +def test_a_fraction_of_a_gigabyte_is_legitimate(tmp_path): + """Not a count, so it does not get `_positive`'s floor of one. + + A node on a small disk may well want to stop at half a gigabyte, and + rounding that to zero would refuse every upload. + """ + cfg = _cfg(tmp_path, """ + [node] + max_upload_gb = 0.5 + """) + assert cfg.node.max_upload_gb == 0.5 + _t, s = _session(0.5) + assert s._max_upload_bytes() == GB_BYTES // 2 + + +def test_saying_nothing_gets_the_default(tmp_path): + cfg = _cfg(tmp_path, """ + [node] + quic_port = 19010 + """) + assert cfg.node.max_upload_gb * GB_BYTES == MAX_UPLOAD_BYTES, ( + "the config default and the source default disagree, so the ceiling " + "depends on whether a node.toml happens to mention it") + + +@pytest.mark.parametrize("value", ["0", "-2", '"lots"', "true"]) +def test_a_value_that_would_refuse_every_upload_is_refused(tmp_path, value, caplog): + cfg = _cfg(tmp_path, f""" + [node] + max_upload_gb = {value} + """) + assert cfg.node.max_upload_gb * GB_BYTES == MAX_UPLOAD_BYTES + assert "max_upload_gb" in caplog.text, ( + "the value was silently discarded — the operator has no way to learn " + "their setting is not in effect") + + +# ── That the number reaches the check it governs ───────────────────────────── + +@pytest.mark.parametrize("gb,expect", [ + (None, MAX_UPLOAD_BYTES), + (2, 2 * GB_BYTES), + (16, 16 * GB_BYTES), +]) +def test_the_configured_size_is_what_the_handler_enforces(gb, expect): + _t, s = _session(gb) + assert s._max_upload_bytes() == expect + + +def test_the_handler_reads_it_rather_than_the_constant(): + """A ceiling captured once is one the operator cannot change. + + The check runs per chunk, so raising the cap has to reach an upload that + is already in flight — which it does only if the handler asks the context + each time instead of closing over the constant. + """ + src = (Path(__file__).resolve().parents[1] / "src" / "meshbay_node" + / "transport" / "webrtc_server.py").read_text(encoding="utf-8") + i = src.index("Upload exceeds size limit") + check = src[src.rindex("if state.bytes", 0, i):i] + assert "_max_upload_bytes()" in check, ( + "the upload check reads the module constant, so node.toml, the Node " + "page and the CLI are all read and then ignored") + + +def test_raising_it_reaches_an_upload_already_running(): + t, s = _session(2) + assert s._max_upload_bytes() == 2 * GB_BYTES + t.set_capacity(max_upload_gb=10) + assert s._max_upload_bytes() == 10 * GB_BYTES, ( + "the session kept the ceiling it started with, so the setting only " + "takes effect on a restart" + ) + + +def test_zero_is_refused_at_the_transport_too(): + t, _s = _session(4) + with pytest.raises(ValueError): + t.set_capacity(max_upload_gb=0) + + +def test_the_daemon_passes_it(): + """The join that syntax checking cannot see.""" + daemon = (Path(__file__).resolve().parents[1] / "src" / "meshbay_node" + / "daemon.py").read_text(encoding="utf-8") + i = daemon.index("WebRTCTransport(") + call = daemon[i:daemon.index(")", daemon.index("denylist=denylist", i))] + assert "max_upload_gb=self._config.node.max_upload_gb" in call, ( + "the daemon builds the transport without the operator's ceiling, so " + "node.toml is read and then ignored") |