ssh-tunnel-cli 0.1.1__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.
- ssh_tunnel_cli-0.1.1.dist-info/METADATA +127 -0
- ssh_tunnel_cli-0.1.1.dist-info/RECORD +10 -0
- ssh_tunnel_cli-0.1.1.dist-info/WHEEL +4 -0
- ssh_tunnel_cli-0.1.1.dist-info/entry_points.txt +2 -0
- ssh_tunnel_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
- tunnel_cli/__init__.py +1 -0
- tunnel_cli/cli.py +263 -0
- tunnel_cli/config.py +105 -0
- tunnel_cli/core.py +402 -0
- tunnel_cli/prompts.py +120 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ssh-tunnel-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Guided local SSH tunnels using your existing OpenSSH configuration
|
|
5
|
+
Project-URL: Repository, https://github.com/maneeshv/ssh-tunnel-cli
|
|
6
|
+
Project-URL: Issues, https://github.com/maneeshv/ssh-tunnel-cli/issues
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Internet
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: prompt-toolkit==3.0.53
|
|
16
|
+
Requires-Dist: questionary==2.1.1
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# tunnel
|
|
20
|
+
|
|
21
|
+
Create and manage local SSH tunnels without remembering forwarding syntax. Pick an alias from your SSH config, answer the prompts, review the exact commands, and choose **Start tunnel**.
|
|
22
|
+
|
|
23
|
+
Python + Questionary, using your Mac's existing OpenSSH client for connections and authentication. No remote agent or installed manager service.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Requires macOS, Python 3.11 or later, [uv](https://docs.astral.sh/uv/), and `/usr/bin/ssh`.
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
uv tool install ssh-tunnel-cli
|
|
31
|
+
tunnel
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Alternatively, use `pipx install ssh-tunnel-cli`, or `python -m pip install ssh-tunnel-cli` inside a virtual environment. The Python package exposes the same `tunnel` command.
|
|
35
|
+
|
|
36
|
+
If your shell cannot find `tunnel`, run `uv tool update-shell` and restart the shell.
|
|
37
|
+
|
|
38
|
+
To run or develop from a checkout:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
git clone https://github.com/maneeshv/ssh-tunnel-cli.git
|
|
42
|
+
cd ssh-tunnel-cli
|
|
43
|
+
uv sync --locked
|
|
44
|
+
uv run tunnel
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
From that checkout, `uv tool install .` installs the command for everyday use.
|
|
48
|
+
|
|
49
|
+
Your config must already contain a literal alias, for example:
|
|
50
|
+
|
|
51
|
+
```sshconfig
|
|
52
|
+
Host other-mac
|
|
53
|
+
HostName your-other-machine
|
|
54
|
+
User your-username
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Configure and verify SSH access yourself first. `tunnel` does not edit SSH config, set up Tailscale, or save credentials. Add hosts to your config manually and reload the picker.
|
|
58
|
+
|
|
59
|
+
## Use
|
|
60
|
+
|
|
61
|
+
- **New tunnel:** machine → remote app port → local port → destination → mode → review.
|
|
62
|
+
- **Background:** returns to the shell; SSH keeps running after that terminal closes.
|
|
63
|
+
- **Foreground:** remains attached; Ctrl+C stops this tunnel.
|
|
64
|
+
- **Manage tunnels:** inspect commands and diagnostics, stop, or review a restart.
|
|
65
|
+
- **Recent tunnels:** reuse successful settings, with a fresh review before connecting.
|
|
66
|
+
- **Esc:** back, retaining answers. **Ctrl+C:** cancel. Arrow keys select; type to filter the host picker.
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
tunnel list
|
|
70
|
+
tunnel stop
|
|
71
|
+
tunnel restart
|
|
72
|
+
tunnel --help
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The app port is different from the SSH server port. SSH's server port comes from your configuration. The destination `127.0.0.1` means loopback **on the selected remote machine**; the local listener is always `127.0.0.1` **on this Mac**. No public/LAN bind option is provided.
|
|
76
|
+
|
|
77
|
+
**Running means SSH is active, not that the remote app is healthy.** Browser opening is optional and only appropriate for web apps. Tailscale is optional existing connectivity, not something this utility configures or guarantees.
|
|
78
|
+
|
|
79
|
+
## Connection ownership
|
|
80
|
+
|
|
81
|
+
Every attempt has its own private control socket. The reviewed launch has two commands: first start an isolated SSH connection with inherited forwards cleared, then add the one selected local forward through that socket. This avoids accidentally launching forwards defined elsewhere in SSH configuration. SSH continues to resolve keys, users, jump hosts, and authentication settings. LocalCommand, agent/X11 forwarding, tunnel devices, and persistent/shared control settings are overridden for this utility.
|
|
82
|
+
|
|
83
|
+
Only tool-owned connections appear in the inventory. Stop never searches by port or kills an arbitrary PID. Closing the menu does not stop a background tunnel. Network failures require a manual restart; no auto-start or reconnect service is installed.
|
|
84
|
+
|
|
85
|
+
Native SSH authentication and host-key prompts appear only after Start. User SSH config is trusted: connection configuration such as ProxyCommand or Match exec can execute its configured commands at connection time. Host discovery itself does not execute those directives.
|
|
86
|
+
|
|
87
|
+
## State and limitations
|
|
88
|
+
|
|
89
|
+
Records, a lock file, SSH control sockets, and diagnostics live in `~/.tunnel/`, with owner-only permissions. Override the location with `--state-dir`; use `--config` for a separate SSH config. A short state path is required by Unix control-socket limits. An unsafe or corrupt state store is reported and preserved, not silently repaired or discarded.
|
|
90
|
+
|
|
91
|
+
Host discovery supports literal ASCII aliases and ordinary nested/globbed Includes. Conditional Includes and paths containing SSH tokens or environment expansions are skipped with a warning that the list may be incomplete. Wildcard/negated Host patterns are not selectable machines. Relative user Includes resolve under `~/.ssh`, including when `--config` points elsewhere, matching OpenSSH.
|
|
92
|
+
|
|
93
|
+
Diagnostics contain SSH errors, not remote application logs or typed authentication input. Each log is trimmed to its last 64 KiB when viewed. Without a background manager, logs can grow between views. Old records and diagnostics remain in the private state directory; no automatic history deletion is performed.
|
|
94
|
+
|
|
95
|
+
Foreground/background lifecycle is macOS-first. Other operating systems and every OpenSSH configuration combination are not promised.
|
|
96
|
+
|
|
97
|
+
## Verify
|
|
98
|
+
|
|
99
|
+
Unit and prompt tests (no real hosts):
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
uv run python -m unittest discover -s tests -v
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Opt-in integration tests start an isolated loopback `sshd`, generate disposable keys, and exercise actual forwarding. They do not read your SSH keys or configuration, change system SSH settings, or contact remote machines. They require permission to bind/connect loopback ports, `/usr/sbin/sshd`, `/usr/bin/ssh-keygen`, and `trash` for fixture cleanup.
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
TUNNEL_SSH_INTEGRATION=1 uv run python -m unittest tests.test_ssh_integration -v
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
See [the specification](https://github.com/maneeshv/ssh-tunnel-cli/blob/main/SPEC.md) for requirements, decisions, and the [original Warp mockup](https://github.com/maneeshv/ssh-tunnel-cli/blob/main/design/tunnel-mockup.png). The mockup is a design reference, not a screenshot of the implemented terminal UI.
|
|
112
|
+
|
|
113
|
+
Verified on 2026-11-04 with Python 3.11.11 and macOS OpenSSH 10.3p1: all 73 tests passed, including 12 real-SSH integration tests. The wheel and source archive build successfully, and the wheel's CLI runs independently of the source checkout. Menus were inspected in a 96-column terminal with color disabled. Manual Warp rendering and password/passphrase authentication were not checked; generated-key authentication and native host-key confirmation were exercised through a PTY.
|
|
114
|
+
|
|
115
|
+
## TypeScript comparison
|
|
116
|
+
|
|
117
|
+
A separate [TypeScript alternative](https://github.com/maneeshv/ssh-tunnel-cli/tree/main/typescript-version) is available for comparison. Run `npm ci && npm run build && npm start` from `typescript-version/`. It uses the `tunnel-ts` command and its own state directory; npm publication is pending evaluation.
|
|
118
|
+
|
|
119
|
+
## Contributing
|
|
120
|
+
|
|
121
|
+
Small fixes and improvements are welcome. Open an issue for substantial behavior changes before implementing them. Use absolute imports, keep dependencies minimal, and run the checks above before submitting a pull request. Include a regression test for behavior changes. CI runs unit/prompt tests and package smoke checks on macOS; real-SSH tests remain opt-in.
|
|
122
|
+
|
|
123
|
+
When reporting a bug, include the macOS, Python, and OpenSSH versions and sanitized reproduction steps. Remove hostnames, usernames, addresses, credentials, and private configuration from pasted diagnostics.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
[MIT](https://github.com/maneeshv/ssh-tunnel-cli/blob/main/LICENSE). The mockup is an AI-generated design reference; the project is not affiliated with Warp.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
tunnel_cli/__init__.py,sha256=NBntThTKm44yTq_eM_WoRJPUb4INAxZPsOnz0i6dSEc,60
|
|
2
|
+
tunnel_cli/cli.py,sha256=IrqWezGYlRyh7hQKkDvLSJw5ole4j7wk0c_DYH9y2o4,12953
|
|
3
|
+
tunnel_cli/config.py,sha256=Ie9xf47vkqB6VWhBpTXxnqsGe2IJ5JJuUkZWyP2xpWA,4763
|
|
4
|
+
tunnel_cli/core.py,sha256=jCjfsYkaurVLD4tlBL0R-kY9TEXGRIdyZzaIBa3n8qo,19003
|
|
5
|
+
tunnel_cli/prompts.py,sha256=py_hWnyFTK5YVOojHAov-YFccn4wtsb0TeYjsklw2H0,4443
|
|
6
|
+
ssh_tunnel_cli-0.1.1.dist-info/METADATA,sha256=SN7RTqacPQdrEep-t_cPH0xNALGzHzOkPLkQiIupRJ4,7817
|
|
7
|
+
ssh_tunnel_cli-0.1.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
8
|
+
ssh_tunnel_cli-0.1.1.dist-info/entry_points.txt,sha256=9MTJNX4HXvKedLmEekeLC3-QOv2pLiX7N6pIeDSQv0s,47
|
|
9
|
+
ssh_tunnel_cli-0.1.1.dist-info/licenses/LICENSE,sha256=UeK6aVwkxvwo_r9FUsupT345hKjgzrtP3DXbBQSAB0g,1064
|
|
10
|
+
ssh_tunnel_cli-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maneesh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tunnel_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Interactive local forwards managed by system OpenSSH."""
|
tunnel_cli/cli.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Guided setup and management, with no connection before a reviewed Start."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shlex
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import textwrap
|
|
9
|
+
import webbrowser
|
|
10
|
+
|
|
11
|
+
from questionary import Choice
|
|
12
|
+
|
|
13
|
+
from tunnel_cli.config import discover_hosts
|
|
14
|
+
from tunnel_cli.core import Manager, Settings, TunnelError, available_port, validate_destination, validate_port
|
|
15
|
+
from tunnel_cli.prompts import Back, choose, say, text
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def label(record: dict) -> str:
|
|
19
|
+
s = record["settings"]
|
|
20
|
+
return f"{s['alias']} 127.0.0.1:{s['local_port']} → {s['destination']}:{s['remote_port']} {record['status']}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def show_commands(record: dict):
|
|
24
|
+
say("\nExact commands — private SSH connection, then the selected forward:")
|
|
25
|
+
width = max(40, shutil.get_terminal_size().columns - 4)
|
|
26
|
+
for index, command in enumerate(record["commands"], 1):
|
|
27
|
+
say(textwrap.fill(shlex.join(command), width=width,
|
|
28
|
+
initial_indent=f" {index}. ", subsequent_indent=" ",
|
|
29
|
+
break_long_words=False, break_on_hyphens=False))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def show_details(record: dict):
|
|
33
|
+
say(f"\n{label(record)}\nID: {record['id']}\nMode: {record['settings']['mode']}\nCreated: {record['created_at']}")
|
|
34
|
+
if record.get("last_error"):
|
|
35
|
+
say(f"Last diagnostic: {record['last_error']}")
|
|
36
|
+
say("Running describes the SSH tunnel; application health is not checked.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _host(manager: Manager, default: str | None):
|
|
40
|
+
while True:
|
|
41
|
+
discovery = discover_hosts(manager.config_path)
|
|
42
|
+
for warning in discovery.warnings:
|
|
43
|
+
say(f"Warning: {warning}")
|
|
44
|
+
if discovery.aliases:
|
|
45
|
+
return choose("Choose a machine", discovery.aliases,
|
|
46
|
+
default=default if default in discovery.aliases else None, search=True)
|
|
47
|
+
say("No literal SSH aliases found. Add a Host alias to your SSH config manually, then reload.")
|
|
48
|
+
if choose("Next", ["Reload hosts", "Back"]) == "Back":
|
|
49
|
+
raise Back
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def same_route(first: dict, second: dict) -> bool:
|
|
53
|
+
return all(first[key] == second[key] for key in ("alias", "local_port", "remote_port", "destination"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _started(record: dict):
|
|
57
|
+
settings = record["settings"]
|
|
58
|
+
say(f"\nTunnel running in {settings['mode']}: 127.0.0.1:{settings['local_port']}\nID: {record['id']}")
|
|
59
|
+
say("Tunnel active; application health not checked.")
|
|
60
|
+
if settings["mode"] == "foreground":
|
|
61
|
+
say("Ctrl+C stops this tunnel.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_wizard(manager: Manager, initial: dict | None = None, restart: dict | None = None) -> bool:
|
|
65
|
+
answers = dict(initial or {})
|
|
66
|
+
step = 5 if initial else 0
|
|
67
|
+
editing = False
|
|
68
|
+
fields = ["Machine", "Remote port", "Local port", "Destination", "Run mode"]
|
|
69
|
+
while True:
|
|
70
|
+
try:
|
|
71
|
+
if step == 0:
|
|
72
|
+
answers["alias"] = _host(manager, answers.get("alias"))
|
|
73
|
+
elif step == 1:
|
|
74
|
+
say("Remote application port — the SSH server port comes from SSH config.")
|
|
75
|
+
answers["remote_port"] = validate_port(text("Remote port", str(answers.get("remote_port", "")),
|
|
76
|
+
validate=validate_port))
|
|
77
|
+
elif step == 2:
|
|
78
|
+
def local_port(value):
|
|
79
|
+
port = validate_port(value)
|
|
80
|
+
owned_port = restart and restart["status"] == "Running" and restart["settings"]["local_port"] == port
|
|
81
|
+
if not owned_port and not available_port(port):
|
|
82
|
+
raise ValueError(f"Port {port} is unavailable. Enter another local port.")
|
|
83
|
+
return port
|
|
84
|
+
answers["local_port"] = validate_port(text("Local port", str(answers.get("local_port", answers["remote_port"])),
|
|
85
|
+
validate=local_port))
|
|
86
|
+
elif step == 3:
|
|
87
|
+
say("127.0.0.1 means the selected remote machine. Advanced: another host/IP reachable from it.")
|
|
88
|
+
answers["destination"] = validate_destination(text("Destination", answers.get("destination", "127.0.0.1"),
|
|
89
|
+
validate=validate_destination))
|
|
90
|
+
elif step == 4:
|
|
91
|
+
say("Background returns to your shell. Foreground stays attached until Ctrl+C.")
|
|
92
|
+
answers["mode"] = choose("How should it run?", [Choice("Background", "background"), Choice("Foreground", "foreground")],
|
|
93
|
+
default=answers.get("mode", "background"))
|
|
94
|
+
else:
|
|
95
|
+
record = manager.prepare(Settings(**answers))
|
|
96
|
+
say("\nReady to start")
|
|
97
|
+
say(f" Machine {answers['alias']}\n Local 127.0.0.1:{answers['local_port']}"
|
|
98
|
+
f"\n Destination {answers['destination']}:{answers['remote_port']} on {answers['alias']}"
|
|
99
|
+
f"\n Mode {answers['mode'].capitalize()}")
|
|
100
|
+
if restart:
|
|
101
|
+
say(f"Restart will stop tunnel {restart['id']} before starting this route. Existing connections will be interrupted.")
|
|
102
|
+
show_commands(record)
|
|
103
|
+
action = choose("Review", ["Start tunnel", "Edit settings", "Cancel"])
|
|
104
|
+
if action == "Cancel":
|
|
105
|
+
return False
|
|
106
|
+
if action == "Edit settings":
|
|
107
|
+
editing = True
|
|
108
|
+
step = choose("Edit which setting?", [Choice(name, index) for index, name in enumerate(fields)])
|
|
109
|
+
continue
|
|
110
|
+
current = manager.records()
|
|
111
|
+
for existing in current:
|
|
112
|
+
if existing["status"] == "Running" and same_route(existing["settings"], answers) and (not restart or existing["id"] != restart["id"]):
|
|
113
|
+
say("That route is already running. Opening its management actions.")
|
|
114
|
+
return manage(manager, existing["id"])
|
|
115
|
+
if restart:
|
|
116
|
+
if answers["alias"] not in discover_hosts(manager.config_path).aliases:
|
|
117
|
+
raise TunnelError("Selected alias is no longer discoverable. Edit Machine before restarting; existing tunnel was left running.")
|
|
118
|
+
original = next((item for item in current if item["id"] == restart["id"]), None)
|
|
119
|
+
owns_port = original and original["status"] == "Running" and original["settings"]["local_port"] == answers["local_port"]
|
|
120
|
+
if not owns_port and not available_port(answers["local_port"]):
|
|
121
|
+
raise TunnelError("Replacement port is unavailable. Edit Local port; existing tunnel was left running.")
|
|
122
|
+
manager.stop(restart["id"])
|
|
123
|
+
restart = None
|
|
124
|
+
say(f"Connecting to {answers['alias']}… SSH may request authentication.")
|
|
125
|
+
result = manager.start(record, on_started=_started)
|
|
126
|
+
if answers["mode"] == "foreground":
|
|
127
|
+
say(f"Tunnel {result['status'].lower()}.")
|
|
128
|
+
else:
|
|
129
|
+
try:
|
|
130
|
+
action = choose("Next", ["Return to shell", "Open as HTTP website", "Open as HTTPS website"])
|
|
131
|
+
if action != "Return to shell":
|
|
132
|
+
scheme = "https" if action == "Open as HTTPS website" else "http"
|
|
133
|
+
if not webbrowser.open(f"{scheme}://127.0.0.1:{answers['local_port']}"):
|
|
134
|
+
say("Could not open the browser. Open the local address manually.")
|
|
135
|
+
except (Back, KeyboardInterrupt):
|
|
136
|
+
say("Tunnel remains running in the background.")
|
|
137
|
+
return True
|
|
138
|
+
step = 5 if editing else step + 1
|
|
139
|
+
editing = False
|
|
140
|
+
except Back:
|
|
141
|
+
if editing:
|
|
142
|
+
step, editing = 5, False
|
|
143
|
+
elif step == 0:
|
|
144
|
+
return False
|
|
145
|
+
else:
|
|
146
|
+
step -= 1
|
|
147
|
+
except (TunnelError, OSError, ValueError) as exc:
|
|
148
|
+
say(f"Could not start tunnel: {exc}")
|
|
149
|
+
# Review is rebuilt for every attempt, so retry cannot reuse a reserved ID.
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def manage(manager: Manager, tunnel_id: str | None = None, action: str | None = None) -> bool:
|
|
153
|
+
while True:
|
|
154
|
+
try:
|
|
155
|
+
records = manager.records()
|
|
156
|
+
if not records:
|
|
157
|
+
say("No managed tunnels yet.")
|
|
158
|
+
return False
|
|
159
|
+
if tunnel_id is None:
|
|
160
|
+
tunnel_id = choose("Manage tunnels", [Choice(label(item), item["id"]) for item in records] + [Choice("Back", "back")])
|
|
161
|
+
if tunnel_id == "back":
|
|
162
|
+
return False
|
|
163
|
+
record = next((item for item in records if item["id"] == tunnel_id), None)
|
|
164
|
+
if record is None:
|
|
165
|
+
raise TunnelError("Tunnel not found. Use tunnel list for managed IDs.")
|
|
166
|
+
selected = action or choose(label(record), ["Details", "Show command", "View logs", "Stop tunnel", "Restart tunnel", "Back"])
|
|
167
|
+
if selected == "Details":
|
|
168
|
+
show_details(record)
|
|
169
|
+
elif selected == "Show command":
|
|
170
|
+
show_commands(record)
|
|
171
|
+
elif selected == "View logs":
|
|
172
|
+
say(manager.logs(tunnel_id))
|
|
173
|
+
elif selected in ("Stop tunnel", "stop"):
|
|
174
|
+
if choose(f"Stop {record['settings']['alias']} on local port {record['settings']['local_port']}?", ["Cancel", "Stop tunnel"]) == "Stop tunnel":
|
|
175
|
+
manager.stop(tunnel_id)
|
|
176
|
+
say("Tunnel stopped.")
|
|
177
|
+
elif selected in ("Restart tunnel", "restart"):
|
|
178
|
+
return run_wizard(manager, record["settings"], restart=record)
|
|
179
|
+
else:
|
|
180
|
+
return False
|
|
181
|
+
if action:
|
|
182
|
+
return False
|
|
183
|
+
except Back:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def recent(manager: Manager) -> bool:
|
|
188
|
+
records = [item for item in reversed(manager.records()) if item["successful"]]
|
|
189
|
+
unique = {}
|
|
190
|
+
for item in records:
|
|
191
|
+
unique.setdefault(tuple(sorted(item["settings"].items())), item)
|
|
192
|
+
if not unique:
|
|
193
|
+
say("No successful tunnels to reuse yet.")
|
|
194
|
+
return False
|
|
195
|
+
try:
|
|
196
|
+
item = choose("Recent tunnels", [Choice(label(record), record) for record in unique.values()])
|
|
197
|
+
if item["status"] == "Running":
|
|
198
|
+
return manage(manager, item["id"])
|
|
199
|
+
return run_wizard(manager, item["settings"])
|
|
200
|
+
except Back:
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def home(manager: Manager):
|
|
205
|
+
while True:
|
|
206
|
+
try:
|
|
207
|
+
count = sum(item["status"] == "Running" for item in manager.records())
|
|
208
|
+
choice = choose("What would you like to do?", ["New tunnel", f"Manage tunnels · {count} running", "Recent tunnels", "Exit"])
|
|
209
|
+
if choice == "Exit":
|
|
210
|
+
return
|
|
211
|
+
if choice == "New tunnel":
|
|
212
|
+
finished = run_wizard(manager)
|
|
213
|
+
elif choice == "Recent tunnels":
|
|
214
|
+
finished = recent(manager)
|
|
215
|
+
else:
|
|
216
|
+
finished = manage(manager)
|
|
217
|
+
if finished:
|
|
218
|
+
return
|
|
219
|
+
except Back:
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def main(argv: list[str] | None = None) -> int:
|
|
224
|
+
parser = argparse.ArgumentParser(description="Guided local SSH tunnels using existing config aliases.")
|
|
225
|
+
parser.add_argument("--config", type=Path, help="SSH config to use (default: ~/.ssh/config)")
|
|
226
|
+
parser.add_argument("--state-dir", type=Path, help="Private, short state directory (default: ~/.tunnel)")
|
|
227
|
+
parser.add_argument("--version", action="version", version="tunnel 0.1.1")
|
|
228
|
+
commands = parser.add_subparsers(dest="command")
|
|
229
|
+
commands.add_parser("list", help="Print owned tunnel status without prompting")
|
|
230
|
+
for name in ("stop", "restart"):
|
|
231
|
+
command = commands.add_parser(name, help=f"Interactively {name} a managed tunnel")
|
|
232
|
+
command.add_argument("id", nargs="?", help="Tunnel ID; omit to select interactively")
|
|
233
|
+
args = parser.parse_args(argv)
|
|
234
|
+
if args.command != "list" and (not sys.stdin.isatty() or not sys.stdout.isatty()):
|
|
235
|
+
say("Interactive commands need a terminal. Use tunnel list for noninteractive status.")
|
|
236
|
+
return 2
|
|
237
|
+
try:
|
|
238
|
+
manager = Manager(args.config, args.state_dir)
|
|
239
|
+
if args.command == "list":
|
|
240
|
+
records = manager.records()
|
|
241
|
+
if not records:
|
|
242
|
+
say("No managed tunnels.")
|
|
243
|
+
else:
|
|
244
|
+
say("ID MACHINE / LOCAL → DESTINATION / STATUS")
|
|
245
|
+
for record in records:
|
|
246
|
+
say(f"{record['id']} {label(record)}")
|
|
247
|
+
elif args.command:
|
|
248
|
+
manage(manager, args.id, args.command)
|
|
249
|
+
else:
|
|
250
|
+
home(manager)
|
|
251
|
+
return 0
|
|
252
|
+
except KeyboardInterrupt:
|
|
253
|
+
say("\nCancelled.")
|
|
254
|
+
return 130
|
|
255
|
+
except Back:
|
|
256
|
+
return 0
|
|
257
|
+
except (TunnelError, OSError, ValueError) as exc:
|
|
258
|
+
say(f"Error: {exc}")
|
|
259
|
+
return 1
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
raise SystemExit(main())
|
tunnel_cli/config.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Discover explicit SSH aliases without evaluating SSH conditions or commands."""
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Discovery:
|
|
12
|
+
aliases: list[str] = field(default_factory=list)
|
|
13
|
+
warnings: list[str] = field(default_factory=list)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_alias(value: str) -> bool:
|
|
17
|
+
"""Accept literal aliases that cannot be interpreted as SSH options."""
|
|
18
|
+
return re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]*", value) is not None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def discover_hosts(config_path: Path | None = None) -> Discovery:
|
|
22
|
+
"""Read aliases, conservatively skipping conditional and dynamic Includes.
|
|
23
|
+
|
|
24
|
+
Relative user Includes use ~/.ssh, including with a custom config path.
|
|
25
|
+
Included Host/Match directives affect the following parent-file lines.
|
|
26
|
+
This is a discovery reader, not an evaluator of OpenSSH configuration.
|
|
27
|
+
"""
|
|
28
|
+
config_path = config_path or Path.home() / ".ssh" / "config"
|
|
29
|
+
base = Path.home() / ".ssh"
|
|
30
|
+
result = Discovery()
|
|
31
|
+
seen: set[str] = set()
|
|
32
|
+
active: set[Path] = set()
|
|
33
|
+
|
|
34
|
+
def read(path: Path, unconditional: bool, depth: int) -> bool:
|
|
35
|
+
try:
|
|
36
|
+
path = path.resolve()
|
|
37
|
+
if path in active:
|
|
38
|
+
result.warnings.append(f"{path}: Include cycle skipped; host list may be incomplete.")
|
|
39
|
+
return unconditional
|
|
40
|
+
if depth > 16:
|
|
41
|
+
result.warnings.append(f"{path}: Include depth limit reached; host list may be incomplete.")
|
|
42
|
+
return unconditional
|
|
43
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
44
|
+
except (OSError, UnicodeError, RuntimeError) as exc:
|
|
45
|
+
result.warnings.append(f"{path}: Cannot read SSH config ({exc}); host list may be incomplete.")
|
|
46
|
+
return unconditional
|
|
47
|
+
|
|
48
|
+
active.add(path)
|
|
49
|
+
try:
|
|
50
|
+
for number, line in enumerate(lines, 1):
|
|
51
|
+
match = re.match(r"\s*([^\s=#]+)(?:\s*=\s*|\s+)?(.*)", line)
|
|
52
|
+
if not match:
|
|
53
|
+
continue
|
|
54
|
+
keyword, arguments = match.groups()
|
|
55
|
+
keyword = keyword.lower()
|
|
56
|
+
if keyword not in {"host", "match", "include"}:
|
|
57
|
+
continue
|
|
58
|
+
location = f"{path}:{number}"
|
|
59
|
+
try:
|
|
60
|
+
values = shlex.split(arguments, comments=True)
|
|
61
|
+
if not values:
|
|
62
|
+
raise ValueError("missing argument")
|
|
63
|
+
except ValueError as exc:
|
|
64
|
+
result.warnings.append(f"{location}: Invalid {keyword} ({exc}); host list may be incomplete.")
|
|
65
|
+
if keyword in {"host", "match"}:
|
|
66
|
+
unconditional = False
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
if keyword == "host":
|
|
70
|
+
unconditional = values == ["*"]
|
|
71
|
+
for alias in values:
|
|
72
|
+
if any(character in alias for character in "*?!"):
|
|
73
|
+
continue
|
|
74
|
+
if not is_alias(alias):
|
|
75
|
+
result.warnings.append(f"{location}: Unsupported alias {alias!r} skipped.")
|
|
76
|
+
elif alias not in seen:
|
|
77
|
+
seen.add(alias)
|
|
78
|
+
result.aliases.append(alias)
|
|
79
|
+
elif keyword == "match":
|
|
80
|
+
unconditional = len(values) == 1 and values[0].lower() == "all"
|
|
81
|
+
elif not unconditional:
|
|
82
|
+
result.warnings.append(f"{location}: Conditional Include skipped; host list may be incomplete.")
|
|
83
|
+
else:
|
|
84
|
+
for value in values:
|
|
85
|
+
if "$" in value or "%" in value:
|
|
86
|
+
result.warnings.append(f"{location}: Dynamic Include {value!r} skipped; host list may be incomplete.")
|
|
87
|
+
continue
|
|
88
|
+
try:
|
|
89
|
+
pattern = Path(value).expanduser()
|
|
90
|
+
except (KeyError, RuntimeError) as exc:
|
|
91
|
+
result.warnings.append(f"{location}: Cannot expand Include {value!r} ({exc}).")
|
|
92
|
+
continue
|
|
93
|
+
if not pattern.is_absolute():
|
|
94
|
+
pattern = base / pattern
|
|
95
|
+
paths = sorted(glob.glob(str(pattern)))
|
|
96
|
+
if not paths:
|
|
97
|
+
result.warnings.append(f"{location}: Include {value!r} matched no files; host list may be incomplete.")
|
|
98
|
+
for included in paths:
|
|
99
|
+
unconditional = read(Path(included), unconditional, depth + 1)
|
|
100
|
+
finally:
|
|
101
|
+
active.remove(path)
|
|
102
|
+
return unconditional
|
|
103
|
+
|
|
104
|
+
read(config_path.expanduser(), True, 0)
|
|
105
|
+
return result
|
tunnel_cli/core.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Native SSH lifecycle and private, durable ownership records."""
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import errno
|
|
7
|
+
import fcntl
|
|
8
|
+
import ipaddress
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import re
|
|
13
|
+
import signal
|
|
14
|
+
import socket
|
|
15
|
+
import stat
|
|
16
|
+
import subprocess
|
|
17
|
+
import time
|
|
18
|
+
from typing import Callable
|
|
19
|
+
import uuid
|
|
20
|
+
|
|
21
|
+
from tunnel_cli.config import discover_hosts, is_alias
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TunnelError(Exception):
|
|
25
|
+
"""An actionable failure, suitable for displaying without a traceback."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_port(value: str | int) -> int:
|
|
29
|
+
if isinstance(value, bool) or not re.fullmatch(r"[0-9]{1,5}", str(value)):
|
|
30
|
+
raise ValueError("Enter a decimal port from 1 to 65535.")
|
|
31
|
+
port = int(value)
|
|
32
|
+
if not 1 <= port <= 65535:
|
|
33
|
+
raise ValueError("Enter a decimal port from 1 to 65535.")
|
|
34
|
+
return port
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_destination(value: str) -> str:
|
|
38
|
+
if not isinstance(value, str):
|
|
39
|
+
raise ValueError("Destination must be a hostname or IP address string.")
|
|
40
|
+
try:
|
|
41
|
+
return str(ipaddress.ip_address(value))
|
|
42
|
+
except ValueError:
|
|
43
|
+
if len(value) > 253 or not all(
|
|
44
|
+
re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", part)
|
|
45
|
+
for part in value.rstrip(".").split(".")
|
|
46
|
+
):
|
|
47
|
+
raise ValueError("Enter a hostname or IP address, without a port or URL.") from None
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def available_port(port: int) -> bool:
|
|
52
|
+
with socket.socket() as listener:
|
|
53
|
+
try:
|
|
54
|
+
listener.bind(("127.0.0.1", validate_port(port)))
|
|
55
|
+
except OSError:
|
|
56
|
+
return False
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Settings:
|
|
62
|
+
alias: str
|
|
63
|
+
remote_port: int
|
|
64
|
+
local_port: int
|
|
65
|
+
destination: str = "127.0.0.1"
|
|
66
|
+
mode: str = "background"
|
|
67
|
+
|
|
68
|
+
def __post_init__(self):
|
|
69
|
+
if not isinstance(self.alias, str) or not is_alias(self.alias):
|
|
70
|
+
raise ValueError("Choose a literal SSH-config alias.")
|
|
71
|
+
validate_port(self.remote_port)
|
|
72
|
+
validate_port(self.local_port)
|
|
73
|
+
if type(self.remote_port) is not int or type(self.local_port) is not int:
|
|
74
|
+
raise ValueError("Stored ports must be integers.")
|
|
75
|
+
validate_destination(self.destination)
|
|
76
|
+
if self.mode not in ("background", "foreground"):
|
|
77
|
+
raise ValueError("Run mode must be background or foreground.")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def now() -> str:
|
|
81
|
+
return datetime.now(timezone.utc).isoformat()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _interrupt(*_):
|
|
85
|
+
raise KeyboardInterrupt
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Manager:
|
|
89
|
+
def __init__(self, config_path: Path | None = None, state_dir: Path | None = None):
|
|
90
|
+
self.config_path = Path(config_path).expanduser().absolute() if config_path else None
|
|
91
|
+
self.state_dir = Path(state_dir or Path.home() / ".tunnel").expanduser().absolute()
|
|
92
|
+
self.ssh = "/usr/bin/ssh"
|
|
93
|
+
if not Path(self.ssh).is_file():
|
|
94
|
+
raise TunnelError("System OpenSSH was not found at /usr/bin/ssh.")
|
|
95
|
+
self.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
96
|
+
info = self.state_dir.lstat()
|
|
97
|
+
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
|
98
|
+
raise TunnelError(f"State directory must be owned by you, private (0700), and not a symlink: {self.state_dir}")
|
|
99
|
+
if len(os.fsencode(self.state_dir / ('0' * 12 + '.sock'))) >= 100:
|
|
100
|
+
raise TunnelError("State path is too long for SSH control sockets. Choose a shorter --state-dir.")
|
|
101
|
+
|
|
102
|
+
def _path(self, tunnel_id: str, suffix: str) -> Path:
|
|
103
|
+
if not re.fullmatch(r"[0-9a-f]{12}", tunnel_id):
|
|
104
|
+
raise TunnelError("Invalid tunnel ID.")
|
|
105
|
+
return self.state_dir / f"{tunnel_id}.{suffix}"
|
|
106
|
+
|
|
107
|
+
def _open(self, path: Path, flags: int):
|
|
108
|
+
fd = os.open(path, flags | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
|
|
109
|
+
info = os.fstat(fd)
|
|
110
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_nlink != 1:
|
|
111
|
+
os.close(fd)
|
|
112
|
+
raise TunnelError(f"Unsafe state file (expected a private regular file): {path}")
|
|
113
|
+
return fd
|
|
114
|
+
|
|
115
|
+
@contextmanager
|
|
116
|
+
def _locked(self):
|
|
117
|
+
fd = self._open(self.state_dir / "lock", os.O_RDWR | os.O_CREAT)
|
|
118
|
+
try:
|
|
119
|
+
try:
|
|
120
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
121
|
+
except BlockingIOError:
|
|
122
|
+
raise TunnelError("Another tunnel command is changing state. Try again when it finishes.") from None
|
|
123
|
+
yield
|
|
124
|
+
finally:
|
|
125
|
+
os.close(fd)
|
|
126
|
+
|
|
127
|
+
def _read(self) -> list[dict]:
|
|
128
|
+
try:
|
|
129
|
+
fd = self._open(self.state_dir / "registry.json", os.O_RDONLY)
|
|
130
|
+
except FileNotFoundError:
|
|
131
|
+
return []
|
|
132
|
+
try:
|
|
133
|
+
with os.fdopen(fd) as file:
|
|
134
|
+
data = json.load(file)
|
|
135
|
+
if data["version"] != 1 or not isinstance(data["tunnels"], list):
|
|
136
|
+
raise ValueError("unsupported schema")
|
|
137
|
+
ids = set()
|
|
138
|
+
for record in data["tunnels"]:
|
|
139
|
+
self._path(record["id"], "sock")
|
|
140
|
+
if record["id"] in ids:
|
|
141
|
+
raise ValueError("duplicate tunnel ID")
|
|
142
|
+
ids.add(record["id"])
|
|
143
|
+
Settings(**record["settings"])
|
|
144
|
+
if not isinstance(record["created_at"], str):
|
|
145
|
+
raise ValueError("invalid creation time")
|
|
146
|
+
if record.get("last_error") is not None and not isinstance(record["last_error"], str):
|
|
147
|
+
raise ValueError("invalid diagnostic")
|
|
148
|
+
if record["status"] not in ("Connecting", "Running", "Stopped", "Disconnected", "Failed", "Unknown"):
|
|
149
|
+
raise ValueError("unknown status")
|
|
150
|
+
if not isinstance(record["successful"], bool) or not isinstance(record["commands"], list):
|
|
151
|
+
raise ValueError("invalid record")
|
|
152
|
+
if not record["commands"] or any(
|
|
153
|
+
not isinstance(command, list) or not command or not all(isinstance(arg, str) for arg in command)
|
|
154
|
+
for command in record["commands"]
|
|
155
|
+
):
|
|
156
|
+
raise ValueError("invalid saved command")
|
|
157
|
+
return data["tunnels"]
|
|
158
|
+
except (ValueError, TypeError, KeyError, TunnelError) as exc:
|
|
159
|
+
raise TunnelError(f"Cannot read registry; preserved it unchanged: {exc}") from exc
|
|
160
|
+
|
|
161
|
+
def _save(self, records: list[dict]):
|
|
162
|
+
pending = self.state_dir / "registry.pending"
|
|
163
|
+
fd = self._open(pending, os.O_WRONLY | os.O_CREAT)
|
|
164
|
+
with os.fdopen(fd, "w") as file:
|
|
165
|
+
file.truncate(0)
|
|
166
|
+
json.dump({"version": 1, "tunnels": records}, file, indent=2)
|
|
167
|
+
file.flush()
|
|
168
|
+
os.fsync(file.fileno())
|
|
169
|
+
os.replace(pending, self.state_dir / "registry.json")
|
|
170
|
+
directory = os.open(self.state_dir, os.O_RDONLY)
|
|
171
|
+
try:
|
|
172
|
+
os.fsync(directory)
|
|
173
|
+
finally:
|
|
174
|
+
os.close(directory)
|
|
175
|
+
|
|
176
|
+
def _control_args(self, record: dict, operation: str) -> list[str]:
|
|
177
|
+
# Management must never re-evaluate user configuration or fall back to a new connection.
|
|
178
|
+
return [self.ssh, "-F", "/dev/null", "-S", str(self._path(record["id"], "sock")),
|
|
179
|
+
"-O", operation, record["settings"]["alias"]]
|
|
180
|
+
|
|
181
|
+
def _control(self, record: dict, operation: str) -> subprocess.CompletedProcess:
|
|
182
|
+
return subprocess.run(self._control_args(record, operation), capture_output=True, text=True, timeout=5)
|
|
183
|
+
|
|
184
|
+
def _probe(self, record: dict) -> str:
|
|
185
|
+
path = self._path(record["id"], "sock")
|
|
186
|
+
try:
|
|
187
|
+
info = path.lstat()
|
|
188
|
+
if not stat.S_ISSOCK(info.st_mode) or info.st_uid != os.getuid():
|
|
189
|
+
return "Unknown"
|
|
190
|
+
result = self._control(record, "check")
|
|
191
|
+
if result.returncode == 0:
|
|
192
|
+
return "Alive"
|
|
193
|
+
# Distinguish stale/unlinked sockets from inspection failures.
|
|
194
|
+
with socket.socket(socket.AF_UNIX) as client:
|
|
195
|
+
client.settimeout(1)
|
|
196
|
+
try:
|
|
197
|
+
client.connect(str(path))
|
|
198
|
+
except OSError as exc:
|
|
199
|
+
if exc.errno in (errno.ENOENT, errno.ECONNREFUSED):
|
|
200
|
+
return "Gone"
|
|
201
|
+
return "Unknown"
|
|
202
|
+
except FileNotFoundError:
|
|
203
|
+
return "Gone"
|
|
204
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
205
|
+
return "Unknown"
|
|
206
|
+
|
|
207
|
+
def _status(self, record: dict) -> str:
|
|
208
|
+
probe = self._probe(record)
|
|
209
|
+
if probe == "Unknown":
|
|
210
|
+
return "Unknown"
|
|
211
|
+
if probe == "Alive":
|
|
212
|
+
return "Running" if record["status"] == "Running" else "Unknown"
|
|
213
|
+
return record["status"] if record["status"] in ("Stopped", "Failed") else "Disconnected"
|
|
214
|
+
|
|
215
|
+
def records(self) -> list[dict]:
|
|
216
|
+
return [dict(record, status=self._status(record)) for record in self._read()]
|
|
217
|
+
|
|
218
|
+
def commands(self, record: dict) -> list[list[str]]:
|
|
219
|
+
settings = Settings(**record["settings"])
|
|
220
|
+
master = [self.ssh]
|
|
221
|
+
if self.config_path:
|
|
222
|
+
master += ["-F", str(self.config_path)]
|
|
223
|
+
master += ["-n", "-N", "-T", "-M", "-S", str(self._path(record["id"], "sock")),
|
|
224
|
+
"-E", str(self._path(record["id"], "log"))]
|
|
225
|
+
for option in ("ClearAllForwardings=yes", "ControlPersist=no", "ExitOnForwardFailure=yes",
|
|
226
|
+
"PermitLocalCommand=no", "RemoteCommand=none", "ForwardAgent=no",
|
|
227
|
+
"ForwardX11=no", "Tunnel=no", "ServerAliveInterval=15", "ServerAliveCountMax=3",
|
|
228
|
+
"LogLevel=ERROR", "ConnectTimeout=15", "ConnectionAttempts=1",
|
|
229
|
+
f"ForkAfterAuthentication={'yes' if settings.mode == 'background' else 'no'}"):
|
|
230
|
+
master += ["-o", option]
|
|
231
|
+
master += [settings.alias]
|
|
232
|
+
destination = f"[{settings.destination}]" if ":" in settings.destination else settings.destination
|
|
233
|
+
forward = self._control_args(record, "forward")
|
|
234
|
+
forward[-1:-1] = ["-o", "ClearAllForwardings=no", "-L",
|
|
235
|
+
f"127.0.0.1:{settings.local_port}:{destination}:{settings.remote_port}"]
|
|
236
|
+
return [master, forward]
|
|
237
|
+
|
|
238
|
+
def prepare(self, settings: Settings) -> dict:
|
|
239
|
+
record = {"id": uuid.uuid4().hex[:12], "settings": asdict(settings), "created_at": now(),
|
|
240
|
+
"status": "Connecting", "successful": False, "last_error": None}
|
|
241
|
+
record["commands"] = self.commands(record)
|
|
242
|
+
return record
|
|
243
|
+
|
|
244
|
+
def _find(self, records: list[dict], tunnel_id: str) -> dict:
|
|
245
|
+
self._path(tunnel_id, "sock")
|
|
246
|
+
for record in records:
|
|
247
|
+
if record["id"] == tunnel_id:
|
|
248
|
+
return record
|
|
249
|
+
raise TunnelError("Tunnel not found. Use tunnel list to see managed IDs.")
|
|
250
|
+
|
|
251
|
+
def logs(self, tunnel_id: str) -> str:
|
|
252
|
+
self._find(self._read(), tunnel_id)
|
|
253
|
+
path = self._path(tunnel_id, "log")
|
|
254
|
+
try:
|
|
255
|
+
fd = self._open(path, os.O_RDWR)
|
|
256
|
+
except FileNotFoundError:
|
|
257
|
+
return "No SSH diagnostics recorded. Application logs are not collected."
|
|
258
|
+
with os.fdopen(fd, "r+b") as file:
|
|
259
|
+
file.seek(0, os.SEEK_END)
|
|
260
|
+
size = file.tell()
|
|
261
|
+
file.seek(max(0, size - 65536))
|
|
262
|
+
output = file.read()
|
|
263
|
+
# shortcut: SSH owns the log; trim on access, add rotation if continuous log growth matters.
|
|
264
|
+
if size > 65536:
|
|
265
|
+
file.seek(0)
|
|
266
|
+
file.write(output)
|
|
267
|
+
file.truncate()
|
|
268
|
+
return output.decode(errors="replace") or "No SSH diagnostics recorded. Application logs are not collected."
|
|
269
|
+
|
|
270
|
+
def _exit(self, record: dict):
|
|
271
|
+
probe = self._probe(record)
|
|
272
|
+
if probe == "Unknown":
|
|
273
|
+
raise TunnelError("Cannot verify the owned control socket. No process was killed.")
|
|
274
|
+
if probe == "Gone":
|
|
275
|
+
return
|
|
276
|
+
try:
|
|
277
|
+
result = self._control(record, "exit")
|
|
278
|
+
except subprocess.TimeoutExpired as exc:
|
|
279
|
+
raise TunnelError("Timed out stopping the owned SSH connection; inspect its status.") from exc
|
|
280
|
+
for _ in range(30):
|
|
281
|
+
if self._probe(record) == "Gone":
|
|
282
|
+
return
|
|
283
|
+
time.sleep(0.1)
|
|
284
|
+
raise TunnelError(result.stderr.strip() or "SSH shutdown could not be verified.")
|
|
285
|
+
|
|
286
|
+
def stop(self, tunnel_id: str) -> dict:
|
|
287
|
+
with self._locked():
|
|
288
|
+
records = self._read()
|
|
289
|
+
record = self._find(records, tunnel_id)
|
|
290
|
+
self._exit(record)
|
|
291
|
+
record.update(status="Stopped", stopped_at=now(), last_error=None)
|
|
292
|
+
self._save(records)
|
|
293
|
+
return dict(record)
|
|
294
|
+
|
|
295
|
+
def _cleanup_attempt(self, record: dict, process: subprocess.Popen | None):
|
|
296
|
+
if process is not None and process.poll() is None:
|
|
297
|
+
process.terminate()
|
|
298
|
+
try:
|
|
299
|
+
process.wait(timeout=5)
|
|
300
|
+
except subprocess.TimeoutExpired:
|
|
301
|
+
process.kill()
|
|
302
|
+
process.wait()
|
|
303
|
+
self._exit(record)
|
|
304
|
+
|
|
305
|
+
def start(self, record: dict, on_started: Callable[[dict], None] | None = None) -> dict:
|
|
306
|
+
settings = Settings(**record["settings"])
|
|
307
|
+
process = None
|
|
308
|
+
previous_hup = None
|
|
309
|
+
try:
|
|
310
|
+
previous_hup = signal.signal(signal.SIGHUP, _interrupt)
|
|
311
|
+
with self._locked():
|
|
312
|
+
records = self._read()
|
|
313
|
+
if settings.alias not in discover_hosts(self.config_path).aliases:
|
|
314
|
+
raise TunnelError("The selected alias is no longer in the discovered SSH configuration.")
|
|
315
|
+
if any(item["id"] == record["id"] for item in records):
|
|
316
|
+
raise TunnelError("This launch was already attempted. Review a fresh attempt before retrying.")
|
|
317
|
+
for existing in records:
|
|
318
|
+
route = ("alias", "destination", "local_port", "remote_port")
|
|
319
|
+
if all(existing["settings"][key] == record["settings"][key] for key in route) and self._probe(existing) == "Alive":
|
|
320
|
+
raise TunnelError(f"This tunnel is already active: {existing['id']}. Open Manage tunnels.")
|
|
321
|
+
if not available_port(settings.local_port):
|
|
322
|
+
raise TunnelError(f"Port {settings.local_port} is unavailable. Choose another local port.")
|
|
323
|
+
if self._path(record["id"], "sock").exists():
|
|
324
|
+
raise TunnelError("Control path already exists. Review a fresh attempt.")
|
|
325
|
+
if record["commands"] != self.commands(record):
|
|
326
|
+
raise TunnelError("Launch settings changed after review. Review again before starting.")
|
|
327
|
+
record = dict(record, status="Connecting", started_at=now())
|
|
328
|
+
records.append(record)
|
|
329
|
+
self._save(records)
|
|
330
|
+
try:
|
|
331
|
+
fd = self._open(self._path(record["id"], "log"), os.O_WRONLY | os.O_CREAT | os.O_EXCL)
|
|
332
|
+
os.close(fd)
|
|
333
|
+
process = subprocess.Popen(record["commands"][0])
|
|
334
|
+
while self._probe(record) != "Alive":
|
|
335
|
+
if process.poll() is not None:
|
|
336
|
+
# A background master may publish its socket just after its parent exits.
|
|
337
|
+
if settings.mode == "background" and process.returncode == 0:
|
|
338
|
+
for _ in range(30):
|
|
339
|
+
if self._probe(record) == "Alive":
|
|
340
|
+
break
|
|
341
|
+
time.sleep(0.1)
|
|
342
|
+
else:
|
|
343
|
+
raise TunnelError("SSH exited without a responsive owned control socket.")
|
|
344
|
+
break
|
|
345
|
+
raise TunnelError(f"SSH startup failed (exit {process.returncode}). {self.logs(record['id'])}")
|
|
346
|
+
time.sleep(0.05)
|
|
347
|
+
if settings.mode == "background":
|
|
348
|
+
process.wait(timeout=5)
|
|
349
|
+
if process.returncode:
|
|
350
|
+
raise TunnelError(f"SSH backgrounding failed (exit {process.returncode}).")
|
|
351
|
+
result = subprocess.run(record["commands"][1], capture_output=True, text=True, timeout=10)
|
|
352
|
+
if result.returncode:
|
|
353
|
+
raise TunnelError(result.stderr.strip() or "SSH refused the requested forward.")
|
|
354
|
+
if self._probe(record) != "Alive":
|
|
355
|
+
raise TunnelError("SSH disconnected while opening the forward.")
|
|
356
|
+
record.update(status="Running", successful=True, last_error=None)
|
|
357
|
+
self._save(records)
|
|
358
|
+
except BaseException as exc:
|
|
359
|
+
try:
|
|
360
|
+
self._cleanup_attempt(record, process)
|
|
361
|
+
record["status"] = "Failed"
|
|
362
|
+
except (TunnelError, OSError) as cleanup_error:
|
|
363
|
+
record["status"] = "Unknown"
|
|
364
|
+
record["last_error"] = f"Cleanup needs attention: {cleanup_error}"
|
|
365
|
+
record["last_error"] = record["last_error"] or ("Cancelled during startup." if isinstance(exc, KeyboardInterrupt) else str(exc))
|
|
366
|
+
record["successful"] = False
|
|
367
|
+
try:
|
|
368
|
+
self._save(records)
|
|
369
|
+
except OSError as save_error:
|
|
370
|
+
raise TunnelError(f"Launch failed; could not save its outcome. Record {record['id']} was reserved before launch. {save_error}") from exc
|
|
371
|
+
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
|
372
|
+
raise
|
|
373
|
+
raise TunnelError(record["last_error"]) from exc
|
|
374
|
+
if settings.mode == "foreground":
|
|
375
|
+
interrupted = False
|
|
376
|
+
try:
|
|
377
|
+
if on_started:
|
|
378
|
+
on_started(dict(record))
|
|
379
|
+
try:
|
|
380
|
+
process.wait()
|
|
381
|
+
except KeyboardInterrupt:
|
|
382
|
+
interrupted = True
|
|
383
|
+
finally:
|
|
384
|
+
stopped = interrupted or process.poll() is None
|
|
385
|
+
# Release the owned connection even if updating the registry later fails.
|
|
386
|
+
self._exit(record)
|
|
387
|
+
process.wait(timeout=5)
|
|
388
|
+
with self._locked():
|
|
389
|
+
records = self._read()
|
|
390
|
+
stored = self._find(records, record["id"])
|
|
391
|
+
if stopped:
|
|
392
|
+
stored.update(status="Stopped", stopped_at=now(), last_error=None)
|
|
393
|
+
elif stored["status"] != "Stopped":
|
|
394
|
+
stored.update(status="Disconnected", last_error=f"SSH exited ({process.returncode}).")
|
|
395
|
+
self._save(records)
|
|
396
|
+
return dict(stored)
|
|
397
|
+
if on_started:
|
|
398
|
+
on_started(dict(record))
|
|
399
|
+
return dict(record)
|
|
400
|
+
finally:
|
|
401
|
+
if previous_hup is not None:
|
|
402
|
+
signal.signal(signal.SIGHUP, previous_hup)
|
tunnel_cli/prompts.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Small prompt layer over Questionary: Esc raises Back, search never submits
|
|
2
|
+
an unmatched host, and say() prints untrusted text without control sequences."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import questionary
|
|
8
|
+
from prompt_toolkit.filters import Condition
|
|
9
|
+
from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
|
|
10
|
+
from prompt_toolkit.output import ColorDepth
|
|
11
|
+
from prompt_toolkit.styles import Style
|
|
12
|
+
from prompt_toolkit.validation import ValidationError, Validator
|
|
13
|
+
from questionary.prompts.common import InquirerControl
|
|
14
|
+
|
|
15
|
+
_HINT = "Esc back · Ctrl+C cancel"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Back(Exception):
|
|
19
|
+
"""The user pressed Esc to go back one step; collected answers stay valid."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_UNSAFE = re.compile(
|
|
23
|
+
r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?" # OSC (title/hyperlink injection)
|
|
24
|
+
r"|\x1b\[[0-?]*[ -/]*[@-~]?" # CSI sequences
|
|
25
|
+
r"|\x1b.?" # any other escape, even truncated
|
|
26
|
+
r"|[\x00-\x08\x0b-\x1f\x7f\x80-\x9f]" # C0 except \t\n, DEL, C1
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def say(message: str = "") -> None:
|
|
31
|
+
"""Print possibly untrusted text (SSH diagnostics, config warnings) with
|
|
32
|
+
terminal control characters removed; newlines and tabs survive."""
|
|
33
|
+
print(_UNSAFE.sub("", str(message)))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _style() -> Style:
|
|
37
|
+
return Style(
|
|
38
|
+
[("qmark", "fg:ansicyan"), ("pointer", "fg:ansicyan"),
|
|
39
|
+
("highlighted", "fg:ansicyan"), ("answer", "fg:ansicyan")]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _color() -> dict:
|
|
44
|
+
"""NO_COLOR present (even empty) forces monochrome rendering. A Style([])
|
|
45
|
+
would merge with Questionary's defaults and not suppress colors; the
|
|
46
|
+
application's color depth does."""
|
|
47
|
+
return {"color_depth": ColorDepth.DEPTH_1_BIT} if "NO_COLOR" in os.environ else {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _Checked(Validator):
|
|
51
|
+
"""Adapt a callable that raises ValueError (or returns False) for invalid text."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, check):
|
|
54
|
+
self._check = check
|
|
55
|
+
|
|
56
|
+
def validate(self, document):
|
|
57
|
+
try:
|
|
58
|
+
ok = self._check(document.text)
|
|
59
|
+
except ValueError as error:
|
|
60
|
+
raise ValidationError(message=str(error), cursor_position=len(document.text)) from None
|
|
61
|
+
if ok is False:
|
|
62
|
+
raise ValidationError(message="Invalid input.", cursor_position=len(document.text))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ask(question, *extra_bindings):
|
|
66
|
+
"""Run a questionary question with a shared Esc-goes-back binding."""
|
|
67
|
+
application = question.application
|
|
68
|
+
back = KeyBindings()
|
|
69
|
+
|
|
70
|
+
@back.add("escape", eager=True)
|
|
71
|
+
def _back(event):
|
|
72
|
+
event.app.exit(exception=Back())
|
|
73
|
+
|
|
74
|
+
application.key_bindings = merge_key_bindings(
|
|
75
|
+
[bindings for bindings in (application.key_bindings, back, *extra_bindings) if bindings]
|
|
76
|
+
)
|
|
77
|
+
application.ttimeoutlen = 0.1 # resolve a lone Esc quickly
|
|
78
|
+
return question.unsafe_ask()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def choose(message: str, choices: list, default=None, search: bool = False, **io):
|
|
82
|
+
"""Arrow-key menu. With search=True, typing filters the choices and Enter is
|
|
83
|
+
ignored while the filter matches nothing, so an invisible choice can never
|
|
84
|
+
be submitted and typed text is never the answer. Raises Back on Esc."""
|
|
85
|
+
hint = ("type to filter · " if search else "") + f"↑↓ navigate · Enter select · {_HINT}"
|
|
86
|
+
question = questionary.select(
|
|
87
|
+
message, choices=choices, default=default, style=_style(),
|
|
88
|
+
instruction=f"({hint})",
|
|
89
|
+
use_search_filter=search, use_jk_keys=False, use_shortcuts=False,
|
|
90
|
+
**_color(), **io,
|
|
91
|
+
)
|
|
92
|
+
extra = []
|
|
93
|
+
if search:
|
|
94
|
+
control = next(
|
|
95
|
+
window.content
|
|
96
|
+
for window in question.application.layout.find_all_windows()
|
|
97
|
+
if isinstance(window.content, InquirerControl)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@Condition
|
|
101
|
+
def unmatched():
|
|
102
|
+
# shortcut: pinned Questionary 2.1.1 control internals, recheck on upgrade.
|
|
103
|
+
needle = (control.search_filter or "").lower()
|
|
104
|
+
return bool(needle) and not any(
|
|
105
|
+
needle in choice.title.lower() for choice in control.choices
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
block = KeyBindings()
|
|
109
|
+
block.add("enter", eager=True, filter=unmatched)(lambda event: None)
|
|
110
|
+
extra.append(block)
|
|
111
|
+
return _ask(question, *extra)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def text(message: str, default: str = "", validate=None, **io) -> str:
|
|
115
|
+
"""One-line answer with optional validation; Esc raises Back."""
|
|
116
|
+
question = questionary.text(
|
|
117
|
+
message, default=default, style=_style(), instruction=f"({_HINT}) ",
|
|
118
|
+
validate=_Checked(validate) if validate else None, **_color(), **io,
|
|
119
|
+
)
|
|
120
|
+
return _ask(question)
|