Skip to content

Commit eccf83a

Browse files
author
pufferfish
authored
Add health status endpoint (#107)
* implement metric endpoint * implement ICMP ping * fix linting * fix IPv6 pings * Add documentation for --info
1 parent 54cedea commit eccf83a

5 files changed

Lines changed: 276 additions & 14 deletions

File tree

README.md

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ of wireproxy by [@juev](https://github.com/juev).
3838

3939
```
4040
usage: wireproxy [-h|--help] [-c|--config "<value>"] [-s|--silent]
41-
[-d|--daemon] [-v|--version] [-n|--configtest]
41+
[-d|--daemon] [-i|--info "<value>"] [-v|--version]
42+
[-n|--configtest]
4243
4344
Userspace wireguard client for proxying
4445
@@ -48,9 +49,11 @@ Arguments:
4849
-c --config Path of configuration file
4950
-s --silent Silent mode
5051
-d --daemon Make wireproxy run in background
52+
-i --info Specify the address and port for exposing health status
5153
-v --version Print version
5254
-n --configtest Configtest mode. Only check the configuration file for
5355
validity.
56+
5457
```
5558

5659
# Build instruction
@@ -188,6 +191,64 @@ PublicKey = YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY=
188191
AllowedIPs = 10.254.254.100/32
189192
# Note there is no Endpoint defined here.
190193
```
194+
# Health endpoint
195+
Wireproxy supports exposing a health endpoint for monitoring purposes.
196+
The argument `--info/-i` specifies an address and port (e.g. `localhost:9080`), which exposes a HTTP server that provides health status metric of the server.
197+
198+
Currently two endpoints are implemented:
199+
200+
`/metrics`: Exposes information of the wireguard daemon, this provides the same information you would get with `wg show`. [This](https://www.wireguard.com/xplatform/#example-dialog) shows an example of what the response would look like.
201+
202+
`/readyz`: This responds with a json which shows the last time a pong is received from an IP specified with `CheckAlive`. When `CheckAlive` is set, a ping is sent out to addresses in `CheckAlive` per `CheckAliveInterval` seconds (defaults to 5) via wireguard. If a pong has not been received from one of the addresses within the last `CheckAliveInterval` seconds (+2 seconds for some leeway to account for latency), then it would respond with a 503, otherwise a 200.
203+
204+
For example:
205+
```
206+
[Interface]
207+
PrivateKey = censored
208+
Address = 10.2.0.2/32
209+
DNS = 10.2.0.1
210+
CheckAlive = 1.1.1.1, 3.3.3.3
211+
CheckAliveInterval = 3
212+
213+
[Peer]
214+
PublicKey = censored
215+
AllowedIPs = 0.0.0.0/0
216+
Endpoint = 149.34.244.174:51820
217+
218+
[Socks5]
219+
BindAddress = 127.0.0.1:25344
220+
```
221+
`/readyz` would respond with
222+
```
223+
< HTTP/1.1 503 Service Unavailable
224+
< Date: Thu, 11 Apr 2024 00:54:59 GMT
225+
< Content-Length: 35
226+
< Content-Type: text/plain; charset=utf-8
227+
<
228+
{"1.1.1.1":1712796899,"3.3.3.3":0}
229+
```
230+
231+
And for:
232+
```
233+
[Interface]
234+
PrivateKey = censored
235+
Address = 10.2.0.2/32
236+
DNS = 10.2.0.1
237+
CheckAlive = 1.1.1.1
238+
```
239+
`/readyz` would respond with
240+
```
241+
< HTTP/1.1 200 OK
242+
< Date: Thu, 11 Apr 2024 00:56:21 GMT
243+
< Content-Length: 23
244+
< Content-Type: text/plain; charset=utf-8
245+
<
246+
{"1.1.1.1":1712796979}
247+
```
248+
249+
If nothing is set for `CheckAlive`, an empty JSON object with 200 will be the response.
250+
251+
The peer which the ICMP ping packet is routed to depends on the `AllowedIPs` set for each peers.
191252

192253
# Stargazers over time
193254
[![Stargazers over time](https://starchart.cc/octeep/wireproxy.svg)](https://starchart.cc/octeep/wireproxy)

cmd/wireproxy/main.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"log"
7+
"net/http"
78
"os"
89
"os/exec"
910
"os/signal"
@@ -78,6 +79,7 @@ func main() {
7879
config := parser.String("c", "config", &argparse.Options{Help: "Path of configuration file"})
7980
silent := parser.Flag("s", "silent", &argparse.Options{Help: "Silent mode"})
8081
daemon := parser.Flag("d", "daemon", &argparse.Options{Help: "Make wireproxy run in background"})
82+
info := parser.String("i", "info", &argparse.Options{Help: "Specify the address and port for exposing health status"})
8183
printVerison := parser.Flag("v", "version", &argparse.Options{Help: "Print version"})
8284
configTest := parser.Flag("n", "configtest", &argparse.Options{Help: "Configtest mode. Only check the configuration file for validity."})
8385

@@ -140,13 +142,24 @@ func main() {
140142
// no file access is allowed from now on, only networking
141143
pledgeOrPanic("stdio inet dns")
142144

143-
tnet, err := wireproxy.StartWireguard(conf.Device, logLevel)
145+
tun, err := wireproxy.StartWireguard(conf.Device, logLevel)
144146
if err != nil {
145147
log.Fatal(err)
146148
}
147149

148150
for _, spawner := range conf.Routines {
149-
go spawner.SpawnRoutine(tnet)
151+
go spawner.SpawnRoutine(tun)
152+
}
153+
154+
tun.StartPingIPs()
155+
156+
if *info != "" {
157+
go func() {
158+
err := http.ListenAndServe(*info, tun)
159+
if err != nil {
160+
panic(err)
161+
}
162+
}()
150163
}
151164

152165
<-ctx.Done()

config.go

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@ type PeerConfig struct {
2222

2323
// DeviceConfig contains the information to initiate a wireguard connection
2424
type DeviceConfig struct {
25-
SecretKey string
26-
Endpoint []netip.Addr
27-
Peers []PeerConfig
28-
DNS []netip.Addr
29-
MTU int
30-
ListenPort *int
25+
SecretKey string
26+
Endpoint []netip.Addr
27+
Peers []PeerConfig
28+
DNS []netip.Addr
29+
MTU int
30+
ListenPort *int
31+
CheckAlive []netip.Addr
32+
CheckAliveInterval int
3133
}
3234

3335
type TCPClientTunnelConfig struct {
@@ -237,6 +239,25 @@ func ParseInterface(cfg *ini.File, device *DeviceConfig) error {
237239
device.ListenPort = &value
238240
}
239241

242+
checkAlive, err := parseNetIP(section, "CheckAlive")
243+
if err != nil {
244+
return err
245+
}
246+
device.CheckAlive = checkAlive
247+
248+
device.CheckAliveInterval = 5
249+
if sectionKey, err := section.GetKey("CheckAliveInterval"); err == nil {
250+
value, err := sectionKey.Int()
251+
if err != nil {
252+
return err
253+
}
254+
if len(checkAlive) == 0 {
255+
return errors.New("CheckAliveInterval is only valid when CheckAlive is set")
256+
}
257+
258+
device.CheckAliveInterval = value
259+
}
260+
240261
return nil
241262
}
242263

routine.go

Lines changed: 167 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
package wireproxy
22

33
import (
4+
"bytes"
45
"context"
6+
srand "crypto/rand"
57
"crypto/subtle"
8+
"encoding/binary"
9+
"encoding/json"
610
"errors"
11+
"golang.org/x/net/icmp"
12+
"golang.org/x/net/ipv4"
13+
"golang.org/x/net/ipv6"
14+
"golang.zx2c4.com/wireguard/device"
715
"io"
816
"log"
917
"math/rand"
1018
"net"
19+
"net/http"
1120
"os"
21+
"path"
1222
"strconv"
23+
"strings"
24+
"time"
1325

1426
"github.com/sourcegraph/conc"
1527
"github.com/things-go/go-socks5"
@@ -32,7 +44,11 @@ type CredentialValidator struct {
3244
// VirtualTun stores a reference to netstack network and DNS configuration
3345
type VirtualTun struct {
3446
Tnet *netstack.Net
47+
Dev *device.Device
3548
SystemDNS bool
49+
Conf *DeviceConfig
50+
// PingRecord stores the last time an IP was pinged
51+
PingRecord map[string]uint64
3652
}
3753

3854
// RoutineSpawner spawns a routine (e.g. socks5, tcp static routes) after the configuration is parsed
@@ -148,16 +164,16 @@ func (config *Socks5Config) SpawnRoutine(vt *VirtualTun) {
148164

149165
// SpawnRoutine spawns a http server.
150166
func (config *HTTPConfig) SpawnRoutine(vt *VirtualTun) {
151-
http := &HTTPServer{
167+
server := &HTTPServer{
152168
config: config,
153169
dial: vt.Tnet.Dial,
154170
auth: CredentialValidator{config.Username, config.Password},
155171
}
156172
if config.Username != "" || config.Password != "" {
157-
http.authRequired = true
173+
server.authRequired = true
158174
}
159175

160-
if err := http.ListenAndServe("tcp", config.BindAddress); err != nil {
176+
if err := server.ListenAndServe("tcp", config.BindAddress); err != nil {
161177
log.Fatal(err)
162178
}
163179
}
@@ -330,3 +346,151 @@ func (conf *TCPServerTunnelConfig) SpawnRoutine(vt *VirtualTun) {
330346
go tcpServerForward(vt, raddr, conn)
331347
}
332348
}
349+
350+
func (d VirtualTun) ServeHTTP(w http.ResponseWriter, r *http.Request) {
351+
log.Printf("Health metric request: %s\n", r.URL.Path)
352+
switch path.Clean(r.URL.Path) {
353+
case "/readyz":
354+
body, err := json.Marshal(d.PingRecord)
355+
if err != nil {
356+
errorLogger.Printf("Failed to get device metrics: %s\n", err.Error())
357+
w.WriteHeader(http.StatusInternalServerError)
358+
return
359+
}
360+
361+
status := http.StatusOK
362+
for _, record := range d.PingRecord {
363+
lastPong := time.Unix(int64(record), 0)
364+
// +2 seconds to account for the time it takes to ping the IP
365+
if time.Since(lastPong) > time.Duration(d.Conf.CheckAliveInterval+2)*time.Second {
366+
status = http.StatusServiceUnavailable
367+
break
368+
}
369+
}
370+
371+
w.WriteHeader(status)
372+
_, _ = w.Write(body)
373+
_, _ = w.Write([]byte("\n"))
374+
case "/metrics":
375+
get, err := d.Dev.IpcGet()
376+
if err != nil {
377+
errorLogger.Printf("Failed to get device metrics: %s\n", err.Error())
378+
w.WriteHeader(http.StatusInternalServerError)
379+
return
380+
}
381+
var buf bytes.Buffer
382+
for _, peer := range strings.Split(get, "\n") {
383+
pair := strings.SplitN(peer, "=", 2)
384+
if len(pair) != 2 {
385+
buf.WriteString(peer)
386+
continue
387+
}
388+
if pair[0] == "private_key" || pair[0] == "preshared_key" {
389+
pair[1] = "REDACTED"
390+
}
391+
buf.WriteString(pair[0])
392+
buf.WriteString("=")
393+
buf.WriteString(pair[1])
394+
buf.WriteString("\n")
395+
}
396+
397+
w.WriteHeader(http.StatusOK)
398+
_, _ = w.Write(buf.Bytes())
399+
default:
400+
w.WriteHeader(http.StatusNotFound)
401+
}
402+
}
403+
404+
func (d VirtualTun) pingIPs() {
405+
for _, addr := range d.Conf.CheckAlive {
406+
socket, err := d.Tnet.Dial("ping", addr.String())
407+
if err != nil {
408+
errorLogger.Printf("Failed to ping %s: %s\n", addr, err.Error())
409+
continue
410+
}
411+
412+
data := make([]byte, 16)
413+
_, _ = srand.Read(data)
414+
415+
requestPing := icmp.Echo{
416+
Seq: rand.Intn(1 << 16),
417+
Data: data,
418+
}
419+
420+
var icmpBytes []byte
421+
if addr.Is4() {
422+
icmpBytes, _ = (&icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &requestPing}).Marshal(nil)
423+
} else if addr.Is6() {
424+
icmpBytes, _ = (&icmp.Message{Type: ipv6.ICMPTypeEchoRequest, Code: 0, Body: &requestPing}).Marshal(nil)
425+
} else {
426+
errorLogger.Printf("Failed to ping %s: invalid address: %s\n", addr, addr.String())
427+
continue
428+
}
429+
430+
_ = socket.SetReadDeadline(time.Now().Add(time.Duration(d.Conf.CheckAliveInterval) * time.Second))
431+
_, err = socket.Write(icmpBytes)
432+
if err != nil {
433+
errorLogger.Printf("Failed to ping %s: %s\n", addr, err.Error())
434+
continue
435+
}
436+
437+
addr := addr
438+
go func() {
439+
n, err := socket.Read(icmpBytes[:])
440+
if err != nil {
441+
errorLogger.Printf("Failed to read ping response from %s: %s\n", addr, err.Error())
442+
return
443+
}
444+
445+
replyPacket, err := icmp.ParseMessage(1, icmpBytes[:n])
446+
if err != nil {
447+
errorLogger.Printf("Failed to parse ping response from %s: %s\n", addr, err.Error())
448+
return
449+
}
450+
451+
if addr.Is4() {
452+
replyPing, ok := replyPacket.Body.(*icmp.Echo)
453+
if !ok {
454+
errorLogger.Printf("Failed to parse ping response from %s: invalid reply type: %s\n", addr, replyPacket.Type)
455+
return
456+
}
457+
if !bytes.Equal(replyPing.Data, requestPing.Data) || replyPing.Seq != requestPing.Seq {
458+
errorLogger.Printf("Failed to parse ping response from %s: invalid ping reply: %v\n", addr, replyPing)
459+
return
460+
}
461+
}
462+
463+
if addr.Is6() {
464+
replyPing, ok := replyPacket.Body.(*icmp.RawBody)
465+
if !ok {
466+
errorLogger.Printf("Failed to parse ping response from %s: invalid reply type: %s\n", addr, replyPacket.Type)
467+
return
468+
}
469+
470+
seq := binary.BigEndian.Uint16(replyPing.Data[2:4])
471+
pongBody := replyPing.Data[4:]
472+
if !bytes.Equal(pongBody, requestPing.Data) || int(seq) != requestPing.Seq {
473+
errorLogger.Printf("Failed to parse ping response from %s: invalid ping reply: %v\n", addr, replyPing)
474+
return
475+
}
476+
}
477+
478+
d.PingRecord[addr.String()] = uint64(time.Now().Unix())
479+
480+
defer socket.Close()
481+
}()
482+
}
483+
}
484+
485+
func (d VirtualTun) StartPingIPs() {
486+
for _, addr := range d.Conf.CheckAlive {
487+
d.PingRecord[addr.String()] = 0
488+
}
489+
490+
go func() {
491+
for {
492+
d.pingIPs()
493+
time.Sleep(time.Duration(d.Conf.CheckAliveInterval) * time.Second)
494+
}
495+
}()
496+
}

0 commit comments

Comments
 (0)