feat(agent): 增加 HTTPS pin 模式并修复隧道端口竞态
This commit is contained in:
+31
-26
@@ -220,6 +220,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/v1/routes/{domain}/versions/{name}/rollback", s.auth(s.rollbackRoute))
|
||||
mux.HandleFunc("PUT /api/v1/china-cidr", s.auth(s.putChinaCIDR))
|
||||
mux.HandleFunc("POST /api/v1/nginx/install", s.auth(s.installNginx))
|
||||
mux.HandleFunc("PUT /api/v1/transport/https", s.auth(s.putHTTPSProxy))
|
||||
mux.HandleFunc("DELETE /api/v1/transport/https", s.auth(s.deleteHTTPSProxy))
|
||||
mux.HandleFunc("DELETE /api/v1/routes/{domain}", s.auth(s.deleteRoute))
|
||||
mux.HandleFunc("PUT /api/v1/frps/config", s.auth(s.putFRPSConfig))
|
||||
mux.HandleFunc("POST /api/v1/frps/install", s.auth(s.installFRPS))
|
||||
@@ -278,33 +280,36 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
|
||||
ports := map[string]bool{"80": portListening(80), "443": portListening(443), "7000": portListening(7000), "8080": portListening(8080)}
|
||||
nginxProbe := s.probeNginx()
|
||||
frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
|
||||
httpsState, httpsConfigured := readHTTPSProxyState(s.cfg.DataDir)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"service": "fonu-agent",
|
||||
"agent_version": AgentVersion,
|
||||
"listen_addr": s.cfg.ListenAddr,
|
||||
"listen_scope": listenScope(s.listenLoopback),
|
||||
"remote_access_enabled": s.cfg.AllowRemote,
|
||||
"transport_security": transportSecurity(s.listenLoopback),
|
||||
"nginx_binary": s.cfg.NginxBinary,
|
||||
"nginx_conf_dir": s.cfg.NginxConfDir,
|
||||
"nginx_ok": nginxProbe.OK,
|
||||
"nginx_missing": nginxProbe.Missing,
|
||||
"nginx_running": nginxProbe.Running,
|
||||
"nginx_path": nginxProbe.Path,
|
||||
"nginx_version": nginxProbe.Version,
|
||||
"nginx_error": nginxProbe.Error,
|
||||
"frps_binary": s.cfg.FrpsBinary,
|
||||
"frps_config_path": s.cfg.FrpsConfigPath,
|
||||
"ports": ports,
|
||||
"frps_active": frpsActive,
|
||||
"frps_vhost_hint": "curl -H 'Host: 你的域名' http://127.0.0.1:8080 可测试 frps vhost 是否通",
|
||||
"frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")),
|
||||
"frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
|
||||
"frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)),
|
||||
"frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)),
|
||||
"dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
|
||||
"routes": s.routeNames(),
|
||||
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"service": "fonu-agent",
|
||||
"agent_version": AgentVersion,
|
||||
"listen_addr": s.cfg.ListenAddr,
|
||||
"listen_scope": listenScope(s.listenLoopback),
|
||||
"remote_access_enabled": s.cfg.AllowRemote,
|
||||
"transport_security": transportSecurity(s.listenLoopback),
|
||||
"https_proxy_configured": httpsConfigured,
|
||||
"https_proxy_port": httpsState.Port,
|
||||
"nginx_binary": s.cfg.NginxBinary,
|
||||
"nginx_conf_dir": s.cfg.NginxConfDir,
|
||||
"nginx_ok": nginxProbe.OK,
|
||||
"nginx_missing": nginxProbe.Missing,
|
||||
"nginx_running": nginxProbe.Running,
|
||||
"nginx_path": nginxProbe.Path,
|
||||
"nginx_version": nginxProbe.Version,
|
||||
"nginx_error": nginxProbe.Error,
|
||||
"frps_binary": s.cfg.FrpsBinary,
|
||||
"frps_config_path": s.cfg.FrpsConfigPath,
|
||||
"ports": ports,
|
||||
"frps_active": frpsActive,
|
||||
"frps_vhost_hint": "curl -H 'Host: 你的域名' http://127.0.0.1:8080 可测试 frps vhost 是否通",
|
||||
"frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")),
|
||||
"frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
|
||||
"frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)),
|
||||
"frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)),
|
||||
"dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
|
||||
"routes": s.routeNames(),
|
||||
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
)
|
||||
|
||||
const httpsProxyConfigName = "fonuagent-https.conf"
|
||||
|
||||
type httpsProxyRequest struct {
|
||||
CertPEM string `json:"cert_pem"`
|
||||
KeyPEM string `json:"key_pem"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
type httpsProxyState struct {
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
func (s *Server) putHTTPSProxy(w http.ResponseWriter, r *http.Request) {
|
||||
var req httpsProxyRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if req.Port <= 0 || req.Port > 65535 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口需在 1-65535 之间"})
|
||||
return
|
||||
}
|
||||
if _, err := tls.X509KeyPair([]byte(req.CertPEM), []byte(req.KeyPEM)); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "证书或私钥格式无效"})
|
||||
return
|
||||
}
|
||||
if probe := s.probeNginx(); probe.Missing {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "VPS 未安装 Nginx,无法启用 HTTPS 直连"})
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.configureHTTPSProxy(req); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "port": req.Port})
|
||||
}
|
||||
|
||||
func (s *Server) deleteHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.removeHTTPSProxy(); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
|
||||
dir, err := s.ensureNginxDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tlsDir := "/etc/fonu-agent/tls"
|
||||
if err := os.MkdirAll(tlsDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
certPath := filepath.Join(tlsDir, "agent.crt")
|
||||
keyPath := filepath.Join(tlsDir, "agent.key")
|
||||
if err := atomicWrite(certPath, []byte(req.CertPEM), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := atomicWrite(keyPath, []byte(req.KeyPEM), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, httpsProxyConfigName)
|
||||
previous, readErr := os.ReadFile(path)
|
||||
if _, err := fsutil.BackupVersioned(path, 10); err != nil {
|
||||
return err
|
||||
}
|
||||
content := renderHTTPSProxy(req.Port, certPath, keyPath)
|
||||
if err := atomicWrite(path, []byte(content), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateNginx(); err != nil {
|
||||
s.restoreHTTPSConfig(path, previous, readErr)
|
||||
return fmt.Errorf("nginx config rejected: %w", err)
|
||||
}
|
||||
if err := s.reloadNginx(); err != nil {
|
||||
s.restoreHTTPSConfig(path, previous, readErr)
|
||||
return err
|
||||
}
|
||||
return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: req.Port})
|
||||
}
|
||||
|
||||
func (s *Server) removeHTTPSProxy() error {
|
||||
dir, err := s.ensureNginxDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, httpsProxyConfigName)
|
||||
previous, readErr := os.ReadFile(path)
|
||||
if errors.Is(readErr, os.ErrNotExist) {
|
||||
_ = os.Remove(httpsProxyStatePath(s.cfg.DataDir))
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := s.validateNginx(); err != nil {
|
||||
s.restoreHTTPSConfig(path, previous, readErr)
|
||||
return err
|
||||
}
|
||||
if err := s.reloadNginx(); err != nil {
|
||||
s.restoreHTTPSConfig(path, previous, readErr)
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(httpsProxyStatePath(s.cfg.DataDir))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) restoreHTTPSConfig(path string, previous []byte, readErr error) {
|
||||
if readErr == nil {
|
||||
_ = atomicWrite(path, previous, 0o640)
|
||||
} else {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
_ = s.reloadNginx()
|
||||
}
|
||||
|
||||
func (s *Server) ensureNginxDir() (string, error) {
|
||||
dir := strings.TrimSpace(s.cfg.NginxConfDir)
|
||||
if dir != "" {
|
||||
return dir, nil
|
||||
}
|
||||
dir, err := detectNginxConfDir(s.nginxPath())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.cfg.NginxConfDir = dir
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func renderHTTPSProxy(port int, certPath, keyPath string) string {
|
||||
return fmt.Sprintf(`# Managed by fonu-agent. Do not edit.
|
||||
server {
|
||||
listen %d ssl;
|
||||
server_name _;
|
||||
ssl_certificate %s;
|
||||
ssl_certificate_key %s;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
location /api/v1/ {
|
||||
proxy_pass http://127.0.0.1:7700;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
}
|
||||
`, port, certPath, keyPath)
|
||||
}
|
||||
|
||||
func httpsProxyStatePath(dataDir string) string {
|
||||
return filepath.Join(dataDir, "https_transport.json")
|
||||
}
|
||||
|
||||
func writeHTTPSProxyState(dataDir string, state httpsProxyState) error {
|
||||
if strings.TrimSpace(dataDir) == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicWrite(httpsProxyStatePath(dataDir), data, 0o640)
|
||||
}
|
||||
|
||||
func readHTTPSProxyState(dataDir string) (httpsProxyState, bool) {
|
||||
data, err := os.ReadFile(httpsProxyStatePath(dataDir))
|
||||
if err != nil {
|
||||
return httpsProxyState{}, false
|
||||
}
|
||||
var state httpsProxyState
|
||||
if err := json.Unmarshal(data, &state); err != nil || state.Port <= 0 {
|
||||
return httpsProxyState{}, false
|
||||
}
|
||||
return state, true
|
||||
}
|
||||
Reference in New Issue
Block a user