X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/f61a39eb70046280bd4611ea2dad38a5602303dc..354335e323305190b957c59c4be26c1231c2d115:/services/login-sync/bin/arvados-login-sync?ds=sidebyside diff --git a/services/login-sync/bin/arvados-login-sync b/services/login-sync/bin/arvados-login-sync index 57487711d5..1a825c90f9 100755 --- a/services/login-sync/bin/arvados-login-sync +++ b/services/login-sync/bin/arvados-login-sync @@ -1,4 +1,7 @@ #!/usr/bin/env ruby +# Copyright (C) The Arvados Authors. All rights reserved. +# +# SPDX-License-Identifier: AGPL-3.0 require 'rubygems' require 'pp' @@ -6,6 +9,20 @@ require 'arvados' require 'etc' require 'fileutils' require 'yaml' +require 'optparse' +require 'open3' + +def ensure_dir(path, mode, owner, group) + begin + Dir.mkdir(path, mode) + rescue Errno::EEXIST + # No change needed + false + else + FileUtils.chown(owner, group, path) + true + end +end req_envs = %w(ARVADOS_API_HOST ARVADOS_API_TOKEN ARVADOS_VIRTUAL_MACHINE_UUID) req_envs.each do |k| @@ -14,31 +31,57 @@ req_envs.each do |k| end end -exclusive_mode = ARGV.index("--exclusive") +options = {} +OptionParser.new do |parser| + parser.on('--exclusive', 'Manage SSH keys file exclusively.') + parser.on('--rotate-tokens', 'Force a rotation of all user tokens.') + parser.on('--skip-missing-users', "Don't try to create any local accounts.") + parser.on('--token-lifetime SECONDS', 'Create user tokens that expire after SECONDS.', Integer) + parser.on('--debug', 'Enable debug output') +end.parse!(into: options) + exclusive_banner = "####################################################################################### # THIS FILE IS MANAGED BY #{$0} -- CHANGES WILL BE OVERWRITTEN # #######################################################################################\n\n" start_banner = "### BEGIN Arvados-managed keys -- changes between markers will be overwritten\n" end_banner = "### END Arvados-managed keys -- changes between markers will be overwritten\n" -# some LDAP systems have already the user there -# use this falg -dont_create_user = ARGV.index("--dont-create-user") +actions = { + # These names correspond to the names in the cluster Users configuration. + # Managing everything was the original behavior. + SyncUserAccounts: true, + SyncUserGroups: true, + SyncUserSSHKeys: true, + SyncUserAPITokens: true, +} keys = '' -seen = Hash.new - begin - uids = Hash[Etc.to_enum(:passwd).map { |ent| [ent.name, ent.uid] }] - gids = Hash[Etc.to_enum(:group).map { |ent| [ent.name, ent.gid] }] + debug = false + if options[:"debug"] + debug = true + end arv = Arvados.new({ :suppress_ssl_warnings => false }) + logincluster_host = ENV['ARVADOS_API_HOST'] + logincluster_name = arv.cluster_config['Login']['LoginCluster'] or '' + # Requiring the fuse group was previous hardcoded behavior + minimum_groups = arv.cluster_config['Login']['SyncRequiredGroups'] || ['fuse'] + actions.each_pair do |key, default| + actions[key] = arv.cluster_config['Login'].fetch(key.to_s, default) + end + + if logincluster_name != '' and logincluster_name != arv.cluster_config['ClusterID'] + logincluster_host = arv.cluster_config['RemoteClusters'][logincluster_name]['Host'] + end + logincluster_arv = Arvados.new({ :api_host => logincluster_host, + :suppress_ssl_warnings => false }) vm_uuid = ENV['ARVADOS_VIRTUAL_MACHINE_UUID'] logins = arv.virtual_machine.logins(:uuid => vm_uuid)[:items] logins = [] if logins.nil? - logins = logins.reject { |l| l[:username].nil? or l[:hostname].nil? or l[:public_key].nil? or l[:virtual_machine_uuid] != vm_uuid } + logins = logins.reject { |l| l[:username].nil? or l[:hostname].nil? or l[:virtual_machine_uuid] != vm_uuid } # No system users uid_min = 1000 @@ -56,92 +99,199 @@ begin uid_min = new_uid_min if (new_uid_min > 0) end end - logins.reject! { |l| (uids[l[:username]] || 65535) < uid_min } + pwnam = Hash.new() + logins.reject! do |l| + if not pwnam[l[:username]] + begin + pwnam[l[:username]] = Etc.getpwnam(l[:username]) + rescue + if options[:"skip-missing-users"] + STDERR.puts "Account #{l[:username]} not found. Skipping" + true + end + else + if pwnam[l[:username]].uid < uid_min + STDERR.puts "Account #{l[:username]} uid #{pwnam[l[:username]].uid} < uid_min #{uid_min}. Skipping" if debug + true + end + end + end + end keys = Hash.new() # Collect all keys logins.each do |l| + STDERR.puts("Considering #{l[:username]} ...") if debug keys[l[:username]] = Array.new() if not keys.has_key?(l[:username]) key = l[:public_key] - # Handle putty-style ssh public keys - key.sub!(/^(Comment: "r[^\n]*\n)(.*)$/m,'ssh-rsa \2 \1') - key.sub!(/^(Comment: "d[^\n]*\n)(.*)$/m,'ssh-dss \2 \1') - key.gsub!(/\n/,'') - key.strip + if !key.nil? + # Handle putty-style ssh public keys + key.sub!(/^(Comment: "r[^\n]*\n)(.*)$/m,'ssh-rsa \2 \1') + key.sub!(/^(Comment: "d[^\n]*\n)(.*)$/m,'ssh-dss \2 \1') + key.gsub!(/\n/,'') + key.strip - keys[l[:username]].push(key) if not keys[l[:username]].include?(key) + keys[l[:username]].push(key) if not keys[l[:username]].include?(key) + end end seen = Hash.new() - devnull = open("/dev/null", "w") + + all_groups = [] + current_user_groups = Hash.new { |hash, key| hash[key] = [] } + while (ent = Etc.getgrent()) do + all_groups << ent.name + ent.mem.each do |member| + current_user_groups[member] << ent.name + end + end + Etc.endgrent() logins.each do |l| next if seen[l[:username]] - seen[l[:username]] = true if not seen.has_key?(l[:username]) + seen[l[:username]] = true - unless uids[l[:username]] or dont_create_user + username = l[:username] + + unless pwnam[l[:username]] + unless actions[:SyncUserAccounts] + STDERR.puts "User #{username} does not exist and SyncUserAccounts=false. Skipping." + next + end STDERR.puts "Creating account #{l[:username]}" - groups = l[:groups] || [] - # Adding users to the FUSE group has long been hardcoded behavior. - groups << "fuse" - groups.select! { |name| gids[name] } # Create new user - next unless system("useradd", "-m", - "-c", l[:username], - "-s", "/bin/bash", - "-G", groups.join(","), - l[:username], - out: devnull) + out, st = Open3.capture2e("useradd", "-m", + "-c", username, + "-s", "/bin/bash", + username) + if st.exitstatus != 0 + STDERR.puts "Account creation failed for #{l[:username]}:\n#{out}" + next + end + begin + pwnam[username] = Etc.getpwnam(username) + rescue => e + STDERR.puts "Created account but then getpwnam() failed for #{l[:username]}: #{e}" + raise + end end - # If after all this effort isn't listed using Etc.getpwnam() - # this means that wont be available in the system - # some LDAP configurations will need this - begin - # Create .ssh directory if necessary - Etc.getpwnam(l[:username]) - rescue ArgumentError - STDERR.puts "Account #{l[:username]} not found. Skipping" + user_gid = pwnam[username].gid + homedir = pwnam[l[:username]].dir + if !File.exist?(homedir) + STDERR.puts "Cannot set up user #{username} because their home directory #{homedir} does not exist. Skipping." next end - - @homedir = Etc.getpwnam(l[:username]).dir - userdotssh = File.join(@homedir, ".ssh") - Dir.mkdir(userdotssh) if !File.exists?(userdotssh) - newkeys = "###\n###\n" + keys[l[:username]].join("\n") + "\n###\n###\n" + if actions[:SyncUserGroups] + have_groups = current_user_groups[username] + want_groups = l[:groups] || [] + want_groups |= minimum_groups + want_groups &= all_groups - keysfile = File.join(userdotssh, "authorized_keys") + (want_groups - have_groups).each do |addgroup| + # User should be in group, but isn't, so add them. + STDERR.puts "Add user #{username} to #{addgroup} group" + out, st = Open3.capture2e("usermod", "-aG", addgroup, username) + if st.exitstatus != 0 + STDERR.puts "Failed to add #{username} to #{addgroup} group:\n#{out}" + end + end - if File.exists?(keysfile) - oldkeys = IO::read(keysfile) - else - oldkeys = "" + (have_groups - want_groups).each do |removegroup| + # User is in a group, but shouldn't be, so remove them. + STDERR.puts "Remove user #{username} from #{removegroup} group" + out, st = Open3.capture2e("gpasswd", "-d", username, removegroup) + if st.exitstatus != 0 + STDERR.puts "Failed to remove user #{username} from #{removegroup} group:\n#{out}" + end + end end - if exclusive_mode - newkeys = exclusive_banner + newkeys - elsif oldkeys.start_with?(exclusive_banner) - newkeys = start_banner + newkeys + end_banner - elsif (m = /^(.*?\n|)#{start_banner}(.*?\n|)#{end_banner}(.*)/m.match(oldkeys)) - newkeys = m[1] + start_banner + newkeys + end_banner + m[3] - else - newkeys = start_banner + newkeys + end_banner + oldkeys + if actions[:SyncUserSSHKeys] + userdotssh = File.join(homedir, ".ssh") + ensure_dir(userdotssh, 0700, username, user_gid) + + newkeys = "###\n###\n" + keys[l[:username]].join("\n") + "\n###\n###\n" + + keysfile = File.join(userdotssh, "authorized_keys") + begin + oldkeys = File.read(keysfile) + rescue Errno::ENOENT + oldkeys = "" + end + + if options[:exclusive] + newkeys = exclusive_banner + newkeys + elsif oldkeys.start_with?(exclusive_banner) + newkeys = start_banner + newkeys + end_banner + elsif (m = /^(.*?\n|)#{start_banner}(.*?\n|)#{end_banner}(.*)/m.match(oldkeys)) + newkeys = m[1] + start_banner + newkeys + end_banner + m[3] + else + newkeys = start_banner + newkeys + end_banner + oldkeys + end + + if oldkeys != newkeys then + File.open(keysfile, 'w', 0600) do |f| + f.write(newkeys) + end + FileUtils.chown(username, user_gid, keysfile) + end end - if oldkeys != newkeys then - f = File.new(keysfile, 'w') - f.write(newkeys) - f.close() + if actions[:SyncUserAPITokens] + userdotconfig = File.join(homedir, ".config") + ensure_dir(userdotconfig, 0755, username, user_gid) + configarvados = File.join(userdotconfig, "arvados") + ensure_dir(configarvados, 0700, username, user_gid) + + tokenfile = File.join(configarvados, "settings.conf") + + begin + STDERR.puts "Processing #{tokenfile} ..." if debug + newToken = false + if File.exist?(tokenfile) + # check if the token is still valid + myToken = ENV["ARVADOS_API_TOKEN"] + userEnv = File.read(tokenfile) + if (m = /^ARVADOS_API_TOKEN=(.*?\n)/m.match(userEnv)) + begin + tmp_arv = Arvados.new({ :api_host => logincluster_host, + :api_token => (m[1]), + :suppress_ssl_warnings => false }) + tmp_arv.user.current + rescue Arvados::TransactionFailedError => e + if e.to_s =~ /401 Unauthorized/ + STDERR.puts "Account #{l[:username]} token not valid, creating new token." + newToken = true + else + raise + end + end + end + elsif !File.exist?(tokenfile) || options[:"rotate-tokens"] + STDERR.puts "Account #{l[:username]} token file not found, creating new token." + newToken = true + end + if newToken + aca_params = {owner_uuid: l[:user_uuid], api_client_id: 0} + if options[:"token-lifetime"] && options[:"token-lifetime"] > 0 + aca_params.merge!(expires_at: (Time.now + options[:"token-lifetime"])) + end + user_token = logincluster_arv.api_client_authorization.create(api_client_authorization: aca_params) + File.open(tokenfile, 'w', 0600) do |f| + f.write("ARVADOS_API_HOST=#{ENV['ARVADOS_API_HOST']}\n") + f.write("ARVADOS_API_TOKEN=v2/#{user_token[:uuid]}/#{user_token[:api_token]}\n") + end + FileUtils.chown(username, user_gid, tokenfile) + end + rescue => e + STDERR.puts "Error setting token for #{l[:username]}: #{e}" + end end - FileUtils.chown_R(l[:username], nil, userdotssh) - File.chmod(0700, userdotssh) - File.chmod(0750, @homedir) - File.chmod(0600, keysfile) end - devnull.close rescue Exception => bang puts "Error: " + bang.to_s puts bang.backtrace.join("\n")