blob: ffa0d6c0256be47197c92f5d25170809ca4b4d32 [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
Erik Elmekeeede3742024-02-29 17:33:51 +010027from git_command import git
Mike Frysinger5291eaf2021-05-05 15:53:03 -040028import platform_utils
29from repo_trace import Trace
30
31
Gavin Makea2e3302023-03-11 06:46:20 +000032PROXY_PATH = os.path.join(os.path.dirname(__file__), "git_ssh")
Mike Frysinger5291eaf2021-05-05 15:53:03 -040033
34
35def _run_ssh_version():
Gavin Makea2e3302023-03-11 06:46:20 +000036 """run ssh -V to display the version number"""
37 return subprocess.check_output(
38 ["ssh", "-V"], stderr=subprocess.STDOUT
39 ).decode()
Mike Frysinger5291eaf2021-05-05 15:53:03 -040040
41
42def _parse_ssh_version(ver_str=None):
Gavin Makea2e3302023-03-11 06:46:20 +000043 """parse a ssh version string into a tuple"""
44 if ver_str is None:
45 ver_str = _run_ssh_version()
Saagar Jha90f574f2023-05-04 13:50:00 -070046 m = re.match(r"^OpenSSH_([0-9.]+)(p[0-9]+)?[\s,]", ver_str)
Gavin Makea2e3302023-03-11 06:46:20 +000047 if m:
48 return tuple(int(x) for x in m.group(1).split("."))
49 else:
50 return ()
Mike Frysinger5291eaf2021-05-05 15:53:03 -040051
52
53@functools.lru_cache(maxsize=None)
54def version():
Gavin Makea2e3302023-03-11 06:46:20 +000055 """return ssh version as a tuple"""
56 try:
57 return _parse_ssh_version()
58 except FileNotFoundError:
59 print("fatal: ssh not installed", file=sys.stderr)
60 sys.exit(1)
Sebastian Schuberthfff1d2d2023-11-15 15:51:33 +010061 except subprocess.CalledProcessError as e:
62 print(
63 "fatal: unable to detect ssh version"
64 f" (code={e.returncode}, output={e.stdout})",
65 file=sys.stderr,
66 )
Gavin Makea2e3302023-03-11 06:46:20 +000067 sys.exit(1)
Mike Frysinger5291eaf2021-05-05 15:53:03 -040068
69
Gavin Makea2e3302023-03-11 06:46:20 +000070URI_SCP = re.compile(r"^([^@:]*@?[^:/]{1,}):")
71URI_ALL = re.compile(r"^([a-z][a-z+-]*)://([^@/]*@?[^/]*)/")
Mike Frysinger5291eaf2021-05-05 15:53:03 -040072
73
Mike Frysinger339f2df2021-05-06 00:44:42 -040074class ProxyManager:
Gavin Makea2e3302023-03-11 06:46:20 +000075 """Manage various ssh clients & masters that we spawn.
Mike Frysinger5291eaf2021-05-05 15:53:03 -040076
Gavin Makea2e3302023-03-11 06:46:20 +000077 This will take care of sharing state between multiprocessing children, and
78 make sure that if we crash, we don't leak any of the ssh sessions.
Mike Frysinger5291eaf2021-05-05 15:53:03 -040079
Gavin Makea2e3302023-03-11 06:46:20 +000080 The code should work with a single-process scenario too, and not add too
81 much overhead due to the manager.
Mike Frysinger339f2df2021-05-06 00:44:42 -040082 """
Mike Frysinger339f2df2021-05-06 00:44:42 -040083
Gavin Makea2e3302023-03-11 06:46:20 +000084 # Path to the ssh program to run which will pass our master settings along.
85 # Set here more as a convenience API.
86 proxy = PROXY_PATH
Mike Frysinger339f2df2021-05-06 00:44:42 -040087
Gavin Makea2e3302023-03-11 06:46:20 +000088 def __init__(self, manager):
89 # Protect access to the list of active masters.
90 self._lock = multiprocessing.Lock()
91 # List of active masters (pid). These will be spawned on demand, and we
92 # are responsible for shutting them all down at the end.
93 self._masters = manager.list()
94 # Set of active masters indexed by "host:port" information.
95 # The value isn't used, but multiprocessing doesn't provide a set class.
96 self._master_keys = manager.dict()
97 # Whether ssh masters are known to be broken, so we give up entirely.
98 self._master_broken = manager.Value("b", False)
99 # List of active ssh sesssions. Clients will be added & removed as
100 # connections finish, so this list is just for safety & cleanup if we
101 # crash.
102 self._clients = manager.list()
103 # Path to directory for holding master sockets.
104 self._sock_path = None
Mike Frysinger339f2df2021-05-06 00:44:42 -0400105
Gavin Makea2e3302023-03-11 06:46:20 +0000106 def __enter__(self):
107 """Enter a new context."""
108 return self
Mike Frysinger339f2df2021-05-06 00:44:42 -0400109
Gavin Makea2e3302023-03-11 06:46:20 +0000110 def __exit__(self, exc_type, exc_value, traceback):
111 """Exit a context & clean up all resources."""
112 self.close()
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400113
Gavin Makea2e3302023-03-11 06:46:20 +0000114 def add_client(self, proc):
115 """Track a new ssh session."""
116 self._clients.append(proc.pid)
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400117
Gavin Makea2e3302023-03-11 06:46:20 +0000118 def remove_client(self, proc):
119 """Remove a completed ssh session."""
120 try:
121 self._clients.remove(proc.pid)
122 except ValueError:
123 pass
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400124
Gavin Makea2e3302023-03-11 06:46:20 +0000125 def add_master(self, proc):
126 """Track a new master connection."""
127 self._masters.append(proc.pid)
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400128
Gavin Makea2e3302023-03-11 06:46:20 +0000129 def _terminate(self, procs):
130 """Kill all |procs|."""
131 for pid in procs:
132 try:
133 os.kill(pid, signal.SIGTERM)
134 os.waitpid(pid, 0)
135 except OSError:
136 pass
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400137
Gavin Makea2e3302023-03-11 06:46:20 +0000138 # The multiprocessing.list() API doesn't provide many standard list()
139 # methods, so we have to manually clear the list.
140 while True:
141 try:
142 procs.pop(0)
143 except: # noqa: E722
144 break
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400145
Gavin Makea2e3302023-03-11 06:46:20 +0000146 def close(self):
147 """Close this active ssh session.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400148
Gavin Makea2e3302023-03-11 06:46:20 +0000149 Kill all ssh clients & masters we created, and nuke the socket dir.
150 """
151 self._terminate(self._clients)
152 self._terminate(self._masters)
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400153
Gavin Makea2e3302023-03-11 06:46:20 +0000154 d = self.sock(create=False)
155 if d:
156 try:
157 platform_utils.rmdir(os.path.dirname(d))
158 except OSError:
159 pass
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400160
Gavin Makea2e3302023-03-11 06:46:20 +0000161 def _open_unlocked(self, host, port=None):
162 """Make sure a ssh master session exists for |host| & |port|.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400163
Gavin Makea2e3302023-03-11 06:46:20 +0000164 If one doesn't exist already, we'll create it.
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400165
Gavin Makea2e3302023-03-11 06:46:20 +0000166 We won't grab any locks, so the caller has to do that. This helps keep
167 the business logic of actually creating the master separate from
168 grabbing locks.
169 """
170 # Check to see whether we already think that the master is running; if
171 # we think it's already running, return right away.
172 if port is not None:
Jason R. Coombsb32ccbb2023-09-29 11:04:49 -0400173 key = f"{host}:{port}"
Gavin Makea2e3302023-03-11 06:46:20 +0000174 else:
175 key = host
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400176
Gavin Makea2e3302023-03-11 06:46:20 +0000177 if key in self._master_keys:
178 return True
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400179
Gavin Makea2e3302023-03-11 06:46:20 +0000180 if self._master_broken.value or "GIT_SSH" in os.environ:
181 # Failed earlier, so don't retry.
182 return False
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400183
Gavin Makea2e3302023-03-11 06:46:20 +0000184 # We will make two calls to ssh; this is the common part of both calls.
185 command_base = ["ssh", "-o", "ControlPath %s" % self.sock(), host]
186 if port is not None:
187 command_base[1:1] = ["-p", str(port)]
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400188
Gavin Makea2e3302023-03-11 06:46:20 +0000189 # Since the key wasn't in _master_keys, we think that master isn't
190 # running... but before actually starting a master, we'll double-check.
191 # This can be important because we can't tell that that '[email protected]'
192 # is the same as 'myhost.com' where "User git" is setup in the user's
193 # ~/.ssh/config file.
194 check_command = command_base + ["-O", "check"]
195 with Trace("Call to ssh (check call): %s", " ".join(check_command)):
196 try:
197 check_process = subprocess.Popen(
198 check_command,
199 stdout=subprocess.PIPE,
200 stderr=subprocess.PIPE,
201 )
202 check_process.communicate() # read output, but ignore it...
203 isnt_running = check_process.wait()
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400204
Gavin Makea2e3302023-03-11 06:46:20 +0000205 if not isnt_running:
206 # Our double-check found that the master _was_ infact
207 # running. Add to the list of keys.
208 self._master_keys[key] = True
209 return True
210 except Exception:
211 # Ignore excpetions. We we will fall back to the normal command
212 # and print to the log there.
213 pass
Mike Frysinger5291eaf2021-05-05 15:53:03 -0400214
Erik Elmekeeede3742024-02-29 17:33:51 +0100215 # Git protocol V2 is a new feature in git 2.18.0, made default in
216 # git 2.26.0
217 # It is faster and more efficient than V1.
218 # To enable it when using SSH, the environment variable GIT_PROTOCOL
219 # must be set in the SSH side channel when establishing the connection
220 # to the git server.
221 # See https://git-scm.com/docs/protocol-v2#_ssh_and_file_transport
222 # Normally git does this by itself. But here, where the SSH connection
223 # is established manually over ControlMaster via the repo-tool, it must
224 # be passed in explicitly instead.
225 # Based on https://git-scm.com/docs/gitprotocol-pack#_extra_parameters,
226 # GIT_PROTOCOL is considered an "Extra Parameter" and must be ignored
227 # by servers that do not understand it. This means that it is safe to
228 # set it even when connecting to older servers.
229 # It should also be safe to set the environment variable for older
230 # local git versions, since it is only part of the ssh side channel.
231 git_protocol_version = _get_git_protocol_version()
232 ssh_git_protocol_args = [
233 "-o",
234 f"SetEnv GIT_PROTOCOL=version={git_protocol_version}",
235 ]
236
237 command = (
238 command_base[:1]
239 + ["-M", "-N", *ssh_git_protocol_args]
240 + command_base[1:]
241 )
Gavin Makea2e3302023-03-11 06:46:20 +0000242 p = None
243 try:
244 with Trace("Call to ssh: %s", " ".join(command)):
245 p = subprocess.Popen(command)
246 except Exception as e:
247 self._master_broken.value = True
248 print(
249 "\nwarn: cannot enable ssh control master for %s:%s\n%s"
250 % (host, port, str(e)),
251 file=sys.stderr,
252 )
253 return False
254
255 time.sleep(1)
256 ssh_died = p.poll() is not None
257 if ssh_died:
258 return False
259
260 self.add_master(p)
261 self._master_keys[key] = True
262 return True
263
264 def _open(self, host, port=None):
265 """Make sure a ssh master session exists for |host| & |port|.
266
267 If one doesn't exist already, we'll create it.
268
269 This will obtain any necessary locks to avoid inter-process races.
270 """
271 # Bail before grabbing the lock if we already know that we aren't going
272 # to try creating new masters below.
273 if sys.platform in ("win32", "cygwin"):
274 return False
275
276 # Acquire the lock. This is needed to prevent opening multiple masters
277 # for the same host when we're running "repo sync -jN" (for N > 1) _and_
278 # the manifest <remote fetch="ssh://xyz"> specifies a different host
279 # from the one that was passed to repo init.
280 with self._lock:
281 return self._open_unlocked(host, port)
282
283 def preconnect(self, url):
284 """If |uri| will create a ssh connection, setup the ssh master for it.""" # noqa: E501
285 m = URI_ALL.match(url)
286 if m:
287 scheme = m.group(1)
288 host = m.group(2)
289 if ":" in host:
290 host, port = host.split(":")
291 else:
292 port = None
293 if scheme in ("ssh", "git+ssh", "ssh+git"):
294 return self._open(host, port)
295 return False
296
297 m = URI_SCP.match(url)
298 if m:
299 host = m.group(1)
300 return self._open(host)
301
302 return False
303
304 def sock(self, create=True):
305 """Return the path to the ssh socket dir.
306
307 This has all the master sockets so clients can talk to them.
308 """
309 if self._sock_path is None:
310 if not create:
311 return None
312 tmp_dir = "/tmp"
313 if not os.path.exists(tmp_dir):
314 tmp_dir = tempfile.gettempdir()
315 if version() < (6, 7):
316 tokens = "%r@%h:%p"
317 else:
318 tokens = "%C" # hash of %l%h%p%r
319 self._sock_path = os.path.join(
320 tempfile.mkdtemp("", "ssh-", tmp_dir), "master-" + tokens
321 )
322 return self._sock_path
Erik Elmekeeede3742024-02-29 17:33:51 +0100323
324
325@functools.lru_cache(maxsize=1)
326def _get_git_protocol_version() -> str:
327 """Return the git protocol version.
328
329 The version is found by first reading the global git config.
330 If no git config for protocol version exists, try to deduce the default
331 protocol version based on the git version.
332
333 See https://git-scm.com/docs/gitprotocol-v2 for details.
334 """
335 try:
336 return subprocess.check_output(
337 ["git", "config", "--get", "--global", "protocol.version"],
338 encoding="utf-8",
339 stderr=subprocess.PIPE,
340 ).strip()
341 except subprocess.CalledProcessError as e:
342 if e.returncode == 1:
343 # Exit code 1 means that the git config key was not found.
344 # Try to imitate the defaults that git would have used.
345 git_version = git.version_tuple()
346 if git_version >= (2, 26, 0):
347 # Since git version 2.26, protocol v2 is the default.
348 return "2"
349 return "1"
350 # Other exit codes indicate error with reading the config.
351 raise