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
4 changes: 3 additions & 1 deletion docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,9 @@ legacy protocol) and remote port forwarding. SFTP runs the
sandbox's own SFTP server over one persistent session for each SSH
connection, thus repeated file operations on one connection start no new
session. The session closes after a short idle period, so an inactive
sandbox still sleeps.
sandbox still sleeps. The gateway answers a client's `limits@openssh.com`
request with the sandbox server's own limits, so the client sizes its
packets to what that server accepts.

Forward a local port to a service that listens on the sandbox loopback:

Expand Down
7 changes: 4 additions & 3 deletions src/gateway/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ async def _acquire(self) -> SFTPClientHandler:
if self._idle_closer:
self._idle_closer.cancel()
self._idle_closer = None
if self._handler is None:
if not self._handler:
await self._open()
assert self._handler is not None
assert self._handler
self._in_flight += 1
return self._handler

Expand Down Expand Up @@ -152,11 +152,12 @@ async def _open(self) -> None:
)
try:
await handler.start()
self._recv_task = asyncio.create_task(handler.recv_packets())
await handler.request_limits()
except Exception:
await self._teardown()
raise
self._handler = handler
self._recv_task = asyncio.create_task(handler.recv_packets())
logger.info("gateway: sftp backend open host=%s", self._host_name)

async def _close_after_idle(self) -> None:
Expand Down
3 changes: 2 additions & 1 deletion src/gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from core.database import async_session_factory
from gateway.backend import SandboxSftpBackend
from gateway.settings import GatewaySettings
from gateway.sftp import GatewaySFTPServer
from gateway.sftp import GatewaySFTPServer, relay_limits
from hosts.models import Host, HostStatus
from providers.base import SandboxProcess, TerminalSize
from providers.exceptions import ProviderError
Expand Down Expand Up @@ -203,6 +203,7 @@ def _open_sftp(channel: asyncssh.SSHServerChannel) -> GatewaySFTPServer:
for extension in asyncssh.sftp.SFTPServerHandler._extensions
if extension[0] not in _UNSUPPORTED_SFTP_EXTENSIONS
]
asyncssh.sftp.SFTPServerHandler._packet_handlers[b"limits@openssh.com"] = relay_limits
server = cast(GatewayConnection, channel.get_connection().get_owner())
return GatewaySFTPServer(channel, server.get_sftp_backend())

Expand Down
21 changes: 21 additions & 0 deletions src/gateway/sftp.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
# each server handle straight to the client, so the handle-argument and
# override checks do not apply here.
# pyright: reportArgumentType=false, reportIncompatibleMethodOverride=false
from typing import cast

import asyncssh
from asyncssh.packet import SSHPacket

from gateway.backend import SandboxSftpBackend

Expand Down Expand Up @@ -72,6 +75,10 @@ async def realpath(self, path):
names, _ = await handler.realpath(path)
return names[0].filename

async def limits(self) -> asyncssh.SFTPLimits:
async with self._backend.session() as handler:
return handler.limits

def _unsupported(self, *args, **kwargs):
raise asyncssh.SFTPOpUnsupported("not served by this gateway")

Expand All @@ -85,3 +92,17 @@ def _unsupported(self, *args, **kwargs):
link = _unsupported
lock = _unsupported
unlock = _unsupported


async def relay_limits(
handler: asyncssh.sftp.SFTPServerHandler, packet: SSHPacket
) -> asyncssh.SFTPLimits:
"""Answer a caller's limits request with the sandbox server's limits.

asyncssh's server answers it from its own constants, a 4 MiB write, and
never asks the ``SFTPServer``. The sandbox's OpenSSH server ends the
session on a packet of 256 KiB or more, so the caller must size its
packets by that server's answer.
"""
packet.check_end()
return await cast(GatewaySFTPServer, handler._server).limits()
19 changes: 14 additions & 5 deletions src/gateway/tests/localprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@

from providers.base import SandboxProcess, TerminalSize

SFTP_SERVER = "/usr/libexec/sftp-server"
# OpenSSH installs its SFTP server at the Debian path on Linux and under
# libexec on macOS.
SFTP_SERVER = next(
(
path
for path in ("/usr/lib/openssh/sftp-server", "/usr/libexec/sftp-server")
if os.path.exists(path)
),
"/usr/lib/openssh/sftp-server",
)
SFTP_SERVER_COMMAND = f"exec {SFTP_SERVER}"


Expand Down Expand Up @@ -34,19 +43,19 @@ async def open(cls, name, *, command, terminal):
return cls(process)

async def receive(self, max_bytes: int) -> bytes:
assert self._process.stdout is not None
assert self._process.stdout
return await self._process.stdout.read(max_bytes)

async def receive_stderr(self, max_bytes: int) -> bytes:
assert self._process.stderr is not None
assert self._process.stderr
return await self._process.stderr.read(max_bytes)

def send(self, data: bytes) -> None:
assert self._process.stdin is not None
assert self._process.stdin
self._process.stdin.write(data)

def send_eof(self) -> None:
assert self._process.stdin is not None
assert self._process.stdin
self._process.stdin.write_eof()

def resize(self, size: TerminalSize) -> None:
Expand Down
22 changes: 22 additions & 0 deletions src/gateway/tests/test_sftp.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,25 @@ async def test_a_caller_writes_dotfiles_by_home_and_a_command_reads_them(connect

result = await connected.run("sh -c 'cat $HOME/.gitconfig'")
assert "name = Sandbox" in result.stdout


async def test_the_advertised_limits_are_the_sandbox_servers(connected):
# asyncssh's own server advertises 4 MiB writes. The sandbox's OpenSSH
# server ends the session on a packet of 256 KiB or more, so the caller
# must hear the sandbox server's numbers, not asyncssh's.
async with connected.start_sftp_client() as sftp:
assert 0 < sftp.limits.max_write_len < 256 * 1024


async def test_a_file_larger_than_the_sandbox_servers_packet_round_trips(connected, tmp_path):
# A put at the client's default block size trusts the advertised write
# length. With the sandbox server's limits, a 300 KiB file goes in
# packets the sandbox server accepts.
source = tmp_path / "source.bin"
source.write_bytes(os.urandom(300 * 1024))
remote = tmp_path / "sb-sftp" / "upload.bin"

async with connected.start_sftp_client() as sftp:
await sftp.put(str(source), str(remote))

assert remote.read_bytes() == source.read_bytes()
Loading