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

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-06.html#name-coap-file-service 

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, etag_length=8): 

83 super().__init__() 

84 self.root = root 

85 self.log = log 

86 self.write = write 

87 self.etag_length = etag_length 

88 

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

90 

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

100 

101 async def check_files_for_refreshes(self): 

102 while True: 

103 await asyncio.sleep(10) 

104 

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 

113 

114 def relevant(s): 

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

116 

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

122 

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

127 

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

129 

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 

141 

142 def hash_stat(self, stat): 

143 """Builds an ETag for a given stat output, truncating it to the configured length 

144 

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 ] 

153 

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 ) 

160 

161 path = self.request_to_localpath(request) 

162 try: 

163 st = path.stat() 

164 except FileNotFoundError: 

165 raise NoSuchFile() 

166 

167 etag = self.hash_stat(st) 

168 

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 

178 

179 if request.opt.etags or send_etag: 

180 response.opt.etag = etag 

181 return response 

182 

183 async def render_put(self, request): 

184 if not self.write: 

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

186 

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) 

190 

191 path = self.request_to_localpath(request) 

192 

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

199 

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

210 

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 

228 

229 st = path.stat() 

230 etag = self.hash_stat(st) 

231 

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

233 

234 async def render_delete(self, request): 

235 if not self.write: 

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

237 

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) 

241 

242 path = self.request_to_localpath(request) 

243 

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

254 

255 try: 

256 path.unlink() 

257 except FileNotFoundError: 

258 raise NoSuchFile() 

259 

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

261 

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

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

264 raise TrailingSlashMissingError() 

265 

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

267 

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) 

276 

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

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

279 raise AbundantTrailingSlashError() 

280 

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

282 

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

284 0, 0, 6 

285 ) 

286 

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

288 f.seek(block_in.start) 

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

290 

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

298 

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

300 

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 ) 

324 

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

326 path = self.request_to_localpath(request) 

327 

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 

331 

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 ) 

338 

339 

340class FileServerProgram(AsyncCLIDaemon): 

341 async def start(self): 

342 try: 

343 import colorlog 

344 except ImportError: 

345 logging.basicConfig() 

346 else: 

347 colorlog.basicConfig() 

348 

349 self.registerer = None 

350 

351 p = self.build_parser() 

352 

353 opts = p.parse_args() 

354 server_opts = extract_server_arguments(opts) 

355 

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

357 

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 ) 

395 

396 add_server_arguments(p) 

397 

398 return p 

399 

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

411 

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) 

420 

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) 

426 

427 self.refreshes = asyncio.create_task( 

428 server.check_files_for_refreshes(), 

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

430 ) 

431 

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

435 

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

437 

438 if verbosity == 2: 

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

440 elif verbosity >= 3: 

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

442 

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

448 

449 

450# used by doc/aiocoap_index.py 

451build_parser = FileServerProgram.build_parser 

452 

453if __name__ == "__main__": 

454 FileServerProgram.sync_main()