geoscan/frontend.go
2026-06-30 15:49:12 +03:00

769 lines
21 KiB
Go
Executable File

package main
import (
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"os"
"strings"
_ "github.com/go-sql-driver/mysql"
_ "github.com/mattn/go-sqlite3"
)
var port = "9000"
var (
username string = "meer"
password string = "Satori"
)
type BannerStore interface {
QueryHTTPBanners(listeningOnly, hasTitle, interesting bool, search string) ([]Host, error)
QuerySSHBanners(search string, listeningOnly bool) ([]SSHHost, error)
QueryTelnetBanners(search string, listeningOnly, hasBanner bool) ([]TelnetHost, error)
QueryHTTPBannerByIP(ip string) (*HostDetail, error)
}
type SQLBannerStore struct {
db *sql.DB
}
type HostDetail struct {
Ip, Title, Resp, Headers, Server, ContentType, ContentLength,
Redirect, MetaTags, BodySnippet string
IsListening bool
}
type TelnetHost struct {
Ip, Firstline string
IsListening bool
}
type SSHHost struct {
Ip, Banner string
IsListening bool
}
var db *sql.DB
var Store BannerStore
type DBConfig struct {
DbDriver string `json:"dbdriver"`
Username string `json:"username"`
Password string `json:"password"`
DBName string `json:"dbname"`
}
type Webconfig struct {
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
}
type Config struct {
ZmapFile string `json:"zmapfile"`
Channelfeeders int `json:"channelfeeders"`
DB DBConfig `json:"db"`
Web Webconfig `json:"web"`
}
func loadConfig(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var cfg Config
if err := json.NewDecoder(f).Decode(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
type Host struct {
Ip, Title, Resp, Server, ContentType, ContentLength, Redirect string
IsListening bool
}
func sqlInitMySQLFrontend(dsn string) (*SQLBannerStore, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
return &SQLBannerStore{db: db}, nil
}
func sqlInitSQLiteFrontend(dbname string) (*SQLBannerStore, error) {
db, err := sql.Open("sqlite3", dbname)
if err != nil {
return nil, err
}
return &SQLBannerStore{db: db}, nil
}
func (s *SQLBannerStore) QueryHTTPBanners(listeningOnly, hasTitle, interesting bool, search string) ([]Host, error) {
q := `SELECT Ip, IsListening, httptitle, Httpresp, ServerHeader, ContentType, ContentLength, Redirect
FROM httpbanners WHERE 1=1`
args := []interface{}{}
if listeningOnly {
q += " AND IsListening=1"
}
if hasTitle {
q += " AND httptitle IS NOT NULL AND httptitle<>''"
}
if search != "" {
q += " AND (Ip LIKE ? OR httptitle LIKE ? OR ServerHeader LIKE ? OR ContentType LIKE ? OR Redirect LIKE ?)"
like := "%" + search + "%"
args = append(args, like, like, like, like, like)
}
if interesting {
q += buildExclusionSQL("excluded_titles.txt")
}
q += " ORDER BY Ip"
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var hosts []Host
for rows.Next() {
var h Host
if err := rows.Scan(&h.Ip, &h.IsListening, &h.Title, &h.Resp, &h.Server,
&h.ContentType, &h.ContentLength, &h.Redirect); err != nil {
continue
}
hosts = append(hosts, h)
}
return hosts, nil
}
func (s *SQLBannerStore) QueryTelnetBanners(search string, listeningOnly, hasBanner bool) ([]TelnetHost, error) {
q := "SELECT Ip, IsListening, firstline FROM telnetbanners WHERE 1=1"
args := []interface{}{}
if listeningOnly {
q += " AND IsListening=1"
}
if hasBanner {
q += " AND firstline IS NOT NULL AND firstline<>''"
}
if search != "" {
q += " AND (Ip LIKE ? OR firstline LIKE ?)"
like := "%" + search + "%"
args = append(args, like, like)
}
q += " ORDER BY Ip"
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var hosts []TelnetHost
for rows.Next() {
var h TelnetHost
if err := rows.Scan(&h.Ip, &h.IsListening, &h.Firstline); err != nil {
continue
}
hosts = append(hosts, h)
}
return hosts, nil
}
func (s *SQLBannerStore) QueryHTTPBannerByIP(ip string) (*HostDetail, error) {
row := s.db.QueryRow(`SELECT Ip, httptitle, Httpresp, Headers,
ServerHeader, ContentType, ContentLength,
Redirect, MetaTags, BodySnippet, IsListening
FROM httpbanners WHERE Ip=?`, ip)
var h HostDetail
if err := row.Scan(&h.Ip, &h.Title, &h.Resp, &h.Headers,
&h.Server, &h.ContentType, &h.ContentLength,
&h.Redirect, &h.MetaTags, &h.BodySnippet, &h.IsListening); err != nil {
return nil, err
}
return &h, nil
}
func (s *SQLBannerStore) QuerySSHBanners(search string, listeningOnly bool) ([]SSHHost, error) {
q := "SELECT Ip, IsListening, firstline FROM sshbanners WHERE 1=1"
args := []interface{}{}
if listeningOnly {
q += " AND IsListening=1"
}
if search != "" {
q += " AND (Ip LIKE ? OR firstline LIKE ?)"
like := "%" + search + "%"
args = append(args, like, like)
}
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var hosts []SSHHost
for rows.Next() {
var h SSHHost
if err := rows.Scan(&h.Ip, &h.IsListening, &h.Banner); err != nil {
continue
}
hosts = append(hosts, h)
}
return hosts, nil
}
func main() {
cfg, err := loadConfig("config.json")
if err != nil {
log.Fatal(err)
}
switch cfg.DB.DbDriver {
case "mysql":
dsn := fmt.Sprintf("%s:%s@tcp(127.0.0.1:3306)/%s",
cfg.DB.Username, cfg.DB.Password, cfg.DB.DBName)
Store, err = sqlInitMySQLFrontend(dsn)
case "sqlite":
Store, err = sqlInitSQLiteFrontend(cfg.DB.DBName)
default:
log.Fatalf("Unsupported driver: %s", cfg.DB.DbDriver)
}
if err != nil {
log.Fatal(err)
}
fmt.Println("DB opened")
http.HandleFunc("/", home)
http.HandleFunc("/http", listHTTP)
http.HandleFunc("/telnet", listTelnet)
http.HandleFunc("/ssh", listSSH)
http.HandleFunc("/ssh/", sshDetail)
http.HandleFunc("/http/", httpDetail)
http.HandleFunc("/telnet/", telnetDetail)
http.HandleFunc("/admin", basicAuth(adminPanel, cfg.Web.Username, cfg.Web.Password))
fmt.Printf("Frontend running at http://localhost:%d\n", cfg.Web.Port)
http.ListenAndServe(fmt.Sprintf(":%d", cfg.Web.Port), nil)
}
func home(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<html><head><title>Pixalia scan</title>`+css+`</head><body>
<nav>
<a href="/http">HTTP Result data</a>
<a href="/telnet">Telnet Result</a>
<a href="/ssh">SSH Results</a>
</nav>
<h1>Welcome to Pixalia scan!</h1>
<h2>Select a tab above to view scan results from Pixalia scan.</h2>
<p>for now, only telnet and http and SSH are available, more will be added soon</p>
</body></html>`)
}
func listHTTP(w http.ResponseWriter, r *http.Request) {
listening := r.URL.Query().Get("listening") == "1"
hasTitle := r.URL.Query().Get("title") == "1"
interesting := r.URL.Query().Get("interesting") == "1"
search := r.URL.Query().Get("q")
// Ask the BannerStore for results
hosts, err := Store.QueryHTTPBanners(listening, hasTitle, interesting, search)
if err != nil {
http.Error(w, "DB error: "+err.Error(), 500)
return
}
tmpl := template.Must(template.New("http").Parse(`
<html><head><title>HTTP Results</title>` + css + `</head><body>
<nav>
<a href="/http">HTTP Results</a>
<a href="/telnet">Telnet Results</a>
<a href="/ssh">SSH Results</a>
</nav>
<h2>HTTP Banners</h2>
<form method="get">
<label><input type="checkbox" name="listening" value="1" {{if eq .FilterListening "1"}}checked{{end}}> Listening only</label>
<label><input type="checkbox" name="title" value="1" {{if eq .FilterTitle "1"}}checked{{end}}> Has title</label>
<label><input type="checkbox" name="interesting" value="1" {{if eq .FilterInteresting "1"}}checked{{end}}> Hide boring results</label>
<input type="text" name="q" value="{{.Search}}" placeholder="Search IP, title, server, content-type, redirect">
<input type="submit" value="Filter">
</form>
{{if .HasResults}}
<table>
<tr>
<th>IP</th><th>Listening</th><th>Title</th><th>Server</th><th>Content-Type</th>
<th>Response</th><th>Length</th><th>Redirect</th>
</tr>
{{range .Hosts}}
<tr>
<td><a href="/http/{{.Ip}}">{{.Ip}}</a></td>
<td>{{.IsListening}}</td>
<td>{{.Title}}</td>
<td>{{.Server}}</td>
<td>{{.ContentType}}</td>
<td>{{.Resp}}</td>
<td>{{.ContentLength}}</td>
<td>{{.Redirect}}</td>
</tr>
{{end}}
</table>
{{else}}
<div class="error-box">
<h3>No results found :(</h3>
<p>Try adjusting your filters or search terms.</p>
</div>
{{end}}
</body></html>`))
tmpl.Execute(w, map[string]interface{}{
"Hosts": hosts,
"FilterListening": r.URL.Query().Get("listening"),
"FilterInteresting": r.URL.Query().Get("interesting"),
"FilterTitle": r.URL.Query().Get("title"),
"Search": search,
"HasResults": len(hosts) > 0,
})
}
func listSSH(w http.ResponseWriter, r *http.Request) {
listening := r.URL.Query().Get("listening") == "1"
search := r.URL.Query().Get("q")
hosts, err := Store.QuerySSHBanners(search, listening)
if err != nil {
http.Error(w, "DB error: "+err.Error(), 500)
return
}
tmpl := template.Must(template.New("http").Parse(`
<html><head><title>SSH Results</title>` + css + `</head><body>
<nav>
<a href="/http">HTTP Results</a>
<a href="/telnet">Telnet Results</a>
<a href="/ssh">SSH Results</a>
</nav>
<h2>SSH Banners</h2>
<form method="get">
<label><input type="checkbox" name="listening" value="1" {{if eq .FilterListening "1"}}checked{{end}}> Listening only</label>
<label><input type="checkbox" name="title" value="1" {{if eq .FilterTitle "1"}}checked{{end}}> Has title</label>
<input type="text" name="q" value="{{.Search}}" placeholder="Search IP or title">
<input type="submit" value="Filter">
</form>
{{if .HasResults}}
<table>
<tr><th>IP</th><th>Listening</th><th>Title</th></tr>
{{range .Hosts}}
<tr>
<td><a href="/ssh/{{.Ip}}">{{.Ip}}</a></td>
<td>{{.IsListening}}</td>
<td>{{.Banner}}</td>
</tr>
{{end}}
</table>
{{else}}
<div class="error-box">
<h3>No results found :(</h3>
<p>Try adjusting your filters or search terms.</p>
<p>dont worry, we will eventually update our database for your desired results.</p>
</div>
{{end}}
</body></html>`))
tmpl.Execute(w, map[string]interface{}{
"Hosts": hosts,
"FilterListening": r.URL.Query().Get("listening"),
"FilterTitle": r.URL.Query().Get("title"),
"Search": search,
"HasResults": len(hosts) > 0,
})
}
func sshDetail(w http.ResponseWriter, r *http.Request) {
}
func httpDetail(w http.ResponseWriter, r *http.Request) {
ip := strings.TrimPrefix(r.URL.Path, "/http/")
h, err := Store.QueryHTTPBannerByIP(ip)
if err != nil {
http.Error(w, "DB error: "+err.Error(), 500)
}
tmpl := template.Must(template.New("httpDetail").Parse(`
<html><head><title>{{.Title}}</title>` + css + `</head><body>
<nav>
<a href="/http">HTTP Results</a>
<a href="/telnet">Telnet Results</a>
<a href="/ssh">SSH Results</a>
</nav>
<h2>HTTP Host {{.Ip}}</h2>
<p><b>Title:</b> {{.Title}}</p>
<p><b>Response:</b> {{.Resp}}</p>
<p><b>Server:</b> {{.Server}}</p>
<p><b>Content-Type:</b> {{.ContentType}}</p>
<p><b>Content-Length:</b> {{.ContentLength}}</p>
<p><b>Redirect:</b> {{.Redirect}}</p>
{{ if .MetaTags}}
<h3>Meta Tags</h3>
<pre>{{.MetaTags}}</pre>
{{ end }}
<h3>Raw Headers</h3>
<pre>{{.Headers}}</pre>
<h3>Body Snippet</h3>
<pre>{{.BodySnippet}}</pre>
<p><a href="http://{{.Ip}}" target="_blank">Visit Site</a></p>
</body></html>`))
tmpl.Execute(w, map[string]interface{}{
"Ip": h.Ip,
"Title": h.Title,
"Resp": h.Resp,
"Headers": h.Headers,
"Server": h.Server,
"ContentType": h.ContentType,
"ContentLength": h.ContentLength,
"Redirect": h.Redirect,
"MetaTags": h.MetaTags,
"BodySnippet": h.BodySnippet,
})
}
func listTelnet(w http.ResponseWriter, r *http.Request) {
listening := r.URL.Query().Get("listening") == "1"
banner := r.URL.Query().Get("banner") == "1"
search := r.URL.Query().Get("q")
hosts, err := Store.QueryTelnetBanners(search, listening, banner)
if err != nil {
http.Error(w, "DB error: "+err.Error(), 500)
return
}
tmpl := template.Must(template.New("telnet").Parse(`
<html><head><title>Telnet Results</title>` + css + `</head><body>
<nav>
<a href="/http">HTTP Results</a>
<a href="/telnet">Telnet Results</a>
</nav>
<h2>Telnet Banners</h2>
<form method="get">
<label><input type="checkbox" name="listening" value="1" {{if eq .FilterListening "1"}}checked{{end}}> Listening only</label>
<label><input type="checkbox" name="banner" value="1" {{if eq .FilterBanner "1"}}checked{{end}}> Has banner</label>
<input type="submit" value="Filter">
</form>
<input type="text" id="search" placeholder="Search IP or title">
<button onclick="filterTable()">Search</button>
<table id="telnetTable">
<tr><th>IP</th><th>Listening</th><th>Firstline</th></tr>
{{range .Hosts}}
<tr>
<td><a href="/telnet/{{.Ip}}">{{.Ip}}</a></td>
<td>{{.IsListening}}</td>
<td>{{.Firstline}}</td>
</tr>
{{end}}
</table>
<script>
function filterTable() {
let input = document.getElementById("search").value.toLowerCase();
let rows = document.querySelectorAll("#telnetTable tr");
for (let i=1; i<rows.length; i++) {
let text = rows[i].innerText.toLowerCase();
rows[i].style.display = text.includes(input) ? "" : "none";
}
}
document.addEventListener("DOMContentLoaded", function() {
let tables = document.querySelectorAll("table");
tables.forEach(function(table) {
let rows = table.querySelectorAll("tr");
rows.forEach(function(row) {
// Get all cell text in the row
let text = row.innerText.trim();
if (text === "") {
row.remove(); // kill empty row
}
});
});
});
</script>
</body></html>`))
tmpl.Execute(w, map[string]interface{}{
"Hosts": hosts,
"FilterListening": r.URL.Query().Get("listening"),
"FilterBanner": r.URL.Query().Get("banner"),
})
}
func telnetDetail(w http.ResponseWriter, r *http.Request) {
ip := strings.TrimPrefix(r.URL.Path, "/telnet/")
row := db.QueryRow("SELECT Ip, fullbanner FROM telnetbanners WHERE Ip=?", ip)
var ipAddr, banner string
row.Scan(&ipAddr, &banner)
tmpl := template.Must(template.New("telnetDetail").Parse(`
<html><head><title>Telnet Host {{.Ip}}</title>` + css + `</head><body>
<nav>
<a href="/http">HTTP Results</a>
<a href="/telnet">Telnet Results</a>
</nav>
<h2>Telnet Host {{.Ip}}</h2>
<pre>{{.Banner}}</pre>
</body></html>`))
tmpl.Execute(w, map[string]interface{}{
"Ip": ipAddr,
"Banner": banner,
})
}
const css = `<style>
body {
font-family: "Segoe UI", Arial, sans-serif;
background-color: #121212;
color: #e0e0e0;
margin: 0;
padding: 0;
}
.error-box {
margin: 20px auto;
padding: 20px;
max-width: 600px;
background: #1e1e1e;
border: 1px solid #444;
border-radius: 6px;
text-align: center;
color: #ff6b6b;
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
}
.error-box h3 {
margin: 0 0 10px;
}
nav {
background: #1f1f1f;
padding: 12px 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.6);
}
nav a {
color: #ffffff;
margin-right: 20px;
text-decoration: none;
font-weight: 500;
}
nav a:hover {
color: #4dabf7;
}
h1, h2 {
margin: 20px;
color: #ffffff;
}
form {
margin: 20px;
}
label {
margin-right: 15px;
}
input[type="submit"] {
background: #4dabf7;
border: none;
padding: 6px 12px;
color: #fff;
cursor: pointer;
border-radius: 4px;
}
input[type="submit"]:hover {
background: #339af0;
}
input[type="text"] {
padding: 8px;
margin: 20px;
width: 300px;
border: 1px solid #444;
border-radius: 4px;
background: #1e1e1e;
color: #e0e0e0;
}
table {
width: 95%;
margin: 20px auto;
border-collapse: collapse;
background: #1e1e1e;
border-radius: 6px;
overflow: hidden;
}
th {
background: #2c2c2c;
color: #ffffff;
text-align: left;
padding: 10px;
}
td {
padding: 10px;
border-bottom: 1px solid #333;
}
tr:hover {
background-color: #2a2a2a;
}
a {
color: #4dabf7;
}
a:hover {
text-decoration: underline;
}
pre {
background: #1e1e1e;
padding: 15px;
border: 1px solid #333;
border-radius: 6px;
overflow-x: auto;
margin: 20px;
font-family: Consolas, monospace;
}
button, input[type="submit"] {
background: #4dabf7;
border: none;
padding: 8px 16px;
color: #fff;
cursor: pointer;
border-radius: 4px;
font-weight: 500;
transition: all 0.2s ease-in-out;
box-shadow: 0 2px 4px rgba(0,0,0,0.4);
}
button:hover, input[type="submit"]:hover {
background: #339af0;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.6);
}
button:active, input[type="submit"]:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0,0,0,0.4);
}
</style>`
// Admin panel handler
func adminPanel(w http.ResponseWriter, r *http.Request) {
fmt.Printf("admin login from %s, X-real-IP header: %s\n", r.RemoteAddr, r.Header.Get("X-Real-IP"))
if r.Method == http.MethodPost {
// Delete IP
if ip := r.FormValue("delete_ip"); ip != "" {
_, err := db.Exec("DELETE FROM httpbanners WHERE Ip = ?", ip)
fmt.Println("[admin panel] deleting IP: ", ip)
if err != nil {
http.Error(w, "Error deleting IP: "+err.Error(), http.StatusInternalServerError)
return
}
}
// Add excluded title
if title := r.FormValue("add_title"); title != "" {
f, err := os.OpenFile("excluded_titles.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
http.Error(w, "Error writing title: "+err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
f.WriteString(title + "\n")
}
if remove := r.FormValue("remove_title"); remove != "" {
data, err := os.ReadFile("excluded_titles.txt")
if err == nil {
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
var newLines []string
for _, l := range lines {
if strings.TrimSpace(l) != remove {
newLines = append(newLines, l)
}
}
os.WriteFile("excluded_titles.txt", []byte(strings.Join(newLines, "\n")+"\n"), 0644)
}
}
}
var titles []string
if data, err := os.ReadFile("excluded_titles.txt"); err == nil {
titles = strings.Split(strings.TrimSpace(string(data)), "\n")
}
tmpl := template.Must(template.New("admin").Parse(`
<html><head><title>Admin Panel</title>` + css + `</head><body>
<h2>Admin Panel</h2>
<form method="post">
<label>Delete IP:</label>
<input type="text" name="delete_ip" placeholder="Enter IP to delete">
<input type="submit" value="Delete">
</form>
<form method="post">
<label>Add Excluded Title:</label>
<input type="text" name="add_title" placeholder="Enter title phrase">
<input type="submit" value="Add">
</form>
<h3>Current Excluded Titles</h3>
<ul>
{{range .}}
<li>{{.}}
<form method="post" style="display:inline;">
<input type="hidden" name="remove_title" value="{{.}}">
<input type="submit" value="Remove">
</form>
</li>
{{end}}
</ul>
</body></html>`))
tmpl.Execute(w, titles)
}
func buildExclusionSQL(filename string) string {
data, _ := os.ReadFile(filename)
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
var parts []string
for _, l := range lines {
l = strings.TrimSpace(l)
if l == "" {
continue
}
parts = append(parts, fmt.Sprintf("httptitle NOT LIKE '%%%s%%'", l))
}
if len(parts) == 0 {
return ""
}
return " AND (" + strings.Join(parts, " AND ") + ")"
}
// taken from https://www.alexedwards.net/blog/basic-authentication-in-go
func basicAuth(next http.HandlerFunc, user, pass string) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if ok {
// Calculate SHA-256 hashes for the provided and expected
// usernames and passwords.
usernameHash := sha256.Sum256([]byte(username))
passwordHash := sha256.Sum256([]byte(password))
expectedUsernameHash := sha256.Sum256([]byte(user))
expectedPasswordHash := sha256.Sum256([]byte(pass))
usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1)
passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1)
// If the username and password are correct, then call
// the next handler in the chain. Make sure to return
// afterwards, so that none of the code below is run.
if usernameMatch && passwordMatch {
next.ServeHTTP(w, r)
return
}
}
// If the Authentication header is not present, is invalid, or the
// username or password is wrong, then set a WWW-Authenticate
// header to inform the client that we expect them to use basic
// authentication and send a 401 Unauthorized response.
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
}