oc-discovery -> conf
This commit is contained in:
@@ -21,21 +21,37 @@ import (
|
||||
lpp "github.com/libp2p/go-libp2p/core/peer"
|
||||
)
|
||||
|
||||
// DefaultTTLSeconds is the default TTL for peer records when the publisher
|
||||
// does not declare a custom TTL. Exported so the node package can reference it.
|
||||
const DefaultTTLSeconds = 120
|
||||
|
||||
// maxTTLSeconds caps how far in the future a publisher can set their ExpiryDate.
|
||||
const maxTTLSeconds = 86400 // 24h
|
||||
|
||||
// tombstoneTTL is how long a signed delete record stays alive in the DHT —
|
||||
// long enough to propagate everywhere, short enough not to linger forever.
|
||||
const tombstoneTTL = 10 * time.Minute
|
||||
|
||||
type PeerRecordPayload struct {
|
||||
Name string `json:"name"`
|
||||
DID string `json:"did"`
|
||||
PubKey []byte `json:"pub_key"`
|
||||
ExpiryDate time.Time `json:"expiry_date"`
|
||||
// TTLSeconds is the publisher's declared lifetime for this record in seconds.
|
||||
// 0 means "use the default (120 s)". Included in the signed payload so it
|
||||
// cannot be altered by an intermediary.
|
||||
TTLSeconds int `json:"ttl_seconds,omitempty"`
|
||||
}
|
||||
|
||||
type PeerRecord struct {
|
||||
PeerRecordPayload
|
||||
PeerID string `json:"peer_id"`
|
||||
APIUrl string `json:"api_url"`
|
||||
StreamAddress string `json:"stream_address"`
|
||||
NATSAddress string `json:"nats_address"`
|
||||
WalletAddress string `json:"wallet_address"`
|
||||
Signature []byte `json:"signature"`
|
||||
PeerID string `json:"peer_id"`
|
||||
APIUrl string `json:"api_url"`
|
||||
StreamAddress string `json:"stream_address"`
|
||||
NATSAddress string `json:"nats_address"`
|
||||
WalletAddress string `json:"wallet_address"`
|
||||
Location *pp.PeerLocation `json:"location,omitempty"`
|
||||
Signature []byte `json:"signature"`
|
||||
}
|
||||
|
||||
func (p *PeerRecord) Sign() error {
|
||||
@@ -84,6 +100,7 @@ func (pr *PeerRecord) ExtractPeer(ourkey string, key string, pubKey crypto.PubKe
|
||||
StreamAddress: pr.StreamAddress,
|
||||
NATSAddress: pr.NATSAddress,
|
||||
WalletAddress: pr.WalletAddress,
|
||||
Location: pr.Location,
|
||||
}
|
||||
if time.Now().UTC().After(pr.ExpiryDate) {
|
||||
return pp.SELF == p.Relation, nil, errors.New("peer " + key + " is offline")
|
||||
@@ -91,6 +108,42 @@ func (pr *PeerRecord) ExtractPeer(ourkey string, key string, pubKey crypto.PubKe
|
||||
return pp.SELF == p.Relation, p, nil
|
||||
}
|
||||
|
||||
// TombstonePayload is the signed body of a delete request.
|
||||
// Only the owner's private key can produce a valid signature over this payload.
|
||||
type TombstonePayload struct {
|
||||
DID string `json:"did"`
|
||||
PeerID string `json:"peer_id"`
|
||||
DeletedAt time.Time `json:"deleted_at"`
|
||||
}
|
||||
|
||||
// TombstoneRecord is stored in the DHT at /node/{DID} to signal that a peer
|
||||
// has voluntarily left the network. The Tombstone bool field acts as a
|
||||
// discriminator so validators can distinguish it from a live PeerRecord.
|
||||
type TombstoneRecord struct {
|
||||
TombstonePayload
|
||||
PubKey []byte `json:"pub_key"`
|
||||
Tombstone bool `json:"tombstone"`
|
||||
Signature []byte `json:"signature"`
|
||||
}
|
||||
|
||||
func (ts *TombstoneRecord) Verify() (crypto.PubKey, error) {
|
||||
pubKey, err := crypto.UnmarshalPublicKey(ts.PubKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, _ := json.Marshal(ts.TombstonePayload)
|
||||
if ok, _ := pubKey.Verify(payload, ts.Signature); !ok {
|
||||
return nil, errors.New("invalid tombstone signature")
|
||||
}
|
||||
return pubKey, nil
|
||||
}
|
||||
|
||||
// isTombstone returns true if data is a valid, well-formed TombstoneRecord.
|
||||
func isTombstone(data []byte) bool {
|
||||
var ts TombstoneRecord
|
||||
return json.Unmarshal(data, &ts) == nil && ts.Tombstone
|
||||
}
|
||||
|
||||
type GetValue struct {
|
||||
Key string `json:"key"`
|
||||
PeerID string `json:"peer_id,omitempty"`
|
||||
@@ -147,9 +200,9 @@ func (ix *IndexerService) isPeerKnown(pid lpp.ID) bool {
|
||||
return false
|
||||
}
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_, err = ix.DHT.GetValue(ctx2, ix.genKey(string(did)))
|
||||
val, err := ix.DHT.GetValue(ctx2, ix.genKey(string(did)))
|
||||
cancel2()
|
||||
return err == nil
|
||||
return err == nil && !isTombstone(val)
|
||||
}
|
||||
|
||||
func (ix *IndexerService) initNodeHandler() {
|
||||
@@ -188,6 +241,18 @@ func (ix *IndexerService) initNodeHandler() {
|
||||
logger.Warn().Err(err).Str("did", rec.DID).Msg("indexer: heartbeat record signature invalid")
|
||||
return
|
||||
}
|
||||
// Don't republish if a tombstone was recently stored for this DID:
|
||||
// the peer explicitly left and we must not re-animate their record.
|
||||
ix.deletedDIDsMu.Lock()
|
||||
if t, ok := ix.deletedDIDs[rec.DID]; ok {
|
||||
if time.Since(t) < tombstoneTTL {
|
||||
ix.deletedDIDsMu.Unlock()
|
||||
return
|
||||
}
|
||||
// tombstoneTTL elapsed — peer is allowed to re-register.
|
||||
delete(ix.deletedDIDs, rec.DID)
|
||||
}
|
||||
ix.deletedDIDsMu.Unlock()
|
||||
// Keep StreamRecord.Record in sync so BuildHeartbeatResponse always
|
||||
// sees a populated PeerRecord (Name, DID, etc.) regardless of whether
|
||||
// handleNodePublish ran before or after the heartbeat stream was opened.
|
||||
@@ -220,6 +285,8 @@ func (ix *IndexerService) initNodeHandler() {
|
||||
ix.Host.SetStreamHandler(common.ProtocolHeartbeat, ix.HandleHeartbeat)
|
||||
ix.Host.SetStreamHandler(common.ProtocolPublish, ix.handleNodePublish)
|
||||
ix.Host.SetStreamHandler(common.ProtocolGet, ix.handleNodeGet)
|
||||
ix.Host.SetStreamHandler(common.ProtocolDelete, ix.handleNodeDelete)
|
||||
ix.Host.SetStreamHandler(common.ProtocolIndirectProbe, ix.handleIndirectProbe)
|
||||
ix.Host.SetStreamHandler(common.ProtocolIndexerCandidates, ix.handleCandidateRequest)
|
||||
ix.initSearchHandlers()
|
||||
}
|
||||
@@ -383,12 +450,12 @@ func (ix *IndexerService) handleNodeGet(s network.Stream) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
c, err := ix.DHT.GetValue(ctx, ix.genKey(key))
|
||||
cancel()
|
||||
if err == nil {
|
||||
if err == nil && !isTombstone(c) {
|
||||
var rec PeerRecord
|
||||
if json.Unmarshal(c, &rec) == nil {
|
||||
resp.Records[rec.PeerID] = rec
|
||||
}
|
||||
} else {
|
||||
} else if err != nil {
|
||||
logger.Err(err).Msg("Failed to fetch PeerRecord from DHT " + key)
|
||||
}
|
||||
}
|
||||
@@ -399,3 +466,121 @@ func (ix *IndexerService) handleNodeGet(s network.Stream) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleNodeDelete processes a signed delete (tombstone) request from a peer.
|
||||
// It verifies that the request is:
|
||||
// - marked as a tombstone
|
||||
// - recent (within 5 minutes, preventing replay attacks)
|
||||
// - sent by the actual peer whose record is being deleted (PeerID == remotePeer)
|
||||
// - signed by the matching private key
|
||||
//
|
||||
// On success it stores the tombstone in the DHT, evicts the peer from the local
|
||||
// stream records, and marks the DID in deletedDIDs so AfterHeartbeat cannot
|
||||
// accidentally republish the record during the tombstoneTTL window.
|
||||
func (ix *IndexerService) handleNodeDelete(s network.Stream) {
|
||||
defer s.Close()
|
||||
logger := oclib.GetLogger()
|
||||
remotePeer := s.Conn().RemotePeer()
|
||||
s.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
var ts TombstoneRecord
|
||||
if err := json.NewDecoder(s).Decode(&ts); err != nil || !ts.Tombstone {
|
||||
s.Reset()
|
||||
return
|
||||
}
|
||||
if ts.PeerID == "" || ts.DID == "" {
|
||||
s.Reset()
|
||||
return
|
||||
}
|
||||
if time.Since(ts.DeletedAt) > 5*time.Minute {
|
||||
logger.Warn().Str("peer", remotePeer.String()).Msg("[delete] stale tombstone rejected")
|
||||
s.Reset()
|
||||
return
|
||||
}
|
||||
if ts.PeerID != remotePeer.String() {
|
||||
logger.Warn().Str("peer", remotePeer.String()).Msg("[delete] tombstone PeerID mismatch")
|
||||
s.Reset()
|
||||
return
|
||||
}
|
||||
if _, err := ts.Verify(); err != nil {
|
||||
logger.Warn().Err(err).Str("peer", remotePeer.String()).Msg("[delete] invalid tombstone signature")
|
||||
s.Reset()
|
||||
return
|
||||
}
|
||||
|
||||
// Mark DID as deleted in-memory before writing to DHT so AfterHeartbeat
|
||||
// cannot win a race and republish the live record on top of the tombstone.
|
||||
ix.deletedDIDsMu.Lock()
|
||||
ix.deletedDIDs[ts.DID] = ts.DeletedAt
|
||||
ix.deletedDIDsMu.Unlock()
|
||||
|
||||
data, _ := json.Marshal(ts)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if err := ix.DHT.PutValue(ctx, ix.genKey(ts.DID), data); err != nil {
|
||||
logger.Warn().Err(err).Str("did", ts.DID).Msg("[delete] DHT write tombstone failed")
|
||||
}
|
||||
cancel()
|
||||
|
||||
// Invalidate the /pid/ secondary index so isPeerKnown returns false quickly.
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if err := ix.DHT.PutValue(ctx2, ix.genPIDKey(ts.PeerID), []byte("")); err != nil {
|
||||
logger.Warn().Err(err).Str("pid", ts.PeerID).Msg("[delete] DHT clear pid failed")
|
||||
}
|
||||
cancel2()
|
||||
|
||||
// Evict from active stream records.
|
||||
if pid, err := lpp.Decode(ts.PeerID); err == nil {
|
||||
ix.StreamMU.Lock()
|
||||
delete(ix.StreamRecords[common.ProtocolHeartbeat], pid)
|
||||
ix.StreamMU.Unlock()
|
||||
}
|
||||
|
||||
logger.Info().Str("did", ts.DID).Str("peer", ts.PeerID).Msg("[delete] tombstone stored, peer evicted")
|
||||
}
|
||||
|
||||
// handleIndirectProbe is the SWIM inter-indexer probe handler.
|
||||
// A node opens this stream toward a live indexer to ask: "can you reach peer X?"
|
||||
// The indexer attempts a ProtocolBandwidthProbe to X and reports back.
|
||||
// This is the only protocol that indexers use to communicate with each other;
|
||||
// no persistent inter-indexer connections are maintained.
|
||||
func (ix *IndexerService) handleIndirectProbe(s network.Stream) {
|
||||
defer s.Close()
|
||||
s.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
var req common.IndirectProbeRequest
|
||||
if err := json.NewDecoder(s).Decode(&req); err != nil {
|
||||
s.Reset()
|
||||
return
|
||||
}
|
||||
|
||||
respond := func(reachable bool, latencyMs int64) {
|
||||
json.NewEncoder(s).Encode(common.IndirectProbeResponse{
|
||||
Reachable: reachable,
|
||||
LatencyMs: latencyMs,
|
||||
})
|
||||
}
|
||||
|
||||
// Connect to target if not already connected.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
||||
defer cancel()
|
||||
if ix.Host.Network().Connectedness(req.Target.ID) != network.Connected {
|
||||
if err := ix.Host.Connect(ctx, req.Target); err != nil {
|
||||
respond(false, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Open a bandwidth probe stream — already registered on all nodes/indexers.
|
||||
start := time.Now()
|
||||
ps, err := ix.Host.NewStream(ctx, req.Target.ID, common.ProtocolBandwidthProbe)
|
||||
if err != nil {
|
||||
respond(false, 0)
|
||||
return
|
||||
}
|
||||
defer ps.Reset()
|
||||
ps.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
ps.Write([]byte("ping"))
|
||||
buf := make([]byte, 4)
|
||||
_, err = ps.Read(buf)
|
||||
latency := time.Since(start).Milliseconds()
|
||||
respond(err == nil, latency)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user