blob: 8567275feb784273e12441e07530c719e41a063a [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
370 def _CheckTLSCertificate(self):
371 """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
399 tls_context.check_hostname = True
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,
409 username=None, password=None, orig_host=None):
410 self._state.username = username
411 self._state.password = password
412 self._state.host = host
413 self._state.port = port
414 self._state.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800415 self._state.ssl_self_signed = False
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800416 self._state.orig_host = orig_host
417 self._state.ssh_pid = ssh_pid
418 self._state.selected_mid = None
419
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800420 tls_enabled = self._TLSEnabled()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800421 if tls_enabled:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800422 result = self._CheckTLSCertificate()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800423 if not result:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800424 if self._state.ssl_self_signed:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800425 return ('SSLCertificateChanged', ssl.get_server_certificate(
426 (self._state.host, self._state.port)))
427 else:
428 return ('SSLVerifyFailed', ssl.get_server_certificate(
429 (self._state.host, self._state.port)))
430
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800431 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800432 self._state.ssl = tls_enabled
433 UrlOpen(self._state, '%s:%d' % (host, port))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800434 except urllib2.HTTPError as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800435 return ('HTTPError', e.getcode(), str(e), e.read().strip())
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800436 except Exception as e:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800437 return str(e)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800438 else:
439 return True
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800440
441 def Clients(self):
442 if time.time() - self._state.last_list <= _LIST_CACHE_TIMEOUT:
443 return self._state.listing
444
445 mids = [client['mid'] for client in self._GetJSON('/api/agents/list')]
446 self._state.listing = sorted(list(set(mids)))
447 self._state.last_list = time.time()
448 return self._state.listing
449
450 def SelectClient(self, mid):
451 self._state.selected_mid = mid
452
453 def AddForward(self, mid, remote, local, pid):
454 self._state.forwards[local] = (mid, remote, pid)
455
456 def RemoveForward(self, local_port):
457 try:
458 unused_mid, unused_remote, pid = self._state.forwards[local_port]
459 KillGraceful(pid)
460 del self._state.forwards[local_port]
461 except (KeyError, OSError):
462 pass
463
464 def RemoveAllForward(self):
465 for unused_mid, unused_remote, pid in self._state.forwards.values():
466 try:
467 KillGraceful(pid)
468 except OSError:
469 pass
470 self._state.forwards = {}
471
472
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800473class SSLEnabledWebSocketBaseClient(WebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800474 def __init__(self, state, *args, **kwargs):
475 cafile = ssl.get_default_verify_paths().openssl_cafile
476 # For some system / distribution, python can not detect system cafile path.
477 # In such case we fallback to the default path.
478 if not os.path.exists(cafile):
479 cafile = '/etc/ssl/certs/ca-certificates.crt'
480
481 if state.ssl_self_signed:
482 cafile = GetTLSCertPath(state.host)
483
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800484 ssl_options = {
485 'cert_reqs': ssl.CERT_REQUIRED,
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800486 'ca_certs': cafile
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800487 }
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800488 # ws4py does not allow you to specify SSLContext, but rather passing in the
489 # argument of ssl.wrap_socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800490 super(SSLEnabledWebSocketBaseClient, self).__init__(
491 ssl_options=ssl_options, *args, **kwargs)
492
493
494class TerminalWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800495 def __init__(self, state, mid, escape, *args, **kwargs):
496 super(TerminalWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800497 self._mid = mid
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800498 self._escape = escape
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800499 self._stdin_fd = sys.stdin.fileno()
500 self._old_termios = None
501
502 def handshake_ok(self):
503 pass
504
505 def opened(self):
506 nonlocals = {'size': (80, 40)}
507
508 def _ResizeWindow():
509 size = GetTerminalSize()
510 if size != nonlocals['size']: # Size not changed, ignore
511 control = {'command': 'resize', 'params': list(size)}
512 payload = chr(_CONTROL_START) + json.dumps(control) + chr(_CONTROL_END)
513 nonlocals['size'] = size
514 try:
515 self.send(payload, binary=True)
516 except Exception:
517 pass
518
519 def _FeedInput():
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800520 self._old_termios = termios.tcgetattr(self._stdin_fd)
521 tty.setraw(self._stdin_fd)
522
523 READY, ENTER_PRESSED, ESCAPE_PRESSED = range(3)
524
525 try:
526 state = READY
527 while True:
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800528 # Check if terminal is resized
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800529 _ResizeWindow()
530
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800531 ch = sys.stdin.read(1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800532
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800533 # Scan for escape sequence
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800534 if self._escape:
535 if state == READY:
536 state = ENTER_PRESSED if ch == chr(0x0d) else READY
537 elif state == ENTER_PRESSED:
538 state = ESCAPE_PRESSED if ch == self._escape else READY
539 elif state == ESCAPE_PRESSED:
540 if ch == '.':
541 self.close()
542 break
543 else:
544 state = READY
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800545
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800546 self.send(ch)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800547 except (KeyboardInterrupt, RuntimeError):
548 pass
549
550 t = threading.Thread(target=_FeedInput)
551 t.daemon = True
552 t.start()
553
554 def closed(self, code, reason=None):
555 termios.tcsetattr(self._stdin_fd, termios.TCSANOW, self._old_termios)
556 print('Connection to %s closed.' % self._mid)
557
558 def received_message(self, msg):
559 if msg.is_binary:
560 sys.stdout.write(msg.data)
561 sys.stdout.flush()
562
563
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800564class ShellWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800565 def __init__(self, state, output, *args, **kwargs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800566 """Constructor.
567
568 Args:
569 output: output file object.
570 """
571 self.output = output
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800572 super(ShellWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800573
574 def handshake_ok(self):
575 pass
576
577 def opened(self):
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800578 def _FeedInput():
579 try:
580 while True:
581 data = sys.stdin.read(1)
582
583 if len(data) == 0:
584 self.send(_STDIN_CLOSED * 2)
585 break
586 self.send(data, binary=True)
587 except (KeyboardInterrupt, RuntimeError):
588 pass
589
590 t = threading.Thread(target=_FeedInput)
591 t.daemon = True
592 t.start()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800593
594 def closed(self, code, reason=None):
595 pass
596
597 def received_message(self, msg):
598 if msg.is_binary:
599 self.output.write(msg.data)
600 self.output.flush()
601
602
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800603class ForwarderWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800604 def __init__(self, state, sock, *args, **kwargs):
605 super(ForwarderWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800606 self._sock = sock
607 self._stop = threading.Event()
608
609 def handshake_ok(self):
610 pass
611
612 def opened(self):
613 def _FeedInput():
614 try:
615 self._sock.setblocking(False)
616 while True:
617 rd, unused_w, unused_x = select.select([self._sock], [], [], 0.5)
618 if self._stop.is_set():
619 break
620 if self._sock in rd:
621 data = self._sock.recv(_BUFSIZ)
622 if len(data) == 0:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800623 self.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800624 break
625 self.send(data, binary=True)
626 except Exception:
627 pass
628 finally:
629 self._sock.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800630
631 t = threading.Thread(target=_FeedInput)
632 t.daemon = True
633 t.start()
634
635 def closed(self, code, reason=None):
636 self._stop.set()
637 sys.exit(0)
638
639 def received_message(self, msg):
640 if msg.is_binary:
641 self._sock.send(msg.data)
642
643
644def Arg(*args, **kwargs):
645 return (args, kwargs)
646
647
648def Command(command, help_msg=None, args=None):
649 """Decorator for adding argparse parameter for a method."""
650 if args is None:
651 args = []
652 def WrapFunc(func):
653 def Wrapped(*args, **kwargs):
654 return func(*args, **kwargs)
655 # pylint: disable=W0212
656 Wrapped.__arg_attr = {'command': command, 'help': help_msg, 'args': args}
657 return Wrapped
658 return WrapFunc
659
660
661def ParseMethodSubCommands(cls):
662 """Decorator for a class using the @Command decorator.
663
664 This decorator retrieve command info from each method and append it in to the
665 SUBCOMMANDS class variable, which is later used to construct parser.
666 """
667 for unused_key, method in cls.__dict__.iteritems():
668 if hasattr(method, '__arg_attr'):
669 cls.SUBCOMMANDS.append(method.__arg_attr) # pylint: disable=W0212
670 return cls
671
672
673@ParseMethodSubCommands
674class OverlordCLIClient(object):
675 """Overlord command line interface client."""
676
677 SUBCOMMANDS = []
678
679 def __init__(self):
680 self._parser = self._BuildParser()
681 self._selected_mid = None
682 self._server = None
683 self._state = None
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800684 self._escape = None
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800685
686 def _BuildParser(self):
687 root_parser = argparse.ArgumentParser(prog='ovl')
688 subparsers = root_parser.add_subparsers(help='sub-command')
689
690 root_parser.add_argument('-s', dest='selected_mid', action='store',
691 default=None,
692 help='select target to execute command on')
693 root_parser.add_argument('-S', dest='select_mid_before_action',
694 action='store_true', default=False,
695 help='select target before executing command')
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800696 root_parser.add_argument('-e', dest='escape', metavar='ESCAPE_CHAR',
697 action='store', default=_ESCAPE, type=str,
698 help='set shell escape character, \'none\' to '
699 'disable escape completely')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800700
701 for attr in self.SUBCOMMANDS:
702 parser = subparsers.add_parser(attr['command'], help=attr['help'])
703 parser.set_defaults(which=attr['command'])
704 for arg in attr['args']:
705 parser.add_argument(*arg[0], **arg[1])
706
707 return root_parser
708
709 def Main(self):
710 # We want to pass the rest of arguments after shell command directly to the
711 # function without parsing it.
712 try:
713 index = sys.argv.index('shell')
714 except ValueError:
715 args = self._parser.parse_args()
716 else:
717 args = self._parser.parse_args(sys.argv[1:index + 1])
718
719 command = args.which
720 self._selected_mid = args.selected_mid
721
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800722 if args.escape and args.escape != 'none':
723 self._escape = args.escape[0]
724
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800725 if command == 'start-server':
726 self.StartServer()
727 return
728 elif command == 'kill-server':
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800729 self.KillServer()
730 return
731
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800732 self.CheckDaemon()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800733 if command == 'status':
734 self.Status()
735 return
736 elif command == 'connect':
737 self.Connect(args)
738 return
739
740 # The following command requires connection to the server
741 self.CheckConnection()
742
743 if args.select_mid_before_action:
744 self.SelectClient(store=False)
745
746 if command == 'select':
747 self.SelectClient(args)
748 elif command == 'ls':
749 self.ListClients()
750 elif command == 'shell':
751 command = sys.argv[sys.argv.index('shell') + 1:]
752 self.Shell(command)
753 elif command == 'push':
754 self.Push(args)
755 elif command == 'pull':
756 self.Pull(args)
757 elif command == 'forward':
758 self.Forward(args)
759
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800760 def _SaveTLSCertificate(self, host, cert_pem):
761 try:
762 os.makedirs(_CERT_DIR)
763 except Exception:
764 pass
765 with open(GetTLSCertPath(host), 'w') as f:
766 f.write(cert_pem)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800767
768 def _HTTPPostFile(self, url, filename, progress=None, user=None, passwd=None):
769 """Perform HTTP POST and upload file to Overlord.
770
771 To minimize the external dependencies, we construct the HTTP post request
772 by ourselves.
773 """
774 url = MakeRequestUrl(self._state, url)
775 size = os.stat(filename).st_size
776 boundary = '-----------%s' % _HTTP_BOUNDARY_MAGIC
777 CRLF = '\r\n'
778 parse = urlparse.urlparse(url)
779
780 part_headers = [
781 '--' + boundary,
782 'Content-Disposition: form-data; name="file"; '
783 'filename="%s"' % os.path.basename(filename),
784 'Content-Type: application/octet-stream',
785 '', ''
786 ]
787 part_header = CRLF.join(part_headers)
788 end_part = CRLF + '--' + boundary + '--' + CRLF
789
790 content_length = len(part_header) + size + len(end_part)
791 if parse.scheme == 'http':
792 h = httplib.HTTP(parse.netloc)
793 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800794 h = httplib.HTTPS(parse.netloc, context=self._state.ssl_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800795
796 post_path = url[url.index(parse.netloc) + len(parse.netloc):]
797 h.putrequest('POST', post_path)
798 h.putheader('Content-Length', content_length)
799 h.putheader('Content-Type', 'multipart/form-data; boundary=%s' % boundary)
800
801 if user and passwd:
802 h.putheader(*BasicAuthHeader(user, passwd))
803 h.endheaders()
804 h.send(part_header)
805
806 count = 0
807 with open(filename, 'r') as f:
808 while True:
809 data = f.read(_BUFSIZ)
810 if not data:
811 break
812 count += len(data)
813 if progress:
814 progress(int(count * 100.0 / size), count)
815 h.send(data)
816
817 h.send(end_part)
818 progress(100)
819
820 if count != size:
821 logging.warning('file changed during upload, upload may be truncated.')
822
823 errcode, unused_x, unused_y = h.getreply()
824 return errcode == 200
825
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800826 def CheckDaemon(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800827 self._server = OverlordClientDaemon.GetRPCServer()
828 if self._server is None:
829 print('* daemon not running, starting it now on port %d ... *' %
830 _OVERLORD_CLIENT_DAEMON_PORT)
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800831 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800832
833 self._state = self._server.State()
834 sha1sum = GetVersionDigest()
835
836 if sha1sum != self._state.version_sha1sum:
837 print('ovl server is out of date. killing...')
838 KillGraceful(self._server.GetPid())
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800839 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800840
841 def GetSSHControlFile(self, host):
842 return _SSH_CONTROL_SOCKET_PREFIX + host
843
844 def SSHTunnel(self, user, host, port):
845 """SSH forward the remote overlord server.
846
847 Overlord server may not have port 9000 open to the public network, in such
848 case we can SSH forward the port to localhost.
849 """
850
851 control_file = self.GetSSHControlFile(host)
852 try:
853 os.unlink(control_file)
854 except Exception:
855 pass
856
857 subprocess.Popen([
858 'ssh', '-Nf',
859 '-M', # Enable master mode
860 '-S', control_file,
861 '-L', '9000:localhost:9000',
862 '-p', str(port),
863 '%s%s' % (user + '@' if user else '', host)
864 ]).wait()
865
866 p = subprocess.Popen([
867 'ssh',
868 '-S', control_file,
869 '-O', 'check', host,
870 ], stderr=subprocess.PIPE)
871 unused_stdout, stderr = p.communicate()
872
873 s = re.search(r'pid=(\d+)', stderr)
874 if s:
875 return int(s.group(1))
876
877 raise RuntimeError('can not establish ssh connection')
878
879 def CheckConnection(self):
880 if self._state.host is None:
881 raise RuntimeError('not connected to any server, abort')
882
883 try:
884 self._server.Clients()
885 except Exception:
886 raise RuntimeError('remote server disconnected, abort')
887
888 if self._state.ssh_pid is not None:
889 ret = subprocess.Popen(['kill', '-0', str(self._state.ssh_pid)],
890 stdout=subprocess.PIPE,
891 stderr=subprocess.PIPE).wait()
892 if ret != 0:
893 raise RuntimeError('ssh tunnel disconnected, please re-connect')
894
895 def CheckClient(self):
896 if self._selected_mid is None:
897 if self._state.selected_mid is None:
898 raise RuntimeError('No client is selected')
899 self._selected_mid = self._state.selected_mid
900
901 if self._selected_mid not in self._server.Clients():
902 raise RuntimeError('client %s disappeared' % self._selected_mid)
903
904 def CheckOutput(self, command):
905 headers = []
906 if self._state.username is not None and self._state.password is not None:
907 headers.append(BasicAuthHeader(self._state.username,
908 self._state.password))
909
910 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
911 sio = StringIO.StringIO()
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800912 ws = ShellWebSocketClient(self._state, sio,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800913 scheme + '%s:%d/api/agent/shell/%s?command=%s' %
914 (self._state.host, self._state.port,
915 self._selected_mid, urllib2.quote(command)),
916 headers=headers)
917 ws.connect()
918 ws.run()
919 return sio.getvalue()
920
921 @Command('status', 'show Overlord connection status')
922 def Status(self):
923 if self._state.host is None:
924 print('Not connected to any host.')
925 else:
926 if self._state.ssh_pid is not None:
927 print('Connected to %s with SSH tunneling.' % self._state.orig_host)
928 else:
929 print('Connected to %s:%d.' % (self._state.host, self._state.port))
930
931 if self._selected_mid is None:
932 self._selected_mid = self._state.selected_mid
933
934 if self._selected_mid is None:
935 print('No client is selected.')
936 else:
937 print('Client %s selected.' % self._selected_mid)
938
939 @Command('connect', 'connect to Overlord server', [
940 Arg('host', metavar='HOST', type=str, default='localhost',
941 help='Overlord hostname/IP'),
942 Arg('port', metavar='PORT', type=int,
943 default=_OVERLORD_HTTP_PORT, help='Overlord port'),
944 Arg('-f', '--forward', dest='ssh_forward', default=False,
945 action='store_true',
946 help='connect with SSH forwarding to the host'),
947 Arg('-p', '--ssh-port', dest='ssh_port', default=22,
948 type=int, help='SSH server port for SSH forwarding'),
949 Arg('-l', '--ssh-login', dest='ssh_login', default='',
950 type=str, help='SSH server login name for SSH forwarding'),
951 Arg('-u', '--user', dest='user', default=None,
952 type=str, help='Overlord HTTP auth username'),
953 Arg('-w', '--passwd', dest='passwd', default=None, type=str,
954 help='Overlord HTTP auth password')])
955 def Connect(self, args):
956 ssh_pid = None
957 host = args.host
958 orig_host = args.host
959
960 if args.ssh_forward:
961 # Kill previous SSH tunnel
962 self.KillSSHTunnel()
963
964 ssh_pid = self.SSHTunnel(args.ssh_login, args.host, args.ssh_port)
965 host = 'localhost'
966
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800967 username_provided = args.user is not None
968 password_provided = args.passwd is not None
969 prompt = False
970
971 for unused_i in range(3):
972 try:
973 if prompt:
974 if not username_provided:
975 args.user = raw_input('Username: ')
976 if not password_provided:
977 args.passwd = getpass.getpass('Password: ')
978
979 ret = self._server.Connect(host, args.port, ssh_pid, args.user,
980 args.passwd, orig_host)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800981 if isinstance(ret, list):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800982 if ret[0].startswith('SSL'):
983 cert_pem = ret[1]
984 fp = GetTLSCertificateSHA1Fingerprint(cert_pem)
985 fp_text = ':'.join([fp[i:i+2] for i in range(0, len(fp), 2)])
986
987 if ret[0] == 'SSLCertificateChanged':
988 print(_TLS_CERT_CHANGED_WARNING % (fp_text, GetTLSCertPath(host)))
989 return
990 elif ret[0] == 'SSLVerifyFailed':
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800991 print(_TLS_CERT_FAILED_WARNING % (fp_text), end='')
992 response = raw_input()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800993 if response.lower() in ['y', 'ye', 'yes']:
994 self._SaveTLSCertificate(host, cert_pem)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800995 print('TLS host Certificate trusted, you will not be prompted '
996 'next time.\n')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800997 continue
998 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800999 print('connection aborted.')
1000 return
1001 elif ret[0] == 'HTTPError':
1002 code, except_str, body = ret[1:]
1003 if code == 401:
1004 print('connect: %s' % body)
1005 prompt = True
1006 if not username_provided or not password_provided:
1007 continue
1008 else:
1009 break
1010 else:
1011 logging.error('%s; %s', except_str, body)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001012
1013 if ret is not True:
1014 print('can not connect to %s: %s' % (host, ret))
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001015 else:
1016 print('connection to %s:%d established.' % (host, args.port))
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001017 except Exception as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001018 logging.error(e)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001019 else:
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001020 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001021
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001022 @Command('start-server', 'start overlord CLI client server')
1023 def StartServer(self):
1024 self._server = OverlordClientDaemon.GetRPCServer()
1025 if self._server is None:
1026 OverlordClientDaemon().Start()
1027 time.sleep(1)
1028 self._server = OverlordClientDaemon.GetRPCServer()
1029 if self._server is not None:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001030 print('* daemon started successfully *\n')
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001031
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001032 @Command('kill-server', 'kill overlord CLI client server')
1033 def KillServer(self):
1034 self._server = OverlordClientDaemon.GetRPCServer()
1035 if self._server is None:
1036 return
1037
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001038 self._state = self._server.State()
1039
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001040 # Kill SSH Tunnel
1041 self.KillSSHTunnel()
1042
1043 # Kill server daemon
1044 KillGraceful(self._server.GetPid())
1045
1046 def KillSSHTunnel(self):
1047 if self._state.ssh_pid is not None:
1048 KillGraceful(self._state.ssh_pid)
1049
1050 @Command('ls', 'list all clients')
1051 def ListClients(self):
1052 for client in self._server.Clients():
1053 print(client)
1054
1055 @Command('select', 'select default client', [
1056 Arg('mid', metavar='mid', nargs='?', default=None)])
1057 def SelectClient(self, args=None, store=True):
1058 clients = self._server.Clients()
1059
1060 mid = args.mid if args is not None else None
1061 if mid is None:
1062 print('Select from the following clients:')
1063 for i, client in enumerate(clients):
1064 print(' %d. %s' % (i + 1, client))
1065
1066 print('\nSelection: ', end='')
1067 try:
1068 choice = int(raw_input()) - 1
1069 mid = clients[choice]
1070 except ValueError:
1071 raise RuntimeError('select: invalid selection')
1072 except IndexError:
1073 raise RuntimeError('select: selection out of range')
1074 else:
1075 if mid not in clients:
1076 raise RuntimeError('select: client %s does not exist' % mid)
1077
1078 self._selected_mid = mid
1079 if store:
1080 self._server.SelectClient(mid)
1081 print('Client %s selected' % mid)
1082
1083 @Command('shell', 'open a shell or execute a shell command', [
1084 Arg('command', metavar='CMD', nargs='?', help='command to execute')])
1085 def Shell(self, command=None):
1086 if command is None:
1087 command = []
1088 self.CheckClient()
1089
1090 headers = []
1091 if self._state.username is not None and self._state.password is not None:
1092 headers.append(BasicAuthHeader(self._state.username,
1093 self._state.password))
1094
1095 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1096 if len(command) == 0:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001097 ws = TerminalWebSocketClient(self._state, self._selected_mid,
Wei-Ning Huang0c520e92016-03-19 20:01:10 +08001098 self._escape,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001099 scheme + '%s:%d/api/agent/tty/%s' %
1100 (self._state.host, self._state.port,
1101 self._selected_mid), headers=headers)
1102 else:
1103 cmd = ' '.join(command)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001104 ws = ShellWebSocketClient(self._state, sys.stdout,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001105 scheme + '%s:%d/api/agent/shell/%s?command=%s' %
1106 (self._state.host, self._state.port,
1107 self._selected_mid, urllib2.quote(cmd)),
1108 headers=headers)
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001109 try:
1110 ws.connect()
1111 ws.run()
1112 except socket.error as e:
1113 if e.errno == 32: # Broken pipe
1114 pass
1115 else:
1116 raise
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001117
1118 @Command('push', 'push a file or directory to remote', [
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001119 Arg('srcs', nargs='+', metavar='SOURCE'),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001120 Arg('dst', metavar='DESTINATION')])
1121 def Push(self, args):
1122 self.CheckClient()
1123
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001124 @AutoRetry('push', _RETRY_TIMES)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001125 def _push(src, dst):
1126 src_base = os.path.basename(src)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001127
1128 # Local file is a link
1129 if os.path.islink(src):
1130 pbar = ProgressBar(src_base)
1131 link_path = os.readlink(src)
1132 self.CheckOutput('mkdir -p %(dirname)s; '
1133 'if [ -d "%(dst)s" ]; then '
1134 'ln -sf "%(link_path)s" "%(dst)s/%(link_name)s"; '
1135 'else ln -sf "%(link_path)s" "%(dst)s"; fi' %
1136 dict(dirname=os.path.dirname(dst),
1137 link_path=link_path, dst=dst,
1138 link_name=src_base))
1139 pbar.End()
1140 return
1141
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001142 mode = '0%o' % (0x1FF & os.stat(src).st_mode)
1143 url = ('%s:%d/api/agent/upload/%s?dest=%s&perm=%s' %
1144 (self._state.host, self._state.port, self._selected_mid, dst,
1145 mode))
1146 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001147 UrlOpen(self._state, url + '&filename=%s' % src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001148 except urllib2.HTTPError as e:
1149 msg = json.loads(e.read()).get('error', None)
1150 raise RuntimeError('push: %s' % msg)
1151
1152 pbar = ProgressBar(src_base)
1153 self._HTTPPostFile(url, src, pbar.SetProgress,
1154 self._state.username, self._state.password)
1155 pbar.End()
1156
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001157 def _push_single_target(src, dst):
1158 if os.path.isdir(src):
1159 dst_exists = ast.literal_eval(self.CheckOutput(
1160 'stat %s >/dev/null 2>&1 && echo True || echo False' % dst))
1161 for root, unused_x, files in os.walk(src):
1162 # If destination directory does not exist, we should strip the first
1163 # layer of directory. For example: src_dir contains a single file 'A'
1164 #
1165 # push src_dir dest_dir
1166 #
1167 # If dest_dir exists, the resulting directory structure should be:
1168 # dest_dir/src_dir/A
1169 # If dest_dir does not exist, the resulting directory structure should
1170 # be:
1171 # dest_dir/A
1172 dst_root = root if dst_exists else root[len(src):].lstrip('/')
1173 for name in files:
1174 _push(os.path.join(root, name),
1175 os.path.join(dst, dst_root, name))
1176 else:
1177 _push(src, dst)
1178
1179 if len(args.srcs) > 1:
1180 dst_type = self.CheckOutput('stat \'%s\' --printf \'%%F\' '
1181 '2>/dev/null' % args.dst).strip()
1182 if not dst_type:
1183 raise RuntimeError('push: %s: No such file or directory' % args.dst)
1184 if dst_type != 'directory':
1185 raise RuntimeError('push: %s: Not a directory' % args.dst)
1186
1187 for src in args.srcs:
1188 if not os.path.exists(src):
1189 raise RuntimeError('push: can not stat "%s": no such file or directory'
1190 % src)
1191 if not os.access(src, os.R_OK):
1192 raise RuntimeError('push: can not open "%s" for reading' % src)
1193
1194 _push_single_target(src, args.dst)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001195
1196 @Command('pull', 'pull a file or directory from remote', [
1197 Arg('src', metavar='SOURCE'),
1198 Arg('dst', metavar='DESTINATION', default='.', nargs='?')])
1199 def Pull(self, args):
1200 self.CheckClient()
1201
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001202 @AutoRetry('pull', _RETRY_TIMES)
1203 def _pull(src, dst, ftype, perm=0644, link=None):
1204 try:
1205 os.makedirs(os.path.dirname(dst))
1206 except Exception:
1207 pass
1208
1209 src_base = os.path.basename(src)
1210
1211 # Remote file is a link
1212 if ftype == 'l':
1213 pbar = ProgressBar(src_base)
1214 if os.path.exists(dst):
1215 os.remove(dst)
1216 os.symlink(link, dst)
1217 pbar.End()
1218 return
1219
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001220 url = ('%s:%d/api/agent/download/%s?filename=%s' %
1221 (self._state.host, self._state.port, self._selected_mid,
1222 urllib2.quote(src)))
1223 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001224 h = UrlOpen(self._state, url)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001225 except urllib2.HTTPError as e:
1226 msg = json.loads(e.read()).get('error', 'unkown error')
1227 raise RuntimeError('pull: %s' % msg)
1228 except KeyboardInterrupt:
1229 return
1230
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001231 pbar = ProgressBar(src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001232 with open(dst, 'w') as f:
1233 os.fchmod(f.fileno(), perm)
1234 total_size = int(h.headers.get('Content-Length'))
1235 downloaded_size = 0
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001236
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001237 while True:
1238 data = h.read(_BUFSIZ)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001239 if len(data) == 0:
1240 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001241 downloaded_size += len(data)
1242 pbar.SetProgress(float(downloaded_size) * 100 / total_size,
1243 downloaded_size)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001244 f.write(data)
1245 pbar.End()
1246
1247 # Use find to get a listing of all files under a root directory. The 'stat'
1248 # command is used to retrieve the filename and it's filemode.
1249 output = self.CheckOutput(
1250 'cd $HOME; '
1251 'stat "%(src)s" >/dev/null && '
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001252 'find "%(src)s" \'(\' -type f -o -type l \')\' '
1253 '-printf \'%%m\t%%p\t%%y\t%%l\n\''
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001254 % {'src': args.src})
1255
1256 # We got error from the stat command
1257 if output.startswith('stat: '):
1258 sys.stderr.write(output)
1259 return
1260
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001261 entries = output.strip('\n').split('\n')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001262 common_prefix = os.path.dirname(args.src)
1263
1264 if len(entries) == 1:
1265 entry = entries[0]
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001266 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001267 if os.path.isdir(args.dst):
1268 dst = os.path.join(args.dst, os.path.basename(src_path))
1269 else:
1270 dst = args.dst
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001271 _pull(src_path, dst, ftype, int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001272 else:
1273 if not os.path.exists(args.dst):
1274 common_prefix = args.src
1275
1276 for entry in entries:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001277 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001278 rel_dst = src_path[len(common_prefix):].lstrip('/')
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001279 _pull(src_path, os.path.join(args.dst, rel_dst), ftype,
1280 int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001281
1282 @Command('forward', 'forward remote port to local port', [
1283 Arg('--list', dest='list_all', action='store_true', default=False,
1284 help='list all port forwarding sessions'),
1285 Arg('--remove', metavar='LOCAL_PORT', dest='remove', type=int,
1286 default=None,
1287 help='remove port forwarding for local port LOCAL_PORT'),
1288 Arg('--remove-all', dest='remove_all', action='store_true',
1289 default=False, help='remove all port forwarding'),
1290 Arg('remote', metavar='REMOTE_PORT', type=int, nargs='?'),
1291 Arg('local', metavar='LOCAL_PORT', type=int, nargs='?')])
1292 def Forward(self, args):
1293 if args.list_all:
1294 max_len = 10
1295 if len(self._state.forwards):
1296 max_len = max([len(v[0]) for v in self._state.forwards.values()])
1297
1298 print('%-*s %-8s %-8s' % (max_len, 'Client', 'Remote', 'Local'))
1299 for local in sorted(self._state.forwards.keys()):
1300 value = self._state.forwards[local]
1301 print('%-*s %-8s %-8s' % (max_len, value[0], value[1], local))
1302 return
1303
1304 if args.remove_all:
1305 self._server.RemoveAllForward()
1306 return
1307
1308 if args.remove:
1309 self._server.RemoveForward(args.remove)
1310 return
1311
1312 self.CheckClient()
1313
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001314 if args.remote is None:
1315 raise RuntimeError('remote port not specified')
1316
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001317 if args.local is None:
1318 args.local = args.remote
1319 remote = int(args.remote)
1320 local = int(args.local)
1321
1322 def HandleConnection(conn):
1323 headers = []
1324 if self._state.username is not None and self._state.password is not None:
1325 headers.append(BasicAuthHeader(self._state.username,
1326 self._state.password))
1327
1328 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1329 ws = ForwarderWebSocketClient(
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001330 self._state, conn,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001331 scheme + '%s:%d/api/agent/forward/%s?port=%d' %
1332 (self._state.host, self._state.port, self._selected_mid, remote),
1333 headers=headers)
1334 try:
1335 ws.connect()
1336 ws.run()
1337 except Exception as e:
1338 print('error: %s' % e)
1339 finally:
1340 ws.close()
1341
1342 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1343 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1344 server.bind(('0.0.0.0', local))
1345 server.listen(5)
1346
1347 pid = os.fork()
1348 if pid == 0:
1349 while True:
1350 conn, unused_addr = server.accept()
1351 t = threading.Thread(target=HandleConnection, args=(conn,))
1352 t.daemon = True
1353 t.start()
1354 else:
1355 self._server.AddForward(self._selected_mid, remote, local, pid)
1356
1357
1358def main():
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001359 # Setup logging format
1360 logger = logging.getLogger()
1361 logger.setLevel(logging.INFO)
1362 handler = logging.StreamHandler()
1363 formatter = logging.Formatter('%(asctime)s %(message)s', '%Y/%m/%d %H:%M:%S')
1364 handler.setFormatter(formatter)
1365 logger.addHandler(handler)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001366
1367 # Add DaemonState to JSONRPC lib classes
1368 Config.instance().classes.add(DaemonState)
1369
1370 ovl = OverlordCLIClient()
1371 try:
1372 ovl.Main()
1373 except KeyboardInterrupt:
1374 print('Ctrl-C received, abort')
1375 except Exception as e:
1376 print('error: %s' % e)
1377
1378
1379if __name__ == '__main__':
1380 main()