From bacc0de2fd827c64f270baec1366725933cfeefa Mon Sep 17 00:00:00 2001 From: Sergey Kalinin Date: Thu, 4 Dec 2025 11:30:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BF=D1=83=D1=81=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + Dockerfile | 27 ++ README.md | 31 ++ docker-compose.yml | 22 ++ go.mod | 22 ++ go.sum | 55 +++ main.go | 777 +++++++++++++++++++++++++++++++++++++++++++ templates/index.html | 502 ++++++++++++++++++++++++++++ 8 files changed, 1439 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49af8d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.mmdb +.env +errors diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cbffaa1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# FROM golang:alpine3.16 AS build +FROM golang:trixie AS build +RUN apt install gcc g++ make git +WORKDIR /go/src/app +COPY . . +# RUN go get net/netip +RUN go get ./... + +RUN GOOS=linux go build -ldflags="-s -w" -o ./bin/whois-geoip-web ./main.go + +# FROM alpine:3.16 +FROM debian:trixie-slim +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt update -y && apt install -y tzdata ca-certificates + +RUN mkdir -p /usr/local/share/ca-certificates/ && mkdir -p /usr/local/share/geoip/db/ && mkdir -p /usr/local/share/geoip/templates/ +COPY samson.crt /usr/local/share/ca-certificates/root-ca.crt + +RUN update-ca-certificates + +WORKDIR /usr/bin + +COPY --from=build /go/src/app/bin /go/bin +COPY templates/*.html /usr/local/share/geoip/templates + +CMD ["/go/bin/whois-geoip-web"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..da80e01 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# whois-geoip-web + +Вэб-сервис для поиска и вывода информации по IP-адресу. + +Используются локальная БД MaxMind и whois-сервера. + +База данных скачивается при старте с указанной ссылки и потом каждый день обновляется. При этом сравнивается контрольная сумма локальных и удаленных файлов и при совпадении обновления не происходит. + + +Лицензия GPL V3 + +## Использование + +Для получения JSON-данных через API запрос следует делать в виде http://whois.some.domain/api/x.x.x.x + +Для передачи адреса ввиде ссылки на вэб-страницу - http://whois.some.domain/x.x.x.x + +## Настройки + +Настройки передаются через переменные окружения: + + - MMDB_URL - адрес ресурса с базами данных (на каталог где лежат файлы) + - MMDB_LOCAL_PATH - локальный каталог с файлами (/usr/local/share/geoip/db) + - HTML_TEMPLATE_PATH - каталог с html-шаблонами вэб-страницы (/usr/local/share/geoip/templates) + - LISTEN_PORT - порт на котором будет запущен сервис (LISTEN_PORT:-8080) + +## Внешний вид + +![whois-geo-ip-1.png](https://nuk-svk.ru/images/[whois-geo-ip-1.png) + +![whois-geo-ip-2.png](https://nuk-svk.ru/images/[whois-geo-ip-2.png) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9ad3e86 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3' + +services: + whois-geoip-web: + image: $IMAGE_PATH/whois-geoip-web:$RELEASE_VERSION + container_name: whois-geoip-web + environment: + - MMDB_URL=${MMDB_URL:-localhost} + - MMDB_LOCAL_PATH=${MMDB_LOCAL_PATH:-/usr/local/share/geoip/db} + - HTML_TEMPLATE_PATH=${HTML_TEMPLATE_PATH:-/usr/local/share/geoip/templates} + - LISTEN_PORT=${LISTEN_PORT:-8181} + - TZ=Europe/Moscow + restart: always + build: + context: . + logging: + # driver: "syslog" + options: + max-size: "10m" + max-file: "5" + networks: + - default diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7ef07bd --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module geoip + +go 1.24.0 + +toolchain go1.24.10 + +require ( + github.com/gorilla/mux v1.8.1 + github.com/likexian/whois v1.15.6 + github.com/oschwald/geoip2-golang v1.13.0 + golang.org/x/text v0.31.0 +) + +require ( + github.com/go-co-op/gocron v1.37.0 // indirect + github.com/google/uuid v1.4.0 // indirect + github.com/oschwald/maxminddb-golang v1.13.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + go.uber.org/atomic v1.9.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d83206a --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0= +github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/likexian/gokit v0.25.15 h1:QjospM1eXhdMMHwZRpMKKAHY/Wig9wgcREmLtf9NslY= +github.com/likexian/gokit v0.25.15/go.mod h1:S2QisdsxLEHWeD/XI0QMVeggp+jbxYqUxMvSBil7MRg= +github.com/likexian/whois v1.15.6 h1:hizngFHJTNQDlhwhU+FEGyPGxy8bRnf25gHDNrSB4Ag= +github.com/likexian/whois v1.15.6/go.mod h1:vx3kt3sZ4mx4XFgpaNp3GXQCZQIzAoyrUAkRtJwoM2I= +github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI= +github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= +github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= +github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..10e689b --- /dev/null +++ b/main.go @@ -0,0 +1,777 @@ +//----------------------------- +//Distributed under GPL +//Author Sergey Kalinin +//svk@nuk-svk.ru +//------------------------------ + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "html/template" + "io" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/go-co-op/gocron" + "github.com/gorilla/mux" + "github.com/likexian/whois" + "github.com/oschwald/geoip2-golang" + "golang.org/x/text/language" +) + +type GeoData struct { + // IP адрес + IPAddress string `json:"ip_address"` + // Местоположение + Location struct { + Country string `json:"country,omitempty"` + City string `json:"city,omitempty"` + Continent string `json:"continent,omitempty"` + Region string `json:"region,omitempty"` + RegionISO string `json:"region_iso,omitempty"` + Latitude float64 `json:"latitude,omitempty"` + Longitude float64 `json:"longitude,omitempty"` + TimeZone string `json:"time_zone,omitempty"` + PostalCode string `json:"postal_code,omitempty"` + } `json:"location"` + // Данные о сети + Network struct { + ASN uint `json:"asn,omitempty"` + ASOrg string `json:"autonomous_system_organization,omitempty"` + } `json:"network"` + // ISO коды + Codes struct { + CountryISO string `json:"country,omitempty"` + ContinentISO string `json:"continent,omitempty"` + } `json:"codes"` + // Прокси + Security struct { + IsAnonymousProxy bool `json:"is_anonymous_proxy,omitempty"` + IsSatelliteProvider bool `json:"is_satellite_provider,omitempty"` + } `json:"security"` + // Локаль + Metadata struct { + LocaleUsed string `json:"locale_used,omitempty"` + DataSource string `json:"data_source,omitempty"` + } `json:"metadata"` +} + +// TemplateData структура для передачи данных в шаблон +type TemplateData struct { + *GeoData + Error string + JSONData string + WhoIsData string + BaseURL string + DBVersion string +} + +type Config struct { + MMDBURL string + MMDBLocalPath string + HTMLTemplatePath string + ListenPort string +} + +var ( + cityDB *geoip2.Reader + countryDB *geoip2.Reader + asnDB *geoip2.Reader + templates *template.Template + cfg *Config + dbMutex sync.RWMutex + dbFilesVersion string +) + +func main() { + var err error + // Загрузка конфигурации + cfg = LoadConfig() + + // Запускаем скачивание файлов + mmdbDownload("GeoLite2-City.mmdb") + mmdbDownload("GeoLite2-Country.mmdb") + mmdbDownload("GeoLite2-ASN.mmdb") + + // Запускаем планировщик параллельно с основным процессом + go sheduler() + + // Открываем БД + pathGeoLite2City := filepath.Join(cfg.MMDBLocalPath, "GeoLite2-City.mmdb") + log.Println(pathGeoLite2City) + + cityDB, err = geoip2.Open(pathGeoLite2City) + if err != nil { + log.Fatal("Failed to open City database:", err) + } + defer cityDB.Close() + + pathGeoLite2Country := filepath.Join(cfg.MMDBLocalPath, "GeoLite2-Country.mmdb") + countryDB, err = geoip2.Open(pathGeoLite2Country) + if err != nil { + log.Fatal("Failed to open Country database:", err) + } + defer countryDB.Close() + + pathGeoLite2ASN := filepath.Join(cfg.MMDBLocalPath, "GeoLite2-ASN.mmdb") + asnDB, err = geoip2.Open(pathGeoLite2ASN) + if err != nil { + log.Fatal("Failed to open ASN database:", err) + } + defer asnDB.Close() + + dbFilesVersion = getFileModTime(pathGeoLite2City) + + // Загружаем шаблон + templates = template.Must(template.ParseFiles(filepath.Join(cfg.HTMLTemplatePath, "index.html"))) + + // Обработка ссылок + r := mux.NewRouter() + r.HandleFunc("/", homeHandler) + r.HandleFunc("/api/{ip}", apiHandler) + r.HandleFunc("/api/", apiCurrentHandler) + r.HandleFunc("/lookup/{ip}", lookupHandler) + r.HandleFunc("/{ip}", lookupHandler) + + log.Println("Server starting on:", cfg.ListenPort) + log.Fatal(http.ListenAndServe(":"+cfg.ListenPort, r)) +} + +func getFileModTime(filePath string) string { + // Получаем информацию о файле + fileInfo, err := os.Stat(filePath) + if err != nil { + log.Println("Ошибка в getFileModTime:", err) + return "Error" + } + + // Получаем время последнего изменения + modTime := fileInfo.ModTime() + // fmt.Println("Время изменения файла:", modTime) + + // Форматируем вывод + // fmt.Printf("Форматированное время: %s\n", modTime.Format("02/01/2006 15:04:05")) + return modTime.Format("02/01/2006 15:04:05") +} + +// reopenDBs закрывает и заново открывает нужную БД +func reopenDBs(fileName string) { + dbMutex.Lock() + defer dbMutex.Unlock() + + var err error + + switch fileName { + case "GeoLite2-City.mmdb": + if cityDB != nil { + cityDB.Close() + } + // Открываем базы данных заново + pathGeoLite2City := filepath.Join(cfg.MMDBLocalPath, fileName) + cityDB, err = geoip2.Open(pathGeoLite2City) + if err != nil { + log.Printf("Ошибка открытия базы данных %v: %v", fileName, err) + return + } + case "GeoLite2-Country.mmdb": + if countryDB != nil { + countryDB.Close() + } + pathGeoLite2Country := filepath.Join(cfg.MMDBLocalPath, fileName) + countryDB, err = geoip2.Open(pathGeoLite2Country) + if err != nil { + log.Printf("Ошибка открытия базы данных %v: %v", fileName, err) + return + } + case "GeoLite2-ASN.mmdb": + if asnDB != nil { + asnDB.Close() + } + pathGeoLite2ASN := filepath.Join(cfg.MMDBLocalPath, fileName) + asnDB, err = geoip2.Open(pathGeoLite2ASN) + if err != nil { + log.Printf("Ошибка открытия базы данных %v: %v", fileName, err) + return + } + } + log.Println("Открыта база данных:", fileName) + dbFilesVersion = getFileModTime(filepath.Join(cfg.MMDBLocalPath, fileName)) +} + +func homeHandler(w http.ResponseWriter, r *http.Request) { + baseURL := getBaseURL(r) + + // Обрабатываем параметр ip из GET запроса + ipStr := r.URL.Query().Get("ip") + var geoData *GeoData + var errMsg string + var whoisData string + + if ipStr != "" { + ip := net.ParseIP(ipStr) + if ip == nil { + errMsg = "Неверный IP адрес: " + ipStr + } else { + preferredLocale := getPreferredLocale(r) + var err error + geoData, err = getGeoData(ip, preferredLocale) + if err != nil { + errMsg = err.Error() + } + whoisData = getWhoIsInfo(ipStr) + } + } + + data := TemplateData{ + GeoData: geoData, + WhoIsData: whoisData, + Error: errMsg, + BaseURL: baseURL, + DBVersion: dbFilesVersion, + } + + err := templates.ExecuteTemplate(w, "index.html", data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func apiCurrentHandler(w http.ResponseWriter, r *http.Request) { + // Определяем IP клиента + ipStr := getClientIP(r) + ip := net.ParseIP(ipStr) + if ip == nil { + sendJSONError(w, "Unable to determine client IP", http.StatusBadRequest) + return + } + + // Определяем локаль + preferredLocale := getPreferredLocale(r) + geoData, err := getGeoData(ip, preferredLocale) + if err != nil { + sendJSONError(w, err.Error(), http.StatusInternalServerError) + return + } + + sendJSONResponse(w, geoData) +} + +func apiHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + ipStr := vars["ip"] + + // Проверяем IP + ip := net.ParseIP(ipStr) + if ip == nil { + sendJSONError(w, "Invalid IP address", http.StatusBadRequest) + return + } + + // Определяем локаль + preferredLocale := getPreferredLocale(r) + geoData, err := getGeoData(ip, preferredLocale) + if err != nil { + sendJSONError(w, err.Error(), http.StatusInternalServerError) + return + } + + sendJSONResponse(w, geoData) +} + +// Обрабатываем запросы вида /lookup/{ip} и возвращает HTML страницу с результатами +func lookupHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + ipStr := vars["ip"] + baseURL := getBaseURL(r) + + // Проверка IP + ip := net.ParseIP(ipStr) + if ip == nil { + // Если IP невалидный, показываем ошибку на странице + templates.ExecuteTemplate(w, "index.html", TemplateData{ + Error: "Invalid IP address: " + ipStr, + BaseURL: baseURL, + }) + return + } + + // Определяем локаль + preferredLocale := getPreferredLocale(r) + geoData, err := getGeoData(ip, preferredLocale) + if err != nil { + templates.ExecuteTemplate(w, "index.html", TemplateData{ + Error: err.Error(), + BaseURL: baseURL, + }) + return + } + + jsonData, err := json.MarshalIndent(geoData, "", " ") + if err != nil { + jsonData = []byte("{\"error\": \"Failed to generate JSON\"}") + } + + whoisData := getWhoIsInfo(ipStr) + + // Передаем данные в шаблон + templates.ExecuteTemplate(w, "index.html", TemplateData{ + GeoData: geoData, + JSONData: string(jsonData), + WhoIsData: whoisData, + BaseURL: baseURL, + DBVersion: dbFilesVersion, + }) +} + +// Определяем локаль броузера +func getPreferredLocale(r *http.Request) string { + acceptLang := r.Header.Get("Accept-Language") + if acceptLang == "" { + return "en" + } + + tags, _, err := language.ParseAcceptLanguage(acceptLang) + if err != nil || len(tags) == 0 { + return "en" + } + + preferred := tags[0] + base, _ := preferred.Base() + + locale := base.String() + + supportedLocales := []string{"en", "de", "es", "fr", "ja", "pt-BR", "ru", "zh-CN"} + + for _, supported := range supportedLocales { + if locale == supported { + return locale + } + } + + // Возвращем en если ничего другого не найдено + return "en" +} + +func sendJSONResponse(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Pretty print JSON with indentation + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + sendJSONError(w, "Failed to encode JSON response", http.StatusInternalServerError) + return + } + + w.Write(jsonData) +} + +func sendJSONError(w http.ResponseWriter, message string, statusCode int) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(statusCode) + + errorResponse := map[string]interface{}{ + "error": map[string]string{ + "message": message, + "code": http.StatusText(statusCode), + }, + "success": false, + } + + jsonData, _ := json.MarshalIndent(errorResponse, "", " ") + w.Write(jsonData) +} + +// Определяем адрес клиента +func getClientIP(r *http.Request) string { + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + return forwarded + } + if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + return realIP + } + + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// Проверяем доступную локаль +func getLocalizedString(names map[string]string, preferredLocale string) string { + if names == nil { + return "" + } + + // Определяем локаль + if name, exists := names[preferredLocale]; exists && name != "" { + return name + } + + // Переключаем на en + if name, exists := names["en"]; exists { + return name + } + + // Если нет Английского возвращем пустоту + for _, name := range names { + if name != "" { + return name + } + } + + return "" +} + +func hasRegisteredCountryData(registeredCountry struct { + Names map[string]string `maxminddb:"names"` + IsoCode string `maxminddb:"iso_code"` + GeoNameID uint `maxminddb:"geoname_id"` + IsInEuropeanUnion bool `maxminddb:"is_in_european_union"` +}) bool { + return registeredCountry.IsoCode != "" || len(registeredCountry.Names) > 0 +} + +// Получаем данные из БД MaxMind +func getGeoData(ip net.IP, preferredLocale string) (*GeoData, error) { + dbMutex.RLock() + defer dbMutex.RUnlock() + + data := &GeoData{} + data.IPAddress = ip.String() + data.Metadata.LocaleUsed = preferredLocale + + // Получаем данные из БД City + if city, err := cityDB.City(ip); err == nil { + data.Metadata.DataSource = "city" + + // Сперва определяем страну (main country) + data.Location.Country = getLocalizedString(city.Country.Names, preferredLocale) + data.Codes.CountryISO = city.Country.IsoCode + + // Если (main country) пустое то пробуем (registered country) + if data.Location.Country == "" && hasRegisteredCountryData(city.RegisteredCountry) { + data.Location.Country = getLocalizedString(city.RegisteredCountry.Names, preferredLocale) + if data.Codes.CountryISO == "" { + data.Codes.CountryISO = city.RegisteredCountry.IsoCode + } + } + + // Определяем континет (main continent) + data.Location.Continent = getLocalizedString(city.Continent.Names, preferredLocale) + data.Codes.ContinentISO = city.Continent.Code + + // Город и данные местоположения + data.Location.City = getLocalizedString(city.City.Names, preferredLocale) + data.Location.Latitude = city.Location.Latitude + data.Location.Longitude = city.Location.Longitude + data.Location.TimeZone = city.Location.TimeZone + data.Location.PostalCode = city.Postal.Code + data.Security.IsAnonymousProxy = city.Traits.IsAnonymousProxy + data.Security.IsSatelliteProvider = city.Traits.IsSatelliteProvider + + // Регион + if len(city.Subdivisions) > 0 { + subdivision := city.Subdivisions[0] + data.Location.Region = getLocalizedString(subdivision.Names, preferredLocale) + data.Location.RegionISO = subdivision.IsoCode + } + } + + // Определяем страну (fallback for country data) + if data.Location.Country == "" { + if country, err := countryDB.Country(ip); err == nil { + if data.Metadata.DataSource == "" { + data.Metadata.DataSource = "country" + } + + // Сперва определяем страну (main country) + countryName := getLocalizedString(country.Country.Names, preferredLocale) + countryISO := country.Country.IsoCode + + // Если (main country) пустое то пробуем (registered country) + if countryName == "" && hasRegisteredCountryData(country.RegisteredCountry) { + countryName = getLocalizedString(country.RegisteredCountry.Names, preferredLocale) + if countryISO == "" { + countryISO = country.RegisteredCountry.IsoCode + } + } + + // Обновлем данные если что нашли + if countryName != "" { + data.Location.Country = countryName + } + if countryISO != "" { + data.Codes.CountryISO = countryISO + } + + // Континент + continentName := getLocalizedString(country.Continent.Names, preferredLocale) + continentISO := country.Continent.Code + + if continentName != "" { + data.Location.Continent = continentName + } + if continentISO != "" { + data.Codes.ContinentISO = continentISO + } + + // Прокси + data.Security.IsAnonymousProxy = country.Traits.IsAnonymousProxy + data.Security.IsSatelliteProvider = country.Traits.IsSatelliteProvider + } + } + + // Читаем данные из бд ASN + if asn, err := asnDB.ASN(ip); err == nil { + data.Network.ASN = asn.AutonomousSystemNumber + data.Network.ASOrg = asn.AutonomousSystemOrganization + } + + return data, nil +} + +// Определяем базовый URL сайта где запуцщен проект +func getBaseURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + + host := r.Host + // Убираем порт если это стандартный порт + if strings.HasSuffix(host, ":80") && scheme == "http" { + host = strings.TrimSuffix(host, ":80") + } else if strings.HasSuffix(host, ":443") && scheme == "https" { + host = strings.TrimSuffix(host, ":443") + } + + return scheme + "://" + host +} + +// Запрос информации с серверов whois +func getWhoIsInfo(address string) string { + result, err := whois.Whois(address) + + if err != nil { + return "WhoIs request error" + } + + return result +} + +// Загрузка конфигурации +func LoadConfig() *Config { + return &Config{ + MMDBURL: getEnv("MMDB_URL", "localhost"), + MMDBLocalPath: getEnv("MMDB_LOCAL_PATH", "/usr/local/share/geoip/db"), + HTMLTemplatePath: getEnv("HTML_TEMPLATE_PATH", "/usr/local/share/geoip/templates"), + ListenPort: getEnv("LISTEN_PORT", "8080"), + } +} + +// Получение знаячений переменных окружения +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// Запуск задания скачивания файлов +func sheduler() { + // инициализируем объект планировщика + s := gocron.NewScheduler(time.UTC) + // добавляем одну задачу на каждую минуту + s.Cron("30 2 * * *").Do(startDownload) + // s.Cron("*/1 * * * *").Do(startDownload) + // запускаем планировщик с блокировкой текущего потока + s.StartBlocking() +} + +// Отслеживает прогресс загрузки +type WriteCounter struct { + Total uint64 +} + +// Параллельный запуск загрузки БД +func startDownload() { + go mmdbDownload("GeoLite2-City.mmdb") + go mmdbDownload("GeoLite2-Country.mmdb") + go mmdbDownload("GeoLite2-ASN.mmdb") + +} + +func (wc *WriteCounter) Write(p []byte) (int, error) { + n := len(p) + wc.Total += uint64(n) + wc.PrintProgress() + return n, nil +} + +func (wc WriteCounter) PrintProgress() { + fmt.Printf("\rЗагружено %d байт...", wc.Total) +} + +// Проверка целостности файла. +func isValidMMDB(filePath string) bool { + db, err := geoip2.Open(filePath) + if err != nil { + return false + } + db.Close() + return true +} + +// Проверяем контрольную сумму локального файла +func getFileChecksum(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + return hex.EncodeToString(hash.Sum(nil)), nil +} + +// Проверяем контрольную сумму удаленного файла (скачиваемого) +func getRemoteChecksum(url string) string { + resp, err := http.Get(url) + if err != nil { + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "" + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + + // Предполагаем, что файл содержит только хеш + return strings.TrimSpace(string(body)) +} + +func shouldDownload(fileName string) bool { + log.Printf("Проверяем контрольную сумму файла %s", fileName) + fileURL := cfg.MMDBURL + fileName + filePath := filepath.Join(cfg.MMDBLocalPath, fileName) + + // Если файла нет, нужно скачивать + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return true + } + + // Проверяем возраст файла (скачиваем если старше 7 дней) + /* + if info, err := os.Stat(filePath); err == nil { + if time.Since(info.ModTime()) > 7*24*time.Hour { + return true + } + } + + */ + // Проверка по контрольной сумме + if checksumURL := fileURL + ".sha256"; checksumURL != "" { + remoteFileChecksum := getRemoteChecksum(checksumURL) + // fmt.Println("Remote", remoteFileChecksum) + + if remoteFileChecksum != "" { + localFileChecksum, error := getFileChecksum(filePath) + if error == nil { + if localFileChecksum != "" { + fmt.Println(filePath, "Remote:", remoteFileChecksum, "Local:", localFileChecksum) + fmt.Println(remoteFileChecksum) + fmt.Println(localFileChecksum) + if localFileChecksum != remoteFileChecksum { + return false + } + } + } else { + fmt.Println("Error", filePath, error) + return true + } + } + } + + return false +} + +// Загрузка файлов БД с ВЭБ-сервера +func mmdbDownload(fileName string) { + fileURL := cfg.MMDBURL + fileName + filePath := filepath.Join(cfg.MMDBLocalPath, fileName) + + if !shouldDownload(fileName) { + log.Printf("Файл %s актуален, пропускаем загрузку", fileName) + return + } + + // Скачиваем во временный файл + tempPath := filePath + ".tmp" + + log.Println("Загружается файл:", fileURL) + // Создаём файл + file, err := os.Create(tempPath) + if err != nil { + log.Printf("Ошибка создания файла %s: %v", tempPath, err) + return + } + defer file.Close() + + // Загружаем данные с отслеживанием прогресса + counter := &WriteCounter{} + response, err := http.Get(fileURL) + if err != nil { + log.Printf("Ошибка загрузки файла %s: %v", fileURL, err) + return + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + log.Printf("Ошибка загрузки: %s", response.Status) + return + } + + // Копируем через буфер с отслеживанием прогресса + _, err = io.Copy(file, io.TeeReader(response.Body, counter)) + if err != nil { + log.Printf("Ошибка копирования данных: %v", err) + return + } + + log.Println("Загрузка завершена! Файл сохранен:", tempPath) + + // Переоткрываем базы данных после успешной загрузки + // reopenDBs(fileName) + // Проверяем валидность перед заменой + if isValidMMDB(tempPath) { + // Заменяем старый файл + os.Rename(tempPath, filePath) + // Переоткрываем только эту базу + reopenDBs(fileName) + } else { + log.Printf("Скачанный файл %s поврежден, удаляем", fileName) + os.Remove(tempPath) + } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..c90a8fd --- /dev/null +++ b/templates/index.html @@ -0,0 +1,502 @@ + + + + Поиск по GeoIP и Whois + + + + +
+
+

