diff --git a/Makefile b/Makefile index e7c44da4..ad855f4d 100644 --- a/Makefile +++ b/Makefile @@ -10,9 +10,11 @@ GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") BUILD_TIME ?= $(shell date -u '+%Y-%m-%d_%H:%M:%S') # Linker flags for version information +# Optional minimum peer version for DHT gating can be provided via MIN_VER env/make var LDFLAGS = -X github.com/LumeraProtocol/supernode/v2/supernode/cmd.Version=$(VERSION) \ -X github.com/LumeraProtocol/supernode/v2/supernode/cmd.GitCommit=$(GIT_COMMIT) \ -X github.com/LumeraProtocol/supernode/v2/supernode/cmd.BuildTime=$(BUILD_TIME) \ + -X github.com/LumeraProtocol/supernode/v2/supernode/cmd.MinVer=$(MIN_VER) \ -X github.com/LumeraProtocol/supernode/v2/pkg/logtrace.DDAPIKey=$(DD_API_KEY) \ -X github.com/LumeraProtocol/supernode/v2/pkg/logtrace.DDSite=$(DD_SITE) diff --git a/p2p/kademlia/dht.go b/p2p/kademlia/dht.go index 7048a727..a430de0b 100644 --- a/p2p/kademlia/dht.go +++ b/p2p/kademlia/dht.go @@ -501,7 +501,7 @@ func (s *DHT) newMessage(messageType int, receiver *Node, data interface{}) *Mes IP: hostIP, ID: s.ht.self.ID, Port: s.ht.self.Port, - Version: requiredVersion(), + Version: localVersion(), } return &Message{ Sender: sender, @@ -1399,21 +1399,21 @@ func (s *DHT) sendStoreData(ctx context.Context, n *Node, request *StoreDataRequ // add a node into the appropriate k bucket, return the removed node if it's full func (s *DHT) addNode(ctx context.Context, node *Node) *Node { - // Strict version gating: must match env and be non-empty. + // Minimum-version gating: reject nodes below configured minimum. peerVer := "" if node != nil { peerVer = node.Version } - if required, mismatch := versionMismatch(peerVer); mismatch { + if minRequired, tooOld := versionTooOld(peerVer); tooOld { fields := logtrace.Fields{ logtrace.FieldModule: "p2p", - "required": required, + "min_required": minRequired, "peer_version": strings.TrimSpace(peerVer), } if node != nil { fields["peer"] = node.String() } - logtrace.Debug(ctx, "Rejecting node due to version mismatch", fields) + logtrace.Debug(ctx, "Rejecting node: peer below minimum version", fields) return nil } // Allow localhost for integration testing diff --git a/p2p/kademlia/network.go b/p2p/kademlia/network.go index cb1ff928..a5ae39ee 100644 --- a/p2p/kademlia/network.go +++ b/p2p/kademlia/network.go @@ -415,15 +415,15 @@ func (s *Network) handleConn(ctx context.Context, rawConn net.Conn) { } } - // Strict version gating: reject immediately on mismatch or missing + // Minimum-version gating: reject immediately if peer is below configured minimum var senderVer string if request != nil && request.Sender != nil { senderVer = request.Sender.Version } - if required, mismatch := versionMismatch(senderVer); mismatch { - logtrace.Debug(ctx, "Rejecting connection due to version mismatch", logtrace.Fields{ + if minRequired, tooOld := versionTooOld(senderVer); tooOld { + logtrace.Debug(ctx, "Rejecting connection: peer below minimum version", logtrace.Fields{ logtrace.FieldModule: "p2p", - "required": required, + "min_required": minRequired, "peer_version": strings.TrimSpace(senderVer), }) return diff --git a/p2p/kademlia/node.go b/p2p/kademlia/node.go index ed37d4be..0011c8be 100644 --- a/p2p/kademlia/node.go +++ b/p2p/kademlia/node.go @@ -23,8 +23,8 @@ type Node struct { // port of the node Port uint16 `json:"port,omitempty"` - // Version of the supernode binary (used for strict DHT gating) - Version string `json:"version,omitempty"` + // Version of the supernode binary (advertised to peers; may be used by min-version gating) + Version string `json:"version,omitempty"` HashedID []byte } diff --git a/p2p/kademlia/version_gate.go b/p2p/kademlia/version_gate.go index 74c7dc77..d2d1a755 100644 --- a/p2p/kademlia/version_gate.go +++ b/p2p/kademlia/version_gate.go @@ -1,34 +1,112 @@ package kademlia import ( - "os" + "strconv" "strings" ) -var requiredVer string +// localVer is the advertised version of this binary (e.g., v1.2.3), +// injected by the caller (supernode/cmd) at startup. +var localVer string -// SetRequiredVersion sets the version that peers must match to be accepted. -func SetRequiredVersion(v string) { - requiredVer = strings.TrimSpace(v) +// minVer is the optional minimum peer version to accept. If empty, gating is disabled. +var minVer string + +// SetLocalVersion sets the version this node advertises to peers. +func SetLocalVersion(v string) { + localVer = strings.TrimSpace(v) } -// requiredVersion returns the configured required version (build-time injected by caller). -func requiredVersion() string { - return requiredVer +// SetMinVersion sets the optional minimum required peer version for DHT interactions. +// When empty, version gating is disabled and all peers are accepted regardless of version string. +func SetMinVersion(v string) { + minVer = strings.TrimSpace(v) } -// versionMismatch determines if the given peer version is unacceptable. -// Policy: required and peer must both be non-empty and exactly equal. -func versionMismatch(peerVersion string) (required string, mismatch bool) { - required = requiredVersion() - // Bypass strict gating during integration tests. - // Tests set os.Setenv("INTEGRATION_TEST", "true"). - if os.Getenv("INTEGRATION_TEST") == "true" { - return required, false +// localVersion returns the configured advertised version. +func localVersion() string { return localVer } + +// minimumVersion returns the configured minimum acceptable version; empty disables gating. +func minimumVersion() string { return minVer } + +// versionTooOld reports whether the peerVersion is below the configured minimum version. +// If no minimum is configured, gating is disabled and this returns ("", false). +func versionTooOld(peerVersion string) (minRequired string, tooOld bool) { + minRequired = minimumVersion() + if strings.TrimSpace(minRequired) == "" { + // Gating disabled + return "", false + } + + // Normalize inputs (strip leading 'v' and pre-release/build metadata) + p, okP := parseSemver(peerVersion) + m, okM := parseSemver(minRequired) + if !okM { + // Misconfigured minimum; disable gating to avoid accidental network splits. + return "", false + } + if !okP { + // Peer did not provide a valid version; treat as too old under a min-version policy. + return minRequired, true + } + // Compare peer >= min + if p[0] < m[0] { + return minRequired, true + } + if p[0] > m[0] { + return minRequired, false + } + if p[1] < m[1] { + return minRequired, true + } + if p[1] > m[1] { + return minRequired, false + } + if p[2] < m[2] { + return minRequired, true + } + return minRequired, false +} + +// parseSemver parses versions like "v1.2.3", "1.2.3-alpha" into [major, minor, patch]. +// Returns ok=false if no numeric major part is found. +func parseSemver(v string) ([3]int, bool) { + var out [3]int + s := strings.TrimSpace(v) + if s == "" { + return out, false + } + if s[0] == 'v' || s[0] == 'V' { + s = s[1:] + } + // Drop pre-release/build metadata + if i := strings.IndexAny(s, "-+"); i >= 0 { + s = s[:i] + } + parts := strings.Split(s, ".") + if len(parts) == 0 { + return out, false } - peer := strings.TrimSpace(peerVersion) - if required == "" || peer == "" || peer != required { - return required, true + // Parse up to 3 numeric parts; missing parts default to 0 + for i := 0; i < len(parts) && i < 3; i++ { + numStr := parts[i] + // Trim non-digit suffixes (e.g., "1rc1" -> "1") + j := 0 + for j < len(numStr) && numStr[j] >= '0' && numStr[j] <= '9' { + j++ + } + if j == 0 { + // No leading digits + if i == 0 { + return out, false + } + break + } + n, err := strconv.Atoi(numStr[:j]) + if err != nil { + return out, false + } + out[i] = n } - return required, false + return out, true } diff --git a/sdk/helpers/github_helper.go b/sdk/helpers/github_helper.go deleted file mode 100644 index 0c028c55..00000000 --- a/sdk/helpers/github_helper.go +++ /dev/null @@ -1,37 +0,0 @@ -package helpers - -import ( - "os" - "strings" - "sync" - - gh "github.com/LumeraProtocol/supernode/v2/pkg/github" -) - -var ( - requiredSupernodeVersion string - requiredVersionOnce sync.Once -) - -// ResolveRequiredSupernodeVersion returns the latest stable SuperNode tag from GitHub. -// The value is fetched once per process and cached. If lookup fails, it returns -// an empty string so callers can gracefully skip strict version gating. -func ResolveRequiredSupernodeVersion() string { - // Bypass strict version gating during integration tests. - if os.Getenv("INTEGRATION_TEST") == "true" { - return "" - } - requiredVersionOnce.Do(func() { - client := gh.NewClient("LumeraProtocol/supernode") - if client != nil { - if release, err := client.GetLatestStableRelease(); err == nil { - if tag := strings.TrimSpace(release.TagName); tag != "" { - requiredSupernodeVersion = tag - return - } - } - } - requiredSupernodeVersion = "" - }) - return requiredSupernodeVersion -} diff --git a/sdk/task/task.go b/sdk/task/task.go index 7dd72e8f..1779a93a 100644 --- a/sdk/task/task.go +++ b/sdk/task/task.go @@ -13,7 +13,6 @@ import ( "github.com/LumeraProtocol/supernode/v2/sdk/adapters/lumera" "github.com/LumeraProtocol/supernode/v2/sdk/config" "github.com/LumeraProtocol/supernode/v2/sdk/event" - "github.com/LumeraProtocol/supernode/v2/sdk/helpers" "github.com/LumeraProtocol/supernode/v2/sdk/log" "github.com/LumeraProtocol/supernode/v2/sdk/net" "google.golang.org/grpc/health/grpc_health_v1" @@ -183,10 +182,7 @@ func (t *BaseTask) fetchSupernodesWithLoads(ctx context.Context, height int64) ( t.logger.Info(cctx, "reject supernode: status fetch failed", "error", err) return nil } - if reqVer := helpers.ResolveRequiredSupernodeVersion(); reqVer != "" && status.Version != reqVer { - t.logger.Info(cctx, "reject supernode: version mismatch", "expected", reqVer, "got", status.Version) - return nil - } + // Removed SDK-level version gating; rely on network/node policies instead. // Compute load from running tasks (sum of task_count across services) total := 0 diff --git a/supernode/cascade/register.go b/supernode/cascade/register.go index a9b44117..926f9b31 100644 --- a/supernode/cascade/register.go +++ b/supernode/cascade/register.go @@ -25,97 +25,97 @@ type RegisterResponse struct { } func (task *CascadeRegistrationTask) Register( - ctx context.Context, - req *RegisterRequest, - send func(resp *RegisterResponse) error, + ctx context.Context, + req *RegisterRequest, + send func(resp *RegisterResponse) error, ) (err error) { - // Step 1: Correlate context and capture task identity - if req != nil && req.ActionID != "" { - ctx = logtrace.CtxWithCorrelationID(ctx, req.ActionID) - ctx = logtrace.CtxWithOrigin(ctx, "first_pass") - task.taskID = req.TaskID - } - - // Step 2: Log request and ensure uploaded file cleanup - fields := logtrace.Fields{logtrace.FieldMethod: "Register", logtrace.FieldRequest: req} - logtrace.Info(ctx, "register: request", fields) - defer func() { - if req != nil && req.FilePath != "" { - if remErr := os.RemoveAll(req.FilePath); remErr != nil { - logtrace.Warn(ctx, "Failed to remove uploaded file", fields) - } else { - logtrace.Debug(ctx, "Uploaded file cleaned up", fields) - } - } - }() - - // Step 3: Fetch the action details - action, err := task.fetchAction(ctx, req.ActionID, fields) - if err != nil { - return err - } - fields[logtrace.FieldBlockHeight] = action.BlockHeight + // Step 1: Correlate context and capture task identity + if req != nil && req.ActionID != "" { + ctx = logtrace.CtxWithCorrelationID(ctx, req.ActionID) + ctx = logtrace.CtxWithOrigin(ctx, "first_pass") + task.taskID = req.TaskID + } + + // Step 2: Log request and ensure uploaded file cleanup + fields := logtrace.Fields{logtrace.FieldMethod: "Register", logtrace.FieldRequest: req} + logtrace.Info(ctx, "register: request", fields) + defer func() { + if req != nil && req.FilePath != "" { + if remErr := os.RemoveAll(req.FilePath); remErr != nil { + logtrace.Warn(ctx, "Failed to remove uploaded file", fields) + } else { + logtrace.Debug(ctx, "Uploaded file cleaned up", fields) + } + } + }() + + // Step 3: Fetch the action details + action, err := task.fetchAction(ctx, req.ActionID, fields) + if err != nil { + return err + } + fields[logtrace.FieldBlockHeight] = action.BlockHeight fields[logtrace.FieldCreator] = action.Creator fields[logtrace.FieldStatus] = action.State fields[logtrace.FieldPrice] = action.Price logtrace.Info(ctx, "register: action fetched", fields) task.streamEvent(SupernodeEventTypeActionRetrieved, "Action retrieved", "", send) - // Step 4: Verify action fee based on data size (rounded up to KB) - if err := task.verifyActionFee(ctx, action, req.DataSize, fields); err != nil { - return err - } + // Step 4: Verify action fee based on data size (rounded up to KB) + if err := task.verifyActionFee(ctx, action, req.DataSize, fields); err != nil { + return err + } logtrace.Info(ctx, "register: fee verified", fields) task.streamEvent(SupernodeEventTypeActionFeeVerified, "Action fee verified", "", send) - // Step 5: Ensure this node is eligible (top supernode for block) - fields[logtrace.FieldSupernodeState] = task.SupernodeAccountAddress - if err := task.ensureIsTopSupernode(ctx, uint64(action.BlockHeight), fields); err != nil { - return err - } + // Step 5: Ensure this node is eligible (top supernode for block) + fields[logtrace.FieldSupernodeState] = task.SupernodeAccountAddress + if err := task.ensureIsTopSupernode(ctx, uint64(action.BlockHeight), fields); err != nil { + return err + } logtrace.Info(ctx, "register: top supernode confirmed", fields) task.streamEvent(SupernodeEventTypeTopSupernodeCheckPassed, "Top supernode eligibility confirmed", "", send) - // Step 6: Decode Cascade metadata from the action - cascadeMeta, err := cascadekit.UnmarshalCascadeMetadata(action.Metadata) - if err != nil { - return task.wrapErr(ctx, "failed to unmarshal cascade metadata", err, fields) - } + // Step 6: Decode Cascade metadata from the action + cascadeMeta, err := cascadekit.UnmarshalCascadeMetadata(action.Metadata) + if err != nil { + return task.wrapErr(ctx, "failed to unmarshal cascade metadata", err, fields) + } logtrace.Info(ctx, "register: metadata decoded", fields) task.streamEvent(SupernodeEventTypeMetadataDecoded, "Cascade metadata decoded", "", send) - // Step 7: Verify request-provided data hash matches metadata - if err := cascadekit.VerifyB64DataHash(req.DataHash, cascadeMeta.DataHash); err != nil { - return err - } + // Step 7: Verify request-provided data hash matches metadata + if err := cascadekit.VerifyB64DataHash(req.DataHash, cascadeMeta.DataHash); err != nil { + return err + } logtrace.Debug(ctx, "request data-hash has been matched with the action data-hash", fields) logtrace.Info(ctx, "register: data hash matched", fields) task.streamEvent(SupernodeEventTypeDataHashVerified, "Data hash verified", "", send) - // Step 8: Encode input using the RQ codec to produce layout and symbols - encodeResult, err := task.encodeInput(ctx, req.ActionID, req.FilePath, fields) - if err != nil { - return err - } + // Step 8: Encode input using the RQ codec to produce layout and symbols + encodeResult, err := task.encodeInput(ctx, req.ActionID, req.FilePath, fields) + if err != nil { + return err + } fields["symbols_dir"] = encodeResult.SymbolsDir logtrace.Info(ctx, "register: input encoded", fields) task.streamEvent(SupernodeEventTypeInputEncoded, "Input encoded", "", send) - // Step 9: Verify index and layout signatures; produce layoutB64 - logtrace.Info(ctx, "register: verify+decode layout start", fields) - indexFile, layoutB64, vErr := task.validateIndexAndLayout(ctx, action.Creator, cascadeMeta.Signatures, encodeResult.Layout) - if vErr != nil { - return task.wrapErr(ctx, "signature or index validation failed", vErr, fields) - } - layoutSignatureB64 := indexFile.LayoutSignature - logtrace.Info(ctx, "register: signature verified", fields) - task.streamEvent(SupernodeEventTypeSignatureVerified, "Signature verified", "", send) - - // Step 10: Generate RQID files (layout and index) and compute IDs - rqIDs, idFiles, err := task.generateRQIDFiles(ctx, cascadeMeta, layoutSignatureB64, layoutB64, fields) - if err != nil { - return err - } + // Step 9: Verify index and layout signatures; produce layoutB64 + logtrace.Info(ctx, "register: verify+decode layout start", fields) + indexFile, layoutB64, vErr := task.validateIndexAndLayout(ctx, action.Creator, cascadeMeta.Signatures, encodeResult.Layout) + if vErr != nil { + return task.wrapErr(ctx, "signature or index validation failed", vErr, fields) + } + layoutSignatureB64 := indexFile.LayoutSignature + logtrace.Info(ctx, "register: signature verified", fields) + task.streamEvent(SupernodeEventTypeSignatureVerified, "Signature verified", "", send) + + // Step 10: Generate RQID files (layout and index) and compute IDs + rqIDs, idFiles, err := task.generateRQIDFiles(ctx, cascadeMeta, layoutSignatureB64, layoutB64, fields) + if err != nil { + return err + } // Calculate combined size of all index and layout files totalSize := 0 @@ -134,29 +134,29 @@ func (task *CascadeRegistrationTask) Register( logtrace.Info(ctx, "register: rqids validated", fields) task.streamEvent(SupernodeEventTypeRqIDsVerified, "RQIDs verified", "", send) - // Step 11: Simulate finalize to ensure the tx will succeed - if _, err := task.LumeraClient.SimulateFinalizeAction(ctx, action.ActionID, rqIDs); err != nil { - fields[logtrace.FieldError] = err.Error() - logtrace.Info(ctx, "register: finalize simulation failed", fields) - task.streamEvent(SupernodeEventTypeFinalizeSimulationFailed, "Finalize simulation failed", "", send) - return task.wrapErr(ctx, "finalize action simulation failed", err, fields) - } + // Step 11: Simulate finalize to ensure the tx will succeed + if _, err := task.LumeraClient.SimulateFinalizeAction(ctx, action.ActionID, rqIDs); err != nil { + fields[logtrace.FieldError] = err.Error() + logtrace.Info(ctx, "register: finalize simulation failed", fields) + task.streamEvent(SupernodeEventTypeFinalizeSimulationFailed, "Finalize simulation failed", "", send) + return task.wrapErr(ctx, "finalize action simulation failed", err, fields) + } logtrace.Info(ctx, "register: finalize simulation passed", fields) task.streamEvent(SupernodeEventTypeFinalizeSimulated, "Finalize simulation passed", "", send) - // Step 12: Store artefacts to the network store - if err := task.storeArtefacts(ctx, action.ActionID, idFiles, encodeResult.SymbolsDir, fields); err != nil { - return err - } - task.emitArtefactsStored(ctx, fields, encodeResult.Layout, send) - - // Step 13: Finalize the action on-chain - resp, err := task.LumeraClient.FinalizeAction(ctx, action.ActionID, rqIDs) - if err != nil { - fields[logtrace.FieldError] = err.Error() - logtrace.Info(ctx, "register: finalize action error", fields) - return task.wrapErr(ctx, "failed to finalize action", err, fields) - } + // Step 12: Store artefacts to the network store + if err := task.storeArtefacts(ctx, action.ActionID, idFiles, encodeResult.SymbolsDir, fields); err != nil { + return err + } + task.emitArtefactsStored(ctx, fields, encodeResult.Layout, send) + + // Step 13: Finalize the action on-chain + resp, err := task.LumeraClient.FinalizeAction(ctx, action.ActionID, rqIDs) + if err != nil { + fields[logtrace.FieldError] = err.Error() + logtrace.Info(ctx, "register: finalize action error", fields) + return task.wrapErr(ctx, "failed to finalize action", err, fields) + } txHash := resp.TxResponse.TxHash fields[logtrace.FieldTxHash] = txHash logtrace.Info(ctx, "register: action finalized", fields) diff --git a/supernode/cmd/start.go b/supernode/cmd/start.go index 44722f24..f2d81467 100644 --- a/supernode/cmd/start.go +++ b/supernode/cmd/start.go @@ -34,7 +34,7 @@ import ( pbsupernode "github.com/LumeraProtocol/supernode/v2/gen/supernode" - // Configure DHT version gating from build-injected Version + // Configure DHT advertised/minimum versions from build-time variables "github.com/LumeraProtocol/supernode/v2/p2p/kademlia" ) @@ -48,8 +48,12 @@ The supernode will connect to the Lumera network and begin participating in the // Initialize logging logtrace.Setup("supernode") - // Set strict DHT required version from build-time injected variable - kademlia.SetRequiredVersion(Version) + // Advertise our binary version to peers + kademlia.SetLocalVersion(Version) + // Optionally enforce a minimum peer version if provided at build time + if strings.TrimSpace(MinVer) != "" { + kademlia.SetMinVersion(MinVer) + } // Create context with correlation ID for tracing ctx := logtrace.CtxWithCorrelationID(context.Background(), "supernode-start") diff --git a/supernode/cmd/version.go b/supernode/cmd/version.go index e6d085d8..9daaabc8 100644 --- a/supernode/cmd/version.go +++ b/supernode/cmd/version.go @@ -11,6 +11,8 @@ var ( Version = "dev" GitCommit = "unknown" BuildTime = "unknown" + // Optional: minimum peer version for DHT gating (empty disables gating) + MinVer = "" ) // versionCmd represents the version command