initial v1.0-beta commit

This commit is contained in:
Reese Norris
2025-05-12 17:21:16 -07:00
commit cde3128509
68 changed files with 12495 additions and 0 deletions

110
fsd/client.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}
}