Windows Server 2012 搭建内网 NTP 服务器并进行离线时钟漂移补偿

Windows Server 2012 搭建内网 NTP 服务器并进行离线时钟漂移补偿

在隔离或半隔离内网中,可以使用 Windows Server 2012 自带的 W32Time 作为统一 NTP 时间服务器:

上级 NTP(可选)
       │
       ▼
Windows Server 2012
       │
       ├─ Windows 客户端
       ├─ Linux / Chrony
       └─ 网络设备

如果服务器完全无法连接外部时间源,也可以使用本机时钟向内网授时,再通过本文后面的脚本补偿服务器自身稳定的时钟漂移。


一、启用 Windows Time 服务

使用管理员 CMD:

sc config w32time start= auto
net start w32time

启用 NTP Server:

reg add "HKLM\SYSTEM\CurrentControlSet\Services\W32Time\TimeProviders\NtpServer" /v Enabled /t REG_DWORD /d 1 /f

将本机设置为可靠时间源:

w32tm /config /reliable:yes /update

重启服务:

net stop w32time
net start w32time

二、配置上级 NTP 服务器

如果服务器能够访问其他标准 NTP 时间源,建议让 Server 2012 先同步上级服务器,再向内网提供时间。

例如:

w32tm /config /manualpeerlist:"<上级NTP服务器>,0x8" /syncfromflags:manual /update
net stop w32time
net start w32time
w32tm /resync /rediscover

检查:

w32tm /query /source
w32tm /query /status
w32tm /query /peers

如果是完全离线内网,没有任何上游 NTP,则可以不配置 manualpeerlist,将 Server 2012 本机时间作为整个内网的基准。


三、调整本地时钟可信度

为了让 Linux Chrony 等客户端能够正常接受 Windows 提供的 NTP 时间,可以设置:

w32tm /config /localclockdispersion:1 /update

或者直接修改注册表:

reg add "HKLM\SYSTEM\CurrentControlSet\Services\W32Time\Config" /v LocalClockDispersion /t REG_DWORD /d 1 /f

然后:

net stop w32time
net start w32time

检查:

w32tm /query /configuration

应能看到:

LocalClockDispersion: 1

LocalClockDispersion 只是服务器对本地时钟误差的声明,并不会真正提高硬件时钟精度。


四、开放 NTP 服务端口

NTP 使用:

UDP 123

确认系统已经监听:

netstat -ano -p udp | findstr ":123"

同时确保 Windows 防火墙以及内网 ACL 允许客户端访问服务器的 UDP 123。


五、Windows 客户端配置

在客户端管理员 CMD 中:

w32tm /config /manualpeerlist:"<内网NTP服务器>,0x8" /syncfromflags:manual /update
net stop w32time
net start w32time
w32tm /resync

检查:

w32tm /query /source
w32tm /query /status

也可以先测试服务器:

w32tm /stripchart /computer:<内网NTP服务器> /dataonly /samples:10

六、Linux Chrony 客户端配置

编辑:

/etc/chrony.conf

加入:

server <内网NTP服务器> iburst

重启:

systemctl restart chronyd

检查:

chronyc sources -v
chronyc tracking

正常情况下,当前使用的时间源前面会显示:

^*

例如:

^* ntp-server

需要立即修正较大的时间偏差时:

chronyc makestep

七、完全离线环境中的服务器时钟漂移

如果 Server 2012 没有任何上级 NTP、GPS 或北斗授时源,它无法自己知道真正的标准时间。

例如经过多日测量发现服务器:

每天稳定慢约 5 秒

对应频率误差约:

5 / 86400 ≈ 57.9 ppm

可以通过 Windows 的 SetSystemTimeAdjustment() API,让系统时钟持续稍微走快一些,而不是每天突然把时间增加 5 秒。

例如某台机器检测到:

TimeIncrement = 156250

补偿每天约 5 秒时:

TimeAdjustment = 156259

实际补偿约:

+4.9766 秒/天

下面的脚本会自动读取实际 TimeIncrement 并计算,不需要手工填写 156259


八、完整时钟漂移补偿脚本

保存为:

C:\ClockRateAdjust.ps1

内容:

Add-Type @"
using System;
using System.Runtime.InteropServices;

public static class ClockAdjust
{
    [StructLayout(LayoutKind.Sequential)]
    public struct LUID
    {
        public uint LowPart;
        public int HighPart;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct TOKEN_PRIVILEGES
    {
        public uint PrivilegeCount;
        public LUID Luid;
        public uint Attributes;
    }

    public const uint TOKEN_ADJUST_PRIVILEGES = 0x0020;
    public const uint TOKEN_QUERY = 0x0008;
    public const uint SE_PRIVILEGE_ENABLED = 0x00000002;

    [DllImport("advapi32.dll", SetLastError=true)]
    public static extern bool OpenProcessToken(
        IntPtr ProcessHandle,
        uint DesiredAccess,
        out IntPtr TokenHandle);

