blob: 7f3e6fc5644453013c07555303a0f51adff48a43 [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
37
38from jsonrpclib.SimpleJSONRPCServer import SimpleJSONRPCServer
39from jsonrpclib.config import Config
40from ws4py.client import WebSocketBaseClient
41
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080042
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080043_CERT_DIR = os.path.expanduser('~/.config/ovl')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080044
45_ESCAPE = '~'
46_BUFSIZ = 8192
47_OVERLORD_PORT = 4455
48_OVERLORD_HTTP_PORT = 9000
49_OVERLORD_CLIENT_DAEMON_PORT = 4488
50_OVERLORD_CLIENT_DAEMON_RPC_ADDR = ('127.0.0.1', _OVERLORD_CLIENT_DAEMON_PORT)
51
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080052_CONNECT_TIMEOUT = 3
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +080053_DEFAULT_HTTP_TIMEOUT = 30
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080054_LIST_CACHE_TIMEOUT = 2
55_DEFAULT_TERMINAL_WIDTH = 80
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +080056_RETRY_TIMES = 3
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080057
58# echo -n overlord | md5sum
59_HTTP_BOUNDARY_MAGIC = '9246f080c855a69012707ab53489b921'
60
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +080061# Terminal resize control
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080062_CONTROL_START = 128
63_CONTROL_END = 129
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +080064
65# Stream control
66_STDIN_CLOSED = '##STDIN_CLOSED##'
67
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080068_SSH_CONTROL_SOCKET_PREFIX = os.path.join(tempfile.gettempdir(),
69 'ovl-ssh-control-')
70
Wei-Ning Huang47c79b82016-05-24 01:24:46 +080071_TLS_CERT_FAILED_WARNING = """
72@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
73@ WARNING: REMOTE HOST VERIFICATION HAS FAILED! @
74@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
75IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
76Someone could be eavesdropping on you right now (man-in-the-middle attack)!
77It is also possible that the server is using a self-signed certificate.
78The fingerprint for the TLS host certificate sent by the remote host is
79
80%s
81
82Do you want to trust this certificate and proceed? [Y/n] """
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080083
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080084_TLS_CERT_CHANGED_WARNING = """
85@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
86@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
87@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
88IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
89Someone could be eavesdropping on you right now (man-in-the-middle attack)!
Wei-Ning Huang47c79b82016-05-24 01:24:46 +080090It is also possible that the TLS host certificate has just been changed.
91The fingerprint for the TLS host certificate sent by the remote host is
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +080092
93%s
94
95Remove '%s' if you still want to proceed.
96SSL Certificate verification failed."""
97
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +080098
99def GetVersionDigest():
100 """Return the sha1sum of the current executing script."""
101 with open(__file__, 'r') as f:
102 return hashlib.sha1(f.read()).hexdigest()
103
104
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800105def GetTLSCertPath(host):
106 return os.path.join(_CERT_DIR, '%s.cert' % host)
107
108
109def UrlOpen(state, url):
110 """Wrapper for urllib2.urlopen.
111
112 It selects correct HTTP scheme according to self._state.ssl, add HTTP
113 basic auth headers, and add specify correct SSL context.
114 """
115 url = MakeRequestUrl(state, url)
116 request = urllib2.Request(url)
117 if state.username is not None and state.password is not None:
118 request.add_header(*BasicAuthHeader(state.username, state.password))
119 return urllib2.urlopen(request, timeout=_DEFAULT_HTTP_TIMEOUT,
120 context=state.ssl_context)
121
122
123def GetTLSCertificateSHA1Fingerprint(cert_pem):
124 beg = cert_pem.index('\n')
125 end = cert_pem.rindex('\n', 0, len(cert_pem) - 2)
126 cert_pem = cert_pem[beg:end] # Remove BEGIN/END CERTIFICATE boundary
127 cert_der = base64.b64decode(cert_pem)
128 return hashlib.sha1(cert_der).hexdigest()
129
130
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800131def KillGraceful(pid, wait_secs=1):
132 """Kill a process gracefully by first sending SIGTERM, wait for some time,
133 then send SIGKILL to make sure it's killed."""
134 try:
135 os.kill(pid, signal.SIGTERM)
136 time.sleep(wait_secs)
137 os.kill(pid, signal.SIGKILL)
138 except OSError:
139 pass
140
141
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800142def AutoRetry(action_name, retries):
143 """Decorator for retry function call."""
144 def Wrap(func):
145 def Loop(*args, **kwargs):
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800146 for unused_i in range(retries):
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800147 try:
148 func(*args, **kwargs)
149 except Exception as e:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800150 print('error: %s: %s: retrying ...' % (args[0], e))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800151 else:
152 break
153 else:
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800154 print('error: failed to %s %s' % (action_name, args[0]))
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +0800155 return Loop
156 return Wrap
157
158
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800159def BasicAuthHeader(user, password):
160 """Return HTTP basic auth header."""
161 credential = base64.b64encode('%s:%s' % (user, password))
162 return ('Authorization', 'Basic %s' % credential)
163
164
165def GetTerminalSize():
166 """Retrieve terminal window size."""
167 ws = struct.pack('HHHH', 0, 0, 0, 0)
168 ws = fcntl.ioctl(0, termios.TIOCGWINSZ, ws)
169 lines, columns, unused_x, unused_y = struct.unpack('HHHH', ws)
170 return lines, columns
171
172
173def MakeRequestUrl(state, url):
174 return 'http%s://%s' % ('s' if state.ssl else '', url)
175
176
177class ProgressBar(object):
178 SIZE_WIDTH = 11
179 SPEED_WIDTH = 10
180 DURATION_WIDTH = 6
181 PERCENTAGE_WIDTH = 8
182
183 def __init__(self, name):
184 self._start_time = time.time()
185 self._name = name
186 self._size = 0
187 self._width = 0
188 self._name_width = 0
189 self._name_max = 0
190 self._stat_width = 0
191 self._max = 0
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800192 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800193 self.SetProgress(0)
194
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800195 def _CalculateSize(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800196 self._width = GetTerminalSize()[1] or _DEFAULT_TERMINAL_WIDTH
197 self._name_width = int(self._width * 0.3)
198 self._name_max = self._name_width
199 self._stat_width = self.SIZE_WIDTH + self.SPEED_WIDTH + self.DURATION_WIDTH
200 self._max = (self._width - self._name_width - self._stat_width -
201 self.PERCENTAGE_WIDTH)
202
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800203 def _SizeToHuman(self, size_in_bytes):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800204 if size_in_bytes < 1024:
205 unit = 'B'
206 value = size_in_bytes
207 elif size_in_bytes < 1024 ** 2:
208 unit = 'KiB'
209 value = size_in_bytes / 1024.0
210 elif size_in_bytes < 1024 ** 3:
211 unit = 'MiB'
212 value = size_in_bytes / (1024.0 ** 2)
213 elif size_in_bytes < 1024 ** 4:
214 unit = 'GiB'
215 value = size_in_bytes / (1024.0 ** 3)
216 return ' %6.1f %3s' % (value, unit)
217
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800218 def _SpeedToHuman(self, speed_in_bs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800219 if speed_in_bs < 1024:
220 unit = 'B'
221 value = speed_in_bs
222 elif speed_in_bs < 1024 ** 2:
223 unit = 'K'
224 value = speed_in_bs / 1024.0
225 elif speed_in_bs < 1024 ** 3:
226 unit = 'M'
227 value = speed_in_bs / (1024.0 ** 2)
228 elif speed_in_bs < 1024 ** 4:
229 unit = 'G'
230 value = speed_in_bs / (1024.0 ** 3)
231 return ' %6.1f%s/s' % (value, unit)
232
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800233 def _DurationToClock(self, duration):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800234 return ' %02d:%02d' % (duration / 60, duration % 60)
235
236 def SetProgress(self, percentage, size=None):
237 current_width = GetTerminalSize()[1]
238 if self._width != current_width:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800239 self._CalculateSize()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800240
241 if size is not None:
242 self._size = size
243
244 elapse_time = time.time() - self._start_time
245 speed = self._size / float(elapse_time)
246
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800247 size_str = self._SizeToHuman(self._size)
248 speed_str = self._SpeedToHuman(speed)
249 elapse_str = self._DurationToClock(elapse_time)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800250
251 width = int(self._max * percentage / 100.0)
252 sys.stdout.write(
253 '%*s' % (- self._name_max,
254 self._name if len(self._name) <= self._name_max else
255 self._name[:self._name_max - 4] + ' ...') +
256 size_str + speed_str + elapse_str +
257 ((' [' + '#' * width + ' ' * (self._max - width) + ']' +
258 '%4d%%' % int(percentage)) if self._max > 2 else '') + '\r')
259 sys.stdout.flush()
260
261 def End(self):
262 self.SetProgress(100.0)
263 sys.stdout.write('\n')
264 sys.stdout.flush()
265
266
267class DaemonState(object):
268 """DaemonState is used for storing Overlord state info."""
269 def __init__(self):
270 self.version_sha1sum = GetVersionDigest()
271 self.host = None
272 self.port = None
273 self.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800274 self.ssl_self_signed = False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800275 self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800276 self.ssh = False
277 self.orig_host = None
278 self.ssh_pid = None
279 self.username = None
280 self.password = None
281 self.selected_mid = None
282 self.forwards = {}
283 self.listing = []
284 self.last_list = 0
285
286
287class OverlordClientDaemon(object):
288 """Overlord Client Daemon."""
289 def __init__(self):
290 self._state = DaemonState()
291 self._server = None
292
293 def Start(self):
294 self.StartRPCServer()
295
296 def StartRPCServer(self):
297 self._server = SimpleJSONRPCServer(_OVERLORD_CLIENT_DAEMON_RPC_ADDR,
298 logRequests=False)
299 exports = [
300 (self.State, 'State'),
301 (self.Ping, 'Ping'),
302 (self.GetPid, 'GetPid'),
303 (self.Connect, 'Connect'),
304 (self.Clients, 'Clients'),
305 (self.SelectClient, 'SelectClient'),
306 (self.AddForward, 'AddForward'),
307 (self.RemoveForward, 'RemoveForward'),
308 (self.RemoveAllForward, 'RemoveAllForward'),
309 ]
310 for func, name in exports:
311 self._server.register_function(func, name)
312
313 pid = os.fork()
314 if pid == 0:
315 self._server.serve_forever()
316
317 @staticmethod
318 def GetRPCServer():
319 """Returns the Overlord client daemon RPC server."""
320 server = jsonrpclib.Server('http://%s:%d' %
321 _OVERLORD_CLIENT_DAEMON_RPC_ADDR)
322 try:
323 server.Ping()
324 except Exception:
325 return None
326 return server
327
328 def State(self):
329 return self._state
330
331 def Ping(self):
332 return True
333
334 def GetPid(self):
335 return os.getpid()
336
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800337 def _GetJSON(self, path):
338 url = '%s:%d%s' % (self._state.host, self._state.port, path)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800339 return json.loads(UrlOpen(self._state, url).read())
340
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800341 def _TLSEnabled(self):
342 """Determine if TLS is enabled on given server address."""
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800343 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
344 try:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800345 # Allow any certificate since we only want to check if server talks TLS.
346 context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
347 context.verify_mode = ssl.CERT_NONE
348
349 sock = context.wrap_socket(sock, server_hostname=self._state.host)
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800350 sock.settimeout(_CONNECT_TIMEOUT)
351 sock.connect((self._state.host, self._state.port))
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800352 return True
353 except ssl.SSLError as e:
354 return False
355 except socket.error: # Connect refused or timeout
356 raise
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800357 except Exception:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800358 return False # For whatever reason above failed, assume False
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800359
360 def _CheckTLSCertificate(self):
361 """Check TLS certificate.
362
363 Returns:
364 A tupple (check_result, if_certificate_is_loaded)
365 """
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800366 def _DoConnect(context):
367 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
368 try:
369 sock.settimeout(_CONNECT_TIMEOUT)
370 sock = context.wrap_socket(sock, server_hostname=self._state.host)
371 sock.connect((self._state.host, self._state.port))
372 except ssl.SSLError:
373 return False
374 finally:
375 sock.close()
376
377 # Save SSLContext for future use.
378 self._state.ssl_context = context
379 return True
380
381 # First try connect with built-in certificates
382 tls_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
383 if _DoConnect(tls_context):
384 return True
385
386 # Try with already saved certificate, if any.
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800387 tls_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
388 tls_context.verify_mode = ssl.CERT_REQUIRED
389 tls_context.check_hostname = True
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800390
391 tls_cert_path = GetTLSCertPath(self._state.host)
392 if os.path.exists(tls_cert_path):
393 tls_context.load_verify_locations(tls_cert_path)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800394 self._state.ssl_self_signed = True
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800395
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800396 return _DoConnect(tls_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800397
398 def Connect(self, host, port=_OVERLORD_HTTP_PORT, ssh_pid=None,
399 username=None, password=None, orig_host=None):
400 self._state.username = username
401 self._state.password = password
402 self._state.host = host
403 self._state.port = port
404 self._state.ssl = False
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800405 self._state.ssl_self_signed = False
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800406 self._state.orig_host = orig_host
407 self._state.ssh_pid = ssh_pid
408 self._state.selected_mid = None
409
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800410 tls_enabled = self._TLSEnabled()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800411 if tls_enabled:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800412 result = self._CheckTLSCertificate()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800413 if not result:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800414 if self._state.ssl_self_signed:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800415 return ('SSLCertificateChanged', ssl.get_server_certificate(
416 (self._state.host, self._state.port)))
417 else:
418 return ('SSLVerifyFailed', ssl.get_server_certificate(
419 (self._state.host, self._state.port)))
420
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800421 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800422 self._state.ssl = tls_enabled
423 UrlOpen(self._state, '%s:%d' % (host, port))
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800424 except urllib2.HTTPError as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800425 return ('HTTPError', e.getcode(), str(e), e.read().strip())
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800426 except Exception as e:
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800427 return str(e)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800428 else:
429 return True
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800430
431 def Clients(self):
432 if time.time() - self._state.last_list <= _LIST_CACHE_TIMEOUT:
433 return self._state.listing
434
435 mids = [client['mid'] for client in self._GetJSON('/api/agents/list')]
436 self._state.listing = sorted(list(set(mids)))
437 self._state.last_list = time.time()
438 return self._state.listing
439
440 def SelectClient(self, mid):
441 self._state.selected_mid = mid
442
443 def AddForward(self, mid, remote, local, pid):
444 self._state.forwards[local] = (mid, remote, pid)
445
446 def RemoveForward(self, local_port):
447 try:
448 unused_mid, unused_remote, pid = self._state.forwards[local_port]
449 KillGraceful(pid)
450 del self._state.forwards[local_port]
451 except (KeyError, OSError):
452 pass
453
454 def RemoveAllForward(self):
455 for unused_mid, unused_remote, pid in self._state.forwards.values():
456 try:
457 KillGraceful(pid)
458 except OSError:
459 pass
460 self._state.forwards = {}
461
462
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800463class SSLEnabledWebSocketBaseClient(WebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800464 def __init__(self, state, *args, **kwargs):
465 cafile = ssl.get_default_verify_paths().openssl_cafile
466 # For some system / distribution, python can not detect system cafile path.
467 # In such case we fallback to the default path.
468 if not os.path.exists(cafile):
469 cafile = '/etc/ssl/certs/ca-certificates.crt'
470
471 if state.ssl_self_signed:
472 cafile = GetTLSCertPath(state.host)
473
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800474 ssl_options = {
475 'cert_reqs': ssl.CERT_REQUIRED,
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800476 'ca_certs': cafile
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800477 }
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800478 # ws4py does not allow you to specify SSLContext, but rather passing in the
479 # argument of ssl.wrap_socket
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800480 super(SSLEnabledWebSocketBaseClient, self).__init__(
481 ssl_options=ssl_options, *args, **kwargs)
482
483
484class TerminalWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800485 def __init__(self, state, mid, escape, *args, **kwargs):
486 super(TerminalWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800487 self._mid = mid
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800488 self._escape = escape
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800489 self._stdin_fd = sys.stdin.fileno()
490 self._old_termios = None
491
492 def handshake_ok(self):
493 pass
494
495 def opened(self):
496 nonlocals = {'size': (80, 40)}
497
498 def _ResizeWindow():
499 size = GetTerminalSize()
500 if size != nonlocals['size']: # Size not changed, ignore
501 control = {'command': 'resize', 'params': list(size)}
502 payload = chr(_CONTROL_START) + json.dumps(control) + chr(_CONTROL_END)
503 nonlocals['size'] = size
504 try:
505 self.send(payload, binary=True)
506 except Exception:
507 pass
508
509 def _FeedInput():
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800510 self._old_termios = termios.tcgetattr(self._stdin_fd)
511 tty.setraw(self._stdin_fd)
512
513 READY, ENTER_PRESSED, ESCAPE_PRESSED = range(3)
514
515 try:
516 state = READY
517 while True:
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800518 # Check if terminal is resized
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800519 _ResizeWindow()
520
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800521 ch = sys.stdin.read(1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800522
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800523 # Scan for escape sequence
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800524 if self._escape:
525 if state == READY:
526 state = ENTER_PRESSED if ch == chr(0x0d) else READY
527 elif state == ENTER_PRESSED:
528 state = ESCAPE_PRESSED if ch == self._escape else READY
529 elif state == ESCAPE_PRESSED:
530 if ch == '.':
531 self.close()
532 break
533 else:
534 state = READY
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800535
Wei-Ning Huang85e763d2016-01-21 15:53:18 +0800536 self.send(ch)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800537 except (KeyboardInterrupt, RuntimeError):
538 pass
539
540 t = threading.Thread(target=_FeedInput)
541 t.daemon = True
542 t.start()
543
544 def closed(self, code, reason=None):
545 termios.tcsetattr(self._stdin_fd, termios.TCSANOW, self._old_termios)
546 print('Connection to %s closed.' % self._mid)
547
548 def received_message(self, msg):
549 if msg.is_binary:
550 sys.stdout.write(msg.data)
551 sys.stdout.flush()
552
553
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800554class ShellWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800555 def __init__(self, state, output, *args, **kwargs):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800556 """Constructor.
557
558 Args:
559 output: output file object.
560 """
561 self.output = output
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800562 super(ShellWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800563
564 def handshake_ok(self):
565 pass
566
567 def opened(self):
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800568 def _FeedInput():
569 try:
570 while True:
571 data = sys.stdin.read(1)
572
573 if len(data) == 0:
574 self.send(_STDIN_CLOSED * 2)
575 break
576 self.send(data, binary=True)
577 except (KeyboardInterrupt, RuntimeError):
578 pass
579
580 t = threading.Thread(target=_FeedInput)
581 t.daemon = True
582 t.start()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800583
584 def closed(self, code, reason=None):
585 pass
586
587 def received_message(self, msg):
588 if msg.is_binary:
589 self.output.write(msg.data)
590 self.output.flush()
591
592
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800593class ForwarderWebSocketClient(SSLEnabledWebSocketBaseClient):
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800594 def __init__(self, state, sock, *args, **kwargs):
595 super(ForwarderWebSocketClient, self).__init__(state, *args, **kwargs)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800596 self._sock = sock
597 self._stop = threading.Event()
598
599 def handshake_ok(self):
600 pass
601
602 def opened(self):
603 def _FeedInput():
604 try:
605 self._sock.setblocking(False)
606 while True:
607 rd, unused_w, unused_x = select.select([self._sock], [], [], 0.5)
608 if self._stop.is_set():
609 break
610 if self._sock in rd:
611 data = self._sock.recv(_BUFSIZ)
612 if len(data) == 0:
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +0800613 self.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800614 break
615 self.send(data, binary=True)
616 except Exception:
617 pass
618 finally:
619 self._sock.close()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800620
621 t = threading.Thread(target=_FeedInput)
622 t.daemon = True
623 t.start()
624
625 def closed(self, code, reason=None):
626 self._stop.set()
627 sys.exit(0)
628
629 def received_message(self, msg):
630 if msg.is_binary:
631 self._sock.send(msg.data)
632
633
634def Arg(*args, **kwargs):
635 return (args, kwargs)
636
637
638def Command(command, help_msg=None, args=None):
639 """Decorator for adding argparse parameter for a method."""
640 if args is None:
641 args = []
642 def WrapFunc(func):
643 def Wrapped(*args, **kwargs):
644 return func(*args, **kwargs)
645 # pylint: disable=W0212
646 Wrapped.__arg_attr = {'command': command, 'help': help_msg, 'args': args}
647 return Wrapped
648 return WrapFunc
649
650
651def ParseMethodSubCommands(cls):
652 """Decorator for a class using the @Command decorator.
653
654 This decorator retrieve command info from each method and append it in to the
655 SUBCOMMANDS class variable, which is later used to construct parser.
656 """
657 for unused_key, method in cls.__dict__.iteritems():
658 if hasattr(method, '__arg_attr'):
659 cls.SUBCOMMANDS.append(method.__arg_attr) # pylint: disable=W0212
660 return cls
661
662
663@ParseMethodSubCommands
664class OverlordCLIClient(object):
665 """Overlord command line interface client."""
666
667 SUBCOMMANDS = []
668
669 def __init__(self):
670 self._parser = self._BuildParser()
671 self._selected_mid = None
672 self._server = None
673 self._state = None
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800674 self._escape = None
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800675
676 def _BuildParser(self):
677 root_parser = argparse.ArgumentParser(prog='ovl')
678 subparsers = root_parser.add_subparsers(help='sub-command')
679
680 root_parser.add_argument('-s', dest='selected_mid', action='store',
681 default=None,
682 help='select target to execute command on')
683 root_parser.add_argument('-S', dest='select_mid_before_action',
684 action='store_true', default=False,
685 help='select target before executing command')
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800686 root_parser.add_argument('-e', dest='escape', metavar='ESCAPE_CHAR',
687 action='store', default=_ESCAPE, type=str,
688 help='set shell escape character, \'none\' to '
689 'disable escape completely')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800690
691 for attr in self.SUBCOMMANDS:
692 parser = subparsers.add_parser(attr['command'], help=attr['help'])
693 parser.set_defaults(which=attr['command'])
694 for arg in attr['args']:
695 parser.add_argument(*arg[0], **arg[1])
696
697 return root_parser
698
699 def Main(self):
700 # We want to pass the rest of arguments after shell command directly to the
701 # function without parsing it.
702 try:
703 index = sys.argv.index('shell')
704 except ValueError:
705 args = self._parser.parse_args()
706 else:
707 args = self._parser.parse_args(sys.argv[1:index + 1])
708
709 command = args.which
710 self._selected_mid = args.selected_mid
711
Wei-Ning Huang0c520e92016-03-19 20:01:10 +0800712 if args.escape and args.escape != 'none':
713 self._escape = args.escape[0]
714
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800715 if command == 'start-server':
716 self.StartServer()
717 return
718 elif command == 'kill-server':
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800719 self.KillServer()
720 return
721
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800722 self.CheckDaemon()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800723 if command == 'status':
724 self.Status()
725 return
726 elif command == 'connect':
727 self.Connect(args)
728 return
729
730 # The following command requires connection to the server
731 self.CheckConnection()
732
733 if args.select_mid_before_action:
734 self.SelectClient(store=False)
735
736 if command == 'select':
737 self.SelectClient(args)
738 elif command == 'ls':
739 self.ListClients()
740 elif command == 'shell':
741 command = sys.argv[sys.argv.index('shell') + 1:]
742 self.Shell(command)
743 elif command == 'push':
744 self.Push(args)
745 elif command == 'pull':
746 self.Pull(args)
747 elif command == 'forward':
748 self.Forward(args)
749
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800750 def _SaveTLSCertificate(self, host, cert_pem):
751 try:
752 os.makedirs(_CERT_DIR)
753 except Exception:
754 pass
755 with open(GetTLSCertPath(host), 'w') as f:
756 f.write(cert_pem)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800757
758 def _HTTPPostFile(self, url, filename, progress=None, user=None, passwd=None):
759 """Perform HTTP POST and upload file to Overlord.
760
761 To minimize the external dependencies, we construct the HTTP post request
762 by ourselves.
763 """
764 url = MakeRequestUrl(self._state, url)
765 size = os.stat(filename).st_size
766 boundary = '-----------%s' % _HTTP_BOUNDARY_MAGIC
767 CRLF = '\r\n'
768 parse = urlparse.urlparse(url)
769
770 part_headers = [
771 '--' + boundary,
772 'Content-Disposition: form-data; name="file"; '
773 'filename="%s"' % os.path.basename(filename),
774 'Content-Type: application/octet-stream',
775 '', ''
776 ]
777 part_header = CRLF.join(part_headers)
778 end_part = CRLF + '--' + boundary + '--' + CRLF
779
780 content_length = len(part_header) + size + len(end_part)
781 if parse.scheme == 'http':
782 h = httplib.HTTP(parse.netloc)
783 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800784 h = httplib.HTTPS(parse.netloc, context=self._state.ssl_context)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800785
786 post_path = url[url.index(parse.netloc) + len(parse.netloc):]
787 h.putrequest('POST', post_path)
788 h.putheader('Content-Length', content_length)
789 h.putheader('Content-Type', 'multipart/form-data; boundary=%s' % boundary)
790
791 if user and passwd:
792 h.putheader(*BasicAuthHeader(user, passwd))
793 h.endheaders()
794 h.send(part_header)
795
796 count = 0
797 with open(filename, 'r') as f:
798 while True:
799 data = f.read(_BUFSIZ)
800 if not data:
801 break
802 count += len(data)
803 if progress:
804 progress(int(count * 100.0 / size), count)
805 h.send(data)
806
807 h.send(end_part)
808 progress(100)
809
810 if count != size:
811 logging.warning('file changed during upload, upload may be truncated.')
812
813 errcode, unused_x, unused_y = h.getreply()
814 return errcode == 200
815
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800816 def CheckDaemon(self):
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800817 self._server = OverlordClientDaemon.GetRPCServer()
818 if self._server is None:
819 print('* daemon not running, starting it now on port %d ... *' %
820 _OVERLORD_CLIENT_DAEMON_PORT)
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800821 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800822
823 self._state = self._server.State()
824 sha1sum = GetVersionDigest()
825
826 if sha1sum != self._state.version_sha1sum:
827 print('ovl server is out of date. killing...')
828 KillGraceful(self._server.GetPid())
Wei-Ning Huang5564eea2016-01-19 14:36:45 +0800829 self.StartServer()
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800830
831 def GetSSHControlFile(self, host):
832 return _SSH_CONTROL_SOCKET_PREFIX + host
833
834 def SSHTunnel(self, user, host, port):
835 """SSH forward the remote overlord server.
836
837 Overlord server may not have port 9000 open to the public network, in such
838 case we can SSH forward the port to localhost.
839 """
840
841 control_file = self.GetSSHControlFile(host)
842 try:
843 os.unlink(control_file)
844 except Exception:
845 pass
846
847 subprocess.Popen([
848 'ssh', '-Nf',
849 '-M', # Enable master mode
850 '-S', control_file,
851 '-L', '9000:localhost:9000',
852 '-p', str(port),
853 '%s%s' % (user + '@' if user else '', host)
854 ]).wait()
855
856 p = subprocess.Popen([
857 'ssh',
858 '-S', control_file,
859 '-O', 'check', host,
860 ], stderr=subprocess.PIPE)
861 unused_stdout, stderr = p.communicate()
862
863 s = re.search(r'pid=(\d+)', stderr)
864 if s:
865 return int(s.group(1))
866
867 raise RuntimeError('can not establish ssh connection')
868
869 def CheckConnection(self):
870 if self._state.host is None:
871 raise RuntimeError('not connected to any server, abort')
872
873 try:
874 self._server.Clients()
875 except Exception:
876 raise RuntimeError('remote server disconnected, abort')
877
878 if self._state.ssh_pid is not None:
879 ret = subprocess.Popen(['kill', '-0', str(self._state.ssh_pid)],
880 stdout=subprocess.PIPE,
881 stderr=subprocess.PIPE).wait()
882 if ret != 0:
883 raise RuntimeError('ssh tunnel disconnected, please re-connect')
884
885 def CheckClient(self):
886 if self._selected_mid is None:
887 if self._state.selected_mid is None:
888 raise RuntimeError('No client is selected')
889 self._selected_mid = self._state.selected_mid
890
891 if self._selected_mid not in self._server.Clients():
892 raise RuntimeError('client %s disappeared' % self._selected_mid)
893
894 def CheckOutput(self, command):
895 headers = []
896 if self._state.username is not None and self._state.password is not None:
897 headers.append(BasicAuthHeader(self._state.username,
898 self._state.password))
899
900 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
901 sio = StringIO.StringIO()
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800902 ws = ShellWebSocketClient(self._state, sio,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +0800903 scheme + '%s:%d/api/agent/shell/%s?command=%s' %
904 (self._state.host, self._state.port,
905 self._selected_mid, urllib2.quote(command)),
906 headers=headers)
907 ws.connect()
908 ws.run()
909 return sio.getvalue()
910
911 @Command('status', 'show Overlord connection status')
912 def Status(self):
913 if self._state.host is None:
914 print('Not connected to any host.')
915 else:
916 if self._state.ssh_pid is not None:
917 print('Connected to %s with SSH tunneling.' % self._state.orig_host)
918 else:
919 print('Connected to %s:%d.' % (self._state.host, self._state.port))
920
921 if self._selected_mid is None:
922 self._selected_mid = self._state.selected_mid
923
924 if self._selected_mid is None:
925 print('No client is selected.')
926 else:
927 print('Client %s selected.' % self._selected_mid)
928
929 @Command('connect', 'connect to Overlord server', [
930 Arg('host', metavar='HOST', type=str, default='localhost',
931 help='Overlord hostname/IP'),
932 Arg('port', metavar='PORT', type=int,
933 default=_OVERLORD_HTTP_PORT, help='Overlord port'),
934 Arg('-f', '--forward', dest='ssh_forward', default=False,
935 action='store_true',
936 help='connect with SSH forwarding to the host'),
937 Arg('-p', '--ssh-port', dest='ssh_port', default=22,
938 type=int, help='SSH server port for SSH forwarding'),
939 Arg('-l', '--ssh-login', dest='ssh_login', default='',
940 type=str, help='SSH server login name for SSH forwarding'),
941 Arg('-u', '--user', dest='user', default=None,
942 type=str, help='Overlord HTTP auth username'),
943 Arg('-w', '--passwd', dest='passwd', default=None, type=str,
944 help='Overlord HTTP auth password')])
945 def Connect(self, args):
946 ssh_pid = None
947 host = args.host
948 orig_host = args.host
949
950 if args.ssh_forward:
951 # Kill previous SSH tunnel
952 self.KillSSHTunnel()
953
954 ssh_pid = self.SSHTunnel(args.ssh_login, args.host, args.ssh_port)
955 host = 'localhost'
956
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800957 username_provided = args.user is not None
958 password_provided = args.passwd is not None
959 prompt = False
960
961 for unused_i in range(3):
962 try:
963 if prompt:
964 if not username_provided:
965 args.user = raw_input('Username: ')
966 if not password_provided:
967 args.passwd = getpass.getpass('Password: ')
968
969 ret = self._server.Connect(host, args.port, ssh_pid, args.user,
970 args.passwd, orig_host)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800971 if isinstance(ret, list):
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800972 if ret[0].startswith('SSL'):
973 cert_pem = ret[1]
974 fp = GetTLSCertificateSHA1Fingerprint(cert_pem)
975 fp_text = ':'.join([fp[i:i+2] for i in range(0, len(fp), 2)])
976
977 if ret[0] == 'SSLCertificateChanged':
978 print(_TLS_CERT_CHANGED_WARNING % (fp_text, GetTLSCertPath(host)))
979 return
980 elif ret[0] == 'SSLVerifyFailed':
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800981 print(_TLS_CERT_FAILED_WARNING % (fp_text), end='')
982 response = raw_input()
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800983 if response.lower() in ['y', 'ye', 'yes']:
984 self._SaveTLSCertificate(host, cert_pem)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +0800985 print('TLS host Certificate trusted, you will not be prompted '
986 'next time.\n')
Wei-Ning Huangba768ab2016-02-07 14:38:06 +0800987 continue
988 else:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +0800989 print('connection aborted.')
990 return
991 elif ret[0] == 'HTTPError':
992 code, except_str, body = ret[1:]
993 if code == 401:
994 print('connect: %s' % body)
995 prompt = True
996 if not username_provided or not password_provided:
997 continue
998 else:
999 break
1000 else:
1001 logging.error('%s; %s', except_str, body)
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001002
1003 if ret is not True:
1004 print('can not connect to %s: %s' % (host, ret))
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001005 else:
1006 print('connection to %s:%d established.' % (host, args.port))
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001007 except Exception as e:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001008 logging.error(e)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001009 else:
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001010 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001011
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001012 @Command('start-server', 'start overlord CLI client server')
1013 def StartServer(self):
1014 self._server = OverlordClientDaemon.GetRPCServer()
1015 if self._server is None:
1016 OverlordClientDaemon().Start()
1017 time.sleep(1)
1018 self._server = OverlordClientDaemon.GetRPCServer()
1019 if self._server is not None:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001020 print('* daemon started successfully *\n')
Wei-Ning Huang5564eea2016-01-19 14:36:45 +08001021
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001022 @Command('kill-server', 'kill overlord CLI client server')
1023 def KillServer(self):
1024 self._server = OverlordClientDaemon.GetRPCServer()
1025 if self._server is None:
1026 return
1027
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001028 self._state = self._server.State()
1029
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001030 # Kill SSH Tunnel
1031 self.KillSSHTunnel()
1032
1033 # Kill server daemon
1034 KillGraceful(self._server.GetPid())
1035
1036 def KillSSHTunnel(self):
1037 if self._state.ssh_pid is not None:
1038 KillGraceful(self._state.ssh_pid)
1039
1040 @Command('ls', 'list all clients')
1041 def ListClients(self):
1042 for client in self._server.Clients():
1043 print(client)
1044
1045 @Command('select', 'select default client', [
1046 Arg('mid', metavar='mid', nargs='?', default=None)])
1047 def SelectClient(self, args=None, store=True):
1048 clients = self._server.Clients()
1049
1050 mid = args.mid if args is not None else None
1051 if mid is None:
1052 print('Select from the following clients:')
1053 for i, client in enumerate(clients):
1054 print(' %d. %s' % (i + 1, client))
1055
1056 print('\nSelection: ', end='')
1057 try:
1058 choice = int(raw_input()) - 1
1059 mid = clients[choice]
1060 except ValueError:
1061 raise RuntimeError('select: invalid selection')
1062 except IndexError:
1063 raise RuntimeError('select: selection out of range')
1064 else:
1065 if mid not in clients:
1066 raise RuntimeError('select: client %s does not exist' % mid)
1067
1068 self._selected_mid = mid
1069 if store:
1070 self._server.SelectClient(mid)
1071 print('Client %s selected' % mid)
1072
1073 @Command('shell', 'open a shell or execute a shell command', [
1074 Arg('command', metavar='CMD', nargs='?', help='command to execute')])
1075 def Shell(self, command=None):
1076 if command is None:
1077 command = []
1078 self.CheckClient()
1079
1080 headers = []
1081 if self._state.username is not None and self._state.password is not None:
1082 headers.append(BasicAuthHeader(self._state.username,
1083 self._state.password))
1084
1085 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1086 if len(command) == 0:
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001087 ws = TerminalWebSocketClient(self._state, self._selected_mid,
Wei-Ning Huang0c520e92016-03-19 20:01:10 +08001088 self._escape,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001089 scheme + '%s:%d/api/agent/tty/%s' %
1090 (self._state.host, self._state.port,
1091 self._selected_mid), headers=headers)
1092 else:
1093 cmd = ' '.join(command)
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001094 ws = ShellWebSocketClient(self._state, sys.stdout,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001095 scheme + '%s:%d/api/agent/shell/%s?command=%s' %
1096 (self._state.host, self._state.port,
1097 self._selected_mid, urllib2.quote(cmd)),
1098 headers=headers)
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001099 try:
1100 ws.connect()
1101 ws.run()
1102 except socket.error as e:
1103 if e.errno == 32: # Broken pipe
1104 pass
1105 else:
1106 raise
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001107
1108 @Command('push', 'push a file or directory to remote', [
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001109 Arg('srcs', nargs='+', metavar='SOURCE'),
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001110 Arg('dst', metavar='DESTINATION')])
1111 def Push(self, args):
1112 self.CheckClient()
1113
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001114 @AutoRetry('push', _RETRY_TIMES)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001115 def _push(src, dst):
1116 src_base = os.path.basename(src)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001117
1118 # Local file is a link
1119 if os.path.islink(src):
1120 pbar = ProgressBar(src_base)
1121 link_path = os.readlink(src)
1122 self.CheckOutput('mkdir -p %(dirname)s; '
1123 'if [ -d "%(dst)s" ]; then '
1124 'ln -sf "%(link_path)s" "%(dst)s/%(link_name)s"; '
1125 'else ln -sf "%(link_path)s" "%(dst)s"; fi' %
1126 dict(dirname=os.path.dirname(dst),
1127 link_path=link_path, dst=dst,
1128 link_name=src_base))
1129 pbar.End()
1130 return
1131
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001132 mode = '0%o' % (0x1FF & os.stat(src).st_mode)
1133 url = ('%s:%d/api/agent/upload/%s?dest=%s&perm=%s' %
1134 (self._state.host, self._state.port, self._selected_mid, dst,
1135 mode))
1136 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001137 UrlOpen(self._state, url + '&filename=%s' % src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001138 except urllib2.HTTPError as e:
1139 msg = json.loads(e.read()).get('error', None)
1140 raise RuntimeError('push: %s' % msg)
1141
1142 pbar = ProgressBar(src_base)
1143 self._HTTPPostFile(url, src, pbar.SetProgress,
1144 self._state.username, self._state.password)
1145 pbar.End()
1146
Wei-Ning Huang37e61102016-02-07 13:41:07 +08001147 def _push_single_target(src, dst):
1148 if os.path.isdir(src):
1149 dst_exists = ast.literal_eval(self.CheckOutput(
1150 'stat %s >/dev/null 2>&1 && echo True || echo False' % dst))
1151 for root, unused_x, files in os.walk(src):
1152 # If destination directory does not exist, we should strip the first
1153 # layer of directory. For example: src_dir contains a single file 'A'
1154 #
1155 # push src_dir dest_dir
1156 #
1157 # If dest_dir exists, the resulting directory structure should be:
1158 # dest_dir/src_dir/A
1159 # If dest_dir does not exist, the resulting directory structure should
1160 # be:
1161 # dest_dir/A
1162 dst_root = root if dst_exists else root[len(src):].lstrip('/')
1163 for name in files:
1164 _push(os.path.join(root, name),
1165 os.path.join(dst, dst_root, name))
1166 else:
1167 _push(src, dst)
1168
1169 if len(args.srcs) > 1:
1170 dst_type = self.CheckOutput('stat \'%s\' --printf \'%%F\' '
1171 '2>/dev/null' % args.dst).strip()
1172 if not dst_type:
1173 raise RuntimeError('push: %s: No such file or directory' % args.dst)
1174 if dst_type != 'directory':
1175 raise RuntimeError('push: %s: Not a directory' % args.dst)
1176
1177 for src in args.srcs:
1178 if not os.path.exists(src):
1179 raise RuntimeError('push: can not stat "%s": no such file or directory'
1180 % src)
1181 if not os.access(src, os.R_OK):
1182 raise RuntimeError('push: can not open "%s" for reading' % src)
1183
1184 _push_single_target(src, args.dst)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001185
1186 @Command('pull', 'pull a file or directory from remote', [
1187 Arg('src', metavar='SOURCE'),
1188 Arg('dst', metavar='DESTINATION', default='.', nargs='?')])
1189 def Pull(self, args):
1190 self.CheckClient()
1191
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001192 @AutoRetry('pull', _RETRY_TIMES)
1193 def _pull(src, dst, ftype, perm=0644, link=None):
1194 try:
1195 os.makedirs(os.path.dirname(dst))
1196 except Exception:
1197 pass
1198
1199 src_base = os.path.basename(src)
1200
1201 # Remote file is a link
1202 if ftype == 'l':
1203 pbar = ProgressBar(src_base)
1204 if os.path.exists(dst):
1205 os.remove(dst)
1206 os.symlink(link, dst)
1207 pbar.End()
1208 return
1209
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001210 url = ('%s:%d/api/agent/download/%s?filename=%s' %
1211 (self._state.host, self._state.port, self._selected_mid,
1212 urllib2.quote(src)))
1213 try:
Wei-Ning Huangd3a57dc2016-02-09 16:31:04 +08001214 h = UrlOpen(self._state, url)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001215 except urllib2.HTTPError as e:
1216 msg = json.loads(e.read()).get('error', 'unkown error')
1217 raise RuntimeError('pull: %s' % msg)
1218 except KeyboardInterrupt:
1219 return
1220
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001221 pbar = ProgressBar(src_base)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001222 with open(dst, 'w') as f:
1223 os.fchmod(f.fileno(), perm)
1224 total_size = int(h.headers.get('Content-Length'))
1225 downloaded_size = 0
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001226
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001227 while True:
1228 data = h.read(_BUFSIZ)
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001229 if len(data) == 0:
1230 break
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001231 downloaded_size += len(data)
1232 pbar.SetProgress(float(downloaded_size) * 100 / total_size,
1233 downloaded_size)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001234 f.write(data)
1235 pbar.End()
1236
1237 # Use find to get a listing of all files under a root directory. The 'stat'
1238 # command is used to retrieve the filename and it's filemode.
1239 output = self.CheckOutput(
1240 'cd $HOME; '
1241 'stat "%(src)s" >/dev/null && '
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001242 'find "%(src)s" \'(\' -type f -o -type l \')\' '
1243 '-printf \'%%m\t%%p\t%%y\t%%l\n\''
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001244 % {'src': args.src})
1245
1246 # We got error from the stat command
1247 if output.startswith('stat: '):
1248 sys.stderr.write(output)
1249 return
1250
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001251 entries = output.strip('\n').split('\n')
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001252 common_prefix = os.path.dirname(args.src)
1253
1254 if len(entries) == 1:
1255 entry = entries[0]
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001256 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001257 if os.path.isdir(args.dst):
1258 dst = os.path.join(args.dst, os.path.basename(src_path))
1259 else:
1260 dst = args.dst
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001261 _pull(src_path, dst, ftype, int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001262 else:
1263 if not os.path.exists(args.dst):
1264 common_prefix = args.src
1265
1266 for entry in entries:
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001267 perm, src_path, ftype, link = entry.split('\t', -1)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001268 rel_dst = src_path[len(common_prefix):].lstrip('/')
Wei-Ning Huangee7ca8d2015-12-12 05:48:02 +08001269 _pull(src_path, os.path.join(args.dst, rel_dst), ftype,
1270 int(perm, base=8), link)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001271
1272 @Command('forward', 'forward remote port to local port', [
1273 Arg('--list', dest='list_all', action='store_true', default=False,
1274 help='list all port forwarding sessions'),
1275 Arg('--remove', metavar='LOCAL_PORT', dest='remove', type=int,
1276 default=None,
1277 help='remove port forwarding for local port LOCAL_PORT'),
1278 Arg('--remove-all', dest='remove_all', action='store_true',
1279 default=False, help='remove all port forwarding'),
1280 Arg('remote', metavar='REMOTE_PORT', type=int, nargs='?'),
1281 Arg('local', metavar='LOCAL_PORT', type=int, nargs='?')])
1282 def Forward(self, args):
1283 if args.list_all:
1284 max_len = 10
1285 if len(self._state.forwards):
1286 max_len = max([len(v[0]) for v in self._state.forwards.values()])
1287
1288 print('%-*s %-8s %-8s' % (max_len, 'Client', 'Remote', 'Local'))
1289 for local in sorted(self._state.forwards.keys()):
1290 value = self._state.forwards[local]
1291 print('%-*s %-8s %-8s' % (max_len, value[0], value[1], local))
1292 return
1293
1294 if args.remove_all:
1295 self._server.RemoveAllForward()
1296 return
1297
1298 if args.remove:
1299 self._server.RemoveForward(args.remove)
1300 return
1301
1302 self.CheckClient()
1303
Wei-Ning Huang9083b7c2016-01-26 16:44:11 +08001304 if args.remote is None:
1305 raise RuntimeError('remote port not specified')
1306
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001307 if args.local is None:
1308 args.local = args.remote
1309 remote = int(args.remote)
1310 local = int(args.local)
1311
1312 def HandleConnection(conn):
1313 headers = []
1314 if self._state.username is not None and self._state.password is not None:
1315 headers.append(BasicAuthHeader(self._state.username,
1316 self._state.password))
1317
1318 scheme = 'ws%s://' % ('s' if self._state.ssl else '')
1319 ws = ForwarderWebSocketClient(
Wei-Ning Huang47c79b82016-05-24 01:24:46 +08001320 self._state, conn,
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001321 scheme + '%s:%d/api/agent/forward/%s?port=%d' %
1322 (self._state.host, self._state.port, self._selected_mid, remote),
1323 headers=headers)
1324 try:
1325 ws.connect()
1326 ws.run()
1327 except Exception as e:
1328 print('error: %s' % e)
1329 finally:
1330 ws.close()
1331
1332 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1333 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1334 server.bind(('0.0.0.0', local))
1335 server.listen(5)
1336
1337 pid = os.fork()
1338 if pid == 0:
1339 while True:
1340 conn, unused_addr = server.accept()
1341 t = threading.Thread(target=HandleConnection, args=(conn,))
1342 t.daemon = True
1343 t.start()
1344 else:
1345 self._server.AddForward(self._selected_mid, remote, local, pid)
1346
1347
1348def main():
Wei-Ning Huangba768ab2016-02-07 14:38:06 +08001349 # Setup logging format
1350 logger = logging.getLogger()
1351 logger.setLevel(logging.INFO)
1352 handler = logging.StreamHandler()
1353 formatter = logging.Formatter('%(asctime)s %(message)s', '%Y/%m/%d %H:%M:%S')
1354 handler.setFormatter(formatter)
1355 logger.addHandler(handler)
Wei-Ning Huang91aaeed2015-09-24 14:51:56 +08001356
1357 # Add DaemonState to JSONRPC lib classes
1358 Config.instance().classes.add(DaemonState)
1359
1360 ovl = OverlordCLIClient()
1361 try:
1362 ovl.Main()
1363 except KeyboardInterrupt:
1364 print('Ctrl-C received, abort')
1365 except Exception as e:
1366 print('error: %s' % e)
1367
1368
1369if __name__ == '__main__':
1370 main()