001 /** 002 * Licensed to the Apache Software Foundation (ASF) under one 003 * or more contributor license agreements. See the NOTICE file 004 * distributed with this work for additional information 005 * regarding copyright ownership. The ASF licenses this file 006 * to you under the Apache License, Version 2.0 (the 007 * "License"); you may not use this file except in compliance 008 * with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018 package org.apache.hadoop.security; 019 020 import java.io.BufferedReader; 021 import java.io.File; 022 import java.io.FileInputStream; 023 import java.io.IOException; 024 import java.io.InputStreamReader; 025 import java.util.HashMap; 026 import java.util.Map; 027 import java.util.regex.Matcher; 028 import java.util.regex.Pattern; 029 030 import org.apache.commons.logging.Log; 031 import org.apache.commons.logging.LogFactory; 032 import org.apache.hadoop.conf.Configuration; 033 import org.apache.hadoop.util.Time; 034 035 import com.google.common.annotations.VisibleForTesting; 036 import com.google.common.collect.BiMap; 037 import com.google.common.collect.HashBiMap; 038 039 /** 040 * A simple shell-based implementation of {@link IdMappingServiceProvider} 041 * Map id to user name or group name. It does update every 15 minutes. Only a 042 * single instance of this class is expected to be on the server. 043 */ 044 public class ShellBasedIdMapping implements IdMappingServiceProvider { 045 046 private static final Log LOG = 047 LogFactory.getLog(ShellBasedIdMapping.class); 048 049 private final static String OS = System.getProperty("os.name"); 050 051 /** Shell commands to get users and groups */ 052 static final String GET_ALL_USERS_CMD = "getent passwd | cut -d: -f1,3"; 053 static final String GET_ALL_GROUPS_CMD = "getent group | cut -d: -f1,3"; 054 static final String MAC_GET_ALL_USERS_CMD = "dscl . -list /Users UniqueID"; 055 static final String MAC_GET_ALL_GROUPS_CMD = "dscl . -list /Groups PrimaryGroupID"; 056 057 private final File staticMappingFile; 058 059 // Used for parsing the static mapping file. 060 private static final Pattern EMPTY_LINE = Pattern.compile("^\\s*$"); 061 private static final Pattern COMMENT_LINE = Pattern.compile("^\\s*#.*$"); 062 private static final Pattern MAPPING_LINE = 063 Pattern.compile("^(uid|gid)\\s+(\\d+)\\s+(\\d+)\\s*(#.*)?$"); 064 065 final private long timeout; 066 067 // Maps for id to name map. Guarded by this object monitor lock 068 private BiMap<Integer, String> uidNameMap = HashBiMap.create(); 069 private BiMap<Integer, String> gidNameMap = HashBiMap.create(); 070 071 private long lastUpdateTime = 0; // Last time maps were updated 072 073 public ShellBasedIdMapping(Configuration conf, 074 final String defaultStaticIdMappingFile) throws IOException { 075 long updateTime = conf.getLong( 076 IdMappingConstant.USERGROUPID_UPDATE_MILLIS_KEY, 077 IdMappingConstant.USERGROUPID_UPDATE_MILLIS_DEFAULT); 078 // Minimal interval is 1 minute 079 if (updateTime < IdMappingConstant.USERGROUPID_UPDATE_MILLIS_MIN) { 080 LOG.info("User configured user account update time is less" 081 + " than 1 minute. Use 1 minute instead."); 082 timeout = IdMappingConstant.USERGROUPID_UPDATE_MILLIS_MIN; 083 } else { 084 timeout = updateTime; 085 } 086 087 String staticFilePath = conf.get(IdMappingConstant.STATIC_ID_MAPPING_FILE_KEY, 088 defaultStaticIdMappingFile); 089 staticMappingFile = new File(staticFilePath); 090 091 updateMaps(); 092 } 093 094 public ShellBasedIdMapping(Configuration conf) throws IOException { 095 this(conf, IdMappingConstant.STATIC_ID_MAPPING_FILE_DEFAULT); 096 } 097 098 @VisibleForTesting 099 public long getTimeout() { 100 return timeout; 101 } 102 103 synchronized private boolean isExpired() { 104 return Time.monotonicNow() - lastUpdateTime > timeout; 105 } 106 107 // If can't update the maps, will keep using the old ones 108 private void checkAndUpdateMaps() { 109 if (isExpired()) { 110 LOG.info("Update cache now"); 111 try { 112 updateMaps(); 113 } catch (IOException e) { 114 LOG.error("Can't update the maps. Will use the old ones," 115 + " which can potentially cause problem.", e); 116 } 117 } 118 } 119 120 private static final String DUPLICATE_NAME_ID_DEBUG_INFO = 121 "NFS gateway could have problem starting with duplicate name or id on the host system.\n" 122 + "This is because HDFS (non-kerberos cluster) uses name as the only way to identify a user or group.\n" 123 + "The host system with duplicated user/group name or id might work fine most of the time by itself.\n" 124 + "However when NFS gateway talks to HDFS, HDFS accepts only user and group name.\n" 125 + "Therefore, same name means the same user or same group. To find the duplicated names/ids, one can do:\n" 126 + "<getent passwd | cut -d: -f1,3> and <getent group | cut -d: -f1,3> on Linux systems,\n" 127 + "<dscl . -list /Users UniqueID> and <dscl . -list /Groups PrimaryGroupID> on MacOS."; 128 129 private static void reportDuplicateEntry(final String header, 130 final Integer key, final String value, 131 final Integer ekey, final String evalue) { 132 LOG.warn("\n" + header + String.format( 133 "new entry (%d, %s), existing entry: (%d, %s).\n%s\n%s", 134 key, value, ekey, evalue, 135 "The new entry is to be ignored for the following reason.", 136 DUPLICATE_NAME_ID_DEBUG_INFO)); 137 } 138 139 /** 140 * uid and gid are defined as uint32 in linux. Some systems create 141 * (intended or unintended) <nfsnobody, 4294967294> kind of <name,Id> 142 * mapping, where 4294967294 is 2**32-2 as unsigned int32. As an example, 143 * https://bugzilla.redhat.com/show_bug.cgi?id=511876. 144 * Because user or group id are treated as Integer (signed integer or int32) 145 * here, the number 4294967294 is out of range. The solution is to convert 146 * uint32 to int32, so to map the out-of-range ID to the negative side of 147 * Integer, e.g. 4294967294 maps to -2 and 4294967295 maps to -1. 148 */ 149 private static Integer parseId(final String idStr) { 150 Long longVal = Long.parseLong(idStr); 151 int intVal = longVal.intValue(); 152 return Integer.valueOf(intVal); 153 } 154 155 /** 156 * Get the whole list of users and groups and save them in the maps. 157 * @throws IOException 158 */ 159 @VisibleForTesting 160 public static void updateMapInternal(BiMap<Integer, String> map, String mapName, 161 String command, String regex, Map<Integer, Integer> staticMapping) 162 throws IOException { 163 BufferedReader br = null; 164 try { 165 Process process = Runtime.getRuntime().exec( 166 new String[] { "bash", "-c", command }); 167 br = new BufferedReader(new InputStreamReader(process.getInputStream())); 168 String line = null; 169 while ((line = br.readLine()) != null) { 170 String[] nameId = line.split(regex); 171 if ((nameId == null) || (nameId.length != 2)) { 172 throw new IOException("Can't parse " + mapName + " list entry:" + line); 173 } 174 LOG.debug("add to " + mapName + "map:" + nameId[0] + " id:" + nameId[1]); 175 // HDFS can't differentiate duplicate names with simple authentication 176 final Integer key = staticMapping.get(parseId(nameId[1])); 177 final String value = nameId[0]; 178 if (map.containsKey(key)) { 179 final String prevValue = map.get(key); 180 if (value.equals(prevValue)) { 181 // silently ignore equivalent entries 182 continue; 183 } 184 reportDuplicateEntry( 185 "Got multiple names associated with the same id: ", 186 key, value, key, prevValue); 187 continue; 188 } 189 if (map.containsValue(value)) { 190 final Integer prevKey = map.inverse().get(value); 191 reportDuplicateEntry( 192 "Got multiple ids associated with the same name: ", 193 key, value, prevKey, value); 194 continue; 195 } 196 map.put(key, value); 197 } 198 LOG.info("Updated " + mapName + " map size: " + map.size()); 199 200 } catch (IOException e) { 201 LOG.error("Can't update " + mapName + " map"); 202 throw e; 203 } finally { 204 if (br != null) { 205 try { 206 br.close(); 207 } catch (IOException e1) { 208 LOG.error("Can't close BufferedReader of command result", e1); 209 } 210 } 211 } 212 } 213 214 synchronized public void updateMaps() throws IOException { 215 BiMap<Integer, String> uMap = HashBiMap.create(); 216 BiMap<Integer, String> gMap = HashBiMap.create(); 217 218 if (!OS.startsWith("Linux") && !OS.startsWith("Mac")) { 219 LOG.error("Platform is not supported:" + OS 220 + ". Can't update user map and group map and" 221 + " 'nobody' will be used for any user and group."); 222 return; 223 } 224 225 StaticMapping staticMapping = new StaticMapping( 226 new HashMap<Integer, Integer>(), new HashMap<Integer, Integer>()); 227 if (staticMappingFile.exists()) { 228 LOG.info("Using '" + staticMappingFile + "' for static UID/GID mapping..."); 229 staticMapping = parseStaticMap(staticMappingFile); 230 } else { 231 LOG.info("Not doing static UID/GID mapping because '" + staticMappingFile 232 + "' does not exist."); 233 } 234 235 if (OS.startsWith("Mac")) { 236 updateMapInternal(uMap, "user", MAC_GET_ALL_USERS_CMD, "\\s+", 237 staticMapping.uidMapping); 238 updateMapInternal(gMap, "group", MAC_GET_ALL_GROUPS_CMD, "\\s+", 239 staticMapping.gidMapping); 240 } else { 241 updateMapInternal(uMap, "user", GET_ALL_USERS_CMD, ":", 242 staticMapping.uidMapping); 243 updateMapInternal(gMap, "group", GET_ALL_GROUPS_CMD, ":", 244 staticMapping.gidMapping); 245 } 246 247 uidNameMap = uMap; 248 gidNameMap = gMap; 249 lastUpdateTime = Time.monotonicNow(); 250 } 251 252 @SuppressWarnings("serial") 253 static final class PassThroughMap<K> extends HashMap<K, K> { 254 255 public PassThroughMap() { 256 this(new HashMap<K, K>()); 257 } 258 259 public PassThroughMap(Map<K, K> mapping) { 260 super(); 261 for (Map.Entry<K, K> entry : mapping.entrySet()) { 262 super.put(entry.getKey(), entry.getValue()); 263 } 264 } 265 266 @SuppressWarnings("unchecked") 267 @Override 268 public K get(Object key) { 269 if (super.containsKey(key)) { 270 return super.get(key); 271 } else { 272 return (K) key; 273 } 274 } 275 } 276 277 @VisibleForTesting 278 static final class StaticMapping { 279 final Map<Integer, Integer> uidMapping; 280 final Map<Integer, Integer> gidMapping; 281 282 public StaticMapping(Map<Integer, Integer> uidMapping, 283 Map<Integer, Integer> gidMapping) { 284 this.uidMapping = new PassThroughMap<Integer>(uidMapping); 285 this.gidMapping = new PassThroughMap<Integer>(gidMapping); 286 } 287 } 288 289 static StaticMapping parseStaticMap(File staticMapFile) 290 throws IOException { 291 292 Map<Integer, Integer> uidMapping = new HashMap<Integer, Integer>(); 293 Map<Integer, Integer> gidMapping = new HashMap<Integer, Integer>(); 294 295 BufferedReader in = new BufferedReader(new InputStreamReader( 296 new FileInputStream(staticMapFile))); 297 298 try { 299 String line = null; 300 while ((line = in.readLine()) != null) { 301 // Skip entirely empty and comment lines. 302 if (EMPTY_LINE.matcher(line).matches() || 303 COMMENT_LINE.matcher(line).matches()) { 304 continue; 305 } 306 307 Matcher lineMatcher = MAPPING_LINE.matcher(line); 308 if (!lineMatcher.matches()) { 309 LOG.warn("Could not parse line '" + line + "'. Lines should be of " + 310 "the form '[uid|gid] [remote id] [local id]'. Blank lines and " + 311 "everything following a '#' on a line will be ignored."); 312 continue; 313 } 314 315 // We know the line is fine to parse without error checking like this 316 // since it matched the regex above. 317 String firstComponent = lineMatcher.group(1); 318 int remoteId = Integer.parseInt(lineMatcher.group(2)); 319 int localId = Integer.parseInt(lineMatcher.group(3)); 320 if (firstComponent.equals("uid")) { 321 uidMapping.put(localId, remoteId); 322 } else { 323 gidMapping.put(localId, remoteId); 324 } 325 } 326 } finally { 327 in.close(); 328 } 329 330 return new StaticMapping(uidMapping, gidMapping); 331 } 332 333 synchronized public int getUid(String user) throws IOException { 334 checkAndUpdateMaps(); 335 336 Integer id = uidNameMap.inverse().get(user); 337 if (id == null) { 338 throw new IOException("User just deleted?:" + user); 339 } 340 return id.intValue(); 341 } 342 343 synchronized public int getGid(String group) throws IOException { 344 checkAndUpdateMaps(); 345 346 Integer id = gidNameMap.inverse().get(group); 347 if (id == null) { 348 throw new IOException("No such group:" + group); 349 350 } 351 return id.intValue(); 352 } 353 354 synchronized public String getUserName(int uid, String unknown) { 355 checkAndUpdateMaps(); 356 String uname = uidNameMap.get(uid); 357 if (uname == null) { 358 LOG.warn("Can't find user name for uid " + uid 359 + ". Use default user name " + unknown); 360 uname = unknown; 361 } 362 return uname; 363 } 364 365 synchronized public String getGroupName(int gid, String unknown) { 366 checkAndUpdateMaps(); 367 String gname = gidNameMap.get(gid); 368 if (gname == null) { 369 LOG.warn("Can't find group name for gid " + gid 370 + ". Use default group name " + unknown); 371 gname = unknown; 372 } 373 return gname; 374 } 375 376 // When can't map user, return user name's string hashcode 377 public int getUidAllowingUnknown(String user) { 378 checkAndUpdateMaps(); 379 int uid; 380 try { 381 uid = getUid(user); 382 } catch (IOException e) { 383 uid = user.hashCode(); 384 LOG.info("Can't map user " + user + ". Use its string hashcode:" + uid); 385 } 386 return uid; 387 } 388 389 // When can't map group, return group name's string hashcode 390 public int getGidAllowingUnknown(String group) { 391 checkAndUpdateMaps(); 392 int gid; 393 try { 394 gid = getGid(group); 395 } catch (IOException e) { 396 gid = group.hashCode(); 397 LOG.info("Can't map group " + group + ". Use its string hashcode:" + gid); 398 } 399 return gid; 400 } 401 }