    [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
    public static extern bool LookupPrivilegeValue(
        string lpSystemName,
        string lpName,
        out LUID lpLuid);

    [DllImport("advapi32.dll", SetLastError=true)]
    public static extern bool AdjustTokenPrivileges(
        IntPtr TokenHandle,
        bool DisableAllPrivileges,
        ref TOKEN_PRIVILEGES NewState,
        uint BufferLength,
        IntPtr PreviousState,
        IntPtr ReturnLength);

    [DllImport("kernel32.dll")]
    public static extern IntPtr GetCurrentProcess();

    [DllImport("kernel32.dll", SetLastError=true)]
    public static extern bool CloseHandle(IntPtr hObject);

    [DllImport("kernel32.dll", SetLastError=true)]
    public static extern bool GetSystemTimeAdjustment(
        out uint lpTimeAdjustment,
        out uint lpTimeIncrement,
        out bool lpTimeAdjustmentDisabled);

    [DllImport("kernel32.dll", SetLastError=true)]
    public static extern bool SetSystemTimeAdjustment(
        uint dwTimeAdjustment,
        bool bTimeAdjustmentDisabled);

    public static bool EnableSystemTimePrivilege()
    {
        IntPtr token;

        if (!OpenProcessToken(
            GetCurrentProcess(),
            TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
            out token))
            return false;

        try
        {
            LUID luid;

            if (!LookupPrivilegeValue(
                null,
                "SeSystemtimePrivilege",
                out luid))
                return false;

            TOKEN_PRIVILEGES tp = new TOKEN_PRIVILEGES();
            tp.PrivilegeCount = 1;
            tp.Luid = luid;
            tp.Attributes = SE_PRIVILEGE_ENABLED;

            if (!AdjustTokenPrivileges(
                token,
                false,
                ref tp,
                0,
                IntPtr.Zero,
                IntPtr.Zero))
                return false;

            return Marshal.GetLastWin32Error() == 0;
        }
        finally
        {
            CloseHandle(token);
        }
    }
}
"@

# ==============================
# 漂移补偿参数
# ==============================

# 正数:本机每天慢多少秒
# 例如每天慢5秒填写5.0
#
# 负数:本机每天快多少秒
# 例如每天快3秒填写-3.0

$DriftSecondsPerDay = 5.0


# ==============================
# 启用系统时间调整权限
# ==============================

if (-not [ClockAdjust]::EnableSystemTimePrivilege()) {
    throw "Cannot enable SeSystemtimePrivilege. Run as Administrator."
}

Write-Host "SeSystemtimePrivilege enabled."


# ==============================
# 获取当前时钟参数
# ==============================

[uint32]$CurrentAdjustment = 0
[uint32]$TimeIncrement = 0
[bool]$AdjustmentDisabled = $false

if (-not [ClockAdjust]::GetSystemTimeAdjustment(
    [ref]$CurrentAdjustment,
    [ref]$TimeIncrement,
    [ref]$AdjustmentDisabled)) {

    throw "GetSystemTimeAdjustment failed."
}

Write-Host ""
Write-Host "Current TimeAdjustment : $CurrentAdjustment"
Write-Host "TimeIncrement          : $TimeIncrement"
Write-Host "AdjustmentDisabled     : $AdjustmentDisabled"


# ==============================
# 计算新的时钟频率
# ==============================

$Ratio = $DriftSecondsPerDay / 86400.0

[uint32]$NewAdjustment =
    [Math]::Round(
        $TimeIncrement * (1.0 + $Ratio)
    )

$Actual =
    (($NewAdjustment / [double]$TimeIncrement) - 1.0) * 86400.0


Write-Host ""
Write-Host "Requested correction   : $DriftSecondsPerDay sec/day"
Write-Host "New TimeAdjustment     : $NewAdjustment"
Write-Host "Actual correction      : $([Math]::Round($Actual,4)) sec/day"


# ==============================
# 应用时钟频率补偿
# ==============================

if (-not [ClockAdjust]::SetSystemTimeAdjustment(
    $NewAdjustment,
    $false)) {

    $err =
        [Runtime.InteropServices.Marshal]::GetLastWin32Error()

    throw "SetSystemTimeAdjustment failed. Win32 error: $err"
}

Write-Host ""
Write-Host "SUCCESS - Clock rate adjustment applied."

管理员 PowerShell 或 CMD 执行:

powershell.exe -ExecutionPolicy Bypass -File C:\ClockRateAdjust.ps1

九、如何调整补偿量

正常情况下只需要修改:

$DriftSecondsPerDay = 5.0

例如原来设置:

5.0 秒/天

观察一周以后仍然:

慢 4.2 秒

则剩余漂移约:

4.2 ÷ 7 = 0.6 秒/天

新的设置:

$DriftSecondsPerDay = 5.6

如果一周以后反而:

快 4.2 秒

则:

5.0 - 4.2 / 7 = 4.4

改为:

$DriftSecondsPerDay = 4.4

修改后重新执行一次脚本即可,不需要先恢复默认设置。

建议至少观察 3~7 天 后再调整,避免根据短时间误差判断漂移率。


十、恢复 Windows 默认时钟模式

建议同时保留一个恢复脚本:

C:\ClockRateReset.ps1

内容:

Add-Type @"
using System;
using System.Runtime.InteropServices;

public static class ClockReset
{
    [StructLayout(LayoutKind.Sequential)]
    public struct LUID
    {
        public uint LowPart;
        public int HighPart;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct TOKEN_PRIVILEGES
    {
        public uint PrivilegeCount;
        public LUID Luid;
        public uint Attributes;
    }

