Coverage for aiocoap/cli/fileserver.py: 72%
217 statements
« prev ^ index » next coverage.py v7.6.8, created at 2024-11-28 12:34 +0000
« prev ^ index » next coverage.py v7.6.8, created at 2024-11-28 12:34 +0000
1# SPDX-FileCopyrightText: Christian Amsüss and the aiocoap contributors
2#
3# SPDX-License-Identifier: MIT
5"""A simple file server that serves the contents of a given directory in a
6read-only fashion via CoAP. It provides directory listings, and guesses the
7media type of files it serves.
9It follows the conventions set out for the [kitchen-sink fileserver],
10optionally with write support, with some caveats:
12* There are some time-of-check / time-of-use race conditions around the
13 handling of ETags, which could probably only be resolved if heavy file system
14 locking were used. Some of these races are a consequence of this server
15 implementing atomic writes through renames.
17 As long as no other processes access the working area, and aiocoap is run
18 single threaded, the races should not be visible to CoAP users.
20* ETags are constructed based on information in the file's (or directory's)
21 `stat` output -- this avoids reaing the whole file on overwrites etc.
23 This means that forcing the MTime to stay constant across a change would
24 confuse clients.
26* While GET requests on files are served block by block (reading only what is
27 being requested), PUT operations are spooled in memory rather than on the
28 file system.
30* Directory creation and deletion is not supported at the moment.
32[kitchen-sink fileserver]: https://www.ietf.org/archive/id/draft-amsuess-core-coap-kitchensink-00.html#name-coap
33"""
35import argparse
36import asyncio
37from pathlib import Path
38import logging
39from stat import S_ISREG, S_ISDIR
40import mimetypes
41import tempfile
42import hashlib
44import aiocoap
45import aiocoap.error as error
46import aiocoap.numbers.codes as codes
47from aiocoap.resource import Resource
48from aiocoap.util.cli import AsyncCLIDaemon
49from aiocoap.cli.common import (
50 add_server_arguments,
51 server_context_from_arguments,
52 extract_server_arguments,
53)
54from aiocoap.resourcedirectory.client.register import Registerer
57class InvalidPathError(error.ConstructionRenderableError):
58 code = codes.BAD_REQUEST
61class TrailingSlashMissingError(error.ConstructionRenderableError):
62 code = codes.BAD_REQUEST
63 message = "Error: Not a file (add trailing slash)"
66class AbundantTrailingSlashError(error.ConstructionRenderableError):
67 code = codes.BAD_REQUEST
68 message = "Error: Not a directory (strip the trailing slash)"
71class NoSuchFile(error.NotFound): # just for the better error msg
72 message = "Error: File not found!"
75class PreconditionFailed(error.ConstructionRenderableError):
76 code = codes.PRECONDITION_FAILED
79class FileServer(Resource, aiocoap.interfaces.ObservableResource):
80 # Resource is only used to give the nice render_xxx methods
82 def __init__(self, root, log, *, write=False):
83 super().__init__()
84 self.root = root
85 self.log = log
86 self.write = write
88 self._observations = {} # path -> [last_stat, [callbacks]]
90 # While we don't have a .well-known/core resource that would need this, we
91 # still allow registration at an RD and thus need something in here.
92 #
93 # As we can't possibly register all files in here, we're just registering a
94 # single link to the index.
95 def get_resources_as_linkheader(self):
96 # Resource type indicates draft-amsuess-core-coap-kitchensink-00 file
97 # service, might use registered name later
98 return '</>;ct=40;rt="tag:chrysn@fsfe.org,2022:fileserver"'
100 async def check_files_for_refreshes(self):
101 while True:
102 await asyncio.sleep(10)
104 for path, data in list(self._observations.items()):
105 last_stat, callbacks = data
106 if last_stat is None:
107 continue # this hit before the original response even triggered
108 try:
109 new_stat = path.stat()
110 except Exception:
111 new_stat = False
113 def relevant(s):
114 return (s.st_ino, s.st_dev, s.st_size, s.st_mtime, s.st_ctime)
116 if relevant(new_stat) != relevant(last_stat):
117 self.log.info("New stat for %s", path)
118 data[0] = new_stat
119 for cb in callbacks:
120 cb()
122 def request_to_localpath(self, request):
123 path = request.opt.uri_path
124 if any("/" in p or p in (".", "..") for p in path):
125 raise InvalidPathError()
127 return self.root / "/".join(path)
129 async def needs_blockwise_assembly(self, request):
130 if request.code != codes.GET:
131 return True
132 if (
133 not request.opt.uri_path
134 or request.opt.uri_path[-1] == ""
135 or request.opt.uri_path == (".well-known", "core")
136 ):
137 return True
138 # Only GETs to non-directory access handle it explicitly
139 return False
141 @staticmethod
142 def hash_stat(stat):
143 # The subset that the author expects to (possibly) change if the file changes
144 data = (stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size)
145 return hashlib.sha256(repr(data).encode("ascii")).digest()[:8]
147 async def render_get(self, request):
148 if request.opt.uri_path == (".well-known", "core"):
149 return aiocoap.Message(
150 payload=str(self.get_resources_as_linkheader()).encode("utf8"),
151 content_format=40,
152 )
154 path = self.request_to_localpath(request)
155 try:
156 st = path.stat()
157 except FileNotFoundError:
158 raise NoSuchFile()
160 etag = self.hash_stat(st)
162 if etag in request.opt.etags:
163 response = aiocoap.Message(code=codes.VALID)
164 else:
165 if S_ISDIR(st.st_mode):
166 response = await self.render_get_dir(request, path)
167 elif S_ISREG(st.st_mode):
168 response = await self.render_get_file(request, path)
170 response.opt.etag = etag
171 return response
173 async def render_put(self, request):
174 if not self.write:
175 return aiocoap.Message(code=codes.FORBIDDEN)
177 if not request.opt.uri_path or not request.opt.uri_path[-1]:
178 # Attempting to write to a directory
179 return aiocoap.Message(code=codes.BAD_REQUEST)
181 path = self.request_to_localpath(request)
183 if request.opt.if_none_match:
184 # FIXME: This is locally a race condition; files could be created
185 # in the "x" mode, but then how would writes to them be made
186 # atomic?
187 if path.exists():
188 raise PreconditionFailed()
190 if request.opt.if_match and b"" not in request.opt.if_match:
191 # FIXME: This is locally a race condition; not sure how to prevent
192 # that.
193 try:
194 st = path.stat()
195 except FileNotFoundError:
196 # Absent file in particular doesn't have the expected ETag
197 raise PreconditionFailed()
198 if self.hash_stat(st) not in request.opt.if_match:
199 raise PreconditionFailed()
201 # Is there a way to get both "Use umask for file creation (or the
202 # existing file's permissions)" logic *and* atomic file creation on
203 # portable UNIX? If not, all we could do would be emulate the logic of
204 # just opening the file (by interpreting umask and the existing file's
205 # permissions), and that fails horrobly if there are ACLs in place that
206 # bites rsync in https://bugzilla.samba.org/show_bug.cgi?id=9377.
207 #
208 # If there is not, secure temporary file creation is as good as
209 # anything else.
210 with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as spool:
211 spool.write(request.payload)
212 temppath = Path(spool.name)
213 try:
214 temppath.rename(path)
215 except Exception:
216 temppath.unlink()
217 raise
219 st = path.stat()
220 etag = self.hash_stat(st)
222 return aiocoap.Message(code=codes.CHANGED, etag=etag)
224 async def render_delete(self, request):
225 if not self.write:
226 return aiocoap.Message(code=codes.FORBIDDEN)
228 if not request.opt.uri_path or not request.opt.uri_path[-1]:
229 # Deleting directories is not supported as they can't be created
230 return aiocoap.Message(code=codes.BAD_REQUEST)
232 path = self.request_to_localpath(request)
234 if request.opt.if_match and b"" not in request.opt.if_match:
235 # FIXME: This is locally a race condition; not sure how to prevent
236 # that.
237 try:
238 st = path.stat()
239 except FileNotFoundError:
240 # Absent file in particular doesn't have the expected ETag
241 raise NoSuchFile()
242 if self.hash_stat(st) not in request.opt.if_match:
243 raise PreconditionFailed()
245 try:
246 path.unlink()
247 except FileNotFoundError:
248 raise NoSuchFile()
250 return aiocoap.Message(code=codes.DELETED)
252 async def render_get_dir(self, request, path):
253 if request.opt.uri_path and request.opt.uri_path[-1] != "":
254 raise TrailingSlashMissingError()
256 self.log.info("Serving directory %s", path)
258 response = ""
259 for f in path.iterdir():
260 rel = f.relative_to(self.root)
261 if f.is_dir():
262 response += "</%s/>;ct=40," % rel
263 else:
264 response += "</%s>," % rel
265 return aiocoap.Message(payload=response[:-1].encode("utf8"), content_format=40)
267 async def render_get_file(self, request, path):
268 if request.opt.uri_path and request.opt.uri_path[-1] == "":
269 raise AbundantTrailingSlashError()
271 self.log.info("Serving file %s", path)
273 block_in = request.opt.block2 or aiocoap.optiontypes.BlockOption.BlockwiseTuple(
274 0, 0, 6
275 )
277 with path.open("rb") as f:
278 f.seek(block_in.start)
279 data = f.read(block_in.size + 1)
281 if path in self._observations and self._observations[path][0] is None:
282 # FIXME this is not *completely* precise, as it might mean that in
283 # a (Observation 1 established, check loop run, file modified,
284 # observation 2 established) situation, observation 2 could receive
285 # a needless update on the next check, but it's simple and errs on
286 # the side of caution.
287 self._observations[path][0] = path.stat()
289 guessed_type, _ = mimetypes.guess_type(str(path))
291 block_out = aiocoap.optiontypes.BlockOption.BlockwiseTuple(
292 block_in.block_number, len(data) > block_in.size, block_in.size_exponent
293 )
294 content_format = None
295 if guessed_type is not None:
296 try:
297 content_format = aiocoap.numbers.ContentFormat.by_media_type(
298 guessed_type
299 )
300 except KeyError:
301 if guessed_type and guessed_type.startswith("text/"):
302 content_format = aiocoap.numbers.ContentFormat.TEXT
303 return aiocoap.Message(
304 payload=data[: block_in.size],
305 block2=block_out,
306 content_format=content_format,
307 observe=request.opt.observe,
308 )
310 async def add_observation(self, request, serverobservation):
311 path = self.request_to_localpath(request)
313 # the actual observable flag will only be set on files anyway, the
314 # library will cancel the file observation accordingly if the requested
315 # thing is not actually a file -- so it can be done unconditionally here
317 last_stat, callbacks = self._observations.setdefault(path, [None, []])
318 cb = serverobservation.trigger
319 callbacks.append(cb)
320 serverobservation.accept(
321 lambda self=self, path=path, cb=cb: self._observations[path][1].remove(cb)
322 )
325class FileServerProgram(AsyncCLIDaemon):
326 async def start(self):
327 logging.basicConfig()
329 self.registerer = None
331 p = self.build_parser()
333 opts = p.parse_args()
334 server_opts = extract_server_arguments(opts)
336 await self.start_with_options(**vars(opts), server_opts=server_opts)
338 @staticmethod
339 def build_parser():
340 p = argparse.ArgumentParser(description=__doc__)
341 p.add_argument(
342 "-v",
343 "--verbose",
344 help="Be more verbose (repeat to debug)",
345 action="count",
346 dest="verbosity",
347 default=0,
348 )
349 p.add_argument(
350 "--register",
351 help="Register with a Resource directory",
352 metavar="RD-URI",
353 nargs="?",
354 default=False,
355 )
356 p.add_argument("--write", help="Allow writes by any user", action="store_true")
357 p.add_argument(
358 "path",
359 help="Root directory of the server",
360 nargs="?",
361 default=".",
362 type=Path,
363 )
365 add_server_arguments(p)
367 return p
369 async def start_with_options(
370 self, path, verbosity=0, register=False, server_opts=None, write=False
371 ):
372 log = logging.getLogger("fileserver")
373 coaplog = logging.getLogger("coap-server")
375 if verbosity == 1:
376 log.setLevel(logging.INFO)
377 elif verbosity == 2:
378 log.setLevel(logging.DEBUG)
379 coaplog.setLevel(logging.INFO)
380 elif verbosity >= 3:
381 log.setLevel(logging.DEBUG)
382 coaplog.setLevel(logging.DEBUG)
384 server = FileServer(path, log, write=write)
385 if server_opts is None:
386 self.context = await aiocoap.Context.create_server_context(server)
387 else:
388 self.context = await server_context_from_arguments(server, server_opts)
390 self.refreshes = asyncio.create_task(
391 server.check_files_for_refreshes(),
392 name="Refresh on %r" % (path,),
393 )
395 if register is not False:
396 if register is not None and register.count("/") != 2:
397 log.warn("Resource directory does not look like a host-only CoAP URI")
399 self.registerer = Registerer(self.context, rd=register, lt=60)
401 if verbosity == 2:
402 self.registerer.log.setLevel(logging.INFO)
403 elif verbosity >= 3:
404 self.registerer.log.setLevel(logging.DEBUG)
406 async def shutdown(self):
407 if self.registerer is not None:
408 await self.registerer.shutdown()
409 self.refreshes.cancel()
410 await self.context.shutdown()
413# used by doc/aiocoap_index.py
414build_parser = FileServerProgram.build_parser
416if __name__ == "__main__":
417 FileServerProgram.sync_main()