init
This commit is contained in:
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal 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
9
.idea/AbstractWizard.iml
generated
Normal 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
4
.idea/encodings.xml
generated
Normal 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
11
.idea/go.imports.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
21
Dockerfile
Normal 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
287
README.md
Normal 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
46
audit/logger.go
Normal 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
55
config/atomic.go
Normal 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
156
config/backup.go
Normal 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
83
config/parser.go
Normal 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
49
config/validation.go
Normal 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
14
docker-compose.yaml
Normal 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
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module AbstractWizard
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
4
go.sum
Normal file
4
go.sum
Normal 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
51
main.go
Normal 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
202
proj_plan.md
Normal 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
|
||||||
|
* 共享 volume:app_config
|
||||||
|
* 端口绑定:127.0.0.1:18080:8080
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、风险评估
|
||||||
|
|
||||||
|
* 配置误写风险 → Schema 校验
|
||||||
|
* 文件损坏风险 → 原子写入
|
||||||
|
* 未授权访问 → Token + 本地绑定
|
||||||
|
* 忘记关闭初始化接口 → 强制初始化状态机
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、成功标准
|
||||||
|
|
||||||
|
* 初始化流程可控
|
||||||
|
* 配置变更可追溯
|
||||||
|
* 不暴露公网攻击面
|
||||||
|
* 容器体积可控
|
||||||
|
* 与业务系统解耦
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Abstract Wizard 应保持职责单一:
|
||||||
|
|
||||||
|
只负责配置初始化与安全修改,不承担业务逻辑或编排职责。
|
||||||
|
|
||||||
|
控制范围,才能保证安全与稳定。
|
||||||
265
server/handlers.go
Normal file
265
server/handlers.go
Normal 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
27
server/middleware.go
Normal 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
137
server/server.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user