Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1"""hods - home directory synchronization. 

2 

3Copyright (C) 2016-2020 Mathias Stelzer <knoppo@rolln.de> 

4 

5hods is free software: you can redistribute it and/or modify 

6it under the terms of the GNU General Public License as published by 

7the Free Software Foundation, either version 3 of the License, or 

8(at your option) any later version. 

9 

10hods is distributed in the hope that it will be useful, 

11but WITHOUT ANY WARRANTY; without even the implied warranty of 

12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

13GNU General Public License for more details. 

14 

15You should have received a copy of the GNU General Public License 

16along with this program. If not, see <http://www.gnu.org/licenses/>. 

17""" 

18import logging 

19import os 

20import platform 

21import pwd 

22import re 

23import subprocess 

24from collections import namedtuple 

25 

26logger = logging.getLogger(__name__) 

27 

28 

29def is_in_path(executable): 

30 """Look for the given executable in PATH and return a bool whether found.""" 

31 paths = os.getenv('PATH', '').split(os.pathsep) 

32 for path in paths: 

33 path = path.strip() 

34 if not path: 

35 continue 

36 if os.access(os.path.join(path, executable), os.X_OK): 

37 return True 

38 return False 

39 

40 

41try: 

42 from shutil import which 

43except ImportError: # pragma: no cover 

44 try: 

45 from distutils.spawn import find_executable as which 

46 except ImportError: 

47 which = is_in_path 

48 

49 

50def get_hostname(): 

51 """Retrieve the hostname.""" 

52 computer_name = os.getenv('COMPUTERNAME', platform.node()) 

53 return os.getenv('HOSTNAME', computer_name).split('.')[0] 

54 

55 

56# Don't trust the environment with the current user! 

57# Get the user and home directory from passwd using the effective user id to 

58# allow execution in a different environment. This makes it easier to use 

59# hods with ssh-disabled remote root users. 

60def pw(): 

61 """Return the pwd namedtuple for the current user.""" 

62 return pwd.getpwuid(os.geteuid()) 

63 

64 

65def pw_user(): 

66 """Return the username of the current user.""" 

67 return pw().pw_name 

68 

69 

70def pw_home(): 

71 """Return the home directory of the current user.""" 

72 return pw().pw_dir 

73 

74 

75def run(*cmd, check=True, capture_output=True, hide=False, **kwargs): 

76 """Wrapper for subprocess.run with extensive logging. 

77 

78 Args: 

79 cmd: 

80 Command arguments to run. 

81 check: 

82 raise `subprocess.CalledProcessError` if command exits with non-zero return code. 

83 capture_output: 

84 Pipe and store stdout/stderr. stderr is piped to stdout and will always be empty. 

85 hide: 

86 Do not forward subprocess stdout. Does not affect logging. Ignored if 

87 ``capture_output`` is `False`. 

88 **kwargs: 

89 Pass to subprocess function. 

90 

91 Return: 

92 `subprocess.CompletedProcess` 

93 

94 Raises: 

95 `subprocess.CalledProcessError` if check is `True` 

96 """ 

97 kwargs.setdefault('universal_newlines', True) 

98 

99 if capture_output: 

100 kwargs['stdout'] = subprocess.PIPE 

101 kwargs['stderr'] = subprocess.STDOUT 

102 

103 shell_cmd = subprocess.list2cmdline(cmd) 

104 logger.info(shell_cmd) 

105 

106 stdout = None 

107 with subprocess.Popen(cmd, **kwargs) as process: 

108 if capture_output: 

109 stdout = '' 

110 for line in process.stdout: 

111 stdout += line 

112 line = line.rstrip() 

113 logger.info('%s: %s', cmd[0], line) 

114 if not hide: 

115 print(line) 

116 process.stdout.close() 

117 retcode = process.wait() 

118 else: 

119 process.communicate() 

120 retcode = process.poll() 

121 

122 if check and retcode: 

123 logger.exception('subprocess error:%s\n%s', shell_cmd, stdout) 

