onlyoffice: isolated docker network, pinned server sources, strict deadlines

- Dockerfile: pin ONLYOFFICE server sources to a known-good commit
  (build-arg ONLYOFFICE_SERVER_REF) instead of tracking master; master
  gained a 'memory runtime' branch that forks no converter workers on
  community edition, silently breaking all conversions. Also patch that
  branch out as defense in depth.
- setup_docker: run the container on an internal-only network (oonet,
  172.30.0.0/24) with no outbound internet; only host callback traffic
  is possible.
- _get_callback_host: resolve the oonet gateway via docker network
  inspect instead of assuming docker0; fall back to docker0.
- OOConversionManager: cancel the background conversion task when its
  last waiter is cancelled (strict preview deadline), releasing the
  semaphore slot and aborting the hung HTTP request; shield the shared
  future so one waiter's cancellation does not affect others.
- Split timeouts: convert POST 7s (conversion happens inside the
  request with async:false), result PNG download 2s.
This commit is contained in:
2026-08-13 06:40:01 +00:00
parent 7ac373179b
commit 3c1894f337
2 changed files with 123 additions and 18 deletions
+17 -4
View File
@@ -36,10 +36,17 @@ RUN apt-get update -qq && \
ca-certificates && \
rm -rf /var/lib/apt/lists/*
# Clone the open-source server components (shallow, ~15 MB).
# The master branch is used because the Linux/web tags are not published
# in the server repo; the license.js file has been stable for years.
RUN git clone --depth 1 https://github.com/ONLYOFFICE/server.git /opt/oo-server
# Pin the open-source server components to a known-good commit (~15 MB).
# The master branch is a moving target (the Linux/web tags are not published
# in the server repo): a 2026 convertermaster change there detects community
# edition as a "memory runtime" and forks NO converter workers, silently
# breaking all conversions. Pinned to the commit proven in production.
ARG ONLYOFFICE_SERVER_REF=4e56b8d056640557ffcd8c860a65535ab6cbd95b
RUN git init /opt/oo-server && \
cd /opt/oo-server && \
git remote add origin https://github.com/ONLYOFFICE/server.git && \
git fetch --depth 1 origin "$ONLYOFFICE_SERVER_REF" && \
git checkout FETCH_HEAD
# Patch license.js so the converter worker count is read from an env var
# instead of being hardcoded to 1.
@@ -47,6 +54,12 @@ RUN sed -i \
's/count: 1,/count: parseInt(process.env.WORKERS, 10) || 8,/' \
/opt/oo-server/Common/sources/license.js
# Defense in depth: never take the "memory runtime" branch that forks no
# converter workers, even if the pinned ref is bumped carelessly.
RUN sed -i \
's/runtimeProfile\.isMemoryRuntime()/false \/* patched: always fork converter workers *\//g' \
/opt/oo-server/FileConverter/sources/convertermaster.js
# Install npm dependencies for the modules the FileConverter touches.
# DocService deps are also needed because converter.js pulls in baseConnector.
RUN cd /opt/oo-server/Common && npm ci --no-audit --no-fund
+106 -14
View File
@@ -47,6 +47,11 @@ except ImportError: # pragma: no cover - optional office extra
logger = logging.getLogger(__name__)
# Isolated docker network for the OnlyOffice container: internal-only (no
# outbound internet), the container can only reach the host on this bridge.
OO_NETWORK = "oonet"
OO_SUBNET = "172.30.0.0/24"
# ---------------------------------------------------------------------------
# Configuration helpers
# ---------------------------------------------------------------------------
@@ -67,12 +72,41 @@ def _get_jwt_secret() -> str:
return os.environ.get("ONLYOFFICE_JWT_SECRET", "")
def _docker_network_gateway(network: str) -> str | None:
"""Return the host-side gateway IP of a docker network, or None."""
try:
result = subprocess.run(
[
"docker",
"network",
"inspect",
network,
"--format",
"{{range .IPAM.Config}}{{.Gateway}}{{end}}",
],
capture_output=True,
text=True,
timeout=2,
check=False,
)
gateway = result.stdout.strip()
if result.returncode == 0 and gateway:
return gateway
except Exception:
logger.debug("Failed to inspect docker network %s", network)
return None
@lru_cache(maxsize=1)
def _get_callback_host() -> str:
"""Return the host IP that OnlyOffice (usually in Docker) can use to reach us."""
"""Return the host IP that OnlyOffice (in Docker) can use to reach us."""
if host := os.environ.get("ONLYOFFICE_CALLBACK_HOST"):
return host
# Try to auto-detect docker bridge IP
# Prefer the gateway of the isolated network setup_docker() creates —
# this is the network the container is actually attached to.
if gateway := _docker_network_gateway(OO_NETWORK):
return gateway
# Fall back to the default docker bridge IP
try:
result = subprocess.run(
["/sbin/ip", "-4", "addr", "show", "docker0"],
@@ -182,6 +216,24 @@ def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str
if result.returncode != 0:
raise RuntimeError("Failed to build OnlyOffice image")
# Isolated network: internal-only, so the container has no outbound
# internet access and can only reach the host on this bridge (needed
# for the preview file callback). Already-exists is fine.
net_cmd = [
"docker",
"network",
"create",
"--internal",
"--subnet",
OO_SUBNET,
OO_NETWORK,
]
result = subprocess.run(net_cmd, capture_output=True, check=False) # noqa: S603
if result.returncode != 0 and b"already exists" not in result.stderr:
raise RuntimeError(
f"Failed to create docker network {OO_NETWORK}: {result.stderr.decode(errors='replace').strip()}"
)
logger.info("Starting OnlyOffice container")
run_cmd = [
"docker",
@@ -189,6 +241,8 @@ def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str
"-d",
"-p",
f"{port}:80",
"--network",
OO_NETWORK,
"-e",
f"JWT_SECRET={secret}",
"-e",
@@ -204,6 +258,9 @@ def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str
if result.returncode != 0:
raise RuntimeError("Failed to start OnlyOffice container")
logger.info("OnlyOffice is running on http://localhost:%d", port)
logger.info(
"Callback host for file downloads: %s", _docker_network_gateway(OO_NETWORK)
)
return secret
@@ -297,9 +354,14 @@ def _build_jwt_token(payload: dict) -> str | None:
return jwt.encode(payload, secret, algorithm="HS256")
async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) -> bytes:
async def convert_to_png_async(
file_path: Path, request_timeout: float = 7.0, download_timeout: float = 2.0
) -> bytes:
"""Convert *file_path* to PNG using OnlyOffice Document Server (async).
With ``async: false`` the conversion itself runs inside the POST request,
so *request_timeout* must cover full conversion time. *download_timeout*
covers fetching the resulting one-page PNG, which is pure transfer.
Returns the PNG bytes. Raises RuntimeError on failure.
"""
if httpx is None or jwt is None:
@@ -367,10 +429,10 @@ async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) ->
# Download converted PNG
try:
png_response = await client.get(file_url, timeout=request_timeout)
png_response = await client.get(file_url, timeout=download_timeout)
png_response.raise_for_status()
except httpx.TimeoutException as e:
raise preview_timeout_error("onlyoffice", request_timeout) from e
raise preview_timeout_error("onlyoffice", download_timeout) from e
except httpx.HTTPStatusError as e:
raise onlyoffice_http_error(e.response.status_code) from e
except httpx.RequestError as e:
@@ -391,12 +453,23 @@ async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) ->
OO_MAX_CONCURRENT = max(2, min(8, cpu_count()))
class _InFlight:
"""A deduplicated conversion: shared future, its task, and waiter count."""
__slots__ = ("future", "task", "waiters")
def __init__(self, future: asyncio.Future[bytes], task: asyncio.Task[None]):
self.future = future
self.task = task
self.waiters = 0
class OOConversionManager:
"""Manages async OnlyOffice conversions with deduplication and concurrency limits."""
def __init__(self, max_concurrent: int = OO_MAX_CONCURRENT):
self._semaphore = asyncio.Semaphore(max_concurrent)
self._in_flight: dict[str, asyncio.Future[bytes]] = {}
self._in_flight: dict[str, _InFlight] = {}
self._tasks: set[asyncio.Task[None]] = set()
self._lock = asyncio.Lock()
@@ -408,31 +481,50 @@ class OOConversionManager:
key = f"{filepath}:{stat.st_mtime_ns}"
async with self._lock:
if key in self._in_flight:
future = self._in_flight[key]
else:
entry = self._in_flight.get(key)
if entry is None:
future = asyncio.get_running_loop().create_future()
self._in_flight[key] = future
task = asyncio.create_task(self._do_convert(filepath, key, future))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
entry = _InFlight(future, task)
self._in_flight[key] = entry
entry.waiters += 1
return await future
try:
# shield: one waiter's cancellation must not cancel the future
# shared with other waiters.
return await asyncio.shield(entry.future)
except asyncio.CancelledError:
# The caller hit the (strict) preview deadline or disconnected.
# When no other waiter remains, cancel the background task so it
# releases its semaphore slot and aborts the HTTP request instead
# of running orphaned and piling load onto OnlyOffice.
async with self._lock:
entry.waiters -= 1
orphan = entry.waiters == 0
if orphan:
entry.task.cancel()
raise
async def _do_convert(
self, filepath: Path, key: str, future: asyncio.Future[bytes]
) -> None:
try:
async with self._semaphore:
png_bytes = await convert_to_png_async(filepath, request_timeout=5.0)
png_bytes = await convert_to_png_async(filepath)
except asyncio.CancelledError:
# All waiters gave up; cancel the future so nothing hangs on it.
if not future.done():
future.cancel()
raise
except Exception as e:
if not future.done():
future.set_exception(e)
async with self._lock:
self._in_flight.pop(key, None)
else:
if not future.done():
future.set_result(png_bytes)
finally:
async with self._lock:
self._in_flight.pop(key, None)