Add monitoring of the current public IP
All checks were successful
continuous-integration/drone/push Build is passing

Ref #4

This commit adds an active monitoring of the current public IP. If a
change is detected, then a message is sent to the given Telegram
channel. Add a few tests for the main monitoring logic.
This commit is contained in:
Melora Hugues 2023-01-28 14:29:39 +01:00
parent a4ca608937
commit 3a1bb20a1f
9 changed files with 331 additions and 29 deletions

View file

@ -3,6 +3,7 @@ package bot
import (
"context"
"fmt"
"net"
"time"
"git.faercol.me/faercol/public-ip-tracker/tracker/config"
@ -13,9 +14,15 @@ import (
type Notifier struct {
ctx context.Context
cancel context.CancelFunc
tgBot *bot.ConcreteBot
notifChannel int64
ipGetter *ip.IPGetter
tgBot bot.Bot
tgChatID int64
ipGetter ip.IPGetter
timeGetter func() time.Time
currentIP net.IP
frequency time.Duration
errChan chan error
changesChan chan net.IP
exitChan chan struct{}
}
func (n *Notifier) SendInitMessage() error {
@ -24,13 +31,54 @@ func (n *Notifier) SendInitMessage() error {
return fmt.Errorf("failed to get current public IP: %w", err)
}
initMsg := fmt.Sprintf("Public IP tracker initialized at %v, public IP is %s", time.Now(), publicIP)
if err := n.tgBot.SendMessage(n.ctx, n.notifChannel, initMsg); err != nil {
n.currentIP = publicIP
currentTime := n.timeGetter()
initMsg := fmt.Sprintf("Public IP tracker initialized at %v, public IP is %s", currentTime, publicIP)
if err := n.tgBot.SendMessage(n.ctx, n.tgChatID, initMsg); err != nil {
return fmt.Errorf("failed to send initialization message: %w", err)
}
return nil
}
func (n *Notifier) sendUpdatedIPMsg() error {
updateMsg := fmt.Sprintf("Public IP has been changed, is now %s", n.currentIP)
if err := n.tgBot.SendMessage(n.ctx, n.tgChatID, updateMsg); err != nil {
return fmt.Errorf("failed to send update message: %w", err)
}
return nil
}
func (n *Notifier) Run() {
for {
select {
case <-time.After(n.frequency):
newIP, err := n.ipGetter.GetCurrentPublicIP(n.ctx)
if err != nil {
n.errChan <- fmt.Errorf("failed to update public IP: %w", err)
continue
}
if !newIP.Equal(n.currentIP) {
n.currentIP = newIP
if err := n.sendUpdatedIPMsg(); err != nil {
n.errChan <- err
}
}
case <-n.ctx.Done():
n.exitChan <- struct{}{}
return
}
}
}
func (n *Notifier) ErrChan() <-chan error {
return n.errChan
}
func (n *Notifier) Exit() <-chan struct{} {
return n.exitChan
}
func New(ctx context.Context, config *config.Config) *Notifier {
subCtx, cancel := context.WithCancel(ctx)
tgBot := bot.New(config.Telegram.Token)
@ -39,7 +87,11 @@ func New(ctx context.Context, config *config.Config) *Notifier {
ctx: subCtx,
cancel: cancel,
tgBot: tgBot,
notifChannel: config.Telegram.ChannelID,
tgChatID: config.Telegram.ChannelID,
timeGetter: time.Now,
ipGetter: ip.New(),
errChan: make(chan error, 10),
exitChan: make(chan struct{}, 1),
frequency: config.PollingFrequency,
}
}

191
tracker/bot/bot_test.go Normal file
View file

@ -0,0 +1,191 @@
package bot
import (
"context"
"errors"
"net"
"testing"
"time"
iptest "git.faercol.me/faercol/public-ip-tracker/tracker/ip/test"
"github.com/ahugues/go-telegram-api/structs"
)
const expectedChatID = 42
// Need to mock the telegram bot, because no mock is provided by my own lib, what a shame.
type mockTGBot struct {
SendMessageProp error
SendMessageFunc func(context.Context, int64, string) error
}
// Do nothing here, it's not used by this bot
func (mb *mockTGBot) GetMe(ctx context.Context) (structs.User, error) {
return structs.User{}, nil
}
func (mb *mockTGBot) SendMessage(ctx context.Context, chatID int64, content string) error {
if mb.SendMessageFunc == nil {
return mb.SendMessageProp
}
return mb.SendMessageFunc(ctx, chatID, content)
}
func TestInit(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
called := false
tgBot := mockTGBot{
SendMessageFunc: func(ctx context.Context, chatID int64, content string) error {
if chatID != expectedChatID {
t.Errorf("Unexpected chatID %d", chatID)
}
if content != "Public IP tracker initialized at 2023-01-28 14:17:12 +0000 UTC, public IP is 198.51.100.42" {
t.Errorf("Unexpected message %s", content)
}
called = true
return nil
},
}
ipGetter := iptest.TestIPGetter{
PublicIPProp: net.ParseIP("198.51.100.42"),
}
bot := Notifier{
ctx: ctx,
cancel: cancel,
tgBot: &tgBot,
tgChatID: expectedChatID,
timeGetter: func() time.Time { return time.Date(2023, 1, 28, 14, 17, 12, 0, time.UTC) },
ipGetter: &ipGetter,
frequency: 1 * time.Minute,
}
if err := bot.SendInitMessage(); err != nil {
t.Fatalf("Unexpected error %s", err.Error())
}
if !called {
t.Error("Telegram bot not called")
}
}
func TestUpdateIP(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
called := false
tgBot := mockTGBot{
SendMessageFunc: func(ctx context.Context, chatID int64, content string) error {
if chatID != expectedChatID {
t.Errorf("Unexpected chatID %d", chatID)
}
if content != "Public IP has been changed, is now 198.51.100.42" {
t.Errorf("Unexpected message %s", content)
}
called = true
return nil
},
}
ipGetter := iptest.TestIPGetter{
PublicIPProp: net.ParseIP("198.51.100.42"),
}
bot := Notifier{
ctx: ctx,
cancel: cancel,
tgBot: &tgBot,
tgChatID: expectedChatID,
timeGetter: func() time.Time { return time.Date(2023, 1, 28, 14, 17, 12, 0, time.UTC) },
currentIP: net.ParseIP("198.51.100.12"),
exitChan: make(chan struct{}, 1),
errChan: make(chan error, 5),
ipGetter: &ipGetter,
frequency: 500 * time.Millisecond,
}
go bot.Run()
select {
case <-bot.Exit():
t.Error("Unexpected exit")
case <-time.After(1 * time.Second):
break
}
cancel()
select {
case <-bot.Exit():
break
case <-time.After(2 * time.Second):
t.Error("Unexpected timeout")
}
select {
case err := <-bot.ErrChan():
t.Errorf("Unexpected error %s", err.Error())
default:
break
}
if !called {
t.Error("Telegram bot not called")
}
}
func TestUpdateIPNoChange(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
tgBot := mockTGBot{
SendMessageProp: errors.New("should not be called"),
}
ipGetter := iptest.TestIPGetter{
PublicIPProp: net.ParseIP("198.51.100.42"),
}
bot := Notifier{
ctx: ctx,
cancel: cancel,
tgBot: &tgBot,
tgChatID: expectedChatID,
timeGetter: func() time.Time { return time.Date(2023, 1, 28, 14, 17, 12, 0, time.UTC) },
currentIP: net.ParseIP("198.51.100.42"),
exitChan: make(chan struct{}, 1),
errChan: make(chan error, 5),
ipGetter: &ipGetter,
frequency: 100 * time.Millisecond,
}
go bot.Run()
select {
case <-bot.Exit():
t.Error("Unexpected exit")
case <-time.After(500 * time.Millisecond):
break
}
cancel()
select {
case <-bot.Exit():
break
case <-time.After(2 * time.Second):
t.Error("Unexpected timeout")
}
select {
case err := <-bot.ErrChan():
t.Errorf("Unexpected error %s", err.Error())
default:
break
}
}

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"time"
)
type TelegramConfig struct {
@ -12,7 +13,13 @@ type TelegramConfig struct {
}
type Config struct {
Telegram *TelegramConfig
PollingFrequency time.Duration
}
type jsonConfig struct {
Telegram *TelegramConfig `json:"telegram"`
PollingFrequency int64 `json:"polling_frequency"`
}
func New(filepath string) (*Config, error) {
@ -20,9 +27,12 @@ func New(filepath string) (*Config, error) {
if err != nil {
return nil, fmt.Errorf("failed to read config file %q: %w", filepath, err)
}
var conf Config
if err := json.Unmarshal(content, &conf); err != nil {
var jsonConf jsonConfig
if err := json.Unmarshal(content, &jsonConf); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
return &conf, nil
return &Config{
Telegram: jsonConf.Telegram,
PollingFrequency: time.Duration(jsonConf.PollingFrequency) * time.Second,
}, nil
}

View file

@ -2,4 +2,4 @@ module git.faercol.me/faercol/public-ip-tracker/tracker
go 1.16
require github.com/ahugues/go-telegram-api v0.0.0-20230125191847-f1f02f942580
require github.com/ahugues/go-telegram-api v0.0.0-20230128131122-4d5782beddd0

View file

@ -1,5 +1,5 @@
github.com/ahugues/go-telegram-api v0.0.0-20230125191847-f1f02f942580 h1:0EzaHeqTet8yPON8P6kHfHVl9Zb8+eWON0wPco93N7Y=
github.com/ahugues/go-telegram-api v0.0.0-20230125191847-f1f02f942580/go.mod h1:8I/JWxd9GYM7dHOgGmkRI3Ei1u+nGvzeR2knIMmFw7E=
github.com/ahugues/go-telegram-api v0.0.0-20230128131122-4d5782beddd0 h1:1R472WmBuKK9Bvt4+rlqj01z1MwlVic3Xzgjvql3PXA=
github.com/ahugues/go-telegram-api v0.0.0-20230128131122-4d5782beddd0/go.mod h1:8I/JWxd9GYM7dHOgGmkRI3Ei1u+nGvzeR2knIMmFw7E=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=

View file

@ -14,13 +14,17 @@ const ifconfigURL = "https://ifconfig.me"
const httpMaxRead = 100
const httpTimeout = 10 * time.Second
type IPGetter struct {
type IPGetter interface {
GetCurrentPublicIP(ctx context.Context) (net.IP, error)
}
type concreteIPGetter struct {
httpClt *http.Client
remoteAddress string
timeout time.Duration
}
func (c *IPGetter) GetCurrentPublicIP(ctx context.Context) (net.IP, error) {
func (c *concreteIPGetter) GetCurrentPublicIP(ctx context.Context) (net.IP, error) {
reqCtx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
@ -52,8 +56,8 @@ func (c *IPGetter) GetCurrentPublicIP(ctx context.Context) (net.IP, error) {
return res, nil
}
func New() *IPGetter {
return &IPGetter{
func New() IPGetter {
return &concreteIPGetter{
httpClt: http.DefaultClient,
remoteAddress: ifconfigURL,
timeout: httpTimeout,

View file

@ -18,7 +18,7 @@ func TestGetIPOK(t *testing.T) {
}))
defer mockSrv.Close()
clt := IPGetter{
clt := concreteIPGetter{
httpClt: mockSrv.Client(),
remoteAddress: mockSrv.URL,
timeout: 1 * time.Second,
@ -43,7 +43,7 @@ func TestGetIPServerErr(t *testing.T) {
}))
defer mockSrv.Close()
clt := IPGetter{
clt := concreteIPGetter{
httpClt: mockSrv.Client(),
remoteAddress: mockSrv.URL,
timeout: 1 * time.Second,
@ -66,7 +66,7 @@ func TestGetIPUnreachable(t *testing.T) {
}))
mockSrv.Close()
clt := IPGetter{
clt := concreteIPGetter{
httpClt: mockSrv.Client(),
remoteAddress: mockSrv.URL,
timeout: 1 * time.Second,
@ -91,7 +91,7 @@ func TestGetIPInvalidResponse(t *testing.T) {
}))
defer mockSrv.Close()
clt := IPGetter{
clt := concreteIPGetter{
httpClt: mockSrv.Client(),
remoteAddress: mockSrv.URL,
timeout: 1 * time.Second,
@ -117,7 +117,7 @@ func TestGetIPTimeout(t *testing.T) {
}))
defer mockSrv.Close()
clt := IPGetter{
clt := concreteIPGetter{
httpClt: mockSrv.Client(),
remoteAddress: mockSrv.URL,
timeout: 1 * time.Millisecond,

18
tracker/ip/test/ip.go Normal file
View file

@ -0,0 +1,18 @@
package test
import (
"context"
"net"
)
type TestIPGetter struct {
PublicIPProp net.IP
PublicIPFunc func(ctx context.Context) (net.IP, error)
}
func (g *TestIPGetter) GetCurrentPublicIP(ctx context.Context) (net.IP, error) {
if g.PublicIPFunc == nil {
return g.PublicIPProp, nil
}
return g.PublicIPFunc(ctx)
}

View file

@ -4,6 +4,8 @@ import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"git.faercol.me/faercol/public-ip-tracker/tracker/bot"
"git.faercol.me/faercol/public-ip-tracker/tracker/config"
@ -27,6 +29,8 @@ func main() {
fmt.Println("Parsing arguments")
args := parseArgs()
mainCtx, cancel := context.WithCancel(context.Background())
fmt.Println("Parsing config")
conf, err := config.New(args.configPath)
if err != nil {
@ -34,10 +38,33 @@ func main() {
}
fmt.Println("Initializing bot")
notifBot := bot.New(context.Background(), conf)
notifBot := bot.New(mainCtx, conf)
if err := notifBot.SendInitMessage(); err != nil {
panic(err)
}
fmt.Println("Starting monitoring")
go notifBot.Run()
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
outerloop:
for {
select {
case <-c:
fmt.Println("received cancel")
cancel()
break outerloop
case err := <-notifBot.ErrChan():
fmt.Printf("Unexpected error %s", err.Error())
case <-notifBot.Exit():
fmt.Println("Unexpected exit")
}
}
<-notifBot.Exit()
fmt.Println("OK")
os.Exit(0)
}