From 4c0466a92b59ffa556fdc52110edc3e29d296c34 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 12 Mar 2026 22:26:06 +0800 Subject: [PATCH] feat: enhance configuration management with main and route configs - Added `main_config` and `route_config` fields to ActiveConfigResponse and AgentConfigResponse for better configuration handling. - Updated NginxManager interface to accept separate main and route configurations. - Modified sync service to apply main and route configurations correctly. - Enhanced tests to validate new configuration fields and their application. - Updated ConfigVersion model to include main configuration. - Improved rendering logic for main and route configurations in the service layer. - Added UI components to display main configuration changes and OpenResty parameter changes. --- atsf_agent/cmd/agent/main.go | 3 + atsf_agent/internal/config/config.go | 51 +- atsf_agent/internal/config/config_test.go | 10 + atsf_agent/internal/nginx/manager.go | 88 +++- atsf_agent/internal/nginx/manager_test.go | 75 ++- atsf_agent/internal/protocol/agent_api.go | 2 + atsf_agent/internal/sync/service.go | 8 +- atsf_agent/internal/sync/service_test.go | 36 +- atsf_server/model/config_version.go | 1 + atsf_server/model/option.go | 3 + atsf_server/router/api_phase1_test.go | 14 + atsf_server/service/agent.go | 4 + atsf_server/service/config_version.go | 447 +++++++++++++----- atsf_server/service/https_phase1_test.go | 23 + .../components/config-versions-page.tsx | 61 ++- .../web/features/config-versions/types.ts | 67 +-- 16 files changed, 713 insertions(+), 180 deletions(-) diff --git a/atsf_agent/cmd/agent/main.go b/atsf_agent/cmd/agent/main.go index 6fa015cc..5c08325e 100644 --- a/atsf_agent/cmd/agent/main.go +++ b/atsf_agent/cmd/agent/main.go @@ -32,6 +32,7 @@ func main() { DockerBinary: cfg.DockerBinary, ContainerName: cfg.OpenrestyContainerName, Image: cfg.OpenrestyDockerImage, + MainConfigPath: cfg.MainConfigPath, RouteConfigPath: cfg.RouteConfigPath, CertDir: cfg.CertDir, NginxCertDir: cfg.OpenrestyCertDir, @@ -42,6 +43,7 @@ func main() { client := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) stateStore := state.NewStore(cfg.StatePath) runtimeManager := &nginx.Manager{ + MainConfigPath: cfg.MainConfigPath, RouteConfigPath: cfg.RouteConfigPath, CertDir: cfg.CertDir, NginxCertDir: cfg.OpenrestyCertDir, @@ -50,6 +52,7 @@ func main() { DockerBinary: cfg.DockerBinary, ContainerName: cfg.OpenrestyContainerName, Image: cfg.OpenrestyDockerImage, + MainConfigPath: cfg.MainConfigPath, RouteConfigPath: cfg.RouteConfigPath, CertDir: cfg.CertDir, NginxCertDir: cfg.OpenrestyCertDir, diff --git a/atsf_agent/internal/config/config.go b/atsf_agent/internal/config/config.go index 84c46796..b5b00533 100644 --- a/atsf_agent/internal/config/config.go +++ b/atsf_agent/internal/config/config.go @@ -12,6 +12,7 @@ import ( ) const ( + defaultDockerMainConfigRelativePath = "etc/nginx/nginx.conf" defaultDockerRouteConfigRelativePath = "etc/nginx/conf.d/atsflare_routes.conf" defaultCertDirRelativePath = "etc/nginx/certs" defaultDockerStateRelativePath = "var/lib/atsflare/agent-state.json" @@ -19,26 +20,27 @@ const ( ) type Config struct { - ServerURL string `json:"server_url"` - AgentToken string `json:"agent_token"` - DiscoveryToken string `json:"discovery_token"` - NodeName string `json:"node_name"` - NodeIP string `json:"node_ip"` - AgentVersion string `json:"-"` - NginxVersion string `json:"-"` - OpenrestyPath string `json:"openresty_path"` - OpenrestyContainerName string `json:"openresty_container_name"` - OpenrestyDockerImage string `json:"openresty_docker_image"` - DockerBinary string `json:"docker_binary"` - DataDir string `json:"data_dir"` - RouteConfigPath string `json:"route_config_path"` - CertDir string `json:"cert_dir"` - OpenrestyCertDir string `json:"openresty_cert_dir"` - StatePath string `json:"state_path"` - HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"` - SyncInterval MillisecondDuration `json:"sync_interval"` - RequestTimeout MillisecondDuration `json:"request_timeout"` - configPath string + ServerURL string `json:"server_url"` + AgentToken string `json:"agent_token"` + DiscoveryToken string `json:"discovery_token"` + NodeName string `json:"node_name"` + NodeIP string `json:"node_ip"` + AgentVersion string `json:"-"` + NginxVersion string `json:"-"` + OpenrestyPath string `json:"openresty_path"` + OpenrestyContainerName string `json:"openresty_container_name"` + OpenrestyDockerImage string `json:"openresty_docker_image"` + DockerBinary string `json:"docker_binary"` + DataDir string `json:"data_dir"` + MainConfigPath string `json:"main_config_path"` + RouteConfigPath string `json:"route_config_path"` + CertDir string `json:"cert_dir"` + OpenrestyCertDir string `json:"openresty_cert_dir"` + StatePath string `json:"state_path"` + HeartbeatInterval MillisecondDuration `json:"heartbeat_interval"` + SyncInterval MillisecondDuration `json:"sync_interval"` + RequestTimeout MillisecondDuration `json:"request_timeout"` + configPath string } type configFile struct { @@ -52,6 +54,7 @@ type configFile struct { OpenrestyDockerImage string `json:"openresty_docker_image"` DockerBinary string `json:"docker_binary"` DataDir string `json:"data_dir"` + MainConfigPath string `json:"main_config_path"` RouteConfigPath string `json:"route_config_path"` CertDir string `json:"cert_dir"` OpenrestyCertDir string `json:"openresty_cert_dir"` @@ -81,6 +84,7 @@ func Load(path string) (*Config, error) { OpenrestyDockerImage: file.OpenrestyDockerImage, DockerBinary: file.DockerBinary, DataDir: file.DataDir, + MainConfigPath: file.MainConfigPath, RouteConfigPath: file.RouteConfigPath, CertDir: file.CertDir, OpenrestyCertDir: file.OpenrestyCertDir, @@ -119,9 +123,13 @@ func applyDefaults(cfg *Config, baseDir string) { cfg.NodeIP = detectNodeIP() } if cfg.OpenrestyPath == "" { + cfg.MainConfigPath = joinManagedPath(cfg.DataDir, defaultDockerMainConfigRelativePath) cfg.RouteConfigPath = joinManagedPath(cfg.DataDir, defaultDockerRouteConfigRelativePath) cfg.StatePath = joinManagedPath(cfg.DataDir, defaultDockerStateRelativePath) } else { + if cfg.MainConfigPath == "" { + cfg.MainConfigPath = joinManagedPath(cfg.DataDir, defaultDockerMainConfigRelativePath) + } if cfg.RouteConfigPath == "" { cfg.RouteConfigPath = joinManagedPath(cfg.DataDir, defaultDockerRouteConfigRelativePath) } @@ -158,6 +166,9 @@ func normalizeManagedPaths(cfg *Config) { if usesSlashPath(cfg.DataDir) { cfg.DataDir = filepath.ToSlash(cfg.DataDir) } + if usesSlashPath(cfg.MainConfigPath) { + cfg.MainConfigPath = filepath.ToSlash(cfg.MainConfigPath) + } if usesSlashPath(cfg.RouteConfigPath) { cfg.RouteConfigPath = filepath.ToSlash(cfg.RouteConfigPath) } diff --git a/atsf_agent/internal/config/config_test.go b/atsf_agent/internal/config/config_test.go index 24f3017a..34c9c32a 100644 --- a/atsf_agent/internal/config/config_test.go +++ b/atsf_agent/internal/config/config_test.go @@ -33,6 +33,9 @@ func TestLoadDockerModeUsesManagedPaths(t *testing.T) { if cfg.DataDir != filepath.Join(dir, "data") { t.Fatalf("unexpected data dir: %s", cfg.DataDir) } + if cfg.MainConfigPath != filepath.Join(dir, "data", defaultDockerMainConfigRelativePath) { + t.Fatalf("unexpected main config path: %s", cfg.MainConfigPath) + } if cfg.RouteConfigPath != filepath.Join(dir, "data", defaultDockerRouteConfigRelativePath) { t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath) } @@ -62,6 +65,7 @@ func TestLoadPathModeKeepsExplicitPaths(t *testing.T) { "node_name": "edge-01", "node_ip": "10.0.0.8", "openresty_path": "/usr/local/openresty/nginx/sbin/openresty", + "main_config_path": "/tmp/nginx.conf", "route_config_path": "/tmp/routes.conf", "state_path": "/tmp/agent-state.json", } @@ -78,6 +82,9 @@ func TestLoadPathModeKeepsExplicitPaths(t *testing.T) { t.Fatalf("Load failed: %v", err) } + if cfg.MainConfigPath != "/tmp/nginx.conf" { + t.Fatalf("unexpected main config path: %s", cfg.MainConfigPath) + } if cfg.RouteConfigPath != "/tmp/routes.conf" { t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath) } @@ -115,6 +122,9 @@ func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) { if cfg.RouteConfigPath != "/srv/atsflare/"+defaultDockerRouteConfigRelativePath { t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath) } + if cfg.MainConfigPath != "/srv/atsflare/"+defaultDockerMainConfigRelativePath { + t.Fatalf("unexpected main config path: %s", cfg.MainConfigPath) + } if cfg.StatePath != "/srv/atsflare/"+defaultDockerStateRelativePath { t.Fatalf("unexpected state path: %s", cfg.StatePath) } diff --git a/atsf_agent/internal/nginx/manager.go b/atsf_agent/internal/nginx/manager.go index 1a939a90..3ceb9baf 100644 --- a/atsf_agent/internal/nginx/manager.go +++ b/atsf_agent/internal/nginx/manager.go @@ -18,6 +18,8 @@ import ( ) const CertDirPlaceholder = "__ATSF_CERT_DIR__" +const RouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__" +const DockerMainConfigPath = "/usr/local/openresty/nginx/conf/nginx.conf" const dockerRuntimeCommand = "openresty" @@ -95,6 +97,7 @@ type DockerExecutor struct { DockerBinary string ContainerName string Image string + MainConfigPath string RouteConfigDir string CertDir string NginxCertDir string @@ -174,6 +177,7 @@ func (e *DockerExecutor) runContainer(ctx context.Context) error { "--name", e.ContainerName, "-p", "80:80", "-p", "443:443", + "-v", fmt.Sprintf("%s:%s", e.MainConfigPath, DockerMainConfigPath), "-v", fmt.Sprintf("%s:/etc/nginx/conf.d", e.RouteConfigDir), "-v", fmt.Sprintf("%s:%s", e.CertDir, e.NginxCertDir), e.Image, @@ -187,14 +191,15 @@ func (e *DockerExecutor) runContainer(ctx context.Context) error { } type Manager struct { + MainConfigPath string RouteConfigPath string CertDir string NginxCertDir string Executor Executor } -func (m *Manager) Apply(ctx context.Context, content string, supportFiles []protocol.SupportFile) error { - log.Printf("openresty apply started: route_config=%s support_files=%d", m.RouteConfigPath, len(supportFiles)) +func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error { + log.Printf("openresty apply started: main_config=%s route_config=%s support_files=%d", m.MainConfigPath, m.RouteConfigPath, len(supportFiles)) backup, err := m.backup() if err != nil { return err @@ -204,8 +209,14 @@ func (m *Manager) Apply(ctx context.Context, content string, supportFiles []prot _ = m.restore(backup) return err } - renderedContent := m.renderConfig(content) - if err = os.WriteFile(m.RouteConfigPath, []byte(renderedContent), 0o644); err != nil { + renderedMainConfig := m.renderMainConfig(mainConfig) + if err = os.WriteFile(m.MainConfigPath, []byte(renderedMainConfig), 0o644); err != nil { + log.Printf("writing openresty main config failed, restoring backup: error=%v", err) + _ = m.restore(backup) + return err + } + renderedRouteConfig := m.renderRouteConfig(routeConfig) + if err = os.WriteFile(m.RouteConfigPath, []byte(renderedRouteConfig), 0o644); err != nil { log.Printf("writing openresty route config failed, restoring backup: error=%v", err) _ = m.restore(backup) return err @@ -220,7 +231,7 @@ func (m *Manager) Apply(ctx context.Context, content string, supportFiles []prot _ = m.restore(backup) return err } - log.Printf("openresty apply completed successfully: route_config=%s", m.RouteConfigPath) + log.Printf("openresty apply completed successfully: main_config=%s route_config=%s", m.MainConfigPath, m.RouteConfigPath) return nil } @@ -251,6 +262,16 @@ func (m *Manager) CurrentChecksum() (string, error) { if m.RouteConfigPath == "" { return "", errors.New("route config path 不能为空") } + if m.MainConfigPath == "" { + return "", errors.New("main config path 不能为空") + } + mainData, err := os.ReadFile(m.MainConfigPath) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } data, err := os.ReadFile(m.RouteConfigPath) if err != nil { if os.IsNotExist(err) { @@ -258,16 +279,20 @@ func (m *Manager) CurrentChecksum() (string, error) { } return "", err } - normalized := string(data) + normalizedMain := string(mainData) + if m.RouteConfigPath != "" { + normalizedMain = strings.ReplaceAll(normalizedMain, m.RouteConfigPath, RouteConfigPlaceholder) + } + normalizedRoute := string(data) if m.NginxCertDir != "" { - normalized = strings.ReplaceAll(normalized, m.NginxCertDir, CertDirPlaceholder) + normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder) } files, err := m.readSupportFiles() if err != nil { return "", err } - result := bundleChecksum(normalized, files) - log.Printf("openresty current checksum calculated: route_config=%s checksum=%s support_files=%d", m.RouteConfigPath, result, len(files)) + result := bundleChecksum(normalizedMain, normalizedRoute, files) + log.Printf("openresty current checksum calculated: main_config=%s route_config=%s checksum=%s support_files=%d", m.MainConfigPath, m.RouteConfigPath, result, len(files)) return result, nil } @@ -276,6 +301,7 @@ type ExecutorOptions struct { DockerBinary string ContainerName string Image string + MainConfigPath string RouteConfigPath string CertDir string NginxCertDir string @@ -301,6 +327,7 @@ func NewExecutor(options ExecutorOptions) Executor { DockerBinary: options.DockerBinary, ContainerName: options.ContainerName, Image: options.Image, + MainConfigPath: options.MainConfigPath, RouteConfigDir: routeConfigDir, CertDir: certDir, NginxCertDir: options.NginxCertDir, @@ -371,6 +398,8 @@ func (e *DockerExecutor) runEphemeralRuntimeCommandWithBinary(ctx context.Contex "run", "--rm", "-v", + fmt.Sprintf("%s:%s", e.MainConfigPath, DockerMainConfigPath), + "-v", fmt.Sprintf("%s:/etc/nginx/conf.d", e.RouteConfigDir), "-v", fmt.Sprintf("%s:%s", e.CertDir, e.NginxCertDir), @@ -386,15 +415,23 @@ func runDockerVersionProbe(ctx context.Context, runner CommandRunner, dockerBina } type backupState struct { + MainExisted bool + MainData []byte RouteExisted bool RouteData []byte Files []protocol.SupportFile } func (m *Manager) backup() (*backupState, error) { + if m.MainConfigPath == "" { + return nil, errors.New("main config path 不能为空") + } if m.RouteConfigPath == "" { return nil, errors.New("route config path 不能为空") } + if err := os.MkdirAll(filepath.Dir(m.MainConfigPath), 0o755); err != nil { + return nil, err + } if err := os.MkdirAll(filepath.Dir(m.RouteConfigPath), 0o755); err != nil { return nil, err } @@ -404,6 +441,13 @@ func (m *Manager) backup() (*backupState, error) { } } state := &backupState{} + mainData, err := os.ReadFile(m.MainConfigPath) + if err == nil { + state.MainExisted = true + state.MainData = mainData + } else if !os.IsNotExist(err) { + return nil, err + } data, err := os.ReadFile(m.RouteConfigPath) if err == nil { state.RouteExisted = true @@ -416,7 +460,7 @@ func (m *Manager) backup() (*backupState, error) { return nil, err } state.Files = files - log.Printf("backup captured: route_exists=%t support_files=%d", state.RouteExisted, len(state.Files)) + log.Printf("backup captured: main_exists=%t route_exists=%t support_files=%d", state.MainExisted, state.RouteExisted, len(state.Files)) return state, nil } @@ -424,7 +468,14 @@ func (m *Manager) restore(state *backupState) error { if state == nil { return nil } - log.Printf("restoring nginx backup: route_existed=%t support_files=%d", state.RouteExisted, len(state.Files)) + log.Printf("restoring nginx backup: main_existed=%t route_existed=%t support_files=%d", state.MainExisted, state.RouteExisted, len(state.Files)) + if state.MainExisted { + if err := os.WriteFile(m.MainConfigPath, state.MainData, 0o644); err != nil { + return err + } + } else if err := os.Remove(m.MainConfigPath); err != nil && !os.IsNotExist(err) { + return err + } if state.RouteExisted { if err := os.WriteFile(m.RouteConfigPath, state.RouteData, 0o644); err != nil { return err @@ -516,25 +567,34 @@ func (m *Manager) readSupportFiles() ([]protocol.SupportFile, error) { return files, nil } -func (m *Manager) renderConfig(content string) string { +func (m *Manager) renderRouteConfig(content string) string { if m.NginxCertDir == "" { return content } return strings.ReplaceAll(content, CertDirPlaceholder, m.NginxCertDir) } +func (m *Manager) renderMainConfig(content string) string { + if m.RouteConfigPath == "" { + return content + } + return strings.ReplaceAll(content, RouteConfigPlaceholder, m.RouteConfigPath) +} + func checksum(content string) string { sum := sha256.Sum256([]byte(content)) return hex.EncodeToString(sum[:]) } -func bundleChecksum(renderedConfig string, supportFiles []protocol.SupportFile) string { +func bundleChecksum(mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) string { files := append([]protocol.SupportFile(nil), supportFiles...) sort.Slice(files, func(i int, j int) bool { return files[i].Path < files[j].Path }) var builder strings.Builder - builder.WriteString(renderedConfig) + builder.WriteString(mainConfig) + builder.WriteString("\n--route-config--\n") + builder.WriteString(routeConfig) builder.WriteString("\n--support-files--\n") for _, file := range files { builder.WriteString(file.Path) diff --git a/atsf_agent/internal/nginx/manager_test.go b/atsf_agent/internal/nginx/manager_test.go index bec72d6e..1a7f2241 100644 --- a/atsf_agent/internal/nginx/manager_test.go +++ b/atsf_agent/internal/nginx/manager_test.go @@ -119,6 +119,7 @@ func TestDockerExecutorCheckHealthFailsWhenContainerStopped(t *testing.T) { DockerBinary: "docker", ContainerName: "atsflare-openresty", Image: "openresty/openresty:alpine", + MainConfigPath: filepath.Clean("/tmp/nginx.conf"), RouteConfigDir: filepath.Clean("/tmp/routes"), CertDir: filepath.Clean("/tmp/certs"), NginxCertDir: "/etc/nginx/atsflare-certs", @@ -142,6 +143,7 @@ func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) { DockerBinary: "docker", ContainerName: "atsflare-openresty", Image: "openresty/openresty:alpine", + MainConfigPath: filepath.Clean("/tmp/nginx.conf"), RouteConfigDir: filepath.Clean("/tmp/routes"), CertDir: filepath.Clean("/tmp/certs"), NginxCertDir: "/etc/nginx/atsflare-certs", @@ -176,6 +178,7 @@ func TestDockerExecutorStartsStoppedContainer(t *testing.T) { DockerBinary: "docker", ContainerName: "atsflare-openresty", Image: "openresty/openresty:alpine", + MainConfigPath: filepath.Clean("/tmp/nginx.conf"), RouteConfigDir: filepath.Clean("/tmp/routes"), CertDir: filepath.Clean("/tmp/certs"), NginxCertDir: "/etc/nginx/atsflare-certs", @@ -213,6 +216,7 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) { DockerBinary: "docker", ContainerName: "atsflare-openresty", Image: "openresty/openresty:alpine", + MainConfigPath: filepath.Clean("/tmp/nginx.conf"), RouteConfigDir: filepath.Clean("/tmp/routes"), CertDir: filepath.Clean("/tmp/certs"), NginxCertDir: "/etc/nginx/atsflare-certs", @@ -238,6 +242,7 @@ func TestNewExecutorUsesAbsoluteDockerMountPath(t *testing.T) { DockerBinary: "docker", ContainerName: "atsflare-openresty", Image: "openresty/openresty:alpine", + MainConfigPath: "./data/etc/nginx/nginx.conf", RouteConfigPath: "./data/etc/nginx/conf.d/atsflare_routes.conf", CertDir: "./data/etc/nginx/certs", NginxCertDir: "/etc/nginx/atsflare-certs", @@ -271,6 +276,59 @@ func TestDetectVersionFromBinary(t *testing.T) { } } +func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) { + tempDir := t.TempDir() + mainPath := filepath.Join(tempDir, "nginx.conf") + routePath := filepath.Join(tempDir, "conf.d", "atsflare_routes.conf") + certDir := filepath.Join(tempDir, "certs") + manager := &Manager{ + MainConfigPath: mainPath, + RouteConfigPath: routePath, + CertDir: certDir, + NginxCertDir: "/etc/nginx/atsflare-certs", + Executor: &fakeExecutor{}, + } + + err := manager.Apply( + context.Background(), + "include __ATSF_ROUTE_CONFIG__;\n", + "ssl_certificate __ATSF_CERT_DIR__/1.crt;\n", + []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, + ) + if err != nil { + t.Fatalf("Apply failed: %v", err) + } + + mainData, err := os.ReadFile(mainPath) + if err != nil { + t.Fatalf("failed to read main config: %v", err) + } + if string(mainData) != "include "+routePath+";\n" { + t.Fatalf("unexpected main config: %s", string(mainData)) + } + + routeData, err := os.ReadFile(routePath) + if err != nil { + t.Fatalf("failed to read route config: %v", err) + } + if string(routeData) != "ssl_certificate /etc/nginx/atsflare-certs/1.crt;\n" { + t.Fatalf("unexpected route config: %s", string(routeData)) + } + + value, err := manager.CurrentChecksum() + if err != nil { + t.Fatalf("CurrentChecksum failed: %v", err) + } + expected := bundleChecksum( + "include __ATSF_ROUTE_CONFIG__;\n", + "ssl_certificate __ATSF_CERT_DIR__/1.crt;\n", + []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, + ) + if value != expected { + t.Fatalf("unexpected checksum: got %s want %s", value, expected) + } +} + func TestDetectVersionFromDockerImage(t *testing.T) { runner := &fakeRunner{ runFn: func(name string, args ...string) ([]byte, error) { @@ -311,13 +369,14 @@ func TestParseNginxVersionIgnoresDockerEntrypointPaths(t *testing.T) { func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) { tempDir := t.TempDir() manager := &Manager{ + MainConfigPath: filepath.Join(tempDir, "nginx.conf"), RouteConfigPath: filepath.Join(tempDir, "routes.conf"), CertDir: filepath.Join(tempDir, "certs"), NginxCertDir: "/etc/nginx/atsflare-certs", Executor: &fakeExecutor{}, } - err := manager.Apply(context.Background(), "ssl_certificate __ATSF_CERT_DIR__/1.crt;", []protocol.SupportFile{ + err := manager.Apply(context.Background(), "include __ATSF_ROUTE_CONFIG__;", "ssl_certificate __ATSF_CERT_DIR__/1.crt;", []protocol.SupportFile{ {Path: "1.crt", Content: "cert-data"}, {Path: "1.key", Content: "key-data"}, }) @@ -344,10 +403,14 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) { func TestManagerRollbackRestoresSupportFiles(t *testing.T) { tempDir := t.TempDir() routePath := filepath.Join(tempDir, "routes.conf") + mainPath := filepath.Join(tempDir, "nginx.conf") certDir := filepath.Join(tempDir, "certs") if err := os.MkdirAll(certDir, 0o755); err != nil { t.Fatalf("MkdirAll failed: %v", err) } + if err := os.WriteFile(mainPath, []byte("old-main"), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } if err := os.WriteFile(routePath, []byte("old-route"), 0o644); err != nil { t.Fatalf("WriteFile failed: %v", err) } @@ -355,6 +418,7 @@ func TestManagerRollbackRestoresSupportFiles(t *testing.T) { t.Fatalf("WriteFile failed: %v", err) } manager := &Manager{ + MainConfigPath: mainPath, RouteConfigPath: routePath, CertDir: certDir, NginxCertDir: "/etc/nginx/atsflare-certs", @@ -363,13 +427,20 @@ func TestManagerRollbackRestoresSupportFiles(t *testing.T) { }, } - err := manager.Apply(context.Background(), "new-route", []protocol.SupportFile{ + err := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{ {Path: "1.crt", Content: "new-cert"}, }) if err == nil { t.Fatal("expected Apply to fail") } + mainData, err := os.ReadFile(mainPath) + if err != nil { + t.Fatalf("failed to read main config: %v", err) + } + if string(mainData) != "old-main" { + t.Fatalf("expected main rollback, got %s", string(mainData)) + } routeData, err := os.ReadFile(routePath) if err != nil { t.Fatalf("failed to read route config: %v", err) diff --git a/atsf_agent/internal/protocol/agent_api.go b/atsf_agent/internal/protocol/agent_api.go index a5e5872b..6f8f42bc 100644 --- a/atsf_agent/internal/protocol/agent_api.go +++ b/atsf_agent/internal/protocol/agent_api.go @@ -58,6 +58,8 @@ type ApplyLogPayload struct { type ActiveConfigResponse struct { Version string `json:"version"` Checksum string `json:"checksum"` + MainConfig string `json:"main_config"` + RouteConfig string `json:"route_config"` RenderedConfig string `json:"rendered_config"` SupportFiles []SupportFile `json:"support_files"` CreatedAt string `json:"created_at"` diff --git a/atsf_agent/internal/sync/service.go b/atsf_agent/internal/sync/service.go index 827dd6f8..a45828b7 100644 --- a/atsf_agent/internal/sync/service.go +++ b/atsf_agent/internal/sync/service.go @@ -19,7 +19,7 @@ type ConfigClient interface { } type NginxManager interface { - Apply(ctx context.Context, content string, supportFiles []protocol.SupportFile) error + Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error EnsureRuntime(ctx context.Context, recreate bool) error CurrentChecksum() (string, error) } @@ -88,8 +88,12 @@ func (s *Service) sync(ctx context.Context, startup bool) error { log.Printf("skipping apply because state already records target version/checksum: version=%s checksum=%s", config.Version, config.Checksum) return nil } + routeConfig := config.RouteConfig + if routeConfig == "" { + routeConfig = config.RenderedConfig + } log.Printf("applying new openresty config: mode=%s from_version=%s to_version=%s old_checksum=%s new_checksum=%s", mode, snapshot.CurrentVersion, config.Version, currentChecksum, config.Checksum) - if err = s.nginxManager.Apply(ctx, config.RenderedConfig, config.SupportFiles); err != nil { + if err = s.nginxManager.Apply(ctx, config.MainConfig, routeConfig, config.SupportFiles); err != nil { log.Printf("apply openresty config failed: mode=%s version=%s error=%v", mode, config.Version, err) snapshot.LastError = err.Error() snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy diff --git a/atsf_agent/internal/sync/service_test.go b/atsf_agent/internal/sync/service_test.go index f868f50f..f9150b59 100644 --- a/atsf_agent/internal/sync/service_test.go +++ b/atsf_agent/internal/sync/service_test.go @@ -28,7 +28,8 @@ type fakeManager struct { currentChecksumErr error ensureErr error ensureCalls []bool - applyContents []string + applyMainContents []string + applyRouteContents []string applyFiles [][]protocol.SupportFile } @@ -61,8 +62,9 @@ func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyL return nil } -func (m *fakeManager) Apply(ctx context.Context, content string, supportFiles []protocol.SupportFile) error { - m.applyContents = append(m.applyContents, content) +func (m *fakeManager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error { + m.applyMainContents = append(m.applyMainContents, mainConfig) + m.applyRouteContents = append(m.applyRouteContents, routeConfig) m.applyFiles = append(m.applyFiles, append([]protocol.SupportFile(nil), supportFiles...)) return m.applyErr } @@ -81,6 +83,8 @@ func TestSyncOnceSuccess(t *testing.T) { config: protocol.ActiveConfigResponse{ Version: "20260309-001", Checksum: "checksum-1", + MainConfig: "worker_processes auto;", + RouteConfig: "server { listen 80; }", RenderedConfig: "server { listen 80; }", SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, CreatedAt: time.Now().Format(time.RFC3339), @@ -100,6 +104,7 @@ func TestSyncOnceSuccess(t *testing.T) { routePath := filepath.Join(t.TempDir(), "routes.conf") service := New(client, &nginx.Manager{ + MainConfigPath: filepath.Join(filepath.Dir(routePath), "nginx.conf"), RouteConfigPath: routePath, Executor: &fakeExecutor{}, }, stateStore) @@ -115,6 +120,13 @@ func TestSyncOnceSuccess(t *testing.T) { if string(data) != "server { listen 80; }" { t.Fatal("expected rendered config to be written to route file") } + mainData, err := os.ReadFile(filepath.Join(filepath.Dir(routePath), "nginx.conf")) + if err != nil { + t.Fatalf("failed to read main config: %v", err) + } + if string(mainData) != "worker_processes auto;" { + t.Fatal("expected main config to be written") + } snapshot, err = stateStore.Load() if err != nil { t.Fatalf("failed to load state: %v", err) @@ -132,6 +144,8 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) { config: protocol.ActiveConfigResponse{ Version: "20260309-002", Checksum: "checksum-2", + MainConfig: "worker_processes 2;", + RouteConfig: "server { listen 81; }", RenderedConfig: "server { listen 81; }", SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, CreatedAt: time.Now().Format(time.RFC3339), @@ -139,7 +153,11 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) { } tempDir := t.TempDir() + mainPath := filepath.Join(tempDir, "nginx.conf") routePath := filepath.Join(tempDir, "routes.conf") + if err := os.WriteFile(mainPath, []byte("worker_processes auto;"), 0o644); err != nil { + t.Fatalf("failed to seed main file: %v", err) + } if err := os.WriteFile(routePath, []byte("server { listen 80; }"), 0o644); err != nil { t.Fatalf("failed to seed route file: %v", err) } @@ -158,6 +176,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) { } service := New(client, &nginx.Manager{ + MainConfigPath: mainPath, RouteConfigPath: routePath, Executor: &fakeExecutor{ testErr: context.DeadlineExceeded, @@ -176,6 +195,13 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) { if string(data) != "server { listen 80; }" { t.Fatal("expected original route config to be restored after rollback") } + mainData, readErr := os.ReadFile(mainPath) + if readErr != nil { + t.Fatalf("failed to read main file after rollback: %v", readErr) + } + if string(mainData) != "worker_processes auto;" { + t.Fatal("expected original main config to be restored after rollback") + } snapshot, loadErr := stateStore.Load() if loadErr != nil { t.Fatalf("failed to load state: %v", loadErr) @@ -193,6 +219,8 @@ func TestSyncOnStartupRecreatesRuntimeWhenChecksumMatches(t *testing.T) { config: protocol.ActiveConfigResponse{ Version: "20260309-003", Checksum: "checksum-3", + MainConfig: "worker_processes auto;", + RouteConfig: "server { listen 82; }", RenderedConfig: "server { listen 82; }", SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, CreatedAt: time.Now().Format(time.RFC3339), @@ -235,6 +263,8 @@ func TestSyncOnStartupRecordsRuntimeFailure(t *testing.T) { config: protocol.ActiveConfigResponse{ Version: "20260309-004", Checksum: "checksum-4", + MainConfig: "worker_processes 4;", + RouteConfig: "server { listen 83; }", RenderedConfig: "server { listen 83; }", CreatedAt: time.Now().Format(time.RFC3339), }, diff --git a/atsf_server/model/config_version.go b/atsf_server/model/config_version.go index 75f2ca09..3670e142 100644 --- a/atsf_server/model/config_version.go +++ b/atsf_server/model/config_version.go @@ -6,6 +6,7 @@ type ConfigVersion struct { ID uint `json:"id" gorm:"primaryKey"` Version string `json:"version" gorm:"uniqueIndex;size:32;not null"` SnapshotJSON string `json:"snapshot_json" gorm:"type:text;not null"` + MainConfig string `json:"main_config" gorm:"type:text;not null;default:''"` RenderedConfig string `json:"rendered_config" gorm:"type:text;not null"` SupportFilesJSON string `json:"support_files_json" gorm:"type:text;not null;default:'[]'"` Checksum string `json:"checksum" gorm:"size:64;not null"` diff --git a/atsf_server/model/option.go b/atsf_server/model/option.go index 021f2f1c..48c95b1b 100644 --- a/atsf_server/model/option.go +++ b/atsf_server/model/option.go @@ -121,6 +121,9 @@ func UpdateOption(key string, value string) error { func updateOptionMap(key string, value string) { common.OptionMapRWMutex.Lock() defer common.OptionMapRWMutex.Unlock() + if common.OptionMap == nil { + common.OptionMap = make(map[string]string) + } common.OptionMap[key] = value if strings.HasSuffix(key, "Permission") { intValue, _ := strconv.Atoi(value) diff --git a/atsf_server/router/api_phase1_test.go b/atsf_server/router/api_phase1_test.go index 1d7ed6cb..a060bfeb 100644 --- a/atsf_server/router/api_phase1_test.go +++ b/atsf_server/router/api_phase1_test.go @@ -72,6 +72,9 @@ func TestPhase1PublishLifecycle(t *testing.T) { if version1.SnapshotJSON == "" || version1.RenderedConfig == "" || version1.Checksum == "" { t.Fatal("expected published version to contain snapshot, rendered config and checksum") } + if version1.MainConfig == "" { + t.Fatal("expected published version to contain main config") + } repeatPublishReq := httptest.NewRequest(http.MethodPost, "/api/config-versions/publish", nil) repeatPublishReq.Header.Set("Authorization", "Bearer "+token) @@ -92,6 +95,7 @@ func TestPhase1PublishLifecycle(t *testing.T) { } initialSnapshot := version1.SnapshotJSON + initialMainConfig := version1.MainConfig initialRendered := version1.RenderedConfig updateBody := map[string]any{ @@ -142,6 +146,9 @@ func TestPhase1PublishLifecycle(t *testing.T) { if storedVersion1.SnapshotJSON != initialSnapshot { t.Fatal("expected version1 snapshot to remain immutable") } + if storedVersion1.MainConfig != initialMainConfig { + t.Fatal("expected version1 main config to remain immutable") + } if storedVersion1.RenderedConfig != initialRendered { t.Fatal("expected version1 rendered config to remain immutable") } @@ -257,6 +264,9 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) { resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil) var version model.ConfigVersion decodeResponseData(t, resp, &version) + if !strings.Contains(version.MainConfig, "include __ATSF_ROUTE_CONFIG__;") { + t.Fatal("expected active config to render managed main config") + } if !strings.Contains(version.RenderedConfig, "listen 443 ssl;") { t.Fatal("expected active config to render https listener") } @@ -282,6 +292,10 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) { agentResp := performAgentJSONRequestWithToken(t, engine, common.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil) var activeConfig map[string]any decodeResponseData(t, agentResp, &activeConfig) + mainConfig, ok := activeConfig["main_config"].(string) + if !ok || !strings.Contains(mainConfig, "include __ATSF_ROUTE_CONFIG__;") { + t.Fatalf("expected active config to expose main_config, got %#v", activeConfig["main_config"]) + } supportFiles, ok := activeConfig["support_files"].([]any) if !ok || len(supportFiles) != 2 { t.Fatalf("expected active config to expose 2 support files, got %#v", activeConfig["support_files"]) diff --git a/atsf_server/service/agent.go b/atsf_server/service/agent.go index f2601f84..f1ab3a33 100644 --- a/atsf_server/service/agent.go +++ b/atsf_server/service/agent.go @@ -44,6 +44,8 @@ type ApplyLogPayload struct { type AgentConfigResponse struct { Version string `json:"version"` Checksum string `json:"checksum"` + MainConfig string `json:"main_config"` + RouteConfig string `json:"route_config"` RenderedConfig string `json:"rendered_config"` SupportFiles []SupportFile `json:"support_files"` CreatedAt time.Time `json:"created_at"` @@ -145,6 +147,8 @@ func GetActiveConfigForAgent() (*AgentConfigResponse, error) { return &AgentConfigResponse{ Version: version.Version, Checksum: version.Checksum, + MainConfig: version.MainConfig, + RouteConfig: version.RenderedConfig, RenderedConfig: version.RenderedConfig, SupportFiles: supportFiles, CreatedAt: version.CreatedAt, diff --git a/atsf_server/service/config_version.go b/atsf_server/service/config_version.go index 6402bdf8..7080a839 100644 --- a/atsf_server/service/config_version.go +++ b/atsf_server/service/config_version.go @@ -1,6 +1,7 @@ package service import ( + "atsflare/common" "atsflare/model" "crypto/sha256" "encoding/hex" @@ -26,6 +27,8 @@ type SupportFile struct { type ConfigPreviewResult struct { SnapshotJSON string `json:"snapshot_json"` + MainConfig string `json:"main_config"` + RouteConfig string `json:"route_config"` RenderedConfig string `json:"rendered_config"` SupportFiles []SupportFile `json:"support_files"` Checksum string `json:"checksum"` @@ -33,10 +36,12 @@ type ConfigPreviewResult struct { } type ConfigDiffResult struct { - ActiveVersion string `json:"active_version,omitempty"` - AddedDomains []string `json:"added_domains"` - RemovedDomains []string `json:"removed_domains"` - ModifiedDomains []string `json:"modified_domains"` + ActiveVersion string `json:"active_version,omitempty"` + AddedDomains []string `json:"added_domains"` + RemovedDomains []string `json:"removed_domains"` + ModifiedDomains []string `json:"modified_domains"` + MainConfigChanged bool `json:"main_config_changed"` + ChangedOptionKeys []string `json:"changed_option_keys"` } type snapshotRoute struct { @@ -50,16 +55,59 @@ type snapshotRoute struct { Remark string `json:"remark,omitempty"` } -type configBundle struct { - Routes []*model.ProxyRoute - SnapshotRoutes []snapshotRoute - SnapshotJSON string - RenderedConfig string - SupportFiles []SupportFile - Checksum string +type openRestyConfigSnapshot struct { + WorkerProcesses string `json:"worker_processes"` + WorkerConnections int `json:"worker_connections"` + WorkerRlimitNofile int `json:"worker_rlimit_nofile"` + EventsUse string `json:"events_use,omitempty"` + EventsMultiAcceptEnabled bool `json:"events_multi_accept_enabled"` + KeepaliveTimeout int `json:"keepalive_timeout"` + KeepaliveRequests int `json:"keepalive_requests"` + ClientHeaderTimeout int `json:"client_header_timeout"` + ClientBodyTimeout int `json:"client_body_timeout"` + SendTimeout int `json:"send_timeout"` + ProxyConnectTimeout int `json:"proxy_connect_timeout"` + ProxySendTimeout int `json:"proxy_send_timeout"` + ProxyReadTimeout int `json:"proxy_read_timeout"` + ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"` + ProxyBuffers string `json:"proxy_buffers"` + ProxyBufferSize string `json:"proxy_buffer_size"` + ProxyBusyBuffersSize string `json:"proxy_busy_buffers_size"` + GzipEnabled bool `json:"gzip_enabled"` + GzipMinLength int `json:"gzip_min_length"` + GzipCompLevel int `json:"gzip_comp_level"` + CacheEnabled bool `json:"cache_enabled"` + CachePath string `json:"cache_path,omitempty"` + CacheLevels string `json:"cache_levels"` + CacheInactive string `json:"cache_inactive"` + CacheMaxSize string `json:"cache_max_size"` + CacheKeyTemplate string `json:"cache_key_template"` + CacheLockEnabled bool `json:"cache_lock_enabled"` + CacheLockTimeout string `json:"cache_lock_timeout"` + CacheUseStale string `json:"cache_use_stale"` } -const nginxCertDirPlaceholder = "__ATSF_CERT_DIR__" +type snapshotDocument struct { + Routes []snapshotRoute `json:"routes"` + OpenRestyConfig openRestyConfigSnapshot `json:"openresty_config"` +} + +type configBundle struct { + Routes []*model.ProxyRoute + SnapshotRoutes []snapshotRoute + OpenRestyConfig openRestyConfigSnapshot + SnapshotJSON string + MainConfig string + RouteConfig string + SupportFiles []SupportFile + Checksum string + ChangedOptionKeys []string +} + +const ( + nginxCertDirPlaceholder = "__ATSF_CERT_DIR__" + nginxRouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__" +) func ListConfigVersions() ([]*model.ConfigVersion, error) { return model.ListConfigVersions() @@ -76,7 +124,9 @@ func PreviewConfigVersion() (*ConfigPreviewResult, error) { } return &ConfigPreviewResult{ SnapshotJSON: bundle.SnapshotJSON, - RenderedConfig: bundle.RenderedConfig, + MainConfig: bundle.MainConfig, + RouteConfig: bundle.RouteConfig, + RenderedConfig: bundle.RouteConfig, SupportFiles: bundle.SupportFiles, Checksum: bundle.Checksum, RouteCount: len(bundle.Routes), @@ -89,9 +139,10 @@ func DiffConfigVersion() (*ConfigDiffResult, error) { return nil, err } result := &ConfigDiffResult{ - AddedDomains: []string{}, - RemovedDomains: []string{}, - ModifiedDomains: []string{}, + AddedDomains: []string{}, + RemovedDomains: []string{}, + ModifiedDomains: []string{}, + ChangedOptionKeys: []string{}, } activeVersion, err := model.GetActiveConfigVersion() if err != nil { @@ -99,12 +150,14 @@ func DiffConfigVersion() (*ConfigDiffResult, error) { for _, route := range bundle.SnapshotRoutes { result.AddedDomains = append(result.AddedDomains, route.Domain) } + result.MainConfigChanged = true + result.ChangedOptionKeys = openRestyOptionKeys() return result, nil } return nil, err } result.ActiveVersion = activeVersion.Version - activeRoutes, err := parseSnapshotRoutes(activeVersion.SnapshotJSON) + activeSnapshot, err := parseSnapshotDocument(activeVersion.SnapshotJSON) if err != nil { return nil, err } @@ -112,8 +165,8 @@ func DiffConfigVersion() (*ConfigDiffResult, error) { for _, route := range bundle.SnapshotRoutes { currentMap[route.Domain] = route } - activeMap := make(map[string]snapshotRoute, len(activeRoutes)) - for _, route := range activeRoutes { + activeMap := make(map[string]snapshotRoute, len(activeSnapshot.Routes)) + for _, route := range activeSnapshot.Routes { activeMap[route.Domain] = route } for domain, currentRoute := range currentMap { @@ -131,9 +184,12 @@ func DiffConfigVersion() (*ConfigDiffResult, error) { result.RemovedDomains = append(result.RemovedDomains, domain) } } + result.MainConfigChanged = activeVersion.MainConfig != bundle.MainConfig + result.ChangedOptionKeys = diffOpenRestyOptionKeys(activeSnapshot.OpenRestyConfig, bundle.OpenRestyConfig) sort.Strings(result.AddedDomains) sort.Strings(result.RemovedDomains) sort.Strings(result.ModifiedDomains) + sort.Strings(result.ChangedOptionKeys) return result, nil } @@ -178,7 +234,8 @@ func PublishConfigVersion(createdBy string) (*ReleaseResult, error) { record := &model.ConfigVersion{ Version: version, SnapshotJSON: bundle.SnapshotJSON, - RenderedConfig: bundle.RenderedConfig, + MainConfig: bundle.MainConfig, + RenderedConfig: bundle.RouteConfig, SupportFilesJSON: string(supportFilesJSON), Checksum: bundle.Checksum, IsActive: true, @@ -226,19 +283,222 @@ func ActivateConfigVersion(id uint) (*model.ConfigVersion, error) { return version, nil } -func renderSnapshot(routes []*model.ProxyRoute) (string, error) { - items, err := buildSnapshotRoutes(routes) +func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) { + routes, err := model.GetEnabledProxyRoutes() if err != nil { - return "", err + return nil, err } - data, err := json.Marshal(items) + if requireRoutes && len(routes) == 0 { + return nil, errors.New("没有可发布的启用规则") + } + snapshotRoutes, err := buildSnapshotRoutes(routes) if err != nil { - return "", err + return nil, err } - return string(data), nil + openRestyConfig := buildOpenRestyConfigSnapshot() + snapshotDoc := snapshotDocument{ + Routes: snapshotRoutes, + OpenRestyConfig: openRestyConfig, + } + snapshotJSON, err := json.Marshal(snapshotDoc) + if err != nil { + return nil, err + } + routeConfig, supportFiles, err := renderRouteConfig(routes) + if err != nil { + return nil, err + } + mainConfig := renderMainConfig(openRestyConfig) + return &configBundle{ + Routes: routes, + SnapshotRoutes: snapshotRoutes, + OpenRestyConfig: openRestyConfig, + SnapshotJSON: string(snapshotJSON), + MainConfig: mainConfig, + RouteConfig: routeConfig, + SupportFiles: supportFiles, + Checksum: checksumBundle(mainConfig, routeConfig, supportFiles), + ChangedOptionKeys: openRestyOptionKeys(), + }, nil } -func renderNginxConfig(routes []*model.ProxyRoute) (string, []SupportFile, error) { +func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) { + items := make([]snapshotRoute, 0, len(routes)) + for _, route := range routes { + customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders) + if err != nil { + return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain) + } + items = append(items, snapshotRoute{ + Domain: route.Domain, + OriginURL: route.OriginURL, + Enabled: route.Enabled, + EnableHTTPS: route.EnableHTTPS, + CertID: route.CertID, + RedirectHTTP: route.RedirectHTTP, + CustomHeaders: customHeaders, + Remark: route.Remark, + }) + } + return items, nil +} + +func parseSnapshotDocument(snapshotJSON string) (*snapshotDocument, error) { + text := strings.TrimSpace(snapshotJSON) + if text == "" { + return &snapshotDocument{Routes: []snapshotRoute{}}, nil + } + if strings.HasPrefix(text, "[") { + var routes []snapshotRoute + if err := json.Unmarshal([]byte(text), &routes); err != nil { + return nil, errors.New("历史版本快照格式不合法") + } + return &snapshotDocument{Routes: normalizeSnapshotRoutes(routes)}, nil + } + var snapshot snapshotDocument + if err := json.Unmarshal([]byte(text), &snapshot); err != nil { + return nil, errors.New("历史版本快照格式不合法") + } + snapshot.Routes = normalizeSnapshotRoutes(snapshot.Routes) + return &snapshot, nil +} + +func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute { + if len(routes) == 0 { + return []snapshotRoute{} + } + for index := range routes { + normalizedHeaders, err := normalizeCustomHeaders(routes[index].CustomHeaders) + if err == nil { + routes[index].CustomHeaders = normalizedHeaders + } + } + return routes +} + +func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool { + if left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || !uintPointerEqual(left.CertID, right.CertID) { + return false + } + if len(left.CustomHeaders) != len(right.CustomHeaders) { + return false + } + for index := range left.CustomHeaders { + if left.CustomHeaders[index] != right.CustomHeaders[index] { + return false + } + } + return true +} + +func buildOpenRestyConfigSnapshot() openRestyConfigSnapshot { + return openRestyConfigSnapshot{ + WorkerProcesses: common.OpenRestyWorkerProcesses, + WorkerConnections: common.OpenRestyWorkerConnections, + WorkerRlimitNofile: common.OpenRestyWorkerRlimitNofile, + EventsUse: common.OpenRestyEventsUse, + EventsMultiAcceptEnabled: common.OpenRestyEventsMultiAcceptEnabled, + KeepaliveTimeout: common.OpenRestyKeepaliveTimeout, + KeepaliveRequests: common.OpenRestyKeepaliveRequests, + ClientHeaderTimeout: common.OpenRestyClientHeaderTimeout, + ClientBodyTimeout: common.OpenRestyClientBodyTimeout, + SendTimeout: common.OpenRestySendTimeout, + ProxyConnectTimeout: common.OpenRestyProxyConnectTimeout, + ProxySendTimeout: common.OpenRestyProxySendTimeout, + ProxyReadTimeout: common.OpenRestyProxyReadTimeout, + ProxyBufferingEnabled: common.OpenRestyProxyBufferingEnabled, + ProxyBuffers: common.OpenRestyProxyBuffers, + ProxyBufferSize: common.OpenRestyProxyBufferSize, + ProxyBusyBuffersSize: common.OpenRestyProxyBusyBuffersSize, + GzipEnabled: common.OpenRestyGzipEnabled, + GzipMinLength: common.OpenRestyGzipMinLength, + GzipCompLevel: common.OpenRestyGzipCompLevel, + CacheEnabled: common.OpenRestyCacheEnabled, + CachePath: common.OpenRestyCachePath, + CacheLevels: common.OpenRestyCacheLevels, + CacheInactive: common.OpenRestyCacheInactive, + CacheMaxSize: common.OpenRestyCacheMaxSize, + CacheKeyTemplate: common.OpenRestyCacheKeyTemplate, + CacheLockEnabled: common.OpenRestyCacheLockEnabled, + CacheLockTimeout: common.OpenRestyCacheLockTimeout, + CacheUseStale: common.OpenRestyCacheUseStale, + } +} + +func diffOpenRestyOptionKeys(left openRestyConfigSnapshot, right openRestyConfigSnapshot) []string { + changes := make([]string, 0) + appendIfChanged := func(key string, changed bool) { + if changed { + changes = append(changes, key) + } + } + appendIfChanged("OpenRestyWorkerProcesses", left.WorkerProcesses != right.WorkerProcesses) + appendIfChanged("OpenRestyWorkerConnections", left.WorkerConnections != right.WorkerConnections) + appendIfChanged("OpenRestyWorkerRlimitNofile", left.WorkerRlimitNofile != right.WorkerRlimitNofile) + appendIfChanged("OpenRestyEventsUse", left.EventsUse != right.EventsUse) + appendIfChanged("OpenRestyEventsMultiAcceptEnabled", left.EventsMultiAcceptEnabled != right.EventsMultiAcceptEnabled) + appendIfChanged("OpenRestyKeepaliveTimeout", left.KeepaliveTimeout != right.KeepaliveTimeout) + appendIfChanged("OpenRestyKeepaliveRequests", left.KeepaliveRequests != right.KeepaliveRequests) + appendIfChanged("OpenRestyClientHeaderTimeout", left.ClientHeaderTimeout != right.ClientHeaderTimeout) + appendIfChanged("OpenRestyClientBodyTimeout", left.ClientBodyTimeout != right.ClientBodyTimeout) + appendIfChanged("OpenRestySendTimeout", left.SendTimeout != right.SendTimeout) + appendIfChanged("OpenRestyProxyConnectTimeout", left.ProxyConnectTimeout != right.ProxyConnectTimeout) + appendIfChanged("OpenRestyProxySendTimeout", left.ProxySendTimeout != right.ProxySendTimeout) + appendIfChanged("OpenRestyProxyReadTimeout", left.ProxyReadTimeout != right.ProxyReadTimeout) + appendIfChanged("OpenRestyProxyBufferingEnabled", left.ProxyBufferingEnabled != right.ProxyBufferingEnabled) + appendIfChanged("OpenRestyProxyBuffers", left.ProxyBuffers != right.ProxyBuffers) + appendIfChanged("OpenRestyProxyBufferSize", left.ProxyBufferSize != right.ProxyBufferSize) + appendIfChanged("OpenRestyProxyBusyBuffersSize", left.ProxyBusyBuffersSize != right.ProxyBusyBuffersSize) + appendIfChanged("OpenRestyGzipEnabled", left.GzipEnabled != right.GzipEnabled) + appendIfChanged("OpenRestyGzipMinLength", left.GzipMinLength != right.GzipMinLength) + appendIfChanged("OpenRestyGzipCompLevel", left.GzipCompLevel != right.GzipCompLevel) + appendIfChanged("OpenRestyCacheEnabled", left.CacheEnabled != right.CacheEnabled) + appendIfChanged("OpenRestyCachePath", left.CachePath != right.CachePath) + appendIfChanged("OpenRestyCacheLevels", left.CacheLevels != right.CacheLevels) + appendIfChanged("OpenRestyCacheInactive", left.CacheInactive != right.CacheInactive) + appendIfChanged("OpenRestyCacheMaxSize", left.CacheMaxSize != right.CacheMaxSize) + appendIfChanged("OpenRestyCacheKeyTemplate", left.CacheKeyTemplate != right.CacheKeyTemplate) + appendIfChanged("OpenRestyCacheLockEnabled", left.CacheLockEnabled != right.CacheLockEnabled) + appendIfChanged("OpenRestyCacheLockTimeout", left.CacheLockTimeout != right.CacheLockTimeout) + appendIfChanged("OpenRestyCacheUseStale", left.CacheUseStale != right.CacheUseStale) + return changes +} + +func openRestyOptionKeys() []string { + return []string{ + "OpenRestyWorkerProcesses", + "OpenRestyWorkerConnections", + "OpenRestyWorkerRlimitNofile", + "OpenRestyEventsUse", + "OpenRestyEventsMultiAcceptEnabled", + "OpenRestyKeepaliveTimeout", + "OpenRestyKeepaliveRequests", + "OpenRestyClientHeaderTimeout", + "OpenRestyClientBodyTimeout", + "OpenRestySendTimeout", + "OpenRestyProxyConnectTimeout", + "OpenRestyProxySendTimeout", + "OpenRestyProxyReadTimeout", + "OpenRestyProxyBufferingEnabled", + "OpenRestyProxyBuffers", + "OpenRestyProxyBufferSize", + "OpenRestyProxyBusyBuffersSize", + "OpenRestyGzipEnabled", + "OpenRestyGzipMinLength", + "OpenRestyGzipCompLevel", + "OpenRestyCacheEnabled", + "OpenRestyCachePath", + "OpenRestyCacheLevels", + "OpenRestyCacheInactive", + "OpenRestyCacheMaxSize", + "OpenRestyCacheKeyTemplate", + "OpenRestyCacheLockEnabled", + "OpenRestyCacheLockTimeout", + "OpenRestyCacheUseStale", + } +} + +func renderRouteConfig(routes []*model.ProxyRoute) (string, []SupportFile, error) { var builder strings.Builder builder.WriteString("# This file is generated by ATSFlare. Do not edit manually.\n") supportFiles := make([]SupportFile, 0) @@ -272,89 +532,59 @@ func renderNginxConfig(routes []*model.ProxyRoute) (string, []SupportFile, error return builder.String(), dedupeSupportFiles(supportFiles), nil } -func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) { - routes, err := model.GetEnabledProxyRoutes() - if err != nil { - return nil, err +func renderMainConfig(cfg openRestyConfigSnapshot) string { + var builder strings.Builder + builder.WriteString("# This file is generated by ATSFlare. Do not edit manually.\n") + builder.WriteString(fmt.Sprintf("worker_processes %s;\n", cfg.WorkerProcesses)) + builder.WriteString(fmt.Sprintf("worker_rlimit_nofile %d;\n", cfg.WorkerRlimitNofile)) + builder.WriteString("pid logs/nginx.pid;\n\n") + builder.WriteString("events {\n") + builder.WriteString(fmt.Sprintf(" worker_connections %d;\n", cfg.WorkerConnections)) + if strings.TrimSpace(cfg.EventsUse) != "" { + builder.WriteString(fmt.Sprintf(" use %s;\n", cfg.EventsUse)) } - if requireRoutes && len(routes) == 0 { - return nil, errors.New("没有可发布的启用规则") + if cfg.EventsMultiAcceptEnabled { + builder.WriteString(" multi_accept on;\n") } - snapshotRoutes, err := buildSnapshotRoutes(routes) - if err != nil { - return nil, err + builder.WriteString("}\n\n") + builder.WriteString("http {\n") + builder.WriteString(" include mime.types;\n") + builder.WriteString(" default_type application/octet-stream;\n") + builder.WriteString(" sendfile on;\n") + builder.WriteString(" tcp_nopush on;\n") + builder.WriteString(" tcp_nodelay on;\n") + builder.WriteString(fmt.Sprintf(" keepalive_timeout %d;\n", cfg.KeepaliveTimeout)) + builder.WriteString(fmt.Sprintf(" keepalive_requests %d;\n", cfg.KeepaliveRequests)) + builder.WriteString(fmt.Sprintf(" client_header_timeout %d;\n", cfg.ClientHeaderTimeout)) + builder.WriteString(fmt.Sprintf(" client_body_timeout %d;\n", cfg.ClientBodyTimeout)) + builder.WriteString(fmt.Sprintf(" send_timeout %d;\n", cfg.SendTimeout)) + builder.WriteString(fmt.Sprintf(" proxy_connect_timeout %d;\n", cfg.ProxyConnectTimeout)) + builder.WriteString(fmt.Sprintf(" proxy_send_timeout %d;\n", cfg.ProxySendTimeout)) + builder.WriteString(fmt.Sprintf(" proxy_read_timeout %d;\n", cfg.ProxyReadTimeout)) + builder.WriteString(fmt.Sprintf(" proxy_buffering %s;\n", onOff(cfg.ProxyBufferingEnabled))) + builder.WriteString(fmt.Sprintf(" proxy_buffers %s;\n", cfg.ProxyBuffers)) + builder.WriteString(fmt.Sprintf(" proxy_buffer_size %s;\n", cfg.ProxyBufferSize)) + builder.WriteString(fmt.Sprintf(" proxy_busy_buffers_size %s;\n", cfg.ProxyBusyBuffersSize)) + builder.WriteString(fmt.Sprintf(" gzip %s;\n", onOff(cfg.GzipEnabled))) + builder.WriteString(fmt.Sprintf(" gzip_min_length %d;\n", cfg.GzipMinLength)) + builder.WriteString(fmt.Sprintf(" gzip_comp_level %d;\n", cfg.GzipCompLevel)) + if cfg.CacheEnabled { + builder.WriteString(fmt.Sprintf(" proxy_cache_path %s levels=%s keys_zone=atsflare_cache:10m inactive=%s max_size=%s;\n", cfg.CachePath, cfg.CacheLevels, cfg.CacheInactive, cfg.CacheMaxSize)) + builder.WriteString(fmt.Sprintf(" proxy_cache_key \"%s\";\n", cfg.CacheKeyTemplate)) + builder.WriteString(fmt.Sprintf(" proxy_cache_lock %s;\n", onOff(cfg.CacheLockEnabled))) + builder.WriteString(fmt.Sprintf(" proxy_cache_lock_timeout %s;\n", cfg.CacheLockTimeout)) + builder.WriteString(fmt.Sprintf(" proxy_cache_use_stale %s;\n", cfg.CacheUseStale)) } - snapshotJSON, err := json.Marshal(snapshotRoutes) - if err != nil { - return nil, err - } - renderedConfig, supportFiles, err := renderNginxConfig(routes) - if err != nil { - return nil, err - } - return &configBundle{ - Routes: routes, - SnapshotRoutes: snapshotRoutes, - SnapshotJSON: string(snapshotJSON), - RenderedConfig: renderedConfig, - SupportFiles: supportFiles, - Checksum: checksumBundle(renderedConfig, supportFiles), - }, nil + builder.WriteString(fmt.Sprintf(" include %s;\n", nginxRouteConfigPlaceholder)) + builder.WriteString("}\n") + return builder.String() } -func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) { - items := make([]snapshotRoute, 0, len(routes)) - for _, route := range routes { - customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders) - if err != nil { - return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain) - } - items = append(items, snapshotRoute{ - Domain: route.Domain, - OriginURL: route.OriginURL, - Enabled: route.Enabled, - EnableHTTPS: route.EnableHTTPS, - CertID: route.CertID, - RedirectHTTP: route.RedirectHTTP, - CustomHeaders: customHeaders, - Remark: route.Remark, - }) +func onOff(value bool) string { + if value { + return "on" } - return items, nil -} - -func parseSnapshotRoutes(snapshotJSON string) ([]snapshotRoute, error) { - text := strings.TrimSpace(snapshotJSON) - if text == "" { - return []snapshotRoute{}, nil - } - var routes []snapshotRoute - if err := json.Unmarshal([]byte(text), &routes); err != nil { - return nil, errors.New("历史版本快照格式不合法") - } - for index := range routes { - normalizedHeaders, err := normalizeCustomHeaders(routes[index].CustomHeaders) - if err != nil { - return nil, err - } - routes[index].CustomHeaders = normalizedHeaders - } - return routes, nil -} - -func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool { - if left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || !uintPointerEqual(left.CertID, right.CertID) { - return false - } - if len(left.CustomHeaders) != len(right.CustomHeaders) { - return false - } - for index := range left.CustomHeaders { - if left.CustomHeaders[index] != right.CustomHeaders[index] { - return false - } - } - return true + return "off" } func uintPointerEqual(left *uint, right *uint) bool { @@ -369,9 +599,11 @@ func checksum(content string) string { return hex.EncodeToString(sum[:]) } -func checksumBundle(renderedConfig string, supportFiles []SupportFile) string { +func checksumBundle(mainConfig string, routeConfig string, supportFiles []SupportFile) string { var builder strings.Builder - builder.WriteString(renderedConfig) + builder.WriteString(mainConfig) + builder.WriteString("\n--route-config--\n") + builder.WriteString(routeConfig) builder.WriteString("\n--support-files--\n") files := dedupeSupportFiles(supportFiles) sort.Slice(files, func(i int, j int) bool { @@ -418,6 +650,9 @@ func renderProxyHeaderBlock(customHeaders []ProxyRouteCustomHeaderInput) string for _, header := range customHeaders { builder.WriteString(fmt.Sprintf(" proxy_set_header %s %s;\n", header.Key, quoteNginxHeaderValue(header.Value))) } + if common.OpenRestyCacheEnabled { + builder.WriteString(" proxy_cache atsflare_cache;\n") + } return builder.String() } diff --git a/atsf_server/service/https_phase1_test.go b/atsf_server/service/https_phase1_test.go index 4945506e..229457da 100644 --- a/atsf_server/service/https_phase1_test.go +++ b/atsf_server/service/https_phase1_test.go @@ -51,6 +51,9 @@ func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) { if err != nil { t.Fatalf("PublishConfigVersion failed: %v", err) } + if !strings.Contains(result.Version.MainConfig, "include __ATSF_ROUTE_CONFIG__;") { + t.Fatal("expected main config to include managed route config placeholder") + } if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;") { t.Fatal("expected rendered config to include https server block") } @@ -178,6 +181,9 @@ func TestPreviewAndDiffConfigVersion(t *testing.T) { if err != nil { t.Fatalf("PreviewConfigVersion failed: %v", err) } + if !strings.Contains(preview.MainConfig, "include __ATSF_ROUTE_CONFIG__;") { + t.Fatal("expected preview main config to include managed route config placeholder") + } if !strings.Contains(preview.RenderedConfig, `proxy_set_header X-Release "candidate";`) { t.Fatal("expected preview config to include modified custom header") } @@ -198,6 +204,23 @@ func TestPreviewAndDiffConfigVersion(t *testing.T) { if len(diff.ModifiedDomains) != 1 || diff.ModifiedDomains[0] != "api.example.com" { t.Fatalf("unexpected modified domains: %#v", diff.ModifiedDomains) } + if diff.MainConfigChanged { + t.Fatal("expected main config to remain unchanged when only routes change") + } + + if err = model.UpdateOption("OpenRestyProxyReadTimeout", "120"); err != nil { + t.Fatalf("UpdateOption failed: %v", err) + } + diff, err = DiffConfigVersion() + if err != nil { + t.Fatalf("DiffConfigVersion after option change failed: %v", err) + } + if !diff.MainConfigChanged { + t.Fatal("expected main config change after OpenResty option update") + } + if len(diff.ChangedOptionKeys) == 0 || diff.ChangedOptionKeys[0] == "" { + t.Fatal("expected changed OpenResty option keys to be reported") + } } func TestCreateTLSCertificateRejectsInvalidPEM(t *testing.T) { diff --git a/atsf_server/web/features/config-versions/components/config-versions-page.tsx b/atsf_server/web/features/config-versions/components/config-versions-page.tsx index 95c63915..6ab355ff 100644 --- a/atsf_server/web/features/config-versions/components/config-versions-page.tsx +++ b/atsf_server/web/features/config-versions/components/config-versions-page.tsx @@ -55,6 +55,8 @@ function hasConfigDiff(diff: ConfigDiffResult) { diff.added_domains.length > 0 || diff.removed_domains.length > 0 || diff.modified_domains.length > 0 || + diff.main_config_changed || + diff.changed_option_keys.length > 0 || !diff.active_version ); } @@ -180,7 +182,15 @@ function SnapshotModal({

- 渲染结果 + 主配置 +

+ + {version.main_config} + +
+
+

+ 路由配置

{version.rendered_config} @@ -252,6 +262,14 @@ function PublishPreviewCard({ {diff.modified_domains.length}

+
+

+ 主配置变化 +

+

+ {diff.main_config_changed ? '已变化' : '无变化'} +

+
{!canPublish ? ( @@ -267,15 +285,54 @@ function PublishPreviewCard({ +
+
+

+ OpenResty 参数变化 +

+ 0 ? 'info' : 'warning'} + /> +
+ {diff.changed_option_keys.length > 0 ? ( +
+ {diff.changed_option_keys.map((item) => ( + + {item} + + ))} +
+ ) : ( +

+ 当前无 OpenResty 性能参数变化。 +

+ )} +
+

- 渲染结果 + 主配置

Checksum:{preview.checksum}

+ + {preview.main_config} + +
+ +
+
+

+ 路由配置 +

+
{preview.rendered_config} diff --git a/atsf_server/web/features/config-versions/types.ts b/atsf_server/web/features/config-versions/types.ts index bac83205..73b5c902 100644 --- a/atsf_server/web/features/config-versions/types.ts +++ b/atsf_server/web/features/config-versions/types.ts @@ -1,31 +1,36 @@ -export interface ConfigVersionItem { - id: number; - version: string; - snapshot_json: string; - rendered_config: string; - support_files_json: string; - checksum: string; - is_active: boolean; - created_by: string; - created_at: string; -} - -export interface SupportFile { - path: string; - content: string; -} - -export interface ConfigPreviewResult { - snapshot_json: string; - rendered_config: string; - support_files: SupportFile[]; - checksum: string; - route_count: number; -} - -export interface ConfigDiffResult { - active_version?: string; - added_domains: string[]; - removed_domains: string[]; - modified_domains: string[]; -} +export interface ConfigVersionItem { + id: number; + version: string; + snapshot_json: string; + main_config: string; + rendered_config: string; + support_files_json: string; + checksum: string; + is_active: boolean; + created_by: string; + created_at: string; +} + +export interface SupportFile { + path: string; + content: string; +} + +export interface ConfigPreviewResult { + snapshot_json: string; + main_config: string; + route_config: string; + rendered_config: string; + support_files: SupportFile[]; + checksum: string; + route_count: number; +} + +export interface ConfigDiffResult { + active_version?: string; + added_domains: string[]; + removed_domains: string[]; + modified_domains: string[]; + main_config_changed: boolean; + changed_option_keys: string[]; +}