evo-cli 0.31.3__tar.gz → 0.32.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 (155) hide show
  1. {evo_cli-0.31.3 → evo_cli-0.32.0}/PKG-INFO +3 -1
  2. {evo_cli-0.31.3 → evo_cli-0.32.0}/README.md +2 -0
  3. evo_cli-0.32.0/evo_cli/VERSION +1 -0
  4. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/openvpn.py +39 -14
  5. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/openvpn.py +76 -24
  6. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli.egg-info/PKG-INFO +3 -1
  7. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_openvpn.py +120 -5
  8. evo_cli-0.31.3/evo_cli/VERSION +0 -1
  9. {evo_cli-0.31.3 → evo_cli-0.32.0}/Containerfile +0 -0
  10. {evo_cli-0.31.3 → evo_cli-0.32.0}/HISTORY.md +0 -0
  11. {evo_cli-0.31.3 → evo_cli-0.32.0}/LICENSE +0 -0
  12. {evo_cli-0.31.3 → evo_cli-0.32.0}/MANIFEST.in +0 -0
  13. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/__init__.py +0 -0
  14. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/__main__.py +0 -0
  15. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/base.py +0 -0
  16. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/cli.py +0 -0
  17. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/__init__.py +0 -0
  18. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/agent_toy.py +0 -0
  19. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/claude_code.py +0 -0
  20. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/cloudflare.py +0 -0
  21. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/cred.py +0 -0
  22. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/download.py +0 -0
  23. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/fix_claude.py +0 -0
  24. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/gdrive.py +0 -0
  25. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/gh.py +0 -0
  26. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/__init__.py +0 -0
  27. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/_dag.py +0 -0
  28. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/_git.py +0 -0
  29. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/_hub.py +0 -0
  30. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/_mirror.py +0 -0
  31. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/_model.py +0 -0
  32. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/_mutate.py +0 -0
  33. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/_paths.py +0 -0
  34. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/_render.py +0 -0
  35. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/_server.py +0 -0
  36. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/check.py +0 -0
  37. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/clone.py +0 -0
  38. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/edit.py +0 -0
  39. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/pull.py +0 -0
  40. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/serve.py +0 -0
  41. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/show.py +0 -0
  42. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/views.py +0 -0
  43. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
  44. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
  45. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  46. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  47. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  48. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  49. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  50. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  51. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  52. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  53. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  54. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  55. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  56. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  57. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/harness/web/index.html +0 -0
  58. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/hwid.py +0 -0
  59. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/hwid_reset.py +0 -0
  60. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/image.py +0 -0
  61. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/localproxy.py +0 -0
  62. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/mcp.py +0 -0
  63. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/miniconda.py +0 -0
  64. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/netcheck.py +0 -0
  65. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/ngrok.py +0 -0
  66. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/opencode.py +0 -0
  67. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/plantuml.py +0 -0
  68. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/route/__init__.py +0 -0
  69. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/route/_dns.py +0 -0
  70. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/route/_dpi.py +0 -0
  71. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/route/_hosts.py +0 -0
  72. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/route/_probe.py +0 -0
  73. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/route/_spoofdpi.py +0 -0
  74. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/route/check.py +0 -0
  75. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/route/fix.py +0 -0
  76. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/route/probe.py +0 -0
  77. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/route/status.py +0 -0
  78. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/serp.py +0 -0
  79. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/site2s.py +0 -0
  80. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/ssh.py +0 -0
  81. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/storage.py +0 -0
  82. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/sysmon.py +0 -0
  83. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/tts.py +0 -0
  84. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/update.py +0 -0
  85. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/commands/wifi.py +0 -0
  86. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/console.py +0 -0
  87. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/credentials/__init__.py +0 -0
  88. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/credentials/doctor.py +0 -0
  89. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/credentials/google_oauth.py +0 -0
  90. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/credentials/migrate.py +0 -0
  91. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/credentials/oauth_flow.py +0 -0
  92. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/credentials/registry.py +0 -0
  93. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/credentials/store.py +0 -0
  94. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/credentials/sync.py +0 -0
  95. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/imaging/__init__.py +0 -0
  96. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/imaging/align.py +0 -0
  97. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/imaging/core.py +0 -0
  98. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/imaging/creds.py +0 -0
  99. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/imaging/errors.py +0 -0
  100. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/imaging/gemini.py +0 -0
  101. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/imaging/ncnn.py +0 -0
  102. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/mcp_registry.py +0 -0
  103. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/serp/__init__.py +0 -0
  104. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/serp/client.py +0 -0
  105. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/serp/creds.py +0 -0
  106. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/serp/errors.py +0 -0
  107. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/serp/install.py +0 -0
  108. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/serp/render.py +0 -0
  109. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/tts/__init__.py +0 -0
  110. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/tts/chunking.py +0 -0
  111. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/tts/core.py +0 -0
  112. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/tts/creds.py +0 -0
  113. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/tts/errors.py +0 -0
  114. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/tts/gemini.py +0 -0
  115. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/tts/http.py +0 -0
  116. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/tts/openai.py +0 -0
  117. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/tts/player.py +0 -0
  118. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli/tts/vbee.py +0 -0
  119. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli.egg-info/SOURCES.txt +0 -0
  120. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli.egg-info/dependency_links.txt +0 -0
  121. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli.egg-info/entry_points.txt +0 -0
  122. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli.egg-info/requires.txt +0 -0
  123. {evo_cli-0.31.3 → evo_cli-0.32.0}/evo_cli.egg-info/top_level.txt +0 -0
  124. {evo_cli-0.31.3 → evo_cli-0.32.0}/pyproject.toml +0 -0
  125. {evo_cli-0.31.3 → evo_cli-0.32.0}/setup.cfg +0 -0
  126. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/__init__.py +0 -0
  127. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/fake_evo_agents.py +0 -0
  128. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/fixtures/hub/contract-0.2.0.json +0 -0
  129. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/fixtures/hub/mirror-demo.yaml +0 -0
  130. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_agent_toy.py +0 -0
  131. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_claude_code.py +0 -0
  132. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_cli.py +0 -0
  133. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_console.py +0 -0
  134. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_cred.py +0 -0
  135. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_download.py +0 -0
  136. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_fix_claude.py +0 -0
  137. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_gdrive.py +0 -0
  138. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_gh.py +0 -0
  139. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_harness.py +0 -0
  140. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_harness_clone.py +0 -0
  141. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_harness_dag.py +0 -0
  142. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_harness_hub.py +0 -0
  143. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_harness_show.py +0 -0
  144. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_image.py +0 -0
  145. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_mcp.py +0 -0
  146. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_ngrok.py +0 -0
  147. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_opencode.py +0 -0
  148. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_plantuml.py +0 -0
  149. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_route.py +0 -0
  150. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_serp.py +0 -0
  151. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_storage.py +0 -0
  152. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_sysmon.py +0 -0
  153. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_tts.py +0 -0
  154. {evo_cli-0.31.3 → evo_cli-0.32.0}/tests/test_update.py +0 -0
  155. {evo_cli-0.31.3 → evo_cli-0.32.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.31.3
3
+ Version: 0.32.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
@@ -159,6 +159,8 @@ permissions and private-repository access, not an encrypted vault. Do not sync i
159
159
  a public repository or treat storing the password and OTP together as independent factors.
160
160
 
161
161
  Use `credentials NAME --otp` for hidden manual enrollment input instead of a QR image.
162
+ For a PIN+OTP server whose codes come from a link, `credentials NAME --otp-url` saves the
163
+ https link (hidden input; it is a secret) and each login sends the PIN plus a fresh code.
162
164
  `evo openvpn otp NAME` explicitly prints the current code; list and status never print
163
165
  secrets. Sudo passwords are not stored. Automation can pass the sudo password on stdin
164
166
  with `connect NAME --sudo-password-stdin`, not in an argument or environment variable.
@@ -126,6 +126,8 @@ permissions and private-repository access, not an encrypted vault. Do not sync i
126
126
  a public repository or treat storing the password and OTP together as independent factors.
127
127
 
128
128
  Use `credentials NAME --otp` for hidden manual enrollment input instead of a QR image.
129
+ For a PIN+OTP server whose codes come from a link, `credentials NAME --otp-url` saves the
130
+ https link (hidden input; it is a secret) and each login sends the PIN plus a fresh code.
129
131
  `evo openvpn otp NAME` explicitly prints the current code; list and status never print
130
132
  secrets. Sudo passwords are not stored. Automation can pass the sudo password on stdin
131
133
  with `connect NAME --sudo-password-stdin`, not in an argument or environment variable.
@@ -0,0 +1 @@
1
+ 0.32.0
@@ -43,7 +43,7 @@ def _table(rows):
43
43
  state.get("vpn_ip", ""),
44
44
  (profile.get("remotes") or [""])[0],
45
45
  _creds(profile),
46
- "yes" if profile.get("totp") else "no",
46
+ "url" if profile.get("otp_url") else "yes" if profile.get("totp") else "no",
47
47
  )
