Coverage for aiocoap/cli/rd.py: 56%

416 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 plain CoAP resource directory according to RFC9176_ 

6 

7Known Caveats: 

8 

9 * It is very permissive. Not only is no security implemented. 

10 

11 * This may and will make exotic choices about discoverable paths whereever 

12 it can (see StandaloneResourceDirectory documentation) 

13 

14 * Split-horizon is not implemented correctly 

15 

16 * Unless enforced by security (ie. not so far), endpoint and sector names 

17 (ep, d) are not checked for their lengths or other validity. 

18 

19 * Simple registrations don't cache .well-known/core contents 

20 

21.. _RFC9176: https://datatracker.ietf.org/doc/html/rfc9176 

22""" 

23 

24import string 

25import sys 

26import logging 

27import asyncio 

28import argparse 

29from urllib.parse import urljoin 

30import itertools 

31 

32import aiocoap 

33from aiocoap.resource import ( 

34 Site, 

35 Resource, 

36 ObservableResource, 

37 PathCapable, 

38 WKCResource, 

39 link_format_to_message, 

40) 

41from aiocoap.proxy.server import Proxy 

42from aiocoap.util.cli import AsyncCLIDaemon 

43import aiocoap.util.uri 

44from aiocoap import error 

45from aiocoap.cli.common import add_server_arguments, server_context_from_arguments 

46from aiocoap.numbers import codes, ContentFormat 

47import aiocoap.proxy.server 

48 

49from aiocoap.util.linkformat import Link, LinkFormat, parse 

50 

51from ..util.linkformat import link_header 

52 

53IMMUTABLE_PARAMETERS = ("ep", "d", "proxy") 

54 

55 

56class NoActiveRegistration(error.ConstructionRenderableError): 

57 code = codes.PROXYING_NOT_SUPPORTED 

58 message = "no registration with that name" 

59 

60 

61def query_split(msg): 

62 """Split a message's query up into (key, [*value]) pairs from a 

63 ?key=value&key2=value2 style Uri-Query options. 

64 

65 Keys without an `=` sign will have a None value, and all values are 

66 expressed as an (at least 1-element) list of repetitions. 

67 

68 >>> m = aiocoap.Message(uri="coap://example.com/foo?k1=v1.1&k1=v1.2&obs") 

69 >>> query_split(m) 

70 {'k1': ['v1.1', 'v1.2'], 'obs': [None]} 

71 """ 

72 result = {} 

73 for q in msg.opt.uri_query: 

74 if "=" not in q: 

75 k = q 

76 # matching the representation in link_header 

77 v = None 

78 else: 

79 k, v = q.split("=", 1) 

80 result.setdefault(k, []).append(v) 

81 return result 

82 

83 

84def pop_single_arg(query, name): 

85 """Out of query which is the output of query_split, pick the single value 

86 at the key name, raise a suitable BadRequest on error, or return None if 

87 nothing is there. The value is removed from the query dictionary.""" 

88 

89 if name not in query: 

90 return None 

91 if len(query[name]) > 1: 

92 raise error.BadRequest("Multiple values for %r" % name) 

93 return query.pop(name)[0] 

94 

95 

96class CommonRD: 

97 # "Key" here always means an (ep, d) tuple. 

98 

99 entity_prefix = ("reg",) 

100 

101 def __init__(self, proxy_domain=None, log=None): 

102 super().__init__() 

103 

104 self.log = log or logging.getLogger("resource-directory") 

105 

106 self._by_key = {} # key -> Registration 

107 self._by_path = {} # path -> Registration 

108 

109 self._updated_state_cb = [] 

110 

111 self.proxy_domain = proxy_domain 

112 self.proxy_active = {} # uri_host -> Remote 

113 

114 class Registration: 

115 # FIXME: split this into soft and hard grace period (where the former 

116 # may be 0). the node stays discoverable for the soft grace period, but 

117 # the registration stays alive for a (possibly much longer, at least 

118 # +lt) hard grace period, in which any action on the reg resource 

119 # reactivates it -- preventing premature reuse of the resource URI 

120 grace_period = 15 

121 

122 @property 

123 def href(self): 

124 return "/" + "/".join(self.path) 

125 

126 def __init__( 

127 self, 

128 static_registration_parameters, 

129 path, 

130 network_remote, 

131 delete_cb, 

132 update_cb, 

133 registration_parameters, 

134 proxy_host, 

135 setproxyremote_cb, 

136 ): 

137 # note that this can not modify d and ep any more, since they are 

138 # already part of the key and possibly the path 

139 self.path = path 

140 self.links = LinkFormat([]) 

141 

142 self._delete_cb = delete_cb 

143 self._update_cb = update_cb 

144 

145 self.registration_parameters = static_registration_parameters 

146 self.lt = 90000 

147 self.base_is_explicit = False 

148 

149 self.proxy_host = proxy_host 

150 self._setproxyremote_cb = setproxyremote_cb 

151 

152 self.update_params(network_remote, registration_parameters, is_initial=True) 

153 

154 def update_params( 

155 self, network_remote, registration_parameters, is_initial=False 

156 ): 

157 """Set the registration_parameters from the parsed query arguments, 

