Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions containerfs/etc/passwd
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
appuser:x:1001:1001:App User:/:/sbin/nologin
271 changes: 271 additions & 0 deletions cycle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
package main

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/joeig/go-powerdns/v3"
)

func TestValidateWebhookURL_Empty(t *testing.T) {
if err := validateWebhookURL(""); err != nil {
t.Errorf("expected nil for empty URL, got %v", err)
}
}

func TestValidateWebhookURL_HTTP(t *testing.T) {
if err := validateWebhookURL("http://example.com/webhook"); err != nil {
t.Errorf("expected nil for http URL, got %v", err)
}
}

func TestValidateWebhookURL_HTTPS(t *testing.T) {
if err := validateWebhookURL("https://example.com/webhook"); err != nil {
t.Errorf("expected nil for https URL, got %v", err)
}
}

func TestValidateWebhookURL_BadScheme(t *testing.T) {
err := validateWebhookURL("ftp://example.com/webhook")
if err == nil {
t.Fatal("expected error for ftp scheme")
}
if !strings.Contains(err.Error(), "http or https") {
t.Errorf("expected scheme error, got %v", err)
}
}

func TestValidateWebhookURL_NoHost(t *testing.T) {
err := validateWebhookURL("https://")
if err == nil {
t.Fatal("expected error for missing host")
}
if !strings.Contains(err.Error(), "host") {
t.Errorf("expected host error, got %v", err)
}
}

func TestValidateWebhookURL_InvalidURL(t *testing.T) {
err := validateWebhookURL("://bad")
if err == nil {
t.Fatal("expected error for invalid URL")
}
}

func TestRunCycle_IPChanged_SendsNotification(t *testing.T) {
var ntfyBody string
ntfyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := make([]byte, r.ContentLength)
r.Body.Read(body)
ntfyBody = string(body)
w.WriteHeader(http.StatusOK)
}))
defer ntfyServer.Close()

mock := &MockRecordsClient{
GetResult: []powerdns.RRset{
{Records: []powerdns.Record{{Content: strPtr("1.2.3.4")}}},
},
}

updated, err := runCycle(
context.Background(),
"5.6.7.8",
rrsetSlice{{Name: "myhost.", Zone: "example.com."}},
mock,
"",
ntfyServer.URL+"/mytopic",
ntfyServer.Client(),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !updated {
t.Fatal("expected updated to be true")
}
if !mock.ChangeCalled {
t.Fatal("expected Change to be called since IP differs")
}
if !strings.Contains(ntfyBody, "5.6.7.8") {
t.Errorf("expected ntfy notification to contain IP, got %q", ntfyBody)
}
}

func TestRunCycle_IPUnchanged_NoNotification(t *testing.T) {
notificationSent := false
discordServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
notificationSent = true
w.WriteHeader(http.StatusNoContent)
}))
defer discordServer.Close()

mock := &MockRecordsClient{
GetResult: []powerdns.RRset{
{Records: []powerdns.Record{{Content: strPtr("1.2.3.4")}}},
},
}

updated, err := runCycle(
context.Background(),
"1.2.3.4",
rrsetSlice{{Name: "myhost.", Zone: "example.com."}},
mock,
discordServer.URL+"/webhook",
"",
discordServer.Client(),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated {
t.Fatal("expected updated to be false since IP unchanged")
}
if notificationSent {
t.Fatal("expected no notification when IP unchanged")
}
}

func TestRunCycle_UpdateError_SendsCriticalNotification(t *testing.T) {
var ntfyBody string
ntfyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := make([]byte, r.ContentLength)
r.Body.Read(body)
ntfyBody = string(body)
w.WriteHeader(http.StatusOK)
}))
defer ntfyServer.Close()

mock := &MockRecordsClient{
GetError: fmt.Errorf("API error"),
}

_, err := runCycle(
context.Background(),
"5.6.7.8",
rrsetSlice{{Name: "myhost.", Zone: "example.com."}},
mock,
"",
ntfyServer.URL+"/mytopic",
ntfyServer.Client(),
)
if err == nil {
t.Fatal("expected error when update fails")
}
if !strings.Contains(ntfyBody, "CRITICAL") {
t.Errorf("expected CRITICAL notification, got %q", ntfyBody)
}
}

func TestRunCycle_UpdateError_NoWebhooks(t *testing.T) {
mock := &MockRecordsClient{
GetError: fmt.Errorf("API error"),
}

_, err := runCycle(
context.Background(),
"5.6.7.8",
rrsetSlice{{Name: "myhost.", Zone: "example.com."}},
mock,
"",
"",
http.DefaultClient,
)
if err == nil {
t.Fatal("expected error when update fails")
}
}

func TestRunCycle_UpdateError_NotificationAlsoFails(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()

mock := &MockRecordsClient{
GetError: fmt.Errorf("API error"),
}

_, err := runCycle(
context.Background(),
"5.6.7.8",
rrsetSlice{{Name: "myhost.", Zone: "example.com."}},
mock,
"",
server.URL+"/mytopic",
server.Client(),
)
if err == nil {
t.Fatal("expected error when update fails")
}
}

