Add monitoring of the current public IP
All checks were successful
continuous-integration/drone/push Build is passing
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:
parent
a4ca608937
commit
3a1bb20a1f
9 changed files with 331 additions and 29 deletions
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
191
tracker/bot/bot_test.go
Normal file
191
tracker/bot/bot_test.go
Normal 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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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=
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
18
tracker/ip/test/ip.go
Normal 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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue