Skip to content
This repository was archived by the owner on Mar 13, 2022. It is now read-only.

Commit 74d0e29

Browse files
committed
Implement port forwarding.
1 parent 471a678 commit 74d0e29

File tree

3 files changed

+178
-4
lines changed

3 files changed

+178
-4
lines changed

stream/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from .stream import stream
15+
from .stream import stream, portforward

stream/stream.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
from . import ws_client
1818

1919

20-
def _websocket_reqeust(websocket_request, api_method, *args, **kwargs):
20+
def _websocket_reqeust(websocket_request, force_kwargs, api_method, *args, **kwargs):
2121
"""Override the ApiClient.request method with an alternative websocket based
2222
method and call the supplied Kubernetes API method with that in place."""
23+
if force_kwargs:
24+
for kwarg, value in force_kwargs.items():
25+
kwargs[kwarg] = value
2326
api_client = api_method.__self__.api_client
2427
# old generated code's api client has config. new ones has configuration
2528
try:
@@ -34,4 +37,5 @@ def _websocket_reqeust(websocket_request, api_method, *args, **kwargs):
3437
api_client.request = prev_request
3538

3639

37-
stream = functools.partial(_websocket_reqeust, ws_client.websocket_call)
40+
stream = functools.partial(_websocket_reqeust, ws_client.websocket_call, None)
41+
portforward = functools.partial(_websocket_reqeust, ws_client.portforward_call, {'_preload_content':False})

stream/ws_client.py

+171-1
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from kubernetes.client.rest import ApiException
15+
from kubernetes.client.rest import ApiException, ApiValueError
1616

1717
import certifi
1818
import collections
1919
import select
20+
import socket
2021
import ssl
22+
import threading
2123
import time
2224

2325
import six
@@ -225,6 +227,143 @@ def close(self, **kwargs):
225227
WSResponse = collections.namedtuple('WSResponse', ['data'])
226228

227229

