Compare commits

..

4 Commits

16 changed files with 411 additions and 26 deletions
+53 -2
View File
@@ -89,6 +89,10 @@ type snapshotRoute struct {
BasicAuthUsername string `json:"basic_auth_username,omitempty"`
BasicAuthPassword string `json:"basic_auth_password,omitempty"`
Remark string `json:"remark,omitempty"`
UpstreamType string `json:"upstream_type,omitempty"`
TunnelNodeID *uint `json:"tunnel_node_id,omitempty"`
TunnelTargetAddr string `json:"tunnel_target_addr,omitempty"`
TunnelTargetProto string `json:"tunnel_target_protocol,omitempty"`
}
type snapshotWAFRuleGroup struct {
@@ -500,10 +504,22 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
if err != nil {
return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
}
upstreamType := normalizeUpstreamType(route.UpstreamType)
originURL := route.OriginURL
upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL)
if err != nil {
return nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain)
}
var tunnelNodeID *uint
var tunnelTargetAddr string
var tunnelTargetProtocol string
if upstreamType == "tunnel" {
originURL = resolveTunnelOpenRestyUpstreamURL()
upstreams = []string{originURL}
tunnelNodeID = route.TunnelNodeID
tunnelTargetAddr = strings.TrimSpace(route.TunnelTargetAddr)
tunnelTargetProtocol = normalizeTunnelTargetProtocol(route.TunnelTargetProtocol)
}
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
if err != nil {
return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain)
@@ -520,7 +536,7 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, domains[0]),
Domain: domains[0],
Domains: domains,
OriginURL: route.OriginURL,
OriginURL: originURL,
OriginHost: route.OriginHost,
Upstreams: upstreams,
Enabled: route.Enabled,
@@ -542,11 +558,30 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
BasicAuthUsername: route.BasicAuthUsername,
BasicAuthPassword: route.BasicAuthPassword,
Remark: route.Remark,
UpstreamType: upstreamType,
TunnelNodeID: tunnelNodeID,
TunnelTargetAddr: tunnelTargetAddr,
TunnelTargetProto: tunnelTargetProtocol,
})
}
return items, nil
}
func resolveTunnelOpenRestyUpstreamURL() string {
relayNodes, err := model.ListNodesByType("tunnel_relay")
if err == nil && len(relayNodes) > 0 {
for _, node := range relayNodes {
if node != nil {
addr := relayAgentAddress(node)
if addr != "" {
return "http://" + addr
}
}
}
}
return "http://127.0.0.1:8080"
}
func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument, error) {
if err := EnsureDefaultWAFRuleGroup(); err != nil {
return snapshotWAFDocument{}, err
@@ -778,6 +813,15 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
routes[index].BasicAuthUsername = ""
routes[index].BasicAuthPassword = ""
}
routes[index].UpstreamType = normalizeUpstreamType(routes[index].UpstreamType)
if routes[index].UpstreamType == "tunnel" {
routes[index].TunnelTargetAddr = strings.TrimSpace(routes[index].TunnelTargetAddr)
routes[index].TunnelTargetProto = normalizeTunnelTargetProtocol(routes[index].TunnelTargetProto)
} else {
routes[index].TunnelNodeID = nil
routes[index].TunnelTargetAddr = ""
routes[index].TunnelTargetProto = ""
}
}
return routes
}
@@ -803,7 +847,7 @@ func flattenSnapshotRoutesByDomain(routes []snapshotRoute) map[string]snapshotRo
}
func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
if left.SiteName != right.SiteName || left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.LimitConnPerServer != right.LimitConnPerServer || left.LimitConnPerIP != right.LimitConnPerIP || left.LimitRate != right.LimitRate || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || left.PoWEnabled != right.PoWEnabled || left.BasicAuthEnabled != right.BasicAuthEnabled || left.BasicAuthUsername != right.BasicAuthUsername || left.BasicAuthPassword != right.BasicAuthPassword || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
if left.SiteName != right.SiteName || left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.LimitConnPerServer != right.LimitConnPerServer || left.LimitConnPerIP != right.LimitConnPerIP || left.LimitRate != right.LimitRate || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || left.PoWEnabled != right.PoWEnabled || left.BasicAuthEnabled != right.BasicAuthEnabled || left.BasicAuthUsername != right.BasicAuthUsername || left.BasicAuthPassword != right.BasicAuthPassword || left.UpstreamType != right.UpstreamType || !uintPtrEqual(left.TunnelNodeID, right.TunnelNodeID) || left.TunnelTargetAddr != right.TunnelTargetAddr || left.TunnelTargetProto != right.TunnelTargetProto || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
return false
}
if len(left.Domains) != len(right.Domains) {
@@ -1165,6 +1209,13 @@ func uintSliceEqual(left []uint, right []uint) bool {
return true
}
func uintPtrEqual(left *uint, right *uint) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}
func nextVersionNumber(now time.Time) (string, error) {
prefix := now.Format("20060102")
var latest model.ConfigVersion
+46 -1
View File
@@ -63,6 +63,7 @@ type ProxyRouteInput struct {
Remark string `json:"remark"`
UpstreamType string `json:"upstream_type"`
TunnelNodeID *uint `json:"tunnel_node_id"`
TunnelID *uint `json:"tunnel_id"`
TunnelTargetAddr string `json:"tunnel_target_addr"`
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
}
@@ -102,6 +103,7 @@ type ProxyRouteView struct {
Remark string `json:"remark"`
UpstreamType string `json:"upstream_type"`
TunnelNodeID *uint `json:"tunnel_node_id"`
TunnelID *uint `json:"tunnel_id"`
TunnelTargetAddr string `json:"tunnel_target_addr"`
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
CreatedAt time.Time `json:"created_at"`
@@ -327,7 +329,14 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
route.Remark = remark
route.UpstreamType = upstreamType
if upstreamType == "tunnel" {
route.TunnelNodeID = input.TunnelNodeID
tunnelNodeID, err := normalizeTunnelNodeID(input.TunnelNodeID, input.TunnelID)
if err != nil {
return nil, err
}
if err := validateTunnelRouteInput(tunnelNodeID, input.TunnelTargetAddr, input.TunnelTargetProtocol); err != nil {
return nil, err
}
route.TunnelNodeID = tunnelNodeID
route.TunnelTargetAddr = strings.TrimSpace(input.TunnelTargetAddr)
route.TunnelTargetProtocol = normalizeTunnelTargetProtocol(input.TunnelTargetProtocol)
} else {
@@ -422,6 +431,7 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
Remark: route.Remark,
UpstreamType: route.UpstreamType,
TunnelNodeID: route.TunnelNodeID,
TunnelID: route.TunnelNodeID,
TunnelTargetAddr: route.TunnelTargetAddr,
TunnelTargetProtocol: route.TunnelTargetProtocol,
CreatedAt: route.CreatedAt,
@@ -429,6 +439,41 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
}, nil
}
func normalizeTunnelNodeID(tunnelNodeID *uint, legacyTunnelID *uint) (*uint, error) {
if tunnelNodeID != nil && *tunnelNodeID != 0 {
return tunnelNodeID, nil
}
if legacyTunnelID != nil && *legacyTunnelID != 0 {
return legacyTunnelID, nil
}
return nil, errors.New("tunnel_node_id is required for tunnel upstream")
}
func validateTunnelRouteInput(tunnelNodeID *uint, targetAddr string, targetProtocol string) error {
if tunnelNodeID == nil || *tunnelNodeID == 0 {
return errors.New("tunnel_node_id is required for tunnel upstream")
}
tunnelNode, err := model.GetNodeByID(*tunnelNodeID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("tunnel client node does not exist")
}
return err
}
if tunnelNode.NodeType != "tunnel_client" {
return errors.New("tunnel_node_id must reference a tunnel_client node")
}
if strings.TrimSpace(targetAddr) == "" {
return errors.New("tunnel_target_addr is required for tunnel upstream")
}
switch strings.ToLower(strings.TrimSpace(targetProtocol)) {
case "", "http", "https":
return nil
default:
return errors.New("tunnel_target_protocol must be http or https")
}
}
func normalizeProxyRouteSiteNameInput(route *model.ProxyRoute, raw string, primaryDomain string) string {
siteName := strings.TrimSpace(raw)
if siteName != "" {
+71 -4
View File
@@ -4,8 +4,10 @@ import (
"errors"
"fmt"
"log/slog"
"net"
"openflare/common"
"openflare/model"
"strconv"
"strings"
"time"
@@ -189,6 +191,7 @@ func normalizeRelayStatus(status string) string {
type FlaredHeartbeatPayload struct {
ClientVersion string `json:"client_version"`
FrpVersion string `json:"frp_version"`
IP string `json:"ip"`
TunnelStatus string `json:"tunnel_status"`
ConnectedRelays []FlaredConnectedRelay `json:"connected_relays"`
CurrentVersion string `json:"current_version"`
@@ -198,6 +201,7 @@ type FlaredHeartbeatPayload struct {
func normalizeFlaredHeartbeatPayload(payload FlaredHeartbeatPayload) FlaredHeartbeatPayload {
payload.ClientVersion = strings.TrimSpace(payload.ClientVersion)
payload.FrpVersion = strings.TrimSpace(payload.FrpVersion)
payload.IP = strings.TrimSpace(payload.IP)
payload.TunnelStatus = strings.ToLower(strings.TrimSpace(payload.TunnelStatus))
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
payload.CurrentChecksum = strings.TrimSpace(payload.CurrentChecksum)
@@ -253,8 +257,23 @@ func HeartbeatFlared(node *model.Node, payload FlaredHeartbeatPayload) (*FlaredH
node.CurrentVersion = payload.CurrentVersion
node.LastSeenAt = now
node.Status = NodeStatusOnline
if !node.IPManualOverride && payload.IP != "" && previous.IP != payload.IP {
changes["ip"] = payload.IP
node.IP = payload.IP
}
if !node.GeoManualOverride {
applyGeoInfoFromIP(node, node.IP)
if previous.GeoName != node.GeoName {
changes["geo_name"] = node.GeoName
}
if !coordinatesEqual(previous.GeoLatitude, node.GeoLatitude) {
changes["geo_latitude"] = node.GeoLatitude
}
if !coordinatesEqual(previous.GeoLongitude, node.GeoLongitude) {
changes["geo_longitude"] = node.GeoLongitude
}
}
if len(changes) > 0 {
@@ -369,10 +388,7 @@ func GetFlaredTunnelConfig(node *model.Node) (*FlaredTunnelConfigResponse, error
relays := make([]FlaredRelayInfo, 0, len(relayNodes))
for _, node := range relayNodes {
if node.RelayStatus == "healthy" || node.Status == NodeStatusOnline {
addr := strings.TrimSpace(node.RelayClientAccessAddr)
if addr == "" {
addr = fmt.Sprintf("%s:%d", strings.TrimSpace(node.IP), node.RelayBindPort)
}
addr := relayClientAddress(node)
relays = append(relays, FlaredRelayInfo{
RelayNodeID: node.NodeID,
Address: addr,
@@ -413,6 +429,57 @@ func GetFlaredTunnelConfig(node *model.Node) (*FlaredTunnelConfigResponse, error
}, nil
}
func relayClientAddress(node *model.Node) string {
if node == nil {
return ""
}
port := node.RelayBindPort
if port <= 0 {
port = 7000
}
addr := strings.TrimSpace(node.RelayClientAccessAddr)
if addr == "" {
addr = strings.TrimSpace(node.IP)
}
if addr == "" {
return fmt.Sprintf("127.0.0.1:%d", port)
}
if _, _, err := net.SplitHostPort(addr); err == nil {
return addr
}
if strings.Contains(addr, ":") && strings.Count(addr, ":") > 1 {
return net.JoinHostPort(addr, strconv.Itoa(port))
}
return fmt.Sprintf("%s:%d", addr, port)
}
func relayAgentAddress(node *model.Node) string {
if node == nil {
return ""
}
port := node.RelayVhostHTTPPort
if port <= 0 {
port = 8080
}
addr := strings.TrimSpace(node.RelayAgentAccessAddr)
if addr == "" {
addr = strings.TrimSpace(node.RelayClientAccessAddr)
}
if addr == "" {
addr = strings.TrimSpace(node.IP)
}
if addr == "" {
return fmt.Sprintf("127.0.0.1:%d", port)
}
if _, _, err := net.SplitHostPort(addr); err == nil {
return addr
}
if strings.Contains(addr, ":") && strings.Count(addr, ":") > 1 {
return net.JoinHostPort(addr, strconv.Itoa(port))
}
return fmt.Sprintf("%s:%d", addr, port)
}
func parseTunnelTargetAddr(addr string) (string, int) {
addr = strings.TrimSpace(addr)
if addr == "" {
+106
View File
@@ -3,6 +3,7 @@ package service
import (
"errors"
"openflare/model"
"strings"
"testing"
"time"
@@ -149,6 +150,7 @@ func TestHeartbeatFlaredPersistsRuntime(t *testing.T) {
resp, err := HeartbeatFlared(node, FlaredHeartbeatPayload{
ClientVersion: " v0.2.0 ",
FrpVersion: " 0.61.1 ",
IP: " 192.168.1.10 ",
TunnelStatus: " RUNNING ",
ConnectedRelays: []FlaredConnectedRelay{
{RelayNodeID: " node-relay-1 ", Status: " HEALTHY ", ProxyCount: 3},
@@ -184,12 +186,39 @@ func TestHeartbeatFlaredPersistsRuntime(t *testing.T) {
if updated.ExtVersion != "0.61.1" {
t.Fatalf("expected frp_version to be trimmed and stored, got %q", updated.ExtVersion)
}
if updated.IP != "192.168.1.10" {
t.Fatalf("expected IP to be trimmed and stored, got %q", updated.IP)
}
if updated.CurrentVersion != "v1" {
t.Fatalf("expected current_version to be stored, got %q", updated.CurrentVersion)
}
if updated.LastSeenAt.IsZero() {
t.Fatal("expected last_seen_at to be updated")
}
// Test IPManualOverride
updated.IPManualOverride = true
if err := updated.Update(); err != nil {
t.Fatalf("failed to lock IP: %v", err)
}
_, err = HeartbeatFlared(updated, FlaredHeartbeatPayload{
ClientVersion: "v0.2.0",
FrpVersion: "0.61.1",
IP: "10.0.0.99",
TunnelStatus: "running",
})
if err != nil {
t.Fatalf("second HeartbeatFlared failed: %v", err)
}
lockedNode, err := model.GetNodeByNodeID(node.NodeID)
if err != nil {
t.Fatalf("failed to reload locked node: %v", err)
}
if lockedNode.IP != "192.168.1.10" {
t.Fatalf("expected IP to stay locked at 192.168.1.10, but got %q", lockedNode.IP)
}
}
func TestHeartbeatFlaredTrimsAndFiltersRelays(t *testing.T) {
@@ -295,3 +324,80 @@ func TestGetFlaredTunnelConfigRequiresActiveVersion(t *testing.T) {
t.Logf("GetFlaredTunnelConfig returned wrapped error: %v", err)
}
}
func TestTunnelRoutePublishAndFlaredConfigUseRelayPorts(t *testing.T) {
setupServiceTestDB(t)
relayNode := &model.Node{
NodeID: "node-relay-ports",
Name: "relay-ports",
IP: "85.235.64.179",
AccessToken: "relay-token-ports",
Status: NodeStatusOnline,
NodeType: "tunnel_relay",
RelayStatus: "healthy",
RelayBindPort: 17000,
RelayVhostHTTPPort: 18080,
RelayAuthToken: "relay-auth-token",
RelayClientAccessAddr: "de-e",
}
if err := relayNode.Insert(); err != nil {
t.Fatalf("failed to seed relay node: %v", err)
}
tunnelNode := &model.Node{
NodeID: "node-flared-ports",
Name: "flared-ports",
IP: "",
AccessToken: "tunnel-token-ports",
Status: NodeStatusOnline,
NodeType: "tunnel_client",
Version: "v0.2.0",
}
if err := tunnelNode.Insert(); err != nil {
t.Fatalf("failed to seed tunnel client node: %v", err)
}
route, err := CreateProxyRoute(ProxyRouteInput{
Domain: "flared.example.com",
UpstreamType: "tunnel",
TunnelID: &tunnelNode.ID,
TunnelTargetAddr: "10.0.0.8:8080",
TunnelTargetProtocol: "http",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if route.TunnelNodeID == nil || *route.TunnelNodeID != tunnelNode.ID {
t.Fatalf("expected legacy tunnel_id to bind tunnel_node_id, got %+v", route.TunnelNodeID)
}
result, err := PublishConfigVersion("root", false)
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.RenderedConfig, "server de-e:18080 max_fails=3 fail_timeout=10s;") {
t.Fatalf("expected rendered OpenResty upstream to use relay vhost port, got:\n%s", result.Version.RenderedConfig)
}
config, err := GetFlaredTunnelConfig(tunnelNode)
if err != nil {
t.Fatalf("GetFlaredTunnelConfig failed: %v", err)
}
if len(config.Relays) != 1 {
t.Fatalf("expected one relay, got %+v", config.Relays)
}
if config.Relays[0].Address != "de-e:17000" {
t.Fatalf("expected relay client address to include bind port, got %q", config.Relays[0].Address)
}
if len(config.Proxies) != 1 {
t.Fatalf("expected one proxy, got %+v", config.Proxies)
}
proxy := config.Proxies[0]
if proxy.LocalAddr != "10.0.0.8" || proxy.LocalPort != 8080 {
t.Fatalf("unexpected proxy target: %+v", proxy)
}
if len(proxy.CustomDomains) != 1 || proxy.CustomDomains[0] != "flared.example.com" {
t.Fatalf("unexpected proxy domains: %+v", proxy.CustomDomains)
}
}
@@ -47,6 +47,7 @@ const nodeEditorSchema = z
ip_manual_override: z.boolean(),
auto_update_enabled: z.boolean(),
relay_bind_port: z.string().trim(),
relay_vhost_http_port: z.string().trim(),
relay_client_access_addr: z.string().trim(),
relay_agent_access_addr: z.string().trim(),
relay_client_proxy_url: z.string().trim(),
@@ -74,6 +75,14 @@ const nodeEditorSchema = z
message: '请填写正确的端口号 (1-65535)',
});
}
const vhostPort = Number(values.relay_vhost_http_port);
if (Number.isNaN(vhostPort) || vhostPort < 1 || vhostPort > 65535) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_vhost_http_port'],
message: '请填写正确的端口号 (1-65535)',
});
}
}
if (!values.geo_manual_override) {
@@ -129,6 +138,7 @@ const defaultValues: NodeEditorValues = {
ip_manual_override: false,
auto_update_enabled: false,
relay_bind_port: '7000',
relay_vhost_http_port: '8080',
relay_client_access_addr: '',
relay_agent_access_addr: '',
relay_client_proxy_url: '',
@@ -219,6 +229,7 @@ function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
ip_manual_override: node.ip_manual_override ?? false,
auto_update_enabled: node.auto_update_enabled ?? false,
relay_bind_port: String(node.relay_bind_port ?? 7000),
relay_vhost_http_port: String(node.relay_vhost_http_port ?? 8080),
relay_client_access_addr: node.relay_client_access_addr ?? '',
relay_agent_access_addr: node.relay_agent_access_addr ?? '',
relay_client_proxy_url: node.relay_client_proxy_url ?? '',
@@ -244,6 +255,7 @@ function toPayload(values: NodeEditorValues): NodeMutationPayload {
ip_manual_override: values.ip_manual_override,
auto_update_enabled: values.auto_update_enabled,
relay_bind_port: values.type === 'tunnel_relay' ? Number(values.relay_bind_port) : undefined,
relay_vhost_http_port: values.type === 'tunnel_relay' ? Number(values.relay_vhost_http_port) : undefined,
relay_client_access_addr: values.type === 'tunnel_relay' ? values.relay_client_access_addr.trim() : undefined,
relay_agent_access_addr: values.type === 'tunnel_relay' ? values.relay_agent_access_addr.trim() : undefined,
relay_client_proxy_url: values.type === 'tunnel_relay' ? values.relay_client_proxy_url.trim() : undefined,
@@ -383,6 +395,14 @@ export function NodeEditorModal({
<ResourceInput placeholder="7000" {...form.register('relay_bind_port')} />
</ResourceField>
<ResourceField
label="中继 Vhost HTTP 端口 (Vhost HTTP Port)"
hint="中继服务端在此端口监听 HTTP 虚拟主机流量,用于 Nginx 转发到中继。"
error={form.formState.errors.relay_vhost_http_port?.message}
>
<ResourceInput placeholder="8080" {...form.register('relay_vhost_http_port')} />
</ResourceField>
<ResourceField
label="客户端接入地址 (Client Access Addr)"
hint="可选,如果不填默认使用节点的 IP + 绑定端口。"
@@ -367,9 +367,10 @@ export function NodesPage() {
)}
/>
) : node.node_type === 'tunnel_client' ? (
<span className="text-sm text-[var(--foreground-secondary)]">
-
</span>
<StatusBadge
label={node.status === 'online' ? '运行中' : '未知'}
variant={node.status === 'online' ? 'success' : 'warning'}
/>
) : (
<StatusBadge
label={getOpenrestyStatusLabel(
@@ -383,16 +384,20 @@ export function NodesPage() {
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{node.current_version || '未应用'}
{node.current_version || (node.node_type === 'tunnel_relay' ? '实时配置' : '未应用')}
</td>
<td className="px-3 py-4">
<div className="space-y-2">
<StatusBadge
label={getApplyLabel(node.latest_apply_result)}
variant={getApplyVariant(
node.latest_apply_result,
)}
/>
{node.node_type === 'tunnel_relay' ? (
<span className="text-sm text-[var(--foreground-secondary)]">—</span>
) : (
<StatusBadge
label={getApplyLabel(node.latest_apply_result)}
variant={getApplyVariant(
node.latest_apply_result,
)}
/>
)}
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
@@ -8,6 +8,7 @@ export interface NodeItem {
ip: string;
ip_manual_override: boolean;
relay_bind_port: number;
relay_vhost_http_port: number;
relay_client_access_addr: string;
relay_agent_access_addr: string;
relay_client_proxy_url: string;
@@ -54,6 +55,7 @@ export interface NodeMutationPayload {
ip: string;
ip_manual_override: boolean;
relay_bind_port?: number;
relay_vhost_http_port?: number;
relay_client_access_addr?: string;
relay_agent_access_addr?: string;
relay_client_proxy_url?: string;
+2 -1
View File
@@ -1,9 +1,10 @@
import type { NodeItem } from '@/features/nodes/types';
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
export const FLARED_WS_CONNECTED_LAST_SEEN = '__OPENFLARE_FLARED_WS_CONNECTED__';
export function isWSConnectedLastSeen(value: string | null | undefined) {
return value === WS_CONNECTED_LAST_SEEN;
return value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN;
}
export function isMeaningfulTime(value: string | null | undefined) {
@@ -561,7 +561,7 @@ function ReverseProxySection({
upstream_type: route.upstream_type || 'direct',
origin_urls_text: route.upstream_list.join('\n'),
origin_host: route.origin_host || '',
tunnel_id: route.tunnel_id ? String(route.tunnel_id) : '',
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
custom_headers_text: customHeadersToText(route.custom_header_list),
@@ -574,7 +574,7 @@ function ReverseProxySection({
upstream_type: route.upstream_type || 'direct',
origin_urls_text: route.upstream_list.join('\n'),
origin_host: route.origin_host || '',
tunnel_id: route.tunnel_id ? String(route.tunnel_id) : '',
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
custom_headers_text: customHeadersToText(route.custom_header_list),
@@ -632,7 +632,7 @@ function ReverseProxySection({
custom_headers: headers,
remark: values.remark.trim(),
upstream_type: values.upstream_type,
tunnel_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
tunnel_node_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
tunnel_target_addr: values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
tunnel_target_protocol: values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
}),
@@ -249,7 +249,7 @@ export function ProxyRouteCreateDrawer({
basic_auth_enabled: false,
remark: values.remark.trim(),
upstream_type: values.upstream_type,
tunnel_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
tunnel_node_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
tunnel_target_addr: values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
tunnel_target_protocol: values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
});
@@ -300,11 +300,20 @@ export function buildPayloadFromRoute(
basic_auth_enabled: route.basic_auth_enabled,
basic_auth_username: route.basic_auth_username,
basic_auth_password: route.basic_auth_password,
upstream_type: route.upstream_type,
tunnel_node_id: route.tunnel_node_id ?? route.tunnel_id ?? null,
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol: route.tunnel_target_protocol || '',
...overrides,
};
}
export function getUpstreamSummary(route: ProxyRouteItem) {
if (route.upstream_type === 'tunnel') {
const protocol = route.tunnel_target_protocol || 'http';
const target = route.tunnel_target_addr || '未配置目标';
return `Tunnel → ${protocol}://${target}`;
}
if (route.upstream_list.length <= 1) {
return route.origin_url;
}
@@ -54,6 +54,7 @@ export interface ProxyRouteItem {
basic_auth_password: string;
remark: string;
upstream_type: 'direct' | 'tunnel';
tunnel_node_id?: number | null;
tunnel_id?: number | null;
tunnel_target_addr?: string;
tunnel_target_protocol?: string;
@@ -93,6 +94,7 @@ export interface ProxyRouteMutationPayload {
basic_auth_password?: string;
remark: string;
upstream_type?: 'direct' | 'tunnel';
tunnel_node_id?: number | null;
tunnel_id?: number | null;
tunnel_target_addr?: string;
tunnel_target_protocol?: string;
@@ -17,6 +17,8 @@ import {
getManagedDomains,
} from '@/features/managed-domains/api/managed-domains';
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
import { getUpstreamSummary } from '@/features/proxy-routes/helpers';
import {
deleteTlsCertificate,
getTlsCertificates,
@@ -539,7 +541,7 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div className="max-w-72 space-y-1 break-all">
<p>{route.origin_url}</p>
<p>{getUpstreamSummary(route)}</p>
<p className="text-xs text-[var(--foreground-muted)]">
回源主机名: {route.origin_host || '$host'}
</p>
@@ -12,6 +12,7 @@ function buildNode(overrides: Partial<NodeItem> = {}): NodeItem {
ip: '127.0.0.1',
ip_manual_override: false,
relay_bind_port: 7000,
relay_vhost_http_port: 8080,
relay_client_access_addr: '',
relay_agent_access_addr: '',
relay_client_proxy_url: '',
+12 -3
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"path/filepath"
@@ -210,9 +211,17 @@ auth.token = "%s"
}
func parseAddr(addr string) (string, string) {
parts := strings.Split(addr, ":")
if len(parts) == 2 {
return parts[0], parts[1]
addr = strings.TrimSpace(addr)
if addr == "" {
return "127.0.0.1", "7000"
}
host, port, err := net.SplitHostPort(addr)
if err == nil {
return strings.Trim(host, "[]"), port
}
lastColon := strings.LastIndex(addr, ":")
if lastColon > 0 && strings.Count(addr, ":") == 1 {
return addr[:lastColon], addr[lastColon+1:]
}
return addr, "7000"
}
+65
View File
@@ -3,12 +3,20 @@ package heartbeat
import (
"context"
"log/slog"
"net"
"time"
"openflare-flared/internal/config"
"openflare-flared/internal/frpc"
"openflare-flared/internal/httpclient"
"openflare/service"
"openflare/utils/geoip"
"openflare/utils/geoip/iputil"
)
var (
lookupOutboundIP = geoip.GetOutboundIP
lookupLocalIP = detectLocalNodeIP
)
type Service struct {
@@ -45,9 +53,12 @@ func (s *Service) Run(ctx context.Context) {
func (s *Service) doHeartbeat(ctx context.Context) {
slog.Debug("sending flared heartbeat")
ip := detectNodeIP()
payload := service.FlaredHeartbeatPayload{
ClientVersion: config.Version,
FrpVersion: s.frpcManager.GetVersion(),
IP: ip,
TunnelStatus: "running", // TODO implement proper status tracking
ConnectedRelays: s.frpcManager.GetConnectedRelays(),
CurrentVersion: s.frpcManager.GetCurrentConfigVersion(),
@@ -61,3 +72,57 @@ func (s *Service) doHeartbeat(ctx context.Context) {
}
slog.Debug("flared heartbeat succeeded")
}
func detectNodeIP() string {
if ip := detectOutboundNodeIP(); ip != "" {
return ip
}
return lookupLocalIP()
}
func detectOutboundNodeIP() string {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ip, err := lookupOutboundIP(ctx)
if err != nil || ip == nil {
return ""
}
return ip.String()
}
func detectLocalNodeIP() string {
interfaces, err := net.Interfaces()
if err != nil {
return ""
}
bestIP := ""
bestPriority := -1
for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok || ipNet.IP == nil || ipNet.IP.IsLoopback() {
continue
}
ipv4 := ipNet.IP.To4()
if ipv4 == nil {
continue
}
priority := iputil.Score(ipv4)
if priority > bestPriority {
bestIP = ipv4.String()
bestPriority = priority
}
if bestPriority == 2 {
return bestIP
}
}
}
return bestIP
}