ffwd-tunnel 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/ffwd-tunnel-agent +516 -0
- data/lib/ffwd/plugin/tunnel/binary_protocol.rb +384 -0
- data/lib/ffwd/plugin/tunnel/connection_tcp.rb +62 -0
- data/lib/ffwd/plugin/tunnel/version.rb +22 -0
- data/lib/ffwd/plugin/tunnel.rb +57 -0
- metadata +92 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 5df1c67ae1513904503ff2db9e7071ec1fff649a
|
4
|
+
data.tar.gz: 07ec0e4930a3f2b8af7c850dd799d73775f83c08
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: e463d7728abf1f437ef965038f41267f4a93161617d9ed77ea4ebdee3b7018e374c12d29a9fb9d0c68624099b81c09487df74aafe2cbe199b0a82be15956e6ba
|
7
|
+
data.tar.gz: 21bfd3d35a7bbef9b6e979fe7b0ab9cbc7a2cc64b5990fe18f3d9a04b40f928a7d329ab454eaaed73fd77b7d62d4b764d6b34b55664c388e0e2c3b6aa7386bc1
|
@@ -0,0 +1,516 @@
|
|
1
|
+
#!/usr/bin/env python
|
2
|
+
"""A reference tunneling proxy for FFWD."""
|
3
|
+
|
4
|
+
import time
|
5
|
+
import json
|
6
|
+
import asyncore
|
7
|
+
import sys
|
8
|
+
import socket
|
9
|
+
import struct
|
10
|
+
import logging
|
11
|
+
import errno
|
12
|
+
import argparse
|
13
|
+
|
14
|
+
log = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
TEXT = object()
|
17
|
+
BINARY = object()
|
18
|
+
UDP = 'udp'
|
19
|
+
TCP = 'tcp'
|
20
|
+
RECV_MAX = 8192
|
21
|
+
DEFAULT_PROTOCOL = "text"
|
22
|
+
PROTOCOL_MAX_DATALEN = 2 ** 16
|
23
|
+
DEFAULT_PORT = 9000
|
24
|
+
|
25
|
+
|
26
|
+
class _BindUDP(asyncore.dispatcher):
|
27
|
+
def __init__(self, family, tunnel, bindaddress, bindport):
|
28
|
+
asyncore.dispatcher.__init__(self)
|
29
|
+
self.create_socket(family, socket.SOCK_DGRAM)
|
30
|
+
self.set_reuse_addr()
|
31
|
+
self.bind((bindaddress, bindport))
|
32
|
+
|
33
|
+
self._family = family
|
34
|
+
self._tunnel = tunnel
|
35
|
+
self._port = bindport
|
36
|
+
|
37
|
+
def writable(self):
|
38
|
+
return False
|
39
|
+
|
40
|
+
def handle_read(self):
|
41
|
+
"""implement asyncore.dispatcher#handle_read"""
|
42
|
+
data, addr = self.recvfrom(RECV_MAX)
|
43
|
+
|
44
|
+
self._tunnel.client_data(
|
45
|
+
socket.SOCK_DGRAM, self._family, self._port, addr, data)
|
46
|
+
|
47
|
+
def receive_data(self, addr, data):
|
48
|
+
family, addr, port = addr
|
49
|
+
self.sendto(data, (addr, port))
|
50
|
+
|
51
|
+
|
52
|
+
class _BindTCP(asyncore.dispatcher):
|
53
|
+
class Connection(asyncore.dispatcher_with_send):
|
54
|
+
def __init__(self, bind, sock, addr):
|
55
|
+
asyncore.dispatcher_with_send.__init__(self, sock)
|
56
|
+
self.bind = bind
|
57
|
+
self.addr = addr
|
58
|
+
|
59
|
+
def handle_close(self):
|
60
|
+
"""implement asyncore.dispatcher_with_send#handle_close."""
|
61
|
+
self.bind.conn_handle_close(self.addr)
|
62
|
+
|
63
|
+
def handle_error(self):
|
64
|
+
"""implement asyncore.dispatcher_with_send#handle_error."""
|
65
|
+
|
66
|
+
def handle_read(self):
|
67
|
+
"""implement asyncore.dispatcher#handle_read."""
|
68
|
+
self.bind.conn_handle_data(self.addr, self.recv(RECV_MAX))
|
69
|
+
|
70
|
+
def __init__(self, family, tunnel, bindaddress, bindport):
|
71
|
+
asyncore.dispatcher.__init__(self)
|
72
|
+
self.create_socket(family, socket.SOCK_STREAM)
|
73
|
+
self.set_reuse_addr()
|
74
|
+
self.bind((bindaddress, bindport))
|
75
|
+
self.listen(5)
|
76
|
+
|
77
|
+
self._family = family
|
78
|
+
self._tunnel = tunnel
|
79
|
+
self._port = bindport
|
80
|
+
self._connections = dict()
|
81
|
+
|
82
|
+
def handle_close(self):
|
83
|
+
"""implement asyncore.dispatcher#handle_close."""
|
84
|
+
self.close()
|
85
|
+
|
86
|
+
def handle_accept(self):
|
87
|
+
"""implement asyncore.dispatcher#handle_accept."""
|
88
|
+
pair = self.accept()
|
89
|
+
|
90
|
+
if pair is not None:
|
91
|
+
sock, addr = pair
|
92
|
+
log.debug("open tcp/%d: %s:%d", self._port, addr[0], addr[1])
|
93
|
+
self._connections[addr] = self.Connection(self, sock, addr)
|
94
|
+
self.client_state(addr, Protocol.OPEN)
|
95
|
+
|
96
|
+
def conn_handle_data(self, addr, data):
|
97
|
+
"""Receive data from TCP connections."""
|
98
|
+
self.client_data(addr, data)
|
99
|
+
|
100
|
+
def conn_handle_close(self, addr):
|
101
|
+
"""Remove the client connection associated with addr."""
|
102
|
+
log.debug("closed tcp/%d: %s:%d", self._port, addr[0], addr[1])
|
103
|
+
|
104
|
+
client = self._connections[addr]
|
105
|
+
client.close()
|
106
|
+
|
107
|
+
del self._connections[addr]
|
108
|
+
self.client_state(addr, Protocol.CLOSE)
|
109
|
+
|
110
|
+
def client_data(self, addr, data):
|
111
|
+
self._tunnel.client_data(
|
112
|
+
socket.SOCK_STREAM, self._family, self._port, addr, data)
|
113
|
+
|
114
|
+
def client_state(self, addr, state):
|
115
|
+
self._tunnel.client_state(
|
116
|
+
socket.SOCK_STREAM, self._family, self._port, addr, state)
|
117
|
+
|
118
|
+
def close(self):
|
119
|
+
for client in self._connections.values():
|
120
|
+
client.close()
|
121
|
+
|
122
|
+
self._connections = {}
|
123
|
+
asyncore.dispatcher.close(self)
|
124
|
+
|
125
|
+
def receive_data(self, addr, data):
|
126
|
+
try:
|
127
|
+
client = self._connections[addr]
|
128
|
+
except KeyError:
|
129
|
+
log.error("no such client: %s", addr)
|
130
|
+
self.close()
|
131
|
+
return
|
132
|
+
|
133
|
+
client.send(data)
|
134
|
+
|
135
|
+
|
136
|
+
class _LineProtocol(object):
|
137
|
+
delimiter = '\n'
|
138
|
+
|
139
|
+
buffer_limit = 1048576
|
140
|
+
|
141
|
+
def __init__(self):
|
142
|
+
self._lp_buffer = ""
|
143
|
+
self._lp_size = 0
|
144
|
+
self._lp_limit = self.buffer_limit
|
145
|
+
|
146
|
+
def set_mode(self, size):
|
147
|
+
self._lp_size = size
|
148
|
+
|
149
|
+
def handle_read(self):
|
150
|
+
"""implement asyncore.dispatcher#handle_read."""
|
151
|
+
|
152
|
+
data = self.recv(RECV_MAX)
|
153
|
+
|
154
|
+
if len(self._lp_buffer) + len(data) > self._lp_limit:
|
155
|
+
log.error("buffer limit reached, closing connection")
|
156
|
+
self.close()
|
157
|
+
return
|
158
|
+
|
159
|
+
if self._lp_size == 0:
|
160
|
+
self._handle_line(data)
|
161
|
+
else:
|
162
|
+
self._handle_text(data)
|
163
|
+
|
164
|
+
def _handle_line(self, data):
|
165
|
+
while True:
|
166
|
+
try:
|
167
|
+
i = data.index(self.delimiter)
|
168
|
+
except ValueError:
|
169
|
+
break
|
170
|
+
|
171
|
+
try:
|
172
|
+
self.receive_line(self._lp_buffer + data[:i])
|
173
|
+
except:
|
174
|
+
log.error("receive_line failed", exc_info=sys.exc_info())
|
175
|
+
self.close()
|
176
|
+
return
|
177
|
+
|
178
|
+
self._lp_buffer = ""
|
179
|
+
data = data[i + 2:]
|
180
|
+
|
181
|
+
if len(data) > 0:
|
182
|
+
self._lp_buffer += data
|
183
|
+
|
184
|
+
def _handle_text(self, data):
|
185
|
+
self._lp_buffer += data
|
186
|
+
|
187
|
+
while len(self._lp_buffer) >= self._lp_size:
|
188
|
+
size = self._lp_size
|
189
|
+
self._lp_size = 0
|
190
|
+
|
191
|
+
try:
|
192
|
+
self.receive_text(self._lp_buffer[:size])
|
193
|
+
except:
|
194
|
+
log.error("failed to receive text", exc_info=sys.exc_info())
|
195
|
+
self.close()
|
196
|
+
return
|
197
|
+
|
198
|
+
self._lp_buffer = self._lp_buffer[size:]
|
199
|
+
|
200
|
+
def send_line(self, line):
|
201
|
+
"""Send a line of data using the specified delimiter."""
|
202
|
+
self.send(line + self.delimiter)
|
203
|
+
|
204
|
+
|
205
|
+
BIND_PROTOCOLS = {
|
206
|
+
socket.SOCK_STREAM: _BindTCP,
|
207
|
+
socket.SOCK_DGRAM: _BindUDP,
|
208
|
+
}
|
209
|
+
|
210
|
+
|
211
|
+
class Protocol(object):
|
212
|
+
ST_HEADER = struct.Struct("!HHHBB")
|
213
|
+
|
214
|
+
ST_STATE = struct.Struct("!H")
|
215
|
+
ST_PEER_ADDR_AF_INET = struct.Struct("!4sH")
|
216
|
+
ST_PEER_ADDR_AF_INET6 = struct.Struct("!16sH")
|
217
|
+
|
218
|
+
STATE = 0x0000
|
219
|
+
DATA = 0x0001
|
220
|
+
|
221
|
+
OPEN = 0x0000
|
222
|
+
CLOSE = 0x0001
|
223
|
+
|
224
|
+
PACKET_TYPES = {
|
225
|
+
STATE: ST_STATE,
|
226
|
+
DATA: None,
|
227
|
+
}
|
228
|
+
|
229
|
+
def __init__(self, conn):
|
230
|
+
self._header = None
|
231
|
+
self._c = conn
|
232
|
+
|
233
|
+
def parse_st_addr(self, family):
|
234
|
+
if family == socket.AF_INET:
|
235
|
+
return self.ST_PEER_ADDR_AF_INET
|
236
|
+
|
237
|
+
if family == socket.AF_INET6:
|
238
|
+
return self.ST_PEER_ADDR_AF_INET6
|
239
|
+
|
240
|
+
raise Exception("Unsupported family: %d" % (family))
|
241
|
+
|
242
|
+
def peer_addr_pack(self, family, addr):
|
243
|
+
st_addr = self.parse_st_addr(family)
|
244
|
+
ip, port = addr
|
245
|
+
ip = socket.inet_pton(family, ip)
|
246
|
+
return st_addr.pack(ip, port), st_addr.size
|
247
|
+
|
248
|
+
def peer_addr_unpack(self, family, data):
|
249
|
+
st_addr = self.parse_st_addr(family)
|
250
|
+
ip, port = st_addr.unpack(data[:st_addr.size])
|
251
|
+
ip = socket.inet_ntop(family, ip)
|
252
|
+
return (ip, port), st_addr.size
|
253
|
+
|
254
|
+
def client_data(self, protocol, family, port, addr, data):
|
255
|
+
"""Send client DATA."""
|
256
|
+
addr, addr_size = self.peer_addr_pack(family, addr)
|
257
|
+
|
258
|
+
length = self.ST_HEADER.size + addr_size + len(data)
|
259
|
+
|
260
|
+
if length > PROTOCOL_MAX_DATALEN:
|
261
|
+
raise Exception("Maximum possible frame length exceeded")
|
262
|
+
|
263
|
+
header = self.ST_HEADER.pack(length, self.DATA, port, family, protocol)
|
264
|
+
|
265
|
+
frame = header + addr + data
|
266
|
+
self._c.send(frame)
|
267
|
+
|
268
|
+
def client_state(self, protocol, family, port, addr, state):
|
269
|
+
"""Send a client STATE update."""
|
270
|
+
addr_data, addr_size = self.peer_addr_pack(family, addr)
|
271
|
+
|
272
|
+
length = self.ST_HEADER.size + addr_size + self.ST_STATE.size
|
273
|
+
|
274
|
+
if length > PROTOCOL_MAX_DATALEN:
|
275
|
+
raise Exception("Maximum possible frame length exceeded")
|
276
|
+
|
277
|
+
header_data = self.ST_HEADER.pack(
|
278
|
+
length, self.STATE, port, family, protocol)
|
279
|
+
state_data = self.ST_STATE.pack(state)
|
280
|
+
|
281
|
+
frame = header_data + addr_data + state_data
|
282
|
+
self._c.send(frame)
|
283
|
+
|
284
|
+
def setup(self):
|
285
|
+
self._c.set_mode(self.ST_HEADER.size)
|
286
|
+
|
287
|
+
def receive_line(self, line):
|
288
|
+
raise Exception("did not expect line")
|
289
|
+
|
290
|
+
def receive_text(self, data):
|
291
|
+
if self._header is None:
|
292
|
+
self._header = self.ST_HEADER.unpack(data)
|
293
|
+
self._c.set_mode(self._header[0] - self.ST_HEADER.size)
|
294
|
+
return
|
295
|
+
|
296
|
+
length, frame_type, port, family, protocol = self._header
|
297
|
+
addr, addr_size = self.peer_addr_unpack(family, data)
|
298
|
+
rest = data[addr_size:]
|
299
|
+
|
300
|
+
if frame_type == self.DATA:
|
301
|
+
tunnel_id = (family, protocol, port)
|
302
|
+
self._c.receive_data(tunnel_id, addr, rest)
|
303
|
+
else:
|
304
|
+
raise Exception("Unexpected frame type: %d" % (frame_type))
|
305
|
+
|
306
|
+
self._c.set_mode(self.ST_HEADER.size)
|
307
|
+
self._header = None
|
308
|
+
|
309
|
+
|
310
|
+
class TunnelClient(_LineProtocol, asyncore.dispatcher_with_send):
|
311
|
+
def __init__(self, metadata, addr):
|
312
|
+
asyncore.dispatcher_with_send.__init__(self)
|
313
|
+
_LineProtocol.__init__(self)
|
314
|
+
self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
|
315
|
+
self.connect(addr)
|
316
|
+
|
317
|
+
self._metadata = metadata
|
318
|
+
self._tunnels = dict()
|
319
|
+
self._protocol = None
|
320
|
+
self._addr = addr
|
321
|
+
|
322
|
+
def handle_error(self):
|
323
|
+
"""implement asyncore.dispatcher#handle_error."""
|
324
|
+
exc_info = sys.exc_info()
|
325
|
+
e = exc_info[1]
|
326
|
+
|
327
|
+
if isinstance(e, socket.error):
|
328
|
+
if e.errno == errno.ECONNREFUSED:
|
329
|
+
log.warn("connection refused: %s", self._addr)
|
330
|
+
self.close()
|
331
|
+
return
|
332
|
+
|
333
|
+
log.error("error: %s", str(exc_info[1]), exc_info=exc_info)
|
334
|
+
self.close()
|
335
|
+
|
336
|
+
def handle_close(self):
|
337
|
+
"""implement asyncore.dispatcher#handle_close."""
|
338
|
+
log.info("closed")
|
339
|
+
self.close()
|
340
|
+
|
341
|
+
def close(self):
|
342
|
+
for tunnel in self._tunnels.values():
|
343
|
+
tunnel.close()
|
344
|
+
|
345
|
+
self._tunnels = dict()
|
346
|
+
self._protocol = None
|
347
|
+
|
348
|
+
asyncore.dispatcher_with_send.close(self)
|
349
|
+
|
350
|
+
def client_data(self, *args):
|
351
|
+
if self._protocol is None:
|
352
|
+
raise Exception("protocol is not configured")
|
353
|
+
|
354
|
+
self._protocol.client_data(*args)
|
355
|
+
|
356
|
+
def client_state(self, *args):
|
357
|
+
if self._protocol is None:
|
358
|
+
raise Exception("protocol is not configured")
|
359
|
+
|
360
|
+
self._protocol.client_state(*args)
|
361
|
+
|
362
|
+
def _receive_binary(self, protocol, bindport, addr, data):
|
363
|
+
family = addr[0]
|
364
|
+
ip = socket.inet_pton(addr[0], addr[1])
|
365
|
+
port = addr[2]
|
366
|
+
datasize = len(data)
|
367
|
+
|
368
|
+
if datasize > PROTOCOL_MAX_DATALEN:
|
369
|
+
raise Exception("Maximum data length exceeded")
|
370
|
+
|
371
|
+
header = self.ST_HEADER.pack(
|
372
|
+
protocol, bindport, family, ip, port, datasize)
|
373
|
+
|
374
|
+
frame = header + data
|
375
|
+
self.send(frame)
|
376
|
+
|
377
|
+
def handle_connect(self):
|
378
|
+
"""implement asyncore.dispatcher#handle_connect."""
|
379
|
+
log.info("connected")
|
380
|
+
self.send_line(json.dumps(self._metadata))
|
381
|
+
|
382
|
+
def receive_line(self, line):
|
383
|
+
"""implement _LineProtocol#receive_line."""
|
384
|
+
|
385
|
+
if self._protocol is not None:
|
386
|
+
raise Exception("protocol already configured")
|
387
|
+
|
388
|
+
try:
|
389
|
+
self._protocol = self.configure(line)
|
390
|
+
except:
|
391
|
+
log.error("failed to receive line", exc_info=sys.exc_info())
|
392
|
+
self.close()
|
393
|
+
return
|
394
|
+
|
395
|
+
try:
|
396
|
+
self._protocol.setup()
|
397
|
+
except:
|
398
|
+
log.error("failed to setup protocol", exc_info=sys.exc_info())
|
399
|
+
self.close()
|
400
|
+
return
|
401
|
+
|
402
|
+
def configure(self, line):
|
403
|
+
config = json.loads(line)
|
404
|
+
log.info("CONFIG: %s", repr(config))
|
405
|
+
|
406
|
+
if not self._bind_all(config):
|
407
|
+
self.close()
|
408
|
+
return
|
409
|
+
|
410
|
+
return Protocol(self)
|
411
|
+
|
412
|
+
def receive_text(self, data):
|
413
|
+
if self._protocol is None:
|
414
|
+
raise Exception("protocol is not configured")
|
415
|
+
|
416
|
+
try:
|
417
|
+
self._protocol.receive_text(data)
|
418
|
+
except:
|
419
|
+
log.error("failed to receive text", exc_info=sys.exc_info())
|
420
|
+
self.close()
|
421
|
+
|
422
|
+
def receive_data(self, tunnel_id, addr, data):
|
423
|
+
try:
|
424
|
+
tunnel = self._tunnels[tunnel_id]
|
425
|
+
except KeyError:
|
426
|
+
log.error("no such tunnel: %s", tunnel_id)
|
427
|
+
return
|
428
|
+
|
429
|
+
tunnel.receive_data(addr, data)
|
430
|
+
|
431
|
+
def _bind_all(self, config):
|
432
|
+
"""Bind all protocol/port combinations from configuration."""
|
433
|
+
bind = config.get('bind', [])
|
434
|
+
|
435
|
+
for b in bind:
|
436
|
+
tunnel_id = (b['family'], b['protocol'], b['port'])
|
437
|
+
|
438
|
+
if tunnel_id in self._tunnels:
|
439
|
+
log.error("Already bound: %s", repr(tunnel_id))
|
440
|
+
continue
|
441
|
+
|
442
|
+
try:
|
443
|
+
family, protocol, port = tunnel_id
|
444
|
+
protocol = BIND_PROTOCOLS[protocol]
|
445
|
+
self._tunnels[tunnel_id] = protocol(
|
446
|
+
family, self, '127.0.0.1', port)
|
447
|
+
except:
|
448
|
+
log.error("failed to bind: %s", repr(b),
|
449
|
+
exc_info=sys.exc_info())
|
450
|
+
continue
|
451
|
+
|
452
|
+
if len(self._tunnels) != len(bind):
|
453
|
+
log.error("unable to bind everything: %s", repr(bind))
|
454
|
+
return False
|
455
|
+
|
456
|
+
log.info("ports bound")
|
457
|
+
return True
|
458
|
+
|
459
|
+
|
460
|
+
def hostip(string):
|
461
|
+
if ':' not in string:
|
462
|
+
return (string, DEFAULT_PORT)
|
463
|
+
|
464
|
+
ip, port = string.split(':', 2)
|
465
|
+
return (ip, int(port))
|
466
|
+
|
467
|
+
parser = argparse.ArgumentParser(sys.argv[0])
|
468
|
+
|
469
|
+
parser.add_argument(
|
470
|
+
"-j", "--json-metadata", dest="json_metadata",
|
471
|
+
help="Load metadata from JSON file.", metavar="<file>")
|
472
|
+
|
473
|
+
parser.add_argument(
|
474
|
+
"-d", "--debug", dest="debug", action="store_const", const=True,
|
475
|
+
default=False, help="Enable debugging.")
|
476
|
+
|
477
|
+
parser.add_argument(
|
478
|
+
"-c", "--connect", dest="connect", default=('127.0.0.1', DEFAULT_PORT),
|
479
|
+
type=hostip, metavar="<host>[:port]", help="Connect to the specified ")
|
480
|
+
|
481
|
+
|
482
|
+
def parse_args(args):
|
483
|
+
ns = parser.parse_args(args)
|
484
|
+
|
485
|
+
log_level = logging.INFO
|
486
|
+
|
487
|
+
if ns.debug:
|
488
|
+
log_level = logging.DEBUG
|
489
|
+
|
490
|
+
logging.basicConfig(level=log_level)
|
491
|
+
|
492
|
+
if ns.json_metadata:
|
493
|
+
with open(ns.json_metadata) as f:
|
494
|
+
ns.metadata = json.load(f)
|
495
|
+
else:
|
496
|
+
ns.metadata = dict()
|
497
|
+
|
498
|
+
log.info("Metadata: %s", repr(ns.metadata))
|
499
|
+
|
500
|
+
return ns
|
501
|
+
|
502
|
+
|
503
|
+
def _main(args):
|
504
|
+
ns = parse_args(args)
|
505
|
+
|
506
|
+
reconnect_timeout = 1.0
|
507
|
+
|
508
|
+
while True:
|
509
|
+
TunnelClient(ns.metadata, ns.connect)
|
510
|
+
asyncore.loop()
|
511
|
+
|
512
|
+
log.info("reconnecting in %ds", reconnect_timeout)
|
513
|
+
time.sleep(reconnect_timeout)
|
514
|
+
|
515
|
+
if __name__ == "__main__":
|
516
|
+
sys.exit(_main(sys.argv[1:]))
|
@@ -0,0 +1,384 @@
|
|
1
|
+
# $LICENSE
|
2
|
+
# Copyright 2013-2014 Spotify AB. All rights reserved.
|
3
|
+
#
|
4
|
+
# The contents of this file are licensed under the Apache License, Version 2.0
|
5
|
+
# (the "License"); you may not use this file except in compliance with the
|
6
|
+
# License. You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
12
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
13
|
+
# License for the specific language governing permissions and limitations under
|
14
|
+
# the License.
|
15
|
+
|
16
|
+
require 'ffwd/core/emitter'
|
17
|
+
require 'ffwd/core/interface'
|
18
|
+
require 'ffwd/core/reporter'
|
19
|
+
require 'ffwd/lifecycle'
|
20
|
+
require 'ffwd/logging'
|
21
|
+
require 'ffwd/plugin_channel'
|
22
|
+
require 'ffwd/tunnel/plugin'
|
23
|
+
require 'ffwd/utils'
|
24
|
+
|
25
|
+
module FFWD::Plugin::Tunnel
|
26
|
+
class BinaryProtocol < FFWD::Tunnel::Plugin
|
27
|
+
class BindUDP
|
28
|
+
class Handle < FFWD::Tunnel::Plugin::Handle
|
29
|
+
attr_reader :addr
|
30
|
+
|
31
|
+
def initialize bind, addr
|
32
|
+
@bind = bind
|
33
|
+
@addr = addr
|
34
|
+
end
|
35
|
+
|
36
|
+
def send_data data
|
37
|
+
@bind.send_data @addr, data
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def initialize port, family, tunnel, block
|
42
|
+
@port = port
|
43
|
+
@family = family
|
44
|
+
@tunnel = tunnel
|
45
|
+
@block = block
|
46
|
+
end
|
47
|
+
|
48
|
+
def send_data addr, data
|
49
|
+
@tunnel.send_data Socket::SOCK_DGRAM, @family, @port, addr, data
|
50
|
+
end
|
51
|
+
|
52
|
+
def data! addr, data
|
53
|
+
handle = Handle.new self, addr
|
54
|
+
@block.call handle, data
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
class BindTCP
|
59
|
+
class Handle < FFWD::Tunnel::Plugin::Handle
|
60
|
+
attr_reader :addr
|
61
|
+
|
62
|
+
def initialize bind, addr
|
63
|
+
@bind = bind
|
64
|
+
@addr = addr
|
65
|
+
@close = nil
|
66
|
+
@data = nil
|
67
|
+
end
|
68
|
+
|
69
|
+
def send_data data
|
70
|
+
@bind.send_data @addr, data
|
71
|
+
end
|
72
|
+
|
73
|
+
def close &block
|
74
|
+
@close = block
|
75
|
+
end
|
76
|
+
|
77
|
+
def data &block
|
78
|
+
@data = block
|
79
|
+
end
|
80
|
+
|
81
|
+
def recv_close
|
82
|
+
return if @close.nil?
|
83
|
+
@close.call
|
84
|
+
@close = nil
|
85
|
+
@data = nil
|
86
|
+
end
|
87
|
+
|
88
|
+
def recv_data data
|
89
|
+
return if @data.nil?
|
90
|
+
@data.call data
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def initialize port, family, tunnel, block
|
95
|
+
@port = port
|
96
|
+
@family = family
|
97
|
+
@tunnel = tunnel
|
98
|
+
@block = block
|
99
|
+
@handles = {}
|
100
|
+
end
|
101
|
+
|
102
|
+
def open addr
|
103
|
+
raise "Already open: #{addr}" if @handles[addr]
|
104
|
+
handle = @handles[addr] = Handle.new self, addr
|
105
|
+
@block.call handle
|
106
|
+
end
|
107
|
+
|
108
|
+
def close addr
|
109
|
+
raise "Not open: #{addr}" unless handle = @handles[addr]
|
110
|
+
handle.recv_close
|
111
|
+
@handles.delete addr
|
112
|
+
end
|
113
|
+
|
114
|
+
def data addr, data
|
115
|
+
unless handle = @handles[addr]
|
116
|
+
raise "Not available: #{addr}"
|
117
|
+
end
|
118
|
+
|
119
|
+
handle.recv_data data
|
120
|
+
end
|
121
|
+
|
122
|
+
def send_data addr, data
|
123
|
+
@tunnel.send_data Socket::SOCK_STREAM, @family, @port, addr, data
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
include FFWD::Logging
|
128
|
+
include FFWD::Lifecycle
|
129
|
+
|
130
|
+
Header = Struct.new(:length, :type, :port, :family, :protocol)
|
131
|
+
HeaderFormat = 'nnnCC'
|
132
|
+
HeaderSize = 8
|
133
|
+
|
134
|
+
PeerAddrAfInet = Struct.new(:ip, :port)
|
135
|
+
PeerAddrAfInetFormat = "a4n"
|
136
|
+
PeerAddrAfInetSize = 6
|
137
|
+
|
138
|
+
PeerAddrAfInet6 = Struct.new(:ip, :port)
|
139
|
+
PeerAddrAfInet6Format = "a16n"
|
140
|
+
PeerAddrAfInet6Size = 18
|
141
|
+
|
142
|
+
State = Struct.new(:state)
|
143
|
+
StateFormat = 'n'
|
144
|
+
StateSize = 2
|
145
|
+
|
146
|
+
STATE = 0x0000
|
147
|
+
DATA = 0x0001
|
148
|
+
|
149
|
+
OPEN = 0x0000
|
150
|
+
CLOSE = 0x0001
|
151
|
+
|
152
|
+
def initialize core, c
|
153
|
+
@c = c
|
154
|
+
@tcp_bind = {}
|
155
|
+
@udp_bind = {}
|
156
|
+
@input = FFWD::PluginChannel.build 'tunnel_input'
|
157
|
+
|
158
|
+
@metadata = nil
|
159
|
+
@processor = nil
|
160
|
+
@channel_id = nil
|
161
|
+
@statistics_id = nil
|
162
|
+
@header = nil
|
163
|
+
|
164
|
+
starting do
|
165
|
+
raise "no metadata" if @metadata.nil?
|
166
|
+
|
167
|
+
if host = @metadata[:host]
|
168
|
+
@statistics_id = "tunnel/#{host}"
|
169
|
+
@channel_id = "tunnel.input/#{host}"
|
170
|
+
else
|
171
|
+
@statistics_id = "tunnel/#{@c.get_peer}"
|
172
|
+
@channel_id = "tunnel.input/#{@c.get_peer}"
|
173
|
+
end
|
174
|
+
|
175
|
+
# setup a small core
|
176
|
+
emitter = FFWD::Core::Emitter.build @core.output, @metadata
|
177
|
+
@processor = FFWD::Core::Processor.build @input, emitter, @core.processors
|
178
|
+
|
179
|
+
@reporter = FFWD::Core::Reporter.new [@input, @processor]
|
180
|
+
|
181
|
+
if @core.debug
|
182
|
+
@core.debug.monitor @channel_id, @input, FFWD::Debug::Input
|
183
|
+
end
|
184
|
+
|
185
|
+
if @core.statistics
|
186
|
+
@core.statistics.register @statistics_id, @reporter
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
stopping do
|
191
|
+
if @core.statistics and @statistics_id
|
192
|
+
@core.statistics.unregister @statistics_id
|
193
|
+
@statistics_id = nil
|
194
|
+
end
|
195
|
+
|
196
|
+
@metadata = nil
|
197
|
+
@processor = nil
|
198
|
+
@channel_id = nil
|
199
|
+
@tcp_bind = {}
|
200
|
+
@udp_bind = {}
|
201
|
+
end
|
202
|
+
|
203
|
+
@core = core.reconnect @input
|
204
|
+
|
205
|
+
@core.tunnel_plugins.each do |t|
|
206
|
+
instance = t.setup @core, self
|
207
|
+
instance.depend_on self
|
208
|
+
end
|
209
|
+
|
210
|
+
@input.depend_on self
|
211
|
+
@core.depend_on self
|
212
|
+
end
|
213
|
+
|
214
|
+
def tcp port, &block
|
215
|
+
@tcp_bind[[port, Socket::AF_INET]] = BindTCP.new(
|
216
|
+
port, Socket::AF_INET, self, block)
|
217
|
+
end
|
218
|
+
|
219
|
+
def udp port, &block
|
220
|
+
@udp_bind[[port, Socket::AF_INET]] = BindUDP.new(
|
221
|
+
port, Socket::AF_INET, self, block)
|
222
|
+
end
|
223
|
+
|
224
|
+
def read_metadata data
|
225
|
+
d = {}
|
226
|
+
|
227
|
+
d[:tags] = FFWD.merge_sets @core.tags, data["tags"]
|
228
|
+
d[:attributes] = FFWD.merge_sets @core.attributes, data["attributes"]
|
229
|
+
|
230
|
+
if host = data["host"]
|
231
|
+
d[:host] = host
|
232
|
+
end
|
233
|
+
|
234
|
+
if ttl = data["ttl"]
|
235
|
+
d[:ttl] = ttl
|
236
|
+
end
|
237
|
+
|
238
|
+
d
|
239
|
+
end
|
240
|
+
|
241
|
+
def send_config
|
242
|
+
response = {}
|
243
|
+
|
244
|
+
tcp = @tcp_bind.keys.map{|port, family|
|
245
|
+
{:protocol => Socket::SOCK_STREAM,
|
246
|
+
:family => family,
|
247
|
+
:port => port}}
|
248
|
+
udp = @udp_bind.keys.map{|port, family|
|
249
|
+
{:protocol => Socket::SOCK_DGRAM,
|
250
|
+
:family => family,
|
251
|
+
:port => port}}
|
252
|
+
|
253
|
+
response[:bind] = tcp + udp
|
254
|
+
|
255
|
+
response = JSON.dump(response)
|
256
|
+
@c.send_data "#{response}\n"
|
257
|
+
end
|
258
|
+
|
259
|
+
def receive_metadata data
|
260
|
+
@metadata = read_metadata data
|
261
|
+
start
|
262
|
+
send_config
|
263
|
+
end
|
264
|
+
|
265
|
+
def receive_line line
|
266
|
+
raise "already have metadata" if @metadata
|
267
|
+
receive_metadata JSON.load(line)
|
268
|
+
@c.set_text_mode HeaderSize
|
269
|
+
end
|
270
|
+
|
271
|
+
def parse_addr_format family
|
272
|
+
if family == Socket::AF_INET
|
273
|
+
return PeerAddrAfInetFormat, PeerAddrAfInetSize
|
274
|
+
end
|
275
|
+
|
276
|
+
if family == Socket::AF_INET6
|
277
|
+
return PeerAddrAfInet6Format, PeerAddrAfInet6Size
|
278
|
+
end
|
279
|
+
|
280
|
+
raise "Unsupported address family: #{family}"
|
281
|
+
end
|
282
|
+
|
283
|
+
def peer_addr_pack family, addr
|
284
|
+
format, size = parse_addr_format family
|
285
|
+
return addr.pack(format), size
|
286
|
+
end
|
287
|
+
|
288
|
+
def peer_addr_unpack family, data
|
289
|
+
format, size = parse_addr_format family
|
290
|
+
return data[0,size].unpack(format), size
|
291
|
+
end
|
292
|
+
|
293
|
+
def receive_frame header, addr, data
|
294
|
+
if header.type == DATA
|
295
|
+
tunnel_data header, addr, data
|
296
|
+
return
|
297
|
+
end
|
298
|
+
|
299
|
+
if header.type == STATE
|
300
|
+
state = data.unpack(StateFormat)[0]
|
301
|
+
tunnel_state header, addr, state
|
302
|
+
return
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
def receive_binary_data data
|
307
|
+
if @header
|
308
|
+
addr, addr_size = peer_addr_unpack @header.family, data
|
309
|
+
data = data[addr_size,data.size - addr_size]
|
310
|
+
receive_frame @header, addr, data
|
311
|
+
@header = nil
|
312
|
+
@c.set_text_mode HeaderSize
|
313
|
+
return
|
314
|
+
end
|
315
|
+
|
316
|
+
fields = data.unpack HeaderFormat
|
317
|
+
@header = Header.new(*fields)
|
318
|
+
rest = @header.length - HeaderSize
|
319
|
+
@c.set_text_mode rest
|
320
|
+
end
|
321
|
+
|
322
|
+
def send_data protocol, family, port, addr, data
|
323
|
+
addr_data, addr_size = peer_addr_pack family, addr
|
324
|
+
length = HeaderSize + addr_size + data.size
|
325
|
+
# Struct.new(:length, :type, :port, :family, :protocol)
|
326
|
+
header_data = [
|
327
|
+
length, DATA, port, family, protocol].pack HeaderFormat
|
328
|
+
frame = header_data + addr_data + data
|
329
|
+
@c.send_data frame
|
330
|
+
end
|
331
|
+
|
332
|
+
def tunnel_data header, addr, data
|
333
|
+
if header.protocol == Socket::SOCK_DGRAM
|
334
|
+
if udp = @udp_bind[[header.port, header.family]]
|
335
|
+
udp.data! addr, data
|
336
|
+
end
|
337
|
+
|
338
|
+
return
|
339
|
+
end
|
340
|
+
|
341
|
+
if header.protocol == Socket::SOCK_STREAM
|
342
|
+
unless bind = @tcp_bind[[header.port, header.family]]
|
343
|
+
log.error "Nothing listening on tcp/#{header.port}"
|
344
|
+
return
|
345
|
+
end
|
346
|
+
|
347
|
+
bind.data addr, data
|
348
|
+
return
|
349
|
+
end
|
350
|
+
|
351
|
+
log.error "DATA: Unsupported protocol: #{header.protocol}"
|
352
|
+
end
|
353
|
+
|
354
|
+
def tunnel_state header, addr, state
|
355
|
+
if header.protocol == Socket::SOCK_DGRAM
|
356
|
+
# ignored
|
357
|
+
log.error "UDP does not handle: #{state}"
|
358
|
+
return
|
359
|
+
end
|
360
|
+
|
361
|
+
if header.protocol == Socket::SOCK_STREAM
|
362
|
+
unless bind = @tcp_bind[[header.port, header.family]]
|
363
|
+
log.error "Nothing listening on tcp/#{header.port}"
|
364
|
+
return
|
365
|
+
end
|
366
|
+
|
367
|
+
if state == OPEN
|
368
|
+
bind.open addr
|
369
|
+
return
|
370
|
+
end
|
371
|
+
|
372
|
+
if state == CLOSE
|
373
|
+
bind.close addr
|
374
|
+
return
|
375
|
+
end
|
376
|
+
|
377
|
+
log.error "Unknown state: #{state}"
|
378
|
+
return
|
379
|
+
end
|
380
|
+
|
381
|
+
log.error "STATE: Unsupported protocol: #{header.protocol}"
|
382
|
+
end
|
383
|
+
end
|
384
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# $LICENSE
|
2
|
+
# Copyright 2013-2014 Spotify AB. All rights reserved.
|
3
|
+
#
|
4
|
+
# The contents of this file are licensed under the Apache License, Version 2.0
|
5
|
+
# (the "License"); you may not use this file except in compliance with the
|
6
|
+
# License. You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
12
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
13
|
+
# License for the specific language governing permissions and limitations under
|
14
|
+
# the License.
|
15
|
+
|
16
|
+
require 'ffwd/connection'
|
17
|
+
|
18
|
+
module FFWD::Plugin::Tunnel
|
19
|
+
class ConnectionTCP < FFWD::Connection
|
20
|
+
include FFWD::Logging
|
21
|
+
include EM::Protocols::LineText2
|
22
|
+
|
23
|
+
def self.plugin_type
|
24
|
+
"tunnel_in_tcp"
|
25
|
+
end
|
26
|
+
|
27
|
+
def initialize bind, core, tunnel_protocol
|
28
|
+
@bind = bind
|
29
|
+
@core = core
|
30
|
+
@tunnel_protocol = tunnel_protocol
|
31
|
+
@protocol_instance = nil
|
32
|
+
end
|
33
|
+
|
34
|
+
def receive_line line
|
35
|
+
@protocol_instance.receive_line line
|
36
|
+
end
|
37
|
+
|
38
|
+
def receive_binary_data data
|
39
|
+
@protocol_instance.receive_binary_data data
|
40
|
+
end
|
41
|
+
|
42
|
+
def get_peer
|
43
|
+
peer = get_peername
|
44
|
+
port, ip = Socket.unpack_sockaddr_in(peer)
|
45
|
+
"#{ip}:#{port}"
|
46
|
+
end
|
47
|
+
|
48
|
+
def post_init
|
49
|
+
@protocol_instance = @tunnel_protocol.new @core, self
|
50
|
+
end
|
51
|
+
|
52
|
+
def unbind
|
53
|
+
log.info "Shutting down tunnel connection"
|
54
|
+
@protocol_instance.stop
|
55
|
+
@protocol_instance = nil
|
56
|
+
end
|
57
|
+
|
58
|
+
def metadata?
|
59
|
+
not @metadata.nil?
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# $LICENSE
|
2
|
+
# Copyright 2013-2014 Spotify AB. All rights reserved.
|
3
|
+
#
|
4
|
+
# The contents of this file are licensed under the Apache License, Version 2.0
|
5
|
+
# (the "License"); you may not use this file except in compliance with the
|
6
|
+
# License. You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
12
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
13
|
+
# License for the specific language governing permissions and limitations under
|
14
|
+
# the License.
|
15
|
+
|
16
|
+
module FFWD
|
17
|
+
module Plugin
|
18
|
+
module Tunnel
|
19
|
+
VERSION = "0.1.0"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# $LICENSE
|
2
|
+
# Copyright 2013-2014 Spotify AB. All rights reserved.
|
3
|
+
#
|
4
|
+
# The contents of this file are licensed under the Apache License, Version 2.0
|
5
|
+
# (the "License"); you may not use this file except in compliance with the
|
6
|
+
# License. You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
12
|
+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
13
|
+
# License for the specific language governing permissions and limitations under
|
14
|
+
# the License.
|
15
|
+
|
16
|
+
require 'eventmachine'
|
17
|
+
require 'base64'
|
18
|
+
|
19
|
+
require_relative 'tunnel/connection_tcp'
|
20
|
+
require_relative 'tunnel/binary_protocol'
|
21
|
+
|
22
|
+
require 'ffwd/logging'
|
23
|
+
require 'ffwd/plugin'
|
24
|
+
require 'ffwd/protocol'
|
25
|
+
|
26
|
+
module FFWD::Plugin::Tunnel
|
27
|
+
include FFWD::Plugin
|
28
|
+
include FFWD::Logging
|
29
|
+
|
30
|
+
register_plugin "tunnel"
|
31
|
+
|
32
|
+
DEFAULT_HOST = 'localhost'
|
33
|
+
DEFAULT_PORT = 9000
|
34
|
+
DEFAULT_PROTOCOL = 'tcp'
|
35
|
+
DEFAULT_PROTOCOL_TYPE = 'text'
|
36
|
+
|
37
|
+
CONNECTIONS = {
|
38
|
+
:tcp => ConnectionTCP
|
39
|
+
}
|
40
|
+
|
41
|
+
def self.setup_input opts, core
|
42
|
+
opts[:host] ||= DEFAULT_HOST
|
43
|
+
opts[:port] ||= DEFAULT_PORT
|
44
|
+
protocol = FFWD.parse_protocol(opts[:protocol] || DEFAULT_PROTOCOL)
|
45
|
+
protocol_type = opts[:protocol_type] || DEFAULT_PROTOCOL_TYPE
|
46
|
+
|
47
|
+
unless connection = CONNECTIONS[protocol.family]
|
48
|
+
raise "No connection for protocol family: #{protocol.family}"
|
49
|
+
end
|
50
|
+
|
51
|
+
if core.tunnel_plugins.empty?
|
52
|
+
raise "Nothing requires tunneling"
|
53
|
+
end
|
54
|
+
|
55
|
+
protocol.bind opts, core, log, connection, BinaryProtocol
|
56
|
+
end
|
57
|
+
end
|
metadata
ADDED
@@ -0,0 +1,92 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ffwd-tunnel
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- John-John Tedro
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-02-25 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: ffwd
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - '>='
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - '>='
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rspec
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '>='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '>='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec-mocks
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - '>='
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
description:
|
56
|
+
email:
|
57
|
+
- udoprog@spotify.com
|
58
|
+
executables:
|
59
|
+
- ffwd-tunnel-agent
|
60
|
+
extensions: []
|
61
|
+
extra_rdoc_files: []
|
62
|
+
files:
|
63
|
+
- bin/ffwd-tunnel-agent
|
64
|
+
- lib/ffwd/plugin/tunnel.rb
|
65
|
+
- lib/ffwd/plugin/tunnel/binary_protocol.rb
|
66
|
+
- lib/ffwd/plugin/tunnel/version.rb
|
67
|
+
- lib/ffwd/plugin/tunnel/connection_tcp.rb
|
68
|
+
homepage: https://github.com/spotify/ffwd
|
69
|
+
licenses:
|
70
|
+
- Apache 2.0
|
71
|
+
metadata: {}
|
72
|
+
post_install_message:
|
73
|
+
rdoc_options: []
|
74
|
+
require_paths:
|
75
|
+
- lib
|
76
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
77
|
+
requirements:
|
78
|
+
- - '>='
|
79
|
+
- !ruby/object:Gem::Version
|
80
|
+
version: '0'
|
81
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
82
|
+
requirements:
|
83
|
+
- - '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
requirements: []
|
87
|
+
rubyforge_project:
|
88
|
+
rubygems_version: 2.0.3
|
89
|
+
signing_key:
|
90
|
+
specification_version: 4
|
91
|
+
summary: Simple tunneling support for FFWD.
|
92
|
+
test_files: []
|