add global config support

This commit is contained in:
wjqserver 2025-06-30 15:31:14 +08:00
parent cd1e1a42f3
commit 34d553a890
23 changed files with 1682 additions and 343 deletions

View file

@ -1,20 +1,23 @@
{ {
debug admin localhost:2019
admin :2019
http_port 80 http_port 80
https_port 443 https_port 443
metrics metrics
order ja4h_header first order ja4h_header first
order webdav before file_server order webdav before file_server
order cache before rewrite order cache before rewrite
cache { cache {
cache_name CaddyCache cache_name CaddyCache
} }
log { log {
level INFO level INFO
output file ./log/caddy.log { output file ./log/caddy.log {
roll_size 10MB roll_size 10MB
roll_keep 10 roll_keep 10
roll_keep_for 24h
} }
} }
} }
@ -71,9 +74,6 @@
} }
(tls) { (tls) {
tls {
dns {args[0]} {args[1]}
}
} }
(rate_limit) { (rate_limit) {

View file

@ -46,6 +46,8 @@ func ApiGroup(v0 touka.IRouter, cdb *db.ConfigDB, cfg *config.Config) {
glbr.GET("/tls/providers", func(c *touka.Context) { glbr.GET("/tls/providers", func(c *touka.Context) {
c.JSON(200, gen.ProviderList) c.JSON(200, gen.ProviderList)
}) })
glbr.PUT("/config", PutGlobalConfig(cdb, cfg))
glbr.GET("/config", GetGlobalConfig(cdb))
} }
} }

View file

