evo-cli 0.25.0__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.
Files changed (145) hide show
  1. {evo_cli-0.25.0 → evo_cli-0.26.0}/PKG-INFO +54 -1
  2. {evo_cli-0.25.0 → evo_cli-0.26.0}/README.md +53 -0
  3. evo_cli-0.26.0/evo_cli/VERSION +1 -0
  4. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/cli.py +2 -0
  5. evo_cli-0.26.0/evo_cli/commands/openvpn.py +357 -0
  6. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/credentials/registry.py +11 -0
  7. evo_cli-0.26.0/evo_cli/openvpn.py +450 -0
  8. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli.egg-info/PKG-INFO +54 -1
  9. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli.egg-info/SOURCES.txt +3 -0
  10. evo_cli-0.26.0/tests/test_openvpn.py +334 -0
  11. evo_cli-0.25.0/evo_cli/VERSION +0 -1
  12. {evo_cli-0.25.0 → evo_cli-0.26.0}/Containerfile +0 -0
  13. {evo_cli-0.25.0 → evo_cli-0.26.0}/HISTORY.md +0 -0
  14. {evo_cli-0.25.0 → evo_cli-0.26.0}/LICENSE +0 -0
  15. {evo_cli-0.25.0 → evo_cli-0.26.0}/MANIFEST.in +0 -0
  16. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/__init__.py +0 -0
  17. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/__main__.py +0 -0
  18. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/base.py +0 -0
  19. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/__init__.py +0 -0
  20. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/agent_toy.py +0 -0
  21. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/claude_code.py +0 -0
  22. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/cloudflare.py +0 -0
  23. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/cred.py +0 -0
  24. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/download.py +0 -0
  25. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/fix_claude.py +0 -0
  26. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/gdrive.py +0 -0
  27. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/gh.py +0 -0
  28. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/__init__.py +0 -0
  29. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/_dag.py +0 -0
  30. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/_git.py +0 -0
  31. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/_model.py +0 -0
  32. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/_mutate.py +0 -0
  33. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/_paths.py +0 -0
  34. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/_render.py +0 -0
  35. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/_server.py +0 -0
  36. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/check.py +0 -0
  37. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/clone.py +0 -0
  38. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/edit.py +0 -0
  39. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/pull.py +0 -0
  40. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/serve.py +0 -0
  41. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/show.py +0 -0
  42. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/views.py +0 -0
  43. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
  44. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
  45. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  46. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  47. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  48. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  49. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  50. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  51. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  52. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  53. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  54. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  55. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  56. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  57. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/harness/web/index.html +0 -0
  58. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/hwid.py +0 -0
  59. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/hwid_reset.py +0 -0
  60. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/image.py +0 -0
  61. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/localproxy.py +0 -0
  62. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/mcp.py +0 -0
  63. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/miniconda.py +0 -0
  64. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/netcheck.py +0 -0
  65. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/ngrok.py +0 -0
  66. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/opencode.py +0 -0
  67. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/plantuml.py +0 -0
  68. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/route/__init__.py +0 -0
  69. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/route/_dns.py +0 -0
  70. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/route/_dpi.py +0 -0
  71. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/route/_hosts.py +0 -0
  72. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/route/_probe.py +0 -0
  73. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/route/check.py +0 -0
  74. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/route/fix.py +0 -0
  75. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/route/probe.py +0 -0
  76. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/route/status.py +0 -0
  77. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/serp.py +0 -0
  78. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/site2s.py +0 -0
  79. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/ssh.py +0 -0
  80. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/sysmon.py +0 -0
  81. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/tts.py +0 -0
  82. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/update.py +0 -0
  83. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/commands/wifi.py +0 -0
  84. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/console.py +0 -0
  85. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/credentials/__init__.py +0 -0
  86. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/credentials/doctor.py +0 -0
  87. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/credentials/google_oauth.py +0 -0
  88. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/credentials/migrate.py +0 -0
  89. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/credentials/oauth_flow.py +0 -0
  90. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/credentials/store.py +0 -0
  91. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/credentials/sync.py +0 -0
  92. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/imaging/__init__.py +0 -0
  93. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/imaging/align.py +0 -0
  94. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/imaging/core.py +0 -0
  95. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/imaging/creds.py +0 -0
  96. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/imaging/errors.py +0 -0
  97. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/imaging/gemini.py +0 -0
  98. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/imaging/ncnn.py +0 -0
  99. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/mcp_registry.py +0 -0
  100. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/serp/__init__.py +0 -0
  101. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/serp/client.py +0 -0
  102. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/serp/creds.py +0 -0
  103. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/serp/errors.py +0 -0
  104. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/serp/install.py +0 -0
  105. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/serp/render.py +0 -0
  106. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/tts/__init__.py +0 -0
  107. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/tts/chunking.py +0 -0
  108. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/tts/core.py +0 -0
  109. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/tts/creds.py +0 -0
  110. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/tts/errors.py +0 -0
  111. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/tts/gemini.py +0 -0
  112. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/tts/http.py +0 -0
  113. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/tts/openai.py +0 -0
  114. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/tts/player.py +0 -0
  115. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli/tts/vbee.py +0 -0
  116. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  117. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli.egg-info/entry_points.txt +0 -0
  118. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli.egg-info/requires.txt +0 -0
  119. {evo_cli-0.25.0 → evo_cli-0.26.0}/evo_cli.egg-info/top_level.txt +0 -0
  120. {evo_cli-0.25.0 → evo_cli-0.26.0}/pyproject.toml +0 -0
  121. {evo_cli-0.25.0 → evo_cli-0.26.0}/setup.cfg +0 -0
  122. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/__init__.py +0 -0
  123. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_agent_toy.py +0 -0
  124. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_claude_code.py +0 -0
  125. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_cli.py +0 -0
  126. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_console.py +0 -0
  127. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_cred.py +0 -0
  128. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_download.py +0 -0
  129. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_fix_claude.py +0 -0
  130. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_gdrive.py +0 -0
  131. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_gh.py +0 -0
  132. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_harness.py +0 -0
  133. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_harness_clone.py +0 -0
  134. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_harness_dag.py +0 -0
  135. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_image.py +0 -0
  136. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_mcp.py +0 -0
  137. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_ngrok.py +0 -0
  138. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_opencode.py +0 -0
  139. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_plantuml.py +0 -0
  140. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_route.py +0 -0
  141. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_serp.py +0 -0
  142. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_sysmon.py +0 -0
  143. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_tts.py +0 -0
  144. {evo_cli-0.25.0 → evo_cli-0.26.0}/tests/test_update.py +0 -0
  145. {evo_cli-0.25.0 → 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.25.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",