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
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
|
// Package api0 implements the original master server API.
//
// External differences:
// - Proper HTTP response codes are used (this won't break anything since existing code doesn't check them).
// - Caching headers are supported and used where appropriate.
// - Pdiff stuff has been removed (this was never fully implemented to begin with; see docs/PDATA.md).
// - Error messages have been improved. Enum values remain the same for compatibility.
// - Some rate limits (no longer necessary due to increased performance and better caching) have been removed.
// - More HTTP methods and features are supported (e.g., HEAD, OPTIONS, Content-Encoding).
// - Website split into a separate handler (set Handler.NotFound to http.HandlerFunc(web.ServeHTTP) for identical behaviour).
// - /accounts/write_persistence returns a error message for easier debugging.
// - Alive/dead servers can be replaced by a new successful registration from the same ip/port. This eliminates the main cause of the duplicate server error requiring retries, and doesn't add much risk since you need to custom fuckery to start another server when you're already listening on the port.
package api0
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"net/netip"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/klauspost/compress/gzip"
"github.com/pg9182/ip2x"
"github.com/r2northstar/atlas/pkg/eax"
"github.com/r2northstar/atlas/pkg/metricsx"
"github.com/r2northstar/atlas/pkg/nspkt"
"github.com/r2northstar/atlas/pkg/origin"
"github.com/rs/zerolog/hlog"
"golang.org/x/mod/semver"
)
// Handler serves requests for the original master server API.
type Handler struct {
// ServerList stores registered servers.
ServerList *ServerList
// AccountStorage stores accounts. It must be non-nil.
AccountStorage AccountStorage
// PdataStorage stores player data. It must be non-nil.
PdataStorage PdataStorage
// NSPkt handles connectionless packets. It must be non-nil.
NSPkt *nspkt.Listener
// UsernameSource configures the source to use for usernames.
UsernameSource UsernameSource
// OriginAuthMgr, if provided, manages Origin nucleus tokens for checking
// usernames.
OriginAuthMgr *origin.AuthMgr
// EAXClient makes requests to the EAX API.
EAXClient *eax.Client
// CleanBadWords is used to filter bad words from server names and
// descriptions. If not provided, words will not be filtered.
CleanBadWords func(s string) string
// MainMenuPromos gets the main menu promos to return for a request.
MainMenuPromos func(*http.Request) MainMenuPromos
// NotFound handles requests not handled by this Handler.
NotFound http.Handler
// MaxServers limits the number of registered servers. If -1, no limit is
// applied. If 0, a reasonable default is used.
MaxServers int
// MaxServersPerIP limits the number of registered servers per IP. If -1, no
// limit is applied. If 0, a reasonable default is used.
MaxServersPerIP int
// InsecureDevNoCheckPlayerAuth is an option you shouldn't use since it
// makes the server trust that clients are who they say they are. Blame
// @BobTheBob9 for this option even existing in the first place.
InsecureDevNoCheckPlayerAuth bool
// MinimumLauncherVersion* restricts authentication and server registration
// to clients with at least this version, which must be valid semver. +dev
// versions are always allowed.
MinimumLauncherVersionClient, MinimumLauncherVersionServer string
// TokenExpiryTime controls the expiry of player masterserver auth tokens.
// If zero, a reasonable a default is used.
TokenExpiryTime time.Duration
// AllowGameServerIPv6 controls whether to allow game servers to use IPv6.
AllowGameServerIPv6 bool
// LookupIP looks up an IP2Location record for an IP. If not provided,
// server regions and geo metrics are disabled. If it doesn't include latlon
// info, geo metrics will be disabled too.
LookupIP func(netip.Addr) (ip2x.Record, error)
// GetRegion gets the region name from an IP2Location record. If not
// provided, server regions are disabled.
//
// Errors should only be returned for unexpected situations, and a
// best-effort region should still be returned if applicable (it will still
// be used on error if non-empty).
//
// Note that it is valid to return an
// empty region and no error if no region is to be assigned.
GetRegion func(netip.Addr, ip2x.Record) (string, error)
metricsInit sync.Once
metricsObj apiMetrics
connect sync.Map // [connectStateKey]*connectState
}
type connectStateKey struct {
ServerID string
Token string
}
type connectState struct {
res chan<- string // buffer 1
pdata []byte
gotPdata atomic.Bool
}
// ServeHTTP routes requests to Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var notPanicked bool // this lets us catch panics without swallowing them
defer func() {
if !notPanicked {
h.m().request_panics_total.Inc()
}
}()
w.Header().Set("Server", "Atlas")
switch r.URL.Path {
case "/client/mainmenupromos":
h.handleMainMenuPromos(w, r)
case "/client/origin_auth":
h.handleClientOriginAuth(w, r)
case "/client/auth_with_server":
h.handleClientAuthWithServer(w, r)
case "/client/auth_with_self":
h.handleClientAuthWithSelf(w, r)
case "/client/servers":
h.handleClientServers(w, r)
case "/server/add_server", "/server/update_values", "/server/heartbeat":
h.handleServerUpsert(w, r)
case "/server/remove_server":
h.handleServerRemove(w, r)
case "/server/connect":
h.handleServerConnect(w, r)
case "/accounts/write_persistence":
h.handleAccountsWritePersistence(w, r)
case "/accounts/get_username":
h.handleAccountsGetUsername(w, r)
case "/accounts/lookup_uid":
h.handleAccountsLookupUID(w, r)
case "/player/pdata", "/player/info", "/player/stats", "/player/loadout":
h.handlePlayer(w, r)
default:
if h.NotFound == nil {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
} else {
notPanicked = true
h.NotFound.ServeHTTP(w, r)
}
}
notPanicked = true
}
// CheckLauncherVersion checks if the r was made by NorthstarLauncher and if it
// is at least MinimumLauncherVersion.
func (h *Handler) CheckLauncherVersion(r *http.Request, client bool) bool {
rver, _, _ := strings.Cut(r.Header.Get("User-Agent"), " ")
if x := strings.TrimPrefix(rver, "R2Northstar/"); rver != x {
if len(x) > 0 && x[0] != 'v' {
rver = "v" + x
} else {
rver = x
}
} else {
h.m().versiongate_checks_total.reject_notns.Inc()
return false // deny: not R2Northstar
}
var mver string
if client {
mver = h.MinimumLauncherVersionClient
} else {
mver = h.MinimumLauncherVersionServer
}
if mver != "" {
if mver[0] != 'v' {
mver = "v" + mver
}
} else {
h.m().versiongate_checks_total.success_ok.Inc()
return true // allow: no minimum version
}
if !semver.IsValid(mver) {
hlog.FromRequest(r).Warn().Msgf("not checking invalid minimum version %q", mver)
h.m().versiongate_checks_total.success_ok.Inc()
return true // allow: invalid minimum version
}
if strings.HasSuffix(rver, "+dev") {
h.m().versiongate_checks_total.success_dev.Inc()
return true // allow: dev versions
}
if !semver.IsValid(rver) {
h.m().versiongate_checks_total.reject_invalid.Inc()
return false // deny: invalid version
}
if semver.Compare(rver, mver) < 0 {
h.m().versiongate_checks_total.reject_old.Inc()
return false // deny: too old
}
h.m().versiongate_checks_total.success_ok.Inc()
return true
}
// ExtractLauncherVersion extracts the launcher version from r, returning an
// empty string if it's missing or invalid.
func (h *Handler) ExtractLauncherVersion(r *http.Request) string {
rver, _, _ := strings.Cut(r.Header.Get("User-Agent"), " ")
if x := strings.TrimPrefix(rver, "R2Northstar/"); rver != x {
if len(x) > 0 && x[0] != 'v' {
rver = "v" + x
} else {
rver = x
}
} else {
rver = ""
}
if rver != "" && semver.IsValid(rver) {
return rver[1:]
}
return ""
}
// geoCounter2 increments a [metricsx.GeoCounter2] for the location of r.
func (h *Handler) geoCounter2(r *http.Request, ctr *metricsx.GeoCounter2) {
if h.LookupIP == nil {
return
}
a, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return
}
c, err := h.LookupIP(a.Addr())
if err != nil {
return
}
lat, _ := c.GetFloat32(ip2x.Latitude)
lon, _ := c.GetFloat32(ip2x.Longitude)
if lat != 0 && lon != 0 {
ctr.Inc(float64(lat), float64(lon))
} else {
ctr.IncUnknown()
}
}
// respFail writes a {success:false,error:ErrorObj} response with the provided
// response status.
func respFail(w http.ResponseWriter, r *http.Request, status int, obj ErrorObj) {
if rid, ok := hlog.IDFromRequest(r); ok {
respJSON(w, r, status, map[string]any{
"success": false,
"error": obj,
"request_id": rid.String(),
})
} else {
respJSON(w, r, status, map[string]any{
"success": false,
"error": obj,
})
}
}
// respJSON writes the JSON encoding of obj with the provided response status.
func respJSON(w http.ResponseWriter, r *http.Request, status int, obj any) {
if r.Method == http.MethodHead {
w.WriteHeader(status)
return
}
buf, err := json.Marshal(obj)
if err != nil {
panic(err)
}
hlog.FromRequest(r).Trace().Msgf("json api response %.2048s", string(buf))
buf = append(buf, '\n')
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(buf)))
w.WriteHeader(status)
w.Write(buf)
}
// respMaybeCompress writes buf with the provided response status, compressing
// it with gzip if the client supports it and the result is smaller.
func respMaybeCompress(w http.ResponseWriter, r *http.Request, status int, buf []byte) {
for _, e := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
if t, _, _ := strings.Cut(e, ";"); strings.TrimSpace(t) == "gzip" {
var cbuf bytes.Buffer
gw := gzip.NewWriter(&cbuf)
if _, err := gw.Write(buf); err != nil {
break
}
if err := gw.Close(); err != nil {
break
}
if cbuf.Len() < int(float64(len(buf))*0.8) {
buf = cbuf.Bytes()
w.Header().Set("Content-Encoding", "gzip")
w.Header().Del("ETag") // to avoid breaking caching proxies since ETag must be unique if Content-Encoding is different
}
break
}
}
w.Header().Set("Content-Length", strconv.Itoa(len(buf)))
w.WriteHeader(status)
if r.Method != http.MethodHead {
w.Write(buf)
}
}
// cryptoRandHex gets a string of random hex digits with length n.
func cryptoRandHex(n int) (string, error) {
b := make([]byte, (n+1)/2) // round up
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b)[:n], nil
}
// marshalJSONBytesAsArray marshals b as an array of numbers (rather than the
// default of base64).
func marshalJSONBytesAsArray(b []byte) json.RawMessage {
var e bytes.Buffer
e.Grow(2 + len(b)*3)
e.WriteByte('[')
for i, c := range b {
if i != 0 {
e.WriteByte(',')
}
e.WriteString(strconv.FormatUint(uint64(c), 10))
}
e.WriteByte(']')
return json.RawMessage(e.Bytes())
}
|