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
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ services:
# - API_KEY=your_api_key_here
# ↑ 留空:容器以 --lan 启动,首次自动生成随机 Key 写入 ./accounts/settings.json
# 并打印在日志(docker compose logs qoder-proxy | grep -i "api key")
# 企业 VPC(仅 CN):实例名或 acme.vpc.qoder.com.cn
# - QD_CN_VPC_ENDPOINT=acme
- TZ=Asia/Shanghai
volumes:
# 持久化挂载账号凭证与用量流水
Expand Down
23 changes: 23 additions & 0 deletions docs/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,29 @@

> #90 之前只认第 2、3 条,而 userinfo **根本不返回 `user_type`**,所以企业号会被误判成个人号:团队闸门不生效、共享额度不计入有效余额。现在组织字段是第一判据。

### 企业 VPC 专属域(CN)

如果企业购买的是 Qoder CN(全家桶)企业 VPC 版,服务入口不是公网 `gateway.qoder.com.cn`,而是实例专属域。启动网关前设置:

```bash
export QD_CN_VPC_ENDPOINT=acme # 也可以填 acme.vpc.qoder.com.cn
./start-qoder-proxy.sh
```

网关按官方 CLI 的固定映射推导三个端点:

| 用途 | 地址 |
|---|---|
| 登录 / 官网 | `https://acme.vpc.qoder.com.cn` |
| OpenAPI | `https://acme-openapi.vpc.qoder.com.cn` |
| 推理网关 | `https://acme-gateway.vpc.qoder.com.cn` |

- 只对国内版生效;国际版账号仍走公网 `api3.qoder.sh`
- VPC 模式**不会回退**到公网 openapi / gateway,避免把企业凭证发到错误入口
- 网关所在机器必须能访问企业 VPC(专线、VPN 或内网 DNS)。专属域解析到内网地址是预期行为,SSRF 防护只放行这三个推导出来的主机
- 「添加账号(OAuth)」「导入 PAT」会走 VPC 的 OpenAPI;「扫描本机凭证」会同时检查 `~/.qoder-cn/.auth` 和 `~/.qoder-cn/.vpc/<实例名>/.auth`
- 启动日志会打印 `vpc : acme (openapi=... gateway=...)`;如果当前出口仍是国际版,会额外打印 WARN

### 「企业号抓不到」的常见原因

| 现象 | 原因 | 处理 |
Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ python qoder_proxy.py [--host HOST] [--port PORT] [--lan] [--api-key KEY]
- 每个 Key 有名称,可以单独启用 / 停用 / 删除,删除后立即失效
- 每个 Key 可以绑定出口:
- **国际版**:走 `api3.qoder.sh`,连不上时依次切换到 `api2` / `api1`
- **国内版**:走 `gateway.qoder.com.cn`
- **国内版**:走 `gateway.qoder.com.cn`;设置 `QD_CN_VPC_ENDPOINT` 后走企业实例专属网关
- **不绑定**:跟随看板顶部的全局出口
- 请求的模型和 Key 绑定的出口不匹配时,直接返回 400,给出说明

