blob: b32ec5fa9e4cd66c5c46ebd37762881d5d70563d [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
Yilin Yang752db712019-09-27 15:42:38 +080016import http.client
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080017import 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)))
Yilin Yang15a3f8f2020-01-03 17:49:00 +0800434 return ('SSLVerifyFailed', ssl.get_server_certificate(
435 (self._state.host, self._state.port)))
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800436
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800437 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800438 self._state.ssl = tls_enabled
439 UrlOpen(self._state, '%s:%d' % (host, port))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800440 except urllib2.HTTPError as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800441 return ('HTTPError', e.getcode(), str(e), e.read().strip())
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800442 except Exception as e:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800443 return str(e)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800444 else:
445 return True
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800446
447 def Clients(self):
448 if time.time() - self._state.last_list <= _LIST_CACHE_TIMEOUT:
449 return self._state.listing
450
Peter Shihcf0f3b22017-06-19 15:59:22 +0800451 self._state.listing = self._GetJSON('/api/agents/list')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800452 self._state.last_list = time.time()
453 return self._state.listing
454
455 def SelectClient(self, mid):
456 self._state.selected_mid = mid
457
458 def AddForward(self, mid, remote, local, pid):
459 self._state.forwards[local] = (mid, remote, pid)
460
461 def RemoveForward(self, local_port):
462 try:
463 unused_mid, unused_remote, pid = self._state.forwards[local_port]
464 KillGraceful(pid)
465 del self._state.forwards[local_port]
466 except (KeyError, OSError):
467 pass
468
469 def RemoveAllForward(self):
470 for unused_mid, unused_remote, pid in self._state.forwards.values():
471 try:
472 KillGraceful(pid)
473 except OSError:
474 pass
475 self._state.forwards = {}
476
477
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800478class SSLEnabledWebSocketBaseClient(WebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800479 def __init__(self, state, *args, **kwargs):
480 cafile = ssl.get_default_verify_paths().openssl_cafile
481 # For some system / distribution, python can not detect system cafile path.
482 # In such case we fallback to the default path.
483 if not os.path.exists(cafile):
484 cafile = '/etc/ssl/certs/ca-certificates.crt'
485
486 if state.ssl_self_signed:
487 cafile = GetTLSCertPath(state.host)
488
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800489 ssl_options = {
490 'cert_reqs': ssl.CERT_REQUIRED,
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800491 'ca_certs': cafile
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800492 }
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800493 # ws4py does not allow you to specify SSLContext, but rather passing in the
494 # argument of ssl.wrap_socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800495 super(SSLEnabledWebSocketBaseClient, self).__init__(
496 ssl_options=ssl_options, *args, **kwargs)
497
498
499class TerminalWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800500 def __init__(self, state, mid, escape, *args, **kwargs):
501 super(TerminalWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800502 self._mid = mid
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800503 self._escape = escape
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800504 self._stdin_fd = sys.stdin.fileno()
505 self._old_termios = None
506
507 def handshake_ok(self):
508 pass
509
510 def opened(self):
511 nonlocals = {'size': (80, 40)}
512
513 def _ResizeWindow():
514 size = GetTerminalSize()
515 if size != nonlocals['size']: # Size not changed, ignore
516 control = {'command': 'resize', 'params': list(size)}
517 payload = chr(_CONTROL_START) + json.dumps(control) + chr(_CONTROL_END)
518 nonlocals['size'] = size
519 try:
520 self.send(payload, binary=True)
521 except Exception:
522 pass
523
524 def _FeedInput():
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800525 self._old_termios = termios.tcgetattr(self._stdin_fd)
526 tty.setraw(self._stdin_fd)
527
528 READY, ENTER_PRESSED, ESCAPE_PRESSED = range(3)
529
530 try:
531 state = READY
532 while True:
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800533 # Check if terminal is resized
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800534 _ResizeWindow()
535
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800536 ch = sys.stdin.read(1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800537
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800538 # Scan for escape sequence
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800539 if self._escape:
540 if state == READY:
541 state = ENTER_PRESSED if ch == chr(0x0d) else READY
542 elif state == ENTER_PRESSED:
543 state = ESCAPE_PRESSED if ch == self._escape else READY
544 elif state == ESCAPE_PRESSED:
545 if ch == '.':
546 self.close()
547 break
548 else:
549 state = READY
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800550
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800551 self.send(ch)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800552 except (KeyboardInterrupt, RuntimeError):
553 pass
554
555 t = threading.Thread(target=_FeedInput)
556 t.daemon = True
557 t.start()
558
559 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800560 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800561 termios.tcsetattr(self._stdin_fd, termios.TCSANOW, self._old_termios)
562 print('Connection to %s closed.' % self._mid)
563
Yilin Yangf64670b2020-01-06 11:22:18 +0800564 def received_message(self, message):
565 if message.is_binary:
566 sys.stdout.write(message.data)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800567 sys.stdout.flush()
568
569
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800570class ShellWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800571 def __init__(self, state, output, *args, **kwargs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800572 """Constructor.
573
574 Args:
575 output: output file object.
576 """
577 self.output = output
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800578 super(ShellWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800579
580 def handshake_ok(self):
581 pass
582
583 def opened(self):
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800584 def _FeedInput():
585 try:
586 while True:
587 data = sys.stdin.read(1)
588
Peter Shihf84a8972017-06-19 15:18:24 +0800589 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800590 self.send(_STDIN_CLOSED * 2)
591 break
592 self.send(data, binary=True)
593 except (KeyboardInterrupt, RuntimeError):
594 pass
595
596 t = threading.Thread(target=_FeedInput)
597 t.daemon = True
598 t.start()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800599
600 def closed(self, code, reason=None):
601 pass
602
Yilin Yangf64670b2020-01-06 11:22:18 +0800603 def received_message(self, message):
604 if message.is_binary:
605 self.output.write(message.data)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800606 self.output.flush()
607
608
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800609class ForwarderWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800610 def __init__(self, state, sock, *args, **kwargs):
611 super(ForwarderWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800612 self._sock = sock
613 self._stop = threading.Event()
614
615 def handshake_ok(self):
616 pass
617
618 def opened(self):
619 def _FeedInput():
620 try:
621 self._sock.setblocking(False)
622 while True:
623 rd, unused_w, unused_x = select.select([self._sock], [], [], 0.5)
624 if self._stop.is_set():
625 break
626 if self._sock in rd:
627 data = self._sock.recv(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +0800628 if not data:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800629 self.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800630 break
631 self.send(data, binary=True)
632 except Exception:
633 pass
634 finally:
635 self._sock.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800636
637 t = threading.Thread(target=_FeedInput)
638 t.daemon = True
639 t.start()
640
641 def closed(self, code, reason=None):
Peter Shihf84a8972017-06-19 15:18:24 +0800642 del code, reason # Unused.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800643 self._stop.set()
644 sys.exit(0)
645
Yilin Yangf64670b2020-01-06 11:22:18 +0800646 def received_message(self, message):
647 if message.is_binary:
648 self._sock.send(message.data)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800649
650
651def Arg(*args, **kwargs):
652 return (args, kwargs)
653
654
655def Command(command, help_msg=None, args=None):
656 """Decorator for adding argparse parameter for a method."""
657 if args is None:
658 args = []
659 def WrapFunc(func):
Peter Shih13e78c52018-01-23 12:57:07 +0800660 @functools.wraps(func)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800661 def Wrapped(*args, **kwargs):
662 return func(*args, **kwargs)
Peter Shih99b73ec2017-06-16 17:54:15 +0800663 # pylint: disable=protected-access
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800664 Wrapped.__arg_attr = {'command': command, 'help': help_msg, 'args': args}
665 return Wrapped
666 return WrapFunc
667
668
669def ParseMethodSubCommands(cls):
670 """Decorator for a class using the @Command decorator.
671
672 This decorator retrieve command info from each method and append it in to the
673 SUBCOMMANDS class variable, which is later used to construct parser.
674 """
Yilin Yangea784662019-09-26 13:51:03 +0800675 for unused_key, method in iteritems(cls.__dict__):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800676 if hasattr(method, '__arg_attr'):
Peter Shih99b73ec2017-06-16 17:54:15 +0800677 # pylint: disable=protected-access
678 cls.SUBCOMMANDS.append(method.__arg_attr)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800679 return cls
680
681
682@ParseMethodSubCommands
683class OverlordCLIClient(object):
684 """Overlord command line interface client."""
685
686 SUBCOMMANDS = []
687
688 def __init__(self):
689 self._parser = self._BuildParser()
690 self._selected_mid = None
691 self._server = None
692 self._state = None
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800693 self._escape = None
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800694
695 def _BuildParser(self):
696 root_parser = argparse.ArgumentParser(prog='ovl')
697 subparsers = root_parser.add_subparsers(help='sub-command')
698
699 root_parser.add_argument('-s', dest='selected_mid', action='store',
700 default=None,
701 help='select target to execute command on')
702 root_parser.add_argument('-S', dest='select_mid_before_action',
703 action='store_true', default=False,
704 help='select target before executing command')
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800705 root_parser.add_argument('-e', dest='escape', metavar='ESCAPE_CHAR',
706 action='store', default=_ESCAPE, type=str,
707 help='set shell escape character, \'none\' to '
708 'disable escape completely')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800709
710 for attr in self.SUBCOMMANDS:
711 parser = subparsers.add_parser(attr['command'], help=attr['help'])
712 parser.set_defaults(which=attr['command'])
713 for arg in attr['args']:
714 parser.add_argument(*arg[0], **arg[1])
715
716 return root_parser
717
718 def Main(self):
719 # We want to pass the rest of arguments after shell command directly to the
720 # function without parsing it.
721 try:
722 index = sys.argv.index('shell')
723 except ValueError:
724 args = self._parser.parse_args()
725 else:
726 args = self._parser.parse_args(sys.argv[1:index + 1])
727
728 command = args.which
729 self._selected_mid = args.selected_mid
730
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800731 if args.escape and args.escape != 'none':
732 self._escape = args.escape[0]
733
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800734 if command == 'start-server':
735 self.StartServer()
736 return
737 elif command == 'kill-server':
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800738 self.KillServer()
739 return
740
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800741 self.CheckDaemon()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800742 if command == 'status':
743 self.Status()
744 return
745 elif command == 'connect':
746 self.Connect(args)
747 return
748
749 # The following command requires connection to the server
750 self.CheckConnection()
751
752 if args.select_mid_before_action:
753 self.SelectClient(store=False)
754
755 if command == 'select':
756 self.SelectClient(args)
757 elif command == 'ls':
Peter Shih99b73ec2017-06-16 17:54:15 +0800758 self.ListClients(args)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800759 elif command == 'shell':
760 command = sys.argv[sys.argv.index('shell') + 1:]
761 self.Shell(command)
762 elif command == 'push':
763 self.Push(args)
764 elif command == 'pull':
765 self.Pull(args)
766 elif command == 'forward':
767 self.Forward(args)
768
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800769 def _SaveTLSCertificate(self, host, cert_pem):
770 try:
771 os.makedirs(_CERT_DIR)
772 except Exception:
773 pass
774 with open(GetTLSCertPath(host), 'w') as f:
775 f.write(cert_pem)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800776
777 def _HTTPPostFile(self, url, filename, progress=None, user=None, passwd=None):
778 """Perform HTTP POST and upload file to Overlord.
779
780 To minimize the external dependencies, we construct the HTTP post request
781 by ourselves.
782 """
783 url = MakeRequestUrl(self._state, url)
784 size = os.stat(filename).st_size
785 boundary = '-----------%s' % _HTTP_BOUNDARY_MAGIC
786 CRLF = '\r\n'
787 parse = urlparse.urlparse(url)
788
789 part_headers = [
790 '--' + boundary,
791 'Content-Disposition: form-data; name="file"; '
792 'filename="%s"' % os.path.basename(filename),
793 'Content-Type: application/octet-stream',
794 '', ''
795 ]
796 part_header = CRLF.join(part_headers)
797 end_part = CRLF + '--' + boundary + '--' + CRLF
798
799 content_length = len(part_header) + size + len(end_part)
800 if parse.scheme == 'http':
Yilin Yang752db712019-09-27 15:42:38 +0800801 h = http.client.HTTP(parse.netloc)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800802 else:
Yilin Yang752db712019-09-27 15:42:38 +0800803 h = http.client.HTTPS(parse.netloc, context=self._state.ssl_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800804
805 post_path = url[url.index(parse.netloc) + len(parse.netloc):]
806 h.putrequest('POST', post_path)
807 h.putheader('Content-Length', content_length)
808 h.putheader('Content-Type', 'multipart/form-data; boundary=%s' % boundary)
809
810 if user and passwd:
811 h.putheader(*BasicAuthHeader(user, passwd))
812 h.endheaders()
813 h.send(part_header)
814
815 count = 0
816 with open(filename, 'r') as f:
817 while True:
818 data = f.read(_BUFSIZ)
819 if not data:
820 break
821 count += len(data)
822 if progress:
Yilin Yang14d02a22019-11-01 11:32:03 +0800823 progress(count * 100 // size, count)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800824 h.send(data)
825
826 h.send(end_part)
827 progress(100)
828
829 if count != size:
830 logging.warning('file changed during upload, upload may be truncated.')
831
832 errcode, unused_x, unused_y = h.getreply()
833 return errcode == 200
834
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800835 def CheckDaemon(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800836 self._server = OverlordClientDaemon.GetRPCServer()
837 if self._server is None:
838 print('* daemon not running, starting it now on port %d ... *' %
839 _OVERLORD_CLIENT_DAEMON_PORT)
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800840 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800841
842 self._state = self._server.State()
843 sha1sum = GetVersionDigest()
844
845 if sha1sum != self._state.version_sha1sum:
846 print('ovl server is out of date. killing...')
847 KillGraceful(self._server.GetPid())
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800848 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800849
850 def GetSSHControlFile(self, host):
851 return _SSH_CONTROL_SOCKET_PREFIX + host
852
853 def SSHTunnel(self, user, host, port):
854 """SSH forward the remote overlord server.
855
856 Overlord server may not have port 9000 open to the public network, in such
857 case we can SSH forward the port to localhost.
858 """
859
860 control_file = self.GetSSHControlFile(host)
861 try:
862 os.unlink(control_file)
863 except Exception:
864 pass
865
866 subprocess.Popen([
867 'ssh', '-Nf',
868 '-M', # Enable master mode
869 '-S', control_file,
870 '-L', '9000:localhost:9000',
871 '-p', str(port),
872 '%s%s' % (user + '@' if user else '', host)
873 ]).wait()
874
875 p = subprocess.Popen([
876 'ssh',
877 '-S', control_file,
878 '-O', 'check', host,
879 ], stderr=subprocess.PIPE)
880 unused_stdout, stderr = p.communicate()
881
882 s = re.search(r'pid=(\d+)', stderr)
883 if s:
884 return int(s.group(1))
885
886 raise RuntimeError('can not establish ssh connection')
887
888 def CheckConnection(self):
889 if self._state.host is None:
890 raise RuntimeError('not connected to any server, abort')
891
892 try:
893 self._server.Clients()
894 except Exception:
895 raise RuntimeError('remote server disconnected, abort')
896
897 if self._state.ssh_pid is not None:
898 ret = subprocess.Popen(['kill', '-0', str(self._state.ssh_pid)],
899 stdout=subprocess.PIPE,
900 stderr=subprocess.PIPE).wait()
901 if ret != 0:
902 raise RuntimeError('ssh tunnel disconnected, please re-connect')
903
904 def CheckClient(self):
905 if self._selected_mid is None:
906 if self._state.selected_mid is None:
907 raise RuntimeError('No client is selected')
908 self._selected_mid = self._state.selected_mid
909
Peter Shihcf0f3b22017-06-19 15:59:22 +0800910 if not any(client['mid'] == self._selected_mid
911 for client in self._server.Clients()):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800912 raise RuntimeError('client %s disappeared' % self._selected_mid)
913
914 def CheckOutput(self, command):
915 headers = []
916 if self._state.username is not None and self._state.password is not None:
917 headers.append(BasicAuthHeader(self._state.username,
918 self._state.password))
919
920 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
921 sio = StringIO.StringIO()
Peter Shihe03450b2017-06-14 14:02:08 +0800922 ws = ShellWebSocketClient(
923 self._state, sio, scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
924 self._state.host, self._state.port,
925 urllib2.quote(self._selected_mid), urllib2.quote(command)),
926 headers=headers)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800927 ws.connect()
928 ws.run()
929 return sio.getvalue()
930
931 @Command('status', 'show Overlord connection status')
932 def Status(self):
933 if self._state.host is None:
934 print('Not connected to any host.')
935 else:
936 if self._state.ssh_pid is not None:
937 print('Connected to %s with SSH tunneling.' % self._state.orig_host)
938 else:
939 print('Connected to %s:%d.' % (self._state.host, self._state.port))
940
941 if self._selected_mid is None:
942 self._selected_mid = self._state.selected_mid
943
944 if self._selected_mid is None:
945 print('No client is selected.')
946 else:
947 print('Client %s selected.' % self._selected_mid)
948
949 @Command('connect', 'connect to Overlord server', [
950 Arg('host', metavar='HOST', type=str, default='localhost',
951 help='Overlord hostname/IP'),
952 Arg('port', metavar='PORT', type=int,
953 default=_OVERLORD_HTTP_PORT, help='Overlord port'),
954 Arg('-f', '--forward', dest='ssh_forward', default=False,
955 action='store_true',
956 help='connect with SSH forwarding to the host'),
957 Arg('-p', '--ssh-port', dest='ssh_port', default=22,
958 type=int, help='SSH server port for SSH forwarding'),
959 Arg('-l', '--ssh-login', dest='ssh_login', default='',
960 type=str, help='SSH server login name for SSH forwarding'),
961 Arg('-u', '--user', dest='user', default=None,
962 type=str, help='Overlord HTTP auth username'),
963 Arg('-w', '--passwd', dest='passwd', default=None, type=str,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800964 help='Overlord HTTP auth password'),
965 Arg('-i', '--no-check-hostname', dest='check_hostname',
966 default=True, action='store_false',
967 help='Ignore SSL cert hostname check')])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800968 def Connect(self, args):
969 ssh_pid = None
970 host = args.host
971 orig_host = args.host
972
973 if args.ssh_forward:
974 # Kill previous SSH tunnel
975 self.KillSSHTunnel()
976
977 ssh_pid = self.SSHTunnel(args.ssh_login, args.host, args.ssh_port)
978 host = 'localhost'
979
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800980 username_provided = args.user is not None
981 password_provided = args.passwd is not None
982 prompt = False
983
Peter Shih533566a2018-09-05 17:48:03 +0800984 for unused_i in range(3): # pylint: disable=too-many-nested-blocks
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800985 try:
986 if prompt:
987 if not username_provided:
Yilin Yang8cc5dfb2019-10-22 15:58:53 +0800988 args.user = input('Username: ')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800989 if not password_provided:
990 args.passwd = getpass.getpass('Password: ')
991
992 ret = self._server.Connect(host, args.port, ssh_pid, args.user,
Wei-Ning Huang4f8f6092017-06-09 19:35:35 +0800993 args.passwd, orig_host,
994 args.check_hostname)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800995 if isinstance(ret, list):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800996 if ret[0].startswith('SSL'):
997 cert_pem = ret[1]
998 fp = GetTLSCertificateSHA1Fingerprint(cert_pem)
999 fp_text = ':'.join([fp[i:i+2] for i in range(0, len(fp), 2)])
1000
1001 if ret[0] == 'SSLCertificateChanged':
1002 print(_TLS_CERT_CHANGED_WARNING % (fp_text, GetTLSCertPath(host)))
1003 return
1004 elif ret[0] == 'SSLVerifyFailed':
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001005 print(_TLS_CERT_FAILED_WARNING % (fp_text), end='')
Yilin Yang8cc5dfb2019-10-22 15:58:53 +08001006 response = input()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001007 if response.lower() in ['y', 'ye', 'yes']:
1008 self._SaveTLSCertificate(host, cert_pem)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001009 print('TLS host Certificate trusted, you will not be prompted '
1010 'next time.\n')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001011 continue
1012 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001013 print('connection aborted.')
1014 return
1015 elif ret[0] == 'HTTPError':
1016 code, except_str, body = ret[1:]
1017 if code == 401:
1018 print('connect: %s' % body)
1019 prompt = True
1020 if not username_provided or not password_provided:
1021 continue
1022 else:
1023 break
1024 else:
1025 logging.error('%s; %s', except_str, body)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001026
1027 if ret is not True:
1028 print('can not connect to %s: %s' % (host, ret))
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001029 else:
1030 print('connection to %s:%d established.' % (host, args.port))
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001031 except Exception as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001032 logging.error(e)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001033 else:
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001034 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001035
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001036 @Command('start-server', 'start overlord CLI client server')
1037 def StartServer(self):
1038 self._server = OverlordClientDaemon.GetRPCServer()
1039 if self._server is None:
1040 OverlordClientDaemon().Start()
1041 time.sleep(1)
1042 self._server = OverlordClientDaemon.GetRPCServer()
1043 if self._server is not None:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001044 print('* daemon started successfully *\n')
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001045
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001046 @Command('kill-server', 'kill overlord CLI client server')
1047 def KillServer(self):
1048 self._server = OverlordClientDaemon.GetRPCServer()
1049 if self._server is None:
1050 return
1051
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001052 self._state = self._server.State()
1053
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001054 # Kill SSH Tunnel
1055 self.KillSSHTunnel()
1056
1057 # Kill server daemon
1058 KillGraceful(self._server.GetPid())
1059
1060 def KillSSHTunnel(self):
1061 if self._state.ssh_pid is not None:
1062 KillGraceful(self._state.ssh_pid)
1063
Peter Shihcf0f3b22017-06-19 15:59:22 +08001064 def _FilterClients(self, clients, prop_filters, mid=None):
1065 def _ClientPropertiesMatch(client, key, regex):
1066 try:
1067 return bool(re.search(regex, client['properties'][key]))
1068 except KeyError:
1069 return False
1070
1071 for prop_filter in prop_filters:
1072 key, sep, regex = prop_filter.partition('=')
1073 if not sep:
1074 # The filter doesn't contains =.
1075 raise ValueError('Invalid filter condition %r' % filter)
1076 clients = [c for c in clients if _ClientPropertiesMatch(c, key, regex)]
1077
1078 if mid is not None:
1079 client = next((c for c in clients if c['mid'] == mid), None)
1080 if client:
1081 return [client]
1082 clients = [c for c in clients if c['mid'].startswith(mid)]
1083 return clients
1084
1085 @Command('ls', 'list clients', [
1086 Arg('-f', '--filter', default=[], dest='filters', action='append',
1087 help=('Conditions to filter clients by properties. '
1088 'Should be in form "key=regex", where regex is the regular '
1089 'expression that should be found in the value. '
1090 'Multiple --filter arguments would be ANDed.')),
Peter Shih99b73ec2017-06-16 17:54:15 +08001091 Arg('-v', '--verbose', default=False, action='store_true',
1092 help='Print properties of each client.')
1093 ])
1094 def ListClients(self, args):
Peter Shihcf0f3b22017-06-19 15:59:22 +08001095 clients = self._FilterClients(self._server.Clients(), args.filters)
1096 for client in clients:
1097 if args.verbose:
Peter Shih99b73ec2017-06-16 17:54:15 +08001098 print(yaml.safe_dump(client, default_flow_style=False))
Peter Shihcf0f3b22017-06-19 15:59:22 +08001099 else:
1100 print(client['mid'])
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001101
1102 @Command('select', 'select default client', [
Peter Shihcf0f3b22017-06-19 15:59:22 +08001103 Arg('-f', '--filter', default=[], dest='filters', action='append',
1104 help=('Conditions to filter clients by properties. '
1105 'Should be in form "key=regex", where regex is the regular '
1106 'expression that should be found in the value. '
1107 'Multiple --filter arguments would be ANDed.')),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001108 Arg('mid', metavar='mid', nargs='?', default=None)])
1109 def SelectClient(self, args=None, store=True):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001110 mid = args.mid if args is not None else None
Wei-Ning Huang6796a132017-06-22 14:46:32 +08001111 filters = args.filters if args is not None else []
1112 clients = self._FilterClients(self._server.Clients(), filters, mid=mid)
Peter Shihcf0f3b22017-06-19 15:59:22 +08001113
1114 if not clients:
1115 raise RuntimeError('select: client not found')
1116 elif len(clients) == 1:
1117 mid = clients[0]['mid']
1118 else:
1119 # This case would not happen when args.mid is specified.
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001120 print('Select from the following clients:')
1121 for i, client in enumerate(clients):
Peter Shihcf0f3b22017-06-19 15:59:22 +08001122 print(' %d. %s' % (i + 1, client['mid']))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001123
1124 print('\nSelection: ', end='')
1125 try:
Yilin Yang8cc5dfb2019-10-22 15:58:53 +08001126 choice = int(input()) - 1
Peter Shihcf0f3b22017-06-19 15:59:22 +08001127 mid = clients[choice]['mid']
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001128 except ValueError:
1129 raise RuntimeError('select: invalid selection')
1130 except IndexError:
1131 raise RuntimeError('select: selection out of range')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001132
1133 self._selected_mid = mid
1134 if store:
1135 self._server.SelectClient(mid)
1136 print('Client %s selected' % mid)
1137
1138 @Command('shell', 'open a shell or execute a shell command', [
1139 Arg('command', metavar='CMD', nargs='?', help='command to execute')])
1140 def Shell(self, command=None):
1141 if command is None:
1142 command = []
1143 self.CheckClient()
1144
1145 headers = []
1146 if self._state.username is not None and self._state.password is not None:
1147 headers.append(BasicAuthHeader(self._state.username,
1148 self._state.password))
1149
1150 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
Peter Shihe03450b2017-06-14 14:02:08 +08001151 if command:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001152 cmd = ' '.join(command)
Peter Shihe03450b2017-06-14 14:02:08 +08001153 ws = ShellWebSocketClient(
1154 self._state, sys.stdout,
1155 scheme + '%s:%d/api/agent/shell/%s?command=%s' % (
1156 self._state.host, self._state.port,
1157 urllib2.quote(self._selected_mid), urllib2.quote(cmd)),
1158 headers=headers)
1159 else:
1160 ws = TerminalWebSocketClient(
1161 self._state, self._selected_mid, self._escape,
1162 scheme + '%s:%d/api/agent/tty/%s' % (
1163 self._state.host, self._state.port,
1164 urllib2.quote(self._selected_mid)),
1165 headers=headers)
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001166 try:
1167 ws.connect()
1168 ws.run()
1169 except socket.error as e:
1170 if e.errno == 32: # Broken pipe
1171 pass
1172 else:
1173 raise
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001174
1175 @Command('push', 'push a file or directory to remote', [
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001176 Arg('srcs', nargs='+', metavar='SOURCE'),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001177 Arg('dst', metavar='DESTINATION')])
1178 def Push(self, args):
1179 self.CheckClient()
1180
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001181 @AutoRetry('push', _RETRY_TIMES)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001182 def _push(src, dst):
1183 src_base = os.path.basename(src)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001184
1185 # Local file is a link
1186 if os.path.islink(src):
1187 pbar = ProgressBar(src_base)
1188 link_path = os.readlink(src)
1189 self.CheckOutput('mkdir -p %(dirname)s; '
1190 'if [ -d "%(dst)s" ]; then '
1191 'ln -sf "%(link_path)s" "%(dst)s/%(link_name)s"; '
1192 'else ln -sf "%(link_path)s" "%(dst)s"; fi' %
1193 dict(dirname=os.path.dirname(dst),
1194 link_path=link_path, dst=dst,
1195 link_name=src_base))
1196 pbar.End()
1197 return
1198
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001199 mode = '0%o' % (0x1FF & os.stat(src).st_mode)
1200 url = ('%s:%d/api/agent/upload/%s?dest=%s&perm=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001201 (self._state.host, self._state.port,
1202 urllib2.quote(self._selected_mid), dst, mode))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001203 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001204 UrlOpen(self._state, url + '&filename=%s' % src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001205 except urllib2.HTTPError as e:
1206 msg = json.loads(e.read()).get('error', None)
1207 raise RuntimeError('push: %s' % msg)
1208
1209 pbar = ProgressBar(src_base)
1210 self._HTTPPostFile(url, src, pbar.SetProgress,
1211 self._state.username, self._state.password)
1212 pbar.End()
1213
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001214 def _push_single_target(src, dst):
1215 if os.path.isdir(src):
1216 dst_exists = ast.literal_eval(self.CheckOutput(
1217 'stat %s >/dev/null 2>&1 && echo True || echo False' % dst))
1218 for root, unused_x, files in os.walk(src):
1219 # If destination directory does not exist, we should strip the first
1220 # layer of directory. For example: src_dir contains a single file 'A'
1221 #
1222 # push src_dir dest_dir
1223 #
1224 # If dest_dir exists, the resulting directory structure should be:
1225 # dest_dir/src_dir/A
1226 # If dest_dir does not exist, the resulting directory structure should
1227 # be:
1228 # dest_dir/A
1229 dst_root = root if dst_exists else root[len(src):].lstrip('/')
1230 for name in files:
1231 _push(os.path.join(root, name),
1232 os.path.join(dst, dst_root, name))
1233 else:
1234 _push(src, dst)
1235
1236 if len(args.srcs) > 1:
1237 dst_type = self.CheckOutput('stat \'%s\' --printf \'%%F\' '
1238 '2>/dev/null' % args.dst).strip()
1239 if not dst_type:
1240 raise RuntimeError('push: %s: No such file or directory' % args.dst)
1241 if dst_type != 'directory':
1242 raise RuntimeError('push: %s: Not a directory' % args.dst)
1243
1244 for src in args.srcs:
1245 if not os.path.exists(src):
1246 raise RuntimeError('push: can not stat "%s": no such file or directory'
1247 % src)
1248 if not os.access(src, os.R_OK):
1249 raise RuntimeError('push: can not open "%s" for reading' % src)
1250
1251 _push_single_target(src, args.dst)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001252
1253 @Command('pull', 'pull a file or directory from remote', [
1254 Arg('src', metavar='SOURCE'),
1255 Arg('dst', metavar='DESTINATION', default='.', nargs='?')])
1256 def Pull(self, args):
1257 self.CheckClient()
1258
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001259 @AutoRetry('pull', _RETRY_TIMES)
Peter Shihe6afab32018-09-11 17:16:48 +08001260 def _pull(src, dst, ftype, perm=0o644, link=None):
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001261 try:
1262 os.makedirs(os.path.dirname(dst))
1263 except Exception:
1264 pass
1265
1266 src_base = os.path.basename(src)
1267
1268 # Remote file is a link
1269 if ftype == 'l':
1270 pbar = ProgressBar(src_base)
1271 if os.path.exists(dst):
1272 os.remove(dst)
1273 os.symlink(link, dst)
1274 pbar.End()
1275 return
1276
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001277 url = ('%s:%d/api/agent/download/%s?filename=%s' %
Peter Shihe03450b2017-06-14 14:02:08 +08001278 (self._state.host, self._state.port,
1279 urllib2.quote(self._selected_mid), urllib2.quote(src)))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001280 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001281 h = UrlOpen(self._state, url)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001282 except urllib2.HTTPError as e:
1283 msg = json.loads(e.read()).get('error', 'unkown error')
1284 raise RuntimeError('pull: %s' % msg)
1285 except KeyboardInterrupt:
1286 return
1287
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001288 pbar = ProgressBar(src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001289 with open(dst, 'w') as f:
1290 os.fchmod(f.fileno(), perm)
1291 total_size = int(h.headers.get('Content-Length'))
1292 downloaded_size = 0
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001293
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001294 while True:
1295 data = h.read(_BUFSIZ)
Peter Shihf84a8972017-06-19 15:18:24 +08001296 if not data:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001297 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001298 downloaded_size += len(data)
Yilin Yang14d02a22019-11-01 11:32:03 +08001299 pbar.SetProgress(downloaded_size * 100 / total_size,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001300 downloaded_size)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001301 f.write(data)
1302 pbar.End()
1303
1304 # Use find to get a listing of all files under a root directory. The 'stat'
1305 # command is used to retrieve the filename and it's filemode.
1306 output = self.CheckOutput(
1307 'cd $HOME; '
1308 'stat "%(src)s" >/dev/null && '
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001309 'find "%(src)s" \'(\' -type f -o -type l \')\' '
1310 '-printf \'%%m\t%%p\t%%y\t%%l\n\''
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001311 % {'src': args.src})
1312
1313 # We got error from the stat command
1314 if output.startswith('stat: '):
1315 sys.stderr.write(output)
1316 return
1317
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001318 entries = output.strip('\n').split('\n')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001319 common_prefix = os.path.dirname(args.src)
1320
1321 if len(entries) == 1:
1322 entry = entries[0]
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001323 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001324 if os.path.isdir(args.dst):
1325 dst = os.path.join(args.dst, os.path.basename(src_path))
1326 else:
1327 dst = args.dst
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001328 _pull(src_path, dst, ftype, int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001329 else:
1330 if not os.path.exists(args.dst):
1331 common_prefix = args.src
1332
1333 for entry in entries:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001334 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001335 rel_dst = src_path[len(common_prefix):].lstrip('/')
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001336 _pull(src_path, os.path.join(args.dst, rel_dst), ftype,
1337 int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001338
1339 @Command('forward', 'forward remote port to local port', [
1340 Arg('--list', dest='list_all', action='store_true', default=False,
1341 help='list all port forwarding sessions'),
1342 Arg('--remove', metavar='LOCAL_PORT', dest='remove', type=int,
1343 default=None,
1344 help='remove port forwarding for local port LOCAL_PORT'),
1345 Arg('--remove-all', dest='remove_all', action='store_true',
1346 default=False, help='remove all port forwarding'),
1347 Arg('remote', metavar='REMOTE_PORT', type=int, nargs='?'),
1348 Arg('local', metavar='LOCAL_PORT', type=int, nargs='?')])
1349 def Forward(self, args):
1350 if args.list_all:
1351 max_len = 10
Peter Shihf84a8972017-06-19 15:18:24 +08001352 if self._state.forwards:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001353 max_len = max([len(v[0]) for v in self._state.forwards.values()])
1354
1355 print('%-*s %-8s %-8s' % (max_len, 'Client', 'Remote', 'Local'))
1356 for local in sorted(self._state.forwards.keys()):
1357 value = self._state.forwards[local]
1358 print('%-*s %-8s %-8s' % (max_len, value[0], value[1], local))
1359 return
1360
1361 if args.remove_all:
1362 self._server.RemoveAllForward()
1363 return
1364
1365 if args.remove:
1366 self._server.RemoveForward(args.remove)
1367 return
1368
1369 self.CheckClient()
1370
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001371 if args.remote is None:
1372 raise RuntimeError('remote port not specified')
1373
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001374 if args.local is None:
1375 args.local = args.remote
1376 remote = int(args.remote)
1377 local = int(args.local)
1378
1379 def HandleConnection(conn):
1380 headers = []
1381 if self._state.username is not None and self._state.password is not None:
1382 headers.append(BasicAuthHeader(self._state.username,
1383 self._state.password))
1384
1385 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1386 ws = ForwarderWebSocketClient(
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001387 self._state, conn,
Peter Shihe03450b2017-06-14 14:02:08 +08001388 scheme + '%s:%d/api/agent/forward/%s?port=%d' % (
1389 self._state.host, self._state.port,
1390 urllib2.quote(self._selected_mid), remote),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001391 headers=headers)
1392 try:
1393 ws.connect()
1394 ws.run()
1395 except Exception as e:
1396 print('error: %s' % e)
1397 finally:
1398 ws.close()
1399
1400 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1401 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1402 server.bind(('0.0.0.0', local))
1403 server.listen(5)
1404
1405 pid = os.fork()
1406 if pid == 0:
1407 while True:
1408 conn, unused_addr = server.accept()
1409 t = threading.Thread(target=HandleConnection, args=(conn,))
1410 t.daemon = True
1411 t.start()
1412 else:
1413 self._server.AddForward(self._selected_mid, remote, local, pid)
1414
1415
1416def main():
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001417 # Setup logging format
1418 logger = logging.getLogger()
1419 logger.setLevel(logging.INFO)
1420 handler = logging.StreamHandler()
1421 formatter = logging.Formatter('%(asctime)s %(message)s', '%Y/%m/%d %H:%M:%S')
1422 handler.setFormatter(formatter)
1423 logger.addHandler(handler)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001424
1425 # Add DaemonState to JSONRPC lib classes
1426 Config.instance().classes.add(DaemonState)
1427
1428 ovl = OverlordCLIClient()
1429 try:
1430 ovl.Main()
1431 except KeyboardInterrupt:
1432 print('Ctrl-C received, abort')
1433 except Exception as e:
1434 print('error: %s' % e)
1435
1436
1437if __name__ == '__main__':
1438 main()