    public const uint TOKEN_ADJUST_PRIVILEGES = 0x0020;
    public const uint TOKEN_QUERY = 0x0008;
    public const uint SE_PRIVILEGE_ENABLED = 0x00000002;

    [DllImport("advapi32.dll", SetLastError=true)]
    public static extern bool OpenProcessToken(
        IntPtr ProcessHandle,
        uint DesiredAccess,
        out IntPtr TokenHandle);

    [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
    public static extern bool LookupPrivilegeValue(
        string lpSystemName,
        string lpName,
        out LUID lpLuid);

    [DllImport("advapi32.dll", SetLastError=true)]
    public static extern bool AdjustTokenPrivileges(
        IntPtr TokenHandle,
        bool DisableAllPrivileges,
        ref TOKEN_PRIVILEGES NewState,
        uint BufferLength,
        IntPtr PreviousState,
        IntPtr ReturnLength);

    [DllImport("kernel32.dll")]
    public static extern IntPtr GetCurrentProcess();

    [DllImport("kernel32.dll", SetLastError=true)]
    public static extern bool CloseHandle(IntPtr hObject);

    [DllImport("kernel32.dll", SetLastError=true)]
    public static extern bool GetSystemTimeAdjustment(
        out uint lpTimeAdjustment,
        out uint lpTimeIncrement,
        out bool lpTimeAdjustmentDisabled);

    [DllImport("kernel32.dll", SetLastError=true)]
    public static extern bool SetSystemTimeAdjustment(
        uint dwTimeAdjustment,
        bool bTimeAdjustmentDisabled);

    public static bool EnableSystemTimePrivilege()
    {
        IntPtr token;

        if (!OpenProcessToken(
            GetCurrentProcess(),
            TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
            out token))
            return false;

        try
        {
            LUID luid;

            if (!LookupPrivilegeValue(
                null,
                "SeSystemtimePrivilege",
                out luid))
                return false;

            TOKEN_PRIVILEGES tp = new TOKEN_PRIVILEGES();
            tp.PrivilegeCount = 1;
            tp.Luid = luid;
            tp.Attributes = SE_PRIVILEGE_ENABLED;

            if (!AdjustTokenPrivileges(
                token,
                false,
                ref tp,
                0,
                IntPtr.Zero,
                IntPtr.Zero))
                return false;

            return Marshal.GetLastWin32Error() == 0;
        }
        finally
        {
            CloseHandle(token);
        }
    }
}
"@

if (-not [ClockReset]::EnableSystemTimePrivilege()) {
    throw "Cannot enable SeSystemtimePrivilege. Run as Administrator."
}

if (-not [ClockReset]::SetSystemTimeAdjustment(0, $true)) {

    $err =
        [Runtime.InteropServices.Marshal]::GetLastWin32Error()

    throw "Reset failed. Win32 error: $err"
}

[uint32]$CurrentAdjustment = 0
[uint32]$TimeIncrement = 0
[bool]$AdjustmentDisabled = $false

[ClockReset]::GetSystemTimeAdjustment(
    [ref]$CurrentAdjustment,
    [ref]$TimeIncrement,
    [ref]$AdjustmentDisabled
) | Out-Null

Write-Host ""
Write-Host "TimeAdjustment       : $CurrentAdjustment"
Write-Host "TimeIncrement        : $TimeIncrement"
Write-Host "AdjustmentDisabled   : $AdjustmentDisabled"
Write-Host ""
Write-Host "SUCCESS - Windows default clock adjustment restored."

执行:

powershell.exe -ExecutionPolicy Bypass -File C:\ClockRateReset.ps1

恢复后通常可以看到:

TimeAdjustment     : 156250
TimeIncrement      : 156250
AdjustmentDisabled : True

表示重新使用 Windows 默认时钟模式。


十一、建议的最终部署方式

对于完全隔离的内网:

             定期人工核对标准时间
                       │
                       ▼
            Windows Server 2012
            W32Time + 漂移补偿
                       │
          ┌────────────┼────────────┐
          ▼            ▼            ▼
       Windows       Linux       网络设备

应做到:

  1. 全网只认一台主 NTP Server;
  2. 测量主服务器长期漂移率;
  3. 使用 ClockRateAdjust.ps1 做连续频率补偿;
  4. 每隔一段时间与可靠标准时间人工核对;
  5. 如果漂移发生变化,只修改 $DriftSecondsPerDay;
  6. 保留 ClockRateReset.ps1,便于随时恢复系统默认模式。

需要注意的是,这种方法解决的是本机时钟稳定地快或慢的问题。它不能代替真正的标准时间源。如果内网要求较高的绝对时间精度,仍建议最终使用 GPS/北斗 NTP 授时设备。

上一篇 NanoVNA-H/H4 1.2.50菜单的中英文对照