Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ password = { env = "OFFSITE_CRYPT_PASSWORD" }
password2 = { env = "OFFSITE_CRYPT_SALT" } # salt — optional but recommended
```

`password` and `password2` are **rclone-obscured** values, the same representation `rclone config` stores for its own crypt remotes — generate one with `rclone obscure <plaintext>`. Both accept a literal or `{ env = "VAR" }`. Squirrel renders two sections into its `rclone.conf` — the underlying remote plus a crypt remote wrapping it — and addresses all sync and restore transfers through the crypt remote. Keep the passwords safe: restoring from an encrypted destination requires them.
`password` and `password2` are **plaintext** (a literal or `{ env = "VAR" }`); squirrel obscures them into rclone's on-disk form when it renders `rclone.conf`, so you never run `rclone obscure` yourself. A config written before this behaviour, whose values are already obscured, keeps them verbatim by adding `obscured = true` to the crypt block. Squirrel renders two sections into its `rclone.conf` — the underlying remote plus a crypt remote wrapping it — and addresses all sync and restore transfers through the crypt remote. Keep the passwords safe: restoring from an encrypted destination requires them.

Two properties to be aware of:

Expand Down
28 changes: 21 additions & 7 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,11 @@ type SyncRunReport struct {
Err error
}

// Config configures one agent listener. Fields are validated by New; all
// of them are required except TLSCert/TLSKey (which must be set together
// or not at all — empty pair means plain HTTP).
// Config configures one agent. Fields are validated by New: Version is
// always required; Listen is optional (empty selects listener-less,
// scheduler-only mode, F35); Token is required only when Listen is set (an
// HTTP surface needs a bearer token); TLSCert/TLSKey must be set together
// or not at all — empty pair means plain HTTP.
type Config struct {
// Listen is the bind address passed to net.Listen, e.g. "0.0.0.0:8443".
Listen string
Expand Down Expand Up @@ -182,16 +184,28 @@ func (s *Server) Handler() http.Handler { return s.handler }
// dial.
func (s *Server) HasTLS() bool { return s.cfg.TLSCert != "" }

// CertFingerprint returns the sha256: pin of the agent's configured TLS
// certificate — the value the startup banner prints so an operator can see
// what peers must pin. It errors when the agent serves plain HTTP (no cert)
// or the certificate file cannot be read.
func (s *Server) CertFingerprint() (string, error) {
if s.cfg.TLSCert == "" {
return "", errors.New("agent: no TLS certificate configured")
}
return FingerprintCertFile(s.cfg.TLSCert)
}

// Addr returns the configured listen address verbatim. For `:0`-style
// binds the kernel-assigned port is only knowable from the net.Listener
// the caller hands to Serve; this accessor is for the startup banner.
func (s *Server) Addr() string { return s.cfg.Listen }

func validateConfig(cfg Config) error {
if cfg.Listen == "" {
return errors.New("agent: Config.Listen is required")
}
if cfg.Token == "" {
// An empty Listen selects listener-less mode (F35): the agent runs only
// its background schedulers, so neither a bind address nor a bearer
// token is required. A token is required only when there is an HTTP
// surface to protect.
if cfg.Listen != "" && cfg.Token == "" {
return errors.New("agent: Config.Token is required")
}
if (cfg.TLSCert == "") != (cfg.TLSKey == "") {
Expand Down
38 changes: 37 additions & 1 deletion agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ func TestNewRejectsBadConfig(t *testing.T) {
cfg Config
want string
}{
{"no listen", Config{Token: "t", Version: "v"}, "Listen is required"},
{"no token", Config{Listen: ":0", Version: "v"}, "Token is required"},
{"half tls", Config{Listen: ":0", Token: "t", TLSCert: "c", Version: "v"}, "must be set together"},
{"no version", Config{Listen: ":0", Token: "t"}, "Version is required"},
Expand All @@ -79,6 +78,43 @@ func TestNewRejectsBadConfig(t *testing.T) {
}
}

// TestNewListenerLess covers F35: an empty Listen (and no Token) is a
// valid scheduler-only agent, and the server reports no listener/TLS.
func TestNewListenerLess(t *testing.T) {
srv, err := New(Config{Version: "v"}, openTestStore(t))
if err != nil {
t.Fatalf("New (listener-less): %v", err)
}
if srv.Addr() != "" {
t.Fatalf("Addr = %q, want empty for a listener-less agent", srv.Addr())
}
if srv.HasTLS() {
t.Fatal("listener-less agent unexpectedly reports TLS")
}
}

// TestRunSchedulersReturnsOnCancel pins that the listener-less run path
// blocks until its context is cancelled and then returns cleanly, without
// ever binding a listener.
func TestRunSchedulersReturnsOnCancel(t *testing.T) {
srv, err := New(Config{Version: "v"}, openTestStore(t))
if err != nil {
t.Fatalf("New: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- srv.RunSchedulers(ctx) }()
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("RunSchedulers: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("RunSchedulers did not return after context cancel")
}
}

// TestNewDefaultsLoggerToDiscard pins the contract that callers may
// leave Config.Logger nil and the agent will still be safe to use:
// New() substitutes a discard logger so the future scheduler can
Expand Down
127 changes: 127 additions & 0 deletions agent/cert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package agent

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"time"
)

// SelfSignedCert bundles a freshly generated self-signed certificate, its
// private key (both PEM-encoded), and the sha256: fingerprint pin peers
// paste into their [nodes.X.tls] cert_fingerprint.
type SelfSignedCert struct {
CertPEM []byte
KeyPEM []byte
Fingerprint string
}

// certValidity is how long a generated agent certificate stays valid.
// Generous because these certs are pinned by fingerprint, not chained to a
// CA: a peer that pins the fingerprint trusts it regardless of expiry, and
// regenerating would change the fingerprint and force every peer to re-pin.
const certValidity = 10 * 365 * 24 * time.Hour

// GenerateSelfSignedCert mints an ECDSA P-256 self-signed certificate for a
// squirrel agent. nodeName becomes the subject common name and a DNS SAN;
// localhost and the loopback addresses are added so a locally-dialed agent
// still validates for clients that do check names. The peer-sync client
// pins the fingerprint and skips name checks, but nothing should depend on
// that. The returned Fingerprint is the sha256: pin over the certificate's
// DER bytes — the exact value the peer-sync verifier compares.
func GenerateSelfSignedCert(nodeName string) (SelfSignedCert, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return SelfSignedCert{}, fmt.Errorf("generate key: %w", err)
}
tmpl, err := certTemplate(nodeName)
if err != nil {
return SelfSignedCert{}, err
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return SelfSignedCert{}, fmt.Errorf("create certificate: %w", err)
}
keyPEM, err := encodeKeyPEM(key)
if err != nil {
return SelfSignedCert{}, err
}
return SelfSignedCert{
CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
KeyPEM: keyPEM,
Fingerprint: fingerprintDER(der),
}, nil
}

// certTemplate builds the x509 template for a self-signed agent cert.
func certTemplate(nodeName string) (*x509.Certificate, error) {
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("generate serial: %w", err)
}
cn := nodeName
if cn == "" {
cn = "squirrel-agent"
}
dns := []string{"localhost"}
if nodeName != "" {
dns = append([]string{nodeName}, dns...)
}
now := time.Now()
return &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: cn},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(certValidity),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: dns,
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
}, nil
}

func encodeKeyPEM(key *ecdsa.PrivateKey) ([]byte, error) {
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, fmt.Errorf("marshal key: %w", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil
}

// fingerprintDER renders the sha256: pin over a certificate's DER bytes,
// matching the peer-sync client's VerifyConnection comparison.
func fingerprintDER(der []byte) string {
sum := sha256.Sum256(der)
return "sha256:" + hex.EncodeToString(sum[:])
}

// FingerprintCertFile reads a PEM certificate file and returns its sha256:
// pin — the value the agent logs at startup and peers put in
// [nodes.X.tls] cert_fingerprint. The first CERTIFICATE block (the leaf)
// wins; trailing chain blocks are ignored.
func FingerprintCertFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read certificate %s: %w", path, err)
}
for {
block, rest := pem.Decode(data)
if block == nil {
return "", fmt.Errorf("no CERTIFICATE block in %s", path)
}
if block.Type == "CERTIFICATE" {
return fingerprintDER(block.Bytes), nil
}
data = rest
}
}
111 changes: 111 additions & 0 deletions agent/cert_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package agent

import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"os"
"path/filepath"
"regexp"
"testing"
)

// fingerprintShape mirrors config.fingerprintRE so the generated pin is
// guaranteed pasteable into [nodes.X.tls] cert_fingerprint.
var fingerprintShape = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)

func TestGenerateSelfSignedCert(t *testing.T) {
c, err := GenerateSelfSignedCert("nas")
if err != nil {
t.Fatalf("GenerateSelfSignedCert: %v", err)
}
if !fingerprintShape.MatchString(c.Fingerprint) {
t.Fatalf("fingerprint %q does not match sha256:<64-hex>", c.Fingerprint)
}
// cert + key must form a usable TLS pair.
if _, err := tls.X509KeyPair(c.CertPEM, c.KeyPEM); err != nil {
t.Fatalf("cert/key do not form a valid pair: %v", err)
}
// The fingerprint must equal the SHA-256 over the leaf's DER — exactly
// what the peer-sync client compares in VerifyConnection.
block, _ := pem.Decode(c.CertPEM)
if block == nil {
t.Fatal("no PEM block in CertPEM")
}
leaf, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse certificate: %v", err)
}
sum := sha256.Sum256(leaf.Raw)
if want := "sha256:" + hex.EncodeToString(sum[:]); want != c.Fingerprint {
t.Fatalf("fingerprint = %q, want %q", c.Fingerprint, want)
}
if leaf.Subject.CommonName != "nas" {
t.Fatalf("CommonName = %q, want nas", leaf.Subject.CommonName)
}
}

func TestGenerateSelfSignedCertUnique(t *testing.T) {
a, err := GenerateSelfSignedCert("nas")
if err != nil {
t.Fatalf("GenerateSelfSignedCert: %v", err)
}
b, err := GenerateSelfSignedCert("nas")
if err != nil {
t.Fatalf("GenerateSelfSignedCert: %v", err)
}
if a.Fingerprint == b.Fingerprint {
t.Fatal("two generations produced the same fingerprint")
}
}

func TestFingerprintCertFile(t *testing.T) {
c, err := GenerateSelfSignedCert("laptop")
if err != nil {
t.Fatalf("GenerateSelfSignedCert: %v", err)
}
path := filepath.Join(t.TempDir(), "agent.crt")
if err := os.WriteFile(path, c.CertPEM, 0o600); err != nil {
t.Fatalf("write cert: %v", err)
}
got, err := FingerprintCertFile(path)
if err != nil {
t.Fatalf("FingerprintCertFile: %v", err)
}
if got != c.Fingerprint {
t.Fatalf("FingerprintCertFile = %q, want %q", got, c.Fingerprint)
}
}

func TestFingerprintCertFileMissing(t *testing.T) {
if _, err := FingerprintCertFile(filepath.Join(t.TempDir(), "nope.crt")); err == nil {
t.Fatal("expected error for missing cert file")
}
}

// TestServerCertFingerprint covers the accessor logAgentStartup uses to log
// the pin at startup: it returns the configured cert's fingerprint and
// errors for a plain-HTTP agent.
func TestServerCertFingerprint(t *testing.T) {
c, err := GenerateSelfSignedCert("nas")
if err != nil {
t.Fatalf("GenerateSelfSignedCert: %v", err)
}
path := filepath.Join(t.TempDir(), "agent.crt")
if err := os.WriteFile(path, c.CertPEM, 0o600); err != nil {
t.Fatalf("write cert: %v", err)
}
tlsSrv := &Server{cfg: Config{TLSCert: path}}
got, err := tlsSrv.CertFingerprint()
if err != nil {
t.Fatalf("CertFingerprint: %v", err)
}
if got != c.Fingerprint {
t.Fatalf("CertFingerprint = %q, want %q", got, c.Fingerprint)
}
if _, err := (&Server{cfg: Config{}}).CertFingerprint(); err == nil {
t.Fatal("expected error for a plain-HTTP agent with no cert")
}
}
20 changes: 20 additions & 0 deletions agent/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,26 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
}
}

// RunSchedulers runs the background scan + cadence loops without binding
// an HTTP listener — the listener-less agent mode (F35) for cadence-only
// machines that never receive peer syncs. It blocks until ctx is
// cancelled, then waits for an in-flight loop tick to finish its volume,
// mirroring Serve's shutdown discipline minus the HTTP server. The two
// loops are gated exactly as under Serve (scan only when ScanInterval is
// set; scheduler only when a volume declares a cadence), so an agent with
// nothing scheduled runs no goroutines and simply waits for cancellation.
func (s *Server) RunSchedulers(ctx context.Context) error {
loopCtx, cancelLoops := context.WithCancel(ctx)
defer cancelLoops()
var loopWG sync.WaitGroup
s.startScanLoop(loopCtx, &loopWG, s.scanLogger())
s.startSchedulerLoop(loopCtx, &loopWG)
<-ctx.Done()
cancelLoops()
loopWG.Wait()
return nil
}

// startScanLoop spins up the drift-detection scheduler in a sibling
// goroutine, but only when ScanInterval is set. The WaitGroup lets
// Serve block on the loop's clean exit during shutdown so a tick
Expand Down
Loading
Loading