diff --git a/pkg/api_gateway.go b/pkg/api_gateway.go index 9a6fc9e8..e179987e 100644 --- a/pkg/api_gateway.go +++ b/pkg/api_gateway.go @@ -16,6 +16,9 @@ type SubstrateGateway interface { EnsureAccount(activationURL []string, termsAndConditionsLink string, termsAndConditionsHash string) (info substrate.AccountInfo, err error) GetContract(id uint64) (substrate.Contract, SubstrateError) GetContractIDByNameRegistration(name string) (uint64, SubstrateError) + // GetCouncilMembers returns the account ids of the current council members. Used to + // authorize council-driven (ops) deployment migrations without the owner's key. + GetCouncilMembers() ([]types.AccountID, SubstrateError) GetFarm(id uint32) (substrate.Farm, error) GetNode(id uint32) (substrate.Node, error) GetNodeByTwinID(twin uint32) (uint32, SubstrateError) diff --git a/pkg/provision/engine.go b/pkg/provision/engine.go index f2264587..e0eeaf3f 100644 --- a/pkg/provision/engine.go +++ b/pkg/provision/engine.go @@ -1195,9 +1195,12 @@ func (n *NativeEngine) PrepareDeployment(twin uint32, deployment gridtypes.Deplo return err } - if err := deployment.Verify(n.twins); err != nil { - return err - } + // NOTE: no owner-signature check here (unlike CreateOrUpdate). A migration is authorized by + // the CHAIN: n.Prepare -> validate() enforces that the contract names THIS node and that its + // on-chain deployment_hash equals this deployment's ChallengeHash. Only the council can point + // a contract at a node and set that hash (migrate_node_contract), and the RMB caller was + // already authorized as the owner or a council member in the API handler. This lets ops move + // a VM without the owner's key — the deployment keeps its original owner twin. ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() diff --git a/pkg/stubs/api_gateway_stub.go b/pkg/stubs/api_gateway_stub.go index f2d70684..21b8aacf 100644 --- a/pkg/stubs/api_gateway_stub.go +++ b/pkg/stubs/api_gateway_stub.go @@ -115,6 +115,23 @@ func (s *SubstrateGatewayStub) GetContractIDByNameRegistration(ctx context.Conte return } +func (s *SubstrateGatewayStub) GetCouncilMembers(ctx context.Context) (ret0 []types.AccountID, ret1 pkg.SubstrateError) { + args := []interface{}{} + result, err := s.client.RequestContext(ctx, s.module, s.object, "GetCouncilMembers", args...) + if err != nil { + panic(err) + } + result.PanicOnError() + loader := zbus.Loader{ + &ret0, + &ret1, + } + if err := result.Unmarshal(&loader); err != nil { + panic(err) + } + return +} + func (s *SubstrateGatewayStub) GetFarm(ctx context.Context, arg0 uint32) (ret0 tfchainclientgo.Farm, ret1 error) { args := []interface{}{arg0} result, err := s.client.RequestContext(ctx, s.module, s.object, "GetFarm", args...) diff --git a/pkg/substrate_gateway/substrate_gateway.go b/pkg/substrate_gateway/substrate_gateway.go index 23f7e673..b3071e85 100644 --- a/pkg/substrate_gateway/substrate_gateway.go +++ b/pkg/substrate_gateway/substrate_gateway.go @@ -72,6 +72,37 @@ func (g *substrateGateway) GetZosVersion() (string, error) { return result, err } +func (g *substrateGateway) GetCouncilMembers() (result []types.AccountID, serr pkg.SubstrateError) { + log.Trace().Str("method", "GetCouncilMembers").Msg("method called") + + err := backoff.Retry(func() error { + cl, meta, retryErr := g.sub.GetClient() + if retryErr != nil { + return retryErr + } + key, retryErr := types.CreateStorageKey(meta, "Council", "Members") + if retryErr != nil { + // a metadata/pallet-name problem won't fix itself on retry + return backoff.Permanent(retryErr) + } + var members []types.AccountID + ok, retryErr := cl.RPC.State.GetStorageLatest(key, &members) + if retryErr != nil { + log.Debug().Err(retryErr).Msg("GetCouncilMembers failed, retrying") + return retryErr + } + if !ok { + result = nil + return nil + } + result = members + return nil + }, createBackoff()) + + serr = buildSubstrateError(err) + return +} + func (g *substrateGateway) CreateNode(node substrate.Node) (uint32, error) { log.Debug(). Str("method", "CreateNode"). diff --git a/pkg/zos_api/deployment.go b/pkg/zos_api/deployment.go index 28895026..2fee0269 100644 --- a/pkg/zos_api/deployment.go +++ b/pkg/zos_api/deployment.go @@ -1,6 +1,7 @@ package zosapi import ( + "bytes" "context" "encoding/json" "fmt" @@ -62,8 +63,22 @@ func (g *ZosAPI) deploymentGetHandler(ctx context.Context, payload []byte) (inte return nil, err } - return g.provisionStub.Get(ctx, peer.GetTwinID(ctx), args.ContractID) - + // Fast path (unchanged behavior): the caller reads its own deployment — no chain lookup. + twin := peer.GetTwinID(ctx) + if dl, err := g.provisionStub.Get(ctx, twin, args.ContractID); err == nil { + return dl, nil + } + // Slow path: not the caller's own deployment. Allow an ops/council read of another owner's + // deployment (needed to drive a keyless migration) — resolve the owner from the on-chain + // contract and authorize the caller as the owner or a council member. + owner, oerr := g.ownerOfContract(ctx, args.ContractID) + if oerr != nil { + return nil, oerr + } + if aerr := g.authorizeMigration(ctx, owner); aerr != nil { + return nil, aerr + } + return g.provisionStub.Get(ctx, owner, args.ContractID) } func (g *ZosAPI) deploymentListHandler(ctx context.Context, payload []byte) (interface{}, error) { @@ -86,6 +101,42 @@ func (g *ZosAPI) deploymentChangesHandler(ctx context.Context, payload []byte) ( // source deployment for a consistent copy, then uploads each requested workload // to a caller-provided presigned S3 URL (HTTP PUT). Used on the OLD node during // a contract move. +// authorizeMigration authorizes a council-driven (ops) migration op on a deployment owned by +// ownerTwin. The RMB caller must be either the owner itself or a current council member — +// council already governs the on-chain contract move (migrate_node_contract), so it may also +// drive the node-side data move. This is what lets ops migrate a VM without the owner's key. +// It returns nil if authorized. +func (g *ZosAPI) authorizeMigration(ctx context.Context, ownerTwin uint32) error { + caller := peer.GetTwinID(ctx) + if caller == ownerTwin { + return nil + } + callerTwin, err := g.substrateGatewayStub.GetTwin(ctx, caller) + if err != nil { + return fmt.Errorf("failed to resolve caller twin %d: %w", caller, err) + } + members, serr := g.substrateGatewayStub.GetCouncilMembers(ctx) + if serr.IsError() { + return fmt.Errorf("failed to fetch council members: %w", serr.Err) + } + callerPk := callerTwin.Account.PublicKey() + for _, m := range members { + if bytes.Equal(m[:], callerPk) { + return nil + } + } + return fmt.Errorf("caller twin %d is neither the deployment owner (twin %d) nor a council member", caller, ownerTwin) +} + +// ownerOfContract resolves the owner twin of a node contract from chain. +func (g *ZosAPI) ownerOfContract(ctx context.Context, contractID uint64) (uint32, error) { + contract, serr := g.substrateGatewayStub.GetContract(ctx, contractID) + if serr.IsError() { + return 0, fmt.Errorf("failed to get contract %d: %w", contractID, serr.Err) + } + return uint32(contract.TwinID), nil +} + func (g *ZosAPI) deploymentTransferHandler(ctx context.Context, payload []byte) (interface{}, error) { var args struct { ContractID uint64 `json:"contract_id"` @@ -95,7 +146,16 @@ func (g *ZosAPI) deploymentTransferHandler(ctx context.Context, payload []byte) return nil, err } - twin := peer.GetTwinID(ctx) + // Owner-agnostic: resolve the owner from the contract and authorize the caller as the + // owner or a council member. All storage ops below use the OWNER twin (deployments are + // stored per-owner), so an ops/council caller can transfer a VM it does not own. + twin, err := g.ownerOfContract(ctx, args.ContractID) + if err != nil { + return nil, err + } + if err := g.authorizeMigration(ctx, twin); err != nil { + return nil, err + } deployment, err := g.provisionStub.Get(ctx, twin, args.ContractID) if err != nil { return nil, err @@ -137,7 +197,13 @@ func (g *ZosAPI) deploymentPrepareHandler(ctx context.Context, payload []byte) ( return nil, err } - twin := peer.GetTwinID(ctx) + // The deployment carries its owner twin; authorize the caller as the owner or a council + // member (the on-chain contract hash, set by the council move, is the real authority — see + // engine.validate). The owner twin is used for the (per-owner) provisioning. + twin := args.Deployment.TwinID + if err := g.authorizeMigration(ctx, twin); err != nil { + return nil, err + } // resolve + validate the requested workloads up front (synchronous errors) jobs, err := resolveTransferJobs(&args.Deployment, args.Downloads) @@ -168,7 +234,14 @@ func (g *ZosAPI) deploymentStartHandler(ctx context.Context, payload []byte) (in if err := json.Unmarshal(payload, &args); err != nil { return nil, err } - return nil, g.provisionStub.StartDeployment(ctx, peer.GetTwinID(ctx), args.ContractID) + twin, err := g.ownerOfContract(ctx, args.ContractID) + if err != nil { + return nil, err + } + if err := g.authorizeMigration(ctx, twin); err != nil { + return nil, err + } + return nil, g.provisionStub.StartDeployment(ctx, twin, args.ContractID) } // waitWorkloadProvisioned blocks until the workload with the given global id diff --git a/pkg/zos_api/zos_api.go b/pkg/zos_api/zos_api.go index 469abf31..a343a6f4 100644 --- a/pkg/zos_api/zos_api.go +++ b/pkg/zos_api/zos_api.go @@ -30,6 +30,7 @@ type ZosAPI struct { statisticsStub *stubs.StatisticsStub storageStub *stubs.StorageModuleStub performanceMonitorStub *stubs.PerformanceMonitorStub + substrateGatewayStub *stubs.SubstrateGatewayStub diagnosticsManager *diagnostics.DiagnosticsManager farmerID uint32 inMemCache *cache.Cache @@ -56,6 +57,7 @@ func NewZosAPI(manager substrate.Manager, client zbus.Client, msgBrokerCon strin statisticsStub: stubs.NewStatisticsStub(client), storageStub: storageModuleStub, performanceMonitorStub: stubs.NewPerformanceMonitorStub(client), + substrateGatewayStub: stubs.NewSubstrateGatewayStub(client), diagnosticsManager: diagnosticsManager, } exp := backoff.NewExponentialBackOff() diff --git a/pkg/zos_api_light/deployment.go b/pkg/zos_api_light/deployment.go index 6c2dde52..052b1601 100644 --- a/pkg/zos_api_light/deployment.go +++ b/pkg/zos_api_light/deployment.go @@ -1,6 +1,7 @@ package zosapi import ( + "bytes" "context" "encoding/json" "fmt" @@ -64,8 +65,20 @@ func (g *ZosAPI) deploymentGetHandler(ctx context.Context, payload []byte) (inte return nil, err } - return g.provisionStub.Get(ctx, peer.GetTwinID(ctx), args.ContractID) - + // fast path (unchanged): the caller reads its own deployment — no chain lookup + twin := peer.GetTwinID(ctx) + if dl, err := g.provisionStub.Get(ctx, twin, args.ContractID); err == nil { + return dl, nil + } + // slow path: allow an ops/council read of another owner's deployment (keyless migration) + owner, oerr := g.ownerOfContract(ctx, args.ContractID) + if oerr != nil { + return nil, oerr + } + if aerr := g.authorizeMigration(ctx, owner); aerr != nil { + return nil, aerr + } + return g.provisionStub.Get(ctx, owner, args.ContractID) } func (g *ZosAPI) deploymentListHandler(ctx context.Context, payload []byte) (interface{}, error) { @@ -88,6 +101,41 @@ func (g *ZosAPI) deploymentChangesHandler(ctx context.Context, payload []byte) ( // source deployment for a consistent copy, then uploads each requested workload // to a caller-provided presigned S3 URL (HTTP PUT). Used on the OLD node during // a contract move. +// authorizeMigration authorizes a council-driven (ops) migration op on a deployment owned by +// ownerTwin: the RMB caller must be the owner or a current council member (council governs the +// on-chain contract move, so it may also drive the node-side data move). Lets ops migrate a VM +// without the owner's key. +func (g *ZosAPI) authorizeMigration(ctx context.Context, ownerTwin uint32) error { + caller := peer.GetTwinID(ctx) + if caller == ownerTwin { + return nil + } + callerTwin, err := g.substrateGatewayStub.GetTwin(ctx, caller) + if err != nil { + return fmt.Errorf("failed to resolve caller twin %d: %w", caller, err) + } + members, serr := g.substrateGatewayStub.GetCouncilMembers(ctx) + if serr.IsError() { + return fmt.Errorf("failed to fetch council members: %w", serr.Err) + } + callerPk := callerTwin.Account.PublicKey() + for _, m := range members { + if bytes.Equal(m[:], callerPk) { + return nil + } + } + return fmt.Errorf("caller twin %d is neither the deployment owner (twin %d) nor a council member", caller, ownerTwin) +} + +// ownerOfContract resolves the owner twin of a node contract from chain. +func (g *ZosAPI) ownerOfContract(ctx context.Context, contractID uint64) (uint32, error) { + contract, serr := g.substrateGatewayStub.GetContract(ctx, contractID) + if serr.IsError() { + return 0, fmt.Errorf("failed to get contract %d: %w", contractID, serr.Err) + } + return uint32(contract.TwinID), nil +} + func (g *ZosAPI) deploymentTransferHandler(ctx context.Context, payload []byte) (interface{}, error) { var args struct { ContractID uint64 `json:"contract_id"` @@ -97,7 +145,15 @@ func (g *ZosAPI) deploymentTransferHandler(ctx context.Context, payload []byte) return nil, err } - twin := peer.GetTwinID(ctx) + // owner-agnostic: resolve the owner from the contract and authorize the caller as owner or + // council; all storage ops below run under the OWNER twin. + twin, err := g.ownerOfContract(ctx, args.ContractID) + if err != nil { + return nil, err + } + if err := g.authorizeMigration(ctx, twin); err != nil { + return nil, err + } deployment, err := g.provisionStub.Get(ctx, twin, args.ContractID) if err != nil { return nil, err @@ -139,7 +195,12 @@ func (g *ZosAPI) deploymentPrepareHandler(ctx context.Context, payload []byte) ( return nil, err } - twin := peer.GetTwinID(ctx) + // the deployment carries its owner twin; authorize the caller as owner or council. The + // on-chain contract hash (set by the council move) is the authority — see engine.validate. + twin := args.Deployment.TwinID + if err := g.authorizeMigration(ctx, twin); err != nil { + return nil, err + } // resolve + validate the requested workloads up front (synchronous errors) jobs, err := resolveTransferJobs(&args.Deployment, args.Downloads) @@ -170,7 +231,14 @@ func (g *ZosAPI) deploymentStartHandler(ctx context.Context, payload []byte) (in if err := json.Unmarshal(payload, &args); err != nil { return nil, err } - return nil, g.provisionStub.StartDeployment(ctx, peer.GetTwinID(ctx), args.ContractID) + twin, err := g.ownerOfContract(ctx, args.ContractID) + if err != nil { + return nil, err + } + if err := g.authorizeMigration(ctx, twin); err != nil { + return nil, err + } + return nil, g.provisionStub.StartDeployment(ctx, twin, args.ContractID) } // waitWorkloadProvisioned blocks until the workload with the given global id diff --git a/pkg/zos_api_light/zos_api.go b/pkg/zos_api_light/zos_api.go index 6771c988..057e2986 100644 --- a/pkg/zos_api_light/zos_api.go +++ b/pkg/zos_api_light/zos_api.go @@ -29,6 +29,7 @@ type ZosAPI struct { statisticsStub *stubs.StatisticsStub storageStub *stubs.StorageModuleStub performanceMonitorStub *stubs.PerformanceMonitorStub + substrateGatewayStub *stubs.SubstrateGatewayStub diagnosticsManager *diagnostics.DiagnosticsManager farmerID uint32 inMemCache *cache.Cache @@ -54,6 +55,7 @@ func NewZosAPI(manager substrate.Manager, client zbus.Client, msgBrokerCon strin statisticsStub: stubs.NewStatisticsStub(client), storageStub: storageModuleStub, performanceMonitorStub: stubs.NewPerformanceMonitorStub(client), + substrateGatewayStub: stubs.NewSubstrateGatewayStub(client), diagnosticsManager: diagnosticsManager, } exp := backoff.NewExponentialBackOff() @@ -109,6 +111,7 @@ func NewZosAPIWithFarmerID(client zbus.Client, farmerID uint32, msgBrokerCon str statisticsStub: stubs.NewStatisticsStub(client), storageStub: storageModuleStub, performanceMonitorStub: stubs.NewPerformanceMonitorStub(client), + substrateGatewayStub: stubs.NewSubstrateGatewayStub(client), diagnosticsManager: diagnosticsManager, } api.farmerID = farmerID diff --git a/scripts/qsfs/main.go b/scripts/qsfs/main.go index dac2c5ce..e25d4146 100644 --- a/scripts/qsfs/main.go +++ b/scripts/qsfs/main.go @@ -47,10 +47,11 @@ const ( QSFSCacheSize = 1 // GB SSHKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDTwULSsUubOq3VPWL6cdrDvexDmjfznGydFPyaNcn7gAL9lRxwFbCDPMj7MbhNSpxxHV2+/iJPQOTVJu4oc1N7bPP3gBCnF51rPrhTpGCt5pBbTzeyNweanhedkKDsCO2mIEh/92Od5Hg512dX4j7Zw6ipRWYSaepapfyoRnNSriW/s3DH/uewezVtL5EuypMdfNngV/u2KZYWoeiwhrY/yEUykQVUwDysW/xUJNP5o+KSTAvNSJatr3FbuCFuCjBSvageOLHePTeUwu6qjqe+Xs4piF1ByO/6cOJ8bt5Vcx0bAtI8/MPApplUU/JWevsPNApvnA/ntffI+u8DCwgP ashraf@thinkpad" - - Mnemonic = "junior sock chunk accident pilot under ask green endless remove coast wood" ) +// Mnemonic is the owner twin mnemonic — read from the MNEMONIC env var, never hard-coded. +var Mnemonic = os.Getenv("MNEMONIC") + // generateWGPrivateKey generates a WireGuard (Curve25519) private key func generateWGPrivateKey() string { var key [32]byte @@ -343,6 +344,10 @@ func main() { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") + if Mnemonic == "" { + panic("set the MNEMONIC env var (owner twin mnemonic)") + } + identity, err := substrate.NewIdentityFromSr25519Phrase(Mnemonic) if err != nil { panic(err)