Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 46 additions & 74 deletions cmd/playerpath/internal/handler/foward.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,83 +26,33 @@ func (h *Handler) HandleDynamicForward(c echo.Context) error {
p := struct {
PID int `query:"pid"`
}{}
if err2 := c.Bind(&p); err2 != nil {
if err := c.Bind(&p); err != nil {
return c.String(http.StatusOK, asp.NewSyntaxErrorResponse().Serialize())
}

pv, err2 := h.determineProvider(c.Request().Context(), p.PID)
if err2 != nil {
return echo.NewHTTPError(http.StatusInternalServerError).SetInternal(err2)
}

log.Debug().
Stringer(trace.LogProvider, pv).
Str("URI", c.Request().RequestURI).
Msg("Forwarding request")

res, err2 := h.forwardRequest(c.Request().Context(), pv, c.Request(), c.RealIP())
if err2 != nil {
return echo.NewHTTPError(http.StatusInternalServerError).SetInternal(err2)
}

// Copy all upstream header to ensure response can be handled correctly downstream
for key, values := range res.Header {
for _, value := range values {
c.Response().Header().Add(key, value)
}
pv, err := h.determineProvider(c.Request().Context(), p.PID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError).SetInternal(err)
}

return c.String(res.StatusCode, string(res.Body))
return h.handleForward(c, pv)
}

func (h *Handler) HandleStaticForward(c echo.Context) error {
res, err2 := h.forwardRequest(c.Request().Context(), h.provider, c.Request(), c.RealIP())
if err2 != nil {
return echo.NewHTTPError(http.StatusInternalServerError).SetInternal(err2)
}

// Copy all upstream header to ensure response can be handled correctly downstream
for key, values := range res.Header {
for _, value := range values {
c.Response().Header().Add(key, value)
}
}

return c.String(res.StatusCode, string(res.Body))
return h.handleForward(c, h.provider)
}

func (h *Handler) determineProvider(ctx context.Context, pid int) (provider.Provider, error) {
p, err := h.repository.FindByPID(ctx, pid)
if err != nil {
if errors.Is(err, player.ErrPlayerNotFound) {
log.Warn().
Int(trace.LogPlayerPID, pid).
Msg("Player not found, falling back to default provider")
return h.provider, nil
}
if errors.Is(err, player.ErrMultiplePlayersFound) {
log.Warn().
Int(trace.LogPlayerPID, pid).
Msg("Found multiple players, falling back to default provider")
return h.provider, nil
}
return 0, err
}

return p.Provider, nil
}

func (h *Handler) forwardRequest(ctx context.Context, pv provider.Provider, incoming *http.Request, realIP string) (*UpstreamResponse, error) {
func (h *Handler) handleForward(c echo.Context, pv provider.Provider) error {
u, err := url.Parse(pv.BaseURL())
if err != nil {
return nil, err
return err
}
u = u.JoinPath(incoming.URL.Path)
u.RawQuery = incoming.URL.RawQuery
u = u.JoinPath(c.Request().URL.Path)
u.RawQuery = c.Request().URL.RawQuery

req, err := http.NewRequestWithContext(ctx, incoming.Method, u.String(), incoming.Body)
req, err := http.NewRequestWithContext(c.Request().Context(), c.Request().Method, u.String(), c.Request().Body)
if err != nil {
return nil, err
return err
}

// Use GameSpy host value only if provider requires it
Expand All @@ -119,29 +69,51 @@ func (h *Handler) forwardRequest(ctx context.Context, pv provider.Provider, inco
}

// Copy downstream user agent to ensure compatibility
req.Header.Set("User-Agent", incoming.Header.Get("User-Agent"))
req.Header.Set("X-Forwarded-Proto", incoming.Proto)
req.Header.Set("X-Forwarded-For", realIP)
req.Header.Set("X-Real-IP", realIP)
req.Header.Set("User-Agent", c.Request().Header.Get("User-Agent"))
req.Header.Set("X-Forwarded-Proto", c.Request().Proto)
req.Header.Set("X-Forwarded-For", c.RealIP())
req.Header.Set("X-Real-IP", c.RealIP())

log.Debug().
Stringer(trace.LogProvider, pv).
Str("URI", c.Request().RequestURI).
Msg("Forwarding request")

res, err := h.client.Do(req)
if err != nil {
return nil, err
return err
}

bytes, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
return err
}

err = res.Body.Close()
if err != nil {
return nil, err
return err
}

return &UpstreamResponse{
StatusCode: res.StatusCode,
Header: res.Header,
Body: bytes,
}, nil
return c.String(res.StatusCode, string(bytes))
}

func (h *Handler) determineProvider(ctx context.Context, pid int) (provider.Provider, error) {
p, err := h.repository.FindByPID(ctx, pid)
if err != nil {
if errors.Is(err, player.ErrPlayerNotFound) {
log.Warn().
Int(trace.LogPlayerPID, pid).
Msg("Player not found, falling back to default provider")
return h.provider, nil
}
if errors.Is(err, player.ErrMultiplePlayersFound) {
log.Warn().
Int(trace.LogPlayerPID, pid).
Msg("Found multiple players, falling back to default provider")
return h.provider, nil
}
return 0, err
}

return p.Provider, nil
}
100 changes: 100 additions & 0 deletions cmd/playerpath/internal/handler/verify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package handler

import (
"errors"
"fmt"
"net/http"
"strconv"

"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"

"github.com/cetteup/playerpath/cmd/playerpath/internal/asp"
"github.com/cetteup/playerpath/internal/domain/player"
"github.com/cetteup/playerpath/internal/domain/provider"
"github.com/cetteup/playerpath/internal/trace"
)

const (
dummyPID = 0
)

func (h *Handler) HandleGetVerifyPlayer(c echo.Context) error {
params := struct {
PID int `query:"pid"`
Nick string `query:"SoldierNick"`
}{}
if err := c.Bind(&params); err != nil {
return c.String(http.StatusOK, asp.NewSyntaxErrorResponse().Serialize())
}

p, err := h.repository.FindByPID(c.Request().Context(), params.PID)
if err != nil {
if errors.Is(err, player.ErrPlayerNotFound) {
log.Warn().
Int(trace.LogPlayerPID, params.PID).
Msg("Player not found, treating as unverified")
// Treating not found as unverified here to ensure you cannot bypass verification simply by using
// an unknown PID when using a non-verifying default provider such as BF2Hub
return c.String(http.StatusOK, buildResponse(
addInvalidPrefix(params.Nick),
params.Nick,
dummyPID,
params.PID,
).Serialize())
}
if errors.Is(err, player.ErrMultiplePlayersFound) {
log.Warn().
Int(trace.LogPlayerPID, params.PID).
Msg("Found multiple players, using default provider to verify player")
// Using the default provider here isn't great either, but there is no clean solution to this conflict
// If we treat the conflict as a verification failure, neither of the conflicting PID players will pass
// By leaving the verification up to the default provider (which should be the provider used by the server),
// the provider can (potentially) resolve the conflict based on the `auth` parameter
return h.handleForward(c, h.provider)
}
return echo.NewHTTPError(http.StatusInternalServerError).SetInternal(fmt.Errorf("failed to find player: %w", err))
}

switch p.Provider {
case provider.ProviderBF2Hub, provider.ProviderB2BF2:
// Neither BF2Hub nor B2BF2 currently offer a (compatible) VerifyPlayer.aspx endpoint
// All we can do is validate that an account with the given pid and name exists
return c.String(http.StatusOK, buildResponse(p.Nick, params.Nick, p.PID, params.PID).Serialize())
default:
return h.handleForward(c, p.Provider)
}
}

// buildResponse Signature analog to default onPlayerNameValidated handler
func buildResponse(realNick, oldNick string, realPID, oldPID int) *asp.Response {
resp := asp.NewOKResponse().
WriteHeader("pid", "nick", "spid", "asof").
WriteData(strconv.Itoa(realPID), realNick, strconv.Itoa(oldPID), asp.Timestamp()).
WriteHeader("result")

if realNick == oldNick && realPID == oldPID {
resp.WriteData("Ok")
} else if realNick != oldNick && realPID != oldPID {
// We obviously cannot validate the auth param, but neither value matching would indicate
// that the player was not found and this is the closest to "completely invalid" there is
// (no player can be logged into a profile that does not exist)
resp.WriteData("InvalidAuthProfileID")
} else if realNick != oldNick {
resp.WriteData("InvalidReportedNick")
} else {
// Currently unused as realNick differs from oldNick for any non-ok response
// Primarily here for completeness-sake
resp.WriteData("InvalidReportedProfileID")
}

return resp
}

func addInvalidPrefix(nick string) string {
// `[prefix] nick` usually get cut off after 23 characters in the game's client-server protocols
// While the limit appears to not be applied to values returned by the validation,
// it's probably best to follow that convention/limit
prefixed := "INVALID " + nick
return prefixed[:min(len(prefixed), 23)]
}
2 changes: 2 additions & 0 deletions cmd/playerpath/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ func main() {
asp.GET("/getawardsinfo.aspx", h.HandleDynamicForward)
asp.GET("/getunlocksinfo.aspx", h.HandleDynamicForward)
asp.GET("/getrankinfo.aspx", h.HandleDynamicForward)
// Requests with special/split handling
asp.GET("/VerifyPlayer.aspx", h.HandleGetVerifyPlayer)
// Fallback forward to default provider
asp.Any("/*.aspx", h.HandleStaticForward)

Expand Down