boat 0.2

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
+ repositories/
2
+ tmp/
3
+ .*.swp
4
+ .swp
5
+ pkg/*
6
+ *.gem
7
+ .bundle
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source :gemcutter
2
+
3
+ # Specify your gem's dependencies in boat.gemspec
4
+ gemspec
data/README ADDED
File without changes
@@ -0,0 +1,2 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/ruby
2
+
3
+ require 'rubygems'
4
+ require 'boat'
5
+
6
+ case command = ARGV.shift
7
+ when 'server'
8
+ Boat::Server.new.run
9
+
10
+ when 'put'
11
+ Boat::Put.new.run
12
+
13
+ when 'configure', 'config'
14
+ config_file = ARGV.first || Boat::DEFAULT_CLIENT_CONFIGURATION_FILE
15
+
16
+ configuration = File.exists?(config_file) ? YAML.load(IO.read(config_file)) : {}
17
+ puts "Configuring #{config_file}\n\n"
18
+
19
+ [["Username", "username"], ["Key", "key"], ["Hostname", "host"]].each do |title, key|
20
+ print "#{title} [#{configuration[key]}] "
21
+ input = STDIN.gets.strip
22
+ configuration[key] = input unless input.empty?
23
+ end
24
+
25
+ File.open(config_file, "w") {|file| file.write configuration.to_yaml}
26
+
27
+
28
+ else
29
+ puts "Unknown command #{command}\n\n" if command && !command.empty? && command != 'help'
30
+
31
+ puts <<-EOT
32
+ Boat #{Boat::VERSION}
33
+ Copyright 2011 Roger Nesbitt
34
+
35
+ Boat is a file transfer server and client, made for backing up files.
36
+
37
+ Server usage:
38
+
39
+ boat server [-c config_file]
40
+ Starts the boat server.
41
+ Uses /etc/boat.conf if config_file is not specified.
42
+
43
+ Client usage:
44
+
45
+ boat configure [config_file]
46
+ Configures the username, key and hostname to use. If no config
47
+ file is specified, ~/.boat.yml is used by default.
48
+
49
+ boat put [-v] [-c config_file] source_filename [destination_filename]
50
+ Uploads source_filename to the remote server.
51
+ source_filename may be '-' to upload from stdin, but in this case
52
+ a destination_filename must be specified.
53
+
54
+ EOT
55
+ end
@@ -0,0 +1,20 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path("../lib/boat/version", __FILE__)
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "boat"
6
+ s.version = Boat::VERSION
7
+ s.platform = Gem::Platform::RUBY
8
+ s.authors = ["Roger Nesbitt"]
9
+ s.email = []
10
+ s.homepage = "http://rubygems.org/gems/boat"
11
+ s.summary = "File upload client and server specifically aimed at transferring already-encrypted backups"
12
+ s.description = s.summary
13
+
14
+ s.required_rubygems_version = ">= 1.3.6"
15
+ s.rubyforge_project = "boat"
16
+
17
+ s.files = `git ls-files`.split("\n")
18
+ s.executables = `git ls-files`.split("\n").map{|f| f =~ /^bin\/(.*)/ ? $1 : nil}.compact
19
+ s.require_path = 'lib'
20
+ end
@@ -0,0 +1,11 @@
1
+ module Boat
2
+ DEFAULT_PORT = 19184
3
+ DEFAULT_SERVER_CONFIGURATION_FILE = "/etc/boat.conf"
4
+ DEFAULT_CLIENT_CONFIGURATION_FILE = "#{ENV['HOME']}/.boat.yml"
5
+ DEFAULT_STORAGE_DIRECTORY = "/var/lib/boat"
6
+ end
7
+
8
+ require 'boat/version'
9
+ require 'boat/client'
10
+ require 'boat/server'
11
+ require 'boat/put'
@@ -0,0 +1,126 @@
1
+ require 'hmac/sha2'
2
+ require 'socket'
3
+
4
+ class Boat::Client
5
+ Error = Class.new(StandardError)
6
+
7
+ def initialize(username, key, host, opts = {})
8
+ port = opts.fetch(:port, Boat::DEFAULT_PORT)
9
+ @key = key
10
+ @debug = opts.fetch(:debug, false)
11
+ @chunk_size = opts.fetch(:chunk_size, 1048576)
12
+
13
+ puts "[debug] connecting to #{host} port #{port}" if @debug
14
+ @socket = TCPSocket.new(host, port)
15
+ response = socket_gets.to_s
16
+ raise Error, response unless response =~ /^220/
17
+
18
+ puts "[debug] sending username" if @debug
19
+ socket_puts "user #{username}"
20
+ response = socket_gets.to_s
21
+ raise Error, response unless response =~ /^251 HMAC-SHA256 (.+)/
22
+
23
+ puts "[debug] sending password" if @debug
24
+ password_hash = HMAC::SHA256.hexdigest(key, $1)
25
+ socket_puts "pass #{password_hash}"
26
+ response = socket_gets.to_s
27
+ raise Error, response unless response =~ /^250/
28
+ end
29
+
30
+ def put(io, filename, size = nil, hash = nil)
31
+ encoded_filename = CGI.escape(filename)
32
+ puts "[debug] sending put command with filename #{encoded_filename}" if @debug
33
+ socket_puts "put #{encoded_filename}"
34
+ response = socket_gets.to_s
35
+ raise Error, response unless response =~ /^250/
36
+ server_salt = response.strip[4..-1]
37
+
38
+ size ||= io.respond_to?(:stat) ? io.stat.size : io.length
39
+
40
+ hash ||= if io.respond_to?(:path)
41
+ Digest::SHA256.file(io.path)
42
+ elsif !io.respond_to?(:read)
43
+ Digest::SHA256.hexdigest(io)
44
+ else
45
+ "-"
46
+ end
47
+
48
+ client_salt = [Digest::SHA256.digest((0..64).inject("") {|r, i| r << rand(256).chr})].pack("m").strip
49
+ signature = HMAC::SHA256.hexdigest(@key, "#{server_salt}#{encoded_filename}#{size}#{hash}#{client_salt}")
50
+
51
+ puts "[debug] sending data command" if @debug
52
+ socket_puts "data #{size} #{hash} #{client_salt} #{signature}"
53
+ response = socket_gets.to_s
54
+
55
+ # The server might already have the file with this hash - if so it'll return 255 at this point.
56
+ if matches = response.strip.match(/\A255 accepted ([0-9a-f]{64})\z/i)
57
+ confirm_hash = HMAC::SHA256.hexdigest(@key, "#{client_salt}#{hash}")
58
+ if matches[1] != confirm_hash
59
+ raise Error, "Incorrect server signature; the srver may be faking that it received the upload"
60
+ end
61
+ return size
62
+ end
63
+
64
+ raise Error, response unless response =~ /^253/
65
+
66
+ if io.respond_to?(:read)
67
+ digest = Digest::SHA256.new if hash == '-'
68
+ written = 0
69
+ while data = io.read(@chunk_size)
70
+ if @debug
71
+ print "[debug] sending data (#{written} / #{size} bytes)\r"
72
+ STDOUT.flush
73
+ end
74
+ digest << data if hash == '-'
75
+ @socket.write(data)
76
+ written += data.length
77
+ end
78
+ else
79
+ puts "[debug] sending data" if @debug
80
+ @socket.write(io)
81
+ digest << io
82
+ end
83
+
84
+ puts "[debug] data sent (#{size} bytes); waiting for response" if @debug
85
+ response = socket_gets.to_s
86
+
87
+ if response =~ /^254/ # we need to send the hash of the file because we didn't on the DATA line
88
+ hash = digest.to_s
89
+ signature = HMAC::SHA256.hexdigest(@key, "#{server_salt}#{encoded_filename}#{size}#{hash}#{client_salt}")
90
+
91
+ puts "[debug] sending confirm command" if @debug
92
+ socket_puts "confirm #{hash} #{signature}\n"
93
+ response = socket_gets.to_s
94
+ end
95
+
96
+ raise Error, response unless response && matches = response.strip.match(/\A255 accepted ([0-9a-f]{64})\z/i)
97
+
98
+ confirm_hash = HMAC::SHA256.hexdigest(@key, "#{client_salt}#{hash}")
99
+ if matches[1] != confirm_hash
100
+ raise Error, "Incorrect server signature; the srver may be faking that it received the upload"
101
+ end
102
+
103
+ size
104
+ end
105
+
106
+ def quit
107
+ puts "[debug] sending quit" if @debug
108
+ socket_puts "quit"
109
+ response = socket_gets
110
+ @socket.close
111
+ end
112
+
113
+ private
114
+ def socket_gets
115
+ data = @socket.gets
116
+ puts "[debug] < #{data}" if @debug
117
+ data
118
+ end
119
+
120
+ def socket_puts(data)
121
+ result = @socket.puts(data)
122
+ puts "[debug] > #{data}" if @debug
123
+ result
124
+ end
125
+ end
126
+
@@ -0,0 +1,46 @@
1
+ require 'cgi'
2
+ require 'yaml'
3
+
4
+ class Boat::Put
5
+ def run
6
+ while ARGV.first && ARGV.first[0..0] == '-' && ARGV.first.length > 1
7
+ case opt = ARGV.shift
8
+ when '-v' then debug = true
9
+ when '-c' then config_file = ARGV.shift
10
+ else raise "unknown commandline option #{opt}"
11
+ end
12
+ end
13
+
14
+ filename, destination_filename = ARGV
15
+
16
+ if filename == '-' && destination_filename.to_s.empty?
17
+ raise "you must specify a destination_filename if you are uploading from stdin"
18
+ end
19
+
20
+ destination_filename ||= File.basename(filename)
21
+ config_file ||= Boat::DEFAULT_CLIENT_CONFIGURATION_FILE
22
+
23
+ unless File.exists?(config_file)
24
+ raise "#{config_file} does not exist. run boat configure"
25
+ end
26
+
27
+ configuration = YAML.load(IO.read(config_file))
28
+
29
+ if filename != '-' && !File.exists?(filename)
30
+ raise "#{filename} doesn't exist"
31
+ end
32
+
33
+ begin
34
+ client = Boat::Client.new(configuration["username"], configuration["key"], configuration["host"], :debug => debug)
35
+ if filename == '-'
36
+ client.put(STDIN, destination_filename)
37
+ else
38
+ File.open(filename, "r") {|file| client.put(file, destination_filename)}
39
+ end
40
+ client.quit
41
+ rescue Boat::Client::Error => e
42
+ STDERR.puts e.message
43
+ exit(1)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,291 @@
1
+ require 'hmac/sha2'
2
+ require 'eventmachine'
3
+ require 'syslog'
4
+ require 'digest'
5
+ require 'fileutils'
6
+
7
+ class Boat::Server
8
+ ConfigurationError = Class.new(StandardError)
9
+
10
+ attr_reader :configuration
11
+
12
+ module BoatServer
13
+ include EventMachine::Protocols::LineText2
14
+ NextCommand = Class.new(StandardError)
15
+ @@last_connection_id = 0
16
+
17
+ def initialize(configuration)
18
+ @configuration = configuration
19
+ end
20
+
21
+ def post_init
22
+ @@last_connection_id += 1
23
+ @connection_id = @@last_connection_id
24
+ @temporary_files = []
25
+ send_data "220 Boat Server #{Boat::VERSION}\n"
26
+ end
27
+
28
+ def receive_line(line)
29
+ match = line.match(/\A(\S*)(.*)?/)
30
+ command = match[1].downcase
31
+ args = match[2].strip if match[2] && !match[2].strip.empty?
32
+
33
+ begin
34
+ if %w(user pass put get data confirm quit).include?(command)
35
+ send("command_#{command}", args)
36
+ else
37
+ send_data "500 unknown command\n"
38
+ end
39
+ rescue NextCommand
40
+ end
41
+ end
42
+
43
+ def command_user(args)
44
+ if @authenticated
45
+ send_data "500 already authenticated\n"
46
+ elsif args.empty? || args.match(/[^a-z0-9_]/i)
47
+ send_data "500 invalid username\n"
48
+ else
49
+ @username = args
50
+ @login_salt = random_salt
51
+ send_data "251 HMAC-SHA256 #{@login_salt}\n"
52
+ end
53
+ end
54
+
55
+ def command_pass(args)
56
+ if @authenticated
57
+ send_data "500 already authenticated\n"
58
+ elsif @username.nil? || @login_salt.nil?
59
+ send_data "500 USER first\n"
60
+ else
61
+ user = @configuration.fetch("users", {}).fetch(@username, nil)
62
+ expected = HMAC::SHA256.hexdigest(user["key"], @login_salt) if user
63
+ if user && expected && args == expected
64
+ send_data "250 OK\n"
65
+ @user = user
66
+ @authenticated = true
67
+ else
68
+ @username = @login_salt = nil
69
+ send_data "401 invalid username or password\n"
70
+ end
71
+ end
72
+ end
73
+
74
+ def command_put(args)
75
+ check_authenticated!
76
+
77
+ if @user["access"] == "r"
78
+ send_data "400 no write access\n"
79
+ elsif @put
80
+ send_data "500 PUT already sent\n"
81
+ elsif !args.match(/\A[a-z0-9_.%+-]+\z/i) # filenames should be urlencoded
82
+ send_data "500 invalid filename\n"
83
+ else
84
+ if @user.fetch("versioning", true) == false && File.exists?("#{repository_path}/current.#{args}")
85
+ send_data "500 file already exists\n"
86
+ else
87
+ @put = {:state => "PUT", :filename => args, :server_salt => random_salt}
88
+ send_data "250 #{@put[:server_salt]}\n"
89
+ end
90
+ end
91
+ end
92
+
93
+ def command_data(args)
94
+ check_authenticated!
95
+
96
+ if @put.nil?
97
+ send_data "500 PUT first\n"
98
+ elsif @put[:state] != "PUT"
99
+ send_data "500 DATA already sent\n"
100
+ elsif (matches = args.match(/\A([0-9]+) ([0-9a-f]{64}|-) (\S+) ([0-9a-f]{64})\z/i)).nil?
101
+ send_data "500 invalid DATA command line; requires size, hash, new salt and signature\n"
102
+ else
103
+ size = matches[1].to_i
104
+ file_hash = matches[2].downcase
105
+ client_salt = matches[3]
106
+ signature = matches[4].downcase
107
+
108
+ if size >= 1<<31
109
+ send_data "500 size too large\n"
110
+ elsif signature != HMAC::SHA256.hexdigest(@user["key"], "#{@put.fetch(:server_salt)}#{@put.fetch(:filename)}#{size}#{file_hash}#{client_salt}")
111
+ send_data "500 signature is invalid\n"
112
+ elsif File.exists?(current_filename = "#{repository_path}/current.#{@put.fetch(:filename)}") && Digest::SHA256.file(current_filename).to_s == file_hash
113
+ signature = HMAC::SHA256.hexdigest(@user["key"], "#{client_salt}#{file_hash}")
114
+ send_data "255 accepted #{signature}\n"
115
+ else
116
+ @put[:temporary_id] = "#{Time.now.to_i}.#{Process.pid}.#{@connection_id}"
117
+ @put[:temporary_filename] = "#{@configuration["storage_path"]}/tmp/#{@put.fetch(:temporary_id)}"
118
+ @put.merge!(
119
+ :state => "DATA",
120
+ :size => size,
121
+ :hash => (file_hash unless file_hash == '-'),
122
+ :client_salt => client_salt,
123
+ :file_handle => File.open(@put[:temporary_filename], "w"),
124
+ :digest => Digest::SHA256.new)
125
+
126
+ @temporary_files << @put[:temporary_filename]
127
+
128
+ send_data "253 send #{size} bytes now\n"
129
+ set_binary_mode size
130
+ end
131
+ end
132
+ end
133
+
134
+ def receive_binary_data(data)
135
+ @put[:file_handle].write data
136
+ @put[:digest] << data
137
+ end
138
+
139
+ def receive_end_of_binary_data
140
+ @put[:file_handle].close
141
+
142
+ if @put.fetch(:hash).nil?
143
+ @put[:state] = "awaiting CONFIRM"
144
+ send_data "254 send hash confirmation\n"
145
+ else
146
+ complete_put
147
+ end
148
+ end
149
+
150
+ def command_confirm(args)
151
+ if @put.nil? || @put[:state] != "awaiting CONFIRM"
152
+ send_data "500 no need to send CONFIRM\n"
153
+ elsif (matches = args.match(/\A([0-9a-f]{64}) ([0-9a-f]{64})\z/i)).nil?
154
+ send_data "500 invalid CONFIRM command line; requires hash and signature\n"
155
+ else
156
+ file_hash = matches[1].downcase
157
+ signature = matches[2].downcase
158
+
159
+ if signature != HMAC::SHA256.hexdigest(@user["key"], "#{@put.fetch(:server_salt)}#{@put.fetch(:filename)}#{@put.fetch(:size)}#{file_hash}#{@put.fetch(:client_salt)}")
160
+ send_data "500 signature is invalid\n"
161
+ @put = nil
162
+ else
163
+ @put[:hash] = file_hash
164
+ complete_put
165
+ end
166
+ end
167
+ end
168
+
169
+ def complete_put
170
+ calculated_hash = @put.fetch(:digest).to_s
171
+
172
+ if @put.fetch(:hash) != calculated_hash
173
+ send_data "500 file hash does not match hash supplied by client\n"
174
+ File.unlink(@put.fetch(:temporary_filename))
175
+ @temporary_files.delete(@put.fetch(:temporary_filename))
176
+ return
177
+ end
178
+
179
+ FileUtils.mkdir_p(repository_path)
180
+ version_filename = "#{repository_path}/#{@put.fetch(:temporary_id)}.#{@put.fetch(:filename)}"
181
+ symlink_name = "#{repository_path}/current.#{@put.fetch(:filename)}"
182
+
183
+ if @user.fetch("versioning", true) == false && File.exists?(symlink_name)
184
+ send_data "500 file with same filename was uploaded before this upload completed\n"
185
+ File.unlink(@put.fetch(:temporary_filename))
186
+ @temporary_files.delete(@put.fetch(:temporary_filename))
187
+ return
188
+ end
189
+
190
+ File.rename(@put.fetch(:temporary_filename), version_filename)
191
+ @temporary_files.delete(@put.fetch(:temporary_filename))
192
+ begin
193
+ File.unlink(symlink_name) if File.symlink?(symlink_name)
194
+ rescue Errno::ENOENT
195
+ end
196
+ File.symlink(version_filename, symlink_name)
197
+
198
+ signature = HMAC::SHA256.hexdigest(@user["key"], "#{@put.fetch(:client_salt)}#{@put.fetch(:hash)}")
199
+ send_data "255 accepted #{signature}\n"
200
+ ensure
201
+ @put = nil
202
+ end
203
+
204
+ def command_get(args)
205
+ check_authenticated!
206
+ send_data "500 not implemented\n"
207
+ end
208
+
209
+ def command_quit(args)
210
+ send_data "221 bye\n"
211
+ close_connection_after_writing
212
+ end
213
+
214
+ def check_authenticated!
215
+ unless @authenticated
216
+ send_data "500 not authenticated\n"
217
+ raise NextCommand
218
+ end
219
+ end
220
+
221
+ def unbind
222
+ @temporary_files.each do |filename|
223
+ begin
224
+ File.unlink(filename)
225
+ rescue Errno::ENOENT
226
+ end
227
+ end
228
+ end
229
+
230
+ def random_salt
231
+ [Digest::SHA256.digest((0..64).inject("") {|r, i| r << rand(256).chr})].pack("m").strip
232
+ end
233
+
234
+ def repository_path
235
+ @user && "#{@configuration.fetch("storage_path")}/repositories/#{@user.fetch("repository")}"
236
+ end
237
+ end
238
+
239
+ def load_configuration
240
+ unless File.exists?(@config_file)
241
+ raise "configuration file #{config_file} does not exist"
242
+ end
243
+
244
+ configuration = YAML.load(IO.read(@config_file))
245
+ if configuration["users"].nil? || configuration["users"].empty?
246
+ raise "configuration file does not have any users defined in it"
247
+ end
248
+
249
+ configuration["storage_path"] ||= Boat::DEFAULT_STORAGE_DIRECTORY
250
+ FileUtils.mkdir_p("#{configuration["storage_path"]}/tmp")
251
+
252
+ @configuration.update(configuration)
253
+ rescue => e
254
+ raise ConfigurationError, e.message, $@
255
+ end
256
+
257
+ def run
258
+ trap('SIGINT') { exit }
259
+ trap('SIGTERM') { exit }
260
+
261
+ while ARGV.first && ARGV.first[0..0] == '-' && ARGV.first.length > 1
262
+ case opt = ARGV.shift
263
+ when '-c' then @config_file = ARGV.shift
264
+ else raise "unknown commandline option #{opt}"
265
+ end
266
+ end
267
+
268
+ @config_file ||= Boat::DEFAULT_SERVER_CONFIGURATION_FILE
269
+ @configuration = {}
270
+ load_configuration
271
+
272
+ trap('SIGHUP') do
273
+ begin
274
+ load_configuration
275
+ rescue ConfigurationError => e
276
+ STDERR.puts "Could not reload configuration file: #{e.message}"
277
+ end
278
+ end
279
+
280
+ #Syslog.open 'boat'
281
+
282
+ File.umask(0077)
283
+ EventMachine.run do
284
+ EventMachine.start_server(
285
+ @configuration.fetch("listen_address", "localhost"),
286
+ @configuration.fetch("listen_port", Boat::DEFAULT_PORT),
287
+ BoatServer,
288
+ @configuration)
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,3 @@
1
+ module Boat
2
+ VERSION = "0.2"
3
+ end
@@ -0,0 +1,11 @@
1
+ listen_address: localhost
2
+ listen_port: 19184
3
+
4
+ storage_path: /var/lib/boat
5
+
6
+ users:
7
+ roger:
8
+ key: testtest
9
+ access: w
10
+ repository: test
11
+ versioning: true
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: boat
3
+ version: !ruby/object:Gem::Version
4
+ hash: 15
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 2
9
+ version: "0.2"
10
+ platform: ruby
11
+ authors:
12
+ - Roger Nesbitt
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2011-05-30 00:00:00 +12:00
18
+ default_executable:
19
+ dependencies: []
20
+
21
+ description: File upload client and server specifically aimed at transferring already-encrypted backups
22
+ email: []
23
+
24
+ executables:
25
+ - boat
26
+ extensions: []
27
+
28
+ extra_rdoc_files: []
29
+
30
+ files:
31
+ - .gitignore
32
+ - Gemfile
33
+ - README
34
+ - Rakefile
35
+ - bin/boat
36
+ - boat.gemspec
37
+ - lib/boat.rb
38
+ - lib/boat/client.rb
39
+ - lib/boat/put.rb
40
+ - lib/boat/server.rb
41
+ - lib/boat/version.rb
42
+ - server.conf
43
+ has_rdoc: true
44
+ homepage: http://rubygems.org/gems/boat
45
+ licenses: []
46
+
47
+ post_install_message:
48
+ rdoc_options: []
49
+
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ none: false
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ hash: 3
58
+ segments:
59
+ - 0
60
+ version: "0"
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ none: false
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ hash: 23
67
+ segments:
68
+ - 1
69
+ - 3
70
+ - 6
71
+ version: 1.3.6
72
+ requirements: []
73
+
74
+ rubyforge_project: boat
75
+ rubygems_version: 1.4.2
76
+ signing_key:
77
+ specification_version: 3
78
+ summary: File upload client and server specifically aimed at transferring already-encrypted backups
79
+ test_files: []
80
+