48
48
  return table
49
49
 
@@ -58,6 +58,17 @@ def _creds(profile):
58
58
  return "no"
59
59
 
60
60
 
61
+ def _ask(name, command="status"):
62
+ """vpn.request for polling loops, where a missed 2s reply is no verdict.
63
+
64
+ A worker stops answering for a moment while it fetches an OTP or waits for OpenVPN to exit.
65
+ """
66
+ try:
67
+ return _guard(vpn.request, name, command)
68
+ except OSError:
69
+ return {"profile": name, "state": "UNKNOWN", "error": "Worker did not answer in time."}
70
+
71
+
61
72
  def _rows():
62
73
  profiles = _guard(vpn.profiles)
63
74
  return [(name, profiles[name], _guard(vpn.request, name)) for name in sorted(profiles)]
@@ -233,7 +244,9 @@ def import_profile(name, config, replace):
233
244
  click.echo(f"Imported {name} into evo cred: openvpn.profiles.{name}")
234
245
 
235
246
 
236
- @openvpn_group.command("credentials", help="Save username/password and optionally a TOTP enrollment; input is hidden.")
247
+ @openvpn_group.command(
248
+ "credentials", help="Save username/password and optionally an OTP source (TOTP or URL); input is hidden."
249
+ )
237
250
  @click.argument("name", required=False)
