This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type firewallStatus struct {
|
||||
Tool string `json:"tool"`
|
||||
Active bool `json:"active"`
|
||||
PortOpen bool `json:"port_open"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// firewallStatus 只检测主机防火墙,不修改规则;云安全组无法从 VPS 内部感知。
|
||||
func (s *Server) firewallStatus(w http.ResponseWriter, r *http.Request) {
|
||||
port := 7443
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("port")); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 65535 {
|
||||
port = parsed
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detectFirewall(port))
|
||||
}
|
||||
|
||||
// firewallAllow 仅在用户显式确认后调用;只自动处理 ufw 与 firewalld。
|
||||
func (s *Server) firewallAllow(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Port int `json:"port"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).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": "端口无效"})
|
||||
return
|
||||
}
|
||||
output, err := allowFirewallPort(req.Port)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "output": output, "status": detectFirewall(req.Port)})
|
||||
}
|
||||
|
||||
func detectFirewall(port int) firewallStatus {
|
||||
if path, err := exec.LookPath("ufw"); err == nil {
|
||||
if out, err := exec.Command(path, "status").Output(); err == nil {
|
||||
active, open := parseUFWStatus(string(out), port)
|
||||
if active {
|
||||
return firewallStatus{Tool: "ufw", Active: true, PortOpen: open}
|
||||
}
|
||||
}
|
||||
}
|
||||
if path, err := exec.LookPath("firewall-cmd"); err == nil {
|
||||
if out, err := exec.Command(path, "--state").Output(); err == nil && strings.TrimSpace(string(out)) == "running" {
|
||||
return firewallStatus{Tool: "firewalld", Active: true, PortOpen: firewalldPortOpen(path, port)}
|
||||
}
|
||||
}
|
||||
if _, err := exec.LookPath("nft"); err == nil {
|
||||
return firewallStatus{Tool: "nftables", Active: true, Message: "nftables 需要手工放行,Agent 不会自动修改规则"}
|
||||
}
|
||||
if _, err := exec.LookPath("iptables"); err == nil {
|
||||
return firewallStatus{Tool: "iptables", Active: true, Message: "iptables 需要手工放行,Agent 不会自动修改规则"}
|
||||
}
|
||||
return firewallStatus{Tool: "none", Message: "未检测到主机防火墙"}
|
||||
}
|
||||
|
||||
// parseUFWStatus 纯解析 ufw status 输出:是否启用、目标端口是否已放行。
|
||||
func parseUFWStatus(output string, port int) (bool, bool) {
|
||||
active := false
|
||||
open := false
|
||||
needle := strconv.Itoa(port)
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Status:") {
|
||||
active = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(line, "Status:")), "active")
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(line, needle) || !strings.Contains(line, "ALLOW") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 && (fields[0] == needle || strings.HasPrefix(fields[0], needle+"/")) {
|
||||
open = true
|
||||
}
|
||||
}
|
||||
return active, open
|
||||
}
|
||||
|
||||
func firewalldPortOpen(path string, port int) bool {
|
||||
out, err := exec.Command(path, "--list-ports").Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range strings.Fields(string(out)) {
|
||||
if strings.HasPrefix(item, strconv.Itoa(port)+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func allowFirewallPort(port int) (string, error) {
|
||||
status := detectFirewall(port)
|
||||
switch status.Tool {
|
||||
case "ufw":
|
||||
out, err := exec.Command("ufw", "allow", strconv.Itoa(port)+"/tcp").CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
case "firewalld":
|
||||
first, err := exec.Command("firewall-cmd", "--permanent", "--add-port="+strconv.Itoa(port)+"/tcp").CombinedOutput()
|
||||
if err != nil {
|
||||
return strings.TrimSpace(string(first)), err
|
||||
}
|
||||
second, err := exec.Command("firewall-cmd", "--reload").CombinedOutput()
|
||||
return strings.TrimSpace(string(first) + "\n" + string(second)), err
|
||||
case "nftables", "iptables":
|
||||
return "", errors.New("当前防火墙需要手工放行,Agent 不会自动修改 nftables / iptables 规则")
|
||||
default:
|
||||
return "", errors.New("未检测到可自动放行的主机防火墙(仅支持 ufw / firewalld)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseUFWStatus(t *testing.T) {
|
||||
output := `Status: active
|
||||
|
||||
To Action From
|
||||
-- ------ ----
|
||||
22/tcp ALLOW Anywhere
|
||||
7443/tcp ALLOW Anywhere
|
||||
`
|
||||
active, open := parseUFWStatus(output, 7443)
|
||||
if !active || !open {
|
||||
t.Fatalf("应识别出 ufw 已启用且 7443 已放行:active=%v open=%v", active, open)
|
||||
}
|
||||
_, open = parseUFWStatus(output, 9443)
|
||||
if open {
|
||||
t.Fatal("未放行的端口不应判定为已放行")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUFWStatusInactive(t *testing.T) {
|
||||
active, open := parseUFWStatus("Status: inactive\n", 7443)
|
||||
if active || open {
|
||||
t.Fatalf("inactive 时不应判定为启用:active=%v open=%v", active, open)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateHTTPSPorts(t *testing.T) {
|
||||
if got := candidateHTTPSPorts(9443); len(got) != 1 || got[0] != 9443 {
|
||||
t.Fatalf("显式端口应只返回该端口:%v", got)
|
||||
}
|
||||
got := candidateHTTPSPorts(0)
|
||||
if len(got) != 3 || got[0] != 7443 || got[1] != 8443 || got[2] != 9443 {
|
||||
t.Fatalf("自动模式应返回 7443/8443/9443:%v", got)
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,8 @@ import (
|
||||
// 修掉「配置目录还没探测出来、迁移逻辑没机会执行」导致 HTTPS 直连持续失败的问题。
|
||||
// 0.13.3:HTTPS 反代改用 default_server 并在 reload 后做本机证书指纹自检,
|
||||
// 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。
|
||||
const AgentVersion = "0.13.3"
|
||||
// 0.14.0:HTTPS 端口自动选择(7443/8443/9443)、当前证书信息查询、主机防火墙检测与 ufw/firewalld 显式放行。
|
||||
const AgentVersion = "0.14.0"
|
||||
|
||||
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||
type Config struct {
|
||||
@@ -227,7 +228,10 @@ func (s *Server) Handler() http.Handler {
|
||||
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("GET /api/v1/transport/https", s.auth(s.getHTTPSProxy))
|
||||
mux.HandleFunc("DELETE /api/v1/transport/https", s.auth(s.deleteHTTPSProxy))
|
||||
mux.HandleFunc("GET /api/v1/firewall", s.auth(s.firewallStatus))
|
||||
mux.HandleFunc("POST /api/v1/firewall/allow", s.auth(s.firewallAllow))
|
||||
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))
|
||||
|
||||
+55
-13
@@ -20,7 +20,11 @@ import (
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
)
|
||||
|
||||
const httpsProxyConfigName = "fonuagent-https.conf"
|
||||
const (
|
||||
httpsProxyConfigName = "fonuagent-https.conf"
|
||||
httpsProxyCertPath = "/etc/fonu-agent/tls/agent.crt"
|
||||
httpsProxyKeyPath = "/etc/fonu-agent/tls/agent.key"
|
||||
)
|
||||
|
||||
type httpsProxyRequest struct {
|
||||
CertPEM string `json:"cert_pem"`
|
||||
@@ -38,8 +42,8 @@ func (s *Server) putHTTPSProxy(w http.ResponseWriter, r *http.Request) {
|
||||
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 之间"})
|
||||
if req.Port < 0 || req.Port > 65535 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "端口需在 0-65535 之间,0 表示自动选择"})
|
||||
return
|
||||
}
|
||||
if _, err := tls.X509KeyPair([]byte(req.CertPEM), []byte(req.KeyPEM)); err != nil {
|
||||
@@ -52,11 +56,12 @@ func (s *Server) putHTTPSProxy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.configureHTTPSProxy(req); err != nil {
|
||||
port, err := s.configureHTTPSProxy(req)
|
||||
if 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, "self_check": "ok"})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "port": port, "self_check": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) deleteHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -69,7 +74,46 @@ func (s *Server) deleteHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
|
||||
// getHTTPSProxy 返回当前 HTTPS 反代状态与证书到期时间,供中央端同步轮换计划。
|
||||
func (s *Server) getHTTPSProxy(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
state, enabled := readHTTPSProxyState(s.cfg.DataDir)
|
||||
notAfter := ""
|
||||
if t, err := readCertificateNotAfter(httpsProxyCertPath); err == nil {
|
||||
notAfter = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": enabled,
|
||||
"port": state.Port,
|
||||
"not_after": notAfter,
|
||||
})
|
||||
}
|
||||
|
||||
// configureHTTPSProxy 按候选端口逐个尝试;请求端口为 0 时自动选择 7443 / 8443 / 9443。
|
||||
func (s *Server) configureHTTPSProxy(req httpsProxyRequest) (int, error) {
|
||||
var lastErr error
|
||||
for _, port := range candidateHTTPSPorts(req.Port) {
|
||||
if err := s.applyHTTPSProxy(req, port); err != nil {
|
||||
lastErr = fmt.Errorf("端口 %d 不可用: %w", port, err)
|
||||
continue
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("没有可用的 HTTPS 端口")
|
||||
}
|
||||
return 0, lastErr
|
||||
}
|
||||
|
||||
func candidateHTTPSPorts(requested int) []int {
|
||||
if requested > 0 {
|
||||
return []int{requested}
|
||||
}
|
||||
return []int{7443, 8443, 9443}
|
||||
}
|
||||
|
||||
func (s *Server) applyHTTPSProxy(req httpsProxyRequest, port int) error {
|
||||
dir, err := s.ensureNginxDir()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -78,12 +122,10 @@ func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
|
||||
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 {
|
||||
if err := atomicWrite(httpsProxyCertPath, []byte(req.CertPEM), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := atomicWrite(keyPath, []byte(req.KeyPEM), 0o600); err != nil {
|
||||
if err := atomicWrite(httpsProxyKeyPath, []byte(req.KeyPEM), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, httpsProxyConfigName)
|
||||
@@ -91,7 +133,7 @@ func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
|
||||
if _, err := fsutil.BackupVersioned(path, 10); err != nil {
|
||||
return err
|
||||
}
|
||||
content := renderHTTPSProxy(req.Port, certPath, keyPath)
|
||||
content := renderHTTPSProxy(port, httpsProxyCertPath, httpsProxyKeyPath)
|
||||
if err := atomicWrite(path, []byte(content), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -107,11 +149,11 @@ func (s *Server) configureHTTPSProxy(req httpsProxyRequest) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.verifyHTTPSProxy(req.Port, pin); err != nil {
|
||||
if err := s.verifyHTTPSProxy(port, pin); err != nil {
|
||||
s.restoreHTTPSConfig(path, previous, readErr)
|
||||
return err
|
||||
}
|
||||
return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: req.Port})
|
||||
return writeHTTPSProxyState(s.cfg.DataDir, httpsProxyState{Port: port})
|
||||
}
|
||||
|
||||
func (s *Server) removeHTTPSProxy() error {
|
||||
|
||||
Reference in New Issue
Block a user