From 72843442aca8920a17ed442443ac88c4fcbe4a9d Mon Sep 17 00:00:00 2001 From: hzhang Date: Sun, 15 Feb 2026 08:49:27 +0000 Subject: [PATCH] init --- .idea/.gitignore | 10 ++ .idea/AbstractWizard.iml | 9 ++ .idea/encodings.xml | 4 + .idea/go.imports.xml | 11 ++ .idea/modules.xml | 8 ++ .idea/vcs.xml | 6 + Dockerfile | 21 +++ README.md | 287 +++++++++++++++++++++++++++++++++++++++ audit/logger.go | 46 +++++++ config/atomic.go | 55 ++++++++ config/backup.go | 156 +++++++++++++++++++++ config/parser.go | 83 +++++++++++ config/validation.go | 49 +++++++ docker-compose.yaml | 14 ++ go.mod | 5 + go.sum | 4 + main.go | 51 +++++++ proj_plan.md | 202 +++++++++++++++++++++++++++ server/handlers.go | 265 ++++++++++++++++++++++++++++++++++++ server/middleware.go | 27 ++++ server/server.go | 137 +++++++++++++++++++ 21 files changed, 1450 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/AbstractWizard.iml create mode 100644 .idea/encodings.xml create mode 100644 .idea/go.imports.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 audit/logger.go create mode 100644 config/atomic.go create mode 100644 config/backup.go create mode 100644 config/parser.go create mode 100644 config/validation.go create mode 100644 docker-compose.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 proj_plan.md create mode 100644 server/handlers.go create mode 100644 server/middleware.go create mode 100644 server/server.go diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -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/ diff --git a/.idea/AbstractWizard.iml b/.idea/AbstractWizard.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/AbstractWizard.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..0471e06 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a96637d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf00a6e --- /dev/null +++ b/README.md @@ -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 # 示例部署配置 +``` diff --git a/audit/logger.go b/audit/logger.go new file mode 100644 index 0000000..376c741 --- /dev/null +++ b/audit/logger.go @@ -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)) +} diff --git a/config/atomic.go b/config/atomic.go new file mode 100644 index 0000000..d041525 --- /dev/null +++ b/config/atomic.go @@ -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 +} diff --git a/config/backup.go b/config/backup.go new file mode 100644 index 0000000..d7f33f5 --- /dev/null +++ b/config/backup.go @@ -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 +} diff --git a/config/parser.go b/config/parser.go new file mode 100644 index 0000000..6a81a05 --- /dev/null +++ b/config/parser.go @@ -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 +} diff --git a/config/validation.go b/config/validation.go new file mode 100644 index 0000000..b4dae91 --- /dev/null +++ b/config/validation.go @@ -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 +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..ac6b32e --- /dev/null +++ b/docker-compose.yaml @@ -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: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2240697 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module AbstractWizard + +go 1.24 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..068535b --- /dev/null +++ b/main.go @@ -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 +} diff --git a/proj_plan.md b/proj_plan.md new file mode 100644 index 0000000..664cafc --- /dev/null +++ b/proj_plan.md @@ -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 +* 共享 volume:app_config +* 端口绑定:127.0.0.1:18080:8080 + +--- + +## 八、风险评估 + +* 配置误写风险 → Schema 校验 +* 文件损坏风险 → 原子写入 +* 未授权访问 → Token + 本地绑定 +* 忘记关闭初始化接口 → 强制初始化状态机 + +--- + +## 九、成功标准 + +* 初始化流程可控 +* 配置变更可追溯 +* 不暴露公网攻击面 +* 容器体积可控 +* 与业务系统解耦 + +--- + +Abstract Wizard 应保持职责单一: + +只负责配置初始化与安全修改,不承担业务逻辑或编排职责。 + +控制范围,才能保证安全与稳定。 diff --git a/server/handlers.go b/server/handlers.go new file mode 100644 index 0000000..cc1e365 --- /dev/null +++ b/server/handlers.go @@ -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) +} diff --git a/server/middleware.go b/server/middleware.go new file mode 100644 index 0000000..df7909a --- /dev/null +++ b/server/middleware.go @@ -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)) + }) +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..27cd468 --- /dev/null +++ b/server/server.go @@ -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) +}