diff options
author | 2016-07-19 17:05:01 -0700 | |
---|---|---|
committer | 2016-07-19 17:05:01 -0700 | |
commit | 513358426254cdb5ff024633bf1b624e8ec2bfc2 (patch) | |
tree | 5fe9b328f0f1cd3d6cb00712a7ec19bbee48dd67 /exechook | |
download | githooks-513358426254cdb5ff024633bf1b624e8ec2bfc2.tar.gz githooks-513358426254cdb5ff024633bf1b624e8ec2bfc2.tar.bz2 githooks-513358426254cdb5ff024633bf1b624e8ec2bfc2.zip |
Initial commit.
Signed-off-by: Robin H. Johnson <robbat2@gentoo.org>
Diffstat (limited to 'exechook')
-rw-r--r-- | exechook/README | 4 | ||||
-rw-r--r-- | exechook/examples/cfengine.yaml | 39 | ||||
-rw-r--r-- | exechook/examples/dns.yaml | 35 | ||||
-rw-r--r-- | exechook/examples/puppet.yaml | 32 | ||||
-rwxr-xr-x | exechook/exechook | 341 | ||||
-rwxr-xr-x | exechook/exechook-ssh | 90 |
6 files changed, 541 insertions, 0 deletions
diff --git a/exechook/README b/exechook/README new file mode 100644 index 0000000..7824783 --- /dev/null +++ b/exechook/README @@ -0,0 +1,4 @@ +ExecHook Copyright 2013-2016 Robin H. Johnson <robbat2@gentoo.org> + +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. diff --git a/exechook/examples/cfengine.yaml b/exechook/examples/cfengine.yaml new file mode 100644 index 0000000..cf98392 --- /dev/null +++ b/exechook/examples/cfengine.yaml @@ -0,0 +1,39 @@ +--- +exechook-send: + settings: + verbose: 1 + dryrun: 1 + parallel: 1 + background: 1 + timeout: 5 + ssh_command: + - '/usr/bin/timeout' + - '60' + - '/usr/bin/ssh' + notify: 'Pushing to cfengine: %{ssh_host}' + targets: + - + mode: ssh + ssh_host: cfservd-eu.example.com + ssh_user: cfservd + ssh_identityfile: /etc/ssh/system-users/cfservd/cfservd.priv + background: 1 + - + mode: ssh + ssh_host: cfservd-us.example.com + ssh_user: cfservd + ssh_identityfile: /etc/ssh/system-users/cfservd/cfservd.priv + background: 1 +exechook-recv: + settings: + parallel: 0 + background: 0 + targets: + - + mode: git_checkout + dir: '/var/cfengine/repository/' + git_repo_uri: 'git+ssh://git@git.example.com/infra/cfengine.git' + git_remote: origin + git_branch: master + ssh_identityfile: 'etc/ssh/system-users/cfservd/cfservd.priv' + notify: 'cfengine: Updating checkout' diff --git a/exechook/examples/dns.yaml b/exechook/examples/dns.yaml new file mode 100644 index 0000000..35dbd52 --- /dev/null +++ b/exechook/examples/dns.yaml @@ -0,0 +1,35 @@ +--- +exechook-send: + settings: + parallel: 1 + background: 1 + targets: + - + mode: ssh + ssh_host: ns0.example.com + ssh_user: dns-master + ssh_identityfile: '/etc/ssh/system-users/dns-master/dns-master.priv' + notify: 'dns-master: notifying ns0/%{ssh_host}' + timeout: 15 +exechook-recv: + settings: + parallel: 0 + background: 0 + targets: + - + mode: git_checkout + dir: '/var/bind/git-dns-zones' + ssh_identityfile: '/etc/ssh/system-users/dns-master/dns-master.priv' + git_remote: origin + git_branch: master + git_repo_uri: 'git+ssh://git@git.example.com/infra/dns-zones.git' + notify: 'dns-master: Updating checkout' + - + mode: exec + env: + DEMOVAR: '1' + chdir: '/var/bind/git-dns-zones' + command: + - 'bin/DEPLOY-MY-DNS' + - '/var/bind/pri' + timeout: 180 diff --git a/exechook/examples/puppet.yaml b/exechook/examples/puppet.yaml new file mode 100644 index 0000000..045a427 --- /dev/null +++ b/exechook/examples/puppet.yaml @@ -0,0 +1,32 @@ +# This is mostly moot in the world of r10k +--- +exechook-send: + settings: + parallel: 1 + background: 1 + notify: 'Pushing to puppetmaster: %{ssh_host}' + targets: + - mode: ssh + ssh_host: puppetmaster-eu.example.com + ssh_user: puppet + ssh_identityfile: '/etc/ssh/system-users/puppet/puppet.priv' + - mode: ssh + ssh_host: puppetmaster-us.example.com + ssh_user: puppet + ssh_identityfile: '/etc/ssh/system-users/puppet/puppet.priv' +exechook-recv: + settings: + parallel: 0 + background: 0 + targets: + - mode: git_checkout + dir: '/var/lib/puppet/checkouts' + ssh_identityfile: '/etc/ssh/system-users/puppet/puppet.priv' + git_remote: origin + git_branch: master + git_repo_uri: 'git+ssh://git@git.example.com/infra/puppet.git' + notify: 'puppet: Updating checkout' + - mode: git_submodule + dir: '/var/lib/puppet/checkouts' + ssh_identityfile: '/etc/ssh/system-users/puppet/puppet.priv' + notify: 'puppet: Updating git submodules' diff --git a/exechook/exechook b/exechook/exechook new file mode 100755 index 0000000..3ccb96f --- /dev/null +++ b/exechook/exechook @@ -0,0 +1,341 @@ +#!/usr/bin/ruby +# Copyright 2013-2016 Robin H. Johnson <robbat2@gentoo.org> +# 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: diff --git a/exechook/exechook-ssh b/exechook/exechook-ssh new file mode 100755 index 0000000..a8ef879 --- /dev/null +++ b/exechook/exechook-ssh @@ -0,0 +1,90 @@ +#!/bin/sh +# Copyright 2013-2016 Robin H. Johnson <robbat2@gentoo.org> +# 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. +# +# This is a little helper for GIT_SSH, because Git expects GIT_SSH to point +# directly to a single command, and does not take parameters. +# +# Newer git also supports GIT_SSH_COMMAND, but this was written for systems +# where older Git still exists and GIT_SSH_COMMAND is not supported. +vars=$(env -0 |grep -zZi -e '^GIT_SSH_OPT' | sed -z 's,=.*,,g' | tr '\0' '\n') +for v in $vars ; do + optname=${v#GIT_SSH_OPT_} + #optvalue=${!v} # Bash-specific, don't use it + eval optvalue=\$$v + set -- -o "$optname"="$optvalue" "$@" +done + +# NOT available in configfile +[ -n "$GIT_SSH_CONFIG" ] && set -- -F "$GIT_SSH_CONFIG" "$@" +[ -n "$GIT_SSH_LOGFILE" ] && set -- -E "$GIT_SSH_LOGFILE" "$@" +# Common, but available in Config +[ -n "$GIT_SSH_USER" ] && set -- -l "$GIT_SSH_USER" "$@" +[ -n "$GIT_SSH_IDENTITYFILE" ] && set -- -i "$GIT_SSH_IDENTITYFILE" "$@" +[ -n "$GIT_SSH_BACKGROUND" ] && set -- -f "$@" + +# TODO: +# - support multiple +# - IdentityFiles +# - LocalForward +# - RemoteForward +# - DynamicForward + +# If you want other options, you should PROBABLY be using the GIT_SSH_OPT stuff. +# Prefix the name of the option with GIT_SSH_OPT_ +# Options allowed multiple times specified with * +# -1 Protocol=1 +# -2 Protocol=2 +# -4 AddressFamily=inet +# -6 AddressFamily=inet6 +# -A/-a ForwardAgent +# -b BindAddress +# -C Compression +# -c Ciphers +# -D DynamicForward* +# -g GatewayPorts +# -I PKCS11Provider +# -i IdentityFile* +# -K/-k GSSAPIAuthentication +# -L LocalForward* +# -l User +# -M ControlMaster +# -m MACs +# -N ... no mapping, not useful to us +# -n ... no mapping, not useful to us +# -O ... no mapping, not useful to us +# -o ... we use this a lot! +# -p Port +# -Q ... no mapping, not useful to us +# -q ... no mapping, but we do it manually +# -R RemoteForward* +# -S ControlPath +# -s ... no mapping, not useful to us +# -T/-t RequestTTY +# -v LogLevel +# -W ... no mapping, not useful to us +# -w Tunnel +# -X/-x ForwardX11 +# -Y ForwardX11Trusted +# -y ... no mapping, not useful to us + +[ -n "$GIT_SSH__VERBOSE" ] && +echo exec /usr/bin/ssh \ + -a \ + -q \ + -T \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=yes \ + -o BatchMode=yes \ + "$@" 1>&2 + +exec /usr/bin/ssh \ + -a \ + -q \ + -T \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=yes \ + -o BatchMode=yes \ + "$@" |