🌍 Поиск по GeoIP и Whois

+
+ + +
+
+ +
+
+

Введите IP адрес

+
+
+ + +
+
+ +
+ +
+ {{if .Error}} +
+ Error: {{.Error}} +
+ {{end}} + {{if .GeoData}} + +

Результат поиска для {{.IPAddress}}

+ +
+ 🌐 Местоположение: +

+
+
Страна:
+
{{if .Location.Country}}{{.Location.Country}}{{else}}N/A{{end}}
+
+
+
Регион:
+
{{if .Location.Region}}{{.Location.Region}}{{else}}N/A{{end}}
+
+
+
Код региона :
+
{{if .Location.RegionISO}}{{.Location.RegionISO}}{{else}}N/A{{end}}
+
+
+
Город:
+
{{if .Location.City}}{{.Location.City}}{{else}}N/A{{end}}
+
+
+
Континент:
+
{{if .Location.Continent}}{{.Location.Continent}}{{else}}N/A{{end}}
+
+
+
Координаты:
+
+ {{if .Location.Latitude}}{{.Location.Latitude}}, {{.Location.Longitude}}{{else}}N/A{{end}} +
+
+
+
Временная зона:
+
{{if .Location.TimeZone}}{{.Location.TimeZone}}{{else}}N/A{{end}}
+
+
+
Почтовый индекс:
+
{{if .Location.PostalCode}}{{.Location.PostalCode}}{{else}}N/A{{end}}
+
+
+ +
+ 🌐 Информация о операторе: +

