blob: a53dfce3bcd38f8e1c70df4a20c72f97b10f5b38 [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# Copyright 2015 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6from __future__ import print_function
7
8import argparse
9import ast
10import base64
11import fcntl
Peter Shih13e78c52018-01-23 12:57:07 +080012import functools
Wei-Ning Huangba768ab2016-02-07 14:38:06 +080013import getpass
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080014import hashlib
15import httplib
16import json
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080017import logging
18import os
19import re
20import select
21import signal
22import socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080023import ssl
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080024import StringIO
25import struct
26import subprocess
27import sys
28import tempfile
29import termios
30import threading
31import time
32import tty
Peter Shih99b73ec2017-06-16 17:54:15 +080033import unicodedata # required by pyinstaller, pylint: disable=unused-import
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080034import urllib2
35import urlparse
36
Peter Shih99b73ec2017-06-16 17:54:15 +080037import jsonrpclib
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080038from jsonrpclib.config import Config
Peter Shih99b73ec2017-06-16 17:54:15 +080039from jsonrpclib.SimpleJSONRPCServer import SimpleJSONRPCServer
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080040from ws4py.client import WebSocketBaseClient
Peter Shih99b73ec2017-06-16 17:54:15 +080041import yaml
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080042
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):
Peter Shih13e78c52018-01-23 12:57:07 +0800155 @functools.wraps(func)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800156 def Loop(*args, **kwargs):
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800157 for unused_i in range(retries):
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800158 try:
159 func(*args, **kwargs)
160 except Exception as e:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800161 print('error: %s: %s: retrying ...' % (args[0], e))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800162 else:
163 break
164 else:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800165 print('error: failed to %s %s' % (action_name, args[0]))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800166 return Loop
167 return Wrap
168
169
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800170def BasicAuthHeader(user, password):
171 """Return HTTP basic auth header."""
172 credential = base64.b64encode('%s:%s' % (user, password))
173 return ('Authorization', 'Basic %s' % credential)
174
175
176def GetTerminalSize():
177 """Retrieve terminal window size."""
178 ws = struct.pack('HHHH', 0, 0, 0, 0)
179 ws = fcntl.ioctl(0, termios.TIOCGWINSZ, ws)
180 lines, columns, unused_x, unused_y = struct.unpack('HHHH', ws)
181 return lines, columns
182
183
184def MakeRequestUrl(state, url):
185 return 'http%s://%s' % ('s' if state.ssl else '', url)
186
187
188class ProgressBar(object):
189 SIZE_WIDTH = 11
190 SPEED_WIDTH = 10
191 DURATION_WIDTH = 6
192 PERCENTAGE_WIDTH = 8
193
194 def __init__(self, name):
195 self._start_time = time.time()
196 self._name = name
197 self._size = 0
198 self._width = 0
199 self._name_width = 0
200 self._name_max = 0
201 self._stat_width = 0
202 self._max = 0
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800203 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800204 self.SetProgress(0)
205
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800206 def _CalculateSize(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800207 self._width = GetTerminalSize()[1] or _DEFAULT_TERMINAL_WIDTH
208 self._name_width = int(self._width * 0.3)
209 self._name_max = self._name_width
210 self._stat_width = self.SIZE_WIDTH + self.SPEED_WIDTH + self.DURATION_WIDTH
211 self._max = (self._width - self._name_width - self._stat_width -
212 self.PERCENTAGE_WIDTH)
213
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800214 def _SizeToHuman(self, size_in_bytes):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800215 if size_in_bytes < 1024:
216 unit = 'B'
217 value = size_in_bytes
218 elif size_in_bytes < 1024 ** 2:
219 unit = 'KiB'
220 value = size_in_bytes / 1024.0
221 elif size_in_bytes < 1024 ** 3:
222 unit = 'MiB'
223 value = size_in_bytes / (1024.0 ** 2)
224 elif size_in_bytes < 1024 ** 4:
225 unit = 'GiB'
226 value = size_in_bytes / (1024.0 ** 3)
227 return ' %6.1f %3s' % (value, unit)
228
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800229 def _SpeedToHuman(self, speed_in_bs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800230 if speed_in_bs < 1024:
231 unit = 'B'
232 value = speed_in_bs
233 elif speed_in_bs < 1024 ** 2:
234 unit = 'K'
235 value = speed_in_bs / 1024.0
236 elif speed_in_bs < 1024 ** 3:
237 unit = 'M'
238 value = speed_in_bs / (1024.0 ** 2)
239 elif speed_in_bs < 1024 ** 4:
240 unit = 'G'
241 value = speed_in_bs / (1024.0 ** 3)
242 return ' %6.1f%s/s' % (value, unit)
243
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800244 def _DurationToClock(self, duration):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800245 return ' %02d:%02d' % (duration / 60, duration % 60)
246
247 def SetProgress(self, percentage, size=None):
248 current_width = GetTerminalSize()[1]
249 if self._width != current_width:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800250 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800251
252 if size is not None:
253 self._size = size
254
255 elapse_time = time.time() - self._start_time
256 speed = self._size / float(elapse_time)
257
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800258 size_str = self._SizeToHuman(self._size)
259 speed_str = self._SpeedToHuman(speed)
260 elapse_str = self._DurationToClock(elapse_time)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800261
262 width = int(self._max * percentage / 100.0)
263 sys.stdout.write(
264 '%*s' % (- self._name_max,
265 self._name if len(self._name) <= self._name_max else
266 self._name[:self._name_max - 4] + ' ...') +
267 size_str + speed_str + elapse_str +
268 ((' [' + '#' * width + ' ' * (self._max - width) + ']' +
269 '%4d%%' % int(percentage)) if self._max > 2 else '') + '\r')
270 sys.stdout.flush()
271
272 def End(self):
273 self.SetProgress(100.0)
274 sys.stdout.write('\n')
275 sys.stdout.flush()
276
277
278class DaemonState(object):
279 """DaemonState is used for storing Overlord state info."""
280 def __init__(self):
281 self.version_sha1sum = GetVersionDigest()
282 self.host = None
283 self.port = None
284 self.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800285 self.ssl_self_signed = False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800286 self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800287 self.ssh = False
288 self.orig_host = None
289 self.ssh_pid = None
290 self.username = None
291 self.password = None
292 self.selected_mid = None
293 self.forwards = {}
294 self.listing = []
295 self.last_list = 0
296
297
298class OverlordClientDaemon(object):
299 """Overlord Client Daemon."""
300 def __init__(self):
301 self._state = DaemonState()
302 self._server = None
303
304 def Start(self):
305 self.StartRPCServer()
306
307 def StartRPCServer(self):
308 self._server = SimpleJSONRPCServer(_OVERLORD_CLIENT_DAEMON_RPC_ADDR,
309 logRequests=False)
310 exports = [
311 (self.State, 'State'),
312 (self.Ping, 'Ping'),
313 (self.GetPid, 'GetPid'),
314 (self.Connect, 'Connect'),
315 (self.Clients, 'Clients'),
316 (self.SelectClient, 'SelectClient'),
317 (self.AddForward, 'AddForward'),
318 (self.RemoveForward, 'RemoveForward'),
319 (self.RemoveAllForward, 'RemoveAllForward'),
320 ]
321 for func, name in exports:
322 self._server.register_function(func, name)
323
324 pid = os.fork()
325 if pid == 0:
Peter Shih4d55ded2017-07-03 17:19:01 +0800326 for fd in range(3):
327 os.close(fd)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800328 self._server.serve_forever()
329
330 @staticmethod
331 def GetRPCServer():
332 """Returns the Overlord client daemon RPC server."""
333 server = jsonrpclib.Server('http://%s:%d' %
334 _OVERLORD_CLIENT_DAEMON_RPC_ADDR)
335 try:
336 server.Ping()
337 except Exception:
338 return None
339 return server
340
341 def State(self):
342 return self._state
343
344 def Ping(self):
345 return True
346
347 def GetPid(self):
348 return os.getpid()
349
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800350 def _GetJSON(self, path):
351 url = '%s:%d%s' % (self._state.host, self._state.port, path)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800352 return json.loads(UrlOpen(self._state, url).read())
353
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800354 def _TLSEnabled(self):
355 """Determine if TLS is enabled on given server address."""
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800356 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
357 try:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800358 # Allow any certificate since we only want to check if server talks TLS.
359 context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
360 context.verify_mode = ssl.CERT_NONE
361
362 sock = context.wrap_socket(sock, server_hostname=self._state.host)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800363 sock.settimeout(_CONNECT_TIMEOUT)
364 sock.connect((self._state.host, self._state.port))
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800365 return True
Wei-Ning Huangecc80b82016-07-01 16:55:10 +0800366 except ssl.SSLError:
367 return False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800368 except socket.error: # Connect refused or timeout
369 raise
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800370 except Exception:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800371 return False # For whatever reason above failed, assume False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800372
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800373 def _CheckTLSCertificate(self, check_hostname=True):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800374 """Check TLS certificate.
375
376 Returns:
377 A tupple (check_result, if_certificate_is_loaded)
378 """
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800379 def _DoConnect(context):
380 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
381 try:
382 sock.settimeout(_CONNECT_TIMEOUT)
383 sock = context.wrap_socket(sock, server_hostname=self._state.host)
384 sock.connect((self._state.host, self._state.port))
385 except ssl.SSLError:
386 return False
387 finally:
388 sock.close()
389
390 # Save SSLContext for future use.
391 self._state.ssl_context = context
392 return True
393
394 # First try connect with built-in certificates
395 tls_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
396 if _DoConnect(tls_context):
397 return True
398
399 # Try with already saved certificate, if any.
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800400 tls_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
401 tls_context.verify_mode = ssl.CERT_REQUIRED
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800402 tls_context.check_hostname = check_hostname
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800403
404 tls_cert_path = GetTLSCertPath(self._state.host)
405 if os.path.exists(tls_cert_path):
406 tls_context.load_verify_locations(tls_cert_path)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800407 self._state.ssl_self_signed = True
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800408
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800409 return _DoConnect(tls_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800410
411 def Connect(self, host, port=_OVERLORD_HTTP_PORT, ssh_pid=None,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800412 username=None, password=None, orig_host=None,
413 check_hostname=True):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800414 self._state.username = username
415 self._state.password = password
416 self._state.host = host
417 self._state.port = port
418 self._state.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800419 self._state.ssl_self_signed = False
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800420 self._state.orig_host = orig_host
421 self._state.ssh_pid = ssh_pid
422 self._state.selected_mid = None
423
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800424 tls_enabled = self._TLSEnabled()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800425 if tls_enabled:
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800426 result = self._CheckTLSCertificate(check_hostname)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800427 if not result:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800428 if self._state.ssl_self_signed:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800429 return ('SSLCertificateChanged', ssl.get_server_certificate(
430 (self._state.host, self._state.port)))
431 else:
432 return ('SSLVerifyFailed', ssl.get_server_certificate(
433 (self._state.host, self._state.port)))
434
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800435 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800436 self._state.ssl = tls_enabled
437 UrlOpen(self._state, '%s:%d' % (host, port))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800438 except urllib2.HTTPError as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800439 return ('HTTPError', e.getcode(), str(e), e.read().strip())
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800440 except Exception as e:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800441 return str(e)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800442 else:
443 return True
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800444
445 def Clients(self):
446 if time.time() - self._state.last_list <= _LIST_CACHE_TIMEOUT:
447 return self._state.listing
448
Peter Shihcf0f3b22017-06-19 15:59:22 +0800449 self._state.listing = self._GetJSON('/api/agents/list')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800450 self._state.last_list = time.time()
451 return self._state.listing
452
453 def SelectClient(self, mid):
454 self._state.selected_mid = mid
455
456 def AddForward(self, mid, remote, local, pid):
457 self._state.forwards[local] = (mid, remote, pid)
458
459 def RemoveForward(self, local_port):
460 try:
461 unused_mid, unused_remote, pid = self._state.forwards[local_port]
462 KillGraceful(pid)
463 del self._state.forwards[local_port]
464 except (KeyError, OSError):
465 pass
466
467 def RemoveAllForward(self):
468 for unused_mid, unused_remote, pid in self._state.forwards.values():
469 try:
470 KillGraceful(pid)
471 except OSError:
472 pass
473 self._state.forwards = {}
474
475
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800476class SSLEnabledWebSocketBaseClient(WebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800477 def __init__(self, state, *args, **kwargs):
478 cafile = ssl.get_default_verify_paths().openssl_cafile
479 # For some system / distribution, python can not detect system cafile path.
480 # In such case we fallback to the default path.
481 if not os.path.exists(cafile):
482 cafile = '/etc/ssl/certs/ca-certificates.crt'
483
484 if state.ssl_self_signed:
485 cafile = GetTLSCertPath(state.host)
486
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800487 ssl_options = {
488 'cert_reqs': ssl.CERT_REQUIRED,
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800489 'ca_certs': cafile
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800490 }
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800491 # ws4py does not allow you to specify SSLContext, but rather passing in the
492 # argument of ssl.wrap_socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800493 super(SSLEnabledWebSocketBaseClient, self).__init__(
494 ssl_options=ssl_options, *args, **kwargs)
495
496
497class TerminalWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800498 def __init__(self, state, mid, escape, *args, **kwargs):
499 super(TerminalWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800500 self._mid = mid
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800501 self._escape = escape
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800502 self._stdin_fd = sys.stdin.fileno()
503 self._old_termios = None
504
505 def handshake_ok(self):
506 pass
507
508 def opened(self):
509 nonlocals = {'size': (80, 40)}
510
511 def _ResizeWindow():
512 size = GetTerminalSize()
513 if size != nonlocals['size']: # Size not changed, ignore
514 control = {'command': 'resize', 'params': list(size)}
515 payload = chr(_CONTROL_START) + json.dumps(control) + chr(_CONTROL_END)
516 nonlocals['size'] = size
517 try:
518 self.send(payload, binary=True)
519 except Exception:
520 pass
521
522 def _FeedInput():
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800523 self._old_termios = termios.tcgetattr(self._stdin_fd)
524 tty.setraw(self._stdin_fd)
525
526 READY, ENTER_PRESSED, ESCAPE_PRESSED = range(3)
527
528 try:
529 state = READY
530 while True:
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800531 # Check if terminal is resized
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800532 _ResizeWindow()
533
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800534 ch = sys.stdin.read(1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800535
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800536 # Scan for escape sequence
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800537 if self._escape:
538 if state == READY:
539 state = ENTER_PRESSED if ch == chr(0x0d) else READY
540 elif state == ENTER_PRESSED:
541 state = ESCAPE_PRESSED if ch == self._escape else READY
542 elif state == ESCAPE_PRESSED:
543 if ch == '.':
544 self.close()
545 break
546 else:
547 state = READY
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800548
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800549 self.send(ch)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800550 except (KeyboardInterrupt, RuntimeError):
551 pass
552
553 t = threading.Thread(target=_FeedInput)
554 t.daemon = True
555 t.start()
556
557 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800558 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800559 termios.tcsetattr(self._stdin_fd, termios.TCSANOW, self._old_termios)
560 print('Connection to %s closed.' % self._mid)
561
562 def received_message(self, msg):
563 if msg.is_binary:
564 sys.stdout.write(msg.data)
565 sys.stdout.flush()
566
567
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800568class ShellWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800569 def __init__(self, state, output, *args, **kwargs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800570 """Constructor.
571
572 Args:
573 output: output file object.
574 """
575 self.output = output
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800576 super(ShellWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800577
578 def handshake_ok(self):
579 pass
580
581 def opened(self):
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800582 def _FeedInput():
583 try:
584 while True:
585 data = sys.stdin.read(1)
586
Peter Shihf84a8972017-06-19 15:18:24 +0800587 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800588 self.send(_STDIN_CLOSED * 2)
589 break
590 self.send(data, binary=True)
591 except (KeyboardInterrupt, RuntimeError):
592 pass
593
594 t = threading.Thread(target=_FeedInput)
595 t.daemon = True
596 t.start()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800597
598 def closed(self, code, reason=None):
599 pass
600
601 def received_message(self, msg):
602 if msg.is_binary:
603 self.output.write(msg.data)
604 self.output.flush()
605
606
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800607class ForwarderWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800608 def __init__(self, state, sock, *args, **kwargs):
609 super(ForwarderWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800610 self._sock = sock
611 self._stop = threading.Event()
612
613 def handshake_ok(self):
614 pass
615
616 def opened(self):
617 def _FeedInput():
618 try:
619 self._sock.setblocking(False)
620 while True:
621 rd, unused_w, unused_x = select.select([self._sock], [], [], 0.5)
622 if self._stop.is_set():
623 break
624 if self._sock in rd:
625 data = self._sock.recv(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +0800626 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800627 self.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800628 break
629 self.send(data, binary=True)
630 except Exception:
631 pass
632 finally:
633 self._sock.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800634
635 t = threading.Thread(target=_FeedInput)
636 t.daemon = True
637 t.start()
638
639 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800640 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800641 self._stop.set()
642 sys.exit(0)
643
644 def received_message(self, msg):
645 if msg.is_binary:
646 self._sock.send(msg.data)
647
648
649def Arg(*args, **kwargs):
650 return (args, kwargs)
651
652
653def Command(command, help_msg=None, args=None):
654 """Decorator for adding argparse parameter for a method."""
655 if args is None:
656 args = []
657 def WrapFunc(func):
Peter Shih13e78c52018-01-23 12:57:07 +0800658 @functools.wraps(func)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800659 def Wrapped(*args, **kwargs):
660 return func(*args, **kwargs)
Peter Shih99b73ec2017-06-16 17:54:15 +0800661 # pylint: disable=protected-access
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800662 Wrapped.__arg_attr = {'command': command, 'help': help_msg, 'args': args}
663 return Wrapped
664 return WrapFunc
665
666
667def ParseMethodSubCommands(cls):
668 """Decorator for a class using the @Command decorator.
669
670 This decorator retrieve command info from each method and append it in to the
671 SUBCOMMANDS class variable, which is later used to construct parser.
672 """
673 for unused_key, method in cls.__dict__.iteritems():
674 if hasattr(method, '__arg_attr'):
Peter Shih99b73ec2017-06-16 17:54:15 +0800675 # pylint: disable=protected-access
676 cls.SUBCOMMANDS.append(method.__arg_attr)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800677 return cls
678
679
680@ParseMethodSubCommands
681class OverlordCLIClient(object):
682 """Overlord command line interface client."""
683
684 SUBCOMMANDS = []
685
686 def __init__(self):
687 self._parser = self._BuildParser()
688 self._selected_mid = None
689 self._server = None
690 self._state = None
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800691 self._escape = None
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800692
693 def _BuildParser(self):
694 root_parser = argparse.ArgumentParser(prog='ovl')
695 subparsers = root_parser.add_subparsers(help='sub-command')
696
697 root_parser.add_argument('-s', dest='selected_mid', action='store',
698 default=None,
699 help='select target to execute command on')
700 root_parser.add_argument('-S', dest='select_mid_before_action',
701 action='store_true', default=False,
702 help='select target before executing command')
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800703 root_parser.add_argument('-e', dest='escape', metavar='ESCAPE_CHAR',
704 action='store', default=_ESCAPE, type=str,
705 help='set shell escape character, \'none\' to '
706 'disable escape completely')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800707
708 for attr in self.SUBCOMMANDS:
709 parser = subparsers.add_parser(attr['command'], help=attr['help'])
710 parser.set_defaults(which=attr['command'])
711 for arg in attr['args']:
712 parser.add_argument(*arg[0], **arg[1])
713
714 return root_parser
715
716 def Main(self):
717 # We want to pass the rest of arguments after shell command directly to the
718 # function without parsing it.
719 try:
720 index = sys.argv.index('shell')
721 except ValueError:
722 args = self._parser.parse_args()
723 else:
724 args = self._parser.parse_args(sys.argv[1:index + 1])
725
726 command = args.which
727 self._selected_mid = args.selected_mid
728
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800729 if args.escape and args.escape != 'none':
730 self._escape = args.escape[0]
731
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800732 if command == 'start-server':
733 self.StartServer()
734 return
735 elif command == 'kill-server':
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800736 self.KillServer()
737 return
738
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800739 self.CheckDaemon()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800740 if command == 'status':
741 self.Status()
742 return
743 elif command == 'connect':
744 self.Connect(args)
745 return
746
747 # The following command requires connection to the server
748 self.CheckConnection()
749
750 if args.select_mid_before_action:
751 self.SelectClient(store=False)
752
753 if command == 'select':
754 self.SelectClient(args)
755 elif command == 'ls':
Peter Shih99b73ec2017-06-16 17:54:15 +0800756 self.ListClients(args)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800757 elif command == 'shell':
758 command = sys.argv[sys.argv.index('shell') + 1:]
759 self.Shell(command)
760 elif command == 'push':
761 self.Push(args)
762 elif command == 'pull':
763 self.Pull(args)
764 elif command == 'forward':
765 self.Forward(args)
766
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800767 def _SaveTLSCertificate(self, host, cert_pem):
768 try:
769 os.makedirs(_CERT_DIR)
770 except Exception:
771 pass
772 with open(GetTLSCertPath(host), 'w') as f:
773 f.write(cert_pem)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800774
775 def _HTTPPostFile(self, url, filename, progress=None, user=None, passwd=None):
776 """Perform HTTP POST and upload file to Overlord.
777
778 To minimize the external dependencies, we construct the HTTP post request
779 by ourselves.
780 """
781 url = MakeRequestUrl(self._state, url)
782 size = os.stat(filename).st_size
783 boundary = '-----------%s' % _HTTP_BOUNDARY_MAGIC
784 CRLF = '\r\n'
785 parse = urlparse.urlparse(url)
786
787 part_headers = [
788 '--' + boundary,
789 'Content-Disposition: form-data; name="file"; '
790 'filename="%s"' % os.path.basename(filename),
791 'Content-Type: application/octet-stream',
792 '', ''
793 ]
794 part_header = CRLF.join(part_headers)
795 end_part = CRLF + '--' + boundary + '--' + CRLF
796
797 content_length = len(part_header) + size + len(end_part)
798 if parse.scheme == 'http':
799 h = httplib.HTTP(parse.netloc)
800 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800801 h = httplib.HTTPS(parse.netloc, context=self._state.ssl_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800802
803 post_path = url[url.index(parse.netloc) + len(parse.netloc):]
804 h.putrequest('POST', post_path)
805 h.putheader('Content-Length', content_length)
806 h.putheader('Content-Type', 'multipart/form-data; boundary=%s' % boundary)
807
808 if user and passwd:
809 h.putheader(*BasicAuthHeader(user, passwd))
810 h.endheaders()
811 h.send(part_header)
812
813 count = 0
814 with open(filename, 'r') as f:
815 while True:
816 data = f.read(_BUFSIZ)
817 if not data:
818 break
819 count += len(data)
820 if progress:
821 progress(int(count * 100.0 / size), count)
822 h.send(data)
823
824 h.send(end_part)
825 progress(100)
826
827 if count != size:
828 logging.warning('file changed during upload, upload may be truncated.')
829
830 errcode, unused_x, unused_y = h.getreply()
831 return errcode == 200
832
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800833 def CheckDaemon(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800834 self._server = OverlordClientDaemon.GetRPCServer()
835 if self._server is None:
836 print('* daemon not running, starting it now on port %d ... *' %
837 _OVERLORD_CLIENT_DAEMON_PORT)
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800838 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800839
840 self._state = self._server.State()
841 sha1sum = GetVersionDigest()
842
843 if sha1sum != self._state.version_sha1sum:
844 print('ovl server is out of date. killing...')
845 KillGraceful(self._server.GetPid())
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800846 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800847
848 def GetSSHControlFile(self, host):
849 return _SSH_CONTROL_SOCKET_PREFIX + host
850
851 def SSHTunnel(self, user, host, port):
852 """SSH forward the remote overlord server.
853
854 Overlord server may not have port 9000 open to the public network, in such
855 case we can SSH forward the port to localhost.
856 """
857
858 control_file = self.GetSSHControlFile(host)
859 try:
860 os.unlink(control_file)
861 except Exception:
862 pass
863
864 subprocess.Popen([
865 'ssh', '-Nf',
866 '-M', # Enable master mode
867 '-S', control_file,
868 '-L', '9000:localhost:9000',
869 '-p', str(port),
870 '%s%s' % (user + '@' if user else '', host)
871 ]).wait()
872
873 p = subprocess.Popen([
874 'ssh',
875 '-S', control_file,
876 '-O', 'check', host,
877 ], stderr=subprocess.PIPE)
878 unused_stdout, stderr = p.communicate()
879
880 s = re.search(r'pid=(\d+)', stderr)
881 if s:
882 return int(s.group(1))
883
884 raise RuntimeError('can not establish ssh connection')
885
886 def CheckConnection(self):
887 if self._state.host is None:
888 raise RuntimeError('not connected to any server, abort')
889
890 try:
891 self._server.Clients()
892 except Exception:
893 raise RuntimeError('remote server disconnected, abort')
894
895 if self._state.ssh_pid is not None:
896 ret = subprocess.Popen(['kill', '-0', str(self._state.ssh_pid)],
897 stdout=subprocess.PIPE,
898 stderr=subprocess.PIPE).wait()
899 if ret != 0:
900 raise RuntimeError('ssh tunnel disconnected, please re-connect')
901
902 def CheckClient(self):
903 if self._selected_mid is None:
904 if self._state.selected_mid is None:
905 raise RuntimeError('No client is selected')
906 self._selected_mid = self._state.selected_mid
907
Peter Shihcf0f3b22017-06-19 15:59:22 +0800908 if not any(client['mid'] == self._selected_mid
909 for client in self._server.Clients()):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800910 raise RuntimeError('client %s disappeared' % self._selected_mid)
911
912 def CheckOutput(self, command):
913 headers = []
914 if self._state.username is not None and self._state.password is not None:
915 headers.append(BasicAuthHeader(self._state.username,
916 self._state.password))
917
918 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
919 sio = StringIO.StringIO()
Peter Shihe03450b2017-06-14 14:02:08 +0800920 ws = ShellWebSocketClient(
921 self._state, sio, scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
922 self._state.host, self._state.port,
923 urllib2.quote(self._selected_mid), urllib2.quote(command)),
924 headers=headers)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800925 ws.connect()
926 ws.run()
927 return sio.getvalue()
928
929 @Command('status', 'show Overlord connection status')
930 def Status(self):
931 if self._state.host is None:
932 print('Not connected to any host.')
933 else:
934 if self._state.ssh_pid is not None:
935 print('Connected to %s with SSH tunneling.' % self._state.orig_host)
936 else:
937 print('Connected to %s:%d.' % (self._state.host, self._state.port))
938
939 if self._selected_mid is None:
940 self._selected_mid = self._state.selected_mid
941
942 if self._selected_mid is None:
943 print('No client is selected.')
944 else:
945 print('Client %s selected.' % self._selected_mid)
946
947 @Command('connect', 'connect to Overlord server', [
948 Arg('host', metavar='HOST', type=str, default='localhost',
949 help='Overlord hostname/IP'),
950 Arg('port', metavar='PORT', type=int,
951 default=_OVERLORD_HTTP_PORT, help='Overlord port'),
952 Arg('-f', '--forward', dest='ssh_forward', default=False,
953 action='store_true',
954 help='connect with SSH forwarding to the host'),
955 Arg('-p', '--ssh-port', dest='ssh_port', default=22,
956 type=int, help='SSH server port for SSH forwarding'),
957 Arg('-l', '--ssh-login', dest='ssh_login', default='',
958 type=str, help='SSH server login name for SSH forwarding'),
959 Arg('-u', '--user', dest='user', default=None,
960 type=str, help='Overlord HTTP auth username'),
961 Arg('-w', '--passwd', dest='passwd', default=None, type=str,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800962 help='Overlord HTTP auth password'),
963 Arg('-i', '--no-check-hostname', dest='check_hostname',
964 default=True, action='store_false',
965 help='Ignore SSL cert hostname check')])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800966 def Connect(self, args):
967 ssh_pid = None
968 host = args.host
969 orig_host = args.host
970
971 if args.ssh_forward:
972 # Kill previous SSH tunnel
973 self.KillSSHTunnel()
974
975 ssh_pid = self.SSHTunnel(args.ssh_login, args.host, args.ssh_port)
976 host = 'localhost'
977
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800978 username_provided = args.user is not None
979 password_provided = args.passwd is not None
980 prompt = False
981
982 for unused_i in range(3):
983 try:
984 if prompt:
985 if not username_provided:
986 args.user = raw_input('Username: ')
987 if not password_provided:
988 args.passwd = getpass.getpass('Password: ')
989
990 ret = self._server.Connect(host, args.port, ssh_pid, args.user,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800991 args.passwd, orig_host,
992 args.check_hostname)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800993 if isinstance(ret, list):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800994 if ret[0].startswith('SSL'):
995 cert_pem = ret[1]
996 fp = GetTLSCertificateSHA1Fingerprint(cert_pem)
997 fp_text = ':'.join([fp[i:i+2] for i in range(0, len(fp), 2)])
998
999 if ret[0] == 'SSLCertificateChanged':
1000 print(_TLS_CERT_CHANGED_WARNING % (fp_text, GetTLSCertPath(host)))
1001 return
1002 elif ret[0] == 'SSLVerifyFailed':
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001003 print(_TLS_CERT_FAILED_WARNING % (fp_text), end='')
1004 response = raw_input()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001005 if response.lower() in ['y', 'ye', 'yes']:
1006 self._SaveTLSCertificate(host, cert_pem)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001007 print('TLS host Certificate trusted, you will not be prompted '
1008 'next time.\n')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001009 continue
1010 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001011 print('connection aborted.')
1012 return
1013 elif ret[0] == 'HTTPError':
1014 code, except_str, body = ret[1:]
1015 if code == 401:
1016 print('connect: %s' % body)
1017 prompt = True
1018 if not username_provided or not password_provided:
1019 continue
1020 else:
1021 break
1022 else:
1023 logging.error('%s; %s', except_str, body)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001024
1025 if ret is not True:
1026 print('can not connect to %s: %s' % (host, ret))
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001027 else:
1028 print('connection to %s:%d established.' % (host, args.port))
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001029 except Exception as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001030 logging.error(e)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001031 else:
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001032 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001033
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001034 @Command('start-server', 'start overlord CLI client server')
1035 def StartServer(self):
1036 self._server = OverlordClientDaemon.GetRPCServer()
1037 if self._server is None:
1038 OverlordClientDaemon().Start()
1039 time.sleep(1)
1040 self._server = OverlordClientDaemon.GetRPCServer()
1041 if self._server is not None:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001042 print('* daemon started successfully *\n')
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001043
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001044 @Command('kill-server', 'kill overlord CLI client server')
1045 def KillServer(self):
1046 self._server = OverlordClientDaemon.GetRPCServer()
1047 if self._server is None:
1048 return
1049
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001050 self._state = self._server.State()
1051
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001052 # Kill SSH Tunnel
1053 self.KillSSHTunnel()
1054
1055 # Kill server daemon
1056 KillGraceful(self._server.GetPid())
1057
1058 def KillSSHTunnel(self):
1059 if self._state.ssh_pid is not None:
1060 KillGraceful(self._state.ssh_pid)
1061
Peter Shihcf0f3b22017-06-19 15:59:22 +08001062 def _FilterClients(self, clients, prop_filters, mid=None):
1063 def _ClientPropertiesMatch(client, key, regex):
1064 try:
1065 return bool(re.search(regex, client['properties'][key]))
1066 except KeyError:
1067 return False
1068
1069 for prop_filter in prop_filters:
1070 key, sep, regex = prop_filter.partition('=')
1071 if not sep:
1072 # The filter doesn't contains =.
1073 raise ValueError('Invalid filter condition %r' % filter)
1074 clients = [c for c in clients if _ClientPropertiesMatch(c, key, regex)]
1075
1076 if mid is not None:
1077 client = next((c for c in clients if c['mid'] == mid), None)
1078 if client:
1079 return [client]
1080 clients = [c for c in clients if c['mid'].startswith(mid)]
1081 return clients
1082
1083 @Command('ls', 'list clients', [
1084 Arg('-f', '--filter', default=[], dest='filters', action='append',
1085 help=('Conditions to filter clients by properties. '
1086 'Should be in form "key=regex", where regex is the regular '
1087 'expression that should be found in the value. '
1088 'Multiple --filter arguments would be ANDed.')),
Peter Shih99b73ec2017-06-16 17:54:15 +08001089 Arg('-v', '--verbose', default=False, action='store_true',
1090 help='Print properties of each client.')
1091 ])
1092 def ListClients(self, args):
Peter Shihcf0f3b22017-06-19 15:59:22 +08001093 clients = self._FilterClients(self._server.Clients(), args.filters)
1094 for client in clients:
1095 if args.verbose:
Peter Shih99b73ec2017-06-16 17:54:15 +08001096 print(yaml.safe_dump(client, default_flow_style=False))
Peter Shihcf0f3b22017-06-19 15:59:22 +08001097 else:
1098 print(client['mid'])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001099
1100 @Command('select', 'select default client', [
Peter Shihcf0f3b22017-06-19 15:59:22 +08001101 Arg('-f', '--filter', default=[], dest='filters', action='append',
1102 help=('Conditions to filter clients by properties. '
1103 'Should be in form "key=regex", where regex is the regular '
1104 'expression that should be found in the value. '
1105 'Multiple --filter arguments would be ANDed.')),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001106 Arg('mid', metavar='mid', nargs='?', default=None)])
1107 def SelectClient(self, args=None, store=True):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001108 mid = args.mid if args is not None else None
Wei-Ning Huang6796a132017-06-22 14:46:32 +08001109 filters = args.filters if args is not None else []
1110 clients = self._FilterClients(self._server.Clients(), filters, mid=mid)
Peter Shihcf0f3b22017-06-19 15:59:22 +08001111
1112 if not clients:
1113 raise RuntimeError('select: client not found')
1114 elif len(clients) == 1:
1115 mid = clients[0]['mid']
1116 else:
1117 # This case would not happen when args.mid is specified.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001118 print('Select from the following clients:')
1119 for i, client in enumerate(clients):
Peter Shihcf0f3b22017-06-19 15:59:22 +08001120 print(' %d. %s' % (i + 1, client['mid']))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001121
1122 print('\nSelection: ', end='')
1123 try:
1124 choice = int(raw_input()) - 1
Peter Shihcf0f3b22017-06-19 15:59:22 +08001125 mid = clients[choice]['mid']
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001126 except ValueError:
1127 raise RuntimeError('select: invalid selection')
1128 except IndexError:
1129 raise RuntimeError('select: selection out of range')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001130
1131 self._selected_mid = mid
1132 if store:
1133 self._server.SelectClient(mid)
1134 print('Client %s selected' % mid)
1135
1136 @Command('shell', 'open a shell or execute a shell command', [
1137 Arg('command', metavar='CMD', nargs='?', help='command to execute')])
1138 def Shell(self, command=None):
1139 if command is None:
1140 command = []
1141 self.CheckClient()
1142
1143 headers = []
1144 if self._state.username is not None and self._state.password is not None:
1145 headers.append(BasicAuthHeader(self._state.username,
1146 self._state.password))
1147
1148 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
Peter Shihe03450b2017-06-14 14:02:08 +08001149 if command:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001150 cmd = ' '.join(command)
Peter Shihe03450b2017-06-14 14:02:08 +08001151 ws = ShellWebSocketClient(
1152 self._state, sys.stdout,
1153 scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
1154 self._state.host, self._state.port,
1155 urllib2.quote(self._selected_mid), urllib2.quote(cmd)),
1156 headers=headers)
1157 else:
1158 ws = TerminalWebSocketClient(
1159 self._state, self._selected_mid, self._escape,
1160 scheme + '%s:%d/api/agent/tty/%s' % (
1161 self._state.host, self._state.port,
1162 urllib2.quote(self._selected_mid)),
1163 headers=headers)
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001164 try:
1165 ws.connect()
1166 ws.run()
1167 except socket.error as e:
1168 if e.errno == 32: # Broken pipe
1169 pass
1170 else:
1171 raise
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001172
1173 @Command('push', 'push a file or directory to remote', [
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001174 Arg('srcs', nargs='+', metavar='SOURCE'),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001175 Arg('dst', metavar='DESTINATION')])
1176 def Push(self, args):
1177 self.CheckClient()
1178
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001179 @AutoRetry('push', _RETRY_TIMES)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001180 def _push(src, dst):
1181 src_base = os.path.basename(src)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001182
1183 # Local file is a link
1184 if os.path.islink(src):
1185 pbar = ProgressBar(src_base)
1186 link_path = os.readlink(src)
1187 self.CheckOutput('mkdir -p %(dirname)s; '
1188 'if [ -d "%(dst)s" ]; then '
1189 'ln -sf "%(link_path)s" "%(dst)s/%(link_name)s"; '
1190 'else ln -sf "%(link_path)s" "%(dst)s"; fi' %
1191 dict(dirname=os.path.dirname(dst),
1192 link_path=link_path, dst=dst,
1193 link_name=src_base))
1194 pbar.End()
1195 return
1196
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001197 mode = '0%o' % (0x1FF & os.stat(src).st_mode)
1198 url = ('%s:%d/api/agent/upload/%s?dest=%s&perm=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001199 (self._state.host, self._state.port,
1200 urllib2.quote(self._selected_mid), dst, mode))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001201 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001202 UrlOpen(self._state, url + '&filename=%s' % src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001203 except urllib2.HTTPError as e:
1204 msg = json.loads(e.read()).get('error', None)
1205 raise RuntimeError('push: %s' % msg)
1206
1207 pbar = ProgressBar(src_base)
1208 self._HTTPPostFile(url, src, pbar.SetProgress,
1209 self._state.username, self._state.password)
1210 pbar.End()
1211
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001212 def _push_single_target(src, dst):
1213 if os.path.isdir(src):
1214 dst_exists = ast.literal_eval(self.CheckOutput(
1215 'stat %s >/dev/null 2>&1 && echo True || echo False' % dst))
1216 for root, unused_x, files in os.walk(src):
1217 # If destination directory does not exist, we should strip the first
1218 # layer of directory. For example: src_dir contains a single file 'A'
1219 #
1220 # push src_dir dest_dir
1221 #
1222 # If dest_dir exists, the resulting directory structure should be:
1223 # dest_dir/src_dir/A
1224 # If dest_dir does not exist, the resulting directory structure should
1225 # be:
1226 # dest_dir/A
1227 dst_root = root if dst_exists else root[len(src):].lstrip('/')
1228 for name in files:
1229 _push(os.path.join(root, name),
1230 os.path.join(dst, dst_root, name))
1231 else:
1232 _push(src, dst)
1233
1234 if len(args.srcs) > 1:
1235 dst_type = self.CheckOutput('stat \'%s\' --printf \'%%F\' '
1236 '2>/dev/null' % args.dst).strip()
1237 if not dst_type:
1238 raise RuntimeError('push: %s: No such file or directory' % args.dst)
1239 if dst_type != 'directory':
1240 raise RuntimeError('push: %s: Not a directory' % args.dst)
1241
1242 for src in args.srcs:
1243 if not os.path.exists(src):
1244 raise RuntimeError('push: can not stat "%s": no such file or directory'
1245 % src)
1246 if not os.access(src, os.R_OK):
1247 raise RuntimeError('push: can not open "%s" for reading' % src)
1248
1249 _push_single_target(src, args.dst)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001250
1251 @Command('pull', 'pull a file or directory from remote', [
1252 Arg('src', metavar='SOURCE'),
1253 Arg('dst', metavar='DESTINATION', default='.', nargs='?')])
1254 def Pull(self, args):
1255 self.CheckClient()
1256
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001257 @AutoRetry('pull', _RETRY_TIMES)
1258 def _pull(src, dst, ftype, perm=0644, link=None):
1259 try:
1260 os.makedirs(os.path.dirname(dst))
1261 except Exception:
1262 pass
1263
1264 src_base = os.path.basename(src)
1265
1266 # Remote file is a link
1267 if ftype == 'l':
1268 pbar = ProgressBar(src_base)
1269 if os.path.exists(dst):
1270 os.remove(dst)
1271 os.symlink(link, dst)
1272 pbar.End()
1273 return
1274
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001275 url = ('%s:%d/api/agent/download/%s?filename=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001276 (self._state.host, self._state.port,
1277 urllib2.quote(self._selected_mid), urllib2.quote(src)))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001278 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001279 h = UrlOpen(self._state, url)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001280 except urllib2.HTTPError as e:
1281 msg = json.loads(e.read()).get('error', 'unkown error')
1282 raise RuntimeError('pull: %s' % msg)
1283 except KeyboardInterrupt:
1284 return
1285
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001286 pbar = ProgressBar(src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001287 with open(dst, 'w') as f:
1288 os.fchmod(f.fileno(), perm)
1289 total_size = int(h.headers.get('Content-Length'))
1290 downloaded_size = 0
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001291
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001292 while True:
1293 data = h.read(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +08001294 if not data:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001295 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001296 downloaded_size += len(data)
1297 pbar.SetProgress(float(downloaded_size) * 100 / total_size,
1298 downloaded_size)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001299 f.write(data)
1300 pbar.End()
1301
1302 # Use find to get a listing of all files under a root directory. The 'stat'
1303 # command is used to retrieve the filename and it's filemode.
1304 output = self.CheckOutput(
1305 'cd $HOME; '
1306 'stat "%(src)s" >/dev/null && '
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001307 'find "%(src)s" \'(\' -type f -o -type l \')\' '
1308 '-printf \'%%m\t%%p\t%%y\t%%l\n\''
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001309 % {'src': args.src})
1310
1311 # We got error from the stat command
1312 if output.startswith('stat: '):
1313 sys.stderr.write(output)
1314 return
1315
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001316 entries = output.strip('\n').split('\n')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001317 common_prefix = os.path.dirname(args.src)
1318
1319 if len(entries) == 1:
1320 entry = entries[0]
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001321 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001322 if os.path.isdir(args.dst):
1323 dst = os.path.join(args.dst, os.path.basename(src_path))
1324 else:
1325 dst = args.dst
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001326 _pull(src_path, dst, ftype, int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001327 else:
1328 if not os.path.exists(args.dst):
1329 common_prefix = args.src
1330
1331 for entry in entries:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001332 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001333 rel_dst = src_path[len(common_prefix):].lstrip('/')
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001334 _pull(src_path, os.path.join(args.dst, rel_dst), ftype,
1335 int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001336
1337 @Command('forward', 'forward remote port to local port', [
1338 Arg('--list', dest='list_all', action='store_true', default=False,
1339 help='list all port forwarding sessions'),
1340 Arg('--remove', metavar='LOCAL_PORT', dest='remove', type=int,
1341 default=None,
1342 help='remove port forwarding for local port LOCAL_PORT'),
1343 Arg('--remove-all', dest='remove_all', action='store_true',
1344 default=False, help='remove all port forwarding'),
1345 Arg('remote', metavar='REMOTE_PORT', type=int, nargs='?'),
1346 Arg('local', metavar='LOCAL_PORT', type=int, nargs='?')])
1347 def Forward(self, args):
1348 if args.list_all:
1349 max_len = 10
Peter Shihf84a8972017-06-19 15:18:24 +08001350 if self._state.forwards:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001351 max_len = max([len(v[0]) for v in self._state.forwards.values()])
1352
1353 print('%-*s %-8s %-8s' % (max_len, 'Client', 'Remote', 'Local'))
1354 for local in sorted(self._state.forwards.keys()):
1355 value = self._state.forwards[local]
1356 print('%-*s %-8s %-8s' % (max_len, value[0], value[1], local))
1357 return
1358
1359 if args.remove_all:
1360 self._server.RemoveAllForward()
1361 return
1362
1363 if args.remove:
1364 self._server.RemoveForward(args.remove)
1365 return
1366
1367 self.CheckClient()
1368
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001369 if args.remote is None:
1370 raise RuntimeError('remote port not specified')
1371
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001372 if args.local is None:
1373 args.local = args.remote
1374 remote = int(args.remote)
1375 local = int(args.local)
1376
1377 def HandleConnection(conn):
1378 headers = []
1379 if self._state.username is not None and self._state.password is not None:
1380 headers.append(BasicAuthHeader(self._state.username,
1381 self._state.password))
1382
1383 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1384 ws = ForwarderWebSocketClient(
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001385 self._state, conn,
Peter Shihe03450b2017-06-14 14:02:08 +08001386 scheme + '%s:%d/api/agent/forward/%s?port=%d' % (
1387 self._state.host, self._state.port,
1388 urllib2.quote(self._selected_mid), remote),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001389 headers=headers)
1390 try:
1391 ws.connect()
1392 ws.run()
1393 except Exception as e:
1394 print('error: %s' % e)
1395 finally:
1396 ws.close()
1397
1398 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1399 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1400 server.bind(('0.0.0.0', local))
1401 server.listen(5)
1402
1403 pid = os.fork()
1404 if pid == 0:
1405 while True:
1406 conn, unused_addr = server.accept()
1407 t = threading.Thread(target=HandleConnection, args=(conn,))
1408 t.daemon = True
1409 t.start()
1410 else:
1411 self._server.AddForward(self._selected_mid, remote, local, pid)
1412
1413
1414def main():
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001415 # Setup logging format
1416 logger = logging.getLogger()
1417 logger.setLevel(logging.INFO)
1418 handler = logging.StreamHandler()
1419 formatter = logging.Formatter('%(asctime)s %(message)s', '%Y/%m/%d %H:%M:%S')
1420 handler.setFormatter(formatter)
1421 logger.addHandler(handler)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001422
1423 # Add DaemonState to JSONRPC lib classes
1424 Config.instance().classes.add(DaemonState)
1425
1426 ovl = OverlordCLIClient()
1427 try:
1428 ovl.Main()
1429 except KeyboardInterrupt:
1430 print('Ctrl-C received, abort')
1431 except Exception as e:
1432 print('error: %s' % e)
1433
1434
1435if __name__ == '__main__':
1436 main()