158 update any effects of them, and trigger any observation updates if 

159 required (the typical ones don't because their 

160 registration_parameters are {} and all it does is restart the 

161 lifetime counter)""" 

162 

163 if any(k in ("ep", "d") for k in registration_parameters.keys()): 

164 # The ep and d of initial registrations are already popped out 

165 raise error.BadRequest("Parameters 'd' and 'ep' can not be updated") 

166 

167 # Not in use class "R" or otherwise conflict with common parameters 

168 if any( 

169 k in ("page", "count", "rt", "href", "anchor") 

170 for k in registration_parameters.keys() 

171 ): 

172 raise error.BadRequest("Unsuitable parameter for registration") 

173 

174 if ( 

175 is_initial or not self.base_is_explicit 

176 ) and "base" not in registration_parameters: 

177 # check early for validity to avoid side effects of requests 

178 # answered with 4.xx 

179 if self.proxy_host is None: 

180 try: 

181 network_base = network_remote.uri 

182 except error.AnonymousHost: 

183 raise error.BadRequest("explicit base required") 

184 else: 

185 # FIXME: Advertise alternative transports (write alternative-transports) 

186 network_base = "coap://" + self.proxy_host 

187 

188 if is_initial: 

189 # technically might be a re-registration, but we can't catch that at this point 

190 actual_change = True 

191 else: 

192 actual_change = False 

193 

194 # Don't act while still checking 

195 set_lt = None 

196 set_base = None 

197 

198 if "lt" in registration_parameters: 

199 try: 

200 set_lt = int(pop_single_arg(registration_parameters, "lt")) 

201 except ValueError: 

202 raise error.BadRequest("lt must be numeric") 

203 

204 if "base" in registration_parameters: 

205 set_base = pop_single_arg(registration_parameters, "base") 

206 

207 if set_lt is not None and self.lt != set_lt: 

208 actual_change = True 

209 self.lt = set_lt 

210 if set_base is not None and (is_initial or self.base != set_base): 

211 actual_change = True 

212 self.base = set_base 

213 self.base_is_explicit = True 

214 

215 if not self.base_is_explicit and (is_initial or self.base != network_base): 

216 self.base = network_base 

217 actual_change = True 

218 

219 if any( 

220 v != self.registration_parameters.get(k) 

221 for (k, v) in registration_parameters.items() 

222 ): 

223 self.registration_parameters.update(registration_parameters) 

224 actual_change = True 

225 

226 if is_initial: 

227 self._set_timeout() 

228 else: 

229 self.refresh_timeout() 

230 

231 if actual_change: 

232 self._update_cb() 

233 

234 if self.proxy_host: 

235 self._setproxyremote_cb(network_remote) 

236 

237 def delete(self): 

238 self.timeout.cancel() 

239 self._update_cb() 

240 self._delete_cb() 

241 

242 def _set_timeout(self): 

243 delay = self.lt + self.grace_period 

244 # workaround for python issue20493 

245 

246 async def longwait(delay, callback): 

247 await asyncio.sleep(delay) 

248 callback() 

249 

250 self.timeout = asyncio.create_task( 

251 longwait(delay, self.delete), 

252 name="RD Timeout for %r" % self, 

253 ) 

254 

255 def refresh_timeout(self): 

256 self.timeout.cancel() 

257 self._set_timeout() 

258 

259 def get_host_link(self): 

260 attr_pairs = [] 

261 for k, values in self.registration_parameters.items(): 

262 for v in values: 

263 attr_pairs.append([k, v]) 

264 return Link( 

265 href=self.href, attr_pairs=attr_pairs, base=self.base, rt="core.rd-ep" 

266 ) 

267 

268 def get_based_links(self): 

269 """Produce a LinkFormat object that represents all statements in 

