Files
PilotVault/API Server/internal/plugins/builtin/opensky/opensky.go
T
tajniak81andClaude Opus 4.8 150758b0bf Add bounding-box region picker to the API Server plugin panel
Give the superadmin Plugins panel the same OpenSky "Default bounding
box" experience as the Web App: a grouped preset dropdown (World /
Continents / Countries) plus a Custom option for manual
lamin,lomin,lamax,lomax entry.

Introduce a "bbox" config-field type (rendering hint only; unknown
types still degrade to a text input) and mark the OpenSky bbox field
with it. Values that match a preset render as the region name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:28:23 +02:00

342 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package opensky is a built-in plugin connecting the OpenSky Network REST API
// (live ADS-B aircraft state vectors). It demonstrates a real third-party
// integration behind the plugin contract, including an OAuth2 client-credentials
// AuthProvider with an anonymous fallback.
//
// Docs: https://openskynetwork.github.io/opensky-api/rest.html
package opensky
import (
"context"
"encoding/json"
"errors"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"pilotvault/apiserver/internal/plugins"
)
const (
apiBase = "https://opensky-network.org/api"
tokenURL = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token"
// Default bounding box covers Europe; matches the "Europe" preset offered in
// the Web App bounding-box picker. Users can narrow it (e.g. a single country)
// to reduce per-query credit cost.
defaultBBox = "34,-25,72,45" // lamin,lomin,lamax,lomax (Europe)
)
func init() {
plugins.Register("opensky", func() plugins.Plugin { return &Plugin{} })
}
// Plugin is the OpenSky connector.
type Plugin struct {
mu sync.Mutex
clientID string
clientSecret string
bbox string
plan string
allowAnonymous bool
client *http.Client
token string
tokenExp time.Time
}
// errAnonDisabled is returned when a probe/call has no resolved credentials and
// the operator has disabled anonymous access.
var errAnonDisabled = errors.New("OpenSky credentials required — anonymous access is disabled")
func (p *Plugin) Descriptor() plugins.Descriptor {
return plugins.Descriptor{
Name: "opensky",
Provider: "OpenSky Network",
Version: "1.0.0",
Kind: plugins.KindBuiltin,
Category: plugins.CategoryAPIsExternal,
Capabilities: []plugins.Capability{
{ID: "states.all", Method: "GET", Endpoint: "/states/all",
Description: "All current aircraft state vectors, world-wide (costs 4 credits/call)."},
{ID: "states.bbox", Method: "GET", Endpoint: "/states/all?lamin&lomin&lamax&lomax",
Description: "State vectors within the configured bounding box (14 credits by area)."},
},
AuthType: plugins.AuthOAuth2,
ConfigFields: []plugins.ConfigField{
{Key: "plan", Label: "OpenSky plan", Type: "select",
Options: []plugins.SelectOption{
{Value: "", Label: "Not set — let organizations and users choose"},
{Value: "anonymous", Label: "Anonymous — 400 credits/day"},
{Value: "standard", Label: "Standard (registered) — 4000 credits/day"},
{Value: "contributor", Label: "Contributor — 8000 credits/day"},
},
Help: "Global account tier. Leave it unset to let each organization or user pick their own plan; set a value only to force one plan for everyone. Determines the daily credit allowance shown next to remaining credits."},
{Key: "clientId", Label: "OAuth2 client ID", Type: "text", Help: "Optional — leave blank for anonymous access (lower rate limits)."},
{Key: "clientSecret", Label: "OAuth2 client secret", Type: "password", Secret: true, Help: "Paired with the client ID for authenticated access."},
{Key: "bbox", Label: "Default bounding box", Type: "bbox", Default: defaultBBox, Help: "Pick a region or choose Custom to enter lamin,lomin,lamax,lomax — used by the health probe and states.bbox."},
{Key: "allowAnonymous", Label: "Anonymous access", Type: "select", Default: "true",
Options: []plugins.SelectOption{
{Value: "true", Label: "Enabled — allow use without credentials"},
{Value: "false", Label: "Disabled — require OAuth2 credentials"},
},
Help: "Global policy: when disabled, the plugin can only be used once OAuth2 credentials resolve from some layer (superadmin, organization, or user)."},
},
}
}
// planDailyCredits maps an OpenSky plan to its daily credit allowance.
// See https://openskynetwork.github.io/opensky-api/rest.html#api-credits
func planDailyCredits(plan string) int {
switch plan {
case "anonymous":
return 400
case "contributor":
return 8000
default: // "standard"
return 4000
}
}
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.clientID = strings.TrimSpace(config["clientId"])
p.clientSecret = config["clientSecret"]
p.bbox = strings.TrimSpace(config["bbox"])
if p.bbox == "" {
p.bbox = defaultBBox
}
p.plan = strings.TrimSpace(config["plan"])
if p.plan == "" {
p.plan = "standard" // OpenSky registered-user default
}
// Anonymous access defaults to enabled; only an explicit "false" turns it off.
p.allowAnonymous = !strings.EqualFold(strings.TrimSpace(config["allowAnonymous"]), "false")
p.client = &http.Client{Timeout: 10 * time.Second}
p.token, p.tokenExp = "", time.Time{}
return nil
}
// bearer returns a valid OAuth2 token, fetching/refreshing via client-credentials
// when configured. Returns "" (no error) when running anonymously.
func (p *Plugin) bearer(ctx context.Context) (string, error) {
p.mu.Lock()
id, secret, allowAnon := p.clientID, p.clientSecret, p.allowAnonymous
if p.token != "" && time.Now().Before(p.tokenExp) {
tok := p.token
p.mu.Unlock()
return tok, nil
}
p.mu.Unlock()
if id == "" || secret == "" {
if !allowAnon {
return "", errAnonDisabled
}
return "", nil // anonymous
}
form := url.Values{
"grant_type": {"client_credentials"},
"client_id": {id},
"client_secret": {secret},
}
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", errors.New("token endpoint returned HTTP " + resp.Status)
}
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(data, &out); err != nil || out.AccessToken == "" {
return "", errors.New("no access_token in token response")
}
p.mu.Lock()
p.token = out.AccessToken
ttl := out.ExpiresIn
if ttl <= 0 {
ttl = 1800
}
p.tokenExp = time.Now().Add(time.Duration(ttl-30) * time.Second)
p.mu.Unlock()
return out.AccessToken, nil
}
// statesURLBBox builds the /states/all request URL constrained to the configured
// bounding box. Falls back to the whole world if the bbox is malformed.
func (p *Plugin) statesURLBBox() string {
p.mu.Lock()
bbox := p.bbox
p.mu.Unlock()
parts := strings.Split(bbox, ",")
if len(parts) != 4 {
return apiBase + "/states/all"
}
q := url.Values{
"lamin": {strings.TrimSpace(parts[0])},
"lomin": {strings.TrimSpace(parts[1])},
"lamax": {strings.TrimSpace(parts[2])},
"lomax": {strings.TrimSpace(parts[3])},
}
return apiBase + "/states/all?" + q.Encode()
}
// statesURLAll returns the world-wide /states/all URL (no bounding box).
func (p *Plugin) statesURLAll() string { return apiBase + "/states/all" }
// creditCost returns the OpenSky credit cost of a /states/all call over the given
// bounding box, per https://openskynetwork.github.io/opensky-api/rest.html#api-credits:
// 1 credit ≤ 25 sq°, 2 ≤ 100, 3 ≤ 400, 4 for larger or the whole world.
func creditCost(bbox string) int {
parts := strings.Split(bbox, ",")
if len(parts) != 4 {
return 4 // no/invalid box → whole world
}
lamin, e1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
lomin, e2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
lamax, e3 := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
lomax, e4 := strconv.ParseFloat(strings.TrimSpace(parts[3]), 64)
if e1 != nil || e2 != nil || e3 != nil || e4 != nil {
return 4
}
area := math.Abs(lamax-lamin) * math.Abs(lomax-lomin)
switch {
case area <= 25:
return 1
case area <= 100:
return 2
case area <= 400:
return 3
default:
return 4
}
}
// creditWord renders a credit count with correct pluralisation.
func creditWord(n int) string {
if n == 1 {
return "1 credit"
}
return strconv.Itoa(n) + " credits"
}
// HealthCheck performs a live states query (authenticated when configured, else
// anonymous) and classifies the outcome.
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
start := time.Now()
token, err := p.bearer(ctx)
if errors.Is(err, errAnonDisabled) {
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(),
Detail: err.Error()}
}
if err != nil {
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(),
Detail: "auth failed: " + err.Error()}
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, p.statesURLBBox(), nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := p.client.Do(req)
lat := time.Since(start).Milliseconds()
if err != nil {
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
mode := "anonymous"
if token != "" {
mode = "authenticated"
}
h := plugins.Health{LatencyMs: lat}
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
h.Status, h.Detail = plugins.StatusOK, "OpenSky reachable ("+mode+")"
case resp.StatusCode == http.StatusTooManyRequests:
h.Status, h.Detail = plugins.StatusDegraded, "rate limited (HTTP 429)"
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
h.Status, h.Detail = plugins.StatusDegraded, "auth rejected (HTTP "+resp.Status+")"
default:
h.Status, h.Detail = plugins.StatusDown, "HTTP "+resp.Status
}
// Surface live credit usage from the rate-limit header, the plan's daily
// allowance, and this probe's cost (e.g. "3996/4000 credits left today · 1 credit/probe").
// The same figures are also exposed structurally (h.Credits) so the UI can
// render a dedicated usage meter without parsing this string.
p.mu.Lock()
bbox, plan := p.bbox, p.plan
p.mu.Unlock()
cost := creditCost(bbox)
credits := &plugins.HealthCredits{Daily: planDailyCredits(plan), ProbeCost: cost, Mode: mode}
if rem := strings.TrimSpace(resp.Header.Get("X-Rate-Limit-Remaining")); rem != "" {
if n, err := strconv.Atoi(rem); err == nil {
credits.Remaining = &n
}
h.Detail += " · " + p.creditsText(rem)
}
h.Detail += " · " + creditWord(cost) + "/probe"
h.Credits = credits
return h
}
// creditsText formats the remaining-credit header against the plan's daily
// allowance. Empty when the header is absent.
func (p *Plugin) creditsText(remaining string) string {
remaining = strings.TrimSpace(remaining)
if remaining == "" {
return ""
}
p.mu.Lock()
daily := planDailyCredits(p.plan)
p.mu.Unlock()
return remaining + "/" + strconv.Itoa(daily) + " credits left today"
}
// Invoke exposes states.all / states.bbox. Part of the contract; no HTTP endpoint
// surfaces it in v1, but it keeps the connector functional for future use.
func (p *Plugin) Invoke(ctx context.Context, action string, _ json.RawMessage) (json.RawMessage, error) {
switch action {
case "states.all", "states.bbox":
token, err := p.bearer(ctx)
if err != nil {
return nil, err
}
target := p.statesURLBBox()
if action == "states.all" {
target = p.statesURLAll() // world-wide (4 credits)
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
return data, nil
default:
return nil, errors.New("unknown action: " + action)
}
}
func (p *Plugin) Shutdown(context.Context) error { return nil }