This commit is contained in:
wjqserver 2025-06-20 16:33:27 +08:00
commit b10790c212
40 changed files with 4149 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
pages
log
*.db
caddy
caddydash
config.d

90
Caddyfile Normal file
View file

@ -0,0 +1,90 @@
{
debug
admin :2019
http_port 80
https_port 443
metrics
order ja4h_header first
order webdav before file_server
order cache before rewrite
cache {
cache_name CaddyCache
}
log {
level INFO
output file ./log/caddy.log {
roll_size 10MB
roll_keep 10
}
}
}
(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]}"` {
time_format "02/Jan/2006:15:04:05 -0700"
}
output file ./log/{args[0]}/access.log {
roll_size 10MB
roll_keep 10
roll_keep_for 24h
}
}
}
(error_page) {
handle_errors {
rewrite * /{err.status_code}.html
root * ./pages/errors
file_server
}
}
(encode) {
encode {
zstd
br
gzip
minimum_length 512
}
}
(cache) {
cache {
allowed_http_verbs GET
stale {args[0]}
ttl {args[1]}
}
}
(header_realip) {
header_up X-Real-IP {remote_host}
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-Proto {http.request.header.CF-Visitor}
}
(tls) {
tls {
dns {args[0]} {args[1]}
}
}
(rate_limit) {
route /* {
rate_limit {remote.ip} {args[0]}r/m 10000 429
}
}
(route_nocache) {
route {args[0]} {
rate_limit {remote.ip} {args[1]}r/m 10000 429
cache {
stale 0s
ttl 0s
}
}
}
import ./config.d/*
#import ./config/*

373
LICENSE Normal file
View file

@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

1
README.md Normal file
View file

@ -0,0 +1 @@
# caddydash

92
api/api.go Normal file
View file

@ -0,0 +1,92 @@
package api
import (
"caddydash/apic"
"caddydash/config"
"caddydash/db"
"caddydash/user"
"github.com/infinite-iroha/touka"
)
func ApiGroup(v0 touka.IRouter, cdb *db.ConfigDB, cfg *config.Config) {
api := v0.Group("/api")
api.GET("/config/filenames", func(c *touka.Context) {
filenames, err := cdb.GetFileNames()
if err != nil {
c.JSON(500, touka.H{"error": err.Error()})
return
}
c.JSON(200, filenames)
})
// 配置参数相关
api.GET("/config/file/:filename", GetConfig(cdb)) // 读取配置(与写入一致)
api.PUT("/config/file/:filename", PutConfig(cdb, cfg)) // 写入配置
api.DELETE("/config/file/:filename", DeleteConfig(cdb, cfg)) //删除配置
api.GET("/config/files/params", FilesParams(cdb)) // 获取所有配置, 需进行decode
api.GET("/config/files/templates", FilesTemplates(cdb)) // 获取所有模板
api.GET("/config/files/rendered", FilesRendered(cdb)) // 获取所有渲染产物
api.GET("/config/templates", GetTemplates(cdb)) // 获取可用模板名称
// caddy实例相关
api.POST("/caddy/stop", apic.StopCaddy()) // 无需payload
api.POST("/caddy/run", apic.StartCaddy(cfg))
api.POST("/caddy/restart", apic.RestartCaddy(cfg))
api.GET("/caddy/status", apic.IsCaddyRunning())
auth := api.Group("/auth")
{
auth.POST("/login", func(c *touka.Context) {
AuthLogin(c, cfg, cdb)
})
auth.POST("/logout", func(c *touka.Context) {
AuthLogout(c)
})
auth.GET("/logout", func(c *touka.Context) {
// 重定向到/
c.Redirect(302, "/")
c.Abort()
return
})
auth.GET("/init", func(c *touka.Context) {
// 返回是否init管理员
isInit := user.IsAdminInit()
if isInit {
c.JSON(200, touka.H{"admin_init": true})
} else {
c.JSON(200, touka.H{"admin_init": false})
}
})
auth.POST("/init", func(c *touka.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
// 验证是否为空
if username == "" || password == "" {
c.JSON(400, touka.H{"error": "username and password are required"})
return
}
// 初始化管理员
err := user.InitAdminUser(username, password, cdb)
if err != nil {
c.JSON(500, touka.H{"error": err.Error()})
return
}
c.JSON(200, touka.H{"message": "admin initialized"})
})
}
}
// GetTemplates 获取可用的tmpls name
func GetTemplates(cdb *db.ConfigDB) touka.HandlerFunc {
return func(c *touka.Context) {
templates, err := cdb.RangeTemplates()
if err != nil {
c.JSON(500, touka.H{"error": err.Error()})
return
}
c.JSON(200, templates)
}
}

127
api/auth.go Normal file
View file

@ -0,0 +1,127 @@
package api
import (
"caddydash/config"
"caddydash/db"
"caddydash/user"
"net/http"
"strings"
"github.com/fenthope/sessions"
"github.com/infinite-iroha/touka"
)
var (
exactMatchPaths = map[string]struct{}{
"/login": {},
"/login.html": {},
"/v0/api/auth/login": {},
"/v0/api/auth/init": {},
"/init.html": {},
"/favicon.ico": {},
}
prefixMatchPaths = []string{ // 保持前缀匹配,因为数量少
"/js/",
"/css/",
}
loginMatchPaths = map[string]struct{}{
"/login": {},
"/login.html": {},
"/v0/api/auth/login": {},
}
initMatchPaths = map[string]struct{}{
"/v0/api/auth/init": {},
"/init.html": {},
}
)
func isPassPath(requestPath string) bool {
// 精确匹配
if _, ok := exactMatchPaths[requestPath]; ok {
return true
}
// 前缀匹配
for _, prefix := range prefixMatchPaths {
if strings.HasPrefix(requestPath, prefix) {
return true
}
}
return false
}
func isLoginPath(requestPath string) bool {
if _, ok := loginMatchPaths[requestPath]; ok {
return true
}
return false
}
func isInitPath(requestPath string) bool {
if _, ok := initMatchPaths[requestPath]; ok {
return true
}
return false
}
func SessionMiddleware(cdb *db.ConfigDB) touka.HandlerFunc {
return func(c *touka.Context) {
session := sessions.Default(c)
requestPath := c.Request.URL.Path
pass := isPassPath(requestPath)
if !user.IsAdminInit() && !pass || !user.IsAdminInit() && isLoginPath(requestPath) {
c.Redirect(http.StatusFound, "/init.html")
c.Abort()
return
} else if user.IsAdminInit() && isInitPath(requestPath) {
c.Redirect(http.StatusFound, "/login.html")
c.Abort()
return
}
if session.Get("authenticated") != true && !pass {
c.Redirect(http.StatusFound, "/login.html")
c.Abort()
return
}
c.Next()
}
}
func AuthLogin(c *touka.Context, cfg *config.Config, cdb *db.ConfigDB) {
username := c.PostForm("username")
password := c.PostForm("password")
// 输入验证
if username == "" || password == "" {
c.Errorf("Username or password not provided")
c.JSON(http.StatusBadRequest, touka.H{"error": "Need username and password"})
return
}
// 验证账户密码
pass, err := user.CheckLogin(username, password, cdb)
if err != nil {
c.Errorf("Failed to check login: %v", err)
c.JSON(http.StatusInternalServerError, touka.H{"error": "Internal Auth Check Error"})
return
}
if !pass {
c.Errorf("Invalid username or password")
c.JSON(http.StatusUnauthorized, touka.H{"error": "Invalid username or password"})
return
}
session := sessions.Default(c)
session.Set("authenticated", true)
session.Save()
c.Infof("Login successful for user: %s", username)
c.JSON(http.StatusOK, touka.H{"success": true})
}
func AuthLogout(c *touka.Context) {
session := sessions.Default(c)
session.Clear()
session.Set("authenticated", false)
session.Save()
c.Redirect(http.StatusFound, "/login.html")
}

225
api/config.go Normal file
View file

@ -0,0 +1,225 @@
package api
import (
"caddydash/config"
"caddydash/db"
"caddydash/gen"
"fmt"
"os"
"github.com/infinite-iroha/touka"
)
func GetConfig(cdb *db.ConfigDB) touka.HandlerFunc {
return func(c *touka.Context) {
filename := c.Param("filename")
params, err := cdb.GetParams(filename)
if err != nil {
c.JSON(500, touka.H{"error": err.Error()})
return
}
// 解码[]byte的gob数据
var config gen.CaddyUniConfig
err = gen.DecodeGobConfig(params.ParamsOrigin, &config)
if err != nil {
c.JSON(500, touka.H{"error": err.Error()})
return
}
c.JSON(200, config)
}
}
func PutConfig(cdb *db.ConfigDB, cfg *config.Config) touka.HandlerFunc {
return func(c *touka.Context) {
filename := c.Param("filename")
var config gen.CaddyUniConfig
err := c.ShouldBindJSON(&config)
if err != nil {
c.JSON(500, touka.H{"error": err.Error()})
return
}
var paramsGOB []byte
var paramsOrigin []byte
// Mode标识符固定为uni, 模板已被统合为只有uni
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
}
// 把json变为gob []byte
paramsOrigin, err = gen.EncodeGobConfig(config)
if err != nil {
c.Warnf("encode origin config error: %v", err)
c.JSON(500, touka.H{"error": err.Error()})
return
}
paramsEntry := db.ParamsEntry{
Filename: filename,
TemplateType: config.Mode,
ParamsGOB: paramsGOB,
ParamsOrigin: paramsOrigin,
}
err = WriteConfig(cdb, paramsEntry, cfg, filename)
if err != nil {
c.Warnf("write config error: %v", err)
c.JSON(500, touka.H{"error": err.Error()})
return
}
c.JSON(200, touka.H{"message": "config saved and rendered"})
return
}
}
// 渲染并写入配置
func WriteConfig(cdb *db.ConfigDB, paramsEntry db.ParamsEntry, cfg *config.Config, filename string) error {
var err error
err = cdb.SaveParams(paramsEntry)
if err != nil {
err = fmt.Errorf("save params error: %w", err)
return err
}
err = gen.RenderConfig(filename, cdb)
if err != nil {
err = fmt.Errorf("render config error: %w", err)
return err
}
// 写入文件
renderedEntry, err := cdb.GetRenderedConfig(filename)
if err != nil {
err = fmt.Errorf("get rendered config error: %w", err)
return err
}
err = os.WriteFile(cfg.Server.CaddyDir+"config.d/"+filename, renderedEntry.RenderedContent, 0644)
if err != nil {
err = fmt.Errorf("write rendered config file error: %w", err)
return err
}
return nil
}
func DeleteConfig(cdb *db.ConfigDB, cfg *config.Config) touka.HandlerFunc {
return func(c *touka.Context) {
filename := c.Param("filename")
err := cdb.DeleteParams(filename)
if err != nil {
c.JSON(500, touka.H{"error": err.Error()})
return
}
// 删除文件
err = os.Remove(cfg.Server.CaddyDir + "config.d/" + filename)
if err != nil {
c.Warnf("delete rendered config file error: %v", err)
}
c.JSON(200, touka.H{"message": "config deleted"})
}
}
/*
func PutConfig(cdb *db.ConfigDB, cfg *config.Config) touka.HandlerFunc {
return func(c *touka.Context) {
filename := c.Param("filename")
var config gen.CaddyUniConfig
err := c.ShouldBindJSON(&config)
if err != nil {
c.JSON(500, touka.H{"error": err.Error()})
return
}
var (
paramsGOB []byte
paramsOrigin []byte
)
switch config.TmplType {
case "file_server":
caddyfscfg := gen.CaddyFileServerConfig{
Domain: config.Domain,
FileDirPath: config.FileServer.FileDirPath,
EnableBrowser: config.FileServer.EnableBrowser,
Headers: gen.HeadersMapToHeadersUp(config.Headers),
EnableLog: config.Log.EnableLog,
LogDomain: config.Log.LogDomain,
EnableErrorPage: config.ErrorPage.EnableErrorPage,
EnableEncode: config.Encode.EnableEncode,
}
paramsGOB, err = gen.EncodeGobConfig(caddyfscfg)
if err != nil {
c.Warnf("encode gob config error: %v", err)
c.JSON(500, touka.H{"error": err.Error()})
return
}
// 把json变为gob []byte
paramsOrigin, err = gen.EncodeGobConfig(config)
if err != nil {
c.Warnf("encode origin config error: %v", err)
c.JSON(500, touka.H{"error": err.Error()})
return
}
paramsEntry := db.ParamsEntry{
Filename: filename,
TemplateType: config.TmplType,
ParamsGOB: paramsGOB,
ParamsOrigin: paramsOrigin,
}
err = WriteConfig(cdb, paramsEntry, cfg, filename)
if err != nil {
c.Warnf("write config error: %v", err)
c.JSON(500, touka.H{"error": err.Error()})
return
}
c.JSON(200, touka.H{"message": "config saved and rendered"})
return
case "reverse_proxy":
caddyrpCfg := gen.CaddyReverseProxyConfig{
Domain: config.Domain,
ReverseProxy: config.UpStream.UpStream,
Headers: gen.HeadersMapToHeadersUp(config.Headers),
HeadersUp: gen.HeadersMapToHeadersUp(config.UpStream.UpStreamHeaders),
EnableLog: config.Log.EnableLog,
LogDomain: config.Log.LogDomain,
EnableErrorPage: config.ErrorPage.EnableErrorPage,
EnableEncode: config.Encode.EnableEncode,
}
paramsGOB, err = gen.EncodeGobConfig(caddyrpCfg)
if err != nil {
c.Warnf("encode gob config error: %v", err)
c.JSON(500, touka.H{"error": err.Error()})
return
}
// 把json变为gob []byte
paramsOrigin, err = gen.EncodeGobConfig(config)
if err != nil {
c.Warnf("encode origin config error: %v", err)
c.JSON(500, touka.H{"error": err.Error()})
return
}
paramsEntry := db.ParamsEntry{
Filename: filename,
TemplateType: config.TmplType,
ParamsGOB: paramsGOB,
ParamsOrigin: paramsOrigin,
}
err = WriteConfig(cdb, paramsEntry, cfg, filename)
if err != nil {
c.Warnf("write config error: %v", err)
c.JSON(500, touka.H{"error": err.Error()})
return
}
c.JSON(200, touka.H{"message": "config saved and rendered"})
return
default:
c.JSON(500, touka.H{"error": "unknown template type"})
return
}
}
}
*/

40
api/files.go Normal file
View file

@ -0,0 +1,40 @@
package api
import (
"caddydash/db"
"github.com/infinite-iroha/touka"
)
func FilesParams(cdb *db.ConfigDB) touka.HandlerFunc {
return func(c *touka.Context) {
params, err := cdb.RangeAllParams()
if err != nil {
c.JSON(500, touka.H{"error": err.Error()})
return
}
c.JSON(200, params)
}
}
func FilesTemplates(cdb *db.ConfigDB) touka.HandlerFunc {
return func(c *touka.Context) {
templates, err := cdb.GetAllTemplates()
if err != nil {
c.JSON(500, touka.H{"error": err.Error()})
return
}
c.JSON(200, templates)
}
}
func FilesRendered(cdb *db.ConfigDB) touka.HandlerFunc {
return func(c *touka.Context) {
rendered, err := cdb.RangeAllReandered()
if err != nil {
c.JSON(500, touka.H{"error": err.Error()})
return
}
c.JSON(200, rendered)
}
}

170
apic/run.go Normal file
View file

@ -0,0 +1,170 @@
package apic
import (
"caddydash/config"
"context"
"log"
"os"
"os/exec"
"sync"
"time"
"github.com/infinite-iroha/touka"
)
type CaddyRunning struct {
running bool
mu sync.Mutex
}
func (c *CaddyRunning) IsRunning() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.running
}
func (c *CaddyRunning) SetRunning(running bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.running = running
}
var caddyRunning = &CaddyRunning{}
func RunCaddy(cfg *config.Config) error {
if caddyRunning.IsRunning() {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
caddyPath := cfg.Server.CaddyDir + "caddy"
cmd := exec.CommandContext(ctx, caddyPath, "run", "--config", "Caddyfile")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
caddyRunning.SetRunning(true)
err := cmd.Start()
if err != nil {
cancel()
return err
}
go func() {
waitErr := cmd.Wait()
caddyRunning.SetRunning(false)
log.Printf("Caddy process exited with error: %v", waitErr)
cancel()
if waitErr != nil {
// 如果 Caddy 非正常退出例如崩溃Wait() 会返回 *exec.ExitError
// 对于优雅关闭,如果 Caddy 接收到 SIGTERM 并正常退出Wait() 返回 nil
// 只有当进程以非零状态码退出时Wait() 才返回非 nil 的 ExitError
// 这里的日志输出应由外部日志库处理而不是标准库log
if exitErr, ok := waitErr.(*exec.ExitError); ok {
log.Printf("Caddy process exited with non-zero status: %v", exitErr)
} else {
log.Printf("Caddy process exited with error: %v", waitErr)
}
} else {
log.Println("Caddy process exited gracefully.")
}
}()
return nil
}
func StartCaddy(cfg *config.Config) touka.HandlerFunc {
return func(c *touka.Context) {
if caddyRunning.IsRunning() {
c.JSON(200, map[string]string{"message": "Caddy is already running"})
return
}
go func() {
err := RunCaddy(cfg)
if err != nil {
c.Errorf("Failed to start Caddy: %v", err)
c.JSON(500, map[string]string{"error": err.Error()})
return
}
}()
c.JSON(200, map[string]string{"message": "Caddy is starting"})
}
}
func IsCaddyRunning() touka.HandlerFunc {
return func(c *touka.Context) {
if caddyRunning.IsRunning() {
c.JSON(200, map[string]string{"message": "Caddy is running"})
} else {
c.JSON(200, map[string]string{"message": "Caddy is not running"})
}
}
}
func StopCaddy() touka.HandlerFunc {
return func(c *touka.Context) {
if !caddyRunning.IsRunning() {
c.JSON(200, map[string]string{"message": "Caddy is not running"})
return
}
client := c.GetHTTPC()
rb := client.NewRequestBuilder("POST", "http://127.0.0.1:2019/stop")
resp, err := rb.Execute()
if err != nil {
c.JSON(500, map[string]string{"error": err.Error()})
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
c.JSON(resp.StatusCode, map[string]string{"error": "Failed to stop Caddy"})
return
}
caddyRunning.SetRunning(false)
c.JSON(200, map[string]string{"message": "Caddy stopped successfully"})
}
}
func RestartCaddy(cfg *config.Config) touka.HandlerFunc {
return func(c *touka.Context) {
if !caddyRunning.IsRunning() {
c.JSON(200, map[string]string{"message": "Caddy is not running, starting it now"})
go func() {
err := RunCaddy(cfg)
if err != nil {
c.Errorf("Failed to start Caddy: %v", err)
}
}()
return
}
// StopCaddy
client := c.GetHTTPC()
rb := client.NewRequestBuilder("POST", "http://127.0.0.1:2019/stop")
resp, err := rb.Execute()
if err != nil {
c.JSON(500, map[string]string{"error": err.Error()})
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
c.JSON(resp.StatusCode, map[string]string{"error": "Failed to stop Caddy for restart"})
return
}
// 等待caddy关闭
for caddyRunning.IsRunning() {
time.Sleep(500 * time.Millisecond) // 等待500ms
}
// StartCaddy
go func() {
err := RunCaddy(cfg)
if err != nil {
c.Errorf("Failed to restart Caddy: %v", err)
}
}()
c.JSON(200, map[string]string{"message": "Caddy is restarting"})
}
}

11
config.toml Normal file
View file

@ -0,0 +1,11 @@
[Server]
port = 81
host = "0.0.0.0"
debug = false
caddyDir = "./"
[Tmpl]
path = "./tmpl"
[database]
filepath = "caddydash.db"

110
config/config.go Normal file
View file

@ -0,0 +1,110 @@
package config
import (
"os"
"github.com/BurntSushi/toml"
)
type Config struct {
Server ServerConfig
Tmpl TmplConfig
DB DatabaseConfig `toml:"database"`
}
/*
[server]
host = "0.0.0.0"
port = 8080
debug = false
caddyDir = "./"
*/
type ServerConfig struct {
Port int `toml:"port"`
Host string `toml:"host"`
Debug bool `toml:"debug"`
CaddyDir string `toml:"caddyDir"`
}
/*
[tmpl]
path = "./tmpl"
*/
type TmplConfig struct {
Path string `toml:"path"`
}
/*
[database]
filepath = "sqlite.db"
*/
type DatabaseConfig struct {
Filepath string `toml:"filepath"`
}
// LoadConfig 从 TOML 配置文件加载配置
func LoadConfig(filePath string) (*Config, error) {
if !FileExists(filePath) {
// 楔入配置文件
err := DefaultConfig().WriteConfig(filePath)
if err != nil {
return nil, err
}
return DefaultConfig(), nil
}
var config Config
if _, err := toml.DecodeFile(filePath, &config); err != nil {
return nil, err
}
return &config, nil
}
// 写入配置文件
func (c *Config) WriteConfig(filePath string) error {
file, err := os.Create(filePath)
if err != nil {
return err
}
defer file.Close()
encoder := toml.NewEncoder(file)
return encoder.Encode(c)
}
// 检测文件是否存在
func FileExists(filename string) bool {
_, err := os.Stat(filename)
return !os.IsNotExist(err)
}
// 默认配置结构体
func DefaultConfig() *Config {
/*
[server]
host = "0.0.0.0"
port = 81
debug = false
caddyDir = "./"
[tmpl]
path = "./tmpl"
[database]
filepath = "caddydash.db"
*/
return &Config{
Server: ServerConfig{
Host: "0.0.0.0",
Port: 81,
Debug: false,
CaddyDir: "./",
},
Tmpl: TmplConfig{
Path: "./tmpl",
},
DB: DatabaseConfig{
Filepath: "caddydash.db",
},
}
}

11
config/config.toml Normal file
View file

@ -0,0 +1,11 @@
[server]
host = "0.0.0.0"
port = 8080
debug = false
caddyDir = "./"
[tmpl]
path = "/data/github/WJQSERVER/caddydash/tmpl"
[database]
filepath = "test.db"

157
db/db.go Normal file
View file

@ -0,0 +1,157 @@
package db
import (
"database/sql"
"fmt"
"log"
_ "modernc.org/sqlite"
//_ "github.com/mattn/go-sqlite3"
)
// TemplateEntry 对应 templates 表
type TemplateEntry struct {
Filename string `json:"filename"`
TemplateType string `json:"template_type"`
Content []byte `json:"content"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// ParamsEntry 对应 config_params 表
type ParamsEntry struct {
Filename string `json:"filename"`
TemplateType string `json:"template_type"`
ParamsGOB []byte `json:"params_gob"`
ParamsOrigin []byte `json:"params_origin"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// RenderedConfigEntry 对应 rendered_configs 表
type RenderedConfigEntry struct {
Filename string `json:"filename"`
RenderedContent []byte `json:"rendered_content"`
RenderedAt int64 `json:"rendered_at"`
UpdatedAt int64 `json:"updated_at"`
}
// UsersTable
type UsersTable struct {
UserName string `json:"username"`
Password string `json:"password"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// ConfigDB
type ConfigDB struct {
DB *sql.DB
}
func InitDB(filepath string) (*ConfigDB, error) {
db, err := loadDB(filepath)
if err != nil {
return nil, err
}
cdb := &ConfigDB{DB: db}
// 尝试 Ping 数据库以验证连接是否成功
if err = db.Ping(); err != nil {
db.Close() // 如果连接失败,确保关闭数据库句柄
return nil, fmt.Errorf("db: failed to connect to database: %w", err)
}
err = cdb.createTables()
if err != nil {
return nil, err
}
return cdb, nil
}
func loadDB(filepath string) (*sql.DB, error) {
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?cache=shared&_journal=WAL", filepath))
if err != nil {
log.Fatalf("Can not connect to database: %v", err)
return nil, err
}
return db, nil
}
func (cdb *ConfigDB) createTables() error {
tx, err := cdb.DB.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction for table creation: %w", err)
}
defer tx.Rollback() // 确保在出错时回滚事务
// 创建 templates 表
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS templates (
filename TEXT PRIMARY KEY,
template_type TEXT NOT NULL,
content BLOB NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);`)
if err != nil {
return fmt.Errorf("failed to create 'templates' table: %w", err)
}
// 2. 创建 config_params 表
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS config_params (
filename TEXT PRIMARY KEY,
template_type TEXT NOT NULL,
params_gob BLOB NOT NULL,
params_origin BLOB NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
-- FOREIGN KEY(filename) REFERENCES templates(filename) ON DELETE CASCADE -- 已移除
);`)
if err != nil {
return fmt.Errorf("failed to create 'config_params' table: %w", err)
}
// 3. 创建 rendered_configs 表 (外键指向 config_params 表)
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS rendered_configs (
filename TEXT PRIMARY KEY,
rendered_content BLOB NOT NULL,
rendered_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
FOREIGN KEY(filename) REFERENCES config_params(filename) ON DELETE CASCADE -- 修改外键引用
);`)
if err != nil {
return fmt.Errorf("failed to create 'rendered_configs' table: %w", err)
}
//
// 4. 创建 users 表
_, err = tx.Exec(`
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
password TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);`)
if err != nil {
return fmt.Errorf("failed to create 'users' table: %w", err)
}
// 提交事务
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction for table creation: %w", err)
}
return nil
}
func (cdb *ConfigDB) CloseDB() error {
if cdb.DB == nil {
return nil // 数据库未打开或已关闭
}
err := cdb.DB.Close()
if err != nil {
return fmt.Errorf("db: failed to close database: %w", err)
}
return nil
}

