mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-09-26 09:55:38 +00:00
restore to state on main
This commit is contained in:
@@ -23,13 +23,13 @@ require (
|
||||
github.com/ncruces/go-sqlite3 v0.29.0
|
||||
github.com/ncruces/zenity v0.10.14
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/vearutop/statigz v1.5.0
|
||||
go.uber.org/multierr v1.11.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/sync v0.17.0
|
||||
golang.org/x/sys v0.36.0
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2
|
||||
google.golang.org/grpc v1.72.0
|
||||
google.golang.org/protobuf v1.36.6
|
||||
@@ -40,7 +40,6 @@ require (
|
||||
github.com/akavel/rsrc v0.10.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dchest/jsmin v1.0.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/getlantern/context v0.0.0-20220418194847-3d5e7a086201 // indirect
|
||||
@@ -59,7 +58,6 @@ require (
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/julianday v1.0.0 // indirect
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.63.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
@@ -70,15 +68,6 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
golang.org/x/image v0.27.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 // indirect
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.65.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.10.0 // indirect
|
||||
modernc.org/sqlite v1.37.0 // indirect
|
||||
>>>>>>> 9041d3c (improve sync api security by using 'Authorization' headers for initial key exchange)
|
||||
)
|
||||
|
||||
@@ -93,12 +93,8 @@ github.com/josephspurrier/goversioninfo v1.5.0/go.mod h1:6MoTvFZ6GKJkzcdLnU5T/RG
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
|
||||
@@ -136,13 +132,6 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 h1:GranzK4hv1/pqTIhMTXt2X8MmMOuH3hMeUR0o9SP5yc=
|
||||
github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8=
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
>>>>>>> 9041d3c (improve sync api security by using 'Authorization' headers for initial key exchange)
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
@@ -235,8 +224,6 @@ google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
package syncapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
v1 "github.com/garethgeorge/backrest/gen/go/v1"
|
||||
"github.com/garethgeorge/backrest/internal/config"
|
||||
"github.com/garethgeorge/backrest/internal/cryptoutil"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type peerContextKey struct{}
|
||||
type peerPublicKeyContextKey struct{}
|
||||
|
||||
func PeerFromContext(ctx context.Context) *v1.Multihost_Peer {
|
||||
return ctx.Value(peerContextKey{}).(*v1.Multihost_Peer)
|
||||
}
|
||||
|
||||
func PeerPublicKeyFromContext(ctx context.Context) *cryptoutil.PublicKey {
|
||||
return ctx.Value(peerPublicKeyContextKey{}).(*cryptoutil.PublicKey)
|
||||
}
|
||||
|
||||
func ContextWithPeer(ctx context.Context, peer *v1.Multihost_Peer, publicKey *cryptoutil.PublicKey) context.Context {
|
||||
if peer == nil {
|
||||
return ctx
|
||||
}
|
||||
ctx = context.WithValue(ctx, peerContextKey{}, peer)
|
||||
ctx = context.WithValue(ctx, peerPublicKeyContextKey{}, publicKey)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// HTTP decorator for authentication middleware.
|
||||
func AuthenticationMiddleware(configManager *config.ConfigManager, handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Unauthorized: missing authentication header", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
config, err := configManager.Get()
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
authorizedClientPeers := config.GetMultihost().GetAuthorizedClients()
|
||||
|
||||
peerKey, instanceID, err := verifyAuthenticationHeader(authHeader)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Unauthorized: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
authorizedPeerIdx := slices.IndexFunc(authorizedClientPeers, func(peer *v1.Multihost_Peer) bool {
|
||||
return peer.Keyid == peerKey.KeyID()
|
||||
})
|
||||
if authorizedPeerIdx == -1 {
|
||||
zap.S().Errorf("peer key %q is not listed in authorized clients", peerKey.KeyID())
|
||||
http.Error(w, fmt.Sprintf("Unauthorized: peer key %q is not listed in authorized clients", peerKey.KeyID()), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
authorizedPeer := authorizedClientPeers[authorizedPeerIdx]
|
||||
if authorizedPeer.InstanceId != instanceID {
|
||||
zap.S().Errorf("instance ID mismatch for peer key %q, expected %q, got %q", peerKey.KeyID(), authorizedPeer.InstanceId, instanceID)
|
||||
http.Error(w, fmt.Sprintf("Unauthorized: instance ID mismatch for peer key %q, expected %q, got %q", peerKey.KeyID(), authorizedPeer.InstanceId, instanceID), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ctx := ContextWithPeer(r.Context(), authorizedPeer, peerKey)
|
||||
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func createHandshakePacket(instanceID string, identity *cryptoutil.PrivateKey) (*v1.SyncStreamItem, error) {
|
||||
signedMessage, err := createSignedMessage([]byte(instanceID), identity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing instance ID: %w", err)
|
||||
}
|
||||
|
||||
return &v1.SyncStreamItem{
|
||||
Action: &v1.SyncStreamItem_Handshake{
|
||||
Handshake: &v1.SyncStreamItem_SyncActionHandshake{
|
||||
ProtocolVersion: SyncProtocolVersion,
|
||||
InstanceId: signedMessage,
|
||||
PublicKey: identity.PublicKeyProto(),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// send authentication in a header rather than in the stream.
|
||||
func createAuthenticationHeader(instanceID string, identity *cryptoutil.PrivateKey) (string, error) {
|
||||
signedMessage, err := createSignedMessage([]byte(instanceID), identity)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("signing instance ID for authentication header: %w", err)
|
||||
}
|
||||
|
||||
handshakePacket := &v1.SyncStreamItem_SyncActionHandshake{
|
||||
ProtocolVersion: SyncProtocolVersion,
|
||||
InstanceId: signedMessage,
|
||||
PublicKey: identity.PublicKeyProto(),
|
||||
}
|
||||
|
||||
encodedHandshake, err := proto.Marshal(handshakePacket)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshalling handshake packet: %w", err)
|
||||
}
|
||||
|
||||
if len(encodedHandshake) > 512 {
|
||||
return "", fmt.Errorf("authorization header is too large, max size is 512 bytes, got %d bytes", len(encodedHandshake))
|
||||
}
|
||||
|
||||
base64Handshake := base64.StdEncoding.EncodeToString(encodedHandshake)
|
||||
return fmt.Sprintf("Backrest-Sync-Auth %s", base64Handshake), nil
|
||||
}
|
||||
|
||||
// verifyHandshakePacket verifies that
|
||||
// - the signature on the instance ID is valid against the public key provided in the handshake
|
||||
// - that the public key's ID is as attested in the handshake packet e.g. matches handshake.PublicKey.Keyid
|
||||
//
|
||||
// To authenticate, the caller must then check that the public key is trusted by checking the key ID against a local list.
|
||||
func verifyHandshakePacket(item *v1.SyncStreamItem) (*cryptoutil.PublicKey, error) {
|
||||
handshake := item.GetHandshake()
|
||||
if handshake == nil {
|
||||
return nil, fmt.Errorf("empty or nil handshake, handshake packet must be sent first")
|
||||
}
|
||||
|
||||
if handshake.ProtocolVersion != SyncProtocolVersion {
|
||||
return nil, fmt.Errorf("protocol version mismatch: expected %d, got %d", SyncProtocolVersion, handshake.ProtocolVersion)
|
||||
}
|
||||
|
||||
if len(handshake.InstanceId.GetPayload()) == 0 || len(handshake.InstanceId.GetSignature()) == 0 {
|
||||
return nil, errors.New("instance ID payload and signature must not be empty")
|
||||
}
|
||||
|
||||
if len(handshake.PublicKey.Keyid) == 0 {
|
||||
return nil, errors.New("public key ID must not be empty")
|
||||
}
|
||||
|
||||
peerKey, err := cryptoutil.NewPublicKey(handshake.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading peer public key: %w", err)
|
||||
}
|
||||
|
||||
if err := verifySignedMessage(handshake.InstanceId, peerKey); err != nil {
|
||||
return nil, fmt.Errorf("verifying instance ID signature: %w", err)
|
||||
}
|
||||
|
||||
return peerKey, nil
|
||||
}
|
||||
|
||||
func verifyAuthenticationHeader(header string) (*cryptoutil.PublicKey, string, error) {
|
||||
if len(header) == 0 {
|
||||
return nil, "", errors.New("authentication header must not be empty")
|
||||
}
|
||||
|
||||
// The header is expected to be in the format "Backrest-Sync-Auth <base64-encoded-handshake-packet>"
|
||||
if !strings.HasPrefix(header, "Backrest-Sync-Auth ") {
|
||||
return nil, "", fmt.Errorf("invalid authentication header format, expected 'Backrest-Sync-Auth <base64-encoded-handshake-packet>', got %s", header)
|
||||
}
|
||||
|
||||
// Extract the base64-encoded handshake packet
|
||||
header = header[len("Backrest-Sync-Auth "):]
|
||||
decoded, err := base64.StdEncoding.DecodeString(header)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decoding authentication header: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal the decoded header into a handshake packet
|
||||
var handshakePacket v1.SyncStreamItem_SyncActionHandshake
|
||||
if err := proto.Unmarshal(decoded, &handshakePacket); err != nil {
|
||||
return nil, "", fmt.Errorf("unmarshalling handshake packet: %w", err)
|
||||
}
|
||||
|
||||
// Verify the handshake packet
|
||||
peerKey, err := verifyHandshakePacket(&v1.SyncStreamItem{
|
||||
Action: &v1.SyncStreamItem_Handshake{
|
||||
Handshake: &handshakePacket,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("verifying handshake packet: %w", err)
|
||||
}
|
||||
|
||||
return peerKey, string(handshakePacket.GetInstanceId().GetPayload()), nil
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package syncapi
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
v1 "github.com/garethgeorge/backrest/gen/go/v1"
|
||||
"github.com/garethgeorge/backrest/internal/cryptoutil"
|
||||
)
|
||||
|
||||
func createSignedMessage(payload []byte, identity *cryptoutil.PrivateKey) (*v1.SignedMessage, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, errors.New("payload must not be empty")
|
||||
}
|
||||
|
||||
timestampMillis := time.Now().UnixMilli()
|
||||
|
||||
payloadWithTimestamp := make([]byte, 0, len(payload)+8)
|
||||
binary.BigEndian.AppendUint64(payloadWithTimestamp, uint64(timestampMillis))
|
||||
payloadWithTimestamp = append(payloadWithTimestamp, payload...)
|
||||
|
||||
signature, err := identity.Sign(payloadWithTimestamp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing payload: %w", err)
|
||||
}
|
||||
|
||||
return &v1.SignedMessage{
|
||||
Payload: payload,
|
||||
Signature: signature,
|
||||
Keyid: identity.KeyID(),
|
||||
TimestampMillis: timestampMillis,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func verifySignedMessage(msg *v1.SignedMessage, publicKey *cryptoutil.PublicKey) error {
|
||||
if msg == nil {
|
||||
return errors.New("signed message must not be nil")
|
||||
}
|
||||
if len(msg.GetPayload()) == 0 {
|
||||
return errors.New("signed message payload must not be empty")
|
||||
}
|
||||
if len(msg.GetSignature()) == 0 {
|
||||
return errors.New("signed message signature must not be empty")
|
||||
}
|
||||
if len(msg.GetKeyid()) == 0 {
|
||||
return errors.New("signed message key ID must not be empty")
|
||||
}
|
||||
|
||||
if publicKey.KeyID() != msg.GetKeyid() {
|
||||
return fmt.Errorf("public key ID mismatch: expected %s, got %s", publicKey.KeyID(), msg.GetKeyid())
|
||||
}
|
||||
|
||||
payloadWithTimestamp := make([]byte, 0, len(msg.GetPayload())+8)
|
||||
binary.BigEndian.AppendUint64(payloadWithTimestamp, uint64(msg.GetTimestampMillis()))
|
||||
payloadWithTimestamp = append(payloadWithTimestamp, msg.GetPayload()...)
|
||||
|
||||
if err := publicKey.Verify(payloadWithTimestamp, msg.GetSignature()); err != nil {
|
||||
return fmt.Errorf("verifying signed message: %w", err)
|
||||
}
|
||||
|
||||
if time.Since(time.UnixMilli(msg.GetTimestampMillis())) > maxSignatureAge {
|
||||
return fmt.Errorf("signature is too old, max age is %s. Is the clock out of sync?", maxSignatureAge)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -42,6 +42,20 @@ func NewSyncErrorPending(message error) *SyncError {
|
||||
}
|
||||
}
|
||||
|
||||
func NewSyncErrorConnected(message error) *SyncError {
|
||||
return &SyncError{
|
||||
State: v1.SyncConnectionState_CONNECTION_STATE_CONNECTED,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
func NewSyncErrorRetryWait(message error) *SyncError {
|
||||
return &SyncError{
|
||||
State: v1.SyncConnectionState_CONNECTION_STATE_RETRY_WAIT,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
func NewSyncErrorAuth(message error) *SyncError {
|
||||
return &SyncError{
|
||||
State: v1.SyncConnectionState_CONNECTION_STATE_ERROR_AUTH,
|
||||
|
||||
@@ -25,8 +25,8 @@ type PeerState struct {
|
||||
ConnectionStateMessage string
|
||||
|
||||
// Plans and repos available on this peer
|
||||
KnownRepos map[string]*v1.SyncRepoMetadata
|
||||
KnownPlans map[string]*v1.SyncPlanMetadata
|
||||
KnownRepos map[string]struct{}
|
||||
KnownPlans map[string]struct{}
|
||||
|
||||
// Partial configuration available for this peer
|
||||
Config *v1.RemoteConfig
|
||||
@@ -39,8 +39,8 @@ func newPeerState(instanceID, keyID string) *PeerState {
|
||||
LastHeartbeat: time.Now(),
|
||||
ConnectionState: v1.SyncConnectionState_CONNECTION_STATE_DISCONNECTED,
|
||||
ConnectionStateMessage: "disconnected",
|
||||
KnownRepos: make(map[string]*v1.SyncRepoMetadata),
|
||||
KnownPlans: make(map[string]*v1.SyncPlanMetadata),
|
||||
KnownRepos: make(map[string]struct{}),
|
||||
KnownPlans: make(map[string]struct{}),
|
||||
Config: nil, // Will be set when the config is received
|
||||
}
|
||||
}
|
||||
@@ -68,8 +68,8 @@ func peerStateToProto(state *PeerState) *v1.PeerState {
|
||||
LastHeartbeatMillis: state.LastHeartbeat.UnixMilli(),
|
||||
State: state.ConnectionState,
|
||||
StatusMessage: state.ConnectionStateMessage,
|
||||
KnownRepos: slices.Collect(maps.Values(state.KnownRepos)),
|
||||
KnownPlans: slices.Collect(maps.Values(state.KnownPlans)),
|
||||
KnownRepos: slices.Collect(maps.Keys(state.KnownRepos)),
|
||||
KnownPlans: slices.Collect(maps.Keys(state.KnownPlans)),
|
||||
RemoteConfig: state.Config,
|
||||
}
|
||||
}
|
||||
@@ -78,13 +78,13 @@ func peerStateFromProto(state *v1.PeerState) *PeerState {
|
||||
if state.PeerInstanceId == "" || state.PeerKeyid == "" {
|
||||
return nil
|
||||
}
|
||||
knownRepos := make(map[string]*v1.SyncRepoMetadata, len(state.KnownRepos))
|
||||
knownRepos := make(map[string]struct{}, len(state.KnownRepos))
|
||||
for _, repo := range state.KnownRepos {
|
||||
knownRepos[repo.Id] = repo
|
||||
knownRepos[repo] = struct{}{}
|
||||
}
|
||||
knownPlans := make(map[string]*v1.SyncPlanMetadata, len(state.KnownPlans))
|
||||
knownPlans := make(map[string]struct{}, len(state.KnownPlans))
|
||||
for _, plan := range state.KnownPlans {
|
||||
knownPlans[plan.Id] = plan
|
||||
knownPlans[plan] = struct{}{}
|
||||
}
|
||||
|
||||
return &PeerState{
|
||||
@@ -103,7 +103,6 @@ type PeerStateManager interface {
|
||||
GetPeerState(keyID string) *PeerState
|
||||
GetAll() []*PeerState
|
||||
SetPeerState(keyID string, state *PeerState)
|
||||
UpdatePeerState(keyID string, instanceID string, updateFn func(state *PeerState))
|
||||
OnStateChanged() eventemitter.Receiver[*PeerState]
|
||||
Close() error
|
||||
}
|
||||
@@ -159,23 +158,6 @@ func (m *InMemoryPeerStateManager) SetPeerState(keyID string, state *PeerState)
|
||||
m.onStateChanged.Emit(copy)
|
||||
}
|
||||
|
||||
func (m *InMemoryPeerStateManager) UpdatePeerState(keyID string, instanceID string, updateFn func(state *PeerState)) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var state *PeerState
|
||||
if existingState, exists := m.peerStates[keyID]; exists {
|
||||
state = existingState.Clone()
|
||||
} else {
|
||||
state = newPeerState(instanceID, keyID)
|
||||
}
|
||||
|
||||
updateFn(state)
|
||||
|
||||
m.peerStates[keyID] = state
|
||||
m.onStateChanged.Emit(state.Clone())
|
||||
}
|
||||
|
||||
func (m *InMemoryPeerStateManager) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -264,41 +246,6 @@ func (m *SqlitePeerStateManager) SetPeerState(keyID string, state *PeerState) {
|
||||
m.onStateChanged.Emit(state.Clone())
|
||||
}
|
||||
|
||||
func (m *SqlitePeerStateManager) UpdatePeerState(keyID string, instanceID string, updateFn func(state *PeerState)) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var state *PeerState
|
||||
if stateBytes, err := m.kvstore.Get(keyID); err == nil {
|
||||
var stateProto v1.PeerState
|
||||
if err := proto.Unmarshal(stateBytes, &stateProto); err != nil {
|
||||
zap.S().Warnf("error unmarshalling peer state for key %s: %v", keyID, err)
|
||||
} else {
|
||||
state = peerStateFromProto(&stateProto)
|
||||
}
|
||||
} else {
|
||||
zap.S().Warnf("error getting peer state for key %s: %v", keyID, err)
|
||||
}
|
||||
if state == nil {
|
||||
state = newPeerState(instanceID, keyID)
|
||||
}
|
||||
|
||||
updateFn(state)
|
||||
|
||||
stateProto := peerStateToProto(state)
|
||||
newStateBytes, err := proto.Marshal(stateProto)
|
||||
if err != nil {
|
||||
zap.S().Warnf("error marshalling peer state for key %s: %v", keyID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.kvstore.Set(keyID, newStateBytes); err != nil {
|
||||
zap.S().Warnf("error setting peer state for key %s: %v", keyID, err)
|
||||
return
|
||||
}
|
||||
m.onStateChanged.Emit(state.Clone())
|
||||
}
|
||||
|
||||
func (m *SqlitePeerStateManager) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
package syncapi
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
<<<<<<< HEAD
|
||||
"github.com/garethgeorge/backrest/internal/kvstore"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
_ "github.com/ncruces/go-sqlite3/driver"
|
||||
"github.com/ncruces/go-sqlite3/vfs/memdb"
|
||||
=======
|
||||
v1 "github.com/garethgeorge/backrest/gen/go/v1"
|
||||
"github.com/garethgeorge/backrest/internal/cryptoutil"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"google.golang.org/protobuf/testing/protocmp"
|
||||
"zombiezen.com/go/sqlite"
|
||||
"zombiezen.com/go/sqlite/sqlitex"
|
||||
>>>>>>> 9041d3c (improve sync api security by using 'Authorization' headers for initial key exchange)
|
||||
)
|
||||
|
||||
func PeerStateManagersForTest(t testing.TB) map[string]PeerStateManager {
|
||||
dbpool := newDbForTest(t)
|
||||
dbpool := kvstore.NewInMemorySqliteDbForKvStore(t)
|
||||
t.Cleanup(func() {
|
||||
dbpool.Close()
|
||||
})
|
||||
@@ -44,18 +33,12 @@ func TestPeerStateManager_GetSet(t *testing.T) {
|
||||
InstanceID: "testInstance",
|
||||
KeyID: keyID,
|
||||
LastHeartbeat: time.Now().Round(time.Millisecond),
|
||||
KnownRepos: map[string]*v1.SyncRepoMetadata{
|
||||
"repo1": {Id: "repo1"},
|
||||
"repo2": {Id: "repo2"},
|
||||
},
|
||||
KnownPlans: map[string]*v1.SyncPlanMetadata{
|
||||
"plan1": {Id: "plan1"},
|
||||
"plan2": {Id: "plan2"},
|
||||
},
|
||||
KnownRepos: map[string]struct{}{"repo1": {}, "repo2": {}},
|
||||
KnownPlans: map[string]struct{}{"plan1": {}, "plan2": {}},
|
||||
}
|
||||
psm.SetPeerState(keyID, state)
|
||||
gotState := psm.GetPeerState(keyID)
|
||||
if diff := cmp.Diff(state, gotState, protocmp.Transform()); diff != "" {
|
||||
if diff := cmp.Diff(state, gotState, cmp.AllowUnexported(PeerState{})); diff != "" {
|
||||
t.Errorf("unexpected diff: %v", diff)
|
||||
}
|
||||
})
|
||||
@@ -95,7 +78,7 @@ func TestPeerStateManager_OnStateChanged(t *testing.T) {
|
||||
|
||||
select {
|
||||
case changedState := <-ch:
|
||||
if diff := cmp.Diff(state, changedState, protocmp.Transform()); diff != "" {
|
||||
if diff := cmp.Diff(state, changedState, cmp.AllowUnexported(PeerState{})); diff != "" {
|
||||
t.Errorf("unexpected diff: %v", diff)
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
@@ -104,12 +87,3 @@ func TestPeerStateManager_OnStateChanged(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newDbForTest(t testing.TB) *sql.DB {
|
||||
t.Helper()
|
||||
dbpool, err := sql.Open("sqlite3", memdb.TestDB(t))
|
||||
if err != nil {
|
||||
t.Fatalf("error creating sqlite pool: %s", err)
|
||||
}
|
||||
return dbpool
|
||||
}
|
||||
|
||||
@@ -539,9 +539,6 @@ func tryExpectExactOperations(t *testing.T, ctx context.Context, peer *peerUnder
|
||||
for _, op := range ops {
|
||||
op.Modno = 0
|
||||
}
|
||||
for _, op := range wantOps {
|
||||
op.Modno = 0
|
||||
}
|
||||
if diff := cmp.Diff(ops, wantOps, protocmp.Transform()); diff != "" {
|
||||
return fmt.Errorf("unexpected diff: %v", diff)
|
||||
}
|
||||
@@ -626,13 +623,13 @@ func waitForConnectionState(t *testing.T, ctx context.Context, peer *peerUnderTe
|
||||
onStateChanged := peer.manager.peerStateManager.OnStateChanged().Subscribe()
|
||||
defer peer.manager.peerStateManager.OnStateChanged().Unsubscribe(onStateChanged)
|
||||
|
||||
// First check if the peer is already in the desired state.
|
||||
// First check if the peer is already connected.
|
||||
state := peer.manager.peerStateManager.GetPeerState(hostPeer.Keyid)
|
||||
if state != nil && state.ConnectionState == wantState {
|
||||
return // Already in desired state, nothing to do
|
||||
if state != nil && state.ConnectionState == v1.SyncConnectionState_CONNECTION_STATE_CONNECTED {
|
||||
return // Already connected, nothing to do
|
||||
}
|
||||
|
||||
// If not in desired state, wait for a connection event
|
||||
// If not connected, wait for a connection event
|
||||
var lastState *PeerState
|
||||
stop := false
|
||||
for !stop {
|
||||
@@ -644,7 +641,7 @@ func waitForConnectionState(t *testing.T, ctx context.Context, peer *peerUnderTe
|
||||
}
|
||||
if state.KeyID == hostPeer.Keyid && state.InstanceID == hostPeer.InstanceId {
|
||||
lastState = state
|
||||
if state.ConnectionState == wantState {
|
||||
if state.ConnectionState == v1.SyncConnectionState_CONNECTION_STATE_CONNECTED {
|
||||
stop = true
|
||||
continue
|
||||
}
|
||||
@@ -656,8 +653,8 @@ func waitForConnectionState(t *testing.T, ctx context.Context, peer *peerUnderTe
|
||||
}
|
||||
if lastState == nil {
|
||||
t.Fatalf("timeout waiting for connection to host peer %s", hostPeer.InstanceId)
|
||||
} else if lastState.ConnectionState != wantState {
|
||||
t.Fatalf("expected connection state to be %v, got %v (reason: %q)", wantState, lastState.ConnectionState, lastState.ConnectionStateMessage)
|
||||
} else if lastState.ConnectionState != v1.SyncConnectionState_CONNECTION_STATE_CONNECTED {
|
||||
t.Fatalf("expected connection state to be CONNECTED, got %v (reason: %q)", lastState.ConnectionState, lastState.ConnectionStateMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,8 +665,8 @@ func tryConnect(t *testing.T, ctx context.Context, peer *peerUnderTest, hostPeer
|
||||
func runSyncAPIWithCtx(ctx context.Context, peer *peerUnderTest, bindAddr string) {
|
||||
mux := http.NewServeMux()
|
||||
syncHandler := NewBackrestSyncHandler(peer.manager)
|
||||
endPoint, syncServiceHandler := v1connect.NewBackrestSyncServiceHandler(syncHandler)
|
||||
mux.Handle(endPoint, AuthenticationMiddleware(peer.configMgr, syncServiceHandler))
|
||||
mux.Handle(v1connect.NewBackrestSyncServiceHandler(syncHandler))
|
||||
|
||||
server := &http.Server{
|
||||
Addr: bindAddr,
|
||||
Handler: h2c.NewHandler(mux, &http2.Server{}), // h2c is HTTP/2 without TLS for grpc-connect support.
|
||||
@@ -768,7 +765,7 @@ func newPeerUnderTest(t *testing.T, initialConfig *v1.Config) *peerUnderTest {
|
||||
t.Fatalf("failed to create peer state manager: %v", err)
|
||||
}
|
||||
|
||||
manager := NewSyncManager(configMgr, oplog, logStore, orchestrator, peerStateManager)
|
||||
manager := NewSyncManager(configMgr, oplog, orchestrator, peerStateManager)
|
||||
manager.syncClientRetryDelay = 250 * time.Millisecond
|
||||
|
||||
return &peerUnderTest{
|
||||
|
||||
@@ -74,9 +74,7 @@ func NewSyncClient(
|
||||
oplog: oplog,
|
||||
l: zap.L().Named(fmt.Sprintf("syncclient for %q", peer.GetInstanceId())),
|
||||
}
|
||||
c.mgr.peerStateManager.UpdatePeerState(peer.Keyid, peer.InstanceId, func(peerState *PeerState) {
|
||||
// this will create a new peer state if one doesn't exist
|
||||
})
|
||||
c.mgr.peerStateManager.SetPeerState(peer.Keyid, newPeerState(peer.InstanceId, peer.Keyid))
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -101,92 +99,47 @@ func (c *SyncClient) RunSync(ctx context.Context) {
|
||||
c.l.Sugar().Infof("connecting to peer %q (%s) at %s", c.peer.InstanceId, c.peer.Keyid, c.peer.GetInstanceUrl())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Attach the 'Authorization' header to the sync stream as we create it.
|
||||
syncStream := c.client.Sync(ctx)
|
||||
if authHeader, err := createAuthenticationHeader(c.syncConfigSnapshot.config.Instance, c.syncConfigSnapshot.identityKey); err != nil {
|
||||
c.l.Sugar().Errorf("failed to create authentication header: %v", err)
|
||||
} else {
|
||||
syncStream.RequestHeader().Set("Authorization", authHeader)
|
||||
}
|
||||
|
||||
// Start a thread to handle the stream IO e.g. send and receive messages and pass them into the cmdStream stream abstraction.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := cmdStream.ConnectStream(ctx, syncStream); err != nil {
|
||||
c.l.Sugar().Infof("lost stream connection to peer %q (%s): %v", c.peer.InstanceId, c.peer.Keyid, err)
|
||||
var syncErr *SyncError
|
||||
c.mgr.peerStateManager.UpdatePeerState(c.peer.Keyid, c.peer.InstanceId, func(state *PeerState) {
|
||||
state.LastHeartbeat = time.Now()
|
||||
if errors.As(err, &syncErr) {
|
||||
state.ConnectionState = syncErr.State
|
||||
state.ConnectionStateMessage = syncErr.Message.Error()
|
||||
} else {
|
||||
state.ConnectionState = v1.SyncConnectionState_CONNECTION_STATE_ERROR_INTERNAL
|
||||
state.ConnectionStateMessage = err.Error()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
c.l.Sugar().Infof("stream connection to peer %q (%s) closed", c.peer.InstanceId, c.peer.Keyid)
|
||||
c.reconnectAttempts = 0
|
||||
c.mgr.peerStateManager.UpdatePeerState(c.peer.Keyid, c.peer.InstanceId, func(state *PeerState) {
|
||||
state.ConnectionState = v1.SyncConnectionState_CONNECTION_STATE_DISCONNECTED
|
||||
state.ConnectionStateMessage = "disconnected"
|
||||
state.LastHeartbeat = time.Now()
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
// Start a thread to handle the API loop, this is fundamentally a switch statement operating on incomming commands.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
// Get the headers from the stream
|
||||
headers := syncStream.ResponseHeader()
|
||||
authHeader := headers.Get("Authorization")
|
||||
|
||||
peerKey, instanceID, err := verifyAuthenticationHeader(authHeader)
|
||||
if err != nil {
|
||||
cmdStream.SendErrorAndTerminate(NewSyncErrorAuth(fmt.Errorf("verifying authentication header: %w", err)))
|
||||
return
|
||||
}
|
||||
if peerKey.KeyID() != c.peer.Keyid {
|
||||
cmdStream.SendErrorAndTerminate(NewSyncErrorAuth(fmt.Errorf("remote peer key ID mismatch: expected %q, got %q", c.peer.Keyid, peerKey.KeyID())))
|
||||
return
|
||||
}
|
||||
if instanceID != c.peer.InstanceId {
|
||||
cmdStream.SendErrorAndTerminate(NewSyncErrorAuth(fmt.Errorf("remote instance ID mismatch: expected %q, got %q", c.peer.InstanceId, instanceID)))
|
||||
return
|
||||
}
|
||||
ctx := ContextWithPeer(context.Background(), c.peer, peerKey)
|
||||
|
||||
// Run sync once the first packet has been received allowing us to read the request headers.
|
||||
// This means it is essential that the server always sends some initial packet, today this is just a heartbeat to start off the connection.
|
||||
err = runSync(
|
||||
err := runSync(
|
||||
ctx,
|
||||
c.localInstanceID,
|
||||
c.syncConfigSnapshot.identityKey,
|
||||
cmdStream,
|
||||
syncSessionHandler,
|
||||
c.syncConfigSnapshot.config.GetMultihost().GetKnownHosts(),
|
||||
)
|
||||
cmdStream.SendErrorAndTerminate(err)
|
||||
}()
|
||||
|
||||
// Send a heartbeat packet to trigger establishing the connection.
|
||||
cmdStream.Send(&v1.SyncStreamItem{
|
||||
Action: &v1.SyncStreamItem_Heartbeat{},
|
||||
})
|
||||
if err := cmdStream.ConnectStream(ctx, c.client.Sync(ctx)); err != nil {
|
||||
c.l.Sugar().Infof("lost stream connection to peer %q (%s): %v", c.peer.InstanceId, c.peer.Keyid, err)
|
||||
var syncErr *SyncError
|
||||
state := c.mgr.peerStateManager.GetPeerState(c.peer.Keyid).Clone()
|
||||
if state == nil {
|
||||
state = newPeerState(c.peer.InstanceId, c.peer.Keyid)
|
||||
}
|
||||
state.LastHeartbeat = time.Now()
|
||||
if errors.As(err, &syncErr) {
|
||||
state.ConnectionState = syncErr.State
|
||||
state.ConnectionStateMessage = syncErr.Message.Error()
|
||||
} else {
|
||||
state.ConnectionState = v1.SyncConnectionState_CONNECTION_STATE_ERROR_INTERNAL
|
||||
state.ConnectionStateMessage = err.Error()
|
||||
}
|
||||
c.mgr.peerStateManager.SetPeerState(c.peer.Keyid, state)
|
||||
} else {
|
||||
c.reconnectAttempts = 0
|
||||
}
|
||||
|
||||
// Wait for the thread running the API loop and the thread running the stream connection to finish.
|
||||
wg.Wait()
|
||||
|
||||
reconnectDelayWithBackoff := c.reconnectDelay
|
||||
delay := c.reconnectDelay - time.Since(lastConnect)
|
||||
if c.reconnectAttempts > 0 {
|
||||
reconnectDelayWithBackoff *= time.Duration(1 << min(c.reconnectAttempts, 5)) // 2^reconnectAttempts, max 32
|
||||
backoff := time.Duration(1<<min(c.reconnectAttempts, 5)) * c.reconnectDelay // 2^reconnectAttempts, max 32
|
||||
delay += backoff
|
||||
}
|
||||
delay := reconnectDelayWithBackoff - time.Since(lastConnect)
|
||||
c.l.Sugar().Infof("disconnected, will retry after %v (attempt %d)", delay, c.reconnectAttempts)
|
||||
c.reconnectAttempts++
|
||||
select {
|
||||
@@ -253,11 +206,14 @@ func (c *syncSessionHandlerClient) OnConnectionEstablished(ctx context.Context,
|
||||
}
|
||||
|
||||
c.l.Sugar().Infof("sync connection established with peer %q (%s)", peer.InstanceId, peer.Keyid)
|
||||
c.mgr.peerStateManager.UpdatePeerState(peer.Keyid, c.peer.InstanceId, func(peerState *PeerState) {
|
||||
peerState.ConnectionState = v1.SyncConnectionState_CONNECTION_STATE_CONNECTED
|
||||
peerState.ConnectionStateMessage = "connected"
|
||||
peerState.LastHeartbeat = time.Now()
|
||||
})
|
||||
peerState := c.mgr.peerStateManager.GetPeerState(peer.Keyid).Clone()
|
||||
if peerState == nil {
|
||||
peerState = newPeerState(c.peer.InstanceId, peer.Keyid)
|
||||
}
|
||||
peerState.ConnectionState = v1.SyncConnectionState_CONNECTION_STATE_CONNECTED
|
||||
peerState.ConnectionStateMessage = "connected"
|
||||
peerState.LastHeartbeat = time.Now()
|
||||
c.mgr.peerStateManager.SetPeerState(peer.Keyid, peerState)
|
||||
|
||||
// Send a heartbeat every 2 minutes to keep the connection alive.
|
||||
go sendHeartbeats(ctx, stream, env.MultihostHeartbeatInterval())
|
||||
@@ -274,22 +230,13 @@ func (c *syncSessionHandlerClient) OnConnectionEstablished(ctx context.Context,
|
||||
for _, repo := range localConfig.Repos {
|
||||
if c.permissions.CheckPermissionForRepo(repo.Guid, v1.Multihost_Permission_PERMISSION_READ_CONFIG) {
|
||||
remoteConfig.Repos = append(remoteConfig.Repos, repo)
|
||||
}
|
||||
if c.permissions.CheckPermissionForRepo(repo.Guid, v1.Multihost_Permission_PERMISSION_READ_OPERATIONS, v1.Multihost_Permission_PERMISSION_READ_CONFIG) {
|
||||
resourceList.Repos = append(resourceList.Repos, &v1.SyncRepoMetadata{
|
||||
Id: repo.Id,
|
||||
Guid: repo.Guid,
|
||||
})
|
||||
resourceList.RepoIds = append(resourceList.RepoIds, repo.Id)
|
||||
}
|
||||
}
|
||||
for _, plan := range localConfig.Plans {
|
||||
if c.permissions.CheckPermissionForPlan(plan.Id, v1.Multihost_Permission_PERMISSION_READ_CONFIG) {
|
||||
remoteConfig.Plans = append(remoteConfig.Plans, plan)
|
||||
}
|
||||
if c.permissions.CheckPermissionForPlan(plan.Id, v1.Multihost_Permission_PERMISSION_READ_OPERATIONS, v1.Multihost_Permission_PERMISSION_READ_CONFIG) {
|
||||
resourceList.Plans = append(resourceList.Plans, &v1.SyncPlanMetadata{
|
||||
Id: plan.Id,
|
||||
})
|
||||
resourceList.PlanIds = append(resourceList.PlanIds, plan.Id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +309,7 @@ func (c *syncSessionHandlerClient) OnConnectionEstablished(ctx context.Context,
|
||||
// This is a slow operation and we don't want to block the main loop waiting for it to complete and potentially forcing incomming messages to buffer or drop.
|
||||
go func() {
|
||||
startSync := func(diffSel *v1.OpSelector) error {
|
||||
c.l.Sugar().Debugf("starting sync with diffselector: %v", diffSel)
|
||||
c.l.Sugar().Infof("starting sync with diffselector: %v", diffSel)
|
||||
|
||||
diffQuery, err := protoutil.OpSelectorToQuery(diffSel)
|
||||
if err != nil {
|
||||
@@ -436,18 +383,13 @@ func (c *syncSessionHandlerClient) OnConnectionEstablished(ctx context.Context,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *syncSessionHandlerClient) OnConnectionClosed(ctx context.Context, stream *bidiSyncCommandStream) error {
|
||||
c.l.Sugar().Infof("syncclient connection closed for client %q", c.peer.GetInstanceId())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *syncSessionHandlerClient) HandleHeartbeat(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionHeartbeat) error {
|
||||
c.mgr.peerStateManager.UpdatePeerState(c.peer.Keyid, c.peer.InstanceId, func(peerState *PeerState) {
|
||||
if peerState == nil {
|
||||
return // this should not happen
|
||||
}
|
||||
peerState.LastHeartbeat = time.Now()
|
||||
})
|
||||
peerState := c.mgr.peerStateManager.GetPeerState(c.peer.Keyid).Clone()
|
||||
if peerState == nil {
|
||||
return NewSyncErrorInternal(fmt.Errorf("peer state not found for peer %q", c.peer.InstanceId))
|
||||
}
|
||||
peerState.LastHeartbeat = time.Now()
|
||||
c.mgr.peerStateManager.SetPeerState(c.peer.Keyid, peerState)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -530,16 +472,16 @@ func (c *syncSessionHandlerClient) HandleSendOperations(ctx context.Context, str
|
||||
|
||||
func (c *syncSessionHandlerClient) HandleSendConfig(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionSendConfig) error {
|
||||
c.l.Sugar().Debugf("received remote config update")
|
||||
peerState := c.mgr.peerStateManager.GetPeerState(c.peer.Keyid).Clone()
|
||||
if peerState == nil {
|
||||
return NewSyncErrorInternal(fmt.Errorf("peer state for %q not found", c.peer.Keyid))
|
||||
}
|
||||
newRemoteConfig := item.Config
|
||||
if newRemoteConfig == nil {
|
||||
return NewSyncErrorProtocol(fmt.Errorf("received nil remote config"))
|
||||
}
|
||||
c.mgr.peerStateManager.UpdatePeerState(c.peer.Keyid, c.peer.InstanceId, func(peerState *PeerState) {
|
||||
if peerState == nil {
|
||||
return // this should not happen
|
||||
}
|
||||
peerState.Config = newRemoteConfig
|
||||
})
|
||||
peerState.Config = newRemoteConfig
|
||||
c.mgr.peerStateManager.SetPeerState(c.peer.Keyid, peerState)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -636,59 +578,3 @@ func (c *syncSessionHandlerClient) HandleListResources(ctx context.Context, stre
|
||||
c.l.Sugar().Debugf("received ListResources request from peer %q", c.peer.InstanceId)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *syncSessionHandlerClient) HandleGetLog(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionGetLog) error {
|
||||
c.l.Sugar().Debugf("received GetLog request from peer %q for log ID %q", c.peer.InstanceId, item.GetLogId())
|
||||
|
||||
reader, err := c.mgr.logStore.Open(item.GetLogId())
|
||||
if err != nil {
|
||||
c.l.Sugar().Warnf("failed to open log %q: %v", item.GetLogId(), err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read everything from the io.ReadCloser respecting context cancellation
|
||||
buf := make([]byte, 128*1024) // 128 KiB buffer, read in large chunks to reduce the number of sends.
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.l.Sugar().Debugf("context cancelled while reading log %q", item.GetLogId())
|
||||
return nil
|
||||
default:
|
||||
|
||||
// TODO: make this implementation... correct.
|
||||
n, err := reader.Read(buf)
|
||||
if n > 0 {
|
||||
stream.Send(&v1.SyncStreamItem{
|
||||
Action: &v1.SyncStreamItem_SendLogData{
|
||||
SendLogData: &v1.SyncStreamItem_SyncActionSendLogData{
|
||||
LogId: item.GetLogId(),
|
||||
Chunk: buf[:n],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) || errors.Is(err, context.Canceled) {
|
||||
c.l.Sugar().Debugf("finished reading log %q, context cancelled or stream closed", item.GetLogId())
|
||||
return nil
|
||||
}
|
||||
c.l.Sugar().Warnf("error reading log %q: %v", item.GetLogId(), err)
|
||||
return fmt.Errorf("reading log %q: %w", item.GetLogId(), err)
|
||||
}
|
||||
if n == 0 {
|
||||
c.l.Sugar().Debugf("finished reading log %q, no more data", item.GetLogId())
|
||||
// No more data to read, we can close the stream.
|
||||
stream.Send(&v1.SyncStreamItem{
|
||||
Action: &v1.SyncStreamItem_SendLogData{
|
||||
SendLogData: &v1.SyncStreamItem_SyncActionSendLogData{
|
||||
LogId: item.GetLogId(),
|
||||
Chunk: nil, // Send an empty chunk to signal the end of the log.
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ package syncapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
v1 "github.com/garethgeorge/backrest/gen/go/v1"
|
||||
"github.com/garethgeorge/backrest/internal/cryptoutil"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var maxSignatureAge = 5 * time.Minute
|
||||
@@ -18,18 +20,41 @@ func runSync(
|
||||
localKey *cryptoutil.PrivateKey,
|
||||
commandStream *bidiSyncCommandStream,
|
||||
handler syncSessionHandler,
|
||||
knownPeers []*v1.Multihost_Peer, // could be known hosts or authorized clients, doesn't matter. This is used to verify the handshake packet, authorization comes later.
|
||||
) error {
|
||||
peer := PeerFromContext(ctx)
|
||||
peerPublicKey := PeerPublicKeyFromContext(ctx)
|
||||
if peer == nil || peerPublicKey == nil {
|
||||
return NewSyncErrorAuth(fmt.Errorf("peer not found in context, ensure authentication middleware is applied before sync handlers"))
|
||||
// send the initial handshake packet to the peer to establish the connection.
|
||||
go func() {
|
||||
handshakePacket, err := createHandshakePacket(localInstanceID, localKey)
|
||||
if err != nil {
|
||||
commandStream.SendErrorAndTerminate(fmt.Errorf("creating handshake packet: %w", err))
|
||||
return
|
||||
}
|
||||
commandStream.Send(handshakePacket)
|
||||
}()
|
||||
|
||||
// Wait for the handshake packet to be acknowledged by the peer.
|
||||
handshake := commandStream.ReceiveWithinDuration(15 * time.Second)
|
||||
if handshake == nil {
|
||||
return NewSyncErrorAuth(fmt.Errorf("no handshake packet received from peer within timeout"))
|
||||
}
|
||||
if _, err := verifyHandshakePacket(handshake); err != nil {
|
||||
return NewSyncErrorAuth(fmt.Errorf("verifying handshake packet: %w", err))
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := handler.OnConnectionClosed(ctx, commandStream); err != nil {
|
||||
zap.L().Error("error handling connection closed", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
// Find the peer definition in the known peers list.
|
||||
var peer *v1.Multihost_Peer
|
||||
peerIdx := slices.IndexFunc(knownPeers, func(p *v1.Multihost_Peer) bool {
|
||||
return p.Keyid == handshake.GetHandshake().GetPublicKey().GetKeyid()
|
||||
})
|
||||
if peerIdx >= 0 {
|
||||
peer = knownPeers[peerIdx]
|
||||
} else {
|
||||
return NewSyncErrorAuth(fmt.Errorf("peer public key ID %s (instance ID %s) not found in known peers", handshake.GetHandshake().GetPublicKey().GetKeyid(), string(handshake.GetHandshake().GetInstanceId().GetPayload())))
|
||||
}
|
||||
|
||||
if err := authorizeHandshakeAsPeer(handshake, peer); err != nil {
|
||||
return NewSyncErrorAuth(fmt.Errorf("authorizing handshake as peer: %w", err))
|
||||
}
|
||||
|
||||
if err := handler.OnConnectionEstablished(ctx, commandStream, peer); err != nil {
|
||||
return err
|
||||
@@ -65,14 +90,6 @@ func runSync(
|
||||
if err := handler.HandleThrottle(ctx, commandStream, item.GetThrottle()); err != nil {
|
||||
return fmt.Errorf("handling throttle: %w", err)
|
||||
}
|
||||
case *v1.SyncStreamItem_GetLog:
|
||||
if err := handler.HandleGetLog(ctx, commandStream, item.GetGetLog()); err != nil {
|
||||
return fmt.Errorf("handling get log: %w", err)
|
||||
}
|
||||
case *v1.SyncStreamItem_SendLogData:
|
||||
if err := handler.HandleSendLogData(ctx, commandStream, item.GetSendLogData()); err != nil {
|
||||
return fmt.Errorf("handling send log data: %w", err)
|
||||
}
|
||||
default:
|
||||
return NewSyncErrorProtocol(fmt.Errorf("unknown action type %T in sync stream item", item.GetAction()))
|
||||
}
|
||||
@@ -97,6 +114,131 @@ func tryReceiveWithinDuration(ctx context.Context, receiveChan chan *v1.SyncStre
|
||||
}
|
||||
}
|
||||
|
||||
func createSignedMessage(payload []byte, identity *cryptoutil.PrivateKey) (*v1.SignedMessage, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, errors.New("payload must not be empty")
|
||||
}
|
||||
|
||||
timestampMillis := time.Now().UnixMilli()
|
||||
|
||||
payloadWithTimestamp := make([]byte, 0, len(payload)+8)
|
||||
binary.BigEndian.AppendUint64(payloadWithTimestamp, uint64(timestampMillis))
|
||||
payloadWithTimestamp = append(payloadWithTimestamp, payload...)
|
||||
|
||||
signature, err := identity.Sign(payloadWithTimestamp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing payload: %w", err)
|
||||
}
|
||||
|
||||
return &v1.SignedMessage{
|
||||
Payload: payload,
|
||||
Signature: signature,
|
||||
Keyid: identity.KeyID(),
|
||||
TimestampMillis: timestampMillis,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func verifySignedMessage(msg *v1.SignedMessage, publicKey *cryptoutil.PublicKey) error {
|
||||
if msg == nil {
|
||||
return errors.New("signed message must not be nil")
|
||||
}
|
||||
if len(msg.GetPayload()) == 0 {
|
||||
return errors.New("signed message payload must not be empty")
|
||||
}
|
||||
if len(msg.GetSignature()) == 0 {
|
||||
return errors.New("signed message signature must not be empty")
|
||||
}
|
||||
if len(msg.GetKeyid()) == 0 {
|
||||
return errors.New("signed message key ID must not be empty")
|
||||
}
|
||||
|
||||
if publicKey.KeyID() != msg.GetKeyid() {
|
||||
return fmt.Errorf("public key ID mismatch: expected %s, got %s", publicKey.KeyID(), msg.GetKeyid())
|
||||
}
|
||||
|
||||
payloadWithTimestamp := make([]byte, 0, len(msg.GetPayload())+8)
|
||||
binary.BigEndian.AppendUint64(payloadWithTimestamp, uint64(msg.GetTimestampMillis()))
|
||||
payloadWithTimestamp = append(payloadWithTimestamp, msg.GetPayload()...)
|
||||
|
||||
if err := publicKey.Verify(payloadWithTimestamp, msg.GetSignature()); err != nil {
|
||||
return fmt.Errorf("verifying signed message: %w", err)
|
||||
}
|
||||
|
||||
if time.Since(time.UnixMilli(msg.GetTimestampMillis())) > maxSignatureAge {
|
||||
return fmt.Errorf("signature is too old, max age is %s. Is the clock out of sync?", maxSignatureAge)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createHandshakePacket(instanceID string, identity *cryptoutil.PrivateKey) (*v1.SyncStreamItem, error) {
|
||||
signedMessage, err := createSignedMessage([]byte(instanceID), identity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing instance ID: %w", err)
|
||||
}
|
||||
|
||||
return &v1.SyncStreamItem{
|
||||
Action: &v1.SyncStreamItem_Handshake{
|
||||
Handshake: &v1.SyncStreamItem_SyncActionHandshake{
|
||||
ProtocolVersion: SyncProtocolVersion,
|
||||
InstanceId: signedMessage,
|
||||
PublicKey: identity.PublicKeyProto(),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// verifyHandshakePacket verifies that
|
||||
// - the signature on the instance ID is valid against the public key provided in the handshake
|
||||
// - that the public key's ID is as attested in the handshake packet e.g. matches handshake.PublicKey.Keyid
|
||||
//
|
||||
// To authenticate, the caller must then check that the public key is trusted by checking the key ID against a local list.
|
||||
func verifyHandshakePacket(item *v1.SyncStreamItem) (*cryptoutil.PublicKey, error) {
|
||||
handshake := item.GetHandshake()
|
||||
if handshake == nil {
|
||||
return nil, fmt.Errorf("empty or nil handshake, handshake packet must be sent first")
|
||||
}
|
||||
|
||||
if handshake.ProtocolVersion != SyncProtocolVersion {
|
||||
return nil, fmt.Errorf("protocol version mismatch: expected %d, got %d", SyncProtocolVersion, handshake.ProtocolVersion)
|
||||
}
|
||||
|
||||
if len(handshake.InstanceId.GetPayload()) == 0 || len(handshake.InstanceId.GetSignature()) == 0 {
|
||||
return nil, errors.New("instance ID payload and signature must not be empty")
|
||||
}
|
||||
|
||||
if len(handshake.PublicKey.Keyid) == 0 {
|
||||
return nil, errors.New("public key ID must not be empty")
|
||||
}
|
||||
|
||||
peerKey, err := cryptoutil.NewPublicKey(handshake.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading peer public key: %w", err)
|
||||
}
|
||||
|
||||
if err := verifySignedMessage(handshake.InstanceId, peerKey); err != nil {
|
||||
return nil, fmt.Errorf("verifying instance ID signature: %w", err)
|
||||
}
|
||||
|
||||
return peerKey, nil
|
||||
}
|
||||
|
||||
// authorizeHandshakeAsPeer checks that the handshake packet has the expected key ID and instance ID.
|
||||
// If this succeeds and the handshake is verified, then it is safe to assume the identity we are talking to.
|
||||
func authorizeHandshakeAsPeer(item *v1.SyncStreamItem, peer *v1.Multihost_Peer) error {
|
||||
handshake := item.GetHandshake()
|
||||
if handshake == nil {
|
||||
return fmt.Errorf("empty or nil handshake, handshake packet must be sent first")
|
||||
}
|
||||
if string(handshake.GetInstanceId().GetPayload()) != peer.InstanceId {
|
||||
return fmt.Errorf("instance ID mismatch: expected %s, got %s", peer.InstanceId, string(handshake.InstanceId.GetPayload()))
|
||||
}
|
||||
if handshake.GetPublicKey().GetKeyid() != peer.Keyid {
|
||||
return fmt.Errorf("public key ID mismatch: expected %s, got %s", peer.Keyid, handshake.PublicKey.Keyid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendHeartbeats sends a heartbeat message to the stream at regular intervals.
|
||||
// This is useful for keeping the connection alive and ensuring that the peer is still responsive.
|
||||
func sendHeartbeats(ctx context.Context, stream *bidiSyncCommandStream, interval time.Duration) {
|
||||
@@ -120,11 +262,7 @@ func sendHeartbeats(ctx context.Context, stream *bidiSyncCommandStream, interval
|
||||
// syncSessionHandler is a stateful handler for the messages within the context of a sync stream session.
|
||||
// the handler does not need to be thread safe as it is guaranteed to be called from a single thread.
|
||||
type syncSessionHandler interface {
|
||||
// OnConnectionEstablished is called when a new sync connection is established, provides the peer information once identified.
|
||||
OnConnectionEstablished(ctx context.Context, stream *bidiSyncCommandStream, peer *v1.Multihost_Peer) error
|
||||
// OnConnectionClosed is called when the sync connection is closed, e.g. when the peer disconnects. Guaranteed to be called after OnConnectionEstablished.
|
||||
OnConnectionClosed(ctx context.Context, stream *bidiSyncCommandStream) error
|
||||
// Handle* methods are called for each action type in the sync stream.
|
||||
HandleHeartbeat(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionHeartbeat) error
|
||||
HandleDiffOperations(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionDiffOperations) error
|
||||
HandleSendOperations(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionSendOperations) error
|
||||
@@ -132,18 +270,12 @@ type syncSessionHandler interface {
|
||||
HandleSetConfig(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionSetConfig) error
|
||||
HandleListResources(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionListResources) error
|
||||
HandleThrottle(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionThrottle) error
|
||||
HandleGetLog(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionGetLog) error
|
||||
HandleSendLogData(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionSendLogData) error
|
||||
}
|
||||
|
||||
type unimplementedSyncSessionHandler struct{}
|
||||
|
||||
func (h *unimplementedSyncSessionHandler) OnConnectionEstablished(ctx context.Context, stream *bidiSyncCommandStream, peer *v1.Multihost_Peer) error {
|
||||
return nil // no-op by default.
|
||||
}
|
||||
|
||||
func (h *unimplementedSyncSessionHandler) OnConnectionClosed(ctx context.Context, stream *bidiSyncCommandStream) error {
|
||||
panic("must not be unimplemented")
|
||||
return NewSyncErrorProtocol(fmt.Errorf("OnConnectionEstablished not implemented"))
|
||||
}
|
||||
|
||||
func (h *unimplementedSyncSessionHandler) HandleHeartbeat(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionHeartbeat) error {
|
||||
@@ -173,11 +305,3 @@ func (h *unimplementedSyncSessionHandler) HandleListResources(ctx context.Contex
|
||||
func (h *unimplementedSyncSessionHandler) HandleThrottle(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionThrottle) error {
|
||||
return NewSyncErrorProtocol(fmt.Errorf("HandleThrottle not implemented"))
|
||||
}
|
||||
|
||||
func (h *unimplementedSyncSessionHandler) HandleGetLog(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionGetLog) error {
|
||||
return NewSyncErrorProtocol(fmt.Errorf("HandleGetLog not implemented"))
|
||||
}
|
||||
|
||||
func (h *unimplementedSyncSessionHandler) HandleSendLogData(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionSendLogData) error {
|
||||
return NewSyncErrorProtocol(fmt.Errorf("HandleSendLogData not implemented"))
|
||||
}
|
||||
|
||||
@@ -4,10 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
@@ -26,8 +24,6 @@ const SyncProtocolVersion = 1
|
||||
type BackrestSyncHandler struct {
|
||||
v1connect.UnimplementedBackrestSyncServiceHandler
|
||||
mgr *SyncManager
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ v1connect.BackrestSyncServiceHandler = &BackrestSyncHandler{}
|
||||
@@ -46,25 +42,9 @@ func (h *BackrestSyncHandler) Sync(ctx context.Context, stream *connect.BidiStre
|
||||
return connect.NewError(connect.CodePermissionDenied, errors.New("sync server is not configured"))
|
||||
}
|
||||
|
||||
// Attach the 'Authorization' header to the sync stream as we create it.
|
||||
if authHeader, err := createAuthenticationHeader(snapshot.config.Instance, snapshot.identityKey); err != nil {
|
||||
zap.S().Errorf("failed to create authentication header: %v", err)
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("creating authentication header: %w", err))
|
||||
} else {
|
||||
stream.ResponseHeader().Set("Authorization", authHeader)
|
||||
}
|
||||
|
||||
// Setup to read from the stream and handle commands
|
||||
// Note that runSync will perform the authentication check, which in this case is asserting that a peer is present.
|
||||
// The peer must be provided in the context by the authentication middleware.
|
||||
sessionHandler := newSyncHandlerServer(h.mgr, snapshot)
|
||||
cmdStream := newBidiSyncCommandStream()
|
||||
|
||||
// Send a heartbeat packet to send the initial headers to the client and establish the connection.
|
||||
cmdStream.Send(&v1.SyncStreamItem{
|
||||
Action: &v1.SyncStreamItem_Heartbeat{},
|
||||
})
|
||||
|
||||
go func() {
|
||||
err := runSync(
|
||||
ctx,
|
||||
@@ -72,29 +52,25 @@ func (h *BackrestSyncHandler) Sync(ctx context.Context, stream *connect.BidiStre
|
||||
snapshot.identityKey,
|
||||
cmdStream,
|
||||
sessionHandler,
|
||||
snapshot.config.GetMultihost().GetAuthorizedClients(),
|
||||
)
|
||||
cmdStream.SendErrorAndTerminate(err)
|
||||
}()
|
||||
|
||||
if err := cmdStream.ConnectStream(ctx, stream); err != nil {
|
||||
if sessionHandler.peer != nil {
|
||||
zap.S().Errorf("sync handler stream error for client %q: %v", sessionHandler.peer.InstanceId, err)
|
||||
h.mgr.peerStateManager.UpdatePeerState(sessionHandler.peer.Keyid, sessionHandler.peer.InstanceId, func(peerState *PeerState) {
|
||||
peerState.LastHeartbeat = time.Now()
|
||||
peerState.ConnectionState = v1.SyncConnectionState_CONNECTION_STATE_DISCONNECTED
|
||||
peerState.ConnectionStateMessage = err.Error()
|
||||
var syncErr *SyncError
|
||||
if errors.As(err, &syncErr) {
|
||||
peerState.ConnectionState = syncErr.State
|
||||
peerState.ConnectionStateMessage = syncErr.Message.Error()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
zap.S().Errorf("sync handler stream error for unestablished session: %v", err)
|
||||
}
|
||||
|
||||
zap.S().Errorf("sync handler stream error: %v", err)
|
||||
var syncErr *SyncError
|
||||
if errors.As(err, &syncErr) {
|
||||
if sessionHandler.peer != nil {
|
||||
peerState := h.mgr.peerStateManager.GetPeerState(sessionHandler.peer.Keyid).Clone()
|
||||
if peerState == nil {
|
||||
peerState = newPeerState(sessionHandler.peer.InstanceId, sessionHandler.peer.Keyid)
|
||||
}
|
||||
peerState.ConnectionState = syncErr.State
|
||||
peerState.ConnectionStateMessage = syncErr.Message.Error()
|
||||
peerState.LastHeartbeat = time.Now()
|
||||
h.mgr.peerStateManager.SetPeerState(sessionHandler.peer.Keyid, peerState)
|
||||
}
|
||||
switch syncErr.State {
|
||||
case v1.SyncConnectionState_CONNECTION_STATE_ERROR_AUTH:
|
||||
return connect.NewError(connect.CodePermissionDenied, syncErr.Message)
|
||||
@@ -104,19 +80,12 @@ func (h *BackrestSyncHandler) Sync(ctx context.Context, stream *connect.BidiStre
|
||||
return connect.NewError(connect.CodeInternal, syncErr.Message)
|
||||
}
|
||||
}
|
||||
return connect.NewError(connect.CodeInternal, err)
|
||||
} else {
|
||||
h.mgr.peerStateManager.UpdatePeerState(sessionHandler.peer.Keyid, sessionHandler.peer.InstanceId, func(peerState *PeerState) {
|
||||
peerState.LastHeartbeat = time.Now()
|
||||
peerState.ConnectionState = v1.SyncConnectionState_CONNECTION_STATE_DISCONNECTED
|
||||
peerState.ConnectionStateMessage = "disconnected"
|
||||
})
|
||||
zap.S().Infof("sync handler stream closed for client %q", sessionHandler.peer.InstanceId)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncSessionHandlerServer is a syncSessionHandler implementation for servers.
|
||||
type syncSessionHandlerServer struct {
|
||||
unimplementedSyncSessionHandler
|
||||
|
||||
@@ -129,9 +98,6 @@ type syncSessionHandlerServer struct {
|
||||
opIDLru *lru.Cache[int64, int64] // original ID -> local ID
|
||||
flowIDLru *lru.Cache[int64, int64] // original flow ID -> local flow ID
|
||||
|
||||
requestedLogStreams map[string]struct{}
|
||||
activeLogStreams map[string]io.WriteCloser
|
||||
|
||||
configWatchCh chan struct{} // Channel for configuration updates
|
||||
}
|
||||
|
||||
@@ -144,24 +110,12 @@ func newSyncHandlerServer(mgr *SyncManager, snapshot *syncConfigSnapshot) *syncS
|
||||
snapshot: *snapshot,
|
||||
opIDLru: opIDLru,
|
||||
flowIDLru: flowIDLru,
|
||||
|
||||
requestedLogStreams: make(map[string]struct{}),
|
||||
activeLogStreams: make(map[string]io.WriteCloser),
|
||||
}
|
||||
}
|
||||
|
||||
var _ syncSessionHandler = (*syncSessionHandlerServer)(nil)
|
||||
|
||||
func (h *syncSessionHandlerServer) OnConnectionEstablished(ctx context.Context, stream *bidiSyncCommandStream, peer *v1.Multihost_Peer) error {
|
||||
// Check if the peer is already connected, and then store the connection.
|
||||
h.mgr.mu.Lock()
|
||||
if _, exists := h.mgr.sessionHandlerMap[peer.Keyid]; exists {
|
||||
h.mgr.mu.Unlock()
|
||||
return NewSyncErrorAuth(fmt.Errorf("client %q is already connected", peer.InstanceId))
|
||||
}
|
||||
h.mgr.sessionHandlerMap[peer.Keyid] = h
|
||||
h.mgr.mu.Unlock()
|
||||
|
||||
// Verify that the peer is in our authorized clients list
|
||||
authorizedClientPeerIdx := slices.IndexFunc(h.snapshot.config.Multihost.GetAuthorizedClients(), func(p *v1.Multihost_Peer) bool {
|
||||
return p.InstanceId == peer.InstanceId && p.Keyid == peer.Keyid
|
||||
@@ -185,11 +139,11 @@ func (h *syncSessionHandlerServer) OnConnectionEstablished(ctx context.Context,
|
||||
}
|
||||
|
||||
// Configure the state for the connected peer.
|
||||
h.mgr.peerStateManager.UpdatePeerState(h.peer.Keyid, peer.InstanceId, func(peerState *PeerState) {
|
||||
peerState.ConnectionStateMessage = "connected"
|
||||
peerState.ConnectionState = v1.SyncConnectionState_CONNECTION_STATE_CONNECTED
|
||||
peerState.LastHeartbeat = time.Now()
|
||||
})
|
||||
peerState := newPeerState(peer.InstanceId, h.peer.Keyid)
|
||||
peerState.ConnectionStateMessage = "connected"
|
||||
peerState.ConnectionState = v1.SyncConnectionState_CONNECTION_STATE_CONNECTED
|
||||
peerState.LastHeartbeat = time.Now()
|
||||
h.mgr.peerStateManager.SetPeerState(h.peer.Keyid, peerState)
|
||||
|
||||
zap.S().Infof("syncserver accepted a connection from client instance ID %q", h.peer.InstanceId)
|
||||
|
||||
@@ -220,38 +174,16 @@ func (h *syncSessionHandlerServer) OnConnectionEstablished(ctx context.Context,
|
||||
}()
|
||||
|
||||
// Send initial configuration to client
|
||||
if err := h.sendConfigToClient(stream, h.snapshot.config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *syncSessionHandlerServer) OnConnectionClosed(ctx context.Context, stream *bidiSyncCommandStream) error {
|
||||
if h.peer != nil {
|
||||
zap.S().Infof("syncserver connection closed for client %q", h.peer.InstanceId)
|
||||
h.mgr.mu.Lock()
|
||||
delete(h.mgr.sessionHandlerMap, h.peer.Keyid)
|
||||
h.mgr.mu.Unlock()
|
||||
}
|
||||
|
||||
// Close any active resources e.g. sinks for active log streams.
|
||||
for logID, logSink := range h.activeLogStreams {
|
||||
if err := logSink.Close(); err != nil {
|
||||
return fmt.Errorf("action SendLogData: closing log stream %q: %w", logID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return h.sendConfigToClient(stream, h.snapshot.config)
|
||||
}
|
||||
|
||||
func (h *syncSessionHandlerServer) HandleHeartbeat(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionHeartbeat) error {
|
||||
h.mgr.peerStateManager.UpdatePeerState(h.peer.Keyid, h.peer.InstanceId, func(peerState *PeerState) {
|
||||
if peerState == nil {
|
||||
return // this should not happen
|
||||
}
|
||||
peerState.LastHeartbeat = time.Now()
|
||||
})
|
||||
peerState := h.mgr.peerStateManager.GetPeerState(h.peer.Keyid).Clone()
|
||||
if peerState == nil {
|
||||
return NewSyncErrorInternal(fmt.Errorf("peer state for %q not found", h.peer.Keyid))
|
||||
}
|
||||
peerState.LastHeartbeat = time.Now()
|
||||
h.mgr.peerStateManager.SetPeerState(h.peer.Keyid, peerState)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -369,7 +301,7 @@ func (h *syncSessionHandlerServer) HandleDiffOperations(ctx context.Context, str
|
||||
func (h *syncSessionHandlerServer) HandleSendOperations(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionSendOperations) error {
|
||||
switch event := item.GetEvent().Event.(type) {
|
||||
case *v1.OperationEvent_CreatedOperations:
|
||||
zap.L().Debug("syncserver received create operations", zap.Any("operations", event.CreatedOperations.GetOperations()))
|
||||
zap.L().Debug("syncserver received created operations", zap.Any("operations", event.CreatedOperations.GetOperations()))
|
||||
for _, op := range event.CreatedOperations.GetOperations() {
|
||||
if err := h.insertOrUpdate(op); err != nil {
|
||||
return fmt.Errorf("action SendOperations: operation event create %+v: %w", op, err)
|
||||
@@ -397,79 +329,32 @@ func (h *syncSessionHandlerServer) HandleSendOperations(ctx context.Context, str
|
||||
}
|
||||
|
||||
func (h *syncSessionHandlerServer) HandleSendConfig(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionSendConfig) error {
|
||||
h.mgr.peerStateManager.UpdatePeerState(h.peer.Keyid, h.peer.InstanceId, func(peerState *PeerState) {
|
||||
if peerState == nil {
|
||||
return // this should not happen
|
||||
}
|
||||
peerState.Config = item.GetConfig()
|
||||
})
|
||||
peerState := h.mgr.peerStateManager.GetPeerState(h.peer.Keyid).Clone()
|
||||
if peerState == nil {
|
||||
return NewSyncErrorInternal(fmt.Errorf("peer state for %q not found", h.peer.Keyid))
|
||||
}
|
||||
peerState.Config = item.GetConfig()
|
||||
h.mgr.peerStateManager.SetPeerState(h.peer.Keyid, peerState)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *syncSessionHandlerServer) HandleListResources(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionListResources) error {
|
||||
zap.L().Debug("syncserver received resource list from client", zap.String("client_instance_id", h.peer.InstanceId),
|
||||
zap.Any("repos", item.GetRepos()),
|
||||
zap.Any("plans", item.GetPlans()))
|
||||
h.mgr.peerStateManager.UpdatePeerState(h.peer.Keyid, h.peer.InstanceId, func(peerState *PeerState) {
|
||||
if peerState == nil {
|
||||
return // this should not happen
|
||||
}
|
||||
|
||||
repos := item.GetRepos()
|
||||
plans := item.GetPlans()
|
||||
for _, repo := range repos {
|
||||
peerState.KnownRepos[repo.Id] = repo
|
||||
}
|
||||
for _, plan := range plans {
|
||||
peerState.KnownPlans[plan.Id] = plan
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *syncSessionHandlerServer) HandleSendLogData(ctx context.Context, stream *bidiSyncCommandStream, item *v1.SyncStreamItem_SyncActionSendLogData) error {
|
||||
h.mgr.mu.Lock()
|
||||
defer h.mgr.mu.Unlock()
|
||||
|
||||
logID := item.GetLogId()
|
||||
if logID == "" {
|
||||
return NewSyncErrorProtocol(errors.New("action SendLogData: log ID is required"))
|
||||
zap.Any("repos", item.GetRepoIds()),
|
||||
zap.Any("plans", item.GetPlanIds()))
|
||||
peerState := h.mgr.peerStateManager.GetPeerState(h.peer.Keyid).Clone()
|
||||
if peerState == nil {
|
||||
return NewSyncErrorInternal(fmt.Errorf("peer state for %q not found", h.peer.Keyid))
|
||||
}
|
||||
if _, exists := h.requestedLogStreams[logID]; !exists {
|
||||
return NewSyncErrorProtocol(fmt.Errorf("action SendLogData: log ID %q was not requested", logID))
|
||||
repos := item.GetRepoIds()
|
||||
plans := item.GetPlanIds()
|
||||
for _, repoID := range repos {
|
||||
peerState.KnownRepos[repoID] = struct{}{}
|
||||
}
|
||||
|
||||
var logSink io.WriteCloser
|
||||
if s, exists := h.activeLogStreams[logID]; !exists {
|
||||
// Check if there are too many active log streams
|
||||
if len(h.activeLogStreams) >= 16 {
|
||||
return NewSyncErrorProtocol(fmt.Errorf("action SendLogData: too many active log streams, limit is 16"))
|
||||
}
|
||||
|
||||
// If the log stream is not active, we need to create a new one.
|
||||
f, err := h.mgr.logStore.Create(logID, 0, 24*3600) // 24 hour retention, will just be re-requested next time it's wanted.
|
||||
if err != nil {
|
||||
return fmt.Errorf("action SendLogData: creating log stream %q: %w", logID, err)
|
||||
}
|
||||
logSink = f
|
||||
h.activeLogStreams[logID] = logSink
|
||||
} else {
|
||||
logSink = s
|
||||
for _, planID := range plans {
|
||||
peerState.KnownPlans[planID] = struct{}{}
|
||||
}
|
||||
|
||||
if len(item.GetChunk()) == 0 {
|
||||
delete(h.activeLogStreams, logID)
|
||||
delete(h.requestedLogStreams, logID)
|
||||
if err := logSink.Close(); err != nil {
|
||||
return fmt.Errorf("action SendLogData: closing log stream %q: %w", logID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := logSink.Write(item.GetChunk()); err != nil {
|
||||
return fmt.Errorf("action SendLogData: writing to log stream %q: %w", logID, err)
|
||||
}
|
||||
|
||||
h.mgr.peerStateManager.SetPeerState(h.peer.Keyid, peerState)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -545,14 +430,14 @@ func (h *syncSessionHandlerServer) sendConfigToClient(stream *bidiSyncCommandStr
|
||||
for _, repo := range config.Repos {
|
||||
if h.permissions.CheckPermissionForRepo(repo.Id, v1.Multihost_Permission_PERMISSION_READ_CONFIG) {
|
||||
remoteConfig.Repos = append(remoteConfig.Repos, repo)
|
||||
resourceListMsg.Repos = append(resourceListMsg.Repos, &v1.SyncRepoMetadata{Id: repo.Id, Guid: repo.Guid})
|
||||
resourceListMsg.RepoIds = append(resourceListMsg.RepoIds, repo.Id)
|
||||
allowedRepoIDs = append(allowedRepoIDs, repo.Id)
|
||||
}
|
||||
}
|
||||
for _, plan := range config.Plans {
|
||||
if h.permissions.CheckPermissionForPlan(plan.Id, v1.Multihost_Permission_PERMISSION_READ_CONFIG) {
|
||||
remoteConfig.Plans = append(remoteConfig.Plans, plan)
|
||||
resourceListMsg.Plans = append(resourceListMsg.Plans, &v1.SyncPlanMetadata{Id: plan.Id})
|
||||
resourceListMsg.PlanIds = append(resourceListMsg.PlanIds, plan.Id)
|
||||
allowedPlanIDs = append(allowedPlanIDs, plan.Id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
v1 "github.com/garethgeorge/backrest/gen/go/v1"
|
||||
"github.com/garethgeorge/backrest/internal/config"
|
||||
"github.com/garethgeorge/backrest/internal/cryptoutil"
|
||||
"github.com/garethgeorge/backrest/internal/logstore"
|
||||
"github.com/garethgeorge/backrest/internal/oplog"
|
||||
"github.com/garethgeorge/backrest/internal/orchestrator"
|
||||
"go.uber.org/zap"
|
||||
@@ -21,7 +20,6 @@ type SyncManager struct {
|
||||
configMgr *config.ConfigManager
|
||||
orchestrator *orchestrator.Orchestrator
|
||||
oplog *oplog.OpLog
|
||||
logStore *logstore.LogStore
|
||||
|
||||
// mutable properties
|
||||
mu sync.Mutex
|
||||
@@ -30,27 +28,32 @@ type SyncManager struct {
|
||||
|
||||
syncClientRetryDelay time.Duration // the default retry delay for sync clients, protected by mu
|
||||
|
||||
syncClients map[string]*SyncClient // current sync clients, protected by mu
|
||||
sessionHandlerMap map[string]*syncSessionHandlerServer // handlers for sync sessions keyed by peer KeyID, protected by mu
|
||||
syncClients map[string]*SyncClient // current sync clients, protected by mu
|
||||
|
||||
peerStateManager PeerStateManager
|
||||
}
|
||||
|
||||
func NewSyncManager(configMgr *config.ConfigManager, oplog *oplog.OpLog, logStore *logstore.LogStore, orchestrator *orchestrator.Orchestrator, peerStateManager PeerStateManager) *SyncManager {
|
||||
func NewSyncManager(configMgr *config.ConfigManager, oplog *oplog.OpLog, orchestrator *orchestrator.Orchestrator, peerStateManager PeerStateManager) *SyncManager {
|
||||
// Fetch the config, and mark all sync clients and known hosts as disconnected (but preserve other fields).
|
||||
config, err := configMgr.Get()
|
||||
if err == nil {
|
||||
for _, knownHostPeer := range config.GetMultihost().GetKnownHosts() {
|
||||
peerStateManager.UpdatePeerState(knownHostPeer.Keyid, knownHostPeer.InstanceId, func(state *PeerState) {
|
||||
state.ConnectionState = v1.SyncConnectionState_CONNECTION_STATE_DISCONNECTED
|
||||
state.ConnectionStateMessage = "disconnected"
|
||||
})
|
||||
state := peerStateManager.GetPeerState(knownHostPeer.Keyid).Clone()
|
||||
if state == nil {
|
||||
state = newPeerState(knownHostPeer.InstanceId, knownHostPeer.Keyid)
|
||||
}
|
||||
state.ConnectionState = v1.SyncConnectionState_CONNECTION_STATE_DISCONNECTED
|
||||
state.ConnectionStateMessage = "disconnected"
|
||||
peerStateManager.SetPeerState(knownHostPeer.Keyid, state)
|
||||
}
|
||||
for _, authorizedClient := range config.GetMultihost().GetAuthorizedClients() {
|
||||
peerStateManager.UpdatePeerState(authorizedClient.Keyid, authorizedClient.InstanceId, func(state *PeerState) {
|
||||
state.ConnectionState = v1.SyncConnectionState_CONNECTION_STATE_DISCONNECTED
|
||||
state.ConnectionStateMessage = "disconnected"
|
||||
})
|
||||
state := peerStateManager.GetPeerState(authorizedClient.Keyid).Clone()
|
||||
if state == nil {
|
||||
state = newPeerState(authorizedClient.InstanceId, authorizedClient.Keyid)
|
||||
}
|
||||
state.ConnectionState = v1.SyncConnectionState_CONNECTION_STATE_DISCONNECTED
|
||||
state.ConnectionStateMessage = "disconnected"
|
||||
peerStateManager.SetPeerState(authorizedClient.Keyid, state)
|
||||
}
|
||||
} else {
|
||||
zap.S().Errorf("syncmanager failed to get initial config: %v", err)
|
||||
@@ -59,11 +62,9 @@ func NewSyncManager(configMgr *config.ConfigManager, oplog *oplog.OpLog, logStor
|
||||
configMgr: configMgr,
|
||||
orchestrator: orchestrator,
|
||||
oplog: oplog,
|
||||
logStore: logStore,
|
||||
|
||||
syncClientRetryDelay: 60 * time.Second,
|
||||
syncClients: make(map[string]*SyncClient),
|
||||
sessionHandlerMap: make(map[string]*syncSessionHandlerServer),
|
||||
|
||||
peerStateManager: peerStateManager,
|
||||
}
|
||||
@@ -151,7 +152,6 @@ func (m *SyncManager) RunSync(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
zap.S().Debugf("syncmanager context canceled for instance %q, stopping sync", m.snapshot.config.GetInstance())
|
||||
return
|
||||
case <-configWatchCh:
|
||||
runSyncWithNewConfig()
|
||||
|
||||
@@ -42,10 +42,10 @@ func NewSqliteDbForKvStore(db string) (*sql.DB, error) {
|
||||
return dbpool, nil
|
||||
}
|
||||
|
||||
func NewInMemorySqliteDbForKvStore(t testing.TB) (*sql.DB, error) {
|
||||
func NewInMemorySqliteDbForKvStore(t testing.TB) *sql.DB {
|
||||
dbpool, err := sql.Open("sqlite3", memdb.TestDB(t))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite pool: %v", err)
|
||||
t.Fatalf("failed to open db: %v", err)
|
||||
}
|
||||
return dbpool, nil
|
||||
return dbpool
|
||||
}
|
||||
|
||||
@@ -270,23 +270,23 @@ func (ls *LogStore) SelectAll(f func(id string, parentID int64)) error {
|
||||
|
||||
// Find logs owned by a specific operation ID.
|
||||
func (ls *LogStore) FindLogsWithParent(parentOpID int64) ([]string, error) {
|
||||
conn, err := ls.dbpool.Take(context.Background())
|
||||
rows, err := ls.dbpool.QueryContext(context.Background(), "SELECT id FROM logs WHERE owner_opid = ?", parentOpID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("take connection: %v", err)
|
||||
}
|
||||
defer ls.dbpool.Put(conn)
|
||||
|
||||
var logs []string
|
||||
if err := sqlitex.Execute(conn, "SELECT id FROM logs WHERE owner_opid = ?", &sqlitex.ExecOptions{
|
||||
Args: []any{parentOpID},
|
||||
ResultFunc: func(stmt *sqlite.Stmt) error {
|
||||
logs = append(logs, stmt.ColumnText(0))
|
||||
return nil
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("select logs: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("scan log id: %v", err)
|
||||
}
|
||||
logs = append(logs, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate logs: %v", err)
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user