add global config support
This commit is contained in:
parent
cd1e1a42f3
commit
34d553a890
23 changed files with 1682 additions and 343 deletions
118
Caddyfile
118
Caddyfile
|
|
@ -1,95 +1,95 @@
|
||||||
{
|
{
|
||||||
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_name CaddyCache
|
cache {
|
||||||
}
|
cache_name CaddyCache
|
||||||
log {
|
}
|
||||||
level INFO
|
|
||||||
output file ./log/caddy.log {
|
log {
|
||||||
roll_size 10MB
|
level INFO
|
||||||
roll_keep 10
|
output file ./log/caddy.log {
|
||||||
}
|
roll_size 10MB
|
||||||
}
|
roll_keep 10
|
||||||
|
roll_keep_for 24h
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(log) {
|
(log) {
|
||||||
log {
|
log {
|
||||||
format transform `{request>headers>X-Forwarded-For>[0]:request>remote_ip} - {user_id} [{ts}] "{request>method} {request>uri} {request>proto}" {status} {size} "{request>headers>Referer>[0]}" "{request>headers>User-Agent>[0]}"` {
|
format transform `{request>headers>X-Forwarded-For>[0]:request>remote_ip} - {user_id} [{ts}] "{request>method} {request>uri} {request>proto}" {status} {size} "{request>headers>Referer>[0]}" "{request>headers>User-Agent>[0]}"` {
|
||||||
time_format "02/Jan/2006:15:04:05 -0700"
|
time_format "02/Jan/2006:15:04:05 -0700"
|
||||||
}
|
}
|
||||||
output file ./log/{args[0]}/access.log {
|
output file ./log/{args[0]}/access.log {
|
||||||
roll_size 10MB
|
roll_size 10MB
|
||||||
roll_keep 10
|
roll_keep 10
|
||||||
roll_keep_for 24h
|
roll_keep_for 24h
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(error_page) {
|
(error_page) {
|
||||||
handle_errors {
|
handle_errors {
|
||||||
rewrite * /{err.status_code}.html
|
rewrite * /{err.status_code}.html
|
||||||
root * ./pages/errors
|
root * ./pages/errors
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(encode) {
|
(encode) {
|
||||||
encode {
|
encode {
|
||||||
zstd
|
zstd
|
||||||
br
|
br
|
||||||
gzip
|
gzip
|
||||||
minimum_length 512
|
minimum_length 512
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(cache) {
|
(cache) {
|
||||||
cache {
|
cache {
|
||||||
allowed_http_verbs GET
|
allowed_http_verbs GET
|
||||||
stale {args[0]}
|
stale {args[0]}
|
||||||
ttl {args[1]}
|
ttl {args[1]}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(header_realip_cf) {
|
(header_realip_cf) {
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
header_up X-Real-IP {http.request.header.CF-Connecting-IP}
|
header_up X-Real-IP {http.request.header.CF-Connecting-IP}
|
||||||
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
|
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
|
||||||
header_up X-Forwarded-Proto {http.request.header.CF-Visitor}
|
header_up X-Forwarded-Proto {http.request.header.CF-Visitor}
|
||||||
}
|
}
|
||||||
|
|
||||||
(header_realip) {
|
(header_realip) {
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
header_up X-Forwarded-For {remote_host}
|
header_up X-Forwarded-For {remote_host}
|
||||||
header_up X-Forwarded-Proto {scheme}
|
header_up X-Forwarded-Proto {scheme}
|
||||||
}
|
}
|
||||||
|
|
||||||
(tls) {
|
(tls) {
|
||||||
tls {
|
|
||||||
dns {args[0]} {args[1]}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(rate_limit) {
|
(rate_limit) {
|
||||||
route /* {
|
route /* {
|
||||||
rate_limit {remote.ip} {args[0]}r/m 10000 429
|
rate_limit {remote.ip} {args[0]}r/m 10000 429
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(route_nocache) {
|
(route_nocache) {
|
||||||
route {args[0]} {
|
route {args[0]} {
|
||||||
rate_limit {remote.ip} {args[1]}r/m 10000 429
|
rate_limit {remote.ip} {args[1]}r/m 10000 429
|
||||||
cache {
|
cache {
|
||||||
stale 0s
|
stale 0s
|
||||||
ttl 0s
|
ttl 0s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import ./config.d/*
|
import ./config.d/*
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()})
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
23
db/db.go
23
db/db.go
|
|
@ -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)
|
||||||
|
|
|
||||||
181
db/operation.go
181
db/operation.go
|
|
@ -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
101
db/utils.go
Normal 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
141
frontend/global.html
Normal 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>
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -151,11 +157,11 @@ async function openPresetModal(targetType) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const choice = await notification.confirm('如何填充预设?', '选择填充方式', { confirmText: '追加', cancelText: '替换' });
|
const choice = await notification.confirm('如何填充预设?', '选择填充方式', { confirmText: '追加', cancelText: '替换' });
|
||||||
|
|
||||||
if (choice === false) { // 用户选择了“替换”
|
if (choice === false) { // 用户选择了“替换”
|
||||||
targetContainer.innerHTML = '';
|
targetContainer.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(preset.headers).forEach(([key, values]) => {
|
Object.entries(preset.headers).forEach(([key, values]) => {
|
||||||
values.forEach(value => {
|
values.forEach(value => {
|
||||||
addKeyValueInput(targetContainer, keyName, valueName, key, value);
|
addKeyValueInput(targetContainer, keyName, valueName, key, value);
|
||||||
|
|
@ -171,29 +177,30 @@ 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();
|
||||||
|
|
||||||
api.get('/config/headers-presets')
|
api.get('/config/headers-presets')
|
||||||
.then(presets => {
|
.then(presets => {
|
||||||
state.headerPresets = presets || [];
|
state.headerPresets = presets || [];
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
if (err.message) notification.toast(`加载Header预设失败: ${err.message}`, 'error');
|
if (err.message) notification.toast(`加载Header预设失败: ${err.message}`, 'error');
|
||||||
});
|
});
|
||||||
|
|
||||||
DOMElements.menuToggleBtn.addEventListener('click', () => DOMElements.sidebar.classList.toggle('is-open'));
|
DOMElements.menuToggleBtn.addEventListener('click', () => DOMElements.sidebar.classList.toggle('is-open'));
|
||||||
DOMElements.mainContent.addEventListener('click', () => DOMElements.sidebar.classList.remove('is-open'));
|
DOMElements.mainContent.addEventListener('click', () => DOMElements.sidebar.classList.remove('is-open'));
|
||||||
|
|
||||||
DOMElements.addNewConfigBtn.addEventListener('click', () => {
|
DOMElements.addNewConfigBtn.addEventListener('click', () => {
|
||||||
state.isEditing = false;
|
state.isEditing = false;
|
||||||
switchView(DOMElements.configFormPanel);
|
switchView(DOMElements.configFormPanel);
|
||||||
DOMElements.formTitle.textContent = '创建新配置';
|
DOMElements.formTitle.textContent = '创建新配置';
|
||||||
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);
|
||||||
|
|
||||||
|
|
@ -203,13 +210,13 @@ function init() {
|
||||||
DOMElements.multiUpstreamContainer.innerHTML = '';
|
DOMElements.multiUpstreamContainer.innerHTML = '';
|
||||||
DOMElements.originalFilenameInput.value = '';
|
DOMElements.originalFilenameInput.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
DOMElements.backToListBtn.addEventListener('click', attemptExitForm);
|
DOMElements.backToListBtn.addEventListener('click', attemptExitForm);
|
||||||
DOMElements.cancelEditBtn.addEventListener('click', attemptExitForm);
|
DOMElements.cancelEditBtn.addEventListener('click', attemptExitForm);
|
||||||
|
|
||||||
DOMElements.configForm.addEventListener('submit', handleSaveConfig);
|
DOMElements.configForm.addEventListener('submit', handleSaveConfig);
|
||||||
DOMElements.logoutBtn.addEventListener('click', handleLogout);
|
DOMElements.logoutBtn.addEventListener('click', handleLogout);
|
||||||
|
|
||||||
DOMElements.configListContainer.addEventListener('click', e => {
|
DOMElements.configListContainer.addEventListener('click', e => {
|
||||||
const button = e.target.closest('button');
|
const button = e.target.closest('button');
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
|
|
@ -237,7 +244,7 @@ function init() {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openPresetModal('upstream');
|
openPresetModal('upstream');
|
||||||
});
|
});
|
||||||
|
|
||||||
DOMElements.addMultiUpstreamBtn.addEventListener('click', (e) => {
|
DOMElements.addMultiUpstreamBtn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addSingleInput(DOMElements.multiUpstreamContainer, 'upstream_servers', '例如: 127.0.0.1:8081');
|
addSingleInput(DOMElements.multiUpstreamContainer, 'upstream_servers', '例如: 127.0.0.1:8081');
|
||||||
|
|
|
||||||
153
frontend/js/global.js
Normal file
153
frontend/js/global.js
Normal 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
109
frontend/js/locale.js
Normal 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(); // 刷新页面以应用所有翻译是最简单可靠的方式
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -227,4 +227,58 @@ 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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -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
19
gen/default.go
Normal 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
70
gen/init.go
70
gen/init.go
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -55,11 +55,12 @@ type CaddyUniEncodeConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CaddyGlobalConfig struct {
|
type CaddyGlobalConfig struct {
|
||||||
Debug bool `json:"debug"`
|
Debug bool `json:"debug"`
|
||||||
PortsConfig CaddyGlobalPortsConfig `json:"ports_config"`
|
PortsConfig CaddyGlobalPortsConfig `json:"ports_config"`
|
||||||
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,8 +89,18 @@ var LogLevelList = map[string]struct{}{
|
||||||
}
|
}
|
||||||
|
|
||||||
type CaddyGlobalTLSConfig struct {
|
type CaddyGlobalTLSConfig struct {
|
||||||
Provider string `json:"provider"`
|
EnableDNSChallenge bool `json:"enable_dns_challenge"`
|
||||||
Token string `json:"token"`
|
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"`
|
||||||
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 维护一个提供商列表
|
// 维护一个提供商列表
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
7
main.go
7
main.go
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue