This commit is contained in:
2026-01-10 20:07:35 +01:00
parent a015fd6b2e
commit 43c6e90649
18 changed files with 512 additions and 674 deletions
+2 -2
View File
@@ -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
+9 -16
View File
@@ -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 ./...
+2
View File
@@ -0,0 +1,2 @@
history*
*templ.go
+2 -1
View File
@@ -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"]
+6
View File
@@ -0,0 +1,6 @@
services:
iphistory:
build: .
container_name: iphistory
ports:
- "8080:8080"
+2 -5
View File
@@ -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:
-60
View File
@@ -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 <number_of_records>")
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)
+20 -1
View File
@@ -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
)
+53
View File
@@ -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=
View File
+136 -180
View File
@@ -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)
}
}
+5 -42
View File
@@ -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
+68
View File
@@ -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 <count>")
}
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")
}
+89
View File
@@ -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
}
+118
View File
@@ -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) {
<div id="main-content" class="fade-in">
if page == 1 && query == "" && len(records) > 0 {
<section class="current-ip-card">
<div style="font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.5rem;">Current Public IP</div>
<div class="ip-display">{ records[0].IP }</div>
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.5rem;">
Last observed: { records[0].Timestamp.Format("Jan 02, 15:04:05 MST") }
</div>
</section>
}
<table class="history-table">
<thead>
<tr><th>Timestamp</th><th>Observed IP</th></tr>
</thead>
<tbody>
for _, r := range records {
<tr>
<td style="color: var(--text-muted);">{ r.Timestamp.Format("2006-01-02 15:04:05") }</td>
<td style="font-family: ui-monospace, monospace; font-weight: 500;">{ r.IP }</td>
</tr>
}
</tbody>
</table>
<div class="pagination">
if page > 1 {
<a
href={ templ.SafeURL(pathBuilder(page-1, query)) }
hx-get={ pathBuilder(page-1, query) }
hx-target="#main-content"
hx-push-url="true"
class="nav-link"
>← Previous</a>
} else {
<a class="nav-link disabled">← Previous</a>
}
<span style="font-size: 0.875rem; color: var(--text-muted);">Page { fmt.Sprint(page) }</span>
if hasMore {
<a
href={ templ.SafeURL(pathBuilder(page+1, query)) }
hx-get={ pathBuilder(page+1, query) }
hx-target="#main-content"
hx-push-url="true"
class="nav-link"
>Next →</a>
} else {
<a class="nav-link disabled">Next →</a>
}
</div>
</div>
}
templ Page(records []Record, query string, page int, hasMore bool) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>IP History</title>
<link rel="icon" href="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
:root { --bg: #0a0a0c; --card: #16161a; --border: #2d2d33; --text: #ececed; --text-muted: #94949e; --primary: #3b82f6; }
html { scrollbar-gutter: stable; overflow-y: auto; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: ui-sans-serif, system-ui, sans-serif; background-color: var(--bg); color: var(--text); line-height: 1.6; padding: 2rem 1rem; }
.container { max-width: 800px; margin-inline: auto; }
header { margin-bottom: 2.5rem; }
h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.025em; }
a.title-link { text-decoration: none; color: inherit; display: inline-block; }
a.title-link:hover h1 { color: var(--primary); }
.search-form { margin-bottom: 2rem; display: flex; gap: 0.5rem; }
.search-input { flex: 1; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; color: var(--text); font-size: 1rem; }
.search-input:focus { outline: none; border-color: var(--primary); }
.btn-search { background: var(--primary); color: white; border: none; padding: 0 1.5rem; border-radius: 8px; font-weight: 600; cursor: pointer; }
.current-ip-card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; margin-bottom: 2rem; }
.ip-display { font-family: ui-monospace, monospace; font-size: 2rem; font-weight: 700; color: var(--primary); }
.history-table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
th, td { padding: 1rem; text-align: left; border-bottom: 1px solid var(--border); }
th { font-size: 0.75rem; text-transform: uppercase; color: var(--text-muted); background: rgba(255,255,255,0.02); }
.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 1.5rem; }
.nav-link { background: var(--card); border: 1px solid var(--border); color: var(--text); padding: 0.5rem 1rem; border-radius: 6px; text-decoration: none; font-size: 0.875rem; }
.nav-link:hover { border-color: var(--primary); }
.nav-link.disabled { opacity: 0.2; pointer-events: none; }
.fade-in { animation: fadeIn 0.15s ease-out; }
@keyframes fadeIn { from { opacity: 0.5; transform: translateY(2px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body hx-boost="true">
<main class="container">
<header>
<a href="/" hx-boost="false" class="title-link"><h1>IP History</h1></a>
<p style="color: var(--text-muted); font-size: 0.875rem;">A simple timeline of your public IP changes</p>
</header>
<form class="search-form" hx-get="/" hx-target="#main-content" hx-push-url="true">
<input type="text" name="q" class="search-input" placeholder="Search..." value={ query } autocomplete="off"/>
<button type="submit" class="btn-search">Search</button>
</form>
@MainContent(records, query, page, hasMore)
</main>
</body>
</html>
}
-31
View File
@@ -1,31 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>IP History</title>
<link rel="stylesheet" href="style.css">
<script defer src="script.js"></script>
</head>
<body>
<h1><a href="/">IP History</a></h1>
<div class="controls">
<button class="export" id="exportBtn">Export to CSV</button>
<input type="text" id="searchBar" class="search-bar" placeholder="Search IP history..." autocomplete="off">
</div>
<table id="ipTable">
<thead>
<tr>
<th id="sortTimestamp">Timestamp</th>
<th id="sortIPv4">IPv4 Address</th>
<th id="sortIPv6">IPv6 Address</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="pagination" id="pagination">
<button id="prevBtn" disabled>&laquo; Prev</button>
<button id="nextBtn" disabled>Next &raquo;</button>
</div>
</body>
</html>
-115
View File
@@ -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 = `
<td>${entry.timestamp}</td>
<td>${entry.ipv4 || 'N/A'}</td>
<td>${entry.ipv6 || 'N/A'}</td>
`;
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);
}
-221
View File
@@ -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;
}
}