Coverage for aiocoap/cli/fileserver.py: 72%

219 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-16 16:09 +0000

1# SPDX-FileCopyrightText: Christian Amsüss and the aiocoap contributors 

2# 

3# SPDX-License-Identifier: MIT 

4 

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. 

8 

9It follows the conventions set out for the [kitchen-sink fileserver], 

10optionally with write support, with some caveats: 

11 

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. 

16 

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. 

19 

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. 

22 

23 This means that forcing the MTime to stay constant across a change would 

24 confuse clients. 

25 

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. 

29 

30* Directory creation and deletion is not supported at the moment. 

31 

32[kitchen-sink fileserver]: https://www.ietf.org/archive/id/draft-amsuess-core-coap-kitchensink-00.html#name-coap 

33""" 

34 

35import argparse 

36import asyncio 

37from pathlib import Path 

38import logging 

39from stat import S_ISREG, S_ISDIR 

40import mimetypes 

41import tempfile 

42import hashlib 

43 

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 (add_server_arguments, 

50 server_context_from_arguments, extract_server_arguments) 

51from aiocoap.resourcedirectory.client.register import Registerer 

52from ..util.asyncio import py38args 

53 

54class InvalidPathError(error.ConstructionRenderableError): 

55 code = codes.BAD_REQUEST 

56 

57class TrailingSlashMissingError(error.ConstructionRenderableError): 

58 code = codes.BAD_REQUEST 

59 message = "Error: Not a file (add trailing slash)" 

60 

61class AbundantTrailingSlashError(error.ConstructionRenderableError): 

62 code = codes.BAD_REQUEST 

63 message = "Error: Not a directory (strip the trailing slash)" 

64 

65class NoSuchFile(error.NotFound): # just for the better error msg 

66 message = "Error: File not found!" 

67 

68class PreconditionFailed(error.ConstructionRenderableError): 

69 code = codes.PRECONDITION_FAILED 

70 

71class FileServer(Resource, aiocoap.interfaces.ObservableResource): 

72 # Resource is only used to give the nice render_xxx methods 

73 

74 def __init__(self, root, log, *, write=False): 

75 super().__init__() 

76 self.root = root 

77 self.log = log 

78 self.write = write 

79 

80 self._observations = {} # path -> [last_stat, [callbacks]] 

81 

82 # While we don't have a .well-known/core resource that would need this, we 

83 # still allow registration at an RD and thus need something in here. 

84 # 

85 # As we can't possibly register all files in here, we're just registering a 

86 # single link to the index. 

87 def get_resources_as_linkheader(self): 

88 # Resource type indicates draft-amsuess-core-coap-kitchensink-00 file 

89 # service, might use registered name later 

90 return '</>;ct=40;rt="tag:chrysn@fsfe.org,2022:fileserver"' 

91 

92 async def check_files_for_refreshes(self): 

93 while True: 

94 await asyncio.sleep(10) 

95 

96 for path, data in list(self._observations.items()): 

97 last_stat, callbacks = data 

98 if last_stat is None: 

99 continue # this hit before the original response even triggered 

100 try: 

101 new_stat = path.stat() 

102 except Exception: 

103 new_stat = False 

104 def relevant(s): 

105 return (s.st_ino, s.st_dev, s.st_size, s.st_mtime, s.st_ctime) 

106 if relevant(new_stat) != relevant(last_stat): 

107 self.log.info("New stat for %s", path) 

108 data[0] = new_stat 

109 for cb in callbacks: 

110 cb() 

111 

112 def request_to_localpath(self, request): 

113 path = request.opt.uri_path 

114 if any('/' in p or p in ('.', '..') for p in path): 

115 raise InvalidPathError() 

116 

117 return self.root / "/".join(path) 

118 

119 async def needs_blockwise_assembly(self, request): 

120 if request.code != codes.GET: 

121 return True 

122 if not request.opt.uri_path or request.opt.uri_path[-1] == '' or \ 

123 request.opt.uri_path == ('.well-known', 'core'): 

124 return True 

125 # Only GETs to non-directory access handle it explicitly 

126 return False 

127 

128 @staticmethod 

129 def hash_stat(stat): 

130 # The subset that the author expects to (possibly) change if the file changes 

131 data = (stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size) 

132 return hashlib.sha256(repr(data).encode('ascii')).digest()[:8] 

133 

134 async def render_get(self, request): 

135 if request.opt.uri_path == ('.well-known', 'core'): 

136 return aiocoap.Message( 

137 payload=str(self.get_resources_as_linkheader()).encode('utf8'), 

138 content_format=40 

139 ) 

140 

141 path = self.request_to_localpath(request) 

142 try: 

143 st = path.stat() 

144 except FileNotFoundError: 

145 raise NoSuchFile() 

146 

147 etag = self.hash_stat(st) 

148 

149 if etag in request.opt.etags: 

150 response = aiocoap.Message(code=codes.VALID) 

151 else: 

152 if S_ISDIR(st.st_mode): 

153 response = await self.render_get_dir(request, path) 

154 elif S_ISREG(st.st_mode): 

155 response = await self.render_get_file(request, path) 

156 

157 response.opt.etag = etag 

158 return response 

159 

160 async def render_put(self, request): 

161 if not self.write: 

162 return aiocoap.Message(code=codes.FORBIDDEN) 

163 

164 if not request.opt.uri_path or not request.opt.uri_path[-1]: 

165 # Attempting to write to a directory 

166 return aiocoap.Message(code=codes.BAD_REQUEST) 

167 

168 path = self.request_to_localpath(request) 

169 

170 if request.opt.if_none_match: 

171 # FIXME: This is locally a race condition; files could be created 

172 # in the "x" mode, but then how would writes to them be made 

173 # atomic? 

174 if path.exists(): 

175 raise PreconditionFailed() 

176 

177 if request.opt.if_match and b"" not in request.opt.if_match: 

178 # FIXME: This is locally a race condition; not sure how to prevent 

179 # that. 

180 try: 

181 st = path.stat() 

182 except FileNotFoundError: 

183 # Absent file in particular doesn't have the expected ETag 

184 raise PreconditionFailed() 

185 if self.hash_stat(st) not in request.opt.if_match: 

186 raise PreconditionFailed() 

187 

188 # Is there a way to get both "Use umask for file creation (or the 

189 # existing file's permissions)" logic *and* atomic file creation on 

190 # portable UNIX? If not, all we could do would be emulate the logic of 

191 # just opening the file (by interpreting umask and the existing file's 

192 # permissions), and that fails horrobly if there are ACLs in place that 

193 # bites rsync in https://bugzilla.samba.org/show_bug.cgi?id=9377. 

194 # 

195 # If there is not, secure temporary file creation is as good as 

196 # anything else. 

197 with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as spool: 

198 spool.write(request.payload) 

199 temppath = Path(spool.name) 

200 try: 

201 temppath.rename(path) 

202 except Exception: 

203 temppath.unlink() 

204 raise 

205 

206 st = path.stat() 

207 etag = self.hash_stat(st) 

208 

209 return aiocoap.Message(code=codes.CHANGED, etag=etag) 

210 

211 async def render_delete(self, request): 

212 if not self.write: 

213 return aiocoap.Message(code=codes.FORBIDDEN) 

214 

215 if not request.opt.uri_path or not request.opt.uri_path[-1]: 

216 # Deleting directories is not supported as they can't be created 

217 return aiocoap.Message(code=codes.BAD_REQUEST) 

218 

219 path = self.request_to_localpath(request) 

220 

221 if request.opt.if_match and b"" not in request.opt.if_match: 

222 # FIXME: This is locally a race condition; not sure how to prevent 

223 # that. 

224 try: 

225 st = path.stat() 

226 except FileNotFoundError: 

227 # Absent file in particular doesn't have the expected ETag 

228 raise NoSuchFile() 

229 if self.hash_stat(st) not in request.opt.if_match: 

230 raise PreconditionFailed() 

231 

232 try: 

233 path.unlink() 

234 except FileNotFoundError: 

235 raise NoSuchFile() 

236 

237 return aiocoap.Message(code=codes.DELETED) 

238 

239 async def render_get_dir(self, request, path): 

240 if request.opt.uri_path and request.opt.uri_path[-1] != '': 

241 raise TrailingSlashMissingError() 

242 

243 self.log.info("Serving directory %s", path) 

244 

245 response = "" 

246 for f in path.iterdir(): 

247 rel = f.relative_to(self.root) 

248 if f.is_dir(): 

249 response += "</%s/>;ct=40," % rel 

250 else: 

251 response += "</%s>," % rel 

252 return aiocoap.Message(payload=response[:-1].encode('utf8'), content_format=40) 

253 

254 async def render_get_file(self, request, path): 

255 if request.opt.uri_path and request.opt.uri_path[-1] == '': 

256 raise AbundantTrailingSlashError() 

257 

258 self.log.info("Serving file %s", path) 

259 

260 block_in = request.opt.block2 or aiocoap.optiontypes.BlockOption.BlockwiseTuple(0, 0, 6) 

261 

262 with path.open('rb') as f: 

263 f.seek(block_in.start) 

264 data = f.read(block_in.size + 1) 

265 

266 if path in self._observations and self._observations[path][0] is None: 

267 # FIXME this is not *completely* precise, as it might mean that in 

268 # a (Observation 1 established, check loop run, file modified, 

269 # observation 2 established) situation, observation 2 could receive 

270 # a needless update on the next check, but it's simple and errs on 

271 # the side of caution. 

272 self._observations[path][0] = path.stat() 

273 

274 guessed_type, _ = mimetypes.guess_type(str(path)) 

275 

276 block_out = aiocoap.optiontypes.BlockOption.BlockwiseTuple(block_in.block_number, len(data) > block_in.size, block_in.size_exponent) 

277 content_format = None 

278 if guessed_type is not None: 

279 try: 

280 content_format = aiocoap.numbers.ContentFormat.by_media_type(guessed_type) 

281 except KeyError: 

282 if guessed_type and guessed_type.startswith('text/'): 

283 content_format = aiocoap.numbers.ContentFormat.TEXT 

284 return aiocoap.Message( 

285 payload=data[:block_in.size], 

286 block2=block_out, 

287 content_format=content_format, 

288 observe=request.opt.observe 

289 ) 

290 

291 async def add_observation(self, request, serverobservation): 

292 path = self.request_to_localpath(request) 

293 

294 # the actual observable flag will only be set on files anyway, the 

295 # library will cancel the file observation accordingly if the requested 

296 # thing is not actually a file -- so it can be done unconditionally here 

297 

298 last_stat, callbacks = self._observations.setdefault(path, [None, []]) 

299 cb = serverobservation.trigger 

300 callbacks.append(cb) 

301 serverobservation.accept(lambda self=self, path=path, cb=cb: self._observations[path][1].remove(cb)) 

302 

303class FileServerProgram(AsyncCLIDaemon): 

304 async def start(self): 

305 logging.basicConfig() 

306 

307 self.registerer = None 

308 

309 p = self.build_parser() 

310 

311 opts = p.parse_args() 

312 server_opts = extract_server_arguments(opts) 

313 

314 await self.start_with_options(**vars(opts), server_opts=server_opts) 

315 

316 @staticmethod 

317 def build_parser(): 

318 p = argparse.ArgumentParser(description=__doc__) 

319 p.add_argument("-v", "--verbose", help="Be more verbose (repeat to debug)", action='count', dest="verbosity", default=0) 

320 p.add_argument("--register", help="Register with a Resource directory", metavar='RD-URI', nargs='?', default=False) 

321 p.add_argument("--write", help="Allow writes by any user", action='store_true') 

322 p.add_argument("path", help="Root directory of the server", nargs="?", default=".", type=Path) 

323 

324 add_server_arguments(p) 

325 

326 return p 

327 

328 async def start_with_options(self, path, verbosity=0, register=False, 

329 server_opts=None, write=False): 

330 log = logging.getLogger('fileserver') 

331 coaplog = logging.getLogger('coap-server') 

332 

333 if verbosity == 1: 

334 log.setLevel(logging.INFO) 

335 elif verbosity == 2: 

336 log.setLevel(logging.DEBUG) 

337 coaplog.setLevel(logging.INFO) 

338 elif verbosity >= 3: 

339 log.setLevel(logging.DEBUG) 

340 coaplog.setLevel(logging.DEBUG) 

341 

342 server = FileServer(path, log, write=write) 

343 if server_opts is None: 

344 self.context = await aiocoap.Context.create_server_context(server) 

345 else: 

346 self.context = await server_context_from_arguments(server, server_opts) 

347 

348 self.refreshes = asyncio.create_task( 

349 server.check_files_for_refreshes(), 

350 **py38args(name="Refresh on %r" % (path,)) 

351 ) 

352 

353 if register is not False: 

354 if register is not None and register.count('/') != 2: 

355 log.warn("Resource directory does not look like a host-only CoAP URI") 

356 

357 self.registerer = Registerer(self.context, rd=register, lt=60) 

358 

359 if verbosity == 2: 

360 self.registerer.log.setLevel(logging.INFO) 

361 elif verbosity >= 3: 

362 self.registerer.log.setLevel(logging.DEBUG) 

363 

364 async def shutdown(self): 

365 if self.registerer is not None: 

366 await self.registerer.shutdown() 

367 self.refreshes.cancel() 

368 await self.context.shutdown() 

369 

370# used by doc/aiocoap_index.py 

371build_parser = FileServerProgram.build_parser 

372 

373if __name__ == "__main__": 

374 FileServerProgram.sync_main()