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.
- mazip-0.1.4.dist-info/METADATA +87 -0
- mazip-0.1.4.dist-info/RECORD +10 -0
- mazip-0.1.4.dist-info/WHEEL +5 -0
- mazip-0.1.4.dist-info/entry_points.txt +2 -0
- mazip-0.1.4.dist-info/top_level.txt +1 -0
- mezilink/__init__.py +1 -0
- mezilink/__main__.py +3 -0
- mezilink/cli.py +190 -0
- mezilink/core.py +211 -0
- mezilink/terminal.py +130 -0
|
@@ -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 @@
|
|
|
1
|
+
mezilink
|
mezilink/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.4"
|
mezilink/__main__.py
ADDED
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)
|