custom_geo.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. package service
  2. import (
  3. "errors"
  4. "fmt"
  5. "io"
  6. "net/http"
  7. "net/url"
  8. "os"
  9. "path/filepath"
  10. "regexp"
  11. "strings"
  12. "time"
  13. "github.com/mhsanaei/3x-ui/v2/config"
  14. "github.com/mhsanaei/3x-ui/v2/database"
  15. "github.com/mhsanaei/3x-ui/v2/database/model"
  16. "github.com/mhsanaei/3x-ui/v2/logger"
  17. )
  18. const (
  19. customGeoTypeGeosite = "geosite"
  20. customGeoTypeGeoip = "geoip"
  21. minDatBytes = 64
  22. customGeoProbeTimeout = 12 * time.Second
  23. )
  24. var (
  25. customGeoAliasPattern = regexp.MustCompile(`^[a-z0-9_-]+$`)
  26. reservedCustomAliases = map[string]struct{}{
  27. "geoip": {}, "geosite": {},
  28. "geoip_ir": {}, "geosite_ir": {},
  29. "geoip_ru": {}, "geosite_ru": {},
  30. }
  31. ErrCustomGeoInvalidType = errors.New("custom_geo_invalid_type")
  32. ErrCustomGeoAliasRequired = errors.New("custom_geo_alias_required")
  33. ErrCustomGeoAliasPattern = errors.New("custom_geo_alias_pattern")
  34. ErrCustomGeoAliasReserved = errors.New("custom_geo_alias_reserved")
  35. ErrCustomGeoURLRequired = errors.New("custom_geo_url_required")
  36. ErrCustomGeoInvalidURL = errors.New("custom_geo_invalid_url")
  37. ErrCustomGeoURLScheme = errors.New("custom_geo_url_scheme")
  38. ErrCustomGeoURLHost = errors.New("custom_geo_url_host")
  39. ErrCustomGeoDuplicateAlias = errors.New("custom_geo_duplicate_alias")
  40. ErrCustomGeoNotFound = errors.New("custom_geo_not_found")
  41. ErrCustomGeoDownload = errors.New("custom_geo_download")
  42. )
  43. type CustomGeoUpdateAllItem struct {
  44. Id int `json:"id"`
  45. Alias string `json:"alias"`
  46. FileName string `json:"fileName"`
  47. }
  48. type CustomGeoUpdateAllFailure struct {
  49. Id int `json:"id"`
  50. Alias string `json:"alias"`
  51. FileName string `json:"fileName"`
  52. Err string `json:"error"`
  53. }
  54. type CustomGeoUpdateAllResult struct {
  55. Succeeded []CustomGeoUpdateAllItem `json:"succeeded"`
  56. Failed []CustomGeoUpdateAllFailure `json:"failed"`
  57. }
  58. type CustomGeoService struct {
  59. serverService *ServerService
  60. updateAllGetAll func() ([]model.CustomGeoResource, error)
  61. updateAllApply func(id int, onStartup bool) (string, error)
  62. updateAllRestart func() error
  63. }
  64. func NewCustomGeoService() *CustomGeoService {
  65. s := &CustomGeoService{
  66. serverService: &ServerService{},
  67. }
  68. s.updateAllGetAll = s.GetAll
  69. s.updateAllApply = s.applyDownloadAndPersist
  70. s.updateAllRestart = func() error { return s.serverService.RestartXrayService() }
  71. return s
  72. }
  73. func NormalizeAliasKey(alias string) string {
  74. return strings.ToLower(strings.ReplaceAll(alias, "-", "_"))
  75. }
  76. func (s *CustomGeoService) fileNameFor(typ, alias string) string {
  77. if typ == customGeoTypeGeoip {
  78. return fmt.Sprintf("geoip_%s.dat", alias)
  79. }
  80. return fmt.Sprintf("geosite_%s.dat", alias)
  81. }
  82. func (s *CustomGeoService) validateType(typ string) error {
  83. if typ != customGeoTypeGeosite && typ != customGeoTypeGeoip {
  84. return ErrCustomGeoInvalidType
  85. }
  86. return nil
  87. }
  88. func (s *CustomGeoService) validateAlias(alias string) error {
  89. if alias == "" {
  90. return ErrCustomGeoAliasRequired
  91. }
  92. if !customGeoAliasPattern.MatchString(alias) {
  93. return ErrCustomGeoAliasPattern
  94. }
  95. if _, ok := reservedCustomAliases[NormalizeAliasKey(alias)]; ok {
  96. return ErrCustomGeoAliasReserved
  97. }
  98. return nil
  99. }
  100. func (s *CustomGeoService) validateURL(raw string) error {
  101. if raw == "" {
  102. return ErrCustomGeoURLRequired
  103. }
  104. u, err := url.Parse(raw)
  105. if err != nil {
  106. return ErrCustomGeoInvalidURL
  107. }
  108. if u.Scheme != "http" && u.Scheme != "https" {
  109. return ErrCustomGeoURLScheme
  110. }
  111. if u.Host == "" {
  112. return ErrCustomGeoURLHost
  113. }
  114. return nil
  115. }
  116. func localDatFileNeedsRepair(path string) bool {
  117. fi, err := os.Stat(path)
  118. if err != nil {
  119. return true
  120. }
  121. if fi.IsDir() {
  122. return true
  123. }
  124. return fi.Size() < int64(minDatBytes)
  125. }
  126. func CustomGeoLocalFileNeedsRepair(path string) bool {
  127. return localDatFileNeedsRepair(path)
  128. }
  129. func probeCustomGeoURLWithGET(rawURL string) error {
  130. client := &http.Client{Timeout: customGeoProbeTimeout}
  131. req, err := http.NewRequest(http.MethodGet, rawURL, nil)
  132. if err != nil {
  133. return err
  134. }
  135. req.Header.Set("Range", "bytes=0-0")
  136. resp, err := client.Do(req)
  137. if err != nil {
  138. return err
  139. }
  140. defer resp.Body.Close()
  141. _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 256))
  142. switch resp.StatusCode {
  143. case http.StatusOK, http.StatusPartialContent:
  144. return nil
  145. default:
  146. return fmt.Errorf("get range status %d", resp.StatusCode)
  147. }
  148. }
  149. func probeCustomGeoURL(rawURL string) error {
  150. client := &http.Client{Timeout: customGeoProbeTimeout}
  151. req, err := http.NewRequest(http.MethodHead, rawURL, nil)
  152. if err != nil {
  153. return err
  154. }
  155. resp, err := client.Do(req)
  156. if err != nil {
  157. return err
  158. }
  159. _ = resp.Body.Close()
  160. sc := resp.StatusCode
  161. if sc >= 200 && sc < 300 {
  162. return nil
  163. }
  164. if sc == http.StatusMethodNotAllowed || sc == http.StatusNotImplemented {
  165. return probeCustomGeoURLWithGET(rawURL)
  166. }
  167. return fmt.Errorf("head status %d", sc)
  168. }
  169. func (s *CustomGeoService) EnsureOnStartup() {
  170. list, err := s.GetAll()
  171. if err != nil {
  172. logger.Warning("custom geo startup: load list:", err)
  173. return
  174. }
  175. n := len(list)
  176. if n == 0 {
  177. logger.Info("custom geo startup: no custom geofiles configured")
  178. return
  179. }
  180. logger.Infof("custom geo startup: checking %d custom geofile(s)", n)
  181. for i := range list {
  182. r := &list[i]
  183. if err := s.validateURL(r.Url); err != nil {
  184. logger.Warningf("custom geo startup id=%d: invalid url: %v", r.Id, err)
  185. continue
  186. }
  187. s.syncLocalPath(r)
  188. localPath := r.LocalPath
  189. if !localDatFileNeedsRepair(localPath) {
  190. logger.Infof("custom geo startup id=%d alias=%s path=%s: present", r.Id, r.Alias, localPath)
  191. continue
  192. }
  193. logger.Infof("custom geo startup id=%d alias=%s path=%s: missing or needs repair, probing source", r.Id, r.Alias, localPath)
  194. if err := probeCustomGeoURL(r.Url); err != nil {
  195. logger.Warningf("custom geo startup id=%d alias=%s url=%s: probe: %v (attempting download anyway)", r.Id, r.Alias, r.Url, err)
  196. }
  197. _, _ = s.applyDownloadAndPersist(r.Id, true)
  198. }
  199. }
  200. func (s *CustomGeoService) downloadToPath(resourceURL, destPath string, lastModifiedHeader string) (skipped bool, newLastModified string, err error) {
  201. skipped, lm, err := s.downloadToPathOnce(resourceURL, destPath, lastModifiedHeader, false)
  202. if err != nil {
  203. return false, "", err
  204. }
  205. if skipped {
  206. if _, statErr := os.Stat(destPath); statErr == nil && !localDatFileNeedsRepair(destPath) {
  207. return true, lm, nil
  208. }
  209. return s.downloadToPathOnce(resourceURL, destPath, lastModifiedHeader, true)
  210. }
  211. return false, lm, nil
  212. }
  213. func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, lastModifiedHeader string, forceFull bool) (skipped bool, newLastModified string, err error) {
  214. var req *http.Request
  215. req, err = http.NewRequest(http.MethodGet, resourceURL, nil)
  216. if err != nil {
  217. return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
  218. }
  219. if !forceFull {
  220. if fi, statErr := os.Stat(destPath); statErr == nil && !localDatFileNeedsRepair(destPath) {
  221. if !fi.ModTime().IsZero() {
  222. req.Header.Set("If-Modified-Since", fi.ModTime().UTC().Format(http.TimeFormat))
  223. } else if lastModifiedHeader != "" {
  224. if t, perr := time.Parse(http.TimeFormat, lastModifiedHeader); perr == nil {
  225. req.Header.Set("If-Modified-Since", t.UTC().Format(http.TimeFormat))
  226. }
  227. }
  228. }
  229. }
  230. client := &http.Client{Timeout: 10 * time.Minute}
  231. resp, err := client.Do(req)
  232. if err != nil {
  233. return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
  234. }
  235. defer resp.Body.Close()
  236. var serverModTime time.Time
  237. if lm := resp.Header.Get("Last-Modified"); lm != "" {
  238. if parsed, perr := time.Parse(http.TimeFormat, lm); perr == nil {
  239. serverModTime = parsed
  240. newLastModified = lm
  241. }
  242. }
  243. updateModTime := func() {
  244. if !serverModTime.IsZero() {
  245. _ = os.Chtimes(destPath, serverModTime, serverModTime)
  246. }
  247. }
  248. if resp.StatusCode == http.StatusNotModified {
  249. if forceFull {
  250. return false, "", fmt.Errorf("%w: unexpected 304 on unconditional get", ErrCustomGeoDownload)
  251. }
  252. updateModTime()
  253. return true, newLastModified, nil
  254. }
  255. if resp.StatusCode != http.StatusOK {
  256. return false, "", fmt.Errorf("%w: unexpected status %d", ErrCustomGeoDownload, resp.StatusCode)
  257. }
  258. binDir := filepath.Dir(destPath)
  259. if err = os.MkdirAll(binDir, 0o755); err != nil {
  260. return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
  261. }
  262. tmpPath := destPath + ".tmp"
  263. out, err := os.Create(tmpPath)
  264. if err != nil {
  265. return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
  266. }
  267. n, err := io.Copy(out, resp.Body)
  268. closeErr := out.Close()
  269. if err != nil {
  270. _ = os.Remove(tmpPath)
  271. return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
  272. }
  273. if closeErr != nil {
  274. _ = os.Remove(tmpPath)
  275. return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, closeErr)
  276. }
  277. if n < minDatBytes {
  278. _ = os.Remove(tmpPath)
  279. return false, "", fmt.Errorf("%w: file too small", ErrCustomGeoDownload)
  280. }
  281. if err = os.Rename(tmpPath, destPath); err != nil {
  282. _ = os.Remove(tmpPath)
  283. return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
  284. }
  285. updateModTime()
  286. if newLastModified == "" && resp.Header.Get("Last-Modified") != "" {
  287. newLastModified = resp.Header.Get("Last-Modified")
  288. }
  289. return false, newLastModified, nil
  290. }
  291. func (s *CustomGeoService) resolveDestPath(r *model.CustomGeoResource) string {
  292. if r.LocalPath != "" {
  293. return r.LocalPath
  294. }
  295. return filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
  296. }
  297. func (s *CustomGeoService) syncLocalPath(r *model.CustomGeoResource) {
  298. p := filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
  299. r.LocalPath = p
  300. }
  301. func (s *CustomGeoService) Create(r *model.CustomGeoResource) error {
  302. if err := s.validateType(r.Type); err != nil {
  303. return err
  304. }
  305. if err := s.validateAlias(r.Alias); err != nil {
  306. return err
  307. }
  308. if err := s.validateURL(r.Url); err != nil {
  309. return err
  310. }
  311. var existing int64
  312. database.GetDB().Model(&model.CustomGeoResource{}).
  313. Where("geo_type = ? AND alias = ?", r.Type, r.Alias).Count(&existing)
  314. if existing > 0 {
  315. return ErrCustomGeoDuplicateAlias
  316. }
  317. s.syncLocalPath(r)
  318. skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified)
  319. if err != nil {
  320. return err
  321. }
  322. now := time.Now().Unix()
  323. r.LastUpdatedAt = now
  324. r.LastModified = lm
  325. if err = database.GetDB().Create(r).Error; err != nil {
  326. _ = os.Remove(r.LocalPath)
  327. return err
  328. }
  329. logger.Infof("custom geo created id=%d type=%s alias=%s skipped=%v", r.Id, r.Type, r.Alias, skipped)
  330. if err = s.serverService.RestartXrayService(); err != nil {
  331. logger.Warning("custom geo create: restart xray:", err)
  332. }
  333. return nil
  334. }
  335. func (s *CustomGeoService) Update(id int, r *model.CustomGeoResource) error {
  336. var cur model.CustomGeoResource
  337. if err := database.GetDB().First(&cur, id).Error; err != nil {
  338. if database.IsNotFound(err) {
  339. return ErrCustomGeoNotFound
  340. }
  341. return err
  342. }
  343. if err := s.validateType(r.Type); err != nil {
  344. return err
  345. }
  346. if err := s.validateAlias(r.Alias); err != nil {
  347. return err
  348. }
  349. if err := s.validateURL(r.Url); err != nil {
  350. return err
  351. }
  352. if cur.Type != r.Type || cur.Alias != r.Alias {
  353. var cnt int64
  354. database.GetDB().Model(&model.CustomGeoResource{}).
  355. Where("geo_type = ? AND alias = ? AND id <> ?", r.Type, r.Alias, id).
  356. Count(&cnt)
  357. if cnt > 0 {
  358. return ErrCustomGeoDuplicateAlias
  359. }
  360. }
  361. oldPath := s.resolveDestPath(&cur)
  362. s.syncLocalPath(r)
  363. r.Id = id
  364. r.LocalPath = filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
  365. if oldPath != r.LocalPath && oldPath != "" {
  366. if _, err := os.Stat(oldPath); err == nil {
  367. _ = os.Remove(oldPath)
  368. }
  369. }
  370. _, lm, err := s.downloadToPath(r.Url, r.LocalPath, cur.LastModified)
  371. if err != nil {
  372. return err
  373. }
  374. r.LastUpdatedAt = time.Now().Unix()
  375. r.LastModified = lm
  376. err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(map[string]any{
  377. "geo_type": r.Type,
  378. "alias": r.Alias,
  379. "url": r.Url,
  380. "local_path": r.LocalPath,
  381. "last_updated_at": r.LastUpdatedAt,
  382. "last_modified": r.LastModified,
  383. }).Error
  384. if err != nil {
  385. return err
  386. }
  387. logger.Infof("custom geo updated id=%d", id)
  388. if err = s.serverService.RestartXrayService(); err != nil {
  389. logger.Warning("custom geo update: restart xray:", err)
  390. }
  391. return nil
  392. }
  393. func (s *CustomGeoService) Delete(id int) (displayName string, err error) {
  394. var r model.CustomGeoResource
  395. if err := database.GetDB().First(&r, id).Error; err != nil {
  396. if database.IsNotFound(err) {
  397. return "", ErrCustomGeoNotFound
  398. }
  399. return "", err
  400. }
  401. displayName = s.fileNameFor(r.Type, r.Alias)
  402. p := s.resolveDestPath(&r)
  403. if err := database.GetDB().Delete(&model.CustomGeoResource{}, id).Error; err != nil {
  404. return displayName, err
  405. }
  406. if p != "" {
  407. if _, err := os.Stat(p); err == nil {
  408. if rmErr := os.Remove(p); rmErr != nil {
  409. logger.Warningf("custom geo delete file %s: %v", p, rmErr)
  410. }
  411. }
  412. }
  413. logger.Infof("custom geo deleted id=%d", id)
  414. if err := s.serverService.RestartXrayService(); err != nil {
  415. logger.Warning("custom geo delete: restart xray:", err)
  416. }
  417. return displayName, nil
  418. }
  419. func (s *CustomGeoService) GetAll() ([]model.CustomGeoResource, error) {
  420. var list []model.CustomGeoResource
  421. err := database.GetDB().Order("id asc").Find(&list).Error
  422. return list, err
  423. }
  424. func (s *CustomGeoService) applyDownloadAndPersist(id int, onStartup bool) (displayName string, err error) {
  425. var r model.CustomGeoResource
  426. if err := database.GetDB().First(&r, id).Error; err != nil {
  427. if database.IsNotFound(err) {
  428. return "", ErrCustomGeoNotFound
  429. }
  430. return "", err
  431. }
  432. displayName = s.fileNameFor(r.Type, r.Alias)
  433. s.syncLocalPath(&r)
  434. skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified)
  435. if err != nil {
  436. if onStartup {
  437. logger.Warningf("custom geo startup download id=%d: %v", id, err)
  438. } else {
  439. logger.Warningf("custom geo manual update id=%d: %v", id, err)
  440. }
  441. return displayName, err
  442. }
  443. now := time.Now().Unix()
  444. updates := map[string]any{
  445. "last_modified": lm,
  446. "local_path": r.LocalPath,
  447. "last_updated_at": now,
  448. }
  449. if err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(updates).Error; err != nil {
  450. if onStartup {
  451. logger.Warningf("custom geo startup id=%d: persist metadata: %v", id, err)
  452. } else {
  453. logger.Warningf("custom geo manual update id=%d: persist metadata: %v", id, err)
  454. }
  455. return displayName, err
  456. }
  457. if skipped {
  458. if onStartup {
  459. logger.Infof("custom geo startup download skipped (not modified) id=%d", id)
  460. } else {
  461. logger.Infof("custom geo manual update skipped (not modified) id=%d", id)
  462. }
  463. } else {
  464. if onStartup {
  465. logger.Infof("custom geo startup download ok id=%d", id)
  466. } else {
  467. logger.Infof("custom geo manual update ok id=%d", id)
  468. }
  469. }
  470. return displayName, nil
  471. }
  472. func (s *CustomGeoService) TriggerUpdate(id int) (string, error) {
  473. displayName, err := s.applyDownloadAndPersist(id, false)
  474. if err != nil {
  475. return displayName, err
  476. }
  477. if err = s.serverService.RestartXrayService(); err != nil {
  478. logger.Warning("custom geo manual update: restart xray:", err)
  479. }
  480. return displayName, nil
  481. }
  482. func (s *CustomGeoService) TriggerUpdateAll() (*CustomGeoUpdateAllResult, error) {
  483. var list []model.CustomGeoResource
  484. var err error
  485. if s.updateAllGetAll != nil {
  486. list, err = s.updateAllGetAll()
  487. } else {
  488. list, err = s.GetAll()
  489. }
  490. if err != nil {
  491. return nil, err
  492. }
  493. res := &CustomGeoUpdateAllResult{}
  494. if len(list) == 0 {
  495. return res, nil
  496. }
  497. for _, r := range list {
  498. var name string
  499. var applyErr error
  500. if s.updateAllApply != nil {
  501. name, applyErr = s.updateAllApply(r.Id, false)
  502. } else {
  503. name, applyErr = s.applyDownloadAndPersist(r.Id, false)
  504. }
  505. if applyErr != nil {
  506. res.Failed = append(res.Failed, CustomGeoUpdateAllFailure{
  507. Id: r.Id, Alias: r.Alias, FileName: name, Err: applyErr.Error(),
  508. })
  509. continue
  510. }
  511. res.Succeeded = append(res.Succeeded, CustomGeoUpdateAllItem{
  512. Id: r.Id, Alias: r.Alias, FileName: name,
  513. })
  514. }
  515. if len(res.Succeeded) > 0 {
  516. var restartErr error
  517. if s.updateAllRestart != nil {
  518. restartErr = s.updateAllRestart()
  519. } else {
  520. restartErr = s.serverService.RestartXrayService()
  521. }
  522. if restartErr != nil {
  523. logger.Warning("custom geo update all: restart xray:", restartErr)
  524. }
  525. }
  526. return res, nil
  527. }
  528. type CustomGeoAliasItem struct {
  529. Alias string `json:"alias"`
  530. Type string `json:"type"`
  531. FileName string `json:"fileName"`
  532. ExtExample string `json:"extExample"`
  533. }
  534. type CustomGeoAliasesResponse struct {
  535. Geosite []CustomGeoAliasItem `json:"geosite"`
  536. Geoip []CustomGeoAliasItem `json:"geoip"`
  537. }
  538. func (s *CustomGeoService) GetAliasesForUI() (CustomGeoAliasesResponse, error) {
  539. list, err := s.GetAll()
  540. if err != nil {
  541. logger.Warning("custom geo GetAliasesForUI:", err)
  542. return CustomGeoAliasesResponse{}, err
  543. }
  544. var out CustomGeoAliasesResponse
  545. for _, r := range list {
  546. fn := s.fileNameFor(r.Type, r.Alias)
  547. ex := fmt.Sprintf("ext:%s:tag", fn)
  548. item := CustomGeoAliasItem{
  549. Alias: r.Alias,
  550. Type: r.Type,
  551. FileName: fn,
  552. ExtExample: ex,
  553. }
  554. if r.Type == customGeoTypeGeoip {
  555. out.Geoip = append(out.Geoip, item)
  556. } else {
  557. out.Geosite = append(out.Geosite, item)
  558. }
  559. }
  560. return out, nil
  561. }