Expand Down Expand Up @@ -147,6 +147,7 @@ Redis 仅开放 Compose 内部网络,数据由专用命名卷保存。
| `QD_AFFINITY_MAX_MSGS` | `0` | 对话超过 N 条消息后不再固定账号,`0` 表示不限制 |
| `QD_AFFINITY_DEBUG` | `0` | 打印会话亲和的调试日志 |
| `QD_INTL_GATEWAY` | `https://api3.qoder.sh` | 国际版推理主机。连接失败时依次切换到 `api2` / `api1` |
| `QD_CN_VPC_ENDPOINT` | — | 国内版企业 VPC 实例名或专属域(如 `acme` / `acme.vpc.qoder.com.cn`)。设置后自动推导 openapi / gateway / website 三个专属域,且不回退公网。仅 CN 生效,见[企业 VPC 专属域](accounts.md#企业-vpc-专属域cn) |

### 会话缓存保温(可选)

Expand Down
193 changes: 174 additions & 19 deletions qoder_accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,15 +252,111 @@ def normalize_quota_payload(payload, realm=""):
return out


VPC_DOMAIN_SUFFIX = "vpc.qoder.com.cn"


def parse_vpc_endpoint(value):
"""把 QD_CN_VPC_ENDPOINT 解析成企业 VPC 的三个官方主机。

接受实例名(acme)或三个按服务拆分的域名(acme.vpc.qoder.com.cn /
acme-openapi.vpc.qoder.com.cn / acme-gateway.vpc.qoder.com.cn)。
空值返回 None;路径、端口、凭据、query、fragment 或空白一律拒绝,
避免一个值只改掉流程里的一部分端点。
"""
raw = str(value or "")
if not raw.strip():
return None
if raw != raw.strip() or any(ch in raw for ch in "\r\n\t"):
raise ValueError("invalid VPC endpoint: surrounding whitespace or "
"control characters")
target = raw if "://" in raw else "https://" + raw
try:
parsed = urllib.parse.urlsplit(target)
port = parsed.port
except ValueError:
raise ValueError("invalid VPC endpoint: malformed host or port")
if (parsed.scheme not in ("http", "https") or not parsed.hostname
or port is not None or parsed.username is not None
or parsed.password is not None or parsed.query
or parsed.fragment or "#" in raw
or parsed.path not in ("", "/")):
raise ValueError("invalid VPC endpoint: expected an instance name or a "
"domain under %s" % VPC_DOMAIN_SUFFIX)
host = parsed.hostname.lower()
instance = host
if host.endswith("." + VPC_DOMAIN_SUFFIX):
instance = host[:-len("." + VPC_DOMAIN_SUFFIX)]
if instance.endswith("-openapi"):
instance = instance[:-len("-openapi")]
elif instance.endswith("-gateway"):
instance = instance[:-len("-gateway")]
elif "." in host:
raise ValueError("invalid VPC endpoint: expected an instance name or a "
"domain under %s" % VPC_DOMAIN_SUFFIX)
if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", instance):
raise ValueError("invalid VPC endpoint: bad instance name %r" % instance)
domain = instance + "." + VPC_DOMAIN_SUFFIX
return {
"instance": instance,
"domain": domain,
"website": "https://" + domain,
"openapi": "https://" + instance + "-openapi." + VPC_DOMAIN_SUFFIX,
"gateway": "https://" + instance + "-gateway." + VPC_DOMAIN_SUFFIX,
}


_VPC_CN = None
VPC_CONFIG_ERROR = ""
try:
_VPC_CN = parse_vpc_endpoint(os.environ.get("QD_CN_VPC_ENDPOINT"))
except ValueError as exc:
VPC_CONFIG_ERROR = str(exc)

_VPC_HOSTS = frozenset(
urllib.parse.urlsplit(_VPC_CN[key]).hostname.lower()
for key in ("website", "openapi", "gateway")
if _VPC_CN and urllib.parse.urlsplit(_VPC_CN[key]).hostname
)


def vpc_configured():
return _VPC_CN is not None


def vpc_instance():
return (_VPC_CN or {}).get("instance", "")


def vpc_config_error():
return VPC_CONFIG_ERROR


def vpc_endpoint_hosts():
return _VPC_HOSTS


def is_configured_vpc_host(host):
"""仅对 QD_CN_VPC_ENDPOINT 推导出的三个主机放行解析级 SSRF 校验。

企业 VPC 域名通常解析到公司内网地址,通用规则会把它当内网目标拒绝;
但这是用户显式配置的固定主机,不是请求里可注入的 URL。
"""
name = str(host or "").strip().strip("[]").lower()
return bool(name) and name in vpc_endpoint_hosts()


REALM_CONFIGS = {
"cn": {
"name": "国内版 (China)",
"openapi": "https://openapi.qoder.com.cn",
"gateway": "https://gateway.qoder.com.cn",
"website": "https://qoder.com.cn",
"openapi": (_VPC_CN or {}).get("openapi")
or "https://openapi.qoder.com.cn",
"gateway": (_VPC_CN or {}).get("gateway")
or "https://gateway.qoder.com.cn",
"website": (_VPC_CN or {}).get("website") or "https://qoder.com.cn",
"domain": (_VPC_CN or {}).get("domain") or "qoder.com.cn",
"vpc_instance": (_VPC_CN or {}).get("instance", ""),
"client_id": "1c5e33e1-364d-4ce6-b02c-acaa81274a5c",
"redirect_uri": "qoder-work-cn://",
"domain": "qoder.com.cn",
"ua": "QoderWork/1.1.75",
# has_checkin 只是"历史上该区域曾开放 sash 签到"的提示位,**不再作为
# 门控**:能力改为运行时探测(见 Account.checkin_capability)。官方把
Expand Down Expand Up @@ -1188,6 +1284,10 @@ def openapi_candidates(realm):
base = str(cfg.get("openapi") or "").rstrip("/")
if base:
out.append(base)
# 企业 VPC:只能访问实例专属域,绝不回退到公网 openapi(那会把企业
# 凭证发给另一个入口,且通常直接 401/404)。
if cfg.get("vpc_instance"):
return out
domain = str(cfg.get("domain") or "").strip()
# 国际版活动平台另有一个可用入口 center.qoder.sh(0.4.3 客户端仍引用;实测
# intl=200 / cn=401),放在官网域名之前作为回退。
Expand All @@ -1210,12 +1310,32 @@ def gateway_candidates(realm):
"""
cfg = get_realm_config(realm)
out = [cfg["gateway"]]
# 企业 VPC 只有实例专属推理域,公网 api1/api2/api3 不能作为故障回退。
if cfg.get("vpc_instance"):
return out
for host in cfg.get("gateway_fallbacks") or ():
if host and host not in out:
out.append(host)
return out


def cli_auth_dirs(realm):
"""官方 CLI 的凭据目录:公网 ``.auth`` +(VPC 模式)实例专属 ``.auth``。

企业 VPC 账号可能由官方 CLI 登录在 ``~/.qoder-cn/.auth``,也可能按
实例隔离在 ``~/.qoder-cn/.vpc/<instance>/.auth``;扫描两者,导入时
的 realm 仍是 cn。
"""
cfg = get_realm_config(realm)
root = os.path.join(os.path.expanduser("~"), cfg["home_dir"])
out = []
instance = str(cfg.get("vpc_instance") or "")
if instance:
out.append(os.path.join(root, ".vpc", instance, ".auth"))
out.append(os.path.join(root, ".auth"))
return out


def detect_realm_from_domain(domain):
d = str(domain or "").lower()
if "qoder.sh" in d or (d.endswith("qoder.com") and "qoder.com.cn" not in d) \
Expand Down Expand Up @@ -1470,6 +1590,19 @@ def fold_daily_usage(log_path=None, day=None, max_lines=200000):
ipaddress.ip_network("2001:2::/48"),
ipaddress.ip_network("fdfe:dcba:9876::/48"),
]
# macOS 的 getaddrinfo 会把 fake-IP 以 IPv4-translated 形式(::ffff:0:c612:2d)
# 一并返回;只认纯 IPv4 会让 all(...) 失败,于是所有公网域名都被误判成内网。
_FAKEIP_V4_TRANSLATED = ipaddress.ip_network("::ffff:0:0:0/96")


def _is_fake_ip(ip):
candidates = [ip]
if isinstance(ip, ipaddress.IPv6Address):
if ip.ipv4_mapped is not None:
candidates.append(ip.ipv4_mapped)
elif ip in _FAKEIP_V4_TRANSLATED:
candidates.append(ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF))
return any(addr in net for addr in candidates for net in _FAKEIP_NETS)


def _host_boundary_violation(ip, allow_local):
Expand All @@ -1496,6 +1629,10 @@ def validate_public_http_url(url, allow_local=False):
host = (parsed.hostname or "").strip().strip("[]").lower()
if not host:
raise ValueError("URL host is required")
# 企业 VPC 专属域通常解析到公司内网,不能按公网目标拒绝;只放行
# QD_CN_VPC_ENDPOINT 推导出的固定主机。
if is_configured_vpc_host(host):
return url
if host == "localhost" or host.endswith(".localhost") or host.endswith(".local"):
if not allow_local:
raise ValueError("requests to localhost are not allowed")
Expand All @@ -1521,7 +1658,7 @@ def validate_public_http_url(url, allow_local=False):
addrs.append(ipaddress.ip_address(addr))
except ValueError:
raise ValueError("URL host resolved to a non-IP address: %r" % addr)
if addrs and all(any(ip in net for net in _FAKEIP_NETS) for ip in addrs):
if addrs and all(_is_fake_ip(ip) for ip in addrs):
return url # fake-IP DNS:真实 IP 不可见,名称级校验已通过
for ip in addrs:
if _host_boundary_violation(ip, allow_local):
Expand Down Expand Up @@ -1602,7 +1739,8 @@ def upstream_clock_skew(timeout=4.0):
"""
from email.utils import parsedate_to_datetime
try:
req = urllib.request.Request("https://openapi.qoder.com.cn/", method="HEAD")
base = openapi_candidates("cn")[0]
req = urllib.request.Request(base.rstrip("/") + "/", method="HEAD")
with _opener_for_request().open(req, timeout=timeout) as resp:
date_hdr = resp.headers.get("Date")
if not date_hdr:
Expand Down Expand Up @@ -4495,16 +4633,15 @@ def import_rows(self, rows, realm=None, overwrite=False, group=None):
@staticmethod
def _local_machine_id(realm):
"""读取本机官方客户端的 machine_id(优先,保持设备一致),缺则生成。"""
cfg = get_realm_config(realm)
home = os.path.join(os.path.expanduser("~"), cfg["home_dir"], ".auth")
p = os.path.join(home, "machine_id")
try:
with open(p, encoding="utf-8") as fh:
mid = fh.read().strip()
if mid:
return mid
except Exception:
pass
for auth_dir in cli_auth_dirs(realm):
try:
with open(os.path.join(auth_dir, "machine_id"),
encoding="utf-8") as fh:
mid = fh.read().strip()
if mid:
return mid
except Exception:
pass
return str(uuid.uuid4())

def start_login(self, realm="cn", platform="CLI"):
Expand Down Expand Up @@ -4820,9 +4957,10 @@ def scan_desktop_credentials():
item["error"] = str(exc)
found.append(item)

# 2) CLI 端 user / user.{profile}
auth_dir = os.path.join(os.path.expanduser("~"), cfg["home_dir"], ".auth")
if os.path.isdir(auth_dir):
# 2) CLI 端 user / user.{profile}(含 VPC 实例专属目录)
for auth_dir in cli_auth_dirs(realm):
if not os.path.isdir(auth_dir):
continue
machine_key = ""
try:
with open(os.path.join(auth_dir, "machine_id"),
Expand Down Expand Up @@ -4899,6 +5037,8 @@ def import_desktop_credential(path=None, realm=None):

token = refresh = ""
uid = nickname = ""
user_type = DEFAULT_USER_TYPE
org_id = org_name = ""
exp = 0
base = os.path.basename(path)
if base == "auth.v1.dat":
Expand All @@ -4908,6 +5048,12 @@ def import_desktop_credential(path=None, realm=None):
user = data.get("user") or {}
uid = str(user.get("id") or "")
nickname = str(user.get("name") or "")
user_type = (str(user.get("user_type") or user.get("userType") or "")
or DEFAULT_USER_TYPE)
org_id = str(user.get("organization_id")
or user.get("organizationId") or "")
org_name = str(user.get("organization_name")
or user.get("organizationName") or "")
try:
import datetime
exp = int(datetime.datetime.strptime(
Expand All @@ -4929,6 +5075,12 @@ def import_desktop_credential(path=None, realm=None):
refresh = str(data.get("refresh_token") or "")
uid = str(data.get("uid") or "")
nickname = str(data.get("name") or "")
user_type = (str(data.get("user_type") or data.get("userType") or "")
or DEFAULT_USER_TYPE)
org_id = str(data.get("organization_id")
or data.get("organizationId") or "")
org_name = str(data.get("organization_name")
or data.get("organizationName") or "")
exp = normalize_epoch(data.get("expire_time"))

if not token:
Expand All @@ -4946,6 +5098,9 @@ def import_desktop_credential(path=None, realm=None):
"expiresAt": exp or (int(time.time()) + 30 * 86400),
"source": "desktop-app",
"enabled": True,
"userType": user_type,
"organizationId": org_id,
"organizationName": org_name,
})
return add_to_pool(account)

Expand Down
Loading
Loading