@ -35,7 +35,7 @@ func PutConfig(cdb *db.ConfigDB, cfg *config.Config) touka.HandlerFunc {
var config gen.CaddyUniConfig var config gen.CaddyUniConfig
err := c.ShouldBindJSON(&config) err := c.ShouldBindJSON(&config)
if err != nil { if err != nil {
c.JSON(500, touka.H{"error": err.Error()}) c.JSON(400, touka.H{"error": err.Error()})
return return
} }

View file

@ -1 +1,87 @@
package api package api
import (
"caddydash/config"
"caddydash/db"
"caddydash/gen"
"os"
"github.com/infinite-iroha/touka"
)
func PutGlobalConfig(cdb *db.ConfigDB, cfg *config.Config) touka.HandlerFunc {
return func(c *touka.Context) {
var config gen.CaddyGlobalConfig
err := c.ShouldBindJSON(&config)
if err != nil {
c.JSON(400, touka.H{"error": err.Error()})
return
}
var paramsGob []byte
paramsGob, err = gen.EncodeGobConfig(config)
if err != nil {
c.Warnf("encode gob config error: %v", err)
c.JSON(500, touka.H{"error": err.Error()})
return
}
// 取出数据库内的tmpl
tmplContent, err := cdb.GetGlobalTemplate("caddyfile")
if err != nil {
c.Warnf("get global template error: %v", err)
c.JSON(500, touka.H{"error": err.Error()})
return
}
renderedContent, err := gen.RenderGlobalConfig(paramsGob, tmplContent)
if err != nil {
c.Warnf("render global config error: %v", err)
c.JSON(500, touka.H{"error": err.Error()})
return
}
//回写条目到数据库
err = cdb.SaveGlobalConfig(db.GlobalConfig{
Filename: "caddyfile",
Params: paramsGob,
TmplContent: tmplContent,
RenderedContent: renderedContent,
})
if err != nil {
c.Warnf("save global config error: %v", err)
c.JSON(500, touka.H{"error": err.Error()})
return
}
err = os.WriteFile(cfg.Server.CaddyDir+"Caddyfile", renderedContent, 0644)
if err != nil {
c.Warnf("write Caddyfile error: %v", err)
c.JSON(500, touka.H{"error": err.Error()})
return
}
c.JSON(200, touka.H{"message": "global config saved"})
return
}
}
// GetGlobalConfig 检出已有配置
func GetGlobalConfig(cdb *db.ConfigDB) touka.HandlerFunc {
return func(c *touka.Context) {
globalConfig, err := cdb.GetGlobalConfig("caddyfile")
if err != nil {
c.JSON(500, touka.H{"error": err.Error()})
return
}
var config gen.CaddyGlobalConfig
err = gen.DecodeGobConfig(globalConfig.Params, &config)
if err != nil {
c.JSON(500, touka.H{"error": err.Error()})
return
}
c.JSON(200, config)
}
}

View file

@ -105,7 +105,7 @@ func StopCaddy() touka.HandlerFunc {
return return
} }
client := c.GetHTTPC() client := c.GetHTTPC()
rb := client.NewRequestBuilder("POST", "http://127.0.0.1:2019/stop") rb := client.NewRequestBuilder("POST", "http://localhost:2019/stop")
resp, err := rb.Execute() resp, err := rb.Execute()
if err != nil { if err != nil {
c.JSON(500, map[string]string{"error": err.Error()}) c.JSON(500, map[string]string{"error": err.Error()})
@ -139,7 +139,7 @@ func RestartCaddy(cfg *config.Config) touka.HandlerFunc {
// StopCaddy // StopCaddy
client := c.GetHTTPC() client := c.GetHTTPC()
rb := client.NewRequestBuilder("POST", "http://127.0.0.1:2019/stop") rb := client.NewRequestBuilder("POST", "http://localhost:2019/stop")
resp, err := rb.Execute() resp, err := rb.Execute()
if err != nil { if err != nil {
c.JSON(500, map[string]string{"error": err.Error()}) c.JSON(500, map[string]string{"error": err.Error()})

View file

@ -8,7 +8,6 @@ import (
type Config struct { type Config struct {
Server ServerConfig Server ServerConfig
Tmpl TmplConfig
DB DatabaseConfig `toml:"database"` DB DatabaseConfig `toml:"database"`
} }
@ -26,14 +25,6 @@ type ServerConfig struct {
CaddyDir string `toml:"caddyDir"` CaddyDir string `toml:"caddyDir"`
} }
/*
[tmpl]
path = "./tmpl"
*/
type TmplConfig struct {
Path string `toml:"path"`
}
/* /*
[database] [database]
filepath = "sqlite.db" filepath = "sqlite.db"
@ -100,9 +91,6 @@ func DefaultConfig() *Config {
Debug: false, Debug: false,
CaddyDir: "./", CaddyDir: "./",
}, },
Tmpl: TmplConfig{
Path: "./tmpl",
},
DB: DatabaseConfig{ DB: DatabaseConfig{
Filepath: "caddydash.db", Filepath: "caddydash.db",
}, },

View file

@ -4,8 +4,5 @@ port = 8080
debug = false debug = false
caddyDir = "./" caddyDir = "./"
[tmpl]
path = "/data/github/WJQSERVER/caddydash/tmpl"
[database] [database]
filepath = "test.db" filepath = "test.db"

View file

@ -44,6 +44,15 @@ type UsersTable struct {
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
// GlobalConfig 全局CaddyFile配置专用table
type GlobalConfig struct {
Filename string `json:"filename"`
Params []byte `json:"params"`
TmplContent []byte `json:"tmpl_content"`
RenderedContent []byte `json:"rendered_content"`
UpdatedAt int64 `json:"updated_at"`
}
// ConfigDB // ConfigDB
type ConfigDB struct { type ConfigDB struct {
DB *sql.DB DB *sql.DB
@ -124,7 +133,6 @@ func (cdb *ConfigDB) createTables() error {
return fmt.Errorf("failed to create 'rendered_configs' table: %w", err) return fmt.Errorf("failed to create 'rendered_configs' table: %w", err)
} }
//
// 4. 创建 users 表 // 4. 创建 users 表
_, err = tx.Exec(` _, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
@ -137,6 +145,19 @@ func (cdb *ConfigDB) createTables() error {
return fmt.Errorf("failed to create 'users' table: %w", err) return fmt.Errorf("failed to create 'users' table: %w", err)
} }
// 5. 创建 global_configs 表
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS global_configs (
filename TEXT PRIMARY KEY,
params BLOB NOT NULL DEFAULT '',
tmpl_content BLOB NOT NULL DEFAULT '',
rendered_content BLOB NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);`)
if err != nil {
return fmt.Errorf("failed to create 'global_configs' table: %w", err)
}
// 提交事务 // 提交事务
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction for table creation: %w", err) return fmt.Errorf("failed to commit transaction for table creation: %w", err)

View file

@ -1,13 +1,9 @@
package db package db
import ( import (
"bytes"
"database/sql" "database/sql"
"encoding/gob"
"errors" "errors"
"fmt" "fmt"
"strings"
"text/template"
) )
// 用户校验操作 // 用户校验操作
@ -413,92 +409,119 @@ func (cdb *ConfigDB) RangeAllReandered() ([]RenderedConfigEntry, error) {
return renderedConfigs, nil return renderedConfigs, nil
} }
// --- GOB 编码/解码辅助函数 --- // --- 全局配置操作 (Global_Configs Table) ---
/*
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS global_configs (
filename TEXT PRIMARY KEY,
params BLOB NOT NULL,
tmpl_content BLOB NOT NULL,
rendered_content BLOB NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);`)
*/
// gobEncode 将 Go 值编码为 GOB 格式的字节切片. // SaveGlobalConfig 在 'global_configs' 表中保存或更新全局配置.
// 'data' 必须是可被 GOB 编码的值; 例如基本类型, 切片, 映射, 结构体. func (cdb *ConfigDB) SaveGlobalConfig(entry GlobalConfig) error {
func gobEncode(data interface{}) ([]byte, error) { insertSQL := `
var buf bytes.Buffer INSERT INTO global_configs (filename, params, tmpl_content, rendered_content)
enc := gob.NewEncoder(&buf) VALUES (?, ?, ?, ?)
if err := enc.Encode(data); err != nil { ON CONFLICT(filename) DO UPDATE SET
return nil, fmt.Errorf("db: failed to GOB encode data: %w", err) params = EXCLUDED.params,
} tmpl_content = EXCLUDED.tmpl_content,
return buf.Bytes(), nil rendered_content = EXCLUDED.rendered_content,
} updated_at = strftime('%s', 'now');
`
// gobDecode 将 GOB 格式的字节切片解码到 Go 值. _, err := cdb.DB.Exec(insertSQL, entry.Filename, entry.Params, entry.TmplContent, entry.RenderedContent)
// 'data' 是 GOB 编码的字节切片. if err != nil {
// 'valuePtr' 必须是指向目标 Go 值的指针; 其类型必须与编码时的数据类型兼容. return fmt.Errorf("db: failed to save global config for '%s': %w", entry.Filename, err)
func gobDecode(data []byte, valuePtr interface{}) error {
buf := bytes.NewBuffer(data)
dec := gob.NewDecoder(buf)
if err := dec.Decode(valuePtr); err != nil {
return fmt.Errorf("db: failed to GOB decode data: %w", err)
} }
return nil return nil
} }
// --- 业务逻辑: 渲染并保存 (由使用者调用) --- // GetGlobalConfig 从 'global_configs' 表中获取全局配置.
// RenderAndSaveConfig 从数据库获取模板和参数; 渲染后保存到 'rendered_configs' 表. func (cdb *ConfigDB) GetGlobalConfig(filename string) (*GlobalConfig, error) {
// 这是一个组合操作; 通常由应用程序逻辑在需要时调用. querySQL := `SELECT filename, params, tmpl_content, rendered_content, updated_at FROM global_configs WHERE filename = ?;`
// filename: 要渲染的配置文件的唯一标识. row := cdb.DB.QueryRow(querySQL, filename)
// templateParser: 一个实现了 TemplateParser 接口的模板解析器实例.
// dynamicParams: 运行时动态提供的参数; 它们会覆盖存储在数据库中的同名参数.
func (cdb *ConfigDB) RenderAndSaveConfig(filename string, dynamicParams map[string]interface{}) error {
// 1. 获取模板内容.
tmplEntry, err := cdb.GetTemplate(filename)
if err != nil {
return fmt.Errorf("db: failed to get template '%s' for rendering: %w", filename, err)
}
// 2. 获取存储的参数. entry := &GlobalConfig{}
paramsEntry, err := cdb.GetParams(filename) err := row.Scan(&entry.Filename, &entry.Params, &entry.TmplContent, &entry.RenderedContent, &entry.UpdatedAt)
var storedParams map[string]interface{}
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
storedParams = make(map[string]interface{}) // 参数不存在; 使用空 map. return nil, fmt.Errorf("db: global config '%s' not found: %w", filename, err)
} else {
return fmt.Errorf("db: failed to get parameters for '%s': %w", filename, err)
}
} else {
// 解码 GOB 参数到 map.
if err := gobDecode(paramsEntry.ParamsGOB, &storedParams); err != nil {
return fmt.Errorf("db: failed to decode stored parameters for '%s': %w", filename, err)
} }
return nil, fmt.Errorf("db: failed to get global config '%s': %w", filename, err)
} }
return entry, nil
}
// 合并参数: 动态传入的参数覆盖存储的参数. // DeleteGlobalConfig 从 'global_configs' 表中删除全局配置.
if dynamicParams != nil { func (cdb *ConfigDB) DeleteGlobalConfig(filename string) error {
for k, v := range dynamicParams { _, err := cdb.DB.Exec(`DELETE FROM global_configs WHERE filename = ?;`, filename)
storedParams[k] = v if err != nil {
} return fmt.Errorf("db: failed to delete global config for '%s': %w", filename, err)
}
// 3. 渲染模板.
var parsedTmpl *template.Template
var parseErr error
// 使用传入的 templateParser 实例来解析模板内容.
// 注意: templateParser.Parse 是在提供的实例上调用; 以解析特定内容.
//parsedTmpl, parseErr = templateParser.Parse(string(tmplEntry.Content))
parsedTmpl, parseErr = template.New(tmplEntry.Filename).Parse(string(tmplEntry.Content))
if parseErr != nil {
return fmt.Errorf("db: failed to parse template content for '%s': %w", tmplEntry.Filename, parseErr)
}
var renderedContentBuilder strings.Builder
if err := parsedTmpl.Execute(&renderedContentBuilder, storedParams); err != nil {
return fmt.Errorf("db: failed to render template '%s': %w", tmplEntry.Filename, err)
}
// 4. 保存渲染结果.
renderedEntry := RenderedConfigEntry{
Filename: filename,
RenderedContent: []byte(renderedContentBuilder.String()),
}
if err := cdb.SaveRenderedConfig(renderedEntry); err != nil {
return fmt.Errorf("db: failed to save rendered config for '%s': %w", filename, err)
} }
return nil return nil
} }
// SaveGlobalParams
func (cdb *ConfigDB) SaveGlobalParams(filename string, params []byte) error {
insertSQL := `
INSERT INTO global_configs (filename, params)
VALUES (?, ?)
ON CONFLICT(filename) DO UPDATE SET
params = EXCLUDED.params,
updated_at = strftime('%s', 'now');
`
_, err := cdb.DB.Exec(insertSQL, filename, params)
if err != nil {
return fmt.Errorf("db: failed to save global params for '%s': %w", filename, err)
}
return nil
}
// SaveGlobalRenderedContent
func (cdb *ConfigDB) SaveGlobalRenderedContent(filename string, renderedContent []byte) error {
insertSQL := `
INSERT INTO global_configs (filename, rendered_content)
VALUES (?, ?)
ON CONFLICT(filename) DO UPDATE SET
rendered_content = EXCLUDED.rendered_content,
updated_at = strftime('%s', 'now');
`
_, err := cdb.DB.Exec(insertSQL, filename, renderedContent)
if err != nil {
return fmt.Errorf("db: failed to save global rendered content for '%s': %w", filename, err)
}
return nil
}
// SaveGlobalTemplate
func (cdb *ConfigDB) SaveGlobalTemplate(filename string, tmplContent []byte) error {
insertSQL := `
INSERT INTO global_configs (filename, tmpl_content)
VALUES (?, ?)
ON CONFLICT(filename) DO UPDATE SET
tmpl_content = EXCLUDED.tmpl_content,
updated_at = strftime('%s', 'now');
`
_, err := cdb.DB.Exec(insertSQL, filename, tmplContent)
if err != nil {
return fmt.Errorf("db: failed to save global template for '%s': %w", filename, err)
}
return nil
}
// GetGlobalTemplate
func (cdb *ConfigDB) GetGlobalTemplate(filename string) ([]byte, error) {
querySQL := `SELECT tmpl_content FROM global_configs WHERE filename = ?;`
var tmplContent []byte
err := cdb.DB.QueryRow(querySQL, filename).Scan(&tmplContent)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("db: global template '%s' not found: %w", filename, err)
}
return nil, fmt.Errorf("db: failed to get global template '%s': %w", filename, err)
}
return tmplContent, nil
}

