FreeBSD 14+, gpsd, chrony - stratum 1 time server

Discuss Wi-Fi setups, cybersecurity, and network troubleshooting.
User avatar
ccb056
Site Administrator
Posts: 1003
Joined: January 14th, 2004, 11:36 pm
Location: Texas

FreeBSD 14+, gpsd, chrony - stratum 1 time server

Post by ccb056 »

Start with a fresh FreeBSD 14+ install
Note - do not install NTPD as part of FreeBSD install, NTPD and Chrony cannot be run simultaneously.

Code: Select all

vi /etc/ttys
Comment out these lines in /etc/ttys - this allows gpsd to work with all 4 com ports (your machine may have fewer)

Code: Select all

# The 'dialup' keyword identifies dialin lines to login, fingerd etc.
#ttyu0  "/usr/libexec/getty 3wire"      vt100   onifconsole secure
#ttyu1  "/usr/libexec/getty 3wire"      vt100   onifconsole secure
#ttyu2  "/usr/libexec/getty 3wire"      vt100   onifconsole secure
#ttyu3  "/usr/libexec/getty 3wire"      vt100   onifconsole secure
Load kernel pps:

Code: Select all

kldload pps
Verify it is loaded:

Code: Select all

kldstat
Make it persistent:

Code: Select all

vi /boot/loader.conf
Add at the end;

Code: Select all

pps_load="YES"

Code: Select all

pkg install gpsd

Code: Select all

vi /etc/rc.conf
Add these lines at the end to start gpsd at boot as a background service (daemon)

Code: Select all

gpsd_enable="YES"
gpsd_devices="/dev/cuau0 /dev/cuau1 /dev/cuau2 /dev/cuau3"
gpsd_flags="-n -s 115200 -f 8N1"
-n flag: Don't wait for a client to connect before polling whatever GPS is associated with it.

-s 115200 flag: Fix the serial port speed to the GNSS device. Allowed speeds are: 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800 and 921600. The default is to autobaud.

-f 8N1 flag: Fix the framing to the GNSS device. The framing parameter is of the form: [78][ENO][012]. Most GNSS are 8N1. Some Trimble default to 8O1. The default is to search for the correct framing.

You can now use these commands for control:

Code: Select all

service gpsd status
service gpsd stop
service gpsd start

gpsd -n -s 115200 -f 8N1 /dev/cuau0 /dev/cuau1 /dev/cuau2 /dev/cuau3 &
pkill gpsd
And these commands for monitoring:

Code: Select all

gpsmon -n localhost:2947:/dev/cuau0
gpsmon -n localhost:2947:/dev/cuau1
gpsmon -n localhost:2947:/dev/cuau2
gpsmon -n localhost:2947:/dev/cuau3

cgps -s localhost:2947:/dev/cuau0
cgps -s localhost:2947:/dev/cuau1
cgps -s localhost:2947:/dev/cuau2
cgps -s localhost:2947:/dev/cuau3
If the PPS field in gpsmon is blank, then your device may be using the CTS pin instead of DCD for PPS.

If your PPS is coming in on the CTS pin instead of DCD, you can run these commands:

Code: Select all

sysctl dev.uart.0.pps_mode=1
sysctl dev.uart.1.pps_mode=1
sysctl dev.uart.2.pps_mode=1
sysctl dev.uart.3.pps_mode=1
And to make it persistent between boots:

Code: Select all

vi /boot/loader.conf
Add these lines at the end:

Code: Select all

dev.uart.0.pps_mode="1"
dev.uart.1.pps_mode="1"
dev.uart.2.pps_mode="1"
dev.uart.3.pps_mode="1"
Note - this is one of the benefits of FreeBSD over Linux as an NTP server, Linux PPS requires the signal on the DCD pin, FreeBSD allows either DCD (default) or CTS.

You may also set flags for inverted and/or narrow pulses, read more here:
https://man.freebsd.org/cgi/man.cgi?uart(4)

Code: Select all

pkg install chrony

Code: Select all

vi /etc/rc.conf
Add these lines before the gpsd entries to start chrony at boot as a background service (daemon)

