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

217 statements  

« prev     ^ index     » next       coverage.py v7.6.3, created at 2024-10-15 22:10 +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 ( 

50 add_server_arguments, 

51 server_context_from_arguments, 

52 extract_server_arguments, 

53) 

54from aiocoap.resourcedirectory.client.register import Registerer 

55 

56 

57class InvalidPathError(error.ConstructionRenderableError): 

58 code = codes.BAD_REQUEST 

59 

60 

61class TrailingSlashMissingError(error.ConstructionRenderableError): 

62 code = codes.BAD_REQUEST 

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

64 

65 

66class AbundantTrailingSlashError(error.ConstructionRenderableError): 

67 code = codes.BAD_REQUEST 

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

69 

70 

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

72 message = "Error: File not found!" 

73 

74 

75class PreconditionFailed(error.ConstructionRenderableError): 

76 code = codes.PRECONDITION_FAILED 

77 

78 

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

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

81 

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

83 super().__init__() 

84 self.root = root 

85 self.log = log 

86 self.write = write 

87 

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

89 

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"' 

99 

100 async def check_files_for_refreshes(self): 

101 while True: 

102 await asyncio.sleep(10) 

103 

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 

112 

113 def relevant(s): 

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

115 

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() 

121 

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() 

126 

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

128 

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 

140 

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] 

146 

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 ) 

153 

154 path = self.request_to_localpath(request) 

155 try: 

156 st = path.stat() 

157 except FileNotFoundError: 

158 raise NoSuchFile() 

159 

160 etag = self.hash_stat(st) 

161 

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) 

169 

170 response.opt.etag = etag 

171 return response 

172 

173 async def render_put(self, request): 

174 if not self.write: 

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

176 

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) 

180 

181 path = self.request_to_localpath(request) 

182 

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() 

189 

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() 

200 

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 

218 

219 st = path.stat() 

220 etag = self.hash_stat(st) 

221 

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

223 

224 async def render_delete(self, request): 

225 if not self.write: 

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

227 

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) 

231 

232 path = self.request_to_localpath(request) 

233 

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() 

244 

245 try: 

246 path.unlink() 

247 except FileNotFoundError: 

248 raise NoSuchFile() 

249 

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

251 

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

253 if request.opt.uri_path and request.opt.uri_path[-1] != "": 

254 raise TrailingSlashMissingError() 

255 

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

257 

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) 

266 

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

268 if request.opt.uri_path and request.opt.uri_path[-1] == "": 

269 raise AbundantTrailingSlashError() 

270 

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

272 

273 block_in = request.opt.block2 or aiocoap.optiontypes.BlockOption.BlockwiseTuple( 

274 0, 0, 6 

275 ) 

276 

277 with path.open("rb") as f: 

278 f.seek(block_in.start) 

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

280 

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() 

288 

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

290 

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 ) 

309 

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

311 path = self.request_to_localpath(request) 

312 

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 

316 

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 ) 

323 

324 

325class FileServerProgram(AsyncCLIDaemon): 

326 async def start(self): 

327 logging.basicConfig() 

328 

329 self.registerer = None 

330 

331 p = self.build_parser() 

332 

333 opts = p.parse_args() 

334 server_opts = extract_server_arguments(opts) 

335 

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

337 

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 ) 

364 

365 add_server_arguments(p) 

366 

367 return p 

368 

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") 

374 

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) 

383 

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) 

389 

390 self.refreshes = asyncio.create_task( 

391 server.check_files_for_refreshes(), 

392 name="Refresh on %r" % (path,), 

393 ) 

394 

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") 

398 

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

400 

401 if verbosity == 2: 

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

403 elif verbosity >= 3: 

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

405 

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() 

411 

412 

413# used by doc/aiocoap_index.py 

414build_parser = FileServerProgram.build_parser 

415 

416if __name__ == "__main__": 

417 FileServerProgram.sync_main()