@@ -0,0 +1,89 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: debian-arm64
|
||||
steps:
|
||||
- name: Install toolchain (git, curl, tar, go)
|
||||
run: |
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
if ! command -v git >/dev/null || ! command -v curl >/dev/null || ! command -v tar >/dev/null; then
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends git curl ca-certificates tar
|
||||
fi
|
||||
GO_VERSION=1.22.12
|
||||
case "$(uname -m)" in
|
||||
x86_64) HOST_ARCH=amd64 ;;
|
||||
aarch64) HOST_ARCH=arm64 ;;
|
||||
*) echo "unsupported host arch: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${HOST_ARCH}.tar.gz" -o /tmp/go.tar.gz
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
/usr/local/go/bin/go version
|
||||
|
||||
- name: Clone repository
|
||||
run: |
|
||||
set -e
|
||||
git init -q .
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" 2>/dev/null || \
|
||||
git remote set-url origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git -c http.extraHeader="Authorization: basic $(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 -w0)" \
|
||||
fetch --depth 1 origin "${GITHUB_SHA}"
|
||||
git checkout -q FETCH_HEAD
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build binaries
|
||||
run: |
|
||||
set -e
|
||||
export PATH="/usr/local/go/bin:${PATH}"
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME}-$(echo "${GITHUB_SHA}" | cut -c1-7)"
|
||||
fi
|
||||
echo "version: ${VERSION}"
|
||||
LDFLAGS="-s -w"
|
||||
for arch in amd64 arm64; do
|
||||
echo "building linux/${arch}"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH="${arch}" \
|
||||
go build -trimpath -ldflags "${LDFLAGS}" \
|
||||
-o "zhanlu-proxy-linux-${arch}" ./cmd/zhanlu-proxy
|
||||
done
|
||||
ls -lh zhanlu-proxy-linux-*
|
||||
|
||||
- name: Publish release assets
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
set -e
|
||||
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
AUTH="Authorization: token ${GITHUB_TOKEN}"
|
||||
|
||||
RELEASE_ID=$(curl -fsSL -H "${AUTH}" "${API}/releases/tags/${TAG}" | \
|
||||
grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2 || true)
|
||||
if [ -z "${RELEASE_ID}" ]; then
|
||||
RELEASE_ID=$(curl -fsSL -X POST -H "${AUTH}" -H "Content-Type: application/json" \
|
||||
"${API}/releases" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\"}" | \
|
||||
grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
|
||||
fi
|
||||
echo "release id: ${RELEASE_ID}"
|
||||
|
||||
for arch in amd64 arm64; do
|
||||
f="zhanlu-proxy-linux-${arch}"
|
||||
curl -fsSL -X POST -H "${AUTH}" \
|
||||
-F "attachment=@${f};filename=${f}" \
|
||||
"${API}/releases/${RELEASE_ID}/assets?name=${f}"
|
||||
done
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,9 @@
|
||||
credentials.json
|
||||
extension/
|
||||
*.exe
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
tmp/
|
||||
temp/
|
||||
@@ -0,0 +1,171 @@
|
||||
# Zhanlu Proxy
|
||||
|
||||
一个本地 Go 代理服务,用于读取湛卢插件凭据,按插件认证签名规则请求湛卢上游,并暴露 OpenAI 兼容接口。
|
||||
|
||||
当前实现包含:
|
||||
|
||||
- 登录页支持插件默认的移动云手机号验证码登录,成功后自动保存凭据到本地 JSON。
|
||||
- OpenAI 兼容接口:`/v1/models`、`/v1/chat/completions`。
|
||||
- 湛卢签名逻辑:RSA `authorization`、SHA-256 query hash、HMAC-SHA1 `Signature`。
|
||||
- 湛卢加密 SSE 响应解密并转换为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。
|
||||
- OpenAI 函数/工具调用:支持 `tools`、`tool_choice`、流式 `delta.tool_calls`、非流式 `message.tool_calls` 以及 `role: tool` 结果续传。
|
||||
|
||||
## 运行
|
||||
|
||||
```powershell
|
||||
go run ./cmd/zhanlu-proxy
|
||||
```
|
||||
|
||||
默认监听:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
打开登录页:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8080/login
|
||||
```
|
||||
|
||||
## 登录与凭据
|
||||
|
||||
### 移动云手机号验证码登录
|
||||
|
||||
打开 `/login` 后输入手机号并点击“获取验证码”。实现按插件默认登录分支工作:
|
||||
|
||||
- 生成 16 位一次性 `secret`。
|
||||
- 使用插件内置 RSA 公钥加密手机号和 `secret`。
|
||||
- 调用公网接口 `/api/query/acepilot-h5/manager/code/getAuthCode` 发送验证码。
|
||||
- 输入验证码后调用 `/api/query/acepilot-h5/manager/code/checkCode`。
|
||||
- 使用本次 `secret` AES 解密响应中的 `ak`、`sk`、`license`,得到 `AccessKey`、`SecretKey`、`Token`。
|
||||
- 凭据会写入 JSON 文件,后续 OpenAI 兼容接口自动读取。
|
||||
|
||||
默认保存到当前执行目录:
|
||||
|
||||
```text
|
||||
credentials.json
|
||||
```
|
||||
|
||||
可以通过环境变量覆盖:
|
||||
|
||||
```powershell
|
||||
$env:ZHANLU_CREDENTIALS_FILE="E:\path\to\credentials.json"
|
||||
```
|
||||
|
||||
手机号验证码登录使用 `ZHANLU_SERVER_BASE_URL`,默认公网地址来自插件配置:
|
||||
|
||||
```powershell
|
||||
$env:ZHANLU_SERVER_BASE_URL="https://api-wuxi-1.cmecloud.cn:8443"
|
||||
```
|
||||
|
||||
四共 SSO 的 `/auth/start` 和 `/auth/callback` 仍保留为备用接口,但不是 `/login` 的默认主流程。
|
||||
|
||||
## OpenAI 兼容接口
|
||||
|
||||
### 健康检查
|
||||
|
||||
```powershell
|
||||
curl http://127.0.0.1:8080/healthz
|
||||
```
|
||||
|
||||
### 模型列表
|
||||
|
||||
```powershell
|
||||
curl http://127.0.0.1:8080/v1/models
|
||||
```
|
||||
|
||||
### Chat Completions
|
||||
|
||||
非流式:
|
||||
|
||||
```powershell
|
||||
curl http://127.0.0.1:8080/v1/chat/completions `
|
||||
-H "Content-Type: application/json" `
|
||||
-d '{"model":"minimax-m2.5","messages":[{"role":"user","content":"hello"}],"stream":false}'
|
||||
```
|
||||
|
||||
流式:
|
||||
|
||||
```powershell
|
||||
curl -N http://127.0.0.1:8080/v1/chat/completions `
|
||||
-H "Content-Type: application/json" `
|
||||
-d '{"model":"minimax-m2.5","messages":[{"role":"user","content":"hello"}],"stream":true}'
|
||||
```
|
||||
|
||||
### 工具调用
|
||||
|
||||
请求中的 `tools`、`tool_choice` 会传给湛卢模型。流式响应返回增量 `delta.tool_calls`;非流式响应会把分片聚合为完整的 `message.tool_calls`,并保留 `finish_reason: "tool_calls"`。执行工具后,将 assistant 的 `tool_calls` 和 `role: "tool"` 结果放回 `messages` 再发起请求即可得到最终回答。
|
||||
|
||||
如果设置了本地 OpenAI 兼容 API Key,需要带 `Authorization`:
|
||||
|
||||
```powershell
|
||||
$env:OPENAI_COMPAT_API_KEY="local-secret"
|
||||
```
|
||||
|
||||
```powershell
|
||||
curl http://127.0.0.1:8080/v1/models `
|
||||
-H "Authorization: Bearer local-secret"
|
||||
```
|
||||
|
||||
## 配置项
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `ZHANLU_LISTEN_ADDR` | `:8080` | 本地监听地址 |
|
||||
| `ZHANLU_SERVER_BASE_URL` | `https://api-wuxi-1.cmecloud.cn:8443` | 湛卢上游 Base URL |
|
||||
| `ZHANLU_UPSTREAM_PATH` | `/api/acepilot/zhanlu/aiDeveloper/chat` | 湛卢聊天接口路径,按插件 `createZhanluRequest` 默认分支设置 |
|
||||
| `ZHANLU_CREDENTIALS_FILE` | `credentials.json` | 凭据 JSON 路径,默认当前执行目录 |
|
||||
| `ZHANLU_ACCESS_KEY` | 空 | 直接从环境变量提供 AccessKey |
|
||||
| `ZHANLU_SECRET_KEY` | 空 | 直接从环境变量提供 SecretKey |
|
||||
| `ZHANLU_TOKEN` | 空 | 直接从环境变量提供 Token |
|
||||
| `ZHANLU_SSO_BASE_URL` | `http://rdcloud.4c.hq.cmcc` | 四共 SSO 备用页面 Base URL,非默认手机号登录流程 |
|
||||
| `ZHANLU_SSO_EXCHANGE_URL` | `https://api-wuxi-1.cmecloud.cn:8443/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/checkoutCode` | 四共 SSO code 换 token 的备用接口 |
|
||||
| `ZHANLU_TOKEN_DECRYPT_KEY` | 空 | 解密四共 SSO 返回 `ak/sk/token` 的 AES key;手机号登录不需要设置 |
|
||||
| `ZHANLU_PUBLIC_KEY_PEM` | 插件内置签名公钥 | 签名 URL 中 `authorization` 使用的 RSA 公钥,通常不需要设置 |
|
||||
| `ZHANLU_PHONE_PUBLIC_KEY_PEM` | 插件内置手机号登录公钥 | 手机号验证码登录加密手机号和一次性 secret 使用的 RSA 公钥,通常不需要设置 |
|
||||
| `ZHANLU_MODELS` | `glm47,minimax-m25` | `/v1/models` 返回的模型列表,逗号分隔 |
|
||||
| `ZHANLU_DEFAULT_MODEL` | `minimax-m25` | 请求未传 `model` 时使用的默认模型 |
|
||||
| `ZHANLU_UPSTREAM_TIMEOUT` | `120s` | 上游请求超时 |
|
||||
| `ZHANLU_STREAM_IDLE_TIMEOUT` | `300s` | 预留的流式空闲超时配置 |
|
||||
| `ZHANLU_DEBUG` | `false` | 调试模式,错误信息更详细但会脱敏敏感 query |
|
||||
| `OPENAI_COMPAT_API_KEY` | 空 | 本地 OpenAI 兼容接口鉴权 key |
|
||||
|
||||
## 凭据优先级
|
||||
|
||||
服务启动时按以下优先级加载凭据:
|
||||
|
||||
1. 环境变量 `ZHANLU_ACCESS_KEY`、`ZHANLU_SECRET_KEY`、`ZHANLU_TOKEN`。
|
||||
2. `ZHANLU_CREDENTIALS_FILE` 指向的 JSON 文件。
|
||||
|
||||
登录页面保存后,运行中的服务会立即使用新凭据。
|
||||
|
||||
## 安全说明
|
||||
|
||||
- `credentials.json` 包含明文 `AccessKey`、`SecretKey`、`Token`,请不要提交到仓库。
|
||||
- 默认保存在当前执行目录的 `credentials.json`。
|
||||
- 错误响应默认不会返回签名 URL,避免泄露 `AccessKey`、`authorization`、`Signature`。
|
||||
- `ZHANLU_DEBUG=true` 时会返回更详细错误,但仍会对敏感 query 参数脱敏。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- `ZHANLU_UPSTREAM_PATH` 当前默认值是根据插件分析给出的候选路径,真实环境如果返回 404 或上游错误,需要用实际路径覆盖。
|
||||
- 手机号验证码接口可能有风控或频率限制;请按正常登录频率使用。
|
||||
- 湛卢上游必须使用 `stream:true`;代理对 OpenAI `stream:false` 请求负责聚合流式响应。
|
||||
- UI 模型名 `glm4.7`、`minimax-m2.5` 会按插件逻辑映射为上游 `glm47`、`minimax-m25`。
|
||||
- `zhanlu3` 使用独立的内网 VL Gateway 和 `ZHANLU_VL_API_KEY`,当前代理未接入该特殊分支。
|
||||
|
||||
## 验证
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
go build ./cmd/zhanlu-proxy
|
||||
```
|
||||
|
||||
本地端点验证:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/zhanlu-proxy
|
||||
curl http://127.0.0.1:8080/healthz
|
||||
curl http://127.0.0.1:8080/v1/models
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
h := server.New(cfg)
|
||||
log.Printf("zhanlu proxy listening on %s", cfg.ListenAddr)
|
||||
log.Printf("login page: http://127.0.0.1%s/login", cfg.ListenAddr)
|
||||
if err := http.ListenAndServe(cfg.ListenAddr, h); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func DecryptCredential(ciphertextBase64, key string) (string, error) {
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 {
|
||||
return "", errors.New("invalid AES ciphertext length")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(repeatKey(key, aes.BlockSize))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
plain := make([]byte, len(ciphertext))
|
||||
for start := 0; start < len(ciphertext); start += aes.BlockSize {
|
||||
block.Decrypt(plain[start:start+aes.BlockSize], ciphertext[start:start+aes.BlockSize])
|
||||
}
|
||||
|
||||
plain, err = unpadPKCS7(plain, aes.BlockSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func EncryptCredential(plaintext, key string) (string, error) {
|
||||
block, err := aes.NewCipher(repeatKey(key, aes.BlockSize))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain := padPKCS7([]byte(plaintext), aes.BlockSize)
|
||||
out := make([]byte, len(plain))
|
||||
for start := 0; start < len(plain); start += aes.BlockSize {
|
||||
block.Encrypt(out[start:start+aes.BlockSize], plain[start:start+aes.BlockSize])
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
func repeatKey(key string, size int) []byte {
|
||||
src := []byte(key)
|
||||
out := make([]byte, size)
|
||||
if len(src) == 0 {
|
||||
return out
|
||||
}
|
||||
for i := range out {
|
||||
out[i] = src[i%len(src)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func unpadPKCS7(in []byte, blockSize int) ([]byte, error) {
|
||||
if len(in) == 0 || len(in)%blockSize != 0 {
|
||||
return nil, errors.New("invalid padded data length")
|
||||
}
|
||||
pad := int(in[len(in)-1])
|
||||
if pad == 0 || pad > blockSize || pad > len(in) {
|
||||
return nil, fmt.Errorf("invalid padding size %d", pad)
|
||||
}
|
||||
for _, b := range in[len(in)-pad:] {
|
||||
if int(b) != pad {
|
||||
return nil, errors.New("invalid padding bytes")
|
||||
}
|
||||
}
|
||||
return in[:len(in)-pad], nil
|
||||
}
|
||||
|
||||
func padPKCS7(in []byte, blockSize int) []byte {
|
||||
pad := blockSize - len(in)%blockSize
|
||||
out := make([]byte, len(in)+pad)
|
||||
copy(out, in)
|
||||
for i := len(in); i < len(out); i++ {
|
||||
out[i] = byte(pad)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Credentials struct {
|
||||
AccessKey string `json:"access_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Token string `json:"token"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
}
|
||||
|
||||
func (c Credentials) Validate() error {
|
||||
if strings.TrimSpace(c.AccessKey) == "" {
|
||||
return errors.New("access_key is required")
|
||||
}
|
||||
if strings.TrimSpace(c.SecretKey) == "" {
|
||||
return errors.New("secret_key is required")
|
||||
}
|
||||
if strings.TrimSpace(c.Token) == "" {
|
||||
return errors.New("token is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadCredentials(path string) (Credentials, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Credentials{}, err
|
||||
}
|
||||
var c Credentials
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return Credentials{}, err
|
||||
}
|
||||
return c, c.Validate()
|
||||
}
|
||||
|
||||
func SaveCredentials(path string, c Credentials) error {
|
||||
if err := c.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.SavedAt = time.Now()
|
||||
b, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, b, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func ParsePublicKey(pemText string) (*rsa.PublicKey, error) {
|
||||
block, _ := pem.Decode([]byte(pemText))
|
||||
if block == nil {
|
||||
return nil, errors.New("invalid public key PEM")
|
||||
}
|
||||
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("public key is not RSA")
|
||||
}
|
||||
return rsaPub, nil
|
||||
}
|
||||
|
||||
func EncryptAuthorization(pub *rsa.PublicKey, plaintext string) (string, error) {
|
||||
if pub == nil {
|
||||
return "", errors.New("public key is required")
|
||||
}
|
||||
out, err := rsa.EncryptPKCS1v15(rand.Reader, pub, []byte(plaintext))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(out), nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ExchangeResponse struct {
|
||||
ErrorCode string `json:"errorCode"`
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
Message string `json:"message"`
|
||||
Body map[string]any `json:"body"`
|
||||
}
|
||||
|
||||
func ExchangeCode(client *http.Client, endpoint string, code string, decryptKey string) (Credentials, error) {
|
||||
if strings.TrimSpace(endpoint) == "" {
|
||||
return Credentials{}, errors.New("exchange endpoint is required")
|
||||
}
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return Credentials{}, errors.New("code is required")
|
||||
}
|
||||
if strings.TrimSpace(decryptKey) == "" {
|
||||
return Credentials{}, errors.New("decrypt key is required")
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 60 * time.Second}
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"code": code})
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Credentials{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Credentials{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var exchange ExchangeResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&exchange); err != nil {
|
||||
return Credentials{}, err
|
||||
}
|
||||
if exchange.ErrorCode != "Success" {
|
||||
msg := firstNonEmpty(exchange.ErrorMsg, exchange.Message, exchange.ErrorCode)
|
||||
return Credentials{}, fmt.Errorf("exchange failed: %s", msg)
|
||||
}
|
||||
|
||||
ak, err := decryptBodyField(exchange.Body, "ak", decryptKey)
|
||||
if err != nil {
|
||||
return Credentials{}, err
|
||||
}
|
||||
sk, err := decryptBodyField(exchange.Body, "sk", decryptKey)
|
||||
if err != nil {
|
||||
return Credentials{}, err
|
||||
}
|
||||
token, err := decryptBodyField(exchange.Body, "token", decryptKey)
|
||||
if err != nil {
|
||||
return Credentials{}, err
|
||||
}
|
||||
return Credentials{AccessKey: ak, SecretKey: sk, Token: token, SavedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func decryptBodyField(body map[string]any, key string, decryptKey string) (string, error) {
|
||||
v, ok := body[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("response body missing %s", key)
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
return "", fmt.Errorf("response body %s is not a string", key)
|
||||
}
|
||||
return DecryptCredential(strings.TrimSpace(s), decryptKey)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return "unknown error"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
ServerBaseURL string
|
||||
UpstreamPath string
|
||||
CredentialsPath string
|
||||
SSOExchangeURL string
|
||||
SSOBaseURL string
|
||||
TokenDecryptKey string
|
||||
PublicKeyPEM string
|
||||
PhonePublicKeyPEM string
|
||||
Models []string
|
||||
DefaultModel string
|
||||
OpenAIAPIKey string
|
||||
UpstreamTimeout time.Duration
|
||||
StreamIdleTimout time.Duration
|
||||
Debug bool
|
||||
Credentials auth.Credentials
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
ListenAddr: getenv("ZHANLU_LISTEN_ADDR", ":8080"),
|
||||
ServerBaseURL: getenv("ZHANLU_SERVER_BASE_URL", "https://api-wuxi-1.cmecloud.cn:8443"),
|
||||
UpstreamPath: getenv("ZHANLU_UPSTREAM_PATH", "/api/acepilot/zhanlu/aiDeveloper/chat"),
|
||||
CredentialsPath: getenv("ZHANLU_CREDENTIALS_FILE", defaultCredentialsPath()),
|
||||
SSOBaseURL: getenv("ZHANLU_SSO_BASE_URL", "http://rdcloud.4c.hq.cmcc"),
|
||||
SSOExchangeURL: getenv("ZHANLU_SSO_EXCHANGE_URL", "https://api-wuxi-1.cmecloud.cn:8443/cmdevops-aiplus-agent-gateway/api/acepilot/zhanlu/checkoutCode"),
|
||||
TokenDecryptKey: os.Getenv("ZHANLU_TOKEN_DECRYPT_KEY"),
|
||||
PublicKeyPEM: getenv("ZHANLU_PUBLIC_KEY_PEM", defaultPublicKeyPEM),
|
||||
PhonePublicKeyPEM: getenv("ZHANLU_PHONE_PUBLIC_KEY_PEM", defaultPhonePublicKeyPEM),
|
||||
DefaultModel: getenv("ZHANLU_DEFAULT_MODEL", "minimax-m25"),
|
||||
OpenAIAPIKey: os.Getenv("OPENAI_COMPAT_API_KEY"),
|
||||
UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 120*time.Second),
|
||||
StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second),
|
||||
Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"),
|
||||
}
|
||||
cfg.Models = splitCSV(getenv("ZHANLU_MODELS", "glm47,minimax-m25"))
|
||||
cfg.Credentials = auth.Credentials{
|
||||
AccessKey: os.Getenv("ZHANLU_ACCESS_KEY"),
|
||||
SecretKey: os.Getenv("ZHANLU_SECRET_KEY"),
|
||||
Token: os.Getenv("ZHANLU_TOKEN"),
|
||||
}
|
||||
if cfg.Credentials.Validate() == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
if creds, err := auth.LoadCredentials(cfg.CredentialsPath); err == nil {
|
||||
cfg.Credentials = creds
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func durationEnv(key string, fallback time.Duration) time.Duration {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func defaultCredentialsPath() string {
|
||||
return filepath.Join(".", "credentials.json")
|
||||
}
|
||||
|
||||
const defaultPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
|
||||
-----END PUBLIC KEY-----`
|
||||
|
||||
const defaultPhonePublicKeyPEM = `-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnqiA2qP9BNvKw5DnVnrBVBhd+5gJDVn3mDemCfq/AN1cdaHV57hQo6R1ufp45mOkSwLaJcTE82zFKmgKoEAKwD1SR10rp0xJC7x3yvx2FbpEsiW9TeZlvJdri1BYKUMS8OP8ykjHSJoy0oMaV6e95R2rsu4DEH7JuA9+Bt0sOoLewvHx/fs1e28tH+928uUEKdLug+cv/XTKjLudpLjiSMPZU6EHFqrUhA9zmEasOMmg9Dj0j4sChBooCeCGnh/pYHJaosH5amhlSQ8FnEG0BQBrQbZ+qhRH4LYyqGYN8grDNeSnPj7vPDcwiEm++85i5AngZfEMnGWZg5jYDhO9+QIDAQAB
|
||||
-----END PUBLIC KEY-----`
|
||||
@@ -0,0 +1,65 @@
|
||||
package openai
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type ChatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []map[string]any `json:"messages"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
func (r *ChatCompletionRequest) UnmarshalJSON(data []byte) error {
|
||||
type alias ChatCompletionRequest
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(raw, "model")
|
||||
delete(raw, "messages")
|
||||
delete(raw, "stream")
|
||||
*r = ChatCompletionRequest(a)
|
||||
r.Extra = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r ChatCompletionRequest) MarshalForUpstream() ([]byte, error) {
|
||||
model := map[string]string{
|
||||
"minimax-m2.5": "minimax-m25",
|
||||
"glm4.7": "glm47",
|
||||
}[r.Model]
|
||||
if model == "" {
|
||||
model = r.Model
|
||||
}
|
||||
m := map[string]any{
|
||||
"model": model,
|
||||
"messages": r.Messages,
|
||||
"temperature": 0,
|
||||
"stream": r.Stream,
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
"max_tokens": 16000,
|
||||
"inputs": map[string]any{"aiDevQuestion": ""},
|
||||
}
|
||||
for k, v := range r.Extra {
|
||||
var anyValue any
|
||||
if err := json.Unmarshal(v, &anyValue); err == nil {
|
||||
m[k] = anyValue
|
||||
}
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error ErrorBody `json:"error"`
|
||||
}
|
||||
|
||||
type ErrorBody struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
Param any `json:"param"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/openai"
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/zhanlu"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func New(cfg config.Config) http.Handler {
|
||||
s := &Server{cfg: cfg, mux: http.NewServeMux()}
|
||||
s.routes()
|
||||
return s.mux
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("GET /healthz", s.healthz)
|
||||
s.mux.HandleFunc("GET /login", s.loginPage)
|
||||
s.mux.HandleFunc("GET /auth/start", s.startSSO)
|
||||
s.mux.HandleFunc("GET /auth/callback", s.ssoCallback)
|
||||
s.mux.HandleFunc("POST /api/auth/code", s.requestPhoneCode)
|
||||
s.mux.HandleFunc("POST /api/auth/login", s.loginWithPhoneCode)
|
||||
s.mux.HandleFunc("GET /api/credentials", s.getCredentials)
|
||||
s.mux.HandleFunc("POST /api/credentials", s.saveCredentials)
|
||||
s.mux.HandleFunc("POST /api/sso/exchange", s.exchangeSSOCode)
|
||||
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
|
||||
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
|
||||
}
|
||||
|
||||
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.OpenAIAPIKey != "" {
|
||||
got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if got != s.cfg.OpenAIAPIKey {
|
||||
writeOpenAIError(w, http.StatusUnauthorized, "invalid api key", "auth_error", "invalid_api_key")
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL})
|
||||
}
|
||||
|
||||
func (s *Server) startSSO(w http.ResponseWriter, r *http.Request) {
|
||||
ssoBaseURL := strings.TrimSpace(r.URL.Query().Get("sso_base_url"))
|
||||
if ssoBaseURL == "" {
|
||||
ssoBaseURL = s.cfg.SSOBaseURL
|
||||
}
|
||||
if err := validateHTTPBaseURL(ssoBaseURL); err != nil {
|
||||
s.renderLoginResult(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
callback := callbackURL(r)
|
||||
loginURL := strings.TrimRight(ssoBaseURL, "/") + "/moss/micrologin/#/sso/getauthorizecode" +
|
||||
"?redirectUri=" + url.QueryEscape(callback) +
|
||||
"&sourceid=7192038465&moss_sso_account=1"
|
||||
http.Redirect(w, r, loginURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) ssoCallback(w http.ResponseWriter, r *http.Request) {
|
||||
code := strings.TrimSpace(r.URL.Query().Get("code"))
|
||||
if code == "" {
|
||||
s.renderLoginResult(w, false, "回调中没有授权 code,请重新登录")
|
||||
return
|
||||
}
|
||||
creds, err := auth.ExchangeCode(&http.Client{Timeout: s.cfg.UpstreamTimeout}, s.cfg.SSOExchangeURL, code, s.cfg.TokenDecryptKey)
|
||||
if err != nil {
|
||||
s.renderLoginResult(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
creds.BaseURL = s.cfg.ServerBaseURL
|
||||
if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
|
||||
s.renderLoginResult(w, false, err.Error())
|
||||
return
|
||||
}
|
||||
s.cfg.Credentials = creds
|
||||
s.renderLoginResult(w, true, "凭据已保存,可以关闭此页面并使用 OpenAI 兼容接口")
|
||||
}
|
||||
|
||||
func (s *Server) renderLoginResult(w http.ResponseWriter, success bool, message string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = loginResultTemplate.Execute(w, map[string]any{"Success": success, "Message": message})
|
||||
}
|
||||
|
||||
func callbackURL(r *http.Request) string {
|
||||
host := r.Host
|
||||
if colon := strings.LastIndex(host, ":"); colon >= 0 {
|
||||
host = "127.0.0.1" + host[colon:]
|
||||
} else {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return "http://" + host + "/auth/callback"
|
||||
}
|
||||
|
||||
func validateHTTPBaseURL(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("SSO Base URL 无效:%s", raw)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("SSO Base URL 只支持 http/https:%s", raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) requestPhoneCode(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Telephone string `json:"telephone"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
telephone := strings.TrimSpace(in.Telephone)
|
||||
if !validChineseMobile(telephone) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "请输入有效的 11 位手机号"})
|
||||
return
|
||||
}
|
||||
secret := randomSecret16()
|
||||
pub, err := auth.ParsePublicKey(s.cfg.PhonePublicKeyPEM)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
telephoneCipher, err := auth.EncryptAuthorization(pub, telephone)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
secretCipher, err := auth.EncryptAuthorization(pub, secret)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
endpoint := strings.TrimRight(s.cfg.ServerBaseURL, "/") + "/api/query/acepilot-h5/manager/code/getAuthCode"
|
||||
var out phoneAPIResponse
|
||||
if err := s.postPhoneAPI(endpoint, map[string]string{"telephone": telephoneCipher, "secret": secretCipher}, &out); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if out.State != "OK" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": firstNonEmpty(out.ErrorMessage, "验证码发送失败")})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "secret": secret})
|
||||
}
|
||||
|
||||
func (s *Server) loginWithPhoneCode(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Telephone string `json:"telephone"`
|
||||
Code string `json:"code"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
telephone := strings.TrimSpace(in.Telephone)
|
||||
code := strings.TrimSpace(in.Code)
|
||||
secret := strings.TrimSpace(in.Secret)
|
||||
if !validChineseMobile(telephone) || len(code) != 6 || len(secret) != 16 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "手机号、验证码或登录 secret 无效"})
|
||||
return
|
||||
}
|
||||
pub, err := auth.ParsePublicKey(s.cfg.PhonePublicKeyPEM)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
telephoneCipher, err := auth.EncryptAuthorization(pub, telephone)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
endpoint := strings.TrimRight(s.cfg.ServerBaseURL, "/") + "/api/query/acepilot-h5/manager/code/checkCode"
|
||||
var out phoneAPIResponse
|
||||
if err := s.postPhoneAPI(endpoint, map[string]string{"telephone": telephoneCipher, "code": code}, &out); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !out.Body.Result {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": firstNonEmpty(out.ErrorMessage, "验证码校验失败")})
|
||||
return
|
||||
}
|
||||
creds, err := decryptPhoneCredentials(out.Body, secret)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
creds.BaseURL = s.cfg.ServerBaseURL
|
||||
if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.cfg.Credentials = creds
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.CredentialsPath, "access_key": mask(creds.AccessKey)})
|
||||
}
|
||||
|
||||
type phoneAPIResponse struct {
|
||||
State string `json:"state"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
Body struct {
|
||||
Result bool `json:"result"`
|
||||
AK string `json:"ak"`
|
||||
SK string `json:"sk"`
|
||||
License string `json:"license"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
func (s *Server) postPhoneAPI(endpoint string, payload map[string]string, out *phoneAPIResponse) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("plugin_type", "vscode")
|
||||
req.Header.Set("plugin_version", "2.8.0")
|
||||
req.Header.Set("request", randomRequestID())
|
||||
resp, err := (&http.Client{Timeout: s.cfg.UpstreamTimeout}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return fmt.Errorf("phone auth upstream returned %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func decryptPhoneCredentials(body struct {
|
||||
Result bool `json:"result"`
|
||||
AK string `json:"ak"`
|
||||
SK string `json:"sk"`
|
||||
License string `json:"license"`
|
||||
}, secret string) (auth.Credentials, error) {
|
||||
ak, err := auth.DecryptCredential(strings.TrimSpace(body.AK), secret)
|
||||
if err != nil {
|
||||
return auth.Credentials{}, fmt.Errorf("decrypt access key: %w", err)
|
||||
}
|
||||
sk, err := auth.DecryptCredential(strings.TrimSpace(body.SK), secret)
|
||||
if err != nil {
|
||||
return auth.Credentials{}, fmt.Errorf("decrypt secret key: %w", err)
|
||||
}
|
||||
token, err := auth.DecryptCredential(strings.TrimSpace(body.License), secret)
|
||||
if err != nil {
|
||||
return auth.Credentials{}, fmt.Errorf("decrypt token: %w", err)
|
||||
}
|
||||
return auth.Credentials{AccessKey: ak, SecretKey: sk, Token: token}, nil
|
||||
}
|
||||
|
||||
func validChineseMobile(s string) bool {
|
||||
if len(s) != 11 || s[0] != '1' || s[1] < '3' || s[1] > '9' {
|
||||
return false
|
||||
}
|
||||
for _, ch := range s {
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func randomSecret16() string {
|
||||
const letters = "0123456789abcdef"
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
b := make([]byte, 16)
|
||||
for i := range b {
|
||||
b[i] = letters[r.Intn(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func randomRequestID() string {
|
||||
return fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63())
|
||||
}
|
||||
|
||||
func (s *Server) getCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := auth.LoadCredentials(s.cfg.CredentialsPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"configured": false, "path": s.cfg.CredentialsPath})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"configured": true,
|
||||
"path": s.cfg.CredentialsPath,
|
||||
"access_key": mask(c.AccessKey),
|
||||
"base_url": c.BaseURL,
|
||||
"saved_at": c.SavedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) saveCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
var c auth.Credentials
|
||||
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := auth.SaveCredentials(s.cfg.CredentialsPath, c); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.cfg.Credentials = c
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.CredentialsPath})
|
||||
}
|
||||
|
||||
func (s *Server) exchangeSSOCode(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Code string `json:"code"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
DecryptKey string `json:"decrypt_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
endpoint := firstNonEmpty(in.Endpoint, s.cfg.SSOExchangeURL)
|
||||
decryptKey := firstNonEmpty(in.DecryptKey, s.cfg.TokenDecryptKey)
|
||||
creds, err := auth.ExchangeCode(&http.Client{Timeout: s.cfg.UpstreamTimeout}, endpoint, in.Code, decryptKey)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
creds.BaseURL = in.BaseURL
|
||||
if err := auth.SaveCredentials(s.cfg.CredentialsPath, creds); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.cfg.Credentials = creds
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.CredentialsPath})
|
||||
}
|
||||
|
||||
func (s *Server) models(w http.ResponseWriter, r *http.Request) {
|
||||
data := make([]map[string]any, 0, len(s.cfg.Models))
|
||||
for _, model := range s.cfg.Models {
|
||||
data = append(data, map[string]any{"id": model, "object": "model", "created": 0, "owned_by": "zhanlu"})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data})
|
||||
}
|
||||
|
||||
func (s *Server) chatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
creds, err := s.currentCredentials()
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusUnauthorized, "zhanlu credentials are not configured; open /login first", "auth_error", "missing_credentials")
|
||||
return
|
||||
}
|
||||
var req openai.ChatCompletionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_json")
|
||||
return
|
||||
}
|
||||
if req.Model == "" {
|
||||
req.Model = s.cfg.DefaultModel
|
||||
}
|
||||
clientWantsStream := req.Stream
|
||||
// The Zhanlu gateway always expects streaming responses. Sending stream=false
|
||||
// makes its Java adapter read choice.delta from a non-streaming choice.
|
||||
req.Stream = true
|
||||
body, err := req.MarshalForUpstream()
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "bad_body")
|
||||
return
|
||||
}
|
||||
|
||||
signer, err := s.signer()
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusInternalServerError, err.Error(), "sign_error", "signer_init_failed")
|
||||
return
|
||||
}
|
||||
client := zhanlu.NewClient(s.cfg.ServerBaseURL, s.cfg.UpstreamPath, creds, signer, s.cfg.UpstreamTimeout)
|
||||
resp, err := client.ChatCompletions(r.Context(), body)
|
||||
if err != nil {
|
||||
msg := "zhanlu upstream request failed"
|
||||
if s.cfg.Debug {
|
||||
msg = redactSensitive(err.Error())
|
||||
}
|
||||
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_request_failed")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
msg := fmt.Sprintf("zhanlu upstream returned %d", resp.StatusCode)
|
||||
if s.cfg.Debug && len(b) > 0 {
|
||||
msg += ": " + string(b)
|
||||
}
|
||||
writeOpenAIError(w, http.StatusBadGateway, msg, "upstream_error", "zhanlu_bad_status")
|
||||
return
|
||||
}
|
||||
if clientWantsStream {
|
||||
s.proxyDecryptedStream(w, resp, creds.Token)
|
||||
return
|
||||
}
|
||||
s.aggregateDecryptedStream(w, resp, creds.Token, req.Model)
|
||||
}
|
||||
|
||||
func (s *Server) proxyDecryptedStream(w http.ResponseWriter, resp *http.Response, token string) {
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher, _ := w.(http.Flusher)
|
||||
err := forEachDecryptedChunk(resp.Body, token, func(chunk []byte) error {
|
||||
_, err := fmt.Fprintf(w, "data: %s\n\n", chunk)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
b, _ := json.Marshal(map[string]any{"error": map[string]any{"message": err.Error(), "type": "upstream_error", "code": "zhanlu_stream_error"}})
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
}
|
||||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||||
}
|
||||
|
||||
func (s *Server) aggregateDecryptedStream(w http.ResponseWriter, resp *http.Response, token, model string) {
|
||||
var content, reasoning, id string
|
||||
var usage any
|
||||
finishReason := "stop"
|
||||
type toolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
toolCalls := map[int]*toolCall{}
|
||||
err := forEachDecryptedChunk(resp.Body, token, func(chunk []byte) error {
|
||||
var event struct {
|
||||
ID string `json:"id"`
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ToolCalls []struct {
|
||||
Index int `json:"index"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage any `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal(chunk, &event); err != nil {
|
||||
return err
|
||||
}
|
||||
if event.ID != "" {
|
||||
id = event.ID
|
||||
}
|
||||
if event.Usage != nil {
|
||||
usage = event.Usage
|
||||
}
|
||||
if len(event.Choices) > 0 {
|
||||
content += event.Choices[0].Delta.Content
|
||||
reasoning += event.Choices[0].Delta.ReasoningContent + event.Choices[0].Delta.Reasoning
|
||||
for _, part := range event.Choices[0].Delta.ToolCalls {
|
||||
call := toolCalls[part.Index]
|
||||
if call == nil {
|
||||
call = &toolCall{Type: "function"}
|
||||
toolCalls[part.Index] = call
|
||||
}
|
||||
if part.ID != "" {
|
||||
call.ID = part.ID
|
||||
}
|
||||
if part.Type != "" {
|
||||
call.Type = part.Type
|
||||
}
|
||||
call.Function.Name += part.Function.Name
|
||||
call.Function.Arguments += part.Function.Arguments
|
||||
}
|
||||
if event.Choices[0].FinishReason != nil {
|
||||
finishReason = *event.Choices[0].FinishReason
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusBadGateway, err.Error(), "upstream_error", "zhanlu_stream_error")
|
||||
return
|
||||
}
|
||||
if id == "" {
|
||||
id = "chatcmpl-" + randomRequestID()
|
||||
}
|
||||
message := map[string]any{"role": "assistant", "content": content}
|
||||
if len(toolCalls) > 0 {
|
||||
ordered := make([]*toolCall, 0, len(toolCalls))
|
||||
for i := 0; i < len(toolCalls); i++ {
|
||||
if call := toolCalls[i]; call != nil {
|
||||
ordered = append(ordered, call)
|
||||
}
|
||||
}
|
||||
message["tool_calls"] = ordered
|
||||
if content == "" {
|
||||
message["content"] = nil
|
||||
}
|
||||
}
|
||||
if reasoning != "" {
|
||||
message["reasoning_content"] = reasoning
|
||||
}
|
||||
result := map[string]any{
|
||||
"id": id, "object": "chat.completion", "created": time.Now().Unix(), "model": model,
|
||||
"choices": []map[string]any{{"index": 0, "message": message, "finish_reason": finishReason}},
|
||||
}
|
||||
if usage != nil {
|
||||
result["usage"] = usage
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func forEachDecryptedChunk(r io.Reader, token string, fn func([]byte) error) error {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), 2*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
var upstreamError map[string]any
|
||||
if json.Unmarshal([]byte(line), &upstreamError) == nil && upstreamError["state"] == "ERROR" {
|
||||
return fmt.Errorf("zhanlu upstream error: %v", upstreamError["errorMessage"])
|
||||
}
|
||||
continue
|
||||
}
|
||||
ciphertext := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if ciphertext == "" || ciphertext == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
plain, err := auth.DecryptCredential(ciphertext, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt zhanlu stream: %w", err)
|
||||
}
|
||||
if !json.Valid([]byte(plain)) {
|
||||
return errors.New("zhanlu stream contained invalid decrypted JSON")
|
||||
}
|
||||
if err := fn([]byte(plain)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func (s *Server) currentCredentials() (auth.Credentials, error) {
|
||||
if s.cfg.Credentials.Validate() == nil {
|
||||
return s.cfg.Credentials, nil
|
||||
}
|
||||
c, err := auth.LoadCredentials(s.cfg.CredentialsPath)
|
||||
if err != nil {
|
||||
return auth.Credentials{}, err
|
||||
}
|
||||
return c, c.Validate()
|
||||
}
|
||||
|
||||
func (s *Server) signer() (sign.Signer, error) {
|
||||
if strings.TrimSpace(s.cfg.PublicKeyPEM) == "" {
|
||||
return sign.Signer{Encryptor: func(text string) (string, error) {
|
||||
return "", errors.New("ZHANLU_PUBLIC_KEY_PEM is required for signed upstream requests")
|
||||
}}, nil
|
||||
}
|
||||
pub, err := auth.ParsePublicKey(s.cfg.PublicKeyPEM)
|
||||
if err != nil {
|
||||
return sign.Signer{}, err
|
||||
}
|
||||
return sign.Signer{PublicKey: pub}, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeOpenAIError(w http.ResponseWriter, code int, message, typ, errCode string) {
|
||||
writeJSON(w, code, openai.ErrorResponse{Error: openai.ErrorBody{Message: message, Type: typ, Param: nil, Code: errCode}})
|
||||
}
|
||||
|
||||
func mask(s string) string {
|
||||
if len(s) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return s[:4] + "****" + s[len(s)-4:]
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func redactSensitive(s string) string {
|
||||
for _, key := range []string{"AccessKey", "authorization", "Signature"} {
|
||||
s = redactQueryValue(s, key)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func redactQueryValue(s, key string) string {
|
||||
needle := key + "="
|
||||
for {
|
||||
start := strings.Index(s, needle)
|
||||
if start < 0 {
|
||||
return s
|
||||
}
|
||||
valueStart := start + len(needle)
|
||||
valueEnd := len(s)
|
||||
if amp := strings.Index(s[valueStart:], "&"); amp >= 0 {
|
||||
valueEnd = valueStart + amp
|
||||
}
|
||||
s = s[:valueStart] + "<redacted>" + s[valueEnd:]
|
||||
}
|
||||
}
|
||||
|
||||
var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>湛卢代理登录</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: radial-gradient(circle at top left, #dde7ff, transparent 34rem), linear-gradient(135deg, #101828, #1f2937); color: #e5e7eb; }
|
||||
main { width: min(760px, calc(100vw - 32px)); display: grid; grid-template-columns: 1fr 1fr; gap: 24px; align-items: stretch; }
|
||||
.hero, form { border: 1px solid rgba(255,255,255,.14); background: rgba(15,23,42,.78); backdrop-filter: blur(18px); border-radius: 24px; box-shadow: 0 24px 80px rgba(0,0,0,.28); }
|
||||
.hero { padding: 30px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
h1 { margin: 0; font-size: clamp(28px, 4vw, 44px); letter-spacing: -0.04em; }
|
||||
p { color: #b6c2d9; line-height: 1.7; }
|
||||
code { color: #bfdbfe; word-break: break-all; }
|
||||
.login-card { padding: 28px; display: grid; gap: 16px; border: 1px solid rgba(255,255,255,.14); background: rgba(15,23,42,.78); backdrop-filter: blur(18px); border-radius: 24px; box-shadow: 0 24px 80px rgba(0,0,0,.28); }
|
||||
label { display: grid; gap: 8px; font-size: 14px; color: #cbd5e1; }
|
||||
input, textarea { width: 100%; box-sizing: border-box; border: 1px solid rgba(148,163,184,.35); border-radius: 14px; padding: 12px 14px; background: rgba(2,6,23,.55); color: #f8fafc; outline: none; font: inherit; }
|
||||
input:focus, textarea:focus { border-color: #60a5fa; box-shadow: 0 0 0 4px rgba(96,165,250,.16); }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.login-button { display: block; text-align: center; text-decoration: none; border: 0; border-radius: 14px; padding: 14px 16px; background: linear-gradient(135deg, #3b82f6, #8b5cf6); color: white; font-weight: 700; cursor: pointer; font: inherit; }
|
||||
.login-button:hover { filter: brightness(1.08); }
|
||||
.status { min-height: 22px; color: #93c5fd; }
|
||||
.muted { font-size: 13px; color: #94a3b8; }
|
||||
@media (max-width: 760px) { main { grid-template-columns: 1fr; padding: 18px 0; } .row { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div>
|
||||
<h1>湛卢代理登录</h1>
|
||||
<p>输入手机号获取验证码,按插件默认的移动云登录接口换取凭据。服务会保存凭据,后续 OpenAI 兼容接口自动使用。</p>
|
||||
</div>
|
||||
<div class="muted">保存位置:<br><code>{{.CredentialsPath}}</code></div>
|
||||
</section>
|
||||
<section class="login-card">
|
||||
<h2>手机号验证码登录</h2>
|
||||
<p>手机号和一次性 secret 会按插件逻辑用 RSA 加密后提交到移动云公网接口。</p>
|
||||
<form id="phone-form">
|
||||
<label>手机号<input name="telephone" inputmode="numeric" autocomplete="tel" placeholder="请输入 11 位手机号" required></label>
|
||||
<label>验证码
|
||||
<div class="row">
|
||||
<input name="code" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位验证码" required>
|
||||
<button class="login-button" id="code-button" type="button">获取验证码</button>
|
||||
</div>
|
||||
</label>
|
||||
<button class="login-button" type="submit">登录并保存凭据</button>
|
||||
</form>
|
||||
<div class="status" id="status">正在检查登录状态...</div>
|
||||
<div class="muted">凭据保存到 JSON;验证码本身不会保存。</div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const statusEl = document.getElementById('status');
|
||||
const form = document.getElementById('phone-form');
|
||||
const codeButton = document.getElementById('code-button');
|
||||
let secret = '';
|
||||
let countdown = 0;
|
||||
let countdownTimer = null;
|
||||
|
||||
function setStatus(text) {
|
||||
statusEl.textContent = text;
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
countdown = 60;
|
||||
codeButton.disabled = true;
|
||||
countdownTimer && clearInterval(countdownTimer);
|
||||
countdownTimer = setInterval(() => {
|
||||
if (countdown <= 0) {
|
||||
clearInterval(countdownTimer);
|
||||
codeButton.disabled = false;
|
||||
codeButton.textContent = '获取验证码';
|
||||
return;
|
||||
}
|
||||
codeButton.textContent = countdown + 's';
|
||||
countdown--;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
fetch('/api/credentials').then(r => r.json()).then(data => {
|
||||
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
|
||||
});
|
||||
|
||||
codeButton.addEventListener('click', async () => {
|
||||
const telephone = form.telephone.value.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(telephone)) {
|
||||
setStatus('请输入有效的 11 位手机号');
|
||||
return;
|
||||
}
|
||||
codeButton.disabled = true;
|
||||
setStatus('正在发送验证码...');
|
||||
const res = await fetch('/api/auth/code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ telephone })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) {
|
||||
codeButton.disabled = false;
|
||||
setStatus(data.error || '验证码发送失败');
|
||||
return;
|
||||
}
|
||||
secret = data.secret;
|
||||
setStatus('验证码已发送');
|
||||
startCountdown();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const telephone = form.telephone.value.trim();
|
||||
const code = form.code.value.trim();
|
||||
if (!secret) {
|
||||
setStatus('请先获取验证码');
|
||||
return;
|
||||
}
|
||||
setStatus('正在登录并保存凭据...');
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ telephone, code, secret })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) {
|
||||
setStatus(data.error || '登录失败');
|
||||
return;
|
||||
}
|
||||
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';JSON:' + (data.path || ''));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
var loginResultTemplate = template.Must(template.New("login-result").Parse(`<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>湛卢登录结果</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f172a;color:#e2e8f0;font-family:system-ui}.card{max-width:560px;margin:24px;padding:32px;border:1px solid #334155;border-radius:22px;background:#1e293b;text-align:center}a{color:#93c5fd}</style></head>
|
||||
<body><main class="card">{{if .Success}}<h1>登录成功</h1>{{else}}<h1>登录失败</h1>{{end}}<p>{{.Message}}</p><a href="/login">返回登录页</a></main></body></html>`))
|
||||
@@ -0,0 +1,77 @@
|
||||
package sign
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
||||
)
|
||||
|
||||
type Signer struct {
|
||||
PublicKey *rsa.PublicKey
|
||||
Now func() time.Time
|
||||
Nonce func() string
|
||||
Encryptor func(string) (string, error)
|
||||
}
|
||||
|
||||
func (s Signer) BuildOpURL(path string, creds auth.Credentials, baseURL string, method string) (string, error) {
|
||||
if err := creds.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if method == "" {
|
||||
method = "POST"
|
||||
}
|
||||
now := time.Now
|
||||
if s.Now != nil {
|
||||
now = s.Now
|
||||
}
|
||||
nonce := randomUUID
|
||||
if s.Nonce != nil {
|
||||
nonce = s.Nonce
|
||||
}
|
||||
encryptor := s.Encryptor
|
||||
if encryptor == nil {
|
||||
encryptor = func(text string) (string, error) { return auth.EncryptAuthorization(s.PublicKey, text) }
|
||||
}
|
||||
|
||||
base64Auth, err := encryptor(fmt.Sprintf("%d:%s", now().UnixMilli(), creds.Token))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
timestamp := now().Format("2006-01-02T15:04:05Z")
|
||||
query := "AccessKey=" + creds.AccessKey +
|
||||
"&SignatureMethod=HmacSHA1" +
|
||||
"&SignatureNonce=" + nonce() +
|
||||
"&SignatureVersion=V2.0" +
|
||||
"&Timestamp=" + timestamp +
|
||||
"&Version=2016-12-05" +
|
||||
"&authorization=" + url.QueryEscape(base64Auth)
|
||||
|
||||
canonicalQuery := strings.ReplaceAll(query, ":", "%3A")
|
||||
queryHash := sha256.Sum256([]byte(canonicalQuery))
|
||||
stringToSign := strings.ToUpper(method) + "\n" + strings.ReplaceAll(path, "/", "%2F") + "\n" + hex.EncodeToString(queryHash[:])
|
||||
|
||||
mac := hmac.New(sha1.New, []byte("BC_SIGNATURE&"+creds.SecretKey))
|
||||
mac.Write([]byte(stringToSign))
|
||||
signature := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return strings.TrimRight(baseURL, "/") + path + "?" + query + "&Signature=" + signature, nil
|
||||
}
|
||||
|
||||
func randomUUID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package zhanlu
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/sign"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Path string
|
||||
Creds auth.Credentials
|
||||
Signer sign.Signer
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL, path string, creds auth.Credentials, signer sign.Signer, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
BaseURL: baseURL,
|
||||
Path: path,
|
||||
Creds: creds,
|
||||
Signer: signer,
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) ChatCompletions(ctx context.Context, body []byte) (*http.Response, error) {
|
||||
baseURL := c.BaseURL
|
||||
if c.Creds.BaseURL != "" {
|
||||
baseURL = c.Creds.BaseURL
|
||||
}
|
||||
signedURL, err := c.Signer.BuildOpURL(c.Path, c.Creds, baseURL, http.MethodPost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encryptedBody, err := auth.EncryptCredential(string(body), c.Creds.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wrappedBody := []byte(fmt.Sprintf(`{"data":%q}`, encryptedBody))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signedURL, bytes.NewReader(wrappedBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "text/event-stream, application/json")
|
||||
req.Header.Set("plugin_type", "vscode")
|
||||
req.Header.Set("plugin_version", "2.8.0")
|
||||
req.Header.Set("service_type", "code")
|
||||
req.Header.Set("request", randomRequestID())
|
||||
return c.HTTPClient.Do(req)
|
||||
}
|
||||
|
||||
func randomRequestID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
}
|
||||
Reference in New Issue
Block a user