Code: Select all

chronyd_enable="YES"
Note: Chrony is started before gpsd as recommended by their developers here: https://chrony-project.org/faq.html#_ho ... _with_gpsd
The SOCK refclocks should be preferred over SHM for better security (the shared memory segment needs to be created by chronyd or gpsd with an expected owner and permissions before an untrusted application or user has a chance to create its own in order to feed chronyd with false measurements). gpsd needs to be started after chronyd in order to connect to the socket.
Make a backup of the chrony config file:

Code: Select all

cp /usr/local/etc/chrony.conf /usr/local/etc/chrony.conf.bak

Code: Select all

vi /usr/local/etc/chrony.conf
Add these lines:

Code: Select all

refclock SOCK /var/run/chrony.cuau0.sock refid GPS1
refclock SOCK /var/run/chrony.cuau1.sock refid GPS2
refclock SOCK /var/run/chrony.cuau2.sock refid GPS3
refclock SOCK /var/run/chrony.cuau3.sock refid GPS4
You can now use these commands for control:

Code: Select all

service chronyd status
service chronyd stop
service chronyd start

chronyc makestep => set system time correct to NTP time
And these commands for monitoring:

Code: Select all

while true; do clear; chronyc sources; echo "" ;chronyc sourcestats ; echo ""; chronyc tracking; sleep 1; done

chronyc clients => who is using my ntp-server?
chronyc ntpdata => give a lot of info about all your sources incl remote - sort of multi-tracking
chronyc serverstats => stats of packets, send, received and dropped.
User avatar
ccb056
Site Administrator
Posts: 1003
Joined: January 14th, 2004, 11:36 pm
Location: Texas

Increase Chrony stability - nice periodic tasks

Post by ccb056 »

To increase Chrony stability, make periodic tasks nice

See here for references:
https://man.freebsd.org/cgi/man.cgi?periodic.conf(5)
https://freebsdfoundation.org/blog/an-i ... ic-system/
https://man.freebsd.org/cgi/man.cgi?nice
https://man.freebsd.org/cgi/man.cgi?idprio

Code: Select all

vi /etc/periodic.conf

Code: Select all

# Lower CPU priority for periodic tasks
daily_command="/usr/bin/nice -n 20 /usr/sbin/periodic daily"
weekly_command="/usr/bin/nice -n 20 /usr/sbin/periodic weekly"
monthly_command="/usr/bin/nice -n 20 /usr/sbin/periodic monthly"
OR

Code: Select all

# Run periodic tasks in idle priority class (only when system is idle)
daily_command="/usr/bin/idprio 31 /usr/sbin/periodic daily"
weekly_command="/usr/bin/idprio 31 /usr/sbin/periodic weekly"
monthly_command="/usr/bin/idprio 31 /usr/sbin/periodic monthly"
OR

Code: Select all

daily_command="/usr/bin/idprio 31 /usr/bin/nice -n 20 /usr/sbin/periodic daily"
weekly_command="/usr/bin/idprio 31 /usr/bin/nice -n 20 /usr/sbin/periodic weekly"
monthly_command="/usr/bin/idprio 31 /usr/bin/nice -n 20 /usr/sbin/periodic monthly"
User avatar
ccb056
Site Administrator
Posts: 1003
Joined: January 14th, 2004, 11:36 pm
Location: Texas

Increase Chrony stability - cpu affinity

Post by ccb056 »

Code: Select all

vi /usr/local/etc/rc.d/cpuset_ntp

Code: Select all

#!/bin/sh
#
# PROVIDE: cpuset_ntp
# REQUIRE: chronyd
# KEYWORD: nojail

. /etc/rc.subr

name="cpuset_ntp"
rcvar="cpuset_ntp_enable"

# Tunables (override in /etc/rc.conf as needed)
: ${cpuset_ntp_cpu:="2"}             # CPU to pin to
: ${cpuset_ntp_irq_regex:="uart"}    # Filter for IRQ lines in `vmstat -i`
: ${cpuset_ntp_pin_threads:="YES"}   # Also pin all threads per process
: ${cpuset_ntp_step_delay:="1"}      # Delay between steps (seconds)

