restic-service 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 5eb6ed2de697e518e95589565d8104e15aff50c4
4
+ data.tar.gz: afc4fd0c966455bb46920ce2ca5cd42902eb1e1c
5
+ SHA512:
6
+ metadata.gz: 6c9e35b848ca8d1cb6627aefcc3c3073a3499b2770b995bf9d22ae673ff085f84193dbb52fa54a329ec6dd1dedf802dbe069ebcd8bd8f9ac932bd8806235ef9b
7
+ data.tar.gz: 31d8e7db6d33568552a33a90cef56c3c86996d6e1021a90f56b43224fda7a80e28a910465583a8c8186564784fb041abbb5c9302da66a2fbdda2c81d1ff380db
@@ -0,0 +1,8 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.1
5
+ before_install: gem install bundler -v 1.16.0
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ group :development do
6
+ gem 'pry'
7
+ gem 'pry-byebug'
8
+ end
9
+
10
+ # Specify your gem's dependencies in restic-service.gemspec
11
+ gemspec
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 Sylvain Joyeux
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,126 @@
1
+ # restic-service
2
+
3
+ A backup service using restic and rclone, meant to be run as a background
4
+ service.
5
+
6
+ ## Usage
7
+
8
+ `restic-service` expects a YAML configuration file. By default, it is stored in
9
+ `/etc/restic-service/conf.yml`.
10
+
11
+ The template configuration file looks like this:
12
+
13
+ ~~~ yaml
14
+ # The path to the underlying tools
15
+ tools:
16
+ restic: /opt/restic
17
+ rclone: /opt/rclone
18
+
19
+ # The targets. The only generic parts of a target definition
20
+ # are the name and type. The rest is target-specific
21
+ targets:
22
+ - name: a_restic_sftp_target
23
+ type: restic-sftp
24
+ ~~~
25
+
26
+ ## Remote host identification using SSH {#ssh_key_id}
27
+
28
+ Some target types recognize the remote host based on expected SSH keys.
29
+ Corresponding keys have to be stored locally in the `keys/` subfolder of
30
+ restic-service's configuration folder (i.e. /etc/restic-service/keys/ by
31
+ default). Targets that are recognized this way must have a file named
32
+ `${target_name}.keys`.
33
+
34
+ This file can be created using ssh's tools with
35
+
36
+ ~~~
37
+ ssh-keyscan -H hostname > /etc/restic-service/keys/targetname.keys
38
+ ~~~
39
+
40
+ ## Target type: restic-sftp
41
+
42
+ The rest-sftp target backs files up using sftp on a remote target. The
43
+ target expects the SFTP authentication to be done [using SSH keys](#ssh_key_id).
44
+
45
+ The target takes the following arguments:
46
+
47
+ ~~~ yaml
48
+ - name: name_of_target
49
+ type: restic-sftp
50
+ # The repo host. This is authenticated using SSH keys, so there must be a
51
+ # corresponding keys/name_of_target.keys file in $CONF_DIR/keys
52
+ host: host
53
+ # The username that should be used to connect to the host
54
+ username: the_user
55
+ # The repo path on the host
56
+ path: /
57
+ # The repository password (encryption password from Restic)
58
+ password: repo_password
59
+ # Mandatory, list of paths to backup. Needs at least one
60
+ includes:
61
+ - list
62
+ - of
63
+ - paths
64
+ - to
65
+ - backup
66
+ # Optional, list of excluded patterns
67
+ excludes:
68
+ - list/of/patterns
69
+ - to/not/backup
70
+ one_filesystem: false
71
+ # Optional, the IO class. Defaults to 3 (Idle)
72
+ io_class: 3
73
+ # Optional, the IO priority. Unused for IO class 3
74
+ io_priority: 0
75
+ # Optional, the CPU priority. Higher gets less CPU
76
+ cpu_priority: 19
77
+ ~~~
78
+
79
+ ## Target type: restic-b2
80
+
81
+ The rest-b2 target backs files up using sftp on a B2 bucket.
82
+
83
+ The target takes the following arguments:
84
+
85
+ ~~~ yaml
86
+ - name: name_of_target
87
+ type: restic-b2
88
+ # The B2 bucket
89
+ bucket: mybucket
90
+ # The path within the bucket
91
+ path: path
92
+ # The B2 ID
93
+ id:
94
+ # The B2 Key
95
+ key:
96
+ # The repository password (encryption password from Restic)
97
+ password: repo_password
98
+ # Mandatory, list of paths to backup. Needs at least one
99
+ includes:
100
+ - list
101
+ - of
102
+ - paths
103
+ - to
104
+ - backup
105
+ # Optional, list of excluded patterns
106
+ excludes:
107
+ - list/of/patterns
108
+ - to/not/backup
109
+ one_filesystem: false
110
+ # Optional, the IO class. Defaults to 3 (Idle)
111
+ io_class: 3
112
+ # Optional, the IO priority. Unused for IO class 3
113
+ io_priority: 0
114
+ # Optional, the CPU priority. Higher gets less CPU
115
+ cpu_priority: 19
116
+ ~~~
117
+
118
+ ## Contributing
119
+
120
+ Bug reports and pull requests are welcome on GitHub at
121
+ https://github.com/ThirteenLtda/restic-service.
122
+
123
+ ## License
124
+
125
+ The gem is available as open source under the terms of the [MIT
126
+ License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
9
+
10
+ task :default => :test
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "restic/service"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,9 @@
1
+ #! /usr/bin/env ruby
2
+
3
+ require 'restic/service/cli'
4
+
5
+ begin
6
+ Restic::Service::CLI.start(ARGV)
7
+ rescue Interrupt
8
+ STDERR.puts "Interrupted"
9
+ end
@@ -0,0 +1,31 @@
1
+ #! /bin/sh -ex
2
+
3
+ PROGRAM=restic-service
4
+
5
+ dev_path=$PWD
6
+
7
+ target=`mktemp -d`
8
+ cd $target
9
+ cat > Gemfile <<GEMFILE
10
+ source "https://rubygems.org"
11
+ gem '${PROGRAM}', path: "$dev_path"
12
+ GEMFILE
13
+
14
+ cat Gemfile
15
+
16
+ bundler install --standalone --binstubs
17
+ if test -d /opt/${PROGRAM}; then
18
+ sudo rm -rf /opt/${PROGRAM}
19
+ fi
20
+ sudo cp -r . /opt/${PROGRAM}
21
+ sudo chmod go+rX /opt/${PROGRAM}
22
+
23
+ if test -d /lib/systemd/system; then
24
+ target_gem=`bundler show ${PROGRAM}`
25
+ sudo cp $target_gem/${PROGRAM}.service /lib/systemd/system
26
+ ( sudo systemctl stop ${PROGRAM}.service
27
+ sudo systemctl enable ${PROGRAM}.service
28
+ sudo systemctl start ${PROGRAM}.service )
29
+ fi
30
+
31
+ rm -rf $target
@@ -0,0 +1,27 @@
1
+ #! /bin/sh -ex
2
+
3
+ PROGRAM=restic-service
4
+
5
+ target=`mktemp -d`
6
+ cd $target
7
+ cat > Gemfile <<GEMFILE
8
+ source "https://rubygems.org"
9
+ gem '${PROGRAM}'
10
+ GEMFILE
11
+
12
+ bundler install --standalone --binstubs
13
+ if test -d /opt/${PROGRAM}; then
14
+ sudo rm -rf /opt/${PROGRAM}
15
+ fi
16
+ sudo cp -r . /opt/${PROGRAM}
17
+ sudo chmod go+rX /opt/${PROGRAM}
18
+
19
+ if test -d /lib/systemd/system; then
20
+ target_gem=`bundler show ${PROGRAM}`
21
+ sudo cp $target_gem/${PROGRAM}.service /lib/systemd/system
22
+ ( sudo systemctl stop ${PROGRAM}.service
23
+ sudo systemctl enable ${PROGRAM}.service
24
+ sudo systemctl start ${PROGRAM}.service )
25
+ fi
26
+
27
+ rm -rf $target
@@ -0,0 +1,14 @@
1
+ require 'fileutils'
2
+ require "pathname"
3
+ require 'yaml'
4
+ require 'tempfile'
5
+ require "restic/service/version"
6
+ require "restic/service/targets/base"
7
+ require "restic/service/targets/restic"
8
+ require "restic/service/targets/b2"
9
+ require "restic/service/targets/restic_b2"
10
+ require "restic/service/targets/restic_file"
11
+ require "restic/service/targets/restic_sftp"
12
+ require "restic/service/targets/rclone_b2"
13
+ require "restic/service/ssh_keys"
14
+ require "restic/service/conf"
@@ -0,0 +1,88 @@
1
+ require 'thor'
2
+ require 'restic/service'
3
+
4
+ module Restic
5
+ module Service
6
+ DEFAULT_CONF = "/etc/restic-service"
7
+
8
+ class CLI < Thor
9
+ class_option :conf, desc: "path to the configuration file (#{DEFAULT_CONF})",
10
+ type: :string, default: DEFAULT_CONF
11
+
12
+ no_commands do
13
+ def conf_dir_path
14
+ @conf_dir_path ||= Pathname.new(options[:conf])
15
+ end
16
+
17
+ def conf_file_path
18
+ @conf_file_path ||= (conf_dir_path + "conf.yml")
19
+ end
20
+
21
+ def conf_keys_path
22
+ @conf_keys_path ||= (conf_dir_path + "keys")
23
+ end
24
+
25
+ def load_conf
26
+ Conf.load(conf_file_path)
27
+ end
28
+
29
+ def run_sync(conf, *targets)
30
+ has_target = false
31
+ conf.each_target do |target|
32
+ has_target = true
33
+ if !targets.empty? && !targets.include?(target.name)
34
+ next
35
+ end
36
+
37
+ if !target.available?
38
+ puts "#{target.name} is not available"
39
+ next
40
+ end
41
+
42
+ puts
43
+ puts "-----"
44
+ puts "#{Time.now} - Synchronizing #{target.name}"
45
+ target.run
46
+ end
47
+ if !has_target
48
+ STDERR.puts "WARNING: no targets in #{options[:conf]}"
49
+ end
50
+ end
51
+ end
52
+
53
+ desc 'available-targets', 'finds the available backup targets'
54
+ def whereami
55
+ STDOUT.sync = true
56
+ conf = load_conf
57
+ conf.each_target do |target|
58
+ print "#{target.name}: "
59
+ puts(target.available? ? 'yes' : 'no')
60
+ end
61
+ end
62
+
63
+ desc 'sync', 'synchronize all (some) targets'
64
+ def sync(*targets)
65
+ STDOUT.sync = true
66
+ conf = load_conf
67
+ run_sync(conf, *targets)
68
+ end
69
+
70
+ desc 'auto', 'periodically runs the backups, pass target names to restrict to these'
71
+ def auto(*targets)
72
+ STDOUT.sync = true
73
+ conf = load_conf
74
+ loop do
75
+ puts "#{Time.now} Starting automatic synchronization pass"
76
+ puts ""
77
+
78
+ run_sync(conf, *targets)
79
+
80
+ puts ""
81
+ puts "#{Time.now} Finished automatic synchronization pass"
82
+
83
+ sleep conf.period
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,259 @@
1
+ module Restic
2
+ module Service
3
+ # The overall service configuration
4
+ #
5
+ # This is the API side of the service configuration. The configuration
6
+ # is usually stored on disk in YAML and
7
+ #
8
+ # The YAML format is as follows:
9
+ #
10
+ # # The path to the underlying tools
11
+ # tools:
12
+ # restic: /opt/restic
13
+ # rclone: /opt/rclone
14
+ #
15
+ # # The targets. The only generic parts of a target definition
16
+ # # are the name and type. The rest is target-specific
17
+ # targets:
18
+ # - name: a_restic_sftp_target
19
+ # type: restic-sftp
20
+ #
21
+ # See the README.md for more details about available targets
22
+ class Conf
23
+ # Exception raised when using a target name that does not exist
24
+ class NoSuchTarget < RuntimeError; end
25
+ # Exception raised when an invalid configuration file is loaded
26
+ class InvalidConfigurationFile < RuntimeError; end
27
+
28
+ # The default (empty) configuration
29
+ def self.default_conf
30
+ Hash['targets' => [],
31
+ 'period' => 3600,
32
+ 'bandwidth_limit' => nil,
33
+ 'tools' => Hash.new]
34
+ end
35
+
36
+ TARGET_CLASS_FROM_TYPE = Hash[
37
+ 'restic-b2' => Targets::ResticB2,
38
+ 'restic-sftp' => Targets::ResticSFTP,
39
+ 'restic-file' => Targets::ResticFile,
40
+ 'rclone-b2' => Targets::RcloneB2]
41
+
42
+ TOOLS = %w{restic rclone}
43
+
44
+ # Returns the target class that will handle the given target type
45
+ #
46
+ # @param [String] type the type as represented in the YAML file
47
+ def self.target_class_from_type(type)
48
+ if target_class = TARGET_CLASS_FROM_TYPE[type]
49
+ return target_class
50
+ else
51
+ raise InvalidConfigurationFile, "target type #{type} does not exist, available targets: #{TARGET_CLASS_FROM_TYPE.keys.sort.join(", ")}"
52
+ end
53
+ end
54
+
55
+ # Normalizes and validates a configuration hash, as stored in YAML
56
+ #
57
+ # @raise [InvalidConfigurationFile]
58
+ def self.normalize_yaml(yaml)
59
+ yaml = default_conf.merge(yaml)
60
+ TOOLS.each do |tool_name|
61
+ yaml['tools'][tool_name] ||= tool_name
62
+ end
63
+
64
+ target_names = Array.new
65
+ yaml['targets'] = yaml['targets'].map do |target|
66
+ if !target['name']
67
+ raise InvalidConfigurationFile, "missing 'name' field in target"
68
+ elsif !target['type']
69
+ raise InvalidConfigurationFile, "missing 'type' field in target"
70
+ end
71
+
72
+ target_class = target_class_from_type(target['type'])
73
+ if !target_class
74
+ raise InvalidConfigurationFile, "target type #{target['type']} does not exist, available targets: #{TARGET_CLASS_FROM_TYPE.keys.sort.join(", ")}"
75
+ end
76
+
77
+ name = target['name'].to_s
78
+ if target_names.include?(name)
79
+ raise InvalidConfigurationFile, "duplicate target name '#{name}'"
80
+ end
81
+
82
+ target = target.dup
83
+ target['name'] = name
84
+ target = target_class.normalize_yaml(target)
85
+ target_names << name
86
+ target
87
+ end
88
+ yaml
89
+ end
90
+
91
+ # Load a configuration file
92
+ #
93
+ # @param [Pathname]
94
+ # @return [Conf]
95
+ # @raise (see normalize_yaml)
96
+ def self.load(path)
97
+ if !path.file?
98
+ return Conf.new(Pathname.new(""))
99
+ end
100
+
101
+ yaml = YAML.load(path.read) || Hash.new
102
+ yaml = normalize_yaml(yaml)
103
+
104
+ conf = Conf.new(path.dirname)
105
+ conf.load_from_yaml(yaml)
106
+ conf
107
+ end
108
+
109
+ # The configuration path
110
+ #
111
+ # @return [Pathname]
112
+ attr_reader :conf_path
113
+
114
+ # The polling period in seconds
115
+ #
116
+ # Default is 1h (3600s)
117
+ #
118
+ # @return [Integer]
119
+ attr_reader :period
120
+
121
+ # The bandwidth limit in bytes/s
122
+ #
123
+ # Default is nil (none)
124
+ #
125
+ # @return [nil,Integer]
126
+ attr_reader :bandwidth_limit
127
+
128
+ def initialize(conf_path)
129
+ @conf_path = conf_path
130
+ @targets = Hash.new
131
+ @period = 3600
132
+ @tools = Hash.new
133
+ TOOLS.each do |tool_name|
134
+ @tools[tool_name] = find_in_path(tool_name)
135
+ end
136
+ end
137
+
138
+ BANDWIDTH_SCALES = Hash[
139
+ nil => 1,
140
+ 'k' => 1_000,
141
+ 'm' => 1_000_000,
142
+ 'g' => 1_000_000_000]
143
+
144
+ def self.parse_bandwidth_limit(limit)
145
+ if !limit.respond_to?(:to_str)
146
+ return Integer(limit)
147
+ else
148
+ match = /^(\d+)\s*(k|m|g)?$/.match(limit.downcase)
149
+ if match
150
+ return Integer(match[1]) * BANDWIDTH_SCALES.fetch(match[2])
151
+ else
152
+ raise ArgumentError, "cannot interpret '#{limit}' as a valid bandwidth limit, give a plain number in bytes or use the k, M and G suffixes"
153
+ end
154
+ end
155
+ end
156
+
157
+ # The path to the key file for the given target
158
+ def conf_keys_path_for(target)
159
+ conf_path.join("keys", "#{target.name}.keys")
160
+ end
161
+
162
+ # Gets a target configuration
163
+ #
164
+ # @param [String] name the target name
165
+ # @return [Target]
166
+ # @raise NoSuchTarget
167
+ def target_by_name(name)
168
+ if target = @targets[name]
169
+ target
170
+ else
171
+ raise NoSuchTarget, "no target named '#{name}'"
172
+ end
173
+ end
174
+
175
+ # Enumerates the targets
176
+ def each_target(&block)
177
+ @targets.each_value(&block)
178
+ end
179
+
180
+ # Registers a target
181
+ def register_target(target)
182
+ @targets[target.name] = target
183
+ end
184
+
185
+ # @api private
186
+ #
187
+ # Helper that resolves a binary in PATH
188
+ def find_in_path(name)
189
+ ENV['PATH'].split(File::PATH_SEPARATOR).each do |p|
190
+ candidate = Pathname.new(p).join(name)
191
+ if candidate.file?
192
+ return candidate
193
+ end
194
+ end
195
+ nil
196
+ end
197
+
198
+ # Checks whether a given tool is available
199
+ #
200
+ # @param [String]
201
+ # @return [Boolean]
202
+ def tool_available?(tool_name)
203
+ @tools.has_key?(tool_name)
204
+ end
205
+
206
+ # The full path of a given tool
207
+ #
208
+ # @param [String]
209
+ # @return [Pathname]
210
+ def tool_path(tool_name)
211
+ if tool = @tools[tool_name]
212
+ tool
213
+ else
214
+ raise ArgumentError, "cound not find '#{tool_name}'"
215
+ end
216
+ end
217
+
218
+ # Add the information stored in a YAML-like hash into this
219
+ # configuration
220
+ #
221
+ # @param [Hash] the configuration, following the documented
222
+ # configuration format (see {Conf})
223
+ # @return [void]
224
+ def load_from_yaml(yaml)
225
+ load_tools_from_yaml(yaml['tools'])
226
+ @period = Integer(yaml['period'])
227
+ @bandwidth_limit = if limit_yaml = yaml['bandwidth_limit']
228
+ Conf.parse_bandwidth_limit(limit_yaml)
229
+ end
230
+
231
+ yaml['targets'].each do |yaml_target|
232
+ type = yaml_target['type']
233
+ target_class = Conf.target_class_from_type(type)
234
+ target = target_class.new(yaml_target['name'])
235
+ target.setup_from_conf(self, yaml_target)
236
+ register_target(target)
237
+ end
238
+ end
239
+
240
+ # @api private
241
+ #
242
+ # Helper for {#load_from_yaml}
243
+ def load_tools_from_yaml(yaml)
244
+ TOOLS.each do |tool_name|
245
+ tool_path = Pathname.new(yaml[tool_name])
246
+ if tool_path.relative?
247
+ tool_path = find_in_path(tool_path)
248
+ end
249
+ if tool_path && tool_path.file?
250
+ @tools[tool_name] = tool_path
251
+ else
252
+ STDERR.puts "cannot find path to #{tool_name}"
253
+ @tools.delete(tool_name)
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,129 @@
1
+ module Restic
2
+ module Service
3
+ # Interface to the functionality of querying and verifying host keys
4
+ class SSHKeys
5
+ class SSHFailed < RuntimeError; end
6
+ class NoLocalKey < RuntimeError; end
7
+ class ValidationFailed < RuntimeError; end
8
+
9
+ PublicKey = Struct.new :type, :hash do
10
+ def ==(other)
11
+ other.type == type &&
12
+ other.hash == hash
13
+ end
14
+ end
15
+
16
+ # Load a set of SSH keys from a file
17
+ #
18
+ # @return [Array<PublicKey>]
19
+ def self.load_keys_from_file(path)
20
+ load_keys_from_string(path.read)
21
+ end
22
+
23
+ # Load a set of SSH keys from a string
24
+ #
25
+ # @return [Array<PublicKey>]
26
+ def self.load_keys_from_string(string)
27
+ string.each_line.map do |line|
28
+ _, key_type, *rest = line.chomp.split(" ")
29
+ PublicKey.new(key_type, rest.join(" "))
30
+ end
31
+ end
32
+
33
+ # Query the list of keys for this host
34
+ #
35
+ # @param [#host]
36
+ def query_keys(host)
37
+ self.class.load_keys_from_string(ssh_keyscan_host(host))
38
+ end
39
+
40
+ # Query the keys from a host and returns them as a SSH key string
41
+ def ssh_keyscan_host(host)
42
+ ssh_key_run('ssh-keyscan', '-H', host)
43
+ end
44
+
45
+ # Run a ssh subprocess and returns its standard output
46
+ #
47
+ # @raise [SSHFailed] if the subcommand fails
48
+ def ssh_key_run(*args)
49
+ out_pipe_r, out_pipe_w = IO.pipe
50
+ err_pipe_r, err_pipe_w = IO.pipe
51
+ pid = spawn *args, in: :close, err: err_pipe_w, out: out_pipe_w
52
+ out_pipe_w.close
53
+ err_pipe_w.close
54
+ _, status = Process.waitpid2 pid
55
+
56
+ out = out_pipe_r.read
57
+ err_pipe_r.readlines.each do |line|
58
+ if line !~ /^#/
59
+ STDERR.puts line
60
+ end
61
+ end
62
+
63
+ if !status.success?
64
+ t raise SSHFailed, "failed to run #{args}"
65
+ end
66
+ out
67
+ ensure
68
+ out_pipe_r.close
69
+ err_pipe_r.close
70
+ end
71
+
72
+ def ssh_config_path
73
+ Pathname.new(Dir.home) + ".ssh" + "config"
74
+ end
75
+
76
+ def ssh_setup_config(target_name, username, hostname, key_file, ssh_config_path: self.ssh_config_path)
77
+ ssh_config = ssh_cleanup_config(ssh_config_path: ssh_config_path)
78
+
79
+ if ssh_config[-1] && ssh_config[-1] != ''
80
+ ssh_config << ""
81
+ end
82
+ ssh_config_name = "restic-service-host-#{target_name}"
83
+ ssh_config << "# Added by restic-service"
84
+ ssh_config << "Host #{ssh_config_name}"
85
+ ssh_config << " User #{username}"
86
+ ssh_config << " Hostname #{hostname}"
87
+ ssh_config << " UserKnownHostsFile #{key_file}"
88
+
89
+ ssh_config_path.dirname.mkpath
90
+ ssh_config_path.dirname.chmod 0700
91
+ ssh_config_path.open('w') do |io|
92
+ io.puts ssh_config.join("\n")
93
+ end
94
+ ssh_config_path.chmod 0600
95
+ ssh_config_name
96
+ end
97
+
98
+ def ssh_cleanup_config(ssh_config_path: self.ssh_config_path)
99
+ ssh_config =
100
+ if ssh_config_path.file?
101
+ ssh_config_path.read.split("\n").map(&:chomp)
102
+ else
103
+ []
104
+ end
105
+
106
+ _, host_line = ssh_config.each_with_index.
107
+ find { |line, line_i| line.start_with?("Host restic-service-host-") }
108
+ if host_line
109
+ ssh_config.delete_at(host_line)
110
+ while ssh_config[host_line - 1] && ssh_config[host_line - 1].start_with?("#")
111
+ ssh_config.delete_at(host_line - 1)
112
+ host_line -= 1
113
+ end
114
+
115
+ while ssh_config[host_line] && !ssh_config[host_line].start_with?("Host")
116
+ ssh_config.delete_at(host_line)
117
+ end
118
+ end
119
+ if ssh_config_path.file?
120
+ ssh_config_path.open('w') do |io|
121
+ io.puts ssh_config.join("\n")
122
+ end
123
+ end
124
+ ssh_config
125
+ end
126
+ end
127
+ end
128
+ end
129
+
@@ -0,0 +1,38 @@
1
+ module Restic
2
+ module Service
3
+ module Targets
4
+ module B2
5
+ def available?
6
+ true
7
+ end
8
+
9
+ def self.normalize_yaml(yaml)
10
+ %w{bucket path id key}.each do |required_field|
11
+ if !yaml[required_field]
12
+ raise Conf::InvalidConfigurationFile, "missing '#{required_field}' field in target"
13
+ end
14
+ end
15
+ yaml
16
+ end
17
+
18
+ def initialize(*args)
19
+ super
20
+
21
+ @bucket = nil
22
+ @path = nil
23
+ @id = nil
24
+ @key = nil
25
+ end
26
+
27
+ def setup_from_conf(conf, yaml)
28
+ super
29
+
30
+ @bucket = yaml['bucket']
31
+ @path = yaml['path']
32
+ @id = yaml['id']
33
+ @key = yaml['key']
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,23 @@
1
+ module Restic
2
+ module Service
3
+ module Targets
4
+ class Base
5
+ attr_reader :name
6
+
7
+ def initialize(name)
8
+ @name = name
9
+
10
+ @bandwidth_limit = nil
11
+ end
12
+
13
+ def setup_from_conf(conf, yaml)
14
+ @bandwidth_limit =
15
+ if limit = yaml.fetch('bandwidth_limit', conf.bandwidth_limit)
16
+ Conf.parse_bandwidth_limit(limit)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+
@@ -0,0 +1,49 @@
1
+ module Restic
2
+ module Service
3
+ module Targets
4
+ class RcloneB2 < Base
5
+ include B2
6
+
7
+ def self.normalize_yaml(yaml)
8
+ yaml = B2.normalize_yaml(yaml)
9
+ if !yaml['src']
10
+ raise Conf::InvalidConfigurationFile, "no src field provided for rclone-b2"
11
+ elsif !File.directory?(yaml['src'])
12
+ raise Conf::InvalidConfigurationFile, "provided rclone-b2 source #{yaml['src']} does not exist"
13
+ end
14
+ yaml
15
+ end
16
+
17
+ def setup_from_conf(conf, yaml)
18
+ super
19
+ @rclone_path = conf.tool_path('rclone')
20
+ @src = yaml['src']
21
+ @conf_path = conf.conf_path
22
+ end
23
+
24
+ def run
25
+ extra_args = []
26
+ if @bandwidth_limit
27
+ extra_args << '--bwlimit' << @bandwidth_limit.to_s
28
+ end
29
+
30
+ Tempfile.create "rclone-#{@name}", @conf_path.to_path, perm: 0600 do |io|
31
+ io.puts <<-EOCONF
32
+ [restic-service]
33
+ type = b2
34
+ account = #{@id}
35
+ key = #{@key}
36
+ endpoint =
37
+ EOCONF
38
+ io.flush
39
+ system(@rclone_path.to_path,
40
+ '--transfers', '16',
41
+ '--config', io.path,
42
+ *extra_args,
43
+ 'sync', @src, "restic-service:#{@bucket}/#{@path}", in: :close)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,89 @@
1
+ module Restic
2
+ module Service
3
+ module Targets
4
+ # Base class for all restic-based targets
5
+ #
6
+ # See README.md for the YAML configuration file format
7
+ class Restic < Base
8
+ def initialize(name)
9
+ super
10
+
11
+ @password = nil
12
+ @includes = []
13
+ @excludes = []
14
+ @one_filesystem = false
15
+
16
+ @io_class = 3
17
+ @io_priority = 0
18
+ @cpu_priority = 19
19
+ end
20
+
21
+ def one_filesystem?
22
+ @one_filesystem
23
+ end
24
+
25
+ def self.normalize_yaml(yaml)
26
+ yaml = Hash['includes' => [],
27
+ 'excludes' => [],
28
+ 'one_filesystem' => false,
29
+ 'io_class' => 3,
30
+ 'io_priority' => 0,
31
+ 'cpu_priority' => 19].merge(yaml)
32
+ if yaml['includes'].empty?
33
+ raise Conf::InvalidConfigurationFile, "nothing to backup"
34
+ elsif !yaml['password']
35
+ raise Conf::InvalidConfigurationFile, "no password field"
36
+ end
37
+ yaml
38
+ end
39
+
40
+ def setup_from_conf(conf, yaml)
41
+ super
42
+
43
+ @restic_path = conf.tool_path('restic')
44
+ @password = yaml['password']
45
+ @includes = yaml['includes'] || Array.new
46
+ @excludes = yaml['excludes'] || Array.new
47
+ @one_filesystem = !!yaml['one_filesystem']
48
+ @io_class = Integer(yaml['io_class'])
49
+ @io_priority = Integer(yaml['io_priority'])
50
+ @cpu_priority = Integer(yaml['cpu_priority'])
51
+ end
52
+
53
+ def run(*args)
54
+ old_home = ENV['HOME']
55
+ ENV['HOME'] = old_home || '/root'
56
+
57
+ env = if args.first.kind_of?(Hash)
58
+ env = args.shift
59
+ else
60
+ env = Hash.new
61
+ end
62
+
63
+ ionice_args = []
64
+ if @io_class != 3
65
+ ionice_args << '-n' << @io_priority.to_s
66
+ end
67
+
68
+ extra_args = []
69
+ if one_filesystem?
70
+ extra_args << '--one-file-system'
71
+ end
72
+ if @bandwidth_limit
73
+ limit_KiB = @bandwidth_limit / 1000
74
+ extra_args << '--limit-download' << limit_KiB.to_s << '--limit-upload' << limit_KiB.to_s
75
+ end
76
+
77
+ system(Hash['RESTIC_PASSWORD' => @password].merge(env),
78
+ 'ionice', '-c', @io_class.to_s, *ionice_args,
79
+ 'nice', "-#{@cpu_priority}",
80
+ @restic_path.to_path, *args, *extra_args,
81
+ *@excludes.flat_map { |e| ['--exclude', e] },
82
+ *@includes, in: :close)
83
+ ensure
84
+ ENV['HOME'] = old_home
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,21 @@
1
+ module Restic
2
+ module Service
3
+ module Targets
4
+ # A target that backs up to a SFTP target using Restic
5
+ #
6
+ # See README.md for the YAML configuration file format
7
+ class ResticB2 < Restic
8
+ include B2
9
+
10
+ def self.normalize_yaml(yaml)
11
+ yaml = B2.normalize_yaml(yaml)
12
+ super(yaml)
13
+ end
14
+
15
+ def run
16
+ super(Hash['B2_ACCOUNT_ID' => @id, 'B2_ACCOUNT_KEY' => @key], '-r', "b2:#{@bucket}:#{@path}", 'backup')
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,27 @@
1
+ module Restic
2
+ module Service
3
+ module Targets
4
+ class ResticFile < Restic
5
+ def available?
6
+ @dest.directory?
7
+ end
8
+
9
+ def self.normalize_yaml(yaml)
10
+ if !yaml['dest']
11
+ raise ArgumentError, "'dest' field not set in rest-file target"
12
+ end
13
+ super
14
+ end
15
+
16
+ def setup_from_conf(conf, target_yaml)
17
+ super
18
+ @dest = Pathname.new(target_yaml['dest'])
19
+ end
20
+
21
+ def run
22
+ super('-r', @dest.to_path, 'backup')
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,61 @@
1
+ module Restic
2
+ module Service
3
+ module Targets
4
+ # A target that backs up to a SFTP target using Restic
5
+ #
6
+ # See README.md for the YAML configuration file format
7
+ class ResticSFTP < Restic
8
+ def initialize(name)
9
+ super
10
+ @host = nil
11
+ @username = nil
12
+ @path = nil
13
+ @host_keys = []
14
+ end
15
+
16
+ def available?
17
+ ssh = SSHKeys.new
18
+ actual_keys = ssh.query_keys(@host)
19
+ valid?(actual_keys)
20
+ end
21
+
22
+ def self.normalize_yaml(yaml)
23
+ %w{host username path password}.each do |required_field|
24
+ if !yaml[required_field]
25
+ raise Conf::InvalidConfigurationFile, "missing '#{required_field}' field in target"
26
+ end
27
+ end
28
+ super
29
+ end
30
+
31
+ def setup_from_conf(conf, yaml)
32
+ @target_name = yaml['name']
33
+ @key_path = conf.conf_keys_path_for(self)
34
+ @host_keys = SSHKeys.load_keys_from_file(@key_path)
35
+ @host = yaml['host'].to_str
36
+ @username = yaml['username'].to_str
37
+ @path = yaml['path'].to_str
38
+ @password = yaml['password'].to_str
39
+ super
40
+ end
41
+
42
+ def valid?(actual_keys)
43
+ actual_keys.any? { |k| @host_keys.include?(k) }
44
+ end
45
+
46
+ def run
47
+ current_home = ENV['HOME']
48
+ ENV['HOME'] = current_home || '/root'
49
+
50
+ ssh = SSHKeys.new
51
+ ssh_config_name = ssh.ssh_setup_config(@target_name, @username, @host, @key_path)
52
+
53
+ super('-r', "sftp:#{ssh_config_name}:#{@path}", 'backup')
54
+ ensure
55
+ ssh.ssh_cleanup_config
56
+ ENV['HOME'] = current_home
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,5 @@
1
+ module Restic
2
+ module Service
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,29 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "restic/service/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "restic-service"
8
+ spec.version = Restic::Service::VERSION
9
+ spec.authors = ["Sylvain Joyeux"]
10
+ spec.email = ["sylvain.joyeux@m4x.org"]
11
+
12
+ spec.summary = %q{Higher-level management on top of restic to use it as a peridiodic backup tool}
13
+ spec.homepage = "https://github.com/thirteenltda/restic-service"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(test|spec|features)/})
18
+ end
19
+ spec.bindir = "exe"
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_dependency "thor"
24
+
25
+ spec.add_development_dependency "bundler", "~> 1.16"
26
+ spec.add_development_dependency "rake", "~> 10.0"
27
+ spec.add_development_dependency "minitest", "~> 5.0"
28
+ spec.add_development_dependency "flexmock"
29
+ end
@@ -0,0 +1,9 @@
1
+ [Unit]
2
+ Description=service that uses restic to periodically snapshot and backup
3
+
4
+ [Service]
5
+ Type=simple
6
+ ExecStart=/opt/restic-service/bin/restic-service auto
7
+
8
+ [Install]
9
+ WantedBy=default.target
metadata ADDED
@@ -0,0 +1,141 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: restic-service
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sylvain Joyeux
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2017-12-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: thor
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.16'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.16'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '5.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '5.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: flexmock
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description:
84
+ email:
85
+ - sylvain.joyeux@m4x.org
86
+ executables:
87
+ - restic-service
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - ".gitignore"
92
+ - ".travis.yml"
93
+ - Gemfile
94
+ - LICENSE.txt
95
+ - README.md
96
+ - Rakefile
97
+ - bin/console
98
+ - bin/setup
99
+ - exe/restic-service
100
+ - install-dev.sh
101
+ - install.sh
102
+ - lib/restic/service.rb
103
+ - lib/restic/service/cli.rb
104
+ - lib/restic/service/conf.rb
105
+ - lib/restic/service/ssh_keys.rb
106
+ - lib/restic/service/targets/b2.rb
107
+ - lib/restic/service/targets/base.rb
108
+ - lib/restic/service/targets/rclone_b2.rb
109
+ - lib/restic/service/targets/restic.rb
110
+ - lib/restic/service/targets/restic_b2.rb
111
+ - lib/restic/service/targets/restic_file.rb
112
+ - lib/restic/service/targets/restic_sftp.rb
113
+ - lib/restic/service/version.rb
114
+ - restic-service.gemspec
115
+ - restic-service.service
116
+ homepage: https://github.com/thirteenltda/restic-service
117
+ licenses:
118
+ - MIT
119
+ metadata: {}
120
+ post_install_message:
121
+ rdoc_options: []
122
+ require_paths:
123
+ - lib
124
+ required_ruby_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '0'
129
+ required_rubygems_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ requirements: []
135
+ rubyforge_project:
136
+ rubygems_version: 2.5.1
137
+ signing_key:
138
+ specification_version: 4
139
+ summary: Higher-level management on top of restic to use it as a peridiodic backup
140
+ tool
141
+ test_files: []