+
+
ASN:
+
{{if .Network.ASN}}{{.Network.ASN}}{{else}}N/A{{end}}
+
+
+
Организация:
+
{{if .Network.ASOrg}}{{.Network.ASOrg}}{{else}}N/A{{end}}
+
+
+ +
+ 📋 Международный код (ISO): +

+
+
Код страны:
+
{{if .Codes.CountryISO}}{{.Codes.CountryISO}}{{else}}N/A{{end}}
+
+
+
Код континента:
+
{{if .Codes.ContinentISO}}{{.Codes.ContinentISO}}{{else}}N/A{{end}}
+
+
+ +
+ 🔒 Дополнительная информация: +

+
+
Анонимный прокси:
+
+ {{if .Security.IsAnonymousProxy}}Yes{{else}}No{{end}} +
+
+
+
Спутниковый провайдер:
+
+ {{if .Security.IsSatelliteProvider}}Yes{{else}}No{{end}} +
+
+
+ + + {{if .WhoIsData}} +
+ 🌐 Данные Whois: +

+
{{.WhoIsData}}
+
+ {{end}} +
+ {{end}} +
+ +
+

Дополнительные команды:

+

Данный сервис можно использовать для получения данных в JSON: +

+ {{.BaseURL}}/api/x.x.x.x +
+

Для получения информации ввиде html: +

+ {{.BaseURL}}/x.x.x.x +
+
+
+ + +

+

+ + +