diff --git a/ansible/playbooks/bootstrap.yaml b/ansible/playbooks/bootstrap.yaml index 84d7611..8d80353 100644 --- a/ansible/playbooks/bootstrap.yaml +++ b/ansible/playbooks/bootstrap.yaml @@ -5,6 +5,8 @@ roles: - role: base tags: [base] + - role: railiance_clock + tags: [clock] - role: sops_agent tags: [sops] - role: custodian_agent # injects ~/.ssh/id_custodian_agent.pub into authorized_keys diff --git a/ansible/playbooks/host-time.yaml b/ansible/playbooks/host-time.yaml new file mode 100644 index 0000000..5cb5e78 --- /dev/null +++ b/ansible/playbooks/host-time.yaml @@ -0,0 +1,6 @@ +--- +- name: Converge only explicitly admitted host time infrastructure + hosts: all + become: true + roles: + - role: railiance_clock diff --git a/ansible/roles/railiance_clock/defaults/main.yaml b/ansible/roles/railiance_clock/defaults/main.yaml new file mode 100644 index 0000000..5f0121b --- /dev/null +++ b/ansible/roles/railiance_clock/defaults/main.yaml @@ -0,0 +1,18 @@ +--- +# Both baseline convergence and authority activation are explicit inventory opt-ins. +railiance_clock_enabled: false +railiance_clock_manage_timesyncd: false +railiance_clock_authority_enabled: false +railiance_clock_upstreams: [ntp.ubuntu.com] +railiance_clock_policy_id: railiance01-online-v1 +railiance_clock_install_root: /opt/railiance-clock +railiance_clock_health_root: /run/railiance-clock +railiance_clock_state_root: /var/lib/railiance-clock +railiance_clock_wheelhouse: "" +railiance_clock_wheel_sha256: "" +railiance_clock_private_key_source: "" +railiance_clock_kid: "" +railiance_clock_admission_ref: "" +railiance_clock_max_error_ns: 500000000 +railiance_clock_measurement_margin_ns: 10000000 +railiance_clock_host_drift_ppm: 1000 diff --git a/ansible/roles/railiance_clock/handlers/main.yaml b/ansible/roles/railiance_clock/handlers/main.yaml new file mode 100644 index 0000000..fd992aa --- /dev/null +++ b/ansible/roles/railiance_clock/handlers/main.yaml @@ -0,0 +1,13 @@ +--- +- name: Restart reviewed timesyncd + ansible.builtin.systemd: + name: systemd-timesyncd + state: restarted +- name: Reload clock service units + ansible.builtin.systemd: + daemon_reload: true +- name: Restart admitted clock authority + ansible.builtin.systemd: + name: railiance-clock.service + state: restarted + daemon_reload: true diff --git a/ansible/roles/railiance_clock/tasks/authority.yaml b/ansible/roles/railiance_clock/tasks/authority.yaml new file mode 100644 index 0000000..912bc60 --- /dev/null +++ b/ansible/roles/railiance_clock/tasks/authority.yaml @@ -0,0 +1,128 @@ +--- +- name: Require versioned artifact and admitted signing-key delivery + ansible.builtin.assert: + that: + - railiance_clock_wheelhouse | length > 0 + - railiance_clock_wheel_sha256 is match('^[0-9a-f]{64}$') + - railiance_clock_private_key_source | length > 0 + - railiance_clock_kid | length > 0 +- name: Create unprivileged authority account + ansible.builtin.user: + name: railiance-clock + system: true + shell: /usr/sbin/nologin + create_home: false +- name: Create root-owned install tree + ansible.builtin.file: + path: "{{ railiance_clock_install_root }}" + state: directory + owner: root + group: root + mode: '0755' +- name: Create private signing-key directory + ansible.builtin.file: + path: "{{ railiance_clock_state_root }}" + state: directory + owner: railiance-clock + group: railiance-clock + mode: '0700' +- name: Create public health runtime directory + ansible.builtin.file: + path: "{{ railiance_clock_health_root }}" + state: directory + owner: root + group: root + mode: '0755' +- name: Persist runtime directory creation across boot + ansible.builtin.copy: + dest: /etc/tmpfiles.d/railiance-clock.conf + content: "d {{ railiance_clock_health_root }} 0755 root root -\n" + owner: root + group: root + mode: '0644' +- name: Verify selected wheel checksum on controller + ansible.builtin.stat: + path: "{{ railiance_clock_wheelhouse }}/railiance_clock-0.1.0-py3-none-any.whl" + checksum_algorithm: sha256 + delegate_to: localhost + become: false + register: clock_wheel +- name: Refuse artifact drift + ansible.builtin.assert: + that: clock_wheel.stat.checksum == railiance_clock_wheel_sha256 +- name: Copy offline wheelhouse with locked dependencies + ansible.builtin.copy: + src: "{{ railiance_clock_wheelhouse }}/" + dest: "{{ railiance_clock_install_root }}/wheels/" + owner: root + group: root + mode: '0644' + register: clock_artifact_copy +- name: Install isolated authority package without network package lookup + ansible.builtin.pip: + name: railiance-clock==0.1.0 + state: "{{ 'forcereinstall' if clock_artifact_copy.changed else 'present' }}" + virtualenv: "{{ railiance_clock_install_root }}/venv" + virtualenv_command: python3 -m venv + extra_args: "--no-index --find-links={{ railiance_clock_install_root }}/wheels" + notify: Restart admitted clock authority +- name: Deliver admitted signing key without logging values + ansible.builtin.copy: + src: "{{ railiance_clock_private_key_source }}" + dest: "{{ railiance_clock_state_root }}/signing.pem" + owner: railiance-clock + group: railiance-clock + mode: '0600' + no_log: true + diff: false + notify: Restart admitted clock authority +- name: Install prebuilt read-only kernel-health probe + ansible.builtin.copy: + src: "{{ railiance_clock_wheelhouse }}/railiance-clock-kernel-health" + dest: "{{ railiance_clock_install_root }}/kernel-health" + owner: root + group: root + mode: '0755' +- name: Write non-secret authority and health policy configuration + ansible.builtin.template: + src: "{{ item }}.json.j2" + dest: "{{ railiance_clock_install_root }}/{{ item }}.json" + owner: root + group: root + mode: '0644' + loop: [authority, health-policy] + notify: Restart admitted clock authority +- name: Install host service units + ansible.builtin.template: + src: "{{ item }}.j2" + dest: "/etc/systemd/system/{{ item }}" + owner: root + group: root + mode: '0644' + loop: [railiance-clock.service, railiance-clock-health.service, railiance-clock-health.timer] + notify: Reload clock service units +- name: Load changed units before starting + ansible.builtin.meta: flush_handlers +- name: Export current host health + ansible.builtin.systemd: + name: railiance-clock-health.service + state: started +- name: Enable bounded periodic host-health export + ansible.builtin.systemd: + name: railiance-clock-health.timer + enabled: true + state: started +- name: Enable private authority + ansible.builtin.systemd: + name: railiance-clock.service + enabled: true + state: started +- name: Verify usable authority independently of service liveness + ansible.builtin.uri: + url: http://127.0.0.1:8787/readyz + return_content: true + status_code: 200 + register: clock_ready + retries: 3 + delay: 2 + until: clock_ready.status == 200 diff --git a/ansible/roles/railiance_clock/tasks/main.yaml b/ansible/roles/railiance_clock/tasks/main.yaml new file mode 100644 index 0000000..8c01292 --- /dev/null +++ b/ansible/roles/railiance_clock/tasks/main.yaml @@ -0,0 +1,50 @@ +--- +- name: Converge explicitly admitted Railiance Clock host scope + when: railiance_clock_enabled | bool + block: + - name: Require supported host and concrete admission + ansible.builtin.assert: + that: + - ansible_system == 'Linux' + - ansible_distribution == 'Ubuntu' + - ansible_distribution_major_version == '24' + - railiance_clock_admission_ref | length > 0 + - railiance_clock_upstreams | length > 0 + - railiance_clock_host_drift_ppm | int >= 500 + - railiance_clock_max_error_ns | int > railiance_clock_measurement_margin_ns | int + - name: Gather service facts without changing clocks + ansible.builtin.service_facts: + - name: Refuse competing discipline daemons + ansible.builtin.assert: + that: + - ansible_facts.services.get(item, {}).get('state', 'stopped') != 'running' + loop: [chrony.service, chronyd.service, ntp.service, ntpd.service] + - name: Ensure the existing time daemon is installed + ansible.builtin.assert: + that: "'systemd-timesyncd.service' in ansible_facts.services" + - name: Configure reviewed timesyncd baseline only when explicitly selected + when: railiance_clock_manage_timesyncd | bool + block: + - name: Create timesyncd configuration directory + ansible.builtin.file: + path: /etc/systemd/timesyncd.conf.d + state: directory + owner: root + group: root + mode: '0755' + - name: Install sole owner drop-in + ansible.builtin.template: + src: timesyncd.conf.j2 + dest: /etc/systemd/timesyncd.conf.d/60-railiance-clock.conf + owner: root + group: root + mode: '0644' + notify: Restart reviewed timesyncd + - name: Keep admitted daemon enabled + ansible.builtin.systemd: + name: systemd-timesyncd + enabled: true + state: started + - name: Install authority and health export + when: railiance_clock_authority_enabled | bool + ansible.builtin.include_tasks: authority.yaml diff --git a/ansible/roles/railiance_clock/templates/authority.json.j2 b/ansible/roles/railiance_clock/templates/authority.json.j2 new file mode 100644 index 0000000..d8ba60c --- /dev/null +++ b/ansible/roles/railiance_clock/templates/authority.json.j2 @@ -0,0 +1 @@ +{{ {'private_key_file': railiance_clock_state_root + '/signing.pem', 'health_file': railiance_clock_health_root + '/health.json', 'health_owner_uid': 0, 'authority_id': 'railiance01', 'environment': 'prod', 'kid': railiance_clock_kid, 'policy_id': railiance_clock_policy_id, 'port': 8787, 'max_health_age_ns': 10000000000, 'host_drift_ppm': railiance_clock_host_drift_ppm | int, 'max_error_ns': railiance_clock_max_error_ns | int, 'max_continuity_error_ns': 50000000, 'max_sample_age_ns': 5000000000} | to_nice_json }} diff --git a/ansible/roles/railiance_clock/templates/health-policy.json.j2 b/ansible/roles/railiance_clock/templates/health-policy.json.j2 new file mode 100644 index 0000000..40d2def --- /dev/null +++ b/ansible/roles/railiance_clock/templates/health-policy.json.j2 @@ -0,0 +1 @@ +{{ {'status': 'admitted', 'policy_id': railiance_clock_policy_id, 'upstreams': railiance_clock_upstreams, 'kernel_helper': railiance_clock_install_root + '/kernel-health', 'max_error_ns': railiance_clock_max_error_ns | int, 'measurement_margin_ns': railiance_clock_measurement_margin_ns | int, 'host_drift_ppm': railiance_clock_host_drift_ppm | int, 'admission_ref': railiance_clock_admission_ref} | to_nice_json }} diff --git a/ansible/roles/railiance_clock/templates/railiance-clock-health.service.j2 b/ansible/roles/railiance_clock/templates/railiance-clock-health.service.j2 new file mode 100644 index 0000000..414215e --- /dev/null +++ b/ansible/roles/railiance_clock/templates/railiance-clock-health.service.j2 @@ -0,0 +1,18 @@ +[Unit] +Description=Read-only Railiance host clock quality export +After=systemd-timesyncd.service +[Service] +Type=oneshot +ExecStart={{ railiance_clock_install_root }}/venv/bin/railiance-clock health-export --policy {{ railiance_clock_install_root }}/health-policy.json --output {{ railiance_clock_health_root }}/health.json +User=root +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +ProtectSystem=strict +ReadWritePaths={{ railiance_clock_health_root }} +ProtectHome=yes +PrivateTmp=yes +ProtectClock=yes +RestrictAddressFamilies=AF_UNIX +TimeoutStartSec=12 +UMask=0022 diff --git a/ansible/roles/railiance_clock/templates/railiance-clock-health.timer.j2 b/ansible/roles/railiance_clock/templates/railiance-clock-health.timer.j2 new file mode 100644 index 0000000..c3a2840 --- /dev/null +++ b/ansible/roles/railiance_clock/templates/railiance-clock-health.timer.j2 @@ -0,0 +1,9 @@ +[Unit] +Description=Refresh Railiance host clock health +[Timer] +OnBootSec=2 +OnUnitActiveSec=2 +AccuracySec=100ms +Unit=railiance-clock-health.service +[Install] +WantedBy=timers.target diff --git a/ansible/roles/railiance_clock/templates/railiance-clock.service.j2 b/ansible/roles/railiance_clock/templates/railiance-clock.service.j2 new file mode 100644 index 0000000..f7c4ffa --- /dev/null +++ b/ansible/roles/railiance_clock/templates/railiance-clock.service.j2 @@ -0,0 +1,29 @@ +[Unit] +Description=Private Railiance application time authority +After=network.target railiance-clock-health.service +Requires=railiance-clock-health.service +[Service] +User=railiance-clock +Group=railiance-clock +ExecStart={{ railiance_clock_install_root }}/venv/bin/railiance-clock serve --config {{ railiance_clock_install_root }}/authority.json +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectClock=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_INET AF_INET6 +IPAddressDeny=any +IPAddressAllow=localhost +MemoryMax=128M +TasksMax=32 +UMask=0077 +Restart=on-failure +RestartSec=5 +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/railiance_clock/templates/timesyncd.conf.j2 b/ansible/roles/railiance_clock/templates/timesyncd.conf.j2 new file mode 100644 index 0000000..1d5fa50 --- /dev/null +++ b/ansible/roles/railiance_clock/templates/timesyncd.conf.j2 @@ -0,0 +1,7 @@ +# Managed by railiance-infra; {{ railiance_clock_admission_ref }}. +[Time] +NTP={{ railiance_clock_upstreams | join(' ') }} +FallbackNTP= +PollIntervalMinSec=32 +PollIntervalMaxSec=64 +RootDistanceMaxSec=500ms diff --git a/workplans/RAIL-HO-WP-0013-host-time-baseline.md b/workplans/RAIL-HO-WP-0013-host-time-baseline.md index 0800e1b..542f845 100644 --- a/workplans/RAIL-HO-WP-0013-host-time-baseline.md +++ b/workplans/RAIL-HO-WP-0013-host-time-baseline.md @@ -4,7 +4,7 @@ type: workplan title: "Declare and verify the Railiance host UTC baseline" domain: financials repo: railiance-infra -status: ready +status: active flavor: planning owner: codex topic_slug: railiance @@ -49,7 +49,7 @@ Define what source health can honestly claim before exposing it to the clock app ```task id: RAIL-HO-WP-0013-T02 -status: wait +status: progress priority: high state_hub_task_id: "75f17ffa-b781-549d-82ce-d19d431c2618" ``` @@ -92,3 +92,10 @@ IaC to apply/read back. Reverify usable host health and no extra listener/daemon record rollback and monitoring ownership. Hand evidence to RCLK-WP-0005 and railiance-bootstrap for ordering/rehearsal. Keep all residuals live before closure. No corporate workstation time settings or app-clock trust adoption in this plan. + + +2026-09-15: operator requested implementation and consumer use. Added opt-in +railiance_clock owner role and host-time playbook; bootstrap invokes the same +role. Authority activation requires artifact hash, admission reference and +explicit signing-key delivery. Defaults change no clocks and deploy no service. +Native convergence, custody and measured UTC policy acceptance remain open.