send.go 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. // Package send implements the LocalSend v2.2 sending side.
  2. //
  3. // It is a trimmed, zero-external-dependency port of the official localgo
  4. // send pipeline: given a known target device (from discovery) and a list of
  5. // local file paths, it performs /prepare-upload and then streams each file
  6. // over /upload using the LocalSend v2.2 protocol (mTLS client cert + optional
  7. // server fingerprint pinning).
  8. package send
  9. import (
  10. "context"
  11. "crypto/rand"
  12. "crypto/sha256"
  13. "crypto/tls"
  14. "encoding/hex"
  15. "encoding/json"
  16. "errors"
  17. "fmt"
  18. "io"
  19. "net"
  20. "net/http"
  21. "os"
  22. "path/filepath"
  23. "strconv"
  24. "strings"
  25. "sync"
  26. "time"
  27. "TengTuToolbox/localsend/crypto"
  28. "TengTuToolbox/localsend/logging"
  29. "TengTuToolbox/localsend/model"
  30. )
  31. // ProtocolVersion is the LocalSend protocol version implemented here.
  32. const ProtocolVersion = "2.2"
  33. // SendConfig carries the sender identity + security context.
  34. type SendConfig struct {
  35. Alias string
  36. Port int // local listen port (announced to peers)
  37. SecurityContext *crypto.StoredSecurityContext
  38. HttpsEnabled bool
  39. Concurrency int // max parallel uploads (0 = default 4)
  40. }
  41. // SendFiles sends the given local file/directory paths to a target device.
  42. func SendFiles(ctx context.Context, cfg *SendConfig, device *model.Device, filePaths []string, logger *logging.Logger) error {
  43. if logger == nil {
  44. logger = logging.NewQuiet()
  45. }
  46. if cfg == nil {
  47. return errors.New("send: nil SendConfig")
  48. }
  49. if device == nil {
  50. return errors.New("send: nil target device")
  51. }
  52. if len(filePaths) == 0 {
  53. return errors.New("send: no files to send")
  54. }
  55. if cfg.Port <= 0 {
  56. cfg.Port = model.DefaultPort
  57. }
  58. client, scheme, err := newHTTPClient(cfg, device)
  59. if err != nil {
  60. return err
  61. }
  62. fileMap, err := getFilesWithRelativePaths(filePaths)
  63. if err != nil {
  64. return fmt.Errorf("failed to process file paths: %w", err)
  65. }
  66. filesDtoMap, filePathMap, err := buildFileDTOs(fileMap)
  67. if err != nil {
  68. return err
  69. }
  70. fingerprint := cfg.SecurityContext.CertificateHash
  71. prepareDto := model.PrepareUploadRequestDto{
  72. Info: model.InfoDto{
  73. Alias: cfg.Alias,
  74. Version: ProtocolVersion,
  75. DeviceType: model.DeviceTypeDesktop,
  76. Fingerprint: fingerprint,
  77. Port: device.Port,
  78. Protocol: model.ProtocolType(scheme),
  79. Download: true,
  80. },
  81. Files: filesDtoMap,
  82. }
  83. jsonData, err := json.Marshal(prepareDto)
  84. if err != nil {
  85. return fmt.Errorf("failed to marshal prepare dto: %w", err)
  86. }
  87. prepareResponse, err := doPrepare(ctx, client, device, scheme, jsonData)
  88. if err != nil {
  89. return err
  90. }
  91. concurrency := cfg.Concurrency
  92. if concurrency <= 0 {
  93. concurrency = 4
  94. }
  95. sem := make(chan struct{}, concurrency)
  96. var wg sync.WaitGroup
  97. errCh := make(chan error, len(prepareResponse.Files))
  98. for fileID, token := range prepareResponse.Files {
  99. filePath, ok := filePathMap[fileID]
  100. if !ok {
  101. logger.Warnf("Server responded with unknown file ID: %s", fileID)
  102. continue
  103. }
  104. displayName := filepath.Base(filePath)
  105. if remoteName, ok := fileMap[filePath]; ok && remoteName != "" {
  106. displayName = remoteName
  107. }
  108. wg.Add(1)
  109. go func(fID, tkn, fPath, name string) {
  110. defer wg.Done()
  111. sem <- struct{}{}
  112. defer func() { <-sem }()
  113. logger.Infof("Uploading file: %s", name)
  114. if err := uploadFile(ctx, client, device, fPath, fID, prepareResponse.SessionID, tkn, scheme, logger); err != nil {
  115. logger.Errorf("Failed to upload %s: %v", name, err)
  116. errCh <- fmt.Errorf("failed to upload %s: %w", name, err)
  117. }
  118. }(fileID, token, filePath, displayName)
  119. }
  120. wg.Wait()
  121. close(errCh)
  122. var uploadErrors []error
  123. for err := range errCh {
  124. uploadErrors = append(uploadErrors, err)
  125. }
  126. if len(uploadErrors) > 0 {
  127. return fmt.Errorf("encountered %d upload error(s), first error: %w", len(uploadErrors), uploadErrors[0])
  128. }
  129. logger.Info("All files uploaded successfully!")
  130. return nil
  131. }
  132. // newHTTPClient builds an http.Client that presents our mTLS client cert and,
  133. // when the target fingerprint is known, pins the server certificate.
  134. func newHTTPClient(cfg *SendConfig, device *model.Device) (*http.Client, string, error) {
  135. var getClientCert func(*tls.CertificateRequestInfo) (*tls.Certificate, error)
  136. if cfg.SecurityContext != nil {
  137. fn, err := cfg.SecurityContext.ClientCertificateFunc()
  138. if err != nil {
  139. return nil, "", fmt.Errorf("failed to load client TLS certificate: %w", err)
  140. }
  141. getClientCert = fn
  142. }
  143. scheme := "http"
  144. if device.Protocol == model.ProtocolTypeHTTPS {
  145. scheme = "https"
  146. tlsConfig := &tls.Config{
  147. InsecureSkipVerify: true,
  148. GetClientCertificate: getClientCert,
  149. }
  150. if device.Fingerprint != "" {
  151. expected := device.Fingerprint
  152. tlsConfig.VerifyConnection = func(state tls.ConnectionState) error {
  153. if len(state.PeerCertificates) == 0 {
  154. return errors.New("no peer certificates presented")
  155. }
  156. cert := state.PeerCertificates[0]
  157. hash := sha256.Sum256(cert.Raw)
  158. actual := hex.EncodeToString(hash[:])
  159. if !strings.EqualFold(actual, expected) {
  160. return fmt.Errorf("TLS certificate fingerprint mismatch: expected %s, got %s", expected, actual)
  161. }
  162. return nil
  163. }
  164. }
  165. tr := &http.Transport{TLSClientConfig: tlsConfig}
  166. return &http.Client{Transport: tr}, scheme, nil
  167. }
  168. // HTTP fallback (unlikely for LocalSend but supported)
  169. return &http.Client{}, scheme, nil
  170. }
  171. // doPrepare posts /prepare-upload and decodes the session response.
  172. func doPrepare(ctx context.Context, client *http.Client, device *model.Device, scheme string, jsonData []byte) (*model.PrepareUploadResponseDto, error) {
  173. endpoint := fmt.Sprintf("%s://%s/api/localsend/v2/prepare-upload", scheme, net.JoinHostPort(device.IP, strconv.Itoa(device.Port)))
  174. req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(jsonData)))
  175. if err != nil {
  176. return nil, fmt.Errorf("failed to create prepare request: %w", err)
  177. }
  178. req.Header.Set("Content-Type", "application/json")
  179. resp, err := client.Do(req)
  180. if err != nil {
  181. return nil, fmt.Errorf("failed to send prepare request: %w", err)
  182. }
  183. defer resp.Body.Close()
  184. if resp.StatusCode != http.StatusOK {
  185. return nil, fmt.Errorf("prepare request failed with status: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
  186. }
  187. var prepareResponse model.PrepareUploadResponseDto
  188. if err := json.NewDecoder(io.LimitReader(resp.Body, 1024*1024)).Decode(&prepareResponse); err != nil {
  189. return nil, fmt.Errorf("failed to decode prepare response: %w", err)
  190. }
  191. return &prepareResponse, nil
  192. }
  193. // getFilesWithRelativePaths expands a list of paths (files or dirs) into a
  194. // map of localPath -> remote relative name (slash-separated).
  195. func getFilesWithRelativePaths(paths []string) (map[string]string, error) {
  196. result := make(map[string]string)
  197. for _, p := range paths {
  198. p = filepath.Clean(p)
  199. info, err := os.Stat(p)
  200. if err != nil {
  201. return nil, err
  202. }
  203. if info.IsDir() {
  204. baseDir := filepath.Dir(p)
  205. err = filepath.Walk(p, func(path string, fInfo os.FileInfo, err error) error {
  206. if err != nil {
  207. return err
  208. }
  209. if !fInfo.IsDir() {
  210. rel, err := filepath.Rel(baseDir, path)
  211. if err == nil {
  212. result[path] = filepath.ToSlash(rel)
  213. } else {
  214. result[path] = filepath.Base(path)
  215. }
  216. }
  217. return nil
  218. })
  219. if err != nil {
  220. return nil, err
  221. }
  222. } else {
  223. result[p] = filepath.Base(p)
  224. }
  225. }
  226. return result, nil
  227. }
  228. // buildFileDTOs builds the FileDto map and the fileID -> localPath map.
  229. func buildFileDTOs(fileMap map[string]string) (map[string]model.FileDto, map[string]string, error) {
  230. filesDtoMap := make(map[string]model.FileDto)
  231. filePathMap := make(map[string]string)
  232. for filePath, remoteName := range fileMap {
  233. fileInfo, err := os.Stat(filePath)
  234. if err != nil {
  235. return nil, nil, fmt.Errorf("failed to get file info for %s: %w", filePath, err)
  236. }
  237. file, err := os.Open(filePath)
  238. if err != nil {
  239. return nil, nil, fmt.Errorf("failed to open file for detection %s: %w", filePath, err)
  240. }
  241. buffer := make([]byte, 512)
  242. n, _ := file.Read(buffer)
  243. file.Close()
  244. contentType := http.DetectContentType(buffer[:n])
  245. modTime := fileInfo.ModTime().Format(time.RFC3339)
  246. fileDto := model.FileDto{
  247. ID: randomID(),
  248. FileName: remoteName,
  249. Size: fileInfo.Size(),
  250. FileType: contentType,
  251. Metadata: &model.FileMetadata{Modified: &modTime},
  252. }
  253. filesDtoMap[fileDto.ID] = fileDto
  254. filePathMap[fileDto.ID] = filePath
  255. }
  256. return filesDtoMap, filePathMap, nil
  257. }
  258. // randomID generates a random hex string (used as file/session IDs, no uuid dep).
  259. func randomID() string {
  260. b := make([]byte, 16)
  261. if _, err := rand.Read(b); err != nil {
  262. return fmt.Sprintf("%x", time.Now().UnixNano())
  263. }
  264. return hex.EncodeToString(b)
  265. }