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.
- {evo_cli-0.33.0 → evo_cli-0.34.1}/PKG-INFO +1 -1
- evo_cli-0.34.1/evo_cli/VERSION +1 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/openvpn.py +6 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/openvpn.py +59 -1
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli.egg-info/PKG-INFO +1 -1
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_openvpn.py +78 -8
- evo_cli-0.33.0/evo_cli/VERSION +0 -1
- {evo_cli-0.33.0 → evo_cli-0.34.1}/Containerfile +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/HISTORY.md +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/LICENSE +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/MANIFEST.in +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/README.md +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/__init__.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/__main__.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/base.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/cli.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/__init__.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/agent_toy.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/claude_code.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/cloudflare.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/cred.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/download.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/fix_claude.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/gdrive.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/gh.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/__init__.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_dag.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_git.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_hub.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_mirror.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_model.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_mutate.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_paths.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_render.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/_server.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/check.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/clone.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/edit.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/pull.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/serve.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/show.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/views.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/index-Cs7hXRPJ.css +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/index-CuNOlaH7.js +0 -0
- {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
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- {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
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- {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
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- {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
- {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
- {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
- {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
- {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
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/harness/web/index.html +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/hwid.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/hwid_reset.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/image.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/localproxy.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/mcp.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/miniconda.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/netcheck.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/ngrok.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/opencode.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/plantuml.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/__init__.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/_dns.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/_dpi.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/_hosts.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/_page.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/_probe.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/_spoofdpi.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/check.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/fix.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/probe.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/route/status.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/serp.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/site2s.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/ssh.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/storage.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/sysmon.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/tts.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/update.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/commands/wifi.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/console.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/__init__.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/doctor.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/google_oauth.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/migrate.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/oauth_flow.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/registry.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/store.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/credentials/sync.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/imaging/__init__.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/imaging/align.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/imaging/core.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/imaging/creds.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/imaging/errors.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/imaging/gemini.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/imaging/ncnn.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/mcp_registry.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/serp/__init__.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/serp/client.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/serp/creds.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/serp/errors.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/serp/install.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/serp/render.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/__init__.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/chunking.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/core.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/creds.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/errors.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/gemini.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/http.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/openai.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/player.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli/tts/vbee.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli.egg-info/SOURCES.txt +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli.egg-info/dependency_links.txt +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli.egg-info/entry_points.txt +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli.egg-info/requires.txt +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/evo_cli.egg-info/top_level.txt +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/pyproject.toml +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/setup.cfg +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/__init__.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/fake_evo_agents.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/fixtures/hub/contract-0.2.0.json +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/fixtures/hub/mirror-demo.yaml +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_agent_toy.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_claude_code.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_cli.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_console.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_cred.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_download.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_fix_claude.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_gdrive.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_gh.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_harness.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_harness_clone.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_harness_dag.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_harness_hub.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_harness_show.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_image.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_mcp.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_ngrok.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_opencode.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_plantuml.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_route.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_serp.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_storage.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_sysmon.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_tts.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_update.py +0 -0
- {evo_cli-0.33.0 → evo_cli-0.34.1}/tests/test_wifi.py +0 -0
|
@@ -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
|
-
|
|
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"):
|
|
@@ -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
|
-
|
|
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(
|
|
262
|
-
assert
|
|
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
|
-
|
|
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":
|
|
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)
|
evo_cli-0.33.0/evo_cli/VERSION
DELETED
|
@@ -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
|
|
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
|
|
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
|
|
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
|