blob: 450383dcb496a29374bcfe6af4487e45e6bc0bc6 [file] [log] [blame]
Mike Frysinger5291eaf2021-05-05 15:53:03 -04001# Copyright (C) 2008 The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Common SSH management logic."""
16
17import functools
Mike Frysinger339f2df2021-05-06 00:44:42 -040018import multiprocessing
Mike Frysinger5291eaf2021-05-05 15:53:03 -040019import os
20import re
21import signal
22import subprocess
23import sys
24import tempfile
Mike Frysinger5291eaf2021-05-05 15:53:03 -040025import time
26
27import platform_utils
28from repo_trace import Trace
29
30
Mike Frysinger339f2df2021-05-06 00:44:42 -040031PROXY_PATH = os.path.join(os.path.dirname(__file__), 'git_ssh')
Mike Frysinger5291eaf2021-05-05 15:53:03 -040032
33
34def _run_ssh_version():
35 """run ssh -V to display the version number"""
36 return subprocess.check_output(['ssh', '-V'], stderr=subprocess.STDOUT).decode()
37
38
39def _parse_ssh_version(ver_str=None):
40 """parse a ssh version string into a tuple"""
41 if ver_str is None:
42 ver_str = _run_ssh_version()
43 m = re.match(r'^OpenSSH_([0-9.]+)(p[0-9]+)?\s', ver_str)
44 if m:
45 return tuple(int(x) for x in m.group(1).split('.'))
46 else:
47 return ()
48
49
50@functools.lru_cache(maxsize=None)
51def version():
52 """return ssh version as a tuple"""
53 try:
54 return _parse_ssh_version()
Mike Frysinger282d0ca2021-10-26 12:12:54 -040055 except FileNotFoundError:
56 print('fatal: ssh not installed', file=sys.stderr)
57 sys.exit(1)
Mike Frysinger5291eaf2021-05-05 15:53:03 -040058 except subprocess.CalledProcessError:
59 print('fatal: unable to detect ssh version', file=sys.stderr)
60 sys.exit(1)
61
62
Mike Frysinger339f2df2021-05-06 00:44:42 -040063URI_SCP = re.compile(r'^([^@:]*@?[^:/]{1,}):')
64URI_ALL = re.compile(r'^([a-z][a-z+-]*)://([^@/]*@?[^/]*)/')
Mike Frysinger5291eaf2021-05-05 15:53:03 -040065
66
Mike Frysinger339f2df2021-05-06 00:44:42 -040067class ProxyManager:
68 """Manage various ssh clients & masters that we spawn.
Mike Frysinger5291eaf2021-05-05 15:53:03 -040069
Mike Frysinger339f2df2021-05-06 00:44:42 -040070 This will take care of sharing state between multiprocessing children, and
71 make sure that if we crash, we don't leak any of the ssh sessions.
Mike Frysinger5291eaf2021-05-05 15:53:03 -040072
Mike Frysinger339f2df2021-05-06 00:44:42 -040073 The code should work with a single-process scenario too, and not add too much
74 overhead due to the manager.
Mike Frysinger5291eaf2021-05-05 15:53:03 -040075 """
Mike Frysinger5291eaf2021-05-05 15:53:03 -040076
Mike Frysinger339f2df2021-05-06 00:44:42 -040077 # Path to the ssh program to run which will pass our master settings along.
78 # Set here more as a convenience API.
79 proxy = PROXY_PATH
Mike Frysinger5291eaf2021-05-05 15:53:03 -040080
Mike Frysinger339f2df2021-05-06 00:44:42 -040081 def __init__(self, manager):
82 # Protect access to the list of active masters.
83 self._lock = multiprocessing.Lock()
84 # List of active masters (pid). These will be spawned on demand, and we are
85 # responsible for shutting them all down at the end.
86 self._masters = manager.list()
87 # Set of active masters indexed by "host:port" information.
88 # The value isn't used, but multiprocessing doesn't provide a set class.
89 self._master_keys = manager.dict()
90 # Whether ssh masters are known to be broken, so we give up entirely.
91 self._master_broken = manager.Value('b', False)
92 # List of active ssh sesssions. Clients will be added & removed as
93 # connections finish, so this list is just for safety & cleanup if we crash.
94 self._clients = manager.list()
95 # Path to directory for holding master sockets.
96 self._sock_path = None
Mike Frysinger5291eaf2021-05-05 15:53:03 -040097
Mike Frysinger339f2df2021-05-06 00:44:42 -040098 def __enter__(self):
99 """Enter a new context."""
100 return self
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400101
Mike Frysinger339f2df2021-05-06 00:44:42 -0400102 def __exit__(self, exc_type, exc_value, traceback):
103 """Exit a context & clean up all resources."""
104 self.close()
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400105
Mike Frysinger339f2df2021-05-06 00:44:42 -0400106 def add_client(self, proc):
107 """Track a new ssh session."""
108 self._clients.append(proc.pid)
109
110 def remove_client(self, proc):
111 """Remove a completed ssh session."""
112 try:
113 self._clients.remove(proc.pid)
114 except ValueError:
115 pass
116
117 def add_master(self, proc):
118 """Track a new master connection."""
119 self._masters.append(proc.pid)
120
121 def _terminate(self, procs):
122 """Kill all |procs|."""
123 for pid in procs:
124 try:
125 os.kill(pid, signal.SIGTERM)
126 os.waitpid(pid, 0)
127 except OSError:
128 pass
129
130 # The multiprocessing.list() API doesn't provide many standard list()
131 # methods, so we have to manually clear the list.
132 while True:
133 try:
134 procs.pop(0)
135 except:
136 break
137
138 def close(self):
139 """Close this active ssh session.
140
141 Kill all ssh clients & masters we created, and nuke the socket dir.
142 """
143 self._terminate(self._clients)
144 self._terminate(self._masters)
145
146 d = self.sock(create=False)
147 if d:
148 try:
149 platform_utils.rmdir(os.path.dirname(d))
150 except OSError:
151 pass
152
153 def _open_unlocked(self, host, port=None):
154 """Make sure a ssh master session exists for |host| & |port|.
155
156 If one doesn't exist already, we'll create it.
157
158 We won't grab any locks, so the caller has to do that. This helps keep the
159 business logic of actually creating the master separate from grabbing locks.
160 """
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400161 # Check to see whether we already think that the master is running; if we
162 # think it's already running, return right away.
163 if port is not None:
164 key = '%s:%s' % (host, port)
165 else:
166 key = host
167
Mike Frysinger339f2df2021-05-06 00:44:42 -0400168 if key in self._master_keys:
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400169 return True
170
Mike Frysinger339f2df2021-05-06 00:44:42 -0400171 if self._master_broken.value or 'GIT_SSH' in os.environ:
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400172 # Failed earlier, so don't retry.
173 return False
174
175 # We will make two calls to ssh; this is the common part of both calls.
Mike Frysinger339f2df2021-05-06 00:44:42 -0400176 command_base = ['ssh', '-o', 'ControlPath %s' % self.sock(), host]
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400177 if port is not None:
178 command_base[1:1] = ['-p', str(port)]
179
180 # Since the key wasn't in _master_keys, we think that master isn't running.
181 # ...but before actually starting a master, we'll double-check. This can
182 # be important because we can't tell that that 'git@myhost.com' is the same
183 # as 'myhost.com' where "User git" is setup in the user's ~/.ssh/config file.
184 check_command = command_base + ['-O', 'check']
185 try:
186 Trace(': %s', ' '.join(check_command))
187 check_process = subprocess.Popen(check_command,
188 stdout=subprocess.PIPE,
189 stderr=subprocess.PIPE)
190 check_process.communicate() # read output, but ignore it...
191 isnt_running = check_process.wait()
192
193 if not isnt_running:
194 # Our double-check found that the master _was_ infact running. Add to
195 # the list of keys.
Mike Frysinger339f2df2021-05-06 00:44:42 -0400196 self._master_keys[key] = True
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400197 return True
198 except Exception:
199 # Ignore excpetions. We we will fall back to the normal command and print
200 # to the log there.
201 pass
202
203 command = command_base[:1] + ['-M', '-N'] + command_base[1:]
204 try:
205 Trace(': %s', ' '.join(command))
206 p = subprocess.Popen(command)
207 except Exception as e:
Mike Frysinger339f2df2021-05-06 00:44:42 -0400208 self._master_broken.value = True
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400209 print('\nwarn: cannot enable ssh control master for %s:%s\n%s'
210 % (host, port, str(e)), file=sys.stderr)
211 return False
212
213 time.sleep(1)
214 ssh_died = (p.poll() is not None)
215 if ssh_died:
216 return False
217
Mike Frysinger339f2df2021-05-06 00:44:42 -0400218 self.add_master(p)
219 self._master_keys[key] = True
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400220 return True
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400221
Mike Frysinger339f2df2021-05-06 00:44:42 -0400222 def _open(self, host, port=None):
223 """Make sure a ssh master session exists for |host| & |port|.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400224
Mike Frysinger339f2df2021-05-06 00:44:42 -0400225 If one doesn't exist already, we'll create it.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400226
Mike Frysinger339f2df2021-05-06 00:44:42 -0400227 This will obtain any necessary locks to avoid inter-process races.
228 """
229 # Bail before grabbing the lock if we already know that we aren't going to
230 # try creating new masters below.
231 if sys.platform in ('win32', 'cygwin'):
232 return False
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400233
Mike Frysinger339f2df2021-05-06 00:44:42 -0400234 # Acquire the lock. This is needed to prevent opening multiple masters for
235 # the same host when we're running "repo sync -jN" (for N > 1) _and_ the
236 # manifest <remote fetch="ssh://xyz"> specifies a different host from the
237 # one that was passed to repo init.
238 with self._lock:
239 return self._open_unlocked(host, port)
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400240
Mike Frysinger339f2df2021-05-06 00:44:42 -0400241 def preconnect(self, url):
242 """If |uri| will create a ssh connection, setup the ssh master for it."""
243 m = URI_ALL.match(url)
244 if m:
245 scheme = m.group(1)
246 host = m.group(2)
247 if ':' in host:
248 host, port = host.split(':')
249 else:
250 port = None
251 if scheme in ('ssh', 'git+ssh', 'ssh+git'):
252 return self._open(host, port)
253 return False
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400254
Mike Frysinger339f2df2021-05-06 00:44:42 -0400255 m = URI_SCP.match(url)
256 if m:
257 host = m.group(1)
258 return self._open(host)
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400259
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400260 return False
261
Mike Frysinger339f2df2021-05-06 00:44:42 -0400262 def sock(self, create=True):
263 """Return the path to the ssh socket dir.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400264
Mike Frysinger339f2df2021-05-06 00:44:42 -0400265 This has all the master sockets so clients can talk to them.
266 """
267 if self._sock_path is None:
268 if not create:
269 return None
270 tmp_dir = '/tmp'
271 if not os.path.exists(tmp_dir):
272 tmp_dir = tempfile.gettempdir()
273 if version() < (6, 7):
274 tokens = '%r@%h:%p'
275 else:
276 tokens = '%C' # hash of %l%h%p%r
277 self._sock_path = os.path.join(
278 tempfile.mkdtemp('', 'ssh-', tmp_dir),
279 'master-' + tokens)
280 return self._sock_path