101
db/utils.go Normal file
View file

@ -0,0 +1,101 @@
package db
import (
"bytes"
"database/sql"
"encoding/gob"
"errors"
"fmt"
"strings"
"text/template"
)
// --- GOB 编码/解码辅助函数 ---
// gobEncode 将 Go 值编码为 GOB 格式的字节切片.
// 'data' 必须是可被 GOB 编码的值; 例如基本类型, 切片, 映射, 结构体.
func gobEncode(data interface{}) ([]byte, error) {
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
if err := enc.Encode(data); err != nil {
return nil, fmt.Errorf("db: failed to GOB encode data: %w", err)
}
return buf.Bytes(), nil
}
// gobDecode 将 GOB 格式的字节切片解码到 Go 值.
// 'data' 是 GOB 编码的字节切片.
// 'valuePtr' 必须是指向目标 Go 值的指针; 其类型必须与编码时的数据类型兼容.
func gobDecode(data []byte, valuePtr interface{}) error {
buf := bytes.NewBuffer(data)
dec := gob.NewDecoder(buf)
if err := dec.Decode(valuePtr); err != nil {
return fmt.Errorf("db: failed to GOB decode data: %w", err)
}
return nil
}
// --- 业务逻辑: 渲染并保存 (由使用者调用) ---
// RenderAndSaveConfig 从数据库获取模板和参数; 渲染后保存到 'rendered_configs' 表.
// 这是一个组合操作; 通常由应用程序逻辑在需要时调用.
// filename: 要渲染的配置文件的唯一标识.
// templateParser: 一个实现了 TemplateParser 接口的模板解析器实例.
// dynamicParams: 运行时动态提供的参数; 它们会覆盖存储在数据库中的同名参数.
func (cdb *ConfigDB) RenderAndSaveConfig(filename string, dynamicParams map[string]interface{}) error {
// 1. 获取模板内容.
tmplEntry, err := cdb.GetTemplate(filename)
if err != nil {
return fmt.Errorf("db: failed to get template '%s' for rendering: %w", filename, err)
}
// 2. 获取存储的参数.
paramsEntry, err := cdb.GetParams(filename)
var storedParams map[string]interface{}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
storedParams = make(map[string]interface{}) // 参数不存在; 使用空 map.
} else {
return fmt.Errorf("db: failed to get parameters for '%s': %w", filename, err)
}
} else {
// 解码 GOB 参数到 map.
if err := gobDecode(paramsEntry.ParamsGOB, &storedParams); err != nil {
return fmt.Errorf("db: failed to decode stored parameters for '%s': %w", filename, err)
}
}
// 合并参数: 动态传入的参数覆盖存储的参数.
if dynamicParams != nil {
for k, v := range dynamicParams {
storedParams[k] = v
}
}
// 3. 渲染模板.
var parsedTmpl *template.Template
var parseErr error
// 使用传入的 templateParser 实例来解析模板内容.
// 注意: templateParser.Parse 是在提供的实例上调用; 以解析特定内容.
//parsedTmpl, parseErr = templateParser.Parse(string(tmplEntry.Content))
parsedTmpl, parseErr = template.New(tmplEntry.Filename).Parse(string(tmplEntry.Content))
if parseErr != nil {
return fmt.Errorf("db: failed to parse template content for '%s': %w", tmplEntry.Filename, parseErr)
}
var renderedContentBuilder strings.Builder
if err := parsedTmpl.Execute(&renderedContentBuilder, storedParams); err != nil {
return fmt.Errorf("db: failed to render template '%s': %w", tmplEntry.Filename, err)
}
// 4. 保存渲染结果.
renderedEntry := RenderedConfigEntry{
Filename: filename,
RenderedContent: []byte(renderedContentBuilder.String()),
}
if err := cdb.SaveRenderedConfig(renderedEntry); err != nil {
return fmt.Errorf("db: failed to save rendered config for '%s': %w", filename, err)
}
return nil
}

