From 1f55c2448d8e02644e8328c7a5d18a013a39957d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 21:24:58 +0800 Subject: [PATCH] feat: add NTP server configuration and manual sync server option (#1232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat: add NTP server configuration support for time sync Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * feat: add system NTP server configuration with chrony and timesyncd support Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * fix: improve NTP service restart error handling Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * feat: 优化ntp * fix: logo跳转 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> Co-authored-by: 耗子 --- internal/http/request/toolbox_system.go | 8 + internal/route/http.go | 2 + internal/service/toolbox_system.go | 46 ++- pkg/ntp/ntp.go | 17 +- pkg/ntp/system.go | 299 ++++++++++++++++++ web/src/api/panel/toolbox-system/index.ts | 9 +- .../layout/sidebar/components/SideLogo.vue | 2 +- web/src/views/toolbox/SystemView.vue | 124 +++++++- 8 files changed, 497 insertions(+), 10 deletions(-) create mode 100644 pkg/ntp/system.go diff --git a/internal/http/request/toolbox_system.go b/internal/http/request/toolbox_system.go index b0d01a53..0a45a485 100644 --- a/internal/http/request/toolbox_system.go +++ b/internal/http/request/toolbox_system.go @@ -30,3 +30,11 @@ type ToolboxSystemHosts struct { type ToolboxSystemPassword struct { Password string `form:"password" json:"password" validate:"required|password"` } + +type ToolboxSystemSyncTime struct { + Server string `form:"server" json:"server"` // 可选的 NTP 服务器地址 +} + +type ToolboxSystemNTPServers struct { + Servers []string `form:"servers" json:"servers" validate:"required"` +} diff --git a/internal/route/http.go b/internal/route/http.go index d55a4b27..7900c0f9 100644 --- a/internal/route/http.go +++ b/internal/route/http.go @@ -468,6 +468,8 @@ func (route *Http) Register(r *chi.Mux) { r.Post("/timezone", route.toolboxSystem.UpdateTimezone) r.Post("/time", route.toolboxSystem.UpdateTime) r.Post("/sync_time", route.toolboxSystem.SyncTime) + r.Get("/ntp_servers", route.toolboxSystem.GetNTPServers) + r.Post("/ntp_servers", route.toolboxSystem.UpdateNTPServers) r.Get("/hostname", route.toolboxSystem.GetHostname) r.Post("/hostname", route.toolboxSystem.UpdateHostname) r.Get("/hosts", route.toolboxSystem.GetHosts) diff --git a/internal/service/toolbox_system.go b/internal/service/toolbox_system.go index 2cf7f2a1..56a2957f 100644 --- a/internal/service/toolbox_system.go +++ b/internal/service/toolbox_system.go @@ -6,6 +6,7 @@ import ( "path/filepath" "regexp" "strings" + "time" "github.com/leonelquinteros/gotext" "github.com/libtnb/chix" @@ -231,7 +232,18 @@ func (s *ToolboxSystemService) UpdateTime(w http.ResponseWriter, r *http.Request // SyncTime 同步时间 func (s *ToolboxSystemService) SyncTime(w http.ResponseWriter, r *http.Request) { - now, err := ntp.Now() + req, err := Bind[request.ToolboxSystemSyncTime](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + var now time.Time + if req.Server != "" { + now, err = ntp.Now(req.Server) + } else { + now, err = ntp.Now() + } if err != nil { Error(w, http.StatusInternalServerError, "%v", err) return @@ -245,6 +257,38 @@ func (s *ToolboxSystemService) SyncTime(w http.ResponseWriter, r *http.Request) Success(w, nil) } +// GetNTPServers 获取系统 NTP 服务器配置 +func (s *ToolboxSystemService) GetNTPServers(w http.ResponseWriter, r *http.Request) { + config, err := ntp.GetSystemNTPConfig() + if err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to get NTP configuration: %v", err)) + return + } + + Success(w, chix.M{ + "service_type": config.ServiceType, + "servers": config.Servers, + "builtins": ntp.GetBuiltinServers(), + }) +} + +// UpdateNTPServers 更新系统 NTP 服务器配置 +func (s *ToolboxSystemService) UpdateNTPServers(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ToolboxSystemNTPServers](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + // 更新系统 NTP 配置 + if err = ntp.SetSystemNTPServers(req.Servers); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to set NTP servers: %v", err)) + return + } + + Success(w, nil) +} + // GetHostname 获取主机名 func (s *ToolboxSystemService) GetHostname(w http.ResponseWriter, r *http.Request) { hostname, err := shell.Execf("hostnamectl hostname") diff --git a/pkg/ntp/ntp.go b/pkg/ntp/ntp.go index c16797e6..bc2e06cc 100644 --- a/pkg/ntp/ntp.go +++ b/pkg/ntp/ntp.go @@ -15,8 +15,8 @@ var ErrNotReachable = errors.New("failed to reach NTP server") var ErrNoAvailableServer = errors.New("no available NTP server found") -var defaultAddresses = []string{ - //"ntp.ntsc.ac.cn", // 中科院国家授时中心的服务器很快,但是多刷几次就会被封 +// builtinAddresses 内置的 NTP 服务器地址列表(用于面板同步时间) +var builtinAddresses = []string{ "ntp.aliyun.com", // 阿里云 "ntp1.aliyun.com", // 阿里云2 "ntp.tencent.com", // 腾讯云 @@ -24,8 +24,15 @@ var defaultAddresses = []string{ "time.apple.com", // Apple } +// GetBuiltinServers 获取内置的 NTP 服务器列表 +func GetBuiltinServers() []string { + result := make([]string, len(builtinAddresses)) + copy(result, builtinAddresses) + return result +} + func Now(address ...string) (time.Time, error) { - if len(address) > 0 { + if len(address) > 0 && address[0] != "" { if now, err := ntp.Time(address[0]); err != nil { return time.Now(), fmt.Errorf("%w: %s", ErrNotReachable, err) } else { @@ -33,7 +40,7 @@ func Now(address ...string) (time.Time, error) { } } - best, err := bestServer(defaultAddresses...) + best, err := bestServer(builtinAddresses...) if err != nil { return time.Now(), err } @@ -70,7 +77,7 @@ func pingServer(addr string) (time.Duration, error) { // bestServer 返回延迟最低的NTP服务器 func bestServer(addresses ...string) (string, error) { if len(addresses) == 0 { - addresses = defaultAddresses + addresses = builtinAddresses } type ntpResult struct { diff --git a/pkg/ntp/system.go b/pkg/ntp/system.go new file mode 100644 index 00000000..1ee2047e --- /dev/null +++ b/pkg/ntp/system.go @@ -0,0 +1,299 @@ +package ntp + +import ( + "bufio" + "fmt" + "regexp" + "strings" + + "github.com/acepanel/panel/pkg/io" + "github.com/acepanel/panel/pkg/shell" +) + +// NTPServiceType 表示系统使用的 NTP 服务类型 +type NTPServiceType string + +const ( + NTPServiceTimesyncd NTPServiceType = "timesyncd" // systemd-timesyncd (Debian/Ubuntu) + NTPServiceChrony NTPServiceType = "chrony" // chrony (RHEL/CentOS/Rocky) + NTPServiceUnknown NTPServiceType = "unknown" // 未知或不支持 +) + +// timesyncd 配置文件路径 +const timesyncdConfigPath = "/etc/systemd/timesyncd.conf" + +// chrony 配置文件路径(按优先级排序) +var chronyConfigPaths = []string{ + "/etc/chrony.conf", + "/etc/chrony/chrony.conf", +} + +// SystemNTPConfig 系统 NTP 配置信息 +type SystemNTPConfig struct { + ServiceType NTPServiceType `json:"service_type"` // 服务类型 + Servers []string `json:"servers"` // NTP 服务器列表 +} + +// DetectNTPService 检测系统使用的 NTP 服务类型 +func DetectNTPService() NTPServiceType { + // 优先检查 chrony + if _, err := shell.Execf("systemctl is-active chronyd 2>/dev/null"); err == nil { + return NTPServiceChrony + } + if _, err := shell.Execf("systemctl is-active chrony 2>/dev/null"); err == nil { + return NTPServiceChrony + } + + // 检查 systemd-timesyncd + if _, err := shell.Execf("systemctl is-active systemd-timesyncd 2>/dev/null"); err == nil { + return NTPServiceTimesyncd + } + + // 检查配置文件是否存在 + for _, path := range chronyConfigPaths { + if io.Exists(path) { + return NTPServiceChrony + } + } + if io.Exists(timesyncdConfigPath) { + return NTPServiceTimesyncd + } + + return NTPServiceUnknown +} + +// GetSystemNTPConfig 获取系统 NTP 配置 +func GetSystemNTPConfig() (*SystemNTPConfig, error) { + serviceType := DetectNTPService() + config := &SystemNTPConfig{ + ServiceType: serviceType, + Servers: []string{}, + } + + switch serviceType { + case NTPServiceTimesyncd: + servers, err := getTimesyncdServers() + if err != nil { + return config, err + } + config.Servers = servers + case NTPServiceChrony: + servers, err := getChronyServers() + if err != nil { + return config, err + } + config.Servers = servers + } + + return config, nil +} + +// SetSystemNTPServers 设置系统 NTP 服务器 +func SetSystemNTPServers(servers []string) error { + serviceType := DetectNTPService() + + switch serviceType { + case NTPServiceTimesyncd: + return setTimesyncdServers(servers) + case NTPServiceChrony: + return setChronyServers(servers) + default: + return fmt.Errorf("unsupported NTP service type") + } +} + +// getTimesyncdServers 获取 systemd-timesyncd 的 NTP 服务器配置 +func getTimesyncdServers() ([]string, error) { + if !io.Exists(timesyncdConfigPath) { + return []string{}, nil + } + + content, err := io.Read(timesyncdConfigPath) + if err != nil { + return nil, err + } + + // 解析配置文件,查找 NTP= 行 + var servers []string + scanner := bufio.NewScanner(strings.NewReader(content)) + ntpRegex := regexp.MustCompile(`^\s*NTP\s*=\s*(.+)$`) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "#") { + continue + } + if matches := ntpRegex.FindStringSubmatch(line); len(matches) > 1 { + // NTP 服务器以空格分隔 + for _, server := range strings.Fields(matches[1]) { + if server != "" { + servers = append(servers, server) + } + } + } + } + + return servers, nil +} + +// setTimesyncdServers 设置 systemd-timesyncd 的 NTP 服务器配置 +func setTimesyncdServers(servers []string) error { + var content string + if io.Exists(timesyncdConfigPath) { + var err error + content, err = io.Read(timesyncdConfigPath) + if err != nil { + return err + } + } + + // 构建新的 NTP 配置行 + ntpLine := "NTP=" + strings.Join(servers, " ") + + // 检查是否已有 [Time] 段和 NTP= 行 + hasTimeSection := strings.Contains(content, "[Time]") + ntpRegex := regexp.MustCompile(`(?m)^\s*#?\s*NTP\s*=.*$`) + + if ntpRegex.MatchString(content) { + // 替换现有的 NTP= 行 + content = ntpRegex.ReplaceAllString(content, ntpLine) + } else if hasTimeSection { + // 在 [Time] 段后添加 NTP= 行 + content = strings.Replace(content, "[Time]", "[Time]\n"+ntpLine, 1) + } else { + // 添加 [Time] 段和 NTP= 行 + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + content += "[Time]\n" + ntpLine + "\n" + } + + // 写入配置文件 + if err := io.Write(timesyncdConfigPath, content, 0644); err != nil { + return err + } + + // 重启 systemd-timesyncd 服务 + _, _ = shell.Execf("systemctl restart systemd-timesyncd 2>/dev/null") + + return nil +} + +// getChronyServers 获取 chrony 的 NTP 服务器配置 +func getChronyServers() ([]string, error) { + var configPath string + for _, path := range chronyConfigPaths { + if io.Exists(path) { + configPath = path + break + } + } + + if configPath == "" { + return []string{}, nil + } + + content, err := io.Read(configPath) + if err != nil { + return nil, err + } + + // 解析配置文件,查找 server 或 pool 行 + var servers []string + scanner := bufio.NewScanner(strings.NewReader(content)) + serverRegex := regexp.MustCompile(`^\s*(server|pool)\s+(\S+)`) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "#") { + continue + } + if matches := serverRegex.FindStringSubmatch(line); len(matches) > 2 { + servers = append(servers, matches[2]) + } + } + + return servers, nil +} + +// setChronyServers 设置 chrony 的 NTP 服务器配置 +func setChronyServers(servers []string) error { + var configPath string + for _, path := range chronyConfigPaths { + if io.Exists(path) { + configPath = path + break + } + } + + if configPath == "" { + // 如果配置文件不存在,使用默认路径 + configPath = chronyConfigPaths[0] + } + + var content string + if io.Exists(configPath) { + var err error + content, err = io.Read(configPath) + if err != nil { + return err + } + } + + // 移除现有的 server 和 pool 行 + var newLines []string + scanner := bufio.NewScanner(strings.NewReader(content)) + serverRegex := regexp.MustCompile(`^\s*(server|pool)\s+`) + + for scanner.Scan() { + line := scanner.Text() + if !serverRegex.MatchString(line) { + newLines = append(newLines, line) + } + } + + // 在文件开头添加新的 server 行 + var serverLines []string + for _, server := range servers { + serverLines = append(serverLines, fmt.Sprintf("server %s iburst", server)) + } + + // 组合新内容 + newContent := strings.Join(serverLines, "\n") + if len(newLines) > 0 { + newContent += "\n" + strings.Join(newLines, "\n") + } + if !strings.HasSuffix(newContent, "\n") { + newContent += "\n" + } + + // 写入配置文件 + if err := io.Write(configPath, newContent, 0644); err != nil { + return err + } + + // 重启 chrony 服务 + _, _ = shell.Execf("systemctl restart chronyd 2>/dev/null") + _, _ = shell.Execf("systemctl restart chrony 2>/dev/null") + + return nil +} + +// RestartNTPService 重启 NTP 服务 +func RestartNTPService() error { + serviceType := DetectNTPService() + + switch serviceType { + case NTPServiceTimesyncd: + _, err := shell.Execf("systemctl restart systemd-timesyncd") + return err + case NTPServiceChrony: + if _, err := shell.Execf("systemctl restart chronyd 2>/dev/null"); err != nil { + _, err = shell.Execf("systemctl restart chrony") + return err + } + return nil + default: + return fmt.Errorf("unsupported NTP service type") + } +} diff --git a/web/src/api/panel/toolbox-system/index.ts b/web/src/api/panel/toolbox-system/index.ts index a406c489..5d755c9f 100644 --- a/web/src/api/panel/toolbox-system/index.ts +++ b/web/src/api/panel/toolbox-system/index.ts @@ -15,8 +15,13 @@ export default { updateTimezone: (timezone: string): any => http.Post('/toolbox_system/timezone', { timezone }), // 设置时间 updateTime: (time: string): any => http.Post('/toolbox_system/time', { time }), - // 同步时间 - syncTime: (): any => http.Post('/toolbox_system/sync_time'), + // 同步时间(可选指定 NTP 服务器) + syncTime: (server?: string): any => http.Post('/toolbox_system/sync_time', { server }), + // 获取 NTP 服务器配置 + ntpServers: (): any => http.Get('/toolbox_system/ntp_servers'), + // 设置 NTP 服务器配置 + updateNtpServers: (servers: string[]): any => + http.Post('/toolbox_system/ntp_servers', { servers }), // 主机名 hostname: (): any => http.Get('/toolbox_system/hostname'), // Hosts diff --git a/web/src/layout/sidebar/components/SideLogo.vue b/web/src/layout/sidebar/components/SideLogo.vue index 77e47d93..8a118a68 100644 --- a/web/src/layout/sidebar/components/SideLogo.vue +++ b/web/src/layout/sidebar/components/SideLogo.vue @@ -7,7 +7,7 @@ const router = useRouter() const logo = computed(() => themeStore.logo || logoImg) const toHome = () => { - router.push({ name: 'home' }) + router.push({ name: 'home-index' }) } diff --git a/web/src/views/toolbox/SystemView.vue b/web/src/views/toolbox/SystemView.vue index 5915e6c6..5372a5f5 100644 --- a/web/src/views/toolbox/SystemView.vue +++ b/web/src/views/toolbox/SystemView.vue @@ -21,6 +21,12 @@ const hosts = ref('') const timezone = ref('') const timezones = ref([]) const time = ref(DateTime.now().toMillis()) +const syncServer = ref('') +const ntpServers = ref([]) +const builtinNtpServers = ref([]) +const ntpServiceType = ref('') +const showNtpModal = ref(false) +const editingNtpServers = ref([]) const dnsManager = ref('') @@ -45,6 +51,11 @@ useRequest(system.timezone()).onSuccess(({ data }) => { timezone.value = data.timezone timezones.value = data.timezones }) +useRequest(system.ntpServers()).onSuccess(({ data }) => { + ntpServers.value = data.servers || [] + builtinNtpServers.value = data.builtins || [] + ntpServiceType.value = data.service_type || '' +}) const handleUpdateDNS = () => { useRequest(system.updateDns(dns1.value, dns2.value)).onSuccess(() => { @@ -77,10 +88,41 @@ const handleUpdateTime = async () => { } const handleSyncTime = () => { - useRequest(system.syncTime()).onSuccess(() => { + useRequest(system.syncTime(syncServer.value || undefined)).onSuccess(() => { window.$message.success($gettext('Synchronized successfully')) }) } + +const handleOpenNtpSettings = () => { + editingNtpServers.value = [...ntpServers.value] + showNtpModal.value = true +} + +const handleAddNtpServer = () => { + editingNtpServers.value.push('') +} + +const handleRemoveNtpServer = (index: number) => { + editingNtpServers.value.splice(index, 1) +} + +const handleResetNtpServers = () => { + editingNtpServers.value = [...builtinNtpServers.value] +} + +const handleSaveNtpServers = () => { + // 过滤空字符串 + const servers = editingNtpServers.value.filter((s) => s.trim() !== '') + if (servers.length === 0) { + window.$message.error($gettext('At least one NTP server is required')) + return + } + useRequest(system.updateNtpServers(servers)).onSuccess(() => { + ntpServers.value = servers + showNtpModal.value = false + window.$message.success($gettext('Saved successfully')) + }) +}