124 raise subprocess.CalledProcessError(retcode, process.args, stdout) 

125 

126 logger.debug('subprocess finished successfully') 

127 return subprocess.CompletedProcess(process.args, retcode, stdout) 

128 

129 

130class ProcessError(Exception): 

131 """Base class for all command errors.""" 

132 

133 

134class SSHError(ProcessError): 

135 """Base class for all ssh errors.""" 

136 

137 pass 

138 

139 

140class RSyncError(ProcessError): 

141 """Base class for all rsync errors.""" 

142 

143 pass 

144 

145 

146class GitError(ProcessError): 

147 """Base class for all git errors.""" 

148 

149 pass 

150 

151 

152def run_ssh(server, *cmd, **kwargs): 

153 """Run ssh subprocess with given arguements.""" 

154 try: 

155 return run('ssh', server, *cmd, **kwargs) 

156 except FileNotFoundError: 

157 raise SSHError('ssh is not installed') 

158 

159 

160def run_rsync(src, dst, **kwargs): 

161 """Run rsync subprocess to synchronize the given paths. 

162 

163 :param src: Command arguments to run. 

164 :param dst: Command arguments to run. 

165 :param kwargs: Pass to subprocess function. 

166 :return: Command status 

167 """ 

168 try: 

169 return run('rsync', '-ave', 'ssh', src, dst, **kwargs) 

170 except FileNotFoundError: 

171 raise RSyncError('rsync is not installed') 

172 

173 

174def run_git(*cmd, **kwargs): 

175 """Run git subprocess with given arguements.""" 

176 try: 

177 return run('git', *cmd, **kwargs) 

178 except FileNotFoundError: 

179 raise GitError('git is not installed') 

180 

181 

182def format_kwargs(*args, **kwargs): 

183 """Format and return the given arguments as string.""" 

184 formatted = [str(a) for a in args] 

185 formatted += ['{}={}'.format(k, v) for k, v in kwargs.items()] 

186 return ', '.join(formatted) 

187 

188 

189def clean_server(server): 

190 """Extract and clean the host from the given server and make sure it ends with ":".""" 

191 if server is None: 

192 return 

193 server = server.strip() 

194 if not server: 

195 return 

196 if '@' in server: 

197 user, serv = server.split('@', 1) 

198 if not user: 

199 raise ValueError('Server address contains "@" but user is empty') 

200 if not serv: 

201 raise ValueError('Server address contains "@" but server is empty') 

202 if ':' in server: 

203 server = server.split(':')[0] 

204 return server 

205 

206 

207class Sortable: 

208 """Mixin to add move_up and move_down methods to a child class.""" 

209 

210 def _get_sortable_items(self): 

211 """Return the sortable list containing this item.""" 

212 return self.parent.children 

213 

214 def _move(self, up=True): 

215 """Move the item one position up or down if possible.""" 

216 items = self._get_sortable_items() 

217 if self not in items: 

218 return False 

219 index = items.index(self) 

220 

221 if up: 

222 index -= 1 

223 if index == -1: 

224 return False # end of list, abort 

225 else: 

226 index += 1 

227 if index == len(items): 

228 return False # end of list, abort 

229 

230 items.remove(self) 

231 items.insert(index, self) 

232 return True 

233 

234 def move_up(self): 

235 """Move the item one position up if possible.""" 

236 return self._move(up=True) 

237 

238 def move_down(self): 

239 """Move the item one position down if possible.""" 

240 return self._move(up=False) 

241 

242 

243class SSHAgentConnectionError(Exception): 

244 """Exception thrown when connecting to the ssh agent fails.""" 

245 

246 def __init__(self, *args, **kwargs): 

247 """Initialize exception.""" 

248 msg = 'Failed to connect to ssh agent. Is it running?' 

249 super().__init__(msg, *args, **kwargs) 

250 

251 

252SSHAgentKey = namedtuple('SSHAgentKey', ('length', 'algorithm', 'key', 'path', 'type')) 

