From b0ad6abc0ae43c2af414aadd3e4fac4fe325dd0a Mon Sep 17 00:00:00 2001 From: Clement Parisot Date: Wed, 12 Apr 2017 14:02:28 +0200 Subject: Add new output GitCrypt Signed-off-by: Clement Parisot --- Gemfile.lock | 1 + README.md | 69 +++++++++++- lib/oxidized/node.rb | 36 ++++-- lib/oxidized/output/gitcrypt.rb | 244 ++++++++++++++++++++++++++++++++++++++++ oxidized.gemspec | 1 + 5 files changed, 341 insertions(+), 10 deletions(-) create mode 100644 lib/oxidized/output/gitcrypt.rb diff --git a/Gemfile.lock b/Gemfile.lock index ab52715..2c887c1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -33,6 +33,7 @@ PLATFORMS DEPENDENCIES bundler (~> 1.10) + git (~> 1) minitest (~> 5.8) mocha (~> 1.1) oxidized! diff --git a/README.md b/README.md index 5dcd7e0..6e71e27 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Oxidized is a network device configuration backup tool. It's a RANCID replacemen * [Source: Mysql](#source-mysql) * [Source: HTTP](#source-http) * [Output: GIT](#output-git) + * [Output: GIT-Crypt](#output-git-crypt) * [Output: HTTP](#output-http) * [Output: File](#output-file) * [Output types](#output-types) @@ -236,7 +237,7 @@ Oxidized supports ```CSV```, ```SQLite``` and ```HTTP``` as source backends. The ## Outputs -Possible outputs are either ```file``` or ```git```. The file backend takes a destination directory as argument and will keep a file per device, with most recent running version of a device. The GIT backend (recommended) will initialize an empty GIT repository in the specified path and create a new commit on every configuration change. Take a look at the [Cookbook](#cookbook) for more details. +Possible outputs are either ```file```, ```git``` or ```git-crypt```. The file backend takes a destination directory as argument and will keep a file per device, with most recent running version of a device. The GIT backend (recommended) will initialize an empty GIT repository in the specified path and create a new commit on every configuration change. The GIT-Crypt backend will also initialize a GIT repository but every configuration push to it will be encrypted on the fly by using ```git-crypt``` tool. Take a look at the [Cookbook](#cookbook) for more details. Maps define how to map a model's fields to model [model fields](https://github.com/ytti/oxidized/tree/master/lib/oxidized/model). Most of the settings should be self explanatory, log is ignored if `use_syslog`(requires Ruby >= 2.0) is set to `true`. @@ -604,6 +605,72 @@ output: ``` +### Output: Git-Crypt + +This uses the gem git and system git-crypt interfaces. Have a look at [GIT-Crypt](https://www.agwa.name/projects/git-crypt/) documentation to know how to install it. +Additionally to user and email informations, you have to provide the users ID that can be a key ID, a full fingerprint, an email address, or anything else that uniquely identifies a public key to GPG (see "HOW TO SPECIFY A USER ID" in the gpg man page). + + +For a single repositories for all devices: + +``` yaml +output: + default: gitcrypt + gitcrypt: + user: Oxidized + email: o@example.com + repo: "/var/lib/oxidized/devices" + users: + - "0x0123456789ABCDEF" + - "" +``` + +And for groups repositories: + +``` yaml +output: + default: gitcrypt + gitcrypt: + user: Oxidized + email: o@example.com + repo: "/var/lib/oxidized/git-repos/default" + users: + - "0xABCDEF0123456789" + - "0x0123456789ABCDEF" +``` + +Oxidized will create a repository for each group in the same directory as the `default`. For +example: + +``` csv +host1:ios:first +host2:nxos:second +``` + +This will generate the following repositories: + +``` bash +$ ls /var/lib/oxidized/git-repos + +default.git first.git second.git +``` + +If you would like to use groups and a single repository, you can force this with the `single_repo` config. + +``` yaml +output: + default: gitcrypt + gitcrypt: + single_repo: true + repo: "/var/lib/oxidized/devices" + users: + - "0xABCDEF0123456789" + - "0x0123456789ABCDEF" + +``` + +Please note that user list is only updated once at creation. + ### Output: Http POST a config to the specified URL diff --git a/lib/oxidized/node.rb b/lib/oxidized/node.rb index 6f89b56..cf71e48 100644 --- a/lib/oxidized/node.rb +++ b/lib/oxidized/node.rb @@ -166,18 +166,32 @@ module Oxidized end def resolve_repo opt - return unless is_git? opt - - remote_repo = Oxidized.config.output.git.repo - - if remote_repo.is_a?(::String) - if Oxidized.config.output.git.single_repo? || @group.nil? - remote_repo + if is_git? opt + remote_repo = Oxidized.config.output.git.repo + + if remote_repo.is_a?(::String) + if Oxidized.config.output.git.single_repo? || @group.nil? + remote_repo + else + File.join(File.dirname(remote_repo), @group + '.git') + end + else + remote_repo[@group] + end + elsif is_gitcrypt? opt + remote_repo = Oxidized.config.output.gitcrypt.repo + + if remote_repo.is_a?(::String) + if Oxidized.config.output.gitcrypt.single_repo? || @group.nil? + remote_repo + else + File.join(File.dirname(remote_repo), @group + '.git') + end else - File.join(File.dirname(remote_repo), @group + '.git') + remote_repo[@group] end else - remote_repo[@group] + return end end @@ -212,5 +226,9 @@ module Oxidized (opt[:output] || Oxidized.config.output.default) == 'git' end + def is_gitcrypt? opt + (opt[:output] || Oxidized.config.output.default) == 'gitcrypt' + end + end end diff --git a/lib/oxidized/output/gitcrypt.rb b/lib/oxidized/output/gitcrypt.rb new file mode 100644 index 0000000..b0d80f2 --- /dev/null +++ b/lib/oxidized/output/gitcrypt.rb @@ -0,0 +1,244 @@ +module Oxidized + class GitCrypt < Output + class GitCryptError < OxidizedError; end + begin + require 'git' + rescue LoadError + raise OxidizedError, 'git not found: sudo gem install ruby-git' + end + + attr_reader :commitref + + def initialize + @cfg = Oxidized.config.output.gitcrypt + @gitcrypt_cmd = "/usr/bin/git-crypt" + @gitcrypt_init = @gitcrypt_cmd + " init" + @gitcrypt_unlock = @gitcrypt_cmd + " unlock" + @gitcrypt_lock = @gitcrypt_cmd + " lock" + @gitcrypt_adduser = @gitcrypt_cmd + " add-gpg-user --trusted " + end + + def setup + if @cfg.empty? + Oxidized.asetus.user.output.gitcrypt.user = 'Oxidized' + Oxidized.asetus.user.output.gitcrypt.email = 'o@example.com' + Oxidized.asetus.user.output.gitcrypt.repo = File.join(Config::Root, 'oxidized.git') + Oxidized.asetus.save :user + raise NoConfig, 'no output git config, edit ~/.config/oxidized/config' + end + + if @cfg.repo.respond_to?(:each) + @cfg.repo.each do |group, repo| + @cfg.repo["#{group}="] = File.expand_path repo + end + else + @cfg.repo = File.expand_path @cfg.repo + end + end + + def crypt_init repo + repo.chdir do + system(@gitcrypt_init) + @cfg.users.each do |user| + system("#{@gitcrypt_adduser} #{user}") + end + File.write(".gitattributes", "* filter=git-crypt diff=git-crypt\n.gitattributes !filter !diff") + repo.add(".gitattributes") + repo.commit("Initial commit: crypt all config files") + end + end + + def lock repo + repo.chdir do + system(@gitcrypt_lock) + end + end + + def unlock repo + repo.chdir do + system(@gitcrypt_unlock) + end + end + + def store file, outputs, opt={} + @msg = opt[:msg] + @user = (opt[:user] or @cfg.user) + @email = (opt[:email] or @cfg.email) + @opt = opt + @commitref = nil + repo = @cfg.repo + + outputs.types.each do |type| + type_cfg = '' + type_repo = File.join(File.dirname(repo), type + '.git') + outputs.type(type).each do |output| + (type_cfg << output; next) if not output.name + type_file = file + '--' + output.name + if @cfg.type_as_directory? + type_file = type + '/' + type_file + type_repo = repo + end + update type_repo, type_file, output + end + update type_repo, file, type_cfg + end + + update repo, file, outputs.to_cfg + end + + + def fetch node, group + begin + repo, path = yield_repo_and_path(node, group) + repo = Git.open repo + unlock repo + index = repo.index + # Empty repo ? + empty = File.exists? index.path + if empty + raise 'Empty git repo' + else + File.read path + end + lock repo + rescue + 'node not found' + end + end + + # give a hash of all oid revision for the given node, and the date of the commit + def version node, group + begin + repo, path = yield_repo_and_path(node, group) + + repo = Git.open repo + unlock repo + walker = repo.log.path(path) + i = -1 + tab = [] + walker.each do |commit| + hash = {} + hash[:date] = commit.date.to_s + hash[:oid] = commit.objectish + hash[:author] = commit.author + hash[:message] = commit.message + tab[i += 1] = hash + end + walker.reset + tab + rescue + 'node not found' + end + end + + #give the blob of a specific revision + def get_version node, group, oid + begin + repo, path = yield_repo_and_path(node, group) + repo = Git.open repo + unlock repo + repo.gtree(oid).files[path].contents + rescue + 'version not found' + ensure + lock repo + end + end + + #give a hash with the patch of a diff between 2 revision and the stats (added and deleted lines) + def get_diff node, group, oid1, oid2 + begin + diff_commits = nil + repo, path = yield_repo_and_path(node, group) + repo = Git.open repo + unlock repo + commit = repo.gcommit(oid1) + + if oid2 + commit_old = repo.gcommit(oid2) + diff = repo.diff(commit_old, commit) + stats = [diff.stats[:files][node.name][:insertions], diff.stats[:files][node.name][:deletions]] + diff.each do |patch| + if /#{node.name}\s+/.match(patch.patch.to_s.lines.first) + diff_commits = {:patch => patch.patch.to_s, :stat => stats} + break + end + end + else + stat = commit.parents[0].diff(commit).stats + stat = [stat[:files][node.name][:insertions],stat[:files][node.name][:deletions]] + patch = commit.parents[0].diff(commit).patch + diff_commits = {:patch => patch, :stat => stat} + end + lock repo + diff_commits + rescue + 'no diffs' + ensure + lock repo + end + end + + private + + def yield_repo_and_path(node, group) + repo, path = node.repo, node.name + + if group and @cfg.single_repo? + path = "#{group}/#{node.name}" + end + + [repo, path] + end + + def update repo, file, data + return if data.empty? + + if @opt[:group] + if @cfg.single_repo? + file = File.join @opt[:group], file + else + repo = if repo.is_a?(::String) + File.join File.dirname(repo), @opt[:group] + '.git' + else + repo[@opt[:group]] + end + end + end + + begin + update_repo repo, file, data, @msg, @user, @email + rescue Git::GitExecuteError, ArgumentError => open_error + Oxidized.logger.debug "open_error #{open_error} #{file}" + begin + grepo = Git.init repo + crypt_init grepo + rescue => create_error + raise GitCryptError, "first '#{open_error.message}' was raised while opening git repo, then '#{create_error.message}' was while trying to create git repo" + end + retry + end + end + + def update_repo repo, file, data, msg, user, email + grepo = Git.open repo + grepo.config('user.name', user) + grepo.config('user.email', email) + grepo.chdir do + unlock grepo + File.write(file, data) + grepo.add(file) + if grepo.status[file].nil? + grepo.commit(msg) + @commitref = grepo.log(1).first.objectish + true + elsif !grepo.status[file].type.nil? + grepo.commit(msg) + @commitref = grepo.log(1).first.objectish + true + end + lock grepo + end + end + end +end diff --git a/oxidized.gemspec b/oxidized.gemspec index 78324d4..ea088e7 100644 --- a/oxidized.gemspec +++ b/oxidized.gemspec @@ -33,4 +33,5 @@ Gem::Specification.new do |s| s.add_development_dependency 'rake', '~> 10.0' s.add_development_dependency 'minitest', '~> 5.8' s.add_development_dependency 'mocha', '~> 1.1' + s.add_development_dependency 'git', '~> 1' end -- cgit v1.2.1