blob: df8242e28f9cfdf53cbc0167abada5ba9f101a84 [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 Shih99b73ec2017-06-16 17:54:15 +0800445 client_list = self._GetJSON('/api/agents/list')
446 self._state.listing = {client['mid']: client for client in client_list}
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800447 self._state.last_list = time.time()
448 return self._state.listing
449
450 def SelectClient(self, mid):
451 self._state.selected_mid = mid
452
453 def AddForward(self, mid, remote, local, pid):
454 self._state.forwards[local] = (mid, remote, pid)
455
456 def RemoveForward(self, local_port):
457 try:
458 unused_mid, unused_remote, pid = self._state.forwards[local_port]
459 KillGraceful(pid)
460 del self._state.forwards[local_port]
461 except (KeyError, OSError):
462 pass
463
464 def RemoveAllForward(self):
465 for unused_mid, unused_remote, pid in self._state.forwards.values():
466 try:
467 KillGraceful(pid)
468 except OSError:
469 pass
470 self._state.forwards = {}
471
472
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800473class SSLEnabledWebSocketBaseClient(WebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800474 def __init__(self, state, *args, **kwargs):
475 cafile = ssl.get_default_verify_paths().openssl_cafile
476 # For some system / distribution, python can not detect system cafile path.
477 # In such case we fallback to the default path.
478 if not os.path.exists(cafile):
479 cafile = '/etc/ssl/certs/ca-certificates.crt'
480
481 if state.ssl_self_signed:
482 cafile = GetTLSCertPath(state.host)
483
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800484 ssl_options = {
485 'cert_reqs': ssl.CERT_REQUIRED,
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800486 'ca_certs': cafile
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800487 }
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800488 # ws4py does not allow you to specify SSLContext, but rather passing in the
489 # argument of ssl.wrap_socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800490 super(SSLEnabledWebSocketBaseClient, self).__init__(
491 ssl_options=ssl_options, *args, **kwargs)
492
493
494class TerminalWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800495 def __init__(self, state, mid, escape, *args, **kwargs):
496 super(TerminalWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800497 self._mid = mid
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800498 self._escape = escape
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800499 self._stdin_fd = sys.stdin.fileno()
500 self._old_termios = None
501
502 def handshake_ok(self):
503 pass
504
505 def opened(self):
506 nonlocals = {'size': (80, 40)}
507
508 def _ResizeWindow():
509 size = GetTerminalSize()
510 if size != nonlocals['size']: # Size not changed, ignore
511 control = {'command': 'resize', 'params': list(size)}
512 payload = chr(_CONTROL_START) + json.dumps(control) + chr(_CONTROL_END)
513 nonlocals['size'] = size
514 try:
515 self.send(payload, binary=True)
516 except Exception:
517 pass
518
519 def _FeedInput():
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800520 self._old_termios = termios.tcgetattr(self._stdin_fd)
521 tty.setraw(self._stdin_fd)
522
523 READY, ENTER_PRESSED, ESCAPE_PRESSED = range(3)
524
525 try:
526 state = READY
527 while True:
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800528 # Check if terminal is resized
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800529 _ResizeWindow()
530
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800531 ch = sys.stdin.read(1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800532
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800533 # Scan for escape sequence
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800534 if self._escape:
535 if state == READY:
536 state = ENTER_PRESSED if ch == chr(0x0d) else READY
537 elif state == ENTER_PRESSED:
538 state = ESCAPE_PRESSED if ch == self._escape else READY
539 elif state == ESCAPE_PRESSED:
540 if ch == '.':
541 self.close()
542 break
543 else:
544 state = READY
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800545
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800546 self.send(ch)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800547 except (KeyboardInterrupt, RuntimeError):
548 pass
549
550 t = threading.Thread(target=_FeedInput)
551 t.daemon = True
552 t.start()
553
554 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800555 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800556 termios.tcsetattr(self._stdin_fd, termios.TCSANOW, self._old_termios)
557 print('Connection to %s closed.' % self._mid)
558
559 def received_message(self, msg):
560 if msg.is_binary:
561 sys.stdout.write(msg.data)
562 sys.stdout.flush()
563
564
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800565class ShellWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800566 def __init__(self, state, output, *args, **kwargs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800567 """Constructor.
568
569 Args:
570 output: output file object.
571 """
572 self.output = output
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800573 super(ShellWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800574
575 def handshake_ok(self):
576 pass
577
578 def opened(self):
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800579 def _FeedInput():
580 try:
581 while True:
582 data = sys.stdin.read(1)
583
Peter Shihf84a8972017-06-19 15:18:24 +0800584 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800585 self.send(_STDIN_CLOSED * 2)
586 break
587 self.send(data, binary=True)
588 except (KeyboardInterrupt, RuntimeError):
589 pass
590
591 t = threading.Thread(target=_FeedInput)
592 t.daemon = True
593 t.start()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800594
595 def closed(self, code, reason=None):
596 pass
597
598 def received_message(self, msg):
599 if msg.is_binary:
600 self.output.write(msg.data)
601 self.output.flush()
602
603
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800604class ForwarderWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800605 def __init__(self, state, sock, *args, **kwargs):
606 super(ForwarderWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800607 self._sock = sock
608 self._stop = threading.Event()
609
610 def handshake_ok(self):
611 pass
612
613 def opened(self):
614 def _FeedInput():
615 try:
616 self._sock.setblocking(False)
617 while True:
618 rd, unused_w, unused_x = select.select([self._sock], [], [], 0.5)
619 if self._stop.is_set():
620 break
621 if self._sock in rd:
622 data = self._sock.recv(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +0800623 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800624 self.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800625 break
626 self.send(data, binary=True)
627 except Exception:
628 pass
629 finally:
630 self._sock.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800631
632 t = threading.Thread(target=_FeedInput)
633 t.daemon = True
634 t.start()
635
636 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800637 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800638 self._stop.set()
639 sys.exit(0)
640
641 def received_message(self, msg):
642 if msg.is_binary:
643 self._sock.send(msg.data)
644
645
646def Arg(*args, **kwargs):
647 return (args, kwargs)
648
649
650def Command(command, help_msg=None, args=None):
651 """Decorator for adding argparse parameter for a method."""
652 if args is None:
653 args = []
654 def WrapFunc(func):
655 def Wrapped(*args, **kwargs):
656 return func(*args, **kwargs)
Peter Shih99b73ec2017-06-16 17:54:15 +0800657 # pylint: disable=protected-access
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800658 Wrapped.__arg_attr = {'command': command, 'help': help_msg, 'args': args}
659 return Wrapped
660 return WrapFunc
661
662
663def ParseMethodSubCommands(cls):
664 """Decorator for a class using the @Command decorator.
665
666 This decorator retrieve command info from each method and append it in to the
667 SUBCOMMANDS class variable, which is later used to construct parser.
668 """
669 for unused_key, method in cls.__dict__.iteritems():
670 if hasattr(method, '__arg_attr'):
Peter Shih99b73ec2017-06-16 17:54:15 +0800671 # pylint: disable=protected-access
672 cls.SUBCOMMANDS.append(method.__arg_attr)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800673 return cls
674
675
676@ParseMethodSubCommands
677class OverlordCLIClient(object):
678 """Overlord command line interface client."""
679
680 SUBCOMMANDS = []
681
682 def __init__(self):
683 self._parser = self._BuildParser()
684 self._selected_mid = None
685 self._server = None
686 self._state = None
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800687 self._escape = None
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800688
689 def _BuildParser(self):
690 root_parser = argparse.ArgumentParser(prog='ovl')
691 subparsers = root_parser.add_subparsers(help='sub-command')
692
693 root_parser.add_argument('-s', dest='selected_mid', action='store',
694 default=None,
695 help='select target to execute command on')
696 root_parser.add_argument('-S', dest='select_mid_before_action',
697 action='store_true', default=False,
698 help='select target before executing command')
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800699 root_parser.add_argument('-e', dest='escape', metavar='ESCAPE_CHAR',
700 action='store', default=_ESCAPE, type=str,
701 help='set shell escape character, \'none\' to '
702 'disable escape completely')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800703
704 for attr in self.SUBCOMMANDS:
705 parser = subparsers.add_parser(attr['command'], help=attr['help'])
706 parser.set_defaults(which=attr['command'])
707 for arg in attr['args']:
708 parser.add_argument(*arg[0], **arg[1])
709
710 return root_parser
711
712 def Main(self):
713 # We want to pass the rest of arguments after shell command directly to the
714 # function without parsing it.
715 try:
716 index = sys.argv.index('shell')
717 except ValueError:
718 args = self._parser.parse_args()
719 else:
720 args = self._parser.parse_args(sys.argv[1:index + 1])
721
722 command = args.which
723 self._selected_mid = args.selected_mid
724
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800725 if args.escape and args.escape != 'none':
726 self._escape = args.escape[0]
727
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800728 if command == 'start-server':
729 self.StartServer()
730 return
731 elif command == 'kill-server':
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800732 self.KillServer()
733 return
734
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800735 self.CheckDaemon()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800736 if command == 'status':
737 self.Status()
738 return
739 elif command == 'connect':
740 self.Connect(args)
741 return
742
743 # The following command requires connection to the server
744 self.CheckConnection()
745
746 if args.select_mid_before_action:
747 self.SelectClient(store=False)
748
749 if command == 'select':
750 self.SelectClient(args)
751 elif command == 'ls':
Peter Shih99b73ec2017-06-16 17:54:15 +0800752 self.ListClients(args)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800753 elif command == 'shell':
754 command = sys.argv[sys.argv.index('shell') + 1:]
755 self.Shell(command)
756 elif command == 'push':
757 self.Push(args)
758 elif command == 'pull':
759 self.Pull(args)
760 elif command == 'forward':
761 self.Forward(args)
762
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800763 def _SaveTLSCertificate(self, host, cert_pem):
764 try:
765 os.makedirs(_CERT_DIR)
766 except Exception:
767 pass
768 with open(GetTLSCertPath(host), 'w') as f:
769 f.write(cert_pem)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800770
771 def _HTTPPostFile(self, url, filename, progress=None, user=None, passwd=None):
772 """Perform HTTP POST and upload file to Overlord.
773
774 To minimize the external dependencies, we construct the HTTP post request
775 by ourselves.
776 """
777 url = MakeRequestUrl(self._state, url)
778 size = os.stat(filename).st_size
779 boundary = '-----------%s' % _HTTP_BOUNDARY_MAGIC
780 CRLF = '\r\n'
781 parse = urlparse.urlparse(url)
782
783 part_headers = [
784 '--' + boundary,
785 'Content-Disposition: form-data; name="file"; '
786 'filename="%s"' % os.path.basename(filename),
787 'Content-Type: application/octet-stream',
788 '', ''
789 ]
790 part_header = CRLF.join(part_headers)
791 end_part = CRLF + '--' + boundary + '--' + CRLF
792
793 content_length = len(part_header) + size + len(end_part)
794 if parse.scheme == 'http':
795 h = httplib.HTTP(parse.netloc)
796 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800797 h = httplib.HTTPS(parse.netloc, context=self._state.ssl_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800798
799 post_path = url[url.index(parse.netloc) + len(parse.netloc):]
800 h.putrequest('POST', post_path)
801 h.putheader('Content-Length', content_length)
802 h.putheader('Content-Type', 'multipart/form-data; boundary=%s' % boundary)
803
804 if user and passwd:
805 h.putheader(*BasicAuthHeader(user, passwd))
806 h.endheaders()
807 h.send(part_header)
808
809 count = 0
810 with open(filename, 'r') as f:
811 while True:
812 data = f.read(_BUFSIZ)
813 if not data:
814 break
815 count += len(data)
816 if progress:
817 progress(int(count * 100.0 / size), count)
818 h.send(data)
819
820 h.send(end_part)
821 progress(100)
822
823 if count != size:
824 logging.warning('file changed during upload, upload may be truncated.')
825
826 errcode, unused_x, unused_y = h.getreply()
827 return errcode == 200
828
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800829 def CheckDaemon(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800830 self._server = OverlordClientDaemon.GetRPCServer()
831 if self._server is None:
832 print('* daemon not running, starting it now on port %d ... *' %
833 _OVERLORD_CLIENT_DAEMON_PORT)
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800834 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800835
836 self._state = self._server.State()
837 sha1sum = GetVersionDigest()
838
839 if sha1sum != self._state.version_sha1sum:
840 print('ovl server is out of date. killing...')
841 KillGraceful(self._server.GetPid())
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800842 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800843
844 def GetSSHControlFile(self, host):
845 return _SSH_CONTROL_SOCKET_PREFIX + host
846
847 def SSHTunnel(self, user, host, port):
848 """SSH forward the remote overlord server.
849
850 Overlord server may not have port 9000 open to the public network, in such
851 case we can SSH forward the port to localhost.
852 """
853
854 control_file = self.GetSSHControlFile(host)
855 try:
856 os.unlink(control_file)
857 except Exception:
858 pass
859
860 subprocess.Popen([
861 'ssh', '-Nf',
862 '-M', # Enable master mode
863 '-S', control_file,
864 '-L', '9000:localhost:9000',
865 '-p', str(port),
866 '%s%s' % (user + '@' if user else '', host)
867 ]).wait()
868
869 p = subprocess.Popen([
870 'ssh',
871 '-S', control_file,
872 '-O', 'check', host,
873 ], stderr=subprocess.PIPE)
874 unused_stdout, stderr = p.communicate()
875
876 s = re.search(r'pid=(\d+)', stderr)
877 if s:
878 return int(s.group(1))
879
880 raise RuntimeError('can not establish ssh connection')
881
882 def CheckConnection(self):
883 if self._state.host is None:
884 raise RuntimeError('not connected to any server, abort')
885
886 try:
887 self._server.Clients()
888 except Exception:
889 raise RuntimeError('remote server disconnected, abort')
890
891 if self._state.ssh_pid is not None:
892 ret = subprocess.Popen(['kill', '-0', str(self._state.ssh_pid)],
893 stdout=subprocess.PIPE,
894 stderr=subprocess.PIPE).wait()
895 if ret != 0:
896 raise RuntimeError('ssh tunnel disconnected, please re-connect')
897
898 def CheckClient(self):
899 if self._selected_mid is None:
900 if self._state.selected_mid is None:
901 raise RuntimeError('No client is selected')
902 self._selected_mid = self._state.selected_mid
903
904 if self._selected_mid not in self._server.Clients():
905 raise RuntimeError('client %s disappeared' % self._selected_mid)
906
907 def CheckOutput(self, command):
908 headers = []
909 if self._state.username is not None and self._state.password is not None:
910 headers.append(BasicAuthHeader(self._state.username,
911 self._state.password))
912
913 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
914 sio = StringIO.StringIO()
Peter Shihe03450b2017-06-14 14:02:08 +0800915 ws = ShellWebSocketClient(
916 self._state, sio, scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
917 self._state.host, self._state.port,
918 urllib2.quote(self._selected_mid), urllib2.quote(command)),
919 headers=headers)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800920 ws.connect()
921 ws.run()
922 return sio.getvalue()
923
924 @Command('status', 'show Overlord connection status')
925 def Status(self):
926 if self._state.host is None:
927 print('Not connected to any host.')
928 else:
929 if self._state.ssh_pid is not None:
930 print('Connected to %s with SSH tunneling.' % self._state.orig_host)
931 else:
932 print('Connected to %s:%d.' % (self._state.host, self._state.port))
933
934 if self._selected_mid is None:
935 self._selected_mid = self._state.selected_mid
936
937 if self._selected_mid is None:
938 print('No client is selected.')
939 else:
940 print('Client %s selected.' % self._selected_mid)
941
942 @Command('connect', 'connect to Overlord server', [
943 Arg('host', metavar='HOST', type=str, default='localhost',
944 help='Overlord hostname/IP'),
945 Arg('port', metavar='PORT', type=int,
946 default=_OVERLORD_HTTP_PORT, help='Overlord port'),
947 Arg('-f', '--forward', dest='ssh_forward', default=False,
948 action='store_true',
949 help='connect with SSH forwarding to the host'),
950 Arg('-p', '--ssh-port', dest='ssh_port', default=22,
951 type=int, help='SSH server port for SSH forwarding'),
952 Arg('-l', '--ssh-login', dest='ssh_login', default='',
953 type=str, help='SSH server login name for SSH forwarding'),
954 Arg('-u', '--user', dest='user', default=None,
955 type=str, help='Overlord HTTP auth username'),
956 Arg('-w', '--passwd', dest='passwd', default=None, type=str,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800957 help='Overlord HTTP auth password'),
958 Arg('-i', '--no-check-hostname', dest='check_hostname',
959 default=True, action='store_false',
960 help='Ignore SSL cert hostname check')])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800961 def Connect(self, args):
962 ssh_pid = None
963 host = args.host
964 orig_host = args.host
965
966 if args.ssh_forward:
967 # Kill previous SSH tunnel
968 self.KillSSHTunnel()
969
970 ssh_pid = self.SSHTunnel(args.ssh_login, args.host, args.ssh_port)
971 host = 'localhost'
972
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800973 username_provided = args.user is not None
974 password_provided = args.passwd is not None
975 prompt = False
976
977 for unused_i in range(3):
978 try:
979 if prompt:
980 if not username_provided:
981 args.user = raw_input('Username: ')
982 if not password_provided:
983 args.passwd = getpass.getpass('Password: ')
984
985 ret = self._server.Connect(host, args.port, ssh_pid, args.user,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800986 args.passwd, orig_host,
987 args.check_hostname)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800988 if isinstance(ret, list):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800989 if ret[0].startswith('SSL'):
990 cert_pem = ret[1]
991 fp = GetTLSCertificateSHA1Fingerprint(cert_pem)
992 fp_text = ':'.join([fp[i:i+2] for i in range(0, len(fp), 2)])
993
994 if ret[0] == 'SSLCertificateChanged':
995 print(_TLS_CERT_CHANGED_WARNING % (fp_text, GetTLSCertPath(host)))
996 return
997 elif ret[0] == 'SSLVerifyFailed':
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800998 print(_TLS_CERT_FAILED_WARNING % (fp_text), end='')
999 response = raw_input()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001000 if response.lower() in ['y', 'ye', 'yes']:
1001 self._SaveTLSCertificate(host, cert_pem)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001002 print('TLS host Certificate trusted, you will not be prompted '
1003 'next time.\n')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001004 continue
1005 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001006 print('connection aborted.')
1007 return
1008 elif ret[0] == 'HTTPError':
1009 code, except_str, body = ret[1:]
1010 if code == 401:
1011 print('connect: %s' % body)
1012 prompt = True
1013 if not username_provided or not password_provided:
1014 continue
1015 else:
1016 break
1017 else:
1018 logging.error('%s; %s', except_str, body)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001019
1020 if ret is not True:
1021 print('can not connect to %s: %s' % (host, ret))
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001022 else:
1023 print('connection to %s:%d established.' % (host, args.port))
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001024 except Exception as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001025 logging.error(e)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001026 else:
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001027 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001028
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001029 @Command('start-server', 'start overlord CLI client server')
1030 def StartServer(self):
1031 self._server = OverlordClientDaemon.GetRPCServer()
1032 if self._server is None:
1033 OverlordClientDaemon().Start()
1034 time.sleep(1)
1035 self._server = OverlordClientDaemon.GetRPCServer()
1036 if self._server is not None:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001037 print('* daemon started successfully *\n')
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001038
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001039 @Command('kill-server', 'kill overlord CLI client server')
1040 def KillServer(self):
1041 self._server = OverlordClientDaemon.GetRPCServer()
1042 if self._server is None:
1043 return
1044
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001045 self._state = self._server.State()
1046
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001047 # Kill SSH Tunnel
1048 self.KillSSHTunnel()
1049
1050 # Kill server daemon
1051 KillGraceful(self._server.GetPid())
1052
1053 def KillSSHTunnel(self):
1054 if self._state.ssh_pid is not None:
1055 KillGraceful(self._state.ssh_pid)
1056
Peter Shih99b73ec2017-06-16 17:54:15 +08001057 @Command('ls', 'list all clients', [
1058 Arg('-v', '--verbose', default=False, action='store_true',
1059 help='Print properties of each client.')
1060 ])
1061 def ListClients(self, args):
1062 if args.verbose:
1063 for client in self._server.Clients().itervalues():
1064 print(yaml.safe_dump(client, default_flow_style=False))
1065 else:
1066 for mid in self._server.Clients():
1067 print(mid)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001068
1069 @Command('select', 'select default client', [
1070 Arg('mid', metavar='mid', nargs='?', default=None)])
1071 def SelectClient(self, args=None, store=True):
1072 clients = self._server.Clients()
1073
1074 mid = args.mid if args is not None else None
1075 if mid is None:
1076 print('Select from the following clients:')
1077 for i, client in enumerate(clients):
1078 print(' %d. %s' % (i + 1, client))
1079
1080 print('\nSelection: ', end='')
1081 try:
1082 choice = int(raw_input()) - 1
1083 mid = clients[choice]
1084 except ValueError:
1085 raise RuntimeError('select: invalid selection')
1086 except IndexError:
1087 raise RuntimeError('select: selection out of range')
1088 else:
1089 if mid not in clients:
Peter Shih54fd5a12017-06-16 17:00:47 +08001090 # Find a prefix match if no exact match found.
1091 matched_mid = [x for x in clients if x.startswith(mid)]
1092 if not matched_mid:
1093 raise RuntimeError('select: client %s does not exist' % mid)
1094 if len(matched_mid) > 1:
1095 raise RuntimeError('select: multiple client matched %r' % mid)
1096 mid = matched_mid[0]
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001097
1098 self._selected_mid = mid
1099 if store:
1100 self._server.SelectClient(mid)
1101 print('Client %s selected' % mid)
1102
1103 @Command('shell', 'open a shell or execute a shell command', [
1104 Arg('command', metavar='CMD', nargs='?', help='command to execute')])
1105 def Shell(self, command=None):
1106 if command is None:
1107 command = []
1108 self.CheckClient()
1109
1110 headers = []
1111 if self._state.username is not None and self._state.password is not None:
1112 headers.append(BasicAuthHeader(self._state.username,
1113 self._state.password))
1114
1115 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
Peter Shihe03450b2017-06-14 14:02:08 +08001116 if command:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001117 cmd = ' '.join(command)
Peter Shihe03450b2017-06-14 14:02:08 +08001118 ws = ShellWebSocketClient(
1119 self._state, sys.stdout,
1120 scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
1121 self._state.host, self._state.port,
1122 urllib2.quote(self._selected_mid), urllib2.quote(cmd)),
1123 headers=headers)
1124 else:
1125 ws = TerminalWebSocketClient(
1126 self._state, self._selected_mid, self._escape,
1127 scheme + '%s:%d/api/agent/tty/%s' % (
1128 self._state.host, self._state.port,
1129 urllib2.quote(self._selected_mid)),
1130 headers=headers)
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001131 try:
1132 ws.connect()
1133 ws.run()
1134 except socket.error as e:
1135 if e.errno == 32: # Broken pipe
1136 pass
1137 else:
1138 raise
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001139
1140 @Command('push', 'push a file or directory to remote', [
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001141 Arg('srcs', nargs='+', metavar='SOURCE'),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001142 Arg('dst', metavar='DESTINATION')])
1143 def Push(self, args):
1144 self.CheckClient()
1145
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001146 @AutoRetry('push', _RETRY_TIMES)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001147 def _push(src, dst):
1148 src_base = os.path.basename(src)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001149
1150 # Local file is a link
1151 if os.path.islink(src):
1152 pbar = ProgressBar(src_base)
1153 link_path = os.readlink(src)
1154 self.CheckOutput('mkdir -p %(dirname)s; '
1155 'if [ -d "%(dst)s" ]; then '
1156 'ln -sf "%(link_path)s" "%(dst)s/%(link_name)s"; '
1157 'else ln -sf "%(link_path)s" "%(dst)s"; fi' %
1158 dict(dirname=os.path.dirname(dst),
1159 link_path=link_path, dst=dst,
1160 link_name=src_base))
1161 pbar.End()
1162 return
1163
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001164 mode = '0%o' % (0x1FF & os.stat(src).st_mode)
1165 url = ('%s:%d/api/agent/upload/%s?dest=%s&perm=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001166 (self._state.host, self._state.port,
1167 urllib2.quote(self._selected_mid), dst, mode))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001168 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001169 UrlOpen(self._state, url + '&filename=%s' % src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001170 except urllib2.HTTPError as e:
1171 msg = json.loads(e.read()).get('error', None)
1172 raise RuntimeError('push: %s' % msg)
1173
1174 pbar = ProgressBar(src_base)
1175 self._HTTPPostFile(url, src, pbar.SetProgress,
1176 self._state.username, self._state.password)
1177 pbar.End()
1178
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001179 def _push_single_target(src, dst):
1180 if os.path.isdir(src):
1181 dst_exists = ast.literal_eval(self.CheckOutput(
1182 'stat %s >/dev/null 2>&1 && echo True || echo False' % dst))
1183 for root, unused_x, files in os.walk(src):
1184 # If destination directory does not exist, we should strip the first
1185 # layer of directory. For example: src_dir contains a single file 'A'
1186 #
1187 # push src_dir dest_dir
1188 #
1189 # If dest_dir exists, the resulting directory structure should be:
1190 # dest_dir/src_dir/A
1191 # If dest_dir does not exist, the resulting directory structure should
1192 # be:
1193 # dest_dir/A
1194 dst_root = root if dst_exists else root[len(src):].lstrip('/')
1195 for name in files:
1196 _push(os.path.join(root, name),
1197 os.path.join(dst, dst_root, name))
1198 else:
1199 _push(src, dst)
1200
1201 if len(args.srcs) > 1:
1202 dst_type = self.CheckOutput('stat \'%s\' --printf \'%%F\' '
1203 '2>/dev/null' % args.dst).strip()
1204 if not dst_type:
1205 raise RuntimeError('push: %s: No such file or directory' % args.dst)
1206 if dst_type != 'directory':
1207 raise RuntimeError('push: %s: Not a directory' % args.dst)
1208
1209 for src in args.srcs:
1210 if not os.path.exists(src):
1211 raise RuntimeError('push: can not stat "%s": no such file or directory'
1212 % src)
1213 if not os.access(src, os.R_OK):
1214 raise RuntimeError('push: can not open "%s" for reading' % src)
1215
1216 _push_single_target(src, args.dst)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001217
1218 @Command('pull', 'pull a file or directory from remote', [
1219 Arg('src', metavar='SOURCE'),
1220 Arg('dst', metavar='DESTINATION', default='.', nargs='?')])
1221 def Pull(self, args):
1222 self.CheckClient()
1223
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001224 @AutoRetry('pull', _RETRY_TIMES)
1225 def _pull(src, dst, ftype, perm=0644, link=None):
1226 try:
1227 os.makedirs(os.path.dirname(dst))
1228 except Exception:
1229 pass
1230
1231 src_base = os.path.basename(src)
1232
1233 # Remote file is a link
1234 if ftype == 'l':
1235 pbar = ProgressBar(src_base)
1236 if os.path.exists(dst):
1237 os.remove(dst)
1238 os.symlink(link, dst)
1239 pbar.End()
1240 return
1241
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001242 url = ('%s:%d/api/agent/download/%s?filename=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001243 (self._state.host, self._state.port,
1244 urllib2.quote(self._selected_mid), urllib2.quote(src)))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001245 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001246 h = UrlOpen(self._state, url)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001247 except urllib2.HTTPError as e:
1248 msg = json.loads(e.read()).get('error', 'unkown error')
1249 raise RuntimeError('pull: %s' % msg)
1250 except KeyboardInterrupt:
1251 return
1252
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001253 pbar = ProgressBar(src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001254 with open(dst, 'w') as f:
1255 os.fchmod(f.fileno(), perm)
1256 total_size = int(h.headers.get('Content-Length'))
1257 downloaded_size = 0
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001258
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001259 while True:
1260 data = h.read(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +08001261 if not data:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001262 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001263 downloaded_size += len(data)
1264 pbar.SetProgress(float(downloaded_size) * 100 / total_size,
1265 downloaded_size)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001266 f.write(data)
1267 pbar.End()
1268
1269 # Use find to get a listing of all files under a root directory. The 'stat'
1270 # command is used to retrieve the filename and it's filemode.
1271 output = self.CheckOutput(
1272 'cd $HOME; '
1273 'stat "%(src)s" >/dev/null && '
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001274 'find "%(src)s" \'(\' -type f -o -type l \')\' '
1275 '-printf \'%%m\t%%p\t%%y\t%%l\n\''
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001276 % {'src': args.src})
1277
1278 # We got error from the stat command
1279 if output.startswith('stat: '):
1280 sys.stderr.write(output)
1281 return
1282
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001283 entries = output.strip('\n').split('\n')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001284 common_prefix = os.path.dirname(args.src)
1285
1286 if len(entries) == 1:
1287 entry = entries[0]
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001288 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001289 if os.path.isdir(args.dst):
1290 dst = os.path.join(args.dst, os.path.basename(src_path))
1291 else:
1292 dst = args.dst
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001293 _pull(src_path, dst, ftype, int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001294 else:
1295 if not os.path.exists(args.dst):
1296 common_prefix = args.src
1297
1298 for entry in entries:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001299 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001300 rel_dst = src_path[len(common_prefix):].lstrip('/')
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001301 _pull(src_path, os.path.join(args.dst, rel_dst), ftype,
1302 int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001303
1304 @Command('forward', 'forward remote port to local port', [
1305 Arg('--list', dest='list_all', action='store_true', default=False,
1306 help='list all port forwarding sessions'),
1307 Arg('--remove', metavar='LOCAL_PORT', dest='remove', type=int,
1308 default=None,
1309 help='remove port forwarding for local port LOCAL_PORT'),
1310 Arg('--remove-all', dest='remove_all', action='store_true',
1311 default=False, help='remove all port forwarding'),
1312 Arg('remote', metavar='REMOTE_PORT', type=int, nargs='?'),
1313 Arg('local', metavar='LOCAL_PORT', type=int, nargs='?')])
1314 def Forward(self, args):
1315 if args.list_all:
1316 max_len = 10
Peter Shihf84a8972017-06-19 15:18:24 +08001317 if self._state.forwards:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001318 max_len = max([len(v[0]) for v in self._state.forwards.values()])
1319
1320 print('%-*s %-8s %-8s' % (max_len, 'Client', 'Remote', 'Local'))
1321 for local in sorted(self._state.forwards.keys()):
1322 value = self._state.forwards[local]
1323 print('%-*s %-8s %-8s' % (max_len, value[0], value[1], local))
1324 return
1325
1326 if args.remove_all:
1327 self._server.RemoveAllForward()
1328 return
1329
1330 if args.remove:
1331 self._server.RemoveForward(args.remove)
1332 return
1333
1334 self.CheckClient()
1335
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001336 if args.remote is None:
1337 raise RuntimeError('remote port not specified')
1338
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001339 if args.local is None:
1340 args.local = args.remote
1341 remote = int(args.remote)
1342 local = int(args.local)
1343
1344 def HandleConnection(conn):
1345 headers = []
1346 if self._state.username is not None and self._state.password is not None:
1347 headers.append(BasicAuthHeader(self._state.username,
1348 self._state.password))
1349
1350 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1351 ws = ForwarderWebSocketClient(
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001352 self._state, conn,
Peter Shihe03450b2017-06-14 14:02:08 +08001353 scheme + '%s:%d/api/agent/forward/%s?port=%d' % (
1354 self._state.host, self._state.port,
1355 urllib2.quote(self._selected_mid), remote),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001356 headers=headers)
1357 try:
1358 ws.connect()
1359 ws.run()
1360 except Exception as e:
1361 print('error: %s' % e)
1362 finally:
1363 ws.close()
1364
1365 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1366 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1367 server.bind(('0.0.0.0', local))
1368 server.listen(5)
1369
1370 pid = os.fork()
1371 if pid == 0:
1372 while True:
1373 conn, unused_addr = server.accept()
1374 t = threading.Thread(target=HandleConnection, args=(conn,))
1375 t.daemon = True
1376 t.start()
1377 else:
1378 self._server.AddForward(self._selected_mid, remote, local, pid)
1379
1380
1381def main():
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001382 # Setup logging format
1383 logger = logging.getLogger()
1384 logger.setLevel(logging.INFO)
1385 handler = logging.StreamHandler()
1386 formatter = logging.Formatter('%(asctime)s %(message)s', '%Y/%m/%d %H:%M:%S')
1387 handler.setFormatter(formatter)
1388 logger.addHandler(handler)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001389
1390 # Add DaemonState to JSONRPC lib classes
1391 Config.instance().classes.add(DaemonState)
1392
1393 ovl = OverlordCLIClient()
1394 try:
1395 ovl.Main()
1396 except KeyboardInterrupt:
1397 print('Ctrl-C received, abort')
1398 except Exception as e:
1399 print('error: %s' % e)
1400
1401
1402if __name__ == '__main__':
1403 main()