diff --git a/.changeset/safe-driver-control-v2.md b/.changeset/safe-driver-control-v2.md new file mode 100644 index 00000000..fd438253 --- /dev/null +++ b/.changeset/safe-driver-control-v2.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Add the signed Device Support control v2 host with exact site pins, short leases, default-mode recovery, a restricted Lua API, Modbus-only permissions, and local command-result records. Existing bundled, local, and read-only v1 drivers keep their current runtime. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 38bfb2cb..3aeafd20 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -339,6 +339,15 @@ func main() { } reg := drivers.NewRegistry(tel) reg.SetTroubleshootingMode(cfg.Site.TroubleshootingMode) + reg.RuntimePolicyResolver = driverRepository.RuntimePolicy + reg.CommandResultSink = func(driverName string, result drivers.DriverCommandResultV1) { + if err := st.RecordDriverCommandResult( + result.ID, driverName, result.Command, result.Status, result.Code, + result.CompletedAt.UnixMilli(), result.JSON(), + ); err != nil { + slog.Error("persist driver command result", "driver", driverName, "command_id", result.ID, "err", err) + } + } reg.MQTTFactory = func(name string, c *config.MQTTConfig) (drivers.MQTTCap, error) { return mqttcli.Dial(c.Host, c.Port, c.Username, c.Password, "ftw-"+name) } diff --git a/go/internal/components/contracts.go b/go/internal/components/contracts.go index a6255712..df5dc555 100644 --- a/go/internal/components/contracts.go +++ b/go/internal/components/contracts.go @@ -5,7 +5,11 @@ package components const ( ComponentManifestSchemaVersion = 1 OptimizerProtocolVersion = 1 - DriverHostAPIVersion = 1 + // DriverHostAPIVersion is the newest API implemented by this host. The + // v1 surface remains available for read-only and legacy drivers; v2 is a + // separate, restricted control surface selected by signed package metadata. + DriverHostAPIMinVersion = 1 + DriverHostAPIVersion = 2 ) type Kind string @@ -33,3 +37,17 @@ func (r CompatibleRange) Includes(version int) bool { } return version >= min && version <= max } + +// OverlapsDriverHost reports whether at least one API version requested by a +// driver is implemented by this host. Runtime selection still uses the exact +// signed profile; overlap alone never upgrades a v1 driver to v2. +func (r CompatibleRange) OverlapsDriverHost() bool { + min, max := r.Min, r.Max + if min == 0 { + min = DriverHostAPIMinVersion + } + if max == 0 { + max = min + } + return max >= DriverHostAPIMinVersion && min <= DriverHostAPIVersion +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index d3076ea9..3a924013 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -6,6 +6,7 @@ package config import ( + "encoding/hex" "errors" "fmt" "math" @@ -727,6 +728,11 @@ type Driver struct { // Disabled skips this driver at startup / reload. Set via the UI when // you want to temporarily take a driver out without editing yaml. Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` + // Control opts this one site into one exact signed control artifact. + // The runtime rejects control unless all three pins match the active + // Device Support package. Merely selecting the beta channel or installing + // a control-capable artifact never enables writes. + Control *DriverControlOptIn `yaml:"control,omitempty" json:"control,omitempty"` // HasPassword is a JSON-only signal to the UI that Config["password"] // holds a non-empty value on disk. Populated by MaskSecrets after the // real password is blanked out so the operator can still tell apart @@ -748,6 +754,15 @@ type Driver struct { Modbus *ModbusConfig `yaml:"modbus,omitempty" json:"modbus,omitempty"` } +// DriverControlOptIn is a per-site, fail-closed control grant. PackageID, +// Version and ArtifactSHA256 must match signed active package metadata. +type DriverControlOptIn struct { + Enabled bool `yaml:"enabled" json:"enabled"` + PackageID string `yaml:"package_id" json:"package_id"` + Version string `yaml:"version" json:"version"` + ArtifactSHA256 string `yaml:"artifact_sha256" json:"artifact_sha256"` +} + // Capabilities explicitly scope what host resources a driver can access. type Capabilities struct { MQTT *MQTTConfig `yaml:"mqtt,omitempty" json:"mqtt,omitempty"` @@ -1439,6 +1454,15 @@ func (c *Config) Validate() error { if d.Lua == "" { return fmt.Errorf("driver %q: must specify `lua`", d.Name) } + if d.Control != nil && d.Control.Enabled { + if !strings.HasPrefix(d.Control.PackageID, "com.sourceful.driver.") || d.Control.Version == "" { + return fmt.Errorf("driver %q: control requires an exact Sourceful package_id and version", d.Name) + } + hash, err := hex.DecodeString(strings.ToLower(strings.TrimSpace(d.Control.ArtifactSHA256))) + if err != nil || len(hash) != 32 { + return fmt.Errorf("driver %q: control artifact_sha256 must be 64 hexadecimal characters", d.Name) + } + } if d.EffectiveMQTT() == nil && d.EffectiveModbus() == nil && d.Capabilities.HTTP == nil && d.Capabilities.WebSocket == nil && d.Capabilities.TCP == nil { diff --git a/go/internal/driverrepo/manager.go b/go/internal/driverrepo/manager.go index 496660ed..b8c91aa2 100644 --- a/go/internal/driverrepo/manager.go +++ b/go/internal/driverrepo/manager.go @@ -76,6 +76,12 @@ type ManifestDriver struct { PackageEnvelopeSHA256 string `json:"package_envelope_sha256,omitempty"` SourceCommit string `json:"source_commit,omitempty"` Channel string `json:"channel,omitempty"` + ControlEnabled bool `json:"control_enabled,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` + Permissions []string `json:"permissions,omitempty"` + Commands []sourcefulCommand `json:"commands,omitempty"` + DefaultMode sourcefulDefaultMode `json:"default_mode,omitempty"` + LeasePolicy sourcefulLeasePolicy `json:"lease_policy,omitempty"` } type RepositoryStatus struct { @@ -435,6 +441,19 @@ func (m *Manager) Install(ctx context.Context, repositoryID, driverID, version s if err := validateLuaArtifact(installPath, entry); err != nil { return state.DriverRepoInstall{}, err } + if entry.PackageID != "" { + packageRaw, err := readLimitedFile(m.sourcefulPackageCachePath(repo, entry.PackageEnvelopeSHA256), maxManifestBytes) + if err != nil { + return state.DriverRepoInstall{}, fmt.Errorf("read verified package envelope for install: %w", err) + } + sum := sha256.Sum256(packageRaw) + if hex.EncodeToString(sum[:]) != entry.PackageEnvelopeSHA256 { + return state.DriverRepoInstall{}, errors.New("cached package envelope hash changed before install") + } + if err := atomicWrite(filepath.Join(filepath.Dir(installPath), sourcefulInstalledPackageEnvelope), packageRaw, 0o600); err != nil { + return state.DriverRepoInstall{}, fmt.Errorf("persist package envelope with artifact: %w", err) + } + } logical := filepath.ToSlash(entry.Path) installed := state.DriverRepoInstall{ RepoURL: manifest.Repository, RepoID: repo.ID, DriverID: entry.ID, @@ -810,8 +829,8 @@ func validateManifestDriver(d ManifestDriver, allowInsecure bool) error { if d.SizeBytes < 0 || d.SizeBytes > maxDriverBytes { return fmt.Errorf("driver %s has invalid size %d", d.ID, d.SizeBytes) } - if !d.HostAPI.Includes(components.DriverHostAPIVersion) { - return fmt.Errorf("driver %s host API range %d..%d excludes host %d", d.ID, d.HostAPI.Min, d.HostAPI.Max, components.DriverHostAPIVersion) + if !d.HostAPI.OverlapsDriverHost() { + return fmt.Errorf("driver %s host API range %d..%d excludes host range %d..%d", d.ID, d.HostAPI.Min, d.HostAPI.Max, components.DriverHostAPIMinVersion, components.DriverHostAPIVersion) } u, err := url.Parse(d.URL) if err != nil || (u.Scheme != "https" && !(allowInsecure && (u.Scheme == "http" || u.Scheme == "file"))) { @@ -845,7 +864,7 @@ func validateLuaArtifact(path string, manifest ManifestDriver) error { !regexp.MustCompile(`(?m)^\s*host_api_max\s*=\s*[0-9]+`).MatchString(source) { return errors.New("managed driver must declare host_api_min and host_api_max") } - if metadata.HostAPIMin > components.DriverHostAPIVersion || metadata.HostAPIMax < components.DriverHostAPIVersion { + if metadata.HostAPIMin > components.DriverHostAPIVersion || metadata.HostAPIMax < components.DriverHostAPIMinVersion { return fmt.Errorf("driver metadata host API range %d..%d is incompatible", metadata.HostAPIMin, metadata.HostAPIMax) } return nil diff --git a/go/internal/driverrepo/sourceful.go b/go/internal/driverrepo/sourceful.go index 28e0bd24..ccb2dba3 100644 --- a/go/internal/driverrepo/sourceful.go +++ b/go/internal/driverrepo/sourceful.go @@ -26,20 +26,23 @@ import ( ) const ( - sourcefulIndexSchema = "sourceful.driver-index/v1" - sourcefulIndexEnvelopeSchema = "sourceful.driver-index-envelope/v1" - sourcefulIndexPayloadType = "application/vnd.sourceful.driver-index.v1+json" - sourcefulPackageSchema = "sourceful.driver-package/v1" - sourcefulPackageEnvelopeSchema = "sourceful.driver-package-envelope/v1" - sourcefulPackagePayloadType = "application/vnd.sourceful.driver-package.v1+json" - sourcefulCanonicalJSON = "sourceful.canonical-json/v1" - sourcefulFTWTarget = "ftw-core" - sourcefulFTWHostProduct = "ftw" - sourcefulFTWRuntime = "gopher-lua" - sourcefulFTWSemantics = "lua-5.1" - sourcefulFTWRuntimeVersion = "1.1.2" - sourcefulFTWABI = "gopher-lua-source-v1" - sourcefulFTWHostAPIProfile = "sourceful.host/ftw-core/v1" + sourcefulIndexSchema = "sourceful.driver-index/v1" + sourcefulIndexEnvelopeSchema = "sourceful.driver-index-envelope/v1" + sourcefulIndexPayloadType = "application/vnd.sourceful.driver-index.v1+json" + sourcefulPackageSchema = "sourceful.driver-package/v1" + sourcefulPackageEnvelopeSchema = "sourceful.driver-package-envelope/v1" + sourcefulPackagePayloadType = "application/vnd.sourceful.driver-package.v1+json" + sourcefulCanonicalJSON = "sourceful.canonical-json/v1" + sourcefulFTWTarget = "ftw-core" + sourcefulFTWHostProduct = "ftw" + sourcefulFTWRuntime = "gopher-lua" + sourcefulFTWSemantics = "lua-5.1" + sourcefulFTWRuntimeVersion = "1.1.2" + sourcefulFTWABIV1 = "gopher-lua-source-v1" + sourcefulFTWHostAPIProfileV1 = "sourceful.host/ftw-core/v1" + sourcefulFTWABIV2 = drivers.ControlRuntimeABIV2 + sourcefulFTWHostAPIProfileV2 = drivers.ControlHostAPIProfileV2 + sourcefulInstalledPackageEnvelope = "sourceful-package.envelope.json" ) var ( @@ -47,6 +50,7 @@ var ( sourcefulHashRE = regexp.MustCompile(`^[0-9a-f]{64}$`) sourcefulCommitRE = regexp.MustCompile(`^[0-9a-f]{40}$`) sourcefulKeyIDRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$`) + sourcefulCommandRE = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$`) ) type sourcefulSignedEnvelope struct { @@ -427,23 +431,6 @@ func (m *Manager) sourcefulPackageDriver( pkg.Telemetry.SignConvention != "sourceful.site-import-positive/v1" { return ManifestDriver{}, false, errors.New("signed package device or telemetry contract is invalid") } - // Phase 1 is deliberately telemetry-only. FTW Core already owns a robust - // control safety lifecycle, but canonical control packages remain gated on - // explicit lease/default-mode/command-result HIL acceptance. - if !pkg.ReadOnly || len(pkg.Commands) != 0 || len(pkg.Capabilities.Control) != 0 || - pkg.DefaultMode.Strategy != "not_applicable" || pkg.DefaultMode.Entrypoint != "" || - pkg.LeasePolicy.RequiredForControl || pkg.LeasePolicy.MaxDurationSeconds != nil || - pkg.LeasePolicy.HeartbeatIntervalSeconds != nil || pkg.LeasePolicy.ExpiryAction != "not_applicable" { - return ManifestDriver{}, false, errors.New("FTW Device Support pilot accepts read-only packages only") - } - for _, permission := range pkg.Permissions { - switch permission { - case "http.get", "modbus.read", "mqtt.subscribe", "serial.read": - default: - return ManifestDriver{}, false, fmt.Errorf("read-only package requests write-capable permission %q", permission) - } - } - var target *sourcefulCompatibility for i := range pkg.Compatibility { if pkg.Compatibility[i].Target != sourcefulFTWTarget { @@ -457,9 +444,6 @@ func (m *Manager) sourcefulPackageDriver( if target == nil { return ManifestDriver{}, false, errors.New("index advertises ftw-core but package has no ftw-core compatibility") } - if target.ControlEnabled { - return ManifestDriver{}, false, errors.New("read-only package enables FTW control") - } if target.Host.Product != sourcefulFTWHostProduct || !semverRE.MatchString(target.Host.MinVersion) || (target.Host.MaxVersionExclusive != "" && !semverRE.MatchString(target.Host.MaxVersionExclusive)) { return ManifestDriver{}, false, errors.New("invalid FTW host compatibility") @@ -471,12 +455,31 @@ func (m *Manager) sourcefulPackageDriver( (target.Host.MaxVersionExclusive != "" && compareSemver(m.hostVersion, target.Host.MaxVersionExclusive) >= 0) { return ManifestDriver{}, false, nil } + controlV2 := !pkg.ReadOnly + if pkg.ReadOnly { + if err := validateSourcefulReadOnlyContract(pkg, *target); err != nil { + return ManifestDriver{}, false, err + } + } else { + // A staged control source with control_enabled=false stays out of the + // FTW catalog. It cannot be installed or activated by accident. + if !target.ControlEnabled { + return ManifestDriver{}, false, nil + } + if err := validateSourcefulControlContract(pkg); err != nil { + return ManifestDriver{}, false, err + } + } + runtime := target.Runtime + wantABI, wantProfile, wantAPI := sourcefulFTWABIV1, sourcefulFTWHostAPIProfileV1, 1 + if controlV2 { + wantABI, wantProfile, wantAPI = sourcefulFTWABIV2, sourcefulFTWHostAPIProfileV2, 2 + } if runtime.Name != sourcefulFTWRuntime || runtime.Semantics != sourcefulFTWSemantics || - runtime.Version != sourcefulFTWRuntimeVersion || runtime.ABI != sourcefulFTWABI || - runtime.HostAPI.Profile != sourcefulFTWHostAPIProfile || runtime.HostAPI.Min <= 0 || - runtime.HostAPI.Max < runtime.HostAPI.Min || - components.DriverHostAPIVersion < runtime.HostAPI.Min || components.DriverHostAPIVersion > runtime.HostAPI.Max { + runtime.Version != sourcefulFTWRuntimeVersion || runtime.ABI != wantABI || + runtime.HostAPI.Profile != wantProfile || runtime.HostAPI.Min != wantAPI || runtime.HostAPI.Max != wantAPI || + wantAPI < components.DriverHostAPIMinVersion || wantAPI > components.DriverHostAPIVersion { return ManifestDriver{}, false, nil } @@ -526,7 +529,7 @@ func (m *Manager) sourcefulPackageDriver( Source: "upstream", Protocols: sourcefulProtocols(pkg.Permissions), Capabilities: sourcefulFTWCapabilities(pkg.Capabilities.Telemetry), Description: "Canonical Sourceful driver package from Device Support.", - ReadOnly: true, VerificationStatus: "experimental", + ReadOnly: pkg.ReadOnly, VerificationStatus: "experimental", TestedModels: sourcefulTestedModels(pkg.DeviceMatches), } return ManifestDriver{ @@ -538,9 +541,207 @@ func (m *Manager) sourcefulPackageDriver( RuntimeVersion: runtime.Version, RuntimeABI: runtime.ABI, HostAPIProfile: runtime.HostAPI.Profile, PackageEnvelopeURL: ref.EnvelopeURL, PackageEnvelopeSHA256: ref.EnvelopeSHA256, SourceCommit: pkg.Source.Commit, Channel: pkg.Channel, + ControlEnabled: target.ControlEnabled, ReadOnly: pkg.ReadOnly, + Permissions: append([]string(nil), pkg.Permissions...), Commands: append([]sourcefulCommand(nil), pkg.Commands...), + DefaultMode: pkg.DefaultMode, LeasePolicy: pkg.LeasePolicy, }, true, nil } +// RuntimePolicy verifies the package envelope stored with an active managed +// artifact and derives the v2 host policy without a network call. A local, +// bundled, legacy repository or read-only Sourceful artifact returns nil. +func (m *Manager) RuntimePolicy(cfg config.Driver) (*drivers.RuntimePolicy, error) { + if m.store == nil { + if cfg.Control != nil && cfg.Control.Enabled { + return nil, errors.New("control opt-in requires the managed driver state store") + } + return nil, nil + } + resolved, err := filepath.EvalSymlinks(cfg.Lua) + installedRoot, rootErr := filepath.EvalSymlinks(filepath.Join(m.root, "installed")) + if err != nil || rootErr != nil || !pathInside(installedRoot, resolved) { + if cfg.Control != nil && cfg.Control.Enabled { + return nil, errors.New("control opt-in requires an active managed Device Support artifact") + } + return nil, nil + } + recordedPath := filepath.Clean(cfg.Lua) + if target, linkErr := os.Readlink(cfg.Lua); linkErr == nil { + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(cfg.Lua), target) + } + recordedPath = filepath.Clean(target) + } + installed, err := m.store.DriverRepoInstallByPath(recordedPath) + if err != nil { + return nil, fmt.Errorf("resolve managed driver activation: %w", err) + } + if !installed.Active { + if cfg.Control != nil && cfg.Control.Enabled { + return nil, errors.New("control opt-in requires the active managed artifact") + } + return nil, nil + } + var repo *config.DriverRepositorySource + for i := range m.cfg.Repositories { + if m.cfg.Repositories[i].ID == installed.RepoID { + repo = &m.cfg.Repositories[i] + break + } + } + if repo == nil || repositoryFormat(*repo) != config.DriverRepositoryFormatSourcefulIndexV1 { + if cfg.Control != nil && cfg.Control.Enabled { + return nil, errors.New("control opt-in requires a configured Device Support trust root") + } + return nil, nil + } + packageRaw, err := readLimitedFile(filepath.Join(filepath.Dir(resolved), sourcefulInstalledPackageEnvelope), maxManifestBytes) + if err != nil { + if cfg.Control != nil && cfg.Control.Enabled { + return nil, fmt.Errorf("read installed signed package envelope: %w", err) + } + return nil, nil + } + payloadRaw, _, err := verifySourcefulEnvelope(packageRaw, *repo, sourcefulPackageEnvelopeSchema, sourcefulPackagePayloadType) + if err != nil { + return nil, fmt.Errorf("verify installed signed package envelope: %w", err) + } + var pkg sourcefulPackage + if err := strictJSON(payloadRaw, &pkg); err != nil { + return nil, fmt.Errorf("decode installed signed package: %w", err) + } + envelopeHash := sha256.Sum256(packageRaw) + ref := sourcefulDriverPackageRef{ + PackageID: pkg.PackageID, Version: pkg.Version, + EnvelopeURL: "https://installed.invalid/" + sourcefulInstalledPackageEnvelope, + EnvelopeSHA256: hex.EncodeToString(envelopeHash[:]), Targets: []string{sourcefulFTWTarget}, + } + entry, compatible, err := m.sourcefulPackageDriver(pkg, ref, pkg.Channel, repo.AllowInsecure) + if err != nil { + return nil, fmt.Errorf("validate installed signed package: %w", err) + } + if !compatible || entry.ReadOnly || !entry.ControlEnabled { + if cfg.Control != nil && cfg.Control.Enabled { + return nil, errors.New("control opt-in targets an artifact without an enabled FTW v2 control target") + } + return nil, nil + } + if !strings.EqualFold(entry.SHA256, installed.SHA256) || entry.Version != installed.Version || entry.ID != installed.DriverID { + return nil, errors.New("installed artifact does not match its signed package envelope") + } + + siteEnabled := false + if cfg.Control != nil && cfg.Control.Enabled { + if cfg.Control.PackageID != entry.PackageID || cfg.Control.Version != entry.Version || + !strings.EqualFold(strings.TrimSpace(cfg.Control.ArtifactSHA256), entry.SHA256) { + return nil, errors.New("control opt-in package/version/hash pin does not match the active signed artifact") + } + siteEnabled = true + } + permissions := make(map[string]bool, len(entry.Permissions)) + for _, permission := range entry.Permissions { + permissions[permission] = true + } + commands := make(map[string]drivers.RuntimeCommand, len(entry.Commands)) + for _, command := range entry.Commands { + inputs := make(map[string]drivers.RuntimeCommandInput, len(command.Inputs)) + for _, input := range command.Inputs { + inputs[input.Name] = drivers.RuntimeCommandInput{Type: input.Type, Required: input.Required} + } + commands[command.ID] = drivers.RuntimeCommand{ + ID: command.ID, RuntimeAction: command.RuntimeAction, Inputs: inputs, + } + } + return &drivers.RuntimePolicy{ + PackageID: entry.PackageID, Version: entry.Version, ArtifactSHA256: strings.ToLower(entry.SHA256), + RuntimeABI: entry.RuntimeABI, HostAPIProfile: entry.HostAPIProfile, + Permissions: permissions, Commands: commands, DefaultMode: entry.DefaultMode.Entrypoint, + Lease: drivers.RuntimeLeasePolicy{ + MaxDuration: time.Duration(*entry.LeasePolicy.MaxDurationSeconds) * time.Second, + HeartbeatInterval: time.Duration(*entry.LeasePolicy.HeartbeatIntervalSeconds) * time.Second, + ExpiryAction: entry.LeasePolicy.ExpiryAction, + }, + SiteEnabled: siteEnabled, MaxWrites: 128, + }, nil +} + +func validateSourcefulReadOnlyContract(pkg sourcefulPackage, target sourcefulCompatibility) error { + if !pkg.ReadOnly || target.ControlEnabled || len(pkg.Commands) != 0 || len(pkg.Capabilities.Control) != 0 || + pkg.DefaultMode.Strategy != "not_applicable" || pkg.DefaultMode.Entrypoint != "" || + pkg.LeasePolicy.RequiredForControl || pkg.LeasePolicy.MaxDurationSeconds != nil || + pkg.LeasePolicy.HeartbeatIntervalSeconds != nil || pkg.LeasePolicy.ExpiryAction != "not_applicable" { + return errors.New("read-only package has a control contract") + } + for _, permission := range pkg.Permissions { + switch permission { + case "http.get", "modbus.read", "mqtt.subscribe", "serial.read": + default: + return fmt.Errorf("read-only package requests write-capable permission %q", permission) + } + } + return nil +} + +func validateSourcefulControlContract(pkg sourcefulPackage) error { + if pkg.ReadOnly || len(pkg.Commands) == 0 || len(pkg.Capabilities.Control) == 0 || + pkg.DefaultMode.Strategy != "vendor_autonomous" || pkg.DefaultMode.Entrypoint != "driver_default_mode_v2" || + !pkg.LeasePolicy.RequiredForControl || pkg.LeasePolicy.MaxDurationSeconds == nil || + pkg.LeasePolicy.HeartbeatIntervalSeconds == nil || pkg.LeasePolicy.ExpiryAction != "return_to_default" { + return errors.New("control package lacks the FTW v2 lifecycle contract") + } + maxSeconds, heartbeatSeconds := *pkg.LeasePolicy.MaxDurationSeconds, *pkg.LeasePolicy.HeartbeatIntervalSeconds + if maxSeconds < 1 || maxSeconds > 300 || heartbeatSeconds < 1 || heartbeatSeconds > maxSeconds { + return errors.New("control package lease bounds are invalid") + } + writePermission := false + for _, permission := range pkg.Permissions { + switch permission { + case "modbus.read", "modbus.write": + default: + return fmt.Errorf("FTW control v2 only supports Modbus permissions, got %q", permission) + } + if permission == "modbus.write" { + writePermission = true + } + } + if !writePermission { + return errors.New("control package has no write permission") + } + permissions := make(map[string]bool, len(pkg.Permissions)) + for _, permission := range pkg.Permissions { + permissions[permission] = true + } + if permissions["modbus.write"] && !permissions["modbus.read"] { + return errors.New("control package modbus writes require readback permission") + } + controlCapabilities := make(map[string]bool, len(pkg.Capabilities.Control)) + for _, capability := range pkg.Capabilities.Control { + if !sourcefulCommandRE.MatchString(capability) || controlCapabilities[capability] { + return errors.New("control package capabilities are invalid or duplicated") + } + controlCapabilities[capability] = true + } + commands := make(map[string]bool, len(pkg.Commands)) + runtimeActions := make(map[string]bool, len(pkg.Commands)) + for _, command := range pkg.Commands { + if !sourcefulCommandRE.MatchString(command.ID) || !sourcefulCommandRE.MatchString(command.RuntimeAction) || + commands[command.ID] || runtimeActions[command.RuntimeAction] || !controlCapabilities[command.Capability] { + return errors.New("control package command identity is invalid or duplicated") + } + commands[command.ID] = true + runtimeActions[command.RuntimeAction] = true + inputs := make(map[string]bool, len(command.Inputs)) + for _, input := range command.Inputs { + if !sourcefulCommandRE.MatchString(input.Name) || inputs[input.Name] || + (input.Type != "number" && input.Type != "boolean" && input.Type != "string") { + return fmt.Errorf("control package command %q has an invalid input", command.ID) + } + inputs[input.Name] = true + } + } + return nil +} + func validateSourcefulPackageMetadata(pkg sourcefulPackage) error { if pkg.Identity.Serial != "driver_reported_when_available" && pkg.Identity.Serial != "unavailable" { return errors.New("signed package serial identity contract is invalid") diff --git a/go/internal/driverrepo/sourceful_test.go b/go/internal/driverrepo/sourceful_test.go index 1decc5de..b77f70d4 100644 --- a/go/internal/driverrepo/sourceful_test.go +++ b/go/internal/driverrepo/sourceful_test.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -77,23 +78,38 @@ func signSourcefulFixture( return append(raw, '\n') } -func (f *sourcefulFixture) build(t *testing.T, serverURL string, readOnly bool) { +func (f *sourcefulFixture) build(t *testing.T, serverURL string, readOnly, controlEnabled bool) { t.Helper() - f.artifact = []byte(`DRIVER = { + hostAPIMin, hostAPIMax := 1, 1 + runtimeABI, hostAPIProfile := sourcefulFTWABIV1, sourcefulFTWHostAPIProfileV1 + readOnlyLua := "true" + commandEntrypoints := `function driver_command() return false end +function driver_default_mode() end` + if !readOnly { + hostAPIMin, hostAPIMax = 2, 2 + runtimeABI, hostAPIProfile = sourcefulFTWABIV2, sourcefulFTWHostAPIProfileV2 + readOnlyLua = "false" + commandEntrypoints = `function driver_command_v2(command) + return {status="applied", code="ok", device_state="controlled", evidence={"write_ack", "readback"}} +end +function driver_default_mode_v2(context) + return {status="defaulted", code="default_restored", device_state="default", evidence={"write_ack", "readback"}} +end` + } + f.artifact = []byte(fmt.Sprintf(`DRIVER = { id = "sdm630", name = "Eastron SDM630 meter", version = "1.1.1", - host_api_min = 1, - host_api_max = 1, + host_api_min = %d, + host_api_max = %d, protocols = { "modbus" }, capabilities = { "meter" }, - read_only = true, + read_only = %s, } function driver_init(config) end function driver_poll() return 1000 end -function driver_command() return false end -function driver_default_mode() end -`) +%s +`, hostAPIMin, hostAPIMax, readOnlyLua, commandEntrypoints)) artifactSum := sha256.Sum256(f.artifact) artifactHash := hex.EncodeToString(artifactSum[:]) artifactFilename := "sdm630-1.1.1-ftw-core-ftw.lua51.source-" + artifactHash + ".lua" @@ -116,7 +132,7 @@ function driver_default_mode() end "description": "Set power.", "inputs": []any{}, }} defaultMode = map[string]any{ - "strategy": "vendor_autonomous", "entrypoint": "driver_default_mode", + "strategy": "vendor_autonomous", "entrypoint": "driver_default_mode_v2", "description": "Return to vendor control.", } lease = map[string]any{ @@ -125,6 +141,14 @@ function driver_default_mode() end } } + permissions := []string{"modbus.read"} + if !readOnly { + permissions = append(permissions, "modbus.write") + } + hostMinVersion := "1.4.0" + if !readOnly { + hostMinVersion = "1.7.0" + } packagePayload := map[string]any{ "schema_version": sourcefulPackageSchema, "package_id": "com.sourceful.driver.sdm630", @@ -149,7 +173,7 @@ function driver_default_mode() end "variants": []string{"SDM630-Modbus"}, "regions": []string{}, }}, "capabilities": map[string]any{"telemetry": []string{"meter"}, "control": control}, - "permissions": []string{"modbus.read"}, + "permissions": permissions, "telemetry": map[string]any{ "schema": "sourceful.telemetry/v2", "sign_convention": "sourceful.site-import-positive/v1", "streams": []any{map[string]any{ @@ -166,14 +190,14 @@ function driver_default_mode() end "compatibility": []any{map[string]any{ "target": sourcefulFTWTarget, "artifact_id": "ftw.lua51.source", "host": map[string]any{ - "product": sourcefulFTWHostProduct, "min_version": "1.4.0", "max_version_exclusive": "2.0.0", + "product": sourcefulFTWHostProduct, "min_version": hostMinVersion, "max_version_exclusive": "2.0.0", }, "runtime": map[string]any{ "name": sourcefulFTWRuntime, "semantics": sourcefulFTWSemantics, - "version": sourcefulFTWRuntimeVersion, "abi": sourcefulFTWABI, - "host_api": map[string]any{"profile": sourcefulFTWHostAPIProfile, "min": 1, "max": 1}, + "version": sourcefulFTWRuntimeVersion, "abi": runtimeABI, + "host_api": map[string]any{"profile": hostAPIProfile, "min": hostAPIMin, "max": hostAPIMax}, }, - "control_enabled": !readOnly, + "control_enabled": controlEnabled, }}, "artifacts": []any{map[string]any{ "artifact_id": "ftw.lua51.source", "target": sourcefulFTWTarget, @@ -206,7 +230,7 @@ func TestSourcefulIndexPackageInstallAndOfflineCache(t *testing.T) { } fixture := &sourcefulFixture{private: private} server := httptest.NewServer(http.HandlerFunc(fixture.serveHTTP)) - fixture.build(t, server.URL, true) + fixture.build(t, server.URL, true, false) dir := t.TempDir() store, err := state.Open(filepath.Join(dir, "state.db")) @@ -231,7 +255,7 @@ func TestSourcefulIndexPackageInstallAndOfflineCache(t *testing.T) { driver := catalog[0].Driver if driver.ID != "sdm630" || !driver.Metadata.ReadOnly || driver.PackageID != "com.sourceful.driver.sdm630" || driver.Channel != "beta" || driver.SourceCommit != strings.Repeat("a", 40) || - driver.RuntimeABI != sourcefulFTWABI || driver.HostAPIProfile != sourcefulFTWHostAPIProfile || + driver.RuntimeABI != sourcefulFTWABIV1 || driver.HostAPIProfile != sourcefulFTWHostAPIProfileV1 || driver.PackageKeyID != "sourceful-test-1" { t.Fatalf("adapted driver = %+v", driver) } @@ -276,26 +300,106 @@ func TestSourcefulIndexPackageInstallAndOfflineCache(t *testing.T) { } } -func TestSourcefulPilotRejectsControlAndAmbiguousJSON(t *testing.T) { +func TestSourcefulRejectsAmbiguousJSON(t *testing.T) { if _, err := canonicalJSON([]byte(`{"a":1,"a":2}`)); err == nil { t.Fatal("duplicate JSON object keys accepted") } if canonical, err := canonicalJSON([]byte(`{"empty":[]}`)); err != nil || string(canonical) != `{"empty":[]}` { t.Fatalf("empty array canonicalization = %q, %v", canonical, err) } +} + +func TestSourcefulStagedControlTargetIsExcluded(t *testing.T) { + public, private, _ := ed25519.GenerateKey(rand.Reader) + fixture := &sourcefulFixture{private: private} + server := httptest.NewServer(http.HandlerFunc(fixture.serveHTTP)) + defer server.Close() + fixture.build(t, server.URL, false, false) + cfg := &config.DeviceRepository{Enabled: true, Repositories: []config.DriverRepositorySource{{ + ID: "sourceful", Format: config.DriverRepositoryFormatSourcefulIndexV1, + ManifestURL: server.URL + "/index.json", Enabled: true, AllowInsecure: true, + TrustedKeys: map[string]string{"sourceful-test-1": base64.StdEncoding.EncodeToString(public)}, + }}} + dir := t.TempDir() + store, err := state.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + manager := NewWithHostVersion(cfg, dir, store, "1.7.0") + if err := manager.Refresh(context.Background(), "sourceful"); err != nil { + t.Fatal(err) + } + catalog, err := manager.Catalog() + if err != nil || len(catalog) != 0 { + t.Fatalf("staged control catalog = %+v, %v", catalog, err) + } +} + +func TestSourcefulControlV2RequiresExactActivePackagePin(t *testing.T) { public, private, _ := ed25519.GenerateKey(rand.Reader) fixture := &sourcefulFixture{private: private} server := httptest.NewServer(http.HandlerFunc(fixture.serveHTTP)) defer server.Close() - fixture.build(t, server.URL, false) + fixture.build(t, server.URL, false, true) + + dir := t.TempDir() + store, err := state.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() cfg := &config.DeviceRepository{Enabled: true, Repositories: []config.DriverRepositorySource{{ ID: "sourceful", Format: config.DriverRepositoryFormatSourcefulIndexV1, ManifestURL: server.URL + "/index.json", Enabled: true, AllowInsecure: true, TrustedKeys: map[string]string{"sourceful-test-1": base64.StdEncoding.EncodeToString(public)}, }}} - manager := NewWithHostVersion(cfg, t.TempDir(), nil, "1.4.0") - if err := manager.Refresh(context.Background(), "sourceful"); err == nil || !strings.Contains(err.Error(), "read-only") { - t.Fatalf("control package error = %v", err) + manager := NewWithHostVersion(cfg, dir, store, "1.7.0") + if err := manager.Refresh(context.Background(), "sourceful"); err != nil { + t.Fatal(err) + } + catalog, err := manager.Catalog() + if err != nil || len(catalog) != 1 { + t.Fatalf("control v2 catalog = %+v, %v", catalog, err) + } + entry := catalog[0].Driver + if entry.ReadOnly || !entry.ControlEnabled || entry.RuntimeABI != sourcefulFTWABIV2 || + entry.HostAPIProfile != sourcefulFTWHostAPIProfileV2 { + t.Fatalf("control v2 entry = %+v", entry) + } + installed, err := manager.Install(context.Background(), "sourceful", "sdm630", "1.1.1") + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(filepath.Dir(installed.InstalledPath), sourcefulInstalledPackageEnvelope)); err != nil { + t.Fatalf("installed signed package envelope: %v", err) + } + driverCfg := config.Driver{ + Name: "sdm630", Lua: filepath.Join(manager.ActiveDir(), "sdm630.lua"), + Control: &config.DriverControlOptIn{ + Enabled: true, PackageID: entry.PackageID, Version: entry.Version, ArtifactSHA256: entry.SHA256, + }, + } + resolved, err := filepath.EvalSymlinks(driverCfg.Lua) + installedRoot, rootErr := filepath.EvalSymlinks(filepath.Join(manager.root, "installed")) + if err != nil || rootErr != nil || !pathInside(installedRoot, resolved) { + t.Fatalf("active managed path %q resolved to %q: %v", driverCfg.Lua, resolved, err) + } + policy, err := manager.RuntimePolicy(driverCfg) + if err != nil || policy == nil || !policy.SiteEnabled || !policy.Permissions["modbus.write"] { + t.Fatalf("selected control policy = %+v, %v", policy, err) + } + driverCfg.Control = nil + policy, err = manager.RuntimePolicy(driverCfg) + if err != nil || policy == nil || policy.SiteEnabled { + t.Fatalf("unselected control policy = %+v, %v", policy, err) + } + driverCfg.Control = &config.DriverControlOptIn{ + Enabled: true, PackageID: entry.PackageID, Version: entry.Version, ArtifactSHA256: entry.SHA256, + } + driverCfg.Control.ArtifactSHA256 = strings.Repeat("0", 64) + if _, err := manager.RuntimePolicy(driverCfg); err == nil || !strings.Contains(err.Error(), "pin does not match") { + t.Fatalf("wrong artifact pin error = %v", err) } } diff --git a/go/internal/drivers/control_v2.go b/go/internal/drivers/control_v2.go new file mode 100644 index 00000000..e477fc5b --- /dev/null +++ b/go/internal/drivers/control_v2.go @@ -0,0 +1,328 @@ +package drivers + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + "time" +) + +const ( + ControlRuntimeABIV2 = "gopher-lua-source-v2" + ControlHostAPIProfileV2 = "sourceful.host/ftw-core/v2" + DriverCommandSchemaV1 = "sourceful.driver-command/v1" + DriverCommandResultSchema = "sourceful.driver-command-result/v1" + defaultMaxWritesPerCall = 128 +) + +var controlTokenRE = regexp.MustCompile(`^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$`) +var controlHashRE = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// RuntimePolicy is the verified, signed package policy bound to one managed +// artifact. SiteEnabled becomes true only when the local config pins the same +// package ID, version and artifact hash. +type RuntimePolicy struct { + PackageID string + Version string + ArtifactSHA256 string + RuntimeABI string + HostAPIProfile string + Permissions map[string]bool + Commands map[string]RuntimeCommand + DefaultMode string + Lease RuntimeLeasePolicy + SiteEnabled bool + MaxWrites int +} + +type RuntimeCommand struct { + ID string + RuntimeAction string + Inputs map[string]RuntimeCommandInput +} + +type RuntimeCommandInput struct { + Type string + Required bool +} + +type RuntimeLeasePolicy struct { + MaxDuration time.Duration + HeartbeatInterval time.Duration + ExpiryAction string +} + +func (p *RuntimePolicy) IsControlV2() bool { + return p != nil && p.RuntimeABI == ControlRuntimeABIV2 && p.HostAPIProfile == ControlHostAPIProfileV2 +} + +func (p *RuntimePolicy) validate() error { + if !p.IsControlV2() || !strings.HasPrefix(p.PackageID, "com.sourceful.driver.") || p.Version == "" || + !controlHashRE.MatchString(p.ArtifactSHA256) || p.DefaultMode != "driver_default_mode_v2" { + return errors.New("invalid control v2 runtime identity") + } + if p.Lease.MaxDuration < time.Second || p.Lease.MaxDuration > 300*time.Second || + p.Lease.HeartbeatInterval < time.Second || p.Lease.HeartbeatInterval > p.Lease.MaxDuration || + p.Lease.ExpiryAction != "return_to_default" { + return errors.New("invalid control v2 lease policy") + } + hasWrite := false + for permission, allowed := range p.Permissions { + if !allowed { + continue + } + switch permission { + case "modbus.read", "modbus.write": + default: + return fmt.Errorf("FTW control v2 only supports Modbus permissions, got %q", permission) + } + if permission == "modbus.write" { + hasWrite = true + } + } + if !hasWrite || (p.Permissions["modbus.write"] && !p.Permissions["modbus.read"]) { + return errors.New("control v2 policy lacks a verifiable write path") + } + if len(p.Commands) == 0 || len(p.Commands) > 128 { + return errors.New("control v2 policy has an invalid command count") + } + actions := make(map[string]bool, len(p.Commands)) + for id, command := range p.Commands { + if id != command.ID || !validControlToken(id) || !validControlToken(command.RuntimeAction) || actions[command.RuntimeAction] { + return errors.New("control v2 policy has an invalid or repeated command") + } + actions[command.RuntimeAction] = true + if len(command.Inputs) > 32 { + return fmt.Errorf("control v2 command %q has too many inputs", id) + } + for name, input := range command.Inputs { + if !validControlToken(name) || (input.Type != "number" && input.Type != "boolean" && input.Type != "string") { + return fmt.Errorf("control v2 command %q has an invalid input", id) + } + } + } + return nil +} + +func (p *RuntimePolicy) allows(permission string) bool { + return p == nil || p.Permissions[permission] +} + +func (p *RuntimePolicy) maxWrites() int { + if p == nil || p.MaxWrites <= 0 || p.MaxWrites > defaultMaxWritesPerCall { + return defaultMaxWritesPerCall + } + return p.MaxWrites +} + +func (p *RuntimePolicy) requiredEvidence() string { + if p != nil && p.Permissions["modbus.write"] { + return "readback" + } + return "write_ack" +} + +// DriverCommandV1 is the host-owned command passed to driver_command_v2. +type DriverCommandV1 struct { + SchemaVersion string `json:"schema_version"` + ID string `json:"id"` + Command string `json:"command"` + Source string `json:"source"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + Attempt int `json:"attempt"` + Lease DriverCommandLeaseV1 `json:"lease"` + Inputs map[string]interface{} `json:"inputs"` +} + +type DriverCommandLeaseV1 struct { + ID string `json:"id"` + ExpiresAt time.Time `json:"expires_at"` + HeartbeatIntervalMS int64 `json:"heartbeat_interval_ms"` +} + +// DriverCommandResultV1 is completed by the host after Lua returns. +type DriverCommandResultV1 struct { + SchemaVersion string `json:"schema_version"` + ID string `json:"id"` + Command string `json:"command"` + LeaseID string `json:"lease_id,omitempty"` + Status string `json:"status"` + Code string `json:"code"` + Message string `json:"message,omitempty"` + CompletedAt time.Time `json:"completed_at"` + DeviceState string `json:"device_state"` + Writes int `json:"writes"` + Evidence []string `json:"evidence,omitempty"` + Applied map[string]interface{} `json:"applied,omitempty"` + Driver DriverResultIdentityV1 `json:"driver"` +} + +type DriverResultIdentityV1 struct { + PackageID string `json:"package_id"` + Version string `json:"version"` + ArtifactSHA256 string `json:"artifact_sha256"` +} + +type luaCommandResultV2 struct { + Status string + Code string + Message string + DeviceState string + Evidence []string + Applied map[string]interface{} +} + +func (p *RuntimePolicy) validateCommand(cmd DriverCommandV1, now time.Time) (RuntimeCommand, error) { + if !p.IsControlV2() { + return RuntimeCommand{}, errors.New("driver control v2 policy is not active") + } + if !p.SiteEnabled { + return RuntimeCommand{}, errors.New("driver control is not enabled for this site and exact artifact") + } + if cmd.SchemaVersion != DriverCommandSchemaV1 || !validControlID(cmd.ID) || + !validControlToken(cmd.Command) || !validControlToken(cmd.Source) || cmd.Attempt < 1 || cmd.Attempt > 16 { + return RuntimeCommand{}, errors.New("invalid sourceful.driver-command/v1 envelope") + } + if cmd.IssuedAt.IsZero() || cmd.ExpiresAt.IsZero() || !cmd.ExpiresAt.After(cmd.IssuedAt) || !now.Before(cmd.ExpiresAt) { + return RuntimeCommand{}, errors.New("driver command has expired or has invalid times") + } + if cmd.IssuedAt.After(now.Add(30*time.Second)) || cmd.ExpiresAt.After(now.Add(30*time.Second)) { + return RuntimeCommand{}, errors.New("driver command time window exceeds the host limit") + } + heartbeat := time.Duration(cmd.Lease.HeartbeatIntervalMS) * time.Millisecond + if !validControlID(cmd.Lease.ID) || cmd.Lease.ExpiresAt.IsZero() || !now.Before(cmd.Lease.ExpiresAt) || + heartbeat < 100*time.Millisecond || heartbeat > 300*time.Second || heartbeat != p.Lease.HeartbeatInterval { + return RuntimeCommand{}, errors.New("driver command has an invalid lease") + } + if p.Lease.MaxDuration <= 0 || cmd.Lease.ExpiresAt.After(now.Add(p.Lease.MaxDuration)) || + !cmd.Lease.ExpiresAt.After(now.Add(heartbeat)) { + return RuntimeCommand{}, errors.New("driver command lease exceeds signed package limit") + } + decl, ok := p.Commands[cmd.Command] + if !ok { + return RuntimeCommand{}, fmt.Errorf("driver command %q is not declared by the signed package", cmd.Command) + } + if len(cmd.Inputs) > 32 { + return RuntimeCommand{}, errors.New("driver command has too many inputs") + } + for name, value := range cmd.Inputs { + input, ok := decl.Inputs[name] + if !ok { + return RuntimeCommand{}, fmt.Errorf("driver command input %q is not declared", name) + } + if err := validateCommandInput(name, value, input.Type); err != nil { + return RuntimeCommand{}, err + } + } + for name, input := range decl.Inputs { + if _, ok := cmd.Inputs[name]; input.Required && !ok { + return RuntimeCommand{}, fmt.Errorf("driver command input %q is required", name) + } + } + return decl, nil +} + +func validateCommandInput(name string, value interface{}, typ string) error { + valid := false + switch typ { + case "number": + _, valid = value.(float64) + case "boolean": + _, valid = value.(bool) + case "string": + v, ok := value.(string) + valid = ok && len(v) <= 512 + } + if !valid { + return fmt.Errorf("driver command input %q must be %s", name, typ) + } + return nil +} + +func parseLuaCommandResult(value interface{}) (luaCommandResultV2, error) { + m, ok := value.(map[string]interface{}) + if !ok { + return luaCommandResultV2{}, errors.New("driver_command_v2 must return a table") + } + allowed := map[string]bool{ + "status": true, "code": true, "message": true, "device_state": true, + "evidence": true, "applied": true, + } + for key := range m { + if !allowed[key] { + return luaCommandResultV2{}, fmt.Errorf("driver result has undeclared field %q", key) + } + } + result := luaCommandResultV2{} + result.Status, _ = m["status"].(string) + result.Code, _ = m["code"].(string) + result.Message, _ = m["message"].(string) + result.DeviceState, _ = m["device_state"].(string) + if !map[string]bool{"accepted": true, "applied": true, "rejected": true, "failed": true, "expired": true, "defaulted": true}[result.Status] { + return luaCommandResultV2{}, fmt.Errorf("driver result status %q is invalid", result.Status) + } + if !validControlToken(result.Code) { + return luaCommandResultV2{}, fmt.Errorf("driver result code %q is invalid", result.Code) + } + if len(result.Message) > 512 { + return luaCommandResultV2{}, errors.New("driver result message exceeds 512 bytes") + } + if !map[string]bool{"controlled": true, "default": true, "unchanged": true, "unknown": true}[result.DeviceState] { + return luaCommandResultV2{}, fmt.Errorf("driver result device_state %q is invalid", result.DeviceState) + } + if raw, ok := m["evidence"]; ok { + list, ok := raw.([]interface{}) + if !ok || len(list) > 3 { + return luaCommandResultV2{}, errors.New("driver result evidence must be an array of at most three values") + } + seen := map[string]bool{} + for _, item := range list { + name, ok := item.(string) + if !ok || !map[string]bool{"write_ack": true, "vendor_ack": true, "readback": true}[name] || seen[name] { + return luaCommandResultV2{}, errors.New("driver result evidence is invalid") + } + seen[name] = true + result.Evidence = append(result.Evidence, name) + } + } + if raw, ok := m["applied"]; ok { + applied, ok := raw.(map[string]interface{}) + if !ok || len(applied) > 32 { + return luaCommandResultV2{}, errors.New("driver result applied must be an object with at most 32 values") + } + for key, value := range applied { + if !validControlToken(key) { + return luaCommandResultV2{}, fmt.Errorf("driver result applied key %q is invalid", key) + } + switch v := value.(type) { + case string: + if len(v) > 512 { + return luaCommandResultV2{}, fmt.Errorf("driver result applied value %q is too long", key) + } + case float64, bool: + default: + return luaCommandResultV2{}, fmt.Errorf("driver result applied value %q is not scalar", key) + } + } + result.Applied = applied + } + return result, nil +} + +func (r DriverCommandResultV1) JSON() []byte { + b, _ := json.Marshal(r) + return b +} + +func validControlToken(value string) bool { + return len(value) <= 128 && controlTokenRE.MatchString(value) +} + +func validControlID(value string) bool { + return len(value) >= 16 && len(value) <= 128 && strings.IndexFunc(value, func(r rune) bool { + return !(r >= 'A' && r <= 'Z') && !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '.' && r != '_' && r != ':' && r != '-' + }) == -1 +} diff --git a/go/internal/drivers/control_v2_test.go b/go/internal/drivers/control_v2_test.go new file mode 100644 index 00000000..2b3cb2f4 --- /dev/null +++ b/go/internal/drivers/control_v2_test.go @@ -0,0 +1,392 @@ +package drivers + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +type controlV2Modbus struct { + registers map[uint16]uint16 + writes int + writeErr error +} + +func (m *controlV2Modbus) Read(addr, count uint16, _ int32) ([]uint16, error) { + out := make([]uint16, count) + for i := range out { + out[i] = m.registers[addr+uint16(i)] + } + return out, nil +} + +func (m *controlV2Modbus) WriteSingle(addr, value uint16) error { + if m.writeErr != nil { + return m.writeErr + } + m.registers[addr] = value + m.writes++ + return nil +} + +func (m *controlV2Modbus) WriteMulti(addr uint16, values []uint16) error { + if m.writeErr != nil { + return m.writeErr + } + for i, value := range values { + m.registers[addr+uint16(i)] = value + } + m.writes++ + return nil +} + +func (m *controlV2Modbus) Close() error { return nil } + +const controlV2Lua = ` +assert(os == nil and io == nil and debug == nil and package == nil) +assert(load == nil and loadfile == nil and dofile == nil and require == nil and getfenv == nil and setfenv == nil) + +init_write_error = nil +poll_write_error = nil + +function driver_init(config) + init_write_error = host.modbus_write(10, 99) +end + +function driver_poll() + poll_write_error = host.modbus_write(10, 98) + return 1000 +end + +function driver_command_v2(command) + local power_w = command.inputs.power_w + local err = host.modbus_write(10, power_w) + if err then + return {status="failed", code="write_failed", message=err, device_state="unchanged"} + end + local values, read_err = host.modbus_read(10, 1, "holding") + if read_err or values[1] ~= power_w then + return {status="failed", code="readback_failed", message=read_err or "mismatch", device_state="unknown"} + end + return { + status="applied", code="ok", device_state="controlled", + evidence={"write_ack", "readback"}, applied={power_w=power_w} + } +end + +function driver_default_mode_v2(context) + local err = host.modbus_write(10, 0) + if err then + return {status="failed", code="default_write_failed", message=err, device_state="unknown"} + end + local values, read_err = host.modbus_read(10, 1, "holding") + if read_err or values[1] ~= 0 then + return {status="failed", code="default_readback_failed", message=read_err or "mismatch", device_state="unknown"} + end + return {status="defaulted", code="default_restored", device_state="default", evidence={"write_ack", "readback"}} +end +` + +func testControlV2Policy(enabled bool) *RuntimePolicy { + return &RuntimePolicy{ + PackageID: "com.sourceful.driver.test", Version: "1.0.0", + ArtifactSHA256: strings.Repeat("a", 64), RuntimeABI: ControlRuntimeABIV2, + HostAPIProfile: ControlHostAPIProfileV2, + Permissions: map[string]bool{"modbus.read": true, "modbus.write": true}, + Commands: map[string]RuntimeCommand{ + "battery.set_power": { + ID: "battery.set_power", RuntimeAction: "battery", + Inputs: map[string]RuntimeCommandInput{"power_w": {Type: "number", Required: true}}, + }, + }, + DefaultMode: "driver_default_mode_v2", + Lease: RuntimeLeasePolicy{ + MaxDuration: 30 * time.Second, HeartbeatInterval: 10 * time.Second, ExpiryAction: "return_to_default", + }, + SiteEnabled: enabled, + } +} + +func TestControlV2PolicyRejectsNetworkPermissions(t *testing.T) { + for _, permission := range []string{"http.get", "http.post", "mqtt.subscribe", "mqtt.publish"} { + policy := testControlV2Policy(true) + policy.Permissions[permission] = true + if err := policy.validate(); err == nil || !strings.Contains(err.Error(), "only supports Modbus") { + t.Fatalf("permission %q validation error = %v", permission, err) + } + } +} + +func loadControlV2Driver(t *testing.T, policy *RuntimePolicy) (*LuaDriver, *controlV2Modbus) { + return loadControlV2DriverSource(t, policy, controlV2Lua) +} + +func loadControlV2DriverSource(t *testing.T, policy *RuntimePolicy, source string) (*LuaDriver, *controlV2Modbus) { + t.Helper() + path := filepath.Join(t.TempDir(), "control.lua") + if err := os.WriteFile(path, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + modbus := &controlV2Modbus{registers: make(map[uint16]uint16)} + env := NewHostEnv("control-v2", telemetry.NewStore()).WithModbus(modbus) + driver, err := NewLuaDriverWithPolicy(path, env, policy) + if err != nil { + t.Fatal(err) + } + t.Cleanup(driver.Cleanup) + return driver, modbus +} + +func commandV2(now time.Time) DriverCommandV1 { + return DriverCommandV1{ + SchemaVersion: DriverCommandSchemaV1, + ID: "ftw.command:0123456789abcdef", Command: "battery.set_power", Source: "ftw.control", + IssuedAt: now, ExpiresAt: now.Add(5 * time.Second), Attempt: 1, + Lease: DriverCommandLeaseV1{ + ID: "ftw.lease:0123456789abcdef", ExpiresAt: now.Add(20 * time.Second), HeartbeatIntervalMS: 10000, + }, + Inputs: map[string]interface{}{"power_w": float64(750)}, + } +} + +func TestControlV2RestrictsWritesAndReturnsHostEvidence(t *testing.T) { + driver, modbus := loadControlV2Driver(t, testControlV2Policy(true)) + if err := driver.Init(context.Background(), map[string]interface{}{}); err != nil { + t.Fatal(err) + } + if modbus.writes != 0 { + t.Fatalf("init wrote to hardware: %d writes", modbus.writes) + } + if _, err := driver.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if modbus.writes != 0 { + t.Fatalf("poll wrote to hardware: %d writes", modbus.writes) + } + + now := time.Now() + result, err := driver.CommandV2(context.Background(), commandV2(now), now) + if err != nil { + t.Fatal(err) + } + if result.Status != "applied" || result.DeviceState != "controlled" || result.Writes != 1 { + t.Fatalf("unexpected command result: %+v", result) + } + if modbus.registers[10] != 750 { + t.Fatalf("register = %d, want 750", modbus.registers[10]) + } + + defaultResult, err := driver.DefaultModeV2(context.Background(), "ftw.default:0123456789abcdef", "test", time.Now()) + if err != nil { + t.Fatal(err) + } + if defaultResult.Status != "defaulted" || defaultResult.DeviceState != "default" || modbus.registers[10] != 0 { + t.Fatalf("unexpected default result: %+v register=%d", defaultResult, modbus.registers[10]) + } +} + +func TestControlV2RequiresExactSiteOptIn(t *testing.T) { + driver, modbus := loadControlV2Driver(t, testControlV2Policy(false)) + now := time.Now() + result, err := driver.CommandV2(context.Background(), commandV2(now), now) + if err == nil || result.Status != "rejected" || modbus.writes != 0 { + t.Fatalf("result=%+v err=%v writes=%d", result, err, modbus.writes) + } +} + +func TestControlV2RejectsExpiredCommand(t *testing.T) { + driver, modbus := loadControlV2Driver(t, testControlV2Policy(true)) + now := time.Now() + cmd := commandV2(now.Add(-time.Minute)) + result, err := driver.CommandV2(context.Background(), cmd, now) + if err == nil || result.Status != "expired" || modbus.writes != 0 { + t.Fatalf("result=%+v err=%v writes=%d", result, err, modbus.writes) + } +} + +func TestControlV2RejectsReadbackTakenBeforeWrite(t *testing.T) { + source := ` +function driver_command_v2(command) + local before, read_err = host.modbus_read(10, 1, "holding") + if read_err then error(read_err) end + local write_err = host.modbus_write(10, command.inputs.power_w) + if write_err then error(write_err) end + return {status="applied", code="ok", device_state="controlled", evidence={"readback"}} +end +function driver_default_mode_v2(context) + local write_err = host.modbus_write(10, 0) + if write_err then error(write_err) end + local value, read_err = host.modbus_read(10, 1, "holding") + if read_err then error(read_err) end + return {status="defaulted", code="ok", device_state="default", evidence={"readback"}} +end +` + driver, _ := loadControlV2DriverSource(t, testControlV2Policy(true), source) + now := time.Now() + result, err := driver.CommandV2(context.Background(), commandV2(now), now) + if err == nil || result.Status != "failed" || result.Code != "evidence_unproven" || result.Writes != 1 { + t.Fatalf("result=%+v err=%v", result, err) + } +} + +func TestControlV2RejectsReadbackAfterFailedWrite(t *testing.T) { + source := ` +function driver_command_v2(command) + host.modbus_write(10, command.inputs.power_w) + local value = host.modbus_read(10, 1, "holding") + return {status="applied", code="ok", device_state="controlled", evidence={"write_ack", "readback"}} +end +function driver_default_mode_v2(context) + return {status="failed", code="unused", device_state="unknown"} +end +` + driver, modbus := loadControlV2DriverSource(t, testControlV2Policy(true), source) + modbus.registers[10] = 750 + modbus.writeErr = errors.New("device write failed") + now := time.Now() + result, err := driver.CommandV2(context.Background(), commandV2(now), now) + if err == nil || result.Status != "failed" || result.Code != "evidence_unproven" || result.Writes != 0 { + t.Fatalf("result=%+v err=%v", result, err) + } +} + +func TestControlV2LifecycleHonorsCanceledContext(t *testing.T) { + source := ` +function driver_init(config) + while true do end +end +function driver_default_mode_v2(context) + return {status="failed", code="unused", device_state="unknown"} +end +` + driver, _ := loadControlV2DriverSource(t, testControlV2Policy(true), source) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := driver.Init(ctx, nil); err == nil { + t.Fatal("canceled v2 init was not interrupted") + } +} + +func TestControlV2RegistryRestoresDefaultAfterPartialFailure(t *testing.T) { + source := ` +function driver_init(config) end +function driver_poll() return 60000 end +function driver_command_v2(command) + local write_err = host.modbus_write(10, command.inputs.power_w) + if write_err then error(write_err) end + local value, read_err = host.modbus_read(10, 1, "holding") + if read_err then error(read_err) end + return {status="failed", code="device_rejected", device_state="unknown", evidence={"write_ack", "readback"}} +end +function driver_default_mode_v2(context) + local write_err = host.modbus_write(10, 0) + if write_err then error(write_err) end + local value, read_err = host.modbus_read(10, 1, "holding") + if read_err then error(read_err) end + return {status="defaulted", code="default_restored", device_state="default", evidence={"write_ack", "readback"}} +end +` + path := filepath.Join(t.TempDir(), "control.lua") + if err := os.WriteFile(path, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + modbus := &controlV2Modbus{registers: make(map[uint16]uint16)} + registry := NewRegistry(telemetry.NewStore()) + registry.RuntimePolicyResolver = func(config.Driver) (*RuntimePolicy, error) { + return testControlV2Policy(true), nil + } + registry.ModbusFactory = func(string, *config.ModbusConfig) (ModbusCap, error) { + return modbus, nil + } + var results []DriverCommandResultV1 + registry.CommandResultSink = func(_ string, result DriverCommandResultV1) { + results = append(results, result) + } + cfg := config.Driver{ + Name: "control-v2", Lua: path, + Modbus: &config.ModbusConfig{Host: "device", Port: 502}, + } + if err := registry.Add(context.Background(), cfg); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { registry.remove(cfg.Name, true) }) + if err := registry.Send(context.Background(), cfg.Name, []byte(`{"action":"battery","power_w":750}`)); err == nil { + t.Fatal("partly applied failed command was accepted") + } + if modbus.registers[10] != 0 || modbus.writes != 3 { + t.Fatalf("register=%d writes=%d, want default register and startup/command/fallback writes", modbus.registers[10], modbus.writes) + } + if len(results) != 3 || results[1].Status != "failed" || results[2].Status != "defaulted" { + t.Fatalf("command results = %+v", results) + } +} + +func TestControlV2RegistryExpiresLeaseIntoDefault(t *testing.T) { + source := ` +function driver_init(config) end +function driver_poll() return 60000 end +function driver_command_v2(command) + local write_err = host.modbus_write(10, command.inputs.power_w) + if write_err then error(write_err) end + local value, read_err = host.modbus_read(10, 1, "holding") + if read_err then error(read_err) end + return {status="applied", code="ok", device_state="controlled", evidence={"write_ack", "readback"}} +end +function driver_default_mode_v2(context) + local write_err = host.modbus_write(10, 0) + if write_err then error(write_err) end + local value, read_err = host.modbus_read(10, 1, "holding") + if read_err then error(read_err) end + return {status="defaulted", code="default_restored", device_state="default", evidence={"write_ack", "readback"}} +end +` + path := filepath.Join(t.TempDir(), "control.lua") + if err := os.WriteFile(path, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + modbus := &controlV2Modbus{registers: make(map[uint16]uint16)} + policy := testControlV2Policy(true) + policy.Lease.MaxDuration = 2 * time.Second + policy.Lease.HeartbeatInterval = time.Second + registry := NewRegistry(telemetry.NewStore()) + registry.RuntimePolicyResolver = func(config.Driver) (*RuntimePolicy, error) { return policy, nil } + registry.ModbusFactory = func(string, *config.ModbusConfig) (ModbusCap, error) { return modbus, nil } + resultCh := make(chan DriverCommandResultV1, 4) + registry.CommandResultSink = func(_ string, result DriverCommandResultV1) { resultCh <- result } + cfg := config.Driver{ + Name: "control-v2", Lua: path, + Modbus: &config.ModbusConfig{Host: "device", Port: 502}, + } + if err := registry.Add(context.Background(), cfg); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { registry.remove(cfg.Name, true) }) + <-resultCh // startup default + if err := registry.Send(context.Background(), cfg.Name, []byte(`{"action":"battery","power_w":750}`)); err != nil { + t.Fatal(err) + } + if modbus.registers[10] != 750 { + t.Fatalf("controlled register = %d, want 750", modbus.registers[10]) + } + deadline := time.After(4 * time.Second) + for { + select { + case result := <-resultCh: + if result.Status == "defaulted" { + if modbus.registers[10] != 0 { + t.Fatalf("expired lease left register at %d", modbus.registers[10]) + } + return + } + case <-deadline: + t.Fatal("lease did not restore default mode") + } + } +} diff --git a/go/internal/drivers/host.go b/go/internal/drivers/host.go index 539b6c74..670d9c47 100644 --- a/go/internal/drivers/host.go +++ b/go/internal/drivers/host.go @@ -99,6 +99,10 @@ type HostEnv struct { // callers / tests can inspect what was granted. TCPAllowedHosts []string Start time.Time // monotonic start; host.millis() computed from here + // RuntimePolicy is nil for bundled, local and managed v1 drivers. A signed + // v2 policy narrows every host call and permits writes only inside a bounded + // command/default-mode call. + RuntimePolicy *RuntimePolicy // BatteryCapacityWh mirrors the operator's `battery_capacity_wh` // declaration for this driver. Zero means "no physical battery @@ -135,6 +139,11 @@ type HostEnv struct { // closure (see registry.go SecretPersister). Keep the value small: // it is round-tripped through config.yaml as a plain string. PersistSecret func(key, value string) error + writePhase string + writeDeadline time.Time + writeAttempts int + writeCount int + writeEvidence map[string]bool } // NewHostEnv creates a fresh host environment for a driver. @@ -148,6 +157,105 @@ func NewHostEnv(name string, tel *telemetry.Store) *HostEnv { } } +func (h *HostEnv) WithRuntimePolicy(policy *RuntimePolicy) *HostEnv { + h.RuntimePolicy = policy + return h +} + +func (h *HostEnv) permissionAllowed(permission string) bool { + return h.RuntimePolicy == nil || h.RuntimePolicy.allows(permission) +} + +func (h *HostEnv) beginWriteScope(phase string, deadline time.Time) error { + if h.RuntimePolicy == nil { + return nil + } + if !h.RuntimePolicy.IsControlV2() { + return errors.New("managed driver has an unsupported control runtime") + } + if phase != "command" && phase != "default" { + return fmt.Errorf("invalid driver write phase %q", phase) + } + if deadline.IsZero() || !time.Now().Before(deadline) { + return errors.New("driver write scope has expired") + } + h.mu.Lock() + h.writePhase = phase + h.writeDeadline = deadline + h.writeAttempts = 0 + h.writeCount = 0 + h.writeEvidence = make(map[string]bool) + h.mu.Unlock() + return nil +} + +func (h *HostEnv) endWriteScope() (int, []string) { + if h.RuntimePolicy == nil { + return 0, nil + } + h.mu.Lock() + writes := h.writeCount + evidence := make([]string, 0, len(h.writeEvidence)) + for _, name := range []string{"write_ack", "vendor_ack", "readback"} { + if h.writeEvidence[name] { + evidence = append(evidence, name) + } + } + h.writePhase = "" + h.writeDeadline = time.Time{} + h.writeAttempts = 0 + h.writeCount = 0 + h.writeEvidence = nil + h.mu.Unlock() + return writes, evidence +} + +func (h *HostEnv) allowWrite(permission string) error { + if h.RuntimePolicy == nil { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + if !h.RuntimePolicy.allows(permission) { + return fmt.Errorf("%s: permission not granted by signed package", permission) + } + if h.writePhase != "command" && h.writePhase != "default" { + return fmt.Errorf("%s: write is not allowed during init, poll, or cleanup", permission) + } + if h.writeDeadline.IsZero() || !time.Now().Before(h.writeDeadline) { + h.writePhase = "" + return fmt.Errorf("%s: write scope expired", permission) + } + if h.writeAttempts >= h.RuntimePolicy.maxWrites() { + return fmt.Errorf("%s: write budget exhausted", permission) + } + h.writeAttempts++ + return nil +} + +func (h *HostEnv) recordWriteEvidence(name string) { + if h.RuntimePolicy == nil { + return + } + h.mu.Lock() + if h.writePhase == "command" || h.writePhase == "default" { + if name == "write_ack" { + h.writeCount++ + } + // Readback proves an applied write only when the read happened after + // at least one successful host write in this scope. + if name == "readback" && h.writeCount == 0 { + h.mu.Unlock() + return + } + if h.writeEvidence == nil { + h.writeEvidence = make(map[string]bool) + } + h.writeEvidence[name] = true + } + h.mu.Unlock() +} + // WithMQTT binds an MQTT capability to this host. func (h *HostEnv) WithMQTT(m MQTTCap) *HostEnv { h.MQTT = m; return h } diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go index 09ada803..fcb97e80 100644 --- a/go/internal/drivers/lua.go +++ b/go/internal/drivers/lua.go @@ -49,6 +49,7 @@ import ( "crypto/tls" "encoding/hex" "encoding/json" + "errors" "fmt" "io" net_http "net/http" @@ -74,14 +75,42 @@ type LuaDriver struct { // The driver's top-level is executed once so `driver_init` etc. become // callable globals. Returns an error if the file fails to load/execute. func NewLuaDriver(path string, env *HostEnv) (*LuaDriver, error) { + return NewLuaDriverWithPolicy(path, env, nil) +} + +// NewLuaDriverWithPolicy selects the restricted v2 surface only from verified +// managed package metadata. Local, bundled and v1 managed drivers keep the +// existing Lua 5.1 environment. +func NewLuaDriverWithPolicy(path string, env *HostEnv, policy *RuntimePolicy) (*LuaDriver, error) { + if policy != nil { + if err := policy.validate(); err != nil { + return nil, fmt.Errorf("control runtime policy: %w", err) + } + } src, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read %s: %w", path, err) } - L := lua.NewState(lua.Options{SkipOpenLibs: false}) + restricted := policy != nil && policy.IsControlV2() + L := lua.NewState(lua.Options{SkipOpenLibs: restricted}) + if restricted { + openRestrictedLibraries(L) + env.WithRuntimePolicy(policy) + } d := &LuaDriver{Env: env, Path: path, L: L} registerHost(L, env) - if err := L.DoString(string(src)); err != nil { + var loadCancel context.CancelFunc + if restricted { + loadCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + loadCancel = cancel + L.SetContext(loadCtx) + } + err = L.DoString(string(src)) + if restricted { + L.RemoveContext() + loadCancel() + } + if err != nil { L.Close() return nil, fmt.Errorf("execute %s: %w", path, err) } @@ -96,6 +125,34 @@ func NewLuaDriver(path string, env *HostEnv) (*LuaDriver, error) { return d, nil } +func openRestrictedLibraries(L *lua.LState) { + for _, lib := range []struct { + name string + open lua.LGFunction + }{ + {lua.BaseLibName, lua.OpenBase}, + {lua.TabLibName, lua.OpenTable}, + {lua.StringLibName, lua.OpenString}, + {lua.MathLibName, lua.OpenMath}, + } { + L.Push(L.NewFunction(lib.open)) + L.Push(lua.LString(lib.name)) + L.Call(1, 0) + } + for _, name := range []string{ + "collectgarbage", "dofile", "load", "loadfile", "loadstring", "module", + "newproxy", "require", "getfenv", "setfenv", + } { + L.SetGlobal(name, lua.LNil) + } + L.SetGlobal("package", lua.LNil) + L.SetGlobal("os", lua.LNil) + L.SetGlobal("io", lua.LNil) + L.SetGlobal("debug", lua.LNil) + L.SetGlobal("channel", lua.LNil) + L.SetGlobal("coroutine", lua.LNil) +} + func driverDeclaresReadOnlyBattery(L *lua.LState) bool { meta, ok := L.GetGlobal("DRIVER").(*lua.LTable) if !ok || meta.RawGetString("read_only") != lua.LTrue { @@ -128,6 +185,8 @@ func (d *LuaDriver) Init(ctx context.Context, config map[string]any) error { if config != nil { arg = goToLua(d.L, config) } + cleanup := d.setLifecycleContext(ctx, 10*time.Second) + defer cleanup() return d.L.CallByParam(lua.P{Fn: fn, NRet: 0, Protect: true}, arg) } @@ -140,6 +199,8 @@ func (d *LuaDriver) Poll(ctx context.Context) (time.Duration, error) { if fn == lua.LNil { return 0, nil } + cleanup := d.setLifecycleContext(ctx, 10*time.Second) + defer cleanup() if err := d.L.CallByParam(lua.P{Fn: fn, NRet: 1, Protect: true}); err != nil { return 0, err } @@ -157,6 +218,9 @@ func (d *LuaDriver) Poll(ctx context.Context) (time.Duration, error) { // where full_cmd is the original decoded table (for drivers that want // extra fields). func (d *LuaDriver) Command(ctx context.Context, cmdJSON []byte) error { + if d.Env.RuntimePolicy != nil && d.Env.RuntimePolicy.IsControlV2() { + return errors.New("managed control v2 driver requires a sourceful.driver-command/v1 call") + } d.mu.Lock() defer d.mu.Unlock() fn := d.L.GetGlobal("driver_command") @@ -182,6 +246,230 @@ func (d *LuaDriver) Command(ctx context.Context, cmdJSON []byte) error { return luaReturnError("driver_command", ret) } +// CommandV2 validates a host-owned command, grants a short write scope, calls +// the v2 entrypoint and completes the signed-package-bound result. Validation +// happens before Lua runs. +func (d *LuaDriver) CommandV2(ctx context.Context, cmd DriverCommandV1, now time.Time) (DriverCommandResultV1, error) { + policy := d.Env.RuntimePolicy + result := d.commandResult(cmd, now) + decl, err := policy.validateCommand(cmd, now) + if err != nil { + result.Status = "rejected" + result.Code = "host_validation_failed" + if strings.Contains(err.Error(), "expired") { + result.Status = "expired" + result.Code = "command_expired" + } + result.Message = err.Error() + return result, err + } + + d.mu.Lock() + defer d.mu.Unlock() + fn := d.L.GetGlobal("driver_command_v2") + if fn == lua.LNil { + err = errors.New("driver_command_v2 is required by the control v2 ABI") + result.Status, result.Code, result.Message = "failed", "entrypoint_missing", err.Error() + return result, err + } + + deadline := cmd.ExpiresAt + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + if err := d.Env.beginWriteScope("command", deadline); err != nil { + result.Status, result.Code, result.Message = "failed", "write_scope_denied", err.Error() + return result, err + } + writes := 0 + var hostEvidence []string + ended := false + endScope := func() { + if !ended { + writes, hostEvidence = d.Env.endWriteScope() + ended = true + } + } + defer endScope() + + callCtx, cancel := boundedLuaContext(ctx, deadline) + defer cancel() + d.L.SetContext(callCtx) + defer d.L.RemoveContext() + payload := map[string]interface{}{} + raw, _ := json.Marshal(cmd) + _ = json.Unmarshal(raw, &payload) + // runtime_action is signed package adapter data. The canonical command ID + // stays unchanged; the adapter may use this field while old action names are + // phased out. + payload["runtime_action"] = decl.RuntimeAction + if err := d.L.CallByParam(lua.P{Fn: fn, NRet: 1, Protect: true}, goToLua(d.L, payload)); err != nil { + endScope() + result.Status, result.Code, result.Message, result.Writes = "failed", "lua_call_failed", err.Error(), writes + return result, err + } + ret := d.L.Get(-1) + d.L.Pop(1) + endScope() + parsed, err := parseLuaCommandResult(luaToGo(ret)) + if err != nil { + result.Status, result.Code, result.Message, result.Writes = "failed", "invalid_driver_result", err.Error(), writes + return result, err + } + result.Status, result.Code, result.Message = parsed.Status, parsed.Code, parsed.Message + result.DeviceState, result.Writes = parsed.DeviceState, writes + result.Evidence, result.Applied = parsed.Evidence, parsed.Applied + if !evidenceContained(parsed.Evidence, hostEvidence) { + err = errors.New("driver result claims evidence not observed by the host") + result.Status, result.Code, result.Message = "failed", "evidence_unproven", err.Error() + return result, err + } + if result.Status == "applied" && + ((result.DeviceState != "controlled" && result.DeviceState != "default") || result.Writes == 0 || + !containsEvidence(result.Evidence, policy.requiredEvidence())) { + err = errors.New("applied result requires a known state, at least one write, and evidence") + result.Status, result.Code, result.Message = "failed", "application_unproven", err.Error() + return result, err + } + return result, nil +} + +// DefaultModeV2 runs only the signed default-mode entrypoint with its own +// bounded write scope. It never calls the legacy default function. +func (d *LuaDriver) DefaultModeV2(ctx context.Context, id, reason string, now time.Time) (DriverCommandResultV1, error) { + policy := d.Env.RuntimePolicy + cmd := DriverCommandV1{ID: id, Command: "driver.default_mode", Lease: DriverCommandLeaseV1{ID: id}} + result := d.commandResult(cmd, now) + result.LeaseID = "" + if policy == nil || !policy.IsControlV2() || policy.DefaultMode != "driver_default_mode_v2" { + err := errors.New("driver_default_mode_v2 is not declared by the signed package") + result.Status, result.Code, result.Message = "failed", "default_mode_not_declared", err.Error() + return result, err + } + + d.mu.Lock() + defer d.mu.Unlock() + fn := d.L.GetGlobal("driver_default_mode_v2") + if fn == lua.LNil { + err := errors.New("driver_default_mode_v2 is required by the control v2 ABI") + result.Status, result.Code, result.Message = "failed", "entrypoint_missing", err.Error() + return result, err + } + deadline := now.Add(5 * time.Second) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + if err := d.Env.beginWriteScope("default", deadline); err != nil { + result.Status, result.Code, result.Message = "failed", "write_scope_denied", err.Error() + return result, err + } + writes := 0 + var hostEvidence []string + ended := false + endScope := func() { + if !ended { + writes, hostEvidence = d.Env.endWriteScope() + ended = true + } + } + defer endScope() + callCtx, cancel := boundedLuaContext(ctx, deadline) + defer cancel() + d.L.SetContext(callCtx) + defer d.L.RemoveContext() + callArg := goToLua(d.L, map[string]interface{}{ + "reason": reason, + "at": now.UTC().Format(time.RFC3339Nano), + }) + if err := d.L.CallByParam(lua.P{Fn: fn, NRet: 1, Protect: true}, callArg); err != nil { + endScope() + result.Status, result.Code, result.Message, result.Writes = "failed", "lua_call_failed", err.Error(), writes + return result, err + } + ret := d.L.Get(-1) + d.L.Pop(1) + endScope() + parsed, err := parseLuaCommandResult(luaToGo(ret)) + if err != nil { + result.Status, result.Code, result.Message, result.Writes = "failed", "invalid_driver_result", err.Error(), writes + return result, err + } + result.Status, result.Code, result.Message = parsed.Status, parsed.Code, parsed.Message + result.DeviceState, result.Writes = parsed.DeviceState, writes + result.Evidence, result.Applied = parsed.Evidence, parsed.Applied + if !evidenceContained(parsed.Evidence, hostEvidence) { + err = errors.New("driver default result claims evidence not observed by the host") + result.Status, result.Code, result.Message = "failed", "evidence_unproven", err.Error() + return result, err + } + if result.Status != "defaulted" || result.DeviceState != "default" || result.Writes == 0 || + !containsEvidence(result.Evidence, policy.requiredEvidence()) { + err = errors.New("default result requires defaulted status, default state, a write, and host-observed evidence") + result.Status, result.Code, result.Message = "failed", "default_unproven", err.Error() + return result, err + } + return result, nil +} + +func boundedLuaContext(parent context.Context, deadline time.Time) (context.Context, context.CancelFunc) { + if parent == nil { + parent = context.Background() + } + return context.WithDeadline(parent, deadline) +} + +func evidenceContained(claimed, observed []string) bool { + seen := make(map[string]bool, len(observed)) + for _, item := range observed { + seen[item] = true + } + for _, item := range claimed { + if !seen[item] { + return false + } + } + return true +} + +func containsEvidence(evidence []string, want string) bool { + for _, item := range evidence { + if item == want { + return true + } + } + return false +} + +func (d *LuaDriver) setLifecycleContext(parent context.Context, timeout time.Duration) func() { + if d.Env.RuntimePolicy == nil || !d.Env.RuntimePolicy.IsControlV2() { + return func() {} + } + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, timeout) + d.L.SetContext(ctx) + return func() { + d.L.RemoveContext() + cancel() + } +} + +func (d *LuaDriver) commandResult(cmd DriverCommandV1, now time.Time) DriverCommandResultV1 { + policy := d.Env.RuntimePolicy + result := DriverCommandResultV1{ + SchemaVersion: DriverCommandResultSchema, + ID: cmd.ID, Command: cmd.Command, LeaseID: cmd.Lease.ID, + Status: "failed", Code: "host_error", CompletedAt: now.UTC(), DeviceState: "unknown", + } + if policy != nil { + result.Driver = DriverResultIdentityV1{ + PackageID: policy.PackageID, Version: policy.Version, ArtifactSHA256: policy.ArtifactSHA256, + } + } + return result +} + // Cleanup calls driver_cleanup() and closes the VM. func (d *LuaDriver) Cleanup() { _ = d.call("driver_cleanup") @@ -204,6 +492,8 @@ func (d *LuaDriver) call(name string) error { if fn == lua.LNil { return nil } + cleanup := d.setLifecycleContext(context.Background(), 5*time.Second) + defer cleanup() if err := d.L.CallByParam(lua.P{Fn: fn, NRet: 1, Protect: true}); err != nil { return err } @@ -334,6 +624,10 @@ func registerHost(L *lua.LState, env *HostEnv) { // safe because each driver has its own goroutine and VM lock. host.RawSetString("sleep", L.NewFunction(func(L *lua.LState) int { ms := L.CheckInt(1) + if env.RuntimePolicy != nil && ms > 100 { + L.Push(lua.LString("sleep exceeds control v2 limit of 100 ms")) + return 1 + } if ms > 0 { time.Sleep(time.Duration(ms) * time.Millisecond) } @@ -393,7 +687,7 @@ func registerHost(L *lua.LState, env *HostEnv) { host.RawSetString("persist_secret", L.NewFunction(func(L *lua.LState) int { key := L.CheckString(1) val := L.CheckString(2) - if env.PersistSecret == nil { + if env.RuntimePolicy != nil || env.PersistSecret == nil { L.Push(lua.LBool(false)) L.Push(lua.LString("persist_secret: capability not granted")) return 2 @@ -410,6 +704,10 @@ func registerHost(L *lua.LState, env *HostEnv) { mqttSubscribe := L.NewFunction(func(L *lua.LState) int { topic := L.CheckString(1) + if !env.permissionAllowed("mqtt.subscribe") { + L.Push(lua.LString("mqtt.subscribe: permission not granted by signed package")) + return 1 + } if env.MQTT == nil { L.Push(lua.LString("no mqtt capability")) return 1 @@ -430,16 +728,25 @@ func registerHost(L *lua.LState, env *HostEnv) { L.Push(lua.LString("no mqtt capability")) return 1 } + if err := env.allowWrite("mqtt.publish"); err != nil { + L.Push(lua.LString(err.Error())) + return 1 + } if err := env.MQTT.Publish(topic, []byte(payload)); err != nil { L.Push(lua.LString(err.Error())) return 1 } + env.recordWriteEvidence("write_ack") return 0 }) host.RawSetString("mqtt_publish", mqttPublish) host.RawSetString("mqtt_pub", mqttPublish) // alias host.RawSetString("mqtt_messages", L.NewFunction(func(L *lua.LState) int { + if !env.permissionAllowed("mqtt.subscribe") { + L.Push(L.NewTable()) + return 1 + } if env.MQTT == nil { L.Push(L.NewTable()) return 1 @@ -460,6 +767,11 @@ func registerHost(L *lua.LState, env *HostEnv) { addr := L.CheckInt(1) count := L.CheckInt(2) kindS := L.CheckString(3) + if !env.permissionAllowed("modbus.read") { + L.Push(lua.LNil) + L.Push(lua.LString("modbus.read: permission not granted by signed package")) + return 2 + } if env.Modbus == nil { L.Push(lua.LNil) L.Push(lua.LString("no modbus capability")) @@ -477,6 +789,7 @@ func registerHost(L *lua.LState, env *HostEnv) { L.Push(lua.LString(err.Error())) return 2 } + env.recordWriteEvidence("readback") t := L.NewTable() for i, r := range regs { t.RawSetInt(i+1, lua.LNumber(r)) @@ -492,10 +805,15 @@ func registerHost(L *lua.LState, env *HostEnv) { L.Push(lua.LString("no modbus capability")) return 1 } + if err := env.allowWrite("modbus.write"); err != nil { + L.Push(lua.LString(err.Error())) + return 1 + } if err := env.Modbus.WriteSingle(uint16(addr), uint16(val)); err != nil { L.Push(lua.LString(err.Error())) return 1 } + env.recordWriteEvidence("write_ack") return 0 })) @@ -506,6 +824,10 @@ func registerHost(L *lua.LState, env *HostEnv) { L.Push(lua.LString("no modbus capability")) return 1 } + if err := env.allowWrite("modbus.write"); err != nil { + L.Push(lua.LString(err.Error())) + return 1 + } vals := make([]uint16, 0, t.Len()) t.ForEach(func(_ lua.LValue, v lua.LValue) { if n, ok := v.(lua.LNumber); ok { @@ -516,6 +838,7 @@ func registerHost(L *lua.LState, env *HostEnv) { L.Push(lua.LString(err.Error())) return 1 } + env.recordWriteEvidence("write_ack") return 0 })) @@ -710,6 +1033,11 @@ func registerHost(L *lua.LState, env *HostEnv) { } host.RawSetString("http_get", L.NewFunction(func(L *lua.LState) int { + if !env.permissionAllowed("http.get") { + L.Push(lua.LNil) + L.Push(lua.LString("http.get: permission not granted by signed package")) + return 2 + } if !env.HTTP { L.Push(lua.LNil) L.Push(lua.LString("http: capability not granted")) @@ -756,6 +1084,11 @@ func registerHost(L *lua.LState, env *HostEnv) { L.Push(lua.LString("http: capability not granted")) return 2 } + if err := env.allowWrite("http.post"); err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } url := L.CheckString(1) if ok, reason := hostAllowed(url); !ok { L.Push(lua.LNil) @@ -789,6 +1122,7 @@ func registerHost(L *lua.LState, env *HostEnv) { L.Push(lua.LString(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)))) return 2 } + env.recordWriteEvidence("write_ack") L.Push(lua.LString(string(body))) return 1 })) @@ -938,6 +1272,17 @@ func registerHost(L *lua.LState, env *HostEnv) { return 0 })) + if env.RuntimePolicy != nil { + // The canonical package contract has no WebSocket or raw TCP grant. + // Do not leave these functions reachable in a v2 VM even when local + // YAML happens to configure those legacy capabilities. + for _, name := range []string{ + "persist_secret", "ws_open", "ws_send", "ws_messages", "ws_is_open", "ws_close", + "tcp_open", "tcp_recv", "tcp_is_open", "tcp_close", + } { + host.RawSetString(name, lua.LNil) + } + } L.SetGlobal("host", host) } diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index 10df641c..e3394460 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -2,7 +2,10 @@ package drivers import ( "context" + "crypto/rand" + "encoding/hex" "encoding/json" + "errors" "fmt" "log/slog" "net/url" @@ -39,6 +42,12 @@ type Registry struct { // config.yaml value at driver_init so a rotated token survives a // restart. Returns ("", false) when no override exists. SecretOverride func(driverName, key string) (string, bool) + // RuntimePolicyResolver returns verified signed policy for a managed + // artifact. Nil means legacy/bundled/local v1 behavior. + RuntimePolicyResolver func(config.Driver) (*RuntimePolicy, error) + // CommandResultSink records completed v2 command and default-mode results. + // A nil sink keeps tests and legacy setups simple. + CommandResultSink func(driverName string, result DriverCommandResultV1) mu sync.Mutex rec map[string]*runningDriver @@ -71,6 +80,11 @@ type driverRuntime interface { Env() *HostEnv } +type controlV2Runtime interface { + CommandV2(context.Context, DriverCommandV1, time.Time) (DriverCommandResultV1, error) + DefaultModeV2(context.Context, string, string, time.Time) (DriverCommandResultV1, error) +} + // luaRuntime adapts *LuaDriver to driverRuntime. LuaDriver's internal // signatures take a map (not raw JSON) for ergonomics, so we decode // once at the boundary. @@ -86,6 +100,12 @@ func (l *luaRuntime) Init(ctx context.Context, cfg []byte) error { func (l *luaRuntime) DefaultMode(ctx context.Context) error { return l.LuaDriver.DefaultMode() } func (l *luaRuntime) Cleanup(ctx context.Context) error { l.LuaDriver.Cleanup(); return nil } func (l *luaRuntime) Env() *HostEnv { return l.LuaDriver.Env } +func (l *luaRuntime) CommandV2(ctx context.Context, cmd DriverCommandV1, now time.Time) (DriverCommandResultV1, error) { + return l.LuaDriver.CommandV2(ctx, cmd, now) +} +func (l *luaRuntime) DefaultModeV2(ctx context.Context, id, reason string, now time.Time) (DriverCommandResultV1, error) { + return l.LuaDriver.DefaultModeV2(ctx, id, reason, now) +} func driverInitConfigJSON(cfg config.Driver, troubleshootingMode bool) []byte { if len(cfg.Config) == 0 && !troubleshootingMode && !cfg.SupportsPVCurtail { @@ -109,9 +129,12 @@ func driverInitConfigJSON(cfg config.Driver, troubleshootingMode bool) []byte { } type runningDriver struct { - driver driverRuntime - env *HostEnv - cfg config.Driver + driver driverRuntime + env *HostEnv + cfg config.Driver + policy *RuntimePolicy + leaseExpiresAt time.Time + controlBlocked bool // Poll loop coordination cmdCh chan driverCmd stop chan bool @@ -138,6 +161,14 @@ func (r *Registry) Add(ctx context.Context, cfg config.Driver) error { if cfg.Lua == "" { return fmt.Errorf("driver %q: must specify `lua` path", cfg.Name) } + var policy *RuntimePolicy + if r.RuntimePolicyResolver != nil { + resolved, policyErr := r.RuntimePolicyResolver(cfg) + if policyErr != nil { + return fmt.Errorf("runtime policy: %w", policyErr) + } + policy = resolved + } env := NewHostEnv(cfg.Name, r.tel) env.BatteryCapacityWh = cfg.BatteryCapacityWh @@ -212,7 +243,7 @@ func (r *Registry) Add(ctx context.Context, cfg config.Driver) error { } } - luaDrv, err := NewLuaDriver(cfg.Lua, env) + luaDrv, err := NewLuaDriverWithPolicy(cfg.Lua, env, policy) if err != nil { return fmt.Errorf("load lua: %w", err) } @@ -248,11 +279,21 @@ func (r *Registry) Add(ctx context.Context, cfg config.Driver) error { drv.Cleanup(ctx) return fmt.Errorf("driver_init: %w", err) } + if policy != nil && policy.IsControlV2() { + v2 := drv.(controlV2Runtime) + result, defaultErr := v2.DefaultModeV2(ctx, newControlID("default"), "host_start", time.Now()) + r.recordCommandResult(cfg.Name, result) + if defaultErr != nil { + drv.Cleanup(ctx) + return fmt.Errorf("driver_default_mode_v2 on startup: %w", defaultErr) + } + } rd := &runningDriver{ driver: drv, env: env, cfg: cfg, + policy: policy, cmdCh: make(chan driverCmd, 8), stop: make(chan bool, 1), done: make(chan struct{}), @@ -282,11 +323,39 @@ func (r *Registry) runLoop(rd *runningDriver) { interval := rd.env.PollInterval() timer := time.NewTimer(interval) defer timer.Stop() + leaseTimer := time.NewTimer(time.Hour) + if !leaseTimer.Stop() { + <-leaseTimer.C + } + defer leaseTimer.Stop() + var leaseC <-chan time.Time + clearLease := func() { + rd.leaseExpiresAt = time.Time{} + if !leaseTimer.Stop() { + select { + case <-leaseTimer.C: + default: + } + } + leaseC = nil + } + armLease := func(expiresAt time.Time) { + clearLease() + rd.leaseExpiresAt = expiresAt + d := time.Until(expiresAt) + if d < time.Millisecond { + d = time.Millisecond + } + leaseTimer.Reset(d) + leaseC = leaseTimer.C + } for { select { case skipDefault := <-rd.stop: if !skipDefault { - _ = rd.driver.DefaultMode(ctx) + if err := r.defaultDriver(ctx, rd, "host_shutdown"); err != nil { + slog.Error("driver failed to enter default mode during shutdown", "name", rd.cfg.Name, "err", err) + } } _ = rd.driver.Cleanup(ctx) // Tear down capability connections so a subsequent Add @@ -316,15 +385,50 @@ func (r *Registry) runLoop(rd *runningDriver) { } switch cmd.kind { case "command": - err = rd.driver.Command(cmdCtx, cmd.payload) + if rd.policy != nil && rd.policy.IsControlV2() { + if rd.controlBlocked { + err = errors.New("driver control is blocked because default mode failed") + } else { + var result DriverCommandResultV1 + var leaseExpiresAt time.Time + result, leaseExpiresAt, err = r.dispatchV2Command(cmdCtx, rd, cmd.payload) + r.recordCommandResult(rd.cfg.Name, result) + if err == nil && result.Status == "applied" && result.DeviceState == "controlled" { + armLease(leaseExpiresAt) + } else if err == nil && result.Status == "applied" && result.DeviceState == "default" { + clearLease() + } else if err != nil && result.Writes > 0 { + // A failed call may still have changed the device. End any old + // lease and restore the signed default before more control. + clearLease() + defaultCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defaultErr := r.defaultDriver(defaultCtx, rd, "command_failed_after_write") + cancel() + if defaultErr != nil { + rd.controlBlocked = true + err = errors.Join(err, fmt.Errorf("restore default after partial command: %w", defaultErr)) + } + } + } + } else { + err = rd.driver.Command(cmdCtx, cmd.payload) + } case "default": - err = rd.driver.DefaultMode(cmdCtx) + err = r.defaultDriver(cmdCtx, rd, "host_request") + if err == nil { + clearLease() + rd.controlBlocked = false + } else if rd.policy != nil && rd.policy.IsControlV2() { + rd.controlBlocked = true + } } if cmd.result != nil { cmd.result <- err } case <-timer.C: + pollFailed := false if _, err := rd.driver.Poll(ctx); err != nil { + pollFailed = true slog.Warn("driver poll failed", "name", rd.cfg.Name, "err", err) r.tel.RecordDriverError(rd.cfg.Name, err.Error()) } else if r.tel != nil { @@ -338,11 +442,118 @@ func (r *Registry) runLoop(rd *runningDriver) { // re-stamps LastSuccess every tick from cached values. r.tel.RecordDriverTick(rd.cfg.Name) } + if rd.policy != nil && rd.policy.IsControlV2() && !rd.leaseExpiresAt.IsZero() { + health := r.tel.DriverHealth(rd.cfg.Name) + if pollFailed || health == nil || !health.IsOnline() { + defaultCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + err := r.defaultDriver(defaultCtx, rd, "driver_stale") + cancel() + if err != nil { + rd.controlBlocked = true + slog.Error("driver stale default mode failed; control blocked", "name", rd.cfg.Name, "err", err) + } else { + clearLease() + } + } + } // Re-arm timer at driver's requested interval interval = rd.env.PollInterval() timer.Reset(interval) + case <-leaseC: + defaultCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + err := r.defaultDriver(defaultCtx, rd, "lease_expired") + cancel() + clearLease() + if err != nil { + rd.controlBlocked = true + slog.Error("driver lease expiry default mode failed; control blocked", "name", rd.cfg.Name, "err", err) + } + } + } +} + +func (r *Registry) dispatchV2Command(ctx context.Context, rd *runningDriver, payload []byte) (DriverCommandResultV1, time.Time, error) { + var legacy map[string]interface{} + if err := json.Unmarshal(payload, &legacy); err != nil { + return DriverCommandResultV1{}, time.Time{}, err + } + action, _ := legacy["action"].(string) + var declared *RuntimeCommand + for _, candidate := range rd.policy.Commands { + if candidate.RuntimeAction == action { + if declared != nil { + return DriverCommandResultV1{}, time.Time{}, fmt.Errorf("signed package maps more than one command to runtime action %q", action) + } + copy := candidate + declared = © } } + if declared == nil { + return DriverCommandResultV1{}, time.Time{}, fmt.Errorf("runtime action %q is not declared by the signed package", action) + } + inputs := make(map[string]interface{}, len(declared.Inputs)) + for name := range declared.Inputs { + if value, ok := legacy[name]; ok { + inputs[name] = value + continue + } + if name == "power_w" { + if value, ok := legacy["w"]; ok { + inputs[name] = value + } + } + } + now := time.Now() + heartbeat := rd.policy.Lease.HeartbeatInterval + if heartbeat < time.Second { + heartbeat = time.Second + } + leaseDuration := 2 * heartbeat + if leaseDuration > rd.policy.Lease.MaxDuration { + leaseDuration = rd.policy.Lease.MaxDuration + } + commandDeadline := now.Add(5 * time.Second) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(commandDeadline) { + commandDeadline = ctxDeadline + } + cmd := DriverCommandV1{ + SchemaVersion: DriverCommandSchemaV1, + ID: newControlID("command"), Command: declared.ID, Source: "ftw.control", + IssuedAt: now.UTC(), ExpiresAt: commandDeadline.UTC(), Attempt: 1, Inputs: inputs, + Lease: DriverCommandLeaseV1{ + ID: newControlID("lease"), ExpiresAt: now.Add(leaseDuration).UTC(), + HeartbeatIntervalMS: heartbeat.Milliseconds(), + }, + } + result, err := rd.driver.(controlV2Runtime).CommandV2(ctx, cmd, now) + if err == nil && result.Status != "applied" { + err = fmt.Errorf("driver command returned %s, not applied", result.Status) + } + return result, cmd.Lease.ExpiresAt, err +} + +func (r *Registry) defaultDriver(ctx context.Context, rd *runningDriver, reason string) error { + if rd.policy == nil || !rd.policy.IsControlV2() { + return rd.driver.DefaultMode(ctx) + } + result, err := rd.driver.(controlV2Runtime).DefaultModeV2(ctx, newControlID("default"), reason, time.Now()) + r.recordCommandResult(rd.cfg.Name, result) + return err +} + +func (r *Registry) recordCommandResult(driverName string, result DriverCommandResultV1) { + if r.CommandResultSink != nil && result.SchemaVersion != "" { + result.CompletedAt = time.Now().UTC() + r.CommandResultSink(driverName, result) + } +} + +func newControlID(kind string) string { + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + return fmt.Sprintf("ftw.%s:%d", kind, time.Now().UnixNano()) + } + return "ftw." + kind + ":" + hex.EncodeToString(raw) } // Remove stops and cleans up a driver. Idempotent. Also wipes the @@ -652,7 +863,8 @@ func sameDriverConfig(a, b config.Driver) bool { a.IsSiteMeter != b.IsSiteMeter || a.BatteryCapacityWh != b.BatteryCapacityWh || a.BatteryTelemetryOnly != b.BatteryTelemetryOnly || - a.Disabled != b.Disabled { + a.Disabled != b.Disabled || + !reflect.DeepEqual(a.Control, b.Control) { return false } aMq, bMq := a.EffectiveMQTT(), b.EffectiveMQTT() diff --git a/go/internal/state/driver_command_results.go b/go/internal/state/driver_command_results.go new file mode 100644 index 00000000..49599a78 --- /dev/null +++ b/go/internal/state/driver_command_results.go @@ -0,0 +1,22 @@ +package state + +// RecordDriverCommandResult appends one host-completed v2 result and keeps a +// bounded local audit. The caller supplies the already validated JSON result. +func (s *Store) RecordDriverCommandResult(id, driverName, command, status, code string, completedAtMS int64, resultJSON []byte) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`INSERT INTO driver_command_results + (id, driver_name, command, status, code, completed_at_ms, result_json) + VALUES (?, ?, ?, ?, ?, ?, ?)`, id, driverName, command, status, code, completedAtMS, string(resultJSON)); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM driver_command_results WHERE id IN ( + SELECT id FROM driver_command_results ORDER BY completed_at_ms DESC LIMIT -1 OFFSET 2000 + )`); err != nil { + return err + } + return tx.Commit() +} diff --git a/go/internal/state/store.go b/go/internal/state/store.go index ffdb518e..1e1667d2 100644 --- a/go/internal/state/store.go +++ b/go/internal/state/store.go @@ -730,6 +730,17 @@ func (s *Store) migrate() error { ON driver_repo_installs(repo_id, driver_id, version, sha256)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_driver_repo_active_path ON driver_repo_installs(logical_path) WHERE active = 1`, + `CREATE TABLE IF NOT EXISTS driver_command_results ( + id TEXT PRIMARY KEY NOT NULL, + driver_name TEXT NOT NULL, + command TEXT NOT NULL, + status TEXT NOT NULL, + code TEXT NOT NULL, + completed_at_ms INTEGER NOT NULL, + result_json TEXT NOT NULL + ) STRICT`, + `CREATE INDEX IF NOT EXISTS idx_driver_command_results_completed + ON driver_command_results(completed_at_ms DESC)`, // Cross-component update audit. The operation key survives a core // container recreation, allowing the new process to finish the event