- 内置 SSH 终端(/terminal):复用已保存的 SSH 密码/私钥与独立 known_hosts 校验 - 终端二次认证、会话审计(/terminal/audits)、录像审计与常用命令 - DDNS 服务商名称与自定义 IP,新增迁移 037/038/039 - CHANGELOG 与 VERSION 更新至 2.0.0
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 用临时参数跑探测,避免单测真的等满默认的 1.5 秒间隔
|
||||
func stubProbeRetry(t *testing.T, attempts int) {
|
||||
t.Helper()
|
||||
oldAttempts, oldInterval := routeProbeAttempts, routeProbeInterval
|
||||
routeProbeAttempts, routeProbeInterval = attempts, time.Millisecond
|
||||
t.Cleanup(func() { routeProbeAttempts, routeProbeInterval = oldAttempts, oldInterval })
|
||||
}
|
||||
|
||||
func probePort(t *testing.T, server *httptest.Server) int {
|
||||
t.Helper()
|
||||
addr, ok := server.Listener.Addr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
t.Fatalf("无法取到测试服务端口:%v", server.Listener.Addr())
|
||||
}
|
||||
return addr.Port
|
||||
}
|
||||
|
||||
// TestProbeVhostReadyWaitsForRegistration:前两次 404(隧道尚未注册)后应继续重试并拿到 200,
|
||||
// 这正是「部署后立刻探测误报失败、刷新又正常」的场景。
|
||||
func TestProbeVhostReadyWaitsForRegistration(t *testing.T) {
|
||||
stubProbeRetry(t, 4)
|
||||
var hits int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if atomic.AddInt32(&hits, 1) <= 2 {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
code, _ := (&Server{}).probeVhostReady(probePort(t, server), "a.example.com")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("期望重试后拿到 200,实际 %d", code)
|
||||
}
|
||||
if hits != 3 {
|
||||
t.Fatalf("期望探测 3 次后成功,实际 %d 次", hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbeVhostReadyStopsOnSuccess:非 404 立即返回,不做多余重试。
|
||||
func TestProbeVhostReadyStopsOnSuccess(t *testing.T) {
|
||||
stubProbeRetry(t, 4)
|
||||
var hits int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
code, _ := (&Server{}).probeVhostReady(probePort(t, server), "a.example.com")
|
||||
if code != http.StatusBadGateway {
|
||||
t.Fatalf("期望原样返回 502,实际 %d", code)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("502 不应重试,实际探测 %d 次", hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProbeVhostReadyGivesUpAfterAttempts:一直 404 时用尽重试次数并返回最后一次结果。
|
||||
func TestProbeVhostReadyGivesUpAfterAttempts(t *testing.T) {
|
||||
stubProbeRetry(t, 3)
|
||||
var hits int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
code, _ := (&Server{}).probeVhostReady(probePort(t, server), "a.example.com")
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("期望最终仍为 404,实际 %d", code)
|
||||
}
|
||||
if hits != 3 {
|
||||
t.Fatalf("期望探测 3 次后放弃,实际 %d 次", hits)
|
||||
}
|
||||
}
|
||||
+50
-18
@@ -31,7 +31,7 @@ import (
|
||||
"github.com/havline/havline/internal/sysinfo"
|
||||
)
|
||||
|
||||
const AgentVersion = "0.15.2"
|
||||
const AgentVersion = "0.15.3"
|
||||
|
||||
// Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||
type Config struct {
|
||||
@@ -1029,6 +1029,31 @@ func (s *Server) putRoute(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "VPS 未安装 Nginx(或不在常见路径):请先安装(apt install nginx / yum install nginx),或在「公网反代」页点「一键安装 Nginx」后再部署"})
|
||||
return
|
||||
}
|
||||
// 写配置串行化;回源自检不持锁,避免重试等待期间阻塞其他路由操作
|
||||
generatedPassword, writeErr := s.writeRouteLocked(req)
|
||||
if writeErr != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": writeErr.Error()})
|
||||
return
|
||||
}
|
||||
result := map[string]any{"ok": true, "domain": req.Domain, "websocket": req.WebSocket}
|
||||
if generatedPassword != "" {
|
||||
result["basic_auth_user"] = strings.TrimSpace(req.BasicAuthUser)
|
||||
result["basic_auth_password"] = generatedPassword
|
||||
}
|
||||
// 部署后回源自检:模拟 Nginx 的回源请求,区分三种状态:
|
||||
// 2xx/3xx = 隧道通且服务响应;404 = 反代/frps 正常但无此域名的隧道注册;0/5xx = 回源失败或内网服务异常
|
||||
if vhost := req.VhostPort; vhost > 0 {
|
||||
code, body := s.probeVhostReady(vhost, req.Domain)
|
||||
result["probe_status"] = code
|
||||
result["probe_ok"] = code >= 200 && code < 400
|
||||
result["probe_notfound"] = code == 404
|
||||
result["probe_body_snip"] = body
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// writeRouteLocked 串行化写入路由配置,返回首次自动生成的 Basic Auth 明文(仅本次响应返回一次)。
|
||||
func (s *Server) writeRouteLocked(req routeRequest) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// Basic Auth 密码留空时首次自动生成:生成的明文只在本次响应里回一次,
|
||||
@@ -1044,24 +1069,31 @@ func (s *Server) putRoute(w http.ResponseWriter, r *http.Request) {
|
||||
// 部署失败就丢弃刚生成的凭据,否则重试时文件已存在、密码再也拿不到
|
||||
_ = os.Remove(basicAuthFilePath(req.Domain))
|
||||
}
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
return "", err
|
||||
}
|
||||
result := map[string]any{"ok": true, "domain": req.Domain, "websocket": req.WebSocket}
|
||||
if generatedPassword != "" {
|
||||
result["basic_auth_user"] = strings.TrimSpace(req.BasicAuthUser)
|
||||
result["basic_auth_password"] = generatedPassword
|
||||
return generatedPassword, nil
|
||||
}
|
||||
|
||||
// 回源自检的重试参数:刚应用完 frpc 配置时隧道可能还没注册到 frps,单测里会调小间隔。
|
||||
var (
|
||||
routeProbeAttempts = 6
|
||||
routeProbeInterval = 1500 * time.Millisecond
|
||||
)
|
||||
|
||||
// probeVhostReady 与 probeVhost 相同,但在 404 时短时间重试:frpc 应用配置后需要几秒
|
||||
// 才把域名注册到 frps,立即探测会误报「没有该域名的隧道」。
|
||||
func (s *Server) probeVhostReady(port int, host string) (int, string) {
|
||||
code, body := 0, ""
|
||||
for attempt := 0; attempt < routeProbeAttempts; attempt++ {
|
||||
code, body = s.probeVhost(port, host)
|
||||
if code != http.StatusNotFound {
|
||||
return code, body
|
||||
}
|
||||
if attempt < routeProbeAttempts-1 {
|
||||
time.Sleep(routeProbeInterval)
|
||||
}
|
||||
}
|
||||
// 部署后回源自检:模拟 Nginx 的回源请求,区分三种状态:
|
||||
// 2xx/3xx = 隧道通且服务响应;404 = 反代/frps 正常但无此域名的隧道注册;0/5xx = 回源失败或内网服务异常
|
||||
if vhost := req.VhostPort; vhost > 0 {
|
||||
code, body := s.probeVhost(vhost, req.Domain)
|
||||
result["probe_status"] = code
|
||||
result["probe_ok"] = code >= 200 && code < 400
|
||||
result["probe_notfound"] = code == 404
|
||||
result["probe_body_snip"] = body
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
return code, body
|
||||
}
|
||||
|
||||
// probeVhost 从 VPS 本机模拟 Nginx 回源:返回 HTTP 状态码与响应片段(限长)。
|
||||
@@ -1309,7 +1341,7 @@ func (s *Server) writeRoute(req routeRequest) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// putRoute 已持有 s.mu;这里不能再次加锁,否则会发生自锁死。
|
||||
// writeRouteLocked 已持有 s.mu;这里不能再次加锁,否则会发生自锁死。
|
||||
s.cfg.NginxConfDir = dir
|
||||
}
|
||||
certDir := req.CertDir
|
||||
|
||||
Reference in New Issue
Block a user