package node import ( "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "errors" "net/http" "os" "path/filepath" "sync" "time" ) type Invitation struct { ID string `json:"id"` Endpoint string `json:"endpoint"` ExpiresAt int64 `json:"expires_at"` SecretHash string `json:"secret_hash,omitempty"` } type CoreBinding struct { BindingID string `json:"binding_id"` CoreID string `json:"core_id"` CoreName string `json:"core_name"` Endpoint string `json:"endpoint"` CAPEM string `json:"ca_pem"` ClientPEM string `json:"client_pem"` Receipt string `json:"receipt,omitempty"` OfferHash string `json:"offer_hash,omitempty"` ExpiresAt int64 `json:"expires_at"` } type PairState struct { Schema string `json:"schema"` Phase string `json:"phase"` Invitation *Invitation `json:"invitation,omitempty"` Binding *CoreBinding `json:"binding,omitempty"` Revocations []CoreBinding `json:"revocations,omitempty"` } type Pairing struct { Sensors *Sensors DeviceEnrollment *DeviceEnrollment mu sync.Mutex path string store *Store state PairState now func() time.Time inventory func() Inventory version string lastSeen int64 connection string listenError string failureWindow int64 failures int clients map[string]*http.Client } func OpenPairing(store *Store, dir, version string, inventory func() Inventory) (*Pairing, error) { p := &Pairing{store: store, path: filepath.Join(dir, "core-binding.json"), now: time.Now, inventory: inventory, version: version, connection: "offline", clients: make(map[string]*http.Client), state: PairState{Schema: PairSchema, Phase: "unpaired"}} data, e := os.ReadFile(p.path) if os.IsNotExist(e) { return p, nil } if e != nil { return nil, e } info, e := os.Lstat(p.path) if e != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || len(data) > 65536 { return nil, errors.New("invalid Core binding permissions or size") } if json.Unmarshal(data, &p.state) != nil || p.state.Schema != PairSchema { return nil, errors.New("invalid Core binding; recovery required") } switch p.state.Phase { case "unpaired", "inviting", "pending", "paired", "revoked": default: return nil, errors.New("unknown Core binding state") } if (p.state.Phase == "pending" || p.state.Phase == "paired") && p.state.Binding == nil { return nil, errors.New("incomplete Core binding") } if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation == nil { return nil, errors.New("incomplete invitation") } if (p.state.Phase == "paired" || p.state.Phase == "revoked") && p.state.Invitation != nil { next := p.state next.Invitation = nil if e := p.save(next); e != nil { return nil, e } } return p, nil } func savePrivateJSON(path string, value any) error { data, e := json.Marshal(value) if e != nil { return e } f, e := os.CreateTemp(filepath.Dir(path), ".binding-*") if e != nil { return e } defer os.Remove(f.Name()) if _, e = f.Write(data); e != nil { f.Close() return e } if e = f.Sync(); e != nil { f.Close() return e } if e = f.Close(); e != nil { return e } if e = os.Rename(f.Name(), path); e != nil { return e } dir, e := os.Open(filepath.Dir(path)) if e != nil { return e } defer dir.Close() return dir.Sync() } func (p *Pairing) save(next PairState) error { if e := savePrivateJSON(p.path, next); e != nil { return e } p.state = next return nil } func digest(value string) string { h := sha256.Sum256([]byte(value)); return hex.EncodeToString(h[:]) } func (p *Pairing) expire() error { if (p.state.Phase == "inviting" && p.state.Invitation.ExpiresAt <= p.now().Unix()) || (p.state.Phase == "pending" && p.state.Binding.ExpiresAt <= p.now().Unix()) { return p.save(PairState{Schema: PairSchema, Phase: "unpaired", Revocations: p.state.Revocations}) } return nil } func (p *Pairing) status() map[string]any { p.mu.Lock() defer p.mu.Unlock() _ = p.expire() id, _ := p.store.Public() out := map[string]any{"phase": p.state.Phase, "node_id": id, "connection": p.connection, "last_seen": p.lastSeen, "notice": p.listenError, "addresses": p.addresses(), "pending_revocations": len(p.state.Revocations)} if i := p.state.Invitation; i != nil { out["invitation"] = map[string]any{"id": i.ID, "endpoint": i.Endpoint, "expires_at": i.ExpiresAt} } if b := p.state.Binding; b != nil { out["binding"] = map[string]any{"binding_id": b.BindingID, "core_id": b.CoreID, "core_name": b.CoreName, "endpoint": b.Endpoint} } return out } func (p *Pairing) addresses() []string { result := []string{} for _, network := range p.inventory().Networks { if !network.Up { continue } for _, alias := range network.Addresses { address := alias for i, c := range address { if c == '/' { address = address[:i] break } } if PrivateAddress(address) { result = append(result, address) } } } return result } func (p *Pairing) invite(address string) (map[string]any, error) { p.mu.Lock() defer p.mu.Unlock() if e := p.expire(); e != nil { return nil, e } if p.state.Phase == "paired" || p.state.Phase == "pending" { return nil, errors.New("Сначала отмените текущую привязку") } found := false for _, a := range p.addresses() { if a == address { found = true } } if !found { return nil, errors.New("Выберите доступный частный адрес этого БК") } secret := token() i := &Invitation{ID: token(), Endpoint: "https://" + address + ":" + PairPort, ExpiresAt: p.now().Add(10 * time.Minute).Unix(), SecretHash: digest(secret)} if e := p.save(PairState{Schema: PairSchema, Phase: "inviting", Invitation: i, Revocations: p.state.Revocations}); e != nil { return nil, e } p.connection = "offline" p.lastSeen = 0 id, _ := p.store.Public() code, _ := json.Marshal(map[string]any{"schema": PairSchema, "node_id": id, "id": i.ID, "endpoint": i.Endpoint, "expires_at": i.ExpiresAt, "secret": secret}) return map[string]any{"code": "MCN1." + base64.RawURLEncoding.EncodeToString(code), "expires_at": i.ExpiresAt}, nil } func (p *Pairing) cancel() error { p.mu.Lock() defer p.mu.Unlock() revocations := append([]CoreBinding(nil), p.state.Revocations...) if p.state.Binding != nil { if len(revocations) >= 8 { return errors.New("Дождитесь доставки предыдущих отзывов доверия") } revocations = append(revocations, *p.state.Binding) } p.connection = "offline" p.listenError = "" p.lastSeen = 0 return p.save(PairState{Schema: PairSchema, Phase: "unpaired", Revocations: revocations}) } func (p *Pairing) checkInvitation(id, secret string) bool { now := p.now().Unix() if now-p.failureWindow >= 60 { p.failureWindow = now p.failures = 0 } if p.failures >= 32 { return false } i := p.state.Invitation if i == nil || i.ID != id || i.ExpiresAt <= now || subtle.ConstantTimeCompare([]byte(i.SecretHash), []byte(digest(secret))) != 1 { p.failures++ return false } return true }