504
db/operation.go Normal file
View file

@ -0,0 +1,504 @@
package db
import (
"bytes"
"database/sql"
"encoding/gob"
"errors"
"fmt"
"strings"
"text/template"
)
// 用户校验操作
/*
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
password TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);`)
*/
// AddUser 向 'users' 表中添加一个新用户.
func (cdb *ConfigDB) AddUser(username, password string) error {
insertSQL := `
INSERT INTO users (username, password)
VALUES (?, ?);
`
_, err := cdb.DB.Exec(insertSQL, username, password)
if err != nil {
return fmt.Errorf("db: failed to add user '%s': %w", username, err)
}
return nil
}
// GetUserByUsername 从 'users' 表中根据用户名获取用户信息.
func (cdb *ConfigDB) GetUserByUsername(username string) (*UsersTable, error) {
querySQL := `SELECT username, password, created_at, updated_at FROM users WHERE username = ?;`
row := cdb.DB.QueryRow(querySQL, username)
user := &UsersTable{}
err := row.Scan(&user.UserName, &user.Password, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("db: user '%s' not found: %w", username, err)
}
return nil, fmt.Errorf("db: failed to get user '%s': %w", username, err)
}
return user, nil
}
// DeleteUser 从 'users' 表中删除一个用户.
func (cdb *ConfigDB) DeleteUser(username string) error {
_, err := cdb.DB.Exec(`DELETE FROM users WHERE username = ?;`, username)
if err != nil {
return fmt.Errorf("db: failed to delete user '%s': %w", username, err)
}
return nil
}
// UpdateUserPassword 更新用户的密码.
func (cdb *ConfigDB) UpdateUserPassword(username, newPassword string) error {
updateSQL := `
UPDATE users
SET password = ?, updated_at = strftime('%s', 'now')
WHERE username = ?;
`
result, err := cdb.DB.Exec(updateSQL, newPassword, username)
if err != nil {
return fmt.Errorf("db: failed to update user '%s' password: %w", username, err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("db: failed to get rows affected by update: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("db: no user with username '%s' found to update", username)
}
return nil
}
// RangeUserNames 获取所有用户的用户名.
func (cdb *ConfigDB) RangeUserNames() ([]string, error) {
querySQL := `SELECT username FROM users;`
rows, err := cdb.DB.Query(querySQL)
if err != nil {
return nil, fmt.Errorf("db: failed to get usernames from users: %w", err)
}
defer rows.Close()
var usernames []string
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return nil, fmt.Errorf("db: failed to scan username: %w", err)
}
usernames = append(usernames, username)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("db: error during user rows iteration: %w", err)
}
return usernames, nil
}
// HasAnyUser 检查 'users' 表中是否存在任何用户.
func (cdb *ConfigDB) HasAnyUser() (bool, error) {
querySQL := `SELECT EXISTS(SELECT 1 FROM users LIMIT 1);`
var exists bool
err := cdb.DB.QueryRow(querySQL).Scan(&exists)
if err != nil {
return false, fmt.Errorf("db: failed to check if any user exists: %w", err)
}
return exists, nil
}
// IsUserExists 检查指定用户名的用户是否存在.
func (cdb *ConfigDB) IsUserExists(username string) (bool, error) {
querySQL := `SELECT EXISTS(SELECT 1 FROM users WHERE username = ? LIMIT 1);`
var exists bool
err := cdb.DB.QueryRow(querySQL, username).Scan(&exists)
if err != nil {
return false, fmt.Errorf("db: failed to check if user '%s' exists: %w", username, err)
}
return exists, nil
}
// GetPasswordByUsername 从 'users' 表中根据用户名获取密码.
func (cdb *ConfigDB) GetPasswordByUsername(username string) (string, error) {
querySQL := `SELECT password FROM users WHERE username = ?;`
var password string
err := cdb.DB.QueryRow(querySQL, username).Scan(&password)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("db: user '%s' not found: %w", username, err)
}
return "", fmt.Errorf("db: failed to get password for user '%s': %w", username, err)
}
return password, nil
}
// --- 模板操作 (Templates Table) ---
// SaveTemplate 在 'templates' 表中保存或更新一个模板.
func (cdb *ConfigDB) SaveTemplate(entry TemplateEntry) error {
insertSQL := `
INSERT INTO templates (filename, template_type, content)
VALUES (?, ?, ?)
ON CONFLICT(filename) DO UPDATE SET
template_type = EXCLUDED.template_type,
content = EXCLUDED.content,
updated_at = strftime('%s', 'now');
`
_, err := cdb.DB.Exec(insertSQL, entry.Filename, entry.TemplateType, entry.Content)
if err != nil {
return fmt.Errorf("db: failed to save template '%s': %w", entry.Filename, err)
}
return nil
}
// GetTemplate 从 'templates' 表中获取一个模板内容.
func (cdb *ConfigDB) GetTemplate(filename string) (*TemplateEntry, error) {
querySQL := `SELECT filename, template_type, content, created_at, updated_at FROM templates WHERE filename = ?;`
row := cdb.DB.QueryRow(querySQL, filename)
entry := &TemplateEntry{}
err := row.Scan(&entry.Filename, &entry.TemplateType, &entry.Content, &entry.CreatedAt, &entry.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("db: template '%s' not found: %w", filename, err)
}
return nil, fmt.Errorf("db: failed to get template '%s': %w", filename, err)
}
return entry, nil
}
// DeleteTemplate 从 'templates' 表中删除一个模板.
// 请注意: 此操作不会级联删除 'config_params' 或 'rendered_configs' 中的关联数据;
// 因为 'templates' 表不再是它们的外键父表.
func (cdb *ConfigDB) DeleteTemplate(filename string) error {
_, err := cdb.DB.Exec(`DELETE FROM templates WHERE filename = ?;`, filename)
if err != nil {
return fmt.Errorf("db: failed to delete template '%s': %w", filename, err)
}
return nil
}
// RangeTempaltes 获取所有模板的名称
func (cdb *ConfigDB) RangeTemplates() ([]string, error) {
querySQL := `SELECT filename FROM templates;`
rows, err := cdb.DB.Query(querySQL)
if err != nil {
return nil, fmt.Errorf("db: failed to get filenames from templates: %w", err)
}
defer rows.Close()
var filenames []string
for rows.Next() {
var filename string
if err := rows.Scan(&filename); err != nil {
return nil, fmt.Errorf("db: failed to scan template filename: %w", err)
}
filenames = append(filenames, filename)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("db: error during template rows iteration: %w", err)
}
return filenames, nil
}
// GetAllTempaltes
func (cdb *ConfigDB) GetAllTemplates() ([]TemplateEntry, error) {
querySQL := `SELECT filename, template_type, content, created_at, updated_at FROM templates;`
rows, err := cdb.DB.Query(querySQL)
if err != nil {
return nil, fmt.Errorf("db: failed to get all templates: %w", err)
}
defer rows.Close()
var templates []TemplateEntry
for rows.Next() {
var entry TemplateEntry
if err := rows.Scan(&entry.Filename, &entry.TemplateType, &entry.Content, &entry.CreatedAt, &entry.UpdatedAt); err != nil {
return nil, fmt.Errorf("db: failed to scan template entry: %w", err)
}
templates = append(templates, entry)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("db: error during templates rows iteration: %w", err)
}
return templates, nil
}
// --- 参数操作 (Config_Params Table) ---
/*
filename TEXT PRIMARY KEY,
template_type TEXT NOT NULL,
params_gob BLOB NOT NULL,
params_origin BLOB NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
*/
// SaveParams 在 'config_params' 表中保存或更新一个模板的参数.
// entry.ParamsGOB 应该是一个经过 GOB 编码的字节切片.
func (cdb *ConfigDB) SaveParams(entry ParamsEntry) error {
insertSQL := `
INSERT INTO config_params (filename, template_type, params_gob, params_origin)
VALUES (?, ?, ?, ?)
ON CONFLICT(filename) DO UPDATE SET
template_type = EXCLUDED.template_type,
params_gob = EXCLUDED.params_gob,
params_origin = EXCLUDED.params_origin,
updated_at = strftime('%s', 'now');
`
_, err := cdb.DB.Exec(insertSQL, entry.Filename, entry.TemplateType, entry.ParamsGOB, entry.ParamsOrigin)
if err != nil {
return fmt.Errorf("db: failed to save params for '%s': %w", entry.Filename, err)
}
return nil
}
// GetParams 从 'config_params' 表中获取一个模板的参数.
// 返回的 ParamsGOB 是 GOB 编码的字节切片; 调用方需要自行解码.
func (cdb *ConfigDB) GetParams(filename string) (*ParamsEntry, error) {
querySQL := `SELECT filename, template_type, params_gob, params_origin, created_at, updated_at FROM config_params WHERE filename = ?;`
row := cdb.DB.QueryRow(querySQL, filename)
entry := &ParamsEntry{}
err := row.Scan(&entry.Filename, &entry.TemplateType, &entry.ParamsGOB, &entry.ParamsOrigin, &entry.CreatedAt, &entry.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("db: params for '%s' not found: %w", filename, err)
}
return nil, fmt.Errorf("db: failed to get params for '%s': %w", filename, err)
}
return entry, nil
}
// DeleteParams 从 'config_params' 表中删除一个模板的参数.
// 此操作将级联删除 'rendered_configs' 表中与该 filename 关联的所有渲染产物.
func (cdb *ConfigDB) DeleteParams(filename string) error {
_, err := cdb.DB.Exec(`DELETE FROM config_params WHERE filename = ?;`, filename)
if err != nil {
return fmt.Errorf("db: failed to delete params for '%s': %w", filename, err)
}
return nil
}
// GetFileNames
func (cdb *ConfigDB) GetFileNames() ([]string, error) {
querySQL := `SELECT filename FROM config_params;`
rows, err := cdb.DB.Query(querySQL)
if err != nil {
return nil, fmt.Errorf("db: failed to get filenames from config_params: %w", err)
}
defer rows.Close()
var filenames []string
for rows.Next() {
var filename string
if err := rows.Scan(&filename); err != nil {
return nil, fmt.Errorf("db: failed to scan filename: %w", err)
}
filenames = append(filenames, filename)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("db: error during rows iteration: %w", err)
}
return filenames, nil
}
// RangeAllParams
func (cdb *ConfigDB) RangeAllParams() ([]ParamsEntry, error) {
querySQL := `SELECT filename, template_type, params_gob, params_origin, created_at, updated_at FROM config_params;`
rows, err := cdb.DB.Query(querySQL)
if err != nil {
return nil, fmt.Errorf("db: failed to get all params: %w", err)
}
defer rows.Close()
var params []ParamsEntry
for rows.Next() {
var entry ParamsEntry
if err := rows.Scan(&entry.Filename, &entry.TemplateType, &entry.ParamsGOB, &entry.CreatedAt, &entry.UpdatedAt); err != nil {
return nil, fmt.Errorf("db: failed to scan params entry: %w", err)
}
params = append(params, entry)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("db: error during params rows iteration: %w", err)
}
return params, nil
}
// --- 渲染产物操作 (Rendered_Configs Table) ---
// SaveRenderedConfig 在 'rendered_configs' 表中保存或更新一个渲染后的配置文件内容.
// 注意: 该操作依赖于 'config_params' 表中已存在对应的 filename; 否则会违反外键约束.
func (cdb *ConfigDB) SaveRenderedConfig(entry RenderedConfigEntry) error {
insertSQL := `
INSERT INTO rendered_configs (filename, rendered_content, rendered_at)
VALUES (?, ?, strftime('%s', 'now'))
ON CONFLICT(filename) DO UPDATE SET
rendered_content = EXCLUDED.rendered_content,
rendered_at = strftime('%s', 'now'),
updated_at = strftime('%s', 'now');
`
_, err := cdb.DB.Exec(insertSQL, entry.Filename, entry.RenderedContent)
if err != nil {
return fmt.Errorf("db: failed to save rendered config for '%s': %w", entry.Filename, err)
}
return nil
}
// GetRenderedConfig 从 'rendered_configs' 表中获取一个渲染后的配置文件内容.
func (cdb *ConfigDB) GetRenderedConfig(filename string) (*RenderedConfigEntry, error) {
querySQL := `SELECT filename, rendered_content, rendered_at, updated_at FROM rendered_configs WHERE filename = ?;`
row := cdb.DB.QueryRow(querySQL, filename)
entry := &RenderedConfigEntry{}
err := row.Scan(&entry.Filename, &entry.RenderedContent, &entry.RenderedAt, &entry.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("db: rendered config '%s' not found: %w", filename, err)
}
return nil, fmt.Errorf("db: failed to get rendered config '%s': %w", filename, err)
}
return entry, nil
}
// DeleteRenderedConfig 从 'rendered_configs' 表中删除一个渲染后的配置文件内容.
func (cdb *ConfigDB) DeleteRenderedConfig(filename string) error {
_, err := cdb.DB.Exec(`DELETE FROM rendered_configs WHERE filename = ?;`, filename)
if err != nil {
return fmt.Errorf("db: failed to delete rendered config for '%s': %w", filename, err)
}
return nil
}
// RangeAllReandered
func (cdb *ConfigDB) RangeAllReandered() ([]RenderedConfigEntry, error) {
querySQL := `SELECT filename, rendered_content, rendered_at, updated_at FROM rendered_configs;`
rows, err := cdb.DB.Query(querySQL)
if err != nil {
return nil, fmt.Errorf("db: failed to get all rendered configs: %w", err)
}
defer rows.Close()
var renderedConfigs []RenderedConfigEntry
for rows.Next() {
var entry RenderedConfigEntry
if err := rows.Scan(&entry.Filename, &entry.RenderedContent, &entry.RenderedAt, &entry.UpdatedAt); err != nil {
return nil, fmt.Errorf("db: failed to scan rendered config entry: %w", err)
}
renderedConfigs = append(renderedConfigs, entry)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("db: error during rendered configs rows iteration: %w", err)
}
return renderedConfigs, nil
}
// --- 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
}

