CI / verify (push) Canceled after 0s
备份、隧道生命周期、日志、会话与设置项存在一批「静默失效」问题,本次一并收敛。 正确性: - 备份归档补上 session_secret 与 frp 配置。缺密钥时换机恢复后库里所有凭据都解不开, 界面表现为「配置都在、一执行就报解密失败」;同时支持口令加密(scrypt + AES-256-GCM)。 - 修复「立即备份」必失败:tar 头用的是遍历时的文件尺寸,而 logs、frpc 配置在打包期间 还在增长,读出的字节多于声明值导致 archive/tar 报错、整份备份作废。改为打开后重新 取尺寸并用 LimitReader 兜住,被截短时补零保持条目结构完整。 - 数据库改用 VACUUM INTO 生成一致性快照。直接打包正在写入的 SQLite 文件可能得到 结构完整但内容自相矛盾的副本,恢复后才暴露。 - Cloudflare 启动改用 StartAuto,Shutdown 显式 StopAll,子进程绑定应用 ctx。此前 重启会把用户手动停掉的隧道重新拉起,退出也可能留下孤儿进程。 - Nginx 访问/错误日志加轮转(200MB 阈值、gzip 归档、USR1 重开),读取改为从文件尾 分块倒读,并跨当日归档统计。此前日志只增不减,读接口每次全量扫描。 - 过期会话改由日任务清理;删除反代规则时同步清掉流量累计,避免同名规则重建继承旧读数。 - 启动加就绪门:Nginx bootstrap 完成后再放行 FRP 对账、Agent 隧道与 Cloudflare 自启。 设置项: - 新增设置项注册表(internal/settings/registry.go),白名单与默认值从它派生, 并记录每个键的消费者,防止再出现「界面能改、后端没人读」的黑洞设置。 - 接上三个此前不生效的设置项:timezone(启动与保存时应用到 time.Local)、 theme(后端为权威源,挂载时覆盖本地偏好、顶栏切换回写)、log_retention_days (控制日志归档保留天数)。 工程质量: - gofmt 全量格式化 23 个历史文件,CI 增加格式化门禁。 - 前端引入 ESLint(flat config)与 lint 脚本,CI 增加 lint 步骤;顺带修掉 logDisplay 的 let→const 与 LogsView 模板内直接改 prop 两处问题。 - 删除无引用的 pnpm-lock.yaml,prototype/ 加入忽略列表。 - 打包版本对齐:Docker manifest 同步到 2.0.0,CI 校验 VERSION 与 package.json 一致。 验证:gofmt 0 文件、go vet、go test ./...、npm run lint/typecheck/test/build 全绿。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
184 lines
3.6 KiB
Go
184 lines
3.6 KiB
Go
package acme
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
certstore "github.com/havline/havline/internal/certificate"
|
|
)
|
|
|
|
type JobEvent struct {
|
|
Type string `json:"type"`
|
|
Level string `json:"level,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
Result *JobDonePayload `json:"result,omitempty"`
|
|
}
|
|
|
|
type JobDonePayload struct {
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error,omitempty"`
|
|
Domain string `json:"domain,omitempty"`
|
|
CertPath string `json:"cert_path,omitempty"`
|
|
KeyPath string `json:"key_path,omitempty"`
|
|
CertDir string `json:"cert_dir,omitempty"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
type Job struct {
|
|
id string
|
|
mu sync.Mutex
|
|
events []JobEvent
|
|
done bool
|
|
waiters map[chan JobEvent]struct{}
|
|
}
|
|
|
|
type JobManager struct {
|
|
mu sync.Mutex
|
|
jobs map[string]*Job
|
|
}
|
|
|
|
func NewJobManager() *JobManager {
|
|
return &JobManager{jobs: map[string]*Job{}}
|
|
}
|
|
|
|
func (m *JobManager) Create() *Job {
|
|
job := &Job{
|
|
id: fmt.Sprintf("%d", time.Now().UnixNano()),
|
|
waiters: map[chan JobEvent]struct{}{},
|
|
}
|
|
m.mu.Lock()
|
|
m.jobs[job.id] = job
|
|
m.mu.Unlock()
|
|
return job
|
|
}
|
|
|
|
func (m *JobManager) Get(id string) *Job {
|
|
m.mu.Lock()
|
|
job := m.jobs[id]
|
|
m.mu.Unlock()
|
|
return job
|
|
}
|
|
|
|
func (j *Job) ID() string {
|
|
return j.id
|
|
}
|
|
|
|
func (j *Job) Append(level, message string) {
|
|
j.emit(JobEvent{Type: "log", Level: level, Message: message})
|
|
}
|
|
|
|
func (j *Job) Info(message string) { j.Append("info", message) }
|
|
func (j *Job) Warn(message string) { j.Append("warn", message) }
|
|
func (j *Job) Error(message string) { j.Append("error", message) }
|
|
|
|
func (j *Job) Finish(result JobDonePayload) {
|
|
j.emit(JobEvent{Type: "done", Result: &result})
|
|
}
|
|
|
|
func (j *Job) emit(ev JobEvent) {
|
|
j.mu.Lock()
|
|
j.events = append(j.events, ev)
|
|
if ev.Type == "done" {
|
|
j.done = true
|
|
}
|
|
for ch := range j.waiters {
|
|
ch <- ev
|
|
}
|
|
j.mu.Unlock()
|
|
}
|
|
|
|
func (j *Job) Snapshot() []JobEvent {
|
|
j.mu.Lock()
|
|
out := append([]JobEvent(nil), j.events...)
|
|
j.mu.Unlock()
|
|
return out
|
|
}
|
|
|
|
func (j *Job) Subscribe() chan JobEvent {
|
|
ch := make(chan JobEvent, 32)
|
|
j.mu.Lock()
|
|
j.waiters[ch] = struct{}{}
|
|
j.mu.Unlock()
|
|
return ch
|
|
}
|
|
|
|
func (j *Job) Unsubscribe(ch chan JobEvent) {
|
|
j.mu.Lock()
|
|
delete(j.waiters, ch)
|
|
j.mu.Unlock()
|
|
close(ch)
|
|
}
|
|
|
|
func (m *JobManager) Stream(ctx context.Context, w http.ResponseWriter, id string, flush func() error) error {
|
|
job := m.Get(id)
|
|
if job == nil {
|
|
return fmt.Errorf("任务不存在或已过期")
|
|
}
|
|
|
|
ch := job.Subscribe()
|
|
defer job.Unsubscribe(ch)
|
|
|
|
for _, ev := range job.Snapshot() {
|
|
if err := writeJobSSE(w, ev); err != nil {
|
|
return err
|
|
}
|
|
if err := flush(); err != nil {
|
|
return err
|
|
}
|
|
if ev.Type == "done" {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case ev := <-ch:
|
|
if err := writeJobSSE(w, ev); err != nil {
|
|
return err
|
|
}
|
|
if err := flush(); err != nil {
|
|
return err
|
|
}
|
|
if ev.Type == "done" {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeJobSSE(w io.Writer, ev JobEvent) error {
|
|
data, err := json.Marshal(ev)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data)
|
|
return err
|
|
}
|
|
|
|
func jobDoneFromRecord(rec certstore.Record, err error) JobDonePayload {
|
|
if err != nil {
|
|
return JobDonePayload{OK: false, Error: err.Error()}
|
|
}
|
|
payload := JobDonePayload{
|
|
OK: true,
|
|
Domain: rec.Domain,
|
|
CertPath: rec.CertPath,
|
|
KeyPath: rec.KeyPath,
|
|
}
|
|
if rec.CertPath != "" {
|
|
payload.CertDir = filepath.Dir(rec.CertPath)
|
|
}
|
|
if rec.ExpiresAt != nil {
|
|
payload.ExpiresAt = rec.ExpiresAt.UTC().Format(time.RFC3339)
|
|
}
|
|
return payload
|
|
}
|