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.
This commit is contained in:
Reese Norris
2026-07-23 20:13:33 -04:00
parent 2df0ee6eb8
commit 3bc93e0101
9 changed files with 184 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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")
}
}

View File

@@ -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 {

View File

@@ -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")
}

View File

@@ -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,

View File

@@ -27,6 +27,7 @@ func (s *Server) handleGetConfig(c *gin.Context) {
db.ConfigFsdServerIdent,
db.ConfigFsdServerLocation,
db.ConfigApiServerBaseURL,
db.ConfigRequirePilotPPL,
}
type ResponseBody struct {

View File

@@ -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 {

View File

@@ -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")
}
}

View File

@@ -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) {