blob: b0e8335590b6c0c1d10929f69da962546f14e994 [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
Yilin Yang14d02a22019-11-01 11:32:03 +08006from __future__ import division
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08007from __future__ import print_function
8
9import argparse
10import ast
11import base64
12import fcntl
Peter Shih13e78c52018-01-23 12:57:07 +080013import functools
Wei-Ning Huangba768ab2016-02-07 14:38:06 +080014import getpass
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080015import hashlib
16import httplib
17import json
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080018import logging
19import os
20import re
21import select
22import signal
23import socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080024import ssl
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080025import StringIO
26import struct
27import subprocess
28import sys
29import tempfile
30import termios
31import threading
32import time
33import tty
Peter Shih99b73ec2017-06-16 17:54:15 +080034import unicodedata # required by pyinstaller, pylint: disable=unused-import
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080035import urllib2
36import urlparse
37
Peter Shih99b73ec2017-06-16 17:54:15 +080038import jsonrpclib
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080039from jsonrpclib.config import Config
Peter Shih99b73ec2017-06-16 17:54:15 +080040from jsonrpclib.SimpleJSONRPCServer import SimpleJSONRPCServer
Yilin Yangea784662019-09-26 13:51:03 +080041from six import iteritems
Yilin Yang8cc5dfb2019-10-22 15:58:53 +080042from six.moves import input
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080043from ws4py.client import WebSocketBaseClient
Peter Shih99b73ec2017-06-16 17:54:15 +080044import yaml
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080045
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080046
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080047_CERT_DIR = os.path.expanduser('~/.config/ovl')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080048
49_ESCAPE = '~'
50_BUFSIZ = 8192
51_OVERLORD_PORT = 4455
52_OVERLORD_HTTP_PORT = 9000
53_OVERLORD_CLIENT_DAEMON_PORT = 4488
54_OVERLORD_CLIENT_DAEMON_RPC_ADDR = ('127.0.0.1', _OVERLORD_CLIENT_DAEMON_PORT)
55
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080056_CONNECT_TIMEOUT = 3
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +080057_DEFAULT_HTTP_TIMEOUT = 30
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080058_LIST_CACHE_TIMEOUT = 2
59_DEFAULT_TERMINAL_WIDTH = 80
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +080060_RETRY_TIMES = 3
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080061
62# echo -n overlord | md5sum
63_HTTP_BOUNDARY_MAGIC = '9246f080c855a69012707ab53489b921'
64
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +080065# Terminal resize control
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080066_CONTROL_START = 128
67_CONTROL_END = 129
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +080068
69# Stream control
70_STDIN_CLOSED = '##STDIN_CLOSED##'
71
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080072_SSH_CONTROL_SOCKET_PREFIX = os.path.join(tempfile.gettempdir(),
73 'ovl-ssh-control-')
74
Wei-Ning Huang47c79b82016-05-24 01:24:46 +080075_TLS_CERT_FAILED_WARNING = """
76@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
77@ WARNING: REMOTE HOST VERIFICATION HAS FAILED! @
78@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
79IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
80Someone could be eavesdropping on you right now (man-in-the-middle attack)!
81It is also possible that the server is using a self-signed certificate.
82The fingerprint for the TLS host certificate sent by the remote host is
83
84%s
85
86Do you want to trust this certificate and proceed? [Y/n] """
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080087
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080088_TLS_CERT_CHANGED_WARNING = """
89@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
90@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
91@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
92IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
93Someone could be eavesdropping on you right now (man-in-the-middle attack)!
Wei-Ning Huang47c79b82016-05-24 01:24:46 +080094It is also possible that the TLS host certificate has just been changed.
95The fingerprint for the TLS host certificate sent by the remote host is
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080096
97%s
98
99Remove '%s' if you still want to proceed.
100SSL Certificate verification failed."""
101
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800102
103def GetVersionDigest():
104 """Return the sha1sum of the current executing script."""
Wei-Ning Huang31609662016-08-11 00:22:25 +0800105 # Check python script by default
106 filename = __file__
107
108 # If we are running from a frozen binary, we should calculate the checksum
109 # against that binary instead of the python script.
110 # See: https://pyinstaller.readthedocs.io/en/stable/runtime-information.html
111 if getattr(sys, 'frozen', False):
112 filename = sys.executable
113
114 with open(filename, 'r') as f:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800115 return hashlib.sha1(f.read()).hexdigest()
116
117
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800118def GetTLSCertPath(host):
119 return os.path.join(_CERT_DIR, '%s.cert' % host)
120
121
122def UrlOpen(state, url):
123 """Wrapper for urllib2.urlopen.
124
125 It selects correct HTTP scheme according to self._state.ssl, add HTTP
126 basic auth headers, and add specify correct SSL context.
127 """
128 url = MakeRequestUrl(state, url)
129 request = urllib2.Request(url)
130 if state.username is not None and state.password is not None:
131 request.add_header(*BasicAuthHeader(state.username, state.password))
132 return urllib2.urlopen(request, timeout=_DEFAULT_HTTP_TIMEOUT,
133 context=state.ssl_context)
134
135
136def GetTLSCertificateSHA1Fingerprint(cert_pem):
137 beg = cert_pem.index('\n')
138 end = cert_pem.rindex('\n', 0, len(cert_pem) - 2)
139 cert_pem = cert_pem[beg:end] # Remove BEGIN/END CERTIFICATE boundary
140 cert_der = base64.b64decode(cert_pem)
141 return hashlib.sha1(cert_der).hexdigest()
142
143
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800144def KillGraceful(pid, wait_secs=1):
145 """Kill a process gracefully by first sending SIGTERM, wait for some time,
146 then send SIGKILL to make sure it's killed."""
147 try:
148 os.kill(pid, signal.SIGTERM)
149 time.sleep(wait_secs)
150 os.kill(pid, signal.SIGKILL)
151 except OSError:
152 pass
153
154
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800155def AutoRetry(action_name, retries):
156 """Decorator for retry function call."""
157 def Wrap(func):
Peter Shih13e78c52018-01-23 12:57:07 +0800158 @functools.wraps(func)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800159 def Loop(*args, **kwargs):
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800160 for unused_i in range(retries):
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800161 try:
162 func(*args, **kwargs)
163 except Exception as e:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800164 print('error: %s: %s: retrying ...' % (args[0], e))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800165 else:
166 break
167 else:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800168 print('error: failed to %s %s' % (action_name, args[0]))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800169 return Loop
170 return Wrap
171
172
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800173def BasicAuthHeader(user, password):
174 """Return HTTP basic auth header."""
175 credential = base64.b64encode('%s:%s' % (user, password))
176 return ('Authorization', 'Basic %s' % credential)
177
178
179def GetTerminalSize():
180 """Retrieve terminal window size."""
181 ws = struct.pack('HHHH', 0, 0, 0, 0)
182 ws = fcntl.ioctl(0, termios.TIOCGWINSZ, ws)
183 lines, columns, unused_x, unused_y = struct.unpack('HHHH', ws)
184 return lines, columns
185
186
187def MakeRequestUrl(state, url):
188 return 'http%s://%s' % ('s' if state.ssl else '', url)
189
190
191class ProgressBar(object):
192 SIZE_WIDTH = 11
193 SPEED_WIDTH = 10
194 DURATION_WIDTH = 6
195 PERCENTAGE_WIDTH = 8
196
197 def __init__(self, name):
198 self._start_time = time.time()
199 self._name = name
200 self._size = 0
201 self._width = 0
202 self._name_width = 0
203 self._name_max = 0
204 self._stat_width = 0
205 self._max = 0
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800206 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800207 self.SetProgress(0)
208
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800209 def _CalculateSize(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800210 self._width = GetTerminalSize()[1] or _DEFAULT_TERMINAL_WIDTH
211 self._name_width = int(self._width * 0.3)
212 self._name_max = self._name_width
213 self._stat_width = self.SIZE_WIDTH + self.SPEED_WIDTH + self.DURATION_WIDTH
214 self._max = (self._width - self._name_width - self._stat_width -
215 self.PERCENTAGE_WIDTH)
216
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800217 def _SizeToHuman(self, size_in_bytes):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800218 if size_in_bytes < 1024:
219 unit = 'B'
220 value = size_in_bytes
221 elif size_in_bytes < 1024 ** 2:
222 unit = 'KiB'
Yilin Yang14d02a22019-11-01 11:32:03 +0800223 value = size_in_bytes / 1024
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800224 elif size_in_bytes < 1024 ** 3:
225 unit = 'MiB'
Yilin Yang14d02a22019-11-01 11:32:03 +0800226 value = size_in_bytes / (1024 ** 2)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800227 elif size_in_bytes < 1024 ** 4:
228 unit = 'GiB'
Yilin Yang14d02a22019-11-01 11:32:03 +0800229 value = size_in_bytes / (1024 ** 3)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800230 return ' %6.1f %3s' % (value, unit)
231
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800232 def _SpeedToHuman(self, speed_in_bs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800233 if speed_in_bs < 1024:
234 unit = 'B'
235 value = speed_in_bs
236 elif speed_in_bs < 1024 ** 2:
237 unit = 'K'
Yilin Yang14d02a22019-11-01 11:32:03 +0800238 value = speed_in_bs / 1024
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800239 elif speed_in_bs < 1024 ** 3:
240 unit = 'M'
Yilin Yang14d02a22019-11-01 11:32:03 +0800241 value = speed_in_bs / (1024 ** 2)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800242 elif speed_in_bs < 1024 ** 4:
243 unit = 'G'
Yilin Yang14d02a22019-11-01 11:32:03 +0800244 value = speed_in_bs / (1024 ** 3)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800245 return ' %6.1f%s/s' % (value, unit)
246
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800247 def _DurationToClock(self, duration):
Yilin Yang14d02a22019-11-01 11:32:03 +0800248 return ' %02d:%02d' % (duration // 60, duration % 60)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800249
250 def SetProgress(self, percentage, size=None):
251 current_width = GetTerminalSize()[1]
252 if self._width != current_width:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800253 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800254
255 if size is not None:
256 self._size = size
257
258 elapse_time = time.time() - self._start_time
Yilin Yang14d02a22019-11-01 11:32:03 +0800259 speed = self._size / elapse_time
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800260
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800261 size_str = self._SizeToHuman(self._size)
262 speed_str = self._SpeedToHuman(speed)
263 elapse_str = self._DurationToClock(elapse_time)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800264
265 width = int(self._max * percentage / 100.0)
266 sys.stdout.write(
267 '%*s' % (- self._name_max,
268 self._name if len(self._name) <= self._name_max else
269 self._name[:self._name_max - 4] + ' ...') +
270 size_str + speed_str + elapse_str +
271 ((' [' + '#' * width + ' ' * (self._max - width) + ']' +
272 '%4d%%' % int(percentage)) if self._max > 2 else '') + '\r')
273 sys.stdout.flush()
274
275 def End(self):
276 self.SetProgress(100.0)
277 sys.stdout.write('\n')
278 sys.stdout.flush()
279
280
281class DaemonState(object):
282 """DaemonState is used for storing Overlord state info."""
283 def __init__(self):
284 self.version_sha1sum = GetVersionDigest()
285 self.host = None
286 self.port = None
287 self.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800288 self.ssl_self_signed = False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800289 self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800290 self.ssh = False
291 self.orig_host = None
292 self.ssh_pid = None
293 self.username = None
294 self.password = None
295 self.selected_mid = None
296 self.forwards = {}
297 self.listing = []
298 self.last_list = 0
299
300
301class OverlordClientDaemon(object):
302 """Overlord Client Daemon."""
303 def __init__(self):
304 self._state = DaemonState()
305 self._server = None
306
307 def Start(self):
308 self.StartRPCServer()
309
310 def StartRPCServer(self):
311 self._server = SimpleJSONRPCServer(_OVERLORD_CLIENT_DAEMON_RPC_ADDR,
312 logRequests=False)
313 exports = [
314 (self.State, 'State'),
315 (self.Ping, 'Ping'),
316 (self.GetPid, 'GetPid'),
317 (self.Connect, 'Connect'),
318 (self.Clients, 'Clients'),
319 (self.SelectClient, 'SelectClient'),
320 (self.AddForward, 'AddForward'),
321 (self.RemoveForward, 'RemoveForward'),
322 (self.RemoveAllForward, 'RemoveAllForward'),
323 ]
324 for func, name in exports:
325 self._server.register_function(func, name)
326
327 pid = os.fork()
328 if pid == 0:
Peter Shih4d55ded2017-07-03 17:19:01 +0800329 for fd in range(3):
330 os.close(fd)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800331 self._server.serve_forever()
332
333 @staticmethod
334 def GetRPCServer():
335 """Returns the Overlord client daemon RPC server."""
336 server = jsonrpclib.Server('http://%s:%d' %
337 _OVERLORD_CLIENT_DAEMON_RPC_ADDR)
338 try:
339 server.Ping()
340 except Exception:
341 return None
342 return server
343
344 def State(self):
345 return self._state
346
347 def Ping(self):
348 return True
349
350 def GetPid(self):
351 return os.getpid()
352
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800353 def _GetJSON(self, path):
354 url = '%s:%d%s' % (self._state.host, self._state.port, path)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800355 return json.loads(UrlOpen(self._state, url).read())
356
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800357 def _TLSEnabled(self):
358 """Determine if TLS is enabled on given server address."""
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800359 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
360 try:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800361 # Allow any certificate since we only want to check if server talks TLS.
362 context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
363 context.verify_mode = ssl.CERT_NONE
364
365 sock = context.wrap_socket(sock, server_hostname=self._state.host)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800366 sock.settimeout(_CONNECT_TIMEOUT)
367 sock.connect((self._state.host, self._state.port))
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800368 return True
Wei-Ning Huangecc80b82016-07-01 16:55:10 +0800369 except ssl.SSLError:
370 return False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800371 except socket.error: # Connect refused or timeout
372 raise
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800373 except Exception:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800374 return False # For whatever reason above failed, assume False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800375
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800376 def _CheckTLSCertificate(self, check_hostname=True):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800377 """Check TLS certificate.
378
379 Returns:
380 A tupple (check_result, if_certificate_is_loaded)
381 """
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800382 def _DoConnect(context):
383 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
384 try:
385 sock.settimeout(_CONNECT_TIMEOUT)
386 sock = context.wrap_socket(sock, server_hostname=self._state.host)
387 sock.connect((self._state.host, self._state.port))
388 except ssl.SSLError:
389 return False
390 finally:
391 sock.close()
392
393 # Save SSLContext for future use.
394 self._state.ssl_context = context
395 return True
396
397 # First try connect with built-in certificates
398 tls_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
399 if _DoConnect(tls_context):
400 return True
401
402 # Try with already saved certificate, if any.
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800403 tls_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
404 tls_context.verify_mode = ssl.CERT_REQUIRED
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800405 tls_context.check_hostname = check_hostname
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800406
407 tls_cert_path = GetTLSCertPath(self._state.host)
408 if os.path.exists(tls_cert_path):
409 tls_context.load_verify_locations(tls_cert_path)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800410 self._state.ssl_self_signed = True
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800411
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800412 return _DoConnect(tls_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800413
414 def Connect(self, host, port=_OVERLORD_HTTP_PORT, ssh_pid=None,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800415 username=None, password=None, orig_host=None,
416 check_hostname=True):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800417 self._state.username = username
418 self._state.password = password
419 self._state.host = host
420 self._state.port = port
421 self._state.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800422 self._state.ssl_self_signed = False
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800423 self._state.orig_host = orig_host
424 self._state.ssh_pid = ssh_pid
425 self._state.selected_mid = None
426
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800427 tls_enabled = self._TLSEnabled()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800428 if tls_enabled:
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800429 result = self._CheckTLSCertificate(check_hostname)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800430 if not result:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800431 if self._state.ssl_self_signed:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800432 return ('SSLCertificateChanged', ssl.get_server_certificate(
433 (self._state.host, self._state.port)))
434 else:
435 return ('SSLVerifyFailed', ssl.get_server_certificate(
436 (self._state.host, self._state.port)))
437
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800438 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800439 self._state.ssl = tls_enabled
440 UrlOpen(self._state, '%s:%d' % (host, port))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800441 except urllib2.HTTPError as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800442 return ('HTTPError', e.getcode(), str(e), e.read().strip())
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800443 except Exception as e:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800444 return str(e)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800445 else:
446 return True
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800447
448 def Clients(self):
449 if time.time() - self._state.last_list <= _LIST_CACHE_TIMEOUT:
450 return self._state.listing
451
Peter Shihcf0f3b22017-06-19 15:59:22 +0800452 self._state.listing = self._GetJSON('/api/agents/list')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800453 self._state.last_list = time.time()
454 return self._state.listing
455
456 def SelectClient(self, mid):
457 self._state.selected_mid = mid
458
459 def AddForward(self, mid, remote, local, pid):
460 self._state.forwards[local] = (mid, remote, pid)
461
462 def RemoveForward(self, local_port):
463 try:
464 unused_mid, unused_remote, pid = self._state.forwards[local_port]
465 KillGraceful(pid)
466 del self._state.forwards[local_port]
467 except (KeyError, OSError):
468 pass
469
470 def RemoveAllForward(self):
471 for unused_mid, unused_remote, pid in self._state.forwards.values():
472 try:
473 KillGraceful(pid)
474 except OSError:
475 pass
476 self._state.forwards = {}
477
478
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800479class SSLEnabledWebSocketBaseClient(WebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800480 def __init__(self, state, *args, **kwargs):
481 cafile = ssl.get_default_verify_paths().openssl_cafile
482 # For some system / distribution, python can not detect system cafile path.
483 # In such case we fallback to the default path.
484 if not os.path.exists(cafile):
485 cafile = '/etc/ssl/certs/ca-certificates.crt'
486
487 if state.ssl_self_signed:
488 cafile = GetTLSCertPath(state.host)
489
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800490 ssl_options = {
491 'cert_reqs': ssl.CERT_REQUIRED,
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800492 'ca_certs': cafile
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800493 }
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800494 # ws4py does not allow you to specify SSLContext, but rather passing in the
495 # argument of ssl.wrap_socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800496 super(SSLEnabledWebSocketBaseClient, self).__init__(
497 ssl_options=ssl_options, *args, **kwargs)
498
499
500class TerminalWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800501 def __init__(self, state, mid, escape, *args, **kwargs):
502 super(TerminalWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800503 self._mid = mid
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800504 self._escape = escape
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800505 self._stdin_fd = sys.stdin.fileno()
506 self._old_termios = None
507
508 def handshake_ok(self):
509 pass
510
511 def opened(self):
512 nonlocals = {'size': (80, 40)}
513
514 def _ResizeWindow():
515 size = GetTerminalSize()
516 if size != nonlocals['size']: # Size not changed, ignore
517 control = {'command': 'resize', 'params': list(size)}
518 payload = chr(_CONTROL_START) + json.dumps(control) + chr(_CONTROL_END)
519 nonlocals['size'] = size
520 try:
521 self.send(payload, binary=True)
522 except Exception:
523 pass
524
525 def _FeedInput():
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800526 self._old_termios = termios.tcgetattr(self._stdin_fd)
527 tty.setraw(self._stdin_fd)
528
529 READY, ENTER_PRESSED, ESCAPE_PRESSED = range(3)
530
531 try:
532 state = READY
533 while True:
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800534 # Check if terminal is resized
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800535 _ResizeWindow()
536
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800537 ch = sys.stdin.read(1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800538
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800539 # Scan for escape sequence
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800540 if self._escape:
541 if state == READY:
542 state = ENTER_PRESSED if ch == chr(0x0d) else READY
543 elif state == ENTER_PRESSED:
544 state = ESCAPE_PRESSED if ch == self._escape else READY
545 elif state == ESCAPE_PRESSED:
546 if ch == '.':
547 self.close()
548 break
549 else:
550 state = READY
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800551
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800552 self.send(ch)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800553 except (KeyboardInterrupt, RuntimeError):
554 pass
555
556 t = threading.Thread(target=_FeedInput)
557 t.daemon = True
558 t.start()
559
560 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800561 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800562 termios.tcsetattr(self._stdin_fd, termios.TCSANOW, self._old_termios)
563 print('Connection to %s closed.' % self._mid)
564
565 def received_message(self, msg):
566 if msg.is_binary:
567 sys.stdout.write(msg.data)
568 sys.stdout.flush()
569
570
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800571class ShellWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800572 def __init__(self, state, output, *args, **kwargs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800573 """Constructor.
574
575 Args:
576 output: output file object.
577 """
578 self.output = output
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800579 super(ShellWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800580
581 def handshake_ok(self):
582 pass
583
584 def opened(self):
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800585 def _FeedInput():
586 try:
587 while True:
588 data = sys.stdin.read(1)
589
Peter Shihf84a8972017-06-19 15:18:24 +0800590 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800591 self.send(_STDIN_CLOSED * 2)
592 break
593 self.send(data, binary=True)
594 except (KeyboardInterrupt, RuntimeError):
595 pass
596
597 t = threading.Thread(target=_FeedInput)
598 t.daemon = True
599 t.start()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800600
601 def closed(self, code, reason=None):
602 pass
603
604 def received_message(self, msg):
605 if msg.is_binary:
606 self.output.write(msg.data)
607 self.output.flush()
608
609
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800610class ForwarderWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800611 def __init__(self, state, sock, *args, **kwargs):
612 super(ForwarderWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800613 self._sock = sock
614 self._stop = threading.Event()
615
616 def handshake_ok(self):
617 pass
618
619 def opened(self):
620 def _FeedInput():
621 try:
622 self._sock.setblocking(False)
623 while True:
624 rd, unused_w, unused_x = select.select([self._sock], [], [], 0.5)
625 if self._stop.is_set():
626 break
627 if self._sock in rd:
628 data = self._sock.recv(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +0800629 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800630 self.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800631 break
632 self.send(data, binary=True)
633 except Exception:
634 pass
635 finally:
636 self._sock.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800637
638 t = threading.Thread(target=_FeedInput)
639 t.daemon = True
640 t.start()
641
642 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800643 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800644 self._stop.set()
645 sys.exit(0)
646
647 def received_message(self, msg):
648 if msg.is_binary:
649 self._sock.send(msg.data)
650
651
652def Arg(*args, **kwargs):
653 return (args, kwargs)
654
655
656def Command(command, help_msg=None, args=None):
657 """Decorator for adding argparse parameter for a method."""
658 if args is None:
659 args = []
660 def WrapFunc(func):
Peter Shih13e78c52018-01-23 12:57:07 +0800661 @functools.wraps(func)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800662 def Wrapped(*args, **kwargs):
663 return func(*args, **kwargs)
Peter Shih99b73ec2017-06-16 17:54:15 +0800664 # pylint: disable=protected-access
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800665 Wrapped.__arg_attr = {'command': command, 'help': help_msg, 'args': args}
666 return Wrapped
667 return WrapFunc
668
669
670def ParseMethodSubCommands(cls):
671 """Decorator for a class using the @Command decorator.
672
673 This decorator retrieve command info from each method and append it in to the
674 SUBCOMMANDS class variable, which is later used to construct parser.
675 """
Yilin Yangea784662019-09-26 13:51:03 +0800676 for unused_key, method in iteritems(cls.__dict__):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800677 if hasattr(method, '__arg_attr'):
Peter Shih99b73ec2017-06-16 17:54:15 +0800678 # pylint: disable=protected-access
679 cls.SUBCOMMANDS.append(method.__arg_attr)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800680 return cls
681
682
683@ParseMethodSubCommands
684class OverlordCLIClient(object):
685 """Overlord command line interface client."""
686
687 SUBCOMMANDS = []
688
689 def __init__(self):
690 self._parser = self._BuildParser()
691 self._selected_mid = None
692 self._server = None
693 self._state = None
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800694 self._escape = None
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800695
696 def _BuildParser(self):
697 root_parser = argparse.ArgumentParser(prog='ovl')
698 subparsers = root_parser.add_subparsers(help='sub-command')
699
700 root_parser.add_argument('-s', dest='selected_mid', action='store',
701 default=None,
702 help='select target to execute command on')
703 root_parser.add_argument('-S', dest='select_mid_before_action',
704 action='store_true', default=False,
705 help='select target before executing command')
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800706 root_parser.add_argument('-e', dest='escape', metavar='ESCAPE_CHAR',
707 action='store', default=_ESCAPE, type=str,
708 help='set shell escape character, \'none\' to '
709 'disable escape completely')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800710
711 for attr in self.SUBCOMMANDS:
712 parser = subparsers.add_parser(attr['command'], help=attr['help'])
713 parser.set_defaults(which=attr['command'])
714 for arg in attr['args']:
715 parser.add_argument(*arg[0], **arg[1])
716
717 return root_parser
718
719 def Main(self):
720 # We want to pass the rest of arguments after shell command directly to the
721 # function without parsing it.
722 try:
723 index = sys.argv.index('shell')
724 except ValueError:
725 args = self._parser.parse_args()
726 else:
727 args = self._parser.parse_args(sys.argv[1:index + 1])
728
729 command = args.which
730 self._selected_mid = args.selected_mid
731
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800732 if args.escape and args.escape != 'none':
733 self._escape = args.escape[0]
734
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800735 if command == 'start-server':
736 self.StartServer()
737 return
738 elif command == 'kill-server':
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800739 self.KillServer()
740 return
741
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800742 self.CheckDaemon()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800743 if command == 'status':
744 self.Status()
745 return
746 elif command == 'connect':
747 self.Connect(args)
748 return
749
750 # The following command requires connection to the server
751 self.CheckConnection()
752
753 if args.select_mid_before_action:
754 self.SelectClient(store=False)
755
756 if command == 'select':
757 self.SelectClient(args)
758 elif command == 'ls':
Peter Shih99b73ec2017-06-16 17:54:15 +0800759 self.ListClients(args)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800760 elif command == 'shell':
761 command = sys.argv[sys.argv.index('shell') + 1:]
762 self.Shell(command)
763 elif command == 'push':
764 self.Push(args)
765 elif command == 'pull':
766 self.Pull(args)
767 elif command == 'forward':
768 self.Forward(args)
769
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800770 def _SaveTLSCertificate(self, host, cert_pem):
771 try:
772 os.makedirs(_CERT_DIR)
773 except Exception:
774 pass
775 with open(GetTLSCertPath(host), 'w') as f:
776 f.write(cert_pem)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800777
778 def _HTTPPostFile(self, url, filename, progress=None, user=None, passwd=None):
779 """Perform HTTP POST and upload file to Overlord.
780
781 To minimize the external dependencies, we construct the HTTP post request
782 by ourselves.
783 """
784 url = MakeRequestUrl(self._state, url)
785 size = os.stat(filename).st_size
786 boundary = '-----------%s' % _HTTP_BOUNDARY_MAGIC
787 CRLF = '\r\n'
788 parse = urlparse.urlparse(url)
789
790 part_headers = [
791 '--' + boundary,
792 'Content-Disposition: form-data; name="file"; '
793 'filename="%s"' % os.path.basename(filename),
794 'Content-Type: application/octet-stream',
795 '', ''
796 ]
797 part_header = CRLF.join(part_headers)
798 end_part = CRLF + '--' + boundary + '--' + CRLF
799
800 content_length = len(part_header) + size + len(end_part)
801 if parse.scheme == 'http':
802 h = httplib.HTTP(parse.netloc)
803 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800804 h = httplib.HTTPS(parse.netloc, context=self._state.ssl_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800805
806 post_path = url[url.index(parse.netloc) + len(parse.netloc):]
807 h.putrequest('POST', post_path)
808 h.putheader('Content-Length', content_length)
809 h.putheader('Content-Type', 'multipart/form-data; boundary=%s' % boundary)
810
811 if user and passwd:
812 h.putheader(*BasicAuthHeader(user, passwd))
813 h.endheaders()
814 h.send(part_header)
815
816 count = 0
817 with open(filename, 'r') as f:
818 while True:
819 data = f.read(_BUFSIZ)
820 if not data:
821 break
822 count += len(data)
823 if progress:
Yilin Yang14d02a22019-11-01 11:32:03 +0800824 progress(count * 100 // size, count)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800825 h.send(data)
826
827 h.send(end_part)
828 progress(100)
829
830 if count != size:
831 logging.warning('file changed during upload, upload may be truncated.')
832
833 errcode, unused_x, unused_y = h.getreply()
834 return errcode == 200
835
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800836 def CheckDaemon(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800837 self._server = OverlordClientDaemon.GetRPCServer()
838 if self._server is None:
839 print('* daemon not running, starting it now on port %d ... *' %
840 _OVERLORD_CLIENT_DAEMON_PORT)
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800841 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800842
843 self._state = self._server.State()
844 sha1sum = GetVersionDigest()
845
846 if sha1sum != self._state.version_sha1sum:
847 print('ovl server is out of date. killing...')
848 KillGraceful(self._server.GetPid())
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800849 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800850
851 def GetSSHControlFile(self, host):
852 return _SSH_CONTROL_SOCKET_PREFIX + host
853
854 def SSHTunnel(self, user, host, port):
855 """SSH forward the remote overlord server.
856
857 Overlord server may not have port 9000 open to the public network, in such
858 case we can SSH forward the port to localhost.
859 """
860
861 control_file = self.GetSSHControlFile(host)
862 try:
863 os.unlink(control_file)
864 except Exception:
865 pass
866
867 subprocess.Popen([
868 'ssh', '-Nf',
869 '-M', # Enable master mode
870 '-S', control_file,
871 '-L', '9000:localhost:9000',
872 '-p', str(port),
873 '%s%s' % (user + '@' if user else '', host)
874 ]).wait()
875
876 p = subprocess.Popen([
877 'ssh',
878 '-S', control_file,
879 '-O', 'check', host,
880 ], stderr=subprocess.PIPE)
881 unused_stdout, stderr = p.communicate()
882
883 s = re.search(r'pid=(\d+)', stderr)
884 if s:
885 return int(s.group(1))
886
887 raise RuntimeError('can not establish ssh connection')
888
889 def CheckConnection(self):
890 if self._state.host is None:
891 raise RuntimeError('not connected to any server, abort')
892
893 try:
894 self._server.Clients()
895 except Exception:
896 raise RuntimeError('remote server disconnected, abort')
897
898 if self._state.ssh_pid is not None:
899 ret = subprocess.Popen(['kill', '-0', str(self._state.ssh_pid)],
900 stdout=subprocess.PIPE,
901 stderr=subprocess.PIPE).wait()
902 if ret != 0:
903 raise RuntimeError('ssh tunnel disconnected, please re-connect')
904
905 def CheckClient(self):
906 if self._selected_mid is None:
907 if self._state.selected_mid is None:
908 raise RuntimeError('No client is selected')
909 self._selected_mid = self._state.selected_mid
910
Peter Shihcf0f3b22017-06-19 15:59:22 +0800911 if not any(client['mid'] == self._selected_mid
912 for client in self._server.Clients()):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800913 raise RuntimeError('client %s disappeared' % self._selected_mid)
914
915 def CheckOutput(self, command):
916 headers = []
917 if self._state.username is not None and self._state.password is not None:
918 headers.append(BasicAuthHeader(self._state.username,
919 self._state.password))
920
921 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
922 sio = StringIO.StringIO()
Peter Shihe03450b2017-06-14 14:02:08 +0800923 ws = ShellWebSocketClient(
924 self._state, sio, scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
925 self._state.host, self._state.port,
926 urllib2.quote(self._selected_mid), urllib2.quote(command)),
927 headers=headers)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800928 ws.connect()
929 ws.run()
930 return sio.getvalue()
931
932 @Command('status', 'show Overlord connection status')
933 def Status(self):
934 if self._state.host is None:
935 print('Not connected to any host.')
936 else:
937 if self._state.ssh_pid is not None:
938 print('Connected to %s with SSH tunneling.' % self._state.orig_host)
939 else:
940 print('Connected to %s:%d.' % (self._state.host, self._state.port))
941
942 if self._selected_mid is None:
943 self._selected_mid = self._state.selected_mid
944
945 if self._selected_mid is None:
946 print('No client is selected.')
947 else:
948 print('Client %s selected.' % self._selected_mid)
949
950 @Command('connect', 'connect to Overlord server', [
951 Arg('host', metavar='HOST', type=str, default='localhost',
952 help='Overlord hostname/IP'),
953 Arg('port', metavar='PORT', type=int,
954 default=_OVERLORD_HTTP_PORT, help='Overlord port'),
955 Arg('-f', '--forward', dest='ssh_forward', default=False,
956 action='store_true',
957 help='connect with SSH forwarding to the host'),
958 Arg('-p', '--ssh-port', dest='ssh_port', default=22,
959 type=int, help='SSH server port for SSH forwarding'),
960 Arg('-l', '--ssh-login', dest='ssh_login', default='',
961 type=str, help='SSH server login name for SSH forwarding'),
962 Arg('-u', '--user', dest='user', default=None,
963 type=str, help='Overlord HTTP auth username'),
964 Arg('-w', '--passwd', dest='passwd', default=None, type=str,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800965 help='Overlord HTTP auth password'),
966 Arg('-i', '--no-check-hostname', dest='check_hostname',
967 default=True, action='store_false',
968 help='Ignore SSL cert hostname check')])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800969 def Connect(self, args):
970 ssh_pid = None
971 host = args.host
972 orig_host = args.host
973
974 if args.ssh_forward:
975 # Kill previous SSH tunnel
976 self.KillSSHTunnel()
977
978 ssh_pid = self.SSHTunnel(args.ssh_login, args.host, args.ssh_port)
979 host = 'localhost'
980
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800981 username_provided = args.user is not None
982 password_provided = args.passwd is not None
983 prompt = False
984
Peter Shih533566a2018-09-05 17:48:03 +0800985 for unused_i in range(3): # pylint: disable=too-many-nested-blocks
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800986 try:
987 if prompt:
988 if not username_provided:
Yilin Yang8cc5dfb2019-10-22 15:58:53 +0800989 args.user = input('Username: ')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800990 if not password_provided:
991 args.passwd = getpass.getpass('Password: ')
992
993 ret = self._server.Connect(host, args.port, ssh_pid, args.user,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800994 args.passwd, orig_host,
995 args.check_hostname)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800996 if isinstance(ret, list):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800997 if ret[0].startswith('SSL'):
998 cert_pem = ret[1]
999 fp = GetTLSCertificateSHA1Fingerprint(cert_pem)
1000 fp_text = ':'.join([fp[i:i+2] for i in range(0, len(fp), 2)])
1001
1002 if ret[0] == 'SSLCertificateChanged':
1003 print(_TLS_CERT_CHANGED_WARNING % (fp_text, GetTLSCertPath(host)))
1004 return
1005 elif ret[0] == 'SSLVerifyFailed':
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001006 print(_TLS_CERT_FAILED_WARNING % (fp_text), end='')
Yilin Yang8cc5dfb2019-10-22 15:58:53 +08001007 response = input()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001008 if response.lower() in ['y', 'ye', 'yes']:
1009 self._SaveTLSCertificate(host, cert_pem)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001010 print('TLS host Certificate trusted, you will not be prompted '
1011 'next time.\n')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001012 continue
1013 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001014 print('connection aborted.')
1015 return
1016 elif ret[0] == 'HTTPError':
1017 code, except_str, body = ret[1:]
1018 if code == 401:
1019 print('connect: %s' % body)
1020 prompt = True
1021 if not username_provided or not password_provided:
1022 continue
1023 else:
1024 break
1025 else:
1026 logging.error('%s; %s', except_str, body)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001027
1028 if ret is not True:
1029 print('can not connect to %s: %s' % (host, ret))
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001030 else:
1031 print('connection to %s:%d established.' % (host, args.port))
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001032 except Exception as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001033 logging.error(e)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001034 else:
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001035 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001036
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001037 @Command('start-server', 'start overlord CLI client server')
1038 def StartServer(self):
1039 self._server = OverlordClientDaemon.GetRPCServer()
1040 if self._server is None:
1041 OverlordClientDaemon().Start()
1042 time.sleep(1)
1043 self._server = OverlordClientDaemon.GetRPCServer()
1044 if self._server is not None:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001045 print('* daemon started successfully *\n')
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001046
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001047 @Command('kill-server', 'kill overlord CLI client server')
1048 def KillServer(self):
1049 self._server = OverlordClientDaemon.GetRPCServer()
1050 if self._server is None:
1051 return
1052
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001053 self._state = self._server.State()
1054
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001055 # Kill SSH Tunnel
1056 self.KillSSHTunnel()
1057
1058 # Kill server daemon
1059 KillGraceful(self._server.GetPid())
1060
1061 def KillSSHTunnel(self):
1062 if self._state.ssh_pid is not None:
1063 KillGraceful(self._state.ssh_pid)
1064
Peter Shihcf0f3b22017-06-19 15:59:22 +08001065 def _FilterClients(self, clients, prop_filters, mid=None):
1066 def _ClientPropertiesMatch(client, key, regex):
1067 try:
1068 return bool(re.search(regex, client['properties'][key]))
1069 except KeyError:
1070 return False
1071
1072 for prop_filter in prop_filters:
1073 key, sep, regex = prop_filter.partition('=')
1074 if not sep:
1075 # The filter doesn't contains =.
1076 raise ValueError('Invalid filter condition %r' % filter)
1077 clients = [c for c in clients if _ClientPropertiesMatch(c, key, regex)]
1078
1079 if mid is not None:
1080 client = next((c for c in clients if c['mid'] == mid), None)
1081 if client:
1082 return [client]
1083 clients = [c for c in clients if c['mid'].startswith(mid)]
1084 return clients
1085
1086 @Command('ls', 'list clients', [
1087 Arg('-f', '--filter', default=[], dest='filters', action='append',
1088 help=('Conditions to filter clients by properties. '
1089 'Should be in form "key=regex", where regex is the regular '
1090 'expression that should be found in the value. '
1091 'Multiple --filter arguments would be ANDed.')),
Peter Shih99b73ec2017-06-16 17:54:15 +08001092 Arg('-v', '--verbose', default=False, action='store_true',
1093 help='Print properties of each client.')
1094 ])
1095 def ListClients(self, args):
Peter Shihcf0f3b22017-06-19 15:59:22 +08001096 clients = self._FilterClients(self._server.Clients(), args.filters)
1097 for client in clients:
1098 if args.verbose:
Peter Shih99b73ec2017-06-16 17:54:15 +08001099 print(yaml.safe_dump(client, default_flow_style=False))
Peter Shihcf0f3b22017-06-19 15:59:22 +08001100 else:
1101 print(client['mid'])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001102
1103 @Command('select', 'select default client', [
Peter Shihcf0f3b22017-06-19 15:59:22 +08001104 Arg('-f', '--filter', default=[], dest='filters', action='append',
1105 help=('Conditions to filter clients by properties. '
1106 'Should be in form "key=regex", where regex is the regular '
1107 'expression that should be found in the value. '
1108 'Multiple --filter arguments would be ANDed.')),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001109 Arg('mid', metavar='mid', nargs='?', default=None)])
1110 def SelectClient(self, args=None, store=True):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001111 mid = args.mid if args is not None else None
Wei-Ning Huang6796a132017-06-22 14:46:32 +08001112 filters = args.filters if args is not None else []
1113 clients = self._FilterClients(self._server.Clients(), filters, mid=mid)
Peter Shihcf0f3b22017-06-19 15:59:22 +08001114
1115 if not clients:
1116 raise RuntimeError('select: client not found')
1117 elif len(clients) == 1:
1118 mid = clients[0]['mid']
1119 else:
1120 # This case would not happen when args.mid is specified.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001121 print('Select from the following clients:')
1122 for i, client in enumerate(clients):
Peter Shihcf0f3b22017-06-19 15:59:22 +08001123 print(' %d. %s' % (i + 1, client['mid']))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001124
1125 print('\nSelection: ', end='')
1126 try:
Yilin Yang8cc5dfb2019-10-22 15:58:53 +08001127 choice = int(input()) - 1
Peter Shihcf0f3b22017-06-19 15:59:22 +08001128 mid = clients[choice]['mid']
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001129 except ValueError:
1130 raise RuntimeError('select: invalid selection')
1131 except IndexError:
1132 raise RuntimeError('select: selection out of range')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001133
1134 self._selected_mid = mid
1135 if store:
1136 self._server.SelectClient(mid)
1137 print('Client %s selected' % mid)
1138
1139 @Command('shell', 'open a shell or execute a shell command', [
1140 Arg('command', metavar='CMD', nargs='?', help='command to execute')])
1141 def Shell(self, command=None):
1142 if command is None:
1143 command = []
1144 self.CheckClient()
1145
1146 headers = []
1147 if self._state.username is not None and self._state.password is not None:
1148 headers.append(BasicAuthHeader(self._state.username,
1149 self._state.password))
1150
1151 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
Peter Shihe03450b2017-06-14 14:02:08 +08001152 if command:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001153 cmd = ' '.join(command)
Peter Shihe03450b2017-06-14 14:02:08 +08001154 ws = ShellWebSocketClient(
1155 self._state, sys.stdout,
1156 scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
1157 self._state.host, self._state.port,
1158 urllib2.quote(self._selected_mid), urllib2.quote(cmd)),
1159 headers=headers)
1160 else:
1161 ws = TerminalWebSocketClient(
1162 self._state, self._selected_mid, self._escape,
1163 scheme + '%s:%d/api/agent/tty/%s' % (
1164 self._state.host, self._state.port,
1165 urllib2.quote(self._selected_mid)),
1166 headers=headers)
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001167 try:
1168 ws.connect()
1169 ws.run()
1170 except socket.error as e:
1171 if e.errno == 32: # Broken pipe
1172 pass
1173 else:
1174 raise
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001175
1176 @Command('push', 'push a file or directory to remote', [
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001177 Arg('srcs', nargs='+', metavar='SOURCE'),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001178 Arg('dst', metavar='DESTINATION')])
1179 def Push(self, args):
1180 self.CheckClient()
1181
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001182 @AutoRetry('push', _RETRY_TIMES)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001183 def _push(src, dst):
1184 src_base = os.path.basename(src)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001185
1186 # Local file is a link
1187 if os.path.islink(src):
1188 pbar = ProgressBar(src_base)
1189 link_path = os.readlink(src)
1190 self.CheckOutput('mkdir -p %(dirname)s; '
1191 'if [ -d "%(dst)s" ]; then '
1192 'ln -sf "%(link_path)s" "%(dst)s/%(link_name)s"; '
1193 'else ln -sf "%(link_path)s" "%(dst)s"; fi' %
1194 dict(dirname=os.path.dirname(dst),
1195 link_path=link_path, dst=dst,
1196 link_name=src_base))
1197 pbar.End()
1198 return
1199
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001200 mode = '0%o' % (0x1FF & os.stat(src).st_mode)
1201 url = ('%s:%d/api/agent/upload/%s?dest=%s&perm=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001202 (self._state.host, self._state.port,
1203 urllib2.quote(self._selected_mid), dst, mode))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001204 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001205 UrlOpen(self._state, url + '&filename=%s' % src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001206 except urllib2.HTTPError as e:
1207 msg = json.loads(e.read()).get('error', None)
1208 raise RuntimeError('push: %s' % msg)
1209
1210 pbar = ProgressBar(src_base)
1211 self._HTTPPostFile(url, src, pbar.SetProgress,
1212 self._state.username, self._state.password)
1213 pbar.End()
1214
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001215 def _push_single_target(src, dst):
1216 if os.path.isdir(src):
1217 dst_exists = ast.literal_eval(self.CheckOutput(
1218 'stat %s >/dev/null 2>&1 && echo True || echo False' % dst))
1219 for root, unused_x, files in os.walk(src):
1220 # If destination directory does not exist, we should strip the first
1221 # layer of directory. For example: src_dir contains a single file 'A'
1222 #
1223 # push src_dir dest_dir
1224 #
1225 # If dest_dir exists, the resulting directory structure should be:
1226 # dest_dir/src_dir/A
1227 # If dest_dir does not exist, the resulting directory structure should
1228 # be:
1229 # dest_dir/A
1230 dst_root = root if dst_exists else root[len(src):].lstrip('/')
1231 for name in files:
1232 _push(os.path.join(root, name),
1233 os.path.join(dst, dst_root, name))
1234 else:
1235 _push(src, dst)
1236
1237 if len(args.srcs) > 1:
1238 dst_type = self.CheckOutput('stat \'%s\' --printf \'%%F\' '
1239 '2>/dev/null' % args.dst).strip()
1240 if not dst_type:
1241 raise RuntimeError('push: %s: No such file or directory' % args.dst)
1242 if dst_type != 'directory':
1243 raise RuntimeError('push: %s: Not a directory' % args.dst)
1244
1245 for src in args.srcs:
1246 if not os.path.exists(src):
1247 raise RuntimeError('push: can not stat "%s": no such file or directory'
1248 % src)
1249 if not os.access(src, os.R_OK):
1250 raise RuntimeError('push: can not open "%s" for reading' % src)
1251
1252 _push_single_target(src, args.dst)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001253
1254 @Command('pull', 'pull a file or directory from remote', [
1255 Arg('src', metavar='SOURCE'),
1256 Arg('dst', metavar='DESTINATION', default='.', nargs='?')])
1257 def Pull(self, args):
1258 self.CheckClient()
1259
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001260 @AutoRetry('pull', _RETRY_TIMES)
Peter Shihe6afab32018-09-11 17:16:48 +08001261 def _pull(src, dst, ftype, perm=0o644, link=None):
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001262 try:
1263 os.makedirs(os.path.dirname(dst))
1264 except Exception:
1265 pass
1266
1267 src_base = os.path.basename(src)
1268
1269 # Remote file is a link
1270 if ftype == 'l':
1271 pbar = ProgressBar(src_base)
1272 if os.path.exists(dst):
1273 os.remove(dst)
1274 os.symlink(link, dst)
1275 pbar.End()
1276 return
1277
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001278 url = ('%s:%d/api/agent/download/%s?filename=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001279 (self._state.host, self._state.port,
1280 urllib2.quote(self._selected_mid), urllib2.quote(src)))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001281 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001282 h = UrlOpen(self._state, url)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001283 except urllib2.HTTPError as e:
1284 msg = json.loads(e.read()).get('error', 'unkown error')
1285 raise RuntimeError('pull: %s' % msg)
1286 except KeyboardInterrupt:
1287 return
1288
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001289 pbar = ProgressBar(src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001290 with open(dst, 'w') as f:
1291 os.fchmod(f.fileno(), perm)
1292 total_size = int(h.headers.get('Content-Length'))
1293 downloaded_size = 0
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001294
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001295 while True:
1296 data = h.read(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +08001297 if not data:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001298 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001299 downloaded_size += len(data)
Yilin Yang14d02a22019-11-01 11:32:03 +08001300 pbar.SetProgress(downloaded_size * 100 / total_size,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001301 downloaded_size)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001302 f.write(data)
1303 pbar.End()
1304
1305 # Use find to get a listing of all files under a root directory. The 'stat'
1306 # command is used to retrieve the filename and it's filemode.
1307 output = self.CheckOutput(
1308 'cd $HOME; '
1309 'stat "%(src)s" >/dev/null && '
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001310 'find "%(src)s" \'(\' -type f -o -type l \')\' '
1311 '-printf \'%%m\t%%p\t%%y\t%%l\n\''
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001312 % {'src': args.src})
1313
1314 # We got error from the stat command
1315 if output.startswith('stat: '):
1316 sys.stderr.write(output)
1317 return
1318
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001319 entries = output.strip('\n').split('\n')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001320 common_prefix = os.path.dirname(args.src)
1321
1322 if len(entries) == 1:
1323 entry = entries[0]
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001324 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001325 if os.path.isdir(args.dst):
1326 dst = os.path.join(args.dst, os.path.basename(src_path))
1327 else:
1328 dst = args.dst
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001329 _pull(src_path, dst, ftype, int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001330 else:
1331 if not os.path.exists(args.dst):
1332 common_prefix = args.src
1333
1334 for entry in entries:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001335 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001336 rel_dst = src_path[len(common_prefix):].lstrip('/')
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001337 _pull(src_path, os.path.join(args.dst, rel_dst), ftype,
1338 int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001339
1340 @Command('forward', 'forward remote port to local port', [
1341 Arg('--list', dest='list_all', action='store_true', default=False,
1342 help='list all port forwarding sessions'),
1343 Arg('--remove', metavar='LOCAL_PORT', dest='remove', type=int,
1344 default=None,
1345 help='remove port forwarding for local port LOCAL_PORT'),
1346 Arg('--remove-all', dest='remove_all', action='store_true',
1347 default=False, help='remove all port forwarding'),
1348 Arg('remote', metavar='REMOTE_PORT', type=int, nargs='?'),
1349 Arg('local', metavar='LOCAL_PORT', type=int, nargs='?')])
1350 def Forward(self, args):
1351 if args.list_all:
1352 max_len = 10
Peter Shihf84a8972017-06-19 15:18:24 +08001353 if self._state.forwards:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001354 max_len = max([len(v[0]) for v in self._state.forwards.values()])
1355
1356 print('%-*s %-8s %-8s' % (max_len, 'Client', 'Remote', 'Local'))
1357 for local in sorted(self._state.forwards.keys()):
1358 value = self._state.forwards[local]
1359 print('%-*s %-8s %-8s' % (max_len, value[0], value[1], local))
1360 return
1361
1362 if args.remove_all:
1363 self._server.RemoveAllForward()
1364 return
1365
1366 if args.remove:
1367 self._server.RemoveForward(args.remove)
1368 return
1369
1370 self.CheckClient()
1371
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001372 if args.remote is None:
1373 raise RuntimeError('remote port not specified')
1374
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001375 if args.local is None:
1376 args.local = args.remote
1377 remote = int(args.remote)
1378 local = int(args.local)
1379
1380 def HandleConnection(conn):
1381 headers = []
1382 if self._state.username is not None and self._state.password is not None:
1383 headers.append(BasicAuthHeader(self._state.username,
1384 self._state.password))
1385
1386 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1387 ws = ForwarderWebSocketClient(
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001388 self._state, conn,
Peter Shihe03450b2017-06-14 14:02:08 +08001389 scheme + '%s:%d/api/agent/forward/%s?port=%d' % (
1390 self._state.host, self._state.port,
1391 urllib2.quote(self._selected_mid), remote),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001392 headers=headers)
1393 try:
1394 ws.connect()
1395 ws.run()
1396 except Exception as e:
1397 print('error: %s' % e)
1398 finally:
1399 ws.close()
1400
1401 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1402 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1403 server.bind(('0.0.0.0', local))
1404 server.listen(5)
1405
1406 pid = os.fork()
1407 if pid == 0:
1408 while True:
1409 conn, unused_addr = server.accept()
1410 t = threading.Thread(target=HandleConnection, args=(conn,))
1411 t.daemon = True
1412 t.start()
1413 else:
1414 self._server.AddForward(self._selected_mid, remote, local, pid)
1415
1416
1417def main():
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001418 # Setup logging format
1419 logger = logging.getLogger()
1420 logger.setLevel(logging.INFO)
1421 handler = logging.StreamHandler()
1422 formatter = logging.Formatter('%(asctime)s %(message)s', '%Y/%m/%d %H:%M:%S')
1423 handler.setFormatter(formatter)
1424 logger.addHandler(handler)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001425
1426 # Add DaemonState to JSONRPC lib classes
1427 Config.instance().classes.add(DaemonState)
1428
1429 ovl = OverlordCLIClient()
1430 try:
1431 ovl.Main()
1432 except KeyboardInterrupt:
1433 print('Ctrl-C received, abort')
1434 except Exception as e:
1435 print('error: %s' % e)
1436
1437
1438if __name__ == '__main__':
1439 main()