mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-05-04 20:10:36 +00:00
425 lines
11 KiB
Go
425 lines
11 KiB
Go
package syncapi
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
v1 "github.com/garethgeorge/backrest/gen/go/v1"
|
|
"github.com/garethgeorge/backrest/gen/go/v1sync"
|
|
"github.com/garethgeorge/backrest/internal/config/migrations"
|
|
"github.com/garethgeorge/backrest/internal/cryptoutil"
|
|
"github.com/garethgeorge/backrest/internal/testutil"
|
|
)
|
|
|
|
func TestValidatePairingSecret(t *testing.T) {
|
|
now := time.Unix(1000, 0)
|
|
|
|
tokens := []*v1.Multihost_PairingToken{
|
|
{
|
|
Secret: "valid-secret",
|
|
Label: "test-token",
|
|
CreatedAtUnix: 900,
|
|
ExpiresAtUnix: 2000,
|
|
MaxUses: 3,
|
|
Uses: 1,
|
|
},
|
|
{
|
|
Secret: "unlimited-token",
|
|
Label: "unlimited",
|
|
CreatedAtUnix: 900,
|
|
ExpiresAtUnix: 0, // no expiry
|
|
MaxUses: 0, // unlimited uses
|
|
Uses: 100,
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
secret string
|
|
tokens []*v1.Multihost_PairingToken
|
|
now time.Time
|
|
wantLabel string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "valid secret",
|
|
secret: "valid-secret",
|
|
tokens: tokens,
|
|
now: now,
|
|
wantLabel: "test-token",
|
|
},
|
|
{
|
|
name: "unlimited token",
|
|
secret: "unlimited-token",
|
|
tokens: tokens,
|
|
now: now,
|
|
wantLabel: "unlimited",
|
|
},
|
|
{
|
|
name: "empty secret",
|
|
secret: "",
|
|
tokens: tokens,
|
|
now: now,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "wrong secret",
|
|
secret: "wrong-secret",
|
|
tokens: tokens,
|
|
now: now,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "expired token",
|
|
secret: "valid-secret",
|
|
tokens: []*v1.Multihost_PairingToken{
|
|
{
|
|
Secret: "valid-secret",
|
|
Label: "expired",
|
|
ExpiresAtUnix: 500,
|
|
MaxUses: 0,
|
|
},
|
|
},
|
|
now: now,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "max uses reached",
|
|
secret: "valid-secret",
|
|
tokens: []*v1.Multihost_PairingToken{
|
|
{
|
|
Secret: "valid-secret",
|
|
Label: "exhausted",
|
|
MaxUses: 2,
|
|
Uses: 2,
|
|
},
|
|
},
|
|
now: now,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "nil tokens",
|
|
secret: "anything",
|
|
tokens: nil,
|
|
now: now,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
token, err := ValidatePairingSecret(tc.secret, tc.tokens, tc.now)
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if token.Label != tc.wantLabel {
|
|
t.Errorf("label = %q, want %q", token.Label, tc.wantLabel)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPairingTokenFlow(t *testing.T) {
|
|
testutil.InstallZapLogger(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
peerHostAddr := testutil.AllocOpenBindAddr(t)
|
|
peerClientAddr := testutil.AllocOpenBindAddr(t)
|
|
|
|
pairingSecret, err := cryptoutil.GeneratePairingSecret()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate pairing secret: %v", err)
|
|
}
|
|
|
|
// Host has a pairing token but NO authorized clients yet.
|
|
peerHostConfig := &v1.Config{
|
|
Version: migrations.CurrentVersion,
|
|
Instance: defaultHostID,
|
|
Repos: []*v1.Repo{},
|
|
Multihost: &v1.Multihost{
|
|
Identity: identity1,
|
|
AuthorizedClients: []*v1.Multihost_Peer{}, // empty — client not pre-authorized
|
|
PairingTokens: []*v1.Multihost_PairingToken{
|
|
{
|
|
Secret: pairingSecret,
|
|
Label: "test-pairing",
|
|
CreatedAtUnix: time.Now().Unix(),
|
|
ExpiresAtUnix: time.Now().Add(1 * time.Hour).Unix(),
|
|
MaxUses: 1,
|
|
Uses: 0,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Client knows about the host and has the pairing secret.
|
|
peerClientConfig := &v1.Config{
|
|
Version: migrations.CurrentVersion,
|
|
Instance: defaultClientID,
|
|
Repos: []*v1.Repo{},
|
|
Multihost: &v1.Multihost{
|
|
Identity: identity2,
|
|
KnownHosts: []*v1.Multihost_Peer{
|
|
{
|
|
Keyid: identity1.Keyid,
|
|
InstanceId: defaultHostID,
|
|
InstanceUrl: fmt.Sprintf("http://%s", peerHostAddr),
|
|
InitialPairingSecret: pairingSecret,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
peerHost := newPeerUnderTest(t, peerHostConfig)
|
|
peerClient := newPeerUnderTest(t, peerClientConfig)
|
|
|
|
startRunningSyncAPI(t, peerHost, peerHostAddr)
|
|
startRunningSyncAPI(t, peerClient, peerClientAddr)
|
|
|
|
// The client should successfully connect via the pairing token.
|
|
tryConnect(t, ctx, peerClient, peerClientConfig.Multihost.KnownHosts[0])
|
|
|
|
// The server pairs the client during its own runSync handshake handling, which
|
|
// runs concurrently with — and may finish slightly after — the client reaching
|
|
// CONNECTED state. Poll for the host config to reflect the pairing.
|
|
var hostConfig *v1.Config
|
|
testutil.Try(t, ctx, func() error {
|
|
var err error
|
|
hostConfig, err = peerHost.configMgr.Get()
|
|
if err != nil {
|
|
return fmt.Errorf("get host config: %w", err)
|
|
}
|
|
if len(hostConfig.Multihost.AuthorizedClients) != 1 {
|
|
return fmt.Errorf("expected 1 authorized client, got %d", len(hostConfig.Multihost.AuthorizedClients))
|
|
}
|
|
return nil
|
|
})
|
|
|
|
ac := hostConfig.Multihost.AuthorizedClients[0]
|
|
if ac.Keyid != identity2.Keyid {
|
|
t.Errorf("authorized client keyid = %q, want %q", ac.Keyid, identity2.Keyid)
|
|
}
|
|
if ac.InstanceId != defaultClientID {
|
|
t.Errorf("authorized client instance id = %q, want %q", ac.InstanceId, defaultClientID)
|
|
}
|
|
|
|
// Verify the pairing token was consumed (max_uses=1, so it should be removed).
|
|
if len(hostConfig.Multihost.PairingTokens) != 0 {
|
|
t.Errorf("expected 0 pairing tokens after consumption, got %d", len(hostConfig.Multihost.PairingTokens))
|
|
}
|
|
}
|
|
|
|
func TestPairingTokenExpiredRejected(t *testing.T) {
|
|
testutil.InstallZapLogger(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
peerHostAddr := testutil.AllocOpenBindAddr(t)
|
|
peerClientAddr := testutil.AllocOpenBindAddr(t)
|
|
|
|
pairingSecret, _ := cryptoutil.GeneratePairingSecret()
|
|
|
|
// Host has an expired pairing token.
|
|
peerHostConfig := &v1.Config{
|
|
Version: migrations.CurrentVersion,
|
|
Instance: defaultHostID,
|
|
Repos: []*v1.Repo{},
|
|
Multihost: &v1.Multihost{
|
|
Identity: identity1,
|
|
AuthorizedClients: []*v1.Multihost_Peer{},
|
|
PairingTokens: []*v1.Multihost_PairingToken{
|
|
{
|
|
Secret: pairingSecret,
|
|
Label: "expired-token",
|
|
CreatedAtUnix: time.Now().Add(-2 * time.Hour).Unix(),
|
|
ExpiresAtUnix: time.Now().Add(-1 * time.Hour).Unix(), // expired 1 hour ago
|
|
MaxUses: 0,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
peerClientConfig := &v1.Config{
|
|
Version: migrations.CurrentVersion,
|
|
Instance: defaultClientID,
|
|
Repos: []*v1.Repo{},
|
|
Multihost: &v1.Multihost{
|
|
Identity: identity2,
|
|
KnownHosts: []*v1.Multihost_Peer{
|
|
{
|
|
Keyid: identity1.Keyid,
|
|
InstanceId: defaultHostID,
|
|
InstanceUrl: fmt.Sprintf("http://%s", peerHostAddr),
|
|
InitialPairingSecret: pairingSecret,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
peerHost := newPeerUnderTest(t, peerHostConfig)
|
|
peerClient := newPeerUnderTest(t, peerClientConfig)
|
|
|
|
startRunningSyncAPI(t, peerHost, peerHostAddr)
|
|
startRunningSyncAPI(t, peerClient, peerClientAddr)
|
|
|
|
// Connection should fail with auth error.
|
|
waitForConnectionState(t, ctx, peerClient, peerClientConfig.Multihost.KnownHosts[0], v1sync.ConnectionState_CONNECTION_STATE_ERROR_AUTH)
|
|
|
|
// Host should still have no authorized clients.
|
|
hostConfig, _ := peerHost.configMgr.Get()
|
|
if len(hostConfig.Multihost.AuthorizedClients) != 0 {
|
|
t.Errorf("expected 0 authorized clients, got %d", len(hostConfig.Multihost.AuthorizedClients))
|
|
}
|
|
}
|
|
|
|
func TestPairingTokenMaxUsesEnforced(t *testing.T) {
|
|
testutil.InstallZapLogger(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
peerHostAddr := testutil.AllocOpenBindAddr(t)
|
|
|
|
pairingSecret, _ := cryptoutil.GeneratePairingSecret()
|
|
|
|
identity3, _ := cryptoutil.GeneratePrivateKey()
|
|
|
|
// Host has a pairing token with max_uses=1.
|
|
peerHostConfig := &v1.Config{
|
|
Version: migrations.CurrentVersion,
|
|
Instance: defaultHostID,
|
|
Repos: []*v1.Repo{},
|
|
Multihost: &v1.Multihost{
|
|
Identity: identity1,
|
|
AuthorizedClients: []*v1.Multihost_Peer{},
|
|
PairingTokens: []*v1.Multihost_PairingToken{
|
|
{
|
|
Secret: pairingSecret,
|
|
Label: "single-use",
|
|
CreatedAtUnix: time.Now().Unix(),
|
|
ExpiresAtUnix: time.Now().Add(1 * time.Hour).Unix(),
|
|
MaxUses: 1,
|
|
Uses: 0,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// First client pairs successfully.
|
|
peerClient1Config := &v1.Config{
|
|
Version: migrations.CurrentVersion,
|
|
Instance: "client-1",
|
|
Repos: []*v1.Repo{},
|
|
Multihost: &v1.Multihost{
|
|
Identity: identity2,
|
|
KnownHosts: []*v1.Multihost_Peer{
|
|
{
|
|
Keyid: identity1.Keyid,
|
|
InstanceId: defaultHostID,
|
|
InstanceUrl: fmt.Sprintf("http://%s", peerHostAddr),
|
|
InitialPairingSecret: pairingSecret,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
peerHost := newPeerUnderTest(t, peerHostConfig)
|
|
peerClient1 := newPeerUnderTest(t, peerClient1Config)
|
|
|
|
var wg sync.WaitGroup
|
|
syncCtx, cancelSync := context.WithCancel(ctx)
|
|
|
|
wg.Add(2)
|
|
go func() { defer wg.Done(); runSyncAPIWithCtx(syncCtx, peerHost, peerHostAddr) }()
|
|
go func() {
|
|
defer wg.Done()
|
|
peerClient1Addr := testutil.AllocOpenBindAddr(t)
|
|
runSyncAPIWithCtx(syncCtx, peerClient1, peerClient1Addr)
|
|
}()
|
|
|
|
tryConnect(t, ctx, peerClient1, peerClient1Config.Multihost.KnownHosts[0])
|
|
|
|
// Stop first client, start second client with same pairing secret.
|
|
cancelSync()
|
|
wg.Wait()
|
|
|
|
peerClient2Config := &v1.Config{
|
|
Version: migrations.CurrentVersion,
|
|
Instance: "client-2",
|
|
Repos: []*v1.Repo{},
|
|
Multihost: &v1.Multihost{
|
|
Identity: identity3,
|
|
KnownHosts: []*v1.Multihost_Peer{
|
|
{
|
|
Keyid: identity1.Keyid,
|
|
InstanceId: defaultHostID,
|
|
InstanceUrl: fmt.Sprintf("http://%s", peerHostAddr),
|
|
InitialPairingSecret: pairingSecret,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
peerClient2 := newPeerUnderTest(t, peerClient2Config)
|
|
|
|
startRunningSyncAPI(t, peerHost, peerHostAddr)
|
|
startRunningSyncAPI(t, peerClient2, testutil.AllocOpenBindAddr(t))
|
|
|
|
// Second client should fail — token is consumed.
|
|
waitForConnectionState(t, ctx, peerClient2, peerClient2Config.Multihost.KnownHosts[0], v1sync.ConnectionState_CONNECTION_STATE_ERROR_AUTH)
|
|
}
|
|
|
|
func TestNoPairingSecretRejected(t *testing.T) {
|
|
testutil.InstallZapLogger(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
peerHostAddr := testutil.AllocOpenBindAddr(t)
|
|
peerClientAddr := testutil.AllocOpenBindAddr(t)
|
|
|
|
// Host has NO pairing tokens and NO authorized clients.
|
|
peerHostConfig := &v1.Config{
|
|
Version: migrations.CurrentVersion,
|
|
Instance: defaultHostID,
|
|
Repos: []*v1.Repo{},
|
|
Multihost: &v1.Multihost{
|
|
Identity: identity1,
|
|
AuthorizedClients: []*v1.Multihost_Peer{},
|
|
},
|
|
}
|
|
|
|
// Client tries to connect without any pairing secret.
|
|
peerClientConfig := &v1.Config{
|
|
Version: migrations.CurrentVersion,
|
|
Instance: defaultClientID,
|
|
Repos: []*v1.Repo{},
|
|
Multihost: &v1.Multihost{
|
|
Identity: identity2,
|
|
KnownHosts: []*v1.Multihost_Peer{
|
|
{
|
|
Keyid: identity1.Keyid,
|
|
InstanceId: defaultHostID,
|
|
InstanceUrl: fmt.Sprintf("http://%s", peerHostAddr),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
peerHost := newPeerUnderTest(t, peerHostConfig)
|
|
peerClient := newPeerUnderTest(t, peerClientConfig)
|
|
|
|
startRunningSyncAPI(t, peerHost, peerHostAddr)
|
|
startRunningSyncAPI(t, peerClient, peerClientAddr)
|
|
|
|
waitForConnectionState(t, ctx, peerClient, peerClientConfig.Multihost.KnownHosts[0], v1sync.ConnectionState_CONNECTION_STATE_ERROR_AUTH)
|
|
}
|