Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@

## [Unreleased]

### Added

- **新增 `proxy_env_extra` 配置:`proxyctl env` 可以额外导出指定的代理变量。**
列出的变量名会被 `proxyctl env` 导出为引擎的 HTTP 代理地址
(`http://127.0.0.1:<proxy_port>`),`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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand Down
9 changes: 8 additions & 1 deletion config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ extra_daemons: {}
# port: 7891

# ─────────────────────────────────────────────────────────────
# 代理端口与 NO_PROXY
# 代理端口与代理环境变量(NO_PROXY / 额外变量)
# ─────────────────────────────────────────────────────────────
# 引擎对外暴露的 HTTP/SOCKS mixed-port(应与 mihomo/sing-box 配置一致)
# 默认 7890;同机多实例(如 Docker 已占 7890)时改这里
Expand All @@ -75,6 +75,13 @@ extra_daemons: {}
# - 100.64.0.0/10 # Tailscale CGNAT
# - .ts.net # Tailscale MagicDNS

# 额外导出为代理地址(http://127.0.0.1:<proxy_port>)的环境变量名:
# `proxyctl env` 一并 export,`proxyctl env --unset` 一并 unset。
# 给不读 HTTP(S)_PROXY、只认自家变量的工具用——例如 omp 的 Anthropic 模型请求
# 只读 PI_PROXY_ANTHROPIC / PI_PROXY,缺了就直连(受限地区返回 403)。
# proxy_env_extra:
# - PI_PROXY_ANTHROPIC

# ─────────────────────────────────────────────────────────────
# 其他配置
# ─────────────────────────────────────────────────────────────
Expand Down
4 changes: 3 additions & 1 deletion man/proxyctl.1
Original file line number Diff line number Diff line change
Expand Up @@ -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 + 数据行)。
Expand Down Expand Up @@ -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)。
Expand Down
55 changes: 52 additions & 3 deletions src/proxyctl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__))
Expand Down Expand Up @@ -1352,21 +1355,65 @@ 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 语句。

用法:
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

Expand All @@ -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 ─────────────────────────────────────────────────────────────────
Expand Down
8 changes: 6 additions & 2 deletions src/proxyctl/explain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
41 changes: 41 additions & 0 deletions tests/unit/test_cli_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from types import SimpleNamespace

import pytest
import yaml

from proxyctl import cli

Expand Down Expand Up @@ -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
# ────────────────────────────────────────────────────────────────────────────
Expand Down
Loading