40
db/struct.go Normal file
View file

@ -0,0 +1,40 @@
package db
/*
// 通用配置结构
type CaddyfileConfig struct {
Domain string `json:"domain"`
TmplType string `json:"tmpl_type"`
UpStream UpStreamConfig `json:"upstream"`
FileServer FileServerConfig `json:"file_server"`
Headers map[string][]string `json:"headers"`
Log LogConfig `json:"log"`
ErrorPage ErrorPageConfig `json:"error_page"`
Encode EncodeConfig `json:"encode"`
}
type UpStreamConfig struct {
UpStream string `json:"upstream"`
MutiUpStream bool `json:"muti_upstream"`
UpStreams []string `json:"upstream_servers"`
UpStreamHeaders map[string][]string `json:"upstream_headers"`
}
type FileServerConfig struct {
FileDirPath string `json:"file_dir_path"`
EnableBrowser bool `json:"enable_browser"`
}
type LogConfig struct {
EnableLog bool `json:"enable_log"`
LogDomain string `json:"log_domain"`
}
type ErrorPageConfig struct {
EnableErrorPage bool `json:"enable_error_page"`
}
type EncodeConfig struct {
EnableEncode bool `json:"enable_encode"`
}
*/

0
deploy/install.sh Normal file
View file

411
frontend/css/style.css Normal file
View file

