netconfgen 0.0.1
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.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/Gemfile +4 -0
- data/Rakefile +35 -0
- data/bin/confgen +99 -0
- data/lib/netconfgen/netconfgen.rb +469 -0
- data/lib/netconfgen/version.rb +4 -0
- data/lib/netconfgen.rb +1 -0
- data/netconfgen.gemspec +21 -0
- data/netconfgen.yaml +4 -0
- data/test/blockreader.rb +37 -0
- data/test/data/file1.txt +6 -0
- data/test/data/file2.txt +9 -0
- data/test/data/variables.txt +4 -0
- data/test/data.json +10 -0
- metadata +104 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: e74877df80cf028688b27e0712cc3d56b15caa44
|
4
|
+
data.tar.gz: 37c95750d391b9f41217cd724f1e08e8e21f5184
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 12e26b6ae98233cca7b0534c3affcf99405410c02adb99cf60ea167feba85865e6d294c182709e036ccdd79c342688f2fe4ec19f2b9a84c65c29310eef39616c
|
7
|
+
data.tar.gz: e334f1c2070bde07d19a111362fc19ceb87a9778c63da993524ed5d2d5d5cebdbf8d5d8f304ddbed051f155203250b34893c52958895c9ea0c4585601535a8b1
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/Rakefile
ADDED
@@ -0,0 +1,35 @@
|
|
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.warning = true
|
24
|
+
t.test_files = FileList['test/*.rb']
|
25
|
+
end
|
26
|
+
|
27
|
+
FileList['test/*.rb'].each do |p|
|
28
|
+
name = p.split('/').last.split('.').first
|
29
|
+
Rake::TestTask.new do |t|
|
30
|
+
t.libs = ['lib', 'test']
|
31
|
+
t.name = "test:#{name}"
|
32
|
+
t.warning = true
|
33
|
+
t.test_files = [p]
|
34
|
+
end
|
35
|
+
end
|
data/bin/confgen
ADDED
@@ -0,0 +1,99 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
$LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib')
|
4
|
+
|
5
|
+
require 'logger'
|
6
|
+
require 'optparse'
|
7
|
+
require 'netconfgen'
|
8
|
+
require 'pp'
|
9
|
+
require 'yaml'
|
10
|
+
require 'json'
|
11
|
+
|
12
|
+
CONFIG_FILE = ["/etc/netconfgen.yaml", "~/.netconfgen.yaml", "netconfgen.yaml"]
|
13
|
+
config = {}
|
14
|
+
CONFIG_FILE.each do |fname|
|
15
|
+
fname = File.expand_path(fname)
|
16
|
+
if File.exists?(fname)
|
17
|
+
config = YAML::load_file(fname)
|
18
|
+
config = config.each_with_object({}) { |(k,v),memo| memo[k.to_sym] = v }
|
19
|
+
break
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
config[:variables] ||= {}
|
24
|
+
config[:root] ||= 'test/data'
|
25
|
+
|
26
|
+
# Defaults
|
27
|
+
op = OptionParser.new do |o|
|
28
|
+
o.banner = "Usage: #{$PROGRAM_NAME} [OPTIONS] MAIN_BLOCK_NAME"
|
29
|
+
o.on('-l', '--listen', 'Run a listening server instead') do
|
30
|
+
config[:listen] = true
|
31
|
+
end
|
32
|
+
o.on('-o [OPTION]', 'Pass a template variable') do |arg|
|
33
|
+
if m = arg.match(/(.+?)=(.+)/)
|
34
|
+
config[:variables][m[1]] = m[2]
|
35
|
+
end
|
36
|
+
end
|
37
|
+
o.on('--json=[VARIABLE=FILE]', 'Load json into a variable') do |arg|
|
38
|
+
if m = arg.match(/(.+?)=(.+)/)
|
39
|
+
data = File.read(m[2])
|
40
|
+
config[:variables][m[1]] = JSON.parse(data)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
o.on('-a', '--address ADDRESS', String,
|
44
|
+
'Address to listen on (default: 0.0.0.0)') do |address|
|
45
|
+
config[:address] = address
|
46
|
+
end
|
47
|
+
o.on('-h', '--http PORT', Integer,
|
48
|
+
'Port to listen on for http (default: 8080)') do |port|
|
49
|
+
abort 'Invalid port' if port < 1 || port > 65535
|
50
|
+
config[:http] = http
|
51
|
+
end
|
52
|
+
o.on('-v', '--verbose', 'Enable verbose output') do
|
53
|
+
config[:verbose] = true
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
config[:name] = op.parse!.first
|
58
|
+
abort 'Last argument must be the main block name' unless config[:name]
|
59
|
+
|
60
|
+
if config[:listen] && config[:port] < 1024 && !Process.euid.zero?
|
61
|
+
abort 'Please run tftpd as root via sudo!'
|
62
|
+
end
|
63
|
+
|
64
|
+
log = Logger.new(STDOUT)
|
65
|
+
log.level = config[:verbose] ? "DEBUG" : "INFO"
|
66
|
+
log.formatter = lambda do |s, d, p, m|
|
67
|
+
"#{d.strftime('%Y-%m-%d %H:%M:%S.%3N')} | #{s.ljust(5)} | #{m}\n"
|
68
|
+
end
|
69
|
+
config[:logger] = log
|
70
|
+
|
71
|
+
if config[:verbose]
|
72
|
+
PP.pp(config, STDERR)
|
73
|
+
end
|
74
|
+
|
75
|
+
if !config[:listen]
|
76
|
+
STDERR.puts "Rendering block #{config[:name]}" if config[:verbose]
|
77
|
+
br = NetConfGen::BlockEngine.new(config[:root])
|
78
|
+
|
79
|
+
config[:variables].each do |k, v|
|
80
|
+
br.set(k, v)
|
81
|
+
end
|
82
|
+
block = br.load(config[:name])
|
83
|
+
puts block.render
|
84
|
+
exit(0)
|
85
|
+
end
|
86
|
+
|
87
|
+
begin
|
88
|
+
log.info "Serving from and to #{config[:path]}"
|
89
|
+
srv = TFTP::Server::RWSimple.new(config[:path], config)
|
90
|
+
srv.run!
|
91
|
+
rescue SignalException => e
|
92
|
+
puts if e.is_a? Interrupt
|
93
|
+
srv.stop
|
94
|
+
end
|
95
|
+
|
96
|
+
if Thread.list.length > 1
|
97
|
+
log.info 'Waiting for outstanding connections'
|
98
|
+
Thread.stop
|
99
|
+
end
|
@@ -0,0 +1,469 @@
|
|
1
|
+
# After https://www.ietf.org/rfc/rfc1350.txt
|
2
|
+
#
|
3
|
+
|
4
|
+
require 'erb'
|
5
|
+
|
6
|
+
|
7
|
+
module NetConfGen
|
8
|
+
|
9
|
+
|
10
|
+
class Block
|
11
|
+
attr_accessor :code, :blockengine
|
12
|
+
|
13
|
+
def initialize(name)
|
14
|
+
@name = name
|
15
|
+
end
|
16
|
+
|
17
|
+
def render
|
18
|
+
t = ERB.new(@code)
|
19
|
+
str = t.result(@blockengine.context.instance_eval { binding })
|
20
|
+
return str
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_s
|
24
|
+
self.render
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
class BlockContext
|
30
|
+
def initialize(blockengine)
|
31
|
+
@blockengine = blockengine
|
32
|
+
end
|
33
|
+
|
34
|
+
def include(name)
|
35
|
+
block = @blockengine.load(name)
|
36
|
+
return block.render
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
class BlockEngine
|
41
|
+
attr_reader :context
|
42
|
+
def initialize(basepath)
|
43
|
+
@basepath = basepath
|
44
|
+
if !File.directory?(basepath)
|
45
|
+
raise "Basepath #{basepath} does not exists"
|
46
|
+
end
|
47
|
+
@suffix = '.txt'
|
48
|
+
|
49
|
+
@blocks = {}
|
50
|
+
|
51
|
+
@context = BlockContext.new(self)
|
52
|
+
end
|
53
|
+
|
54
|
+
def set(key, value)
|
55
|
+
@context.instance_variable_set("@" + key, value)
|
56
|
+
end
|
57
|
+
|
58
|
+
def load(name)
|
59
|
+
if @blocks[name]
|
60
|
+
return @blocks[name]
|
61
|
+
end
|
62
|
+
|
63
|
+
@code = '';
|
64
|
+
File.open(@basepath + '/' + name + @suffix, "r") do |f|
|
65
|
+
code_started = false
|
66
|
+
code = ''
|
67
|
+
f.each_line do |line|
|
68
|
+
if line == "<code>\n"
|
69
|
+
code_started = true
|
70
|
+
elsif line == "</code>\n" || line == "</code>"
|
71
|
+
code_started = false
|
72
|
+
elsif code_started == true
|
73
|
+
code += line
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
block = Block.new(name)
|
78
|
+
block.code = code
|
79
|
+
block.blockengine = self
|
80
|
+
@blocks[name] = block
|
81
|
+
|
82
|
+
return block
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
|
88
|
+
# TFTP-specific errors.
|
89
|
+
class Error < Exception; end
|
90
|
+
# Packet parsing exception.
|
91
|
+
class ParseError < Error; end
|
92
|
+
|
93
|
+
# Packet can parse a binary string into a lightweight object representation.
|
94
|
+
module Packet
|
95
|
+
# Base is a thin layer over a Struct.
|
96
|
+
class Base < Struct
|
97
|
+
# Encode the packet back to binary string.
|
98
|
+
# It uses the #to_str method to properly format each packet, and then forces
|
99
|
+
# 8bit encoding.
|
100
|
+
def encode; to_str.force_encoding('ascii-8bit'); end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Read Request
|
104
|
+
RRQ = Base.new(:filename, :mode)
|
105
|
+
class RRQ
|
106
|
+
# Convert to binary string.
|
107
|
+
def to_str; "\x00\x01" + self.filename + "\x00" + self.mode.to_s + "\x00"; end
|
108
|
+
end
|
109
|
+
|
110
|
+
# Write Request
|
111
|
+
WRQ = Base.new(:filename, :mode)
|
112
|
+
class WRQ
|
113
|
+
def to_str; "\x00\x02" + self.filename + "\x00" + self.mode.to_s + "\x00"; end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Data
|
117
|
+
DATA = Base.new(:seq, :data)
|
118
|
+
class DATA
|
119
|
+
def to_str; "\x00\x03" + [self.seq].pack('n') + self.data; end
|
120
|
+
# Check if this is the last data packet for this session.
|
121
|
+
def last?; self.data.length < 512; end
|
122
|
+
end
|
123
|
+
|
124
|
+
# Acknowledgement
|
125
|
+
ACK = Base.new(:seq)
|
126
|
+
class ACK
|
127
|
+
def to_str; "\x00\x04" + [self.seq].pack('n'); end
|
128
|
+
end
|
129
|
+
|
130
|
+
# Error
|
131
|
+
ERROR = Base.new(:code, :msg)
|
132
|
+
class ERROR
|
133
|
+
def to_str; "\x00\x05" + [self.code].pack('n') + self.msg + "\x00"; end
|
134
|
+
end
|
135
|
+
|
136
|
+
# Parse a binary string into a packet.
|
137
|
+
# Does some sanity checking, can raise a ParseError.
|
138
|
+
def self.parse(data)
|
139
|
+
data = data.force_encoding('ascii-8bit')
|
140
|
+
|
141
|
+
opcode = data.unpack('n').first
|
142
|
+
if opcode < 1 || opcode > 5
|
143
|
+
raise ParseError, "Unknown packet opcode '#{opcode.inspect}'"
|
144
|
+
end
|
145
|
+
|
146
|
+
payload = data.slice(2, data.length - 2)
|
147
|
+
case opcode
|
148
|
+
when 1, 2 # rrq, wrq
|
149
|
+
raise ParseError, 'Not null terminated' if payload.slice(payload.length - 1) != "\x00"
|
150
|
+
xs = payload.split("\x00")
|
151
|
+
raise ParseError, "Not enough elements: #{xs.inspect}" if xs.length < 2
|
152
|
+
filename = xs[0]
|
153
|
+
mode = xs[1].downcase.to_sym
|
154
|
+
raise ParseError, "Unknown mode '#{xs[1].inspect}'" unless [:netascii, :octet].member? mode
|
155
|
+
return RRQ.new(filename, mode) if opcode == 1
|
156
|
+
return WRQ.new(filename, mode)
|
157
|
+
when 3 # data
|
158
|
+
seq = payload.unpack('n').first
|
159
|
+
block = payload.slice(2, payload.length - 2) || ''
|
160
|
+
raise ParseError, "Exceeded block length with #{block.length} bytes" if block.length > 512
|
161
|
+
return DATA.new(seq, block)
|
162
|
+
when 4 # ack
|
163
|
+
raise ParseError, "Wrong payload length with #{payload.length} bytes" if payload.length != 2
|
164
|
+
seq = payload.unpack('n').first
|
165
|
+
return ACK.new(seq)
|
166
|
+
when 5 # error
|
167
|
+
raise ParseError, 'Not null terminated' if payload.slice(payload.length - 1) != "\x00"
|
168
|
+
code = payload.unpack('n').first
|
169
|
+
raise ParseError, "Unknown error code '#{code.inspect}'" if code < 0 || code > 7
|
170
|
+
msg = payload.slice(2, payload.length - 3) || ''
|
171
|
+
return ERROR.new(code, msg)
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
# Handlers implement session-handling logic.
|
177
|
+
module Handler
|
178
|
+
# Base handler contains the common methods for real handlers.
|
179
|
+
class Base
|
180
|
+
# Initialize the handler.
|
181
|
+
#
|
182
|
+
# Options:
|
183
|
+
#
|
184
|
+
# - :logger => logger object (e.g. a Logger instance)
|
185
|
+
# - :timeout => used while waiting for next DATA/ACK packets (default: 5s)
|
186
|
+
#
|
187
|
+
# All given options are saved in @opts.
|
188
|
+
#
|
189
|
+
# @param opts [Hash] Options
|
190
|
+
def initialize(opts = {})
|
191
|
+
@logger = opts[:logger]
|
192
|
+
@timeout = opts[:timeout] || 5
|
193
|
+
@opts = opts
|
194
|
+
end
|
195
|
+
|
196
|
+
# Send data over an established connection.
|
197
|
+
#
|
198
|
+
# Doesn't close neither sock nor io.
|
199
|
+
#
|
200
|
+
# @param tag [String] Tag used for logging
|
201
|
+
# @param sock [UDPSocket] Connected socket
|
202
|
+
# @param io [IO] Object to send data from
|
203
|
+
def send(tag, sock, io)
|
204
|
+
seq = 1
|
205
|
+
begin
|
206
|
+
while not io.eof?
|
207
|
+
block = io.read(512)
|
208
|
+
sock.send(Packet::DATA.new(seq, block).encode, 0)
|
209
|
+
unless IO.select([sock], nil, nil, @timeout)
|
210
|
+
log :warn, "#{tag} Timeout at block ##{seq}"
|
211
|
+
return
|
212
|
+
end
|
213
|
+
msg, _ = sock.recvfrom(4, 0)
|
214
|
+
pkt = Packet.parse(msg)
|
215
|
+
if pkt.class != Packet::ACK
|
216
|
+
log :warn, "#{tag} Expected ACK but got: #{pkt.class}"
|
217
|
+
return
|
218
|
+
end
|
219
|
+
if pkt.seq != seq
|
220
|
+
log :warn, "#{tag} Seq mismatch: #{seq} != #{pkt.seq}"
|
221
|
+
return
|
222
|
+
end
|
223
|
+
# Increment with wrap around at 16 bit boundary,
|
224
|
+
# because of tftp block number field size limit.
|
225
|
+
seq = (seq + 1) & 0xFFFF
|
226
|
+
end
|
227
|
+
sock.send(Packet::DATA.new(seq, '').encode, 0) if io.size % 512 == 0
|
228
|
+
rescue ParseError => e
|
229
|
+
log :warn, "#{tag} Packet parse error: #{e.to_s}"
|
230
|
+
return
|
231
|
+
end
|
232
|
+
log :info, "#{tag} Sent file"
|
233
|
+
end
|
234
|
+
|
235
|
+
# Receive data over an established connection.
|
236
|
+
#
|
237
|
+
# Doesn't close neither sock nor io.
|
238
|
+
# Returns true if whole file has been received, false otherwise.
|
239
|
+
#
|
240
|
+
# @param tag [String] Tag used for logging
|
241
|
+
# @param sock [UDPSocket] Connected socket
|
242
|
+
# @param io [IO] Object to write data to
|
243
|
+
# @return [Boolean]
|
244
|
+
def recv(tag, sock, io)
|
245
|
+
sock.send(Packet::ACK.new(0).encode, 0)
|
246
|
+
seq = 1
|
247
|
+
begin
|
248
|
+
loop do
|
249
|
+
unless IO.select([sock], nil, nil, @timeout)
|
250
|
+
log :warn, "#{tag} Timeout at block ##{seq}"
|
251
|
+
return false
|
252
|
+
end
|
253
|
+
msg, _ = sock.recvfrom(516, 0)
|
254
|
+
pkt = Packet.parse(msg)
|
255
|
+
if pkt.class != Packet::DATA
|
256
|
+
log :warn, "#{tag} Expected DATA but got: #{pkt.class}"
|
257
|
+
return false
|
258
|
+
end
|
259
|
+
if pkt.seq != seq
|
260
|
+
log :warn, "#{tag} Seq mismatch: #{seq} != #{pkt.seq}"
|
261
|
+
return false
|
262
|
+
end
|
263
|
+
io.write(pkt.data)
|
264
|
+
sock.send(Packet::ACK.new(seq).encode, 0)
|
265
|
+
break if pkt.last?
|
266
|
+
seq = (seq + 1) & 0xFFFF
|
267
|
+
end
|
268
|
+
rescue ParseError => e
|
269
|
+
log :warn, "#{tag} Packet parse error: #{e.to_s}"
|
270
|
+
return false
|
271
|
+
end
|
272
|
+
log :info, "#{tag} Received file"
|
273
|
+
true
|
274
|
+
end
|
275
|
+
|
276
|
+
private
|
277
|
+
def log(level, msg)
|
278
|
+
@logger.send(level, msg) if @logger
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
# Basic read-write session over a 'physical' directory.
|
283
|
+
class RWSimple < Base
|
284
|
+
# Initialize the handler.
|
285
|
+
#
|
286
|
+
# Options:
|
287
|
+
#
|
288
|
+
# - :no_read => deny read access if true
|
289
|
+
# - :no_write => deny write access if true
|
290
|
+
#
|
291
|
+
# @param path [String] Path to serving root directory
|
292
|
+
# @param opts [Hash] Options
|
293
|
+
def initialize(path, opts = {})
|
294
|
+
@path = path
|
295
|
+
super(opts)
|
296
|
+
end
|
297
|
+
|
298
|
+
# Handle a session.
|
299
|
+
#
|
300
|
+
# Has to close the socket (and any other resources).
|
301
|
+
# Note that the current version 'guards' against path traversal by a simple
|
302
|
+
# substitution of '..' with '__'.
|
303
|
+
#
|
304
|
+
# @param tag [String] Tag used for logging
|
305
|
+
# @param req [Packet] The initial request packet
|
306
|
+
# @param sock [UDPSocket] Connected socket
|
307
|
+
# @param src [UDPSource] Initial connection information
|
308
|
+
def run!(tag, req, sock, src)
|
309
|
+
name = req.filename.gsub('..', '__')
|
310
|
+
path = File.join(@path, name)
|
311
|
+
|
312
|
+
case req
|
313
|
+
when Packet::RRQ
|
314
|
+
if @opts[:no_read]
|
315
|
+
log :info, "#{tag} Denied read request for #{req.filename}"
|
316
|
+
sock.send(Packet::ERROR.new(2, 'Access denied.').encode, 0)
|
317
|
+
sock.close
|
318
|
+
return
|
319
|
+
end
|
320
|
+
log :info, "#{tag} Read request for #{req.filename} (#{req.mode})"
|
321
|
+
unless File.exist? path
|
322
|
+
log :warn, "#{tag} File not found"
|
323
|
+
sock.send(Packet::ERROR.new(1, 'File not found.').encode, 0)
|
324
|
+
sock.close
|
325
|
+
return
|
326
|
+
end
|
327
|
+
mode = 'r'
|
328
|
+
mode += 'b' if req.mode == :octet
|
329
|
+
io = File.open(path, mode)
|
330
|
+
send(tag, sock, io)
|
331
|
+
sock.close
|
332
|
+
io.close
|
333
|
+
when Packet::WRQ
|
334
|
+
if @opts[:no_write]
|
335
|
+
log :info, "#{tag} Denied write request for #{req.filename}"
|
336
|
+
sock.send(Packet::ERROR.new(2, 'Access denied.').encode, 0)
|
337
|
+
sock.close
|
338
|
+
return
|
339
|
+
end
|
340
|
+
log :info, "#{tag} Write request for #{req.filename} (#{req.mode})"
|
341
|
+
mode = 'w'
|
342
|
+
mode += 'b' if req.mode == :octet
|
343
|
+
io = File.open(path, mode)
|
344
|
+
ok = recv(tag, sock, io)
|
345
|
+
sock.close
|
346
|
+
io.close
|
347
|
+
unless ok
|
348
|
+
log :warn, "#{tag} Removing partial file #{req.filename}"
|
349
|
+
File.delete(path)
|
350
|
+
end
|
351
|
+
end
|
352
|
+
end
|
353
|
+
end
|
354
|
+
end
|
355
|
+
|
356
|
+
# Servers customize the Basic server and perhaps combine it with a handler.
|
357
|
+
module Server
|
358
|
+
# Basic server utilizing threads for handling sessions.
|
359
|
+
#
|
360
|
+
# It lacks a mutex around access to @clients, in case you'd want to stress
|
361
|
+
# test it for 10K or something.
|
362
|
+
#
|
363
|
+
# @attr handler [Handler] Session handler
|
364
|
+
# @attr address [String] Address to listen to
|
365
|
+
# @attr port [Integer] Session dispatcher port
|
366
|
+
# @attr clients [Hash] Current sessions
|
367
|
+
class Base
|
368
|
+
attr_reader :handler, :address, :port, :clients
|
369
|
+
|
370
|
+
# Initialize the server.
|
371
|
+
#
|
372
|
+
# Options:
|
373
|
+
#
|
374
|
+
# - :address => address to listen to (default: '0.0.0.0')
|
375
|
+
# - :port => dispatcher port (default: 69)
|
376
|
+
# - :logger => logger instance
|
377
|
+
#
|
378
|
+
# @param handler [Handler] Initialized session handler
|
379
|
+
# @param opts [Hash] Options
|
380
|
+
def initialize(handler, opts = {})
|
381
|
+
@handler = handler
|
382
|
+
|
383
|
+
@address = opts[:address] || '0.0.0.0'
|
384
|
+
@port = opts[:port] || 69
|
385
|
+
@logger = opts[:logger]
|
386
|
+
|
387
|
+
@clients = Hash.new
|
388
|
+
@run = false
|
389
|
+
end
|
390
|
+
|
391
|
+
# Run the main server loop.
|
392
|
+
#
|
393
|
+
# This is obviously blocking.
|
394
|
+
def run!
|
395
|
+
log :info, "UDP server loop at #{@address}:#{@port}"
|
396
|
+
@run = true
|
397
|
+
Socket.udp_server_loop(@address, @port) do |msg, src|
|
398
|
+
break unless @run
|
399
|
+
|
400
|
+
addr = src.remote_address
|
401
|
+
tag = "[#{addr.ip_address}:#{addr.ip_port.to_s.ljust(5)}]"
|
402
|
+
log :info, "#{tag} New initial packet received"
|
403
|
+
|
404
|
+
begin
|
405
|
+
pkt = Packet.parse(msg)
|
406
|
+
rescue ParseError => e
|
407
|
+
log :warn, "#{tag} Packet parse error: #{e.to_s}"
|
408
|
+
next
|
409
|
+
end
|
410
|
+
|
411
|
+
log :debug, "#{tag} -> PKT: #{pkt.inspect}"
|
412
|
+
tid = get_tid
|
413
|
+
tag = "[#{addr.ip_address}:#{addr.ip_port.to_s.ljust(5)}:#{tid.to_s.ljust(5)}]"
|
414
|
+
sock = addr.connect_from(@address, tid)
|
415
|
+
@clients[tid] = tag
|
416
|
+
|
417
|
+
unless pkt.is_a?(Packet::RRQ) || pkt.is_a?(Packet::WRQ)
|
418
|
+
log :warn, "#{tag} Bad initial packet: #{pkt.class}"
|
419
|
+
sock.send(Packet::ERROR.new(4, 'Illegal TFTP operation.').encode, 0)
|
420
|
+
sock.close
|
421
|
+
next
|
422
|
+
end
|
423
|
+
|
424
|
+
Thread.new do
|
425
|
+
@handler.run!(tag, pkt, sock, src)
|
426
|
+
@clients.delete(tid)
|
427
|
+
log :info, "#{tag} Session ended"
|
428
|
+
end
|
429
|
+
end
|
430
|
+
log :info, 'UDP server loop has stopped'
|
431
|
+
end
|
432
|
+
|
433
|
+
# Stop the main server loop.
|
434
|
+
#
|
435
|
+
# This will allow the currently pending sessions to finish.
|
436
|
+
def stop
|
437
|
+
log :info, 'Stopping UDP server loop'
|
438
|
+
@run = false
|
439
|
+
UDPSocket.new.send('break', 0, @address, @port)
|
440
|
+
end
|
441
|
+
|
442
|
+
private
|
443
|
+
# Get the server's TID.
|
444
|
+
#
|
445
|
+
# The TID is basically a random port number we will use for a session.
|
446
|
+
# This actually tries to get a unique TID per session.
|
447
|
+
# It uses only ports 1024 - 65535 as not to require root.
|
448
|
+
def get_tid
|
449
|
+
tid = 1024 + rand(64512)
|
450
|
+
tid = 1024 + rand(64512) while @clients.has_key? tid
|
451
|
+
tid
|
452
|
+
end
|
453
|
+
|
454
|
+
def log(level, msg)
|
455
|
+
@logger.send(level, msg) if @logger
|
456
|
+
end
|
457
|
+
end
|
458
|
+
|
459
|
+
# Basic read-write TFTP server.
|
460
|
+
#
|
461
|
+
# This is what most other TFTPd implementations give you.
|
462
|
+
class RWSimple < Base
|
463
|
+
def initialize(path, opts = {})
|
464
|
+
handler = Handler::RWSimple.new(path, opts)
|
465
|
+
super(handler, opts)
|
466
|
+
end
|
467
|
+
end
|
468
|
+
end
|
469
|
+
end
|
data/lib/netconfgen.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'netconfgen/netconfgen'
|
data/netconfgen.gemspec
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
#
|
3
|
+
|
4
|
+
Gem::Specification.new do |s|
|
5
|
+
s.name = 'netconfgen'
|
6
|
+
s.version = '0.0.1'
|
7
|
+
s.date = Time.now
|
8
|
+
|
9
|
+
s.summary = %q{Template based config generation}
|
10
|
+
s.files = `git ls-files`.split("\n")
|
11
|
+
s.executables = ['confgen']
|
12
|
+
s.test_files = s.files.grep(%r{^test/})
|
13
|
+
s.require_paths = ['lib']
|
14
|
+
s.authors = "Juho Mäkinen juho.makinen@gmail.com"
|
15
|
+
|
16
|
+
s.required_ruby_version = '>= 2.1.0'
|
17
|
+
|
18
|
+
s.add_development_dependency 'rubygems-tasks', '~> 0.2'
|
19
|
+
s.add_development_dependency 'minitest', '~> 5.4'
|
20
|
+
s.add_development_dependency 'rake', '~> 10.0'
|
21
|
+
end
|
data/netconfgen.yaml
ADDED
data/test/blockreader.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'minitest/autorun'
|
2
|
+
|
3
|
+
require 'netconfgen'
|
4
|
+
|
5
|
+
class BlockEngine < Minitest::Test
|
6
|
+
|
7
|
+
def test_block_loading
|
8
|
+
|
9
|
+
br = NetConfGen::BlockEngine.new('test/data')
|
10
|
+
block = br.load('file1')
|
11
|
+
assert_equal block.code, "! this is a code module\n"
|
12
|
+
end
|
13
|
+
|
14
|
+
def test_block_variables
|
15
|
+
|
16
|
+
br = NetConfGen::BlockEngine.new('test/data')
|
17
|
+
|
18
|
+
br.set('foo', 1)
|
19
|
+
br.set('bar', 'hello')
|
20
|
+
block = br.load('variables')
|
21
|
+
assert_equal block.render, "foo: 1\nbar: hello\n"
|
22
|
+
end
|
23
|
+
|
24
|
+
|
25
|
+
def test_recursive_block_rendering
|
26
|
+
br = NetConfGen::BlockEngine.new('test/data')
|
27
|
+
block = br.load('file2')
|
28
|
+
|
29
|
+
assert_equal block.render,
|
30
|
+
%{! main
|
31
|
+
! this is a code module
|
32
|
+
|
33
|
+
! main again
|
34
|
+
}
|
35
|
+
|
36
|
+
end
|
37
|
+
end
|
data/test/data/file1.txt
ADDED
data/test/data/file2.txt
ADDED
data/test/data.json
ADDED
metadata
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: netconfgen
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Juho Mäkinen juho.makinen@gmail.com
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2019-07-06 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
|
+
- !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
|
+
description:
|
56
|
+
email:
|
57
|
+
executables:
|
58
|
+
- confgen
|
59
|
+
extensions: []
|
60
|
+
extra_rdoc_files: []
|
61
|
+
files:
|
62
|
+
- ".gitignore"
|
63
|
+
- Gemfile
|
64
|
+
- Rakefile
|
65
|
+
- bin/confgen
|
66
|
+
- lib/netconfgen.rb
|
67
|
+
- lib/netconfgen/netconfgen.rb
|
68
|
+
- lib/netconfgen/version.rb
|
69
|
+
- netconfgen.gemspec
|
70
|
+
- netconfgen.yaml
|
71
|
+
- test/blockreader.rb
|
72
|
+
- test/data.json
|
73
|
+
- test/data/file1.txt
|
74
|
+
- test/data/file2.txt
|
75
|
+
- test/data/variables.txt
|
76
|
+
homepage:
|
77
|
+
licenses: []
|
78
|
+
metadata: {}
|
79
|
+
post_install_message:
|
80
|
+
rdoc_options: []
|
81
|
+
require_paths:
|
82
|
+
- lib
|
83
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
84
|
+
requirements:
|
85
|
+
- - ">="
|
86
|
+
- !ruby/object:Gem::Version
|
87
|
+
version: 2.1.0
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
89
|
+
requirements:
|
90
|
+
- - ">="
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: '0'
|
93
|
+
requirements: []
|
94
|
+
rubyforge_project:
|
95
|
+
rubygems_version: 2.5.2
|
96
|
+
signing_key:
|
97
|
+
specification_version: 4
|
98
|
+
summary: Template based config generation
|
99
|
+
test_files:
|
100
|
+
- test/blockreader.rb
|
101
|
+
- test/data.json
|
102
|
+
- test/data/file1.txt
|
103
|
+
- test/data/file2.txt
|
104
|
+
- test/data/variables.txt
|