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>
342 lines
12 KiB
Go
342 lines
12 KiB
Go
// 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 (1–4 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 }
|