feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
// Controlled, explicitly approved migration of one legacy device. Never deletes history.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func secret(name string) (string, error) {
|
||||
if path := os.Getenv(name + "_FILE"); path != "" {
|
||||
b, err := os.ReadFile(path)
|
||||
return strings.TrimSpace(string(b)), err
|
||||
}
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%s is not configured", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func run() error {
|
||||
public := flag.String("device", "", "exact existing public UUID")
|
||||
binding := flag.String("expected-binding", "", "exact current binding UUID, or none")
|
||||
mac := flag.String("factory-mac", "", "verified hardware MAC, uppercase 12 hex")
|
||||
output := flag.String("output", "", "new private credential JSON file")
|
||||
confirmed := flag.Bool("confirm-unbind-and-migrate", false, "requires explicit owner authorization")
|
||||
dryRun := flag.Bool("dry-run", false, "validate the complete transaction, then roll it back without exporting keys")
|
||||
flag.Parse()
|
||||
if !*confirmed || uuid.Validate(*public) != nil || (*binding != "none" && uuid.Validate(*binding) != nil) || !regexp.MustCompile(`^[0-9A-F]{12}$`).MatchString(*mac) || *output == "" {
|
||||
return errors.New("exact device, expected binding, factory MAC, private output and explicit confirmation are required")
|
||||
}
|
||||
if _, err := os.Stat(*output); !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.New("credential output already exists or is inaccessible; inspect previous attempt before retry")
|
||||
}
|
||||
dsn, err := secret("DATABASE_URL")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
masterHex, err := secret("ONBOARDING_MASTER_KEY")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
master, err := hex.DecodeString(masterHex)
|
||||
if err != nil || len(master) != 32 {
|
||||
return errors.New("invalid onboarding master key")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pool.Close()
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var id, tenant, mode, lifecycle, status string
|
||||
err = tx.QueryRow(ctx, `SELECT id::text,tenant_id::text,enrollment_mode,activation_state,status FROM device WHERE public_id=$1::uuid FOR UPDATE`, *public).Scan(&id, &tenant, &mode, &lifecycle, &status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mode != "legacy" || status != "registered" || (lifecycle != "ACTIVE" && lifecycle != "BOUND" && lifecycle != "CLAIMABLE") {
|
||||
return errors.New("device is not an eligible legacy identity; no changes made")
|
||||
}
|
||||
var current string
|
||||
var busy, hasKey bool
|
||||
err = tx.QueryRow(ctx, `SELECT COALESCE((SELECT id::text FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL),'none'),EXISTS(SELECT 1 FROM conversation WHERE device_id=$1::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED')),EXISTS(SELECT 1 FROM device_onboarding_key WHERE device_id=$1::uuid)`, id).Scan(¤t, &busy, &hasKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current != *binding || busy || hasKey {
|
||||
return errors.New("binding changed, conversation still running, or onboarding key already exists; no changes made")
|
||||
}
|
||||
requestID := uuid.NewString()
|
||||
if current != "none" {
|
||||
if _, err = tx.Exec(ctx, `UPDATE device_binding SET unbound_at=now(),reason='用户明确授权:迁移蓝牙配网,保留历史记录' WHERE id=$1::uuid AND unbound_at IS NULL`, current); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err = deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: id, TenantID: tenant, Target: deviceactivation.Claimable, Reason: "用户授权旧设备迁移蓝牙注册", Source: "ble-device-migrate", ActorType: "system", RequestID: requestID}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sql := range []string{
|
||||
`UPDATE device_claim SET revoked_at=now() WHERE device_id=$1::uuid AND used_at IS NULL AND revoked_at IS NULL`,
|
||||
`UPDATE hardware_agent_provider_credential SET status='SUSPENDED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'`,
|
||||
`UPDATE hardware_agent_device_binding SET status='DISABLED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'`,
|
||||
} {
|
||||
if _, err = tx.Exec(ctx, sql, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
factoryID := "KP01-" + *mac
|
||||
sum := sha256.Sum256([]byte(factoryID))
|
||||
_, err = tx.Exec(ctx, `UPDATE device SET enrollment_mode='physical_confirm',factory_device_id=$2,factory_mac=$3,model='ECHOEAR-S3',hardware_revision='V1.2',credential_ref=$4,serial_digest=$5,onboarding_released_at=now(),onboarding_boot_id=NULL,onboarding_revision=onboarding_revision+1,version=version+1,updated_at=now() WHERE id=$1::uuid`, id, factoryID, *mac, "onboarding://"+factoryID, hex.EncodeToString(sum[:]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err = rand.Read(key); err != nil {
|
||||
return err
|
||||
}
|
||||
block, err := aes.NewCipher(master)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
encrypted := aead.Seal(nonce, nonce, key, []byte("ONBOARDING-KEY1\n"+id+"\n1"))
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_onboarding_key(device_id,tenant_id,key_version,ciphertext,status,activated_at) VALUES($1::uuid,$2::uuid,1,$3,'ACTIVE',now())`, id, tenant, encrypted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = audit.NewRepository().Record(ctx, tx, audit.Event{TenantID: &tenant, ActorType: "system", Action: "device.ble.migrate", ResourceType: "device", ResourceID: public, RequestID: requestID, Result: "success", Detail: map[string]any{"factory_device_id": factoryID, "previous_binding_id": current, "user_approved_unbind": true, "history_preserved": true}}); err != nil {
|
||||
return err
|
||||
}
|
||||
if *dryRun {
|
||||
if err = tx.Rollback(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]any{"validated": true, "rolled_back": true, "device_id": factoryID})
|
||||
}
|
||||
// Persist private recovery material before committing the identity mutation.
|
||||
f, err := os.OpenFile(*output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = json.NewEncoder(f).Encode(map[string]any{"device_id": factoryID, "platform_device_id": *public, "model": "ECHOEAR-S3", "key_version": 1, "device_key_hex": hex.EncodeToString(key), "request_id": requestID})
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
closeErr := f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit uncertain; retain private output and inspect device state: %w", err)
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]any{"migrated": true, "device_id": factoryID, "platform_device_id": *public, "history_preserved": true, "request_id": requestID})
|
||||
}
|
||||
Reference in New Issue
Block a user