Skip to content

Commit 21eceb2

Browse files
committed
Add sshfs exporter class
Adds a class that allows tests to export a local directory to an exporter via sshfs. In order to prevent the exporter from being able to access any file that the user has permission for on the file system (which would allow it to read SSH keys for example), the SFTP server is run in an isolated container by default using podman, although this can be overridden to use docker or directly execute the SFTP server if the exporters are trusted. Signed-off-by: Joshua Watt <[email protected]>
1 parent 95f309d commit 21eceb2

File tree

6 files changed

+210
-2
lines changed

6 files changed

+210
-2
lines changed

.github/workflows/docker.yml

+2
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ jobs:
2727
docker tag labgrid-client ${{ secrets.DOCKERHUB_PREFIX }}client
2828
docker tag labgrid-exporter ${{ secrets.DOCKERHUB_PREFIX }}exporter
2929
docker tag labgrid-coordinator ${{ secrets.DOCKERHUB_PREFIX }}coordinator
30+
docker tag labgrid-sftp-server ${{ secrets.DOCKERHUB_PREFIX }}sftp-server
3031
docker push ${{ secrets.DOCKERHUB_PREFIX }}client
3132
docker push ${{ secrets.DOCKERHUB_PREFIX }}exporter
3233
docker push ${{ secrets.DOCKERHUB_PREFIX }}coordinator
34+
docker push ${{ secrets.DOCKERHUB_PREFIX }}sftp-server
3335
docker images

.github/workflows/reusable-unit-tests.yml

+4-1
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ jobs:
3030
${{ runner.os }}-pip-
3131
- name: Install system dependencies
3232
run: |
33-
sudo apt-get install -yq libow-dev openssh-server openssh-client libsnappy-dev ncurses-term graphviz openocd
33+
sudo apt-get install -yq libow-dev openssh-server openssh-client libsnappy-dev ncurses-term graphviz openocd sshfs podman
3434
sudo mkdir -p /var/cache/labgrid/runner && sudo chown runner /var/cache/labgrid/runner
35+
- name: Build sftp server
36+
run: |
37+
podman build --target labgrid-sftp-server -t labgrid/sftp-server:latest -f dockerfiles/Dockerfile .
3538
- name: Prepare local SSH
3639
run: |
3740
# the default of 777 is too open for SSH

dockerfiles/Dockerfile

+7
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,10 @@ COPY --from=ser2net /opt/ser2net /
121121
VOLUME /opt/conf
122122

123123
CMD ["/entrypoint.sh"]
124+
125+
#
126+
# SFTP server
127+
#
128+
FROM alpine:3.17 as labgrid-sftp-server
129+
130+
RUN apk add openssh-sftp-server

dockerfiles/build.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export DOCKER_BUILDKIT=1
1515

1616
VERSION="$(python -m setuptools_scm)"
1717

18-
for t in client exporter coordinator; do
18+
for t in client exporter coordinator sftp-server; do
1919
${DOCKER} build --build-arg VERSION="$VERSION" \
2020
--target labgrid-${t} -t labgrid-${t} -f "${SCRIPT_DIR}/Dockerfile" .
2121
done

labgrid/util/sshfs.py

