From 3bc93e0101d120850e23ecea81ca3befe7bbf429 Mon Sep 17 00:00:00 2001 From: Reese Norris Date: Thu, 23 Jul 2026 20:13:33 -0400 Subject: [PATCH] feat: optional REQUIRE_PILOT_PPL gate for pilot FSD logins Add config REQUIRE_PILOT_PPL (default false). When enabled, #AP pilot connections need pilot_rating PPL or higher; ATC is unchanged. Exposed in admin config editor and /api/v1/config. --- internal/db/config_repository.go | 28 ++++++++++++++++++ internal/db/config_sqlite_test.go | 43 +++++++++++++++++++++++++++- internal/server/conn.go | 37 ++++++++++++++++++++++++ internal/server/e2e_security_test.go | 42 +++++++++++++++++++++++++++ internal/server/testserver.go | 6 ++++ internal/web/config.go | 1 + internal/web/pagemodel.go | 6 ++++ pkg/protocol/pilot_rating_test.go | 13 +++++++++ pkg/protocol/types.go | 9 ++++++ 9 files changed, 184 insertions(+), 1 deletion(-) diff --git a/internal/db/config_repository.go b/internal/db/config_repository.go index bb7421d..95928f0 100644 --- a/internal/db/config_repository.go +++ b/internal/db/config_repository.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "errors" "io" + "strings" ) type ConfigRepository interface { @@ -30,6 +31,11 @@ const ( ConfigApiServerBaseURL = "API_SERVER_BASE_URL" ConfigWelcomeMessage = "WELCOME_MESSAGE" + + // ConfigRequirePilotPPL, when true, rejects pilot (#AP) connections unless the + // certificate's pilot_rating is PPL (1) or higher. ATC connections are unaffected. + // Default false (see InitDefaultConfig). Values: true/false, 1/0, yes/no. + ConfigRequirePilotPPL = "REQUIRE_PILOT_PPL" ) var ErrConfigKeyNotFound = errors.New("config: key not found") @@ -53,6 +59,27 @@ func GetWelcomeMessage(r ConfigRepository) (msg string) { return } +// ParseBoolConfig interprets common config string booleans. +// Empty / unknown / missing → false (safe default for restrictive flags). +func ParseBoolConfig(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +// RequirePilotPPL reports whether pilot connections require pilot_rating ≥ PPL. +// Missing key or parse failure → false (disabled by default). +func RequirePilotPPL(r ConfigRepository) bool { + v, err := r.Get(ConfigRequirePilotPPL) + if err != nil { + return false + } + return ParseBoolConfig(v) +} + func InitDefaultConfig(r ConfigRepository) (err error) { secretKey, err := GenerateJwtSecretKey() if err != nil { @@ -66,6 +93,7 @@ func InitDefaultConfig(r ConfigRepository) (err error) { ConfigFsdServerIdent: "OPENFSD", ConfigFsdServerLocation: "Earth", ConfigApiServerBaseURL: "http://localhost", + ConfigRequirePilotPPL: "false", } for k, v := range defaultConfig { diff --git a/internal/db/config_sqlite_test.go b/internal/db/config_sqlite_test.go index 3519103..4234f4f 100644 --- a/internal/db/config_sqlite_test.go +++ b/internal/db/config_sqlite_test.go @@ -145,7 +145,7 @@ func TestMultipleSets(t *testing.T) { } err = repo.Set("key2", "value2") if err != nil { - t.Errorf("expected no{kcal error, got %v", err) + t.Errorf("expected no error, got %v", err) } // Retrieve and verify @@ -174,3 +174,44 @@ func TestMultipleSets(t *testing.T) { t.Errorf("expected value2, got %s, err %v", val2, err) } } + +func TestParseBoolConfig(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"", false}, + {"false", false}, + {"0", false}, + {"no", false}, + {"true", true}, + {"TRUE", true}, + {"1", true}, + {"yes", true}, + {"on", true}, + {" yes ", true}, + {"maybe", false}, + } + for _, tc := range cases { + if got := ParseBoolConfig(tc.in); got != tc.want { + t.Errorf("ParseBoolConfig(%q)=%v want %v", tc.in, got, tc.want) + } + } +} + +func TestRequirePilotPPLDefault(t *testing.T) { + dbConn, repo := setupConfigTestDB(t) + defer dbConn.Close() + if err := InitDefaultConfig(repo); err != nil { + t.Fatal(err) + } + if RequirePilotPPL(repo) { + t.Fatal("default REQUIRE_PILOT_PPL should be false") + } + if err := repo.Set(ConfigRequirePilotPPL, "true"); err != nil { + t.Fatal(err) + } + if !RequirePilotPPL(repo) { + t.Fatal("expected true after Set") + } +} diff --git a/internal/server/conn.go b/internal/server/conn.go index 84c6b56..cb30bb0 100644 --- a/internal/server/conn.go +++ b/internal/server/conn.go @@ -275,6 +275,18 @@ func (s *Server) attemptAuthentication(client *session.Session, token string) (e } client.MaxNetworkRating = claims.NetworkRating + // Pilot PPL gate needs DB pilot_rating (JWT claims do not carry it). + user, userErr := s.users.GetUserByCID(client.CID) + if userErr != nil { + s.authFails.recordFailure(ip, now) + err = ErrInvalidAddPacket + sendError(client.Conn, InvalidLogonError, invalidLogonMsg) + return + } + if err = s.enforcePilotPPLRequirement(client, user); err != nil { + return + } + return } @@ -309,9 +321,34 @@ func (s *Server) attemptAuthentication(client *session.Session, token string) (e } client.MaxNetworkRating = NetworkRating(user.NetworkRating) + if err = s.enforcePilotPPLRequirement(client, user); err != nil { + return + } + return } +// 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 { + if client.IsAtc { + return nil + } + if s.configKV == nil { + return nil + } + raw, err := s.configKV.Get(db.ConfigRequirePilotPPL) + if err != nil || !db.ParseBoolConfig(raw) { + return nil + } + if user == nil || !protocol.MeetsMinimumPilotRating(user.PilotRating, protocol.PilotRatingPPL) { + err := ErrInvalidAddPacket + sendError(client.Conn, RequestedLevelTooHighError, "Pilot rating PPL or higher required") + return err + } + return nil +} + func (s *Server) broadcastAddPacket(client *session.Session) { var packet string if client.IsAtc { diff --git a/internal/server/e2e_security_test.go b/internal/server/e2e_security_test.go index a563423..6c09a61 100644 --- a/internal/server/e2e_security_test.go +++ b/internal/server/e2e_security_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/renorris/openfsd/internal/db" "github.com/renorris/openfsd/internal/server" "github.com/renorris/openfsd/pkg/fsdclient" "github.com/renorris/openfsd/pkg/protocol" @@ -194,3 +195,44 @@ func TestE2E_MaxConnectionsRejectsExcess(t *testing.T) { t.Fatal("expected Dial/handshake failure when at connection cap") } } + +// TestE2E_RequirePilotPPL gates pilot #AP logins when REQUIRE_PILOT_PPL is true. +func TestE2E_RequirePilotPPL(t *testing.T) { + ts := server.StartTestServer(t) + + // Default (false): P0 pilot may connect. + c := dial(t, ts) + loginPilot(t, c, "PPLGATE0", ts.PilotCID, ts.PilotPassword, protocol.NetworkRatingObserver) + waitMOTD(t, c, "PPLGATE0") + _ = c.Close(context.Background()) + + // Enable gate. + if err := ts.ConfigRepo.Set(db.ConfigRequirePilotPPL, "true"); err != nil { + t.Fatal(err) + } + + // Pilot still at P0 → rejected. + c2 := dial(t, ts) + loginPilot(t, c2, "PPLGATE1", ts.PilotCID, ts.PilotPassword, protocol.NetworkRatingObserver) + waitError(t, c2, protocol.RequestedLevelTooHighError) + + // Raise pilot certificate to PPL → allowed. + u, err := ts.UserRepo.GetUserByCID(ts.PilotCID) + if err != nil { + t.Fatal(err) + } + u.PilotRating = int(protocol.PilotRatingPPL) + u.Password = "" // keep hash + if err := ts.UserRepo.UpdateUser(u); err != nil { + t.Fatal(err) + } + + c3 := dial(t, ts) + loginPilot(t, c3, "PPLGATE2", ts.PilotCID, ts.PilotPassword, protocol.NetworkRatingObserver) + waitMOTD(t, c3, "PPLGATE2") + + // ATC unaffected by the gate (still P0 pilot_rating on ATC user is fine). + atc := dial(t, ts) + loginATC(t, atc, "PPL_ATC", ts.ATCCID, ts.ATCPassword, protocol.NetworkRatingController1) + waitMOTD(t, atc, "PPL_ATC") +} diff --git a/internal/server/testserver.go b/internal/server/testserver.go index 5a32b6e..f74044e 100644 --- a/internal/server/testserver.go +++ b/internal/server/testserver.go @@ -104,6 +104,10 @@ type TestServer struct { // Second pilot for multi-client scenarios (same password as Pilot). Pilot2CID int + // ConfigRepo / UserRepo expose seeded stores for e2e policy tests. + ConfigRepo db.ConfigRepository + UserRepo db.UserRepository + sqlDB *sql.DB cancel context.CancelFunc done <-chan error @@ -328,6 +332,8 @@ func StartTestServerOpts(t testing.TB, opts TestServerOptions) *TestServer { SupCID: sup.CID, SupPassword: TestSupPassword, Pilot2CID: pilot2.CID, + ConfigRepo: repos.ConfigRepo, + UserRepo: repos.UserRepo, sqlDB: sqlDB, cancel: cancel, done: done, diff --git a/internal/web/config.go b/internal/web/config.go index 802debe..dc57b48 100644 --- a/internal/web/config.go +++ b/internal/web/config.go @@ -27,6 +27,7 @@ func (s *Server) handleGetConfig(c *gin.Context) { db.ConfigFsdServerIdent, db.ConfigFsdServerLocation, db.ConfigApiServerBaseURL, + db.ConfigRequirePilotPPL, } type ResponseBody struct { diff --git a/internal/web/pagemodel.go b/internal/web/pagemodel.go index 21bd811..888d61e 100644 --- a/internal/web/pagemodel.go +++ b/internal/web/pagemodel.go @@ -367,6 +367,12 @@ var editableConfigKeys = []struct { Description: "API server base URL advertised to clients", Placeholder: "https://example.com", }, + { + Key: db.ConfigRequirePilotPPL, + Label: "Require PPL for pilot connections", + Description: "When true, pilots must hold pilot rating PPL (or higher) to connect as a pilot. ATC is unaffected. Values: true/false (default false).", + Placeholder: "false", + }, } func isEditableConfigKey(key string) bool { diff --git a/pkg/protocol/pilot_rating_test.go b/pkg/protocol/pilot_rating_test.go index 32980a0..7a04223 100644 --- a/pkg/protocol/pilot_rating_test.go +++ b/pkg/protocol/pilot_rating_test.go @@ -42,4 +42,17 @@ func TestPilotRatingScaleMatchesVATSIM(t *testing.T) { t.Errorf("IsValidPilotRating(%d) = true, want false", bad) } } + + if MeetsMinimumPilotRating(int(PilotRatingNone), PilotRatingPPL) { + t.Error("P0 should not meet PPL minimum") + } + if !MeetsMinimumPilotRating(int(PilotRatingPPL), PilotRatingPPL) { + t.Error("PPL should meet PPL minimum") + } + if !MeetsMinimumPilotRating(int(PilotRatingATPL), PilotRatingPPL) { + t.Error("ATPL should meet PPL minimum") + } + if MeetsMinimumPilotRating(2, PilotRatingPPL) { + t.Error("invalid rating 2 should not meet PPL") + } } diff --git a/pkg/protocol/types.go b/pkg/protocol/types.go index 46d185d..e93f824 100644 --- a/pkg/protocol/types.go +++ b/pkg/protocol/types.go @@ -117,6 +117,15 @@ func IsValidPilotRating(v int) bool { return false } +// MeetsMinimumPilotRating reports whether v is a valid pilot rating at least min. +// Ordering follows PilotRatingScale numeric IDs (0 < 1 < 3 < 7 < 15 < 31 < 63). +func MeetsMinimumPilotRating(v int, min PilotRating) bool { + if !IsValidPilotRating(v) { + return false + } + return v >= int(min) +} + // PilotRatingShort returns the short code (P0, PPL, IR, …) for a pilot rating ID. func PilotRatingShort(v int) string { switch PilotRating(v) {