From 43c6e9064921c0392972119abfb1a3857d34c3c1 Mon Sep 17 00:00:00 2001 From: skidoodle Date: Sat, 10 Jan 2026 20:07:35 +0100 Subject: [PATCH] v2 --- .github/workflows/docker-publish.yml | 4 +- .github/workflows/go.yml | 25 +-- .gitignore | 2 + Dockerfile | 3 +- compose.dev.yaml | 6 + docker-compose.yaml => compose.yaml | 7 +- genhistory.py | 60 ----- go.mod | 21 +- go.sum | 53 +++++ history.json | 0 main.go | 316 ++++++++++++--------------- readme.md | 47 +--- seed.go | 68 ++++++ store.go | 89 ++++++++ ui.templ | 118 ++++++++++ web/index.html | 31 --- web/script.js | 115 ---------- web/style.css | 221 ------------------- 18 files changed, 512 insertions(+), 674 deletions(-) create mode 100644 .gitignore create mode 100644 compose.dev.yaml rename docker-compose.yaml => compose.yaml (68%) delete mode 100644 genhistory.py create mode 100644 go.sum delete mode 100644 history.json create mode 100644 seed.go create mode 100644 store.go create mode 100644 ui.templ delete mode 100644 web/index.html delete mode 100644 web/script.js delete mode 100644 web/style.css diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f0a9d5c..041fd21 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Docker on: push: - branches: [ "main" ] + branches: ["main"] env: REGISTRY: ghcr.io @@ -24,7 +24,7 @@ jobs: if: github.event_name != 'pull_request' uses: sigstore/cosign-installer@v3.5.0 with: - cosign-release: 'v2.1.1' + cosign-release: "v2.1.1" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 5681490..b52bb94 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,28 +1,21 @@ -# This workflow will build a golang project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go - name: Go on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] jobs: - build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.22.3' + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.25.4" - - name: Build - run: go build -v ./... - - - name: Test - run: go test -v ./... + - name: Build + run: go build -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0225f55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +history* +*templ.go diff --git a/Dockerfile b/Dockerfile index 07444c1..b974fda 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,13 @@ FROM golang:alpine as builder WORKDIR /build +RUN go install github.com/a-h/templ/cmd/templ@latest COPY . . +RUN templ generate RUN go build -o iphistory . FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /app/ COPY --from=builder /build/iphistory . -COPY --from=builder /build/web ./web EXPOSE 8080 CMD ["./iphistory"] diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..4f9faad --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,6 @@ +services: + iphistory: + build: . + container_name: iphistory + ports: + - "8080:8080" diff --git a/docker-compose.yaml b/compose.yaml similarity index 68% rename from docker-compose.yaml rename to compose.yaml index acc1212..d59d1ea 100644 --- a/docker-compose.yaml +++ b/compose.yaml @@ -1,5 +1,3 @@ -version: '3.9' - services: iphistory: image: ghcr.io/skidoodle/iphistory:main @@ -8,8 +6,7 @@ services: ports: - "8080:8080" volumes: - - iphistory_data:/app + - data:/app volumes: - iphistory_data: - external: false + data: diff --git a/genhistory.py b/genhistory.py deleted file mode 100644 index 830c29b..0000000 --- a/genhistory.py +++ /dev/null @@ -1,60 +0,0 @@ -import random -import json -from datetime import datetime -import os -import sys - -IP_HISTORY_FILE = 'history.json' - -def generate_ipv4(): - return '.'.join(str(random.randint(0, 255)) for _ in range(4)) - -def generate_ipv6(): - return ':'.join('{:x}'.format(random.randint(0, 65535)) for _ in range(8)) - -def generate_timestamp(): - return datetime.now().strftime("%Y-%m-%d %H:%M:%S %Z") - -def generate_ip_record(): - return { - "timestamp": generate_timestamp(), - "ipv4": generate_ipv4(), - "ipv6": generate_ipv6() - } - -def load_ip_history(): - if os.path.exists(IP_HISTORY_FILE): - with open(IP_HISTORY_FILE, 'r') as file: - try: - return json.load(file) - except json.JSONDecodeError: - return [] - return [] - -def save_ip_history(ip_history): - with open(IP_HISTORY_FILE, 'w') as file: - json.dump(ip_history, file, indent=2) - -def generate_and_save_ip_records(num_records=1): - ip_history = load_ip_history() - new_records = [generate_ip_record() for _ in range(num_records)] - ip_history = new_records + ip_history # Append new records at the beginning - save_ip_history(ip_history) - -if __name__ == "__main__": - if len(sys.argv) != 2: - print("Usage: python genhistory.py ") - sys.exit(1) - - try: - num_records = int(sys.argv[1]) - if num_records <= 0: - raise ValueError("Number of records must be a positive integer.") - - generate_and_save_ip_records(num_records) - print(f"Successfully generated {num_records} IP records.") - - except ValueError as e: - print(f"Error: {e}") - print("Please provide a valid positive integer for the number of records.") - sys.exit(1) diff --git a/go.mod b/go.mod index 343a1ec..c1d917d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,22 @@ module iphistory -go 1.22.3 +go 1.25.4 + +require ( + github.com/a-h/templ v0.3.977 + golang.org/x/sync v0.19.0 + modernc.org/sqlite v1.43.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..66fe6fd --- /dev/null +++ b/go.sum @@ -0,0 +1,53 @@ +github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= +github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA= +modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/history.json b/history.json deleted file mode 100644 index e69de29..0000000 diff --git a/main.go b/main.go index 2c84664..0f683d7 100644 --- a/main.go +++ b/main.go @@ -1,195 +1,151 @@ package main import ( - "encoding/json" - "fmt" - "io" + "context" + "errors" + "log/slog" + "net" "net/http" "os" + "os/signal" + "strconv" "strings" + "syscall" "time" + + "golang.org/x/sync/errgroup" ) -const ( - ipv4URL = "https://4.ident.me/" - ipv6URL = "https://6.ident.me/" - ipHistoryPath = "history.json" - checkInterval = 30 * time.Second - maxRetries = 3 - retryDelay = 10 * time.Second -) - -type IPRecord struct { - Timestamp string `json:"timestamp"` - IPv4 string `json:"ipv4"` - IPv6 string `json:"ipv6"` -} - -func getPublicIPs() (string, string, error) { - var ipv4, ipv6 string - var err error - - for attempt := 1; attempt <= maxRetries; attempt++ { - ipv4, ipv6, err = fetchIPs() - if err == nil { - return ipv4, ipv6, nil - } - fmt.Printf("Attempt %d: Error fetching IPs: %v\n", attempt, err) - time.Sleep(retryDelay) - } - return "", "", err -} - -func fetchIPs() (string, string, error) { - ipv4, err := fetchIP(ipv4URL) - if err != nil { - return "", "", err - } - - ipv6, err := fetchIP(ipv6URL) - if err != nil { - fmt.Println("Warning: Could not fetch IPv6 address:", err) - } - - return ipv4, ipv6, nil -} - -func fetchIP(url string) (string, error) { - resp, err := http.Get(url) - if err != nil { - return "", err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - return strings.TrimSpace(string(body)), nil -} - -func readHistory() ([]IPRecord, error) { - file, err := os.Open(ipHistoryPath) - if os.IsNotExist(err) { - return []IPRecord{}, nil - } else if err != nil { - return nil, err - } - defer file.Close() - - fileInfo, err := file.Stat() - if err != nil { - return nil, err - } - - // Check if the file is empty - if fileInfo.Size() == 0 { - return []IPRecord{}, nil - } - - var history []IPRecord - err = json.NewDecoder(file).Decode(&history) - if err != nil { - return nil, err - } - return history, nil -} - -func writeHistory(history []IPRecord) error { - file, err := os.Create(ipHistoryPath) - if err != nil { - return err - } - defer file.Close() - - encoder := json.NewEncoder(file) - encoder.SetIndent("", " ") // Pretty-print with indentation - err = encoder.Encode(history) - if err != nil { - return err - } - return nil -} - -func trackIP(ipChan <-chan IPRecord) { - var lastLoggedIP IPRecord - hasNotifiedUpToDate := false - - for ipRecord := range ipChan { - history, err := readHistory() - if err != nil { - fmt.Println("Error reading IP history:", err) - continue - } - - if len(history) > 0 { - lastLoggedIP = history[0] - } - - // Check if IPs are the same as the last logged one - if ipRecord.IPv4 == lastLoggedIP.IPv4 && ipRecord.IPv6 == lastLoggedIP.IPv6 { - if !hasNotifiedUpToDate { - fmt.Println("🤷 IPs are already up to date") - hasNotifiedUpToDate = true - } - continue - } - - // Reset the notification flag when IP changes - hasNotifiedUpToDate = false - - ipRecord.Timestamp = time.Now().Format("2006-01-02 15:04:05") - history = append([]IPRecord{ipRecord}, history...) - if err := writeHistory(history); err != nil { - fmt.Println("Error writing IP history:", err) - continue - } - - fmt.Printf("📄 New IP logged: {Timestamp: %s, IPv4: %s, IPv6: %s}\n", ipRecord.Timestamp, ipRecord.IPv4, ipRecord.IPv6) - } -} - - -func serveWeb() { - http.Handle("/", http.StripPrefix("/", http.FileServer(http.Dir("web")))) - - http.HandleFunc("/history", func(w http.ResponseWriter, r *http.Request) { - history, err := readHistory() - if err != nil { - http.Error(w, "Error reading IP history", http.StatusInternalServerError) - return - } - - jsonData, err := json.Marshal(history) - if err != nil { - http.Error(w, "Error converting history to JSON", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.Write(jsonData) - }) - - fmt.Println("Starting server on :8080") - http.ListenAndServe(":8080", nil) -} +//go:generate templ generate func main() { - ipChan := make(chan IPRecord) + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - go func() { - for { - ipv4, ipv6, err := getPublicIPs() - if err != nil { - fmt.Println("Error fetching public IPs:", err) - } else { - ipChan <- IPRecord{IPv4: ipv4, IPv6: ipv6} - } - time.Sleep(checkInterval) + store, err := NewStore("history.db") + if err != nil { + logger.Error("db init failed", "err", err) + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + g, gCtx := errgroup.WithContext(ctx) + + g.Go(func() error { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + type provider struct { + server string + host string + isTXT bool } - }() - go trackIP(ipChan) - serveWeb() + providers := []provider{ + {server: "8.8.8.8:53", host: "o-o.myaddr.l.google.com", isTXT: true}, + {server: "208.67.222.222:53", host: "myip.opendns.com", isTXT: false}, + } + + for { + var detectedIP string + for _, p := range providers { + resolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 5 * time.Second} + return d.DialContext(ctx, "udp", p.server) + }, + } + + var raw string + if p.isTXT { + txt, err := resolver.LookupTXT(gCtx, p.host) + if err == nil && len(txt) > 0 { + raw = strings.Trim(txt[0], "\"") + } + } else { + ips, err := resolver.LookupHost(gCtx, p.host) + if err == nil && len(ips) > 0 { + raw = ips[0] + } + } + + if ip := net.ParseIP(raw); ip != nil { + detectedIP = ip.String() + break + } + } + + if detectedIP != "" { + last, _ := store.GetLatest() + if detectedIP != last { + if err := store.Insert(detectedIP); err != nil { + logger.Error("failed to save IP", "err", err) + } else { + logger.Info("IP change detected", "ip", detectedIP) + } + } + } + + select { + case <-gCtx.Done(): + return nil + case <-ticker.C: + } + } + }) + + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", handleList(store, logger)) + mux.HandleFunc("GET /p/{page}", handleList(store, logger)) + + srv := &http.Server{ + Addr: ":8080", + Handler: mux, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + } + + g.Go(func() error { + logger.Info("server started", "url", "http://localhost:8080") + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil + }) + + g.Go(func() error { + <-gCtx.Done() + sCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return srv.Shutdown(sCtx) + }) + + if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) { + logger.Error("application error", "err", err) + } +} + +func handleList(store *Store, logger *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + page, _ := strconv.Atoi(r.PathValue("page")) + if page < 1 { + page = 1 + } + query := r.URL.Query().Get("q") + + records, hasMore, err := store.FetchPage(query, page, 50) + if err != nil { + slog.Error("DB Error", "err", err) + http.Error(w, "Internal Error", 500) + return + } + + if r.Header.Get("HX-Request") == "true" && r.Header.Get("HX-Target") == "main-content" { + _ = MainContent(records, query, page, hasMore).Render(r.Context(), w) + return + } + _ = Page(records, query, page, hasMore).Render(r.Context(), w) + } } diff --git a/readme.md b/readme.md index c9da739..25ed5b7 100644 --- a/readme.md +++ b/readme.md @@ -1,59 +1,22 @@ # IPHistory -The IPHistory project is a simple yet effective solution for tracking and logging the public IP address of your network. It periodically fetches the public IP address and logs it to a file, while also providing a web interface to view the IP history in a clean UI and an endpoint (`/history`) for JSON format. +A simple tool for tracking your network's public IP address. It periodically checks for changes and logs them to a local database, providing a clean, searchable web interface to browse your history. It's designed to be lightweight, self-reliant, and fast without requiring any maintenance. -![iphistory](https://github.com/user-attachments/assets/daca427a-91ff-4dd8-a72a-83cbb59db3b2) - -## Running Locally - -### With Docker - -```sh -git clone https://github.com/skidoodle/iphistory -cd iphistory -docker build -t iphistory:main . -docker run -p 8080:8080 iphistory:main -``` - -### Without Docker - -```sh -git clone https://github.com/skidoodle/iphistory -cd iphistory -go run main.go -``` - -## Deploying - -### Docker Compose +## Deploy ```yaml -version: '3.9' - services: iphistory: - image: ghcr.io/skidoodle/iphistory:main + image: ghcr.io/skidoodle/iphistory container_name: iphistory restart: unless-stopped ports: - "8080:8080" volumes: - - iphistory_data:/app + - data:/app volumes: - iphistory_data: - external: false -``` - -### Docker Run - -```sh -docker run \ - -d \ - --name=iphistory \ - --restart=unless-stopped \ - -p 8080:8080 \ - ghcr.io/skidoodle/iphistory:main + data: ``` ## License diff --git a/seed.go b/seed.go new file mode 100644 index 0000000..fc73ecd --- /dev/null +++ b/seed.go @@ -0,0 +1,68 @@ +//go:build ignore + +package main + +import ( + "fmt" + "math/rand/v2" + "os" + "strconv" + "strings" + "time" +) + +func main() { + if len(os.Args) < 2 { + panic("usage: go run seed.go ") + } + + n, _ := strconv.Atoi(os.Args[1]) + store, err := NewStore("history.db") + if err != nil { + panic(err) + } + + store.db.Exec("PRAGMA synchronous = OFF") + store.db.Exec("PRAGMA journal_mode = MEMORY") + + tx, _ := store.db.Begin() + + batchSize := 1000 + q := strings.Builder{} + vals := make([]any, 0, batchSize*2) + baseTime := time.Now().UTC() + + fmt.Printf("generating %d rows...\n", n) + + for i := 0; i < n; i++ { + ip := strconv.Itoa(rand.IntN(254)+1) + "." + + strconv.Itoa(rand.IntN(255)) + "." + + strconv.Itoa(rand.IntN(255)) + "." + + strconv.Itoa(rand.IntN(255)) + + ts := baseTime.Add(time.Duration(-i) * time.Hour) + + if len(vals) == 0 { + q.WriteString("INSERT INTO ip_history (ip, ts) VALUES ") + } else { + q.WriteString(",") + } + q.WriteString("(?,?)") + vals = append(vals, ip, ts) + + if len(vals) >= batchSize*2 { + if _, err := tx.Exec(q.String(), vals...); err != nil { + panic(err) + } + q.Reset() + vals = vals[:0] + } + } + + if len(vals) > 0 { + tx.Exec(q.String(), vals...) + } + + tx.Commit() + fmt.Println("done") +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..6b8754a --- /dev/null +++ b/store.go @@ -0,0 +1,89 @@ +package main + +import ( + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +type Record struct { + ID int + Timestamp time.Time + IP string +} + +type Store struct { + db *sql.DB +} + +func NewStore(path string) (*Store, error) { + dsn := fmt.Sprintf("%s?_pragma=journal_mode(WAL)&_pragma=synchronous=NORMAL&_pragma=mmap_size=268435456", path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + + schema := ` + CREATE TABLE IF NOT EXISTS ip_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts DATETIME DEFAULT CURRENT_TIMESTAMP, + ip TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_ts ON ip_history(ts DESC); + CREATE INDEX IF NOT EXISTS idx_ip ON ip_history(ip COLLATE NOCASE);` + + if _, err := db.Exec(schema); err != nil { + return nil, err + } + return &Store{db: db}, nil +} + +func (s *Store) GetLatest() (string, error) { + var ip string + err := s.db.QueryRow("SELECT ip FROM ip_history ORDER BY ts DESC LIMIT 1").Scan(&ip) + if err == sql.ErrNoRows { + return "", nil + } + return ip, err +} + +func (s *Store) Insert(ip string) error { + _, err := s.db.Exec("INSERT INTO ip_history (ip, ts) VALUES (?, ?)", ip, time.Now().UTC()) + return err +} + +func (s *Store) FetchPage(query string, page, pageSize int) ([]Record, bool, error) { + offset := (page - 1) * pageSize + limit := pageSize + 1 + + q := query + "%" + if query == "" { + q = "%" + } + + rows, err := s.db.Query( + "SELECT id, ts, ip FROM ip_history WHERE ip LIKE ? ORDER BY ts DESC LIMIT ? OFFSET ?", + q, limit, offset, + ) + if err != nil { + return nil, false, err + } + defer rows.Close() + + var records []Record + for rows.Next() { + var r Record + if err := rows.Scan(&r.ID, &r.Timestamp, &r.IP); err != nil { + return nil, false, err + } + records = append(records, r) + } + + hasMore := len(records) > pageSize + if hasMore { + records = records[:pageSize] + } + return records, hasMore, nil +} diff --git a/ui.templ b/ui.templ new file mode 100644 index 0000000..f714df8 --- /dev/null +++ b/ui.templ @@ -0,0 +1,118 @@ +package main + +import "fmt" + +func pathBuilder(page int, query string) string { + base := "/" + if page > 1 { + base = fmt.Sprintf("/p/%d", page) + } + if query != "" { + return fmt.Sprintf("%s?q=%s", base, query) + } + return base +} + +templ MainContent(records []Record, query string, page int, hasMore bool) { +
+ if page == 1 && query == "" && len(records) > 0 { +
+
Current Public IP
+
{ records[0].IP }
+
+ Last observed: { records[0].Timestamp.Format("Jan 02, 15:04:05 MST") } +
+
+ } + + + + + + for _, r := range records { + + + + + } + +
TimestampObserved IP
{ r.Timestamp.Format("2006-01-02 15:04:05") }{ r.IP }
+ +
+} + +templ Page(records []Record, query string, page int, hasMore bool) { + + + + + + IP History + + + + + +
+
+

IP History

+

A simple timeline of your public IP changes

+
+
+ + +
+ @MainContent(records, query, page, hasMore) +
+ + +} diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 270fcc5..0000000 --- a/web/index.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - IP History - - - - -

