diff --git a/CHANGELOG.md b/CHANGELOG.md index da7d1ec..c3a0601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ ## [Unreleased] +### Added + +- **新增 `proxy_env_extra` 配置:`proxyctl env` 可以额外导出指定的代理变量。** + 列出的变量名会被 `proxyctl env` 导出为引擎的 HTTP 代理地址 + (`http://127.0.0.1:`),`proxyctl env --unset` 一并清除。 + 用于不读 `HTTP(S)_PROXY`、只认自家变量的工具——例如 omp 的 Anthropic 模型请求 + 只读 `PI_PROXY_ANTHROPIC` / `PI_PROXY`,缺了就直连,受限地区返回 + `403 Request not allowed`;配置 `proxy_env_extra: [PI_PROXY_ANTHROPIC]` 即可。 + 默认为空,`proxyctl env` 的现有输出不变。非法变量名、以及 YAML 解析成布尔 / + 空值的项(未加引号的 `yes` / `no` / `on` / `off` / `null` 等)会被跳过并在 + stderr 提示。 + ## [0.5.13] — 2026-07-17 ### Fixed diff --git a/README.md b/README.md index b6a74c0..3c702de 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,8 @@ proxy_port: 7890 # 自 0.1.4 起可配 no_proxy_extra: # 自 0.1.5 起追加 NO_PROXY - "*.internal.example.com" - 10.0.0.0/8 +proxy_env_extra: # 自 0.5.14 起:额外导出为代理地址的变量名 + - PI_PROXY_ANTHROPIC # omp 的 Anthropic 请求只读它,不读 HTTPS_PROXY ``` 完整字段见 [config.yaml.example](config.yaml.example)。 diff --git a/config.yaml.example b/config.yaml.example index 6151d76..86e7c1e 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -61,7 +61,7 @@ extra_daemons: {} # port: 7891 # ───────────────────────────────────────────────────────────── -# 代理端口与 NO_PROXY +# 代理端口与代理环境变量(NO_PROXY / 额外变量) # ───────────────────────────────────────────────────────────── # 引擎对外暴露的 HTTP/SOCKS mixed-port(应与 mihomo/sing-box 配置一致) # 默认 7890;同机多实例(如 Docker 已占 7890)时改这里 @@ -75,6 +75,13 @@ extra_daemons: {} # - 100.64.0.0/10 # Tailscale CGNAT # - .ts.net # Tailscale MagicDNS +# 额外导出为代理地址(http://127.0.0.1:)的环境变量名: +# `proxyctl env` 一并 export,`proxyctl env --unset` 一并 unset。 +# 给不读 HTTP(S)_PROXY、只认自家变量的工具用——例如 omp 的 Anthropic 模型请求 +# 只读 PI_PROXY_ANTHROPIC / PI_PROXY,缺了就直连(受限地区返回 403)。 +# proxy_env_extra: +# - PI_PROXY_ANTHROPIC + # ───────────────────────────────────────────────────────────── # 其他配置 # ───────────────────────────────────────────────────────────── diff --git a/man/proxyctl.1 b/man/proxyctl.1 index 9adb5da..022ad09 100644 --- a/man/proxyctl.1 +++ b/man/proxyctl.1 @@ -186,6 +186,8 @@ rulePayload / host / IP 聚合目的地,输出 app_contexts / candidate_contex .TP .B proxyctl env [--unset] 输出 / 清除 HTTP_PROXY / HTTPS_PROXY / NO_PROXY 环境变量(可 \fIeval\fR)。 +配置 \fBproxy_env_extra\fR 列出的变量名(如 omp 的 \fBPI_PROXY_ANTHROPIC\fR) +一并导出为同一 HTTP 代理地址、一并清除。 .TP .B proxyctl log [--tail \fIN\fR] [--no-follow] [--json] 查看后端日志。默认 \fItail -f\fR;\fB--json\fR 为 NDJSON(首行 meta header + 数据行)。 @@ -271,7 +273,7 @@ DEPENDENCY_MISSING — 依赖二进制 / 脚本缺失 .SH FILES .TP .I ~/.config/proxyctl/config.yaml -proxyctl 自身配置(API token / 端口 / 企业 DNS / extra_daemons / no_proxy_extra / plugins_disabled)。 +proxyctl 自身配置(API token / 端口 / 企业 DNS / extra_daemons / no_proxy_extra / proxy_env_extra / plugins_disabled)。 .TP .I ~/.config/mihomo/config.yaml mihomo 引擎配置(rules / proxies / proxy-providers / dns / tun)。 diff --git a/src/proxyctl/cli.py b/src/proxyctl/cli.py index 1eb1847..38bb5df 100755 --- a/src/proxyctl/cli.py +++ b/src/proxyctl/cli.py @@ -55,6 +55,9 @@ # 个人附加的 NO_PROXY 项(追加到默认 localhost/私网集合之后) # 例: ["corp.example.com", "intranet.local"] 或 "corp.example.com,intranet.local" "no_proxy_extra": [], + # 额外导出为代理地址的环境变量名:`proxyctl env` 一并 export,`env --unset` 一并 unset。 + # 给不读 HTTP(S)_PROXY、只认自家变量的工具,例: ["PI_PROXY_ANTHROPIC"](omp) + "proxy_env_extra": [], } SCRIPTS_DIR = os.path.dirname(os.path.realpath(__file__)) @@ -1352,6 +1355,48 @@ def _normalize_no_proxy_extra(extra) -> list[str]: return out +_ENV_PROXY_VARS = ("http_proxy", "https_proxy", "all_proxy", + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", + "no_proxy", "NO_PROXY") +_ENV_VAR_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +def _normalize_proxy_env_extra(extra) -> list[str]: + """proxy_env_extra → 可安全 eval 的变量名列表(去重、保序)。 + + `proxyctl env` 的输出会被 eval:非法变量名一律跳过并在 stderr 提示; + 与内置代理变量同名的项跳过(内置那组已导出,不能被改写成 HTTP 地址)。 + YAML 会把 yes / no / on / off / true / false / null 解析成 bool / None, + 非字符串项同样跳过并提示,不能被 str() 成 True / None 再导出。 + """ + def warn(msg: str) -> None: + print(f"{YELLOW}警告:proxy_env_extra {msg}{NC}", file=sys.stderr) + + if not extra: + return [] + if isinstance(extra, str): + items = extra.split(",") + elif isinstance(extra, (list, tuple)): + items = extra + else: + warn(f"应为列表或逗号分隔字符串,已忽略:{extra!r}") + return [] + out: list[str] = [] + for item in items: + if not isinstance(item, str): + warn(f"跳过非字符串项 {item!r}(YAML 会把 yes/no/on/off/true/false/null " + "解析成布尔或空值,这类变量名请加引号)") + continue + name = item.strip() + if not name or name in out or name in _ENV_PROXY_VARS: + continue + if not _ENV_VAR_NAME_RE.fullmatch(name): + warn(f"跳过非法变量名 {name!r}") + continue + out.append(name) + return out + + def cmd_env(config: dict, unset: bool = False): """输出设置/清除代理环境变量的 shell 语句。 @@ -1359,14 +1404,16 @@ def cmd_env(config: dict, unset: bool = False): eval $(proxyctl env) # 设置代理 eval $(proxyctl env --unset) # 清除代理 + config.proxy_env_extra 列出的变量名一并 export 为 HTTP 代理地址、一并 unset, + 给不读 HTTP(S)_PROXY、只认自家变量的工具用(如 omp 的 PI_PROXY_ANTHROPIC)。 + Args: config: 全局配置字典 unset: True 则输出 unset 语句 """ + extra_vars = _normalize_proxy_env_extra(config.get("proxy_env_extra")) if unset: - for var in ("http_proxy", "https_proxy", "all_proxy", - "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", - "no_proxy", "NO_PROXY"): + for var in _ENV_PROXY_VARS + tuple(extra_vars): print(f"unset {var};") return @@ -1387,6 +1434,8 @@ def cmd_env(config: dict, unset: bool = False): print(f"export {var}={proxy_socks};") for var in ("no_proxy", "NO_PROXY"): print(f"export {var}={no_proxy};") + for var in extra_vars: + print(f"export {var}={proxy_http};") # ── 命令:log ───────────────────────────────────────────────────────────────── diff --git a/src/proxyctl/explain.py b/src/proxyctl/explain.py index 2e0079f..6753453 100644 --- a/src/proxyctl/explain.py +++ b/src/proxyctl/explain.py @@ -215,11 +215,15 @@ def _t_env(backend, config) -> TopicCard: "summary": ( "代理环境变量(HTTP_PROXY / HTTPS_PROXY / NO_PROXY 等)。" f"`proxyctl env` 输出 export 行,可 eval 进当前 shell(端口 {port})。" + "只认自家变量、不读 HTTP(S)_PROXY 的工具(如 omp 的 " + "PI_PROXY_ANTHROPIC)把变量名加进 proxy_env_extra,随 env 一并导出。" ), - "file": _io_proxyctl_config_path() + " [no_proxy_extra: 字段]", + "file": _io_proxyctl_config_path() + " [no_proxy_extra / proxy_env_extra 字段]", "edit": ( " # no_proxy_extra: 追加内网域名 / IPv4 CIDR / 企业 host\n" - " # 裸 IPv6 CIDR 会被跳过,避免 Python/httpx 误解析 NO_PROXY" + " # 裸 IPv6 CIDR 会被跳过,避免 Python/httpx 误解析 NO_PROXY\n" + " # proxy_env_extra: 额外导出为代理地址的变量名,例 [PI_PROXY_ANTHROPIC]\n" + " # env --unset 一并清除;非法变量名会被跳过" ), "verify": "eval \"$(proxyctl env)\" && env | grep -i proxy", "next_commands": ["env", "env --unset"], diff --git a/tests/unit/test_cli_helpers.py b/tests/unit/test_cli_helpers.py index 5ee35a1..f074708 100644 --- a/tests/unit/test_cli_helpers.py +++ b/tests/unit/test_cli_helpers.py @@ -11,6 +11,7 @@ from types import SimpleNamespace import pytest +import yaml from proxyctl import cli @@ -80,6 +81,46 @@ def test_normalize_no_proxy_extra_filters_ipv6_cidr_from_csv(): ) == [".crhan.com", ".ts.net", "100.64.0.0/10"] +def test_cmd_env_exports_and_unsets_proxy_env_extra(capsys): + """proxy_env_extra 的变量随 env 导出为 HTTP 代理地址,随 env --unset 清除。""" + config = {"proxy_port": 7891, "proxy_env_extra": ["PI_PROXY_ANTHROPIC"]} + + cli.cmd_env(config) + exported = capsys.readouterr().out.splitlines() + assert "export PI_PROXY_ANTHROPIC=http://127.0.0.1:7891;" in exported + + cli.cmd_env(config, unset=True) + assert "unset PI_PROXY_ANTHROPIC;" in capsys.readouterr().out.splitlines() + + +def test_normalize_proxy_env_extra_keeps_eval_output_safe(capsys): + """env 输出会被 eval:非法名字不能进 stdout,内置代理变量不能被改写。""" + names = cli._normalize_proxy_env_extra( + "PI_PROXY_ANTHROPIC, PI_PROXY;id, $(id), 9LIVES, ALL_PROXY, PI_PROXY_ANTHROPIC" + ) + + captured = capsys.readouterr() + assert names == ["PI_PROXY_ANTHROPIC"] + assert captured.out == "" + assert "PI_PROXY;id" in captured.err + + +def test_cmd_env_skips_yaml_scalars_that_are_not_names(capsys): + """YAML 把 yes / null 解析成 True / None:不能被导出成 True=… / None=…。""" + config = yaml.safe_load( + "proxy_port: 7890\n" + "proxy_env_extra: [PI_PROXY_ANTHROPIC, yes, null]\n" + ) + + cli.cmd_env(config) + + names = [line.split("=", 1)[0] for line in capsys.readouterr().out.splitlines()] + assert "export PI_PROXY_ANTHROPIC" in names + assert "export True" not in names + assert "export None" not in names + assert cli._normalize_proxy_env_extra(True) == [] + + # ──────────────────────────────────────────────────────────────────────────── # wait_port # ────────────────────────────────────────────────────────────────────────────