File diff suppressed because it is too large Load diff

141
frontend/global.html Normal file
View file

@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>全局配置 - CaddyDash</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="app-container">
<!-- 侧边栏 (保持不变) -->
<aside class="sidebar" id="sidebar">
<header class="sidebar-header"><i class="fa-solid fa-rocket"></i>
<h1>CaddyDash</h1>
</header>
<nav class="sidebar-nav">
<ul>
<li><a href="/" data-nav-id="configs"><i class="fa-solid fa-sitemap"></i> <span>站点配置</span></a></li>
<li><a href="/global.html" data-nav-id="global"><i class="fa-solid fa-globe"></i>
<span>全局配置</span></a></li>
<li><a href="/settings.html" data-nav-id="settings"><i class="fa-solid fa-gears"></i>
<span>面板设置</span></a></li>
</ul>
</nav>
<div class="sidebar-bottom">
<div class="theme-switcher"><i class="fa-solid fa-sun"></i><label class="switch"><input type="checkbox"
id="theme-toggle-input"><span class="slider"></span></label><i class="fa-solid fa-moon"></i>
</div>
<div class="caddy-control-panel">
<div id="caddy-status-indicator" class="caddy-status"><span class="status-dot checking"></span><span
class="status-text">检查中...</span></div>
<div id="caddy-action-button-container"></div>
</div>
<div class="logout-section"><button id="logout-btn" class="btn btn-secondary"><i
class="fa-solid fa-right-from-bracket"></i><span>退出登录</span></button></div>
</div>
</aside>
<main class="main-content">
<header class="main-header">
<button class="btn-icon" id="menu-toggle-btn"><i class="fa-solid fa-bars"></i></button>
<h2>全局 Caddyfile 配置</h2>
</header>
<div id="view-container">
<section class="card-panel">
<p class="text-secondary" style="margin-top:-1rem; margin-bottom: 2rem;">修改这些配置将会重写您的主 Caddyfile 并触发
Caddy 重载。</p>
<form id="global-caddy-form">
<fieldset>
<legend>通用选项</legend>
<div class="form-grid">
<div class="form-group"><label for="admin_port">管理API端口 (只读)</label><input type="text"
id="admin_port" name="admin_port" value=":2019" readonly></div>
<div class="form-group"><label for="http_port">HTTP 端口</label><input type="number"
id="http_port" name="http_port" min="1" max="65535"></div>
<div class="form-group"><label for="https_port">HTTPS 端口</label><input type="number"
id="https_port" name="https_port" min="1" max="65535"></div>
</div>
<div class="checkbox-grid">
<label class="custom-checkbox"><input type="checkbox" id="debug" name="debug"><span
class="checkmark"></span> 启用Debug模式</label>
<label class="custom-checkbox"><input type="checkbox" id="metrics" name="metrics"><span
class="checkmark"></span> 启用Prometheus指标</label>
</div>
</fieldset>
<fieldset>
<legend>主日志配置</legend>
<div class="form-grid">
<div class="form-group"><label>日志级别</label>
<div id="select-log-level" class="custom-select"></div>
</div>
<div class="form-group"><label for="log_rotate_size">滚动大小</label><input type="text"
id="log_rotate_size" name="log_rotate_size" placeholder="例如: 10MB"></div>
<div class="form-group"><label for="log_rotate_keep">保留文件数</label><input type="text"
id="log_rotate_keep" name="log_rotate_keep" placeholder="例如: 10"></div>
<div class="form-group"><label for="log_rotate_keep_for_time">保留时间</label><input
type="text" id="log_rotate_keep_for_time" name="log_rotate_keep_for_time"
placeholder="例如: 24h"></div>
</div>
</fieldset>
<fieldset>
<legend>全局TLS配置 (ACME)</legend>
<label class="custom-checkbox"><input type="checkbox" id="enable_dns_challenge"
name="enable_dns_challenge"><span class="checkmark"></span> 启用全局 DNS
Challenge</label>
<div id="global-tls-config-group" class="hidden" style="margin-top:16px;">
<div class="form-grid">
<div class="form-group"><label>DNS 提供商</label>
<div id="select-tls-provider" class="custom-select"></div>
</div>
<div class="form-group"><label for="tls_email">ACME 邮箱</label><input type="email"
id="tls_email" name="tls_email" placeholder="用于证书申请和续订通知"></div>
<div class="form-group"><label for="tls_token">API Token (或等效凭证)</label><input
type="password" id="tls_token" name="tls_token" autocomplete="new-password">
</div>
</div>
</div>
</fieldset>
<!-- 新增: ECH 配置区域 -->
<fieldset>
<legend>加密客户端问候 (ECH)</legend>
<label class="custom-checkbox"><input type="checkbox" id="enable_ech"
name="enable_ech"><span class="checkmark"></span> 启用 ECH (实验性功能)</label>
<div id="ech-config-group" class="hidden" style="margin-top:16px;">
<div class="form-group">
<label for="tls_ech_sni">ECH Outer SNI</label>
<input type="text" id="tls_ech_sni" name="tls_ech_sni"
placeholder="例如: ech.example.com">
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit" class="btn btn-primary"><i class="fa-solid fa-save"></i>
保存并重载</button>
</div>
</form>
</section>
</div>
</main>
</div>
<div id="toast-container" class="toast-container"></div>
<div id="dialog-container"></div>
<script type="module" src="js/common.js"></script>
<script type="module" src="js/global.js"></script>
</body>
</html>

View file

