mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 23:26: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),
|
HeartbeatService: heartbeat.New(client),
|
||||||
SyncService: syncservice.New(client, &nginx.Manager{
|
SyncService: syncservice.New(client, &nginx.Manager{
|
||||||
RouteConfigPath: cfg.RouteConfigPath,
|
RouteConfigPath: cfg.RouteConfigPath,
|
||||||
|
CertDir: cfg.CertDir,
|
||||||
|
NginxCertDir: cfg.NginxCertDir,
|
||||||
Executor: nginx.NewExecutor(nginx.ExecutorOptions{
|
Executor: nginx.NewExecutor(nginx.ExecutorOptions{
|
||||||
NginxPath: cfg.NginxPath,
|
NginxPath: cfg.NginxPath,
|
||||||
DockerBinary: cfg.DockerBinary,
|
DockerBinary: cfg.DockerBinary,
|
||||||
ContainerName: cfg.NginxContainerName,
|
ContainerName: cfg.NginxContainerName,
|
||||||
Image: cfg.NginxDockerImage,
|
Image: cfg.NginxDockerImage,
|
||||||
RouteConfigPath: cfg.RouteConfigPath,
|
RouteConfigPath: cfg.RouteConfigPath,
|
||||||
|
CertDir: cfg.CertDir,
|
||||||
|
NginxCertDir: cfg.NginxCertDir,
|
||||||
}),
|
}),
|
||||||
}, stateStore),
|
}, stateStore),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
defaultDockerRouteConfigRelativePath = "etc/nginx/conf.d/atsflare_routes.conf"
|
defaultDockerRouteConfigRelativePath = "etc/nginx/conf.d/atsflare_routes.conf"
|
||||||
|
defaultCertDirRelativePath = "etc/nginx/certs"
|
||||||
defaultDockerStateRelativePath = "var/lib/atsflare/agent-state.json"
|
defaultDockerStateRelativePath = "var/lib/atsflare/agent-state.json"
|
||||||
|
defaultDockerNginxCertDir = "/etc/nginx/atsflare-certs"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -26,6 +28,8 @@ type Config struct {
|
|||||||
DockerBinary string `json:"docker_binary"`
|
DockerBinary string `json:"docker_binary"`
|
||||||
DataDir string `json:"data_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
RouteConfigPath string `json:"route_config_path"`
|
RouteConfigPath string `json:"route_config_path"`
|
||||||
|
CertDir string `json:"cert_dir"`
|
||||||
|
NginxCertDir string `json:"nginx_cert_dir"`
|
||||||
StatePath string `json:"state_path"`
|
StatePath string `json:"state_path"`
|
||||||
HeartbeatInterval time.Duration `json:"heartbeat_interval"`
|
HeartbeatInterval time.Duration `json:"heartbeat_interval"`
|
||||||
SyncInterval time.Duration `json:"sync_interval"`
|
SyncInterval time.Duration `json:"sync_interval"`
|
||||||
@@ -76,6 +80,16 @@ func applyDefaults(cfg *Config, baseDir string) {
|
|||||||
cfg.StatePath = filepath.Join(cfg.DataDir, defaultDockerStateRelativePath)
|
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 {
|
if cfg.HeartbeatInterval <= 0 {
|
||||||
cfg.HeartbeatInterval = 30 * time.Second
|
cfg.HeartbeatInterval = 30 * time.Second
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ func TestLoadDockerModeUsesManagedPaths(t *testing.T) {
|
|||||||
if cfg.RouteConfigPath != filepath.Join(dir, "data", defaultDockerRouteConfigRelativePath) {
|
if cfg.RouteConfigPath != filepath.Join(dir, "data", defaultDockerRouteConfigRelativePath) {
|
||||||
t.Fatalf("unexpected route config path: %s", cfg.RouteConfigPath)
|
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) {
|
if cfg.StatePath != filepath.Join(dir, "data", defaultDockerStateRelativePath) {
|
||||||
t.Fatalf("unexpected state path: %s", cfg.StatePath)
|
t.Fatalf("unexpected state path: %s", cfg.StatePath)
|
||||||
}
|
}
|
||||||
@@ -72,6 +78,9 @@ func TestLoadPathModeKeepsExplicitPaths(t *testing.T) {
|
|||||||
if cfg.StatePath != "/tmp/agent-state.json" {
|
if cfg.StatePath != "/tmp/agent-state.json" {
|
||||||
t.Fatalf("unexpected state path: %s", cfg.StatePath)
|
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) {
|
func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
|
||||||
@@ -104,4 +113,7 @@ func TestLoadUsesCustomDataDirForGeneratedFiles(t *testing.T) {
|
|||||||
if cfg.StatePath != "/srv/atsflare/"+defaultDockerStateRelativePath {
|
if cfg.StatePath != "/srv/atsflare/"+defaultDockerStateRelativePath {
|
||||||
t.Fatalf("unexpected state path: %s", cfg.StatePath)
|
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"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"atsflare-agent/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const CertDirPlaceholder = "__ATSF_CERT_DIR__"
|
||||||
|
|
||||||
type Executor interface {
|
type Executor interface {
|
||||||
Test(ctx context.Context) error
|
Test(ctx context.Context) error
|
||||||
Reload(ctx context.Context) error
|
Reload(ctx context.Context) error
|
||||||
@@ -60,6 +65,8 @@ type DockerExecutor struct {
|
|||||||
ContainerName string
|
ContainerName string
|
||||||
Image string
|
Image string
|
||||||
RouteConfigDir string
|
RouteConfigDir string
|
||||||
|
CertDir string
|
||||||
|
NginxCertDir string
|
||||||
Runner CommandRunner
|
Runner CommandRunner
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +78,8 @@ func (e *DockerExecutor) Test(ctx context.Context) error {
|
|||||||
"--rm",
|
"--rm",
|
||||||
"-v",
|
"-v",
|
||||||
fmt.Sprintf("%s:/etc/nginx/conf.d", e.RouteConfigDir),
|
fmt.Sprintf("%s:/etc/nginx/conf.d", e.RouteConfigDir),
|
||||||
|
"-v",
|
||||||
|
fmt.Sprintf("%s:%s", e.CertDir, e.NginxCertDir),
|
||||||
e.Image,
|
e.Image,
|
||||||
"nginx",
|
"nginx",
|
||||||
"-t",
|
"-t",
|
||||||
@@ -124,6 +133,7 @@ func (e *DockerExecutor) runContainer(ctx context.Context) error {
|
|||||||
"-p", "80:80",
|
"-p", "80:80",
|
||||||
"-p", "443:443",
|
"-p", "443:443",
|
||||||
"-v", fmt.Sprintf("%s:/etc/nginx/conf.d", e.RouteConfigDir),
|
"-v", fmt.Sprintf("%s:/etc/nginx/conf.d", e.RouteConfigDir),
|
||||||
|
"-v", fmt.Sprintf("%s:%s", e.CertDir, e.NginxCertDir),
|
||||||
e.Image,
|
e.Image,
|
||||||
}
|
}
|
||||||
runOutput, runErr := e.Runner.Run(ctx, e.DockerBinary, runArgs...)
|
runOutput, runErr := e.Runner.Run(ctx, e.DockerBinary, runArgs...)
|
||||||
@@ -135,28 +145,33 @@ func (e *DockerExecutor) runContainer(ctx context.Context) error {
|
|||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
RouteConfigPath string
|
RouteConfigPath string
|
||||||
|
CertDir string
|
||||||
|
NginxCertDir string
|
||||||
Executor Executor
|
Executor Executor
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Apply(ctx context.Context, content string) error {
|
func (m *Manager) Apply(ctx context.Context, content string, supportFiles []protocol.SupportFile) error {
|
||||||
backupPath, hadExisting, err := m.backup()
|
backup, err := m.backup()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
if err = m.Executor.Test(ctx); err != nil {
|
if err = m.Executor.Test(ctx); err != nil {
|
||||||
_ = m.restore(backupPath, hadExisting)
|
_ = m.restore(backup)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = m.Executor.Reload(ctx); err != nil {
|
if err = m.Executor.Reload(ctx); err != nil {
|
||||||
_ = m.restore(backupPath, hadExisting)
|
_ = m.restore(backup)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if backupPath != "" {
|
|
||||||
_ = os.Remove(backupPath)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,8 +193,15 @@ func (m *Manager) CurrentChecksum() (string, error) {
|
|||||||
}
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
sum := sha256.Sum256(data)
|
normalized := string(data)
|
||||||
return hex.EncodeToString(sum[:]), nil
|
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 {
|
type ExecutorOptions struct {
|
||||||
@@ -188,6 +210,8 @@ type ExecutorOptions struct {
|
|||||||
ContainerName string
|
ContainerName string
|
||||||
Image string
|
Image string
|
||||||
RouteConfigPath string
|
RouteConfigPath string
|
||||||
|
CertDir string
|
||||||
|
NginxCertDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExecutor(options ExecutorOptions) Executor {
|
func NewExecutor(options ExecutorOptions) Executor {
|
||||||
@@ -202,46 +226,167 @@ func NewExecutor(options ExecutorOptions) Executor {
|
|||||||
if absDir, err := filepath.Abs(routeConfigDir); err == nil {
|
if absDir, err := filepath.Abs(routeConfigDir); err == nil {
|
||||||
routeConfigDir = absDir
|
routeConfigDir = absDir
|
||||||
}
|
}
|
||||||
|
certDir := options.CertDir
|
||||||
|
if absDir, err := filepath.Abs(certDir); err == nil {
|
||||||
|
certDir = absDir
|
||||||
|
}
|
||||||
return &DockerExecutor{
|
return &DockerExecutor{
|
||||||
DockerBinary: options.DockerBinary,
|
DockerBinary: options.DockerBinary,
|
||||||
ContainerName: options.ContainerName,
|
ContainerName: options.ContainerName,
|
||||||
Image: options.Image,
|
Image: options.Image,
|
||||||
RouteConfigDir: routeConfigDir,
|
RouteConfigDir: routeConfigDir,
|
||||||
|
CertDir: certDir,
|
||||||
|
NginxCertDir: options.NginxCertDir,
|
||||||
Runner: runner,
|
Runner: runner,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) backup() (string, bool, error) {
|
type backupState struct {
|
||||||
if m.RouteConfigPath == "" {
|
RouteExisted bool
|
||||||
return "", false, errors.New("route config path 不能为空")
|
RouteData []byte
|
||||||
}
|
Files []protocol.SupportFile
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) restore(backupPath string, hadExisting bool) error {
|
func (m *Manager) backup() (*backupState, error) {
|
||||||
if hadExisting {
|
if m.RouteConfigPath == "" {
|
||||||
data, err := os.ReadFile(backupPath)
|
return nil, errors.New("route config path 不能为空")
|
||||||
if err != nil {
|
}
|
||||||
|
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 err
|
||||||
}
|
}
|
||||||
return os.WriteFile(m.RouteConfigPath, data, 0o644)
|
} else if err := os.Remove(m.RouteConfigPath); err != nil && !os.IsNotExist(err) {
|
||||||
}
|
|
||||||
if err := os.Remove(m.RouteConfigPath); err != nil && !os.IsNotExist(err) {
|
|
||||||
return 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
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"atsflare-agent/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
type runCall struct {
|
type runCall struct {
|
||||||
@@ -19,6 +22,11 @@ type fakeRunner struct {
|
|||||||
runFn func(name string, args ...string) ([]byte, error)
|
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) {
|
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...)})
|
r.calls = append(r.calls, runCall{name: name, args: append([]string{}, args...)})
|
||||||
if r.runFn != nil {
|
if r.runFn != nil {
|
||||||
@@ -27,6 +35,18 @@ func (r *fakeRunner) Run(ctx context.Context, name string, args ...string) ([]by
|
|||||||
return nil, nil
|
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) {
|
func TestPathExecutorCommands(t *testing.T) {
|
||||||
runner := &fakeRunner{}
|
runner := &fakeRunner{}
|
||||||
executor := &PathExecutor{
|
executor := &PathExecutor{
|
||||||
@@ -74,6 +94,8 @@ func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) {
|
|||||||
ContainerName: "atsflare-nginx",
|
ContainerName: "atsflare-nginx",
|
||||||
Image: "nginx:stable-alpine",
|
Image: "nginx:stable-alpine",
|
||||||
RouteConfigDir: filepath.Clean("/tmp/routes"),
|
RouteConfigDir: filepath.Clean("/tmp/routes"),
|
||||||
|
CertDir: filepath.Clean("/tmp/certs"),
|
||||||
|
NginxCertDir: "/etc/nginx/atsflare-certs",
|
||||||
Runner: runner,
|
Runner: runner,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +125,8 @@ func TestDockerExecutorStartsStoppedContainer(t *testing.T) {
|
|||||||
ContainerName: "atsflare-nginx",
|
ContainerName: "atsflare-nginx",
|
||||||
Image: "nginx:stable-alpine",
|
Image: "nginx:stable-alpine",
|
||||||
RouteConfigDir: filepath.Clean("/tmp/routes"),
|
RouteConfigDir: filepath.Clean("/tmp/routes"),
|
||||||
|
CertDir: filepath.Clean("/tmp/certs"),
|
||||||
|
NginxCertDir: "/etc/nginx/atsflare-certs",
|
||||||
Runner: runner,
|
Runner: runner,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +162,8 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) {
|
|||||||
ContainerName: "atsflare-nginx",
|
ContainerName: "atsflare-nginx",
|
||||||
Image: "nginx:stable-alpine",
|
Image: "nginx:stable-alpine",
|
||||||
RouteConfigDir: filepath.Clean("/tmp/routes"),
|
RouteConfigDir: filepath.Clean("/tmp/routes"),
|
||||||
|
CertDir: filepath.Clean("/tmp/certs"),
|
||||||
|
NginxCertDir: "/etc/nginx/atsflare-certs",
|
||||||
Runner: runner,
|
Runner: runner,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +187,8 @@ func TestNewExecutorUsesAbsoluteDockerMountPath(t *testing.T) {
|
|||||||
ContainerName: "atsflare-nginx",
|
ContainerName: "atsflare-nginx",
|
||||||
Image: "nginx:stable-alpine",
|
Image: "nginx:stable-alpine",
|
||||||
RouteConfigPath: "./data/etc/nginx/conf.d/atsflare_routes.conf",
|
RouteConfigPath: "./data/etc/nginx/conf.d/atsflare_routes.conf",
|
||||||
|
CertDir: "./data/etc/nginx/certs",
|
||||||
|
NginxCertDir: "/etc/nginx/atsflare-certs",
|
||||||
})
|
})
|
||||||
|
|
||||||
dockerExecutor, ok := executor.(*DockerExecutor)
|
dockerExecutor, ok := executor.(*DockerExecutor)
|
||||||
@@ -174,3 +202,81 @@ func TestNewExecutorUsesAbsoluteDockerMountPath(t *testing.T) {
|
|||||||
t.Fatalf("unexpected route config dir: %s", dockerExecutor.RouteConfigDir)
|
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 {
|
type ActiveConfigResponse struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Checksum string `json:"checksum"`
|
Checksum string `json:"checksum"`
|
||||||
RenderedConfig string `json:"rendered_config"`
|
RenderedConfig string `json:"rendered_config"`
|
||||||
CreatedAt string `json:"created_at"`
|
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 {
|
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
|
EnsureRuntime(ctx context.Context, recreate bool) error
|
||||||
CurrentChecksum() (string, 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 {
|
if snapshot.CurrentVersion == config.Version && snapshot.CurrentChecksum == config.Checksum && !startup {
|
||||||
return nil
|
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()
|
snapshot.LastError = err.Error()
|
||||||
_ = s.stateStore.Save(snapshot)
|
_ = s.stateStore.Save(snapshot)
|
||||||
reportErr := s.client.ReportApplyLog(ctx, protocol.ApplyLogPayload{
|
reportErr := s.client.ReportApplyLog(ctx, protocol.ApplyLogPayload{
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type fakeManager struct {
|
|||||||
currentChecksumErr error
|
currentChecksumErr error
|
||||||
ensureCalls []bool
|
ensureCalls []bool
|
||||||
applyContents []string
|
applyContents []string
|
||||||
|
applyFiles [][]protocol.SupportFile
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeExecutor) Test(ctx context.Context) error {
|
func (f *fakeExecutor) Test(ctx context.Context) error {
|
||||||
@@ -51,8 +52,9 @@ func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyL
|
|||||||
return nil
|
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.applyContents = append(m.applyContents, content)
|
||||||
|
m.applyFiles = append(m.applyFiles, append([]protocol.SupportFile(nil), supportFiles...))
|
||||||
return m.applyErr
|
return m.applyErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ func TestSyncOnceSuccess(t *testing.T) {
|
|||||||
Version: "20260309-001",
|
Version: "20260309-001",
|
||||||
Checksum: "checksum-1",
|
Checksum: "checksum-1",
|
||||||
RenderedConfig: "server { listen 80; }",
|
RenderedConfig: "server { listen 80; }",
|
||||||
|
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||||
CreatedAt: time.Now().Format(time.RFC3339),
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -121,6 +124,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
|
|||||||
Version: "20260309-002",
|
Version: "20260309-002",
|
||||||
Checksum: "checksum-2",
|
Checksum: "checksum-2",
|
||||||
RenderedConfig: "server { listen 81; }",
|
RenderedConfig: "server { listen 81; }",
|
||||||
|
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||||
CreatedAt: time.Now().Format(time.RFC3339),
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -181,6 +185,7 @@ func TestSyncOnStartupRecreatesRuntimeWhenChecksumMatches(t *testing.T) {
|
|||||||
Version: "20260309-003",
|
Version: "20260309-003",
|
||||||
Checksum: "checksum-3",
|
Checksum: "checksum-3",
|
||||||
RenderedConfig: "server { listen 82; }",
|
RenderedConfig: "server { listen 82; }",
|
||||||
|
SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}},
|
||||||
CreatedAt: time.Now().Format(time.RFC3339),
|
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"
|
import "time"
|
||||||
|
|
||||||
type ConfigVersion struct {
|
type ConfigVersion struct {
|
||||||
ID uint `json:"id" gorm:"primaryKey"`
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
Version string `json:"version" gorm:"uniqueIndex;size:32;not null"`
|
Version string `json:"version" gorm:"uniqueIndex;size:32;not null"`
|
||||||
SnapshotJSON string `json:"snapshot_json" gorm:"type:text;not null"`
|
SnapshotJSON string `json:"snapshot_json" gorm:"type:text;not null"`
|
||||||
RenderedConfig string `json:"rendered_config" gorm:"type:text;not null"`
|
RenderedConfig string `json:"rendered_config" gorm:"type:text;not null"`
|
||||||
Checksum string `json:"checksum" gorm:"size:64;not null"`
|
SupportFilesJSON string `json:"support_files_json" gorm:"type:text;not null;default:'[]'"`
|
||||||
IsActive bool `json:"is_active" gorm:"not null;default:false;index"`
|
Checksum string `json:"checksum" gorm:"size:64;not null"`
|
||||||
CreatedBy string `json:"created_by" gorm:"size:64;not null"`
|
IsActive bool `json:"is_active" gorm:"not null;default:false;index"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedBy string `json:"created_by" gorm:"size:64;not null"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListConfigVersions() (versions []*ConfigVersion, err error) {
|
func ListConfigVersions() (versions []*ConfigVersion, err error) {
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ func InitDB() (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
err = db.AutoMigrate(&TLSCertificate{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
err = createRootAccountIfNeed()
|
err = createRootAccountIfNeed()
|
||||||
return err
|
return err
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ package model
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type ProxyRoute struct {
|
type ProxyRoute struct {
|
||||||
ID uint `json:"id" gorm:"primaryKey"`
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
|
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
|
||||||
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
|
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
|
||||||
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||||
Remark string `json:"remark" gorm:"size:255"`
|
EnableHTTPS bool `json:"enable_https" gorm:"not null;default:false"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CertID *uint `json:"cert_id"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
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) {
|
func ListProxyRoutes() (routes []*ProxyRoute, err error) {
|
||||||
@@ -33,7 +36,7 @@ func (route *ProxyRoute) Insert() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (route *ProxyRoute) Update() 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 {
|
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.PUT("/:id", controller.UpdateProxyRoute)
|
||||||
proxyRoute.DELETE("/:id", controller.DeleteProxyRoute)
|
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 := apiRouter.Group("/config-versions")
|
||||||
configVersionRoute.Use(middleware.AdminAuth())
|
configVersionRoute.Use(middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,18 +2,27 @@ package router_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"gin-template/common"
|
"gin-template/common"
|
||||||
"gin-template/model"
|
"gin-template/model"
|
||||||
"gin-template/router"
|
"gin-template/router"
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-contrib/sessions/cookie"
|
"github.com/gin-contrib/sessions/cookie"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"mime/multipart"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type apiResponse struct {
|
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) {
|
func setupTestDB(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dbPath := filepath.Join(t.TempDir(), "phase1.db")
|
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 {
|
func toString(id uint) string {
|
||||||
return strconv.FormatUint(uint64(id), 10)
|
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
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"gin-template/common"
|
"gin-template/common"
|
||||||
"gin-template/model"
|
"gin-template/model"
|
||||||
@@ -35,10 +36,11 @@ type ApplyLogPayload struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfigResponse struct {
|
type AgentConfigResponse struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Checksum string `json:"checksum"`
|
Checksum string `json:"checksum"`
|
||||||
RenderedConfig string `json:"rendered_config"`
|
RenderedConfig string `json:"rendered_config"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
SupportFiles []SupportFile `json:"support_files"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeView struct {
|
type NodeView struct {
|
||||||
@@ -72,10 +74,17 @@ func GetActiveConfigForAgent() (*AgentConfigResponse, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
var supportFiles []SupportFile
|
||||||
|
if version.SupportFilesJSON != "" {
|
||||||
|
if err = json.Unmarshal([]byte(version.SupportFilesJSON), &supportFiles); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return &AgentConfigResponse{
|
return &AgentConfigResponse{
|
||||||
Version: version.Version,
|
Version: version.Version,
|
||||||
Checksum: version.Checksum,
|
Checksum: version.Checksum,
|
||||||
RenderedConfig: version.RenderedConfig,
|
RenderedConfig: version.RenderedConfig,
|
||||||
|
SupportFiles: supportFiles,
|
||||||
CreatedAt: version.CreatedAt,
|
CreatedAt: version.CreatedAt,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gin-template/model"
|
"gin-template/model"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,6 +19,13 @@ type ReleaseResult struct {
|
|||||||
Routes []*model.ProxyRoute `json:"routes"`
|
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) {
|
func ListConfigVersions() ([]*model.ConfigVersion, error) {
|
||||||
return model.ListConfigVersions()
|
return model.ListConfigVersions()
|
||||||
}
|
}
|
||||||
@@ -38,18 +46,26 @@ func PublishConfigVersion(createdBy string) (*ReleaseResult, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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())
|
version, err := nextVersionNumber(time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
record := &model.ConfigVersion{
|
record := &model.ConfigVersion{
|
||||||
Version: version,
|
Version: version,
|
||||||
SnapshotJSON: snapshotJSON,
|
SnapshotJSON: snapshotJSON,
|
||||||
RenderedConfig: renderedConfig,
|
RenderedConfig: renderedConfig,
|
||||||
Checksum: checksum(renderedConfig),
|
SupportFilesJSON: string(supportFilesJSON),
|
||||||
IsActive: true,
|
Checksum: checksumBundle(renderedConfig, supportFiles),
|
||||||
CreatedBy: createdBy,
|
IsActive: true,
|
||||||
|
CreatedBy: createdBy,
|
||||||
}
|
}
|
||||||
err = model.DB.Transaction(func(tx *gorm.DB) error {
|
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 {
|
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) {
|
func renderSnapshot(routes []*model.ProxyRoute) (string, error) {
|
||||||
type snapshotRoute struct {
|
type snapshotRoute struct {
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
OriginURL string `json:"origin_url"`
|
OriginURL string `json:"origin_url"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Remark string `json:"remark,omitempty"`
|
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))
|
items := make([]snapshotRoute, 0, len(routes))
|
||||||
for _, route := range routes {
|
for _, route := range routes {
|
||||||
@@ -106,6 +125,9 @@ func renderSnapshot(routes []*model.ProxyRoute) (string, error) {
|
|||||||
Domain: route.Domain,
|
Domain: route.Domain,
|
||||||
OriginURL: route.OriginURL,
|
OriginURL: route.OriginURL,
|
||||||
Enabled: route.Enabled,
|
Enabled: route.Enabled,
|
||||||
|
EnableHTTPS: route.EnableHTTPS,
|
||||||
|
CertID: route.CertID,
|
||||||
|
RedirectHTTP: route.RedirectHTTP,
|
||||||
Remark: route.Remark,
|
Remark: route.Remark,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -116,13 +138,34 @@ func renderSnapshot(routes []*model.ProxyRoute) (string, error) {
|
|||||||
return string(data), nil
|
return string(data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderNginxConfig(routes []*model.ProxyRoute) string {
|
func renderNginxConfig(routes []*model.ProxyRoute) (string, []SupportFile, error) {
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
builder.WriteString("# This file is generated by ATSFlare. Do not edit manually.\n")
|
builder.WriteString("# This file is generated by ATSFlare. Do not edit manually.\n")
|
||||||
|
supportFiles := make([]SupportFile, 0)
|
||||||
for _, route := range routes {
|
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 {
|
func checksum(content string) string {
|
||||||
@@ -130,6 +173,23 @@ func checksum(content string) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
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) {
|
func nextVersionNumber(now time.Time) (string, error) {
|
||||||
prefix := now.Format("20060102")
|
prefix := now.Format("20060102")
|
||||||
var count int64
|
var count int64
|
||||||
@@ -138,3 +198,44 @@ func nextVersionNumber(now time.Time) (string, error) {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%s-%03d", prefix, count+1), nil
|
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 {
|
type ProxyRouteInput struct {
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
OriginURL string `json:"origin_url"`
|
OriginURL string `json:"origin_url"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Remark string `json:"remark"`
|
EnableHTTPS bool `json:"enable_https"`
|
||||||
|
CertID *uint `json:"cert_id"`
|
||||||
|
RedirectHTTP bool `json:"redirect_http"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListProxyRoutes() ([]*model.ProxyRoute, error) {
|
func ListProxyRoutes() ([]*model.ProxyRoute, error) {
|
||||||
@@ -71,12 +74,30 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
|
|||||||
if err := validateOriginURL(originURL); err != nil {
|
if err := validateOriginURL(originURL); err != nil {
|
||||||
return nil, err
|
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 {
|
if route == nil {
|
||||||
route = &model.ProxyRoute{}
|
route = &model.ProxyRoute{}
|
||||||
}
|
}
|
||||||
route.Domain = domain
|
route.Domain = domain
|
||||||
route.OriginURL = originURL
|
route.OriginURL = originURL
|
||||||
route.Enabled = input.Enabled
|
route.Enabled = input.Enabled
|
||||||
|
route.EnableHTTPS = input.EnableHTTPS
|
||||||
|
route.CertID = input.CertID
|
||||||
|
route.RedirectHTTP = input.RedirectHTTP
|
||||||
route.Remark = remark
|
route.Remark = remark
|
||||||
return route, nil
|
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 ConfigVersion from './pages/ConfigVersion';
|
||||||
import Node from './pages/Node';
|
import Node from './pages/Node';
|
||||||
import ApplyLog from './pages/ApplyLog';
|
import ApplyLog from './pages/ApplyLog';
|
||||||
|
import TLSCertificate from './pages/TLSCertificate';
|
||||||
|
|
||||||
const Home = lazy(() => import('./pages/Home'));
|
const Home = lazy(() => import('./pages/Home'));
|
||||||
const About = lazy(() => import('./pages/About'));
|
const About = lazy(() => import('./pages/About'));
|
||||||
@@ -108,6 +109,14 @@ function App() {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path='/tls-certificate'
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<TLSCertificate />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/file'
|
path='/file'
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ const headerButtons = [
|
|||||||
icon: 'tasks',
|
icon: 'tasks',
|
||||||
admin: true,
|
admin: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '证书',
|
||||||
|
to: '/tls-certificate',
|
||||||
|
icon: 'lock',
|
||||||
|
admin: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: '文件',
|
name: '文件',
|
||||||
to: '/file',
|
to: '/file',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Dropdown,
|
||||||
Form,
|
Form,
|
||||||
Header,
|
Header,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -15,11 +16,15 @@ const initialForm = {
|
|||||||
domain: '',
|
domain: '',
|
||||||
origin_url: '',
|
origin_url: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
enable_https: false,
|
||||||
|
cert_id: '',
|
||||||
|
redirect_http: false,
|
||||||
remark: '',
|
remark: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProxyRoute = () => {
|
const ProxyRoute = () => {
|
||||||
const [routes, setRoutes] = useState([]);
|
const [routes, setRoutes] = useState([]);
|
||||||
|
const [certificates, setCertificates] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [form, setForm] = useState(initialForm);
|
const [form, setForm] = useState(initialForm);
|
||||||
@@ -37,8 +42,19 @@ const ProxyRoute = () => {
|
|||||||
setLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
loadRoutes().then();
|
loadRoutes().then();
|
||||||
|
loadCertificates().then();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
@@ -51,6 +67,7 @@ const ProxyRoute = () => {
|
|||||||
...form,
|
...form,
|
||||||
domain: form.domain.trim(),
|
domain: form.domain.trim(),
|
||||||
origin_url: form.origin_url.trim(),
|
origin_url: form.origin_url.trim(),
|
||||||
|
cert_id: form.enable_https && form.cert_id ? Number(form.cert_id) : null,
|
||||||
remark: form.remark.trim(),
|
remark: form.remark.trim(),
|
||||||
};
|
};
|
||||||
const res = editingId
|
const res = editingId
|
||||||
@@ -95,10 +112,19 @@ const ProxyRoute = () => {
|
|||||||
domain: route.domain,
|
domain: route.domain,
|
||||||
origin_url: route.origin_url,
|
origin_url: route.origin_url,
|
||||||
enabled: route.enabled,
|
enabled: route.enabled,
|
||||||
|
enable_https: route.enable_https || false,
|
||||||
|
cert_id: route.cert_id || '',
|
||||||
|
redirect_http: route.redirect_http || false,
|
||||||
remark: route.remark || '',
|
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 (
|
return (
|
||||||
<Segment loading={loading}>
|
<Segment loading={loading}>
|
||||||
<div className='page-toolbar'>
|
<div className='page-toolbar'>
|
||||||
@@ -127,6 +153,33 @@ const ProxyRoute = () => {
|
|||||||
onChange={(e, { value }) => setForm({ ...form, origin_url: value })}
|
onChange={(e, { value }) => setForm({ ...form, origin_url: value })}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</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.Group widths='equal'>
|
||||||
<Form.Field
|
<Form.Field
|
||||||
control={TextArea}
|
control={TextArea}
|
||||||
@@ -142,6 +195,14 @@ const ProxyRoute = () => {
|
|||||||
onChange={(e, { checked }) => setForm({ ...form, enabled: checked })}
|
onChange={(e, { checked }) => setForm({ ...form, enabled: checked })}
|
||||||
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
|
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>
|
</Form.Group>
|
||||||
<Button primary type='submit'>
|
<Button primary type='submit'>
|
||||||
{editingId ? '保存修改' : '新增规则'}
|
{editingId ? '保存修改' : '新增规则'}
|
||||||
@@ -158,6 +219,7 @@ const ProxyRoute = () => {
|
|||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.HeaderCell>域名</Table.HeaderCell>
|
<Table.HeaderCell>域名</Table.HeaderCell>
|
||||||
<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>
|
<Table.HeaderCell>备注</Table.HeaderCell>
|
||||||
<Table.HeaderCell>更新时间</Table.HeaderCell>
|
<Table.HeaderCell>更新时间</Table.HeaderCell>
|
||||||
@@ -169,6 +231,13 @@ const ProxyRoute = () => {
|
|||||||
<Table.Row key={route.id}>
|
<Table.Row key={route.id}>
|
||||||
<Table.Cell>{route.domain}</Table.Cell>
|
<Table.Cell>{route.domain}</Table.Cell>
|
||||||
<Table.Cell>{route.origin_url}</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>
|
<Table.Cell>
|
||||||
{route.enabled ? <Label color='green'>启用</Label> : <Label>停用</Label>}
|
{route.enabled ? <Label color='green'>启用</Label> : <Label>停用</Label>}
|
||||||
</Table.Cell>
|
</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