238
251
  @click.option("--username", help="VPN account name; password is prompted, never passed as an argument.")
239
252
  @click.option("--password-stdin", is_flag=True, help="Read VPN password from stdin rather than the hidden prompt.")
@@ -241,22 +254,34 @@ def import_profile(name, config, replace):
241
254
  @click.option(
242
255
  "--qr", type=click.Path(exists=True, dir_okay=False, path_type=Path), help="Read OTP from a local QR image."
243
256
  )
244
- def credentials(name, username, password_stdin, otp, qr):
245
- if otp and qr:
246
- raise click.UsageError("Use either --otp or --qr, not both.")
257
+ @click.option(
258
+ "--otp-url",
259
+ is_flag=True,
260
+ help="Prompt for a link that answers with the current OTP; each login sends the password (PIN) plus a fresh code.",
261
+ )
262
+ def credentials(name, username, password_stdin, otp, qr, otp_url):
263
+ if otp + bool(qr) + otp_url > 1:
264
+ raise click.UsageError("Use only one of --otp, --qr and --otp-url.")
247
265
  name = _pick(name)
248
266
  profile = _guard(vpn.get_profile, name)
249
267
  username = username or click.prompt("Username", default=profile.get("username", ""))
250
- password = sys.stdin.readline().rstrip("\n") if password_stdin else getpass.getpass("VPN password (hidden): ")
268
+ label = "VPN password or PIN, without the OTP" if otp_url or profile.get("otp_url") else "VPN password"
269
+ password = sys.stdin.readline().rstrip("\n") if password_stdin else getpass.getpass(f"{label} (hidden): ")
251
270
  if not password:
252
271
  raise click.ClickException("Empty password; nothing saved.")