230+
class PortForward:
231+
def __init__(self, websocket, ports):
232+
"""A websocket client with support for port forwarding.
233+
234+
Port Forward command sends on 2 channels per port, a read/write
235+
data channel and a read only error channel. Both channels are sent an
236+
initial frame contaning the port number that channel is associated with.
237+
"""
238+
239+
self.websocket = websocket
240+
self.ports = {}
241+
for ix, port_number in enumerate(ports):
242+
self.ports[port_number] = self._Port(ix, port_number)
243+
threading.Thread(
244+
name="Kubernetes port forward proxy", target=self._proxy, daemon=True
245+
).start()
246+
247+
def socket(self, port_number):
248+
if port_number not in self.ports:
249+
raise ValueError("Invalid port number")
250+
return self.ports[port_number].socket
251+
252+
def error(self, port_number):
253+
if port_number not in self.ports:
254+
raise ValueError("Invalid port number")
255+
return self.ports[port_number].error
256+
257+
def close(self):
258+
for port in self.ports.values():
259+
port.socket.close()
260+
261+
class _Port:
262+
def __init__(self, ix, number):
263+
self.number = number
264+
self.channel = bytes([ix * 2])
265+
s, self.python = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
266+
self.socket = self._Socket(s)
267+
self.data = b''
268+
self.error = None
269+
270+
class _Socket:
271+
def __init__(self, socket):
272+
self._socket = socket
273+
274+
def __getattr__(self, name):
275+
return getattr(self._socket, name)
276+
277+
def setsockopt(self, level, optname, value):
278+
# The following socket option is not valid with a socket created from socketpair,
279+
# and is set when creating an SSLSocket from this socket.
280+
if level == socket.IPPROTO_TCP and optname == socket.TCP_NODELAY:
281+
return
282+
self._socket.setsockopt(level, optname, value)
283+
284+
# Proxy all socket data between the python code and the kubernetes websocket.
285+
def _proxy(self):
286+
channel_ports = []
287+
channel_initialized = []
288+
python_ports = {}
289+
rlist = []
290+
for port in self.ports.values():
291+
channel_ports.append(port)
292+
channel_initialized.append(False)
293+
channel_ports.append(port)
294+
channel_initialized.append(False)
295+
python_ports[port.python] = port
296+
rlist.append(port.python)
297+
rlist.append(self.websocket.sock)
298+
kubernetes_data = b''
299+
while True:
300+
wlist = []
301+
for port in self.ports.values():
302+
if port.data:
303+
wlist.append(port.python)
304+
if kubernetes_data:
305+
wlist.append(self.websocket.sock)
306+
r, w, _ = select.select(rlist, wlist, [])
307+
for s in w:
308+
if s == self.websocket.sock:
309+
sent = self.websocket.sock.send(kubernetes_data)
310+
kubernetes_data = kubernetes_data[sent:]
311+
else:
312+
port = python_ports[s]
313+
sent = port.python.send(port.data)
314+
port.data = port.data[sent:]
315+
for s in r:
316+
if s == self.websocket.sock:
317+
opcode, frame = self.websocket.recv_data_frame(True)
318+
if opcode == ABNF.OPCODE_CLOSE:
319+
for port in self.ports.values():
320+
port.python.close()
321+
return
322+
if opcode == ABNF.OPCODE_BINARY:
323+
if not frame.data:
324+
raise RuntimeError("Unexpected frame data size")
325+
channel = frame.data[0]
326+
if channel >= len(channel_ports):
327+
raise RuntimeError("Unexpected channel number: " + str(channel))
328+
port = channel_ports[channel]
329+
if channel_initialized[channel]:
330+
if channel % 2:
331+
port.error = frame.data[1:].decode()
332+
if port.python in rlist:
333+
port.python.close()
334+
rlist.remove(port.python)
335+
port.data = b''
336+
else:
337+
port.data += frame.data[1:]
338+
else:
339+
if len(frame.data) != 3:
340+
raise RuntimeError(
341+
"Unexpected initial channel frame data size"
342+
)
343+
port_number = frame.data[1] + (frame.data[2] * 256)
344+
if port_number != port.number:
345+
raise RuntimeError(
346+
"Unexpected port number in initial channel frame: " + str(port_number)
347+
)
348+
channel_initialized[channel] = True
349+
elif opcode not in (ABNF.OPCODE_PING, ABNF.OPCODE_PONG):
350+
raise RuntimeError("Unexpected websocket opcode: " + str(opcode))
351+
else:
352+
port = python_ports[s]
353+
data = port.python.recv(1024 * 1024)
354+
if data:
355+
kubernetes_data += ABNF.create_frame(
356+
port.channel + data,
357+
ABNF.OPCODE_BINARY,
358+
).format()
359+
else:
360+
port.python.close()
361+
rlist.remove(s)
362+
if len(rlist) == 1:
363+
self.websocket.close()
364+
return
365+
366+
228367
def get_websocket_url(url, query_params=None):
229368
parsed_url = urlparse(url)
230369
parts = list(parsed_url)
@@ -302,3 +441,34 @@ def websocket_call(configuration, _method, url, **kwargs):
302441
return WSResponse('%s' % ''.join(client.read_all()))
303442
except (Exception, KeyboardInterrupt, SystemExit) as e:
304443
raise ApiException(status=0, reason=str(e))
444+
445+
446+
def portforward_call(configuration, _method, url, **kwargs):
447+
"""An internal function to be called in api-client when a websocket
448+
connection is required for port forwarding. args and kwargs are the
449+
parameters of apiClient.request method."""
450+
451+
query_params = kwargs.get("query_params")
452+
453+
ports = []
454+
for key, value in query_params:
455+
if key == 'ports':
456+
for port in value.split(','):
457+
try:
458+
port = int(port)
459+
if not (0 < port < 65536):
460+
raise ValueError
461+
ports.append(port)
462+
except ValueError:
463+
raise ApiValueError("Invalid port number `" + str(port) + "`")
464+
if not ports:
465+
raise ApiValueError("Missing required parameter `ports`")
466+
467+
url = get_websocket_url(url, query_params)
468+
headers = kwargs.get("headers")
469+
470+
try:
471+
websocket = create_websocket(configuration, url, headers)
472+
return PortForward(websocket, ports)
473+
except (Exception, KeyboardInterrupt, SystemExit) as e:
474+
raise ApiException(status=0, reason=str(e))

0 commit comments

Comments
 (0)