270 the registration, resolved to the registration's base (and thus 

271 suitable for comparing anchors).""" 

272 result = [] 

273 for link in self.links.links: 

274 href = urljoin(self.base, link.href) 

275 if "anchor" in link: 

276 absanchor = urljoin(self.base, link.anchor) 

277 data = [(k, v) for (k, v) in link.attr_pairs if k != "anchor"] + [ 

278 ["anchor", absanchor] 

279 ] 

280 else: 

281 data = link.attr_pairs + [["anchor", urljoin(href, "/")]] 

282 result.append(Link(href, data)) 

283 return LinkFormat(result) 

284 

285 async def shutdown(self): 

286 pass 

287 

288 def register_change_callback(self, callback): 

289 """Ask RD to invoke the callback whenever any of the RD state 

290 changed""" 

291 # This has no unregister equivalent as it's only called by the lookup 

292 # resources that are expected to be live for the remainder of the 

293 # program, like the Registry is. 

294 self._updated_state_cb.append(callback) 

295 

296 def _updated_state(self): 

297 for cb in self._updated_state_cb: 

298 cb() 

299 

300 def _new_pathtail(self): 

301 for i in itertools.count(1): 

302 # In the spirit of making legal but unconvential choices (see 

303 # StandaloneResourceDirectory documentation): Whoever strips or 

304 # ignores trailing slashes shall have a hard time keeping 

305 # registrations alive. 

306 path = (str(i), "") 

307 if path not in self._by_path: 

308 return path 

309 

310 def initialize_endpoint(self, network_remote, registration_parameters): 

311 # copying around for later use in static, but not checking again 

312 # because reading them from the original will already have screamed by 

313 # the time this is used 

314 static_registration_parameters = { 

315 k: v 

316 for (k, v) in registration_parameters.items() 

317 if k in IMMUTABLE_PARAMETERS 

318 } 

319 

320 ep = pop_single_arg(registration_parameters, "ep") 

321 if ep is None: 

322 raise error.BadRequest("ep argument missing") 

323 d = pop_single_arg(registration_parameters, "d") 

324 

325 proxy = pop_single_arg(registration_parameters, "proxy") 

326 

327 if proxy is not None and proxy != "on": 

328 raise error.BadRequest("Unsupported proxy value") 

329 

330 key = (ep, d) 

331 

332 if static_registration_parameters.pop("proxy", None): 

333 # FIXME: 'ondemand' is done unconditionally 

334 

335 if not self.proxy_domain: 

336 raise error.BadRequest("Proxying not enabled") 

337 

338 def is_usable(s): 

339 # Host names per RFC1123 (which is stricter than what RFC3986 would allow). 

340 # 

341 # Only supporting lowercase names as to avoid ambiguities due 

342 # to hostname capitalizatio normalization (otherwise it'd need 

343 # to be first-registered-first-served) 

344 return s and all( 

345 x in string.ascii_lowercase + string.digits + "-" for x in s 

346 ) 

347 

348 if not is_usable(ep) or (d is not None and not is_usable(d)): 

349 raise error.BadRequest( 

350 "Proxying only supported for limited ep and d set (lowercase, digits, dash)" 

351 ) 

352 

353 proxy_host = ep 

354 if d is not None: 

355 proxy_host += "." + d 

356 proxy_host = proxy_host + "." + self.proxy_domain 

357 else: 

358 proxy_host = None 

359 

360 # No more errors should fly out from below here, as side effects start now 

361 

362 try: 

363 oldreg = self._by_key[key] 

364 except KeyError: 

365 path = self._new_pathtail() 

366 else: 

367 path = oldreg.path[len(self.entity_prefix) :] 

368 oldreg.delete() 

369 

370 # this was the brutal way towards idempotency (delete and re-create). 

371 # if any actions based on that are implemented here, they have yet to 

372 # decide wheter they'll treat idempotent recreations like deletions or 

373 # just ignore them unless something otherwise unchangeable (ep, d) 

374 # changes. 

375 

376 def delete(): 

377 del self._by_path[path] 

378 del self._by_key[key] 

379 self.proxy_active.pop(proxy_host, None) 

380 

381 def setproxyremote(remote): 

382 self.proxy_active[proxy_host] = remote 

383 

384 reg = self.Registration( 

385 static_registration_parameters, 

386 self.entity_prefix + path, 

387 network_remote, 

388 delete, 

389 self._updated_state, 

390 registration_parameters, 

391 proxy_host, 

392 setproxyremote, 

393 ) 

394 

395 self._by_key[key] = reg 

396 self._by_path[path] = reg 

397 

398 return reg 

399 

400 def get_endpoints(self): 

401 return self._by_key.values() 

402 

403 

404def link_format_from_message(message): 

405 """Convert a response message into a LinkFormat object 

