blob: 98e1e988a05f6a5b2cbded0dc1d119d044a19a8c [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:
Peter Shih4d55ded2017-07-03 17:19:01 +0800324 for fd in range(3):
325 os.close(fd)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800326 self._server.serve_forever()
327
328 @staticmethod
329 def GetRPCServer():
330 """Returns the Overlord client daemon RPC server."""
331 server = jsonrpclib.Server('http://%s:%d' %
332 _OVERLORD_CLIENT_DAEMON_RPC_ADDR)
333 try:
334 server.Ping()
335 except Exception:
336 return None
337 return server
338
339 def State(self):
340 return self._state
341
342 def Ping(self):
343 return True
344
345 def GetPid(self):
346 return os.getpid()
347
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800348 def _GetJSON(self, path):
349 url = '%s:%d%s' % (self._state.host, self._state.port, path)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800350 return json.loads(UrlOpen(self._state, url).read())
351
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800352 def _TLSEnabled(self):
353 """Determine if TLS is enabled on given server address."""
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800354 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
355 try:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800356 # Allow any certificate since we only want to check if server talks TLS.
357 context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
358 context.verify_mode = ssl.CERT_NONE
359
360 sock = context.wrap_socket(sock, server_hostname=self._state.host)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800361 sock.settimeout(_CONNECT_TIMEOUT)
362 sock.connect((self._state.host, self._state.port))
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800363 return True
Wei-Ning Huangecc80b82016-07-01 16:55:10 +0800364 except ssl.SSLError:
365 return False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800366 except socket.error: # Connect refused or timeout
367 raise
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800368 except Exception:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800369 return False # For whatever reason above failed, assume False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800370
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800371 def _CheckTLSCertificate(self, check_hostname=True):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800372 """Check TLS certificate.
373
374 Returns:
375 A tupple (check_result, if_certificate_is_loaded)
376 """
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800377 def _DoConnect(context):
378 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
379 try:
380 sock.settimeout(_CONNECT_TIMEOUT)
381 sock = context.wrap_socket(sock, server_hostname=self._state.host)
382 sock.connect((self._state.host, self._state.port))
383 except ssl.SSLError:
384 return False
385 finally:
386 sock.close()
387
388 # Save SSLContext for future use.
389 self._state.ssl_context = context
390 return True
391
392 # First try connect with built-in certificates
393 tls_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
394 if _DoConnect(tls_context):
395 return True
396
397 # Try with already saved certificate, if any.
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800398 tls_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
399 tls_context.verify_mode = ssl.CERT_REQUIRED
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800400 tls_context.check_hostname = check_hostname
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800401
402 tls_cert_path = GetTLSCertPath(self._state.host)
403 if os.path.exists(tls_cert_path):
404 tls_context.load_verify_locations(tls_cert_path)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800405 self._state.ssl_self_signed = True
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800406
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800407 return _DoConnect(tls_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800408
409 def Connect(self, host, port=_OVERLORD_HTTP_PORT, ssh_pid=None,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800410 username=None, password=None, orig_host=None,
411 check_hostname=True):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800412 self._state.username = username
413 self._state.password = password
414 self._state.host = host
415 self._state.port = port
416 self._state.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800417 self._state.ssl_self_signed = False
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800418 self._state.orig_host = orig_host
419 self._state.ssh_pid = ssh_pid
420 self._state.selected_mid = None
421
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800422 tls_enabled = self._TLSEnabled()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800423 if tls_enabled:
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800424 result = self._CheckTLSCertificate(check_hostname)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800425 if not result:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800426 if self._state.ssl_self_signed:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800427 return ('SSLCertificateChanged', ssl.get_server_certificate(
428 (self._state.host, self._state.port)))
429 else:
430 return ('SSLVerifyFailed', ssl.get_server_certificate(
431 (self._state.host, self._state.port)))
432
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800433 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800434 self._state.ssl = tls_enabled
435 UrlOpen(self._state, '%s:%d' % (host, port))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800436 except urllib2.HTTPError as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800437 return ('HTTPError', e.getcode(), str(e), e.read().strip())
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800438 except Exception as e:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800439 return str(e)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800440 else:
441 return True
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800442
443 def Clients(self):
444 if time.time() - self._state.last_list <= _LIST_CACHE_TIMEOUT:
445 return self._state.listing
446
Peter Shihcf0f3b22017-06-19 15:59:22 +0800447 self._state.listing = self._GetJSON('/api/agents/list')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800448 self._state.last_list = time.time()
449 return self._state.listing
450
451 def SelectClient(self, mid):
452 self._state.selected_mid = mid
453
454 def AddForward(self, mid, remote, local, pid):
455 self._state.forwards[local] = (mid, remote, pid)
456
457 def RemoveForward(self, local_port):
458 try:
459 unused_mid, unused_remote, pid = self._state.forwards[local_port]
460 KillGraceful(pid)
461 del self._state.forwards[local_port]
462 except (KeyError, OSError):
463 pass
464
465 def RemoveAllForward(self):
466 for unused_mid, unused_remote, pid in self._state.forwards.values():
467 try:
468 KillGraceful(pid)
469 except OSError:
470 pass
471 self._state.forwards = {}
472
473
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800474class SSLEnabledWebSocketBaseClient(WebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800475 def __init__(self, state, *args, **kwargs):
476 cafile = ssl.get_default_verify_paths().openssl_cafile
477 # For some system / distribution, python can not detect system cafile path.
478 # In such case we fallback to the default path.
479 if not os.path.exists(cafile):
480 cafile = '/etc/ssl/certs/ca-certificates.crt'
481
482 if state.ssl_self_signed:
483 cafile = GetTLSCertPath(state.host)
484
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800485 ssl_options = {
486 'cert_reqs': ssl.CERT_REQUIRED,
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800487 'ca_certs': cafile
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800488 }
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800489 # ws4py does not allow you to specify SSLContext, but rather passing in the
490 # argument of ssl.wrap_socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800491 super(SSLEnabledWebSocketBaseClient, self).__init__(
492 ssl_options=ssl_options, *args, **kwargs)
493
494
495class TerminalWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800496 def __init__(self, state, mid, escape, *args, **kwargs):
497 super(TerminalWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800498 self._mid = mid
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800499 self._escape = escape
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800500 self._stdin_fd = sys.stdin.fileno()
501 self._old_termios = None
502
503 def handshake_ok(self):
504 pass
505
506 def opened(self):
507 nonlocals = {'size': (80, 40)}
508
509 def _ResizeWindow():
510 size = GetTerminalSize()
511 if size != nonlocals['size']: # Size not changed, ignore
512 control = {'command': 'resize', 'params': list(size)}
513 payload = chr(_CONTROL_START) + json.dumps(control) + chr(_CONTROL_END)
514 nonlocals['size'] = size
515 try:
516 self.send(payload, binary=True)
517 except Exception:
518 pass
519
520 def _FeedInput():
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800521 self._old_termios = termios.tcgetattr(self._stdin_fd)
522 tty.setraw(self._stdin_fd)
523
524 READY, ENTER_PRESSED, ESCAPE_PRESSED = range(3)
525
526 try:
527 state = READY
528 while True:
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800529 # Check if terminal is resized
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800530 _ResizeWindow()
531
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800532 ch = sys.stdin.read(1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800533
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800534 # Scan for escape sequence
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800535 if self._escape:
536 if state == READY:
537 state = ENTER_PRESSED if ch == chr(0x0d) else READY
538 elif state == ENTER_PRESSED:
539 state = ESCAPE_PRESSED if ch == self._escape else READY
540 elif state == ESCAPE_PRESSED:
541 if ch == '.':
542 self.close()
543 break
544 else:
545 state = READY
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800546
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800547 self.send(ch)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800548 except (KeyboardInterrupt, RuntimeError):
549 pass
550
551 t = threading.Thread(target=_FeedInput)
552 t.daemon = True
553 t.start()
554
555 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800556 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800557 termios.tcsetattr(self._stdin_fd, termios.TCSANOW, self._old_termios)
558 print('Connection to %s closed.' % self._mid)
559
560 def received_message(self, msg):
561 if msg.is_binary:
562 sys.stdout.write(msg.data)
563 sys.stdout.flush()
564
565
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800566class ShellWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800567 def __init__(self, state, output, *args, **kwargs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800568 """Constructor.
569
570 Args:
571 output: output file object.
572 """
573 self.output = output
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800574 super(ShellWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800575
576 def handshake_ok(self):
577 pass
578
579 def opened(self):
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800580 def _FeedInput():
581 try:
582 while True:
583 data = sys.stdin.read(1)
584
Peter Shihf84a8972017-06-19 15:18:24 +0800585 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800586 self.send(_STDIN_CLOSED * 2)
587 break
588 self.send(data, binary=True)
589 except (KeyboardInterrupt, RuntimeError):
590 pass
591
592 t = threading.Thread(target=_FeedInput)
593 t.daemon = True
594 t.start()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800595
596 def closed(self, code, reason=None):
597 pass
598
599 def received_message(self, msg):
600 if msg.is_binary:
601 self.output.write(msg.data)
602 self.output.flush()
603
604
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800605class ForwarderWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800606 def __init__(self, state, sock, *args, **kwargs):
607 super(ForwarderWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800608 self._sock = sock
609 self._stop = threading.Event()
610
611 def handshake_ok(self):
612 pass
613
614 def opened(self):
615 def _FeedInput():
616 try:
617 self._sock.setblocking(False)
618 while True:
619 rd, unused_w, unused_x = select.select([self._sock], [], [], 0.5)
620 if self._stop.is_set():
621 break
622 if self._sock in rd:
623 data = self._sock.recv(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +0800624 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800625 self.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800626 break
627 self.send(data, binary=True)
628 except Exception:
629 pass
630 finally:
631 self._sock.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800632
633 t = threading.Thread(target=_FeedInput)
634 t.daemon = True
635 t.start()
636
637 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800638 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800639 self._stop.set()
640 sys.exit(0)
641
642 def received_message(self, msg):
643 if msg.is_binary:
644 self._sock.send(msg.data)
645
646
647def Arg(*args, **kwargs):
648 return (args, kwargs)
649
650
651def Command(command, help_msg=None, args=None):
652 """Decorator for adding argparse parameter for a method."""
653 if args is None:
654 args = []
655 def WrapFunc(func):
656 def Wrapped(*args, **kwargs):
657 return func(*args, **kwargs)
Peter Shih99b73ec2017-06-16 17:54:15 +0800658 # pylint: disable=protected-access
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800659 Wrapped.__arg_attr = {'command': command, 'help': help_msg, 'args': args}
660 return Wrapped
661 return WrapFunc
662
663
664def ParseMethodSubCommands(cls):
665 """Decorator for a class using the @Command decorator.
666
667 This decorator retrieve command info from each method and append it in to the
668 SUBCOMMANDS class variable, which is later used to construct parser.
669 """
670 for unused_key, method in cls.__dict__.iteritems():
671 if hasattr(method, '__arg_attr'):
Peter Shih99b73ec2017-06-16 17:54:15 +0800672 # pylint: disable=protected-access
673 cls.SUBCOMMANDS.append(method.__arg_attr)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800674 return cls
675
676
677@ParseMethodSubCommands
678class OverlordCLIClient(object):
679 """Overlord command line interface client."""
680
681 SUBCOMMANDS = []
682
683 def __init__(self):
684 self._parser = self._BuildParser()
685 self._selected_mid = None
686 self._server = None
687 self._state = None
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800688 self._escape = None
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800689
690 def _BuildParser(self):
691 root_parser = argparse.ArgumentParser(prog='ovl')
692 subparsers = root_parser.add_subparsers(help='sub-command')
693
694 root_parser.add_argument('-s', dest='selected_mid', action='store',
695 default=None,
696 help='select target to execute command on')
697 root_parser.add_argument('-S', dest='select_mid_before_action',
698 action='store_true', default=False,
699 help='select target before executing command')
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800700 root_parser.add_argument('-e', dest='escape', metavar='ESCAPE_CHAR',
701 action='store', default=_ESCAPE, type=str,
702 help='set shell escape character, \'none\' to '
703 'disable escape completely')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800704
705 for attr in self.SUBCOMMANDS:
706 parser = subparsers.add_parser(attr['command'], help=attr['help'])
707 parser.set_defaults(which=attr['command'])
708 for arg in attr['args']:
709 parser.add_argument(*arg[0], **arg[1])
710
711 return root_parser
712
713 def Main(self):
714 # We want to pass the rest of arguments after shell command directly to the
715 # function without parsing it.
716 try:
717 index = sys.argv.index('shell')
718 except ValueError:
719 args = self._parser.parse_args()
720 else:
721 args = self._parser.parse_args(sys.argv[1:index + 1])
722
723 command = args.which
724 self._selected_mid = args.selected_mid
725
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800726 if args.escape and args.escape != 'none':
727 self._escape = args.escape[0]
728
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800729 if command == 'start-server':
730 self.StartServer()
731 return
732 elif command == 'kill-server':
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800733 self.KillServer()
734 return
735
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800736 self.CheckDaemon()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800737 if command == 'status':
738 self.Status()
739 return
740 elif command == 'connect':
741 self.Connect(args)
742 return
743
744 # The following command requires connection to the server
745 self.CheckConnection()
746
747 if args.select_mid_before_action:
748 self.SelectClient(store=False)
749
750 if command == 'select':
751 self.SelectClient(args)
752 elif command == 'ls':
Peter Shih99b73ec2017-06-16 17:54:15 +0800753 self.ListClients(args)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800754 elif command == 'shell':
755 command = sys.argv[sys.argv.index('shell') + 1:]
756 self.Shell(command)
757 elif command == 'push':
758 self.Push(args)
759 elif command == 'pull':
760 self.Pull(args)
761 elif command == 'forward':
762 self.Forward(args)
763
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800764 def _SaveTLSCertificate(self, host, cert_pem):
765 try:
766 os.makedirs(_CERT_DIR)
767 except Exception:
768 pass
769 with open(GetTLSCertPath(host), 'w') as f:
770 f.write(cert_pem)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800771
772 def _HTTPPostFile(self, url, filename, progress=None, user=None, passwd=None):
773 """Perform HTTP POST and upload file to Overlord.
774
775 To minimize the external dependencies, we construct the HTTP post request
776 by ourselves.
777 """
778 url = MakeRequestUrl(self._state, url)
779 size = os.stat(filename).st_size
780 boundary = '-----------%s' % _HTTP_BOUNDARY_MAGIC
781 CRLF = '\r\n'
782 parse = urlparse.urlparse(url)
783
784 part_headers = [
785 '--' + boundary,
786 'Content-Disposition: form-data; name="file"; '
787 'filename="%s"' % os.path.basename(filename),
788 'Content-Type: application/octet-stream',
789 '', ''
790 ]
791 part_header = CRLF.join(part_headers)
792 end_part = CRLF + '--' + boundary + '--' + CRLF
793
794 content_length = len(part_header) + size + len(end_part)
795 if parse.scheme == 'http':
796 h = httplib.HTTP(parse.netloc)
797 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800798 h = httplib.HTTPS(parse.netloc, context=self._state.ssl_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800799
800 post_path = url[url.index(parse.netloc) + len(parse.netloc):]
801 h.putrequest('POST', post_path)
802 h.putheader('Content-Length', content_length)
803 h.putheader('Content-Type', 'multipart/form-data; boundary=%s' % boundary)
804
805 if user and passwd:
806 h.putheader(*BasicAuthHeader(user, passwd))
807 h.endheaders()
808 h.send(part_header)
809
810 count = 0
811 with open(filename, 'r') as f:
812 while True:
813 data = f.read(_BUFSIZ)
814 if not data:
815 break
816 count += len(data)
817 if progress:
818 progress(int(count * 100.0 / size), count)
819 h.send(data)
820
821 h.send(end_part)
822 progress(100)
823
824 if count != size:
825 logging.warning('file changed during upload, upload may be truncated.')
826
827 errcode, unused_x, unused_y = h.getreply()
828 return errcode == 200
829
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800830 def CheckDaemon(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800831 self._server = OverlordClientDaemon.GetRPCServer()
832 if self._server is None:
833 print('* daemon not running, starting it now on port %d ... *' %
834 _OVERLORD_CLIENT_DAEMON_PORT)
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800835 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800836
837 self._state = self._server.State()
838 sha1sum = GetVersionDigest()
839
840 if sha1sum != self._state.version_sha1sum:
841 print('ovl server is out of date. killing...')
842 KillGraceful(self._server.GetPid())
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800843 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800844
845 def GetSSHControlFile(self, host):
846 return _SSH_CONTROL_SOCKET_PREFIX + host
847
848 def SSHTunnel(self, user, host, port):
849 """SSH forward the remote overlord server.
850
851 Overlord server may not have port 9000 open to the public network, in such
852 case we can SSH forward the port to localhost.
853 """
854
855 control_file = self.GetSSHControlFile(host)
856 try:
857 os.unlink(control_file)
858 except Exception:
859 pass
860
861 subprocess.Popen([
862 'ssh', '-Nf',
863 '-M', # Enable master mode
864 '-S', control_file,
865 '-L', '9000:localhost:9000',
866 '-p', str(port),
867 '%s%s' % (user + '@' if user else '', host)
868 ]).wait()
869
870 p = subprocess.Popen([
871 'ssh',
872 '-S', control_file,
873 '-O', 'check', host,
874 ], stderr=subprocess.PIPE)
875 unused_stdout, stderr = p.communicate()
876
877 s = re.search(r'pid=(\d+)', stderr)
878 if s:
879 return int(s.group(1))
880
881 raise RuntimeError('can not establish ssh connection')
882
883 def CheckConnection(self):
884 if self._state.host is None:
885 raise RuntimeError('not connected to any server, abort')
886
887 try:
888 self._server.Clients()
889 except Exception:
890 raise RuntimeError('remote server disconnected, abort')
891
892 if self._state.ssh_pid is not None:
893 ret = subprocess.Popen(['kill', '-0', str(self._state.ssh_pid)],
894 stdout=subprocess.PIPE,
895 stderr=subprocess.PIPE).wait()
896 if ret != 0:
897 raise RuntimeError('ssh tunnel disconnected, please re-connect')
898
899 def CheckClient(self):
900 if self._selected_mid is None:
901 if self._state.selected_mid is None:
902 raise RuntimeError('No client is selected')
903 self._selected_mid = self._state.selected_mid
904
Peter Shihcf0f3b22017-06-19 15:59:22 +0800905 if not any(client['mid'] == self._selected_mid
906 for client in self._server.Clients()):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800907 raise RuntimeError('client %s disappeared' % self._selected_mid)
908
909 def CheckOutput(self, command):
910 headers = []
911 if self._state.username is not None and self._state.password is not None:
912 headers.append(BasicAuthHeader(self._state.username,
913 self._state.password))
914
915 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
916 sio = StringIO.StringIO()
Peter Shihe03450b2017-06-14 14:02:08 +0800917 ws = ShellWebSocketClient(
918 self._state, sio, scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
919 self._state.host, self._state.port,
920 urllib2.quote(self._selected_mid), urllib2.quote(command)),
921 headers=headers)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800922 ws.connect()
923 ws.run()
924 return sio.getvalue()
925
926 @Command('status', 'show Overlord connection status')
927 def Status(self):
928 if self._state.host is None:
929 print('Not connected to any host.')
930 else:
931 if self._state.ssh_pid is not None:
932 print('Connected to %s with SSH tunneling.' % self._state.orig_host)
933 else:
934 print('Connected to %s:%d.' % (self._state.host, self._state.port))
935
936 if self._selected_mid is None:
937 self._selected_mid = self._state.selected_mid
938
939 if self._selected_mid is None:
940 print('No client is selected.')
941 else:
942 print('Client %s selected.' % self._selected_mid)
943
944 @Command('connect', 'connect to Overlord server', [
945 Arg('host', metavar='HOST', type=str, default='localhost',
946 help='Overlord hostname/IP'),
947 Arg('port', metavar='PORT', type=int,
948 default=_OVERLORD_HTTP_PORT, help='Overlord port'),
949 Arg('-f', '--forward', dest='ssh_forward', default=False,
950 action='store_true',
951 help='connect with SSH forwarding to the host'),
952 Arg('-p', '--ssh-port', dest='ssh_port', default=22,
953 type=int, help='SSH server port for SSH forwarding'),
954 Arg('-l', '--ssh-login', dest='ssh_login', default='',
955 type=str, help='SSH server login name for SSH forwarding'),
956 Arg('-u', '--user', dest='user', default=None,
957 type=str, help='Overlord HTTP auth username'),
958 Arg('-w', '--passwd', dest='passwd', default=None, type=str,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800959 help='Overlord HTTP auth password'),
960 Arg('-i', '--no-check-hostname', dest='check_hostname',
961 default=True, action='store_false',
962 help='Ignore SSL cert hostname check')])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800963 def Connect(self, args):
964 ssh_pid = None
965 host = args.host
966 orig_host = args.host
967
968 if args.ssh_forward:
969 # Kill previous SSH tunnel
970 self.KillSSHTunnel()
971
972 ssh_pid = self.SSHTunnel(args.ssh_login, args.host, args.ssh_port)
973 host = 'localhost'
974
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800975 username_provided = args.user is not None
976 password_provided = args.passwd is not None
977 prompt = False
978
979 for unused_i in range(3):
980 try:
981 if prompt:
982 if not username_provided:
983 args.user = raw_input('Username: ')
984 if not password_provided:
985 args.passwd = getpass.getpass('Password: ')
986
987 ret = self._server.Connect(host, args.port, ssh_pid, args.user,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800988 args.passwd, orig_host,
989 args.check_hostname)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800990 if isinstance(ret, list):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800991 if ret[0].startswith('SSL'):
992 cert_pem = ret[1]
993 fp = GetTLSCertificateSHA1Fingerprint(cert_pem)
994 fp_text = ':'.join([fp[i:i+2] for i in range(0, len(fp), 2)])
995
996 if ret[0] == 'SSLCertificateChanged':
997 print(_TLS_CERT_CHANGED_WARNING % (fp_text, GetTLSCertPath(host)))
998 return
999 elif ret[0] == 'SSLVerifyFailed':
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001000 print(_TLS_CERT_FAILED_WARNING % (fp_text), end='')
1001 response = raw_input()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001002 if response.lower() in ['y', 'ye', 'yes']:
1003 self._SaveTLSCertificate(host, cert_pem)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001004 print('TLS host Certificate trusted, you will not be prompted '
1005 'next time.\n')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001006 continue
1007 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001008 print('connection aborted.')
1009 return
1010 elif ret[0] == 'HTTPError':
1011 code, except_str, body = ret[1:]
1012 if code == 401:
1013 print('connect: %s' % body)
1014 prompt = True
1015 if not username_provided or not password_provided:
1016 continue
1017 else:
1018 break
1019 else:
1020 logging.error('%s; %s', except_str, body)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001021
1022 if ret is not True:
1023 print('can not connect to %s: %s' % (host, ret))
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001024 else:
1025 print('connection to %s:%d established.' % (host, args.port))
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001026 except Exception as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001027 logging.error(e)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001028 else:
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001029 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001030
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001031 @Command('start-server', 'start overlord CLI client server')
1032 def StartServer(self):
1033 self._server = OverlordClientDaemon.GetRPCServer()
1034 if self._server is None:
1035 OverlordClientDaemon().Start()
1036 time.sleep(1)
1037 self._server = OverlordClientDaemon.GetRPCServer()
1038 if self._server is not None:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001039 print('* daemon started successfully *\n')
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001040
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001041 @Command('kill-server', 'kill overlord CLI client server')
1042 def KillServer(self):
1043 self._server = OverlordClientDaemon.GetRPCServer()
1044 if self._server is None:
1045 return
1046
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001047 self._state = self._server.State()
1048
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001049 # Kill SSH Tunnel
1050 self.KillSSHTunnel()
1051
1052 # Kill server daemon
1053 KillGraceful(self._server.GetPid())
1054
1055 def KillSSHTunnel(self):
1056 if self._state.ssh_pid is not None:
1057 KillGraceful(self._state.ssh_pid)
1058
Peter Shihcf0f3b22017-06-19 15:59:22 +08001059 def _FilterClients(self, clients, prop_filters, mid=None):
1060 def _ClientPropertiesMatch(client, key, regex):
1061 try:
1062 return bool(re.search(regex, client['properties'][key]))
1063 except KeyError:
1064 return False
1065
1066 for prop_filter in prop_filters:
1067 key, sep, regex = prop_filter.partition('=')
1068 if not sep:
1069 # The filter doesn't contains =.
1070 raise ValueError('Invalid filter condition %r' % filter)
1071 clients = [c for c in clients if _ClientPropertiesMatch(c, key, regex)]
1072
1073 if mid is not None:
1074 client = next((c for c in clients if c['mid'] == mid), None)
1075 if client:
1076 return [client]
1077 clients = [c for c in clients if c['mid'].startswith(mid)]
1078 return clients
1079
1080 @Command('ls', 'list clients', [
1081 Arg('-f', '--filter', default=[], dest='filters', action='append',
1082 help=('Conditions to filter clients by properties. '
1083 'Should be in form "key=regex", where regex is the regular '
1084 'expression that should be found in the value. '
1085 'Multiple --filter arguments would be ANDed.')),
Peter Shih99b73ec2017-06-16 17:54:15 +08001086 Arg('-v', '--verbose', default=False, action='store_true',
1087 help='Print properties of each client.')
1088 ])
1089 def ListClients(self, args):
Peter Shihcf0f3b22017-06-19 15:59:22 +08001090 clients = self._FilterClients(self._server.Clients(), args.filters)
1091 for client in clients:
1092 if args.verbose:
Peter Shih99b73ec2017-06-16 17:54:15 +08001093 print(yaml.safe_dump(client, default_flow_style=False))
Peter Shihcf0f3b22017-06-19 15:59:22 +08001094 else:
1095 print(client['mid'])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001096
1097 @Command('select', 'select default client', [
Peter Shihcf0f3b22017-06-19 15:59:22 +08001098 Arg('-f', '--filter', default=[], dest='filters', action='append',
1099 help=('Conditions to filter clients by properties. '
1100 'Should be in form "key=regex", where regex is the regular '
1101 'expression that should be found in the value. '
1102 'Multiple --filter arguments would be ANDed.')),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001103 Arg('mid', metavar='mid', nargs='?', default=None)])
1104 def SelectClient(self, args=None, store=True):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001105 mid = args.mid if args is not None else None
Wei-Ning Huang6796a132017-06-22 14:46:32 +08001106 filters = args.filters if args is not None else []
1107 clients = self._FilterClients(self._server.Clients(), filters, mid=mid)
Peter Shihcf0f3b22017-06-19 15:59:22 +08001108
1109 if not clients:
1110 raise RuntimeError('select: client not found')
1111 elif len(clients) == 1:
1112 mid = clients[0]['mid']
1113 else:
1114 # This case would not happen when args.mid is specified.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001115 print('Select from the following clients:')
1116 for i, client in enumerate(clients):
Peter Shihcf0f3b22017-06-19 15:59:22 +08001117 print(' %d. %s' % (i + 1, client['mid']))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001118
1119 print('\nSelection: ', end='')
1120 try:
1121 choice = int(raw_input()) - 1
Peter Shihcf0f3b22017-06-19 15:59:22 +08001122 mid = clients[choice]['mid']
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001123 except ValueError:
1124 raise RuntimeError('select: invalid selection')
1125 except IndexError:
1126 raise RuntimeError('select: selection out of range')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001127
1128 self._selected_mid = mid
1129 if store:
1130 self._server.SelectClient(mid)
1131 print('Client %s selected' % mid)
1132
1133 @Command('shell', 'open a shell or execute a shell command', [
1134 Arg('command', metavar='CMD', nargs='?', help='command to execute')])
1135 def Shell(self, command=None):
1136 if command is None:
1137 command = []
1138 self.CheckClient()
1139
1140 headers = []
1141 if self._state.username is not None and self._state.password is not None:
1142 headers.append(BasicAuthHeader(self._state.username,
1143 self._state.password))
1144
1145 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
Peter Shihe03450b2017-06-14 14:02:08 +08001146 if command:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001147 cmd = ' '.join(command)
Peter Shihe03450b2017-06-14 14:02:08 +08001148 ws = ShellWebSocketClient(
1149 self._state, sys.stdout,
1150 scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
1151 self._state.host, self._state.port,
1152 urllib2.quote(self._selected_mid), urllib2.quote(cmd)),
1153 headers=headers)
1154 else:
1155 ws = TerminalWebSocketClient(
1156 self._state, self._selected_mid, self._escape,
1157 scheme + '%s:%d/api/agent/tty/%s' % (
1158 self._state.host, self._state.port,
1159 urllib2.quote(self._selected_mid)),
1160 headers=headers)
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001161 try:
1162 ws.connect()
1163 ws.run()
1164 except socket.error as e:
1165 if e.errno == 32: # Broken pipe
1166 pass
1167 else:
1168 raise
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001169
1170 @Command('push', 'push a file or directory to remote', [
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001171 Arg('srcs', nargs='+', metavar='SOURCE'),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001172 Arg('dst', metavar='DESTINATION')])
1173 def Push(self, args):
1174 self.CheckClient()
1175
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001176 @AutoRetry('push', _RETRY_TIMES)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001177 def _push(src, dst):
1178 src_base = os.path.basename(src)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001179
1180 # Local file is a link
1181 if os.path.islink(src):
1182 pbar = ProgressBar(src_base)
1183 link_path = os.readlink(src)
1184 self.CheckOutput('mkdir -p %(dirname)s; '
1185 'if [ -d "%(dst)s" ]; then '
1186 'ln -sf "%(link_path)s" "%(dst)s/%(link_name)s"; '
1187 'else ln -sf "%(link_path)s" "%(dst)s"; fi' %
1188 dict(dirname=os.path.dirname(dst),
1189 link_path=link_path, dst=dst,
1190 link_name=src_base))
1191 pbar.End()
1192 return
1193
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001194 mode = '0%o' % (0x1FF & os.stat(src).st_mode)
1195 url = ('%s:%d/api/agent/upload/%s?dest=%s&perm=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001196 (self._state.host, self._state.port,
1197 urllib2.quote(self._selected_mid), dst, mode))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001198 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001199 UrlOpen(self._state, url + '&filename=%s' % src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001200 except urllib2.HTTPError as e:
1201 msg = json.loads(e.read()).get('error', None)
1202 raise RuntimeError('push: %s' % msg)
1203
1204 pbar = ProgressBar(src_base)
1205 self._HTTPPostFile(url, src, pbar.SetProgress,
1206 self._state.username, self._state.password)
1207 pbar.End()
1208
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001209 def _push_single_target(src, dst):
1210 if os.path.isdir(src):
1211 dst_exists = ast.literal_eval(self.CheckOutput(
1212 'stat %s >/dev/null 2>&1 && echo True || echo False' % dst))
1213 for root, unused_x, files in os.walk(src):
1214 # If destination directory does not exist, we should strip the first
1215 # layer of directory. For example: src_dir contains a single file 'A'
1216 #
1217 # push src_dir dest_dir
1218 #
1219 # If dest_dir exists, the resulting directory structure should be:
1220 # dest_dir/src_dir/A
1221 # If dest_dir does not exist, the resulting directory structure should
1222 # be:
1223 # dest_dir/A
1224 dst_root = root if dst_exists else root[len(src):].lstrip('/')
1225 for name in files:
1226 _push(os.path.join(root, name),
1227 os.path.join(dst, dst_root, name))
1228 else:
1229 _push(src, dst)
1230
1231 if len(args.srcs) > 1:
1232 dst_type = self.CheckOutput('stat \'%s\' --printf \'%%F\' '
1233 '2>/dev/null' % args.dst).strip()
1234 if not dst_type:
1235 raise RuntimeError('push: %s: No such file or directory' % args.dst)
1236 if dst_type != 'directory':
1237 raise RuntimeError('push: %s: Not a directory' % args.dst)
1238
1239 for src in args.srcs:
1240 if not os.path.exists(src):
1241 raise RuntimeError('push: can not stat "%s": no such file or directory'
1242 % src)
1243 if not os.access(src, os.R_OK):
1244 raise RuntimeError('push: can not open "%s" for reading' % src)
1245
1246 _push_single_target(src, args.dst)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001247
1248 @Command('pull', 'pull a file or directory from remote', [
1249 Arg('src', metavar='SOURCE'),
1250 Arg('dst', metavar='DESTINATION', default='.', nargs='?')])
1251 def Pull(self, args):
1252 self.CheckClient()
1253
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001254 @AutoRetry('pull', _RETRY_TIMES)
1255 def _pull(src, dst, ftype, perm=0644, link=None):
1256 try:
1257 os.makedirs(os.path.dirname(dst))
1258 except Exception:
1259 pass
1260
1261 src_base = os.path.basename(src)
1262
1263 # Remote file is a link
1264 if ftype == 'l':
1265 pbar = ProgressBar(src_base)
1266 if os.path.exists(dst):
1267 os.remove(dst)
1268 os.symlink(link, dst)
1269 pbar.End()
1270 return
1271
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001272 url = ('%s:%d/api/agent/download/%s?filename=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001273 (self._state.host, self._state.port,
1274 urllib2.quote(self._selected_mid), urllib2.quote(src)))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001275 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001276 h = UrlOpen(self._state, url)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001277 except urllib2.HTTPError as e:
1278 msg = json.loads(e.read()).get('error', 'unkown error')
1279 raise RuntimeError('pull: %s' % msg)
1280 except KeyboardInterrupt:
1281 return
1282
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001283 pbar = ProgressBar(src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001284 with open(dst, 'w') as f:
1285 os.fchmod(f.fileno(), perm)
1286 total_size = int(h.headers.get('Content-Length'))
1287 downloaded_size = 0
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001288
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001289 while True:
1290 data = h.read(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +08001291 if not data:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001292 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001293 downloaded_size += len(data)
1294 pbar.SetProgress(float(downloaded_size) * 100 / total_size,
1295 downloaded_size)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001296 f.write(data)
1297 pbar.End()
1298
1299 # Use find to get a listing of all files under a root directory. The 'stat'
1300 # command is used to retrieve the filename and it's filemode.
1301 output = self.CheckOutput(
1302 'cd $HOME; '
1303 'stat "%(src)s" >/dev/null && '
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001304 'find "%(src)s" \'(\' -type f -o -type l \')\' '
1305 '-printf \'%%m\t%%p\t%%y\t%%l\n\''
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001306 % {'src': args.src})
1307
1308 # We got error from the stat command
1309 if output.startswith('stat: '):
1310 sys.stderr.write(output)
1311 return
1312
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001313 entries = output.strip('\n').split('\n')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001314 common_prefix = os.path.dirname(args.src)
1315
1316 if len(entries) == 1:
1317 entry = entries[0]
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001318 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001319 if os.path.isdir(args.dst):
1320 dst = os.path.join(args.dst, os.path.basename(src_path))
1321 else:
1322 dst = args.dst
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001323 _pull(src_path, dst, ftype, int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001324 else:
1325 if not os.path.exists(args.dst):
1326 common_prefix = args.src
1327
1328 for entry in entries:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001329 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001330 rel_dst = src_path[len(common_prefix):].lstrip('/')
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001331 _pull(src_path, os.path.join(args.dst, rel_dst), ftype,
1332 int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001333
1334 @Command('forward', 'forward remote port to local port', [
1335 Arg('--list', dest='list_all', action='store_true', default=False,
1336 help='list all port forwarding sessions'),
1337 Arg('--remove', metavar='LOCAL_PORT', dest='remove', type=int,
1338 default=None,
1339 help='remove port forwarding for local port LOCAL_PORT'),
1340 Arg('--remove-all', dest='remove_all', action='store_true',
1341 default=False, help='remove all port forwarding'),
1342 Arg('remote', metavar='REMOTE_PORT', type=int, nargs='?'),
1343 Arg('local', metavar='LOCAL_PORT', type=int, nargs='?')])
1344 def Forward(self, args):
1345 if args.list_all:
1346 max_len = 10
Peter Shihf84a8972017-06-19 15:18:24 +08001347 if self._state.forwards:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001348 max_len = max([len(v[0]) for v in self._state.forwards.values()])
1349
1350 print('%-*s %-8s %-8s' % (max_len, 'Client', 'Remote', 'Local'))
1351 for local in sorted(self._state.forwards.keys()):
1352 value = self._state.forwards[local]
1353 print('%-*s %-8s %-8s' % (max_len, value[0], value[1], local))
1354 return
1355
1356 if args.remove_all:
1357 self._server.RemoveAllForward()
1358 return
1359
1360 if args.remove:
1361 self._server.RemoveForward(args.remove)
1362 return
1363
1364 self.CheckClient()
1365
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001366 if args.remote is None:
1367 raise RuntimeError('remote port not specified')
1368
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001369 if args.local is None:
1370 args.local = args.remote
1371 remote = int(args.remote)
1372 local = int(args.local)
1373
1374 def HandleConnection(conn):
1375 headers = []
1376 if self._state.username is not None and self._state.password is not None:
1377 headers.append(BasicAuthHeader(self._state.username,
1378 self._state.password))
1379
1380 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1381 ws = ForwarderWebSocketClient(
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001382 self._state, conn,
Peter Shihe03450b2017-06-14 14:02:08 +08001383 scheme + '%s:%d/api/agent/forward/%s?port=%d' % (
1384 self._state.host, self._state.port,
1385 urllib2.quote(self._selected_mid), remote),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001386 headers=headers)
1387 try:
1388 ws.connect()
1389 ws.run()
1390 except Exception as e:
1391 print('error: %s' % e)
1392 finally:
1393 ws.close()
1394
1395 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1396 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1397 server.bind(('0.0.0.0', local))
1398 server.listen(5)
1399
1400 pid = os.fork()
1401 if pid == 0:
1402 while True:
1403 conn, unused_addr = server.accept()
1404 t = threading.Thread(target=HandleConnection, args=(conn,))
1405 t.daemon = True
1406 t.start()
1407 else:
1408 self._server.AddForward(self._selected_mid, remote, local, pid)
1409
1410
1411def main():
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001412 # Setup logging format
1413 logger = logging.getLogger()
1414 logger.setLevel(logging.INFO)
1415 handler = logging.StreamHandler()
1416 formatter = logging.Formatter('%(asctime)s %(message)s', '%Y/%m/%d %H:%M:%S')
1417 handler.setFormatter(formatter)
1418 logger.addHandler(handler)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001419
1420 # Add DaemonState to JSONRPC lib classes
1421 Config.instance().classes.add(DaemonState)
1422
1423 ovl = OverlordCLIClient()
1424 try:
1425 ovl.Main()
1426 except KeyboardInterrupt:
1427 print('Ctrl-C received, abort')
1428 except Exception as e:
1429 print('error: %s' % e)
1430
1431
1432if __name__ == '__main__':
1433 main()