commit 8963c16212e1943ccfab142c7a39869a99d9455d Author: Adam Harrison-Fuller Date: Wed Jan 14 22:34:06 2026 +0000 Initial commit: Chrony NTP Server add-on for Home Assistant - NTP client syncs from configurable pools and servers - NTP server mode serves time to LAN clients - Web UI dashboard showing sync status, sources, and clients - Supports iburst and maxsources options Co-Authored-By: Claude Opus 4.5 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..a09af40 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "name": "Example devcontainer for add-on repositories", + "image": "ghcr.io/home-assistant/devcontainer:2-addons", + "appPort": ["7123:8123", "7357:4357"], + "postStartCommand": "bash devcontainer_bootstrap", + "runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"], + "workspaceFolder": "/mnt/supervisor/addons/local/${localWorkspaceFolderBasename}", + "workspaceMount": "source=${localWorkspaceFolder},target=${containerWorkspaceFolder},type=bind,consistency=cached", + "containerEnv": { + "WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}" + }, + "customizations": { + "vscode": { + "extensions": ["timonwong.shellcheck", "esbenp.prettier-vscode"], + "settings": { + "terminal.integrated.profiles.linux": { + "zsh": { + "path": "/usr/bin/zsh" + } + }, + "terminal.integrated.defaultProfile.linux": "zsh", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } + } + }, + "mounts": [ + "type=volume,target=/var/lib/docker", + "type=volume,target=/mnt/supervisor" + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..091effa --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,20 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Start Home Assistant", + "type": "shell", + "command": "supervisor_run", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + } + ] + } + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7d32912 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Home Assistant add-on that provides an NTP server and client using Chrony. It includes a Flask-based web UI for monitoring synchronization status. + +## Development Environment + +Use the devcontainer configuration for local development. The devcontainer uses the Home Assistant add-on development image and mounts the workspace at `/mnt/supervisor/addons/local/`. + +To start Home Assistant with the add-on for testing: +```bash +supervisor_run +``` + +Ports exposed in devcontainer: +- 7123 → 8123 (Home Assistant) +- 7357 → 4357 (debug) + +## Architecture + +**Container Runtime**: Alpine-based image using s6-overlay for process supervision. + +**Services (s6-rc.d)**: +- `chrony`: Main NTP daemon - reads `/data/options.json`, generates `/etc/chrony/chrony.conf`, runs `chronyd -d` +- `webui`: Flask app on port 8099 (or INGRESS_PORT env var) - provides status dashboard and API + +**Configuration Flow**: Home Assistant writes add-on config to `/data/options.json` → `chrony/run` script parses with `jq` → generates chrony.conf → starts chronyd + +**Web UI** (`rootfs/var/www/`): +- `app.py`: Flask app with routes for dashboard and API endpoints (`/api/status`, `/api/options`, `/api/force-sync`, `/api/burst`) +- Uses `chronyc -c` commands to get machine-readable status output +- Templates use Jinja2 with Home Assistant-style dark theme + +## Key Files + +- `config.yaml`: Add-on metadata, schema, ports, privileges (SYS_TIME required for time sync) +- `build.yaml`: Architecture-specific base images +- `Dockerfile`: Installs chrony, jq, python3, py3-flask +- `rootfs/etc/s6-overlay/s6-rc.d/*/run`: Service startup scripts + +## Add-on Configuration Schema + +Options defined in `config.yaml`: +- `ntp_servers`: List of `{server: str, iburst: bool?}` +- `allow_clients`: List of CIDR networks +- `enable_ntp_server`: bool +- `log_level`: debug|info|warning|error diff --git a/README.md b/README.md new file mode 100644 index 0000000..9aa8b0d --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Home Assistant Add-on Repository + +This repository contains Home Assistant add-ons. + +## Add-ons + +### [Chrony NTP Server](./chrony-addon) + +NTP server and client using Chrony for accurate time synchronization. + +## Installation + +1. In Home Assistant, navigate to **Settings** → **Add-ons** → **Add-on Store** +2. Click the menu icon (⋮) in the top right and select **Repositories** +3. Add this repository URL: `https://github.com/adamhf/homassistant_chrony_addon` +4. Click **Add** → **Close** +5. The add-on should now appear in the add-on store diff --git a/chrony-addon/CHANGELOG.md b/chrony-addon/CHANGELOG.md new file mode 100644 index 0000000..397b6e3 --- /dev/null +++ b/chrony-addon/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## [1.0.0] - 2025-01-14 + +### Added +- Initial release +- NTP client functionality with configurable upstream servers +- NTP server mode to serve time to local network clients +- Configurable client allow lists +- Support for multiple architectures (amd64, aarch64, armv7, armhf, i386) +- Configurable log levels diff --git a/chrony-addon/Dockerfile b/chrony-addon/Dockerfile new file mode 100644 index 0000000..a559fb5 --- /dev/null +++ b/chrony-addon/Dockerfile @@ -0,0 +1,18 @@ +ARG BUILD_FROM +FROM $BUILD_FROM + +# Install chrony, jq for config parsing, and python for status dashboard +RUN apk add --no-cache \ + chrony \ + jq \ + python3 \ + py3-flask + +# Copy root filesystem +COPY rootfs / + +# Make scripts executable +RUN chmod a+x /etc/s6-overlay/s6-rc.d/chrony/run \ + && chmod a+x /etc/s6-overlay/s6-rc.d/webui/run + +WORKDIR / diff --git a/chrony-addon/README.md b/chrony-addon/README.md new file mode 100644 index 0000000..3fb9602 --- /dev/null +++ b/chrony-addon/README.md @@ -0,0 +1,67 @@ +# Chrony NTP Server Add-on for Home Assistant + +This add-on provides an NTP (Network Time Protocol) server and client using Chrony, enabling accurate time synchronization for your Home Assistant installation and local network devices. + +## Features + +- **NTP Client**: Synchronizes time from configurable upstream NTP servers +- **NTP Server**: Serves accurate time to devices on your local network +- **Lightweight**: Uses Chrony, a versatile and efficient NTP implementation +- **Multi-architecture**: Supports amd64 & aarch64 + +## Configuration + +### Options + +| Option | Description | Default | +| ------------------- | ----------------------------------------------------------- | ----------------------------------------- | +| `ntp_pools` | List of NTP pools (DNS names resolving to multiple servers) | pool.ntp.org | +| `ntp_servers` | List of individual NTP servers | time.google.com | +| `allow_clients` | Networks allowed to query this NTP server | 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12 | +| `enable_ntp_server` | Enable NTP server mode | true | +| `detailed_logging` | Enable detailed chrony logs (measurements, statistics) | false | + +### NTP Pools vs Servers + +- **Pools** (`ntp_pools`): DNS names that resolve to multiple NTP servers (e.g., pool.ntp.org). Chrony will automatically discover and use multiple servers from the pool. Use `maxsources` to limit how many servers to use from each pool. +- **Servers** (`ntp_servers`): Individual NTP server addresses (e.g., time.google.com). Use these for specific servers you want to always query. + +### Example Configuration + +```yaml +ntp_pools: + - pool: "pool.ntp.org" + iburst: true + maxsources: 4 + - pool: "time.cloudflare.com" + iburst: true +ntp_servers: + - server: "time.google.com" + iburst: true +allow_clients: + - "192.168.1.0/24" +enable_ntp_server: true +detailed_logging: false +``` + +## Installation + +1. Add this repository to your Home Assistant add-on store +2. Install the "Chrony NTP Server" add-on +3. Configure the add-on options as needed +4. Start the add-on + +## Usage + +Once running, the add-on will: + +1. Synchronize time from configured upstream NTP servers +2. If `enable_ntp_server` is true, serve time to clients on allowed networks via UDP port 123 + +### Configuring Clients + +Point your network devices to use your Home Assistant's IP address as their NTP server. + +## Support + +For issues and feature requests, please open an issue on the repository. diff --git a/chrony-addon/build.yaml b/chrony-addon/build.yaml new file mode 100644 index 0000000..40b24e1 --- /dev/null +++ b/chrony-addon/build.yaml @@ -0,0 +1,6 @@ +build_from: + amd64: ghcr.io/home-assistant/amd64-base:3.18 + aarch64: ghcr.io/home-assistant/aarch64-base:3.18 + armv7: ghcr.io/home-assistant/armv7-base:3.18 + armhf: ghcr.io/home-assistant/armhf-base:3.18 + i386: ghcr.io/home-assistant/i386-base:3.18 diff --git a/chrony-addon/config.yaml b/chrony-addon/config.yaml new file mode 100644 index 0000000..6010391 --- /dev/null +++ b/chrony-addon/config.yaml @@ -0,0 +1,49 @@ +name: "Chrony NTP Server" +description: "NTP server and client using Chrony for accurate time synchronization" +version: "1.0" +slug: "chrony-addon" +#image: "local/chrony-{arch}" +init: false +arch: + - amd64 + - aarch64 +host_network: true +privileged: + - SYS_TIME +webui: "http://[HOST]:[PORT:8099]" +ingress: true +ingress_port: 8099 +ingress_stream: false +panel_icon: "mdi:clock-sync" +panel_title: "Chrony NTP" +map: + - config:rw +options: + ntp_pools: + - pool: "pool.ntp.org" + iburst: true + ntp_servers: + - server: "time.google.com" + iburst: true + allow_clients: + - "192.168.0.0/16" + - "10.0.0.0/8" + - "172.16.0.0/12" + enable_ntp_server: true + detailed_logging: false +schema: + ntp_pools: + - pool: str + iburst: bool? + maxsources: int(1,16)? + ntp_servers: + - server: str + iburst: bool? + allow_clients: + - str + enable_ntp_server: bool + detailed_logging: bool +ports: + 123/udp: 123 +ports_description: + 123/udp: "NTP service" diff --git a/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/chrony/run b/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/chrony/run new file mode 100644 index 0000000..bcd2416 --- /dev/null +++ b/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/chrony/run @@ -0,0 +1,77 @@ +#!/command/with-contenv bash +# Generate chrony configuration from add-on options + +CONFIG_FILE="/etc/chrony/chrony.conf" +OPTIONS_FILE="/data/options.json" + +echo "[INFO] Generating Chrony configuration..." + +# Create config directory +mkdir -p /etc/chrony /var/lib/chrony /var/log/chrony + +# Start with base config +cat > "$CONFIG_FILE" << 'EOF' +# Chrony configuration - generated by Home Assistant add-on +driftfile /var/lib/chrony/chrony.drift +makestep 1.0 3 +rtcsync +logdir /var/log/chrony +EOF + +# Parse options.json with jq +if [ -f "$OPTIONS_FILE" ]; then + # Add NTP pools + pool_count=$(jq '.ntp_pools | length' "$OPTIONS_FILE") + for i in $(seq 0 $((pool_count - 1))); do + pool_addr=$(jq -r ".ntp_pools[$i].pool" "$OPTIONS_FILE") + iburst=$(jq -r ".ntp_pools[$i].iburst" "$OPTIONS_FILE") + maxsources=$(jq -r ".ntp_pools[$i].maxsources" "$OPTIONS_FILE") + + pool_line="pool ${pool_addr}" + [ "$iburst" = "true" ] && pool_line="${pool_line} iburst" + [ "$maxsources" != "null" ] && pool_line="${pool_line} maxsources ${maxsources}" + + echo "$pool_line" >> "$CONFIG_FILE" + echo "[INFO] Added NTP pool: ${pool_addr}" + done + + # Add NTP servers + server_count=$(jq '.ntp_servers | length' "$OPTIONS_FILE") + for i in $(seq 0 $((server_count - 1))); do + server_addr=$(jq -r ".ntp_servers[$i].server" "$OPTIONS_FILE") + iburst=$(jq -r ".ntp_servers[$i].iburst" "$OPTIONS_FILE") + + if [ "$iburst" = "true" ]; then + echo "server ${server_addr} iburst" >> "$CONFIG_FILE" + else + echo "server ${server_addr}" >> "$CONFIG_FILE" + fi + echo "[INFO] Added NTP server: ${server_addr}" + done + + # Configure as NTP server if enabled + enable_server=$(jq -r '.enable_ntp_server' "$OPTIONS_FILE") + if [ "$enable_server" = "true" ]; then + echo "[INFO] NTP server mode enabled" + + # Add allowed client networks + for network in $(jq -r '.allow_clients[]' "$OPTIONS_FILE"); do + echo "allow ${network}" >> "$CONFIG_FILE" + echo "[INFO] Allowing clients from: ${network}" + done + fi + + # Set logging + detailed_logging=$(jq -r '.detailed_logging' "$OPTIONS_FILE") + if [ "$detailed_logging" = "true" ]; then + echo "log tracking measurements statistics" >> "$CONFIG_FILE" + echo "[INFO] Detailed logging enabled" + else + echo "log tracking" >> "$CONFIG_FILE" + fi +fi + +echo "[INFO] Chrony configuration generated successfully" + +# Start chrony in foreground +exec chronyd -d -f "$CONFIG_FILE" diff --git a/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/chrony/type b/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/chrony/type new file mode 100644 index 0000000..5883cff --- /dev/null +++ b/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/chrony/type @@ -0,0 +1 @@ +longrun diff --git a/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/chrony b/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/chrony new file mode 100644 index 0000000..e69de29 diff --git a/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/webui b/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/webui new file mode 100644 index 0000000..e69de29 diff --git a/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/webui/run b/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/webui/run new file mode 100644 index 0000000..59fd1c6 --- /dev/null +++ b/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/webui/run @@ -0,0 +1,4 @@ +#!/command/with-contenv bash + +cd /var/www +exec python3 /var/www/app.py diff --git a/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/webui/type b/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/webui/type new file mode 100644 index 0000000..5883cff --- /dev/null +++ b/chrony-addon/rootfs/etc/s6-overlay/s6-rc.d/webui/type @@ -0,0 +1 @@ +longrun diff --git a/chrony-addon/rootfs/var/www/app.py b/chrony-addon/rootfs/var/www/app.py new file mode 100644 index 0000000..94857b6 --- /dev/null +++ b/chrony-addon/rootfs/var/www/app.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Chrony NTP Server Web UI for Home Assistant.""" + +import json +import os +import subprocess +from flask import Flask, render_template, jsonify + +app = Flask(__name__) + +OPTIONS_FILE = "/data/options.json" + + +def get_options(): + """Load current options from Home Assistant.""" + try: + with open(OPTIONS_FILE, 'r') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return { + "ntp_pools": [ + {"pool": "pool.ntp.org", "iburst": True} + ], + "ntp_servers": [ + {"server": "time.google.com", "iburst": True} + ], + "allow_clients": ["192.168.0.0/16", "10.0.0.0/8", "172.16.0.0/12"], + "enable_ntp_server": True, + "detailed_logging": False + } + + +def get_chrony_status(): + """Get current chrony synchronization status.""" + status = { + "tracking": {}, + "sources": [], + "clients": [] + } + + try: + # Get tracking info + result = subprocess.run( + ["chronyc", "-c", "tracking"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + fields = result.stdout.strip().split(',') + if len(fields) >= 14: + status["tracking"] = { + "reference_id": fields[0], + "reference_name": fields[1], + "stratum": fields[2], + "ref_time": fields[3], + "system_time": fields[4], + "last_offset": fields[5], + "rms_offset": fields[6], + "frequency": fields[7], + "residual_freq": fields[8], + "skew": fields[9], + "root_delay": fields[10], + "root_dispersion": fields[11], + "update_interval": fields[12], + "leap_status": fields[13] + } + + # Get sources + result = subprocess.run( + ["chronyc", "-c", "sources"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + for line in result.stdout.strip().split('\n'): + if line: + fields = line.split(',') + if len(fields) >= 10: + status["sources"].append({ + "mode": fields[0], + "state": fields[1], + "name": fields[2], + "stratum": fields[3], + "poll": fields[4], + "reach": fields[5], + "last_rx": fields[6], + "last_sample_offset": fields[7], + "last_sample_error": fields[8] + }) + + # Get clients (if server mode enabled) + result = subprocess.run( + ["chronyc", "-c", "clients"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + for line in result.stdout.strip().split('\n'): + if line: + fields = line.split(',') + if len(fields) >= 5: + status["clients"].append({ + "hostname": fields[0], + "ntp_requests": fields[1], + "drop": fields[2], + "last_rx": fields[4] + }) + + except subprocess.TimeoutExpired: + status["error"] = "Timeout getting chrony status" + except Exception as e: + status["error"] = str(e) + + return status + + +@app.route('/') +def index(): + """Main dashboard page.""" + options = get_options() + status = get_chrony_status() + return render_template('index.html', options=options, status=status) + + +@app.route('/api/status') +def api_status(): + """API endpoint for current status.""" + return jsonify(get_chrony_status()) + + +@app.route('/api/options') +def api_options(): + """API endpoint for current options.""" + return jsonify(get_options()) + + +@app.route('/api/force-sync', methods=['POST']) +def force_sync(): + """Force immediate time synchronization.""" + try: + result = subprocess.run( + ["chronyc", "makestep"], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + return jsonify({"success": True, "message": "Time step applied"}) + else: + return jsonify({"success": False, "message": result.stderr}), 500 + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +@app.route('/api/burst', methods=['POST']) +def burst_sync(): + """Trigger burst mode for faster synchronization.""" + try: + result = subprocess.run( + ["chronyc", "burst", "4/4"], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + return jsonify({"success": True, "message": "Burst mode activated"}) + else: + return jsonify({"success": False, "message": result.stderr}), 500 + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +if __name__ == '__main__': + # Get ingress port from environment + port = int(os.environ.get('INGRESS_PORT', 8099)) + app.run(host='0.0.0.0', port=port, debug=False) diff --git a/chrony-addon/rootfs/var/www/templates/index.html b/chrony-addon/rootfs/var/www/templates/index.html new file mode 100644 index 0000000..dbd9bff --- /dev/null +++ b/chrony-addon/rootfs/var/www/templates/index.html @@ -0,0 +1,477 @@ + + + + + + Chrony NTP Server + + + +
+
+

+ 🕐 + Chrony NTP Server +

+ {% if status.tracking.stratum and status.tracking.stratum != '0' %} + ● Synchronized + {% else %} + ● Synchronizing + {% endif %} +
+ +
+
+

Time Synchronization Status

+
+
+
Reference Server
+
{{ status.tracking.reference_name or 'N/A' }}
+
+
+
Stratum
+
{{ status.tracking.stratum or 'N/A' }}
+
+
+
System Time Offset
+
{{ status.tracking.system_time or 'N/A' }}
+
+
+
Frequency
+
{{ status.tracking.frequency or 'N/A' }} ppm
+
+
+
Root Delay
+
{{ status.tracking.root_delay or 'N/A' }}
+
+
+
Update Interval
+
{{ status.tracking.update_interval or 'N/A' }}s
+
+
+
+ + +
+
+ +
+

Configuration

+
    +
  • + NTP Server Mode + {% if options.enable_ntp_server %} + Enabled + {% else %} + Disabled + {% endif %} +
  • +
  • + Detailed Logging + {% if options.detailed_logging %} + Enabled + {% else %} + Disabled + {% endif %} +
  • +
  • + NTP Pools + {{ options.ntp_pools|default([])|length }} +
  • +
  • + NTP Servers + {{ options.ntp_servers|default([])|length }} +
  • +
  • + Allowed Networks + {{ options.allow_clients|length }} +
  • +
+

+ To modify settings, go to Add-on Configuration in Home Assistant. +

+
+
+ +
+

NTP Sources

+ + + + + + + + + + + + + {% for source in status.sources %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
StatusServerStratumPollReachOffset
+ {% if source.state == '*' %} + Synced + {% elif source.state == '+' %} + Combined + {% elif source.state == '-' %} + Candidate + {% elif source.state == '?' %} + Unreachable + {% elif source.state == 'x' %} + Falseticker + {% elif source.state == '~' %} + Variable + {% else %} + Unknown + {% endif %} + {{ source.name }}{{ source.stratum }}{{ (2 ** (source.poll | int)) }}s{{ source.reach }}{{ source.last_sample_offset }}
+ No sources available yet. Chrony may still be starting up. +
+
+ + {% if options.enable_ntp_server %} +
+

Connected Clients

+ + + + + + + + + + + {% for client in status.clients %} + + + + + + + {% else %} + + + + {% endfor %} + +
ClientNTP RequestsDroppedLast Seen
{{ client.hostname }}{{ client.ntp_requests }}{{ client.drop }}{{ client.last_rx }}
+ No clients have connected yet. +
+ +

Allowed Networks

+
    + {% for network in options.allow_clients %} +
  • {{ network }}
  • + {% endfor %} +
+
+ {% endif %} + +

Page auto-refreshes every 30 seconds • Last updated:

+
+ +
+ + + + diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 0000000..36ba13e --- /dev/null +++ b/repository.yaml @@ -0,0 +1,3 @@ +name: Chrony NTP Add-on Repository +url: https://github.com/adamhf/homassistant_chrony_addon +maintainer: adamhf