+152
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import logging
2+
import os
3+
import subprocess
4+
import random
5+
import string
6+
import time
7+
import shlex
8+
from contextlib import contextmanager
9+
10+
import attr
11+
12+
from .ssh import sshmanager
13+
from .timeout import Timeout
14+
from ..driver.exception import ExecutionError
15+
from ..resource.common import Resource, NetworkResource
16+
17+
18+
DEFAULT_SFTP_SERVER = "podman run --rm -i --mount type=bind,source={path},target={path} labgrid/sftp-server:latest usr/lib/ssh/sftp-server -e {readonly}"
19+
DEFAULT_RO_OPT = "-R"
20+
21+
22+
@attr.s
23+
class SSHFsExport:
24+
"""
25+
Exports a local directory to a remote device using "reverse" SSH FS
26+
"""
27+
28+
local_path = attr.ib(
29+
validator=attr.validators.instance_of(str),
30+
converter=lambda x: os.path.abspath(str(x)),
31+
)
32+
resource = attr.ib(
33+
validator=attr.validators.instance_of(Resource),
34+
)
35+
readonly = attr.ib(
36+
default=True,
37+
validator=attr.validators.instance_of(bool),
38+
)
39+
40+
def __attrs_post_init__(self):
41+
if not os.path.isdir(self.local_path):
42+
raise FileNotFoundError(f"Local directory {self.local_path} not found")
43+
self.logger = logging.getLogger(f"{self}")
44+
45+
@contextmanager
46+
def export(self, remote_path=None):
47+
host = self.resource.host
48+
conn = sshmanager.open(host)
49+
50+
# If no remote path was specified, mount on a temporary directory
51+
if remote_path is None:
52+
tmpname = "".join(random.choices(string.ascii_lowercase, k=10))
53+
remote_path = f"/tmp/labgrid-sshfs/{tmpname}/"
54+
conn.run_check(f"mkdir -p {remote_path}")
55+
56+
env = self.resource.target.env
57+
58+
if env is None:
59+
sftp_server_opt = DEFAULT_SFTP_SERVER
60+
ro_opt = DEFAULT_RO_OPT
61+
else:
62+
sftp_server_opt = env.config.get_option("sftp_server", DEFAULT_SFTP_SERVER)
63+
ro_opt = env.config.get_option("sftp_server_readonly_opt", DEFAULT_RO_OPT)
64+
65+
sftp_command = shlex.split(
66+
sftp_server_opt.format(
67+
path=self.local_path,
68+
uid=str(os.getuid()),
69+
gid=str(os.getgid()),
70+
readonly=ro_opt if self.readonly else "",
71+
)
72+
)
73+
74+
sshfs_command = conn.get_prefix() + [
75+
"sshfs",
76+
"-o",
77+
"slave",
78+
"-o",
79+
"idmap=user",
80+
f":{self.local_path}",
81+
remote_path,
82+
]
83+
84+
self.logger.info(
85+
"Running %s <-> %s", " ".join(sftp_command), " ".join(sshfs_command)
86+
)
87+
88+
# Reverse sshfs requires that sftp-server running locally and sshfs
89+
# running remotely each have their stdout connected to the others
90+
# stdin. Connecting sftp-server stdout to sshfs stdin is done using
91+
# Popen pipes, but in order to connect sshfs stdout to sftp-stdin, an
92+
# external pipe is needed.
93+
(rfd, wfd) = os.pipe2(os.O_CLOEXEC)
94+
try:
95+
with subprocess.Popen(
96+
sftp_command,
97+
stdout=subprocess.PIPE,
98+
stdin=rfd,
99+
) as sftp_server, subprocess.Popen(
100+
sshfs_command,
101+
stdout=wfd,
102+
stdin=sftp_server.stdout,
103+
) as sshfs:
104+
# Close all file descriptor open in this process. This way, if
105+
# either process exits, the other will get the EPIPE error and
106+
# exit also. If this process doesn't close its copy of the
107+
# descriptors, that won't happen
108+
sftp_server.stdout.close()
109+
110+
os.close(rfd)
111+
rfd = -1
112+
113+
os.close(wfd)
114+
wfd = -1
115+
116+
# Wait until the mount point appears remotely
117+
t = Timeout(30.0)
118+
119+
while not t.expired:
120+
(_, _, exitcode) = conn.run(f"mountpoint --quiet {remote_path}")
121+
122+
if exitcode == 0:
123+
break
124+
125+
if sshfs.poll() is not None:
126+
raise ExecutionError(
127+
"sshfs process exited with {sshfs.returncode}"
128+
)
129+
130+
if sftp_server.poll() is not None:
131+
raise ExecutionError(
132+
"sftp process exited with {sftp_server.returncode}"
133+
)
134+
135+
time.sleep(1)
136+
137+
if t.expired:
138+
raise TimeoutError("Timeout waiting for SSH fs to mount")
139+
140+
try:
141+
yield remote_path
142+
finally:
143+
sshfs.terminate()
144+
sftp_server.terminate()
145+
146+
finally:
147+
# Cleanup if not done already
148+
if rfd >= 0:
149+
os.close(rfd)
150+
151+
if wfd >= 0:
152+
os.close(wfd)

tests/test_util.py

+44
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from labgrid.util.ssh import ForwardError, SSHConnection, sshmanager
1616
from labgrid.util.proxy import proxymanager
1717
from labgrid.util.managedfile import ManagedFile
18+
from labgrid.util.sshfs import SSHFsExport
1819
from labgrid.driver.exception import ExecutionError
1920
from labgrid.resource.serialport import NetworkSerialPort
2021
from labgrid.resource.common import Resource, NetworkResource
@@ -352,6 +353,49 @@ def test_local_managedfile(target, tmpdir):
352353
assert hash == mf.get_hash()
353354
assert str(t) == mf.get_remote_path()
354355

356+
@pytest.mark.localsshmanager
357+
def test_sshfs(target, tmpdir):
358+
t = tmpdir.join("test")
359+
t_data = """
360+
Test
361+
"""
362+
t.write(t_data)
363+
364+
res = NetworkResource(target, "test", "localhost")
365+
sshfs = SSHFsExport(tmpdir, res)
366+
367+
with sshfs.export() as remote_path:
368+
remote_test = f"{remote_path}/test"
369+
#assert os.path.exists(remote_test)
370+
with open(remote_test, "r") as f:
371+
remote_data = f.read()
372+
373+
assert remote_data == t_data
374+
375+
# Read-only by default
376+
with pytest.raises(PermissionError):
377+
with open(remote_test, "w") as f:
378+
pass
379+
380+
@pytest.mark.localsshmanager
381+
def test_sshfs_write(target, tmpdir):
382+
t = tmpdir.join("test")
383+
t.write(
384+
"""
385+
Test
386+
"""
387+
)
388+
389+
res = NetworkResource(target, "test", "localhost")
390+
sshfs = SSHFsExport(tmpdir, res, readonly=False)
391+
392+
with sshfs.export() as remote_path:
393+
remote_test = f"{remote_path}test"
394+
#assert os.path.exists(remote_test)
395+
with open(remote_test, "w") as f:
396+
f.write("Hello")
397+
398+
assert t.read() == "Hello"
355399

356400
def test_find_dict():
357401
dict_a = {"a": {"a.a": {"a.a.a": "a.a.a_val"}}, "b": "b_val"}

0 commit comments

Comments
 (0)