// 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. 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 { // 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 // 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 } // 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 "/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()) }