@@ -0,0 +1,385 @@
|
||||
package devicereset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("device factory reset input is invalid")
|
||||
ErrStateConflict = errors.New("device state does not allow factory reset")
|
||||
ErrResetCounterConflict = errors.New("device factory reset counter is stale")
|
||||
ErrFirmwareInProgress = errors.New("device firmware update is in progress")
|
||||
ErrPendingTranscripts = errors.New("device has pending transcripts")
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
ResetID string `json:"reset_id"`
|
||||
ResetCounter int64 `json:"reset_counter"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Trigger string `json:"trigger"`
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
PendingTurnCount int `json:"pending_turn_count"`
|
||||
ActiveConversationID string `json:"active_conversation_id,omitempty"`
|
||||
}
|
||||
|
||||
type Claim struct {
|
||||
Code string `json:"code"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
ResetID string `json:"reset_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ActivationState string `json:"activation_state"`
|
||||
ResetCounter int64 `json:"reset_counter"`
|
||||
BindingRemoved bool `json:"binding_removed"`
|
||||
PreviousBindingID string `json:"previous_binding_id,omitempty"`
|
||||
ProviderCredentialStatus string `json:"provider_credential_status"`
|
||||
LicenseAction string `json:"license_action"`
|
||||
EndedConversationCount int `json:"ended_conversation_count"`
|
||||
CancelledCommandCount int64 `json:"cancelled_command_count"`
|
||||
ConfigRevision int64 `json:"config_revision"`
|
||||
Claim Claim `json:"claim"`
|
||||
LocalActions []string `json:"local_actions"`
|
||||
ResetAt time.Time `json:"reset_at"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
executor *idempotency.Executor
|
||||
audit *audit.Repository
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, executor *idempotency.Executor, auditRepository *audit.Repository) (*Service, error) {
|
||||
if pool == nil || executor == nil || auditRepository == nil {
|
||||
return nil, errors.New("device factory reset dependencies are required")
|
||||
}
|
||||
return &Service{pool: pool, executor: executor, audit: auditRepository}, nil
|
||||
}
|
||||
|
||||
func Validate(input Input) error {
|
||||
if _, err := uuid.Parse(strings.TrimSpace(input.ResetID)); err != nil || input.ResetCounter < 1 || input.OccurredAt.IsZero() ||
|
||||
input.OccurredAt.After(time.Now().UTC().Add(5*time.Minute)) || len(strings.TrimSpace(input.FirmwareVersion)) < 1 ||
|
||||
len(strings.TrimSpace(input.FirmwareVersion)) > 80 || input.PendingTurnCount < 0 {
|
||||
return ErrInvalid
|
||||
}
|
||||
if input.Trigger != "physical_long_press" && input.Trigger != "device_menu" && input.Trigger != "service_tool" {
|
||||
return ErrInvalid
|
||||
}
|
||||
if input.ActiveConversationID != "" {
|
||||
if _, err := uuid.Parse(input.ActiveConversationID); err != nil {
|
||||
return ErrInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Reset(ctx context.Context, identity deviceauth.Identity, idempotencyKey, requestID string, rawRequest []byte, input Input) (Response, bool, error) {
|
||||
if err := Validate(input); err != nil {
|
||||
return Response{}, false, err
|
||||
}
|
||||
if input.PendingTurnCount > 0 {
|
||||
return Response{}, false, ErrPendingTranscripts
|
||||
}
|
||||
tenantID := identity.TenantID
|
||||
result, err := s.executor.Execute(ctx, idempotency.Request{
|
||||
TenantID: &tenantID, ActorKey: "device:" + identity.PublicID,
|
||||
Scope: "POST /device/v3/factory-reset", Key: idempotencyKey,
|
||||
RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 7 * 24 * time.Hour,
|
||||
}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
response, resetErr := s.reset(operationContext, tx, identity, requestID, input)
|
||||
if resetErr != nil {
|
||||
return idempotency.Result{}, resetErr
|
||||
}
|
||||
body, marshalErr := json.Marshal(response)
|
||||
if marshalErr != nil {
|
||||
return idempotency.Result{}, marshalErr
|
||||
}
|
||||
resourceID := identity.PublicID
|
||||
return idempotency.Result{StatusCode: http.StatusOK, Body: body, ResourceID: &resourceID}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return Response{}, false, err
|
||||
}
|
||||
var response Response
|
||||
if err := json.Unmarshal(result.Body, &response); err != nil {
|
||||
return Response{}, result.Replayed, fmt.Errorf("decode device factory reset response: %w", err)
|
||||
}
|
||||
return response, result.Replayed, nil
|
||||
}
|
||||
|
||||
func (s *Service) reset(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID string, input Input) (Response, error) {
|
||||
var activationState string
|
||||
var currentCounter int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT activation_state,factory_reset_counter
|
||||
FROM device
|
||||
WHERE id=$1::uuid AND tenant_id=$2::uuid AND status='registered'
|
||||
FOR UPDATE`, identity.ID, identity.TenantID).Scan(&activationState, ¤tCounter); err != nil {
|
||||
return Response{}, fmt.Errorf("lock device for factory reset: %w", err)
|
||||
}
|
||||
if activationState != string(deviceactivation.Claimable) && activationState != string(deviceactivation.Bound) &&
|
||||
activationState != string(deviceactivation.Provisioning) && activationState != string(deviceactivation.Active) {
|
||||
return Response{}, ErrStateConflict
|
||||
}
|
||||
if input.ResetCounter <= currentCounter {
|
||||
return Response{}, ErrResetCounterConflict
|
||||
}
|
||||
|
||||
var activeFirmware int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT count(*) FROM firmware_assignment
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('ASSIGNED','DOWNLOADING','INSTALLING')`,
|
||||
identity.ID, identity.TenantID).Scan(&activeFirmware); err != nil {
|
||||
return Response{}, fmt.Errorf("check active firmware update: %w", err)
|
||||
}
|
||||
if activeFirmware > 0 {
|
||||
return Response{}, ErrFirmwareInProgress
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
endedConversations, err := endActiveConversations(ctx, tx, identity, requestID, now)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
var previousBindingID, previousHouseholdID, previousSubjectID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE device_binding
|
||||
SET unbound_at=$3,unbound_by=NULL,reason='device_factory_reset'
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND unbound_at IS NULL
|
||||
RETURNING public_id::text,household_id::text,subject_id::text`, identity.ID, identity.TenantID, now).
|
||||
Scan(&previousBindingID, &previousHouseholdID, &previousSubjectID)
|
||||
bindingRemoved := err == nil
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Response{}, fmt.Errorf("close device binding for factory reset: %w", err)
|
||||
}
|
||||
if bindingRemoved {
|
||||
if err := notifyPreviousOwner(ctx, tx, identity, previousHouseholdID, previousSubjectID, now); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE device_claim SET revoked_at=$3
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND used_at IS NULL AND revoked_at IS NULL`,
|
||||
identity.ID, identity.TenantID, now); err != nil {
|
||||
return Response{}, fmt.Errorf("revoke old device claims: %w", err)
|
||||
}
|
||||
|
||||
providerStatus := "NOT_PROVISIONED"
|
||||
if tag, err := tx.Exec(ctx, `
|
||||
UPDATE hardware_agent_provider_credential
|
||||
SET status='SUSPENDED',updated_at=$3
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status='ACTIVE'`, identity.ID, identity.TenantID, now); err != nil {
|
||||
return Response{}, fmt.Errorf("suspend provider credential: %w", err)
|
||||
} else if tag.RowsAffected() > 0 {
|
||||
providerStatus = "SUSPENDED"
|
||||
} else if err := tx.QueryRow(ctx, `SELECT status FROM hardware_agent_provider_credential WHERE device_id=$1::uuid`, identity.ID).Scan(&providerStatus); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Response{}, fmt.Errorf("read provider credential status: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_device_binding SET status='DISABLED',updated_at=$2 WHERE device_id=$1::uuid AND status='ACTIVE'`, identity.ID, now); err != nil {
|
||||
return Response{}, fmt.Errorf("disable hardware agent binding: %w", err)
|
||||
}
|
||||
|
||||
var cancelledCommands int64
|
||||
if tag, err := tx.Exec(ctx, `
|
||||
UPDATE device_command
|
||||
SET status='CANCELLED',result_summary='{"error_code":"DEVICE_FACTORY_RESET"}'::jsonb,
|
||||
completed_at=$3,version=version+1,updated_at=$3
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED')`,
|
||||
identity.ID, identity.TenantID, now); err != nil {
|
||||
return Response{}, fmt.Errorf("cancel device commands: %w", err)
|
||||
} else {
|
||||
cancelledCommands = tag.RowsAffected()
|
||||
}
|
||||
|
||||
if err := transitionToClaimable(ctx, tx, identity, requestID); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
var configRevision int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
UPDATE device_twin
|
||||
SET desired_revision=desired_revision+1,reported_revision=NULL,reported_config='{}'::jsonb,
|
||||
capabilities='{}'::jsonb,updated_at=$3
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid
|
||||
RETURNING desired_revision`, identity.ID, identity.TenantID, now).Scan(&configRevision); err != nil {
|
||||
return Response{}, fmt.Errorf("reset device twin: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE device SET factory_reset_counter=$3,version=version+1,updated_at=$4
|
||||
WHERE id=$1::uuid AND tenant_id=$2::uuid`, identity.ID, identity.TenantID, input.ResetCounter, now); err != nil {
|
||||
return Response{}, fmt.Errorf("record factory reset counter: %w", err)
|
||||
}
|
||||
|
||||
claim, err := issueClaim(ctx, tx, identity, now)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
response := Response{
|
||||
ResetID: input.ResetID, DeviceID: identity.PublicID, ActivationState: string(deviceactivation.Claimable),
|
||||
ResetCounter: input.ResetCounter, BindingRemoved: bindingRemoved, PreviousBindingID: previousBindingID,
|
||||
ProviderCredentialStatus: providerStatus, LicenseAction: "RETAINED",
|
||||
EndedConversationCount: endedConversations, CancelledCommandCount: cancelledCommands,
|
||||
ConfigRevision: configRevision, Claim: claim, ResetAt: now,
|
||||
LocalActions: []string{"clear_volc_credential", "clear_user_context", "clear_session_state", "clear_network_credentials", "reboot_to_claimable"},
|
||||
}
|
||||
actorID := identity.PublicID
|
||||
if err := s.audit.Record(ctx, tx, audit.Event{
|
||||
TenantID: &identity.TenantID, ActorType: "device", ActorPublicID: &actorID,
|
||||
Action: "device.factory_reset", ResourceType: "device", ResourceID: &actorID,
|
||||
RequestID: requestID, Result: "success", Detail: map[string]any{
|
||||
"reset_id": input.ResetID, "reset_counter": input.ResetCounter, "trigger": input.Trigger,
|
||||
"firmware_version": input.FirmwareVersion, "previous_binding_id": previousBindingID,
|
||||
"device_reported_conversation_id": input.ActiveConversationID,
|
||||
"ended_conversation_count": endedConversations, "cancelled_command_count": cancelledCommands,
|
||||
"provider_credential_status": providerStatus, "license_action": "RETAINED",
|
||||
},
|
||||
}); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func notifyPreviousOwner(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, householdID, subjectID string, now time.Time) error {
|
||||
var wasOnline bool
|
||||
err := tx.QueryRow(ctx, `
|
||||
UPDATE device_presence_state SET online=false,changed_at=$4
|
||||
WHERE tenant_id=$1::uuid AND device_id=$2::uuid AND household_id=$3::uuid AND online
|
||||
RETURNING true`, identity.TenantID, identity.ID, householdID, now).Scan(&wasOnline)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("mark factory-reset device offline: %w", err)
|
||||
}
|
||||
if wasOnline {
|
||||
if _, err := tx.Exec(ctx, `SELECT append_web_event($1::uuid,$2::uuid,$3::uuid,'device.presence.changed','device',$4::uuid,'offline')`,
|
||||
identity.TenantID, householdID, subjectID, identity.PublicID); err != nil {
|
||||
return fmt.Errorf("publish factory-reset presence event: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `SELECT append_web_event($1::uuid,$2::uuid,$3::uuid,'device.binding.reset','device',$4::uuid,'CLAIMABLE')`,
|
||||
identity.TenantID, householdID, subjectID, identity.PublicID); err != nil {
|
||||
return fmt.Errorf("publish factory-reset binding event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func endActiveConversations(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID string, now time.Time) (int, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id::text,status FROM conversation
|
||||
WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED')
|
||||
FOR UPDATE`, identity.ID, identity.TenantID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("lock active conversations for factory reset: %w", err)
|
||||
}
|
||||
type activeConversation struct{ id, status string }
|
||||
items := make([]activeConversation, 0)
|
||||
for rows.Next() {
|
||||
var item activeConversation
|
||||
if err := rows.Scan(&item.id, &item.status); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE conversation SET status='FAILED',ended_at=$2,stop_reason='device_factory_reset',
|
||||
failure_code='DEVICE_FACTORY_RESET',version=version+1,updated_at=$2
|
||||
WHERE id=$1::uuid`, item.id, now); err != nil {
|
||||
return 0, fmt.Errorf("fail conversation during factory reset: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source,request_id,occurred_at)
|
||||
VALUES($1::uuid,$2,'FAILED','device factory reset','device',NULLIF($3,''),$4)`, item.id, item.status, requestID, now); err != nil {
|
||||
return 0, fmt.Errorf("record factory reset conversation transition: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE hardware_agent_session SET status='FAILED',provider_ended_at=COALESCE(provider_ended_at,$2),updated_at=$2
|
||||
WHERE conversation_id=$1::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED')`, item.id, now); err != nil {
|
||||
return 0, fmt.Errorf("fail hardware agent session during factory reset: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE rtc_task SET status='FAILED',updated_at=$2
|
||||
WHERE conversation_id=$1::uuid AND status NOT IN ('COMPLETED','FAILED')`, item.id, now); err != nil {
|
||||
return 0, fmt.Errorf("fail rtc task during factory reset: %w", err)
|
||||
}
|
||||
}
|
||||
return len(items), nil
|
||||
}
|
||||
|
||||
func transitionToClaimable(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, requestID string) error {
|
||||
var stateText string
|
||||
if err := tx.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&stateText); err != nil {
|
||||
return fmt.Errorf("read device activation state after reset cleanup: %w", err)
|
||||
}
|
||||
state, err := deviceactivation.Parse(stateText)
|
||||
if err != nil {
|
||||
return ErrStateConflict
|
||||
}
|
||||
actorID := identity.PublicID
|
||||
apply := func(target deviceactivation.State, reason string) error {
|
||||
_, applyErr := deviceactivation.Apply(ctx, tx, deviceactivation.Input{
|
||||
DeviceID: identity.ID, TenantID: identity.TenantID, Target: target,
|
||||
Reason: reason, Source: "device-factory-reset", ActorType: "device",
|
||||
ActorPublicID: &actorID, RequestID: requestID,
|
||||
})
|
||||
return applyErr
|
||||
}
|
||||
if state == deviceactivation.Provisioning {
|
||||
if err := apply(deviceactivation.Bound, "设备恢复出厂,结束预配"); err != nil {
|
||||
return fmt.Errorf("return provisioning device to bound state: %w", err)
|
||||
}
|
||||
state = deviceactivation.Bound
|
||||
}
|
||||
if state != deviceactivation.Claimable {
|
||||
if state != deviceactivation.Bound && state != deviceactivation.Active && state != deviceactivation.Lost {
|
||||
return ErrStateConflict
|
||||
}
|
||||
if err := apply(deviceactivation.Claimable, "设备恢复出厂并解除用户绑定"); err != nil {
|
||||
return fmt.Errorf("make factory-reset device claimable: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func issueClaim(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, issuedAt time.Time) (Claim, error) {
|
||||
randomBytes := make([]byte, 10)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return Claim{}, fmt.Errorf("generate factory reset claim: %w", err)
|
||||
}
|
||||
rawCode := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes)
|
||||
code := strings.Join([]string{rawCode[0:4], rawCode[4:8], rawCode[8:12], rawCode[12:16]}, "-")
|
||||
digest := sha256.Sum256([]byte(code))
|
||||
expiresAt := issuedAt.Add(24 * time.Hour)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO device_claim(tenant_id,device_id,token_digest,expires_at,created_at)
|
||||
VALUES($1::uuid,$2::uuid,$3,$4,$5)`, identity.TenantID, identity.ID, hex.EncodeToString(digest[:]), expiresAt, issuedAt); err != nil {
|
||||
return Claim{}, fmt.Errorf("insert factory reset claim: %w", err)
|
||||
}
|
||||
return Claim{Code: code, IssuedAt: issuedAt, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user