Files
replicate/webdav/replica_client.go
zeekayandGitHub 5f0d4bb8d5 fix(replicate): fail-closed age encryption (no plaintext to S3) — clean re-apply of #6 (#7)
* fix(replicate): fail-closed age encryption (no plaintext LTX to S3)

Replicate silently wrote plaintext LTX1 to the destination whenever an age
recipient was unset -- fleet-wide plaintext SQLite backups. This makes
encryption fail-closed: when a replica is told it must encrypt, the absence
of a recipient REFUSES the write (ErrEncryptionRequired) instead of
downgrading to plaintext.

- Replica.RequireEncryption + guards in Start(), WriteLTXFile(), and Compactor
  .Compact() -- every write path funnels through a fail-closed check.
- DB.init() fails closed at the earliest point (before the "no db file yet"
  early return) so a misconfigured deployment surfaces the error on its first
  Sync, loudly, every interval -- never silently no-ops until data appears.
- DB.Snapshot() now routes through Replica.WriteLTXFile (was raw Client), so
  snapshots are age-encrypted like L0 sync; previously a plaintext bypass.
- internal.ExtractLTXTimestamp: sniff the age v1 intro so encrypted objects
  fall back to write time instead of every backend failing to parse an LTX
  header out of ciphertext. All 8 replica clients use it.
- AutoReplicate / CLI: RequireEncryption defaults on; opt out only via explicit
  REPLICATE_ALLOW_PLAINTEXT / age.allow-plaintext for non-sensitive dev targets.

Tests: replica_failclosed_test.go (unit: write/start/compact fail closed;
encrypt+round-trip with recipient) and replicate_e2e_encrypt_test.go (full DB
stack: every destination object is age ciphertext across L0 sync AND the
snapshot path; live-writer restore round-trip; DB sync fails closed).

Re-applies the fix from #6 onto a fresh branch off main (#6's branch shares no
git history with main and cannot merge). age bumped 1.4.0->1.6.0 to match the
platform (cloud) and resolve main's broken [email protected] go.sum checksum; go mod
tidy prunes the orphaned prometheus indirect deps left by the metric migration.

* test(replicate): define testutil shim so the package test binary compiles

The luxfi/metric migration (commit 7c8ef84) dropped prometheus but left
db_internal_test.go referencing an undefined `testutil`, so `go test ./` did
not compile at all on main. This adds the minimal in-tree ToFloat64 shim
(luxfi/metric Counter/Gauge expose Get() float64) and drops the now-unused
metric import, unblocking the package test binary.

NOTE: this only fixes COMPILATION. The metric value-assertion tests
(TestDB_*Metrics) remain pre-existing RED because luxfi/metric's
WithLabelValues/With are non-memoizing (proven: vec.WithLabelValues("x").Set(5)
then vec.WithLabelValues("x").Get() == 0). That is an orthogonal, incomplete
metric-migration defect (also affects runtime aggregation at db.go checkpoint
Inc) and must be completed in a dedicated metric PR -- not folded into the
fail-closed security fix. Identical failures occur with or without encryption.

* docs(deps): justify luxfi/age v1.4.0->v1.6.0 bump (forced by broken main go.sum)

* fix(ci): unpoison luxfi/zap + run root metric tests under -tags metrics

Two pre-existing main breakages surfaced by CI (not by the fail-closed
change):

1. luxfi/zap v0.8.1's tag was re-published upstream, so its go.sum hash
   no longer verifies (SECURITY ERROR on every job). Bump to the latest
   patch in-minor, v0.8.12 (sumdb-verified) — same remedy as the age bump,
   no lazy major.

