diff --git a/.gitignore b/.gitignore index 8da22dc..09d3da0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ *.out # build directory -tracker/build +**/build # Dependency directories (remove the comment below to include it) # vendor/ diff --git a/Dockerfile b/Dockerfile index 0e1db1b..aa44457 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ FROM --platform=$TARGETPLATFORM alpine:latest WORKDIR /root COPY --from=builder go/src/git.faercol.me/public-ip-tracker/build/tracker ./ -VOLUME [ "/config" ] +VOLUME [ "/config", "/output" ] ENTRYPOINT [ "./tracker" ] CMD [ "-config", "/config/config.json" ] diff --git a/README.md b/README.md index d14e9f3..4840a89 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ For now, the program is configured through a JSON configuration file. Here is a "polling_frequency": 5, "log": { "level": "info" + }, + "export": { + "mode": "native } } ``` diff --git a/examples/docker-compose-unix.yml b/examples/docker-compose-unix.yml new file mode 100644 index 0000000..73a47e8 --- /dev/null +++ b/examples/docker-compose-unix.yml @@ -0,0 +1,17 @@ +services: + telegram_exporter: + container_name: telegram_exporter + image: git.faercol.me/notification/telegram-notifier:latest + environment: + - SOCK_PATH=/input/telegram.sock + volumes: + - './build/config/exporter:/config' + - './build/run:/input' + + ip_tracker: + container_name: ip_tracker + image: git.faercol.me/faercol/public-ip-tracker:latest + # Volumes store your data between container upgrades + volumes: + - './build/config/tracker:/config' + - './build/run:/output' diff --git a/tracker/bot/bot.go b/tracker/bot/bot.go index 0a4827c..6d0cf7d 100644 --- a/tracker/bot/bot.go +++ b/tracker/bot/bot.go @@ -10,6 +10,8 @@ import ( "git.faercol.me/faercol/public-ip-tracker/tracker/config" "git.faercol.me/faercol/public-ip-tracker/tracker/ip" "git.faercol.me/faercol/public-ip-tracker/tracker/logger" + "git.faercol.me/faercol/public-ip-tracker/tracker/messager" + "git.faercol.me/faercol/public-ip-tracker/tracker/unixsender" "github.com/ahugues/go-telegram-api/bot" "github.com/ahugues/go-telegram-api/notifier" "github.com/ahugues/go-telegram-api/structs" @@ -31,20 +33,32 @@ Public IP has changed, new IP is %s`, formattedHostname, currentTime.Format(time } type Notifier struct { - ctx context.Context - cancel context.CancelFunc - tgBot bot.Bot - tgChatID int64 - tgWatcher notifier.EventNotifier - ipGetter ip.IPGetter - timeGetter func() time.Time - currentIP net.IP - frequency time.Duration - errChan chan error - changesChan chan net.IP - exitChan chan struct{} - logger logrus.Logger - hostname string + ctx context.Context + cancel context.CancelFunc + tgBot bot.Bot + tgChatID int64 + tgWatcher notifier.EventNotifier + ipGetter ip.IPGetter + timeGetter func() time.Time + currentIP net.IP + frequency time.Duration + errChan chan error + exitChan chan struct{} + logger logrus.Logger + hostname string + sendMode config.ExportMode + sender messager.Sender +} + +func (n *Notifier) sendMessage(msg string) error { + switch n.sendMode { + case config.ExportNative: + return n.tgBot.SendMessage(n.ctx, n.tgChatID, msg, structs.FormattingMarkdownV2) + case config.ExportUnix: + return n.sender.SendMessage(msg) + default: + return fmt.Errorf("invalid sending mode %v", n.sendMode) + } } func (n *Notifier) SendInitMessage() error { @@ -59,7 +73,8 @@ func (n *Notifier) SendInitMessage() error { currentTime := n.timeGetter() n.logger.Debug("Sending init message to Telegram") - if err := n.tgBot.SendMessage(n.ctx, n.tgChatID, formatInitMsg(currentTime, publicIP, n.hostname), structs.FormattingMarkdownV2); err != nil { + initMsg := formatInitMsg(currentTime, publicIP, n.hostname) + if err := n.sendMessage(initMsg); err != nil { return fmt.Errorf("failed to send initialization message: %w", err) } n.logger.Debug("Message sent") @@ -67,7 +82,8 @@ func (n *Notifier) SendInitMessage() error { } func (n *Notifier) sendUpdatedIPMsg() error { - if err := n.tgBot.SendMessage(n.ctx, n.tgChatID, formatUpdate(n.timeGetter(), n.currentIP, n.hostname), structs.FormattingMarkdownV2); err != nil { + updateMsg := formatUpdate(n.timeGetter(), n.currentIP, n.hostname) + if err := n.sendMessage(updateMsg); err != nil { return fmt.Errorf("failed to send update message: %w", err) } return nil @@ -75,7 +91,7 @@ func (n *Notifier) sendUpdatedIPMsg() error { func (n *Notifier) sendCurrentIP() error { statusMsg := fmt.Sprintf("Current public IP is %s", n.currentIP) - if err := n.tgBot.SendMessage(n.ctx, n.tgChatID, statusMsg, structs.FormattingMarkdownV2); err != nil { + if err := n.sendMessage(statusMsg); err != nil { return fmt.Errorf("failed to send message: %w", err) } return nil @@ -140,10 +156,26 @@ func (n *Notifier) Exit() <-chan struct{} { return n.exitChan } -func New(ctx context.Context, config *config.Config) *Notifier { +func buildSender(ctx context.Context, conf *config.Config) (messager.Sender, error) { + switch conf.Export.Mode { + case config.ExportUnix: + logger.L.Infof("Building notifier in Unix mode") + return unixsender.New(ctx, conf) + default: + return nil, nil + } +} + +func New(ctx context.Context, config *config.Config) (*Notifier, error) { subCtx, cancel := context.WithCancel(ctx) tgBot := bot.New(config.Telegram.Token) + sender, err := buildSender(subCtx, config) + if err != nil { + cancel() + return nil, fmt.Errorf("failed to build message sender: %w", err) + } + return &Notifier{ ctx: subCtx, cancel: cancel, @@ -157,5 +189,7 @@ func New(ctx context.Context, config *config.Config) *Notifier { tgWatcher: notifier.New(config.Telegram.Token), logger: logger.L, hostname: config.Hostname, - } + sendMode: config.Export.Mode, + sender: sender, + }, nil } diff --git a/tracker/config/config.go b/tracker/config/config.go index fbb1bc5..ed6a224 100644 --- a/tracker/config/config.go +++ b/tracker/config/config.go @@ -9,6 +9,24 @@ import ( "github.com/sirupsen/logrus" ) +type ExportMode int64 + +const ( + ExportNative = iota + ExportUnix +) + +func exportModeFromStr(val string) ExportMode { + switch val { + case "native": + return ExportNative + case "unix": + return ExportUnix + default: + return ExportNative + } +} + type TelegramConfig struct { ChannelID int64 `json:"channel_id"` Token string `json:"token"` @@ -22,18 +40,30 @@ type jsonLogConfig struct { Level string `json:"level"` } +type ExportConfig struct { + Mode ExportMode + UnixSock string +} + +type jsonExportConfig struct { + Mode string `json:"mode"` + UnixSock string `json:"sock_path"` +} + type Config struct { Telegram *TelegramConfig PollingFrequency time.Duration Hostname string - Log *LogConfig + Log LogConfig + Export ExportConfig } type jsonConfig struct { - Telegram *TelegramConfig `json:"telegram"` - PollingFrequency int64 `json:"polling_frequency"` - Hostname string `json:"hostname"` - Log *jsonLogConfig `json:"log"` + Telegram *TelegramConfig `json:"telegram"` + PollingFrequency int64 `json:"polling_frequency"` + Hostname string `json:"hostname"` + Log jsonLogConfig `json:"log"` + Export jsonExportConfig `json:"export"` } func parseLevel(lvlStr string) logrus.Level { @@ -54,12 +84,17 @@ func New(filepath string) (*Config, error) { if err := json.Unmarshal(content, &jsonConf); err != nil { return nil, fmt.Errorf("failed to parse config file: %w", err) } + return &Config{ Telegram: jsonConf.Telegram, PollingFrequency: time.Duration(jsonConf.PollingFrequency) * time.Second, Hostname: jsonConf.Hostname, - Log: &LogConfig{ + Log: LogConfig{ Level: parseLevel(jsonConf.Log.Level), }, + Export: ExportConfig{ + UnixSock: jsonConf.Export.UnixSock, + Mode: exportModeFromStr(jsonConf.Export.Mode), + }, }, nil } diff --git a/tracker/main.go b/tracker/main.go index 59f5643..62e9ee4 100644 --- a/tracker/main.go +++ b/tracker/main.go @@ -48,7 +48,10 @@ func main() { } logger.L.Debug("Initializing notification bot") - notifBot := bot.New(mainCtx, conf) + notifBot, err := bot.New(mainCtx, conf) + if err != nil { + logger.L.Fatalf("Failed to create notification bot: %s", err.Error()) + } logger.L.Debug("Sending initialization message to Telegram") if err := notifBot.SendInitMessage(); err != nil { diff --git a/tracker/messager/messager.go b/tracker/messager/messager.go new file mode 100644 index 0000000..f184e63 --- /dev/null +++ b/tracker/messager/messager.go @@ -0,0 +1,26 @@ +package messager + +import ( + "fmt" + "net" + "strings" + "time" +) + +type Sender interface { + SendMessage(message string) error +} + +func FormatInitMsg(currentTime time.Time, publicIP net.IP, hostname string) string { + formattedHostname := fmt.Sprintf("`%s`", hostname) + formattedIP := strings.ReplaceAll(publicIP.String(), ".", "\\.") + return fmt.Sprintf(`\[Host %s\] %s +Public IP tracker initialized\. Current IP is %s`, formattedHostname, currentTime.Format(time.RFC1123), formattedIP) +} + +func FormatUpdate(currentTime time.Time, publicIP net.IP, hostname string) string { + formattedHostname := fmt.Sprintf("`%s`", hostname) + formattedIP := strings.ReplaceAll(publicIP.String(), ".", "\\.") + return fmt.Sprintf(`\[Host %s\] %s +Public IP has changed, new IP is %s`, formattedHostname, currentTime.Format(time.RFC1123), formattedIP) +} diff --git a/tracker/unixsender/unixsender.go b/tracker/unixsender/unixsender.go new file mode 100644 index 0000000..109ef4d --- /dev/null +++ b/tracker/unixsender/unixsender.go @@ -0,0 +1,66 @@ +package unixsender + +import ( + "bytes" + "context" + "fmt" + "net" + "net/http" + + "git.faercol.me/faercol/public-ip-tracker/tracker/config" + "git.faercol.me/faercol/public-ip-tracker/tracker/logger" + "github.com/sirupsen/logrus" +) + +const method = http.MethodPost +const baseURL = "http://unix/" + +type UnixSender struct { + logger *logrus.Logger + ctx context.Context + cancel context.CancelFunc + httpClt *http.Client +} + +func (s *UnixSender) SendMessage(message string) error { + s.logger.Debug("Sending message to unix sock") + + body := bytes.NewBufferString(message) + + req, err := http.NewRequestWithContext(s.ctx, method, baseURL, body) + if err != nil { + return fmt.Errorf("failed to prepare request: %w", err) + } + + resp, err := s.httpClt.Do(req) + if err != nil { + return fmt.Errorf("failed to run query: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("invalid returncode %d", resp.StatusCode) + } + + return nil +} + +func New(ctx context.Context, config *config.Config) (*UnixSender, error) { + subCtx, cancel := context.WithCancel(ctx) + + logger.L.Infof("Creating Unix exporter to sock %q", config.Export.UnixSock) + + httpClt := http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + dialer := net.Dialer{} + return dialer.DialContext(ctx, "unix", config.Export.UnixSock) + }, + }, + } + + return &UnixSender{ + ctx: subCtx, + cancel: cancel, + httpClt: &httpClt, + logger: &logger.L, + }, nil +}