evo-cli 0.25.1__tar.gz → 0.26.0__tar.gz
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.
- {evo_cli-0.25.1 → evo_cli-0.26.0}/PKG-INFO +54 -1
- {evo_cli-0.25.1 → evo_cli-0.26.0}/README.md +53 -0
- evo_cli-0.26.0/evo_cli/VERSION +1 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/cli.py +2 -0
- evo_cli-0.26.0/evo_cli/commands/openvpn.py +357 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/credentials/registry.py +11 -0
- evo_cli-0.26.0/evo_cli/openvpn.py +450 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli.egg-info/PKG-INFO +54 -1
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli.egg-info/SOURCES.txt +3 -0
- evo_cli-0.26.0/tests/test_openvpn.py +334 -0
- evo_cli-0.25.1/evo_cli/VERSION +0 -1
- {evo_cli-0.25.1 → evo_cli-0.26.0}/Containerfile +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/HISTORY.md +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/LICENSE +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/MANIFEST.in +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/__init__.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/__main__.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/base.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/agent_toy.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/cred.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/__init__.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/_dag.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/_git.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/_model.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/_mutate.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/_paths.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/_render.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/_server.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/check.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/clone.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/edit.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/pull.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/serve.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/show.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/views.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/harness/web/index.html +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/image.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/ngrok.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/route/__init__.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/route/_dns.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/route/_dpi.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/route/_hosts.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/route/_probe.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/route/check.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/route/fix.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/route/probe.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/route/status.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/serp.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/tts.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/console.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/credentials/oauth_flow.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/imaging/__init__.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/imaging/align.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/imaging/core.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/imaging/creds.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/imaging/errors.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/imaging/gemini.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/imaging/ncnn.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/serp/__init__.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/serp/client.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/serp/creds.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/serp/errors.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/serp/install.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/serp/render.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/tts/__init__.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/tts/chunking.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/tts/core.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/tts/creds.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/tts/errors.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/tts/gemini.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/tts/http.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/tts/openai.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/tts/player.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli/tts/vbee.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/pyproject.toml +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/setup.cfg +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/__init__.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_agent_toy.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_claude_code.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_cli.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_console.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_cred.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_download.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_gdrive.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_gh.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_harness.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_harness_clone.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_harness_dag.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_image.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_mcp.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_ngrok.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_opencode.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_plantuml.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_route.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_serp.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_sysmon.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_tts.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_update.py +0 -0
- {evo_cli-0.25.1 → evo_cli-0.26.0}/tests/test_wifi.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evo_cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.26.0
|
|
4
4
|
Summary: Evolution CLI - a developer toolbox for setting up dev machines
|
|
5
5
|
Author: maycuatroi
|
|
6
6
|
Project-URL: Homepage, https://github.com/maycuatroi/evo-cli
|
|
@@ -119,6 +119,59 @@ Options:
|
|
|
119
119
|
- `-f, --force` - Apply the fix even if the version is not in the affected range
|
|
120
120
|
- `--unpin` - Undo the fix: re-enable the auto-updater and install the latest build
|
|
121
121
|
|
|
122
|
+
#### OpenVPN
|
|
123
|
+
|
|
124
|
+
Manage separate VPN profiles on macOS or Linux. Install the OpenVPN binary first
|
|
125
|
+
(`brew install openvpn` on macOS); QR enrollment also needs `brew install zbar`.
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
evo openvpn list --app # inventory OpenVPN Connect, without changing it
|
|
129
|
+
evo openvpn import work ~/Downloads/work.ovpn # also accepts .ovpn.txt
|
|
130
|
+
evo openvpn credentials work --username alice --qr ~/Downloads/otp.png
|
|
131
|
+
evo openvpn list # numbered table: state, tunnel IP, remote
|
|
132
|
+
evo openvpn connect work # hidden sudo prompt, then a live status view
|
|
133
|
+
evo openvpn connect 2 # by # from list; omit to pick (last one is default)
|
|
134
|
+
evo openvpn status -w # watch again later; alerts on drops
|
|
135
|
+
evo openvpn disconnect # picks the active tunnel
|
|
136
|
+
evo cred sync push
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`connect`, `status`, `disconnect`, `otp` and `credentials` take a profile name, its `#`
|
|
140
|
+
from `list`, or nothing: a single (or single active) profile is used directly, otherwise a
|
|
141
|
+
numbered prompt appears. Without a terminal, pass the name so scripts never block on a prompt.
|
|
142
|
+
|
|
143
|
+
After connecting in a terminal, `connect` keeps a live view of state, uptime, traffic and
|
|
144
|
+
reconnects (`--no-watch` returns immediately). Ctrl-C stops watching and offers to
|
|
145
|
+
disconnect; the tunnel otherwise keeps running. Soft drops (`ping-restart`,
|
|
146
|
+
`connection-reset`) reconnect automatically with a fresh OTP; the view logs each one and
|
|
147
|
+
raises a desktop notification, and exits 1 with the reason if the tunnel goes down.
|
|
148
|
+
|
|
149
|
+
Import each profile under a different name. Imports refuse to overwrite an existing
|
|
150
|
+
name unless `--replace` is supplied; replacement preserves that profile's credentials.
|
|
151
|
+
Configurations (including embedded private keys), passwords and TOTP enrollments live
|
|
152
|
+
under `openvpn.profiles.NAME` in `evo cred`. They sync through the existing private
|
|
153
|
+
GitHub credential repository. That store is plaintext, protected by local file
|
|
154
|
+
permissions and private-repository access, not an encrypted vault. Do not sync it to
|
|
155
|
+
a public repository or treat storing the password and OTP together as independent factors.
|
|
156
|
+
|
|
157
|
+
Use `credentials NAME --otp` for hidden manual enrollment input instead of a QR image.
|
|
158
|
+
`evo openvpn otp NAME` explicitly prints the current code; list and status never print
|
|
159
|
+
secrets. Sudo passwords are not stored. Automation can pass the sudo password on stdin
|
|
160
|
+
with `connect NAME --sudo-password-stdin`, not in an argument or environment variable.
|
|
161
|
+
|
|
162
|
+
Connections run in the background with a private Unix management socket and an
|
|
163
|
+
OTP-aware worker that remains available for reauthentication. Static challenges use
|
|
164
|
+
OpenVPN's SCRV1 protocol. Dynamic challenges and encrypted private-key prompts are not
|
|
165
|
+
supported. Only trusted, self-contained TUN client profiles are accepted; scripts,
|
|
166
|
+
plugins and external certificate/key files are refused. Runtime data uses
|
|
167
|
+
`~/.evo/openvpn` (override with `EVO_OPENVPN_HOME`).
|
|
168
|
+
|
|
169
|
+
Only one evo-managed tunnel runs at a time. Disconnect any GUI VPN before connecting
|
|
170
|
+
through evo; GUI profiles are never removed or modified. Server routes are applied,
|
|
171
|
+
but this backend does not change system DNS. Internal IP access works through the
|
|
172
|
+
tunnel; private DNS names may need your organization's DNS configuration. `CONNECTED`
|
|
173
|
+
confirms the tunnel, not the availability of a particular internal application.
|
|
174
|
+
|
|
122
175
|
#### Credentials
|
|
123
176
|
|
|
124
177
|
`evo` owns the omelet credential store. The source of truth is a folder of one JSON file per service
|
|
@@ -86,6 +86,59 @@ Options:
|
|
|
86
86
|
- `-f, --force` - Apply the fix even if the version is not in the affected range
|
|
87
87
|
- `--unpin` - Undo the fix: re-enable the auto-updater and install the latest build
|
|
88
88
|
|
|
89
|
+
#### OpenVPN
|
|
90
|
+
|
|
91
|
+
Manage separate VPN profiles on macOS or Linux. Install the OpenVPN binary first
|
|
92
|
+
(`brew install openvpn` on macOS); QR enrollment also needs `brew install zbar`.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
evo openvpn list --app # inventory OpenVPN Connect, without changing it
|
|
96
|
+
evo openvpn import work ~/Downloads/work.ovpn # also accepts .ovpn.txt
|
|
97
|
+
evo openvpn credentials work --username alice --qr ~/Downloads/otp.png
|
|
98
|
+
evo openvpn list # numbered table: state, tunnel IP, remote
|
|
99
|
+
evo openvpn connect work # hidden sudo prompt, then a live status view
|
|
100
|
+
evo openvpn connect 2 # by # from list; omit to pick (last one is default)
|
|
101
|
+
evo openvpn status -w # watch again later; alerts on drops
|
|
102
|
+
evo openvpn disconnect # picks the active tunnel
|
|
103
|
+
evo cred sync push
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`connect`, `status`, `disconnect`, `otp` and `credentials` take a profile name, its `#`
|
|
107
|
+
from `list`, or nothing: a single (or single active) profile is used directly, otherwise a
|
|
108
|
+
numbered prompt appears. Without a terminal, pass the name so scripts never block on a prompt.
|
|
109
|
+
|
|
110
|
+
After connecting in a terminal, `connect` keeps a live view of state, uptime, traffic and
|
|
111
|
+
reconnects (`--no-watch` returns immediately). Ctrl-C stops watching and offers to
|
|
112
|
+
disconnect; the tunnel otherwise keeps running. Soft drops (`ping-restart`,
|
|
113
|
+
`connection-reset`) reconnect automatically with a fresh OTP; the view logs each one and
|
|
114
|
+
raises a desktop notification, and exits 1 with the reason if the tunnel goes down.
|
|
115
|
+
|
|
116
|
+
Import each profile under a different name. Imports refuse to overwrite an existing
|
|
117
|
+
name unless `--replace` is supplied; replacement preserves that profile's credentials.
|
|
118
|
+
Configurations (including embedded private keys), passwords and TOTP enrollments live
|
|
119
|
+
under `openvpn.profiles.NAME` in `evo cred`. They sync through the existing private
|
|
120
|
+
GitHub credential repository. That store is plaintext, protected by local file
|
|
121
|
+
permissions and private-repository access, not an encrypted vault. Do not sync it to
|
|
122
|
+
a public repository or treat storing the password and OTP together as independent factors.
|
|
123
|
+
|
|
124
|
+
Use `credentials NAME --otp` for hidden manual enrollment input instead of a QR image.
|
|
125
|
+
`evo openvpn otp NAME` explicitly prints the current code; list and status never print
|
|
126
|
+
secrets. Sudo passwords are not stored. Automation can pass the sudo password on stdin
|
|
127
|
+
with `connect NAME --sudo-password-stdin`, not in an argument or environment variable.
|
|
128
|
+
|
|
129
|
+
Connections run in the background with a private Unix management socket and an
|
|
130
|
+
OTP-aware worker that remains available for reauthentication. Static challenges use
|
|
131
|
+
OpenVPN's SCRV1 protocol. Dynamic challenges and encrypted private-key prompts are not
|
|
132
|
+
supported. Only trusted, self-contained TUN client profiles are accepted; scripts,
|
|
133
|
+
plugins and external certificate/key files are refused. Runtime data uses
|
|
134
|
+
`~/.evo/openvpn` (override with `EVO_OPENVPN_HOME`).
|
|
135
|
+
|
|
136
|
+
Only one evo-managed tunnel runs at a time. Disconnect any GUI VPN before connecting
|
|
137
|
+
through evo; GUI profiles are never removed or modified. Server routes are applied,
|
|
138
|
+
but this backend does not change system DNS. Internal IP access works through the
|
|
139
|
+
tunnel; private DNS names may need your organization's DNS configuration. `CONNECTED`
|
|
140
|
+
confirms the tunnel, not the availability of a particular internal application.
|
|
141
|
+
|
|
89
142
|
#### Credentials
|
|
90
143
|
|
|
91
144
|
`evo` owns the omelet credential store. The source of truth is a folder of one JSON file per service
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.26.0
|
|
@@ -19,6 +19,7 @@ from evo_cli.commands.miniconda import miniconda
|
|
|
19
19
|
from evo_cli.commands.netcheck import netcheck
|
|
20
20
|
from evo_cli.commands.ngrok import setup_ngrok
|
|
21
21
|
from evo_cli.commands.opencode import setup_opencode
|
|
22
|
+
from evo_cli.commands.openvpn import openvpn_group
|
|
22
23
|
from evo_cli.commands.plantuml import plantuml
|
|
23
24
|
from evo_cli.commands.route import route_group
|
|
24
25
|
from evo_cli.commands.serp import serp_group, setup_serp
|
|
@@ -79,6 +80,7 @@ cli.add_command(site2s)
|
|
|
79
80
|
cli.add_command(localproxy)
|
|
80
81
|
cli.add_command(mcp_group)
|
|
81
82
|
cli.add_command(netcheck)
|
|
83
|
+
cli.add_command(openvpn_group)
|
|
82
84
|
cli.add_command(plantuml)
|
|
83
85
|
cli.add_command(route_group)
|
|
84
86
|
cli.add_command(serp_group)
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import rich_click as click
|
|
12
|
+
from rich.live import Live
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from evo_cli import openvpn as vpn
|
|
18
|
+
from evo_cli.commands.cred import _guard
|
|
19
|
+
from evo_cli.console import console
|
|
20
|
+
|
|
21
|
+
DOWN = ("STOPPED", "FAILED", "UNKNOWN")
|
|
22
|
+
# Anything else (WAIT, AUTH, GET_CONFIG, RECONNECTING...) is OpenVPN still working on it.
|
|
23
|
+
COLORS = {"CONNECTED": "green", "STOPPING": "dim", "STOPPED": "dim", "FAILED": "red", "UNKNOWN": "red"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _interactive():
|
|
27
|
+
return sys.stdin.isatty()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _state_text(state):
|
|
31
|
+
return Text(state, style=COLORS.get(state, "yellow"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _table(rows):
|
|
35
|
+
table = Table(header_style="accent", box=None, pad_edge=False)
|
|
36
|
+
for column in ("#", "NAME", "STATE", "TUNNEL IP", "REMOTE", "CREDS", "OTP"):
|
|
37
|
+
table.add_column(column)
|
|
38
|
+
for index, (name, profile, state) in enumerate(rows, 1):
|
|
39
|
+
table.add_row(
|
|
40
|
+
str(index),
|
|
41
|
+
name,
|
|
42
|
+
_state_text(state["state"]),
|
|
43
|
+
state.get("vpn_ip", ""),
|
|
44
|
+
(profile.get("remotes") or [""])[0],
|
|
45
|
+
"yes" if profile.get("password") else "no",
|
|
46
|
+
"yes" if profile.get("totp") else "no",
|
|
47
|
+
)
|
|
48
|
+
return table
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _rows():
|
|
52
|
+
profiles = _guard(vpn.profiles)
|
|
53
|
+
return [(name, profiles[name], _guard(vpn.request, name)) for name in sorted(profiles)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _pick(name, prefer_active=False):
|
|
57
|
+
"""Resolve NAME or its # from `evo openvpn list`; when omitted, auto-select or prompt."""
|
|
58
|
+
names = sorted(_guard(vpn.profiles))
|
|
59
|
+
if name:
|
|
60
|
+
if name.isdigit() and name not in names:
|
|
61
|
+
if not 1 <= int(name) <= len(names):
|
|
62
|
+
raise click.ClickException(f"No profile #{name}; run evo openvpn list.")
|
|
63
|
+
name = names[int(name) - 1]
|
|
64
|
+
_guard(vpn.get_profile, name)
|
|
65
|
+
return name
|
|
66
|
+
if not names:
|
|
67
|
+
raise click.ClickException("No profiles yet; run evo openvpn import NAME FILE.")
|
|
68
|
+
rows = _rows()
|
|
69
|
+
active = [row[0] for row in rows if row[2]["state"] != "STOPPED"]
|
|
70
|
+
if prefer_active and len(active) == 1:
|
|
71
|
+
name = active[0]
|
|
72
|
+
elif len(names) == 1:
|
|
73
|
+
name = names[0]
|
|
74
|
+
elif not _interactive():
|
|
75
|
+
raise click.ClickException("Several profiles; pass NAME or its # from evo openvpn list.")
|
|
76
|
+
else:
|
|
77
|
+
console.print(_table(rows))
|
|
78
|
+
last = _guard(vpn.last_profile)
|
|
79
|
+
default = names.index(last) + 1 if last in names else 1
|
|
80
|
+
return names[click.prompt("Profile #", type=click.IntRange(1, len(names)), default=default) - 1]
|
|
81
|
+
click.echo(f"Profile: {name}", err=True)
|
|
82
|
+
return name
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _duration(seconds):
|
|
86
|
+
minutes, seconds = divmod(int(seconds), 60)
|
|
87
|
+
hours, minutes = divmod(minutes, 60)
|
|
88
|
+
return f"{hours}:{minutes:02}:{seconds:02}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _size(count):
|
|
92
|
+
for unit in ("B", "KB", "MB"):
|
|
93
|
+
if count < 1024:
|
|
94
|
+
return f"{count:.0f} {unit}" if unit == "B" else f"{count:.1f} {unit}"
|
|
95
|
+
count /= 1024
|
|
96
|
+
return f"{count:.2f} GB"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _panel(name, state, remote):
|
|
100
|
+
grid = Table.grid(padding=(0, 2))
|
|
101
|
+
grid.add_column(style="dim")
|
|
102
|
+
grid.add_column()
|
|
103
|
+
grid.add_row("State", _state_text(state["state"]))
|
|
104
|
+
if state.get("vpn_ip"):
|
|
105
|
+
grid.add_row("Tunnel IP", state["vpn_ip"])
|
|
106
|
+
if remote:
|
|
107
|
+
grid.add_row("Remote", remote)
|
|
108
|
+
if state.get("since"):
|
|
109
|
+
grid.add_row("Uptime", _duration(time.time() - state["since"]))
|
|
110
|
+
if "bytes_in" in state:
|
|
111
|
+
grid.add_row("Traffic", f"down {_size(state['bytes_in'])} up {_size(state['bytes_out'])}")
|
|
112
|
+
if state.get("reconnects"):
|
|
113
|
+
grid.add_row("Reconnects", Text(f"{state['reconnects']} (last: {state.get('last_reason', '?')})"))
|
|
114
|
+
if state.get("error"):
|
|
115
|
+
grid.add_row("Error", Text(state["error"], style="red"))
|
|
116
|
+
return Panel(grid, title=f"[accent]{name}[/accent]", subtitle="[dim]Ctrl-C to stop watching[/dim]", expand=False)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _notify(message):
|
|
120
|
+
"""Ring the bell and raise a desktop notification, so a drop is noticed with the terminal hidden."""
|
|
121
|
+
console.bell()
|
|
122
|
+
if sys.platform == "darwin" and shutil.which("osascript"):
|
|
123
|
+
script = ["-e", "on run argv", "-e", 'display notification (item 1 of argv) with title "evo openvpn"']
|
|
124
|
+
command = ["osascript", *script, "-e", "end run", message]
|
|
125
|
+
elif shutil.which("notify-send"):
|
|
126
|
+
command = ["notify-send", "evo openvpn", message]
|
|
127
|
+
else:
|
|
128
|
+
return
|
|
129
|
+
try:
|
|
130
|
+
subprocess.run(command, capture_output=True, timeout=5, check=False)
|
|
131
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _event(out, name, state, previous):
|
|
136
|
+
status = state["state"]
|
|
137
|
+
line = Text(f"{datetime.now():%H:%M:%S} ")
|
|
138
|
+
line.append(status, style=COLORS.get(status, "yellow"))
|
|
139
|
+
detail = state.get("vpn_ip") or (state.get("last_reason") if status == "RECONNECTING" else state.get("error"))
|
|
140
|
+
if detail:
|
|
141
|
+
line.append(f" {detail}")
|
|
142
|
+
out.print(line)
|
|
143
|
+
if status == "RECONNECTING":
|
|
144
|
+
_notify(f"{name} dropped ({state.get('last_reason', 'unknown')}); reconnecting")
|
|
145
|
+
elif status == "CONNECTED" and previous != "CONNECTED" and state.get("reconnects"):
|
|
146
|
+
_notify(f"{name} reconnected")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _watch(name):
|
|
150
|
+
"""Live status until the tunnel goes down (exit 1) or Ctrl-C (offer to disconnect)."""
|
|
151
|
+
remote = (_guard(vpn.get_profile, name).get("remotes") or [""])[0]
|
|
152
|
+
state, previous, failures = {}, None, 0
|
|
153
|
+
try:
|
|
154
|
+
with Live(console=console, auto_refresh=False) as live:
|
|
155
|
+
while True:
|
|
156
|
+
try:
|
|
157
|
+
state, failures = vpn.request(name), 0
|
|
158
|
+
except (OSError, ValueError):
|
|
159
|
+
failures += 1
|
|
160
|
+
if failures < 5:
|
|
161
|
+
time.sleep(0.5)
|
|
162
|
+
continue
|
|
163
|
+
state = {"profile": name, "state": "UNKNOWN", "error": "Worker is not responding."}
|
|
164
|
+
if previous and (state["state"], state.get("vpn_ip")) != previous:
|
|
165
|
+
_event(live.console, name, state, previous[0])
|
|
166
|
+
previous = (state["state"], state.get("vpn_ip"))
|
|
167
|
+
live.update(_panel(name, state, remote), refresh=True)
|
|
168
|
+
if state["state"] in DOWN:
|
|
169
|
+
break
|
|
170
|
+
time.sleep(1)
|
|
171
|
+
except KeyboardInterrupt:
|
|
172
|
+
console.print()
|
|
173
|
+
if _interactive() and click.confirm(f"Disconnect {name}?", default=False):
|
|
174
|
+
_disconnect(name)
|
|
175
|
+
else:
|
|
176
|
+
console.print(f"[dim]{name} keeps running in the background.[/dim]")
|
|
177
|
+
console.print(f"[dim]Watch: evo openvpn status {name} -w | Stop: evo openvpn disconnect {name}[/dim]")
|
|
178
|
+
return
|
|
179
|
+
if state["state"] == "STOPPED" and not state.get("error"):
|
|
180
|
+
click.echo(f"{name}: stopped.")
|
|
181
|
+
return
|
|
182
|
+
message = state.get("error") or "tunnel is down."
|
|
183
|
+
_notify(f"{name} disconnected: {message}")
|
|
184
|
+
raise click.ClickException(f"{name} disconnected: {message}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@click.group("openvpn", help="Manage named OpenVPN profiles with credentials and TOTP in evo cred (macOS/Linux).")
|
|
188
|
+
def openvpn_group():
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@openvpn_group.command("list", help="List managed profiles, or inventory the OpenVPN Connect app without changing it.")
|
|
193
|
+
@click.option("--app", is_flag=True, help="Read the existing profiles in OpenVPN Connect (macOS).")
|
|
194
|
+
def list_profiles(app):
|
|
195
|
+
if app:
|
|
196
|
+
click.echo(json.dumps(_guard(vpn.app_profiles), indent=2))
|
|
197
|
+
return
|
|
198
|
+
rows = _rows()
|
|
199
|
+
if not rows:
|
|
200
|
+
click.echo("No profiles yet; run evo openvpn import NAME FILE.")
|
|
201
|
+
return
|
|
202
|
+
console.print(_table(rows))
|
|
203
|
+
console.print("[dim]Use a NAME or # in connect/status/disconnect, or omit it to pick interactively.[/dim]")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@openvpn_group.command("import", help="Import a trusted self-contained .ovpn (or .ovpn.txt) into evo cred.")
|
|
207
|
+
@click.argument("name")
|
|
208
|
+
@click.argument("config", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
209
|
+
@click.option(
|
|
210
|
+
"--replace", is_flag=True, help="Explicitly replace only this profile's config; preserve its credentials."
|
|
211
|
+
)
|
|
212
|
+
def import_profile(name, config, replace):
|
|
213
|
+
_guard(vpn.profile_name, name)
|
|
214
|
+
existing = _guard(vpn.profiles).get(name, {})
|
|
215
|
+
if existing and not replace:
|
|
216
|
+
raise click.ClickException("Profile already exists; use --replace to update only its configuration.")
|
|
217
|
+
if _guard(vpn.request, name)["state"] != "STOPPED":
|
|
218
|
+
raise click.ClickException("Disconnect this profile before replacing its configuration.")
|
|
219
|
+
text = config.read_text(encoding="utf-8-sig")
|
|
220
|
+
remotes = _guard(vpn.validate_config, text)
|
|
221
|
+
_guard(vpn.save_profile, name, {**existing, "config": text, "remotes": remotes})
|
|
222
|
+
click.echo(f"Imported {name} into evo cred: openvpn.profiles.{name}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@openvpn_group.command("credentials", help="Save username/password and optionally a TOTP enrollment; input is hidden.")
|
|
226
|
+
@click.argument("name", required=False)
|
|
227
|
+
@click.option("--username", help="VPN account name; password is prompted, never passed as an argument.")
|
|
228
|
+
@click.option("--password-stdin", is_flag=True, help="Read VPN password from stdin rather than the hidden prompt.")
|
|
229
|
+
@click.option("--otp", is_flag=True, help="Prompt for a base32 secret or otpauth://totp URI (not a one-time code).")
|
|
230
|
+
@click.option(
|
|
231
|
+
"--qr", type=click.Path(exists=True, dir_okay=False, path_type=Path), help="Read OTP from a local QR image."
|
|
232
|
+
)
|
|
233
|
+
def credentials(name, username, password_stdin, otp, qr):
|
|
234
|
+
if otp and qr:
|
|
235
|
+
raise click.UsageError("Use either --otp or --qr, not both.")
|
|
236
|
+
name = _pick(name)
|
|
237
|
+
profile = _guard(vpn.get_profile, name)
|
|
238
|
+
username = username or click.prompt("Username", default=profile.get("username", ""))
|
|
239
|
+
password = sys.stdin.readline().rstrip("\n") if password_stdin else getpass.getpass("VPN password (hidden): ")
|
|
240
|
+
if not password:
|
|
241
|
+
raise click.ClickException("Empty password; nothing saved.")
|
|
242
|
+
_guard(vpn.quote, username)
|
|
243
|
+
_guard(vpn.quote, password)
|
|
244
|
+
profile.update(username=username, password=password)
|
|
245
|
+
if qr:
|
|
246
|
+
profile["totp"] = _guard(vpn.qr_totp, qr)
|
|
247
|
+
elif otp:
|
|
248
|
+
profile["totp"] = _guard(vpn.parse_totp, getpass.getpass("OTP enrollment secret/URI (hidden): "))
|
|
249
|
+
_guard(vpn.save_profile, name, profile)
|
|
250
|
+
click.echo(f"Saved credentials for {name}. Sync explicitly with: evo cred sync push")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@openvpn_group.command("otp", help="Print the current OTP for one explicitly selected profile (sensitive output).")
|
|
254
|
+
@click.argument("name", required=False)
|
|
255
|
+
def otp_code(name):
|
|
256
|
+
name = _pick(name)
|
|
257
|
+
settings = _guard(vpn.get_profile, name).get("totp")
|
|
258
|
+
if not settings:
|
|
259
|
+
raise click.ClickException("No OTP enrollment for this profile.")
|
|
260
|
+
click.echo(_guard(vpn.totp, settings))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@openvpn_group.command("status", help="Query the live worker; stale status files never count as connected.")
|
|
264
|
+
@click.argument("name", required=False)
|
|
265
|
+
@click.option(
|
|
266
|
+
"-w", "--watch", is_flag=True, help="Live view until Ctrl-C; alerts on drops, exits 1 if the VPN goes down."
|
|
267
|
+
)
|
|
268
|
+
def status(name, watch):
|
|
269
|
+
name = _pick(name, prefer_active=True)
|
|
270
|
+
if watch:
|
|
271
|
+
_watch(name)
|
|
272
|
+
return
|
|
273
|
+
click.echo(json.dumps(_guard(vpn.request, name), indent=2))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@openvpn_group.command("disconnect", help="Disconnect only the named evo-managed connection, not the GUI profiles.")
|
|
277
|
+
@click.argument("name", required=False)
|
|
278
|
+
def disconnect(name):
|
|
279
|
+
_disconnect(_pick(name, prefer_active=True))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _disconnect(name):
|
|
283
|
+
state = _guard(vpn.request, name, "disconnect")
|
|
284
|
+
deadline = time.monotonic() + 20
|
|
285
|
+
while state["state"] != "STOPPED" and time.monotonic() < deadline:
|
|
286
|
+
time.sleep(0.2)
|
|
287
|
+
state = _guard(vpn.request, name)
|
|
288
|
+
if state["state"] != "STOPPED":
|
|
289
|
+
raise click.ClickException("Disconnect not confirmed; inspect evo openvpn status.")
|
|
290
|
+
click.echo(f"{name}: stopped (OpenVPN Connect profiles unchanged).")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@openvpn_group.command(
|
|
294
|
+
"connect", help="Connect in the background (OTP-aware worker), then show live status; Ctrl-C leaves it running."
|
|
295
|
+
)
|
|
296
|
+
@click.argument("name", required=False)
|
|
297
|
+
@click.option("--timeout", type=click.IntRange(10, 300), default=60, show_default=True)
|
|
298
|
+
@click.option("--sudo-password-stdin", is_flag=True, help="Read sudo password from stdin instead of a hidden prompt.")
|
|
299
|
+
@click.option(
|
|
300
|
+
"--watch/--no-watch", default=None, help="Show live status after connecting [default: when output is a terminal]."
|
|
301
|
+
)
|
|
302
|
+
def connect(name, timeout, sudo_password_stdin, watch):
|
|
303
|
+
if os.name != "posix":
|
|
304
|
+
raise click.ClickException("Managed connections currently require macOS or Linux.")
|
|
305
|
+
name = _pick(name)
|
|
306
|
+
profile = _guard(vpn.get_profile, name)
|
|
307
|
+
_guard(vpn.validate_config, profile["config"])
|
|
308
|
+
if not shutil.which("openvpn"):
|
|
309
|
+
raise click.ClickException("Install OpenVPN first (macOS: brew install openvpn; Linux: your package manager).")
|
|
310
|
+
if not profile.get("username") or not profile.get("password"):
|
|
311
|
+
raise click.ClickException("Missing credentials; run evo openvpn credentials PROFILE.")
|
|
312
|
+
if _guard(vpn.request, name)["state"] != "STOPPED":
|
|
313
|
+
raise click.ClickException("This profile already has a worker; use status or disconnect first.")
|
|
314
|
+
# Avoid unintentionally stacking two route sets. The external GUI is never controlled here.
|
|
315
|
+
for other in _guard(vpn.profiles):
|
|
316
|
+
if other != name and _guard(vpn.request, other)["state"] != "STOPPED":
|
|
317
|
+
raise click.ClickException(f"Disconnect '{other}' first to avoid conflicting VPN routes.")
|
|
318
|
+
password = ""
|
|
319
|
+
if os.geteuid() != 0:
|
|
320
|
+
if sudo_password_stdin:
|
|
321
|
+
password = sys.stdin.readline().rstrip("\n")
|
|
322
|
+
elif subprocess.run(["sudo", "-n", "true"], capture_output=True).returncode:
|
|
323
|
+
password = getpass.getpass("sudo password (not stored): ")
|
|
324
|
+
state_path = Path(str(_guard(vpn.session_path, name)) + ".json")
|
|
325
|
+
state_path.unlink(missing_ok=True)
|
|
326
|
+
process = subprocess.Popen(
|
|
327
|
+
[sys.executable, "-m", "evo_cli.openvpn", name, str(timeout)],
|
|
328
|
+
stdin=subprocess.PIPE,
|
|
329
|
+
stdout=subprocess.DEVNULL,
|
|
330
|
+
stderr=subprocess.DEVNULL,
|
|
331
|
+
start_new_session=True,
|
|
332
|
+
)
|
|
333
|
+
process.stdin.write((password + "\n").encode())
|
|
334
|
+
process.stdin.close()
|
|
335
|
+
password = ""
|
|
336
|
+
click.echo(f"Connecting {name}; disconnect any external GUI VPN first. System DNS is not modified.")
|
|
337
|
+
deadline = time.monotonic() + timeout + 20
|
|
338
|
+
try:
|
|
339
|
+
while time.monotonic() < deadline:
|
|
340
|
+
state = _guard(vpn.request, name)
|
|
341
|
+
if state["state"] == "CONNECTED":
|
|
342
|
+
click.echo(f"{name}: connected, tunnel IP {state.get('vpn_ip', 'unknown')}")
|
|
343
|
+
break
|
|
344
|
+
if process.poll() is not None:
|
|
345
|
+
if state_path.exists():
|
|
346
|
+
last = json.loads(state_path.read_text())
|
|
347
|
+
raise click.ClickException(last.get("error", "OpenVPN stopped before connecting."))
|
|
348
|
+
raise click.ClickException("OpenVPN worker exited before startup.")
|
|
349
|
+
time.sleep(0.2)
|
|
350
|
+
finally:
|
|
351
|
+
if _guard(vpn.request, name)["state"] != "CONNECTED":
|
|
352
|
+
_guard(vpn.request, name, "disconnect")
|
|
353
|
+
if state["state"] != "CONNECTED":
|
|
354
|
+
raise click.ClickException("Connection not confirmed before timeout; disconnect requested.")
|
|
355
|
+
_guard(vpn.remember, name)
|
|
356
|
+
if sys.stdout.isatty() if watch is None else watch:
|
|
357
|
+
_watch(name)
|
|
@@ -20,6 +20,17 @@ META_FIELDS = (
|
|
|
20
20
|
GENERATED_NOTE = "DO NOT EDIT - generated from credentials/ by `evo cred compile`. Source of truth is the folder."
|
|
21
21
|
|
|
22
22
|
SPECS = [
|
|
23
|
+
{
|
|
24
|
+
"path": "infra/openvpn.json",
|
|
25
|
+
"id": "openvpn",
|
|
26
|
+
"service": "OpenVPN profiles",
|
|
27
|
+
"category": "infra",
|
|
28
|
+
"type": "account",
|
|
29
|
+
"lifetime": "stable",
|
|
30
|
+
"description": "Named VPN configurations (including private keys), account credentials and TOTP enrollments",
|
|
31
|
+
"rotate": "VPN administrator: reissue profile/password and re-enroll TOTP",
|
|
32
|
+
"keys": ["openvpn"],
|
|
33
|
+
},
|
|
23
34
|
{
|
|
24
35
|
"path": "accounts/omelet.json",
|
|
25
36
|
"id": "omelet",
|