Skip to content

Commit 081977e

Browse files
authored
Merge pull request #6 from yuuahp/feat/parallel-fallback
feat: parallel requests, fallback servers
2 parents 669464a + 1d2e9d8 commit 081977e

File tree

4 files changed

+181
-76
lines changed

4 files changed

+181
-76
lines changed

README.md

+26
Original file line numberDiff line numberDiff line change
@@ -26,23 +26,42 @@ A tiny CLI tool to call NTP servers.
2626
Please note that `--address` conflicts with `--hostname` and `--port`.
2727

2828
```bash
29+
# Call default NTP server
2930
$ ./ntp-cli
3031
Calling NTP server at pool.ntp.org:123...
3132
Current time: Tue Apr 1 23:50:00 JST 2025
3233

34+
# Call NTP server with hostname and default port
3335
$ ./ntp-cli -h time.apple.com
3436
Calling NTP server at time.apple.com:123...
3537
Current time: Tue Apr 1 23:50:59 JST 2025
3638

39+
# Silence the output
3740
$ ./ntp-cli -a time.apple.com:123 -q
3841
$ ./ntp-cli -h time.apple.com -p 123 -q
3942
Tue Apr 1 23:53:09 JST 2025
4043

44+
# Change the time format
4145
$ ./ntp-cli -f RFC3339 -q
4246
2025-04-01T23:57:00+09:00
4347

48+
# This causes an error because you can't specify both address and hostname
4449
$ ./ntp-cli -h time.apple.com -a pool.ntp.org:123
4550
invalid arguments: you can either specify an address or a hostname, but not both
51+
52+
# Call NTP servers in parallel
53+
$ ./ntp-cli -h time.apple.com -p 123 --parallel time.apple.com:234,pool.ntp.org:123
54+
Calling NTP server at pool.ntp.org:123...
55+
Calling NTP server at time.apple.com:234... # This fails
56+
Calling NTP server at time.apple.com:123...
57+
Current time: Fri Apr 4 00:45:38 JST 2025
58+
59+
# Call NTP server with fallback
60+
$ ./ntp-cli -h time.apple.com -p 234 --fallback time.apple.com:123,time.apple.com:245
61+
Calling NTP server at time.apple.com:234...
62+
Reading the response failed: read udp 192.168.2.107:62348->17.253.68.253:234: i/o timeout
63+
Calling NTP server at time.apple.com:123...
64+
Current time: Fri Apr 4 00:33:26 JST 2025
4665
```
4766

4867
## Flags
@@ -84,3 +103,10 @@ invalid arguments: you can either specify an address or a hostname, but not both
84103
| Seconds1970 | 1743518576 |
85104

86105
</details>
106+
107+
- `-q`, `--quiet`:
108+
Suppress output. Only the time and errors will be printed.
109+
- `--fallback <string>`:
110+
Fallback server addresses to call if the primary server fails. Separated by commas.
111+
- `--parallel <string>`:
112+
Server addresses to call in parallel. Separated by commas.

format.go

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"time"
6+
)
7+
8+
func FormatTime(currentTime *time.Time, layout string) string {
9+
var timeString string
10+
11+
switch layout {
12+
case "Seconds1970":
13+
timeString = fmt.Sprintf("%d", currentTime.Unix())
14+
case "Seconds1900":
15+
timeString = fmt.Sprintf("%d", currentTime.Unix()+2208988800)
16+
default:
17+
timeString = currentTime.Format(layout)
18+
}
19+
20+
return timeString
21+
}

main.go

+63-76
Original file line numberDiff line numberDiff line change
@@ -2,71 +2,16 @@ package main
22

33
import (
44
"context"
5-
"encoding/binary"
65
"errors"
76
"fmt"
87
"github.com/urfave/cli/v3"
98
"log"
10-
"net"
119
"os"
10+
"strings"
11+
"sync"
1212
"time"
1313
)
1414

