mirror of
https://github.com/renorris/openfsd
synced 2026-03-22 06:25:35 +08:00
initial v1.0-beta commit
This commit is contained in:
110
fsd/client.go
Normal file
110
fsd/client.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package fsd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"go.uber.org/atomic"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
conn net.Conn
|
||||
scanner *bufio.Scanner
|
||||
ctx context.Context
|
||||
cancelCtx func()
|
||||
sendChan chan string
|
||||
|
||||
latLon [2]float64
|
||||
visRange float64
|
||||
|
||||
flightPlan *atomic.String
|
||||
beaconCode *atomic.String
|
||||
|
||||
facilityType int // ATC facility type. This value is only relevant for ATC
|
||||
loginData
|
||||
|
||||
authState vatsimAuthState
|
||||
}
|
||||
|
||||
func newClient(ctx context.Context, conn net.Conn, scanner *bufio.Scanner, loginData loginData) (client *Client) {
|
||||
clientCtx, cancel := context.WithCancel(ctx)
|
||||
return &Client{
|
||||
conn: conn,
|
||||
scanner: scanner,
|
||||
ctx: clientCtx,
|
||||
cancelCtx: cancel,
|
||||
sendChan: make(chan string, 32),
|
||||
flightPlan: &atomic.String{},
|
||||
beaconCode: &atomic.String{},
|
||||
loginData: loginData,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) senderWorker() {
|
||||
defer c.conn.Close()
|
||||
defer c.cancelCtx()
|
||||
|
||||
for {
|
||||
select {
|
||||
case packet := <-c.sendChan:
|
||||
if _, err := c.conn.Write([]byte(packet)); err != nil {
|
||||
return
|
||||
}
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendError sends an FSD error packet to a Client with the specified code and message.
|
||||
// It returns an error if writing to the connection fails.
|
||||
func (c *Client) sendError(code int, message string) (err error) {
|
||||
packet := strings.Builder{}
|
||||
packet.Grow(128)
|
||||
packet.WriteString("$ERserver:unknown:")
|
||||
codeBuf := make([]byte, 0, 8)
|
||||
codeBuf = strconv.AppendInt(codeBuf, int64(code), 10)
|
||||
packet.Write(codeBuf)
|
||||
packet.WriteString("::")
|
||||
packet.WriteString(message)
|
||||
packet.WriteString("\r\n")
|
||||
|
||||
return c.send(packet.String())
|
||||
}
|
||||
|
||||
func (c *Client) send(packet string) (err error) {
|
||||
select {
|
||||
case c.sendChan <- packet:
|
||||
return
|
||||
case <-c.ctx.Done():
|
||||
return c.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) eventLoop(client *Client) {
|
||||
defer client.cancelCtx()
|
||||
|
||||
go client.senderWorker()
|
||||
|
||||
for {
|
||||
if !client.scanner.Scan() {
|
||||
return
|
||||
}
|
||||
|
||||
// Reference the next packet
|
||||
packet := client.scanner.Bytes()
|
||||
packet = append(packet, '\r', '\n') // Re-append delimiter
|
||||
|
||||
// Verify packet and obtain type
|
||||
packetType, ok := verifyPacket(packet, client)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Run handler
|
||||
handler := s.getHandler(packetType)
|
||||
handler(client, packet)
|
||||
}
|
||||
}
|
||||
359
fsd/conn.go
Normal file
359
fsd/conn.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package fsd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sendError sends an FSD error packet to an io.Writer with the specified code and message.
|
||||
// It returns an error if writing to the connection fails.
|
||||
//
|
||||
// This function must only be used during the login phase,
|
||||
// as it synchronously writes the error directly to the
|
||||
// connection socket.
|
||||
func sendError(conn io.Writer, code int, message string) (err error) {
|
||||
packet := fmt.Sprintf("$ERserver:unknown:%d::%s\r\n", code, message)
|
||||
_, err = conn.Write([]byte(packet))
|
||||
return
|
||||
}
|
||||
|
||||
// handleConn manages a single Client connection.
|
||||
// If any errors occur during the process, it sends an error to the Client and closes the connection.
|
||||
func (s *Server) handleConn(ctx context.Context, conn net.Conn) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
fmt.Println("An FSD connection goroutine panicked:")
|
||||
fmt.Println(err)
|
||||
}
|
||||
}()
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
if err := sendServerIdent(conn); err != nil {
|
||||
fmt.Printf("Error sending server ident: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(conn)
|
||||
buf := make([]byte, 4096)
|
||||
scanner.Buffer(buf, len(buf))
|
||||
|
||||
data, token, err := readLoginPackets(conn, scanner)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the requested callsign is OK
|
||||
if !isValidClientCallsign([]byte(data.callsign)) {
|
||||
sendError(conn, CallsignInvalidError, "Callsign invalid")
|
||||
return
|
||||
}
|
||||
|
||||
client := newClient(ctx, conn, scanner, data)
|
||||
|
||||
// Attempt to authenticate connection
|
||||
if err = s.attemptAuthentication(client, token); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt to register to post office
|
||||
if err = s.postOffice.register(client); err != nil {
|
||||
if errors.Is(err, ErrCallsignInUse) {
|
||||
sendError(conn, CallsignInUseError, "Callsign already in use")
|
||||
}
|
||||
return
|
||||
}
|
||||
defer s.postOffice.release(client)
|
||||
|
||||
// Send hello message to client
|
||||
if err = s.sendMotd(client); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast add packet to entire server
|
||||
s.broadcastAddPacket(client)
|
||||
defer s.broadcastDisconnectPacket(client)
|
||||
|
||||
s.eventLoop(client)
|
||||
}
|
||||
|
||||
// sendServerIdent sends the initial server identification packet to the Client.
|
||||
// It returns an error if writing to the connection fails.
|
||||
func sendServerIdent(conn io.Writer) (err error) {
|
||||
packet := "$DISERVER:CLIENT:openfsd:6f70656e667364\r\n"
|
||||
_, err = conn.Write([]byte(packet))
|
||||
return
|
||||
}
|
||||
|
||||
// loginData holds the data extracted from the Client's login packets.
|
||||
type loginData struct {
|
||||
clientChallenge string // Optional Client challenge for authentication
|
||||
callsign string // Callsign of the Client (ATC or pilot)
|
||||
cid int // Cert ID
|
||||
realName string // Real name
|
||||
networkRating NetworkRating // Network rating of the Client
|
||||
protoRevision int // Protocol revision
|
||||
clientId uint16 // Client ID
|
||||
isAtc bool // True if the Client is an ATC, false if a pilot
|
||||
}
|
||||
|
||||
// ErrInvalidAddPacket is returned when the add packet from the Client is invalid.
|
||||
var ErrInvalidAddPacket = errors.New("invalid add packet")
|
||||
|
||||
// ErrInvalidIDPacket is returned when the ID packet from the Client is invalid.
|
||||
var ErrInvalidIDPacket = errors.New("invalid ID packet")
|
||||
|
||||
// readLoginPackets reads the two expected login packets from the Client:
|
||||
// the Client identification packet and the add packet.
|
||||
// It parses these packets to extract the Client's data and returns it in a loginData struct.
|
||||
// If any errors occur during reading or parsing, it sends an error to the Client and returns an error.
|
||||
func readLoginPackets(conn net.Conn, scanner *bufio.Scanner) (data loginData, token string, err error) {
|
||||
// Client ident
|
||||
if !scanner.Scan() {
|
||||
err = ErrInvalidIDPacket
|
||||
sendError(conn, SyntaxError, "Error reading Client ident packet")
|
||||
return
|
||||
}
|
||||
idPacket := append([]byte{}, scanner.Bytes()...)
|
||||
|
||||
// Add packet
|
||||
if !scanner.Scan() {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(conn, SyntaxError, "Error reading add packet")
|
||||
return
|
||||
}
|
||||
addPacket := append([]byte{}, scanner.Bytes()...)
|
||||
|
||||
// Check if the Client sent a challenge field
|
||||
if countFields(idPacket) == 9 {
|
||||
// Extract the challenge
|
||||
data.clientChallenge = string(getField(idPacket, 8))
|
||||
|
||||
// Extract the client ID
|
||||
var clientId uint64
|
||||
clientId, err = strconv.ParseUint(string(getField(idPacket, 2)), 16, 16)
|
||||
if err != nil {
|
||||
err = ErrInvalidIDPacket
|
||||
sendError(conn, SyntaxError, "Error parsing client ID")
|
||||
return
|
||||
}
|
||||
data.clientId = uint16(clientId)
|
||||
}
|
||||
|
||||
if len(addPacket) < 16 {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(conn, SyntaxError, "Invalid add packet")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine Client type
|
||||
var prefix string
|
||||
switch string(addPacket[:3]) {
|
||||
case "#AA":
|
||||
data.isAtc = true
|
||||
prefix = "#AA"
|
||||
case "#AP":
|
||||
prefix = "#AP"
|
||||
default:
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(conn, SyntaxError, "Invalid add packet prefix")
|
||||
return
|
||||
}
|
||||
|
||||
if data.isAtc {
|
||||
if countFields(addPacket) != 7 {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(conn, SyntaxError, "Invalid number of fields in ATC add packet")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if countFields(addPacket) != 8 {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(conn, SyntaxError, "Invalid number of fields in pilot add packet")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if callsign, found := bytes.CutPrefix(getField(addPacket, 0), []byte(prefix)); found {
|
||||
data.callsign = string(callsign)
|
||||
} else {
|
||||
sendError(conn, SyntaxError, "Invalid callsign in add packet")
|
||||
err = ErrInvalidAddPacket
|
||||
return
|
||||
}
|
||||
|
||||
if data.isAtc {
|
||||
data.realName = string(getField(addPacket, 2))
|
||||
if data.cid, err = strconv.Atoi(string(getField(addPacket, 3))); err != nil {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(conn, SyntaxError, "Invalid CID in ATC add packet")
|
||||
return
|
||||
}
|
||||
token = string(getField(addPacket, 4))
|
||||
var networkRating int
|
||||
if networkRating, err = strconv.Atoi(string(getField(addPacket, 5))); err != nil {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(conn, SyntaxError, "Invalid network rating in pilot add packet")
|
||||
return
|
||||
}
|
||||
data.networkRating = NetworkRating(networkRating)
|
||||
if data.protoRevision, err = strconv.Atoi(string(getField(addPacket, 6))); err != nil {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(conn, SyntaxError, "Invalid protocol revision in ATC add packet")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if data.cid, err = strconv.Atoi(string(getField(addPacket, 2))); err != nil {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(conn, SyntaxError, "Invalid CID in pilot add packet")
|
||||
return
|
||||
}
|
||||
token = string(getField(addPacket, 3))
|
||||
var networkRating int
|
||||
if networkRating, err = strconv.Atoi(string(getField(addPacket, 4))); err != nil {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(conn, SyntaxError, "Invalid network rating in pilot add packet")
|
||||
return
|
||||
}
|
||||
data.networkRating = NetworkRating(networkRating)
|
||||
if data.protoRevision, err = strconv.Atoi(string(getField(addPacket, 5))); err != nil {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(conn, SyntaxError, "Invalid protocol revision in pilot add packet")
|
||||
return
|
||||
}
|
||||
data.realName = string(getField(addPacket, 7))
|
||||
}
|
||||
|
||||
if data.protoRevision < 100 || data.protoRevision > 101 {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(conn, InvalidProtocolRevisionError, "Invalid protocol revision")
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) attemptAuthentication(client *Client, token string) (err error) {
|
||||
// Check vatsim auth compatibility
|
||||
if client.clientChallenge != "" {
|
||||
if err = client.authState.Initialize(
|
||||
client.clientId,
|
||||
[]byte(client.clientChallenge),
|
||||
); err != nil {
|
||||
err = ErrInvalidIDPacket
|
||||
sendError(client.conn, UnauthorizedSoftwareError, "Client incompatible with auth challenges")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const invalidLogonMsg = "Invalid CID/password"
|
||||
|
||||
// Check if the provided token is actually a JWT
|
||||
if mostLikelyJwt([]byte(token)) {
|
||||
var jwtToken *JwtToken
|
||||
if jwtToken, err = ParseJwtToken(token, s.jwtSecret); err != nil {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(client.conn, InvalidLogonError, invalidLogonMsg)
|
||||
return
|
||||
}
|
||||
|
||||
claims := jwtToken.CustomClaims()
|
||||
|
||||
if claims.TokenType != "fsd" {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(client.conn, InvalidLogonError, invalidLogonMsg)
|
||||
return
|
||||
}
|
||||
|
||||
if client.cid != claims.CID {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(client.conn, RequestedLevelTooHighError, invalidLogonMsg)
|
||||
return
|
||||
}
|
||||
if client.networkRating > claims.NetworkRating {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(client.conn, RequestedLevelTooHighError, "Requested level too high")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, treat it as a plaintext password
|
||||
password := token
|
||||
|
||||
// Attempt to fetch user
|
||||
user, err := s.dbRepo.UserRepo.GetUserByCID(client.cid)
|
||||
if err != nil {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(client.conn, InvalidLogonError, invalidLogonMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password hash
|
||||
if !s.dbRepo.UserRepo.VerifyPasswordHash(password, user.Password) {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(client.conn, InvalidLogonError, invalidLogonMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify network rating
|
||||
if client.networkRating > NetworkRating(user.NetworkRating) {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(client.conn, RequestedLevelTooHighError, "Requested level too high")
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) broadcastAddPacket(client *Client) {
|
||||
var packet string
|
||||
if client.isAtc {
|
||||
packet = fmt.Sprintf(
|
||||
"#AA%s:SERVER:%s:%d::%d:%d\r\n",
|
||||
client.callsign,
|
||||
client.realName,
|
||||
client.cid,
|
||||
client.networkRating,
|
||||
client.protoRevision)
|
||||
} else {
|
||||
packet = fmt.Sprintf(
|
||||
"#AP%s:SERVER:%d::%d:%d:1:%s\r\n",
|
||||
client.callsign,
|
||||
client.cid,
|
||||
client.networkRating,
|
||||
client.protoRevision,
|
||||
client.realName)
|
||||
}
|
||||
|
||||
broadcastAll(s.postOffice, client, []byte(packet))
|
||||
}
|
||||
|
||||
func (s *Server) broadcastDisconnectPacket(client *Client) {
|
||||
packet := strings.Builder{}
|
||||
if client.isAtc {
|
||||
packet.WriteString("#DA")
|
||||
} else {
|
||||
packet.WriteString("#DP")
|
||||
}
|
||||
|
||||
packet.WriteString(client.callsign)
|
||||
packet.WriteString(":SERVER:")
|
||||
packet.WriteString(strconv.Itoa(client.cid))
|
||||
packet.WriteString("\r\n")
|
||||
|
||||
broadcastAll(s.postOffice, client, []byte(packet.String()))
|
||||
}
|
||||
|
||||
func (s *Server) sendMotd(client *Client) (err error) {
|
||||
packet := fmt.Sprintf("#TMserver:%s:Connected to openfsd\r\n", client.callsign)
|
||||
_, err = client.conn.Write([]byte(packet))
|
||||
return
|
||||
}
|
||||
468
fsd/handler.go
Normal file
468
fsd/handler.go
Normal file
@@ -0,0 +1,468 @@
|
||||
package fsd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) getHandler(packetType PacketType) handlerFunc {
|
||||
switch packetType {
|
||||
case PacketTypeTextMessage:
|
||||
return s.handleTextMessage
|
||||
case PacketTypeATCPosition:
|
||||
return s.handleATCPosition
|
||||
case PacketTypePilotPosition:
|
||||
return s.handlePilotPosition
|
||||
case PacketTypePilotPositionFast, PacketTypePilotPositionSlow, PacketTypePilotPositionStopped:
|
||||
return s.handleFastPilotPosition
|
||||
case PacketTypeDeletePilot, PacketTypeDeleteATC:
|
||||
return s.handleDelete
|
||||
case PacketTypeSquawkbox:
|
||||
return s.handleSquawkbox
|
||||
case PacketTypeProController:
|
||||
return s.handleProcontroller
|
||||
case PacketTypeClientQuery, PacketTypeClientQueryResponse:
|
||||
return s.handleClientQuery
|
||||
case PacketTypeKillRequest:
|
||||
return s.handleKillRequest
|
||||
case PacketTypeAuthChallenge:
|
||||
return s.handleAuthChallenge
|
||||
case PacketTypeHandoffRequest, PacketTypeHandoffAccept:
|
||||
return s.handleHandoff
|
||||
case PacketTypeMetarRequest:
|
||||
return s.handleMetarRequest
|
||||
case PacketTypeFlightPlan:
|
||||
return s.handleFileFlightplan
|
||||
case PacketTypeFlightPlanAmendment:
|
||||
return s.handleAmendFlightplan
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleTextMessage(client *Client, packet []byte) {
|
||||
recipient := getField(packet, 1)
|
||||
|
||||
// ATC chat
|
||||
if string(recipient) == "@49999" {
|
||||
if !client.isAtc {
|
||||
return
|
||||
}
|
||||
broadcastRangedAtcOnly(s.postOffice, client, packet)
|
||||
return
|
||||
}
|
||||
|
||||
// Frequency message
|
||||
if bytes.HasPrefix(recipient, []byte("@")) {
|
||||
broadcastRanged(s.postOffice, client, packet)
|
||||
return
|
||||
}
|
||||
|
||||
// Wallop
|
||||
if string(recipient) == "*S" {
|
||||
broadcastAllSupervisors(s.postOffice, client, packet)
|
||||
return
|
||||
}
|
||||
|
||||
// Server-wide broadcast message
|
||||
if string(recipient) == "*" {
|
||||
if client.networkRating < NetworkRatingSupervisor {
|
||||
return
|
||||
}
|
||||
broadcastAll(s.postOffice, client, packet)
|
||||
return
|
||||
}
|
||||
|
||||
if string(recipient) == "FP" {
|
||||
// TODO: handle FP
|
||||
return
|
||||
}
|
||||
|
||||
if string(recipient) == "SERVER" {
|
||||
// TODO: handle SERVER
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, treat as direct message
|
||||
sendDirectOrErr(s.postOffice, client, recipient, packet)
|
||||
}
|
||||
|
||||
func (s *Server) handleATCPosition(client *Client, packet []byte) {
|
||||
// Verify and set facility type
|
||||
facilityType, err := strconv.ParseInt(string(getField(packet, 2)), 10, 32)
|
||||
if err != nil {
|
||||
client.sendError(SyntaxError, "Invalid facility type")
|
||||
return
|
||||
}
|
||||
|
||||
if !isAllowedFacilityType(client.networkRating, int(facilityType)) {
|
||||
client.sendError(InvalidPositionForRatingError, "Invalid position for rating")
|
||||
client.cancelCtx()
|
||||
return
|
||||
}
|
||||
|
||||
client.facilityType = int(facilityType)
|
||||
|
||||
// Extract location and visibility range
|
||||
lat, lon, ok := parseLatLon(packet, 5, 6)
|
||||
if !ok {
|
||||
client.sendError(SyntaxError, "Invalid latitude/longitude")
|
||||
return
|
||||
}
|
||||
visRange, ok := parseVisRange(packet, 3)
|
||||
if !ok {
|
||||
client.sendError(SyntaxError, "Invalid visibility range")
|
||||
return
|
||||
}
|
||||
|
||||
// Update post office position
|
||||
s.postOffice.updatePosition(client, [2]float64{lat, lon}, visRange)
|
||||
|
||||
// Broadcast position update
|
||||
broadcastRanged(s.postOffice, client, packet)
|
||||
}
|
||||
|
||||
// handlePilotPosition handles logic for 0.2hz `@` pilot position updates
|
||||
func (s *Server) handlePilotPosition(client *Client, packet []byte) {
|
||||
lat, lon, ok := parseLatLon(packet, 4, 5)
|
||||
if !ok {
|
||||
client.sendError(SyntaxError, "Invalid latitude/longitude")
|
||||
return
|
||||
}
|
||||
|
||||
const pilotVisRange = 50.0 * 1852.0 // 50 nautical miles
|
||||
|
||||
// Update post office position
|
||||
s.postOffice.updatePosition(client, [2]float64{lat, lon}, pilotVisRange)
|
||||
|
||||
// Broadcast position update
|
||||
broadcastRanged(s.postOffice, client, packet)
|
||||
}
|
||||
|
||||
// handleFastPilotPosition handles logic for fast `^`, stopped `#ST`, and slow `#SL` pilot position updates
|
||||
func (s *Server) handleFastPilotPosition(client *Client, packet []byte) {
|
||||
// Broadcast position update
|
||||
broadcastRangedVelocity(s.postOffice, client, packet)
|
||||
}
|
||||
|
||||
// handleDelete handles logic for Delete ATC `#DA` and Delete Pilot `#DP` packets
|
||||
func (s *Server) handleDelete(client *Client, packet []byte) {
|
||||
// Broadcast delete packet
|
||||
broadcastAll(s.postOffice, client, packet)
|
||||
|
||||
// Cancel context. Writer worker will close the connection
|
||||
client.cancelCtx()
|
||||
}
|
||||
|
||||
// handleSquawkbox handles logic for Squawkbox `#SB` packets
|
||||
func (s *Server) handleSquawkbox(client *Client, packet []byte) {
|
||||
// Forward packet to recipient
|
||||
recipient := getField(packet, 1)
|
||||
sendDirectOrErr(s.postOffice, client, recipient, packet)
|
||||
}
|
||||
|
||||
// handleProcontroller handles logic for Pro Controller `#PC` packets
|
||||
func (s *Server) handleProcontroller(client *Client, packet []byte) {
|
||||
// ATC-only packet
|
||||
if !client.isAtc {
|
||||
return
|
||||
}
|
||||
|
||||
recipient := getField(packet, 1)
|
||||
if len(recipient) < 2 {
|
||||
client.sendError(SyntaxError, "Invalid recipient")
|
||||
return
|
||||
}
|
||||
pcType := getField(packet, 3)
|
||||
|
||||
switch string(pcType) {
|
||||
|
||||
// Unprivileged requests
|
||||
case
|
||||
"VER", // Version
|
||||
"ID", // Modern client check
|
||||
"DI", // Modern client check response
|
||||
"IC", "IK", "IB", "IO",
|
||||
"OC", "OK", "OB", "OO",
|
||||
"MC", "MK", "MB", "MO": // Landline commands
|
||||
|
||||
sendDirectOrErr(s.postOffice, client, recipient, packet)
|
||||
|
||||
// Privileged requests
|
||||
case
|
||||
"IH", // I have
|
||||
"SC", // Set scratchpad
|
||||
"GD", // Set global data
|
||||
"TA", // Set temporary altitude
|
||||
"FA", // Set final altitude
|
||||
"VT", // Set voice type
|
||||
"BC", // Set beacon code
|
||||
"HC", // Cancel handoff
|
||||
"PT", // Pointout
|
||||
"DP", // Push to departure list
|
||||
"ST": // Set flight strip
|
||||
|
||||
// Only active ATC above OBS
|
||||
if client.facilityType <= 0 {
|
||||
client.sendError(InvalidControlError, "Invalid control")
|
||||
return
|
||||
}
|
||||
if recipient[0] == '@' {
|
||||
broadcastRangedAtcOnly(s.postOffice, client, packet)
|
||||
} else {
|
||||
sendDirectOrErr(s.postOffice, client, recipient, packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleClientQuery(client *Client, packet []byte) {
|
||||
recipient := getField(packet, 1)
|
||||
queryType := getField(packet, 2)
|
||||
|
||||
// Handle queries sent to SERVER
|
||||
if string(recipient) == "SERVER" {
|
||||
switch string(queryType) {
|
||||
case "ATC":
|
||||
s.handleClientQueryATCRequest(client, packet)
|
||||
case "IP":
|
||||
s.handleClientQueryIPRequest(client, packet)
|
||||
case "FP":
|
||||
s.handleClientQueryFlightplanRequest(client, packet)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle broadcast queries (recipient starts with "@")
|
||||
if bytes.HasPrefix(recipient, []byte("@")) {
|
||||
switch string(queryType) {
|
||||
|
||||
// Unprivileged ATC queries
|
||||
case
|
||||
"BY", // Request relief
|
||||
"HI", // Cancel request relief
|
||||
"HLP", // Request help
|
||||
"NOHLP", // Cancel request help
|
||||
"WH", // Who has
|
||||
"NEWATIS", // Broadcast new ATIS letter
|
||||
"NEWINFO": // Broadcast new ATIS info
|
||||
|
||||
// ATC only
|
||||
if !client.isAtc {
|
||||
client.sendError(InvalidControlError, "Invalid control")
|
||||
return
|
||||
}
|
||||
broadcastRangedAtcOnly(s.postOffice, client, packet)
|
||||
|
||||
// Privileged ATC queries
|
||||
case
|
||||
"IT", // Initiate track
|
||||
"DR", // Drop track
|
||||
"HT", // Accept handoff
|
||||
"TA", // Set temporary altitude
|
||||
"FA", // Set final altitude
|
||||
"BC", // Set beacon code
|
||||
"SC", // Set scratchpad
|
||||
"VT", // Set voice type
|
||||
"EST", // Set estimate time
|
||||
"GD": // Set global data
|
||||
|
||||
// ATC above OBS facility only
|
||||
if !client.isAtc || client.facilityType <= 0 {
|
||||
client.sendError(InvalidControlError, "Invalid control")
|
||||
return
|
||||
}
|
||||
broadcastRangedAtcOnly(s.postOffice, client, packet)
|
||||
|
||||
// Allow aircraft configuration queries from any client
|
||||
case "ACC":
|
||||
broadcastRanged(s.postOffice, client, packet)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle direct queries to another client
|
||||
switch string(queryType) {
|
||||
|
||||
// General unprivileged queries
|
||||
case "CAPS", "C?", "RN", "ATIS", "ACC":
|
||||
sendDirectOrErr(s.postOffice, client, recipient, packet)
|
||||
|
||||
// Supervisor queries
|
||||
case "SV", "INF":
|
||||
// Allow responses from any client
|
||||
if getPacketType(packet) == PacketTypeClientQueryResponse {
|
||||
sendDirectOrErr(s.postOffice, client, recipient, packet)
|
||||
return
|
||||
}
|
||||
|
||||
// Require >= SUP for interrogations
|
||||
if client.networkRating < NetworkRatingSupervisor {
|
||||
client.sendError(InvalidControlError, "Invalid control")
|
||||
return
|
||||
}
|
||||
sendDirectOrErr(s.postOffice, client, recipient, packet)
|
||||
|
||||
// Force squawk code change. I have no idea if anyone even supports this.
|
||||
case "IPC":
|
||||
if !client.isAtc || client.facilityType <= 0 {
|
||||
client.sendError(InvalidControlError, "Invalid control")
|
||||
return
|
||||
}
|
||||
sendDirectOrErr(s.postOffice, client, recipient, packet)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleClientQueryATCRequest(client *Client, packet []byte) {
|
||||
if countFields(packet) != 4 {
|
||||
client.sendError(SyntaxError, "Invalid ATC request")
|
||||
return
|
||||
}
|
||||
|
||||
targetCallsign := getField(packet, 3)
|
||||
targetClient, err := s.postOffice.find(string(targetCallsign))
|
||||
if err != nil {
|
||||
client.sendError(NoSuchCallsignError, "No such callsign")
|
||||
return
|
||||
}
|
||||
|
||||
var p string
|
||||
if targetClient.facilityType > 0 {
|
||||
p = fmt.Sprintf("$CRSERVER:%s:ATC:Y:%s\r\n", client.callsign, targetCallsign)
|
||||
} else {
|
||||
p = fmt.Sprintf("$CRSERVER:%s:ATC:N:%s\r\n", client.callsign, targetCallsign)
|
||||
}
|
||||
client.send(p)
|
||||
}
|
||||
|
||||
func (s *Server) handleClientQueryIPRequest(client *Client, packet []byte) {
|
||||
ip := strings.SplitN(client.conn.RemoteAddr().String(), ":", 2)[0]
|
||||
p := fmt.Sprintf("$CRSERVER:%s:IP:%s\r\n", client.callsign, ip)
|
||||
client.send(p)
|
||||
}
|
||||
|
||||
func (s *Server) handleClientQueryFlightplanRequest(client *Client, packet []byte) {
|
||||
if !client.isAtc {
|
||||
return
|
||||
}
|
||||
|
||||
if countFields(packet) != 4 {
|
||||
client.sendError(SyntaxError, "Invalid flightplan request syntax")
|
||||
return
|
||||
}
|
||||
|
||||
targetCallsign := string(getField(packet, 3))
|
||||
targetClient, err := s.postOffice.find(targetCallsign)
|
||||
if err != nil {
|
||||
client.sendError(NoSuchCallsignError, "No such callsign: "+targetCallsign)
|
||||
return
|
||||
}
|
||||
|
||||
fplInfo := targetClient.flightPlan.Load()
|
||||
if fplInfo == "" {
|
||||
client.sendError(NoFlightPlanError, "No flightplan for: "+targetCallsign)
|
||||
return
|
||||
}
|
||||
|
||||
beaconCode := targetClient.beaconCode.Load()
|
||||
if beaconCode == "" {
|
||||
beaconCode = "0"
|
||||
}
|
||||
|
||||
// Send flightplan packet
|
||||
fplPacket := buildFileFlightplanPacket(targetCallsign, "*A", fplInfo)
|
||||
client.send(fplPacket)
|
||||
|
||||
// Send assigned beacon code
|
||||
bcPacket := buildBeaconCodePacket("server", client.callsign, targetCallsign, beaconCode)
|
||||
client.send(bcPacket)
|
||||
|
||||
// TODO: research any other data that should be sent here
|
||||
}
|
||||
|
||||
func (s *Server) handleMetarRequest(client *Client, packet []byte) {
|
||||
recipient := getField(packet, 1)
|
||||
staticField := getField(packet, 2)
|
||||
icaoCode := getField(packet, 3)
|
||||
|
||||
if string(recipient) != "SERVER" || string(staticField) != "METAR" {
|
||||
return
|
||||
}
|
||||
|
||||
s.metarService.fetchAndSendMetar(client.ctx, client, string(icaoCode))
|
||||
}
|
||||
|
||||
func (s *Server) handleKillRequest(client *Client, packet []byte) {
|
||||
if client.networkRating < NetworkRatingSupervisor {
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt to find the victim client
|
||||
recipient := getField(packet, 1)
|
||||
victim, err := s.postOffice.find(string(recipient))
|
||||
if err != nil {
|
||||
client.sendError(NoSuchCallsignError, "No such callsign")
|
||||
return
|
||||
}
|
||||
|
||||
// Closing the context of the victim client will eventually cause it to disconnect
|
||||
victim.cancelCtx()
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthChallenge(client *Client, packet []byte) {
|
||||
if client.clientChallenge == "" {
|
||||
client.sendError(UnauthorizedSoftwareError, "Cannot reply to auth challenge since no initial challenge was recieved")
|
||||
return
|
||||
}
|
||||
|
||||
challenge := getField(packet, 2)
|
||||
resp := client.authState.GetResponseForChallenge(challenge)
|
||||
client.authState.UpdateState(&resp)
|
||||
|
||||
respPacket := strings.Builder{}
|
||||
respPacket.WriteString("$ZRSERVER:")
|
||||
respPacket.WriteString(client.callsign)
|
||||
respPacket.WriteByte(':')
|
||||
respPacket.Write(resp[:])
|
||||
respPacket.WriteString("\r\n")
|
||||
|
||||
client.send(respPacket.String())
|
||||
}
|
||||
|
||||
func (s *Server) handleHandoff(client *Client, packet []byte) {
|
||||
// Active >OBS ATC only
|
||||
if !client.isAtc || client.facilityType <= 1 {
|
||||
return
|
||||
}
|
||||
|
||||
recipient := getField(packet, 1)
|
||||
sendDirectOrErr(s.postOffice, client, recipient, packet)
|
||||
}
|
||||
|
||||
func (s *Server) handleFileFlightplan(client *Client, packet []byte) {
|
||||
fplInfo := extractFlightplanInfoSection(packet)
|
||||
client.flightPlan.Store(fplInfo)
|
||||
|
||||
broadcastPacket := buildFileFlightplanPacket(client.callsign, "*A", fplInfo)
|
||||
broadcastAllATC(s.postOffice, client, []byte(broadcastPacket))
|
||||
}
|
||||
|
||||
func (s *Server) handleAmendFlightplan(client *Client, packet []byte) {
|
||||
if !client.isAtc || client.facilityType <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
fplInfo := extractFlightplanInfoSection(packet)
|
||||
|
||||
targetCallsign := string(getField(packet, 2))
|
||||
targetClient, err := s.postOffice.find(targetCallsign)
|
||||
if err != nil {
|
||||
client.sendError(NoSuchCallsignError, "No such callsign: "+targetCallsign)
|
||||
return
|
||||
}
|
||||
targetClient.flightPlan.Store(fplInfo)
|
||||
|
||||
broadcastPacket := buildAmendFlightplanPacket(client.callsign, "*A", targetCallsign, fplInfo)
|
||||
broadcastAllATC(s.postOffice, client, []byte(broadcastPacket))
|
||||
}
|
||||
72
fsd/jwt.go
Normal file
72
fsd/jwt.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package fsd
|
||||
|
||||
import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
const issuer = "openfsd"
|
||||
|
||||
type JwtToken struct {
|
||||
*jwt.Token
|
||||
}
|
||||
|
||||
type CustomClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
CustomFields
|
||||
}
|
||||
|
||||
type CustomFields struct {
|
||||
TokenType string `json:"token_type"`
|
||||
CID int `json:"cid"`
|
||||
FirstName string `json:"first_name,omitempty"`
|
||||
LastName string `json:"last_name,omitempty"`
|
||||
NetworkRating NetworkRating `json:"network_rating"`
|
||||
}
|
||||
|
||||
func (t *JwtToken) CustomClaims() *CustomClaims {
|
||||
return t.Claims.(*CustomClaims)
|
||||
}
|
||||
|
||||
func MakeJwtToken(customFields *CustomFields, validityDuration time.Duration) (token *jwt.Token, err error) {
|
||||
// Generate random ID
|
||||
id, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := &CustomClaims{
|
||||
jwt.RegisteredClaims{
|
||||
Issuer: issuer,
|
||||
ExpiresAt: &jwt.NumericDate{Time: now.Add(validityDuration)},
|
||||
NotBefore: &jwt.NumericDate{Time: now.Add(-30 * time.Second)},
|
||||
IssuedAt: &jwt.NumericDate{Time: now},
|
||||
ID: id.String(),
|
||||
},
|
||||
*customFields,
|
||||
}
|
||||
|
||||
token = jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return
|
||||
}
|
||||
|
||||
func ParseJwtToken(rawToken string, secretKey []byte) (token *JwtToken, err error) {
|
||||
var customClaims CustomClaims
|
||||
jwtToken, err := jwt.ParseWithClaims(
|
||||
rawToken,
|
||||
&customClaims, func(_ *jwt.Token) (any, error) {
|
||||
return secretKey, nil
|
||||
},
|
||||
jwt.WithValidMethods([]string{"HS256"}),
|
||||
jwt.WithIssuer(issuer),
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
token = &JwtToken{jwtToken}
|
||||
|
||||
return
|
||||
}
|
||||
122
fsd/metar.go
Normal file
122
fsd/metar.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package fsd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type metarService struct {
|
||||
numWorkers int
|
||||
httpClient *http.Client
|
||||
metarRequests chan metarRequest
|
||||
}
|
||||
|
||||
type metarRequest struct {
|
||||
client *Client
|
||||
icaoCode string
|
||||
}
|
||||
|
||||
func newMetarService(numWorkers int) *metarService {
|
||||
return &metarService{
|
||||
numWorkers: numWorkers,
|
||||
httpClient: &http.Client{},
|
||||
metarRequests: make(chan metarRequest, 128),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *metarService) run(ctx context.Context) {
|
||||
for range s.numWorkers {
|
||||
go s.worker(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *metarService) worker(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case req := <-s.metarRequests:
|
||||
s.handleMetarRequest(&req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *metarService) handleMetarRequest(req *metarRequest) {
|
||||
url := buildMetarRequestURL(req.icaoCode)
|
||||
res, err := s.httpClient.Get(url)
|
||||
if err != nil {
|
||||
sendMetarServiceError(req)
|
||||
return
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
sendMetarServiceError(req)
|
||||
return
|
||||
}
|
||||
|
||||
bufBytes := make([]byte, 4096)
|
||||
buf := bytes.NewBuffer(bufBytes)
|
||||
if _, err = io.Copy(buf, res.Body); err != nil {
|
||||
sendMetarServiceError(req)
|
||||
return
|
||||
}
|
||||
|
||||
resBody := buf.Bytes()
|
||||
|
||||
if bytes.Count(resBody, []byte("\n")) != 2 {
|
||||
fmt.Println("NOAA METAR response was invalid")
|
||||
sendMetarServiceError(req)
|
||||
return
|
||||
}
|
||||
|
||||
// First line is timestamp
|
||||
resBody = resBody[bytes.IndexByte(resBody, '\n')+1:]
|
||||
|
||||
// Second line is METAR and ends with \n
|
||||
resBody = resBody[:bytes.IndexByte(resBody, '\n')+1]
|
||||
|
||||
packet := buildMetarResponsePacket(req.client.callsign, resBody)
|
||||
req.client.send(packet)
|
||||
}
|
||||
|
||||
func buildMetarResponsePacket(callsign string, metar []byte) string {
|
||||
packet := strings.Builder{}
|
||||
packet.WriteString("$ARSERVER:")
|
||||
packet.WriteString(callsign)
|
||||
packet.WriteString(":METAR:")
|
||||
packet.Write(metar)
|
||||
packet.WriteString("\r\n")
|
||||
return packet.String()
|
||||
}
|
||||
|
||||
func buildMetarRequestURL(icaoCode string) string {
|
||||
url := strings.Builder{}
|
||||
url.WriteString("https://tgftp.nws.noaa.gov/data/observations/metar/stations/")
|
||||
url.WriteString(icaoCode)
|
||||
url.WriteString(".TXT")
|
||||
return url.String()
|
||||
}
|
||||
|
||||
func sendMetarServiceError(req *metarRequest) {
|
||||
req.client.sendError(NoWeatherProfileError, metarServiceErrString(req.icaoCode))
|
||||
}
|
||||
|
||||
func metarServiceErrString(icaoCode string) string {
|
||||
msg := strings.Builder{}
|
||||
msg.WriteString("Error fetching METAR for ")
|
||||
msg.WriteString(icaoCode)
|
||||
|
||||
return msg.String()
|
||||
}
|
||||
|
||||
// fetchAndSendMetar fetches a METAR observation for a given ICAO code and sends it to the client once received.
|
||||
// This function returns immediately once the request has been queued.
|
||||
func (s *metarService) fetchAndSendMetar(ctx context.Context, client *Client, icaoCode string) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case s.metarRequests <- metarRequest{client: client, icaoCode: icaoCode}:
|
||||
}
|
||||
}
|
||||
288
fsd/metar_test.go
Normal file
288
fsd/metar_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package fsd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockClient simulates a Client for capturing sent packets.
|
||||
type mockClient struct {
|
||||
*Client
|
||||
sentPackets []string
|
||||
}
|
||||
|
||||
// newMockClient creates a mockClient with a valid sendChan and ctx.
|
||||
func newMockClient(callsign string) *mockClient {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
client := &Client{
|
||||
ctx: ctx,
|
||||
cancelCtx: cancel,
|
||||
sendChan: make(chan string, 32), // Buffered to prevent blocking
|
||||
loginData: loginData{callsign: callsign},
|
||||
}
|
||||
return &mockClient{
|
||||
Client: client,
|
||||
sentPackets: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// send overrides Client's send method to capture packets.
|
||||
func (c *mockClient) send(packet string) error {
|
||||
c.sentPackets = append(c.sentPackets, packet)
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectPackets drains the sendChan and returns all sent packets.
|
||||
func (c *mockClient) collectPackets() []string {
|
||||
packets := append([]string{}, c.sentPackets...)
|
||||
for {
|
||||
select {
|
||||
case packet := <-c.sendChan:
|
||||
packets = append(packets, packet)
|
||||
default:
|
||||
return packets
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mockTransport simulates HTTP responses for testing handleMetarRequest.
|
||||
type mockTransport struct {
|
||||
response *http.Response
|
||||
err error
|
||||
}
|
||||
|
||||
func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.response, t.err
|
||||
}
|
||||
|
||||
// TestBuildMetarRequestURL verifies that buildMetarRequestURL correctly formats URLs for given ICAO codes.
|
||||
func TestBuildMetarRequestURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
icaoCode string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Valid ICAO KJFK",
|
||||
icaoCode: "KJFK",
|
||||
expected: "https://tgftp.nws.noaa.gov/data/observations/metar/stations/KJFK.TXT",
|
||||
},
|
||||
{
|
||||
name: "Valid ICAO EGLL",
|
||||
icaoCode: "EGLL",
|
||||
expected: "https://tgftp.nws.noaa.gov/data/observations/metar/stations/EGLL.TXT",
|
||||
},
|
||||
{
|
||||
name: "Empty ICAO",
|
||||
icaoCode: "",
|
||||
expected: "https://tgftp.nws.noaa.gov/data/observations/metar/stations/.TXT",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildMetarRequestURL(tt.icaoCode)
|
||||
if got != tt.expected {
|
||||
t.Errorf("buildMetarRequestURL(%q) = %q, want %q", tt.icaoCode, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMetarResponsePacket verifies that buildMetarResponsePacket correctly formats METAR response packets.
|
||||
func TestBuildMetarResponsePacket(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
callsign string
|
||||
metar []byte
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Valid METAR for KJFK",
|
||||
callsign: "TEST",
|
||||
metar: []byte("KJFK 301951Z 18010KT 10SM FEW250 29/19 A2992"),
|
||||
expected: "$ARSERVER:TEST:KJFK 301951Z 18010KT 10SM FEW250 29/19 A2992\r\n",
|
||||
},
|
||||
{
|
||||
name: "Valid METAR for EGLL",
|
||||
callsign: "PILOT1",
|
||||
metar: []byte("EGLL 301950Z 24008KT 9999 FEW040 18/12 Q1015"),
|
||||
expected: "$ARSERVER:PILOT1:EGLL 301950Z 24008KT 9999 FEW040 18/12 Q1015\r\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildMetarResponsePacket(tt.callsign, tt.metar)
|
||||
if got != tt.expected {
|
||||
t.Errorf("buildMetarResponsePacket(%q, %q) = %q, want %q", tt.callsign, tt.metar, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendMetarServiceError verifies that sendMetarServiceError sends the correct error packet to the client.
|
||||
func TestSendMetarServiceError(t *testing.T) {
|
||||
mockClient := newMockClient("TEST")
|
||||
req := &metarRequest{
|
||||
client: mockClient.Client,
|
||||
icaoCode: "KJFK",
|
||||
}
|
||||
sendMetarServiceError(req)
|
||||
|
||||
packets := mockClient.collectPackets()
|
||||
expectedPacket := "$ERserver:unknown:9::Error fetching METAR for KJFK\r\n"
|
||||
if len(packets) != 1 {
|
||||
t.Errorf("expected 1 packet sent, got %d", len(packets))
|
||||
} else if packets[0] != expectedPacket {
|
||||
t.Errorf("expected packet %q, got %q", expectedPacket, packets[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMetarRequest_Success verifies that handleMetarRequest correctly processes a valid METAR response.
|
||||
func TestHandleMetarRequest_Success(t *testing.T) {
|
||||
responseBody := []byte("2023/04/30 19:51\nKJFK 301951Z 18010KT 10SM FEW250 29/19 A2992\n")
|
||||
mockResponse := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(responseBody)),
|
||||
}
|
||||
mockTransport := &mockTransport{response: mockResponse}
|
||||
|
||||
service := &metarService{
|
||||
httpClient: &http.Client{Transport: mockTransport},
|
||||
}
|
||||
|
||||
mockClient := newMockClient("TEST")
|
||||
req := &metarRequest{
|
||||
client: mockClient.Client,
|
||||
icaoCode: "KJFK",
|
||||
}
|
||||
|
||||
service.handleMetarRequest(req)
|
||||
|
||||
packets := mockClient.collectPackets()
|
||||
if len(packets) != 1 {
|
||||
t.Errorf("expected 1 packet sent, got %d", len(packets))
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(packets[0], "$ARSERVER:TEST:KJFK ") || !strings.HasSuffix(packets[0], "\r\n") {
|
||||
t.Errorf("bad response packet")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMetarRequest_HTTPError verifies that handleMetarRequest handles HTTP errors correctly.
|
||||
func TestHandleMetarRequest_HTTPError(t *testing.T) {
|
||||
mockResponse := &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Body: io.NopCloser(strings.NewReader("Not Found")),
|
||||
}
|
||||
mockTransport := &mockTransport{response: mockResponse}
|
||||
|
||||
service := &metarService{
|
||||
httpClient: &http.Client{Transport: mockTransport},
|
||||
}
|
||||
|
||||
mockClient := newMockClient("TEST")
|
||||
req := &metarRequest{
|
||||
client: mockClient.Client,
|
||||
icaoCode: "INVALID",
|
||||
}
|
||||
|
||||
service.handleMetarRequest(req)
|
||||
|
||||
packets := mockClient.collectPackets()
|
||||
expectedPacket := "$ERserver:unknown:9::Error fetching METAR for INVALID\r\n"
|
||||
if len(packets) != 1 {
|
||||
t.Errorf("expected 1 packet sent, got %d", len(packets))
|
||||
} else if packets[0] != expectedPacket {
|
||||
t.Errorf("expected packet %q, got %q", expectedPacket, packets[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMetarRequest_NetworkError verifies that handleMetarRequest handles network errors correctly.
|
||||
func TestHandleMetarRequest_NetworkError(t *testing.T) {
|
||||
mockTransport := &mockTransport{err: errors.New("network error")}
|
||||
|
||||
service := &metarService{
|
||||
httpClient: &http.Client{Transport: mockTransport},
|
||||
}
|
||||
|
||||
mockClient := newMockClient("TEST")
|
||||
req := &metarRequest{
|
||||
client: mockClient.Client,
|
||||
icaoCode: "KJFK",
|
||||
}
|
||||
|
||||
service.handleMetarRequest(req)
|
||||
|
||||
packets := mockClient.collectPackets()
|
||||
expectedPacket := "$ERserver:unknown:9::Error fetching METAR for KJFK\r\n"
|
||||
if len(packets) != 1 {
|
||||
t.Errorf("expected 1 packet sent, got %d", len(packets))
|
||||
} else if packets[0] != expectedPacket {
|
||||
t.Errorf("expected packet %q, got %q", expectedPacket, packets[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMetarRequest_InvalidResponse verifies that handleMetarRequest handles responses with invalid formats.
|
||||
func TestHandleMetarRequest_InvalidResponse(t *testing.T) {
|
||||
responseBody := []byte("Invalid response\n")
|
||||
mockResponse := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(responseBody)),
|
||||
}
|
||||
mockTransport := &mockTransport{response: mockResponse}
|
||||
|
||||
service := &metarService{
|
||||
httpClient: &http.Client{Transport: mockTransport},
|
||||
}
|
||||
|
||||
mockClient := newMockClient("TEST")
|
||||
req := &metarRequest{
|
||||
client: mockClient.Client,
|
||||
icaoCode: "KJFK",
|
||||
}
|
||||
|
||||
service.handleMetarRequest(req)
|
||||
|
||||
packets := mockClient.collectPackets()
|
||||
expectedPacket := "$ERserver:unknown:9::Error fetching METAR for KJFK\r\n"
|
||||
if len(packets) != 1 {
|
||||
t.Errorf("expected 1 packet sent, got %d", len(packets))
|
||||
} else if packets[0] != expectedPacket {
|
||||
t.Errorf("expected packet %q, got %q", expectedPacket, packets[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMetarRequest_MoreThanTwoLines verifies that handleMetarRequest handles responses with too many lines.
|
||||
func TestHandleMetarRequest_MoreThanTwoLines(t *testing.T) {
|
||||
responseBody := []byte("Line1\nLine2\nLine3\n")
|
||||
mockResponse := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(responseBody)),
|
||||
}
|
||||
mockTransport := &mockTransport{response: mockResponse}
|
||||
|
||||
service := &metarService{
|
||||
httpClient: &http.Client{Transport: mockTransport},
|
||||
}
|
||||
|
||||
mockClient := newMockClient("TEST")
|
||||
req := &metarRequest{
|
||||
client: mockClient.Client,
|
||||
icaoCode: "KJFK",
|
||||
}
|
||||
|
||||
service.handleMetarRequest(req)
|
||||
|
||||
packets := mockClient.collectPackets()
|
||||
expectedPacket := "$ERserver:unknown:9::Error fetching METAR for KJFK\r\n"
|
||||
if len(packets) != 1 {
|
||||
t.Errorf("expected 1 packet sent, got %d", len(packets))
|
||||
} else if packets[0] != expectedPacket {
|
||||
t.Errorf("expected packet %q, got %q", expectedPacket, packets[0])
|
||||
}
|
||||
}
|
||||
219
fsd/packet.go
Normal file
219
fsd/packet.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package fsd
|
||||
|
||||
import "bytes"
|
||||
|
||||
type PacketType int
|
||||
|
||||
const (
|
||||
PacketTypeUnknown PacketType = iota
|
||||
PacketTypeTextMessage
|
||||
PacketTypePilotPosition
|
||||
PacketTypePilotPositionFast
|
||||
PacketTypePilotPositionSlow
|
||||
PacketTypePilotPositionStopped
|
||||
PacketTypeATCPosition
|
||||
PacketTypeDeleteATC
|
||||
PacketTypeDeletePilot
|
||||
PacketTypeClientQuery
|
||||
PacketTypeClientQueryResponse
|
||||
PacketTypeProController
|
||||
PacketTypeSquawkbox
|
||||
PacketTypeMetarRequest
|
||||
PacketTypeKillRequest
|
||||
PacketTypeAuthChallenge
|
||||
PacketTypeHandoffRequest
|
||||
PacketTypeHandoffAccept
|
||||
PacketTypeFlightPlan
|
||||
PacketTypeFlightPlanAmendment
|
||||
)
|
||||
|
||||
// sourceCallsignFieldIndex returns the index of the field containing the source callsign
|
||||
func sourceCallsignFieldIndex(packetType PacketType) (index int) {
|
||||
switch packetType {
|
||||
case PacketTypePilotPosition:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// getPacketType parses the packet type given a packet
|
||||
func getPacketType(packet []byte) PacketType {
|
||||
switch packet[0] {
|
||||
case '^':
|
||||
return PacketTypePilotPositionFast
|
||||
case '@':
|
||||
return PacketTypePilotPosition
|
||||
case '%':
|
||||
return PacketTypeATCPosition
|
||||
case '#':
|
||||
switch string(packet[:3]) {
|
||||
case "#DA":
|
||||
return PacketTypeDeleteATC
|
||||
case "#DP":
|
||||
return PacketTypeDeletePilot
|
||||
case "#TM":
|
||||
return PacketTypeTextMessage
|
||||
case "#SL":
|
||||
return PacketTypePilotPositionSlow
|
||||
case "#ST":
|
||||
return PacketTypePilotPositionStopped
|
||||
case "#PC":
|
||||
return PacketTypeProController
|
||||
case "#SB":
|
||||
return PacketTypeSquawkbox
|
||||
default:
|
||||
return PacketTypeUnknown
|
||||
}
|
||||
case '$':
|
||||
switch string(packet[:3]) {
|
||||
case "$CQ":
|
||||
return PacketTypeClientQuery
|
||||
case "$CR":
|
||||
return PacketTypeClientQueryResponse
|
||||
case "$AX":
|
||||
return PacketTypeMetarRequest
|
||||
case "$!!":
|
||||
return PacketTypeKillRequest
|
||||
case "$ZC":
|
||||
return PacketTypeAuthChallenge
|
||||
case "$HO":
|
||||
return PacketTypeHandoffRequest
|
||||
case "$HA":
|
||||
return PacketTypeHandoffAccept
|
||||
case "$FP":
|
||||
return PacketTypeFlightPlan
|
||||
case "$AM":
|
||||
return PacketTypeFlightPlanAmendment
|
||||
default:
|
||||
return PacketTypeUnknown
|
||||
}
|
||||
default:
|
||||
return PacketTypeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func getPacketPrefix(packetType PacketType) string {
|
||||
switch packetType {
|
||||
case PacketTypePilotPositionFast:
|
||||
return "^"
|
||||
case PacketTypePilotPosition:
|
||||
return "@"
|
||||
case PacketTypeATCPosition:
|
||||
return "%"
|
||||
case PacketTypeDeleteATC:
|
||||
return "#DA"
|
||||
case PacketTypeDeletePilot:
|
||||
return "#DP"
|
||||
case PacketTypeTextMessage:
|
||||
return "#TM"
|
||||
case PacketTypePilotPositionSlow:
|
||||
return "#SL"
|
||||
case PacketTypePilotPositionStopped:
|
||||
return "#ST"
|
||||
case PacketTypeProController:
|
||||
return "#PC"
|
||||
case PacketTypeSquawkbox:
|
||||
return "#SB"
|
||||
case PacketTypeClientQuery:
|
||||
return "$CQ"
|
||||
case PacketTypeClientQueryResponse:
|
||||
return "$CR"
|
||||
case PacketTypeMetarRequest:
|
||||
return "$AX"
|
||||
case PacketTypeKillRequest:
|
||||
return "$!!"
|
||||
case PacketTypeAuthChallenge:
|
||||
return "$ZC"
|
||||
case PacketTypeHandoffRequest:
|
||||
return "$HO"
|
||||
case PacketTypeHandoffAccept:
|
||||
return "$HA"
|
||||
case PacketTypeFlightPlan:
|
||||
return "$FP"
|
||||
case PacketTypeFlightPlanAmendment:
|
||||
return "$AM"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func minFields(packetType PacketType) int {
|
||||
switch packetType {
|
||||
case PacketTypePilotPosition:
|
||||
return 9
|
||||
case PacketTypePilotPositionFast, PacketTypePilotPositionSlow, PacketTypePilotPositionStopped:
|
||||
return 7
|
||||
case PacketTypeATCPosition:
|
||||
return 7
|
||||
case PacketTypeDeleteATC, PacketTypeDeletePilot:
|
||||
return 2
|
||||
case PacketTypeTextMessage:
|
||||
return 3
|
||||
case PacketTypeProController:
|
||||
return 4
|
||||
case PacketTypeSquawkbox:
|
||||
return 3
|
||||
case PacketTypeClientQuery:
|
||||
return 3
|
||||
case PacketTypeClientQueryResponse:
|
||||
return 3
|
||||
case PacketTypeMetarRequest:
|
||||
return 4
|
||||
case PacketTypeKillRequest:
|
||||
return 3
|
||||
case PacketTypeAuthChallenge:
|
||||
return 3
|
||||
case PacketTypeHandoffRequest, PacketTypeHandoffAccept:
|
||||
return 3
|
||||
case PacketTypeFlightPlan:
|
||||
return 17
|
||||
case PacketTypeFlightPlanAmendment:
|
||||
return 18
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
type handlerFunc func(client *Client, packet []byte)
|
||||
|
||||
func getSourceCallsign(packet []byte, packetType PacketType) []byte {
|
||||
callsign, _ := bytes.CutPrefix(
|
||||
getField(packet, sourceCallsignFieldIndex(packetType)),
|
||||
[]byte(getPacketPrefix(packetType)),
|
||||
)
|
||||
return callsign
|
||||
}
|
||||
|
||||
func verifySourceCallsign(packet []byte, packetType PacketType, callsign string) bool {
|
||||
sourceCallsign := getSourceCallsign(packet, packetType)
|
||||
return string(sourceCallsign) == callsign
|
||||
}
|
||||
|
||||
// verifyPacket runs a set of sanity checks against a packet sent by a client and returns the detected packet type
|
||||
func verifyPacket(packet []byte, client *Client) (packetType PacketType, ok bool) {
|
||||
numFields := countFields(packet)
|
||||
if len(packet) < 8 || numFields < 3 {
|
||||
client.sendError(SyntaxError, "Packet too short")
|
||||
return
|
||||
}
|
||||
|
||||
packetType = getPacketType(packet)
|
||||
if packetType == PacketTypeUnknown {
|
||||
client.sendError(SyntaxError, "Unknown packet type")
|
||||
return
|
||||
}
|
||||
|
||||
if !verifySourceCallsign(packet, packetType, client.callsign) {
|
||||
client.sendError(SourceInvalidError, "Source invalid")
|
||||
return
|
||||
}
|
||||
|
||||
if numFields < minFields(packetType) {
|
||||
client.sendError(SyntaxError, "Minimum field count requirement not satisfied")
|
||||
return
|
||||
}
|
||||
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
180
fsd/postoffice.go
Normal file
180
fsd/postoffice.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package fsd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/tidwall/rtree"
|
||||
"math"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type postOffice struct {
|
||||
clientMap map[string]*Client // Callsign -> *Client
|
||||
clientMapLock *sync.RWMutex
|
||||
|
||||
tree *rtree.RTreeG[*Client] // Geospatial rtree
|
||||
treeLock *sync.RWMutex
|
||||
}
|
||||
|
||||
func newPostOffice() *postOffice {
|
||||
return &postOffice{
|
||||
clientMap: make(map[string]*Client, 128),
|
||||
clientMapLock: &sync.RWMutex{},
|
||||
tree: &rtree.RTreeG[*Client]{},
|
||||
treeLock: &sync.RWMutex{},
|
||||
}
|
||||
}
|
||||
|
||||
var ErrCallsignInUse = errors.New("callsign in use")
|
||||
var ErrCallsignDoesNotExist = errors.New("callsign does not exist")
|
||||
|
||||
// register adds a new Client to the post office. Returns ErrCallsignInUse when the callsign is taken.
|
||||
func (p *postOffice) register(client *Client) (err error) {
|
||||
p.clientMapLock.Lock()
|
||||
if _, exists := p.clientMap[client.callsign]; exists {
|
||||
p.clientMapLock.Unlock()
|
||||
err = ErrCallsignInUse
|
||||
return
|
||||
}
|
||||
p.clientMap[client.callsign] = client
|
||||
p.clientMapLock.Unlock()
|
||||
|
||||
// Insert into R-tree
|
||||
clientMin, clientMax := calculateBoundingBox(client.latLon, client.visRange)
|
||||
p.treeLock.Lock()
|
||||
p.tree.Insert(clientMin, clientMax, client)
|
||||
p.treeLock.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// release removes a Client from the post office.
|
||||
func (p *postOffice) release(client *Client) {
|
||||
clientMin, clientMax := calculateBoundingBox(client.latLon, client.visRange)
|
||||
|
||||
p.treeLock.Lock()
|
||||
p.tree.Delete(clientMin, clientMax, client)
|
||||
p.treeLock.Unlock()
|
||||
|
||||
p.clientMapLock.Lock()
|
||||
delete(p.clientMap, client.callsign)
|
||||
p.clientMapLock.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// updatePosition updates the geospatial position of a Client.
|
||||
// The referenced client's latLon and visRange are rewritten.
|
||||
func (p *postOffice) updatePosition(client *Client, newCenter [2]float64, newVisRange float64) {
|
||||
oldMin, oldMax := calculateBoundingBox(client.latLon, client.visRange)
|
||||
newMin, newMax := calculateBoundingBox(newCenter, newVisRange)
|
||||
|
||||
client.latLon = newCenter
|
||||
client.visRange = newVisRange
|
||||
|
||||
// Avoid redundant updates
|
||||
if oldMin == newMin && oldMax == newMax {
|
||||
return
|
||||
}
|
||||
|
||||
p.treeLock.Lock()
|
||||
p.tree.Delete(oldMin, oldMax, client)
|
||||
p.tree.Insert(newMin, newMax, client)
|
||||
p.treeLock.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// search calls `callback` for every other Client within geographical range of the provided Client
|
||||
func (p *postOffice) search(client *Client, callback func(recipient *Client) bool) {
|
||||
clientMin, clientMax := calculateBoundingBox(client.latLon, client.visRange)
|
||||
|
||||
p.treeLock.RLock()
|
||||
p.tree.Search(clientMin, clientMax, func(foundMin [2]float64, foundMax [2]float64, foundClient *Client) bool {
|
||||
if foundClient == client {
|
||||
return true // Ignore self
|
||||
}
|
||||
return callback(foundClient)
|
||||
})
|
||||
p.treeLock.RUnlock()
|
||||
}
|
||||
|
||||
// send sends a packet to a client with a given callsign.
|
||||
//
|
||||
// Returns ErrCallsignDoesNotExist if the callsign does not exist.
|
||||
func (p *postOffice) send(callsign string, packet string) (err error) {
|
||||
p.clientMapLock.RLock()
|
||||
client, exists := p.clientMap[callsign]
|
||||
p.clientMapLock.RUnlock()
|
||||
|
||||
if !exists {
|
||||
err = ErrCallsignDoesNotExist
|
||||
return
|
||||
}
|
||||
|
||||
return client.send(packet)
|
||||
}
|
||||
|
||||
// find finds a Client with a given callsign.
|
||||
//
|
||||
// Returns ErrCallsignDoesNotExist if the callsign does not exist.
|
||||
func (p *postOffice) find(callsign string) (client *Client, err error) {
|
||||
p.clientMapLock.RLock()
|
||||
client, exists := p.clientMap[callsign]
|
||||
p.clientMapLock.RUnlock()
|
||||
|
||||
if !exists {
|
||||
err = ErrCallsignDoesNotExist
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// all calls `callback` for every single client registered to the post office.
|
||||
func (p *postOffice) all(client *Client, callback func(recipient *Client) bool) {
|
||||
p.clientMapLock.RLock()
|
||||
for _, recipient := range p.clientMap {
|
||||
if recipient == client {
|
||||
continue
|
||||
}
|
||||
if !callback(recipient) {
|
||||
break
|
||||
}
|
||||
}
|
||||
p.clientMapLock.RUnlock()
|
||||
}
|
||||
|
||||
const earthRadius = 6371000.0 // meters, approximate mean radius of Earth
|
||||
|
||||
func calculateBoundingBox(center [2]float64, radius float64) (min [2]float64, max [2]float64) {
|
||||
latRad := center[0] * math.Pi / 180
|
||||
metersPerDegreeLat := (math.Pi * earthRadius) / 180
|
||||
deltaLat := radius / metersPerDegreeLat
|
||||
metersPerDegreeLon := metersPerDegreeLat * math.Cos(latRad)
|
||||
deltaLon := radius / metersPerDegreeLon
|
||||
|
||||
minLat := center[0] - deltaLat
|
||||
maxLat := center[0] + deltaLat
|
||||
minLon := center[1] - deltaLon
|
||||
maxLon := center[1] + deltaLon
|
||||
|
||||
min = [2]float64{minLat, minLon}
|
||||
max = [2]float64{maxLat, maxLon}
|
||||
|
||||
return min, max
|
||||
}
|
||||
|
||||
// distance calculates the great-circle distance between two points using the Haversine formula.
|
||||
func distance(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
lat1Rad := lat1 * (math.Pi / 180)
|
||||
lon1Rad := lon1 * (math.Pi / 180)
|
||||
lat2Rad := lat2 * (math.Pi / 180)
|
||||
lon2Rad := lon2 * (math.Pi / 180)
|
||||
|
||||
dLat := lat2Rad - lat1Rad
|
||||
dLon := lon2Rad - lon1Rad
|
||||
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
|
||||
return earthRadius * c
|
||||
}
|
||||
324
fsd/postoffice_test.go
Normal file
324
fsd/postoffice_test.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package fsd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
p := newPostOffice()
|
||||
client1 := &Client{loginData: loginData{callsign: "client1"}, latLon: [2]float64{0, 0}, visRange: 100000}
|
||||
err := p.register(client1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
if p.clientMap["client1"] != client1 {
|
||||
t.Errorf("expected client1 in map")
|
||||
}
|
||||
client2 := &Client{loginData: loginData{callsign: "client1"}, latLon: [2]float64{0, 0}, visRange: 100000}
|
||||
err = p.register(client2)
|
||||
if err != ErrCallsignInUse {
|
||||
t.Errorf("expected ErrCallsignInUse, got %v", err)
|
||||
}
|
||||
if p.clientMap["client1"] != client1 {
|
||||
t.Errorf("expected original client1 in map")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelease(t *testing.T) {
|
||||
p := newPostOffice()
|
||||
client1 := &Client{
|
||||
loginData: loginData{callsign: "client1"},
|
||||
latLon: [2]float64{0, 0},
|
||||
visRange: 100000,
|
||||
}
|
||||
err := p.register(client1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client2 := &Client{
|
||||
loginData: loginData{callsign: "client2"},
|
||||
latLon: [2]float64{0, 0},
|
||||
visRange: 200000,
|
||||
}
|
||||
err = p.register(client2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var found []*Client
|
||||
p.search(client2, func(recipient *Client) bool {
|
||||
found = append(found, recipient)
|
||||
return true
|
||||
})
|
||||
if len(found) != 1 || found[0] != client1 {
|
||||
t.Errorf("expected to find client1, got %v", found)
|
||||
}
|
||||
|
||||
p.release(client1)
|
||||
_, exists := p.clientMap["client1"]
|
||||
if exists {
|
||||
t.Errorf("expected client1 to be removed from map")
|
||||
}
|
||||
|
||||
found = nil
|
||||
p.search(client2, func(recipient *Client) bool {
|
||||
found = append(found, recipient)
|
||||
return true
|
||||
})
|
||||
if len(found) != 0 {
|
||||
t.Errorf("expected no clients found after release, got %v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePosition(t *testing.T) {
|
||||
p := newPostOffice()
|
||||
client1 := &Client{
|
||||
loginData: loginData{callsign: "client1"},
|
||||
latLon: [2]float64{0, 0},
|
||||
visRange: 100000,
|
||||
}
|
||||
err := p.register(client1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client2 := &Client{
|
||||
loginData: loginData{callsign: "client2"},
|
||||
latLon: [2]float64{0.5, 0.5},
|
||||
visRange: 100000,
|
||||
}
|
||||
err = p.register(client2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var found []*Client
|
||||
p.search(client1, func(recipient *Client) bool {
|
||||
found = append(found, recipient)
|
||||
return true
|
||||
})
|
||||
if len(found) != 1 || found[0] != client2 {
|
||||
t.Errorf("expected to find client2, got %v", found)
|
||||
}
|
||||
|
||||
newLatLon := [2]float64{100, 100}
|
||||
newVisRange := 100000.0
|
||||
p.updatePosition(client2, newLatLon, newVisRange)
|
||||
|
||||
found = nil
|
||||
p.search(client1, func(recipient *Client) bool {
|
||||
found = append(found, recipient)
|
||||
return true
|
||||
})
|
||||
if len(found) != 0 {
|
||||
t.Errorf("expected no clients found after position update, got %v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
p := newPostOffice()
|
||||
client1 := &Client{
|
||||
loginData: loginData{callsign: "client1"},
|
||||
latLon: [2]float64{32.0, -117.0},
|
||||
visRange: 100000,
|
||||
}
|
||||
err := p.register(client1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client2 := &Client{
|
||||
loginData: loginData{callsign: "client2"},
|
||||
latLon: [2]float64{33.0, -117.0},
|
||||
visRange: 50000,
|
||||
}
|
||||
err = p.register(client2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client3 := &Client{
|
||||
loginData: loginData{callsign: "client3"},
|
||||
latLon: [2]float64{34.0, -117.0},
|
||||
visRange: 50000,
|
||||
}
|
||||
err = p.register(client3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var found []*Client
|
||||
p.search(client1, func(recipient *Client) bool {
|
||||
found = append(found, recipient)
|
||||
return true
|
||||
})
|
||||
if len(found) != 1 || found[0].callsign != "client2" {
|
||||
t.Errorf("expected to find client2, got %v", found)
|
||||
}
|
||||
|
||||
found = nil
|
||||
p.search(client2, func(recipient *Client) bool {
|
||||
found = append(found, recipient)
|
||||
return true
|
||||
})
|
||||
if len(found) != 1 || found[0].callsign != "client1" {
|
||||
t.Errorf("expected to find client1, got %v", found)
|
||||
}
|
||||
|
||||
found = nil
|
||||
p.search(client3, func(recipient *Client) bool {
|
||||
found = append(found, recipient)
|
||||
return true
|
||||
})
|
||||
if len(found) != 0 {
|
||||
t.Errorf("expected no clients found, got %v", found)
|
||||
}
|
||||
|
||||
client4 := &Client{
|
||||
loginData: loginData{callsign: "client4"},
|
||||
latLon: [2]float64{31.0, -117.0},
|
||||
visRange: 50000,
|
||||
}
|
||||
err = p.register(client4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
found = nil
|
||||
p.search(client1, func(recipient *Client) bool {
|
||||
found = append(found, recipient)
|
||||
return true
|
||||
})
|
||||
foundCallsigns := make([]string, len(found))
|
||||
for i, c := range found {
|
||||
foundCallsigns[i] = c.callsign
|
||||
}
|
||||
sort.Strings(foundCallsigns)
|
||||
expected := []string{"client2", "client4"}
|
||||
sort.Strings(expected)
|
||||
if !reflect.DeepEqual(foundCallsigns, expected) {
|
||||
t.Errorf("expected %v, got %v", expected, foundCallsigns)
|
||||
}
|
||||
|
||||
for _, c := range found {
|
||||
if c == client1 {
|
||||
t.Errorf("search included self")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBoundingBox(t *testing.T) {
|
||||
const earthRadius = 6371000.0
|
||||
tests := []struct {
|
||||
name string
|
||||
center [2]float64
|
||||
radius float64
|
||||
expectedMin [2]float64
|
||||
expectedMax [2]float64
|
||||
}{
|
||||
{
|
||||
name: "equator",
|
||||
center: [2]float64{0, 0},
|
||||
radius: 100000,
|
||||
expectedMin: [2]float64{-0.8993216059187304, -0.8993216059187304},
|
||||
expectedMax: [2]float64{0.8993216059187304, 0.8993216059187304},
|
||||
},
|
||||
{
|
||||
name: "45 degrees latitude",
|
||||
center: [2]float64{45, 0},
|
||||
radius: 100000,
|
||||
expectedMin: [2]float64{44.10067839408127, -1.2718328120254205},
|
||||
expectedMax: [2]float64{45.89932160591873, 1.2718328120254205},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
min, max := calculateBoundingBox(tt.center, tt.radius)
|
||||
if !approxEqual(min[0], tt.expectedMin[0]) || !approxEqual(min[1], tt.expectedMin[1]) {
|
||||
t.Errorf("min mismatch: got %v, expected %v", min, tt.expectedMin)
|
||||
}
|
||||
if !approxEqual(max[0], tt.expectedMax[0]) || !approxEqual(max[1], tt.expectedMax[1]) {
|
||||
t.Errorf("max mismatch: got %v, expected %v", max, tt.expectedMax)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func approxEqual(a, b float64) bool {
|
||||
const epsilon = 1e-6
|
||||
return math.Abs(a-b) < epsilon
|
||||
}
|
||||
|
||||
// BenchmarkDistance measures the performance of the distance function using pre-generated pseudo-random coordinates.
|
||||
func BenchmarkDistance(b *testing.B) {
|
||||
const numPairs = 1000
|
||||
lats1 := make([]float64, numPairs)
|
||||
lons1 := make([]float64, numPairs)
|
||||
lats2 := make([]float64, numPairs)
|
||||
lons2 := make([]float64, numPairs)
|
||||
|
||||
// Seed for reproducible results
|
||||
rand.Seed(42)
|
||||
|
||||
// Pre-generate random latitude and longitude pairs
|
||||
for i := 0; i < numPairs; i++ {
|
||||
lats1[i] = -90 + rand.Float64()*180 // Latitude: -90 to 90
|
||||
lons1[i] = -180 + rand.Float64()*360 // Longitude: -180 to 180
|
||||
lats2[i] = -90 + rand.Float64()*180 // Latitude: -90 to 90
|
||||
lons2[i] = -180 + rand.Float64()*360 // Longitude: -180 to 180
|
||||
}
|
||||
|
||||
// Reset timer to exclude setup time from measurement
|
||||
b.ResetTimer()
|
||||
|
||||
// Run the benchmark loop
|
||||
for i := 0; i < b.N; i++ {
|
||||
idx := i % numPairs
|
||||
_ = distance(lats1[idx], lons1[idx], lats2[idx], lons2[idx])
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkSearchWithN(b *testing.B, n int) {
|
||||
// Create postOffice
|
||||
p := newPostOffice()
|
||||
|
||||
// Create n clients
|
||||
clients := make([]*Client, n)
|
||||
for i := 0; i < n; i++ {
|
||||
clients[i] = &Client{
|
||||
loginData: loginData{callsign: fmt.Sprintf("Client%d", i)},
|
||||
latLon: [2]float64{rand.Float64(), rand.Float64()},
|
||||
visRange: 10000,
|
||||
}
|
||||
p.register(clients[i])
|
||||
}
|
||||
|
||||
// Define callback
|
||||
callback := func(recipient *Client) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Report allocations
|
||||
b.ReportAllocs()
|
||||
|
||||
// Reset timer
|
||||
b.ResetTimer()
|
||||
|
||||
// Run the benchmark loop
|
||||
for i := 0; i < b.N; i++ {
|
||||
searchClient := clients[i%10]
|
||||
p.search(searchClient, callback)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSearch(b *testing.B) {
|
||||
rand.Seed(42)
|
||||
for _, n := range []int{100, 1000, 10000} {
|
||||
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
|
||||
benchmarkSearchWithN(b, n)
|
||||
})
|
||||
}
|
||||
}
|
||||
96
fsd/server.go
Normal file
96
fsd/server.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package fsd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/renorris/openfsd/db"
|
||||
"net"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
listenAddrs []string
|
||||
jwtSecret []byte
|
||||
postOffice *postOffice
|
||||
metarService *metarService
|
||||
dbRepo *db.Repositories
|
||||
}
|
||||
|
||||
func NewServer(listenAddrs []string, jwtSecret []byte, dbRepo *db.Repositories) (server *Server, err error) {
|
||||
server = &Server{
|
||||
listenAddrs: listenAddrs,
|
||||
jwtSecret: jwtSecret,
|
||||
postOffice: newPostOffice(),
|
||||
metarService: newMetarService(4),
|
||||
dbRepo: dbRepo,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) Run(ctx context.Context) (err error) {
|
||||
// Start metar service
|
||||
go s.metarService.run(ctx)
|
||||
|
||||
errCh := make(chan error, len(s.listenAddrs))
|
||||
var listenerWg sync.WaitGroup
|
||||
|
||||
for _, addr := range s.listenAddrs {
|
||||
listenerWg.Add(1)
|
||||
go func(ctx context.Context, addr string) {
|
||||
defer listenerWg.Done()
|
||||
s.listen(ctx, addr, errCh)
|
||||
}(ctx, addr)
|
||||
}
|
||||
|
||||
// Collect startup errors
|
||||
go func() {
|
||||
listenerWg.Wait()
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
var startupErrors []error
|
||||
for err := range errCh {
|
||||
startupErrors = append(startupErrors, err)
|
||||
}
|
||||
|
||||
if len(startupErrors) > 0 {
|
||||
return fmt.Errorf("some listeners failed: %v", startupErrors)
|
||||
}
|
||||
|
||||
// All listeners started successfully; wait for context to be cancelled
|
||||
<-ctx.Done()
|
||||
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func (s *Server) listen(ctx context.Context, addr string, errCh chan<- error) {
|
||||
config := net.ListenConfig{}
|
||||
listener, err := config.Listen(ctx, "tcp4", addr)
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("failed to listen on %s: %w", addr, err)
|
||||
return
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
// Start a goroutine to close the listener when the context is cancelled
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
listener.Close()
|
||||
}()
|
||||
|
||||
// Accept connections in a loop
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
// Listener was closed due to context cancellation; exit the loop
|
||||
return
|
||||
}
|
||||
// Log or handle non-fatal accept errors
|
||||
continue
|
||||
}
|
||||
// Handle the connection in another goroutine
|
||||
go s.handleConn(ctx, conn)
|
||||
}
|
||||
}
|
||||
354
fsd/util.go
Normal file
354
fsd/util.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package fsd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FSD error codes
|
||||
const (
|
||||
CallsignInUseError = 1 // Callsign is already in use
|
||||
CallsignInvalidError = 2 // Callsign is invalid
|
||||
AlreadyRegisteredError = 3 // Client is already registered
|
||||
SyntaxError = 4 // Packet syntax is invalid
|
||||
SourceInvalidError = 5 // Packet source is invalid
|
||||
InvalidLogonError = 6 // Login credentials or token are invalid
|
||||
NoSuchCallsignError = 7 // Specified callsign does not exist
|
||||
NoFlightPlanError = 8 // No flight plan found for the Client
|
||||
NoWeatherProfileError = 9 // No weather profile available
|
||||
InvalidProtocolRevisionError = 10 // Client uses an unsupported protocol version
|
||||
RequestedLevelTooHighError = 11 // Requested access level is too high
|
||||
ServerFullError = 12 // Server has reached capacity
|
||||
CertificateSuspendedError = 13 // Client's certificate is suspended
|
||||
InvalidControlError = 14 // Invalid control command
|
||||
InvalidPositionForRatingError = 15 // Position not allowed for Client's rating
|
||||
UnauthorizedSoftwareError = 16 // Client software is not authorized
|
||||
ClientAuthenticationResponseTimeoutError = 17 // Authentication response timed out
|
||||
)
|
||||
|
||||
// FSD Network Ratings
|
||||
|
||||
type NetworkRating int
|
||||
|
||||
const (
|
||||
NetworkRatingInactive NetworkRating = iota - 1
|
||||
NetworkRatingSuspended
|
||||
NetworkRatingObserver
|
||||
NetworkRatingStudent1
|
||||
NetworkRatingStudent2
|
||||
NetworkRatingStudent3
|
||||
NetworkRatingController1
|
||||
NetworkRatingController2
|
||||
NetworkRatingController3
|
||||
NetworkRatingInstructor1
|
||||
NetworkRatingInstructor2
|
||||
NetworkRatingInstructor3
|
||||
NetworkRatingSupervisor
|
||||
NetworkRatingAdministator
|
||||
)
|
||||
|
||||
func countFields(packet []byte) int {
|
||||
return bytes.Count(packet, []byte(":")) + 1
|
||||
}
|
||||
|
||||
func rebaseToNextField(packet []byte) []byte {
|
||||
return packet[bytes.IndexByte(packet, ':')+1:]
|
||||
}
|
||||
|
||||
func getField(packet []byte, index int) []byte {
|
||||
for range index {
|
||||
packet = rebaseToNextField(packet)
|
||||
}
|
||||
|
||||
if i := bytes.IndexByte(packet, ':'); i != -1 {
|
||||
packet = packet[:i]
|
||||
}
|
||||
|
||||
packet, _ = bytes.CutSuffix(packet, []byte("\r\n"))
|
||||
|
||||
return packet
|
||||
}
|
||||
|
||||
// mostLikelyJwt returns whether a given byte slice is most likely a JWT token
|
||||
func mostLikelyJwt(token []byte) bool {
|
||||
tmp := token
|
||||
dotCount := 0
|
||||
for {
|
||||
if i := bytes.IndexByte(tmp, '.'); i > -1 {
|
||||
tmp = tmp[i+1:]
|
||||
dotCount++
|
||||
if dotCount > 2 {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if dotCount != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
rawJwtHeader := token[:bytes.IndexByte(token, '.')]
|
||||
|
||||
buf := make([]byte, 0, 256)
|
||||
buf, err := base64.StdEncoding.AppendDecode(buf, rawJwtHeader)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
type jwtHeader struct {
|
||||
Alg string `json:"alg"`
|
||||
Typ string `json:"typ"`
|
||||
}
|
||||
|
||||
header := jwtHeader{}
|
||||
if err = json.Unmarshal(buf, &header); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if header.Alg == "" || header.Typ == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isValidCallsignLength(callsign []byte) bool {
|
||||
return len(callsign) <= 10 && len(callsign) >= 2
|
||||
}
|
||||
|
||||
var reservedCallsigns = []string{
|
||||
"SERVER",
|
||||
"CLIENT",
|
||||
"FP",
|
||||
}
|
||||
|
||||
func isValidClientCallsign(callsign []byte) bool {
|
||||
if !isValidCallsignLength(callsign) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Only uppercase alphanumeric characters and/or hyphen/underscores
|
||||
for i := range callsign {
|
||||
b := callsign[i]
|
||||
if (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b == '-' || b == '_') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Check against reserved callsigns
|
||||
if slices.Contains(reservedCallsigns, string(callsign)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// isAllowedFacilityType checks if a given network rating is allowed to connect as a given facility type.
|
||||
func isAllowedFacilityType(rating NetworkRating, facilityType int) bool {
|
||||
// Observer facility type is allowed for all ratings
|
||||
if facilityType == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Map of facility types to minimum required rating protocol values
|
||||
minRating := map[int]NetworkRating{
|
||||
1: NetworkRatingController1, // Flight Service Station (FSS) - C1 and above
|
||||
2: NetworkRatingStudent1, // Delivery (DEL) - S1 and above
|
||||
3: NetworkRatingStudent1, // Ground (GND) - S1 and above
|
||||
4: NetworkRatingStudent2, // Tower (TWR) - S2 and above
|
||||
5: NetworkRatingStudent3, // Approach (APP) - S3 and above
|
||||
6: NetworkRatingController1, // Centre (CTR) - C1 and above
|
||||
}[facilityType]
|
||||
|
||||
// Return false for invalid facility types
|
||||
if minRating == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the rating meets the minimum requirement
|
||||
return rating >= minRating
|
||||
}
|
||||
|
||||
// parseLatLon extracts two base-10-encoded float64 values from a packet at the specified field indices
|
||||
func parseLatLon(packet []byte, latIndex, lonIndex int) (lat float64, lon float64, ok bool) {
|
||||
rawLat := getField(packet, latIndex)
|
||||
rawLon := getField(packet, lonIndex)
|
||||
lat, err := strconv.ParseFloat(string(rawLat), 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
lon, err = strconv.ParseFloat(string(rawLon), 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
// parseVisRange parses an FSD-encoded visibility range and returns the distance in meters
|
||||
func parseVisRange(packet []byte, index int) (visRange float64, ok bool) {
|
||||
visRangeNauticalMiles, err := strconv.ParseFloat(string(getField(packet, index)), 10)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to meters
|
||||
visRange = visRangeNauticalMiles * 1852.0
|
||||
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
// broadcastRanged broadcasts a packet to all clients in range
|
||||
func broadcastRanged(po *postOffice, client *Client, packet []byte) {
|
||||
packetStr := string(packet)
|
||||
po.search(client, func(recipient *Client) bool {
|
||||
recipient.send(packetStr)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// broadcastRangedVelocity broadcasts a packet to all clients in range
|
||||
// supporting the Vatsim2022 (101) protocol revision.
|
||||
func broadcastRangedVelocity(po *postOffice, client *Client, packet []byte) {
|
||||
packetStr := string(packet)
|
||||
po.search(client, func(recipient *Client) bool {
|
||||
if client.protoRevision != 101 {
|
||||
return true
|
||||
}
|
||||
recipient.send(packetStr)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// broadcastRangedAtcOnly broadcasts a packet to all ATC clients in range
|
||||
func broadcastRangedAtcOnly(po *postOffice, client *Client, packet []byte) {
|
||||
packetStr := string(packet)
|
||||
po.search(client, func(recipient *Client) bool {
|
||||
if !client.isAtc {
|
||||
return true
|
||||
}
|
||||
recipient.send(packetStr)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// broadcastAll broadcasts a packet to the entire server
|
||||
func broadcastAll(po *postOffice, client *Client, packet []byte) {
|
||||
packetStr := string(packet)
|
||||
po.all(client, func(recipient *Client) bool {
|
||||
recipient.send(packetStr)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// broadcastAllATC broadcasts a packet to all ATC on entire server
|
||||
func broadcastAllATC(po *postOffice, client *Client, packet []byte) {
|
||||
packetStr := string(packet)
|
||||
po.all(client, func(recipient *Client) bool {
|
||||
if !recipient.isAtc {
|
||||
return true
|
||||
}
|
||||
recipient.send(packetStr)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// broadcastAll broadcasts a packet to all supervisors on the server
|
||||
func broadcastAllSupervisors(po *postOffice, client *Client, packet []byte) {
|
||||
packetStr := string(packet)
|
||||
po.all(client, func(recipient *Client) bool {
|
||||
if client.networkRating < NetworkRatingSupervisor {
|
||||
return true
|
||||
}
|
||||
recipient.send(packetStr)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// sendDirectOrErr attempts to send a packet directly to a recipient.
|
||||
// If the post office responds with an ErrCallsignDoesNotExist, the client
|
||||
// is notified with a NoSuchCallsignError.
|
||||
func sendDirectOrErr(po *postOffice, client *Client, recipient []byte, packet []byte) {
|
||||
if err := po.send(string(recipient), string(packet)); err != nil {
|
||||
client.sendError(NoSuchCallsignError, "No such callsign")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// extractFlightplanInfoSection extracts the useful flightplan information from an $FP or $AM packet
|
||||
func extractFlightplanInfoSection(packet []byte) (fpl string) {
|
||||
switch getPacketType(packet) {
|
||||
case PacketTypeFlightPlan:
|
||||
for range 2 {
|
||||
packet = rebaseToNextField(packet)
|
||||
}
|
||||
default: // PacketTypeFlightPlanAmendment
|
||||
for range 3 {
|
||||
packet = rebaseToNextField(packet)
|
||||
}
|
||||
}
|
||||
|
||||
packet, _ = bytes.CutSuffix(packet, []byte("\r\n"))
|
||||
return string(packet)
|
||||
}
|
||||
|
||||
// buildFileFlightplanPacket builds an $FP packet
|
||||
func buildFileFlightplanPacket(source, recipient, fplInfo string) (packet string) {
|
||||
prefix := strings.Builder{}
|
||||
prefix.WriteString("$FP")
|
||||
prefix.WriteString(source)
|
||||
prefix.WriteByte(':')
|
||||
prefix.WriteString(recipient)
|
||||
prefix.WriteByte(':')
|
||||
|
||||
return buildFlightplanPacket(prefix.String(), fplInfo)
|
||||
}
|
||||
|
||||
// buildAmendFlightplanPacket builds an $AM packet
|
||||
func buildAmendFlightplanPacket(source, recipient, targetCallsign, fplInfo string) (packet string) {
|
||||
prefix := strings.Builder{}
|
||||
prefix.Grow(36)
|
||||
prefix.WriteString("$AM")
|
||||
prefix.WriteString(source)
|
||||
prefix.WriteByte(':')
|
||||
prefix.WriteString(recipient)
|
||||
prefix.WriteByte(':')
|
||||
prefix.WriteString(targetCallsign)
|
||||
prefix.WriteByte(':')
|
||||
|
||||
return buildFlightplanPacket(prefix.String(), fplInfo)
|
||||
}
|
||||
|
||||
func buildFlightplanPacket(prefix, fplInfo string) (packet string) {
|
||||
builder := strings.Builder{}
|
||||
builder.Grow(len(prefix) + len(fplInfo) + 2)
|
||||
builder.WriteString(prefix)
|
||||
builder.WriteString(fplInfo)
|
||||
builder.WriteString("\r\n")
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func buildBeaconCodePacket(source, recipient, targetCallsign, beaconCode string) (packet string) {
|
||||
builder := strings.Builder{}
|
||||
builder.Grow(48)
|
||||
builder.WriteString("#PC")
|
||||
builder.WriteString(source)
|
||||
builder.WriteByte(':')
|
||||
builder.WriteString(recipient)
|
||||
builder.WriteString(":CCP:BC:")
|
||||
builder.WriteString(targetCallsign)
|
||||
builder.WriteByte(':')
|
||||
builder.WriteString(beaconCode)
|
||||
builder.WriteString("\r\n")
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
54
fsd/util_test.go
Normal file
54
fsd/util_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package fsd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCountFields verifies the countFields function.
|
||||
func TestCountFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
packet []byte
|
||||
want int
|
||||
}{
|
||||
{[]byte(""), 1},
|
||||
{[]byte("abc"), 1},
|
||||
{[]byte("a:b"), 2},
|
||||
{[]byte("a:b:c"), 3},
|
||||
{[]byte("a:b:"), 3},
|
||||
{[]byte(":a:b"), 3},
|
||||
{[]byte(":"), 2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := countFields(tt.packet)
|
||||
if got != tt.want {
|
||||
t.Errorf("countFields(%q) = %d, want %d", tt.packet, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetField verifies the getField function.
|
||||
func TestGetField(t *testing.T) {
|
||||
tests := []struct {
|
||||
packet []byte
|
||||
index int
|
||||
want string
|
||||
}{
|
||||
{[]byte("a:b:c"), 0, "a"},
|
||||
{[]byte("a:b:c"), 1, "b"},
|
||||
{[]byte("a:b:c"), 2, "c"},
|
||||
{[]byte("a:"), 0, "a"},
|
||||
{[]byte("a:"), 1, ""},
|
||||
{[]byte("a:"), 2, ""},
|
||||
{[]byte(":a"), 0, ""},
|
||||
{[]byte(":a"), 1, "a"},
|
||||
{[]byte(""), 0, ""},
|
||||
{[]byte(""), 1, ""},
|
||||
{[]byte("a"), 0, "a"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := string(getField(tt.packet, tt.index))
|
||||
if got != tt.want {
|
||||
t.Errorf("getField(%q, %d) = %q, want %q", tt.packet, tt.index, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
107
fsd/vatsimauth.go
Normal file
107
fsd/vatsimauth.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package fsd
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var ErrUnsupportedAuthClient = errors.New("vatsimauth: unsupported client")
|
||||
|
||||
var vatsimAuthKeys = map[uint16]string{
|
||||
8464: "945507c4c50222c34687e742729252e6", // vSTARS
|
||||
10452: "0ad74157c7f449c216bfed04f3af9fb9", // vERAM
|
||||
24515: "3424cbcebcca6fe95f973b350ff85cef", // vatSys
|
||||
27095: "3518a62c421937ffa46ac3316957da43", // Euroscope
|
||||
33456: "52d9343020e9c7d0c6b04b0cca20ad3b", // swift
|
||||
35044: "fe28334fb753cf0e3d19942197b9ce3e", // vPilot
|
||||
48312: "bc2eb1ef4d96709c683084055dd5e83f", // TWRTrainer
|
||||
55538: "ImuL1WbbhVuD8d3MuKpWn2rrLZRa9iVP", // xPilot
|
||||
56862: "3518a62c421937ffa46ac3316957da43", // VRC
|
||||
}
|
||||
|
||||
type vatsimAuthState struct {
|
||||
init, curr [16]byte
|
||||
clientId uint16
|
||||
}
|
||||
|
||||
func (s *vatsimAuthState) initAsHex() (d [32]byte) {
|
||||
hex.Encode(d[:], s.init[:])
|
||||
return
|
||||
}
|
||||
|
||||
func (s *vatsimAuthState) currAsHex() (d [32]byte) {
|
||||
hex.Encode(d[:], s.curr[:])
|
||||
return
|
||||
}
|
||||
|
||||
func (s *vatsimAuthState) Initialize(clientId uint16, initialChallenge []byte) (err error) {
|
||||
keyStr, ok := vatsimAuthKeys[clientId]
|
||||
if !ok {
|
||||
err = ErrUnsupportedAuthClient
|
||||
return
|
||||
}
|
||||
s.clientId = clientId
|
||||
|
||||
key := [32]byte{}
|
||||
copy(key[:], keyStr)
|
||||
|
||||
s.init = s.runObfuscationRound(&key, initialChallenge)
|
||||
s.curr = s.init
|
||||
return
|
||||
}
|
||||
|
||||
func (s *vatsimAuthState) IsInitialized() bool {
|
||||
return s.clientId != 0
|
||||
}
|
||||
|
||||
func (s *vatsimAuthState) GetResponseForChallenge(challenge []byte) (res [32]byte) {
|
||||
curr := s.currAsHex()
|
||||
round := s.runObfuscationRound(&curr, challenge)
|
||||
hex.Encode(res[:], round[:])
|
||||
return
|
||||
}
|
||||
|
||||
func (s *vatsimAuthState) UpdateState(d *[32]byte) {
|
||||
init := s.initAsHex()
|
||||
tmp := [64]byte{}
|
||||
copy(tmp[:32], init[:])
|
||||
copy(tmp[32:], d[:])
|
||||
|
||||
s.curr = md5.Sum(tmp[:])
|
||||
}
|
||||
|
||||
func (s *vatsimAuthState) runObfuscationRound(curr *[32]byte, challenge []byte) (res [16]byte) {
|
||||
c1, c2 := challenge[0:(len(challenge)/2)], challenge[(len(challenge)/2):]
|
||||
|
||||
if (s.clientId & 1) == 1 {
|
||||
c1, c2 = c2, c1
|
||||
}
|
||||
|
||||
s1, s2, s3 := curr[0:12], curr[12:22], curr[22:32]
|
||||
|
||||
tmp := make([]byte, 0, 64)
|
||||
switch s.clientId % 3 {
|
||||
case 0:
|
||||
tmp = append(tmp, s1...)
|
||||
tmp = append(tmp, c1...)
|
||||
tmp = append(tmp, s2...)
|
||||
tmp = append(tmp, c2...)
|
||||
tmp = append(tmp, s3...)
|
||||
case 1:
|
||||
tmp = append(tmp, s2...)
|
||||
tmp = append(tmp, c1...)
|
||||
tmp = append(tmp, s3...)
|
||||
tmp = append(tmp, c2...)
|
||||
tmp = append(tmp, s1...)
|
||||
default:
|
||||
tmp = append(tmp, s3...)
|
||||
tmp = append(tmp, c1...)
|
||||
tmp = append(tmp, s1...)
|
||||
tmp = append(tmp, c2...)
|
||||
tmp = append(tmp, s2...)
|
||||
}
|
||||
|
||||
res = md5.Sum(tmp)
|
||||
return
|
||||
}
|
||||
49
fsd/vatsimauth_test.go
Normal file
49
fsd/vatsimauth_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package fsd
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVatsimAuth(t *testing.T) {
|
||||
s := vatsimAuthState{}
|
||||
|
||||
// 35044 = vPilot, 30984979d8caed23 = initial challenge
|
||||
err := s.Initialize(35044, []byte("30984979d8caed23"))
|
||||
assert.Nil(t, err)
|
||||
|
||||
dst := s.GetResponseForChallenge([]byte("de6acb8e"))
|
||||
actual := string(dst[:])
|
||||
expected := "f8ee97157f66455ed6108fccef6ccf5f"
|
||||
assert.Equal(t, expected, actual)
|
||||
|
||||
s.UpdateState(&dst)
|
||||
|
||||
dst = s.GetResponseForChallenge([]byte("65b479573b0e"))
|
||||
actual = string(dst[:])
|
||||
expected = "8953f545c4e0ffd20943ad89b8ddd087"
|
||||
assert.Equal(t, expected, actual)
|
||||
|
||||
s = vatsimAuthState{}
|
||||
// 48312 = TWRTrainer, 3ae3baf4 = initial challenge
|
||||
err = s.Initialize(48312, []byte("3ae3baf4"))
|
||||
assert.Nil(t, err)
|
||||
|
||||
dst = s.GetResponseForChallenge([]byte("abcdef"))
|
||||
actual = string(dst[:])
|
||||
expected = "60ef113425658b09a1e555279d27f64a"
|
||||
assert.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func BenchmarkVatsimAuth(b *testing.B) {
|
||||
s := vatsimAuthState{}
|
||||
|
||||
if err := s.Initialize(35044, []byte("0123456789abcdef")); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
dst := s.GetResponseForChallenge([]byte("fedcba9876543210"))
|
||||
s.UpdateState(&dst)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user