2. luxfi/metric defaults to a NO-OP registry (//go:build !metrics); the
   real in-process registry is behind `metrics`. `go test .` ran without
   it, so every WithLabelValues returned a throwaway gauge that reads 0 —
   the 8 TestDB_*Metrics failures. Root cause is the build tag (not
   'non-memoizing' as first guessed, nor a prod defect): the DB's cached
   gauge holds the value; only re-derivations under the no-op build miss.
   Run the root suite with -tags metrics; passes 8/8 (27s, full package).

* revert(ci): drop -tags metrics test kludge

Prod builds cmd/replicate without -tags metrics (Dockerfile), so the
metric registry is no-op in production regardless; adding the tag only to
CI tests validated a mode prod never runs. The 8 TestDB_*Metrics failures
are a pre-existing main issue (luxfi/metric's no-op default), tracked
separately — not papered over with a test-only build tag. The essential
fix in this PR stands: luxfi/zap v0.8.1->v0.8.12 (re-published-tag go.sum
poison). One way, no kludge.
2026-07-07 17:59:12 -07:00

368 lines
10 KiB
Go

package webdav
import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"net/url"
"os"
"path"
"sort"
"sync"
"time"
"github.com/hanzoai/ltx"
"github.com/studio-b12/gowebdav"
"github.com/hanzoai/replicate"
"github.com/hanzoai/replicate/internal"
)
func init() {
replicate.RegisterReplicaClientFactory("webdav", NewReplicaClientFromURL)
replicate.RegisterReplicaClientFactory("webdavs", NewReplicaClientFromURL)
}
const ReplicaClientType = "webdav"
const (
DefaultTimeout = 30 * time.Second
)
var _ replicate.ReplicaClient = (*ReplicaClient)(nil)
type ReplicaClient struct {
mu sync.Mutex
client *gowebdav.Client
logger *slog.Logger
URL string
Username string
Password string
Path string
Timeout time.Duration
}
func NewReplicaClient() *ReplicaClient {
return &ReplicaClient{
logger: slog.Default().WithGroup(ReplicaClientType),
Timeout: DefaultTimeout,
}
}
func (c *ReplicaClient) SetLogger(logger *slog.Logger) {
c.logger = logger.WithGroup(ReplicaClientType)
}
// NewReplicaClientFromURL creates a new ReplicaClient from URL components.
// This is used by the replica client factory registration.
// URL format: webdav://[user[:password]@]host[:port]/path or webdavs://... (for HTTPS)
func NewReplicaClientFromURL(scheme, host, urlPath string, query url.Values, userinfo *url.Userinfo) (replicate.ReplicaClient, error) {
client := NewReplicaClient()
// Determine HTTP or HTTPS based on scheme
httpScheme := "http"
if scheme == "webdavs" {
httpScheme = "https"
}
// Extract credentials from userinfo
if userinfo != nil {
client.Username = userinfo.Username()
client.Password, _ = userinfo.Password()
}
if host == "" {
return nil, fmt.Errorf("host required for webdav replica URL")
}
client.URL = fmt.Sprintf("%s://%s", httpScheme, host)
client.Path = urlPath
return client, nil
}
func (c *ReplicaClient) Type() string {
return ReplicaClientType
}
func (c *ReplicaClient) Init(ctx context.Context) error {
_, err := c.init(ctx)
return err
}
// init initializes the connection and returns the WebDAV client.
func (c *ReplicaClient) init(ctx context.Context) (_ *gowebdav.Client, err error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.client != nil {
return c.client, nil
}
if c.URL == "" {
return nil, fmt.Errorf("webdav url required")
}
c.client = gowebdav.NewClient(c.URL, c.Username, c.Password)
c.client.SetTimeout(c.Timeout)
if err := c.client.Connect(); err != nil {
c.client = nil
return nil, fmt.Errorf("webdav: cannot connect to server: %w", err)
}
return c.client, nil
}
func (c *ReplicaClient) DeleteAll(ctx context.Context) error {
client, err := c.init(ctx)
if err != nil {
return err
}
if err := client.RemoveAll(c.Path); err != nil && !os.IsNotExist(err) && !gowebdav.IsErrNotFound(err) {
return fmt.Errorf("webdav: cannot delete path %q: %w", c.Path, err)
}
internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "DELETE").Inc()
return nil
}
func (c *ReplicaClient) LTXFiles(ctx context.Context, level int, seek ltx.TXID, _ bool) (_ ltx.FileIterator, err error) {
client, err := c.init(ctx)
if err != nil {
return nil, err
}
dir := replicate.LTXLevelDir(c.Path, level)
files, err := client.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) || gowebdav.IsErrNotFound(err) {
return ltx.NewFileInfoSliceIterator(nil), nil
}
return nil, fmt.Errorf("webdav: cannot read directory %q: %w", dir, err)
}
infos := make([]*ltx.FileInfo, 0, len(files))
for _, fi := range files {
if fi.IsDir() {
continue
}
minTXID, maxTXID, err := ltx.ParseFilename(path.Base(fi.Name()))
if err != nil {
continue
} else if minTXID < seek {
continue
}
infos = append(infos, &ltx.FileInfo{
Level: level,
MinTXID: minTXID,
MaxTXID: maxTXID,
Size: fi.Size(),
CreatedAt: fi.ModTime().UTC(),
})
}
sort.Slice(infos, func(i, j int) bool {
if infos[i].MinTXID != infos[j].MinTXID {
return infos[i].MinTXID < infos[j].MinTXID
}
return infos[i].MaxTXID < infos[j].MaxTXID
})
return ltx.NewFileInfoSliceIterator(infos), nil
}
// WriteLTXFile writes an LTX file to the WebDAV server.
//
// WebDAV Upload Strategy - Temp File Approach:
//
// Unlike other replica backends (S3, SFTP, NATS, ABS) which stream directly using
// internal.NewReadCounter, WebDAV requires a different approach due to library and
// protocol constraints:
//
// 1. gowebdav Library Limitations:
//
// - WriteStream() buffers entire payload in memory for non-seekable readers
//
// - WriteStreamWithLength() requires both content-length AND seekable reader
//
// - No native support for HTTP chunked transfer encoding
//
// 2. Server Compatibility Issues:
// Research shows HTTP chunked transfer encoding with WebDAV is unreliable:
//
// - Nginx + FastCGI: Discards request body → 0-byte files (silent data loss)
//
// - Lighttpd: Returns HTTP 411 (Length Required), rejects chunked requests
//
// - Apache + FastCGI: Request body never arrives at application
//
// - Only Apache + mod_php handles chunked encoding reliably (~30-40% of deployments)
//
// 3. LTX Header Requirement:
// - Must peek at LTX header to extract timestamp before upload
// - Peeking consumes data, making the reader non-seekable
// - Cannot calculate content-length without fully reading stream
//
// Solution: Stage to temporary file
//
// To ensure universal compatibility and prevent silent data loss:
// 1. Extract timestamp from LTX header (required for file metadata)
// 2. Stream full contents to temporary file on disk
// 3. Seek back to start of temp file (now seekable + known size)
// 4. Upload using WriteStreamWithLength() with Content-Length header
// 5. Clean up temp file
//
// Trade-offs:
// - Universal compatibility with all WebDAV server configurations
// - No risk of silent data loss or failed uploads
// - Predictable, reliable behavior
// - Additional disk I/O overhead
// - Requires local disk space proportional to LTX file size
// - Diverges from streaming pattern used by other backends
//
// References:
// - https://github.com/studio-b12/gowebdav/issues/35 (chunked encoding issues)
// - https://github.com/nextcloud/server/issues/7995 (0-byte file bug)
// - https://evertpot.com/260/ (WebDAV chunked encoding compatibility)
func (c *ReplicaClient) WriteLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, rd io.Reader) (info *ltx.FileInfo, err error) {
client, err := c.init(ctx)
if err != nil {
return nil, err
}
filename := replicate.LTXFilePath(c.Path, level, minTXID, maxTXID)
// Extract the LTX-header timestamp (age-encrypted objects fall back to write
// time) and get a reader that replays the full object for upload.
timestamp, fullReader, err := internal.ExtractLTXTimestamp(rd)
if err != nil {
return nil, err
}
// Stage to temporary file to get seekable reader with known size.
// This ensures compatibility with all WebDAV servers and avoids the
// unreliable chunked transfer encoding that causes silent data loss
// on common configurations (Nginx+FastCGI, Lighttpd, Apache+FastCGI).
tmpFile, err := os.CreateTemp("", "replicate-webdav-*.ltx")
if err != nil {
return nil, fmt.Errorf("webdav: cannot create temp file: %w", err)
}
defer func() {
_ = tmpFile.Close()
_ = os.Remove(tmpFile.Name())
}()
size, err := io.Copy(tmpFile, fullReader)
if err != nil {
return nil, fmt.Errorf("webdav: cannot copy to temp file: %w", err)
}
if _, err := tmpFile.Seek(0, io.SeekStart); err != nil {
return nil, fmt.Errorf("webdav: cannot seek temp file: %w", err)
}
if err := client.MkdirAll(path.Dir(filename), 0755); err != nil {
return nil, fmt.Errorf("webdav: cannot create parent directory %q: %w", path.Dir(filename), err)
}
// Upload with Content-Length header using seekable temp file.
// WriteStreamWithLength requires both a seekable reader and known size,
// which we now have from the temp file. This avoids chunked encoding
// and ensures reliable uploads across all WebDAV server configurations.
if err := client.WriteStreamWithLength(filename, tmpFile, size, 0644); err != nil {
return nil, fmt.Errorf("webdav: cannot write file %q: %w", filename, err)
}
internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "PUT").Inc()
internal.OperationBytesCounterVec.WithLabelValues(ReplicaClientType, "PUT").Add(float64(size))
return &ltx.FileInfo{
Level: level,
MinTXID: minTXID,
MaxTXID: maxTXID,
Size: size,
CreatedAt: timestamp,
}, nil
}
func (c *ReplicaClient) OpenLTXFile(ctx context.Context, level int, minTXID, maxTXID ltx.TXID, offset, size int64) (_ io.ReadCloser, err error) {
client, err := c.init(ctx)
if err != nil {
return nil, err
}
filename := replicate.LTXFilePath(c.Path, level, minTXID, maxTXID)
internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "GET").Inc()
if size > 0 {
rc, err := client.ReadStreamRange(filename, offset, size)
if err != nil {
if os.IsNotExist(err) || gowebdav.IsErrNotFound(err) {
return nil, os.ErrNotExist
}
return nil, fmt.Errorf("webdav: cannot read file %q: %w", filename, err)
}
return internal.LimitReadCloser(rc, size), nil
}
if offset > 0 {
rc, err := client.ReadStream(filename)
if err != nil {
if os.IsNotExist(err) || gowebdav.IsErrNotFound(err) {
return nil, os.ErrNotExist
}
return nil, fmt.Errorf("webdav: cannot read file %q: %w", filename, err)
}
if _, err := io.CopyN(io.Discard, rc, offset); err != nil {
if err == io.EOF || err == io.ErrUnexpectedEOF {
_ = rc.Close()
return io.NopCloser(bytes.NewReader(nil)), nil
}
_ = rc.Close()
return nil, fmt.Errorf("webdav: cannot skip offset in file %q: %w", filename, err)
}
return rc, nil
}
rc, err := client.ReadStream(filename)
if err != nil {
if os.IsNotExist(err) || gowebdav.IsErrNotFound(err) {
return nil, os.ErrNotExist
}
return nil, fmt.Errorf("webdav: cannot read file %q: %w", filename, err)
}
return rc, nil
}
func (c *ReplicaClient) DeleteLTXFiles(ctx context.Context, a []*ltx.FileInfo) error {
client, err := c.init(ctx)
if err != nil {
return err
}
for _, info := range a {
filename := replicate.LTXFilePath(c.Path, info.Level, info.MinTXID, info.MaxTXID)
c.logger.Debug("deleting ltx file", "level", info.Level, "minTXID", info.MinTXID, "maxTXID", info.MaxTXID, "path", filename)
if err := client.Remove(filename); err != nil && !os.IsNotExist(err) && !gowebdav.IsErrNotFound(err) {
return fmt.Errorf("webdav: cannot delete ltx file %q: %w", filename, err)
}
internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "DELETE").Inc()
}
return nil
}