feat(node): pair onboard computers with the Core fleet through UI
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user