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
|
||||
}
|
||||
@@ -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 = ©
|
||||
}
|
||||
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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user