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
|
// 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"
"compress/gzip"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/pg9182/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
// OriginAuthMgr manages Origin nucleus tokens (used for checking
// usernames). If not provided, usernames will not be updated.
OriginAuthMgr *origin.AuthMgr
// 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.
MinimumLauncherVersion 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
}
// ServeHTTP routes requests to Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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_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 "/accounts/write_persistence":
h.handleAccountsWritePersistence(w, r)
case "/accounts/get_username":
h.handleAccountsGetUsername(w, r)
case "/accounts/lookup_uid":
h.handleAccountsLookupUID(w, r)
default:
if strings.HasPrefix(r.URL.Path, "/player/") {
// TODO: rate limit
h.handlePlayer(w, r)
return
}
if h.NotFound == nil {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
} else {
h.NotFound.ServeHTTP(w, r)
}
}
}
// checkLauncherVersion checks if the r was made by NorthstarLauncher and if it
// is at least MinimumLauncherVersion.
func (h *Handler) checkLauncherVersion(r *http.Request) bool {
rver, _, _ := strings.Cut(r.Header.Get("User-Agent"), " ")
if x := strings.TrimPrefix(rver, "R2Northstar/"); rver != x {
if x[0] != 'v' {
rver = "v" + x
} else {
rver = x
}
} else {
return false // deny: not R2Northstar
}
mver := h.MinimumLauncherVersion
if mver != "" {
if mver[0] != 'v' {
mver = "v" + mver
}
} else {
return true // allow: no minimum version
}
if !semver.IsValid(mver) {
hlog.FromRequest(r).Warn().Msgf("not checking invalid minimum version %q", mver)
return true // allow: invalid minimum version
}
if strings.HasSuffix(rver, "+dev") {
return true // allow: dev versions
}
return semver.Compare(rver, mver) >= 0
}
// 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)
}
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())
}
|