253 

254 

255class SSHAgent: 

256 """The ssh-agent in the current environment.""" 

257 

258 def __init__(self): 

259 """Initialize ssh agent.""" 

260 self.started = False 

261 

262 @property 

263 def auth_sock(self): 

264 """The SSH_AUTH_SOCK environment variable.""" 

265 return os.environ.get('SSH_AUTH_SOCK', None) 

266 

267 @auth_sock.setter 

268 def auth_sock(self, value): 

269 """The SSH_AUTH_SOCK environment variable.""" 

270 if value is None: 

271 try: 

272 del os.environ['SSH_AUTH_SOCK'] 

273 except KeyError: 

274 pass 

275 return 

276 os.environ['SSH_AUTH_SOCK'] = value 

277 

278 def start(self): 

279 """Start the agent and set environment variables.""" 

280 if self.is_running(): 

281 return False 

282 

283 output = subprocess.check_output(['ssh-agent'], stderr=subprocess.PIPE).decode() 

284 

285 m = re.search('SSH_AUTH_SOCK=(?P<auth_sock>[^;]+);', output, re.DOTALL) 

286 if m is None: 

287 raise ValueError('SSH_AUTH_SOCK not found in ssh-agent output: ' + output) 

288 

289 self.auth_sock = m.group('auth_sock') 

290 self.started = True 

291 return True 

292 

293 def is_running(self): 

294 """Check whether the agent is running.""" 

295 if not self.auth_sock: 

296 return False 

297 try: 

298 list(self.gen()) 

299 except SSHAgentConnectionError: 

300 return False 

301 return True 

302 

303 def kill(self): 

304 """Kill the agent and remove environment variables.""" 

305 subprocess.call(['ssh-agent', '-k'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 

306 self.auth_sock = None 

307 

308 def gen(self): 

309 """Generate active ssh agent keys.""" 

310 cmd = ['ssh-add', '-l'] 

311 try: 

312 output = subprocess.check_output(cmd, stderr=subprocess.PIPE) 

313 except subprocess.CalledProcessError as e: 

314 if e.returncode == 1: 

315 return 

316 if e.returncode == 2: 

317 raise SSHAgentConnectionError() 

318 raise 

319 

320 for line in output.decode().splitlines(): 

321 line = line.strip() 

322 if not line: 

323 continue 

324 pattern = re.compile( 

325 r'^(?P<length>\d+) ' 

326 r'(?P<algorithm>[A-Z0-9]+):(?P<key>[a-zA-Z0-9+]+) ' 

327 r'(?P<path>(/.*)+) ' 

328 r'\((?P<type>[a-zA-Z0-9]+)\)$' # noqa: C812 

329 ) 

330 m = re.match(pattern, line) 

331 if m is None: 

332 raise ValueError('Invalid line in ssh-add output: {}'.format(line)) 

333 yield SSHAgentKey(**m.groupdict()) 

334 

335 def has(self, key=None): 

336 """Check whether the ssh agent has the given or any key. 

337 

338 :param key: Path to the private key. Checks for any key if none is given. 

339 :type key: `str` or `None` 

340 :return: Whether the key is active or not. 

341 :rtype: `bool` 

342 """ 

343 keys = list(self.gen()) 

344 if key: 

345 return key in keys 

346 return bool(keys) 

347 

348 def add(self, key=None): 

349 """Add a private key to the agent. 

350 

351 :param key: Path to the private key. The default key is used if none is given. 

352 :type key: `str` or `None` 

353 :return: Whether the operation was successful. 

354 :rtype: `bool` 

355 """ 

356 cmd = ['ssh-add'] 

357 if key: 

358 cmd.append(key) 

359 try: 

360 exitcode = subprocess.call(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 

361 except subprocess.CalledProcessError as e: 

362 if e.returncode == 2: 

363 raise SSHAgentConnectionError() 

364 raise 

365 return not exitcode