anonpenguin23 07638354d2 feat(#72): full-privacy push — self-hosted ntfy + APNs-direct provider
Migration 028: namespace_push_credentials
- Per-(namespace, provider) AES-256-GCM encrypted credential blob.
- Generic schema — apns/ntfy/expo/future plug in with zero migration.
- Separated from migration 026's namespace_push_config (preferences vs
  credentials, different access patterns).

pkg/push/credentials
- Manager + Registry + RQLite store; HKDF purpose "namespace-push-credentials"
  via pkg/secrets. Provider Validator interface for per-provider schema.

pkg/push/providers/apns
- Apple Push Notification service direct provider (no Expo proxy).
- Validator + dispatcher; credentials are p8 signing key + key_id + team_id.

pkg/push/providers/ntfy/credentials.go
- ntfy credential schema (auth_token + default topic). Used both with
  the public ntfy.sh and our self-hosted instance.

pkg/environments/production/installers/ntfy.go
- Self-hosted ntfy server installer. Binary, system user, hardened
  /etc/ntfy/server.yml, systemd unit. Listens on 127.0.0.1:NtfyListenPort
  only — Caddy is the only public path.

pkg/environments/production/installers/caddy.go
- Emit reverse_proxy block for push.<dnsZone> -> 127.0.0.1:NtfyListenPort
  when operator enables ntfy on a node.

CLI: install/upgrade orchestrators learn a new "ntfy" install/preserve
phase; flag gating in install/flags.go + upgrade/flags.go.

Gateway handlers/push/credentials_handler.go
- GET/PUT/DELETE /v1/namespace/push-credentials/{provider}.
- PUT validates against provider Validator before encrypting and storing.
- GET returns a redacted view (booleans + non-secret fields only).

Push manager: provider resolution now also consults
namespace_push_credentials before falling back to YAML defaults.

Docs: core/docs/PUSH_NOTIFICATIONS.md walks through end-to-end setup.

VERSION bumped to 0.122.14.
2026-05-14 10:48:00 +03:00

182 lines
5.1 KiB
Go

package credentials
import (
"container/list"
"context"
"errors"
"sync"
"time"
"go.uber.org/zap"
)
// Manager is the read-side entry point for per-namespace, per-provider
// credentials. Provider packages call Manager.Get to load credentials
// at push-send time; the LRU+TTL cache eliminates per-call decryption
// for the (almost always) cache-hit path.
//
// Cache invalidation (defense in depth):
//
// - Immediate (this-gateway): the HTTP handler calls Invalidate(ns,
// provider) after PUT/DELETE so the next lookup on THIS gateway
// rebuilds from store.
// - Bounded staleness (cluster-wide): every cached entry expires
// after cacheEntryTTL (30s) and is reloaded from the store on the
// next call. Bounds the window during which a config change on
// gateway A is invisible to gateway B without requiring a pub/sub
// broadcast layer. Same model as pkg/ratelimit.
//
// Safe for concurrent use.
type Manager struct {
store Store
logger *zap.Logger
ttl time.Duration // configurable for tests; defaults to cacheEntryTTL
mu sync.Mutex
cache map[cacheKey]*list.Element
lru *list.List
cacheCap int
}
// cacheKey is (namespace, provider) — the natural primary key.
type cacheKey struct {
namespace string
provider string
}
// cacheEntry is the LRU node payload.
type cacheEntry struct {
key cacheKey
cred *Credential // nil means "no row" (negative cache)
builtAt time.Time
}
// NewManager constructs a Manager backed by the given store.
func NewManager(store Store, logger *zap.Logger) *Manager {
if logger == nil {
logger = zap.NewNop()
}
return &Manager{
store: store,
logger: logger,
ttl: cacheEntryTTL,
cache: make(map[cacheKey]*list.Element, defaultCacheCap),
lru: list.New(),
cacheCap: defaultCacheCap,
}
}
// SetCacheTTL overrides the default cache-entry TTL. Intended for tests
// (where 30s is too long to wait) and for operators who want a tighter
// propagation window across multi-gateway deployments. A non-positive
// argument is a no-op.
func (m *Manager) SetCacheTTL(d time.Duration) {
if d <= 0 {
return
}
m.mu.Lock()
defer m.mu.Unlock()
m.ttl = d
}
// Get returns the credential for (namespace, provider) or (nil, nil) if
// no credential is configured. A store error is returned to the caller
// — unlike rate limiting (where we fail open under a store error), a
// missing push credential MUST surface so the caller doesn't silently
// drop a message to a misconfigured provider.
func (m *Manager) Get(ctx context.Context, namespace, provider string) (*Credential, error) {
if namespace == "" {
return nil, ErrInvalidNamespace
}
if provider == "" {
return nil, ErrInvalidProvider
}
key := cacheKey{namespace: namespace, provider: provider}
m.mu.Lock()
if el, ok := m.cache[key]; ok {
entry := el.Value.(*cacheEntry)
if time.Since(entry.builtAt) < m.ttl {
m.lru.MoveToFront(el)
m.mu.Unlock()
return entry.cred, nil
}
// Expired — drop and fall through to rebuild.
m.lru.Remove(el)
delete(m.cache, key)
}
m.mu.Unlock()
cred, err := m.store.Get(ctx, namespace, provider)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
// Store ErrNotFound → cache a negative (nil cred) entry so we don't
// hammer rqlite for "namespace doesn't use this provider" on the hot
// send path. The TTL still expires the negative entry, so once a
// tenant DOES configure the provider, latency to first-effective is
// bounded by the TTL.
m.mu.Lock()
defer m.mu.Unlock()
// Recheck under lock — another goroutine may have built one
// concurrently. Use it if it's still fresh.
if el, ok := m.cache[key]; ok {
entry := el.Value.(*cacheEntry)
if time.Since(entry.builtAt) < m.ttl {
m.lru.MoveToFront(el)
return entry.cred, nil
}
m.lru.Remove(el)
delete(m.cache, key)
}
entry := &cacheEntry{key: key, cred: cred, builtAt: time.Now()}
el := m.lru.PushFront(entry)
m.cache[key] = el
for m.lru.Len() > m.cacheCap {
tail := m.lru.Back()
if tail == nil {
break
}
m.lru.Remove(tail)
delete(m.cache, tail.Value.(*cacheEntry).key)
}
return cred, nil
}
// Invalidate evicts the cached entry for (namespace, provider). Called
// by the HTTP handler after PUT/DELETE so the next Get reloads from
// the store.
func (m *Manager) Invalidate(namespace, provider string) {
m.mu.Lock()
defer m.mu.Unlock()
key := cacheKey{namespace: namespace, provider: provider}
if el, ok := m.cache[key]; ok {
m.lru.Remove(el)
delete(m.cache, key)
}
}
// InvalidateNamespace evicts every cached entry for the given namespace,
// regardless of provider. Used when a namespace is deleted wholesale or
// during an admin "rotate all credentials" operation.
func (m *Manager) InvalidateNamespace(namespace string) {
m.mu.Lock()
defer m.mu.Unlock()
for k, el := range m.cache {
if k.namespace == namespace {
m.lru.Remove(el)
delete(m.cache, k)
}
}
}
// Store returns the underlying store. Used by the HTTP handlers for
// write paths (PUT/DELETE) which go straight to the store and then
// Invalidate; reads of cached state remain on the Manager.
func (m *Manager) Store() Store {
return m.store
}