Initial commit: PilotVault multi-service project
Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App (Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment configs. Design assets and build artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
# Building PilotVault Plugins
|
||||
|
||||
A **plugin** integrates an external third-party service (flight data,
|
||||
notifications, …) behind one uniform contract. There are two kinds:
|
||||
|
||||
| Kind | Written as | Added by | Rebuild? | Use when |
|
||||
|---|---|---|---|---|
|
||||
| **built-in** | Go code in this repo | a rebuild | yes | first-party, high-trust, type-safe connectors |
|
||||
| **external** | any HTTP service | registering a URL at runtime | **no** | third-party / less-trusted / independently deployed |
|
||||
|
||||
Both implement the same behaviour; the server treats them identically. Enable
|
||||
state and per-plugin config persist to `plugins.json` and load on boot. Every
|
||||
plugin is managed by a **superadmin** from the panel (`/`) or the
|
||||
`/api/admin/plugins*` API.
|
||||
|
||||
---
|
||||
|
||||
## The contract
|
||||
|
||||
All plugins satisfy the Go interface in [`plugin.go`](plugin.go):
|
||||
|
||||
```go
|
||||
type Plugin interface {
|
||||
Descriptor() Descriptor
|
||||
Init(ctx context.Context, config map[string]string) error
|
||||
HealthCheck(ctx context.Context) Health
|
||||
Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error)
|
||||
Shutdown(ctx context.Context) error
|
||||
}
|
||||
```
|
||||
|
||||
- **`Descriptor`** — static metadata (name, provider, version, capabilities,
|
||||
auth type, config fields). Drives the panel UI.
|
||||
- **`Init`** — called with the resolved config (secrets included) whenever the
|
||||
plugin is enabled or its config changes. Prepare clients/tokens here.
|
||||
- **`HealthCheck`** — probe the upstream and classify: `Health{Status, LatencyMs, Detail}`
|
||||
where `Status` is `StatusOK` / `StatusDegraded` / `StatusDown`.
|
||||
- **`Invoke`** — run a named capability. **Part of the contract for the future;
|
||||
no HTTP endpoint exposes it in v1.** Implement it anyway so the connector is
|
||||
ready.
|
||||
- **`Shutdown`** — release resources.
|
||||
|
||||
### Descriptor & config fields
|
||||
|
||||
```go
|
||||
Descriptor{
|
||||
Name: "acme", // unique id, [a-z0-9-]
|
||||
Provider: "ACME Corp", // human label
|
||||
Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin, // or KindExternal
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."},
|
||||
},
|
||||
AuthType: plugins.AuthAPIKey, // None | APIKey | Basic | OAuth2 | Webhook (metadata only)
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
{Key: "apiKey", Label: "API key", Type: "password", Required: true, Secret: true,
|
||||
Help: "Found under ACME → Settings → API."},
|
||||
{Key: "region", Label: "Region", Type: "text", Help: "e.g. eu-west-1"},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`ConfigField.Type` is `"text"`, `"password"`, or `"number"` (form input hint).
|
||||
Set **`Secret: true`** for credentials — the server never echoes them back in
|
||||
clear; the panel shows a mask (`••••••••`), and on save a field left at the mask
|
||||
keeps its stored value (so operators don't retype secrets). **`Required: true`**
|
||||
fields must be non-empty before the plugin can be enabled.
|
||||
|
||||
---
|
||||
|
||||
## Building a built-in plugin
|
||||
|
||||
1. **Create a package** under `internal/plugins/builtin/<name>/`.
|
||||
2. **Implement `Plugin`** and **register it in `init()`**.
|
||||
3. **Blank-import** your package from [`builtin/builtin.go`](builtin/builtin.go).
|
||||
4. **Rebuild** the server.
|
||||
|
||||
### Minimal example — `internal/plugins/builtin/acme/acme.go`
|
||||
|
||||
```go
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugins.Register("acme", func() plugins.Plugin { return &Plugin{} })
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
apiKey string
|
||||
region string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "acme", Provider: "ACME Corp", Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin, AuthType: plugins.AuthAPIKey,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."},
|
||||
},
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
{Key: "apiKey", Label: "API key", Type: "password", Required: true, Secret: true},
|
||||
{Key: "region", Label: "Region", Type: "text"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
p.apiKey = strings.TrimSpace(config["apiKey"])
|
||||
p.region = strings.TrimSpace(config["region"])
|
||||
p.client = &http.Client{Timeout: 10 * time.Second}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
||||
start := time.Now()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.acme.example/ping", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
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()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: "reachable"}
|
||||
}
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: "HTTP " + resp.Status}
|
||||
}
|
||||
|
||||
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
// Implement your capabilities; return normalized JSON. (Not yet called in v1.)
|
||||
return json.RawMessage(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
||||
```
|
||||
|
||||
### Register it for compilation — `internal/plugins/builtin/builtin.go`
|
||||
|
||||
```go
|
||||
import (
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/acme"
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/opensky"
|
||||
)
|
||||
```
|
||||
|
||||
### Rebuild
|
||||
|
||||
```powershell
|
||||
cd "API Server"
|
||||
go build -o api-server.exe ./cmd/server
|
||||
```
|
||||
|
||||
Restart the server. The plugin appears in the panel's **Plugins** card,
|
||||
**disabled** by default. See [`builtin/opensky/opensky.go`](builtin/opensky/opensky.go)
|
||||
for a fuller example with an **OAuth2 client-credentials** auth provider and an
|
||||
anonymous fallback.
|
||||
|
||||
---
|
||||
|
||||
## Building an external plugin (no rebuild)
|
||||
|
||||
An external plugin is **any HTTP service** you host (Go recommended, but any
|
||||
language works). You register its base URL at runtime; the server drives it over
|
||||
a tiny JSON contract.
|
||||
|
||||
### The HTTP contract
|
||||
|
||||
| Method & path | Purpose | Response |
|
||||
|---|---|---|
|
||||
| `GET {base}/manifest` | describe the plugin (optional) | `{provider, version, capabilities, authType, configFields}` |
|
||||
| `GET {base}/health` | health probe (required) | `2xx` = healthy; optional body `{status, detail}` |
|
||||
| `POST {base}/invoke` | run a capability (optional; unused in v1) | `{action, params}` in → arbitrary JSON out |
|
||||
|
||||
Health rules the server applies: transport error or `5xx` → `down`; `2xx` → `ok`;
|
||||
anything else → `degraded`. An explicit `{"status":"ok|degraded|down","detail":"…"}`
|
||||
body overrides the status-code heuristic. Bodies are size-limited (health 64 KiB,
|
||||
manifest 1 MiB).
|
||||
|
||||
### Minimal example — a Go plugin service
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/manifest", func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"provider": "ACME Cloud",
|
||||
"version": "2.1.0",
|
||||
"authType": "apikey",
|
||||
"capabilities": []map[string]any{
|
||||
{"id": "widgets.list", "method": "GET", "endpoint": "/widgets", "description": "List widgets."},
|
||||
}, // a plain []string{"widgets.list"} is also accepted
|
||||
"configFields": []map[string]any{
|
||||
{"key": "apiKey", "label": "API key", "type": "password", "required": true, "secret": true},
|
||||
},
|
||||
})
|
||||
})
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "detail": "acme cloud reachable"})
|
||||
})
|
||||
http.HandleFunc("/invoke", func(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Action string `json:"action"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&in)
|
||||
json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": in.Action})
|
||||
})
|
||||
http.ListenAndServe(":9100", nil)
|
||||
}
|
||||
```
|
||||
|
||||
### Register it
|
||||
|
||||
From the panel's **Plugins** card → *Register external plugin* (name + base URL),
|
||||
or via the API:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/admin/plugins \
|
||||
-H "Authorization: $SUPERADMIN_TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"name":"acme-cloud","baseURL":"http://127.0.0.1:9100","provider":"ACME Cloud"}'
|
||||
```
|
||||
|
||||
It starts **disabled**; enable it and run a health check from the panel. Because
|
||||
it runs as its own process/container, an external plugin is also the
|
||||
**sandboxing** path for less-trusted integrations.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle, config & secrets
|
||||
|
||||
- **Enable/disable** and **config** persist to `plugins.json` (gitignored; override
|
||||
the path with `PLUGINS_FILE`). Enabling calls `Init`; disabling calls `Shutdown`.
|
||||
- **Secrets** (`Secret: true` fields) are returned masked. On save, a field still
|
||||
equal to the mask keeps its stored value; send a new value to change it, or an
|
||||
empty string to clear it.
|
||||
- **Required** fields are validated when enabling — enabling fails with a clear
|
||||
error if one is blank.
|
||||
- If `Init` fails (e.g. bad credentials), the state is still saved and the API
|
||||
returns the plugin plus a `warning`; fix the config and re-save.
|
||||
|
||||
---
|
||||
|
||||
## Managing plugins (superadmin API)
|
||||
|
||||
All endpoints require a superadmin bearer token (`Authorization: <token>` from
|
||||
`POST /api/auth/login`). See the panel's **Management API** reference too.
|
||||
|
||||
| Method | Path | Body | Purpose |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/admin/plugins` | — | list all plugins + state + last health |
|
||||
| `GET` | `/api/admin/plugins/{name}` | — | one plugin |
|
||||
| `PUT` | `/api/admin/plugins/{name}` | `{enabled?, config?}` | enable/disable + configure |
|
||||
| `POST` | `/api/admin/plugins` | `{name, baseURL, provider?}` | register an external plugin |
|
||||
| `DELETE` | `/api/admin/plugins/{name}` | — | remove an external plugin (built-ins only disable) |
|
||||
| `POST` | `/api/admin/plugins/{name}/health` | — | run a health check now |
|
||||
|
||||
---
|
||||
|
||||
## Testing your plugin
|
||||
|
||||
1. Build + restart (built-in) or start your service (external) and register it.
|
||||
2. `GET /api/admin/plugins` → confirm your descriptor, config fields, capabilities.
|
||||
3. `PUT /api/admin/plugins/{name} {"enabled":true, "config":{…}}` → enable with config.
|
||||
4. `POST /api/admin/plugins/{name}/health` → confirm the live probe classifies correctly.
|
||||
5. Restart the server → confirm state reloads from `plugins.json`.
|
||||
|
||||
A Go unit test can exercise a built-in directly:
|
||||
|
||||
```go
|
||||
p := &acme.Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"apiKey": "test"})
|
||||
if h := p.HealthCheck(context.Background()); h.Status == "" {
|
||||
t.Fatal("expected a health status")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Not yet implemented (roadmap)
|
||||
|
||||
The contract is shaped for these; see [`doc.go`](doc.go):
|
||||
|
||||
- **Invocation API** — an endpoint to call `Invoke` from clients, with a normalized
|
||||
request/response envelope and a provider→internal mapper.
|
||||
- **Resilience** — retry/backoff, circuit breaker, per-plugin latency/error metrics.
|
||||
- **Per-tenant credentials** — config keyed by org/user so users connect their own accounts.
|
||||
- **Audit logging** of plugin access.
|
||||
|
||||
Until the invocation API lands, `Invoke` is dormant — plugins are discoverable,
|
||||
configurable, and health-checked, but not yet callable over HTTP.
|
||||
@@ -0,0 +1,11 @@
|
||||
// Package builtin blank-imports every built-in plugin so their init() functions
|
||||
// register them with the plugin registry. Import this package once (from the api
|
||||
// package) to make all built-in connectors available.
|
||||
package builtin
|
||||
|
||||
import (
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/filetransfer"
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/localstorage"
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/opensky"
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin/webdav"
|
||||
)
|
||||
@@ -0,0 +1,610 @@
|
||||
// Package filetransfer is a built-in plugin that connects to a file-transfer
|
||||
// server over FTP, FTPS (explicit TLS), or SFTP (SSH). It demonstrates a
|
||||
// stateful third-party integration behind the plugin contract: one descriptor
|
||||
// with a protocol switch, and a small protocol-agnostic `conn` abstraction that
|
||||
// HealthCheck and Invoke drive without caring which wire protocol is in use.
|
||||
//
|
||||
// Connections are opened per operation rather than pooled: FTP/SFTP sessions are
|
||||
// stateful and idle-timeout aggressively, so dialling on demand is both simpler
|
||||
// and more robust than keeping a long-lived connection healthy. Init only stores
|
||||
// the resolved config; nothing connects until HealthCheck or Invoke runs.
|
||||
//
|
||||
// - FTP : github.com/jlaffaye/ftp
|
||||
// - FTPS : github.com/jlaffaye/ftp with explicit TLS (AUTH TLS)
|
||||
// - SFTP : golang.org/x/crypto/ssh + github.com/pkg/sftp
|
||||
package filetransfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jlaffaye/ftp"
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
const (
|
||||
protoSFTP = "sftp"
|
||||
protoFTP = "ftp"
|
||||
protoFTPS = "ftps"
|
||||
|
||||
dialTimeout = 12 * time.Second
|
||||
// maxReadBytes caps a download so a huge remote file can't exhaust memory;
|
||||
// the health probe and Invoke both honour it.
|
||||
maxReadBytes = 32 << 20 // 32 MiB
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugins.Register("filetransfer", func() plugins.Plugin { return &Plugin{} })
|
||||
}
|
||||
|
||||
// Plugin is the FTP/FTPS/SFTP connector. All fields are guarded by mu because
|
||||
// Init may run concurrently with a HealthCheck/Invoke from another request.
|
||||
type Plugin struct {
|
||||
mu sync.Mutex
|
||||
protocol string
|
||||
host string
|
||||
port int
|
||||
username string
|
||||
password string
|
||||
privateKey string // PEM-encoded SSH private key (sftp only)
|
||||
keyPass string // passphrase for the private key
|
||||
basePath string
|
||||
// hostKeyFP, when set, pins the SFTP server's SHA256 host-key fingerprint
|
||||
// ("SHA256:…"); empty means accept any host key (trust-on-first-use, no
|
||||
// verification — flagged as degraded by the health probe).
|
||||
hostKeyFP string
|
||||
// insecureTLS skips FTPS certificate verification when true.
|
||||
insecureTLS bool
|
||||
}
|
||||
|
||||
func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "filetransfer",
|
||||
Provider: "FTP / SFTP",
|
||||
Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin,
|
||||
Category: plugins.CategoryDrivesExternal,
|
||||
AuthType: plugins.AuthBasic,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "list", Method: "GET", Endpoint: "/", Description: "List a remote directory. params: {path}"},
|
||||
{ID: "stat", Method: "GET", Endpoint: "/", Description: "Stat one remote path. params: {path}"},
|
||||
{ID: "download", Method: "GET", Endpoint: "/", Description: "Read a remote file (base64, ≤32 MiB). params: {path}"},
|
||||
{ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a remote file. params: {path, contentBase64}"},
|
||||
{ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a remote file. params: {path}"},
|
||||
{ID: "mkdir", Method: "PUT", Endpoint: "/", Description: "Create a remote directory. params: {path}"},
|
||||
},
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
// No field is Required: the plugin can be enabled as a master switch with
|
||||
// an empty global config, leaving each organization or user to supply
|
||||
// their own connection through the cascade (mirrors OpenSky). A missing
|
||||
// host is reported gracefully by the health probe.
|
||||
{Key: "protocol", Label: "Protocol", Type: "select", Default: protoSFTP,
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: protoSFTP, Label: "SFTP — file transfer over SSH (recommended)"},
|
||||
{Value: protoFTPS, Label: "FTPS — FTP with explicit TLS (AUTH TLS)"},
|
||||
{Value: protoFTP, Label: "FTP — plaintext (insecure)"},
|
||||
},
|
||||
Help: "SFTP runs over SSH (port 22); FTP/FTPS use port 21 by default."},
|
||||
{Key: "host", Label: "Host", Type: "text", Help: "Server hostname or IP, e.g. files.example.com"},
|
||||
{Key: "port", Label: "Port", Type: "number", Help: "Leave blank for the protocol default (22 for SFTP, 21 for FTP/FTPS)."},
|
||||
{Key: "username", Label: "Username", Type: "text"},
|
||||
{Key: "password", Label: "Password", Type: "password", Secret: true,
|
||||
Help: "Password for FTP/FTPS, or SFTP password auth. Leave blank to use an SFTP private key."},
|
||||
{Key: "privateKey", Label: "SSH private key (SFTP)", Type: "password", Secret: true,
|
||||
Help: "PEM-encoded private key for SFTP key auth. Used instead of, or alongside, a password."},
|
||||
{Key: "keyPassphrase", Label: "Private key passphrase", Type: "password", Secret: true,
|
||||
Help: "Passphrase protecting the SSH private key, if any."},
|
||||
{Key: "basePath", Label: "Base path", Type: "text", Default: ".",
|
||||
Help: "Directory used as the working root and probed by the health check, e.g. /uploads. Relative capability paths are resolved under it."},
|
||||
{Key: "hostKeyFingerprint", Label: "SFTP host key fingerprint", Type: "text",
|
||||
Help: "Optional SHA256:… fingerprint to pin the SFTP server's host key. Leave blank to accept any key (no verification)."},
|
||||
{Key: "insecureSkipVerify", Label: "FTPS TLS verification", Type: "select", Default: "false",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "false", Label: "Verify certificate (recommended)"},
|
||||
{Value: "true", Label: "Skip verification — accept any certificate"},
|
||||
},
|
||||
Help: "Only affects FTPS. Skip verification only for self-signed test servers."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.protocol = strings.ToLower(strings.TrimSpace(config["protocol"]))
|
||||
if p.protocol == "" {
|
||||
p.protocol = protoSFTP
|
||||
}
|
||||
p.host = strings.TrimSpace(config["host"])
|
||||
p.port = 0
|
||||
if raw := strings.TrimSpace(config["port"]); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil {
|
||||
p.port = n
|
||||
}
|
||||
}
|
||||
p.username = strings.TrimSpace(config["username"])
|
||||
p.password = config["password"]
|
||||
p.privateKey = config["privateKey"]
|
||||
p.keyPass = config["keyPassphrase"]
|
||||
p.basePath = strings.TrimSpace(config["basePath"])
|
||||
if p.basePath == "" {
|
||||
p.basePath = "."
|
||||
}
|
||||
p.hostKeyFP = strings.TrimSpace(config["hostKeyFingerprint"])
|
||||
p.insecureTLS = strings.EqualFold(strings.TrimSpace(config["insecureSkipVerify"]), "true")
|
||||
return nil
|
||||
}
|
||||
|
||||
// effectivePort returns the configured port or the protocol default.
|
||||
func (p *Plugin) effectivePort() int {
|
||||
if p.port > 0 {
|
||||
return p.port
|
||||
}
|
||||
if p.protocol == protoSFTP {
|
||||
return 22
|
||||
}
|
||||
return 21
|
||||
}
|
||||
|
||||
// resolve joins a caller-supplied path against the base path. An absolute path
|
||||
// is used as-is; an empty path becomes the base path itself.
|
||||
func (p *Plugin) resolve(rel string) string {
|
||||
rel = strings.TrimSpace(rel)
|
||||
if rel == "" {
|
||||
return p.basePath
|
||||
}
|
||||
if strings.HasPrefix(rel, "/") || p.basePath == "" || p.basePath == "." {
|
||||
return rel
|
||||
}
|
||||
return path.Join(p.basePath, rel)
|
||||
}
|
||||
|
||||
// HealthCheck dials, authenticates, and lists the base path, classifying the
|
||||
// outcome. A missing/unverified SFTP host key downgrades OK to degraded.
|
||||
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
||||
start := time.Now()
|
||||
|
||||
p.mu.Lock()
|
||||
proto, host, hostKeyFP := p.protocol, p.host, p.hostKeyFP
|
||||
base := p.basePath
|
||||
p.mu.Unlock()
|
||||
|
||||
if host == "" {
|
||||
return plugins.Health{Status: plugins.StatusDown, Detail: "no host configured"}
|
||||
}
|
||||
|
||||
c, err := p.dial(ctx)
|
||||
if err != nil {
|
||||
lat := time.Since(start).Milliseconds()
|
||||
return plugins.Health{Status: classifyDialErr(err), LatencyMs: lat, Detail: err.Error()}
|
||||
}
|
||||
defer c.close()
|
||||
|
||||
entries, err := c.list(base)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: lat,
|
||||
Detail: fmt.Sprintf("connected (%s) but listing %q failed: %v", proto, base, err)}
|
||||
}
|
||||
|
||||
detail := fmt.Sprintf("%s reachable — %d entr%s under %q", strings.ToUpper(proto), len(entries), plural(len(entries)), base)
|
||||
status := plugins.StatusOK
|
||||
if proto == protoSFTP && hostKeyFP == "" {
|
||||
status = plugins.StatusDegraded
|
||||
detail += " · host key not verified (no fingerprint pinned)"
|
||||
}
|
||||
if proto == protoFTP {
|
||||
detail += " · plaintext (no encryption)"
|
||||
}
|
||||
return plugins.Health{Status: status, LatencyMs: lat, Detail: detail}
|
||||
}
|
||||
|
||||
// Invoke runs one capability against a freshly-dialled connection.
|
||||
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
c, err := p.dial(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.close()
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
entries, err := c.list(p.resolve(in.Path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "entries": entries})
|
||||
|
||||
case "stat":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
fi, err := c.stat(p.resolve(in.Path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(fi)
|
||||
|
||||
case "download":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
data, err := c.read(p.resolve(in.Path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"path": p.resolve(in.Path),
|
||||
"size": len(data),
|
||||
"contentBase64": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
|
||||
case "upload":
|
||||
var in writeParams
|
||||
if err := json.Unmarshal(params, &in); err != nil {
|
||||
return nil, fmt.Errorf("invalid params: %w", err)
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(in.ContentBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err)
|
||||
}
|
||||
if err := c.write(p.resolve(in.Path), data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "size": len(data), "ok": true})
|
||||
|
||||
case "delete":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
if err := c.remove(p.resolve(in.Path)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "ok": true})
|
||||
|
||||
case "mkdir":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
if err := c.mkdir(p.resolve(in.Path)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": p.resolve(in.Path), "ok": true})
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
||||
|
||||
// pathParams / writeParams are the Invoke request shapes.
|
||||
type pathParams struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
type writeParams struct {
|
||||
Path string `json:"path"`
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
|
||||
// fileInfo is the normalized directory-entry shape returned by list/stat.
|
||||
type fileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
ModTime string `json:"modTime,omitempty"`
|
||||
}
|
||||
|
||||
// conn is the protocol-agnostic surface HealthCheck and Invoke drive. Both the
|
||||
// FTP and SFTP implementations satisfy it.
|
||||
type conn interface {
|
||||
list(path string) ([]fileInfo, error)
|
||||
stat(path string) (fileInfo, error)
|
||||
read(path string) ([]byte, error)
|
||||
write(path string, data []byte) error
|
||||
remove(path string) error
|
||||
mkdir(path string) error
|
||||
close() error
|
||||
}
|
||||
|
||||
// dial builds an authenticated connection for the configured protocol.
|
||||
func (p *Plugin) dial(ctx context.Context) (conn, error) {
|
||||
p.mu.Lock()
|
||||
proto := p.protocol
|
||||
p.mu.Unlock()
|
||||
|
||||
switch proto {
|
||||
case protoSFTP:
|
||||
return p.dialSFTP(ctx)
|
||||
case protoFTP, protoFTPS:
|
||||
return p.dialFTP(ctx)
|
||||
default:
|
||||
return nil, errors.New("unsupported protocol: " + proto)
|
||||
}
|
||||
}
|
||||
|
||||
// classifyDialErr maps a dial/auth failure to a health status: an auth rejection
|
||||
// is degraded (server reachable, credentials wrong); anything else is down.
|
||||
func classifyDialErr(err error) string {
|
||||
msg := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(msg, "unable to authenticate"),
|
||||
strings.Contains(msg, "auth"),
|
||||
strings.Contains(msg, "password"),
|
||||
strings.Contains(msg, "login"),
|
||||
strings.Contains(msg, "530"), // FTP: not logged in
|
||||
strings.Contains(msg, "permission denied"):
|
||||
return plugins.StatusDegraded
|
||||
default:
|
||||
return plugins.StatusDown
|
||||
}
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return "y"
|
||||
}
|
||||
return "ies"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SFTP implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type sftpConn struct {
|
||||
ssh *ssh.Client
|
||||
cli *sftp.Client
|
||||
}
|
||||
|
||||
func (p *Plugin) dialSFTP(ctx context.Context) (conn, error) {
|
||||
p.mu.Lock()
|
||||
host, user, pass := p.host, p.username, p.password
|
||||
key, keyPass, hostKeyFP := p.privateKey, p.keyPass, p.hostKeyFP
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(p.effectivePort()))
|
||||
p.mu.Unlock()
|
||||
|
||||
var auth []ssh.AuthMethod
|
||||
if strings.TrimSpace(key) != "" {
|
||||
signer, err := parseSigner(key, keyPass)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("private key: %w", err)
|
||||
}
|
||||
auth = append(auth, ssh.PublicKeys(signer))
|
||||
}
|
||||
if pass != "" {
|
||||
auth = append(auth, ssh.Password(pass))
|
||||
}
|
||||
if len(auth) == 0 {
|
||||
return nil, errors.New("SFTP requires a password or a private key")
|
||||
}
|
||||
|
||||
hostKeyCallback, err := hostKeyChecker(hostKeyFP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: auth,
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: dialTimeout,
|
||||
}
|
||||
|
||||
// ssh.Dial has no context form; dial the TCP conn with the context, then
|
||||
// run the SSH handshake over it.
|
||||
d := net.Dialer{Timeout: dialTimeout}
|
||||
tcp, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sshConn, chans, reqs, err := ssh.NewClientConn(tcp, addr, cfg)
|
||||
if err != nil {
|
||||
_ = tcp.Close()
|
||||
return nil, err
|
||||
}
|
||||
client := ssh.NewClient(sshConn, chans, reqs)
|
||||
sc, err := sftp.NewClient(client)
|
||||
if err != nil {
|
||||
_ = client.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &sftpConn{ssh: client, cli: sc}, nil
|
||||
}
|
||||
|
||||
// parseSigner parses a PEM private key, with or without a passphrase.
|
||||
func parseSigner(pem, passphrase string) (ssh.Signer, error) {
|
||||
if strings.TrimSpace(passphrase) != "" {
|
||||
return ssh.ParsePrivateKeyWithPassphrase([]byte(pem), []byte(passphrase))
|
||||
}
|
||||
return ssh.ParsePrivateKey([]byte(pem))
|
||||
}
|
||||
|
||||
// hostKeyChecker returns a HostKeyCallback that pins the given SHA256:…
|
||||
// fingerprint, or accepts any key when the fingerprint is empty.
|
||||
func hostKeyChecker(fingerprint string) (ssh.HostKeyCallback, error) {
|
||||
if fingerprint == "" {
|
||||
return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec // opt-in: no fingerprint pinned
|
||||
}
|
||||
want := strings.TrimSpace(fingerprint)
|
||||
return func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
||||
got := ssh.FingerprintSHA256(key)
|
||||
if got != want {
|
||||
return fmt.Errorf("host key mismatch: server presented %s, expected %s", got, want)
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *sftpConn) list(p string) ([]fileInfo, error) {
|
||||
infos, err := c.cli.ReadDir(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]fileInfo, 0, len(infos))
|
||||
for _, fi := range infos {
|
||||
out = append(out, fileInfo{
|
||||
Name: fi.Name(),
|
||||
Size: fi.Size(),
|
||||
IsDir: fi.IsDir(),
|
||||
ModTime: fi.ModTime().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *sftpConn) stat(p string) (fileInfo, error) {
|
||||
fi, err := c.cli.Stat(p)
|
||||
if err != nil {
|
||||
return fileInfo{}, err
|
||||
}
|
||||
return fileInfo{
|
||||
Name: fi.Name(),
|
||||
Size: fi.Size(),
|
||||
IsDir: fi.IsDir(),
|
||||
ModTime: fi.ModTime().UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *sftpConn) read(p string) ([]byte, error) {
|
||||
f, err := c.cli.Open(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, maxReadBytes))
|
||||
}
|
||||
|
||||
func (c *sftpConn) write(p string, data []byte) error {
|
||||
f, err := c.cli.Create(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *sftpConn) remove(p string) error { return c.cli.Remove(p) }
|
||||
func (c *sftpConn) mkdir(p string) error { return c.cli.MkdirAll(p) }
|
||||
|
||||
func (c *sftpConn) close() error {
|
||||
err := c.cli.Close()
|
||||
if c.ssh != nil {
|
||||
_ = c.ssh.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FTP / FTPS implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ftpConn struct {
|
||||
c *ftp.ServerConn
|
||||
}
|
||||
|
||||
func (p *Plugin) dialFTP(ctx context.Context) (conn, error) {
|
||||
p.mu.Lock()
|
||||
host, user, pass, proto := p.host, p.username, p.password, p.protocol
|
||||
insecure := p.insecureTLS
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(p.effectivePort()))
|
||||
p.mu.Unlock()
|
||||
|
||||
opts := []ftp.DialOption{ftp.DialWithContext(ctx), ftp.DialWithTimeout(dialTimeout)}
|
||||
if proto == protoFTPS {
|
||||
opts = append(opts, ftp.DialWithExplicitTLS(&tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: insecure, //nolint:gosec // opt-in for self-signed test servers
|
||||
}))
|
||||
}
|
||||
|
||||
sc, err := ftp.Dial(addr, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := sc.Login(user, pass); err != nil {
|
||||
_ = sc.Quit()
|
||||
return nil, err
|
||||
}
|
||||
return &ftpConn{c: sc}, nil
|
||||
}
|
||||
|
||||
func (c *ftpConn) list(p string) ([]fileInfo, error) {
|
||||
entries, err := c.c.List(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]fileInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.Name == "." || e.Name == ".." {
|
||||
continue
|
||||
}
|
||||
out = append(out, entryToInfo(e))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *ftpConn) stat(p string) (fileInfo, error) {
|
||||
// FTP has no portable stat; MLST via GetEntry works on servers that support
|
||||
// it, otherwise fall back to listing the parent and matching the name.
|
||||
if e, err := c.c.GetEntry(p); err == nil && e != nil {
|
||||
return entryToInfo(e), nil
|
||||
}
|
||||
dir, base := path.Split(strings.TrimRight(p, "/"))
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
entries, err := c.c.List(dir)
|
||||
if err != nil {
|
||||
return fileInfo{}, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name == base {
|
||||
return entryToInfo(e), nil
|
||||
}
|
||||
}
|
||||
return fileInfo{}, fmt.Errorf("not found: %s", p)
|
||||
}
|
||||
|
||||
func (c *ftpConn) read(p string) ([]byte, error) {
|
||||
resp, err := c.c.Retr(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Close()
|
||||
return io.ReadAll(io.LimitReader(resp, maxReadBytes))
|
||||
}
|
||||
|
||||
func (c *ftpConn) write(p string, data []byte) error {
|
||||
return c.c.Stor(p, strings.NewReader(string(data)))
|
||||
}
|
||||
|
||||
func (c *ftpConn) remove(p string) error { return c.c.Delete(p) }
|
||||
func (c *ftpConn) mkdir(p string) error { return c.c.MakeDir(p) }
|
||||
|
||||
func (c *ftpConn) close() error { return c.c.Quit() }
|
||||
|
||||
// entryToInfo normalizes a jlaffaye/ftp entry.
|
||||
func entryToInfo(e *ftp.Entry) fileInfo {
|
||||
fi := fileInfo{
|
||||
Name: e.Name,
|
||||
Size: int64(e.Size),
|
||||
IsDir: e.Type == ftp.EntryTypeFolder,
|
||||
}
|
||||
if !e.Time.IsZero() {
|
||||
fi.ModTime = e.Time.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return fi
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package filetransfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
func TestDescriptor(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
d := p.Descriptor()
|
||||
if d.Name != "filetransfer" {
|
||||
t.Fatalf("name = %q, want filetransfer", d.Name)
|
||||
}
|
||||
if d.Kind != plugins.KindBuiltin {
|
||||
t.Fatalf("kind = %q, want builtin", d.Kind)
|
||||
}
|
||||
if len(d.Capabilities) == 0 {
|
||||
t.Fatal("expected capabilities")
|
||||
}
|
||||
// Every secret field must be flagged so the manager masks it.
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Key == "password" || f.Key == "privateKey" || f.Key == "keyPassphrase" {
|
||||
if !f.Secret {
|
||||
t.Errorf("config field %q must be Secret", f.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDefaults(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
if err := p.Init(context.Background(), map[string]string{"host": "h", "username": "u"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.protocol != protoSFTP {
|
||||
t.Errorf("default protocol = %q, want sftp", p.protocol)
|
||||
}
|
||||
if p.effectivePort() != 22 {
|
||||
t.Errorf("default sftp port = %d, want 22", p.effectivePort())
|
||||
}
|
||||
p.protocol = protoFTP
|
||||
if p.effectivePort() != 21 {
|
||||
t.Errorf("default ftp port = %d, want 21", p.effectivePort())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
p := &Plugin{basePath: "/uploads"}
|
||||
cases := map[string]string{
|
||||
"": "/uploads",
|
||||
"a/b.txt": "/uploads/a/b.txt",
|
||||
"/etc/abs": "/etc/abs",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := p.resolve(in); got != want {
|
||||
t.Errorf("resolve(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthCheckUnreachable confirms an unreachable host is classified as down
|
||||
// (not a panic) — the graceful-failure path Init/HealthCheck must guarantee.
|
||||
func TestHealthCheckUnreachable(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
// Port 1 is reserved and refuses connections quickly.
|
||||
if err := p.Init(context.Background(), map[string]string{
|
||||
"protocol": protoSFTP, "host": "127.0.0.1", "port": "1",
|
||||
"username": "u", "password": "pw",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := p.HealthCheck(context.Background())
|
||||
if h.Status != plugins.StatusDown {
|
||||
t.Errorf("status = %q, want down (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostKeyFingerprintMismatch(t *testing.T) {
|
||||
cb, err := hostKeyChecker("SHA256:doesnotmatch")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cb == nil {
|
||||
t.Fatal("expected a callback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistered confirms the plugin registered itself with the shared registry
|
||||
// via init(), so the manager will surface it.
|
||||
func TestRegistered(t *testing.T) {
|
||||
m := plugins.NewManager(t.TempDir() + "/plugins.json")
|
||||
if _, ok := m.Get("filetransfer"); !ok {
|
||||
t.Fatal("filetransfer not registered in the plugin manager")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// Package localstorage is a built-in plugin that exposes a directory on the host
|
||||
// machine's own filesystem as a storage "drive", behind the same capability
|
||||
// surface (list/stat/download/upload/delete/mkdir) as the remote filetransfer
|
||||
// connector. Where filetransfer dials FTP/SFTP, this one just calls the os
|
||||
// package — there is no network, no auth, and nothing to dial.
|
||||
//
|
||||
// Every caller-supplied path is confined under the configured base path: paths
|
||||
// are treated as relative to the base and cleaned so that ".." or a leading
|
||||
// separator can never escape the storage root. This is the one piece of extra
|
||||
// care a local-filesystem connector needs that a remote one gets from the remote
|
||||
// server's own chroot/permissions.
|
||||
package localstorage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
// maxReadBytes caps a download so a huge file can't exhaust memory; the health
|
||||
// probe and Invoke both honour it (mirrors filetransfer).
|
||||
const maxReadBytes = 32 << 20 // 32 MiB
|
||||
|
||||
func init() {
|
||||
plugins.Register("localstorage", func() plugins.Plugin { return &Plugin{} })
|
||||
}
|
||||
|
||||
// Plugin is the local-filesystem connector. Fields are guarded by mu because
|
||||
// Init may run concurrently with a HealthCheck/Invoke from another request.
|
||||
type Plugin struct {
|
||||
mu sync.Mutex
|
||||
basePath string
|
||||
createMissing bool // create the base path (and upload/mkdir parents) if absent
|
||||
readOnly bool // reject upload/delete/mkdir when true
|
||||
}
|
||||
|
||||
func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "localstorage",
|
||||
Provider: "Local Filesystem",
|
||||
Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin,
|
||||
Category: plugins.CategoryDrivesLocal,
|
||||
AuthType: plugins.AuthNone,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "list", Method: "GET", Endpoint: "/", Description: "List a directory under the base path. params: {path}"},
|
||||
{ID: "stat", Method: "GET", Endpoint: "/", Description: "Stat one path under the base path. params: {path}"},
|
||||
{ID: "download", Method: "GET", Endpoint: "/", Description: "Read a file (base64, ≤32 MiB). params: {path}"},
|
||||
{ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a file (creates parent dirs). params: {path, contentBase64}"},
|
||||
{ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a file or empty directory. params: {path}"},
|
||||
{ID: "mkdir", Method: "PUT", Endpoint: "/", Description: "Create a directory. params: {path}"},
|
||||
},
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
// No field is Required: like the other drive plugins, this can be enabled
|
||||
// as a master switch with an empty config; a missing base path is reported
|
||||
// gracefully by the health probe rather than blocking the switch.
|
||||
{Key: "basePath", Label: "Base path", Type: "text",
|
||||
Help: `Absolute directory used as the storage root, e.g. /data or /var/lib/pilotvault. In Docker this should be a mounted volume so data survives redeploys, and the container user must own it. Every operation is confined within it — ".." and absolute paths cannot escape.`},
|
||||
{Key: "createMissing", Label: "Create base path", Type: "select", Default: "false",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "false", Label: "Require the directory to already exist"},
|
||||
{Value: "true", Label: "Create it if missing (also creates upload/mkdir parents)"},
|
||||
},
|
||||
Help: "When on, the base path is created by the health check and parent directories are created on upload/mkdir."},
|
||||
{Key: "readOnly", Label: "Access mode", Type: "select", Default: "false",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "false", Label: "Read-write"},
|
||||
{Value: "true", Label: "Read-only — reject upload, delete and mkdir"},
|
||||
},
|
||||
Help: "Read-only is a safety guard for pointing at a directory you only want to serve from."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.basePath = strings.TrimSpace(config["basePath"])
|
||||
p.createMissing = strings.EqualFold(strings.TrimSpace(config["createMissing"]), "true")
|
||||
p.readOnly = strings.EqualFold(strings.TrimSpace(config["readOnly"]), "true")
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve confines a caller-supplied path under the base path. The path is always
|
||||
// treated as relative to the base; a leading separator or ".." segments are
|
||||
// neutralized by cleaning against a virtual root, so the result can never escape.
|
||||
func (p *Plugin) resolve(rel string) (string, error) {
|
||||
base := strings.TrimSpace(p.basePath)
|
||||
if base == "" {
|
||||
return "", errors.New("no base path configured")
|
||||
}
|
||||
absBase, err := filepath.Abs(base)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Clean against a virtual root so "..", ".", and leading separators collapse to
|
||||
// a path that stays at or below "/", then strip the root and join under base.
|
||||
virtual := filepath.ToSlash(strings.TrimSpace(rel))
|
||||
cleaned := filepath.Clean("/" + strings.TrimLeft(virtual, "/"))
|
||||
sub := filepath.FromSlash(strings.TrimPrefix(cleaned, "/"))
|
||||
joined := filepath.Join(absBase, sub)
|
||||
|
||||
// Belt-and-braces containment check after joining.
|
||||
if joined != absBase && !strings.HasPrefix(joined, absBase+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("path %q escapes the base directory", rel)
|
||||
}
|
||||
return joined, nil
|
||||
}
|
||||
|
||||
// HealthCheck verifies the base path exists, is a directory, is readable, and
|
||||
// (unless read-only) is writable. Missing-but-creatable resolves to OK.
|
||||
func (p *Plugin) HealthCheck(_ context.Context) plugins.Health {
|
||||
start := time.Now()
|
||||
|
||||
p.mu.Lock()
|
||||
base, create, readOnly := p.basePath, p.createMissing, p.readOnly
|
||||
p.mu.Unlock()
|
||||
|
||||
if strings.TrimSpace(base) == "" {
|
||||
return plugins.Health{Status: plugins.StatusDown, Detail: "no base path configured"}
|
||||
}
|
||||
absBase, err := filepath.Abs(base)
|
||||
if err != nil {
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), Detail: err.Error()}
|
||||
}
|
||||
|
||||
info, err := os.Stat(absBase)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) && create {
|
||||
if mkErr := os.MkdirAll(absBase, 0o755); mkErr != nil {
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start),
|
||||
Detail: fmt.Sprintf("base path %q does not exist and could not be created: %v", absBase, mkErr)}
|
||||
}
|
||||
info, err = os.Stat(absBase)
|
||||
}
|
||||
if err != nil {
|
||||
detail := fmt.Sprintf("base path %q not accessible: %v", absBase, err)
|
||||
if os.IsNotExist(err) {
|
||||
detail = fmt.Sprintf("base path %q does not exist (enable \"Create base path\" to create it)", absBase)
|
||||
}
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start), Detail: detail}
|
||||
}
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: ms(start),
|
||||
Detail: fmt.Sprintf("base path %q is not a directory", absBase)}
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(absBase)
|
||||
if err != nil {
|
||||
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: ms(start),
|
||||
Detail: fmt.Sprintf("base path %q is not readable: %v", absBase, err)}
|
||||
}
|
||||
|
||||
detail := fmt.Sprintf("%q reachable — %d entr%s", absBase, len(entries), plural(len(entries)))
|
||||
status := plugins.StatusOK
|
||||
if readOnly {
|
||||
detail += " · read-only"
|
||||
} else if werr := probeWritable(absBase); werr != nil {
|
||||
status = plugins.StatusDegraded
|
||||
detail += fmt.Sprintf(" · not writable: %v", werr)
|
||||
} else {
|
||||
detail += " · read-write"
|
||||
}
|
||||
return plugins.Health{Status: status, LatencyMs: ms(start), Detail: detail}
|
||||
}
|
||||
|
||||
// Invoke runs one capability against the local filesystem.
|
||||
func (p *Plugin) Invoke(_ context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
p.mu.Lock()
|
||||
create, readOnly := p.createMissing, p.readOnly
|
||||
p.mu.Unlock()
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
target, err := p.resolve(in.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]fileInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, dirEntryToInfo(e))
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": target, "entries": out})
|
||||
|
||||
case "stat":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
target, err := p.resolve(in.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fi, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(statToInfo(fi))
|
||||
|
||||
case "download":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
target, err := p.resolve(in.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := readCapped(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"path": target,
|
||||
"size": len(data),
|
||||
"contentBase64": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
|
||||
case "upload":
|
||||
if readOnly {
|
||||
return nil, errReadOnly
|
||||
}
|
||||
var in writeParams
|
||||
if err := json.Unmarshal(params, &in); err != nil {
|
||||
return nil, fmt.Errorf("invalid params: %w", err)
|
||||
}
|
||||
target, err := p.resolve(in.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(in.ContentBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err)
|
||||
}
|
||||
if create {
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": target, "size": len(data), "ok": true})
|
||||
|
||||
case "delete":
|
||||
if readOnly {
|
||||
return nil, errReadOnly
|
||||
}
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
target, err := p.resolve(in.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Refuse to delete the base path itself.
|
||||
absBase, _ := filepath.Abs(strings.TrimSpace(p.basePath))
|
||||
if target == absBase {
|
||||
return nil, errors.New("refusing to delete the base directory")
|
||||
}
|
||||
if err := os.Remove(target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": target, "ok": true})
|
||||
|
||||
case "mkdir":
|
||||
if readOnly {
|
||||
return nil, errReadOnly
|
||||
}
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
target, err := p.resolve(in.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": target, "ok": true})
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
||||
|
||||
var errReadOnly = errors.New("plugin is configured read-only")
|
||||
|
||||
// pathParams / writeParams are the Invoke request shapes (mirrors filetransfer).
|
||||
type pathParams struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
type writeParams struct {
|
||||
Path string `json:"path"`
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
|
||||
// fileInfo is the normalized directory-entry shape returned by list/stat.
|
||||
type fileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
ModTime string `json:"modTime,omitempty"`
|
||||
}
|
||||
|
||||
func statToInfo(fi os.FileInfo) fileInfo {
|
||||
return fileInfo{
|
||||
Name: fi.Name(),
|
||||
Size: fi.Size(),
|
||||
IsDir: fi.IsDir(),
|
||||
ModTime: fi.ModTime().UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// dirEntryToInfo normalizes an os.DirEntry, tolerating a stat failure on a single
|
||||
// entry (e.g. a broken symlink) by reporting name/isDir without size/modtime.
|
||||
func dirEntryToInfo(e os.DirEntry) fileInfo {
|
||||
fi, err := e.Info()
|
||||
if err != nil {
|
||||
return fileInfo{Name: e.Name(), IsDir: e.IsDir()}
|
||||
}
|
||||
return statToInfo(fi)
|
||||
}
|
||||
|
||||
// readCapped reads a file up to maxReadBytes.
|
||||
func readCapped(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, maxReadBytes))
|
||||
}
|
||||
|
||||
// probeWritable confirms the directory accepts a write by creating and removing a
|
||||
// short-lived temp file.
|
||||
func probeWritable(dir string) error {
|
||||
f, err := os.CreateTemp(dir, ".pilotvault-health-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := f.Name()
|
||||
_ = f.Close()
|
||||
return os.Remove(name)
|
||||
}
|
||||
|
||||
func ms(start time.Time) int64 { return time.Since(start).Milliseconds() }
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return "y"
|
||||
}
|
||||
return "ies"
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package localstorage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
func TestDescriptor(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
d := p.Descriptor()
|
||||
if d.Name != "localstorage" {
|
||||
t.Fatalf("name = %q, want localstorage", d.Name)
|
||||
}
|
||||
if d.Kind != plugins.KindBuiltin {
|
||||
t.Fatalf("kind = %q, want builtin", d.Kind)
|
||||
}
|
||||
if d.Category != plugins.CategoryDrivesLocal {
|
||||
t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryDrivesLocal)
|
||||
}
|
||||
if len(d.Capabilities) == 0 {
|
||||
t.Fatal("expected capabilities")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistered confirms the plugin registered itself with the shared registry
|
||||
// via init(), so the manager will surface it.
|
||||
func TestRegistered(t *testing.T) {
|
||||
m := plugins.NewManager(t.TempDir() + "/plugins.json")
|
||||
if _, ok := m.Get("localstorage"); !ok {
|
||||
t.Fatal("localstorage not registered in the plugin manager")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveConfinement verifies that traversal, absolute-looking, and
|
||||
// backslash paths all stay under the base directory.
|
||||
func TestResolveConfinement(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
p := &Plugin{basePath: base}
|
||||
absBase, _ := filepath.Abs(base)
|
||||
|
||||
contained := []string{"a/b.txt", "/etc/passwd", "../../../etc/passwd", "a\\b", "./x", ""}
|
||||
for _, in := range contained {
|
||||
got, err := p.resolve(in)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve(%q) errored: %v", in, err)
|
||||
}
|
||||
if got != absBase && !strings.HasPrefix(got, absBase+string(os.PathSeparator)) {
|
||||
t.Errorf("resolve(%q) = %q escaped base %q", in, got, absBase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNoBase(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
if _, err := p.resolve("x"); err == nil {
|
||||
t.Fatal("expected error when base path unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckMissing(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
// Base path unset -> down.
|
||||
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
|
||||
t.Errorf("unset base: status = %q, want down", h.Status)
|
||||
}
|
||||
// Nonexistent path without createMissing -> down.
|
||||
_ = p.Init(context.Background(), map[string]string{"basePath": filepath.Join(t.TempDir(), "nope")})
|
||||
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
|
||||
t.Errorf("missing base: status = %q, want down (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckCreateMissing(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "created")
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"basePath": dir, "createMissing": "true"})
|
||||
h := p.HealthCheck(context.Background())
|
||||
if h.Status != plugins.StatusOK {
|
||||
t.Fatalf("status = %q, want ok (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
|
||||
t.Fatalf("base path was not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoundTrip exercises upload -> list -> download -> delete end to end.
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"basePath": base, "createMissing": "true"})
|
||||
|
||||
payload := []byte("hello pilotvault")
|
||||
up, _ := json.Marshal(writeParams{Path: "sub/dir/file.txt", ContentBase64: base64.StdEncoding.EncodeToString(payload)})
|
||||
if _, err := p.Invoke(context.Background(), "upload", up); err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
|
||||
dl, _ := json.Marshal(pathParams{Path: "sub/dir/file.txt"})
|
||||
raw, err := p.Invoke(context.Background(), "download", dl)
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &got)
|
||||
if decoded, _ := base64.StdEncoding.DecodeString(got.ContentBase64); string(decoded) != string(payload) {
|
||||
t.Fatalf("download content = %q, want %q", decoded, payload)
|
||||
}
|
||||
|
||||
if _, err := p.Invoke(context.Background(), "delete", dl); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, "sub", "dir", "file.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("file still present after delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyRejectsWrites(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
p := &Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"basePath": base, "readOnly": "true"})
|
||||
|
||||
up, _ := json.Marshal(writeParams{Path: "x.txt", ContentBase64: ""})
|
||||
if _, err := p.Invoke(context.Background(), "upload", up); err == nil {
|
||||
t.Error("upload should be rejected in read-only mode")
|
||||
}
|
||||
del, _ := json.Marshal(pathParams{Path: "x.txt"})
|
||||
if _, err := p.Invoke(context.Background(), "delete", del); err == nil {
|
||||
t.Error("delete should be rejected in read-only mode")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// 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"
|
||||
// Small default bounding box (Netherlands) keeps the health probe cheap.
|
||||
defaultBBox = "50.5,3.2,53.7,7.3" // lamin,lomin,lamax,lomax
|
||||
)
|
||||
|
||||
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: "text", Default: defaultBBox, Help: "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 }
|
||||
@@ -0,0 +1,551 @@
|
||||
// Package webdav is a built-in plugin that connects to a WebDAV server over
|
||||
// HTTP(S). It offers the same capability surface as the filetransfer plugin
|
||||
// (list/stat/download/upload/delete/mkdir) but speaks WebDAV verbs — PROPFIND,
|
||||
// GET, PUT, DELETE, MKCOL — directly over net/http, so it needs no third-party
|
||||
// client library and cross-compiles cleanly for the Linux container.
|
||||
//
|
||||
// Like filetransfer, nothing connects during Init; each capability (and the
|
||||
// health probe) issues its own HTTP request against the configured base URL,
|
||||
// authenticating with HTTP Basic auth. This suits WebDAV, which is stateless
|
||||
// per request, and keeps the plugin free of long-lived connection state.
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
const (
|
||||
dialTimeout = 12 * time.Second
|
||||
// maxReadBytes caps a download so a huge remote file can't exhaust memory;
|
||||
// the health probe and Invoke both honour it.
|
||||
maxReadBytes = 32 << 20 // 32 MiB
|
||||
|
||||
// propfindBody requests just the properties we normalize into fileInfo.
|
||||
propfindBody = `<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<d:propfind xmlns:d="DAV:"><d:prop>` +
|
||||
`<d:displayname/><d:getcontentlength/><d:getlastmodified/><d:resourcetype/>` +
|
||||
`</d:prop></d:propfind>`
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugins.Register("webdav", func() plugins.Plugin { return &Plugin{} })
|
||||
}
|
||||
|
||||
// Plugin is the WebDAV connector. All fields are guarded by mu because Init may
|
||||
// run concurrently with a HealthCheck/Invoke from another request.
|
||||
type Plugin struct {
|
||||
mu sync.Mutex
|
||||
baseURL string // e.g. https://cloud.example.com/remote.php/dav/files/alice/
|
||||
username string
|
||||
password string
|
||||
basePath string // working root, resolved under the base URL's path
|
||||
// insecureTLS skips HTTPS certificate verification when true.
|
||||
insecureTLS bool
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "webdav",
|
||||
Provider: "WebDAV",
|
||||
Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin,
|
||||
Category: plugins.CategoryDrivesExternal,
|
||||
AuthType: plugins.AuthBasic,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "list", Method: "PROPFIND", Endpoint: "/", Description: "List a remote directory. params: {path}"},
|
||||
{ID: "stat", Method: "PROPFIND", Endpoint: "/", Description: "Stat one remote path. params: {path}"},
|
||||
{ID: "download", Method: "GET", Endpoint: "/", Description: "Read a remote file (base64, ≤32 MiB). params: {path}"},
|
||||
{ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a remote file. params: {path, contentBase64}"},
|
||||
{ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a remote file or directory. params: {path}"},
|
||||
{ID: "mkdir", Method: "MKCOL", Endpoint: "/", Description: "Create a remote directory. params: {path}"},
|
||||
},
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
// No field is Required: the plugin can be enabled as a master switch with
|
||||
// an empty global config, leaving each organization or user to supply
|
||||
// their own connection through the cascade (mirrors filetransfer). A
|
||||
// missing base URL is reported gracefully by the health probe.
|
||||
{Key: "baseURL", Label: "Server URL", Type: "text",
|
||||
Help: "WebDAV endpoint, e.g. https://cloud.example.com/remote.php/dav/files/alice/ — must include the scheme."},
|
||||
{Key: "username", Label: "Username", Type: "text"},
|
||||
{Key: "password", Label: "Password", Type: "password", Secret: true,
|
||||
Help: "Password or app-specific token for HTTP Basic auth. Leave blank for an anonymous/public share."},
|
||||
{Key: "basePath", Label: "Base path", Type: "text", Default: ".",
|
||||
Help: "Directory under the server URL used as the working root and probed by the health check, e.g. /Documents. Relative capability paths resolve under it."},
|
||||
{Key: "insecureSkipVerify", Label: "TLS verification", Type: "select", Default: "false",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "false", Label: "Verify certificate (recommended)"},
|
||||
{Value: "true", Label: "Skip verification — accept any certificate"},
|
||||
},
|
||||
Help: "Only affects HTTPS. Skip verification only for self-signed test servers."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.baseURL = strings.TrimSpace(config["baseURL"])
|
||||
p.username = strings.TrimSpace(config["username"])
|
||||
p.password = config["password"]
|
||||
p.basePath = strings.TrimSpace(config["basePath"])
|
||||
if p.basePath == "" {
|
||||
p.basePath = "."
|
||||
}
|
||||
p.insecureTLS = strings.EqualFold(strings.TrimSpace(config["insecureSkipVerify"]), "true")
|
||||
|
||||
p.client = &http.Client{
|
||||
// No client-level timeout: request lifetime is bounded by the caller's
|
||||
// context so large downloads aren't cut off mid-stream.
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: dialTimeout}).DialContext,
|
||||
TLSHandshakeTimeout: dialTimeout,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: p.insecureTLS, //nolint:gosec // opt-in for self-signed test servers
|
||||
},
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve joins a caller-supplied path against the base path. A leading "/" is
|
||||
// treated as relative to the server URL's own path root; an empty path becomes
|
||||
// the base path itself. It never allows escaping above that root: the joined
|
||||
// path is cleaned against a virtual "/" so "..", stray separators, and
|
||||
// backslashes can't climb out.
|
||||
func (p *Plugin) resolve(rel string) string {
|
||||
rel = strings.TrimSpace(rel)
|
||||
rel = strings.ReplaceAll(rel, "\\", "/")
|
||||
base := p.basePath
|
||||
if base == "." {
|
||||
base = ""
|
||||
}
|
||||
var joined string
|
||||
switch {
|
||||
case rel == "":
|
||||
joined = base
|
||||
case strings.HasPrefix(rel, "/"):
|
||||
joined = rel // relative to the server URL root, not the base path
|
||||
case base == "":
|
||||
joined = rel
|
||||
default:
|
||||
joined = base + "/" + rel
|
||||
}
|
||||
// Clean against a virtual root so nothing escapes above it.
|
||||
return strings.TrimPrefix(path.Clean("/"+joined), "/")
|
||||
}
|
||||
|
||||
// requestURL builds the absolute request URL for a resolved path. When dir is
|
||||
// true a trailing slash is kept, which WebDAV servers expect for collection
|
||||
// operations (PROPFIND/MKCOL). url.URL.String() percent-escapes the path, so
|
||||
// callers pass unescaped segments.
|
||||
func (p *Plugin) requestURL(resolved string, dir bool) (string, error) {
|
||||
base, err := url.Parse(p.baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid server URL: %w", err)
|
||||
}
|
||||
if base.Scheme == "" || base.Host == "" {
|
||||
return "", errors.New("server URL must include scheme and host")
|
||||
}
|
||||
full := *base
|
||||
full.Path = path.Join("/"+strings.Trim(base.Path, "/"), resolved)
|
||||
full.RawPath = "" // force re-escaping from Path
|
||||
if dir && !strings.HasSuffix(full.Path, "/") {
|
||||
full.Path += "/"
|
||||
}
|
||||
return full.String(), nil
|
||||
}
|
||||
|
||||
// do issues one authenticated WebDAV request and returns the response. The
|
||||
// caller is responsible for closing the body.
|
||||
func (p *Plugin) do(ctx context.Context, method, rawURL string, body io.Reader, headers map[string]string) (*http.Response, error) {
|
||||
p.mu.Lock()
|
||||
client, user, pass := p.client, p.username, p.password
|
||||
p.mu.Unlock()
|
||||
if client == nil {
|
||||
return nil, errors.New("plugin not initialized")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user != "" || pass != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
// HealthCheck issues a PROPFIND against the base path and classifies the
|
||||
// outcome. A 401/403 means the server is reachable but auth failed (degraded);
|
||||
// a transport error is down.
|
||||
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
||||
start := time.Now()
|
||||
|
||||
p.mu.Lock()
|
||||
base, baseURL := p.basePath, p.baseURL
|
||||
p.mu.Unlock()
|
||||
|
||||
if baseURL == "" {
|
||||
return plugins.Health{Status: plugins.StatusDown, Detail: "no server URL configured"}
|
||||
}
|
||||
|
||||
entries, err := p.propfind(ctx, p.resolve(""), 1)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
var he *httpError
|
||||
if errors.As(err, &he) {
|
||||
return plugins.Health{Status: classifyStatus(he.code), LatencyMs: lat,
|
||||
Detail: fmt.Sprintf("connected but PROPFIND %q returned %d %s", base, he.code, http.StatusText(he.code))}
|
||||
}
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
|
||||
}
|
||||
|
||||
detail := fmt.Sprintf("WebDAV reachable — %d entr%s under %q", len(entries), plural(len(entries)), base)
|
||||
status := plugins.StatusOK
|
||||
if strings.HasPrefix(strings.ToLower(baseURL), "http://") {
|
||||
status = plugins.StatusDegraded
|
||||
detail += " · plaintext HTTP (no encryption)"
|
||||
}
|
||||
return plugins.Health{Status: status, LatencyMs: lat, Detail: detail}
|
||||
}
|
||||
|
||||
// Invoke runs one capability against the WebDAV server.
|
||||
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
switch action {
|
||||
case "list":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
rp := p.resolve(in.Path)
|
||||
entries, err := p.propfind(ctx, rp, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": rp, "entries": entries})
|
||||
|
||||
case "stat":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
rp := p.resolve(in.Path)
|
||||
entries, err := p.propfind(ctx, rp, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("not found: %s", rp)
|
||||
}
|
||||
return json.Marshal(entries[0])
|
||||
|
||||
case "download":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
rp := p.resolve(in.Path)
|
||||
data, err := p.read(ctx, rp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{
|
||||
"path": rp,
|
||||
"size": len(data),
|
||||
"contentBase64": base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
|
||||
case "upload":
|
||||
var in writeParams
|
||||
if err := json.Unmarshal(params, &in); err != nil {
|
||||
return nil, fmt.Errorf("invalid params: %w", err)
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(in.ContentBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err)
|
||||
}
|
||||
rp := p.resolve(in.Path)
|
||||
if err := p.write(ctx, rp, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": rp, "size": len(data), "ok": true})
|
||||
|
||||
case "delete":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
rp := p.resolve(in.Path)
|
||||
if err := p.remove(ctx, rp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": rp, "ok": true})
|
||||
|
||||
case "mkdir":
|
||||
var in pathParams
|
||||
_ = json.Unmarshal(params, &in)
|
||||
rp := p.resolve(in.Path)
|
||||
if err := p.mkdir(ctx, rp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"path": rp, "ok": true})
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
||||
|
||||
// pathParams / writeParams are the Invoke request shapes.
|
||||
type pathParams struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
type writeParams struct {
|
||||
Path string `json:"path"`
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
|
||||
// fileInfo is the normalized directory-entry shape returned by list/stat. It
|
||||
// matches filetransfer's shape so callers can treat the drives uniformly.
|
||||
type fileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
ModTime string `json:"modTime,omitempty"`
|
||||
}
|
||||
|
||||
// httpError carries a non-2xx status so HealthCheck can classify it.
|
||||
type httpError struct {
|
||||
code int
|
||||
method string
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string {
|
||||
return fmt.Sprintf("%s: %d %s", e.method, e.code, http.StatusText(e.code))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebDAV operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// propfind lists (depth 1) or stats (depth 0) a path. For depth 1 the entry
|
||||
// describing the collection itself is dropped so only children are returned.
|
||||
func (p *Plugin) propfind(ctx context.Context, resolved string, depth int) ([]fileInfo, error) {
|
||||
u, err := p.requestURL(resolved, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := p.do(ctx, "PROPFIND", u, strings.NewReader(propfindBody), map[string]string{
|
||||
"Depth": strconv.Itoa(depth),
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer drainClose(resp.Body)
|
||||
|
||||
// 207 Multi-Status is the success case; 200 is tolerated for lenient servers.
|
||||
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
|
||||
return nil, &httpError{code: resp.StatusCode, method: "PROPFIND"}
|
||||
}
|
||||
|
||||
var ms davMultistatus
|
||||
if err := xml.NewDecoder(io.LimitReader(resp.Body, maxReadBytes)).Decode(&ms); err != nil {
|
||||
return nil, fmt.Errorf("parse PROPFIND response: %w", err)
|
||||
}
|
||||
|
||||
// The request path, cleaned, is used to recognise and drop the self entry.
|
||||
self := strings.Trim(resolved, "/")
|
||||
out := make([]fileInfo, 0, len(ms.Responses))
|
||||
for _, r := range ms.Responses {
|
||||
hrefPath := hrefToPath(r.Href)
|
||||
if depth == 1 && strings.Trim(hrefPath, "/") == self {
|
||||
continue // the collection itself
|
||||
}
|
||||
out = append(out, r.toFileInfo())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) read(ctx context.Context, resolved string) ([]byte, error) {
|
||||
u, err := p.requestURL(resolved, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := p.do(ctx, http.MethodGet, u, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer drainClose(resp.Body)
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return nil, &httpError{code: resp.StatusCode, method: "GET"}
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, maxReadBytes))
|
||||
}
|
||||
|
||||
func (p *Plugin) write(ctx context.Context, resolved string, data []byte) error {
|
||||
u, err := p.requestURL(resolved, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := p.do(ctx, http.MethodPut, u, strings.NewReader(string(data)),
|
||||
map[string]string{"Content-Type": "application/octet-stream"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer drainClose(resp.Body)
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return &httpError{code: resp.StatusCode, method: "PUT"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) remove(ctx context.Context, resolved string) error {
|
||||
u, err := p.requestURL(resolved, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := p.do(ctx, http.MethodDelete, u, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer drainClose(resp.Body)
|
||||
// 404 is tolerated as already-gone.
|
||||
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound {
|
||||
return &httpError{code: resp.StatusCode, method: "DELETE"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) mkdir(ctx context.Context, resolved string) error {
|
||||
u, err := p.requestURL(resolved, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := p.do(ctx, "MKCOL", u, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer drainClose(resp.Body)
|
||||
// 405 Method Not Allowed is what most servers return when the collection
|
||||
// already exists — treat it as success (idempotent mkdir).
|
||||
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
return &httpError{code: resp.StatusCode, method: "MKCOL"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PROPFIND XML shapes and helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type davMultistatus struct {
|
||||
XMLName xml.Name `xml:"DAV: multistatus"`
|
||||
Responses []davResponse `xml:"DAV: response"`
|
||||
}
|
||||
|
||||
type davResponse struct {
|
||||
Href string `xml:"DAV: href"`
|
||||
Propstats []davPropstat `xml:"DAV: propstat"`
|
||||
}
|
||||
|
||||
type davPropstat struct {
|
||||
Status string `xml:"DAV: status"`
|
||||
Prop davProp `xml:"DAV: prop"`
|
||||
}
|
||||
|
||||
type davProp struct {
|
||||
DisplayName string `xml:"DAV: displayname"`
|
||||
ContentLen string `xml:"DAV: getcontentlength"`
|
||||
LastModified string `xml:"DAV: getlastmodified"`
|
||||
ResourceType davResourceType `xml:"DAV: resourcetype"`
|
||||
}
|
||||
|
||||
type davResourceType struct {
|
||||
Collection *xml.Name `xml:"DAV: collection"`
|
||||
}
|
||||
|
||||
// toFileInfo normalizes a PROPFIND <response>, preferring the 2xx propstat.
|
||||
func (r davResponse) toFileInfo() fileInfo {
|
||||
fi := fileInfo{Name: nameFromHref(r.Href)}
|
||||
for _, ps := range r.Propstats {
|
||||
if !strings.Contains(ps.Status, " 2") { // "HTTP/1.1 200 OK"
|
||||
continue
|
||||
}
|
||||
if ps.Prop.ResourceType.Collection != nil {
|
||||
fi.IsDir = true
|
||||
}
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(ps.Prop.ContentLen), 10, 64); err == nil {
|
||||
fi.Size = n
|
||||
}
|
||||
if lm := strings.TrimSpace(ps.Prop.LastModified); lm != "" {
|
||||
if t, err := http.ParseTime(lm); err == nil {
|
||||
fi.ModTime = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
if fi.Name == "" && strings.TrimSpace(ps.Prop.DisplayName) != "" {
|
||||
fi.Name = ps.Prop.DisplayName
|
||||
}
|
||||
}
|
||||
return fi
|
||||
}
|
||||
|
||||
// hrefToPath extracts the URL path from an href, which may be absolute
|
||||
// (http://host/a/b) or path-only (/a/b), and percent-decodes it.
|
||||
func hrefToPath(href string) string {
|
||||
if u, err := url.Parse(href); err == nil && u.Path != "" {
|
||||
return u.Path
|
||||
}
|
||||
if dec, err := url.PathUnescape(href); err == nil {
|
||||
return dec
|
||||
}
|
||||
return href
|
||||
}
|
||||
|
||||
// nameFromHref returns the last path segment of an href, percent-decoded.
|
||||
func nameFromHref(href string) string {
|
||||
p := strings.TrimRight(hrefToPath(href), "/")
|
||||
if i := strings.LastIndex(p, "/"); i >= 0 {
|
||||
p = p[i+1:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// classifyStatus maps an HTTP status to a health status: an auth rejection means
|
||||
// the server is reachable but credentials are wrong (degraded); anything else is
|
||||
// down.
|
||||
func classifyStatus(code int) string {
|
||||
switch code {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return plugins.StatusDegraded
|
||||
default:
|
||||
return plugins.StatusDown
|
||||
}
|
||||
}
|
||||
|
||||
// drainClose drains and closes a response body so the connection can be reused.
|
||||
func drainClose(body io.ReadCloser) {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(body, 4<<10))
|
||||
_ = body.Close()
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return "y"
|
||||
}
|
||||
return "ies"
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
func TestDescriptor(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
d := p.Descriptor()
|
||||
if d.Name != "webdav" {
|
||||
t.Fatalf("name = %q, want webdav", d.Name)
|
||||
}
|
||||
if d.Kind != plugins.KindBuiltin {
|
||||
t.Fatalf("kind = %q, want builtin", d.Kind)
|
||||
}
|
||||
if d.Category != plugins.CategoryDrivesExternal {
|
||||
t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryDrivesExternal)
|
||||
}
|
||||
if len(d.Capabilities) == 0 {
|
||||
t.Fatal("expected capabilities")
|
||||
}
|
||||
// The password field must be flagged so the manager masks it, and no field
|
||||
// may be Required (so the plugin can be enabled as an empty master switch).
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Key == "password" && !f.Secret {
|
||||
t.Errorf("config field %q must be Secret", f.Key)
|
||||
}
|
||||
if f.Required {
|
||||
t.Errorf("config field %q must not be Required", f.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDefaults(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
if err := p.Init(context.Background(), map[string]string{"baseURL": "https://h/dav"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.basePath != "." {
|
||||
t.Errorf("default basePath = %q, want .", p.basePath)
|
||||
}
|
||||
if p.client == nil {
|
||||
t.Error("Init must build an http client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfinement(t *testing.T) {
|
||||
p := &Plugin{basePath: "Documents"}
|
||||
cases := map[string]string{
|
||||
"": "Documents",
|
||||
"a/b.txt": "Documents/a/b.txt",
|
||||
"/etc/abs": "etc/abs", // leading slash → relative to dav root, not base
|
||||
"../../escape": "escape", // cannot climb above the root
|
||||
"a/../../escape": "escape", // nor via traversal
|
||||
"a\\b": "Documents/a/b", // backslashes normalized
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := p.resolve(in); got != want {
|
||||
t.Errorf("resolve(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestURL(t *testing.T) {
|
||||
p := &Plugin{baseURL: "https://cloud.example.com/remote.php/dav/files/alice/"}
|
||||
got, err := p.requestURL("Documents/report 1.txt", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "https://cloud.example.com/remote.php/dav/files/alice/Documents/report%201.txt"
|
||||
if got != want {
|
||||
t.Errorf("requestURL = %q, want %q", got, want)
|
||||
}
|
||||
// A directory op keeps the trailing slash servers expect for collections.
|
||||
dir, _ := p.requestURL("Documents", true)
|
||||
if !strings.HasSuffix(dir, "/") {
|
||||
t.Errorf("dir URL %q should end with /", dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestURLRejectsBadBase(t *testing.T) {
|
||||
p := &Plugin{baseURL: "not-a-url"}
|
||||
if _, err := p.requestURL("x", false); err == nil {
|
||||
t.Fatal("expected error for base URL without scheme/host")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthCheckNoURL confirms an empty base URL is reported as down, not a panic.
|
||||
func TestHealthCheckNoURL(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
if err := p.Init(context.Background(), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown {
|
||||
t.Errorf("status = %q, want down (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameFromHref(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/dav/files/alice/report%201.txt": "report 1.txt",
|
||||
"http://host/dav/Photos/": "Photos",
|
||||
"/dav/": "dav",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := nameFromHref(in); got != want {
|
||||
t.Errorf("nameFromHref(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistered(t *testing.T) {
|
||||
m := plugins.NewManager(t.TempDir() + "/plugins.json")
|
||||
if _, ok := m.Get("webdav"); !ok {
|
||||
t.Fatal("webdav not registered in the plugin manager")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeDAV is a minimal in-memory WebDAV server exercising the verbs the plugin
|
||||
// uses. It is not spec-complete — just enough to drive the round-trip test.
|
||||
type fakeDAV struct {
|
||||
files map[string][]byte // path (no leading slash) → contents; dirs end in "/"
|
||||
}
|
||||
|
||||
func newFakeDAV() *fakeDAV {
|
||||
return &fakeDAV{files: map[string][]byte{
|
||||
"": nil, // root collection
|
||||
"docs/": nil, // a subdirectory
|
||||
"hello.txt": []byte("hi"), // a file
|
||||
}}
|
||||
}
|
||||
|
||||
func (f *fakeDAV) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if u, _, ok := r.BasicAuth(); !ok || u != "alice" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
key := strings.Trim(r.URL.Path, "/")
|
||||
switch r.Method {
|
||||
case "PROPFIND":
|
||||
f.propfind(w, r, key)
|
||||
case http.MethodGet:
|
||||
if data, ok := f.files[key]; ok && data != nil {
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case http.MethodPut:
|
||||
body := make([]byte, r.ContentLength)
|
||||
_, _ = r.Body.Read(body)
|
||||
f.files[key] = body
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
case http.MethodDelete:
|
||||
delete(f.files, key)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case "MKCOL":
|
||||
f.files[key+"/"] = nil
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeDAV) propfind(w http.ResponseWriter, r *http.Request, key string) {
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">`)
|
||||
writeResp := func(href string, isDir bool, size int) {
|
||||
rt := ""
|
||||
if isDir {
|
||||
rt = "<d:collection/>"
|
||||
}
|
||||
fmt.Fprintf(&b, `<d:response><d:href>%s</d:href><d:propstat>`+
|
||||
`<d:prop><d:getcontentlength>%d</d:getcontentlength>`+
|
||||
`<d:getlastmodified>Wed, 08 Jul 2026 10:00:00 GMT</d:getlastmodified>`+
|
||||
`<d:resourcetype>%s</d:resourcetype></d:prop>`+
|
||||
`<d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`,
|
||||
href, size, rt)
|
||||
}
|
||||
// Self entry first.
|
||||
writeResp("/"+key, true, 0)
|
||||
if r.Header.Get("Depth") == "1" && key == "" {
|
||||
writeResp("/docs/", true, 0)
|
||||
writeResp("/hello.txt", false, 2)
|
||||
}
|
||||
b.WriteString(`</d:multistatus>`)
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
}
|
||||
|
||||
// TestRoundTrip drives list/stat/download/upload/delete/mkdir against the fake
|
||||
// server and checks the plugin's normalized responses.
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
srv := httptest.NewServer(newFakeDAV())
|
||||
defer srv.Close()
|
||||
|
||||
p := &Plugin{}
|
||||
if err := p.Init(context.Background(), map[string]string{
|
||||
"baseURL": srv.URL, "username": "alice", "password": "pw",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// list: the self entry is dropped, leaving docs/ and hello.txt.
|
||||
raw, err := p.Invoke(ctx, "list", json.RawMessage(`{"path":""}`))
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
var listed struct {
|
||||
Entries []fileInfo `json:"entries"`
|
||||
}
|
||||
mustJSON(t, raw, &listed)
|
||||
if len(listed.Entries) != 2 {
|
||||
t.Fatalf("list returned %d entries, want 2: %+v", len(listed.Entries), listed.Entries)
|
||||
}
|
||||
var sawDir, sawFile bool
|
||||
for _, e := range listed.Entries {
|
||||
if e.Name == "docs" && e.IsDir {
|
||||
sawDir = true
|
||||
}
|
||||
if e.Name == "hello.txt" && !e.IsDir && e.Size == 2 {
|
||||
sawFile = true
|
||||
}
|
||||
}
|
||||
if !sawDir || !sawFile {
|
||||
t.Errorf("unexpected entries: %+v", listed.Entries)
|
||||
}
|
||||
|
||||
// download
|
||||
raw, err = p.Invoke(ctx, "download", json.RawMessage(`{"path":"hello.txt"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
var dl struct {
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
mustJSON(t, raw, &dl)
|
||||
if got, _ := base64.StdEncoding.DecodeString(dl.ContentBase64); string(got) != "hi" {
|
||||
t.Errorf("download content = %q, want hi", got)
|
||||
}
|
||||
|
||||
// upload → then download it back
|
||||
body := base64.StdEncoding.EncodeToString([]byte("new-file"))
|
||||
if _, err := p.Invoke(ctx, "upload", json.RawMessage(fmt.Sprintf(`{"path":"new.txt","contentBase64":%q}`, body))); err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
raw, err = p.Invoke(ctx, "download", json.RawMessage(`{"path":"new.txt"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("download after upload: %v", err)
|
||||
}
|
||||
mustJSON(t, raw, &dl)
|
||||
if got, _ := base64.StdEncoding.DecodeString(dl.ContentBase64); string(got) != "new-file" {
|
||||
t.Errorf("round-tripped content = %q, want new-file", got)
|
||||
}
|
||||
|
||||
// mkdir and delete should succeed without error
|
||||
if _, err := p.Invoke(ctx, "mkdir", json.RawMessage(`{"path":"newdir"}`)); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if _, err := p.Invoke(ctx, "delete", json.RawMessage(`{"path":"new.txt"}`)); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
|
||||
// health check is OK against a live (http) server, but degraded because it's plaintext
|
||||
if h := p.HealthCheck(ctx); h.Status != plugins.StatusDegraded {
|
||||
t.Errorf("health status = %q, want degraded (plaintext http); detail=%q", h.Status, h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthCheckAuthFailure confirms a 401 is classified as degraded, not down.
|
||||
func TestHealthCheckAuthFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(newFakeDAV())
|
||||
defer srv.Close()
|
||||
p := &Plugin{}
|
||||
if err := p.Init(context.Background(), map[string]string{
|
||||
"baseURL": srv.URL, "username": "wrong", "password": "pw",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDegraded {
|
||||
t.Errorf("status = %q, want degraded on 401 (detail=%q)", h.Status, h.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, raw json.RawMessage, v any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(raw, v); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", raw, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package plugins
|
||||
|
||||
// Deferred extension points (deliberately NOT in v1 — the "Management MVP").
|
||||
// The contract and manager are shaped so these can be added without a redesign:
|
||||
//
|
||||
// - Invocation API: the Plugin.Invoke method already exists; a
|
||||
// POST /api/admin/plugins/{name}/action endpoint + a normalized request/
|
||||
// response envelope would expose it. Add a mapper layer so core logic never
|
||||
// depends on a provider's schema.
|
||||
// - Resilience: wrap plugin calls with retry/backoff + a circuit breaker, and
|
||||
// record per-plugin latency/error/quota metrics for the panel.
|
||||
// - Per-tenant credentials: today config is a single global blob per plugin.
|
||||
// A (pluginName, orgID/userID) → config store would let users connect their
|
||||
// own third-party accounts.
|
||||
// - Audit logging: record which plugin accessed what and when.
|
||||
// - Sandboxing: the "external" plugin kind is the isolation story — run less
|
||||
// trusted plugins as separate processes/containers behind the HTTP contract.
|
||||
// - Hot-adding builtin Go code without a rebuild is intentionally unsupported
|
||||
// (Go .so plugins are Linux-only and toolchain-fragile); use the external
|
||||
// HTTP kind to add plugins at runtime instead.
|
||||
@@ -0,0 +1,149 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// externalPlugin adapts a remote HTTP service to the Plugin contract. The remote
|
||||
// side implements a tiny JSON contract:
|
||||
//
|
||||
// GET {baseURL}/manifest → { provider, version, capabilities, authType, configFields }
|
||||
// GET {baseURL}/health → 2xx, optionally { status, detail }
|
||||
// POST {baseURL}/invoke → { action, params } → arbitrary JSON (v1: unused)
|
||||
//
|
||||
// This is the "add a plugin without a rebuild" path: register a base URL at
|
||||
// runtime and the server drives it over HTTP. It is also the sandboxing story —
|
||||
// a less-trusted plugin runs as its own process/container.
|
||||
type externalPlugin struct {
|
||||
name string
|
||||
baseURL string
|
||||
desc Descriptor
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newExternalPlugin(name, baseURL, provider string) *externalPlugin {
|
||||
if provider == "" {
|
||||
provider = "External"
|
||||
}
|
||||
return &externalPlugin{
|
||||
name: name,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{Timeout: 8 * time.Second},
|
||||
desc: Descriptor{
|
||||
Name: name,
|
||||
Provider: provider,
|
||||
Version: "external",
|
||||
Kind: KindExternal,
|
||||
Category: CategoryAPIsExternal, // remote HTTP service; a manifest may override
|
||||
AuthType: AuthNone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalPlugin) Descriptor() Descriptor { return e.desc }
|
||||
|
||||
// Init best-effort fetches the remote manifest to enrich the descriptor. A
|
||||
// missing/broken manifest is non-fatal — the basic descriptor stands.
|
||||
func (e *externalPlugin) Init(ctx context.Context, _ map[string]string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.baseURL+"/manifest", nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
resp, err := e.client.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var man struct {
|
||||
Provider string `json:"provider"`
|
||||
Version string `json:"version"`
|
||||
Category string `json:"category"`
|
||||
Capabilities []Capability `json:"capabilities"`
|
||||
AuthType AuthType `json:"authType"`
|
||||
ConfigFields []ConfigField `json:"configFields"`
|
||||
}
|
||||
if json.Unmarshal(data, &man) == nil {
|
||||
if man.Provider != "" {
|
||||
e.desc.Provider = man.Provider
|
||||
}
|
||||
if man.Version != "" {
|
||||
e.desc.Version = man.Version
|
||||
}
|
||||
if man.AuthType != "" {
|
||||
e.desc.AuthType = man.AuthType
|
||||
}
|
||||
if man.Category != "" {
|
||||
e.desc.Category = man.Category
|
||||
}
|
||||
e.desc.Capabilities = man.Capabilities
|
||||
e.desc.ConfigFields = man.ConfigFields
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *externalPlugin) HealthCheck(ctx context.Context) Health {
|
||||
start := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
return Health{Status: StatusDown, Detail: err.Error()}
|
||||
}
|
||||
resp, err := e.client.Do(req)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return Health{Status: StatusDown, LatencyMs: lat, Detail: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
|
||||
// Honour an explicit {status, detail} body when present.
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &body)
|
||||
|
||||
h := Health{LatencyMs: lat, Detail: body.Detail}
|
||||
switch {
|
||||
case body.Status != "":
|
||||
h.Status = body.Status
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
h.Status = StatusOK
|
||||
case resp.StatusCode >= 500:
|
||||
h.Status = StatusDown
|
||||
default:
|
||||
h.Status = StatusDegraded
|
||||
}
|
||||
if h.Detail == "" && h.Status != StatusOK {
|
||||
h.Detail = "HTTP " + resp.Status
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Invoke proxies to the remote /invoke endpoint. Part of the contract; no HTTP
|
||||
// endpoint exposes it in v1.
|
||||
func (e *externalPlugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
payload, _ := json.Marshal(map[string]any{"action": action, "params": params})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+"/invoke", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := e.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (e *externalPlugin) Shutdown(context.Context) error { return nil }
|
||||
@@ -0,0 +1,387 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// secretMask is what a set secret value is echoed back as. On save, a field that
|
||||
// still equals the mask is left unchanged (mirrors the pb-config password flow).
|
||||
const secretMask = "••••••••"
|
||||
|
||||
// record is the persisted state for one plugin. For builtins, Kind/BaseURL are
|
||||
// omitted (the descriptor comes from the registry); external plugins set them.
|
||||
type record struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
BaseURL string `json:"baseURL,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config map[string]string `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// View is the plugin shape returned to the panel (secrets masked).
|
||||
type View struct {
|
||||
Descriptor
|
||||
Enabled bool `json:"enabled"`
|
||||
Config map[string]string `json:"config"`
|
||||
BaseURL string `json:"baseURL,omitempty"`
|
||||
Health *Health `json:"health,omitempty"`
|
||||
}
|
||||
|
||||
// Manager owns the plugin registry, persisted state, and live instances.
|
||||
type Manager struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
factories map[string]Factory
|
||||
records map[string]*record
|
||||
live map[string]Plugin
|
||||
health map[string]*Health
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewManager builds a Manager backed by the JSON state file at path.
|
||||
func NewManager(path string) *Manager {
|
||||
return &Manager{
|
||||
path: path,
|
||||
factories: builtinFactories(),
|
||||
records: map[string]*record{},
|
||||
live: map[string]Plugin{},
|
||||
health: map[string]*Health{},
|
||||
client: &http.Client{Timeout: 12 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the state file and initialises every enabled plugin. A missing file
|
||||
// is fine (no plugins configured yet).
|
||||
func (m *Manager) Load() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if data, err := os.ReadFile(m.path); err == nil {
|
||||
var recs map[string]*record
|
||||
if err := json.Unmarshal(data, &recs); err != nil {
|
||||
return err
|
||||
}
|
||||
m.records = recs
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for name, rec := range m.records {
|
||||
if !rec.Enabled {
|
||||
continue
|
||||
}
|
||||
p := construct(name, m.factories[name], rec)
|
||||
if p == nil {
|
||||
log.Printf("plugins: cannot construct %q (unknown builtin?)", name)
|
||||
continue
|
||||
}
|
||||
if err := p.Init(ctx, rec.Config); err != nil {
|
||||
log.Printf("plugins: init %q failed: %v", name, err)
|
||||
continue
|
||||
}
|
||||
m.live[name] = p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// construct builds a plugin instance from a builtin factory or an external record.
|
||||
func construct(name string, f Factory, rec *record) Plugin {
|
||||
if f != nil {
|
||||
return f()
|
||||
}
|
||||
if rec != nil && rec.Kind == KindExternal {
|
||||
return newExternalPlugin(name, rec.BaseURL, rec.Provider)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// descriptorFor returns a plugin's descriptor without needing a live instance.
|
||||
func (m *Manager) descriptorFor(name string, rec *record) Descriptor {
|
||||
if p := m.live[name]; p != nil {
|
||||
return p.Descriptor()
|
||||
}
|
||||
if f := m.factories[name]; f != nil {
|
||||
return f().Descriptor()
|
||||
}
|
||||
if rec != nil && rec.Kind == KindExternal {
|
||||
return newExternalPlugin(name, rec.BaseURL, rec.Provider).Descriptor()
|
||||
}
|
||||
return Descriptor{Name: name}
|
||||
}
|
||||
|
||||
// maskConfig echoes config back with secret fields masked when set.
|
||||
func maskConfig(d Descriptor, cfg map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Secret && out[f.Key] != "" {
|
||||
out[f.Key] = secretMask
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// List returns every known plugin (registry ∪ persisted), sorted by name.
|
||||
func (m *Manager) List() []View {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
names := map[string]bool{}
|
||||
for n := range m.factories {
|
||||
names[n] = true
|
||||
}
|
||||
for n := range m.records {
|
||||
names[n] = true
|
||||
}
|
||||
|
||||
out := make([]View, 0, len(names))
|
||||
for name := range names {
|
||||
rec := m.records[name]
|
||||
d := m.descriptorFor(name, rec)
|
||||
v := View{Descriptor: d, Health: m.health[name]}
|
||||
if rec != nil {
|
||||
v.Enabled = rec.Enabled
|
||||
v.BaseURL = rec.BaseURL
|
||||
v.Config = maskConfig(d, rec.Config)
|
||||
} else {
|
||||
v.Config = map[string]string{}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns a single plugin view (ok=false when unknown).
|
||||
func (m *Manager) Get(name string) (View, bool) {
|
||||
for _, v := range m.List() {
|
||||
if v.Name == name {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return View{}, false
|
||||
}
|
||||
|
||||
// Upsert enables/disables a plugin and merges its config, then (re)initialises or
|
||||
// shuts down the live instance to match. Secrets left at the mask are preserved.
|
||||
func (m *Manager) Upsert(ctx context.Context, name string, enabled bool, incoming map[string]string) (View, error) {
|
||||
m.mu.Lock()
|
||||
|
||||
_, isBuiltin := m.factories[name]
|
||||
rec := m.records[name]
|
||||
if !isBuiltin && (rec == nil || rec.Kind != KindExternal) {
|
||||
m.mu.Unlock()
|
||||
return View{}, errUnknown
|
||||
}
|
||||
if rec == nil {
|
||||
rec = &record{}
|
||||
m.records[name] = rec
|
||||
}
|
||||
|
||||
d := m.descriptorFor(name, rec)
|
||||
merged := map[string]string{}
|
||||
for k, v := range rec.Config {
|
||||
merged[k] = v
|
||||
}
|
||||
// Apply incoming values, honouring the secret-mask keep-current rule.
|
||||
secretKeys := map[string]bool{}
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Secret {
|
||||
secretKeys[f.Key] = true
|
||||
}
|
||||
}
|
||||
for k, v := range incoming {
|
||||
if secretKeys[k] && v == secretMask {
|
||||
continue // keep existing secret
|
||||
}
|
||||
merged[k] = strings.TrimSpace(v)
|
||||
}
|
||||
// Validate required fields when enabling.
|
||||
if enabled {
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Required && merged[f.Key] == "" {
|
||||
m.mu.Unlock()
|
||||
return View{}, errors.New("missing required setting: " + f.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rec.Enabled = enabled
|
||||
rec.Config = merged
|
||||
if err := m.persistLocked(); err != nil {
|
||||
m.mu.Unlock()
|
||||
return View{}, err
|
||||
}
|
||||
|
||||
// Reconcile the live instance.
|
||||
if old := m.live[name]; old != nil {
|
||||
_ = old.Shutdown(ctx)
|
||||
delete(m.live, name)
|
||||
}
|
||||
var initErr error
|
||||
if enabled {
|
||||
p := construct(name, m.factories[name], rec)
|
||||
if p != nil {
|
||||
if err := p.Init(ctx, merged); err != nil {
|
||||
initErr = err
|
||||
} else {
|
||||
m.live[name] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
v, _ := m.Get(name)
|
||||
return v, initErr
|
||||
}
|
||||
|
||||
// RegisterExternal adds a new external (remote HTTP) plugin at runtime — the
|
||||
// "add a plugin without a rebuild" path. It starts disabled.
|
||||
func (m *Manager) RegisterExternal(name, baseURL, provider string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if name == "" || baseURL == "" {
|
||||
return errors.New("name and baseURL are required")
|
||||
}
|
||||
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||
baseURL = "http://" + baseURL
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, dup := m.factories[name]; dup {
|
||||
return errors.New("a builtin plugin already uses that name")
|
||||
}
|
||||
if _, dup := m.records[name]; dup {
|
||||
return errors.New("a plugin with that name already exists")
|
||||
}
|
||||
m.records[name] = &record{Kind: KindExternal, BaseURL: baseURL, Provider: provider}
|
||||
return m.persistLocked()
|
||||
}
|
||||
|
||||
// Remove deletes an external plugin registration. Builtins can only be disabled.
|
||||
func (m *Manager) Remove(ctx context.Context, name string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
rec := m.records[name]
|
||||
if rec == nil || rec.Kind != KindExternal {
|
||||
return errors.New("only external plugins can be removed")
|
||||
}
|
||||
if p := m.live[name]; p != nil {
|
||||
_ = p.Shutdown(ctx)
|
||||
delete(m.live, name)
|
||||
}
|
||||
delete(m.records, name)
|
||||
delete(m.health, name)
|
||||
return m.persistLocked()
|
||||
}
|
||||
|
||||
// HealthCheck probes a plugin now, building a transient instance if it is not
|
||||
// currently live (so disabled plugins can still be tested). Result is cached.
|
||||
func (m *Manager) HealthCheck(ctx context.Context, name string) (Health, error) {
|
||||
m.mu.Lock()
|
||||
p := m.live[name]
|
||||
transient := false
|
||||
var cfg map[string]string
|
||||
if p == nil {
|
||||
rec := m.records[name]
|
||||
if rec != nil {
|
||||
cfg = rec.Config
|
||||
}
|
||||
p = construct(name, m.factories[name], rec)
|
||||
transient = true
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if p == nil {
|
||||
return Health{}, errUnknown
|
||||
}
|
||||
if transient {
|
||||
_ = p.Init(ctx, cfg)
|
||||
defer func() { _ = p.Shutdown(context.Background()) }()
|
||||
}
|
||||
h := p.HealthCheck(ctx)
|
||||
|
||||
m.mu.Lock()
|
||||
hc := h
|
||||
m.health[name] = &hc
|
||||
m.mu.Unlock()
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// HealthCheckWith probes a plugin using a caller-supplied config instead of the
|
||||
// stored record. It always builds a transient instance, so it never disturbs the
|
||||
// live instance or the cached global health. Used by per-user integration flows
|
||||
// that resolve their own effective config (e.g. the OpenSky settings cascade).
|
||||
func (m *Manager) HealthCheckWith(ctx context.Context, name string, cfg map[string]string) (Health, error) {
|
||||
m.mu.Lock()
|
||||
rec := m.records[name]
|
||||
p := construct(name, m.factories[name], rec)
|
||||
m.mu.Unlock()
|
||||
|
||||
if p == nil {
|
||||
return Health{}, errUnknown
|
||||
}
|
||||
_ = p.Init(ctx, cfg)
|
||||
defer func() { _ = p.Shutdown(context.Background()) }()
|
||||
return p.HealthCheck(ctx), nil
|
||||
}
|
||||
|
||||
// RawConfig returns a plugin's stored config UNMASKED, together with its enabled
|
||||
// flag and whether the plugin is known. Server-side callers use it to resolve a
|
||||
// layered effective config (which needs the real secret values); it must never be
|
||||
// returned to a client. ok is false for an unknown plugin.
|
||||
func (m *Manager) RawConfig(name string) (cfg map[string]string, enabled, ok bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
_, isBuiltin := m.factories[name]
|
||||
rec := m.records[name]
|
||||
if !isBuiltin && rec == nil {
|
||||
return nil, false, false
|
||||
}
|
||||
out := map[string]string{}
|
||||
if rec != nil {
|
||||
for k, v := range rec.Config {
|
||||
out[k] = v
|
||||
}
|
||||
enabled = rec.Enabled
|
||||
}
|
||||
return out, enabled, true
|
||||
}
|
||||
|
||||
// Shutdown tears down every live plugin instance. Wire into graceful shutdown.
|
||||
func (m *Manager) Shutdown(ctx context.Context) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for name, p := range m.live {
|
||||
_ = p.Shutdown(ctx)
|
||||
delete(m.live, name)
|
||||
}
|
||||
}
|
||||
|
||||
// persistLocked writes the state file. Caller must hold m.mu.
|
||||
func (m *Manager) persistLocked() error {
|
||||
data, err := json.MarshalIndent(m.records, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(m.path, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
var errUnknown = errors.New("unknown plugin")
|
||||
|
||||
// IsUnknown reports whether err came from addressing a plugin that doesn't exist.
|
||||
func IsUnknown(err error) bool { return errors.Is(err, errUnknown) }
|
||||
@@ -0,0 +1,167 @@
|
||||
// Package plugins is the API Server's plugin system: a uniform contract for
|
||||
// integrating external third-party services (flight data, notifications, …).
|
||||
//
|
||||
// Two plugin kinds share one contract:
|
||||
// - "builtin" — a Go connector compiled into the server (type-safe, first-party).
|
||||
// Adding a new builtin requires a rebuild. See builtin/opensky for an example.
|
||||
// - "external" — a remote service registered at runtime (no rebuild) that speaks
|
||||
// a small JSON contract over HTTP. See external.go.
|
||||
//
|
||||
// Enable-state and per-plugin config (including secrets) are persisted to a local
|
||||
// plugins.json by the Manager, mirroring how the PocketBase connection persists to
|
||||
// .env. See doc.go for the deliberately-deferred extension points.
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Plugin kinds.
|
||||
const (
|
||||
KindBuiltin = "builtin"
|
||||
KindExternal = "external"
|
||||
)
|
||||
|
||||
// AuthType describes how a plugin authenticates to its upstream. It is metadata
|
||||
// for the UI/operators; each plugin implements the mechanics itself.
|
||||
type AuthType string
|
||||
|
||||
const (
|
||||
AuthNone AuthType = "none"
|
||||
AuthAPIKey AuthType = "apikey"
|
||||
AuthBasic AuthType = "basic"
|
||||
AuthOAuth2 AuthType = "oauth2"
|
||||
AuthWebhook AuthType = "webhook"
|
||||
)
|
||||
|
||||
// Health status values.
|
||||
const (
|
||||
StatusOK = "ok"
|
||||
StatusDegraded = "degraded"
|
||||
StatusDown = "down"
|
||||
)
|
||||
|
||||
// SelectOption is one choice for a ConfigField of Type "select".
|
||||
type SelectOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// ConfigField declares one configurable setting a plugin accepts. It drives the
|
||||
// panel's generated config form and controls secret masking.
|
||||
type ConfigField struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"` // "text" | "password" | "number" | "select"
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"` // never echoed back to clients in clear
|
||||
Help string `json:"help,omitempty"`
|
||||
Default string `json:"default,omitempty"` // effective default when unset
|
||||
Options []SelectOption `json:"options,omitempty"` // for Type "select"
|
||||
}
|
||||
|
||||
// Capability is one operation a plugin exposes. It maps a stable id to the
|
||||
// upstream endpoint it calls and a human description shown in the panel.
|
||||
type Capability struct {
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method,omitempty"` // e.g. "GET"
|
||||
Endpoint string `json:"endpoint,omitempty"` // upstream path, e.g. "/states/all"
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts either a bare string ("states.all") or a full object, so
|
||||
// external manifests can advertise capabilities in either form.
|
||||
func (c *Capability) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if json.Unmarshal(b, &s) == nil {
|
||||
c.ID = s
|
||||
return nil
|
||||
}
|
||||
type alias Capability
|
||||
var a alias
|
||||
if err := json.Unmarshal(b, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
*c = Capability(a)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Category groups a plugin under a tab in the admin panel. A plugin with an
|
||||
// empty category is treated as CategoryAPIsExternal by the panel.
|
||||
const (
|
||||
CategoryAPIsExternal = "apis-external" // remote HTTP APIs (OpenSky, external plugins)
|
||||
CategoryDrivesExternal = "drives-external" // remote file stores (FTP/SFTP)
|
||||
CategoryDrivesLocal = "drives-local" // drives on the host machine
|
||||
)
|
||||
|
||||
// Descriptor is the static metadata a plugin advertises about itself.
|
||||
type Descriptor struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Version string `json:"version"`
|
||||
Kind string `json:"kind"` // KindBuiltin | KindExternal
|
||||
Category string `json:"category"` // one of Category* — groups the plugin in the panel
|
||||
Capabilities []Capability `json:"capabilities"`
|
||||
AuthType AuthType `json:"authType"`
|
||||
ConfigFields []ConfigField `json:"configFields"`
|
||||
}
|
||||
|
||||
// Health is the outcome of a plugin's HealthCheck.
|
||||
type Health struct {
|
||||
Status string `json:"status"` // StatusOK | StatusDegraded | StatusDown
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Credits *HealthCredits `json:"credits,omitempty"`
|
||||
}
|
||||
|
||||
// HealthCredits is optional structured rate-limit/credit accounting a plugin may
|
||||
// report alongside a probe (e.g. OpenSky's daily credit allowance). It lets the UI
|
||||
// render a dedicated usage meter instead of parsing it back out of Detail.
|
||||
type HealthCredits struct {
|
||||
Remaining *int `json:"remaining,omitempty"` // credits left today; nil when the upstream didn't report it (e.g. anonymous)
|
||||
Daily int `json:"daily,omitempty"` // the plan's daily allowance
|
||||
ProbeCost int `json:"probeCost,omitempty"` // credits one query/probe costs
|
||||
Mode string `json:"mode,omitempty"` // "authenticated" | "anonymous"
|
||||
}
|
||||
|
||||
// Plugin is the contract every plugin (builtin or external) implements.
|
||||
type Plugin interface {
|
||||
// Descriptor returns the plugin's static metadata. It may be enriched after
|
||||
// Init (e.g. an external plugin fetching its manifest).
|
||||
Descriptor() Descriptor
|
||||
// Init prepares the plugin with its resolved config (secrets included). It is
|
||||
// called when the plugin is enabled or its config changes.
|
||||
Init(ctx context.Context, config map[string]string) error
|
||||
// HealthCheck probes the upstream and classifies the result.
|
||||
HealthCheck(ctx context.Context) Health
|
||||
// Invoke runs a named capability. Part of the contract for future use; v1
|
||||
// exposes no HTTP endpoint for it.
|
||||
Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error)
|
||||
// Shutdown releases any resources held by the plugin.
|
||||
Shutdown(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Factory builds a fresh instance of a builtin plugin.
|
||||
type Factory func() Plugin
|
||||
|
||||
// registry holds the builtin plugin factories keyed by descriptor name.
|
||||
var registry = map[string]Factory{}
|
||||
|
||||
// Register adds a builtin plugin factory. Called from a builtin package's init().
|
||||
// Panics on a duplicate name so wiring mistakes surface at startup.
|
||||
func Register(name string, f Factory) {
|
||||
if _, dup := registry[name]; dup {
|
||||
panic("plugins: duplicate registration for " + name)
|
||||
}
|
||||
registry[name] = f
|
||||
}
|
||||
|
||||
// builtinFactories returns a copy of the registered builtin factories.
|
||||
func builtinFactories() map[string]Factory {
|
||||
out := make(map[string]Factory, len(registry))
|
||||
for k, v := range registry {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user