@@ -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