| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400 |
- /*
- * Copyright (c) 2016, Psiphon Inc.
- * All rights reserved.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
- // Package psinet implements psinet database services. The psinet database is a
- // JSON-format file containing information about the Psiphon network, including
- // sponsors, home pages, stats regexes, available upgrades, and other servers for
- // discovery.
- package psinet
- import (
- "crypto/md5"
- "encoding/json"
- "math/rand"
- "strconv"
- "strings"
- "time"
- "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
- "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
- "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
- )
- const (
- MAX_DATABASE_AGE_FOR_SERVER_ENTRY_VALIDITY = 48 * time.Hour
- )
- // Database serves Psiphon API data requests. The Reload function supports hot
- // reloading of Psiphon network data while the server is running.
- //
- // All of the methods on Database are thread-safe, but callers must not mutate
- // any returned data. The struct may be safely shared across goroutines.
- type Database struct {
- common.ReloadableFile
- Sponsors map[string]*Sponsor `json:"sponsors"`
- Versions map[string][]ClientVersion `json:"client_versions"`
- DefaultSponsorID string `json:"default_sponsor_id"`
- DefaultAlertActionURLs map[string][]string `json:"default_alert_action_urls"`
- ValidServerEntryTags map[string]bool `json:"valid_server_entry_tags"`
- DiscoveryServers []*DiscoveryServer `json:"discovery_servers"`
- fileModTime time.Time
- }
- type DiscoveryServer struct {
- DiscoveryDateRange []time.Time `json:"discovery_date_range"`
- EncodedServerEntry string `json:"encoded_server_entry"`
- IPAddress string `json:"-"`
- }
- // consistent.Member implementation.
- // TODO: move to discovery package. Requires bridging to a new type.
- func (s *DiscoveryServer) String() string {
- // Other options:
- // - Tag
- // - EncodedServerEntry
- // - ...
- return s.IPAddress
- }
- type Sponsor struct {
- ID string `json:"id"`
- HomePages map[string][]HomePage `json:"home_pages"`
- MobileHomePages map[string][]HomePage `json:"mobile_home_pages"`
- AlertActionURLs map[string][]string `json:"alert_action_urls"`
- HttpsRequestRegexes []HttpsRequestRegex `json:"https_request_regexes"`
- domainBytesChecksum []byte `json:"-"`
- }
- type ClientVersion struct {
- Version string `json:"version"`
- }
- type HomePage struct {
- Region string `json:"region"`
- URL string `json:"url"`
- }
- type HttpsRequestRegex struct {
- Regex string `json:"regex"`
- Replace string `json:"replace"`
- }
- // NewDatabase initializes a Database, calling Reload on the specified
- // filename.
- func NewDatabase(filename string) (*Database, error) {
- database := &Database{}
- database.ReloadableFile = common.NewReloadableFile(
- filename,
- true,
- func(fileContent []byte, fileModTime time.Time) error {
- var newDatabase *Database
- err := json.Unmarshal(fileContent, &newDatabase)
- if err != nil {
- return errors.Trace(err)
- }
- // Note: an unmarshal directly into &database would fail
- // to reset to zero value fields not present in the JSON.
- database.Sponsors = newDatabase.Sponsors
- database.Versions = newDatabase.Versions
- database.DefaultSponsorID = newDatabase.DefaultSponsorID
- database.DefaultAlertActionURLs = newDatabase.DefaultAlertActionURLs
- database.ValidServerEntryTags = newDatabase.ValidServerEntryTags
- database.DiscoveryServers = newDatabase.DiscoveryServers
- database.fileModTime = fileModTime
- for _, sponsor := range database.Sponsors {
- value, err := json.Marshal(sponsor.HttpsRequestRegexes)
- if err != nil {
- return errors.Trace(err)
- }
- // MD5 hash is used solely as a data checksum and not for any
- // security purpose.
- checksum := md5.Sum(value)
- sponsor.domainBytesChecksum = checksum[:]
- }
- // Decode each encoded server entry for its IP address, which is used in
- // the consistent.Member implementation in the discovery package.
- //
- // Also ensure that no servers share the same IP address, which is
- // a requirement of consistent hashing discovery; otherwise it will
- // panic in the underlying Psiphon-Labs/consistent package.
- serverIPToDiagnosticID := make(map[string]string)
- for i, server := range database.DiscoveryServers {
- serverEntry, err := protocol.DecodeServerEntry(server.EncodedServerEntry, "", "")
- if err != nil {
- return errors.Trace(err)
- }
- if serverEntry.IpAddress == "" {
- return errors.Tracef("unexpected empty IP address in server entry for %s ", serverEntry.GetDiagnosticID())
- }
- if diagnosticID, ok := serverIPToDiagnosticID[serverEntry.IpAddress]; ok {
- return errors.Tracef("unexpected %s and %s shared the same IP address", diagnosticID, serverEntry.GetDiagnosticID())
- } else {
- serverIPToDiagnosticID[serverEntry.IpAddress] = serverEntry.GetDiagnosticID()
- }
- database.DiscoveryServers[i].IPAddress = serverEntry.IpAddress
- }
- return nil
- })
- _, err := database.Reload()
- if err != nil {
- return nil, errors.Trace(err)
- }
- return database, nil
- }
- // GetRandomizedHomepages returns a randomly ordered list of home pages
- // for the specified sponsor, region, and platform.
- func (db *Database) GetRandomizedHomepages(
- sponsorID, clientRegion, clientASN, deviceRegion string, isMobilePlatform bool) []string {
- homepages := db.GetHomepages(sponsorID, clientRegion, clientASN, deviceRegion, isMobilePlatform)
- if len(homepages) > 1 {
- shuffledHomepages := make([]string, len(homepages))
- perm := rand.Perm(len(homepages))
- for i, v := range perm {
- shuffledHomepages[v] = homepages[i]
- }
- return shuffledHomepages
- }
- return homepages
- }
- // GetHomepages returns a list of home pages for the specified sponsor,
- // region, and platform.
- func (db *Database) GetHomepages(
- sponsorID, clientRegion, clientASN, deviceRegion string, isMobilePlatform bool) []string {
- db.ReloadableFile.RLock()
- defer db.ReloadableFile.RUnlock()
- sponsorHomePages := make([]string, 0)
- // Sponsor id does not exist: fail gracefully
- sponsor, ok := db.Sponsors[sponsorID]
- if !ok {
- sponsor, ok = db.Sponsors[db.DefaultSponsorID]
- if !ok {
- return sponsorHomePages
- }
- }
- if sponsor == nil {
- return sponsorHomePages
- }
- homePages := sponsor.HomePages
- if isMobilePlatform {
- if len(sponsor.MobileHomePages) > 0 {
- homePages = sponsor.MobileHomePages
- }
- }
- // Case: lookup succeeded and corresponding homepages found for region
- homePagesByRegion, ok := homePages[clientRegion]
- if ok {
- for _, homePage := range homePagesByRegion {
- sponsorHomePages = append(
- sponsorHomePages, homepageQueryParameterSubstitution(
- homePage.URL, clientRegion, clientASN, deviceRegion))
- }
- }
- // Case: lookup failed or no corresponding homepages found for region --> use default
- if len(sponsorHomePages) == 0 {
- defaultHomePages, ok := homePages["None"]
- if ok {
- for _, homePage := range defaultHomePages {
- // client_region query parameter substitution
- sponsorHomePages = append(
- sponsorHomePages, homepageQueryParameterSubstitution(
- homePage.URL, clientRegion, clientASN, deviceRegion))
- }
- }
- }
- return sponsorHomePages
- }
- func homepageQueryParameterSubstitution(
- url, clientRegion, clientASN, deviceRegion string) string {
- url = strings.Replace(url, "client_region=XX", "client_region="+clientRegion, 1)
- url = strings.Replace(url, "client_asn=XX", "client_asn="+clientASN, 1)
- url = strings.Replace(url, "device_region=XX", "device_region="+deviceRegion, 1)
- return url
- }
- // GetAlertActionURLs returns a list of alert action URLs for the specified
- // alert reason and sponsor.
- func (db *Database) GetAlertActionURLs(
- alertReason, sponsorID, clientRegion, clientASN, deviceRegion string) []string {
- db.ReloadableFile.RLock()
- defer db.ReloadableFile.RUnlock()
- // Prefer URLs from the Sponsor.AlertActionURLs. When there are no sponsor
- // URLs, then select from Database.DefaultAlertActionURLs.
- actionURLs := []string{}
- sponsor := db.Sponsors[sponsorID]
- if sponsor != nil {
- for _, URL := range sponsor.AlertActionURLs[alertReason] {
- actionURLs = append(
- actionURLs, homepageQueryParameterSubstitution(
- URL, clientRegion, clientASN, deviceRegion))
- }
- }
- if len(actionURLs) == 0 {
- for _, URL := range db.DefaultAlertActionURLs[alertReason] {
- actionURLs = append(
- actionURLs, homepageQueryParameterSubstitution(
- URL, clientRegion, clientASN, deviceRegion))
- }
- }
- return actionURLs
- }
- // GetUpgradeClientVersion returns a new client version when an upgrade is
- // indicated for the specified client current version. The result is "" when
- // no upgrade is available. Caller should normalize clientPlatform.
- func (db *Database) GetUpgradeClientVersion(clientVersion, clientPlatform string) string {
- db.ReloadableFile.RLock()
- defer db.ReloadableFile.RUnlock()
- // Check lastest version number against client version number
- clientVersions, ok := db.Versions[clientPlatform]
- if !ok {
- return ""
- }
- if len(clientVersions) == 0 {
- return ""
- }
- // NOTE: Assumes versions list is in ascending version order
- lastVersion := clientVersions[len(clientVersions)-1].Version
- lastVersionInt, err := strconv.Atoi(lastVersion)
- if err != nil {
- return ""
- }
- clientVersionInt, err := strconv.Atoi(clientVersion)
- if err != nil {
- return ""
- }
- // Return latest version if upgrade needed
- if lastVersionInt > clientVersionInt {
- return lastVersion
- }
- return ""
- }
- // GetHttpsRequestRegexes returns bytes transferred stats regexes and the
- // associated checksum for the specified sponsor. The checksum may be nil.
- func (db *Database) GetHttpsRequestRegexes(sponsorID string) ([]map[string]string, []byte) {
- db.ReloadableFile.RLock()
- defer db.ReloadableFile.RUnlock()
- regexes := make([]map[string]string, 0)
- sponsor, ok := db.Sponsors[sponsorID]
- if !ok {
- sponsor = db.Sponsors[db.DefaultSponsorID]
- }
- if sponsor == nil {
- return regexes, nil
- }
- // If neither sponsorID or DefaultSponsorID were found, sponsor will be the
- // zero value of the map, an empty Sponsor struct.
- for _, sponsorRegex := range sponsor.HttpsRequestRegexes {
- regex := make(map[string]string)
- regex["replace"] = sponsorRegex.Replace
- regex["regex"] = sponsorRegex.Regex
- regexes = append(regexes, regex)
- }
- return regexes, sponsor.domainBytesChecksum
- }
- // GetDomainBytesChecksum returns the bytes transferred stats regexes
- // checksum for the specified sponsor. The checksum may be nil.
- func (db *Database) GetDomainBytesChecksum(sponsorID string) []byte {
- db.ReloadableFile.RLock()
- defer db.ReloadableFile.RUnlock()
- sponsor, ok := db.Sponsors[sponsorID]
- if !ok {
- sponsor = db.Sponsors[db.DefaultSponsorID]
- }
- if sponsor == nil {
- return nil
- }
- return sponsor.domainBytesChecksum
- }
- // IsValidServerEntryTag checks if the specified server entry tag is valid.
- func (db *Database) IsValidServerEntryTag(serverEntryTag string) bool {
- db.ReloadableFile.RLock()
- defer db.ReloadableFile.RUnlock()
- // Default to "valid" if the valid list is unexpectedly empty or stale. This
- // helps prevent premature client-side server-entry pruning when there is an
- // issue with updating the database.
- if len(db.ValidServerEntryTags) == 0 ||
- db.fileModTime.Add(MAX_DATABASE_AGE_FOR_SERVER_ENTRY_VALIDITY).Before(time.Now()) {
- return true
- }
- // The tag must be in the map and have the value "true".
- return db.ValidServerEntryTags[serverEntryTag]
- }
- func (db *Database) GetDiscoveryServers() []*DiscoveryServer {
- db.ReloadableFile.RLock()
- defer db.ReloadableFile.RUnlock()
- return db.DiscoveryServers
- }
|