@ -0,0 +1,411 @@
/* --- 全局与变量定义 --- */
:root {
--bg-color: #f8f9fa;
--surface-color: #ffffff;
--primary-color: #4f46e5;
--primary-color-hover: #4338ca;
--danger-color: #e11d48;
--danger-color-hover: #be123c;
--success-color: #22c55e;
--success-color-hover: #16a34a;
--warning-color: #f59e0b;
--warning-color-hover: #d97706;
--checking-color: #f59e0b;
--text-color: #1f2937;
--text-color-secondary: #6b7280;
--border-color: #e5e7eb;
--border-radius-large: 12px;
--border-radius-small: 8px;
--font-family: 'Inter', sans-serif;
--sidebar-width: 260px;
--transition-speed: 0.3s;
}
[data-theme="dark"] {
--bg-color: #111827;
--surface-color: #1f2937;
--text-color: #f9fafb;
--text-color-secondary: #9ca3af;
--border-color: #374151;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-family);
background-color: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
overflow: hidden;
transition: background-color var(--transition-speed), color var(--transition-speed);
}
.hidden { display: none !important; }
/* --- 登录页 --- */
.login-page-body {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.login-container {
width: 100%;
max-width: 400px;
padding: 40px;
background-color: var(--surface-color);
border-radius: var(--border-radius-large);
border: 1px solid var(--border-color);
box-shadow: 0 10px 25px rgba(0,0,0,0.05);
text-align: center;
}
.login-header { margin-bottom: 32px; }
.login-header .fa-rocket {
font-size: 2.5rem;
color: var(--primary-color);
margin-bottom: 16px;
}
.login-header h1 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 8px;
}
.login-header p { color: var(--text-color-secondary); }
#login-form .form-group {
text-align: left;
margin-bottom: 20px;
}
#init-form .form-group {
text-align: left;
margin-bottom: 20px;
}
.btn-login {
margin-top: 16px;
width: 100%;
justify-content: space-between;
padding-left: 24px;
padding-right: 24px;
}
.error-text {
color: var(--danger-color);
font-size: 0.875rem;
text-align: left;
margin-top: -12px;
margin-bottom: 16px;
min-height: 1.2em;
}
/* --- Toast 通知 --- */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 12px;
}
.toast {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background-color: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-small);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
width: 320px;
opacity: 0;
transform: translateX(100%);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast.show { opacity: 1; transform: translateX(0); }
.toast-icon { font-size: 1.25rem; }
.toast-message { flex-grow: 1; font-size: 0.9rem; font-weight: 500; }
.toast-close {
background: none; border: none; color: var(--text-color-secondary);
cursor: pointer; font-size: 1.1rem; padding: 4px;
}
.toast.success .toast-icon { color: var(--success-color); }
.toast.error .toast-icon { color: var(--danger-color); }
.toast.info .toast-icon { color: var(--primary-color); }
/* --- 交互式对话框/Modal --- */
#dialog-container {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
z-index: 1000; display: flex; align-items: center; justify-content: center;
background-color: rgba(0,0,0,0.3);
opacity: 0; visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s;
}
#dialog-container.active {
opacity: 1; visibility: visible;
}
.dialog-box {
background-color: var(--surface-color); border-radius: var(--border-radius-large);
padding: 24px; width: 90%; max-width: 400px;
box-shadow: 0 10px 25px rgba(0,0,0,0.1); text-align: center;
transform: scale(0.95);
transition: transform 0.2s ease;
}
#dialog-container.active .dialog-box {
transform: scale(1);
}
.dialog-message { margin: 16px 0 24px; font-size: 1rem; color: var(--text-color-secondary); }
.dialog-actions { display: flex; justify-content: center; gap: 12px; }
.dialog-actions .btn { width: auto; }
/* --- 主应用布局 --- */
.app-container { display: flex; height: 100vh; }
.main-content { flex-grow: 1; padding: 24px 32px; overflow-y: auto; }
#view-container { position: relative; }
.view { animation: fadeIn 0.5s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.sidebar {
width: var(--sidebar-width);
background-color: var(--surface-color);
padding: 24px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
flex-shrink: 0;
transition: transform var(--transition-speed) ease, background-color var(--transition-speed), border-color var(--transition-speed);
}
.sidebar-header {
display: flex; align-items: center; gap: 12px;
margin-bottom: 32px; font-size: 1.5rem; color: var(--primary-color);
}
.sidebar-header h1 { font-size: 1.25rem; font-weight: 600; color: var(--text-color); }
.sidebar-nav { flex-grow: 1; }
.sidebar-nav ul li a {
display: flex; align-items: center; gap: 16px; padding: 12px;
border-radius: var(--border-radius-small); color: var(--text-color-secondary);
font-weight: 500; transition: all 0.2s ease;
}
.sidebar-nav ul li a:hover { background-color: var(--bg-color); color: var(--text-color); }
.sidebar-nav ul li a.active { background-color: var(--primary-color); color: white; }
.sidebar-nav ul li a.active i { color: white; }
.sidebar-bottom { margin-top: auto; padding-top: 16px; border-top: 1px solid var(--border-color); transition: border-color var(--transition-speed); }
.theme-switcher {
display: flex;
justify-content: space-around;
align-items: center;
padding: 12px;
background-color: var(--bg-color);
border-radius: var(--border-radius-small);
margin-bottom: 16px;
transition: background-color var(--transition-speed);
}
.theme-switcher i { color: var(--text-color-secondary); }
.switch { position: relative; display: inline-block; width: 44px; height: 24px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
background-color: #ccc; border-radius: 24px;
transition: var(--transition-speed);
}
.slider:before {
position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px;
background-color: white; border-radius: 50%;
transition: var(--transition-speed);
}
input:checked + .slider { background-color: var(--primary-color); }
input:checked + .slider:before { transform: translateX(20px); }
.caddy-control-panel {
margin-top: 16px;
padding: 12px;
background-color: var(--bg-color);
border-radius: var(--border-radius-small);
transition: background-color var(--transition-speed);
}
#caddy-action-button-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.caddy-status {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
transition: background-color var(--transition-speed);
}
.status-dot.running { background-color: var(--success-color); }
.status-dot.stopped { background-color: var(--danger-color); }
.status-dot.checking { background-color: var(--checking-color); }
.status-dot.error { background-color: var(--text-color-secondary); }
.status-text {
font-weight: 500;
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.logout-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
transition: border-color var(--transition-speed);
}
.main-header {
display: flex; align-items: center; margin-bottom: 24px;
}
.main-header h2 { font-size: 1.75rem; font-weight: 700; flex-grow: 1; }
#menu-toggle-btn { display: none; margin-right: 16px; }
.card-panel {
background-color: var(--surface-color);
border-radius: var(--border-radius-large);
padding: 24px;
margin-bottom: 24px;
border: 1px solid var(--border-color);
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -1px rgba(0,0,0,0.03);
transition: background-color var(--transition-speed), border-color var(--transition-speed);
}
.form-panel-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.form-panel-header h3 {
flex-grow: 1;
}
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 10px 18px; border: 1px solid transparent;
border-radius: var(--border-radius-small); font-weight: 600;
cursor: pointer; transition: all 0.2s ease;
width: 100%;
}
.btn:active { transform: scale(0.97); }
.btn:disabled {
background-color: #e5e7eb;
color: #9ca3af;
cursor: not-allowed;
border-color: transparent;
transform: none;
}
[data-theme="dark"] .btn:disabled {
background-color: #374151;
color: #6b7280;
}
.btn-primary { background-color: var(--primary-color); color: white; }
.btn-primary:hover:not(:disabled) { background-color: var(--primary-color-hover); }
.btn-secondary { background-color: var(--surface-color); color: var(--text-color); border-color: var(--border-color); }
.btn-secondary:hover:not(:disabled) { border-color: #ced4da; background-color: var(--bg-color); }
.btn-danger { background-color: var(--danger-color); color: white; }
.btn-danger:hover:not(:disabled) { background-color: var(--danger-color-hover); }
.btn-success { background-color: var(--success-color); color: white; }
.btn-success:hover:not(:disabled) { background-color: var(--success-color-hover); }
.btn-warning { background-color: var(--warning-color); color: white; }
.btn-warning:hover:not(:disabled) { background-color: var(--warning-color-hover); }
.btn-small { padding: 6px 12px; font-size: 0.875rem; width: auto; }
.btn-icon {
background: none; border: none; color: var(--text-color-secondary); cursor: pointer;
width: 40px; height: 40px; font-size: 1.1rem; border-radius: 50%;
width: auto;
}
.btn-icon:hover { background-color: var(--bg-color); color: var(--text-color); }
.main-header .btn-primary { width: auto; }
.form-actions .btn { width: auto; }
.config-list-container { display: flex; flex-direction: column; gap: 12px; }
.config-item {
display: flex; align-items: center; padding: 12px 16px; background-color: var(--bg-color);
border: 1px solid var(--border-color); border-radius: var(--border-radius-small);
transition: all 0.2s ease;
}
.config-item:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.05); }
.config-item-name { font-weight: 500; flex-grow: 1; }
.config-item-actions { display: flex; gap: 8px; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; margin-bottom: 24px; }
.form-group label, fieldset legend {
display: block; font-weight: 500; margin-bottom: 8px; color: var(--text-color-secondary); font-size: 0.875rem;
}
.form-group input {
width: 100%; padding: 12px; background-color: var(--surface-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-small); color: var(--text-color); font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s, background-color var(--transition-speed);
}
.form-group input:focus {
outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
}
fieldset { border: none; padding: 0; margin: 0 0 24px 0; }
.sub-fieldset {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-small);
padding: 16px;
margin-top: 20px;
}
.sub-legend {
font-weight: 500;
color: var(--text-color-secondary);
font-size: 0.875rem;
margin-bottom: 12px;
}
.checkbox-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-top: 8px; }
.custom-checkbox {
position: relative; display: inline-flex; align-items: center; cursor: pointer; gap: 12px;
}
.custom-checkbox input { display: none; }
.custom-checkbox .checkmark {
width: 20px; height: 20px; border: 2px solid var(--border-color);
border-radius: 6px; transition: all 0.2s;
}
.custom-checkbox input:checked + .checkmark { background-color: var(--primary-color); border-color: var(--primary-color); }
.custom-checkbox .checkmark::after {
content: "\f00c"; font-family: "Font Awesome 6 Free"; font-weight: 900;
font-size: 12px; color: white; position: absolute; top: 3px; left: 3px;
transform: scale(0); transition: transform 0.2s;
}
.custom-checkbox input:checked + .checkmark::after { transform: scale(1); }
.header-entry {
display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; margin-bottom: 12px;
}
.header-entry input {
width: 100%; padding: 10px; background-color: var(--bg-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-small); color: var(--text-color);
}
.custom-select { position: relative; }
.select-selected {
display: flex; justify-content: space-between; align-items: center;
padding: 12px; background-color: var(--surface-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-small); cursor: pointer; user-select: none;
transition: border-color 0.2s, box-shadow 0.2s, background-color var(--transition-speed);
}
.select-selected.select-arrow-active {
border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
}
.select-selected::after {
content: '\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900;
transition: transform var(--transition-speed) ease;
}
.select-selected.select-arrow-active::after { transform: rotate(180deg); }
.select-items {
position: absolute; background-color: var(--surface-color); top: 100%; left: 0; right: 0;
border: 1px solid var(--border-color); border-radius: var(--border-radius-small);
box-shadow: 0 8px 16px rgba(0,0,0,0.1); z-index: 99;
margin-top: 8px; max-height: 200px; overflow-y: auto;
opacity: 0; transform: translateY(-10px); visibility: hidden;
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s;
}
.select-items.select-show { opacity: 1; transform: translateY(0); visibility: visible; }
.select-items div {
padding: 12px 16px; cursor: pointer; transition: background-color 0.2s;
}
.select-items div:hover, .same-as-selected { background-color: var(--bg-color); }
#rendered-output-panel pre {
background-color: var(--bg-color); padding: 16px; border-radius: var(--border-radius-small);
overflow-x: auto; white-space: pre-wrap; word-break: break-all;
border: 1px solid var(--border-color); transition: background-color var(--transition-speed);
}
@media (max-width: 992px) {
.sidebar {
position: fixed; z-index: 200; height: 100%;
transform: translateX(-100%);
box-shadow: 0 0 20px rgba(0,0,0,0.1);
}
.sidebar.is-open { transform: translateX(0); }
#menu-toggle-btn { display: inline-flex; }
.main-header .btn-text { display: none; }
.sidebar-nav span, .sidebar-footer span { display: inline; }
.main-content { padding: 16px; }
}

148
frontend/index.html Normal file
View file

