001 /** 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with this 004 * work for additional information regarding copyright ownership. The ASF 005 * licenses this file to you under the Apache License, Version 2.0 (the 006 * "License"); you may not use this file except in compliance with the License. 007 * You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 013 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 014 * License for the specific language governing permissions and limitations under 015 * the License. 016 */ 017 package org.apache.hadoop.security; 018 019 import static org.apache.hadoop.fs.CommonConfigurationKeysPublic.HADOOP_SECURITY_AUTHENTICATION; 020 021 import java.io.IOException; 022 import java.net.InetAddress; 023 import java.net.InetSocketAddress; 024 import java.net.URI; 025 import java.net.URL; 026 import java.net.URLConnection; 027 import java.net.UnknownHostException; 028 import java.security.AccessController; 029 import java.security.PrivilegedAction; 030 import java.security.PrivilegedExceptionAction; 031 import java.util.Arrays; 032 import java.util.List; 033 import java.util.Locale; 034 import java.util.ServiceLoader; 035 import java.util.Set; 036 037 import javax.security.auth.Subject; 038 import javax.security.auth.kerberos.KerberosPrincipal; 039 import javax.security.auth.kerberos.KerberosTicket; 040 041 import org.apache.commons.logging.Log; 042 import org.apache.commons.logging.LogFactory; 043 import org.apache.hadoop.classification.InterfaceAudience; 044 import org.apache.hadoop.classification.InterfaceStability; 045 import org.apache.hadoop.conf.Configuration; 046 import org.apache.hadoop.fs.CommonConfigurationKeys; 047 import org.apache.hadoop.http.HttpConfig; 048 import org.apache.hadoop.io.Text; 049 import org.apache.hadoop.net.NetUtils; 050 import org.apache.hadoop.security.UserGroupInformation.AuthenticationMethod; 051 import org.apache.hadoop.security.authentication.client.AuthenticatedURL; 052 import org.apache.hadoop.security.authentication.client.AuthenticationException; 053 import org.apache.hadoop.security.ssl.SSLFactory; 054 import org.apache.hadoop.security.token.Token; 055 import org.apache.hadoop.security.token.TokenInfo; 056 057 import com.google.common.annotations.VisibleForTesting; 058 059 //this will need to be replaced someday when there is a suitable replacement 060 import sun.net.dns.ResolverConfiguration; 061 import sun.net.util.IPAddressUtil; 062 063 @InterfaceAudience.LimitedPrivate({"HDFS", "MapReduce"}) 064 @InterfaceStability.Evolving 065 public class SecurityUtil { 066 public static final Log LOG = LogFactory.getLog(SecurityUtil.class); 067 public static final String HOSTNAME_PATTERN = "_HOST"; 068 069 // controls whether buildTokenService will use an ip or host/ip as given 070 // by the user 071 @VisibleForTesting 072 static boolean useIpForTokenService; 073 @VisibleForTesting 074 static HostResolver hostResolver; 075 076 private static SSLFactory sslFactory; 077 078 static { 079 Configuration conf = new Configuration(); 080 boolean useIp = conf.getBoolean( 081 CommonConfigurationKeys.HADOOP_SECURITY_TOKEN_SERVICE_USE_IP, 082 CommonConfigurationKeys.HADOOP_SECURITY_TOKEN_SERVICE_USE_IP_DEFAULT); 083 setTokenServiceUseIp(useIp); 084 if (HttpConfig.isSecure()) { 085 sslFactory = new SSLFactory(SSLFactory.Mode.CLIENT, conf); 086 try { 087 sslFactory.init(); 088 } catch (Exception ex) { 089 throw new RuntimeException(ex); 090 } 091 } 092 } 093 094 /** 095 * For use only by tests and initialization 096 */ 097 @InterfaceAudience.Private 098 static void setTokenServiceUseIp(boolean flag) { 099 useIpForTokenService = flag; 100 hostResolver = !useIpForTokenService 101 ? new QualifiedHostResolver() 102 : new StandardHostResolver(); 103 } 104 105 /** 106 * Find the original TGT within the current subject's credentials. Cross-realm 107 * TGT's of the form "krbtgt/[email protected]" may be present. 108 * 109 * @return The TGT from the current subject 110 * @throws IOException 111 * if TGT can't be found 112 */ 113 private static KerberosTicket getTgtFromSubject() throws IOException { 114 Subject current = Subject.getSubject(AccessController.getContext()); 115 if (current == null) { 116 throw new IOException( 117 "Can't get TGT from current Subject, because it is null"); 118 } 119 Set<KerberosTicket> tickets = current 120 .getPrivateCredentials(KerberosTicket.class); 121 for (KerberosTicket t : tickets) { 122 if (isOriginalTGT(t)) 123 return t; 124 } 125 throw new IOException("Failed to find TGT from current Subject:"+current); 126 } 127 128 /** 129 * TGS must have the server principal of the form "krbtgt/FOO@FOO". 130 * @param principal 131 * @return true or false 132 */ 133 static boolean 134 isTGSPrincipal(KerberosPrincipal principal) { 135 if (principal == null) 136 return false; 137 if (principal.getName().equals("krbtgt/" + principal.getRealm() + 138 "@" + principal.getRealm())) { 139 return true; 140 } 141 return false; 142 } 143 144 /** 145 * Check whether the server principal is the TGS's principal 146 * @param ticket the original TGT (the ticket that is obtained when a 147 * kinit is done) 148 * @return true or false 149 */ 150 protected static boolean isOriginalTGT(KerberosTicket ticket) { 151 return isTGSPrincipal(ticket.getServer()); 152 } 153 154 /** 155 * Convert Kerberos principal name pattern to valid Kerberos principal 156 * names. It replaces hostname pattern with hostname, which should be 157 * fully-qualified domain name. If hostname is null or "0.0.0.0", it uses 158 * dynamically looked-up fqdn of the current host instead. 159 * 160 * @param principalConfig 161 * the Kerberos principal name conf value to convert 162 * @param hostname 163 * the fully-qualified domain name used for substitution 164 * @return converted Kerberos principal name 165 * @throws IOException if the client address cannot be determined 166 */ 167 @InterfaceAudience.Public 168 @InterfaceStability.Evolving 169 public static String getServerPrincipal(String principalConfig, 170 String hostname) throws IOException { 171 String[] components = getComponents(principalConfig); 172 if (components == null || components.length != 3 173 || !components[1].equals(HOSTNAME_PATTERN)) { 174 return principalConfig; 175 } else { 176 return replacePattern(components, hostname); 177 } 178 } 179 180 /** 181 * Convert Kerberos principal name pattern to valid Kerberos principal names. 182 * This method is similar to {@link #getServerPrincipal(String, String)}, 183 * except 1) the reverse DNS lookup from addr to hostname is done only when 184 * necessary, 2) param addr can't be null (no default behavior of using local 185 * hostname when addr is null). 186 * 187 * @param principalConfig 188 * Kerberos principal name pattern to convert 189 * @param addr 190 * InetAddress of the host used for substitution 191 * @return converted Kerberos principal name 192 * @throws IOException if the client address cannot be determined 193 */ 194 @InterfaceAudience.Public 195 @InterfaceStability.Evolving 196 public static String getServerPrincipal(String principalConfig, 197 InetAddress addr) throws IOException { 198 String[] components = getComponents(principalConfig); 199 if (components == null || components.length != 3 200 || !components[1].equals(HOSTNAME_PATTERN)) { 201 return principalConfig; 202 } else { 203 if (addr == null) { 204 throw new IOException("Can't replace " + HOSTNAME_PATTERN 205 + " pattern since client address is null"); 206 } 207 return replacePattern(components, addr.getCanonicalHostName()); 208 } 209 } 210 211 private static String[] getComponents(String principalConfig) { 212 if (principalConfig == null) 213 return null; 214 return principalConfig.split("[/@]"); 215 } 216 217 private static String replacePattern(String[] components, String hostname) 218 throws IOException { 219 String fqdn = hostname; 220 if (fqdn == null || fqdn.equals("") || fqdn.equals("0.0.0.0")) { 221 fqdn = getLocalHostName(); 222 } 223 return components[0] + "/" + fqdn.toLowerCase(Locale.US) + "@" + components[2]; 224 } 225 226 static String getLocalHostName() throws UnknownHostException { 227 return InetAddress.getLocalHost().getCanonicalHostName(); 228 } 229 230 /** 231 * Login as a principal specified in config. Substitute $host in 232 * user's Kerberos principal name with a dynamically looked-up fully-qualified 233 * domain name of the current host. 234 * 235 * @param conf 236 * conf to use 237 * @param keytabFileKey 238 * the key to look for keytab file in conf 239 * @param userNameKey 240 * the key to look for user's Kerberos principal name in conf 241 * @throws IOException if login fails 242 */ 243 @InterfaceAudience.Public 244 @InterfaceStability.Evolving 245 public static void login(final Configuration conf, 246 final String keytabFileKey, final String userNameKey) throws IOException { 247 login(conf, keytabFileKey, userNameKey, getLocalHostName()); 248 } 249 250 /** 251 * Login as a principal specified in config. Substitute $host in user's Kerberos principal 252 * name with hostname. If non-secure mode - return. If no keytab available - 253 * bail out with an exception 254 * 255 * @param conf 256 * conf to use 257 * @param keytabFileKey 258 * the key to look for keytab file in conf 259 * @param userNameKey 260 * the key to look for user's Kerberos principal name in conf 261 * @param hostname 262 * hostname to use for substitution 263 * @throws IOException if the config doesn't specify a keytab 264 */ 265 @InterfaceAudience.Public 266 @InterfaceStability.Evolving 267 public static void login(final Configuration conf, 268 final String keytabFileKey, final String userNameKey, String hostname) 269 throws IOException { 270 271 if(! UserGroupInformation.isSecurityEnabled()) 272 return; 273 274 String keytabFilename = conf.get(keytabFileKey); 275 if (keytabFilename == null || keytabFilename.length() == 0) { 276 throw new IOException("Running in secure mode, but config doesn't have a keytab"); 277 } 278 279 String principalConfig = conf.get(userNameKey, System 280 .getProperty("user.name")); 281 String principalName = SecurityUtil.getServerPrincipal(principalConfig, 282 hostname); 283 UserGroupInformation.loginUserFromKeytab(principalName, keytabFilename); 284 } 285 286 /** 287 * create the service name for a Delegation token 288 * @param uri of the service 289 * @param defPort is used if the uri lacks a port 290 * @return the token service, or null if no authority 291 * @see #buildTokenService(InetSocketAddress) 292 */ 293 public static String buildDTServiceName(URI uri, int defPort) { 294 String authority = uri.getAuthority(); 295 if (authority == null) { 296 return null; 297 } 298 InetSocketAddress addr = NetUtils.createSocketAddr(authority, defPort); 299 return buildTokenService(addr).toString(); 300 } 301 302 /** 303 * Get the host name from the principal name of format <service>/host@realm. 304 * @param principalName principal name of format as described above 305 * @return host name if the the string conforms to the above format, else null 306 */ 307 public static String getHostFromPrincipal(String principalName) { 308 return new HadoopKerberosName(principalName).getHostName(); 309 } 310 311 private static ServiceLoader<SecurityInfo> securityInfoProviders = 312 ServiceLoader.load(SecurityInfo.class); 313 private static SecurityInfo[] testProviders = new SecurityInfo[0]; 314 315 /** 316 * Test setup method to register additional providers. 317 * @param providers a list of high priority providers to use 318 */ 319 @InterfaceAudience.Private 320 public static void setSecurityInfoProviders(SecurityInfo... providers) { 321 testProviders = providers; 322 } 323 324 /** 325 * Look up the KerberosInfo for a given protocol. It searches all known 326 * SecurityInfo providers. 327 * @param protocol the protocol class to get the information for 328 * @param conf configuration object 329 * @return the KerberosInfo or null if it has no KerberosInfo defined 330 */ 331 public static KerberosInfo 332 getKerberosInfo(Class<?> protocol, Configuration conf) { 333 synchronized (testProviders) { 334 for(SecurityInfo provider: testProviders) { 335 KerberosInfo result = provider.getKerberosInfo(protocol, conf); 336 if (result != null) { 337 return result; 338 } 339 } 340 } 341 342 synchronized (securityInfoProviders) { 343 for(SecurityInfo provider: securityInfoProviders) { 344 KerberosInfo result = provider.getKerberosInfo(protocol, conf); 345 if (result != null) { 346 return result; 347 } 348 } 349 } 350 return null; 351 } 352 353 /** 354 * Look up the TokenInfo for a given protocol. It searches all known 355 * SecurityInfo providers. 356 * @param protocol The protocol class to get the information for. 357 * @param conf Configuration object 358 * @return the TokenInfo or null if it has no KerberosInfo defined 359 */ 360 public static TokenInfo getTokenInfo(Class<?> protocol, Configuration conf) { 361 synchronized (testProviders) { 362 for(SecurityInfo provider: testProviders) { 363 TokenInfo result = provider.getTokenInfo(protocol, conf); 364 if (result != null) { 365 return result; 366 } 367 } 368 } 369 370 synchronized (securityInfoProviders) { 371 for(SecurityInfo provider: securityInfoProviders) { 372 TokenInfo result = provider.getTokenInfo(protocol, conf); 373 if (result != null) { 374 return result; 375 } 376 } 377 } 378 379 return null; 380 } 381 382 /** 383 * Decode the given token's service field into an InetAddress 384 * @param token from which to obtain the service 385 * @return InetAddress for the service 386 */ 387 public static InetSocketAddress getTokenServiceAddr(Token<?> token) { 388 return NetUtils.createSocketAddr(token.getService().toString()); 389 } 390 391 /** 392 * Set the given token's service to the format expected by the RPC client 393 * @param token a delegation token 394 * @param addr the socket for the rpc connection 395 */ 396 public static void setTokenService(Token<?> token, InetSocketAddress addr) { 397 Text service = buildTokenService(addr); 398 if (token != null) { 399 token.setService(service); 400 if (LOG.isDebugEnabled()) { 401 LOG.debug("Acquired token "+token); // Token#toString() prints service 402 } 403 } else { 404 LOG.warn("Failed to get token for service "+service); 405 } 406 } 407 408 /** 409 * Construct the service key for a token 410 * @param addr InetSocketAddress of remote connection with a token 411 * @return "ip:port" or "host:port" depending on the value of 412 * hadoop.security.token.service.use_ip 413 */ 414 public static Text buildTokenService(InetSocketAddress addr) { 415 String host = null; 416 if (useIpForTokenService) { 417 if (addr.isUnresolved()) { // host has no ip address 418 throw new IllegalArgumentException( 419 new UnknownHostException(addr.getHostName()) 420 ); 421 } 422 host = addr.getAddress().getHostAddress(); 423 } else { 424 host = addr.getHostName().toLowerCase(); 425 } 426 return new Text(host + ":" + addr.getPort()); 427 } 428 429 /** 430 * Construct the service key for a token 431 * @param uri of remote connection with a token 432 * @return "ip:port" or "host:port" depending on the value of 433 * hadoop.security.token.service.use_ip 434 */ 435 public static Text buildTokenService(URI uri) { 436 return buildTokenService(NetUtils.createSocketAddr(uri.getAuthority())); 437 } 438 439 /** 440 * Perform the given action as the daemon's login user. If the login 441 * user cannot be determined, this will log a FATAL error and exit 442 * the whole JVM. 443 */ 444 public static <T> T doAsLoginUserOrFatal(PrivilegedAction<T> action) { 445 if (UserGroupInformation.isSecurityEnabled()) { 446 UserGroupInformation ugi = null; 447 try { 448 ugi = UserGroupInformation.getLoginUser(); 449 } catch (IOException e) { 450 LOG.fatal("Exception while getting login user", e); 451 e.printStackTrace(); 452 Runtime.getRuntime().exit(-1); 453 } 454 return ugi.doAs(action); 455 } else { 456 return action.run(); 457 } 458 } 459 460 /** 461 * Perform the given action as the daemon's login user. If an 462 * InterruptedException is thrown, it is converted to an IOException. 463 * 464 * @param action the action to perform 465 * @return the result of the action 466 * @throws IOException in the event of error 467 */ 468 public static <T> T doAsLoginUser(PrivilegedExceptionAction<T> action) 469 throws IOException { 470 return doAsUser(UserGroupInformation.getLoginUser(), action); 471 } 472 473 /** 474 * Perform the given action as the daemon's current user. If an 475 * InterruptedException is thrown, it is converted to an IOException. 476 * 477 * @param action the action to perform 478 * @return the result of the action 479 * @throws IOException in the event of error 480 */ 481 public static <T> T doAsCurrentUser(PrivilegedExceptionAction<T> action) 482 throws IOException { 483 return doAsUser(UserGroupInformation.getCurrentUser(), action); 484 } 485 486 private static <T> T doAsUser(UserGroupInformation ugi, 487 PrivilegedExceptionAction<T> action) throws IOException { 488 try { 489 return ugi.doAs(action); 490 } catch (InterruptedException ie) { 491 throw new IOException(ie); 492 } 493 } 494 495 /** 496 * Open a (if need be) secure connection to a URL in a secure environment 497 * that is using SPNEGO to authenticate its URLs. All Namenode and Secondary 498 * Namenode URLs that are protected via SPNEGO should be accessed via this 499 * method. 500 * 501 * @param url to authenticate via SPNEGO. 502 * @return A connection that has been authenticated via SPNEGO 503 * @throws IOException If unable to authenticate via SPNEGO 504 */ 505 public static URLConnection openSecureHttpConnection(URL url) throws IOException { 506 if (!HttpConfig.isSecure() && !UserGroupInformation.isSecurityEnabled()) { 507 return url.openConnection(); 508 } 509 510 AuthenticatedURL.Token token = new AuthenticatedURL.Token(); 511 try { 512 return new AuthenticatedURL(null, sslFactory).openConnection(url, token); 513 } catch (AuthenticationException e) { 514 throw new IOException("Exception trying to open authenticated connection to " 515 + url, e); 516 } 517 } 518 519 /** 520 * Resolves a host subject to the security requirements determined by 521 * hadoop.security.token.service.use_ip. 522 * 523 * @param hostname host or ip to resolve 524 * @return a resolved host 525 * @throws UnknownHostException if the host doesn't exist 526 */ 527 @InterfaceAudience.Private 528 public static 529 InetAddress getByName(String hostname) throws UnknownHostException { 530 return hostResolver.getByName(hostname); 531 } 532 533 interface HostResolver { 534 InetAddress getByName(String host) throws UnknownHostException; 535 } 536 537 /** 538 * Uses standard java host resolution 539 */ 540 static class StandardHostResolver implements HostResolver { 541 @Override 542 public InetAddress getByName(String host) throws UnknownHostException { 543 return InetAddress.getByName(host); 544 } 545 } 546 547 /** 548 * This an alternate resolver with important properties that the standard 549 * java resolver lacks: 550 * 1) The hostname is fully qualified. This avoids security issues if not 551 * all hosts in the cluster do not share the same search domains. It 552 * also prevents other hosts from performing unnecessary dns searches. 553 * In contrast, InetAddress simply returns the host as given. 554 * 2) The InetAddress is instantiated with an exact host and IP to prevent 555 * further unnecessary lookups. InetAddress may perform an unnecessary 556 * reverse lookup for an IP. 557 * 3) A call to getHostName() will always return the qualified hostname, or 558 * more importantly, the IP if instantiated with an IP. This avoids 559 * unnecessary dns timeouts if the host is not resolvable. 560 * 4) Point 3 also ensures that if the host is re-resolved, ex. during a 561 * connection re-attempt, that a reverse lookup to host and forward 562 * lookup to IP is not performed since the reverse/forward mappings may 563 * not always return the same IP. If the client initiated a connection 564 * with an IP, then that IP is all that should ever be contacted. 565 * 566 * NOTE: this resolver is only used if: 567 * hadoop.security.token.service.use_ip=false 568 */ 569 protected static class QualifiedHostResolver implements HostResolver { 570 @SuppressWarnings("unchecked") 571 private List<String> searchDomains = 572 ResolverConfiguration.open().searchlist(); 573 574 /** 575 * Create an InetAddress with a fully qualified hostname of the given 576 * hostname. InetAddress does not qualify an incomplete hostname that 577 * is resolved via the domain search list. 578 * {@link InetAddress#getCanonicalHostName()} will fully qualify the 579 * hostname, but it always return the A record whereas the given hostname 580 * may be a CNAME. 581 * 582 * @param host a hostname or ip address 583 * @return InetAddress with the fully qualified hostname or ip 584 * @throws UnknownHostException if host does not exist 585 */ 586 @Override 587 public InetAddress getByName(String host) throws UnknownHostException { 588 InetAddress addr = null; 589 590 if (IPAddressUtil.isIPv4LiteralAddress(host)) { 591 // use ipv4 address as-is 592 byte[] ip = IPAddressUtil.textToNumericFormatV4(host); 593 addr = InetAddress.getByAddress(host, ip); 594 } else if (IPAddressUtil.isIPv6LiteralAddress(host)) { 595 // use ipv6 address as-is 596 byte[] ip = IPAddressUtil.textToNumericFormatV6(host); 597 addr = InetAddress.getByAddress(host, ip); 598 } else if (host.endsWith(".")) { 599 // a rooted host ends with a dot, ex. "host." 600 // rooted hosts never use the search path, so only try an exact lookup 601 addr = getByExactName(host); 602 } else if (host.contains(".")) { 603 // the host contains a dot (domain), ex. "host.domain" 604 // try an exact host lookup, then fallback to search list 605 addr = getByExactName(host); 606 if (addr == null) { 607 addr = getByNameWithSearch(host); 608 } 609 } else { 610 // it's a simple host with no dots, ex. "host" 611 // try the search list, then fallback to exact host 612 InetAddress loopback = InetAddress.getByName(null); 613 if (host.equalsIgnoreCase(loopback.getHostName())) { 614 addr = InetAddress.getByAddress(host, loopback.getAddress()); 615 } else { 616 addr = getByNameWithSearch(host); 617 if (addr == null) { 618 addr = getByExactName(host); 619 } 620 } 621 } 622 // unresolvable! 623 if (addr == null) { 624 throw new UnknownHostException(host); 625 } 626 return addr; 627 } 628 629 InetAddress getByExactName(String host) { 630 InetAddress addr = null; 631 // InetAddress will use the search list unless the host is rooted 632 // with a trailing dot. The trailing dot will disable any use of the 633 // search path in a lower level resolver. See RFC 1535. 634 String fqHost = host; 635 if (!fqHost.endsWith(".")) fqHost += "."; 636 try { 637 addr = getInetAddressByName(fqHost); 638 // can't leave the hostname as rooted or other parts of the system 639 // malfunction, ex. kerberos principals are lacking proper host 640 // equivalence for rooted/non-rooted hostnames 641 addr = InetAddress.getByAddress(host, addr.getAddress()); 642 } catch (UnknownHostException e) { 643 // ignore, caller will throw if necessary 644 } 645 return addr; 646 } 647 648 InetAddress getByNameWithSearch(String host) { 649 InetAddress addr = null; 650 if (host.endsWith(".")) { // already qualified? 651 addr = getByExactName(host); 652 } else { 653 for (String domain : searchDomains) { 654 String dot = !domain.startsWith(".") ? "." : ""; 655 addr = getByExactName(host + dot + domain); 656 if (addr != null) break; 657 } 658 } 659 return addr; 660 } 661 662 // implemented as a separate method to facilitate unit testing 663 InetAddress getInetAddressByName(String host) throws UnknownHostException { 664 return InetAddress.getByName(host); 665 } 666 667 void setSearchDomains(String ... domains) { 668 searchDomains = Arrays.asList(domains); 669 } 670 } 671 672 public static AuthenticationMethod getAuthenticationMethod(Configuration conf) { 673 String value = conf.get(HADOOP_SECURITY_AUTHENTICATION, "simple"); 674 try { 675 return Enum.valueOf(AuthenticationMethod.class, value.toUpperCase()); 676 } catch (IllegalArgumentException iae) { 677 throw new IllegalArgumentException("Invalid attribute value for " + 678 HADOOP_SECURITY_AUTHENTICATION + " of " + value); 679 } 680 } 681 682 public static void setAuthenticationMethod( 683 AuthenticationMethod authenticationMethod, Configuration conf) { 684 if (authenticationMethod == null) { 685 authenticationMethod = AuthenticationMethod.SIMPLE; 686 } 687 conf.set(HADOOP_SECURITY_AUTHENTICATION, 688 authenticationMethod.toString().toLowerCase()); 689 } 690 }