Files
story/internal/onboarding/server_activation.go
T

306 lines
11 KiB
Go
Raw Normal View History

package onboarding
import (
"context"
"encoding/json"
"errors"
"time"
"data-terminal-cloud/internal/hardwareprovision"
"data-terminal-cloud/internal/outbox"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Commit the execution fence before contacting the provider. A lost response or
// process crash is uncertain, never permission to register the same device again.
func (s *Service) processServerActivation(ctx context.Context, event outbox.Event) error {
if !s.options.ActivationEnabled {
return outbox.Retryable(errors.New("onboarding activation paused"))
}
var in struct {
DeviceID string `json:"device_id"`
ActivationID string `json:"activation_id"`
Generation int64 `json:"binding_generation"`
}
if json.Unmarshal(event.Payload, &in) != nil || !uuidValid(in.DeviceID) {
return errors.New("invalid activation event")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, in.DeviceID, true)
if err != nil {
return err
}
j, err := readJob(ctx, tx, d.ID)
if err != nil {
return err
}
bound, err := voiceBound(ctx, tx, d)
if err != nil {
return err
}
if !bound || !deviceAllowed(d) || j.ID != in.ActivationID || j.Generation != d.Generation || in.Generation != d.Generation {
return nil
}
if j.Product != s.options.ProductKey || j.Instance != s.options.InstanceID {
return fail(409, "ACTIVATION_PRODUCT_CHANGED", "设备所属火山产品已变化,请核对")
}
if j.Registered != nil {
// Old device-side registrations may not have a server copy of the secret.
// Wait for migration; never re-register them to recover a missing secret.
if len(j.Secret) > 0 && j.RTCAppID != "" && oneOf(j.Status, "REGISTERED_OBSERVED", "READY") {
if err = s.ensureConfig(ctx, tx, d, &j); err != nil {
return err
}
}
return tx.Commit(ctx)
}
if err = s.expireAttempt(ctx, tx, d, &j); err != nil {
return err
}
var busy bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_activation_attempt WHERE activation_id=$1::uuid AND status IN ('CONTACT_POSSIBLE','UNKNOWN','SUCCESS_OBSERVED') AND stage IN ('SERVER_REGISTER','SDK_REGISTER'))`, j.ID).Scan(&busy); err != nil {
return err
}
if busy || !oneOf(j.Status, "NOT_STARTED", "RESERVED") {
return tx.Commit(ctx)
}
if s.options.Registrar == nil {
return errors.New("server registrar unavailable")
}
attempt := uuid.NewString()
_, err = tx.Exec(ctx, `INSERT INTO device_activation_attempt(id,activation_id,executor,stage,binding_generation,config_version,status,permit_expires_at) VALUES($1::uuid,$2::uuid,'SERVER','SERVER_REGISTER',$3,$4,'CONTACT_POSSIBLE',$5)`, attempt, j.ID, d.Generation, j.ConfigVersion, s.now().Add(30*time.Second))
if err != nil {
return err
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='CONTACT_POSSIBLE',current_attempt_id=$2::uuid,last_error_code=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, attempt)
if err != nil {
return err
}
if err = tx.Commit(ctx); err != nil {
return err
}
callCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
result, callErr := s.options.Registrar.Register(callCtx, hardwareprovision.RegisterInput{InstanceID: j.Instance, ProductKey: j.Product, DeviceName: j.Name})
cancel()
// Do not discard successful credentials just because the worker was cancelled.
saveCtx, saveCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer saveCancel()
return s.saveServerRegistration(saveCtx, d.ID, j.ID, attempt, result, callErr)
}
func voiceBound(ctx context.Context, tx pgx.Tx, d Device) (bool, error) {
var bound bool
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_binding WHERE device_id=$1::uuid AND generation=$2 AND unbound_at IS NULL) OR EXISTS(SELECT 1 FROM device_account_binding WHERE device_id=$1::uuid AND generation=$2 AND released_at IS NULL)`, d.ID, d.Generation).Scan(&bound)
return bound, err
}
func (s *Service) saveServerRegistration(ctx context.Context, device, job, attempt string, result hardwareprovision.RegisterResult, callErr error) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, device, true)
if err != nil {
return err
}
j, err := readJob(ctx, tx, device)
if err != nil {
return err
}
if j.ID != job {
return errState
}
var status string
if err = tx.QueryRow(ctx, `SELECT status FROM device_activation_attempt WHERE id=$1::uuid AND activation_id=$2::uuid AND executor='SERVER' FOR UPDATE`, attempt, job).Scan(&status); err != nil {
return err
}
if !oneOf(status, "CONTACT_POSSIBLE", "UNKNOWN") {
return tx.Commit(ctx)
}
good := callErr == nil && validVoiceSecret(result.DeviceSecret) && validRTCAppID(result.RTCAppID)
if !good {
_, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status='UNKNOWN',completed_at=now() WHERE id=$1::uuid`, attempt)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET status='RECONCILE_REQUIRED',last_error_code='SERVER_RESULT_UNCERTAIN',version=version+1,updated_at=now() WHERE id=$1::uuid`, job)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// A rebind/unbind while the HTTP call ran does not erase the physical device's
// newly acquired identity. Save it, but only deliver it to a current binding.
j.Secret = s.crypt.seal([]byte(result.DeviceSecret), "provider:"+job)
j.RTCAppID = result.RTCAppID
now := s.now()
j.Registered = &now
bound, err := voiceBound(ctx, tx, d)
if err != nil {
return err
}
j.Status = "BLOCKED"
if bound && deviceAllowed(d) && j.Generation == d.Generation {
j.Status = "REGISTERED_OBSERVED"
}
_, err = tx.Exec(ctx, `UPDATE device_activation_attempt SET status='SUCCESS_OBSERVED',completed_at=now() WHERE id=$1::uuid`, attempt)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET provider_secret_ciphertext=$2,provider_rtc_app_id=$3,sdk_registered_at=now(),engine_ready_at=NULL,config_ciphertext=NULL,config_digest=NULL,config_expires_at=NULL,config_ack_at=NULL,status=$4,last_error_code=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, job, j.Secret, j.RTCAppID, j.Status)
if err != nil {
return err
}
j.Config = nil
j.Expires = nil
j.Ack = nil
j.Engine = nil
if j.Status == "REGISTERED_OBSERVED" {
if err = s.ensureConfig(ctx, tx, d, &j); err != nil {
return err
}
}
return tx.Commit(ctx)
}
type DeviceVoiceCredential struct {
InstanceID string `json:"instance_id"`
ProductKey string `json:"product_key"`
DeviceName string `json:"device_name"`
DeviceSecret string `json:"device_secret"`
RTCAppID string `json:"RTCAppID"`
}
func validVoiceSecret(v string) bool { return len(v) >= 16 && len(v) <= 256 }
func validRTCAppID(v string) bool { return len(v) > 0 && len(v) <= 128 }
// Does not expose credentials through mini-program/account endpoints.
func (s *Service) voiceFields(ctx context.Context, tx pgx.Tx, d Device) (map[string]any, error) {
out := map[string]any{"voice_status": "preparing", "voice": nil}
if !s.options.ActivationEnabled {
out["voice_status"] = "paused"
return out, nil
}
j, err := readJob(ctx, tx, d.ID)
if errors.Is(err, pgx.ErrNoRows) {
return out, nil
}
if err != nil {
return nil, err
}
if j.Generation != d.Generation {
return out, nil
}
if j.Registered != nil && (len(j.Secret) == 0 || j.RTCAppID == "") {
out["voice_status"] = "migration_required"
return out, nil
}
if oneOf(j.Status, "BLOCKED", "RECONCILE_REQUIRED") {
out["voice_status"] = "reconcile_required"
return out, nil
}
if j.Registered == nil || len(j.Secret) == 0 || j.RTCAppID == "" {
return out, nil
}
secret, err := s.crypt.open(j.Secret, "provider:"+j.ID)
if err != nil {
return nil, err
}
if !validVoiceSecret(string(secret)) || !validRTCAppID(j.RTCAppID) {
return nil, errors.New("invalid stored provider credential")
}
out["voice_status"] = "ready"
out["voice"] = DeviceVoiceCredential{j.Instance, j.Product, j.Name, string(secret), j.RTCAppID}
return out, nil
}
// Callable immediately after binding, including before boot registration.
func (s *Service) VoiceConfig(ctx context.Context, d Device) (map[string]any, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
current, err := readDevice(ctx, tx, d.ID, true)
if err != nil {
return nil, err
}
bound, err := voiceBound(ctx, tx, current)
if err != nil {
return nil, err
}
if !deviceAllowed(current) || !bound {
return nil, fail(409, "DEVICE_UNBOUND", "绑定错误:设备没有绑定人员")
}
out, err := s.voiceFields(ctx, tx, current)
if err != nil {
return nil, err
}
return out, tx.Commit(ctx)
}
// One-time import of credentials held only by old, already-registered firmware.
// Refuses unknown/unregistered devices and never overwrites a different secret.
func (s *Service) ImportVoiceCredential(ctx context.Context, d Device, in DeviceVoiceCredential) (map[string]any, error) {
if !validVoiceSecret(in.DeviceSecret) || !validRTCAppID(in.RTCAppID) {
return nil, fail(400, "INVALID_VOICE_CREDENTIAL", "语音凭据格式错误")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
current, err := readDevice(ctx, tx, d.ID, true)
if err != nil {
return nil, err
}
bound, err := voiceBound(ctx, tx, current)
if err != nil {
return nil, err
}
if !bound || !deviceAllowed(current) {
return nil, errAccess
}
j, err := readJob(ctx, tx, d.ID)
if err != nil {
return nil, err
}
if j.Registered == nil || j.Generation != current.Generation || j.Instance != in.InstanceID || j.Product != in.ProductKey || j.Name != in.DeviceName {
return nil, fail(409, "VOICE_MIGRATION_NOT_ALLOWED", "仅可迁移本设备已完成注册的原凭据")
}
if len(j.Secret) > 0 {
old, e := s.crypt.open(j.Secret, "provider:"+j.ID)
if e != nil {
return nil, e
}
if !sameSecret(string(old), in.DeviceSecret) || (j.RTCAppID != "" && j.RTCAppID != in.RTCAppID) {
return nil, fail(409, "VOICE_CREDENTIAL_CONFLICT", "平台已有不同凭据,请核对")
}
if j.RTCAppID == in.RTCAppID {
return map[string]any{"accepted": true}, tx.Commit(ctx)
}
}
if !oneOf(j.Status, "REGISTERED_OBSERVED", "READY") {
return nil, errState
}
j.Secret = s.crypt.seal([]byte(in.DeviceSecret), "provider:"+j.ID)
j.RTCAppID = in.RTCAppID
_, err = tx.Exec(ctx, `UPDATE device_activation_job SET provider_secret_ciphertext=$2,provider_rtc_app_id=$3,config_ciphertext=NULL,config_expires_at=NULL,config_ack_at=NULL,engine_ready_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, j.ID, j.Secret, j.RTCAppID)
if err != nil {
return nil, err
}
j.Config = nil
j.Expires = nil
j.Ack = nil
if err = s.ensureConfig(ctx, tx, current, &j); err != nil {
return nil, err
}
return map[string]any{"accepted": true}, tx.Commit(ctx)
}