evo-cli 0.33.0__tar.gz → 0.34.1__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 (156) hide show
  1. {evo_cli-0.33.0 → evo_cli-0.34.1}/PKG-INFO +1 -1
  2. evo_cli-0.34.1/evo_cli/VERSION +1 -0
  3. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/openvpn.py +6 -0
  4. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/openvpn.py +59 -1
  5. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli.egg-info/PKG-INFO +1 -1
  6. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_openvpn.py +78 -8
  7. evo_cli-0.33.0/evo_cli/VERSION +0 -1
  8. {evo_cli-0.33.0 → evo_cli-0.34.1}/Containerfile +0 -0
  9. {evo_cli-0.33.0 → evo_cli-0.34.1}/HISTORY.md +0 -0
  10. {evo_cli-0.33.0 → evo_cli-0.34.1}/LICENSE +0 -0
  11. {evo_cli-0.33.0 → evo_cli-0.34.1}/MANIFEST.in +0 -0
  12. {evo_cli-0.33.0 → evo_cli-0.34.1}/README.md +0 -0
  13. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/__init__.py +0 -0
  14. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/__main__.py +0 -0
  15. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/base.py +0 -0
  16. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/cli.py +0 -0
  17. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/__init__.py +0 -0
  18. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/agent_toy.py +0 -0
  19. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/claude_code.py +0 -0
  20. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/cloudflare.py +0 -0
  21. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/cred.py +0 -0
  22. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/download.py +0 -0
  23. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/fix_claude.py +0 -0
  24. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/gdrive.py +0 -0
  25. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/gh.py +0 -0
  26. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/__init__.py +0 -0
  27. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_dag.py +0 -0
  28. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_git.py +0 -0
  29. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_hub.py +0 -0
  30. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_mirror.py +0 -0
  31. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_model.py +0 -0
  32. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_mutate.py +0 -0
  33. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_paths.py +0 -0
  34. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_render.py +0 -0
  35. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_server.py +0 -0
  36. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/check.py +0 -0
  37. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/clone.py +0 -0
  38. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/edit.py +0 -0
  39. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/pull.py +0 -0
  40. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/serve.py +0 -0
  41. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/show.py +0 -0
  42. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/views.py +0 -0
  43. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
  44. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
  45. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  46. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  47. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  48. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  49. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  50. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  51. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  52. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  53. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  54. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  55. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  56. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  57. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/index.html +0 -0
  58. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/hwid.py +0 -0
  59. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/hwid_reset.py +0 -0
  60. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/image.py +0 -0
  61. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/localproxy.py +0 -0
  62. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/mcp.py +0 -0
  63. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/miniconda.py +0 -0
  64. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/netcheck.py +0 -0
  65. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/ngrok.py +0 -0
  66. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/opencode.py +0 -0
  67. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/plantuml.py +0 -0
  68. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/__init__.py +0 -0
  69. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/_dns.py +0 -0
  70. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/_dpi.py +0 -0
  71. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/_hosts.py +0 -0
  72. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/_page.py +0 -0
  73. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/_probe.py +0 -0
  74. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/_spoofdpi.py +0 -0
  75. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/check.py +0 -0
  76. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/fix.py +0 -0
  77. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/probe.py +0 -0
  78. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/status.py +0 -0
  79. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/serp.py +0 -0
  80. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/site2s.py +0 -0
  81. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/ssh.py +0 -0
  82. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/storage.py +0 -0
  83. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/sysmon.py +0 -0
  84. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/tts.py +0 -0
  85. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/update.py +0 -0
  86. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/wifi.py +0 -0
  87. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/console.py +0 -0
  88. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/__init__.py +0 -0
  89. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/doctor.py +0 -0
  90. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/google_oauth.py +0 -0
  91. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/migrate.py +0 -0
  92. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/oauth_flow.py +0 -0
  93. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/registry.py +0 -0
  94. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/store.py +0 -0
  95. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/sync.py +0 -0
  96. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/imaging/__init__.py +0 -0
  97. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/imaging/align.py +0 -0
  98. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/imaging/core.py +0 -0
  99. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/imaging/creds.py +0 -0
  100. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/imaging/errors.py +0 -0
  101. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/imaging/gemini.py +0 -0
  102. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/imaging/ncnn.py +0 -0
  103. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/mcp_registry.py +0 -0
  104. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/serp/__init__.py +0 -0
  105. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/serp/client.py +0 -0
  106. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/serp/creds.py +0 -0
  107. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/serp/errors.py +0 -0
  108. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/serp/install.py +0 -0
  109. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/serp/render.py +0 -0
  110. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/__init__.py +0 -0
  111. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/chunking.py +0 -0
  112. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/core.py +0 -0
  113. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/creds.py +0 -0
  114. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/errors.py +0 -0
  115. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/gemini.py +0 -0
  116. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/http.py +0 -0
  117. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/openai.py +0 -0
  118. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/player.py +0 -0
  119. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/vbee.py +0 -0
  120. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli.egg-info/SOURCES.txt +0 -0
  121. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli.egg-info/dependency_links.txt +0 -0
  122. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli.egg-info/entry_points.txt +0 -0
  123. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli.egg-info/requires.txt +0 -0
  124. {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli.egg-info/top_level.txt +0 -0
  125. {evo_cli-0.33.0 → evo_cli-0.34.1}/pyproject.toml +0 -0
  126. {evo_cli-0.33.0 → evo_cli-0.34.1}/setup.cfg +0 -0
  127. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/__init__.py +0 -0
  128. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/fake_evo_agents.py +0 -0
  129. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/fixtures/hub/contract-0.2.0.json +0 -0
  130. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/fixtures/hub/mirror-demo.yaml +0 -0
  131. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_agent_toy.py +0 -0
  132. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_claude_code.py +0 -0
  133. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_cli.py +0 -0
  134. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_console.py +0 -0
  135. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_cred.py +0 -0
  136. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_download.py +0 -0
  137. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_fix_claude.py +0 -0
  138. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_gdrive.py +0 -0
  139. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_gh.py +0 -0
  140. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_harness.py +0 -0
  141. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_harness_clone.py +0 -0
  142. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_harness_dag.py +0 -0
  143. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_harness_hub.py +0 -0
  144. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_harness_show.py +0 -0
  145. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_image.py +0 -0
  146. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_mcp.py +0 -0
  147. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_ngrok.py +0 -0
  148. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_opencode.py +0 -0
  149. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_plantuml.py +0 -0
  150. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_route.py +0 -0
  151. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_serp.py +0 -0
  152. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_storage.py +0 -0
  153. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_sysmon.py +0 -0
  154. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_tts.py +0 -0
  155. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_update.py +0 -0
  156. {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.33.0
3
+ Version: 0.34.1
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
@@ -0,0 +1 @@
1
+ 0.34.1
@@ -392,9 +392,15 @@ def connect(name, timeout, sudo_password_stdin, watch):
392
392
  password = ""
393
393
  click.echo(f"Connecting {name}; disconnect any external GUI VPN first. System DNS is not modified.")
394
394
  deadline = time.monotonic() + timeout + 20
395
+ shown = None
395
396
  try:
396
397
  while time.monotonic() < deadline:
397
398
  state = _ask(name)
399
+ if state.get("auth_url") and state["auth_url"] != shown:
400
+ shown = state["auth_url"]
401
+ click.echo(f"Sign in to finish connecting (opened in your browser): {shown}")
402
+ if state.get("auth_expires"):
403
+ deadline = max(deadline, time.monotonic() + state["auth_expires"] - time.time() + 20)
398
404
  if state["state"] == "CONNECTED":
399
405
  click.echo(f"{name}: connected, tunnel IP {state.get('vpn_ip', 'unknown')}")
400
406
  break
@@ -45,6 +45,7 @@ OPTIONS = {
45
45
  "connect-retry",
46
46
  "connect-retry-max",
47
47
  "connect-timeout",
48
+ "server-poll-timeout",
48
49
  "float",
49
50
  "tun-mtu",
50
51
  "mssfix",
@@ -502,12 +503,26 @@ def quote(value):
502
503
  return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
503
504
 
504
505
 
506
+ def rejection_reason(line):
507
+ """The server's text in a Verification Failed notice, for the user; a dynamic challenge keeps only its prompt.
508
+
509
+ OpenVPN writes >PASSWORD:Verification Failed: 'Auth' ['REASON'] (the brackets are its own). A CRV1 challenge
510
+ carries a state ID and the username besides its prompt, so neither is shown.
511
+ """
512
+ reason = line.partition("['")[2].rpartition("']")[0]
513
+ if reason.startswith("CRV1:"):
514
+ return "dynamic challenge, not supported: " + reason.split(":", 4)[-1]
515
+ return reason
516
+
517
+
505
518
  def auth_commands(line, profile):
506
519
  # OpenVPN itself caches server-issued session tokens. This notification needs no reply.
507
520
  if line.startswith(">PASSWORD:Auth-Token:"):
508
521
  return ""
509
522
  if "Verification Failed" in line:
510
- raise CredentialError("VPN authentication rejected; check password/OTP enrollment before retrying.")
523
+ reason = rejection_reason(line)
524
+ detail = f": {reason}" if reason else "; check password/OTP enrollment before retrying"
525
+ raise CredentialError(f"VPN authentication rejected{detail}.")
511
526
  if "Need 'Auth' username/password" not in line:
512
527
  raise CredentialError("Unsupported authentication challenge (private-key password or dynamic challenge).")
513
528
  username, password = profile.get("username", ""), profile.get("password", "")
@@ -522,6 +537,30 @@ def auth_commands(line, profile):
522
537
  return f'username "Auth" {quote(username)}\npassword "Auth" {quote(password)}\n'
523
538
 
524
539
 
540
+ def web_auth_url(message):
541
+ """The sign-in URL of a pending-auth INFOMSG (WEB_AUTH:flags:url or the older OPEN_URL:url), else None."""
542
+ if message.startswith("WEB_AUTH:"):
543
+ # No proxy is announced, so flags hold no colon.
544
+ url = message[9:].partition(":")[2]
545
+ elif message.startswith("OPEN_URL:"):
546
+ url = message[9:]
547
+ else:
548
+ return None
549
+ if urlparse(url).scheme != "https" or any(c.isspace() or not c.isprintable() for c in url):
550
+ raise CredentialError("VPN server sent an unusable sign-in URL.")
551
+ return url
552
+
553
+
554
+ def open_url(url):
555
+ """Open the sign-in page in the user's browser; the worker runs as the user, never as root."""
556
+ opener = shutil.which("open" if sys.platform == "darwin" else "xdg-open")
557
+ if opener:
558
+ try:
559
+ subprocess.Popen([opener, url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
560
+ except OSError:
561
+ pass
562
+
563
+
525
564
  def worker(name, timeout):
526
565
  """Keep the management channel alive for reauthentication; never persist raw VPN logs."""
527
566
  import fcntl
@@ -592,6 +631,10 @@ def worker(name, timeout):
592
631
  "--verb",
593
632
  "3",
594
633
  ]
634
+ if not uses_credentials(profile["config"]):
635
+ # With no password to ask for, a server can add a sign-in step only through the browser (Access
636
+ # Server SAML); announce it, or such a server refuses the client.
637
+ command += ["--setenv", "IV_SSO", "openurl,webauth"]
595
638
  pinned = [] if takes_default_route(profile["config"]) else remotes
596
639
  if profile.get("otp_url"):
597
640
  pinned = [*pinned, urlparse(profile["otp_url"]).hostname]
@@ -662,6 +705,12 @@ def worker(name, timeout):
662
705
  if stamp.isdigit():
663
706
  clock = time.strftime("%H:%M:%S", time.localtime(int(stamp)))
664
707
  log.append(f"{clock} {rest.partition(',')[2]}")
708
+ elif line.startswith(">INFOMSG:"):
709
+ url = web_auth_url(line[9:])
710
+ if url:
711
+ state["auth_url"] = url
712
+ publish()
713
+ open_url(url)
665
714
  elif line.startswith(">FATAL:"):
666
715
  raise CredentialError("OpenVPN reported a fatal error; check config, TLS and permissions.")
667
716
  elif line.startswith(">STATE:"):
@@ -671,11 +720,20 @@ def worker(name, timeout):
671
720
  if fields[1] == "CONNECTED" and fields[2] == "SUCCESS":
672
721
  state["vpn_ip"] = fields[3]
673
722
  state["since"] = time.time()
723
+ state.pop("auth_url", None)
724
+ state.pop("auth_expires", None)
674
725
  # Once up, let OpenVPN retry for as long as the network is gone.
675
726
  deadline = float("inf")
676
727
  elif fields[1] != "CONNECTED":
677
728
  state.pop("vpn_ip", None)
678
729
  state.pop("since", None)
730
+ if fields[1] == "AUTH_PENDING":
731
+ # Signing in happens in the browser: allow the time the server gives it.
732
+ # "timeout 180" in OpenVPN 2.6, a bare number in older notes.
733
+ wait = fields[2].split()[-1] if fields[2] else ""
734
+ wait = int(wait) if wait.isdigit() else 300
735
+ deadline = max(deadline, time.monotonic() + wait)
736
+ state["auth_expires"] = time.time() + wait
679
737
  if fields[1] == "RECONNECTING":
680
738
  state["reconnects"] = state.get("reconnects", 0) + 1
681
739
  if fields[1] in ("RECONNECTING", "EXITING"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo_cli
3
- Version: 0.33.0
3
+ Version: 0.34.1
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
@@ -104,6 +104,11 @@ def test_rejects_unsafe_or_external_config(directive):
104
104
  vpn.validate_config(CONFIG + directive + "\n")
105
105
 
106
106
 
107
+ def test_config_accepts_access_server_timing():
108
+ # OpenVPN Access Server profiles carry server-poll-timeout, an alias of connect-timeout.
109
+ assert vpn.validate_config(CONFIG + "server-poll-timeout 4\n") == ["vpn.example 1194 udp"]
110
+
111
+
107
112
  def test_config_accepts_inline_key_without_parsing_it():
108
113
  assert vpn.validate_config(CONFIG + "lport 0\n<key>\nPRIVATE SECRET\n</key>\n") == ["vpn.example 1194 udp"]
109
114
 
@@ -256,10 +261,23 @@ def test_static_challenge_uses_scrv1_and_fresh_totp(monkeypatch):
256
261
  assert "SCRV1:cGFzcw==:NjU0MzIx" in vpn.auth_commands(line, profile)
257
262
 
258
263
 
259
- def test_auth_rejection_does_not_retry_or_leak_challenge():
264
+ @pytest.mark.parametrize(
265
+ "line,shown,hidden",
266
+ [
267
+ (">PASSWORD:Verification Failed: 'Auth'", "check password/OTP enrollment", None),
268
+ (">PASSWORD:Verification Failed: 'Auth' ['SAML user does not match']", ": SAML user does not match.", None),
269
+ (
270
+ ">PASSWORD:Verification Failed: 'Auth' ['CRV1:R,E:state-id-secret:dXNlcg==:Enter token PIN']",
271
+ "dynamic challenge, not supported: Enter token PIN",
272
+ "state-id-secret",
273
+ ),
274
+ ],
275
+ )
276
+ def test_auth_rejection_shows_the_reason_but_not_the_challenge_state(line, shown, hidden):
260
277
  with pytest.raises(CredentialError) as exc:
261
- vpn.auth_commands(">PASSWORD:Verification Failed: 'Auth' ['sensitive']", {})
262
- assert "sensitive" not in str(exc.value)
278
+ vpn.auth_commands(line, {})
279
+ assert shown in str(exc.value)
280
+ assert not hidden or (hidden not in str(exc.value) and "dXNlcg==" not in str(exc.value))
263
281
 
264
282
 
265
283
  def test_auth_token_is_a_notification_not_a_prompt():
@@ -349,6 +367,26 @@ def test_worker_pins_the_otp_host_even_for_a_full_tunnel(store, monkeypatch):
349
367
  assert pinned == [["otp.example"]]
350
368
 
351
369
 
370
+ @pytest.mark.parametrize(
371
+ "message,expected",
372
+ [
373
+ ("WEB_AUTH::https://vpn.example/a?b=1", "https://vpn.example/a?b=1"),
374
+ ("WEB_AUTH:external:https://vpn.example/a", "https://vpn.example/a"),
375
+ ("OPEN_URL:https://vpn.example/a", "https://vpn.example/a"),
376
+ ("CR_TEXT:E,R:Enter code", None),
377
+ ("some other notice", None),
378
+ ],
379
+ )
380
+ def test_web_auth_url(message, expected):
381
+ assert vpn.web_auth_url(message) == expected
382
+
383
+
384
+ @pytest.mark.parametrize("url", ["http://vpn.example/a", "file:///etc/passwd", "https://vpn.example/a b", "-a"])
385
+ def test_web_auth_url_rejects_anything_but_plain_https(url):
386
+ with pytest.raises(CredentialError):
387
+ vpn.web_auth_url("WEB_AUTH::" + url)
388
+
389
+
352
390
  def test_plain_password_escaping_and_no_unrequested_otp():
353
391
  text = vpn.auth_commands("Need 'Auth' username/password", {"username": "u", "password": 'p"\\'})
354
392
  assert text == 'username "Auth" "u"\npassword "Auth" "p\\"\\\\"\n'
@@ -462,7 +500,12 @@ with socket.socket(socket.AF_UNIX) as s:
462
500
  seen = hold()
463
501
  assert {b'state on\\n', b'log on\\n', b'bytecount 2\\n'} <= seen
464
502
  held = b'hold off\\n' not in seen
465
- auth()
503
+ if sys.argv[2] == 'webauth':
504
+ # Access Server SAML: no password prompt, a browser sign-in instead.
505
+ s.sendall(b'>STATE:1,AUTH_PENDING,timeout 120,,,,,\\n')
506
+ s.sendall(b'>INFOMSG:WEB_AUTH::https://vpn.example/auth/1\\n')
507
+ else:
508
+ auth()
466
509
  if sys.argv[2] == 'fail':
467
510
  s.sendall(b\">PASSWORD:Verification Failed: 'Auth'\\n\")
468
511
  else:
@@ -480,12 +523,15 @@ with socket.socket(socket.AF_UNIX) as s:
480
523
  """
481
524
 
482
525
 
483
- @pytest.mark.parametrize("mode", ["ok", "fail", "reconnect"])
526
+ @pytest.mark.parametrize("mode", ["ok", "fail", "reconnect", "webauth"])
484
527
  def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatch, mode):
485
- fail = mode == "fail"
528
+ fail, webauth = mode == "fail", mode == "webauth"
529
+ config = CERT_ONLY if webauth else CONFIG
486
530
  vpn.save_profile(
487
- "m1", {"config": CONFIG, "username": "test", "password": "password-secret", "totp": vpn.parse_totp(SECRET)}
531
+ "m1", {"config": config, "username": "test", "password": "password-secret", "totp": vpn.parse_totp(SECRET)}
488
532
  )
533
+ opened = []
534
+ monkeypatch.setattr(vpn, "open_url", opened.append)
489
535
  monkeypatch.setattr(sys, "stdin", io.StringIO("not-a-real-sudo-password\n"))
490
536
  monkeypatch.setattr(vpn.os, "geteuid", lambda: 0)
491
537
  monkeypatch.setattr(vpn.shutil, "which", lambda _: "/fake/openvpn")
@@ -497,6 +543,8 @@ def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatc
497
543
  # A soft restart (ping-restart, connection-reset) must reconnect, not exit.
498
544
  assert "--remap-usr1" not in command and "--connect-retry-max" not in command
499
545
  assert command[command.index("--ping") + 1] == "10"
546
+ # Only a profile without a password announces browser sign-in.
547
+ assert ("IV_SSO" in command) == webauth
500
548
  endpoint = command[command.index("--management") + 1]
501
549
  return popen([sys.executable, "-c", FAKE_OPENVPN, endpoint, mode], **kwargs)
502
550
 
@@ -511,7 +559,7 @@ def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatc
511
559
  if state["state"] == "CONNECTED" and (mode != "reconnect" or state.get("reconnects")):
512
560
  assert state["vpn_ip"] == "10.8.0.2"
513
561
  assert state["since"] and (state["bytes_in"], state["bytes_out"]) == (1024, 2048)
514
- assert not fail
562
+ assert not fail and "auth_url" not in state and "auth_expires" not in state
515
563
  if mode == "reconnect":
516
564
  assert state["reconnects"] == 1 and state["last_reason"] == "connection-reset"
517
565
  # Only the newest lines are kept, and a reply larger than one read arrives whole.
@@ -522,6 +570,7 @@ def test_worker_real_sockets_auth_status_shutdown_without_root(store, monkeypatc
522
570
  time.sleep(0.05)
523
571
  thread.join(5)
524
572
  assert not thread.is_alive()
573
+ assert opened == (["https://vpn.example/auth/1"] if webauth else [])
525
574
  assert vpn.request("m1")["state"] == "STOPPED"
526
575
  base = vpn.session_path("m1")
527
576
  state = json.loads(Path(str(base) + ".json").read_text())
@@ -609,6 +658,27 @@ def test_cert_only_profile_connects_and_lists_without_credentials(store, monkeyp
609
658
  assert rows == {"cert": "cert", "login": "no"}
610
659
 
611
660
 
661
+ def test_connect_shows_the_sign_in_url_once_and_waits_for_it(store, monkeypatch):
662
+ vpn.save_profile("sso", {"config": CERT_ONLY})
663
+ pending = {"state": "AUTH_PENDING", "auth_url": "https://vpn.example/auth/1", "auth_expires": time.time() + 120}
664
+ states = iter([{"state": "STOPPED"}, pending, pending])
665
+ monkeypatch.setattr(vpn, "request", lambda name, command="status": next(states, {"state": "CONNECTED"}))
666
+ monkeypatch.setattr("evo_cli.commands.openvpn.shutil.which", lambda _: "/fake/openvpn")
667
+ monkeypatch.setattr("evo_cli.commands.openvpn.os.geteuid", lambda: 0)
668
+
669
+ class Running:
670
+ def __init__(self):
671
+ self.stdin = io.BytesIO()
672
+
673
+ def poll(self):
674
+ return None
675
+
676
+ monkeypatch.setattr("evo_cli.commands.openvpn.subprocess.Popen", lambda command, **_: Running())
677
+ result = CliRunner().invoke(cli, ["openvpn", "connect", "sso", "--no-watch", "--timeout", "10"])
678
+ assert result.exit_code == 0, result.output
679
+ assert result.output.count("https://vpn.example/auth/1") == 1 and "connected" in result.output
680
+
681
+
612
682
  def test_pick_profile_by_number_single_or_prompt(store, monkeypatch):
613
683
  runner = CliRunner()
614
684
  totp = vpn.parse_totp(SECRET)
@@ -1 +0,0 @@
1
- 0.33.0
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
File without changes