@ -25,22 +25,26 @@ function getFormStateAsString() {
} }
return JSON.stringify(data); return JSON.stringify(data);
} }
async function attemptExitForm() { async function attemptExitForm() {
if (getFormStateAsString() !== state.initialFormState) { if (getFormStateAsString() !== state.initialFormState) {
if (await notification.confirm('您有未保存的更改。确定要放弃吗?')) switchView(DOMElements.configListPanel); if (await notification.confirm('您有未保存的更改。确定要放弃吗?')) switchView(DOMElements.configListPanel);
} else switchView(DOMElements.configListPanel); } else switchView(DOMElements.configListPanel);
} }
async function handleLogout() { async function handleLogout() {
if (!await notification.confirm('您确定要退出登录吗?')) return; if (!await notification.confirm('您确定要退出登录吗?')) return;
notification.toast('正在退出...', 'info'); notification.toast('正在退出...', 'info');
setTimeout(() => { window.location.href = `/v0/api/auth/logout`; }, 500); setTimeout(() => { window.location.href = `/v0/api/auth/logout`; }, 500);
} }
async function loadAllConfigs() { async function loadAllConfigs() {
try { try {
const filenames = await api.get('/config/filenames'); const filenames = await api.get('/config/filenames');
renderConfigList(filenames); renderConfigList(filenames);
} catch(error) { if (error.message) notification.toast(`加载配置列表失败: ${error.message}`, 'error'); } } catch (error) { if (error.message) notification.toast(`加载配置列表失败: ${error.message}`, 'error'); }
} }
async function handleEditConfig(originalFilename) { async function handleEditConfig(originalFilename) {
try { try {
const [config, rendered] = await Promise.all([ const [config, rendered] = await Promise.all([
@ -55,16 +59,18 @@ async function handleEditConfig(originalFilename) {
const mode = config.upstream_config?.enable_upstream ? 'reverse_proxy' : (config.file_server_config?.enable_file_server ? 'file_server' : 'none'); const mode = config.upstream_config?.enable_upstream ? 'reverse_proxy' : (config.file_server_config?.enable_file_server ? 'file_server' : 'none');
updateServiceModeView(mode); updateServiceModeView(mode);
state.initialFormState = getFormStateAsString(); state.initialFormState = getFormStateAsString();
} catch(error) { notification.toast(`加载配置详情失败: ${error.message}`, 'error'); } } catch (error) { notification.toast(`加载配置详情失败: ${error.message}`, 'error'); }
} }
async function handleDeleteConfig(filename) { async function handleDeleteConfig(filename) {
if (!await notification.confirm(`确定要删除配置 "${filename}" 吗?`)) return; if (!await notification.confirm(`确定要删除配置 "${filename}" 吗?`)) return;
try { try {
await api.delete(`/config/file/${filename}`); await api.delete(`/config/file/${filename}`);
notification.toast('配置已成功删除。', 'success'); notification.toast('配置已成功删除。', 'success');
loadAllConfigs(); loadAllConfigs();
} catch(error) { notification.toast(`删除失败: ${error.message}`, 'error'); } } catch (error) { notification.toast(`删除失败: ${error.message}`, 'error'); }
} }
async function handleSaveConfig(e) { async function handleSaveConfig(e) {
e.preventDefault(); e.preventDefault();
const formData = new FormData(DOMElements.configForm); const formData = new FormData(DOMElements.configForm);
@ -115,7 +121,7 @@ async function handleSaveConfig(e) {
switchView(DOMElements.configListPanel); switchView(DOMElements.configListPanel);
loadAllConfigs(); loadAllConfigs();
}, 500); }, 500);
} catch(error) { notification.toast(`保存失败: ${error.message}`, 'error'); } } catch (error) { notification.toast(`保存失败: ${error.message}`, 'error'); }
} }
async function openPresetModal(targetType) { async function openPresetModal(targetType) {
@ -171,6 +177,7 @@ async function openPresetModal(targetType) {
function init() { function init() {
theme.init(DOMElements.themeToggleInput); theme.init(DOMElements.themeToggleInput);
notification.init(DOMElements.toastContainer, DOMElements.dialogContainer, DOMElements.modalContainer); notification.init(DOMElements.toastContainer, DOMElements.dialogContainer, DOMElements.modalContainer);
activateNav('configs');
initCaddyStatus(); initCaddyStatus();
loadAllConfigs(); loadAllConfigs();
@ -193,7 +200,7 @@ function init() {
DOMElements.configForm.reset(); DOMElements.configForm.reset();
const noneButton = DOMElements.serviceModeControl.querySelector('[data-mode="none"]'); const noneButton = DOMElements.serviceModeControl.querySelector('[data-mode="none"]');
if(noneButton) updateSegmentedControl(noneButton); if (noneButton) updateSegmentedControl(noneButton);
updateServiceModeView('none'); updateServiceModeView('none');
updateMultiUpstreamView(false); updateMultiUpstreamView(false);

153
frontend/js/global.js Normal file
View file

@ -0,0 +1,153 @@
// js/global.js - 全局配置页面的逻辑
import { theme, activateNav } from './common.js';
import { api } from './api.js';
import { notification } from './notifications.js';
import { initCaddyStatus } from './caddy.js';
import { createCustomSelect } from './ui.js';
const DOMElements = {
globalForm: document.getElementById('global-caddy-form'),
themeToggleInput: document.getElementById('theme-toggle-input'),
logoutBtn: document.getElementById('logout-btn'),
toastContainer: document.getElementById('toast-container'),
dialogContainer: document.getElementById('dialog-container'),
enableDnsChallengeCheckbox: document.getElementById('enable_dns_challenge'),
globalTlsConfigGroup: document.getElementById('global-tls-config-group'),
enableEchCheckbox: document.getElementById('enable_ech'),
echConfigGroup: document.getElementById('ech-config-group'),
};
const submitButton = DOMElements.globalForm.querySelector('button[type="submit"]');
// 从表单收集数据, 构建成后端需要的JSON结构
function getGlobalConfigFromForm() {
const formData = new FormData(DOMElements.globalForm);
const enableEch = DOMElements.enableEchCheckbox.checked;
return {
debug: DOMElements.globalForm.querySelector('[name="debug"]').checked,
ports_config: {
admin_port: formData.get('admin_port'),
http_port: parseInt(formData.get('http_port'), 10) || 80,
https_port: parseInt(formData.get('https_port'), 10) || 443,
},
metrics: DOMElements.globalForm.querySelector('[name="metrics"]').checked,
log_config: {
level: DOMElements.globalForm.querySelector('input[name="log_level"]').value,
rotate_size: formData.get('log_rotate_size'),
rotate_keep: formData.get('log_rotate_keep'),
rotate_keep_for_time: formData.get('log_rotate_keep_for_time'),
},
tls_config: {
enable_dns_challenge: DOMElements.enableDnsChallengeCheckbox.checked,
provider: DOMElements.globalForm.querySelector('input[name="tls_provider"]').value,
token: formData.get('tls_token'),
echouter_sni: enableEch ? formData.get('tls_ech_sni') : "",
email: formData.get('tls_email'),
},
tls_snippet_config: {},
};
}
// 用从API获取的数据填充表单
function fillGlobalConfigForm(config) {
if (!config) return;
DOMElements.globalForm.querySelector('[name="debug"]').checked = config.debug || false;
DOMElements.globalForm.querySelector('[name="metrics"]').checked = config.metrics || false;
const ports = config.ports_config || {};
DOMElements.globalForm.querySelector('[name="admin_port"]').value = ports.admin_port || ':2019';
DOMElements.globalForm.querySelector('[name="http_port"]').value = ports.http_port || 80;
DOMElements.globalForm.querySelector('[name="https_port"]').value = ports.https_port || 443;
const log = config.log_config || {};
const logLevelSelect = document.getElementById('select-log-level');
const logLevel = log.level || 'INFO';
if (logLevelSelect && logLevelSelect.querySelector('.select-selected')) {
logLevelSelect.querySelector('.select-selected').textContent = logLevel;
const hiddenInput = logLevelSelect.querySelector('input[name="log_level"]');
if (hiddenInput) hiddenInput.value = logLevel;
}
DOMElements.globalForm.querySelector('[name="log_rotate_size"]').value = log.rotate_size || '10MB';
DOMElements.globalForm.querySelector('[name="log_rotate_keep"]').value = log.rotate_keep || '10';
DOMElements.globalForm.querySelector('[name="log_rotate_keep_for_time"]').value = log.rotate_keep_for_time || '24h';
const tls = config.tls_config || {};
DOMElements.enableDnsChallengeCheckbox.checked = tls.enable_dns_challenge || false;
DOMElements.globalTlsConfigGroup.classList.toggle('hidden', !DOMElements.enableDnsChallengeCheckbox.checked);
const tlsProviderSelect = document.getElementById('select-tls-provider');
const provider = tls.provider || '';
if (tlsProviderSelect && provider && tlsProviderSelect.querySelector('.select-selected')) {
tlsProviderSelect.querySelector('.select-selected').textContent = provider;
const hiddenProviderInput = tlsProviderSelect.querySelector('input[name="tls_provider"]');
if (hiddenProviderInput) hiddenProviderInput.value = provider;
}
DOMElements.globalForm.querySelector('[name="tls_token"]').value = tls.token || '';
DOMElements.globalForm.querySelector('[name="tls_email"]').value = tls.email || '';
const echOuterSni = tls.echouter_sni || '';
DOMElements.enableEchCheckbox.checked = !!echOuterSni;
DOMElements.echConfigGroup.classList.toggle('hidden', !DOMElements.enableEchCheckbox.checked);
DOMElements.globalForm.querySelector('[name="tls_ech_sni"]').value = echOuterSni;
}
async function handleSaveGlobalConfig(e) {
e.preventDefault();
const configData = getGlobalConfigFromForm();
submitButton.disabled = true;
submitButton.querySelector('span').textContent = "保存中...";
try {
// 修正: 更新API端点路径
const result = await api.put('/global/config', configData);
notification.toast(result.message || '全局配置已成功保存Caddy正在重载...', 'success');
} catch (error) {
notification.toast(`保存失败: ${error.message}`, 'error');
} finally {
submitButton.disabled = false;
submitButton.querySelector('span').textContent = "保存并重载";
}
}
async function handleLogout() {
if (await notification.confirm('您确定要退出登录吗?')) {
notification.toast('正在退出...', 'info');
setTimeout(() => { window.location.href = '/v0/api/auth/logout'; }, 500);
}
}
function init() {
theme.init(DOMElements.themeToggleInput);
notification.init(DOMElements.toastContainer, DOMElements.dialogContainer);
activateNav('global');
initCaddyStatus();
// 修正: 更新API端点路径
api.get('/global/log/levels')
.then(levels => createCustomSelect('select-log-level', Object.keys(levels)))
.catch(err => notification.toast(`加载日志级别失败: ${err.message}`, 'error'));
// 修正: 更新API端点路径
api.get('/global/tls/providers')
.then(providers => createCustomSelect('select-tls-provider', Object.keys(providers)))
.catch(err => notification.toast(`加载TLS提供商失败: ${err.message}`, 'error'));
// 修正: 更新API端点路径
api.get('/global/config')
.then(config => fillGlobalConfigForm(config))
.catch(err => notification.toast(`加载全局配置失败: ${err.message}`, 'error'));
DOMElements.globalForm.addEventListener('submit', handleSaveGlobalConfig);
DOMElements.logoutBtn.addEventListener('click', handleLogout);
DOMElements.enableDnsChallengeCheckbox.addEventListener('change', (e) => {
DOMElements.globalTlsConfigGroup.classList.toggle('hidden', !e.target.checked);
});
DOMElements.enableEchCheckbox.addEventListener('change', (e) => {
DOMElements.echConfigGroup.classList.toggle('hidden', !e.target.checked);
});
}
init();

109
frontend/js/locale.js Normal file
View file

@ -0,0 +1,109 @@
// js/locale.js - 国际化 (i18n) 核心模块
let currentLocale = {};
let currentLang = 'en'; // 默认语言
const supportedLangs = ['en', 'zh-CN']; // 应用支持的语言列表
/**
* 加载指定的语言文件 (JSON)
* @param {string} lang - 语言代码 (e.g., 'en', 'zh-CN')
*/
async function loadLocale(lang) {
try {
const response = await fetch(`/locales/${lang}.json?v=${Date.now()}`);
if (!response.ok) {
throw new Error(`Language file for ${lang} not found (status: ${response.status}).`);
}
currentLocale = await response.json();
currentLang = lang;
document.documentElement.lang = lang;
} catch (error) {
console.error("i18n Error:", error);
// 如果加载目标语言失败, 安全回退到默认的英语
if (lang !== 'en') {
console.warn(`Falling back to default language 'en'.`);
await loadLocale('en');
}
}
}
/**
* 将加载的翻译应用到所有带有 data-i18n 属性的DOM元素上
*/
function applyTranslationsToDOM() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.dataset.i18n;
const translation = t(key);
if (translation !== key) {
// 优先替换元素的第一个文本节点, 避免覆盖内部的 <i> 等元素
const textNode = Array.from(el.childNodes).find(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
if (textNode) {
// 在图标和文本之间保留一个空格
textNode.textContent = el.querySelector('i') ? ` ${translation}` : translation;
} else {
el.textContent = translation;
}
}
});
// 特殊处理 title 属性
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.dataset.i18nTitle;
const translation = t(key);
if(translation !== key) el.title = translation;
});
}
/**
* 获取翻译文本, 支持点分隔的路径和占位符替换
* @param {string} key - 翻译键 (e.g., 'pages.login.welcome')
* @param {object} [replacements={}] - 用于替换占位符的键值对
* @returns {string} - 翻译后的字符串
*/
export function t(key, replacements = {}) {
// 通过路径 'a.b.c' 在嵌套对象中查找值: currentLocale['a']['b']['c']
const translation = key.split('.').reduce((obj, k) => obj && obj[k], currentLocale);
let result = translation || key; // 如果找不到, 返回原始key作为回退
// 处理占位符替换, e.g., {filename: 'example.com'}
if (typeof result === 'string') {
for (const placeholder in replacements) {
result = result.replace(`{${placeholder}}`, replacements[placeholder]);
}
}
return result;
}
/**
* 初始化 i18n 系统: 检测语言, 加载语言包, 并应用翻译
*/
export async function initI18n() {
const urlParams = new URLSearchParams(window.location.search);
const langFromUrl = urlParams.get('lang');
const langFromStorage = localStorage.getItem('appLanguage');
const browserLang = navigator.language.startsWith('zh') ? 'zh-CN' : 'en';
let langToLoad = 'en';
if (langFromUrl && supportedLangs.includes(langFromUrl)) {
langToLoad = langFromUrl;
} else if (langFromStorage && supportedLangs.includes(langFromStorage)) {
langToLoad = langFromStorage;
} else if (supportedLangs.includes(browserLang)) {
langToLoad = browserLang;
}
await loadLocale(langToLoad);
applyTranslationsToDOM();
}
/**
* 切换应用语言
* @param {string} lang - 目标语言代码
*/
export async function setLanguage(lang) {
if (supportedLangs.includes(lang) && lang !== currentLang) {
localStorage.setItem('appLanguage', lang);
window.location.reload(); // 刷新页面以应用所有翻译是最简单可靠的方式
}
}

View file

@ -228,3 +228,57 @@ export function createPresetSelectionModal(presets) {
modalContainer.addEventListener('click', eventHandler); modalContainer.addEventListener('click', eventHandler);
}); });
} }
export function createCustomSelect(containerId, options, onSelect) {
const container = document.getElementById(containerId);
if (!container) return;
// 从容器的ID动态生成隐藏input的name属性
// 例如, id 'select-log-level' -> name 'log_level'
const inputName = container.id.replace('select-', '').replace(/-/g, '_');
container.innerHTML = `<div class="select-selected"></div><div class="select-items"></div><input type="hidden" name="${inputName}">`;
const selectedDiv = container.querySelector('.select-selected');
const itemsDiv = container.querySelector('.select-items');
const hiddenInput = container.querySelector('input[type="hidden"]');
itemsDiv.innerHTML = '';
if (!options || options.length === 0) {
selectedDiv.textContent = '无可用选项';
return;
}
options.forEach((option, index) => {
const item = document.createElement('div');
item.textContent = option;
item.dataset.value = option;
if (index === 0) {
selectedDiv.textContent = option;
hiddenInput.value = option;
}
item.addEventListener('click', function(e) {
selectedDiv.textContent = this.textContent;
hiddenInput.value = this.dataset.value;
itemsDiv.classList.remove('select-show');
selectedDiv.classList.remove('select-arrow-active');
onSelect && onSelect(this.dataset.value);
e.stopPropagation();
});
itemsDiv.appendChild(item);
});
selectedDiv.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.select-items.select-show').forEach(openSelect => {
if (openSelect !== itemsDiv) {
openSelect.classList.remove('select-show');
openSelect.previousElementSibling.classList.remove('select-arrow-active');
}
});
itemsDiv.classList.toggle('select-show');
selectedDiv.classList.toggle('select-arrow-active');
});
document.addEventListener('click', () => {
itemsDiv.classList.remove('select-show');
if(selectedDiv) selectedDiv.classList.remove('select-arrow-active');
});
}