406 

407 This expects an explicit media type set on the response (or was explicitly requested) 

408 """ 

409 certain_format = message.opt.content_format 

410 if certain_format is None and hasattr(message, "request"): 

411 certain_format = message.request.opt.accept 

412 try: 

413 if certain_format == ContentFormat.LINKFORMAT: 

414 return parse(message.payload.decode("utf8")) 

415 else: 

416 raise error.UnsupportedMediaType() 

417 except (UnicodeDecodeError, link_header.ParseException): 

418 raise error.BadRequest() 

419 

420 

421class ThingWithCommonRD: 

422 def __init__(self, common_rd): 

423 super().__init__() 

424 self.common_rd = common_rd 

425 

426 if isinstance(self, ObservableResource): 

427 self.common_rd.register_change_callback(self.updated_state) 

428 

429 

430class DirectoryResource(ThingWithCommonRD, Resource): 

431 ct = link_format_to_message.supported_ct # type: ignore 

432 rt = "core.rd" 

433 

434 #: Issue a custom warning when registrations come in via this interface 

435 registration_warning = None 

436 

437 async def render_post(self, request): 

438 links = link_format_from_message(request) 

439 

440 registration_parameters = query_split(request) 

441 

442 if self.registration_warning: 

443 # Conveniently placed so it could be changed to something setting 

444 # additional registration_parameters instead 

445 self.common_rd.log.warning( 

446 "Warning from registration: %s", self.registration_warning 

447 ) 

448 

449 regresource = self.common_rd.initialize_endpoint( 

450 request.remote, registration_parameters 

451 ) 

452 regresource.links = links 

453 

454 return aiocoap.Message(code=aiocoap.CREATED, location_path=regresource.path) 

455 

456 

457class RegistrationResource(Resource): 

458 """The resource object wrapping a registration is just a very thin and 

459 ephemeral object; all those methods could just as well be added to 

460 Registration with `s/self.reg/self/g`, making RegistrationResource(reg) = 

461 reg (or handleded in a single RegistrationDispatchSite), but this is kept 

