psinet.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. /*
  2. * Copyright (c) 2016, Psiphon Inc.
  3. * All rights reserved.
  4. *
  5. * This program is free software: you can redistribute it and/or modify
  6. * it under the terms of the GNU General Public License as published by
  7. * the Free Software Foundation, either version 3 of the License, or
  8. * (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. * GNU General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU General Public License
  16. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  17. *
  18. */
  19. // Package psinet implements psinet database services. The psinet database is a
  20. // JSON-format file containing information about the Psiphon network, including
  21. // sponsors, home pages, stats regexes, available upgrades, and other servers for
  22. // discovery.
  23. package psinet
  24. import (
  25. "crypto/md5"
  26. "encoding/json"
  27. "math/rand"
  28. "strconv"
  29. "strings"
  30. "time"
  31. "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
  32. "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
  33. "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
  34. )
  35. const (
  36. MAX_DATABASE_AGE_FOR_SERVER_ENTRY_VALIDITY = 48 * time.Hour
  37. )
  38. // Database serves Psiphon API data requests. The Reload function supports hot
  39. // reloading of Psiphon network data while the server is running.
  40. //
  41. // All of the methods on Database are thread-safe, but callers must not mutate
  42. // any returned data. The struct may be safely shared across goroutines.
  43. type Database struct {
  44. common.ReloadableFile
  45. Sponsors map[string]*Sponsor `json:"sponsors"`
  46. Versions map[string][]ClientVersion `json:"client_versions"`
  47. DefaultSponsorID string `json:"default_sponsor_id"`
  48. DefaultAlertActionURLs map[string][]string `json:"default_alert_action_urls"`
  49. ValidServerEntryTags map[string]bool `json:"valid_server_entry_tags"`
  50. DiscoveryServers []*DiscoveryServer `json:"discovery_servers"`
  51. fileModTime time.Time
  52. }
  53. type DiscoveryServer struct {
  54. DiscoveryDateRange []time.Time `json:"discovery_date_range"`
  55. EncodedServerEntry string `json:"encoded_server_entry"`
  56. IPAddress string `json:"-"`
  57. }
  58. // consistent.Member implementation.
  59. // TODO: move to discovery package. Requires bridging to a new type.
  60. func (s *DiscoveryServer) String() string {
  61. // Other options:
  62. // - Tag
  63. // - EncodedServerEntry
  64. // - ...
  65. return s.IPAddress
  66. }
  67. type Sponsor struct {
  68. ID string `json:"id"`
  69. HomePages map[string][]HomePage `json:"home_pages"`
  70. MobileHomePages map[string][]HomePage `json:"mobile_home_pages"`
  71. AlertActionURLs map[string][]string `json:"alert_action_urls"`
  72. HttpsRequestRegexes []HttpsRequestRegex `json:"https_request_regexes"`
  73. domainBytesChecksum []byte `json:"-"`
  74. }
  75. type ClientVersion struct {
  76. Version string `json:"version"`
  77. }
  78. type HomePage struct {
  79. Region string `json:"region"`
  80. URL string `json:"url"`
  81. }
  82. type HttpsRequestRegex struct {
  83. Regex string `json:"regex"`
  84. Replace string `json:"replace"`
  85. }
  86. // NewDatabase initializes a Database, calling Reload on the specified
  87. // filename.
  88. func NewDatabase(filename string) (*Database, error) {
  89. database := &Database{}
  90. database.ReloadableFile = common.NewReloadableFile(
  91. filename,
  92. true,
  93. func(fileContent []byte, fileModTime time.Time) error {
  94. var newDatabase *Database
  95. err := json.Unmarshal(fileContent, &newDatabase)
  96. if err != nil {
  97. return errors.Trace(err)
  98. }
  99. // Note: an unmarshal directly into &database would fail
  100. // to reset to zero value fields not present in the JSON.
  101. database.Sponsors = newDatabase.Sponsors
  102. database.Versions = newDatabase.Versions
  103. database.DefaultSponsorID = newDatabase.DefaultSponsorID
  104. database.DefaultAlertActionURLs = newDatabase.DefaultAlertActionURLs
  105. database.ValidServerEntryTags = newDatabase.ValidServerEntryTags
  106. database.DiscoveryServers = newDatabase.DiscoveryServers
  107. database.fileModTime = fileModTime
  108. for _, sponsor := range database.Sponsors {
  109. value, err := json.Marshal(sponsor.HttpsRequestRegexes)
  110. if err != nil {
  111. return errors.Trace(err)
  112. }
  113. // MD5 hash is used solely as a data checksum and not for any
  114. // security purpose.
  115. checksum := md5.Sum(value)
  116. sponsor.domainBytesChecksum = checksum[:]
  117. }
  118. // Decode each encoded server entry for its IP address, which is used in
  119. // the consistent.Member implementation in the discovery package.
  120. //
  121. // Also ensure that no servers share the same IP address, which is
  122. // a requirement of consistent hashing discovery; otherwise it will
  123. // panic in the underlying Psiphon-Labs/consistent package.
  124. serverIPToDiagnosticID := make(map[string]string)
  125. for i, server := range database.DiscoveryServers {
  126. serverEntry, err := protocol.DecodeServerEntry(server.EncodedServerEntry, "", "")
  127. if err != nil {
  128. return errors.Trace(err)
  129. }
  130. if serverEntry.IpAddress == "" {
  131. return errors.Tracef("unexpected empty IP address in server entry for %s ", serverEntry.GetDiagnosticID())
  132. }
  133. if diagnosticID, ok := serverIPToDiagnosticID[serverEntry.IpAddress]; ok {
  134. return errors.Tracef("unexpected %s and %s shared the same IP address", diagnosticID, serverEntry.GetDiagnosticID())
  135. } else {
  136. serverIPToDiagnosticID[serverEntry.IpAddress] = serverEntry.GetDiagnosticID()
  137. }
  138. database.DiscoveryServers[i].IPAddress = serverEntry.IpAddress
  139. }
  140. return nil
  141. })
  142. _, err := database.Reload()
  143. if err != nil {
  144. return nil, errors.Trace(err)
  145. }
  146. return database, nil
  147. }
  148. // GetRandomizedHomepages returns a randomly ordered list of home pages
  149. // for the specified sponsor, region, and platform.
  150. func (db *Database) GetRandomizedHomepages(
  151. sponsorID, clientRegion, clientASN, deviceRegion string, isMobilePlatform bool) []string {
  152. homepages := db.GetHomepages(sponsorID, clientRegion, clientASN, deviceRegion, isMobilePlatform)
  153. if len(homepages) > 1 {
  154. shuffledHomepages := make([]string, len(homepages))
  155. perm := rand.Perm(len(homepages))
  156. for i, v := range perm {
  157. shuffledHomepages[v] = homepages[i]
  158. }
  159. return shuffledHomepages
  160. }
  161. return homepages
  162. }
  163. // GetHomepages returns a list of home pages for the specified sponsor,
  164. // region, and platform.
  165. func (db *Database) GetHomepages(
  166. sponsorID, clientRegion, clientASN, deviceRegion string, isMobilePlatform bool) []string {
  167. db.ReloadableFile.RLock()
  168. defer db.ReloadableFile.RUnlock()
  169. sponsorHomePages := make([]string, 0)
  170. // Sponsor id does not exist: fail gracefully
  171. sponsor, ok := db.Sponsors[sponsorID]
  172. if !ok {
  173. sponsor, ok = db.Sponsors[db.DefaultSponsorID]
  174. if !ok {
  175. return sponsorHomePages
  176. }
  177. }
  178. if sponsor == nil {
  179. return sponsorHomePages
  180. }
  181. homePages := sponsor.HomePages
  182. if isMobilePlatform {
  183. if len(sponsor.MobileHomePages) > 0 {
  184. homePages = sponsor.MobileHomePages
  185. }
  186. }
  187. // Case: lookup succeeded and corresponding homepages found for region
  188. homePagesByRegion, ok := homePages[clientRegion]
  189. if ok {
  190. for _, homePage := range homePagesByRegion {
  191. sponsorHomePages = append(
  192. sponsorHomePages, homepageQueryParameterSubstitution(
  193. homePage.URL, clientRegion, clientASN, deviceRegion))
  194. }
  195. }
  196. // Case: lookup failed or no corresponding homepages found for region --> use default
  197. if len(sponsorHomePages) == 0 {
  198. defaultHomePages, ok := homePages["None"]
  199. if ok {
  200. for _, homePage := range defaultHomePages {
  201. // client_region query parameter substitution
  202. sponsorHomePages = append(
  203. sponsorHomePages, homepageQueryParameterSubstitution(
  204. homePage.URL, clientRegion, clientASN, deviceRegion))
  205. }
  206. }
  207. }
  208. return sponsorHomePages
  209. }
  210. func homepageQueryParameterSubstitution(
  211. url, clientRegion, clientASN, deviceRegion string) string {
  212. url = strings.Replace(url, "client_region=XX", "client_region="+clientRegion, 1)
  213. url = strings.Replace(url, "client_asn=XX", "client_asn="+clientASN, 1)
  214. url = strings.Replace(url, "device_region=XX", "device_region="+deviceRegion, 1)
  215. return url
  216. }
  217. // GetAlertActionURLs returns a list of alert action URLs for the specified
  218. // alert reason and sponsor.
  219. func (db *Database) GetAlertActionURLs(
  220. alertReason, sponsorID, clientRegion, clientASN, deviceRegion string) []string {
  221. db.ReloadableFile.RLock()
  222. defer db.ReloadableFile.RUnlock()
  223. // Prefer URLs from the Sponsor.AlertActionURLs. When there are no sponsor
  224. // URLs, then select from Database.DefaultAlertActionURLs.
  225. actionURLs := []string{}
  226. sponsor := db.Sponsors[sponsorID]
  227. if sponsor != nil {
  228. for _, URL := range sponsor.AlertActionURLs[alertReason] {
  229. actionURLs = append(
  230. actionURLs, homepageQueryParameterSubstitution(
  231. URL, clientRegion, clientASN, deviceRegion))
  232. }
  233. }
  234. if len(actionURLs) == 0 {
  235. for _, URL := range db.DefaultAlertActionURLs[alertReason] {
  236. actionURLs = append(
  237. actionURLs, homepageQueryParameterSubstitution(
  238. URL, clientRegion, clientASN, deviceRegion))
  239. }
  240. }
  241. return actionURLs
  242. }
  243. // GetUpgradeClientVersion returns a new client version when an upgrade is
  244. // indicated for the specified client current version. The result is "" when
  245. // no upgrade is available. Caller should normalize clientPlatform.
  246. func (db *Database) GetUpgradeClientVersion(clientVersion, clientPlatform string) string {
  247. db.ReloadableFile.RLock()
  248. defer db.ReloadableFile.RUnlock()
  249. // Check lastest version number against client version number
  250. clientVersions, ok := db.Versions[clientPlatform]
  251. if !ok {
  252. return ""
  253. }
  254. if len(clientVersions) == 0 {
  255. return ""
  256. }
  257. // NOTE: Assumes versions list is in ascending version order
  258. lastVersion := clientVersions[len(clientVersions)-1].Version
  259. lastVersionInt, err := strconv.Atoi(lastVersion)
  260. if err != nil {
  261. return ""
  262. }
  263. clientVersionInt, err := strconv.Atoi(clientVersion)
  264. if err != nil {
  265. return ""
  266. }
  267. // Return latest version if upgrade needed
  268. if lastVersionInt > clientVersionInt {
  269. return lastVersion
  270. }
  271. return ""
  272. }
  273. // GetHttpsRequestRegexes returns bytes transferred stats regexes and the
  274. // associated checksum for the specified sponsor. The checksum may be nil.
  275. func (db *Database) GetHttpsRequestRegexes(sponsorID string) ([]map[string]string, []byte) {
  276. db.ReloadableFile.RLock()
  277. defer db.ReloadableFile.RUnlock()
  278. regexes := make([]map[string]string, 0)
  279. sponsor, ok := db.Sponsors[sponsorID]
  280. if !ok {
  281. sponsor = db.Sponsors[db.DefaultSponsorID]
  282. }
  283. if sponsor == nil {
  284. return regexes, nil
  285. }
  286. // If neither sponsorID or DefaultSponsorID were found, sponsor will be the
  287. // zero value of the map, an empty Sponsor struct.
  288. for _, sponsorRegex := range sponsor.HttpsRequestRegexes {
  289. regex := make(map[string]string)
  290. regex["replace"] = sponsorRegex.Replace
  291. regex["regex"] = sponsorRegex.Regex
  292. regexes = append(regexes, regex)
  293. }
  294. return regexes, sponsor.domainBytesChecksum
  295. }
  296. // GetDomainBytesChecksum returns the bytes transferred stats regexes
  297. // checksum for the specified sponsor. The checksum may be nil.
  298. func (db *Database) GetDomainBytesChecksum(sponsorID string) []byte {
  299. db.ReloadableFile.RLock()
  300. defer db.ReloadableFile.RUnlock()
  301. sponsor, ok := db.Sponsors[sponsorID]
  302. if !ok {
  303. sponsor = db.Sponsors[db.DefaultSponsorID]
  304. }
  305. if sponsor == nil {
  306. return nil
  307. }
  308. return sponsor.domainBytesChecksum
  309. }
  310. // IsValidServerEntryTag checks if the specified server entry tag is valid.
  311. func (db *Database) IsValidServerEntryTag(serverEntryTag string) bool {
  312. db.ReloadableFile.RLock()
  313. defer db.ReloadableFile.RUnlock()
  314. // Default to "valid" if the valid list is unexpectedly empty or stale. This
  315. // helps prevent premature client-side server-entry pruning when there is an
  316. // issue with updating the database.
  317. if len(db.ValidServerEntryTags) == 0 ||
  318. db.fileModTime.Add(MAX_DATABASE_AGE_FOR_SERVER_ENTRY_VALIDITY).Before(time.Now()) {
  319. return true
  320. }
  321. // The tag must be in the map and have the value "true".
  322. return db.ValidServerEntryTags[serverEntryTag]
  323. }
  324. func (db *Database) GetDiscoveryServers() []*DiscoveryServer {
  325. db.ReloadableFile.RLock()
  326. defer db.ReloadableFile.RUnlock()
  327. return db.DiscoveryServers
  328. }