15-
// Calls an NTP server according to [RFC 5905](https://datatracker.ietf.org/doc/html/rfc5905), and returns the current time.
16-
func callNTPAddress(address string, quiet bool) *time.Time {
17-
if !quiet {
18-
fmt.Printf("Calling NTP server at %s...\n", address)
19-
}
20-
21-
packet := make([]byte, 48)
22-
23-
// leap 0, version 4, mode 3 (client)
24-
// 0 4 3 -> 00 100 011 -> 0x23
25-
packet[0] = 0x23
26-
27-
connection, err := net.Dial("udp", address)
28-
29-
if err != nil {
30-
fmt.Println("An error occurred: ", err)
31-
return nil
32-
}
33-
34-
defer connection.Close()
35-
36-
// Set a deadline for the connection
37-
if deadlineExceededError := connection.SetDeadline(time.Now().Add(3 * time.Second)); deadlineExceededError != nil {
38-
fmt.Println("Deadline exceeded: ", deadlineExceededError)
39-
return nil
40-
}
41-
42-
// Send the packet to the server
43-
if _, writeError := connection.Write(packet); writeError != nil {
44-
fmt.Println("Writing the packet failed: ", writeError)
45-
return nil
46-
}
47-
48-
responsePacket := make([]byte, 48)
49-
50-
// Read the response from the server
51-
if _, readError := connection.Read(responsePacket); readError != nil {
52-
fmt.Println("Reading the response failed: ", readError)
53-
return nil
54-
}
55-
56-
ntpSeconds := binary.BigEndian.Uint32(responsePacket[40:44])
57-
58-
// RFC 868: https://datatracker.ietf.org/doc/rfc868/
59-
unixSeconds := int64(ntpSeconds) - 2208988800
60-
61-
unixTime := time.Unix(unixSeconds, 0)
62-
63-
return &unixTime
64-
}
65-
66-
func callNTP(hostname string, port int, quiet bool) *time.Time {
67-
return callNTPAddress(fmt.Sprintf("%s:%d", hostname, port), quiet)
68-
}
69-
7015
func main() {
7116
timeFormats := map[string]string{
7217
"Layout": time.Layout,
@@ -105,6 +50,9 @@ func main() {
10550

10651
layout := time.UnixDate
10752

53+
var parallels []string
54+
var fallbacks []string
55+
10856
command := &cli.Command{
10957
Name: "ntp",
11058
Usage: "Get the current time from an NTP server",
@@ -161,42 +109,81 @@ func main() {
161109
layout = dateLayout
162110
}
163111

112+
return nil
113+
},
114+
},
115+
&cli.StringFlag{
116+
Name: "parallel",
117+
Usage: "Servers to call in parallel, alongside the main server. Separated by commas.",
118+
Action: func(_ context.Context, _ *cli.Command, serversString string) error {
119+
parallels = strings.Split(serversString, ",")
120+
121+
return nil
122+
},
123+
},
124+
&cli.StringFlag{
125+
Name: "fallback",
126+
Usage: "Fallback servers to call if the main server fails. Separated by commas.",
127+
Action: func(_ context.Context, _ *cli.Command, serversString string) error {
128+
fallbacks = strings.Split(serversString, ",")
129+
164130
return nil
165131
},
166132
},
167133
},
168134

169135
Action: func(context context.Context, command *cli.Command) error {
170-
var currentTime *time.Time
136+
var group sync.WaitGroup
137+
group.Add(1)
138+
139+
channel := make(chan *time.Time, 1)
171140

172141
switch {
173-
case !addressPresent && hostnamePresent: // hostname is present, address is not (use default port)
174-
currentTime = callNTP(hostname, int(port), quiet)
142+
case !addressPresent && (hostnamePresent || (!hostnamePresent && !portPresent)):
143+
go CallNTPAsync(fmt.Sprintf("%s:%d", hostname, port), quiet, channel, &group)
175144
case addressPresent && !hostnamePresent && !portPresent: // address is present, hostname and port are not
176-
currentTime = callNTPAddress(address, quiet)
177-
case !addressPresent && !hostnamePresent && !portPresent: // neither address nor hostname nor port are present (use defaults)
178-
currentTime = callNTP(hostname, int(port), quiet)
145+
go CallNTPAsync(address, quiet, channel, &group)
179146
default:
180147
return errors.New("invalid arguments: you can either specify an address or a hostname, but not both")
181148
}
182149

183-
if currentTime != nil {
184-
var timeString string
185-
186-
switch layout {
187-
case "Seconds1970":
188-
timeString = fmt.Sprintf("%d", currentTime.Unix())
189-
case "Seconds1900":
190-
timeString = fmt.Sprintf("%d", currentTime.Unix()+2208988800)
191-
default:
192-
timeString = currentTime.Format(layout)
150+
if parallelsLen, fallbacksLen := len(parallels), len(fallbacks); parallelsLen > 0 && fallbacksLen > 0 {
151+
return errors.New("you can either specify parallel servers or fallback servers, but not both")
152+
} else if parallelsLen > 0 {
153+
for _, address := range parallels {
154+
go CallNTPAsync(address, quiet, channel, &group)
193155
}
156+
} else if fallbacksLen > 0 {
157+
group.Wait() // wait for the primary server to respond
158+
159+
for _, address := range fallbacks {
160+
select {
161+
case <-channel: // if the last server has already responded
162+
break
163+
default:
164+
}
165+
166+
if result := CallNTP(address, quiet); result != nil {
167+
channel <- result
168+
}
169+
}
170+
}
171+
172+
group.Wait()
173+
174+
close(channel)
175+
176+
currentTime := <-channel
177+
178+
if currentTime != nil {
179+
timeString := FormatTime(currentTime, layout)
194180

195181
if !quiet {
196-
fmt.Printf("Current time: %s\n", timeString)
197-
} else {
198-
fmt.Printf("%s\n", timeString)
182+
fmt.Printf("Current time: ")
199183
}
184+
185+
fmt.Printf("%s\n", timeString)
186+
200187
} else {
201188
return errors.New("failed to get the current time")
202189
}

ntp.go

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package main
2+
3+
import (
4+
"encoding/binary"
5+
"fmt"
6+
"net"
7+
"sync"
8+
"time"
9+
)
10+
11+
func CallNTPAsync(address string, quiet bool, channel chan *time.Time, group *sync.WaitGroup) {
12+
currentTime := CallNTP(address, quiet)
13+
14+
if currentTime != nil {
15+
channel <- currentTime
16+
}
17+
18+
group.Done()
19+
}
20+
21+
// CallNTP Calls an NTP server according to [RFC 5905](https://datatracker.ietf.org/doc/html/rfc5905), and returns the current time.
22+
// Uses the NTP v4
23+
func CallNTP(address string, quiet bool) *time.Time {
24+
if !quiet {
25+
fmt.Printf("Calling NTP server at %s...\n", address)
26+
}
27+
28+
packet := make([]byte, 48)
29+
30+
// leap 0, version 4, mode 3 (client)
31+
// 0 4 3 -> 00 100 011 -> 0x23
32+
packet[0] = 0x23
33+
34+
connection, err := net.Dial("udp", address)
35+
36+
if err != nil {
37+
fmt.Println("An error occurred: ", err)
38+
return nil
39+
}
40+
41+
defer connection.Close()
42+
43+
// Set a deadline for the connection
44+
if deadlineExceededError := connection.SetDeadline(time.Now().Add(3 * time.Second)); deadlineExceededError != nil {
45+
fmt.Println("Deadline exceeded: ", deadlineExceededError)
46+
return nil
47+
}
48+
49+
// Send the packet to the server
50+
if _, writeError := connection.Write(packet); writeError != nil {
51+
fmt.Println("Writing the packet failed: ", writeError)
52+
return nil
53+
}
54+
55+
responsePacket := make([]byte, 48)
56+
57+
// Read the response from the server
58+
if _, readError := connection.Read(responsePacket); readError != nil {
59+
fmt.Println("Reading the response failed: ", readError)
60+
return nil
61+
}
62+
63+
ntpSeconds := binary.BigEndian.Uint32(responsePacket[40:44])
64+
65+
// RFC 868: https://datatracker.ietf.org/doc/rfc868/
66+
unixSeconds := int64(ntpSeconds) - 2208988800
67+
68+
unixTime := time.Unix(unixSeconds, 0)
69+
70+
return &unixTime
71+
}

0 commit comments

Comments
 (0)