2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 04:22:33 +08:00

feat: add NTP server configuration and manual sync server option (#1232)

* 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: 耗子 <haozi@loli.email>
This commit is contained in:
Copilot
2026-01-13 21:24:58 +08:00
committed by GitHub
parent d885be3fea
commit 1f55c2448d
8 changed files with 497 additions and 10 deletions

View File

@@ -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"`
}

View File

@@ -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)

View File

@@ -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")

View File

@@ -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 {

299
pkg/ntp/system.go Normal file
View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -7,7 +7,7 @@ const router = useRouter()
const logo = computed(() => themeStore.logo || logoImg)
const toHome = () => {
router.push({ name: 'home' })
router.push({ name: 'home-index' })
}
</script>

View File

@@ -21,6 +21,12 @@ const hosts = ref('')
const timezone = ref('')
const timezones = ref<any[]>([])
const time = ref(DateTime.now().toMillis())
const syncServer = ref('')
const ntpServers = ref<string[]>([])
const builtinNtpServers = ref<string[]>([])
const ntpServiceType = ref('')
const showNtpModal = ref(false)
const editingNtpServers = ref<string[]>([])
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'))
})
}
</script>
<template>
@@ -172,6 +214,18 @@ const handleSyncTime = () => {
<n-form-item :label="$gettext('Modify Time')">
<n-date-picker v-model:value="time" type="datetime" clearable />
</n-form-item>
<n-form-item :label="$gettext('NTP Server')">
<n-flex :size="8" align="center" style="width: 100%">
<n-input
v-model:value="syncServer"
:placeholder="$gettext('Optional, leave empty to use default servers')"
style="flex: 1"
/>
<n-button @click="handleOpenNtpSettings">
{{ $gettext('Configure Default Servers') }}
</n-button>
</n-flex>
</n-form-item>
</n-form>
<n-flex>
<n-button type="primary" @click="handleUpdateTime">
@@ -184,4 +238,72 @@ const handleSyncTime = () => {
</n-flex>
</n-tab-pane>
</n-tabs>
<!-- NTP 服务器配置弹窗 -->
<n-modal
v-model:show="showNtpModal"
preset="card"
:title="$gettext('System NTP Server Configuration')"
style="width: 60vw"
size="huge"
:bordered="false"
:segmented="false"
>
<n-flex vertical>
<n-alert v-if="ntpServiceType === 'unknown'" type="warning">
{{
$gettext(
'Unable to detect NTP service. Please ensure chrony or systemd-timesyncd is installed.'
)
}}
</n-alert>
<n-alert v-else type="info" :show-icon="false">
{{
$gettext(
'Current NTP service: %{ service }. Changes will be applied to system configuration.',
{
service: ntpServiceType === 'chrony' ? 'Chrony' : 'systemd-timesyncd'
}
)
}}
</n-alert>
<n-list>
<n-list-item v-for="(_, index) in editingNtpServers" :key="index">
<n-flex :size="8" align="center">
<n-input
v-model:value="editingNtpServers[index]"
:placeholder="$gettext('Enter NTP server address')"
style="flex: 1"
/>
<n-button
quaternary
type="error"
:disabled="editingNtpServers.length <= 1"
@click="handleRemoveNtpServer(index)"
>
<template #icon>
<i-mdi-delete />
</template>
</n-button>
</n-flex>
</n-list-item>
</n-list>
<n-flex justify="space-between">
<n-flex :size="8">
<n-button @click="handleAddNtpServer">
<template #icon>
<i-mdi-plus />
</template>
{{ $gettext('Add') }}
</n-button>
<n-button @click="handleResetNtpServers">
{{ $gettext('Reset to Default') }}
</n-button>
</n-flex>
<n-button type="primary" @click="handleSaveNtpServers">
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-modal>
</template>