// 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 }