threatfeed: Add honeypot log data statistics

This change adds the ability to view various statistics for honeypot log data. This includes views such as unique SSH usernames, unique HTTP paths, unique HTTP host headers, etc.

A new `/logs/{logtype}/{subtype}` route is added for rendering stats.
This commit is contained in:
Ryan Smith
2025-04-07 16:40:18 -07:00
parent 7bc73f6695
commit 540b0b940c
5 changed files with 451 additions and 71 deletions

View File

@@ -1,6 +1,7 @@
package threatfeed package threatfeed
import ( import (
"cmp"
"encoding/json" "encoding/json"
"html/template" "html/template"
"io" "io"
@@ -22,14 +23,52 @@ func handleLogsMain(w http.ResponseWriter, r *http.Request) {
func handleLogs(w http.ResponseWriter, r *http.Request) { func handleLogs(w http.ResponseWriter, r *http.Request) {
switch r.PathValue("logtype") { switch r.PathValue("logtype") {
case "http": case "http":
handleLogHTTP(w) switch r.PathValue("subtype") {
case "":
handleLogHTTP(w)
case "ip":
displayStats(w, httpIPStats{})
case "useragent":
displayStats(w, httpUserAgentStats{})
case "path":
displayStats(w, httpPathStats{})
case "query":
displayStats(w, httpQueryStats{})
case "method":
displayStats(w, httpMethodStats{})
case "host":
displayStats(w, httpHostStats{})
default:
handleNotFound(w, r)
}
case "ssh": case "ssh":
handleLogSSH(w) switch r.PathValue("subtype") {
case "":
handleLogSSH(w)
case "ip":
displayStats(w, sshIPStats{})
case "client":
displayStats(w, sshClientStats{})
case "username":
displayStats(w, sshUsernameStats{})
case "password":
displayStats(w, sshPasswordStats{})
default:
handleNotFound(w, r)
}
default: default:
handleNotFound(w, r) handleNotFound(w, r)
} }
} }
// displayLogErrorPage servers an error page when there is a problem parsing
// log files.
func displayLogErrorPage(w http.ResponseWriter, err error) {
w.WriteHeader(http.StatusInternalServerError)
tmpl := template.Must(template.ParseFS(templates, "templates/logs-error.html", "templates/nav.html"))
_ = tmpl.ExecuteTemplate(w, "logs-error.html", map[string]any{"Error": err, "NavData": "logs"})
}
// handleLogSSH serves the SSH honeypot logs as a web page. It opens the // handleLogSSH serves the SSH honeypot logs as a web page. It opens the
// honeypot log files, parses the data to JSON, and passes the result to an // honeypot log files, parses the data to JSON, and passes the result to an
// HTML template for rendering. // HTML template for rendering.
@@ -37,9 +76,7 @@ func handleLogSSH(w http.ResponseWriter) {
l := logFiles{} l := logFiles{}
reader, err := l.open() reader, err := l.open()
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) displayLogErrorPage(w, err)
tmpl := template.Must(template.ParseFS(templates, "templates/logs-error.html", "templates/nav.html"))
_ = tmpl.ExecuteTemplate(w, "logs-error.html", map[string]any{"Error": err, "NavData": "logs"})
return return
} }
defer l.close() defer l.close()
@@ -59,14 +96,13 @@ func handleLogSSH(w http.ResponseWriter) {
data := make([]Log, 0, maxResults+1) data := make([]Log, 0, maxResults+1)
for d.More() { for d.More() {
var entry Log var entry Log
if err := d.Decode(&entry); err != nil { err := d.Decode(&entry)
if err != nil || entry.EventType != "ssh" {
continue continue
} }
if entry.EventType == "ssh" { data = append(data, entry)
data = append(data, entry) if len(data) > maxResults {
if len(data) > maxResults { data = data[1:]
data = data[1:]
}
} }
} }
slices.Reverse(data) slices.Reverse(data)
@@ -82,9 +118,7 @@ func handleLogHTTP(w http.ResponseWriter) {
l := logFiles{} l := logFiles{}
reader, err := l.open() reader, err := l.open()
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) displayLogErrorPage(w, err)
tmpl := template.Must(template.ParseFS(templates, "templates/logs-error.html", "templates/nav.html"))
_ = tmpl.ExecuteTemplate(w, "logs-error.html", map[string]any{"Error": err, "NavData": "logs"})
return return
} }
defer l.close() defer l.close()
@@ -104,14 +138,13 @@ func handleLogHTTP(w http.ResponseWriter) {
data := make([]Log, 0, maxResults+1) data := make([]Log, 0, maxResults+1)
for d.More() { for d.More() {
var entry Log var entry Log
if err := d.Decode(&entry); err != nil { err := d.Decode(&entry)
if err != nil || entry.EventType != "http" {
continue continue
} }
if entry.EventType == "http" { data = append(data, entry)
data = append(data, entry) if len(data) > maxResults {
if len(data) > maxResults { data = data[1:]
data = data[1:]
}
} }
} }
slices.Reverse(data) slices.Reverse(data)
@@ -120,6 +153,292 @@ func handleLogHTTP(w http.ResponseWriter) {
_ = tmpl.ExecuteTemplate(w, "logs-http.html", map[string]any{"Data": data, "NavData": "logs"}) _ = tmpl.ExecuteTemplate(w, "logs-http.html", map[string]any{"Data": data, "NavData": "logs"})
} }
// displayStats handles the processing and rendering of statistics for a given
// field. It reads honeypot log data, counts the occurrences of `field` and
// displays the results.
func displayStats(w http.ResponseWriter, field fieldCounter) {
l := logFiles{}
reader, err := l.open()
if err != nil {
displayLogErrorPage(w, err)
return
}
defer l.close()
fieldCounts := field.count(reader)
results := []statsResult{}
for k, v := range fieldCounts {
results = append(results, statsResult{Field: k, Count: v})
}
slices.SortFunc(results, func(a, b statsResult) int {
return cmp.Or(
-cmp.Compare(a.Count, b.Count),
cmp.Compare(a.Field, b.Field),
)
})
tmpl := template.Must(template.ParseFS(templates, "templates/logs-stats.html", "templates/nav.html"))
_ = tmpl.ExecuteTemplate(
w,
"logs-stats.html",
map[string]any{
"Data": results,
"Header": field.fieldName(),
"NavData": "logs",
},
)
}
// statsResult holds a specific value for field and its associated count.
type statsResult struct {
Field string
Count int
}
// fieldCounter is an interface that defines methods for counting occurrences
// of specific fields.
type fieldCounter interface {
count(io.Reader) map[string]int
fieldName() string
}
// sshIPStats is the log structure for extracting SSH IP data.
type sshIPStats struct {
EventType string `json:"event_type"`
SourceIP string `json:"source_ip"`
}
func (sshIPStats) fieldName() string { return "Source IP" }
func (sshIPStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry sshIPStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "ssh" {
continue
}
fieldCounts[entry.SourceIP]++
}
return fieldCounts
}
// sshClientStats is the log structure for extracting SSH client data.
type sshClientStats struct {
EventType string `json:"event_type"`
Details struct {
Client string `json:"ssh_client"`
} `json:"event_details"`
}
func (sshClientStats) fieldName() string { return "SSH Client" }
func (sshClientStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry sshClientStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "ssh" {
continue
}
fieldCounts[entry.Details.Client]++
}
return fieldCounts
}
// sshUsernameStats is the log structure for extracting SSH username data.
type sshUsernameStats struct {
EventType string `json:"event_type"`
Details struct {
Username string `json:"username"`
} `json:"event_details"`
}
func (sshUsernameStats) fieldName() string { return "Username" }
func (sshUsernameStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry sshUsernameStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "ssh" {
continue
}
fieldCounts[entry.Details.Username]++
}
return fieldCounts
}
// sshPasswordStats is the log structure for extracting SSH password data.
type sshPasswordStats struct {
EventType string `json:"event_type"`
Details struct {
Password string `json:"password"`
} `json:"event_details"`
}
func (sshPasswordStats) fieldName() string { return "Password" }
func (sshPasswordStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry sshPasswordStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "ssh" {
continue
}
fieldCounts[entry.Details.Password]++
}
return fieldCounts
}
// httpIPStats is the log structure for extracting HTTP IP data.
type httpIPStats struct {
EventType string `json:"event_type"`
SourceIP string `json:"source_ip"`
}
func (httpIPStats) fieldName() string { return "Source IP" }
func (httpIPStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry httpIPStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "http" {
continue
}
fieldCounts[entry.SourceIP]++
}
return fieldCounts
}
// httpUserAgentStats is the log structure for extracting HTTP user-agent data.
type httpUserAgentStats struct {
EventType string `json:"event_type"`
Details struct {
UserAgent string `json:"user_agent"`
} `json:"event_details"`
}
func (httpUserAgentStats) fieldName() string { return "User-Agent" }
func (httpUserAgentStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry httpUserAgentStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "http" {
continue
}
fieldCounts[entry.Details.UserAgent]++
}
return fieldCounts
}
// httpPathStats is the log structure for extracting HTTP path data.
type httpPathStats struct {
EventType string `json:"event_type"`
Details struct {
Path string `json:"path"`
} `json:"event_details"`
}
func (httpPathStats) fieldName() string { return "Path" }
func (httpPathStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry httpPathStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "http" {
continue
}
fieldCounts[entry.Details.Path]++
}
return fieldCounts
}
// httpQueryStats is the log structure for extracting HTTP query string data.
type httpQueryStats struct {
EventType string `json:"event_type"`
Details struct {
Query string `json:"query"`
} `json:"event_details"`
}
func (httpQueryStats) fieldName() string { return "Query String" }
func (httpQueryStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry httpQueryStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "http" {
continue
}
fieldCounts[entry.Details.Query]++
}
return fieldCounts
}
// httpMethodStats is the log structure for extracting HTTP method data.
type httpMethodStats struct {
EventType string `json:"event_type"`
Details struct {
Method string `json:"method"`
} `json:"event_details"`
}
func (httpMethodStats) fieldName() string { return "HTTP Method" }
func (httpMethodStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry httpMethodStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "http" {
continue
}
fieldCounts[entry.Details.Method]++
}
return fieldCounts
}
// httpHostStats is the log structure for extracting HTTP host header data.
type httpHostStats struct {
EventType string `json:"event_type"`
Details struct {
Host string `json:"host"`
} `json:"event_details"`
}
func (httpHostStats) fieldName() string { return "Host Header" }
func (httpHostStats) count(r io.Reader) map[string]int {
fieldCounts := map[string]int{}
d := json.NewDecoder(r)
for d.More() {
var entry httpHostStats
err := d.Decode(&entry)
if err != nil || entry.EventType != "http" {
continue
}
fieldCounts[entry.Details.Host]++
}
return fieldCounts
}
// logFiles represents open honeypot log files and their associate io.Reader. // logFiles represents open honeypot log files and their associate io.Reader.
type logFiles struct { type logFiles struct {
files []*os.File files []*os.File

View File

@@ -81,6 +81,7 @@ func Start(c *config.Config) {
// Honeypot log handlers. // Honeypot log handlers.
mux.HandleFunc("GET /logs", enforcePrivateIP(handleLogsMain)) mux.HandleFunc("GET /logs", enforcePrivateIP(handleLogsMain))
mux.HandleFunc("GET /logs/{logtype}", enforcePrivateIP(handleLogs)) mux.HandleFunc("GET /logs/{logtype}", enforcePrivateIP(handleLogs))
mux.HandleFunc("GET /logs/{logtype}/{subtype}", enforcePrivateIP(handleLogs))
srv := &http.Server{ srv := &http.Server{
Addr: ":" + c.ThreatFeed.Port, Addr: ":" + c.ThreatFeed.Port,

View File

@@ -239,11 +239,11 @@ ul.no-bullets {
} }
ul.log-list { ul.log-list {
font-family: 'Menlo', 'Consolas', 'Monaco', 'Liberation Mono', 'Lucida Console', monospace;
font-size: 1.1rem; font-size: 1.1rem;
line-height: 2.5rem; line-height: 1.75rem;
list-style-type: none; list-style-type: none;
margin-bottom: 0.3rem; padding: 0;
padding-left: 0;
} }
/* ======= */ /* ======= */
@@ -294,29 +294,15 @@ thead th a:active {
color: #9cf; color: #9cf;
text-decoration: none; text-decoration: none;
text-underline-offset: 0.3rem; text-underline-offset: 0.3rem;
padding: 0.75rem 0.8rem; padding: 0.3rem 0;
min-width: 100rem;
border-radius: 8px;
}
.log-list .icon {
margin-right: 0.6rem;
vertical-align: middle;
padding-bottom: 0.3rem;
color: #aab;
} }
.log-list a:hover { .log-list a:hover {
outline: 2px solid #7de; text-decoration: underline;
color: #fb0;
}
.log-list a:hover .icon {
color: #fff;
} }
.log-list a:active { .log-list a:active {
background-color: #112; text-decoration: none;
} }
/* ===== */ /* ===== */
@@ -704,46 +690,69 @@ table.live-logs td.tooltip:hover pre.tooltip-content {
color: #ff5; color: #ff5;
} }
/* ===== */
/* Stats */
/* ===== */
/* Count */
.logs-stats tbody td:nth-child(1) {
color: #ee6;
text-align: right;
}
.logs-stats tbody tr:nth-child(odd) td:nth-child(1) {
color: #ff5;
}
/* Value */
.logs-stats tbody td:nth-child(2) {
color: #1ee;
overflow-wrap: anywhere;
}
.logs-stats tbody tr:nth-child(odd) td:nth-child(2) {
color: #5ff;
}
/* ======== */ /* ======== */
/* SSH Logs */ /* SSH Logs */
/* ======== */ /* ======== */
/* Time */ /* Time */
.logs-ssh tbody td:nth-child(1) { .logs-ssh tbody td:nth-child(1) {
color: #8b949e; color: #778;
white-space: nowrap; white-space: nowrap;
} }
.logs-ssh tbody tr:nth-child(odd) td:nth-child(1) { .logs-ssh tbody tr:nth-child(odd) td:nth-child(1) {
color: #ccc; color: #99a;
} }
/* Source IP */ /* Source IP */
.logs-ssh tbody td:nth-child(2) { .logs-ssh tbody td:nth-child(2) {
color: #48e3ff; color: #1ee;
} }
.logs-ssh tbody tr:nth-child(odd) td:nth-child(2) { .logs-ssh tbody tr:nth-child(odd) td:nth-child(2) {
color: #aaffff; color: #5ff;
} }
/* Username */ /* Username */
.logs-ssh tbody td:nth-child(3) { .logs-ssh tbody td:nth-child(3) {
color: #b8c1ff; color: #ed7;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.logs-ssh tbody tr:nth-child(odd) td:nth-child(3) { .logs-ssh tbody tr:nth-child(odd) td:nth-child(3) {
color: #c8e1ff; color: #ff5;
} }
/* Password */ /* Password */
.logs-ssh tbody td:nth-child(4) { .logs-ssh tbody td:nth-child(4) {
color: #ffff55; color: #8bf;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.logs-ssh tbody tr:nth-child(odd) td:nth-child(4) { .logs-ssh tbody tr:nth-child(odd) td:nth-child(4) {
color: #eedc82; color: #69e;
} }
/* ========= */ /* ========= */
@@ -751,40 +760,40 @@ table.live-logs td.tooltip:hover pre.tooltip-content {
/* ========= */ /* ========= */
/* Time */ /* Time */
.logs-http tbody td:nth-child(1) { .logs-http tbody td:nth-child(1) {
color: #8b949e; color: #778;
white-space: nowrap; white-space: nowrap;
} }
.logs-http tbody tr:nth-child(odd) td:nth-child(1) { .logs-http tbody tr:nth-child(odd) td:nth-child(1) {
color: #ccc; color: #99a;
} }
/* Source IP */ /* Source IP */
.logs-http tbody td:nth-child(2) { .logs-http tbody td:nth-child(2) {
color: #48e3ff; color: #1ee;
} }
.logs-http tbody tr:nth-child(odd) td:nth-child(2) { .logs-http tbody tr:nth-child(odd) td:nth-child(2) {
color: #aaffff; color: #5ff;
} }
/* Method */ /* Method */
.logs-http tbody td:nth-child(3) { .logs-http tbody td:nth-child(3) {
color: #b8c1ff; color: #8bf;
} }
.logs-http tbody tr:nth-child(odd) td:nth-child(3) { .logs-http tbody tr:nth-child(odd) td:nth-child(3) {
color: #c8e1ff; color: #69e;
} }
/* Path */ /* Path */
.logs-http tbody td:nth-child(4) { .logs-http tbody td:nth-child(4) {
color: #ffff55; color: #ed7;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.logs-http tbody tr:nth-child(odd) td:nth-child(4) { .logs-http tbody tr:nth-child(odd) td:nth-child(4) {
color: #eedc82; color: #ff5;
} }
/* ============= */ /* ============= */
@@ -965,6 +974,10 @@ table.live-logs td.tooltip:hover pre.tooltip-content {
.live-logs td:nth-child(3) { .live-logs td:nth-child(3) {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
pre.tooltip-content {
top: 0;
}
} }
@media (max-width: 550px) { @media (max-width: 550px) {

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deceptifeed</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="full-width">
<header>
{{template "nav" .NavData}}
</header>
<main class="full-width">
{{if .Data}}
<table id="stats" class="logs logs-stats">
<thead>
<tr>
<th onclick="sortTable(0)">Count
<th onclick="sortTable(1)">{{.Header}}
</tr>
</thead>
<tbody>
{{range .Data}}<tr><td>{{.Count}}<td>{{.Field}}</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="no-results">No log data found</p>
{{end}}
</main>
<script>
function applyNumberSeparator() {
// Format 'Count' with a thousands separator based on user's locale.
const numberFormat = new Intl.NumberFormat();
document.querySelectorAll("#stats tbody tr").forEach(row => {
const observationCount = parseInt(row.cells[0].textContent, 10);
if (!isNaN(observationCount)) {
row.cells[0].textContent = numberFormat.format(observationCount);
}
});
}
applyNumberSeparator();
</script>
</body>
</html>

View File

@@ -14,21 +14,21 @@
<article> <article>
<h2>Honeypot Logs</h2> <h2>Honeypot Logs</h2>
<ul class="log-list"> <ul class="log-list">
<li> <li><a href="/logs/ssh">SSH Logs</a></li>
<a href="/logs/ssh"> <li><a href="/logs/http">HTTP Logs</a></li>
<svg class="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="28" height="28" fill="currentColor"> </ul>
<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM12 15H18V17H12V15ZM8.66685 12L5.83842 9.17157L7.25264 7.75736L11.4953 12L7.25264 16.2426L5.83842 14.8284L8.66685 12Z"/> <ul class="log-list">
</svg>SSH Logs <li><a href="/logs/ssh/ip">Unique: SSH Source IPs</a></li>
</a> <li><a href="/logs/ssh/username">Unique: SSH Usernames</a></li>
</li> <li><a href="/logs/ssh/password">Unique: SSH Passwords</a></li>
<li> <li><a href="/logs/ssh/client">Unique: SSH Clients</a></li>
<a href="/logs/http"> <li><a href="/logs/http/ip">Unique: HTTP Source IPs</a></li>
<svg class="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="28" height="28" fill="currentColor"> <li><a href="/logs/http/useragent">Unique: HTTP User-Agents</a></li>
<path d="M6.23509 6.45329C4.85101 7.89148 4 9.84636 4 12C4 16.4183 7.58172 20 12 20C13.0808 20 14.1116 19.7857 15.0521 19.3972C15.1671 18.6467 14.9148 17.9266 14.8116 17.6746C14.582 17.115 13.8241 16.1582 12.5589 14.8308C12.2212 14.4758 12.2429 14.2035 12.3636 13.3943L12.3775 13.3029C12.4595 12.7486 12.5971 12.4209 14.4622 12.1248C15.4097 11.9746 15.6589 12.3533 16.0043 12.8777C16.0425 12.9358 16.0807 12.9928 16.1198 13.0499C16.4479 13.5297 16.691 13.6394 17.0582 13.8064C17.2227 13.881 17.428 13.9751 17.7031 14.1314C18.3551 14.504 18.3551 14.9247 18.3551 15.8472V15.9518C18.3551 16.3434 18.3168 16.6872 18.2566 16.9859C19.3478 15.6185 20 13.8854 20 12C20 8.70089 18.003 5.8682 15.1519 4.64482C14.5987 5.01813 13.8398 5.54726 13.575 5.91C13.4396 6.09538 13.2482 7.04166 12.6257 7.11976C12.4626 7.14023 12.2438 7.12589 12.012 7.11097C11.3905 7.07058 10.5402 7.01606 10.268 7.75495C10.0952 8.2232 10.0648 9.49445 10.6239 10.1543C10.7134 10.2597 10.7307 10.4547 10.6699 10.6735C10.59 10.9608 10.4286 11.1356 10.3783 11.1717C10.2819 11.1163 10.0896 10.8931 9.95938 10.7412C9.64554 10.3765 9.25405 9.92233 8.74797 9.78176C8.56395 9.73083 8.36166 9.68867 8.16548 9.64736C7.6164 9.53227 6.99443 9.40134 6.84992 9.09302C6.74442 8.8672 6.74488 8.55621 6.74529 8.22764C6.74529 7.8112 6.74529 7.34029 6.54129 6.88256C6.46246 6.70541 6.35689 6.56446 6.23509 6.45329ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22Z"/> <li><a href="/logs/http/path">Unique: HTTP Paths</a></li>
</svg>HTTP Logs <li><a href="/logs/http/query">Unique: HTTP Queries</a></li>
</a> <li><a href="/logs/http/method">Unique: HTTP Methods</a></li>
</li> <li><a href="/logs/http/host">Unique: HTTP Host Headers</a></li>
</ul> </ul>
</article> </article>
</main> </main>
</body> </body>