224 lines
6.7 KiB
Go
224 lines
6.7 KiB
Go
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
|
|
}
|