Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/safe-driver-control-v2.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
20 changes: 19 additions & 1 deletion go/internal/components/contracts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
24 changes: 24 additions & 0 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package config

import (
"encoding/hex"
"errors"
"fmt"
"math"
Expand Down Expand Up @@ -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
Expand All @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down
25 changes: 22 additions & 3 deletions go/internal/driverrepo/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"))) {
Expand Down Expand Up @@ -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
Expand Down
Loading