├── .dockerignore ├── .gitignore ├── Dockerfile_fsd ├── Dockerfile_web ├── LICENSE ├── README.md ├── db ├── config_postgres.go ├── config_repository.go ├── config_sqlite.go ├── config_sqlite_test.go ├── migrations.go ├── migrations │ ├── postgres │ │ ├── 20250427232209_create_users_table.down.sql │ │ ├── 20250427232209_create_users_table.up.sql │ │ ├── 20250515185717_create_config_table.down.sql │ │ └── 20250515185717_create_config_table.up.sql │ └── sqlite │ │ ├── 20250427232213_create_users_table.down.sql │ │ ├── 20250427232213_create_users_table.up.sql │ │ ├── 20250515185720_create_config_table.down.sql │ │ └── 20250515185720_create_config_table.up.sql ├── repositories.go ├── user_postgres.go ├── user_repository.go ├── user_sqlite.go └── user_sqlite_test.go ├── docker-compose.yml ├── docs ├── about.md ├── authentication-token.md ├── enumerations.md ├── index.md ├── protocol.md └── vatsim-auth.md ├── fsd ├── client.go ├── conn.go ├── env.go ├── handler.go ├── http_service.go ├── jwt.go ├── metar.go ├── metar_test.go ├── packet.go ├── postoffice.go ├── postoffice_test.go ├── server.go ├── util.go ├── util_test.go ├── vatsimauth.go └── vatsimauth_test.go ├── go.mod ├── go.sum ├── main.go ├── mkdocs.yml ├── run-windows.bat └── web ├── README.md ├── api_tokens.go ├── api_v1_response.go ├── auth.go ├── config.go ├── data.go ├── data_templates ├── servers.txt └── status.txt ├── env.go ├── frontend.go ├── fsdconn.go ├── main.go ├── routes.go ├── server.go ├── static ├── css │ ├── bootstrap.min.css │ └── leaflet.css ├── images │ └── plane.png └── js │ ├── bootstrap.bundle.min.js │ ├── jquery-3.7.1.min.js │ ├── leaflet.js │ └── openfsd │ ├── api.js │ ├── configeditor.js │ ├── dashboard.js │ ├── leaflet.rotatedmarker.js │ ├── login.js │ └── usereditor.js ├── templates.go ├── templates ├── configeditor.html ├── dashboard.html ├── landing.html ├── layout.html ├── login.html └── usereditor.html ├── user.go └── util.go /.dockerignore: -------------------------------------------------------------------------------- 1 | docker-compose.yml 2 | .gitignore 3 | mkdocs.yml 4 | README.md 5 | docs 6 | **tmp** 7 | build-and-push.sh 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .vscode 4 | *.db 5 | **tmp** 6 | build-and-push.sh 7 | -------------------------------------------------------------------------------- /Dockerfile_fsd: -------------------------------------------------------------------------------- 1 | LABEL org.opencontainers.image.source=https://github.com/renorris/openfsd 2 | 3 | FROM golang:1.24 AS build 4 | 5 | WORKDIR /go/src/fsd 6 | COPY go.mod go.sum ./ 7 | 8 | # Cache module downloads 9 | RUN go mod download 10 | 11 | COPY . . 12 | 13 | # Cache builds 14 | ENV GOCACHE=/root/.cache/go-build 15 | RUN --mount=type=cache,target="/root/.cache/go-build" CGO_ENABLED=0 go build -o /go/bin/fsd 16 | 17 | FROM alpine:latest 18 | 19 | RUN addgroup -g 2001 nonroot && \ 20 | adduser -u 2001 -G nonroot -D nonroot && \ 21 | mkdir /db && chown -R nonroot:nonroot /db 22 | 23 | COPY --from=build --chown=nonroot:nonroot /go/bin/fsd / 24 | 25 | USER 2001:2001 26 | 27 | CMD ["/fsd"] 28 | -------------------------------------------------------------------------------- /Dockerfile_web: -------------------------------------------------------------------------------- 1 | LABEL org.opencontainers.image.source=https://github.com/renorris/openfsd 2 | 3 | FROM golang:1.24 AS build 4 | 5 | WORKDIR /go/src/fsdweb 6 | COPY go.mod go.sum ./ 7 | 8 | # Cache module downloads 9 | RUN go mod download 10 | 11 | COPY . . 12 | 13 | # Cache builds 14 | ENV GOCACHE=/root/.cache/go-build 15 | RUN --mount=type=cache,target="/root/.cache/go-build" \ 16 | cd web && \ 17 | CGO_ENABLED=0 go build -o /go/bin/fsdweb 18 | 19 | FROM alpine:latest 20 | 21 | RUN addgroup -g 2001 nonroot && \ 22 | adduser -u 2001 -G nonroot -D nonroot && \ 23 | mkdir /db && chown -R nonroot:nonroot /db 24 | 25 | COPY --from=build --chown=nonroot:nonroot /go/bin/fsdweb / 26 | 27 | USER 2001:2001 28 | 29 | CMD ["/fsdweb"] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Reese Norris 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # openfsd 2 | 3 | [![license](https://img.shields.io/github/license/renorris/openfsd)](https://github.com/renorris/openfsd/blob/main/LICENSE) 4 | 5 | **openfsd** is an open-source multiplayer flight simulation server implementing the modern VATSIM FSD protocol. It connects pilots and air traffic controllers in a shared virtual environment. 6 | 7 | ## About 8 | 9 | Flight Sim Daemon (colloquially known as FSD) is the software/protocol responsible for connecting home flight simulator clients to a single, shared multiplayer world on hobbyist networks such as [VATSIM](https://vatsim.net/docs/about/about-vatsim) and [IVAO](https://www.ivao.aero/). 10 | FSD was originally written in the late 90's by [Marty Bochane](https://github.com/kuroneko/fsd) for [SATCO](https://web.archive.org/web/20000619145015/http://www.satco.org/), later to be forked and taken closed-source by VATSIM in 2001. 11 | As of May 2025, FSD is still used to facilitate over 140,000 active members connecting their flight simulators to the [network](https://vatsim-radar.com/). 12 | 13 | ## Features 14 | 15 | - Facilitate multiplayer flight simulation with VATSIM protocol compatibility. 16 | - Integrate web-based management for users, settings, and connections. 17 | - Support SQLite and PostgreSQL for persistent storage. 18 | 19 | ## Quick Start with Docker 20 | 21 | The preferred way to run openfsd is using **Docker** and **Docker Compose**. See the [Deployment Wiki](https://github.com/renorris/openfsd/wiki/Deployment). 22 | 23 | ### Prerequisites 24 | 25 | - [Docker](https://docs.docker.com/get-docker/) 26 | - [Docker Compose](https://docs.docker.com/compose/install/) 27 | 28 | ### Steps 29 | 30 | 1. **Clone the Repository**: 31 | ```bash 32 | git clone https://github.com/renorris/openfsd.git 33 | cd openfsd 34 | ``` 35 | 36 | 2. **Start with Docker Compose**: 37 | ```bash 38 | docker-compose up -d 39 | ``` 40 | This launches the FSD server and web server sharing an SQLite database persisted in a named Docker volume. This setup will work great for most people running small servers. 41 | 42 | 3. **Configure the Server via Web Interface**: 43 | - Open `http://localhost:8000` in a browser. 44 | - Log in with the default administrator credentials (printed in the FSD server logs on first startup). 45 | - Navigate to the **Configure Server** menu 46 | - Set configuration values. See the [Configuration](https://github.com/renorris/openfsd/wiki/Configuration) wiki. 47 | 48 | 4. **Connect**: 49 | See the [Client Connection Wiki](https://github.com/renorris/openfsd/wiki/Client-Connection) for client-specific instructions. 50 | 51 | ## API 52 | 53 | The web server exposes APIs under `/api/v1` for authentication, user management, and configuration. Although a basic web interface is provided, users are encouraged to call this API from their own external applications. See the [API](https://github.com/renorris/openfsd/tree/main/web) documentation. 54 | 55 | ## Docs 56 | 57 | Unofficial reverse-engineered protocol documentation is included in this repository: 58 | 59 | ``` 60 | pip install mkdocs 61 | git clone git@github.com:renorris/openfsd.git 62 | cd openfsd/ 63 | mkdocs serve 64 | ``` 65 | -------------------------------------------------------------------------------- /db/config_postgres.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | ) 7 | 8 | type PostgresConfigRepository struct { 9 | db *sql.DB 10 | } 11 | 12 | // InitDefault initializes the default configuration values. 13 | func (p *PostgresConfigRepository) InitDefault() (err error) { 14 | if err = p.ensureSecretKeyExists(); err != nil { 15 | return 16 | } 17 | return 18 | } 19 | 20 | func (p *PostgresConfigRepository) ensureSecretKeyExists() (err error) { 21 | secretKey, err := GenerateJwtSecretKey() 22 | if err != nil { 23 | return 24 | } 25 | 26 | querystr := ` 27 | INSERT INTO config (key, value) 28 | SELECT $1, $2 29 | WHERE NOT EXISTS ( 30 | SELECT 1 FROM config WHERE key = $1 31 | );` 32 | _, err = p.db.Exec(querystr, ConfigJwtSecretKey, secretKey) 33 | return 34 | } 35 | 36 | // Set sets the value for the given key in the configuration. 37 | // If the key already exists, it updates the value. 38 | func (p *PostgresConfigRepository) Set(key string, value string) (err error) { 39 | querystr := ` 40 | INSERT INTO config (key, value) VALUES ($1, $2) 41 | ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; 42 | ` 43 | _, err = p.db.Exec(querystr, key, value) 44 | return 45 | } 46 | 47 | func (p *PostgresConfigRepository) SetIfNotExists(key string, value string) (err error) { 48 | querystr := ` 49 | INSERT INTO config (key, value) VALUES ($1, $2) 50 | ON CONFLICT (key) DO NOTHING; 51 | ` 52 | _, err = p.db.Exec(querystr, key, value) 53 | return 54 | } 55 | 56 | // Get retrieves the value for the given key from the configuration. 57 | // If the key does not exist, it returns ErrConfigKeyNotFound. 58 | func (p *PostgresConfigRepository) Get(key string) (value string, err error) { 59 | querystr := ` 60 | SELECT value FROM config WHERE key = $1; 61 | ` 62 | err = p.db.QueryRow(querystr, key).Scan(&value) 63 | if err != nil { 64 | if errors.Is(err, sql.ErrNoRows) { 65 | err = ErrConfigKeyNotFound 66 | } 67 | return 68 | } 69 | return 70 | } 71 | -------------------------------------------------------------------------------- /db/config_repository.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "errors" 7 | "io" 8 | ) 9 | 10 | type ConfigRepository interface { 11 | // Set sets a value for a given key 12 | Set(key string, value string) (err error) 13 | 14 | // SetIfNotExists sets a value for a given key if it does not already exist 15 | SetIfNotExists(key string, value string) (err error) 16 | 17 | // Get gets a value for a given key. 18 | // 19 | // Returns ErrConfigKeyNotFound if no key/value pair is found. 20 | Get(key string) (value string, err error) 21 | } 22 | 23 | const ( 24 | ConfigJwtSecretKey = "JWT_SECRET_KEY" 25 | 26 | ConfigFsdServerHostname = "FSD_SERVER_HOSTNAME" 27 | ConfigFsdServerIdent = "FSD_SERVER_IDENT" 28 | ConfigFsdServerLocation = "FSD_SERVER_LOCATION" 29 | 30 | ConfigApiServerBaseURL = "API_SERVER_BASE_URL" 31 | 32 | ConfigWelcomeMessage = "WELCOME_MESSAGE" 33 | ) 34 | 35 | var ErrConfigKeyNotFound = errors.New("config: key not found") 36 | 37 | const secretKeyBits = 256 38 | 39 | func GenerateJwtSecretKey() (key [secretKeyBits / 8]byte, err error) { 40 | secretKey := make([]byte, (secretKeyBits/8)/2) 41 | if _, err = io.ReadFull(rand.Reader, secretKey); err != nil { 42 | return 43 | } 44 | 45 | hex.Encode(key[:], secretKey) 46 | 47 | return 48 | } 49 | 50 | // GetWelcomeMessage returns any configured welcome message. 51 | // Returns an empty string if no message is found. 52 | func GetWelcomeMessage(r *ConfigRepository) (msg string) { 53 | msg, _ = (*r).Get(ConfigWelcomeMessage) 54 | return 55 | } 56 | 57 | func InitDefaultConfig(r *ConfigRepository) (err error) { 58 | secretKey, err := GenerateJwtSecretKey() 59 | if err != nil { 60 | return 61 | } 62 | 63 | defaultConfig := map[string]string{ 64 | ConfigJwtSecretKey: string(secretKey[:]), 65 | ConfigWelcomeMessage: "Connected to openfsd", 66 | ConfigFsdServerHostname: "localhost", 67 | ConfigFsdServerIdent: "OPENFSD", 68 | ConfigFsdServerLocation: "Earth", 69 | ConfigApiServerBaseURL: "http://localhost", 70 | } 71 | 72 | for k, v := range defaultConfig { 73 | if err = (*r).SetIfNotExists(k, v); err != nil { 74 | return 75 | } 76 | } 77 | 78 | return 79 | } 80 | -------------------------------------------------------------------------------- /db/config_sqlite.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | ) 7 | 8 | type SQLiteConfigRepository struct { 9 | db *sql.DB 10 | } 11 | 12 | func (s *SQLiteConfigRepository) SetIfNotExists(key string, value string) (err error) { 13 | querystr := ` 14 | INSERT INTO config (key, value) VALUES (?, ?) 15 | ON CONFLICT(key) DO NOTHING; 16 | ` 17 | if _, err = s.db.Exec(querystr, key, value); err != nil { 18 | return 19 | } 20 | return 21 | } 22 | 23 | func (s *SQLiteConfigRepository) Set(key string, value string) (err error) { 24 | querystr := ` 25 | INSERT INTO config (key, value) VALUES (?, ?) 26 | ON CONFLICT(key) DO UPDATE SET value = excluded.value; 27 | ` 28 | if _, err = s.db.Exec(querystr, key, value); err != nil { 29 | return 30 | } 31 | return 32 | } 33 | 34 | func (s *SQLiteConfigRepository) Get(key string) (value string, err error) { 35 | querystr := ` 36 | SELECT value FROM config WHERE key = ?; 37 | ` 38 | if err = s.db.QueryRow(querystr, key).Scan(&value); err != nil { 39 | if errors.Is(err, sql.ErrNoRows) { 40 | err = ErrConfigKeyNotFound 41 | } 42 | return 43 | } 44 | return 45 | } 46 | -------------------------------------------------------------------------------- /db/config_sqlite_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | _ "modernc.org/sqlite" 7 | "testing" 8 | ) 9 | 10 | // setupConfigTestDB initializes an in-memory SQLite database, applies migrations, and returns the database connection and repository. 11 | func setupConfigTestDB(t *testing.T) (*sql.DB, *SQLiteConfigRepository) { 12 | db, err := sql.Open("sqlite", ":memory:") 13 | if err != nil { 14 | t.Fatalf("failed to open database: %v", err) 15 | } 16 | 17 | err = Migrate(db) 18 | if err != nil { 19 | t.Fatalf("failed to migrate database: %v", err) 20 | } 21 | 22 | repo := &SQLiteConfigRepository{db: db} 23 | return db, repo 24 | } 25 | 26 | // TestInitDefault verifies that InitDefault correctly inserts the JWT secret key and does not overwrite it on subsequent calls. 27 | func TestInitDefault(t *testing.T) { 28 | db, repo := setupConfigTestDB(t) 29 | defer db.Close() 30 | 31 | // Initially, the key should not exist 32 | _, err := repo.Get(ConfigJwtSecretKey) 33 | if !errors.Is(err, ErrConfigKeyNotFound) { 34 | t.Errorf("expected ErrConfigKeyNotFound, got %v", err) 35 | } 36 | 37 | // Call InitDefault 38 | err = repo.InitDefault() 39 | if err != nil { 40 | t.Errorf("expected no error, got %v", err) 41 | } 42 | 43 | // Verify the key exists and is a 32-character hex string 44 | value, err := repo.Get(ConfigJwtSecretKey) 45 | if err != nil { 46 | t.Errorf("expected no error, got %v", err) 47 | } 48 | if len(value) != 32 { 49 | t.Errorf("expected 32-character hex string, got %s", value) 50 | } 51 | 52 | // Call InitDefault again 53 | err = repo.InitDefault() 54 | if err != nil { 55 | t.Errorf("expected no error, got %v", err) 56 | } 57 | 58 | // Verify the key has not changed 59 | newValue, err := repo.Get(ConfigJwtSecretKey) 60 | if err != nil { 61 | t.Errorf("expected no error, got %v", err) 62 | } 63 | if newValue != value { 64 | t.Errorf("expected the same value, but it changed from %s to %s", value, newValue) 65 | } 66 | } 67 | 68 | // TestSet verifies that Set correctly inserts new key-value pairs and updates existing ones. 69 | func TestSet(t *testing.T) { 70 | db, repo := setupConfigTestDB(t) 71 | defer db.Close() 72 | 73 | // Set a new key-value pair 74 | key := "test_key" 75 | value := "test_value" 76 | err := repo.Set(key, value) 77 | if err != nil { 78 | t.Errorf("expected no error, got %v", err) 79 | } 80 | 81 | // Retrieve and verify 82 | retrievedValue, err := repo.Get(key) 83 | if err != nil { 84 | t.Errorf("expected no error, got %v", err) 85 | } 86 | if retrievedValue != value { 87 | t.Errorf("expected %s, got %s", value, retrievedValue) 88 | } 89 | 90 | // Update the key with a new value 91 | newValue := "new_test_value" 92 | err = repo.Set(key, newValue) 93 | if err != nil { 94 | t.Errorf("expected no error, got %v", err) 95 | } 96 | 97 | // Retrieve and verify again 98 | retrievedValue, err = repo.Get(key) 99 | if err != nil { 100 | t.Errorf("expected no error, got %v", err) 101 | } 102 | if retrievedValue != newValue { 103 | t.Errorf("expected %s, got %s", newValue, retrievedValue) 104 | } 105 | } 106 | 107 | // TestGet verifies that Get retrieves values for existing keys and returns an error for non-existing keys. 108 | func TestGet(t *testing.T) { 109 | db, repo := setupConfigTestDB(t) 110 | defer db.Close() 111 | 112 | // Try to get a non-existing key 113 | _, err := repo.Get("non_existing_key") 114 | if !errors.Is(err, ErrConfigKeyNotFound) { 115 | t.Errorf("expected ErrConfigKeyNotFound, got %v", err) 116 | } 117 | 118 | // Set a key-value pair 119 | key := "another_key" 120 | value := "another_value" 121 | err = repo.Set(key, value) 122 | if err != nil { 123 | t.Errorf("expected no error, got %v", err) 124 | } 125 | 126 | // Retrieve and verify 127 | retrievedValue, err := repo.Get(key) 128 | if err != nil { 129 | t.Errorf("expected no error, got %v", err) 130 | } 131 | if retrievedValue != value { 132 | t.Errorf("expected %s, got %s", value, retrievedValue) 133 | } 134 | } 135 | 136 | // TestMultipleSets verifies that setting multiple keys works correctly and updating one does not affect others. 137 | func TestMultipleSets(t *testing.T) { 138 | db, repo := setupConfigTestDB(t) 139 | defer db.Close() 140 | 141 | // Set multiple keys 142 | err := repo.Set("key1", "value1") 143 | if err != nil { 144 | t.Errorf("expected no error, got %v", err) 145 | } 146 | err = repo.Set("key2", "value2") 147 | if err != nil { 148 | t.Errorf("expected no{kcal error, got %v", err) 149 | } 150 | 151 | // Retrieve and verify 152 | val1, err := repo.Get("key1") 153 | if err != nil || val1 != "value1" { 154 | t.Errorf("expected value1, got %s, err %v", val1, err) 155 | } 156 | val2, err := repo.Get("key2") 157 | if err != nil || val2 != "value2" { 158 | t.Errorf("expected value2, got %s, err %v", val2, err) 159 | } 160 | 161 | // Update one key 162 | err = repo.Set("key1", "new_value1") 163 | if err != nil { 164 | t.Errorf("expected no error, got %v", err) 165 | } 166 | 167 | // Check both keys 168 | val1, err = repo.Get("key1") 169 | if err != nil || val1 != "new_value1" { 170 | t.Errorf("expected new_value1, got %s, err %v", val1, err) 171 | } 172 | val2, err = repo.Get("key2") 173 | if err != nil || val2 != "value2" { 174 | t.Errorf("expected value2, got %s, err %v", val2, err) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /db/migrations.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "embed" 6 | "errors" 7 | "fmt" 8 | "github.com/golang-migrate/migrate/v4" 9 | "github.com/golang-migrate/migrate/v4/database" 10 | migratePostgres "github.com/golang-migrate/migrate/v4/database/postgres" 11 | migrateSqlite "github.com/golang-migrate/migrate/v4/database/sqlite" 12 | "github.com/golang-migrate/migrate/v4/source/iofs" 13 | "github.com/lib/pq" // PostgreSQL driver 14 | "modernc.org/sqlite" // SQLite driver 15 | ) 16 | 17 | //go:embed migrations 18 | var migrationsFS embed.FS 19 | 20 | // Migrate applies database migrations. 21 | func Migrate(db *sql.DB) (err error) { 22 | var driver database.Driver 23 | var dbType string 24 | var migrationPath string 25 | switch db.Driver().(type) { 26 | case *pq.Driver: 27 | dbType = "postgres" 28 | migrationPath = "migrations/postgres" 29 | driver, err = migratePostgres.WithInstance(db, &migratePostgres.Config{}) 30 | case *sqlite.Driver: 31 | dbType = "sqlite" 32 | migrationPath = "migrations/sqlite" 33 | driver, err = migrateSqlite.WithInstance(db, &migrateSqlite.Config{}) 34 | default: 35 | return fmt.Errorf("unsupported database type") 36 | } 37 | if err != nil { 38 | return err 39 | } 40 | 41 | d, err := iofs.New(migrationsFS, migrationPath) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | m, err := migrate.NewWithInstance("iofs", d, dbType, driver) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | err = m.Up() 52 | if err != nil && !errors.Is(err, migrate.ErrNoChange) { 53 | return err 54 | } 55 | 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /db/migrations/postgres/20250427232209_create_users_table.down.sql: -------------------------------------------------------------------------------- 1 | drop sequence public.users_cid_seq; 2 | 3 | drop table public.users; 4 | -------------------------------------------------------------------------------- /db/migrations/postgres/20250427232209_create_users_table.up.sql: -------------------------------------------------------------------------------- 1 | create sequence public.users_cid_seq 2 | as integer; 3 | 4 | create table public.users 5 | ( 6 | cid serial 7 | constraint users_pk 8 | default nextval('users_cid_seq') 9 | primary key, 10 | password char(60) not null, 11 | first_name varchar(255), 12 | last_name varchar(255), 13 | network_rating smallint not null 14 | ); 15 | -------------------------------------------------------------------------------- /db/migrations/postgres/20250515185717_create_config_table.down.sql: -------------------------------------------------------------------------------- 1 | drop table config; 2 | -------------------------------------------------------------------------------- /db/migrations/postgres/20250515185717_create_config_table.up.sql: -------------------------------------------------------------------------------- 1 | create table config 2 | ( 3 | key varchar not null, 4 | value varchar not null 5 | ); 6 | 7 | create unique index config_key_uindex 8 | on config (key); 9 | -------------------------------------------------------------------------------- /db/migrations/sqlite/20250427232213_create_users_table.down.sql: -------------------------------------------------------------------------------- 1 | drop table users; 2 | -------------------------------------------------------------------------------- /db/migrations/sqlite/20250427232213_create_users_table.up.sql: -------------------------------------------------------------------------------- 1 | create table users 2 | ( 3 | cid integer not null 4 | constraint users_pk 5 | primary key autoincrement, 6 | password text(60) not null, 7 | first_name text(255), 8 | last_name text(255), 9 | network_rating integer not null 10 | ); 11 | -------------------------------------------------------------------------------- /db/migrations/sqlite/20250515185720_create_config_table.down.sql: -------------------------------------------------------------------------------- 1 | drop table config; -------------------------------------------------------------------------------- /db/migrations/sqlite/20250515185720_create_config_table.up.sql: -------------------------------------------------------------------------------- 1 | create table config 2 | ( 3 | key text not null, 4 | value text not null 5 | ); 6 | 7 | create unique index config_key_uindex 8 | on config (key); 9 | -------------------------------------------------------------------------------- /db/repositories.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "github.com/lib/pq" 7 | "modernc.org/sqlite" 8 | ) 9 | 10 | // Repositories bundles all repository interfaces 11 | type Repositories struct { 12 | UserRepo UserRepository 13 | ConfigRepo ConfigRepository 14 | } 15 | 16 | // NewUserRepository creates a UserRepository based on the database driver 17 | func NewUserRepository(db *sql.DB) (UserRepository, error) { 18 | switch db.Driver().(type) { 19 | case *pq.Driver: 20 | return &PostgresUserRepository{db: db}, nil 21 | case *sqlite.Driver: 22 | return &SQLiteUserRepository{db: db}, nil 23 | default: 24 | return nil, fmt.Errorf("unsupported database") 25 | } 26 | } 27 | 28 | // NewConfigRepository creates a ConfigRepository based on the database driver 29 | func NewConfigRepository(db *sql.DB) (ConfigRepository, error) { 30 | switch db.Driver().(type) { 31 | case *pq.Driver: 32 | return &PostgresConfigRepository{db: db}, nil 33 | case *sqlite.Driver: 34 | return &SQLiteConfigRepository{db: db}, nil 35 | default: 36 | return nil, fmt.Errorf("unsupported database") 37 | } 38 | } 39 | 40 | // NewRepositories creates a Repositories bundle with implementations for the given database 41 | func NewRepositories(db *sql.DB) (repositories *Repositories, err error) { 42 | repositories = &Repositories{} 43 | if repositories.UserRepo, err = NewUserRepository(db); err != nil { 44 | return 45 | } 46 | if repositories.ConfigRepo, err = NewConfigRepository(db); err != nil { 47 | return 48 | } 49 | return 50 | } 51 | -------------------------------------------------------------------------------- /db/user_postgres.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "golang.org/x/crypto/bcrypt" 7 | "strings" 8 | ) 9 | 10 | type PostgresUserRepository struct { 11 | db *sql.DB 12 | } 13 | 14 | func (r *PostgresUserRepository) CreateUser(user *User) (err error) { 15 | // Password must not contain colon characters 16 | if strings.Contains(user.Password, ":") { 17 | err = errors.New("password cannot contain colon `:` characters") 18 | return 19 | } 20 | 21 | // Hash password 22 | hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) 23 | if err != nil { 24 | return 25 | } 26 | 27 | row := r.db.QueryRow(` 28 | INSERT INTO public.users 29 | (password, first_name, last_name, network_rating) 30 | VALUES 31 | ($1, $2, $3, $4, $5) 32 | RETURNING cid`, 33 | hash, user.FirstName, user.LastName, user.NetworkRating, 34 | ) 35 | if err = row.Err(); err != nil { 36 | return 37 | } 38 | 39 | if err = row.Scan(&user.CID); err != nil { 40 | return 41 | } 42 | 43 | return 44 | } 45 | 46 | func (r *PostgresUserRepository) GetUserByCID(cid int) (user *User, err error) { 47 | row := r.db.QueryRow(` 48 | SELECT 49 | cid, password, first_name, 50 | last_name, network_rating 51 | FROM public.users 52 | WHERE cid = $1`, 53 | cid, 54 | ) 55 | if err = row.Err(); err != nil { 56 | return 57 | } 58 | 59 | user = &User{} 60 | if err = row.Scan( 61 | &user.CID, 62 | &user.Password, 63 | &user.FirstName, 64 | &user.LastName, 65 | &user.NetworkRating, 66 | ); err != nil { 67 | return 68 | } 69 | 70 | return 71 | } 72 | 73 | func (r *PostgresUserRepository) VerifyPasswordHash(plaintext string, hash string) (ok bool) { 74 | return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) == nil 75 | } 76 | 77 | func (r *PostgresUserRepository) UpdateUser(user *User) (err error) { 78 | // Prepare query and arguments based on whether password is provided 79 | var query string 80 | var args []interface{} 81 | 82 | if user.Password != "" { 83 | // Check if password contains colon characters 84 | if strings.Contains(user.Password, ":") { 85 | return errors.New("password cannot contain colon `:` characters") 86 | } 87 | 88 | // Hash the password 89 | hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | // Include password in update 95 | query = ` 96 | UPDATE public.users 97 | SET password = $1, first_name = $2, last_name = $3, network_rating = $4 98 | WHERE cid = $5` 99 | args = []interface{}{hash, user.FirstName, user.LastName, user.NetworkRating, user.CID} 100 | } else { 101 | // Exclude password from update 102 | query = ` 103 | UPDATE public.users 104 | SET first_name = $1, last_name = $2, network_rating = $3 105 | WHERE cid = $4` 106 | args = []interface{}{user.FirstName, user.LastName, user.NetworkRating, user.CID} 107 | } 108 | 109 | // Execute the UPDATE statement 110 | result, err := r.db.Exec(query, args...) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | // Check if any rows were affected 116 | rowsAffected, err := result.RowsAffected() 117 | if err != nil { 118 | return err 119 | } 120 | if rowsAffected == 0 { 121 | return sql.ErrNoRows 122 | } 123 | 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /db/user_repository.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | type User struct { 4 | CID int 5 | Password string 6 | FirstName *string 7 | LastName *string 8 | NetworkRating int 9 | } 10 | 11 | type UserRepository interface { 12 | // CreateUser creates a new User record. 13 | // The CID value is automatically populated in the provided User struct. 14 | // 15 | // The provided password must be in plaintext. 16 | CreateUser(*User) (err error) 17 | 18 | // GetUserByCID retrieves a User record by CID. 19 | // 20 | // Returns sql.ErrNoRows when no rows are found. 21 | GetUserByCID(cid int) (*User, error) 22 | 23 | // UpdateUser updates a User record by CID. 24 | // 25 | // All fields must be provided except: 26 | // 27 | // 1. Password is only updated if a non-empty string is provided. 28 | UpdateUser(*User) error 29 | 30 | // ListUsersByCID retrieves a list of ordered User records starting at a given CID 31 | //ListUsersByCID(cid int, limit int, offset int) ([]*User, error) 32 | 33 | // VerifyPasswordHash verifies a User password hash. 34 | VerifyPasswordHash(plaintext string, hash string) (ok bool) 35 | } 36 | -------------------------------------------------------------------------------- /db/user_sqlite.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "golang.org/x/crypto/bcrypt" 7 | "strings" 8 | ) 9 | 10 | type SQLiteUserRepository struct { 11 | db *sql.DB 12 | } 13 | 14 | func (r *SQLiteUserRepository) CreateUser(user *User) (err error) { 15 | // Password must not contain colon characters 16 | if strings.Contains(user.Password, ":") { 17 | err = errors.New("password cannot contain colon `:` characters") 18 | return 19 | } 20 | 21 | // Hash password 22 | hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) 23 | if err != nil { 24 | return 25 | } 26 | 27 | row := r.db.QueryRow(` 28 | INSERT INTO users 29 | (password, first_name, last_name, network_rating) 30 | VALUES 31 | (?, ?, ?, ?) 32 | RETURNING cid`, 33 | hash, user.FirstName, user.LastName, user.NetworkRating, 34 | ) 35 | if err = row.Err(); err != nil { 36 | return 37 | } 38 | 39 | if err = row.Scan(&user.CID); err != nil { 40 | return 41 | } 42 | 43 | return 44 | } 45 | 46 | func (r *SQLiteUserRepository) GetUserByCID(cid int) (user *User, err error) { 47 | row := r.db.QueryRow(` 48 | SELECT 49 | cid, password, first_name, 50 | last_name, network_rating 51 | FROM users 52 | WHERE cid = $1`, 53 | cid, 54 | ) 55 | if err = row.Err(); err != nil { 56 | return 57 | } 58 | 59 | user = &User{} 60 | if err = row.Scan( 61 | &user.CID, 62 | &user.Password, 63 | &user.FirstName, 64 | &user.LastName, 65 | &user.NetworkRating, 66 | ); err != nil { 67 | return 68 | } 69 | 70 | return 71 | } 72 | 73 | func (r *SQLiteUserRepository) UpdateUser(user *User) (err error) { 74 | // Prepare query and arguments based on whether password is provided 75 | var query string 76 | var args []interface{} 77 | 78 | if user.Password != "" { 79 | // Check if password contains colon characters 80 | if strings.Contains(user.Password, ":") { 81 | return errors.New("password cannot contain colon `:` characters") 82 | } 83 | 84 | // Hash the password 85 | hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | // Include password in update 91 | query = ` 92 | UPDATE users 93 | SET password = ?, first_name = ?, last_name = ?, network_rating = ? 94 | WHERE cid = ?` 95 | args = []interface{}{hash, user.FirstName, user.LastName, user.NetworkRating, user.CID} 96 | } else { 97 | // Exclude password from update 98 | query = ` 99 | UPDATE users 100 | SET first_name = ?, last_name = ?, network_rating = ? 101 | WHERE cid = ?` 102 | args = []interface{}{user.FirstName, user.LastName, user.NetworkRating, user.CID} 103 | } 104 | 105 | // Execute the UPDATE statement 106 | result, err := r.db.Exec(query, args...) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | // Check if any rows were affected 112 | rowsAffected, err := result.RowsAffected() 113 | if err != nil { 114 | return err 115 | } 116 | if rowsAffected == 0 { 117 | return sql.ErrNoRows 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func (r *SQLiteUserRepository) VerifyPasswordHash(plaintext string, hash string) (ok bool) { 124 | return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) == nil 125 | } 126 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | fsd: 3 | image: ghcr.io/renorris/openfsd:fsd-latest 4 | restart: unless-stopped 5 | container_name: openfsd_fsd 6 | hostname: openfsd_fsd 7 | expose: 8 | - "13618/tcp" # Internal HTTP REST API service. The webserver talks to this in order to obtain FSD state info. 9 | ports: 10 | - "6809:6809/tcp" 11 | environment: 12 | DATABASE_SOURCE_NAME: /db/openfsd.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL) 13 | DATABASE_AUTO_MIGRATE: true 14 | volumes: 15 | - sqlite:/db 16 | 17 | fsdweb: 18 | image: ghcr.io/renorris/openfsd:web-latest 19 | restart: unless-stopped 20 | container_name: openfsd_web 21 | hostname: openfsd_web 22 | ports: 23 | - "8000:8000/tcp" 24 | environment: 25 | DATABASE_SOURCE_NAME: /db/openfsd.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL) 26 | FSD_HTTP_SERVICE_ADDRESS: "http://openfsd_fsd:13618" 27 | volumes: 28 | - sqlite:/db 29 | 30 | networks: 31 | openfsd: 32 | name: openfsd_net 33 | 34 | volumes: 35 | sqlite: 36 | -------------------------------------------------------------------------------- /docs/about.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | The FSD protocol functions primarily as a message forwarder. 4 | Aside from a few direct client-server interactions, its main purpose is to relay messages between flight simulator clients via a centralized server. 5 | This architecture is not peer-to-peer; all communication is routed through the central server. 6 | 7 | FSD is a plaintext protocol that can be easily intercepted and analyzed with tools such as Wireshark. 8 | By default, it operates on TCP port 6809, and its functionality can be tested using [telnet](https://linux.die.net/man/1/telnet): 9 | 10 | ``` 11 | telnet 6809 12 | ``` 13 | 14 | Various implementations of FSD exist, each with unique protocol nuances. 15 | This project specifically replicates VATSIM behavior, which differs from other networks such as [IVAO](https://www.ivao.aero/). 16 | 17 | - FSD messages consist of plaintext MS-DOS-style lines ending with CR/LF characters. 18 | - The protocol is strictly limited to the [ISO/IEC 8859-1](https://en.wikipedia.org/wiki/ISO/IEC_8859-1) aka. 'Latin alphabet no. 1' character set. 19 | - Each message, or 'line', begins with a 1- or 3-character packet identifier, followed by colon (`:`) delimited fields. 20 | - All numerical values are encoded as base-10 (or occasionally base-16) ASCII strings, with no raw binary data used. 21 | - Clients are identified by plaintext aviation callsigns (e.g., `N7938C`). 22 | - Most packets include "From" and "To" fields, which serve as source and recipient identifiers, respectively. 23 | - Depending on the packet type, the "To" field may specify a single recipient or a group of clients. 24 | 25 | #### Example [Server Identification](/packets/#server-identification-di) Packet 26 | 27 | ```text 28 | $DISERVER:CLIENT:VATSIM FSD V3.43:d95f57db664f\r\n 29 | ``` 30 | 31 | ##### Hexadecimal Representation 32 | 33 | ```text 34 | 00000000 24 44 49 53 45 52 56 45 52 3a 43 4c 49 45 4e 54 $DISERVE R:CLIENT 35 | 00000010 3a 56 41 54 53 49 4d 20 46 53 44 20 56 33 2e 34 :VATSIM FSD V3.4 36 | 00000020 33 3a 64 39 35 66 35 37 64 62 36 36 34 66 0d 0a 3:d95f57 db664f.. 37 | ``` 38 | 39 | ##### Explanation 40 | - Packet Type Identifier: `$DI` 41 | - Fields are delimited by colon (`:`) characters. 42 | - Sender: `SERVER` (reserved callsign for the server)
43 | - Recipient: `CLIENT` (placeholder used for an unknown client)
44 | - Server version identifier: `VATSIM FSD V3.43`
45 | - Random data (see [documentation](/packets/#server-identification-di)): `d95f57db664f` 46 | - Packet is terminated with the delimiter sequence: `\r\n` (`0x0d0a`) 47 | 48 | For technical documentation on packet types, see [Protocol](/protocol/). 49 | -------------------------------------------------------------------------------- /docs/authentication-token.md: -------------------------------------------------------------------------------- 1 | # Authentication Tokens 2 | 3 | *See [JSON Web Token](https://en.wikipedia.org/wiki/JSON_Web_Token)* 4 | 5 | FSD authentication tokens adhere to the JSON Web Token (JWT) standard. 6 | They are retrieved via HTTPS and subsequently transmitted in plaintext to the FSD server as part of the login process. 7 | 8 | Add Pilot (`#AP`) and Add ATC (`#AA`) packets previously used plaintext passwords in the Token field. 9 | Now, *any client* using *any protocol revision* must use the new authentication token. 10 | 11 | ### Endpoint 12 | 13 | ```text 14 | POST https://auth.vatsim.net/api/fsd-jwt 15 | ``` 16 | 17 | ##### Request Body 18 | 19 | ```json 20 | { 21 | "cid": "123456", 22 | "password": "s3cr3t" 23 | } 24 | ``` 25 | 26 | ##### Response Body 27 | 28 | ```json 29 | { 30 | "success": true, 31 | "token": "" 32 | } 33 | ``` 34 | 35 | ##### Response Body (Error Cases) 36 | 37 | ```json 38 | { 39 | "success": false, 40 | "error_msg": "" 41 | } 42 | ``` 43 | 44 |
45 | 46 | ### Token Fields 47 | 48 | *See [JWT Standard Fields](https://en.wikipedia.org/wiki/JSON_Web_Token#Standard_fields)* 49 | 50 | VATSIM FSD JSON Web Tokens adhere to the following format: 51 | 52 | ##### Header 53 | 54 | ```json 55 | { 56 | "typ": "JWT", 57 | "alg": "HS256" 58 | } 59 | ``` 60 | 61 | ##### Payload Example 62 | 63 | ```json 64 | { 65 | "iat": 1735772371, 66 | "nbf": 1735772251, 67 | "exp": 1735772671, 68 | "iss": "https://auth.vatsim.net/api/fsd-jwt", 69 | "sub": "123456", 70 | "aud": "fsd-live", 71 | "jti": "rK7v1yEs1TExNDI1S", 72 | "controller_rating": 0, 73 | "pilot_rating": 0 74 | } 75 | ``` 76 | Two custom `number` fields are used: `controller_rating` and `pilot_rating`.
77 | The Subject (`sub`) field specifies the user's VATSIM CID. 78 | 79 | ##### Encoded Example 80 | 81 | ```text 82 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MzU3NzIzNzEsIm5iZiI6MTczNTc3MjI1MSwiZXhwIjoxNzM1NzcyNjcxLCJpc3MiOiJodHRwczovL2F1dGgudmF0c2ltLm5ldC9hcGkvZnNkLWp3dCIsInN1YiI6IjEyMzQ1NiIsImF1ZCI6ImZzZC1saXZlIiwianRpIjoicks3djF5RXMxVEV4TkRJMVMiLCJjb250cm9sbGVyX3JhdGluZyI6MCwicGlsb3RfcmF0aW5nIjowfQ.3aqOBIqhAP9RndXN1lao9OPsqMixX2Yndn89NpsvVjA 83 | ``` 84 | -------------------------------------------------------------------------------- /docs/enumerations.md: -------------------------------------------------------------------------------- 1 | # Enumerations 2 | 3 | ## Network Ratings 4 | 5 | | Shorthand Identifier | Name | Protocol Value | Description | 6 | |----------------------|-----------------------------------|----------------|------------------------------------------------------------------------------------------------| 7 | | `OBS` | Observer | `1` | The default rating.
Observer-only permissions for ATC.
Used for pilot connections. | 8 | | `S1` | Student 1 / Tower Trainee | `2` | Initial rating given to new ATCs. | 9 | | `S2` | Student 2 / Tower Controller | `3` | All aerodrome control services: Delivery (DEL), Ground (GND) and Tower (TWR). | 10 | | `S3` | Student 3 / Senior Student | `4` | Approach (APP) and Departure (DEP) positions. | 11 | | `C1` | Controller 1 / Enroute Controller | `5` | 'Enroute' or 'Area' sectors (CTR); both radar and non-radar control services. | 12 | | `C2` | Controller 2 | `6` | Not in use. | 13 | | `C3` | Controller 3 / Senior Controller | `7` | A further rating granted by divisions. No increased privileges. | 14 | | `I1` | Instructor 1 | `8` | ATC Instructor | 15 | | `I2` | Instructor 2 | `9` | Not in use. | 16 | | `I3` | Instructor 3 / Senior Instructor | `10` | | 17 | | `SUP` | Supervisor | `11` | Responsible for answering questions, providing technical support and enforcing the VATSIM CoC. | 18 | | `ADM` | Administrator | `12` | | 19 | 20 | ## Facility Types 21 | 22 | Serialization values for different ATC facility types. 23 | 24 | | Name | Protocol Value | 25 | |-----------------------------------|----------------| 26 | | Observer | 0 | 27 | | Flight Service Station | 1 | 28 | | Delivery | 2 | 29 | | Ground | 3 | 30 | | Tower | 4 | 31 | | Approach | 5 | 32 | | Centre | 6 | 33 | 34 | # Pilot Ratings 35 | 36 | Serialization values for different pilot ratings used in the system. 37 | 38 | | Name | Protocol Value | 39 | |-------------------|----------------| 40 | | Student | 1 | 41 | | Private Pilot | 2 | 42 | | Instrument Pilot | 3 | 43 | | Flight Instructor | 4 | 44 | | DPE | 5 | 45 | 46 | ## Client Capabilities 47 | 48 | - Capabilities a client or the server can advertise to the network. 49 | - Each corresponds to a specific feature or functionality that a client supports. 50 | 51 | | Shorthand Identifier | Name | Description | 52 | |----------------------|--------------------------|-------------| 53 | | `VERSION` | Version | | 54 | | `ATCINFO` | ATC Info | | 55 | | `MODELDESC` | Model Description | | 56 | | `ACCONFIG` | Aircraft Configuration | | 57 | | `VISUPDATE` | Visual Position Updates | | 58 | | `RADARUPDATE` | Radar Updates | | 59 | | `ATCMULTI` | ATC Multi | | 60 | | `SECPOS` | Secondary Position | | 61 | | `ICAOEQ` | ICAO Equipment Suffixes | | 62 | | `FASTPOS` | Fast Position Updates | | 63 | | `ONGOINGCOORD` | Ongoing Coordination | | 64 | | `INTERIMPOS` | Interim Position Updates | | 65 | | `STEALTH` | Stealth Mode | | 66 | | `TEAMSPEAK` | TeamSpeak Integration | | 67 | | `NEWATIS` | New ATIS | | 68 | | `MUMBLE` | Mumble Integration | | 69 | | `GLOBALDATA` | Global Data | | 70 | | `SIMULATED` | Simulated | | 71 | | `OBSPILOT` | Observer/Pilot | | 72 | 73 | ## Simulator Types 74 | 75 | Serialization values for different flight simulators. 76 | 77 | | Name | Protocol Value | 78 | |-------------------------------------|----------------| 79 | | Unknown | `0` | 80 | | Microsoft Flight Simulator 95 | `1` | 81 | | Microsoft Flight Simulator 98 | `2` | 82 | | Microsoft Combat Flight Simulator | `3` | 83 | | Microsoft Flight Simulator 2000 | `4` | 84 | | Microsoft Combat Flight Simulator 2 | `5` | 85 | | Microsoft Flight Simulator 2002 | `6` | 86 | | Microsoft Combat Flight Simulator 3 | `7` | 87 | | Microsoft Flight Simulator 2004 | `8` | 88 | | Microsoft Flight Simulator X | `9` | 89 | | Microsoft Flight Simulator 2020 | `10` | 90 | | Microsoft Flight Simulator 2024 | `11` | 91 | | X-Plane 8 | `12` | 92 | | X-Plane 9 | `13` | 93 | | X-Plane 10 | `14` | 94 | | X-Plane 11 | `15` | 95 | | X-Plane 12 | `16` | 96 | | Prepar3D v1 | `17` | 97 | | Prepar3D v2 | `18` | 98 | | Prepar3D v3 | `19` | 99 | | Prepar3D v4 | `20` | 100 | | Prepar3D v5 | `21` | 101 | | FlightGear | `22` | 102 | 103 | ## Flight Rules 104 | 105 | Serialization values for the different types of flight rules. 106 | 107 | | Name | Protocol Value | 108 | |-------|----------------| 109 | | DVFR | `D` | 110 | | SVFR | `S` | 111 | | VFR | `V` | 112 | | IFR | `I` | 113 | 114 | ## Server Error Codes 115 | 116 | | Error Code | Description | 117 | |--------------------------------|------------------------------| 118 | | `0` (`NoError`) | No error | 119 | | `1` (`CallsignInUse`) | Callsign in use | 120 | | `2` (`InvalidCallsign`) | Invalid callsign | 121 | | `3` (`AlreadyRegistered`) | Already registered | 122 | | `4` (`SyntaxError`) | Syntax error | 123 | | `5` (`InvalidSrcCallsign`) | Invalid source callsign | 124 | | `6` (`InvalidCidPassword`) | Invalid CID/password | 125 | | `7` (`NoSuchCallsign`) | No such callsign | 126 | | `8` (`NoFlightPlan`) | No flight plan | 127 | | `9` (`NoWeatherProfile`) | No such weather profile | 128 | | `10` (`InvalidRevision`) | Invalid protocol revision | 129 | | `11` (`RequestedLevelTooHigh`) | Requested level too high | 130 | | `12` (`ServerFull`) | Server full | 131 | | `13` (`CidSuspended`) | CID/PID suspended | 132 | | `14` (`InvalidCtrl`) | Invalid control | 133 | | `15` (`RatingTooLow`) | Rating too low | 134 | | `16` (`InvalidClient`) | Unauthorized client software | 135 | | `17` (`AuthTimeout`) | Authorization timeout | 136 | | `18` (`Unknown`) | Unknown error | 137 | 138 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # VATSIM FSD Protocol Documentation 2 | 3 | > Updated January 2025 4 | 5 | FSD is an application-layer TCP/IP protocol facilitating real-time communications between all clients connected to the [VATSIM](https://vatsim.net) Network. 6 | 7 | This documentation is reverse-engineered. 8 | 9 | See [openfsd](https://github.com/renorris/openfsd) for an alternative, free and open-source FSD server implementation. 10 | 11 | For an introduction, see the [About](/about/) page. 12 | 13 | *This documentation is an independent work and is not affiliated with, endorsed by, or associated with VATSIM, Inc.* 14 | -------------------------------------------------------------------------------- /docs/vatsim-auth.md: -------------------------------------------------------------------------------- 1 | # VATSIM Auth 2 | 3 | ## Overview 4 | 5 | VATSIM employs a bidirectional obfuscation scheme to "verify" a client's authenticity over an FSD connection. 6 | 7 | Every VATSIM-approved client receives a unique unsigned 16-bit integer client ID and a 32-byte private key. 8 | 9 | There are two parties in an FSD connection: the client, and the server. 10 | When an FSD connection is established, the client and the server each send some random data in the [Client Identification](/protocol#client-identification-di) and [Server Identification](/protocol#server-identification-di) packets, respectively. 11 | 12 | Using all of the above values, two distinguishable Auth States are constructed, one for the server, and one for the client. 13 | 14 | ## Auth State 15 | An Auth State can be described as such: 16 | ```go 17 | type AuthState struct { 18 | clientID uint16 // Assigned Client ID 19 | initState string // Initial State 20 | currState string // Current State 21 | } 22 | ``` 23 | 24 | ## Auth State Construction 25 | 26 | To construct the initial Auth State, (once the [Client Identification](/protocol#client-identification-di) and [Server Identification](/protocol#server-identification-di) packets have been exchanged) 27 | the following operations are ran: 28 | 29 | 1. Set the Client ID using the static assigned value for our client. The same value is used for both the server and client states. 30 | 2. Set the Current State to the client's assigned private key. The same value is used for both the server and client states. 31 | 3. The server and the client each use the random data they _received_ from the _other_ side of the connection to each run their own round of the 'Obfuscation Scheme'. The random data is passed into the scheme as the "challenge" string. 32 | 4. Set the Initial State **and** the Current State to the result of this 'Obfuscation Scheme' round. 33 | 34 | ## Obfuscation Scheme 35 | 36 | The 'Obfuscation Scheme' is described as follows: 37 | 38 | Inputs: AuthState, and a "challenge" string.
39 | Output: 32-byte **hexadecimal-encoded** MD5 hash. 40 | 41 | ```go 42 | func (state *AuthState) ObfuscationScheme(challenge string) string { 43 | 44 | // Split the challenge into two halves 45 | c1, c2 := challenge[:(len(challenge)/2)], challenge[(len(challenge)/2):] 46 | 47 | // If the Client ID is an odd number, swap the two halves. 48 | if (state.clientID & 1) == 1 { 49 | c1, c2 = c2, c1 50 | } 51 | 52 | // Split the current state into three parts 53 | s1, s2, s3 := state.currState[0:12], state.currState[12:22], state.currState[22:32] 54 | 55 | // Declare a temporary buffer 56 | var h string 57 | 58 | // Interleave the above values 59 | switch state.clientID % 3 { 60 | case 0: 61 | h = s1 + c1 + s2 + c2 + s3 62 | case 1: 63 | h = s2 + c1 + s3 + c2 + s1 64 | default: 65 | h = s3 + c1 + s1 + c2 + s2 66 | } 67 | 68 | // Generate an MD5 sum from the temporary buffer's value. 69 | hash := md5.Sum([]byte(h.String())) 70 | 71 | // Return a 32-byte hexadecimal representation of the hash. 72 | return hex.EncodeToString(hash[:]) 73 | } 74 | ``` 75 | 76 | ## Interrogations 77 | 78 | Once the initial Auth States have been constructed, both sides of the connection are able to interrogate (or "challenge") the other using [Auth Challenge](/protocol#auth-challenge-zc) and [Auth Response](/protocol#auth-response-zr) packets. 79 | 80 | The mechanism is as follows: 81 | 82 | 1. An [Auth Challenge](/protocol#auth-challenge-zc) contains a random hexadecimal-encoded bytearray. 83 | 2. Along with the current Auth State, this challenge value is fed into a round of Obfuscation Scheme. 84 | 3. Send the scheme's return value to the other side of the connection using an [Auth Response](/protocol#auth-response-zr) packet. 85 | 4. Concatenate the return value of this round (a 32-byte hexadecimal-encoded byte array) onto the Initial State (initState + returnValue) resulting in a 64-byte hexadecimal encoded array. 86 | 5. Generate an MD5 sum using this 64-byte value as input. 87 | 6. Set the Current State to the _hexadecimal-representation_ of this hash sum. This value is used to compute the next round when the next [Auth Challenge](/protocol#auth-challenge-zc) packet is received. 88 | 89 | Keep in mind: the receiver of the [Auth Response](/protocol#auth-response-zr) packet must maintain a "mirror" version of the other side of the connection's Auth State in order to verify their responses. 90 | 91 | ## Known Clients 92 | 93 | A list of known clients is as follows: 94 | 95 | | Client ID | Private Key | Client Name | 96 | |-----------|------------------------------------|-------------| 97 | | `8464` | `945507c4c50222c34687e742729252e6` | vSTARS | 98 | | `10452` | `0ad74157c7f449c216bfed04f3af9fb9` | vERAM | 99 | | `24515` | `3424cbcebcca6fe95f973b350ff85cef` | vatSys | 100 | | `27095` | `3518a62c421937ffa46ac3316957da43` | Euroscope | 101 | | `33456` | `52d9343020e9c7d0c6b04b0cca20ad3b` | swift | 102 | | `35044` | `fe28334fb753cf0e3d19942197b9ce3e` | vPilot | 103 | | `55538` | `ImuL1WbbhVuD8d3MuKpWn2rrLZRa9iVP` | xPilot | 104 | | `56862` | `3518a62c421937ffa46ac3316957da43` | VRC | 105 | -------------------------------------------------------------------------------- /fsd/client.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "go.uber.org/atomic" 7 | "net" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | type Client struct { 13 | conn net.Conn 14 | scanner *bufio.Scanner 15 | ctx context.Context 16 | cancelCtx func() 17 | sendChan chan string 18 | 19 | coords atomic.Value 20 | visRange atomic.Float64 21 | closestVelocityClientDistance float64 // The closest Velocity-compatible client in meters 22 | 23 | flightPlan atomic.String 24 | assignedBeaconCode atomic.String 25 | 26 | frequency atomic.String // ATC frequency 27 | altitude atomic.Int32 // Pilot altitude 28 | groundspeed atomic.Int32 // Pilot ground speed 29 | transponder atomic.String // Active pilot transponder 30 | heading atomic.Int32 // Pilot heading 31 | lastUpdated atomic.Time // Last updated time 32 | 33 | facilityType int // ATC facility type. This value is only relevant for ATC 34 | loginData 35 | 36 | authState vatsimAuthState 37 | sendFastEnabled bool 38 | } 39 | 40 | type LatLon struct { 41 | lat, lon float64 42 | } 43 | 44 | func newClient(ctx context.Context, conn net.Conn, scanner *bufio.Scanner, loginData loginData) (client *Client) { 45 | clientCtx, cancel := context.WithCancel(ctx) 46 | client = &Client{ 47 | conn: conn, 48 | scanner: scanner, 49 | ctx: clientCtx, 50 | cancelCtx: cancel, 51 | sendChan: make(chan string, 32), 52 | loginData: loginData, 53 | } 54 | client.setLatLon(0, 0) 55 | return 56 | } 57 | 58 | func (c *Client) senderWorker() { 59 | defer c.conn.Close() 60 | defer c.cancelCtx() 61 | 62 | for { 63 | select { 64 | case packet := <-c.sendChan: 65 | if _, err := c.conn.Write([]byte(packet)); err != nil { 66 | return 67 | } 68 | case <-c.ctx.Done(): 69 | return 70 | } 71 | } 72 | } 73 | 74 | // sendError sends an FSD error packet to a Client with the specified code and message. 75 | // It returns an error if writing to the connection fails. 76 | // 77 | // This call is thread-safe 78 | func (c *Client) sendError(code int, message string) (err error) { 79 | packet := strings.Builder{} 80 | packet.Grow(128) 81 | packet.WriteString("$ERserver:unknown:") 82 | codeBuf := make([]byte, 0, 8) 83 | codeBuf = strconv.AppendInt(codeBuf, int64(code), 10) 84 | packet.Write(codeBuf) 85 | packet.WriteString("::") 86 | packet.WriteString(message) 87 | packet.WriteString("\r\n") 88 | 89 | return c.send(packet.String()) 90 | } 91 | 92 | // send sends a packet string to a Client. 93 | // This call queues the packet in the Client's outbound send channel. 94 | // This call will block until the packet can be queued in the send channel. 95 | // Returns a context error if the Client's context has elapsed. 96 | func (c *Client) send(packet string) (err error) { 97 | select { 98 | case c.sendChan <- packet: 99 | return 100 | case <-c.ctx.Done(): 101 | return c.ctx.Err() 102 | } 103 | } 104 | 105 | func (s *Server) eventLoop(client *Client) { 106 | defer client.cancelCtx() 107 | 108 | go client.senderWorker() 109 | 110 | for { 111 | if !client.scanner.Scan() { 112 | return 113 | } 114 | 115 | // Reference the next packet 116 | packet := client.scanner.Bytes() 117 | packet = append(packet, '\r', '\n') // Re-append delimiter 118 | 119 | // Verify packet and obtain type 120 | packetType, ok := verifyPacket(packet, client) 121 | if !ok { 122 | continue 123 | } 124 | 125 | // Run handler 126 | handler := s.getHandler(packetType) 127 | handler(client, packet) 128 | } 129 | } 130 | 131 | func (c *Client) latLon() [2]float64 { 132 | latLon := c.coords.Load().(LatLon) 133 | return [2]float64{latLon.lat, latLon.lon} 134 | } 135 | 136 | func (c *Client) setLatLon(lat, lon float64) { 137 | c.coords.Store(LatLon{lat: lat, lon: lon}) 138 | } 139 | -------------------------------------------------------------------------------- /fsd/conn.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "errors" 8 | "fmt" 9 | "github.com/renorris/openfsd/db" 10 | "io" 11 | "net" 12 | "strconv" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | // sendError sends an FSD error packet to an io.Writer with the specified code and message. 18 | // It returns an error if writing to the connection fails. 19 | // 20 | // This function must only be used during the login phase, 21 | // as it synchronously writes the error directly to the 22 | // connection socket. 23 | func sendError(conn io.Writer, code int, message string) (err error) { 24 | packet := fmt.Sprintf("$ERserver:unknown:%d::%s\r\n", code, message) 25 | _, err = conn.Write([]byte(packet)) 26 | return 27 | } 28 | 29 | // handleConn manages a single Client connection. 30 | // If any errors occur during the process, it sends an error to the Client and closes the connection. 31 | func (s *Server) handleConn(ctx context.Context, conn net.Conn) { 32 | defer func() { 33 | if err := recover(); err != nil { 34 | fmt.Println("An FSD connection goroutine panicked:") 35 | fmt.Println(err) 36 | } 37 | }() 38 | 39 | defer conn.Close() 40 | 41 | if err := sendServerIdent(conn); err != nil { 42 | fmt.Printf("Error sending server ident: %v\n", err) 43 | return 44 | } 45 | 46 | scanner := bufio.NewScanner(conn) 47 | buf := make([]byte, 4096) 48 | scanner.Buffer(buf, len(buf)) 49 | 50 | data, token, err := readLoginPackets(conn, scanner) 51 | if err != nil { 52 | return 53 | } 54 | 55 | // Check if the requested callsign is OK 56 | if !isValidClientCallsign([]byte(data.callsign)) { 57 | sendError(conn, CallsignInvalidError, "Callsign invalid") 58 | return 59 | } 60 | 61 | client := newClient(ctx, conn, scanner, data) 62 | 63 | // Attempt to authenticate connection 64 | if err = s.attemptAuthentication(client, token); err != nil { 65 | return 66 | } 67 | 68 | // Attempt to register to post office 69 | if err = s.postOffice.register(client); err != nil { 70 | if errors.Is(err, ErrCallsignInUse) { 71 | sendError(conn, CallsignInUseError, "Callsign already in use") 72 | } 73 | return 74 | } 75 | defer s.postOffice.release(client) 76 | 77 | // Send hello message to client 78 | if err = s.sendMotd(client); err != nil { 79 | return 80 | } 81 | 82 | // Broadcast add packet to entire server 83 | s.broadcastAddPacket(client) 84 | defer s.broadcastDisconnectPacket(client) 85 | 86 | s.eventLoop(client) 87 | } 88 | 89 | // sendServerIdent sends the initial server identification packet to the Client. 90 | // It returns an error if writing to the connection fails. 91 | func sendServerIdent(conn io.Writer) (err error) { 92 | packet := "$DISERVER:CLIENT:openfsd:6f70656e667364\r\n" 93 | _, err = conn.Write([]byte(packet)) 94 | return 95 | } 96 | 97 | // loginData holds the data extracted from the Client's login packets. 98 | type loginData struct { 99 | clientChallenge string // Optional Client challenge for authentication 100 | callsign string // Callsign of the Client 101 | cid int // Cert ID 102 | realName string // Real name 103 | networkRating NetworkRating // Network rating of the Client 104 | maxNetworkRating NetworkRating // Maximum allowed network rating (what is stored in the database) 105 | protoRevision int // Protocol revision 106 | loginTime time.Time // Time of login 107 | clientId uint16 // Client ID 108 | isAtc bool // True if the Client is an ATC, false if a pilot 109 | } 110 | 111 | // ErrInvalidAddPacket is returned when the add packet from the Client is invalid. 112 | var ErrInvalidAddPacket = errors.New("invalid add packet") 113 | 114 | // ErrInvalidIDPacket is returned when the ID packet from the Client is invalid. 115 | var ErrInvalidIDPacket = errors.New("invalid ID packet") 116 | 117 | // readLoginPackets reads the two expected login packets from the Client: 118 | // the Client identification packet and the add packet. 119 | // It parses these packets to extract the Client's data and returns it in a loginData struct. 120 | // If any errors occur during reading or parsing, it sends an error to the Client and returns an error. 121 | func readLoginPackets(conn net.Conn, scanner *bufio.Scanner) (data loginData, token string, err error) { 122 | // Client ident 123 | if !scanner.Scan() { 124 | err = ErrInvalidIDPacket 125 | sendError(conn, SyntaxError, "Error reading Client ident packet") 126 | return 127 | } 128 | idPacket := append([]byte{}, scanner.Bytes()...) 129 | 130 | // Add packet 131 | if !scanner.Scan() { 132 | err = ErrInvalidAddPacket 133 | sendError(conn, SyntaxError, "Error reading add packet") 134 | return 135 | } 136 | addPacket := append([]byte{}, scanner.Bytes()...) 137 | 138 | // Check if the Client sent a challenge field 139 | if countFields(idPacket) == 9 { 140 | // Extract the challenge 141 | data.clientChallenge = string(getField(idPacket, 8)) 142 | 143 | // Extract the client ID 144 | var clientId uint64 145 | clientId, err = strconv.ParseUint(string(getField(idPacket, 2)), 16, 16) 146 | if err != nil { 147 | err = ErrInvalidIDPacket 148 | sendError(conn, SyntaxError, "Error parsing client ID") 149 | return 150 | } 151 | data.clientId = uint16(clientId) 152 | } 153 | 154 | if len(addPacket) < 16 { 155 | err = ErrInvalidAddPacket 156 | sendError(conn, SyntaxError, "Invalid add packet") 157 | return 158 | } 159 | 160 | // Determine Client type 161 | var prefix string 162 | switch string(addPacket[:3]) { 163 | case "#AA": 164 | data.isAtc = true 165 | prefix = "#AA" 166 | case "#AP": 167 | prefix = "#AP" 168 | default: 169 | err = ErrInvalidAddPacket 170 | sendError(conn, SyntaxError, "Invalid add packet prefix") 171 | return 172 | } 173 | 174 | if data.isAtc { 175 | if countFields(addPacket) != 7 { 176 | err = ErrInvalidAddPacket 177 | sendError(conn, SyntaxError, "Invalid number of fields in ATC add packet") 178 | return 179 | } 180 | } else { 181 | if countFields(addPacket) != 8 { 182 | err = ErrInvalidAddPacket 183 | sendError(conn, SyntaxError, "Invalid number of fields in pilot add packet") 184 | return 185 | } 186 | } 187 | 188 | if callsign, found := bytes.CutPrefix(getField(addPacket, 0), []byte(prefix)); found { 189 | data.callsign = string(callsign) 190 | } else { 191 | sendError(conn, SyntaxError, "Invalid callsign in add packet") 192 | err = ErrInvalidAddPacket 193 | return 194 | } 195 | 196 | if data.isAtc { 197 | data.realName = string(getField(addPacket, 2)) 198 | if data.cid, err = strconv.Atoi(string(getField(addPacket, 3))); err != nil { 199 | err = ErrInvalidAddPacket 200 | sendError(conn, SyntaxError, "Invalid CID in ATC add packet") 201 | return 202 | } 203 | token = string(getField(addPacket, 4)) 204 | var networkRating int 205 | if networkRating, err = strconv.Atoi(string(getField(addPacket, 5))); err != nil { 206 | err = ErrInvalidAddPacket 207 | sendError(conn, SyntaxError, "Invalid network rating in pilot add packet") 208 | return 209 | } 210 | data.networkRating = NetworkRating(networkRating) 211 | if data.protoRevision, err = strconv.Atoi(string(getField(addPacket, 6))); err != nil { 212 | err = ErrInvalidAddPacket 213 | sendError(conn, SyntaxError, "Invalid protocol revision in ATC add packet") 214 | return 215 | } 216 | } else { 217 | if data.cid, err = strconv.Atoi(string(getField(addPacket, 2))); err != nil { 218 | err = ErrInvalidAddPacket 219 | sendError(conn, SyntaxError, "Invalid CID in pilot add packet") 220 | return 221 | } 222 | token = string(getField(addPacket, 3)) 223 | var networkRating int 224 | if networkRating, err = strconv.Atoi(string(getField(addPacket, 4))); err != nil { 225 | err = ErrInvalidAddPacket 226 | sendError(conn, SyntaxError, "Invalid network rating in pilot add packet") 227 | return 228 | } 229 | data.networkRating = NetworkRating(networkRating) 230 | if data.protoRevision, err = strconv.Atoi(string(getField(addPacket, 5))); err != nil { 231 | err = ErrInvalidAddPacket 232 | sendError(conn, SyntaxError, "Invalid protocol revision in pilot add packet") 233 | return 234 | } 235 | data.realName = string(getField(addPacket, 7)) 236 | } 237 | 238 | if data.protoRevision < 100 || data.protoRevision > 101 { 239 | err = ErrInvalidAddPacket 240 | sendError(conn, InvalidProtocolRevisionError, "Invalid protocol revision") 241 | return 242 | } 243 | 244 | data.loginTime = time.Now() 245 | 246 | return 247 | } 248 | 249 | func (s *Server) attemptAuthentication(client *Client, token string) (err error) { 250 | // Check vatsim auth compatibility 251 | if client.clientChallenge != "" { 252 | if err = client.authState.Initialize( 253 | client.clientId, 254 | []byte(client.clientChallenge), 255 | ); err != nil { 256 | err = ErrInvalidIDPacket 257 | sendError(client.conn, UnauthorizedSoftwareError, "Client incompatible with auth challenges") 258 | return 259 | } 260 | } 261 | 262 | const invalidLogonMsg = "Invalid CID/password" 263 | 264 | // Check if the provided token is actually a JWT 265 | if mostLikelyJwt([]byte(token)) { 266 | var jwtSecret string 267 | if jwtSecret, err = s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey); err != nil { 268 | return 269 | } 270 | 271 | var jwtToken *JwtToken 272 | if jwtToken, err = ParseJwtToken(token, []byte(jwtSecret)); err != nil { 273 | err = ErrInvalidAddPacket 274 | sendError(client.conn, InvalidLogonError, invalidLogonMsg) 275 | return 276 | } 277 | 278 | claims := jwtToken.CustomClaims() 279 | 280 | if claims.TokenType != "fsd" { 281 | err = ErrInvalidAddPacket 282 | sendError(client.conn, InvalidLogonError, invalidLogonMsg) 283 | return 284 | } 285 | 286 | if client.cid != claims.CID { 287 | err = ErrInvalidAddPacket 288 | sendError(client.conn, RequestedLevelTooHighError, invalidLogonMsg) 289 | return 290 | } 291 | if client.networkRating > claims.NetworkRating { 292 | err = ErrInvalidAddPacket 293 | sendError(client.conn, RequestedLevelTooHighError, "Requested level too high") 294 | return 295 | } 296 | if client.networkRating < NetworkRatingObserver { 297 | err = ErrInvalidAddPacket 298 | sendError(client.conn, CertificateSuspendedError, "Certificate inactive or suspended") 299 | return 300 | } 301 | client.maxNetworkRating = claims.NetworkRating 302 | 303 | return 304 | } 305 | 306 | // Otherwise, treat it as a plaintext password 307 | password := token 308 | 309 | // Attempt to fetch user 310 | user, err := s.dbRepo.UserRepo.GetUserByCID(client.cid) 311 | if err != nil { 312 | err = ErrInvalidAddPacket 313 | sendError(client.conn, InvalidLogonError, invalidLogonMsg) 314 | return 315 | } 316 | 317 | // Verify password hash 318 | if !s.dbRepo.UserRepo.VerifyPasswordHash(password, user.Password) { 319 | err = ErrInvalidAddPacket 320 | sendError(client.conn, InvalidLogonError, invalidLogonMsg) 321 | return 322 | } 323 | 324 | // Verify network rating 325 | if client.networkRating > NetworkRating(user.NetworkRating) { 326 | err = ErrInvalidAddPacket 327 | sendError(client.conn, RequestedLevelTooHighError, "Requested level too high") 328 | return 329 | } 330 | if client.networkRating < NetworkRatingObserver { 331 | err = ErrInvalidAddPacket 332 | sendError(client.conn, CertificateSuspendedError, "Certificate inactive or suspended") 333 | return 334 | } 335 | client.maxNetworkRating = NetworkRating(user.NetworkRating) 336 | 337 | return 338 | } 339 | 340 | func (s *Server) broadcastAddPacket(client *Client) { 341 | var packet string 342 | if client.isAtc { 343 | packet = fmt.Sprintf( 344 | "#AA%s:SERVER:%s:%d::%d:%d\r\n", 345 | client.callsign, 346 | client.realName, 347 | client.cid, 348 | client.networkRating, 349 | client.protoRevision) 350 | } else { 351 | packet = fmt.Sprintf( 352 | "#AP%s:SERVER:%d::%d:%d:1:%s\r\n", 353 | client.callsign, 354 | client.cid, 355 | client.networkRating, 356 | client.protoRevision, 357 | client.realName) 358 | } 359 | 360 | broadcastAll(s.postOffice, client, []byte(packet)) 361 | } 362 | 363 | func (s *Server) broadcastDisconnectPacket(client *Client) { 364 | packet := strings.Builder{} 365 | if client.isAtc { 366 | packet.WriteString("#DA") 367 | } else { 368 | packet.WriteString("#DP") 369 | } 370 | 371 | packet.WriteString(client.callsign) 372 | packet.WriteString(":SERVER:") 373 | packet.WriteString(strconv.Itoa(client.cid)) 374 | packet.WriteString("\r\n") 375 | 376 | broadcastAll(s.postOffice, client, []byte(packet.String())) 377 | } 378 | 379 | func (s *Server) sendMotd(client *Client) (err error) { 380 | welcomeMsg := db.GetWelcomeMessage(&s.dbRepo.ConfigRepo) 381 | if welcomeMsg != "" { 382 | lines := strings.Split(welcomeMsg, "\n") 383 | for i := range lines { 384 | if err = s.sendServerTextMessage(client, lines[i]); err != nil { 385 | return 386 | } 387 | } 388 | } else { 389 | if err = s.sendServerTextMessage(client, "Connected to openfsd"); err != nil { 390 | return 391 | } 392 | } 393 | return 394 | } 395 | 396 | // sendServerTextMessage synchronously sends a server #TM to the client's socket 397 | func (s *Server) sendServerTextMessage(client *Client, msg string) (err error) { 398 | packet := strings.Builder{} 399 | packet.Grow(32 + len(msg)) 400 | packet.WriteString("#TMserver:") 401 | packet.WriteString(client.callsign) 402 | packet.WriteByte(':') 403 | packet.WriteString(msg) 404 | packet.WriteString("\r\n") 405 | 406 | _, err = client.conn.Write([]byte(packet.String())) 407 | return 408 | } 409 | -------------------------------------------------------------------------------- /fsd/env.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import ( 4 | "context" 5 | "github.com/sethvargo/go-envconfig" 6 | ) 7 | 8 | type ServerConfig struct { 9 | FsdListenAddrs []string `env:"FSD_LISTEN_ADDRS, default=:6809"` // FSD listen addresses 10 | 11 | DatabaseDriver string `env:"DATABASE_DRIVER, default=sqlite"` // Golang sql database driver name 12 | DatabaseSourceName string `env:"DATABASE_SOURCE_NAME, default=:memory:"` // Golang sql database source name 13 | DatabaseAutoMigrate bool `env:"DATABASE_AUTO_MIGRATE, default=false"` // Whether to automatically run database migrations on startup 14 | DatabaseMaxConns int `env:"DATABASE_MAX_CONNS, default=1"` // Max number of database connections 15 | 16 | NumMetarWorkers int `env:"NUM_METAR_WORKERS, default=4"` // Number of METAR fetch workers to run 17 | 18 | ServiceHTTPListenAddr string `env:"SERVICE_HTTP_LISTEN_ADDR, default=:13618"` 19 | } 20 | 21 | func loadServerConfig(ctx context.Context) (config *ServerConfig, err error) { 22 | config = &ServerConfig{} 23 | if err = envconfig.Process(ctx, config); err != nil { 24 | return 25 | } 26 | return 27 | } 28 | -------------------------------------------------------------------------------- /fsd/http_service.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "github.com/gin-gonic/gin" 8 | "github.com/renorris/openfsd/db" 9 | "log/slog" 10 | "maps" 11 | "net/http" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | // runServiceHTTP starts the admin service HTTP server used for 17 | // internal communication between the API HTTP server and this FSD server. 18 | func (s *Server) runServiceHTTP(ctx context.Context) { 19 | e := s.setupRoutes() 20 | if err := e.Run(s.cfg.ServiceHTTPListenAddr); err != nil { 21 | slog.Error(err.Error()) 22 | } 23 | } 24 | 25 | func (s *Server) setupRoutes() (e *gin.Engine) { 26 | e = gin.New() 27 | 28 | // Verify administrator service JWT 29 | e.Use(s.authMiddleware) 30 | e.GET("/online_users", s.handleGetOnlineUsers) 31 | e.POST("/kick_user", s.handleKickUser) 32 | 33 | return 34 | } 35 | 36 | func (s *Server) authMiddleware(c *gin.Context) { 37 | authHeader, found := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ") 38 | if !found { 39 | c.AbortWithStatus(http.StatusBadRequest) 40 | return 41 | } 42 | 43 | jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey) 44 | if err != nil { 45 | slog.Error(err.Error()) 46 | c.AbortWithStatus(http.StatusInternalServerError) 47 | return 48 | } 49 | 50 | accessToken, err := ParseJwtToken(authHeader, []byte(jwtSecret)) 51 | if err != nil { 52 | c.AbortWithStatus(http.StatusBadRequest) 53 | return 54 | } 55 | 56 | claims := accessToken.CustomClaims() 57 | if claims.TokenType != "fsd_service" || claims.NetworkRating < NetworkRatingAdministator { 58 | c.AbortWithStatus(http.StatusForbidden) 59 | return 60 | } 61 | 62 | c.Next() 63 | } 64 | 65 | type OnlineUserGeneralData struct { 66 | Callsign string `json:"callsign"` 67 | CID int `json:"cid"` 68 | Name string `json:"name"` 69 | NetworkRating int `json:"network_rating"` 70 | MaxNetworkRating int `json:"max_network_rating"` 71 | Latitude float64 `json:"latitude"` 72 | Longitude float64 `json:"longitude"` 73 | LogonTime time.Time `json:"logon_time"` 74 | LastUpdated time.Time `json:"last_updated"` 75 | } 76 | 77 | type OnlineUserPilot struct { 78 | OnlineUserGeneralData 79 | Altitude int `json:"altitude"` 80 | Groundspeed int `json:"groundspeed"` 81 | Heading int `json:"heading"` 82 | Transponder string `json:"transponder"` 83 | } 84 | 85 | type OnlineUserATC struct { 86 | OnlineUserGeneralData 87 | Frequency string `json:"frequency"` 88 | Facility int `json:"facility"` 89 | VisRange int `json:"visual_range"` 90 | } 91 | 92 | type OnlineUsersResponseData struct { 93 | Pilots []OnlineUserPilot `json:"pilots"` 94 | ATC []OnlineUserATC `json:"atc"` 95 | } 96 | 97 | func (s *Server) handleGetOnlineUsers(c *gin.Context) { 98 | s.postOffice.clientMapLock.RLock() 99 | mapLen := len(s.postOffice.clientMap) 100 | s.postOffice.clientMapLock.RUnlock() 101 | 102 | clientMap := make(map[string]*Client, mapLen+16) 103 | 104 | s.postOffice.clientMapLock.RLock() 105 | maps.Copy(clientMap, s.postOffice.clientMap) 106 | s.postOffice.clientMapLock.RUnlock() 107 | 108 | resData := OnlineUsersResponseData{ 109 | Pilots: make([]OnlineUserPilot, 0, 512), 110 | ATC: make([]OnlineUserATC, 0, 128), 111 | } 112 | 113 | for _, client := range clientMap { 114 | latLon := client.latLon() 115 | genData := OnlineUserGeneralData{ 116 | Callsign: client.callsign, 117 | CID: client.cid, 118 | Name: client.realName, 119 | NetworkRating: int(client.networkRating), 120 | MaxNetworkRating: int(client.maxNetworkRating), 121 | Latitude: latLon[0], 122 | Longitude: latLon[1], 123 | LogonTime: client.loginTime, 124 | LastUpdated: client.lastUpdated.Load(), 125 | } 126 | 127 | if client.isAtc { 128 | atc := OnlineUserATC{ 129 | OnlineUserGeneralData: genData, 130 | Frequency: client.frequency.Load(), 131 | Facility: client.facilityType, 132 | VisRange: int(client.visRange.Load() * 0.000539957), // Convert meters to nautical miles 133 | } 134 | resData.ATC = append(resData.ATC, atc) 135 | } else { 136 | pilot := OnlineUserPilot{ 137 | OnlineUserGeneralData: genData, 138 | Altitude: int(client.altitude.Load()), 139 | Groundspeed: int(client.groundspeed.Load()), 140 | Heading: int(client.heading.Load()), 141 | Transponder: client.transponder.Load(), 142 | } 143 | resData.Pilots = append(resData.Pilots, pilot) 144 | } 145 | } 146 | 147 | c.Writer.Header().Set("Content-Type", "application/json") 148 | c.Writer.WriteHeader(http.StatusOK) 149 | json.NewEncoder(c.Writer).Encode(&resData) 150 | } 151 | 152 | func (s *Server) handleKickUser(c *gin.Context) { 153 | type RequestBody struct { 154 | Callsign string `json:"callsign" binding:"required"` 155 | } 156 | 157 | var reqBody RequestBody 158 | if err := c.ShouldBindJSON(&reqBody); err != nil { 159 | c.AbortWithStatus(http.StatusBadRequest) 160 | } 161 | 162 | client, err := s.postOffice.find(reqBody.Callsign) 163 | if err != nil { 164 | if !errors.Is(err, ErrCallsignDoesNotExist) { 165 | c.AbortWithStatus(http.StatusInternalServerError) 166 | return 167 | } 168 | c.AbortWithStatus(http.StatusNotFound) 169 | return 170 | } 171 | 172 | // Cancelling the context will cause the client's event loop to close 173 | client.cancelCtx() 174 | 175 | c.AbortWithStatus(http.StatusNoContent) 176 | } 177 | -------------------------------------------------------------------------------- /fsd/jwt.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import ( 4 | "github.com/golang-jwt/jwt/v5" 5 | "github.com/google/uuid" 6 | "time" 7 | ) 8 | 9 | const issuer = "openfsd" 10 | 11 | type JwtToken struct { 12 | *jwt.Token 13 | } 14 | 15 | type CustomClaims struct { 16 | jwt.RegisteredClaims 17 | CustomFields 18 | } 19 | 20 | type CustomFields struct { 21 | TokenType string `json:"token_type"` 22 | CID int `json:"cid"` 23 | FirstName string `json:"first_name,omitempty"` 24 | LastName string `json:"last_name,omitempty"` 25 | NetworkRating NetworkRating `json:"network_rating"` 26 | } 27 | 28 | func (t *JwtToken) CustomClaims() *CustomClaims { 29 | return t.Claims.(*CustomClaims) 30 | } 31 | 32 | func MakeJwtToken(customFields *CustomFields, validityDuration time.Duration) (token *jwt.Token, err error) { 33 | // Generate random ID 34 | id, err := uuid.NewRandom() 35 | if err != nil { 36 | return 37 | } 38 | 39 | now := time.Now() 40 | claims := &CustomClaims{ 41 | jwt.RegisteredClaims{ 42 | Issuer: issuer, 43 | ExpiresAt: &jwt.NumericDate{Time: now.Add(validityDuration)}, 44 | NotBefore: &jwt.NumericDate{Time: now.Add(-30 * time.Second)}, 45 | IssuedAt: &jwt.NumericDate{Time: now}, 46 | ID: id.String(), 47 | }, 48 | *customFields, 49 | } 50 | 51 | token = jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 52 | return 53 | } 54 | 55 | func ParseJwtToken(rawToken string, secretKey []byte) (token *JwtToken, err error) { 56 | var customClaims CustomClaims 57 | jwtToken, err := jwt.ParseWithClaims( 58 | rawToken, 59 | &customClaims, func(_ *jwt.Token) (any, error) { 60 | return secretKey, nil 61 | }, 62 | jwt.WithValidMethods([]string{"HS256"}), 63 | jwt.WithIssuer(issuer), 64 | ) 65 | if err != nil { 66 | return 67 | } 68 | 69 | token = &JwtToken{jwtToken} 70 | 71 | return 72 | } 73 | -------------------------------------------------------------------------------- /fsd/metar.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strings" 10 | ) 11 | 12 | type metarService struct { 13 | numWorkers int 14 | httpClient *http.Client 15 | metarRequests chan metarRequest 16 | } 17 | 18 | type metarRequest struct { 19 | client *Client 20 | icaoCode string 21 | } 22 | 23 | func newMetarService(numWorkers int) *metarService { 24 | return &metarService{ 25 | numWorkers: numWorkers, 26 | httpClient: &http.Client{}, 27 | metarRequests: make(chan metarRequest, 128), 28 | } 29 | } 30 | 31 | func (s *metarService) run(ctx context.Context) { 32 | for range s.numWorkers { 33 | go s.worker(ctx) 34 | } 35 | } 36 | 37 | func (s *metarService) worker(ctx context.Context) { 38 | for { 39 | select { 40 | case <-ctx.Done(): 41 | return 42 | case req := <-s.metarRequests: 43 | s.handleMetarRequest(&req) 44 | } 45 | } 46 | } 47 | 48 | func (s *metarService) handleMetarRequest(req *metarRequest) { 49 | url := buildMetarRequestURL(req.icaoCode) 50 | res, err := s.httpClient.Get(url) 51 | if err != nil { 52 | sendMetarServiceError(req) 53 | return 54 | } 55 | if res.StatusCode != http.StatusOK { 56 | sendMetarServiceError(req) 57 | return 58 | } 59 | 60 | bufBytes := make([]byte, 512) 61 | buf := bytes.NewBuffer(bufBytes) 62 | if _, err = io.Copy(buf, res.Body); err != nil { 63 | sendMetarServiceError(req) 64 | return 65 | } 66 | 67 | resBody := buf.Bytes() 68 | 69 | if bytes.Count(resBody, []byte("\n")) != 2 { 70 | fmt.Println("NOAA METAR response was invalid") 71 | sendMetarServiceError(req) 72 | return 73 | } 74 | 75 | // First line is timestamp 76 | resBody = resBody[bytes.IndexByte(resBody, '\n')+1:] 77 | 78 | // Second line is METAR and ends with \n 79 | resBody = resBody[:bytes.IndexByte(resBody, '\n')+1] 80 | 81 | packet := buildMetarResponsePacket(req.client.callsign, resBody) 82 | req.client.send(packet) 83 | } 84 | 85 | func buildMetarResponsePacket(callsign string, metar []byte) string { 86 | packet := strings.Builder{} 87 | packet.WriteString("$ARSERVER:") 88 | packet.WriteString(callsign) 89 | packet.WriteString(":METAR:") 90 | packet.Write(metar) 91 | packet.WriteString("\r\n") 92 | return packet.String() 93 | } 94 | 95 | func buildMetarRequestURL(icaoCode string) string { 96 | url := strings.Builder{} 97 | url.WriteString("https://tgftp.nws.noaa.gov/data/observations/metar/stations/") 98 | url.WriteString(icaoCode) 99 | url.WriteString(".TXT") 100 | return url.String() 101 | } 102 | 103 | func sendMetarServiceError(req *metarRequest) { 104 | req.client.sendError(NoWeatherProfileError, metarServiceErrString(req.icaoCode)) 105 | } 106 | 107 | func metarServiceErrString(icaoCode string) string { 108 | msg := strings.Builder{} 109 | msg.WriteString("Error fetching METAR for ") 110 | msg.WriteString(icaoCode) 111 | 112 | return msg.String() 113 | } 114 | 115 | // fetchAndSendMetar fetches a METAR observation for a given ICAO code and sends it to the client once received. 116 | // This function returns immediately once the request has been queued. 117 | func (s *metarService) fetchAndSendMetar(ctx context.Context, client *Client, icaoCode string) { 118 | select { 119 | case <-ctx.Done(): 120 | case s.metarRequests <- metarRequest{client: client, icaoCode: icaoCode}: 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /fsd/metar_test.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "io" 8 | "net/http" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | // mockClient simulates a Client for capturing sent packets. 14 | type mockClient struct { 15 | *Client 16 | sentPackets []string 17 | } 18 | 19 | // newMockClient creates a mockClient with a valid sendChan and ctx. 20 | func newMockClient(callsign string) *mockClient { 21 | ctx, cancel := context.WithCancel(context.Background()) 22 | client := &Client{ 23 | ctx: ctx, 24 | cancelCtx: cancel, 25 | sendChan: make(chan string, 32), // Buffered to prevent blocking 26 | loginData: loginData{callsign: callsign}, 27 | } 28 | return &mockClient{ 29 | Client: client, 30 | sentPackets: []string{}, 31 | } 32 | } 33 | 34 | // send overrides Client's send method to capture packets. 35 | func (c *mockClient) send(packet string) error { 36 | c.sentPackets = append(c.sentPackets, packet) 37 | return nil 38 | } 39 | 40 | // collectPackets drains the sendChan and returns all sent packets. 41 | func (c *mockClient) collectPackets() []string { 42 | packets := append([]string{}, c.sentPackets...) 43 | for { 44 | select { 45 | case packet := <-c.sendChan: 46 | packets = append(packets, packet) 47 | default: 48 | return packets 49 | } 50 | } 51 | } 52 | 53 | // mockTransport simulates HTTP responses for testing handleMetarRequest. 54 | type mockTransport struct { 55 | response *http.Response 56 | err error 57 | } 58 | 59 | func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { 60 | return t.response, t.err 61 | } 62 | 63 | // TestBuildMetarRequestURL verifies that buildMetarRequestURL correctly formats URLs for given ICAO codes. 64 | func TestBuildMetarRequestURL(t *testing.T) { 65 | tests := []struct { 66 | name string 67 | icaoCode string 68 | expected string 69 | }{ 70 | { 71 | name: "Valid ICAO KJFK", 72 | icaoCode: "KJFK", 73 | expected: "https://tgftp.nws.noaa.gov/data/observations/metar/stations/KJFK.TXT", 74 | }, 75 | { 76 | name: "Valid ICAO EGLL", 77 | icaoCode: "EGLL", 78 | expected: "https://tgftp.nws.noaa.gov/data/observations/metar/stations/EGLL.TXT", 79 | }, 80 | { 81 | name: "Empty ICAO", 82 | icaoCode: "", 83 | expected: "https://tgftp.nws.noaa.gov/data/observations/metar/stations/.TXT", 84 | }, 85 | } 86 | for _, tt := range tests { 87 | t.Run(tt.name, func(t *testing.T) { 88 | got := buildMetarRequestURL(tt.icaoCode) 89 | if got != tt.expected { 90 | t.Errorf("buildMetarRequestURL(%q) = %q, want %q", tt.icaoCode, got, tt.expected) 91 | } 92 | }) 93 | } 94 | } 95 | 96 | // TestBuildMetarResponsePacket verifies that buildMetarResponsePacket correctly formats METAR response packets. 97 | func TestBuildMetarResponsePacket(t *testing.T) { 98 | tests := []struct { 99 | name string 100 | callsign string 101 | metar []byte 102 | expected string 103 | }{ 104 | { 105 | name: "Valid METAR for KJFK", 106 | callsign: "TEST", 107 | metar: []byte("KJFK 301951Z 18010KT 10SM FEW250 29/19 A2992"), 108 | expected: "$ARSERVER:TEST:KJFK 301951Z 18010KT 10SM FEW250 29/19 A2992\r\n", 109 | }, 110 | { 111 | name: "Valid METAR for EGLL", 112 | callsign: "PILOT1", 113 | metar: []byte("EGLL 301950Z 24008KT 9999 FEW040 18/12 Q1015"), 114 | expected: "$ARSERVER:PILOT1:EGLL 301950Z 24008KT 9999 FEW040 18/12 Q1015\r\n", 115 | }, 116 | } 117 | for _, tt := range tests { 118 | t.Run(tt.name, func(t *testing.T) { 119 | got := buildMetarResponsePacket(tt.callsign, tt.metar) 120 | if got != tt.expected { 121 | t.Errorf("buildMetarResponsePacket(%q, %q) = %q, want %q", tt.callsign, tt.metar, got, tt.expected) 122 | } 123 | }) 124 | } 125 | } 126 | 127 | // TestSendMetarServiceError verifies that sendMetarServiceError sends the correct error packet to the client. 128 | func TestSendMetarServiceError(t *testing.T) { 129 | mockClient := newMockClient("TEST") 130 | req := &metarRequest{ 131 | client: mockClient.Client, 132 | icaoCode: "KJFK", 133 | } 134 | sendMetarServiceError(req) 135 | 136 | packets := mockClient.collectPackets() 137 | expectedPacket := "$ERserver:unknown:9::Error fetching METAR for KJFK\r\n" 138 | if len(packets) != 1 { 139 | t.Errorf("expected 1 packet sent, got %d", len(packets)) 140 | } else if packets[0] != expectedPacket { 141 | t.Errorf("expected packet %q, got %q", expectedPacket, packets[0]) 142 | } 143 | } 144 | 145 | // TestHandleMetarRequest_Success verifies that handleMetarRequest correctly processes a valid METAR response. 146 | func TestHandleMetarRequest_Success(t *testing.T) { 147 | responseBody := []byte("2023/04/30 19:51\nKJFK 301951Z 18010KT 10SM FEW250 29/19 A2992\n") 148 | mockResponse := &http.Response{ 149 | StatusCode: http.StatusOK, 150 | Body: io.NopCloser(bytes.NewReader(responseBody)), 151 | } 152 | mockTransport := &mockTransport{response: mockResponse} 153 | 154 | service := &metarService{ 155 | httpClient: &http.Client{Transport: mockTransport}, 156 | } 157 | 158 | mockClient := newMockClient("TEST") 159 | req := &metarRequest{ 160 | client: mockClient.Client, 161 | icaoCode: "KJFK", 162 | } 163 | 164 | service.handleMetarRequest(req) 165 | 166 | packets := mockClient.collectPackets() 167 | if len(packets) != 1 { 168 | t.Errorf("expected 1 packet sent, got %d", len(packets)) 169 | } 170 | 171 | if !strings.HasPrefix(packets[0], "$ARSERVER:TEST:KJFK ") || !strings.HasSuffix(packets[0], "\r\n") { 172 | t.Errorf("bad response packet") 173 | } 174 | } 175 | 176 | // TestHandleMetarRequest_HTTPError verifies that handleMetarRequest handles HTTP errors correctly. 177 | func TestHandleMetarRequest_HTTPError(t *testing.T) { 178 | mockResponse := &http.Response{ 179 | StatusCode: http.StatusNotFound, 180 | Body: io.NopCloser(strings.NewReader("Not Found")), 181 | } 182 | mockTransport := &mockTransport{response: mockResponse} 183 | 184 | service := &metarService{ 185 | httpClient: &http.Client{Transport: mockTransport}, 186 | } 187 | 188 | mockClient := newMockClient("TEST") 189 | req := &metarRequest{ 190 | client: mockClient.Client, 191 | icaoCode: "INVALID", 192 | } 193 | 194 | service.handleMetarRequest(req) 195 | 196 | packets := mockClient.collectPackets() 197 | expectedPacket := "$ERserver:unknown:9::Error fetching METAR for INVALID\r\n" 198 | if len(packets) != 1 { 199 | t.Errorf("expected 1 packet sent, got %d", len(packets)) 200 | } else if packets[0] != expectedPacket { 201 | t.Errorf("expected packet %q, got %q", expectedPacket, packets[0]) 202 | } 203 | } 204 | 205 | // TestHandleMetarRequest_NetworkError verifies that handleMetarRequest handles network errors correctly. 206 | func TestHandleMetarRequest_NetworkError(t *testing.T) { 207 | mockTransport := &mockTransport{err: errors.New("network error")} 208 | 209 | service := &metarService{ 210 | httpClient: &http.Client{Transport: mockTransport}, 211 | } 212 | 213 | mockClient := newMockClient("TEST") 214 | req := &metarRequest{ 215 | client: mockClient.Client, 216 | icaoCode: "KJFK", 217 | } 218 | 219 | service.handleMetarRequest(req) 220 | 221 | packets := mockClient.collectPackets() 222 | expectedPacket := "$ERserver:unknown:9::Error fetching METAR for KJFK\r\n" 223 | if len(packets) != 1 { 224 | t.Errorf("expected 1 packet sent, got %d", len(packets)) 225 | } else if packets[0] != expectedPacket { 226 | t.Errorf("expected packet %q, got %q", expectedPacket, packets[0]) 227 | } 228 | } 229 | 230 | // TestHandleMetarRequest_InvalidResponse verifies that handleMetarRequest handles responses with invalid formats. 231 | func TestHandleMetarRequest_InvalidResponse(t *testing.T) { 232 | responseBody := []byte("Invalid response\n") 233 | mockResponse := &http.Response{ 234 | StatusCode: http.StatusOK, 235 | Body: io.NopCloser(bytes.NewReader(responseBody)), 236 | } 237 | mockTransport := &mockTransport{response: mockResponse} 238 | 239 | service := &metarService{ 240 | httpClient: &http.Client{Transport: mockTransport}, 241 | } 242 | 243 | mockClient := newMockClient("TEST") 244 | req := &metarRequest{ 245 | client: mockClient.Client, 246 | icaoCode: "KJFK", 247 | } 248 | 249 | service.handleMetarRequest(req) 250 | 251 | packets := mockClient.collectPackets() 252 | expectedPacket := "$ERserver:unknown:9::Error fetching METAR for KJFK\r\n" 253 | if len(packets) != 1 { 254 | t.Errorf("expected 1 packet sent, got %d", len(packets)) 255 | } else if packets[0] != expectedPacket { 256 | t.Errorf("expected packet %q, got %q", expectedPacket, packets[0]) 257 | } 258 | } 259 | 260 | // TestHandleMetarRequest_MoreThanTwoLines verifies that handleMetarRequest handles responses with too many lines. 261 | func TestHandleMetarRequest_MoreThanTwoLines(t *testing.T) { 262 | responseBody := []byte("Line1\nLine2\nLine3\n") 263 | mockResponse := &http.Response{ 264 | StatusCode: http.StatusOK, 265 | Body: io.NopCloser(bytes.NewReader(responseBody)), 266 | } 267 | mockTransport := &mockTransport{response: mockResponse} 268 | 269 | service := &metarService{ 270 | httpClient: &http.Client{Transport: mockTransport}, 271 | } 272 | 273 | mockClient := newMockClient("TEST") 274 | req := &metarRequest{ 275 | client: mockClient.Client, 276 | icaoCode: "KJFK", 277 | } 278 | 279 | service.handleMetarRequest(req) 280 | 281 | packets := mockClient.collectPackets() 282 | expectedPacket := "$ERserver:unknown:9::Error fetching METAR for KJFK\r\n" 283 | if len(packets) != 1 { 284 | t.Errorf("expected 1 packet sent, got %d", len(packets)) 285 | } else if packets[0] != expectedPacket { 286 | t.Errorf("expected packet %q, got %q", expectedPacket, packets[0]) 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /fsd/packet.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import "bytes" 4 | 5 | type PacketType int 6 | 7 | const ( 8 | PacketTypeUnknown PacketType = iota 9 | PacketTypeTextMessage 10 | PacketTypePilotPosition 11 | PacketTypePilotPositionFast 12 | PacketTypePilotPositionSlow 13 | PacketTypePilotPositionStopped 14 | PacketTypeATCPosition 15 | PacketTypeDeleteATC 16 | PacketTypeDeletePilot 17 | PacketTypeClientQuery 18 | PacketTypeClientQueryResponse 19 | PacketTypeProController 20 | PacketTypeSquawkbox 21 | PacketTypeMetarRequest 22 | PacketTypeKillRequest 23 | PacketTypeAuthChallenge 24 | PacketTypeHandoffRequest 25 | PacketTypeHandoffAccept 26 | PacketTypeFlightPlan 27 | PacketTypeFlightPlanAmendment 28 | ) 29 | 30 | // sourceCallsignFieldIndex returns the index of the field containing the source callsign 31 | func sourceCallsignFieldIndex(packetType PacketType) (index int) { 32 | switch packetType { 33 | case PacketTypePilotPosition: 34 | return 1 35 | default: 36 | return 0 37 | } 38 | } 39 | 40 | // getPacketType parses the packet type given a packet 41 | func getPacketType(packet []byte) PacketType { 42 | switch packet[0] { 43 | case '^': 44 | return PacketTypePilotPositionFast 45 | case '@': 46 | return PacketTypePilotPosition 47 | case '%': 48 | return PacketTypeATCPosition 49 | case '#': 50 | switch string(packet[:3]) { 51 | case "#DA": 52 | return PacketTypeDeleteATC 53 | case "#DP": 54 | return PacketTypeDeletePilot 55 | case "#TM": 56 | return PacketTypeTextMessage 57 | case "#SL": 58 | return PacketTypePilotPositionSlow 59 | case "#ST": 60 | return PacketTypePilotPositionStopped 61 | case "#PC": 62 | return PacketTypeProController 63 | case "#SB": 64 | return PacketTypeSquawkbox 65 | default: 66 | return PacketTypeUnknown 67 | } 68 | case '$': 69 | switch string(packet[:3]) { 70 | case "$CQ": 71 | return PacketTypeClientQuery 72 | case "$CR": 73 | return PacketTypeClientQueryResponse 74 | case "$AX": 75 | return PacketTypeMetarRequest 76 | case "$!!": 77 | return PacketTypeKillRequest 78 | case "$ZC": 79 | return PacketTypeAuthChallenge 80 | case "$HO": 81 | return PacketTypeHandoffRequest 82 | case "$HA": 83 | return PacketTypeHandoffAccept 84 | case "$FP": 85 | return PacketTypeFlightPlan 86 | case "$AM": 87 | return PacketTypeFlightPlanAmendment 88 | default: 89 | return PacketTypeUnknown 90 | } 91 | default: 92 | return PacketTypeUnknown 93 | } 94 | } 95 | 96 | func getPacketPrefix(packetType PacketType) string { 97 | switch packetType { 98 | case PacketTypePilotPositionFast: 99 | return "^" 100 | case PacketTypePilotPosition: 101 | return "@" 102 | case PacketTypeATCPosition: 103 | return "%" 104 | case PacketTypeDeleteATC: 105 | return "#DA" 106 | case PacketTypeDeletePilot: 107 | return "#DP" 108 | case PacketTypeTextMessage: 109 | return "#TM" 110 | case PacketTypePilotPositionSlow: 111 | return "#SL" 112 | case PacketTypePilotPositionStopped: 113 | return "#ST" 114 | case PacketTypeProController: 115 | return "#PC" 116 | case PacketTypeSquawkbox: 117 | return "#SB" 118 | case PacketTypeClientQuery: 119 | return "$CQ" 120 | case PacketTypeClientQueryResponse: 121 | return "$CR" 122 | case PacketTypeMetarRequest: 123 | return "$AX" 124 | case PacketTypeKillRequest: 125 | return "$!!" 126 | case PacketTypeAuthChallenge: 127 | return "$ZC" 128 | case PacketTypeHandoffRequest: 129 | return "$HO" 130 | case PacketTypeHandoffAccept: 131 | return "$HA" 132 | case PacketTypeFlightPlan: 133 | return "$FP" 134 | case PacketTypeFlightPlanAmendment: 135 | return "$AM" 136 | default: 137 | return "" 138 | } 139 | } 140 | 141 | func minFields(packetType PacketType) int { 142 | switch packetType { 143 | case PacketTypePilotPosition: 144 | return 9 145 | case PacketTypePilotPositionFast, PacketTypePilotPositionSlow: 146 | return 13 147 | case PacketTypePilotPositionStopped: 148 | return 7 149 | case PacketTypeATCPosition: 150 | return 7 151 | case PacketTypeDeleteATC, PacketTypeDeletePilot: 152 | return 1 153 | case PacketTypeTextMessage: 154 | return 3 155 | case PacketTypeProController: 156 | return 4 157 | case PacketTypeSquawkbox: 158 | return 3 159 | case PacketTypeClientQuery: 160 | return 3 161 | case PacketTypeClientQueryResponse: 162 | return 3 163 | case PacketTypeMetarRequest: 164 | return 4 165 | case PacketTypeKillRequest: 166 | return 3 167 | case PacketTypeAuthChallenge: 168 | return 3 169 | case PacketTypeHandoffRequest, PacketTypeHandoffAccept: 170 | return 3 171 | case PacketTypeFlightPlan: 172 | return 17 173 | case PacketTypeFlightPlanAmendment: 174 | return 18 175 | default: 176 | return -1 177 | } 178 | } 179 | 180 | type handlerFunc func(client *Client, packet []byte) 181 | 182 | func getSourceCallsign(packet []byte, packetType PacketType) []byte { 183 | callsign, _ := bytes.CutPrefix( 184 | getField(packet, sourceCallsignFieldIndex(packetType)), 185 | []byte(getPacketPrefix(packetType)), 186 | ) 187 | return callsign 188 | } 189 | 190 | func verifySourceCallsign(packet []byte, packetType PacketType, callsign string) bool { 191 | sourceCallsign := getSourceCallsign(packet, packetType) 192 | return string(sourceCallsign) == callsign 193 | } 194 | 195 | // verifyPacket runs a set of sanity checks against a packet sent by a client and returns the detected packet type 196 | func verifyPacket(packet []byte, client *Client) (packetType PacketType, ok bool) { 197 | numFields := countFields(packet) 198 | if len(packet) < 8 || numFields < 3 { 199 | client.sendError(SyntaxError, "Packet too short") 200 | return 201 | } 202 | 203 | packetType = getPacketType(packet) 204 | if packetType == PacketTypeUnknown { 205 | client.sendError(SyntaxError, "Unknown packet type") 206 | return 207 | } 208 | 209 | if !verifySourceCallsign(packet, packetType, client.callsign) { 210 | client.sendError(SourceInvalidError, "Source invalid") 211 | return 212 | } 213 | 214 | if numFields < minFields(packetType) { 215 | client.sendError(SyntaxError, "Minimum field count requirement not satisfied") 216 | return 217 | } 218 | 219 | ok = true 220 | return 221 | } 222 | -------------------------------------------------------------------------------- /fsd/postoffice.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import ( 4 | "errors" 5 | "github.com/tidwall/rtree" 6 | "math" 7 | "sync" 8 | ) 9 | 10 | type postOffice struct { 11 | clientMap map[string]*Client // Callsign -> *Client 12 | clientMapLock *sync.RWMutex 13 | 14 | tree *rtree.RTreeG[*Client] // Geospatial rtree 15 | treeLock *sync.RWMutex 16 | } 17 | 18 | func newPostOffice() *postOffice { 19 | return &postOffice{ 20 | clientMap: make(map[string]*Client, 128), 21 | clientMapLock: &sync.RWMutex{}, 22 | tree: &rtree.RTreeG[*Client]{}, 23 | treeLock: &sync.RWMutex{}, 24 | } 25 | } 26 | 27 | var ErrCallsignInUse = errors.New("callsign in use") 28 | var ErrCallsignDoesNotExist = errors.New("callsign does not exist") 29 | 30 | // register adds a new Client to the post office. Returns ErrCallsignInUse when the callsign is taken. 31 | func (p *postOffice) register(client *Client) (err error) { 32 | p.clientMapLock.Lock() 33 | if _, exists := p.clientMap[client.callsign]; exists { 34 | p.clientMapLock.Unlock() 35 | err = ErrCallsignInUse 36 | return 37 | } 38 | p.clientMap[client.callsign] = client 39 | p.clientMapLock.Unlock() 40 | 41 | // Insert into R-tree 42 | clientMin, clientMax := calculateBoundingBox(client.latLon(), client.visRange.Load()) 43 | p.treeLock.Lock() 44 | p.tree.Insert(clientMin, clientMax, client) 45 | p.treeLock.Unlock() 46 | 47 | return 48 | } 49 | 50 | // release removes a Client from the post office. 51 | func (p *postOffice) release(client *Client) { 52 | clientMin, clientMax := calculateBoundingBox(client.latLon(), client.visRange.Load()) 53 | 54 | p.treeLock.Lock() 55 | p.tree.Delete(clientMin, clientMax, client) 56 | p.treeLock.Unlock() 57 | 58 | p.clientMapLock.Lock() 59 | delete(p.clientMap, client.callsign) 60 | p.clientMapLock.Unlock() 61 | 62 | return 63 | } 64 | 65 | // updatePosition updates the geospatial position of a Client. 66 | // The referenced client's latLon and visRange are rewritten. 67 | func (p *postOffice) updatePosition(client *Client, newCenter [2]float64, newVisRange float64) { 68 | oldMin, oldMax := calculateBoundingBox(client.latLon(), client.visRange.Load()) 69 | newMin, newMax := calculateBoundingBox(newCenter, newVisRange) 70 | 71 | client.setLatLon(newCenter[0], newCenter[1]) 72 | client.visRange.Store(newVisRange) 73 | 74 | // Avoid redundant updates 75 | if oldMin == newMin && oldMax == newMax { 76 | return 77 | } 78 | 79 | p.treeLock.Lock() 80 | p.tree.Delete(oldMin, oldMax, client) 81 | p.tree.Insert(newMin, newMax, client) 82 | p.treeLock.Unlock() 83 | 84 | return 85 | } 86 | 87 | // search calls `callback` for every other Client within geographical range of the provided Client. 88 | // 89 | // It automatically resets and populates the Client.nearbyClients and Client.closestVelocityClientDistance values 90 | func (p *postOffice) search(client *Client, callback func(recipient *Client) bool) { 91 | clientMin, clientMax := calculateBoundingBox(client.latLon(), client.visRange.Load()) 92 | 93 | client.closestVelocityClientDistance = math.MaxFloat64 94 | 95 | p.treeLock.RLock() 96 | p.tree.Search(clientMin, clientMax, func(foundMin [2]float64, foundMax [2]float64, foundClient *Client) bool { 97 | if foundClient == client { 98 | return true // Ignore self 99 | } 100 | 101 | if !client.isAtc && client.protoRevision == 101 && foundClient.protoRevision == 101 { 102 | clientLatLon := client.latLon() 103 | foundClientLatLon := foundClient.latLon() 104 | dist := distance(clientLatLon[0], clientLatLon[1], foundClientLatLon[0], foundClientLatLon[1]) 105 | if dist < client.closestVelocityClientDistance { 106 | client.closestVelocityClientDistance = dist 107 | } 108 | } 109 | 110 | return callback(foundClient) 111 | }) 112 | p.treeLock.RUnlock() 113 | } 114 | 115 | // send sends a packet to a client with a given callsign. 116 | // 117 | // Returns ErrCallsignDoesNotExist if the callsign does not exist. 118 | func (p *postOffice) send(callsign string, packet string) (err error) { 119 | p.clientMapLock.RLock() 120 | client, exists := p.clientMap[callsign] 121 | p.clientMapLock.RUnlock() 122 | 123 | if !exists { 124 | err = ErrCallsignDoesNotExist 125 | return 126 | } 127 | 128 | return client.send(packet) 129 | } 130 | 131 | // find finds a Client with a given callsign. 132 | // 133 | // Returns ErrCallsignDoesNotExist if the callsign does not exist. 134 | func (p *postOffice) find(callsign string) (client *Client, err error) { 135 | p.clientMapLock.RLock() 136 | client, exists := p.clientMap[callsign] 137 | p.clientMapLock.RUnlock() 138 | 139 | if !exists { 140 | err = ErrCallsignDoesNotExist 141 | } 142 | 143 | return 144 | } 145 | 146 | // all calls `callback` for every single client registered to the post office. 147 | func (p *postOffice) all(client *Client, callback func(recipient *Client) bool) { 148 | p.clientMapLock.RLock() 149 | for _, recipient := range p.clientMap { 150 | if recipient == client { 151 | continue 152 | } 153 | if !callback(recipient) { 154 | break 155 | } 156 | } 157 | p.clientMapLock.RUnlock() 158 | } 159 | 160 | const ( 161 | earthRadius = 6371000.0 // meters, approximate mean radius of Earth 162 | degToRad = math.Pi / 180 163 | ) 164 | 165 | func calculateBoundingBox(center [2]float64, radius float64) (min [2]float64, max [2]float64) { 166 | latRad := center[0] * degToRad 167 | const metersPerDegreeLat = (math.Pi * earthRadius) / 180 168 | deltaLat := radius / metersPerDegreeLat 169 | metersPerDegreeLon := metersPerDegreeLat * math.Cos(latRad) 170 | deltaLon := radius / metersPerDegreeLon 171 | 172 | minLat := center[0] - deltaLat 173 | maxLat := center[0] + deltaLat 174 | minLon := center[1] - deltaLon 175 | maxLon := center[1] + deltaLon 176 | 177 | min = [2]float64{minLat, minLon} 178 | max = [2]float64{maxLat, maxLon} 179 | 180 | return min, max 181 | } 182 | 183 | // distance calculates the great-circle distance between two points using the Haversine formula. 184 | func distance(lat1, lon1, lat2, lon2 float64) float64 { 185 | dLat := (lat2 - lat1) * degToRad 186 | dLon := (lon2 - lon1) * degToRad 187 | 188 | sinDLat2 := math.Sin(dLat * 0.5) 189 | sinDLon2 := math.Sin(dLon * 0.5) 190 | 191 | cosLat1 := math.Cos(lat1 * degToRad) 192 | cosLat2 := math.Cos(lat2 * degToRad) 193 | 194 | a := sinDLat2*sinDLat2 + cosLat1*cosLat2*sinDLon2*sinDLon2 195 | 196 | sqrtA := math.Sqrt(a) 197 | sqrt1MinusA := math.Sqrt(1 - a) 198 | 199 | c := 2 * math.Atan2(sqrtA, sqrt1MinusA) 200 | 201 | return earthRadius * c 202 | } 203 | -------------------------------------------------------------------------------- /fsd/postoffice_test.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "math/rand" 7 | "reflect" 8 | "sort" 9 | "testing" 10 | ) 11 | 12 | // TestRegister tests the registration of clients with unique and duplicate callsigns. 13 | func TestRegister(t *testing.T) { 14 | p := newPostOffice() 15 | client1 := &Client{loginData: loginData{callsign: "client1"}} 16 | client1.lat.Store(0) 17 | client1.lon.Store(0) 18 | client1.visRange.Store(100000) 19 | err := p.register(client1) 20 | if err != nil { 21 | t.Errorf("expected no error, got %v", err) 22 | } 23 | if p.clientMap["client1"] != client1 { 24 | t.Errorf("expected client1 in map") 25 | } 26 | client2 := &Client{loginData: loginData{callsign: "client1"}} 27 | client2.lat.Store(0) 28 | client2.lon.Store(0) 29 | client2.visRange.Store(100000) 30 | err = p.register(client2) 31 | if err != ErrCallsignInUse { 32 | t.Errorf("expected ErrCallsignInUse, got %v", err) 33 | } 34 | if p.clientMap["client1"] != client1 { 35 | t.Errorf("expected original client1 in map") 36 | } 37 | } 38 | 39 | // TestRelease tests the removal of a client and its effect on search results. 40 | func TestRelease(t *testing.T) { 41 | p := newPostOffice() 42 | client1 := &Client{loginData: loginData{callsign: "client1"}} 43 | client1.lat.Store(0) 44 | client1.lon.Store(0) 45 | client1.visRange.Store(100000) 46 | err := p.register(client1) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | client2 := &Client{loginData: loginData{callsign: "client2"}} 51 | client2.lat.Store(0) 52 | client2.lon.Store(0) 53 | client2.visRange.Store(200000) 54 | err = p.register(client2) 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | 59 | var found []*Client 60 | p.search(client2, func(recipient *Client) bool { 61 | found = append(found, recipient) 62 | return true 63 | }) 64 | if len(found) != 1 || found[0] != client1 { 65 | t.Errorf("expected to find client1, got %v", found) 66 | } 67 | 68 | p.release(client1) 69 | _, exists := p.clientMap["client1"] 70 | if exists { 71 | t.Errorf("expected client1 to be removed from map") 72 | } 73 | 74 | found = nil 75 | p.search(client2, func(recipient *Client) bool { 76 | found = append(found, recipient) 77 | return true 78 | }) 79 | if len(found) != 0 { 80 | t.Errorf("expected no clients found after release, got %v", found) 81 | } 82 | } 83 | 84 | // TestUpdatePosition tests updating a client's position and its effect on search. 85 | func TestUpdatePosition(t *testing.T) { 86 | p := newPostOffice() 87 | client1 := &Client{loginData: loginData{callsign: "client1"}} 88 | client1.lat.Store(0) 89 | client1.lon.Store(0) 90 | client1.visRange.Store(100000) 91 | err := p.register(client1) 92 | if err != nil { 93 | t.Fatal(err) 94 | } 95 | client2 := &Client{loginData: loginData{callsign: "client2"}} 96 | client2.lat.Store(0.5) 97 | client2.lon.Store(0.5) 98 | client2.visRange.Store(100000) 99 | err = p.register(client2) 100 | if err != nil { 101 | t.Fatal(err) 102 | } 103 | 104 | var found []*Client 105 | p.search(client1, func(recipient *Client) bool { 106 | found = append(found, recipient) 107 | return true 108 | }) 109 | if len(found) != 1 || found[0] != client2 { 110 | t.Errorf("expected to find client2, got %v", found) 111 | } 112 | 113 | // Assuming updatePosition now takes lat, lon, visRange separately 114 | newLat := 100.0 115 | newLon := 100.0 116 | newVisRange := 100000.0 117 | p.updatePosition(client2, [2]float64{newLat, newLon}, newVisRange) 118 | 119 | found = nil 120 | p.search(client1, func(recipient *Client) bool { 121 | found = append(found, recipient) 122 | return true 123 | }) 124 | if len(found) != 0 { 125 | t.Errorf("expected no clients found after position update, got %v", found) 126 | } 127 | } 128 | 129 | // TestSearch tests the search functionality with multiple clients. 130 | func TestSearch(t *testing.T) { 131 | p := newPostOffice() 132 | client1 := &Client{loginData: loginData{callsign: "client1"}} 133 | client1.lat.Store(32.0) 134 | client1.lon.Store(-117.0) 135 | client1.visRange.Store(100000) 136 | err := p.register(client1) 137 | if err != nil { 138 | t.Fatal(err) 139 | } 140 | client2 := &Client{loginData: loginData{callsign: "client2"}} 141 | client2.lat.Store(33.0) 142 | client2.lon.Store(-117.0) 143 | client2.visRange.Store(50000) 144 | err = p.register(client2) 145 | if err != nil { 146 | t.Fatal(err) 147 | } 148 | client3 := &Client{loginData: loginData{callsign: "client3"}} 149 | client3.lat.Store(34.0) 150 | client3.lon.Store(-117.0) 151 | client3.visRange.Store(50000) 152 | err = p.register(client3) 153 | if err != nil { 154 | t.Fatal(err) 155 | } 156 | 157 | var found []*Client 158 | p.search(client1, func(recipient *Client) bool { 159 | found = append(found, recipient) 160 | return true 161 | }) 162 | if len(found) != 1 || found[0].callsign != "client2" { 163 | t.Errorf("expected to find client2, got %v", found) 164 | } 165 | 166 | found = nil 167 | p.search(client2, func(recipient *Client) bool { 168 | found = append(found, recipient) 169 | return true 170 | }) 171 | if len(found) != 1 || found[0].callsign != "client1" { 172 | t.Errorf("expected to find client1, got %v", found) 173 | } 174 | 175 | found = nil 176 | p.search(client3, func(recipient *Client) bool { 177 | found = append(found, recipient) 178 | return true 179 | }) 180 | if len(found) != 0 { 181 | t.Errorf("expected no clients found, got %v", found) 182 | } 183 | 184 | client4 := &Client{loginData: loginData{callsign: "client4"}} 185 | client4.lat.Store(31.0) 186 | client4.lon.Store(-117.0) 187 | client4.visRange.Store(50000) 188 | err = p.register(client4) 189 | if err != nil { 190 | t.Fatal(err) 191 | } 192 | 193 | found = nil 194 | p.search(client1, func(recipient *Client) bool { 195 | found = append(found, recipient) 196 | return true 197 | }) 198 | foundCallsigns := make([]string, len(found)) 199 | for i, c := range found { 200 | foundCallsigns[i] = c.callsign 201 | } 202 | sort.Strings(foundCallsigns) 203 | expected := []string{"client2", "client4"} 204 | sort.Strings(expected) 205 | if !reflect.DeepEqual(foundCallsigns, expected) { 206 | t.Errorf("expected %v, got %v", expected, foundCallsigns) 207 | } 208 | 209 | for _, c := range found { 210 | if c == client1 { 211 | t.Errorf("search included self") 212 | } 213 | } 214 | } 215 | 216 | // TestCalculateBoundingBox remains unchanged as it doesn't involve Client. 217 | func TestCalculateBoundingBox(t *testing.T) { 218 | const earthRadius = 6371000.0 219 | tests := []struct { 220 | name string 221 | center [2]float64 222 | radius float64 223 | expectedMin [2]float64 224 | expectedMax [2]float64 225 | }{ 226 | { 227 | name: "equator", 228 | center: [2]float64{0, 0}, 229 | radius: 100000, 230 | expectedMin: [2]float64{-0.8993216059187304, -0.8993216059187304}, 231 | expectedMax: [2]float64{0.8993216059187304, 0.8993216059187304}, 232 | }, 233 | { 234 | name: "45 degrees latitude", 235 | center: [2]float64{45, 0}, 236 | radius: 100000, 237 | expectedMin: [2]float64{44.10067839408127, -1.2718328120254205}, 238 | expectedMax: [2]float64{45.89932160591873, 1.2718328120254205}, 239 | }, 240 | } 241 | 242 | for _, tt := range tests { 243 | t.Run(tt.name, func(t *testing.T) { 244 | min, max := calculateBoundingBox(tt.center, tt.radius) 245 | if !approxEqual(min[0], tt.expectedMin[0]) || !approxEqual(min[1], tt.expectedMin[1]) { 246 | t.Errorf("min mismatch: got %v, expected %v", min, tt.expectedMin) 247 | } 248 | if !approxEqual(max[0], tt.expectedMax[0]) || !approxEqual(max[1], tt.expectedMax[1]) { 249 | t.Errorf("max mismatch: got %v, expected %v", max, tt.expectedMax) 250 | } 251 | }) 252 | } 253 | } 254 | 255 | func approxEqual(a, b float64) bool { 256 | const epsilon = 1e-6 257 | return math.Abs(a-b) < epsilon 258 | } 259 | 260 | // BenchmarkDistance remains unchanged as it doesn't involve Client directly. 261 | func BenchmarkDistance(b *testing.B) { 262 | const numPairs = 1024 * 64 263 | lats1 := make([]float64, numPairs) 264 | lons1 := make([]float64, numPairs) 265 | lats2 := make([]float64, numPairs) 266 | lons2 := make([]float64, numPairs) 267 | 268 | // Seed for reproducible results 269 | rand.Seed(42) 270 | 271 | // Pre-generate random latitude and longitude pairs 272 | for i := 0; i < numPairs; i++ { 273 | lats1[i] = -90 + rand.Float64()*180 // Latitude: -90 to 90 274 | lons1[i] = -180 + rand.Float64()*360 // Longitude: -180 to 180 275 | lats2[i] = -90 + rand.Float64()*180 // Latitude: -90 to 90 276 | lons2[i] = -180 + rand.Float64()*360 // Longitude: -180 to 180 277 | } 278 | 279 | // Reset timer to exclude setup time from measurement 280 | b.ResetTimer() 281 | 282 | // Run the benchmark loop 283 | for i := 0; i < b.N; i++ { 284 | idx := i % numPairs 285 | _ = distance(lats1[idx], lons1[idx], lats2[idx], lons2[idx]) 286 | } 287 | } 288 | 289 | // benchmarkSearchWithN benchmarks search performance with n clients. 290 | func benchmarkSearchWithN(b *testing.B, n int) { 291 | // Create postOffice 292 | p := newPostOffice() 293 | 294 | // Create n clients 295 | clients := make([]*Client, n) 296 | for i := 0; i < n; i++ { 297 | clients[i] = &Client{loginData: loginData{callsign: fmt.Sprintf("Client%d", i)}} 298 | clients[i].lat.Store(-90 + rand.Float64()*180) // Latitude: -90 to 90 299 | clients[i].lon.Store(-180 + rand.Float64()*360) // Longitude: -180 to 180 300 | clients[i].visRange.Store(10000) 301 | p.register(clients[i]) 302 | } 303 | 304 | // Define callback 305 | callback := func(recipient *Client) bool { 306 | return true 307 | } 308 | 309 | // Report allocations 310 | b.ReportAllocs() 311 | 312 | // Reset timer 313 | b.ResetTimer() 314 | 315 | // Run the benchmark loop 316 | for i := 0; i < b.N; i++ { 317 | searchClient := clients[i%10] 318 | p.search(searchClient, callback) 319 | } 320 | } 321 | 322 | // BenchmarkSearch runs benchmarks for different client counts. 323 | func BenchmarkSearch(b *testing.B) { 324 | rand.Seed(42) 325 | for _, n := range []int{100, 1000, 10000} { 326 | b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) { 327 | benchmarkSearchWithN(b, n) 328 | }) 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /fsd/server.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "database/sql" 7 | "encoding/hex" 8 | "errors" 9 | "fmt" 10 | "github.com/renorris/openfsd/db" 11 | "io" 12 | "log/slog" 13 | "net" 14 | "sync" 15 | ) 16 | 17 | type Server struct { 18 | cfg *ServerConfig 19 | postOffice *postOffice 20 | metarService *metarService 21 | dbRepo *db.Repositories 22 | } 23 | 24 | // NewServer creates a new Server instance. 25 | // 26 | // See NewDefaultServer to create a server using default settings obtained via environment variables. 27 | func NewServer(cfg *ServerConfig, dbRepo *db.Repositories, numMetarWorkers int) (server *Server, err error) { 28 | server = &Server{ 29 | cfg: cfg, 30 | postOffice: newPostOffice(), 31 | metarService: newMetarService(numMetarWorkers), 32 | dbRepo: dbRepo, 33 | } 34 | return 35 | } 36 | 37 | // NewDefaultServer creates a new Server instance using the default configuration obtained via environment variables 38 | func NewDefaultServer(ctx context.Context) (server *Server, err error) { 39 | config, err := loadServerConfig(ctx) 40 | if err != nil { 41 | return 42 | } 43 | 44 | slog.Info(fmt.Sprintf("using %s", config.DatabaseDriver)) 45 | 46 | slog.Debug("connecting to SQL") 47 | sqlDb, err := sql.Open(config.DatabaseDriver, config.DatabaseSourceName) 48 | if err != nil { 49 | return 50 | } 51 | slog.Debug("SQL opened") 52 | 53 | if err = sqlDb.PingContext(ctx); err != nil { 54 | return 55 | } 56 | 57 | sqlDb.SetMaxOpenConns(config.DatabaseMaxConns) 58 | 59 | if config.DatabaseAutoMigrate { 60 | slog.Debug("automatically migrating database") 61 | if err = db.Migrate(sqlDb); err != nil { 62 | return 63 | } 64 | slog.Debug("migrate OK") 65 | } 66 | 67 | dbRepo, err := db.NewRepositories(sqlDb) 68 | if err != nil { 69 | return 70 | } 71 | 72 | // Generate a default admin user if CID 1 isn't taken 73 | if _, err = dbRepo.UserRepo.GetUserByCID(1); err != nil { 74 | if !errors.Is(err, sql.ErrNoRows) { 75 | return 76 | } 77 | err = nil 78 | 79 | slog.Debug("no user with CID = 1 found, creating default admin user") 80 | var user *db.User 81 | if user, err = generateDefaultAdminUser(dbRepo); err != nil { 82 | return 83 | } 84 | slog.Info(fmt.Sprintf( 85 | ` 86 | 87 | DEFAULT ADMINISTRATOR CREDENTIALS: 88 | CID: %d 89 | Password: %s 90 | 91 | `, 92 | user.CID, 93 | user.Password, 94 | )) 95 | } 96 | 97 | // Ensure default configuration is written to persistent storage 98 | slog.Debug("initializing default config") 99 | if err = db.InitDefaultConfig(&dbRepo.ConfigRepo); err != nil { 100 | return 101 | } 102 | slog.Debug("config OK") 103 | 104 | if server, err = NewServer(config, dbRepo, config.NumMetarWorkers); err != nil { 105 | return 106 | } 107 | 108 | return 109 | } 110 | 111 | func generateDefaultAdminUser(dbRepo *db.Repositories) (user *db.User, err error) { 112 | passwordBuf := make([]byte, 8) 113 | if _, err = io.ReadFull(rand.Reader, passwordBuf); err != nil { 114 | return 115 | } 116 | password := hex.EncodeToString(passwordBuf) 117 | 118 | user = &db.User{ 119 | Password: password, 120 | FirstName: strPtr("Default Administrator"), 121 | NetworkRating: int(NetworkRatingAdministator), 122 | } 123 | 124 | if err = dbRepo.UserRepo.CreateUser(user); err != nil { 125 | return 126 | } 127 | 128 | return 129 | } 130 | 131 | func (s *Server) Run(ctx context.Context) (err error) { 132 | // Start metar service 133 | go s.metarService.run(ctx) 134 | 135 | // Start HTTP service 136 | go s.runServiceHTTP(ctx) 137 | 138 | errCh := make(chan error, len(s.cfg.FsdListenAddrs)) 139 | var listenerWg sync.WaitGroup 140 | 141 | for _, addr := range s.cfg.FsdListenAddrs { 142 | slog.Info(fmt.Sprintf("Listening on %s\n", addr)) 143 | listenerWg.Add(1) 144 | go func(ctx context.Context, addr string) { 145 | defer listenerWg.Done() 146 | s.listen(ctx, addr, errCh) 147 | }(ctx, addr) 148 | } 149 | 150 | // Collect startup errors 151 | go func() { 152 | listenerWg.Wait() 153 | close(errCh) 154 | }() 155 | 156 | var startupErrors []error 157 | for err := range errCh { 158 | startupErrors = append(startupErrors, err) 159 | } 160 | 161 | if len(startupErrors) > 0 { 162 | return fmt.Errorf("some listeners failed: %v", startupErrors) 163 | } 164 | 165 | // All listeners started successfully; wait for context to be cancelled 166 | <-ctx.Done() 167 | 168 | return 169 | } 170 | 171 | func (s *Server) listen(ctx context.Context, addr string, errCh chan<- error) { 172 | config := net.ListenConfig{} 173 | listener, err := config.Listen(ctx, "tcp4", addr) 174 | if err != nil { 175 | errCh <- fmt.Errorf("failed to listen on %s: %w", addr, err) 176 | return 177 | } 178 | defer listener.Close() 179 | 180 | // Start a goroutine to close the listener when the context is cancelled 181 | go func() { 182 | <-ctx.Done() 183 | listener.Close() 184 | }() 185 | 186 | // Accept connections in a loop 187 | for { 188 | conn, err := listener.Accept() 189 | if err != nil { 190 | if errors.Is(err, net.ErrClosed) { 191 | // Listener was closed due to context cancellation; exit the loop 192 | return 193 | } 194 | // Log or handle non-fatal accept errors 195 | continue 196 | } 197 | // Handle the connection in another goroutine 198 | go s.handleConn(ctx, conn) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /fsd/util.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "slices" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | // FSD error codes 13 | const ( 14 | CallsignInUseError = 1 // Callsign is already in use 15 | CallsignInvalidError = 2 // Callsign is invalid 16 | AlreadyRegisteredError = 3 // Client is already registered 17 | SyntaxError = 4 // Packet syntax is invalid 18 | SourceInvalidError = 5 // Packet source is invalid 19 | InvalidLogonError = 6 // Login credentials or token are invalid 20 | NoSuchCallsignError = 7 // Specified callsign does not exist 21 | NoFlightPlanError = 8 // No flight plan found for the Client 22 | NoWeatherProfileError = 9 // No weather profile available 23 | InvalidProtocolRevisionError = 10 // Client uses an unsupported protocol version 24 | RequestedLevelTooHighError = 11 // Requested access level is too high 25 | ServerFullError = 12 // Server has reached capacity 26 | CertificateSuspendedError = 13 // Client's certificate is suspended 27 | InvalidControlError = 14 // Invalid control command 28 | InvalidPositionForRatingError = 15 // Position not allowed for Client's rating 29 | UnauthorizedSoftwareError = 16 // Client software is not authorized 30 | ClientAuthenticationResponseTimeoutError = 17 // Authentication response timed out 31 | ) 32 | 33 | // FSD Network Ratings 34 | 35 | type NetworkRating int 36 | 37 | const ( 38 | NetworkRatingInactive NetworkRating = iota - 1 39 | NetworkRatingSuspended 40 | NetworkRatingObserver 41 | NetworkRatingStudent1 42 | NetworkRatingStudent2 43 | NetworkRatingStudent3 44 | NetworkRatingController1 45 | NetworkRatingController2 46 | NetworkRatingController3 47 | NetworkRatingInstructor1 48 | NetworkRatingInstructor2 49 | NetworkRatingInstructor3 50 | NetworkRatingSupervisor 51 | NetworkRatingAdministator 52 | ) 53 | 54 | func countFields(packet []byte) int { 55 | return bytes.Count(packet, []byte(":")) + 1 56 | } 57 | 58 | func rebaseToNextField(packet []byte) []byte { 59 | return packet[bytes.IndexByte(packet, ':')+1:] 60 | } 61 | 62 | func getField(packet []byte, index int) []byte { 63 | for range index { 64 | packet = rebaseToNextField(packet) 65 | } 66 | 67 | if i := bytes.IndexByte(packet, ':'); i != -1 { 68 | packet = packet[:i] 69 | } 70 | 71 | packet, _ = bytes.CutSuffix(packet, []byte("\r\n")) 72 | 73 | return packet 74 | } 75 | 76 | // mostLikelyJwt returns whether a given byte slice is most likely a JWT token 77 | func mostLikelyJwt(token []byte) bool { 78 | tmp := token 79 | dotCount := 0 80 | for { 81 | if i := bytes.IndexByte(tmp, '.'); i > -1 { 82 | tmp = tmp[i+1:] 83 | dotCount++ 84 | if dotCount > 2 { 85 | return false 86 | } 87 | continue 88 | } 89 | break 90 | } 91 | if dotCount != 2 { 92 | return false 93 | } 94 | 95 | rawJwtHeader := token[:bytes.IndexByte(token, '.')] 96 | 97 | buf := make([]byte, 0, 256) 98 | buf, err := base64.StdEncoding.AppendDecode(buf, rawJwtHeader) 99 | if err != nil { 100 | return false 101 | } 102 | 103 | type jwtHeader struct { 104 | Alg string `json:"alg"` 105 | Typ string `json:"typ"` 106 | } 107 | 108 | header := jwtHeader{} 109 | if err = json.Unmarshal(buf, &header); err != nil { 110 | return false 111 | } 112 | 113 | if header.Alg == "" || header.Typ == "" { 114 | return false 115 | } 116 | 117 | return true 118 | } 119 | 120 | func isValidCallsignLength(callsign []byte) bool { 121 | return len(callsign) <= 10 && len(callsign) >= 2 122 | } 123 | 124 | var reservedCallsigns = []string{ 125 | "SERVER", 126 | "CLIENT", 127 | "FP", 128 | } 129 | 130 | func isValidClientCallsign(callsign []byte) bool { 131 | if !isValidCallsignLength(callsign) { 132 | return false 133 | } 134 | 135 | // Only uppercase alphanumeric characters and/or hyphen/underscores 136 | for i := range callsign { 137 | b := callsign[i] 138 | if (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b == '-' || b == '_') { 139 | continue 140 | } 141 | return false 142 | } 143 | 144 | // Check against reserved callsigns 145 | if slices.Contains(reservedCallsigns, string(callsign)) { 146 | return false 147 | } 148 | 149 | return true 150 | } 151 | 152 | // isAllowedFacilityType checks if a given network rating is allowed to connect as a given facility type. 153 | func isAllowedFacilityType(rating NetworkRating, facilityType int) bool { 154 | // Observer facility type is allowed for all ratings 155 | if facilityType == 0 { 156 | return true 157 | } 158 | 159 | // Map of facility types to minimum required rating protocol values 160 | minRating := map[int]NetworkRating{ 161 | 1: NetworkRatingController1, // Flight Service Station (FSS) - C1 and above 162 | 2: NetworkRatingStudent1, // Delivery (DEL) - S1 and above 163 | 3: NetworkRatingStudent1, // Ground (GND) - S1 and above 164 | 4: NetworkRatingStudent2, // Tower (TWR) - S2 and above 165 | 5: NetworkRatingStudent3, // Approach (APP) - S3 and above 166 | 6: NetworkRatingController1, // Centre (CTR) - C1 and above 167 | }[facilityType] 168 | 169 | // Return false for invalid facility types 170 | if minRating == 0 { 171 | return false 172 | } 173 | 174 | // Check if the rating meets the minimum requirement 175 | return rating >= minRating 176 | } 177 | 178 | // parseLatLon extracts two base-10-encoded float64 values from a packet at the specified field indices 179 | func parseLatLon(packet []byte, latIndex, lonIndex int) (lat float64, lon float64, ok bool) { 180 | rawLat := getField(packet, latIndex) 181 | rawLon := getField(packet, lonIndex) 182 | lat, err := strconv.ParseFloat(string(rawLat), 64) 183 | if err != nil { 184 | return 185 | } 186 | lon, err = strconv.ParseFloat(string(rawLon), 64) 187 | if err != nil { 188 | return 189 | } 190 | 191 | ok = true 192 | return 193 | } 194 | 195 | // parseVisRange parses an FSD-encoded visibility range and returns the distance in meters 196 | func parseVisRange(packet []byte, index int) (visRange float64, ok bool) { 197 | visRangeNauticalMiles, err := strconv.ParseFloat(string(getField(packet, index)), 64) 198 | if err != nil { 199 | return 200 | } 201 | 202 | // Convert to meters 203 | visRange = visRangeNauticalMiles * 1852.0 204 | 205 | ok = true 206 | return 207 | } 208 | 209 | // forwardClientQuery freely routes a client query packet depending on the recipient. 210 | func forwardClientQuery(po *postOffice, client *Client, packet []byte) { 211 | recipient := getField(packet, 1) 212 | 213 | if len(recipient) < 2 { 214 | client.sendError(NoSuchCallsignError, "Invalid recipient") 215 | return 216 | } 217 | 218 | switch string(recipient) { 219 | case "@94835": 220 | // Broadcast to in-range ATC 221 | broadcastRangedAtcOnly(po, client, packet) 222 | return 223 | case "@94836": 224 | // Broadcast to all in-range clients 225 | broadcastRanged(po, client, packet) 226 | return 227 | } 228 | 229 | sendDirectOrErr(po, client, recipient, packet) 230 | } 231 | 232 | // broadcastRanged broadcasts a packet to all clients in range 233 | func broadcastRanged(po *postOffice, client *Client, packet []byte) { 234 | packetStr := string(packet) 235 | po.search(client, func(recipient *Client) bool { 236 | recipient.send(packetStr) 237 | return true 238 | }) 239 | } 240 | 241 | // broadcastRangedVelocity broadcasts a packet to all clients in range 242 | // supporting the Vatsim2022 (101) protocol revision. 243 | func broadcastRangedVelocity(po *postOffice, client *Client, packet []byte) { 244 | packetStr := string(packet) 245 | po.search(client, func(recipient *Client) bool { 246 | if recipient.protoRevision != 101 { 247 | return true 248 | } 249 | recipient.send(packetStr) 250 | return true 251 | }) 252 | } 253 | 254 | // broadcastRangedAtcOnly broadcasts a packet to all ATC clients in range 255 | func broadcastRangedAtcOnly(po *postOffice, client *Client, packet []byte) { 256 | packetStr := string(packet) 257 | po.search(client, func(recipient *Client) bool { 258 | if !recipient.isAtc { 259 | return true 260 | } 261 | recipient.send(packetStr) 262 | return true 263 | }) 264 | } 265 | 266 | // broadcastAll broadcasts a packet to the entire server 267 | func broadcastAll(po *postOffice, client *Client, packet []byte) { 268 | packetStr := string(packet) 269 | po.all(client, func(recipient *Client) bool { 270 | recipient.send(packetStr) 271 | return true 272 | }) 273 | } 274 | 275 | // broadcastAllATC broadcasts a packet to all ATC on entire server 276 | func broadcastAllATC(po *postOffice, client *Client, packet []byte) { 277 | packetStr := string(packet) 278 | po.all(client, func(recipient *Client) bool { 279 | if !recipient.isAtc { 280 | return true 281 | } 282 | recipient.send(packetStr) 283 | return true 284 | }) 285 | } 286 | 287 | // broadcastAll broadcasts a packet to all supervisors on the server 288 | func broadcastAllSupervisors(po *postOffice, client *Client, packet []byte) { 289 | packetStr := string(packet) 290 | po.all(client, func(recipient *Client) bool { 291 | if recipient.networkRating < NetworkRatingSupervisor { 292 | return true 293 | } 294 | recipient.send(packetStr) 295 | return true 296 | }) 297 | } 298 | 299 | // sendDirectOrErr attempts to send a packet directly to a recipient. 300 | // If the post office responds with an ErrCallsignDoesNotExist, the client 301 | // is notified with a NoSuchCallsignError. 302 | func sendDirectOrErr(po *postOffice, client *Client, recipient []byte, packet []byte) { 303 | if err := po.send(string(recipient), string(packet)); err != nil { 304 | client.sendError(NoSuchCallsignError, "No such callsign") 305 | return 306 | } 307 | } 308 | 309 | // extractFlightplanInfoSection extracts the useful flightplan information from an $FP or $AM packet 310 | func extractFlightplanInfoSection(packet []byte) (fpl string) { 311 | switch getPacketType(packet) { 312 | case PacketTypeFlightPlan: 313 | for range 2 { 314 | packet = rebaseToNextField(packet) 315 | } 316 | default: // PacketTypeFlightPlanAmendment 317 | for range 3 { 318 | packet = rebaseToNextField(packet) 319 | } 320 | } 321 | 322 | packet, _ = bytes.CutSuffix(packet, []byte("\r\n")) 323 | return string(packet) 324 | } 325 | 326 | // buildFileFlightplanPacket builds an $FP packet 327 | func buildFileFlightplanPacket(source, recipient, fplInfo string) (packet string) { 328 | prefix := strings.Builder{} 329 | prefix.WriteString("$FP") 330 | prefix.WriteString(source) 331 | prefix.WriteByte(':') 332 | prefix.WriteString(recipient) 333 | prefix.WriteByte(':') 334 | 335 | return buildFlightplanPacket(prefix.String(), fplInfo) 336 | } 337 | 338 | // buildAmendFlightplanPacket builds an $AM packet 339 | func buildAmendFlightplanPacket(source, recipient, targetCallsign, fplInfo string) (packet string) { 340 | prefix := strings.Builder{} 341 | prefix.Grow(36) 342 | prefix.WriteString("$AM") 343 | prefix.WriteString(source) 344 | prefix.WriteByte(':') 345 | prefix.WriteString(recipient) 346 | prefix.WriteByte(':') 347 | prefix.WriteString(targetCallsign) 348 | prefix.WriteByte(':') 349 | 350 | return buildFlightplanPacket(prefix.String(), fplInfo) 351 | } 352 | 353 | func buildFlightplanPacket(prefix, fplInfo string) (packet string) { 354 | builder := strings.Builder{} 355 | builder.Grow(len(prefix) + len(fplInfo) + 2) 356 | builder.WriteString(prefix) 357 | builder.WriteString(fplInfo) 358 | builder.WriteString("\r\n") 359 | 360 | return builder.String() 361 | } 362 | 363 | func buildBeaconCodePacket(source, recipient, targetCallsign, beaconCode string) (packet string) { 364 | builder := strings.Builder{} 365 | builder.Grow(48) 366 | builder.WriteString("#PC") 367 | builder.WriteString(source) 368 | builder.WriteByte(':') 369 | builder.WriteString(recipient) 370 | builder.WriteString(":CCP:BC:") 371 | builder.WriteString(targetCallsign) 372 | builder.WriteByte(':') 373 | builder.WriteString(beaconCode) 374 | builder.WriteString("\r\n") 375 | 376 | return builder.String() 377 | } 378 | 379 | func pitchBankHeading(packed uint32) (pitch float64, bank float64, heading float64) { 380 | // Map 11 bits of resolution to degrees [0..359] 381 | const conversionRatio float64 = 359.0 / 1023.0 382 | const mask uint32 = 1023 // 0b1111111111 383 | 384 | pitch = float64(packed>>22&mask) * conversionRatio 385 | bank = float64(packed>>12&mask) * conversionRatio 386 | heading = float64(packed>>2&mask) * conversionRatio 387 | 388 | return 389 | } 390 | 391 | func strPtr(str string) *string { 392 | return &str 393 | } 394 | 395 | // sendEnableSendFastPacket sends an 'enable' $SF Send Fast packet to the client 396 | func sendEnableSendFastPacket(client *Client) { 397 | sendSendFastPacket(client, true) 398 | } 399 | 400 | // sendDisableSendFastPacket sends a 'disable' $SF Send Fast packet to the client 401 | func sendDisableSendFastPacket(client *Client) { 402 | sendSendFastPacket(client, false) 403 | } 404 | 405 | // sendSendFastPacket sends a $SF Send Fast packet to the client 406 | func sendSendFastPacket(client *Client, enabled bool) { 407 | builder := strings.Builder{} 408 | builder.Grow(32) 409 | builder.WriteString("$SFSERVER:") 410 | builder.WriteString(client.callsign) 411 | builder.WriteByte(':') 412 | if enabled { 413 | builder.WriteByte('1') 414 | } else { 415 | builder.WriteByte('0') 416 | } 417 | builder.WriteString("\r\n") 418 | 419 | client.send(builder.String()) 420 | } 421 | -------------------------------------------------------------------------------- /fsd/util_test.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | // TestCountFields verifies the countFields function. 9 | func TestCountFields(t *testing.T) { 10 | tests := []struct { 11 | packet []byte 12 | want int 13 | }{ 14 | {[]byte(""), 1}, 15 | {[]byte("abc"), 1}, 16 | {[]byte("a:b"), 2}, 17 | {[]byte("a:b:c"), 3}, 18 | {[]byte("a:b:"), 3}, 19 | {[]byte(":a:b"), 3}, 20 | {[]byte(":"), 2}, 21 | } 22 | for _, tt := range tests { 23 | got := countFields(tt.packet) 24 | if got != tt.want { 25 | t.Errorf("countFields(%q) = %d, want %d", tt.packet, got, tt.want) 26 | } 27 | } 28 | } 29 | 30 | // TestGetField verifies the getField function. 31 | func TestGetField(t *testing.T) { 32 | tests := []struct { 33 | packet []byte 34 | index int 35 | want string 36 | }{ 37 | {[]byte("a:b:c"), 0, "a"}, 38 | {[]byte("a:b:c"), 1, "b"}, 39 | {[]byte("a:b:c"), 2, "c"}, 40 | {[]byte("a:"), 0, "a"}, 41 | {[]byte("a:"), 1, ""}, 42 | {[]byte("a:"), 2, ""}, 43 | {[]byte(":a"), 0, ""}, 44 | {[]byte(":a"), 1, "a"}, 45 | {[]byte(""), 0, ""}, 46 | {[]byte(""), 1, ""}, 47 | {[]byte("a"), 0, "a"}, 48 | } 49 | for _, tt := range tests { 50 | got := string(getField(tt.packet, tt.index)) 51 | if got != tt.want { 52 | t.Errorf("getField(%q, %d) = %q, want %q", tt.packet, tt.index, got, tt.want) 53 | } 54 | } 55 | } 56 | 57 | func TestPitchBankHeading(t *testing.T) { 58 | pitch, bank, heading := pitchBankHeading(4261294148) 59 | fmt.Println(pitch) 60 | fmt.Println(bank) 61 | fmt.Println(heading) 62 | } 63 | -------------------------------------------------------------------------------- /fsd/vatsimauth.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "errors" 7 | ) 8 | 9 | var ErrUnsupportedAuthClient = errors.New("vatsimauth: unsupported client") 10 | 11 | var vatsimAuthKeys = map[uint16]string{ 12 | 8464: "945507c4c50222c34687e742729252e6", // vSTARS 13 | 10452: "0ad74157c7f449c216bfed04f3af9fb9", // vERAM 14 | 24515: "3424cbcebcca6fe95f973b350ff85cef", // vatSys 15 | 27095: "3518a62c421937ffa46ac3316957da43", // Euroscope 16 | 33456: "52d9343020e9c7d0c6b04b0cca20ad3b", // swift 17 | 35044: "fe28334fb753cf0e3d19942197b9ce3e", // vPilot 18 | 48312: "bc2eb1ef4d96709c683084055dd5e83f", // TWRTrainer 19 | 55538: "ImuL1WbbhVuD8d3MuKpWn2rrLZRa9iVP", // xPilot 20 | 56862: "3518a62c421937ffa46ac3316957da43", // VRC 21 | } 22 | 23 | type vatsimAuthState struct { 24 | init, curr [16]byte 25 | clientId uint16 26 | } 27 | 28 | func (s *vatsimAuthState) initAsHex() (d [32]byte) { 29 | hex.Encode(d[:], s.init[:]) 30 | return 31 | } 32 | 33 | func (s *vatsimAuthState) currAsHex() (d [32]byte) { 34 | hex.Encode(d[:], s.curr[:]) 35 | return 36 | } 37 | 38 | func (s *vatsimAuthState) Initialize(clientId uint16, initialChallenge []byte) (err error) { 39 | keyStr, ok := vatsimAuthKeys[clientId] 40 | if !ok { 41 | err = ErrUnsupportedAuthClient 42 | return 43 | } 44 | s.clientId = clientId 45 | 46 | key := [32]byte{} 47 | copy(key[:], keyStr) 48 | 49 | s.init = s.runObfuscationRound(&key, initialChallenge) 50 | s.curr = s.init 51 | return 52 | } 53 | 54 | func (s *vatsimAuthState) IsInitialized() bool { 55 | return s.clientId != 0 56 | } 57 | 58 | func (s *vatsimAuthState) GetResponseForChallenge(challenge []byte) (res [32]byte) { 59 | curr := s.currAsHex() 60 | round := s.runObfuscationRound(&curr, challenge) 61 | hex.Encode(res[:], round[:]) 62 | return 63 | } 64 | 65 | func (s *vatsimAuthState) UpdateState(d *[32]byte) { 66 | init := s.initAsHex() 67 | tmp := [64]byte{} 68 | copy(tmp[:32], init[:]) 69 | copy(tmp[32:], d[:]) 70 | 71 | s.curr = md5.Sum(tmp[:]) 72 | } 73 | 74 | func (s *vatsimAuthState) runObfuscationRound(curr *[32]byte, challenge []byte) (res [16]byte) { 75 | c1, c2 := challenge[0:(len(challenge)/2)], challenge[(len(challenge)/2):] 76 | 77 | if (s.clientId & 1) == 1 { 78 | c1, c2 = c2, c1 79 | } 80 | 81 | s1, s2, s3 := curr[0:12], curr[12:22], curr[22:32] 82 | 83 | tmp := make([]byte, 0, 64) 84 | switch s.clientId % 3 { 85 | case 0: 86 | tmp = append(tmp, s1...) 87 | tmp = append(tmp, c1...) 88 | tmp = append(tmp, s2...) 89 | tmp = append(tmp, c2...) 90 | tmp = append(tmp, s3...) 91 | case 1: 92 | tmp = append(tmp, s2...) 93 | tmp = append(tmp, c1...) 94 | tmp = append(tmp, s3...) 95 | tmp = append(tmp, c2...) 96 | tmp = append(tmp, s1...) 97 | default: 98 | tmp = append(tmp, s3...) 99 | tmp = append(tmp, c1...) 100 | tmp = append(tmp, s1...) 101 | tmp = append(tmp, c2...) 102 | tmp = append(tmp, s2...) 103 | } 104 | 105 | res = md5.Sum(tmp) 106 | return 107 | } 108 | -------------------------------------------------------------------------------- /fsd/vatsimauth_test.go: -------------------------------------------------------------------------------- 1 | package fsd 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestVatsimAuth(t *testing.T) { 9 | s := vatsimAuthState{} 10 | 11 | // 35044 = vPilot, 30984979d8caed23 = initial challenge 12 | err := s.Initialize(35044, []byte("30984979d8caed23")) 13 | assert.Nil(t, err) 14 | 15 | dst := s.GetResponseForChallenge([]byte("de6acb8e")) 16 | actual := string(dst[:]) 17 | expected := "f8ee97157f66455ed6108fccef6ccf5f" 18 | assert.Equal(t, expected, actual) 19 | 20 | s.UpdateState(&dst) 21 | 22 | dst = s.GetResponseForChallenge([]byte("65b479573b0e")) 23 | actual = string(dst[:]) 24 | expected = "8953f545c4e0ffd20943ad89b8ddd087" 25 | assert.Equal(t, expected, actual) 26 | 27 | s = vatsimAuthState{} 28 | // 48312 = TWRTrainer, 3ae3baf4 = initial challenge 29 | err = s.Initialize(48312, []byte("3ae3baf4")) 30 | assert.Nil(t, err) 31 | 32 | dst = s.GetResponseForChallenge([]byte("abcdef")) 33 | actual = string(dst[:]) 34 | expected = "60ef113425658b09a1e555279d27f64a" 35 | assert.Equal(t, expected, actual) 36 | } 37 | 38 | func BenchmarkVatsimAuth(b *testing.B) { 39 | s := vatsimAuthState{} 40 | 41 | if err := s.Initialize(35044, []byte("0123456789abcdef")); err != nil { 42 | b.Fatal(err) 43 | } 44 | 45 | for i := 0; i < b.N; i++ { 46 | dst := s.GetResponseForChallenge([]byte("fedcba9876543210")) 47 | s.UpdateState(&dst) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/renorris/openfsd 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/gin-gonic/gin v1.10.0 7 | github.com/golang-jwt/jwt/v5 v5.2.2 8 | github.com/golang-migrate/migrate/v4 v4.18.3 9 | github.com/google/uuid v1.6.0 10 | github.com/lib/pq v1.10.9 11 | github.com/sethvargo/go-envconfig v1.3.0 12 | github.com/stretchr/testify v1.10.0 13 | github.com/tidwall/rtree v1.10.0 14 | go.uber.org/atomic v1.11.0 15 | golang.org/x/crypto v0.38.0 16 | modernc.org/sqlite v1.37.0 17 | ) 18 | 19 | require ( 20 | github.com/bytedance/sonic v1.13.2 // indirect 21 | github.com/bytedance/sonic/loader v0.2.4 // indirect 22 | github.com/cloudwego/base64x v0.1.5 // indirect 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/dustin/go-humanize v1.0.1 // indirect 25 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect 26 | github.com/gin-contrib/sse v1.1.0 // indirect 27 | github.com/go-playground/locales v0.14.1 // indirect 28 | github.com/go-playground/universal-translator v0.18.1 // indirect 29 | github.com/go-playground/validator/v10 v10.26.0 // indirect 30 | github.com/goccy/go-json v0.10.5 // indirect 31 | github.com/hashicorp/errwrap v1.1.0 // indirect 32 | github.com/hashicorp/go-multierror v1.1.1 // indirect 33 | github.com/json-iterator/go v1.1.12 // indirect 34 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 35 | github.com/kr/pretty v0.3.0 // indirect 36 | github.com/leodido/go-urn v1.4.0 // indirect 37 | github.com/mattn/go-isatty v0.0.20 // indirect 38 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 39 | github.com/modern-go/reflect2 v1.0.2 // indirect 40 | github.com/ncruces/go-strftime v0.1.9 // indirect 41 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 42 | github.com/pmezard/go-difflib v1.0.0 // indirect 43 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 44 | github.com/tidwall/geoindex v1.7.0 // indirect 45 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 46 | github.com/ugorji/go/codec v1.2.12 // indirect 47 | golang.org/x/arch v0.17.0 // indirect 48 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 49 | golang.org/x/net v0.40.0 // indirect 50 | golang.org/x/sys v0.33.0 // indirect 51 | golang.org/x/text v0.25.0 // indirect 52 | google.golang.org/protobuf v1.36.6 // indirect 53 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 54 | gopkg.in/yaml.v3 v3.0.1 // indirect 55 | modernc.org/libc v1.65.4 // indirect 56 | modernc.org/mathutil v1.7.1 // indirect 57 | modernc.org/memory v1.10.0 // indirect 58 | ) 59 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/renorris/openfsd/fsd" 6 | "log/slog" 7 | "os" 8 | "os/signal" 9 | ) 10 | 11 | func main() { 12 | setSlogLevel() 13 | 14 | ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt) 15 | server, err := fsd.NewDefaultServer(ctx) 16 | if err != nil { 17 | panic(err) 18 | } 19 | 20 | if err = server.Run(ctx); err != nil { 21 | slog.Error(err.Error()) 22 | } 23 | slog.Info("FSD server closed") 24 | } 25 | 26 | func setSlogLevel() { 27 | if os.Getenv("LOG_DEBUG") == "true" { 28 | slog.SetLogLoggerLevel(slog.LevelDebug) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: FSD Documentation 2 | repo_url: https://github.com/renorris/openfsd 3 | repo_name: openfsd 4 | theme: readthedocs 5 | nav: 6 | - 'index.md' 7 | - 'about.md' 8 | - 'protocol.md' 9 | - 'enumerations.md' 10 | - 'authentication-token.md' 11 | - 'vatsim-auth.md' 12 | -------------------------------------------------------------------------------- /run-windows.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | title openfsd 4 | 5 | start /b cmd /c "set DATABASE_AUTO_MIGRATE=true&& set DATABASE_SOURCE_NAME=openfsd.db?_pragma=busy_timeout(5000)^&_pragma=journal_mode(WAL)&& go run ." 6 | 7 | powershell -Command "$ProgressPreference = 'SilentlyContinue'; while (-not (Test-NetConnection -ComputerName localhost -Port 13618 -InformationLevel Quiet)) { Start-Sleep -Seconds 1 }" >nul 2>&1 8 | 9 | cmd /c "cd web&& set FSD_HTTP_SERVICE_ADDRESS=http://localhost:13618&& set DATABASE_SOURCE_NAME=../openfsd.db?_pragma=busy_timeout(5000)^&_pragma=journal_mode(WAL)&& go run ." 10 | -------------------------------------------------------------------------------- /web/api_tokens.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/renorris/openfsd/db" 6 | "github.com/renorris/openfsd/fsd" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | func (s *Server) handleCreateNewAPIToken(c *gin.Context) { 12 | claims := getJwtContext(c) 13 | if claims.NetworkRating < fsd.NetworkRatingAdministator { 14 | writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) 15 | return 16 | } 17 | 18 | type RequestBody struct { 19 | ExpiryDateTime time.Time `json:"expiry_date_time" time_format:"2006-01-02T15:04:05.000Z" binding:"required"` 20 | } 21 | 22 | var reqBody RequestBody 23 | if ok := bindJSONOrAbort(c, &reqBody); !ok { 24 | return 25 | } 26 | 27 | now := time.Now() 28 | 29 | if reqBody.ExpiryDateTime.Before(now) { 30 | res := newAPIV1Failure("expiry_date_time cannot be in the past") 31 | writeAPIV1Response(c, http.StatusBadRequest, &res) 32 | return 33 | } 34 | 35 | validityDuration := reqBody.ExpiryDateTime.Sub(now) 36 | 37 | accessToken, err := fsd.MakeJwtToken(&fsd.CustomFields{ 38 | TokenType: "access", 39 | CID: claims.CID, 40 | NetworkRating: fsd.NetworkRatingAdministator, 41 | }, validityDuration) 42 | if err != nil { 43 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 44 | return 45 | } 46 | 47 | secretKey, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey) 48 | if err != nil { 49 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 50 | return 51 | } 52 | 53 | accessTokenStr, err := accessToken.SignedString([]byte(secretKey)) 54 | if err != nil { 55 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 56 | return 57 | } 58 | 59 | type ResponseBody struct { 60 | Token string `json:"token"` 61 | } 62 | 63 | resBody := ResponseBody{Token: accessTokenStr} 64 | res := newAPIV1Success(&resBody) 65 | writeAPIV1Response(c, http.StatusCreated, &res) 66 | } 67 | -------------------------------------------------------------------------------- /web/api_v1_response.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/gin-gonic/gin" 6 | "net/http" 7 | ) 8 | 9 | type APIV1Response struct { 10 | Version string `json:"version"` 11 | Err *string `json:"err"` 12 | Data any `json:"data"` 13 | } 14 | 15 | const v1Version = "v1" 16 | 17 | func newAPIV1Success(data any) APIV1Response { 18 | return APIV1Response{ 19 | Version: v1Version, 20 | Err: nil, 21 | Data: data, 22 | } 23 | } 24 | 25 | func newAPIV1Failure(err string) APIV1Response { 26 | return APIV1Response{ 27 | Version: v1Version, 28 | Err: &err, 29 | Data: nil, 30 | } 31 | } 32 | 33 | var genericAPIV1InternalServerError = newAPIV1Failure("internal server error") 34 | var genericAPIV1Forbidden = newAPIV1Failure("forbidden") 35 | var genericAPIV1NotFound = newAPIV1Failure("not found") 36 | 37 | // bindJSONOrAbort attempts to JSON bind a given request body. On failure, 38 | // it aborts the request with http.StatusBadRequest and returns ok = false. 39 | func bindJSONOrAbort(c *gin.Context, reqBody any) (ok bool) { 40 | if err := c.ShouldBindJSON(reqBody); err != nil { 41 | res := newAPIV1Failure("invalid JSON body") 42 | writeAPIV1Response(c, http.StatusBadRequest, &res) 43 | return 44 | } 45 | 46 | return true 47 | } 48 | 49 | func writeAPIV1Response(c *gin.Context, code int, res *APIV1Response) { 50 | resBody, err := json.Marshal(res) 51 | if err != nil { 52 | c.Writer.Header().Set("Content-Type", "text/plain") 53 | c.Writer.WriteHeader(http.StatusInternalServerError) 54 | c.Writer.Write([]byte("internal server error\n\nbad response body")) 55 | return 56 | } 57 | 58 | c.Writer.Header().Add("Content-Type", "application/json") 59 | c.Writer.WriteHeader(code) 60 | c.Writer.Write(resBody) 61 | } 62 | -------------------------------------------------------------------------------- /web/auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "github.com/gin-gonic/gin" 7 | "github.com/renorris/openfsd/db" 8 | "github.com/renorris/openfsd/fsd" 9 | "net/http" 10 | "strconv" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | // getAccessRefreshTokens returns access and refresh tokens given FSD login credentials 16 | func (s *Server) getAccessRefreshTokens(c *gin.Context) { 17 | type RequestBody struct { 18 | CID int `json:"cid" binding:"min=1,required"` 19 | Password string `json:"password" binding:"required"` 20 | RememberMe bool `json:"remember_me"` 21 | } 22 | 23 | var reqBody RequestBody 24 | if !bindJSONOrAbort(c, &reqBody) { 25 | return 26 | } 27 | 28 | unauthRes := newAPIV1Failure("Bad CID and/or password") 29 | 30 | user, err := s.dbRepo.UserRepo.GetUserByCID(reqBody.CID) 31 | if err != nil { 32 | writeAPIV1Response(c, http.StatusUnauthorized, &unauthRes) 33 | return 34 | } 35 | 36 | if !s.dbRepo.UserRepo.VerifyPasswordHash(reqBody.Password, user.Password) { 37 | writeAPIV1Response(c, http.StatusUnauthorized, &unauthRes) 38 | return 39 | } 40 | 41 | access, refresh, err := s.makeAccessRefreshTokens(user, reqBody.RememberMe) 42 | if err != nil { 43 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 44 | return 45 | } 46 | 47 | type ResponseBody struct { 48 | AccessToken string `json:"access_token"` 49 | RefreshToken string `json:"refresh_token"` 50 | } 51 | 52 | resBody := ResponseBody{ 53 | AccessToken: access, 54 | RefreshToken: refresh, 55 | } 56 | 57 | res := newAPIV1Success(&resBody) 58 | c.JSON(http.StatusOK, &res) 59 | } 60 | 61 | // refreshAccessToken refreshes an access token given a refresh token 62 | func (s *Server) refreshAccessToken(c *gin.Context) { 63 | type RequestBody struct { 64 | RefreshToken string `json:"refresh_token" binding:"required"` 65 | } 66 | 67 | var reqBody RequestBody 68 | if !bindJSONOrAbort(c, &reqBody) { 69 | return 70 | } 71 | 72 | badTokenRes := newAPIV1Failure("bad token") 73 | 74 | jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey) 75 | if err != nil { 76 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 77 | return 78 | } 79 | 80 | refreshToken, err := fsd.ParseJwtToken(reqBody.RefreshToken, []byte(jwtSecret)) 81 | if err != nil { 82 | writeAPIV1Response(c, http.StatusUnauthorized, &badTokenRes) 83 | return 84 | } 85 | 86 | claims := refreshToken.CustomClaims() 87 | 88 | if claims.TokenType != "refresh" { 89 | writeAPIV1Response(c, http.StatusUnauthorized, &badTokenRes) 90 | return 91 | } 92 | 93 | user, err := s.dbRepo.UserRepo.GetUserByCID(claims.CID) 94 | if err != nil { 95 | writeAPIV1Response(c, http.StatusUnauthorized, &badTokenRes) 96 | return 97 | } 98 | 99 | access, err := s.makeAccessToken(user, []byte(jwtSecret)) 100 | if err != nil { 101 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 102 | return 103 | } 104 | 105 | type ResponseBody struct { 106 | AccessToken string `json:"access_token"` 107 | } 108 | 109 | resBody := ResponseBody{ 110 | AccessToken: access, 111 | } 112 | 113 | res := newAPIV1Success(&resBody) 114 | c.JSON(http.StatusOK, &res) 115 | } 116 | 117 | func (s *Server) getFsdJwt(c *gin.Context) { 118 | type RequestBody struct { 119 | CID string `json:"cid" form:"cid" binding:"required"` 120 | Password string `json:"password" form:"password" binding:"required"` 121 | } 122 | 123 | var reqBody RequestBody 124 | if err := c.ShouldBind(&reqBody); err != nil { 125 | return 126 | } 127 | 128 | type ResponseBody struct { 129 | Success bool `json:"success"` 130 | Token string `json:"token,omitempty"` 131 | ErrorMsg string `json:"error_msg,omitempty"` 132 | } 133 | 134 | cid, err := strconv.Atoi(reqBody.CID) 135 | if err != nil || cid < 1 { 136 | resBody := ResponseBody{ 137 | ErrorMsg: "Invalid CID", 138 | } 139 | c.JSON(http.StatusBadRequest, &resBody) 140 | return 141 | } 142 | 143 | user, err := s.dbRepo.UserRepo.GetUserByCID(cid) 144 | if err != nil { 145 | if errors.Is(err, sql.ErrNoRows) { 146 | resBody := ResponseBody{ 147 | ErrorMsg: "Invalid CID and/or password", 148 | } 149 | c.JSON(http.StatusUnauthorized, &resBody) 150 | return 151 | } 152 | 153 | resBody := ResponseBody{ 154 | ErrorMsg: "Internal server error", 155 | } 156 | c.JSON(http.StatusInternalServerError, &resBody) 157 | return 158 | } 159 | 160 | if user.NetworkRating <= int(fsd.NetworkRatingSuspended) { 161 | resBody := ResponseBody{ 162 | ErrorMsg: "Certificate suspended or inactive", 163 | } 164 | c.JSON(http.StatusForbidden, &resBody) 165 | return 166 | } 167 | 168 | fsdJwtToken, err := fsd.MakeJwtToken(&fsd.CustomFields{ 169 | TokenType: "fsd", 170 | CID: user.CID, 171 | FirstName: safeStr(user.FirstName), 172 | LastName: safeStr(user.LastName), 173 | NetworkRating: fsd.NetworkRating(user.NetworkRating), 174 | }, 5*time.Minute) 175 | if err != nil { 176 | resBody := ResponseBody{ 177 | ErrorMsg: "Internal server error", 178 | } 179 | c.JSON(http.StatusInternalServerError, &resBody) 180 | return 181 | } 182 | 183 | jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey) 184 | if err != nil { 185 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 186 | return 187 | } 188 | 189 | fsdJwtTokenStr, err := fsdJwtToken.SignedString([]byte(jwtSecret)) 190 | if err != nil { 191 | resBody := ResponseBody{ 192 | ErrorMsg: "Internal server error", 193 | } 194 | c.JSON(http.StatusInternalServerError, &resBody) 195 | return 196 | } 197 | 198 | resBody := ResponseBody{ 199 | Success: true, 200 | Token: fsdJwtTokenStr, 201 | } 202 | 203 | c.JSON(http.StatusOK, &resBody) 204 | } 205 | 206 | // jwtBearerMiddleware verifies the existence of, validates, and parses JWT bearer tokens. 207 | // 208 | // No specific validation of verified claims are done in this function. 209 | func (s *Server) jwtBearerMiddleware(c *gin.Context) { 210 | authHeader := c.GetHeader("Authorization") 211 | authHeader, found := strings.CutPrefix(authHeader, "Bearer ") 212 | if !found { 213 | res := newAPIV1Failure("bad bearer token") 214 | writeAPIV1Response(c, http.StatusBadRequest, &res) 215 | c.Abort() 216 | return 217 | } 218 | 219 | jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey) 220 | if err != nil { 221 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 222 | c.Abort() 223 | return 224 | } 225 | 226 | accessToken, err := fsd.ParseJwtToken(authHeader, []byte(jwtSecret)) 227 | if err != nil { 228 | res := newAPIV1Failure("invalid bearer token") 229 | writeAPIV1Response(c, http.StatusUnauthorized, &res) 230 | c.Abort() 231 | return 232 | } 233 | 234 | claims := accessToken.CustomClaims() 235 | 236 | if claims.TokenType != "access" { 237 | res := newAPIV1Failure("invalid token type") 238 | writeAPIV1Response(c, http.StatusUnauthorized, &res) 239 | c.Abort() 240 | return 241 | } 242 | 243 | setJwtContext(c, claims) 244 | 245 | c.Next() 246 | } 247 | 248 | const jwtContextKey = "jwtbearer" 249 | 250 | func setJwtContext(c *gin.Context, claims *fsd.CustomClaims) { 251 | c.Set(jwtContextKey, claims) 252 | } 253 | 254 | func getJwtContext(c *gin.Context) (claims *fsd.CustomClaims) { 255 | val, exists := c.Get(jwtContextKey) 256 | if !exists { 257 | panic("attempted to load non-existent jwt context") 258 | } 259 | 260 | claims = val.(*fsd.CustomClaims) 261 | 262 | return 263 | } 264 | 265 | func (s *Server) makeAccessRefreshTokens(user *db.User, rememberMe bool) (access string, refresh string, err error) { 266 | jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey) 267 | if err != nil { 268 | return 269 | } 270 | 271 | access, err = s.makeAccessToken(user, []byte(jwtSecret)) 272 | if err != nil { 273 | return 274 | } 275 | 276 | refresh, err = s.makeRefreshToken(user, rememberMe, []byte(jwtSecret)) 277 | if err != nil { 278 | return 279 | } 280 | 281 | return 282 | } 283 | 284 | func (s *Server) makeAccessToken(user *db.User, jwtSecret []byte) (access string, err error) { 285 | // Make access token 286 | accessToken, err := fsd.MakeJwtToken(&fsd.CustomFields{ 287 | TokenType: "access", 288 | CID: user.CID, 289 | FirstName: safeStr(user.FirstName), 290 | LastName: safeStr(user.LastName), 291 | NetworkRating: fsd.NetworkRating(user.NetworkRating), 292 | }, 15*time.Minute) 293 | if err != nil { 294 | return 295 | } 296 | 297 | access, err = accessToken.SignedString(jwtSecret) 298 | if err != nil { 299 | return 300 | } 301 | 302 | return 303 | } 304 | 305 | func (s *Server) makeRefreshToken(user *db.User, rememberMe bool, jwtSecret []byte) (refresh string, err error) { 306 | refreshTokenDuration := time.Hour * 24 307 | if rememberMe { 308 | refreshTokenDuration = time.Hour * 24 * 30 309 | } 310 | 311 | // Make refresh token 312 | refreshToken, err := fsd.MakeJwtToken(&fsd.CustomFields{ 313 | TokenType: "refresh", 314 | CID: user.CID, 315 | FirstName: safeStr(user.FirstName), 316 | LastName: safeStr(user.LastName), 317 | NetworkRating: fsd.NetworkRating(user.NetworkRating), 318 | }, refreshTokenDuration) 319 | if err != nil { 320 | return 321 | } 322 | 323 | refresh, err = refreshToken.SignedString(jwtSecret) 324 | if err != nil { 325 | return 326 | } 327 | 328 | return 329 | } 330 | -------------------------------------------------------------------------------- /web/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "github.com/gin-gonic/gin" 6 | "github.com/renorris/openfsd/db" 7 | "github.com/renorris/openfsd/fsd" 8 | "net/http" 9 | ) 10 | 11 | type KeyValuePair struct { 12 | Key string `json:"key" binding:"required"` 13 | Value string `json:"value" binding:"required"` 14 | } 15 | 16 | func (s *Server) handleGetConfig(c *gin.Context) { 17 | claims := getJwtContext(c) 18 | if claims.NetworkRating < fsd.NetworkRatingAdministator { 19 | writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) 20 | return 21 | } 22 | 23 | var configKeys = []string{ 24 | db.ConfigWelcomeMessage, 25 | db.ConfigFsdServerHostname, 26 | db.ConfigFsdServerIdent, 27 | db.ConfigFsdServerLocation, 28 | db.ConfigApiServerBaseURL, 29 | } 30 | 31 | type ResponseBody struct { 32 | KeyValuePairs []KeyValuePair `json:"key_value_pairs" binding:"required"` 33 | } 34 | 35 | resBody := ResponseBody{ 36 | KeyValuePairs: make([]KeyValuePair, 0, len(configKeys)), 37 | } 38 | 39 | for i := range configKeys { 40 | key := configKeys[i] 41 | val, err := s.dbRepo.ConfigRepo.Get(key) 42 | if err != nil { 43 | if !errors.Is(err, db.ErrConfigKeyNotFound) { 44 | res := newAPIV1Failure("Error reading key/value from persistent storage") 45 | writeAPIV1Response(c, http.StatusInternalServerError, &res) 46 | return 47 | } 48 | continue 49 | } 50 | resBody.KeyValuePairs = append(resBody.KeyValuePairs, 51 | KeyValuePair{ 52 | Key: key, 53 | Value: val, 54 | }, 55 | ) 56 | } 57 | 58 | res := newAPIV1Success(&resBody) 59 | writeAPIV1Response(c, http.StatusOK, &res) 60 | } 61 | 62 | func (s *Server) handleUpdateConfig(c *gin.Context) { 63 | claims := getJwtContext(c) 64 | if claims.NetworkRating < fsd.NetworkRatingAdministator { 65 | writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) 66 | return 67 | } 68 | 69 | type RequestBody struct { 70 | KeyValuePairs []KeyValuePair `json:"key_value_pairs" binding:"required"` 71 | } 72 | 73 | var reqBody RequestBody 74 | if ok := bindJSONOrAbort(c, &reqBody); !ok { 75 | return 76 | } 77 | 78 | for i := range reqBody.KeyValuePairs { 79 | kv := reqBody.KeyValuePairs[i] 80 | if err := s.dbRepo.ConfigRepo.Set(kv.Key, kv.Value); err != nil { 81 | res := newAPIV1Failure("Error writing key/value into persistent storage") 82 | writeAPIV1Response(c, http.StatusInternalServerError, &res) 83 | return 84 | } 85 | } 86 | 87 | res := newAPIV1Success(nil) 88 | writeAPIV1Response(c, http.StatusOK, &res) 89 | } 90 | 91 | func (s *Server) handleResetSecretKey(c *gin.Context) { 92 | claims := getJwtContext(c) 93 | if claims.NetworkRating < fsd.NetworkRatingAdministator { 94 | writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) 95 | return 96 | } 97 | 98 | secretKey, err := db.GenerateJwtSecretKey() 99 | if err != nil { 100 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 101 | return 102 | } 103 | 104 | if err = s.dbRepo.ConfigRepo.Set(db.ConfigJwtSecretKey, string(secretKey[:])); err != nil { 105 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 106 | return 107 | } 108 | 109 | res := newAPIV1Success(nil) 110 | writeAPIV1Response(c, http.StatusOK, &res) 111 | } 112 | -------------------------------------------------------------------------------- /web/data.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/md5" 7 | _ "embed" 8 | "encoding/hex" 9 | "encoding/json" 10 | "errors" 11 | "github.com/gin-gonic/gin" 12 | "github.com/renorris/openfsd/db" 13 | "github.com/renorris/openfsd/fsd" 14 | "go.uber.org/atomic" 15 | "io" 16 | "log/slog" 17 | "net/http" 18 | "strconv" 19 | "strings" 20 | "text/template" 21 | "time" 22 | ) 23 | 24 | //go:embed data_templates/status.txt 25 | var statusTxtRawTemplate string 26 | var statusTxtTemplate *template.Template 27 | 28 | //go:embed data_templates/servers.txt 29 | var serversTxtRawTemplate string 30 | var serversTxtTemplate *template.Template 31 | 32 | func init() { 33 | var err error 34 | statusTxtTemplate = template.New("statustxt") 35 | if statusTxtTemplate, err = statusTxtTemplate.Parse(statusTxtRawTemplate); err != nil { 36 | panic("Unable to parse status.txt template: " + err.Error()) 37 | } 38 | 39 | serversTxtTemplate = template.New("serverstxt") 40 | if serversTxtTemplate, err = serversTxtTemplate.Parse(serversTxtRawTemplate); err != nil { 41 | panic("Unable to parse servers.txt template: " + err.Error()) 42 | } 43 | } 44 | 45 | func (s *Server) handleGetStatusTxt(c *gin.Context) { 46 | baseURL, ok := s.getBaseURLOrErr(c) 47 | if !ok { 48 | return 49 | } 50 | 51 | // Generate a new status.txt 52 | statusTxt, err := generateStatusTxt(baseURL) 53 | if err != nil { 54 | c.Writer.WriteHeader(http.StatusInternalServerError) 55 | c.Writer.WriteString("Error generating status.txt") 56 | slog.Error(err.Error()) 57 | return 58 | } 59 | 60 | c.Writer.WriteHeader(http.StatusOK) 61 | c.Writer.WriteString(statusTxt) 62 | } 63 | 64 | func generateStatusTxt(baseURL string) (txt string, err error) { 65 | type TemplateData struct { 66 | ApiServerBaseURL string 67 | } 68 | 69 | tmplData := TemplateData{ApiServerBaseURL: baseURL} 70 | 71 | buf := bytes.Buffer{} 72 | buf.Grow(1024) 73 | if err = statusTxtTemplate.Execute(&buf, &tmplData); err != nil { 74 | return 75 | } 76 | 77 | // Ensure all newlines have a carriage return 78 | txt = strings.ReplaceAll(buf.String(), "\n", "\r\n") 79 | return 80 | } 81 | 82 | type DataJsonStatus struct { 83 | Data map[string][]string `json:"data"` 84 | } 85 | 86 | func (s *Server) handleGetStatusJSON(c *gin.Context) { 87 | baseURL, ok := s.getBaseURLOrErr(c) 88 | if !ok { 89 | return 90 | } 91 | 92 | statusJson := DataJsonStatus{ 93 | Data: map[string][]string{ 94 | "v3": { 95 | baseURL + "/api/v1/data/openfsd-data.json", 96 | }, 97 | "servers": { 98 | baseURL + "/api/v1/data/openfsd-servers.json", 99 | }, 100 | "servers_sweatbox": { 101 | baseURL + "/api/v1/data/sweatbox-servers.json", 102 | }, 103 | "servers_all": { 104 | baseURL + "/api/v1/data/all-servers.json", 105 | }, 106 | }, 107 | } 108 | 109 | res, err := json.Marshal(&statusJson) 110 | if err != nil { 111 | slog.Error(err.Error()) 112 | writePlaintext500Error(c, "Unable to marshal JSON") 113 | return 114 | } 115 | 116 | c.Writer.Header().Set("Content-Type", "application/json") 117 | c.Writer.WriteHeader(http.StatusOK) 118 | c.Writer.Write(res) 119 | } 120 | 121 | type DataJsonServer struct { 122 | Ident string `json:"ident"` 123 | HostnameOrIp string `json:"hostname_or_ip"` 124 | Location string `json:"location"` 125 | Name string `json:"name"` 126 | ClientsConnectionAllowed int `json:"clients_connection_allowed"` 127 | ClientConnectionsAllowed bool `json:"client_connections_allowed"` 128 | IsSweatbox bool `json:"is_sweatbox"` 129 | } 130 | 131 | func (s *Server) handleGetServersJSON(c *gin.Context) { 132 | serverIdent, serverHostname, serverLocation, err := s.getFsdServerInfo() 133 | if err != nil { 134 | writePlaintext500Error(c, "Unable to load FSD server info from configuration") 135 | return 136 | } 137 | 138 | _, isSweatbox := c.Get("is_sweatbox") 139 | 140 | type ServersJson []DataJsonServer 141 | dataJson := ServersJson{ 142 | { 143 | Ident: serverIdent, 144 | HostnameOrIp: serverHostname, 145 | Location: serverLocation, 146 | Name: serverIdent, 147 | ClientConnectionsAllowed: true, 148 | ClientsConnectionAllowed: 99, 149 | IsSweatbox: isSweatbox, 150 | }, 151 | { 152 | Ident: "AUTOMATIC", 153 | HostnameOrIp: serverHostname, 154 | Location: serverLocation, 155 | Name: serverIdent, 156 | ClientConnectionsAllowed: true, 157 | ClientsConnectionAllowed: 99, 158 | IsSweatbox: isSweatbox, 159 | }, 160 | } 161 | 162 | res, err := json.Marshal(&dataJson) 163 | if err != nil { 164 | slog.Error(err.Error()) 165 | writePlaintext500Error(c, "Unable to marshal JSON") 166 | return 167 | } 168 | 169 | c.Writer.Header().Set("Content-Type", "application/json") 170 | c.Writer.WriteHeader(http.StatusOK) 171 | c.Writer.Write(res) 172 | } 173 | 174 | func (s *Server) handleGetServersTxt(c *gin.Context) { 175 | serversTxt, err := s.generateServersTxt() 176 | if err != nil { 177 | c.Writer.WriteHeader(http.StatusInternalServerError) 178 | c.Writer.WriteString("Error generating status.txt") 179 | slog.Error(err.Error()) 180 | return 181 | } 182 | 183 | c.Writer.WriteHeader(http.StatusOK) 184 | c.Writer.WriteString(serversTxt) 185 | } 186 | 187 | func (s *Server) generateServersTxt() (txt string, err error) { 188 | serverIdent, serverHostname, serverLocation, err := s.getFsdServerInfo() 189 | if err != nil { 190 | slog.Error(err.Error()) 191 | return 192 | } 193 | 194 | type TemplateData []DataJsonServer 195 | tmplData := TemplateData{ 196 | { 197 | Ident: serverIdent, 198 | HostnameOrIp: serverHostname, 199 | Location: serverLocation, 200 | Name: serverIdent, 201 | ClientConnectionsAllowed: true, 202 | ClientsConnectionAllowed: 99, 203 | IsSweatbox: false, 204 | }, 205 | { 206 | Ident: "AUTOMATIC", 207 | HostnameOrIp: serverHostname, 208 | Location: serverLocation, 209 | Name: serverIdent, 210 | ClientConnectionsAllowed: true, 211 | ClientsConnectionAllowed: 99, 212 | IsSweatbox: false, 213 | }, 214 | } 215 | 216 | buf := bytes.Buffer{} 217 | buf.Grow(1024) 218 | if err = serversTxtTemplate.Execute(&buf, &tmplData); err != nil { 219 | return 220 | } 221 | 222 | // Ensure all newlines have a carriage return 223 | txt = strings.ReplaceAll(buf.String(), "\n", "\r\n") 224 | return 225 | } 226 | 227 | func (s *Server) getFsdServerInfo() (serverIdent string, serverHostname string, serverLocation string, err error) { 228 | serverIdent, err = s.dbRepo.ConfigRepo.Get(db.ConfigFsdServerIdent) 229 | if err != nil { 230 | slog.Error(err.Error()) 231 | return 232 | } 233 | 234 | serverHostname, err = s.dbRepo.ConfigRepo.Get(db.ConfigFsdServerHostname) 235 | if err != nil { 236 | slog.Error(err.Error()) 237 | return 238 | } 239 | 240 | serverLocation, err = s.dbRepo.ConfigRepo.Get(db.ConfigFsdServerLocation) 241 | if err != nil { 242 | slog.Error(err.Error()) 243 | return 244 | } 245 | 246 | return 247 | } 248 | 249 | func writePlaintext500Error(c *gin.Context, msg string) { 250 | c.Writer.Header().Set("Content-Type", "text/plain") 251 | c.Writer.WriteHeader(http.StatusInternalServerError) 252 | c.Writer.WriteString(msg) 253 | } 254 | 255 | func (s *Server) getBaseURLOrErr(c *gin.Context) (baseURL string, ok bool) { 256 | baseURL, err := s.dbRepo.ConfigRepo.Get(db.ConfigApiServerBaseURL) 257 | if err != nil { 258 | c.Writer.WriteHeader(http.StatusInternalServerError) 259 | if !errors.Is(err, db.ErrConfigKeyNotFound) { 260 | slog.Error(err.Error()) 261 | return 262 | } 263 | errMsg := "API server base URL is not set in the config" 264 | slog.Error(errMsg) 265 | c.Writer.WriteString(errMsg) 266 | return 267 | } 268 | 269 | ok = true 270 | return 271 | } 272 | 273 | type Datafeed struct { 274 | General DatafeedGeneral `json:"general"` 275 | Pilots []DatafeedPilot `json:"pilots"` 276 | ATC []DatafeedATC `json:"controllers"` 277 | } 278 | 279 | type DatafeedGeneral struct { 280 | Version int `json:"version"` 281 | UpdateTimestamp time.Time `json:"update_timestamp"` 282 | ConnectedClients int `json:"connected_clients"` 283 | UniqueUsers int `json:"unique_users"` 284 | } 285 | 286 | type DatafeedPilot struct { 287 | fsd.OnlineUserPilot 288 | Server string `json:"server"` 289 | PilotRating int `json:"pilot_rating"` // INOP placeholder 290 | MilitaryRating int `json:"military_rating"` // INOP placeholder 291 | QnhIHg float64 `json:"qnh_i_hg"` // INOP placeholder 292 | QnhMb int `json:"qnh_mb"` // INOP placeholder 293 | FlightPlan *DatafeedFlightplan `json:"flight_plan,omitempty"` // INOP placeholder 294 | } 295 | 296 | type DatafeedFlightplan struct { 297 | FlightRules string `json:"flight_rules"` 298 | Aircraft string `json:"aircraft"` 299 | AircraftFAA string `json:"aircraft_faa"` 300 | AircraftShort string `json:"aircraft_short"` 301 | Departure string `json:"departure"` 302 | Arrival string `json:"arrival"` 303 | Alternate string `json:"alternate"` 304 | DepTime string `json:"deptime"` 305 | EnrouteTime string `json:"enroute_time"` 306 | FuelTime string `json:"fuel_time"` 307 | Remarks string `json:"remarks"` 308 | Route string `json:"route"` 309 | RevisionID int `json:"revision_id"` 310 | AssignedTransponder string `json:"assigned_transponder"` 311 | } 312 | 313 | type DatafeedATC struct { 314 | fsd.OnlineUserATC 315 | Server string `json:"server"` 316 | TextATIS []string `json:"text_atis"` // INOP placeholder 317 | } 318 | 319 | type DatafeedCache struct { 320 | jsonStr string 321 | etag string 322 | lastUpdated time.Time 323 | } 324 | 325 | var datafeedCache atomic.Pointer[DatafeedCache] 326 | 327 | func (s *Server) getDatafeed(c *gin.Context) { 328 | feed := datafeedCache.Load() 329 | if feed == nil { 330 | c.AbortWithStatus(http.StatusInternalServerError) 331 | return 332 | } 333 | 334 | c.Writer.Header().Set("Content-Type", "application/json") 335 | 336 | timeUntilInvalid := feed.lastUpdated.Sub(time.Now()) 337 | if timeUntilInvalid > 0 { 338 | secondsUntilInvalid := int(timeUntilInvalid.Seconds()) + 1 339 | c.Writer.Header().Set("Cache-Control", "max-age="+strconv.Itoa(secondsUntilInvalid)) 340 | } 341 | c.Writer.WriteHeader(http.StatusOK) 342 | c.Writer.WriteString(feed.jsonStr) 343 | } 344 | 345 | func (s *Server) generateDatafeed() (feed *DatafeedCache, err error) { 346 | client := http.Client{} 347 | req, err := s.makeFsdHttpServiceHttpRequest("GET", "/online_users", nil) 348 | if err != nil { 349 | return 350 | } 351 | res, err := client.Do(req) 352 | if err != nil { 353 | return 354 | } 355 | 356 | if res.StatusCode != http.StatusOK { 357 | err = errors.New("FSD HTTP service returned a non-200 status code") 358 | return 359 | } 360 | 361 | decoder := json.NewDecoder(res.Body) 362 | onlineUsers := fsd.OnlineUsersResponseData{} 363 | if err = decoder.Decode(&onlineUsers); err != nil { 364 | return 365 | } 366 | 367 | now := time.Now() 368 | 369 | dataFeed := Datafeed{ 370 | General: DatafeedGeneral{ 371 | Version: 3, // Match VATSIM API version 372 | UpdateTimestamp: now, 373 | ConnectedClients: len(onlineUsers.Pilots) + len(onlineUsers.ATC), 374 | UniqueUsers: len(onlineUsers.Pilots) + len(onlineUsers.ATC), 375 | }, 376 | Pilots: []DatafeedPilot{}, 377 | ATC: []DatafeedATC{}, 378 | } 379 | 380 | for _, pilot := range onlineUsers.Pilots { 381 | dataFeed.Pilots = append(dataFeed.Pilots, DatafeedPilot{ 382 | OnlineUserPilot: pilot, 383 | Server: "OPENFSD", 384 | PilotRating: 1, 385 | MilitaryRating: 1, 386 | QnhIHg: 29.92, 387 | QnhMb: 1013, 388 | }) 389 | } 390 | 391 | for _, atc := range onlineUsers.ATC { 392 | dataFeed.ATC = append(dataFeed.ATC, DatafeedATC{ 393 | OnlineUserATC: atc, 394 | Server: "OPENFSD", 395 | TextATIS: []string{}, 396 | }) 397 | } 398 | 399 | buf := bytes.Buffer{} 400 | encoder := json.NewEncoder(&buf) 401 | if err = encoder.Encode(&dataFeed); err != nil { 402 | return 403 | } 404 | 405 | etag := md5.Sum(buf.Bytes()) 406 | 407 | feed = &DatafeedCache{ 408 | jsonStr: buf.String(), 409 | etag: hex.EncodeToString(etag[:]), 410 | lastUpdated: now, 411 | } 412 | return 413 | } 414 | 415 | // makeFsdHttpServiceHttpRequest prepares an HTTP request destined for the internal FSD HTTP API. 416 | // 417 | // method sets the HTTP method, path is the relative HTTP path (e.g. /online_users), and body is an optional request body. 418 | func (s *Server) makeFsdHttpServiceHttpRequest(method string, path string, body io.Reader) (req *http.Request, err error) { 419 | // Generate JWT bearer token 420 | customFields := fsd.CustomFields{ 421 | TokenType: "fsd_service", 422 | CID: -1, 423 | NetworkRating: fsd.NetworkRatingAdministator, 424 | } 425 | token, err := fsd.MakeJwtToken(&customFields, 15*time.Minute) 426 | if err != nil { 427 | return 428 | } 429 | secretKey, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey) 430 | if err != nil { 431 | return 432 | } 433 | tokenStr, err := token.SignedString([]byte(secretKey)) 434 | if err != nil { 435 | return 436 | } 437 | 438 | url := s.cfg.FsdHttpServiceAddress + path 439 | req, err = http.NewRequest(method, url, body) 440 | if err != nil { 441 | return 442 | } 443 | 444 | req.Header.Set("Authorization", "Bearer "+tokenStr) 445 | 446 | return 447 | } 448 | 449 | func (s *Server) runDatafeedWorker(ctx context.Context) { 450 | s.updateDataFeedCache() 451 | ticker := time.NewTicker(15 * time.Second) 452 | 453 | for { 454 | select { 455 | case <-ctx.Done(): 456 | return 457 | case <-ticker.C: 458 | s.updateDataFeedCache() 459 | } 460 | } 461 | } 462 | 463 | func (s *Server) updateDataFeedCache() { 464 | feed, err := s.generateDatafeed() 465 | if err != nil { 466 | slog.Error(err.Error()) 467 | return 468 | } 469 | datafeedCache.Store(feed) 470 | } 471 | -------------------------------------------------------------------------------- /web/data_templates/servers.txt: -------------------------------------------------------------------------------- 1 | !GENERAL: 2 | VERSION = 8 3 | RELOAD = 2 4 | UPDATE = 20220401021210 5 | ATIS ALLOW MIN = 5 6 | CONNECTED CLIENTS = 1 7 | ; 8 | ; 9 | !SERVERS: 10 | {{ range $index, $element := . }}{{ if $index }} 11 | {{ end }}{{ $element.Ident }}:{{ $element.HostnameOrIp }}:{{ $element.Location }}:{{ $element.Name }}:{{ $element.ClientsConnectionAllowed }}:{{ end }} 12 | ; 13 | ; END 14 | -------------------------------------------------------------------------------- /web/data_templates/status.txt: -------------------------------------------------------------------------------- 1 | ; IMPORTANT NOTE: This file can change as data sources change. Please check at regular intervals. 2 | ; 3 | ; PEOPLE UTILISING THIS FEED ARE STRONGLY ENCOURAGED TO MIGRATE TO {{ .ApiServerBaseURL }}/api/v1/data/status.json 4 | ; 5 | ; Data formats are: 6 | ; 7 | ; 120128:NOTCP - used by WhazzUp only 8 | ; json3 - JSON Data Version 3 9 | ; url1 - URLs where servers list data files are available. Please choose one randomly every time 10 | ; 11 | ; 12 | 120218:NOTCP 13 | ; 14 | json3={{ .ApiServerBaseURL }}/api/v1/data/openfsd-data.json 15 | ; 16 | url1={{ .ApiServerBaseURL }}/api/v1/data/openfsd-servers.txt 17 | ; 18 | servers.live={{ .ApiServerBaseURL }}/api/v1/data/openfsd-servers.txt 19 | ; 20 | voice0=afv 21 | ; 22 | ; END 23 | -------------------------------------------------------------------------------- /web/env.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/sethvargo/go-envconfig" 6 | ) 7 | 8 | type ServerConfig struct { 9 | ListenAddr string `env:"LISTEN_ADDR, default=:8000"` // HTTP listen address 10 | 11 | DatabaseDriver string `env:"DATABASE_DRIVER, default=sqlite"` // Golang sql database driver name 12 | DatabaseSourceName string `env:"DATABASE_SOURCE_NAME, default=:memory:"` // Golang sql database source name 13 | DatabaseMaxConns int `env:"DATABASE_MAX_CONNS, default=1"` // Max number of database connections 14 | 15 | FsdHttpServiceAddress string `env:"FSD_HTTP_SERVICE_ADDRESS, required"` // HTTP address to talk to the FSD http service 16 | } 17 | 18 | func loadServerConfig(ctx context.Context) (config *ServerConfig, err error) { 19 | config = &ServerConfig{} 20 | if err = envconfig.Process(ctx, config); err != nil { 21 | return 22 | } 23 | return 24 | } 25 | -------------------------------------------------------------------------------- /web/frontend.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | func (s *Server) handleFrontendLanding(c *gin.Context) { 8 | writeTemplate(c, "landing", nil) 9 | } 10 | 11 | func (s *Server) handleFrontendLogin(c *gin.Context) { 12 | writeTemplate(c, "login", nil) 13 | } 14 | 15 | func (s *Server) handleFrontendDashboard(c *gin.Context) { 16 | writeTemplate(c, "dashboard", nil) 17 | } 18 | 19 | func (s *Server) handleFrontendUserEditor(c *gin.Context) { 20 | writeTemplate(c, "usereditor", nil) 21 | } 22 | 23 | func (s *Server) handleFrontendConfigEditor(c *gin.Context) { 24 | writeTemplate(c, "configeditor", nil) 25 | } 26 | -------------------------------------------------------------------------------- /web/fsdconn.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "github.com/gin-gonic/gin" 7 | "github.com/renorris/openfsd/fsd" 8 | "net/http" 9 | ) 10 | 11 | func (s *Server) handleKickActiveConnection(c *gin.Context) { 12 | claims := getJwtContext(c) 13 | if claims.NetworkRating < fsd.NetworkRatingSupervisor { 14 | writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) 15 | return 16 | } 17 | 18 | type RequestBody struct { 19 | Callsign string `json:"callsign" binding:"required"` 20 | } 21 | 22 | var reqBody RequestBody 23 | if !bindJSONOrAbort(c, &reqBody) { 24 | return 25 | } 26 | 27 | buf := bytes.Buffer{} 28 | if err := json.NewEncoder(&buf).Encode(reqBody); err != nil { 29 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 30 | return 31 | } 32 | 33 | client := http.Client{} 34 | defer client.CloseIdleConnections() 35 | req, err := s.makeFsdHttpServiceHttpRequest("POST", "/kick_user", &buf) 36 | if err != nil { 37 | return 38 | } 39 | res, err := client.Do(req) 40 | if err != nil { 41 | return 42 | } 43 | 44 | switch res.StatusCode { 45 | case http.StatusNoContent: 46 | apiV1Res := newAPIV1Success(nil) 47 | writeAPIV1Response(c, http.StatusOK, &apiV1Res) 48 | return 49 | case http.StatusNotFound: 50 | apiV1Res := newAPIV1Failure("Callsign not found") 51 | writeAPIV1Response(c, http.StatusNotFound, &apiV1Res) 52 | return 53 | default: 54 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 55 | return 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /web/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | ) 8 | 9 | func main() { 10 | ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt) 11 | 12 | server, err := NewDefaultServer(ctx) 13 | if err != nil { 14 | panic(err) 15 | } 16 | 17 | server.Run(ctx) 18 | } 19 | -------------------------------------------------------------------------------- /web/routes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "github.com/gin-gonic/gin" 6 | "io/fs" 7 | "log" 8 | "net/http" 9 | "os" 10 | ) 11 | 12 | //go:embed static/* 13 | var staticFS embed.FS 14 | 15 | func (s *Server) setupRoutes() (e *gin.Engine) { 16 | e = gin.New() 17 | e.Use(gin.Recovery()) 18 | if os.Getenv("GIN_LOGGER") != "" { 19 | e.Use(gin.Logger()) 20 | } 21 | 22 | e.POST("/j", func(c *gin.Context) { 23 | c.Redirect(http.StatusFound, "/api/v1/fsd-jwt") 24 | }) 25 | 26 | // API groups 27 | apiV1Group := e.Group("/api/v1") 28 | apiV1Group.POST("/fsd-jwt", s.getFsdJwt) 29 | s.setupAuthRoutes(apiV1Group) 30 | s.setupUserRoutes(apiV1Group) 31 | s.setupConfigRoutes(apiV1Group) 32 | s.setupDataRoutes(apiV1Group) 33 | s.setupFsdConnRoutes(apiV1Group) 34 | 35 | // Frontend groups 36 | s.setupFrontendRoutes(e.Group("")) 37 | 38 | // Serve static files 39 | subFS, err := fs.Sub(staticFS, "static") 40 | if err != nil { 41 | log.Fatal(err) 42 | } 43 | e.StaticFS("/static", http.FS(subFS)) 44 | 45 | return 46 | } 47 | 48 | func (s *Server) setupAuthRoutes(parent *gin.RouterGroup) { 49 | authGroup := parent.Group("/auth") 50 | authGroup.POST("/login", s.getAccessRefreshTokens) 51 | authGroup.POST("/refresh", s.refreshAccessToken) 52 | } 53 | 54 | func (s *Server) setupUserRoutes(parent *gin.RouterGroup) { 55 | usersGroup := parent.Group("/user") 56 | usersGroup.Use(s.jwtBearerMiddleware) 57 | usersGroup.POST("/load", s.getUserByCID) 58 | usersGroup.PATCH("/update", s.updateUser) 59 | usersGroup.POST("/create", s.createUser) 60 | } 61 | 62 | func (s *Server) setupConfigRoutes(parent *gin.RouterGroup) { 63 | configGroup := parent.Group("/config") 64 | configGroup.Use(s.jwtBearerMiddleware) 65 | configGroup.GET("/load", s.handleGetConfig) 66 | configGroup.POST("/update", s.handleUpdateConfig) 67 | configGroup.POST("/resetsecretkey", s.handleResetSecretKey) 68 | configGroup.POST("/createtoken", s.handleCreateNewAPIToken) 69 | } 70 | 71 | func (s *Server) setupFsdConnRoutes(parent *gin.RouterGroup) { 72 | fsdConnGroup := parent.Group("/fsdconn") 73 | fsdConnGroup.Use(s.jwtBearerMiddleware) 74 | fsdConnGroup.POST("/kickuser", s.handleKickActiveConnection) 75 | } 76 | 77 | func (s *Server) setupDataRoutes(parent *gin.RouterGroup) { 78 | dataGroup := parent.Group("/data") 79 | dataGroup.GET("/status.txt", s.handleGetStatusTxt) 80 | dataGroup.GET("/status.json", s.handleGetStatusJSON) 81 | dataGroup.GET("/openfsd-servers.txt", s.handleGetServersTxt) 82 | dataGroup.GET("/openfsd-servers.json", s.handleGetServersJSON) 83 | dataGroup.GET("/sweatbox-servers.json", func(c *gin.Context) { 84 | c.Set("is_sweatbox", "true") 85 | s.handleGetServersJSON(c) 86 | }) 87 | dataGroup.GET("/all-servers.json", s.handleGetServersJSON) 88 | dataGroup.GET("/openfsd-data.json", s.getDatafeed) 89 | } 90 | 91 | func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) { 92 | frontendGroup := parent.Group("") 93 | frontendGroup.GET("", s.handleFrontendLanding) 94 | frontendGroup.GET("/login", s.handleFrontendLogin) 95 | frontendGroup.GET("/dashboard", s.handleFrontendDashboard) 96 | frontendGroup.GET("/usereditor", s.handleFrontendUserEditor) 97 | frontendGroup.GET("/configeditor", s.handleFrontendConfigEditor) 98 | } 99 | -------------------------------------------------------------------------------- /web/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "github.com/renorris/openfsd/db" 8 | "log/slog" 9 | "net" 10 | ) 11 | 12 | type Server struct { 13 | cfg *ServerConfig 14 | dbRepo *db.Repositories 15 | } 16 | 17 | func NewDefaultServer(ctx context.Context) (server *Server, err error) { 18 | cfg, err := loadServerConfig(ctx) 19 | if err != nil { 20 | return 21 | } 22 | 23 | slog.Info(fmt.Sprintf("using %s", cfg.DatabaseDriver)) 24 | 25 | slog.Debug("connecting to SQL") 26 | sqlDb, err := sql.Open(cfg.DatabaseDriver, cfg.DatabaseSourceName) 27 | if err != nil { 28 | return 29 | } 30 | slog.Debug("SQL OK") 31 | 32 | sqlDb.SetMaxOpenConns(cfg.DatabaseMaxConns) 33 | 34 | dbRepo, err := db.NewRepositories(sqlDb) 35 | if err != nil { 36 | return 37 | } 38 | 39 | if server, err = NewServer(cfg, dbRepo); err != nil { 40 | return 41 | } 42 | 43 | return 44 | } 45 | 46 | func NewServer(cfg *ServerConfig, dbRepo *db.Repositories) (server *Server, err error) { 47 | server = &Server{ 48 | cfg: cfg, 49 | dbRepo: dbRepo, 50 | } 51 | 52 | return 53 | } 54 | 55 | func (s *Server) Run(ctx context.Context) (err error) { 56 | e := s.setupRoutes() 57 | go s.runDatafeedWorker(ctx) 58 | 59 | listener, err := net.Listen("tcp", s.cfg.ListenAddr) 60 | if err != nil { 61 | return 62 | } 63 | defer listener.Close() 64 | 65 | go func() { 66 | if err := e.RunListener(listener); err != nil { 67 | slog.Error(err.Error()) 68 | } 69 | }() 70 | 71 | <-ctx.Done() 72 | 73 | return 74 | } 75 | -------------------------------------------------------------------------------- /web/static/images/plane.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renorris/openfsd/8c49beeb8c71746629d610ed57d15fb8d0162a58/web/static/images/plane.png -------------------------------------------------------------------------------- /web/static/js/openfsd/api.js: -------------------------------------------------------------------------------- 1 | async function doAPIRequestWithAuth(method, url, data) { 2 | return doAPIRequest(method, url, true, data) 3 | } 4 | 5 | async function doAPIRequestNoAuth(method, url, data) { 6 | return doAPIRequest(method, url, false, data) 7 | } 8 | 9 | async function doAPIRequest(method, url, withAuth, data) { 10 | return new Promise(async (resolve, reject) => { 11 | let accessToken = ""; 12 | if (withAuth) { 13 | accessToken = await getAccessToken(); 14 | } 15 | 16 | $.ajax({ 17 | url: url, 18 | method: method, 19 | headers: withAuth ? {"Authorization": `Bearer ${accessToken}`} : {}, 20 | contentType: "application/json", 21 | data: JSON.stringify(data), 22 | dataType: "json", 23 | }).done((res) => { 24 | resolve(res) 25 | }).fail((xhr) => { 26 | reject(xhr) 27 | }); 28 | }); 29 | } 30 | 31 | // getAccessToken returns the current valid access token. 32 | // An exception is thrown if no token is found, an error occurs refreshing the access token, 33 | // or if the refresh token is expired. 34 | async function getAccessToken() { 35 | const storedAccessToken = localStorage.getItem("access_token"); 36 | 37 | if (!storedAccessToken) { 38 | window.location.href = "/login" 39 | throw new Error("No access token found") 40 | } 41 | 42 | const jwtPayload = decodeJwt(storedAccessToken); 43 | const exp = jwtPayload.exp; 44 | const now = Math.floor(Date.now() / 1000); 45 | if (exp < (now + 15)) { // Assuming corrected logic 46 | const newAccessToken = await refreshAccessToken(); 47 | localStorage.setItem("access_token", newAccessToken); 48 | return newAccessToken; 49 | } 50 | return storedAccessToken; 51 | } 52 | 53 | async function refreshAccessToken() { 54 | const storedRefreshToken = localStorage.getItem("refresh_token"); 55 | const jwtPayload = decodeJwt(storedRefreshToken); 56 | const exp = jwtPayload.exp; 57 | const now = Math.floor(Date.now() / 1000); 58 | if (exp < (now + 15)) { 59 | window.location.href = "/login"; 60 | throw new Error("refresh token expired"); 61 | } 62 | 63 | return new Promise((resolve, reject) => { 64 | $.ajax({ 65 | url: "/api/v1/auth/refresh", 66 | method: "POST", 67 | contentType: "application/json", 68 | data: JSON.stringify({ 'refresh_token': storedRefreshToken }), 69 | dataType: "json", 70 | }).done((res) => { 71 | resolve(res.data.access_token) 72 | }).fail((xhr) => { 73 | window.location.href = "/login"; 74 | reject(new Error("failed to refresh access token")) 75 | }) 76 | }); 77 | } 78 | 79 | function getAccessTokenClaims() { 80 | return decodeJwt(localStorage.getItem("access_token")) 81 | } 82 | 83 | function decodeJwt(token) { 84 | if (!token) { 85 | window.location.href = "/login"; 86 | throw new Error("no token found") 87 | } 88 | 89 | var base64Url = token.split('.')[1]; 90 | var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); 91 | var jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function(c) { 92 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); 93 | }).join('')); 94 | 95 | return JSON.parse(jsonPayload); 96 | } 97 | 98 | function logout() { 99 | localStorage.removeItem("access_token") 100 | localStorage.removeItem("refresh_token") 101 | window.location.href = "/login" 102 | } 103 | -------------------------------------------------------------------------------- /web/static/js/openfsd/configeditor.js: -------------------------------------------------------------------------------- 1 | const keyLabels = { 2 | "WELCOME_MESSAGE": { 3 | "name": "Welcome Message", 4 | "description": "Welcome message sent to FSD clients after they connect", 5 | "type": "text", 6 | "placeholder": "Welcome to my FSD server!" 7 | }, 8 | "FSD_SERVER_HOSTNAME": { 9 | "name": "FSD Server Hostname", 10 | "description": "Server hostname advertised to clients", 11 | "type": "text", 12 | "placeholder": "myfsdserver.com" 13 | }, 14 | "FSD_SERVER_IDENT": { 15 | "name": "FSD Server Ident", 16 | "description": "Server ident advertised to clients", 17 | "type": "text", 18 | "placeholder": "MY-FSD-SERVER" 19 | }, 20 | "FSD_SERVER_LOCATION": { 21 | "name": "FSD Server Location", 22 | "description": "Geographical server location advertised to clients", 23 | "type": "text", 24 | "placeholder": "East US", 25 | }, 26 | "API_SERVER_BASE_URL": { 27 | "name": "API Server Base URL", 28 | "description": "API server base URL advertised to clients", 29 | "type": "text", 30 | "placeholder": "https://example.com" 31 | }, 32 | }; 33 | 34 | // Function to show message modal 35 | function showMessageModal(message, token) { 36 | const messageText = document.getElementById('messageText'); 37 | if (token) { 38 | messageText.innerHTML = message + '
' + token + '
'; 39 | const copyBtn = messageText.querySelector('.copy-btn'); 40 | copyBtn.addEventListener('click', function() { 41 | navigator.clipboard.writeText(token).then(() => { 42 | copyBtn.textContent = 'Copied!'; 43 | setTimeout(() => { 44 | copyBtn.textContent = 'Copy'; 45 | }, 2000); 46 | }).catch(err => { 47 | console.error('Failed to copy: ', err); 48 | }); 49 | }); 50 | } else { 51 | messageText.textContent = message; 52 | } 53 | const messageModal = new bootstrap.Modal(document.getElementById('messageModal')); 54 | messageModal.show(); 55 | } 56 | 57 | async function loadConfig() { 58 | try { 59 | const res = await doAPIRequestWithAuth('GET', '/api/v1/config/load'); 60 | if (res.err) { 61 | alert('Error: ' + res.err); 62 | return; 63 | } 64 | const configForm = document.getElementById('config-form'); 65 | configForm.innerHTML = ''; // Clear existing fields 66 | res.data.key_value_pairs.forEach(kv => { 67 | const label = keyLabels[kv.key].name || kv.key; 68 | const desc = keyLabels[kv.key].description || kv.key 69 | const div = document.createElement('div'); 70 | div.className = 'mb-3'; 71 | div.innerHTML = ` 72 | 73 | 74 |
${desc}
75 | `; 76 | configForm.appendChild(div); 77 | }); 78 | } catch (xhr) { 79 | const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred'; 80 | alert('Error: ' + errMsg); 81 | console.error('Request failed:', xhr); 82 | } 83 | } 84 | 85 | document.getElementById('add-config').addEventListener('click', function() { 86 | const configForm = document.getElementById('config-form'); 87 | const div = document.createElement('div'); 88 | div.className = 'mb-3 new-config'; 89 | // Create dropdown options from keyLabels 90 | let options = ''; 91 | Object.keys(keyLabels).forEach(key => { 92 | options += ``; 93 | }); 94 | div.innerHTML = ` 95 | 96 | 100 | 101 | 102 |
103 | `; 104 | configForm.appendChild(div); 105 | 106 | // Add event listener to update input type based on selected key 107 | const select = div.querySelector('select[data-type="new-key"]'); 108 | const valueInput = div.querySelector('input[data-type="new-value"]'); 109 | select.addEventListener('change', function() { 110 | const selectedKey = select.value; 111 | if (selectedKey && keyLabels[selectedKey]) { 112 | const inputType = keyLabels[selectedKey].type; 113 | if (inputType === 'checkbox') { 114 | valueInput.type = 'checkbox'; 115 | valueInput.removeAttribute('placeholder'); 116 | valueInput.classList.add('form-check-input'); 117 | valueInput.value = 'true'; // Default for checkbox 118 | } else { 119 | valueInput.type = inputType; 120 | valueInput.setAttribute('placeholder', keyLabels[selectedKey].placeholder); 121 | valueInput.classList.remove('form-check-input'); 122 | valueInput.value = ''; // Clear value for text input 123 | } 124 | } 125 | document.getElementById("new-value-description").innerText = keyLabels[selectedKey].description 126 | }); 127 | }); 128 | 129 | document.getElementById('save-config').addEventListener('click', async function() { 130 | const keyValuePairs = []; 131 | 132 | // Existing configs 133 | const existingInputs = document.querySelectorAll('#config-form input[data-key]'); 134 | existingInputs.forEach(input => { 135 | const key = input.getAttribute('data-key'); 136 | const value = keyLabels[key].type === 'checkbox' ? input.checked.toString() : input.value; 137 | keyValuePairs.push({ 138 | key: key, 139 | value: value 140 | }); 141 | }); 142 | 143 | // New configs 144 | const newConfigDivs = document.querySelectorAll('#config-form .new-config'); 145 | newConfigDivs.forEach(div => { 146 | const keySelect = div.querySelector('select[data-type="new-key"]'); 147 | const valueInput = div.querySelector('input[data-type="new-value"]'); 148 | if (keySelect && valueInput && keySelect.value.trim() !== '') { 149 | const key = keySelect.value; 150 | const value = keyLabels[key].type === 'checkbox' ? valueInput.checked.toString() : valueInput.value; 151 | keyValuePairs.push({ 152 | key: key, 153 | value: value 154 | }); 155 | } 156 | }); 157 | 158 | try { 159 | const res = await doAPIRequestWithAuth('POST', '/api/v1/config/update', { key_value_pairs: keyValuePairs }); 160 | if (res.err) { 161 | alert('Error: ' + res.err); 162 | } else { 163 | alert('Config updated successfully'); 164 | loadConfig(); // Reload to show new configs if added 165 | } 166 | } catch (xhr) { 167 | const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred'; 168 | alert('Error: ' + errMsg); 169 | console.error('Request failed:', xhr); 170 | } 171 | }); 172 | 173 | // Added function to reset the JWT Secret Key 174 | async function resetJwtSecretKey() { 175 | try { 176 | const res = await doAPIRequestWithAuth('POST', '/api/v1/config/resetsecretkey'); 177 | if (res.err) { 178 | alert('Error: ' + res.err); 179 | } else { 180 | alert('JWT Secret Key reset successfully'); 181 | } 182 | } catch (xhr) { 183 | const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred'; 184 | alert('Error: ' + errMsg); 185 | console.error('Request failed:', xhr); 186 | } 187 | } 188 | 189 | // Added event listener for the reset button 190 | document.addEventListener('DOMContentLoaded', function() { 191 | document.getElementById('reset-jwt-secret').addEventListener('click', function() { 192 | if (confirm('Are you sure you want to reset the JWT Secret Key? All previously generated API tokens and all active sessions will be invalidated.')) { 193 | resetJwtSecretKey(); 194 | } 195 | }); 196 | }); 197 | 198 | // Modified event listener for create new API token button 199 | document.addEventListener('DOMContentLoaded', function() { 200 | document.getElementById('create-new-api-key').addEventListener('click', function() { 201 | const createTokenModal = new bootstrap.Modal(document.getElementById('createTokenModal')); 202 | createTokenModal.show(); 203 | }); 204 | }); 205 | 206 | // Added event listener for submit create token 207 | document.addEventListener('DOMContentLoaded', function() { 208 | document.getElementById('submitCreateToken').addEventListener('click', async function() { 209 | const expiryDateStr = document.getElementById('expiryDate').value; 210 | let expiryDate; 211 | if (expiryDateStr) { 212 | const [year, month, day] = expiryDateStr.split('-').map(Number); 213 | if (isNaN(year) || isNaN(month) || isNaN(day)) { 214 | showMessageModal('Invalid date format.'); 215 | return; 216 | } 217 | expiryDate = new Date(Date.UTC(year, month - 1, day)); 218 | if (isNaN(expiryDate.getTime())) { 219 | showMessageModal('Invalid date.'); 220 | return; 221 | } 222 | } else { 223 | expiryDate = new Date(); 224 | expiryDate.setFullYear(expiryDate.getFullYear() + 1); 225 | } 226 | const expiryDateTime = expiryDate.toJSON(); 227 | const data = { 228 | "expiry_date_time": expiryDateTime 229 | }; 230 | try { 231 | const res = await doAPIRequestWithAuth('POST', '/api/v1/config/createtoken', data); 232 | if (res.err) { 233 | showMessageModal('Error: ' + res.err); 234 | } else { 235 | showMessageModal('API Token created:', res.data.token); 236 | } 237 | } catch (xhr) { 238 | const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred'; 239 | showMessageModal('Error: ' + errMsg); 240 | } 241 | // Hide the createTokenModal 242 | const createTokenModal = bootstrap.Modal.getInstance(document.getElementById('createTokenModal')); 243 | createTokenModal.hide(); 244 | }); 245 | }); 246 | 247 | document.addEventListener('DOMContentLoaded', loadConfig); 248 | -------------------------------------------------------------------------------- /web/static/js/openfsd/dashboard.js: -------------------------------------------------------------------------------- 1 | let userNetworkRating; 2 | 3 | async function kickUser(callsign) { 4 | try { 5 | await doAPIRequestWithAuth("POST", "/api/v1/fsdconn/kickuser", { callsign: callsign }); 6 | alert("User kicked successfully"); 7 | } catch (error) { 8 | alert("Failed to kick user"); 9 | } 10 | } 11 | 12 | function networkRatingFromInt(val) { 13 | switch (val) { 14 | case -1: return "Inactive" 15 | case 0: return "Suspended" 16 | case 1: return "Observer" 17 | case 2: return "Student 1" 18 | case 3: return "Student 2" 19 | case 4: return "Student 3" 20 | case 5: return "Controller 1" 21 | case 6: return "Controller 2" 22 | case 7: return "Controller 3" 23 | case 8: return "Instructor 1" 24 | case 9: return "Instructor 2" 25 | case 10: return "Instructor 3" 26 | case 11: return "Supervisor" 27 | case 12: return "Administrator" 28 | default: return "Unknown" 29 | } 30 | } 31 | 32 | $(async () => { 33 | const claims = getAccessTokenClaims() 34 | await loadUserInfo(claims.cid) 35 | 36 | const map = L.map('map').setView([30, 0], 1); 37 | L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { 38 | maxZoom: 19 39 | }).addTo(map); 40 | map.attributionControl.setPrefix('') 41 | 42 | const planeIcon = L.icon({ 43 | iconUrl: '/static/images/plane.png', 44 | iconSize: [16, 16], // Adjust based on your icon's dimensions 45 | iconAnchor: [8, 8], // Center of the icon 46 | }); 47 | 48 | await populateMap(map, planeIcon) 49 | setInterval(() => { populateMap(map, planeIcon) }, 15000) 50 | }) 51 | 52 | async function loadUserInfo(cid) { 53 | const res = await doAPIRequest("POST", "/api/v1/user/load", true, { cid: cid }) 54 | userNetworkRating = res.data.network_rating; 55 | 56 | if (res.data.first_name !== "") { 57 | $("#dashboard-real-name").text(`Welcome, ${res.data.first_name}!`); 58 | } else { 59 | $("#dashboard-real-name").text("Welcome!") 60 | } 61 | 62 | $("#dashboard-network-rating").text(networkRatingFromInt(res.data.network_rating)) 63 | $("#dashboard-cid").text(`CID: ${res.data.cid}`) 64 | 65 | if (res.data.network_rating >= 11) { 66 | $("#dashboard-user-editor").html(` 67 | 68 | 69 | `) 70 | } 71 | } 72 | 73 | let dashboardMarkers = []; 74 | 75 | async function populateMap(map, planeIcon) { 76 | try { 77 | const res = await $.ajax("/api/v1/data/openfsd-data.json", { 78 | method: "GET", 79 | dataType: "json" 80 | }); 81 | 82 | // Collect callsigns of markers with open popups 83 | const openCallsigns = new Set(); 84 | dashboardMarkers.forEach((marker) => { 85 | if (marker.getPopup() && marker.getPopup().isOpen()) { 86 | openCallsigns.add(marker.options.title); 87 | } 88 | }); 89 | 90 | // Remove existing markers 91 | dashboardMarkers.forEach((marker) => { 92 | map.removeLayer(marker); 93 | }); 94 | dashboardMarkers = []; 95 | 96 | // Add new markers 97 | res.pilots.forEach((pilot) => { 98 | const callsign = pilot.callsign; 99 | const lat = pilot.latitude; 100 | const lon = pilot.longitude; 101 | const heading = pilot.heading; 102 | const name = pilot.name; 103 | 104 | const marker = L.marker([lat, lon], { 105 | icon: planeIcon, 106 | rotationAngle: heading, 107 | rotationOrigin: 'center center', 108 | title: callsign 109 | }); 110 | 111 | let popupContent = `Callsign: ${callsign}
${name}
${lat} ${lon}`; 112 | if (userNetworkRating >= 11) { 113 | popupContent += `
`; 114 | } 115 | marker.bindPopup(popupContent); 116 | marker.addTo(map); 117 | dashboardMarkers.push(marker); 118 | 119 | // If this callsign was open before, open its popup 120 | if (openCallsigns.has(callsign)) { 121 | marker.openPopup(); 122 | } 123 | }); 124 | $("#dashboard-connection-count").text(dashboardMarkers.length); 125 | } catch (error) { 126 | console.error("Failed to fetch VATSIM data:", error); 127 | } 128 | } -------------------------------------------------------------------------------- /web/static/js/openfsd/leaflet.rotatedmarker.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | The MIT License (MIT) 4 | 5 | Copyright (c) 2015 Benjamin Becquet 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | */ 26 | 27 | /* https://github.com/bbecquet/Leaflet.RotatedMarker */ 28 | 29 | (function() { 30 | // save these original methods before they are overwritten 31 | var proto_initIcon = L.Marker.prototype._initIcon; 32 | var proto_setPos = L.Marker.prototype._setPos; 33 | 34 | var oldIE = (L.DomUtil.TRANSFORM === 'msTransform'); 35 | 36 | L.Marker.addInitHook(function () { 37 | var iconOptions = this.options.icon && this.options.icon.options; 38 | var iconAnchor = iconOptions && this.options.icon.options.iconAnchor; 39 | if (iconAnchor) { 40 | iconAnchor = (iconAnchor[0] + 'px ' + iconAnchor[1] + 'px'); 41 | } 42 | this.options.rotationOrigin = this.options.rotationOrigin || iconAnchor || 'center bottom' ; 43 | this.options.rotationAngle = this.options.rotationAngle || 0; 44 | 45 | // Ensure marker keeps rotated during dragging 46 | this.on('drag', function(e) { e.target._applyRotation(); }); 47 | }); 48 | 49 | L.Marker.include({ 50 | _initIcon: function() { 51 | proto_initIcon.call(this); 52 | }, 53 | 54 | _setPos: function (pos) { 55 | proto_setPos.call(this, pos); 56 | this._applyRotation(); 57 | }, 58 | 59 | _applyRotation: function () { 60 | if(this.options.rotationAngle) { 61 | this._icon.style[L.DomUtil.TRANSFORM+'Origin'] = this.options.rotationOrigin; 62 | 63 | if(oldIE) { 64 | // for IE 9, use the 2D rotation 65 | this._icon.style[L.DomUtil.TRANSFORM] = 'rotate(' + this.options.rotationAngle + 'deg)'; 66 | } else { 67 | // for modern browsers, prefer the 3D accelerated version 68 | this._icon.style[L.DomUtil.TRANSFORM] += ' rotateZ(' + this.options.rotationAngle + 'deg)'; 69 | } 70 | } 71 | }, 72 | 73 | setRotationAngle: function(angle) { 74 | this.options.rotationAngle = angle; 75 | this.update(); 76 | return this; 77 | }, 78 | 79 | setRotationOrigin: function(origin) { 80 | this.options.rotationOrigin = origin; 81 | this.update(); 82 | return this; 83 | } 84 | }); 85 | })(); -------------------------------------------------------------------------------- /web/static/js/openfsd/login.js: -------------------------------------------------------------------------------- 1 | $("#login-form").on("submit", (ev) => { 2 | ev.preventDefault(); 3 | 4 | const requestBody = { 5 | 'cid': parseInt($("#login-input-cid").val()), 6 | 'password': $("#login-input-password").val(), 7 | 'remember_me': $("#login-form-remember-me").prop('checked') 8 | } 9 | 10 | $.ajax("/api/v1/auth/login", { 11 | method: "POST", 12 | contentType: "application/json", 13 | dataType: "json", 14 | data: JSON.stringify(requestBody) 15 | }).then((res) => { 16 | console.log(res); 17 | localStorage.setItem("access_token", res.data.access_token) 18 | localStorage.setItem("refresh_token", res.data.refresh_token) 19 | window.location.href = "/dashboard" 20 | }).fail((xhr) => { 21 | alert(`${xhr.statusText}: ${xhr.responseJSON['err']}`) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /web/static/js/openfsd/usereditor.js: -------------------------------------------------------------------------------- 1 | // Form Handlers 2 | document.getElementById('search-form').addEventListener('submit', async function(event) { 3 | event.preventDefault(); 4 | const cid = document.getElementById('search-cid').value; 5 | try { 6 | const res = await doAPIRequestWithAuth('POST', '/api/v1/user/load', {cid: parseInt(cid)}); 7 | if (res.err) { 8 | alert('Error: ' + res.err); 9 | } else { 10 | const user = res.data; 11 | document.getElementById('edit-cid').value = user.cid; 12 | document.getElementById('edit-cid').hidden = false; 13 | document.getElementById('edit-first-name').value = user.first_name || ''; 14 | document.getElementById('edit-last-name').value = user.last_name || ''; 15 | document.getElementById('edit-network-rating').value = user.network_rating; 16 | document.getElementById('edit-password').value = ''; 17 | } 18 | } catch (xhr) { 19 | const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred'; 20 | alert('Error: ' + errMsg); 21 | console.error('Request failed:', xhr); 22 | } 23 | }); 24 | 25 | document.getElementById('create-form').addEventListener('submit', async function(event) { 26 | event.preventDefault(); 27 | const firstName = document.getElementById('create-first-name').value; 28 | const lastName = document.getElementById('create-last-name').value; 29 | const password = document.getElementById('create-password').value; 30 | const networkRating = document.getElementById('create-network-rating').value; 31 | const data = { 32 | password: password, 33 | network_rating: parseInt(networkRating) 34 | }; 35 | if (firstName) data.first_name = firstName; 36 | if (lastName) data.last_name = lastName; 37 | try { 38 | const res = await doAPIRequestWithAuth('POST', '/api/v1/user/create', data); 39 | if (res.err) { 40 | alert('Error: ' + res.err); 41 | } else { 42 | alert('User created successfully. CID: ' + res.data.cid); 43 | document.getElementById('create-first-name').value = ''; 44 | document.getElementById('create-last-name').value = ''; 45 | document.getElementById('create-password').value = ''; 46 | document.getElementById('create-network-rating').value = '-1'; 47 | } 48 | } catch (xhr) { 49 | const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred'; 50 | alert('Error: ' + errMsg); 51 | console.error('Request failed:', xhr); 52 | } 53 | }); 54 | 55 | document.getElementById('edit-form').addEventListener('submit', async function(event) { 56 | event.preventDefault(); 57 | const cid = document.getElementById('edit-cid').value; 58 | const firstName = document.getElementById('edit-first-name').value; 59 | const lastName = document.getElementById('edit-last-name').value; 60 | const networkRating = document.getElementById('edit-network-rating').value; 61 | const password = document.getElementById('edit-password').value; 62 | const data = { 63 | cid: parseInt(cid), 64 | first_name: firstName, 65 | last_name: lastName, 66 | network_rating: parseInt(networkRating) 67 | }; 68 | if (password) data.password = password; 69 | try { 70 | const res = await doAPIRequestWithAuth('PATCH', '/api/v1/user/update', data); 71 | if (res.err) { 72 | alert('Error: ' + res.err); 73 | } else { 74 | alert('User updated successfully'); 75 | } 76 | } catch (xhr) { 77 | const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred'; 78 | alert('Error: ' + errMsg); 79 | console.error('Request failed:', xhr); 80 | } 81 | }); 82 | 83 | document.addEventListener('DOMContentLoaded', () => { 84 | const createPasswordInput = document.getElementById('create-password'); 85 | const createStrengthBar = document.getElementById('create-password-strength'); 86 | const createFeedback = document.getElementById('create-password-feedback'); 87 | const editPasswordInput = document.getElementById('edit-password'); 88 | const editStrengthBar = document.getElementById('edit-password-strength'); 89 | const editFeedback = document.getElementById('edit-password-feedback'); 90 | 91 | const evaluatePassword = (password, strengthBar, feedback) => { 92 | if (!password) { 93 | strengthBar.style.width = '0%'; 94 | strengthBar.className = 'progress-bar'; 95 | feedback.textContent = ''; 96 | return; 97 | } 98 | 99 | let strength = 0; 100 | if (password.length === 8) strength += 50; 101 | if (/[A-Z]/.test(password)) strength += 15; 102 | if (/[a-z]/.test(password)) strength += 15; 103 | if (/[0-9]/.test(password)) strength += 10; 104 | if (/[^A-Za-z0-9]/.test(password)) strength += 10; 105 | 106 | strength = Math.min(strength, 100); 107 | strengthBar.style.width = `${strength}%`; 108 | 109 | if (strength < 60) { 110 | strengthBar.className = 'progress-bar bg-danger'; 111 | feedback.textContent = 'Weak: Include uppercase, lowercase, numbers, or symbols.'; 112 | } else if (strength < 80) { 113 | strengthBar.className = 'progress-bar bg-warning'; 114 | feedback.textContent = 'Moderate: Add more character types for strength.'; 115 | } else { 116 | strengthBar.className = 'progress-bar bg-success'; 117 | feedback.textContent = 'Strong: Good password!'; 118 | } 119 | }; 120 | 121 | createPasswordInput.addEventListener('input', () => { 122 | evaluatePassword(createPasswordInput.value, createStrengthBar, createFeedback); 123 | }); 124 | 125 | editPasswordInput.addEventListener('input', () => { 126 | evaluatePassword(editPasswordInput.value, editStrengthBar, editFeedback); 127 | }); 128 | }); -------------------------------------------------------------------------------- /web/templates.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "github.com/gin-gonic/gin" 7 | "html/template" 8 | "io" 9 | "net/http" 10 | "path" 11 | ) 12 | 13 | //go:embed templates 14 | var templatesFS embed.FS 15 | 16 | var basePath = path.Join(".", "templates") 17 | 18 | func loadTemplate(key string) (t *template.Template) { 19 | t, err := template.ParseFS(templatesFS, path.Join(basePath, "layout.html"), path.Join(basePath, key+".html")) 20 | if err != nil { 21 | panic(err) 22 | } 23 | return 24 | } 25 | 26 | func writeTemplate(c *gin.Context, key string, data any) { 27 | c.Writer.Header().Set("Content-Type", "text/html") 28 | 29 | buf := bytes.Buffer{} 30 | if err := loadTemplate(key).Execute(&buf, nil); err != nil { 31 | c.Writer.WriteHeader(http.StatusInternalServerError) 32 | return 33 | } 34 | 35 | io.Copy(c.Writer, &buf) 36 | } 37 | -------------------------------------------------------------------------------- /web/templates/configeditor.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Config Editor{{ end }} 2 | 3 | {{ define "body" }} 4 |
5 |
6 |
7 |
Config Editor
8 |
9 | 10 |
11 | 12 | 13 |
14 |
15 |
16 |
17 | API Tokens 18 |
19 | 20 | 21 |
22 | 23 | 45 | 46 | 62 |
63 | 64 | 69 | 70 | 71 | {{ end }} -------------------------------------------------------------------------------- /web/templates/dashboard.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Dashboard{{ end }} 2 | 3 | {{ define "body" }} 4 |
5 |
users connected
6 |
7 |
8 |
9 |
Loading...
10 |
Loading...
11 |
Network Rating: Loading...
12 |
13 |
14 |
15 |
16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | {{ end }} 24 | -------------------------------------------------------------------------------- /web/templates/landing.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Welcome{{ end }} 2 | 3 | {{ define "body" }} 4 |
5 | Login 6 |
7 | {{ end }} 8 | -------------------------------------------------------------------------------- /web/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | openfsd - {{ template "title" . }} 15 | 16 | 17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 |
29 | openfsd 30 |
31 |
32 | {{ template "body" . }} 33 | 34 | -------------------------------------------------------------------------------- /web/templates/login.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Login{{ end }} 2 | 3 | {{ define "body" }} 4 |
5 |
6 |
7 |
8 | 9 | 10 |
11 |
12 | 13 | 14 |
15 |
16 | 17 | 18 |
19 | 20 |
21 |
22 |
23 | 24 | 25 | {{ end }} -------------------------------------------------------------------------------- /web/user.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "github.com/gin-gonic/gin" 7 | "github.com/renorris/openfsd/db" 8 | "github.com/renorris/openfsd/fsd" 9 | "net/http" 10 | ) 11 | 12 | // getUserByCID returns the user info of the specified CID. 13 | // 14 | // Only >= SUP can request CIDs other than what is indicated in their bearer token. 15 | func (s *Server) getUserByCID(c *gin.Context) { 16 | type RequestBody struct { 17 | CID int `json:"cid" binding:"min=1,required"` 18 | } 19 | 20 | var reqBody RequestBody 21 | if !bindJSONOrAbort(c, &reqBody) { 22 | return 23 | } 24 | 25 | claims := getJwtContext(c) 26 | 27 | if reqBody.CID != claims.CID && claims.NetworkRating < fsd.NetworkRatingSupervisor { 28 | writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) 29 | return 30 | } 31 | 32 | user, err := s.dbRepo.UserRepo.GetUserByCID(reqBody.CID) 33 | if err != nil { 34 | if errors.Is(err, sql.ErrNoRows) { 35 | writeAPIV1Response(c, http.StatusNotFound, &genericAPIV1NotFound) 36 | return 37 | } 38 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 39 | return 40 | } 41 | 42 | type ResponseBody struct { 43 | CID int `json:"cid"` 44 | FirstName string `json:"first_name"` 45 | LastName string `json:"last_name"` 46 | NetworkRating int `json:"network_rating"` 47 | } 48 | 49 | resBody := ResponseBody{ 50 | CID: user.CID, 51 | FirstName: safeStr(user.FirstName), 52 | LastName: safeStr(user.LastName), 53 | NetworkRating: user.NetworkRating, 54 | } 55 | 56 | res := newAPIV1Success(&resBody) 57 | writeAPIV1Response(c, http.StatusOK, &res) 58 | } 59 | 60 | // updateUser updates the user with a specified CID. 61 | // 62 | // The CID itself is immutable and cannot be changed. 63 | // Only >= SUP can update CIDs other than what is indicated in their bearer token. 64 | func (s *Server) updateUser(c *gin.Context) { 65 | claims := getJwtContext(c) 66 | if claims.NetworkRating < fsd.NetworkRatingSupervisor { 67 | writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) 68 | return 69 | } 70 | 71 | type RequestBody struct { 72 | CID int `json:"cid" binding:"min=1,required"` 73 | Password *string `json:"password"` 74 | FirstName *string `json:"first_name"` 75 | LastName *string `json:"last_name"` 76 | NetworkRating *int `json:"network_rating" binding:"min=-1,max=12"` 77 | } 78 | 79 | var reqBody RequestBody 80 | if !bindJSONOrAbort(c, &reqBody) { 81 | return 82 | } 83 | 84 | targetUser, err := s.dbRepo.UserRepo.GetUserByCID(reqBody.CID) 85 | if err != nil { 86 | writeAPIV1Response(c, http.StatusNotFound, &genericAPIV1NotFound) 87 | return 88 | } 89 | 90 | if targetUser.NetworkRating > int(claims.NetworkRating) { 91 | res := newAPIV1Failure("cannot update user with higher network rating") 92 | writeAPIV1Response(c, http.StatusForbidden, &res) 93 | return 94 | } 95 | 96 | // Update target user's fields depending on what was provided in the request 97 | if reqBody.Password != nil { 98 | targetUser.Password = *reqBody.Password 99 | } 100 | if reqBody.FirstName != nil { 101 | targetUser.FirstName = reqBody.FirstName 102 | } 103 | if reqBody.LastName != nil { 104 | targetUser.LastName = reqBody.LastName 105 | } 106 | if reqBody.NetworkRating != nil { 107 | targetUser.NetworkRating = *reqBody.NetworkRating 108 | } 109 | 110 | err = s.dbRepo.UserRepo.UpdateUser(targetUser) 111 | if err != nil { 112 | if errors.Is(err, sql.ErrNoRows) { 113 | writeAPIV1Response(c, http.StatusNotFound, &genericAPIV1NotFound) 114 | return 115 | } 116 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 117 | return 118 | } 119 | 120 | type ResponseBody struct { 121 | CID int `json:"cid"` 122 | FirstName string `json:"first_name"` 123 | LastName string `json:"last_name"` 124 | NetworkRating int `json:"network_rating"` 125 | } 126 | 127 | resBody := ResponseBody{ 128 | CID: targetUser.CID, 129 | FirstName: safeStr(targetUser.FirstName), 130 | LastName: safeStr(targetUser.LastName), 131 | NetworkRating: targetUser.NetworkRating, 132 | } 133 | 134 | res := newAPIV1Success(&resBody) 135 | writeAPIV1Response(c, http.StatusOK, &res) 136 | } 137 | 138 | func (s *Server) createUser(c *gin.Context) { 139 | type RequestBody struct { 140 | Password string `json:"password" binding:"min=8,required"` 141 | FirstName *string `json:"first_name"` 142 | LastName *string `json:"last_name"` 143 | NetworkRating int `json:"network_rating" binding:"min=-1,max=12,required"` 144 | } 145 | 146 | var reqBody RequestBody 147 | if !bindJSONOrAbort(c, &reqBody) { 148 | return 149 | } 150 | 151 | claims := getJwtContext(c) 152 | if claims.NetworkRating < fsd.NetworkRatingSupervisor || 153 | reqBody.NetworkRating > int(claims.NetworkRating) { 154 | writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) 155 | return 156 | } 157 | 158 | user := &db.User{ 159 | Password: reqBody.Password, 160 | FirstName: reqBody.FirstName, 161 | LastName: reqBody.LastName, 162 | NetworkRating: reqBody.NetworkRating, 163 | } 164 | 165 | if err := s.dbRepo.UserRepo.CreateUser(user); err != nil { 166 | writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) 167 | return 168 | } 169 | 170 | type ResponseBody struct { 171 | CID int `json:"cid"` 172 | FirstName *string `json:"first_name"` 173 | LastName *string `json:"last_name"` 174 | NetworkRating int `json:"network_rating" binding:"min=-1,max=12,required"` 175 | } 176 | 177 | resBody := ResponseBody{ 178 | CID: user.CID, 179 | FirstName: user.FirstName, 180 | LastName: user.LastName, 181 | NetworkRating: user.NetworkRating, 182 | } 183 | 184 | res := newAPIV1Success(&resBody) 185 | writeAPIV1Response(c, http.StatusCreated, &res) 186 | } 187 | -------------------------------------------------------------------------------- /web/util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // safeStr returns an empty string if the pointer is nil, or the underlying string value if not nil. 4 | func safeStr(str *string) string { 5 | if str == nil { 6 | return "" 7 | } else { 8 | return *str 9 | } 10 | } 11 | --------------------------------------------------------------------------------