View file

@ -14,7 +14,6 @@
<body> <body>
<div class="app-container"> <div class="app-container">
<!-- 侧边栏 (与 index.html 完全相同) -->
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
<header class="sidebar-header"> <header class="sidebar-header">
<i class="fa-solid fa-rocket"></i> <i class="fa-solid fa-rocket"></i>
@ -23,6 +22,7 @@
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<ul> <ul>
<li><a href="/" data-nav-id="configs"><i class="fa-solid fa-sitemap"></i> <span>配置管理</span></a></li> <li><a href="/" data-nav-id="configs"><i class="fa-solid fa-sitemap"></i> <span>配置管理</span></a></li>
<li><a href="/global.html" data-nav-id="global"><i class="fa-solid fa-globe"></i> <span>全局配置</span></a></li>
<li><a href="/settings.html" data-nav-id="settings"><i class="fa-solid fa-gears"></i> <li><a href="/settings.html" data-nav-id="settings"><i class="fa-solid fa-gears"></i>
<span>面板设置</span></a></li> <span>面板设置</span></a></li>
</ul> </ul>

19
gen/default.go Normal file
View file

@ -0,0 +1,19 @@
package gen
var (
DefaultGlobalConfig = CaddyGlobalConfig{
Debug: false,
PortsConfig: CaddyGlobalPortsConfig{
AdminPort: "localhost:2019",
HTTPPort: 80,
HTTPSPort: 443,
},
Metrics: true,
LogConfig: CaddyGlobalLogConfig{
Level: "INFO",
RotateSize: "10MB",
RotateKeep: "10",
RotateKeepForTime: "24h",
},
}
)

