blob: bb89fa1f6b2042143a533222dbdedd402b3b290a [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
Gavin Makea2e3302023-03-11 06:46:20 +000031PROXY_PATH = os.path.join(os.path.dirname(__file__), "git_ssh")
Mike Frysinger5291eaf2021-05-05 15:53:03 -040032
33
34def _run_ssh_version():
Gavin Makea2e3302023-03-11 06:46:20 +000035 """run ssh -V to display the version number"""
36 return subprocess.check_output(
37 ["ssh", "-V"], stderr=subprocess.STDOUT
38 ).decode()
Mike Frysinger5291eaf2021-05-05 15:53:03 -040039
40
41def _parse_ssh_version(ver_str=None):
Gavin Makea2e3302023-03-11 06:46:20 +000042 """parse a ssh version string into a tuple"""
43 if ver_str is None:
44 ver_str = _run_ssh_version()
Saagar Jha90f574f2023-05-04 13:50:00 -070045 m = re.match(r"^OpenSSH_([0-9.]+)(p[0-9]+)?[\s,]", ver_str)
Gavin Makea2e3302023-03-11 06:46:20 +000046 if m:
47 return tuple(int(x) for x in m.group(1).split("."))
48 else:
49 return ()
Mike Frysinger5291eaf2021-05-05 15:53:03 -040050
51
52@functools.lru_cache(maxsize=None)
53def version():
Gavin Makea2e3302023-03-11 06:46:20 +000054 """return ssh version as a tuple"""
55 try:
56 return _parse_ssh_version()
57 except FileNotFoundError:
58 print("fatal: ssh not installed", file=sys.stderr)
59 sys.exit(1)
60 except subprocess.CalledProcessError:
61 print("fatal: unable to detect ssh version", file=sys.stderr)
62 sys.exit(1)
Mike Frysinger5291eaf2021-05-05 15:53:03 -040063
64
Gavin Makea2e3302023-03-11 06:46:20 +000065URI_SCP = re.compile(r"^([^@:]*@?[^:/]{1,}):")
66URI_ALL = re.compile(r"^([a-z][a-z+-]*)://([^@/]*@?[^/]*)/")
Mike Frysinger5291eaf2021-05-05 15:53:03 -040067
68
Mike Frysinger339f2df2021-05-06 00:44:42 -040069class ProxyManager:
Gavin Makea2e3302023-03-11 06:46:20 +000070 """Manage various ssh clients & masters that we spawn.
Mike Frysinger5291eaf2021-05-05 15:53:03 -040071
Gavin Makea2e3302023-03-11 06:46:20 +000072 This will take care of sharing state between multiprocessing children, and
73 make sure that if we crash, we don't leak any of the ssh sessions.
Mike Frysinger5291eaf2021-05-05 15:53:03 -040074
Gavin Makea2e3302023-03-11 06:46:20 +000075 The code should work with a single-process scenario too, and not add too
76 much overhead due to the manager.
Mike Frysinger339f2df2021-05-06 00:44:42 -040077 """
Mike Frysinger339f2df2021-05-06 00:44:42 -040078
Gavin Makea2e3302023-03-11 06:46:20 +000079 # Path to the ssh program to run which will pass our master settings along.
80 # Set here more as a convenience API.
81 proxy = PROXY_PATH
Mike Frysinger339f2df2021-05-06 00:44:42 -040082
Gavin Makea2e3302023-03-11 06:46:20 +000083 def __init__(self, manager):
84 # Protect access to the list of active masters.
85 self._lock = multiprocessing.Lock()
86 # List of active masters (pid). These will be spawned on demand, and we
87 # are responsible for shutting them all down at the end.
88 self._masters = manager.list()
89 # Set of active masters indexed by "host:port" information.
90 # The value isn't used, but multiprocessing doesn't provide a set class.
91 self._master_keys = manager.dict()
92 # Whether ssh masters are known to be broken, so we give up entirely.
93 self._master_broken = manager.Value("b", False)
94 # List of active ssh sesssions. Clients will be added & removed as
95 # connections finish, so this list is just for safety & cleanup if we
96 # crash.
97 self._clients = manager.list()
98 # Path to directory for holding master sockets.
99 self._sock_path = None
Mike Frysinger339f2df2021-05-06 00:44:42 -0400100
Gavin Makea2e3302023-03-11 06:46:20 +0000101 def __enter__(self):
102 """Enter a new context."""
103 return self
Mike Frysinger339f2df2021-05-06 00:44:42 -0400104
Gavin Makea2e3302023-03-11 06:46:20 +0000105 def __exit__(self, exc_type, exc_value, traceback):
106 """Exit a context & clean up all resources."""
107 self.close()
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400108
Gavin Makea2e3302023-03-11 06:46:20 +0000109 def add_client(self, proc):
110 """Track a new ssh session."""
111 self._clients.append(proc.pid)
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400112
Gavin Makea2e3302023-03-11 06:46:20 +0000113 def remove_client(self, proc):
114 """Remove a completed ssh session."""
115 try:
116 self._clients.remove(proc.pid)
117 except ValueError:
118 pass
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400119
Gavin Makea2e3302023-03-11 06:46:20 +0000120 def add_master(self, proc):
121 """Track a new master connection."""
122 self._masters.append(proc.pid)
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400123
Gavin Makea2e3302023-03-11 06:46:20 +0000124 def _terminate(self, procs):
125 """Kill all |procs|."""
126 for pid in procs:
127 try:
128 os.kill(pid, signal.SIGTERM)
129 os.waitpid(pid, 0)
130 except OSError:
131 pass
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400132
Gavin Makea2e3302023-03-11 06:46:20 +0000133 # The multiprocessing.list() API doesn't provide many standard list()
134 # methods, so we have to manually clear the list.
135 while True:
136 try:
137 procs.pop(0)
138 except: # noqa: E722
139 break
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400140
Gavin Makea2e3302023-03-11 06:46:20 +0000141 def close(self):
142 """Close this active ssh session.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400143
Gavin Makea2e3302023-03-11 06:46:20 +0000144 Kill all ssh clients & masters we created, and nuke the socket dir.
145 """
146 self._terminate(self._clients)
147 self._terminate(self._masters)
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400148
Gavin Makea2e3302023-03-11 06:46:20 +0000149 d = self.sock(create=False)
150 if d:
151 try:
152 platform_utils.rmdir(os.path.dirname(d))
153 except OSError:
154 pass
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400155
Gavin Makea2e3302023-03-11 06:46:20 +0000156 def _open_unlocked(self, host, port=None):
157 """Make sure a ssh master session exists for |host| & |port|.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400158
Gavin Makea2e3302023-03-11 06:46:20 +0000159 If one doesn't exist already, we'll create it.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400160
Gavin Makea2e3302023-03-11 06:46:20 +0000161 We won't grab any locks, so the caller has to do that. This helps keep
162 the business logic of actually creating the master separate from
163 grabbing locks.
164 """
165 # Check to see whether we already think that the master is running; if
166 # we think it's already running, return right away.
167 if port is not None:
168 key = "%s:%s" % (host, port)
169 else:
170 key = host
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400171
Gavin Makea2e3302023-03-11 06:46:20 +0000172 if key in self._master_keys:
173 return True
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400174
Gavin Makea2e3302023-03-11 06:46:20 +0000175 if self._master_broken.value or "GIT_SSH" in os.environ:
176 # Failed earlier, so don't retry.
177 return False
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400178
Gavin Makea2e3302023-03-11 06:46:20 +0000179 # We will make two calls to ssh; this is the common part of both calls.
180 command_base = ["ssh", "-o", "ControlPath %s" % self.sock(), host]
181 if port is not None:
182 command_base[1:1] = ["-p", str(port)]
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400183
Gavin Makea2e3302023-03-11 06:46:20 +0000184 # Since the key wasn't in _master_keys, we think that master isn't
185 # running... but before actually starting a master, we'll double-check.
186 # This can be important because we can't tell that that 'git@myhost.com'
187 # is the same as 'myhost.com' where "User git" is setup in the user's
188 # ~/.ssh/config file.
189 check_command = command_base + ["-O", "check"]
190 with Trace("Call to ssh (check call): %s", " ".join(check_command)):
191 try:
192 check_process = subprocess.Popen(
193 check_command,
194 stdout=subprocess.PIPE,
195 stderr=subprocess.PIPE,
196 )
197 check_process.communicate() # read output, but ignore it...
198 isnt_running = check_process.wait()
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400199
Gavin Makea2e3302023-03-11 06:46:20 +0000200 if not isnt_running:
201 # Our double-check found that the master _was_ infact
202 # running. Add to the list of keys.
203 self._master_keys[key] = True
204 return True
205 except Exception:
206 # Ignore excpetions. We we will fall back to the normal command
207 # and print to the log there.
208 pass
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400209
Gavin Makea2e3302023-03-11 06:46:20 +0000210 command = command_base[:1] + ["-M", "-N"] + command_base[1:]
211 p = None
212 try:
213 with Trace("Call to ssh: %s", " ".join(command)):
214 p = subprocess.Popen(command)
215 except Exception as e:
216 self._master_broken.value = True
217 print(
218 "\nwarn: cannot enable ssh control master for %s:%s\n%s"
219 % (host, port, str(e)),
220 file=sys.stderr,
221 )
222 return False
223
224 time.sleep(1)
225 ssh_died = p.poll() is not None
226 if ssh_died:
227 return False
228
229 self.add_master(p)
230 self._master_keys[key] = True
231 return True
232
233 def _open(self, host, port=None):
234 """Make sure a ssh master session exists for |host| & |port|.
235
236 If one doesn't exist already, we'll create it.
237
238 This will obtain any necessary locks to avoid inter-process races.
239 """
240 # Bail before grabbing the lock if we already know that we aren't going
241 # to try creating new masters below.
242 if sys.platform in ("win32", "cygwin"):
243 return False
244
245 # Acquire the lock. This is needed to prevent opening multiple masters
246 # for the same host when we're running "repo sync -jN" (for N > 1) _and_
247 # the manifest <remote fetch="ssh://xyz"> specifies a different host
248 # from the one that was passed to repo init.
249 with self._lock:
250 return self._open_unlocked(host, port)
251
252 def preconnect(self, url):
253 """If |uri| will create a ssh connection, setup the ssh master for it.""" # noqa: E501
254 m = URI_ALL.match(url)
255 if m:
256 scheme = m.group(1)
257 host = m.group(2)
258 if ":" in host:
259 host, port = host.split(":")
260 else:
261 port = None
262 if scheme in ("ssh", "git+ssh", "ssh+git"):
263 return self._open(host, port)
264 return False
265
266 m = URI_SCP.match(url)
267 if m:
268 host = m.group(1)
269 return self._open(host)
270
271 return False
272
273 def sock(self, create=True):
274 """Return the path to the ssh socket dir.
275
276 This has all the master sockets so clients can talk to them.
277 """
278 if self._sock_path is None:
279 if not create:
280 return None
281 tmp_dir = "/tmp"
282 if not os.path.exists(tmp_dir):
283 tmp_dir = tempfile.gettempdir()
284 if version() < (6, 7):
285 tokens = "%r@%h:%p"
286 else:
287 tokens = "%C" # hash of %l%h%p%r
288 self._sock_path = os.path.join(
289 tempfile.mkdtemp("", "ssh-", tmp_dir), "master-" + tokens
290 )
291 return self._sock_path