This commit is contained in:
h z
2026-02-15 08:49:27 +00:00
commit 72843442ac
21 changed files with 1450 additions and 0 deletions

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

9
.idea/AbstractWizard.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

4
.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

11
.idea/go.imports.xml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="github.com/pkg/errors" />
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/AbstractWizard.iml" filepath="$PROJECT_DIR$/.idea/AbstractWizard.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /abstract-wizard .
FROM gcr.io/distroless/static-debian12
COPY --from=build /abstract-wizard /abstract-wizard
USER nonroot:nonroot
ENV CONFIG_DIR=/config
ENV LISTEN_ADDR=0.0.0.0:8080
EXPOSE 8080
VOLUME /config
ENTRYPOINT ["/abstract-wizard"]

287
README.md Normal file
View File

@@ -0,0 +1,287 @@
# Abstract Wizard
[English](#english) | [中文](#中文)
---
## English
Secure configuration file management service. Read, modify, and version-control JSON/YAML config files via REST API. Listens on localhost only — access remotely via SSH tunnel.
### Quick Start
#### Docker Compose (Recommended)
```bash
docker compose up -d
```
The service listens on `127.0.0.1:18080`.
#### Run Locally
```bash
go build -o abstract-wizard .
CONFIG_DIR=./configs ./abstract-wizard
```
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CONFIG_DIR` | `/config` | Base directory for config files |
| `LISTEN_ADDR` | `127.0.0.1:8080` | HTTP listen address |
| `MAX_BACKUPS` | `10` | Max backup versions per file |
### API
#### Health Check
```bash
curl http://127.0.0.1:18080/health
```
#### Read Config
```bash
curl http://127.0.0.1:18080/api/v1/config/app.json
```
#### Full Replace
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/config/app.json \
-d '{"database": {"host": "localhost", "port": 5432}}'
```
A backup is created automatically before each write. The response includes `backup_version`.
#### Partial Update (Deep Merge)
```bash
curl -X PATCH http://127.0.0.1:18080/api/v1/config/app.json \
-d '{"database": {"port": 3306}}'
```
Only the specified fields are updated; nested objects are merged recursively.
#### List Backups
```bash
curl http://127.0.0.1:18080/api/v1/backups/app.json
```
#### Rollback
```bash
# Get the version from the backup list, then:
curl -X POST http://127.0.0.1:18080/api/v1/rollback/app.json \
-d '{"version": "20260215T120000Z"}'
```
#### Mode Switching
Get current mode:
```bash
curl http://127.0.0.1:18080/api/v1/mode
```
Switch to readonly (rejects all writes):
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/mode \
-d '{"mode": "readonly"}'
```
Switch back to init (read/write):
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/mode \
-d '{"mode": "init"}'
```
### YAML Support
All endpoints support both JSON and YAML. The format is detected by file extension:
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/config/app.yaml \
-H "Content-Type: text/yaml" \
-d '
database:
host: localhost
port: 5432
'
```
### Security Model
The service binds to `127.0.0.1` by default and is not exposed externally. Use an SSH tunnel for remote access:
```bash
ssh -L 18080:127.0.0.1:18080 user@server
```
Then access via `http://127.0.0.1:18080` locally.
### Project Structure
```
├── main.go # Entry point, env config, graceful shutdown
├── config/
│ ├── validation.go # Path validation, traversal prevention
│ ├── parser.go # JSON/YAML parse, serialize, deep merge
│ ├── atomic.go # Atomic write (temp → fsync → rename)
│ └── backup.go # Timestamped backups, pruning, rollback
├── audit/
│ └── logger.go # Structured JSON audit log
├── server/
│ ├── server.go # HTTP server, routing, mode state machine
│ ├── middleware.go # Request logging middleware
│ └── handlers.go # API handlers
├── Dockerfile # Multi-stage build
└── docker-compose.yaml # Example deployment
```
---
## 中文
安全的配置文件管理服务。通过 REST API 对 JSON/YAML 配置文件进行读取、修改和版本管理,仅监听 localhost通过 SSH 隧道访问。
### 快速开始
#### Docker Compose推荐
```bash
docker compose up -d
```
服务启动后监听 `127.0.0.1:18080`
#### 本地运行
```bash
go build -o abstract-wizard .
CONFIG_DIR=./configs ./abstract-wizard
```
### 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `CONFIG_DIR` | `/config` | 配置文件存放目录 |
| `LISTEN_ADDR` | `127.0.0.1:8080` | 监听地址 |
| `MAX_BACKUPS` | `10` | 每个文件保留的最大备份数 |
### API
#### 健康检查
```bash
curl http://127.0.0.1:18080/health
```
#### 读取配置
```bash
curl http://127.0.0.1:18080/api/v1/config/app.json
```
#### 完整替换配置
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/config/app.json \
-d '{"database": {"host": "localhost", "port": 5432}}'
```
写入前会自动创建备份,响应中包含 `backup_version`
#### 局部更新(深度合并)
```bash
curl -X PATCH http://127.0.0.1:18080/api/v1/config/app.json \
-d '{"database": {"port": 3306}}'
```
仅更新指定字段,嵌套对象递归合并。
#### 查看备份列表
```bash
curl http://127.0.0.1:18080/api/v1/backups/app.json
```
#### 回滚到指定版本
```bash
# 先从备份列表获取版本号,然后:
curl -X POST http://127.0.0.1:18080/api/v1/rollback/app.json \
-d '{"version": "20260215T120000Z"}'
```
#### 模式切换
查看当前模式:
```bash
curl http://127.0.0.1:18080/api/v1/mode
```
切换为只读模式(拒绝所有写操作):
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/mode \
-d '{"mode": "readonly"}'
```
切换回初始化模式(允许读写):
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/mode \
-d '{"mode": "init"}'
```
### YAML 支持
所有端点同时支持 JSON 和 YAML格式由文件扩展名自动判断
```bash
curl -X PUT http://127.0.0.1:18080/api/v1/config/app.yaml \
-H "Content-Type: text/yaml" \
-d '
database:
host: localhost
port: 5432
'
```
### 安全模型
服务默认仅监听 `127.0.0.1`,不暴露到外部网络。远程访问通过 SSH 隧道实现:
```bash
ssh -L 18080:127.0.0.1:18080 user@server
```
之后本地即可通过 `http://127.0.0.1:18080` 访问。
### 项目结构
```
├── main.go # 入口:环境变量、初始化、优雅关闭
├── config/
│ ├── validation.go # 路径校验,防止目录穿越
│ ├── parser.go # JSON/YAML 解析、序列化、深度合并
│ ├── atomic.go # 原子写入temp → fsync → rename
│ └── backup.go # 时间戳备份、清理、回滚
├── audit/
│ └── logger.go # 结构化 JSON 审计日志
├── server/
│ ├── server.go # HTTP 服务、路由、模式状态机
│ ├── middleware.go # 请求日志中间件
│ └── handlers.go # API 处理函数
├── Dockerfile # 多阶段构建
└── docker-compose.yaml # 示例部署配置
```

46
audit/logger.go Normal file
View File

@@ -0,0 +1,46 @@
package audit
import (
"encoding/json"
"log"
"os"
"time"
)
// Entry represents a single audit log record.
type Entry struct {
Timestamp string `json:"timestamp"`
RemoteIP string `json:"remote_ip"`
Action string `json:"action"`
Path string `json:"path"`
Summary string `json:"summary"`
}
// Logger writes structured audit entries as JSON to stdout.
type Logger struct {
out *log.Logger
}
// NewLogger creates a new audit logger writing to stdout.
func NewLogger() *Logger {
return &Logger{
out: log.New(os.Stdout, "", 0),
}
}
// Log writes a structured audit entry.
func (l *Logger) Log(remoteIP, action, path, summary string) {
entry := Entry{
Timestamp: time.Now().UTC().Format(time.RFC3339),
RemoteIP: remoteIP,
Action: action,
Path: path,
Summary: summary,
}
data, err := json.Marshal(entry)
if err != nil {
l.out.Printf(`{"error":"failed to marshal audit entry: %v"}`, err)
return
}
l.out.Print(string(data))
}

55
config/atomic.go Normal file
View File

@@ -0,0 +1,55 @@
package config
import (
"fmt"
"os"
"path/filepath"
)
// AtomicWrite writes data to the target path atomically by writing to a temp file
// in the same directory, fsyncing, then renaming over the target.
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create directory: %w", err)
}
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpName := tmp.Name()
cleanup := func() {
tmp.Close()
os.Remove(tmpName)
}
if _, err := tmp.Write(data); err != nil {
cleanup()
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
cleanup()
return fmt.Errorf("fsync temp file: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Chmod(tmpName, perm); err != nil {
os.Remove(tmpName)
return fmt.Errorf("chmod temp file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
os.Remove(tmpName)
return fmt.Errorf("rename temp to target: %w", err)
}
return nil
}

156
config/backup.go Normal file
View File

@@ -0,0 +1,156 @@
package config
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
const backupDirName = ".backups"
// BackupInfo holds metadata about a single backup.
type BackupInfo struct {
Version string `json:"version"`
Timestamp string `json:"timestamp"`
Path string `json:"path"`
}
// CreateBackup copies the current file into a timestamped backup before modification.
// Returns the backup version string. If the source file doesn't exist, returns "" with no error.
func CreateBackup(baseDir, relPath string, maxBackups int) (string, error) {
srcPath, err := FullPath(baseDir, relPath)
if err != nil {
return "", err
}
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
return "", nil
}
data, err := os.ReadFile(srcPath)
if err != nil {
return "", fmt.Errorf("read source for backup: %w", err)
}
backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath)))
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return "", fmt.Errorf("create backup dir: %w", err)
}
version := time.Now().UTC().Format("20060102T150405Z")
base := filepath.Base(relPath)
ext := filepath.Ext(base)
name := strings.TrimSuffix(base, ext)
backupName := fmt.Sprintf("%s.%s%s", name, version, ext)
backupPath := filepath.Join(backupDir, backupName)
if err := AtomicWrite(backupPath, data, 0o644); err != nil {
return "", fmt.Errorf("write backup: %w", err)
}
if err := pruneBackups(backupDir, name, ext, maxBackups); err != nil {
return "", fmt.Errorf("prune backups: %w", err)
}
return version, nil
}
// ListBackups returns all backup versions for a given config file, newest first.
func ListBackups(baseDir, relPath string) ([]BackupInfo, error) {
if err := ValidatePath(relPath); err != nil {
return nil, err
}
backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath)))
base := filepath.Base(relPath)
ext := filepath.Ext(base)
name := strings.TrimSuffix(base, ext)
prefix := name + "."
entries, err := os.ReadDir(backupDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read backup dir: %w", err)
}
var backups []BackupInfo
for _, e := range entries {
fname := e.Name()
if !strings.HasPrefix(fname, prefix) || !strings.HasSuffix(fname, ext) {
continue
}
version := strings.TrimSuffix(strings.TrimPrefix(fname, prefix), ext)
backups = append(backups, BackupInfo{
Version: version,
Timestamp: version,
Path: filepath.Join(backupDir, fname),
})
}
sort.Slice(backups, func(i, j int) bool {
return backups[i].Version > backups[j].Version
})
return backups, nil
}
// Rollback restores a config file to the given backup version.
func Rollback(baseDir, relPath, version string) error {
if err := ValidatePath(relPath); err != nil {
return err
}
backupDir := filepath.Join(baseDir, backupDirName, filepath.Dir(filepath.Clean(relPath)))
base := filepath.Base(relPath)
ext := filepath.Ext(base)
name := strings.TrimSuffix(base, ext)
backupName := fmt.Sprintf("%s.%s%s", name, version, ext)
backupPath := filepath.Join(backupDir, backupName)
data, err := os.ReadFile(backupPath)
if err != nil {
return fmt.Errorf("read backup %q: %w", version, err)
}
targetPath, err := FullPath(baseDir, relPath)
if err != nil {
return err
}
return AtomicWrite(targetPath, data, 0o644)
}
// pruneBackups removes oldest backups exceeding maxBackups.
func pruneBackups(backupDir, name, ext string, maxBackups int) error {
if maxBackups <= 0 {
return nil
}
prefix := name + "."
entries, err := os.ReadDir(backupDir)
if err != nil {
return err
}
var matching []string
for _, e := range entries {
if strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ext) {
matching = append(matching, e.Name())
}
}
sort.Strings(matching)
if len(matching) > maxBackups {
for _, old := range matching[:len(matching)-maxBackups] {
os.Remove(filepath.Join(backupDir, old))
}
}
return nil
}

