feat: create standalone native fnOS repository
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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": "测试通知已发送"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("不同访问码不应得到同一哈希")
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user