462 here for better separation of model and interface.""" 

463 

464 def __init__(self, registration): 

465 super().__init__() 

466 self.reg = registration 

467 

468 async def render_get(self, request): 

469 return link_format_to_message(request, self.reg.links) 

470 

471 def _update_params(self, msg): 

472 query = query_split(msg) 

473 self.reg.update_params(msg.remote, query) 

474 

475 async def render_post(self, request): 

476 self._update_params(request) 

477 

478 if request.opt.content_format is not None or request.payload: 

479 raise error.BadRequest("Registration update with body not specified") 

480 

481 return aiocoap.Message(code=aiocoap.CHANGED) 

482 

483 async def render_put(self, request): 

484 # this is not mentioned in the current spec, but seems to make sense 

485 links = link_format_from_message(request) 

486 

487 self._update_params(request) 

488 self.reg.links = links 

489 

490 return aiocoap.Message(code=aiocoap.CHANGED) 

491 

492 async def render_delete(self, request): 

493 self.reg.delete() 

494 

495 return aiocoap.Message(code=aiocoap.DELETED) 

496 

497 

498class RegistrationDispatchSite(ThingWithCommonRD, Resource, PathCapable): 

499 async def render(self, request): 

500 try: 

501 entity = self.common_rd._by_path[request.opt.uri_path] 

502 except KeyError: 

503 raise error.NotFound 

504 

505 entity = RegistrationResource(entity) 

506 

507 return await entity.render(request.copy(uri_path=())) 

508 

509 

510def _paginate(candidates, query): 

511 page = pop_single_arg(query, "page") 

512 count = pop_single_arg(query, "count") 

513 

514 try: 

515 candidates = list(candidates) 

516 if page is not None: 

517 candidates = candidates[int(page) * int(count) :] 

518 if count is not None: 

519 candidates = candidates[: int(count)] 

520 except (KeyError, ValueError): 

521 raise error.BadRequest("page requires count, and both must be ints") 

522 

523 return candidates 

524 

525 

526def _link_matches(link, key, condition): 

527 return any(k == key and condition(v) for (k, v) in link.attr_pairs) 

528 

529 

530class EndpointLookupInterface(ThingWithCommonRD, ObservableResource): 

531 ct = link_format_to_message.supported_ct # type: ignore 

532 rt = "core.rd-lookup-ep" 

533 

534 async def render_get(self, request): 

535 query = query_split(request) 

536 

537 candidates = self.common_rd.get_endpoints() 

538 

539 for search_key, search_values in query.items(): 

540 if search_key in ("page", "count"): 

541 continue # filtered last 

542 

543 for search_value in search_values: 

544 if search_value is not None and search_value.endswith("*"): 

545 

546 def matches(x, start=search_value[:-1]): 

547 return x.startswith(start) 

548 else: 

549 

550 def matches(x, search_value=search_value): 

551 return x == search_value 

552 

553 if search_key in ("if", "rt"): 

554 

555 def matches(x, original_matches=matches): 

556 return any(original_matches(v) for v in x.split()) 

557 

558 if search_key == "href": 

559 candidates = ( 

560 c 

561 for c in candidates 

562 if matches(c.href) 

563 or any(matches(r.href) for r in c.get_based_links().links) 

564 ) 

565 continue 

566 

567 candidates = ( 

568 c 

569 for c in candidates 

570 if ( 

571 search_key in c.registration_parameters 

572 and any( 

573 matches(x) for x in c.registration_parameters[search_key] 

574 ) 

575 ) 

576 or any( 

577 _link_matches(r, search_key, matches) 

578 for r in c.get_based_links().links 

579 ) 

580 ) 

581 

582 candidates = _paginate(candidates, query) 

583 

584 result = [c.get_host_link() for c in candidates] 

585 

586 return link_format_to_message(request, LinkFormat(result)) 

587 

588 

589class ResourceLookupInterface(ThingWithCommonRD, ObservableResource): 

590 ct = link_format_to_message.supported_ct # type: ignore 

591 rt = "core.rd-lookup-res" 

592 

593 async def render_get(self, request): 

594 query = query_split(request) 

595 

596 eps = self.common_rd.get_endpoints() 

597 candidates = ((e, c) for e in eps for c in e.get_based_links().links) 

598 

599 for search_key, search_values in query.items(): 

600 if search_key in ("page", "count"): 

601 continue # filtered last 

602 

603 for search_value in search_values: 

604 if search_value is not None and search_value.endswith("*"): 

605 

606 def matches(x, start=search_value[:-1]): 

607 return x.startswith(start) 

608 else: 

609 

610 def matches(x, search_value=search_value): 

611 return x == search_value 

612 

613 if search_key in ("if", "rt"): 

614 

615 def matches(x, original_matches=matches): 

616 return any(original_matches(v) for v in x.split()) 

617 

618 if search_key == "href": 

619 candidates = ( 

620 (e, c) 

621 for (e, c) in candidates 

622 if matches(c.href) 

623 or matches( 

624 e.href 

625 ) # FIXME: They SHOULD give this as relative as we do, but don't have to 

626 ) 

627 continue 

628 

629 candidates = ( 

630 (e, c) 

631 for (e, c) in candidates 

632 if _link_matches(c, search_key, matches) 

633 or ( 

634 search_key in e.registration_parameters 

635 and any( 

636 matches(x) for x in e.registration_parameters[search_key] 

637 ) 

638 ) 

639 ) 

640 

641 # strip endpoint 

642 candidates = (c for (e, c) in candidates) 

643 

644 candidates = _paginate(candidates, query) 

645 

646 # strip needless anchors 

647 candidates = [ 

648 Link(link.href, [(k, v) for (k, v) in link.attr_pairs if k != "anchor"]) 

649 if dict(link.attr_pairs)["anchor"] == urljoin(link.href, "/") 

650 else link 

651 for link in candidates 

652 ] 

653 

654 return link_format_to_message(request, LinkFormat(candidates)) 

655 

656 

657class SimpleRegistration(ThingWithCommonRD, Resource): 

658 #: Issue a custom warning when registrations come in via this interface 

659 registration_warning = None 

660 

661 def __init__(self, common_rd, context): 

662 super().__init__(common_rd) 

663 self.context = context 

664 

665 async def render_post(self, request): 

666 query = query_split(request) 

667 

668 if "base" in query: 

669 raise error.BadRequest("base is not allowed in simple registrations") 

670 

671 await self.process_request( 

672 network_remote=request.remote, 

673 registration_parameters=query, 

674 ) 

675 

676 return aiocoap.Message(code=aiocoap.CHANGED) 

677 

678 async def process_request(self, network_remote, registration_parameters): 

679 if "proxy" not in registration_parameters: 

680 try: 

681 network_base = network_remote.uri 

682 except error.AnonymousHost: 

683 raise error.BadRequest("explicit base required") 

684 

685 fetch_address = network_base + "/.well-known/core" 

686 get = aiocoap.Message(uri=fetch_address) 

687 else: 

688 # ignoring that there might be a based present, that will err later 

689 get = aiocoap.Message(uri_path=[".well-known", "core"]) 

690 get.remote = network_remote 

691 

692 get.code = aiocoap.GET 

693 get.opt.accept = ContentFormat.LINKFORMAT 

694 

695 # not trying to catch anything here -- the errors are most likely well renderable into the final response 

696 response = await self.context.request(get).response_raising 

697 links = link_format_from_message(response) 

698 

699 if self.registration_warning: 

700 # Conveniently placed so it could be changed to something setting 

701 # additional registration_parameters instead 

702 self.common_rd.log.warning( 

703 "Warning from registration: %s", self.registration_warning 

704 ) 

705 registration = self.common_rd.initialize_endpoint( 

706 network_remote, registration_parameters 

707 ) 

708 registration.links = links 

709 

710 

711class SimpleRegistrationWKC(WKCResource, SimpleRegistration): 

712 def __init__(self, listgenerator, common_rd, context): 

713 super().__init__( 

714 listgenerator=listgenerator, common_rd=common_rd, context=context 

715 ) 

716 self.registration_warning = "via .well-known/core" 

717 

718 

719class StandaloneResourceDirectory(Proxy, Site): 

720 """A site that contains all function sets of the CoAP Resource Directoru 