@ -0,0 +1,148 @@
<!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="#" class="active"><i class="fa-solid fa-sitemap"></i> <span>配置管理</span></a></li>
<li><a href="#"><i class="fa-solid fa-chart-line"></i> <span>仪表盘 (预留)</span></a></li>
<li><a href="#"><i class="fa-solid fa-puzzle-piece"></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>配置管理</h2>
<button id="add-new-config-btn" class="btn btn-primary">
<i class="fa-solid fa-plus"></i> <span class="btn-text">创建新配置</span>
</button>
</header>
<div id="view-container">
<section id="config-list-panel" class="card-panel view">
<ul id="config-list" class="config-list-container"></ul>
</section>
<section id="config-form-panel" class="card-panel view hidden">
<div class="form-panel-header">
<button id="back-to-list-btn" class="btn-icon" title="返回列表">
<i class="fa-solid fa-arrow-left"></i>
</button>
<h3 id="form-title">创建新配置</h3>
</div>
<form id="config-form">
<input type="hidden" id="original-filename" value="">
<div class="form-grid">
<div class="form-group">
<label for="domain">域名 (将用作文件名)</label>
<input type="text" id="domain" name="domain" required>
</div>
<div class="form-group">
<label>模板类型</label>
<div id="custom-select-tmpl" class="custom-select"></div>
</div>
</div>
<!-- 反向代理配置 -->
<fieldset id="upstream-fieldset" class="hidden">
<legend>反向代理 (Upstream)</legend>
<div class="form-group">
<label for="upstream">上游服务地址</label>
<input type="text" id="upstream" name="upstream" placeholder="例如: 127.0.0.1:8080">
</div>
<!-- 新增: 上游请求头配置区域 -->
<div class="sub-fieldset">
<p class="sub-legend">上游请求头 (Upstream Headers)</p>
<div id="upstream-headers-container"></div>
<button type="button" id="add-upstream-header-btn" class="btn btn-secondary btn-small">
<i class="fa-solid fa-plus"></i> 添加上游请求头
</button>
</div>
</fieldset>
<!-- 文件服务器配置 -->
<fieldset id="fileserver-fieldset" class="hidden">
<legend>文件服务 (File Server)</legend>
<div class="form-group">
<label for="file_dir_path">根目录路径</label>
<input type="text" id="file_dir_path" name="file_dir_path" placeholder="例如: /srv/www">
</div>
<label class="custom-checkbox">
<input type="checkbox" id="enable_browser" name="enable_browser">
<span class="checkmark"></span> 启用文件浏览器
</label>
</fieldset>
<!-- 全局请求头 -->
<fieldset id="headers-fieldset">
<legend>全局请求头 (Headers)</legend>
<div id="headers-container"></div>
<button type="button" id="add-header-btn" class="btn btn-secondary btn-small"><i class="fa-solid fa-plus"></i> 添加全局请求头</button>
</fieldset>
<fieldset>
<legend>功能开关</legend>
<div class="checkbox-grid">
<label class="custom-checkbox"><input type="checkbox" id="enable_log" name="enable_log"><span class="checkmark"></span> 启用日志</label>
<label class="custom-checkbox"><input type="checkbox" id="enable_error_page" name="enable_error_page"><span class="checkmark"></span> 启用错误页</label>
<label class="custom-checkbox"><input type="checkbox" id="enable_encode" name="enable_encode"><span class="checkmark"></span> 启用压缩</label>
</div>
</fieldset>
<div class="form-actions">
<button type="submit" class="btn btn-primary"><i class="fa-solid fa-save"></i> <span>保存配置</span></button>
<button type="button" id="cancel-edit-btn" class="btn btn-secondary">取消</button>
</div>
</form>
</section>
<section id="rendered-output-panel" class="card-panel view hidden">
<h3>渲染后的 Caddyfile</h3>
<pre><code id="rendered-content"></code></pre>
</section>
</div>
</main>
</div>
<div id="toast-container" class="toast-container"></div>
<div id="dialog-container"></div>
<script type="module" src="js/app.js"></script>
</body>
</html>

50
frontend/init.html Normal file
View file

@ -0,0 +1,50 @@
<!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 class="login-page-body">
<div class="login-container">
<header class="login-header">
<i class="fa-solid fa-magic-wand-sparkles"></i>
<h1>欢迎使用 CaddyDash</h1>
<p>请创建您的管理员账户以完成首次设置</p>
</header>
<form id="init-form">
<div class="form-group">
<label for="username">管理员用户名</label>
<input type="text" id="username" name="username" autocomplete="username">
</div>
<div class="form-group">
<label for="password">密码 (至少8位)</label>
<input type="password" id="password" name="password" autocomplete="new-password">
</div>
<div class="form-group">
<label for="confirm_password">确认密码</label>
<input type="password" id="confirm_password" name="confirm_password" autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary btn-login">
<span>完成设置</span>
<i class="fa-solid fa-check"></i>
</button>
</form>
</div>
<div id="toast-container" class="toast-container"></div>
<script type="module" src="js/init.js"></script>
</body>
</html>

26
frontend/js/api.js Normal file
View file

@ -0,0 +1,26 @@
// js/api.js - 处理所有与后端API的通信
const API_BASE = '/v0/api';
async function handleResponse(response) {
// 如果响应是重定向(通常是session过期), 则让浏览器自动跳转
if (response.redirected) {
window.location.href = response.url;
// 返回一个永远不会 resolve 的 Promise 来中断后续的 .then() 链
return new Promise(() => {});
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: `HTTP error! status: ${response.status}` }));
throw new Error(errorData.error);
}
const text = await response.text();
// 检查响应体是否为空, 避免解析空字符串时出错
return text ? JSON.parse(text) : { success: true };
}
export const api = {
get: (endpoint) => fetch(`${API_BASE}${endpoint}`).then(handleResponse),
post: (endpoint, body = {}) => fetch(`${API_BASE}${endpoint}`, { method: 'POST', body: JSON.stringify(body), headers: {'Content-Type': 'application/json'} }).then(handleResponse),
put: (endpoint, body) => fetch(`${API_BASE}${endpoint}`, { method: 'PUT', body: JSON.stringify(body), headers: {'Content-Type': 'application/json'} }).then(handleResponse),
delete: (endpoint) => fetch(`${API_BASE}${endpoint}`, { method: 'DELETE' }).then(handleResponse),
};

182
frontend/js/app.js Normal file
View file

@ -0,0 +1,182 @@
// js/app.js - 主应用入口
import { state } from './state.js';
import { api } from './api.js';
import { initTheme } from './theme.js';
import { notification } from './notifications.js';
import { DOMElements, switchView, renderConfigList, createCustomSelect, addKeyValueInput, fillForm, showRenderedConfig, updateCaddyStatusView, updateFormVisibility } from './ui.js';
const POLLING_INTERVAL = 5000;
let caddyStatusInterval;
function getFormStateAsString() {
const formData = new FormData(DOMElements.configForm);
const data = {};
for (const [key, value] of formData.entries()) {
const el = DOMElements.configForm.querySelector(`[name="${key}"]`);
if (el?.type === 'checkbox') data[key] = el.checked;
else if (data[key]) {
if (!Array.isArray(data[key])) data[key] = [data[key]];
data[key].push(value);
} else data[key] = value;
}
return JSON.stringify(data);
}
async function attemptExitForm() {
if (getFormStateAsString() !== state.initialFormState) {
if (await notification.confirm('您有未保存的更改。确定要放弃吗?')) switchView(DOMElements.configListPanel);
} else switchView(DOMElements.configListPanel);
}
async function checkCaddyStatus() {
try {
const response = await api.get('/caddy/status');
updateCaddyStatusView(response.message === 'Caddy is running' ? 'running' : 'stopped', caddyHandlers);
} catch (error) { console.error('Error checking Caddy status:', error); updateCaddyStatusView('error', caddyHandlers); }
}
async function handleStartCaddy() {
try {
const result = await api.post('/caddy/run');
notification.toast(result.message || '启动命令已发送。', 'success');
setTimeout(checkCaddyStatus, 500);
} catch (error) { notification.toast(`启动失败: ${error.message}`, 'error'); }
}
async function handleStopCaddy() {
if (!await notification.confirm('您确定要停止 Caddy 实例吗?')) return;
try {
const result = await api.post('/caddy/stop');
notification.toast(result.message || '停止命令已发送。', 'info');
setTimeout(checkCaddyStatus, 500);
} catch(error) { notification.toast(`操作失败: ${error.message}`, 'error'); }
}
async function handleReloadCaddy() {
if (!await notification.confirm('确定要重载 Caddy 配置吗?')) return;
try {
const result = await api.post('/caddy/restart');
notification.toast(result.message || '重载命令已发送。', 'success');
setTimeout(checkCaddyStatus, 500);
} catch(error) { notification.toast(`重载失败: ${error.message}`, 'error'); }
}
const caddyHandlers = { handleStartCaddy, handleStopCaddy, handleReloadCaddy };
async function handleLogout() {
if (!await notification.confirm('您确定要退出登录吗?')) return;
notification.toast('正在退出...', 'info');
setTimeout(() => { window.location.href = `/v0/api/auth/logout`; }, 500);
}
async function loadAllConfigs() {
try {
const filenames = await api.get('/config/filenames');
renderConfigList(filenames);
} catch(error) { if (error.message) notification.toast(`加载配置列表失败: ${error.message}`, 'error'); }
}
async function handleEditConfig(originalFilename) {
try {
const [config, rendered] = await Promise.all([
api.get(`/config/file/${originalFilename}`),
api.get('/config/files/rendered')
]);
state.isEditing = true;
switchView(DOMElements.configFormPanel);
DOMElements.formTitle.textContent = '编辑配置';
fillForm(config, originalFilename);
showRenderedConfig(rendered, originalFilename);
updateFormVisibility(config.tmpl_type, state.availableTemplates);
state.initialFormState = getFormStateAsString();
} catch(error) { notification.toast(`加载配置详情失败: ${error.message}`, 'error'); }
}
async function handleDeleteConfig(filename) {
if (!await notification.confirm(`确定要删除配置 "${filename}" 吗?`)) return;
try {
await api.delete(`/config/file/${filename}`);
notification.toast('配置已成功删除。', 'success');
loadAllConfigs();
} catch(error) { notification.toast(`删除失败: ${error.message}`, 'error'); }
}
async function handleSaveConfig(e) {
e.preventDefault();
const formData = new FormData(DOMElements.configForm);
const filename = formData.get('domain');
if (!filename) {
notification.toast('域名不能为空。', 'error');
return;
}
const getHeadersMap = (keyName, valueName) => {
const headers = {};
formData.getAll(keyName).forEach((key, i) => {
if (key) {
if (!headers[key]) headers[key] = [];
headers[key].push(formData.getAll(valueName)[i]);
}
});
return headers;
};
const globalHeaders = getHeadersMap('header_key', 'header_value');
const upstreamHeaders = getHeadersMap('upstream_header_key', 'upstream_header_value');
const configData = {
domain: filename,
tmpl_type: formData.get('tmpl_type'),
upstream: { upstream: formData.get('upstream'), muti_upstream: false, upstreams: [], upstream_headers: upstreamHeaders },
file_server: { file_dir_path: formData.get('file_dir_path'), enable_browser: DOMElements.configForm.querySelector('#enable_browser').checked },
headers: globalHeaders,
log: { enable_log: DOMElements.configForm.querySelector('#enable_log').checked, log_domain: filename },
error_page: { enable_error_page: DOMElements.configForm.querySelector('#enable_error_page').checked },
encode: { enable_encode: DOMElements.configForm.querySelector('#enable_encode').checked }
};
try {
const result = await api.put(`/config/file/${filename}`, configData);
state.isEditing = false;
notification.toast(result.message || '配置已成功保存。', 'success');
setTimeout(() => {
switchView(DOMElements.configListPanel);
loadAllConfigs();
}, 500);
} catch(error) { notification.toast(`保存失败: ${error.message}`, 'error'); }
}
function init() {
initTheme(DOMElements.themeToggleInput);
notification.init(DOMElements.toastContainer, DOMElements.dialogContainer);
loadAllConfigs();
api.get('/config/templates')
.then(templates => {
state.availableTemplates = templates || [];
const options = state.availableTemplates.length > 0 ? state.availableTemplates : ['无可用模板'];
createCustomSelect('custom-select-tmpl', options, (selectedValue) => {
updateFormVisibility(selectedValue, state.availableTemplates);
});
if (options[0] !== '无可用模板') updateFormVisibility(options[0], state.availableTemplates);
})
.catch(err => { if(err.message) notification.toast(`加载模板失败: ${err.message}`, 'error') });
checkCaddyStatus();
caddyStatusInterval = setInterval(checkCaddyStatus, POLLING_INTERVAL);
DOMElements.menuToggleBtn.addEventListener('click', () => DOMElements.sidebar.classList.toggle('is-open'));
DOMElements.mainContent.addEventListener('click', () => DOMElements.sidebar.classList.remove('is-open'));
DOMElements.addNewConfigBtn.addEventListener('click', () => {
state.isEditing = false;
switchView(DOMElements.configFormPanel);
DOMElements.formTitle.textContent = '创建新配置';
DOMElements.configForm.reset();
if (state.availableTemplates.length > 0) {
const selectContainer = document.getElementById('custom-select-tmpl');
selectContainer.querySelector('.select-selected').textContent = state.availableTemplates[0];
selectContainer.querySelector('input[type="hidden"]').value = state.availableTemplates[0];
updateFormVisibility(state.availableTemplates[0], state.availableTemplates);
}
state.initialFormState = getFormStateAsString();
DOMElements.headersContainer.innerHTML = '';
DOMElements.upstreamHeadersContainer.innerHTML = '';
DOMElements.originalFilenameInput.value = '';
});
DOMElements.backToListBtn.addEventListener('click', attemptExitForm);
DOMElements.cancelEditBtn.addEventListener('click', attemptExitForm);
DOMElements.addHeaderBtn.addEventListener('click', () => addKeyValueInput(DOMElements.headersContainer, 'header_key', 'header_value'));
DOMElements.addUpstreamHeaderBtn.addEventListener('click', () => addKeyValueInput(DOMElements.upstreamHeadersContainer, 'upstream_header_key', 'upstream_header_value'));
DOMElements.configForm.addEventListener('submit', handleSaveConfig);
DOMElements.logoutBtn.addEventListener('click', handleLogout);
DOMElements.configListContainer.addEventListener('click', e => {
const button = e.target.closest('button');
if (!button) return;
const filename = button.closest('.config-item').dataset.filename;
if (button.classList.contains('edit-btn')) handleEditConfig(filename);
if (button.classList.contains('delete-btn')) handleDeleteConfig(filename);
});
}
init();

