mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 14:56:38 +08:00
fix(pages): 收紧部署包与 Agent 同步边界
完成 V2 Phase 0 安全与一致性前置:统一真实归档限额、流式拉取、候选裁剪、保留上传删除语义及 Pages 路由引用锁。
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package httpclient provides an authenticated HTTP client for the agent.
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -13,6 +17,8 @@ import (
|
||||
edgehttp "github.com/Rain-kl/Wavelet/internal/apps/edge/httpclient"
|
||||
)
|
||||
|
||||
const pagesControlResponseMaxBytes = int64(64 * 1024)
|
||||
|
||||
// Client is a HTTP client used by the agent to communicate with the control plane server.
|
||||
type Client struct {
|
||||
base *edgehttp.Client
|
||||
@@ -98,23 +104,43 @@ func (c *Client) GetPagesDeploymentHash(ctx context.Context, deploymentID uint)
|
||||
return resp.Data.Hash, nil
|
||||
}
|
||||
|
||||
// DownloadPagesDeploymentPackage downloads the deployment package for the given Pages deployment ID.
|
||||
func (c *Client) DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint) ([]byte, error) {
|
||||
res, err := c.base.DoRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/agent/pages/deployments/%d/package", deploymentID), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, edgehttp.ReadHTTPError(res)
|
||||
}
|
||||
return io.ReadAll(res.Body)
|
||||
// DownloadPagesDeploymentPackage streams the deployment package into dst while
|
||||
// enforcing maxBytes against both advertised and actual response sizes.
|
||||
func (c *Client) DownloadPagesDeploymentPackage(
|
||||
ctx context.Context,
|
||||
deploymentID uint,
|
||||
dst io.Writer,
|
||||
maxBytes int64,
|
||||
) (int64, error) {
|
||||
return c.downloadPagesPackage(
|
||||
ctx,
|
||||
fmt.Sprintf("/api/v1/agent/pages/deployments/%d/package", deploymentID),
|
||||
dst,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
// GetPagesProjectLatestHash returns the active deployment package hash for a Pages project.
|
||||
func (c *Client) GetPagesProjectLatestHash(ctx context.Context, projectID uint) (*protocol.PagesProjectLatestHashResponse, error) {
|
||||
res, err := c.base.DoRaw(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
fmt.Sprintf("/api/v1/agent/pages/projects/%d/latest/hash", projectID),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
body, err := readPagesControlResponse(res, pagesControlResponseMaxBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, edgehttp.ReadBodyError(body, res.Status)
|
||||
}
|
||||
resp := protocol.APIResponse[protocol.PagesProjectLatestHashResponse]{}
|
||||
if err := c.base.GetJSON(ctx, fmt.Sprintf("/api/v1/agent/pages/projects/%d/latest/hash", projectID), &resp); err != nil {
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := edgehttp.APIError(resp.ErrorMsg); err != nil {
|
||||
@@ -123,17 +149,86 @@ func (c *Client) GetPagesProjectLatestHash(ctx context.Context, projectID uint)
|
||||
return &resp.Data, nil
|
||||
}
|
||||
|
||||
// DownloadPagesProjectLatestPackage downloads the active deployment package for a Pages project.
|
||||
func (c *Client) DownloadPagesProjectLatestPackage(ctx context.Context, projectID uint) ([]byte, error) {
|
||||
res, err := c.base.DoRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/agent/pages/projects/%d/latest/package", projectID), nil)
|
||||
// DownloadPagesProjectLatestPackage streams the active deployment package into
|
||||
// dst while enforcing maxBytes against both advertised and actual sizes.
|
||||
func (c *Client) DownloadPagesProjectLatestPackage(
|
||||
ctx context.Context,
|
||||
projectID uint,
|
||||
dst io.Writer,
|
||||
maxBytes int64,
|
||||
) (int64, error) {
|
||||
return c.downloadPagesPackage(
|
||||
ctx,
|
||||
fmt.Sprintf("/api/v1/agent/pages/projects/%d/latest/package", projectID),
|
||||
dst,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Client) downloadPagesPackage(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
dst io.Writer,
|
||||
maxBytes int64,
|
||||
) (int64, error) {
|
||||
if dst == nil {
|
||||
return 0, errors.New("pages package destination is required")
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
return 0, errors.New("pages package byte limit must be positive")
|
||||
}
|
||||
res, err := c.base.DoRaw(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, edgehttp.ReadHTTPError(res)
|
||||
body, readErr := readPagesControlResponse(res, pagesControlResponseMaxBytes)
|
||||
if readErr != nil {
|
||||
return 0, readErr
|
||||
}
|
||||
return 0, edgehttp.ReadBodyError(body, res.Status)
|
||||
}
|
||||
return io.ReadAll(res.Body)
|
||||
return copyPagesPackageResponse(dst, res, maxBytes)
|
||||
}
|
||||
|
||||
func readPagesControlResponse(res *http.Response, maxBytes int64) ([]byte, error) {
|
||||
if res.ContentLength > maxBytes {
|
||||
return nil, fmt.Errorf(
|
||||
"pages control response Content-Length %d exceeds limit %d",
|
||||
res.ContentLength,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
limited := &io.LimitedReader{R: res.Body, N: maxBytes + 1}
|
||||
body, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pages control response: %w", err)
|
||||
}
|
||||
if int64(len(body)) > maxBytes {
|
||||
return nil, fmt.Errorf("pages control response body exceeds limit %d", maxBytes)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func copyPagesPackageResponse(dst io.Writer, res *http.Response, maxBytes int64) (int64, error) {
|
||||
if res.ContentLength > maxBytes {
|
||||
return 0, fmt.Errorf(
|
||||
"pages package Content-Length %d exceeds limit %d",
|
||||
res.ContentLength,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
limited := &io.LimitedReader{R: res.Body, N: maxBytes + 1}
|
||||
written, err := io.Copy(dst, limited)
|
||||
if err != nil {
|
||||
return written, fmt.Errorf("stream pages package: %w", err)
|
||||
}
|
||||
if written > maxBytes {
|
||||
return written, fmt.Errorf("pages package body exceeds limit %d", maxBytes)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// SetToken updates the authentication token used for API requests.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDownloadPagesProjectLatestPackageRejectsChunkedBodyOverLimit(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
_, _ = io.WriteString(w, "123456")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(server.URL, "test-token", time.Second)
|
||||
var dst bytes.Buffer
|
||||
written, err := client.DownloadPagesProjectLatestPackage(
|
||||
context.Background(),
|
||||
7,
|
||||
&dst,
|
||||
4,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "body exceeds limit") {
|
||||
t.Fatalf("DownloadPagesProjectLatestPackage(chunked, limit=4) error = %v, want body limit error", err)
|
||||
}
|
||||
if written != 5 {
|
||||
t.Errorf("DownloadPagesProjectLatestPackage(chunked, limit=4) written = %d, want 5", written)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyPagesPackageResponseRejectsAdvertisedContentLengthBeforeWrite(t *testing.T) {
|
||||
response := &http.Response{
|
||||
Body: io.NopCloser(strings.NewReader("123456")),
|
||||
ContentLength: 6,
|
||||
}
|
||||
var dst bytes.Buffer
|
||||
written, err := copyPagesPackageResponse(&dst, response, 4)
|
||||
if err == nil || !strings.Contains(err.Error(), "Content-Length") {
|
||||
t.Fatalf("copyPagesPackageResponse(Content-Length=6, limit=4) error = %v, want Content-Length limit error", err)
|
||||
}
|
||||
if written != 0 || dst.Len() != 0 {
|
||||
t.Errorf("copyPagesPackageResponse(Content-Length=6, limit=4) wrote (%d, %d buffered), want no writes", written, dst.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyPagesPackageResponseRejectsForgedSmallContentLength(t *testing.T) {
|
||||
response := &http.Response{
|
||||
Body: io.NopCloser(strings.NewReader("123456")),
|
||||
ContentLength: 2,
|
||||
}
|
||||
var dst bytes.Buffer
|
||||
written, err := copyPagesPackageResponse(&dst, response, 4)
|
||||
if err == nil || !strings.Contains(err.Error(), "body exceeds limit") {
|
||||
t.Fatalf("copyPagesPackageResponse(forged Content-Length=2, limit=4) error = %v, want body limit error", err)
|
||||
}
|
||||
if written != 5 {
|
||||
t.Errorf("copyPagesPackageResponse(forged Content-Length=2, limit=4) written = %d, want 5", written)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPagesProjectLatestPackageBoundsChunkedErrorResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
_, _ = io.WriteString(w, strings.Repeat("x", int(pagesControlResponseMaxBytes+1)))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(server.URL, "test-token", time.Second)
|
||||
var dst bytes.Buffer
|
||||
_, err := client.DownloadPagesProjectLatestPackage(context.Background(), 7, &dst, 1024)
|
||||
if err == nil || !strings.Contains(err.Error(), "control response body exceeds limit") {
|
||||
t.Fatalf("DownloadPagesProjectLatestPackage(large chunked 400) error = %v, want bounded response error", err)
|
||||
}
|
||||
if dst.Len() != 0 {
|
||||
t.Errorf("DownloadPagesProjectLatestPackage(large chunked 400) wrote %d package bytes, want 0", dst.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPagesProjectLatestHashBoundsChunkedMetadataResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
_, _ = io.WriteString(w, strings.Repeat("x", int(pagesControlResponseMaxBytes+1)))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := New(server.URL, "test-token", time.Second)
|
||||
_, err := client.GetPagesProjectLatestHash(context.Background(), 7)
|
||||
if err == nil || !strings.Contains(err.Error(), "control response body exceeds limit") {
|
||||
t.Fatalf("GetPagesProjectLatestHash(large chunked metadata) error = %v, want bounded response error", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user