mazip 0.1.4__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.
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: mazip
3
+ Version: 0.1.4
4
+ Summary: Interactive local network information and verified SSH connections
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: paramiko<6,>=4
8
+ Requires-Dist: psutil<8,>=5.9
9
+ Provides-Extra: test
10
+ Requires-Dist: pytest<10,>=8; extra == "test"
11
+
12
+ # mazip 0.1.4
13
+
14
+ Interactive local network information and verified SSH connections. Requires Python 3.10 or newer. Not published to PyPI.
15
+
16
+ ## Install on Kali / Ubuntu
17
+
18
+ Open a terminal in the extracted MeziLink directory:
19
+
20
+ ```bash
21
+ sudo apt install pipx
22
+ bash install.sh
23
+ ```
24
+
25
+ Open a NEW terminal and run `mazip` from any directory. No environment activation is required. pipx manages dependencies in its own isolated environment; it does not install into the system Python. You may move or remove the extracted project after installation.
26
+
27
+ The installer uses the bundled wheel, exposes the command with pipx, and runs `pipx ensurepath`. It removes only the old `alias mazip=".../.venv/bin/mazip"` format from .zshrc/.bashrc, saving a backup first. Other custom aliases are left unchanged.
28
+
29
+ To install manually:
30
+
31
+ ```bash
32
+ pipx install --force ./dist/mazip-0.1.4-py3-none-any.whl
33
+ pipx ensurepath
34
+ ```
35
+
36
+ Remove any old mazip alias before using the pipx command. Uninstall with `pipx uninstall mazip`.
37
+
38
+ ## Install on Windows
39
+
40
+ ```powershell
41
+ py -m venv .venv
42
+ .venv\Scripts\python.exe -m pip install .
43
+ .venv\Scripts\mazip.exe
44
+ ```
45
+
46
+ ## Use
47
+
48
+ - Option 1 displays local IPv4/IPv6 addresses, local username, SSH client availability, identifiable SSH server bind addresses/ports, and visible TCP listeners/UDP bindings. SSH listeners are identified by process name, not by an assumed port. Limited permissions are reported; firewall reachability and SSH account access are not verified.
49
+ - Option 2 asks for destination OS, IP, port, then TCP-only testing or SSH authentication.
50
+ - After SSH authentication, c opens a persistent interactive terminal; x runs a single noninteractive command. Ctrl+] closes the interactive shell channel and returns to the menu. Ctrl+C is forwarded to interrupt the remote command. Typing exit also returns to the menu. cd and shell variables persist within that shell, but not after closing and reopening it.
51
+ - Option 0 exits or disconnects.
52
+
53
+ ```bash
54
+ mazip --local
55
+ mazip -S -vv
56
+ mazip -S -vv -f example.json
57
+ python -m mezilink
58
+ ```
59
+
60
+ `-S` opens the destination menu. `-v` shows connection stages; `-vv` adds cipher and server metadata. `-f` loads JSON settings without passwords or commands.
61
+
62
+ ## Connection security
63
+
64
+ The destination must already run an SSH service and accept your credentials. No installation, firewall changes or remote scanning are performed.
65
+
66
+ Existing known_hosts entries are checked. For an unknown server, independently compare the displayed SHA256 fingerprint with the destination console or administrator, then enter the full fingerprint. Merely copying the displayed value does not establish identity. This verification applies to the current connection only.
67
+
68
+ A verified `fingerprint` in the JSON settings becomes the independent trust pin and replaces known_hosts verification. Changed or mismatched identities are rejected. Passwords and private-key passphrases use hidden input and are not saved. Automatic SSH-agent and key discovery are disabled. Python does not guarantee secure erasure of secrets from memory.
69
+
70
+ ## Platform and execution limits
71
+
72
+ Windows profiles require OpenSSH Server. Linux requires an SSH service. Android requires an existing SSH environment such as Termux. Selecting a profile does not detect or install a remote OS.
73
+
74
+ Preset commands and option x use separate noninteractive sessions, with 1 MiB output and 30-second waiting limits. Option c requests a remote PTY and preserves shell state, allows interactive input, and has no command duration/output cap. Linux terminal mode supports Ctrl+C, terminal resizing and raw keyboard input. The Windows client keyboard path is implemented but not tested; full-screen rendering depends on the local terminal and remote SSH server. The remote server must permit PTY and shell requests. A real local terminal is required for c. Closing a channel or timing out does not guarantee that the remote process stops. Single-command output has terminal control characters removed; interactive shell output retains terminal sequences for terminal behavior; its language depends on the destination.
75
+
76
+ Local addresses do not reveal a public NAT IP. Port listings may be incomplete due to OS permissions. A UDP binding does not prove remote reachability. When interface enumeration is unavailable, an explicit warning accompanies limited hostname-resolved addresses; interface status is unknown.
77
+
78
+ ## Tests
79
+
80
+ ```bash
81
+ python -m pip install '.[test]'
82
+ python -m pytest -q
83
+ ```
84
+
85
+ See TEST_REPORT.md for the original functional validation and limitations. Version 0.1.2 removes the iOS profile. All application text is English; Python source contains no comments or docstrings. The internal Python module remains `mezilink`; the distribution and executable are `mazip`.
86
+
87
+ Reference: https://docs.paramiko.org/en/stable/api/client.html
@@ -0,0 +1,10 @@
1
+ mezilink/__init__.py,sha256=Wzf5T3NBDfhQoTnhnRNHSlAsE0XMqbclXG-M81Vas70,22
2
+ mezilink/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ mezilink/cli.py,sha256=iL0t3Lh0g8wA2gs09LpCVE3MBjPnFce3bIdVzBcuoys,8502
4
+ mezilink/core.py,sha256=zACOGSg7D-oXbybZd49xVqmyDhup1uuMW0LjZtexOCw,9059
5
+ mezilink/terminal.py,sha256=pgAiE-j8D9XhAchs-LVERQUGeny_hA8ZA4Pvk0y00bo,4135
6
+ mazip-0.1.4.dist-info/METADATA,sha256=FryEac9n_Pk5ELwTcd-fAFBXfuMA-Ju-3RvCd3udWnw,5131
7
+ mazip-0.1.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ mazip-0.1.4.dist-info/entry_points.txt,sha256=RYoW4Ejj_9kElbsFuO1pUAwjqQvmwRzXpFKZJYnqYAA,44
9
+ mazip-0.1.4.dist-info/top_level.txt,sha256=0qQx-y1HDS6w83DFkVJd4rsONojAvtQaqK1ojGVtWWc,9
10
+ mazip-0.1.4.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mazip = mezilink.cli:main
@@ -0,0 +1 @@
1
+ mezilink
mezilink/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.4"
mezilink/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
mezilink/cli.py ADDED
@@ -0,0 +1,190 @@
1
+ import argparse
2
+ import getpass
3
+ import platform
4
+ import re
5
+ import sys
6
+ import warnings
7
+
8
+ import paramiko
9
+ import psutil
10
+
11
+ from . import __version__
12
+ from .terminal import interactive_shell
13
+ from .core import PROFILES, connect, load_config, local_ports, network_info, run_command, tcp_probe, validate_target, ssh_info
14
+
15
+
16
+ def safe(text):
17
+
18
+ return re.sub(r'[\x00-\x08\x0b-\x1f\x7f-\x9f]', '', str(text))
19
+
20
+
21
+ def show_local():
22
+ print('\n=== Local device ===')
23
+ print(safe(platform.platform()))
24
+ with warnings.catch_warnings(record=True) as notices:
25
+ warnings.simplefilter('always', RuntimeWarning)
26
+ rows = network_info()
27
+ for notice in notices:
28
+ print('Note: ' + safe(notice.message))
29
+ for row in rows:
30
+ print(safe(f"{row['interface']} | {'UNKNOWN' if row['up'] is None else ('UP' if row['up'] else 'DOWN')} | {row['family']} | {row['address']}"))
31
+ print('Public NAT IP is not queried. Hostname fallback, when used, is not a complete interface list.')
32
+ info = ssh_info()
33
+ print('\n=== Local SSH ===')
34
+ print(safe(f"Local username: {info['username']} (SSH account access is not verified)"))
35
+ print(safe(f"SSH client: {info['client'] or 'Not found on PATH'}"))
36
+ if info['listeners']:
37
+ print('SSH server listeners identified by process name:')
38
+ for host, port, process in info['listeners']:
39
+ print(safe(f' {process} | Bind address: {host} | SSH port: {port}'))
40
+ print('0.0.0.0 / :: are wildcard binds; use a matching device IP above.')
41
+ print('127.0.0.1 / ::1 are local-only. Firewall and login access are not verified.')
42
+ else:
43
+ print('SSH server: No identifiable SSH listener found. Port: Unknown.')
44
+ if info['limited']:
45
+ print('SSH detection is incomplete: some socket/process information is inaccessible.')
46
+ print('An installed SSH client does not mean an SSH server is running.')
47
+ print('Port 22 is a common default, not a detected active service.')
48
+ print('\nLocal TCP listeners / bound UDP ports (visible to your account):')
49
+ try:
50
+ for host, port, protocol in local_ports():
51
+ print(safe(f' {protocol} [{host}]:{port}'))
52
+ except (psutil.Error, OSError):
53
+ print('OS permission denied port listing; IP information is still available.')
54
+ print('Port list may be incomplete without elevated permissions. No remote scanning.')
55
+
56
+
57
+ def choose(prompt, choices):
58
+ while True:
59
+ answer = input(prompt).strip()
60
+ if answer in choices:
61
+ return answer
62
+ print('Invalid choice.')
63
+
64
+
65
+ def value(config, key, prompt, default=None):
66
+ if key in config:
67
+ return config[key]
68
+ answer = input(prompt).strip()
69
+ return answer or default
70
+
71
+
72
+ def verify_server(host, actual):
73
+ print(safe(f'New SSH server: {host}\nFingerprint: {actual}'))
74
+ print('Verify this fingerprint independently using the destination console or administrator, then enter it in full. Enter = cancel.')
75
+ return input('Verified SHA256 fingerprint: ').strip() == actual
76
+
77
+
78
+ def remote(config, verbose=0):
79
+ print('\n=== Destination OS ===')
80
+ for key, (name, _) in PROFILES.items():
81
+ print(f'{key}. {name}')
82
+ os_id = config.get('os') or choose('OS: ', PROFILES)
83
+ name, commands = PROFILES[os_id]
84
+ print(f'Profile: {name}. Destination must already provide SSH.')
85
+ host = value(config, 'host', 'Destination IPv4 / IPv6: ')
86
+ port = value(config, 'port', 'SSH port [22]: ', '22')
87
+ host, port = validate_target(host, port)
88
+ timeout = config.get('timeout', 8)
89
+ mode = choose('1. SSH login 2. TCP test only 0. Back: ', {'0', '1', '2'})
90
+ if mode == '0':
91
+ return
92
+ if mode == '2':
93
+ tcp_probe(host, port, timeout)
94
+ print('TCP connected. This does not confirm SSH availability or authentication.')
95
+ return
96
+ username = value(config, 'username', 'SSH username: ', '')
97
+ key_file = config.get('key_file')
98
+ auth = '2' if key_file else choose('1. Password 2. Private key: ', {'1', '2'})
99
+ password = passphrase = None
100
+ if auth == '1':
101
+ password = getpass.getpass('SSH password (hidden): ')
102
+ else:
103
+ key_file = key_file or input('Private key path: ').strip()
104
+ if not key_file:
105
+ raise ValueError('Private key path is required.')
106
+ passphrase = getpass.getpass('Key passphrase (Enter if unencrypted): ') or None
107
+ if verbose:
108
+ print('Connecting with host verification; credentials and commands are not logged.')
109
+ client = connect(host, port, username, password=password, key_file=key_file,
110
+ passphrase=passphrase, known_hosts=config.get('known_hosts'),
111
+ expected=config.get('fingerprint'), verify=verify_server, timeout=timeout)
112
+ password = passphrase = None
113
+ try:
114
+ print('SSH authenticated successfully.')
115
+ if verbose > 1:
116
+ transport = client.get_transport()
117
+ print(safe(f'Cipher: {transport.remote_cipher}; server: {transport.remote_version}'))
118
+ while True:
119
+ for index, (label, _) in enumerate(commands, 1):
120
+ print(f'{index}. {label}')
121
+ print('c. Interactive terminal\nx. Single command\n0. Disconnect')
122
+ selection = choose('Action: ', {'0', 'c', 'x'} | {str(i) for i in range(1, len(commands) + 1)})
123
+ if selection == '0':
124
+ break
125
+ if selection == 'c':
126
+ try:
127
+ interactive_shell(client)
128
+ except (OSError, EOFError, RuntimeError, paramiko.SSHException) as exc:
129
+ print('Shell ended: ' + explain(exc))
130
+ transport = client.get_transport()
131
+ if transport is None or not transport.is_active():
132
+ print('SSH connection lost.')
133
+ break
134
+ continue
135
+ command = input('Remote command: ').strip() if selection == 'x' else commands[int(selection) - 1][1]
136
+ if not command:
137
+ continue
138
+ print(safe(f'Running on {name}: {command}'))
139
+ code, stdout, stderr = run_command(client, command)
140
+ print(safe(stdout), end='')
141
+ print(safe(stderr), end='', file=sys.stderr)
142
+ print(f'\nExit status: {code}')
143
+ finally:
144
+ client.close()
145
+ print('Disconnected.')
146
+
147
+
148
+ def explain(exc):
149
+ if isinstance(exc, paramiko.BadHostKeyException):
150
+ return 'Host key mismatch. Server identity changed; connection blocked.'
151
+ if isinstance(exc, paramiko.AuthenticationException):
152
+ return 'Authentication failed. Username, password or key was rejected.'
153
+ if isinstance(exc, ConnectionRefusedError):
154
+ return 'Connection refused. Check the destination service and port.'
155
+ if isinstance(exc, TimeoutError):
156
+ return 'Timed out. A remote command may still be running.'
157
+ return safe(str(exc))
158
+
159
+
160
+ def main(argv=None):
161
+ parser = argparse.ArgumentParser(description='mazip: local network information and verified SSH.')
162
+ parser.add_argument('-S', '--ssh', action='store_true', help='Open destination menu directly')
163
+ parser.add_argument('-v', action='count', default=0, help='Status details; -vv adds connection metadata')
164
+ parser.add_argument('-f', '--config', help='JSON settings file; no passwords or commands')
165
+ parser.add_argument('--local', action='store_true', help='Show local network information and exit')
166
+ parser.add_argument('--version', action='version', version=__version__)
167
+ args = parser.parse_args(argv)
168
+ try:
169
+ config = load_config(args.config) if args.config else {}
170
+ if args.local:
171
+ show_local()
172
+ return 0
173
+ if args.ssh:
174
+ remote(config, args.v)
175
+ return 0
176
+ print('mazip | Network & SSH')
177
+ while True:
178
+ choice = choose('\n1. Local device IPs & ports\n2. Connect to destination\n0. Exit\n> ', {'0', '1', '2'})
179
+ if choice == '0':
180
+ return 0
181
+ try:
182
+ show_local() if choice == '1' else remote(config, args.v)
183
+ except (OSError, ValueError, RuntimeError, paramiko.SSHException, psutil.Error) as exc:
184
+ print('Operation failed: ' + explain(exc), file=sys.stderr)
185
+ except (EOFError, KeyboardInterrupt):
186
+ print('\nCancelled.')
187
+ return 130
188
+ except (OSError, ValueError, RuntimeError, paramiko.SSHException, psutil.Error) as exc:
189
+ print('Operation failed: ' + explain(exc), file=sys.stderr)
190
+ return 1
mezilink/core.py ADDED
@@ -0,0 +1,211 @@
1
+ import base64
2
+ import hashlib
3
+ import ipaddress
4
+ import json
5
+ import math
6
+ import socket
7
+ import time
8
+ import threading
9
+ import warnings
10
+ from pathlib import Path
11
+
12
+ import paramiko
13
+ import psutil
14
+
15
+ PROFILES = {
16
+ '1': ('Windows', [('User', 'whoami'), ('Network', 'ipconfig'), ('System', 'systeminfo')]),
17
+ '2': ('Kali / Ubuntu', [('User', 'id'), ('Network', 'ip address'), ('System', 'uname -a')]),
18
+ '3': ('Red Hat', [('User', 'id'), ('Network', 'ip address'), ('System', 'uname -a')]),
19
+ '4': ('Android', [('User', 'id'), ('System', 'uname -a'), ('Android version', 'getprop ro.build.version.release')]),
20
+ }
21
+
22
+
23
+ def validate_target(host, port):
24
+ host = str(host).strip()
25
+ if host.startswith('[') and host.endswith(']'):
26
+ host = host[1:-1]
27
+ addr = ipaddress.ip_address(host)
28
+ if addr.is_unspecified or addr.is_multicast:
29
+ raise ValueError('Enter a unicast device IP, not unspecified/multicast.')
30
+ if isinstance(port, bool) or not str(port).isdigit() or not 1 <= int(port) <= 65535:
31
+ raise ValueError('Port must be an integer from 1 to 65535.')
32
+ return str(addr), int(port)
33
+
34
+
35
+ def load_config(path):
36
+ data = json.loads(Path(path).read_text(encoding='utf-8'))
37
+ allowed = {'os', 'host', 'port', 'username', 'key_file', 'known_hosts', 'fingerprint', 'timeout'}
38
+ if not isinstance(data, dict) or set(data) - allowed:
39
+ raise ValueError('Invalid config keys. Passwords and commands are not allowed in config.')
40
+ for key, value in data.items():
41
+ if key in {'port', 'timeout'}:
42
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
43
+ raise ValueError(f'{key} must be numeric.')
44
+ elif not isinstance(value, str):
45
+ raise ValueError(f'{key} must be text.')
46
+ if 'os' in data and data['os'] not in PROFILES:
47
+ raise ValueError('OS must be a string from 1 to 4.')
48
+ if 'port' in data:
49
+ validate_target('127.0.0.1', data['port'])
50
+ if 'timeout' in data and (not math.isfinite(data['timeout']) or not 1 <= data['timeout'] <= 120):
51
+ raise ValueError('Timeout must be from 1 to 120 seconds.')
52
+ return data
53
+
54
+
55
+ def network_info():
56
+ try:
57
+ stats = psutil.net_if_stats()
58
+ interfaces = psutil.net_if_addrs()
59
+ except (OSError, psutil.Error):
60
+ warnings.warn('Interface enumeration unavailable; showing hostname-resolved addresses only (may be incomplete).', RuntimeWarning)
61
+ rows = []
62
+ seen = set()
63
+ for hostname in (socket.gethostname(), 'localhost'):
64
+ try:
65
+ results = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
66
+ except OSError:
67
+ continue
68
+ for family, _, _, _, address in results:
69
+ if family not in (socket.AF_INET, socket.AF_INET6) or address[0] in seen:
70
+ continue
71
+ seen.add(address[0])
72
+ rows.append({'interface': 'hostname lookup: ' + hostname, 'up': None,
73
+ 'family': 'IPv4' if family == socket.AF_INET else 'IPv6',
74
+ 'address': address[0], 'netmask': None})
75
+ return rows
76
+ rows = []
77
+ for name, addresses in interfaces.items():
78
+ for address in addresses:
79
+ if address.family in (socket.AF_INET, socket.AF_INET6):
80
+ rows.append({'interface': name, 'up': bool(stats.get(name) and stats[name].isup),
81
+ 'family': 'IPv4' if address.family == socket.AF_INET else 'IPv6',
82
+ 'address': address.address, 'netmask': address.netmask})
83
+ return rows
84
+
85
+
86
+ def local_ports():
87
+ return sorted({(str(c.laddr.ip), c.laddr.port, 'TCP' if c.type == socket.SOCK_STREAM else 'UDP')
88
+ for c in psutil.net_connections(kind='inet')
89
+ if c.laddr and (c.status == psutil.CONN_LISTEN or c.type == socket.SOCK_DGRAM)})
90
+
91
+
92
+ def tcp_probe(host, port, timeout=8):
93
+ host, port = validate_target(host, port)
94
+ with socket.create_connection((host, port), timeout=timeout):
95
+ return True
96
+
97
+
98
+ def fingerprint(key):
99
+ return 'SHA256:' + base64.b64encode(hashlib.sha256(key.asbytes()).digest()).decode().rstrip('=')
100
+
101
+
102
+ class VerifiedKeyPolicy(paramiko.MissingHostKeyPolicy):
103
+ def __init__(self, expected=None, verify=None):
104
+ self.expected = expected
105
+ self.verify = verify
106
+
107
+ def missing_host_key(self, client, hostname, key):
108
+ actual = fingerprint(key)
109
+ if self.expected is not None:
110
+ if self.expected != actual:
111
+ raise paramiko.SSHException('Server fingerprint mismatch; connection blocked.')
112
+ elif self.verify is None or not self.verify(hostname, actual):
113
+ raise paramiko.SSHException('Unknown server identity was not verified.')
114
+
115
+
116
+
117
+ def connect(host, port, username, *, password=None, key_file=None, passphrase=None,
118
+ known_hosts=None, expected=None, verify=None, timeout=8):
119
+ host, port = validate_target(host, port)
120
+ if not username.strip():
121
+ raise ValueError('Username cannot be empty.')
122
+ client = paramiko.SSHClient()
123
+ try:
124
+ if not expected:
125
+ client.load_system_host_keys()
126
+ if known_hosts:
127
+ client.load_host_keys(str(Path(known_hosts).expanduser()))
128
+ client.set_missing_host_key_policy(VerifiedKeyPolicy(expected, verify))
129
+ client.connect(host, port=port, username=username, password=password,
130
+ key_filename=str(Path(key_file).expanduser()) if key_file else None,
131
+ passphrase=passphrase, look_for_keys=False, allow_agent=False,
132
+ timeout=timeout, banner_timeout=timeout, auth_timeout=timeout,
133
+ channel_timeout=timeout)
134
+ if expected and fingerprint(client.get_transport().get_remote_server_key()) != expected:
135
+ raise paramiko.SSHException('Server fingerprint mismatch; connection blocked.')
136
+ client.get_transport().set_keepalive(20)
137
+ return client
138
+ except BaseException:
139
+ client.close()
140
+ raise
141
+
142
+
143
+ def run_command(client, command, timeout=30, output_limit=1024 * 1024):
144
+ transport = client.get_transport()
145
+ if transport is None or not transport.is_active():
146
+ raise ConnectionError('SSH session is disconnected.')
147
+ deadline = time.monotonic() + timeout
148
+ channel = transport.open_session(timeout=timeout)
149
+ out, err = bytearray(), bytearray()
150
+ watchdog = threading.Timer(max(0.01, deadline - time.monotonic()), channel.close)
151
+ watchdog.daemon = True
152
+ watchdog.start()
153
+ try:
154
+ channel.settimeout(timeout)
155
+ channel.exec_command(command)
156
+ channel.shutdown_write()
157
+ while True:
158
+ for ready, receive, target in ((channel.recv_ready, channel.recv, out),
159
+ (channel.recv_stderr_ready, channel.recv_stderr, err)):
160
+ if ready():
161
+ target.extend(receive(32768))
162
+ if len(out) + len(err) > output_limit:
163
+ raise RuntimeError('Output limit exceeded; channel closed.')
164
+ if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready():
165
+ status = channel.recv_exit_status()
166
+ if status == -1:
167
+ raise ConnectionError('Remote channel closed without an exit status.')
168
+ return status, out.decode('utf-8', 'replace'), err.decode('utf-8', 'replace')
169
+ if not transport.is_active():
170
+ raise ConnectionError('SSH connection lost.')
171
+ if time.monotonic() >= deadline:
172
+ raise TimeoutError('Command timed out; remote process may still be running.')
173
+ time.sleep(0.01)
174
+ except Exception as exc:
175
+ if time.monotonic() >= deadline:
176
+ raise TimeoutError('Command timed out; remote process may still be running.') from exc
177
+ raise
178
+ finally:
179
+ watchdog.cancel()
180
+ channel.close()
181
+
182
+
183
+ def ssh_info():
184
+ import getpass
185
+ import shutil
186
+ result = {'username': getpass.getuser(), 'client': shutil.which('ssh'),
187
+ 'listeners': [], 'unidentified': 0, 'limited': False}
188
+ try:
189
+ connections = psutil.net_connections(kind='tcp')
190
+ except (psutil.Error, OSError):
191
+ result['limited'] = True
192
+ return result
193
+ seen = set()
194
+ for item in connections:
195
+ if item.status != psutil.CONN_LISTEN or not item.laddr:
196
+ continue
197
+ try:
198
+ if item.pid is None:
199
+ raise psutil.AccessDenied()
200
+ name = psutil.Process(item.pid).name().lower()
201
+ except (psutil.Error, OSError):
202
+ result['unidentified'] += 1
203
+ result['limited'] = True
204
+ continue
205
+ if name not in {'sshd', 'sshd.exe', 'sshd-session', 'sshd-auth', 'dropbear'}:
206
+ continue
207
+ address = (item.laddr.ip, item.laddr.port, name)
208
+ if address not in seen:
209
+ result['listeners'].append(address)
210
+ seen.add(address)
211
+ return result
mezilink/terminal.py ADDED
@@ -0,0 +1,130 @@
1
+ import os
2
+ import select
3
+ import shutil
4
+ import signal
5
+ import sys
6
+ import threading
7
+ import time
8
+
9
+
10
+ def open_shell(client, timeout=10):
11
+ transport = client.get_transport()
12
+ if transport is None or not transport.is_active():
13
+ raise ConnectionError('SSH session is disconnected.')
14
+ channel = transport.open_session(timeout=timeout)
15
+ expired = threading.Event()
16
+
17
+ def abort():
18
+ expired.set()
19
+ channel.close()
20
+
21
+ timer = threading.Timer(timeout, abort)
22
+ timer.daemon = True
23
+ timer.start()
24
+ try:
25
+ size = shutil.get_terminal_size((80, 24))
26
+ channel.settimeout(timeout)
27
+ channel.get_pty(term=os.environ.get('TERM') or 'xterm', width=size.columns, height=size.lines)
28
+ channel.invoke_shell()
29
+ if expired.is_set():
30
+ raise TimeoutError('Remote shell startup timed out.')
31
+ return channel
32
+ except BaseException:
33
+ channel.close()
34
+ if expired.is_set():
35
+ raise TimeoutError('Remote shell startup timed out.') from None
36
+ raise
37
+ finally:
38
+ timer.cancel()
39
+
40
+
41
+ def write_output(data):
42
+ stream = getattr(sys.stdout, 'buffer', None)
43
+ if stream is not None:
44
+ stream.write(data)
45
+ stream.flush()
46
+ else:
47
+ sys.stdout.write(data.decode('utf-8', 'replace'))
48
+ sys.stdout.flush()
49
+
50
+
51
+ def forward_input(channel, data):
52
+ before, escape, _ = data.partition(b'\x1d')
53
+ if before:
54
+ channel.sendall(before)
55
+ return not bool(escape)
56
+
57
+
58
+ def posix_terminal(channel):
59
+ import termios
60
+ import tty
61
+ fd = sys.stdin.fileno()
62
+ original = termios.tcgetattr(fd)
63
+ previous_handler = signal.getsignal(signal.SIGWINCH)
64
+ resized = threading.Event()
65
+
66
+ def on_resize(signum, frame):
67
+ resized.set()
68
+
69
+ try:
70
+ signal.signal(signal.SIGWINCH, on_resize)
71
+ tty.setraw(fd, termios.TCSANOW)
72
+ while True:
73
+ if resized.is_set():
74
+ resized.clear()
75
+ size = shutil.get_terminal_size((80, 24))
76
+ channel.resize_pty(width=size.columns, height=size.lines)
77
+ readable, _, _ = select.select([channel, fd], [], [], .2)
78
+ if channel in readable:
79
+ data = channel.recv(32768)
80
+ if not data:
81
+ return
82
+ write_output(data)
83
+ if fd in readable:
84
+ data = os.read(fd, 4096)
85
+ if not data or not forward_input(channel, data):
86
+ return
87
+ if channel.closed and not channel.recv_ready():
88
+ return
89
+ finally:
90
+ termios.tcsetattr(fd, termios.TCSADRAIN, original)
91
+ signal.signal(signal.SIGWINCH, previous_handler)
92
+
93
+
94
+ def windows_terminal(channel):
95
+ import msvcrt
96
+ arrows = {'H': b'\x1b[A', 'P': b'\x1b[B', 'M': b'\x1b[C', 'K': b'\x1b[D',
97
+ 'G': b'\x1b[H', 'O': b'\x1b[F', 'S': b'\x1b[3~'}
98
+ while True:
99
+ if channel.recv_ready():
100
+ data = channel.recv(32768)
101
+ if not data:
102
+ return
103
+ write_output(data)
104
+ if channel.closed and not channel.recv_ready():
105
+ return
106
+ try:
107
+ if msvcrt.kbhit():
108
+ char = msvcrt.getwch()
109
+ data = arrows.get(msvcrt.getwch(), b'') if char in ('\x00', '\xe0') else char.encode('utf-8')
110
+ if not forward_input(channel, data):
111
+ return
112
+ except KeyboardInterrupt:
113
+ channel.sendall(b'\x03')
114
+ time.sleep(.01)
115
+
116
+
117
+ def interactive_shell(client):
118
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
119
+ raise RuntimeError('Interactive shell requires a terminal. Use x for a single command.')
120
+ print('\nRemote terminal: Ctrl+] returns to mazip; Ctrl+C interrupts the remote command.')
121
+ print('Type exit to close the remote shell. Terminal output comes directly from the server.', flush=True)
122
+ channel = open_shell(client)
123
+ try:
124
+ if os.name == 'nt':
125
+ windows_terminal(channel)
126
+ else:
127
+ posix_terminal(channel)
128
+ finally:
129
+ channel.close()
130
+ print('\nReturned to mazip menu.', flush=True)