Discover independent camera instances and prepare their versioned runtime from Node or remote Core. Add isolated SDK workers, camera controls, raw dual-fisheye WebRTC preview, and shared action/region loading states. Recover existing Node bindings over known Tailscale addresses after a Core LAN address change. Preserve identities and trust, pin both peers, migrate endpoints with revision checks, and require real heartbeats for online status. Fix the Python client certificate profile for Go X509 verification. Pin Design Guideline 8c53f73 and retain installer/build/acceptance history. Node 0.8.19 is installed; X4 0.1.3-3 is bundled but hardware activation is pending. Validation: qualified DG/Node builds and Go race tests; 31 fleet tests; Python-to-Go certificate interoperability and live tailnet recovery with five fresh heartbeats; prior 38 X4 tests and bounded remote WebRTC acceptance. Clean-OS, replug/power autonomy, local X4 video and long-run stability remain open.
244 lines
7.4 KiB
Go
244 lines
7.4 KiB
Go
package node
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"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 {
|
|
EndpointRevision uint64 `json:"endpoint_revision,omitempty"`
|
|
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 {
|
|
Monitor *Monitor
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
sort.SliceStable(result, func(i, j int) bool {
|
|
if tailnetAddress(result[i]) != tailnetAddress(result[j]) {
|
|
return tailnetAddress(result[i])
|
|
}
|
|
return result[i] < result[j]
|
|
})
|
|
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
|
|
}
|