253
272
  _guard(vpn.quote, username)
254
273
  _guard(vpn.quote, password)
255
274
  profile.update(username=username, password=password)
275
+ # One OTP source per profile.
256
276
  if qr:
257
277
  profile["totp"] = _guard(vpn.qr_totp, qr)
278
+ profile.pop("otp_url", None)
258
279
  elif otp:
259
280
  profile["totp"] = _guard(vpn.parse_totp, getpass.getpass("OTP enrollment secret/URI (hidden): "))
281
+ profile.pop("otp_url", None)
282
+ elif otp_url:
283
+ profile["otp_url"] = _guard(vpn.parse_otp_url, getpass.getpass("OTP URL (hidden): "))
284
+ profile.pop("totp", None)
260
285
  _guard(vpn.save_profile, name, profile)
261
286
  click.echo(f"Saved credentials for {name}. Sync explicitly with: evo cred sync push")
262
287
 
@@ -265,10 +290,10 @@ def credentials(name, username, password_stdin, otp, qr):
265
290
  @click.argument("name", required=False)
266
291
  def otp_code(name):
267
292
  name = _pick(name)
268
- settings = _guard(vpn.get_profile, name).get("totp")
269
- if not settings:
293
+ profile = _guard(vpn.get_profile, name)
294
+ if not profile.get("totp") and not profile.get("otp_url"):
270
295
  raise click.ClickException("No OTP enrollment for this profile.")
271
- click.echo(_guard(vpn.totp, settings))
296
+ click.echo(_guard(vpn.current_otp, profile))
272
297
 
273
298
 
274
299
  @openvpn_group.command("status", help="Query the live worker; stale status files never count as connected.")
@@ -303,13 +328,13 @@ def disconnect(name):
303
328
 
304
329
 
305
330
  def _disconnect(name):
306
- state = _guard(vpn.request, name, "disconnect")
331
+ state = _ask(name, "disconnect")
307
332
  if state["state"] == "STOPPED" and state.get("error"):
308
333
  click.echo(f"{name} was not running; last error: {state['error']}", err=True)
309
334
  deadline = time.monotonic() + 20
310
335
  while state["state"] != "STOPPED" and time.monotonic() < deadline:
311
336
  time.sleep(0.2)
312
- state = _guard(vpn.request, name)
337
+ state = _ask(name)
313
338
  if state["state"] != "STOPPED":
314
339
  raise click.ClickException(state.get("error") or "Disconnect not confirmed; inspect evo openvpn status.")
315
340
  click.echo(f"{name}: stopped (OpenVPN Connect profiles unchanged).")
@@ -369,7 +394,7 @@ def connect(name, timeout, sudo_password_stdin, watch):
369
394
  deadline = time.monotonic() + timeout + 20
370
395
  try:
371
396
  while time.monotonic() < deadline:
372
- state = _guard(vpn.request, name)
397
+ state = _ask(name)
373
398
  if state["state"] == "CONNECTED":
374
399
  click.echo(f"{name}: connected, tunnel IP {state.get('vpn_ip', 'unknown')}")
375
400
  break
@@ -380,8 +405,8 @@ def connect(name, timeout, sudo_password_stdin, watch):
380
405
  raise click.ClickException("OpenVPN worker exited before startup.")
381
406
  time.sleep(0.2)
382
407
  finally:
383
- if _guard(vpn.request, name)["state"] != "CONNECTED":
384
- _guard(vpn.request, name, "disconnect")
408
+ if _ask(name)["state"] != "CONNECTED":
409
+ _ask(name, "disconnect")
385
410
  if state["state"] != "CONNECTED":
386
411
  raise click.ClickException("Connection not confirmed before timeout; disconnect requested.")
387
412
  _guard(vpn.remember, name)
@@ -238,12 +238,14 @@ def remote_addresses(remotes):
238
238
 
239
239
 
240
240
  def pin_remotes(command, remotes):