IP History

-
- - -
- - - - - - - - - -
TimestampIPv4 AddressIPv6 Address
- - - diff --git a/web/script.js b/web/script.js deleted file mode 100644 index 7c77c02..0000000 --- a/web/script.js +++ /dev/null @@ -1,115 +0,0 @@ -let ipHistory = []; -let filteredHistory = []; -let currentPage = 1; -let entriesPerPage = 10; - -document.addEventListener('DOMContentLoaded', () => { - fetch('/history') - .then(response => response.json()) - .then(data => { - ipHistory = data; - filteredHistory = ipHistory; - displayTable(); - updateTotalIPs(); - }) - .catch(error => console.error('Error fetching IP history:', error)); - - const searchBar = document.getElementById('searchBar'); - searchBar.addEventListener('input', handleSearch); - - document.getElementById('prevBtn').addEventListener('click', prevPage); - document.getElementById('nextBtn').addEventListener('click', nextPage); - document.getElementById('exportBtn').addEventListener('click', exportToCSV); - document.getElementById('sortTimestamp').addEventListener('click', () => sortTable('timestamp')); - document.getElementById('sortIPv4').addEventListener('click', () => sortTable('ipv4')); - document.getElementById('sortIPv6').addEventListener('click', () => sortTable('ipv6')); -}); - - -function handleSearch() { - const query = this.value.toLowerCase(); - filteredHistory = ipHistory.filter(entry => - entry.timestamp.toLowerCase().includes(query) || - (entry.ipv4 && entry.ipv4.toLowerCase().includes(query)) || - (entry.ipv6 && entry.ipv6.toLowerCase().includes(query)) - ); - currentPage = 1; - displayTable(); - updateTotalIPs(); -} - -function displayTable() { - const tbody = document.querySelector('#ipTable tbody'); - tbody.innerHTML = ''; - - const start = (currentPage - 1) * entriesPerPage; - const end = start + entriesPerPage; - const currentEntries = filteredHistory.slice(start, end); - - currentEntries.forEach(entry => { - const row = document.createElement('tr'); - row.innerHTML = ` - ${entry.timestamp} - ${entry.ipv4 || 'N/A'} - ${entry.ipv6 || 'N/A'} - `; - tbody.appendChild(row); - }); - updatePaginationButtons(); - updateTotalIPs(); -} - -function updateTotalIPs() { - const totalIPs = filteredHistory.length; - const searchBar = document.getElementById('searchBar'); - searchBar.placeholder = `Search IP history... (Total IPs: ${totalIPs})`; -} - -function updatePaginationButtons() { - const prevBtn = document.getElementById('prevBtn'); - const nextBtn = document.getElementById('nextBtn'); - prevBtn.disabled = currentPage === 1; - nextBtn.disabled = currentPage === Math.ceil(filteredHistory.length / entriesPerPage); -} - -function nextPage() { - if (currentPage < Math.ceil(filteredHistory.length / entriesPerPage)) { - currentPage++; - displayTable(); - } -} - -function prevPage() { - if (currentPage > 1) { - currentPage--; - displayTable(); - } -} - -function sortTable(field) { - const isAscending = document.getElementById(`sort${capitalizeFirstLetter(field)}`).classList.contains('sort-asc'); - filteredHistory.sort((a, b) => (a[field] && b[field]) ? a[field].localeCompare(b[field]) * (isAscending ? 1 : -1) : 0); - - // Toggle the sort direction class - document.querySelectorAll('th').forEach(th => th.classList.remove('sort-asc', 'sort-desc')); - document.getElementById(`sort${capitalizeFirstLetter(field)}`).classList.add(isAscending ? 'sort-desc' : 'sort-asc'); - - displayTable(); -} - -function exportToCSV() { - const rows = [ - ['Timestamp', 'IPv4 Address', 'IPv6 Address'], - ...filteredHistory.map(entry => [entry.timestamp, entry.ipv4 || '', entry.ipv6 || '']) - ]; - const csvContent = rows.map(row => row.join(',')).join('\n'); - const blob = new Blob([csvContent], { type: 'text/csv' }); - const link = document.createElement('a'); - link.href = URL.createObjectURL(blob); - link.download = 'ip_history.csv'; - link.click(); -} - -function capitalizeFirstLetter(string) { - return string.charAt(0).toUpperCase() + string.slice(1); -} diff --git a/web/style.css b/web/style.css deleted file mode 100644 index d8d168f..0000000 --- a/web/style.css +++ /dev/null @@ -1,221 +0,0 @@ -/* General */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; - font-family: 'Poppins', Arial, sans-serif; - transition: all 0.3s ease; -} - -body { - background-color: #121212; - color: #e0e0e0; - display: flex; - flex-direction: column; - align-items: center; - padding: 20px; - min-height: 100vh; -} - -/* Typography */ -h1 { - font-size: 2.5rem; - margin-bottom: 20px; - color: #007bff; - text-align: center; - font-weight: 700; -} - -h1 a { - color: inherit; - text-decoration: none; -} - -h1 a:hover { - color: #0056b3; -} - -/* Controls */ -.controls { - width: 100%; - max-width: 900px; - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - flex-wrap: wrap; -} - -.export { - background-color: #007bff; - border: 1px solid #007bff; - color: #fff; - padding: 12px 24px; - border-radius: 5px; - font-size: 16px; - cursor: pointer; - flex: 0 1 30%; - text-align: center; - transition: background-color 0.2s; -} - -.export:hover { - background-color: #0056b3; - border-color: #0056b3; -} - -.search-bar { - flex: 0 1 65%; - padding: 12px; - font-size: 16px; - border-radius: 5px; - background-color: #333; - color: #e0e0e0; - border: 1px solid #444; -} - -.search-bar:focus { - outline: none; - border-color: #007bff; -} - -/* Table */ -table { - width: 100%; - max-width: 900px; - border-collapse: collapse; - margin: 20px auto; - background-color: #1e1e1e; - border-radius: 10px; - overflow: hidden; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4); -} - -th, td { - padding: 16px 20px; - text-align: left; - border-bottom: 1px solid #333; - font-size: 16px; - word-break: break-all; -} - -th { - background-color: #007bff; - color: #fff; - font-weight: 600; - cursor: pointer; - position: relative; -} - -th.sort-asc::after, -th.sort-desc::after { - content: " ▼"; - font-size: 12px; - position: absolute; - right: 10px; - top: 50%; - transform: translateY(-50%); - color: #fff; -} - -th.sort-asc::after { - content: " ▲"; -} - -tr:nth-child(even) { - background-color: #292929; -} - -tr:hover { - background-color: #333; - transition: background-color 0.2s ease; -} - -/* Pagination */ -.pagination { - display: flex; - justify-content: center; - margin: 20px 0; -} - -.pagination button { - background-color: #007bff; - color: #fff; - border: none; - padding: 10px 20px; - margin: 0 5px; - border-radius: 5px; - cursor: pointer; - font-size: 16px; -} - -.pagination button:disabled { - background-color: #555; - cursor: not-allowed; -} - -.pagination button:hover:not(:disabled) { - background-color: #0056b3; -} - -/* Responsive Design */ -@media (max-width: 768px) { - h1 { - font-size: 2rem; - } - - .controls { - flex-direction: column; - align-items: center; - } - - .export, .search-bar { - width: 100%; - margin-bottom: 10px; - } - - th, td { - padding: 10px; - font-size: 14px; - } - - table { - font-size: 14px; - } -} - -@media (max-width: 480px) { - h1 { - font-size: 1.8rem; - } - - .controls { - width: 100%; - text-align: center; - } - - .export, .search-bar { - width: 100%; - font-size: 14px; - padding: 10px; - margin-bottom: 10px; - } - - table { - font-size: 12px; - } - - th, td { - font-size: 12px; - padding: 8px 10px; - } - - th, td { - word-wrap: break-word; - word-break: break-all; - } - - .pagination button { - padding: 8px 12px; - } -}