mirror of
https://github.com/renorris/openfsd
synced 2026-08-10 19:36:08 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,7 @@ func (s *Server) handleGetConfig(c *gin.Context) {
|
||||
db.ConfigFsdServerIdent,
|
||||
db.ConfigFsdServerLocation,
|
||||
db.ConfigApiServerBaseURL,
|
||||
db.ConfigRequirePilotPPL,
|
||||
}
|
||||
|
||||
type ResponseBody struct {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user