mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
feat: add TLS certificate management functionality
- Implemented TLS certificate model and service for managing certificates. - Added API endpoints for creating, importing, listing, and deleting TLS certificates. - Enhanced proxy route configuration to support HTTPS with certificate selection. - Updated frontend to include TLS certificate management UI with manual and file import options. - Added validation for HTTPS routes to ensure certificates are selected. - Implemented tests for TLS certificate creation and proxy route validation.
This commit is contained in:
@@ -33,12 +33,16 @@ func main() {
|
||||
HeartbeatService: heartbeat.New(client),
|
||||
SyncService: syncservice.New(client, &nginx.Manager{
|
||||
RouteConfigPath: cfg.RouteConfigPath,
|
||||
CertDir: cfg.CertDir,
|
||||
NginxCertDir: cfg.NginxCertDir,
|
||||
Executor: nginx.NewExecutor(nginx.ExecutorOptions{
|
||||
NginxPath: cfg.NginxPath,
|
||||
DockerBinary: cfg.DockerBinary,
|
||||
ContainerName: cfg.NginxContainerName,
|
||||
Image: cfg.NginxDockerImage,
|
||||
RouteConfigPath: cfg.RouteConfigPath,
|
||||
CertDir: cfg.CertDir,
|
||||
NginxCertDir: cfg.NginxCertDir,
|
||||
}),
|
||||
}, stateStore),
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
|
||||
const (
|
||||
defaultDockerRouteConfigRelativePath = "etc/nginx/conf.d/atsflare_routes.conf"
|
||||
defaultCertDirRelativePath = "etc/nginx/certs"
|
||||
defaultDockerStateRelativePath = "var/lib/atsflare/agent-state.json"
|
||||
defaultDockerNginxCertDir = "/etc/nginx/atsflare-certs"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -26,6 +28,8 @@ type Config struct {
|
||||
DockerBinary string `json:"docker_binary"`
|
||||
DataDir string `json:"data_dir"`
|
||||
RouteConfigPath string `json:"route_config_path"`
|
||||
CertDir string `json:"cert_dir"`
|
||||
NginxCertDir string `json:"nginx_cert_dir"`
|
||||
StatePath string `json:"state_path"`
|
||||
HeartbeatInterval time.Duration `json:"heartbeat_interval"`
|
||||
SyncInterval time.Duration `json:"sync_interval"`
|
||||
@@ -76,6 +80,16 @@ func applyDefaults(cfg *Config, baseDir string) {
|
||||
cfg.StatePath = filepath.Join(cfg.DataDir, defaultDockerStateRelativePath)
|
||||
}
|
||||
}
|
||||
if cfg.CertDir == "" {
|
||||
cfg.CertDir = filepath.Join(cfg.DataDir, defaultCertDirRelativePath)
|
||||
}
|
||||
if cfg.NginxCertDir == "" {
|
||||
if cfg.NginxPath != "" {
|
||||
cfg.NginxCertDir = cfg.CertDir
|
||||
} else {
|
||||
cfg.NginxCertDir = defaultDockerNginxCertDir
|
||||
}
|
||||
}
|
||||
if cfg.HeartbeatInterval <= 0 {
|
||||
cfg.HeartbeatInterval = 30 * time.Second
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ func TestLoadDockerModeUsesManagedPaths(t *testing.T) {
|
||||
if cfg.RouteConfigPath != filepath.Join(dir, "data", defaultDockerRouteConfigRelativePath) {
|
||||
t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath)
|
||||
}
|
||||
if cfg.CertDir != filepath.Join(dir, "data", defaultCertDirRelativePath) {
|
||||
t.Fatalf("unexpected cert dir: %s", cfg.CertDir)
|
||||
}
|
||||
if cfg.NginxCertDir != defaultDockerNginxCertDir {
|
||||
t.Fatalf("unexpected nginx cert dir: %s", cfg.NginxCertDir)
|
||||
}
|
||||
if cfg.StatePath != filepath.Join(dir, "data", defaultDockerStateRelativePath) {
|
||||
t.Fatalf("unexpected state path: %s", cfg.StatePath)
|
||||
}
|
||||
@@ -72,6 +78,9 @@ func TestLoadPathModeKeepsExplicitPaths(t *testing.T) {
|
||||
if cfg.StatePath != "/tmp/agent-state.json" {
|
||||
t.Fatalf("unexpected state path: %s", cfg.StatePath)
|
||||
}
|
||||
if cfg.NginxCertDir != cfg.CertDir {
|
||||
t.Fatalf("expected path mode nginx cert dir to equal cert dir, got %s / %s", cfg.NginxCertDir, cfg.CertDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
|
||||
@@ -104,4 +113,7 @@ func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
|
||||
if cfg.StatePath != "/srv/atsflare/"+defaultDockerStateRelativePath {
|
||||
t.Fatalf("unexpected state path: %s", cfg.StatePath)
|
||||
}
|
||||
if cfg.CertDir != "/srv/atsflare/"+defaultCertDirRelativePath {
|
||||
t.Fatalf("unexpected cert dir: %s", cfg.CertDir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,14 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"atsflare-agent/internal/protocol"
|
||||
)
|
||||
|
||||
const CertDirPlaceholder = "__ATSF_CERT_DIR__"
|
||||
|
||||
type Executor interface {
|
||||
Test(ctx context.Context) error
|
||||
Reload(ctx context.Context) error
|
||||
@@ -60,6 +65,8 @@ type DockerExecutor struct {
|
||||
ContainerName string
|
||||
Image string
|
||||
RouteConfigDir string
|
||||
CertDir string
|
||||
NginxCertDir string
|
||||
Runner CommandRunner
|
||||
}
|
||||
|
||||
@@ -71,6 +78,8 @@ func (e *DockerExecutor) Test(ctx context.Context) error {
|
||||
"--rm",
|
||||
"-v",
|
||||
fmt.Sprintf("%s:/etc/nginx/conf.d", e.RouteConfigDir),
|
||||
"-v",
|
||||
fmt.Sprintf("%s:%s", e.CertDir, e.NginxCertDir),
|
||||
e.Image,
|
||||
"nginx",
|
||||
"-t",
|
||||
@@ -124,6 +133,7 @@ func (e *DockerExecutor) runContainer(ctx context.Context) error {
|
||||
"-p", "80:80",
|
||||
"-p", "443:443",
|
||||
"-v", fmt.Sprintf("%s:/etc/nginx/conf.d", e.RouteConfigDir),
|
||||
"-v", fmt.Sprintf("%s:%s", e.CertDir, e.NginxCertDir),
|
||||
e.Image,
|
||||
}
|
||||
runOutput, runErr := e.Runner.Run(ctx, e.DockerBinary, runArgs...)
|
||||
@@ -135,28 +145,33 @@ func (e *DockerExecutor) runContainer(ctx context.Context) error {
|
||||
|
||||
type Manager struct {
|
||||
RouteConfigPath string
|
||||
CertDir string
|
||||
NginxCertDir string
|
||||
Executor Executor
|
||||
}
|
||||
|
||||
func (m *Manager) Apply(ctx context.Context, content string) error {
|
||||
backupPath, hadExisting, err := m.backup()
|
||||
func (m *Manager) Apply(ctx context.Context, content string, supportFiles []protocol.SupportFile) error {
|
||||
backup, err := m.backup()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.WriteFile(m.RouteConfigPath, []byte(content), 0o644); err != nil {
|
||||
if err = m.writeSupportFiles(supportFiles); err != nil {
|
||||
_ = m.restore(backup)
|
||||
return err
|
||||
}
|
||||
renderedContent := m.renderConfig(content)
|
||||
if err = os.WriteFile(m.RouteConfigPath, []byte(renderedContent), 0o644); err != nil {
|
||||
_ = m.restore(backup)
|
||||
return err
|
||||
}
|
||||
if err = m.Executor.Test(ctx); err != nil {
|
||||
_ = m.restore(backupPath, hadExisting)
|
||||
_ = m.restore(backup)
|
||||
return err
|
||||
}
|
||||
if err = m.Executor.Reload(ctx); err != nil {
|
||||
_ = m.restore(backupPath, hadExisting)
|
||||
_ = m.restore(backup)
|
||||
return err
|
||||
}
|
||||
if backupPath != "" {
|
||||
_ = os.Remove(backupPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -178,8 +193,15 @@ func (m *Manager) CurrentChecksum() (string, error) {
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
normalized := string(data)
|
||||
if m.NginxCertDir != "" {
|
||||
normalized = strings.ReplaceAll(normalized, m.NginxCertDir, CertDirPlaceholder)
|
||||
}
|
||||
files, err := m.readSupportFiles()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return bundleChecksum(normalized, files), nil
|
||||
}
|
||||
|
||||
type ExecutorOptions struct {
|
||||
@@ -188,6 +210,8 @@ type ExecutorOptions struct {
|
||||
ContainerName string
|
||||
Image string
|
||||
RouteConfigPath string
|
||||
CertDir string
|
||||
NginxCertDir string
|
||||
}
|
||||
|
||||
func NewExecutor(options ExecutorOptions) Executor {
|
||||
@@ -202,46 +226,167 @@ func NewExecutor(options ExecutorOptions) Executor {
|
||||
if absDir, err := filepath.Abs(routeConfigDir); err == nil {
|
||||
routeConfigDir = absDir
|
||||
}
|
||||
certDir := options.CertDir
|
||||
if absDir, err := filepath.Abs(certDir); err == nil {
|
||||
certDir = absDir
|
||||
}
|
||||
return &DockerExecutor{
|
||||
DockerBinary: options.DockerBinary,
|
||||
ContainerName: options.ContainerName,
|
||||
Image: options.Image,
|
||||
RouteConfigDir: routeConfigDir,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: options.NginxCertDir,
|
||||
Runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) backup() (string, bool, error) {
|
||||
if m.RouteConfigPath == "" {
|
||||
return "", false, errors.New("route config path 不能为空")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(m.RouteConfigPath), 0o755); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
data, err := os.ReadFile(m.RouteConfigPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
backupPath := m.RouteConfigPath + ".bak"
|
||||
if err = os.WriteFile(backupPath, data, 0o644); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return backupPath, true, nil
|
||||
type backupState struct {
|
||||
RouteExisted bool
|
||||
RouteData []byte
|
||||
Files []protocol.SupportFile
|
||||
}
|
||||
|
||||
func (m *Manager) restore(backupPath string, hadExisting bool) error {
|
||||
if hadExisting {
|
||||
data, err := os.ReadFile(backupPath)
|
||||
if err != nil {
|
||||
func (m *Manager) backup() (*backupState, error) {
|
||||
if m.RouteConfigPath == "" {
|
||||
return nil, errors.New("route config path 不能为空")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(m.RouteConfigPath), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(m.CertDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := &backupState{}
|
||||
data, err := os.ReadFile(m.RouteConfigPath)
|
||||
if err == nil {
|
||||
state.RouteExisted = true
|
||||
state.RouteData = data
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
files, err := m.readSupportFiles()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.Files = files
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (m *Manager) restore(state *backupState) error {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
if state.RouteExisted {
|
||||
if err := os.WriteFile(m.RouteConfigPath, state.RouteData, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(m.RouteConfigPath, data, 0o644)
|
||||
}
|
||||
if err := os.Remove(m.RouteConfigPath); err != nil && !os.IsNotExist(err) {
|
||||
} else if err := os.Remove(m.RouteConfigPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(m.CertDir); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(m.CertDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, file := range state.Files {
|
||||
targetPath := filepath.Join(m.CertDir, filepath.Clean(file.Path))
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(targetPath, []byte(file.Content), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) writeSupportFiles(supportFiles []protocol.SupportFile) error {
|
||||
if err := os.RemoveAll(m.CertDir); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(m.CertDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, file := range supportFiles {
|
||||
targetPath := filepath.Join(m.CertDir, filepath.Clean(file.Path))
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(targetPath, []byte(file.Content), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) readSupportFiles() ([]protocol.SupportFile, error) {
|
||||
if m.CertDir == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := os.Stat(m.CertDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
files := make([]protocol.SupportFile, 0)
|
||||
err := filepath.Walk(m.CertDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relativePath, err := filepath.Rel(m.CertDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, protocol.SupportFile{
|
||||
Path: filepath.ToSlash(relativePath),
|
||||
Content: string(data),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(files, func(i int, j int) bool {
|
||||
return files[i].Path < files[j].Path
|
||||
})
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (m *Manager) renderConfig(content string) string {
|
||||
if m.NginxCertDir == "" {
|
||||
return content
|
||||
}
|
||||
return strings.ReplaceAll(content, CertDirPlaceholder, m.NginxCertDir)
|
||||
}
|
||||
|
||||
func checksum(content string) string {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func bundleChecksum(renderedConfig 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("\n--support-files--\n")
|
||||
for _, file := range files {
|
||||
builder.WriteString(file.Path)
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(file.Content)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
return checksum(builder.String())
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ package nginx
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"atsflare-agent/internal/protocol"
|
||||
)
|
||||
|
||||
type runCall struct {
|
||||
@@ -19,6 +22,11 @@ type fakeRunner struct {
|
||||
runFn func(name string, args ...string) ([]byte, error)
|
||||
}
|
||||
|
||||
type fakeExecutor struct {
|
||||
testErr error
|
||||
reloadErr error
|
||||
}
|
||||
|
||||
func (r *fakeRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
r.calls = append(r.calls, runCall{name: name, args: append([]string{}, args...)})
|
||||
if r.runFn != nil {
|
||||
@@ -27,6 +35,18 @@ func (r *fakeRunner) Run(ctx context.Context, name string, args ...string) ([]by
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (e *fakeExecutor) Test(ctx context.Context) error {
|
||||
return e.testErr
|
||||
}
|
||||
|
||||
func (e *fakeExecutor) Reload(ctx context.Context) error {
|
||||
return e.reloadErr
|
||||
}
|
||||
|
||||
func (e *fakeExecutor) EnsureRuntime(ctx context.Context, recreate bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPathExecutorCommands(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
executor := &PathExecutor{
|
||||
@@ -74,6 +94,8 @@ func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) {
|
||||
ContainerName: "atsflare-nginx",
|
||||
Image: "nginx:stable-alpine",
|
||||
RouteConfigDir: filepath.Clean("/tmp/routes"),
|
||||
CertDir: filepath.Clean("/tmp/certs"),
|
||||
NginxCertDir: "/etc/nginx/atsflare-certs",
|
||||
Runner: runner,
|
||||
}
|
||||
|
||||
@@ -103,6 +125,8 @@ func TestDockerExecutorStartsStoppedContainer(t *testing.T) {
|
||||
ContainerName: "atsflare-nginx",
|
||||
Image: "nginx:stable-alpine",
|
||||
RouteConfigDir: filepath.Clean("/tmp/routes"),
|
||||
CertDir: filepath.Clean("/tmp/certs"),
|
||||
NginxCertDir: "/etc/nginx/atsflare-certs",
|
||||
Runner: runner,
|
||||
}
|
||||
|
||||
@@ -138,6 +162,8 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
|
||||
ContainerName: "atsflare-nginx",
|
||||
Image: "nginx:stable-alpine",
|
||||
RouteConfigDir: filepath.Clean("/tmp/routes"),
|
||||
CertDir: filepath.Clean("/tmp/certs"),
|
||||
NginxCertDir: "/etc/nginx/atsflare-certs",
|
||||
Runner: runner,
|
||||
}
|
||||
|
||||
@@ -161,6 +187,8 @@ func TestNewExecutorUsesAbsoluteDockerMountPath(t *testing.T) {
|
||||
ContainerName: "atsflare-nginx",
|
||||
Image: "nginx:stable-alpine",
|
||||
RouteConfigPath: "./data/etc/nginx/conf.d/atsflare_routes.conf",
|
||||
CertDir: "./data/etc/nginx/certs",
|
||||
NginxCertDir: "/etc/nginx/atsflare-certs",
|
||||
})
|
||||
|
||||
dockerExecutor, ok := executor.(*DockerExecutor)
|
||||
@@ -174,3 +202,81 @@ func TestNewExecutorUsesAbsoluteDockerMountPath(t *testing.T) {
|
||||
t.Fatalf("unexpected route config dir: %s", dockerExecutor.RouteConfigDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
manager := &Manager{
|
||||
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{
|
||||
{Path: "1.crt", Content: "cert-data"},
|
||||
{Path: "1.key", Content: "key-data"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply failed: %v", err)
|
||||
}
|
||||
|
||||
routeData, err := os.ReadFile(manager.RouteConfigPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read route config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(routeData), "/etc/nginx/atsflare-certs/1.crt") {
|
||||
t.Fatalf("expected placeholder replacement in route config, got %s", string(routeData))
|
||||
}
|
||||
certData, err := os.ReadFile(filepath.Join(manager.CertDir, "1.crt"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read cert file: %v", err)
|
||||
}
|
||||
if string(certData) != "cert-data" {
|
||||
t.Fatalf("unexpected cert file content: %s", string(certData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRollbackRestoresSupportFiles(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
routePath := filepath.Join(tempDir, "routes.conf")
|
||||
certDir := filepath.Join(tempDir, "certs")
|
||||
if err := os.MkdirAll(certDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll failed: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(routePath, []byte("old-route"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(certDir, "1.crt"), []byte("old-cert"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
manager := &Manager{
|
||||
RouteConfigPath: routePath,
|
||||
CertDir: certDir,
|
||||
NginxCertDir: "/etc/nginx/atsflare-certs",
|
||||
Executor: &fakeExecutor{
|
||||
testErr: errors.New("nginx test failed"),
|
||||
},
|
||||
}
|
||||
|
||||
err := manager.Apply(context.Background(), "new-route", []protocol.SupportFile{
|
||||
{Path: "1.crt", Content: "new-cert"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected Apply to fail")
|
||||
}
|
||||
|
||||
routeData, err := os.ReadFile(routePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read route config: %v", err)
|
||||
}
|
||||
if string(routeData) != "old-route" {
|
||||
t.Fatalf("expected route rollback, got %s", string(routeData))
|
||||
}
|
||||
certData, err := os.ReadFile(filepath.Join(certDir, "1.crt"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read cert file: %v", err)
|
||||
}
|
||||
if string(certData) != "old-cert" {
|
||||
t.Fatalf("expected cert rollback, got %s", string(certData))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,14 @@ type ApplyLogPayload struct {
|
||||
}
|
||||
|
||||
type ActiveConfigResponse struct {
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
RenderedConfig string `json:"rendered_config"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
RenderedConfig string `json:"rendered_config"`
|
||||
SupportFiles []SupportFile `json:"support_files"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type SupportFile struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type ConfigClient interface {
|
||||
}
|
||||
|
||||
type NginxManager interface {
|
||||
Apply(ctx context.Context, content string) error
|
||||
Apply(ctx context.Context, content string, supportFiles []protocol.SupportFile) error
|
||||
EnsureRuntime(ctx context.Context, recreate bool) error
|
||||
CurrentChecksum() (string, error)
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (s *Service) sync(ctx context.Context, startup bool) error {
|
||||
if snapshot.CurrentVersion == config.Version && snapshot.CurrentChecksum == config.Checksum && !startup {
|
||||
return nil
|
||||
}
|
||||
if err = s.nginxManager.Apply(ctx, config.RenderedConfig); err != nil {
|
||||
if err = s.nginxManager.Apply(ctx, config.RenderedConfig, config.SupportFiles); err != nil {
|
||||
snapshot.LastError = err.Error()
|
||||
_ = s.stateStore.Save(snapshot)
|
||||
reportErr := s.client.ReportApplyLog(ctx, protocol.ApplyLogPayload{
|
||||
|
||||
@@ -28,6 +28,7 @@ type fakeManager struct {
|
||||
currentChecksumErr error
|
||||
ensureCalls []bool
|
||||
applyContents []string
|
||||
applyFiles [][]protocol.SupportFile
|
||||
}
|
||||
|
||||
func (f *fakeExecutor) Test(ctx context.Context) error {
|
||||
@@ -51,8 +52,9 @@ func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyL
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fakeManager) Apply(ctx context.Context, content string) error {
|
||||
func (m *fakeManager) Apply(ctx context.Context, content string, supportFiles []protocol.SupportFile) error {
|
||||
m.applyContents = append(m.applyContents, content)
|
||||
m.applyFiles = append(m.applyFiles, append([]protocol.SupportFile(nil), supportFiles...))
|
||||
return m.applyErr
|
||||
}
|
||||
|
||||
@@ -71,6 +73,7 @@ func TestSyncOnceSuccess(t *testing.T) {
|
||||
Version: "20260309-001",
|
||||
Checksum: "checksum-1",
|
||||
RenderedConfig: "server { listen 80; }",
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
@@ -121,6 +124,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
|
||||
Version: "20260309-002",
|
||||
Checksum: "checksum-2",
|
||||
RenderedConfig: "server { listen 81; }",
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
@@ -181,6 +185,7 @@ func TestSyncOnStartupRecreatesRuntimeWhenChecksumMatches(t *testing.T) {
|
||||
Version: "20260309-003",
|
||||
Checksum: "checksum-3",
|
||||
RenderedConfig: "server { listen 82; }",
|
||||
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gin-template/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func GetTLSCertificates(c *gin.Context) {
|
||||
certificates, err := service.ListTLSCertificates()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": certificates,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateTLSCertificate(c *gin.Context) {
|
||||
var input service.TLSCertificateInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
certificate, err := service.CreateTLSCertificate(input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": certificate,
|
||||
})
|
||||
}
|
||||
|
||||
func ImportTLSCertificateFile(c *gin.Context) {
|
||||
name := c.PostForm("name")
|
||||
remark := c.PostForm("remark")
|
||||
certFile, err := c.FormFile("cert_file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "缺少证书文件",
|
||||
})
|
||||
return
|
||||
}
|
||||
keyFile, err := c.FormFile("key_file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "缺少私钥文件",
|
||||
})
|
||||
return
|
||||
}
|
||||
certificate, err := service.CreateTLSCertificateFromFiles(name, certFile, keyFile, remark)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": certificate,
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteTLSCertificate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err = service.DeleteTLSCertificate(uint(id)); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
}
|
||||
@@ -3,14 +3,15 @@ package model
|
||||
import "time"
|
||||
|
||||
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"`
|
||||
RenderedConfig string `json:"rendered_config" gorm:"type:text;not null"`
|
||||
Checksum string `json:"checksum" gorm:"size:64;not null"`
|
||||
IsActive bool `json:"is_active" gorm:"not null;default:false;index"`
|
||||
CreatedBy string `json:"created_by" gorm:"size:64;not null"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
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"`
|
||||
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"`
|
||||
IsActive bool `json:"is_active" gorm:"not null;default:false;index"`
|
||||
CreatedBy string `json:"created_by" gorm:"size:64;not null"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func ListConfigVersions() (versions []*ConfigVersion, err error) {
|
||||
|
||||
@@ -80,6 +80,10 @@ func InitDB() (err error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.AutoMigrate(&TLSCertificate{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = createRootAccountIfNeed()
|
||||
return err
|
||||
} else {
|
||||
|
||||
@@ -3,13 +3,16 @@ package model
|
||||
import "time"
|
||||
|
||||
type ProxyRoute struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
|
||||
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||
Remark string `json:"remark" gorm:"size:255"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
|
||||
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||
EnableHTTPS bool `json:"enable_https" gorm:"not null;default:false"`
|
||||
CertID *uint `json:"cert_id"`
|
||||
RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"`
|
||||
Remark string `json:"remark" gorm:"size:255"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func ListProxyRoutes() (routes []*ProxyRoute, err error) {
|
||||
@@ -33,7 +36,7 @@ func (route *ProxyRoute) Insert() error {
|
||||
}
|
||||
|
||||
func (route *ProxyRoute) Update() error {
|
||||
return DB.Model(route).Select("domain", "origin_url", "enabled", "remark").Updates(route).Error
|
||||
return DB.Model(route).Select("domain", "origin_url", "enabled", "enable_https", "cert_id", "redirect_http", "remark").Updates(route).Error
|
||||
}
|
||||
|
||||
func (route *ProxyRoute) Delete() error {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type TLSCertificate struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;size:255;not null"`
|
||||
CertPEM string `json:"cert_pem" gorm:"type:text;not null"`
|
||||
KeyPEM string `json:"key_pem" gorm:"type:text;not null"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
Remark string `json:"remark" gorm:"size:255"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func ListTLSCertificates() (certificates []*TLSCertificate, err error) {
|
||||
err = DB.Order("id desc").Find(&certificates).Error
|
||||
return certificates, err
|
||||
}
|
||||
|
||||
func GetTLSCertificateByID(id uint) (*TLSCertificate, error) {
|
||||
certificate := &TLSCertificate{}
|
||||
err := DB.First(certificate, id).Error
|
||||
return certificate, err
|
||||
}
|
||||
|
||||
func (certificate *TLSCertificate) Insert() error {
|
||||
return DB.Create(certificate).Error
|
||||
}
|
||||
|
||||
func (certificate *TLSCertificate) Delete() error {
|
||||
return DB.Delete(certificate).Error
|
||||
}
|
||||
@@ -70,6 +70,14 @@ func SetApiRouter(router *gin.Engine) {
|
||||
proxyRoute.PUT("/:id", controller.UpdateProxyRoute)
|
||||
proxyRoute.DELETE("/:id", controller.DeleteProxyRoute)
|
||||
}
|
||||
tlsCertificateRoute := apiRouter.Group("/tls-certificates")
|
||||
tlsCertificateRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
tlsCertificateRoute.GET("/", controller.GetTLSCertificates)
|
||||
tlsCertificateRoute.POST("/", controller.CreateTLSCertificate)
|
||||
tlsCertificateRoute.POST("/import-file", controller.ImportTLSCertificateFile)
|
||||
tlsCertificateRoute.DELETE("/:id", controller.DeleteTLSCertificate)
|
||||
}
|
||||
configVersionRoute := apiRouter.Group("/config-versions")
|
||||
configVersionRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
|
||||
@@ -2,18 +2,27 @@ package router_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"gin-template/common"
|
||||
"gin-template/model"
|
||||
"gin-template/router"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"mime/multipart"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type apiResponse struct {
|
||||
@@ -125,6 +134,81 @@ func TestPhase1PublishLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
token := prepareRootToken(t)
|
||||
certPEM, keyPEM := generateCertificatePairForRouterTest(t, []string{"secure.example.com"})
|
||||
|
||||
manualResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{
|
||||
"name": "secure-example",
|
||||
"cert_pem": certPEM,
|
||||
"key_pem": keyPEM,
|
||||
"remark": "manual import",
|
||||
})
|
||||
var manualCertificate model.TLSCertificate
|
||||
decodeResponseData(t, manualResp, &manualCertificate)
|
||||
if manualCertificate.ID == 0 {
|
||||
t.Fatal("expected manual certificate import to persist certificate")
|
||||
}
|
||||
|
||||
fileCertPEM, fileKeyPEM := generateCertificatePairForRouterTest(t, []string{"upload.example.com"})
|
||||
multipartResp := performMultipartRequest(t, engine, token, "/api/tls-certificates/import-file", map[string]string{
|
||||
"name": "upload-example",
|
||||
"remark": "upload import",
|
||||
}, map[string]string{
|
||||
"cert_file": fileCertPEM,
|
||||
"key_file": fileKeyPEM,
|
||||
})
|
||||
var uploadedCertificate model.TLSCertificate
|
||||
decodeResponseData(t, multipartResp, &uploadedCertificate)
|
||||
if uploadedCertificate.ID == 0 {
|
||||
t.Fatal("expected file certificate import to persist certificate")
|
||||
}
|
||||
|
||||
resp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", map[string]any{
|
||||
"domain": "secure.example.com",
|
||||
"origin_url": "https://origin-secure.internal",
|
||||
"enabled": true,
|
||||
"enable_https": true,
|
||||
"cert_id": manualCertificate.ID,
|
||||
"redirect_http": true,
|
||||
"remark": "https route",
|
||||
})
|
||||
var route model.ProxyRoute
|
||||
decodeResponseData(t, resp, &route)
|
||||
if !route.EnableHTTPS || route.CertID == nil || *route.CertID != manualCertificate.ID {
|
||||
t.Fatal("expected route to persist https certificate binding")
|
||||
}
|
||||
|
||||
resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil)
|
||||
var version model.ConfigVersion
|
||||
decodeResponseData(t, resp, &version)
|
||||
if !strings.Contains(version.RenderedConfig, "listen 443 ssl;") {
|
||||
t.Fatal("expected active config to render https listener")
|
||||
}
|
||||
if !strings.Contains(version.RenderedConfig, "return 301 https://$host$request_uri;") {
|
||||
t.Fatal("expected active config to render redirect server")
|
||||
}
|
||||
if !strings.Contains(version.SupportFilesJSON, ".crt") || !strings.Contains(version.SupportFilesJSON, ".key") {
|
||||
t.Fatal("expected support files json to contain certificate artifacts")
|
||||
}
|
||||
|
||||
agentResp := performAgentJSONRequest(t, engine, http.MethodGet, "/api/agent/config-versions/active", nil)
|
||||
var activeConfig map[string]any
|
||||
decodeResponseData(t, agentResp, &activeConfig)
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
func setupTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "phase1.db")
|
||||
@@ -192,3 +276,68 @@ func decodeResponseData(t *testing.T, resp apiResponse, target any) {
|
||||
func toString(id uint) string {
|
||||
return strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
func performMultipartRequest(t *testing.T, engine http.Handler, token string, path string, fields map[string]string, files map[string]string) apiResponse {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
for key, value := range fields {
|
||||
if err := writer.WriteField(key, value); err != nil {
|
||||
t.Fatalf("failed to write multipart field: %v", err)
|
||||
}
|
||||
}
|
||||
for fieldName, content := range files {
|
||||
part, err := writer.CreateFormFile(fieldName, fieldName+".pem")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create multipart file: %v", err)
|
||||
}
|
||||
if _, err = part.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("failed to write multipart file content: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close multipart writer: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, path, &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d for multipart %s: %s", recorder.Code, path, recorder.Body.String())
|
||||
}
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal multipart response: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("multipart request %s failed: %s", path, resp.Message)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func generateCertificatePairForRouterTest(t *testing.T, dnsNames []string) (string, string) {
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey failed: %v", err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{
|
||||
CommonName: dnsNames[0],
|
||||
},
|
||||
DNSNames: dnsNames,
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate failed: %v", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
|
||||
return string(certPEM), string(keyPEM)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"gin-template/common"
|
||||
"gin-template/model"
|
||||
@@ -35,10 +36,11 @@ type ApplyLogPayload struct {
|
||||
}
|
||||
|
||||
type AgentConfigResponse struct {
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
RenderedConfig string `json:"rendered_config"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
RenderedConfig string `json:"rendered_config"`
|
||||
SupportFiles []SupportFile `json:"support_files"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type NodeView struct {
|
||||
@@ -72,10 +74,17 @@ func GetActiveConfigForAgent() (*AgentConfigResponse, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var supportFiles []SupportFile
|
||||
if version.SupportFilesJSON != "" {
|
||||
if err = json.Unmarshal([]byte(version.SupportFilesJSON), &supportFiles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &AgentConfigResponse{
|
||||
Version: version.Version,
|
||||
Checksum: version.Checksum,
|
||||
RenderedConfig: version.RenderedConfig,
|
||||
SupportFiles: supportFiles,
|
||||
CreatedAt: version.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"gin-template/model"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +19,13 @@ type ReleaseResult struct {
|
||||
Routes []*model.ProxyRoute `json:"routes"`
|
||||
}
|
||||
|
||||
type SupportFile struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
const nginxCertDirPlaceholder = "__ATSF_CERT_DIR__"
|
||||
|
||||
func ListConfigVersions() ([]*model.ConfigVersion, error) {
|
||||
return model.ListConfigVersions()
|
||||
}
|
||||
@@ -38,18 +46,26 @@ func PublishConfigVersion(createdBy string) (*ReleaseResult, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
renderedConfig := renderNginxConfig(routes)
|
||||
renderedConfig, supportFiles, err := renderNginxConfig(routes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
supportFilesJSON, err := json.Marshal(supportFiles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version, err := nextVersionNumber(time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record := &model.ConfigVersion{
|
||||
Version: version,
|
||||
SnapshotJSON: snapshotJSON,
|
||||
RenderedConfig: renderedConfig,
|
||||
Checksum: checksum(renderedConfig),
|
||||
IsActive: true,
|
||||
CreatedBy: createdBy,
|
||||
Version: version,
|
||||
SnapshotJSON: snapshotJSON,
|
||||
RenderedConfig: renderedConfig,
|
||||
SupportFilesJSON: string(supportFilesJSON),
|
||||
Checksum: checksumBundle(renderedConfig, supportFiles),
|
||||
IsActive: true,
|
||||
CreatedBy: createdBy,
|
||||
}
|
||||
err = model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.ConfigVersion{}).Where("is_active = ?", true).Update("is_active", false).Error; err != nil {
|
||||
@@ -95,10 +111,13 @@ func ActivateConfigVersion(id uint) (*model.ConfigVersion, error) {
|
||||
|
||||
func renderSnapshot(routes []*model.ProxyRoute) (string, error) {
|
||||
type snapshotRoute struct {
|
||||
Domain string `json:"domain"`
|
||||
OriginURL string `json:"origin_url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
Domain string `json:"domain"`
|
||||
OriginURL string `json:"origin_url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EnableHTTPS bool `json:"enable_https"`
|
||||
CertID *uint `json:"cert_id,omitempty"`
|
||||
RedirectHTTP bool `json:"redirect_http"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
}
|
||||
items := make([]snapshotRoute, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
@@ -106,6 +125,9 @@ func renderSnapshot(routes []*model.ProxyRoute) (string, error) {
|
||||
Domain: route.Domain,
|
||||
OriginURL: route.OriginURL,
|
||||
Enabled: route.Enabled,
|
||||
EnableHTTPS: route.EnableHTTPS,
|
||||
CertID: route.CertID,
|
||||
RedirectHTTP: route.RedirectHTTP,
|
||||
Remark: route.Remark,
|
||||
})
|
||||
}
|
||||
@@ -116,13 +138,34 @@ func renderSnapshot(routes []*model.ProxyRoute) (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func renderNginxConfig(routes []*model.ProxyRoute) string {
|
||||
func renderNginxConfig(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)
|
||||
for _, route := range routes {
|
||||
builder.WriteString(fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n location / {\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_pass %s;\n }\n}\n\n", route.Domain, route.OriginURL))
|
||||
if !route.EnableHTTPS {
|
||||
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL))
|
||||
continue
|
||||
}
|
||||
if route.CertID == nil || *route.CertID == 0 {
|
||||
return "", nil, fmt.Errorf("路由 %s 未配置证书", route.Domain)
|
||||
}
|
||||
certificate, err := model.GetTLSCertificateByID(*route.CertID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("路由 %s 关联证书不存在", route.Domain)
|
||||
}
|
||||
supportFiles = append(supportFiles,
|
||||
SupportFile{Path: certificateCertFileName(certificate.ID), Content: normalizePEM(certificate.CertPEM)},
|
||||
SupportFile{Path: certificateKeyFileName(certificate.ID), Content: normalizePEM(certificate.KeyPEM)},
|
||||
)
|
||||
if route.RedirectHTTP {
|
||||
builder.WriteString(renderHTTPRedirectServer(route.Domain))
|
||||
} else {
|
||||
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL))
|
||||
}
|
||||
builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, certificate.ID))
|
||||
}
|
||||
return builder.String()
|
||||
return builder.String(), dedupeSupportFiles(supportFiles), nil
|
||||
}
|
||||
|
||||
func checksum(content string) string {
|
||||
@@ -130,6 +173,23 @@ func checksum(content string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func checksumBundle(renderedConfig string, supportFiles []SupportFile) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString(renderedConfig)
|
||||
builder.WriteString("\n--support-files--\n")
|
||||
files := dedupeSupportFiles(supportFiles)
|
||||
sort.Slice(files, func(i int, j int) bool {
|
||||
return files[i].Path < files[j].Path
|
||||
})
|
||||
for _, file := range files {
|
||||
builder.WriteString(file.Path)
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(file.Content)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
return checksum(builder.String())
|
||||
}
|
||||
|
||||
func nextVersionNumber(now time.Time) (string, error) {
|
||||
prefix := now.Format("20060102")
|
||||
var count int64
|
||||
@@ -138,3 +198,44 @@ func nextVersionNumber(now time.Time) (string, error) {
|
||||
}
|
||||
return fmt.Sprintf("%s-%03d", prefix, count+1), nil
|
||||
}
|
||||
|
||||
func renderHTTPProxyServer(domain string, originURL string) string {
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n location / {\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_pass %s;\n }\n}\n\n", domain, originURL)
|
||||
}
|
||||
|
||||
func renderHTTPRedirectServer(domain string) string {
|
||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", domain)
|
||||
}
|
||||
|
||||
func renderHTTPSServer(domain string, originURL string, certificateID uint) string {
|
||||
certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID))
|
||||
keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID))
|
||||
return fmt.Sprintf("server {\n listen 443 ssl;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n\n location / {\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_pass %s;\n }\n}\n\n", domain, certPath, keyPath, originURL)
|
||||
}
|
||||
|
||||
func certificateCertFileName(id uint) string {
|
||||
return fmt.Sprintf("%d.crt", id)
|
||||
}
|
||||
|
||||
func certificateKeyFileName(id uint) string {
|
||||
return fmt.Sprintf("%d.key", id)
|
||||
}
|
||||
|
||||
func normalizePEM(content string) string {
|
||||
return strings.TrimSpace(content) + "\n"
|
||||
}
|
||||
|
||||
func dedupeSupportFiles(files []SupportFile) []SupportFile {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
unique := make(map[string]SupportFile, len(files))
|
||||
for _, file := range files {
|
||||
unique[file.Path] = file
|
||||
}
|
||||
result := make([]SupportFile, 0, len(unique))
|
||||
for _, file := range unique {
|
||||
result = append(result, file)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"gin-template/common"
|
||||
"gin-template/model"
|
||||
"math/big"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
certPEM, keyPEM := generateCertificatePair(t, []string{"app.example.com"})
|
||||
certificate, err := CreateTLSCertificate(TLSCertificateInput{
|
||||
Name: "app-example",
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
Remark: "test cert",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTLSCertificate failed: %v", err)
|
||||
}
|
||||
if certificate.NotAfter.Before(certificate.NotBefore) {
|
||||
t.Fatal("expected certificate validity period to be parsed")
|
||||
}
|
||||
|
||||
route, err := CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "app.example.com",
|
||||
OriginURL: "https://origin.internal",
|
||||
Enabled: true,
|
||||
EnableHTTPS: true,
|
||||
CertID: &certificate.ID,
|
||||
RedirectHTTP: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||
}
|
||||
if !route.EnableHTTPS || route.CertID == nil {
|
||||
t.Fatal("expected https fields to be persisted")
|
||||
}
|
||||
|
||||
result, err := PublishConfigVersion("root")
|
||||
if err != nil {
|
||||
t.Fatalf("PublishConfigVersion failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;") {
|
||||
t.Fatal("expected rendered config to include https server block")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "return 301 https://$host$request_uri;") {
|
||||
t.Fatal("expected rendered config to include http redirect")
|
||||
}
|
||||
if !strings.Contains(result.Version.RenderedConfig, "__ATSF_CERT_DIR__/") {
|
||||
t.Fatal("expected rendered config to keep certificate dir placeholder")
|
||||
}
|
||||
if !strings.Contains(result.Version.SupportFilesJSON, ".crt") || !strings.Contains(result.Version.SupportFilesJSON, ".key") {
|
||||
t.Fatal("expected support files to contain certificate and key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProxyRouteRejectsHTTPSWithoutCertificate(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
_, err := CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "secure.example.com",
|
||||
OriginURL: "https://origin.internal",
|
||||
Enabled: true,
|
||||
EnableHTTPS: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "必须选择证书") {
|
||||
t.Fatalf("expected certificate validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTLSCertificateRejectsInvalidPEM(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
_, err := CreateTLSCertificate(TLSCertificateInput{
|
||||
Name: "broken-cert",
|
||||
CertPEM: "invalid",
|
||||
KeyPEM: "invalid",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid pem to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func setupServiceTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
common.SQLitePath = filepath.Join(t.TempDir(), "service.db")
|
||||
if err := model.InitDB(); err != nil {
|
||||
t.Fatalf("failed to init db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := model.CloseDB(); err != nil {
|
||||
t.Fatalf("failed to close db: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func generateCertificatePair(t *testing.T, dnsNames []string) (string, string) {
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey failed: %v", err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
Subject: pkix.Name{
|
||||
CommonName: dnsNames[0],
|
||||
},
|
||||
DNSNames: dnsNames,
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IsCA: false,
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate failed: %v", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
|
||||
return string(certPEM), string(keyPEM)
|
||||
}
|
||||
@@ -8,10 +8,13 @@ import (
|
||||
)
|
||||
|
||||
type ProxyRouteInput struct {
|
||||
Domain string `json:"domain"`
|
||||
OriginURL string `json:"origin_url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Remark string `json:"remark"`
|
||||
Domain string `json:"domain"`
|
||||
OriginURL string `json:"origin_url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EnableHTTPS bool `json:"enable_https"`
|
||||
CertID *uint `json:"cert_id"`
|
||||
RedirectHTTP bool `json:"redirect_http"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func ListProxyRoutes() ([]*model.ProxyRoute, error) {
|
||||
@@ -71,12 +74,30 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
|
||||
if err := validateOriginURL(originURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !input.EnableHTTPS {
|
||||
input.RedirectHTTP = false
|
||||
input.CertID = nil
|
||||
}
|
||||
if input.EnableHTTPS {
|
||||
if input.CertID == nil || *input.CertID == 0 {
|
||||
return nil, errors.New("启用 HTTPS 时必须选择证书")
|
||||
}
|
||||
if _, err := model.GetTLSCertificateByID(*input.CertID); err != nil {
|
||||
return nil, errors.New("所选证书不存在")
|
||||
}
|
||||
}
|
||||
if input.RedirectHTTP && !input.EnableHTTPS {
|
||||
return nil, errors.New("仅启用 HTTPS 后才能开启 HTTP 重定向")
|
||||
}
|
||||
if route == nil {
|
||||
route = &model.ProxyRoute{}
|
||||
}
|
||||
route.Domain = domain
|
||||
route.OriginURL = originURL
|
||||
route.Enabled = input.Enabled
|
||||
route.EnableHTTPS = input.EnableHTTPS
|
||||
route.CertID = input.CertID
|
||||
route.RedirectHTTP = input.RedirectHTTP
|
||||
route.Remark = remark
|
||||
return route, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gin-template/model"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type TLSCertificateInput struct {
|
||||
Name string `json:"name"`
|
||||
CertPEM string `json:"cert_pem"`
|
||||
KeyPEM string `json:"key_pem"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func ListTLSCertificates() ([]*model.TLSCertificate, error) {
|
||||
return model.ListTLSCertificates()
|
||||
}
|
||||
|
||||
func CreateTLSCertificate(input TLSCertificateInput) (*model.TLSCertificate, error) {
|
||||
certificate, err := buildTLSCertificate(nil, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = certificate.Insert(); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.New("证书名称已存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return certificate, nil
|
||||
}
|
||||
|
||||
func CreateTLSCertificateFromFiles(name string, certFile *multipart.FileHeader, keyFile *multipart.FileHeader, remark string) (*model.TLSCertificate, error) {
|
||||
if certFile == nil || keyFile == nil {
|
||||
return nil, errors.New("证书文件和私钥文件不能为空")
|
||||
}
|
||||
certContent, err := readMultipartFile(certFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyContent, err := readMultipartFile(keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return CreateTLSCertificate(TLSCertificateInput{
|
||||
Name: name,
|
||||
CertPEM: certContent,
|
||||
KeyPEM: keyContent,
|
||||
Remark: remark,
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteTLSCertificate(id uint) error {
|
||||
var routeCount int64
|
||||
if err := model.DB.Model(&model.ProxyRoute{}).Where("cert_id = ?", id).Count(&routeCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if routeCount > 0 {
|
||||
return errors.New("证书仍被反代规则引用,无法删除")
|
||||
}
|
||||
certificate, err := model.GetTLSCertificateByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return certificate.Delete()
|
||||
}
|
||||
|
||||
func buildTLSCertificate(existing *model.TLSCertificate, input TLSCertificateInput) (*model.TLSCertificate, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
certPEM := strings.TrimSpace(input.CertPEM)
|
||||
keyPEM := strings.TrimSpace(input.KeyPEM)
|
||||
remark := strings.TrimSpace(input.Remark)
|
||||
if name == "" {
|
||||
return nil, errors.New("证书名称不能为空")
|
||||
}
|
||||
if certPEM == "" || keyPEM == "" {
|
||||
return nil, errors.New("证书内容和私钥内容不能为空")
|
||||
}
|
||||
parsed, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("证书或私钥格式不合法: %w", err)
|
||||
}
|
||||
if len(parsed.Certificate) == 0 {
|
||||
return nil, errors.New("证书内容不合法")
|
||||
}
|
||||
leaf, err := parseLeafCertificate(certPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
existing = &model.TLSCertificate{}
|
||||
}
|
||||
existing.Name = name
|
||||
existing.CertPEM = certPEM
|
||||
existing.KeyPEM = keyPEM
|
||||
existing.NotBefore = leaf.NotBefore
|
||||
existing.NotAfter = leaf.NotAfter
|
||||
existing.Remark = remark
|
||||
return existing, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
)
|
||||
|
||||
func parseLeafCertificate(certPEM string) (*x509.Certificate, error) {
|
||||
certPEMBlock, _ := pem.Decode([]byte(certPEM))
|
||||
if certPEMBlock == nil {
|
||||
return nil, errors.New("证书 PEM 内容不合法")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(certPEMBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return leaf, nil
|
||||
}
|
||||
|
||||
func readMultipartFile(fileHeader *multipart.FileHeader) (string, error) {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import ProxyRoute from './pages/ProxyRoute';
|
||||
import ConfigVersion from './pages/ConfigVersion';
|
||||
import Node from './pages/Node';
|
||||
import ApplyLog from './pages/ApplyLog';
|
||||
import TLSCertificate from './pages/TLSCertificate';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const About = lazy(() => import('./pages/About'));
|
||||
@@ -108,6 +109,14 @@ function App() {
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/tls-certificate'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<TLSCertificate />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/file'
|
||||
element={
|
||||
|
||||
@@ -44,6 +44,12 @@ const headerButtons = [
|
||||
icon: 'tasks',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
name: '证书',
|
||||
to: '/tls-certificate',
|
||||
icon: 'lock',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
name: '文件',
|
||||
to: '/file',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Form,
|
||||
Header,
|
||||
Icon,
|
||||
@@ -15,11 +16,15 @@ const initialForm = {
|
||||
domain: '',
|
||||
origin_url: '',
|
||||
enabled: true,
|
||||
enable_https: false,
|
||||
cert_id: '',
|
||||
redirect_http: false,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
const ProxyRoute = () => {
|
||||
const [routes, setRoutes] = useState([]);
|
||||
const [certificates, setCertificates] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [form, setForm] = useState(initialForm);
|
||||
@@ -37,8 +42,19 @@ const ProxyRoute = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const loadCertificates = async () => {
|
||||
const res = await API.get('/api/tls-certificates/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setCertificates(data || []);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRoutes().then();
|
||||
loadCertificates().then();
|
||||
}, []);
|
||||
|
||||
const resetForm = () => {
|
||||
@@ -51,6 +67,7 @@ const ProxyRoute = () => {
|
||||
...form,
|
||||
domain: form.domain.trim(),
|
||||
origin_url: form.origin_url.trim(),
|
||||
cert_id: form.enable_https && form.cert_id ? Number(form.cert_id) : null,
|
||||
remark: form.remark.trim(),
|
||||
};
|
||||
const res = editingId
|
||||
@@ -95,10 +112,19 @@ const ProxyRoute = () => {
|
||||
domain: route.domain,
|
||||
origin_url: route.origin_url,
|
||||
enabled: route.enabled,
|
||||
enable_https: route.enable_https || false,
|
||||
cert_id: route.cert_id || '',
|
||||
redirect_http: route.redirect_http || false,
|
||||
remark: route.remark || '',
|
||||
});
|
||||
};
|
||||
|
||||
const certificateOptions = certificates.map((certificate) => ({
|
||||
key: certificate.id,
|
||||
text: `${certificate.name} (${certificate.not_after ? formatDateTime(certificate.not_after) : 'unknown'})`,
|
||||
value: certificate.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Segment loading={loading}>
|
||||
<div className='page-toolbar'>
|
||||
@@ -127,6 +153,33 @@ const ProxyRoute = () => {
|
||||
onChange={(e, { value }) => setForm({ ...form, origin_url: value })}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group widths='equal'>
|
||||
<Form.Checkbox
|
||||
toggle
|
||||
label='启用 HTTPS'
|
||||
checked={form.enable_https}
|
||||
onChange={(e, { checked }) =>
|
||||
setForm({
|
||||
...form,
|
||||
enable_https: checked,
|
||||
cert_id: checked ? form.cert_id : '',
|
||||
redirect_http: checked ? form.redirect_http : false,
|
||||
})
|
||||
}
|
||||
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
|
||||
/>
|
||||
<Form.Field
|
||||
control={Dropdown}
|
||||
selection
|
||||
clearable
|
||||
disabled={!form.enable_https}
|
||||
label='TLS 证书'
|
||||
placeholder='选择已导入证书'
|
||||
options={certificateOptions}
|
||||
value={form.cert_id}
|
||||
onChange={(e, { value }) => setForm({ ...form, cert_id: value || '' })}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group widths='equal'>
|
||||
<Form.Field
|
||||
control={TextArea}
|
||||
@@ -142,6 +195,14 @@ const ProxyRoute = () => {
|
||||
onChange={(e, { checked }) => setForm({ ...form, enabled: checked })}
|
||||
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
|
||||
/>
|
||||
<Form.Checkbox
|
||||
toggle
|
||||
label='HTTP 跳转 HTTPS'
|
||||
checked={form.redirect_http}
|
||||
disabled={!form.enable_https}
|
||||
onChange={(e, { checked }) => setForm({ ...form, redirect_http: checked })}
|
||||
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button primary type='submit'>
|
||||
{editingId ? '保存修改' : '新增规则'}
|
||||
@@ -158,6 +219,7 @@ const ProxyRoute = () => {
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>域名</Table.HeaderCell>
|
||||
<Table.HeaderCell>源站地址</Table.HeaderCell>
|
||||
<Table.HeaderCell>HTTPS</Table.HeaderCell>
|
||||
<Table.HeaderCell>状态</Table.HeaderCell>
|
||||
<Table.HeaderCell>备注</Table.HeaderCell>
|
||||
<Table.HeaderCell>更新时间</Table.HeaderCell>
|
||||
@@ -169,6 +231,13 @@ const ProxyRoute = () => {
|
||||
<Table.Row key={route.id}>
|
||||
<Table.Cell>{route.domain}</Table.Cell>
|
||||
<Table.Cell>{route.origin_url}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{route.enable_https ? (
|
||||
<Label color='blue'>{route.redirect_http ? 'HTTPS + 重定向' : 'HTTPS'}</Label>
|
||||
) : (
|
||||
<Label>HTTP</Label>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{route.enabled ? <Label color='green'>启用</Label> : <Label>停用</Label>}
|
||||
</Table.Cell>
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Header,
|
||||
Icon,
|
||||
Segment,
|
||||
Table,
|
||||
TextArea,
|
||||
} from 'semantic-ui-react';
|
||||
import { API, formatDateTime, showError, showSuccess } from '../../helpers';
|
||||
|
||||
const initialManualForm = {
|
||||
name: '',
|
||||
cert_pem: '',
|
||||
key_pem: '',
|
||||
remark: '',
|
||||
};
|
||||
|
||||
const initialFileForm = {
|
||||
name: '',
|
||||
remark: '',
|
||||
};
|
||||
|
||||
const TLSCertificate = () => {
|
||||
const [certificates, setCertificates] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [manualForm, setManualForm] = useState(initialManualForm);
|
||||
const [fileForm, setFileForm] = useState(initialFileForm);
|
||||
const [certFile, setCertFile] = useState(null);
|
||||
const [keyFile, setKeyFile] = useState(null);
|
||||
const [submittingManual, setSubmittingManual] = useState(false);
|
||||
const [submittingFiles, setSubmittingFiles] = useState(false);
|
||||
|
||||
const loadCertificates = async () => {
|
||||
setLoading(true);
|
||||
const res = await API.get('/api/tls-certificates/');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setCertificates(data || []);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadCertificates().then();
|
||||
}, []);
|
||||
|
||||
const submitManual = async () => {
|
||||
setSubmittingManual(true);
|
||||
const payload = {
|
||||
name: manualForm.name.trim(),
|
||||
cert_pem: manualForm.cert_pem.trim(),
|
||||
key_pem: manualForm.key_pem.trim(),
|
||||
remark: manualForm.remark.trim(),
|
||||
};
|
||||
const res = await API.post('/api/tls-certificates/', payload);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('证书已导入');
|
||||
setManualForm(initialManualForm);
|
||||
await loadCertificates();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSubmittingManual(false);
|
||||
};
|
||||
|
||||
const submitFiles = async () => {
|
||||
if (!certFile || !keyFile) {
|
||||
showError('请选择证书文件和私钥文件');
|
||||
return;
|
||||
}
|
||||
setSubmittingFiles(true);
|
||||
const formData = new FormData();
|
||||
formData.append('name', fileForm.name.trim());
|
||||
formData.append('remark', fileForm.remark.trim());
|
||||
formData.append('cert_file', certFile);
|
||||
formData.append('key_file', keyFile);
|
||||
const res = await API.post('/api/tls-certificates/import-file', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('证书文件已导入');
|
||||
setFileForm(initialFileForm);
|
||||
setCertFile(null);
|
||||
setKeyFile(null);
|
||||
await loadCertificates();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSubmittingFiles(false);
|
||||
};
|
||||
|
||||
const deleteCertificate = async (id) => {
|
||||
const res = await API.delete(`/api/tls-certificates/${id}`);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('证书已删除');
|
||||
await loadCertificates();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Segment loading={loading}>
|
||||
<Header as='h3'>证书管理</Header>
|
||||
<p className='page-subtitle'>支持手动粘贴 PEM 导入或直接上传证书文件。</p>
|
||||
|
||||
<Form onSubmit={submitManual}>
|
||||
<Header as='h4'>手动导入</Header>
|
||||
<Form.Group widths='equal'>
|
||||
<Form.Input
|
||||
label='证书名称'
|
||||
placeholder='example-com'
|
||||
value={manualForm.name}
|
||||
onChange={(e, { value }) => setManualForm({ ...manualForm, name: value })}
|
||||
/>
|
||||
<Form.Input
|
||||
label='备注'
|
||||
placeholder='可选备注'
|
||||
value={manualForm.remark}
|
||||
onChange={(e, { value }) => setManualForm({ ...manualForm, remark: value })}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Field
|
||||
control={TextArea}
|
||||
label='证书 PEM'
|
||||
placeholder='-----BEGIN CERTIFICATE-----'
|
||||
value={manualForm.cert_pem}
|
||||
onChange={(e, { value }) => setManualForm({ ...manualForm, cert_pem: value })}
|
||||
style={{ minHeight: 140 }}
|
||||
/>
|
||||
<Form.Field
|
||||
control={TextArea}
|
||||
label='私钥 PEM'
|
||||
placeholder='-----BEGIN PRIVATE KEY-----'
|
||||
value={manualForm.key_pem}
|
||||
onChange={(e, { value }) => setManualForm({ ...manualForm, key_pem: value })}
|
||||
style={{ minHeight: 140 }}
|
||||
/>
|
||||
<Button primary type='submit' loading={submittingManual}>
|
||||
导入证书
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Segment secondary>
|
||||
<Form onSubmit={submitFiles}>
|
||||
<Header as='h4'>文件导入</Header>
|
||||
<Form.Group widths='equal'>
|
||||
<Form.Input
|
||||
label='证书名称'
|
||||
placeholder='wildcard-example'
|
||||
value={fileForm.name}
|
||||
onChange={(e, { value }) => setFileForm({ ...fileForm, name: value })}
|
||||
/>
|
||||
<Form.Input
|
||||
label='备注'
|
||||
placeholder='可选备注'
|
||||
value={fileForm.remark}
|
||||
onChange={(e, { value }) => setFileForm({ ...fileForm, remark: value })}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group widths='equal'>
|
||||
<Form.Input
|
||||
type='file'
|
||||
label='证书文件'
|
||||
onChange={(e) => setCertFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
<Form.Input
|
||||
type='file'
|
||||
label='私钥文件'
|
||||
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button primary type='submit' loading={submittingFiles}>
|
||||
上传文件
|
||||
</Button>
|
||||
</Form>
|
||||
</Segment>
|
||||
|
||||
<Table celled stackable className='atsf-table'>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>名称</Table.HeaderCell>
|
||||
<Table.HeaderCell>有效期</Table.HeaderCell>
|
||||
<Table.HeaderCell>备注</Table.HeaderCell>
|
||||
<Table.HeaderCell>更新时间</Table.HeaderCell>
|
||||
<Table.HeaderCell>操作</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{certificates.map((certificate) => (
|
||||
<Table.Row key={certificate.id}>
|
||||
<Table.Cell>
|
||||
<Icon name='lock' />
|
||||
{certificate.name}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{formatDateTime(certificate.not_before)} ~ {formatDateTime(certificate.not_after)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{certificate.remark || '无'}</Table.Cell>
|
||||
<Table.Cell>{formatDateTime(certificate.updated_at)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Button size='small' negative onClick={() => deleteCertificate(certificate.id)}>
|
||||
删除
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</Segment>
|
||||
);
|
||||
};
|
||||
|
||||
export default TLSCertificate;
|
||||
Reference in New Issue
Block a user