aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRobin H. Johnson <robbat2@gentoo.org>2016-07-19 17:05:01 -0700
committerRobin H. Johnson <robbat2@gentoo.org>2016-07-19 17:05:01 -0700
commit513358426254cdb5ff024633bf1b624e8ec2bfc2 (patch)
tree5fe9b328f0f1cd3d6cb00712a7ec19bbee48dd67 /exechook
downloadgithooks-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/README4
-rw-r--r--exechook/examples/cfengine.yaml39
-rw-r--r--exechook/examples/dns.yaml35
-rw-r--r--exechook/examples/puppet.yaml32
-rwxr-xr-xexechook/exechook341
-rwxr-xr-xexechook/exechook-ssh90
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 \
+ "$@"