feat(node): pair onboard computers with the Core fleet through UI

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 21:16:13 +03:00
parent fc545f8440
commit e82d012907
29 changed files with 2442 additions and 19 deletions
+223
View File
@@ -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
}
@@ -0,0 +1,96 @@
package node
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"errors"
"math/big"
"net"
"net/url"
"time"
)
const PairSchema = "missioncore.node-pairing/v1"
const PairPort = "8781"
const CorePort = "8782"
func PrivateAddress(value string) bool {
ip := net.ParseIP(value)
if ip == nil || ip.To4() == nil {
return false
}
return ip.IsPrivate() || (ip.To4()[0] == 100 && ip.To4()[1] >= 64 && ip.To4()[1] <= 127)
}
func privateEndpoint(value, port string) bool {
u, e := url.Parse(value)
return e == nil && u.Scheme == "https" && u.User == nil && u.Path == "" && u.RawQuery == "" && u.Fragment == "" && u.Port() == port && PrivateAddress(u.Hostname())
}
func keyID(prefix string, key ed25519.PublicKey) string {
sum := sha256.Sum256(key)
return prefix + hex.EncodeToString(sum[:])
}
func (s *Store) pairingKey() ed25519.PrivateKey {
s.mu.Lock()
defer s.mu.Unlock()
return append(ed25519.PrivateKey(nil), s.state.PrivateKey...)
}
func bootstrapCertificate(key ed25519.PrivateKey, address string) (tls.Certificate, error) {
serial, e := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if e != nil {
return tls.Certificate{}, e
}
spec := &x509.Certificate{SerialNumber: serial, Subject: pkix.Name{CommonName: "Mission Core Node"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(24 * time.Hour), IPAddresses: []net.IP{net.ParseIP(address)}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true}
der, e := x509.CreateCertificate(rand.Reader, spec, spec, key.Public(), key)
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, e
}
func bindingTLS(b CoreBinding, key ed25519.PrivateKey) (*tls.Config, error) {
if !privateEndpoint(b.Endpoint, CorePort) {
return nil, errors.New("Core address is not private")
}
block, _ := pem.Decode([]byte(b.CAPEM))
if block == nil {
return nil, errors.New("missing Core certificate")
}
ca, e := x509.ParseCertificate(block.Bytes)
if e != nil {
return nil, e
}
pub, ok := ca.PublicKey.(ed25519.PublicKey)
if !ok || keyID("core_", pub) != b.CoreID || !ca.IsCA || ca.CheckSignatureFrom(ca) != nil {
return nil, errors.New("Core identity mismatch")
}
block, _ = pem.Decode([]byte(b.ClientPEM))
if block == nil {
return nil, errors.New("missing client certificate")
}
cert, e := x509.ParseCertificate(block.Bytes)
if e != nil {
return nil, e
}
roots := x509.NewCertPool()
roots.AddCert(ca)
if _, e = cert.Verify(x509.VerifyOptions{Roots: roots, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); e != nil {
return nil, e
}
nodePub, ok := cert.PublicKey.(ed25519.PublicKey)
if !ok || !nodePub.Equal(key.Public()) {
return nil, errors.New("client identity mismatch")
}
return &tls.Config{MinVersion: tls.VersionTLS13, RootCAs: roots, Certificates: []tls.Certificate{{Certificate: [][]byte{cert.Raw}, PrivateKey: key}}}, nil
}
// Once the issued credential expires, the old Core cannot accept this binding.
func clientExpired(b CoreBinding) bool {
block, _ := pem.Decode([]byte(b.ClientPEM))
if block == nil {
return false
}
cert, e := x509.ParseCertificate(block.Bytes)
return e == nil && time.Now().After(cert.NotAfter)
}
@@ -0,0 +1,40 @@
package node
import (
"net"
"sync"
)
// Bound unauthenticated bootstrap sockets before TLS allocates a goroutine.
type pairingListener struct {
net.Listener
slots chan struct{}
done chan struct{}
once sync.Once
}
func (l *pairingListener) Accept() (net.Conn, error) {
select {
case l.slots <- struct{}{}:
case <-l.done:
return nil, net.ErrClosed
}
c, e := l.Listener.Accept()
if e != nil {
<-l.slots
return nil, e
}
return &pairingConn{Conn: c, release: func() { <-l.slots }}, nil
}
func (l *pairingListener) Close() error {
l.once.Do(func() { close(l.done) })
return l.Listener.Close()
}
type pairingConn struct {
net.Conn
release func()
once sync.Once
}
func (c *pairingConn) Close() error { e := c.Conn.Close(); c.once.Do(c.release); return e }
@@ -0,0 +1,165 @@
package node
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"math/big"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
func testPairing(t *testing.T) (*Pairing, map[string]any) {
t.Helper()
dir := t.TempDir()
store, e := OpenStore(dir)
if e != nil {
t.Fatal(e)
}
p, e := OpenPairing(store, dir, "test", func() Inventory {
return Inventory{Networks: []Network{{Name: "test", Up: true, Addresses: []string{"192.168.10.4/24"}}}}
})
if e != nil {
t.Fatal(e)
}
out, e := p.invite("192.168.10.4")
if e != nil {
t.Fatal(e)
}
raw, e := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(out["code"].(string), "MCN1."))
if e != nil {
t.Fatal(e)
}
var invitation map[string]any
if json.Unmarshal(raw, &invitation) != nil {
t.Fatal("invitation")
}
return p, invitation
}
func testCoreBinding(t *testing.T, p *Pairing) CoreBinding {
t.Helper()
pub, key, e := ed25519.GenerateKey(rand.Reader)
if e != nil {
t.Fatal(e)
}
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test Core"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature}
der, e := x509.CreateCertificate(rand.Reader, ca, ca, pub, key)
if e != nil {
t.Fatal(e)
}
ca, _ = x509.ParseCertificate(der)
cert := &x509.Certificate{SerialNumber: big.NewInt(2), NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}
leaf, e := x509.CreateCertificate(rand.Reader, cert, ca, p.store.pairingKey().Public(), key)
if e != nil {
t.Fatal(e)
}
return CoreBinding{BindingID: token(), CoreID: keyID("core_", pub), CoreName: "Test", Endpoint: "https://192.168.10.5:8782", CAPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), ClientPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leaf}))}
}
func pairCall(p *Pairing, path string, body any) *httptest.ResponseRecorder {
raw, _ := json.Marshal(body)
r := httptest.NewRequest("POST", path, strings.NewReader(string(raw)))
r.RemoteAddr = "192.168.10.5:42000"
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
p.remoteHandler().ServeHTTP(w, r)
return w
}
func TestPairingDurableCommitConflictAndReplay(t *testing.T) {
p, i := testPairing(t)
b := testCoreBinding(t, p)
offer := map[string]any{"id": i["id"], "secret": i["secret"], "binding": b}
first := pairCall(p, "/v1/pair/offer", offer)
if first.Code != 200 {
t.Fatal(first.Code, first.Body.String())
}
second := pairCall(p, "/v1/pair/offer", offer)
if second.Code != 200 || first.Body.String() != second.Body.String() {
t.Fatal("retry changed receipt")
}
b.BindingID = token()
offer["binding"] = b
if pairCall(p, "/v1/pair/offer", offer).Code != 409 {
t.Fatal("conflicting owner accepted")
}
// A process restart retains the pending receipt and finishes the same binding.
restored, e := OpenPairing(p.store, strings.TrimSuffix(p.path, "/core-binding.json"), "test", p.inventory)
if e != nil {
t.Fatal(e)
}
commit := map[string]string{"id": restored.state.Binding.BindingID, "receipt": restored.state.Binding.Receipt}
if pairCall(restored, "/v1/pair/commit", commit).Code != 200 {
t.Fatal("commit failed")
}
if pairCall(restored, "/v1/pair/inspect", map[string]any{"id": i["id"], "secret": i["secret"]}).Code != 410 {
t.Fatal("consumed code admitted")
}
if _, e = restored.invite("192.168.10.4"); e == nil {
t.Fatal("paired Node offered another invitation")
}
raw, _ := json.Marshal(restored.status())
if strings.Contains(string(raw), i["secret"].(string)) || strings.Contains(string(raw), "client_pem") {
t.Fatal("status leaked trust")
}
if e = restored.cancel(); e != nil {
t.Fatal(e)
}
if pairCall(restored, "/v1/pair/commit", commit).Code == 200 {
t.Fatal("cancelled binding resurrected")
}
if len(restored.state.Revocations) != 1 {
t.Fatal("revocation not durable")
}
}
func TestPairingExpiryAndPrivateAddressAdmission(t *testing.T) {
p, i := testPairing(t)
for _, address := range []string{"127.0.0.1", "0.0.0.0", "8.8.8.8", "192.168.10.99", "::1"} {
if _, e := p.invite(address); e == nil {
t.Fatal("nonlocal address accepted", address)
}
}
p.now = func() time.Time { return time.Unix(int64(i["expires_at"].(float64))+1, 0) }
if pairCall(p, "/v1/pair/inspect", map[string]any{"id": i["id"], "secret": i["secret"]}).Code != 410 {
t.Fatal("expired code accepted")
}
if p.state.Phase != "unpaired" {
t.Fatal(p.state.Phase)
}
}
func TestPairingRejectsForeignCertificateAndOpenPermissions(t *testing.T) {
p, _ := testPairing(t)
b := testCoreBinding(t, p)
if _, e := bindingTLS(b, p.store.pairingKey()); e != nil {
t.Fatal(e)
}
b.CoreID = "core_" + strings.Repeat("0", 64)
if _, e := bindingTLS(b, p.store.pairingKey()); e == nil {
t.Fatal("unmatched Core pin")
}
b = testCoreBinding(t, p)
_, other, _ := ed25519.GenerateKey(rand.Reader)
if _, e := bindingTLS(b, other); e == nil {
t.Fatal("foreign Node certificate")
}
os.Chmod(p.path, 0644)
if _, e := OpenPairing(p.store, strings.TrimSuffix(p.path, "/core-binding.json"), "test", p.inventory); e == nil {
t.Fatal("open trust file accepted")
}
}
func TestPairingLocalRoutesRequireOperatorSession(t *testing.T) {
s := newTestServer(t)
p, _ := testPairing(t)
s.Pairing = p
if call(s, "GET", "/api/core", "", nil).Code != 401 {
t.Fatal("unauthenticated access")
}
if call(s, "GET", "/api/core", "", login(t, s)).Code != 200 {
t.Fatal("operator denied")
}
}
@@ -0,0 +1,321 @@
package node
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/url"
"time"
)
func (p *Pairing) localRoutes(mux *http.ServeMux, s *Server) {
mux.HandleFunc("GET /api/core", func(w http.ResponseWriter, r *http.Request) {
if s.authorized(w, r) {
reply(w, 200, p.status())
}
})
mux.HandleFunc("POST /api/core/invitation", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
var body struct {
Address string `json:"address"`
}
if !decode(w, r, &body) {
return
}
out, e := p.invite(body.Address)
if e != nil {
reply(w, 409, map[string]string{"error": e.Error()})
return
}
reply(w, 200, out)
})
mux.HandleFunc("DELETE /api/core", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
if e := p.cancel(); e != nil {
reply(w, 409, map[string]string{"error": "Не удалось отменить привязку. Повторите действие."})
return
}
reply(w, 200, map[string]bool{"ok": true})
})
}
func pairDecode(w http.ResponseWriter, r *http.Request, value any) bool {
if r.Method != "POST" || r.Header.Get("Content-Type") != "application/json" || r.Header.Get("Origin") != "" {
http.Error(w, "Invalid request", 400)
return false
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16384))
decoder.DisallowUnknownFields()
if decoder.Decode(value) != nil || decoder.Decode(new(any)) != io.EOF {
http.Error(w, "Invalid request", 400)
return false
}
return true
}
func (p *Pairing) remoteHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
host, _, e := net.SplitHostPort(r.RemoteAddr)
if e != nil || !PrivateAddress(host) {
http.Error(w, "Private peer required", 403)
return
}
var body struct {
ID string `json:"id"`
Secret string `json:"secret"`
Binding *CoreBinding `json:"binding,omitempty"`
Receipt string `json:"receipt,omitempty"`
}
if !pairDecode(w, r, &body) {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if p.expire() != nil {
http.Error(w, "State unavailable", 503)
return
}
id, name := p.store.Public()
switch r.URL.Path {
case "/v1/pair/inspect":
if p.state.Phase != "inviting" || !p.checkInvitation(body.ID, body.Secret) {
http.Error(w, "Invitation expired, consumed or cancelled", 410)
return
}
reply(w, 200, map[string]any{"schema": PairSchema, "node_id": id, "name": name, "version": p.version, "host": p.inventory()})
case "/v1/pair/offer":
if !p.checkInvitation(body.ID, body.Secret) || body.Binding == nil {
http.Error(w, "Invitation expired, consumed or cancelled", 410)
return
}
b := *body.Binding
if len(b.BindingID) != 43 || len(b.CoreName) < 1 || len(b.CoreName) > 128 || len(b.CoreID) != 69 || len(b.CAPEM) > 8192 || len(b.ClientPEM) > 8192 || b.Receipt != "" || b.OfferHash != "" || b.ExpiresAt != 0 {
http.Error(w, "Invalid binding", 400)
return
}
raw, _ := json.Marshal(b)
hash := digest(string(raw))
if p.state.Phase == "pending" || p.state.Phase == "paired" {
if p.state.Binding.OfferHash != hash {
http.Error(w, "Another Core already claimed this Node", 409)
return
}
reply(w, 200, map[string]string{"receipt": p.state.Binding.Receipt, "node_id": id})
return
}
if p.state.Phase != "inviting" {
http.Error(w, "Invitation consumed", 410)
return
}
if _, e = bindingTLS(b, p.store.pairingKey()); e != nil {
http.Error(w, "Invalid Core trust", 400)
return
}
b.Receipt = token()
b.OfferHash = hash
b.ExpiresAt = p.state.Invitation.ExpiresAt
next := p.state
next.Phase = "pending"
next.Binding = &b
if p.save(next) != nil {
http.Error(w, "State unavailable", 503)
return
}
reply(w, 200, map[string]string{"receipt": b.Receipt, "node_id": id})
case "/v1/pair/commit":
b := p.state.Binding
if b == nil || body.ID != b.BindingID || body.Receipt == "" || digest(body.Receipt) != digest(b.Receipt) || (p.state.Phase != "pending" && p.state.Phase != "paired") {
http.Error(w, "No matching pending binding", 409)
return
}
next := p.state
next.Phase = "paired"
if p.save(next) != nil {
http.Error(w, "State unavailable", 503)
return
}
reply(w, 200, map[string]any{"node_id": id, "binding_id": b.BindingID, "phase": "paired"})
default:
http.NotFound(w, r)
}
})
}
func (p *Pairing) Run(ctx context.Context) {
go p.channel(ctx)
var server *http.Server
endpoint := ""
closeServer := func() {
if server != nil {
timeout, cancel := context.WithTimeout(context.Background(), time.Second)
_ = server.Shutdown(timeout)
cancel()
server = nil
}
endpoint = ""
}
defer closeServer()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
p.mu.Lock()
_ = p.expire()
desired := ""
if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation != nil {
desired = p.state.Invitation.Endpoint
}
p.mu.Unlock()
if desired != endpoint {
closeServer()
if desired != "" {
u, _ := url.Parse(desired)
cert, e := bootstrapCertificate(p.store.pairingKey(), u.Hostname())
var listener net.Listener
if e == nil {
listener, e = net.Listen("tcp4", u.Host)
}
p.mu.Lock()
if e != nil {
p.listenError = "Не удалось открыть частное подключение. Проверьте адрес и создайте приглашение повторно."
} else {
p.listenError = ""
}
p.mu.Unlock()
if e == nil {
server = &http.Server{Handler: p.remoteHandler(), ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 5 * time.Second, MaxHeaderBytes: 8192, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert}}}
endpoint = desired
go func(s *http.Server, l net.Listener) { _ = s.Serve(tls.NewListener(l, s.TLSConfig)) }(server, &pairingListener{Listener: listener, slots: make(chan struct{}, 16), done: make(chan struct{})})
}
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (p *Pairing) send(ctx context.Context, b CoreBinding, path string, payload any) (map[string]json.RawMessage, int, error) {
config, e := bindingTLS(b, p.store.pairingKey())
if e != nil {
return nil, 0, e
}
cacheKey := b.BindingID + digest(b.ClientPEM)
client := p.clients[cacheKey]
if client == nil {
transport := &http.Transport{TLSClientConfig: config, Proxy: nil, MaxConnsPerHost: 1, MaxIdleConnsPerHost: 1, IdleConnTimeout: 15 * time.Second, TLSHandshakeTimeout: 4 * time.Second, DialContext: (&net.Dialer{Timeout: 4 * time.Second}).DialContext}
client = &http.Client{Transport: transport, Timeout: 8 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirects forbidden") }}
p.clients[cacheKey] = client
}
data, e := json.Marshal(payload)
if e != nil {
return nil, 0, e
}
request, e := http.NewRequestWithContext(ctx, "POST", b.Endpoint+path, bytes.NewReader(data))
if e != nil {
return nil, 0, e
}
request.Header.Set("Content-Type", "application/json")
response, e := client.Do(request)
if e != nil {
return nil, 0, e
}
defer response.Body.Close()
var out map[string]json.RawMessage
if json.NewDecoder(io.LimitReader(response.Body, 16384)).Decode(&out) != nil {
return nil, response.StatusCode, errors.New("invalid Core response")
}
return out, response.StatusCode, nil
}
func (p *Pairing) channel(ctx context.Context) {
instance := "agent_" + token()
timer := time.NewTicker(5 * time.Second)
defer timer.Stop()
defer func() {
for _, c := range p.clients {
c.CloseIdleConnections()
}
}()
for {
p.mu.Lock()
var binding *CoreBinding
if p.state.Phase == "paired" && p.state.Binding != nil {
copy := *p.state.Binding
binding = &copy
}
revocations := append([]CoreBinding(nil), p.state.Revocations...)
p.mu.Unlock()
wanted := make(map[string]bool)
if binding != nil {
wanted[binding.BindingID+digest(binding.ClientPEM)] = true
}
for _, b := range revocations {
wanted[b.BindingID+digest(b.ClientPEM)] = true
}
for key, c := range p.clients {
if !wanted[key] {
c.CloseIdleConnections()
delete(p.clients, key)
}
}
if binding != nil {
id, name := p.store.Public()
payload := map[string]any{"schema": PairSchema, "binding_id": binding.BindingID, "node_id": id, "name": name, "version": p.version, "execution_binding": map[string]string{"node_id": id, "agent_instance_id": instance, "platform": "linux"}, "host": p.inventory(), "devices": []any{}}
result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload)
p.mu.Lock()
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID {
if e == nil && status == 200 {
p.connection = "online"
p.lastSeen = p.now().Unix()
var cert string
if json.Unmarshal(result["client_pem"], &cert) == nil && cert != "" && cert != binding.ClientPEM {
next := *binding
next.ClientPEM = cert
if _, e := bindingTLS(next, p.store.pairingKey()); e == nil {
state := p.state
state.Binding = &next
_ = p.save(state)
}
}
} else if e == nil && status == 410 {
next := p.state
next.Phase = "revoked"
_ = p.save(next)
p.connection = "revoked"
} else {
p.connection = "offline"
}
}
p.mu.Unlock()
}
for _, b := range revocations {
id, _ := p.store.Public()
_, status, e := p.send(ctx, b, "/v1/node/unpair", map[string]string{"schema": PairSchema, "binding_id": b.BindingID, "node_id": id})
if (e == nil && (status == 200 || status == 410)) || clientExpired(b) {
p.mu.Lock()
next := p.state
next.Revocations = nil
for _, item := range p.state.Revocations {
if item.BindingID != b.BindingID {
next.Revocations = append(next.Revocations, item)
}
}
_ = p.save(next)
p.mu.Unlock()
}
}
select {
case <-ctx.Done():
return
case <-timer.C:
}
}
}
+4
View File
@@ -14,6 +14,7 @@ import (
type Server struct {
Store *Store
Pairing *Pairing
Assets fs.FS
Origin string
Version string
@@ -77,6 +78,9 @@ func reply(w http.ResponseWriter, status int, v any) {
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
if s.Pairing != nil {
s.Pairing.localRoutes(mux, s)
}
if s.Access != nil {
s.accessRoutes(mux)
}