83
config/parser.go Normal file
View File

@@ -0,0 +1,83 @@
package config
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Format represents a config file format.
type Format int
const (
FormatJSON Format = iota
FormatYAML
)
// DetectFormat returns the format based on file extension.
func DetectFormat(path string) (Format, error) {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".json":
return FormatJSON, nil
case ".yaml", ".yml":
return FormatYAML, nil
default:
return 0, fmt.Errorf("unsupported format: %s", ext)
}
}
// Parse deserializes raw bytes into a map based on the given format.
func Parse(data []byte, format Format) (map[string]any, error) {
var result map[string]any
switch format {
case FormatJSON:
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
case FormatYAML:
if err := yaml.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("invalid YAML: %w", err)
}
default:
return nil, fmt.Errorf("unknown format")
}
if result == nil {
result = make(map[string]any)
}
return result, nil
}
// Serialize converts a map back to bytes in the given format.
func Serialize(data map[string]any, format Format) ([]byte, error) {
switch format {
case FormatJSON:
return json.MarshalIndent(data, "", " ")
case FormatYAML:
return yaml.Marshal(data)
default:
return nil, fmt.Errorf("unknown format")
}
}
// DeepMerge merges src into dst recursively. Values in src override those in dst.
// Nested maps are merged; all other types are replaced.
func DeepMerge(dst, src map[string]any) map[string]any {
out := make(map[string]any, len(dst))
for k, v := range dst {
out[k] = v
}
for k, v := range src {
if srcMap, ok := v.(map[string]any); ok {
if dstMap, ok := out[k].(map[string]any); ok {
out[k] = DeepMerge(dstMap, srcMap)
continue
}
}
out[k] = v
}
return out
}