start_cmd="${name}_start"
stop_cmd=":"
status_cmd="${name}_status"

_log() { echo "${name}: $*"; }

_find_uart_irqs() {
    vmstat -i 2>/dev/null | awk -v re="${cpuset_ntp_irq_regex}" '
        $0 ~ re {
            lbl=$1; sub(":", "", lbl);
            if (lbl ~ /^irq[0-9]+$/) print lbl
        }
    '
}

_pin_irq_label() {
    irqlabel="$1"
    [ -n "$irqlabel" ] && cpuset -l "${cpuset_ntp_cpu}" -x "${irqlabel}" && \
        _log "Pinned ${irqlabel} to CPU ${cpuset_ntp_cpu}"
}

_pin_pid_and_threads() {
    pid="$1"
    [ -n "$pid" ] || return 0
    cpuset -l "${cpuset_ntp_cpu}" -p "${pid}" && \
        _log "Pinned PID ${pid} to CPU ${cpuset_ntp_cpu}"
    if [ "${cpuset_ntp_pin_threads}" = "YES" ]; then
        for tid in $(ps -H -p "${pid}" -o tid= 2>/dev/null); do
            cpuset -l "${cpuset_ntp_cpu}" -t "${tid}" && \
                _log "Pinned TID ${tid} (PID ${pid}) to CPU ${cpuset_ntp_cpu}"
        done
    fi
}

cpuset_ntp_start() {
    _log "Starting; CPU=${cpuset_ntp_cpu}, IRQ regex=${cpuset_ntp_irq_regex}, step_delay=${cpuset_ntp_step_delay}s"

    # Step 1: pin IRQs
    for irqlabel in $(_find_uart_irqs); do
        _pin_irq_label "${irqlabel}"
    done

    # Step 2: wait
    sleep "${cpuset_ntp_step_delay}"

    # Step 3: pin chrony (chronyd)
    for cpid in $(pgrep chronyd); do
        _pin_pid_and_threads "${cpid}"
    done

    _log "Done."
}

cpuset_ntp_status() {
    echo "IRQ affinity:"
    for irqlabel in $(_find_uart_irqs); do
        printf "%s: " "${irqlabel}"
        cpuset -g -x "${irqlabel}" | sed 's/^/  /'
    done

    echo
    echo "Process affinity (chronyd only):"
    for pid in $(pgrep chronyd); do
        echo "PID ${pid}:"
        cpuset -g -p "${pid}" | sed 's/^/  /'
    done
}

load_rc_config $name
run_rc_command "$1"

Code: Select all

chmod +x /usr/local/etc/rc.d/cpuset_ntp
add to /etc/rc.conf

Code: Select all

sysrc cpuset_ntp_enable=YES
User avatar
ccb056
Site Administrator
Posts: 1003
Joined: January 14th, 2004, 11:36 pm
Location: Texas

Chrony Log rotation

Post by ccb056 »

enable logging

Code: Select all

vi /usr/local/etc/chrony.conf

Code: Select all

logdir /var/log/chrony
log measurements statistics tracking refclocks

view log files

Code: Select all

ls -lhlt /var/log/chrony/

enable log rotation

Code: Select all

vi /usr/local/sbin/chrony_cyclelogs.sh

Code: Select all

# /usr/local/sbin/chrony_cyclelogs.sh
#!/bin/sh
/usr/local/bin/chronyc cyclelogs

Code: Select all

chmod +x /usr/local/sbin/chrony_cyclelogs.sh

Code: Select all

vi /etc/newsyslog.conf

Code: Select all

