1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
// Package serverlist reads Mozilla's Remote Settings vpn-serverlist
// collection and applies the record filters Firefox evaluates before
// choosing a proxy location.
package serverlist
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/armanlife/firefox-vpn-client/core/internal/controlplane"
"github.com/armanlife/firefox-vpn-client/core/internal/securefile"
)
const remoteSettingsURL = "https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/vpn-serverlist/records"
// RecommendedCountryCode is the reserved Remote Settings country code of the
// recommended (anycast) location. Firefox excludes it from the country list
// and uses it as the default location.
const RecommendedCountryCode = "REC"
// DefaultServerPort is the port Firefox assumes when a server record omits it.
const DefaultServerPort = 443
type Protocol struct {
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
Scheme string `json:"scheme,omitempty"`
TemplateString string `json:"templateString,omitempty"`
}
type Server struct {
Hostname string `json:"hostname"`
Port int `json:"port"`
Quarantined bool `json:"quarantined"`
Protocols []Protocol `json:"protocols"`
}
type City struct {
Name string `json:"name"`
Code string `json:"code"`
Servers []Server `json:"servers"`
}
type Country struct {
Name string `json:"name"`
Code string `json:"code"`
Cities []City `json:"cities"`
// Locked marks a location that Firefox offers only under
// preconditions (e.g. an upgraded account). The program offers every
// location regardless: Mozilla's servers accept an ordinary account's
// pass on locked ones too. It is shown only in the server-list dump.
Locked bool `json:"locked,omitempty"`
// FilterExpression is the Remote Settings JEXL filter that decides
// whether this record applies to a client (Firefox version, region).
FilterExpression string `json:"filter_expression,omitempty"`
}
type remoteSettingsResponse struct {
Data []json.RawMessage `json:"data"`
}
func fetchServerList(ctx context.Context) ([]Country, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, remoteSettingsURL, nil)
if err != nil {
return nil, err
}
controlplane.ApplyFirefoxHeaders(req)
resp, err := controlplane.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching server list: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server list returned HTTP %d: %s", resp.StatusCode, controlplane.ReadErrorBody(resp.Body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading server list response: %w", err)
}
var rsResp remoteSettingsResponse
if err := json.Unmarshal(body, &rsResp); err != nil {
return nil, fmt.Errorf("parsing server list envelope: %w", err)
}
var countries []Country
for _, raw := range rsResp.Data {
var c Country
if err := json.Unmarshal(raw, &c); err != nil {
// If it doesn't parse as a Country, skip it
continue
}
if c.Code != "" && len(c.Cities) > 0 {
countries = append(countries, c)
}
}
return countries, nil
}
func printServerList(countries []Country) {
for _, country := range countries {
flags := ""
if country.Code == RecommendedCountryCode {
flags += " [RECOMMENDED]"
}
if country.Locked {
flags += " [locked in Firefox]"
}
fmt.Printf("\n=== %s (%s)%s ===\n", country.Name, country.Code, flags)
for _, city := range country.Cities {
fmt.Printf(" City: %s (%s)\n", city.Name, city.Code)
for _, srv := range city.Servers {
port := srv.Port
if port <= 0 {
port = DefaultServerPort
}
if srv.Quarantined {
fmt.Printf(" [QUARANTINED] %s:%d\n", srv.Hostname, port)
continue
}
fmt.Printf(" Server: %s:%d\n", srv.Hostname, port)
for _, proto := range srv.Protocols {
switch proto.Name {
case "connect":
scheme := proto.Scheme
if scheme == "" {
scheme = "https"
}
fmt.Printf(" Protocol: CONNECT (%s) -> %s:%d\n", scheme, proto.Host, proto.Port)
case "masque":
fmt.Printf(" Protocol: MASQUE -> %s:%d (template: %s)\n", proto.Host, proto.Port, proto.TemplateString)
default:
fmt.Printf(" Protocol: %s -> %s:%d\n", proto.Name, proto.Host, proto.Port)
}
}
if len(srv.Protocols) == 0 {
fmt.Printf(" Protocol: CONNECT (https) -> %s:%d (default)\n", srv.Hostname, port)
}
}
}
}
}
func Fetch(ctx context.Context) ([]Country, error) {
return fetchServerList(ctx)
}
// cacheFile is the saved list: the records as fetched (before Filter, which
// runs again for the Firefox version of the day) and when.
type cacheFile struct {
SavedAt time.Time `json:"saved_at"`
Countries []Country `json:"countries"`
}
// SaveCache keeps a fetched list in path. The list changes rarely; the
// copy lets the program show the locations at once and connect when the
// next fetch fails (Mozilla's settings server unreachable).
func SaveCache(path string, countries []Country) error {
data, err := json.Marshal(cacheFile{SavedAt: time.Now().UTC(), Countries: countries})
if err != nil {
return err
}
return securefile.WritePrivate(path, data)
}
// LoadCache reads the list SaveCache kept, and when it was saved.
func LoadCache(path string) ([]Country, time.Time, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, time.Time{}, err
}
var file cacheFile
if err := json.Unmarshal(data, &file); err != nil {
return nil, time.Time{}, fmt.Errorf("reading %s: %w", path, err)
}
if len(file.Countries) == 0 {
return nil, time.Time{}, fmt.Errorf("%s holds no locations", path)
}
return file.Countries, file.SavedAt, nil
}
func Print(countries []Country) {
printServerList(countries)
}