20663: Make arvados-login-sync actions configurable
[arvados.git] / services / login-sync / bin / arvados-login-sync
1 #!/usr/bin/env ruby
2 # Copyright (C) The Arvados Authors. All rights reserved.
3 #
4 # SPDX-License-Identifier: AGPL-3.0
5
6 require 'rubygems'
7 require 'pp'
8 require 'arvados'
9 require 'etc'
10 require 'fileutils'
11 require 'yaml'
12 require 'optparse'
13 require 'open3'
14
15 def ensure_dir(path, mode, owner, group)
16   begin
17     Dir.mkdir(path, mode)
18   rescue Errno::EEXIST
19     # No change needed
20     false
21   else
22     FileUtils.chown(owner, group, path)
23     true
24   end
25 end
26
27 req_envs = %w(ARVADOS_API_HOST ARVADOS_API_TOKEN ARVADOS_VIRTUAL_MACHINE_UUID)
28 req_envs.each do |k|
29   unless ENV[k]
30     abort "Fatal: These environment vars must be set: #{req_envs}"
31   end
32 end
33
34 options = {}
35 OptionParser.new do |parser|
36   parser.on('--exclusive', 'Manage SSH keys file exclusively.')
37   parser.on('--rotate-tokens', 'Force a rotation of all user tokens.')
38   parser.on('--skip-missing-users', "Don't try to create any local accounts.")
39   parser.on('--token-lifetime SECONDS', 'Create user tokens that expire after SECONDS.', Integer)
40   parser.on('--debug', 'Enable debug output')
41 end.parse!(into: options)
42
43 exclusive_banner = "#######################################################################################
44 #  THIS FILE IS MANAGED BY #{$0} -- CHANGES WILL BE OVERWRITTEN  #
45 #######################################################################################\n\n"
46 start_banner = "### BEGIN Arvados-managed keys -- changes between markers will be overwritten\n"
47 end_banner = "### END Arvados-managed keys -- changes between markers will be overwritten\n"
48
49 actions = {
50   # These names correspond to the names in the cluster Users configuration.
51   # Managing everything was the original behavior.
52   SyncUserAccounts: true,
53   SyncUserGroups: true,
54   SyncUserSSHKeys: true,
55   SyncUserAPITokens: true,
56 }
57
58 keys = ''
59
60 begin
61   debug = false
62   if options[:"debug"]
63     debug = true
64   end
65   arv = Arvados.new({ :suppress_ssl_warnings => false })
66   logincluster_host = ENV['ARVADOS_API_HOST']
67   logincluster_name = arv.cluster_config['Login']['LoginCluster'] or ''
68   # Requiring the fuse group was previous hardcoded behavior
69   minimum_groups = arv.cluster_config['Login']['SyncRequiredGroups'] || ['fuse']
70   actions.each_pair do |key, default|
71     actions[key] = arv.cluster_config['Login'].fetch(key.to_s, default)
72   end
73
74   if logincluster_name != '' and logincluster_name != arv.cluster_config['ClusterID']
75     logincluster_host = arv.cluster_config['RemoteClusters'][logincluster_name]['Host']
76   end
77   logincluster_arv = Arvados.new({ :api_host => logincluster_host,
78                                    :suppress_ssl_warnings => false })
79
80   vm_uuid = ENV['ARVADOS_VIRTUAL_MACHINE_UUID']
81
82   logins = arv.virtual_machine.logins(:uuid => vm_uuid)[:items]
83   logins = [] if logins.nil?
84   logins = logins.reject { |l| l[:username].nil? or l[:hostname].nil? or l[:virtual_machine_uuid] != vm_uuid }
85
86   # No system users
87   uid_min = 1000
88   open("/etc/login.defs", encoding: "utf-8") do |login_defs|
89     login_defs.each_line do |line|
90       next unless match = /^UID_MIN\s+(\S+)$/.match(line)
91       if match[1].start_with?("0x")
92         base = 16
93       elsif match[1].start_with?("0")
94         base = 8
95       else
96         base = 10
97       end
98       new_uid_min = match[1].to_i(base)
99       uid_min = new_uid_min if (new_uid_min > 0)
100     end
101   end
102
103   pwnam = Hash.new()
104   logins.reject! do |l|
105     if not pwnam[l[:username]]
106       begin
107         pwnam[l[:username]] = Etc.getpwnam(l[:username])
108       rescue
109         if options[:"skip-missing-users"]
110           STDERR.puts "Account #{l[:username]} not found. Skipping"
111           true
112         end
113       else
114         if pwnam[l[:username]].uid < uid_min
115           STDERR.puts "Account #{l[:username]} uid #{pwnam[l[:username]].uid} < uid_min #{uid_min}. Skipping" if debug
116           true
117         end
118       end
119     end
120   end
121   keys = Hash.new()
122
123   # Collect all keys
124   logins.each do |l|
125     STDERR.puts("Considering #{l[:username]} ...") if debug
126     keys[l[:username]] = Array.new() if not keys.has_key?(l[:username])
127     key = l[:public_key]
128     if !key.nil?
129       # Handle putty-style ssh public keys
130       key.sub!(/^(Comment: "r[^\n]*\n)(.*)$/m,'ssh-rsa \2 \1')
131       key.sub!(/^(Comment: "d[^\n]*\n)(.*)$/m,'ssh-dss \2 \1')
132       key.gsub!(/\n/,'')
133       key.strip
134
135       keys[l[:username]].push(key) if not keys[l[:username]].include?(key)
136     end
137   end
138
139   seen = Hash.new()
140
141   all_groups = []
142   current_user_groups = Hash.new { |hash, key| hash[key] = [] }
143   while (ent = Etc.getgrent()) do
144     all_groups << ent.name
145     ent.mem.each do |member|
146       current_user_groups[member] << ent.name
147     end
148   end
149   Etc.endgrent()
150
151   logins.each do |l|
152     next if seen[l[:username]]
153     seen[l[:username]] = true
154
155     username = l[:username]
156
157     unless pwnam[l[:username]]
158       unless actions[:SyncUserAccounts]
159         STDERR.puts "User #{username} does not exist and SyncUserAccounts=false. Skipping."
160         next
161       end
162       STDERR.puts "Creating account #{l[:username]}"
163       # Create new user
164       out, st = Open3.capture2e("useradd", "-m",
165                 "-c", username,
166                 "-s", "/bin/bash",
167                 username)
168       if st.exitstatus != 0
169         STDERR.puts "Account creation failed for #{l[:username]}:\n#{out}"
170         next
171       end
172       begin
173         pwnam[username] = Etc.getpwnam(username)
174       rescue => e
175         STDERR.puts "Created account but then getpwnam() failed for #{l[:username]}: #{e}"
176         raise
177       end
178     end
179
180     user_gid = pwnam[username].gid
181     homedir = pwnam[l[:username]].dir
182     if !File.exist?(homedir)
183       STDERR.puts "Cannot set up user #{username} because their home directory #{homedir} does not exist. Skipping."
184       next
185     end
186
187     if actions[:SyncUserGroups]
188       have_groups = current_user_groups[username]
189       want_groups = l[:groups] || []
190       want_groups |= minimum_groups
191       want_groups &= all_groups
192
193       (want_groups - have_groups).each do |addgroup|
194         # User should be in group, but isn't, so add them.
195         STDERR.puts "Add user #{username} to #{addgroup} group"
196         out, st = Open3.capture2e("usermod", "-aG", addgroup, username)
197         if st.exitstatus != 0
198           STDERR.puts "Failed to add #{username} to #{addgroup} group:\n#{out}"
199         end
200       end
201
202       (have_groups - want_groups).each do |removegroup|
203         # User is in a group, but shouldn't be, so remove them.
204         STDERR.puts "Remove user #{username} from #{removegroup} group"
205         out, st = Open3.capture2e("gpasswd", "-d", username, removegroup)
206         if st.exitstatus != 0
207           STDERR.puts "Failed to remove user #{username} from #{removegroup} group:\n#{out}"
208         end
209       end
210     end
211
212     if actions[:SyncUserSSHKeys]
213       userdotssh = File.join(homedir, ".ssh")
214       ensure_dir(userdotssh, 0700, username, user_gid)
215
216       newkeys = "###\n###\n" + keys[l[:username]].join("\n") + "\n###\n###\n"
217
218       keysfile = File.join(userdotssh, "authorized_keys")
219       begin
220         oldkeys = File.read(keysfile)
221       rescue Errno::ENOENT
222         oldkeys = ""
223       end
224
225       if options[:exclusive]
226         newkeys = exclusive_banner + newkeys
227       elsif oldkeys.start_with?(exclusive_banner)
228         newkeys = start_banner + newkeys + end_banner
229       elsif (m = /^(.*?\n|)#{start_banner}(.*?\n|)#{end_banner}(.*)/m.match(oldkeys))
230         newkeys = m[1] + start_banner + newkeys + end_banner + m[3]
231       else
232         newkeys = start_banner + newkeys + end_banner + oldkeys
233       end
234
235       if oldkeys != newkeys then
236         File.open(keysfile, 'w', 0600) do |f|
237           f.write(newkeys)
238         end
239         FileUtils.chown(username, user_gid, keysfile)
240       end
241     end
242
243     if actions[:SyncUserAPITokens]
244       userdotconfig = File.join(homedir, ".config")
245       ensure_dir(userdotconfig, 0755, username, user_gid)
246       configarvados = File.join(userdotconfig, "arvados")
247       ensure_dir(configarvados, 0700, username, user_gid)
248
249       tokenfile = File.join(configarvados, "settings.conf")
250
251       begin
252         STDERR.puts "Processing #{tokenfile} ..." if debug
253         newToken = false
254         if File.exist?(tokenfile)
255           # check if the token is still valid
256           myToken = ENV["ARVADOS_API_TOKEN"]
257           userEnv = File.read(tokenfile)
258           if (m = /^ARVADOS_API_TOKEN=(.*?\n)/m.match(userEnv))
259             begin
260               tmp_arv = Arvados.new({ :api_host => logincluster_host,
261                                       :api_token => (m[1]),
262                                       :suppress_ssl_warnings => false })
263               tmp_arv.user.current
264             rescue Arvados::TransactionFailedError => e
265               if e.to_s =~ /401 Unauthorized/
266                 STDERR.puts "Account #{l[:username]} token not valid, creating new token."
267                 newToken = true
268               else
269                 raise
270               end
271             end
272           end
273         elsif !File.exist?(tokenfile) || options[:"rotate-tokens"]
274           STDERR.puts "Account #{l[:username]} token file not found, creating new token."
275           newToken = true
276         end
277         if newToken
278           aca_params = {owner_uuid: l[:user_uuid], api_client_id: 0}
279           if options[:"token-lifetime"] && options[:"token-lifetime"] > 0
280             aca_params.merge!(expires_at: (Time.now + options[:"token-lifetime"]))
281           end
282           user_token = logincluster_arv.api_client_authorization.create(api_client_authorization: aca_params)
283           File.open(tokenfile, 'w', 0600) do |f|
284             f.write("ARVADOS_API_HOST=#{ENV['ARVADOS_API_HOST']}\n")
285             f.write("ARVADOS_API_TOKEN=v2/#{user_token[:uuid]}/#{user_token[:api_token]}\n")
286           end
287           FileUtils.chown(username, user_gid, tokenfile)
288         end
289       rescue => e
290         STDERR.puts "Error setting token for #{l[:username]}: #{e}"
291       end
292     end
293   end
294
295 rescue Exception => bang
296   puts "Error: " + bang.to_s
297   puts bang.backtrace.join("\n")
298   exit 1
299 end