termius-mcp 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. termius/__init__.py +3 -0
  2. termius/account/__init__.py +2 -0
  3. termius/account/managers.py +226 -0
  4. termius/cli.py +317 -0
  5. termius/cloud/__init__.py +2 -0
  6. termius/cloud/client/__init__.py +2 -0
  7. termius/cloud/client/browser_sso.py +184 -0
  8. termius/cloud/client/controllers.py +180 -0
  9. termius/cloud/client/cryptor.py +339 -0
  10. termius/cloud/client/grpc_login.py +422 -0
  11. termius/cloud/client/keyring.py +252 -0
  12. termius/cloud/client/sodium.py +216 -0
  13. termius/cloud/client/srp_session.py +348 -0
  14. termius/cloud/client/transformers/__init__.py +2 -0
  15. termius/cloud/client/transformers/base.py +34 -0
  16. termius/cloud/client/transformers/many.py +270 -0
  17. termius/cloud/client/transformers/mixins.py +15 -0
  18. termius/cloud/client/transformers/single.py +232 -0
  19. termius/cloud/client/transformers/utils.py +12 -0
  20. termius/core/__init__.py +2 -0
  21. termius/core/api.py +264 -0
  22. termius/core/constants.py +24 -0
  23. termius/core/device.py +34 -0
  24. termius/core/exceptions.py +59 -0
  25. termius/core/models/__init__.py +2 -0
  26. termius/core/models/base.py +174 -0
  27. termius/core/models/terminal.py +243 -0
  28. termius/core/models/utils.py +66 -0
  29. termius/core/paths.py +19 -0
  30. termius/core/settings.py +85 -0
  31. termius/core/signals.py +22 -0
  32. termius/core/ssh_command.py +60 -0
  33. termius/core/ssh_exec.py +138 -0
  34. termius/core/ssh_files.py +335 -0
  35. termius/core/ssh_merge.py +69 -0
  36. termius/core/storage/__init__.py +248 -0
  37. termius/core/storage/driver.py +152 -0
  38. termius/core/storage/idgenerators.py +25 -0
  39. termius/core/storage/operators.py +11 -0
  40. termius/core/storage/query.py +50 -0
  41. termius/core/storage/strategies.py +150 -0
  42. termius/core/subscribers.py +41 -0
  43. termius/core/utils.py +44 -0
  44. termius/main.py +43 -0
  45. termius/mcp/__init__.py +2 -0
  46. termius/mcp/protocol.py +93 -0
  47. termius/mcp/server.py +140 -0
  48. termius/mcp/tools.py +718 -0
  49. termius/runtime.py +50 -0
  50. termius/session.py +129 -0
  51. termius/sync.py +186 -0
  52. termius/vault.py +65 -0
  53. termius_mcp-3.0.0.dist-info/METADATA +278 -0
  54. termius_mcp-3.0.0.dist-info/RECORD +59 -0
  55. termius_mcp-3.0.0.dist-info/WHEEL +5 -0
  56. termius_mcp-3.0.0.dist-info/entry_points.txt +3 -0
  57. termius_mcp-3.0.0.dist-info/licenses/AUTHORS +7 -0
  58. termius_mcp-3.0.0.dist-info/licenses/LICENSE +30 -0
  59. termius_mcp-3.0.0.dist-info/top_level.txt +1 -0
