fx-tftp 0.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.document +2 -0
- data/.gitignore +3 -0
- data/.travis.yml +8 -0
- data/.yardopts +5 -0
- data/LICENSE.txt +23 -0
- data/README.md +62 -0
- data/Rakefile +37 -0
- data/bin/tftpd +60 -0
- data/fx-tftp.gemspec +26 -0
- data/lib/tftp.rb +2 -0
- data/lib/tftp/tftp.rb +371 -0
- data/lib/tftp/version.rb +4 -0
- data/test/packet.rb +110 -0
- metadata +88 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 39beafe0ad28d4186166ddaa02fdb501c2d303f3
|
4
|
+
data.tar.gz: 4069a701c1205324e86eae44e8153fd377ee1b0a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1f97391b5831198c2528ab81200c0b4175c70f1de238ceafbd03d4e5e6809f18b3568d8873db2db649163eef3615270252036733ec535a7f05dd15511346b364
|
7
|
+
data.tar.gz: b22a2355500c4cb7e7f3a9bfdb66fc7200edf70190ab05bd58baa6ab81a7aa79b6c6de13cfb13d726c154a454160882e09dd63468f7910ef378147eeaab5ed32
|
data/.document
ADDED
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/.yardopts
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
Copyright (c) 2015, Piotr S. Staszewski
|
2
|
+
All rights reserved.
|
3
|
+
|
4
|
+
Redistribution and use in source and binary forms, with or without
|
5
|
+
modification, are permitted provided that the following conditions are met:
|
6
|
+
|
7
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
8
|
+
list of conditions and the following disclaimer.
|
9
|
+
|
10
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
11
|
+
this list of conditions and the following disclaimer in the documentation
|
12
|
+
and/or other materials provided with the distribution.
|
13
|
+
|
14
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
15
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
16
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
17
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
18
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
19
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
20
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
21
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
22
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
23
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
# FX-TFTP [![Build Status](https://travis-ci.org/drbig/fx-tftp.svg?branch=master)](https://travis-ci.org/drbig/fx-tftp) [![Gem](http://img.shields.io/gem/v/fx-tftp.svg)](https://rubygems.org/gems/fx-tftp) [![Yard Docs](http://img.shields.io/badge/yard-docs-blue.svg)](http://www.rubydoc.info/github/drbig/fx-tftp/master)
|
2
|
+
|
3
|
+
* [Homepage](https://github.com/drbig/fx-tftp)
|
4
|
+
* [Documentation](http://rubydoc.info/gems/fx-tftp/frames)
|
5
|
+
|
6
|
+
## Description
|
7
|
+
|
8
|
+
FX-TFTP is a slightly over-OO-ed pure-Ruby implementation of plain [RFC1350](https://www.ietf.org/rfc/rfc1350.txt) TFTP *server*. It is very flexible and intended for hacking. Also, and more importantly, it **works**, contrary to other gems that are occupying space at RubyGems.
|
9
|
+
|
10
|
+
That flexibility may be useful if you're planning on massive custom TFTP-based boots, or if you're into ~~hacking~~ researching cheap router security. The request packets parsing has been relaxed so that it should work with TFTP clients that use some fancy extensions. I have tested the server on Linux x86_64 with Ruby 2.2.0 and on FreeBSD amd64 with Ruby 1.9.3p194, and it successfully exchanged data both ways with clients running on numerous platforms.
|
11
|
+
|
12
|
+
The included `tftpd` executable gives you a fully-fledged read-write TFTP server that also does logging, daemon mode and does not crap out on `SIGTERM`.
|
13
|
+
|
14
|
+
## Hacking
|
15
|
+
|
16
|
+
Suppose we want to have a TFTP server that only supports reading, but the files served should depend on the IP block the client is connecting from:
|
17
|
+
|
18
|
+
class CustomHandler < TFTP::Handler::RWSimple
|
19
|
+
def run!(tag, req, sock, src)
|
20
|
+
if req.is_a? TFTP::Packet::WRQ
|
21
|
+
sock.send(TFTP::Packet::ERROR.new(4, 'Nope').encode, 0)
|
22
|
+
sock.close
|
23
|
+
return
|
24
|
+
end
|
25
|
+
|
26
|
+
ip = src.remote_address.ip_address.split('.')
|
27
|
+
block = ip.slice(0, 3).join('-')
|
28
|
+
req.filename = File.join(block, req.filename)
|
29
|
+
log :info, "#{tag} Mapped filename to #{req.filename}"
|
30
|
+
super(tag, req, sock, src)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
srv = TFTP::Server::Base.new(CustomHandler.new(path), opts)
|
35
|
+
|
36
|
+
When you combine filename inspection and `#send` and `#recv` methods working on plain `IO` objects you can easily whip up things like serving dynamically built scripts/binaries/archives based on parameters passed as the requested 'filename'.
|
37
|
+
|
38
|
+
## Included executable
|
39
|
+
|
40
|
+
$ tftpd
|
41
|
+
Usage: tftpd [OPTIONS] PORT
|
42
|
+
-v, --version Show version and exit
|
43
|
+
-d, --debug Enable debug output
|
44
|
+
-l, --log PATH Log to file
|
45
|
+
-b, --background Fork into background
|
46
|
+
-h, --host HOST Bind do host
|
47
|
+
-p, --path PATH Serving root directory
|
48
|
+
|
49
|
+
## Contributing
|
50
|
+
|
51
|
+
Fell free to contributed patches using the common GitHub model (descried below). I'm also interested in cases where something doesn't work (weird platforms etc.). If you feel like it please write the client side.
|
52
|
+
|
53
|
+
- Fork the repo
|
54
|
+
- Checkout a new branch for your changes
|
55
|
+
- Write code and tests, check, commit
|
56
|
+
- Make a Pull Request
|
57
|
+
|
58
|
+
## Licensing
|
59
|
+
|
60
|
+
Standard two-clause BSD license, see LICENSE.txt for details.
|
61
|
+
|
62
|
+
Copyright (c) 2015 Piotr S. Staszewski
|
data/Rakefile
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'rake/testtask'
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'rubygems/tasks'
|
5
|
+
Gem::Tasks.new
|
6
|
+
rescue LoadError => e
|
7
|
+
warn e.message
|
8
|
+
end
|
9
|
+
|
10
|
+
begin
|
11
|
+
require 'yard'
|
12
|
+
YARD::Rake::YardocTask.new
|
13
|
+
task :doc => :yard
|
14
|
+
rescue LoadError => e
|
15
|
+
warn e.message
|
16
|
+
end
|
17
|
+
|
18
|
+
task :default => :test
|
19
|
+
|
20
|
+
Rake::TestTask.new do |t|
|
21
|
+
t.libs = ['lib', 'test']
|
22
|
+
t.name = 'test'
|
23
|
+
t.description = 'Run all tests'
|
24
|
+
t.warning = true
|
25
|
+
t.test_files = FileList['test/*.rb']
|
26
|
+
end
|
27
|
+
|
28
|
+
FileList['test/*.rb'].each do |p|
|
29
|
+
name = p.split('/').last.split('.').first
|
30
|
+
Rake::TestTask.new do |t|
|
31
|
+
t.libs = ['lib', 'test']
|
32
|
+
t.name = "test:#{name}"
|
33
|
+
t.description = "Run only #{name} specific tests"
|
34
|
+
t.warning = true
|
35
|
+
t.test_files = [p]
|
36
|
+
end
|
37
|
+
end
|
data/bin/tftpd
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
#
|
3
|
+
|
4
|
+
$LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib')
|
5
|
+
|
6
|
+
require 'logger'
|
7
|
+
require 'optparse'
|
8
|
+
require 'tftp'
|
9
|
+
|
10
|
+
config = {:path => Dir.pwd, :host => '127.0.0.1', :fork => false,
|
11
|
+
:ver => false, :loglevel => Logger::INFO, :logfile => STDOUT}
|
12
|
+
|
13
|
+
def die!(msg)
|
14
|
+
STDERR.puts msg
|
15
|
+
exit(2)
|
16
|
+
end
|
17
|
+
|
18
|
+
op = OptionParser.new do |o|
|
19
|
+
o.banner = "Usage: #{$PROGRAM_NAME} [OPTIONS] PORT"
|
20
|
+
o.on('-v', '--version', 'Show version and exit') { config[:ver] = true }
|
21
|
+
o.on('-d', '--debug', 'Enable debug output') { config[:loglevel] = Logger::DEBUG }
|
22
|
+
o.on('-l', '--log PATH', String, 'Log to file') {|a| config[:logfile] = a }
|
23
|
+
o.on('-b', '--background', 'Fork into background') {|a| config[:fork] = true }
|
24
|
+
o.on('-h', '--host HOST', String, 'Bind do host') {|a| config[:host] = a }
|
25
|
+
o.on('-p', '--path PATH', String, 'Serving root directory') {|a| config[:path] = a }
|
26
|
+
end
|
27
|
+
op.parse! or die!(op)
|
28
|
+
|
29
|
+
if config[:ver]
|
30
|
+
puts "fx-tftpd v#{TFTP::VERSION} Copyright (c) 2015, Piotr S. Staszewski"
|
31
|
+
exit
|
32
|
+
end
|
33
|
+
|
34
|
+
die!('Serving root does not exists') unless File.exists? config[:path]
|
35
|
+
|
36
|
+
PORT = ARGV.shift.to_i
|
37
|
+
die!(op) if PORT < 1 || PORT > 65535
|
38
|
+
|
39
|
+
log = Logger.new(config[:logfile])
|
40
|
+
log.level = config[:loglevel]
|
41
|
+
log.formatter = lambda {|s, d, p, m| "#{d.strftime('%Y-%m-%d %H:%M:%S.%3N')} | #{s.ljust(5)} | #{m}\n" }
|
42
|
+
|
43
|
+
if config[:fork]
|
44
|
+
log.info 'Detaching from the console'
|
45
|
+
Process.daemon(true)
|
46
|
+
end
|
47
|
+
|
48
|
+
begin
|
49
|
+
log.info "Serving from and to #{config[:path]}"
|
50
|
+
srv = TFTP::Server::RWSimple.new(config[:path], :host => config[:host], :port => PORT, :logger => log)
|
51
|
+
srv.run!
|
52
|
+
rescue SignalException => e
|
53
|
+
puts if e.is_a? Interrupt
|
54
|
+
srv.stop
|
55
|
+
end
|
56
|
+
|
57
|
+
if Thread.list.length > 1
|
58
|
+
log.info 'Waiting for outstanding connections'
|
59
|
+
Thread.stop
|
60
|
+
end
|
data/fx-tftp.gemspec
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
#
|
3
|
+
|
4
|
+
require File.expand_path('../lib/tftp/version', __FILE__)
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = 'fx-tftp'
|
8
|
+
s.version = TFTP::VERSION
|
9
|
+
s.date = Time.now
|
10
|
+
|
11
|
+
s.summary = %q{Hackable and ACTUALLY WORKING pure-Ruby TFTP server}
|
12
|
+
s.description = %q{Got carried away a bit with the OOness of the whole thing, so while it won't be the fastest TFTP server it might be the most flexible, at least for pure-Ruby ones. With all the infastructure already in place adding a client should be a breeze, should anyone need it.}
|
13
|
+
s.license = 'BSD'
|
14
|
+
s.authors = ['Piotr S. Staszewski']
|
15
|
+
s.email = 'p.staszewski@gmail.com'
|
16
|
+
s.homepage = 'https://github.com/drbig/fx-tftp'
|
17
|
+
|
18
|
+
s.files = `git ls-files`.split("\n")
|
19
|
+
s.test_files = s.files.grep(%r{^test/})
|
20
|
+
s.require_paths = ['lib']
|
21
|
+
|
22
|
+
s.required_ruby_version = '>= 1.9.3'
|
23
|
+
|
24
|
+
s.add_development_dependency 'rubygems-tasks', '~> 0.2'
|
25
|
+
s.add_development_dependency 'minitest', '~> 5.4'
|
26
|
+
end
|
data/lib/tftp.rb
ADDED
data/lib/tftp/tftp.rb
ADDED
@@ -0,0 +1,371 @@
|
|
1
|
+
# After https://www.ietf.org/rfc/rfc1350.txt
|
2
|
+
#
|
3
|
+
|
4
|
+
require 'socket'
|
5
|
+
|
6
|
+
module TFTP
|
7
|
+
# TFTP-specific errors.
|
8
|
+
class Error < Exception; end
|
9
|
+
# Packet parsing exception.
|
10
|
+
class ParseError < Error; end
|
11
|
+
|
12
|
+
# Packet can parse a binary string into a lightweight object representation.
|
13
|
+
module Packet
|
14
|
+
# Base is a thin layer over a Struct.
|
15
|
+
class Base < Struct
|
16
|
+
# Encode the packet back to binary string.
|
17
|
+
# It uses the #to_str method to properly format each packet, and then forces
|
18
|
+
# 8bit encoding.
|
19
|
+
def encode; to_str.force_encoding('ascii-8bit'); end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Read Request
|
23
|
+
RRQ = Base.new(:filename, :mode)
|
24
|
+
class RRQ
|
25
|
+
# Convert to binary string.
|
26
|
+
def to_str; "\x00\x01" + self.filename + "\x00" + self.mode.to_s + "\x00"; end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Write Request
|
30
|
+
WRQ = Base.new(:filename, :mode)
|
31
|
+
class WRQ
|
32
|
+
def to_str; "\x00\x02" + self.filename + "\x00" + self.mode.to_s + "\x00"; end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Data
|
36
|
+
DATA = Base.new(:seq, :data)
|
37
|
+
class DATA
|
38
|
+
def to_str; "\x00\x03" + [self.seq].pack('n') + self.data; end
|
39
|
+
# Check if this is the last data packet for this session.
|
40
|
+
def last?; self.data.length < 512; end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Acknowledgement
|
44
|
+
ACK = Base.new(:seq)
|
45
|
+
class ACK
|
46
|
+
def to_str; "\x00\x04" + [self.seq].pack('n'); end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Error
|
50
|
+
ERROR = Base.new(:code, :msg)
|
51
|
+
class ERROR
|
52
|
+
def to_str; "\x00\x05" + [self.code].pack('n') + self.msg + "\x00"; end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Parse a binary string into a packet.
|
56
|
+
# Does some sanity checking, can raise a ParseError.
|
57
|
+
def self.parse(data)
|
58
|
+
data = data.force_encoding('ascii-8bit')
|
59
|
+
|
60
|
+
opcode = data.unpack('n').first
|
61
|
+
if opcode < 1 || opcode > 5
|
62
|
+
raise ParseError, "Unknown packet opcode '#{opcode.inspect}'"
|
63
|
+
end
|
64
|
+
|
65
|
+
payload = data.slice(2, data.length - 2)
|
66
|
+
case opcode
|
67
|
+
when 1, 2 # rrq, wrq
|
68
|
+
raise ParseError, 'Not null terminated' if payload.slice(payload.length - 1) != "\x00"
|
69
|
+
xs = payload.split("\x00")
|
70
|
+
raise ParseError, "Not enough elements: #{xs.inspect}" if xs.length < 2
|
71
|
+
filename = xs[0]
|
72
|
+
mode = xs[1].downcase.to_sym
|
73
|
+
raise ParseError, "Unknown mode '#{xs[1].inspect}'" unless [:netascii, :octet].member? mode
|
74
|
+
return RRQ.new(filename, mode) if opcode == 1
|
75
|
+
return WRQ.new(filename, mode)
|
76
|
+
when 3 # data
|
77
|
+
seq = payload.unpack('n').first
|
78
|
+
block = payload.slice(2, payload.length - 2) || ''
|
79
|
+
raise ParseError, "Exceeded block length with #{block.length} bytes" if block.length > 512
|
80
|
+
return DATA.new(seq, block)
|
81
|
+
when 4 # ack
|
82
|
+
raise ParseError, "Wrong payload length with #{payload.length} bytes" if payload.length != 2
|
83
|
+
seq = payload.unpack('n').first
|
84
|
+
return ACK.new(seq)
|
85
|
+
when 5 # error
|
86
|
+
raise ParseError, 'Not null terminated' if payload.slice(payload.length - 1) != "\x00"
|
87
|
+
code = payload.unpack('n').first
|
88
|
+
raise ParseError, "Unknown error code '#{code.inspect}'" if code < 0 || code > 7
|
89
|
+
msg = payload.slice(2, payload.length - 3) || ''
|
90
|
+
return ERROR.new(code, msg)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Handlers implement session-handling logic.
|
96
|
+
module Handler
|
97
|
+
# Base handler contains the common methods for real handlers.
|
98
|
+
class Base
|
99
|
+
# Initialize the handler.
|
100
|
+
#
|
101
|
+
# Options:
|
102
|
+
#
|
103
|
+
# - :logger => logger object (e.g. a Logger instance)
|
104
|
+
# - :timeout => used while waiting for next DATA/ACK packets (default: 5s)
|
105
|
+
#
|
106
|
+
# All given options are saved in @opts.
|
107
|
+
#
|
108
|
+
# @param opts [Hash] Options
|
109
|
+
def initialize(opts = {})
|
110
|
+
@logger = opts[:logger]
|
111
|
+
@timeout = opts[:timeout] || 5
|
112
|
+
@opts = opts
|
113
|
+
end
|
114
|
+
|
115
|
+
# Send data over an established connection.
|
116
|
+
#
|
117
|
+
# Doesn't close neither sock nor io.
|
118
|
+
#
|
119
|
+
# @param tag [String] Tag used for logging
|
120
|
+
# @param sock [UDPSocket] Connected socket
|
121
|
+
# @param io [IO] Object to send data from
|
122
|
+
def send(tag, sock, io)
|
123
|
+
seq = 1
|
124
|
+
begin
|
125
|
+
while not io.eof?
|
126
|
+
block = io.read(512)
|
127
|
+
sock.send(Packet::DATA.new(seq, block).encode, 0)
|
128
|
+
unless IO.select([sock], nil, nil, @timeout)
|
129
|
+
log :warn, "#{tag} Timeout at block ##{seq}"
|
130
|
+
return
|
131
|
+
end
|
132
|
+
msg, _ = sock.recvfrom(4, 0)
|
133
|
+
pkt = Packet.parse(msg)
|
134
|
+
if pkt.class != Packet::ACK
|
135
|
+
log :warn, "#{tag} Expected ACK but got: #{pkt.class}"
|
136
|
+
return
|
137
|
+
end
|
138
|
+
if pkt.seq != seq
|
139
|
+
log :warn, "#{tag} Seq mismatch: #{seq} != #{pkt.seq}"
|
140
|
+
return
|
141
|
+
end
|
142
|
+
seq += 1
|
143
|
+
end
|
144
|
+
rescue ParseError => e
|
145
|
+
log :warn, "#{tag} Packet parse error: #{e.to_s}"
|
146
|
+
return
|
147
|
+
end
|
148
|
+
log :info, "#{tag} Sent file"
|
149
|
+
end
|
150
|
+
|
151
|
+
# Receive data over an established connection.
|
152
|
+
#
|
153
|
+
# Doesn't close neither sock nor io.
|
154
|
+
# Returns true if whole file has been received, false otherwise.
|
155
|
+
#
|
156
|
+
# @param tag [String] Tag used for logging
|
157
|
+
# @param sock [UDPSocket] Connected socket
|
158
|
+
# @param io [IO] Object to write data to
|
159
|
+
# @return [Boolean]
|
160
|
+
def recv(tag, sock, io)
|
161
|
+
sock.send(Packet::ACK.new(0).encode, 0)
|
162
|
+
seq = 1
|
163
|
+
begin
|
164
|
+
loop do
|
165
|
+
unless IO.select([sock], nil, nil, @timeout)
|
166
|
+
log :warn, "#{tag} Timeout at block ##{seq}"
|
167
|
+
return false
|
168
|
+
end
|
169
|
+
msg, _ = sock.recvfrom(516, 0)
|
170
|
+
pkt = Packet.parse(msg)
|
171
|
+
if pkt.class != Packet::DATA
|
172
|
+
log :warn, "#{tag} Expected DATA but got: #{pkt.class}"
|
173
|
+
return false
|
174
|
+
end
|
175
|
+
if pkt.seq != seq
|
176
|
+
log :warn, "#{tag} Seq mismatch: #{seq} != #{pkt.seq}"
|
177
|
+
return false
|
178
|
+
end
|
179
|
+
io.write(pkt.data)
|
180
|
+
sock.send(Packet::ACK.new(seq).encode, 0)
|
181
|
+
break if pkt.last?
|
182
|
+
seq += 1
|
183
|
+
end
|
184
|
+
rescue ParseError => e
|
185
|
+
log :warn, "#{tag} Packet parse error: #{e.to_s}"
|
186
|
+
return false
|
187
|
+
end
|
188
|
+
log :info, "#{tag} Received file"
|
189
|
+
true
|
190
|
+
end
|
191
|
+
|
192
|
+
private
|
193
|
+
def log(level, msg)
|
194
|
+
@logger.send(level, msg) if @logger
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
# Basic read-write session over a 'physical' directory.
|
199
|
+
class RWSimple < Base
|
200
|
+
# @param path [String] Path to serving root directory
|
201
|
+
def initialize(path, opts = {})
|
202
|
+
@path = path
|
203
|
+
super(opts)
|
204
|
+
end
|
205
|
+
|
206
|
+
# Handle a session.
|
207
|
+
#
|
208
|
+
# Has to close the socket (and any other resources).
|
209
|
+
# Note that the current version 'guards' against path traversal by a simple
|
210
|
+
# substitution of '..' with '__'.
|
211
|
+
#
|
212
|
+
# @param tag [String] Tag used for logging
|
213
|
+
# @param req [Packet] The initial request packet
|
214
|
+
# @param sock [UDPSocket] Connected socket
|
215
|
+
# @param src [UDPSource] Initial connection information
|
216
|
+
def run!(tag, req, sock, src)
|
217
|
+
name = req.filename.gsub('..', '__')
|
218
|
+
path = File.join(@path, name)
|
219
|
+
|
220
|
+
case req
|
221
|
+
when Packet::RRQ
|
222
|
+
log :info, "#{tag} Read request for #{req.filename} (#{req.mode})"
|
223
|
+
unless File.exist? path
|
224
|
+
log :warn, "#{tag} File not found"
|
225
|
+
sock.send(Packet::ERROR.new(1, 'File not found.').encode, 0)
|
226
|
+
sock.close
|
227
|
+
return
|
228
|
+
end
|
229
|
+
mode = 'r'
|
230
|
+
mode += 'b' if req.mode == :octet
|
231
|
+
io = File.open(path, mode)
|
232
|
+
send(tag, sock, io)
|
233
|
+
sock.close
|
234
|
+
io.close
|
235
|
+
when Packet::WRQ
|
236
|
+
log :info, "#{tag} Write request for #{req.filename} (#{req.mode})"
|
237
|
+
if File.exist? path
|
238
|
+
log :warn, "#{tag} File already exist"
|
239
|
+
sock.send(Packet::ERROR.new(6, 'File already exists.').encode, 0)
|
240
|
+
sock.close
|
241
|
+
return
|
242
|
+
end
|
243
|
+
mode = 'w'
|
244
|
+
mode += 'b' if req.mode == :octet
|
245
|
+
io = File.open(path, mode)
|
246
|
+
ok = recv(tag, sock, io)
|
247
|
+
sock.close
|
248
|
+
io.close
|
249
|
+
unless ok
|
250
|
+
log :warn, "#{tag} Removing partial file #{req.filename}"
|
251
|
+
File.delete(path)
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
# Servers customize the Basic server and perhaps combine it with a handler.
|
259
|
+
module Server
|
260
|
+
# Basic server utilizing threads for handling sessions.
|
261
|
+
#
|
262
|
+
# It lacks a mutex around access to @clients, in case you'd want to stress
|
263
|
+
# test it for 10K or something.
|
264
|
+
#
|
265
|
+
# @attr handler [Handler] Session handler
|
266
|
+
# @attr host [String] Host the sockets bind to
|
267
|
+
# @attr port [Integer] Session dispatcher port
|
268
|
+
# @attr clients [Hash] Current sessions
|
269
|
+
class Base
|
270
|
+
attr_reader :handler, :host, :port, :clients
|
271
|
+
|
272
|
+
# Initialize the server.
|
273
|
+
#
|
274
|
+
# Options:
|
275
|
+
#
|
276
|
+
# - :host => host to bind to (default: 127.0.0.1)
|
277
|
+
# - :port => dispatcher port (default: 69)
|
278
|
+
# - :logger => logger instance
|
279
|
+
#
|
280
|
+
# @param handler [Handler] Initialized session handler
|
281
|
+
# @param opts [Hash] Options
|
282
|
+
def initialize(handler, opts = {})
|
283
|
+
@handler = handler
|
284
|
+
|
285
|
+
@host = opts[:host] || '127.0.0.1'
|
286
|
+
@port = opts[:port] || 69
|
287
|
+
@logger = opts[:logger]
|
288
|
+
|
289
|
+
@clients = Hash.new
|
290
|
+
@run = false
|
291
|
+
end
|
292
|
+
|
293
|
+
# Run the main server loop.
|
294
|
+
#
|
295
|
+
# This is obviously blocking.
|
296
|
+
def run!
|
297
|
+
log :info, "UDP server loop at #{@host}:#{@port}"
|
298
|
+
@run = true
|
299
|
+
Socket.udp_server_loop(@host, @port) do |msg, src|
|
300
|
+
break unless @run
|
301
|
+
|
302
|
+
addr = src.remote_address
|
303
|
+
tag = "[#{addr.ip_address}:#{addr.ip_port.to_s.ljust(5)}]"
|
304
|
+
log :info, "#{tag} New initial packet received"
|
305
|
+
|
306
|
+
begin
|
307
|
+
pkt = Packet.parse(msg)
|
308
|
+
rescue ParseError => e
|
309
|
+
log :warn, "#{tag} Packet parse error: #{e.to_s}"
|
310
|
+
next
|
311
|
+
end
|
312
|
+
|
313
|
+
log :debug, "#{tag} -> PKT: #{pkt.inspect}"
|
314
|
+
tid = get_tid
|
315
|
+
tag = "[#{addr.ip_address}:#{addr.ip_port.to_s.ljust(5)}:#{tid.to_s.ljust(5)}]"
|
316
|
+
sock = addr.connect_from(@host, tid)
|
317
|
+
@clients[tid] = tag
|
318
|
+
|
319
|
+
unless pkt.is_a?(Packet::RRQ) || pkt.is_a?(Packet::WRQ)
|
320
|
+
log :warn, "#{tag} Bad initial packet: #{pkt.class}"
|
321
|
+
sock.send(Packet::ERROR.new(4, 'Illegal TFTP operation.').encode, 0)
|
322
|
+
sock.close
|
323
|
+
next
|
324
|
+
end
|
325
|
+
|
326
|
+
Thread.new do
|
327
|
+
@handler.run!(tag, pkt, sock, src)
|
328
|
+
@clients.delete(tid)
|
329
|
+
log :info, "#{tag} Session ended"
|
330
|
+
end
|
331
|
+
end
|
332
|
+
log :info, 'UDP server loop has stopped'
|
333
|
+
end
|
334
|
+
|
335
|
+
# Stop the main server loop.
|
336
|
+
#
|
337
|
+
# This will allow the currently pending sessions to finish.
|
338
|
+
def stop
|
339
|
+
log :info, 'Stopping UDP server loop'
|
340
|
+
@run = false
|
341
|
+
UDPSocket.new.send('break', 0, @host, @port)
|
342
|
+
end
|
343
|
+
|
344
|
+
private
|
345
|
+
# Get the server's TID.
|
346
|
+
#
|
347
|
+
# The TID is basically a random port number we will use for a session.
|
348
|
+
# This actually tries to get a unique TID per session.
|
349
|
+
# It uses only ports 1024 - 65535 as not to require root.
|
350
|
+
def get_tid
|
351
|
+
tid = 1024 + rand(64512)
|
352
|
+
tid = 1024 + rand(64512) while @clients.has_key? tid
|
353
|
+
tid
|
354
|
+
end
|
355
|
+
|
356
|
+
def log(level, msg)
|
357
|
+
@logger.send(level, msg) if @logger
|
358
|
+
end
|
359
|
+
end
|
360
|
+
|
361
|
+
# Basic read-write TFTP server.
|
362
|
+
#
|
363
|
+
# This is what most other TFTPd implementations give you.
|
364
|
+
class RWSimple < Base
|
365
|
+
def initialize(path, opts = {})
|
366
|
+
handler = Handler::RWSimple.new(path, opts)
|
367
|
+
super(handler, opts)
|
368
|
+
end
|
369
|
+
end
|
370
|
+
end
|
371
|
+
end
|
data/lib/tftp/version.rb
ADDED
data/test/packet.rb
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
$LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib')
|
2
|
+
|
3
|
+
require 'minitest/autorun'
|
4
|
+
require 'tftp'
|
5
|
+
|
6
|
+
class Packet < Minitest::Test
|
7
|
+
def test_parse_rrq
|
8
|
+
assert_equal TFTP::Packet::RRQ.new('test.txt', :netascii),
|
9
|
+
TFTP::Packet.parse("\x00\x01test.txt\x00netascii\x00")
|
10
|
+
assert_equal TFTP::Packet::RRQ.new('binary', :octet),
|
11
|
+
TFTP::Packet.parse("\x00\x01binary\x00octet\x00")
|
12
|
+
assert_equal TFTP::Packet::RRQ.new('test.txt', :netascii),
|
13
|
+
TFTP::Packet.parse("\x00\x01test.txt\x00nEtasCIi\x00")
|
14
|
+
assert_equal TFTP::Packet::RRQ.new('binary.exe', :octet),
|
15
|
+
TFTP::Packet.parse("\x00\x01binary.exe\x00OCTET\x00")
|
16
|
+
|
17
|
+
|
18
|
+
assert_raises(TFTP::ParseError) { TFTP::Packet.parse("\x00\x01\x00\x00") }
|
19
|
+
assert_raises(TFTP::ParseError) { TFTP::Packet.parse("\x00\x01\x00\x00\x00") }
|
20
|
+
assert_raises(TFTP::ParseError) { TFTP::Packet.parse("\x00\x01a\x00c\x00c\x00") }
|
21
|
+
assert_raises(TFTP::ParseError) { TFTP::Packet.parse("\x00\x01foo\x00bar\x00") }
|
22
|
+
end
|
23
|
+
|
24
|
+
def test_encode_rrq
|
25
|
+
assert_equal "\x00\x01test.txt\x00netascii\x00",
|
26
|
+
TFTP::Packet::RRQ.new('test.txt', :netascii).encode
|
27
|
+
assert_equal "\x00\x01binary\x00octet\x00",
|
28
|
+
TFTP::Packet::RRQ.new('binary', :octet).encode
|
29
|
+
end
|
30
|
+
|
31
|
+
def test_parse_wrq
|
32
|
+
assert_equal TFTP::Packet::WRQ.new('test.txt', :netascii),
|
33
|
+
TFTP::Packet.parse("\x00\x02test.txt\x00netascii\x00")
|
34
|
+
assert_equal TFTP::Packet::WRQ.new('binary', :octet),
|
35
|
+
TFTP::Packet.parse("\x00\x02binary\x00octet\x00")
|
36
|
+
assert_equal TFTP::Packet::WRQ.new('test.txt', :netascii),
|
37
|
+
TFTP::Packet.parse("\x00\x02test.txt\x00NetascIi\x00")
|
38
|
+
assert_equal TFTP::Packet::WRQ.new('binary', :octet),
|
39
|
+
TFTP::Packet.parse("\x00\x02binary\x00OctEt\x00")
|
40
|
+
|
41
|
+
|
42
|
+
assert_raises(TFTP::ParseError) { TFTP::Packet.parse("\x00\x02\x00\x00\x00") }
|
43
|
+
assert_raises(TFTP::ParseError) { TFTP::Packet.parse("\x00\x02a\x00c\x00c\x00") }
|
44
|
+
assert_raises(TFTP::ParseError) { TFTP::Packet.parse("\x00\x02foo\x00bar\x00") }
|
45
|
+
end
|
46
|
+
|
47
|
+
def test_encode_wrq
|
48
|
+
assert_equal "\x00\x02test.txt\x00netascii\x00",
|
49
|
+
TFTP::Packet::WRQ.new('test.txt', :netascii).encode
|
50
|
+
assert_equal "\x00\x02binary\x00octet\x00",
|
51
|
+
TFTP::Packet::WRQ.new('binary', :octet).encode
|
52
|
+
end
|
53
|
+
|
54
|
+
def test_parse_data
|
55
|
+
assert_equal TFTP::Packet::DATA.new(0, "1234"),
|
56
|
+
TFTP::Packet.parse("\x00\x03\x00\x001234")
|
57
|
+
assert_equal TFTP::Packet::DATA.new(16, ('a' * 512)),
|
58
|
+
TFTP::Packet.parse("\x00\x03\x00\x10" + ('a' * 512))
|
59
|
+
assert TFTP::Packet.parse("\x00\x03\x00\x001234").last?
|
60
|
+
assert_equal TFTP::Packet::DATA.new(16, ''),
|
61
|
+
TFTP::Packet.parse("\x00\x03\x00\x10")
|
62
|
+
assert TFTP::Packet.parse("\x00\x03\x00\x10").last?
|
63
|
+
|
64
|
+
assert_raises(TFTP::ParseError) { TFTP::Packet.parse("\x00\x03\x00\x00" + ('a' * 513)) }
|
65
|
+
end
|
66
|
+
|
67
|
+
def test_encode_data
|
68
|
+
assert_equal "\x00\x03\x00\x001234",
|
69
|
+
TFTP::Packet::DATA.new(0, "1234").encode
|
70
|
+
assert_equal "\x00\x03\x00\x10" + ('a' * 512),
|
71
|
+
TFTP::Packet::DATA.new(16, ('a' * 512)).encode
|
72
|
+
end
|
73
|
+
|
74
|
+
def test_parse_ack
|
75
|
+
assert_equal TFTP::Packet::ACK.new(0),
|
76
|
+
TFTP::Packet.parse("\x00\x04\x00\x00")
|
77
|
+
assert_equal TFTP::Packet::ACK.new(64434),
|
78
|
+
TFTP::Packet.parse("\x00\x04\xfb\xb2")
|
79
|
+
|
80
|
+
|
81
|
+
assert_raises(TFTP::ParseError) { TFTP::Packet.parse("\x00\x04\x00") }
|
82
|
+
assert_raises(TFTP::ParseError) { TFTP::Packet.parse("\x00\x04\x00" + ('A' * 8)) }
|
83
|
+
end
|
84
|
+
|
85
|
+
def test_encode_ack
|
86
|
+
assert_equal "\x00\x04\x00\x00",
|
87
|
+
TFTP::Packet::ACK.new(0).encode
|
88
|
+
assert_equal "\x00\x04\x00\x01",
|
89
|
+
TFTP::Packet::ACK.new(1).encode
|
90
|
+
end
|
91
|
+
|
92
|
+
def test_parse_error
|
93
|
+
assert_equal TFTP::Packet::ERROR.new(0, 'Not defined, see error message (if any).'),
|
94
|
+
TFTP::Packet.parse("\x00\x05\x00\x00Not defined, see error message (if any).\x00")
|
95
|
+
assert_equal TFTP::Packet::ERROR.new(7, 'No such user.'),
|
96
|
+
TFTP::Packet.parse("\x00\x05\x00\x07No such user.\x00")
|
97
|
+
assert_equal TFTP::Packet::ERROR.new(3, ''),
|
98
|
+
TFTP::Packet.parse("\x00\x05\x00\x03\x00")
|
99
|
+
|
100
|
+
assert_raises(TFTP::ParseError) { TFTP::Packet.parse("\x00\x05\x00\xff\x00") }
|
101
|
+
assert_raises(TFTP::ParseError) { TFTP::Packet.parse("\x00\x05\x00\x03") }
|
102
|
+
end
|
103
|
+
|
104
|
+
def test_encode_error
|
105
|
+
assert_equal "\x00\x05\x00\x07No such user.\x00",
|
106
|
+
TFTP::Packet::ERROR.new(7, 'No such user.').encode
|
107
|
+
assert_equal "\x00\x05\x00\x03\x00",
|
108
|
+
TFTP::Packet::ERROR.new(3, '').encode
|
109
|
+
end
|
110
|
+
end
|
metadata
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: fx-tftp
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: '0.3'
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Piotr S. Staszewski
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-01-23 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rubygems-tasks
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0.2'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0.2'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: minitest
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '5.4'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '5.4'
|
41
|
+
description: Got carried away a bit with the OOness of the whole thing, so while it
|
42
|
+
won't be the fastest TFTP server it might be the most flexible, at least for pure-Ruby
|
43
|
+
ones. With all the infastructure already in place adding a client should be a breeze,
|
44
|
+
should anyone need it.
|
45
|
+
email: p.staszewski@gmail.com
|
46
|
+
executables: []
|
47
|
+
extensions: []
|
48
|
+
extra_rdoc_files: []
|
49
|
+
files:
|
50
|
+
- ".document"
|
51
|
+
- ".gitignore"
|
52
|
+
- ".travis.yml"
|
53
|
+
- ".yardopts"
|
54
|
+
- LICENSE.txt
|
55
|
+
- README.md
|
56
|
+
- Rakefile
|
57
|
+
- bin/tftpd
|
58
|
+
- fx-tftp.gemspec
|
59
|
+
- lib/tftp.rb
|
60
|
+
- lib/tftp/tftp.rb
|
61
|
+
- lib/tftp/version.rb
|
62
|
+
- test/packet.rb
|
63
|
+
homepage: https://github.com/drbig/fx-tftp
|
64
|
+
licenses:
|
65
|
+
- BSD
|
66
|
+
metadata: {}
|
67
|
+
post_install_message:
|
68
|
+
rdoc_options: []
|
69
|
+
require_paths:
|
70
|
+
- lib
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: 1.9.3
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
77
|
+
requirements:
|
78
|
+
- - ">="
|
79
|
+
- !ruby/object:Gem::Version
|
80
|
+
version: '0'
|
81
|
+
requirements: []
|
82
|
+
rubyforge_project:
|
83
|
+
rubygems_version: 2.4.5
|
84
|
+
signing_key:
|
85
|
+
specification_version: 4
|
86
|
+
summary: Hackable and ACTUALLY WORKING pure-Ruby TFTP server
|
87
|
+
test_files:
|
88
|
+
- test/packet.rb
|