blob: 708bc92c9acfe48501319570a70b1b76066d0aaf [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
Wei-Ning Huangba768ab2016-02-07 14:38:06 +080012import getpass
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080013import hashlib
14import httplib
15import json
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080016import logging
17import os
18import re
19import select
20import signal
21import socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080022import ssl
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080023import StringIO
24import struct
25import subprocess
26import sys
27import tempfile
28import termios
29import threading
30import time
31import tty
Peter Shih99b73ec2017-06-16 17:54:15 +080032import unicodedata # required by pyinstaller, pylint: disable=unused-import
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080033import urllib2
34import urlparse
35
Peter Shih99b73ec2017-06-16 17:54:15 +080036import jsonrpclib
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080037from jsonrpclib.config import Config
Peter Shih99b73ec2017-06-16 17:54:15 +080038from jsonrpclib.SimpleJSONRPCServer import SimpleJSONRPCServer
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080039from ws4py.client import WebSocketBaseClient
Peter Shih99b73ec2017-06-16 17:54:15 +080040import yaml
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080041
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080042
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080043_CERT_DIR = os.path.expanduser('~/.config/ovl')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080044
45_ESCAPE = '~'
46_BUFSIZ = 8192
47_OVERLORD_PORT = 4455
48_OVERLORD_HTTP_PORT = 9000
49_OVERLORD_CLIENT_DAEMON_PORT = 4488
50_OVERLORD_CLIENT_DAEMON_RPC_ADDR = ('127.0.0.1', _OVERLORD_CLIENT_DAEMON_PORT)
51
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080052_CONNECT_TIMEOUT = 3
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +080053_DEFAULT_HTTP_TIMEOUT = 30
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080054_LIST_CACHE_TIMEOUT = 2
55_DEFAULT_TERMINAL_WIDTH = 80
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +080056_RETRY_TIMES = 3
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080057
58# echo -n overlord | md5sum
59_HTTP_BOUNDARY_MAGIC = '9246f080c855a69012707ab53489b921'
60
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +080061# Terminal resize control
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080062_CONTROL_START = 128
63_CONTROL_END = 129
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +080064
65# Stream control
66_STDIN_CLOSED = '##STDIN_CLOSED##'
67
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080068_SSH_CONTROL_SOCKET_PREFIX = os.path.join(tempfile.gettempdir(),
69 'ovl-ssh-control-')
70
Wei-Ning Huang47c79b82016-05-24 01:24:46 +080071_TLS_CERT_FAILED_WARNING = """
72@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
73@ WARNING: REMOTE HOST VERIFICATION HAS FAILED! @
74@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
75IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
76Someone could be eavesdropping on you right now (man-in-the-middle attack)!
77It is also possible that the server is using a self-signed certificate.
78The fingerprint for the TLS host certificate sent by the remote host is
79
80%s
81
82Do you want to trust this certificate and proceed? [Y/n] """
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080083
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080084_TLS_CERT_CHANGED_WARNING = """
85@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
86@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
87@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
88IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
89Someone could be eavesdropping on you right now (man-in-the-middle attack)!
Wei-Ning Huang47c79b82016-05-24 01:24:46 +080090It is also possible that the TLS host certificate has just been changed.
91The fingerprint for the TLS host certificate sent by the remote host is
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080092
93%s
94
95Remove '%s' if you still want to proceed.
96SSL Certificate verification failed."""
97
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080098
99def GetVersionDigest():
100 """Return the sha1sum of the current executing script."""
Wei-Ning Huang31609662016-08-11 00:22:25 +0800101 # Check python script by default
102 filename = __file__
103
104 # If we are running from a frozen binary, we should calculate the checksum
105 # against that binary instead of the python script.
106 # See: https://pyinstaller.readthedocs.io/en/stable/runtime-information.html
107 if getattr(sys, 'frozen', False):
108 filename = sys.executable
109
110 with open(filename, 'r') as f:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800111 return hashlib.sha1(f.read()).hexdigest()
112
113
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800114def GetTLSCertPath(host):
115 return os.path.join(_CERT_DIR, '%s.cert' % host)
116
117
118def UrlOpen(state, url):
119 """Wrapper for urllib2.urlopen.
120
121 It selects correct HTTP scheme according to self._state.ssl, add HTTP
122 basic auth headers, and add specify correct SSL context.
123 """
124 url = MakeRequestUrl(state, url)
125 request = urllib2.Request(url)
126 if state.username is not None and state.password is not None:
127 request.add_header(*BasicAuthHeader(state.username, state.password))
128 return urllib2.urlopen(request, timeout=_DEFAULT_HTTP_TIMEOUT,
129 context=state.ssl_context)
130
131
132def GetTLSCertificateSHA1Fingerprint(cert_pem):
133 beg = cert_pem.index('\n')
134 end = cert_pem.rindex('\n', 0, len(cert_pem) - 2)
135 cert_pem = cert_pem[beg:end] # Remove BEGIN/END CERTIFICATE boundary
136 cert_der = base64.b64decode(cert_pem)
137 return hashlib.sha1(cert_der).hexdigest()
138
139
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800140def KillGraceful(pid, wait_secs=1):
141 """Kill a process gracefully by first sending SIGTERM, wait for some time,
142 then send SIGKILL to make sure it's killed."""
143 try:
144 os.kill(pid, signal.SIGTERM)
145 time.sleep(wait_secs)
146 os.kill(pid, signal.SIGKILL)
147 except OSError:
148 pass
149
150
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800151def AutoRetry(action_name, retries):
152 """Decorator for retry function call."""
153 def Wrap(func):
154 def Loop(*args, **kwargs):
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800155 for unused_i in range(retries):
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800156 try:
157 func(*args, **kwargs)
158 except Exception as e:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800159 print('error: %s: %s: retrying ...' % (args[0], e))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800160 else:
161 break
162 else:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800163 print('error: failed to %s %s' % (action_name, args[0]))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800164 return Loop
165 return Wrap
166
167
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800168def BasicAuthHeader(user, password):
169 """Return HTTP basic auth header."""
170 credential = base64.b64encode('%s:%s' % (user, password))
171 return ('Authorization', 'Basic %s' % credential)
172
173
174def GetTerminalSize():
175 """Retrieve terminal window size."""
176 ws = struct.pack('HHHH', 0, 0, 0, 0)
177 ws = fcntl.ioctl(0, termios.TIOCGWINSZ, ws)
178 lines, columns, unused_x, unused_y = struct.unpack('HHHH', ws)
179 return lines, columns
180
181
182def MakeRequestUrl(state, url):
183 return 'http%s://%s' % ('s' if state.ssl else '', url)
184
185
186class ProgressBar(object):
187 SIZE_WIDTH = 11
188 SPEED_WIDTH = 10
189 DURATION_WIDTH = 6
190 PERCENTAGE_WIDTH = 8
191
192 def __init__(self, name):
193 self._start_time = time.time()
194 self._name = name
195 self._size = 0
196 self._width = 0
197 self._name_width = 0
198 self._name_max = 0
199 self._stat_width = 0
200 self._max = 0
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800201 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800202 self.SetProgress(0)
203
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800204 def _CalculateSize(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800205 self._width = GetTerminalSize()[1] or _DEFAULT_TERMINAL_WIDTH
206 self._name_width = int(self._width * 0.3)
207 self._name_max = self._name_width
208 self._stat_width = self.SIZE_WIDTH + self.SPEED_WIDTH + self.DURATION_WIDTH
209 self._max = (self._width - self._name_width - self._stat_width -
210 self.PERCENTAGE_WIDTH)
211
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800212 def _SizeToHuman(self, size_in_bytes):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800213 if size_in_bytes < 1024:
214 unit = 'B'
215 value = size_in_bytes
216 elif size_in_bytes < 1024 ** 2:
217 unit = 'KiB'
218 value = size_in_bytes / 1024.0
219 elif size_in_bytes < 1024 ** 3:
220 unit = 'MiB'
221 value = size_in_bytes / (1024.0 ** 2)
222 elif size_in_bytes < 1024 ** 4:
223 unit = 'GiB'
224 value = size_in_bytes / (1024.0 ** 3)
225 return ' %6.1f %3s' % (value, unit)
226
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800227 def _SpeedToHuman(self, speed_in_bs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800228 if speed_in_bs < 1024:
229 unit = 'B'
230 value = speed_in_bs
231 elif speed_in_bs < 1024 ** 2:
232 unit = 'K'
233 value = speed_in_bs / 1024.0
234 elif speed_in_bs < 1024 ** 3:
235 unit = 'M'
236 value = speed_in_bs / (1024.0 ** 2)
237 elif speed_in_bs < 1024 ** 4:
238 unit = 'G'
239 value = speed_in_bs / (1024.0 ** 3)
240 return ' %6.1f%s/s' % (value, unit)
241
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800242 def _DurationToClock(self, duration):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800243 return ' %02d:%02d' % (duration / 60, duration % 60)
244
245 def SetProgress(self, percentage, size=None):
246 current_width = GetTerminalSize()[1]
247 if self._width != current_width:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800248 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800249
250 if size is not None:
251 self._size = size
252
253 elapse_time = time.time() - self._start_time
254 speed = self._size / float(elapse_time)
255
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800256 size_str = self._SizeToHuman(self._size)
257 speed_str = self._SpeedToHuman(speed)
258 elapse_str = self._DurationToClock(elapse_time)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800259
260 width = int(self._max * percentage / 100.0)
261 sys.stdout.write(
262 '%*s' % (- self._name_max,
263 self._name if len(self._name) <= self._name_max else
264 self._name[:self._name_max - 4] + ' ...') +
265 size_str + speed_str + elapse_str +
266 ((' [' + '#' * width + ' ' * (self._max - width) + ']' +
267 '%4d%%' % int(percentage)) if self._max > 2 else '') + '\r')
268 sys.stdout.flush()
269
270 def End(self):
271 self.SetProgress(100.0)
272 sys.stdout.write('\n')
273 sys.stdout.flush()
274
275
276class DaemonState(object):
277 """DaemonState is used for storing Overlord state info."""
278 def __init__(self):
279 self.version_sha1sum = GetVersionDigest()
280 self.host = None
281 self.port = None
282 self.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800283 self.ssl_self_signed = False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800284 self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800285 self.ssh = False
286 self.orig_host = None
287 self.ssh_pid = None
288 self.username = None
289 self.password = None
290 self.selected_mid = None
291 self.forwards = {}
292 self.listing = []
293 self.last_list = 0
294
295
296class OverlordClientDaemon(object):
297 """Overlord Client Daemon."""
298 def __init__(self):
299 self._state = DaemonState()
300 self._server = None
301
302 def Start(self):
303 self.StartRPCServer()
304
305 def StartRPCServer(self):
306 self._server = SimpleJSONRPCServer(_OVERLORD_CLIENT_DAEMON_RPC_ADDR,
307 logRequests=False)
308 exports = [
309 (self.State, 'State'),
310 (self.Ping, 'Ping'),
311 (self.GetPid, 'GetPid'),
312 (self.Connect, 'Connect'),
313 (self.Clients, 'Clients'),
314 (self.SelectClient, 'SelectClient'),
315 (self.AddForward, 'AddForward'),
316 (self.RemoveForward, 'RemoveForward'),
317 (self.RemoveAllForward, 'RemoveAllForward'),
318 ]
319 for func, name in exports:
320 self._server.register_function(func, name)
321
322 pid = os.fork()
323 if pid == 0:
324 self._server.serve_forever()
325
326 @staticmethod
327 def GetRPCServer():
328 """Returns the Overlord client daemon RPC server."""
329 server = jsonrpclib.Server('http://%s:%d' %
330 _OVERLORD_CLIENT_DAEMON_RPC_ADDR)
331 try:
332 server.Ping()
333 except Exception:
334 return None
335 return server
336
337 def State(self):
338 return self._state
339
340 def Ping(self):
341 return True
342
343 def GetPid(self):
344 return os.getpid()
345
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800346 def _GetJSON(self, path):
347 url = '%s:%d%s' % (self._state.host, self._state.port, path)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800348 return json.loads(UrlOpen(self._state, url).read())
349
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800350 def _TLSEnabled(self):
351 """Determine if TLS is enabled on given server address."""
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800352 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
353 try:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800354 # Allow any certificate since we only want to check if server talks TLS.
355 context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
356 context.verify_mode = ssl.CERT_NONE
357
358 sock = context.wrap_socket(sock, server_hostname=self._state.host)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800359 sock.settimeout(_CONNECT_TIMEOUT)
360 sock.connect((self._state.host, self._state.port))
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800361 return True
Wei-Ning Huangecc80b82016-07-01 16:55:10 +0800362 except ssl.SSLError:
363 return False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800364 except socket.error: # Connect refused or timeout
365 raise
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800366 except Exception:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800367 return False # For whatever reason above failed, assume False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800368
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800369 def _CheckTLSCertificate(self, check_hostname=True):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800370 """Check TLS certificate.
371
372 Returns:
373 A tupple (check_result, if_certificate_is_loaded)
374 """
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800375 def _DoConnect(context):
376 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
377 try:
378 sock.settimeout(_CONNECT_TIMEOUT)
379 sock = context.wrap_socket(sock, server_hostname=self._state.host)
380 sock.connect((self._state.host, self._state.port))
381 except ssl.SSLError:
382 return False
383 finally:
384 sock.close()
385
386 # Save SSLContext for future use.
387 self._state.ssl_context = context
388 return True
389
390 # First try connect with built-in certificates
391 tls_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
392 if _DoConnect(tls_context):
393 return True
394
395 # Try with already saved certificate, if any.
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800396 tls_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
397 tls_context.verify_mode = ssl.CERT_REQUIRED
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800398 tls_context.check_hostname = check_hostname
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800399
400 tls_cert_path = GetTLSCertPath(self._state.host)
401 if os.path.exists(tls_cert_path):
402 tls_context.load_verify_locations(tls_cert_path)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800403 self._state.ssl_self_signed = True
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800404
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800405 return _DoConnect(tls_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800406
407 def Connect(self, host, port=_OVERLORD_HTTP_PORT, ssh_pid=None,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800408 username=None, password=None, orig_host=None,
409 check_hostname=True):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800410 self._state.username = username
411 self._state.password = password
412 self._state.host = host
413 self._state.port = port
414 self._state.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800415 self._state.ssl_self_signed = False
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800416 self._state.orig_host = orig_host
417 self._state.ssh_pid = ssh_pid
418 self._state.selected_mid = None
419
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800420 tls_enabled = self._TLSEnabled()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800421 if tls_enabled:
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800422 result = self._CheckTLSCertificate(check_hostname)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800423 if not result:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800424 if self._state.ssl_self_signed:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800425 return ('SSLCertificateChanged', ssl.get_server_certificate(
426 (self._state.host, self._state.port)))
427 else:
428 return ('SSLVerifyFailed', ssl.get_server_certificate(
429 (self._state.host, self._state.port)))
430
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800431 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800432 self._state.ssl = tls_enabled
433 UrlOpen(self._state, '%s:%d' % (host, port))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800434 except urllib2.HTTPError as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800435 return ('HTTPError', e.getcode(), str(e), e.read().strip())
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800436 except Exception as e:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800437 return str(e)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800438 else:
439 return True
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800440
441 def Clients(self):
442 if time.time() - self._state.last_list <= _LIST_CACHE_TIMEOUT:
443 return self._state.listing
444
Peter Shihcf0f3b22017-06-19 15:59:22 +0800445 self._state.listing = self._GetJSON('/api/agents/list')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800446 self._state.last_list = time.time()
447 return self._state.listing
448
449 def SelectClient(self, mid):
450 self._state.selected_mid = mid
451
452 def AddForward(self, mid, remote, local, pid):
453 self._state.forwards[local] = (mid, remote, pid)
454
455 def RemoveForward(self, local_port):
456 try:
457 unused_mid, unused_remote, pid = self._state.forwards[local_port]
458 KillGraceful(pid)
459 del self._state.forwards[local_port]
460 except (KeyError, OSError):
461 pass
462
463 def RemoveAllForward(self):
464 for unused_mid, unused_remote, pid in self._state.forwards.values():
465 try:
466 KillGraceful(pid)
467 except OSError:
468 pass
469 self._state.forwards = {}
470
471
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800472class SSLEnabledWebSocketBaseClient(WebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800473 def __init__(self, state, *args, **kwargs):
474 cafile = ssl.get_default_verify_paths().openssl_cafile
475 # For some system / distribution, python can not detect system cafile path.
476 # In such case we fallback to the default path.
477 if not os.path.exists(cafile):
478 cafile = '/etc/ssl/certs/ca-certificates.crt'
479
480 if state.ssl_self_signed:
481 cafile = GetTLSCertPath(state.host)
482
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800483 ssl_options = {
484 'cert_reqs': ssl.CERT_REQUIRED,
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800485 'ca_certs': cafile
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800486 }
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800487 # ws4py does not allow you to specify SSLContext, but rather passing in the
488 # argument of ssl.wrap_socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800489 super(SSLEnabledWebSocketBaseClient, self).__init__(
490 ssl_options=ssl_options, *args, **kwargs)
491
492
493class TerminalWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800494 def __init__(self, state, mid, escape, *args, **kwargs):
495 super(TerminalWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800496 self._mid = mid
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800497 self._escape = escape
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800498 self._stdin_fd = sys.stdin.fileno()
499 self._old_termios = None
500
501 def handshake_ok(self):
502 pass
503
504 def opened(self):
505 nonlocals = {'size': (80, 40)}
506
507 def _ResizeWindow():
508 size = GetTerminalSize()
509 if size != nonlocals['size']: # Size not changed, ignore
510 control = {'command': 'resize', 'params': list(size)}
511 payload = chr(_CONTROL_START) + json.dumps(control) + chr(_CONTROL_END)
512 nonlocals['size'] = size
513 try:
514 self.send(payload, binary=True)
515 except Exception:
516 pass
517
518 def _FeedInput():
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800519 self._old_termios = termios.tcgetattr(self._stdin_fd)
520 tty.setraw(self._stdin_fd)
521
522 READY, ENTER_PRESSED, ESCAPE_PRESSED = range(3)
523
524 try:
525 state = READY
526 while True:
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800527 # Check if terminal is resized
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800528 _ResizeWindow()
529
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800530 ch = sys.stdin.read(1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800531
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800532 # Scan for escape sequence
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800533 if self._escape:
534 if state == READY:
535 state = ENTER_PRESSED if ch == chr(0x0d) else READY
536 elif state == ENTER_PRESSED:
537 state = ESCAPE_PRESSED if ch == self._escape else READY
538 elif state == ESCAPE_PRESSED:
539 if ch == '.':
540 self.close()
541 break
542 else:
543 state = READY
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800544
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800545 self.send(ch)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800546 except (KeyboardInterrupt, RuntimeError):
547 pass
548
549 t = threading.Thread(target=_FeedInput)
550 t.daemon = True
551 t.start()
552
553 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800554 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800555 termios.tcsetattr(self._stdin_fd, termios.TCSANOW, self._old_termios)
556 print('Connection to %s closed.' % self._mid)
557
558 def received_message(self, msg):
559 if msg.is_binary:
560 sys.stdout.write(msg.data)
561 sys.stdout.flush()
562
563
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800564class ShellWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800565 def __init__(self, state, output, *args, **kwargs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800566 """Constructor.
567
568 Args:
569 output: output file object.
570 """
571 self.output = output
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800572 super(ShellWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800573
574 def handshake_ok(self):
575 pass
576
577 def opened(self):
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800578 def _FeedInput():
579 try:
580 while True:
581 data = sys.stdin.read(1)
582
Peter Shihf84a8972017-06-19 15:18:24 +0800583 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800584 self.send(_STDIN_CLOSED * 2)
585 break
586 self.send(data, binary=True)
587 except (KeyboardInterrupt, RuntimeError):
588 pass
589
590 t = threading.Thread(target=_FeedInput)
591 t.daemon = True
592 t.start()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800593
594 def closed(self, code, reason=None):
595 pass
596
597 def received_message(self, msg):
598 if msg.is_binary:
599 self.output.write(msg.data)
600 self.output.flush()
601
602
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800603class ForwarderWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800604 def __init__(self, state, sock, *args, **kwargs):
605 super(ForwarderWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800606 self._sock = sock
607 self._stop = threading.Event()
608
609 def handshake_ok(self):
610 pass
611
612 def opened(self):
613 def _FeedInput():
614 try:
615 self._sock.setblocking(False)
616 while True:
617 rd, unused_w, unused_x = select.select([self._sock], [], [], 0.5)
618 if self._stop.is_set():
619 break
620 if self._sock in rd:
621 data = self._sock.recv(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +0800622 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800623 self.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800624 break
625 self.send(data, binary=True)
626 except Exception:
627 pass
628 finally:
629 self._sock.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800630
631 t = threading.Thread(target=_FeedInput)
632 t.daemon = True
633 t.start()
634
635 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800636 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800637 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)
Peter Shih99b73ec2017-06-16 17:54:15 +0800656 # pylint: disable=protected-access
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800657 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'):
Peter Shih99b73ec2017-06-16 17:54:15 +0800670 # pylint: disable=protected-access
671 cls.SUBCOMMANDS.append(method.__arg_attr)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800672 return cls
673
674
675@ParseMethodSubCommands
676class OverlordCLIClient(object):
677 """Overlord command line interface client."""
678
679 SUBCOMMANDS = []
680
681 def __init__(self):
682 self._parser = self._BuildParser()
683 self._selected_mid = None
684 self._server = None
685 self._state = None
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800686 self._escape = None
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800687
688 def _BuildParser(self):
689 root_parser = argparse.ArgumentParser(prog='ovl')
690 subparsers = root_parser.add_subparsers(help='sub-command')
691
692 root_parser.add_argument('-s', dest='selected_mid', action='store',
693 default=None,
694 help='select target to execute command on')
695 root_parser.add_argument('-S', dest='select_mid_before_action',
696 action='store_true', default=False,
697 help='select target before executing command')
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800698 root_parser.add_argument('-e', dest='escape', metavar='ESCAPE_CHAR',
699 action='store', default=_ESCAPE, type=str,
700 help='set shell escape character, \'none\' to '
701 'disable escape completely')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800702
703 for attr in self.SUBCOMMANDS:
704 parser = subparsers.add_parser(attr['command'], help=attr['help'])
705 parser.set_defaults(which=attr['command'])
706 for arg in attr['args']:
707 parser.add_argument(*arg[0], **arg[1])
708
709 return root_parser
710
711 def Main(self):
712 # We want to pass the rest of arguments after shell command directly to the
713 # function without parsing it.
714 try:
715 index = sys.argv.index('shell')
716 except ValueError:
717 args = self._parser.parse_args()
718 else:
719 args = self._parser.parse_args(sys.argv[1:index + 1])
720
721 command = args.which
722 self._selected_mid = args.selected_mid
723
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800724 if args.escape and args.escape != 'none':
725 self._escape = args.escape[0]
726
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800727 if command == 'start-server':
728 self.StartServer()
729 return
730 elif command == 'kill-server':
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800731 self.KillServer()
732 return
733
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800734 self.CheckDaemon()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800735 if command == 'status':
736 self.Status()
737 return
738 elif command == 'connect':
739 self.Connect(args)
740 return
741
742 # The following command requires connection to the server
743 self.CheckConnection()
744
745 if args.select_mid_before_action:
746 self.SelectClient(store=False)
747
748 if command == 'select':
749 self.SelectClient(args)
750 elif command == 'ls':
Peter Shih99b73ec2017-06-16 17:54:15 +0800751 self.ListClients(args)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800752 elif command == 'shell':
753 command = sys.argv[sys.argv.index('shell') + 1:]
754 self.Shell(command)
755 elif command == 'push':
756 self.Push(args)
757 elif command == 'pull':
758 self.Pull(args)
759 elif command == 'forward':
760 self.Forward(args)
761
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800762 def _SaveTLSCertificate(self, host, cert_pem):
763 try:
764 os.makedirs(_CERT_DIR)
765 except Exception:
766 pass
767 with open(GetTLSCertPath(host), 'w') as f:
768 f.write(cert_pem)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800769
770 def _HTTPPostFile(self, url, filename, progress=None, user=None, passwd=None):
771 """Perform HTTP POST and upload file to Overlord.
772
773 To minimize the external dependencies, we construct the HTTP post request
774 by ourselves.
775 """
776 url = MakeRequestUrl(self._state, url)
777 size = os.stat(filename).st_size
778 boundary = '-----------%s' % _HTTP_BOUNDARY_MAGIC
779 CRLF = '\r\n'
780 parse = urlparse.urlparse(url)
781
782 part_headers = [
783 '--' + boundary,
784 'Content-Disposition: form-data; name="file"; '
785 'filename="%s"' % os.path.basename(filename),
786 'Content-Type: application/octet-stream',
787 '', ''
788 ]
789 part_header = CRLF.join(part_headers)
790 end_part = CRLF + '--' + boundary + '--' + CRLF
791
792 content_length = len(part_header) + size + len(end_part)
793 if parse.scheme == 'http':
794 h = httplib.HTTP(parse.netloc)
795 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800796 h = httplib.HTTPS(parse.netloc, context=self._state.ssl_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800797
798 post_path = url[url.index(parse.netloc) + len(parse.netloc):]
799 h.putrequest('POST', post_path)
800 h.putheader('Content-Length', content_length)
801 h.putheader('Content-Type', 'multipart/form-data; boundary=%s' % boundary)
802
803 if user and passwd:
804 h.putheader(*BasicAuthHeader(user, passwd))
805 h.endheaders()
806 h.send(part_header)
807
808 count = 0
809 with open(filename, 'r') as f:
810 while True:
811 data = f.read(_BUFSIZ)
812 if not data:
813 break
814 count += len(data)
815 if progress:
816 progress(int(count * 100.0 / size), count)
817 h.send(data)
818
819 h.send(end_part)
820 progress(100)
821
822 if count != size:
823 logging.warning('file changed during upload, upload may be truncated.')
824
825 errcode, unused_x, unused_y = h.getreply()
826 return errcode == 200
827
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800828 def CheckDaemon(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800829 self._server = OverlordClientDaemon.GetRPCServer()
830 if self._server is None:
831 print('* daemon not running, starting it now on port %d ... *' %
832 _OVERLORD_CLIENT_DAEMON_PORT)
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800833 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800834
835 self._state = self._server.State()
836 sha1sum = GetVersionDigest()
837
838 if sha1sum != self._state.version_sha1sum:
839 print('ovl server is out of date. killing...')
840 KillGraceful(self._server.GetPid())
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800841 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800842
843 def GetSSHControlFile(self, host):
844 return _SSH_CONTROL_SOCKET_PREFIX + host
845
846 def SSHTunnel(self, user, host, port):
847 """SSH forward the remote overlord server.
848
849 Overlord server may not have port 9000 open to the public network, in such
850 case we can SSH forward the port to localhost.
851 """
852
853 control_file = self.GetSSHControlFile(host)
854 try:
855 os.unlink(control_file)
856 except Exception:
857 pass
858
859 subprocess.Popen([
860 'ssh', '-Nf',
861 '-M', # Enable master mode
862 '-S', control_file,
863 '-L', '9000:localhost:9000',
864 '-p', str(port),
865 '%s%s' % (user + '@' if user else '', host)
866 ]).wait()
867
868 p = subprocess.Popen([
869 'ssh',
870 '-S', control_file,
871 '-O', 'check', host,
872 ], stderr=subprocess.PIPE)
873 unused_stdout, stderr = p.communicate()
874
875 s = re.search(r'pid=(\d+)', stderr)
876 if s:
877 return int(s.group(1))
878
879 raise RuntimeError('can not establish ssh connection')
880
881 def CheckConnection(self):
882 if self._state.host is None:
883 raise RuntimeError('not connected to any server, abort')
884
885 try:
886 self._server.Clients()
887 except Exception:
888 raise RuntimeError('remote server disconnected, abort')
889
890 if self._state.ssh_pid is not None:
891 ret = subprocess.Popen(['kill', '-0', str(self._state.ssh_pid)],
892 stdout=subprocess.PIPE,
893 stderr=subprocess.PIPE).wait()
894 if ret != 0:
895 raise RuntimeError('ssh tunnel disconnected, please re-connect')
896
897 def CheckClient(self):
898 if self._selected_mid is None:
899 if self._state.selected_mid is None:
900 raise RuntimeError('No client is selected')
901 self._selected_mid = self._state.selected_mid
902
Peter Shihcf0f3b22017-06-19 15:59:22 +0800903 if not any(client['mid'] == self._selected_mid
904 for client in self._server.Clients()):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800905 raise RuntimeError('client %s disappeared' % self._selected_mid)
906
907 def CheckOutput(self, command):
908 headers = []
909 if self._state.username is not None and self._state.password is not None:
910 headers.append(BasicAuthHeader(self._state.username,
911 self._state.password))
912
913 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
914 sio = StringIO.StringIO()
Peter Shihe03450b2017-06-14 14:02:08 +0800915 ws = ShellWebSocketClient(
916 self._state, sio, scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
917 self._state.host, self._state.port,
918 urllib2.quote(self._selected_mid), urllib2.quote(command)),
919 headers=headers)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800920 ws.connect()
921 ws.run()
922 return sio.getvalue()
923
924 @Command('status', 'show Overlord connection status')
925 def Status(self):
926 if self._state.host is None:
927 print('Not connected to any host.')
928 else:
929 if self._state.ssh_pid is not None:
930 print('Connected to %s with SSH tunneling.' % self._state.orig_host)
931 else:
932 print('Connected to %s:%d.' % (self._state.host, self._state.port))
933
934 if self._selected_mid is None:
935 self._selected_mid = self._state.selected_mid
936
937 if self._selected_mid is None:
938 print('No client is selected.')
939 else:
940 print('Client %s selected.' % self._selected_mid)
941
942 @Command('connect', 'connect to Overlord server', [
943 Arg('host', metavar='HOST', type=str, default='localhost',
944 help='Overlord hostname/IP'),
945 Arg('port', metavar='PORT', type=int,
946 default=_OVERLORD_HTTP_PORT, help='Overlord port'),
947 Arg('-f', '--forward', dest='ssh_forward', default=False,
948 action='store_true',
949 help='connect with SSH forwarding to the host'),
950 Arg('-p', '--ssh-port', dest='ssh_port', default=22,
951 type=int, help='SSH server port for SSH forwarding'),
952 Arg('-l', '--ssh-login', dest='ssh_login', default='',
953 type=str, help='SSH server login name for SSH forwarding'),
954 Arg('-u', '--user', dest='user', default=None,
955 type=str, help='Overlord HTTP auth username'),
956 Arg('-w', '--passwd', dest='passwd', default=None, type=str,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800957 help='Overlord HTTP auth password'),
958 Arg('-i', '--no-check-hostname', dest='check_hostname',
959 default=True, action='store_false',
960 help='Ignore SSL cert hostname check')])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800961 def Connect(self, args):
962 ssh_pid = None
963 host = args.host
964 orig_host = args.host
965
966 if args.ssh_forward:
967 # Kill previous SSH tunnel
968 self.KillSSHTunnel()
969
970 ssh_pid = self.SSHTunnel(args.ssh_login, args.host, args.ssh_port)
971 host = 'localhost'
972
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800973 username_provided = args.user is not None
974 password_provided = args.passwd is not None
975 prompt = False
976
977 for unused_i in range(3):
978 try:
979 if prompt:
980 if not username_provided:
981 args.user = raw_input('Username: ')
982 if not password_provided:
983 args.passwd = getpass.getpass('Password: ')
984
985 ret = self._server.Connect(host, args.port, ssh_pid, args.user,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800986 args.passwd, orig_host,
987 args.check_hostname)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800988 if isinstance(ret, list):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800989 if ret[0].startswith('SSL'):
990 cert_pem = ret[1]
991 fp = GetTLSCertificateSHA1Fingerprint(cert_pem)
992 fp_text = ':'.join([fp[i:i+2] for i in range(0, len(fp), 2)])
993
994 if ret[0] == 'SSLCertificateChanged':
995 print(_TLS_CERT_CHANGED_WARNING % (fp_text, GetTLSCertPath(host)))
996 return
997 elif ret[0] == 'SSLVerifyFailed':
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800998 print(_TLS_CERT_FAILED_WARNING % (fp_text), end='')
999 response = raw_input()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001000 if response.lower() in ['y', 'ye', 'yes']:
1001 self._SaveTLSCertificate(host, cert_pem)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001002 print('TLS host Certificate trusted, you will not be prompted '
1003 'next time.\n')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001004 continue
1005 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001006 print('connection aborted.')
1007 return
1008 elif ret[0] == 'HTTPError':
1009 code, except_str, body = ret[1:]
1010 if code == 401:
1011 print('connect: %s' % body)
1012 prompt = True
1013 if not username_provided or not password_provided:
1014 continue
1015 else:
1016 break
1017 else:
1018 logging.error('%s; %s', except_str, body)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001019
1020 if ret is not True:
1021 print('can not connect to %s: %s' % (host, ret))
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001022 else:
1023 print('connection to %s:%d established.' % (host, args.port))
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001024 except Exception as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001025 logging.error(e)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001026 else:
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001027 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001028
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001029 @Command('start-server', 'start overlord CLI client server')
1030 def StartServer(self):
1031 self._server = OverlordClientDaemon.GetRPCServer()
1032 if self._server is None:
1033 OverlordClientDaemon().Start()
1034 time.sleep(1)
1035 self._server = OverlordClientDaemon.GetRPCServer()
1036 if self._server is not None:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001037 print('* daemon started successfully *\n')
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001038
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001039 @Command('kill-server', 'kill overlord CLI client server')
1040 def KillServer(self):
1041 self._server = OverlordClientDaemon.GetRPCServer()
1042 if self._server is None:
1043 return
1044
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001045 self._state = self._server.State()
1046
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001047 # Kill SSH Tunnel
1048 self.KillSSHTunnel()
1049
1050 # Kill server daemon
1051 KillGraceful(self._server.GetPid())
1052
1053 def KillSSHTunnel(self):
1054 if self._state.ssh_pid is not None:
1055 KillGraceful(self._state.ssh_pid)
1056
Peter Shihcf0f3b22017-06-19 15:59:22 +08001057 def _FilterClients(self, clients, prop_filters, mid=None):
1058 def _ClientPropertiesMatch(client, key, regex):
1059 try:
1060 return bool(re.search(regex, client['properties'][key]))
1061 except KeyError:
1062 return False
1063
1064 for prop_filter in prop_filters:
1065 key, sep, regex = prop_filter.partition('=')
1066 if not sep:
1067 # The filter doesn't contains =.
1068 raise ValueError('Invalid filter condition %r' % filter)
1069 clients = [c for c in clients if _ClientPropertiesMatch(c, key, regex)]
1070
1071 if mid is not None:
1072 client = next((c for c in clients if c['mid'] == mid), None)
1073 if client:
1074 return [client]
1075 clients = [c for c in clients if c['mid'].startswith(mid)]
1076 return clients
1077
1078 @Command('ls', 'list clients', [
1079 Arg('-f', '--filter', default=[], dest='filters', action='append',
1080 help=('Conditions to filter clients by properties. '
1081 'Should be in form "key=regex", where regex is the regular '
1082 'expression that should be found in the value. '
1083 'Multiple --filter arguments would be ANDed.')),
Peter Shih99b73ec2017-06-16 17:54:15 +08001084 Arg('-v', '--verbose', default=False, action='store_true',
1085 help='Print properties of each client.')
1086 ])
1087 def ListClients(self, args):
Peter Shihcf0f3b22017-06-19 15:59:22 +08001088 clients = self._FilterClients(self._server.Clients(), args.filters)
1089 for client in clients:
1090 if args.verbose:
Peter Shih99b73ec2017-06-16 17:54:15 +08001091 print(yaml.safe_dump(client, default_flow_style=False))
Peter Shihcf0f3b22017-06-19 15:59:22 +08001092 else:
1093 print(client['mid'])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001094
1095 @Command('select', 'select default client', [
Peter Shihcf0f3b22017-06-19 15:59:22 +08001096 Arg('-f', '--filter', default=[], dest='filters', action='append',
1097 help=('Conditions to filter clients by properties. '
1098 'Should be in form "key=regex", where regex is the regular '
1099 'expression that should be found in the value. '
1100 'Multiple --filter arguments would be ANDed.')),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001101 Arg('mid', metavar='mid', nargs='?', default=None)])
1102 def SelectClient(self, args=None, store=True):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001103 mid = args.mid if args is not None else None
Peter Shihcf0f3b22017-06-19 15:59:22 +08001104 clients = self._FilterClients(self._server.Clients(), args.filters, mid=mid)
1105
1106 if not clients:
1107 raise RuntimeError('select: client not found')
1108 elif len(clients) == 1:
1109 mid = clients[0]['mid']
1110 else:
1111 # This case would not happen when args.mid is specified.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001112 print('Select from the following clients:')
1113 for i, client in enumerate(clients):
Peter Shihcf0f3b22017-06-19 15:59:22 +08001114 print(' %d. %s' % (i + 1, client['mid']))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001115
1116 print('\nSelection: ', end='')
1117 try:
1118 choice = int(raw_input()) - 1
Peter Shihcf0f3b22017-06-19 15:59:22 +08001119 mid = clients[choice]['mid']
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001120 except ValueError:
1121 raise RuntimeError('select: invalid selection')
1122 except IndexError:
1123 raise RuntimeError('select: selection out of range')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001124
1125 self._selected_mid = mid
1126 if store:
1127 self._server.SelectClient(mid)
1128 print('Client %s selected' % mid)
1129
1130 @Command('shell', 'open a shell or execute a shell command', [
1131 Arg('command', metavar='CMD', nargs='?', help='command to execute')])
1132 def Shell(self, command=None):
1133 if command is None:
1134 command = []
1135 self.CheckClient()
1136
1137 headers = []
1138 if self._state.username is not None and self._state.password is not None:
1139 headers.append(BasicAuthHeader(self._state.username,
1140 self._state.password))
1141
1142 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
Peter Shihe03450b2017-06-14 14:02:08 +08001143 if command:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001144 cmd = ' '.join(command)
Peter Shihe03450b2017-06-14 14:02:08 +08001145 ws = ShellWebSocketClient(
1146 self._state, sys.stdout,
1147 scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
1148 self._state.host, self._state.port,
1149 urllib2.quote(self._selected_mid), urllib2.quote(cmd)),
1150 headers=headers)
1151 else:
1152 ws = TerminalWebSocketClient(
1153 self._state, self._selected_mid, self._escape,
1154 scheme + '%s:%d/api/agent/tty/%s' % (
1155 self._state.host, self._state.port,
1156 urllib2.quote(self._selected_mid)),
1157 headers=headers)
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001158 try:
1159 ws.connect()
1160 ws.run()
1161 except socket.error as e:
1162 if e.errno == 32: # Broken pipe
1163 pass
1164 else:
1165 raise
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001166
1167 @Command('push', 'push a file or directory to remote', [
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001168 Arg('srcs', nargs='+', metavar='SOURCE'),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001169 Arg('dst', metavar='DESTINATION')])
1170 def Push(self, args):
1171 self.CheckClient()
1172
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001173 @AutoRetry('push', _RETRY_TIMES)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001174 def _push(src, dst):
1175 src_base = os.path.basename(src)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001176
1177 # Local file is a link
1178 if os.path.islink(src):
1179 pbar = ProgressBar(src_base)
1180 link_path = os.readlink(src)
1181 self.CheckOutput('mkdir -p %(dirname)s; '
1182 'if [ -d "%(dst)s" ]; then '
1183 'ln -sf "%(link_path)s" "%(dst)s/%(link_name)s"; '
1184 'else ln -sf "%(link_path)s" "%(dst)s"; fi' %
1185 dict(dirname=os.path.dirname(dst),
1186 link_path=link_path, dst=dst,
1187 link_name=src_base))
1188 pbar.End()
1189 return
1190
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001191 mode = '0%o' % (0x1FF & os.stat(src).st_mode)
1192 url = ('%s:%d/api/agent/upload/%s?dest=%s&perm=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001193 (self._state.host, self._state.port,
1194 urllib2.quote(self._selected_mid), dst, mode))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001195 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001196 UrlOpen(self._state, url + '&filename=%s' % src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001197 except urllib2.HTTPError as e:
1198 msg = json.loads(e.read()).get('error', None)
1199 raise RuntimeError('push: %s' % msg)
1200
1201 pbar = ProgressBar(src_base)
1202 self._HTTPPostFile(url, src, pbar.SetProgress,
1203 self._state.username, self._state.password)
1204 pbar.End()
1205
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001206 def _push_single_target(src, dst):
1207 if os.path.isdir(src):
1208 dst_exists = ast.literal_eval(self.CheckOutput(
1209 'stat %s >/dev/null 2>&1 && echo True || echo False' % dst))
1210 for root, unused_x, files in os.walk(src):
1211 # If destination directory does not exist, we should strip the first
1212 # layer of directory. For example: src_dir contains a single file 'A'
1213 #
1214 # push src_dir dest_dir
1215 #
1216 # If dest_dir exists, the resulting directory structure should be:
1217 # dest_dir/src_dir/A
1218 # If dest_dir does not exist, the resulting directory structure should
1219 # be:
1220 # dest_dir/A
1221 dst_root = root if dst_exists else root[len(src):].lstrip('/')
1222 for name in files:
1223 _push(os.path.join(root, name),
1224 os.path.join(dst, dst_root, name))
1225 else:
1226 _push(src, dst)
1227
1228 if len(args.srcs) > 1:
1229 dst_type = self.CheckOutput('stat \'%s\' --printf \'%%F\' '
1230 '2>/dev/null' % args.dst).strip()
1231 if not dst_type:
1232 raise RuntimeError('push: %s: No such file or directory' % args.dst)
1233 if dst_type != 'directory':
1234 raise RuntimeError('push: %s: Not a directory' % args.dst)
1235
1236 for src in args.srcs:
1237 if not os.path.exists(src):
1238 raise RuntimeError('push: can not stat "%s": no such file or directory'
1239 % src)
1240 if not os.access(src, os.R_OK):
1241 raise RuntimeError('push: can not open "%s" for reading' % src)
1242
1243 _push_single_target(src, args.dst)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001244
1245 @Command('pull', 'pull a file or directory from remote', [
1246 Arg('src', metavar='SOURCE'),
1247 Arg('dst', metavar='DESTINATION', default='.', nargs='?')])
1248 def Pull(self, args):
1249 self.CheckClient()
1250
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001251 @AutoRetry('pull', _RETRY_TIMES)
1252 def _pull(src, dst, ftype, perm=0644, link=None):
1253 try:
1254 os.makedirs(os.path.dirname(dst))
1255 except Exception:
1256 pass
1257
1258 src_base = os.path.basename(src)
1259
1260 # Remote file is a link
1261 if ftype == 'l':
1262 pbar = ProgressBar(src_base)
1263 if os.path.exists(dst):
1264 os.remove(dst)
1265 os.symlink(link, dst)
1266 pbar.End()
1267 return
1268
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001269 url = ('%s:%d/api/agent/download/%s?filename=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001270 (self._state.host, self._state.port,
1271 urllib2.quote(self._selected_mid), urllib2.quote(src)))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001272 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001273 h = UrlOpen(self._state, url)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001274 except urllib2.HTTPError as e:
1275 msg = json.loads(e.read()).get('error', 'unkown error')
1276 raise RuntimeError('pull: %s' % msg)
1277 except KeyboardInterrupt:
1278 return
1279
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001280 pbar = ProgressBar(src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001281 with open(dst, 'w') as f:
1282 os.fchmod(f.fileno(), perm)
1283 total_size = int(h.headers.get('Content-Length'))
1284 downloaded_size = 0
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001285
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001286 while True:
1287 data = h.read(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +08001288 if not data:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001289 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001290 downloaded_size += len(data)
1291 pbar.SetProgress(float(downloaded_size) * 100 / total_size,
1292 downloaded_size)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001293 f.write(data)
1294 pbar.End()
1295
1296 # Use find to get a listing of all files under a root directory. The 'stat'
1297 # command is used to retrieve the filename and it's filemode.
1298 output = self.CheckOutput(
1299 'cd $HOME; '
1300 'stat "%(src)s" >/dev/null && '
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001301 'find "%(src)s" \'(\' -type f -o -type l \')\' '
1302 '-printf \'%%m\t%%p\t%%y\t%%l\n\''
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001303 % {'src': args.src})
1304
1305 # We got error from the stat command
1306 if output.startswith('stat: '):
1307 sys.stderr.write(output)
1308 return
1309
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001310 entries = output.strip('\n').split('\n')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001311 common_prefix = os.path.dirname(args.src)
1312
1313 if len(entries) == 1:
1314 entry = entries[0]
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001315 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001316 if os.path.isdir(args.dst):
1317 dst = os.path.join(args.dst, os.path.basename(src_path))
1318 else:
1319 dst = args.dst
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001320 _pull(src_path, dst, ftype, int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001321 else:
1322 if not os.path.exists(args.dst):
1323 common_prefix = args.src
1324
1325 for entry in entries:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001326 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001327 rel_dst = src_path[len(common_prefix):].lstrip('/')
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001328 _pull(src_path, os.path.join(args.dst, rel_dst), ftype,
1329 int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001330
1331 @Command('forward', 'forward remote port to local port', [
1332 Arg('--list', dest='list_all', action='store_true', default=False,
1333 help='list all port forwarding sessions'),
1334 Arg('--remove', metavar='LOCAL_PORT', dest='remove', type=int,
1335 default=None,
1336 help='remove port forwarding for local port LOCAL_PORT'),
1337 Arg('--remove-all', dest='remove_all', action='store_true',
1338 default=False, help='remove all port forwarding'),
1339 Arg('remote', metavar='REMOTE_PORT', type=int, nargs='?'),
1340 Arg('local', metavar='LOCAL_PORT', type=int, nargs='?')])
1341 def Forward(self, args):
1342 if args.list_all:
1343 max_len = 10
Peter Shihf84a8972017-06-19 15:18:24 +08001344 if self._state.forwards:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001345 max_len = max([len(v[0]) for v in self._state.forwards.values()])
1346
1347 print('%-*s %-8s %-8s' % (max_len, 'Client', 'Remote', 'Local'))
1348 for local in sorted(self._state.forwards.keys()):
1349 value = self._state.forwards[local]
1350 print('%-*s %-8s %-8s' % (max_len, value[0], value[1], local))
1351 return
1352
1353 if args.remove_all:
1354 self._server.RemoveAllForward()
1355 return
1356
1357 if args.remove:
1358 self._server.RemoveForward(args.remove)
1359 return
1360
1361 self.CheckClient()
1362
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001363 if args.remote is None:
1364 raise RuntimeError('remote port not specified')
1365
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001366 if args.local is None:
1367 args.local = args.remote
1368 remote = int(args.remote)
1369 local = int(args.local)
1370
1371 def HandleConnection(conn):
1372 headers = []
1373 if self._state.username is not None and self._state.password is not None:
1374 headers.append(BasicAuthHeader(self._state.username,
1375 self._state.password))
1376
1377 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1378 ws = ForwarderWebSocketClient(
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001379 self._state, conn,
Peter Shihe03450b2017-06-14 14:02:08 +08001380 scheme + '%s:%d/api/agent/forward/%s?port=%d' % (
1381 self._state.host, self._state.port,
1382 urllib2.quote(self._selected_mid), remote),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001383 headers=headers)
1384 try:
1385 ws.connect()
1386 ws.run()
1387 except Exception as e:
1388 print('error: %s' % e)
1389 finally:
1390 ws.close()
1391
1392 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1393 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1394 server.bind(('0.0.0.0', local))
1395 server.listen(5)
1396
1397 pid = os.fork()
1398 if pid == 0:
1399 while True:
1400 conn, unused_addr = server.accept()
1401 t = threading.Thread(target=HandleConnection, args=(conn,))
1402 t.daemon = True
1403 t.start()
1404 else:
1405 self._server.AddForward(self._selected_mid, remote, local, pid)
1406
1407
1408def main():
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001409 # Setup logging format
1410 logger = logging.getLogger()
1411 logger.setLevel(logging.INFO)
1412 handler = logging.StreamHandler()
1413 formatter = logging.Formatter('%(asctime)s %(message)s', '%Y/%m/%d %H:%M:%S')
1414 handler.setFormatter(formatter)
1415 logger.addHandler(handler)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001416
1417 # Add DaemonState to JSONRPC lib classes
1418 Config.instance().classes.add(DaemonState)
1419
1420 ovl = OverlordCLIClient()
1421 try:
1422 ovl.Main()
1423 except KeyboardInterrupt:
1424 print('Ctrl-C received, abort')
1425 except Exception as e:
1426 print('error: %s' % e)
1427
1428
1429if __name__ == '__main__':
1430 main()