blob: 004fdbadbfce9b91a30a821496c977a49307bbf3 [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']
Joanna Wanga6c52f52022-11-03 16:51:19 -0400185 with Trace('Call to ssh (check call): %s', ' '.join(check_command)):
186 try:
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()
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400192
Joanna Wanga6c52f52022-11-03 16:51:19 -0400193 if not isnt_running:
194 # Our double-check found that the master _was_ infact running. Add to
195 # the list of keys.
196 self._master_keys[key] = True
197 return True
198 except Exception:
199 # Ignore excpetions. We we will fall back to the normal command and
200 # print to the log there.
201 pass
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400202
203 command = command_base[:1] + ['-M', '-N'] + command_base[1:]
Joanna Wanga6c52f52022-11-03 16:51:19 -0400204 p = None
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400205 try:
Joanna Wanga6c52f52022-11-03 16:51:19 -0400206 with Trace('Call to ssh: %s', ' '.join(command)):
207 p = subprocess.Popen(command)
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400208 except Exception as e:
Mike Frysinger339f2df2021-05-06 00:44:42 -0400209 self._master_broken.value = True
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400210 print('\nwarn: cannot enable ssh control master for %s:%s\n%s'
211 % (host, port, str(e)), file=sys.stderr)
212 return False
213
214 time.sleep(1)
215 ssh_died = (p.poll() is not None)
216 if ssh_died:
217 return False
218
Mike Frysinger339f2df2021-05-06 00:44:42 -0400219 self.add_master(p)
220 self._master_keys[key] = True
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400221 return True
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400222
Mike Frysinger339f2df2021-05-06 00:44:42 -0400223 def _open(self, host, port=None):
224 """Make sure a ssh master session exists for |host| & |port|.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400225
Mike Frysinger339f2df2021-05-06 00:44:42 -0400226 If one doesn't exist already, we'll create it.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400227
Mike Frysinger339f2df2021-05-06 00:44:42 -0400228 This will obtain any necessary locks to avoid inter-process races.
229 """
230 # Bail before grabbing the lock if we already know that we aren't going to
231 # try creating new masters below.
232 if sys.platform in ('win32', 'cygwin'):
233 return False
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400234
Mike Frysinger339f2df2021-05-06 00:44:42 -0400235 # Acquire the lock. This is needed to prevent opening multiple masters for
236 # the same host when we're running "repo sync -jN" (for N > 1) _and_ the
237 # manifest <remote fetch="ssh://xyz"> specifies a different host from the
238 # one that was passed to repo init.
239 with self._lock:
240 return self._open_unlocked(host, port)
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400241
Mike Frysinger339f2df2021-05-06 00:44:42 -0400242 def preconnect(self, url):
243 """If |uri| will create a ssh connection, setup the ssh master for it."""
244 m = URI_ALL.match(url)
245 if m:
246 scheme = m.group(1)
247 host = m.group(2)
248 if ':' in host:
249 host, port = host.split(':')
250 else:
251 port = None
252 if scheme in ('ssh', 'git+ssh', 'ssh+git'):
253 return self._open(host, port)
254 return False
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400255
Mike Frysinger339f2df2021-05-06 00:44:42 -0400256 m = URI_SCP.match(url)
257 if m:
258 host = m.group(1)
259 return self._open(host)
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400260
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400261 return False
262
Mike Frysinger339f2df2021-05-06 00:44:42 -0400263 def sock(self, create=True):
264 """Return the path to the ssh socket dir.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400265
Mike Frysinger339f2df2021-05-06 00:44:42 -0400266 This has all the master sockets so clients can talk to them.
267 """
268 if self._sock_path is None:
269 if not create:
270 return None
271 tmp_dir = '/tmp'
272 if not os.path.exists(tmp_dir):
273 tmp_dir = tempfile.gettempdir()
274 if version() < (6, 7):
275 tokens = '%r@%h:%p'
276 else:
277 tokens = '%C' # hash of %l%h%p%r
278 self._sock_path = os.path.join(
279 tempfile.mkdtemp('', 'ssh-', tmp_dir),
280 'master-' + tokens)
281 return self._sock_path