From 3a1bb20a1fda6d5426392f92431a3905d027228a Mon Sep 17 00:00:00 2001 From: Melora Hugues Date: Sat, 28 Jan 2023 14:29:39 +0100 Subject: [PATCH] Add monitoring of the current public IP 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. --- tracker/bot/bot.go | 76 +++++++++++++--- tracker/bot/bot_test.go | 191 +++++++++++++++++++++++++++++++++++++++ tracker/config/config.go | 18 +++- tracker/go.mod | 2 +- tracker/go.sum | 4 +- tracker/ip/ip.go | 12 ++- tracker/ip/ip_test.go | 10 +- tracker/ip/test/ip.go | 18 ++++ tracker/main.go | 29 +++++- 9 files changed, 331 insertions(+), 29 deletions(-) create mode 100644 tracker/bot/bot_test.go create mode 100644 tracker/ip/test/ip.go diff --git a/tracker/bot/bot.go b/tracker/bot/bot.go index e78f692..52aba5b 100644 --- a/tracker/bot/bot.go +++ b/tracker/bot/bot.go @@ -3,6 +3,7 @@ package bot import ( "context" "fmt" + "net" "time" "git.faercol.me/faercol/public-ip-tracker/tracker/config" @@ -11,11 +12,17 @@ import ( ) type Notifier struct { - ctx context.Context - cancel context.CancelFunc - tgBot *bot.ConcreteBot - notifChannel int64 - ipGetter *ip.IPGetter + ctx context.Context + cancel context.CancelFunc + 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,22 +31,67 @@ 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) return &Notifier{ - ctx: subCtx, - cancel: cancel, - tgBot: tgBot, - notifChannel: config.Telegram.ChannelID, - ipGetter: ip.New(), + ctx: subCtx, + cancel: cancel, + tgBot: tgBot, + tgChatID: config.Telegram.ChannelID, + timeGetter: time.Now, + ipGetter: ip.New(), + errChan: make(chan error, 10), + exitChan: make(chan struct{}, 1), + frequency: config.PollingFrequency, } } diff --git a/tracker/bot/bot_test.go b/tracker/bot/bot_test.go new file mode 100644 index 0000000..d20ecbf --- /dev/null +++ b/tracker/bot/bot_test.go @@ -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 + } +} diff --git a/tracker/config/config.go b/tracker/config/config.go index 268231e..1e38ca1 100644 --- a/tracker/config/config.go +++ b/tracker/config/config.go @@ -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 `json:"telegram"` + 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 } diff --git a/tracker/go.mod b/tracker/go.mod index 5729e45..ccc8544 100644 --- a/tracker/go.mod +++ b/tracker/go.mod @@ -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 diff --git a/tracker/go.sum b/tracker/go.sum index 32cbebb..73c51b5 100644 --- a/tracker/go.sum +++ b/tracker/go.sum @@ -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= diff --git a/tracker/ip/ip.go b/tracker/ip/ip.go index 597f3b1..08c3905 100644 --- a/tracker/ip/ip.go +++ b/tracker/ip/ip.go @@ -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, diff --git a/tracker/ip/ip_test.go b/tracker/ip/ip_test.go index a5d1dcf..3d53760 100644 --- a/tracker/ip/ip_test.go +++ b/tracker/ip/ip_test.go @@ -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, diff --git a/tracker/ip/test/ip.go b/tracker/ip/test/ip.go new file mode 100644 index 0000000..f22b3a0 --- /dev/null +++ b/tracker/ip/test/ip.go @@ -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) +} diff --git a/tracker/main.go b/tracker/main.go index 6e88d69..d5db008 100644 --- a/tracker/main.go +++ b/tracker/main.go @@ -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) }