#!/usr/bin/ruby # Copyright 2013-2016 Robin H. Johnson # ExecHook is designed to be a partner to the GitHub webhook concept. # Instead of HTTP request as the transport, it uses SSH & exec as the # transport. require 'yaml' require 'json' require 'pathname' require 'set' require 'timeout' # this is the user we sudo to SUDO_USER = 0 # root key of config files for validation HASH_ROOT_KEY_BASE = 'exechook' # supported configs SUPPORTED_CONFIGS = 'yaml', 'json' # where to find config CONFIGDIR = '/etc/exechook' # default timeout DEFAULT_TIMEOUT = 300 # dry-run DRYRUN = false DEBUG = false class Hash # Returns a new hash with only the given keys. def slice(*keys) allowed = Set.new(respond_to?(:convert_key) ? keys.map { |key| convert_key(key) } : keys) reject { |key,| !allowed.include?(key) } end # Replaces the hash with only the given keys. def slice!(*keys) replace(slice(*keys)) end def hmap(&block) # http://chrisholtz.com/blog/lets-make-a-ruby-hash-map-method-that-returns-a-hash-instead-of-an-array/ Hash[self.map {|k, v| block.call(k,v) }] end end module Process public def self.children(depth=1) def self._children(pid) return (`ps o pid= --ppid #{pid}`.split.map { |_| _.chomp.to_i }) end pids = [] newpids = [Process.pid] while depth > 0 #newpids = _children(pids.map{|_|_.to_s}.join(',')) pids += newpids newpids = _children(newpids.join(',')) break if newpids.length == 0 # shortcut if no more children found depth = depth-1 end return pids end def self.waitall_deadline(deadline=Time.now, interval=0.01) results = [] begin while 1 do break if Time.new > deadline pid, status = wait(pid=-1, flags=Process::WNOHANG), $? if pid.nil? sleep interval else results.push [pid, status] end end rescue SystemCallError end return results end end def sudo_ourselves absscript = Pathname.new($0).expand_path if Process.euid != SUDO_USER Kernel.exec '/usr/bin/sudo', '-u', "##{SUDO_USER}", '-n', absscript.to_s, *ARGV end end def debug(*args) puts(*args) if DEBUG end # If a SSH private key file is readable by group or other, then SSH refuses to # use it, and spits out a warning (no warning if -q). def validate_ssh_key_perms(file) s = File.lstat(file) return (s.mode & 0044) == 0 end def forked_ssh(target, settings={}) effective = settings.merge(target) host, user, key, config = effective.slice('ssh_host', 'ssh_user', 'ssh_identityfile', 'ssh_config').values unless File.readable?(key) warn "ERR: Key missing or not accessible for ssh://#{user}@#{host}\n" return end unless validate_ssh_key_perms(key) warn "ERR: SSH key is too exposed, SSH refuses it: UNPROTECTED PRIVATE KEY FILE" return end ssh_command = effective['ssh_command'] || '/usr/bin/ssh' ssh_command = *ssh_command command = ssh_command + [ '-a', # Do not forward agent '-q', # Quiet '-T', # Disable TTY allocation '-o','IdentitiesOnly=yes', # Only use the specified key '-o','StrictHostKeyChecking=yes', # Strict '-o','BatchMode=yes', # Non-interactive ] command += [ '-F', config ] if config command += [ '-l',user, '-i',key, host] env = { 'SSH_AUTH_SOCK' => '' } debug("Kernel.system ", (command.map {|_|"'"+_+"'"}.join(' ')),"\n") h="#{user}@#{host}" print "Notifying #{h}\n" if settings['verbose'] # We are inside a fork unless DRYRUN then Kernel.exec(env, *command, :rlimit_core=>0) end end def _forked_Process_send(method, target, settings={}) env = target['env'] || {} command = *(target['command']) exec_opts = [ :chdir,:unsetenv_others,:pgroup,:new_pgroup,:umask,:close_others,:err,:out,:in ] exec_opts += [ 'as', 'core', 'cpu', 'data', 'fsize', 'memlock', 'msgqueue', 'nice', 'nofile', 'nproc', 'rss', 'rtprio', 'rttime', 'sbsize', 'sigpending', 'stack' ].map{|_| ('rlimit_'+_).to_sym } opts = target.hmap{ |k,v| [ k.to_s.to_sym, v ] }.slice(*exec_opts) debug(*command) return Process.send(method, env, *command, opts) end def forked_exec(target, settings={}) return _forked_Process_send(:exec, target, settings) end def forked_spawn(target, settings={}) pid = _forked_Process_send(:spawn, target, settings) return [Process.waitpid(pid), $?] end def _forked_generic_sequence(commands, env, target, settings={}) status = [] commands.take_while{|_| ret = forked_spawn(target.merge({'env'=>env, 'command' => _}), settings) status.push ret #puts ret[1].success? ret[1].success? } unless status.all?{|_|_[1].success?} status.each_with_index {|_,i| warn "ERR: Failed command: #{commands[i].join(' ')}" unless _[1].success? } return false end return true end def _forked_git_ENV(target, settings) effective = {'SSH'=>'/usr/libexec/exechook-ssh'}.merge(settings).merge(target) h = effective.select { |k,v| k.to_s.match(/^ssh/i) }.hmap{ |k,v| [ ('git_'+k).upcase, v ] } #keys = effective.keys.grep(/^ssh_/) #h = {} #h['GIT_SSH'] = '/usr/libexec/exechook-ssh' #h.update(Hash[keys.map{|_| # [ ('git_'+_).upcase, effective[_] ] #}]) return h end def forked_git_submodule(target, settings={}) dir = Pathname(target['dir']) env = _forked_git_ENV(target,settings) unless File.exists?(dir+'.gitmodules') then warn "ERR: No submodules found in #{dir}" return false end Dir.chdir(dir) commands = [ ['/usr/bin/git', 'submodule', '--quiet', 'init'], ['/usr/bin/git', 'submodule', '--quiet', 'sync'], ['/usr/bin/git', 'submodule', '--quiet', 'update'], ] return _forked_generic_sequence(commands, env, target, settings) end def forked_git_checkout(target, settings={}) # TODO: required args: # repo_uri # ssh_identityfile # dir # TODO: optional args: # ssh_* # git_remote # git_branch dir = Pathname(target['dir']) effective = settings.merge(target) git_repo_uri = effective['git_repo_uri'] || nil git_remote = effective['git_remote'] || 'origin' git_branch = effective['git_branch'] || 'branch' # get all of the keys env = _forked_git_ENV(target,settings) # Sanity unless validate_ssh_key_perms(env['GIT_SSH_IDENTITYFILE']) warn "ERR: SSH key is too exposed, SSH refuses it: UNPROTECTED PRIVATE KEY FILE" return false end commands = [] # Set it up if File.directory?(dir) # git pull unless File.directory?(dir+'.git') warn "ERR: Directory #{dir} exists, but is not a git checkout" return false end Dir.chdir(dir) commands = [ ['/usr/bin/git', 'remote', 'set-url', git_remote, git_repo_uri], ['/usr/bin/git', 'pull', '--quiet', git_remote, git_branch], ] else # git checkout commands = [ ['/usr/bin/git', 'clone', '--quiet', '-o', git_remote, '-b', git_branch, git_repo_uri, dir.to_s], ] end return _forked_generic_sequence(commands, env, target, settings) end def childfork(target, settings) method = 'forked_'+target['mode'] method_sym = method.to_sym effective = settings.merge(target) notify = effective['notify'] || nil if self.private_methods.include? method_sym puts (Kernel.sprintf notify, effective.hmap{|k,v|[k.to_sym,v]}) if notify ret = self.send(method_sym, target, settings) else raise("Unknown target mode: #{target.inspect}") end #puts "Child result #{ret}" exit (ret ? 0 : 1) end def main sudo_ourselves #print "ARGS:", ARGV.map {|_| "'#{_}'" }.join(' '), "\n" # mode = ARGV[0] case mode when 'send'; when 'recv'; else raise "No mode specified!" end # Security here, do not take ANY path on this file, it's a relative path ONLY configfile = ARGV[1].gsub(/.*\/+/, '') #configfile += '.yaml' unless configfile.ends_with?(SUPPORTED_CONFIGS) # Ruby 2.1 magic! configfile = Pathname.new(CONFIGDIR) + configfile debug("Loading config from #{configfile}\n") if not File.owned?(configfile) then warn "ERR: Config #{configfile} does not exist or is not accessible by uid=#{Process.euid}" exit 1 end config = YAML.load_file(configfile) if configfile.to_s.match(/\.yaml$/) config = JSON.parse(File.read(configfile)) if configfile.to_s.match(/\.json$/) hash_root_key = HASH_ROOT_KEY_BASE+'-'+mode if not config.has_key?(hash_root_key) then warn "ERR: Config file does not start with correct key" exit 1 end config = config[hash_root_key] targets = config['targets'] || {} settings = config['settings'] || {} pids = [] for target in targets do effective = settings.merge(target) timeout = effective['timeout'] || DEFAULT_TIMEOUT next if effective['disabled'] pid = Process.fork { Timeout::timeout(timeout) { childfork(target,settings) } } pids.push pid Process.detach(pid) if ((effective['background']) == 1) status = nil status = Process.waitpid(pid) unless (effective['parallel'] == 1) unless status.nil? break unless $?.success? end effective = nil end pids = Process.waitall unless (settings['background'] == 1) if settings['timeout'] deadline = Time.now + settings['timeout'].to_f results = Process.waitall_deadline(deadline) sig = settings['timeout_signal'] if (!sig.nil? and Time.new > deadline) pids = Process.children(10) p 'Killing',pids,'with',sig Process.kill(sig, *pids) end end #print pids.inspect end main # vim: sw=2 et ts=2: