1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package android.net.thread.utils;
17 
18 import static android.net.thread.utils.IntegrationTestUtils.SERVICE_DISCOVERY_TIMEOUT;
19 import static android.net.thread.utils.IntegrationTestUtils.waitFor;
20 
21 import static com.google.common.io.BaseEncoding.base16;
22 
23 import static java.util.concurrent.TimeUnit.SECONDS;
24 
25 import android.net.InetAddresses;
26 import android.net.IpPrefix;
27 import android.net.nsd.NsdServiceInfo;
28 import android.net.thread.ActiveOperationalDataset;
29 import android.os.Handler;
30 import android.os.HandlerThread;
31 
32 import com.google.errorprone.annotations.FormatMethod;
33 
34 import java.io.BufferedReader;
35 import java.io.BufferedWriter;
36 import java.io.IOException;
37 import java.io.InputStreamReader;
38 import java.io.OutputStreamWriter;
39 import java.net.Inet6Address;
40 import java.net.InetAddress;
41 import java.nio.charset.StandardCharsets;
42 import java.time.Duration;
43 import java.util.ArrayList;
44 import java.util.List;
45 import java.util.Map;
46 import java.util.concurrent.CompletableFuture;
47 import java.util.concurrent.ExecutionException;
48 import java.util.concurrent.TimeoutException;
49 import java.util.regex.Matcher;
50 import java.util.regex.Pattern;
51 
52 /**
53  * A class that launches and controls a simulation Full Thread Device (FTD).
54  *
55  * <p>This class launches an `ot-cli-ftd` process and communicates with it via command line input
56  * and output. See <a
57  * href="https://github.com/openthread/openthread/blob/main/src/cli/README.md">this page</a> for
58  * available commands.
59  */
60 public final class FullThreadDevice {
61     private static final int HOP_LIMIT = 64;
62     private static final int PING_INTERVAL = 1;
63     private static final int PING_SIZE = 100;
64     // There may not be a response for the ping command, using a short timeout to keep the tests
65     // short.
66     private static final float PING_TIMEOUT_0_1_SECOND = 0.1f;
67     // 1 second timeout should be used when response is expected.
68     private static final float PING_TIMEOUT_1_SECOND = 1f;
69     private static final int READ_LINE_TIMEOUT_SECONDS = 5;
70 
71     private final Process mProcess;
72     private final BufferedReader mReader;
73     private final BufferedWriter mWriter;
74     private final HandlerThread mReaderHandlerThread;
75     private final Handler mReaderHandler;
76 
77     private ActiveOperationalDataset mActiveOperationalDataset;
78 
79     /**
80      * Constructs a {@link FullThreadDevice} for the given node ID.
81      *
82      * <p>It launches an `ot-cli-ftd` process using the given node ID. The node ID is an integer in
83      * range [1, OPENTHREAD_SIMULATION_MAX_NETWORK_SIZE]. `OPENTHREAD_SIMULATION_MAX_NETWORK_SIZE`
84      * is defined in `external/openthread/examples/platforms/simulation/platform-config.h`.
85      *
86      * @param nodeId the node ID for the simulation Full Thread Device.
87      * @throws IllegalStateException the node ID is already occupied by another simulation Thread
88      *     device.
89      */
FullThreadDevice(int nodeId)90     public FullThreadDevice(int nodeId) {
91         try {
92             mProcess = Runtime.getRuntime().exec("/system/bin/ot-cli-ftd -Leth1 " + nodeId);
93         } catch (IOException e) {
94             throw new IllegalStateException(
95                     "Failed to start ot-cli-ftd -Leth1 (id=" + nodeId + ")", e);
96         }
97         mReader = new BufferedReader(new InputStreamReader(mProcess.getInputStream()));
98         mWriter = new BufferedWriter(new OutputStreamWriter(mProcess.getOutputStream()));
99         mReaderHandlerThread = new HandlerThread("FullThreadDeviceReader");
100         mReaderHandlerThread.start();
101         mReaderHandler = new Handler(mReaderHandlerThread.getLooper());
102         mActiveOperationalDataset = null;
103     }
104 
destroy()105     public void destroy() {
106         mProcess.destroy();
107         mReaderHandlerThread.quit();
108     }
109 
110     /**
111      * Returns an OMR (Off-Mesh-Routable) address on this device if any.
112      *
113      * <p>This methods goes through all unicast addresses on the device and returns the first
114      * address which is neither link-local nor mesh-local.
115      */
getOmrAddress()116     public Inet6Address getOmrAddress() {
117         List<String> addresses = executeCommand("ipaddr");
118         IpPrefix meshLocalPrefix = mActiveOperationalDataset.getMeshLocalPrefix();
119         for (String address : addresses) {
120             if (address.startsWith("fe80:")) {
121                 continue;
122             }
123             Inet6Address addr = (Inet6Address) InetAddresses.parseNumericAddress(address);
124             if (!meshLocalPrefix.contains(addr)) {
125                 return addr;
126             }
127         }
128         return null;
129     }
130 
131     /** Returns the Mesh-local EID address on this device if any. */
getMlEid()132     public Inet6Address getMlEid() {
133         List<String> addresses = executeCommand("ipaddr mleid");
134         return (Inet6Address) InetAddresses.parseNumericAddress(addresses.get(0));
135     }
136 
137     /**
138      * Returns the link-local address of the device.
139      *
140      * <p>This methods goes through all unicast addresses on the device and returns the address that
141      * begins with fe80.
142      */
getLinkLocalAddress()143     public Inet6Address getLinkLocalAddress() {
144         List<String> output = executeCommand("ipaddr linklocal");
145         if (!output.isEmpty() && output.get(0).startsWith("fe80:")) {
146             return (Inet6Address) InetAddresses.parseNumericAddress(output.get(0));
147         }
148         return null;
149     }
150 
151     /**
152      * Returns the mesh-local addresses of the device.
153      *
154      * <p>This methods goes through all unicast addresses on the device and returns the address that
155      * begins with mesh-local prefix.
156      */
getMeshLocalAddresses()157     public List<Inet6Address> getMeshLocalAddresses() {
158         List<String> addresses = executeCommand("ipaddr");
159         List<Inet6Address> meshLocalAddresses = new ArrayList<>();
160         IpPrefix meshLocalPrefix = mActiveOperationalDataset.getMeshLocalPrefix();
161         for (String address : addresses) {
162             Inet6Address addr = (Inet6Address) InetAddresses.parseNumericAddress(address);
163             if (meshLocalPrefix.contains(addr)) {
164                 meshLocalAddresses.add(addr);
165             }
166         }
167         return meshLocalAddresses;
168     }
169 
170     /**
171      * Joins the Thread network using the given {@link ActiveOperationalDataset}.
172      *
173      * @param dataset the Active Operational Dataset
174      */
joinNetwork(ActiveOperationalDataset dataset)175     public void joinNetwork(ActiveOperationalDataset dataset) {
176         mActiveOperationalDataset = dataset;
177         executeCommand("dataset set active " + base16().lowerCase().encode(dataset.toThreadTlvs()));
178         executeCommand("ifconfig up");
179         executeCommand("thread start");
180     }
181 
182     /** Stops the Thread network radio. */
stopThreadRadio()183     public void stopThreadRadio() {
184         executeCommand("thread stop");
185         executeCommand("ifconfig down");
186     }
187 
188     /**
189      * Waits for the Thread device to enter the any state of the given {@link List<String>}.
190      *
191      * @param states the list of states to wait for. Valid states are "disabled", "detached",
192      *     "child", "router" and "leader".
193      * @param timeout the time to wait for the expected state before throwing
194      */
waitForStateAnyOf(List<String> states, Duration timeout)195     public void waitForStateAnyOf(List<String> states, Duration timeout) throws TimeoutException {
196         waitFor(() -> states.contains(getState()), timeout);
197     }
198 
199     /**
200      * Gets the state of the Thread device.
201      *
202      * @return a string representing the state.
203      */
getState()204     public String getState() {
205         return executeCommand("state").get(0);
206     }
207 
208     /** Closes the UDP socket. */
udpClose()209     public void udpClose() {
210         executeCommand("udp close");
211     }
212 
213     /** Opens the UDP socket. */
udpOpen()214     public void udpOpen() {
215         executeCommand("udp open");
216     }
217 
218     /** Opens the UDP socket and binds it to a specific address and port. */
udpBind(Inet6Address address, int port)219     public void udpBind(Inet6Address address, int port) {
220         udpClose();
221         udpOpen();
222         executeCommand("udp bind %s %d", address.getHostAddress(), port);
223     }
224 
225     /** Returns the message received on the UDP socket. */
udpReceive()226     public String udpReceive() throws IOException {
227         Pattern pattern =
228                 Pattern.compile("> (\\d+) bytes from ([\\da-f:]+) (\\d+) ([\\x00-\\x7F]+)");
229         Matcher matcher = pattern.matcher(readLine());
230         matcher.matches();
231 
232         return matcher.group(4);
233     }
234 
235     /** Sends a UDP message to given IPv6 address and port. */
udpSend(String message, Inet6Address serverAddr, int serverPort)236     public void udpSend(String message, Inet6Address serverAddr, int serverPort) {
237         executeCommand("udp send %s %d %s", serverAddr.getHostAddress(), serverPort, message);
238     }
239 
240     /** Enables the SRP client and run in autostart mode. */
autoStartSrpClient()241     public void autoStartSrpClient() {
242         executeCommand("srp client autostart enable");
243     }
244 
245     /** Sets the hostname (e.g. "MyHost") for the SRP client. */
setSrpHostname(String hostname)246     public void setSrpHostname(String hostname) {
247         executeCommand("srp client host name " + hostname);
248     }
249 
250     /** Sets the host addresses for the SRP client. */
setSrpHostAddresses(List<Inet6Address> addresses)251     public void setSrpHostAddresses(List<Inet6Address> addresses) {
252         executeCommand(
253                 "srp client host address "
254                         + String.join(
255                                 " ",
256                                 addresses.stream().map(Inet6Address::getHostAddress).toList()));
257     }
258 
259     /** Removes the SRP host */
removeSrpHost()260     public void removeSrpHost() {
261         executeCommand("srp client host remove 1 1");
262     }
263 
264     /**
265      * Adds an SRP service for the SRP client and wait for the registration to complete.
266      *
267      * @param serviceName the service name like "MyService"
268      * @param serviceType the service type like "_test._tcp"
269      * @param subtypes the service subtypes like "_sub1"
270      * @param port the port number in range [1, 65535]
271      * @param txtMap the map of TXT names and values
272      * @throws TimeoutException if the service isn't registered within timeout
273      */
addSrpService( String serviceName, String serviceType, List<String> subtypes, int port, Map<String, byte[]> txtMap)274     public void addSrpService(
275             String serviceName,
276             String serviceType,
277             List<String> subtypes,
278             int port,
279             Map<String, byte[]> txtMap)
280             throws TimeoutException {
281         StringBuilder fullServiceType = new StringBuilder(serviceType);
282         for (String subtype : subtypes) {
283             fullServiceType.append(",").append(subtype);
284         }
285         executeCommand(
286                 "srp client service add %s %s %d %d %d %s",
287                 serviceName,
288                 fullServiceType,
289                 port,
290                 0 /* priority */,
291                 0 /* weight */,
292                 txtMapToHexString(txtMap));
293         waitFor(() -> isSrpServiceRegistered(serviceName, serviceType), SERVICE_DISCOVERY_TIMEOUT);
294     }
295 
296     /**
297      * Removes an SRP service for the SRP client.
298      *
299      * @param serviceName the service name like "MyService"
300      * @param serviceType the service type like "_test._tcp"
301      * @param notifyServer whether to notify SRP server about the removal
302      */
removeSrpService(String serviceName, String serviceType, boolean notifyServer)303     public void removeSrpService(String serviceName, String serviceType, boolean notifyServer) {
304         String verb = notifyServer ? "remove" : "clear";
305         executeCommand("srp client service %s %s %s", verb, serviceName, serviceType);
306     }
307 
308     /**
309      * Updates an existing SRP service for the SRP client.
310      *
311      * <p>This is essentially a 'remove' and an 'add' on the SRP client's side.
312      *
313      * @param serviceName the service name like "MyService"
314      * @param serviceType the service type like "_test._tcp"
315      * @param subtypes the service subtypes like "_sub1"
316      * @param port the port number in range [1, 65535]
317      * @param txtMap the map of TXT names and values
318      * @throws TimeoutException if the service isn't updated within timeout
319      */
updateSrpService( String serviceName, String serviceType, List<String> subtypes, int port, Map<String, byte[]> txtMap)320     public void updateSrpService(
321             String serviceName,
322             String serviceType,
323             List<String> subtypes,
324             int port,
325             Map<String, byte[]> txtMap)
326             throws TimeoutException {
327         removeSrpService(serviceName, serviceType, false /* notifyServer */);
328         addSrpService(serviceName, serviceType, subtypes, port, txtMap);
329     }
330 
331     /** Checks if an SRP service is registered. */
isSrpServiceRegistered(String serviceName, String serviceType)332     public boolean isSrpServiceRegistered(String serviceName, String serviceType) {
333         List<String> lines = executeCommand("srp client service");
334         for (String line : lines) {
335             if (line.contains(serviceName) && line.contains(serviceType)) {
336                 return line.contains("Registered");
337             }
338         }
339         return false;
340     }
341 
342     /** Checks if an SRP host is registered. */
isSrpHostRegistered()343     public boolean isSrpHostRegistered() {
344         List<String> lines = executeCommand("srp client host");
345         for (String line : lines) {
346             return line.contains("Registered");
347         }
348         return false;
349     }
350 
351     /** Sets the DNS server address. */
setDnsServerAddress(String address)352     public void setDnsServerAddress(String address) {
353         executeCommand("dns config " + address);
354     }
355 
356     /** Returns the first browsed service instance of {@code serviceType}. */
browseService(String serviceType)357     public NsdServiceInfo browseService(String serviceType) {
358         // CLI output:
359         // DNS browse response for _testservice._tcp.default.service.arpa.
360         // test-service
361         //    Port:12345, Priority:0, Weight:0, TTL:10
362         //    Host:testhost.default.service.arpa.
363         //    HostAddress:2001:0:0:0:0:0:0:1 TTL:10
364         //    TXT:[key1=0102, key2=03] TTL:10
365 
366         List<String> lines = executeCommand("dns browse " + serviceType);
367         NsdServiceInfo info = new NsdServiceInfo();
368         info.setServiceName(lines.get(1));
369         info.setServiceType(serviceType);
370         info.setPort(DnsServiceCliOutputParser.parsePort(lines.get(2)));
371         info.setHostname(DnsServiceCliOutputParser.parseHostname(lines.get(3)));
372         info.setHostAddresses(List.of(DnsServiceCliOutputParser.parseHostAddress(lines.get(4))));
373         DnsServiceCliOutputParser.parseTxtIntoServiceInfo(lines.get(5), info);
374 
375         return info;
376     }
377 
378     /** Returns the resolved service instance. */
resolveService(String serviceName, String serviceType)379     public NsdServiceInfo resolveService(String serviceName, String serviceType) {
380         // CLI output:
381         // DNS service resolution response for test-service for service
382         // _test._tcp.default.service.arpa.
383         // Port:12345, Priority:0, Weight:0, TTL:10
384         // Host:Android.default.service.arpa.
385         // HostAddress:2001:0:0:0:0:0:0:1 TTL:10
386         // TXT:[key1=0102, key2=03] TTL:10
387 
388         List<String> lines = executeCommand("dns service %s %s", serviceName, serviceType);
389         NsdServiceInfo info = new NsdServiceInfo();
390         info.setServiceName(serviceName);
391         info.setServiceType(serviceType);
392         info.setPort(DnsServiceCliOutputParser.parsePort(lines.get(1)));
393         info.setHostname(DnsServiceCliOutputParser.parseHostname(lines.get(2)));
394         info.setHostAddresses(List.of(DnsServiceCliOutputParser.parseHostAddress(lines.get(3))));
395         DnsServiceCliOutputParser.parseTxtIntoServiceInfo(lines.get(4), info);
396 
397         return info;
398     }
399 
400     /** Runs the "factoryreset" command on the device. */
factoryReset()401     public void factoryReset() {
402         try {
403             mWriter.write("factoryreset\n");
404             mWriter.flush();
405             // fill the input buffer to avoid truncating next command
406             for (int i = 0; i < 1000; ++i) {
407                 mWriter.write("\n");
408             }
409             mWriter.flush();
410         } catch (IOException e) {
411             throw new IllegalStateException("Failed to run factoryreset on ot-cli-ftd", e);
412         }
413     }
414 
subscribeMulticastAddress(Inet6Address address)415     public void subscribeMulticastAddress(Inet6Address address) {
416         executeCommand("ipmaddr add " + address.getHostAddress());
417     }
418 
ping(Inet6Address address, Inet6Address source)419     public void ping(Inet6Address address, Inet6Address source) {
420         ping(
421                 address,
422                 source,
423                 PING_SIZE,
424                 1 /* count */,
425                 PING_INTERVAL,
426                 HOP_LIMIT,
427                 PING_TIMEOUT_0_1_SECOND);
428     }
429 
ping(Inet6Address address)430     public void ping(Inet6Address address) {
431         ping(
432                 address,
433                 null,
434                 PING_SIZE,
435                 1 /* count */,
436                 PING_INTERVAL,
437                 HOP_LIMIT,
438                 PING_TIMEOUT_0_1_SECOND);
439     }
440 
441     /** Returns the number of ping reply packets received. */
ping(Inet6Address address, int count)442     public int ping(Inet6Address address, int count) {
443         List<String> output =
444                 ping(
445                         address,
446                         null,
447                         PING_SIZE,
448                         count,
449                         PING_INTERVAL,
450                         HOP_LIMIT,
451                         PING_TIMEOUT_1_SECOND);
452         return getReceivedPacketsCount(output);
453     }
454 
ping( Inet6Address address, Inet6Address source, int size, int count, int interval, int hopLimit, float timeout)455     private List<String> ping(
456             Inet6Address address,
457             Inet6Address source,
458             int size,
459             int count,
460             int interval,
461             int hopLimit,
462             float timeout) {
463         String cmd =
464                 "ping"
465                         + ((source == null) ? "" : (" -I " + source.getHostAddress()))
466                         + " "
467                         + address.getHostAddress()
468                         + " "
469                         + size
470                         + " "
471                         + count
472                         + " "
473                         + interval
474                         + " "
475                         + hopLimit
476                         + " "
477                         + timeout;
478         return executeCommand(cmd);
479     }
480 
getReceivedPacketsCount(List<String> stringList)481     private int getReceivedPacketsCount(List<String> stringList) {
482         Pattern pattern = Pattern.compile("([\\d]+) packets received");
483 
484         for (String message : stringList) {
485             Matcher matcher = pattern.matcher(message);
486             if (matcher.find()) {
487                 String packetCountStr = matcher.group(1);
488                 return Integer.parseInt(packetCountStr);
489             }
490         }
491         // No match found
492         return -1;
493     }
494 
495     @FormatMethod
executeCommand(String commandFormat, Object... args)496     private List<String> executeCommand(String commandFormat, Object... args) {
497         return executeCommand(String.format(commandFormat, args));
498     }
499 
executeCommand(String command)500     private List<String> executeCommand(String command) {
501         try {
502             mWriter.write(command + "\n");
503             mWriter.flush();
504         } catch (IOException e) {
505             throw new IllegalStateException(
506                     "Failed to write the command " + command + " to ot-cli-ftd", e);
507         }
508         try {
509             return readUntilDone();
510         } catch (IOException e) {
511             throw new IllegalStateException(
512                     "Failed to read the ot-cli-ftd output of command: " + command, e);
513         }
514     }
515 
readLine()516     private String readLine() throws IOException {
517         final CompletableFuture<String> future = new CompletableFuture<>();
518         mReaderHandler.post(
519                 () -> {
520                     try {
521                         future.complete(mReader.readLine());
522                     } catch (IOException e) {
523                         future.completeExceptionally(e);
524                     }
525                 });
526         try {
527             return future.get(READ_LINE_TIMEOUT_SECONDS, SECONDS);
528         } catch (InterruptedException | ExecutionException | TimeoutException e) {
529             throw new IOException("Failed to read a line from ot-cli-ftd");
530         }
531     }
532 
readUntilDone()533     private List<String> readUntilDone() throws IOException {
534         ArrayList<String> result = new ArrayList<>();
535         String line;
536         while ((line = readLine()) != null) {
537             if (line.equals("Done")) {
538                 break;
539             }
540             if (line.startsWith("Error")) {
541                 throw new IOException("ot-cli-ftd reported an error: " + line);
542             }
543             if (!line.startsWith("> ")) {
544                 result.add(line);
545             }
546         }
547         return result;
548     }
549 
txtMapToHexString(Map<String, byte[]> txtMap)550     private static String txtMapToHexString(Map<String, byte[]> txtMap) {
551         if (txtMap == null) {
552             return "";
553         }
554         StringBuilder sb = new StringBuilder();
555         for (Map.Entry<String, byte[]> entry : txtMap.entrySet()) {
556             int length = entry.getKey().length() + entry.getValue().length + 1;
557             sb.append(String.format("%02x", length));
558             sb.append(toHexString(entry.getKey()));
559             sb.append(toHexString("="));
560             sb.append(toHexString(entry.getValue()));
561         }
562         return sb.toString();
563     }
564 
toHexString(String s)565     private static String toHexString(String s) {
566         return toHexString(s.getBytes(StandardCharsets.UTF_8));
567     }
568 
toHexString(byte[] bytes)569     private static String toHexString(byte[] bytes) {
570         return base16().encode(bytes);
571     }
572 
573     private static final class DnsServiceCliOutputParser {
574         /** Returns the first match in the input of a given regex pattern. */
firstMatchOf(String input, String regex)575         private static Matcher firstMatchOf(String input, String regex) {
576             Matcher matcher = Pattern.compile(regex).matcher(input);
577             matcher.find();
578             return matcher;
579         }
580 
581         // Example: "Port:12345"
parsePort(String line)582         private static int parsePort(String line) {
583             return Integer.parseInt(firstMatchOf(line, "Port:(\\d+)").group(1));
584         }
585 
586         // Example: "Host:Android.default.service.arpa."
parseHostname(String line)587         private static String parseHostname(String line) {
588             return firstMatchOf(line, "Host:(.+)").group(1);
589         }
590 
591         // Example: "HostAddress:2001:0:0:0:0:0:0:1"
parseHostAddress(String line)592         private static InetAddress parseHostAddress(String line) {
593             return InetAddresses.parseNumericAddress(
594                     firstMatchOf(line, "HostAddress:([^ ]+)").group(1));
595         }
596 
597         // Example: "TXT:[key1=0102, key2=03]"
parseTxtIntoServiceInfo(String line, NsdServiceInfo serviceInfo)598         private static void parseTxtIntoServiceInfo(String line, NsdServiceInfo serviceInfo) {
599             String txtString = firstMatchOf(line, "TXT:\\[([^\\]]+)\\]").group(1);
600             for (String txtEntry : txtString.split(",")) {
601                 String[] nameAndValue = txtEntry.trim().split("=");
602                 String name = nameAndValue[0];
603                 String value = nameAndValue[1];
604                 byte[] bytes = new byte[value.length() / 2];
605                 for (int i = 0; i < value.length(); i += 2) {
606                     byte b = (byte) ((value.charAt(i) - '0') << 4 | (value.charAt(i + 1) - '0'));
607                     bytes[i / 2] = b;
608                 }
609                 serviceInfo.setAttribute(name, bytes);
610             }
611         }
612     }
613 }
614