447 lines
13 KiB
Go
447 lines
13 KiB
Go
|
|
package stream
|
||
|
|
|
||
|
|
// DTN_cache.go — Disconnection Network Tolerance cache for outbound stream requests.
|
||
|
|
//
|
||
|
|
// When a stream write fails because the remote peer is unreachable, the request
|
||
|
|
// is saved here and retried on the next tick. Two levels are defined:
|
||
|
|
//
|
||
|
|
// - DTNCritical : retry indefinitely (create / update / delete resource).
|
||
|
|
// - DTNModerate : up to DTNMaxModerateRetries retries, then abandon.
|
||
|
|
//
|
||
|
|
// Pubsub messages and search streams are explicitly excluded.
|
||
|
|
// Streams initiated from the indexer side are never enqueued here.
|
||
|
|
//
|
||
|
|
// # Crash-resilient persistence
|
||
|
|
//
|
||
|
|
// Critical entries are written to an encrypted file (AES-256-GCM) so they
|
||
|
|
// survive a node crash/restart. The AES key is derived deterministically from
|
||
|
|
// the node's Ed25519 private key via HKDF-SHA256 — no extra secret to manage.
|
||
|
|
// Moderate entries are intentionally not persisted: their retry budget is small
|
||
|
|
// enough that re-loading them after a restart would be misleading.
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/aes"
|
||
|
|
"crypto/cipher"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/json"
|
||
|
|
"io"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
oclib "cloud.o-forge.io/core/oc-lib"
|
||
|
|
"cloud.o-forge.io/core/oc-lib/tools"
|
||
|
|
"golang.org/x/crypto/hkdf"
|
||
|
|
|
||
|
|
"oc-discovery/conf"
|
||
|
|
|
||
|
|
pp "github.com/libp2p/go-libp2p/core/peer"
|
||
|
|
"github.com/libp2p/go-libp2p/core/protocol"
|
||
|
|
)
|
||
|
|
|
||
|
|
type DTNLevel int
|
||
|
|
|
||
|
|
const (
|
||
|
|
DTNCritical DTNLevel = iota // retry until the message is delivered
|
||
|
|
DTNModerate // retry up to DTNMaxModerateRetries times
|
||
|
|
)
|
||
|
|
|
||
|
|
const DTNMaxModerateRetries = 3
|
||
|
|
const DTNRetryInterval = 15 * time.Second
|
||
|
|
|
||
|
|
// DTNProtocols maps each stream protocol to its DTN level.
|
||
|
|
// Protocols absent from this map receive no caching (e.g. ProtocolSearchResource).
|
||
|
|
var DTNProtocols = map[protocol.ID]DTNLevel{
|
||
|
|
// Critical — data mutations that must eventually be delivered.
|
||
|
|
ProtocolCreateResource: DTNCritical,
|
||
|
|
ProtocolUpdateResource: DTNCritical,
|
||
|
|
ProtocolDeleteResource: DTNCritical,
|
||
|
|
// Moderate — confirmations / config / planner: 3 retries before abandon.
|
||
|
|
ProtocolVerifyResource: DTNModerate,
|
||
|
|
ProtocolSendPlanner: DTNModerate,
|
||
|
|
ProtocolConsidersResource: DTNModerate,
|
||
|
|
ProtocolMinioConfigResource: DTNModerate,
|
||
|
|
ProtocolAdmiraltyConfigResource: DTNModerate,
|
||
|
|
ProtocolSourcePresignResource: DTNModerate,
|
||
|
|
}
|
||
|
|
|
||
|
|
// DTNEntryJSON is the on-disk representation of a DTNEntry.
|
||
|
|
// pp.AddrInfo and protocol.ID don't have built-in JSON tags so we flatten them.
|
||
|
|
type DTNEntryJSON struct {
|
||
|
|
DID string `json:"did"`
|
||
|
|
ResourceID string `json:"resource_id,omitempty"`
|
||
|
|
ForceCritical bool `json:"force_critical,omitempty"`
|
||
|
|
Addr pp.AddrInfo `json:"addr"`
|
||
|
|
DT *tools.DataType `json:"dt,omitempty"`
|
||
|
|
User string `json:"user"`
|
||
|
|
Payload []byte `json:"payload"`
|
||
|
|
Proto protocol.ID `json:"proto"`
|
||
|
|
Retries int `json:"retries"`
|
||
|
|
AddedAt time.Time `json:"added_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type DTNEntry struct {
|
||
|
|
did string
|
||
|
|
resourceID string // UUID of the resource; empty for non-resource payloads (planner, config)
|
||
|
|
forceCritical bool // true when destination is NANO: all protocols become critical
|
||
|
|
addr pp.AddrInfo
|
||
|
|
dt *tools.DataType
|
||
|
|
user string
|
||
|
|
payload []byte
|
||
|
|
proto protocol.ID
|
||
|
|
retries int
|
||
|
|
addedAt time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
// isEffectivelyCritical returns true when the entry must be retried indefinitely,
|
||
|
|
// either because its protocol is inherently critical or because the destination
|
||
|
|
// is a NANO peer (forceCritical).
|
||
|
|
func (e *DTNEntry) isEffectivelyCritical() bool {
|
||
|
|
return DTNProtocols[e.proto] == DTNCritical || e.forceCritical
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *DTNEntry) toJSON() DTNEntryJSON {
|
||
|
|
return DTNEntryJSON{
|
||
|
|
DID: e.did,
|
||
|
|
ResourceID: e.resourceID,
|
||
|
|
ForceCritical: e.forceCritical,
|
||
|
|
Addr: e.addr,
|
||
|
|
DT: e.dt,
|
||
|
|
User: e.user,
|
||
|
|
Payload: e.payload,
|
||
|
|
Proto: e.proto,
|
||
|
|
Retries: e.retries,
|
||
|
|
AddedAt: e.addedAt,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func entryFromJSON(j DTNEntryJSON) *DTNEntry {
|
||
|
|
return &DTNEntry{
|
||
|
|
did: j.DID,
|
||
|
|
resourceID: j.ResourceID,
|
||
|
|
forceCritical: j.ForceCritical,
|
||
|
|
addr: j.Addr,
|
||
|
|
dt: j.DT,
|
||
|
|
user: j.User,
|
||
|
|
payload: j.Payload,
|
||
|
|
proto: j.Proto,
|
||
|
|
retries: j.Retries,
|
||
|
|
addedAt: j.AddedAt,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type DTNCache struct {
|
||
|
|
mu sync.Mutex
|
||
|
|
entries []*DTNEntry
|
||
|
|
// aesKey is the derived AES-256 key used for on-disk encryption.
|
||
|
|
// Nil when key derivation failed: persistence is disabled but the in-memory
|
||
|
|
// cache continues to function normally.
|
||
|
|
aesKey []byte
|
||
|
|
}
|
||
|
|
|
||
|
|
// newDNTCache initialises the cache, derives the encryption key, and restores
|
||
|
|
// any critical entries that were persisted before the last crash.
|
||
|
|
func newDNTCache() *DTNCache {
|
||
|
|
log := oclib.GetLogger()
|
||
|
|
c := &DTNCache{}
|
||
|
|
key, err := deriveDNTKey()
|
||
|
|
if err != nil {
|
||
|
|
log.Warn().Err(err).Msg("[dnt] key derivation failed — persistence disabled")
|
||
|
|
} else {
|
||
|
|
c.aesKey = key
|
||
|
|
c.loadFromDisk()
|
||
|
|
}
|
||
|
|
return c
|
||
|
|
}
|
||
|
|
|
||
|
|
// extractResourceID returns the "id" field from a JSON resource payload.
|
||
|
|
// Returns "" when the payload is not a resource object (planner, config, etc.).
|
||
|
|
func extractResourceID(payload []byte) string {
|
||
|
|
var obj struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(payload, &obj); err != nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return obj.ID
|
||
|
|
}
|
||
|
|
|
||
|
|
// enqueue adds an entry to the cache, respecting the resource lifecycle.
|
||
|
|
// Deduplication key is (did, resourceID): same resource to the same peer keeps
|
||
|
|
// only the latest mutation. resourceID is empty for non-resource payloads
|
||
|
|
// (planner, config), in which case deduplication falls back to did alone.
|
||
|
|
//
|
||
|
|
// - DELETE is terminal: any subsequent mutation on the same key is discarded.
|
||
|
|
// - UPDATE cannot be followed by CREATE: the resource already exists remotely.
|
||
|
|
// - All other cases replace the existing entry (newer mutation supersedes).
|
||
|
|
func (c *DTNCache) enqueue(e *DTNEntry) {
|
||
|
|
c.mu.Lock()
|
||
|
|
found, mutated := false, false
|
||
|
|
for i, existing := range c.entries {
|
||
|
|
if existing.did != e.did || existing.resourceID != e.resourceID {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
found = true
|
||
|
|
if existing.proto == ProtocolDeleteResource ||
|
||
|
|
(existing.proto == ProtocolUpdateResource && e.proto == ProtocolCreateResource) {
|
||
|
|
break // discard new entry silently — existing state is authoritative
|
||
|
|
}
|
||
|
|
c.entries[i] = e
|
||
|
|
mutated = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
if !found {
|
||
|
|
c.entries = append(c.entries, e)
|
||
|
|
mutated = true
|
||
|
|
}
|
||
|
|
c.mu.Unlock()
|
||
|
|
if mutated && e.isEffectivelyCritical() {
|
||
|
|
go c.persistToDisk()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// peersWithPending returns the distinct peer IDs (did) that have at least one
|
||
|
|
// critical entry in the cache. Used to populate Heartbeat.PendingContact.
|
||
|
|
func (c *DTNCache) peersWithPending() []string {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
seen := map[string]struct{}{}
|
||
|
|
var out []string
|
||
|
|
for _, e := range c.entries {
|
||
|
|
if e.isEffectivelyCritical() {
|
||
|
|
if _, ok := seen[e.did]; !ok {
|
||
|
|
seen[e.did] = struct{}{}
|
||
|
|
out = append(out, e.did)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
// drain atomically removes and returns all current entries.
|
||
|
|
func (c *DTNCache) drain() []*DTNEntry {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
out := c.entries
|
||
|
|
c.entries = nil
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
// requeue puts entries back at the head of the list, preserving any new
|
||
|
|
// entries added while the retry loop was running.
|
||
|
|
func (c *DTNCache) requeue(entries []*DTNEntry) {
|
||
|
|
if len(entries) == 0 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
c.entries = append(entries, c.entries...)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Persistence ──────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
// DTNCachePath returns the path of the on-disk cache file, placed next to the
|
||
|
|
// node's private key so it lives on the same persistent volume.
|
||
|
|
func DTNCachePath() string {
|
||
|
|
return filepath.Join(filepath.Dir(conf.GetConfig().PrivateKeyPath), "dnt_cache.bin")
|
||
|
|
}
|
||
|
|
|
||
|
|
// deriveDNTKey derives a 32-byte AES key from the node's Ed25519 private key
|
||
|
|
// using HKDF-SHA256. The derivation is deterministic: the same key is always
|
||
|
|
// produced from the same private key, so no symmetric secret needs storing.
|
||
|
|
func deriveDNTKey() ([]byte, error) {
|
||
|
|
priv, err := tools.LoadKeyFromFilePrivate()
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
// Raw() on a libp2p Ed25519 private key returns the 64-byte representation
|
||
|
|
// (32-byte seed || 32-byte public key). We use the full 64 bytes as IKM.
|
||
|
|
raw, err := priv.Raw()
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
reader := hkdf.New(sha256.New, raw, nil, []byte("oc-discovery/dnt-cache/v1"))
|
||
|
|
key := make([]byte, 32)
|
||
|
|
if _, err := io.ReadFull(reader, key); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return key, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// persistToDisk encrypts all current critical entries and writes them to disk.
|
||
|
|
// Non-critical entries are deliberately excluded — they are not worth restoring
|
||
|
|
// after a restart given their limited retry budget.
|
||
|
|
func (c *DTNCache) persistToDisk() {
|
||
|
|
if c.aesKey == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
log := oclib.GetLogger()
|
||
|
|
c.mu.Lock()
|
||
|
|
var toSave []DTNEntryJSON
|
||
|
|
for _, e := range c.entries {
|
||
|
|
if e.isEffectivelyCritical() {
|
||
|
|
toSave = append(toSave, e.toJSON())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
c.mu.Unlock()
|
||
|
|
|
||
|
|
plaintext, err := json.Marshal(toSave)
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
block, err := aes.NewCipher(c.aesKey)
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
gcm, err := cipher.NewGCM(block)
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
nonce := make([]byte, gcm.NonceSize())
|
||
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||
|
|
|
||
|
|
path := DTNCachePath()
|
||
|
|
tmp := path + ".tmp"
|
||
|
|
if err := os.WriteFile(tmp, ciphertext, 0600); err != nil {
|
||
|
|
log.Warn().Err(err).Msg("[dnt] failed to write cache file")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if err := os.Rename(tmp, path); err != nil {
|
||
|
|
log.Warn().Err(err).Msg("[dnt] failed to rename cache file")
|
||
|
|
_ = os.Remove(tmp)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// loadFromDisk decrypts the on-disk cache and re-enqueues only critical entries.
|
||
|
|
// Errors (missing file, decryption failure) are non-fatal: the cache simply
|
||
|
|
// starts empty, which is safe.
|
||
|
|
func (c *DTNCache) loadFromDisk() {
|
||
|
|
if c.aesKey == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
log := oclib.GetLogger()
|
||
|
|
path := DTNCachePath()
|
||
|
|
data, err := os.ReadFile(path)
|
||
|
|
if err != nil {
|
||
|
|
if !os.IsNotExist(err) {
|
||
|
|
log.Warn().Err(err).Msg("[dnt] failed to read cache file")
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
block, err := aes.NewCipher(c.aesKey)
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
gcm, err := cipher.NewGCM(block)
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if len(data) < gcm.NonceSize() {
|
||
|
|
log.Warn().Msg("[dnt] cache file too short, ignoring")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():]
|
||
|
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||
|
|
if err != nil {
|
||
|
|
log.Warn().Err(err).Msg("[dnt] cache file decryption failed (key mismatch?), ignoring")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
var saved []DTNEntryJSON
|
||
|
|
if err := json.Unmarshal(plaintext, &saved); err != nil {
|
||
|
|
log.Warn().Err(err).Msg("[dnt] cache file unmarshal failed, ignoring")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
count := 0
|
||
|
|
for _, j := range saved {
|
||
|
|
// Only restore critical entries — moderate entries are intentionally
|
||
|
|
// not persisted, but this guard defends against format changes.
|
||
|
|
e := entryFromJSON(j)
|
||
|
|
if !e.isEffectivelyCritical() {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
c.entries = append(c.entries, e)
|
||
|
|
count++
|
||
|
|
}
|
||
|
|
if count > 0 {
|
||
|
|
log.Info().Int("count", count).Msg("[dnt] restored critical entries from disk")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Retry loop ────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
// startDNTLoop runs the background retry goroutine. Call once after init.
|
||
|
|
func (s *StreamService) startDNTLoop() {
|
||
|
|
logger := oclib.GetLogger()
|
||
|
|
ticker := time.NewTicker(DTNRetryInterval)
|
||
|
|
defer ticker.Stop()
|
||
|
|
|
||
|
|
// retryEntries attempts delivery for the given entries and returns those
|
||
|
|
// that must be kept for the next round.
|
||
|
|
retryEntries := func(entries []*DTNEntry) []*DTNEntry {
|
||
|
|
var keep []*DTNEntry
|
||
|
|
for _, e := range entries {
|
||
|
|
_, err := s.write(e.did, &e.addr, e.dt, e.user, e.payload, e.proto)
|
||
|
|
if err == nil {
|
||
|
|
if e.isEffectivelyCritical() {
|
||
|
|
logger.Info().Str("proto", string(e.proto)).Str("peer", e.did).
|
||
|
|
Msg("[dnt] critical message delivered after retry")
|
||
|
|
} else {
|
||
|
|
logger.Info().Str("proto", string(e.proto)).Str("peer", e.did).
|
||
|
|
Int("retries", e.retries).Msg("[dnt] moderate message delivered after retry")
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if e.isEffectivelyCritical() {
|
||
|
|
keep = append(keep, e)
|
||
|
|
} else {
|
||
|
|
e.retries++
|
||
|
|
if e.retries < DTNMaxModerateRetries {
|
||
|
|
keep = append(keep, e)
|
||
|
|
} else {
|
||
|
|
logger.Warn().Str("proto", string(e.proto)).Str("peer", e.did).
|
||
|
|
Int("retries", e.retries).Msg("[dnt] moderate message abandoned after max retries")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return keep
|
||
|
|
}
|
||
|
|
|
||
|
|
for {
|
||
|
|
select {
|
||
|
|
case <-ticker.C:
|
||
|
|
entries := s.dnt.drain()
|
||
|
|
if len(entries) == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
s.dnt.requeue(retryEntries(entries))
|
||
|
|
go s.dnt.persistToDisk()
|
||
|
|
|
||
|
|
case peerID := <-s.dntNudge:
|
||
|
|
// A peer just signalled it is reachable — retry its entries immediately.
|
||
|
|
entries := s.dnt.drain()
|
||
|
|
var forPeer, other []*DTNEntry
|
||
|
|
for _, e := range entries {
|
||
|
|
if e.did == peerID {
|
||
|
|
forPeer = append(forPeer, e)
|
||
|
|
} else {
|
||
|
|
other = append(other, e)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
kept := retryEntries(forPeer)
|
||
|
|
s.dnt.requeue(append(kept, other...))
|
||
|
|
if len(kept) < len(forPeer) {
|
||
|
|
go s.dnt.persistToDisk()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|