boat 0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+