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

422 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-29 12:32 +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 wherever 

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 unconventional 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 not in ("on", "yes", "ondemand"): 

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

329 

330 if proxy == "on": 

331 # FIXME: Deprecate more visibly -- but this has been in the impl 

332 # for quite some time, and is presumably used, given that the 

333 # missing "yes" went uncontested for years. 

334 self.log.warning("Client uses old proprietary value proxy=on") 

335 

336 key = (ep, d) 

337 

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

339 # FIXME: 'ondemand' is done unconditionally 

340 

341 if not self.proxy_domain: 

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

343 

344 def is_usable(s): 

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

346 # 

347 # Only supporting lowercase names as to avoid ambiguities due 

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

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

350 return s and all( 

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

352 ) 

353 

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

355 raise error.BadRequest( 

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

357 ) 

358 

359 proxy_host = ep 

360 if d is not None: 

361 proxy_host += "." + d 

362 proxy_host = proxy_host + "." + self.proxy_domain 

363 else: 

364 proxy_host = None 

365 

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

367 

368 try: 

369 oldreg = self._by_key[key] 

370 except KeyError: 

371 path = self._new_pathtail() 

372 else: 

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

374 oldreg.delete() 

375 

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

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

378 # decide whether they'll treat idempotent recreations like deletions or 

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

380 # changes. 

381 

382 def delete(): 

383 del self._by_path[path] 

384 del self._by_key[key] 

385 self.proxy_active.pop(proxy_host, None) 

386 

387 def setproxyremote(remote): 

388 self.proxy_active[proxy_host] = remote 

389 

390 reg = self.Registration( 

391 static_registration_parameters, 

392 self.entity_prefix + path, 

393 network_remote, 

394 delete, 

395 self._updated_state, 

396 registration_parameters, 

397 proxy_host, 

398 setproxyremote, 

399 ) 

400 

401 self._by_key[key] = reg 

402 self._by_path[path] = reg 

403 

404 return reg 

405 

406 def get_endpoints(self): 

407 return self._by_key.values() 

408 

409 

410def link_format_from_message(message): 

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

412 

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