# chrony log rotation
/var/log/chrony/*.log   chronyd:chronyd 640  7     *    @T00  R /usr/local/sbin/chrony_cyclelogs.sh

how to test:

# Show what would happen (dry-run + verbose + force)

Code: Select all

newsyslog -nvF
# Do a real rotation immediately

Code: Select all

newsyslog -F
# After rotation, the R flag will run your script; you can also manually:

Code: Select all

chronyc cyclelogs
User avatar
ccb056
Site Administrator
Posts: 1003
Joined: January 14th, 2004, 11:36 pm
Location: Texas

Chrony Statistical Analysis

Post by ccb056 »

Code: Select all

vi chrony_refclock_analyzer.py

Code: Select all

#!/usr/bin/env python3
"""
Chrony Refclock Offset Analyzer
Analyzes chrony refclocks.log and calculates recommended offset corrections.
Compatible with FreeBSD and standard Python 3.6+
"""

import sys
import re
import argparse
from collections import defaultdict
import math

class RefclockAnalyzer:
    def __init__(self, log_file, verbose=False):
        self.log_file = log_file
        self.verbose = verbose
        self.cooked_offsets = defaultdict(list)
        self.offset_deltas = defaultdict(list)  # cooked - raw = configured offset + freq correction
        
    def parse_log(self):
        """Parse the chrony refclocks.log file."""
        data_pattern = re.compile(
            r'^(\d{4}-\d{2}-\d{2})\s+'
            r'(\d{2}:\d{2}:\d{2}\.\d+)\s+'
            r'(GPS\d+)\s+'
            r'(\d+|-)\s+'
            r'([NY])\s+'
            r'(\d+|-)\s+'
            r'(-?[\d.e+-]+|-)\s+'
            r'(-?[\d.e+-]+)\s+'
            r'(-?[\d.e+-]+)'
        )
        
        line_count = 0
        parsed_count = 0
        
        try:
            with open(self.log_file, 'r') as f:
                for line in f:
                    line_count += 1
                    line = line.strip()
                    
                    if not line or line.startswith('=') or 'Date (UTC)' in line:
                        continue
                    if 'newsyslog' in line or 'logfile' in line:
                        continue
                    
                    match = data_pattern.match(line)
                    if match:
                        parsed_count += 1
                        refid = match.group(3)
                        raw_str = match.group(7)
                        cooked_offset = float(match.group(8))
                        self.cooked_offsets[refid].append(cooked_offset)
                        
                        # Track delta where raw offset exists
                        if raw_str != '-':
                            raw_offset = float(raw_str)
                            self.offset_deltas[refid].append(cooked_offset - raw_offset)
                            
        except FileNotFoundError:
            print(f"Error: File '{self.log_file}' not found.", file=sys.stderr)
            sys.exit(1)
        except PermissionError:
            print(f"Error: Permission denied reading '{self.log_file}'.", file=sys.stderr)
            sys.exit(1)
            
        if self.verbose:
            print(f"# Parsed {parsed_count} samples from {line_count} lines\n")
        return parsed_count > 0
    
    @staticmethod
    def calculate_stats(values):
        """Calculate statistical measures for a list of values."""
        if not values:
            return None
            
        n = len(values)
        sorted_vals = sorted(values)
        
        mean = sum(values) / n
        
        if n > 1:
            variance = sum((x - mean) ** 2 for x in values) / (n - 1)
            std_dev = math.sqrt(variance)
            # 95% CI: mean ± 1.96 * (std_dev / sqrt(n))
            std_err = std_dev / math.sqrt(n)
            ci_95 = 1.96 * std_err
        else:
            std_dev = 0.0
            ci_95 = 0.0
        
        # Trimmed mean (10% trimmed)
        trim_n = max(1, int(n * 0.1))
        trimmed_vals = sorted_vals[trim_n:-trim_n] if trim_n < n//2 else sorted_vals
        trimmed_mean = sum(trimmed_vals) / len(trimmed_vals)
        
        # Trimmed std dev and CI
        if len(trimmed_vals) > 1:
            trimmed_var = sum((x - trimmed_mean) ** 2 for x in trimmed_vals) / (len(trimmed_vals) - 1)
            trimmed_std = math.sqrt(trimmed_var)
            trimmed_err = trimmed_std / math.sqrt(len(trimmed_vals))
            trimmed_ci_95 = 1.96 * trimmed_err
        else:
            trimmed_std = 0.0
            trimmed_ci_95 = 0.0
        
        return {
            'count': n,
            'mean': mean,
            'std_dev': std_dev,
            'ci_95': ci_95,
            'trimmed_mean': trimmed_mean,
            'trimmed_std': trimmed_std,
            'trimmed_ci_95': trimmed_ci_95
        }
    
    def analyze(self):
        """Perform analysis and generate report."""
        refids = sorted(self.cooked_offsets.keys())
        
        if not refids:
            print("No refclock data found in log file.")
            return
        
        socket_map = {
            'GPS1': '/var/run/chrony.cuau0.sock',
            'GPS2': '/var/run/chrony.cuau1.sock', 
            'GPS3': '/var/run/chrony.cuau2.sock',
            'GPS4': '/var/run/chrony.cuau3.sock'
        }
        
        # Detect current configured offsets from raw vs cooked difference
        # median(cooked - raw) ≈ configured_offset (freq correction is small/consistent)
        detected_offsets = {}
        for refid in refids:
            if self.offset_deltas[refid]:
                deltas = sorted(self.offset_deltas[refid])
                n = len(deltas)
                median_delta = deltas[n//2] if n % 2 else (deltas[n//2-1] + deltas[n//2]) / 2
                # Round to nearest 0.1µs to filter noise
                detected_offsets[refid] = round(median_delta * 1e7) / 1e7
        
        print("# Recommended chrony refclock configuration")
        print("# Current offsets auto-detected from log (cooked - raw)")
        print("# 95% CI: confidence interval for the adjustment\n")
        
        for refid in refids:
            stats = self.calculate_stats(self.cooked_offsets[refid])
            if stats:
                delta_offset = -stats['trimmed_mean']
                current = detected_offsets.get(refid, 0.0)
                total_offset = current + delta_offset
                ci_95 = stats['trimmed_ci_95']
                socket = socket_map.get(refid, f'/var/run/chrony.{refid.lower()}.sock')
                
                offset_str = f"{total_offset:+.9f}".rstrip('0').rstrip('.')
                if '.' not in offset_str or offset_str.endswith('.'):
                    offset_str = f"{total_offset:+.6f}"
                
                ci_str = f"±{ci_95*1e6:.3f}µs"
                n_str = f"n={stats['count']}"
                
                if current != 0.0:
                    current_str = f"cur:{current*1e6:+.1f}µs"
                    delta_str = f"Δ{delta_offset*1e6:+.2f}µs"
                    print(f"refclock SOCK {socket} refid {refid} offset {offset_str}  # {current_str} {delta_str}, 95% CI: {ci_str} ({n_str})")
                else:
                    print(f"refclock SOCK {socket} refid {refid} offset {offset_str}  # 95% CI: {ci_str} ({n_str})")


def main():
    parser = argparse.ArgumentParser(
        description='Analyze chrony refclocks.log and calculate recommended offsets'
    )
    parser.add_argument(
        'logfile',
        nargs='?',
        default='/var/log/chrony/refclocks.log',
        help='Path to chrony refclocks.log (default: /var/log/chrony/refclocks.log)'
    )
    parser.add_argument(
        '-v', '--verbose',
        action='store_true',
        help='Show sample count'
    )
    
    args = parser.parse_args()
    
    analyzer = RefclockAnalyzer(args.logfile, args.verbose)
    
    if analyzer.parse_log():
        analyzer.analyze()
    else:
        print("No data parsed from log file.", file=sys.stderr)
        sys.exit(1)


if __name__ == '__main__':
    main()

Code: Select all

chmod +x chrony_refclock_analyzer.py

Code: Select all

./chrony_refclock_analyzer.py /var/log/chrony/refclocks.log
User avatar
ccb056
Site Administrator
Posts: 1003
Joined: January 14th, 2004, 11:36 pm
Location: Texas

maintain constant CPU clockspeed

Post by ccb056 »

Code: Select all

sysctl dev.hwpstate_intel.0.epp=0
This immediately tells Intel’s Speed Shift hardware to favor maximum performance.

Code: Select all

vi /etc/sysctl.conf

Code: Select all

dev.hwpstate_intel.0.epp=0
Persist across reboots

Code: Select all

sysctl dev.hwpstate_intel.0.epp

Code: Select all

sysctl dev.cpu.0.freq_levels
verify setting