View file

@ -87,27 +87,6 @@ func Add80SiteConfig(cfg *config.Config, cdb *db.ConfigDB) error {
return err return err
} }
/*
originConfig := db.CaddyfileConfig{
Domain: ":80",
TmplType: "file_server",
FileServer: db.FileServerConfig{
FileDirPath: cfg.Server.CaddyDir + "pages/demo",
EnableBrowser: false,
},
Log: db.LogConfig{
EnableLog: true,
LogDomain: ":80",
},
ErrorPage: db.ErrorPageConfig{
EnableErrorPage: true,
},
Encode: db.EncodeConfig{
EnableEncode: true,
},
}
*/
originGobData, err := EncodeGobConfig(siteConfig) originGobData, err := EncodeGobConfig(siteConfig)
if err != nil { if err != nil {
return err return err
@ -150,3 +129,52 @@ func Add80SiteConfig(cfg *config.Config, cdb *db.ConfigDB) error {
return nil return nil
} }
// 读入全局配置模板
func ReadGlobalTmpl(dir string) ([]byte, error) {
// 读取目录下的caddyfile文件
content, err := os.ReadFile(filepath.Join(dir, "gtmpl", "caddyfile"))
if err != nil {
return nil, err
}
fmt.Printf("Read global template: %s\n", "caddyfile")
return content, nil
}
func SetGlobalConfig(cfg *config.Config, cdb *db.ConfigDB) error {
var config = DefaultGlobalConfig
paramsGob, err := EncodeGobConfig(config)
if err != nil {
return fmt.Errorf("encode gob config error: %w", err)
}
// 取出数据库内的tmpl
tmplContent, err := ReadGlobalTmpl(cfg.Server.CaddyDir)
if err != nil {
return fmt.Errorf("get global template error: %w", err)
}
renderedContent, err := RenderGlobalConfig(paramsGob, tmplContent)
if err != nil {
return fmt.Errorf("render global config error: %w", err)
}
//回写条目到数据库
err = cdb.SaveGlobalConfig(db.GlobalConfig{
Filename: "caddyfile",
Params: paramsGob,
TmplContent: tmplContent,
RenderedContent: renderedContent,
})
if err != nil {
return fmt.Errorf("save global config error: %w", err)
}
err = os.WriteFile(cfg.Server.CaddyDir+"Caddyfile", renderedContent, 0644)
if err != nil {
return fmt.Errorf("write Caddyfile error: %w", err)
}
return nil
}

View file

@ -53,6 +53,27 @@ func RenderConfig(site string, cdb *db.ConfigDB) error {
return nil return nil
} }
func RenderGlobalConfig(paramsGob []byte, tmplContent []byte) ([]byte, error) {
// 渲染caddyfile
var globalConfig CaddyGlobalConfig
err := DecodeGobConfig(paramsGob, &globalConfig)
if err != nil {
return nil, fmt.Errorf("failed to decode global config params: %w", err)
}
parsedTmpl, parseErr := template.New("caddyfile").Parse(string(tmplContent))
if parseErr != nil {
return nil, fmt.Errorf("failed to parse global caddyfile template: %w", parseErr)
}
var renderedContentBuilder bytes.Buffer
if err := parsedTmpl.Execute(&renderedContentBuilder, globalConfig); err != nil {
return nil, fmt.Errorf("failed to render global caddyfile template: %w", err)
}
return renderedContentBuilder.Bytes(), nil
}
// 把caddycfg内容转为GOB // 把caddycfg内容转为GOB
func EncodeGobConfig(caddycfg any) ([]byte, error) { func EncodeGobConfig(caddycfg any) ([]byte, error) {
var buf bytes.Buffer var buf bytes.Buffer

View file

@ -60,6 +60,7 @@ type CaddyGlobalConfig struct {
Metrics bool `json:"metrics"` Metrics bool `json:"metrics"`
LogConfig CaddyGlobalLogConfig `json:"log_config"` LogConfig CaddyGlobalLogConfig `json:"log_config"`
TLSConfig CaddyGlobalTLSConfig `json:"tls_config"` TLSConfig CaddyGlobalTLSConfig `json:"tls_config"`
TLSSnippetConfig CaddyGlobalSnippetTLSConfig `json:"tls_snippet_config"`
} }
type CaddyGlobalPortsConfig struct { type CaddyGlobalPortsConfig struct {
@ -88,6 +89,16 @@ var LogLevelList = map[string]struct{}{
} }
type CaddyGlobalTLSConfig struct { type CaddyGlobalTLSConfig struct {
EnableDNSChallenge bool `json:"enable_dns_challenge"`
Provider string `json:"provider"`
Token string `json:"token"`
ECHOuterSNI string `json:"echouter_sni"`
Email string `json:"email"`
}
type CaddyGlobalSnippetTLSConfig struct {
EnableSiteTLSSnippet bool `json:"enable_site_tls_snippet"`
Email string `json:"email"`
Provider string `json:"provider"` Provider string `json:"provider"`
Token string `json:"token"` Token string `json:"token"`
} }

View file

@ -1,22 +1,42 @@
{ {
{{- if .Debug}}
debug debug
admin :2019 {{- end}}
http_port 80 admin {{.PortsConfig.AdminPort}}
https_port 443 http_port {{.PortsConfig.HTTPPort}}
https_port {{.PortsConfig.HTTPSPort}}
{{- if .Metrics}}
metrics metrics
{{- end}}
order ja4h_header first order ja4h_header first
order webdav before file_server order webdav before file_server
order cache before rewrite order cache before rewrite
cache { cache {
cache_name CaddyCache cache_name CaddyCache
} }
log { log {
level INFO level {{.LogConfig.Level}}
output file ./log/caddy.log { output file ./log/caddy.log {
roll_size 10MB roll_size {{.LogConfig.RotateSize}}
roll_keep 10 roll_keep {{.LogConfig.RotateKeep}}
{{- if .LogConfig.RotateKeepForTime}}
roll_keep_for {{.LogConfig.RotateKeepForTime}}
{{- end}}
} }
} }
{{- if .TLSConfig.EnableDNSChallenge}}
dns {{.TLSConfig.Provider}} {{.TLSConfig.Token}}
{{- end}}
{{- if .TLSConfig.ECHOuterSNI}}
ech {{.TLSConfig.ECHOuterSNI}}
{{- end}}
{{- if .TLSConfig.Email}}
email {{.TLSConfig.Email}}
{{- end}}
} }
(log) { (log) {
@ -71,9 +91,11 @@
} }
(tls) { (tls) {
tls { {{- if .TLSSnippetConfig.EnableSiteTLSSnippet}}
dns {args[0]} {args[1]} tls {{- if .TLSSnippetConfig.Email }} {{ .TLSSnippetConfig.Email }}{{- end }} {
dns {{.TLSSnippetConfig.Provider}} {{.TLSSnippetConfig.Token}}
} }
{{- end}}
} }
(rate_limit) { (rate_limit) {

View file

@ -33,7 +33,7 @@ func init() {
parseFlags() parseFlags()
loadConfig() loadConfig()
loadDatabase(cfg.DB.Filepath) loadDatabase(cfg.DB.Filepath)
loadtmpltoDB(cfg.Tmpl.Path, cdb) loadtmpltoDB(cfg.Server.CaddyDir+"tmpl", cdb)
loadAdminStatus(cdb) loadAdminStatus(cdb)
initSessionKey() initSessionKey()
} }
@ -89,6 +89,11 @@ func loadtmpltoDB(path string, cdb *db.ConfigDB) {
fmt.Printf("Failed to load templates: %v\n", err) fmt.Printf("Failed to load templates: %v\n", err)
os.Exit(1) os.Exit(1)
} }
err = gen.SetGlobalConfig(cfg, cdb)
if err != nil {
fmt.Printf("Failed to set global config: %v\n", err)
os.Exit(1)
}
err = gen.Add80SiteConfig(cfg, cdb) err = gen.Add80SiteConfig(cfg, cdb)
if err != nil { if err != nil {
fmt.Printf("Failed to add :80 site config: %v\n", err) fmt.Printf("Failed to add :80 site config: %v\n", err)