#!/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' 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" 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 '' 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() current_user_groups = Hash.new while (ent = Etc.getgrent()) do ent.mem.each do |member| current_user_groups[member] ||= Array.new current_user_groups[member].push 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]] 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 existing_groups = current_user_groups[username] || [] groups = l[:groups] || [] # Adding users to the FUSE group has long been hardcoded behavior. groups << "fuse" groups << username groups.select! { |g| Etc.getgrnam(g) rescue false } groups.each do |addgroup| if existing_groups.index(addgroup).nil? # 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 end existing_groups.each do |removegroup| if groups.index(removegroup).nil? # 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 homedir = pwnam[l[:username]].dir userdotssh = File.join(homedir, ".ssh") Dir.mkdir(userdotssh) if !File.exist?(userdotssh) newkeys = "###\n###\n" + keys[l[:username]].join("\n") + "\n###\n###\n" keysfile = File.join(userdotssh, "authorized_keys") if File.exist?(keysfile) oldkeys = IO::read(keysfile) else 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 f = File.new(keysfile, 'w') f.write(newkeys) f.close() end userdotconfig = File.join(homedir, ".config") if !File.exist?(userdotconfig) Dir.mkdir(userdotconfig) end configarvados = File.join(userdotconfig, "arvados") Dir.mkdir(configarvados) if !File.exist?(configarvados) 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 = IO::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) f = File.new(tokenfile, 'w') f.write("ARVADOS_API_HOST=#{ENV['ARVADOS_API_HOST']}\n") f.write("ARVADOS_API_TOKEN=v2/#{user_token[:uuid]}/#{user_token[:api_token]}\n") f.close() end rescue => e STDERR.puts "Error setting token for #{l[:username]}: #{e}" end FileUtils.chown_R(l[:username], nil, userdotssh) FileUtils.chown_R(l[:username], nil, userdotconfig) File.chmod(0700, userdotssh) File.chmod(0700, userdotconfig) File.chmod(0700, configarvados) File.chmod(0750, homedir) File.chmod(0600, keysfile) if File.exist?(tokenfile) File.chmod(0600, tokenfile) end end rescue Exception => bang puts "Error: " + bang.to_s puts bang.backtrace.join("\n") exit 1 end