150
frontend/js/init.js Normal file
View file

@ -0,0 +1,150 @@
// js/init.js
document.addEventListener('DOMContentLoaded', () => {
const DOMElements = {
initForm: document.getElementById('init-form'),
initButton: null,
toastContainer: document.getElementById('toast-container'),
usernameInput: document.getElementById('username'),
passwordInput: document.getElementById('password'),
confirmPasswordInput: document.getElementById('confirm_password'),
};
const INIT_API_URL = '/v0/api/auth/init';
const PASSWORD_MIN_LENGTH = 8;
const TOAST_DEFAULT_DURATION = 3000;
const REDIRECT_DELAY_SUCCESS = 1500;
const theme = {
init: () => {
const storedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const currentTheme = storedTheme || (systemPrefersDark ? 'dark' : 'light');
document.documentElement.dataset.theme = currentTheme;
}
};
const toast = {
_container: null,
_icons: { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle' },
init: (containerElement) => {
if (!containerElement) {
console.error('Toast container element not found.');
return;
}
toast._container = containerElement;
toast._container.addEventListener('click', (e) => {
if (e.target.dataset.toastClose !== undefined) {
toast._hideToast(e.target.closest('.toast'));
}
});
},
show: (message, type = 'info', duration = TOAST_DEFAULT_DURATION) => {
if (!toast._container) {
console.error('Toast module not initialized. Container is missing.');
return;
}
const iconClass = toast._icons[type] || 'fa-info-circle';
const toastElement = document.createElement('div');
toastElement.className = `toast ${type}`;
toastElement.innerHTML = `
<i class="toast-icon fa-solid ${iconClass}"></i>
<p class="toast-message">${message}</p>
<button class="toast-close" data-toast-close>×</button>
`;
toast._container.appendChild(toastElement);
requestAnimationFrame(() => toastElement.classList.add('show'));
setTimeout(() => toast._hideToast(toastElement), duration);
},
_hideToast: (toastElement) => {
if (!toastElement) return;
toastElement.classList.remove('show');
toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true });
}
};
async function handleInitSubmit(e) {
e.preventDefault();
// 1. 获取并修剪输入值
const username = DOMElements.usernameInput.value.trim(); // 获取用户名并去除前后空格
const password = DOMElements.passwordInput.value.trim();
const confirmPassword = DOMElements.confirmPasswordInput.value.trim();
// 2. 添加用户名输入框的空值验证
if (username === '') {
toast.show('管理员用户名不能为空', 'error');
DOMElements.usernameInput.focus();
return;
}
// 其他密码验证逻辑不变
if (password === '') {
toast.show('密码不能为空', 'error');
DOMElements.passwordInput.focus();
return;
}
if (confirmPassword === '') {
toast.show('确认密码不能为空', 'error');
DOMElements.confirmPasswordInput.focus();
return;
}
if (password !== confirmPassword) {
toast.show('两次输入的密码不匹配', 'error');
DOMElements.passwordInput.focus();
return;
}
if (password.length < PASSWORD_MIN_LENGTH) {
toast.show(`密码长度至少为 ${PASSWORD_MIN_LENGTH}`, 'error');
DOMElements.passwordInput.focus();
return;
}
DOMElements.initButton.disabled = true;
DOMElements.initButton.querySelector('span').textContent = '设置中...';
try {
const formData = new FormData(DOMElements.initForm);
formData.set('username', username); // 确保发送的是修剪过的用户名
formData.set('password', password); // 确保发送的是修剪过的密码
formData.delete('confirm_password');
const response = await fetch(INIT_API_URL, {
method: 'POST',
body: new URLSearchParams(formData)
});
const result = await response.json();
if (response.ok) {
toast.show('管理员账户创建成功!正在跳转到登录页面...', 'success');
setTimeout(() => { window.location.href = '/login.html'; }, REDIRECT_DELAY_SUCCESS);
} else {
throw new Error(result.error || `初始化失败: ${response.status}`);
}
} catch (error) {
toast.show(error.message, 'error');
DOMElements.initButton.disabled = false;
DOMElements.initButton.querySelector('span').textContent = '完成设置';
}
}
function initApp() {
theme.init();
if (DOMElements.initForm) {
DOMElements.initButton = DOMElements.initForm.querySelector('button[type="submit"]');
toast.init(DOMElements.toastContainer);
DOMElements.initForm.addEventListener('submit', handleInitSubmit);
} else {
console.error('Init form element not found. Script may not function correctly.');
}
}
initApp();
});

89
frontend/js/login.js Normal file
View file

@ -0,0 +1,89 @@
document.addEventListener('DOMContentLoaded', () => {
const DOMElements = {
loginForm: document.getElementById('login-form'),
toastContainer: document.getElementById('toast-container'),
};
const loginButton = DOMElements.loginForm.querySelector('button[type="submit"]');
const LOGIN_API_URL = '/v0/api/auth/login';
const theme = {
init: () => {
const storedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const currentTheme = storedTheme || (systemPrefersDark ? 'dark' : 'light');
document.documentElement.dataset.theme = currentTheme;
}
};
const toast = {
show: (message, type = 'info', duration = 3000) => {
const icons = { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle' };
const iconClass = icons[type] || 'fa-info-circle';
const toastElement = document.createElement('div');
toastElement.className = `toast ${type}`;
toastElement.innerHTML = `<i class="toast-icon fa-solid ${iconClass}"></i><p class="toast-message">${message}</p><button class="toast-close" data-toast-close>×</button>`;
DOMElements.toastContainer.appendChild(toastElement);
requestAnimationFrame(() => toastElement.classList.add('show'));
const timeoutId = setTimeout(() => hideToast(toastElement), duration);
toastElement.querySelector('[data-toast-close]').addEventListener('click', () => {
clearTimeout(timeoutId);
hideToast(toastElement);
});
}
};
function hideToast(toastElement) {
if (!toastElement) return;
toastElement.classList.remove('show');
toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true });
}
async function handleLogin(e) {
e.preventDefault();
// 获取并确认值
const username = DOMElements.loginForm.username.value.trim();
const password = DOMElements.loginForm.password.value.trim();
if (username === '') {
toast.show('用户名不能为空', 'error');
DOMElements.loginForm.username.focus();
return;
}
if (password === '') {
toast.show('密码不能为空', 'error');
DOMElements.loginForm.password.focus();
return;
}
loginButton.disabled = true;
loginButton.querySelector('span').textContent = '登录中...';
try {
const response = await fetch(LOGIN_API_URL, {
method: 'POST',
body: new URLSearchParams(new FormData(DOMElements.loginForm))
});
const result = await response.json();
if (response.ok) {
toast.show('登录成功,正在跳转...', 'success');
setTimeout(() => { window.location.href = '/'; }, 500);
} else {
throw new Error(result.error || `登录失败: ${response.status}`);
}
} catch (error) {
toast.show(error.message, 'error');
loginButton.disabled = false;
loginButton.querySelector('span').textContent = '登录';
}
}
function init() {
theme.init();
if (DOMElements.loginForm) {
DOMElements.loginForm.addEventListener('submit', handleLogin);
}
}
init();
});

View file

@ -0,0 +1,57 @@
// js/notifications.js - 提供Toast和Dialog两种通知
// 这个模块在初始化时需要知道容器的DOM元素
let toastContainer;
let dialogContainer;
function hideToast(toastElement) {
if (!toastElement) return;
toastElement.classList.remove('show');
toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true });
}
export const notification = {
init: (toastEl, dialogEl) => {
toastContainer = toastEl;
dialogContainer = dialogEl;
},
toast: (message, type = 'info', duration = 3000) => {
const icons = { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle', warning: 'fa-exclamation-triangle' };
const iconClass = icons[type] || 'fa-info-circle';
const toastElement = document.createElement('div');
toastElement.className = `toast ${type}`;
toastElement.innerHTML = `<i class="toast-icon fa-solid ${iconClass}"></i><p class="toast-message">${message}</p><button class="toast-close" data-toast-close>×</button>`;
toastContainer.appendChild(toastElement);
requestAnimationFrame(() => toastElement.classList.add('show'));
const timeoutId = setTimeout(() => hideToast(toastElement), duration);
toastElement.querySelector('[data-toast-close]').addEventListener('click', () => {
clearTimeout(timeoutId);
hideToast(toastElement);
});
},
confirm: (message) => {
return new Promise(resolve => {
const dialogHTML = `
<div class="dialog-box">
<p class="dialog-message">${message}</p>
<div class="dialog-actions">
<button class="btn btn-secondary" data-action="cancel">取消</button>
<button class="btn btn-primary" data-action="confirm">确定</button>
</div>
</div>`;
dialogContainer.innerHTML = dialogHTML;
dialogContainer.classList.add('active');
const eventHandler = (e) => {
const actionButton = e.target.closest('[data-action]');
if (!actionButton) return;
closeDialog(actionButton.dataset.action === 'confirm');
};
const closeDialog = (result) => {
dialogContainer.removeEventListener('click', eventHandler);
dialogContainer.classList.remove('active');
setTimeout(() => { dialogContainer.innerHTML = ''; resolve(result); }, 200);
};
dialogContainer.addEventListener('click', eventHandler);
});
}
};

7
frontend/js/state.js Normal file
View file

@ -0,0 +1,7 @@
// js/state.js - 管理应用的共享状态
export const state = {
isEditing: false,
initialFormState: '', // 用于检测表单是否有未保存的更改
availableTemplates: [], // 存储从后端获取的可用模板名称
};

26
frontend/js/theme.js Normal file
View file

@ -0,0 +1,26 @@
// js/theme.js - 处理深色/浅色主题切换
// 这个模块在初始化时需要知道切换按钮的DOM元素
let themeToggleInput;
function applyTheme(themeName) {
document.documentElement.dataset.theme = themeName;
if (themeToggleInput) {
themeToggleInput.checked = themeName === 'dark';
}
localStorage.setItem('theme', themeName);
}
function handleToggle(e) {
const newTheme = e.target.checked ? 'dark' : 'light';
applyTheme(newTheme);
}
export function initTheme(toggleElement) {
themeToggleInput = toggleElement;
const storedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const currentTheme = storedTheme || (systemPrefersDark ? 'dark' : 'light');
applyTheme(currentTheme);
themeToggleInput.addEventListener('change', handleToggle);
}

176
frontend/js/ui.js Normal file
View file

@ -0,0 +1,176 @@
// js/ui.js - 管理所有与UI渲染和DOM操作相关的函数
// 集中管理所有需要操作的DOM元素
export const DOMElements = {
sidebar: document.getElementById('sidebar'),
menuToggleBtn: document.getElementById('menu-toggle-btn'),
mainContent: document.querySelector('.main-content'),
configListPanel: document.getElementById('config-list-panel'),
configFormPanel: document.getElementById('config-form-panel'),
renderedOutputPanel: document.getElementById('rendered-output-panel'),
configForm: document.getElementById('config-form'),
formTitle: document.getElementById('form-title'),
backToListBtn: document.getElementById('back-to-list-btn'),
domainInput: document.getElementById('domain'),
originalFilenameInput: document.getElementById('original-filename'),
headersContainer: document.getElementById('headers-container'),
addNewConfigBtn: document.getElementById('add-new-config-btn'),
cancelEditBtn: document.getElementById('cancel-edit-btn'),
addHeaderBtn: document.getElementById('add-header-btn'),
configListContainer: document.getElementById('config-list'),
renderedContentCode: document.getElementById('rendered-content'),
toastContainer: document.getElementById('toast-container'),
dialogContainer: document.getElementById('dialog-container'),
themeToggleInput: document.getElementById('theme-toggle-input'),
caddyStatusIndicator: document.getElementById('caddy-status-indicator'),
caddyActionButtonContainer: document.getElementById('caddy-action-button-container'),
logoutBtn: document.getElementById('logout-btn'),
upstreamFieldset: document.getElementById('upstream-fieldset'),
fileserverFieldset: document.getElementById('fileserver-fieldset'),
upstreamHeadersContainer: document.getElementById('upstream-headers-container'),
addUpstreamHeaderBtn: document.getElementById('add-upstream-header-btn'),
};
export function switchView(viewToShow) {
[DOMElements.configListPanel, DOMElements.configFormPanel, DOMElements.renderedOutputPanel]
.forEach(view => view.classList.add('hidden'));
if (viewToShow) viewToShow.classList.remove('hidden');
DOMElements.addNewConfigBtn.disabled = (viewToShow === DOMElements.configFormPanel);
}
export function renderConfigList(filenames) {
DOMElements.configListContainer.innerHTML = '';
if (!filenames || filenames.length === 0) {
DOMElements.configListContainer.innerHTML = '<p>还没有任何配置,请创建一个。</p>';
return;
}
filenames.forEach(filename => {
const item = document.createElement('li');
item.className = 'config-item';
item.dataset.filename = filename;
item.innerHTML = `<span class="config-item-name">${filename}</span><div class="config-item-actions"><button class="btn-icon edit-btn" title="编辑"><i class="fa-solid fa-pen-to-square"></i></button><button class="btn-icon delete-btn" title="删除"><i class="fa-solid fa-trash-can"></i></button></div>`;
DOMElements.configListContainer.appendChild(item);
});
}
export function createCustomSelect(containerId, options, onSelect) {
const container = document.getElementById(containerId);
container.innerHTML = `<div class="select-selected"></div><div class="select-items"></div><input type="hidden" name="tmpl_type">`;
const selectedDiv = container.querySelector('.select-selected');
const itemsDiv = container.querySelector('.select-items');
const hiddenInput = container.querySelector('input[type="hidden"]');
itemsDiv.innerHTML = '';
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');
selectedDiv.classList.remove('select-arrow-active');
});
}
export function addKeyValueInput(container, keyName, valueName, key = '', value = '') {
const div = document.createElement('div');
div.className = 'header-entry';
div.innerHTML = `
<input type="text" name="${keyName}" placeholder="Key" value="${key}">
<input type="text" name="${valueName}" placeholder="Value" value="${value}">
<button type="button" class="btn-icon" onclick="this.parentElement.remove()" title="移除此条目">
<i class="fa-solid fa-xmark"></i>
</button>`;
container.appendChild(div);
}
export function fillForm(config, originalFilename) {
DOMElements.originalFilenameInput.value = originalFilename;
DOMElements.domainInput.value = config.domain;
document.getElementById('upstream').value = config.upstream?.upstream || '';
document.getElementById('file_dir_path').value = config.file_server?.file_dir_path || '';
document.getElementById('enable_browser').checked = config.file_server?.enable_browser || false;
const selectContainer = document.getElementById('custom-select-tmpl');
selectContainer.querySelector('.select-selected').textContent = config.tmpl_type;
selectContainer.querySelector('input[type="hidden"]').value = config.tmpl_type;
DOMElements.headersContainer.innerHTML = '';
if (config.headers) Object.entries(config.headers).forEach(([k, v]) => v.forEach(val => addKeyValueInput(DOMElements.headersContainer, 'header_key', 'header_value', k, val)));
DOMElements.upstreamHeadersContainer.innerHTML = '';
if (config.upstream?.upstream_headers) Object.entries(config.upstream.upstream_headers).forEach(([k, v]) => v.forEach(val => addKeyValueInput(DOMElements.upstreamHeadersContainer, 'upstream_header_key', 'upstream_header_value', k, val)));
document.getElementById('enable_log').checked = config.log?.enable_log || false;
document.getElementById('enable_error_page').checked = config.error_page?.enable_error_page || false;
document.getElementById('enable_encode').checked = config.encode?.enable_encode || false;
}
export function showRenderedConfig(configs, filename) {
const targetConfig = configs.find(c => c.filename === filename);
if (targetConfig && targetConfig.rendered_content) {
DOMElements.renderedContentCode.textContent = atob(targetConfig.rendered_content);
DOMElements.renderedOutputPanel.classList.remove('hidden');
} else {
DOMElements.renderedOutputPanel.classList.add('hidden');
}
}
function createButton(text, className, onClick) {
const button = document.createElement('button');
button.className = `btn ${className}`;
button.innerHTML = `<span>${text}</span>`;
button.addEventListener('click', onClick);
return button;
}
export function updateCaddyStatusView(status, handlers) {
const { handleReloadCaddy, handleStopCaddy, handleStartCaddy } = handlers;
const dot = DOMElements.caddyStatusIndicator.querySelector('.status-dot');
const text = DOMElements.caddyStatusIndicator.querySelector('.status-text');
const buttonContainer = DOMElements.caddyActionButtonContainer;
dot.className = 'status-dot';
buttonContainer.innerHTML = '';
let statusText, dotClass;
switch (status) {
case 'running':
statusText = '运行中'; dotClass = 'running';
buttonContainer.appendChild(createButton('重载配置', 'btn-warning', handleReloadCaddy));
buttonContainer.appendChild(createButton('停止 Caddy', 'btn-danger', handleStopCaddy));
break;
case 'stopped':
statusText = '已停止'; dotClass = 'stopped';
buttonContainer.appendChild(createButton('启动 Caddy', 'btn-success', handleStartCaddy));
break;
case 'checking': statusText = '检查中...'; dotClass = 'checking'; break;
default: statusText = '状态未知'; dotClass = 'error'; break;
}
text.textContent = statusText;
dot.classList.add(dotClass);
}
export function updateFormVisibility(selectedTemplate, availableTemplates) {
const showUpstream = selectedTemplate === 'reverse_proxy' && availableTemplates.includes('reverse_proxy');
const showFileServer = selectedTemplate === 'file_server' && availableTemplates.includes('file_server');
DOMElements.upstreamFieldset.classList.toggle('hidden', !showUpstream);
DOMElements.fileserverFieldset.classList.toggle('hidden', !showFileServer);
}

