diff --git a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/.openspec.yaml b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/.openspec.yaml new file mode 100644 index 0000000..a26662f --- /dev/null +++ b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-07 diff --git a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/design.md b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/design.md new file mode 100644 index 0000000..c40631e --- /dev/null +++ b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/design.md @@ -0,0 +1,251 @@ +## Context + +`bridge()` in `tedge-zephyr/src/tedge_remote_access.c` resolves the target +on the session's own thread: an IP literal goes straight to the policy; any +other string goes to `zsock_getaddrinfo(host, NULL, AF_INET)`. Zephyr's +resolver sends a name ending in `.local` to the mDNS server entry +(224.0.0.251:5353) only when `CONFIG_MDNS_RESOLVER` is set. tedge-zephyr +selects `DNS_RESOLVER` (through `TEDGE_HTTP`) but nothing selects +`MDNS_RESOLVER`, so a `.local` target goes to the DHCP-supplied unicast DNS +server, which does not know it, and the session fails with +"cannot resolve". + +The SNMP app already sets `CONFIG_MDNS_RESOLVER=y` together with +`CONFIG_MDNS_RESPONDER=y`, and its `.local` trap manager still fails with +`DNS_EAI_SYSTEM` (-11) on every retry, while IP literals work and the +responder answers for the device's own name. The cause has not been found. +So enabling the resolver is necessary but not known to be sufficient, and +every app here that builds tedge also runs the responder. + +Constraints: the module must not depend on `lib/` or `apps/`; every +feature is a build-time choice; the C6/S3 `full` builds and the ota builds +with the `remote-access` extra are tight on RAM and on socket/poll slots +(`CONFIG_ZVFS_POLL_MAX`, `NET_MAX_CONTEXTS`). + +## Goals / Non-Goals + +**Goals:** +- A `.local` target resolves on every board that builds remote access, + with or without the application's mDNS responder. +- Resolution time is bounded per session and the failure reason tells the + operator whether the name timed out or failed. +- The audit trail names the address the session actually reached. +- A spoofed mDNS answer cannot steer a tunnel off the device's LAN. + +**Non-Goals:** +- DNS-SD browsing, a name cache, IPv6, LLMNR (see the proposal). +- Fixing the SNMP trap manager; it may benefit from the root-cause fix + but is not changed here. + +## Decisions + +### 1. Root-cause the `-11` before writing code + +The first task reproduces the failure on a tedge build (S3 DevKitC, which +keeps a console and stays on the network — the C6 console resets on open +and the classic ESP32 boards stall) with `net dns .local` from the +Zephyr shell, with and without the responder. Candidates, in the order +they are cheapest to rule out: + +1. **The DHCP reconfigure drops the mDNS server.** DHCPv4 calls + `dns_resolve_reconfigure()` with only its unicast servers; if the + mDNS entry is not re-added, `.local` queries find no matching server + and return `DNS_EAI_SYSTEM`. Check with `net dns` (lists servers) + before and after the lease. +2. **The socket service / poll table is full.** In Zephyr 4.x the + resolver's sockets register with the socket service (dispatcher) the + responder also uses; the boot log `net_sock_svc: ... 3 poll entries` + already shows the table is too small. A failed registration surfaces as + a system error. Check by raising `CONFIG_ZVFS_POLL_MAX` and + `CONFIG_NET_SOCKETS_SERVICE_*`/`DNS_RESOLVER_MAX_SERVERS` together. +3. **Port 5353 conflict or multicast not received.** The resolver's + query socket and the responder both want 5353 / the 224.0.0.251 group; + or the Wi-Fi driver drops multicast RX without IGMP membership. Check + with a capture on the LAN (`tcpdump -i en0 port 5353`) to see whether + the query leaves and the answer arrives. + +The fix goes where the cause is: a `select`/`imply` or default in +tedge-zephyr's Kconfig if it is configuration; a documented requirement in +`tedge-zephyr/README.md` (and the board confs here) if only the +application can set it; a contained workaround in the module, with a +comment naming the Zephyr version, if it is a Zephyr bug. The finding is +recorded in `tedge-zephyr/docs/debugging-against-real-devices.md`. + +*Alternative considered:* skip Zephyr's resolver and send our own one-shot +mDNS query on a raw UDP socket. Rejected unless the root cause is a Zephyr +bug that cannot be worked around: it duplicates packet parsing the +resolver already has and costs more flash. + +**Finding (2026-10-07, S3 DevKitC, modbus-server `full`, Zephyr 4.4.2).** +Two Zephyr bugs, one behind the other: + +1. *No mDNS server.* `dns_resolve_init_default()` adds 224.0.0.251:5353 to + the default resolver only under `CONFIG_DNS_SERVER_IP_ADDRESSES`. With + DNS from DHCP, `net dns` lists only the DHCP server; a `.local` query has + no server, `dns_get_addr_info()` returns `-ENOENT` and `getaddrinfo()` + reports `DNS_EAI_SYSTEM` (-11) — the SNMP trap manager's error. Setting + `DNS_SERVER_IP_ADDRESSES=y` with empty servers puts mDNS in slot 0, and + the resolver sends *every* non-`.local` name to the first server with a + socket, so `pool.ntp.org` timed out and the client could not connect. + Adding the mDNS server from the module behind the DHCP one works. +2. *Answers never read.* With the application's mDNS responder on 5353, the + resolver's mDNS socket is paired with it by the DNS dispatcher and never + bound or polled. The query leaves from an ephemeral port; a capture on + the Pi shows the query and its answer (`A 192.168.68.84`, unicast back + to that port within 0.1 ms, as RFC 6762 6.7 requires), and the device + times out. No configuration fixes this while the responder runs, and + every app here runs it. + +So the fix is a contained workaround in the module (decision 3). + +### 2. `CONFIG_TEDGE_REMOTE_ACCESS_MDNS`, default `y` + +A bool under `TEDGE_REMOTE_ACCESS` that selects only `NET_UDP`: the module +sends its own query (decision 3), so Zephyr's `MDNS_RESOLVER` is not +needed. Default on, because finding LAN hosts +by name is what the remote-access-enabler role is for, and the cost is +expected to be small. A board without room turns it off; it then fails +`.local` targets with "mDNS is not built in" instead of asking the unicast +server — a clearer reason than "cannot resolve", and it avoids leaking +internal names to an upstream resolver. + +*Alternative:* select `MDNS_RESOLVER` and lean on Zephyr's resolver. +Rejected after the finding above: it cannot receive answers while the +responder runs. + +### 3. `.local` names: the module's own one-shot query; others: `dns_get_addr_info()` + +A `.local` lookup opens a UDP socket, sends one A query for the name to +224.0.0.251:5353 from its ephemeral port, and reads the answers on that +socket until the per-lookup timeout, taking the first that answers its +random id for that name (`mdns_once()`; the packet builder and parser are +pure and unit-tested in `tedge_ra_target.c`). This is RFC 6762's "legacy +unicast" exchange: avahi and systemd-resolved answer it directly to the +sending port, so it works whether or not the application runs the +responder, joins no multicast group, and leaves Zephyr's resolver list +alone. Cost: one socket for the lookup's duration, a 512-byte buffer on the +session stack, about 1 KB of code. + +*Alternatives:* add the mDNS server to Zephyr's resolver (works only +without the responder; tried); patch Zephyr's dispatcher to read the paired +socket (fixes SNMP too, but the module would need a patched Zephyr). The +dispatcher bug is worth reporting upstream; when a release fixes it this +can go back to the resolver. + +For unicast names, `zsock_getaddrinfo()` uses the global `CONFIG_NET_SOCKETS_DNS_TIMEOUT` and +collapses all failures into a few `DNS_EAI_*` codes. The bridge calls the +resolver API on the default context instead, waiting on a semaphore with +`CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_TIMEOUT_MS` per lookup (default 5000; +mDNS hosts normally answer in well under a second, but a host waking from +Wi-Fi power save, or a busy Pi, can take seconds, and a generous window +means fewer retries are spent on a slow answer). +This gives a per-feature bound and distinguishes a timeout +(`DNS_EAI_CANCELED`) from other errors for the reason text. It applies to +unicast names too, so both kinds of name share one path. Resolution stays +on the session thread, never the client thread that owns MQTT, and the seat +is held only for the bounded time. + +The resolver allows one query at a time by default +(`DNS_NUM_CONCUR_QUERIES=1`), so a lookup can find the slot taken by the +client's own (`-EAGAIN`); that maps to `DNS_EAI_SYSTEM` and is retried. + +### 3a. Retry failed lookups a bounded number of times + +A single mDNS query is one multicast UDP packet over Wi-Fi: no ARQ for +multicast, no retransmission by the AP, and a host in power save may miss +it. One lost packet should not fail a session the operator has to reopen +by hand. So `resolve_target()` loops: up to +`CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_ATTEMPTS` lookups (default 3, range +1–5), each bounded by the per-lookup timeout, with +`CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_RETRY_DELAY_MS` (default 500) between +them, and returns on the first answer. + +What is retried and what is not: + +| Outcome of a lookup | Retry? | +|---|---| +| Timeout (`DNS_EAI_CANCELED`) | yes | +| Transient error (`DNS_EAI_AGAIN`, `DNS_EAI_SYSTEM`, `-ENOMEM`, no route yet) | yes | +| Authoritative "no such name" from a unicast server (`DNS_EAI_NONAME`) | no — asking again gives the same answer | +| `.local` without mDNS built in | no — not a lookup at all | +| Policy refusal of the resolved address | no — resolution succeeded | + +Each failed attempt logs at WRN with the attempt number and the error +(never the connection key); only the final failure is posted as the +operation's reason, naming the attempt count. The loop runs on the +session thread and holds the seat while it runs, so the worst case is +bounded: attempts × timeout + (attempts − 1) × delay ≈ 16 s with the +defaults (3 × 5 s + 2 × 0.5 s). Task 5 checks that the Cumulocity +remote-access client waits that long for the device-side WebSocket; if it +does not, lower the attempts rather than the per-lookup timeout. A Kconfig `BUILD_ASSERT` +keeps that product under 30 s so a misconfiguration cannot hold a seat +indefinitely. + +The same loop serves unicast names, which mostly benefit from the +`DNS_EAI_AGAIN` case (a resolver that was still being configured after a +reconnect). + +*Alternatives:* rely on Zephyr's own retransmission inside one +`dns_resolve_name()` call — it retransmits only within one timeout window +and does not cover a socket-level error; or retry at the operation level +by failing and letting the operator reopen — that is exactly the +experience this avoids. Exponential backoff was considered and rejected: +with three attempts on a LAN, a fixed short pause is simpler and no +slower to recover. + +### 4. A `.local` answer must be on the link + +mDNS is unauthenticated: any host on the link can answer for any name. The +LAN policy already rejects an off-subnet address. The allow-list policy +matches names only, so `policy_check()` gains one extra rule: when the +host ends in `.local`, the resolved address must also be loopback, the +device's own, or on the device's subnets, whatever the policy. This keeps +"listing `rpi5.local:22`" meaning "the Pi on this LAN" and costs one +existing subnet check. The application hook still runs after and still +receives the resolved `sockaddr`, so the public API is unchanged. + +### 5. Event and log text carry both forms + +`"tunnel to rpi5.local (192.168.68.72):22 opened"` and the matching close +event; an IP-literal target keeps today's text. With a 63-character host, +an address and the close event's counters, the close text can exceed +`tedge_ra_event.text[144]` (about 192 characters at the extreme). The +event text grows to 200 bytes, and the client's quoting buffers with it +(about 112 B more RAM per queued event pair and on the client stack); a +unit test checks the longest case fits. The resolved address is stored in +the session (`char addr_s[NET_IPV4_ADDR_LEN]`). The twin data's session list keeps the host as requested. + +## Risks / Trade-offs + +- [Our own mDNS client diverges from Zephyr's] → it handles only A + answers to its own legacy-unicast query; the parser is unit-tested + against malformed and looping input. Revisit when Zephyr's dispatcher is + fixed. +- [One more socket while a name resolves, on boards near their limits] + → measure on the C6, S3 and QT Py `full` builds and the WROOM/ESP32-CAM ota + builds; raise `ZVFS_POLL_MAX`/contexts in tedge-zephyr's defaults only if + the measurement shows a shortfall. +- [Two hosts claim the same `.local` name] → the first answer wins; the + event shows which address was reached, so the operator can tell. +- [Seat held while waiting for a dead name] → bounded by attempts × + timeout + delays (about 16 s with the defaults, asserted under 30 s at + build time), and the reason names the timeout and the attempt count. +- [Retries hide a flaky network] → each failed attempt is logged at WRN, + so a target that only resolves on the third try shows up in the logs + even though the session succeeds. +- [Classic-ESP32 multicast reception is unreliable] → test there, but + record the result as board-specific rather than block on it (those + boards already have known Wi-Fi stalls). + +## Migration Plan + +No data or API migration. Existing endpoints with IP literals behave as +before. Rollback: set `CONFIG_TEDGE_REMOTE_ACCESS_MDNS=n`. + +## Open Questions + +- None blocking. Answered: the `-11` is "no mDNS server" (see the finding + under decision 1), and the resolver callback API is usable from the + module for unicast names. +- Follow-ups outside this change: report the dispatcher bug upstream; the + SNMP trap manager can reuse `mdns_once()`'s approach. diff --git a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/proposal.md b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/proposal.md new file mode 100644 index 0000000..f91df83 --- /dev/null +++ b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/proposal.md @@ -0,0 +1,111 @@ +## Why + +Hosts on a small LAN (a Raspberry Pi running SSH, a PLC's web UI, another +tedge board) usually get their address from DHCP and are known by their +`.local` mDNS name, not a fixed IP. Today a remote-access +connect for `rpi5-d83add9f145a.local:22` fails on the device with +"cannot resolve": the bridge hands the name to `getaddrinfo()`, but the +tedge-zephyr builds have no mDNS resolver, so only IP literals and names in +unicast DNS work. The operator has to look up the target's current IP and +edit the Cumulocity remote-access endpoint whenever its lease changes. + +The same on-device `.local` lookup was already seen failing elsewhere in +this repo (the SNMP trap manager logs `not resolvable (-11)` with +`CONFIG_MDNS_RESOLVER=y`), so turning the resolver on is not enough by +itself: the cause of that failure has to be found and fixed too. + +Phase 3 (tedge-zephyr). Protocol: none; this is the Cumulocity remote-access +feature. Boards: every build that includes `CONFIG_TEDGE_REMOTE_ACCESS` — +the `full` profile on the ESP32-C6-DevKitC-1, ESP32-S3-DevKitC-1 and QT Py +ESP32-S3, and the `remote-access` extra on the ota builds (see +`release/devices.yml`). + +## What Changes + +- A remote-access target named `.local` is resolved over mDNS + (multicast query on the device's LAN) before the policy check, and the + session proceeds with the address it resolves to. Unicast DNS names and + IP literals behave as before. +- New Kconfig option `CONFIG_TEDGE_REMOTE_ACCESS_MDNS` (default `y`, + depends on `TEDGE_REMOTE_ACCESS`). The module sends its own one-shot mDNS + query: Zephyr 4.4.2's resolver cannot receive mDNS answers while the + application runs the mDNS responder (see design). Turning it off restores + today's footprint for a board that has no room. +- New Kconfig option `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_TIMEOUT_MS` + bounding how long each lookup waits for an answer (default 5000). +- A lookup that times out or hits a transient error is retried, up to + `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_ATTEMPTS` lookups in all (default 3, + `1` turns retrying off), with a short pause between them + (`CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_RETRY_DELAY_MS`, default 500). mDNS + runs over lossy Wi-Fi multicast, and a sleeping host or a dropped + packet should not fail the session on the first try. This applies to + unicast DNS names too. The worst case with the defaults is about 16 s. +- Resolution failures name the cause and the number of attempts in the + failed operation: "`` did not answer over mDNS after 3 attempts" + (timeout) vs "cannot resolve `` after 3 attempts" (any other + error), and a `.local` target on a build without the + option fails with "mDNS is not built in". +- The open and close events and the logs name both the host as requested and the address it resolved to + (`rpi5.local (192.168.68.72):22`), so the audit trail says which machine + was actually reached. +- Find and fix why an on-device `.local` lookup returns `DNS_EAI_SYSTEM` + (-11). Found: Zephyr adds no mDNS server with DHCP-supplied DNS, and with + the responder running never reads the answers; worked around in the + module. + +## Non-goals + +- DNS-SD service browsing (finding "the SSH server" without a name). The + operator still names the host. +- Caching resolved names across sessions. Each session resolves afresh, + so a host whose lease changed is found at its new address. +- IPv6 targets or AAAA answers; the bridge and the policy are IPv4-only and + stay so. +- LLMNR or NetBIOS name resolution. +- Changing the SNMP trap manager's resolution in `lib/snmp`. If the root + cause found here also fixes it, that is a follow-up for the app. +- Changing the target policies themselves. A name is still judged by the + address it resolves to and an allow-list entry still matches the host + string as requested; the only addition is that a `.local` answer must be + on the device's own subnets. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `tedge-remote-access`: the "targets are checked before any connection is + opened" requirement gains mDNS resolution of `.local` names, bounded + resolution time, distinct failure reasons, and a link-local check on any + `.local` answer (mDNS is unauthenticated); the audit requirement gains the + resolved address in the open and close events. + +## Impact + +- **Code**: `tedge-zephyr/src/tedge_remote_access.c` (resolution step, + reasons, event text); `tedge-zephyr/Kconfig` (four new options); + `tedge-zephyr/tests/kconfig/cases/` (new cases); unit tests for the + `.local` detection and reason text. +- **Public `tedge_*` API**: no change. `struct tedge_remote_target` already + carries the resolved `sockaddr` to the application's hook. +- **Dependencies**: none beyond `NET_UDP`; Zephyr's `MDNS_RESOLVER` is not + used. Nothing from `lib/` or `apps/`. +- **Resources** (measured 2026-10-07, modbus-server `full`, mDNS on vs off): + | Board | Flash | Static RAM | + |---|---|---| + | ESP32-C6-DevKitC-1 | +1,264 B content | 0 B | + | QT Py ESP32-S3 | about +1 KB code (the query builder and parser are 613 B); the image total moves by under 20 B because of segment alignment | 0 B | + + At run time a `.local` lookup holds one UDP socket and a 512 B buffer on + the session's stack, only while it runs; no board needed more sockets or + poll entries. The whole change against the release baseline (retries, + policy check, longer event text, mDNS), from the rebuilt baseline of the + nine `full` builds: C6 +2,401 B content and +176 B RAM; S3 DevKitC and + QT Py +207 to +225 B image and +168 B dram (their image size hides most + code growth in segment alignment). The RAM is mostly the 200-byte event + text. `release/size-baseline.json` is updated for those nine builds. +- **Docs**: `tedge-zephyr/README.md` remote-access section; `DEVICES.md` + if a board needs a tuned option. diff --git a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/specs/tedge-remote-access/spec.md b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/specs/tedge-remote-access/spec.md new file mode 100644 index 0000000..5c904c1 --- /dev/null +++ b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/specs/tedge-remote-access/spec.md @@ -0,0 +1,135 @@ +## ADDED Requirements + +### Requirement: A `.local` target is resolved over mDNS + +The client SHALL resolve a remote-access target whose host name ends in +`.local` by a multicast DNS query on the device's network when mDNS +resolution is built in, and the session SHALL proceed with the IPv4 address it resolves +to. Each lookup SHALL be bounded by the configured timeout. A lookup that +times out or fails with a transient error SHALL be retried after the +configured delay, up to the configured number of attempts in all, and +the session SHALL proceed as soon as any attempt resolves; so a host that +never answers fails the operation within a bounded time rather than +holding the seat. A +`.local` target on a build without mDNS resolution SHALL fail with a +reason saying mDNS is not built in, and SHALL NOT be sent to a unicast DNS +server. + +#### Scenario: SSH to a Raspberry Pi by its mDNS name + +- **WHEN** an operator opens a remote-access session whose endpoint is + `rpi5-d83add9f145a.local:22`, and that host answers mDNS on the device's + subnet +- **THEN** the device resolves the name, bridges the session to the + address it answered with, and the operator reaches its SSH server + +#### Scenario: The host has a new DHCP lease + +- **WHEN** a session to `rpi5-d83add9f145a.local:22` is opened after the + host's address changed since the previous session +- **THEN** the device connects to the new address, without any change to + the endpoint in Cumulocity + +#### Scenario: The first lookup goes unanswered + +- **WHEN** the first mDNS query for the requested `.local` host gets no + answer within the resolve timeout, and the second one does +- **THEN** the device bridges the session to the address from the second + answer, and the operation succeeds + +#### Scenario: Nobody answers + +- **WHEN** the requested `.local` host does not answer any of the + configured attempts +- **THEN** the operation fails with a reason saying the name did not + answer over mDNS and how many attempts were made, no TCP connection is + attempted, and the seat is freed + +#### Scenario: Retrying is turned off + +- **WHEN** the configured number of attempts is one and that lookup gets + no answer +- **THEN** the operation fails after that single lookup + +#### Scenario: mDNS is not built in + +- **WHEN** a build without mDNS resolution receives a connect for a + `.local` target +- **THEN** the operation fails with a reason saying mDNS is not built in + +#### Scenario: The device also advertises itself over mDNS + +- **WHEN** the application runs Zephyr's mDNS responder and an operator + opens a session to another host's `.local` name +- **THEN** the name resolves and the session is bridged, and the device + keeps answering for its own `.local` name + +## MODIFIED Requirements + +### Requirement: Targets are checked before any connection is opened + +The client SHALL check the requested target against the build-time policy +(the device's own IPv4 subnets, an allow-list, or the device itself) and then +against the application's hook, before opening any socket to it. A target the +policy or the hook refuses SHALL fail the operation with a reason naming the +policy, and SHALL NOT be contacted. A host name SHALL be resolved first and +judged by the address it resolves to; this applies equally to a name +resolved over unicast DNS and to a `.local` name resolved over mDNS. An +allow-list entry SHALL match the host as requested, so a `.local` name is +allowed by listing that name; because any host on the link can answer an +mDNS query, a `.local` name SHALL in every policy also resolve to an +address on the device's own IPv4 subnets or to the device itself. A name that cannot be resolved SHALL fail the +operation with a reason that names the host and says whether it timed out +or failed. + +#### Scenario: Target outside the subnet + +- **WHEN** a remote-access connect names an address outside the device's + subnets and the policy is the default +- **THEN** no TCP connection is attempted, and the operation fails with a + reason naming the policy + +#### Scenario: Application narrows the policy + +- **WHEN** the application's hook refuses a target that the built-in policy + allows +- **THEN** the operation fails with the reason, and no connection is made + +#### Scenario: An mDNS answer outside the subnet + +- **WHEN** a `.local` name resolves to an address outside the device's + subnets and the policy is the default +- **THEN** no TCP connection is attempted, and the operation fails with a + reason naming the policy and the address the name resolved to + +#### Scenario: A `.local` name on the allow-list + +- **WHEN** the policy is the allow-list, it lists `rpi5.local:22`, and a + connect names `rpi5.local:22` +- **THEN** the name is resolved over mDNS and the session is bridged + +#### Scenario: A listed `.local` name answers from off the subnet + +- **WHEN** the policy is the allow-list, it lists `rpi5.local:22`, and the + mDNS answer for `rpi5.local` is an address outside the device's subnets +- **THEN** no TCP connection is attempted, and the operation fails with a + reason naming the address + +### Requirement: Every session is auditable + +The client SHALL publish an event when a tunnel opens and when it ends. The +open event SHALL name the target host and port; the close event SHALL name +the target, why the session ended and how many bytes travelled each way. +When the target was given as a name, both events SHALL also name the +address it resolved to, so the record shows which machine was reached. + +#### Scenario: Session ends + +- **WHEN** a tunnel that carried traffic ends because the client disconnected +- **THEN** an event records the target, the reason and the byte counts + +#### Scenario: Session to an mDNS name + +- **WHEN** a tunnel to `rpi5.local:22` opens and later ends, and the name + resolved to 192.168.68.72 +- **THEN** both events name `rpi5.local` and 192.168.68.72 diff --git a/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/tasks.md b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/tasks.md new file mode 100644 index 0000000..c21d261 --- /dev/null +++ b/openspec/changes/archive/2026-10-07-remote-access-mdns-targets/tasks.md @@ -0,0 +1,44 @@ +## 1. Root-cause the on-device `.local` failure (firmware, S3 DevKitC) + +- [x] 1.1 Build `apps/modbus-server` (what the board runs) for the S3 DevKitC with the `full` profile plus `CONFIG_MDNS_RESOLVER=y` and `CONFIG_NET_SHELL=y`; flash it and keep a console logger running from boot +- [x] 1.2 From the shell, run `net dns` (server list) before and after the DHCP lease, then `net dns rpi5-d83add9f145a.local`; record the result and the boot log's `net_sock_svc` line +- [x] 1.3 Capture port 5353 on the LAN during the query (`tcpdump` on the Pi or the Mac) to see whether the query leaves the board and the answer comes back +- [x] 1.4 Isolate the cause: no mDNS server with DHCP DNS (`net dns`), static servers break unicast ordering, and with the responder running the resolver never reads the unicast answer (capture on the Pi) +- [x] 1.5 Write the finding and the fix into `tedge-zephyr/docs/zephyr-mdns-resolver-bugs.md`, and resolve the design's open question on the resolver API (callback API vs `getaddrinfo()`) + +## 2. Kconfig (firmware, tedge-zephyr module) + +- [x] 2.1 Add `CONFIG_TEDGE_REMOTE_ACCESS_MDNS` (default `y`, under `TEDGE_REMOTE_ACCESS`, selecting only `NET_UDP`) +- [x] 2.2 Add `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_TIMEOUT_MS` (default 5000, range 500–10000) with help text +- [x] 2.3 Add `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_ATTEMPTS` (default 3, range 1–5) and `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_RETRY_DELAY_MS` (default 500, range 0–5000), plus a `BUILD_ASSERT` that the worst-case total stays under 30 s +- [x] 2.4 Add kconfig test cases: mDNS on by default with remote access, absent without it, and turned off explicitly; run `tests/kconfig/check-kconfig.sh` + +## 3. Resolution and policy (firmware, `tedge_remote_access.c`) + +- [x] 3.1 Add a `resolve_target()` helper: IP literal → as today; `.local` without the option → fail "mDNS is not built in"; `.local` → the module's own one-shot query (`mdns_once()`, builder/parser in `tedge_ra_target.c`); others → `dns_get_addr_info()` with the bounded timeout; return the address or a reason distinguishing timeout from failure +- [x] 3.2 Wrap the lookup in the retry loop: retry on timeout and transient errors, not on an authoritative no-such-name; WRN-log each failed attempt; name the attempt count in the final reason +- [x] 3.3 Replace the inline `getaddrinfo()` block in `bridge()` with the helper, keeping resolution on the session thread +- [x] 3.4 In `policy_check()`, require a `.local` answer to be loopback, the device's own address or on its subnets under every policy, with a reason naming the address +- [x] 3.5 Store the resolved address in the session and include `host (addr)` in the open/close events and logs when the host was a name; keep IP-literal text unchanged and the twin session list as requested + +## 4. Tests (firmware, host-side unit tests) + +- [x] 4.1 Unit-test the `.local` suffix detection (case-insensitive, trailing dot, `local` alone, `x.localhost` not matched) +- [x] 4.2 Unit-test the link-local rule for `.local` answers under the LAN, allow-list and local policies (factor the decision into a pure function if needed) +- [x] 4.3 Unit-test the mDNS query builder and answer parser (compression, case, wrong id, malformed and looping input) +- [x] 4.4 Unit-test the retry decision (which errors are retried) and the reason text with the attempt count; factor the classification into a pure function +- [x] 4.5 Unit-test that the longest close-event text (63-char host, address, max counters) fits the event text (now 200 bytes) without cutting the counters +- [x] 4.6 Run the unit test suite and the kconfig checks + +## 5. Device verification (firmware, real boards) + +- [x] 5.1 S3 DevKitC `full`: open SSH to the Pi by `.local` name from Cumulocity; confirm the session, both events with name and address, and that the device's own `.local` name still answers +- [x] 5.2 Same board: a non-existent `.local` name fails after the configured attempts (about 16 s) with the mDNS reason and attempt count, logs one WRN per attempt, and frees the seat; an allow-listed name still works +- [x] 5.3 Same board: block the Pi's mDNS answers for the first query (e.g. `avahi-daemon` stopped, restarted within 5 s of the connect) and confirm the session succeeds on a later attempt; also open a session whose name answers only on the third attempt (~11 s) and confirm the Cumulocity web client is still waiting when the tunnel comes up +- [x] 5.4 C6 `full` (on rpi5 `/dev/ttyACM1`): SSH to the Pi by `.local` name, events with name and address; flash/RAM deltas recorded for C6, S3 and QT Py. No release ota build includes remote access, so there was no ota image to repeat it on +- [x] 5.5 Build with `CONFIG_TEDGE_REMOTE_ACCESS_MDNS=n` and confirm the "not built in" reason + +## 6. Docs + +- [x] 6.1 Update the remote-access section of `tedge-zephyr/README.md`: `.local` endpoints, the four options (including retries), the link-local rule, and any application requirement found in task 1 +- [x] 6.2 Add the measured per-board costs to the proposal's Resources note and to `DEVICES.md` if a board needed a tuned option diff --git a/openspec/specs/tedge-remote-access/spec.md b/openspec/specs/tedge-remote-access/spec.md index 2e491d5..7fd85a9 100644 --- a/openspec/specs/tedge-remote-access/spec.md +++ b/openspec/specs/tedge-remote-access/spec.md @@ -2,7 +2,6 @@ ## Purpose Cumulocity remote access through the device: bridging a cloud session to a TCP target on the device's network, the target policy and session cap that bound it, and the events and capacity data that make it auditable. - ## Requirements ### Requirement: The device bridges a cloud remote-access session to a TCP target @@ -32,7 +31,14 @@ The client SHALL check the requested target against the build-time policy against the application's hook, before opening any socket to it. A target the policy or the hook refuses SHALL fail the operation with a reason naming the policy, and SHALL NOT be contacted. A host name SHALL be resolved first and -judged by the address it resolves to. +judged by the address it resolves to; this applies equally to a name +resolved over unicast DNS and to a `.local` name resolved over mDNS. An +allow-list entry SHALL match the host as requested, so a `.local` name is +allowed by listing that name; because any host on the link can answer an +mDNS query, a `.local` name SHALL in every policy also resolve to an +address on the device's own IPv4 subnets or to the device itself. A name that cannot be resolved SHALL fail the +operation with a reason that names the host and says whether it timed out +or failed. #### Scenario: Target outside the subnet @@ -47,6 +53,26 @@ judged by the address it resolves to. allows - **THEN** the operation fails with the reason, and no connection is made +#### Scenario: An mDNS answer outside the subnet + +- **WHEN** a `.local` name resolves to an address outside the device's + subnets and the policy is the default +- **THEN** no TCP connection is attempted, and the operation fails with a + reason naming the policy and the address the name resolved to + +#### Scenario: A `.local` name on the allow-list + +- **WHEN** the policy is the allow-list, it lists `rpi5.local:22`, and a + connect names `rpi5.local:22` +- **THEN** the name is resolved over mDNS and the session is bridged + +#### Scenario: A listed `.local` name answers from off the subnet + +- **WHEN** the policy is the allow-list, it lists `rpi5.local:22`, and the + mDNS answer for `rpi5.local` is an address outside the device's subnets +- **THEN** no TCP connection is attempted, and the operation fails with a + reason naming the address + ### Requirement: Concurrent sessions are capped and the refusal is immediate The number of sessions open at once SHALL NOT exceed the configured maximum. @@ -65,12 +91,20 @@ are open. The client SHALL publish an event when a tunnel opens and when it ends. The open event SHALL name the target host and port; the close event SHALL name the target, why the session ended and how many bytes travelled each way. +When the target was given as a name, both events SHALL also name the +address it resolved to, so the record shows which machine was reached. #### Scenario: Session ends - **WHEN** a tunnel that carried traffic ends because the client disconnected - **THEN** an event records the target, the reason and the byte counts +#### Scenario: Session to an mDNS name + +- **WHEN** a tunnel to `rpi5.local:22` opens and later ends, and the name + resolved to 192.168.68.72 +- **THEN** both events name `rpi5.local` and 192.168.68.72 + ### Requirement: Remote-access capacity is visible before a session is opened The client SHALL publish `tedge_RemoteAccess` twin data with the session @@ -113,3 +147,67 @@ timeout in the close event. the idle timeout - **THEN** the device closes it, publishes the close event and frees the seat +### Requirement: A `.local` target is resolved over mDNS + +The client SHALL resolve a remote-access target whose host name ends in +`.local` by a multicast DNS query on the device's network when mDNS +resolution is built in, and the session SHALL proceed with the IPv4 address it resolves +to. Each lookup SHALL be bounded by the configured timeout. A lookup that +times out or fails with a transient error SHALL be retried after the +configured delay, up to the configured number of attempts in all, and +the session SHALL proceed as soon as any attempt resolves; so a host that +never answers fails the operation within a bounded time rather than +holding the seat. A +`.local` target on a build without mDNS resolution SHALL fail with a +reason saying mDNS is not built in, and SHALL NOT be sent to a unicast DNS +server. + +#### Scenario: SSH to a Raspberry Pi by its mDNS name + +- **WHEN** an operator opens a remote-access session whose endpoint is + `rpi5-d83add9f145a.local:22`, and that host answers mDNS on the device's + subnet +- **THEN** the device resolves the name, bridges the session to the + address it answered with, and the operator reaches its SSH server + +#### Scenario: The host has a new DHCP lease + +- **WHEN** a session to `rpi5-d83add9f145a.local:22` is opened after the + host's address changed since the previous session +- **THEN** the device connects to the new address, without any change to + the endpoint in Cumulocity + +#### Scenario: The first lookup goes unanswered + +- **WHEN** the first mDNS query for the requested `.local` host gets no + answer within the resolve timeout, and the second one does +- **THEN** the device bridges the session to the address from the second + answer, and the operation succeeds + +#### Scenario: Nobody answers + +- **WHEN** the requested `.local` host does not answer any of the + configured attempts +- **THEN** the operation fails with a reason saying the name did not + answer over mDNS and how many attempts were made, no TCP connection is + attempted, and the seat is freed + +#### Scenario: Retrying is turned off + +- **WHEN** the configured number of attempts is one and that lookup gets + no answer +- **THEN** the operation fails after that single lookup + +#### Scenario: mDNS is not built in + +- **WHEN** a build without mDNS resolution receives a connect for a + `.local` target +- **THEN** the operation fails with a reason saying mDNS is not built in + +#### Scenario: The device also advertises itself over mDNS + +- **WHEN** the application runs Zephyr's mDNS responder and an operator + opens a session to another host's `.local` name +- **THEN** the name resolves and the session is bridged, and the device + keeps answering for its own `.local` name + diff --git a/release/size-baseline.json b/release/size-baseline.json index 953964d..82b4766 100644 --- a/release/size-baseline.json +++ b/release/size-baseline.json @@ -74,13 +74,13 @@ } }, "modbus-server-tedge-full-esp32c6-devkitc": { - "image": 962811, - "content": 853123, + "image": 963052, + "content": 855524, "ram": { - "sram0_0_seg": 432540, + "sram0_0_seg": 432716, "lp_ram_seg": 36 }, - "arena": 76896, + "arena": 76720, "provisioner": { "image": 1013964, "content": 909468, @@ -95,13 +95,13 @@ } }, "modbus-server-tedge-full-esp32s3-devkitc": { - "image": 935259, - "content": 935259, + "image": 935467, + "content": 935467, "ram": { "iram0_0_seg": 61332, - "dram0_0_seg": 343028 + "dram0_0_seg": 343196 }, - "arena": 56076, + "arena": 55908, "provisioner": { "image": 732123, "content": 732123, @@ -113,16 +113,16 @@ } }, "modbus-server-tedge-full-qtpy-esp32s3": { - "image": 935435, - "content": 935435, + "image": 935660, + "content": 935660, "ram": { "iram0_0_seg": 60380, - "dram0_0_seg": 339420 + "dram0_0_seg": 339588 }, - "arena": 59684, + "arena": 59516, "provisioner": { - "image": 732284, - "content": 732284, + "image": 732283, + "content": 732283, "ram": { "iram0_0_seg": 57216, "dram0_0_seg": 248204 @@ -394,16 +394,16 @@ } }, "snmp-agent-tedge-full-esp32c6-devkitc": { - "image": 960747, - "content": 843395, + "image": 960988, + "content": 845796, "ram": { - "sram0_0_seg": 444820, + "sram0_0_seg": 444996, "lp_ram_seg": 36 }, - "arena": 64608, + "arena": 64432, "provisioner": { - "image": 1013962, - "content": 909466, + "image": 1013963, + "content": 909467, "ram": { "sram0_0_seg": 302652, "lp_ram_seg": 36 @@ -415,13 +415,13 @@ } }, "snmp-agent-tedge-full-esp32s3-devkitc": { - "image": 809515, - "content": 809515, + "image": 809739, + "content": 809739, "ram": { "iram0_0_seg": 60032, - "dram0_0_seg": 339936 + "dram0_0_seg": 340104 }, - "arena": 59148, + "arena": 58980, "provisioner": { "image": 732122, "content": 732122, @@ -433,16 +433,16 @@ } }, "snmp-agent-tedge-full-qtpy-esp32s3": { - "image": 809659, - "content": 809659, + "image": 809866, + "content": 809866, "ram": { "iram0_0_seg": 59080, - "dram0_0_seg": 336324 + "dram0_0_seg": 336492 }, - "arena": 62756, + "arena": 62588, "provisioner": { - "image": 732283, - "content": 732283, + "image": 732284, + "content": 732284, "ram": { "iram0_0_seg": 57216, "dram0_0_seg": 248204 @@ -528,13 +528,13 @@ } }, "tedge-agent-tedge-full-esp32c6-devkitc": { - "image": 893402, - "content": 829506, + "image": 893643, + "content": 831907, "ram": { - "sram0_0_seg": 427860, + "sram0_0_seg": 428036, "lp_ram_seg": 36 }, - "arena": 81568, + "arena": 81392, "provisioner": { "image": 1013963, "content": 909467, @@ -549,13 +549,13 @@ } }, "tedge-agent-tedge-full-esp32s3-devkitc": { - "image": 931627, - "content": 931627, + "image": 931851, + "content": 931851, "ram": { "iram0_0_seg": 61332, - "dram0_0_seg": 337848 + "dram0_0_seg": 338016 }, - "arena": 61244, + "arena": 61076, "provisioner": { "image": 732122, "content": 732122, @@ -567,13 +567,13 @@ } }, "tedge-agent-tedge-full-qtpy-esp32s3": { - "image": 931819, - "content": 931819, + "image": 932043, + "content": 932043, "ram": { "iram0_0_seg": 60380, - "dram0_0_seg": 334236 + "dram0_0_seg": 334404 }, - "arena": 64852, + "arena": 64684, "provisioner": { "image": 732282, "content": 732282, diff --git a/tedge-zephyr/CMakeLists.txt b/tedge-zephyr/CMakeLists.txt index 829f844..ba9472c 100644 --- a/tedge-zephyr/CMakeLists.txt +++ b/tedge-zephyr/CMakeLists.txt @@ -59,6 +59,7 @@ if(CONFIG_TEDGE) zephyr_library_sources_ifdef(CONFIG_TEDGE_REMOTE_ACCESS src/tedge_remote_access.c src/tedge_ra_allow_list.c + src/tedge_ra_target.c ) zephyr_library_sources_ifdef(CONFIG_TEDGE_AUTH_C8Y_CA src/tedge_enroll.c diff --git a/tedge-zephyr/Kconfig b/tedge-zephyr/Kconfig index b325541..6bbe7a5 100644 --- a/tedge-zephyr/Kconfig +++ b/tedge-zephyr/Kconfig @@ -606,6 +606,46 @@ config TEDGE_REMOTE_ACCESS_IDLE_TIMEOUT_S int "Close a session after this many idle seconds (0 = never)" default 900 +config TEDGE_REMOTE_ACCESS_MDNS + bool "Resolve .local targets over mDNS" + default y + # The client sends its own one-shot query (tedge_remote_access.c, + # mdns_once()); Zephyr's mDNS resolver is not needed, and in 4.4 does + # not receive answers while the application runs the mDNS responder. + select NET_UDP + help + A remote-access target named ".local" is resolved by a + multicast query on the device's network, so an operator can reach a + LAN host by its mDNS name whatever its current DHCP address. Any host + on the link can answer, so the answer must be on the device's own + subnets under every target policy. Each lookup opens one UDP socket + for its duration. Without this option a ".local" target fails with + "mDNS is not built in". + +config TEDGE_REMOTE_ACCESS_RESOLVE_TIMEOUT_MS + int "How long each lookup of a target name waits (ms)" + range 500 10000 + default 5000 + help + Applies to mDNS and unicast DNS names alike. A host waking from + Wi-Fi power save can take seconds to answer. + +config TEDGE_REMOTE_ACCESS_RESOLVE_ATTEMPTS + int "Lookups of a target name before the session fails" + range 1 5 + default 3 + help + A lookup that times out or hits a transient error is tried again, + up to this many lookups in all; 1 turns retrying off. An + authoritative "no such name" is not retried. The session's seat is + held while it resolves: at most attempts x timeout plus the pauses + between them, which the build keeps under 30 s. + +config TEDGE_REMOTE_ACCESS_RESOLVE_RETRY_DELAY_MS + int "Pause between lookups of a target name (ms)" + range 0 5000 + default 500 + endif # TEDGE_REMOTE_ACCESS config TEDGE_PARAMETERS diff --git a/tedge-zephyr/README.md b/tedge-zephyr/README.md index 0547d95..57b051e 100644 --- a/tedge-zephyr/README.md +++ b/tedge-zephyr/README.md @@ -587,6 +587,39 @@ keeps that from happening. and it has to be onboarded again, with a new registration and one-time password. That is why the alarm exists. +### Remote-access targets + +A remote-access endpoint can name its target by IP address, by DNS name or +by mDNS name (`rpi5-d83add9f145a.local:22`). An mDNS name is looked up +afresh for every session, so the endpoint keeps working when the host's +DHCP lease moves it. The address a name resolved to is judged by the target +policy, and the session's open and close events name both: +`tunnel to rpi5-d83add9f145a.local (192.168.68.84):22 opened`. + +| Option | Default | | +|---|---|---| +| `CONFIG_TEDGE_REMOTE_ACCESS_MDNS` | `y` | Resolve `.local` targets over mDNS. Off, a `.local` target fails with "mDNS is not built in" | +| `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_TIMEOUT_MS` | 5000 | How long each lookup waits | +| `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_ATTEMPTS` | 3 | Lookups before the session fails; `1` turns retrying off | +| `CONFIG_TEDGE_REMOTE_ACCESS_RESOLVE_RETRY_DELAY_MS` | 500 | Pause between lookups | + +A lookup that times out or hits a transient error is retried; an +authoritative "no such name" is not. Each failed attempt is logged as a +warning, and the session's seat is held while it resolves: about 16 s at +most with the defaults, never more than 30 s (checked at build time). + +Any host on the network can answer an mDNS query, so a `.local` name must +resolve to an address on the device's own subnets, or the device itself, +under every target policy. An allow-list entry matches the name as +requested (`rpi5-d83add9f145a.local:22`). + +The client sends its own mDNS query rather than using Zephyr's resolver, +which in Zephyr 4.4.2 cannot look up `.local` names on a device that takes +DNS from DHCP or runs the mDNS responder +([`docs/zephyr-mdns-resolver-bugs.md`](docs/zephyr-mdns-resolver-bugs.md)). +It needs nothing from the application beyond UDP; each lookup holds one +socket while it runs. + ### Security limitations - The device key is stored in PSA ITS, encrypted with a key derived from the diff --git a/tedge-zephyr/docs/zephyr-mdns-resolver-bugs.md b/tedge-zephyr/docs/zephyr-mdns-resolver-bugs.md new file mode 100644 index 0000000..2745672 --- /dev/null +++ b/tedge-zephyr/docs/zephyr-mdns-resolver-bugs.md @@ -0,0 +1,93 @@ +# Zephyr's resolver cannot look up `.local` names on a typical device + +A report prepared for upstream Zephyr (`zephyrproject-rtos/zephyr`). It is +kept here because the client works around it, and the workaround should be +removed once it is fixed. + +- **Affects:** Zephyr 4.4.2 (seen), `subsys/net/lib/dns/resolve.c` and + `dispatcher.c` +- **Severity:** `.local` lookups never succeed on a device that takes DNS + from DHCP and also runs the mDNS responder +- **Workaround in this module:** `src/tedge_remote_access.c` + (`mdns_once()`) sends its own one-shot mDNS query for a remote-access + target and reads the answer itself; Zephyr's resolver is used only for + unicast names. + +Seen on an ESP32-S3-DevKitC-1, `apps/modbus-server` with the `full` +profile, 2026-10-07, with a Raspberry Pi (systemd-resolved) as the host +being looked up. Both problems below have to be fixed for a `.local` +lookup to work; the first hides the second. + +## 1. With DNS from DHCP there is no mDNS server + +`dns_resolve_init_default()` adds the mDNS server (224.0.0.251:5353) to +the default context only inside `#if defined(CONFIG_DNS_SERVER_IP_ADDRESSES)`. +A device that gets its DNS server from DHCP has no static servers, so the +default context never gets an mDNS server, whatever `CONFIG_MDNS_RESOLVER` +says. `dns_resolve_name_internal()` then finds no server for the `.local` +query and returns `-ENOENT`, which `getaddrinfo()` reports as +`DNS_EAI_SYSTEM` (-11). + +``` +uart:~$ net dns +DNS servers: + 192.168.178.1:53 via wlan0 (DHCP) +uart:~$ net dns rpi5-d83add9f145a.local +Cannot resolve 'rpi5-d83add9f145a.local' (-2) +``` + +Setting `CONFIG_DNS_SERVER_IP_ADDRESSES=y` with no servers adds the mDNS +server, but in **slot 0**: the empty static entry is skipped, and DHCP's +server lands in slot 1. The resolver keeps `.local` queries off unicast +servers, but not the reverse: every other name goes to the first server +with a socket. Ordinary lookups (`pool.ntp.org`) then go to +224.0.0.251:5353 and time out, and the device cannot connect. Zephyr's own +default list puts mDNS *after* the configured servers, which is why this +only shows up when that list is empty. + +Adding the mDNS server after DHCP has configured its own +(`dns_resolve_reconfigure()` with `"224.0.0.251:5353"`, with +`DNS_RECONFIGURE_CLEANUP` off) keeps the order and fixes this part. + +(The `NET_DBG` in the `servers_sa` branch of `dns_resolve_init_locked()` +also prints `ctx->servers[i]` where it means `ctx->servers[idx]`, so the +DHCP server is logged with the mDNS flag of slot 0. Misleading while +debugging this.) + +## 2. With the mDNS responder running, the answers are never read + +When the application runs `MDNS_RESPONDER`, the responder owns UDP 5353. +`register_dispatcher()` finds that entry for the resolver's mDNS socket +and **pairs** the two: the resolver socket is neither bound nor registered +with the socket service. The resolver then sends its query from that +unbound socket, so from an ephemeral port. RFC 6762 section 6.7 makes that +a "legacy unicast" query, which responders answer by unicast to the +source port; the paired design expects answers on 5353. Nothing reads the +ephemeral port, and every lookup times out. + +A capture on the host being looked up shows the exchange working on the +wire: + +``` +192.168.68.67.39717 > 224.0.0.251.5353: 0+ A (QM)? rpi5-d83add9f145a.local. (41) +192.168.68.84.5353 > 192.168.68.67.39717: 0*- 1/0/0 A 192.168.68.84 (57) +``` + +and the device logs `Query timeout DNS req 0` two seconds later. + +## Suggested fixes + +1. Add the mDNS (and LLMNR) servers in `dns_resolve_init_default()` + whether or not static servers are configured, after them; and skip + mDNS servers when choosing a server for a non-`.local` name. +2. When pairing a resolver socket with a responder, either send the query + from the responder's 5353 socket (so answers are multicast and reach + it), or also bind and poll the resolver's own socket for unicast + answers. + +## Removing the workaround + +Once both are fixed, `mdns_once()` and the packet helpers in +`src/tedge_ra_target.c` can go, and `.local` names can take the same +`dns_get_addr_info()` path as other names, with +`CONFIG_TEDGE_REMOTE_ACCESS_MDNS` selecting `MDNS_RESOLVER`. diff --git a/tedge-zephyr/src/tedge_c8y.c b/tedge-zephyr/src/tedge_c8y.c index e7cc86a..9fa1b94 100644 --- a/tedge-zephyr/src/tedge_c8y.c +++ b/tedge-zephyr/src/tedge_c8y.c @@ -1315,7 +1315,7 @@ static int c8y_poll(int timeout_ms) { struct tedge_ra_event ev; static int64_t twin_due; - char sr[224], quoted[160]; + char sr[272], quoted[208]; while (tedge_ra_poll_event(&ev) == 0) { (void)tedge_sr_quote(ev.text, quoted, sizeof(quoted)); diff --git a/tedge-zephyr/src/tedge_internal.h b/tedge-zephyr/src/tedge_internal.h index d88c8d9..a9663f3 100644 --- a/tedge-zephyr/src/tedge_internal.h +++ b/tedge-zephyr/src/tedge_internal.h @@ -398,14 +398,74 @@ enum tedge_ra_event_type { TEDGE_RA_CLOSED, /* an established tunnel ended: publish the event */ }; +/* Sized for the longest close event: a 63-character host, its address, + * the longest reason and two 20-digit counters (unit-tested). */ struct tedge_ra_event { enum tedge_ra_event_type type; - char text[144]; + char text[200]; }; +/* The open and close event texts; %s is tedge_ra_target_str(). */ +#define TEDGE_RA_OPENED_FMT "tunnel to %s opened" +#define TEDGE_RA_CLOSED_FMT "tunnel to %s closed (%s): %llu B up, %llu B down" + /** True when ":" is one of the comma-separated @p list entries. */ bool tedge_ra_in_allow_list(const char *list, const char *host, uint16_t port); +/* --- Remote-access targets (tedge_ra_target.c) ----------------------------- */ + +/** True when @p host is an mDNS name: "