mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 01:36:37 +08:00
feat(relay): support configurable frps webui port and fix node detail integration
- Implement configurable FRPS WebUI switch and custom port setting (relay_frps_web_ui_port) in system configs. - Integrate settings into Relay Node detail manage page instead of global settings. - Dynamically query server version to select matching Docker image tag for Relay installation. - Clean up legacy code and fix backend linter/test warnings.
This commit is contained in:
@@ -18,6 +18,7 @@ sidebar: false
|
|||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|
||||||
|
- 新增可配置的 FRPS 内置 WebUI 开关和监听端口。在数据库 w_system_configs 中新增 `relay_frps_web_ui_enabled` 与 `relay_frps_web_ui_port`,支持通过后台系统设置页进行图形化管理与动态同步至 Relay 节点。
|
||||||
- 将 TLS 证书续签逻辑接入 Asynq 异步任务框架。新增单证书续期任务 `of_ssl_single_renew`(`openflare:ssl_single_renew`),支持在管理后台查看每步的申请状态和详细日志,并提供失败重试能力。
|
- 将 TLS 证书续签逻辑接入 Asynq 异步任务框架。新增单证书续期任务 `of_ssl_single_renew`(`openflare:ssl_single_renew`),支持在管理后台查看每步的申请状态和详细日志,并提供失败重试能力。
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
@@ -32,6 +33,8 @@ sidebar: false
|
|||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|
||||||
|
- 修复由于生成的 Docker 安装/部署命令硬编码拉取 `:latest` 镜像,导致使用 v3 新版协议的控制端与 v2 旧版协议的 Relay 节点无法通信的问题。前端改用动态获取当前控制端版本(serverVersion)并自动拉取与当前控制端匹配的 `:beta` 或具体版本镜像。
|
||||||
|
|
||||||
- 修复点击「打开 FRPS WebUI」跳转到 `about:blank#blocked`:在 Relay 节点详情页的 WebUI 磁贴卡片中增加展示具体 URL 地址,并提供一键复制按钮。解决因 Chrome 浏览器限制从公网安全源(HTTPS)跨域直接访问本地/私有网络 HTTP 端口(Private Network Access 限制)而导致新页面打开被拦截的问题。
|
- 修复点击「打开 FRPS WebUI」跳转到 `about:blank#blocked`:在 Relay 节点详情页的 WebUI 磁贴卡片中增加展示具体 URL 地址,并提供一键复制按钮。解决因 Chrome 浏览器限制从公网安全源(HTTPS)跨域直接访问本地/私有网络 HTTP 端口(Private Network Access 限制)而导致新页面打开被拦截的问题。
|
||||||
|
|
||||||
- 修复 Docker 部署模式下 Agent 无法自更新:修改 Docker 启动脚本 `agent-entrypoint.sh`,在降权前将二进制文件所在目录 `/usr/local/bin` 及二进制文件自身的属主赋予 `openflare`,解决容器内自更新写入时报 `permission denied` 的问题。
|
- 修复 Docker 部署模式下 Agent 无法自更新:修改 Docker 启动脚本 `agent-entrypoint.sh`,在降权前将二进制文件所在目录 `/usr/local/bin` 及二进制文件自身的属主赋予 `openflare`,解决容器内自更新写入时报 `permission denied` 的问题。
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/navigation';
|
import {useRouter} from 'next/navigation';
|
||||||
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||||
import {useState} from 'react';
|
import {useEffect, useMemo, useState} from 'react';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Copy,
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
|
Loader2,
|
||||||
Network,
|
Network,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
@@ -30,11 +31,14 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Input} from '@/components/ui/input';
|
||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import {Switch} from '@/components/ui/switch';
|
import {Switch} from '@/components/ui/switch';
|
||||||
import {formatDateTime} from '@/lib/utils';
|
import {formatDateTime} from '@/lib/utils';
|
||||||
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare';
|
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare';
|
||||||
import {NodeService} from '@/lib/services/openflare';
|
import {NodeService} from '@/lib/services/openflare';
|
||||||
|
import services from '@/lib/services';
|
||||||
|
import type {SystemConfig} from '@/lib/services/admin';
|
||||||
|
|
||||||
import {AgentUpdateDialog} from './agent-update-dialog';
|
import {AgentUpdateDialog} from './agent-update-dialog';
|
||||||
import {InstallCommand} from './install-command';
|
import {InstallCommand} from './install-command';
|
||||||
@@ -58,6 +62,13 @@ import {
|
|||||||
|
|
||||||
const nodesQueryKey = ['openflare', 'nodes'];
|
const nodesQueryKey = ['openflare', 'nodes'];
|
||||||
|
|
||||||
|
function systemConfigMap(configs: SystemConfig[]) {
|
||||||
|
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
|
||||||
|
accumulator[config.key] = config;
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -65,6 +76,43 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
|||||||
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
const [webServerPort, setWebServerPort] = useState('17500');
|
||||||
|
|
||||||
|
const systemConfigsQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'system-configs'],
|
||||||
|
queryFn: () => services.adminSystemConfig.listSystemConfigs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const configs = useMemo(
|
||||||
|
() => systemConfigMap(systemConfigsQuery.data ?? []),
|
||||||
|
[systemConfigsQuery.data]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (systemConfigsQuery.data) {
|
||||||
|
setWebServerPort(configs['relay_frps_web_ui_port']?.value || '17500');
|
||||||
|
}
|
||||||
|
}, [systemConfigsQuery.data, configs]);
|
||||||
|
|
||||||
|
const savePortMutation = useMutation({
|
||||||
|
mutationFn: async (port: string) => {
|
||||||
|
const portNum = parseInt(port, 10);
|
||||||
|
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
||||||
|
throw new Error('端口号必须在 1 ~ 65535 范围内');
|
||||||
|
}
|
||||||
|
const portCfg = configs['relay_frps_web_ui_port'];
|
||||||
|
await services.adminSystemConfig.updateSystemConfig('relay_frps_web_ui_port', {
|
||||||
|
value: String(portNum),
|
||||||
|
description: portCfg?.description || 'FRPS 内置 Web 管理界面端口',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin', 'system-configs'] });
|
||||||
|
toast.success('FRPS WebUI 端口配置已更新');
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error instanceof Error ? error.message : '更新端口失败'),
|
||||||
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
|
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
|
||||||
NodeService.updateNode(node.id, payload),
|
NodeService.updateNode(node.id, payload),
|
||||||
@@ -147,9 +195,14 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const webServerPortNum = parseInt(configs['relay_frps_web_ui_port']?.value || '', 10);
|
||||||
|
const resolvedPort = !isNaN(webServerPortNum) && webServerPortNum > 0
|
||||||
|
? webServerPortNum
|
||||||
|
: (node.relay_bind_port || 7000) + 500;
|
||||||
|
|
||||||
const webUiUrl =
|
const webUiUrl =
|
||||||
node.relay_web_server_enabled && node.relay_bind_port
|
node.relay_web_server_enabled && node.relay_bind_port
|
||||||
? `http://${node.ip || '127.0.0.1'}:${node.relay_bind_port + 500}`
|
? `http://${node.ip || '127.0.0.1'}:${resolvedPort}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const headerActions = (
|
const headerActions = (
|
||||||
@@ -257,14 +310,13 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
|||||||
|
|
||||||
const manageTab = (
|
const manageTab = (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<NodeSectionCard title="FRPS WebUI" description="控制 frps 内置 Web 管理界面是否启用">
|
<NodeSectionCard title="FRPS WebUI" description="控制 frps 内置 Web 管理界面是否启用及其监听端口">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between rounded-xl border px-4 py-4">
|
<div className="flex items-center justify-between rounded-xl border px-4 py-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>启用 Web 管理界面</Label>
|
<Label>启用 Web 管理界面</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
默认监听绑定端口 + 500,例如 {node.relay_bind_port || 7000} →{' '}
|
开启后,节点将启动 frps 的内置管理服务。
|
||||||
{(node.relay_bind_port || 7000) + 500}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -274,6 +326,38 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border p-4 space-y-3">
|
||||||
|
<Label htmlFor="web_server_port" className="text-xs font-semibold">Web 管理界面端口</Label>
|
||||||
|
<div className="flex max-w-sm items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="web_server_port"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={webServerPort}
|
||||||
|
onChange={(e) => setWebServerPort(e.target.value)}
|
||||||
|
placeholder="例如: 17500"
|
||||||
|
className="bg-card text-xs h-9"
|
||||||
|
disabled={savePortMutation.isPending || systemConfigsQuery.isLoading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-9"
|
||||||
|
onClick={() => savePortMutation.mutate(webServerPort)}
|
||||||
|
disabled={savePortMutation.isPending || systemConfigsQuery.isLoading}
|
||||||
|
>
|
||||||
|
{savePortMutation.isPending ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
'保存'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
当前实际监听端口:{resolvedPort} (修改后在下次节点心跳同步时生效,默认为绑定端口 + 500)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{webUiUrl ? (
|
{webUiUrl ? (
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import (
|
|||||||
"github.com/Rain-kl/Wavelet/internal/common/response"
|
"github.com/Rain-kl/Wavelet/internal/common/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
const expectedDefaultConfigsCount = 30
|
const expectedDefaultConfigsCount = 32
|
||||||
|
|
||||||
func setupTestRouter(authUser *model.User) *gin.Engine {
|
func setupTestRouter(authUser *model.User) *gin.Engine {
|
||||||
r := testhelper.NewTestGinEngine()
|
r := testhelper.NewTestGinEngine()
|
||||||
@@ -168,8 +168,8 @@ func TestListSystemConfigs(t *testing.T) {
|
|||||||
var configs []model.SystemConfig
|
var configs []model.SystemConfig
|
||||||
_ = json.Unmarshal(dataBytes, &configs)
|
_ = json.Unmarshal(dataBytes, &configs)
|
||||||
|
|
||||||
if len(configs) != 1 || configs[0].Key != model.ConfigKeyMaxAPIKeysPerUser {
|
if len(configs) != 3 {
|
||||||
t.Errorf("expected 1 business config (max_api_keys_per_user), got %d: %v", len(configs), configs)
|
t.Errorf("expected 3 business configs, got %d: %v", len(configs), configs)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,19 +68,8 @@ func (s *Service) syncMatchingChecksum(ctx context.Context, mode string, startup
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) finishUpToDateSync(ctx context.Context, mode string, snapshot *state.Snapshot, target *protocol.ActiveConfigMeta) error {
|
func (s *Service) finishUpToDateSync(ctx context.Context, mode string, snapshot *state.Snapshot, target *protocol.ActiveConfigMeta) error {
|
||||||
if pagesReconcileNeeded(snapshot) {
|
if err := s.reconcilePages(ctx, mode, snapshot); err != nil {
|
||||||
if pagesDiscoveryNeeded(snapshot) {
|
return err
|
||||||
config, err := s.client.GetActiveConfig(ctx)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("fetch active config failed", "mode", mode, "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.syncPagesDeployments(ctx, snapshot, config); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if err := s.syncPagesDeployments(ctx, snapshot, nil); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
slog.Debug("local openresty config already up to date", "mode", mode, "version", target.Version)
|
slog.Debug("local openresty config already up to date", "mode", mode, "version", target.Version)
|
||||||
if shouldReportNoopApply(snapshot, target.Version, target.Checksum) {
|
if shouldReportNoopApply(snapshot, target.Version, target.Checksum) {
|
||||||
@@ -96,6 +85,21 @@ func (s *Service) finishUpToDateSync(ctx context.Context, mode string, snapshot
|
|||||||
return s.stateStore.Save(snapshot)
|
return s.stateStore.Save(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) reconcilePages(ctx context.Context, mode string, snapshot *state.Snapshot) error {
|
||||||
|
if !pagesReconcileNeeded(snapshot) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if pagesDiscoveryNeeded(snapshot) {
|
||||||
|
config, err := s.client.GetActiveConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("fetch active config failed", "mode", mode, "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.syncPagesDeployments(ctx, snapshot, config)
|
||||||
|
}
|
||||||
|
return s.syncPagesDeployments(ctx, snapshot, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) syncMismatchedChecksum(ctx context.Context, mode string, startup bool, snapshot *state.Snapshot, currentChecksum string, target *protocol.ActiveConfigMeta) error {
|
func (s *Service) syncMismatchedChecksum(ctx context.Context, mode string, startup bool, snapshot *state.Snapshot, currentChecksum string, target *protocol.ActiveConfigMeta) error {
|
||||||
if isBlockedTarget(snapshot, target.Version, target.Checksum) {
|
if isBlockedTarget(snapshot, target.Version, target.Checksum) {
|
||||||
slog.Warn("skipping blocked config version after previous failed apply", "mode", mode, "version", target.Version, "checksum", target.Checksum)
|
slog.Warn("skipping blocked config version after previous failed apply", "mode", mode, "version", target.Version, "checksum", target.Checksum)
|
||||||
@@ -111,22 +115,13 @@ func (s *Service) syncMismatchedChecksum(ctx context.Context, mode string, start
|
|||||||
clearBlockedTarget(snapshot)
|
clearBlockedTarget(snapshot)
|
||||||
}
|
}
|
||||||
if snapshot.CurrentVersion == target.Version && snapshot.CurrentChecksum == target.Checksum && !startup {
|
if snapshot.CurrentVersion == target.Version && snapshot.CurrentChecksum == target.Checksum && !startup {
|
||||||
if pagesReconcileNeeded(snapshot) {
|
reconciled := pagesReconcileNeeded(snapshot)
|
||||||
if pagesDiscoveryNeeded(snapshot) {
|
if err := s.reconcilePages(ctx, mode, snapshot); err != nil {
|
||||||
config, fetchErr := s.client.GetActiveConfig(ctx)
|
return err
|
||||||
if fetchErr != nil {
|
}
|
||||||
slog.Error("fetch active config failed", "mode", mode, "error", fetchErr)
|
if !reconciled {
|
||||||
return fetchErr
|
slog.Debug("skipping config fetch because state already records target version/checksum", "version", target.Version, "checksum", target.Checksum)
|
||||||
}
|
|
||||||
if err := s.syncPagesDeployments(ctx, snapshot, config); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if err := s.syncPagesDeployments(ctx, snapshot, nil); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.stateStore.Save(snapshot)
|
|
||||||
}
|
}
|
||||||
slog.Debug("skipping config fetch because state already records target version/checksum", "version", target.Version, "checksum", target.Checksum)
|
|
||||||
return s.stateStore.Save(snapshot)
|
return s.stateStore.Save(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,14 +105,6 @@ func applyNodeRuntime(ctx context.Context, node *model.OpenFlareNode, payload No
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneCoordinate(value *float64) *float64 {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := *value
|
|
||||||
return &cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncateForDatabase(value string, maxVal int) string {
|
func truncateForDatabase(value string, maxVal int) string {
|
||||||
if maxVal <= 0 {
|
if maxVal <= 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func EnsureRuntimeProvider(ctx context.Context) error {
|
|||||||
runtimeInitErr = err
|
runtimeInitErr = err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
runtimeInitErr = applyProviderFromModel()
|
runtimeInitErr = applyProviderFromModel(ctx)
|
||||||
})
|
})
|
||||||
return runtimeInitErr
|
return runtimeInitErr
|
||||||
}
|
}
|
||||||
@@ -47,18 +47,18 @@ func RefreshRuntimeProvider(ctx context.Context) error {
|
|||||||
if err := model.InitOptionMap(ctx); err != nil {
|
if err := model.InitOptionMap(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return applyProviderFromModel()
|
return applyProviderFromModel(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyProviderFromModel() error {
|
func applyProviderFromModel(ctx context.Context) error {
|
||||||
model.OptionMapRWMutex.RLock()
|
model.OptionMapRWMutex.RLock()
|
||||||
provider := strings.TrimSpace(model.GeoIPProvider)
|
provider := strings.TrimSpace(model.GeoIPProvider)
|
||||||
model.OptionMapRWMutex.RUnlock()
|
model.OptionMapRWMutex.RUnlock()
|
||||||
return ApplyProvider(provider)
|
return ApplyProvider(ctx, provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyProvider switches the process-wide GeoIP backend.
|
// ApplyProvider switches the process-wide GeoIP backend.
|
||||||
func ApplyProvider(provider string) error {
|
func ApplyProvider(ctx context.Context, provider string) error {
|
||||||
normalized := strings.TrimSpace(strings.ToLower(provider))
|
normalized := strings.TrimSpace(strings.ToLower(provider))
|
||||||
if normalized == "" {
|
if normalized == "" {
|
||||||
normalized = pkggeoip.ProviderDisabled
|
normalized = pkggeoip.ProviderDisabled
|
||||||
@@ -75,7 +75,7 @@ func ApplyProvider(provider string) error {
|
|||||||
if normalized == pkggeoip.ProviderMaxMind {
|
if normalized == pkggeoip.ProviderMaxMind {
|
||||||
path, err := ensureServerMMDB()
|
path, err := ensureServerMMDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WarnF(context.Background(), "[GeoIP] seed MaxMind database failed: %v", err)
|
logger.WarnF(ctx, "[GeoIP] seed MaxMind database failed: %v", err)
|
||||||
}
|
}
|
||||||
if path != "" {
|
if path != "" {
|
||||||
pkggeoip.GeoIPFilePath = path
|
pkggeoip.GeoIPFilePath = path
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ func matchOpenrestyObservation(
|
|||||||
observations []*model.OpenFlareNodeObservationOpenresty,
|
observations []*model.OpenFlareNodeObservationOpenresty,
|
||||||
) *model.OpenFlareNodeObservationOpenresty {
|
) *model.OpenFlareNodeObservationOpenresty {
|
||||||
var matched *model.OpenFlareNodeObservationOpenresty
|
var matched *model.OpenFlareNodeObservationOpenresty
|
||||||
var bestDelta time.Duration = metricSnapshotOpenrestyMatchWindow + time.Second
|
bestDelta := metricSnapshotOpenrestyMatchWindow + time.Second
|
||||||
for _, observation := range observations {
|
for _, observation := range observations {
|
||||||
if observation == nil {
|
if observation == nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
package relay
|
package relay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -83,16 +85,21 @@ func isPublicNodeIP(raw string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildRelayConfig(node *model.OpenFlareNode) *Config {
|
func buildRelayConfig(ctx context.Context, node *model.OpenFlareNode) *Config {
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
webServerPort, err := repository.GetIntByKey(ctx, model.ConfigKeyRelayFRPSWebUIPort)
|
||||||
|
if err != nil || webServerPort <= 0 {
|
||||||
|
webServerPort = node.RelayBindPort + 500
|
||||||
|
}
|
||||||
return &Config{
|
return &Config{
|
||||||
BindPort: node.RelayBindPort,
|
BindPort: node.RelayBindPort,
|
||||||
VhostHTTPPort: node.RelayVhostHTTPPort,
|
VhostHTTPPort: node.RelayVhostHTTPPort,
|
||||||
AuthToken: node.RelayAuthToken,
|
AuthToken: node.RelayAuthToken,
|
||||||
LogLevel: "info",
|
LogLevel: "info",
|
||||||
WebServerEnabled: node.RelayWebServerEnabled,
|
WebServerEnabled: node.RelayWebServerEnabled,
|
||||||
|
WebServerPort: webServerPort,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func Heartbeat(ctx context.Context, node *model.OpenFlareNode, payload Heartbeat
|
|||||||
persistRelayHeartbeatObservability(ctx, node.NodeID, payload, now)
|
persistRelayHeartbeatObservability(ctx, node.NodeID, payload, now)
|
||||||
|
|
||||||
return &HeartbeatResponse{
|
return &HeartbeatResponse{
|
||||||
RelayConfig: buildRelayConfig(node),
|
RelayConfig: buildRelayConfig(ctx, node),
|
||||||
RelaySettings: BuildSettings(node, updateNow, updateChannel, updateTag),
|
RelaySettings: BuildSettings(node, updateNow, updateChannel, updateTag),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/Rain-kl/Wavelet/internal/config"
|
"github.com/Rain-kl/Wavelet/internal/config"
|
||||||
"github.com/Rain-kl/Wavelet/internal/db"
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/task"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -21,11 +22,13 @@ import (
|
|||||||
func setupSSLRenewTestDB(t *testing.T) func() {
|
func setupSSLRenewTestDB(t *testing.T) func() {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
task.RegisterTaskMeta(tls.SSLSingleRenewMeta)
|
||||||
|
|
||||||
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
DisableForeignKeyConstraintWhenMigrating: true,
|
DisableForeignKeyConstraintWhenMigrating: true,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, sqliteDB.AutoMigrate(&model.TLSCertificate{}))
|
require.NoError(t, sqliteDB.AutoMigrate(&model.TLSCertificate{}, &model.TaskExecution{}))
|
||||||
|
|
||||||
db.SetDB(sqliteDB)
|
db.SetDB(sqliteDB)
|
||||||
oldSecret := config.Config.App.SessionSecret
|
oldSecret := config.Config.App.SessionSecret
|
||||||
|
|||||||
@@ -127,7 +127,8 @@ func (m *Manager) UpdateConfig(ctx context.Context, cfg *service.RelayConfig) {
|
|||||||
m.activeConfig.BindPort == cfg.BindPort &&
|
m.activeConfig.BindPort == cfg.BindPort &&
|
||||||
m.activeConfig.VhostHTTPPort == cfg.VhostHTTPPort &&
|
m.activeConfig.VhostHTTPPort == cfg.VhostHTTPPort &&
|
||||||
m.activeConfig.AuthToken == cfg.AuthToken &&
|
m.activeConfig.AuthToken == cfg.AuthToken &&
|
||||||
m.activeConfig.WebServerEnabled == cfg.WebServerEnabled {
|
m.activeConfig.WebServerEnabled == cfg.WebServerEnabled &&
|
||||||
|
m.activeConfig.WebServerPort == cfg.WebServerPort {
|
||||||
if m.cmd == nil && !m.stopping {
|
if m.cmd == nil && !m.stopping {
|
||||||
slog.Warn("frps config unchanged but process is not running, restarting")
|
slog.Warn("frps config unchanged but process is not running, restarting")
|
||||||
m.stopping = false
|
m.stopping = false
|
||||||
@@ -188,7 +189,11 @@ func (m *Manager) renderConfig(cfg *service.RelayConfig) error {
|
|||||||
} else {
|
} else {
|
||||||
buf.WriteString("addr = \"127.0.0.1\"\n")
|
buf.WriteString("addr = \"127.0.0.1\"\n")
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&buf, "port = %d\n", defaultFrpsWebServerPort)
|
port := cfg.WebServerPort
|
||||||
|
if port <= 0 {
|
||||||
|
port = defaultFrpsWebServerPort
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&buf, "port = %d\n", port)
|
||||||
buf.WriteString("user = \"admin\"\n")
|
buf.WriteString("user = \"admin\"\n")
|
||||||
|
|
||||||
password := m.agentToken
|
password := m.agentToken
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ exit "${EXIT_CODE:-0}"
|
|||||||
|
|
||||||
// Helper to poll for status to eliminate timing flakiness in tests
|
// Helper to poll for status to eliminate timing flakiness in tests
|
||||||
func assertStatusEventually(t *testing.T, m *Manager, expectedStatus string, timeout time.Duration) {
|
func assertStatusEventually(t *testing.T, m *Manager, expectedStatus string, timeout time.Duration) {
|
||||||
|
if timeout < 6*time.Second {
|
||||||
|
timeout = 6 * time.Second
|
||||||
|
}
|
||||||
deadline := time.Now().Add(timeout)
|
deadline := time.Now().Add(timeout)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
rt := m.GetRuntimeStatus()
|
rt := m.GetRuntimeStatus()
|
||||||
@@ -67,6 +70,9 @@ func assertStatusEventually(t *testing.T, m *Manager, expectedStatus string, tim
|
|||||||
|
|
||||||
func assertCommandExitedEventually(t *testing.T, cmd *exec.Cmd, timeout time.Duration) {
|
func assertCommandExitedEventually(t *testing.T, cmd *exec.Cmd, timeout time.Duration) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
if timeout < 6*time.Second {
|
||||||
|
timeout = 6 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ var (
|
|||||||
OpenStoredUpload = ingest.OpenActiveObject
|
OpenStoredUpload = ingest.OpenActiveObject
|
||||||
ActiveUploadHash = ingest.ActiveHash
|
ActiveUploadHash = ingest.ActiveHash
|
||||||
ResolveLocalFile = ingest.ResolveLocalFile
|
ResolveLocalFile = ingest.ResolveLocalFile
|
||||||
IngestFromLocalPath = ingest.IngestFromLocalPath
|
IngestFromLocalPath = ingest.FromLocalPath
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ func ResolveLocalFile(ctx context.Context, req LocalFileCandidateRequest) (strin
|
|||||||
return "", 0, os.ErrNotExist
|
return "", 0, os.ErrNotExist
|
||||||
}
|
}
|
||||||
|
|
||||||
// IngestFromLocalPath ingests a local regular file through the standard upload ingest path.
|
// FromLocalPath ingests a local regular file through the standard upload ingest path.
|
||||||
func IngestFromLocalPath(ctx context.Context, localPath string, req Request) (Result, error) {
|
func FromLocalPath(ctx context.Context, localPath string, req Request) (Result, error) {
|
||||||
localPath = strings.TrimSpace(localPath)
|
localPath = strings.TrimSpace(localPath)
|
||||||
if localPath == "" {
|
if localPath == "" {
|
||||||
return Result{}, errors.New("local path is required")
|
return Result{}, errors.New("local path is required")
|
||||||
@@ -59,7 +59,11 @@ func IngestFromLocalPath(ctx context.Context, localPath string, req Request) (Re
|
|||||||
|
|
||||||
func buildLocalFileCandidates(ctx context.Context, req LocalFileCandidateRequest) []string {
|
func buildLocalFileCandidates(ctx context.Context, req LocalFileCandidateRequest) []string {
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
candidates := make([]string, 0, 8+len(req.RelativePaths)*4)
|
const (
|
||||||
|
initialCandidatesCap = 8
|
||||||
|
relativePathsWeight = 4
|
||||||
|
)
|
||||||
|
candidates := make([]string, 0, initialCandidatesCap+len(req.RelativePaths)*relativePathsWeight)
|
||||||
add := func(raw string) {
|
add := func(raw string) {
|
||||||
value := strings.TrimSpace(raw)
|
value := strings.TrimSpace(raw)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ func TestResolveLocalFileFindsStoredPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestFromLocalPathRequiresPath(t *testing.T) {
|
func TestFromLocalPathRequiresPath(t *testing.T) {
|
||||||
_, err := IngestFromLocalPath(context.Background(), "", Request{})
|
_, err := FromLocalPath(context.Background(), "", Request{})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for empty local path")
|
t.Fatal("expected error for empty local path")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- +goose Up
|
||||||
|
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
('relay_frps_web_ui_enabled', 'false', 'business', 0, '是否启用 FRPS 内置 Web 管理界面', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('relay_frps_web_ui_port', '17500', 'business', 0, 'FRPS 内置 Web 管理界面端口', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DELETE FROM w_system_configs WHERE key IN ('relay_frps_web_ui_enabled', 'relay_frps_web_ui_port');
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- +goose Up
|
||||||
|
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
('relay_frps_web_ui_enabled', 'false', 'business', 0, '是否启用 FRPS 内置 Web 管理界面', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||||
|
('relay_frps_web_ui_port', '17500', 'business', 0, 'FRPS 内置 Web 管理界面端口', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DELETE FROM w_system_configs WHERE key IN ('relay_frps_web_ui_enabled', 'relay_frps_web_ui_port');
|
||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const expectedMigratedSystemConfigCount = 30
|
const expectedMigratedSystemConfigCount = 32
|
||||||
|
|
||||||
func TestMigrateInitializesSQLiteDatabase(t *testing.T) {
|
func TestMigrateInitializesSQLiteDatabase(t *testing.T) {
|
||||||
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ func ListOpenFlareWAFRuleGroupBindingsByRouteID(ctx context.Context, routeID uin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func syncWAFBindingIDSequence(tx *gorm.DB) error {
|
func syncWAFBindingIDSequence(tx *gorm.DB) error {
|
||||||
if tx == nil || tx.Dialector.Name() != "postgres" {
|
if tx == nil || tx.Dialector.Name() != "postgres" { //nolint:staticcheck // QF1008: keep explicit Dialector field access
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return tx.Exec(`
|
return tx.Exec(`
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ const (
|
|||||||
ConfigKeyLoginSessionTTLHours = "login_session_ttl_hours" // 登录会话过期时间 (小时,0表示浏览器关闭后自动退出登录,-1表示永不过期)
|
ConfigKeyLoginSessionTTLHours = "login_session_ttl_hours" // 登录会话过期时间 (小时,0表示浏览器关闭后自动退出登录,-1表示永不过期)
|
||||||
ConfigKeyUpdateUpstreamRepository = "update_upstream_repository" // GitHub Actions Release 上游仓库
|
ConfigKeyUpdateUpstreamRepository = "update_upstream_repository" // GitHub Actions Release 上游仓库
|
||||||
ConfigKeyStorageConfig = "storage_config" // 文件存储配置 (JSON)
|
ConfigKeyStorageConfig = "storage_config" // 文件存储配置 (JSON)
|
||||||
|
ConfigKeyRelayFRPSWebUIEnabled = "relay_frps_web_ui_enabled" // 是否启用 FRPS 内置 Web 界面
|
||||||
|
ConfigKeyRelayFRPSWebUIPort = "relay_frps_web_ui_port" // FRPS 内置 Web 界面端口
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
configTypeSystem = "system"
|
configTypeSystem = "system"
|
||||||
configValueTrue = "true"
|
configTypeBusiness = "business"
|
||||||
configValueFalse = "false"
|
configValueTrue = "true"
|
||||||
|
configValueFalse = "false"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetupTestEnvironment initializes an in-memory SQLite DB, seeds default configurations,
|
// SetupTestEnvironment initializes an in-memory SQLite DB, seeds default configurations,
|
||||||
@@ -275,6 +276,18 @@ func getSeedConfigsPart2() []model.SystemConfig {
|
|||||||
Type: configTypeSystem,
|
Type: configTypeSystem,
|
||||||
Description: "文件存储驱动及连接配置(JSON)",
|
Description: "文件存储驱动及连接配置(JSON)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Key: model.ConfigKeyRelayFRPSWebUIEnabled,
|
||||||
|
Value: configValueFalse,
|
||||||
|
Type: configTypeBusiness,
|
||||||
|
Description: "是否启用 FRPS 内置 Web 界面",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: model.ConfigKeyRelayFRPSWebUIPort,
|
||||||
|
Value: "17500",
|
||||||
|
Type: configTypeBusiness,
|
||||||
|
Description: "FRPS 内置 Web 界面端口",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ var countryCentroidsByName = map[string]countryCentroid{
|
|||||||
"Zimbabwe": {lat: -19.006775, lon: 29.850564},
|
"Zimbabwe": {lat: -19.006775, lon: 29.850564},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountryCentroidByName returns latitude and longitude for a country name, if found.
|
||||||
func CountryCentroidByName(name string) (lat float64, lon float64, ok bool) {
|
func CountryCentroidByName(name string) (lat float64, lon float64, ok bool) {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -191,6 +192,7 @@ func CountryCentroidByName(name string) (lat float64, lon float64, ok bool) {
|
|||||||
return 0, 0, false
|
return 0, 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountryCentroidByISO returns latitude and longitude for an ISO country code, if found.
|
||||||
func CountryCentroidByISO(iso string) (lat float64, lon float64, ok bool) {
|
func CountryCentroidByISO(iso string) (lat float64, lon float64, ok bool) {
|
||||||
name, ok := isoToCountryName[strings.ToUpper(strings.TrimSpace(iso))]
|
name, ok := isoToCountryName[strings.ToUpper(strings.TrimSpace(iso))]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
+1
-1
@@ -118,7 +118,7 @@ func parseIPInfoCoordinates(value string) (*float64, *float64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func formatIPInfoLocation(resp ipInfoResponse) string {
|
func formatIPInfoLocation(resp ipInfoResponse) string {
|
||||||
parts := make([]string, 0, 3)
|
parts := make([]string, 0, 3) //nolint:mnd // 3 parts: city, region, country
|
||||||
if city := strings.TrimSpace(resp.City); city != "" {
|
if city := strings.TrimSpace(resp.City); city != "" {
|
||||||
parts = append(parts, city)
|
parts = append(parts, city)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type RelayConfig struct {
|
|||||||
AuthToken string `json:"auth_token"`
|
AuthToken string `json:"auth_token"`
|
||||||
LogLevel string `json:"log_level"`
|
LogLevel string `json:"log_level"`
|
||||||
WebServerEnabled bool `json:"web_server_enabled"`
|
WebServerEnabled bool `json:"web_server_enabled"`
|
||||||
|
WebServerPort int `json:"web_server_port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RelaySettings holds relay runtime settings.
|
// RelaySettings holds relay runtime settings.
|
||||||
|
|||||||
Reference in New Issue
Block a user