wisp-vpn 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wisp/cli/__init__.py +3 -0
- wisp/cli/__main__.py +10 -0
- wisp/cli/app.py +252 -0
- wisp/cli/screens/__init__.py +15 -0
- wisp/cli/screens/base.py +53 -0
- wisp/cli/screens/config.py +713 -0
- wisp/cli/screens/deploy.py +570 -0
- wisp/cli/screens/main_menu.py +225 -0
- wisp/cli/screens/modal.py +242 -0
- wisp/cli/screens/progress.py +404 -0
- wisp/cli/screens/shutdown.py +305 -0
- wisp/cli/screens/tunnel.py +244 -0
- wisp/cli/state.py +103 -0
- wisp/cli/widgets/__init__.py +0 -0
- wisp/config/__init__.py +3 -0
- wisp/config/constants.py +171 -0
- wisp/config/credentials.py +504 -0
- wisp/config/settings.py +275 -0
- wisp/daemon/__init__.py +14 -0
- wisp/daemon/adapter.py +129 -0
- wisp/daemon/install.py +505 -0
- wisp/daemon/protocol.py +63 -0
- wisp/daemon/server.py +122 -0
- wisp/daemon/transport.py +381 -0
- wisp/daemon/windows_service.py +84 -0
- wisp/data/wireguard-server-install.sh +709 -0
- wisp/data/wisp.service.tpl +12 -0
- wisp/data/wisp.socket +11 -0
- wisp/main.py +548 -0
- wisp/providers/__init__.py +31 -0
- wisp/providers/aws/__init__.py +3 -0
- wisp/providers/aws/constants.py +9 -0
- wisp/providers/aws/provider.py +62 -0
- wisp/providers/aws/pulumi.py +267 -0
- wisp/providers/base.py +148 -0
- wisp/providers/gcp/__init__.py +5 -0
- wisp/providers/gcp/constants.py +26 -0
- wisp/providers/gcp/provider.py +84 -0
- wisp/providers/gcp/pulumi.py +271 -0
- wisp/providers/oci/__init__.py +13 -0
- wisp/providers/oci/constants.py +19 -0
- wisp/providers/oci/provider.py +99 -0
- wisp/providers/oci/pulumi.py +470 -0
- wisp/providers/pulumi_base.py +293 -0
- wisp/providers/vps.py +269 -0
- wisp/schemas.py +30 -0
- wisp/session.py +496 -0
- wisp/utils/__init__.py +38 -0
- wisp/utils/logger.py +106 -0
- wisp/utils/platform.py +68 -0
- wisp/utils/pulumi.py +130 -0
- wisp/utils/randoms.py +11 -0
- wisp/utils/watchdog.py +278 -0
- wisp/wireguard/__init__.py +17 -0
- wisp/wireguard/local_client.py +32 -0
- wisp/wireguard/remote_server.py +421 -0
- wisp_vpn-0.1.0.dist-info/METADATA +241 -0
- wisp_vpn-0.1.0.dist-info/RECORD +62 -0
- wisp_vpn-0.1.0.dist-info/WHEEL +5 -0
- wisp_vpn-0.1.0.dist-info/entry_points.txt +2 -0
- wisp_vpn-0.1.0.dist-info/licenses/LICENSE +21 -0
- wisp_vpn-0.1.0.dist-info/top_level.txt +1 -0
wisp/cli/__init__.py
ADDED
wisp/cli/__main__.py
ADDED
wisp/cli/app.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Root Textual application and global styles for the Wisp TUI."""
|
|
2
|
+
|
|
3
|
+
from textual.app import App
|
|
4
|
+
from textual.binding import Binding
|
|
5
|
+
|
|
6
|
+
from wisp.cli.screens import ConfigScreen, DeployScreen, MainMenuScreen
|
|
7
|
+
from wisp.cli.screens.modal import ConfirmDestroyScreen
|
|
8
|
+
from wisp.cli.screens.shutdown import confirmed_destroy
|
|
9
|
+
from wisp.cli.state import AppState
|
|
10
|
+
from wisp.session import (
|
|
11
|
+
find_orphaned_session,
|
|
12
|
+
install_signal_handlers,
|
|
13
|
+
register_atexit,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WispApp(App):
|
|
18
|
+
"""The Wisp terminal UI application.
|
|
19
|
+
|
|
20
|
+
Owns the shared :class:`~wisp.cli.state.AppState`, defines global CSS and
|
|
21
|
+
key bindings, and installs the main-menu, config, and deploy screens on
|
|
22
|
+
mount.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
TITLE = "Wisp"
|
|
26
|
+
SUB_TITLE = "Ephemeral WireGuard VPNs on your own cloud"
|
|
27
|
+
|
|
28
|
+
BINDINGS = [
|
|
29
|
+
Binding("q", "quit", "Exit", show=True),
|
|
30
|
+
Binding("ctrl+c", "quit", "Exit", show=False),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
CSS = """
|
|
34
|
+
Screen {
|
|
35
|
+
background: #0b0f19;
|
|
36
|
+
color: #f1f5f9;
|
|
37
|
+
align: center middle;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
Header {
|
|
41
|
+
background: #111827;
|
|
42
|
+
color: #38bdf8;
|
|
43
|
+
dock: top;
|
|
44
|
+
height: 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
Footer {
|
|
48
|
+
background: #111827;
|
|
49
|
+
dock: bottom;
|
|
50
|
+
height: 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.card {
|
|
54
|
+
background: #1e293b;
|
|
55
|
+
border: round #38bdf8;
|
|
56
|
+
padding: 1 2;
|
|
57
|
+
width: 74;
|
|
58
|
+
height: auto;
|
|
59
|
+
/* Must stay at 100%: any smaller percentage truncates the auto height
|
|
60
|
+
by a row or two, and `overflow-y: auto` then renders a scrollbar for
|
|
61
|
+
that gap alone -- a scroll that reveals nothing but padding. At 100%
|
|
62
|
+
the bar disappears once the content fits and still appears when the
|
|
63
|
+
terminal is genuinely too short to show everything. */
|
|
64
|
+
max-height: 100%;
|
|
65
|
+
overflow-y: auto;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.title {
|
|
69
|
+
text-style: bold;
|
|
70
|
+
color: #38bdf8;
|
|
71
|
+
text-align: center;
|
|
72
|
+
margin-bottom: 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.subtitle {
|
|
76
|
+
color: #94a3b8;
|
|
77
|
+
text-align: center;
|
|
78
|
+
margin-bottom: 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.btn-primary {
|
|
82
|
+
background: #0284c7;
|
|
83
|
+
color: #ffffff;
|
|
84
|
+
border: none;
|
|
85
|
+
width: 100%;
|
|
86
|
+
margin-top: 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.btn-primary:hover {
|
|
90
|
+
background: #0369a1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.btn-secondary {
|
|
94
|
+
background: #334155;
|
|
95
|
+
color: #f8fafc;
|
|
96
|
+
border: none;
|
|
97
|
+
width: 100%;
|
|
98
|
+
margin-top: 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.btn-secondary:hover {
|
|
102
|
+
background: #475569;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.btn-danger {
|
|
106
|
+
background: #dc2626;
|
|
107
|
+
color: #ffffff;
|
|
108
|
+
border: none;
|
|
109
|
+
width: 100%;
|
|
110
|
+
margin-top: 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.btn-danger:hover {
|
|
114
|
+
background: #b91c1c;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
Input:focus, Switch:focus {
|
|
118
|
+
border: tall #38bdf8;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
Button:focus {
|
|
122
|
+
text-style: bold reverse;
|
|
123
|
+
}
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self) -> None:
|
|
127
|
+
super().__init__()
|
|
128
|
+
self.state = AppState()
|
|
129
|
+
|
|
130
|
+
def on_mount(self) -> None:
|
|
131
|
+
"""Install the app screens and show the main menu."""
|
|
132
|
+
try:
|
|
133
|
+
from wisp.config.settings import describe_config_sources
|
|
134
|
+
from wisp.utils import logger
|
|
135
|
+
|
|
136
|
+
logger.info(describe_config_sources())
|
|
137
|
+
except Exception:
|
|
138
|
+
pass # purely informational; never block startup over this
|
|
139
|
+
|
|
140
|
+
self._configure_shutdown()
|
|
141
|
+
|
|
142
|
+
self.install_screen(MainMenuScreen(), name="main_menu")
|
|
143
|
+
self.install_screen(ConfigScreen(), name="config")
|
|
144
|
+
self.install_screen(DeployScreen(), name="deploy")
|
|
145
|
+
self.push_screen("main_menu")
|
|
146
|
+
self._offer_orphan_cleanup()
|
|
147
|
+
|
|
148
|
+
def thread_safe_call(self, message: str) -> None:
|
|
149
|
+
"""Show a one-line status on whichever screen is active, from any thread.
|
|
150
|
+
|
|
151
|
+
Teardown runs on a worker thread long after the screen that started it was
|
|
152
|
+
swapped out, so it cannot hold a reference to it. Resolving the widget at
|
|
153
|
+
call time keeps the teardown tied to the app instead.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
message (str): Status text to display.
|
|
157
|
+
"""
|
|
158
|
+
if self._exit:
|
|
159
|
+
return
|
|
160
|
+
for widget_id in ("#shutdown-status", "#tunnel-health", "#progress-status-msg"):
|
|
161
|
+
for screen in self.screen_stack:
|
|
162
|
+
try:
|
|
163
|
+
widget = screen.query_one(widget_id)
|
|
164
|
+
except Exception:
|
|
165
|
+
continue
|
|
166
|
+
self.call_from_thread(widget.update, message)
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
def _configure_shutdown(self) -> None:
|
|
170
|
+
"""Wire every shutdown trigger into the session guard.
|
|
171
|
+
|
|
172
|
+
Ctrl+C is deliberately *not* routed through a signal handler: Textual's
|
|
173
|
+
driver clears the terminal's ``ISIG`` flag, so Ctrl+C arrives as a key
|
|
174
|
+
event and lands on the ``ctrl+c`` binding below. Keeping it a key event
|
|
175
|
+
means the teardown runs through the same UI path as ``q``, with visible
|
|
176
|
+
progress, instead of interrupting the event loop from a signal context.
|
|
177
|
+
|
|
178
|
+
Signals sent from outside (a shell ``kill``, a supervisor, a closing
|
|
179
|
+
terminal) still have to be caught, so ``SIGINT``/``SIGTERM`` go to the
|
|
180
|
+
guard, and ``atexit`` covers normal interpreter exit on all platforms.
|
|
181
|
+
"""
|
|
182
|
+
install_signal_handlers(self.state.session_guard)
|
|
183
|
+
register_atexit(self.state.session_guard)
|
|
184
|
+
|
|
185
|
+
def action_quit(self) -> None:
|
|
186
|
+
"""Quit, destroying any live tunnel — asking first, unless disabled.
|
|
187
|
+
|
|
188
|
+
Reached by both ``q`` and ``Ctrl+C`` from every screen. When a tunnel is
|
|
189
|
+
up the session guard is armed and quitting destroys it, which is
|
|
190
|
+
irreversible, so the same ``ConfirmDestroyScreen`` as the destroy button
|
|
191
|
+
asks first (honouring ``[general] confirm_destroy``). Elsewhere there is
|
|
192
|
+
nothing live and the app quits straight away.
|
|
193
|
+
"""
|
|
194
|
+
state = self.state
|
|
195
|
+
if isinstance(self.screen, ConfirmDestroyScreen):
|
|
196
|
+
return
|
|
197
|
+
if state.session_guard.armed and state.config.confirm_destroy:
|
|
198
|
+
self.push_screen(ConfirmDestroyScreen(), self._on_quit_destroy_choice)
|
|
199
|
+
else:
|
|
200
|
+
self.exit()
|
|
201
|
+
|
|
202
|
+
def _on_quit_destroy_choice(self, choice: tuple[bool, bool] | None) -> None:
|
|
203
|
+
"""Apply the destroy modal's verdict when quitting with a live session."""
|
|
204
|
+
confirmed_destroy(
|
|
205
|
+
self,
|
|
206
|
+
choice,
|
|
207
|
+
"tunnel destroyed on quit",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _offer_orphan_cleanup(self) -> None:
|
|
211
|
+
"""Offer to clean up a session left behind by a previous run.
|
|
212
|
+
|
|
213
|
+
A crash, a ``kill -9`` or a power loss leave the marker file on disk.
|
|
214
|
+
Its owning process is gone, so its VM is still running and still
|
|
215
|
+
billing; this surfaces that instead of leaking it silently.
|
|
216
|
+
"""
|
|
217
|
+
orphan = find_orphaned_session()
|
|
218
|
+
if orphan is None:
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
from wisp.utils import logger
|
|
222
|
+
|
|
223
|
+
logger.warning(
|
|
224
|
+
f"Found a session left behind by a dead process: {orphan.summary()}"
|
|
225
|
+
)
|
|
226
|
+
try:
|
|
227
|
+
self.notify(
|
|
228
|
+
f"Orphaned tunnel found: {orphan.summary()}.\n"
|
|
229
|
+
"Cloud resources may still be incurring charges; clean them up "
|
|
230
|
+
"using option 4 in the main menu.",
|
|
231
|
+
title="Orphaned session",
|
|
232
|
+
severity="warning",
|
|
233
|
+
timeout=15,
|
|
234
|
+
)
|
|
235
|
+
except Exception:
|
|
236
|
+
# Notifications are cosmetic; never let them block startup.
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
# The notification alone expires, so the menu also carries a durable
|
|
240
|
+
# row for this: the warning is only useful while it can still be acted
|
|
241
|
+
# on, and the marker outlives the process that would have used it.
|
|
242
|
+
self.call_after_refresh(self._refresh_menu_orphan_row)
|
|
243
|
+
|
|
244
|
+
def _refresh_menu_orphan_row(self) -> None:
|
|
245
|
+
"""Ask the main menu to re-evaluate its orphan-cleanup row."""
|
|
246
|
+
try:
|
|
247
|
+
menu = self.get_screen("main_menu")
|
|
248
|
+
except Exception:
|
|
249
|
+
return
|
|
250
|
+
refresher = getattr(menu, "refresh_orphan_row", None)
|
|
251
|
+
if callable(refresher):
|
|
252
|
+
refresher()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from wisp.cli.screens.config import ConfigScreen
|
|
2
|
+
from wisp.cli.screens.deploy import DeployScreen
|
|
3
|
+
from wisp.cli.screens.main_menu import MainMenuScreen
|
|
4
|
+
from wisp.cli.screens.progress import ProgressScreen
|
|
5
|
+
from wisp.cli.screens.shutdown import ShutdownScreen
|
|
6
|
+
from wisp.cli.screens.tunnel import TunnelScreen
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ConfigScreen",
|
|
10
|
+
"DeployScreen",
|
|
11
|
+
"MainMenuScreen",
|
|
12
|
+
"ProgressScreen",
|
|
13
|
+
"ShutdownScreen",
|
|
14
|
+
"TunnelScreen",
|
|
15
|
+
]
|
wisp/cli/screens/base.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Shared base screen for the Wisp TUI.
|
|
2
|
+
|
|
3
|
+
Textual only cycles focus with Tab/Shift+Tab by default. Wisp's screens are
|
|
4
|
+
short forms, so arrow keys feel more natural: Up/Down move between widgets in
|
|
5
|
+
DOM order (row by row), and Left/Right do the same, which is what most people
|
|
6
|
+
try first inside a button group like *Iniciar despliegue / Cancelar*.
|
|
7
|
+
|
|
8
|
+
``WispScreen`` adds that everywhere without touching widgets that already use
|
|
9
|
+
the arrows for their own thing — an ``Input`` consumes ``left``/``right`` to
|
|
10
|
+
move the text cursor, and a ``Select`` consumes ``up``/``down`` to move between
|
|
11
|
+
its options. Textual resolves bindings from the focused widget upwards, so both
|
|
12
|
+
already shadow these screen-level bindings while they are focused; the guard in
|
|
13
|
+
``_arrows_owned_by_focused_widget`` makes the intent explicit and future-proof.
|
|
14
|
+
|
|
15
|
+
All screens should inherit from this instead of ``textual.screen.Screen``.
|
|
16
|
+
Textual merges ``BINDINGS`` across the MRO, so a subclass's own bindings
|
|
17
|
+
(``escape``, ``1``/``2``/``3``, etc.) keep working unchanged.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from textual.binding import Binding
|
|
21
|
+
from textual.screen import Screen
|
|
22
|
+
from textual.widgets import Input, Select
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WispScreen(Screen):
|
|
26
|
+
"""Base screen adding the arrow keys as focus navigation aliases."""
|
|
27
|
+
|
|
28
|
+
BINDINGS = [
|
|
29
|
+
Binding("left", "focus_previous_widget", "Anterior", show=False),
|
|
30
|
+
Binding("right", "focus_next_widget", "Siguiente", show=False),
|
|
31
|
+
Binding("down", "focus_next_widget", "Siguiente", show=False),
|
|
32
|
+
Binding("up", "focus_previous_widget", "Anterior", show=False),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def action_focus_next_widget(self) -> None:
|
|
36
|
+
if self._arrows_owned_by_focused_widget():
|
|
37
|
+
return
|
|
38
|
+
self.focus_next()
|
|
39
|
+
|
|
40
|
+
def action_focus_previous_widget(self) -> None:
|
|
41
|
+
if self._arrows_owned_by_focused_widget():
|
|
42
|
+
return
|
|
43
|
+
self.focus_previous()
|
|
44
|
+
|
|
45
|
+
def _arrows_owned_by_focused_widget(self) -> bool:
|
|
46
|
+
"""True when the focused widget needs the arrow keys for itself.
|
|
47
|
+
|
|
48
|
+
An ``Input`` uses ``left``/``right`` to move the text cursor and a
|
|
49
|
+
``Select`` uses ``up``/``down`` to move between its own options (open
|
|
50
|
+
or not), so we back off and let them handle the arrows instead of
|
|
51
|
+
stealing focus mid-edit / mid-selection.
|
|
52
|
+
"""
|
|
53
|
+
return isinstance(self.focused, (Input, Select))
|