49
config/validation.go Normal file
View File

@@ -0,0 +1,49 @@
package config
import (
"fmt"
"path/filepath"
"strings"
)
var allowedExtensions = map[string]bool{
".json": true,
".yaml": true,
".yml": true,
}
// ValidatePath checks that the given relative path is safe and points to a supported file type.
// It prevents directory traversal and rejects unsupported extensions.
func ValidatePath(relPath string) error {
if relPath == "" {
return fmt.Errorf("empty path")
}
cleaned := filepath.Clean(relPath)
if filepath.IsAbs(cleaned) {
return fmt.Errorf("absolute paths not allowed")
}
for _, part := range strings.Split(cleaned, string(filepath.Separator)) {
if part == ".." {
return fmt.Errorf("path traversal not allowed")
}
}
ext := strings.ToLower(filepath.Ext(cleaned))
if !allowedExtensions[ext] {
return fmt.Errorf("unsupported file extension %q; allowed: .json, .yaml, .yml", ext)
}
return nil
}
// FullPath joins the base config directory with the validated relative path.
func FullPath(baseDir, relPath string) (string, error) {
if err := ValidatePath(relPath); err != nil {
return "", err
}
full := filepath.Join(baseDir, filepath.Clean(relPath))
return full, nil
}

14
docker-compose.yaml Normal file
View File

