#!/usr/bin/env ruby # Copyright (C) The Arvados Authors. All rights reserved. # # SPDX-License-Identifier: AGPL-3.0 require 'rubygems' require 'pp' 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| unless ENV[k] abort "Fatal: These environment vars must be set: #{req_envs}" end end 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" 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 = '' begin 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['Users']['SyncRequiredGroups'] || ['fuse'] ignored_groups = arv.cluster_config['Users']['SyncIgnoredGroups'] || [] (minimum_groups & ignored_groups).each do |group_name| STDERR.puts "WARNING: #{group_name} is listed in both SyncRequiredGroups and SyncIgnoredGroups. It will be ignored." end actions.each_pair do |key, default| actions[key] = arv.cluster_config['Users'].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[:virtual_machine_uuid] != vm_uuid } # No system users uid_min = 1000 open("/etc/login.defs", encoding: "utf-8") do |login_defs| login_defs.each_line do |line| next unless match = /^UID_MIN\s+(\S+)$/.match(line) if match[1].start_with?("0x") base = 16 elsif match[1].start_with?("0") base = 8 else base = 10 end new_uid_min = match[1].to_i(base) uid_min = new_uid_min if (new_uid_min > 0) end end 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] 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) end end seen = Hash.new() 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 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]}" # Create new user 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 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 if actions[:SyncUserGroups] have_groups = current_user_groups[username] - ignored_groups want_groups = l[:groups] || [] want_groups |= minimum_groups want_groups -= ignored_groups want_groups &= all_groups (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 (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 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 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]} 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 end rescue Exception => bang puts "Error: " + bang.to_s puts bang.backtrace.join("\n") exit 1 end