414 """ 

415 certain_format = message.opt.content_format 

416 if certain_format is None and message.request is not None: 

417 certain_format = message.request.opt.accept 

418 try: 

419 if certain_format == ContentFormat.LINKFORMAT: 

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

421 else: 

422 raise error.UnsupportedMediaType() 

423 except (UnicodeDecodeError, link_header.ParseException): 

424 raise error.BadRequest() 

425 

426 

427class ThingWithCommonRD: 

428 def __init__(self, common_rd): 

429 super().__init__() 

430 self.common_rd = common_rd 

431 

432 if isinstance(self, ObservableResource): 

433 self.common_rd.register_change_callback(self.updated_state) 

434 

435 

436class DirectoryResource(ThingWithCommonRD, Resource): 

437 ct = link_format_to_message.supported_ct # type: ignore 

438 rt = "core.rd" 

439 

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

441 registration_warning = None 

442 

443 async def render_post(self, request): 

444 links = link_format_from_message(request) 

445 

446 registration_parameters = query_split(request) 

447 

448 if self.registration_warning: 

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

450 # additional registration_parameters instead 

451 self.common_rd.log.warning( 

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

453 ) 

454 

455 regresource = self.common_rd.initialize_endpoint( 

456 request.remote, registration_parameters 

457 ) 

458 regresource.links = links 

459 

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

461 

462 

463class RegistrationResource(Resource): 

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

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

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

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

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

469 

470 def __init__(self, registration): 

471 super().__init__() 

472 self.reg = registration 

473 

474 async def render_get(self, request): 

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

476 

477 def _update_params(self, msg): 

478 query = query_split(msg) 

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

480 

481 async def render_post(self, request): 

482 self._update_params(request) 

483 

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

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

486 

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

488 

489 async def render_put(self, request): 

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

491 links = link_format_from_message(request) 

492 

493 self._update_params(request) 

494 self.reg.links = links 

495 

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

497 

498 async def render_delete(self, request): 

499 self.reg.delete() 

500 

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

502 

503 

504class RegistrationDispatchSite(ThingWithCommonRD, Resource, PathCapable): 

505 async def render(self, request): 

506 try: 

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

508 except KeyError: 

509 raise error.NotFound 

510 

511 entity = RegistrationResource(entity) 

512 

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

514 

515 

516def _paginate(candidates, query): 

517 page = pop_single_arg(query, "page") 

518 count = pop_single_arg(query, "count") 

519 

520 try: 

521 candidates = list(candidates) 

522 if page is not None: 

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

524 if count is not None: 

525 candidates = candidates[: int(count)] 

526 except (KeyError, ValueError): 

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

528 

529 return candidates 

530 

531 

532def _link_matches(link, key, condition): 

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

534 

535 

536class EndpointLookupInterface(ThingWithCommonRD, ObservableResource): 

537 ct = link_format_to_message.supported_ct # type: ignore 

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

539 

540 async def render_get(self, request): 

541 query = query_split(request) 

542 

543 candidates = self.common_rd.get_endpoints() 

544 

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

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

547 continue # filtered last 

548 

549 for search_value in search_values: 

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

551 

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

553 return x.startswith(start) 

554 else: 

555 

556 def matches(x, search_value=search_value): 

557 return x == search_value 

558 

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

560 

561 def matches(x, original_matches=matches): 

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

563 

564 if search_key == "href": 

565 candidates = ( 

566 c 

567 for c in candidates 

568 if matches(c.href) 

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

570 ) 

571 continue 

572 

573 candidates = ( 

574 c 

575 for c in candidates 

576 if ( 

577 search_key in c.registration_parameters 

578 and any( 

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

580 ) 

581 ) 

582 or any( 

583 _link_matches(r, search_key, matches) 

584 for r in c.get_based_links().links 

585 ) 

586 ) 

587 

588 candidates = _paginate(candidates, query) 

589 

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

591 

592 return link_format_to_message(request, LinkFormat(result)) 

593 

594 

595class ResourceLookupInterface(ThingWithCommonRD, ObservableResource): 

596 ct = link_format_to_message.supported_ct # type: ignore 

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

598 

599 async def render_get(self, request): 

600 query = query_split(request) 

601 

602 eps = self.common_rd.get_endpoints() 

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

604 

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

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

607 continue # filtered last 

608 

609 for search_value in search_values: 

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

611 

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

613 return x.startswith(start) 

614 else: 

615 

616 def matches(x, search_value=search_value): 

617 return x == search_value 

618 

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

620 

621 def matches(x, original_matches=matches): 

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

623 

624 if search_key == "href": 

625 candidates = ( 

626 (e, c) 

627 for (e, c) in candidates 

628 if matches(c.href) 

629 or matches( 

630 e.href 

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

632 ) 

633 continue 

634 

635 candidates = ( 

636 (e, c) 

637 for (e, c) in candidates 

638 if _link_matches(c, search_key, matches) 

639 or ( 

640 search_key in e.registration_parameters 

641 and any( 

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

643 ) 

644 ) 

645 ) 

646 

647 # strip endpoint 

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

649 

650 candidates = _paginate(candidates, query) 

651 

652 # strip needless anchors 

653 candidates = [ 

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

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

656 else link 

657 for link in candidates 

658 ] 

659 

660 return link_format_to_message(request, LinkFormat(candidates)) 

661 

662 

663class SimpleRegistration(ThingWithCommonRD, Resource): 

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

665 registration_warning = None 

666 

667 def __init__(self, common_rd, context): 

668 super().__init__(common_rd) 

669 self.context = context 

670 

671 async def render_post(self, request): 

672 query = query_split(request) 

673 

674 if "base" in query: 

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

676 

677 await self.process_request( 

678 network_remote=request.remote, 

679 registration_parameters=query, 

680 ) 

681 

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

683 

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

685 if "proxy" not in registration_parameters: 

686 try: 

687 network_base = network_remote.uri 

688 except error.AnonymousHost: 

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

690 

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

692 get = aiocoap.Message(uri=fetch_address) 

693 else: 

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

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

696 get.remote = network_remote 

697 

698 get.code = aiocoap.GET 

699 get.opt.accept = ContentFormat.LINKFORMAT 

700 

701 try: 

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

703 except error.ResponseWrappingError as e: 

704 # Note that ResponseWrappingError is *not* itself renderable 

705 raise error.BadRequest(f"got error {e.coapmessage.code.dotted}") 

706 # No handling needed here: This raises renderable error 

707 links = link_format_from_message(response) 

708 

709 if self.registration_warning: 

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

711 # additional registration_parameters instead 

712 self.common_rd.log.warning( 

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

714 ) 

715 registration = self.common_rd.initialize_endpoint( 

716 network_remote, registration_parameters 

717 ) 

718 registration.links = links 

719 

720 

721class SimpleRegistrationWKC(WKCResource, SimpleRegistration): 

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

723 super().__init__( 

724 listgenerator=listgenerator, common_rd=common_rd, context=context 

725 ) 

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

727 

728 

729class StandaloneResourceDirectory(Proxy, Site): 

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

731 

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

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

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

735 

736 rd_path = ("resourcedirectory", "") 

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

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

739 

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

741 if lwm2m_compat is True: 

742 self.rd_path = ("rd",) 

743 

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

745 super().__init__(outgoing_context=context) 

746 

747 common_rd = CommonRD(**kwargs) 

748 

749 self.add_resource( 

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

751 SimpleRegistrationWKC( 

752 self.get_resources_as_linkheader, common_rd=common_rd, context=context 

753 ), 

754 ) 

755 self.add_resource( 

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

757 SimpleRegistration(common_rd=common_rd, context=context), 

758 ) 

759 

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

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

762 second_dir_resource = DirectoryResource(common_rd=common_rd) 

763 second_dir_resource.registration_warning = "via unannounced /rd" 

764 # Hide from listing 

765 second_dir_resource.get_link_description = lambda *args: None 

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

767 self.add_resource( 

768 self.ep_lookup_path, EndpointLookupInterface(common_rd=common_rd) 

769 ) 

770 self.add_resource( 

771 self.res_lookup_path, ResourceLookupInterface(common_rd=common_rd) 

772 ) 

773 

774 self.add_resource( 

775 common_rd.entity_prefix, RegistrationDispatchSite(common_rd=common_rd) 

776 ) 

777 

778 self.common_rd = common_rd 

779 

780 def apply_redirection(self, request): 

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

782 

783 try: 

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

785 except KeyError: 

786 self.common_rd.log.info( 

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

788 request.opt.uri_host, 

789 ) 

790 raise NoActiveRegistration 

791 self.common_rd.log.debug( 

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

793 ) 

794 request.remote = actual_remote 

795 request.opt.uri_host = None 

796 return request 

797 

798 async def shutdown(self): 

799 await self.common_rd.shutdown() 

800 

801 async def render(self, request): 

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

803 

804 if ( 

805 self.common_rd.proxy_domain is not None 

806 and request.opt.uri_host is not None 

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

808 ): # in self.common_rd.proxy_active: 

809 return await Proxy.render(self, request) 

810 else: 

811 return await Site.render(self, request) 

812 

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

814 

815 async def needs_blockwise_assembly(self, request): 

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

817 return await Proxy.needs_blockwise_assembly(self, request) 

818 else: 

819 return await Site.needs_blockwise_assembly(self, request) 

820 

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

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

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

824 else: 

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

826 

827 

828def build_parser(): 

829 p = argparse.ArgumentParser(description=__doc__) 

830 

831 add_server_arguments(p) 

832 

833 p.add_argument( 

834 "--version", action="version", version="%(prog)s " + aiocoap.meta.version 

835 ) 

836 p.add_argument( 

837 "--proxy-domain", 

838 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.", 

839 type=str, 

840 ) 

841 p.add_argument( 

842 "--lwm2m-compat", 

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

844 action="store_true", 

845 default=None, 

846 ) 

847 p.add_argument( 

848 "--no-lwm2m-compat", 

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

850 action="store_false", 

851 dest="lwm2m_compat", 

852 ) 

853 p.add_argument( 

854 "--verbose", 

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

856 action="count", 

857 default=0, 

858 ) 

859 

860 return p 

861 

862 

863class Main(AsyncCLIDaemon): 

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

865 parser = build_parser() 

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

867 

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

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

870 

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

872 if options.verbose >= 2: 

873 self.log.setLevel(logging.DEBUG) 

874 elif options.verbose == 1: 

875 self.log.setLevel(logging.INFO) 

876 

877 self.site = StandaloneResourceDirectory( 

878 context=self.context, 

879 proxy_domain=options.proxy_domain, 

880 lwm2m_compat=options.lwm2m_compat, 

881 log=self.log, 

882 ) 

883 self.context.serversite = self.site 

884 

885 async def shutdown(self): 

886 await self.site.shutdown() 

887 await self.context.shutdown() 

888 

889 

890sync_main = Main.sync_main 

891 

892if __name__ == "__main__": 

893 sync_main()