@@ -0,0 +1,14 @@
services:
abstract-wizard:
build: .
ports:
- "127.0.0.1:18080:8080"
volumes:
- app_config:/config
environment:
CONFIG_DIR: /config
LISTEN_ADDR: "0.0.0.0:8080"
MAX_BACKUPS: "10"
volumes:
app_config:

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module AbstractWizard
go 1.24
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

51
main.go Normal file
View File

@@ -0,0 +1,51 @@
package main
import (
"log"
"os"
"strconv"
"AbstractWizard/audit"
"AbstractWizard/server"
)
func main() {
cfg := server.AppConfig{
ConfigDir: envOrDefault("CONFIG_DIR", "/config"),
ListenAddr: envOrDefault("LISTEN_ADDR", "127.0.0.1:8080"),
MaxBackups: envOrDefaultInt("MAX_BACKUPS", 10),
}
if err := os.MkdirAll(cfg.ConfigDir, 0o755); err != nil {
log.Fatalf("failed to create config directory %s: %v", cfg.ConfigDir, err)
}
auditLog := audit.NewLogger()
srv := server.New(cfg, auditLog)
log.Printf("config_dir=%s listen_addr=%s max_backups=%d", cfg.ConfigDir, cfg.ListenAddr, cfg.MaxBackups)
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("server error: %v", err)
}
}
func envOrDefault(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
func envOrDefaultInt(key string, defaultVal int) int {
v := os.Getenv(key)
if v == "" {
return defaultVal
}
n, err := strconv.Atoi(v)
if err != nil {
log.Printf("warning: invalid %s=%q, using default %d", key, v, defaultVal)
return defaultVal
}
return n
}

202
proj_plan.md Normal file
View File

@@ -0,0 +1,202 @@
# Abstract Wizard 开发计划
## 一、项目定位
Abstract Wizard 是一个面向通用 Web 应用的初始化与配置管理服务。
该服务以独立容器形式运行,通过受限 API 修改挂载卷中的配置文件(支持 JSON、YAML 等格式),并仅通过 SSH 隧道访问,不对公网开放。
目标是提供:
* 安全的初始化机制
* 可审计的配置变更能力
* 与现有 Web 应用解耦的配置管理方案
* 最小暴露面与最小权限原则
---
## 二、整体架构设计
### 1. 部署方式
* 使用 Go 实现
* 独立容器运行
* 与目标 Web 应用处于同一个 docker-compose 网络
* 共享配置卷volume
* 仅绑定宿主机 127.0.0.1
* 通过 ssh -L 进行访问
### 2. 核心组件
* HTTP API 服务层
* Token 鉴权中间件
* 配置解析与序列化模块
* 配置校验模块
* 原子写入模块
* 审计日志模块
---
## 三、功能需求
### 1. 配置读取
* 支持读取指定配置文件
* 自动识别 JSON / YAML
* 返回结构化数据
### 2. 配置修改
* 提供结构化更新接口
* 支持完整替换与局部更新
* 写入前进行语法校验
* 支持可选 Schema 校验
### 3. 原子写入机制
* 使用临时文件写入
* 成功后 rename 覆盖原文件
* 防止中途写入导致文件损坏
### 4. 版本备份
* 每次修改自动生成备份
* 保留最近 N 个版本
* 支持回滚
### 5. 初始化模式控制
* 默认启用初始化模式
* 初始化完成后可关闭写入功能
* 可切换为只读模式
---
## 四、安全设计
### 1. 网络隔离
* 端口仅绑定 127.0.0.1
* 不开放公网端口
* 不信任 X-Forwarded-For
### 2. 鉴权机制
* 必须提供 INIT_TOKEN
* 使用 Bearer Token 方式
* 不允许匿名访问
### 3. 容器安全
* 使用非 root 用户运行
* 不挂载 docker.sock
* 仅对配置卷开放写权限
* 其余文件系统只读
### 4. 审计日志
* 记录修改时间
* 记录修改来源 IP
* 记录变更内容摘要
---
## 五、非功能性要求
* 二进制体积尽量小
* 内存占用低
* 响应延迟可控
* 日志结构化输出
* 可通过环境变量配置
---
## 六、开发阶段划分
### 阶段一:基础框架
* 项目结构搭建
* HTTP 服务启动
* Token 中间件实现
* 配置读取接口
交付结果:只读配置服务
---
### 阶段二:写入能力
* 原子写入实现
* JSON / YAML 校验
* 备份机制实现
* 日志记录
交付结果:安全可写配置服务
---
### 阶段三:安全强化
* 初始化模式开关
* 只读模式
* 输入参数严格校验
* 错误处理完善
交付结果:生产可用版本
---
### 阶段四:容器化优化
* Multi-stage 构建
* 使用 distroless 或 scratch
* 非 root 运行
* docker-compose 集成测试
交付结果:可部署镜像
---
### 阶段五:扩展能力
* CLI 客户端
* Web 管理界面(可选)
* Git 版本控制集成
* 多应用支持
---
## 七、Compose 示例结构
* webapp
* database
* abstract-wizard
* 共享 volumeapp_config
* 端口绑定127.0.0.1:18080:8080
---
## 八、风险评估
* 配置误写风险 → Schema 校验
* 文件损坏风险 → 原子写入
* 未授权访问 → Token + 本地绑定
* 忘记关闭初始化接口 → 强制初始化状态机
---
## 九、成功标准
* 初始化流程可控
* 配置变更可追溯
* 不暴露公网攻击面
* 容器体积可控
* 与业务系统解耦
---
Abstract Wizard 应保持职责单一:
只负责配置初始化与安全修改,不承担业务逻辑或编排职责。
控制范围,才能保证安全与稳定。

265
server/handlers.go Normal file
View File

@@ -0,0 +1,265 @@
package server
import (
"encoding/json"
"io"
"net/http"
"os"
"AbstractWizard/config"
)
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
relPath := r.PathValue("path")
fullPath, err := config.FullPath(s.cfg.ConfigDir, relPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
data, err := os.ReadFile(fullPath)
if os.IsNotExist(err) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read file"})
return
}
format, err := config.DetectFormat(fullPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
parsed, err := config.Parse(data, format)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
s.audit.Log(r.RemoteAddr, "read", relPath, "config read")
writeJSON(w, http.StatusOK, parsed)
}
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
if s.GetMode() == ModeReadOnly {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "server is in readonly mode"})
return
}
relPath := r.PathValue("path")
fullPath, err := config.FullPath(s.cfg.ConfigDir, relPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20)) // 10 MB limit
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read request body"})
return
}
format, err := config.DetectFormat(fullPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
// Validate that the body is valid config
if _, err := config.Parse(body, format); err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
version, err := config.CreateBackup(s.cfg.ConfigDir, relPath, s.cfg.MaxBackups)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "backup failed: " + err.Error()})
return
}
if err := config.AtomicWrite(fullPath, body, 0o644); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "write failed: " + err.Error()})
return
}
s.audit.Log(r.RemoteAddr, "replace", relPath, "full replace")
resp := map[string]string{"status": "ok"}
if version != "" {
resp["backup_version"] = version
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
if s.GetMode() == ModeReadOnly {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "server is in readonly mode"})
return
}
relPath := r.PathValue("path")
fullPath, err := config.FullPath(s.cfg.ConfigDir, relPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
format, err := config.DetectFormat(fullPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
// Read existing config
existing := make(map[string]any)
data, err := os.ReadFile(fullPath)
if err != nil && !os.IsNotExist(err) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read existing file"})
return
}
if err == nil {
existing, err = config.Parse(data, format)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "existing file is invalid: " + err.Error()})
return
}
}
// Parse patch body
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read request body"})
return
}
patch, err := config.Parse(body, format)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
merged := config.DeepMerge(existing, patch)
out, err := config.Serialize(merged, format)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "serialization failed"})
return
}
version, err := config.CreateBackup(s.cfg.ConfigDir, relPath, s.cfg.MaxBackups)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "backup failed: " + err.Error()})
return
}
if err := config.AtomicWrite(fullPath, out, 0o644); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "write failed"})
return
}
s.audit.Log(r.RemoteAddr, "patch", relPath, "deep merge update")
resp := map[string]string{"status": "ok"}
if version != "" {
resp["backup_version"] = version
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
relPath := r.PathValue("path")
backups, err := config.ListBackups(s.cfg.ConfigDir, relPath)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
type backupResponse struct {
Version string `json:"version"`
Timestamp string `json:"timestamp"`
}
var resp []backupResponse
for _, b := range backups {
resp = append(resp, backupResponse{Version: b.Version, Timestamp: b.Timestamp})
}
if resp == nil {
resp = []backupResponse{}
}
s.audit.Log(r.RemoteAddr, "list_backups", relPath, "listed backups")
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
if s.GetMode() == ModeReadOnly {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "server is in readonly mode"})
return
}
relPath := r.PathValue("path")
var body struct {
Version string `json:"version"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if body.Version == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "version is required"})
return
}
// Backup current state before rollback
if _, err := config.CreateBackup(s.cfg.ConfigDir, relPath, s.cfg.MaxBackups); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "pre-rollback backup failed: " + err.Error()})
return
}
if err := config.Rollback(s.cfg.ConfigDir, relPath, body.Version); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rollback failed: " + err.Error()})
return
}
s.audit.Log(r.RemoteAddr, "rollback", relPath, "rolled back to "+body.Version)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "restored_version": body.Version})
}
func (s *Server) handleGetMode(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"mode": s.GetMode().String()})
}
func (s *Server) handleSetMode(w http.ResponseWriter, r *http.Request) {
var body struct {
Mode string `json:"mode"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
mode, ok := ParseMode(body.Mode)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid mode; must be 'init' or 'readonly'"})
return
}
s.SetMode(mode)
s.audit.Log(r.RemoteAddr, "set_mode", "", "mode changed to "+body.Mode)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "mode": mode.String()})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

