Files
tchivert 5a35bf44fb
build / build (push) Successful in 1m20s
docker / docker (push) Successful in 2m23s
various performance improvements and bug fixes
2025-11-13 15:58:00 +01:00

795 lines
18 KiB
Go

package main
import (
"container/list"
"context"
"crypto/tls"
"flag"
"fmt"
"html/template"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/oschwald/geoip2-golang"
"golang.org/x/exp/rand"
)
var (
bind = flag.String("b", "0.0.0.0", "Bind to address")
port = flag.Int("p", 8080, "Port to listen on")
name = flag.String("n", "iplookup.fr", "Name of the application")
domain = flag.String("d", "iplookup.fr", "Domain name")
shortdomain = flag.String("s", "iplk.fr", "Short domain name")
geocity = flag.String("c", "", "Path to the GeoIP database")
geoasn = flag.String("a", "", "Path to the GeoASN database")
verbose = flag.Bool("v", false, "Enable verbose logging")
ttl = flag.Int("t", 300, "Cache TTL in seconds")
maxcache = flag.Int("m", 2048, "Max number of entries in cache")
// Global database connections (opened once at startup)
geoCityDB *geoip2.Reader
geoAsnDB *geoip2.Reader
// Cache with map for O(1) lookups and mutex for concurrent access
cache = make(map[string]*CacheEntry)
cacheList = list.New() // LRU eviction list
cacheMutex sync.RWMutex
// Template parsed once at startup
tmpl *template.Template
cli = []string{
"curl",
"HTTPie",
"httpie-go",
"Wget",
"fetch libfetch",
"Go",
"Go-http-client",
"ddclient",
"Mikrotik",
"xh",
}
colors = []string{
"#2488bf",
"#d84d3d",
"#f39700",
"#4caf50",
}
)
type Ssl struct {
San []string
Ocsp []string
Subject string
Issuer string
Alpn string
NotBefore string
NotAfter string
}
type Geo struct {
Country string
Ccode string
City string
Latitude float64
Longitude float64
Postal string
Timezone string
}
type Asn struct {
Asn uint
Org string
}
type Info struct {
Ip string
Hostname string
Query bool
Port string
UserAgent string
MimeType string
Language string
ContentType string
Encoding string
Method string
CacheControl string
Referer string
XForwardedFor string
XForwardedProto string
Cached bool
Ttl int
Geo Geo
Asn Asn
Ssl Ssl
}
type CacheEntry struct {
Query string
Info Info
Time time.Time
Element *list.Element // Pointer to element in LRU list for O(1) removal
}
type Tmpl struct {
Color string
Title string
Domain string
Short string
Argument string
Info Info
}
func getElem(info Info, elem string) string {
elem = strings.ToLower(elem)
elem = strings.ReplaceAll(elem, "-", "_")
// Use direct field access instead of large switch statement
switch elem {
case "hostname", "host":
return info.Hostname
case "ip":
return info.Ip
case "port":
return info.Port
case "user_agent", "ua", "useragent":
return info.UserAgent
case "mime_type":
return info.MimeType
case "language":
return info.Language
case "content_type":
return info.ContentType
case "encoding":
return info.Encoding
case "method":
return info.Method
case "cache_control":
return info.CacheControl
case "referer":
return info.Referer
case "x_forwarded_for":
return info.XForwardedFor
case "x_forwarded_proto":
return info.XForwardedProto
case "country":
return info.Geo.Country
case "ccode", "cc":
return info.Geo.Ccode
case "city":
return info.Geo.City
case "latitude":
return fmt.Sprintf("%f", info.Geo.Latitude)
case "longitude":
return fmt.Sprintf("%f", info.Geo.Longitude)
case "postal":
return info.Geo.Postal
case "timezone":
return info.Geo.Timezone
case "asn":
return fmt.Sprintf("%d", info.Asn.Asn)
case "org", "organisation", "isp":
return info.Asn.Org
case "ssl_san", "san":
return strings.Join(info.Ssl.San, ", ")
case "ssl_ocsp", "ocsp":
return strings.Join(info.Ssl.Ocsp, ", ")
case "ssl_subject", "subject":
return info.Ssl.Subject
case "ssl_issuer", "issuer":
return info.Ssl.Issuer
case "ssl_alpn", "alpn":
return info.Ssl.Alpn
case "ssl_not_after", "not_after":
return info.Ssl.NotAfter
case "ssl_not_before", "not_before":
return info.Ssl.NotBefore
}
return ""
}
func writeSsl(w http.ResponseWriter, ssl Ssl) {
if ssl.San == nil {
return
}
// Direct field access instead of reflection
if len(ssl.San) > 0 {
fmt.Fprintf(w, "san: %v\n", ssl.San)
}
if len(ssl.Ocsp) > 0 {
fmt.Fprintf(w, "ocsp: %v\n", ssl.Ocsp)
}
if ssl.Subject != "" {
fmt.Fprintf(w, "subject: %s\n", ssl.Subject)
}
if ssl.Issuer != "" {
fmt.Fprintf(w, "issuer: %s\n", ssl.Issuer)
}
if ssl.Alpn != "" {
fmt.Fprintf(w, "alpn: %s\n", ssl.Alpn)
}
if ssl.NotBefore != "" {
fmt.Fprintf(w, "notbefore: %s\n", ssl.NotBefore)
}
if ssl.NotAfter != "" {
fmt.Fprintf(w, "notafter: %s\n", ssl.NotAfter)
}
}
func writeAsn(w http.ResponseWriter, asn Asn) {
if asn.Asn == 0 {
return
}
fmt.Fprintf(w, "asn: %d\n", asn.Asn)
if asn.Org != "" {
fmt.Fprintf(w, "org: %s\n", asn.Org)
}
}
func writeGeo(w http.ResponseWriter, geo Geo) {
if geo.Country == "" {
return
}
fmt.Fprintf(w, "country: %s\n", geo.Country)
if geo.Ccode != "" {
fmt.Fprintf(w, "ccode: %s\n", geo.Ccode)
}
if geo.City != "" {
fmt.Fprintf(w, "city: %s\n", geo.City)
}
if geo.Latitude != 0 {
fmt.Fprintf(w, "latitude: %f\n", geo.Latitude)
}
if geo.Longitude != 0 {
fmt.Fprintf(w, "longitude: %f\n", geo.Longitude)
}
if geo.Postal != "" {
fmt.Fprintf(w, "postal: %s\n", geo.Postal)
}
if geo.Timezone != "" {
fmt.Fprintf(w, "timezone: %s\n", geo.Timezone)
}
}
func writeInfo(w http.ResponseWriter, info Info) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
// Direct field access instead of reflection for better performance
if info.Ip != "" {
fmt.Fprintf(w, "ip: %s\n", info.Ip)
}
if info.Hostname != "" {
fmt.Fprintf(w, "hostname: %s\n", info.Hostname)
}
if info.Port != "" {
fmt.Fprintf(w, "port: %s\n", info.Port)
}
if info.UserAgent != "" {
fmt.Fprintf(w, "useragent: %s\n", info.UserAgent)
}
if info.MimeType != "" {
fmt.Fprintf(w, "mimetype: %s\n", info.MimeType)
}
if info.Language != "" {
fmt.Fprintf(w, "language: %s\n", info.Language)
}
if info.ContentType != "" {
fmt.Fprintf(w, "contenttype: %s\n", info.ContentType)
}
if info.Encoding != "" {
fmt.Fprintf(w, "encoding: %s\n", info.Encoding)
}
if info.Method != "" {
fmt.Fprintf(w, "method: %s\n", info.Method)
}
if info.CacheControl != "" {
fmt.Fprintf(w, "cachecontrol: %s\n", info.CacheControl)
}
if info.Referer != "" {
fmt.Fprintf(w, "referer: %s\n", info.Referer)
}
if info.XForwardedFor != "" {
fmt.Fprintf(w, "xforwardedfor: %s\n", info.XForwardedFor)
}
if info.XForwardedProto != "" {
fmt.Fprintf(w, "xforwardedproto: %s\n", info.XForwardedProto)
}
if info.Ttl > 0 {
fmt.Fprintf(w, "ttl: %d\n", info.Ttl)
}
writeGeo(w, info.Geo)
writeAsn(w, info.Asn)
writeSsl(w, info.Ssl)
}
func reqtype(ip string) string {
if net.ParseIP(ip) != nil {
return "ip"
}
for i := 0; i < len(ip); i++ {
if ip[i] == ':' {
return "ip"
}
if ip[i] < '0' || ip[i] > '9' {
if strings.Contains(ip, ".") {
return "domain"
} else {
return "info"
}
}
}
return ""
}
func getSslInfo(domain string, info Info) Info {
lookup, err := net.LookupIP(domain)
if err != nil {
if *verbose {
fmt.Printf("DNS lookup error for %s: %v\n", domain, err)
}
return info
}
// Check if lookup returned any results
if len(lookup) == 0 {
if *verbose {
fmt.Printf("DNS lookup returned no results for %s\n", domain)
}
return info
}
info.Ip = lookup[0].String()
ssl := Ssl{}
conn, err := tls.DialWithDialer(&net.Dialer{
Timeout: 2 * time.Second,
}, "tcp", domain+":443", &tls.Config{InsecureSkipVerify: true})
if err != nil {
if *verbose {
fmt.Printf("TLS connection error for %s: %v\n", domain, err)
}
return info
}
defer conn.Close()
// Check if peer certificates are available
certs := conn.ConnectionState().PeerCertificates
if len(certs) == 0 {
if *verbose {
fmt.Printf("No peer certificates received for %s\n", domain)
}
return info
}
ssl.San = certs[0].DNSNames
ssl.Subject = certs[0].Subject.String()
ssl.Issuer = certs[0].Issuer.String()
ssl.Alpn = conn.ConnectionState().NegotiatedProtocol
ssl.Ocsp = certs[0].OCSPServer
ssl.NotAfter = certs[0].NotAfter.String()
ssl.NotBefore = certs[0].NotBefore.String()
info.Ssl = ssl
return info
}
func getAsn(ip string) Asn {
asn := Asn{}
if geoAsnDB == nil {
return asn
}
record, err := geoAsnDB.ASN(net.ParseIP(ip))
if err != nil {
if *verbose {
fmt.Printf("ASN lookup error for %s: %v\n", ip, err)
}
return Asn{}
}
asn.Asn = record.AutonomousSystemNumber
asn.Org = record.AutonomousSystemOrganization
return asn
}
func getGeo(ip string) Geo {
geo := Geo{}
if geoCityDB == nil {
return geo
}
record, err := geoCityDB.City(net.ParseIP(ip))
if err != nil {
if *verbose {
fmt.Printf("GeoIP lookup error for %s: %v\n", ip, err)
}
return Geo{}
}
geo.City = record.City.Names["en"]
geo.Latitude, geo.Longitude = record.Location.Latitude, record.Location.Longitude
geo.Country = record.Country.Names["en"]
geo.Ccode = record.Country.IsoCode
geo.Postal = record.Postal.Code
geo.Timezone = record.Location.TimeZone
return geo
}
func getIP(info Info, r *http.Request) (Info, error) {
var err error
if r.Header.Get("X-Forwarded-For") != "" {
info.Ip = r.Header.Get("X-Forwarded-For")
} else if r.Header.Get("X-Real-IP") != "" {
info.Ip = r.Header.Get("X-Real-IP")
} else if r.Header.Get("X-Client-IP") != "" {
info.Ip = r.Header.Get("X-Client-IP")
} else if r.Header.Get("CF-Connecting-IP") != "" {
info.Ip = r.Header.Get("CF-Connecting-IP")
} else {
info.Ip, info.Port, err = net.SplitHostPort(r.RemoteAddr)
if err != nil {
if *verbose {
fmt.Printf("Error parsing remote address %s: %v\n", r.RemoteAddr, err)
}
return info, err
}
}
return info, nil
}
func getIpInfo(info Info, r *http.Request) (Info, error) {
// Create a context with timeout for DNS lookups
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
// Use a custom resolver with timeout
resolver := &net.Resolver{}
hosts, err := resolver.LookupAddr(ctx, info.Ip)
if err != nil {
// If lookup fails or times out, use the IP as hostname
if *verbose {
fmt.Printf("Reverse DNS lookup error for %s: %v\n", info.Ip, err)
}
hosts = []string{info.Ip}
}
if len(hosts) > 0 {
info.Hostname = hosts[0]
} else {
info.Hostname = info.Ip
}
if !info.Query {
info.UserAgent = r.UserAgent()
info.MimeType = r.Header.Get("Accept")
info.Language = r.Header.Get("Accept-Language")
info.ContentType = r.Header.Get("Content-Type")
info.Encoding = r.Header.Get("Accept-Encoding")
info.Method = r.Method
info.CacheControl = r.Header.Get("Cache-Control")
info.Referer = r.Header.Get("Referer")
info.XForwardedFor = r.Header.Get("X-Forwarded-For")
info.XForwardedProto = r.Header.Get("X-Forwarded-Proto")
}
return info, nil
}
func getCache(query string, info Info) Info {
cacheMutex.RLock()
entry, exists := cache[query]
cacheMutex.RUnlock()
if !exists {
return info
}
elapsed := time.Since(entry.Time)
if elapsed < time.Duration(*ttl)*time.Second {
entry.Info.Ttl = int((time.Duration(*ttl)*time.Second - elapsed).Seconds())
entry.Info.Cached = true
// Move to front of LRU list (most recently used)
cacheMutex.Lock()
cacheList.MoveToFront(entry.Element)
cacheMutex.Unlock()
return entry.Info
}
// Entry expired, remove it
cacheMutex.Lock()
if entry.Element != nil {
cacheList.Remove(entry.Element)
}
delete(cache, query)
cacheMutex.Unlock()
return info
}
func putCache(query string, info Info) {
info.Cached = true
info.Ttl = *ttl
cacheMutex.Lock()
defer cacheMutex.Unlock()
// Check if entry already exists (update case)
if existing, exists := cache[query]; exists {
existing.Info = info
existing.Time = time.Now()
cacheList.MoveToFront(existing.Element)
return
}
// LRU eviction if cache is full
if len(cache) >= *maxcache {
// Remove least recently used (back of list)
oldest := cacheList.Back()
if oldest != nil {
oldEntry := oldest.Value.(*CacheEntry)
delete(cache, oldEntry.Query)
cacheList.Remove(oldest)
}
}
// Add new entry
entry := &CacheEntry{
Query: query,
Info: info,
Time: time.Now(),
}
entry.Element = cacheList.PushFront(entry)
cache[query] = entry
}
func handler(w http.ResponseWriter, r *http.Request) {
info := Info{}
arg := ""
isCli := false
var err error
if *verbose {
fmt.Println("Request:", r)
}
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
for i := 0; i < len(cli); i++ {
if strings.Contains(r.UserAgent(), cli[i]) {
isCli = true
}
}
if r.URL.Path == "/" {
info, err = getIP(info, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
if *verbose {
fmt.Printf("Error getting IP: %v\n", err)
}
return
}
if isCli {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte(info.Ip + "\n"))
return
}
info = getCache(info.Ip, info)
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", info.Ttl))
if !info.Cached {
if *geocity != "" {
info.Geo = getGeo(info.Ip)
}
if *geoasn != "" {
info.Asn = getAsn(info.Ip)
}
putCache(info.Ip, info)
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", *ttl))
}
info, err = getIpInfo(info, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
if *verbose {
fmt.Printf("Error getting IP info: %v\n", err)
}
return
}
}
if r.URL.Path == "/favicon.ico" {
w.Header().Set("Content-Type", "image/png")
http.ServeFile(w, r, "favicon.png")
return
}
args := strings.Split(r.URL.Path, "/")
if len(args) > 1 && args[1] != "" {
// Validate that args[1] is not excessively long (prevent DoS)
if len(args[1]) > 256 {
http.Error(w, "Request URI too long", http.StatusRequestURITooLong)
return
}
arg = "/" + args[1]
rtype := reqtype(args[1])
if rtype == "" {
http.Error(w, "Not found", http.StatusNotFound)
return
}
if rtype == "ip" {
info.Query = true
info.Ip = args[1]
info = getCache(args[1], info)
} else if rtype == "domain" {
info.Query = true
info = getCache(args[1], info)
} else {
info, err = getIP(info, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
if *verbose {
fmt.Printf("Error getting IP: %v\n", err)
}
return
}
info = getCache(info.Ip, info)
}
if !info.Cached {
if rtype == "domain" {
info.Query = true
info = getSslInfo(args[1], info)
}
if info.Ip == "" {
http.Error(w, "Not found", http.StatusNotFound)
return
}
if *geocity != "" {
info.Geo = getGeo(info.Ip)
}
if *geoasn != "" {
info.Asn = getAsn(info.Ip)
}
if rtype != "info" {
putCache(args[1], info)
} else {
putCache(info.Ip, info)
}
}
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", info.Ttl))
info, err = getIpInfo(info, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
if *verbose {
fmt.Printf("Error getting IP info: %v\n", err)
}
return
}
if rtype == "info" || (len(args) > 2 && args[2] != "") {
// Safely access the last argument
lastArg := args[len(args)-1]
if lastArg == "all" {
writeInfo(w, info)
return
}
if lastArg == "geo" {
writeGeo(w, info.Geo)
return
}
if lastArg == "as" {
writeAsn(w, info.Asn)
return
}
if lastArg == "ssl" {
writeSsl(w, info.Ssl)
return
}
res := getElem(info, lastArg)
if res == "" {
http.Error(w, "Not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "%s\n", res)
return
}
}
if isCli {
writeInfo(w, info)
return
}
tmplData := Tmpl{
Title: *name,
Domain: *domain,
Short: *shortdomain,
Color: colors[rand.Intn(len(colors))],
Argument: arg,
Info: info,
}
if tmpl == nil {
http.Error(w, "Template not initialized", http.StatusInternalServerError)
return
}
// Set Content-Type header for HTML response
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err = tmpl.Execute(w, tmplData)
if err != nil {
if *verbose {
fmt.Printf("Template execution error: %v\n", err)
}
return
}
}
func initialize() error {
var err error
// Open GeoIP City database if path is provided
if *geocity != "" {
geoCityDB, err = geoip2.Open(*geocity)
if err != nil {
return fmt.Errorf("failed to open GeoIP City database: %w", err)
}
fmt.Println("GeoIP City database loaded:", *geocity)
}
// Open GeoIP ASN database if path is provided
if *geoasn != "" {
geoAsnDB, err = geoip2.Open(*geoasn)
if err != nil {
return fmt.Errorf("failed to open GeoIP ASN database: %w", err)
}
fmt.Println("GeoIP ASN database loaded:", *geoasn)
}
// Parse template once at startup
tmpl, err = template.ParseFiles("template.html")
if err != nil {
return fmt.Errorf("failed to parse template: %w", err)
}
fmt.Println("Template loaded: template.html")
return nil
}
func main() {
flag.Parse()
// Initialize databases and template
if err := initialize(); err != nil {
fmt.Println("Initialization error:", err)
return
}
defer func() {
if geoCityDB != nil {
geoCityDB.Close()
}
if geoAsnDB != nil {
geoAsnDB.Close()
}
}()
fmt.Println("Listening on port", *port)
http.HandleFunc("/", handler)
err := http.ListenAndServe(fmt.Sprintf("%s:%d", *bind, *port), nil)
if err != nil {
fmt.Println(err)
}
}