blob: 4e19633b532842cae3b90c3dd15384f48391f4c8 [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):
556 termios.tcsetattr(self._stdin_fd, termios.TCSANOW, self._old_termios)
557 print('Connection to %s closed.' % self._mid)
558
559 def received_message(self, msg):
560 if msg.is_binary:
561 sys.stdout.write(msg.data)
562 sys.stdout.flush()
563
564
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800565class ShellWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800566 def __init__(self, state, output, *args, **kwargs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800567 """Constructor.
568
569 Args:
570 output: output file object.
571 """
572 self.output = output
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800573 super(ShellWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800574
575 def handshake_ok(self):
576 pass
577
578 def opened(self):
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800579 def _FeedInput():
580 try:
581 while True:
582 data = sys.stdin.read(1)
583
584 if len(data) == 0:
585 self.send(_STDIN_CLOSED * 2)
586 break
587 self.send(data, binary=True)
588 except (KeyboardInterrupt, RuntimeError):
589 pass
590
591 t = threading.Thread(target=_FeedInput)
592 t.daemon = True
593 t.start()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800594
595 def closed(self, code, reason=None):
596 pass
597
598 def received_message(self, msg):
599 if msg.is_binary:
600 self.output.write(msg.data)
601 self.output.flush()
602
603
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800604class ForwarderWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800605 def __init__(self, state, sock, *args, **kwargs):
606 super(ForwarderWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800607 self._sock = sock
608 self._stop = threading.Event()
609
610 def handshake_ok(self):
611 pass
612
613 def opened(self):
614 def _FeedInput():
615 try:
616 self._sock.setblocking(False)
617 while True:
618 rd, unused_w, unused_x = select.select([self._sock], [], [], 0.5)
619 if self._stop.is_set():
620 break
621 if self._sock in rd:
622 data = self._sock.recv(_BUFSIZ)
623 if len(data) == 0:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800624 self.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800625 break
626 self.send(data, binary=True)
627 except Exception:
628 pass
629 finally:
630 self._sock.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800631
632 t = threading.Thread(target=_FeedInput)
633 t.daemon = True
634 t.start()
635
636 def closed(self, code, reason=None):
637 self._stop.set()
638 sys.exit(0)
639
640 def received_message(self, msg):
641 if msg.is_binary:
642 self._sock.send(msg.data)
643
644
645def Arg(*args, **kwargs):
646 return (args, kwargs)
647
648
649def Command(command, help_msg=None, args=None):
650 """Decorator for adding argparse parameter for a method."""
651 if args is None:
652 args = []
653 def WrapFunc(func):
654 def Wrapped(*args, **kwargs):
655 return func(*args, **kwargs)
656 # pylint: disable=W0212
657 Wrapped.__arg_attr = {'command': command, 'help': help_msg, 'args': args}
658 return Wrapped
659 return WrapFunc
660
661
662def ParseMethodSubCommands(cls):
663 """Decorator for a class using the @Command decorator.
664
665 This decorator retrieve command info from each method and append it in to the
666 SUBCOMMANDS class variable, which is later used to construct parser.
667 """
668 for unused_key, method in cls.__dict__.iteritems():
669 if hasattr(method, '__arg_attr'):
670 cls.SUBCOMMANDS.append(method.__arg_attr) # pylint: disable=W0212
671 return cls
672
673
674@ParseMethodSubCommands
675class OverlordCLIClient(object):
676 """Overlord command line interface client."""
677
678 SUBCOMMANDS = []
679
680 def __init__(self):
681 self._parser = self._BuildParser()
682 self._selected_mid = None
683 self._server = None
684 self._state = None
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800685 self._escape = None
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800686
687 def _BuildParser(self):
688 root_parser = argparse.ArgumentParser(prog='ovl')
689 subparsers = root_parser.add_subparsers(help='sub-command')
690
691 root_parser.add_argument('-s', dest='selected_mid', action='store',
692 default=None,
693 help='select target to execute command on')
694 root_parser.add_argument('-S', dest='select_mid_before_action',
695 action='store_true', default=False,
696 help='select target before executing command')
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800697 root_parser.add_argument('-e', dest='escape', metavar='ESCAPE_CHAR',
698 action='store', default=_ESCAPE, type=str,
699 help='set shell escape character, \'none\' to '
700 'disable escape completely')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800701
702 for attr in self.SUBCOMMANDS:
703 parser = subparsers.add_parser(attr['command'], help=attr['help'])
704 parser.set_defaults(which=attr['command'])
705 for arg in attr['args']:
706 parser.add_argument(*arg[0], **arg[1])
707
708 return root_parser
709
710 def Main(self):
711 # We want to pass the rest of arguments after shell command directly to the
712 # function without parsing it.
713 try:
714 index = sys.argv.index('shell')
715 except ValueError:
716 args = self._parser.parse_args()
717 else:
718 args = self._parser.parse_args(sys.argv[1:index + 1])
719
720 command = args.which
721 self._selected_mid = args.selected_mid
722
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800723 if args.escape and args.escape != 'none':
724 self._escape = args.escape[0]
725
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800726 if command == 'start-server':
727 self.StartServer()
728 return
729 elif command == 'kill-server':
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800730 self.KillServer()
731 return
732
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800733 self.CheckDaemon()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800734 if command == 'status':
735 self.Status()
736 return
737 elif command == 'connect':
738 self.Connect(args)
739 return
740
741 # The following command requires connection to the server
742 self.CheckConnection()
743
744 if args.select_mid_before_action:
745 self.SelectClient(store=False)
746
747 if command == 'select':
748 self.SelectClient(args)
749 elif command == 'ls':
750 self.ListClients()
751 elif command == 'shell':
752 command = sys.argv[sys.argv.index('shell') + 1:]
753 self.Shell(command)
754 elif command == 'push':
755 self.Push(args)
756 elif command == 'pull':
757 self.Pull(args)
758 elif command == 'forward':
759 self.Forward(args)
760
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800761 def _SaveTLSCertificate(self, host, cert_pem):
762 try:
763 os.makedirs(_CERT_DIR)
764 except Exception:
765 pass
766 with open(GetTLSCertPath(host), 'w') as f:
767 f.write(cert_pem)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800768
769 def _HTTPPostFile(self, url, filename, progress=None, user=None, passwd=None):
770 """Perform HTTP POST and upload file to Overlord.
771
772 To minimize the external dependencies, we construct the HTTP post request
773 by ourselves.
774 """
775 url = MakeRequestUrl(self._state, url)
776 size = os.stat(filename).st_size
777 boundary = '-----------%s' % _HTTP_BOUNDARY_MAGIC
778 CRLF = '\r\n'
779 parse = urlparse.urlparse(url)
780
781 part_headers = [
782 '--' + boundary,
783 'Content-Disposition: form-data; name="file"; '
784 'filename="%s"' % os.path.basename(filename),
785 'Content-Type: application/octet-stream',
786 '', ''
787 ]
788 part_header = CRLF.join(part_headers)
789 end_part = CRLF + '--' + boundary + '--' + CRLF
790
791 content_length = len(part_header) + size + len(end_part)
792 if parse.scheme == 'http':
793 h = httplib.HTTP(parse.netloc)
794 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800795 h = httplib.HTTPS(parse.netloc, context=self._state.ssl_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800796
797 post_path = url[url.index(parse.netloc) + len(parse.netloc):]
798 h.putrequest('POST', post_path)
799 h.putheader('Content-Length', content_length)
800 h.putheader('Content-Type', 'multipart/form-data; boundary=%s' % boundary)
801
802 if user and passwd:
803 h.putheader(*BasicAuthHeader(user, passwd))
804 h.endheaders()
805 h.send(part_header)
806
807 count = 0
808 with open(filename, 'r') as f:
809 while True:
810 data = f.read(_BUFSIZ)
811 if not data:
812 break
813 count += len(data)
814 if progress:
815 progress(int(count * 100.0 / size), count)
816 h.send(data)
817
818 h.send(end_part)
819 progress(100)
820
821 if count != size:
822 logging.warning('file changed during upload, upload may be truncated.')
823
824 errcode, unused_x, unused_y = h.getreply()
825 return errcode == 200
826
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800827 def CheckDaemon(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800828 self._server = OverlordClientDaemon.GetRPCServer()
829 if self._server is None:
830 print('* daemon not running, starting it now on port %d ... *' %
831 _OVERLORD_CLIENT_DAEMON_PORT)
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800832 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800833
834 self._state = self._server.State()
835 sha1sum = GetVersionDigest()
836
837 if sha1sum != self._state.version_sha1sum:
838 print('ovl server is out of date. killing...')
839 KillGraceful(self._server.GetPid())
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800840 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800841
842 def GetSSHControlFile(self, host):
843 return _SSH_CONTROL_SOCKET_PREFIX + host
844
845 def SSHTunnel(self, user, host, port):
846 """SSH forward the remote overlord server.
847
848 Overlord server may not have port 9000 open to the public network, in such
849 case we can SSH forward the port to localhost.
850 """
851
852 control_file = self.GetSSHControlFile(host)
853 try:
854 os.unlink(control_file)
855 except Exception:
856 pass
857
858 subprocess.Popen([
859 'ssh', '-Nf',
860 '-M', # Enable master mode
861 '-S', control_file,
862 '-L', '9000:localhost:9000',
863 '-p', str(port),
864 '%s%s' % (user + '@' if user else '', host)
865 ]).wait()
866
867 p = subprocess.Popen([
868 'ssh',
869 '-S', control_file,
870 '-O', 'check', host,
871 ], stderr=subprocess.PIPE)
872 unused_stdout, stderr = p.communicate()
873
874 s = re.search(r'pid=(\d+)', stderr)
875 if s:
876 return int(s.group(1))
877
878 raise RuntimeError('can not establish ssh connection')
879
880 def CheckConnection(self):
881 if self._state.host is None:
882 raise RuntimeError('not connected to any server, abort')
883
884 try:
885 self._server.Clients()
886 except Exception:
887 raise RuntimeError('remote server disconnected, abort')
888
889 if self._state.ssh_pid is not None:
890 ret = subprocess.Popen(['kill', '-0', str(self._state.ssh_pid)],
891 stdout=subprocess.PIPE,
892 stderr=subprocess.PIPE).wait()
893 if ret != 0:
894 raise RuntimeError('ssh tunnel disconnected, please re-connect')
895
896 def CheckClient(self):
897 if self._selected_mid is None:
898 if self._state.selected_mid is None:
899 raise RuntimeError('No client is selected')
900 self._selected_mid = self._state.selected_mid
901
902 if self._selected_mid not in self._server.Clients():
903 raise RuntimeError('client %s disappeared' % self._selected_mid)
904
905 def CheckOutput(self, command):
906 headers = []
907 if self._state.username is not None and self._state.password is not None:
908 headers.append(BasicAuthHeader(self._state.username,
909 self._state.password))
910
911 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
912 sio = StringIO.StringIO()
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800913 ws = ShellWebSocketClient(self._state, sio,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800914 scheme + '%s:%d/api/agent/shell/%s?command=%s' %
915 (self._state.host, self._state.port,
916 self._selected_mid, urllib2.quote(command)),
917 headers=headers)
918 ws.connect()
919 ws.run()
920 return sio.getvalue()
921
922 @Command('status', 'show Overlord connection status')
923 def Status(self):
924 if self._state.host is None:
925 print('Not connected to any host.')
926 else:
927 if self._state.ssh_pid is not None:
928 print('Connected to %s with SSH tunneling.' % self._state.orig_host)
929 else:
930 print('Connected to %s:%d.' % (self._state.host, self._state.port))
931
932 if self._selected_mid is None:
933 self._selected_mid = self._state.selected_mid
934
935 if self._selected_mid is None:
936 print('No client is selected.')
937 else:
938 print('Client %s selected.' % self._selected_mid)
939
940 @Command('connect', 'connect to Overlord server', [
941 Arg('host', metavar='HOST', type=str, default='localhost',
942 help='Overlord hostname/IP'),
943 Arg('port', metavar='PORT', type=int,
944 default=_OVERLORD_HTTP_PORT, help='Overlord port'),
945 Arg('-f', '--forward', dest='ssh_forward', default=False,
946 action='store_true',
947 help='connect with SSH forwarding to the host'),
948 Arg('-p', '--ssh-port', dest='ssh_port', default=22,
949 type=int, help='SSH server port for SSH forwarding'),
950 Arg('-l', '--ssh-login', dest='ssh_login', default='',
951 type=str, help='SSH server login name for SSH forwarding'),
952 Arg('-u', '--user', dest='user', default=None,
953 type=str, help='Overlord HTTP auth username'),
954 Arg('-w', '--passwd', dest='passwd', default=None, type=str,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800955 help='Overlord HTTP auth password'),
956 Arg('-i', '--no-check-hostname', dest='check_hostname',
957 default=True, action='store_false',
958 help='Ignore SSL cert hostname check')])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800959 def Connect(self, args):
960 ssh_pid = None
961 host = args.host
962 orig_host = args.host
963
964 if args.ssh_forward:
965 # Kill previous SSH tunnel
966 self.KillSSHTunnel()
967
968 ssh_pid = self.SSHTunnel(args.ssh_login, args.host, args.ssh_port)
969 host = 'localhost'
970
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800971 username_provided = args.user is not None
972 password_provided = args.passwd is not None
973 prompt = False
974
975 for unused_i in range(3):
976 try:
977 if prompt:
978 if not username_provided:
979 args.user = raw_input('Username: ')
980 if not password_provided:
981 args.passwd = getpass.getpass('Password: ')
982
983 ret = self._server.Connect(host, args.port, ssh_pid, args.user,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800984 args.passwd, orig_host,
985 args.check_hostname)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800986 if isinstance(ret, list):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800987 if ret[0].startswith('SSL'):
988 cert_pem = ret[1]
989 fp = GetTLSCertificateSHA1Fingerprint(cert_pem)
990 fp_text = ':'.join([fp[i:i+2] for i in range(0, len(fp), 2)])
991
992 if ret[0] == 'SSLCertificateChanged':
993 print(_TLS_CERT_CHANGED_WARNING % (fp_text, GetTLSCertPath(host)))
994 return
995 elif ret[0] == 'SSLVerifyFailed':
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800996 print(_TLS_CERT_FAILED_WARNING % (fp_text), end='')
997 response = raw_input()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800998 if response.lower() in ['y', 'ye', 'yes']:
999 self._SaveTLSCertificate(host, cert_pem)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001000 print('TLS host Certificate trusted, you will not be prompted '
1001 'next time.\n')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001002 continue
1003 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001004 print('connection aborted.')
1005 return
1006 elif ret[0] == 'HTTPError':
1007 code, except_str, body = ret[1:]
1008 if code == 401:
1009 print('connect: %s' % body)
1010 prompt = True
1011 if not username_provided or not password_provided:
1012 continue
1013 else:
1014 break
1015 else:
1016 logging.error('%s; %s', except_str, body)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001017
1018 if ret is not True:
1019 print('can not connect to %s: %s' % (host, ret))
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001020 else:
1021 print('connection to %s:%d established.' % (host, args.port))
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001022 except Exception as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001023 logging.error(e)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001024 else:
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001025 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001026
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001027 @Command('start-server', 'start overlord CLI client server')
1028 def StartServer(self):
1029 self._server = OverlordClientDaemon.GetRPCServer()
1030 if self._server is None:
1031 OverlordClientDaemon().Start()
1032 time.sleep(1)
1033 self._server = OverlordClientDaemon.GetRPCServer()
1034 if self._server is not None:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001035 print('* daemon started successfully *\n')
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001036
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001037 @Command('kill-server', 'kill overlord CLI client server')
1038 def KillServer(self):
1039 self._server = OverlordClientDaemon.GetRPCServer()
1040 if self._server is None:
1041 return
1042
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001043 self._state = self._server.State()
1044
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001045 # Kill SSH Tunnel
1046 self.KillSSHTunnel()
1047
1048 # Kill server daemon
1049 KillGraceful(self._server.GetPid())
1050
1051 def KillSSHTunnel(self):
1052 if self._state.ssh_pid is not None:
1053 KillGraceful(self._state.ssh_pid)
1054
1055 @Command('ls', 'list all clients')
1056 def ListClients(self):
1057 for client in self._server.Clients():
1058 print(client)
1059
1060 @Command('select', 'select default client', [
1061 Arg('mid', metavar='mid', nargs='?', default=None)])
1062 def SelectClient(self, args=None, store=True):
1063 clients = self._server.Clients()
1064
1065 mid = args.mid if args is not None else None
1066 if mid is None:
1067 print('Select from the following clients:')
1068 for i, client in enumerate(clients):
1069 print(' %d. %s' % (i + 1, client))
1070
1071 print('\nSelection: ', end='')
1072 try:
1073 choice = int(raw_input()) - 1
1074 mid = clients[choice]
1075 except ValueError:
1076 raise RuntimeError('select: invalid selection')
1077 except IndexError:
1078 raise RuntimeError('select: selection out of range')
1079 else:
1080 if mid not in clients:
1081 raise RuntimeError('select: client %s does not exist' % mid)
1082
1083 self._selected_mid = mid
1084 if store:
1085 self._server.SelectClient(mid)
1086 print('Client %s selected' % mid)
1087
1088 @Command('shell', 'open a shell or execute a shell command', [
1089 Arg('command', metavar='CMD', nargs='?', help='command to execute')])
1090 def Shell(self, command=None):
1091 if command is None:
1092 command = []
1093 self.CheckClient()
1094
1095 headers = []
1096 if self._state.username is not None and self._state.password is not None:
1097 headers.append(BasicAuthHeader(self._state.username,
1098 self._state.password))
1099
1100 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1101 if len(command) == 0:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001102 ws = TerminalWebSocketClient(self._state, self._selected_mid,
Wei-Ning Huang0c520e92016-03-19 20:01:10 +08001103 self._escape,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001104 scheme + '%s:%d/api/agent/tty/%s' %
1105 (self._state.host, self._state.port,
1106 self._selected_mid), headers=headers)
1107 else:
1108 cmd = ' '.join(command)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001109 ws = ShellWebSocketClient(self._state, sys.stdout,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001110 scheme + '%s:%d/api/agent/shell/%s?command=%s' %
1111 (self._state.host, self._state.port,
1112 self._selected_mid, urllib2.quote(cmd)),
1113 headers=headers)
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001114 try:
1115 ws.connect()
1116 ws.run()
1117 except socket.error as e:
1118 if e.errno == 32: # Broken pipe
1119 pass
1120 else:
1121 raise
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001122
1123 @Command('push', 'push a file or directory to remote', [
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001124 Arg('srcs', nargs='+', metavar='SOURCE'),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001125 Arg('dst', metavar='DESTINATION')])
1126 def Push(self, args):
1127 self.CheckClient()
1128
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001129 @AutoRetry('push', _RETRY_TIMES)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001130 def _push(src, dst):
1131 src_base = os.path.basename(src)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001132
1133 # Local file is a link
1134 if os.path.islink(src):
1135 pbar = ProgressBar(src_base)
1136 link_path = os.readlink(src)
1137 self.CheckOutput('mkdir -p %(dirname)s; '
1138 'if [ -d "%(dst)s" ]; then '
1139 'ln -sf "%(link_path)s" "%(dst)s/%(link_name)s"; '
1140 'else ln -sf "%(link_path)s" "%(dst)s"; fi' %
1141 dict(dirname=os.path.dirname(dst),
1142 link_path=link_path, dst=dst,
1143 link_name=src_base))
1144 pbar.End()
1145 return
1146
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001147 mode = '0%o' % (0x1FF & os.stat(src).st_mode)
1148 url = ('%s:%d/api/agent/upload/%s?dest=%s&perm=%s' %
1149 (self._state.host, self._state.port, self._selected_mid, dst,
1150 mode))
1151 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001152 UrlOpen(self._state, url + '&filename=%s' % src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001153 except urllib2.HTTPError as e:
1154 msg = json.loads(e.read()).get('error', None)
1155 raise RuntimeError('push: %s' % msg)
1156
1157 pbar = ProgressBar(src_base)
1158 self._HTTPPostFile(url, src, pbar.SetProgress,
1159 self._state.username, self._state.password)
1160 pbar.End()
1161
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001162 def _push_single_target(src, dst):
1163 if os.path.isdir(src):
1164 dst_exists = ast.literal_eval(self.CheckOutput(
1165 'stat %s >/dev/null 2>&1 && echo True || echo False' % dst))
1166 for root, unused_x, files in os.walk(src):
1167 # If destination directory does not exist, we should strip the first
1168 # layer of directory. For example: src_dir contains a single file 'A'
1169 #
1170 # push src_dir dest_dir
1171 #
1172 # If dest_dir exists, the resulting directory structure should be:
1173 # dest_dir/src_dir/A
1174 # If dest_dir does not exist, the resulting directory structure should
1175 # be:
1176 # dest_dir/A
1177 dst_root = root if dst_exists else root[len(src):].lstrip('/')
1178 for name in files:
1179 _push(os.path.join(root, name),
1180 os.path.join(dst, dst_root, name))
1181 else:
1182 _push(src, dst)
1183
1184 if len(args.srcs) > 1:
1185 dst_type = self.CheckOutput('stat \'%s\' --printf \'%%F\' '
1186 '2>/dev/null' % args.dst).strip()
1187 if not dst_type:
1188 raise RuntimeError('push: %s: No such file or directory' % args.dst)
1189 if dst_type != 'directory':
1190 raise RuntimeError('push: %s: Not a directory' % args.dst)
1191
1192 for src in args.srcs:
1193 if not os.path.exists(src):
1194 raise RuntimeError('push: can not stat "%s": no such file or directory'
1195 % src)
1196 if not os.access(src, os.R_OK):
1197 raise RuntimeError('push: can not open "%s" for reading' % src)
1198
1199 _push_single_target(src, args.dst)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001200
1201 @Command('pull', 'pull a file or directory from remote', [
1202 Arg('src', metavar='SOURCE'),
1203 Arg('dst', metavar='DESTINATION', default='.', nargs='?')])
1204 def Pull(self, args):
1205 self.CheckClient()
1206
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001207 @AutoRetry('pull', _RETRY_TIMES)
1208 def _pull(src, dst, ftype, perm=0644, link=None):
1209 try:
1210 os.makedirs(os.path.dirname(dst))
1211 except Exception:
1212 pass
1213
1214 src_base = os.path.basename(src)
1215
1216 # Remote file is a link
1217 if ftype == 'l':
1218 pbar = ProgressBar(src_base)
1219 if os.path.exists(dst):
1220 os.remove(dst)
1221 os.symlink(link, dst)
1222 pbar.End()
1223 return
1224
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001225 url = ('%s:%d/api/agent/download/%s?filename=%s' %
1226 (self._state.host, self._state.port, self._selected_mid,
1227 urllib2.quote(src)))
1228 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001229 h = UrlOpen(self._state, url)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001230 except urllib2.HTTPError as e:
1231 msg = json.loads(e.read()).get('error', 'unkown error')
1232 raise RuntimeError('pull: %s' % msg)
1233 except KeyboardInterrupt:
1234 return
1235
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001236 pbar = ProgressBar(src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001237 with open(dst, 'w') as f:
1238 os.fchmod(f.fileno(), perm)
1239 total_size = int(h.headers.get('Content-Length'))
1240 downloaded_size = 0
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001241
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001242 while True:
1243 data = h.read(_BUFSIZ)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001244 if len(data) == 0:
1245 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001246 downloaded_size += len(data)
1247 pbar.SetProgress(float(downloaded_size) * 100 / total_size,
1248 downloaded_size)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001249 f.write(data)
1250 pbar.End()
1251
1252 # Use find to get a listing of all files under a root directory. The 'stat'
1253 # command is used to retrieve the filename and it's filemode.
1254 output = self.CheckOutput(
1255 'cd $HOME; '
1256 'stat "%(src)s" >/dev/null && '
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001257 'find "%(src)s" \'(\' -type f -o -type l \')\' '
1258 '-printf \'%%m\t%%p\t%%y\t%%l\n\''
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001259 % {'src': args.src})
1260
1261 # We got error from the stat command
1262 if output.startswith('stat: '):
1263 sys.stderr.write(output)
1264 return
1265
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001266 entries = output.strip('\n').split('\n')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001267 common_prefix = os.path.dirname(args.src)
1268
1269 if len(entries) == 1:
1270 entry = entries[0]
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001271 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001272 if os.path.isdir(args.dst):
1273 dst = os.path.join(args.dst, os.path.basename(src_path))
1274 else:
1275 dst = args.dst
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001276 _pull(src_path, dst, ftype, int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001277 else:
1278 if not os.path.exists(args.dst):
1279 common_prefix = args.src
1280
1281 for entry in entries:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001282 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001283 rel_dst = src_path[len(common_prefix):].lstrip('/')
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001284 _pull(src_path, os.path.join(args.dst, rel_dst), ftype,
1285 int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001286
1287 @Command('forward', 'forward remote port to local port', [
1288 Arg('--list', dest='list_all', action='store_true', default=False,
1289 help='list all port forwarding sessions'),
1290 Arg('--remove', metavar='LOCAL_PORT', dest='remove', type=int,
1291 default=None,
1292 help='remove port forwarding for local port LOCAL_PORT'),
1293 Arg('--remove-all', dest='remove_all', action='store_true',
1294 default=False, help='remove all port forwarding'),
1295 Arg('remote', metavar='REMOTE_PORT', type=int, nargs='?'),
1296 Arg('local', metavar='LOCAL_PORT', type=int, nargs='?')])
1297 def Forward(self, args):
1298 if args.list_all:
1299 max_len = 10
1300 if len(self._state.forwards):
1301 max_len = max([len(v[0]) for v in self._state.forwards.values()])
1302
1303 print('%-*s %-8s %-8s' % (max_len, 'Client', 'Remote', 'Local'))
1304 for local in sorted(self._state.forwards.keys()):
1305 value = self._state.forwards[local]
1306 print('%-*s %-8s %-8s' % (max_len, value[0], value[1], local))
1307 return
1308
1309 if args.remove_all:
1310 self._server.RemoveAllForward()
1311 return
1312
1313 if args.remove:
1314 self._server.RemoveForward(args.remove)
1315 return
1316
1317 self.CheckClient()
1318
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001319 if args.remote is None:
1320 raise RuntimeError('remote port not specified')
1321
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001322 if args.local is None:
1323 args.local = args.remote
1324 remote = int(args.remote)
1325 local = int(args.local)
1326
1327 def HandleConnection(conn):
1328 headers = []
1329 if self._state.username is not None and self._state.password is not None:
1330 headers.append(BasicAuthHeader(self._state.username,
1331 self._state.password))
1332
1333 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1334 ws = ForwarderWebSocketClient(
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001335 self._state, conn,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001336 scheme + '%s:%d/api/agent/forward/%s?port=%d' %
1337 (self._state.host, self._state.port, self._selected_mid, remote),
1338 headers=headers)
1339 try:
1340 ws.connect()
1341 ws.run()
1342 except Exception as e:
1343 print('error: %s' % e)
1344 finally:
1345 ws.close()
1346
1347 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1348 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1349 server.bind(('0.0.0.0', local))
1350 server.listen(5)
1351
1352 pid = os.fork()
1353 if pid == 0:
1354 while True:
1355 conn, unused_addr = server.accept()
1356 t = threading.Thread(target=HandleConnection, args=(conn,))
1357 t.daemon = True
1358 t.start()
1359 else:
1360 self._server.AddForward(self._selected_mid, remote, local, pid)
1361
1362
1363def main():
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001364 # Setup logging format
1365 logger = logging.getLogger()
1366 logger.setLevel(logging.INFO)
1367 handler = logging.StreamHandler()
1368 formatter = logging.Formatter('%(asctime)s %(message)s', '%Y/%m/%d %H:%M:%S')
1369 handler.setFormatter(formatter)
1370 logger.addHandler(handler)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001371
1372 # Add DaemonState to JSONRPC lib classes
1373 Config.instance().classes.add(DaemonState)
1374
1375 ovl = OverlordCLIClient()
1376 try:
1377 ovl.Main()
1378 except KeyboardInterrupt:
1379 print('Ctrl-C received, abort')
1380 except Exception as e:
1381 print('error: %s' % e)
1382
1383
1384if __name__ == '__main__':
1385 main()