restic-service 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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: []