diff --git a/.gitignore b/.gitignore index c78ca7d..30b6e12 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ go.work.sum # Built binary cloudpc +dist/ # Runtime config cloud_pc.json @@ -33,6 +34,17 @@ cloud_pc.json # env file .env +# Local reverse-engineering/probe artifacts +.cache/ +.tools/ +.pydeps/ +native_probe/ +native_probe/sdk_zte/ +native_probe/appdata/ +native_probe/tmp/ +native_probe/firm_auth_private.json +native_probe/captures/ + # Editor/IDE # .idea/ # .vscode/ diff --git a/cmd/keepalive.go b/cmd/keepalive.go index e88fd79..a32b0cf 100644 --- a/cmd/keepalive.go +++ b/cmd/keepalive.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "cloud-computer-keepalive/internal/cem" "cloud-computer-keepalive/internal/chuanyun" "cloud-computer-keepalive/internal/config" @@ -9,9 +10,11 @@ import ( "cloud-computer-keepalive/internal/scg" "cloud-computer-keepalive/internal/soho" "cloud-computer-keepalive/internal/spice" + "cloud-computer-keepalive/internal/zte" + "context" "encoding/binary" - "encoding/json" "fmt" + "io" "net" "os" "os/signal" @@ -43,8 +46,23 @@ func Keepalive(args []string) { } logger.Infof("SohoToken: %s", logger.Mask(sohoToken, 4)) - // 1. Get CEM access_token - accessToken, err := cem.GetCEMAccessToken(sohoToken, userID) + // 1. Get firm auth. Sub-account/ZTE clients may receive CAG/VMC fields + // without an SCG auth code, matching the official Windows client flow. + firmAuth, err := cem.GetFirmAuth(sohoToken, userID) + if err != nil { + logger.Errorf("getFirmAuth failed: %v", err) + os.Exit(1) + } + firmAuthCode, _ := firmAuth["scAuthCode"].(string) + if firmAuthCode == "" { + if err := keepaliveZTE(firmAuth, sohoToken, userID, duration); err != nil { + logger.Errorf("ZTE keepalive failed: %v", err) + os.Exit(1) + } + return + } + + accessToken, err := cem.ExchangeCEMAccessToken(firmAuthCode) if err != nil { logger.Errorf("Get CEM access_token failed: %v", err) os.Exit(1) @@ -90,6 +108,343 @@ func Keepalive(args []string) { keepaliveLoop(tlsConn, sohoToken, userID, duration) } +func keepaliveZTE(firmAuth map[string]any, sohoToken, userID string, duration int) error { + return keepaliveZTESession(firmAuth, sohoToken, userID, duration) +} + +func keepaliveZTESession(firmAuth map[string]any, sohoToken, userID string, duration int) error { + logFirmAuthFallback(firmAuth) + + firm := zte.FirmAuth{ + VMUserName: jsonString(firmAuth["vmUserName"]), + VMPassword: jsonString(firmAuth["vmPassword"]), + VMID: jsonString(firmAuth["vmId"]), + VMCIP: jsonString(firmAuth["vmcIp"]), + VMCPort: mustAtoi(jsonString(firmAuth["vmcPort"])), + CAGIP: jsonString(firmAuth["cagIp"]), + CAGPort: mustAtoi(jsonString(firmAuth["cagPort"])), + } + if firm.VMUserName == "" || firm.VMPassword == "" || firm.VMID == "" || firm.CAGIP == "" || firm.CAGPort == 0 { + return fmt.Errorf("firm auth is missing required ZTE fields") + } + + client := zte.NewClient(firm) + logger.Info("ZTE sysConfig...") + if _, err := client.SysConfig(); err != nil { + logger.Warnf("ZTE sysConfig failed, continue: %v", err) + } + + logger.Info("ZTE getToken...") + token, err := client.GetAccessToken() + if err != nil { + return err + } + logger.Infof("ZTE accessToken: %s", logger.Mask(token.AccessToken, 4)) + + logger.Info("ZTE getDesktopList...") + list, err := client.GetDesktopList(token.AccessToken) + if err != nil { + return err + } + desktop := zte.FirstDesktop(list, firm.VMID) + if desktop == nil { + return fmt.Errorf("ZTE desktop list has no matching vmId") + } + + logger.Info("ZTE startDesktop...") + start, err := client.StartDesktop(token.AccessToken, desktop) + if err != nil { + return err + } + connectStr := jsonString(start["connectStr"]) + if connectStr == "" { + logger.Info("ZTE startDesktop returned no connectStr, querying async result...") + for i := 0; i < 30; i++ { + async, err := client.StartDesktopAsyncQuery(token.AccessToken) + if err != nil { + return err + } + connectStr = jsonString(async["connectStr"]) + if connectStr != "" { + logger.Infof("ZTE async start ready after %ds", (i+1)*2) + break + } + time.Sleep(2 * time.Second) + } + } + if connectStr == "" { + return fmt.Errorf("ZTE startDesktop did not return connectStr") + } + + params, err := zte.DecodeConnectParams(connectStr) + if err != nil { + return err + } + logger.Infof("ZTE target: spice=%s:%d vmId=%s proxySport=%d", params.Host, params.Port, logger.Mask(params.VMID, 4), params.ProxySport) + + authTemplate := os.Getenv("CCK_ZTE_CAG_AUTH_TEMPLATE_HEX") + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + cagAddr := fmt.Sprintf("%s:%d", firm.CAGIP, firm.CAGPort) + logger.Infof("Connecting ZTE CAG %s...", cagAddr) + tlsConn, session, err := zte.DialCAGTCPTLS(ctx, zte.CAGDialOptions{ + Address: cagAddr, + Params: params, + AuthTemplateHex: authTemplate, + Timeout: 30 * time.Second, + }) + if err != nil { + logger.Warnf("ZTE CAG TCP/TLS failed, trying UDP/KCP path: %v", err) + tlsConn, session, err = zte.DialCAGTLS(ctx, zte.CAGDialOptions{ + Address: cagAddr, + Params: params, + AuthTemplateHex: authTemplate, + Timeout: 30 * time.Second, + }) + if err != nil { + return err + } + } + defer tlsConn.Close() + logger.Infof("ZTE CAG TLS established: conv=0x%08x", session.Conv) + + mux := zte.NewCAGMux(tlsConn) + proxyConn, err := zte.OpenCAGMuxLink(ctx, mux, params, 1) + if err != nil { + return err + } + logger.Info("ZTE CAG proxy add-link sent") + rawResult := spice.RawMainHandshake(proxyConn, params.Key, params.VMID, proxyConn.LinkUUID(), proxyConn.TraceID(), proxyConn.REDQSpanID()) + if !rawResult.OK { + return fmt.Errorf("ZTE raw SPICE main handshake failed") + } + subLinks, authed, err := sendZTESubchannelREDQs(ctx, mux, params, proxyConn, rawResult.SpiceSessionID) + if err != nil { + logger.Warnf("ZTE subchannel REDQ probe failed: %v", err) + } + startZTESubchannelKeepalive(subLinks, authed) + return keepaliveRawSpiceLoop(proxyConn, sohoToken, userID, duration) +} + +func sendZTESubchannelREDQs(ctx context.Context, mux *zte.CAGMux, params *zte.ConnectParams, main *zte.CAGMuxLink, connectionID uint32) (map[byte]*zte.CAGMuxLink, map[byte]bool, error) { + type subREDQ struct { + linkID byte + channelType uint8 + channelID uint8 + } + firstLinks := []byte{2, 3, 4, 5} + secondLinks := []byte{6, 7, 8} + redqs := []subREDQ{ + {linkID: 3, channelType: 4, channelID: 1}, + {linkID: 2, channelType: 6, channelID: 0}, + {linkID: 4, channelType: 5, channelID: 0}, + {linkID: 6, channelType: 3, channelID: 0}, + {linkID: 7, channelType: 2, channelID: 0}, + {linkID: 8, channelType: 4, channelID: 0}, + {linkID: 5, channelType: 2, channelID: 1}, + } + + links := make(map[byte]*zte.CAGMuxLink) + for _, linkID := range firstLinks { + link, err := zte.OpenCAGMuxLinkWithTrace(ctx, mux, params, linkID, main.TraceID(), main.SpanID()) + if err != nil { + return links, nil, err + } + links[linkID] = link + } + for _, item := range redqs[:3] { + payload := spice.BuildZTERawChannelREDQ(params.Key, params.VMID, main.LinkUUID(), main.TraceID(), main.REDQSpanID(), connectionID, item.channelType, item.channelID) + if _, err := links[item.linkID].Write(payload); err != nil { + return links, nil, err + } + } + for _, linkID := range secondLinks { + link, err := zte.OpenCAGMuxLinkWithTrace(ctx, mux, params, linkID, main.TraceID(), main.SpanID()) + if err != nil { + return links, nil, err + } + links[linkID] = link + } + for _, item := range redqs[3:] { + payload := spice.BuildZTERawChannelREDQ(params.Key, params.VMID, main.LinkUUID(), main.TraceID(), main.REDQSpanID(), connectionID, item.channelType, item.channelID) + if _, err := links[item.linkID].Write(payload); err != nil { + return links, nil, err + } + } + logger.Infof("ZTE subchannel REDQ probe sent (connectionID=0x%s)", logger.Mask(fmt.Sprintf("%08x", connectionID), 4)) + authed := authenticateZTESubchannels(links, 8*time.Second) + return links, authed, nil +} + +func authenticateZTESubchannels(links map[byte]*zte.CAGMuxLink, timeout time.Duration) map[byte]bool { + pending := make(map[byte][]byte) + authing := make(map[byte]bool) + done := make(map[byte]bool) + deadline := time.Now().Add(timeout) + defer func() { + logger.Infof("ZTE subchannel auth completed: %d/%d", len(done), len(links)) + }() + defer func() { + for linkID, link := range links { + if done[linkID] { + _ = link.SetReadDeadline(time.Time{}) + } + } + }() + for time.Now().Before(deadline) && len(done) < len(links) { + progress := false + for linkID, link := range links { + if done[linkID] { + continue + } + _ = link.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + payload := make([]byte, 4096) + n, err := link.Read(payload) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + logger.Debugf("ZTE subchannel link=%d read stopped: %v", linkID, err) + continue + } + payload = payload[:n] + progress = true + if authing[linkID] && len(payload) == 4 { + result := binary.LittleEndian.Uint32(payload) + logger.Debugf("ZTE subchannel link=%d auth result=%d", linkID, result) + if result == 0 { + done[linkID] = true + } + continue + } + pending[linkID] = append(pending[linkID], payload...) + if !authing[linkID] && len(pending[linkID]) > 32 { + if !bytes.Contains(pending[linkID], []byte("REDQ")) { + continue + } + ticket := make([]byte, 128) + if _, err := link.Write(ticket); err != nil { + return done + } + authing[linkID] = true + logger.Debugf("ZTE subchannel link=%d auth ticket sent", linkID) + } + } + if !progress { + time.Sleep(20 * time.Millisecond) + } + } + return done +} + +func startZTESubchannelKeepalive(links map[byte]*zte.CAGMuxLink, authed map[byte]bool) { + for linkID, link := range links { + if !authed[linkID] { + continue + } + switch linkID { + case 6: + if _, err := spice.WriteRawMessage(link, 1, spice.BuildZTERawInputInit()); err != nil { + logger.Warnf("ZTE input init failed on link=%d: %v", linkID, err) + } else { + logger.Debugf("ZTE input init sent on link=%d", linkID) + } + case 5, 7: + if _, err := spice.WriteRawMessage(link, 1, spice.BuildZTERawDisplayInit()); err != nil { + logger.Warnf("ZTE display init failed on link=%d: %v", linkID, err) + } else { + logger.Debugf("ZTE display init sent on link=%d", linkID) + } + } + go keepZTESubchannelAlive(linkID, link) + } +} + +func keepZTESubchannelAlive(linkID byte, link *zte.CAGMuxLink) { + state := &spice.RawState{} + for { + msgType, payload, err := state.ReadMessage(link, 30*time.Second) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + if err != io.EOF { + logger.Debugf("ZTE subchannel link=%d stopped: %v", linkID, err) + } + return + } + logger.Debugf("ZTE subchannel link=%d recv msg=0x%02x len=%d", linkID, msgType, len(payload)) + if state.AutoReply(link, msgType, payload) { + logger.Debugf("ZTE subchannel link=%d auto-reply msg=0x%02x", linkID, msgType) + } + } +} + +func keepaliveRawSpiceLoop(conn net.Conn, sohoToken, userID string, duration int) error { + if duration > 0 { + logger.Infof("Keeping raw SPICE connection for %ds...", duration) + } else { + logger.Info("Persistent raw SPICE keepalive (Ctrl+C to exit)...") + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + start := time.Now() + heartbeatCount := 0 + rawState := &spice.RawState{} + nextHeartbeat := start + + defer func() { + conn.Close() + logger.Info("Raw SPICE connection closed") + }() + + for { + select { + case <-sigCh: + logger.Info("User interrupted") + return nil + default: + } + + elapsed := int(time.Since(start).Seconds()) + if duration > 0 && elapsed >= duration { + logger.Infof("Raw SPICE keepalive %ds done, disconnecting", elapsed) + return nil + } + + now := time.Now() + if !now.Before(nextHeartbeat) { + cfg, _ := config.LoadConfig() + if cfg.UserServiceID != "" { + bodyJSON := fmt.Sprintf(`{"userServiceId":"%s"}`, cfg.UserServiceID) + bodyData, err := crypto.RSAEncrypt(bodyJSON) + if err == nil { + if _, err = soho.SohoRequest("/cc/cloudPc/heartbeat/v2", bodyData, sohoToken, userID); err != nil { + logger.Warnf("Heartbeat error: %v", err) + } + } + } + heartbeatCount++ + logger.Infof("Raw SPICE heartbeat #%d (uptime=%ds)", heartbeatCount, elapsed) + nextHeartbeat = now.Add(25 * time.Second) + } + + msgType, payload, err := rawState.ReadMessage(conn, 1*time.Second) + if err != nil { + if err == io.EOF { + logger.Info("Raw SPICE server closed connection") + return fmt.Errorf("raw SPICE server closed connection") + } + continue + } + logger.Debugf("Raw SPICE loop recv msg=0x%02x len=%d", msgType, len(payload)) + if rawState.AutoReply(conn, msgType, payload) { + logger.Debugf("Raw SPICE auto-reply msg=0x%02x", msgType) + } + } +} + func keepaliveLoop(conn net.Conn, sohoToken, userID string, duration int) { if duration > 0 { logger.Infof("Keeping connection for %ds...", duration) @@ -225,5 +580,45 @@ func keepaliveLoop(conn net.Conn, sohoToken, userID string, duration int) { } } -// suppress unused import warning -var _ = json.Marshal +func sendSohoCloudPCAction(path, sohoToken, userID string) error { + cfg, err := config.LoadConfig() + if err != nil { + return err + } + if cfg.UserServiceID == "" { + return fmt.Errorf("missing user_service_id in config, please run login first") + } + + bodyJSON := fmt.Sprintf(`{"userServiceId":"%s"}`, cfg.UserServiceID) + bodyData, err := crypto.RSAEncrypt(bodyJSON) + if err != nil { + return err + } + + result, err := soho.SohoRequest(path, bodyData, sohoToken, userID) + if err != nil { + return err + } + code, _ := result["code"].(float64) + if code != 2000 && code != 4041 { + return fmt.Errorf("code=%v, msg=%v", result["code"], result["msg"]) + } + return nil +} + +func logFirmAuthFallback(data map[string]any) { + vmc := jsonString(data["vmcIp"]) + vmcPort := jsonString(data["vmcPort"]) + cag := jsonString(data["cagIp"]) + cagPort := jsonString(data["cagPort"]) + vmID := jsonString(data["vmId"]) + if vmc != "" || cag != "" { + logger.Infof("Firm auth: vmId=%s, vmc=%s:%s, cag=%s:%s", + logger.Mask(vmID, 4), vmc, vmcPort, cag, cagPort) + } +} + +func mustAtoi(s string) int { + n, _ := strconv.Atoi(s) + return n +} diff --git a/cmd/login.go b/cmd/login.go index 9454a73..bf70087 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -41,8 +41,11 @@ func Login() { fmt.Println("[*] Login method:") fmt.Println(" [1] SMS code") fmt.Println(" [2] Account password") + fmt.Println(" [3] Sub account password") defaultMethod := "1" - if cfg.Username != "" { + if cfg.LoginMode == "sub_password" || cfg.SubAccount != "" { + defaultMethod = "3" + } else if cfg.Username != "" { defaultMethod = "2" } fmt.Printf("[?] Choose [%s]: ", defaultMethod) @@ -56,16 +59,20 @@ func Login() { switch method { case "1": data = loginSMS(scanner, cfg) + cfg.LoginMode = "sms" case "2": data = loginPassword(scanner, cfg) + cfg.LoginMode = "password" + case "3": + data = loginSubPassword(scanner, cfg) + cfg.LoginMode = "sub_password" default: fmt.Printf("[-] Invalid choice: %s\n", method) os.Exit(1) } cfg.SohoToken, _ = data["sohoToken"].(string) - userIDFloat, _ := data["userId"].(float64) - cfg.UserID = fmt.Sprintf("%.0f", userIDFloat) + cfg.UserID = jsonString(data["userId"]) fmt.Printf("[+] Login success! UserId: %s\n", logger.Mask(cfg.UserID, 4)) // 3. Get cloud PC info @@ -209,6 +216,59 @@ func loginPassword(scanner *bufio.Scanner, cfg *config.Config) map[string]any { return data } +func loginSubPassword(scanner *bufio.Scanner, cfg *config.Config) map[string]any { + subAccount := cfg.SubAccount + if subAccount != "" { + fmt.Printf("[?] Sub account [%s]: ", logger.Mask(subAccount, 4)) + scanner.Scan() + inp := strings.TrimSpace(scanner.Text()) + if inp != "" { + subAccount = inp + } + } else { + fmt.Print("[?] Sub account: ") + scanner.Scan() + subAccount = strings.TrimSpace(scanner.Text()) + } + if subAccount == "" { + fmt.Println("[-] Sub account cannot be empty") + os.Exit(1) + } + + password := cfg.SubPassword + if password != "" { + fmt.Print("[?] Sub account password [****]: ") + scanner.Scan() + inp := strings.TrimSpace(scanner.Text()) + if inp != "" { + password = inp + } + } else { + fmt.Print("[?] Sub account password: ") + scanner.Scan() + password = strings.TrimSpace(scanner.Text()) + } + if password == "" { + fmt.Println("[-] Sub account password cannot be empty") + os.Exit(1) + } + + fmt.Printf("[*] Logging in as sub account %s...\n", logger.Mask(subAccount, 4)) + data, err := soho.SubAccountPasswordLogin(subAccount, password) + if err != nil { + fmt.Printf("[-] Login failed: %v\n", err) + os.Exit(1) + } + + cfg.SubAccount = subAccount + cfg.SubPassword = password + if phone, _ := data["mainAccountPhone"].(string); phone != "" { + cfg.Phone = phone + } + + return data +} + func fetchCloudPC(scanner *bufio.Scanner, cfg *config.Config) { fmt.Println("[*] Getting cloud PC list...") listJSON := `{"pageNum":1,"pageSize":100}` @@ -289,3 +349,16 @@ func fetchCloudPC(scanner *bufio.Scanner, cfg *config.Config) { data, _ = result["data"].(map[string]any) cfg.VMID, _ = data["vmId"].(string) } + +func jsonString(v any) string { + switch value := v.(type) { + case nil: + return "" + case string: + return value + case float64: + return fmt.Sprintf("%.0f", value) + default: + return fmt.Sprintf("%v", value) + } +} diff --git a/go.mod b/go.mod index eddbbf8..c4da458 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,17 @@ module cloud-computer-keepalive -go 1.23.5 +go 1.24.0 + +require github.com/xtaci/kcp-go/v5 v5.5.16 + +require ( + github.com/klauspost/cpuid/v2 v2.2.6 // indirect + github.com/klauspost/reedsolomon v1.12.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/templexxx/cpu v0.0.7 // indirect + github.com/templexxx/xorsimd v0.4.1 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..70e3ef0 --- /dev/null +++ b/go.sum @@ -0,0 +1,121 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/klauspost/cpuid v1.2.4/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/cpuid v1.3.1/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4= +github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= +github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/reedsolomon v1.9.9/go.mod h1:O7yFFHiQwDR6b2t63KPUpccPtNdp5ADgh1gg4fd12wo= +github.com/klauspost/reedsolomon v1.12.0 h1:I5FEp3xSwVCcEh3F5A7dofEfhXdF/bWhQWPH+XwBFno= +github.com/klauspost/reedsolomon v1.12.0/go.mod h1:EPLZJeh4l27pUGC3aXOjheaoh1I9yut7xTURiW3LQ9Y= +github.com/mmcloughlin/avo v0.0.0-20200803215136-443f81d77104/go.mod h1:wqKykBG2QzQDJEzvRkcS8x6MiSJkF52hXZsXcjaB3ls= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/templexxx/cpu v0.0.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk= +github.com/templexxx/cpu v0.0.7 h1:pUEZn8JBy/w5yzdYWgx+0m0xL9uk6j4K91C5kOViAzo= +github.com/templexxx/cpu v0.0.7/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk= +github.com/templexxx/xorsimd v0.4.1 h1:iUZcywbOYDRAZUasAs2eSCUW8eobuZDy0I9FJiORkVg= +github.com/templexxx/xorsimd v0.4.1/go.mod h1:W+ffZz8jJMH2SXwuKu9WhygqBMbFnp14G2fqEr8qaNo= +github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/xtaci/kcp-go/v5 v5.5.16 h1:doQfgOdghnuqnOxsr1fR5kWTxV/X15bUKQXMc11uiOU= +github.com/xtaci/kcp-go/v5 v5.5.16/go.mod h1:pVx3jb4LT5edTmPayc77tIU9nRsjGck8wep5ZV/RBO0= +github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM= +github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae/go.mod h1:gXtu8J62kEgmN++bm9BVICuT/e8yiLI2KFobd/TRFsE= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/arch v0.0.0-20190909030613-46d78d1859ac/go.mod h1:flIaEI6LNU6xOCD5PaJvn9wGP0agmIOqjrtsKGRguv4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200808120158-1030fc2bf1d9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200425043458-8463f397d07c/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200808161706-5bf02b21f123/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/cem/cem.go b/internal/cem/cem.go index e8ed5b2..9550838 100644 --- a/internal/cem/cem.go +++ b/internal/cem/cem.go @@ -15,10 +15,10 @@ import ( ) type ConnectInfo struct { - ScgIP string - ScgPort string - ScAuthCode string - TraceID string + ScgIP string + ScgPort string + ScAuthCode string + TraceID string ReadyStatus float64 } @@ -69,33 +69,50 @@ func cemRequest(path string, body any, accessToken string) (map[string]any, erro } func GetCEMAccessToken(sohoToken, userID string) (string, error) { - cfg, err := config.LoadConfig() + data, err := GetFirmAuth(sohoToken, userID) if err != nil { return "", err } + scAuthCode, _ := data["scAuthCode"].(string) + return ExchangeCEMAccessToken(scAuthCode) +} + +func GetFirmAuth(sohoToken, userID string) (map[string]any, error) { + cfg, err := config.LoadConfig() + if err != nil { + return nil, err + } if cfg.UserServiceID == "" { - return "", fmt.Errorf("missing user_service_id in config, please run login first") + return nil, fmt.Errorf("missing user_service_id in config, please run login first") } - logger.Info("Getting CEM access_token...") + logger.Info("Getting firm auth...") bodyJSON := fmt.Sprintf(`{"userServiceId":"%s"}`, cfg.UserServiceID) bodyData, err := crypto.RSAEncrypt(bodyJSON) if err != nil { - return "", fmt.Errorf("rsa encrypt: %w", err) + return nil, fmt.Errorf("rsa encrypt: %w", err) } result, err := soho.SohoRequest("/cc/getFirmAuth/v1", bodyData, sohoToken, userID) if err != nil { - return "", fmt.Errorf("getFirmAuth: %w", err) + return nil, fmt.Errorf("getFirmAuth: %w", err) } code, _ := result["code"].(float64) if code != 2000 { - return "", fmt.Errorf("getFirmAuth failed: code=%v, msg=%v", result["code"], result["msg"]) + return nil, fmt.Errorf("getFirmAuth failed: code=%v, msg=%v", result["code"], result["msg"]) } data, _ := result["data"].(map[string]any) - scAuthCode, _ := data["scAuthCode"].(string) + return data, nil +} + +func ExchangeCEMAccessToken(scAuthCode string) (string, error) { + if scAuthCode == "" { + return "", fmt.Errorf("empty scAuthCode in firm auth response") + } + + logger.Info("Getting CEM access_token...") // oauth/token formData := url.Values{ diff --git a/internal/config/config.go b/internal/config/config.go index a0645c8..a78ee3c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,6 +47,9 @@ type Config struct { Phone string `json:"phone,omitempty"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` + SubAccount string `json:"sub_account,omitempty"` + SubPassword string `json:"sub_password,omitempty"` + LoginMode string `json:"login_mode,omitempty"` SohoToken string `json:"soho_token,omitempty"` UserID string `json:"user_id,omitempty"` UserServiceID string `json:"user_service_id,omitempty"` diff --git a/internal/soho/soho.go b/internal/soho/soho.go index b89a67e..cafbbb3 100644 --- a/internal/soho/soho.go +++ b/internal/soho/soho.go @@ -122,8 +122,7 @@ func GetDynamicRSAKey() (*rsa.PublicKey, error) { return crypto.ParseRSAPublicKey(pubKeyB64) } -// PasswordLogin performs username+password login. Returns the response data map. -func PasswordLogin(username, password string) (map[string]any, error) { +func passwordLogin(path, accountField, account, password string) (map[string]any, error) { dynamicKey, err := GetDynamicRSAKey() if err != nil { return nil, fmt.Errorf("get public key: %w", err) @@ -134,13 +133,13 @@ func PasswordLogin(username, password string) (map[string]any, error) { return nil, fmt.Errorf("encrypt password: %w", err) } - loginJSON := fmt.Sprintf(`{"username":"%s","password":"%s","verificationCode":"","randomCode":""}`, username, encryptedPwd) + loginJSON := fmt.Sprintf(`{"%s":"%s","password":"%s","verificationCode":"","randomCode":""}`, accountField, account, encryptedPwd) encryptedBody, err := crypto.RSAEncryptChunked(loginJSON) if err != nil { return nil, fmt.Errorf("encrypt body: %w", err) } - result, err := SohoRequest("/login/namePwdLogin/v1", encryptedBody, "", "") + result, err := SohoRequest(path, encryptedBody, "", "") if err != nil { return nil, err } @@ -152,6 +151,16 @@ func PasswordLogin(username, password string) (map[string]any, error) { return data, nil } +// PasswordLogin performs username+password login. Returns the response data map. +func PasswordLogin(username, password string) (map[string]any, error) { + return passwordLogin("/login/namePwdLogin/v1", "username", username, password) +} + +// SubAccountPasswordLogin performs sub-account username+password login. +func SubAccountPasswordLogin(subAccount, password string) (map[string]any, error) { + return passwordLogin("/login/home/namePwdLogin/v1", "subAccount", subAccount, password) +} + func LoadSohoToken() (string, string) { cfg, err := config.LoadConfig() if err != nil || cfg.SohoToken == "" { diff --git a/internal/spice/raw.go b/internal/spice/raw.go new file mode 100644 index 0000000..9b56867 --- /dev/null +++ b/internal/spice/raw.go @@ -0,0 +1,471 @@ +package spice + +import ( + "bytes" + "cloud-computer-keepalive/internal/logger" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "encoding/binary" + "fmt" + "io" + "net" + "time" +) + +type RawHandshakeResult struct { + SpiceSessionID uint32 + OK bool +} + +var lastRawSerial uint32 +var lastRawSuffix []byte + +type RawState struct { + LastSerial uint32 + LastSuffix []byte + NextSerial uint32 +} + +func RawMainHandshake(conn net.Conn, key, vmid string, linkUUID []byte, traceID, spanID string) *RawHandshakeResult { + state := &RawState{} + logger.Info("Raw SPICE main channel link...") + link := buildZTERawMainREDQ(key, vmid, linkUUID, traceID, spanID) + if _, err := conn.Write(link); err != nil { + logger.Errorf("Raw SPICE send REDQ failed: %v", err) + return &RawHandshakeResult{} + } + + reply, err := readRawLinkReply(conn, 8*time.Second) + if err != nil { + logger.Errorf("Raw SPICE read link reply failed: %v", err) + return &RawHandshakeResult{} + } + pkOff := bytes.Index(reply, []byte{0x30, 0x81, 0x9f, 0x30, 0x0d}) + if pkOff < 0 { + pkOff = bytes.Index(reply, []byte{0x30, 0x81}) + } + if pkOff < 0 { + logger.Error("Raw SPICE link reply has no RSA key") + return &RawHandshakeResult{} + } + end := pkOff + 162 + if end > len(reply) { + end = len(reply) + } + parsedKey, err := x509.ParsePKIXPublicKey(reply[pkOff:end]) + if err != nil { + logger.Errorf("Raw SPICE parse RSA key failed: %v", err) + return &RawHandshakeResult{} + } + pub, ok := parsedKey.(*rsa.PublicKey) + if !ok { + logger.Error("Raw SPICE link RSA key has unexpected type") + return &RawHandshakeResult{} + } + _ = pub + + // ZTE's raw SPICE tunnel advertises the normal RSA link reply, but the + // official client answers with a 128-byte zero ticket and no auth-type + // prefix. Sending the regular SPICE auth type leaves the server stream + // misaligned: auth can appear to succeed, then main init is closed. + ticket := make([]byte, 128) + if _, err := conn.Write(ticket); err != nil { + logger.Errorf("Raw SPICE send ticket failed: %v", err) + return &RawHandshakeResult{} + } + + result := make([]byte, 4) + _ = conn.SetReadDeadline(time.Now().Add(8 * time.Second)) + if _, err := io.ReadFull(conn, result); err != nil { + logger.Errorf("Raw SPICE read auth result failed: %v", err) + return &RawHandshakeResult{} + } + if code := binary.LittleEndian.Uint32(result); code != 0 { + logger.Errorf("Raw SPICE auth failed: result=%d", code) + return &RawHandshakeResult{} + } + logger.Info("Raw SPICE main auth success") + + var spiceSessionID uint32 + for i := 0; i < 15; i++ { + msgType, payload, err := state.ReadMessage(conn, 2*time.Second) + if err != nil { + break + } + logger.Debugf("Raw SPICE recv msg=0x%02x len=%d", msgType, len(payload)) + if msgType == 0x67 && len(payload) >= 10 { + logger.Debugf("Raw SPICE MAIN_INIT payload head=%x", payload[:16]) + spiceSessionID = zteMainInitConnectionID(payload) + logger.Infof("Raw SPICE MAIN_INIT session=0x%s", logger.Mask(fmt.Sprintf("%08x", spiceSessionID), 4)) + if d, ok := conn.(interface{ DiscardReadBuffer() }); ok { + d.DiscardReadBuffer() + } + break + } + state.AutoReply(conn, msgType, payload) + } + if spiceSessionID == 0 { + logger.Error("Raw SPICE MAIN_INIT not received") + return &RawHandshakeResult{} + } + + attach, _ := hexDecode("680000000000") + attachSent := false + for i := 0; i < 4; i++ { + msgType, payload, err := state.ReadMessage(conn, 2*time.Second) + if err != nil { + break + } + logger.Debugf("Raw SPICE recv pre-init msg=0x%02x serial=%d len=%d", msgType, state.LastSerial, len(payload)) + if msgType == 0x04 { + if state.LastSerial == 3 || i == 3 { + _, _ = state.WriteMessage(conn, state.LastSerial, attach) + attachSent = true + break + } + continue + } + state.AutoReply(conn, msgType, payload) + } + if !attachSent { + _, _ = conn.Write(append(rawMessageWithPrefix(3, attach), make([]byte, 5)...)) + } + + clientInfo, _ := hexDecode("72000800000000000000000100000001000000") + terminalInfo := buildTerminalInfoMessage() + _, _ = conn.Write(rawMessageWithPrefix(1, clientInfo)) + _, _ = conn.Write(rawMessageWithPrefix(2, terminalInfo)) + logger.Info("Raw SPICE sent client info + ATTACH_CHANNELS") + initOK := false + for i := 0; i < 5; i++ { + msgType, payload, err := state.ReadMessage(conn, 1*time.Second) + if err != nil { + break + } + logger.Debugf("Raw SPICE recv init msg=0x%02x len=%d", msgType, len(payload)) + if msgType != 0x04 { + state.AutoReply(conn, msgType, payload) + } + if msgType == 0x68 { + initOK = true + } + if msgType == 0x73 { + initOK = true + break + } + } + if !initOK { + logger.Error("Raw SPICE init did not reach CHANNELS_LIST/info") + return &RawHandshakeResult{} + } + return &RawHandshakeResult{SpiceSessionID: spiceSessionID, OK: true} +} + +func BuildRawTicket(linkReply []byte) ([]byte, error) { + pkOff := bytes.Index(linkReply, []byte{0x30, 0x82}) + if pkOff < 0 { + pkOff = bytes.Index(linkReply, []byte{0x30, 0x81}) + } + if pkOff < 0 { + return nil, fmt.Errorf("raw SPICE link reply has no RSA key") + } + end := pkOff + derLength(linkReply[pkOff:]) + if end <= pkOff || end > len(linkReply) { + end = len(linkReply) + } + parsedKey, err := x509.ParsePKIXPublicKey(linkReply[pkOff:end]) + if err != nil { + return nil, err + } + pub, ok := parsedKey.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("raw SPICE link RSA key has unexpected type") + } + return rsa.EncryptOAEP(sha1.New(), rand.Reader, pub, []byte{}, nil) +} + +func derLength(der []byte) int { + if len(der) < 2 || der[0] != 0x30 { + return 0 + } + if der[1]&0x80 == 0 { + return int(der[1]) + 2 + } + n := int(der[1] & 0x7f) + if n == 0 || n > 4 || len(der) < 2+n { + return 0 + } + l := 0 + for i := 0; i < n; i++ { + l = (l << 8) | int(der[2+i]) + } + return 2 + n + l +} + +func zteMainInitConnectionID(payload []byte) uint32 { + marker := []byte{0x02, 0x00, 0x00, 0x00, 0x01} + if idx := bytes.Index(payload, marker); idx >= 4 { + return binary.LittleEndian.Uint32(payload[idx-4 : idx]) + } + if len(payload) >= 7 { + return binary.LittleEndian.Uint32(payload[3:7]) + } + return 0 +} + +func buildRawREDQ(channelType uint8, channelID uint8, connectionID uint32) []byte { + redq := make([]byte, 0, 42) + redq = append(redq, []byte("REDQ")...) + redq = appendU32LE(redq, 2) + redq = appendU32LE(redq, 2) + redq = appendU32LE(redq, 26) + redq = appendU32LE(redq, connectionID) + redq = append(redq, channelType, channelID) + redq = appendU32LE(redq, 1) + redq = appendU32LE(redq, 1) + redq = appendU32LE(redq, 18) + redq = appendU32LE(redq, 0x00000009) + redq = appendU32LE(redq, 0x0000000f) + return redq +} + +func buildZTERawMainREDQ(key, vmid string, linkUUID []byte, traceID, spanID string) []byte { + if len(linkUUID) != 16 { + linkUUID = make([]byte, 16) + _, _ = rand.Read(linkUUID) + } + redq := make([]byte, 729) + copy(redq[0:4], []byte("REDQ")) + binary.LittleEndian.PutUint32(redq[4:8], 2) + binary.LittleEndian.PutUint32(redq[8:12], 2) + binary.LittleEndian.PutUint32(redq[12:16], 713) + redq[20] = 1 + binary.LittleEndian.PutUint32(redq[22:26], 1) + binary.LittleEndian.PutUint32(redq[26:30], 1) + binary.LittleEndian.PutUint32(redq[30:34], 705) + binary.LittleEndian.PutUint32(redq[42:46], 0x1400) + binary.LittleEndian.PutUint32(redq[46:50], 0x10000) + copyCString(redq[50:95], key+vmid) + copy(redq[95:111], linkUUID) + copy(redq[127:143], terminalGUIDBytes()) + copyCString(redq[159:192], traceID) + copyCString(redq[192:209], spanID) + binary.LittleEndian.PutUint32(redq[717:721], 0x800) + binary.LittleEndian.PutUint32(redq[721:725], 0x232900) + return redq +} + +func BuildZTERawChannelREDQ(key, vmid string, linkUUID []byte, traceID, spanID string, connectionID uint32, channelType, channelID uint8) []byte { + length := 725 + size := uint32(709) + capCount := uint32(0) + caps := []uint32{0x800} + + switch channelType { + case 2: + length = 733 + size = 717 + capCount = 2 + caps = []uint32{0xa00, 0xffc30dec, 0x48} + case 5: + length = 729 + size = 713 + capCount = 1 + caps = []uint32{0x800, 0x0e} + case 6: + length = 729 + size = 713 + capCount = 1 + caps = []uint32{0x800, 0x07} + } + + redq := make([]byte, length) + copy(redq[0:4], []byte("REDQ")) + binary.LittleEndian.PutUint32(redq[4:8], 2) + binary.LittleEndian.PutUint32(redq[8:12], 2) + binary.LittleEndian.PutUint32(redq[12:16], size) + binary.LittleEndian.PutUint32(redq[16:20], connectionID) + redq[20] = channelType + redq[21] = channelID + binary.LittleEndian.PutUint32(redq[22:26], 1) + binary.LittleEndian.PutUint32(redq[26:30], capCount) + binary.LittleEndian.PutUint32(redq[30:34], 705) + binary.LittleEndian.PutUint32(redq[42:46], 0x1400) + binary.LittleEndian.PutUint32(redq[46:50], 0x10000) + copyCString(redq[50:95], key+vmid) + copy(redq[95:111], linkUUID) + copyCString(redq[159:192], traceID) + copyCString(redq[192:209], spanID) + + capOff := length - len(caps)*4 + for i, capValue := range caps { + binary.LittleEndian.PutUint32(redq[capOff+i*4:capOff+i*4+4], capValue) + } + return redq +} + +func copyCString(dst []byte, s string) { + if len(dst) == 0 { + return + } + n := copy(dst, []byte(s)) + if n < len(dst) { + dst[n] = 0 + } +} + +func terminalGUIDBytes() []byte { + // 31BF5444-86E0-4D5D-B1AB-A42FFBAC72C9, encoded as Windows GUID bytes. + return []byte{0x44, 0x54, 0xbf, 0x31, 0xe0, 0x86, 0x5d, 0x4d, 0xb1, 0xab, 0xa4, 0x2f, 0xfb, 0xac, 0x72, 0xc9} +} + +func buildTerminalInfoMessage() []byte { + msg := make([]byte, 68) + binary.LittleEndian.PutUint16(msg[0:2], 0x7c) + binary.LittleEndian.PutUint32(msg[2:6], 57) + copy(msg[11:], []byte("31BF5444-86E0-4D5D-B1AB-A42FFBAC72C9")) + return msg +} + +func readRawLinkReply(conn net.Conn, timeout time.Duration) ([]byte, error) { + _ = conn.SetReadDeadline(time.Now().Add(timeout)) + head := make([]byte, 16) + if _, err := io.ReadFull(conn, head); err != nil { + return nil, err + } + if !bytes.Equal(head[:4], []byte("REDQ")) { + return nil, fmt.Errorf("invalid REDQ magic: %x", head[:4]) + } + size := binary.LittleEndian.Uint32(head[12:16]) + if size > 4096 { + return nil, fmt.Errorf("invalid REDQ reply size %d", size) + } + body := make([]byte, size) + if _, err := io.ReadFull(conn, body); err != nil { + return nil, err + } + return append(head, body...), nil +} + +func ReadRawMessage(conn net.Conn, timeout time.Duration) (uint16, []byte, error) { + state := &RawState{LastSerial: lastRawSerial, LastSuffix: lastRawSuffix} + msgType, payload, err := state.ReadMessage(conn, timeout) + lastRawSerial = state.LastSerial + lastRawSuffix = state.LastSuffix + return msgType, payload, err +} + +func (s *RawState) ReadMessage(conn net.Conn, timeout time.Duration) (uint16, []byte, error) { + _ = conn.SetReadDeadline(time.Now().Add(timeout)) + head := make([]byte, 6) + hasZTEPrefix := false + if _, err := io.ReadFull(conn, head); err != nil { + return 0, nil, err + } + msgType := binary.LittleEndian.Uint16(head[0:2]) + size := binary.LittleEndian.Uint32(head[2:6]) + if size == 0 { + serial := binary.LittleEndian.Uint32(head[0:4]) + prefixTail := make([]byte, 2) + if _, err := io.ReadFull(conn, prefixTail); err != nil { + return 0, nil, err + } + if _, err := io.ReadFull(conn, head); err != nil { + return 0, nil, err + } + msgType = binary.LittleEndian.Uint16(head[0:2]) + size = binary.LittleEndian.Uint32(head[2:6]) + s.LastSerial = serial + s.LastSuffix = nil + hasZTEPrefix = true + } + if size > 1<<20 { + return 0, nil, fmt.Errorf("raw SPICE message too large: %d", size) + } + payload := make([]byte, size) + if _, err := io.ReadFull(conn, payload); err != nil { + return 0, nil, err + } + if hasZTEPrefix { + if t, ok := conn.(interface{ TakeReadBufferN(int) []byte }); ok { + s.LastSuffix = t.TakeReadBufferN(5) + } + } + return msgType, payload, nil +} + +func RawAutoReply(conn net.Conn, msgType uint16, payload []byte) bool { + state := &RawState{LastSerial: lastRawSerial, LastSuffix: lastRawSuffix} + ok := state.AutoReply(conn, msgType, payload) + lastRawSerial = state.LastSerial + lastRawSuffix = state.LastSuffix + return ok +} + +func (s *RawState) AutoReply(conn net.Conn, msgType uint16, payload []byte) bool { + switch msgType { + case 0x04: + pong := make([]byte, 6+len(payload)) + binary.LittleEndian.PutUint16(pong[0:2], 0x03) + binary.LittleEndian.PutUint32(pong[2:6], uint32(len(payload))) + copy(pong[6:], payload) + _, _ = s.WriteMessage(conn, s.LastSerial, pong) + return true + case 0x03: + var generation uint32 + if len(payload) >= 4 { + generation = binary.LittleEndian.Uint32(payload[:4]) + } + ack := make([]byte, 10) + binary.LittleEndian.PutUint16(ack[0:2], 0x01) + binary.LittleEndian.PutUint32(ack[2:6], 4) + binary.LittleEndian.PutUint32(ack[6:10], generation) + _, _ = s.WriteMessage(conn, s.LastSerial, ack) + return true + case 0x74: + reply := make([]byte, 7) + binary.LittleEndian.PutUint16(reply[0:2], 0x79) + binary.LittleEndian.PutUint32(reply[2:6], 1) + if _, err := s.WriteMessage(conn, s.nextSerial(), reply); err == nil { + return true + } + return false + } + return false +} + +func (s *RawState) WriteMessage(conn net.Conn, serial uint32, msg []byte) (int, error) { + return conn.Write(append(rawMessageWithPrefix(serial, msg), s.LastSuffix...)) +} + +func (s *RawState) nextSerial() uint32 { + if s.NextSerial == 0 { + s.NextSerial = 4 + } + serial := s.NextSerial + s.NextSerial++ + return serial +} + +func WriteRawMessage(conn net.Conn, serial uint32, msg []byte) (int, error) { + return conn.Write(rawMessageWithPrefix(serial, msg)) +} + +func BuildZTERawDisplayInit() []byte { + msg, _ := hexDecode("65001300000000000000000100004001000000000100fc5f000000000003") + return msg +} + +func BuildZTERawInputInit() []byte { + msg, _ := hexDecode("67000200000000000000000200") + return msg +} + +func rawMessageWithPrefix(serial uint32, msg []byte) []byte { + out := make([]byte, 8+len(msg)) + binary.LittleEndian.PutUint32(out[0:4], serial) + copy(out[8:], msg) + return out +} diff --git a/internal/zte/cag.go b/internal/zte/cag.go new file mode 100644 index 0000000..cb9dc64 --- /dev/null +++ b/internal/zte/cag.go @@ -0,0 +1,358 @@ +package zte + +import ( + "context" + "crypto/rand" + "crypto/tls" + "encoding/binary" + "encoding/hex" + "fmt" + "net" + "os" + "path/filepath" + "sync/atomic" + "time" + + kcp "github.com/xtaci/kcp-go/v5" +) + +type CAGSessionInfo struct { + SynID []byte + Conv uint32 +} + +type CAGDialOptions struct { + Address string + Params *ConnectParams + AuthTemplateHex string + Timeout time.Duration +} + +func DialCAGKCP(ctx context.Context, opts CAGDialOptions) (net.Conn, *CAGSessionInfo, error) { + if opts.Params == nil { + return nil, nil, fmt.Errorf("missing connect params") + } + if opts.Address == "" { + return nil, nil, fmt.Errorf("missing CAG address") + } + if opts.Timeout == 0 { + opts.Timeout = 15 * time.Second + } + authTemplate, err := parseAuthTemplate(opts.AuthTemplateHex) + if err != nil { + return nil, nil, err + } + + remote, err := net.ResolveUDPAddr("udp", opts.Address) + if err != nil { + return nil, nil, err + } + udpConn, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, nil, err + } + closeOnError := true + defer func() { + if closeOnError { + _ = udpConn.Close() + } + }() + + deadline := time.Now().Add(opts.Timeout) + _ = udpConn.SetDeadline(deadline) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + _ = udpConn.SetDeadline(ctxDeadline) + } + + first, synID, err := buildCAGAuthHeadPacket() + if err != nil { + return nil, nil, err + } + if _, err := udpConn.WriteToUDP(first, remote); err != nil { + return nil, nil, err + } + + headAck, err := readCAGPacket(ctx, udpConn, remote, 0x07) + if err != nil { + return nil, nil, fmt.Errorf("wait CAG auth head ack: %w", err) + } + replySynID, conv, err := parseCAGAuthHeader(headAck) + if err != nil { + return nil, nil, err + } + if !equalBytes(synID, replySynID) { + return nil, nil, fmt.Errorf("CAG auth head ack syn id mismatch") + } + + second, err := buildCAGAuthPacket(authTemplate, synID, conv, opts.Params) + if err != nil { + return nil, nil, err + } + if _, err := udpConn.WriteToUDP(second, remote); err != nil { + return nil, nil, err + } + if _, err := readCAGPacket(ctx, udpConn, remote, 0x09); err != nil { + return nil, nil, fmt.Errorf("wait CAG auth ack: %w", err) + } + + third := buildCAGSynPacket(synID, conv) + if _, err := udpConn.WriteToUDP(third, remote); err != nil { + return nil, nil, err + } + if _, err := readCAGPacket(ctx, udpConn, remote, 0x02); err != nil { + return nil, nil, fmt.Errorf("wait CAG syn ack: %w", err) + } + _ = udpConn.SetDeadline(time.Time{}) + + packetConn := newCAGPacketConn(udpConn, os.Getenv("CCK_ZTE_CAG_TRACE_DIR")) + session, err := kcp.NewConn3(conv, remote, nil, 0, 0, packetConn) + if err != nil { + return nil, nil, err + } + session.SetNoDelay(1, 10, 2, 1) + session.SetWindowSize(128, 512) + session.SetMtu(1300) + + closeOnError = false + return session, &CAGSessionInfo{SynID: append([]byte(nil), synID...), Conv: conv}, nil +} + +func DialCAGTLS(ctx context.Context, opts CAGDialOptions) (net.Conn, *CAGSessionInfo, error) { + conn, info, err := DialCAGKCP(ctx, opts) + if err != nil { + return nil, nil, err + } + tlsConn := tls.Client(conn, &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + }) + if err := tlsConn.HandshakeContext(ctx); err != nil { + _ = conn.Close() + return nil, nil, fmt.Errorf("CAG TLS handshake: %w", err) + } + return tlsConn, info, nil +} + +func parseAuthTemplate(templateHex string) ([]byte, error) { + if templateHex == "" { + return nil, nil + } + template, err := hex.DecodeString(templateHex) + if err != nil { + return nil, fmt.Errorf("decode CAG auth template: %w", err) + } + if len(template) == 241 && template[0] == 0x08 { + return append([]byte(nil), template...), nil + } + if len(template) == 220 { + return append([]byte(nil), template...), nil + } + return nil, fmt.Errorf("invalid CAG auth template length %d", len(template)) +} + +func buildCAGAuthBlob(params *ConnectParams, template []byte) ([]byte, error) { + if params == nil { + return nil, fmt.Errorf("missing connect params") + } + if len(template) == 241 { + template = template[21:] + } + if len(template) == 220 { + blob := append([]byte(nil), template...) + if len(params.VMID) == 36 { + copy(blob[20:56], []byte(params.VMID)) + } + return blob, nil + } + if len(template) != 0 { + return nil, fmt.Errorf("invalid CAG auth template length %d", len(template)) + } + + ip := net.ParseIP(params.Host).To4() + if ip == nil { + return nil, fmt.Errorf("CAG auth blob requires IPv4 host: %s", params.Host) + } + if params.ProxySport <= 0 { + return nil, fmt.Errorf("CAG auth blob requires proxySport") + } + if len(params.VMID) != 36 { + return nil, fmt.Errorf("CAG auth blob requires 36-byte vmId") + } + + blob := make([]byte, 220) + binary.LittleEndian.PutUint32(blob[0:4], uint32(params.ProxySport)) + copy(blob[4:8], ip) + copy(blob[20:56], []byte(params.VMID)) + fillRandom(blob[60:188]) + blob[188] = 0x50 + return blob, nil +} + +func buildCAGAuthHeadPacket() ([]byte, []byte, error) { + packet := make([]byte, 21+178) + copy(packet[0:4], []byte{0x06, 0x00, 0x00, 0x80}) + synID := packet[11:15] + if _, err := rand.Read(synID); err != nil { + return nil, nil, err + } + + payload := packet[21:] + copy(payload[0:4], []byte("ZTEC")) + binary.LittleEndian.PutUint16(payload[4:6], 0x00ac) + binary.LittleEndian.PutUint32(payload[6:10], 101) + fillRandom(payload[10:14]) + copy(payload[14:18], []byte{0xdc, 0x00, 0x00, 0x00}) + fillRandom(payload[18:38]) + copy(payload[38:42], []byte{0x07, 0x00, 0x0b, 0x0b}) + fillASCIIHex(payload[54:86]) + fillASCIIHex(payload[118:134]) + return packet, append([]byte(nil), synID...), nil +} + +func buildCAGAuthPacket(template, synID []byte, conv uint32, params *ConnectParams) ([]byte, error) { + if len(template) == 241 { + packet := append([]byte(nil), template...) + copy(packet[11:15], synID) + binary.LittleEndian.PutUint32(packet[15:19], conv) + return packet, nil + } + + blob, err := buildCAGAuthBlob(params, template) + if err != nil { + return nil, err + } + packet := make([]byte, 21+len(blob)) + copy(packet[0:4], []byte{0x08, 0x00, 0x00, 0x80}) + copy(packet[11:15], synID) + binary.LittleEndian.PutUint32(packet[15:19], conv) + copy(packet[21:], blob) + return packet, nil +} + +func buildCAGSynPacket(synID []byte, conv uint32) []byte { + packet := make([]byte, 21) + copy(packet[0:4], []byte{0x01, 0x00, 0x00, 0x80}) + copy(packet[4:8], []byte{0x53, 0x01, 0x00, 0x22}) + copy(packet[11:15], synID) + binary.LittleEndian.PutUint32(packet[15:19], conv) + copy(packet[19:21], []byte{0x14, 0x05}) + return packet +} + +func readCAGPacket(ctx context.Context, conn *net.UDPConn, remote *net.UDPAddr, packetType byte) ([]byte, error) { + buf := make([]byte, 2048) + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + n, from, err := conn.ReadFromUDP(buf) + if err != nil { + return nil, err + } + if from == nil || from.IP.String() != remote.IP.String() || from.Port != remote.Port { + continue + } + if n >= 21 && buf[0] == packetType && buf[3] == 0x80 { + return append([]byte(nil), buf[:n]...), nil + } + } +} + +func parseCAGAuthHeader(packet []byte) ([]byte, uint32, error) { + if len(packet) < 21 || packet[0] != 0x07 || packet[3] != 0x80 { + return nil, 0, fmt.Errorf("invalid CAG auth head ack") + } + return append([]byte(nil), packet[11:15]...), binary.LittleEndian.Uint32(packet[15:19]), nil +} + +func fillRandom(dst []byte) { + if _, err := rand.Read(dst); err != nil { + for i := range dst { + dst[i] = byte(time.Now().UnixNano() >> (uint(i%8) * 8)) + } + } +} + +func fillASCIIHex(dst []byte) { + raw := make([]byte, len(dst)/2) + fillRandom(raw) + hex.Encode(dst, raw) +} + +func equalBytes(a, b []byte) bool { + if len(a) != len(b) { + return false + } + var diff byte + for i := range a { + diff |= a[i] ^ b[i] + } + return diff == 0 +} + +type cagPacketConn struct { + net.PacketConn + dir string + seq atomic.Uint64 +} + +func newCAGPacketConn(conn net.PacketConn, dir string) net.PacketConn { + if dir != "" { + _ = os.MkdirAll(dir, 0700) + } + return &cagPacketConn{PacketConn: conn, dir: dir} +} + +func (c *cagPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { + for { + n, addr, err := c.PacketConn.ReadFrom(p) + if n > 0 { + c.write("rx-raw", p[:n]) + if n >= 24 && p[4] == 0x85 { + c.sendMTUAck(p[:n], addr) + continue + } + if n >= 24 && p[4] >= 0x81 && p[4] <= 0x84 { + p[4] -= 0x30 + c.write("rx-kcp", p[:n]) + } + } + return n, addr, err + } +} + +func (c *cagPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { + out := p + if len(p) >= 24 && p[4] >= 0x51 && p[4] <= 0x54 { + out = append([]byte(nil), p...) + out[4] += 0x30 + } + if len(out) > 0 { + c.write("tx-raw", out) + } + return c.PacketConn.WriteTo(out, addr) +} + +func (c *cagPacketConn) write(direction string, data []byte) { + if c.dir == "" { + return + } + seq := c.seq.Add(1) + name := fmt.Sprintf("%06d-%s-%d.bin", seq, direction, len(data)) + _ = os.WriteFile(filepath.Join(c.dir, name), append([]byte(nil), data...), 0600) +} + +func (c *cagPacketConn) sendMTUAck(packet []byte, addr net.Addr) { + if len(packet) < 24 { + return + } + ack := make([]byte, 24) + copy(ack[0:4], packet[0:4]) + ack[4] = 0x86 + copy(ack[5:20], packet[5:20]) + c.write("tx-mtuack", ack) + _, _ = c.PacketConn.WriteTo(ack, addr) +} diff --git a/internal/zte/cag_auth_test.go b/internal/zte/cag_auth_test.go new file mode 100644 index 0000000..e7a63a6 --- /dev/null +++ b/internal/zte/cag_auth_test.go @@ -0,0 +1,63 @@ +package zte + +import ( + "bytes" + "encoding/binary" + "testing" +) + +func TestBuildCAGAuthBlobWithoutTemplate(t *testing.T) { + params := &ConnectParams{ + Host: "10.8.2.26", + ProxySport: 5100, + VMID: "11111111-2222-3333-4444-555555555555", + } + + blob, err := buildCAGAuthBlob(params, nil) + if err != nil { + t.Fatalf("buildCAGAuthBlob() error = %v", err) + } + if len(blob) != 220 { + t.Fatalf("len(blob) = %d, want 220", len(blob)) + } + if got := binary.LittleEndian.Uint32(blob[0:4]); got != 5100 { + t.Fatalf("proxySport = %d, want 5100", got) + } + if got := blob[4:8]; !bytes.Equal(got, []byte{10, 8, 2, 26}) { + t.Fatalf("target IP bytes = %x", got) + } + if got := string(blob[20:56]); got != params.VMID { + t.Fatalf("vmId = %q, want %q", got, params.VMID) + } + if bytes.Equal(blob[60:188], make([]byte, 128)) { + t.Fatal("random ticket block is all zero") + } + if blob[188] != 0x50 { + t.Fatalf("tail marker = 0x%02x, want 0x50", blob[188]) + } + if !bytes.Equal(blob[189:], make([]byte, 31)) { + t.Fatalf("tail padding = %x", blob[189:]) + } +} + +func TestBuildCAGAuthBlobPatchesTemplateVMID(t *testing.T) { + template := make([]byte, 220) + copy(template[20:56], []byte("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")) + template[188] = 0x50 + params := &ConnectParams{ + Host: "10.8.2.26", + ProxySport: 5100, + VMID: "11111111-2222-3333-4444-555555555555", + } + + blob, err := buildCAGAuthBlob(params, template) + if err != nil { + t.Fatalf("buildCAGAuthBlob() error = %v", err) + } + if got := string(blob[20:56]); got != params.VMID { + t.Fatalf("vmId = %q, want %q", got, params.VMID) + } + if template[20] != 'a' { + t.Fatal("template was modified in place") + } +} diff --git a/internal/zte/cag_mux.go b/internal/zte/cag_mux.go new file mode 100644 index 0000000..3daf431 --- /dev/null +++ b/internal/zte/cag_mux.go @@ -0,0 +1,266 @@ +package zte + +import ( + "cloud-computer-keepalive/internal/logger" + "context" + "encoding/binary" + "fmt" + "io" + "net" + "sync" + "time" +) + +type CAGMux struct { + conn net.Conn + writeM sync.Mutex + linksM sync.RWMutex + links map[byte]*CAGMuxLink +} + +type CAGMuxLink struct { + mux *CAGMux + linkID byte + linkUUID []byte + traceID string + spanID string + redqSpanID string + + frames chan cagMuxFrame + rbuf []byte + + deadlineM sync.Mutex + readDeadline time.Time + writeDeadline time.Time +} + +type cagMuxFrame struct { + cmd byte + payload []byte + err error +} + +func NewCAGMux(conn net.Conn) *CAGMux { + m := &CAGMux{ + conn: conn, + links: make(map[byte]*CAGMuxLink), + } + go m.readLoop() + return m +} + +func OpenCAGMuxLink(ctx context.Context, mux *CAGMux, params *ConnectParams, linkID byte) (*CAGMuxLink, error) { + return OpenCAGMuxLinkWithTrace(ctx, mux, params, linkID, randomHex(16), randomHex(8)) +} + +func OpenCAGMuxLinkWithTrace(ctx context.Context, mux *CAGMux, params *ConnectParams, linkID byte, traceID, spanID string) (*CAGMuxLink, error) { + if mux == nil { + return nil, fmt.Errorf("missing CAG mux") + } + if params == nil { + return nil, fmt.Errorf("missing connect params") + } + if linkID == 0 { + linkID = 1 + } + linkUUID := newZTELinkUUID() + if traceID == "" { + traceID = randomHex(16) + } + if spanID == "" { + spanID = randomHex(8) + } + link := &CAGMuxLink{ + mux: mux, + linkID: linkID, + linkUUID: linkUUID, + traceID: traceID, + spanID: spanID, + redqSpanID: randomHex(8), + frames: make(chan cagMuxFrame, 64), + } + + mux.linksM.Lock() + mux.links[linkID] = link + mux.linksM.Unlock() + + packet, err := buildCAGProxyAddLinkPacket(params, linkID, linkUUID, traceID, spanID) + if err != nil { + return nil, err + } + if deadline, ok := ctx.Deadline(); ok { + _ = mux.conn.SetWriteDeadline(deadline) + } + if _, err := mux.writePacket(packet); err != nil { + return nil, fmt.Errorf("send CAG proxy add-link: %w", err) + } + logger.Debugf("ZTE CAG mux send cmd=0x%02x link=%d len=%d head=%s", cagProxyAddLinkCmd, linkID, len(packet)-4, hexPrefix(packet, 80)) + _ = mux.conn.SetWriteDeadline(time.Time{}) + return link, nil +} + +func (m *CAGMux) writePacket(packet []byte) (int, error) { + m.writeM.Lock() + defer m.writeM.Unlock() + return m.conn.Write(packet) +} + +func (m *CAGMux) readLoop() { + for { + head := make([]byte, 4) + if _, err := io.ReadFull(m.conn, head); err != nil { + m.broadcast(cagMuxFrame{err: err}) + return + } + cmd := head[0] + linkID := head[1] + n := int(binary.LittleEndian.Uint16(head[2:4])) + payload := make([]byte, n) + if n > 0 { + if _, err := io.ReadFull(m.conn, payload); err != nil { + m.broadcast(cagMuxFrame{err: err}) + return + } + } + logger.Debugf("ZTE CAG mux recv cmd=0x%02x link=%d len=%d", cmd, linkID, n) + m.linksM.RLock() + link := m.links[linkID] + m.linksM.RUnlock() + if link == nil { + continue + } + err := error(nil) + if cmd == cagProxyCloseLinkCmd { + err = io.EOF + } + link.frames <- cagMuxFrame{cmd: cmd, payload: payload, err: err} + } +} + +func (m *CAGMux) broadcast(frame cagMuxFrame) { + m.linksM.RLock() + defer m.linksM.RUnlock() + for _, link := range m.links { + link.frames <- frame + } +} + +func (l *CAGMuxLink) LinkUUID() []byte { + return append([]byte(nil), l.linkUUID...) +} + +func (l *CAGMuxLink) TraceID() string { return l.traceID } + +func (l *CAGMuxLink) SpanID() string { return l.spanID } + +func (l *CAGMuxLink) REDQSpanID() string { return l.redqSpanID } + +func (l *CAGMuxLink) DiscardReadBuffer() { + l.rbuf = nil +} + +func (l *CAGMuxLink) TakeReadBufferN(n int) []byte { + if n <= 0 || len(l.rbuf) == 0 { + return nil + } + if n > len(l.rbuf) { + n = len(l.rbuf) + } + out := append([]byte(nil), l.rbuf[:n]...) + l.rbuf = l.rbuf[n:] + return out +} + +func (l *CAGMuxLink) Read(p []byte) (int, error) { + for len(l.rbuf) == 0 { + frame, err := l.nextFrame() + if err != nil { + return 0, err + } + if frame.cmd == cagProxyDataCmd || frame.cmd&0x0f == cagProxyDataCmd { + l.rbuf = frame.payload + } + } + n := copy(p, l.rbuf) + l.rbuf = l.rbuf[n:] + return n, nil +} + +func (l *CAGMuxLink) nextFrame() (cagMuxFrame, error) { + l.deadlineM.Lock() + deadline := l.readDeadline + l.deadlineM.Unlock() + if deadline.IsZero() { + frame := <-l.frames + return frame, frame.err + } + timeout := time.Until(deadline) + if timeout <= 0 { + return cagMuxFrame{}, timeoutError{} + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case frame := <-l.frames: + return frame, frame.err + case <-timer.C: + return cagMuxFrame{}, timeoutError{} + } +} + +func (l *CAGMuxLink) Write(p []byte) (int, error) { + written := 0 + for len(p) > 0 { + n := len(p) + if n > cagProxyPayloadMax { + n = cagProxyPayloadMax + } + frame := make([]byte, 4+n) + frame[0] = cagProxyDataCmd + frame[1] = l.linkID + binary.LittleEndian.PutUint16(frame[2:4], uint16(n)) + copy(frame[4:], p[:n]) + logger.Debugf("ZTE CAG mux send cmd=0x%02x link=%d len=%d head=%s", cagProxyDataCmd, l.linkID, n, hexPrefix(frame, 80)) + if _, err := l.mux.writePacket(frame); err != nil { + return written, err + } + written += n + p = p[n:] + } + return written, nil +} + +func (l *CAGMuxLink) Close() error { + l.mux.linksM.Lock() + delete(l.mux.links, l.linkID) + l.mux.linksM.Unlock() + return nil +} + +func (l *CAGMuxLink) LocalAddr() net.Addr { return l.mux.conn.LocalAddr() } +func (l *CAGMuxLink) RemoteAddr() net.Addr { return l.mux.conn.RemoteAddr() } + +func (l *CAGMuxLink) SetDeadline(t time.Time) error { + _ = l.SetReadDeadline(t) + return l.SetWriteDeadline(t) +} + +func (l *CAGMuxLink) SetReadDeadline(t time.Time) error { + l.deadlineM.Lock() + l.readDeadline = t + l.deadlineM.Unlock() + return nil +} + +func (l *CAGMuxLink) SetWriteDeadline(t time.Time) error { + l.deadlineM.Lock() + l.writeDeadline = t + l.deadlineM.Unlock() + return l.mux.conn.SetWriteDeadline(t) +} + +type timeoutError struct{} + +func (timeoutError) Error() string { return "i/o timeout" } +func (timeoutError) Timeout() bool { return true } +func (timeoutError) Temporary() bool { return true } diff --git a/internal/zte/cag_proxy.go b/internal/zte/cag_proxy.go new file mode 100644 index 0000000..7d5eba5 --- /dev/null +++ b/internal/zte/cag_proxy.go @@ -0,0 +1,232 @@ +package zte + +import ( + "cloud-computer-keepalive/internal/logger" + "context" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "net" + "net/netip" + "strings" + "time" +) + +const ( + cagProxyDataCmd = 0x0a + cagProxyAddLinkCmd = 0x1a + cagProxyCloseLinkCmd = 0x2a + cagProxyPayloadMax = 0xffff +) + +type CAGProxyConn struct { + net.Conn + linkID byte + linkUUID []byte + traceID string + spanID string + redqSpanID string + rbuf []byte +} + +func OpenCAGProxyLink(ctx context.Context, conn net.Conn, params *ConnectParams, linkID byte) (*CAGProxyConn, error) { + return OpenCAGProxyLinkWithTrace(ctx, conn, params, linkID, randomHex(16), randomHex(8)) +} + +func OpenCAGProxyLinkWithTrace(ctx context.Context, conn net.Conn, params *ConnectParams, linkID byte, traceID, spanID string) (*CAGProxyConn, error) { + if params == nil { + return nil, fmt.Errorf("missing connect params") + } + if linkID == 0 { + linkID = 1 + } + linkUUID := newZTELinkUUID() + if traceID == "" { + traceID = randomHex(16) + } + if spanID == "" { + spanID = randomHex(8) + } + redqSpanID := randomHex(8) + packet, err := buildCAGProxyAddLinkPacket(params, linkID, linkUUID, traceID, spanID) + if err != nil { + return nil, err + } + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetWriteDeadline(deadline) + } + if _, err := conn.Write(packet); err != nil { + return nil, fmt.Errorf("send CAG proxy add-link: %w", err) + } + logger.Debugf("ZTE CAG proxy send cmd=0x%02x link=%d len=%d head=%s", cagProxyAddLinkCmd, linkID, len(packet)-4, hexPrefix(packet, 80)) + _ = conn.SetWriteDeadline(time.Time{}) + return &CAGProxyConn{Conn: conn, linkID: linkID, linkUUID: linkUUID, traceID: traceID, spanID: spanID, redqSpanID: redqSpanID}, nil +} + +func (c *CAGProxyConn) LinkUUID() []byte { + return append([]byte(nil), c.linkUUID...) +} + +func (c *CAGProxyConn) TraceID() string { return c.traceID } + +func (c *CAGProxyConn) SpanID() string { return c.spanID } + +func (c *CAGProxyConn) REDQSpanID() string { return c.redqSpanID } + +func (c *CAGProxyConn) DiscardReadBuffer() { + c.rbuf = nil +} + +func (c *CAGProxyConn) TakeReadBuffer() []byte { + out := append([]byte(nil), c.rbuf...) + c.rbuf = nil + return out +} + +func (c *CAGProxyConn) TakeReadBufferN(n int) []byte { + if n <= 0 || len(c.rbuf) == 0 { + return nil + } + if n > len(c.rbuf) { + n = len(c.rbuf) + } + out := append([]byte(nil), c.rbuf[:n]...) + c.rbuf = c.rbuf[n:] + return out +} + +func (c *CAGProxyConn) Read(p []byte) (int, error) { + for len(c.rbuf) == 0 { + head := make([]byte, 4) + if _, err := io.ReadFull(c.Conn, head); err != nil { + return 0, err + } + cmd := head[0] + linkID := head[1] + n := int(binary.LittleEndian.Uint16(head[2:4])) + logger.Debugf("ZTE CAG proxy recv cmd=0x%02x link=%d len=%d", cmd, linkID, n) + if n == 0 { + continue + } + payload := make([]byte, n) + if _, err := io.ReadFull(c.Conn, payload); err != nil { + return 0, err + } + if linkID != c.linkID { + continue + } + switch cmd { + case cagProxyDataCmd: + c.rbuf = payload + case cagProxyCloseLinkCmd: + return 0, io.EOF + default: + if cmd&0x0f == cagProxyDataCmd { + c.rbuf = payload + } + } + } + n := copy(p, c.rbuf) + c.rbuf = c.rbuf[n:] + return n, nil +} + +func (c *CAGProxyConn) Write(p []byte) (int, error) { + written := 0 + for len(p) > 0 { + n := len(p) + if n > cagProxyPayloadMax { + n = cagProxyPayloadMax + } + frame := make([]byte, 4+n) + frame[0] = cagProxyDataCmd + frame[1] = c.linkID + binary.LittleEndian.PutUint16(frame[2:4], uint16(n)) + copy(frame[4:], p[:n]) + logger.Debugf("ZTE CAG proxy send cmd=0x%02x link=%d len=%d head=%s", cagProxyDataCmd, c.linkID, n, hexPrefix(frame, 80)) + if _, err := c.Conn.Write(frame); err != nil { + return written, err + } + written += n + p = p[n:] + } + return written, nil +} + +func buildCAGProxyAddLinkPacket(params *ConnectParams, linkID byte, linkUUID []byte, traceID, spanID string) ([]byte, error) { + addr, err := netip.ParseAddr(params.Host) + if err != nil { + return nil, fmt.Errorf("parse CAG target host %q: %w", params.Host, err) + } + if !addr.Is4() { + return nil, fmt.Errorf("CAG proxy add-link currently supports IPv4 target only: %s", params.Host) + } + packet := make([]byte, 4+0x9a) + packet[0] = cagProxyAddLinkCmd + packet[1] = linkID + binary.LittleEndian.PutUint16(packet[2:4], 0x9a) + + payload := packet[4:] + binary.LittleEndian.PutUint16(payload[0:2], uint16(params.Port)) + payload[2] = 1 + if linkID != 1 { + payload[2] = 2 + } + ip := addr.As4() + payload[4] = ip[3] + payload[5] = ip[2] + payload[6] = ip[1] + payload[7] = ip[0] + payload[0x53] = 0x05 // QoS value used by the ZTE tunnel LinkInfo. + if linkID == 1 { + payload[0x54] = 1 // SPICE main channel type. + } + copyCString(payload[0x68:0x89], traceID) + copyCString(payload[0x89:0x9a], spanID) + return packet, nil +} + +func randomHex(n int) string { + buf := make([]byte, n) + _, _ = rand.Read(buf) + return hex.EncodeToString(buf) +} + +func newZTELinkUUID() []byte { + linkUUID := make([]byte, 16) + _, _ = rand.Read(linkUUID) + linkUUID[7] = (linkUUID[7] & 0x0f) | 0x40 + linkUUID[8] = (linkUUID[8] & 0x3f) | 0x80 + return linkUUID +} + +func copyCString(dst []byte, s string) { + if len(dst) == 0 { + return + } + n := copy(dst, []byte(s)) + if n < len(dst) { + dst[n] = 0 + } +} + +func hexPrefix(b []byte, n int) string { + if len(b) < n { + n = len(b) + } + return hex.EncodeToString(b[:n]) +} + +func parseUUIDBytes(value string) ([]byte, error) { + raw := strings.ReplaceAll(value, "-", "") + if len(raw) != 32 { + return nil, fmt.Errorf("invalid UUID %q", value) + } + out, err := hex.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("decode UUID %q: %w", value, err) + } + return out, nil +} diff --git a/internal/zte/cag_tcp.go b/internal/zte/cag_tcp.go new file mode 100644 index 0000000..a867cab --- /dev/null +++ b/internal/zte/cag_tcp.go @@ -0,0 +1,107 @@ +package zte + +import ( + "context" + "crypto/tls" + "encoding/binary" + "fmt" + "io" + "net" + "time" +) + +// DialCAGTCPTLS performs the ZTE CAG TCP pre-auth handshake and then upgrades +// the same socket to TLS. The SPICE tunnel add-link message is sent after this. +func DialCAGTCPTLS(ctx context.Context, opts CAGDialOptions) (net.Conn, *CAGSessionInfo, error) { + if opts.Params == nil { + return nil, nil, fmt.Errorf("missing connect params") + } + if opts.Address == "" { + return nil, nil, fmt.Errorf("missing CAG address") + } + if opts.Timeout == 0 { + opts.Timeout = 15 * time.Second + } + authTemplate, err := parseAuthTemplate(opts.AuthTemplateHex) + if err != nil { + return nil, nil, err + } + + dialer := net.Dialer{Timeout: opts.Timeout} + conn, err := dialer.DialContext(ctx, "tcp", opts.Address) + if err != nil { + return nil, nil, fmt.Errorf("TCP connect: %w", err) + } + closeOnError := true + defer func() { + if closeOnError { + _ = conn.Close() + } + }() + + deadline := time.Now().Add(opts.Timeout) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + _ = conn.SetDeadline(deadline) + + firstUDP, _, err := buildCAGAuthHeadPacket() + if err != nil { + return nil, nil, err + } + first := firstUDP[21:] + if _, err := conn.Write(first); err != nil { + return nil, nil, fmt.Errorf("send CAG TCP local-key: %w", err) + } + headAck, err := readCAGTCPPacket(conn, 50) + if err != nil { + return nil, nil, fmt.Errorf("read CAG TCP local-key ack: %w", err) + } + if len(headAck) < 50 || string(headAck[:4]) != "ZTEC" { + return nil, nil, fmt.Errorf("invalid CAG TCP local-key ack") + } + conv := binary.LittleEndian.Uint32(headAck[14:18]) + + second, err := buildCAGTCPAuthPacket(authTemplate, opts.Params) + if err != nil { + return nil, nil, err + } + if _, err := conn.Write(second); err != nil { + return nil, nil, fmt.Errorf("send CAG TCP auth: %w", err) + } + authAck, err := readCAGTCPPacket(conn, 36) + if err != nil { + return nil, nil, fmt.Errorf("read CAG TCP auth ack: %w", err) + } + if len(authAck) < 8 || authAck[4] != 0x01 { + prefixLen := 16 + if len(authAck) < prefixLen { + prefixLen = len(authAck) + } + return nil, nil, fmt.Errorf("invalid CAG TCP auth ack: %x", authAck[:prefixLen]) + } + + tlsConn := tls.Client(conn, &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + }) + if err := tlsConn.HandshakeContext(ctx); err != nil { + return nil, nil, fmt.Errorf("CAG TCP TLS handshake: %w", err) + } + _ = tlsConn.SetDeadline(time.Time{}) + + closeOnError = false + return tlsConn, &CAGSessionInfo{Conv: conv}, nil +} + +func buildCAGTCPAuthPacket(template []byte, params *ConnectParams) ([]byte, error) { + return buildCAGAuthBlob(params, template) +} + +func readCAGTCPPacket(conn net.Conn, want int) ([]byte, error) { + buf := make([]byte, want) + if _, err := io.ReadFull(conn, buf); err != nil { + return nil, err + } + return buf, nil +} diff --git a/internal/zte/client.go b/internal/zte/client.go new file mode 100644 index 0000000..7a6bd76 --- /dev/null +++ b/internal/zte/client.go @@ -0,0 +1,351 @@ +package zte + +import ( + "bytes" + "crypto/rand" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + ClientVersion = "V7.24.11" + requestFrom = "2" + defaultMac = "8C-04-BA-9C-C2-E7" + defaultIP = "192.168.1.165" + defaultHost = "wangpeng-pc" + defaultUStr = "31BF5444-86E0-4D5D-B1AB-A42FFBAC72C9" +) + +type FirmAuth struct { + VMUserName string + VMPassword string + VMID string + VMCIP string + VMCPort int + CAGIP string + CAGPort int +} + +type Client struct { + FirmAuth FirmAuth + HTTP *http.Client + TerminalUUID string + SerialNumber string +} + +type TokenInfo struct { + AccessToken string + Raw map[string]any +} + +type queryParam struct { + Key string + Value string +} + +func NewClient(firm FirmAuth) *Client { + terminalUUID := newUUID() + serialNumber := newUUID() + jar, _ := cookiejar.New(nil) + return &Client{ + FirmAuth: firm, + TerminalUUID: terminalUUID, + SerialNumber: serialNumber, + HTTP: &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + Transport: &http.Transport{ + ForceAttemptHTTP2: false, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // ZTE CAG uses the bundled client trust store. + TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}, + }, + }, + } +} + +func (c *Client) SysConfig() (map[string]any, error) { + values := []queryParam{ + {"version", ClientVersion}, + {"language", "zh"}, + {"requestFrom", requestFrom}, + {"name", c.FirmAuth.VMUserName}, + {"RspSecurity", "1"}, + } + return c.request("/cs/cs_sysConfig.action", values, "") +} + +func (c *Client) GetAccessToken() (*TokenInfo, error) { + f := c.FirmAuth + password, err := EncodeVDIPassword(f.VMPassword) + if err != nil { + return nil, err + } + values := []queryParam{ + {"username", f.VMUserName}, + {"password", password}, + {"version", ClientVersion}, + {"language", "zh"}, + {"clientId", ""}, + {"encrypt", "4"}, + {"token", ""}, + {"requestFrom", requestFrom}, + {"mac", defaultMac}, + {"clientIp", defaultIP}, + {"hostName", defaultHost}, + {"newVersionCtrl", "1"}, + {"netflags", "1"}, + {"unityType", "1"}, + {"isvm", "0"}, + {"RspSecurity", "1"}, + } + body := map[string]int{ + "clienttype": 0, + "hardware": 4, + "nettype": 2, + "ostype": 1, + } + result, err := c.request("/cs/cs_getToken.action", values, body) + if err != nil { + return nil, err + } + token, _ := result["accessToken"].(string) + if token == "" { + return nil, fmt.Errorf("missing accessToken in response: %v", result) + } + return &TokenInfo{AccessToken: token, Raw: result}, nil +} + +func (c *Client) GetDesktopList(accessToken string) (map[string]any, error) { + values := []queryParam{ + {"accessToken", accessToken}, + {"type", "7"}, + {"version", ClientVersion}, + {"language", "zh"}, + {"clientIp", defaultIP}, + {"requestFrom", requestFrom}, + {"isvm", "0"}, + {"RspSecurity", "1"}, + } + return c.request("/cs/cs_getDesktopList.action", values, "") +} + +func (c *Client) StartDesktop(accessToken string, desktop map[string]any) (map[string]any, error) { + body := c.startDesktopBody(accessToken, desktop) + return c.request("/cs/cs_startDesktop.action", nil, body) +} + +func (c *Client) StartDesktopAsyncQuery(accessToken string) (map[string]any, error) { + values := []queryParam{ + {"accessToken", accessToken}, + {"language", "zh"}, + {"isvm", "0"}, + {"vmid", c.FirmAuth.VMID}, + {"RspSecurity", "1"}, + {"prover", "1"}, + {"allowSwitchRap", "1"}, + } + return c.request("/cs/cs_startDesktop_async_query.action", values, "") +} + +func (c *Client) request(path string, values []queryParam, body any) (map[string]any, error) { + if c.HTTP == nil { + c.HTTP = NewClient(c.FirmAuth).HTTP + } + query := encodeQuery(values) + reqURL := fmt.Sprintf("https://%s:%d%s", c.FirmAuth.CAGIP, c.FirmAuth.CAGPort, path) + if query != "" { + reqURL += "?" + query + } + + encryptedBody, err := encodeRequestBody(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(encryptedBody)) + if err != nil { + return nil, err + } + c.setHeaders(req) + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("zte %s failed: status=%d body=%s", path, resp.StatusCode, string(respBody)) + } + result, err := DecodeSecurityJSON(respBody) + if err != nil { + return nil, fmt.Errorf("zte %s: %w", path, err) + } + if ok, _ := result["success"].(bool); !ok { + return nil, fmt.Errorf("zte %s failed: %s", path, compactJSON(result)) + } + return result, nil +} + +func encodeQuery(values []queryParam) string { + if len(values) == 0 { + return "" + } + parts := make([]string, 0, len(values)) + for _, item := range values { + value := url.QueryEscape(item.Value) + if item.Key == "hostName" { + value = strings.ReplaceAll(value, "-", "%2D") + } + parts = append(parts, url.QueryEscape(item.Key)+"="+value) + } + return strings.Join(parts, "&") +} + +func (c *Client) setHeaders(req *http.Request) { + req.Header.Set("Content-Type", "application/xml") + req.Header.Set("Accept", "*/*") +} + +func (c *Client) startDesktopBody(accessToken string, desktop map[string]any) map[string]any { + userID := intValue(desktop["userId"]) + groupID := intValue(desktop["groupId"]) + poolID := intValue(desktop["poolId"]) + assignRelation := fmt.Sprintf("%d,%d,%d", userID, groupID, poolID) + if userID == 0 && groupID == 0 && poolID == 0 { + assignRelation = "" + } + + return map[string]any{ + "RspSecurity": 1, + "SNcode": c.serialNumber(), + "accessToken": accessToken, + "allowExtUSBPolicy": 1, + "allowSwitchRap": 1, + "assignRelationtoString": assignRelation, + "connectionType": intValueDefault(desktop["connectionType"], 0), + "diskNo": "2250008001546", + "encryption": 1, + "hostName": defaultHost, + "isvm": 0, + "language": "zh", + "localipandmac": defaultIP + "," + defaultMac, + "netType": 2, + "newcharsetparse": 1, + "newpara": 1, + "prover": 1, + "raptype": 2, + "requestFrom": intValueDefault(requestFrom, 2), + "supportAsync": 1, + "supportCustomConfig": "00000000000000000000000000000011", + "type": intValueDefault(desktop["desktopType"], 1), + "upmnew": 1, + "uuid": stringValue(desktop["uuid"]), + "verifyTerminalBind": "11", + "version": ClientVersion, + "vmid": c.FirmAuth.VMID, + "watermarkType": 1, + } +} + +func encodeRequestBody(body any) (string, error) { + switch v := body.(type) { + case nil: + return "", nil + case string: + return v, nil + case []byte: + return string(v), nil + default: + data, err := json.Marshal(v) + if err != nil { + return "", fmt.Errorf("marshal zte request body: %w", err) + } + return string(data), nil + } +} + +func FirstDesktop(list map[string]any, vmID string) map[string]any { + desktops, _ := list["desktopList"].([]any) + for _, item := range desktops { + desktop, _ := item.(map[string]any) + if desktop == nil { + continue + } + if vmID == "" || stringValue(desktop["vmId"]) == vmID { + return desktop + } + } + return nil +} + +func (c *Client) serialNumber() string { + if c.SerialNumber != "" { + return c.SerialNumber + } + return defaultUStr +} + +func newUUID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return defaultUStr + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +func stringValue(v any) string { + switch x := v.(type) { + case string: + return x + case fmt.Stringer: + return x.String() + case nil: + return "" + default: + return fmt.Sprint(x) + } +} + +func intValue(v any) int { + return intValueDefault(v, 0) +} + +func intValueDefault(v any, def int) int { + switch x := v.(type) { + case int: + return x + case int64: + return int(x) + case float64: + return int(x) + case string: + n, err := strconv.Atoi(x) + if err == nil { + return n + } + } + return def +} + +func compactJSON(v any) string { + data, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + return strings.TrimSpace(string(data)) +} diff --git a/internal/zte/connect_params.go b/internal/zte/connect_params.go new file mode 100644 index 0000000..5e192c2 --- /dev/null +++ b/internal/zte/connect_params.go @@ -0,0 +1,119 @@ +package zte + +import ( + "fmt" + "net/url" + "strconv" + "strings" +) + +type ConnectParams struct { + Raw string + Args map[string]string + Host string + Port int + Key string + VMID string + AccessToken string + ProxySport int + VMIP string +} + +func DecodeConnectParams(connectStr string) (*ConnectParams, error) { + plain, err := DecodeConnectString(connectStr) + if err != nil { + return nil, err + } + return ParseConnectParams(plain) +} + +func ParseConnectParams(raw string) (*ConnectParams, error) { + tokens, err := splitCommandLine(raw) + if err != nil { + return nil, err + } + args := make(map[string]string) + for i := 0; i < len(tokens); i++ { + key := tokens[i] + if !strings.HasPrefix(key, "-") { + continue + } + value := "true" + if i+1 < len(tokens) && !strings.HasPrefix(tokens[i+1], "-") { + i++ + value = tokens[i] + } + if decoded, err := url.QueryUnescape(value); err == nil { + value = decoded + } + args[key] = value + } + + params := &ConnectParams{ + Raw: raw, + Args: args, + Host: args["-h"], + Key: args["-k"], + VMID: args["--vmid"], + AccessToken: args["--accessToken"], + VMIP: args["--vmip"], + } + params.Port, _ = strconv.Atoi(args["-p"]) + params.ProxySport, _ = strconv.Atoi(args["--proxy-sport"]) + + if params.Host == "" { + return nil, fmt.Errorf("connectStr missing -h host") + } + if params.Port == 0 { + return nil, fmt.Errorf("connectStr missing -p port") + } + if params.VMID == "" { + return nil, fmt.Errorf("connectStr missing --vmid") + } + return params, nil +} + +func splitCommandLine(s string) ([]string, error) { + var out []string + var b strings.Builder + var quote rune + escaped := false + + flush := func() { + if b.Len() == 0 { + return + } + out = append(out, b.String()) + b.Reset() + } + + for _, r := range s { + switch { + case escaped: + b.WriteRune(r) + escaped = false + case r == '\\': + escaped = true + case quote != 0: + if r == quote { + quote = 0 + } else { + b.WriteRune(r) + } + case r == '\'' || r == '"': + quote = r + case r == ' ' || r == '\t' || r == '\r' || r == '\n': + flush() + default: + b.WriteRune(r) + } + } + if escaped { + b.WriteRune('\\') + } + if quote != 0 { + return nil, fmt.Errorf("unterminated quote in connectStr") + } + flush() + return out, nil +} diff --git a/internal/zte/connect_params_test.go b/internal/zte/connect_params_test.go new file mode 100644 index 0000000..e68f90b --- /dev/null +++ b/internal/zte/connect_params_test.go @@ -0,0 +1,26 @@ +package zte + +import "testing" + +func TestParseConnectParams(t *testing.T) { + raw := `-p 10072 -k abc123 -h 10.8.2.26 -f --vmid 11111111-2222-3333-4444-555555555555 --proxy-sport 5100 --pass-through eyJ0IjoiMSJ9%3D -t %22desktop%22 --accessToken token123 --vmip 10.0.0.1%3B192.168.1.2` + params, err := ParseConnectParams(raw) + if err != nil { + t.Fatalf("ParseConnectParams() error = %v", err) + } + if params.Host != "10.8.2.26" || params.Port != 10072 { + t.Fatalf("target = %s:%d", params.Host, params.Port) + } + if params.Key != "abc123" { + t.Fatalf("Key = %q", params.Key) + } + if params.ProxySport != 5100 { + t.Fatalf("ProxySport = %d", params.ProxySport) + } + if params.Args["-t"] != `"desktop"` { + t.Fatalf("-t = %q", params.Args["-t"]) + } + if params.VMIP != "10.0.0.1;192.168.1.2" { + t.Fatalf("VMIP = %q", params.VMIP) + } +} diff --git a/internal/zte/security.go b/internal/zte/security.go new file mode 100644 index 0000000..a0bff8e --- /dev/null +++ b/internal/zte/security.go @@ -0,0 +1,139 @@ +package zte + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strings" +) + +const ( + securityKey = "56Acf4c3498fD4c5a0B1fb26947e2daB" + securityIV = "3498fD4c5a0B1fbA" + vdiKey = "3fec8a54-7e49-48" +) + +type securityEnvelope struct { + Params string `json:"ZTE_Security_Params"` +} + +func DecodeSecurityParams(body []byte) ([]byte, error) { + var env securityEnvelope + if err := json.Unmarshal(body, &env); err != nil { + return nil, fmt.Errorf("parse security envelope: %w", err) + } + if env.Params == "" { + return body, nil + } + + ciphertext, err := hex.DecodeString(env.Params) + if err != nil { + return nil, fmt.Errorf("decode security params hex: %w", err) + } + if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("invalid security params length: %d", len(ciphertext)) + } + + block, err := aes.NewCipher([]byte(securityKey)) + if err != nil { + return nil, fmt.Errorf("init aes: %w", err) + } + + plain := make([]byte, len(ciphertext)) + cipher.NewCBCDecrypter(block, []byte(securityIV)).CryptBlocks(plain, ciphertext) + + plain, err = pkcs7Unpad(plain, aes.BlockSize) + if err != nil { + return nil, err + } + return plain, nil +} + +func DecodeSecurityJSON(body []byte) (map[string]any, error) { + plain, err := DecodeSecurityParams(body) + if err != nil { + return nil, err + } + var result map[string]any + if err := json.Unmarshal(plain, &result); err != nil { + return nil, fmt.Errorf("parse decoded security json: %w (body: %s)", err, string(plain)) + } + return result, nil +} + +func EncodeSecurityParams(plaintext []byte) (string, error) { + block, err := aes.NewCipher([]byte(securityKey)) + if err != nil { + return "", fmt.Errorf("init aes: %w", err) + } + + padded := pkcs7Pad(plaintext, aes.BlockSize) + ciphertext := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, []byte(securityIV)).CryptBlocks(ciphertext, padded) + return strings.ToUpper(hex.EncodeToString(ciphertext)), nil +} + +func EncodeVDIPassword(password string) (string, error) { + block, err := aes.NewCipher([]byte(vdiKey)) + if err != nil { + return "", fmt.Errorf("init vdi aes: %w", err) + } + plain := pkcs7Pad([]byte(password), aes.BlockSize) + ciphertext := make([]byte, len(plain)) + for start := 0; start < len(plain); start += aes.BlockSize { + block.Encrypt(ciphertext[start:start+aes.BlockSize], plain[start:start+aes.BlockSize]) + } + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func DecodeConnectString(connectStr string) (string, error) { + ciphertext, err := hex.DecodeString(connectStr) + if err != nil { + return "", fmt.Errorf("decode connectStr hex: %w", err) + } + if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 { + return "", fmt.Errorf("invalid connectStr length: %d", len(ciphertext)) + } + block, err := aes.NewCipher([]byte(vdiKey)) + if err != nil { + return "", fmt.Errorf("init vdi aes: %w", 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 = pkcs7Unpad(plain, aes.BlockSize) + if err != nil { + return "", fmt.Errorf("unpad connectStr: %w", err) + } + return string(plain), nil +} + +func pkcs7Pad(data []byte, blockSize int) []byte { + pad := blockSize - len(data)%blockSize + out := make([]byte, len(data)+pad) + copy(out, data) + for i := len(data); i < len(out); i++ { + out[i] = byte(pad) + } + return out +} + +func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { + if len(data) == 0 || len(data)%blockSize != 0 { + return nil, fmt.Errorf("invalid pkcs7 data length: %d", len(data)) + } + pad := int(data[len(data)-1]) + if pad == 0 || pad > blockSize || pad > len(data) { + return nil, fmt.Errorf("invalid pkcs7 padding: %d", pad) + } + for _, b := range data[len(data)-pad:] { + if int(b) != pad { + return nil, fmt.Errorf("invalid pkcs7 padding bytes") + } + } + return data[:len(data)-pad], nil +} diff --git a/internal/zte/security_test.go b/internal/zte/security_test.go new file mode 100644 index 0000000..712d79b --- /dev/null +++ b/internal/zte/security_test.go @@ -0,0 +1,102 @@ +package zte + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/hex" + "testing" +) + +func TestDecodeSecurityParams(t *testing.T) { + plain := []byte(`{"result":"0","success":true}`) + ciphertext := encryptForTest(t, plain) + body := []byte(`{"ZTE_Security_Params":"` + hex.EncodeToString(ciphertext) + `"}`) + + got, err := DecodeSecurityParams(body) + if err != nil { + t.Fatalf("DecodeSecurityParams() error = %v", err) + } + if string(got) != string(plain) { + t.Fatalf("DecodeSecurityParams() = %q, want %q", got, plain) + } +} + +func TestEncodeSecurityParams(t *testing.T) { + tests := []struct { + name string + plain string + want string + }{ + { + name: "empty request body", + plain: "", + want: "B4A8D3307A6B1DFC9CE3A87211FEE4F5", + }, + { + name: "getToken request body", + plain: `{"clienttype":0,"hardware":4,"nettype":2,"ostype":1}`, + want: "B08102A05BB2CD93BB3C466BA39B10EA752158A62574071124D2023277A37AA386ACFA11B8EE9887F39B400C6FBBC5845651914B3A30D3DAC138A48CF30AEB55", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := EncodeSecurityParams([]byte(tt.plain)) + if err != nil { + t.Fatalf("EncodeSecurityParams() error = %v", err) + } + if got != tt.want { + t.Fatalf("EncodeSecurityParams() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestEncodeVDIPassword(t *testing.T) { + got, err := EncodeVDIPassword("11111111-2222-3333-4444-55555555555500000000") + if err != nil { + t.Fatalf("EncodeVDIPassword() error = %v", err) + } + want := "g6vbC77ZD6CH71Ht797bfsT0lb7h6ZrLgOILA6SdIpio6FqXEvefkPPg9KXDALqT" + if got != want { + t.Fatalf("EncodeVDIPassword() = %q, want %q", got, want) + } +} + +func TestDecodeConnectString(t *testing.T) { + want := "-p 10072 -h 10.8.2.26 --vmid test" + connectStr := encodeConnectStringForTest(t, want) + got, err := DecodeConnectString(connectStr) + if err != nil { + t.Fatalf("DecodeConnectString() error = %v", err) + } + if got != want { + t.Fatalf("DecodeConnectString() = %q, want %q", got, want) + } +} + +func encryptForTest(t *testing.T, plain []byte) []byte { + t.Helper() + block, err := aes.NewCipher([]byte(securityKey)) + if err != nil { + t.Fatal(err) + } + padded := pkcs7Pad(plain, aes.BlockSize) + out := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, []byte(securityIV)).CryptBlocks(out, padded) + return out +} + +func encodeConnectStringForTest(t *testing.T, plain string) string { + t.Helper() + block, err := aes.NewCipher([]byte(vdiKey)) + if err != nil { + t.Fatal(err) + } + padded := pkcs7Pad([]byte(plain), aes.BlockSize) + out := make([]byte, len(padded)) + for start := 0; start < len(padded); start += aes.BlockSize { + block.Encrypt(out[start:start+aes.BlockSize], padded[start:start+aes.BlockSize]) + } + return hex.EncodeToString(out) +}