blob: a68593aa00a93e9a57653a56708260a0193af019 [file] [log] [blame]
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001#!/usr/bin/env python
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08002# -*- coding: utf-8 -*-
3#
4# Copyright 2015 The Chromium OS Authors. All rights reserved.
5# Use of this source code is governed by a BSD-style license that can be
6# found in the LICENSE file.
7
8from __future__ import print_function
9
10import argparse
11import ast
12import base64
13import fcntl
Wei-Ning Huangba768ab2016-02-07 14:38:06 +080014import getpass
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080015import hashlib
16import httplib
17import json
18import jsonrpclib
19import logging
20import os
21import re
22import select
23import signal
24import socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080025import ssl
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080026import StringIO
27import struct
28import subprocess
29import sys
30import tempfile
31import termios
32import threading
33import time
34import tty
35import urllib2
36import urlparse
Wei-Ning Huangecc80b82016-07-01 16:55:10 +080037import unicodedata # required by pyinstaller, pylint: disable=W0611
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080038
39from jsonrpclib.SimpleJSONRPCServer import SimpleJSONRPCServer
40from jsonrpclib.config import Config
41from ws4py.client import WebSocketBaseClient
42
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080043
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080044_CERT_DIR = os.path.expanduser('~/.config/ovl')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080045
46_ESCAPE = '~'
47_BUFSIZ = 8192
48_OVERLORD_PORT = 4455
49_OVERLORD_HTTP_PORT = 9000
50_OVERLORD_CLIENT_DAEMON_PORT = 4488
51_OVERLORD_CLIENT_DAEMON_RPC_ADDR = ('127.0.0.1', _OVERLORD_CLIENT_DAEMON_PORT)
52
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080053_CONNECT_TIMEOUT = 3
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +080054_DEFAULT_HTTP_TIMEOUT = 30
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080055_LIST_CACHE_TIMEOUT = 2
56_DEFAULT_TERMINAL_WIDTH = 80
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +080057_RETRY_TIMES = 3
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080058
59# echo -n overlord | md5sum
60_HTTP_BOUNDARY_MAGIC = '9246f080c855a69012707ab53489b921'
61
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +080062# Terminal resize control
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080063_CONTROL_START = 128
64_CONTROL_END = 129
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +080065
66# Stream control
67_STDIN_CLOSED = '##STDIN_CLOSED##'
68
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080069_SSH_CONTROL_SOCKET_PREFIX = os.path.join(tempfile.gettempdir(),
70 'ovl-ssh-control-')
71
Wei-Ning Huang47c79b82016-05-24 01:24:46 +080072_TLS_CERT_FAILED_WARNING = """
73@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
74@ WARNING: REMOTE HOST VERIFICATION HAS FAILED! @
75@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
76IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
77Someone could be eavesdropping on you right now (man-in-the-middle attack)!
78It is also possible that the server is using a self-signed certificate.
79The fingerprint for the TLS host certificate sent by the remote host is
80
81%s
82
83Do you want to trust this certificate and proceed? [Y/n] """
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080084
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080085_TLS_CERT_CHANGED_WARNING = """
86@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
87@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
88@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
89IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
90Someone could be eavesdropping on you right now (man-in-the-middle attack)!
Wei-Ning Huang47c79b82016-05-24 01:24:46 +080091It is also possible that the TLS host certificate has just been changed.
92The fingerprint for the TLS host certificate sent by the remote host is
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080093
94%s
95
96Remove '%s' if you still want to proceed.
97SSL Certificate verification failed."""
98
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080099
100def GetVersionDigest():
101 """Return the sha1sum of the current executing script."""
Wei-Ning Huangecc80b82016-07-01 16:55:10 +0800102 # Because of how pyinstaller works internally, __file__ does not work here.
103 # We use sys.argv[0] as script file here instead.
104 with open(sys.argv[0], 'r') as f:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800105 return hashlib.sha1(f.read()).hexdigest()
106
107
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800108def GetTLSCertPath(host):
109 return os.path.join(_CERT_DIR, '%s.cert' % host)
110
111
112def UrlOpen(state, url):
113 """Wrapper for urllib2.urlopen.
114
115 It selects correct HTTP scheme according to self._state.ssl, add HTTP
116 basic auth headers, and add specify correct SSL context.
117 """
118 url = MakeRequestUrl(state, url)
119 request = urllib2.Request(url)
120 if state.username is not None and state.password is not None:
121 request.add_header(*BasicAuthHeader(state.username, state.password))
122 return urllib2.urlopen(request, timeout=_DEFAULT_HTTP_TIMEOUT,
123 context=state.ssl_context)
124
125
126def GetTLSCertificateSHA1Fingerprint(cert_pem):
127 beg = cert_pem.index('\n')
128 end = cert_pem.rindex('\n', 0, len(cert_pem) - 2)
129 cert_pem = cert_pem[beg:end] # Remove BEGIN/END CERTIFICATE boundary
130 cert_der = base64.b64decode(cert_pem)
131 return hashlib.sha1(cert_der).hexdigest()
132
133
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800134def KillGraceful(pid, wait_secs=1):
135 """Kill a process gracefully by first sending SIGTERM, wait for some time,
136 then send SIGKILL to make sure it's killed."""
137 try:
138 os.kill(pid, signal.SIGTERM)
139 time.sleep(wait_secs)
140 os.kill(pid, signal.SIGKILL)
141 except OSError:
142 pass
143
144
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800145def AutoRetry(action_name, retries):
146 """Decorator for retry function call."""
147 def Wrap(func):
148 def Loop(*args, **kwargs):
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800149 for unused_i in range(retries):
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800150 try:
151 func(*args, **kwargs)
152 except Exception as e:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800153 print('error: %s: %s: retrying ...' % (args[0], e))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800154 else:
155 break
156 else:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800157 print('error: failed to %s %s' % (action_name, args[0]))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800158 return Loop
159 return Wrap
160
161
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800162def BasicAuthHeader(user, password):
163 """Return HTTP basic auth header."""
164 credential = base64.b64encode('%s:%s' % (user, password))
165 return ('Authorization', 'Basic %s' % credential)
166
167
168def GetTerminalSize():
169 """Retrieve terminal window size."""
170 ws = struct.pack('HHHH', 0, 0, 0, 0)
171 ws = fcntl.ioctl(0, termios.TIOCGWINSZ, ws)
172 lines, columns, unused_x, unused_y = struct.unpack('HHHH', ws)
173 return lines, columns
174
175
176def MakeRequestUrl(state, url):
177 return 'http%s://%s' % ('s' if state.ssl else '', url)
178
179
180class ProgressBar(object):
181 SIZE_WIDTH = 11
182 SPEED_WIDTH = 10
183 DURATION_WIDTH = 6
184 PERCENTAGE_WIDTH = 8
185
186 def __init__(self, name):
187 self._start_time = time.time()
188 self._name = name
189 self._size = 0
190 self._width = 0
191 self._name_width = 0
192 self._name_max = 0
193 self._stat_width = 0
194 self._max = 0
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800195 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800196 self.SetProgress(0)
197
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800198 def _CalculateSize(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800199 self._width = GetTerminalSize()[1] or _DEFAULT_TERMINAL_WIDTH
200 self._name_width = int(self._width * 0.3)
201 self._name_max = self._name_width
202 self._stat_width = self.SIZE_WIDTH + self.SPEED_WIDTH + self.DURATION_WIDTH
203 self._max = (self._width - self._name_width - self._stat_width -
204 self.PERCENTAGE_WIDTH)
205
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800206 def _SizeToHuman(self, size_in_bytes):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800207 if size_in_bytes < 1024:
208 unit = 'B'
209 value = size_in_bytes
210 elif size_in_bytes < 1024 ** 2:
211 unit = 'KiB'
212 value = size_in_bytes / 1024.0
213 elif size_in_bytes < 1024 ** 3:
214 unit = 'MiB'
215 value = size_in_bytes / (1024.0 ** 2)
216 elif size_in_bytes < 1024 ** 4:
217 unit = 'GiB'
218 value = size_in_bytes / (1024.0 ** 3)
219 return ' %6.1f %3s' % (value, unit)
220
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800221 def _SpeedToHuman(self, speed_in_bs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800222 if speed_in_bs < 1024:
223 unit = 'B'
224 value = speed_in_bs
225 elif speed_in_bs < 1024 ** 2:
226 unit = 'K'
227 value = speed_in_bs / 1024.0
228 elif speed_in_bs < 1024 ** 3:
229 unit = 'M'
230 value = speed_in_bs / (1024.0 ** 2)
231 elif speed_in_bs < 1024 ** 4:
232 unit = 'G'
233 value = speed_in_bs / (1024.0 ** 3)
234 return ' %6.1f%s/s' % (value, unit)
235
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800236 def _DurationToClock(self, duration):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800237 return ' %02d:%02d' % (duration / 60, duration % 60)
238
239 def SetProgress(self, percentage, size=None):
240 current_width = GetTerminalSize()[1]
241 if self._width != current_width:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800242 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800243
244 if size is not None:
245 self._size = size
246
247 elapse_time = time.time() - self._start_time
248 speed = self._size / float(elapse_time)
249
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800250 size_str = self._SizeToHuman(self._size)
251 speed_str = self._SpeedToHuman(speed)
252 elapse_str = self._DurationToClock(elapse_time)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800253
254 width = int(self._max * percentage / 100.0)
255 sys.stdout.write(
256 '%*s' % (- self._name_max,
257 self._name if len(self._name) <= self._name_max else
258 self._name[:self._name_max - 4] + ' ...') +
259 size_str + speed_str + elapse_str +
260 ((' [' + '#' * width + ' ' * (self._max - width) + ']' +
261 '%4d%%' % int(percentage)) if self._max > 2 else '') + '\r')
262 sys.stdout.flush()
263
264 def End(self):
265 self.SetProgress(100.0)
266 sys.stdout.write('\n')
267 sys.stdout.flush()
268
269
270class DaemonState(object):
271 """DaemonState is used for storing Overlord state info."""
272 def __init__(self):
273 self.version_sha1sum = GetVersionDigest()
274 self.host = None
275 self.port = None
276 self.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800277 self.ssl_self_signed = False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800278 self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800279 self.ssh = False
280 self.orig_host = None
281 self.ssh_pid = None
282 self.username = None
283 self.password = None
284 self.selected_mid = None
285 self.forwards = {}
286 self.listing = []
287 self.last_list = 0
288
289
290class OverlordClientDaemon(object):
291 """Overlord Client Daemon."""
292 def __init__(self):
293 self._state = DaemonState()
294 self._server = None
295
296 def Start(self):
297 self.StartRPCServer()
298
299 def StartRPCServer(self):
300 self._server = SimpleJSONRPCServer(_OVERLORD_CLIENT_DAEMON_RPC_ADDR,
301 logRequests=False)
302 exports = [
303 (self.State, 'State'),
304 (self.Ping, 'Ping'),
305 (self.GetPid, 'GetPid'),
306 (self.Connect, 'Connect'),
307 (self.Clients, 'Clients'),
308 (self.SelectClient, 'SelectClient'),
309 (self.AddForward, 'AddForward'),
310 (self.RemoveForward, 'RemoveForward'),
311 (self.RemoveAllForward, 'RemoveAllForward'),
312 ]
313 for func, name in exports:
314 self._server.register_function(func, name)
315
316 pid = os.fork()
317 if pid == 0:
318 self._server.serve_forever()
319
320 @staticmethod
321 def GetRPCServer():
322 """Returns the Overlord client daemon RPC server."""
323 server = jsonrpclib.Server('http://%s:%d' %
324 _OVERLORD_CLIENT_DAEMON_RPC_ADDR)
325 try:
326 server.Ping()
327 except Exception:
328 return None
329 return server
330
331 def State(self):
332 return self._state
333
334 def Ping(self):
335 return True
336
337 def GetPid(self):
338 return os.getpid()
339
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800340 def _GetJSON(self, path):
341 url = '%s:%d%s' % (self._state.host, self._state.port, path)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800342 return json.loads(UrlOpen(self._state, url).read())
343
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800344 def _TLSEnabled(self):
345 """Determine if TLS is enabled on given server address."""
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800346 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
347 try:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800348 # Allow any certificate since we only want to check if server talks TLS.
349 context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
350 context.verify_mode = ssl.CERT_NONE
351
352 sock = context.wrap_socket(sock, server_hostname=self._state.host)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800353 sock.settimeout(_CONNECT_TIMEOUT)
354 sock.connect((self._state.host, self._state.port))
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800355 return True
Wei-Ning Huangecc80b82016-07-01 16:55:10 +0800356 except ssl.SSLError:
357 return False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800358 except socket.error: # Connect refused or timeout
359 raise
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800360 except Exception:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800361 return False # For whatever reason above failed, assume False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800362
363 def _CheckTLSCertificate(self):
364 """Check TLS certificate.
365
366 Returns:
367 A tupple (check_result, if_certificate_is_loaded)
368 """
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800369 def _DoConnect(context):
370 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
371 try:
372 sock.settimeout(_CONNECT_TIMEOUT)
373 sock = context.wrap_socket(sock, server_hostname=self._state.host)
374 sock.connect((self._state.host, self._state.port))
375 except ssl.SSLError:
376 return False
377 finally:
378 sock.close()
379
380 # Save SSLContext for future use.
381 self._state.ssl_context = context
382 return True
383
384 # First try connect with built-in certificates
385 tls_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
386 if _DoConnect(tls_context):
387 return True
388
389 # Try with already saved certificate, if any.
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800390 tls_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
391 tls_context.verify_mode = ssl.CERT_REQUIRED
392 tls_context.check_hostname = True
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800393
394 tls_cert_path = GetTLSCertPath(self._state.host)
395 if os.path.exists(tls_cert_path):
396 tls_context.load_verify_locations(tls_cert_path)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800397 self._state.ssl_self_signed = True
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800398
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800399 return _DoConnect(tls_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800400
401 def Connect(self, host, port=_OVERLORD_HTTP_PORT, ssh_pid=None,
402 username=None, password=None, orig_host=None):
403 self._state.username = username
404 self._state.password = password
405 self._state.host = host
406 self._state.port = port
407 self._state.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800408 self._state.ssl_self_signed = False
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800409 self._state.orig_host = orig_host
410 self._state.ssh_pid = ssh_pid
411 self._state.selected_mid = None
412
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800413 tls_enabled = self._TLSEnabled()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800414 if tls_enabled:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800415 result = self._CheckTLSCertificate()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800416 if not result:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800417 if self._state.ssl_self_signed:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800418 return ('SSLCertificateChanged', ssl.get_server_certificate(
419 (self._state.host, self._state.port)))
420 else:
421 return ('SSLVerifyFailed', ssl.get_server_certificate(
422 (self._state.host, self._state.port)))
423
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800424 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800425 self._state.ssl = tls_enabled
426 UrlOpen(self._state, '%s:%d' % (host, port))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800427 except urllib2.HTTPError as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800428 return ('HTTPError', e.getcode(), str(e), e.read().strip())
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800429 except Exception as e:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800430 return str(e)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800431 else:
432 return True
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800433
434 def Clients(self):
435 if time.time() - self._state.last_list <= _LIST_CACHE_TIMEOUT:
436 return self._state.listing
437
438 mids = [client['mid'] for client in self._GetJSON('/api/agents/list')]
439 self._state.listing = sorted(list(set(mids)))
440 self._state.last_list = time.time()
441 return self._state.listing
442
443 def SelectClient(self, mid):
444 self._state.selected_mid = mid
445
446 def AddForward(self, mid, remote, local, pid):
447 self._state.forwards[local] = (mid, remote, pid)
448
449 def RemoveForward(self, local_port):
450 try:
451 unused_mid, unused_remote, pid = self._state.forwards[local_port]
452 KillGraceful(pid)
453 del self._state.forwards[local_port]
454 except (KeyError, OSError):
455 pass
456
457 def RemoveAllForward(self):
458 for unused_mid, unused_remote, pid in self._state.forwards.values():
459 try:
460 KillGraceful(pid)
461 except OSError:
462 pass
463 self._state.forwards = {}
464
465
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800466class SSLEnabledWebSocketBaseClient(WebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800467 def __init__(self, state, *args, **kwargs):
468 cafile = ssl.get_default_verify_paths().openssl_cafile
469 # For some system / distribution, python can not detect system cafile path.
470 # In such case we fallback to the default path.
471 if not os.path.exists(cafile):
472 cafile = '/etc/ssl/certs/ca-certificates.crt'
473
474 if state.ssl_self_signed:
475 cafile = GetTLSCertPath(state.host)
476
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800477 ssl_options = {
478 'cert_reqs': ssl.CERT_REQUIRED,
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800479 'ca_certs': cafile
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800480 }
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800481 # ws4py does not allow you to specify SSLContext, but rather passing in the
482 # argument of ssl.wrap_socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800483 super(SSLEnabledWebSocketBaseClient, self).__init__(
484 ssl_options=ssl_options, *args, **kwargs)
485
486
487class TerminalWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800488 def __init__(self, state, mid, escape, *args, **kwargs):
489 super(TerminalWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800490 self._mid = mid
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800491 self._escape = escape
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800492 self._stdin_fd = sys.stdin.fileno()
493 self._old_termios = None
494
495 def handshake_ok(self):
496 pass
497
498 def opened(self):
499 nonlocals = {'size': (80, 40)}
500
501 def _ResizeWindow():
502 size = GetTerminalSize()
503 if size != nonlocals['size']: # Size not changed, ignore
504 control = {'command': 'resize', 'params': list(size)}
505 payload = chr(_CONTROL_START) + json.dumps(control) + chr(_CONTROL_END)
506 nonlocals['size'] = size
507 try:
508 self.send(payload, binary=True)
509 except Exception:
510 pass
511
512 def _FeedInput():
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800513 self._old_termios = termios.tcgetattr(self._stdin_fd)
514 tty.setraw(self._stdin_fd)
515
516 READY, ENTER_PRESSED, ESCAPE_PRESSED = range(3)
517
518 try:
519 state = READY
520 while True:
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800521 # Check if terminal is resized
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800522 _ResizeWindow()
523
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800524 ch = sys.stdin.read(1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800525
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800526 # Scan for escape sequence
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800527 if self._escape:
528 if state == READY:
529 state = ENTER_PRESSED if ch == chr(0x0d) else READY
530 elif state == ENTER_PRESSED:
531 state = ESCAPE_PRESSED if ch == self._escape else READY
532 elif state == ESCAPE_PRESSED:
533 if ch == '.':
534 self.close()
535 break
536 else:
537 state = READY
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800538
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800539 self.send(ch)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800540 except (KeyboardInterrupt, RuntimeError):
541 pass
542
543 t = threading.Thread(target=_FeedInput)
544 t.daemon = True
545 t.start()
546
547 def closed(self, code, reason=None):
548 termios.tcsetattr(self._stdin_fd, termios.TCSANOW, self._old_termios)
549 print('Connection to %s closed.' % self._mid)
550
551 def received_message(self, msg):
552 if msg.is_binary:
553 sys.stdout.write(msg.data)
554 sys.stdout.flush()
555
556
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800557class ShellWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800558 def __init__(self, state, output, *args, **kwargs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800559 """Constructor.
560
561 Args:
562 output: output file object.
563 """
564 self.output = output
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800565 super(ShellWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800566
567 def handshake_ok(self):
568 pass
569
570 def opened(self):
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800571 def _FeedInput():
572 try:
573 while True:
574 data = sys.stdin.read(1)
575
576 if len(data) == 0:
577 self.send(_STDIN_CLOSED * 2)
578 break
579 self.send(data, binary=True)
580 except (KeyboardInterrupt, RuntimeError):
581 pass
582
583 t = threading.Thread(target=_FeedInput)
584 t.daemon = True
585 t.start()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800586
587 def closed(self, code, reason=None):
588 pass
589
590 def received_message(self, msg):
591 if msg.is_binary:
592 self.output.write(msg.data)
593 self.output.flush()
594
595
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800596class ForwarderWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800597 def __init__(self, state, sock, *args, **kwargs):
598 super(ForwarderWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800599 self._sock = sock
600 self._stop = threading.Event()
601
602 def handshake_ok(self):
603 pass
604
605 def opened(self):
606 def _FeedInput():
607 try:
608 self._sock.setblocking(False)
609 while True:
610 rd, unused_w, unused_x = select.select([self._sock], [], [], 0.5)
611 if self._stop.is_set():
612 break
613 if self._sock in rd:
614 data = self._sock.recv(_BUFSIZ)
615 if len(data) == 0:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800616 self.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800617 break
618 self.send(data, binary=True)
619 except Exception:
620 pass
621 finally:
622 self._sock.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800623
624 t = threading.Thread(target=_FeedInput)
625 t.daemon = True
626 t.start()
627
628 def closed(self, code, reason=None):
629 self._stop.set()
630 sys.exit(0)
631
632 def received_message(self, msg):
633 if msg.is_binary:
634 self._sock.send(msg.data)
635
636
637def Arg(*args, **kwargs):
638 return (args, kwargs)
639
640
641def Command(command, help_msg=None, args=None):
642 """Decorator for adding argparse parameter for a method."""
643 if args is None:
644 args = []
645 def WrapFunc(func):
646 def Wrapped(*args, **kwargs):
647 return func(*args, **kwargs)
648 # pylint: disable=W0212
649 Wrapped.__arg_attr = {'command': command, 'help': help_msg, 'args': args}
650 return Wrapped
651 return WrapFunc
652
653
654def ParseMethodSubCommands(cls):
655 """Decorator for a class using the @Command decorator.
656
657 This decorator retrieve command info from each method and append it in to the
658 SUBCOMMANDS class variable, which is later used to construct parser.
659 """
660 for unused_key, method in cls.__dict__.iteritems():
661 if hasattr(method, '__arg_attr'):
662 cls.SUBCOMMANDS.append(method.__arg_attr) # pylint: disable=W0212
663 return cls
664
665
666@ParseMethodSubCommands
667class OverlordCLIClient(object):
668 """Overlord command line interface client."""
669
670 SUBCOMMANDS = []
671
672 def __init__(self):
673 self._parser = self._BuildParser()
674 self._selected_mid = None
675 self._server = None
676 self._state = None
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800677 self._escape = None
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800678
679 def _BuildParser(self):
680 root_parser = argparse.ArgumentParser(prog='ovl')
681 subparsers = root_parser.add_subparsers(help='sub-command')
682
683 root_parser.add_argument('-s', dest='selected_mid', action='store',
684 default=None,
685 help='select target to execute command on')
686 root_parser.add_argument('-S', dest='select_mid_before_action',
687 action='store_true', default=False,
688 help='select target before executing command')
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800689 root_parser.add_argument('-e', dest='escape', metavar='ESCAPE_CHAR',
690 action='store', default=_ESCAPE, type=str,
691 help='set shell escape character, \'none\' to '
692 'disable escape completely')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800693
694 for attr in self.SUBCOMMANDS:
695 parser = subparsers.add_parser(attr['command'], help=attr['help'])
696 parser.set_defaults(which=attr['command'])
697 for arg in attr['args']:
698 parser.add_argument(*arg[0], **arg[1])
699
700 return root_parser
701
702 def Main(self):
703 # We want to pass the rest of arguments after shell command directly to the
704 # function without parsing it.
705 try:
706 index = sys.argv.index('shell')
707 except ValueError:
708 args = self._parser.parse_args()
709 else:
710 args = self._parser.parse_args(sys.argv[1:index + 1])
711
712 command = args.which
713 self._selected_mid = args.selected_mid
714
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800715 if args.escape and args.escape != 'none':
716 self._escape = args.escape[0]
717
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800718 if command == 'start-server':
719 self.StartServer()
720 return
721 elif command == 'kill-server':
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800722 self.KillServer()
723 return
724
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800725 self.CheckDaemon()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800726 if command == 'status':
727 self.Status()
728 return
729 elif command == 'connect':
730 self.Connect(args)
731 return
732
733 # The following command requires connection to the server
734 self.CheckConnection()
735
736 if args.select_mid_before_action:
737 self.SelectClient(store=False)
738
739 if command == 'select':
740 self.SelectClient(args)
741 elif command == 'ls':
742 self.ListClients()
743 elif command == 'shell':
744 command = sys.argv[sys.argv.index('shell') + 1:]
745 self.Shell(command)
746 elif command == 'push':
747 self.Push(args)
748 elif command == 'pull':
749 self.Pull(args)
750 elif command == 'forward':
751 self.Forward(args)
752
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800753 def _SaveTLSCertificate(self, host, cert_pem):
754 try:
755 os.makedirs(_CERT_DIR)
756 except Exception:
757 pass
758 with open(GetTLSCertPath(host), 'w') as f:
759 f.write(cert_pem)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800760
761 def _HTTPPostFile(self, url, filename, progress=None, user=None, passwd=None):
762 """Perform HTTP POST and upload file to Overlord.
763
764 To minimize the external dependencies, we construct the HTTP post request
765 by ourselves.
766 """
767 url = MakeRequestUrl(self._state, url)
768 size = os.stat(filename).st_size
769 boundary = '-----------%s' % _HTTP_BOUNDARY_MAGIC
770 CRLF = '\r\n'
771 parse = urlparse.urlparse(url)
772
773 part_headers = [
774 '--' + boundary,
775 'Content-Disposition: form-data; name="file"; '
776 'filename="%s"' % os.path.basename(filename),
777 'Content-Type: application/octet-stream',
778 '', ''
779 ]
780 part_header = CRLF.join(part_headers)
781 end_part = CRLF + '--' + boundary + '--' + CRLF
782
783 content_length = len(part_header) + size + len(end_part)
784 if parse.scheme == 'http':
785 h = httplib.HTTP(parse.netloc)
786 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800787 h = httplib.HTTPS(parse.netloc, context=self._state.ssl_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800788
789 post_path = url[url.index(parse.netloc) + len(parse.netloc):]
790 h.putrequest('POST', post_path)
791 h.putheader('Content-Length', content_length)
792 h.putheader('Content-Type', 'multipart/form-data; boundary=%s' % boundary)
793
794 if user and passwd:
795 h.putheader(*BasicAuthHeader(user, passwd))
796 h.endheaders()
797 h.send(part_header)
798
799 count = 0
800 with open(filename, 'r') as f:
801 while True:
802 data = f.read(_BUFSIZ)
803 if not data:
804 break
805 count += len(data)
806 if progress:
807 progress(int(count * 100.0 / size), count)
808 h.send(data)
809
810 h.send(end_part)
811 progress(100)
812
813 if count != size:
814 logging.warning('file changed during upload, upload may be truncated.')
815
816 errcode, unused_x, unused_y = h.getreply()
817 return errcode == 200
818
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800819 def CheckDaemon(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800820 self._server = OverlordClientDaemon.GetRPCServer()
821 if self._server is None:
822 print('* daemon not running, starting it now on port %d ... *' %
823 _OVERLORD_CLIENT_DAEMON_PORT)
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800824 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800825
826 self._state = self._server.State()
827 sha1sum = GetVersionDigest()
828
829 if sha1sum != self._state.version_sha1sum:
830 print('ovl server is out of date. killing...')
831 KillGraceful(self._server.GetPid())
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800832 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800833
834 def GetSSHControlFile(self, host):
835 return _SSH_CONTROL_SOCKET_PREFIX + host
836
837 def SSHTunnel(self, user, host, port):
838 """SSH forward the remote overlord server.
839
840 Overlord server may not have port 9000 open to the public network, in such
841 case we can SSH forward the port to localhost.
842 """
843
844 control_file = self.GetSSHControlFile(host)
845 try:
846 os.unlink(control_file)
847 except Exception:
848 pass
849
850 subprocess.Popen([
851 'ssh', '-Nf',
852 '-M', # Enable master mode
853 '-S', control_file,
854 '-L', '9000:localhost:9000',
855 '-p', str(port),
856 '%s%s' % (user + '@' if user else '', host)
857 ]).wait()
858
859 p = subprocess.Popen([
860 'ssh',
861 '-S', control_file,
862 '-O', 'check', host,
863 ], stderr=subprocess.PIPE)
864 unused_stdout, stderr = p.communicate()
865
866 s = re.search(r'pid=(\d+)', stderr)
867 if s:
868 return int(s.group(1))
869
870 raise RuntimeError('can not establish ssh connection')
871
872 def CheckConnection(self):
873 if self._state.host is None:
874 raise RuntimeError('not connected to any server, abort')
875
876 try:
877 self._server.Clients()
878 except Exception:
879 raise RuntimeError('remote server disconnected, abort')
880
881 if self._state.ssh_pid is not None:
882 ret = subprocess.Popen(['kill', '-0', str(self._state.ssh_pid)],
883 stdout=subprocess.PIPE,
884 stderr=subprocess.PIPE).wait()
885 if ret != 0:
886 raise RuntimeError('ssh tunnel disconnected, please re-connect')
887
888 def CheckClient(self):
889 if self._selected_mid is None:
890 if self._state.selected_mid is None:
891 raise RuntimeError('No client is selected')
892 self._selected_mid = self._state.selected_mid
893
894 if self._selected_mid not in self._server.Clients():
895 raise RuntimeError('client %s disappeared' % self._selected_mid)
896
897 def CheckOutput(self, command):
898 headers = []
899 if self._state.username is not None and self._state.password is not None:
900 headers.append(BasicAuthHeader(self._state.username,
901 self._state.password))
902
903 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
904 sio = StringIO.StringIO()
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800905 ws = ShellWebSocketClient(self._state, sio,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800906 scheme + '%s:%d/api/agent/shell/%s?command=%s' %
907 (self._state.host, self._state.port,
908 self._selected_mid, urllib2.quote(command)),
909 headers=headers)
910 ws.connect()
911 ws.run()
912 return sio.getvalue()
913
914 @Command('status', 'show Overlord connection status')
915 def Status(self):
916 if self._state.host is None:
917 print('Not connected to any host.')
918 else:
919 if self._state.ssh_pid is not None:
920 print('Connected to %s with SSH tunneling.' % self._state.orig_host)
921 else:
922 print('Connected to %s:%d.' % (self._state.host, self._state.port))
923
924 if self._selected_mid is None:
925 self._selected_mid = self._state.selected_mid
926
927 if self._selected_mid is None:
928 print('No client is selected.')
929 else:
930 print('Client %s selected.' % self._selected_mid)
931
932 @Command('connect', 'connect to Overlord server', [
933 Arg('host', metavar='HOST', type=str, default='localhost',
934 help='Overlord hostname/IP'),
935 Arg('port', metavar='PORT', type=int,
936 default=_OVERLORD_HTTP_PORT, help='Overlord port'),
937 Arg('-f', '--forward', dest='ssh_forward', default=False,
938 action='store_true',
939 help='connect with SSH forwarding to the host'),
940 Arg('-p', '--ssh-port', dest='ssh_port', default=22,
941 type=int, help='SSH server port for SSH forwarding'),
942 Arg('-l', '--ssh-login', dest='ssh_login', default='',
943 type=str, help='SSH server login name for SSH forwarding'),
944 Arg('-u', '--user', dest='user', default=None,
945 type=str, help='Overlord HTTP auth username'),
946 Arg('-w', '--passwd', dest='passwd', default=None, type=str,
947 help='Overlord HTTP auth password')])
948 def Connect(self, args):
949 ssh_pid = None
950 host = args.host
951 orig_host = args.host
952
953 if args.ssh_forward:
954 # Kill previous SSH tunnel
955 self.KillSSHTunnel()
956
957 ssh_pid = self.SSHTunnel(args.ssh_login, args.host, args.ssh_port)
958 host = 'localhost'
959
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800960 username_provided = args.user is not None
961 password_provided = args.passwd is not None
962 prompt = False
963
964 for unused_i in range(3):
965 try:
966 if prompt:
967 if not username_provided:
968 args.user = raw_input('Username: ')
969 if not password_provided:
970 args.passwd = getpass.getpass('Password: ')
971
972 ret = self._server.Connect(host, args.port, ssh_pid, args.user,
973 args.passwd, orig_host)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800974 if isinstance(ret, list):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800975 if ret[0].startswith('SSL'):
976 cert_pem = ret[1]
977 fp = GetTLSCertificateSHA1Fingerprint(cert_pem)
978 fp_text = ':'.join([fp[i:i+2] for i in range(0, len(fp), 2)])
979
980 if ret[0] == 'SSLCertificateChanged':
981 print(_TLS_CERT_CHANGED_WARNING % (fp_text, GetTLSCertPath(host)))
982 return
983 elif ret[0] == 'SSLVerifyFailed':
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800984 print(_TLS_CERT_FAILED_WARNING % (fp_text), end='')
985 response = raw_input()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800986 if response.lower() in ['y', 'ye', 'yes']:
987 self._SaveTLSCertificate(host, cert_pem)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800988 print('TLS host Certificate trusted, you will not be prompted '
989 'next time.\n')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800990 continue
991 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800992 print('connection aborted.')
993 return
994 elif ret[0] == 'HTTPError':
995 code, except_str, body = ret[1:]
996 if code == 401:
997 print('connect: %s' % body)
998 prompt = True
999 if not username_provided or not password_provided:
1000 continue
1001 else:
1002 break
1003 else:
1004 logging.error('%s; %s', except_str, body)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001005
1006 if ret is not True:
1007 print('can not connect to %s: %s' % (host, ret))
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001008 else:
1009 print('connection to %s:%d established.' % (host, args.port))
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001010 except Exception as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001011 logging.error(e)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001012 else:
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001013 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001014
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001015 @Command('start-server', 'start overlord CLI client server')
1016 def StartServer(self):
1017 self._server = OverlordClientDaemon.GetRPCServer()
1018 if self._server is None:
1019 OverlordClientDaemon().Start()
1020 time.sleep(1)
1021 self._server = OverlordClientDaemon.GetRPCServer()
1022 if self._server is not None:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001023 print('* daemon started successfully *\n')
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001024
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001025 @Command('kill-server', 'kill overlord CLI client server')
1026 def KillServer(self):
1027 self._server = OverlordClientDaemon.GetRPCServer()
1028 if self._server is None:
1029 return
1030
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001031 self._state = self._server.State()
1032
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001033 # Kill SSH Tunnel
1034 self.KillSSHTunnel()
1035
1036 # Kill server daemon
1037 KillGraceful(self._server.GetPid())
1038
1039 def KillSSHTunnel(self):
1040 if self._state.ssh_pid is not None:
1041 KillGraceful(self._state.ssh_pid)
1042
1043 @Command('ls', 'list all clients')
1044 def ListClients(self):
1045 for client in self._server.Clients():
1046 print(client)
1047
1048 @Command('select', 'select default client', [
1049 Arg('mid', metavar='mid', nargs='?', default=None)])
1050 def SelectClient(self, args=None, store=True):
1051 clients = self._server.Clients()
1052
1053 mid = args.mid if args is not None else None
1054 if mid is None:
1055 print('Select from the following clients:')
1056 for i, client in enumerate(clients):
1057 print(' %d. %s' % (i + 1, client))
1058
1059 print('\nSelection: ', end='')
1060 try:
1061 choice = int(raw_input()) - 1
1062 mid = clients[choice]
1063 except ValueError:
1064 raise RuntimeError('select: invalid selection')
1065 except IndexError:
1066 raise RuntimeError('select: selection out of range')
1067 else:
1068 if mid not in clients:
1069 raise RuntimeError('select: client %s does not exist' % mid)
1070
1071 self._selected_mid = mid
1072 if store:
1073 self._server.SelectClient(mid)
1074 print('Client %s selected' % mid)
1075
1076 @Command('shell', 'open a shell or execute a shell command', [
1077 Arg('command', metavar='CMD', nargs='?', help='command to execute')])
1078 def Shell(self, command=None):
1079 if command is None:
1080 command = []
1081 self.CheckClient()
1082
1083 headers = []
1084 if self._state.username is not None and self._state.password is not None:
1085 headers.append(BasicAuthHeader(self._state.username,
1086 self._state.password))
1087
1088 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1089 if len(command) == 0:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001090 ws = TerminalWebSocketClient(self._state, self._selected_mid,
Wei-Ning Huang0c520e92016-03-19 20:01:10 +08001091 self._escape,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001092 scheme + '%s:%d/api/agent/tty/%s' %
1093 (self._state.host, self._state.port,
1094 self._selected_mid), headers=headers)
1095 else:
1096 cmd = ' '.join(command)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001097 ws = ShellWebSocketClient(self._state, sys.stdout,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001098 scheme + '%s:%d/api/agent/shell/%s?command=%s' %
1099 (self._state.host, self._state.port,
1100 self._selected_mid, urllib2.quote(cmd)),
1101 headers=headers)
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001102 try:
1103 ws.connect()
1104 ws.run()
1105 except socket.error as e:
1106 if e.errno == 32: # Broken pipe
1107 pass
1108 else:
1109 raise
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001110
1111 @Command('push', 'push a file or directory to remote', [
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001112 Arg('srcs', nargs='+', metavar='SOURCE'),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001113 Arg('dst', metavar='DESTINATION')])
1114 def Push(self, args):
1115 self.CheckClient()
1116
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001117 @AutoRetry('push', _RETRY_TIMES)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001118 def _push(src, dst):
1119 src_base = os.path.basename(src)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001120
1121 # Local file is a link
1122 if os.path.islink(src):
1123 pbar = ProgressBar(src_base)
1124 link_path = os.readlink(src)
1125 self.CheckOutput('mkdir -p %(dirname)s; '
1126 'if [ -d "%(dst)s" ]; then '
1127 'ln -sf "%(link_path)s" "%(dst)s/%(link_name)s"; '
1128 'else ln -sf "%(link_path)s" "%(dst)s"; fi' %
1129 dict(dirname=os.path.dirname(dst),
1130 link_path=link_path, dst=dst,
1131 link_name=src_base))
1132 pbar.End()
1133 return
1134
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001135 mode = '0%o' % (0x1FF & os.stat(src).st_mode)
1136 url = ('%s:%d/api/agent/upload/%s?dest=%s&perm=%s' %
1137 (self._state.host, self._state.port, self._selected_mid, dst,
1138 mode))
1139 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001140 UrlOpen(self._state, url + '&filename=%s' % src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001141 except urllib2.HTTPError as e:
1142 msg = json.loads(e.read()).get('error', None)
1143 raise RuntimeError('push: %s' % msg)
1144
1145 pbar = ProgressBar(src_base)
1146 self._HTTPPostFile(url, src, pbar.SetProgress,
1147 self._state.username, self._state.password)
1148 pbar.End()
1149
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001150 def _push_single_target(src, dst):
1151 if os.path.isdir(src):
1152 dst_exists = ast.literal_eval(self.CheckOutput(
1153 'stat %s >/dev/null 2>&1 && echo True || echo False' % dst))
1154 for root, unused_x, files in os.walk(src):
1155 # If destination directory does not exist, we should strip the first
1156 # layer of directory. For example: src_dir contains a single file 'A'
1157 #
1158 # push src_dir dest_dir
1159 #
1160 # If dest_dir exists, the resulting directory structure should be:
1161 # dest_dir/src_dir/A
1162 # If dest_dir does not exist, the resulting directory structure should
1163 # be:
1164 # dest_dir/A
1165 dst_root = root if dst_exists else root[len(src):].lstrip('/')
1166 for name in files:
1167 _push(os.path.join(root, name),
1168 os.path.join(dst, dst_root, name))
1169 else:
1170 _push(src, dst)
1171
1172 if len(args.srcs) > 1:
1173 dst_type = self.CheckOutput('stat \'%s\' --printf \'%%F\' '
1174 '2>/dev/null' % args.dst).strip()
1175 if not dst_type:
1176 raise RuntimeError('push: %s: No such file or directory' % args.dst)
1177 if dst_type != 'directory':
1178 raise RuntimeError('push: %s: Not a directory' % args.dst)
1179
1180 for src in args.srcs:
1181 if not os.path.exists(src):
1182 raise RuntimeError('push: can not stat "%s": no such file or directory'
1183 % src)
1184 if not os.access(src, os.R_OK):
1185 raise RuntimeError('push: can not open "%s" for reading' % src)
1186
1187 _push_single_target(src, args.dst)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001188
1189 @Command('pull', 'pull a file or directory from remote', [
1190 Arg('src', metavar='SOURCE'),
1191 Arg('dst', metavar='DESTINATION', default='.', nargs='?')])
1192 def Pull(self, args):
1193 self.CheckClient()
1194
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001195 @AutoRetry('pull', _RETRY_TIMES)
1196 def _pull(src, dst, ftype, perm=0644, link=None):
1197 try:
1198 os.makedirs(os.path.dirname(dst))
1199 except Exception:
1200 pass
1201
1202 src_base = os.path.basename(src)
1203
1204 # Remote file is a link
1205 if ftype == 'l':
1206 pbar = ProgressBar(src_base)
1207 if os.path.exists(dst):
1208 os.remove(dst)
1209 os.symlink(link, dst)
1210 pbar.End()
1211 return
1212
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001213 url = ('%s:%d/api/agent/download/%s?filename=%s' %
1214 (self._state.host, self._state.port, self._selected_mid,
1215 urllib2.quote(src)))
1216 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001217 h = UrlOpen(self._state, url)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001218 except urllib2.HTTPError as e:
1219 msg = json.loads(e.read()).get('error', 'unkown error')
1220 raise RuntimeError('pull: %s' % msg)
1221 except KeyboardInterrupt:
1222 return
1223
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001224 pbar = ProgressBar(src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001225 with open(dst, 'w') as f:
1226 os.fchmod(f.fileno(), perm)
1227 total_size = int(h.headers.get('Content-Length'))
1228 downloaded_size = 0
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001229
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001230 while True:
1231 data = h.read(_BUFSIZ)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001232 if len(data) == 0:
1233 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001234 downloaded_size += len(data)
1235 pbar.SetProgress(float(downloaded_size) * 100 / total_size,
1236 downloaded_size)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001237 f.write(data)
1238 pbar.End()
1239
1240 # Use find to get a listing of all files under a root directory. The 'stat'
1241 # command is used to retrieve the filename and it's filemode.
1242 output = self.CheckOutput(
1243 'cd $HOME; '
1244 'stat "%(src)s" >/dev/null && '
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001245 'find "%(src)s" \'(\' -type f -o -type l \')\' '
1246 '-printf \'%%m\t%%p\t%%y\t%%l\n\''
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001247 % {'src': args.src})
1248
1249 # We got error from the stat command
1250 if output.startswith('stat: '):
1251 sys.stderr.write(output)
1252 return
1253
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001254 entries = output.strip('\n').split('\n')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001255 common_prefix = os.path.dirname(args.src)
1256
1257 if len(entries) == 1:
1258 entry = entries[0]
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001259 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001260 if os.path.isdir(args.dst):
1261 dst = os.path.join(args.dst, os.path.basename(src_path))
1262 else:
1263 dst = args.dst
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001264 _pull(src_path, dst, ftype, int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001265 else:
1266 if not os.path.exists(args.dst):
1267 common_prefix = args.src
1268
1269 for entry in entries:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001270 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001271 rel_dst = src_path[len(common_prefix):].lstrip('/')
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001272 _pull(src_path, os.path.join(args.dst, rel_dst), ftype,
1273 int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001274
1275 @Command('forward', 'forward remote port to local port', [
1276 Arg('--list', dest='list_all', action='store_true', default=False,
1277 help='list all port forwarding sessions'),
1278 Arg('--remove', metavar='LOCAL_PORT', dest='remove', type=int,
1279 default=None,
1280 help='remove port forwarding for local port LOCAL_PORT'),
1281 Arg('--remove-all', dest='remove_all', action='store_true',
1282 default=False, help='remove all port forwarding'),
1283 Arg('remote', metavar='REMOTE_PORT', type=int, nargs='?'),
1284 Arg('local', metavar='LOCAL_PORT', type=int, nargs='?')])
1285 def Forward(self, args):
1286 if args.list_all:
1287 max_len = 10
1288 if len(self._state.forwards):
1289 max_len = max([len(v[0]) for v in self._state.forwards.values()])
1290
1291 print('%-*s %-8s %-8s' % (max_len, 'Client', 'Remote', 'Local'))
1292 for local in sorted(self._state.forwards.keys()):
1293 value = self._state.forwards[local]
1294 print('%-*s %-8s %-8s' % (max_len, value[0], value[1], local))
1295 return
1296
1297 if args.remove_all:
1298 self._server.RemoveAllForward()
1299 return
1300
1301 if args.remove:
1302 self._server.RemoveForward(args.remove)
1303 return
1304
1305 self.CheckClient()
1306
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001307 if args.remote is None:
1308 raise RuntimeError('remote port not specified')
1309
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001310 if args.local is None:
1311 args.local = args.remote
1312 remote = int(args.remote)
1313 local = int(args.local)
1314
1315 def HandleConnection(conn):
1316 headers = []
1317 if self._state.username is not None and self._state.password is not None:
1318 headers.append(BasicAuthHeader(self._state.username,
1319 self._state.password))
1320
1321 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1322 ws = ForwarderWebSocketClient(
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001323 self._state, conn,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001324 scheme + '%s:%d/api/agent/forward/%s?port=%d' %
1325 (self._state.host, self._state.port, self._selected_mid, remote),
1326 headers=headers)
1327 try:
1328 ws.connect()
1329 ws.run()
1330 except Exception as e:
1331 print('error: %s' % e)
1332 finally:
1333 ws.close()
1334
1335 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1336 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1337 server.bind(('0.0.0.0', local))
1338 server.listen(5)
1339
1340 pid = os.fork()
1341 if pid == 0:
1342 while True:
1343 conn, unused_addr = server.accept()
1344 t = threading.Thread(target=HandleConnection, args=(conn,))
1345 t.daemon = True
1346 t.start()
1347 else:
1348 self._server.AddForward(self._selected_mid, remote, local, pid)
1349
1350
1351def main():
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001352 # Setup logging format
1353 logger = logging.getLogger()
1354 logger.setLevel(logging.INFO)
1355 handler = logging.StreamHandler()
1356 formatter = logging.Formatter('%(asctime)s %(message)s', '%Y/%m/%d %H:%M:%S')
1357 handler.setFormatter(formatter)
1358 logger.addHandler(handler)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001359
1360 # Add DaemonState to JSONRPC lib classes
1361 Config.instance().classes.add(DaemonState)
1362
1363 ovl = OverlordCLIClient()
1364 try:
1365 ovl.Main()
1366 except KeyboardInterrupt:
1367 print('Ctrl-C received, abort')
1368 except Exception as e:
1369 print('error: %s' % e)
1370
1371
1372if __name__ == '__main__':
1373 main()