From 49229b740a5c13e2477c12e02815ca7c9cd8824b Mon Sep 17 00:00:00 2001 From: Reese Norris Date: Mon, 27 Jul 2026 13:58:10 -0400 Subject: [PATCH] datafeed: export pilot rating and flight plan via online_users; stop fabricating QNH Extend serviceapi pilot/ATC DTOs; store PilotRating on LoginData at auth; populate online_users from session snapshot. Datafeed drops outer PilotRating/TextATIS, maps info-section to FlightPlan object, zeros military/QNH, and uses ConfigFsdServerIdent for server (fallback OPENFSD). --- internal/server/bootstrap_test.go | 10 ++ internal/server/conn.go | 15 +++ internal/server/http_service.go | 4 + internal/server/security_handlers_test.go | 9 +- internal/server/sweatbox_host.go | 1 + internal/serviceapi/online_users.go | 16 +++ internal/session/session.go | 13 +- internal/web/data.go | 117 +++++++++++++---- internal/web/data_flightplan_test.go | 150 ++++++++++++++++++++++ internal/web/data_test.go | 30 +++++ 10 files changed, 335 insertions(+), 30 deletions(-) create mode 100644 internal/web/data_flightplan_test.go diff --git a/internal/server/bootstrap_test.go b/internal/server/bootstrap_test.go index e621079..0f48c52 100644 --- a/internal/server/bootstrap_test.go +++ b/internal/server/bootstrap_test.go @@ -191,10 +191,14 @@ func TestOnlineUsersSyntheticBadge(t *testing.T) { require.NoError(t, err) human := newSess("HUMAN1", false, NetworkRatingObserver) + human.PilotRating = int(protocol.PilotRatingPPL) + human.FlightPlan.Store("I:B738:450:KLAX:1200:0000:FL350:KJFK:2:0:3:0::rmk:DCT") + human.AssignedBeaconCode.Store("4321") require.NoError(t, reg.Register(human)) synth := newSess("SBX1", false, NetworkRatingObserver) synth.Synthetic = true + synth.PilotRating = 0 require.NoError(t, reg.Register(synth)) e := srv.setupRoutes() @@ -214,9 +218,13 @@ func TestOnlineUsersSyntheticBadge(t *testing.T) { case "HUMAN1": sawHuman = true require.False(t, p.Synthetic, "human pilot must not be synthetic") + require.Equal(t, int(protocol.PilotRatingPPL), p.PilotRating) + require.Contains(t, p.FlightPlan, "KLAX") + require.Equal(t, "4321", p.AssignedBeaconCode) case "SBX1": sawSynth = true require.True(t, p.Synthetic, "sweatbox pilot must be synthetic") + require.Equal(t, 0, p.PilotRating) } } require.True(t, sawHuman && sawSynth, "expected both pilots in snapshot") @@ -224,6 +232,8 @@ func TestOnlineUsersSyntheticBadge(t *testing.T) { require.Contains(t, body, `"synthetic":true`) // Human pilots must omit synthetic when false (omitempty). require.NotContains(t, body, `"synthetic":false`) + // pilot_rating always present as a number. + require.Contains(t, body, `"pilot_rating"`) } func TestRunServiceHTTPAndListen(t *testing.T) { diff --git a/internal/server/conn.go b/internal/server/conn.go index 3de6a26..6a6ebea 100644 --- a/internal/server/conn.go +++ b/internal/server/conn.go @@ -119,6 +119,7 @@ func (s *Server) attemptAuthentication(client *session.Session, token string) (e if err = s.enforcePilotPPLRequirement(client, user); err != nil { return } + setSessionPilotRating(client, user) return } @@ -157,10 +158,24 @@ func (s *Server) attemptAuthentication(client *session.Session, token string) (e if err = s.enforcePilotPPLRequirement(client, user); err != nil { return } + setSessionPilotRating(client, user) return } +// setSessionPilotRating stores the certificate pilot rating on the session. +// Invalid / unknown ratings become 0 (P0). +func setSessionPilotRating(client *session.Session, user *db.User) { + if client == nil || user == nil { + return + } + if protocol.IsValidPilotRating(user.PilotRating) { + client.PilotRating = user.PilotRating + return + } + client.PilotRating = 0 +} + // enforcePilotPPLRequirement rejects pilot (#AP) logins when REQUIRE_PILOT_PPL is // enabled and the certificate's pilot_rating is below PPL. ATC is unaffected. func (s *Server) enforcePilotPPLRequirement(client *session.Session, user *db.User) error { diff --git a/internal/server/http_service.go b/internal/server/http_service.go index 73bf4fe..fe2821f 100644 --- a/internal/server/http_service.go +++ b/internal/server/http_service.go @@ -145,6 +145,7 @@ func (s *Server) handleGetOnlineUsers(c *gin.Context) { Frequency: client.Frequency.Load(), Facility: int(client.FacilityType.Load()), VisRange: int(client.VisRange.Load() * 0.000539957), // Convert meters to nautical miles + // TextATIS empty: openfsd does not persist NEWINFO by default. } resData.ATC = append(resData.ATC, atc) } else { @@ -155,6 +156,9 @@ func (s *Server) handleGetOnlineUsers(c *gin.Context) { Heading: int(client.Heading.Load()), Transponder: client.Transponder.Load(), Synthetic: client.Synthetic, + PilotRating: client.PilotRating, + FlightPlan: client.FlightPlan.Load(), + AssignedBeaconCode: client.AssignedBeaconCode.Load(), } resData.Pilots = append(resData.Pilots, pilot) } diff --git a/internal/server/security_handlers_test.go b/internal/server/security_handlers_test.go index 63d9525..1cfa4bd 100644 --- a/internal/server/security_handlers_test.go +++ b/internal/server/security_handlers_test.go @@ -448,7 +448,11 @@ func TestAttemptAuth_SuccessPassword(t *testing.T) { t.Fatal(err) } f := &fakeUserStore{byCID: map[int]*db.User{ - 5: {CID: 5, Password: string(hash), NetworkRating: int(protocol.NetworkRatingController1)}, + 5: { + CID: 5, Password: string(hash), + NetworkRating: int(protocol.NetworkRatingController1), + PilotRating: int(protocol.PilotRatingATPL), + }, }} srv := newAuthTestServer(t, f, nil) client := session.New(context.Background(), &discardConn{}, nil, session.LoginData{ @@ -463,6 +467,9 @@ func TestAttemptAuth_SuccessPassword(t *testing.T) { if client.MaxNetworkRating != protocol.NetworkRatingController1 { t.Fatalf("MaxNetworkRating=%v", client.MaxNetworkRating) } + if client.PilotRating != int(protocol.PilotRatingATPL) { + t.Fatalf("PilotRating=%d want ATPL(%d)", client.PilotRating, protocol.PilotRatingATPL) + } } func TestAttemptAuth_JWT(t *testing.T) { diff --git a/internal/server/sweatbox_host.go b/internal/server/sweatbox_host.go index 78f747c..975c4aa 100644 --- a/internal/server/sweatbox_host.go +++ b/internal/server/sweatbox_host.go @@ -474,6 +474,7 @@ func (h *SweatboxHost) buildSession(ac sweatbox.AircraftSnapshot) *session.Sessi RealName: "SWEATBOX", NetworkRating: protocol.NetworkRatingObserver, MaxNetworkRating: protocol.NetworkRatingObserver, + PilotRating: 0, // synthetic: no DB certificate (P0) ProtoRevision: 100, LoginTime: now, IsAtc: false, diff --git a/internal/serviceapi/online_users.go b/internal/serviceapi/online_users.go index bacd7b5..b73f5c1 100644 --- a/internal/serviceapi/online_users.go +++ b/internal/serviceapi/online_users.go @@ -30,6 +30,18 @@ type OnlineUserPilot struct { // Synthetic is true for in-process sweatbox pilots (no TCP client). // Omitted from JSON when false so human pilots stay compact. Synthetic bool `json:"synthetic,omitempty"` + + // PilotRating is the VATSIM pilot rating wire ID from the certificate + // at login (0,1,3,7,15,31,63). 0 if unknown (e.g. synthetic without DB). + // Always present as a number (0 is valid P0). + PilotRating int `json:"pilot_rating"` + + // FlightPlan is the session info-section string (no $FP source/dest), + // empty if none filed. Same layout as session.FlightPlan / encodeFlightPlanInfo. + FlightPlan string `json:"flight_plan,omitempty"` + + // AssignedBeaconCode is the ATC-assigned squawk if set; may be empty. + AssignedBeaconCode string `json:"assigned_beacon_code,omitempty"` } // OnlineUserATC is an ATC entry in the online-users snapshot. @@ -38,6 +50,10 @@ type OnlineUserATC struct { Frequency string `json:"frequency"` Facility int `json:"facility"` VisRange int `json:"visual_range"` + + // TextATIS is multi-line controller ATIS when the server stores it. + // Empty when not available (openfsd does not persist NEWINFO by default). + TextATIS []string `json:"text_atis,omitempty"` } // OnlineUsersResponseData is the JSON body for GET /online_users. diff --git a/internal/session/session.go b/internal/session/session.go index 6b22c15..4c59295 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -37,10 +37,13 @@ type LoginData struct { RealName string // Real name NetworkRating protocol.NetworkRating // Network rating of the client MaxNetworkRating protocol.NetworkRating // Maximum allowed network rating (from DB/JWT) - ProtoRevision int // Protocol revision - LoginTime time.Time // Time of login - ClientID uint16 // Client ID (from ident packet) - IsAtc bool // True if the client is ATC, false if a pilot + // PilotRating is the certificate pilot rating wire ID set at auth + // (0,1,3,7,15,31,63). Immutable after login; 0 for synthetic/unknown. + PilotRating int + ProtoRevision int // Protocol revision + LoginTime time.Time // Time of login + ClientID uint16 // Client ID (from ident packet) + IsAtc bool // True if the client is ATC, false if a pilot } // LatLon is a geographic coordinate pair (API convenience; storage is non-boxing atomics). @@ -71,7 +74,7 @@ type LatLon struct { // // Immutable after login (set during login; safe to read concurrently afterward): // - LoginData fields (Callsign, CID, RealName, NetworkRating, ProtoRevision, …) -// - MaxNetworkRating is fixed once authentication completes +// - MaxNetworkRating / PilotRating fixed once authentication completes // - Synthetic — set before Register for in-process (sweatbox) participants; // never set on the TCP login path; concurrent readers OK // diff --git a/internal/web/data.go b/internal/web/data.go index 4e88597..2182b53 100644 --- a/internal/web/data.go +++ b/internal/web/data.go @@ -8,12 +8,7 @@ import ( "encoding/hex" "encoding/json" "errors" - "github.com/gin-gonic/gin" - "github.com/renorris/openfsd/internal/auth" - "github.com/renorris/openfsd/internal/db" - "github.com/renorris/openfsd/internal/serviceapi" - "github.com/renorris/openfsd/pkg/protocol" - "go.uber.org/atomic" + "fmt" "io" "log/slog" "net/http" @@ -21,6 +16,13 @@ import ( "strings" "text/template" "time" + + "github.com/gin-gonic/gin" + "github.com/renorris/openfsd/internal/auth" + "github.com/renorris/openfsd/internal/db" + "github.com/renorris/openfsd/internal/serviceapi" + "github.com/renorris/openfsd/pkg/protocol" + "go.uber.org/atomic" ) //go:embed data_templates/status.txt @@ -284,14 +286,19 @@ type DatafeedGeneral struct { UniqueUsers int `json:"unique_users"` } +// DatafeedPilot embeds OnlineUserPilot so pilot_rating / assigned_beacon_code / +// position fields come from service-HTTP. FlightPlan intentionally shadows the +// embed: service-HTTP carries a string info section; the public datafeed wants +// a VATSIM-shaped object (or omit when nil). Do NOT redeclare PilotRating — +// encoding/json prefers outer fields and would zero honest embed values. type DatafeedPilot struct { - serviceapi.OnlineUserPilot - Server string `json:"server"` - PilotRating int `json:"pilot_rating"` // INOP placeholder - MilitaryRating int `json:"military_rating"` // INOP placeholder - QnhIHg float64 `json:"qnh_i_hg"` // INOP placeholder - QnhMb int `json:"qnh_mb"` // INOP placeholder - FlightPlan *DatafeedFlightplan `json:"flight_plan,omitempty"` // INOP placeholder + serviceapi.OnlineUserPilot // promotes general/position/synthetic/pilot_rating/assigned_beacon_code + Server string `json:"server"` + MilitaryRating int `json:"military_rating"` // always 0: not stored + QnhIHg float64 `json:"qnh_i_hg"` // always 0: no weather model + QnhMb int `json:"qnh_mb"` // always 0: no weather model + // Intentional shadow of embed flight_plan string → object. + FlightPlan *DatafeedFlightplan `json:"flight_plan,omitempty"` } type DatafeedFlightplan struct { @@ -311,10 +318,11 @@ type DatafeedFlightplan struct { AssignedTransponder string `json:"assigned_transponder"` } +// DatafeedATC embeds OnlineUserATC (incl. text_atis when present). +// Do NOT redeclare TextATIS — embed owns json:"text_atis". type DatafeedATC struct { serviceapi.OnlineUserATC - Server string `json:"server"` - TextATIS []string `json:"text_atis"` // INOP placeholder + Server string `json:"server"` } type DatafeedCache struct { @@ -362,22 +370,27 @@ func (s *Server) generateDatafeed() (feed *DatafeedCache, err error) { ATC: []DatafeedATC{}, } + serverIdent, _, _, serverErr := s.getFsdServerInfo() + if serverErr != nil || serverIdent == "" { + serverIdent = "OPENFSD" + } + for _, pilot := range onlineUsers.Pilots { dataFeed.Pilots = append(dataFeed.Pilots, DatafeedPilot{ - OnlineUserPilot: pilot, - Server: "OPENFSD", - PilotRating: 1, - MilitaryRating: 1, - QnhIHg: 29.92, - QnhMb: 1013, + OnlineUserPilot: pilot, // pilot_rating / assigned_beacon from embed + Server: serverIdent, + MilitaryRating: 0, + QnhIHg: 0, + QnhMb: 0, + // Intentional shadow: string info section → VATSIM-shaped object. + FlightPlan: mapInfoSectionToDatafeedFP(pilot.FlightPlan, pilot.AssignedBeaconCode), }) } for _, atc := range onlineUsers.ATC { dataFeed.ATC = append(dataFeed.ATC, DatafeedATC{ - OnlineUserATC: atc, - Server: "OPENFSD", - TextATIS: []string{}, + OnlineUserATC: atc, // text_atis from embed when present + Server: serverIdent, }) } @@ -453,3 +466,59 @@ func (s *Server) updateDataFeedCache() { } datafeedCache.Store(feed) } + +// parseNonNegIntField returns 0 for empty, non-numeric, or negative input. +func parseNonNegIntField(s string) int { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return 0 + } + return n +} + +// formatHHMM formats hour/minute info-section fields as "HHMM" with zero padding. +// sweatbox/encodeFlightPlanInfo may emit single-digit "0" rather than "00". +func formatHHMM(hoursField, minutesField string) string { + h := parseNonNegIntField(hoursField) + m := parseNonNegIntField(minutesField) + return fmt.Sprintf("%02d%02d", h, m) +} + +// mapInfoSectionToDatafeedFP maps a session/serviceapi flight-plan info section +// (colon fields after $FP SOURCE:DEST) to a VATSIM-shaped DatafeedFlightplan. +// Empty info returns nil so the intentional outer FlightPlan shadow omits the key. +func mapInfoSectionToDatafeedFP(info, assignedBeacon string) *DatafeedFlightplan { + info = strings.TrimSpace(info) + if info == "" { + return nil + } + parts := strings.Split(info, ":") + field := func(i int) string { + if i < 0 || i >= len(parts) { + return "" + } + return parts[i] + } + ac := field(1) + fp := &DatafeedFlightplan{ + FlightRules: field(0), + Aircraft: ac, + AircraftFAA: ac, + AircraftShort: ac, + Departure: field(3), + DepTime: field(4), + Arrival: field(7), + EnrouteTime: formatHHMM(field(8), field(9)), + FuelTime: formatHHMM(field(10), field(11)), + Alternate: field(12), + Remarks: field(13), + Route: field(14), + RevisionID: 0, + AssignedTransponder: assignedBeacon, + } + return fp +} diff --git a/internal/web/data_flightplan_test.go b/internal/web/data_flightplan_test.go new file mode 100644 index 0000000..abe7e9f --- /dev/null +++ b/internal/web/data_flightplan_test.go @@ -0,0 +1,150 @@ +package web + +import ( + "encoding/json" + "testing" + + "github.com/renorris/openfsd/internal/serviceapi" +) + +func TestFormatHHMM(t *testing.T) { + cases := []struct { + h, m, want string + }{ + {"0", "0", "0000"}, + {"2", "5", "0205"}, + {"2", "40", "0240"}, + {"", "", "0000"}, + {"x", "3", "0003"}, + {"-1", "5", "0005"}, + {"12", "y", "1200"}, + } + for _, tc := range cases { + got := formatHHMM(tc.h, tc.m) + if got != tc.want { + t.Errorf("formatHHMM(%q,%q)=%q want %q", tc.h, tc.m, got, tc.want) + } + } +} + +func TestMapInfoSectionToDatafeedFP(t *testing.T) { + if got := mapInfoSectionToDatafeedFP("", "1200"); got != nil { + t.Fatalf("empty info → nil, got %#v", got) + } + info := "I:B738:450:KLAX:1200:0000:FL350:KJFK:2:40:5:30:KBOS:RMK:DCT" + fp := mapInfoSectionToDatafeedFP(info, "4321") + if fp == nil { + t.Fatal("expected non-nil FP") + } + if fp.FlightRules != "I" || fp.Aircraft != "B738" || fp.AircraftFAA != "B738" || fp.AircraftShort != "B738" { + t.Fatalf("type/rules: %#v", fp) + } + if fp.Departure != "KLAX" || fp.Arrival != "KJFK" || fp.Alternate != "KBOS" { + t.Fatalf("airports: %#v", fp) + } + if fp.DepTime != "1200" { + t.Fatalf("deptime=%q", fp.DepTime) + } + if fp.EnrouteTime != "0240" || fp.FuelTime != "0530" { + t.Fatalf("times enroute=%q fuel=%q", fp.EnrouteTime, fp.FuelTime) + } + if fp.Remarks != "RMK" || fp.Route != "DCT" { + t.Fatalf("remarks/route: %#v", fp) + } + if fp.AssignedTransponder != "4321" || fp.RevisionID != 0 { + t.Fatalf("beacon/rev: %#v", fp) + } + // Partial fields: map what is present + partial := mapInfoSectionToDatafeedFP("V:C172", "") + if partial == nil || partial.FlightRules != "V" || partial.Aircraft != "C172" { + t.Fatalf("partial: %#v", partial) + } + if partial.EnrouteTime != "0000" || partial.FuelTime != "0000" { + t.Fatalf("partial times: %#v", partial) + } +} + +func TestDatafeedPilotJSONMarshal_EmbedShadowing(t *testing.T) { + info := "I:B738:450:KLAX:1200:0000:FL350:KJFK:2:5:3:0::remarks:route" + pilot := serviceapi.OnlineUserPilot{ + OnlineUserGeneralData: serviceapi.OnlineUserGeneralData{ + Callsign: "UAL1", + CID: 100, + }, + Altitude: 35000, + PilotRating: 15, + FlightPlan: info, + AssignedBeaconCode: "1200", + } + dp := DatafeedPilot{ + OnlineUserPilot: pilot, + Server: "TESTSRV", + MilitaryRating: 0, + QnhIHg: 0, + QnhMb: 0, + FlightPlan: mapInfoSectionToDatafeedFP(pilot.FlightPlan, pilot.AssignedBeaconCode), + } + b, err := json.Marshal(dp) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + // Real pilot_rating from embed (not outer zero). + if pr, ok := m["pilot_rating"].(float64); !ok || pr != 15 { + t.Fatalf("pilot_rating=%v want 15 (json=%s)", m["pilot_rating"], b) + } + // flight_plan must be an object, not a string and not omitted. + fpObj, ok := m["flight_plan"].(map[string]any) + if !ok { + t.Fatalf("flight_plan type %T want object; json=%s", m["flight_plan"], b) + } + if fpObj["departure"] != "KLAX" || fpObj["arrival"] != "KJFK" { + t.Fatalf("flight_plan airports: %#v", fpObj) + } + if fpObj["enroute_time"] != "0205" { + t.Fatalf("enroute_time=%v want 0205", fpObj["enroute_time"]) + } + if m["military_rating"].(float64) != 0 || m["qnh_i_hg"].(float64) != 0 || m["qnh_mb"].(float64) != 0 { + t.Fatalf("expected honest zero QNH/military: %s", b) + } + if m["server"] != "TESTSRV" { + t.Fatalf("server=%v", m["server"]) + } +} + +func TestDatafeedATCJSON_NoOuterTextATISZero(t *testing.T) { + atc := serviceapi.OnlineUserATC{ + OnlineUserGeneralData: serviceapi.OnlineUserGeneralData{Callsign: "LAX_TWR"}, + Frequency: "118500", + // TextATIS empty → omitempty on embed + } + da := DatafeedATC{OnlineUserATC: atc, Server: "S1"} + b, err := json.Marshal(da) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + if _, has := m["text_atis"]; has { + t.Fatalf("empty text_atis should omit: %s", b) + } + // With embed ATIS present: + atc.TextATIS = []string{"line1"} + da = DatafeedATC{OnlineUserATC: atc, Server: "S1"} + b, err = json.Marshal(da) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + atis, ok := m["text_atis"].([]any) + if !ok || len(atis) != 1 || atis[0] != "line1" { + t.Fatalf("text_atis from embed: %s", b) + } +} diff --git a/internal/web/data_test.go b/internal/web/data_test.go index 666f579..3957312 100644 --- a/internal/web/data_test.go +++ b/internal/web/data_test.go @@ -153,10 +153,14 @@ func TestGenerateDatafeedWithStubOnline(t *testing.T) { Pilots: []serviceapi.OnlineUserPilot{{ OnlineUserGeneralData: serviceapi.OnlineUserGeneralData{Callsign: "N1", CID: 1}, Altitude: 1000, + PilotRating: 7, + FlightPlan: "I:B738:450:KLAX:1200:0000:FL350:KJFK:2:5:3:0::rmk:DCT", + AssignedBeaconCode: "1200", }, { OnlineUserGeneralData: serviceapi.OnlineUserGeneralData{Callsign: "SBX1", CID: 900001}, Altitude: 2000, Synthetic: true, + PilotRating: 0, }}, ATC: []serviceapi.OnlineUserATC{{ OnlineUserGeneralData: serviceapi.OnlineUserGeneralData{Callsign: "TWR", CID: 2}, @@ -170,6 +174,32 @@ func TestGenerateDatafeedWithStubOnline(t *testing.T) { assert.Contains(t, s, "N1") assert.Contains(t, s, `"synthetic":true`) assert.NotContains(t, s, `"synthetic":false`) + + // Honest datafeed mapping: no fabricated QNH 29.92 / ratings of 1; real pilot_rating + object FP. + ident, _, _, err := env.server.getFsdServerInfo() + require.NoError(t, err) + if ident == "" { + ident = "OPENFSD" + } + var pilots []DatafeedPilot + for _, pilot := range ou.Pilots { + pilots = append(pilots, DatafeedPilot{ + OnlineUserPilot: pilot, + Server: ident, + MilitaryRating: 0, + QnhIHg: 0, + QnhMb: 0, + FlightPlan: mapInfoSectionToDatafeedFP(pilot.FlightPlan, pilot.AssignedBeaconCode), + }) + } + jb, err := json.Marshal(pilots) + require.NoError(t, err) + js := string(jb) + assert.NotContains(t, js, "29.92") + assert.NotContains(t, js, "1013") + assert.Contains(t, js, `"pilot_rating":7`) + assert.Contains(t, js, `"flight_plan":{`) + assert.Contains(t, js, `"server":"`+ident+`"`) } func TestMakeFsdHttpServiceRequest(t *testing.T) {