* feat(security): encrypt server-owned credentials at rest Introduce AES-256-GCM at-rest encryption (HKDF-derived from a required SECRET_KEY) for server-owned credentials, with row-bound AAD, a versioned enc:v1: envelope, and an idempotent startup backfill. - internal/secret: cipher + RowAAD/SettingsAAD + the startup backfill engine. - SECRET_KEY required at bootstrap; cipher threaded as an explicit dependency. - server_settings: EncryptedSettingsRepo decorator over the audited SensitiveSettingKeys (also drives admin redaction); the config watcher and watch-sync settings reads decrypt too. - Arr keys inline-encrypted; the ambiguous SecretResolver indirection removed from requests/autoscan. - Per-table columns encrypted: subtitles, watch-sync, webhook-sync (not webhook_secret), history-import, and the jellycompat session's bridged Silo access/refresh tokens. - Startup backfill (resolve-then-encrypt for arr refs) is best-effort and primary-node gated. Equality-looked-up secrets and plugin_runtime_configs.config_value are out of scope (need hashing / cross-repo design) — see docs/architecture/secret-encryption.md. Refs #45 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(compose): require SECRET_KEY in docker-compose The server now fatals without SECRET_KEY, so the integrated service (and the commented distributed proxy/transcode examples) pass it through with a fail-fast guard matching the existing MEDIA_ROOT pattern. Distributed worker nodes must use the SAME key as the primary to decrypt shared data. Generate with: openssl rand -base64 48. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(security): encrypt history import session credentials --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
5.9 KiB
Go
162 lines
5.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/autoscan"
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/plugins"
|
|
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
|
"github.com/Silo-Server/silo-server/internal/scantrigger"
|
|
)
|
|
|
|
// autoscanQueuer is the scan-enqueue surface BuildAutoscanService needs; it is
|
|
// satisfied by *scanqueue.Service (autoscan.Queuer's concrete production impl).
|
|
type autoscanQueuer = autoscan.Queuer
|
|
|
|
// RequestIntegrationLookup adapts the Requests repository to the autoscan
|
|
// connection resolver's RequestIntegrationLookup: it resolves a soft-linked
|
|
// Requests integration to its base URL and api key (the repo decrypts the key
|
|
// on read, so this returns plaintext).
|
|
type RequestIntegrationLookup struct {
|
|
Repo *mediarequests.Repository
|
|
}
|
|
|
|
func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKey string, err error) {
|
|
integration, err := l.Repo.GetIntegration(ctx, integrationID)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
// A reused Requests connection must honor the integration's live state. The
|
|
// v1 poll gated on `WHERE ri.enabled = true`; here we surface a disabled or
|
|
// unconfigured (blank base_url) integration as an error so the engine turns
|
|
// it into a logged skip / RecordError rather than polling an unusable target.
|
|
if err := checkRequestIntegrationUsable(integrationID, integration.Enabled, integration.BaseURL); err != nil {
|
|
return "", "", err
|
|
}
|
|
return integration.BaseURL, integration.APIKeyRef, nil
|
|
}
|
|
|
|
// checkRequestIntegrationUsable returns a non-nil error when a linked Requests
|
|
// integration cannot be polled: it is disabled, or it has no base_url. Extracted
|
|
// as a pure function so the gating is unit-testable without a DB-backed repo.
|
|
func checkRequestIntegrationUsable(integrationID string, enabled bool, baseURL string) error {
|
|
if !enabled {
|
|
return fmt.Errorf("linked requests integration %q is disabled", integrationID)
|
|
}
|
|
if strings.TrimSpace(baseURL) == "" {
|
|
return fmt.Errorf("linked requests integration %q has no base_url configured", integrationID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PluginScanSourceAdapter adapts plugins.Service to autoscan.ScanSourceResolver.
|
|
// plugins.Service.ScanSourceClient returns the concrete
|
|
// *pluginhost.ScanSourceClient; that concrete type satisfies
|
|
// autoscan.PollChangesClient (it has the matching PollChanges method), so the
|
|
// adapter declares the exported interface as its return type and returns the
|
|
// concrete value (Go has no return-type covariance, so the method signature must
|
|
// name the interface exactly to satisfy ScanSourceResolver).
|
|
type PluginScanSourceAdapter struct {
|
|
Svc *plugins.Service
|
|
}
|
|
|
|
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (autoscan.PollChangesClient, error) {
|
|
return a.Svc.ScanSourceClientByPluginID(ctx, pluginID, capabilityID)
|
|
}
|
|
|
|
// scanSourceCapabilityType is the plugin capability type autoscan discovery
|
|
// enumerates.
|
|
const scanSourceCapabilityType = "scan_source.v1"
|
|
|
|
// PluginScanSourceLister adapts the plugin installation store to
|
|
// autoscan.ScanSourceLister: it enumerates every installed scan_source.v1
|
|
// capability across ALL installed plugins, regardless of enabled state.
|
|
type PluginScanSourceLister struct {
|
|
Store *plugins.InstallationStore
|
|
}
|
|
|
|
func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan.DiscoveredSource, error) {
|
|
installations, err := l.Store.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []autoscan.DiscoveredSource
|
|
for _, inst := range installations {
|
|
caps, err := l.Store.ListCapabilities(ctx, inst.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, c := range caps {
|
|
if c == nil || c.Type != scanSourceCapabilityType {
|
|
continue
|
|
}
|
|
out = append(out, autoscan.DiscoveredSource{
|
|
PluginID: inst.PluginID,
|
|
CapabilityID: c.ID,
|
|
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
|
})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// scanSourceDisplayName derives a human-friendly label for a scan_source
|
|
// capability: the capability manifest's display_name when present, else the
|
|
// plugin id (with the capability id appended when it adds information).
|
|
func scanSourceDisplayName(pluginID string, c *plugins.Capability) string {
|
|
if c != nil && c.Metadata != nil {
|
|
if name, ok := c.Metadata["display_name"].(string); ok && strings.TrimSpace(name) != "" {
|
|
return strings.TrimSpace(name)
|
|
}
|
|
}
|
|
switch {
|
|
case pluginID != "" && c != nil && c.ID != "":
|
|
return pluginID + " / " + c.ID
|
|
case pluginID != "":
|
|
return pluginID
|
|
case c != nil:
|
|
return c.ID
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// BuildAutoscanService wires the v2 autoscan engine from its concrete
|
|
// dependencies. Both the HTTP router (manual trigger) and the background poll
|
|
// task share this constructor so the adapter wiring lives in exactly one place.
|
|
// Credentials are now decrypted inline by the autoscan/requests repos, so there
|
|
// is no separate secret resolver to thread.
|
|
func BuildAutoscanService(
|
|
repo *autoscan.Repository,
|
|
pluginService *plugins.Service,
|
|
installationStore *plugins.InstallationStore,
|
|
requestsRepo *mediarequests.Repository,
|
|
folderRepo *catalog.FolderRepository,
|
|
queue autoscanQueuer,
|
|
redisClient *redis.Client,
|
|
) *autoscan.Service {
|
|
provider := autoscan.NewPluginProvider(PluginScanSourceAdapter{pluginService})
|
|
connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo})
|
|
svc := autoscan.NewService(
|
|
repo,
|
|
provider,
|
|
connRes,
|
|
scantrigger.NewResolver(folderRepo),
|
|
queue,
|
|
autoscan.NewRedisSuppressor(redisClient),
|
|
PluginScanSourceLister{installationStore},
|
|
)
|
|
// Wire the connection-test + rewrite-suggester deps: a (long-timeout)
|
|
// arr root-folder/status client and a Silo media-folder lister.
|
|
svc.SetSuggesterDeps(
|
|
autoscan.NewArrRootFolderClient(nil),
|
|
autoscan.NewCatalogFolderLister(folderRepo),
|
|
)
|
|
return svc
|
|
}
|