27
server/middleware.go Normal file
View File

@@ -0,0 +1,27 @@
package server
import (
"log"
"net/http"
"time"
)
type responseRecorder struct {
http.ResponseWriter
statusCode int
}
func (r *responseRecorder) WriteHeader(code int) {
r.statusCode = code
r.ResponseWriter.WriteHeader(code)
}
// LoggingMiddleware logs each incoming request with method, path, status, and duration.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rec, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.statusCode, time.Since(start).Round(time.Millisecond))
})
}

137
server/server.go Normal file
View File

@@ -0,0 +1,137 @@
package server
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"AbstractWizard/audit"
)
// Mode represents the server operating mode.
type Mode int
const (
ModeInit Mode = iota // init: read/write allowed
ModeReadOnly // readonly: only reads allowed
)
func (m Mode) String() string {
switch m {
case ModeInit:
return "init"
case ModeReadOnly:
return "readonly"
default:
return "unknown"
}
}
// ParseMode converts a string to a Mode.
func ParseMode(s string) (Mode, bool) {
switch s {
case "init":
return ModeInit, true
case "readonly":
return ModeReadOnly, true
default:
return 0, false
}
}
// AppConfig holds environment-based configuration.
type AppConfig struct {
ConfigDir string
ListenAddr string
MaxBackups int
}
// Server is the main HTTP server.
type Server struct {
cfg AppConfig
audit *audit.Logger
mode Mode
modeMu sync.RWMutex
srv *http.Server
}
// New creates a new Server.
func New(cfg AppConfig, auditLog *audit.Logger) *Server {
s := &Server{
cfg: cfg,
audit: auditLog,
mode: ModeInit,
}
mux := http.NewServeMux()
s.registerRoutes(mux)
s.srv = &http.Server{
Addr: cfg.ListenAddr,
Handler: LoggingMiddleware(mux),
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
return s
}
// registerRoutes sets up all API routes.
func (s *Server) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /api/v1/config/{path...}", s.handleGetConfig)
mux.HandleFunc("PUT /api/v1/config/{path...}", s.handlePutConfig)
mux.HandleFunc("PATCH /api/v1/config/{path...}", s.handlePatchConfig)
mux.HandleFunc("GET /api/v1/backups/{path...}", s.handleListBackups)
mux.HandleFunc("POST /api/v1/rollback/{path...}", s.handleRollback)
mux.HandleFunc("GET /api/v1/mode", s.handleGetMode)
mux.HandleFunc("PUT /api/v1/mode", s.handleSetMode)
}
// GetMode returns the current server mode.
func (s *Server) GetMode() Mode {
s.modeMu.RLock()
defer s.modeMu.RUnlock()
return s.mode
}
// SetMode changes the server mode.
func (s *Server) SetMode(m Mode) {
s.modeMu.Lock()
defer s.modeMu.Unlock()
s.mode = m
}
// ListenAndServe starts the HTTP server and blocks until shutdown.
func (s *Server) ListenAndServe() error {
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
errCh := make(chan error, 1)
go func() {
log.Printf("listening on %s", s.cfg.ListenAddr)
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
}
close(errCh)
}()
select {
case sig := <-stop:
log.Printf("received signal %v, shutting down", sig)
case err := <-errCh:
if err != nil {
return err
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return s.srv.Shutdown(ctx)
}