blob: 937028c4bdb45245326ec496ee8a98abc8b8c402 [file] [log] [blame]
Mike Frysinger63bb3c72019-09-01 15:16:26 -04001#!/usr/bin/env python2
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08002# Copyright 2015 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6from __future__ import print_function
7
8import argparse
9import ast
10import base64
11import fcntl
Peter Shih13e78c52018-01-23 12:57:07 +080012import functools
Wei-Ning Huangba768ab2016-02-07 14:38:06 +080013import getpass
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080014import hashlib
15import httplib
16import json
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080017import logging
18import os
19import re
20import select
21import signal
22import socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080023import ssl
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080024import StringIO
25import struct
26import subprocess
27import sys
28import tempfile
29import termios
30import threading
31import time
32import tty
Peter Shih99b73ec2017-06-16 17:54:15 +080033import unicodedata # required by pyinstaller, pylint: disable=unused-import
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080034import urllib2
35import urlparse
36
Peter Shih99b73ec2017-06-16 17:54:15 +080037import jsonrpclib
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080038from jsonrpclib.config import Config
Peter Shih99b73ec2017-06-16 17:54:15 +080039from jsonrpclib.SimpleJSONRPCServer import SimpleJSONRPCServer
Yilin Yang8cc5dfb2019-10-22 15:58:53 +080040from six.moves import input
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080041from ws4py.client import WebSocketBaseClient
Peter Shih99b73ec2017-06-16 17:54:15 +080042import yaml
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080043
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080044
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080045_CERT_DIR = os.path.expanduser('~/.config/ovl')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080046
47_ESCAPE = '~'
48_BUFSIZ = 8192
49_OVERLORD_PORT = 4455
50_OVERLORD_HTTP_PORT = 9000
51_OVERLORD_CLIENT_DAEMON_PORT = 4488
52_OVERLORD_CLIENT_DAEMON_RPC_ADDR = ('127.0.0.1', _OVERLORD_CLIENT_DAEMON_PORT)
53
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080054_CONNECT_TIMEOUT = 3
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +080055_DEFAULT_HTTP_TIMEOUT = 30
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080056_LIST_CACHE_TIMEOUT = 2
57_DEFAULT_TERMINAL_WIDTH = 80
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +080058_RETRY_TIMES = 3
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080059
60# echo -n overlord | md5sum
61_HTTP_BOUNDARY_MAGIC = '9246f080c855a69012707ab53489b921'
62
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +080063# Terminal resize control
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080064_CONTROL_START = 128
65_CONTROL_END = 129
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +080066
67# Stream control
68_STDIN_CLOSED = '##STDIN_CLOSED##'
69
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080070_SSH_CONTROL_SOCKET_PREFIX = os.path.join(tempfile.gettempdir(),
71 'ovl-ssh-control-')
72
Wei-Ning Huang47c79b82016-05-24 01:24:46 +080073_TLS_CERT_FAILED_WARNING = """
74@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
75@ WARNING: REMOTE HOST VERIFICATION HAS FAILED! @
76@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
77IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
78Someone could be eavesdropping on you right now (man-in-the-middle attack)!
79It is also possible that the server is using a self-signed certificate.
80The fingerprint for the TLS host certificate sent by the remote host is
81
82%s
83
84Do you want to trust this certificate and proceed? [Y/n] """
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080085
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080086_TLS_CERT_CHANGED_WARNING = """
87@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
88@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
89@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
90IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
91Someone could be eavesdropping on you right now (man-in-the-middle attack)!
Wei-Ning Huang47c79b82016-05-24 01:24:46 +080092It is also possible that the TLS host certificate has just been changed.
93The fingerprint for the TLS host certificate sent by the remote host is
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080094
95%s
96
97Remove '%s' if you still want to proceed.
98SSL Certificate verification failed."""
99
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800100
101def GetVersionDigest():
102 """Return the sha1sum of the current executing script."""
Wei-Ning Huang31609662016-08-11 00:22:25 +0800103 # Check python script by default
104 filename = __file__
105
106 # If we are running from a frozen binary, we should calculate the checksum
107 # against that binary instead of the python script.
108 # See: https://pyinstaller.readthedocs.io/en/stable/runtime-information.html
109 if getattr(sys, 'frozen', False):
110 filename = sys.executable
111
112 with open(filename, 'r') as f:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800113 return hashlib.sha1(f.read()).hexdigest()
114
115
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800116def GetTLSCertPath(host):
117 return os.path.join(_CERT_DIR, '%s.cert' % host)
118
119
120def UrlOpen(state, url):
121 """Wrapper for urllib2.urlopen.
122
123 It selects correct HTTP scheme according to self._state.ssl, add HTTP
124 basic auth headers, and add specify correct SSL context.
125 """
126 url = MakeRequestUrl(state, url)
127 request = urllib2.Request(url)
128 if state.username is not None and state.password is not None:
129 request.add_header(*BasicAuthHeader(state.username, state.password))
130 return urllib2.urlopen(request, timeout=_DEFAULT_HTTP_TIMEOUT,
131 context=state.ssl_context)
132
133
134def GetTLSCertificateSHA1Fingerprint(cert_pem):
135 beg = cert_pem.index('\n')
136 end = cert_pem.rindex('\n', 0, len(cert_pem) - 2)
137 cert_pem = cert_pem[beg:end] # Remove BEGIN/END CERTIFICATE boundary
138 cert_der = base64.b64decode(cert_pem)
139 return hashlib.sha1(cert_der).hexdigest()
140
141
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800142def KillGraceful(pid, wait_secs=1):
143 """Kill a process gracefully by first sending SIGTERM, wait for some time,
144 then send SIGKILL to make sure it's killed."""
145 try:
146 os.kill(pid, signal.SIGTERM)
147 time.sleep(wait_secs)
148 os.kill(pid, signal.SIGKILL)
149 except OSError:
150 pass
151
152
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800153def AutoRetry(action_name, retries):
154 """Decorator for retry function call."""
155 def Wrap(func):
Peter Shih13e78c52018-01-23 12:57:07 +0800156 @functools.wraps(func)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800157 def Loop(*args, **kwargs):
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800158 for unused_i in range(retries):
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800159 try:
160 func(*args, **kwargs)
161 except Exception as e:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800162 print('error: %s: %s: retrying ...' % (args[0], e))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800163 else:
164 break
165 else:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800166 print('error: failed to %s %s' % (action_name, args[0]))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800167 return Loop
168 return Wrap
169
170
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800171def BasicAuthHeader(user, password):
172 """Return HTTP basic auth header."""
173 credential = base64.b64encode('%s:%s' % (user, password))
174 return ('Authorization', 'Basic %s' % credential)
175
176
177def GetTerminalSize():
178 """Retrieve terminal window size."""
179 ws = struct.pack('HHHH', 0, 0, 0, 0)
180 ws = fcntl.ioctl(0, termios.TIOCGWINSZ, ws)
181 lines, columns, unused_x, unused_y = struct.unpack('HHHH', ws)
182 return lines, columns
183
184
185def MakeRequestUrl(state, url):
186 return 'http%s://%s' % ('s' if state.ssl else '', url)
187
188
189class ProgressBar(object):
190 SIZE_WIDTH = 11
191 SPEED_WIDTH = 10
192 DURATION_WIDTH = 6
193 PERCENTAGE_WIDTH = 8
194
195 def __init__(self, name):
196 self._start_time = time.time()
197 self._name = name
198 self._size = 0
199 self._width = 0
200 self._name_width = 0
201 self._name_max = 0
202 self._stat_width = 0
203 self._max = 0
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800204 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800205 self.SetProgress(0)
206
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800207 def _CalculateSize(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800208 self._width = GetTerminalSize()[1] or _DEFAULT_TERMINAL_WIDTH
209 self._name_width = int(self._width * 0.3)
210 self._name_max = self._name_width
211 self._stat_width = self.SIZE_WIDTH + self.SPEED_WIDTH + self.DURATION_WIDTH
212 self._max = (self._width - self._name_width - self._stat_width -
213 self.PERCENTAGE_WIDTH)
214
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800215 def _SizeToHuman(self, size_in_bytes):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800216 if size_in_bytes < 1024:
217 unit = 'B'
218 value = size_in_bytes
219 elif size_in_bytes < 1024 ** 2:
220 unit = 'KiB'
221 value = size_in_bytes / 1024.0
222 elif size_in_bytes < 1024 ** 3:
223 unit = 'MiB'
224 value = size_in_bytes / (1024.0 ** 2)
225 elif size_in_bytes < 1024 ** 4:
226 unit = 'GiB'
227 value = size_in_bytes / (1024.0 ** 3)
228 return ' %6.1f %3s' % (value, unit)
229
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800230 def _SpeedToHuman(self, speed_in_bs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800231 if speed_in_bs < 1024:
232 unit = 'B'
233 value = speed_in_bs
234 elif speed_in_bs < 1024 ** 2:
235 unit = 'K'
236 value = speed_in_bs / 1024.0
237 elif speed_in_bs < 1024 ** 3:
238 unit = 'M'
239 value = speed_in_bs / (1024.0 ** 2)
240 elif speed_in_bs < 1024 ** 4:
241 unit = 'G'
242 value = speed_in_bs / (1024.0 ** 3)
243 return ' %6.1f%s/s' % (value, unit)
244
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800245 def _DurationToClock(self, duration):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800246 return ' %02d:%02d' % (duration / 60, duration % 60)
247
248 def SetProgress(self, percentage, size=None):
249 current_width = GetTerminalSize()[1]
250 if self._width != current_width:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800251 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800252
253 if size is not None:
254 self._size = size
255
256 elapse_time = time.time() - self._start_time
257 speed = self._size / float(elapse_time)
258
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800259 size_str = self._SizeToHuman(self._size)
260 speed_str = self._SpeedToHuman(speed)
261 elapse_str = self._DurationToClock(elapse_time)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800262
263 width = int(self._max * percentage / 100.0)
264 sys.stdout.write(
265 '%*s' % (- self._name_max,
266 self._name if len(self._name) <= self._name_max else
267 self._name[:self._name_max - 4] + ' ...') +
268 size_str + speed_str + elapse_str +
269 ((' [' + '#' * width + ' ' * (self._max - width) + ']' +
270 '%4d%%' % int(percentage)) if self._max > 2 else '') + '\r')
271 sys.stdout.flush()
272
273 def End(self):
274 self.SetProgress(100.0)
275 sys.stdout.write('\n')
276 sys.stdout.flush()
277
278
279class DaemonState(object):
280 """DaemonState is used for storing Overlord state info."""
281 def __init__(self):
282 self.version_sha1sum = GetVersionDigest()
283 self.host = None
284 self.port = None
285 self.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800286 self.ssl_self_signed = False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800287 self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800288 self.ssh = False
289 self.orig_host = None
290 self.ssh_pid = None
291 self.username = None
292 self.password = None
293 self.selected_mid = None
294 self.forwards = {}
295 self.listing = []
296 self.last_list = 0
297
298
299class OverlordClientDaemon(object):
300 """Overlord Client Daemon."""
301 def __init__(self):
302 self._state = DaemonState()
303 self._server = None
304
305 def Start(self):
306 self.StartRPCServer()
307
308 def StartRPCServer(self):
309 self._server = SimpleJSONRPCServer(_OVERLORD_CLIENT_DAEMON_RPC_ADDR,
310 logRequests=False)
311 exports = [
312 (self.State, 'State'),
313 (self.Ping, 'Ping'),
314 (self.GetPid, 'GetPid'),
315 (self.Connect, 'Connect'),
316 (self.Clients, 'Clients'),
317 (self.SelectClient, 'SelectClient'),
318 (self.AddForward, 'AddForward'),
319 (self.RemoveForward, 'RemoveForward'),
320 (self.RemoveAllForward, 'RemoveAllForward'),
321 ]
322 for func, name in exports:
323 self._server.register_function(func, name)
324
325 pid = os.fork()
326 if pid == 0:
Peter Shih4d55ded2017-07-03 17:19:01 +0800327 for fd in range(3):
328 os.close(fd)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800329 self._server.serve_forever()
330
331 @staticmethod
332 def GetRPCServer():
333 """Returns the Overlord client daemon RPC server."""
334 server = jsonrpclib.Server('http://%s:%d' %
335 _OVERLORD_CLIENT_DAEMON_RPC_ADDR)
336 try:
337 server.Ping()
338 except Exception:
339 return None
340 return server
341
342 def State(self):
343 return self._state
344
345 def Ping(self):
346 return True
347
348 def GetPid(self):
349 return os.getpid()
350
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800351 def _GetJSON(self, path):
352 url = '%s:%d%s' % (self._state.host, self._state.port, path)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800353 return json.loads(UrlOpen(self._state, url).read())
354
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800355 def _TLSEnabled(self):
356 """Determine if TLS is enabled on given server address."""
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800357 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
358 try:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800359 # Allow any certificate since we only want to check if server talks TLS.
360 context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
361 context.verify_mode = ssl.CERT_NONE
362
363 sock = context.wrap_socket(sock, server_hostname=self._state.host)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800364 sock.settimeout(_CONNECT_TIMEOUT)
365 sock.connect((self._state.host, self._state.port))
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800366 return True
Wei-Ning Huangecc80b82016-07-01 16:55:10 +0800367 except ssl.SSLError:
368 return False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800369 except socket.error: # Connect refused or timeout
370 raise
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800371 except Exception:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800372 return False # For whatever reason above failed, assume False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800373
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800374 def _CheckTLSCertificate(self, check_hostname=True):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800375 """Check TLS certificate.
376
377 Returns:
378 A tupple (check_result, if_certificate_is_loaded)
379 """
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800380 def _DoConnect(context):
381 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
382 try:
383 sock.settimeout(_CONNECT_TIMEOUT)
384 sock = context.wrap_socket(sock, server_hostname=self._state.host)
385 sock.connect((self._state.host, self._state.port))
386 except ssl.SSLError:
387 return False
388 finally:
389 sock.close()
390
391 # Save SSLContext for future use.
392 self._state.ssl_context = context
393 return True
394
395 # First try connect with built-in certificates
396 tls_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
397 if _DoConnect(tls_context):
398 return True
399
400 # Try with already saved certificate, if any.
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800401 tls_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
402 tls_context.verify_mode = ssl.CERT_REQUIRED
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800403 tls_context.check_hostname = check_hostname
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800404
405 tls_cert_path = GetTLSCertPath(self._state.host)
406 if os.path.exists(tls_cert_path):
407 tls_context.load_verify_locations(tls_cert_path)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800408 self._state.ssl_self_signed = True
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800409
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800410 return _DoConnect(tls_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800411
412 def Connect(self, host, port=_OVERLORD_HTTP_PORT, ssh_pid=None,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800413 username=None, password=None, orig_host=None,
414 check_hostname=True):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800415 self._state.username = username
416 self._state.password = password
417 self._state.host = host
418 self._state.port = port
419 self._state.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800420 self._state.ssl_self_signed = False
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800421 self._state.orig_host = orig_host
422 self._state.ssh_pid = ssh_pid
423 self._state.selected_mid = None
424
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800425 tls_enabled = self._TLSEnabled()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800426 if tls_enabled:
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800427 result = self._CheckTLSCertificate(check_hostname)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800428 if not result:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800429 if self._state.ssl_self_signed:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800430 return ('SSLCertificateChanged', ssl.get_server_certificate(
431 (self._state.host, self._state.port)))
432 else:
433 return ('SSLVerifyFailed', ssl.get_server_certificate(
434 (self._state.host, self._state.port)))
435
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800436 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800437 self._state.ssl = tls_enabled
438 UrlOpen(self._state, '%s:%d' % (host, port))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800439 except urllib2.HTTPError as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800440 return ('HTTPError', e.getcode(), str(e), e.read().strip())
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800441 except Exception as e:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800442 return str(e)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800443 else:
444 return True
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800445
446 def Clients(self):
447 if time.time() - self._state.last_list <= _LIST_CACHE_TIMEOUT:
448 return self._state.listing
449
Peter Shihcf0f3b22017-06-19 15:59:22 +0800450 self._state.listing = self._GetJSON('/api/agents/list')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800451 self._state.last_list = time.time()
452 return self._state.listing
453
454 def SelectClient(self, mid):
455 self._state.selected_mid = mid
456
457 def AddForward(self, mid, remote, local, pid):
458 self._state.forwards[local] = (mid, remote, pid)
459
460 def RemoveForward(self, local_port):
461 try:
462 unused_mid, unused_remote, pid = self._state.forwards[local_port]
463 KillGraceful(pid)
464 del self._state.forwards[local_port]
465 except (KeyError, OSError):
466 pass
467
468 def RemoveAllForward(self):
469 for unused_mid, unused_remote, pid in self._state.forwards.values():
470 try:
471 KillGraceful(pid)
472 except OSError:
473 pass
474 self._state.forwards = {}
475
476
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800477class SSLEnabledWebSocketBaseClient(WebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800478 def __init__(self, state, *args, **kwargs):
479 cafile = ssl.get_default_verify_paths().openssl_cafile
480 # For some system / distribution, python can not detect system cafile path.
481 # In such case we fallback to the default path.
482 if not os.path.exists(cafile):
483 cafile = '/etc/ssl/certs/ca-certificates.crt'
484
485 if state.ssl_self_signed:
486 cafile = GetTLSCertPath(state.host)
487
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800488 ssl_options = {
489 'cert_reqs': ssl.CERT_REQUIRED,
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800490 'ca_certs': cafile
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800491 }
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800492 # ws4py does not allow you to specify SSLContext, but rather passing in the
493 # argument of ssl.wrap_socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800494 super(SSLEnabledWebSocketBaseClient, self).__init__(
495 ssl_options=ssl_options, *args, **kwargs)
496
497
498class TerminalWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800499 def __init__(self, state, mid, escape, *args, **kwargs):
500 super(TerminalWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800501 self._mid = mid
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800502 self._escape = escape
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800503 self._stdin_fd = sys.stdin.fileno()
504 self._old_termios = None
505
506 def handshake_ok(self):
507 pass
508
509 def opened(self):
510 nonlocals = {'size': (80, 40)}
511
512 def _ResizeWindow():
513 size = GetTerminalSize()
514 if size != nonlocals['size']: # Size not changed, ignore
515 control = {'command': 'resize', 'params': list(size)}
516 payload = chr(_CONTROL_START) + json.dumps(control) + chr(_CONTROL_END)
517 nonlocals['size'] = size
518 try:
519 self.send(payload, binary=True)
520 except Exception:
521 pass
522
523 def _FeedInput():
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800524 self._old_termios = termios.tcgetattr(self._stdin_fd)
525 tty.setraw(self._stdin_fd)
526
527 READY, ENTER_PRESSED, ESCAPE_PRESSED = range(3)
528
529 try:
530 state = READY
531 while True:
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800532 # Check if terminal is resized
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800533 _ResizeWindow()
534
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800535 ch = sys.stdin.read(1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800536
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800537 # Scan for escape sequence
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800538 if self._escape:
539 if state == READY:
540 state = ENTER_PRESSED if ch == chr(0x0d) else READY
541 elif state == ENTER_PRESSED:
542 state = ESCAPE_PRESSED if ch == self._escape else READY
543 elif state == ESCAPE_PRESSED:
544 if ch == '.':
545 self.close()
546 break
547 else:
548 state = READY
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800549
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800550 self.send(ch)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800551 except (KeyboardInterrupt, RuntimeError):
552 pass
553
554 t = threading.Thread(target=_FeedInput)
555 t.daemon = True
556 t.start()
557
558 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800559 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800560 termios.tcsetattr(self._stdin_fd, termios.TCSANOW, self._old_termios)
561 print('Connection to %s closed.' % self._mid)
562
563 def received_message(self, msg):
564 if msg.is_binary:
565 sys.stdout.write(msg.data)
566 sys.stdout.flush()
567
568
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800569class ShellWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800570 def __init__(self, state, output, *args, **kwargs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800571 """Constructor.
572
573 Args:
574 output: output file object.
575 """
576 self.output = output
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800577 super(ShellWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800578
579 def handshake_ok(self):
580 pass
581
582 def opened(self):
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800583 def _FeedInput():
584 try:
585 while True:
586 data = sys.stdin.read(1)
587
Peter Shihf84a8972017-06-19 15:18:24 +0800588 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800589 self.send(_STDIN_CLOSED * 2)
590 break
591 self.send(data, binary=True)
592 except (KeyboardInterrupt, RuntimeError):
593 pass
594
595 t = threading.Thread(target=_FeedInput)
596 t.daemon = True
597 t.start()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800598
599 def closed(self, code, reason=None):
600 pass
601
602 def received_message(self, msg):
603 if msg.is_binary:
604 self.output.write(msg.data)
605 self.output.flush()
606
607
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800608class ForwarderWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800609 def __init__(self, state, sock, *args, **kwargs):
610 super(ForwarderWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800611 self._sock = sock
612 self._stop = threading.Event()
613
614 def handshake_ok(self):
615 pass
616
617 def opened(self):
618 def _FeedInput():
619 try:
620 self._sock.setblocking(False)
621 while True:
622 rd, unused_w, unused_x = select.select([self._sock], [], [], 0.5)
623 if self._stop.is_set():
624 break
625 if self._sock in rd:
626 data = self._sock.recv(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +0800627 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800628 self.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800629 break
630 self.send(data, binary=True)
631 except Exception:
632 pass
633 finally:
634 self._sock.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800635
636 t = threading.Thread(target=_FeedInput)
637 t.daemon = True
638 t.start()
639
640 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800641 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800642 self._stop.set()
643 sys.exit(0)
644
645 def received_message(self, msg):
646 if msg.is_binary:
647 self._sock.send(msg.data)
648
649
650def Arg(*args, **kwargs):
651 return (args, kwargs)
652
653
654def Command(command, help_msg=None, args=None):
655 """Decorator for adding argparse parameter for a method."""
656 if args is None:
657 args = []
658 def WrapFunc(func):
Peter Shih13e78c52018-01-23 12:57:07 +0800659 @functools.wraps(func)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800660 def Wrapped(*args, **kwargs):
661 return func(*args, **kwargs)
Peter Shih99b73ec2017-06-16 17:54:15 +0800662 # pylint: disable=protected-access
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800663 Wrapped.__arg_attr = {'command': command, 'help': help_msg, 'args': args}
664 return Wrapped
665 return WrapFunc
666
667
668def ParseMethodSubCommands(cls):
669 """Decorator for a class using the @Command decorator.
670
671 This decorator retrieve command info from each method and append it in to the
672 SUBCOMMANDS class variable, which is later used to construct parser.
673 """
674 for unused_key, method in cls.__dict__.iteritems():
675 if hasattr(method, '__arg_attr'):
Peter Shih99b73ec2017-06-16 17:54:15 +0800676 # pylint: disable=protected-access
677 cls.SUBCOMMANDS.append(method.__arg_attr)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800678 return cls
679
680
681@ParseMethodSubCommands
682class OverlordCLIClient(object):
683 """Overlord command line interface client."""
684
685 SUBCOMMANDS = []
686
687 def __init__(self):
688 self._parser = self._BuildParser()
689 self._selected_mid = None
690 self._server = None
691 self._state = None
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800692 self._escape = None
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800693
694 def _BuildParser(self):
695 root_parser = argparse.ArgumentParser(prog='ovl')
696 subparsers = root_parser.add_subparsers(help='sub-command')
697
698 root_parser.add_argument('-s', dest='selected_mid', action='store',
699 default=None,
700 help='select target to execute command on')
701 root_parser.add_argument('-S', dest='select_mid_before_action',
702 action='store_true', default=False,
703 help='select target before executing command')
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800704 root_parser.add_argument('-e', dest='escape', metavar='ESCAPE_CHAR',
705 action='store', default=_ESCAPE, type=str,
706 help='set shell escape character, \'none\' to '
707 'disable escape completely')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800708
709 for attr in self.SUBCOMMANDS:
710 parser = subparsers.add_parser(attr['command'], help=attr['help'])
711 parser.set_defaults(which=attr['command'])
712 for arg in attr['args']:
713 parser.add_argument(*arg[0], **arg[1])
714
715 return root_parser
716
717 def Main(self):
718 # We want to pass the rest of arguments after shell command directly to the
719 # function without parsing it.
720 try:
721 index = sys.argv.index('shell')
722 except ValueError:
723 args = self._parser.parse_args()
724 else:
725 args = self._parser.parse_args(sys.argv[1:index + 1])
726
727 command = args.which
728 self._selected_mid = args.selected_mid
729
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800730 if args.escape and args.escape != 'none':
731 self._escape = args.escape[0]
732
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800733 if command == 'start-server':
734 self.StartServer()
735 return
736 elif command == 'kill-server':
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800737 self.KillServer()
738 return
739
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800740 self.CheckDaemon()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800741 if command == 'status':
742 self.Status()
743 return
744 elif command == 'connect':
745 self.Connect(args)
746 return
747
748 # The following command requires connection to the server
749 self.CheckConnection()
750
751 if args.select_mid_before_action:
752 self.SelectClient(store=False)
753
754 if command == 'select':
755 self.SelectClient(args)
756 elif command == 'ls':
Peter Shih99b73ec2017-06-16 17:54:15 +0800757 self.ListClients(args)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800758 elif command == 'shell':
759 command = sys.argv[sys.argv.index('shell') + 1:]
760 self.Shell(command)
761 elif command == 'push':
762 self.Push(args)
763 elif command == 'pull':
764 self.Pull(args)
765 elif command == 'forward':
766 self.Forward(args)
767
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800768 def _SaveTLSCertificate(self, host, cert_pem):
769 try:
770 os.makedirs(_CERT_DIR)
771 except Exception:
772 pass
773 with open(GetTLSCertPath(host), 'w') as f:
774 f.write(cert_pem)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800775
776 def _HTTPPostFile(self, url, filename, progress=None, user=None, passwd=None):
777 """Perform HTTP POST and upload file to Overlord.
778
779 To minimize the external dependencies, we construct the HTTP post request
780 by ourselves.
781 """
782 url = MakeRequestUrl(self._state, url)
783 size = os.stat(filename).st_size
784 boundary = '-----------%s' % _HTTP_BOUNDARY_MAGIC
785 CRLF = '\r\n'
786 parse = urlparse.urlparse(url)
787
788 part_headers = [
789 '--' + boundary,
790 'Content-Disposition: form-data; name="file"; '
791 'filename="%s"' % os.path.basename(filename),
792 'Content-Type: application/octet-stream',
793 '', ''
794 ]
795 part_header = CRLF.join(part_headers)
796 end_part = CRLF + '--' + boundary + '--' + CRLF
797
798 content_length = len(part_header) + size + len(end_part)
799 if parse.scheme == 'http':
800 h = httplib.HTTP(parse.netloc)
801 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800802 h = httplib.HTTPS(parse.netloc, context=self._state.ssl_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800803
804 post_path = url[url.index(parse.netloc) + len(parse.netloc):]
805 h.putrequest('POST', post_path)
806 h.putheader('Content-Length', content_length)
807 h.putheader('Content-Type', 'multipart/form-data; boundary=%s' % boundary)
808
809 if user and passwd:
810 h.putheader(*BasicAuthHeader(user, passwd))
811 h.endheaders()
812 h.send(part_header)
813
814 count = 0
815 with open(filename, 'r') as f:
816 while True:
817 data = f.read(_BUFSIZ)
818 if not data:
819 break
820 count += len(data)
821 if progress:
822 progress(int(count * 100.0 / size), count)
823 h.send(data)
824
825 h.send(end_part)
826 progress(100)
827
828 if count != size:
829 logging.warning('file changed during upload, upload may be truncated.')
830
831 errcode, unused_x, unused_y = h.getreply()
832 return errcode == 200
833
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800834 def CheckDaemon(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800835 self._server = OverlordClientDaemon.GetRPCServer()
836 if self._server is None:
837 print('* daemon not running, starting it now on port %d ... *' %
838 _OVERLORD_CLIENT_DAEMON_PORT)
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800839 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800840
841 self._state = self._server.State()
842 sha1sum = GetVersionDigest()
843
844 if sha1sum != self._state.version_sha1sum:
845 print('ovl server is out of date. killing...')
846 KillGraceful(self._server.GetPid())
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800847 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800848
849 def GetSSHControlFile(self, host):
850 return _SSH_CONTROL_SOCKET_PREFIX + host
851
852 def SSHTunnel(self, user, host, port):
853 """SSH forward the remote overlord server.
854
855 Overlord server may not have port 9000 open to the public network, in such
856 case we can SSH forward the port to localhost.
857 """
858
859 control_file = self.GetSSHControlFile(host)
860 try:
861 os.unlink(control_file)
862 except Exception:
863 pass
864
865 subprocess.Popen([
866 'ssh', '-Nf',
867 '-M', # Enable master mode
868 '-S', control_file,
869 '-L', '9000:localhost:9000',
870 '-p', str(port),
871 '%s%s' % (user + '@' if user else '', host)
872 ]).wait()
873
874 p = subprocess.Popen([
875 'ssh',
876 '-S', control_file,
877 '-O', 'check', host,
878 ], stderr=subprocess.PIPE)
879 unused_stdout, stderr = p.communicate()
880
881 s = re.search(r'pid=(\d+)', stderr)
882 if s:
883 return int(s.group(1))
884
885 raise RuntimeError('can not establish ssh connection')
886
887 def CheckConnection(self):
888 if self._state.host is None:
889 raise RuntimeError('not connected to any server, abort')
890
891 try:
892 self._server.Clients()
893 except Exception:
894 raise RuntimeError('remote server disconnected, abort')
895
896 if self._state.ssh_pid is not None:
897 ret = subprocess.Popen(['kill', '-0', str(self._state.ssh_pid)],
898 stdout=subprocess.PIPE,
899 stderr=subprocess.PIPE).wait()
900 if ret != 0:
901 raise RuntimeError('ssh tunnel disconnected, please re-connect')
902
903 def CheckClient(self):
904 if self._selected_mid is None:
905 if self._state.selected_mid is None:
906 raise RuntimeError('No client is selected')
907 self._selected_mid = self._state.selected_mid
908
Peter Shihcf0f3b22017-06-19 15:59:22 +0800909 if not any(client['mid'] == self._selected_mid
910 for client in self._server.Clients()):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800911 raise RuntimeError('client %s disappeared' % self._selected_mid)
912
913 def CheckOutput(self, command):
914 headers = []
915 if self._state.username is not None and self._state.password is not None:
916 headers.append(BasicAuthHeader(self._state.username,
917 self._state.password))
918
919 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
920 sio = StringIO.StringIO()
Peter Shihe03450b2017-06-14 14:02:08 +0800921 ws = ShellWebSocketClient(
922 self._state, sio, scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
923 self._state.host, self._state.port,
924 urllib2.quote(self._selected_mid), urllib2.quote(command)),
925 headers=headers)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800926 ws.connect()
927 ws.run()
928 return sio.getvalue()
929
930 @Command('status', 'show Overlord connection status')
931 def Status(self):
932 if self._state.host is None:
933 print('Not connected to any host.')
934 else:
935 if self._state.ssh_pid is not None:
936 print('Connected to %s with SSH tunneling.' % self._state.orig_host)
937 else:
938 print('Connected to %s:%d.' % (self._state.host, self._state.port))
939
940 if self._selected_mid is None:
941 self._selected_mid = self._state.selected_mid
942
943 if self._selected_mid is None:
944 print('No client is selected.')
945 else:
946 print('Client %s selected.' % self._selected_mid)
947
948 @Command('connect', 'connect to Overlord server', [
949 Arg('host', metavar='HOST', type=str, default='localhost',
950 help='Overlord hostname/IP'),
951 Arg('port', metavar='PORT', type=int,
952 default=_OVERLORD_HTTP_PORT, help='Overlord port'),
953 Arg('-f', '--forward', dest='ssh_forward', default=False,
954 action='store_true',
955 help='connect with SSH forwarding to the host'),
956 Arg('-p', '--ssh-port', dest='ssh_port', default=22,
957 type=int, help='SSH server port for SSH forwarding'),
958 Arg('-l', '--ssh-login', dest='ssh_login', default='',
959 type=str, help='SSH server login name for SSH forwarding'),
960 Arg('-u', '--user', dest='user', default=None,
961 type=str, help='Overlord HTTP auth username'),
962 Arg('-w', '--passwd', dest='passwd', default=None, type=str,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800963 help='Overlord HTTP auth password'),
964 Arg('-i', '--no-check-hostname', dest='check_hostname',
965 default=True, action='store_false',
966 help='Ignore SSL cert hostname check')])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800967 def Connect(self, args):
968 ssh_pid = None
969 host = args.host
970 orig_host = args.host
971
972 if args.ssh_forward:
973 # Kill previous SSH tunnel
974 self.KillSSHTunnel()
975
976 ssh_pid = self.SSHTunnel(args.ssh_login, args.host, args.ssh_port)
977 host = 'localhost'
978
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800979 username_provided = args.user is not None
980 password_provided = args.passwd is not None
981 prompt = False
982
Peter Shih533566a2018-09-05 17:48:03 +0800983 for unused_i in range(3): # pylint: disable=too-many-nested-blocks
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800984 try:
985 if prompt:
986 if not username_provided:
Yilin Yang8cc5dfb2019-10-22 15:58:53 +0800987 args.user = input('Username: ')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800988 if not password_provided:
989 args.passwd = getpass.getpass('Password: ')
990
991 ret = self._server.Connect(host, args.port, ssh_pid, args.user,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800992 args.passwd, orig_host,
993 args.check_hostname)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800994 if isinstance(ret, list):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800995 if ret[0].startswith('SSL'):
996 cert_pem = ret[1]
997 fp = GetTLSCertificateSHA1Fingerprint(cert_pem)
998 fp_text = ':'.join([fp[i:i+2] for i in range(0, len(fp), 2)])
999
1000 if ret[0] == 'SSLCertificateChanged':
1001 print(_TLS_CERT_CHANGED_WARNING % (fp_text, GetTLSCertPath(host)))
1002 return
1003 elif ret[0] == 'SSLVerifyFailed':
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001004 print(_TLS_CERT_FAILED_WARNING % (fp_text), end='')
Yilin Yang8cc5dfb2019-10-22 15:58:53 +08001005 response = input()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001006 if response.lower() in ['y', 'ye', 'yes']:
1007 self._SaveTLSCertificate(host, cert_pem)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001008 print('TLS host Certificate trusted, you will not be prompted '
1009 'next time.\n')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001010 continue
1011 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001012 print('connection aborted.')
1013 return
1014 elif ret[0] == 'HTTPError':
1015 code, except_str, body = ret[1:]
1016 if code == 401:
1017 print('connect: %s' % body)
1018 prompt = True
1019 if not username_provided or not password_provided:
1020 continue
1021 else:
1022 break
1023 else:
1024 logging.error('%s; %s', except_str, body)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001025
1026 if ret is not True:
1027 print('can not connect to %s: %s' % (host, ret))
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001028 else:
1029 print('connection to %s:%d established.' % (host, args.port))
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001030 except Exception as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001031 logging.error(e)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001032 else:
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001033 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001034
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001035 @Command('start-server', 'start overlord CLI client server')
1036 def StartServer(self):
1037 self._server = OverlordClientDaemon.GetRPCServer()
1038 if self._server is None:
1039 OverlordClientDaemon().Start()
1040 time.sleep(1)
1041 self._server = OverlordClientDaemon.GetRPCServer()
1042 if self._server is not None:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001043 print('* daemon started successfully *\n')
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001044
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001045 @Command('kill-server', 'kill overlord CLI client server')
1046 def KillServer(self):
1047 self._server = OverlordClientDaemon.GetRPCServer()
1048 if self._server is None:
1049 return
1050
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001051 self._state = self._server.State()
1052
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001053 # Kill SSH Tunnel
1054 self.KillSSHTunnel()
1055
1056 # Kill server daemon
1057 KillGraceful(self._server.GetPid())
1058
1059 def KillSSHTunnel(self):
1060 if self._state.ssh_pid is not None:
1061 KillGraceful(self._state.ssh_pid)
1062
Peter Shihcf0f3b22017-06-19 15:59:22 +08001063 def _FilterClients(self, clients, prop_filters, mid=None):
1064 def _ClientPropertiesMatch(client, key, regex):
1065 try:
1066 return bool(re.search(regex, client['properties'][key]))
1067 except KeyError:
1068 return False
1069
1070 for prop_filter in prop_filters:
1071 key, sep, regex = prop_filter.partition('=')
1072 if not sep:
1073 # The filter doesn't contains =.
1074 raise ValueError('Invalid filter condition %r' % filter)
1075 clients = [c for c in clients if _ClientPropertiesMatch(c, key, regex)]
1076
1077 if mid is not None:
1078 client = next((c for c in clients if c['mid'] == mid), None)
1079 if client:
1080 return [client]
1081 clients = [c for c in clients if c['mid'].startswith(mid)]
1082 return clients
1083
1084 @Command('ls', 'list clients', [
1085 Arg('-f', '--filter', default=[], dest='filters', action='append',
1086 help=('Conditions to filter clients by properties. '
1087 'Should be in form "key=regex", where regex is the regular '
1088 'expression that should be found in the value. '
1089 'Multiple --filter arguments would be ANDed.')),
Peter Shih99b73ec2017-06-16 17:54:15 +08001090 Arg('-v', '--verbose', default=False, action='store_true',
1091 help='Print properties of each client.')
1092 ])
1093 def ListClients(self, args):
Peter Shihcf0f3b22017-06-19 15:59:22 +08001094 clients = self._FilterClients(self._server.Clients(), args.filters)
1095 for client in clients:
1096 if args.verbose:
Peter Shih99b73ec2017-06-16 17:54:15 +08001097 print(yaml.safe_dump(client, default_flow_style=False))
Peter Shihcf0f3b22017-06-19 15:59:22 +08001098 else:
1099 print(client['mid'])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001100
1101 @Command('select', 'select default client', [
Peter Shihcf0f3b22017-06-19 15:59:22 +08001102 Arg('-f', '--filter', default=[], dest='filters', action='append',
1103 help=('Conditions to filter clients by properties. '
1104 'Should be in form "key=regex", where regex is the regular '
1105 'expression that should be found in the value. '
1106 'Multiple --filter arguments would be ANDed.')),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001107 Arg('mid', metavar='mid', nargs='?', default=None)])
1108 def SelectClient(self, args=None, store=True):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001109 mid = args.mid if args is not None else None
Wei-Ning Huang6796a132017-06-22 14:46:32 +08001110 filters = args.filters if args is not None else []
1111 clients = self._FilterClients(self._server.Clients(), filters, mid=mid)
Peter Shihcf0f3b22017-06-19 15:59:22 +08001112
1113 if not clients:
1114 raise RuntimeError('select: client not found')
1115 elif len(clients) == 1:
1116 mid = clients[0]['mid']
1117 else:
1118 # This case would not happen when args.mid is specified.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001119 print('Select from the following clients:')
1120 for i, client in enumerate(clients):
Peter Shihcf0f3b22017-06-19 15:59:22 +08001121 print(' %d. %s' % (i + 1, client['mid']))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001122
1123 print('\nSelection: ', end='')
1124 try:
Yilin Yang8cc5dfb2019-10-22 15:58:53 +08001125 choice = int(input()) - 1
Peter Shihcf0f3b22017-06-19 15:59:22 +08001126 mid = clients[choice]['mid']
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001127 except ValueError:
1128 raise RuntimeError('select: invalid selection')
1129 except IndexError:
1130 raise RuntimeError('select: selection out of range')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001131
1132 self._selected_mid = mid
1133 if store:
1134 self._server.SelectClient(mid)
1135 print('Client %s selected' % mid)
1136
1137 @Command('shell', 'open a shell or execute a shell command', [
1138 Arg('command', metavar='CMD', nargs='?', help='command to execute')])
1139 def Shell(self, command=None):
1140 if command is None:
1141 command = []
1142 self.CheckClient()
1143
1144 headers = []
1145 if self._state.username is not None and self._state.password is not None:
1146 headers.append(BasicAuthHeader(self._state.username,
1147 self._state.password))
1148
1149 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
Peter Shihe03450b2017-06-14 14:02:08 +08001150 if command:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001151 cmd = ' '.join(command)
Peter Shihe03450b2017-06-14 14:02:08 +08001152 ws = ShellWebSocketClient(
1153 self._state, sys.stdout,
1154 scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
1155 self._state.host, self._state.port,
1156 urllib2.quote(self._selected_mid), urllib2.quote(cmd)),
1157 headers=headers)
1158 else:
1159 ws = TerminalWebSocketClient(
1160 self._state, self._selected_mid, self._escape,
1161 scheme + '%s:%d/api/agent/tty/%s' % (
1162 self._state.host, self._state.port,
1163 urllib2.quote(self._selected_mid)),
1164 headers=headers)
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001165 try:
1166 ws.connect()
1167 ws.run()
1168 except socket.error as e:
1169 if e.errno == 32: # Broken pipe
1170 pass
1171 else:
1172 raise
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001173
1174 @Command('push', 'push a file or directory to remote', [
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001175 Arg('srcs', nargs='+', metavar='SOURCE'),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001176 Arg('dst', metavar='DESTINATION')])
1177 def Push(self, args):
1178 self.CheckClient()
1179
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001180 @AutoRetry('push', _RETRY_TIMES)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001181 def _push(src, dst):
1182 src_base = os.path.basename(src)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001183
1184 # Local file is a link
1185 if os.path.islink(src):
1186 pbar = ProgressBar(src_base)
1187 link_path = os.readlink(src)
1188 self.CheckOutput('mkdir -p %(dirname)s; '
1189 'if [ -d "%(dst)s" ]; then '
1190 'ln -sf "%(link_path)s" "%(dst)s/%(link_name)s"; '
1191 'else ln -sf "%(link_path)s" "%(dst)s"; fi' %
1192 dict(dirname=os.path.dirname(dst),
1193 link_path=link_path, dst=dst,
1194 link_name=src_base))
1195 pbar.End()
1196 return
1197
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001198 mode = '0%o' % (0x1FF & os.stat(src).st_mode)
1199 url = ('%s:%d/api/agent/upload/%s?dest=%s&perm=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001200 (self._state.host, self._state.port,
1201 urllib2.quote(self._selected_mid), dst, mode))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001202 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001203 UrlOpen(self._state, url + '&filename=%s' % src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001204 except urllib2.HTTPError as e:
1205 msg = json.loads(e.read()).get('error', None)
1206 raise RuntimeError('push: %s' % msg)
1207
1208 pbar = ProgressBar(src_base)
1209 self._HTTPPostFile(url, src, pbar.SetProgress,
1210 self._state.username, self._state.password)
1211 pbar.End()
1212
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001213 def _push_single_target(src, dst):
1214 if os.path.isdir(src):
1215 dst_exists = ast.literal_eval(self.CheckOutput(
1216 'stat %s >/dev/null 2>&1 && echo True || echo False' % dst))
1217 for root, unused_x, files in os.walk(src):
1218 # If destination directory does not exist, we should strip the first
1219 # layer of directory. For example: src_dir contains a single file 'A'
1220 #
1221 # push src_dir dest_dir
1222 #
1223 # If dest_dir exists, the resulting directory structure should be:
1224 # dest_dir/src_dir/A
1225 # If dest_dir does not exist, the resulting directory structure should
1226 # be:
1227 # dest_dir/A
1228 dst_root = root if dst_exists else root[len(src):].lstrip('/')
1229 for name in files:
1230 _push(os.path.join(root, name),
1231 os.path.join(dst, dst_root, name))
1232 else:
1233 _push(src, dst)
1234
1235 if len(args.srcs) > 1:
1236 dst_type = self.CheckOutput('stat \'%s\' --printf \'%%F\' '
1237 '2>/dev/null' % args.dst).strip()
1238 if not dst_type:
1239 raise RuntimeError('push: %s: No such file or directory' % args.dst)
1240 if dst_type != 'directory':
1241 raise RuntimeError('push: %s: Not a directory' % args.dst)
1242
1243 for src in args.srcs:
1244 if not os.path.exists(src):
1245 raise RuntimeError('push: can not stat "%s": no such file or directory'
1246 % src)
1247 if not os.access(src, os.R_OK):
1248 raise RuntimeError('push: can not open "%s" for reading' % src)
1249
1250 _push_single_target(src, args.dst)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001251
1252 @Command('pull', 'pull a file or directory from remote', [
1253 Arg('src', metavar='SOURCE'),
1254 Arg('dst', metavar='DESTINATION', default='.', nargs='?')])
1255 def Pull(self, args):
1256 self.CheckClient()
1257
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001258 @AutoRetry('pull', _RETRY_TIMES)
Peter Shihe6afab32018-09-11 17:16:48 +08001259 def _pull(src, dst, ftype, perm=0o644, link=None):
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001260 try:
1261 os.makedirs(os.path.dirname(dst))
1262 except Exception:
1263 pass
1264
1265 src_base = os.path.basename(src)
1266
1267 # Remote file is a link
1268 if ftype == 'l':
1269 pbar = ProgressBar(src_base)
1270 if os.path.exists(dst):
1271 os.remove(dst)
1272 os.symlink(link, dst)
1273 pbar.End()
1274 return
1275
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001276 url = ('%s:%d/api/agent/download/%s?filename=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001277 (self._state.host, self._state.port,
1278 urllib2.quote(self._selected_mid), urllib2.quote(src)))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001279 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001280 h = UrlOpen(self._state, url)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001281 except urllib2.HTTPError as e:
1282 msg = json.loads(e.read()).get('error', 'unkown error')
1283 raise RuntimeError('pull: %s' % msg)
1284 except KeyboardInterrupt:
1285 return
1286
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001287 pbar = ProgressBar(src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001288 with open(dst, 'w') as f:
1289 os.fchmod(f.fileno(), perm)
1290 total_size = int(h.headers.get('Content-Length'))
1291 downloaded_size = 0
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001292
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001293 while True:
1294 data = h.read(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +08001295 if not data:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001296 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001297 downloaded_size += len(data)
1298 pbar.SetProgress(float(downloaded_size) * 100 / total_size,
1299 downloaded_size)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001300 f.write(data)
1301 pbar.End()
1302
1303 # Use find to get a listing of all files under a root directory. The 'stat'
1304 # command is used to retrieve the filename and it's filemode.
1305 output = self.CheckOutput(
1306 'cd $HOME; '
1307 'stat "%(src)s" >/dev/null && '
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001308 'find "%(src)s" \'(\' -type f -o -type l \')\' '
1309 '-printf \'%%m\t%%p\t%%y\t%%l\n\''
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001310 % {'src': args.src})
1311
1312 # We got error from the stat command
1313 if output.startswith('stat: '):
1314 sys.stderr.write(output)
1315 return
1316
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001317 entries = output.strip('\n').split('\n')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001318 common_prefix = os.path.dirname(args.src)
1319
1320 if len(entries) == 1:
1321 entry = entries[0]
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001322 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001323 if os.path.isdir(args.dst):
1324 dst = os.path.join(args.dst, os.path.basename(src_path))
1325 else:
1326 dst = args.dst
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001327 _pull(src_path, dst, ftype, int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001328 else:
1329 if not os.path.exists(args.dst):
1330 common_prefix = args.src
1331
1332 for entry in entries:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001333 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001334 rel_dst = src_path[len(common_prefix):].lstrip('/')
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001335 _pull(src_path, os.path.join(args.dst, rel_dst), ftype,
1336 int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001337
1338 @Command('forward', 'forward remote port to local port', [
1339 Arg('--list', dest='list_all', action='store_true', default=False,
1340 help='list all port forwarding sessions'),
1341 Arg('--remove', metavar='LOCAL_PORT', dest='remove', type=int,
1342 default=None,
1343 help='remove port forwarding for local port LOCAL_PORT'),
1344 Arg('--remove-all', dest='remove_all', action='store_true',
1345 default=False, help='remove all port forwarding'),
1346 Arg('remote', metavar='REMOTE_PORT', type=int, nargs='?'),
1347 Arg('local', metavar='LOCAL_PORT', type=int, nargs='?')])
1348 def Forward(self, args):
1349 if args.list_all:
1350 max_len = 10
Peter Shihf84a8972017-06-19 15:18:24 +08001351 if self._state.forwards:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001352 max_len = max([len(v[0]) for v in self._state.forwards.values()])
1353
1354 print('%-*s %-8s %-8s' % (max_len, 'Client', 'Remote', 'Local'))
1355 for local in sorted(self._state.forwards.keys()):
1356 value = self._state.forwards[local]
1357 print('%-*s %-8s %-8s' % (max_len, value[0], value[1], local))
1358 return
1359
1360 if args.remove_all:
1361 self._server.RemoveAllForward()
1362 return
1363
1364 if args.remove:
1365 self._server.RemoveForward(args.remove)
1366 return
1367
1368 self.CheckClient()
1369
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001370 if args.remote is None:
1371 raise RuntimeError('remote port not specified')
1372
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001373 if args.local is None:
1374 args.local = args.remote
1375 remote = int(args.remote)
1376 local = int(args.local)
1377
1378 def HandleConnection(conn):
1379 headers = []
1380 if self._state.username is not None and self._state.password is not None:
1381 headers.append(BasicAuthHeader(self._state.username,
1382 self._state.password))
1383
1384 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1385 ws = ForwarderWebSocketClient(
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001386 self._state, conn,
Peter Shihe03450b2017-06-14 14:02:08 +08001387 scheme + '%s:%d/api/agent/forward/%s?port=%d' % (
1388 self._state.host, self._state.port,
1389 urllib2.quote(self._selected_mid), remote),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001390 headers=headers)
1391 try:
1392 ws.connect()
1393 ws.run()
1394 except Exception as e:
1395 print('error: %s' % e)
1396 finally:
1397 ws.close()
1398
1399 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1400 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1401 server.bind(('0.0.0.0', local))
1402 server.listen(5)
1403
1404 pid = os.fork()
1405 if pid == 0:
1406 while True:
1407 conn, unused_addr = server.accept()
1408 t = threading.Thread(target=HandleConnection, args=(conn,))
1409 t.daemon = True
1410 t.start()
1411 else:
1412 self._server.AddForward(self._selected_mid, remote, local, pid)
1413
1414
1415def main():
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001416 # Setup logging format
1417 logger = logging.getLogger()
1418 logger.setLevel(logging.INFO)
1419 handler = logging.StreamHandler()
1420 formatter = logging.Formatter('%(asctime)s %(message)s', '%Y/%m/%d %H:%M:%S')
1421 handler.setFormatter(formatter)
1422 logger.addHandler(handler)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001423
1424 # Add DaemonState to JSONRPC lib classes
1425 Config.instance().classes.add(DaemonState)
1426
1427 ovl = OverlordCLIClient()
1428 try:
1429 ovl.Main()
1430 except KeyboardInterrupt:
1431 print('Ctrl-C received, abort')
1432 except Exception as e:
1433 print('error: %s' % e)
1434
1435
1436if __name__ == '__main__':
1437 main()