feat: create standalone native fnOS repository

This commit is contained in:
2026-10-02 15:19:09 +08:00
parent 69f3c30fab
commit 6725fc131d
492 changed files with 94023 additions and 3 deletions
+208
View File
@@ -0,0 +1,208 @@
package api
import (
"encoding/json"
"errors"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/notify"
)
type AuthHandler struct {
auth *auth.Service
notify *notify.Service
guard *loginGuard
}
func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service) *AuthHandler {
return &AuthHandler{auth: authSvc, notify: notifySvc, guard: newLoginGuard()}
}
const (
loginFailureLimit = 5
loginLockoutWindow = 5 * time.Minute
)
// loginGuard 是进程内的登录失败退避:同一 IP 在窗口内失败到阈值后直接拒绝,
// 避免明文 HTTP 部署下被无限爆破;与用于通知的失败统计相互独立。
type loginGuard struct {
mu sync.Mutex
failures map[string][]time.Time
}
func newLoginGuard() *loginGuard {
return &loginGuard{failures: make(map[string][]time.Time)}
}
// blocked 返回该 IP 是否仍在锁定窗口内(顺带清理过期记录)。
func (g *loginGuard) blocked(ip string) bool {
g.mu.Lock()
defer g.mu.Unlock()
kept := g.recentLocked(ip)
g.failures[ip] = kept
return len(kept) >= loginFailureLimit
}
func (g *loginGuard) fail(ip string) {
g.mu.Lock()
defer g.mu.Unlock()
if len(g.failures) > 1024 {
for key := range g.failures {
if len(g.recentLocked(key)) == 0 {
delete(g.failures, key)
}
}
}
g.failures[ip] = append(g.recentLocked(ip), time.Now())
}
func (g *loginGuard) reset(ip string) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.failures, ip)
}
// recentLocked 返回窗口内的失败时间(调用方需持有锁)。
func (g *loginGuard) recentLocked(ip string) []time.Time {
now := time.Now()
kept := make([]time.Time, 0, len(g.failures[ip]))
for _, at := range g.failures[ip] {
if now.Sub(at) < loginLockoutWindow {
kept = append(kept, at)
}
}
return kept
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type authStatusResponse struct {
Initialized bool `json:"initialized"`
Authenticated bool `json:"authenticated"`
}
func (h *AuthHandler) Status(w http.ResponseWriter, r *http.Request) {
initialized, err := h.auth.IsInitialized(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取初始化状态失败")
return
}
authenticated := false
if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
if err := h.auth.ValidateSession(r.Context(), sessionID); err == nil {
authenticated = true
}
}
writeJSON(w, http.StatusOK, authStatusResponse{
Initialized: initialized,
Authenticated: authenticated,
})
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
ip := clientIP(r)
if h.guard.blocked(ip) {
writeError(r, w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
return
}
sessionID, err := h.auth.Login(r.Context(), req.Username, req.Password)
if err != nil {
if errors.Is(err, auth.ErrUnauthorized) {
h.guard.fail(ip)
if h.notify != nil {
h.notify.RecordLoginFailure(r.Context(), ip)
}
writeError(r, w, http.StatusUnauthorized, "用户名或密码错误")
return
}
writeError(r, w, http.StatusInternalServerError, "登录失败")
return
}
h.guard.reset(ip)
setSessionCookie(w, r, sessionID)
writeJSON(w, http.StatusOK, map[string]string{"message": "登录成功"})
}
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
var req struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
sessionID := sessionIDFromContext(r.Context())
adminID, err := h.auth.AdminIDForSession(r.Context(), sessionID)
if err != nil {
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
return
}
if err := h.auth.ChangePassword(r.Context(), adminID, req.OldPassword, req.NewPassword, sessionID); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "密码已更新"})
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
_ = h.auth.Logout(r.Context(), sessionID)
}
clearSessionCookie(w, r)
writeJSON(w, http.StatusOK, map[string]string{"message": "已退出登录"})
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, sessionID string) {
http.SetCookie(w, &http.Cookie{
Name: "havline_session",
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: requestIsHTTPS(r),
SameSite: http.SameSiteLaxMode,
MaxAge: int((7 * 24 * time.Hour).Seconds()),
})
}
// requestIsHTTPS:直连 TLS 或经反代(X-Forwarded-Proto)时给会话 cookie 加 Secure;
// 默认明文 HTTP 部署下不加,否则浏览器会直接丢弃 cookie 导致登录不了。
func requestIsHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return strings.TrimSpace(r.RemoteAddr)
}
return host
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "havline_session",
Value: "",
Path: "/",
HttpOnly: true,
Secure: requestIsHTTPS(r),
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/backup"
"github.com/havline/havline/internal/certificate"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/discovery"
"github.com/havline/havline/internal/proxy"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/settings"
)
func TestAuthSetupAndLoginFlow(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
tmpDir := t.TempDir()
authSvc := auth.New(conn)
proxySvc := service.NewProxyService(config.Config{DataDir: tmpDir, NginxPIDFile: tmpDir + "/nginx.pid"}, conn, proxy.NewStore(conn), certificate.NewStore(conn), settings.NewStore(conn), nil)
handler := NewRouter(Deps{
Config: config.Config{DataDir: tmpDir, NginxPIDFile: tmpDir + "/nginx.pid"},
Auth: authSvc,
Proxy: proxySvc,
Backup: backup.New(tmpDir),
Discovery: discovery.New(),
StartedAt: time.Now().UTC().Format(time.RFC3339),
})
statusReq := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)
statusRec := httptest.NewRecorder()
handler.ServeHTTP(statusRec, statusReq)
if statusRec.Code != http.StatusOK {
t.Fatalf("status code: %d", statusRec.Code)
}
if err := authSvc.Setup(context.Background(), "admin", "password123"); err != nil {
t.Fatalf("setup admin: %v", err)
}
loginBody, _ := json.Marshal(map[string]string{
"username": "admin",
"password": "password123",
})
loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(loginBody))
loginRec := httptest.NewRecorder()
handler.ServeHTTP(loginRec, loginReq)
if loginRec.Code != http.StatusOK {
t.Fatalf("login status: %d body=%s", loginRec.Code, loginRec.Body.String())
}
cookie := loginRec.Result().Cookies()[0]
if cookie.Name != "havline_session" || cookie.HttpOnly == false {
t.Fatal("expected httponly session cookie")
}
authStatusReq := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)
authStatusReq.AddCookie(cookie)
authStatusRec := httptest.NewRecorder()
handler.ServeHTTP(authStatusRec, authStatusReq)
if authStatusRec.Code != http.StatusOK {
t.Fatalf("auth status code: %d", authStatusRec.Code)
}
var authStatus authStatusResponse
if err := json.Unmarshal(authStatusRec.Body.Bytes(), &authStatus); err != nil {
t.Fatalf("decode auth status: %v", err)
}
if !authStatus.Initialized || !authStatus.Authenticated {
t.Fatalf("expected authenticated status, got %+v", authStatus)
}
proxiesReq := httptest.NewRequest(http.MethodGet, "/api/proxies", nil)
proxiesReq.AddCookie(cookie)
proxiesRec := httptest.NewRecorder()
handler.ServeHTTP(proxiesRec, proxiesReq)
if proxiesRec.Code != http.StatusOK {
t.Fatalf("proxies status: %d", proxiesRec.Code)
}
}
+167
View File
@@ -0,0 +1,167 @@
package api
import (
"io"
"net/http"
"os"
"path/filepath"
"github.com/havline/havline/internal/backup"
"github.com/havline/havline/internal/settings"
)
type BackupHandler struct {
svc *backup.Service
settings *settings.Store
}
func NewBackupHandler(svc *backup.Service, settingsStore *settings.Store) *BackupHandler {
return &BackupHandler{svc: svc, settings: settingsStore}
}
func (h *BackupHandler) Export(w http.ResponseWriter, r *http.Request) {
path, err := h.svc.Export(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "创建备份失败")
return
}
defer os.Remove(path)
file, err := os.Open(path)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取备份失败")
return
}
defer file.Close()
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", "attachment; filename="+filepath.Base(path))
_, _ = io.Copy(w, file)
}
func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(64 << 20); err != nil {
writeError(r, w, http.StatusBadRequest, "上传文件无效")
return
}
file, _, err := r.FormFile("file")
if err != nil {
writeError(r, w, http.StatusBadRequest, "请选择备份文件")
return
}
defer file.Close()
// 上传文件名完全由客户端控制:不能拼进临时路径(../ 可逃出临时目录),交给 CreateTemp 生成
out, err := os.CreateTemp("", "havline-restore-*.tar.gz")
if err != nil {
writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
return
}
tmpPath := out.Name()
if _, err := io.Copy(out, file); err != nil {
out.Close()
_ = os.Remove(tmpPath)
writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
return
}
out.Close()
if err := h.svc.Restore(r.Context(), tmpPath); err != nil {
_ = os.Remove(tmpPath)
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
_ = os.Remove(tmpPath)
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Havline"})
}
// ListArchives 列出留在磁盘上的备份(自动备份 + 手动「立即备份」)。
// 与「立即下载」不同:这些文件长期留在 <DataDir>/backups 下。
func (h *BackupHandler) ListArchives(w http.ResponseWriter, r *http.Request) {
archives, err := h.svc.List()
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取备份列表失败", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"archives": archives,
"keep": backup.KeepCount(r.Context(), h.settings),
})
}
// CreateArchive 立即备份一份并保留在服务器上,顺带按保留份数剪枝
func (h *BackupHandler) CreateArchive(w http.ResponseWriter, r *http.Request) {
path, err := h.svc.Export(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "创建备份失败", err)
return
}
name := filepath.Base(path)
// 写失败的备份不能留在列表里冒充可用备份
if _, err := h.svc.Verify(r.Context(), path); err != nil {
_ = h.svc.Remove(name)
writeError(r, w, http.StatusInternalServerError, "备份校验失败:"+err.Error(), err)
return
}
removed, err := h.svc.Prune(backup.KeepCount(r.Context(), h.settings))
if err != nil {
writeError(r, w, http.StatusInternalServerError, "备份已创建,但清理旧备份失败:"+err.Error(), err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"name": name, "removed": removed})
}
// DownloadArchive 下载指定备份;用 ServeContent 支持断点续传
func (h *BackupHandler) DownloadArchive(w http.ResponseWriter, r *http.Request) {
path, err := h.svc.Path(r.PathValue("name"))
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
file, err := os.Open(path)
if err != nil {
writeError(r, w, http.StatusNotFound, "备份不存在")
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取备份失败", err)
return
}
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", "attachment; filename="+filepath.Base(path))
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), file)
}
// RestoreArchive 从指定备份恢复。恢复会覆盖数据库与证书,前端必须二次确认
func (h *BackupHandler) RestoreArchive(w http.ResponseWriter, r *http.Request) {
path, err := h.svc.Path(r.PathValue("name"))
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
// 恢复前先校验:半个包不能拿去覆盖现有数据
if _, err := h.svc.Verify(r.Context(), path); err != nil {
writeError(r, w, http.StatusBadRequest, "该备份不可用:"+err.Error(), err)
return
}
if err := h.svc.Restore(r.Context(), path); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error(), err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Havline"})
}
// DeleteArchive 删除指定备份
func (h *BackupHandler) DeleteArchive(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Remove(r.PathValue("name")); err != nil {
if err.Error() == "备份不存在" {
writeError(r, w, http.StatusNotFound, err.Error())
return
}
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+159
View File
@@ -0,0 +1,159 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/havline/havline/internal/acme"
)
type CertHandler struct {
svc *acme.Service
}
func NewCertHandler(svc *acme.Service) *CertHandler {
return &CertHandler{svc: svc}
}
func (h *CertHandler) List(w http.ResponseWriter, r *http.Request) {
records, err := h.svc.List(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取证书失败")
return
}
writeJSON(w, http.StatusOK, records)
}
func (h *CertHandler) Options(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, []map[string]string{
{"value": acme.CALetsEncrypt, "label": acme.CALabel(acme.CALetsEncrypt)},
{"value": acme.CAZeroSSL, "label": acme.CALabel(acme.CAZeroSSL)},
// Buypass 已于 2025-10-15 停止签发、2026-04-15 终止 ACME 服务,不再列为可选 CA
// (历史记录仍能展示,直接提交会被 CAUnavailableReason 抦下);LiteSSL 接替其位置。
{"value": acme.CALiteSSL, "label": acme.CALabel(acme.CALiteSSL)},
{"value": acme.CALetsEncryptStaging, "label": acme.CALabel(acme.CALetsEncryptStaging)},
})
}
func (h *CertHandler) Apply(w http.ResponseWriter, r *http.Request) {
var req struct {
Domain string `json:"domain"`
DNSZone string `json:"dns_zone"`
DDNSConfigID int64 `json:"ddns_config_id"`
Domains []string `json:"domains"`
CA string `json:"ca"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
domains := req.Domains
if len(domains) == 0 && strings.TrimSpace(req.Domain) != "" {
domains = []string{req.Domain}
}
if req.DDNSConfigID <= 0 {
writeError(r, w, http.StatusBadRequest, "请选择 DNS 任务")
return
}
jobID, err := h.svc.StartApply(r.Context(), domains, req.CA, req.Email, req.DDNSConfigID)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID})
}
func (h *CertHandler) ApplyJobStream(w http.ResponseWriter, r *http.Request) {
jobID := strings.TrimSpace(r.PathValue("id"))
if jobID == "" {
writeError(r, w, http.StatusBadRequest, "无效的任务 ID")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
return
}
if err := h.svc.StreamJob(r.Context(), w, jobID, func() error {
flusher.Flush()
return nil
}); err != nil {
return
}
}
func (h *CertHandler) Import(w http.ResponseWriter, r *http.Request) {
var req struct {
Certificate string `json:"certificate"`
PrivateKey string `json:"private_key"`
CertPath string `json:"cert_path"`
KeyPath string `json:"key_path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
record, err := h.svc.Import(r.Context(), req.Certificate, req.PrivateKey, req.CertPath, req.KeyPath)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, record)
}
func (h *CertHandler) Download(w http.ResponseWriter, r *http.Request) {
domain := strings.TrimSpace(r.PathValue("domain"))
if domain == "" {
writeError(r, w, http.StatusBadRequest, "域名不能为空")
return
}
part := r.URL.Query().Get("part")
filename, contentType, data, err := h.svc.Download(r.Context(), domain, part)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
func (h *CertHandler) Delete(w http.ResponseWriter, r *http.Request) {
domain := strings.TrimSpace(r.PathValue("domain"))
if domain == "" {
writeError(r, w, http.StatusBadRequest, "域名不能为空")
return
}
if err := h.svc.Delete(r.Context(), domain); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "证书已删除"})
}
func (h *CertHandler) Renew(w http.ResponseWriter, r *http.Request) {
var req struct {
Domain string `json:"domain"`
CA string `json:"ca"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
record, err := h.svc.Renew(r.Context(), req.Domain, req.CA)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, record)
}
+29
View File
@@ -0,0 +1,29 @@
package api
import (
"net/http"
"github.com/havline/havline/internal/chinacidr"
)
type ChinaCIDRHandler struct {
svc *chinacidr.Service
}
func NewChinaCIDRHandler(svc *chinacidr.Service) *ChinaCIDRHandler {
return &ChinaCIDRHandler{svc: svc}
}
func (h *ChinaCIDRHandler) Status(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
}
func (h *ChinaCIDRHandler) Refresh(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := h.svc.Update(ctx); err != nil {
h.svc.RecordLastError(ctx, err)
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, h.svc.Status(ctx))
}
+404
View File
@@ -0,0 +1,404 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/havline/havline/internal/cloudflared"
)
type CloudflareHandler struct {
svc *cloudflared.Service
}
func NewCloudflareHandler(svc *cloudflared.Service) *CloudflareHandler {
return &CloudflareHandler{svc: svc}
}
func (h *CloudflareHandler) List(w http.ResponseWriter, r *http.Request) {
items, err := h.svc.List(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
}
func (h *CloudflareHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
item, err := h.svc.Get(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Create(w http.ResponseWriter, r *http.Request) {
var input cloudflared.CreateInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
input.Network = normalizeCloudflareNetwork(input.Network)
item, err := h.svc.Create(r.Context(), input)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
var input cloudflared.CreateInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
input.Network = normalizeCloudflareNetwork(input.Network)
item, err := h.svc.Update(r.Context(), id, input)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *CloudflareHandler) Start(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Start)
}
func (h *CloudflareHandler) Stop(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Stop)
}
func (h *CloudflareHandler) Restart(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.Restart)
}
func (h *CloudflareHandler) RefreshCredentials(w http.ResponseWriter, r *http.Request) {
h.tunnelAction(w, r, h.svc.RefreshCredentials)
}
func (h *CloudflareHandler) tunnelAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) (cloudflared.Tunnel, error)) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
item, err := fn(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *CloudflareHandler) Status(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
status, err := h.svc.RuntimeStatus(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *CloudflareHandler) Logs(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
lines, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("lines")))
output, err := h.svc.Logs(r.Context(), id, lines)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"output": output})
}
func (h *CloudflareHandler) Config(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
content, err := h.svc.PreviewConfig(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"content": content})
}
func (h *CloudflareHandler) Routes(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
routes, err := h.svc.Routes(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, routes)
}
func (h *CloudflareHandler) ReplaceRoutes(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
var request struct {
Routes []cloudflared.RouteInput `json:"routes"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
routes, err := h.svc.ReplaceRoutes(r.Context(), id, request.Routes)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, routes)
}
func (h *CloudflareHandler) Drift(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
result, err := h.svc.Drift(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *CloudflareHandler) SyncRoutes(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
if err := h.svc.PushTunnel(r.Context(), id); err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *CloudflareHandler) Diagnostics(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "隧道 ID 无效")
return
}
result, err := h.svc.Diagnostics(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *CloudflareHandler) Preflight(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.svc.Preflight(r.Context()))
}
func (h *CloudflareHandler) TestRoute(w http.ResponseWriter, r *http.Request) {
var request struct {
Service string `json:"service"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
result, err := h.svc.TestService(r.Context(), request.Service)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *CloudflareHandler) BinaryInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"version": h.svc.BinaryVersion(),
"mirrors": cloudflared.DefaultMirrors(),
})
}
func (h *CloudflareHandler) AppSettings(w http.ResponseWriter, r *http.Request) {
settings, err := h.svc.AppSettings(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, settings)
}
func (h *CloudflareHandler) SaveAppSettings(w http.ResponseWriter, r *http.Request) {
var input cloudflared.AppSettingsInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
input.Network = normalizeCloudflareNetwork(input.Network)
settings, err := h.svc.SaveAppSettings(r.Context(), input)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, settings)
}
func (h *CloudflareHandler) TestToken(w http.ResponseWriter, r *http.Request) {
var request struct {
AccountID string `json:"account_id"`
Token string `json:"token"`
Kind string `json:"kind"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
message, err := h.svc.TestToken(r.Context(), request.AccountID, request.Token, request.Kind)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": message})
}
func (h *CloudflareHandler) SetEnabled(w http.ResponseWriter, r *http.Request) {
var request struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
settings, err := h.svc.SetEnabled(r.Context(), request.Enabled)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, settings)
}
func (h *CloudflareHandler) DownloadBinary(w http.ResponseWriter, r *http.Request) {
var request struct {
Mirror string `json:"mirror"`
Version string `json:"version"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&request); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
version, err := h.svc.DownloadBinaryVersion(r.Context(), request.Mirror, request.Version)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
func (h *CloudflareHandler) LatestBinary(w http.ResponseWriter, r *http.Request) {
version, err := h.svc.LatestBinaryVersion(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"version": version})
}
func (h *CloudflareHandler) RollbackBinary(w http.ResponseWriter, r *http.Request) {
version, err := h.svc.RollbackBinary()
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
func (h *CloudflareHandler) IngressCandidates(w http.ResponseWriter, r *http.Request) {
hosts, err := h.svc.IngressCandidates(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"hostnames": hosts})
}
func (h *CloudflareHandler) Zones(w http.ResponseWriter, r *http.Request) {
zones, err := h.svc.Zones(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"zones": zones})
}
func (h *CloudflareHandler) AccessStatus(w http.ResponseWriter, r *http.Request) {
hostname := strings.TrimSpace(r.URL.Query().Get("hostname"))
enabled, name, err := h.svc.AccessStatus(r.Context(), hostname)
if err != nil {
writeError(r, w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"enabled": enabled, "name": name})
}
func normalizeCloudflareNetwork(network cloudflared.NetworkSettings) cloudflared.NetworkSettings {
defaults := cloudflared.DefaultNetworkSettings()
if strings.TrimSpace(network.TransportProtocol) == "" {
network.TransportProtocol = defaults.TransportProtocol
}
if strings.TrimSpace(network.EdgeIPVersion) == "" {
network.EdgeIPVersion = defaults.EdgeIPVersion
}
if network.HAConnections <= 0 {
network.HAConnections = defaults.HAConnections
}
if strings.TrimSpace(network.ProxyMode) == "" {
network.ProxyMode = defaults.ProxyMode
}
return network
}
+160
View File
@@ -0,0 +1,160 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/havline/havline/internal/ddns"
)
type DDNSHandler struct {
svc *ddns.Service
}
func NewDDNSHandler(svc *ddns.Service) *DDNSHandler {
return &DDNSHandler{svc: svc}
}
type ddnsRequest struct {
Provider string `json:"provider"`
RootDomain string `json:"root_domain"`
RecordName string `json:"record_name"`
RecordNames []string `json:"record_names"`
Domains []string `json:"domains"`
IPv4Enabled *bool `json:"ipv4_enabled"`
IPv6Enabled *bool `json:"ipv6_enabled"`
Enabled *bool `json:"enabled"`
CustomIPv4 string `json:"custom_ipv4"`
CustomIPv6 string `json:"custom_ipv6"`
APIToken string `json:"api_token"`
APITokenID string `json:"api_token_id"`
APISecret string `json:"api_secret"`
}
func (h *DDNSHandler) toInput(req ddnsRequest) ddns.SaveInput {
return ddns.SaveInput{
Provider: req.Provider,
RootDomain: req.RootDomain,
RecordName: req.RecordName,
RecordNames: req.RecordNames,
Domains: req.Domains,
IPv4Enabled: boolDefault(req.IPv4Enabled, true),
IPv6Enabled: boolDefault(req.IPv6Enabled, false),
Enabled: boolDefault(req.Enabled, true),
CustomIPv4: req.CustomIPv4,
CustomIPv6: req.CustomIPv6,
APIToken: req.APIToken,
APITokenID: req.APITokenID,
APISecret: req.APISecret,
}
}
func (h *DDNSHandler) List(w http.ResponseWriter, r *http.Request) {
var configs []ddns.Config
var err error
if r.URL.Query().Get("lite") == "1" {
configs, err = h.svc.ListLite(r.Context())
} else {
configs, err = h.svc.List(r.Context())
}
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取 DDNS 配置失败")
return
}
writeJSON(w, http.StatusOK, configs)
}
func (h *DDNSHandler) Create(w http.ResponseWriter, r *http.Request) {
var req ddnsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
cfg, err := h.svc.Create(r.Context(), h.toInput(req))
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, cfg)
}
func (h *DDNSHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
return
}
var req ddnsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
cfg, err := h.svc.Update(r.Context(), id, h.toInput(req))
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, cfg)
}
func (h *DDNSHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *DDNSHandler) Test(w http.ResponseWriter, r *http.Request) {
var req struct {
ConfigID int64 `json:"config_id"`
Provider string `json:"provider"`
APIToken string `json:"api_token"`
APITokenID string `json:"api_token_id"`
APISecret string `json:"api_secret"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
if err := h.svc.Test(r.Context(), ddns.TestInput{
ConfigID: req.ConfigID,
Provider: req.Provider,
APIToken: req.APIToken,
APITokenID: req.APITokenID,
APISecret: req.APISecret,
}); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "连接成功"})
}
func (h *DDNSHandler) UpdateOne(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
return
}
cfg, err := h.svc.UpdateNow(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, cfg)
}
func (h *DDNSHandler) UpdateAll(w http.ResponseWriter, r *http.Request) {
configs, err := h.svc.UpdateAll(r.Context())
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, configs)
}
+43
View File
@@ -0,0 +1,43 @@
package api
import (
"io/fs"
"log/slog"
"github.com/havline/havline/internal/acme"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/backup"
"github.com/havline/havline/internal/chinacidr"
"github.com/havline/havline/internal/cloudflared"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/ddns"
"github.com/havline/havline/internal/discovery"
"github.com/havline/havline/internal/frp"
"github.com/havline/havline/internal/metrics"
"github.com/havline/havline/internal/notify"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/settings"
"github.com/havline/havline/internal/traffic"
"github.com/havline/havline/internal/updater"
)
type Deps struct {
Config config.Config
Logger *slog.Logger
Auth *auth.Service
Proxy *service.ProxyService
DDNS *ddns.Service
ACME *acme.Service
Settings *settings.Store
ChinaCIDR *chinacidr.Service
Backup *backup.Service
Metrics *metrics.Store
Discovery *discovery.Service
FRPMulti *frp.Service
Cloudflare *cloudflared.Service
Traffic *traffic.Collector
Notify *notify.Service
Updater *updater.Client
StaticFS fs.FS
StartedAt string
}
+20
View File
@@ -0,0 +1,20 @@
package api
import (
"net/http"
"github.com/havline/havline/internal/discovery"
)
type DiscoveryHandler struct {
svc *discovery.Service
}
func NewDiscoveryHandler(svc *discovery.Service) *DiscoveryHandler {
return &DiscoveryHandler{svc: svc}
}
func (h *DiscoveryHandler) Scan(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
writeJSON(w, http.StatusOK, h.svc.Scan(r.Context(), host))
}
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
package api
import (
"net/http"
"path/filepath"
"strconv"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/logstore"
)
type LogsHandler struct {
cfg config.Config
}
func NewLogsHandler(cfg config.Config) *LogsHandler {
return &LogsHandler{cfg: cfg}
}
func (h *LogsHandler) Access(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 100)
keyword := r.URL.Query().Get("keyword")
status := queryInt(r, "status", 0)
entries, err := logstore.ReadAccess(filepath.Join(h.cfg.LogsDir(), "access.log"), limit, keyword, status)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取访问日志失败")
return
}
writeJSON(w, http.StatusOK, entries)
}
func (h *LogsHandler) Error(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 100)
keyword := r.URL.Query().Get("keyword")
lines, err := logstore.ReadError(filepath.Join(h.cfg.LogsDir(), "error.log"), limit, keyword)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取错误日志失败")
return
}
writeJSON(w, http.StatusOK, lines)
}
func (h *LogsHandler) System(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 100)
level := r.URL.Query().Get("level")
keyword := r.URL.Query().Get("keyword")
entries, err := logstore.ReadSystem(filepath.Join(h.cfg.LogsDir(), "app.log"), limit, level, keyword)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取系统日志失败")
return
}
writeJSON(w, http.StatusOK, entries)
}
func (h *LogsHandler) Stream(w http.ResponseWriter, r *http.Request) {
kind := r.URL.Query().Get("type")
if kind == "" {
kind = "error"
}
var path string
switch kind {
case "access":
path = filepath.Join(h.cfg.LogsDir(), "access.log")
case "error":
path = filepath.Join(h.cfg.LogsDir(), "error.log")
default:
writeError(r, w, http.StatusBadRequest, "实时日志仅支持 Nginx 访问/错误日志")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
return
}
tail := queryInt(r, "tail", 100)
var filter logstore.LineFilter
if kind == "access" {
filter = logstore.AccessHostFilter(r.URL.Query()["host"])
}
_ = logstore.StreamFile(r.Context(), path, w, func() error {
flusher.Flush()
return nil
}, tail, filter)
}
func (h *LogsHandler) StreamAccessForHosts(w http.ResponseWriter, r *http.Request, hosts []string) {
endpoints := make([]logstore.AccessEndpoint, 0, len(hosts))
for _, host := range hosts {
endpoints = append(endpoints, logstore.AccessEndpoint{Host: host})
}
h.StreamAccessForEndpoints(w, r, endpoints)
}
func (h *LogsHandler) StreamAccessForEndpoints(w http.ResponseWriter, r *http.Request, endpoints []logstore.AccessEndpoint) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
return
}
path := filepath.Join(h.cfg.LogsDir(), "access.log")
tail := queryInt(r, "tail", 100)
filter := logstore.AccessEndpointFilter(endpoints)
_ = logstore.StreamFile(r.Context(), path, w, func() error {
flusher.Flush()
return nil
}, tail, filter)
}
func queryInt(r *http.Request, key string, fallback int) int {
raw := r.URL.Query().Get(key)
if raw == "" {
return fallback
}
n, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return n
}
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/backup"
"github.com/havline/havline/internal/certificate"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/discovery"
"github.com/havline/havline/internal/proxy"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/settings"
)
func TestLogsAccessRoute(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
authSvc := auth.New(conn)
if err := authSvc.Setup(context.Background(), "admin", "password123"); err != nil {
t.Fatalf("setup: %v", err)
}
sessionID, err := authSvc.Login(context.Background(), "admin", "password123")
if err != nil {
t.Fatalf("login: %v", err)
}
handler := NewRouter(Deps{
Config: config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")},
Auth: authSvc,
Proxy: service.NewProxyService(config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")}, conn, proxy.NewStore(conn), certificate.NewStore(conn), settings.NewStore(conn), nil),
Backup: backup.New(dir),
Discovery: discovery.New(),
StartedAt: time.Now().UTC().Format(time.RFC3339),
})
req := httptest.NewRequest(http.MethodGet, "/api/logs/access?limit=100", nil)
req.AddCookie(&http.Cookie{Name: "havline_session", Value: sessionID})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var body []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("invalid json: %v", err)
}
}
+47
View File
@@ -0,0 +1,47 @@
package api
import (
"net/http"
"strconv"
"time"
"github.com/havline/havline/internal/metrics"
)
type MetricsHandler struct {
store *metrics.Store
}
func NewMetricsHandler(store *metrics.Store) *MetricsHandler {
return &MetricsHandler{store: store}
}
// History 返回指标历史:窗口内的序列已按桶取平均,前端拿到就能直接画。
// hours 最多 30 天(=保留期),buckets 最多 metrics.MaxBuckets。
func (h *MetricsHandler) History(w http.ResponseWriter, r *http.Request) {
hours := 24
if raw := r.URL.Query().Get("hours"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 24*30 {
hours = parsed
}
}
buckets := 48
if raw := r.URL.Query().Get("buckets"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
buckets = parsed
}
}
if buckets > metrics.MaxBuckets {
buckets = metrics.MaxBuckets
}
series, err := h.store.Query(r.Context(), time.Duration(hours)*time.Hour, buckets)
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取指标历史失败", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"hours": hours,
"buckets": buckets,
"series": series,
})
}
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"context"
"net/http"
"strings"
"github.com/havline/havline/internal/auth"
)
type contextKey string
const sessionContextKey contextKey = "session_id"
func SessionMiddleware(authSvc *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("havline_session")
if err == nil && cookie.Value != "" {
ctx := context.WithValue(r.Context(), sessionContextKey, cookie.Value)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
}
// RequireAuth 接受两种凭据:浏览器的会话 cookie,以及 Authorization: Bearer <api token>。
// 令牌分读写两种范围:read 只允许 GET/HEAD,write 与会话同权。
func RequireAuth(authSvc *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token := bearerToken(r); token != "" {
scope, err := authSvc.ValidateAPIToken(r.Context(), token)
if err != nil {
writeError(r, w, http.StatusUnauthorized, "API Token 无效或已过期")
return
}
// 账号与令牌管理只允许浏览器会话:否则一个 write 令牌可以自己给自己提权
if tokenDeniedPath(r.URL.Path) {
writeError(r, w, http.StatusForbidden, "该操作需要使用登录会话")
return
}
if scope != auth.TokenScopeWrite && r.Method != http.MethodGet && r.Method != http.MethodHead {
writeError(r, w, http.StatusForbidden, "该 Token 只读,不能执行写操作")
return
}
next.ServeHTTP(w, r)
return
}
sessionID, _ := r.Context().Value(sessionContextKey).(string)
if err := authSvc.ValidateSession(r.Context(), sessionID); err != nil {
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
return
}
next.ServeHTTP(w, r)
})
}
}
// bearerToken 取出 Authorization: Bearer 里的明文令牌;没有或格式不对返回空串。
func bearerToken(r *http.Request) string {
header := strings.TrimSpace(r.Header.Get("Authorization"))
const prefix = "Bearer "
if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
return ""
}
return strings.TrimSpace(header[len(prefix):])
}
// tokenDeniedPath 是只允许浏览器会话的路径(账号与令牌管理)。
func tokenDeniedPath(path string) bool {
if path == "/api/auth/password" || path == "/api/auth/logout" {
return true
}
return strings.HasPrefix(path, "/api/settings/tokens") ||
strings.HasPrefix(path, "/api/system/update")
}
func sessionIDFromContext(ctx context.Context) string {
v, _ := ctx.Value(sessionContextKey).(string)
return v
}
+130
View File
@@ -0,0 +1,130 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/db"
)
func newTokenTestAuth(t *testing.T, scope string) (*auth.Service, string) {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
svc := auth.New(conn)
created, err := svc.CreateAPIToken(context.Background(), "test", scope, time.Time{})
if err != nil {
t.Fatalf("create token: %v", err)
}
return svc, created.Token
}
func okTokenHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }
}
func TestRequireAuthAcceptsReadTokenForGet(t *testing.T) {
svc, plain := newTokenTestAuth(t, auth.TokenScopeRead)
handler := RequireAuth(svc)(okTokenHandler())
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
req.Header.Set("Authorization", "Bearer "+plain)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("只读令牌应能读,实际 %d", rec.Code)
}
}
func TestRequireAuthBlocksWriteForReadToken(t *testing.T) {
svc, plain := newTokenTestAuth(t, auth.TokenScopeRead)
handler := RequireAuth(svc)(okTokenHandler())
req := httptest.NewRequest(http.MethodDelete, "/api/proxies/1", nil)
req.Header.Set("Authorization", "Bearer "+plain)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("只读令牌不应能写,实际 %d", rec.Code)
}
}
func TestRequireAuthAllowsWriteForWriteToken(t *testing.T) {
svc, plain := newTokenTestAuth(t, auth.TokenScopeWrite)
handler := RequireAuth(svc)(okTokenHandler())
req := httptest.NewRequest(http.MethodPost, "/api/proxies", nil)
req.Header.Set("Authorization", "Bearer "+plain)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("写令牌应能写,实际 %d", rec.Code)
}
}
func TestRequireAuthTokenCannotManageTokens(t *testing.T) {
// 写令牌也不能管理令牌:否则等于拿令牌给自己提权或无限续期
svc, plain := newTokenTestAuth(t, auth.TokenScopeWrite)
handler := RequireAuth(svc)(okTokenHandler())
req := httptest.NewRequest(http.MethodGet, "/api/settings/tokens", nil)
req.Header.Set("Authorization", "Bearer "+plain)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("令牌管理应只允许登录会话,实际 %d", rec.Code)
}
}
func TestRequireAuthRejectsBadCredentials(t *testing.T) {
svc, _ := newTokenTestAuth(t, auth.TokenScopeRead)
handler := RequireAuth(svc)(okTokenHandler())
cases := map[string]string{
"未知令牌": "Bearer deadbeef",
"错误前缀": "Token abc",
"没有凭据": "",
}
for name, header := range cases {
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
if header != "" {
req.Header.Set("Authorization", header)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s 应返回 401,实际 %d", name, rec.Code)
}
}
}
func TestBearerTokenParsing(t *testing.T) {
cases := map[string]string{
"Bearer abc": "abc",
"bearer abc": "abc",
"Bearer abc": "abc",
"Basic abc": "",
"Bearer ": "",
"": "",
}
for header, want := range cases {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if header != "" {
req.Header.Set("Authorization", header)
}
if got := bearerToken(req); got != want {
t.Fatalf("Authorization %q 解析为 %q,期望 %q", header, got, want)
}
}
}
+163
View File
@@ -0,0 +1,163 @@
package api
import (
"encoding/json"
"net/http"
"github.com/havline/havline/internal/service"
)
type NginxHandler struct {
proxy *service.ProxyService
}
func NewNginxHandler(proxy *service.ProxyService) *NginxHandler {
return &NginxHandler{proxy: proxy}
}
type saveRuleNginxRequest struct {
Mode string `json:"mode"`
Content *string `json:"content"`
}
type rollbackNginxRequest struct {
Backup string `json:"backup"`
}
func (h *NginxHandler) GetRule(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
return
}
view, err := h.proxy.GetRuleNginx(r.Context(), id)
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) PutRule(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
return
}
var req saveRuleNginxRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
view, err := h.proxy.SaveRuleNginx(r.Context(), id, service.SaveRuleNginxInput{
Mode: req.Mode,
Content: req.Content,
})
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) RollbackRule(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
return
}
var req rollbackNginxRequest
if r.Body != nil && r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
}
view, err := h.proxy.RollbackRuleNginx(r.Context(), id, req.Backup)
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) GetGlobal(w http.ResponseWriter, r *http.Request) {
view, err := h.proxy.GetGlobalNginx(r.Context())
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) PutGlobal(w http.ResponseWriter, r *http.Request) {
var req saveRuleNginxRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
view, err := h.proxy.SaveGlobalNginx(r.Context(), service.SaveGlobalNginxInput{
Mode: req.Mode,
Content: req.Content,
})
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) RollbackGlobal(w http.ResponseWriter, r *http.Request) {
var req rollbackNginxRequest
if r.Body != nil && r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
}
view, err := h.proxy.RollbackGlobalNginx(r.Context(), req.Backup)
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) GetConfigVersions(w http.ResponseWriter, r *http.Request) {
view, err := h.proxy.GetNginxConfigVersions()
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) GetConfigVersion(w http.ResponseWriter, r *http.Request) {
view, err := h.proxy.ReadNginxConfigVersion(r.PathValue("name"))
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func (h *NginxHandler) RollbackConfigVersion(w http.ResponseWriter, r *http.Request) {
view, err := h.proxy.RollbackNginxConfigVersion(r.Context(), r.PathValue("name"))
if err != nil {
writeServiceError(r, w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
func writeServiceError(r *http.Request, w http.ResponseWriter, err error) {
if err == nil {
return
}
msg := err.Error()
if msg == "规则不存在" {
writeError(r, w, http.StatusNotFound, msg, err)
return
}
writeError(r, w, http.StatusBadRequest, msg, err)
}
+33
View File
@@ -0,0 +1,33 @@
package api
import (
"encoding/json"
"net/http"
"github.com/havline/havline/internal/notify"
)
type NotifyHandler struct {
notify *notify.Service
}
func NewNotifyHandler(notifySvc *notify.Service) *NotifyHandler {
return &NotifyHandler{notify: notifySvc}
}
func (h *NotifyHandler) Test(w http.ResponseWriter, r *http.Request) {
if h.notify == nil {
writeError(r, w, http.StatusInternalServerError, "通知服务未初始化")
return
}
var req notify.TestInput
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
if err := h.notify.SendTest(r.Context(), req); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error(), err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "测试通知已发送"})
}
+277
View File
@@ -0,0 +1,277 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/logstore"
"github.com/havline/havline/internal/proxy"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/traffic"
)
type ProxyHandler struct {
cfg config.Config
svc *service.ProxyService
logs *LogsHandler
traffic *traffic.Collector
}
func NewProxyHandler(cfg config.Config, svc *service.ProxyService, logs *LogsHandler, collector *traffic.Collector) *ProxyHandler {
return &ProxyHandler{cfg: cfg, svc: svc, logs: logs, traffic: collector}
}
type proxyRequest struct {
Domain string `json:"domain"`
Upstream string `json:"upstream"`
ListenPort *int `json:"listen_port"`
ListenIPv4 *bool `json:"listen_ipv4"`
ListenIPv6 *bool `json:"listen_ipv6"`
Hosts []string `json:"hosts"`
HTTPSEnabled *bool `json:"https_enabled"`
HTTPRedirect *bool `json:"http_redirect"`
Enabled *bool `json:"enabled"`
Name *string `json:"name"`
Remark *string `json:"remark"` // deprecated alias for name
Security *securityRequest `json:"security,omitempty"`
Exits []string `json:"exits"`
CFTunnelID *int64 `json:"cf_tunnel_id"`
}
type proxyReorderRequest struct {
IDs []int64 `json:"ids"`
}
func (h *ProxyHandler) List(w http.ResponseWriter, r *http.Request) {
rules, err := h.svc.List(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取反向代理规则失败")
return
}
writeJSON(w, http.StatusOK, proxy.RulesForAPI(rules))
}
func (h *ProxyHandler) Create(w http.ResponseWriter, r *http.Request) {
var req proxyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
hosts := req.hosts()
if len(hosts) == 0 {
writeError(r, w, http.StatusBadRequest, "至少需要一个前端域名")
return
}
listenPort := intDefault(req.ListenPort, h.cfg.DefaultListenPort(boolDefault(req.HTTPSEnabled, true)))
name := ""
if resolved := req.resolveName(); resolved != nil {
name = *resolved
}
in := proxy.CreateInput{
Upstream: req.Upstream,
ListenPort: listenPort,
ListenIPv4: boolDefault(req.ListenIPv4, true),
ListenIPv6: boolDefault(req.ListenIPv6, false),
Hosts: hosts,
HTTPSEnabled: boolDefault(req.HTTPSEnabled, true),
HTTPRedirect: boolDefault(req.HTTPRedirect, true),
Enabled: boolDefault(req.Enabled, true),
Name: name,
Security: req.securityInput(),
Exits: req.Exits,
CFTunnelID: int64Default(req.CFTunnelID, 0),
}
rule, err := h.svc.Create(r.Context(), in)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, proxy.RuleForAPI(rule))
}
func (h *ProxyHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
return
}
var req proxyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
in := proxy.UpdateInput{}
if req.Upstream != "" {
in.Upstream = &req.Upstream
}
if req.ListenPort != nil {
in.ListenPort = req.ListenPort
}
if req.ListenIPv4 != nil {
in.ListenIPv4 = req.ListenIPv4
}
if req.ListenIPv6 != nil {
in.ListenIPv6 = req.ListenIPv6
}
if hosts := req.hosts(); len(hosts) > 0 {
in.Hosts = &hosts
}
if req.HTTPSEnabled != nil {
in.HTTPSEnabled = req.HTTPSEnabled
}
if req.HTTPRedirect != nil {
in.HTTPRedirect = req.HTTPRedirect
}
if req.Enabled != nil {
in.Enabled = req.Enabled
}
if resolved := req.resolveName(); resolved != nil {
in.Name = resolved
}
if req.Security != nil {
sec := req.securityInput()
in.Security = &sec
}
if req.Exits != nil {
in.Exits = &req.Exits
}
if req.CFTunnelID != nil {
in.CFTunnelID = req.CFTunnelID
}
rule, err := h.svc.Update(r.Context(), id, in)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, proxy.RuleForAPI(rule))
}
func (h *ProxyHandler) Traffic(w http.ResponseWriter, r *http.Request) {
if h.traffic == nil {
writeJSON(w, http.StatusOK, []traffic.RuleTraffic{})
return
}
rules, err := h.svc.List(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取反向代理规则失败")
return
}
writeJSON(w, http.StatusOK, h.traffic.SnapshotForRules(rules))
}
func (h *ProxyHandler) Clients(w http.ResponseWriter, r *http.Request) {
if h.traffic == nil {
writeJSON(w, http.StatusOK, []traffic.ClientConn{})
return
}
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
return
}
rule, err := h.svc.Get(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadRequest, "规则不存在")
return
}
writeJSON(w, http.StatusOK, h.traffic.ClientsForRule(rule))
}
func (h *ProxyHandler) StreamLogs(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
return
}
rule, err := h.svc.Get(r.Context(), id)
if err != nil {
writeError(r, w, http.StatusBadRequest, "规则不存在")
return
}
endpoints := rule.Endpoints()
if len(endpoints) == 0 {
writeError(r, w, http.StatusBadRequest, "该规则没有前端域名")
return
}
matches := make([]logstore.AccessEndpoint, 0, len(endpoints))
for _, ep := range endpoints {
matches = append(matches, logstore.AccessEndpoint{Host: ep.Hostname, Port: ep.Port})
}
h.logs.StreamAccessForEndpoints(w, r, matches)
}
func (h *ProxyHandler) Reorder(w http.ResponseWriter, r *http.Request) {
var req proxyReorderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
if err := h.svc.Reorder(r.Context(), req.IDs); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *ProxyHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (req proxyRequest) resolveName() *string {
if req.Name != nil {
return req.Name
}
return req.Remark
}
func (req proxyRequest) hosts() []string {
if len(req.Hosts) > 0 {
return req.Hosts
}
if req.Domain != "" {
return []string{req.Domain}
}
return nil
}
func intDefault(v *int, fallback int) int {
if v == nil {
return fallback
}
return *v
}
func parseID(raw string) (int64, error) {
return strconv.ParseInt(raw, 10, 64)
}
func boolDefault(v *bool, fallback bool) bool {
if v == nil {
return fallback
}
return *v
}
func int64Default(v *int64, fallback int64) int64 {
if v == nil {
return fallback
}
return *v
}
+60
View File
@@ -0,0 +1,60 @@
package api
import (
"github.com/havline/havline/internal/proxy"
)
type securityRequest struct {
IPBlacklist []string `json:"ip_blacklist,omitempty"`
IPWhitelist []string `json:"ip_whitelist,omitempty"`
IPWhitelistMode *bool `json:"ip_whitelist_mode,omitempty"`
IPBlacklistText *string `json:"ip_blacklist_text,omitempty"`
IPWhitelistText *string `json:"ip_whitelist_text,omitempty"`
ChinaOnly *bool `json:"china_only,omitempty"`
BasicAuth *basicAuthRequest `json:"basic_auth,omitempty"`
RateLimit *proxy.RateLimitConfig `json:"rate_limit,omitempty"`
ConnLimit *proxy.ConnLimitConfig `json:"conn_limit,omitempty"`
ProxySSLVerifyOff *bool `json:"proxy_ssl_verify_off,omitempty"`
ProxyHostUpstream *bool `json:"proxy_host_upstream,omitempty"`
TLSMin13Only *bool `json:"tls_min_13_only,omitempty"`
SecurityHeaders *bool `json:"security_headers,omitempty"`
}
type basicAuthRequest struct {
Enabled *bool `json:"enabled,omitempty"`
Username *string `json:"username,omitempty"`
Password *string `json:"password,omitempty"`
}
func (req *proxyRequest) securityInput() proxy.SecurityInput {
if req.Security == nil {
return proxy.SecurityInput{}
}
s := req.Security
in := proxy.SecurityInput{
IPBlacklist: s.IPBlacklist,
IPWhitelist: s.IPWhitelist,
IPWhitelistMode: s.IPWhitelistMode,
ChinaOnly: s.ChinaOnly,
RateLimit: s.RateLimit,
ConnLimit: s.ConnLimit,
ProxySSLVerifyOff: s.ProxySSLVerifyOff,
ProxyHostUpstream: s.ProxyHostUpstream,
TLSMin13Only: s.TLSMin13Only,
SecurityHeaders: s.SecurityHeaders,
}
if s.IPBlacklistText != nil {
in.IPBlacklist = proxy.ParseIPListText(*s.IPBlacklistText)
}
if s.IPWhitelistText != nil {
in.IPWhitelist = proxy.ParseIPListText(*s.IPWhitelistText)
}
if s.BasicAuth != nil {
in.BasicAuth = &proxy.BasicAuthInput{
Enabled: s.BasicAuth.Enabled,
Username: s.BasicAuth.Username,
Password: s.BasicAuth.Password,
}
}
return in
}
+64
View File
@@ -0,0 +1,64 @@
package api
import (
"encoding/json"
"log/slog"
"net/http"
"reflect"
)
type ErrorBody struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if payload == nil {
_, _ = w.Write([]byte("null"))
return
}
// Go nil slices/maps encode as JSON null; clients expect [] / {}.
v := reflect.ValueOf(payload)
switch v.Kind() {
case reflect.Slice:
if v.IsNil() {
_, _ = w.Write([]byte("[]"))
return
}
case reflect.Map:
if v.IsNil() {
_, _ = w.Write([]byte("{}"))
return
}
}
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(r *http.Request, w http.ResponseWriter, status int, message string, cause ...error) {
logAPIError(r, status, message, cause)
writeJSON(w, status, ErrorBody{Error: message})
}
func logAPIError(r *http.Request, status int, message string, cause []error) {
if status < 400 {
return
}
attrs := []any{
"module", "API",
"status", status,
"message", message,
}
if r != nil {
attrs = append(attrs, "method", r.Method, "path", r.URL.Path)
}
if len(cause) > 0 && cause[0] != nil {
attrs = append(attrs, "error", cause[0].Error())
}
switch {
case status >= 500:
slog.Error("request failed", attrs...)
default:
slog.Warn("request rejected", attrs...)
}
}
+64
View File
@@ -0,0 +1,64 @@
package api
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
)
func TestWriteJSONNilSlice(t *testing.T) {
var items []string
rec := httptest.NewRecorder()
writeJSON(rec, http.StatusOK, items)
if rec.Body.String() != "[]" {
t.Fatalf("expected [], got %q", rec.Body.String())
}
}
func TestWriteJSONNilMap(t *testing.T) {
var values map[string]string
rec := httptest.NewRecorder()
writeJSON(rec, http.StatusOK, values)
if rec.Body.String() != "{}" {
t.Fatalf("expected {}, got %q", rec.Body.String())
}
}
func TestWriteJSONSlice(t *testing.T) {
rec := httptest.NewRecorder()
writeJSON(rec, http.StatusOK, []int{1, 2})
var out []int
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if len(out) != 2 {
t.Fatalf("expected 2 items, got %v", out)
}
}
func TestWriteErrorLogsClientError(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
defer slog.SetDefault(prev)
req := httptest.NewRequest(http.MethodPost, "/api/settings/notify/test", nil)
rec := httptest.NewRecorder()
writeError(req, rec, http.StatusBadRequest, "请填写 SMTP 主机")
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
if !bytes.Contains(buf.Bytes(), []byte("请填写 SMTP 主机")) {
t.Fatalf("expected log message, got %s", buf.String())
}
if !bytes.Contains(buf.Bytes(), []byte("/api/settings/notify/test")) {
t.Fatalf("expected log path, got %s", buf.String())
}
}
+328
View File
@@ -0,0 +1,328 @@
package api
import (
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"github.com/havline/havline/internal/auth"
)
// routeSpec 是一条 API 路由的注册信息;测试据此遍历全部路由验证鉴权覆盖。
type routeSpec struct {
Pattern string
Handler http.HandlerFunc
Public bool
}
func NewRouter(deps Deps) http.Handler {
handler, _ := newRouterWithSpecs(deps)
return handler
}
func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
logsHandler := NewLogsHandler(deps.Config)
r := &Router{
authHandler: NewAuthHandler(deps.Auth, deps.Notify),
proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
nginxHandler: NewNginxHandler(deps.Proxy),
statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.Cloudflare, deps.StartedAt, deps.Config.NginxPIDFile),
ddnsHandler: NewDDNSHandler(deps.DDNS),
certHandler: NewCertHandler(deps.ACME),
settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify),
chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR),
logsHandler: logsHandler,
backupHandler: NewBackupHandler(deps.Backup, deps.Settings),
statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti, deps.Cloudflare),
metricsHandler: NewMetricsHandler(deps.Metrics),
discoveryHandler: NewDiscoveryHandler(deps.Discovery),
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti, deps.Auth),
cloudflareHandler: NewCloudflareHandler(deps.Cloudflare),
updateHandler: NewUpdateHandler(deps.Updater),
authSvc: deps.Auth,
staticFS: deps.StaticFS,
}
mux := http.NewServeMux()
var specs []routeSpec
public := func(pattern string, handler http.HandlerFunc) {
specs = append(specs, routeSpec{Pattern: pattern, Handler: handler, Public: true})
}
protected := func(pattern string, handler http.HandlerFunc) {
specs = append(specs, routeSpec{Pattern: pattern, Handler: handler})
}
public("GET /api/version", Version(deps.Config))
// 唯一一个匿名只读接口:默认关闭、可设访问码、按 IP 限流
public("GET /api/status-page", r.statusPageHandler.Get)
public("GET /api/auth/status", r.authHandler.Status)
public("POST /api/auth/login", r.authHandler.Login)
public("POST /api/auth/logout", r.authHandler.Logout)
protect := protected
protect("POST /api/auth/password", r.authHandler.ChangePassword)
protect("GET /api/settings/tokens", r.authHandler.ListTokens)
protect("POST /api/settings/tokens", r.authHandler.CreateToken)
protect("DELETE /api/settings/tokens/{id}", r.authHandler.DeleteToken)
protect("GET /api/status", r.statusHandler.Get)
protect("GET /api/proxies", r.proxyHandler.List)
protect("GET /api/proxies/traffic", r.proxyHandler.Traffic)
protect("POST /api/proxies", r.proxyHandler.Create)
protect("PUT /api/proxies/reorder", r.proxyHandler.Reorder)
protect("PUT /api/proxies/{id}", r.proxyHandler.Update)
protect("DELETE /api/proxies/{id}", r.proxyHandler.Delete)
protect("GET /api/proxies/{id}/logs/stream", r.proxyHandler.StreamLogs)
protect("GET /api/proxies/{id}/clients", r.proxyHandler.Clients)
protect("GET /api/proxies/{id}/nginx", r.nginxHandler.GetRule)
protect("PUT /api/proxies/{id}/nginx", r.nginxHandler.PutRule)
protect("POST /api/proxies/{id}/nginx/rollback", r.nginxHandler.RollbackRule)
protect("GET /api/settings/nginx/global", r.nginxHandler.GetGlobal)
protect("PUT /api/settings/nginx/global", r.nginxHandler.PutGlobal)
protect("POST /api/settings/nginx/global/rollback", r.nginxHandler.RollbackGlobal)
protect("GET /api/settings/nginx/config/versions", r.nginxHandler.GetConfigVersions)
protect("GET /api/settings/nginx/config/versions/{name}", r.nginxHandler.GetConfigVersion)
protect("POST /api/settings/nginx/config/versions/{name}/rollback", r.nginxHandler.RollbackConfigVersion)
protect("GET /api/ddns", r.ddnsHandler.List)
protect("POST /api/ddns", r.ddnsHandler.Create)
protect("PUT /api/ddns/{id}", r.ddnsHandler.Update)
protect("DELETE /api/ddns/{id}", r.ddnsHandler.Delete)
protect("POST /api/ddns/test", r.ddnsHandler.Test)
protect("POST /api/ddns/update", r.ddnsHandler.UpdateAll)
protect("POST /api/ddns/{id}/update", r.ddnsHandler.UpdateOne)
protect("GET /api/certificates", r.certHandler.List)
protect("GET /api/certificates/ca-options", r.certHandler.Options)
protect("POST /api/certificates/apply", r.certHandler.Apply)
protect("GET /api/certificates/jobs/{id}/stream", r.certHandler.ApplyJobStream)
protect("POST /api/certificates/import", r.certHandler.Import)
protect("POST /api/certificates/renew", r.certHandler.Renew)
protect("GET /api/certificates/{domain}/download", r.certHandler.Download)
protect("DELETE /api/certificates/{domain}", r.certHandler.Delete)
notifyHandler := NewNotifyHandler(deps.Notify)
protect("GET /api/settings", r.settingsHandler.Get)
protect("PUT /api/settings", r.settingsHandler.Put)
protect("POST /api/settings/notify/test", notifyHandler.Test)
if deps.ChinaCIDR != nil {
protect("GET /api/settings/china-cidr", r.chinaCIDRHandler.Status)
protect("POST /api/settings/china-cidr/refresh", r.chinaCIDRHandler.Refresh)
}
protect("GET /api/logs/access", r.logsHandler.Access)
protect("GET /api/logs/error", r.logsHandler.Error)
protect("GET /api/logs/system", r.logsHandler.System)
protect("GET /api/metrics/history", r.metricsHandler.History)
protect("GET /api/logs/stream", r.logsHandler.Stream)
protect("GET /api/system/update/status", r.updateHandler.Status)
protect("POST /api/system/update/apply", r.updateHandler.Apply)
protect("GET /api/backup/export", r.backupHandler.Export)
protect("POST /api/backup/restore", r.backupHandler.Restore)
protect("GET /api/backup/archives", r.backupHandler.ListArchives)
protect("POST /api/backup/archives", r.backupHandler.CreateArchive)
protect("GET /api/backup/archives/{name}", r.backupHandler.DownloadArchive)
protect("POST /api/backup/archives/{name}/restore", r.backupHandler.RestoreArchive)
protect("DELETE /api/backup/archives/{name}", r.backupHandler.DeleteArchive)
protect("GET /api/discovery/scan", r.discoveryHandler.Scan)
if deps.FRPMulti != nil {
frpHandler := r.frpmultiHandler
protect("GET /api/frpmulti/status", frpHandler.Status)
protect("GET /api/frpmulti/runtime", frpHandler.Runtime)
protect("PUT /api/frpmulti/runtime/download-proxy", frpHandler.SetDownloadProxy)
protect("GET /api/frpmulti/releases", frpHandler.Releases)
protect("GET /api/frpmulti/binaries", frpHandler.Binaries)
protect("GET /api/frpmulti/download", frpHandler.DownloadStatus)
protect("POST /api/frpmulti/releases/{version}/download", frpHandler.DownloadRelease)
protect("POST /api/frpmulti/binaries/{version}/activate", frpHandler.ActivateBinary)
protect("DELETE /api/frpmulti/binaries/{version}", frpHandler.DeleteBinary)
protect("GET /api/frpmulti/servers", frpHandler.ListServers)
protect("GET /api/frpmulti/servers/{id}/detail", frpHandler.ServerDetail)
protect("GET /api/frpmulti/servers/{id}/diagnostics", frpHandler.ServerDiagnostics)
protect("GET /api/frpmulti/servers/{id}/frps-config", frpHandler.FRPSServerConfig)
protect("POST /api/frpmulti/servers/{id}/start", frpHandler.StartServer)
protect("POST /api/frpmulti/servers/{id}/stop", frpHandler.StopServer)
protect("POST /api/frpmulti/servers", frpHandler.CreateServer)
protect("PUT /api/frpmulti/servers/{id}", frpHandler.UpdateServer)
protect("DELETE /api/frpmulti/servers/{id}", frpHandler.DeleteServer)
protect("POST /api/frpmulti/servers/{id}/test", frpHandler.TestServer)
protect("POST /api/frpmulti/servers/{id}/agent/probe", frpHandler.AgentProbe)
protect("POST /api/frpmulti/servers/{id}/agent/install", frpHandler.AgentInstall)
protect("POST /api/frpmulti/servers/{id}/agent/uninstall", frpHandler.AgentUninstall)
protect("POST /api/frpmulti/servers/{id}/agent/ssh-diagnose", frpHandler.AgentSSHDiagnose)
protect("POST /api/frpmulti/servers/{id}/agent/test", frpHandler.AgentTest)
protect("POST /api/frpmulti/servers/{id}/agent/tunnel/restart", frpHandler.AgentTunnelRestart)
protect("POST /api/frpmulti/servers/{id}/agent/transport", frpHandler.SetAgentTransport)
protect("GET /api/frpmulti/servers/{id}/agent/transport", frpHandler.AgentTransportStatus)
protect("POST /api/frpmulti/servers/{id}/agent/transport/rotate", frpHandler.AgentTransportRotateCert)
protect("GET /api/frpmulti/servers/{id}/agent/firewall", frpHandler.AgentFirewallStatus)
protect("POST /api/frpmulti/servers/{id}/agent/firewall/allow", frpHandler.AgentFirewallAllow)
protect("GET /api/frpmulti/servers/{id}/agent/status", frpHandler.AgentStatus)
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
protect("GET /api/frpmulti/routes/summary", frpHandler.AgentRouteHealthSummary)
protect("GET /api/frpmulti/routes/uptime", frpHandler.RouteUptime)
protect("GET /api/frpmulti/servers/{id}/agent/routes/drift", frpHandler.AgentRouteDrift)
protect("POST /api/frpmulti/servers/{id}/agent/routes/redeploy", frpHandler.AgentRedeployRoutes)
protect("GET /api/frpmulti/servers/{id}/agent/metrics", frpHandler.AgentMetrics)
protect("GET /api/frpmulti/servers/{id}/agent/logs/nginx", frpHandler.AgentNginxLogs)
protect("GET /api/frpmulti/servers/{id}/agent/frps-config", frpHandler.AgentFRPSConfig)
protect("GET /api/frpmulti/servers/{id}/agent/certs", frpHandler.AgentCerts)
protect("POST /api/frpmulti/servers/{id}/agent/nginx/reload", frpHandler.AgentNginxReload)
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions", frpHandler.AgentRouteVersions)
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}", frpHandler.AgentRouteVersion)
protect("POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}/rollback", frpHandler.AgentRollbackRouteVersion)
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
protect("POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.DeployAgentRoute)
protect("DELETE /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.RemoveAgentRoute)
protect("POST /api/frpmulti/servers/{id}/agent/frps-install", frpHandler.InstallFRPSOnAgent)
protect("POST /api/frpmulti/servers/{id}/agent/frps-action", frpHandler.AgentFRPSAction)
protect("POST /api/frpmulti/servers/{id}/agent/frps-config", frpHandler.PushFRPSConfigToAgent)
protect("GET /api/frpmulti/proxies", frpHandler.ListProxies)
protect("GET /api/frpmulti/proxies/traffic", frpHandler.ListProxiesTraffic)
protect("POST /api/frpmulti/proxies", frpHandler.CreateProxy)
protect("PUT /api/frpmulti/proxies/{id}", frpHandler.UpdateProxy)
protect("DELETE /api/frpmulti/proxies/{id}", frpHandler.DeleteProxy)
protect("GET /api/frpmulti/proxies/{id}/logs", frpHandler.ProxyLogs)
protect("POST /api/frpmulti/start", frpHandler.Start)
protect("POST /api/frpmulti/stop", frpHandler.Stop)
protect("POST /api/frpmulti/reload", frpHandler.Reload)
protect("GET /api/frpmulti/logs", frpHandler.Logs)
protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig)
}
if deps.Cloudflare != nil {
cf := r.cloudflareHandler
protect("GET /api/cloudflare/tunnels", cf.List)
protect("POST /api/cloudflare/tunnels", cf.Create)
protect("GET /api/cloudflare/tunnels/{id}", cf.Get)
protect("PUT /api/cloudflare/tunnels/{id}", cf.Update)
protect("DELETE /api/cloudflare/tunnels/{id}", cf.Delete)
protect("POST /api/cloudflare/tunnels/{id}/start", cf.Start)
protect("POST /api/cloudflare/tunnels/{id}/stop", cf.Stop)
protect("POST /api/cloudflare/tunnels/{id}/restart", cf.Restart)
protect("POST /api/cloudflare/tunnels/{id}/credentials/refresh", cf.RefreshCredentials)
protect("GET /api/cloudflare/tunnels/{id}/status", cf.Status)
protect("GET /api/cloudflare/tunnels/{id}/logs", cf.Logs)
protect("GET /api/cloudflare/tunnels/{id}/config", cf.Config)
protect("GET /api/cloudflare/tunnels/{id}/routes", cf.Routes)
protect("PUT /api/cloudflare/tunnels/{id}/routes", cf.ReplaceRoutes)
protect("GET /api/cloudflare/tunnels/{id}/drift", cf.Drift)
protect("POST /api/cloudflare/tunnels/{id}/sync", cf.SyncRoutes)
protect("GET /api/cloudflare/tunnels/{id}/diagnostics", cf.Diagnostics)
protect("GET /api/cloudflare/binary", cf.BinaryInfo)
protect("POST /api/cloudflare/binary/download", cf.DownloadBinary)
protect("GET /api/cloudflare/binary/latest", cf.LatestBinary)
protect("POST /api/cloudflare/binary/rollback", cf.RollbackBinary)
protect("GET /api/cloudflare/settings", cf.AppSettings)
protect("PUT /api/cloudflare/settings", cf.SaveAppSettings)
protect("POST /api/cloudflare/settings/test", cf.TestToken)
protect("POST /api/cloudflare/enabled", cf.SetEnabled)
protect("GET /api/cloudflare/ingress-candidates", cf.IngressCandidates)
protect("GET /api/cloudflare/zones", cf.Zones)
protect("GET /api/cloudflare/access-status", cf.AccessStatus)
protect("GET /api/cloudflare/preflight", cf.Preflight)
protect("POST /api/cloudflare/routes/test", cf.TestRoute)
}
if deps.StaticFS != nil {
mux.Handle("/", r.spaHandler())
}
for _, spec := range specs {
if spec.Public {
mux.HandleFunc(spec.Pattern, spec.Handler)
continue
}
mux.Handle(spec.Pattern, SessionMiddleware(deps.Auth)(RequireAuth(deps.Auth)(spec.Handler)))
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux)), specs
}
type Router struct {
authHandler *AuthHandler
proxyHandler *ProxyHandler
nginxHandler *NginxHandler
statusHandler *StatusHandler
ddnsHandler *DDNSHandler
certHandler *CertHandler
settingsHandler *SettingsHandler
chinaCIDRHandler *ChinaCIDRHandler
logsHandler *LogsHandler
backupHandler *BackupHandler
statusPageHandler *StatusPageHandler
metricsHandler *MetricsHandler
discoveryHandler *DiscoveryHandler
frpmultiHandler *FRPMultiHandler
cloudflareHandler *CloudflareHandler
updateHandler *UpdateHandler
authSvc *auth.Service
staticFS fs.FS
}
func (r *Router) spaHandler() http.Handler {
fileServer := http.FileServer(http.FS(r.staticFS))
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if strings.HasPrefix(req.URL.Path, "/api/") {
http.NotFound(w, req)
return
}
path := strings.TrimPrefix(req.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(r.staticFS, path); err != nil {
// SPA fallback: unknown routes serve index.html
req.URL.Path = "/index.html"
}
fileServer.ServeHTTP(w, req)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (w *statusRecorder) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusRecorder) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
if strings.HasPrefix(r.URL.Path, "/api/") &&
!strings.Contains(r.URL.Path, "/logs/stream") &&
!strings.Contains(r.URL.Path, "/certificates/jobs/") &&
rec.status >= 400 {
level := slog.LevelWarn
msg := "api request completed with client error"
if rec.status >= 500 {
level = slog.LevelError
msg = "api request failed"
}
logger.Log(r.Context(), level, msg,
"module", "HTTP",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
)
}
})
}
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/havline/havline/internal/auth"
"github.com/havline/havline/internal/certificate"
"github.com/havline/havline/internal/cloudflared"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/db"
"github.com/havline/havline/internal/frp"
"github.com/havline/havline/internal/secret"
"github.com/havline/havline/internal/settings"
)
var routeParamPattern = regexp.MustCompile(`\{[^}]+\}`)
// TestProtectedRoutesRequireAuth 遍历路由表:受保护路由匿名必须 401,公开路由不能被鉴权误拦。
func TestProtectedRoutesRequireAuth(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
authSvc := auth.New(conn)
box, err := secret.NewBox("router-auth-test-key")
if err != nil {
t.Fatal(err)
}
cfg := config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
frpSvc := frp.NewService(cfg, frp.NewStore(conn), box, logger, certificate.NewStore(conn))
cfSvc := cloudflared.NewService(cfg, cloudflared.NewStore(conn), box, logger, nil)
handler, specs := newRouterWithSpecs(Deps{
Config: cfg,
Auth: authSvc,
FRPMulti: frpSvc,
Cloudflare: cfSvc,
Settings: settings.NewStore(conn),
Logger: logger,
})
protected, public := 0, 0
for _, spec := range specs {
req := httptest.NewRequest(routeMethod(spec.Pattern), sampleRoutePath(spec.Pattern), nil)
code := callRouteSafely(handler, req)
if spec.Public {
public++
if code == http.StatusUnauthorized {
t.Fatalf("公开路由 %s 不应被鉴权拦截", spec.Pattern)
}
continue
}
protected++
if code != http.StatusUnauthorized {
t.Fatalf("受保护路由 %s 匿名请求应返回 401,实际 %d", spec.Pattern, code)
}
}
if protected < 100 {
t.Fatalf("受保护路由覆盖不足:%d 条", protected)
}
if public < 4 {
t.Fatalf("公开路由覆盖不足:%d 条", public)
}
}
func routeMethod(pattern string) string {
method, _, _ := strings.Cut(pattern, " ")
return method
}
func sampleRoutePath(pattern string) string {
_, path, _ := strings.Cut(pattern, " ")
return routeParamPattern.ReplaceAllString(path, "1")
}
// callRouteSafely 在依赖缺失导致 handler panic 时返回 0,避免公开路由的构造依赖影响鉴权断言。
func callRouteSafely(handler http.Handler, req *http.Request) (code int) {
defer func() {
if recover() != nil {
code = 0
}
}()
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec.Code
}
+270
View File
@@ -0,0 +1,270 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/havline/havline/internal/notify"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/settings"
"github.com/havline/havline/internal/validate"
)
type SettingsHandler struct {
store *settings.Store
proxy *service.ProxyService
notify *notify.Service
}
func NewSettingsHandler(store *settings.Store, proxySvc *service.ProxyService, notifySvc *notify.Service) *SettingsHandler {
return &SettingsHandler{store: store, proxy: proxySvc, notify: notifySvc}
}
func (h *SettingsHandler) Get(w http.ResponseWriter, r *http.Request) {
values, err := h.store.List(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取设置失败", err)
return
}
notify.SanitizeSettings(values)
// 状态页访问码只回掩码:既不下发明文也不下发哈希
if values[settings.KeyStatusPageCodeHash] != "" {
values[settings.KeyStatusPageCode] = MaskedStatusPageCode
}
delete(values, settings.KeyStatusPageCodeHash)
writeJSON(w, http.StatusOK, values)
}
func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
var req map[string]string
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
allowed := map[string]bool{
settings.KeyTheme: true,
settings.KeyTimezone: true,
settings.KeyLogRetentionDays: true,
settings.KeyDDNSCheckInterval: true,
settings.KeyCertRenewThreshold: true,
settings.KeyRootDomain: true,
settings.KeyACMEEmail: true,
settings.KeyACMECA: true,
settings.KeyZeroSSLAPIKey: true,
settings.KeyACMEDNSResolvers: true,
settings.KeyLiteSSLEABKid: true,
settings.KeyLiteSSLEABHMAC: true,
settings.KeyNotifyWebhookURL: true,
settings.KeyNotifyType: true,
settings.KeyNotifyEmailJSON: true,
settings.KeyNotifySMTPPassword: true,
settings.KeyNotifyWebhookJSON: true,
settings.KeyNotifyWebhookSecret: true,
settings.KeyNotifyTelegramJSON: true,
settings.KeyNotifyTelegramToken: true,
settings.KeyNotifyOnDDNSIPChange: true,
settings.KeyNotifyOnDDNSFailure: true,
settings.KeyNotifyOnCertExpiry: true,
settings.KeyNotifyOnCertRenewSuccess: true,
settings.KeyNotifyOnIPFrequentAccess: true,
settings.KeyNotifyOnCertRenewFailure: true,
settings.KeyNotifyOnLoginFailure: true,
settings.KeyNotifyOnNginxReloadFailure: true,
settings.KeyNotifyIPFrequentThreshold: true,
settings.KeyNotifyIPFrequentWindowSec: true,
settings.KeyNotifyLoginFailureThreshold: true,
settings.KeyNotifyLoginFailureWindowSec: true,
settings.KeyNotifyOnResourceThreshold: true,
settings.KeyNotifyOnTunnelDown: true,
settings.KeyNotifyCPUThreshold: true,
settings.KeyNotifyMemoryThreshold: true,
settings.KeyNotifyDiskThreshold: true,
settings.KeyNotifyTunnelFailThreshold: true,
settings.KeyNotifyOnFrpsDown: true,
settings.KeyNotifyOnAgentUnreachable: true,
settings.KeyNotifyOnRouteDrift: true,
settings.KeyNotifyFrpsFailThreshold: true,
settings.KeyNotifyAgentFailThreshold: true,
settings.KeyNotifyDriftCooldownHours: true,
settings.KeyBackupAutoEnabled: true,
settings.KeyRouteHealthEnabled: true,
settings.KeyStatusPageEnabled: true,
settings.KeyMetricsHistoryEnabled: true,
settings.KeyStatusPageCode: true,
settings.KeyBackupKeepCount: true,
settings.KeyNotifyOnBackupFailure: true,
settings.KeyTrustedProxy: true,
settings.KeyGlobalIPBlacklist: true,
settings.KeyGlobalIPWhitelist: true,
settings.KeyChinaCIDRUpdateHours: true,
settings.KeyChinaCIDRSourceV4: true,
settings.KeyChinaCIDRSourceV6: true,
}
filtered := map[string]string{}
for key, value := range req {
if !allowed[key] {
continue
}
if err := h.validateSetting(key, value); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
filtered[key] = value
}
// 状态页访问码走「掩码往返」:客户端提交明文,这里落 SHA-256;
// 空串 = 清除访问码,掩码值 = 保持不变。哈希字段不接受客户端直接写入。
if code, ok := filtered[settings.KeyStatusPageCode]; ok {
delete(filtered, settings.KeyStatusPageCode)
trimmed := strings.TrimSpace(code)
switch {
case trimmed == MaskedStatusPageCode:
case trimmed == "":
filtered[settings.KeyStatusPageCodeHash] = ""
default:
filtered[settings.KeyStatusPageCodeHash] = hashStatusPageCode(trimmed)
}
}
if h.notify != nil && h.notify.Store() != nil {
if in, ok := notify.ParseSaveInputFromMap(filtered); ok {
if err := h.notify.Store().Save(r.Context(), in); err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
for key := range filtered {
if isNotifySettingKey(key) {
delete(filtered, key)
}
}
}
}
if err := h.store.SetMany(r.Context(), filtered); err != nil {
writeError(r, w, http.StatusInternalServerError, "保存设置失败", err)
return
}
if h.proxy != nil && (filtered[settings.KeyTrustedProxy] != "" || filtered[settings.KeyGlobalIPBlacklist] != "" || filtered[settings.KeyGlobalIPWhitelist] != "") {
if err := h.proxy.ReloadAll(r.Context()); err != nil {
writeError(r, w, http.StatusInternalServerError, "设置已保存,但 Nginx 重载失败:"+err.Error(), err)
return
}
}
values, err := h.store.List(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取设置失败", err)
return
}
notify.SanitizeSettings(values)
// 状态页访问码只回掩码:既不下发明文也不下发哈希
if values[settings.KeyStatusPageCodeHash] != "" {
values[settings.KeyStatusPageCode] = MaskedStatusPageCode
}
delete(values, settings.KeyStatusPageCodeHash)
writeJSON(w, http.StatusOK, values)
}
func isNotifySettingKey(key string) bool {
switch key {
case settings.KeyNotifyType,
settings.KeyNotifyEmailJSON,
settings.KeyNotifySMTPPassword,
settings.KeyNotifyWebhookJSON,
settings.KeyNotifyWebhookSecret,
settings.KeyNotifyTelegramJSON,
settings.KeyNotifyTelegramToken,
settings.KeyNotifyOnDDNSIPChange,
settings.KeyNotifyOnDDNSFailure,
settings.KeyNotifyOnCertExpiry,
settings.KeyNotifyOnCertRenewSuccess,
settings.KeyNotifyOnIPFrequentAccess,
settings.KeyNotifyOnCertRenewFailure,
settings.KeyNotifyOnLoginFailure,
settings.KeyNotifyOnNginxReloadFailure,
settings.KeyNotifyIPFrequentThreshold,
settings.KeyNotifyIPFrequentWindowSec,
settings.KeyNotifyLoginFailureThreshold,
settings.KeyNotifyLoginFailureWindowSec,
settings.KeyNotifyOnResourceThreshold,
settings.KeyNotifyOnTunnelDown,
settings.KeyNotifyCPUThreshold,
settings.KeyNotifyMemoryThreshold,
settings.KeyNotifyDiskThreshold,
settings.KeyNotifyTunnelFailThreshold,
settings.KeyNotifyOnFrpsDown,
settings.KeyNotifyOnAgentUnreachable,
settings.KeyNotifyOnRouteDrift,
settings.KeyNotifyFrpsFailThreshold,
settings.KeyNotifyAgentFailThreshold,
settings.KeyNotifyDriftCooldownHours,
settings.KeyNotifyOnBackupFailure,
settings.KeyNotifyWebhookURL:
return true
default:
return false
}
}
func (h *SettingsHandler) validateSetting(key, value string) error {
switch key {
case settings.KeyNotifyEmailJSON, settings.KeyNotifyWebhookJSON, settings.KeyNotifyTelegramJSON:
if value != "" {
var raw map[string]any
if err := json.Unmarshal([]byte(value), &raw); err != nil {
return err
}
}
case settings.KeyNotifyType:
switch value {
case "", "email", "webhook", "telegram":
default:
return fmt.Errorf("无效的通知类型")
}
case settings.KeyNotifyIPFrequentThreshold:
if value != "" {
n, err := strconv.Atoi(value)
if err != nil || n < 10 || n > 10000 {
return fmt.Errorf("频繁访问阈值需在 10–10000 之间")
}
}
case settings.KeyNotifyIPFrequentWindowSec, settings.KeyNotifyLoginFailureWindowSec:
if value != "" {
n, err := strconv.Atoi(value)
if err != nil || n < 10 || n > 3600 {
return fmt.Errorf("统计窗口需在 10–3600 秒之间")
}
}
case settings.KeyBackupKeepCount:
if value != "" {
n, err := strconv.Atoi(value)
if err != nil || n < 1 || n > 60 {
return fmt.Errorf("备份保留份数需在 1–60 之间")
}
}
case settings.KeyNotifyLoginFailureThreshold:
if value != "" {
n, err := strconv.Atoi(value)
if err != nil || n < 3 || n > 100 {
return fmt.Errorf("登录失败阈值需在 3–100 之间")
}
}
case settings.KeyGlobalIPBlacklist, settings.KeyGlobalIPWhitelist:
var list []string
if value != "" && value != "[]" {
if err := json.Unmarshal([]byte(value), &list); err != nil {
return err
}
if err := validate.IPOrCIDRList(list); err != nil {
return err
}
}
case settings.KeyTrustedProxy:
if value != "" && value != "{}" {
var raw map[string]any
if err := json.Unmarshal([]byte(value), &raw); err != nil {
return err
}
}
}
return nil
}
+231
View File
@@ -0,0 +1,231 @@
package api
import (
"context"
"fmt"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/havline/havline/internal/acme"
certstore "github.com/havline/havline/internal/certificate"
"github.com/havline/havline/internal/cloudflared"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/ddns"
"github.com/havline/havline/internal/logstore"
"github.com/havline/havline/internal/publicip"
"github.com/havline/havline/internal/service"
"github.com/havline/havline/internal/sysinfo"
)
type StatusHandler struct {
cfg config.Config
proxySvc *service.ProxyService
ddnsSvc *ddns.Service
acmeSvc *acme.Service
cloudflareSvc *cloudflared.Service
startedAt string
nginxPIDFile string
cpuMu sync.Mutex
cpuSample sysinfo.CPUSample
}
func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc *ddns.Service, acmeSvc *acme.Service, cloudflareSvc *cloudflared.Service, startedAt, nginxPIDFile string) *StatusHandler {
// 先采一次 CPU 计数器,使首次请求就能算出占用率(否则只能返回 0)
_, sample := sysinfo.Read(sysinfo.CPUSample{})
return &StatusHandler{
cfg: cfg,
proxySvc: proxySvc,
ddnsSvc: ddnsSvc,
acmeSvc: acmeSvc,
cloudflareSvc: cloudflareSvc,
startedAt: startedAt,
nginxPIDFile: nginxPIDFile,
cpuSample: sample,
}
}
type statusResponse struct {
PublicIPv4 string `json:"public_ipv4"`
PublicIPv6 string `json:"public_ipv6"`
PublicIPSource string `json:"public_ip_source"`
DDNSStatus string `json:"ddns_status"`
DDNSCount int `json:"ddns_count"`
DDNSLastUpdated string `json:"ddns_last_updated,omitempty"`
CertificateStatus string `json:"certificate_status"`
CertificateDays int `json:"certificate_days"`
CertificateCount int `json:"certificate_count"`
CertificateSummary string `json:"certificate_summary,omitempty"`
ProxyCount int `json:"proxy_count"`
NginxStatus string `json:"nginx_status"`
RequestToday int `json:"request_today"`
RequestYesterday int `json:"request_yesterday"`
RequestTrend *int `json:"request_trend,omitempty"`
RequestsHourly []int `json:"requests_hourly"`
RequestsHourlyLabels []string `json:"requests_hourly_labels"`
ErrorToday int `json:"error_today"`
AvgResponseMs float64 `json:"avg_response_ms"`
CPUPercent float64 `json:"cpu_percent"`
MemUsedBytes uint64 `json:"mem_used_bytes"`
MemTotalBytes uint64 `json:"mem_total_bytes"`
DiskUsedBytes uint64 `json:"disk_used_bytes"`
DiskTotalBytes uint64 `json:"disk_total_bytes"`
CloudflareCount int `json:"cloudflare_count"`
CloudflareOnline int `json:"cloudflare_online"`
CloudflareStatus string `json:"cloudflare_status"`
CloudflareMessage string `json:"cloudflare_message,omitempty"`
StartedAt string `json:"started_at"`
UptimeSeconds int64 `json:"uptime_seconds"`
}
func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
rules, err := h.proxySvc.List(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败")
return
}
nginxStatus := "unknown"
if _, err := os.Stat(h.nginxPIDFile); err == nil {
nginxStatus = "running"
} else if os.IsNotExist(err) {
nginxStatus = "stopped"
}
ddnsStatus, ddnsLastUpdated, ddnsCount := h.ddnsSvc.Summary(r.Context())
ipv4, ipv6, ipSource := h.resolvePublicIPs(r.Context())
records, _ := h.acmeSvc.List(r.Context())
certStatus, certDays, certCount, certSummary := summarizeCertificates(records)
resources, cpuSample := sysinfo.Read(h.currentCPUSample())
h.setCPUSample(cpuSample)
cfSummary := cloudflared.TunnelSummary{Status: "none", Message: "未创建隧道"}
if h.cloudflareSvc != nil {
cfSummary = h.cloudflareSvc.Summary(r.Context())
}
accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
total, errors, avgMs := logstore.CountTodayAccess(accessLog)
yesterday, _, _ := logstore.CountAccessForDay(accessLog, time.Now().AddDate(0, 0, -1))
hourly, hourlyLabels := logstore.HourlyAccessCounts(accessLog)
started, _ := time.Parse(time.RFC3339, h.startedAt)
uptime := time.Since(started).Seconds()
writeJSON(w, http.StatusOK, statusResponse{
PublicIPv4: ipv4,
PublicIPv6: ipv6,
PublicIPSource: ipSource,
DDNSStatus: ddnsStatus,
DDNSCount: ddnsCount,
DDNSLastUpdated: ddnsLastUpdated,
CertificateStatus: certStatus,
CertificateDays: certDays,
CertificateCount: certCount,
CertificateSummary: certSummary,
ProxyCount: len(rules),
NginxStatus: nginxStatus,
RequestToday: total,
RequestYesterday: yesterday,
RequestTrend: requestTrendPercent(total, yesterday),
RequestsHourly: hourly,
RequestsHourlyLabels: hourlyLabels,
ErrorToday: errors,
AvgResponseMs: avgMs,
CPUPercent: resources.CPUPercent,
MemUsedBytes: resources.MemUsedBytes,
MemTotalBytes: resources.MemTotalBytes,
DiskUsedBytes: resources.DiskUsedBytes,
DiskTotalBytes: resources.DiskTotalBytes,
CloudflareCount: cfSummary.Count,
CloudflareOnline: cfSummary.Online,
CloudflareStatus: cfSummary.Status,
CloudflareMessage: cfSummary.Message,
StartedAt: h.startedAt,
UptimeSeconds: int64(uptime),
})
}
// currentCPUSample 与 setCPUSample 用锁保护上次采样,/api/status 可能被并发调用
func (h *StatusHandler) currentCPUSample() sysinfo.CPUSample {
h.cpuMu.Lock()
defer h.cpuMu.Unlock()
return h.cpuSample
}
func (h *StatusHandler) setCPUSample(sample sysinfo.CPUSample) {
h.cpuMu.Lock()
defer h.cpuMu.Unlock()
h.cpuSample = sample
}
func (h *StatusHandler) resolvePublicIPs(ctx context.Context) (ipv4, ipv6, source string) {
if ipv4, ipv6, ok := h.ddnsSvc.PublicIPs(ctx); ok {
return ipv4, ipv6, "ddns"
}
if ipv4, ipv6, ok := h.ddnsSvc.LastKnownPublicIPs(ctx); ok {
return ipv4, ipv6, "ddns"
}
detectCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
ipv4, ipv6, err := publicip.Detect(detectCtx)
if err != nil {
return "", ipv6, "none"
}
return ipv4, ipv6, "detect"
}
func summarizeCertificates(records []certstore.Record) (status string, days int, count int, summary string) {
if len(records) == 0 {
return "none", 0, 0, ""
}
count = len(records)
status = "ok"
days = records[0].DaysLeft
for _, rec := range records {
if rec.DaysLeft < days {
days = rec.DaysLeft
}
switch {
case rec.Status == "error" || rec.DaysLeft <= 0:
status = "error"
case rec.DaysLeft <= 30 && status != "error":
status = "warning"
}
}
if count == 1 {
summary = certificateSummaryName(records[0])
} else {
summary = fmt.Sprintf("%d 张证书", count)
}
return status, days, count, summary
}
func requestTrendPercent(today, yesterday int) *int {
if yesterday <= 0 {
if today <= 0 {
return nil
}
v := 100
return &v
}
v := int(math.Round(float64(today-yesterday) / float64(yesterday) * 100))
return &v
}
func certificateSummaryName(rec certstore.Record) string {
for _, domain := range rec.Domains {
if strings.HasPrefix(domain, "*.") {
return domain
}
}
if rec.Wildcard {
return "*." + rec.Domain
}
return rec.Domain
}
@@ -0,0 +1,18 @@
package api
import "testing"
func TestRequestTrendPercent(t *testing.T) {
if got := requestTrendPercent(0, 0); got != nil {
t.Fatalf("expected nil trend, got %v", *got)
}
if got := requestTrendPercent(10, 0); got == nil || *got != 100 {
t.Fatalf("expected 100 for new traffic, got %v", got)
}
if got := requestTrendPercent(150, 100); got == nil || *got != 50 {
t.Fatalf("expected 50, got %v", got)
}
if got := requestTrendPercent(50, 100); got == nil || *got != -50 {
t.Fatalf("expected -50, got %v", got)
}
}
+252
View File
@@ -0,0 +1,252 @@
package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/havline/havline/internal/cloudflared"
"github.com/havline/havline/internal/frp"
"github.com/havline/havline/internal/notify"
"github.com/havline/havline/internal/settings"
)
// statusPageWindow / statusPageLimit 匿名访问的限流窗口与配额:
// 状态页会被扫描器盯上,这里比登录接口更宽松但仍有限度。
const (
statusPageWindow = time.Minute
statusPageLimit = 60
)
// StatusPageHandler 提供**匿名只读**状态页数据,默认关闭。
//
// 刻意只暴露域名与可用率:不带内网地址、端口、证书路径、服务端地址——
// 状态页是要挂到公网上的东西,泄露拓扑就等于把内网结构摊开给别人看。
type StatusPageHandler struct {
settings *settings.Store
frp *frp.Service
cloudflare *cloudflared.Service
limiter *statusPageLimiter
}
func NewStatusPageHandler(settingsStore *settings.Store, frpSvc *frp.Service, cloudflareSvc *cloudflared.Service) *StatusPageHandler {
return &StatusPageHandler{
settings: settingsStore,
frp: frpSvc,
cloudflare: cloudflareSvc,
limiter: newStatusPageLimiter(),
}
}
type statusPageRoute struct {
Domain string `json:"domain"`
State string `json:"state"`
Source string `json:"source,omitempty"`
Uptime float64 `json:"uptime_pct"`
DownMinutes int `json:"down_minutes"`
UnknownMinutes int `json:"unknown_minutes"`
Incidents []statusPageIncident `json:"incidents"`
}
type statusPageIncident struct {
From string `json:"from"`
To string `json:"to,omitempty"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
Minutes int `json:"minutes"`
}
type statusPagePayload struct {
Title string `json:"title"`
CodeRequired bool `json:"code_required"`
CheckedAt string `json:"checked_at"`
Up int `json:"up"`
Down int `json:"down"`
Unknown int `json:"unknown"`
Routes []statusPageRoute `json:"routes"`
}
// Get 是唯一的匿名入口:关闭时一律 404(与「不存在」不可区分),
// 设了访问码时校验 ?code=,校验失败也回 404。
func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
if h == nil || h.settings == nil {
writeError(r, w, http.StatusNotFound, "状态页未开启")
return
}
if !h.limiter.allow(clientIPForLimit(r)) {
writeError(r, w, http.StatusTooManyRequests, "请求过于频繁")
return
}
enabled, _ := h.settings.GetBool(r.Context(), settings.KeyStatusPageEnabled)
if !enabled || (h.frp == nil && h.cloudflare == nil) {
writeError(r, w, http.StatusNotFound, "状态页未开启")
return
}
codeHash, err := h.settings.Get(r.Context(), settings.KeyStatusPageCodeHash)
if err != nil {
codeHash = ""
}
codeRequired := strings.TrimSpace(codeHash) != ""
if codeRequired && hashStatusPageCode(r.URL.Query().Get("code")) != codeHash {
writeError(r, w, http.StatusNotFound, "状态页未开启")
return
}
payload := statusPagePayload{
Title: "Havline 服务状态",
CodeRequired: codeRequired,
CheckedAt: time.Now().UTC().Format(time.RFC3339),
Routes: make([]statusPageRoute, 0),
}
if err := appendFRPStatus(&payload, h.frp, r.Context()); err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
return
}
if err := appendCloudflareStatus(&payload, h.cloudflare, r.Context()); err != nil {
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
return
}
writeJSON(w, http.StatusOK, payload)
}
func appendFRPStatus(payload *statusPagePayload, svc *frp.Service, ctx context.Context) error {
if svc == nil {
return nil
}
stats, err := svc.RouteUptime(ctx, 24*time.Hour)
if err != nil {
return err
}
for _, stat := range stats {
switch stat.State {
case frp.RouteHealthUp:
payload.Up++
case frp.RouteHealthDown:
payload.Down++
default:
payload.Unknown++
}
payload.Routes = append(payload.Routes, statusPageRoute{
Domain: stat.Domain,
State: stat.State,
Source: "frp",
Uptime: stat.Uptime,
DownMinutes: stat.DownMinutes,
UnknownMinutes: stat.UnknownMinutes,
Incidents: mapFRPIncidents(stat.Incidents),
})
}
return nil
}
func appendCloudflareStatus(payload *statusPagePayload, svc *cloudflared.Service, ctx context.Context) error {
if svc == nil {
return nil
}
stats, err := svc.HealthUptime(ctx, 24*time.Hour)
if err != nil {
return err
}
for _, stat := range stats {
switch stat.State {
case cloudflared.HealthUp:
payload.Up++
case cloudflared.HealthDown:
payload.Down++
default:
payload.Unknown++
}
payload.Routes = append(payload.Routes, statusPageRoute{
Domain: stat.Domain,
State: stat.State,
Source: "cloudflare",
Uptime: stat.Uptime,
DownMinutes: stat.DownMinutes,
UnknownMinutes: stat.UnknownMinutes,
Incidents: mapCloudflareIncidents(stat.Incidents),
})
}
return nil
}
func mapFRPIncidents(items []frp.RouteHealthIncident) []statusPageIncident {
out := make([]statusPageIncident, 0, len(items))
for _, item := range items {
out = append(out, statusPageIncident{
From: item.From, To: item.To, Reason: item.Reason, Detail: item.Detail, Minutes: item.Minutes,
})
}
return out
}
func mapCloudflareIncidents(items []cloudflared.HealthIncident) []statusPageIncident {
out := make([]statusPageIncident, 0, len(items))
for _, item := range items {
out = append(out, statusPageIncident{
From: item.From, To: item.To, Reason: item.Reason, Detail: item.Detail, Minutes: item.Minutes,
})
}
return out
}
func hashStatusPageCode(code string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(code)))
return hex.EncodeToString(sum[:])
}
// clientIPForLimit 取限流用的客户端 IP
func clientIPForLimit(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// statusPageLimiter 按 IP 的滑动窗口限流;只给匿名状态页用
type statusPageLimiter struct {
mu sync.Mutex
hits map[string][]time.Time
now func() time.Time
}
func newStatusPageLimiter() *statusPageLimiter {
return &statusPageLimiter{hits: map[string][]time.Time{}, now: time.Now}
}
func (l *statusPageLimiter) allow(ip string) bool {
now := l.now()
cutoff := now.Add(-statusPageWindow)
l.mu.Lock()
defer l.mu.Unlock()
kept := make([]time.Time, 0, len(l.hits[ip])+1)
for _, at := range l.hits[ip] {
if at.After(cutoff) {
kept = append(kept, at)
}
}
if len(kept) >= statusPageLimit {
l.hits[ip] = kept
return false
}
l.hits[ip] = append(kept, now)
// 顺手清理长期不活跃的 IP,避免 map 无限增长
if len(l.hits) > 1024 {
for key, times := range l.hits {
if len(times) == 0 {
delete(l.hits, key)
}
}
}
return true
}
// MaskedStatusPageCode 供设置接口回显用(与通知模块的掩码约定一致)
const MaskedStatusPageCode = notify.MaskedSecret
@@ -0,0 +1,43 @@
package api
import (
"testing"
"time"
)
func TestStatusPageLimiterBlocksAfterLimit(t *testing.T) {
limiter := newStatusPageLimiter()
now := time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC)
limiter.now = func() time.Time { return now }
for i := 0; i < statusPageLimit; i++ {
if !limiter.allow("1.2.3.4") {
t.Fatalf("第 %d 次请求应放行", i+1)
}
}
if limiter.allow("1.2.3.4") {
t.Fatal("超过配额应拒绝")
}
// 限流按 IP 计:别的访客不该被牵连
if !limiter.allow("5.6.7.8") {
t.Fatal("其它 IP 不应被牵连")
}
// 窗口滑过后恢复
now = now.Add(statusPageWindow + time.Second)
if !limiter.allow("1.2.3.4") {
t.Fatal("窗口过后应恢复放行")
}
}
func TestHashStatusPageCodeHidesPlaintext(t *testing.T) {
hash := hashStatusPageCode("my-secret")
if hash == "my-secret" || len(hash) != 64 {
t.Fatalf("访问码应以 SHA-256 存储,实际 %q", hash)
}
if hashStatusPageCode(" my-secret ") != hash {
t.Fatal("前后空白裁剪后应得到同一哈希")
}
if hashStatusPageCode("other") == hash {
t.Fatal("不同访问码不应得到同一哈希")
}
}
+72
View File
@@ -0,0 +1,72 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/havline/havline/internal/auth"
)
// 令牌管理挂在 AuthHandler 上:它已经持有 auth.Service,不必再加一层 handler。
const maxTokenExpiryDays = 3650
type createTokenRequest struct {
Name string `json:"name"`
Scope string `json:"scope"`
ExpiresInDays int `json:"expires_in_days"`
}
func (h *AuthHandler) ListTokens(w http.ResponseWriter, r *http.Request) {
tokens, err := h.auth.ListAPITokens(r.Context())
if err != nil {
writeError(r, w, http.StatusInternalServerError, "读取令牌失败", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"tokens": tokens})
}
// CreateToken 新建令牌;明文只在这一个响应里返回,之后无法再取回。
func (h *AuthHandler) CreateToken(w http.ResponseWriter, r *http.Request) {
var req createTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(r, w, http.StatusBadRequest, "请求格式无效")
return
}
scope := strings.TrimSpace(req.Scope)
if scope == "" {
scope = auth.TokenScopeRead
}
var expiresAt time.Time
if req.ExpiresInDays > 0 {
if req.ExpiresInDays > maxTokenExpiryDays {
writeError(r, w, http.StatusBadRequest, "有效期最长 3650 天")
return
}
expiresAt = time.Now().AddDate(0, 0, req.ExpiresInDays)
}
token, err := h.auth.CreateAPIToken(r.Context(), req.Name, scope, expiresAt)
if err != nil {
writeError(r, w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, token)
}
func (h *AuthHandler) DeleteToken(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(r, w, http.StatusBadRequest, "无效的令牌 ID")
return
}
if err := h.auth.DeleteAPIToken(r.Context(), id); err != nil {
if err.Error() == "令牌不存在" {
writeError(r, w, http.StatusNotFound, err.Error())
return
}
writeError(r, w, http.StatusInternalServerError, "删除令牌失败", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+45
View File
@@ -0,0 +1,45 @@
package api
import (
"net/http"
"github.com/havline/havline/internal/updater"
)
type UpdateHandler struct {
client *updater.Client
}
func NewUpdateHandler(client *updater.Client) *UpdateHandler {
return &UpdateHandler{client: client}
}
func (h *UpdateHandler) Status(w http.ResponseWriter, r *http.Request) {
if h.client == nil || !h.client.Enabled() {
writeJSON(w, http.StatusOK, updater.Status{
Enabled: false,
Phase: updater.PhaseIdle,
Message: "当前部署未启用一键升级",
})
return
}
status, err := h.client.Status(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, "读取升级状态失败", err)
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *UpdateHandler) Apply(w http.ResponseWriter, r *http.Request) {
if h.client == nil || !h.client.Enabled() {
writeError(r, w, http.StatusServiceUnavailable, "当前部署未启用一键升级")
return
}
status, err := h.client.Apply(r.Context())
if err != nil {
writeError(r, w, http.StatusBadGateway, "启动升级失败", err)
return
}
writeJSON(w, http.StatusAccepted, status)
}
@@ -0,0 +1,29 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestUpdateHandlerDisabled(t *testing.T) {
handler := NewUpdateHandler(nil)
statusRecorder := httptest.NewRecorder()
statusReq := httptest.NewRequest(http.MethodGet, "/api/system/update/status", nil)
handler.Status(statusRecorder, statusReq)
if statusRecorder.Code != http.StatusOK {
t.Fatalf("状态接口期望 200,实际 %d", statusRecorder.Code)
}
if body := statusRecorder.Body.String(); !strings.Contains(body, `"enabled":false`) {
t.Fatalf("未返回 enabled=false: %s", body)
}
applyRecorder := httptest.NewRecorder()
applyReq := httptest.NewRequest(http.MethodPost, "/api/system/update/apply", nil)
handler.Apply(applyRecorder, applyReq)
if applyRecorder.Code != http.StatusServiceUnavailable {
t.Fatalf("升级接口期望 503,实际 %d", applyRecorder.Code)
}
}
+24
View File
@@ -0,0 +1,24 @@
package api
import (
"net/http"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/version"
)
type versionResponse struct {
Version string `json:"version"`
NginxHTTPPort int `json:"nginx_http_port"`
NginxHTTPSPort int `json:"nginx_https_port"`
}
func Version(cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, versionResponse{
Version: version.Version,
NginxHTTPPort: cfg.NginxDefaultHTTPPort,
NginxHTTPSPort: cfg.NginxDefaultHTTPSPort,
})
}
}
@@ -0,0 +1,36 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/havline/havline/internal/config"
"github.com/havline/havline/internal/version"
)
func TestVersionEndpoint(t *testing.T) {
version.Version = "0.1.6"
cfg := config.Config{NginxDefaultHTTPPort: 18080, NginxDefaultHTTPSPort: 9443}
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
rec := httptest.NewRecorder()
Version(cfg)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body["version"] != "0.1.6" {
t.Fatalf("version %q", body["version"])
}
if body["nginx_http_port"] != float64(18080) {
t.Fatalf("nginx_http_port %v", body["nginx_http_port"])
}
if body["nginx_https_port"] != float64(9443) {
t.Fatalf("nginx_https_port %v", body["nginx_https_port"])
}
}