blob: 5d44aa0123d662fe21df311c43c0e258b190fb30 [file] [log] [blame]
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001#!/usr/bin/env python
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08002# -*- coding: utf-8 -*-
3#
4# Copyright 2015 The Chromium OS Authors. All rights reserved.
5# Use of this source code is governed by a BSD-style license that can be
6# found in the LICENSE file.
7
8from __future__ import print_function
9
10import argparse
11import ast
12import base64
13import fcntl
Wei-Ning Huangba768ab2016-02-07 14:38:06 +080014import getpass
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080015import hashlib
16import httplib
17import json
18import jsonrpclib
19import logging
20import os
21import re
22import select
23import signal
24import socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080025import ssl
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080026import StringIO
27import struct
28import subprocess
29import sys
30import tempfile
31import termios
32import threading
33import time
34import tty
35import urllib2
36import urlparse
Wei-Ning Huangecc80b82016-07-01 16:55:10 +080037import unicodedata # required by pyinstaller, pylint: disable=W0611
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080038
39from jsonrpclib.SimpleJSONRPCServer import SimpleJSONRPCServer
40from jsonrpclib.config import Config
41from ws4py.client import WebSocketBaseClient
42
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080043
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080044_CERT_DIR = os.path.expanduser('~/.config/ovl')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080045
46_ESCAPE = '~'
47_BUFSIZ = 8192
48_OVERLORD_PORT = 4455
49_OVERLORD_HTTP_PORT = 9000
50_OVERLORD_CLIENT_DAEMON_PORT = 4488
51_OVERLORD_CLIENT_DAEMON_RPC_ADDR = ('127.0.0.1', _OVERLORD_CLIENT_DAEMON_PORT)
52
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080053_CONNECT_TIMEOUT = 3
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +080054_DEFAULT_HTTP_TIMEOUT = 30
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080055_LIST_CACHE_TIMEOUT = 2
56_DEFAULT_TERMINAL_WIDTH = 80
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +080057_RETRY_TIMES = 3
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080058
59# echo -n overlord | md5sum
60_HTTP_BOUNDARY_MAGIC = '9246f080c855a69012707ab53489b921'
61
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +080062# Terminal resize control
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080063_CONTROL_START = 128
64_CONTROL_END = 129
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +080065
66# Stream control
67_STDIN_CLOSED = '##STDIN_CLOSED##'
68
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080069_SSH_CONTROL_SOCKET_PREFIX = os.path.join(tempfile.gettempdir(),
70 'ovl-ssh-control-')
71
Wei-Ning Huang47c79b82016-05-24 01:24:46 +080072_TLS_CERT_FAILED_WARNING = """
73@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
74@ WARNING: REMOTE HOST VERIFICATION HAS FAILED! @
75@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
76IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
77Someone could be eavesdropping on you right now (man-in-the-middle attack)!
78It is also possible that the server is using a self-signed certificate.
79The fingerprint for the TLS host certificate sent by the remote host is
80
81%s
82
83Do you want to trust this certificate and proceed? [Y/n] """
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080084
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080085_TLS_CERT_CHANGED_WARNING = """
86@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
87@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
88@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
89IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
90Someone could be eavesdropping on you right now (man-in-the-middle attack)!
Wei-Ning Huang47c79b82016-05-24 01:24:46 +080091It is also possible that the TLS host certificate has just been changed.
92The fingerprint for the TLS host certificate sent by the remote host is
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080093
94%s
95
96Remove '%s' if you still want to proceed.
97SSL Certificate verification failed."""
98
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080099
100def GetVersionDigest():
101 """Return the sha1sum of the current executing script."""
Wei-Ning Huang31609662016-08-11 00:22:25 +0800102 # Check python script by default
103 filename = __file__
104
105 # If we are running from a frozen binary, we should calculate the checksum
106 # against that binary instead of the python script.
107 # See: https://pyinstaller.readthedocs.io/en/stable/runtime-information.html
108 if getattr(sys, 'frozen', False):
109 filename = sys.executable
110
111 with open(filename, 'r') as f:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800112 return hashlib.sha1(f.read()).hexdigest()
113
114
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800115def GetTLSCertPath(host):
116 return os.path.join(_CERT_DIR, '%s.cert' % host)
117
118
119def UrlOpen(state, url):
120 """Wrapper for urllib2.urlopen.
121
122 It selects correct HTTP scheme according to self._state.ssl, add HTTP
123 basic auth headers, and add specify correct SSL context.
124 """
125 url = MakeRequestUrl(state, url)
126 request = urllib2.Request(url)
127 if state.username is not None and state.password is not None:
128 request.add_header(*BasicAuthHeader(state.username, state.password))
129 return urllib2.urlopen(request, timeout=_DEFAULT_HTTP_TIMEOUT,
130 context=state.ssl_context)
131
132
133def GetTLSCertificateSHA1Fingerprint(cert_pem):
134 beg = cert_pem.index('\n')
135 end = cert_pem.rindex('\n', 0, len(cert_pem) - 2)
136 cert_pem = cert_pem[beg:end] # Remove BEGIN/END CERTIFICATE boundary
137 cert_der = base64.b64decode(cert_pem)
138 return hashlib.sha1(cert_der).hexdigest()
139
140
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800141def KillGraceful(pid, wait_secs=1):
142 """Kill a process gracefully by first sending SIGTERM, wait for some time,
143 then send SIGKILL to make sure it's killed."""
144 try:
145 os.kill(pid, signal.SIGTERM)
146 time.sleep(wait_secs)
147 os.kill(pid, signal.SIGKILL)
148 except OSError:
149 pass
150
151
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800152def AutoRetry(action_name, retries):
153 """Decorator for retry function call."""
154 def Wrap(func):
155 def Loop(*args, **kwargs):
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800156 for unused_i in range(retries):
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800157 try:
158 func(*args, **kwargs)
159 except Exception as e:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800160 print('error: %s: %s: retrying ...' % (args[0], e))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800161 else:
162 break
163 else:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800164 print('error: failed to %s %s' % (action_name, args[0]))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800165 return Loop
166 return Wrap
167
168
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800169def BasicAuthHeader(user, password):
170 """Return HTTP basic auth header."""
171 credential = base64.b64encode('%s:%s' % (user, password))
172 return ('Authorization', 'Basic %s' % credential)
173
174
175def GetTerminalSize():
176 """Retrieve terminal window size."""
177 ws = struct.pack('HHHH', 0, 0, 0, 0)
178 ws = fcntl.ioctl(0, termios.TIOCGWINSZ, ws)
179 lines, columns, unused_x, unused_y = struct.unpack('HHHH', ws)
180 return lines, columns
181
182
183def MakeRequestUrl(state, url):
184 return 'http%s://%s' % ('s' if state.ssl else '', url)
185
186
187class ProgressBar(object):
188 SIZE_WIDTH = 11
189 SPEED_WIDTH = 10
190 DURATION_WIDTH = 6
191 PERCENTAGE_WIDTH = 8
192
193 def __init__(self, name):
194 self._start_time = time.time()
195 self._name = name
196 self._size = 0
197 self._width = 0
198 self._name_width = 0
199 self._name_max = 0
200 self._stat_width = 0
201 self._max = 0
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800202 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800203 self.SetProgress(0)
204
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800205 def _CalculateSize(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800206 self._width = GetTerminalSize()[1] or _DEFAULT_TERMINAL_WIDTH
207 self._name_width = int(self._width * 0.3)
208 self._name_max = self._name_width
209 self._stat_width = self.SIZE_WIDTH + self.SPEED_WIDTH + self.DURATION_WIDTH
210 self._max = (self._width - self._name_width - self._stat_width -
211 self.PERCENTAGE_WIDTH)
212
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800213 def _SizeToHuman(self, size_in_bytes):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800214 if size_in_bytes < 1024:
215 unit = 'B'
216 value = size_in_bytes
217 elif size_in_bytes < 1024 ** 2:
218 unit = 'KiB'
219 value = size_in_bytes / 1024.0
220 elif size_in_bytes < 1024 ** 3:
221 unit = 'MiB'
222 value = size_in_bytes / (1024.0 ** 2)
223 elif size_in_bytes < 1024 ** 4:
224 unit = 'GiB'
225 value = size_in_bytes / (1024.0 ** 3)
226 return ' %6.1f %3s' % (value, unit)
227
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800228 def _SpeedToHuman(self, speed_in_bs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800229 if speed_in_bs < 1024:
230 unit = 'B'
231 value = speed_in_bs
232 elif speed_in_bs < 1024 ** 2:
233 unit = 'K'
234 value = speed_in_bs / 1024.0
235 elif speed_in_bs < 1024 ** 3:
236 unit = 'M'
237 value = speed_in_bs / (1024.0 ** 2)
238 elif speed_in_bs < 1024 ** 4:
239 unit = 'G'
240 value = speed_in_bs / (1024.0 ** 3)
241 return ' %6.1f%s/s' % (value, unit)
242
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800243 def _DurationToClock(self, duration):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800244 return ' %02d:%02d' % (duration / 60, duration % 60)
245
246 def SetProgress(self, percentage, size=None):
247 current_width = GetTerminalSize()[1]
248 if self._width != current_width:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800249 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800250
251 if size is not None:
252 self._size = size
253
254 elapse_time = time.time() - self._start_time
255 speed = self._size / float(elapse_time)
256
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800257 size_str = self._SizeToHuman(self._size)
258 speed_str = self._SpeedToHuman(speed)
259 elapse_str = self._DurationToClock(elapse_time)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800260
261 width = int(self._max * percentage / 100.0)
262 sys.stdout.write(
263 '%*s' % (- self._name_max,
264 self._name if len(self._name) <= self._name_max else
265 self._name[:self._name_max - 4] + ' ...') +
266 size_str + speed_str + elapse_str +
267 ((' [' + '#' * width + ' ' * (self._max - width) + ']' +
268 '%4d%%' % int(percentage)) if self._max > 2 else '') + '\r')
269 sys.stdout.flush()
270
271 def End(self):
272 self.SetProgress(100.0)
273 sys.stdout.write('\n')
274 sys.stdout.flush()
275
276
277class DaemonState(object):
278 """DaemonState is used for storing Overlord state info."""
279 def __init__(self):
280 self.version_sha1sum = GetVersionDigest()
281 self.host = None
282 self.port = None
283 self.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800284 self.ssl_self_signed = False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800285 self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800286 self.ssh = False
287 self.orig_host = None
288 self.ssh_pid = None
289 self.username = None
290 self.password = None
291 self.selected_mid = None
292 self.forwards = {}
293 self.listing = []
294 self.last_list = 0
295
296
297class OverlordClientDaemon(object):
298 """Overlord Client Daemon."""
299 def __init__(self):
300 self._state = DaemonState()
301 self._server = None
302
303 def Start(self):
304 self.StartRPCServer()
305
306 def StartRPCServer(self):
307 self._server = SimpleJSONRPCServer(_OVERLORD_CLIENT_DAEMON_RPC_ADDR,
308 logRequests=False)
309 exports = [
310 (self.State, 'State'),
311 (self.Ping, 'Ping'),
312 (self.GetPid, 'GetPid'),
313 (self.Connect, 'Connect'),
314 (self.Clients, 'Clients'),
315 (self.SelectClient, 'SelectClient'),
316 (self.AddForward, 'AddForward'),
317 (self.RemoveForward, 'RemoveForward'),
318 (self.RemoveAllForward, 'RemoveAllForward'),
319 ]
320 for func, name in exports:
321 self._server.register_function(func, name)
322
323 pid = os.fork()
324 if pid == 0:
325 self._server.serve_forever()
326
327 @staticmethod
328 def GetRPCServer():
329 """Returns the Overlord client daemon RPC server."""
330 server = jsonrpclib.Server('http://%s:%d' %
331 _OVERLORD_CLIENT_DAEMON_RPC_ADDR)
332 try:
333 server.Ping()
334 except Exception:
335 return None
336 return server
337
338 def State(self):
339 return self._state
340
341 def Ping(self):
342 return True
343
344 def GetPid(self):
345 return os.getpid()
346
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800347 def _GetJSON(self, path):
348 url = '%s:%d%s' % (self._state.host, self._state.port, path)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800349 return json.loads(UrlOpen(self._state, url).read())
350
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800351 def _TLSEnabled(self):
352 """Determine if TLS is enabled on given server address."""
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800353 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
354 try:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800355 # Allow any certificate since we only want to check if server talks TLS.
356 context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
357 context.verify_mode = ssl.CERT_NONE
358
359 sock = context.wrap_socket(sock, server_hostname=self._state.host)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800360 sock.settimeout(_CONNECT_TIMEOUT)
361 sock.connect((self._state.host, self._state.port))
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800362 return True
Wei-Ning Huangecc80b82016-07-01 16:55:10 +0800363 except ssl.SSLError:
364 return False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800365 except socket.error: # Connect refused or timeout
366 raise
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800367 except Exception:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800368 return False # For whatever reason above failed, assume False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800369
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800370 def _CheckTLSCertificate(self, check_hostname=True):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800371 """Check TLS certificate.
372
373 Returns:
374 A tupple (check_result, if_certificate_is_loaded)
375 """
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800376 def _DoConnect(context):
377 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
378 try:
379 sock.settimeout(_CONNECT_TIMEOUT)
380 sock = context.wrap_socket(sock, server_hostname=self._state.host)
381 sock.connect((self._state.host, self._state.port))
382 except ssl.SSLError:
383 return False
384 finally:
385 sock.close()
386
387 # Save SSLContext for future use.
388 self._state.ssl_context = context
389 return True
390
391 # First try connect with built-in certificates
392 tls_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
393 if _DoConnect(tls_context):
394 return True
395
396 # Try with already saved certificate, if any.
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800397 tls_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
398 tls_context.verify_mode = ssl.CERT_REQUIRED
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800399 tls_context.check_hostname = check_hostname
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800400
401 tls_cert_path = GetTLSCertPath(self._state.host)
402 if os.path.exists(tls_cert_path):
403 tls_context.load_verify_locations(tls_cert_path)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800404 self._state.ssl_self_signed = True
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800405
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800406 return _DoConnect(tls_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800407
408 def Connect(self, host, port=_OVERLORD_HTTP_PORT, ssh_pid=None,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800409 username=None, password=None, orig_host=None,
410 check_hostname=True):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800411 self._state.username = username
412 self._state.password = password
413 self._state.host = host
414 self._state.port = port
415 self._state.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800416 self._state.ssl_self_signed = False
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800417 self._state.orig_host = orig_host
418 self._state.ssh_pid = ssh_pid
419 self._state.selected_mid = None
420
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800421 tls_enabled = self._TLSEnabled()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800422 if tls_enabled:
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800423 result = self._CheckTLSCertificate(check_hostname)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800424 if not result:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800425 if self._state.ssl_self_signed:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800426 return ('SSLCertificateChanged', ssl.get_server_certificate(
427 (self._state.host, self._state.port)))
428 else:
429 return ('SSLVerifyFailed', ssl.get_server_certificate(
430 (self._state.host, self._state.port)))
431
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800432 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800433 self._state.ssl = tls_enabled
434 UrlOpen(self._state, '%s:%d' % (host, port))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800435 except urllib2.HTTPError as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800436 return ('HTTPError', e.getcode(), str(e), e.read().strip())
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800437 except Exception as e:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800438 return str(e)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800439 else:
440 return True
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800441
442 def Clients(self):
443 if time.time() - self._state.last_list <= _LIST_CACHE_TIMEOUT:
444 return self._state.listing
445
446 mids = [client['mid'] for client in self._GetJSON('/api/agents/list')]
447 self._state.listing = sorted(list(set(mids)))
448 self._state.last_list = time.time()
449 return self._state.listing
450
451 def SelectClient(self, mid):
452 self._state.selected_mid = mid
453
454 def AddForward(self, mid, remote, local, pid):
455 self._state.forwards[local] = (mid, remote, pid)
456
457 def RemoveForward(self, local_port):
458 try:
459 unused_mid, unused_remote, pid = self._state.forwards[local_port]
460 KillGraceful(pid)
461 del self._state.forwards[local_port]
462 except (KeyError, OSError):
463 pass
464
465 def RemoveAllForward(self):
466 for unused_mid, unused_remote, pid in self._state.forwards.values():
467 try:
468 KillGraceful(pid)
469 except OSError:
470 pass
471 self._state.forwards = {}
472
473
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800474class SSLEnabledWebSocketBaseClient(WebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800475 def __init__(self, state, *args, **kwargs):
476 cafile = ssl.get_default_verify_paths().openssl_cafile
477 # For some system / distribution, python can not detect system cafile path.
478 # In such case we fallback to the default path.
479 if not os.path.exists(cafile):
480 cafile = '/etc/ssl/certs/ca-certificates.crt'
481
482 if state.ssl_self_signed:
483 cafile = GetTLSCertPath(state.host)
484
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800485 ssl_options = {
486 'cert_reqs': ssl.CERT_REQUIRED,
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800487 'ca_certs': cafile
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800488 }
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800489 # ws4py does not allow you to specify SSLContext, but rather passing in the
490 # argument of ssl.wrap_socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800491 super(SSLEnabledWebSocketBaseClient, self).__init__(
492 ssl_options=ssl_options, *args, **kwargs)
493
494
495class TerminalWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800496 def __init__(self, state, mid, escape, *args, **kwargs):
497 super(TerminalWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800498 self._mid = mid
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800499 self._escape = escape
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800500 self._stdin_fd = sys.stdin.fileno()
501 self._old_termios = None
502
503 def handshake_ok(self):
504 pass
505
506 def opened(self):
507 nonlocals = {'size': (80, 40)}
508
509 def _ResizeWindow():
510 size = GetTerminalSize()
511 if size != nonlocals['size']: # Size not changed, ignore
512 control = {'command': 'resize', 'params': list(size)}
513 payload = chr(_CONTROL_START) + json.dumps(control) + chr(_CONTROL_END)
514 nonlocals['size'] = size
515 try:
516 self.send(payload, binary=True)
517 except Exception:
518 pass
519
520 def _FeedInput():
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800521 self._old_termios = termios.tcgetattr(self._stdin_fd)
522 tty.setraw(self._stdin_fd)
523
524 READY, ENTER_PRESSED, ESCAPE_PRESSED = range(3)
525
526 try:
527 state = READY
528 while True:
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800529 # Check if terminal is resized
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800530 _ResizeWindow()
531
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800532 ch = sys.stdin.read(1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800533
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800534 # Scan for escape sequence
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800535 if self._escape:
536 if state == READY:
537 state = ENTER_PRESSED if ch == chr(0x0d) else READY
538 elif state == ENTER_PRESSED:
539 state = ESCAPE_PRESSED if ch == self._escape else READY
540 elif state == ESCAPE_PRESSED:
541 if ch == '.':
542 self.close()
543 break
544 else:
545 state = READY
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800546
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800547 self.send(ch)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800548 except (KeyboardInterrupt, RuntimeError):
549 pass
550
551 t = threading.Thread(target=_FeedInput)
552 t.daemon = True
553 t.start()
554
555 def closed(self, code, reason=None):
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)
658 # pylint: disable=W0212
659 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'):
672 cls.SUBCOMMANDS.append(method.__arg_attr) # pylint: disable=W0212
673 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':
752 self.ListClients()
753 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
1057 @Command('ls', 'list all clients')
1058 def ListClients(self):
1059 for client in self._server.Clients():
1060 print(client)
1061
1062 @Command('select', 'select default client', [
1063 Arg('mid', metavar='mid', nargs='?', default=None)])
1064 def SelectClient(self, args=None, store=True):
1065 clients = self._server.Clients()
1066
1067 mid = args.mid if args is not None else None
1068 if mid is None:
1069 print('Select from the following clients:')
1070 for i, client in enumerate(clients):
1071 print(' %d. %s' % (i + 1, client))
1072
1073 print('\nSelection: ', end='')
1074 try:
1075 choice = int(raw_input()) - 1
1076 mid = clients[choice]
1077 except ValueError:
1078 raise RuntimeError('select: invalid selection')
1079 except IndexError:
1080 raise RuntimeError('select: selection out of range')
1081 else:
1082 if mid not in clients:
Peter Shih54fd5a12017-06-16 17:00:47 +08001083 # Find a prefix match if no exact match found.
1084 matched_mid = [x for x in clients if x.startswith(mid)]
1085 if not matched_mid:
1086 raise RuntimeError('select: client %s does not exist' % mid)
1087 if len(matched_mid) > 1:
1088 raise RuntimeError('select: multiple client matched %r' % mid)
1089 mid = matched_mid[0]
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001090
1091 self._selected_mid = mid
1092 if store:
1093 self._server.SelectClient(mid)
1094 print('Client %s selected' % mid)
1095
1096 @Command('shell', 'open a shell or execute a shell command', [
1097 Arg('command', metavar='CMD', nargs='?', help='command to execute')])
1098 def Shell(self, command=None):
1099 if command is None:
1100 command = []
1101 self.CheckClient()
1102
1103 headers = []
1104 if self._state.username is not None and self._state.password is not None:
1105 headers.append(BasicAuthHeader(self._state.username,
1106 self._state.password))
1107
1108 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
Peter Shihe03450b2017-06-14 14:02:08 +08001109 if command:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001110 cmd = ' '.join(command)
Peter Shihe03450b2017-06-14 14:02:08 +08001111 ws = ShellWebSocketClient(
1112 self._state, sys.stdout,
1113 scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
1114 self._state.host, self._state.port,
1115 urllib2.quote(self._selected_mid), urllib2.quote(cmd)),
1116 headers=headers)
1117 else:
1118 ws = TerminalWebSocketClient(
1119 self._state, self._selected_mid, self._escape,
1120 scheme + '%s:%d/api/agent/tty/%s' % (
1121 self._state.host, self._state.port,
1122 urllib2.quote(self._selected_mid)),
1123 headers=headers)
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001124 try:
1125 ws.connect()
1126 ws.run()
1127 except socket.error as e:
1128 if e.errno == 32: # Broken pipe
1129 pass
1130 else:
1131 raise
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001132
1133 @Command('push', 'push a file or directory to remote', [
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001134 Arg('srcs', nargs='+', metavar='SOURCE'),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001135 Arg('dst', metavar='DESTINATION')])
1136 def Push(self, args):
1137 self.CheckClient()
1138
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001139 @AutoRetry('push', _RETRY_TIMES)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001140 def _push(src, dst):
1141 src_base = os.path.basename(src)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001142
1143 # Local file is a link
1144 if os.path.islink(src):
1145 pbar = ProgressBar(src_base)
1146 link_path = os.readlink(src)
1147 self.CheckOutput('mkdir -p %(dirname)s; '
1148 'if [ -d "%(dst)s" ]; then '
1149 'ln -sf "%(link_path)s" "%(dst)s/%(link_name)s"; '
1150 'else ln -sf "%(link_path)s" "%(dst)s"; fi' %
1151 dict(dirname=os.path.dirname(dst),
1152 link_path=link_path, dst=dst,
1153 link_name=src_base))
1154 pbar.End()
1155 return
1156
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001157 mode = '0%o' % (0x1FF & os.stat(src).st_mode)
1158 url = ('%s:%d/api/agent/upload/%s?dest=%s&perm=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001159 (self._state.host, self._state.port,
1160 urllib2.quote(self._selected_mid), dst, mode))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001161 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001162 UrlOpen(self._state, url + '&filename=%s' % src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001163 except urllib2.HTTPError as e:
1164 msg = json.loads(e.read()).get('error', None)
1165 raise RuntimeError('push: %s' % msg)
1166
1167 pbar = ProgressBar(src_base)
1168 self._HTTPPostFile(url, src, pbar.SetProgress,
1169 self._state.username, self._state.password)
1170 pbar.End()
1171
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001172 def _push_single_target(src, dst):
1173 if os.path.isdir(src):
1174 dst_exists = ast.literal_eval(self.CheckOutput(
1175 'stat %s >/dev/null 2>&1 && echo True || echo False' % dst))
1176 for root, unused_x, files in os.walk(src):
1177 # If destination directory does not exist, we should strip the first
1178 # layer of directory. For example: src_dir contains a single file 'A'
1179 #
1180 # push src_dir dest_dir
1181 #
1182 # If dest_dir exists, the resulting directory structure should be:
1183 # dest_dir/src_dir/A
1184 # If dest_dir does not exist, the resulting directory structure should
1185 # be:
1186 # dest_dir/A
1187 dst_root = root if dst_exists else root[len(src):].lstrip('/')
1188 for name in files:
1189 _push(os.path.join(root, name),
1190 os.path.join(dst, dst_root, name))
1191 else:
1192 _push(src, dst)
1193
1194 if len(args.srcs) > 1:
1195 dst_type = self.CheckOutput('stat \'%s\' --printf \'%%F\' '
1196 '2>/dev/null' % args.dst).strip()
1197 if not dst_type:
1198 raise RuntimeError('push: %s: No such file or directory' % args.dst)
1199 if dst_type != 'directory':
1200 raise RuntimeError('push: %s: Not a directory' % args.dst)
1201
1202 for src in args.srcs:
1203 if not os.path.exists(src):
1204 raise RuntimeError('push: can not stat "%s": no such file or directory'
1205 % src)
1206 if not os.access(src, os.R_OK):
1207 raise RuntimeError('push: can not open "%s" for reading' % src)
1208
1209 _push_single_target(src, args.dst)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001210
1211 @Command('pull', 'pull a file or directory from remote', [
1212 Arg('src', metavar='SOURCE'),
1213 Arg('dst', metavar='DESTINATION', default='.', nargs='?')])
1214 def Pull(self, args):
1215 self.CheckClient()
1216
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001217 @AutoRetry('pull', _RETRY_TIMES)
1218 def _pull(src, dst, ftype, perm=0644, link=None):
1219 try:
1220 os.makedirs(os.path.dirname(dst))
1221 except Exception:
1222 pass
1223
1224 src_base = os.path.basename(src)
1225
1226 # Remote file is a link
1227 if ftype == 'l':
1228 pbar = ProgressBar(src_base)
1229 if os.path.exists(dst):
1230 os.remove(dst)
1231 os.symlink(link, dst)
1232 pbar.End()
1233 return
1234
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001235 url = ('%s:%d/api/agent/download/%s?filename=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001236 (self._state.host, self._state.port,
1237 urllib2.quote(self._selected_mid), urllib2.quote(src)))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001238 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001239 h = UrlOpen(self._state, url)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001240 except urllib2.HTTPError as e:
1241 msg = json.loads(e.read()).get('error', 'unkown error')
1242 raise RuntimeError('pull: %s' % msg)
1243 except KeyboardInterrupt:
1244 return
1245
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001246 pbar = ProgressBar(src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001247 with open(dst, 'w') as f:
1248 os.fchmod(f.fileno(), perm)
1249 total_size = int(h.headers.get('Content-Length'))
1250 downloaded_size = 0
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001251
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001252 while True:
1253 data = h.read(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +08001254 if not data:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001255 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001256 downloaded_size += len(data)
1257 pbar.SetProgress(float(downloaded_size) * 100 / total_size,
1258 downloaded_size)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001259 f.write(data)
1260 pbar.End()
1261
1262 # Use find to get a listing of all files under a root directory. The 'stat'
1263 # command is used to retrieve the filename and it's filemode.
1264 output = self.CheckOutput(
1265 'cd $HOME; '
1266 'stat "%(src)s" >/dev/null && '
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001267 'find "%(src)s" \'(\' -type f -o -type l \')\' '
1268 '-printf \'%%m\t%%p\t%%y\t%%l\n\''
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001269 % {'src': args.src})
1270
1271 # We got error from the stat command
1272 if output.startswith('stat: '):
1273 sys.stderr.write(output)
1274 return
1275
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001276 entries = output.strip('\n').split('\n')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001277 common_prefix = os.path.dirname(args.src)
1278
1279 if len(entries) == 1:
1280 entry = entries[0]
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001281 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001282 if os.path.isdir(args.dst):
1283 dst = os.path.join(args.dst, os.path.basename(src_path))
1284 else:
1285 dst = args.dst
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001286 _pull(src_path, dst, ftype, int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001287 else:
1288 if not os.path.exists(args.dst):
1289 common_prefix = args.src
1290
1291 for entry in entries:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001292 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001293 rel_dst = src_path[len(common_prefix):].lstrip('/')
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001294 _pull(src_path, os.path.join(args.dst, rel_dst), ftype,
1295 int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001296
1297 @Command('forward', 'forward remote port to local port', [
1298 Arg('--list', dest='list_all', action='store_true', default=False,
1299 help='list all port forwarding sessions'),
1300 Arg('--remove', metavar='LOCAL_PORT', dest='remove', type=int,
1301 default=None,
1302 help='remove port forwarding for local port LOCAL_PORT'),
1303 Arg('--remove-all', dest='remove_all', action='store_true',
1304 default=False, help='remove all port forwarding'),
1305 Arg('remote', metavar='REMOTE_PORT', type=int, nargs='?'),
1306 Arg('local', metavar='LOCAL_PORT', type=int, nargs='?')])
1307 def Forward(self, args):
1308 if args.list_all:
1309 max_len = 10
Peter Shihf84a8972017-06-19 15:18:24 +08001310 if self._state.forwards:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001311 max_len = max([len(v[0]) for v in self._state.forwards.values()])
1312
1313 print('%-*s %-8s %-8s' % (max_len, 'Client', 'Remote', 'Local'))
1314 for local in sorted(self._state.forwards.keys()):
1315 value = self._state.forwards[local]
1316 print('%-*s %-8s %-8s' % (max_len, value[0], value[1], local))
1317 return
1318
1319 if args.remove_all:
1320 self._server.RemoveAllForward()
1321 return
1322
1323 if args.remove:
1324 self._server.RemoveForward(args.remove)
1325 return
1326
1327 self.CheckClient()
1328
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001329 if args.remote is None:
1330 raise RuntimeError('remote port not specified')
1331
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001332 if args.local is None:
1333 args.local = args.remote
1334 remote = int(args.remote)
1335 local = int(args.local)
1336
1337 def HandleConnection(conn):
1338 headers = []
1339 if self._state.username is not None and self._state.password is not None:
1340 headers.append(BasicAuthHeader(self._state.username,
1341 self._state.password))
1342
1343 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1344 ws = ForwarderWebSocketClient(
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001345 self._state, conn,
Peter Shihe03450b2017-06-14 14:02:08 +08001346 scheme + '%s:%d/api/agent/forward/%s?port=%d' % (
1347 self._state.host, self._state.port,
1348 urllib2.quote(self._selected_mid), remote),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001349 headers=headers)
1350 try:
1351 ws.connect()
1352 ws.run()
1353 except Exception as e:
1354 print('error: %s' % e)
1355 finally:
1356 ws.close()
1357
1358 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1359 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1360 server.bind(('0.0.0.0', local))
1361 server.listen(5)
1362
1363 pid = os.fork()
1364 if pid == 0:
1365 while True:
1366 conn, unused_addr = server.accept()
1367 t = threading.Thread(target=HandleConnection, args=(conn,))
1368 t.daemon = True
1369 t.start()
1370 else:
1371 self._server.AddForward(self._selected_mid, remote, local, pid)
1372
1373
1374def main():
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001375 # Setup logging format
1376 logger = logging.getLogger()
1377 logger.setLevel(logging.INFO)
1378 handler = logging.StreamHandler()
1379 formatter = logging.Formatter('%(asctime)s %(message)s', '%Y/%m/%d %H:%M:%S')
1380 handler.setFormatter(formatter)
1381 logger.addHandler(handler)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001382
1383 # Add DaemonState to JSONRPC lib classes
1384 Config.instance().classes.add(DaemonState)
1385
1386 ovl = OverlordCLIClient()
1387 try:
1388 ovl.Main()
1389 except KeyboardInterrupt:
1390 print('Ctrl-C received, abort')
1391 except Exception as e:
1392 print('error: %s' % e)
1393
1394
1395if __name__ == '__main__':
1396 main()