blob: 44c5567d48de7ed5d6678c7fcd8fc13932d18807 [file] [log] [blame]
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001#!/usr/bin/env python
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08002# -*- coding: utf-8 -*-
3#
4# Copyright 2015 The Chromium OS Authors. All rights reserved.
5# Use of this source code is governed by a BSD-style license that can be
6# found in the LICENSE file.
7
8from __future__ import print_function
9
10import argparse
11import ast
12import base64
13import fcntl
Wei-Ning Huangba768ab2016-02-07 14:38:06 +080014import getpass
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080015import hashlib
16import httplib
17import json
18import jsonrpclib
19import logging
20import os
21import re
22import select
23import signal
24import socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080025import ssl
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080026import StringIO
27import struct
28import subprocess
29import sys
30import tempfile
31import termios
32import threading
33import time
34import tty
35import urllib2
36import urlparse
Wei-Ning Huangecc80b82016-07-01 16:55:10 +080037import unicodedata # required by pyinstaller, pylint: disable=W0611
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080038
39from jsonrpclib.SimpleJSONRPCServer import SimpleJSONRPCServer
40from jsonrpclib.config import Config
41from ws4py.client import WebSocketBaseClient
42
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080043
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080044_CERT_DIR = os.path.expanduser('~/.config/ovl')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080045
46_ESCAPE = '~'
47_BUFSIZ = 8192
48_OVERLORD_PORT = 4455
49_OVERLORD_HTTP_PORT = 9000
50_OVERLORD_CLIENT_DAEMON_PORT = 4488
51_OVERLORD_CLIENT_DAEMON_RPC_ADDR = ('127.0.0.1', _OVERLORD_CLIENT_DAEMON_PORT)
52
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080053_CONNECT_TIMEOUT = 3
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +080054_DEFAULT_HTTP_TIMEOUT = 30
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080055_LIST_CACHE_TIMEOUT = 2
56_DEFAULT_TERMINAL_WIDTH = 80
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +080057_RETRY_TIMES = 3
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080058
59# echo -n overlord | md5sum
60_HTTP_BOUNDARY_MAGIC = '9246f080c855a69012707ab53489b921'
61
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +080062# Terminal resize control
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080063_CONTROL_START = 128
64_CONTROL_END = 129
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +080065
66# Stream control
67_STDIN_CLOSED = '##STDIN_CLOSED##'
68
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080069_SSH_CONTROL_SOCKET_PREFIX = os.path.join(tempfile.gettempdir(),
70 'ovl-ssh-control-')
71
Wei-Ning Huang47c79b82016-05-24 01:24:46 +080072_TLS_CERT_FAILED_WARNING = """
73@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
74@ WARNING: REMOTE HOST VERIFICATION HAS FAILED! @
75@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
76IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
77Someone could be eavesdropping on you right now (man-in-the-middle attack)!
78It is also possible that the server is using a self-signed certificate.
79The fingerprint for the TLS host certificate sent by the remote host is
80
81%s
82
83Do you want to trust this certificate and proceed? [Y/n] """
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080084
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080085_TLS_CERT_CHANGED_WARNING = """
86@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
87@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
88@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
89IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
90Someone could be eavesdropping on you right now (man-in-the-middle attack)!
Wei-Ning Huang47c79b82016-05-24 01:24:46 +080091It is also possible that the TLS host certificate has just been changed.
92The fingerprint for the TLS host certificate sent by the remote host is
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080093
94%s
95
96Remove '%s' if you still want to proceed.
97SSL Certificate verification failed."""
98
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080099
100def GetVersionDigest():
101 """Return the sha1sum of the current executing script."""
Wei-Ning Huang31609662016-08-11 00:22:25 +0800102 # Check python script by default
103 filename = __file__
104
105 # If we are running from a frozen binary, we should calculate the checksum
106 # against that binary instead of the python script.
107 # See: https://pyinstaller.readthedocs.io/en/stable/runtime-information.html
108 if getattr(sys, 'frozen', False):
109 filename = sys.executable
110
111 with open(filename, 'r') as f:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800112 return hashlib.sha1(f.read()).hexdigest()
113
114
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800115def GetTLSCertPath(host):
116 return os.path.join(_CERT_DIR, '%s.cert' % host)
117
118
119def UrlOpen(state, url):
120 """Wrapper for urllib2.urlopen.
121
122 It selects correct HTTP scheme according to self._state.ssl, add HTTP
123 basic auth headers, and add specify correct SSL context.
124 """
125 url = MakeRequestUrl(state, url)
126 request = urllib2.Request(url)
127 if state.username is not None and state.password is not None:
128 request.add_header(*BasicAuthHeader(state.username, state.password))
129 return urllib2.urlopen(request, timeout=_DEFAULT_HTTP_TIMEOUT,
130 context=state.ssl_context)
131
132
133def GetTLSCertificateSHA1Fingerprint(cert_pem):
134 beg = cert_pem.index('\n')
135 end = cert_pem.rindex('\n', 0, len(cert_pem) - 2)
136 cert_pem = cert_pem[beg:end] # Remove BEGIN/END CERTIFICATE boundary
137 cert_der = base64.b64decode(cert_pem)
138 return hashlib.sha1(cert_der).hexdigest()
139
140
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800141def KillGraceful(pid, wait_secs=1):
142 """Kill a process gracefully by first sending SIGTERM, wait for some time,
143 then send SIGKILL to make sure it's killed."""
144 try:
145 os.kill(pid, signal.SIGTERM)
146 time.sleep(wait_secs)
147 os.kill(pid, signal.SIGKILL)
148 except OSError:
149 pass
150
151
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800152def AutoRetry(action_name, retries):
153 """Decorator for retry function call."""
154 def Wrap(func):
155 def Loop(*args, **kwargs):
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800156 for unused_i in range(retries):
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800157 try:
158 func(*args, **kwargs)
159 except Exception as e:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800160 print('error: %s: %s: retrying ...' % (args[0], e))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800161 else:
162 break
163 else:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800164 print('error: failed to %s %s' % (action_name, args[0]))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800165 return Loop
166 return Wrap
167
168
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800169def BasicAuthHeader(user, password):
170 """Return HTTP basic auth header."""
171 credential = base64.b64encode('%s:%s' % (user, password))
172 return ('Authorization', 'Basic %s' % credential)
173
174
175def GetTerminalSize():
176 """Retrieve terminal window size."""
177 ws = struct.pack('HHHH', 0, 0, 0, 0)
178 ws = fcntl.ioctl(0, termios.TIOCGWINSZ, ws)
179 lines, columns, unused_x, unused_y = struct.unpack('HHHH', ws)
180 return lines, columns
181
182
183def MakeRequestUrl(state, url):
184 return 'http%s://%s' % ('s' if state.ssl else '', url)
185
186
187class ProgressBar(object):
188 SIZE_WIDTH = 11
189 SPEED_WIDTH = 10
190 DURATION_WIDTH = 6
191 PERCENTAGE_WIDTH = 8
192
193 def __init__(self, name):
194 self._start_time = time.time()
195 self._name = name
196 self._size = 0
197 self._width = 0
198 self._name_width = 0
199 self._name_max = 0
200 self._stat_width = 0
201 self._max = 0
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800202 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800203 self.SetProgress(0)
204
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800205 def _CalculateSize(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800206 self._width = GetTerminalSize()[1] or _DEFAULT_TERMINAL_WIDTH
207 self._name_width = int(self._width * 0.3)
208 self._name_max = self._name_width
209 self._stat_width = self.SIZE_WIDTH + self.SPEED_WIDTH + self.DURATION_WIDTH
210 self._max = (self._width - self._name_width - self._stat_width -
211 self.PERCENTAGE_WIDTH)
212
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800213 def _SizeToHuman(self, size_in_bytes):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800214 if size_in_bytes < 1024:
215 unit = 'B'
216 value = size_in_bytes
217 elif size_in_bytes < 1024 ** 2:
218 unit = 'KiB'
219 value = size_in_bytes / 1024.0
220 elif size_in_bytes < 1024 ** 3:
221 unit = 'MiB'
222 value = size_in_bytes / (1024.0 ** 2)
223 elif size_in_bytes < 1024 ** 4:
224 unit = 'GiB'
225 value = size_in_bytes / (1024.0 ** 3)
226 return ' %6.1f %3s' % (value, unit)
227
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800228 def _SpeedToHuman(self, speed_in_bs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800229 if speed_in_bs < 1024:
230 unit = 'B'
231 value = speed_in_bs
232 elif speed_in_bs < 1024 ** 2:
233 unit = 'K'
234 value = speed_in_bs / 1024.0
235 elif speed_in_bs < 1024 ** 3:
236 unit = 'M'
237 value = speed_in_bs / (1024.0 ** 2)
238 elif speed_in_bs < 1024 ** 4:
239 unit = 'G'
240 value = speed_in_bs / (1024.0 ** 3)
241 return ' %6.1f%s/s' % (value, unit)
242
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800243 def _DurationToClock(self, duration):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800244 return ' %02d:%02d' % (duration / 60, duration % 60)
245
246 def SetProgress(self, percentage, size=None):
247 current_width = GetTerminalSize()[1]
248 if self._width != current_width:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800249 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800250
251 if size is not None:
252 self._size = size
253
254 elapse_time = time.time() - self._start_time
255 speed = self._size / float(elapse_time)
256
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800257 size_str = self._SizeToHuman(self._size)
258 speed_str = self._SpeedToHuman(speed)
259 elapse_str = self._DurationToClock(elapse_time)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800260
261 width = int(self._max * percentage / 100.0)
262 sys.stdout.write(
263 '%*s' % (- self._name_max,
264 self._name if len(self._name) <= self._name_max else
265 self._name[:self._name_max - 4] + ' ...') +
266 size_str + speed_str + elapse_str +
267 ((' [' + '#' * width + ' ' * (self._max - width) + ']' +
268 '%4d%%' % int(percentage)) if self._max > 2 else '') + '\r')
269 sys.stdout.flush()
270
271 def End(self):
272 self.SetProgress(100.0)
273 sys.stdout.write('\n')
274 sys.stdout.flush()
275
276
277class DaemonState(object):
278 """DaemonState is used for storing Overlord state info."""
279 def __init__(self):
280 self.version_sha1sum = GetVersionDigest()
281 self.host = None
282 self.port = None
283 self.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800284 self.ssl_self_signed = False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800285 self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800286 self.ssh = False
287 self.orig_host = None
288 self.ssh_pid = None
289 self.username = None
290 self.password = None
291 self.selected_mid = None
292 self.forwards = {}
293 self.listing = []
294 self.last_list = 0
295
296
297class OverlordClientDaemon(object):
298 """Overlord Client Daemon."""
299 def __init__(self):
300 self._state = DaemonState()
301 self._server = None
302
303 def Start(self):
304 self.StartRPCServer()
305
306 def StartRPCServer(self):
307 self._server = SimpleJSONRPCServer(_OVERLORD_CLIENT_DAEMON_RPC_ADDR,
308 logRequests=False)
309 exports = [
310 (self.State, 'State'),
311 (self.Ping, 'Ping'),
312 (self.GetPid, 'GetPid'),
313 (self.Connect, 'Connect'),
314 (self.Clients, 'Clients'),
315 (self.SelectClient, 'SelectClient'),
316 (self.AddForward, 'AddForward'),
317 (self.RemoveForward, 'RemoveForward'),
318 (self.RemoveAllForward, 'RemoveAllForward'),
319 ]
320 for func, name in exports:
321 self._server.register_function(func, name)
322
323 pid = os.fork()
324 if pid == 0:
325 self._server.serve_forever()
326
327 @staticmethod
328 def GetRPCServer():
329 """Returns the Overlord client daemon RPC server."""
330 server = jsonrpclib.Server('http://%s:%d' %
331 _OVERLORD_CLIENT_DAEMON_RPC_ADDR)
332 try:
333 server.Ping()
334 except Exception:
335 return None
336 return server
337
338 def State(self):
339 return self._state
340
341 def Ping(self):
342 return True
343
344 def GetPid(self):
345 return os.getpid()
346
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800347 def _GetJSON(self, path):
348 url = '%s:%d%s' % (self._state.host, self._state.port, path)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800349 return json.loads(UrlOpen(self._state, url).read())
350
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800351 def _TLSEnabled(self):
352 """Determine if TLS is enabled on given server address."""
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800353 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
354 try:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800355 # Allow any certificate since we only want to check if server talks TLS.
356 context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
357 context.verify_mode = ssl.CERT_NONE
358
359 sock = context.wrap_socket(sock, server_hostname=self._state.host)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800360 sock.settimeout(_CONNECT_TIMEOUT)
361 sock.connect((self._state.host, self._state.port))
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800362 return True
Wei-Ning Huangecc80b82016-07-01 16:55:10 +0800363 except ssl.SSLError:
364 return False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800365 except socket.error: # Connect refused or timeout
366 raise
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800367 except Exception:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800368 return False # For whatever reason above failed, assume False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800369
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800370 def _CheckTLSCertificate(self, check_hostname=True):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800371 """Check TLS certificate.
372
373 Returns:
374 A tupple (check_result, if_certificate_is_loaded)
375 """
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800376 def _DoConnect(context):
377 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
378 try:
379 sock.settimeout(_CONNECT_TIMEOUT)
380 sock = context.wrap_socket(sock, server_hostname=self._state.host)
381 sock.connect((self._state.host, self._state.port))
382 except ssl.SSLError:
383 return False
384 finally:
385 sock.close()
386
387 # Save SSLContext for future use.
388 self._state.ssl_context = context
389 return True
390
391 # First try connect with built-in certificates
392 tls_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
393 if _DoConnect(tls_context):
394 return True
395
396 # Try with already saved certificate, if any.
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800397 tls_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
398 tls_context.verify_mode = ssl.CERT_REQUIRED
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800399 tls_context.check_hostname = check_hostname
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800400
401 tls_cert_path = GetTLSCertPath(self._state.host)
402 if os.path.exists(tls_cert_path):
403 tls_context.load_verify_locations(tls_cert_path)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800404 self._state.ssl_self_signed = True
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800405
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800406 return _DoConnect(tls_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800407
408 def Connect(self, host, port=_OVERLORD_HTTP_PORT, ssh_pid=None,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800409 username=None, password=None, orig_host=None,
410 check_hostname=True):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800411 self._state.username = username
412 self._state.password = password
413 self._state.host = host
414 self._state.port = port
415 self._state.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800416 self._state.ssl_self_signed = False
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800417 self._state.orig_host = orig_host
418 self._state.ssh_pid = ssh_pid
419 self._state.selected_mid = None
420
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800421 tls_enabled = self._TLSEnabled()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800422 if tls_enabled:
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800423 result = self._CheckTLSCertificate(check_hostname)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800424 if not result:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800425 if self._state.ssl_self_signed:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800426 return ('SSLCertificateChanged', ssl.get_server_certificate(
427 (self._state.host, self._state.port)))
428 else:
429 return ('SSLVerifyFailed', ssl.get_server_certificate(
430 (self._state.host, self._state.port)))
431
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800432 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800433 self._state.ssl = tls_enabled
434 UrlOpen(self._state, '%s:%d' % (host, port))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800435 except urllib2.HTTPError as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800436 return ('HTTPError', e.getcode(), str(e), e.read().strip())
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800437 except Exception as e:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800438 return str(e)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800439 else:
440 return True
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800441
442 def Clients(self):
443 if time.time() - self._state.last_list <= _LIST_CACHE_TIMEOUT:
444 return self._state.listing
445
446 mids = [client['mid'] for client in self._GetJSON('/api/agents/list')]
447 self._state.listing = sorted(list(set(mids)))
448 self._state.last_list = time.time()
449 return self._state.listing
450
451 def SelectClient(self, mid):
452 self._state.selected_mid = mid
453
454 def AddForward(self, mid, remote, local, pid):
455 self._state.forwards[local] = (mid, remote, pid)
456
457 def RemoveForward(self, local_port):
458 try:
459 unused_mid, unused_remote, pid = self._state.forwards[local_port]
460 KillGraceful(pid)
461 del self._state.forwards[local_port]
462 except (KeyError, OSError):
463 pass
464
465 def RemoveAllForward(self):
466 for unused_mid, unused_remote, pid in self._state.forwards.values():
467 try:
468 KillGraceful(pid)
469 except OSError:
470 pass
471 self._state.forwards = {}
472
473
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800474class SSLEnabledWebSocketBaseClient(WebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800475 def __init__(self, state, *args, **kwargs):
476 cafile = ssl.get_default_verify_paths().openssl_cafile
477 # For some system / distribution, python can not detect system cafile path.
478 # In such case we fallback to the default path.
479 if not os.path.exists(cafile):
480 cafile = '/etc/ssl/certs/ca-certificates.crt'
481
482 if state.ssl_self_signed:
483 cafile = GetTLSCertPath(state.host)
484
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800485 ssl_options = {
486 'cert_reqs': ssl.CERT_REQUIRED,
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800487 'ca_certs': cafile
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800488 }
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800489 # ws4py does not allow you to specify SSLContext, but rather passing in the
490 # argument of ssl.wrap_socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800491 super(SSLEnabledWebSocketBaseClient, self).__init__(
492 ssl_options=ssl_options, *args, **kwargs)
493
494
495class TerminalWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800496 def __init__(self, state, mid, escape, *args, **kwargs):
497 super(TerminalWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800498 self._mid = mid
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800499 self._escape = escape
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800500 self._stdin_fd = sys.stdin.fileno()
501 self._old_termios = None
502
503 def handshake_ok(self):
504 pass
505
506 def opened(self):
507 nonlocals = {'size': (80, 40)}
508
509 def _ResizeWindow():
510 size = GetTerminalSize()
511 if size != nonlocals['size']: # Size not changed, ignore
512 control = {'command': 'resize', 'params': list(size)}
513 payload = chr(_CONTROL_START) + json.dumps(control) + chr(_CONTROL_END)
514 nonlocals['size'] = size
515 try:
516 self.send(payload, binary=True)
517 except Exception:
518 pass
519
520 def _FeedInput():
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800521 self._old_termios = termios.tcgetattr(self._stdin_fd)
522 tty.setraw(self._stdin_fd)
523
524 READY, ENTER_PRESSED, ESCAPE_PRESSED = range(3)
525
526 try:
527 state = READY
528 while True:
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800529 # Check if terminal is resized
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800530 _ResizeWindow()
531
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800532 ch = sys.stdin.read(1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800533
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800534 # Scan for escape sequence
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800535 if self._escape:
536 if state == READY:
537 state = ENTER_PRESSED if ch == chr(0x0d) else READY
538 elif state == ENTER_PRESSED:
539 state = ESCAPE_PRESSED if ch == self._escape else READY
540 elif state == ESCAPE_PRESSED:
541 if ch == '.':
542 self.close()
543 break
544 else:
545 state = READY
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800546
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800547 self.send(ch)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800548 except (KeyboardInterrupt, RuntimeError):
549 pass
550
551 t = threading.Thread(target=_FeedInput)
552 t.daemon = True
553 t.start()
554
555 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800556 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800557 termios.tcsetattr(self._stdin_fd, termios.TCSANOW, self._old_termios)
558 print('Connection to %s closed.' % self._mid)
559
560 def received_message(self, msg):
561 if msg.is_binary:
562 sys.stdout.write(msg.data)
563 sys.stdout.flush()
564
565
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800566class ShellWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800567 def __init__(self, state, output, *args, **kwargs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800568 """Constructor.
569
570 Args:
571 output: output file object.
572 """
573 self.output = output
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800574 super(ShellWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800575
576 def handshake_ok(self):
577 pass
578
579 def opened(self):
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800580 def _FeedInput():
581 try:
582 while True:
583 data = sys.stdin.read(1)
584
Peter Shihf84a8972017-06-19 15:18:24 +0800585 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800586 self.send(_STDIN_CLOSED * 2)
587 break
588 self.send(data, binary=True)
589 except (KeyboardInterrupt, RuntimeError):
590 pass
591
592 t = threading.Thread(target=_FeedInput)
593 t.daemon = True
594 t.start()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800595
596 def closed(self, code, reason=None):
597 pass
598
599 def received_message(self, msg):
600 if msg.is_binary:
601 self.output.write(msg.data)
602 self.output.flush()
603
604
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800605class ForwarderWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800606 def __init__(self, state, sock, *args, **kwargs):
607 super(ForwarderWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800608 self._sock = sock
609 self._stop = threading.Event()
610
611 def handshake_ok(self):
612 pass
613
614 def opened(self):
615 def _FeedInput():
616 try:
617 self._sock.setblocking(False)
618 while True:
619 rd, unused_w, unused_x = select.select([self._sock], [], [], 0.5)
620 if self._stop.is_set():
621 break
622 if self._sock in rd:
623 data = self._sock.recv(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +0800624 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800625 self.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800626 break
627 self.send(data, binary=True)
628 except Exception:
629 pass
630 finally:
631 self._sock.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800632
633 t = threading.Thread(target=_FeedInput)
634 t.daemon = True
635 t.start()
636
637 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800638 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800639 self._stop.set()
640 sys.exit(0)
641
642 def received_message(self, msg):
643 if msg.is_binary:
644 self._sock.send(msg.data)
645
646
647def Arg(*args, **kwargs):
648 return (args, kwargs)
649
650
651def Command(command, help_msg=None, args=None):
652 """Decorator for adding argparse parameter for a method."""
653 if args is None:
654 args = []
655 def WrapFunc(func):
656 def Wrapped(*args, **kwargs):
657 return func(*args, **kwargs)
658 # pylint: disable=W0212
659 Wrapped.__arg_attr = {'command': command, 'help': help_msg, 'args': args}
660 return Wrapped
661 return WrapFunc
662
663
664def ParseMethodSubCommands(cls):
665 """Decorator for a class using the @Command decorator.
666
667 This decorator retrieve command info from each method and append it in to the
668 SUBCOMMANDS class variable, which is later used to construct parser.
669 """
670 for unused_key, method in cls.__dict__.iteritems():
671 if hasattr(method, '__arg_attr'):
672 cls.SUBCOMMANDS.append(method.__arg_attr) # pylint: disable=W0212
673 return cls
674
675
676@ParseMethodSubCommands
677class OverlordCLIClient(object):
678 """Overlord command line interface client."""
679
680 SUBCOMMANDS = []
681
682 def __init__(self):
683 self._parser = self._BuildParser()
684 self._selected_mid = None
685 self._server = None
686 self._state = None
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800687 self._escape = None
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800688
689 def _BuildParser(self):
690 root_parser = argparse.ArgumentParser(prog='ovl')
691 subparsers = root_parser.add_subparsers(help='sub-command')
692
693 root_parser.add_argument('-s', dest='selected_mid', action='store',
694 default=None,
695 help='select target to execute command on')
696 root_parser.add_argument('-S', dest='select_mid_before_action',
697 action='store_true', default=False,
698 help='select target before executing command')
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800699 root_parser.add_argument('-e', dest='escape', metavar='ESCAPE_CHAR',
700 action='store', default=_ESCAPE, type=str,
701 help='set shell escape character, \'none\' to '
702 'disable escape completely')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800703
704 for attr in self.SUBCOMMANDS:
705 parser = subparsers.add_parser(attr['command'], help=attr['help'])
706 parser.set_defaults(which=attr['command'])
707 for arg in attr['args']:
708 parser.add_argument(*arg[0], **arg[1])
709
710 return root_parser
711
712 def Main(self):
713 # We want to pass the rest of arguments after shell command directly to the
714 # function without parsing it.
715 try:
716 index = sys.argv.index('shell')
717 except ValueError:
718 args = self._parser.parse_args()
719 else:
720 args = self._parser.parse_args(sys.argv[1:index + 1])
721
722 command = args.which
723 self._selected_mid = args.selected_mid
724
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800725 if args.escape and args.escape != 'none':
726 self._escape = args.escape[0]
727
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800728 if command == 'start-server':
729 self.StartServer()
730 return
731 elif command == 'kill-server':
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800732 self.KillServer()
733 return
734
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800735 self.CheckDaemon()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800736 if command == 'status':
737 self.Status()
738 return
739 elif command == 'connect':
740 self.Connect(args)
741 return
742
743 # The following command requires connection to the server
744 self.CheckConnection()
745
746 if args.select_mid_before_action:
747 self.SelectClient(store=False)
748
749 if command == 'select':
750 self.SelectClient(args)
751 elif command == 'ls':
752 self.ListClients()
753 elif command == 'shell':
754 command = sys.argv[sys.argv.index('shell') + 1:]
755 self.Shell(command)
756 elif command == 'push':
757 self.Push(args)
758 elif command == 'pull':
759 self.Pull(args)
760 elif command == 'forward':
761 self.Forward(args)
762
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800763 def _SaveTLSCertificate(self, host, cert_pem):
764 try:
765 os.makedirs(_CERT_DIR)
766 except Exception:
767 pass
768 with open(GetTLSCertPath(host), 'w') as f:
769 f.write(cert_pem)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800770
771 def _HTTPPostFile(self, url, filename, progress=None, user=None, passwd=None):
772 """Perform HTTP POST and upload file to Overlord.
773
774 To minimize the external dependencies, we construct the HTTP post request
775 by ourselves.
776 """
777 url = MakeRequestUrl(self._state, url)
778 size = os.stat(filename).st_size
779 boundary = '-----------%s' % _HTTP_BOUNDARY_MAGIC
780 CRLF = '\r\n'
781 parse = urlparse.urlparse(url)
782
783 part_headers = [
784 '--' + boundary,
785 'Content-Disposition: form-data; name="file"; '
786 'filename="%s"' % os.path.basename(filename),
787 'Content-Type: application/octet-stream',
788 '', ''
789 ]
790 part_header = CRLF.join(part_headers)
791 end_part = CRLF + '--' + boundary + '--' + CRLF
792
793 content_length = len(part_header) + size + len(end_part)
794 if parse.scheme == 'http':
795 h = httplib.HTTP(parse.netloc)
796 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800797 h = httplib.HTTPS(parse.netloc, context=self._state.ssl_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800798
799 post_path = url[url.index(parse.netloc) + len(parse.netloc):]
800 h.putrequest('POST', post_path)
801 h.putheader('Content-Length', content_length)
802 h.putheader('Content-Type', 'multipart/form-data; boundary=%s' % boundary)
803
804 if user and passwd:
805 h.putheader(*BasicAuthHeader(user, passwd))
806 h.endheaders()
807 h.send(part_header)
808
809 count = 0
810 with open(filename, 'r') as f:
811 while True:
812 data = f.read(_BUFSIZ)
813 if not data:
814 break
815 count += len(data)
816 if progress:
817 progress(int(count * 100.0 / size), count)
818 h.send(data)
819
820 h.send(end_part)
821 progress(100)
822
823 if count != size:
824 logging.warning('file changed during upload, upload may be truncated.')
825
826 errcode, unused_x, unused_y = h.getreply()
827 return errcode == 200
828
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800829 def CheckDaemon(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800830 self._server = OverlordClientDaemon.GetRPCServer()
831 if self._server is None:
832 print('* daemon not running, starting it now on port %d ... *' %
833 _OVERLORD_CLIENT_DAEMON_PORT)
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800834 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800835
836 self._state = self._server.State()
837 sha1sum = GetVersionDigest()
838
839 if sha1sum != self._state.version_sha1sum:
840 print('ovl server is out of date. killing...')
841 KillGraceful(self._server.GetPid())
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800842 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800843
844 def GetSSHControlFile(self, host):
845 return _SSH_CONTROL_SOCKET_PREFIX + host
846
847 def SSHTunnel(self, user, host, port):
848 """SSH forward the remote overlord server.
849
850 Overlord server may not have port 9000 open to the public network, in such
851 case we can SSH forward the port to localhost.
852 """
853
854 control_file = self.GetSSHControlFile(host)
855 try:
856 os.unlink(control_file)
857 except Exception:
858 pass
859
860 subprocess.Popen([
861 'ssh', '-Nf',
862 '-M', # Enable master mode
863 '-S', control_file,
864 '-L', '9000:localhost:9000',
865 '-p', str(port),
866 '%s%s' % (user + '@' if user else '', host)
867 ]).wait()
868
869 p = subprocess.Popen([
870 'ssh',
871 '-S', control_file,
872 '-O', 'check', host,
873 ], stderr=subprocess.PIPE)
874 unused_stdout, stderr = p.communicate()
875
876 s = re.search(r'pid=(\d+)', stderr)
877 if s:
878 return int(s.group(1))
879
880 raise RuntimeError('can not establish ssh connection')
881
882 def CheckConnection(self):
883 if self._state.host is None:
884 raise RuntimeError('not connected to any server, abort')
885
886 try:
887 self._server.Clients()
888 except Exception:
889 raise RuntimeError('remote server disconnected, abort')
890
891 if self._state.ssh_pid is not None:
892 ret = subprocess.Popen(['kill', '-0', str(self._state.ssh_pid)],
893 stdout=subprocess.PIPE,
894 stderr=subprocess.PIPE).wait()
895 if ret != 0:
896 raise RuntimeError('ssh tunnel disconnected, please re-connect')
897
898 def CheckClient(self):
899 if self._selected_mid is None:
900 if self._state.selected_mid is None:
901 raise RuntimeError('No client is selected')
902 self._selected_mid = self._state.selected_mid
903
904 if self._selected_mid not in self._server.Clients():
905 raise RuntimeError('client %s disappeared' % self._selected_mid)
906
907 def CheckOutput(self, command):
908 headers = []
909 if self._state.username is not None and self._state.password is not None:
910 headers.append(BasicAuthHeader(self._state.username,
911 self._state.password))
912
913 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
914 sio = StringIO.StringIO()
Peter Shihe03450b2017-06-14 14:02:08 +0800915 ws = ShellWebSocketClient(
916 self._state, sio, scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
917 self._state.host, self._state.port,
918 urllib2.quote(self._selected_mid), urllib2.quote(command)),
919 headers=headers)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800920 ws.connect()
921 ws.run()
922 return sio.getvalue()
923
924 @Command('status', 'show Overlord connection status')
925 def Status(self):
926 if self._state.host is None:
927 print('Not connected to any host.')
928 else:
929 if self._state.ssh_pid is not None:
930 print('Connected to %s with SSH tunneling.' % self._state.orig_host)
931 else:
932 print('Connected to %s:%d.' % (self._state.host, self._state.port))
933
934 if self._selected_mid is None:
935 self._selected_mid = self._state.selected_mid
936
937 if self._selected_mid is None:
938 print('No client is selected.')
939 else:
940 print('Client %s selected.' % self._selected_mid)
941
942 @Command('connect', 'connect to Overlord server', [
943 Arg('host', metavar='HOST', type=str, default='localhost',
944 help='Overlord hostname/IP'),
945 Arg('port', metavar='PORT', type=int,
946 default=_OVERLORD_HTTP_PORT, help='Overlord port'),
947 Arg('-f', '--forward', dest='ssh_forward', default=False,
948 action='store_true',
949 help='connect with SSH forwarding to the host'),
950 Arg('-p', '--ssh-port', dest='ssh_port', default=22,
951 type=int, help='SSH server port for SSH forwarding'),
952 Arg('-l', '--ssh-login', dest='ssh_login', default='',
953 type=str, help='SSH server login name for SSH forwarding'),
954 Arg('-u', '--user', dest='user', default=None,
955 type=str, help='Overlord HTTP auth username'),
956 Arg('-w', '--passwd', dest='passwd', default=None, type=str,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800957 help='Overlord HTTP auth password'),
958 Arg('-i', '--no-check-hostname', dest='check_hostname',
959 default=True, action='store_false',
960 help='Ignore SSL cert hostname check')])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800961 def Connect(self, args):
962 ssh_pid = None
963 host = args.host
964 orig_host = args.host
965
966 if args.ssh_forward:
967 # Kill previous SSH tunnel
968 self.KillSSHTunnel()
969
970 ssh_pid = self.SSHTunnel(args.ssh_login, args.host, args.ssh_port)
971 host = 'localhost'
972
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800973 username_provided = args.user is not None
974 password_provided = args.passwd is not None
975 prompt = False
976
977 for unused_i in range(3):
978 try:
979 if prompt:
980 if not username_provided:
981 args.user = raw_input('Username: ')
982 if not password_provided:
983 args.passwd = getpass.getpass('Password: ')
984
985 ret = self._server.Connect(host, args.port, ssh_pid, args.user,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800986 args.passwd, orig_host,
987 args.check_hostname)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800988 if isinstance(ret, list):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800989 if ret[0].startswith('SSL'):
990 cert_pem = ret[1]
991 fp = GetTLSCertificateSHA1Fingerprint(cert_pem)
992 fp_text = ':'.join([fp[i:i+2] for i in range(0, len(fp), 2)])
993
994 if ret[0] == 'SSLCertificateChanged':
995 print(_TLS_CERT_CHANGED_WARNING % (fp_text, GetTLSCertPath(host)))
996 return
997 elif ret[0] == 'SSLVerifyFailed':
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800998 print(_TLS_CERT_FAILED_WARNING % (fp_text), end='')
999 response = raw_input()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001000 if response.lower() in ['y', 'ye', 'yes']:
1001 self._SaveTLSCertificate(host, cert_pem)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001002 print('TLS host Certificate trusted, you will not be prompted '
1003 'next time.\n')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001004 continue
1005 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001006 print('connection aborted.')
1007 return
1008 elif ret[0] == 'HTTPError':
1009 code, except_str, body = ret[1:]
1010 if code == 401:
1011 print('connect: %s' % body)
1012 prompt = True
1013 if not username_provided or not password_provided:
1014 continue
1015 else:
1016 break
1017 else:
1018 logging.error('%s; %s', except_str, body)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001019
1020 if ret is not True:
1021 print('can not connect to %s: %s' % (host, ret))
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001022 else:
1023 print('connection to %s:%d established.' % (host, args.port))
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001024 except Exception as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001025 logging.error(e)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001026 else:
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001027 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001028
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001029 @Command('start-server', 'start overlord CLI client server')
1030 def StartServer(self):
1031 self._server = OverlordClientDaemon.GetRPCServer()
1032 if self._server is None:
1033 OverlordClientDaemon().Start()
1034 time.sleep(1)
1035 self._server = OverlordClientDaemon.GetRPCServer()
1036 if self._server is not None:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001037 print('* daemon started successfully *\n')
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001038
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001039 @Command('kill-server', 'kill overlord CLI client server')
1040 def KillServer(self):
1041 self._server = OverlordClientDaemon.GetRPCServer()
1042 if self._server is None:
1043 return
1044
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001045 self._state = self._server.State()
1046
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001047 # Kill SSH Tunnel
1048 self.KillSSHTunnel()
1049
1050 # Kill server daemon
1051 KillGraceful(self._server.GetPid())
1052
1053 def KillSSHTunnel(self):
1054 if self._state.ssh_pid is not None:
1055 KillGraceful(self._state.ssh_pid)
1056
1057 @Command('ls', 'list all clients')
1058 def ListClients(self):
1059 for client in self._server.Clients():
1060 print(client)
1061
1062 @Command('select', 'select default client', [
1063 Arg('mid', metavar='mid', nargs='?', default=None)])
1064 def SelectClient(self, args=None, store=True):
1065 clients = self._server.Clients()
1066
1067 mid = args.mid if args is not None else None
1068 if mid is None:
1069 print('Select from the following clients:')
1070 for i, client in enumerate(clients):
1071 print(' %d. %s' % (i + 1, client))
1072
1073 print('\nSelection: ', end='')
1074 try:
1075 choice = int(raw_input()) - 1
1076 mid = clients[choice]
1077 except ValueError:
1078 raise RuntimeError('select: invalid selection')
1079 except IndexError:
1080 raise RuntimeError('select: selection out of range')
1081 else:
1082 if mid not in clients:
1083 raise RuntimeError('select: client %s does not exist' % mid)
1084
1085 self._selected_mid = mid
1086 if store:
1087 self._server.SelectClient(mid)
1088 print('Client %s selected' % mid)
1089
1090 @Command('shell', 'open a shell or execute a shell command', [
1091 Arg('command', metavar='CMD', nargs='?', help='command to execute')])
1092 def Shell(self, command=None):
1093 if command is None:
1094 command = []
1095 self.CheckClient()
1096
1097 headers = []
1098 if self._state.username is not None and self._state.password is not None:
1099 headers.append(BasicAuthHeader(self._state.username,
1100 self._state.password))
1101
1102 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
Peter Shihe03450b2017-06-14 14:02:08 +08001103 if command:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001104 cmd = ' '.join(command)
Peter Shihe03450b2017-06-14 14:02:08 +08001105 ws = ShellWebSocketClient(
1106 self._state, sys.stdout,
1107 scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
1108 self._state.host, self._state.port,
1109 urllib2.quote(self._selected_mid), urllib2.quote(cmd)),
1110 headers=headers)
1111 else:
1112 ws = TerminalWebSocketClient(
1113 self._state, self._selected_mid, self._escape,
1114 scheme + '%s:%d/api/agent/tty/%s' % (
1115 self._state.host, self._state.port,
1116 urllib2.quote(self._selected_mid)),
1117 headers=headers)
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001118 try:
1119 ws.connect()
1120 ws.run()
1121 except socket.error as e:
1122 if e.errno == 32: # Broken pipe
1123 pass
1124 else:
1125 raise
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001126
1127 @Command('push', 'push a file or directory to remote', [
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001128 Arg('srcs', nargs='+', metavar='SOURCE'),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001129 Arg('dst', metavar='DESTINATION')])
1130 def Push(self, args):
1131 self.CheckClient()
1132
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001133 @AutoRetry('push', _RETRY_TIMES)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001134 def _push(src, dst):
1135 src_base = os.path.basename(src)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001136
1137 # Local file is a link
1138 if os.path.islink(src):
1139 pbar = ProgressBar(src_base)
1140 link_path = os.readlink(src)
1141 self.CheckOutput('mkdir -p %(dirname)s; '
1142 'if [ -d "%(dst)s" ]; then '
1143 'ln -sf "%(link_path)s" "%(dst)s/%(link_name)s"; '
1144 'else ln -sf "%(link_path)s" "%(dst)s"; fi' %
1145 dict(dirname=os.path.dirname(dst),
1146 link_path=link_path, dst=dst,
1147 link_name=src_base))
1148 pbar.End()
1149 return
1150
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001151 mode = '0%o' % (0x1FF & os.stat(src).st_mode)
1152 url = ('%s:%d/api/agent/upload/%s?dest=%s&perm=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001153 (self._state.host, self._state.port,
1154 urllib2.quote(self._selected_mid), dst, mode))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001155 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001156 UrlOpen(self._state, url + '&filename=%s' % src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001157 except urllib2.HTTPError as e:
1158 msg = json.loads(e.read()).get('error', None)
1159 raise RuntimeError('push: %s' % msg)
1160
1161 pbar = ProgressBar(src_base)
1162 self._HTTPPostFile(url, src, pbar.SetProgress,
1163 self._state.username, self._state.password)
1164 pbar.End()
1165
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001166 def _push_single_target(src, dst):
1167 if os.path.isdir(src):
1168 dst_exists = ast.literal_eval(self.CheckOutput(
1169 'stat %s >/dev/null 2>&1 && echo True || echo False' % dst))
1170 for root, unused_x, files in os.walk(src):
1171 # If destination directory does not exist, we should strip the first
1172 # layer of directory. For example: src_dir contains a single file 'A'
1173 #
1174 # push src_dir dest_dir
1175 #
1176 # If dest_dir exists, the resulting directory structure should be:
1177 # dest_dir/src_dir/A
1178 # If dest_dir does not exist, the resulting directory structure should
1179 # be:
1180 # dest_dir/A
1181 dst_root = root if dst_exists else root[len(src):].lstrip('/')
1182 for name in files:
1183 _push(os.path.join(root, name),
1184 os.path.join(dst, dst_root, name))
1185 else:
1186 _push(src, dst)
1187
1188 if len(args.srcs) > 1:
1189 dst_type = self.CheckOutput('stat \'%s\' --printf \'%%F\' '
1190 '2>/dev/null' % args.dst).strip()
1191 if not dst_type:
1192 raise RuntimeError('push: %s: No such file or directory' % args.dst)
1193 if dst_type != 'directory':
1194 raise RuntimeError('push: %s: Not a directory' % args.dst)
1195
1196 for src in args.srcs:
1197 if not os.path.exists(src):
1198 raise RuntimeError('push: can not stat "%s": no such file or directory'
1199 % src)
1200 if not os.access(src, os.R_OK):
1201 raise RuntimeError('push: can not open "%s" for reading' % src)
1202
1203 _push_single_target(src, args.dst)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001204
1205 @Command('pull', 'pull a file or directory from remote', [
1206 Arg('src', metavar='SOURCE'),
1207 Arg('dst', metavar='DESTINATION', default='.', nargs='?')])
1208 def Pull(self, args):
1209 self.CheckClient()
1210
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001211 @AutoRetry('pull', _RETRY_TIMES)
1212 def _pull(src, dst, ftype, perm=0644, link=None):
1213 try:
1214 os.makedirs(os.path.dirname(dst))
1215 except Exception:
1216 pass
1217
1218 src_base = os.path.basename(src)
1219
1220 # Remote file is a link
1221 if ftype == 'l':
1222 pbar = ProgressBar(src_base)
1223 if os.path.exists(dst):
1224 os.remove(dst)
1225 os.symlink(link, dst)
1226 pbar.End()
1227 return
1228
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001229 url = ('%s:%d/api/agent/download/%s?filename=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001230 (self._state.host, self._state.port,
1231 urllib2.quote(self._selected_mid), urllib2.quote(src)))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001232 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001233 h = UrlOpen(self._state, url)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001234 except urllib2.HTTPError as e:
1235 msg = json.loads(e.read()).get('error', 'unkown error')
1236 raise RuntimeError('pull: %s' % msg)
1237 except KeyboardInterrupt:
1238 return
1239
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001240 pbar = ProgressBar(src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001241 with open(dst, 'w') as f:
1242 os.fchmod(f.fileno(), perm)
1243 total_size = int(h.headers.get('Content-Length'))
1244 downloaded_size = 0
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001245
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001246 while True:
1247 data = h.read(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +08001248 if not data:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001249 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001250 downloaded_size += len(data)
1251 pbar.SetProgress(float(downloaded_size) * 100 / total_size,
1252 downloaded_size)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001253 f.write(data)
1254 pbar.End()
1255
1256 # Use find to get a listing of all files under a root directory. The 'stat'
1257 # command is used to retrieve the filename and it's filemode.
1258 output = self.CheckOutput(
1259 'cd $HOME; '
1260 'stat "%(src)s" >/dev/null && '
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001261 'find "%(src)s" \'(\' -type f -o -type l \')\' '
1262 '-printf \'%%m\t%%p\t%%y\t%%l\n\''
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001263 % {'src': args.src})
1264
1265 # We got error from the stat command
1266 if output.startswith('stat: '):
1267 sys.stderr.write(output)
1268 return
1269
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001270 entries = output.strip('\n').split('\n')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001271 common_prefix = os.path.dirname(args.src)
1272
1273 if len(entries) == 1:
1274 entry = entries[0]
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001275 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001276 if os.path.isdir(args.dst):
1277 dst = os.path.join(args.dst, os.path.basename(src_path))
1278 else:
1279 dst = args.dst
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001280 _pull(src_path, dst, ftype, int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001281 else:
1282 if not os.path.exists(args.dst):
1283 common_prefix = args.src
1284
1285 for entry in entries:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001286 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001287 rel_dst = src_path[len(common_prefix):].lstrip('/')
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001288 _pull(src_path, os.path.join(args.dst, rel_dst), ftype,
1289 int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001290
1291 @Command('forward', 'forward remote port to local port', [
1292 Arg('--list', dest='list_all', action='store_true', default=False,
1293 help='list all port forwarding sessions'),
1294 Arg('--remove', metavar='LOCAL_PORT', dest='remove', type=int,
1295 default=None,
1296 help='remove port forwarding for local port LOCAL_PORT'),
1297 Arg('--remove-all', dest='remove_all', action='store_true',
1298 default=False, help='remove all port forwarding'),
1299 Arg('remote', metavar='REMOTE_PORT', type=int, nargs='?'),
1300 Arg('local', metavar='LOCAL_PORT', type=int, nargs='?')])
1301 def Forward(self, args):
1302 if args.list_all:
1303 max_len = 10
Peter Shihf84a8972017-06-19 15:18:24 +08001304 if self._state.forwards:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001305 max_len = max([len(v[0]) for v in self._state.forwards.values()])
1306
1307 print('%-*s %-8s %-8s' % (max_len, 'Client', 'Remote', 'Local'))
1308 for local in sorted(self._state.forwards.keys()):
1309 value = self._state.forwards[local]
1310 print('%-*s %-8s %-8s' % (max_len, value[0], value[1], local))
1311 return
1312
1313 if args.remove_all:
1314 self._server.RemoveAllForward()
1315 return
1316
1317 if args.remove:
1318 self._server.RemoveForward(args.remove)
1319 return
1320
1321 self.CheckClient()
1322
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001323 if args.remote is None:
1324 raise RuntimeError('remote port not specified')
1325
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001326 if args.local is None:
1327 args.local = args.remote
1328 remote = int(args.remote)
1329 local = int(args.local)
1330
1331 def HandleConnection(conn):
1332 headers = []
1333 if self._state.username is not None and self._state.password is not None:
1334 headers.append(BasicAuthHeader(self._state.username,
1335 self._state.password))
1336
1337 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1338 ws = ForwarderWebSocketClient(
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001339 self._state, conn,
Peter Shihe03450b2017-06-14 14:02:08 +08001340 scheme + '%s:%d/api/agent/forward/%s?port=%d' % (
1341 self._state.host, self._state.port,
1342 urllib2.quote(self._selected_mid), remote),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001343 headers=headers)
1344 try:
1345 ws.connect()
1346 ws.run()
1347 except Exception as e:
1348 print('error: %s' % e)
1349 finally:
1350 ws.close()
1351
1352 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1353 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1354 server.bind(('0.0.0.0', local))
1355 server.listen(5)
1356
1357 pid = os.fork()
1358 if pid == 0:
1359 while True:
1360 conn, unused_addr = server.accept()
1361 t = threading.Thread(target=HandleConnection, args=(conn,))
1362 t.daemon = True
1363 t.start()
1364 else:
1365 self._server.AddForward(self._selected_mid, remote, local, pid)
1366
1367
1368def main():
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001369 # Setup logging format
1370 logger = logging.getLogger()
1371 logger.setLevel(logging.INFO)
1372 handler = logging.StreamHandler()
1373 formatter = logging.Formatter('%(asctime)s %(message)s', '%Y/%m/%d %H:%M:%S')
1374 handler.setFormatter(formatter)
1375 logger.addHandler(handler)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001376
1377 # Add DaemonState to JSONRPC lib classes
1378 Config.instance().classes.add(DaemonState)
1379
1380 ovl = OverlordCLIClient()
1381 try:
1382 ovl.Main()
1383 except KeyboardInterrupt:
1384 print('Ctrl-C received, abort')
1385 except Exception as e:
1386 print('error: %s' % e)
1387
1388
1389if __name__ == '__main__':
1390 main()