44
frontend/login.html Normal file
View file

@ -0,0 +1,44 @@
<!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 class="login-page-body">
<div class="login-container">
<header class="login-header">
<i class="fa-solid fa-rocket"></i>
<h1>CaddyDash</h1>
<p>请输入您的凭证以继续</p>
</header>
<form id="login-form">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" autocomplete="username">
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary btn-login">
<span>登录</span>
<i class="fa-solid fa-arrow-right"></i>
</button>
</form>
</div>
<!-- 右上角消息通知容器 -->
<div id="toast-container" class="toast-container"></div>
<script src="js/login.js"></script>
</body>
</html>

152
gen/init.go Normal file
View file

@ -0,0 +1,152 @@
package gen
import (
"caddydash/config"
"caddydash/db"
"fmt"
"os"
"path/filepath"
"time"
)
// 把指定目录下的文件作为模板读入
func ReadTmplToDB(dir string, cdb *db.ConfigDB) error {
// 遍历目录下的所有文件
files, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, file := range files {
if file.IsDir() {
continue
}
// 读取文件内容
content, err := os.ReadFile(filepath.Join(dir, file.Name()))
if err != nil {
return err
}
tmplEntry := db.TemplateEntry{
Filename: file.Name(),
TemplateType: file.Name(),
Content: content,
UpdatedAt: time.Now().Unix(),
}
// 存储到数据库
err = cdb.SaveTemplate(tmplEntry)
if err != nil {
return err
}
// 输出tmpl名
fmt.Printf("Read template: %s\n", file.Name())
}
return nil
}
func Add80SiteConfig(cfg *config.Config, cdb *db.ConfigDB) error {
// 检查:80是否已存在于数据库中
_, err := cdb.GetParams(":80")
if err == nil {
// 如果存在,则不添加
return nil
}
siteConfig := CaddyUniConfig{
DomainConfig: CaddyUniDomainConfig{
Domain: ":80",
MutiDomains: false,
Domains: []string{":80"},
},
Mode: "uni",
FileServer: CaddyUniFileServerConfig{
EnableFileServer: true,
FileDirPath: cfg.Server.CaddyDir + "pages/demo",
EnableBrowser: false,
},
Log: CaddyUniLogConfig{
EnableLog: true,
LogDomain: ":80",
},
ErrorPage: CaddyUniErrorPageConfig{
EnableErrorPage: true,
},
Encode: CaddyUniEncodeConfig{
EnableEncode: true,
},
}
// 制作db.ParamsEntry
gobData, err := EncodeGobConfig(siteConfig)
if err != nil {
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)
if err != nil {
return err
}
paramsEntry := db.ParamsEntry{
Filename: ":80",
TemplateType: "file_server",
ParamsGOB: gobData,
ParamsOrigin: originGobData,
CreatedAt: time.Now().Unix(),
UpdatedAt: time.Now().Unix(),
}
filename := paramsEntry.Filename
// 保存到数据库
err = cdb.SaveParams(paramsEntry)
if err != nil {
return err
}
// 渲染配置
err = RenderConfig(filename, cdb)
if err != nil {
err = fmt.Errorf("render config error: %w", err)
return err
}
// 写入文件
renderedEntry, err := cdb.GetRenderedConfig(filename)
if err != nil {
err = fmt.Errorf("get rendered config error: %w", err)
return err
}
err = os.WriteFile(cfg.Server.CaddyDir+"config.d/"+filename, renderedEntry.RenderedContent, 0644)
if err != nil {
err = fmt.Errorf("write rendered config file error: %w", err)
return err
}
return nil
}

150
gen/render.go Normal file
View file

@ -0,0 +1,150 @@
package gen
import (
"bytes"
"caddydash/db"
"encoding/gob"
"fmt"
"text/template"
)
func RenderConfig(site string, cdb *db.ConfigDB) error {
// 检索site config
paramsEntry, err := cdb.GetParams(site)
if err != nil {
return err
}
var caddycfg CaddyUniConfig
err = DecodeGobConfig(paramsEntry.ParamsGOB, &caddycfg)
if err != nil {
return err
}
// 读取模板
//tmplEntry, err := cdb.GetTemplate("reverse_proxy")
tmplEntry, err := cdb.GetTemplate(caddycfg.Mode)
if err != nil {
return err
}
rpTmpl := string(tmplEntry.Content)
// 使用caddycfg渲染最终产物
parsedTmpl, parseErr := template.New(tmplEntry.Filename).Parse(rpTmpl)
if parseErr != nil {
return fmt.Errorf("db: failed to parse template content for '%s': %w", tmplEntry.Filename, parseErr)
}
var renderedContentBuilder bytes.Buffer
if err := parsedTmpl.Execute(&renderedContentBuilder, caddycfg); err != nil {
return fmt.Errorf("db: failed to render template '%s': %w", tmplEntry.Filename, err)
}
// 保存渲染结果
renderedEntry := db.RenderedConfigEntry{
Filename: caddycfg.DomainConfig.Domain, // 使用域名作为文件名
RenderedContent: renderedContentBuilder.Bytes(),
}
// 保存渲染产物
if err := cdb.SaveRenderedConfig(renderedEntry); err != nil {
return fmt.Errorf("db: failed to save rendered config for '%s': %w", caddycfg.DomainConfig.Domain, err)
}
return nil
}
// 把caddycfg内容转为GOB
func EncodeGobConfig(caddycfg any) ([]byte, error) {
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
if err := enc.Encode(caddycfg); err != nil {
return nil, fmt.Errorf("db: failed to Encode struct to GOB: %w", err)
}
return buf.Bytes(), nil // 返回编码后的字节切片.
}
func DecodeGobConfig(data []byte, tmplStruct any) error {
buf := bytes.NewBuffer(data)
dec := gob.NewDecoder(buf)
if err := dec.Decode(tmplStruct); err != nil {
return fmt.Errorf("db: failed to Decode GOB to struct: %w", err)
}
return nil
}
/*
func RenderConfig(site string, cdb *db.ConfigDB) error {
// 检索site config
paramsEntry, err := cdb.GetParams(site)
if err != nil {
return err
}
var caddycfg any
var specificConfigType interface{}
if paramsEntry.TemplateType == "reverse_proxy" {
specificConfigType = &CaddyReverseProxyConfig{}
} else if paramsEntry.TemplateType == "file_server" {
specificConfigType = &CaddyFileServerConfig{}
} else {
return fmt.Errorf("unknown template type: %s", paramsEntry.TemplateType)
}
err = DecodeGobConfig(paramsEntry.ParamsGOB, specificConfigType)
if err != nil {
log.Printf("decode gob config error: %v", err)
return err
}
caddycfg = specificConfigType
// 读取模板
//tmplEntry, err := cdb.GetTemplate("reverse_proxy")
tmplEntry, err := cdb.GetTemplate(paramsEntry.TemplateType)
if err != nil {
log.Printf("get template error: %v", err)
tmplList, err := cdb.RangeTemplates()
if err != nil {
return err
}
log.Printf("template list: %v, GetName %s", tmplList, paramsEntry.TemplateType)
return err
}
rpTmpl := string(tmplEntry.Content)
// 使用caddycfg渲染最终产物
parsedTmpl, parseErr := template.New(tmplEntry.Filename).Parse(rpTmpl)
if parseErr != nil {
return fmt.Errorf("db: failed to parse template content for '%s': %w", tmplEntry.Filename, parseErr)
}
var renderedContentBuilder bytes.Buffer
if err := parsedTmpl.Execute(&renderedContentBuilder, caddycfg); err != nil {
return fmt.Errorf("db: failed to render template '%s': %w", tmplEntry.Filename, err)
}
// 类型断言获得domain
var domain string
switch cfg := caddycfg.(type) {
case *CaddyReverseProxyConfig:
domain = cfg.Domain
case *CaddyFileServerConfig:
domain = cfg.Domain
default:
return fmt.Errorf("unknown config type for domain extraction")
}
// 保存渲染结果
renderedEntry := db.RenderedConfigEntry{
Filename: domain, // 使用域名作为文件名
RenderedContent: renderedContentBuilder.Bytes(),
}
// 保存渲染产物
if err := cdb.SaveRenderedConfig(renderedEntry); err != nil {
return fmt.Errorf("db: failed to save rendered config for '%s': %w", domain, err)
}
return nil
}
*/

79
gen/struct.go Normal file
View file

