feat: 添加节点自动更新功能,支持手动触发更新

This commit is contained in:
ryan
2026-03-10 20:32:27 +08:00
parent 83a11ead2b
commit e72d658e2f
18 changed files with 227 additions and 90 deletions
+7 -3
View File
@@ -35,6 +35,7 @@ type Runner struct {
Updater Updater
autoUpdate bool
updateNow bool
updateRepo string
}
@@ -130,16 +131,19 @@ func (r *Runner) applySettings(settings *protocol.AgentSettings) bool {
}
}
r.autoUpdate = settings.AutoUpdate
r.updateRepo = settings.UpdateRepo
r.updateNow = settings.UpdateNow
r.updateRepo = strings.TrimSpace(settings.UpdateRepo)
return changed
}
func (r *Runner) tryAutoUpdate(ctx context.Context) {
if !r.autoUpdate || r.Updater == nil || r.updateRepo == "" {
shouldCheck := r.autoUpdate || r.updateNow
r.updateNow = false
if !shouldCheck || r.Updater == nil || r.updateRepo == "" {
return
}
if err := r.Updater.CheckAndUpdate(ctx, r.updateRepo); err != nil {
log.Printf("agent auto-update check failed: %v", err)
log.Printf("agent update check failed: %v", err)
}
}
@@ -18,6 +18,7 @@ type AgentSettings struct {
SyncInterval int `json:"sync_interval"`
AutoUpdate bool `json:"auto_update"`
UpdateRepo string `json:"update_repo"`
UpdateNow bool `json:"update_now"`
}
type NodePayload struct {
-1
View File
@@ -53,7 +53,6 @@ var NodeOfflineThreshold = 2 * time.Minute
// V3 operational settings (hot-reloadable via Option table)
var AgentHeartbeatInterval = 30000 // milliseconds
var AgentSyncInterval = 30000 // milliseconds
var AgentAutoUpdate = false
var AgentUpdateRepo = "Rain-kl/ATSFlare"
const (
+26 -1
View File
@@ -3,9 +3,10 @@ package controller
import (
"atsflare/service"
"encoding/json"
"github.com/gin-gonic/gin"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func CreateNode(c *gin.Context) {
@@ -117,3 +118,27 @@ func DeleteNode(c *gin.Context) {
"message": "",
})
}
func RequestNodeAgentUpdate(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}
node, err := service.RequestNodeAgentUpdate(uint(id))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": node,
})
}
+15 -13
View File
@@ -3,19 +3,21 @@ package model
import "time"
type Node struct {
ID uint `json:"id" gorm:"primaryKey"`
NodeID string `json:"node_id" gorm:"uniqueIndex;size:64;not null"`
Name string `json:"name" gorm:"size:128;not null"`
IP string `json:"ip" gorm:"size:64;not null"`
AgentToken string `json:"-" gorm:"size:128;index"`
AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
NginxVersion string `json:"nginx_version" gorm:"size:64"`
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
CurrentVersion string `json:"current_version" gorm:"size:32"`
LastSeenAt time.Time `json:"last_seen_at"`
LastError string `json:"last_error" gorm:"size:1024"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id" gorm:"primaryKey"`
NodeID string `json:"node_id" gorm:"uniqueIndex;size:64;not null"`
Name string `json:"name" gorm:"size:128;not null"`
IP string `json:"ip" gorm:"size:64;not null"`
AgentToken string `json:"-" gorm:"size:128;index"`
AutoUpdateEnabled bool `json:"auto_update_enabled" gorm:"not null;default:false"`
UpdateRequested bool `json:"update_requested" gorm:"not null;default:false"`
AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
NginxVersion string `json:"nginx_version" gorm:"size:64"`
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
CurrentVersion string `json:"current_version" gorm:"size:32"`
LastSeenAt time.Time `json:"last_seen_at"`
LastError string `json:"last_error" gorm:"size:1024"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func ListNodes() (nodes []*Node, err error) {
-3
View File
@@ -54,7 +54,6 @@ func InitOptionMap() {
common.OptionMap["AgentHeartbeatInterval"] = strconv.Itoa(common.AgentHeartbeatInterval)
common.OptionMap["AgentSyncInterval"] = strconv.Itoa(common.AgentSyncInterval)
common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds()))
common.OptionMap["AgentAutoUpdate"] = strconv.FormatBool(common.AgentAutoUpdate)
common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo
common.OptionMapRWMutex.Unlock()
options, _ := AllOption()
@@ -162,8 +161,6 @@ func updateOptionMap(key string, value string) {
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.NodeOfflineThreshold = time.Duration(v) * time.Millisecond
}
case "AgentAutoUpdate":
common.AgentAutoUpdate = value == "true"
case "AgentUpdateRepo":
if value != "" {
common.AgentUpdateRepo = value
+2
View File
@@ -3,6 +3,7 @@ package router
import (
"atsflare/controller"
"atsflare/middleware"
"github.com/gin-gonic/gin"
)
@@ -104,6 +105,7 @@ func SetApiRouter(router *gin.Engine) {
nodeRoute.POST("/bootstrap-token/rotate", controller.RotateNodeBootstrapToken)
nodeRoute.GET("/", controller.GetNodes)
nodeRoute.POST("/", controller.CreateNode)
nodeRoute.POST("/:id/agent-update", controller.RequestNodeAgentUpdate)
nodeRoute.PUT("/:id", controller.UpdateNode)
nodeRoute.DELETE("/:id", controller.DeleteNode)
}
+8 -2
View File
@@ -49,6 +49,7 @@ type AgentSettings struct {
SyncInterval int `json:"sync_interval"`
AutoUpdate bool `json:"auto_update"`
UpdateRepo string `json:"update_repo"`
UpdateNow bool `json:"update_now"`
}
type HeartbeatResponse struct {
@@ -63,6 +64,8 @@ type NodeView struct {
IP string `json:"ip"`
AgentToken string `json:"agent_token"`
Pending bool `json:"pending"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
UpdateRequested bool `json:"update_requested"`
AgentVersion string `json:"agent_version"`
NginxVersion string `json:"nginx_version"`
Status string `json:"status"`
@@ -87,8 +90,10 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon
if err := validateAgentNodePayload(payload); err != nil {
return nil, err
}
updateNow := node.UpdateRequested
applyNodeRuntime(node, payload, true)
if err := model.DB.Model(node).Select("ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error").Updates(node).Error; err != nil {
node.UpdateRequested = false
if err := model.DB.Model(node).Select("ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error", "update_requested").Updates(node).Error; err != nil {
return nil, err
}
return &HeartbeatResponse{
@@ -96,8 +101,9 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon
AgentSettings: &AgentSettings{
HeartbeatInterval: common.AgentHeartbeatInterval,
SyncInterval: common.AgentSyncInterval,
AutoUpdate: common.AgentAutoUpdate,
AutoUpdate: node.AutoUpdateEnabled,
UpdateRepo: common.AgentUpdateRepo,
UpdateNow: updateNow,
},
}, nil
}
+38 -20
View File
@@ -11,7 +11,8 @@ import (
)
type NodeInput struct {
Name string `json:"name"`
Name string `json:"name"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
}
type NodeBootstrapView struct {
@@ -30,11 +31,12 @@ func CreateNode(input NodeInput) (*NodeView, error) {
return nil, errors.New("节点名不能为空")
}
node := &model.Node{
Name: name,
IP: "",
AgentVersion: "",
NginxVersion: "",
Status: NodeStatusPending,
Name: name,
IP: "",
AgentVersion: "",
NginxVersion: "",
Status: NodeStatusPending,
AutoUpdateEnabled: input.AutoUpdateEnabled,
}
var err error
node.NodeID, err = newServerNodeID()
@@ -65,6 +67,7 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
return nil, err
}
node.Name = name
node.AutoUpdateEnabled = input.AutoUpdateEnabled
if err = node.Update(); err != nil {
return nil, err
}
@@ -81,6 +84,19 @@ func DeleteNode(id uint) error {
return node.Delete()
}
func RequestNodeAgentUpdate(id uint) (*NodeView, error) {
node, err := model.GetNodeByID(id)
if err != nil {
return nil, err
}
node.UpdateRequested = true
if err = model.DB.Model(node).Update("update_requested", true).Error; err != nil {
return nil, err
}
common.SysLog("agent manual update requested: node_id=" + node.NodeID + " name=" + node.Name)
return buildNodeView(node), nil
}
func AuthenticateAgentToken(token string) (*model.Node, error) {
token = strings.TrimSpace(token)
if token == "" {
@@ -149,20 +165,22 @@ func RotateGlobalDiscoveryToken() (*NodeBootstrapView, error) {
func buildNodeView(node *model.Node) *NodeView {
status := computeNodeStatus(node)
view := &NodeView{
ID: node.ID,
NodeID: node.NodeID,
Name: node.Name,
IP: node.IP,
AgentToken: node.AgentToken,
AgentVersion: node.AgentVersion,
NginxVersion: node.NginxVersion,
Status: status,
CurrentVersion: node.CurrentVersion,
LastSeenAt: node.LastSeenAt,
LastError: node.LastError,
CreatedAt: node.CreatedAt,
UpdatedAt: node.UpdatedAt,
Pending: status == NodeStatusPending,
ID: node.ID,
NodeID: node.NodeID,
Name: node.Name,
IP: node.IP,
AgentToken: node.AgentToken,
AgentVersion: node.AgentVersion,
NginxVersion: node.NginxVersion,
Status: status,
CurrentVersion: node.CurrentVersion,
LastSeenAt: node.LastSeenAt,
LastError: node.LastError,
CreatedAt: node.CreatedAt,
UpdatedAt: node.UpdatedAt,
Pending: status == NodeStatusPending,
AutoUpdateEnabled: node.AutoUpdateEnabled,
UpdateRequested: node.UpdateRequested,
}
return view
}
+21 -11
View File
@@ -9,7 +9,14 @@ import NotFound from './pages/NotFound';
import Setting from './pages/Setting';
import EditUser from './pages/User/EditUser';
import AddUser from './pages/User/AddUser';
import { API, showError, showNotice } from './helpers';
import {
API,
getBuildVersion,
hasBuildVersion,
normalizeVersion,
showError,
showNotice,
} from './helpers';
import PasswordResetForm from './components/PasswordResetForm';
import GitHubOAuth from './components/GitHubOAuth';
import PasswordResetConfirm from './components/PasswordResetConfirm';
@@ -44,16 +51,19 @@ function App() {
console.log(`GitHub 仓库地址:https://github.com/Rain-kl/ATSFlare`);
localStorage.setItem('status', JSON.stringify(data));
statusDispatch({ type: 'set', payload: data });
localStorage.setItem('system_name', data.system_name);
localStorage.setItem('footer_html', data.footer_html);
localStorage.setItem('home_page_link', data.home_page_link);
if (
data.version !== process.env.REACT_APP_VERSION &&
data.version !== 'v0.0.0' &&
process.env.REACT_APP_VERSION !== ''
) {
showNotice(
`新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
localStorage.setItem('system_name', data.system_name);
localStorage.setItem('footer_html', data.footer_html);
localStorage.setItem('home_page_link', data.home_page_link);
const currentBuildVersion = getBuildVersion();
const latestServerVersion = normalizeVersion(data.version);
if (
hasBuildVersion() &&
latestServerVersion !== '' &&
latestServerVersion !== '0.0.0' &&
latestServerVersion !== currentBuildVersion
) {
showNotice(
`新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
);
}
} else {
@@ -17,7 +17,6 @@ const OperationSetting = () => {
AgentHeartbeatInterval: '30000',
AgentSyncInterval: '30000',
NodeOfflineThreshold: '120000',
AgentAutoUpdate: 'false',
AgentUpdateRepo: 'Rain-kl/ATSFlare',
ServerAddress: '',
AgentDiscoveryToken: '',
@@ -77,10 +76,6 @@ const OperationSetting = () => {
setInputs((prev) => ({ ...prev, [name]: value }));
};
const handleCheckboxChange = (e, { name, checked }) => {
setInputs((prev) => ({ ...prev, [name]: checked ? 'true' : 'false' }));
};
const submitAgentIntervals = async () => {
const hb = parseInt(inputs.AgentHeartbeatInterval, 10);
const sync = parseInt(inputs.AgentSyncInterval, 10);
@@ -102,8 +97,7 @@ const OperationSetting = () => {
await updateOption('NodeOfflineThreshold', String(offline));
};
const submitAutoUpdate = async () => {
await updateOption('AgentAutoUpdate', inputs.AgentAutoUpdate);
const submitUpdateRepo = async () => {
await updateOption('AgentUpdateRepo', inputs.AgentUpdateRepo);
};
@@ -178,15 +172,13 @@ const OperationSetting = () => {
</Form.Button>
<Divider />
<Header as='h3'>Agent 自动更新</Header>
<Form.Group>
<Form.Checkbox
label='启用 Agent 自动更新'
checked={inputs.AgentAutoUpdate === 'true'}
name='AgentAutoUpdate'
onChange={handleCheckboxChange}
/>
</Form.Group>
<Header as='h3'>Agent 更新源</Header>
<Message info size='small'>
<p>
自动更新开关和手动更新由节点页单独控制,这里只配置 Agent
更新仓库。
</p>
</Message>
<Form.Group widths='equal'>
<Form.Input
label='更新仓库'
@@ -196,7 +188,7 @@ const OperationSetting = () => {
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={submitAutoUpdate} primary>
<Form.Button onClick={submitUpdateRepo} primary>
保存更新设置
</Form.Button>
+20 -10
View File
@@ -1,6 +1,13 @@
import React, { useEffect, useState } from 'react';
import { Button, Divider, Form, Grid, Header, Modal } from 'semantic-ui-react';
import { API, showError, showSuccess } from '../helpers';
import {
API,
getBuildVersion,
hasBuildVersion,
normalizeVersion,
showError,
showSuccess,
} from '../helpers';
import { marked } from 'marked';
const OtherSetting = () => {
@@ -81,15 +88,18 @@ const OtherSetting = () => {
window.location = 'https://github.com/Rain-kl/ATSFlare/releases/latest';
};
const checkUpdate = async () => {
const res = await API.get(
'https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest'
);
const { tag_name, body } = res.data;
if (tag_name === process.env.REACT_APP_VERSION) {
showSuccess(`已是最新版本:${tag_name}`);
} else {
setUpdateData({
const checkUpdate = async () => {
const res = await API.get(
'https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest'
);
const { tag_name, body } = res.data;
if (
hasBuildVersion() &&
normalizeVersion(tag_name) === getBuildVersion()
) {
showSuccess(`已是最新版本:${tag_name}`);
} else {
setUpdateData({
tag_name: tag_name,
content: marked.parse(body),
});
+13
View File
@@ -25,6 +25,19 @@ export function getFooterHTML() {
return localStorage.getItem('footer_html');
}
export function normalizeVersion(value) {
if (!value) return '';
return String(value).trim().replace(/^v/i, '');
}
export function getBuildVersion() {
return normalizeVersion(process.env.REACT_APP_VERSION);
}
export function hasBuildVersion() {
return getBuildVersion() !== '';
}
export async function copy(text, label) {
let okay = true;
try {
+42
View File
@@ -4,6 +4,7 @@ import { API, showError, showSuccess, timeAgo } from '../../helpers';
const initialForm = {
name: '',
auto_update_enabled: false,
};
const renderStatus = (status) => {
@@ -26,6 +27,16 @@ const renderApply = (result) => {
return <Label>暂无</Label>;
};
const renderUpdateMode = (node) => {
if (node.update_requested) {
return <Label color='orange'>等待更新</Label>;
}
if (node.auto_update_enabled) {
return <Label color='green'>自动更新</Label>;
}
return <Label>手动更新</Label>;
};
const Node = () => {
const [nodes, setNodes] = useState([]);
const [bootstrap, setBootstrap] = useState({ discovery_token: '' });
@@ -91,6 +102,7 @@ const Node = () => {
setSubmitting(true);
const payload = {
name: form.name.trim(),
auto_update_enabled: form.auto_update_enabled,
};
const res = editingId
? await API.put(`/api/nodes/${editingId}`, payload)
@@ -110,9 +122,21 @@ const Node = () => {
setEditingId(node.id);
setForm({
name: node.name || '',
auto_update_enabled: !!node.auto_update_enabled,
});
};
const requestAgentUpdate = async (node) => {
const res = await API.post(`/api/nodes/${node.id}/agent-update`);
const { success, message } = res.data;
if (success) {
showSuccess(`已向节点 ${node.name} 下发更新指令`);
await loadNodes(false);
} else {
showError(message);
}
};
const deleteNode = async (node) => {
if (
!window.confirm(
@@ -163,6 +187,14 @@ const Node = () => {
value={form.name}
onChange={(e, { value }) => setForm({ ...form, name: value })}
/>
<Form.Checkbox
label='启用该节点自动更新'
checked={form.auto_update_enabled}
onChange={(e, { checked }) =>
setForm({ ...form, auto_update_enabled: !!checked })
}
style={{ alignSelf: 'flex-end', marginBottom: '0.5rem' }}
/>
</Form.Group>
<Button primary type='submit' loading={submitting}>
{editingId ? '保存修改' : '新增节点'}
@@ -182,6 +214,7 @@ const Node = () => {
<Table.HeaderCell>Auth Token</Table.HeaderCell>
<Table.HeaderCell>IP</Table.HeaderCell>
<Table.HeaderCell>状态</Table.HeaderCell>
<Table.HeaderCell>Agent 更新</Table.HeaderCell>
<Table.HeaderCell>Agent / Nginx</Table.HeaderCell>
<Table.HeaderCell>当前版本</Table.HeaderCell>
<Table.HeaderCell>最近应用</Table.HeaderCell>
@@ -216,6 +249,7 @@ const Node = () => {
</Table.Cell>
<Table.Cell>{node.ip}</Table.Cell>
<Table.Cell>{renderStatus(node.status)}</Table.Cell>
<Table.Cell>{renderUpdateMode(node)}</Table.Cell>
<Table.Cell>
{node.agent_version} / {node.nginx_version || 'unknown'}
</Table.Cell>
@@ -231,6 +265,14 @@ const Node = () => {
</Table.Cell>
<Table.Cell>{node.last_error || '无'}</Table.Cell>
<Table.Cell>
<Button
size='small'
primary
disabled={node.update_requested}
onClick={() => requestAgentUpdate(node)}
>
立即更新
</Button>
<Button size='small' onClick={() => beginEdit(node)}>
编辑
</Button>
+15 -2
View File
@@ -229,7 +229,14 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/insta
### 7.3 Agent 自动更新
在管理端 **运维设置** 中启用「Agent 自动更新」后,Agent 会在每次心跳周期检查 GitHub Releases,发现新版本时自动下载并重启。
Agent 自动更新默认为关闭。
在管理端 **节点管理** 页面中可以:
* 为单个节点开启「自动更新」
* 为单个节点点击「立即更新」,下发一次性更新指令
节点在收到对应心跳响应后,会检查 GitHub Releases,发现新版本时自动下载并重启。
---
@@ -252,9 +259,15 @@ GitHub Release 中的 Agent 二进制命名格式:
| AgentHeartbeatInterval | 心跳间隔(毫秒) | heartbeat_interval |
| AgentSyncInterval | 同步间隔(毫秒) | sync_interval |
| NodeOfflineThreshold | 节点离线阈值(毫秒) | - |
| AgentAutoUpdate | 是否启用自动更新 | auto_update |
| AgentUpdateRepo | 自动更新仓库 | update_repo |
节点管理页下发的字段:
| 参数 | 说明 | Agent 字段 |
| ---------------------- | ------------------ | ----------- |
| Node.AutoUpdateEnabled | 节点是否自动更新 | auto_update |
| Node.UpdateRequested | 节点一次性更新请求 | update_now |
---
## 10. 当前已知限制
+7 -4
View File
@@ -347,7 +347,6 @@ Agent 接口当前覆盖:
* `AgentHeartbeatInterval`:Agent 心跳上报间隔(毫秒),默认 30000
* `AgentSyncInterval`:Agent 配置同步间隔(毫秒),默认 30000
* `NodeOfflineThreshold`:节点离线判定阈值(毫秒),默认 120000
* `AgentAutoUpdate`:是否允许 Agent 自动更新(`true`/`false`),默认 `false`
* `AgentUpdateRepo`:Agent 自动更新 GitHub 仓库地址,默认 `Rain-kl/ATSFlare`
* 环境变量类设置(`SESSION_SECRET`、`SQLITE_PATH`、`PORT`)不迁移,保留原有方式
* 前端在设置页面新增「运维设置」Tab
@@ -364,8 +363,9 @@ Agent 接口当前覆盖:
* 心跳响应新增 `agent_settings` 字段,包含 Server 端可控的运行时参数:
* `heartbeat_interval`(毫秒)
* `sync_interval`(毫秒)
* `auto_update`(布尔值)
* `auto_update`(节点级布尔值)
* `update_repo`(GitHub 仓库名)
* `update_now`(一次性手动更新指令)
* Agent 收到心跳响应后,动态调整本地定时器间隔
* 当 Server 未返回 `agent_settings` 或字段为空时,Agent 保持本地值不变
* Agent 不持久化 Server 下发的间隔值,重启后以本地 `agent.json` 为准,再由下次心跳覆盖
@@ -378,7 +378,7 @@ Agent 接口当前覆盖:
第三版变更:
* Agent 在收到 `auto_update=true` 时:
* Agent 在收到 `auto_update=true` 或 `update_now=true` 时:
* 通过 GitHub Releases API 查询 `update_repo` 的最新 Release
* 比较本地 `agent_version` 与远端 tag
* 若存在更新,下载对应平台的二进制文件
@@ -387,6 +387,8 @@ Agent 接口当前覆盖:
* 更新过程中不中断当前同步任务
* 更新失败不影响正常心跳与同步
* Agent 二进制文件命名约定:`atsflare-agent-{os}-{arch}`
* `auto_update` 默认关闭,由节点管理页逐节点开启
* `update_now` 由节点管理页手动触发,一次心跳消费一次
### 11.5 Agent 一键部署
@@ -443,13 +445,14 @@ Agent 接口当前覆盖:
* Agent 心跳间隔
* Agent 同步间隔
* 节点离线阈值
* Agent 自动更新开关
* Agent 更新仓库
* 全局 Discovery Token 展示与重新生成
* Agent 一键部署命令展示(根据当前 ServerAddress 和 DiscoveryToken 动态生成 curl 命令)
* 节点列表页优化:
* 时间显示改为友好的相对时间格式
* 节点状态使用颜色标识
* 支持逐节点开启自动更新
* 支持逐节点手动触发一次 Agent 更新
---
+1 -1
View File
@@ -205,7 +205,7 @@ Agent 必须满足:
V3 新增行为:
* 心跳响应包含 `agent_settings` 时,动态调整定时器间隔
* `auto_update=true` 时在每次心跳后检查 GitHub Releases 更新
* `auto_update=true` 或 `update_now=true` 时在每次心跳后检查 GitHub Releases 更新
* 自我更新失败不影响心跳与同步
* Server 下发的间隔值不持久化到 `agent.json`,重启后以本地为准
* Agent 新增 `internal/updater` 模块处理自我更新逻辑
+2 -2
View File
@@ -53,7 +53,6 @@
* `AgentHeartbeatInterval`(默认 30000ms)
* `AgentSyncInterval`(默认 30000ms)
* `NodeOfflineThreshold`(默认 120000ms)
* `AgentAutoUpdate`(默认 false)
* `AgentUpdateRepo`(默认 `Rain-kl/ATSFlare`)
2. 在 `model/option.go` 的 `InitOptionMap()` 注册新选项
3. 在 `model/option.go` 的 `updateOptionMap()` 增加对新选项的同步
@@ -101,13 +100,14 @@
* 下载对应平台二进制
* 替换自身并重启(exec syscall)
2. 在 `runner.go` 心跳循环中集成更新检查
3. 更新触发条件:`auto_update=true` 且存在新版本
3. 更新触发条件:节点 `auto_update=true` 或收到一次性 `update_now=true` 且存在新版本
验收标准:
* Agent 能正确检测新版本
* 能下载并替换自身二进制
* 更新后自动重启并恢复心跳
* 默认不自动更新,需由控制面板逐节点开启或手动触发
* 更新失败不影响正常运行
### 3.4 阶段四:GitHub Actions 完善与 Agent 一键部署