添加自定义404页面
CI / verify (push) Canceled after 0s

This commit is contained in:
2026-10-17 20:22:27 +08:00
parent 6b8ff0e5a3
commit 211d8e66e0
16 changed files with 359 additions and 10 deletions
+80
View File
@@ -0,0 +1,80 @@
package agent
import (
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
)
// frps 的 custom404Page 只认 VPS 上的文件路径,所以「自定义 404 页面内容」必须先把内容
// 落到 VPS 上。这里给 Havline 一个受控的写文件入口:只写绝对路径、只写 .html,避免变成
// 任意文件写入。
type frpsPageRequest struct {
Path string `json:"path"`
Content string `json:"content"`
}
func (s *Server) putFRPS404Page(w http.ResponseWriter, r *http.Request) {
var req frpsPageRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
path, err := validatePagePath(req.Path)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if strings.TrimSpace(req.Content) == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "页面内容为空"})
return
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
s.mu.Lock()
defer s.mu.Unlock()
if err := atomicWrite(path, []byte(req.Content), 0o644); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": path, "bytes": len(req.Content)})
}
func (s *Server) deleteFRPS404Page(w http.ResponseWriter, r *http.Request) {
path, err := validatePagePath(r.URL.Query().Get("path"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
s.mu.Lock()
defer s.mu.Unlock()
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// validatePagePath 只接受绝对路径 + .html/.htm 结尾,防止这个接口被当成任意文件写入。
func validatePagePath(raw string) (string, error) {
path := strings.TrimSpace(raw)
// agent 只跑在 Linux 上,这里按 POSIX 绝对路径判断(用 filepath.IsAbs 会在别的平台判错)
if path == "" || !strings.HasPrefix(path, "/") {
return "", errors.New("页面路径必须是绝对路径")
}
if strings.Contains(path, "..") {
return "", errors.New("页面路径不允许包含 ..")
}
switch strings.ToLower(filepath.Ext(path)) {
case ".html", ".htm":
return filepath.Clean(path), nil
default:
return "", errors.New("页面文件必须以 .html 或 .htm 结尾")
}
}
+19
View File
@@ -0,0 +1,19 @@
package agent
import "testing"
// 404 页面写入接口只接受绝对路径 + .html/.htm,避免被当成任意文件写入。
func TestValidatePagePath(t *testing.T) {
ok := []string{"/etc/frp/havline-404.html", "/var/www/404.htm"}
for _, path := range ok {
if _, err := validatePagePath(path); err != nil {
t.Errorf("%s 应当通过:%v", path, err)
}
}
bad := []string{"", "etc/frp/404.html", "/etc/frp/404.txt", "/etc/../etc/passwd.html"}
for _, path := range bad {
if _, err := validatePagePath(path); err == nil {
t.Errorf("%s 应当被拒绝", path)
}
}
}
+3 -1
View File
@@ -31,7 +31,7 @@ import (
"github.com/havline/havline/internal/sysinfo"
)
const AgentVersion = "0.15.4"
const AgentVersion = "0.15.5"
// Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct {
@@ -218,6 +218,8 @@ func agentRoutes(s *Server) []agentRoute {
{http.MethodPut, "/api/v1/frps/config", s.putFRPSConfig},
{http.MethodPost, "/api/v1/frps/install", s.installFRPS},
{http.MethodPost, "/api/v1/frps/action", s.frpsAction},
{http.MethodPut, "/api/v1/frps/404-page", s.putFRPS404Page},
{http.MethodDelete, "/api/v1/frps/404-page", s.deleteFRPS404Page},
{http.MethodPost, "/api/v1/certs", s.putCertificate},
{http.MethodGet, "/api/v1/metrics", s.metrics},
{http.MethodGet, "/api/v1/logs/nginx", s.nginxLogs},