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.
Files changed (62) hide show
  1. wisp/cli/__init__.py +3 -0
  2. wisp/cli/__main__.py +10 -0
  3. wisp/cli/app.py +252 -0
  4. wisp/cli/screens/__init__.py +15 -0
  5. wisp/cli/screens/base.py +53 -0
  6. wisp/cli/screens/config.py +713 -0
  7. wisp/cli/screens/deploy.py +570 -0
  8. wisp/cli/screens/main_menu.py +225 -0
  9. wisp/cli/screens/modal.py +242 -0
  10. wisp/cli/screens/progress.py +404 -0
  11. wisp/cli/screens/shutdown.py +305 -0
  12. wisp/cli/screens/tunnel.py +244 -0
  13. wisp/cli/state.py +103 -0
  14. wisp/cli/widgets/__init__.py +0 -0
  15. wisp/config/__init__.py +3 -0
  16. wisp/config/constants.py +171 -0
  17. wisp/config/credentials.py +504 -0
  18. wisp/config/settings.py +275 -0
  19. wisp/daemon/__init__.py +14 -0
  20. wisp/daemon/adapter.py +129 -0
  21. wisp/daemon/install.py +505 -0
  22. wisp/daemon/protocol.py +63 -0
  23. wisp/daemon/server.py +122 -0
  24. wisp/daemon/transport.py +381 -0
  25. wisp/daemon/windows_service.py +84 -0
  26. wisp/data/wireguard-server-install.sh +709 -0
  27. wisp/data/wisp.service.tpl +12 -0
  28. wisp/data/wisp.socket +11 -0
  29. wisp/main.py +548 -0
  30. wisp/providers/__init__.py +31 -0
  31. wisp/providers/aws/__init__.py +3 -0
  32. wisp/providers/aws/constants.py +9 -0
  33. wisp/providers/aws/provider.py +62 -0
  34. wisp/providers/aws/pulumi.py +267 -0
  35. wisp/providers/base.py +148 -0
  36. wisp/providers/gcp/__init__.py +5 -0
  37. wisp/providers/gcp/constants.py +26 -0
  38. wisp/providers/gcp/provider.py +84 -0
  39. wisp/providers/gcp/pulumi.py +271 -0
  40. wisp/providers/oci/__init__.py +13 -0
  41. wisp/providers/oci/constants.py +19 -0
  42. wisp/providers/oci/provider.py +99 -0
  43. wisp/providers/oci/pulumi.py +470 -0
  44. wisp/providers/pulumi_base.py +293 -0
  45. wisp/providers/vps.py +269 -0
  46. wisp/schemas.py +30 -0
  47. wisp/session.py +496 -0
  48. wisp/utils/__init__.py +38 -0
  49. wisp/utils/logger.py +106 -0
  50. wisp/utils/platform.py +68 -0
  51. wisp/utils/pulumi.py +130 -0
  52. wisp/utils/randoms.py +11 -0
  53. wisp/utils/watchdog.py +278 -0
  54. wisp/wireguard/__init__.py +17 -0
  55. wisp/wireguard/local_client.py +32 -0
  56. wisp/wireguard/remote_server.py +421 -0
  57. wisp_vpn-0.1.0.dist-info/METADATA +241 -0
  58. wisp_vpn-0.1.0.dist-info/RECORD +62 -0
  59. wisp_vpn-0.1.0.dist-info/WHEEL +5 -0
  60. wisp_vpn-0.1.0.dist-info/entry_points.txt +2 -0
  61. wisp_vpn-0.1.0.dist-info/licenses/LICENSE +21 -0
  62. wisp_vpn-0.1.0.dist-info/top_level.txt +1 -0
wisp/cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from wisp.cli.app import WispApp
2
+
3
+ __all__ = ["WispApp"]
wisp/cli/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ from wisp.cli.app import WispApp
2
+
3
+
4
+ def main() -> None:
5
+ app = WispApp()
6
+ app.run()
7
+
8
+
9
+ if __name__ == "__main__":
10
+ main()
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
+ ]
@@ -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))