@ -0,0 +1,79 @@
package gen
/*
type CaddyReverseProxyConfig struct {
Domain string // 域名; 例如 example.com
ReverseProxy string // 反向代理目标; 例如 127.0.0.1:8080 (这里简化为单个目标)
Headers []string // 自定义响应Header
HeadersUp []string // 自定义请求头列表; 例如 ["XXX0 XX", "XXX1 XXX"]
EnableLog bool // 是否导入 log 指令
LogDomain string // log 指令的域名参数
EnableErrorPage bool // 是否导入 error_page 指令
EnableEncode bool // 是否导入 encode 指令
}
type CaddyFileServerConfig struct {
Domain string // 域名; 例如 example.com
FileDirPath string // 文件目录
EnableBrowser bool // 是否导入 browse 指令
Headers []string //
EnableLog bool // 是否导入 log 指令
LogDomain string // log 指令的域名参数
EnableErrorPage bool // 是否导入 error_page 指令
EnableEncode bool // 是否导入 encode 指令
}
*/
func HeadersMapToHeadersUp(headers map[string][]string) []string {
var headersUp []string
for key, values := range headers {
for _, value := range values {
headersUp = append(headersUp, key+" "+value)
}
}
return headersUp
}
type CaddyUniConfig struct {
DomainConfig CaddyUniDomainConfig `json:"domain_config"`
Mode string `json:"mode"`
Upstream CaddyUniUpstreamConfig `json:"upstream_config"`
FileServer CaddyUniFileServerConfig `json:"file_server_config"`
Headers map[string][]string `json:"headers"`
Log CaddyUniLogConfig `json:"log_config"`
ErrorPage CaddyUniErrorPageConfig `json:"error_page_config"`
Encode CaddyUniEncodeConfig `json:"encode_config"`
}
type CaddyUniDomainConfig struct {
Domain string `json:"domain"`
MutiDomains bool `json:"muti_domains"`
Domains []string `json:"domains"`
}
type CaddyUniUpstreamConfig struct {
EnableUpStream bool `json:"enable_upstream"`
UpStream string `json:"upstream"`
MutiUpStreams bool `json:"muti_upstream"`
UpStreams []string `json:"upstream_servers"`
UpStreamHeaders map[string][]string `json:"upstream_headers"`
}
type CaddyUniFileServerConfig struct {
EnableFileServer bool `json:"enable_file_server"`
FileDirPath string `json:"file_dir_path"`
EnableBrowser bool `json:"enable_browser"`
}
type CaddyUniLogConfig struct {
EnableLog bool `json:"enable_log"`
LogDomain string `json:"log_domain"`
}
type CaddyUniErrorPageConfig struct {
EnableErrorPage bool `json:"enable_error_page"`
}
type CaddyUniEncodeConfig struct {
EnableEncode bool `json:"enable_encode"`
}

35
go.mod Normal file
View file

@ -0,0 +1,35 @@
module caddydash
go 1.24.4
require (
github.com/BurntSushi/toml v1.5.0
github.com/fenthope/record v0.0.3
github.com/fenthope/sessions v0.0.1
github.com/infinite-iroha/touka v0.2.2
golang.org/x/crypto v0.37.0
modernc.org/sqlite v1.38.0
)
require (
github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.4 // indirect
github.com/WJQSERVER-STUDIO/httpc v0.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fenthope/reco v0.0.3 // indirect
github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
golang.org/x/sys v0.33.0 // indirect
modernc.org/libc v1.65.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
replace github.com/infinite-iroha/touka => /data/github/WJQSERVER/touka

73
go.sum Normal file
View file

@ -0,0 +1,73 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.4 h1:JLtFd00AdFg/TP+dtvIzLkdHwKUGPOAijN1sMtEYoFg=
github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.4/go.mod h1:FZ6XE+4TKy4MOfX1xWKe6Rwsg0ucYFCdNh1KLvyKTfc=
github.com/WJQSERVER-STUDIO/httpc v0.7.0 h1:iHhqlxppJBjlmvsIjvLZKRbWXqSdbeSGGofjHGmqGJc=
github.com/WJQSERVER-STUDIO/httpc v0.7.0/go.mod h1:M7KNUZjjhCkzzcg9lBPs9YfkImI+7vqjAyjdA19+joE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fenthope/reco v0.0.3 h1:RmnQ0D9a8PWtwOODawitTe4BztTnS9wYwrDbipISNq4=
github.com/fenthope/reco v0.0.3/go.mod h1:mDkGLHte5udWTIcjQTxrABRcf56SSdxBOCLgrRDwI/Y=
github.com/fenthope/record v0.0.3 h1:v5urgs5LAkLMlljAT/MjW8fWuRHXPnAraTem5ui7rm4=
github.com/fenthope/record v0.0.3/go.mod h1:KFEkSc4TDZ3QIhP/wglD32uYVA6X1OUcripiao1DEE4=
github.com/fenthope/sessions v0.0.1 h1:Dw4mY2yvSuyTqW+1CrojdO0gzv2gfsNsavRZcaAl7LM=
github.com/fenthope/sessions v0.0.1/go.mod h1:4aI2BN1jb8MF1qTzXb4QX+GnAzmJWb57S3lDKxUyK8A=
github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8 h1:o8UqXPI6SVwQt04RGsqKp3qqmbOfTNMqDrWsc4O47kk=
github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

145
main.go Normal file
View file

@ -0,0 +1,145 @@
package main
import (
"caddydash/api"
"caddydash/apic"
"caddydash/config"
"caddydash/db"
"caddydash/gen"
"caddydash/user"
"crypto/rand"
"encoding/gob"
"flag"
"fmt"
"net/http"
"os"
"time"
"github.com/fenthope/record"
"github.com/fenthope/sessions"
"github.com/fenthope/sessions/cookie"
"github.com/infinite-iroha/touka"
_ "modernc.org/sqlite"
)
var (
cfg *config.Config
cfgfile string
cdb *db.ConfigDB
sessionKey []byte
)
func init() {
parseFlags()
loadConfig()
loadDatabase(cfg.DB.Filepath)
loadtmpltoDB(cfg.Tmpl.Path, cdb)
loadAdminStatus(cdb)
initSessionKey()
}
func parseFlags() {
//posix
flag.StringVar(&cfgfile, "c", "./config.toml", "Path to the configuration file")
flag.Parse()
}
func loadConfig() {
var err error
cfg, err = config.LoadConfig(cfgfile)
if err != nil {
fmt.Printf("Failed to load config: %v\n", err)
// 如果配置文件加载失败,也显示帮助信息并退出
flag.Usage()
os.Exit(1)
}
if cfg != nil && cfg.Server.Debug { // 确保 cfg 不为 nil
fmt.Println("Config File Path: ", cfgfile)
fmt.Printf("Loaded config: %v\n", cfg)
}
fmt.Printf("Loaded config: %v\n", cfg)
}
func loadDatabase(filepath string) {
var err error
cdb, err = db.InitDB(filepath)
if err != nil {
fmt.Printf("Failed to initialize database: %v\n", err)
os.Exit(1)
}
}
func loadAdminStatus(cdb *db.ConfigDB) {
err := user.InitAdminUserStatus(cdb)
if err != nil {
fmt.Printf("Failed to initialize admin user status: %v\n", err)
os.Exit(1)
}
}
func loadtmpltoDB(path string, cdb *db.ConfigDB) {
err := gen.ReadTmplToDB(path, cdb)
if err != nil {
fmt.Printf("Failed to load templates: %v\n", err)
os.Exit(1)
}
err = gen.Add80SiteConfig(cfg, cdb)
if err != nil {
fmt.Printf("Failed to add :80 site config: %v\n", err)
os.Exit(1)
}
}
func initSessionKey() {
// crypto 生成随机
sessionKey = make([]byte, 32)
_, err := rand.Read(sessionKey)
if err != nil {
fmt.Printf("Failed to generate session key: %v\n", err)
os.Exit(1)
}
}
func main() {
defer cdb.CloseDB()
r := touka.Default()
r.Use(record.Middleware())
store := cookie.NewStore(sessionKey)
store.Options(sessions.Options{
Path: "/",
MaxAge: 10800, // 3 hours
HttpOnly: true,
})
r.Use(sessions.Sessions("mysession", store))
// 应用 session 中间件
r.Use(api.SessionMiddleware(cdb))
v0 := r.Group("/v0")
api.ApiGroup(v0, cdb, cfg)
gob.Register(map[string]interface{}{})
gob.Register(time.Time{})
gob.Register(gen.CaddyUniConfig{})
frontendFS := os.DirFS("frontend")
r.SetUnMatchFS(http.FS(frontendFS))
// 打印定义的路由
fmt.Println("Registered Routes:")
for _, info := range r.GetRouterInfo() {
fmt.Printf(" Method: %-7s Path: %-25s Handler: %-40s Group: %s\n", info.Method, info.Path, info.Handler, info.Group)
}
go func() {
err := apic.RunCaddy(cfg)
if err != nil {
fmt.Printf("Failed to start caddy: %v\n", err)
os.Exit(1)
}
}()
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
r.Run(addr)
}

16
tmpl/file_server Normal file
View file

@ -0,0 +1,16 @@
{{.Domain}} {
root * {{.FileDirPath}}
file_server{{if .EnableBrowser}} browse{{end}}
{{- range .Headers}}
header {{.}}
{{- end}}
{{- if .EnableLog}}
import log {{.LogDomain}}
{{- end}}
{{- if .EnableErrorPage}}
import error_page
{{- end}}
{{- if .EnableEncode}}
import encode
{{- end}}
}

20
tmpl/reverse_proxy Normal file
View file

@ -0,0 +1,20 @@
{{.Domain}} {
reverse_proxy {
to {{.ReverseProxy}}
{{- range .HeadersUp}}
header_up {{.}}
{{- end}}
}
{{- range .HeadersUp}}
header {{.}}
{{- end}}
{{- if .EnableLog}}
import log {{.LogDomain}}
{{- end}}
{{- if .EnableErrorPage}}
import error_page
{{- end}}
{{- if .EnableEncode}}
import encode
{{- end}}
}

40
tmpl/uni Normal file
View file

@ -0,0 +1,40 @@
{{- if .DomainConfig.MutiDomains -}}
{{- range .DomainConfig.Domains}}{{.}} {{end -}}
{{- else -}}
{{- .DomainConfig.Domain -}}
{{- end -}} {
{{- if .Upsteam.EnableUpStream}}
reverse_proxy {
to{{if .Upsteam.MutiUpStreams}}{{range .Upsteam.UpStreams}} {{.}}{{end}}{{else}} {{.Upsteam.UpStream}}{{end}}
{{- range $key, $values := .Upsteam.UpStreamHeaders}}
{{- range $values}}
header_up {{$key}} "{{.}}"
{{- end}}
{{- end}}
}
{{- else if .FileServer.EnableFileServer}}
root * {{.FileServer.FileDirPath}}
file_server{{if .FileServer.EnableBrowser}} browse{{end}}
{{- end}}
{{- range $key, $values := .Headers}}
{{- range $values}}
header {{$key}} "{{.}}"
{{- end}}
{{- end}}
{{- if .Log.EnableLog}}
import log {{.Log.LogDomain}}
{{- end}}
{{- if .ErrorPage.EnableErrorPage}}
import error_page
{{- end}}
{{- if .Encode.EnableEncode}}
import encode
{{- end}}
}

42
user/check.go Normal file
View file

@ -0,0 +1,42 @@
package user
import (
"caddydash/db"
"golang.org/x/crypto/bcrypt"
)
// 判断是否可以登陆
func CheckLogin(username, password string, cdb *db.ConfigDB) (bool, error) {
// 判断数据库内是否存在username
userExist, err := cdb.IsUserExists(username)
if err != nil {
return false, err
}
if !userExist {
return false, nil
}
passwordb, err := cdb.GetPasswordByUsername(username)
if err != nil {
return false, err
}
// 校验密码
check, err := checkPasswordHash(password, passwordb)
if err != nil {
return false, err
}
return check, nil
}
func IsAdminInit() bool {
return userStatus.IsUserInitialized()
}
// 校验密码, 避免时序攻击问题
func checkPasswordHash(password, hash string) (bool, error) {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
if err != nil {
return false, err
}
return true, nil
}

52
user/init.go Normal file
View file

@ -0,0 +1,52 @@
package user
import (
"caddydash/db"
"fmt"
"golang.org/x/crypto/bcrypt"
)
func InitAdminUser(username string, password string, cdb *db.ConfigDB) error {
hasUser, err := cdb.HasAnyUser()
if err != nil {
return fmt.Errorf("failed to check if any user exists: %w", err)
}
if hasUser {
userStatus.SetInitialized(true)
return nil
}
hashedPassword, err := hashPassword(password)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
err = cdb.AddUser(username, hashedPassword)
if err != nil {
return fmt.Errorf("failed to add admin user: %w", err)
}
userStatus.SetInitialized(true)
return nil
}
// bcrypt加密password串
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 11)
if err != nil {
return "", err
}
return string(bytes), err
}
func InitAdminUserStatus(cdb *db.ConfigDB) error {
hasUser, err := cdb.HasAnyUser()
if err != nil {
return fmt.Errorf("failed to check if any user exists: %w", err)
}
if hasUser {
userStatus.SetInitialized(true)
return nil
} else {
userStatus.SetInitialized(false)
return nil
}
}

22
user/status.go Normal file
View file

@ -0,0 +1,22 @@
package user
import "sync"
type UserStatus struct {
IsInitialized bool
mu sync.Mutex
}
func (s *UserStatus) SetInitialized(initialized bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.IsInitialized = initialized
}
func (s *UserStatus) IsUserInitialized() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.IsInitialized
}
var userStatus UserStatus