[新增] OpenFlare Pages

This commit is contained in:
ryan
2026-06-03 17:09:02 +08:00
parent 3ec607106d
commit 2c74d042ed
38 changed files with 2630 additions and 72 deletions
+273
View File
@@ -0,0 +1,273 @@
package sync
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"openflare-agent/internal/protocol"
)
type pagesSourceDocument struct {
Routes []pagesSourceRoute `json:"routes"`
}
type pagesSourceRoute struct {
UpstreamType string `json:"upstream_type"`
PagesDeployment *pagesDeploymentSource `json:"pages_deployment"`
}
type pagesDeploymentSource struct {
DeploymentID uint `json:"deployment_id"`
Checksum string `json:"checksum"`
}
type pagesDeploymentMarker struct {
DeploymentID uint `json:"deployment_id"`
Checksum string `json:"checksum"`
}
func (s *Service) syncPagesDeployments(ctx context.Context, config *protocol.ActiveConfigResponse) error {
deployments, err := referencedPagesDeployments(config)
if err != nil {
return err
}
if len(deployments) == 0 {
return nil
}
if strings.TrimSpace(s.pagesDir) == "" {
return errors.New("pages_dir is required when active config references Pages deployments")
}
for _, deployment := range deployments {
if err := s.ensurePagesDeployment(ctx, deployment); err != nil {
return err
}
}
return nil
}
func (s *Service) ensurePagesDeployment(ctx context.Context, deployment pagesDeploymentSource) error {
currentDir := pagesCurrentDir(s.pagesDir, deployment.DeploymentID)
if markerMatches(currentDir, deployment) {
return nil
}
packageBytes, err := s.client.DownloadPagesDeploymentPackage(ctx, deployment.DeploymentID)
if err != nil {
return fmt.Errorf("download Pages deployment %d: %w", deployment.DeploymentID, err)
}
if got := checksumBytes(packageBytes); got != deployment.Checksum {
return fmt.Errorf("Pages deployment %d checksum mismatch: expected %s, got %s", deployment.DeploymentID, deployment.Checksum, got)
}
releaseDir := pagesReleaseDir(s.pagesDir, deployment.DeploymentID, deployment.Checksum)
if !markerMatches(releaseDir, deployment) {
if err := extractPagesPackage(packageBytes, releaseDir, deployment); err != nil {
return err
}
}
return switchPagesCurrentDir(s.pagesDir, deployment.DeploymentID, releaseDir)
}
func referencedPagesDeployments(config *protocol.ActiveConfigResponse) ([]pagesDeploymentSource, error) {
if config == nil || strings.TrimSpace(config.SourceConfigJSON) == "" {
return nil, nil
}
var doc pagesSourceDocument
if err := json.Unmarshal([]byte(config.SourceConfigJSON), &doc); err != nil {
return nil, fmt.Errorf("decode Pages references: %w", err)
}
seen := make(map[uint]struct{})
result := make([]pagesDeploymentSource, 0)
for _, route := range doc.Routes {
if strings.ToLower(strings.TrimSpace(route.UpstreamType)) != "pages" || route.PagesDeployment == nil {
continue
}
deploymentID := route.PagesDeployment.DeploymentID
checksum := strings.TrimSpace(route.PagesDeployment.Checksum)
if deploymentID == 0 || checksum == "" {
return nil, errors.New("Pages deployment snapshot is incomplete")
}
if _, ok := seen[deploymentID]; ok {
continue
}
seen[deploymentID] = struct{}{}
result = append(result, pagesDeploymentSource{DeploymentID: deploymentID, Checksum: checksum})
}
return result, nil
}
func extractPagesPackage(packageBytes []byte, releaseDir string, deployment pagesDeploymentSource) error {
tmpDir := releaseDir + ".tmp"
_ = os.RemoveAll(tmpDir)
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return err
}
reader, err := zip.NewReader(bytes.NewReader(packageBytes), int64(len(packageBytes)))
if err != nil {
_ = os.RemoveAll(tmpDir)
return fmt.Errorf("open Pages zip: %w", err)
}
for _, item := range reader.File {
relativePath, skip, err := normalizePagesArchivePath(item.Name)
if err != nil {
_ = os.RemoveAll(tmpDir)
return err
}
if skip {
continue
}
if item.FileInfo().Mode()&os.ModeSymlink != 0 {
_ = os.RemoveAll(tmpDir)
return fmt.Errorf("Pages package contains unsupported symlink: %s", relativePath)
}
if err := extractPagesFile(item, filepath.Join(tmpDir, relativePath)); err != nil {
_ = os.RemoveAll(tmpDir)
return err
}
}
if err := writePagesMarker(tmpDir, deployment); err != nil {
_ = os.RemoveAll(tmpDir)
return err
}
_ = os.RemoveAll(releaseDir)
return os.Rename(tmpDir, releaseDir)
}
func extractPagesFile(item *zip.File, targetPath string) error {
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
source, err := item.Open()
if err != nil {
return err
}
defer source.Close()
target, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, item.FileInfo().Mode().Perm())
if err != nil {
return err
}
defer target.Close()
_, err = io.Copy(target, source)
return err
}
func switchPagesCurrentDir(baseDir string, deploymentID uint, releaseDir string) error {
currentDir := pagesCurrentDir(baseDir, deploymentID)
previousDir := currentDir + ".previous"
_ = os.RemoveAll(previousDir)
if err := os.MkdirAll(filepath.Dir(currentDir), 0o755); err != nil {
return err
}
if _, err := os.Stat(currentDir); err == nil {
if err := os.Rename(currentDir, previousDir); err != nil {
return err
}
}
if err := copyPagesDir(releaseDir, currentDir); err != nil {
_ = os.RemoveAll(currentDir)
if _, restoreErr := os.Stat(previousDir); restoreErr == nil {
_ = os.Rename(previousDir, currentDir)
}
return err
}
_ = os.RemoveAll(previousDir)
return nil
}
func copyPagesDir(sourceDir string, targetDir string) error {
return filepath.WalkDir(sourceDir, func(sourcePath string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
relativePath, err := filepath.Rel(sourceDir, sourcePath)
if err != nil || relativePath == "." {
return err
}
targetPath := filepath.Join(targetDir, relativePath)
if entry.IsDir() {
return os.MkdirAll(targetPath, 0o755)
}
info, err := entry.Info()
if err != nil {
return err
}
input, err := os.Open(sourcePath)
if err != nil {
return err
}
defer input.Close()
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
output, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
defer output.Close()
_, err = io.Copy(output, input)
return err
})
}
func normalizePagesArchivePath(raw string) (string, bool, error) {
name := strings.TrimSpace(filepath.ToSlash(raw))
if name == "" || strings.HasSuffix(name, "/") {
return "", true, nil
}
if strings.HasPrefix(name, "/") {
return "", false, fmt.Errorf("Pages package contains absolute path: %s", raw)
}
cleaned := path.Clean(name)
if cleaned == "." {
return "", true, nil
}
if cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.Contains(cleaned, "/../") {
return "", false, fmt.Errorf("Pages package path escapes deployment root: %s", raw)
}
return filepath.FromSlash(cleaned), false, nil
}
func markerMatches(dir string, deployment pagesDeploymentSource) bool {
data, err := os.ReadFile(filepath.Join(dir, ".openflare-pages.json"))
if err != nil {
return false
}
var marker pagesDeploymentMarker
if err := json.Unmarshal(data, &marker); err != nil {
return false
}
return marker.DeploymentID == deployment.DeploymentID && marker.Checksum == deployment.Checksum
}
func writePagesMarker(dir string, deployment pagesDeploymentSource) error {
data, err := json.Marshal(pagesDeploymentMarker{
DeploymentID: deployment.DeploymentID,
Checksum: deployment.Checksum,
})
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, ".openflare-pages.json"), data, 0o644)
}
func pagesCurrentDir(baseDir string, deploymentID uint) string {
return filepath.Join(baseDir, "deployments", fmt.Sprintf("%d", deploymentID), "current")
}
func pagesReleaseDir(baseDir string, deploymentID uint, checksum string) string {
return filepath.Join(baseDir, "deployments", fmt.Sprintf("%d", deploymentID), "releases", checksum)
}
func checksumBytes(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
+9
View File
@@ -25,6 +25,7 @@ const (
type ConfigClient interface {
GetActiveConfig(ctx context.Context) (*protocol.ActiveConfigResponse, error)
DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint) ([]byte, error)
ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error
SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error)
}
@@ -42,6 +43,11 @@ type Service struct {
client ConfigClient
nginxManager NginxManager
stateStore *state.Store
pagesDir string
}
func (s *Service) SetPagesDir(path string) {
s.pagesDir = strings.TrimSpace(path)
}
func New(client ConfigClient, nginxManager NginxManager, stateStore *state.Store) *Service {
@@ -225,6 +231,9 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
if err != nil {
return err
}
if err := s.syncPagesDeployments(ctx, config); err != nil {
return err
}
mainConfigChecksum := checksumString(rendered.mainConfig)
routeConfigChecksum := checksumString(rendered.routeConfig)
slog.Info("applying new openresty config", "mode", mode, "from_version", snapshot.CurrentVersion, "to_version", config.Version, "old_checksum", currentChecksum, "new_checksum", config.Checksum)
+114 -3
View File
@@ -1,7 +1,11 @@
package sync
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
@@ -19,10 +23,15 @@ type fakeExecutor struct {
reloadErr error
}
func testPagesSourceConfigJSON(deploymentID uint, checksum string) string {
return fmt.Sprintf(`{"routes":[{"id":1,"site_name":"pages","domain":"pages.example.com","domains":["pages.example.com"],"origin_url":"openflare-pages://project/1","upstreams":["openflare-pages://project/1"],"enabled":true,"upstream_type":"pages","pages_deployment":{"project_id":1,"project_slug":"pages","deployment_id":%d,"deployment_number":1,"checksum":"%s","entry_file":"index.html","spa_fallback_enabled":true,"local_root":"__OPENFLARE_PAGES_DIR__/deployments/%d/current"}}],"openresty_config":{"worker_processes":"auto","worker_connections":1024,"worker_rlimit_nofile":65535,"events_multi_accept_enabled":true,"keepalive_timeout":20,"keepalive_requests":1000,"client_header_timeout":15,"client_body_timeout":15,"client_max_body_size":"64m","large_client_header_buffers":"4 16k","send_timeout":30,"proxy_connect_timeout":3,"proxy_send_timeout":60,"proxy_read_timeout":60,"websocket_enabled":true,"proxy_request_buffering":false,"proxy_buffering_enabled":true,"proxy_buffers":"16 16k","proxy_buffer_size":"8k","proxy_busy_buffers_size":"64k","gzip_enabled":true,"gzip_min_length":1024,"gzip_comp_level":5,"cache_enabled":false,"cache_levels":"1:2","cache_inactive":"30m","cache_max_size":"1g","cache_key_template":"$scheme$host$request_uri","cache_lock_enabled":true,"cache_lock_timeout":"5s","cache_use_stale":"error timeout updating http_500 http_502 http_503 http_504","main_config_template":"worker_processes {{OpenRestyWorkerProcesses}};"},"waf":{"rule_groups":[],"bindings":[]}}`, deploymentID, checksum, deploymentID)
}
type fakeClient struct {
config protocol.ActiveConfigResponse
reports []protocol.ApplyLogPayload
fetchCalls int
config protocol.ActiveConfigResponse
reports []protocol.ApplyLogPayload
pagesPackages map[uint][]byte
fetchCalls int
}
type fakeManager struct {
@@ -67,6 +76,13 @@ func (f *fakeClient) GetActiveConfig(ctx context.Context) (*protocol.ActiveConfi
return &f.config, nil
}
func (f *fakeClient) DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint) ([]byte, error) {
if f.pagesPackages == nil {
return nil, fmt.Errorf("missing Pages package %d", deploymentID)
}
return f.pagesPackages[deploymentID], nil
}
func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error {
f.reports = append(f.reports, payload)
return nil
@@ -179,6 +195,77 @@ func TestSyncOnceSuccess(t *testing.T) {
}
}
func TestSyncOnceDownloadsPagesDeploymentBeforeApply(t *testing.T) {
packageBytes := testPagesPackage(t, map[string]string{"index.html": "hello"})
checksum := testBytesChecksum(packageBytes)
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-101",
Checksum: "pages-config-checksum",
SourceConfigJSON: testPagesSourceConfigJSON(7, checksum),
CreatedAt: time.Now().Format(time.RFC3339),
},
pagesPackages: map[uint][]byte{7: packageBytes},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
nodeID, err := stateStore.EnsureNodeID()
if err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
snapshot, _ := stateStore.Load()
snapshot.NodeID = nodeID
if err = stateStore.Save(snapshot); err != nil {
t.Fatalf("save state failed: %v", err)
}
manager := &fakeManager{currentChecksum: "old-checksum"}
service := New(client, manager, stateStore)
pagesDir := t.TempDir()
service.SetPagesDir(pagesDir)
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: "20260309-101", Checksum: "pages-config-checksum"}); err != nil {
t.Fatalf("SyncOnce failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(pagesDir, "deployments", "7", "current", "index.html"))
if err != nil {
t.Fatalf("expected Pages file to be extracted: %v", err)
}
if string(data) != "hello" {
t.Fatalf("unexpected Pages file content: %s", string(data))
}
if len(manager.applyRouteContents) != 1 || !strings.Contains(manager.applyRouteContents[0], "__OPENFLARE_PAGES_DIR__/deployments/7/current") {
t.Fatalf("expected Pages placeholder in rendered route config, got %#v", manager.applyRouteContents)
}
}
func TestSyncOnceRejectsPagesZipSlipBeforeApply(t *testing.T) {
packageBytes := testPagesPackage(t, map[string]string{"../escape.html": "bad", "index.html": "ok"})
checksum := testBytesChecksum(packageBytes)
client := &fakeClient{
config: protocol.ActiveConfigResponse{
Version: "20260309-102",
Checksum: "pages-config-checksum",
SourceConfigJSON: testPagesSourceConfigJSON(8, checksum),
CreatedAt: time.Now().Format(time.RFC3339),
},
pagesPackages: map[uint][]byte{8: packageBytes},
}
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
if _, err := stateStore.EnsureNodeID(); err != nil {
t.Fatalf("EnsureNodeID failed: %v", err)
}
manager := &fakeManager{currentChecksum: "old-checksum"}
service := New(client, manager, stateStore)
service.SetPagesDir(t.TempDir())
err := service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: "20260309-102", Checksum: "pages-config-checksum"})
if err == nil || !strings.Contains(err.Error(), "escapes deployment root") {
t.Fatalf("expected zip-slip rejection, got %v", err)
}
if len(manager.applyRouteContents) != 0 {
t.Fatalf("OpenResty apply must not run after Pages package rejection")
}
}
func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
client := &fakeClient{
config: protocol.ActiveConfigResponse{
@@ -761,3 +848,27 @@ func TestSyncOnceSkipsFetchWhenHeartbeatChecksumMatches(t *testing.T) {
t.Fatal("expected no apply log when no config change is needed")
}
}
func testPagesPackage(t *testing.T, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for name, content := range files {
file, err := writer.Create(name)
if err != nil {
t.Fatalf("create zip file failed: %v", err)
}
if _, err := file.Write([]byte(content)); err != nil {
t.Fatalf("write zip file failed: %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close zip failed: %v", err)
}
return buffer.Bytes()
}
func testBytesChecksum(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}