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 */
018package org.apache.hadoop.security;
019
020import java.io.BufferedReader;
021import java.io.File;
022import java.io.FileInputStream;
023import java.io.IOException;
024import java.io.InputStreamReader;
025import java.util.HashMap;
026import java.util.Map;
027import java.util.regex.Matcher;
028import java.util.regex.Pattern;
029
030import org.apache.commons.logging.Log;
031import org.apache.commons.logging.LogFactory;
032import org.apache.hadoop.conf.Configuration;
033import org.apache.hadoop.util.Time;
034
035import com.google.common.annotations.VisibleForTesting;
036import com.google.common.collect.BiMap;
037import 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 */
044public 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}