termius/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Termius Cloud MCP server."""
3
+ __version__ = '3.0.0'
@@ -0,0 +1,2 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Account login and settings."""
@@ -0,0 +1,226 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Module with Account manager."""
3
+ import base64
4
+ import uuid
5
+
6
+ from six.moves import configparser
7
+ from ..core.api import API
8
+ from ..core.device import DeviceIdentity
9
+ from ..core.exceptions import (
10
+ ApiError, AuthyTokenIssue, NotMigratedError,
11
+ OptionNotSetException, OtpTokenRequired,
12
+ )
13
+ from ..cloud.client.grpc_login import GrpcLoginClient
14
+ from ..cloud.client.browser_sso import BrowserSso
15
+
16
+ _SCHEMA_NAMES = {
17
+ 0: 'v3',
18
+ 1: 'v3',
19
+ 2: 'v5',
20
+ '0': 'v3',
21
+ '1': 'v3',
22
+ '2': 'v5',
23
+ 'undefined': 'v3',
24
+ 'v3': 'v3',
25
+ 'v5': 'v5',
26
+ }
27
+
28
+
29
+ def _camel(name):
30
+ parts = name.split('_')
31
+ return parts[0] + ''.join(part[:1].upper() + part[1:] for part in parts[1:])
32
+
33
+
34
+ def _b64_field(credentials, payload, *keys):
35
+ """Pick a credential string, accepting camelCase aliases."""
36
+ for source in (credentials, payload):
37
+ if not isinstance(source, dict):
38
+ continue
39
+ for key in keys:
40
+ value = source.get(key)
41
+ if value not in (None, ''):
42
+ if isinstance(value, (bytes, bytearray)):
43
+ return base64.b64encode(bytes(value)).decode('ascii')
44
+ return value
45
+ return None
46
+
47
+
48
+ def _encryption_schema(value, default='v3'):
49
+ if value is None:
50
+ return default
51
+ mapped = _SCHEMA_NAMES.get(value)
52
+ if mapped:
53
+ return mapped
54
+ mapped = _SCHEMA_NAMES.get(str(value).lower())
55
+ if mapped:
56
+ return mapped
57
+ if isinstance(value, str) and value:
58
+ return value
59
+ return default
60
+
61
+
62
+ class AccountManager(object):
63
+ """Class to keep logic for login and logout."""
64
+
65
+ setting_names = ('synchronize_key', 'agent_forwarding')
66
+
67
+ def __init__(self, config):
68
+ """Create new account manager."""
69
+ self.config = config
70
+ self.api = API()
71
+ self.device = DeviceIdentity(config)
72
+
73
+ def login(self, username, password, authy_token=None, firebase_token=None):
74
+ """Retrieve apikey and crypto settings from server."""
75
+ device = self.device.to_json()
76
+ login_email = '' if firebase_token else username
77
+ try:
78
+ payload = GrpcLoginClient().login(
79
+ login_email, password, device,
80
+ authy_token=authy_token,
81
+ firebase_token=firebase_token,
82
+ account_email=username,
83
+ )
84
+ except (AuthyTokenIssue, OtpTokenRequired):
85
+ raise
86
+ except NotMigratedError:
87
+ payload = self.api.login(
88
+ login_email or username, password, authy_token=authy_token,
89
+ device=device, firebase_token=firebase_token,
90
+ )
91
+
92
+ credentials = payload.get('credentials') or payload
93
+ token = credentials.get('token') or payload.get('token')
94
+ hmac_salt = _b64_field(
95
+ credentials, payload, 'hmac_salt', 'hmacSalt',
96
+ )
97
+ salt = _b64_field(credentials, payload, 'salt')
98
+ if not token:
99
+ raise OptionNotSetException('Login response did not include a token')
100
+ if not salt or not hmac_salt:
101
+ raise OptionNotSetException(
102
+ 'Login response missing vault salts (salt/hmac_salt). '
103
+ 'Sign in again after upgrading termius-cli.'
104
+ )
105
+
106
+ self.config.set('User', 'username', username)
107
+ self.config.set('User', 'apikey', token)
108
+ self.config.set('User', 'token_type', 'device')
109
+ self.config.set('User', 'hmac_salt', hmac_salt)
110
+ self.config.set('User', 'salt', salt)
111
+
112
+ bulk = payload.get('bulk_account') or {}
113
+ account = bulk.get('account') or payload.get('account') or {}
114
+ schema = (
115
+ (account.get('feature_toggles') or {}).get('encryption_schema')
116
+ or (account.get('featureToggles') or {}).get('encryptionSchema')
117
+ or payload.get('encryption_schema')
118
+ or 'v3'
119
+ )
120
+ self.config.set('User', 'encryption_schema', _encryption_schema(schema))
121
+ user_id = account.get('user_id')
122
+ if user_id:
123
+ self.config.set('User', 'user_id', str(user_id))
124
+ team = bulk.get('team') or payload.get('team') or {}
125
+ if team:
126
+ self.config.set('User', 'is_team', 'yes')
127
+ self.config.set(
128
+ 'User', 'can_manage_team',
129
+ 'yes' if team.get('is_owner') else 'no',
130
+ )
131
+ pkset = credentials.get('personal_keyset') or credentials.get(
132
+ 'personalKeyset'
133
+ ) or {}
134
+ if isinstance(pkset, dict):
135
+ for key in (
136
+ 'public_key', 'encrypted_private_key', 'encrypted_personal_key',
137
+ ):
138
+ value = pkset.get(key) or pkset.get(_camel(key))
139
+ if value:
140
+ self.config.set('User', key, value)
141
+ self.config.write()
142
+ return payload
143
+
144
+ def login_with_sso(self, password, provider='google', authy_token=None,
145
+ callback_url=None, log=None):
146
+ """Browser SSO, then encryption-password login."""
147
+ identity = self.prepare_sso(
148
+ provider=provider, callback_url=callback_url, log=log,
149
+ open_browser=False,
150
+ )
151
+ return self.login(
152
+ identity['email'], password, authy_token=authy_token,
153
+ firebase_token=identity['firebase_token'],
154
+ )
155
+
156
+ def prepare_sso(self, provider='google', callback_url=None, log=None,
157
+ open_browser=False):
158
+ """Run browser Google/Apple SSO and detect the Termius account."""
159
+ sso = BrowserSso(
160
+ provider=provider, open_browser=open_browser, log=log
161
+ ).authenticate(
162
+ callback_url=callback_url
163
+ )
164
+ detected = self.api.detect_sso_action(sso['firebase_token'])
165
+ action = detected.get('action')
166
+ email = detected.get('email') or sso.get('email')
167
+ firebase_token = (
168
+ detected.get('firebase_token') or sso['firebase_token']
169
+ )
170
+ if action == 'sign-up':
171
+ raise ApiError(
172
+ 'This Google account is not a Termius account yet. '
173
+ 'Finish sign-up in the Termius app first, then retry.'
174
+ )
175
+ if not email:
176
+ raise ApiError('SSO did not return an email address')
177
+ return {'email': email, 'firebase_token': firebase_token, 'action': action}
178
+
179
+ def set_settings(self, dictionary):
180
+ """Store settings."""
181
+ filtered_settings = {
182
+ k: (dictionary[k] and 'yes') or 'no' for k in self.setting_names
183
+ }
184
+ for k, i in filtered_settings.items():
185
+ self.config.set('Settings', k, i)
186
+ self.config.write()
187
+
188
+ def get_settings(self):
189
+ """Get settings or return default."""
190
+ return {
191
+ i: self.config.get_safe('Settings', i, default='yes') == 'yes'
192
+ for i in self.setting_names
193
+ }
194
+
195
+ def logout(self):
196
+ """Remove apikey and other credentials."""
197
+ self.config.remove_section('User')
198
+ self.config.remove_section('Settings')
199
+ self.config.remove_section('CloudSynchronization')
200
+ self.config.write()
201
+
202
+ @property
203
+ def analytics_id(self):
204
+ """Get or create analytics id for device."""
205
+ try:
206
+ client_id = self.config.get('User', 'analytics_id')
207
+ except (configparser.NoSectionError, configparser.NoOptionError):
208
+ client_id = uuid.uuid4()
209
+
210
+ self.config.set('User', 'analytics_id', client_id)
211
+ self.config.write()
212
+
213
+ return client_id
214
+
215
+ @property
216
+ def username(self):
217
+ """Get username."""
218
+ try:
219
+ return self.config.get('User', 'username')
220
+ except (configparser.NoSectionError, configparser.NoOptionError):
221
+ raise OptionNotSetException
222
+
223
+ @property
224
+ def encryption_schema(self):
225
+ """Return stored encryption schema (v3 or v5)."""
226
+ return self.config.get_safe('User', 'encryption_schema', default='v3')
termius/cli.py ADDED
@@ -0,0 +1,317 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Terminal commands for Termius Cloud login."""
3
+ from __future__ import unicode_literals
4
+
5
+ import argparse
6
+ import getpass
7
+ import os
8
+ import sys
9
+
10
+ from .core.exceptions import (
11
+ ApiError, AuthyTokenIssue, OptionNotSetException, OtpTokenRequired,
12
+ )
13
+ from .runtime import Runtime
14
+ from .session import (
15
+ login_email, login_google_complete, login_google_start, logout,
16
+ )
17
+ from .sync import status_payload
18
+ from .vault import VAULT_ENV
19
+
20
+
21
+ class CliError(Exception):
22
+ """User-facing CLI failure."""
23
+
24
+
25
+ class CliContext(object):
26
+ """IO and runtime for one CLI invocation."""
27
+
28
+ def __init__(self, runtime, prompt, secret, stdout, stderr, isatty):
29
+ self.runtime = runtime
30
+ self.prompt = prompt
31
+ self.secret = secret
32
+ self.stdout = stdout
33
+ self.stderr = stderr
34
+ self.isatty = isatty
35
+
36
+
37
+ _LOGIN_ERRORS = (
38
+ ApiError, AuthyTokenIssue, CliError, OptionNotSetException,
39
+ OtpTokenRequired, ValueError,
40
+ )
41
+
42
+
43
+ def build_parser():
44
+ """Return the argparse parser for terminal commands."""
45
+ parser = argparse.ArgumentParser(
46
+ prog='termius',
47
+ description=(
48
+ 'Termius Cloud MCP server. With no arguments, speak MCP on '
49
+ 'stdin/stdout. Use login to sign in from a terminal.'
50
+ ),
51
+ )
52
+ sub = parser.add_subparsers(dest='command', metavar='command')
53
+ login = sub.add_parser(
54
+ 'login',
55
+ help='Sign in with Google, email and password, or OTP',
56
+ description=(
57
+ 'Sign in to Termius Cloud from a terminal. google prints an SSO '
58
+ 'URL. email prompts for the vault password. If 2FA is on, the '
59
+ 'command prompts for OTP.'
60
+ ),
61
+ )
62
+ login.add_argument(
63
+ 'method',
64
+ nargs='?',
65
+ choices=('google', 'email'),
66
+ help='google SSO or email/password. Prompt if omitted on a TTY.',
67
+ )
68
+ login.add_argument(
69
+ '-u', '--username',
70
+ help='Termius email (email method)',
71
+ )
72
+ login.add_argument(
73
+ '--otp',
74
+ help='Authenticator / Authy code if 2FA is on',
75
+ )
76
+ login.add_argument(
77
+ '--no-remember',
78
+ action='store_true',
79
+ help='Do not write the vault password to ~/.termius/vault',
80
+ )
81
+ sub.add_parser(
82
+ 'logout',
83
+ help='Sign out and clear local inventory',
84
+ )
85
+ sub.add_parser(
86
+ 'status',
87
+ help='Show login state without syncing',
88
+ )
89
+ return parser
90
+
91
+
92
+ def run(argv, runtime=None, prompt=None, secret=None, stdout=None,
93
+ stderr=None, isatty=None):
94
+ """Run a terminal command. Return a process exit code."""
95
+ stdout = sys.stdout if stdout is None else stdout
96
+ stderr = sys.stderr if stderr is None else stderr
97
+ parser = build_parser()
98
+ args = parser.parse_args(argv)
99
+ if not args.command:
100
+ parser.print_help(stderr)
101
+ return 2
102
+ context = CliContext(
103
+ runtime=runtime or Runtime(),
104
+ prompt=prompt or _stdin_prompt,
105
+ secret=secret or getpass.getpass,
106
+ stdout=stdout,
107
+ stderr=stderr,
108
+ isatty=sys.stdin.isatty() if isatty is None else isatty,
109
+ )
110
+ return _dispatch(args, context)
111
+
112
+
113
+ def _dispatch(args, context):
114
+ handlers = {
115
+ 'login': cmd_login,
116
+ 'logout': cmd_logout,
117
+ 'status': cmd_status,
118
+ }
119
+ try:
120
+ return handlers[args.command](args, context)
121
+ except _LOGIN_ERRORS as exc:
122
+ return _fail(context.stderr, _error_message(exc))
123
+
124
+
125
+ def cmd_login(args, context):
126
+ """Sign in with Google SSO or email and password."""
127
+ method = _login_method(args, context)
128
+ if method == 'google':
129
+ return cmd_login_google(args, context)
130
+ return cmd_login_email(args, context)
131
+
132
+
133
+ def cmd_login_google(args, context):
134
+ """Print the desktop SSO URL, then finish with a pasted callback."""
135
+ started = login_google_start()
136
+ _print_google_instructions(context.stderr, started['url'])
137
+ callback = context.prompt('Paste callback URL: ').strip()
138
+ if not callback:
139
+ raise CliError('callback URL is required')
140
+ data = _google_complete(
141
+ context.runtime,
142
+ callback,
143
+ _vault_password(context),
144
+ args.otp,
145
+ not args.no_remember,
146
+ context,
147
+ )
148
+ _print_signed_in(context.stdout, data)
149
+ return 0
150
+
151
+
152
+ def cmd_login_email(args, context):
153
+ """Sign in with email, vault password, and OTP when required."""
154
+ data = _email_complete(
155
+ context.runtime,
156
+ _email_username(args, context),
157
+ _vault_password(context),
158
+ args.otp,
159
+ not args.no_remember,
160
+ context,
161
+ )
162
+ _print_signed_in(context.stdout, data)
163
+ return 0
164
+
165
+
166
+ def cmd_logout(args, context):
167
+ """Clear the cloud session and local inventory."""
168
+ del args
169
+ logout(context.runtime)
170
+ _write_line(context.stdout, 'Signed out.')
171
+ return 0
172
+
173
+
174
+ def cmd_status(args, context):
175
+ """Print login state. Does not pull."""
176
+ del args
177
+ data = status_payload(context.runtime)
178
+ if not data['logged_in']:
179
+ _write_line(context.stdout, 'Not signed in.')
180
+ return 0
181
+ stale = 'stale' if data['stale'] else 'fresh'
182
+ _write_line(
183
+ context.stdout,
184
+ 'Signed in as {}, {} hosts, cache {}.'.format(
185
+ data['username'] or 'unknown', data['hosts'], stale,
186
+ ),
187
+ )
188
+ return 0
189
+
190
+
191
+ def _login_method(args, context):
192
+ if args.method:
193
+ return args.method
194
+ if args.username:
195
+ return 'email'
196
+ return _prompt_method(context)
197
+
198
+
199
+ def _prompt_method(context):
200
+ if not context.isatty:
201
+ raise CliError('specify google or email')
202
+ raw = context.prompt(
203
+ 'Sign-in method (google/email) [google]: '
204
+ ).strip().lower()
205
+ if not raw:
206
+ return 'google'
207
+ if raw not in ('google', 'email'):
208
+ raise CliError('method must be google or email')
209
+ return raw
210
+
211
+
212
+ def _email_username(args, context):
213
+ if args.username:
214
+ username = args.username.strip()
215
+ if username:
216
+ return username
217
+ return _prompt_username(context)
218
+
219
+
220
+ def _prompt_username(context):
221
+ if not context.isatty:
222
+ raise CliError('username is required for email login (use -u)')
223
+ username = context.prompt('Email: ').strip()
224
+ if not username:
225
+ raise CliError('username is required for email login')
226
+ return username
227
+
228
+
229
+ def _vault_password(context):
230
+ env = os.environ.get(VAULT_ENV)
231
+ if env:
232
+ return env
233
+ password = context.secret('Vault encryption password: ')
234
+ if not password:
235
+ raise CliError('vault encryption password is required')
236
+ return password
237
+
238
+
239
+ def _google_complete(runtime, callback, password, otp, remember, context):
240
+ try:
241
+ return login_google_complete(
242
+ runtime, callback, password,
243
+ otp=otp, remember_password=remember,
244
+ )
245
+ except ValueError as exc:
246
+ if not _is_otp_required(exc) or otp:
247
+ raise
248
+ return login_google_complete(
249
+ runtime, callback, password,
250
+ otp=_require_otp(context), remember_password=remember,
251
+ )
252
+
253
+
254
+ def _email_complete(runtime, username, password, otp, remember, context):
255
+ try:
256
+ return login_email(
257
+ runtime, username, password,
258
+ otp=otp, remember_password=remember,
259
+ )
260
+ except ValueError as exc:
261
+ if not _is_otp_required(exc) or otp:
262
+ raise
263
+ return login_email(
264
+ runtime, username, password,
265
+ otp=_require_otp(context), remember_password=remember,
266
+ )
267
+
268
+
269
+ def _is_otp_required(exc):
270
+ return 'requires otp' in str(exc).lower()
271
+
272
+
273
+ def _require_otp(context):
274
+ code = context.prompt('OTP: ').strip()
275
+ if not code:
276
+ raise CliError('otp is required')
277
+ return code
278
+
279
+
280
+ def _print_google_instructions(stream, url):
281
+ _write_line(stream, 'Open this URL in a browser:')
282
+ _write_line(stream, '')
283
+ _write_line(stream, ' {}'.format(url))
284
+ _write_line(stream, '')
285
+ _write_line(stream, 'Sign in with Google.')
286
+ _write_line(
287
+ stream,
288
+ 'When the page tries to open Termius, copy the '
289
+ 'termius://app/continue-sso?... URL.',
290
+ )
291
+ _write_line(stream, 'Paste that URL below.')
292
+
293
+
294
+ def _print_signed_in(stream, data):
295
+ _write_line(stream, 'Signed in as {}.'.format(data['username']))
296
+
297
+
298
+ def _error_message(exc):
299
+ if isinstance(exc, CliError):
300
+ return str(exc)
301
+ return 'Login failed: {}'.format(exc)
302
+
303
+
304
+ def _fail(stream, message):
305
+ _write_line(stream, message)
306
+ return 1
307
+
308
+
309
+ def _write_line(stream, text):
310
+ stream.write(text)
311
+ if not text.endswith('\n'):
312
+ stream.write('\n')
313
+ stream.flush()
314
+
315
+
316
+ def _stdin_prompt(message):
317
+ return input(message)
@@ -0,0 +1,2 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Termius Cloud sync and crypto."""
@@ -0,0 +1,2 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Package to process calls to sync cloud."""