Coverage for src/aiocoap/cli/fileserver.py: 0%
229 statements
« prev ^ index » next coverage.py v7.7.0, created at 2025-03-20 17:26 +0000
« prev ^ index » next coverage.py v7.7.0, created at 2025-03-20 17:26 +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-06.html#name-coap-file-service
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, etag_length=8):
83 super().__init__()
84 self.root = root
85 self.log = log
86 self.write = write
87 self.etag_length = etag_length
89 self._observations = {} # path -> [last_stat, [callbacks]]
91 # While we don't have a .well-known/core resource that would need this, we
92 # still allow registration at an RD and thus need something in here.
93 #
94 # As we can't possibly register all files in here, we're just registering a
95 # single link to the index.
96 def get_resources_as_linkheader(self):
97 # Resource type indicates draft-amsuess-core-coap-kitchensink-00 file
98 # service, might use registered name later
99 return '</>;ct=40;rt="tag:chrysn@fsfe.org,2022:fileserver"'
101 async def check_files_for_refreshes(self):
102 while True:
103 await asyncio.sleep(10)
105 for path, data in list(self._observations.items()):
106 last_stat, callbacks = data
107 if last_stat is None:
108 continue # this hit before the original response even triggered
109 try:
110 new_stat = path.stat()
111 except Exception:
112 new_stat = False
114 def relevant(s):
115 return (s.st_ino, s.st_dev, s.st_size, s.st_mtime, s.st_ctime)
117 if relevant(new_stat) != relevant(last_stat):
118 self.log.info("New stat for %s", path)
119 data[0] = new_stat
120 for cb in callbacks:
121 cb()
123 def request_to_localpath(self, request):
124 path = request.opt.uri_path
125 if any("/" in p or p in (".", "..") for p in path):
126 raise InvalidPathError()
128 return self.root / "/".join(path)
130 async def needs_blockwise_assembly(self, request):
131 if request.code != codes.GET:
132 return True
133 if (
134 not request.opt.uri_path
135 or request.opt.uri_path[-1] == ""
136 or request.opt.uri_path == (".well-known", "core")
137 ):
138 return True
139 # Only GETs to non-directory access handle it explicitly
140 return False
142 def hash_stat(self, stat):
143 """Builds an ETag for a given stat output, truncating it to the configured length
145 When ETags are disabled, None is returned.
146 """
147 if self.etag_length:
148 # The subset that the author expects to (possibly) change if the file changes
149 data = (stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size)
150 return hashlib.sha256(repr(data).encode("ascii")).digest()[
151 : self.etag_length
152 ]
154 async def render_get(self, request):
155 if request.opt.uri_path == (".well-known", "core"):
156 return aiocoap.Message(
157 payload=str(self.get_resources_as_linkheader()).encode("utf8"),
158 content_format=40,
159 )
161 path = self.request_to_localpath(request)
162 try:
163 st = path.stat()
164 except FileNotFoundError:
165 raise NoSuchFile()
167 etag = self.hash_stat(st)
169 if etag and etag in request.opt.etags:
170 response = aiocoap.Message(code=codes.VALID)
171 else:
172 if S_ISDIR(st.st_mode):
173 response = await self.render_get_dir(request, path)
174 send_etag = True
175 elif S_ISREG(st.st_mode):
176 response = await self.render_get_file(request, path)
177 send_etag = response.opt.block2 is not None
179 if request.opt.etags or send_etag:
180 response.opt.etag = etag
181 return response
183 async def render_put(self, request):
184 if not self.write:
185 return aiocoap.Message(code=codes.FORBIDDEN)
187 if not request.opt.uri_path or not request.opt.uri_path[-1]:
188 # Attempting to write to a directory
189 return aiocoap.Message(code=codes.BAD_REQUEST)
191 path = self.request_to_localpath(request)
193 if request.opt.if_none_match:
194 # FIXME: This is locally a race condition; files could be created
195 # in the "x" mode, but then how would writes to them be made
196 # atomic?
197 if path.exists():
198 raise PreconditionFailed()
200 if request.opt.if_match and b"" not in request.opt.if_match:
201 # FIXME: This is locally a race condition; not sure how to prevent
202 # that.
203 try:
204 st = path.stat()
205 except FileNotFoundError:
206 # Absent file in particular doesn't have the expected ETag
207 raise PreconditionFailed()
208 if self.hash_stat(st) not in request.opt.if_match:
209 raise PreconditionFailed()
211 # Is there a way to get both "Use umask for file creation (or the
212 # existing file's permissions)" logic *and* atomic file creation on
213 # portable UNIX? If not, all we could do would be emulate the logic of
214 # just opening the file (by interpreting umask and the existing file's
215 # permissions), and that fails horrobly if there are ACLs in place that
216 # bites rsync in https://bugzilla.samba.org/show_bug.cgi?id=9377.
217 #
218 # If there is not, secure temporary file creation is as good as
219 # anything else.
220 with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as spool:
221 spool.write(request.payload)
222 temppath = Path(spool.name)
223 try:
224 temppath.rename(path)
225 except Exception:
226 temppath.unlink()
227 raise
229 st = path.stat()
230 etag = self.hash_stat(st)
232 return aiocoap.Message(code=codes.CHANGED, etag=etag)
234 async def render_delete(self, request):
235 if not self.write:
236 return aiocoap.Message(code=codes.FORBIDDEN)
238 if not request.opt.uri_path or not request.opt.uri_path[-1]:
239 # Deleting directories is not supported as they can't be created
240 return aiocoap.Message(code=codes.BAD_REQUEST)
242 path = self.request_to_localpath(request)
244 if request.opt.if_match and b"" not in request.opt.if_match:
245 # FIXME: This is locally a race condition; not sure how to prevent
246 # that.
247 try:
248 st = path.stat()
249 except FileNotFoundError:
250 # Absent file in particular doesn't have the expected ETag
251 raise NoSuchFile()
252 if self.hash_stat(st) not in request.opt.if_match:
253 raise PreconditionFailed()
255 try:
256 path.unlink()
257 except FileNotFoundError:
258 raise NoSuchFile()
260 return aiocoap.Message(code=codes.DELETED)
262 async def render_get_dir(self, request, path):
263 if request.opt.uri_path and request.opt.uri_path[-1] != "":
264 raise TrailingSlashMissingError()
266 self.log.info("Serving directory %s", path)
268 response = ""
269 for f in path.iterdir():
270 rel = f.relative_to(self.root)
271 if f.is_dir():
272 response += "</%s/>;ct=40," % rel
273 else:
274 response += "</%s>," % rel
275 return aiocoap.Message(payload=response[:-1].encode("utf8"), content_format=40)
277 async def render_get_file(self, request, path):
278 if request.opt.uri_path and request.opt.uri_path[-1] == "":
279 raise AbundantTrailingSlashError()
281 self.log.info("Serving file %s", path)
283 block_in = request.opt.block2 or aiocoap.optiontypes.BlockOption.BlockwiseTuple(
284 0, 0, 6
285 )
287 with path.open("rb") as f:
288 f.seek(block_in.start)
289 data = f.read(block_in.size + 1)
291 if path in self._observations and self._observations[path][0] is None:
292 # FIXME this is not *completely* precise, as it might mean that in
293 # a (Observation 1 established, check loop run, file modified,
294 # observation 2 established) situation, observation 2 could receive
295 # a needless update on the next check, but it's simple and errs on
296 # the side of caution.
297 self._observations[path][0] = path.stat()
299 guessed_type, _ = mimetypes.guess_type(str(path))
301 block_out = aiocoap.optiontypes.BlockOption.BlockwiseTuple(
302 block_in.block_number, len(data) > block_in.size, block_in.size_exponent
303 )
304 if block_out.block_number == 0 and block_out.more is False:
305 # Didn't find any place in 7959 that says a Block2 must be present
306 # if the full body is sent, but if I mis read that, we might add an
307 # `or request.opt.block2 is not None`.
308 block_out = None
309 content_format = None
310 if guessed_type is not None:
311 try:
312 content_format = aiocoap.numbers.ContentFormat.by_media_type(
313 guessed_type
314 )
315 except KeyError:
316 if guessed_type and guessed_type.startswith("text/"):
317 content_format = aiocoap.numbers.ContentFormat.TEXT
318 return aiocoap.Message(
319 payload=data[: block_in.size],
320 block2=block_out,
321 content_format=content_format,
322 observe=request.opt.observe,
323 )
325 async def add_observation(self, request, serverobservation):
326 path = self.request_to_localpath(request)
328 # the actual observable flag will only be set on files anyway, the
329 # library will cancel the file observation accordingly if the requested
330 # thing is not actually a file -- so it can be done unconditionally here
332 last_stat, callbacks = self._observations.setdefault(path, [None, []])
333 cb = serverobservation.trigger
334 callbacks.append(cb)
335 serverobservation.accept(
336 lambda self=self, path=path, cb=cb: self._observations[path][1].remove(cb)
337 )
340class FileServerProgram(AsyncCLIDaemon):
341 async def start(self):
342 try:
343 import colorlog
344 except ImportError:
345 logging.basicConfig()
346 else:
347 colorlog.basicConfig()
349 self.registerer = None
351 p = self.build_parser()
353 opts = p.parse_args()
354 server_opts = extract_server_arguments(opts)
356 await self.start_with_options(**vars(opts), server_opts=server_opts)
358 @staticmethod
359 def build_parser():
360 p = argparse.ArgumentParser(description=__doc__)
361 p.add_argument(
362 "--version", action="version", version="%(prog)s " + aiocoap.meta.version
363 )
364 p.add_argument(
365 "-v",
366 "--verbose",
367 help="Be more verbose (repeat to debug)",
368 action="count",
369 dest="verbosity",
370 default=0,
371 )
372 p.add_argument(
373 "--register",
374 help="Register with a Resource directory",
375 metavar="RD-URI",
376 nargs="?",
377 default=False,
378 )
379 p.add_argument(
380 "--etag-length",
381 help="Control length of ETag, 0-8 (0 disables, for servers where files are never modified)",
382 metavar="LENGTH",
383 default=8,
384 type=int,
385 choices=range(0, 8),
386 )
387 p.add_argument("--write", help="Allow writes by any user", action="store_true")
388 p.add_argument(
389 "path",
390 help="Root directory of the server",
391 nargs="?",
392 default=".",
393 type=Path,
394 )
396 add_server_arguments(p)
398 return p
400 async def start_with_options(
401 self,
402 path,
403 verbosity=0,
404 register=False,
405 server_opts=None,
406 write=False,
407 etag_length=8,
408 ):
409 log = logging.getLogger("fileserver")
410 coaplog = logging.getLogger("coap-server")
412 if verbosity == 1:
413 log.setLevel(logging.INFO)
414 elif verbosity == 2:
415 log.setLevel(logging.DEBUG)
416 coaplog.setLevel(logging.INFO)
417 elif verbosity >= 3:
418 log.setLevel(logging.DEBUG)
419 coaplog.setLevel(logging.DEBUG)
421 server = FileServer(path, log, write=write, etag_length=etag_length)
422 if server_opts is None:
423 self.context = await aiocoap.Context.create_server_context(server)
424 else:
425 self.context = await server_context_from_arguments(server, server_opts)
427 self.refreshes = asyncio.create_task(
428 server.check_files_for_refreshes(),
429 name="Refresh on %r" % (path,),
430 )
432 if register is not False:
433 if register is not None and register.count("/") != 2:
434 log.warn("Resource directory does not look like a host-only CoAP URI")
436 self.registerer = Registerer(self.context, rd=register, lt=60)
438 if verbosity == 2:
439 self.registerer.log.setLevel(logging.INFO)
440 elif verbosity >= 3:
441 self.registerer.log.setLevel(logging.DEBUG)
443 async def shutdown(self):
444 if self.registerer is not None:
445 await self.registerer.shutdown()
446 self.refreshes.cancel()
447 await self.context.shutdown()
450# used by doc/aiocoap_index.py
451build_parser = FileServerProgram.build_parser
453if __name__ == "__main__":
454 FileServerProgram.sync_main()