241
- """Wrap OpenVPN so the profile's servers stay on the physical gateway while it runs.
241
+ """Wrap OpenVPN so the given hosts (a remote's first word) stay on the physical gateway while it runs.
242
242
 
243
243
  A split-tunnel profile adds no host route for its own server, so a full-tunnel VPN that is (or later comes)
244
- up would carry this handshake inside itself and break it on either side's reconnect. The wrapper runs as
245
- root with OpenVPN: pin each server, run OpenVPN, unpin after it exits. Addresses are validated IPs, so
246
- they are safe to place in the script; the OpenVPN command is passed as arguments, never interpolated.
244
+ up would carry this handshake inside itself and break it on either side's reconnect. The same holds for
245
+ an OTP URL's host, which a reconnect needs while the routes still lead into the tunnel that is down. The
246
+ wrapper runs as root with OpenVPN: pin each host, run OpenVPN, unpin after it exits. Addresses are
247
+ validated IPs, so they are safe to place in the script; the OpenVPN command is passed as arguments, never
248
+ interpolated.
247
249
  """
248
250
  gateway, addresses = default_gateway(), remote_addresses(remotes)
249
251
  if not gateway or not addresses:
@@ -310,6 +312,43 @@ def totp(settings, at=None):
310
312
  return str(number % (10**digits)).zfill(digits)
311
313
 
312
314
 
315
+ def parse_otp_url(value):
316
+ """An https link that answers with the current OTP as bare digits; checked by fetching one code.
317
+
318
+ The link alone yields valid codes, so it is a secret: errors never echo it.
319
+ """
320
+ value = value.strip()
321
+ uri = urlparse(value)
322
+ if uri.scheme != "https" or not uri.hostname:
323
+ raise CredentialError("The OTP URL must be an https:// link.")
324
+ fetch_otp(value)
325
+ return value
326
+
327
+
328
+ def fetch_otp(url):
329
+ import http.client
330
+ import urllib.request
331
+
332
+ # Cloudflare answers the default Python-urllib agent with 403.
333
+ request = urllib.request.Request(url, headers={"User-Agent": "evo-cli"})
334
+ try:
335
+ with urllib.request.urlopen(request, timeout=5) as response:
336
+ code = response.read(64).decode(errors="replace").strip()
337
+ except (OSError, ValueError, http.client.HTTPException):
338
+ raise CredentialError("Cannot fetch the OTP from its URL; check the network and the link.") from None
339
+ if not re.fullmatch(r"\d{6,8}", code):
340
+ raise CredentialError("The OTP URL did not answer with a 6-8 digit code.")
341
+ return code
342
+
343
+
344
+ def current_otp(profile):
345
+ if profile.get("totp"):
346
+ return totp(profile["totp"])
347
+ if profile.get("otp_url"):
348
+ return fetch_otp(profile["otp_url"])
349
+ raise CredentialError("This profile needs OTP; run evo openvpn credentials PROFILE --otp or --otp-url.")
350
+
351
+
313
352
  def qr_totp(path):
314
353
  decoder = shutil.which("zbarimg")
315
354
  if not decoder:
@@ -423,6 +462,17 @@ def _reap(name, last):
423
462
  return state
424
463
 
425
464
 
465
+ def _saved_state(name):
466
+ path = Path(str(session_path(name)) + ".json")
467
+ if path.exists():
468
+ last = json.loads(path.read_text())
469
+ if last["state"] not in ("FAILED", "STOPPED") or last.get("cleanup_failed"):
470
+ return _reap(name, last)
471
+ if last.get("error"):
472
+ return {"profile": name, "state": "STOPPED", "error": last["error"]}
473
+ return {"profile": name, "state": "STOPPED"}
474
+
475
+
426
476
  def request(name, command="status"):
427
477
  endpoint = str(session_path(name)) + ".sock"
428
478
  with socket.socket(socket.AF_UNIX) as client:
@@ -430,20 +480,20 @@ def request(name, command="status"):
430
480
  try:
431
481
  client.connect(endpoint)
432
482
  except (FileNotFoundError, ConnectionRefusedError):
433
- path = Path(str(session_path(name)) + ".json")
434
- if path.exists():
435
- last = json.loads(path.read_text())
436
- if last["state"] not in ("FAILED", "STOPPED") or last.get("cleanup_failed"):
437
- return _reap(name, last)
438
- if last.get("error"):
439
- return {"profile": name, "state": "STOPPED", "error": last["error"]}
440
- return {"profile": name, "state": "STOPPED"}
441
- client.sendall(command.encode() + b"\n")
483
+ return _saved_state(name)
442
484
  # The worker closes after one reply; a log reply spans several reads.
443
485
  chunks = []
444
- while chunk := client.recv(65536):
445
- chunks.append(chunk)
446
- return json.loads(b"".join(chunks))
486
+ try:
487
+ client.sendall(command.encode() + b"\n")
488
+ while chunk := client.recv(65536):
489
+ chunks.append(chunk)
490
+ except (BrokenPipeError, ConnectionResetError):
491
+ chunks = []
492
+ if not chunks:
493
+ # A worker that is shutting down publishes its final state, then closes the control socket with
494
+ # this request still queued (EOF on macOS, reset on Linux). Its outcome is in the saved state.
495
+ return _saved_state(name)
496
+ return json.loads(b"".join(chunks))
447
497
 
448
498
 
449
499
  def quote(value):
@@ -464,12 +514,11 @@ def auth_commands(line, profile):
464
514
  if not username or not password:
465
515
  raise CredentialError("Missing username/password; run evo openvpn credentials PROFILE.")
466
516
  if "SC:" in line:
467
- settings = profile.get("totp")
468
- if not settings:
469
- raise CredentialError("This profile needs OTP; run evo openvpn credentials PROFILE --otp.")
470
- password = (
471
- f"SCRV1:{base64.b64encode(password.encode()).decode()}:{base64.b64encode(totp(settings).encode()).decode()}"
472
- )
517
+ code = current_otp(profile)
518
+ password = f"SCRV1:{base64.b64encode(password.encode()).decode()}:{base64.b64encode(code.encode()).decode()}"
519
+ elif profile.get("otp_url"):
520
+ # PIN+OTP servers take the code appended to the password; no challenge announces it.
521
+ password += fetch_otp(profile["otp_url"])
473
522
  return f'username "Auth" {quote(username)}\npassword "Auth" {quote(password)}\n'
474
523
 
475
524
 
@@ -543,8 +592,11 @@ def worker(name, timeout):
543
592
  "--verb",
544
593
  "3",
545
594
  ]
546
- if not takes_default_route(profile["config"]):
547
- command = pin_remotes(command, remotes)
595
+ pinned = [] if takes_default_route(profile["config"]) else remotes
596
+ if profile.get("otp_url"):
597
+ pinned = [*pinned, urlparse(profile["otp_url"]).hostname]
598
+ if pinned:
599
+ command = pin_remotes(command, pinned)
548
600
  if os.geteuid() != 0:
549
601
  command = ["sudo", "-S", "-p", "", "--"] + command
550
602
  process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.31.3
3
+ Version: 0.32.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
@@ -159,6 +159,8 @@ permissions and private-repository access, not an encrypted vault. Do not sync i
159
159
  a public repository or treat storing the password and OTP together as independent factors.
160
160
 
161
161
  Use `credentials NAME --otp` for hidden manual enrollment input instead of a QR image.
162
+ For a PIN+OTP server whose codes come from a link, `credentials NAME --otp-url` saves the
163
+ https link (hidden input; it is a secret) and each login sends the PIN plus a fresh code.
162
164
  `evo openvpn otp NAME` explicitly prints the current code; list and status never print
163
165
  secrets. Sudo passwords are not stored. Automation can pass the sudo password on stdin
164
166
  with `connect NAME --sudo-password-stdin`, not in an argument or environment variable.
@@ -266,6 +266,89 @@ def test_auth_token_is_a_notification_not_a_prompt():
266
266
  assert vpn.auth_commands(">PASSWORD:Auth-Token:ephemeral-secret", {}) == ""
267
267
 
268
268
 
269
+ OTP_URL = "https://otp.example/public/link-secret"
270
+
271
+
272
+ def test_otp_url_is_appended_without_a_challenge_and_scrv1_with_one(monkeypatch):
273
+ codes = iter(["123456", "654321"])
274
+ monkeypatch.setattr(vpn, "fetch_otp", lambda url: url == OTP_URL and next(codes))
275
+ profile = {"username": "u", "password": "1307", "otp_url": OTP_URL}
276
+ # A fresh code at each login: OpenVPN asks again on every restart (--auth-nocache).
277
+ assert 'password "Auth" "1307123456"' in vpn.auth_commands("Need 'Auth' username/password", profile)
278
+ line = ">PASSWORD:Need 'Auth' username/password SC:1,OTP"
279
+ assert "SCRV1:MTMwNw==:NjU0MzIx" in vpn.auth_commands(line, profile)
280
+
281
+
282
+ def test_fetch_otp_checks_the_answer_without_echoing_the_link(monkeypatch):
283
+ import urllib.error
284
+
285
+ answers = iter([b"042137\n", b"<html>login</html>", urllib.error.HTTPError(OTP_URL, 403, "no", {}, None)])
286
+
287
+ class Response(io.BytesIO):
288
+ def __enter__(self):
289
+ return self
290
+
291
+ def __exit__(self, *_):
292
+ return None
293
+
294
+ def urlopen(request, timeout):
295
+ assert request.get_header("User-agent") == "evo-cli" and timeout <= 5
296
+ answer = next(answers)
297
+ if isinstance(answer, BaseException):
298
+ raise answer
299
+ return Response(answer)
300
+
301
+ monkeypatch.setattr("urllib.request.urlopen", urlopen)
302
+ assert vpn.fetch_otp(OTP_URL) == "042137"
303
+ for _ in range(2):
304
+ with pytest.raises(CredentialError) as exc:
305
+ vpn.fetch_otp(OTP_URL)
306
+ assert "link-secret" not in str(exc.value)
307
+ with pytest.raises(CredentialError) as exc:
308
+ vpn.parse_otp_url("http://otp.example/public/link-secret")
309
+ assert "link-secret" not in str(exc.value)
310
+
311
+
312
+ def test_credentials_otp_url_replaces_totp_and_serves_otp(store, monkeypatch):
313
+ vpn.save_profile("m1", {"config": CONFIG, "totp": vpn.parse_totp(SECRET)})
314
+ prompts = []
315
+
316
+ def getpass(prompt):
317
+ prompts.append(prompt)
318
+ return OTP_URL if prompt.startswith("OTP URL") else "1307"
319
+
320
+ monkeypatch.setattr("evo_cli.commands.openvpn.getpass.getpass", getpass)
321
+ monkeypatch.setattr(vpn, "fetch_otp", lambda url: "042137")
322
+ runner = CliRunner()
323
+ result = runner.invoke(cli, ["openvpn", "credentials", "m1", "--username", "u", "--otp-url"])
324
+ assert result.exit_code == 0, result.output
325
+ assert prompts[0].startswith("VPN password or PIN, without the OTP")
326
+ assert "link-secret" not in result.output and "1307" not in result.output
327
+ profile = vpn.get_profile("m1")
328
+ assert (profile["password"], profile["otp_url"], "totp" in profile) == ("1307", OTP_URL, False)
329
+ assert runner.invoke(cli, ["openvpn", "otp", "m1"]).output.strip() == "042137"
330
+ listing = runner.invoke(cli, ["openvpn", "list"]).output
331
+ assert "url" in listing and "link-secret" not in listing
332
+ assert runner.invoke(cli, ["openvpn", "credentials", "m1", "--otp", "--otp-url"]).exit_code == 2
333
+
334
+
335
+ def test_worker_pins_the_otp_host_even_for_a_full_tunnel(store, monkeypatch):
336
+ vpn.save_profile("m1", {"config": CONFIG, "username": "u", "password": "1307", "otp_url": OTP_URL})
337
+ monkeypatch.setattr(sys, "stdin", io.StringIO("\n"))
338
+ monkeypatch.setattr(vpn.os, "geteuid", lambda: 0)
339
+ monkeypatch.setattr(vpn.shutil, "which", lambda _: "/fake/openvpn")
340
+ pinned = []
341
+ monkeypatch.setattr(vpn, "pin_remotes", lambda command, hosts: pinned.append(hosts) or command)
342
+
343
+ def popen(command, **_):
344
+ raise OSError("stop before running anything")
345
+
346
+ monkeypatch.setattr(vpn.subprocess, "Popen", popen)
347
+ vpn.worker("m1", 10)
348
+ # Not the full-tunnel server (OpenVPN routes it itself), only the host a reconnect needs for its OTP.
349
+ assert pinned == [["otp.example"]]
350
+
351
+
269
352
  def test_plain_password_escaping_and_no_unrequested_otp():
270
353
  text = vpn.auth_commands("Need 'Auth' username/password", {"username": "u", "password": 'p"\\'})
271
354
  assert text == 'username "Auth" "u"\npassword "Auth" "p\\"\\\\"\n'
@@ -320,6 +403,41 @@ def test_dead_worker_is_reaped_and_disconnect_confirms_it(store, monkeypatch):
320
403
  assert vpn.request("m1")["state"] == "STOPPED" # so connect and import accept the profile again
321
404
 
322
405
 
406
+ def test_connect_reports_the_failure_of_a_worker_closing_with_a_request_queued(store, monkeypatch):
407
+ import socket
408
+
409
+ vpn.save_profile("m1", {"config": CONFIG, "username": "u", "password": "p"})
410
+ base = str(vpn.session_path("m1"))
411
+ monkeypatch.setattr("evo_cli.commands.openvpn.shutil.which", lambda _: "/fake/openvpn")
412
+ monkeypatch.setattr("evo_cli.commands.openvpn.os.geteuid", lambda: 0)
413
+ closed = threading.Event()
414
+
415
+ class ShuttingDown:
416
+ """A worker in its finally: final state published, control socket listening but never accepting."""
417
+
418
+ def __init__(self):
419
+ self.stdin = io.BytesIO()
420
+ self.control = socket.socket(socket.AF_UNIX)
421
+ self.control.bind(base + ".sock")
422
+ self.control.listen(2)
423
+ error = "VPN authentication rejected; check password/OTP enrollment before retrying."
424
+ vpn.private_write(Path(base + ".json"), json.dumps({"profile": "m1", "state": "FAILED", "error": error}))
425
+ threading.Timer(0.3, self.close).start()
426
+
427
+ def close(self):
428
+ self.control.close()
429
+ Path(base + ".sock").unlink()
430
+ closed.set()
431
+
432
+ def poll(self):
433
+ return 1 if closed.is_set() else None
434
+
435
+ monkeypatch.setattr("evo_cli.commands.openvpn.subprocess.Popen", lambda command, **_: ShuttingDown())
436
+ result = CliRunner().invoke(cli, ["openvpn", "connect", "m1", "--no-watch"])
437
+ assert result.exit_code == 1 and "VPN authentication rejected" in result.output, result.output
438
+ assert "Expecting value" not in result.output
439
+
440
+
323
441
  FAKE_OPENVPN = """
324
442
  import socket,sys
325
443
  sys.stdin.readline()
@@ -388,11 +506,8 @@ def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatc
388
506
  try:
389
507
  deadline = time.monotonic() + 8
390
508
  while thread.is_alive() and time.monotonic() < deadline:
391
- try:
392
- state = vpn.request("m1")
393
- except (ConnectionResetError, json.JSONDecodeError):
394
- time.sleep(0.05)
395
- continue
509
+ # No retry on reset or empty replies: request() must read through the worker's shutdown.
510
+ state = vpn.request("m1")
396
511
  if state["state"] == "CONNECTED" and (mode != "reconnect" or state.get("reconnects")):
397
512
  assert state["vpn_ip"] == "10.8.0.2"
398
513
  assert state["since"] and (state["bytes_in"], state["bytes_out"]) == (1024, 2048)
@@ -1 +0,0 @@
1
- 0.31.3
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes