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