Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ jobs:
uses: actions/setup-go@v5
with:
go-version: '1.26'
cache-dependency-path: "**/go.sum"
- name: Build and test
run: make acceptance
2 changes: 1 addition & 1 deletion server/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ build:
CGO_ENABLED=0 go build -a -tags netgo -ldflags='-s -w -extldflags "-static"' -o pinch-server

test: build
go test ./...
go test -race ./...

fuzz:
go test ./internal/filexfer/encoding -run=^$$ -fuzz=FuzzRoundTrip -fuzztime=$(FUZZTIME)
Expand Down
2 changes: 1 addition & 1 deletion server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ curl localhost:8080/status/${FD} | jq .
Compression and Encryption
==========================

Pinch can also encrypt and decrypt data using `age`.
Pinch can also encrypt and decrypt data using AEAD (AES-GCM or ChaCha20-Poly1305, auto-detected).

```bash
$ curl -s 'localhost:8080/pinch?timeout=100s&age-public-key=age1630vztsaydze8r9qc3e865spc989mvcls3wg7hh9vu2w3luulqlqp0v6wh' | jq .
Expand Down
15 changes: 9 additions & 6 deletions server/bench.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ Options:
--target-dir PATH Target directory for copy (default: /var/lib/pinch/data).
--skip-fsync Skip fdatasync after each file/window (passed to copy).
--no-sync Alias for --skip-fsync.
--concurrency N Copy command concurrency (default: 128).
--encrypt MODE Client encryption mode (supported: age|aes).
--concurrency N Copy command concurrency (default: adaptive).
--encrypt MODE Client encryption mode (supported: auto|aes|chacha20).
--compress MODE Compression mode passed to copy (adapt|none|lz4|zstd).
--disable-zero-copy Force server to use buffered send path (no tee/splice).
--freq HZ perf sample frequency (default: 199).
Expand Down Expand Up @@ -63,7 +63,7 @@ SERVER_URL="127.0.0.1:3453"
SERVER_STARTUP_TIMEOUT_SEC=30
SOURCE_DIRECTORY=""
TARGET_DIR="/var/lib/pinch/data"
CONCURRENCY="48"
CONCURRENCY=""
ENCRYPT_MODE=""
COMPRESS_MODE=""
DISABLE_ZERO_COPY=false
Expand Down Expand Up @@ -209,8 +209,8 @@ require_cmd go
require_cmd rm
require_cmd time

if [[ -n "${ENCRYPT_MODE}" && "${ENCRYPT_MODE}" != "age" && "${ENCRYPT_MODE}" != "aes" ]]; then
echo "unsupported --encrypt value: ${ENCRYPT_MODE} (supported: age, aes)" >&2
if [[ -n "${ENCRYPT_MODE}" && "${ENCRYPT_MODE}" != "auto" && "${ENCRYPT_MODE}" != "aes" && "${ENCRYPT_MODE}" != "chacha20" ]]; then
echo "unsupported --encrypt value: ${ENCRYPT_MODE} (supported: auto, aes, chacha20)" >&2
exit 2
fi
if [[ "${RSYNC}" == "true" && "${SKIP_WRITE}" == "true" ]]; then
Expand Down Expand Up @@ -345,7 +345,10 @@ echo "bench: source=${SOURCE_DIRECTORY} target=${TARGET_DIR}"
echo "Cleaning prior benchmark output..."
rm -rf "${TARGET_DIR}/" "${STATE_DIR}/"

COPY_CMD=(./pinch filecli "${SERVER_URL}" copy --concurrency "${CONCURRENCY}")
COPY_CMD=(./pinch filecli "${SERVER_URL}" copy)
if [[ -n "${CONCURRENCY}" ]]; then
COPY_CMD+=(--concurrency "${CONCURRENCY}")
fi
if [[ -n "${ENCRYPT_MODE}" ]]; then
COPY_CMD+=(--encrypt "${ENCRYPT_MODE}")
fi
Expand Down
59 changes: 14 additions & 45 deletions server/filexfer/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ type Client struct {
Comp string // adapt|none|lz4|zstd; empty means server default (adapt)
ClientAgePublicKey string
ClientAgeIdentity string
EncryptMode string // "age" (default) or "aes" — selects post-AUTH stream cipher
EncryptMode string // "auto", "aes", or "chacha20" — selects post-AUTH stream cipher

// Context dialer allows clients to setup custom connections
// For example injecting TLS
Expand Down Expand Up @@ -223,7 +223,6 @@ type FileFrameMeta struct {
FileID uint64
Comp string
CompCounts map[string]uint64
Enc string
Offset int64
Size int64
WireSize int64
Expand Down Expand Up @@ -316,6 +315,7 @@ type ProbeResponse struct {
LinkMbps int64
SuggestedConcurrency int
ServerSendBufBytes int64
SuggestedCipher string // resolved cipher suggested for this connection (e.g. "aes", "chacha20", or "" if none)
}

type GetManifestResponse struct {
Expand Down Expand Up @@ -403,7 +403,7 @@ var ErrFileMissing = errors.New("file missing")
const (
// Window and batch sizes. The window is max in-flight bytes per file; the batch is
// the unit of parallel work. parallelism = window / batch.
defaultClientRequestWindowBytes int64 = 1024 * 1024 * 1024
defaultClientRequestWindowBytes int64 = 512 * 1024 * 1024
defaultClientMaxFrameReadBufferBytes int = 64 * 1024 * 1024
defaultClientBatchMaxBytes int64 = 64 * 1024 * 1024

Expand Down Expand Up @@ -850,7 +850,6 @@ func (c *Client) downloadManifestGroupSequential(
meta := FileFrameMeta{
FileID: plan.entry.ID,
Comp: "none",
Enc: "none",
Offset: plan.resumeFrom,
}
offset := plan.resumeFrom
Expand Down Expand Up @@ -888,7 +887,7 @@ func (c *Client) downloadManifestGroupSequential(
}

payloadReader := io.LimitReader(br, frameMeta.WireSize)
logicalReader, decodeErr := decodePayloadReader(payloadReader, frameMeta.Comp, frameMeta.Enc, nil)
logicalReader, decodeErr := decodePayloadReader(payloadReader, frameMeta.Comp)
if decodeErr != nil {
_ = closeWriter()
return nil, nil, nil, fmt.Errorf("decode payload reader: %w", decodeErr)
Expand Down Expand Up @@ -916,7 +915,6 @@ func (c *Client) downloadManifestGroupSequential(
meta.Size += frameMeta.Size
meta.WireSize += frameMeta.WireSize
meta.Comp = frameMeta.Comp
meta.Enc = frameMeta.Enc
offset += frameMeta.Size

trailerLine, trailerReadErr := br.ReadString('\n')
Expand Down Expand Up @@ -1487,7 +1485,6 @@ func aggregateSplitWindowResults(
Meta: FileFrameMeta{
FileID: plan.entry.ID,
Comp: "none",
Enc: "none",
Offset: plan.resumeFrom,
},
}
Expand All @@ -1506,7 +1503,6 @@ func aggregateSplitWindowResults(
aggregate.Meta.WireSize += meta.WireSize
if idx == 0 {
aggregate.Meta.Comp = meta.Comp
aggregate.Meta.Enc = meta.Enc
}
if len(meta.CompCounts) > 0 {
if aggregate.Meta.CompCounts == nil {
Expand Down Expand Up @@ -1804,6 +1800,12 @@ func (c *Client) ProbeLink(ctx context.Context, req ProbeRequest) (ProbeResponse
}
response := summarizeProbeSamples(probeResults, probeBytes)
response.SuggestedConcurrency = clampConcurrency(suggestedConcurrencyFromProbe(response.ServerCPU, response.ServerIODepth, loadStrategy))

// Resolve the cipher name for display. If encryption is enabled,
// resolveTCPAuthState resolves "auto" to the server's recommendation.
if authState, authErr := c.resolveTCPAuthState(ctx); authErr == nil && authState.hasAuth {
response.SuggestedCipher = authState.encMode
}
return response, nil
}

Expand Down Expand Up @@ -2549,24 +2551,20 @@ func (s *fileStream) openNextFrame() error {
if s.expectOffset && meta.Offset != s.expectedOffset {
return fmt.Errorf("non-contiguous frame offset: expected=%d got=%d", s.expectedOffset, meta.Offset)
}
if s.meta.FileID == 0 && s.meta.Comp == "" && s.meta.Enc == "" && s.meta.Size == 0 && s.meta.WireSize == 0 {
if s.meta.FileID == 0 && s.meta.Comp == "" && s.meta.Size == 0 && s.meta.WireSize == 0 {
s.meta.FileID = meta.FileID
s.meta.Comp = meta.Comp
s.meta.Enc = meta.Enc
s.meta.Offset = meta.Offset
s.meta.MaxWireSizeHint = meta.MaxWireSizeHint
s.meta.HeaderTS = meta.HeaderTS
} else {
if meta.FileID != s.meta.FileID {
return fmt.Errorf("file id mismatch across frames: expected=%d got=%d", s.meta.FileID, meta.FileID)
}
if meta.Enc != s.meta.Enc {
return fmt.Errorf("encryption mode mismatch across frames: expected=%s got=%s", s.meta.Enc, meta.Enc)
}
}

payloadReader := io.LimitReader(s.br, meta.WireSize)
logicalReader, err := decodePayloadReader(payloadReader, meta.Comp, meta.Enc, s.identity)
logicalReader, err := decodePayloadReader(payloadReader, meta.Comp)
if err != nil {
return fmt.Errorf("decode payload reader: %w", err)
}
Expand Down Expand Up @@ -2650,7 +2648,6 @@ func parseFXHeader(line string) (FileFrameMeta, error) {
}

comp := props["comp"]
enc := props["enc"]
offset, err := parseHeaderInt(props["offset"], "offset")
if err != nil {
return FileFrameMeta{}, err
Expand Down Expand Up @@ -2680,13 +2677,12 @@ func parseFXHeader(line string) (FileFrameMeta, error) {
if ts < 0 {
return FileFrameMeta{}, errors.New("invalid header ts")
}
if comp == "" || enc == "" {
if comp == "" {
return FileFrameMeta{}, errors.New("missing required frame properties")
}
return FileFrameMeta{
FileID: fileID,
Comp: comp,
Enc: enc,
Offset: offset,
Size: size,
WireSize: wsize,
Expand Down Expand Up @@ -2859,34 +2855,7 @@ func validHashToken(raw string) bool {
return len(parts) == 2 && parts[0] != "" && parts[1] != ""
}

func decodePayloadReader(payload io.Reader, comp string, enc string, identity age.Identity) (io.ReadCloser, error) {
switch enc {
case "none":
return decodePayloadReaderByComp(payload, comp)
case "age":
if identity == nil {
return nil, errors.New("missing age identity for encrypted frame")
}
decrypted, err := age.Decrypt(payload, identity)
if err != nil {
return nil, err
}
return decodePayloadReaderByComp(decrypted, comp)
case "aes":
if identity == nil {
return nil, errors.New("missing identity for AES encrypted frame")
}
decrypted, err := intencoding.Decrypt(payload, identity)
if err != nil {
return nil, err
}
return decodePayloadReaderByComp(decrypted, comp)
default:
return nil, fmt.Errorf("unsupported encryption mode: %s", enc)
}
}

func decodePayloadReaderByComp(payload io.Reader, comp string) (io.ReadCloser, error) {
func decodePayloadReader(payload io.Reader, comp string) (io.ReadCloser, error) {
switch comp {
case "none":
return io.NopCloser(payload), nil
Expand Down
Loading
Loading