mirror of
https://github.com/vxcontrol/cloud.git
synced 2026-07-19 19:53:42 -04:00
408 lines
12 KiB
Go
408 lines
12 KiB
Go
package sdk
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"hash/crc32"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/nacl/box"
|
|
"golang.org/x/net/http2"
|
|
)
|
|
|
|
const (
|
|
defaultScheme = "https"
|
|
defaultTicketPath = "/api/v1/ticket/"
|
|
sdkProtocolVersion = 1
|
|
)
|
|
|
|
const (
|
|
headerXInstallationID = "X-Installation-ID"
|
|
headerXRequestID = "X-Request-ID"
|
|
headerXRequestKey = "X-Request-Key"
|
|
headerXRequestSign = "X-Request-Sign"
|
|
headerXLicenseKey = "X-License-Key"
|
|
)
|
|
|
|
func DefaultTransport() *http.Transport {
|
|
// create dialer with optimal settings
|
|
dialer := &net.Dialer{
|
|
Timeout: 5 * time.Second, // connection timeout
|
|
KeepAlive: 30 * time.Second, // keep-alive probe interval
|
|
}
|
|
|
|
// create transport with production-optimized settings for 2-minute requests
|
|
transport := &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
DialContext: dialer.DialContext,
|
|
|
|
// connection pooling settings
|
|
MaxIdleConns: 50, // total max idle connections across all hosts
|
|
MaxIdleConnsPerHost: 10, // max idle connections per host
|
|
MaxConnsPerHost: 300, // max active connections per host
|
|
IdleConnTimeout: 90 * time.Second, // how long idle connections stay open
|
|
|
|
// timeout settings for backend API processing
|
|
TLSHandshakeTimeout: 10 * time.Second, // TLS handshake timeout
|
|
ResponseHeaderTimeout: 3 * time.Minute, // time to wait for response headers (backend processing)
|
|
ExpectContinueTimeout: 0, // disable expect continue timeout
|
|
|
|
// HTTP/2 and performance settings
|
|
ForceAttemptHTTP2: true, // enable HTTP/2
|
|
DisableCompression: false, // keep compression enabled
|
|
DisableKeepAlives: false, // keep-alive is essential
|
|
}
|
|
|
|
// enable HTTP/2 support for better performance
|
|
err := http2.ConfigureTransport(transport)
|
|
if err != nil {
|
|
// HTTP/2 configuration failed, but HTTP/1.1 will still work
|
|
// in production, you might want to log this
|
|
}
|
|
|
|
return transport
|
|
}
|
|
|
|
// ticketData contains parsed ticket information and session keys
|
|
type ticketData struct {
|
|
Key [16]byte
|
|
IV [16]byte
|
|
RequestID uuid.UUID
|
|
Nonce [16]byte
|
|
AllowedRPM uint16
|
|
WaitDelay uint16
|
|
}
|
|
|
|
func (c *callFunc) getTicket(cctx *callContext) (string, error) {
|
|
requestID := uuid.New()
|
|
|
|
sessionKey := [16]byte{}
|
|
sessionIV := [16]byte{}
|
|
_, err := rand.Read(sessionKey[:])
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate session key: %w", err)
|
|
}
|
|
_, err = rand.Read(sessionIV[:])
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate session IV: %w", err)
|
|
}
|
|
|
|
requestKeyHeader, err := c.createRequestKeyHeader(sessionKey, sessionIV, 0)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create request key header: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(cctx, "GET", cctx.reqTicketURL.String(), nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create ticket request: %w", err)
|
|
}
|
|
|
|
c.sdk.applyExtraHeaders(httpReq)
|
|
httpReq.Header.Set(headerXInstallationID, uuid.UUID(c.sdk.installationID).String())
|
|
httpReq.Header.Set(headerXRequestID, requestID.String())
|
|
httpReq.Header.Set(headerXRequestKey, requestKeyHeader)
|
|
httpReq.Header.Set("User-Agent", c.createUserAgentHeader())
|
|
if c.sdk.licenseKey != emptyLicenseKey && c.sdk.licenseFP != emptyLicenseFP {
|
|
licenseKeyHeader, err := c.createLicenseKeyHeader(sessionKey, sessionIV)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create license key header: %w", err)
|
|
}
|
|
httpReq.Header.Set(headerXLicenseKey, licenseKeyHeader)
|
|
}
|
|
|
|
resp, err := c.sdk.client.Do(httpReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("ticket request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read ticket response: %w", err)
|
|
}
|
|
|
|
if !isSuccessStatus(resp.StatusCode) {
|
|
return "", parseServerError(resp.StatusCode, resp.Header, body)
|
|
}
|
|
|
|
decryptedBody, err := DecryptBytes(body, sessionKey, sessionIV)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to decrypt ticket response: %w", err)
|
|
}
|
|
|
|
return string(decryptedBody), nil
|
|
}
|
|
|
|
func (c *callFunc) solvePoW(cctx *callContext, ticket string) (*ticketData, error) {
|
|
ctx, cancel := context.WithTimeout(cctx, c.sdk.powTimeout)
|
|
defer cancel()
|
|
|
|
result, err := transmute(ctx, ticket, c.sdk.installationID[:])
|
|
cctx.restWaitTime = time.Duration(result.Recipe.RestTime) * time.Second
|
|
if err != nil {
|
|
return nil, fmt.Errorf("PoW solving failed: %w", err)
|
|
}
|
|
|
|
xor(result.Catalyst[0:16], c.sdk.installationID[0:16])
|
|
|
|
td := &ticketData{
|
|
Key: [16]byte(result.Key),
|
|
IV: generateIV(c.sdk.installationID),
|
|
RequestID: uuid.UUID(result.Catalyst),
|
|
Nonce: [16]byte(result.Signature),
|
|
AllowedRPM: result.Recipe.Capacity,
|
|
WaitDelay: result.Recipe.RestTime,
|
|
}
|
|
|
|
return td, nil
|
|
}
|
|
|
|
func (c *callFunc) createSignature(td *ticketData, contentLength int64) (string, error) {
|
|
// nonce[16] + randPadding[11] + version[1] + timestamp[8] + contentLength[8] + crc32[4]
|
|
signData := make([]byte, 48)
|
|
copy(signData[0:16], td.Nonce[0:16])
|
|
xor(signData[0:16], c.sdk.installationID[0:16])
|
|
|
|
_, err := rand.Read(signData[16:27])
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate random padding: %w", err)
|
|
}
|
|
signData[27] = sdkProtocolVersion
|
|
|
|
binary.BigEndian.PutUint64(signData[28:36], uint64(time.Now().UTC().UnixMicro()))
|
|
binary.BigEndian.PutUint64(signData[36:44], uint64(contentLength))
|
|
|
|
hash := crc32.ChecksumIEEE(signData[16:44])
|
|
binary.BigEndian.PutUint32(signData[44:48], hash)
|
|
|
|
sc, err := aes.NewCipher(td.Key[:])
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create AES cipher: %w", err)
|
|
}
|
|
|
|
scb := cipher.NewCBCEncrypter(sc, td.IV[:])
|
|
scb.CryptBlocks(signData[16:48], signData[16:48])
|
|
|
|
return base64.StdEncoding.WithPadding(base64.NoPadding).EncodeToString(signData), nil
|
|
}
|
|
|
|
func (c *callFunc) createRequestKeyHeader(sessionKey, sessionIV [16]byte, contentLength int64) (string, error) {
|
|
var clientPublicKey [32]byte
|
|
copy(clientPublicKey[0:32], c.sdk.clientPublicKey[0:32])
|
|
xor(clientPublicKey[0:16], c.sdk.installationID[0:16])
|
|
xor(clientPublicKey[16:32], c.sdk.installationID[0:16])
|
|
|
|
var sharedKey [32]byte
|
|
box.Precompute(&sharedKey, c.sdk.serverPublicKey, c.sdk.clientPrivateKey)
|
|
|
|
// key[16] + iv[16] + timestamp[8] + contentLength[8]
|
|
payload := make([]byte, 48)
|
|
copy(payload[0:16], sessionKey[:])
|
|
copy(payload[16:32], sessionIV[:])
|
|
binary.BigEndian.PutUint64(payload[32:40], uint64(time.Now().UTC().UnixMicro()))
|
|
binary.BigEndian.PutUint64(payload[40:48], uint64(contentLength))
|
|
|
|
var nonce [24]byte
|
|
if _, err := rand.Read(nonce[:]); err != nil {
|
|
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
|
}
|
|
|
|
encryptedPayload := box.SealAfterPrecomputation(nil, payload, &nonce, &sharedKey)
|
|
|
|
// clientPublicKey[32] + nonce[24] + encryptedPayload[64]
|
|
headerData := make([]byte, 120)
|
|
copy(headerData[0:32], clientPublicKey[:])
|
|
copy(headerData[32:56], nonce[:])
|
|
copy(headerData[56:120], encryptedPayload)
|
|
|
|
return base64.StdEncoding.WithPadding(base64.NoPadding).EncodeToString(headerData), nil
|
|
}
|
|
|
|
func (c *callFunc) createLicenseKeyHeader(sessionKey, sessionIV [16]byte) (string, error) {
|
|
payload := make([]byte, 32)
|
|
copy(payload[6:16], c.sdk.licenseKey[0:10])
|
|
copy(payload[16:32], c.sdk.licenseFP[0:16])
|
|
|
|
if _, err := rand.Read(payload[0:6]); err != nil {
|
|
return "", fmt.Errorf("failed to generate random padding: %w", err)
|
|
}
|
|
|
|
sc, err := aes.NewCipher(sessionKey[:])
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create AES cipher: %w", err)
|
|
}
|
|
|
|
scb := cipher.NewCBCEncrypter(sc, sessionIV[:])
|
|
scb.CryptBlocks(payload[0:16], payload[0:16])
|
|
xor(payload[16:32], payload[0:16])
|
|
|
|
return base64.StdEncoding.WithPadding(base64.NoPadding).EncodeToString(payload), nil
|
|
}
|
|
|
|
func (c *callFunc) createUserAgentHeader() string {
|
|
userAgent := DefaultClientName + "/" + DefaultClientVersion
|
|
if c.sdk.clientName != DefaultClientName || c.sdk.clientVersion != DefaultClientVersion {
|
|
userAgent = c.sdk.clientName + "/" + c.sdk.clientVersion + " " + userAgent
|
|
}
|
|
|
|
return userAgent
|
|
}
|
|
|
|
// fetchTicketData acquires and solves a PoW ticket; shared by invokeRequest and Check.
|
|
func (c *callFunc) fetchTicketData(cctx *callContext) (*ticketData, error) {
|
|
ticket, err := c.getTicket(cctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get ticket: %w", err)
|
|
}
|
|
|
|
td, err := c.solvePoW(cctx, ticket)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to solve PoW: %w", err)
|
|
}
|
|
|
|
return td, nil
|
|
}
|
|
|
|
// newProbeContext builds a callContext with only the ticket URL set (used by Check).
|
|
func (c *callFunc) newProbeContext(ctx context.Context) *callContext {
|
|
return &callContext{
|
|
Context: ctx,
|
|
reqTicketURL: url.URL{
|
|
Scheme: defaultScheme,
|
|
Host: c.cfg.Host,
|
|
Path: defaultTicketPath + c.cfg.Name,
|
|
},
|
|
}
|
|
}
|
|
|
|
// invokeRequest performs a complete PoW-protected request
|
|
func (c *callFunc) invokeRequest(cctx *callContext) error {
|
|
// step 1: get ticket and solve PoW challenge
|
|
td, err := c.fetchTicketData(cctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// step 2: prepare encrypted request
|
|
var reqBody io.ReadCloser
|
|
if cctx.reqBodyReader != nil && cctx.reqBodyLength > 0 {
|
|
reqBody, err = EncryptStream(cctx.reqBodyReader, td.Key, td.IV)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to encrypt request body: %w", err)
|
|
}
|
|
}
|
|
|
|
// step 3: create signature
|
|
signature, err := c.createSignature(td, cctx.reqBodyLength)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create signature: %w", err)
|
|
}
|
|
|
|
// step 4: make HTTP target request
|
|
httpReq, err := http.NewRequestWithContext(cctx, cctx.reqMethod, cctx.reqCallURL.String(), reqBody)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
c.sdk.applyExtraHeaders(httpReq)
|
|
httpReq.Header.Set(headerXInstallationID, uuid.UUID(c.sdk.installationID).String())
|
|
httpReq.Header.Set(headerXRequestID, td.RequestID.String())
|
|
httpReq.Header.Set(headerXRequestSign, signature)
|
|
httpReq.Header.Set("User-Agent", c.createUserAgentHeader())
|
|
if cctx.reqBodyReader != nil && cctx.reqBodyLength > 0 {
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if c.sdk.licenseKey != emptyLicenseKey && c.sdk.licenseFP != emptyLicenseFP {
|
|
licenseKeyHeader, err := c.createLicenseKeyHeader(td.Key, td.IV)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create license key header: %w", err)
|
|
}
|
|
httpReq.Header.Set(headerXLicenseKey, licenseKeyHeader)
|
|
}
|
|
|
|
resp, err := c.sdk.client.Do(httpReq)
|
|
if err != nil {
|
|
return fmt.Errorf("HTTP request failed: %w: %w", ErrClientInternal, err)
|
|
}
|
|
if resp == nil {
|
|
return fmt.Errorf("%w: unexpected response value", ErrClientInternal)
|
|
}
|
|
|
|
cctx.respStatusCode = resp.StatusCode
|
|
if isSuccessStatus(cctx.respStatusCode) {
|
|
// response body should be closed after decryption
|
|
if cctx.respBodyWriter != nil {
|
|
err = DecryptProxy(resp.Body, cctx.respBodyWriter, td.Key, td.IV)
|
|
} else {
|
|
cctx.respBodyReader, err = DecryptStream(resp.Body, td.Key, td.IV)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decrypt response body: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
responseBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
return parseServerError(cctx.respStatusCode, resp.Header, responseBody)
|
|
}
|
|
|
|
func getServerPublicKey() *[32]byte {
|
|
rawPublic := [40]byte{
|
|
0x8C, 0x00, 0x4F, 0xE0, 0xA4, 0xA5, 0x2C, 0x02,
|
|
0xAD, 0xDC, 0x66, 0x4C, 0x52, 0x51, 0xA2, 0xC1,
|
|
0x98, 0x7C, 0xF3, 0x7F, 0x7C, 0x04, 0x60, 0x44,
|
|
0x73, 0x02, 0x2F, 0x89, 0x45, 0x2F, 0xCA, 0x06,
|
|
0xEA, 0x9A, 0xFF, 0x68, 0x03, 0x40, 0x6B, 0x60,
|
|
}
|
|
|
|
result := [40]byte{rawPublic[0]>>4 | rawPublic[0]<<4}
|
|
for i := 1; i < 40; i++ {
|
|
result[i] = result[i-1] ^ rawPublic[i-1] ^ rawPublic[i]
|
|
}
|
|
|
|
key := [32]byte(result[result[37]:result[39]])
|
|
return &key
|
|
}
|
|
|
|
func generateIV(init [16]byte) [16]byte {
|
|
messSha256 := func(data [32]byte) [32]byte {
|
|
result := data
|
|
for range 7 {
|
|
result = sha256.Sum256(result[:])
|
|
}
|
|
return result
|
|
}
|
|
|
|
doubled := [32]byte{}
|
|
copy(doubled[0:16], init[:])
|
|
copy(doubled[16:32], init[:])
|
|
hash := messSha256(doubled)
|
|
xor(hash[0:16], hash[16:32])
|
|
|
|
return [16]byte(hash[0:16])
|
|
}
|
|
|
|
func xor(dst []byte, op []byte) {
|
|
for i := range min(len(dst), len(op)) {
|
|
dst[i] ^= op[i]
|
|
}
|
|
}
|