blob: 54a773044da0a1e5df3cc62d1453b7fc7671c2ea [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)
Sebastian Schuberthfff1d2d2023-11-15 15:51:33 +010060 except subprocess.CalledProcessError as e:
61 print(
62 "fatal: unable to detect ssh version"
63 f" (code={e.returncode}, output={e.stdout})",
64 file=sys.stderr,
65 )
Gavin Makea2e3302023-03-11 06:46:20 +000066 sys.exit(1)
Mike Frysinger5291eaf2021-05-05 15:53:03 -040067
68
Gavin Makea2e3302023-03-11 06:46:20 +000069URI_SCP = re.compile(r"^([^@:]*@?[^:/]{1,}):")
70URI_ALL = re.compile(r"^([a-z][a-z+-]*)://([^@/]*@?[^/]*)/")
Mike Frysinger5291eaf2021-05-05 15:53:03 -040071
72
Mike Frysinger339f2df2021-05-06 00:44:42 -040073class ProxyManager:
Gavin Makea2e3302023-03-11 06:46:20 +000074 """Manage various ssh clients & masters that we spawn.
Mike Frysinger5291eaf2021-05-05 15:53:03 -040075
Gavin Makea2e3302023-03-11 06:46:20 +000076 This will take care of sharing state between multiprocessing children, and
77 make sure that if we crash, we don't leak any of the ssh sessions.
Mike Frysinger5291eaf2021-05-05 15:53:03 -040078
Gavin Makea2e3302023-03-11 06:46:20 +000079 The code should work with a single-process scenario too, and not add too
80 much overhead due to the manager.
Mike Frysinger339f2df2021-05-06 00:44:42 -040081 """
Mike Frysinger339f2df2021-05-06 00:44:42 -040082
Gavin Makea2e3302023-03-11 06:46:20 +000083 # Path to the ssh program to run which will pass our master settings along.
84 # Set here more as a convenience API.
85 proxy = PROXY_PATH
Mike Frysinger339f2df2021-05-06 00:44:42 -040086
Gavin Makea2e3302023-03-11 06:46:20 +000087 def __init__(self, manager):
88 # Protect access to the list of active masters.
89 self._lock = multiprocessing.Lock()
90 # List of active masters (pid). These will be spawned on demand, and we
91 # are responsible for shutting them all down at the end.
92 self._masters = manager.list()
93 # Set of active masters indexed by "host:port" information.
94 # The value isn't used, but multiprocessing doesn't provide a set class.
95 self._master_keys = manager.dict()
96 # Whether ssh masters are known to be broken, so we give up entirely.
97 self._master_broken = manager.Value("b", False)
98 # List of active ssh sesssions. Clients will be added & removed as
99 # connections finish, so this list is just for safety & cleanup if we
100 # crash.
101 self._clients = manager.list()
102 # Path to directory for holding master sockets.
103 self._sock_path = None
Mike Frysinger339f2df2021-05-06 00:44:42 -0400104
Gavin Makea2e3302023-03-11 06:46:20 +0000105 def __enter__(self):
106 """Enter a new context."""
107 return self
Mike Frysinger339f2df2021-05-06 00:44:42 -0400108
Gavin Makea2e3302023-03-11 06:46:20 +0000109 def __exit__(self, exc_type, exc_value, traceback):
110 """Exit a context & clean up all resources."""
111 self.close()
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400112
Gavin Makea2e3302023-03-11 06:46:20 +0000113 def add_client(self, proc):
114 """Track a new ssh session."""
115 self._clients.append(proc.pid)
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400116
Gavin Makea2e3302023-03-11 06:46:20 +0000117 def remove_client(self, proc):
118 """Remove a completed ssh session."""
119 try:
120 self._clients.remove(proc.pid)
121 except ValueError:
122 pass
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400123
Gavin Makea2e3302023-03-11 06:46:20 +0000124 def add_master(self, proc):
125 """Track a new master connection."""
126 self._masters.append(proc.pid)
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400127
Gavin Makea2e3302023-03-11 06:46:20 +0000128 def _terminate(self, procs):
129 """Kill all |procs|."""
130 for pid in procs:
131 try:
132 os.kill(pid, signal.SIGTERM)
133 os.waitpid(pid, 0)
134 except OSError:
135 pass
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400136
Gavin Makea2e3302023-03-11 06:46:20 +0000137 # The multiprocessing.list() API doesn't provide many standard list()
138 # methods, so we have to manually clear the list.
139 while True:
140 try:
141 procs.pop(0)
142 except: # noqa: E722
143 break
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400144
Gavin Makea2e3302023-03-11 06:46:20 +0000145 def close(self):
146 """Close this active ssh session.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400147
Gavin Makea2e3302023-03-11 06:46:20 +0000148 Kill all ssh clients & masters we created, and nuke the socket dir.
149 """
150 self._terminate(self._clients)
151 self._terminate(self._masters)
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400152
Gavin Makea2e3302023-03-11 06:46:20 +0000153 d = self.sock(create=False)
154 if d:
155 try:
156 platform_utils.rmdir(os.path.dirname(d))
157 except OSError:
158 pass
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400159
Gavin Makea2e3302023-03-11 06:46:20 +0000160 def _open_unlocked(self, host, port=None):
161 """Make sure a ssh master session exists for |host| & |port|.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400162
Gavin Makea2e3302023-03-11 06:46:20 +0000163 If one doesn't exist already, we'll create it.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400164
Gavin Makea2e3302023-03-11 06:46:20 +0000165 We won't grab any locks, so the caller has to do that. This helps keep
166 the business logic of actually creating the master separate from
167 grabbing locks.
168 """
169 # Check to see whether we already think that the master is running; if
170 # we think it's already running, return right away.
171 if port is not None:
Jason R. Coombsb32ccbb2023-09-29 11:04:49 -0400172 key = f"{host}:{port}"
Gavin Makea2e3302023-03-11 06:46:20 +0000173 else:
174 key = host
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400175
Gavin Makea2e3302023-03-11 06:46:20 +0000176 if key in self._master_keys:
177 return True
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400178
Gavin Makea2e3302023-03-11 06:46:20 +0000179 if self._master_broken.value or "GIT_SSH" in os.environ:
180 # Failed earlier, so don't retry.
181 return False
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400182
Gavin Makea2e3302023-03-11 06:46:20 +0000183 # We will make two calls to ssh; this is the common part of both calls.
184 command_base = ["ssh", "-o", "ControlPath %s" % self.sock(), host]
185 if port is not None:
186 command_base[1:1] = ["-p", str(port)]
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400187
Gavin Makea2e3302023-03-11 06:46:20 +0000188 # Since the key wasn't in _master_keys, we think that master isn't
189 # running... but before actually starting a master, we'll double-check.
190 # This can be important because we can't tell that that 'git@myhost.com'
191 # is the same as 'myhost.com' where "User git" is setup in the user's
192 # ~/.ssh/config file.
193 check_command = command_base + ["-O", "check"]
194 with Trace("Call to ssh (check call): %s", " ".join(check_command)):
195 try:
196 check_process = subprocess.Popen(
197 check_command,
198 stdout=subprocess.PIPE,
199 stderr=subprocess.PIPE,
200 )
201 check_process.communicate() # read output, but ignore it...
202 isnt_running = check_process.wait()
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400203
Gavin Makea2e3302023-03-11 06:46:20 +0000204 if not isnt_running:
205 # Our double-check found that the master _was_ infact
206 # running. Add to the list of keys.
207 self._master_keys[key] = True
208 return True
209 except Exception:
210 # Ignore excpetions. We we will fall back to the normal command
211 # and print to the log there.
212 pass
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400213
Gavin Makea2e3302023-03-11 06:46:20 +0000214 command = command_base[:1] + ["-M", "-N"] + command_base[1:]
215 p = None
216 try:
217 with Trace("Call to ssh: %s", " ".join(command)):
218 p = subprocess.Popen(command)
219 except Exception as e:
220 self._master_broken.value = True
221 print(
222 "\nwarn: cannot enable ssh control master for %s:%s\n%s"
223 % (host, port, str(e)),
224 file=sys.stderr,
225 )
226 return False
227
228 time.sleep(1)
229 ssh_died = p.poll() is not None
230 if ssh_died:
231 return False
232
233 self.add_master(p)
234 self._master_keys[key] = True
235 return True
236
237 def _open(self, host, port=None):
238 """Make sure a ssh master session exists for |host| & |port|.
239
240 If one doesn't exist already, we'll create it.
241
242 This will obtain any necessary locks to avoid inter-process races.
243 """
244 # Bail before grabbing the lock if we already know that we aren't going
245 # to try creating new masters below.
246 if sys.platform in ("win32", "cygwin"):
247 return False
248
249 # Acquire the lock. This is needed to prevent opening multiple masters
250 # for the same host when we're running "repo sync -jN" (for N > 1) _and_
251 # the manifest <remote fetch="ssh://xyz"> specifies a different host
252 # from the one that was passed to repo init.
253 with self._lock:
254 return self._open_unlocked(host, port)
255
256 def preconnect(self, url):
257 """If |uri| will create a ssh connection, setup the ssh master for it.""" # noqa: E501
258 m = URI_ALL.match(url)
259 if m:
260 scheme = m.group(1)
261 host = m.group(2)
262 if ":" in host:
263 host, port = host.split(":")
264 else:
265 port = None
266 if scheme in ("ssh", "git+ssh", "ssh+git"):
267 return self._open(host, port)
268 return False
269
270 m = URI_SCP.match(url)
271 if m:
272 host = m.group(1)
273 return self._open(host)
274
275 return False
276
277 def sock(self, create=True):
278 """Return the path to the ssh socket dir.
279
280 This has all the master sockets so clients can talk to them.
281 """
282 if self._sock_path is None:
283 if not create:
284 return None
285 tmp_dir = "/tmp"
286 if not os.path.exists(tmp_dir):
287 tmp_dir = tempfile.gettempdir()
288 if version() < (6, 7):
289 tokens = "%r@%h:%p"
290 else:
291 tokens = "%C" # hash of %l%h%p%r
292 self._sock_path = os.path.join(
293 tempfile.mkdtemp("", "ssh-", tmp_dir), "master-" + tokens
294 )
295 return self._sock_path