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