diff --git a/adjust_time.coffee b/adjust_time.coffee new file mode 100755 index 0000000..ea8d53f --- /dev/null +++ b/adjust_time.coffee @@ -0,0 +1,57 @@ +util = require 'util' +{ spawn } = require 'child_process' +dayjs = require 'dayjs' +dayjs.extend require './dayjs_format_ms' +platform = require('os').platform() + + +adjust_time = (delta) -> + console.log 'Adjust time...' + shell = spawn spawn_args... + shell.stderr.on 'data', (data) -> console.error "> " + "#{data}".trim() + shell.stdout.on 'data', (data) -> console.debug "> " + "#{data}".trim() + shell.on 'close', (code) -> + shell.stdin.end() + console.log 'Done' + process.exit 0 + wait_data = util.promisify (cb) -> + shell.stdout.once 'data', (chunk) -> + setTimeout -> + cb null, chunk + , 1 + input_line = util.promisify (cmd, cb) -> + console.debug "$ #{cmd}" + shell.stdin.write "#{cmd}\n", (p...) -> + await wait_data() if platform in ['win32'] + cb p... + await wait_data() if platform in ['win32'] + cmd = dayjs().add(delta, 'ms').format adjust_time.command + await input_line cmd + await input_line 'exit' + + +COMMANDS = { + win32: '[time ]HH:mm:ss.SS[ && date ]YYYY-MM-DD' + linux: '[date -s ]YYYY-MM-DDTHH:mm:ss.SSSZ' +} +adjust_time.command = COMMANDS[platform] or COMMANDS.linux + + +SHELL_ARGS = { + win32: [ + 'cmd' + ['/q', '/k', 'prompt $H && chcp 65001 > nul'] + { + windowsHide: true + } + ] + linux: [ + 'sh' + ] +} +spawn_args = SHELL_ARGS[platform] or SHELL_ARGS.linux +if not /UTF\-8/i.test (process.env.LANGUAGE or '') + process.env.LANGUAGE = 'C.UTF-8' + + +module.exports = adjust_time \ No newline at end of file diff --git a/argv.coffee b/argv.coffee new file mode 100755 index 0000000..caca30e --- /dev/null +++ b/argv.coffee @@ -0,0 +1,122 @@ +path = require 'path' +util = require 'util' +minimist = require 'minimist' +minimist_opt = require 'minimist-options' +{ orderBy } = require 'natural-orderby' +info = require './package.json' +platform = require('os').platform() + + +DEFAULT_OPTIONS = { + help: { + describe: 'This help text' + type: 'boolean' + alias: 'h' + default: false + } + version: { + describe: "display the version of #{info.name} and exit" + type: 'boolean' + alias: 'v' + default: false + } +} + + +print_version = -> + console.log """ + #{info.name} #{info.version}, #{info.description} + + Copyright (C) 2021 Bob Wen. All rights reserved. + + License: AGPL-3.0 + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation; either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Affero General Public License for more details. + + Libraries: + """ + for lib_name, version of info.dependencies + lib_info = require "./node_modules/#{lib_name}/package.json" + console.log """ + #{lib_info.name} #{lib_info.version} + License: #{lib_info.license} + Homepage: #{lib_info.homepage.split('#')[0]} + + """ + + + +print_usage = (options) -> + items = [] + for k, v of options + items.push { + key: k + v... + } + items = orderBy items, (v) -> v.alias ? v.key + console.log """ + #{info.name} #{info.version}, #{info.description} + Usage: #{info.name} [options...] urls... + + Options + """ + for o in items + if o.alias? + line = " -#{o.alias}, --#{o.key}".padEnd 24 + else + line = " --#{o.key}".padEnd 24 + line += "#{o.describe or ''}\n" + if o.default? + line += "".padStart(24) + "Default: #{util.inspect o.default}" + else if o.type? + line += "".padStart(24) + "Type: #{o.type}" + console.log line + '\n' + + +print_examples = (exam = []) -> + console.log """ + Examples + Get time from multiple URLs + #{info.name} www.pool.ntp.org www.openssl.org nodejs.org + + Change default protocol to 'http' + #{info.name} --protocol http www.pool.ntp.org + + Mix http url and https url + #{info.name} http://www.pool.ntp.org https://www.openssl.org + + Access through a http proxy + #{if platform is 'win32' then 'set' else 'export'} http_proxy=http://127.0.0.1:8118 + #{info.name} www.pool.ntp.org + """ + + + +module.exports = (opt, exam) -> + cli_options = { + DEFAULT_OPTIONS... + opt... + } + argv = minimist process.argv.slice(2), minimist_opt cli_options + if argv.version + print_version() + process.exit 0 + if argv.help or argv._.length is 0 + print_usage cli_options + print_examples exam + console.log() + if argv.help + process.exit 0 + console.error "\nError: Missing server url, at least one url should be specified" + process.exit 0 + if argv.count < 1 + argv.count = 1 + argv diff --git a/dayjs_format_ms.coffee b/dayjs_format_ms.coffee new file mode 100755 index 0000000..58995fe --- /dev/null +++ b/dayjs_format_ms.coffee @@ -0,0 +1,16 @@ +module.exports = (o, c, d) -> + proto = c.prototype + old_format = proto.format + proto.format = (fmt_str = 'YYYY-MM-DDTHH:mm:ssZ') -> + locale = @$locale() + utils = @$utils() + r = fmt_str.replace /\[([^\]]+)]|SSS|S{2,2}|S/g, (word) => + switch word + when 'S' + "#{@$ms}" + when 'SS' + ms = Math.round(@$ms / 10) + "#{ms}".padStart(2, '0') + else + word + old_format.bind(this) r diff --git a/index.coffee b/index.coffee new file mode 100755 index 0000000..55a7630 --- /dev/null +++ b/index.coffee @@ -0,0 +1,130 @@ +util = require 'util' +got = require 'got' +dayjs = require 'dayjs' +info = require './package.json' +median = require './median' +adjust_time = require './adjust_time' + + +argv = require('./argv') { + threshold: { + alias: 't' + describe: 'At least how many milliseconds are considered to adjust system time' + default: 1500 + type: 'number' + } + set: { + describe: 'Adjust system time if necessary' + alias: 's' + default: false + type: 'boolean' + } + protocol: { + describe: 'Use this protocol when no protocol is specified in the url' + alias: 'p' + default: 'https' + type: 'string' + } + method: { + describe: 'HTTP method' + alias: 'm' + default: 'HEAD' + type: 'string' + } + count: { + describe: 'The number of requests for each url' + alias: 'c' + default: 4 + type: 'number' + } + redirect: { + describe: 'If redirect responses should be followed' + alias: 'r' + default: false + type: 'boolean' + } + timeout: { + alias: 'T' + default: 6000 + type: 'number' + } + command: { + describe: 'Command to adjust system time, in https://day.js.org/ display format' + alias: 'C' + default: adjust_time.command + type: 'string' + } + interval: { + describe: 'The minimum milliseconds between requests' + alias: 'i' + default: 500 + type: 'number' + } + 'user-agent': { + alias: 'u' + default: "#{info.name}/#{info.version}" + type: 'string' + } +} +adjust_time.command = argv.command + + +req_opt = { + method: argv.method.trim().toUpperCase() + followRedirect: argv.redirect + timeout: argv.timeout + retry: 0 + dnsCache: true + cache: false + headers: { + 'user-agent': argv['user-agent'] + } +} + + +get_time_delta = (url) -> + if not /^https?:\/\//.test url + url = "#{argv.protocol}://#{url}" + console.log "#{argv.method} #{url}" + for i in [1 .. argv.count] + step = "\##{i}: ".padStart 8 + start_at = Date.now() + try + r = await got url, req_opt + catch e + if not e.response? + console.log "#{step}#{e}" + wait_ms = argv.interval + start_at - Date.now() + if wait_ms > 0 + await delay wait_ms + continue + r = e.response + wait_ms = argv.interval + start_at - Date.now() + if wait_ms > 0 + await delay wait_ms + duration = r.timings.end - r.timings.upload + server_moment = dayjs r.headers.date + delta = Math.round(server_moment - r.timings.end - duration / 2 + 500) + console.log "#{step}" + "#{delta} ms".padStart 10 + delta + + +delay = util.promisify (ms, cb) -> + setTimeout cb, ms + + +do -> + values = [] + for url in argv._ + values.push (await get_time_delta url)... + if values.length is 0 + console.log "Network failure" + process.exit 2 + return + delta = median values + console.log "Median: " + "#{delta} ms".padStart 10 + return if not argv.set + if Math.abs(delta) < argv.threshold + console.log "There is no need to adjust the time" + return + await adjust_time delta \ No newline at end of file diff --git a/median.coffee b/median.coffee new file mode 100755 index 0000000..c158153 --- /dev/null +++ b/median.coffee @@ -0,0 +1,25 @@ +# Select the kth element in arr +quickselect = (arr, k) -> + return arr[0] if arr.length is 1 + pivot = arr[0] + lows = arr.filter (e) -> e < pivot + highs = arr.filter (e) -> e > pivot + pivots = arr.filter (e) -> e is pivot + if k < lows.length + # the pivot is too high + quickselect lows, k + else if k < lows.length + pivots.length + # We got lucky and guessed the median + pivot + else + # the pivot is too low + quickselect highs, k - lows.length - pivots.length + + +module.exports = (arr) -> + L = arr.length + halfL = L / 2 + if (L % 2) is 1 + quickselect arr, halfL + else + 0.5 * (quickselect(arr, halfL - 1) + quickselect(arr, halfL)) diff --git a/package.json b/package.json new file mode 100755 index 0000000..d18df2e --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "htpdate", + "version": "1.0.0", + "description": "a tool to synchronize system time with web servers", + "keywords": [ + "htp", + "htpdate", + "ntp", + "ntp-client" + ], + "author": { + "name": "Bob Wen", + "email": "bobwen@tutanota.com", + "url": "https://github.com/bobwen-dev" + }, + "license": "AGPL-3.0-or-later", + "homepage": "https://github.com/bobwen-dev/htpdate", + "bugs": { + "url": "https://github.com/bobwen-dev/htpdate/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/bobwen-dev/htpdate.git" + }, + "scripts": { + "compile": "npx coffee -bc --no-header ./", + "clean": "rm *.js", + "b": "npx nexe -i index.js -r node_modules/**/package.json", + "b-win-x86": "npm run b -- -t windows-x86-14.15.3 -o dists/htpdate-x86.exe", + "b-win-x64": "npm run b -- -t windows-x64-14.15.4 -o dists/htpdate-x64.exe", + "b-linux-x64": "npm run b -- -t linux-x64-14.15.3 -o dists/htpdate-linux-x64", + "b-linux-arm64": "npm run b -- -t linux-arm64-14.15.4 -o dists/htpdate-linux-arm64", + "b-mac-x64": "npm run b -- -t mac-x64-14.15.3 -o dists/htpdate-mac-x64", + "b-all": "npm run b-win-x86 && npm run b-win-x64 && npm run b-linux-x64 && npm run b-linux-arm64 && npm run b-mac-x64", + "build": "npm run compile && npm run b-all && npm run clean", + "test": "npx coffee index.coffee www.pool.ntp.org" + }, + "main": "index.js", + "engines": { + "node": ">=10.19.0" + }, + "dependencies": { + "dayjs": "^1.10.3", + "got": "^11.8.0", + "minimist": "^1.2.5", + "minimist-options": "^4.1.0", + "natural-orderby": "^2.0.3" + }, + "devDependencies": { + "coffeescript": "^2.5.1", + "nexe": "^4.0.0-beta.16" + } +}