func TestRunCycle_NoUpdatesNoErrors_NoNotification(t *testing.T) {
notificationSent := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
notificationSent = true
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

mock := &MockRecordsClient{
GetResult: []powerdns.RRset{
{Records: []powerdns.Record{{Content: strPtr("1.2.3.4")}}},
},
}

updated, err := runCycle(
context.Background(),
"1.2.3.4",
rrsetSlice{{Name: "myhost.", Zone: "example.com."}},
mock,
server.URL+"/discord",
server.URL+"/ntfy",
server.Client(),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated {
t.Fatal("expected updated to be false since IP unchanged")
}
if notificationSent {
t.Fatal("expected no notification when IP unchanged and no errors")
}
}

func TestRunCycle_NotificationError_DoesNotBlock(t *testing.T) {
badServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer badServer.Close()

badClient := badServer.Client()
closeServer := func() { badServer.Close() }
closeServer()

mock := &MockRecordsClient{
GetResult: []powerdns.RRset{
{Records: []powerdns.Record{{Content: strPtr("1.2.3.4")}}},
},
}

updated, err := runCycle(
context.Background(),
"5.6.7.8",
rrsetSlice{{Name: "myhost.", Zone: "example.com."}},
mock,
"http://127.0.0.1:1/webhook",
"",
badClient,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !updated {
t.Fatal("expected updated to be true even when notification fails")
}
}
10 changes: 10 additions & 0 deletions docker-compose.example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
services:
tracker:
image: "kr0nus/iptracker:latest"
command:
- "-b"
- "-i30s"
- '--pdns_url=http://192.168.0.10:8081'
- '--pdns_apikey=super_secret_p@ssword!'
- '-r test.kronus.dev,kronus.dev'
- '-r test2.kronus.dev,kronus.dev'
6 changes: 0 additions & 6 deletions docker-compose.integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,6 @@ services:
- "8081:8081"
- "1053:53"
- "1053:53/udp"
healthcheck:
test: ["CMD-SHELL", "curl -sf -H 'X-API-Key: testapikey' http://localhost:8081/api/v1/servers/localhost || exit 1"]
interval: 5s
timeout: 3s
retries: 30
start_period: 10s

ntfy:
image: binwiederhier/ntfy:latest
Expand Down
13 changes: 9 additions & 4 deletions dockerfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
FROM golang:1.26 AS build
WORKDIR /app
COPY . .
RUN go mod download && go build -o iptracker -v ./... && chmod +x iptracker
RUN go mod download && CGO_ENABLED=0 go build -a -ldflags "-extldflags '-static'" -o iptracker -v ./...

FROM debian:trixie AS src_os
RUN apt update && apt install -y ca-certificates

FROM scratch
USER bot
COPY --from=build /app/iptracker .
CMD ["iptracker"]
COPY containerfs/ .
COPY --from=build --chmod=555 /app/iptracker .
COPY --from=src_os /etc/ssl /etc/ssl
USER appuser
ENTRYPOINT [ "./iptracker" ]
58 changes: 58 additions & 0 deletions errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package main

import (
"errors"
"fmt"
"testing"
)

func TestIPCheckError_Error(t *testing.T) {
err := &IPCheckError{Err: fmt.Errorf("some failure")}
msg := err.Error()
expected := "ip check: some failure"
if msg != expected {
t.Errorf("expected %q, got %q", expected, msg)
}
}

func TestIPCheckError_Unwrap(t *testing.T) {
inner := fmt.Errorf("inner error")
err := &IPCheckError{Err: inner}
if !errors.Is(err, inner) {
t.Fatal("expected errors.Is to match inner error")
}
}

func TestRecordUpdateError_Error(t *testing.T) {
err := &RecordUpdateError{Record: "myhost", Zone: "example.com", Err: fmt.Errorf("fail")}
msg := err.Error()
expected := `update record "myhost" in zone "example.com": fail`
if msg != expected {
t.Errorf("expected %q, got %q", expected, msg)
}
}

func TestRecordUpdateError_Unwrap(t *testing.T) {
inner := fmt.Errorf("inner error")
err := &RecordUpdateError{Record: "myhost", Zone: "example.com", Err: inner}
if !errors.Is(err, inner) {
t.Fatal("expected errors.Is to match inner error")
}
}

func TestNotificationError_Error(t *testing.T) {
err := &NotificationError{Service: "discord", Err: fmt.Errorf("timeout")}
msg := err.Error()
expected := "discord notification: timeout"
if msg != expected {
t.Errorf("expected %q, got %q", expected, msg)
}
}

func TestNotificationError_Unwrap(t *testing.T) {
inner := fmt.Errorf("inner error")
err := &NotificationError{Service: "ntfy", Err: inner}
if !errors.Is(err, inner) {
t.Fatal("expected errors.Is to match inner error")
}
}
Loading