721 

722 To prevent or show ossification of example paths in the specification, all 

723 function set paths are configurable and default to values that are 

724 different from the specification (but still recognizable).""" 

725 

726 rd_path = ("resourcedirectory", "") 

727 ep_lookup_path = ("endpoint-lookup", "") 

728 res_lookup_path = ("resource-lookup", "") 

729 

730 def __init__(self, context, lwm2m_compat=None, **kwargs): 

731 if lwm2m_compat is True: 

732 self.rd_path = ("rd",) 

733 

734 # Double inheritance: works as everything up of Proxy has the same interface 

735 super().__init__(outgoing_context=context) 

736 

737 common_rd = CommonRD(**kwargs) 

738 

739 self.add_resource( 

740 [".well-known", "core"], 

741 SimpleRegistrationWKC( 

742 self.get_resources_as_linkheader, common_rd=common_rd, context=context 

743 ), 

744 ) 

745 self.add_resource( 

746 [".well-known", "rd"], 

747 SimpleRegistration(common_rd=common_rd, context=context), 

748 ) 

749 

750 self.add_resource(self.rd_path, DirectoryResource(common_rd=common_rd)) 

751 if list(self.rd_path) != ["rd"] and lwm2m_compat is None: 

752 second_dir_resource = DirectoryResource(common_rd=common_rd) 

753 second_dir_resource.registration_warning = "via unannounced /rd" 

754 # Hide from listing 

755 second_dir_resource.get_link_description = lambda *args: None 

756 self.add_resource(["rd"], second_dir_resource) 

757 self.add_resource( 

758 self.ep_lookup_path, EndpointLookupInterface(common_rd=common_rd) 

759 ) 

760 self.add_resource( 

761 self.res_lookup_path, ResourceLookupInterface(common_rd=common_rd) 

762 ) 

763 

764 self.add_resource( 

765 common_rd.entity_prefix, RegistrationDispatchSite(common_rd=common_rd) 

766 ) 

767 

768 self.common_rd = common_rd 

769 

770 def apply_redirection(self, request): 

771 # Fully overriding so we don't need to set an add_redirector 

772 

773 try: 

774 actual_remote = self.common_rd.proxy_active[request.opt.uri_host] 

775 except KeyError: 

776 self.common_rd.log.info( 

777 "Request to proxied host %r rejected: No such registration", 

778 request.opt.uri_host, 

779 ) 

780 raise NoActiveRegistration 

781 self.common_rd.log.debug( 

782 "Forwarding request to %r to remote %s", request.opt.uri_host, actual_remote 

783 ) 

784 request.remote = actual_remote 

785 request.opt.uri_host = None 

786 return request 

787 

788 async def shutdown(self): 

789 await self.common_rd.shutdown() 

790 

791 async def render(self, request): 

792 # Full override switching which of the parents' behavior to choose 

793 

794 if ( 

795 self.common_rd.proxy_domain is not None 

796 and request.opt.uri_host is not None 

797 and request.opt.uri_host.endswith("." + self.common_rd.proxy_domain) 

798 ): # in self.common_rd.proxy_active: 

799 return await Proxy.render(self, request) 

800 else: 

801 return await Site.render(self, request) 

802 

803 # See render; necessary on all functions thanks to https://github.com/chrysn/aiocoap/issues/251 

804 

805 async def needs_blockwise_assembly(self, request): 

806 if request.opt.uri_host in self.common_rd.proxy_active: 

807 return await Proxy.needs_blockwise_assembly(self, request) 

808 else: 

809 return await Site.needs_blockwise_assembly(self, request) 

810 

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

812 if request.opt.uri_host in self.common_rd.proxy_active: 

813 return await Proxy.add_observation(self, request, serverobservation) 

814 else: 

815 return await Site.add_observation(self, request, serverobservation) 

816 

817 

818def build_parser(): 

819 p = argparse.ArgumentParser(description=__doc__) 

820 

821 add_server_arguments(p) 

822 

823 return p 

824 

825 

826class Main(AsyncCLIDaemon): 

827 async def start(self, args=None): 

828 parser = build_parser() 

829 parser.add_argument( 

830 "--proxy-domain", 

831 help="Enable the RD proxy extension. Example: `proxy.example.net` will produce base URIs like `coap://node1.proxy.example.net/`. The names must all resolve to an address the RD is bound to.", 

832 type=str, 

833 ) 

834 parser.add_argument( 

835 "--lwm2m-compat", 

836 help="Compatibility mode for LwM2M clients that can not perform some discovery steps (moving the registration resource to `/rd`)", 

837 action="store_true", 

838 default=None, 

839 ) 

840 parser.add_argument( 

841 "--no-lwm2m-compat", 

842 help="Disable all compativility with LwM2M clients that can not perform some discovery steps (not even accepting registrations at `/rd` with warnings)", 

843 action="store_false", 

844 dest="lwm2m_compat", 

845 ) 

846 parser.add_argument( 

847 "--verbose", 

848 help="Increase debug log output (repeat for increased verbosity)", 

849 action="count", 

850 default=0, 

851 ) 

852 options = parser.parse_args(args if args is not None else sys.argv[1:]) 

853 

854 # Putting in an empty site to construct the site with a context 

855 self.context = await server_context_from_arguments(None, options) 

856 

857 self.log = logging.getLogger("resource-directory") 

858 if options.verbose >= 2: 

859 self.log.setLevel(logging.DEBUG) 

860 elif options.verbose == 1: 

861 self.log.setLevel(logging.INFO) 

862 

863 self.site = StandaloneResourceDirectory( 

864 context=self.context, 

865 proxy_domain=options.proxy_domain, 

866 lwm2m_compat=options.lwm2m_compat, 

867 log=self.log, 

868 ) 

869 self.context.serversite = self.site 

870 

871 async def shutdown(self): 

872 await self.site.shutdown() 

873 await self.context.shutdown() 

874 

875 

876sync_main = Main.sync_main 

877 

878if __name__ == "__main__": 

879 sync_main()