mirror of
https://github.com/renorris/openfsd
synced 2026-03-22 06:25:35 +08:00
Introduce SetIfNotExists, atomic Client, dynamic web config, ServerConfig, data APIs, and fixes.
This commit is contained in:
@@ -44,6 +44,15 @@ func (p *PostgresConfigRepository) Set(key string, value string) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (p *PostgresConfigRepository) SetIfNotExists(key string, value string) (err error) {
|
||||
querystr := `
|
||||
INSERT INTO config (key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
`
|
||||
_, err = p.db.Exec(querystr, key, value)
|
||||
return
|
||||
}
|
||||
|
||||
// Get retrieves the value for the given key from the configuration.
|
||||
// If the key does not exist, it returns ErrConfigKeyNotFound.
|
||||
func (p *PostgresConfigRepository) Get(key string) (value string, err error) {
|
||||
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
)
|
||||
|
||||
type ConfigRepository interface {
|
||||
// InitDefault initializes the default state of the Config if one does not already exist.
|
||||
InitDefault() (err error)
|
||||
|
||||
// Set sets a value for a given key
|
||||
Set(key string, value string) (err error)
|
||||
|
||||
// SetIfNotExists sets a value for a given key if it does not already exist
|
||||
SetIfNotExists(key string, value string) (err error)
|
||||
|
||||
// Get gets a value for a given key.
|
||||
//
|
||||
// Returns ErrConfigKeyNotFound if no key/value pair is found.
|
||||
@@ -21,7 +21,14 @@ type ConfigRepository interface {
|
||||
}
|
||||
|
||||
const (
|
||||
ConfigJwtSecretKey = "JWT_SECRET_KEY"
|
||||
ConfigJwtSecretKey = "JWT_SECRET_KEY"
|
||||
|
||||
ConfigFsdServerHostname = "FSD_SERVER_HOSTNAME"
|
||||
ConfigFsdServerIdent = "FSD_SERVER_IDENT"
|
||||
ConfigFsdServerLocation = "FSD_SERVER_LOCATION"
|
||||
|
||||
ConfigApiServerBaseURL = "API_SERVER_BASE_URL"
|
||||
|
||||
ConfigWelcomeMessage = "WELCOME_MESSAGE"
|
||||
)
|
||||
|
||||
@@ -46,3 +53,27 @@ func GetWelcomeMessage(r *ConfigRepository) (msg string) {
|
||||
msg, _ = (*r).Get(ConfigWelcomeMessage)
|
||||
return
|
||||
}
|
||||
|
||||
func InitDefaultConfig(r *ConfigRepository) (err error) {
|
||||
secretKey, err := GenerateJwtSecretKey()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defaultConfig := map[string]string{
|
||||
ConfigJwtSecretKey: string(secretKey[:]),
|
||||
ConfigWelcomeMessage: "Connected to openfsd",
|
||||
ConfigFsdServerHostname: "localhost",
|
||||
ConfigFsdServerIdent: "OPENFSD",
|
||||
ConfigFsdServerLocation: "Earth",
|
||||
ConfigApiServerBaseURL: "http://localhost",
|
||||
}
|
||||
|
||||
for k, v := range defaultConfig {
|
||||
if err = (*r).SetIfNotExists(k, v); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,24 +9,12 @@ type SQLiteConfigRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (s *SQLiteConfigRepository) InitDefault() (err error) {
|
||||
if err = s.ensureSecretKeyExists(); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *SQLiteConfigRepository) ensureSecretKeyExists() (err error) {
|
||||
secretKey, err := GenerateJwtSecretKey()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
func (s *SQLiteConfigRepository) SetIfNotExists(key string, value string) (err error) {
|
||||
querystr := `
|
||||
INSERT INTO config (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO NOTHING;
|
||||
`
|
||||
if _, err = s.db.Exec(querystr, ConfigJwtSecretKey, secretKey[:]); err != nil {
|
||||
if _, err = s.db.Exec(querystr, key, value); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
|
||||
@@ -16,13 +16,19 @@ type Client struct {
|
||||
cancelCtx func()
|
||||
sendChan chan string
|
||||
|
||||
latLon [2]float64
|
||||
visRange float64
|
||||
lat, lon, visRange atomic.Float64
|
||||
|
||||
flightPlan *atomic.String
|
||||
beaconCode *atomic.String
|
||||
flightPlan atomic.String
|
||||
assignedBeaconCode atomic.String
|
||||
|
||||
facilityType int // ATC facility type. This value is only relevant for ATC
|
||||
frequency atomic.String // OnlineUserATC frequency
|
||||
altitude atomic.Int32 // OnlineUserPilot altitude
|
||||
groundspeed atomic.Int32 // OnlineUserPilot ground speed
|
||||
transponder atomic.String // Active pilot transponder
|
||||
heading atomic.Int32 // OnlineUserPilot heading
|
||||
lastUpdated atomic.Time // Last updated time
|
||||
|
||||
facilityType int // OnlineUserATC facility type. This value is only relevant for OnlineUserATC
|
||||
loginData
|
||||
|
||||
authState vatsimAuthState
|
||||
@@ -31,14 +37,12 @@ type Client struct {
|
||||
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,
|
||||
conn: conn,
|
||||
scanner: scanner,
|
||||
ctx: clientCtx,
|
||||
cancelCtx: cancel,
|
||||
sendChan: make(chan string, 32),
|
||||
loginData: loginData,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,3 +118,7 @@ func (s *Server) eventLoop(client *Client) {
|
||||
handler(client, packet)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) latLon() [2]float64 {
|
||||
return [2]float64{c.lat.Load(), c.lon.Load()}
|
||||
}
|
||||
|
||||
14
fsd/conn.go
14
fsd/conn.go
@@ -11,6 +11,7 @@ import (
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sendError sends an FSD error packet to an io.Writer with the specified code and message.
|
||||
@@ -96,13 +97,14 @@ func sendServerIdent(conn io.Writer) (err error) {
|
||||
// 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)
|
||||
callsign string // Callsign of the Client (OnlineUserATC or pilot)
|
||||
cid int // Cert ID
|
||||
realName string // Real name
|
||||
networkRating NetworkRating // Network rating of the Client
|
||||
protoRevision int // Protocol revision
|
||||
loginTime time.Time // Time of login
|
||||
clientId uint16 // Client ID
|
||||
isAtc bool // True if the Client is an ATC, false if a pilot
|
||||
isAtc bool // True if the Client is an OnlineUserATC, false if a pilot
|
||||
}
|
||||
|
||||
// ErrInvalidAddPacket is returned when the add packet from the Client is invalid.
|
||||
@@ -171,7 +173,7 @@ func readLoginPackets(conn net.Conn, scanner *bufio.Scanner) (data loginData, to
|
||||
if data.isAtc {
|
||||
if countFields(addPacket) != 7 {
|
||||
err = ErrInvalidAddPacket
|
||||
sendError(conn, SyntaxError, "Invalid number of fields in ATC add packet")
|
||||
sendError(conn, SyntaxError, "Invalid number of fields in OnlineUserATC add packet")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -194,7 +196,7 @@ func readLoginPackets(conn net.Conn, scanner *bufio.Scanner) (data loginData, to
|
||||
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")
|
||||
sendError(conn, SyntaxError, "Invalid CID in OnlineUserATC add packet")
|
||||
return
|
||||
}
|
||||
token = string(getField(addPacket, 4))
|
||||
@@ -207,7 +209,7 @@ func readLoginPackets(conn net.Conn, scanner *bufio.Scanner) (data loginData, to
|
||||
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")
|
||||
sendError(conn, SyntaxError, "Invalid protocol revision in OnlineUserATC add packet")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -238,6 +240,8 @@ func readLoginPackets(conn net.Conn, scanner *bufio.Scanner) (data loginData, to
|
||||
return
|
||||
}
|
||||
|
||||
data.loginTime = time.Now()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ type ServerConfig struct {
|
||||
DatabaseMaxConns int `env:"DATABASE_MAX_CONNS, default=4"` // Max number of database connections
|
||||
|
||||
NumMetarWorkers int `env:"NUM_METAR_WORKERS, default=4"` // Number of METAR fetch workers to run
|
||||
|
||||
ServiceHTTPListenAddr string `env:"SERVICE_HTTP_LISTEN_ADDR, default=:13618"`
|
||||
}
|
||||
|
||||
func loadServerConfig(ctx context.Context) (config *ServerConfig, err error) {
|
||||
|
||||
@@ -3,8 +3,10 @@ package fsd
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) getHandler(packetType PacketType) handlerFunc {
|
||||
@@ -38,14 +40,19 @@ func (s *Server) getHandler(packetType PacketType) handlerFunc {
|
||||
case PacketTypeFlightPlanAmendment:
|
||||
return s.handleAmendFlightplan
|
||||
default:
|
||||
return nil
|
||||
return s.emptyHandler
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) emptyHandler(client *Client, packet []byte) {
|
||||
slog.Error("empty handler called")
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) handleTextMessage(client *Client, packet []byte) {
|
||||
recipient := getField(packet, 1)
|
||||
|
||||
// ATC chat
|
||||
// OnlineUserATC chat
|
||||
if string(recipient) == "@49999" {
|
||||
if !client.isAtc {
|
||||
return
|
||||
@@ -122,6 +129,8 @@ func (s *Server) handleATCPosition(client *Client, packet []byte) {
|
||||
|
||||
// Broadcast position update
|
||||
broadcastRanged(s.postOffice, client, packet)
|
||||
|
||||
client.lastUpdated.Store(time.Now())
|
||||
}
|
||||
|
||||
// handlePilotPosition handles logic for 0.2hz `@` pilot position updates
|
||||
@@ -139,6 +148,17 @@ func (s *Server) handlePilotPosition(client *Client, packet []byte) {
|
||||
|
||||
// Broadcast position update
|
||||
broadcastRanged(s.postOffice, client, packet)
|
||||
|
||||
// Update state
|
||||
client.transponder.Store(string(getField(packet, 2)))
|
||||
groundspeed, _ := strconv.Atoi(string(getField(packet, 7)))
|
||||
client.groundspeed.Store(int32(groundspeed))
|
||||
altitude, _ := strconv.Atoi(string(getField(packet, 6)))
|
||||
client.altitude.Store(int32(altitude))
|
||||
pbhUint, _ := strconv.ParseUint(string(getField(packet, 8)), 10, 32)
|
||||
_, _, heading := pitchBankHeading(pbhUint).vals()
|
||||
client.heading.Store(int32(heading))
|
||||
client.lastUpdated.Store(time.Now())
|
||||
}
|
||||
|
||||
// handleFastPilotPosition handles logic for fast `^`, stopped `#ST`, and slow `#SL` pilot position updates
|
||||
@@ -147,7 +167,7 @@ func (s *Server) handleFastPilotPosition(client *Client, packet []byte) {
|
||||
broadcastRangedVelocity(s.postOffice, client, packet)
|
||||
}
|
||||
|
||||
// handleDelete handles logic for Delete ATC `#DA` and Delete Pilot `#DP` packets
|
||||
// handleDelete handles logic for Delete OnlineUserATC `#DA` and Delete OnlineUserPilot `#DP` packets
|
||||
func (s *Server) handleDelete(client *Client, packet []byte) {
|
||||
// Broadcast delete packet
|
||||
broadcastAll(s.postOffice, client, packet)
|
||||
@@ -165,7 +185,7 @@ func (s *Server) handleSquawkbox(client *Client, packet []byte) {
|
||||
|
||||
// handleProcontroller handles logic for Pro Controller `#PC` packets
|
||||
func (s *Server) handleProcontroller(client *Client, packet []byte) {
|
||||
// ATC-only packet
|
||||
// OnlineUserATC-only packet
|
||||
if !client.isAtc {
|
||||
return
|
||||
}
|
||||
@@ -204,7 +224,7 @@ func (s *Server) handleProcontroller(client *Client, packet []byte) {
|
||||
"DP", // Push to departure list
|
||||
"ST": // Set flight strip
|
||||
|
||||
// Only active ATC above OBS
|
||||
// Only active OnlineUserATC above OBS
|
||||
if client.facilityType <= 0 {
|
||||
client.sendError(InvalidControlError, "Invalid control")
|
||||
return
|
||||
@@ -224,7 +244,7 @@ func (s *Server) handleClientQuery(client *Client, packet []byte) {
|
||||
// Handle queries sent to SERVER
|
||||
if string(recipient) == "SERVER" {
|
||||
switch string(queryType) {
|
||||
case "ATC":
|
||||
case "OnlineUserATC":
|
||||
s.handleClientQueryATCRequest(client, packet)
|
||||
case "IP":
|
||||
s.handleClientQueryIPRequest(client, packet)
|
||||
@@ -238,7 +258,7 @@ func (s *Server) handleClientQuery(client *Client, packet []byte) {
|
||||
if bytes.HasPrefix(recipient, []byte("@")) {
|
||||
switch string(queryType) {
|
||||
|
||||
// Unprivileged ATC queries
|
||||
// Unprivileged OnlineUserATC queries
|
||||
case
|
||||
"BY", // Request relief
|
||||
"HI", // Cancel request relief
|
||||
@@ -248,14 +268,14 @@ func (s *Server) handleClientQuery(client *Client, packet []byte) {
|
||||
"NEWATIS", // Broadcast new ATIS letter
|
||||
"NEWINFO": // Broadcast new ATIS info
|
||||
|
||||
// ATC only
|
||||
// OnlineUserATC only
|
||||
if !client.isAtc {
|
||||
client.sendError(InvalidControlError, "Invalid control")
|
||||
return
|
||||
}
|
||||
broadcastRangedAtcOnly(s.postOffice, client, packet)
|
||||
|
||||
// Privileged ATC queries
|
||||
// Privileged OnlineUserATC queries
|
||||
case
|
||||
"IT", // Initiate track
|
||||
"DR", // Drop track
|
||||
@@ -268,7 +288,7 @@ func (s *Server) handleClientQuery(client *Client, packet []byte) {
|
||||
"EST", // Set estimate time
|
||||
"GD": // Set global data
|
||||
|
||||
// ATC above OBS facility only
|
||||
// OnlineUserATC above OBS facility only
|
||||
if !client.isAtc || client.facilityType <= 0 {
|
||||
client.sendError(InvalidControlError, "Invalid control")
|
||||
return
|
||||
@@ -316,7 +336,7 @@ func (s *Server) handleClientQuery(client *Client, packet []byte) {
|
||||
|
||||
func (s *Server) handleClientQueryATCRequest(client *Client, packet []byte) {
|
||||
if countFields(packet) != 4 {
|
||||
client.sendError(SyntaxError, "Invalid ATC request")
|
||||
client.sendError(SyntaxError, "Invalid OnlineUserATC request")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -329,9 +349,9 @@ func (s *Server) handleClientQueryATCRequest(client *Client, packet []byte) {
|
||||
|
||||
var p string
|
||||
if targetClient.facilityType > 0 {
|
||||
p = fmt.Sprintf("$CRSERVER:%s:ATC:Y:%s\r\n", client.callsign, targetCallsign)
|
||||
p = fmt.Sprintf("$CRSERVER:%s:OnlineUserATC:Y:%s\r\n", client.callsign, targetCallsign)
|
||||
} else {
|
||||
p = fmt.Sprintf("$CRSERVER:%s:ATC:N:%s\r\n", client.callsign, targetCallsign)
|
||||
p = fmt.Sprintf("$CRSERVER:%s:OnlineUserATC:N:%s\r\n", client.callsign, targetCallsign)
|
||||
}
|
||||
client.send(p)
|
||||
}
|
||||
@@ -365,7 +385,7 @@ func (s *Server) handleClientQueryFlightplanRequest(client *Client, packet []byt
|
||||
return
|
||||
}
|
||||
|
||||
beaconCode := targetClient.beaconCode.Load()
|
||||
beaconCode := targetClient.assignedBeaconCode.Load()
|
||||
if beaconCode == "" {
|
||||
beaconCode = "0"
|
||||
}
|
||||
@@ -431,7 +451,7 @@ func (s *Server) handleAuthChallenge(client *Client, packet []byte) {
|
||||
}
|
||||
|
||||
func (s *Server) handleHandoff(client *Client, packet []byte) {
|
||||
// Active >OBS ATC only
|
||||
// Active >OBS OnlineUserATC only
|
||||
if !client.isAtc || client.facilityType <= 1 {
|
||||
return
|
||||
}
|
||||
|
||||
145
fsd/http_service.go
Normal file
145
fsd/http_service.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package fsd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/renorris/openfsd/db"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// runServiceHTTP starts the admin service HTTP server used for
|
||||
// internal communication between the API HTTP server and this FSD server.
|
||||
func (s *Server) runServiceHTTP(ctx context.Context) {
|
||||
e := s.setupRoutes()
|
||||
if err := e.Run(s.cfg.ServiceHTTPListenAddr); err != nil {
|
||||
slog.Error(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) setupRoutes() (e *gin.Engine) {
|
||||
e = gin.New()
|
||||
|
||||
// Verify administrator service JWT
|
||||
e.Use(s.authMiddleware)
|
||||
e.GET("/online_users", s.handleGetOnlineUsers)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) authMiddleware(c *gin.Context) {
|
||||
authHeader, found := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
if !found {
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey)
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := ParseJwtToken(authHeader, []byte(jwtSecret))
|
||||
if err != nil {
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
claims := accessToken.CustomClaims()
|
||||
if claims.TokenType != "fsd_service" || claims.NetworkRating < NetworkRatingAdministator {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
type OnlineUserGeneralData struct {
|
||||
Callsign string `json:"callsign"`
|
||||
CID int `json:"cid"`
|
||||
Name string `json:"name"`
|
||||
NetworkRating int `json:"network_rating"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
LogonTime time.Time `json:"logon_time"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
type OnlineUserPilot struct {
|
||||
OnlineUserGeneralData
|
||||
Altitude int `json:"altitude"`
|
||||
Groundspeed int `json:"groundspeed"`
|
||||
Heading int `json:"heading"`
|
||||
Transponder string `json:"transponder"`
|
||||
}
|
||||
|
||||
type OnlineUserATC struct {
|
||||
OnlineUserGeneralData
|
||||
Frequency string `json:"frequency"`
|
||||
Facility int `json:"facility"`
|
||||
VisRange int `json:"visual_range"`
|
||||
}
|
||||
|
||||
type OnlineUsersResponseData struct {
|
||||
Pilots []OnlineUserPilot `json:"pilots"`
|
||||
ATC []OnlineUserATC `json:"atc"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetOnlineUsers(c *gin.Context) {
|
||||
s.postOffice.clientMapLock.RLock()
|
||||
mapLen := len(s.postOffice.clientMap)
|
||||
s.postOffice.clientMapLock.RUnlock()
|
||||
|
||||
clientMap := make(map[string]*Client, mapLen+16)
|
||||
|
||||
s.postOffice.clientMapLock.RLock()
|
||||
maps.Copy(clientMap, s.postOffice.clientMap)
|
||||
s.postOffice.clientMapLock.RUnlock()
|
||||
|
||||
resData := OnlineUsersResponseData{
|
||||
Pilots: make([]OnlineUserPilot, 0, 512),
|
||||
ATC: make([]OnlineUserATC, 0, 128),
|
||||
}
|
||||
|
||||
for _, client := range clientMap {
|
||||
genData := OnlineUserGeneralData{
|
||||
Callsign: client.callsign,
|
||||
CID: client.cid,
|
||||
Name: client.realName,
|
||||
NetworkRating: int(client.networkRating),
|
||||
Latitude: client.lat.Load(),
|
||||
Longitude: client.lon.Load(),
|
||||
LogonTime: client.loginTime,
|
||||
LastUpdated: client.lastUpdated.Load(),
|
||||
}
|
||||
|
||||
if client.isAtc {
|
||||
atc := OnlineUserATC{
|
||||
OnlineUserGeneralData: genData,
|
||||
Frequency: client.frequency.Load(),
|
||||
Facility: client.facilityType,
|
||||
VisRange: int(client.visRange.Load()),
|
||||
}
|
||||
resData.ATC = append(resData.ATC, atc)
|
||||
} else {
|
||||
pilot := OnlineUserPilot{
|
||||
OnlineUserGeneralData: genData,
|
||||
Altitude: int(client.altitude.Load()),
|
||||
Groundspeed: int(client.groundspeed.Load()),
|
||||
Heading: int(client.heading.Load()),
|
||||
Transponder: client.transponder.Load(),
|
||||
}
|
||||
resData.Pilots = append(resData.Pilots, pilot)
|
||||
}
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(c.Writer).Encode(&resData)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func (p *postOffice) register(client *Client) (err error) {
|
||||
p.clientMapLock.Unlock()
|
||||
|
||||
// Insert into R-tree
|
||||
clientMin, clientMax := calculateBoundingBox(client.latLon, client.visRange)
|
||||
clientMin, clientMax := calculateBoundingBox(client.latLon(), client.visRange.Load())
|
||||
p.treeLock.Lock()
|
||||
p.tree.Insert(clientMin, clientMax, client)
|
||||
p.treeLock.Unlock()
|
||||
@@ -49,7 +49,7 @@ func (p *postOffice) register(client *Client) (err error) {
|
||||
|
||||
// release removes a Client from the post office.
|
||||
func (p *postOffice) release(client *Client) {
|
||||
clientMin, clientMax := calculateBoundingBox(client.latLon, client.visRange)
|
||||
clientMin, clientMax := calculateBoundingBox(client.latLon(), client.visRange.Load())
|
||||
|
||||
p.treeLock.Lock()
|
||||
p.tree.Delete(clientMin, clientMax, client)
|
||||
@@ -65,11 +65,12 @@ func (p *postOffice) release(client *Client) {
|
||||
// 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)
|
||||
oldMin, oldMax := calculateBoundingBox(client.latLon(), client.visRange.Load())
|
||||
newMin, newMax := calculateBoundingBox(newCenter, newVisRange)
|
||||
|
||||
client.latLon = newCenter
|
||||
client.visRange = newVisRange
|
||||
client.lat.Store(newCenter[0])
|
||||
client.lon.Store(newCenter[1])
|
||||
client.visRange.Store(newVisRange)
|
||||
|
||||
// Avoid redundant updates
|
||||
if oldMin == newMin && oldMax == newMax {
|
||||
@@ -86,7 +87,7 @@ func (p *postOffice) updatePosition(client *Client, newCenter [2]float64, newVis
|
||||
|
||||
// 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)
|
||||
clientMin, clientMax := calculateBoundingBox(client.latLon(), client.visRange.Load())
|
||||
|
||||
p.treeLock.RLock()
|
||||
p.tree.Search(clientMin, clientMax, func(foundMin [2]float64, foundMax [2]float64, foundClient *Client) bool {
|
||||
|
||||
@@ -254,7 +254,7 @@ func approxEqual(a, b float64) bool {
|
||||
|
||||
// BenchmarkDistance measures the performance of the distance function using pre-generated pseudo-random coordinates.
|
||||
func BenchmarkDistance(b *testing.B) {
|
||||
const numPairs = 1000
|
||||
const numPairs = 1024 * 64
|
||||
lats1 := make([]float64, numPairs)
|
||||
lons1 := make([]float64, numPairs)
|
||||
lats2 := make([]float64, numPairs)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
listenAddrs []string
|
||||
cfg *ServerConfig
|
||||
postOffice *postOffice
|
||||
metarService *metarService
|
||||
dbRepo *db.Repositories
|
||||
@@ -24,9 +24,9 @@ type Server struct {
|
||||
// NewServer creates a new Server instance.
|
||||
//
|
||||
// See NewDefaultServer to create a server using default settings obtained via environment variables.
|
||||
func NewServer(listenAddrs []string, dbRepo *db.Repositories, numMetarWorkers int) (server *Server, err error) {
|
||||
func NewServer(cfg *ServerConfig, dbRepo *db.Repositories, numMetarWorkers int) (server *Server, err error) {
|
||||
server = &Server{
|
||||
listenAddrs: listenAddrs,
|
||||
cfg: cfg,
|
||||
postOffice: newPostOffice(),
|
||||
metarService: newMetarService(numMetarWorkers),
|
||||
dbRepo: dbRepo,
|
||||
@@ -92,12 +92,12 @@ func NewDefaultServer(ctx context.Context) (server *Server, err error) {
|
||||
|
||||
// Ensure default configuration is written to persistent storage
|
||||
slog.Debug("initializing default config")
|
||||
if err = dbRepo.ConfigRepo.InitDefault(); err != nil {
|
||||
if err = db.InitDefaultConfig(&dbRepo.ConfigRepo); err != nil {
|
||||
return
|
||||
}
|
||||
slog.Debug("config OK")
|
||||
|
||||
if server, err = NewServer(config.FsdListenAddrs, dbRepo, config.NumMetarWorkers); err != nil {
|
||||
if server, err = NewServer(config, dbRepo, config.NumMetarWorkers); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -128,10 +128,13 @@ func (s *Server) Run(ctx context.Context) (err error) {
|
||||
// Start metar service
|
||||
go s.metarService.run(ctx)
|
||||
|
||||
errCh := make(chan error, len(s.listenAddrs))
|
||||
// Start HTTP service
|
||||
go s.runServiceHTTP(ctx)
|
||||
|
||||
errCh := make(chan error, len(s.cfg.FsdListenAddrs))
|
||||
var listenerWg sync.WaitGroup
|
||||
|
||||
for _, addr := range s.listenAddrs {
|
||||
for _, addr := range s.cfg.FsdListenAddrs {
|
||||
slog.Info(fmt.Sprintf("Listening on %s\n", addr))
|
||||
listenerWg.Add(1)
|
||||
go func(ctx context.Context, addr string) {
|
||||
|
||||
30
fsd/util.go
30
fsd/util.go
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -228,7 +229,7 @@ func broadcastRangedVelocity(po *postOffice, client *Client, packet []byte) {
|
||||
})
|
||||
}
|
||||
|
||||
// broadcastRangedAtcOnly broadcasts a packet to all ATC clients in range
|
||||
// broadcastRangedAtcOnly broadcasts a packet to all OnlineUserATC clients in range
|
||||
func broadcastRangedAtcOnly(po *postOffice, client *Client, packet []byte) {
|
||||
packetStr := string(packet)
|
||||
po.search(client, func(recipient *Client) bool {
|
||||
@@ -249,7 +250,7 @@ func broadcastAll(po *postOffice, client *Client, packet []byte) {
|
||||
})
|
||||
}
|
||||
|
||||
// broadcastAllATC broadcasts a packet to all ATC on entire server
|
||||
// broadcastAllATC broadcasts a packet to all OnlineUserATC on entire server
|
||||
func broadcastAllATC(po *postOffice, client *Client, packet []byte) {
|
||||
packetStr := string(packet)
|
||||
po.all(client, func(recipient *Client) bool {
|
||||
@@ -353,6 +354,31 @@ func buildBeaconCodePacket(source, recipient, targetCallsign, beaconCode string)
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
type pitchBankHeading uint32
|
||||
|
||||
const maxPbhValue = 0b1111111111
|
||||
|
||||
func newPitchBankHeading(pitch uint32, bank uint32, heading uint32) (pbh pitchBankHeading, err error) {
|
||||
if pitch > maxPbhValue || bank > maxPbhValue || heading > maxPbhValue {
|
||||
err = errors.New("out of range")
|
||||
return
|
||||
}
|
||||
|
||||
pbh = (pbh | pitchBankHeading(pitch)) << 10
|
||||
pbh = (pbh | pitchBankHeading(bank)) << 10
|
||||
pbh = (pbh | pitchBankHeading(heading)) << 2
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (pbh pitchBankHeading) vals() (pitch uint32, bank uint32, heading uint32) {
|
||||
pitch = uint32(pbh>>22) & maxPbhValue
|
||||
bank = uint32(pbh>>12) & maxPbhValue
|
||||
heading = uint32(pbh>>2) & maxPbhValue
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func strPtr(str string) *string {
|
||||
return &str
|
||||
}
|
||||
|
||||
11
main.go
11
main.go
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/renorris/openfsd/fsd"
|
||||
"log/slog"
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -11,17 +10,19 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("hello world")
|
||||
|
||||
setSlogLevel()
|
||||
|
||||
ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
|
||||
os.Setenv("DATABASE_AUTO_MIGRATE", "true")
|
||||
server, err := fsd.NewDefaultServer(context.Background())
|
||||
os.Setenv("DATABASE_DRIVER", "sqlite")
|
||||
os.Setenv("DATABASE_SOURCE_NAME", "test.db")
|
||||
|
||||
server, err := fsd.NewDefaultServer(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
if err = server.Run(ctx); err != nil {
|
||||
slog.Error(err.Error())
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ func (s *Server) handleGetConfig(c *gin.Context) {
|
||||
|
||||
var configKeys = []string{
|
||||
db.ConfigWelcomeMessage,
|
||||
db.ConfigFsdServerHostname,
|
||||
db.ConfigFsdServerIdent,
|
||||
db.ConfigFsdServerLocation,
|
||||
db.ConfigApiServerBaseURL,
|
||||
}
|
||||
|
||||
type ResponseBody struct {
|
||||
|
||||
355
web/data.go
Normal file
355
web/data.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/renorris/openfsd/db"
|
||||
"github.com/renorris/openfsd/fsd"
|
||||
"go.uber.org/atomic"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed data_templates/status.txt
|
||||
var statusTxtRawTemplate string
|
||||
var statusTxtTemplate *template.Template
|
||||
|
||||
//go:embed data_templates/servers.txt
|
||||
var serversTxtRawTemplate string
|
||||
var serversTxtTemplate *template.Template
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
statusTxtTemplate = template.New("statustxt")
|
||||
if statusTxtTemplate, err = statusTxtTemplate.Parse(statusTxtRawTemplate); err != nil {
|
||||
panic("Unable to parse status.txt template: " + err.Error())
|
||||
}
|
||||
|
||||
serversTxtTemplate = template.New("serverstxt")
|
||||
if serversTxtTemplate, err = serversTxtTemplate.Parse(serversTxtRawTemplate); err != nil {
|
||||
panic("Unable to parse servers.txt template: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleGetStatusTxt(c *gin.Context) {
|
||||
baseURL, ok := s.getBaseURLOrErr(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a new status.txt
|
||||
statusTxt, err := generateStatusTxt(baseURL)
|
||||
if err != nil {
|
||||
c.Writer.WriteHeader(http.StatusInternalServerError)
|
||||
c.Writer.WriteString("Error generating status.txt")
|
||||
slog.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
c.Writer.WriteString(statusTxt)
|
||||
}
|
||||
|
||||
func generateStatusTxt(baseURL string) (txt string, err error) {
|
||||
type TemplateData struct {
|
||||
ApiServerBaseURL string
|
||||
}
|
||||
|
||||
tmplData := TemplateData{ApiServerBaseURL: baseURL}
|
||||
|
||||
buf := bytes.Buffer{}
|
||||
buf.Grow(1024)
|
||||
if err = statusTxtTemplate.Execute(&buf, &tmplData); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure all newlines have a carriage return
|
||||
txt = strings.ReplaceAll(buf.String(), "\n", "\r\n")
|
||||
return
|
||||
}
|
||||
|
||||
type DataJsonStatus struct {
|
||||
Data map[string][]string `json:"data"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetStatusJSON(c *gin.Context) {
|
||||
baseURL, ok := s.getBaseURLOrErr(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
statusJson := DataJsonStatus{
|
||||
Data: map[string][]string{
|
||||
"v3": {
|
||||
baseURL + "/api/v1/data/openfsd-data.json",
|
||||
},
|
||||
"servers": {
|
||||
baseURL + "/api/v1/data/openfsd-servers.json",
|
||||
},
|
||||
"servers_sweatbox": {
|
||||
baseURL + "/api/v1/data/sweatbox-servers.json",
|
||||
},
|
||||
"servers_all": {
|
||||
baseURL + "/api/v1/data/all-servers.json",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
res, err := json.Marshal(&statusJson)
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
writePlaintext500Error(c, "Unable to marshal JSON")
|
||||
return
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
c.Writer.Write(res)
|
||||
}
|
||||
|
||||
type DataJsonServer struct {
|
||||
Ident string `json:"ident"`
|
||||
HostnameOrIp string `json:"hostname_or_ip"`
|
||||
Location string `json:"location"`
|
||||
Name string `json:"name"`
|
||||
ClientsConnectionAllowed int `json:"clients_connection_allowed"`
|
||||
ClientConnectionsAllowed bool `json:"client_connections_allowed"`
|
||||
IsSweatbox bool `json:"is_sweatbox"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetServersJSON(c *gin.Context) {
|
||||
serverIdent, serverHostname, serverLocation, err := s.getFsdServerInfo()
|
||||
if err != nil {
|
||||
writePlaintext500Error(c, "Unable to load FSD server info from configuration")
|
||||
return
|
||||
}
|
||||
|
||||
_, isSweatbox := c.Get("is_sweatbox")
|
||||
|
||||
type ServersJson []DataJsonServer
|
||||
dataJson := ServersJson{
|
||||
{
|
||||
Ident: serverIdent,
|
||||
HostnameOrIp: serverHostname,
|
||||
Location: serverLocation,
|
||||
Name: serverIdent,
|
||||
ClientConnectionsAllowed: true,
|
||||
ClientsConnectionAllowed: 99,
|
||||
IsSweatbox: isSweatbox,
|
||||
},
|
||||
}
|
||||
|
||||
res, err := json.Marshal(&dataJson)
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
writePlaintext500Error(c, "Unable to marshal JSON")
|
||||
return
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
c.Writer.Write(res)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetServersTxt(c *gin.Context) {
|
||||
serversTxt, err := s.generateServersTxt()
|
||||
if err != nil {
|
||||
c.Writer.WriteHeader(http.StatusInternalServerError)
|
||||
c.Writer.WriteString("Error generating status.txt")
|
||||
slog.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
c.Writer.WriteString(serversTxt)
|
||||
}
|
||||
|
||||
func (s *Server) generateServersTxt() (txt string, err error) {
|
||||
serverIdent, serverHostname, serverLocation, err := s.getFsdServerInfo()
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
type TemplateData []DataJsonServer
|
||||
tmplData := TemplateData{
|
||||
{
|
||||
Ident: serverIdent,
|
||||
HostnameOrIp: serverHostname,
|
||||
Location: serverLocation,
|
||||
Name: serverIdent,
|
||||
ClientConnectionsAllowed: true,
|
||||
ClientsConnectionAllowed: 99,
|
||||
IsSweatbox: false,
|
||||
},
|
||||
}
|
||||
|
||||
buf := bytes.Buffer{}
|
||||
buf.Grow(1024)
|
||||
if err = serversTxtTemplate.Execute(&buf, &tmplData); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure all newlines have a carriage return
|
||||
txt = strings.ReplaceAll(buf.String(), "\n", "\r\n")
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) getFsdServerInfo() (serverIdent string, serverHostname string, serverLocation string, err error) {
|
||||
serverIdent, err = s.dbRepo.ConfigRepo.Get(db.ConfigFsdServerIdent)
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
serverHostname, err = s.dbRepo.ConfigRepo.Get(db.ConfigFsdServerHostname)
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
serverLocation, err = s.dbRepo.ConfigRepo.Get(db.ConfigFsdServerLocation)
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func writePlaintext500Error(c *gin.Context, msg string) {
|
||||
c.Writer.Header().Set("Content-Type", "text/plain")
|
||||
c.Writer.WriteHeader(http.StatusInternalServerError)
|
||||
c.Writer.WriteString(msg)
|
||||
}
|
||||
|
||||
func (s *Server) getBaseURLOrErr(c *gin.Context) (baseURL string, ok bool) {
|
||||
baseURL, err := s.dbRepo.ConfigRepo.Get(db.ConfigApiServerBaseURL)
|
||||
if err != nil {
|
||||
c.Writer.WriteHeader(http.StatusInternalServerError)
|
||||
if !errors.Is(err, db.ErrConfigKeyNotFound) {
|
||||
slog.Error(err.Error())
|
||||
return
|
||||
}
|
||||
errMsg := "API server base URL is not set in the config"
|
||||
slog.Error(errMsg)
|
||||
c.Writer.WriteString(errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
|
||||
type Datafeed struct {
|
||||
Pilots []fsd.OnlineUserPilot `json:"pilots"`
|
||||
ATC []fsd.OnlineUserATC `json:"atc"`
|
||||
}
|
||||
|
||||
type DatafeedCache struct {
|
||||
jsonStr string
|
||||
lastUpdated time.Time
|
||||
}
|
||||
|
||||
var datafeedCache atomic.Pointer[DatafeedCache]
|
||||
|
||||
func (s *Server) getDatafeed(c *gin.Context) {
|
||||
feed := datafeedCache.Load()
|
||||
if feed == nil {
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
c.Writer.WriteString(feed.jsonStr)
|
||||
}
|
||||
|
||||
func (s *Server) generateDatafeed() (feed *DatafeedCache, err error) {
|
||||
// Generate JWT bearer token
|
||||
customFields := fsd.CustomFields{
|
||||
TokenType: "fsd_service",
|
||||
CID: -1,
|
||||
NetworkRating: fsd.NetworkRatingAdministator,
|
||||
}
|
||||
token, err := fsd.MakeJwtToken(&customFields, 15*time.Minute)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
secretKey, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tokenStr, err := token.SignedString([]byte(secretKey))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
client := http.Client{}
|
||||
req, err := http.NewRequest("GET", s.cfg.FsdHttpServiceAddress+"/online_users", nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tokenStr)
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
err = errors.New("FSD HTTP service returned a non-200 status code")
|
||||
return
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(res.Body)
|
||||
onlineUsers := fsd.OnlineUsersResponseData{}
|
||||
if err = decoder.Decode(&onlineUsers); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
dataFeed := Datafeed{
|
||||
Pilots: onlineUsers.Pilots,
|
||||
ATC: onlineUsers.ATC,
|
||||
}
|
||||
buf := bytes.Buffer{}
|
||||
encoder := json.NewEncoder(&buf)
|
||||
if err = encoder.Encode(&dataFeed); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
feed = &DatafeedCache{
|
||||
jsonStr: buf.String(),
|
||||
lastUpdated: time.Now(),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) runDatafeedWorker(ctx context.Context) {
|
||||
s.updateDataFeedCache()
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.updateDataFeedCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) updateDataFeedCache() {
|
||||
feed, err := s.generateDatafeed()
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
return
|
||||
}
|
||||
datafeedCache.Store(feed)
|
||||
}
|
||||
12
web/data_templates/servers.txt
Normal file
12
web/data_templates/servers.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
!GENERAL:
|
||||
VERSION = 8
|
||||
RELOAD = 2
|
||||
UPDATE = 20220401021210
|
||||
ATIS ALLOW MIN = 5
|
||||
CONNECTED CLIENTS = 1
|
||||
;
|
||||
;
|
||||
!SERVERS:
|
||||
{{ range . }}{{ .Ident }}:{{ .HostnameOrIp }}:{{ .Location }}:{{ .Ident }}:{{ .ClientsConnectionAllowed }}:{{ end }}
|
||||
;
|
||||
; END
|
||||
23
web/data_templates/status.txt
Normal file
23
web/data_templates/status.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
; IMPORTANT NOTE: This file can change as data sources change. Please check at regular intervals.
|
||||
;
|
||||
; PEOPLE UTILISING THIS FEED ARE STRONGLY ENCOURAGED TO MIGRATE TO {{ .ApiServerBaseURL }}/api/v1/data/status.json
|
||||
;
|
||||
; Data formats are:
|
||||
;
|
||||
; 120128:NOTCP - used by WhazzUp only
|
||||
; json3 - JSON Data Version 3
|
||||
; url1 - URLs where servers list data files are available. Please choose one randomly every time
|
||||
;
|
||||
;
|
||||
120218:NOTCP
|
||||
;
|
||||
json3={{ .ApiServerBaseURL }}/api/v1/data/openfsd-data.json
|
||||
;
|
||||
url1={{ .ApiServerBaseURL }}/api/v1/data/openfsd-servers.txt
|
||||
;
|
||||
servers.live={{ .ApiServerBaseURL }}/api/v1/data/openfsd-servers.txt
|
||||
;
|
||||
voice0=afv
|
||||
;
|
||||
; END
|
||||
|
||||
25
web/env.go
Normal file
25
web/env.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/sethvargo/go-envconfig"
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
ListenAddr string `env:"LISTEN_ADDR, default=:8000"` // HTTP listen address
|
||||
|
||||
DatabaseDriver string `env:"DATABASE_DRIVER, default=sqlite"` // Golang sql database driver name
|
||||
DatabaseSourceName string `env:"DATABASE_SOURCE_NAME, default=:memory:"` // Golang sql database source name
|
||||
DatabaseAutoMigrate bool `env:"DATABASE_AUTO_MIGRATE, default=false"` // Whether to automatically run database migrations on startup
|
||||
DatabaseMaxConns int `env:"DATABASE_MAX_CONNS, default=1"` // Max number of database connections
|
||||
|
||||
FsdHttpServiceAddress string `env:"FSD_HTTP_SERVICE_ADDRESS, required"` // HTTP address to talk to the FSD http service
|
||||
}
|
||||
|
||||
func loadServerConfig(ctx context.Context) (config *ServerConfig, err error) {
|
||||
config = &ServerConfig{}
|
||||
if err = envconfig.Process(ctx, config); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
50
web/main.go
50
web/main.go
@@ -2,51 +2,21 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"github.com/renorris/openfsd/db"
|
||||
"os"
|
||||
"os/signal"
|
||||
)
|
||||
|
||||
func main() {
|
||||
sqlDb, err := sql.Open("sqlite", ":memory:")
|
||||
ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
|
||||
os.Setenv("DATABASE_DRIVER", "sqlite")
|
||||
os.Setenv("DATABASE_SOURCE_NAME", "../test.db")
|
||||
os.Setenv("FSD_HTTP_SERVICE_ADDRESS", "http://localhost:13618")
|
||||
|
||||
server, err := NewDefaultServer(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = db.Migrate(sqlDb); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
dbRepo, err := db.NewRepositories(sqlDb)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
strPtr := func(str string) *string {
|
||||
return &str
|
||||
}
|
||||
|
||||
if err = dbRepo.UserRepo.CreateUser(&db.User{
|
||||
FirstName: strPtr("Default Administrator"),
|
||||
Password: "12345",
|
||||
NetworkRating: 12,
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = dbRepo.ConfigRepo.Set(db.ConfigJwtSecretKey, "abcdef")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = dbRepo.ConfigRepo.InitDefault()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
server, err := NewServer(dbRepo)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
server.Run(context.Background(), "0.0.0.0:8080")
|
||||
server.Run(ctx)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ func (s *Server) setupRoutes() (e *gin.Engine) {
|
||||
s.setupAuthRoutes(apiV1Group)
|
||||
s.setupUserRoutes(apiV1Group)
|
||||
s.setupConfigRoutes(apiV1Group)
|
||||
s.setupDataRoutes(apiV1Group)
|
||||
|
||||
// Frontend groups
|
||||
s.setupFrontendRoutes(e.Group(""))
|
||||
@@ -56,6 +57,20 @@ func (s *Server) setupConfigRoutes(parent *gin.RouterGroup) {
|
||||
configGroup.POST("/createtoken", s.handleCreateNewAPIToken)
|
||||
}
|
||||
|
||||
func (s *Server) setupDataRoutes(parent *gin.RouterGroup) {
|
||||
dataGroup := parent.Group("/data")
|
||||
dataGroup.GET("/status.txt", s.handleGetStatusTxt)
|
||||
dataGroup.GET("/status.json", s.handleGetStatusJSON)
|
||||
dataGroup.GET("/openfsd-servers.txt", s.handleGetServersTxt)
|
||||
dataGroup.GET("/openfsd-servers.json", s.handleGetServersJSON)
|
||||
dataGroup.GET("/sweatbox-servers.json", func(c *gin.Context) {
|
||||
c.Set("is_sweatbox", "true")
|
||||
s.handleGetServersJSON(c)
|
||||
})
|
||||
dataGroup.GET("/all-servers.json", s.handleGetServersJSON)
|
||||
dataGroup.GET("/openfsd-data.json", s.getDatafeed)
|
||||
}
|
||||
|
||||
func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) {
|
||||
frontendGroup := parent.Group("")
|
||||
frontendGroup.GET("", s.handleFrontendLanding)
|
||||
|
||||
@@ -2,24 +2,73 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/renorris/openfsd/db"
|
||||
"log/slog"
|
||||
"net"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *ServerConfig
|
||||
dbRepo *db.Repositories
|
||||
}
|
||||
|
||||
func NewServer(dbRepo *db.Repositories) (server *Server, err error) {
|
||||
func NewDefaultServer(ctx context.Context) (server *Server, err error) {
|
||||
cfg, err := loadServerConfig(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info(fmt.Sprintf("using %s", cfg.DatabaseDriver))
|
||||
|
||||
slog.Debug("connecting to SQL")
|
||||
sqlDb, err := sql.Open(cfg.DatabaseDriver, cfg.DatabaseSourceName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
slog.Debug("SQL OK")
|
||||
|
||||
sqlDb.SetMaxOpenConns(cfg.DatabaseMaxConns)
|
||||
|
||||
dbRepo, err := db.NewRepositories(sqlDb)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if server, err = NewServer(cfg, dbRepo); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func NewServer(cfg *ServerConfig, dbRepo *db.Repositories) (server *Server, err error) {
|
||||
server = &Server{
|
||||
cfg: cfg,
|
||||
dbRepo: dbRepo,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) Run(ctx context.Context, addr string) (err error) {
|
||||
func (s *Server) Run(ctx context.Context) (err error) {
|
||||
e := s.setupRoutes()
|
||||
e.Run(addr)
|
||||
go s.runDatafeedWorker(ctx)
|
||||
|
||||
listener, err := net.Listen("tcp", s.cfg.ListenAddr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
go func() {
|
||||
if err := e.RunListener(listener); err != nil {
|
||||
slog.Error(err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ async function doAPIRequest(method, url, withAuth, data) {
|
||||
}).done((res) => {
|
||||
resolve(res)
|
||||
}).fail((xhr) => {
|
||||
logout()
|
||||
reject(xhr)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
const keyLabels = {
|
||||
"WELCOME_MESSAGE": "Welcome Message",
|
||||
"WELCOME_MESSAGE": {
|
||||
"name": "Welcome Message",
|
||||
"description": "Welcome message sent to FSD clients after they connect",
|
||||
"type": "text",
|
||||
"placeholder": "Welcome to my FSD server!"
|
||||
},
|
||||
"FSD_SERVER_HOSTNAME": {
|
||||
"name": "FSD Server Hostname",
|
||||
"description": "Server hostname advertised to clients",
|
||||
"type": "text",
|
||||
"placeholder": "myfsdserver.com"
|
||||
},
|
||||
"FSD_SERVER_IDENT": {
|
||||
"name": "FSD Server Ident",
|
||||
"description": "Server ident advertised to clients",
|
||||
"type": "text",
|
||||
"placeholder": "MY-FSD-SERVER"
|
||||
},
|
||||
"FSD_SERVER_LOCATION": {
|
||||
"name": "FSD Server Location",
|
||||
"description": "Geographical server location advertised to clients",
|
||||
"type": "text",
|
||||
"placeholder": "East US",
|
||||
},
|
||||
"API_SERVER_BASE_URL": {
|
||||
"name": "API Server Base URL",
|
||||
"description": "API server base URL advertised to clients",
|
||||
"type": "text",
|
||||
"placeholder": "https://example.com"
|
||||
},
|
||||
};
|
||||
|
||||
// Function to show message modal
|
||||
@@ -35,12 +64,14 @@ async function loadConfig() {
|
||||
const configForm = document.getElementById('config-form');
|
||||
configForm.innerHTML = ''; // Clear existing fields
|
||||
res.data.key_value_pairs.forEach(kv => {
|
||||
const label = keyLabels[kv.key] || kv.key;
|
||||
const label = keyLabels[kv.key].name || kv.key;
|
||||
const desc = keyLabels[kv.key].description || kv.key
|
||||
const div = document.createElement('div');
|
||||
div.className = 'mb-3';
|
||||
div.innerHTML = `
|
||||
<label for="${kv.key}" class="form-label">${label}</label>
|
||||
<input type="text" class="form-control" id="${kv.key}" value="${kv.value}" data-key="${kv.key}">
|
||||
<input type="${keyLabels[kv.key].type}" class="form-control" id="${kv.key}" value="${kv.value}" data-key="${kv.key}" placeholder="${keyLabels[kv.key].placeholder}">
|
||||
<div class="form-text">${desc}</div>
|
||||
`;
|
||||
configForm.appendChild(div);
|
||||
});
|
||||
@@ -58,7 +89,7 @@ document.getElementById('add-config').addEventListener('click', function() {
|
||||
// Create dropdown options from keyLabels
|
||||
let options = '';
|
||||
Object.keys(keyLabels).forEach(key => {
|
||||
options += `<option value="${key}">${keyLabels[key]}</option>`;
|
||||
options += `<option value="${key}">${keyLabels[key].name}</option>`;
|
||||
});
|
||||
div.innerHTML = `
|
||||
<label class="form-label">New Key</label>
|
||||
@@ -68,8 +99,31 @@ document.getElementById('add-config').addEventListener('click', function() {
|
||||
</select>
|
||||
<label class="form-label">Value</label>
|
||||
<input type="text" class="form-control" placeholder="Value" data-type="new-value">
|
||||
<div class="form-text" id="new-value-description"></div>
|
||||
`;
|
||||
configForm.appendChild(div);
|
||||
|
||||
// Add event listener to update input type based on selected key
|
||||
const select = div.querySelector('select[data-type="new-key"]');
|
||||
const valueInput = div.querySelector('input[data-type="new-value"]');
|
||||
select.addEventListener('change', function() {
|
||||
const selectedKey = select.value;
|
||||
if (selectedKey && keyLabels[selectedKey]) {
|
||||
const inputType = keyLabels[selectedKey].type;
|
||||
if (inputType === 'checkbox') {
|
||||
valueInput.type = 'checkbox';
|
||||
valueInput.removeAttribute('placeholder');
|
||||
valueInput.classList.add('form-check-input');
|
||||
valueInput.value = 'true'; // Default for checkbox
|
||||
} else {
|
||||
valueInput.type = inputType;
|
||||
valueInput.setAttribute('placeholder', keyLabels[selectedKey].placeholder);
|
||||
valueInput.classList.remove('form-check-input');
|
||||
valueInput.value = ''; // Clear value for text input
|
||||
}
|
||||
}
|
||||
document.getElementById("new-value-description").innerText = keyLabels[selectedKey].description
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('save-config').addEventListener('click', async function() {
|
||||
@@ -78,9 +132,11 @@ document.getElementById('save-config').addEventListener('click', async function(
|
||||
// Existing configs
|
||||
const existingInputs = document.querySelectorAll('#config-form input[data-key]');
|
||||
existingInputs.forEach(input => {
|
||||
const key = input.getAttribute('data-key');
|
||||
const value = keyLabels[key].type === 'checkbox' ? input.checked.toString() : input.value;
|
||||
keyValuePairs.push({
|
||||
key: input.getAttribute('data-key'),
|
||||
value: input.value
|
||||
key: key,
|
||||
value: value
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,9 +146,11 @@ document.getElementById('save-config').addEventListener('click', async function(
|
||||
const keySelect = div.querySelector('select[data-type="new-key"]');
|
||||
const valueInput = div.querySelector('input[data-type="new-value"]');
|
||||
if (keySelect && valueInput && keySelect.value.trim() !== '') {
|
||||
const key = keySelect.value;
|
||||
const value = keyLabels[key].type === 'checkbox' ? valueInput.checked.toString() : valueInput.value;
|
||||
keyValuePairs.push({
|
||||
key: keySelect.value,
|
||||
value: valueInput.value
|
||||
key: key,
|
||||
value: value
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -186,4 +244,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadConfig);
|
||||
document.addEventListener('DOMContentLoaded', loadConfig);
|
||||
|
||||
Reference in New Issue
Block a user