Documentation
¶
Overview ¶
Package contentbridge is the node side of the kombify Content Bridge (HOMELAB-CONTENT-BRIDGE-STANDARD, ADR-0047). It reads a closed set of counts from the self-hosted apps of one server, strictly read-only, and answers them as a `homelab-content/v1` document.
The bridge is a separate component. It is not kombify Guard and never travels in Guard's channel. It answers only the consent tier its caller is granted, caches for at most one minute, stores nothing and never logs a payload.
Index ¶
- Constants
- Variables
- func ImmichKeySecretRef() string
- func ImmichPermissions() []string
- func NewHandler(service *Service, authority TierAuthority) http.Handler
- func NewReadOnlyTransport(next http.RoundTripper, origin *url.URL, endpoints []Endpoint) http.RoundTripper
- type Adapter
- type Config
- type Document
- type Endpoint
- type ImmichAdapter
- type KeyFunc
- type LoopbackQueryTier
- type PhotosCounts
- type ReadOnlyClient
- type Service
- type ServiceConfig
- type Status
- type Tier
- type TierAuthority
- type TierError
- type UseCase
- type UseCaseResult
Constants ¶
const ( EnvListen = "CONTENT_BRIDGE_LISTEN" EnvHomelabID = "CONTENT_BRIDGE_HOMELAB_ID" EnvImmichURL = "CONTENT_BRIDGE_IMMICH_URL" EnvImmichKeyFile = "CONTENT_BRIDGE_IMMICH_API_KEY_FILE" EnvCacheTTL = "CONTENT_BRIDGE_CACHE_TTL" DefaultListen = ":8083" DefaultImmichURL = "http://immich-server:2283" DefaultImmichKeyFile = "/run/secrets/immich-api-key" )
Environment of the bridge container. The credential arrives as a file, never as an environment value that container inspection would expose.
const ( WorkloadRef = "photos-content-bridge" ImmichKeySlot = "immich-api-key" // ImmichKeyName names the key inside Immich, where the owner sees and // can revoke it. One key per bridge: issuing again replaces it. ImmichKeyName = "stackkits-content-bridge" )
Identity of the bridge's own custody and app credentials. The workload reference follows the Photos add-on convention (photos-kiosk, photos-tools), so the custody reference reads secret://workloads/photos-content-bridge/ immich-api-key like every governed workload secret.
const ( SummaryPath = "/content/v1/summary" HealthPath = "/healthz" )
Paths of the node bridge (standard section 4).
const MaxCacheTTL = 60 * time.Second
MaxCacheTTL is the longest the bridge keeps an app answer in memory (standard section 3).
const Version = "homelab-content/v1"
Version is the `version` of every document the bridge answers.
Variables ¶
var ( ErrAppUnreachable = errors.New("content bridge: app unreachable") ErrCredentialInvalid = errors.New("content bridge: app credential invalid") ErrNotConfigured = errors.New("content bridge: app not configured") // ErrRequestRefused is returned for anything outside the read-only // allowlist. It signals a defect, never a state of the app. ErrRequestRefused = errors.New("content bridge: request refused by the read-only allowlist") )
Outcomes of reading an app. They map one to one onto the statuses of the contract, so a failed read can never become an empty answer.
Functions ¶
func ImmichKeySecretRef ¶
func ImmichKeySecretRef() string
ImmichKeySecretRef is the local custody reference of the bridge's Immich key. The key never leaves node custody (standard section 1).
func ImmichPermissions ¶
func ImmichPermissions() []string
ImmichPermissions are the API key permissions the bridge needs for the summary tier, and nothing more. The Immich 2.7 permission enum offers a statistics permission for each of the three counts, so the key can neither list nor open an asset, an album or a memory, and cannot write.
func NewHandler ¶
func NewHandler(service *Service, authority TierAuthority) http.Handler
NewHandler serves the summary and the loopback health endpoint.
func NewReadOnlyTransport ¶
func NewReadOnlyTransport(next http.RoundTripper, origin *url.URL, endpoints []Endpoint) http.RoundTripper
NewReadOnlyTransport wraps next so that only GET requests to the listed endpoints of one origin can leave the bridge. The guard sits in the transport, below every caller: no code path that holds the client, now or later, can issue a mutating call by choosing another method, path or host.
Types ¶
type Adapter ¶
type Adapter interface {
UseCase() UseCase
// Ceiling is the highest tier this adapter can answer today.
Ceiling() Tier
// Counts returns the summary tier counts, or one of the outcome errors.
Counts(ctx context.Context) (any, error)
}
Adapter answers one use case from one app.
type Config ¶
type Config struct {
Listen string
HomelabID string
ImmichURL string
ImmichKeyFile string
CacheTTL time.Duration
}
Config is the validated environment of the bridge.
type Document ¶
type Document struct {
Version string `json:"version"`
HomelabID string `json:"homelab_id"`
AsOf string `json:"as_of"`
UseCases []UseCaseResult `json:"use_cases"`
}
Document is the `homelab-content/v1` response.
type Endpoint ¶
Endpoint is one allowlisted GET endpoint of an app and the query parameters it may carry.
func ImmichEndpoints ¶
func ImmichEndpoints() []Endpoint
ImmichEndpoints is the complete GET allowlist toward Immich.
type ImmichAdapter ¶
type ImmichAdapter struct {
// contains filtered or unexported fields
}
ImmichAdapter answers the Photos use case from Immich.
func NewImmichAdapter ¶
func NewImmichAdapter(client *ReadOnlyClient, now func() time.Time) *ImmichAdapter
NewImmichAdapter wires the adapter to its read-only client. now may be nil.
func (*ImmichAdapter) Ceiling ¶
func (*ImmichAdapter) Ceiling() Tier
Ceiling is the highest tier the node can answer for Photos. Previews need thumbnails and titles, which arrive with the node-signed thumbnail slice. Until then a previews request is answered at the summary tier.
func (*ImmichAdapter) Counts ¶
func (a *ImmichAdapter) Counts(ctx context.Context) (any, error)
Counts reads the Photos counts. Assets are required: without them the answer is an error and never an empty set. Albums and on-this-day are reported when Immich answers them and omitted otherwise.
func (*ImmichAdapter) UseCase ¶
func (*ImmichAdapter) UseCase() UseCase
UseCase is the contract use case this adapter answers.
type KeyFunc ¶
KeyFunc returns the app credential. It reads custody on every call, so a rotated key takes effect without a restart. It returns ErrNotConfigured when no credential has been issued yet.
type LoopbackQueryTier ¶
type LoopbackQueryTier struct{}
LoopbackQueryTier is the authority used until envelope verification exists. It accepts the `tier` query parameter only from a caller on the loopback interface of the bridge's own network namespace, which is the owner on the node (or a test). Behind the router every request comes from the router's address, so the public route answers 403 until the envelope authority replaces this one. It never reads a forwarded-for header: a remote client controls those.
func (LoopbackQueryTier) GrantedTier ¶
func (LoopbackQueryTier) GrantedTier(r *http.Request) (Tier, error)
GrantedTier implements TierAuthority.
type PhotosCounts ¶
type PhotosCounts struct {
Assets *int64 `json:"assets,omitempty"`
Images *int64 `json:"images,omitempty"`
Videos *int64 `json:"videos,omitempty"`
Albums *int64 `json:"albums,omitempty"`
OnThisDay *int64 `json:"on_this_day,omitempty"`
}
PhotosCounts are the Photos counts of the summary tier. A pointer field is absent when the app did not answer it; zero is a real answer.
type ReadOnlyClient ¶
type ReadOnlyClient struct {
// contains filtered or unexported fields
}
ReadOnlyClient reads JSON from one app. It has no method for anything but GET, and its transport refuses everything outside the allowlist.
func NewReadOnlyClient ¶
func NewReadOnlyClient(origin string, endpoints []Endpoint, key KeyFunc, base http.RoundTripper) (*ReadOnlyClient, error)
NewReadOnlyClient builds the client for one app origin. base is the network transport below the guard; nil selects a direct transport that ignores proxy environment variables, because the app is on the node's own internal network and the bridge has no egress.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service assembles homelab-content/v1 documents.
func NewServiceFromConfig ¶
NewServiceFromConfig builds the service with the Immich adapter.
func (*Service) Summarize ¶
func (s *Service) Summarize(ctx context.Context, useCases []UseCase, granted Tier, homelabID string) Document
Summarize answers the requested use cases at the granted tier. The effective tier of each use case is the minimum of the granted tier and the adapter's ceiling. At the off tier nothing is read.
type ServiceConfig ¶
type ServiceConfig struct {
// HomelabID is answered when the caller supplies none.
HomelabID string
Adapters []Adapter
// CacheTTL is clamped to MaxCacheTTL; zero selects MaxCacheTTL.
CacheTTL time.Duration
Now func() time.Time
Logger *slog.Logger
}
ServiceConfig configures a Service.
type Status ¶
type Status string
Status is the per use case outcome. An app that cannot be read is never reported as empty.
type Tier ¶
type Tier string
Tier is the consent tier a response may carry.
The three consent tiers of the standard, in ascending order of content.
type TierAuthority ¶
TierAuthority decides which tier a request is granted. The bridge never reads a tier from anywhere else, and a request without a grant is refused before any app is called (consent default off).
The grant belongs to the Gateway: it travels as a signed `tier` claim of the installation envelope; the authority that verifies that envelope implements this interface.
type UseCase ¶
type UseCase string
UseCase names one content use case of the contract.
const ( UseCasePhotos UseCase = "photos" UseCaseMedia UseCase = "media" UseCaseDocuments UseCase = "documents" UseCaseFiles UseCase = "files" UseCaseSmartHome UseCase = "smart_home" )
The use cases of homelab-content/v1.
func ParseUseCase ¶
ParseUseCase accepts exactly the use cases of the contract.
type UseCaseResult ¶
type UseCaseResult struct {
UseCase UseCase `json:"use_case"`
App string `json:"app"`
Status Status `json:"status"`
Tier Tier `json:"tier"`
AsOf string `json:"as_of,omitempty"`
Counts any `json:"counts,omitempty"`
}
UseCaseResult is one use case of a Document. Counts is present only for status ok. The bridge carries no items yet: titles and thumbnails belong to the previews tier, which the bridge cannot answer yet (see Ceiling).