Add hex2pcap
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pcap-web-tools/internal/hexutil"
|
||||
"pcap-web-tools/internal/inet"
|
||||
"pcap-web-tools/internal/pcap"
|
||||
)
|
||||
|
||||
const maxFormBytes = 2 << 20
|
||||
|
||||
type linkTypeOption struct {
|
||||
Value string
|
||||
Label string
|
||||
}
|
||||
|
||||
var linkTypeOptions = []linkTypeOption{
|
||||
{"auto", "Auto (Ethernet, or Raw IP when layer 2 is empty and layer 3 is set)"},
|
||||
{"1", "Ethernet (1)"},
|
||||
{"101", "Raw IP (101)"},
|
||||
{"113", "Linux cooked SLL (113)"},
|
||||
{"276", "Linux cooked SLL2 (276)"},
|
||||
{"105", "IEEE 802.11 (105)"},
|
||||
{"custom", "Custom…"},
|
||||
}
|
||||
|
||||
type hex2pcapForm struct {
|
||||
L2, L3, L7 string
|
||||
Fix bool
|
||||
LinkType string
|
||||
CustomLinkType string
|
||||
Filename string
|
||||
}
|
||||
|
||||
var hex2pcapExample = hex2pcapForm{
|
||||
L2: "00 11 22 33 44 55 66 77 88 99 aa bb 08 00",
|
||||
L3: "45 00 00 00 00 01 40 00 40 11 00 00 c0 a8 00 01 c0 a8 00 02\n30 39 04 d2 00 00 00 00",
|
||||
L7: "68 65 6c 6c 6f 2c 20 70 63 61 70 21",
|
||||
Fix: true,
|
||||
LinkType: "auto",
|
||||
}
|
||||
|
||||
type hex2pcapResult struct {
|
||||
Packet []byte
|
||||
LinkType uint32
|
||||
LinkTypeName string
|
||||
L2Len int
|
||||
L3Len int
|
||||
L7Len int
|
||||
Fixed bool
|
||||
IPOffset int
|
||||
Fix inet.Result
|
||||
Dump []dumpRow
|
||||
}
|
||||
|
||||
type hex2pcapPage struct {
|
||||
Form hex2pcapForm
|
||||
Options []linkTypeOption
|
||||
Error string
|
||||
Result *hex2pcapResult
|
||||
Filename string
|
||||
}
|
||||
|
||||
func (s *Server) handleHex2PcapForm(w http.ResponseWriter, r *http.Request) {
|
||||
form := hex2pcapForm{Fix: true, LinkType: "auto"}
|
||||
if r.URL.Query().Has("example") {
|
||||
form = hex2pcapExample
|
||||
}
|
||||
s.render(w, http.StatusOK, "hex2pcap.html", "/hex2pcap", hex2pcapPage{Form: form, Options: linkTypeOptions})
|
||||
}
|
||||
|
||||
func (s *Server) handleHex2Pcap(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
form := hex2pcapForm{
|
||||
L2: r.PostFormValue("l2"),
|
||||
L3: r.PostFormValue("l3"),
|
||||
L7: r.PostFormValue("l7"),
|
||||
Fix: r.PostFormValue("fix") != "",
|
||||
LinkType: r.PostFormValue("linktype"),
|
||||
CustomLinkType: r.PostFormValue("customlinktype"),
|
||||
Filename: r.PostFormValue("filename"),
|
||||
}
|
||||
page := hex2pcapPage{Form: form, Options: linkTypeOptions, Filename: pcapFilename(form.Filename)}
|
||||
|
||||
res, err := buildPacket(form)
|
||||
if err != nil {
|
||||
page.Error = err.Error()
|
||||
s.render(w, http.StatusBadRequest, "hex2pcap.html", "/hex2pcap", page)
|
||||
return
|
||||
}
|
||||
|
||||
if r.PostFormValue("action") != "download" {
|
||||
page.Result = res
|
||||
s.render(w, http.StatusOK, "hex2pcap.html", "/hex2pcap", page)
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
pw, err := pcap.NewWriter(&buf, res.LinkType)
|
||||
if err == nil {
|
||||
err = pw.WritePacket(time.Now(), res.Packet)
|
||||
}
|
||||
if err != nil {
|
||||
page.Error = err.Error()
|
||||
s.render(w, http.StatusBadRequest, "hex2pcap.html", "/hex2pcap", page)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.tcpdump.pcap")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", page.Filename))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
// buildPacket decodes and concatenates the hex fields, and optionally fixes
|
||||
// up the IP lengths and checksums.
|
||||
func buildPacket(f hex2pcapForm) (*hex2pcapResult, error) {
|
||||
var layers [3][]byte
|
||||
for i, in := range []struct{ name, hex string }{
|
||||
{"Layer 2", f.L2}, {"Layer 3", f.L3}, {"Payload", f.L7},
|
||||
} {
|
||||
b, err := hexutil.Decode(in.hex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", in.name, err)
|
||||
}
|
||||
layers[i] = b
|
||||
}
|
||||
l2, l3, l7 := layers[0], layers[1], layers[2]
|
||||
|
||||
pkt := bytes.Join(layers[:], nil)
|
||||
if len(pkt) == 0 {
|
||||
return nil, errors.New("all fields are empty; paste some hex")
|
||||
}
|
||||
if len(pkt) > pcap.DefaultSnapLen {
|
||||
return nil, fmt.Errorf("packet is %d bytes; maximum is %d", len(pkt), pcap.DefaultSnapLen)
|
||||
}
|
||||
|
||||
lt, err := resolveLinkType(f, len(l2) > 0, len(l3) > 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &hex2pcapResult{
|
||||
LinkType: lt,
|
||||
LinkTypeName: pcap.LinkTypeName(lt),
|
||||
L2Len: len(l2),
|
||||
L3Len: len(l3),
|
||||
L7Len: len(l7),
|
||||
}
|
||||
|
||||
orig := bytes.Clone(pkt)
|
||||
if f.Fix {
|
||||
// The layer 3 field marks where the headers end, which tells Fix the
|
||||
// TCP header length and which SCTP chunk the payload belongs to.
|
||||
ipOff, hdrLen := len(l2), len(l3)
|
||||
if len(l3) == 0 {
|
||||
// No explicit layer 3: find the IP header by parsing the link layer.
|
||||
if ipOff, err = inet.FindIP(pkt, lt); err != nil {
|
||||
return nil, fmt.Errorf("fix lengths/checksums: %w (or uncheck the fix option)", err)
|
||||
}
|
||||
}
|
||||
if res.Fix, err = inet.Fix(pkt[ipOff:], hdrLen); err != nil {
|
||||
return nil, fmt.Errorf("fix lengths/checksums: %w (or uncheck the fix option)", err)
|
||||
}
|
||||
res.Fixed = true
|
||||
res.IPOffset = ipOff
|
||||
}
|
||||
|
||||
res.Packet = pkt
|
||||
res.Dump = hexDump(pkt, orig, len(l2), len(l2)+len(l3))
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func resolveLinkType(f hex2pcapForm, haveL2, haveL3 bool) (uint32, error) {
|
||||
switch f.LinkType {
|
||||
case "", "auto":
|
||||
if !haveL2 && haveL3 {
|
||||
return pcap.LinkTypeRaw, nil
|
||||
}
|
||||
return pcap.LinkTypeEthernet, nil
|
||||
case "custom":
|
||||
v, err := strconv.ParseUint(strings.TrimSpace(f.CustomLinkType), 0, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid custom link type %q", f.CustomLinkType)
|
||||
}
|
||||
return uint32(v), nil
|
||||
}
|
||||
v, err := strconv.ParseUint(f.LinkType, 10, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid link type %q", f.LinkType)
|
||||
}
|
||||
return uint32(v), nil
|
||||
}
|
||||
|
||||
// pcapFilename sanitizes a user supplied download name.
|
||||
func pcapFilename(name string) string {
|
||||
name = strings.TrimSuffix(strings.TrimSpace(name), ".pcap")
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, name)
|
||||
if strings.Trim(name, "._") == "" {
|
||||
name = "packet"
|
||||
}
|
||||
return name + ".pcap"
|
||||
}
|
||||
|
||||
type dumpByte struct {
|
||||
Hex string
|
||||
Class string // l2, l3, l7 or empty for padding
|
||||
Changed bool
|
||||
Sep string // whitespace following the byte
|
||||
}
|
||||
|
||||
type dumpRow struct {
|
||||
Offset string
|
||||
Bytes []dumpByte
|
||||
ASCII string
|
||||
}
|
||||
|
||||
// hexDump renders pkt as hexdump rows, tagging each byte with the layer it
|
||||
// came from and whether the fixup changed it relative to orig.
|
||||
func hexDump(pkt, orig []byte, l3Start, l7Start int) []dumpRow {
|
||||
var rows []dumpRow
|
||||
for off := 0; off < len(pkt); off += 16 {
|
||||
row := dumpRow{Offset: fmt.Sprintf("%04x", off)}
|
||||
var ascii strings.Builder
|
||||
for i := off; i < off+16; i++ {
|
||||
sep := " "
|
||||
if i-off == 7 {
|
||||
sep = " "
|
||||
}
|
||||
if i >= len(pkt) {
|
||||
row.Bytes = append(row.Bytes, dumpByte{Hex: " ", Sep: sep})
|
||||
continue
|
||||
}
|
||||
class := "l7"
|
||||
switch {
|
||||
case i < l3Start:
|
||||
class = "l2"
|
||||
case i < l7Start:
|
||||
class = "l3"
|
||||
}
|
||||
b := pkt[i]
|
||||
row.Bytes = append(row.Bytes, dumpByte{Hex: fmt.Sprintf("%02x", b), Class: class, Changed: b != orig[i], Sep: sep})
|
||||
if b >= 0x20 && b < 0x7f {
|
||||
ascii.WriteByte(b)
|
||||
} else {
|
||||
ascii.WriteByte('.')
|
||||
}
|
||||
}
|
||||
row.ASCII = ascii.String()
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pcap-web-tools/internal/pcap"
|
||||
)
|
||||
|
||||
func TestBuildPacketConcatenatesWithoutFix(t *testing.T) {
|
||||
res, err := buildPacket(hex2pcapForm{L2: "aa bb", L3: "cc", L7: "dd:ee"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(res.Packet, []byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee}) {
|
||||
t.Errorf("packet %x", res.Packet)
|
||||
}
|
||||
if res.LinkType != pcap.LinkTypeEthernet || res.Fixed {
|
||||
t.Errorf("link type %d fixed %v", res.LinkType, res.Fixed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPacketLinkType(t *testing.T) {
|
||||
tests := []struct {
|
||||
form hex2pcapForm
|
||||
want uint32
|
||||
}{
|
||||
{hex2pcapForm{L3: "45"}, pcap.LinkTypeRaw},
|
||||
{hex2pcapForm{L2: "00", L3: "45"}, pcap.LinkTypeEthernet},
|
||||
{hex2pcapForm{L7: "00"}, pcap.LinkTypeEthernet},
|
||||
{hex2pcapForm{L3: "45", LinkType: "113"}, pcap.LinkTypeLinuxSLL},
|
||||
{hex2pcapForm{L7: "00", LinkType: "custom", CustomLinkType: "228"}, 228},
|
||||
{hex2pcapForm{L7: "00", LinkType: "custom", CustomLinkType: "0x93"}, 147},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
res, err := buildPacket(tt.form)
|
||||
if err != nil {
|
||||
t.Errorf("%+v: %v", tt.form, err)
|
||||
continue
|
||||
}
|
||||
if res.LinkType != tt.want {
|
||||
t.Errorf("%+v: link type %d, want %d", tt.form, res.LinkType, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPacketFixWholeFrameInPayload(t *testing.T) {
|
||||
// Full Ethernet + IPv4 + ICMP frame pasted into the payload field only.
|
||||
res, err := buildPacket(hex2pcapForm{
|
||||
L7: "00112233445566778899aabb0800 45000000000100004001 0000 c0a8000108080808 0800 0000 abcd0001",
|
||||
Fix: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.IPOffset != 14 {
|
||||
t.Errorf("IP offset %d, want 14", res.IPOffset)
|
||||
}
|
||||
if got := binary.BigEndian.Uint16(res.Packet[14+2:]); got != 28 {
|
||||
t.Errorf("IPv4 total length %d, want 28", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPacketFixUsesLayer3Boundary(t *testing.T) {
|
||||
// IPv4 with a 4 byte option and TCP with 12 bytes of options in layer 3:
|
||||
// the TCP data offset is set from where the layer 3 field ends.
|
||||
res, err := buildPacket(hex2pcapForm{
|
||||
L3: "46000000123440004006 0000 0a0000010a000002 94040000 c350005000000001000000000018ffff00000000 0101080a0000000100000000",
|
||||
L7: "474554202f",
|
||||
Fix: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := res.Packet[24+12] >> 4; got != 8 {
|
||||
t.Errorf("TCP data offset %d, want 8", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPacketErrors(t *testing.T) {
|
||||
for name, f := range map[string]hex2pcapForm{
|
||||
"empty": {},
|
||||
"bad hex": {L3: "4g"},
|
||||
"fix non-IP": {L7: "ffffffffffff 66778899aabb 0806 0001", Fix: true},
|
||||
"bad custom": {L7: "00", LinkType: "custom", CustomLinkType: "x"},
|
||||
"fix not IP hdr": {L3: "00", Fix: true},
|
||||
} {
|
||||
if _, err := buildPacket(f); err == nil {
|
||||
t.Errorf("%s: expected error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPcapFilename(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"": "packet.pcap",
|
||||
" ": "packet.pcap",
|
||||
"dns query": "dns_query.pcap",
|
||||
"x.pcap": "x.pcap",
|
||||
"../etc/passwd": ".._etc_passwd.pcap",
|
||||
`a"b`: "a_b.pcap",
|
||||
} {
|
||||
if got := pcapFilename(in); got != want {
|
||||
t.Errorf("pcapFilename(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlers(t *testing.T) {
|
||||
srv, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, path := range []string{"/", "/hex2pcap", "/hex2pcap?example", "/static/style.css"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("GET %s: %d", path, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
post := func(v url.Values) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("POST", "/hex2pcap", strings.NewReader(v.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
rec := post(url.Values{"l3": {"4500"}, "l7": {"aabb"}, "action": {"download"}, "filename": {"test"}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("download: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if cd := rec.Header().Get("Content-Disposition"); cd != `attachment; filename="test.pcap"` {
|
||||
t.Errorf("Content-Disposition %q", cd)
|
||||
}
|
||||
body := rec.Body.Bytes()
|
||||
if len(body) != 24+16+4 || binary.LittleEndian.Uint32(body[20:]) != pcap.LinkTypeRaw {
|
||||
t.Errorf("pcap body %x", body)
|
||||
}
|
||||
|
||||
rec = post(url.Values{"l3": {"4500"}, "action": {"preview"}})
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Hex dump") {
|
||||
t.Errorf("preview: %d", rec.Code)
|
||||
}
|
||||
|
||||
rec = post(url.Values{"l3": {"zz"}, "action": {"download"}})
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid character") {
|
||||
t.Errorf("bad hex: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package web serves the pcap tools web UI.
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed templates static
|
||||
var assets embed.FS
|
||||
|
||||
// Tool is one sub-application, listed on the index page and in the navigation.
|
||||
type Tool struct {
|
||||
Name string
|
||||
Path string
|
||||
Description string
|
||||
}
|
||||
|
||||
var tools = []Tool{
|
||||
{Name: "Hex → pcap", Path: "/hex2pcap", Description: "Build a pcap file from hex-encoded layer 2, layer 3 and payload bytes, optionally fixing IP lengths and checksums."},
|
||||
}
|
||||
|
||||
// Server is the HTTP handler for the whole application.
|
||||
type Server struct {
|
||||
mux *http.ServeMux
|
||||
pages map[string]*template.Template
|
||||
}
|
||||
|
||||
// New parses the templates and sets up the routes.
|
||||
func New() (*Server, error) {
|
||||
s := &Server{mux: http.NewServeMux(), pages: map[string]*template.Template{}}
|
||||
|
||||
// Each page is parsed together with the shared layout so pages can define
|
||||
// their own "title" and "content" blocks.
|
||||
for _, page := range []string{"index.html", "hex2pcap.html"} {
|
||||
t, err := template.New(page).ParseFS(assets, "templates/layout.html", "templates/"+page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.pages[page] = t
|
||||
}
|
||||
|
||||
static, err := fs.Sub(assets, "static")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static)))
|
||||
s.mux.HandleFunc("GET /{$}", s.handleIndex)
|
||||
s.mux.HandleFunc("GET /hex2pcap", s.handleHex2PcapForm)
|
||||
s.mux.HandleFunc("POST /hex2pcap", s.handleHex2Pcap)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// pageData is passed to every page template.
|
||||
type pageData struct {
|
||||
Tools []Tool
|
||||
Active string // path of the current tool, for nav highlighting
|
||||
Data any // page specific data
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, status int, page, active string, data any) {
|
||||
var buf bytes.Buffer
|
||||
if err := s.pages[page].ExecuteTemplate(&buf, "layout", pageData{Tools: tools, Active: active, Data: data}); err != nil {
|
||||
log.Printf("render %s: %v", page, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, http.StatusOK, "index.html", "/", nil)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
:root {
|
||||
--bg: #f7f7f5;
|
||||
--fg: #1d1d1f;
|
||||
--muted: #6b6b70;
|
||||
--card: #ffffff;
|
||||
--border: #d9d9de;
|
||||
--accent: #2463eb;
|
||||
--accent-fg: #ffffff;
|
||||
--error-bg: #fdecec;
|
||||
--error-fg: #9b1c1c;
|
||||
--l2: #b45309;
|
||||
--l3: #1d4ed8;
|
||||
--l7: #047857;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #161618;
|
||||
--fg: #e8e8ea;
|
||||
--muted: #9a9aa2;
|
||||
--card: #1f1f22;
|
||||
--border: #36363c;
|
||||
--accent: #5b8cff;
|
||||
--accent-fg: #0b0b0c;
|
||||
--error-bg: #3a1717;
|
||||
--error-fg: #ffb4b4;
|
||||
--l2: #f5a65b;
|
||||
--l3: #8fb0ff;
|
||||
--l7: #5fd3a6;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
header { border-bottom: 1px solid var(--border); background: var(--card); }
|
||||
nav { max-width: 960px; margin: 0 auto; padding: 0.75rem 1rem; display: flex; gap: 1.25rem; flex-wrap: wrap; }
|
||||
nav a { color: var(--muted); text-decoration: none; }
|
||||
nav a:hover, nav a.active { color: var(--fg); }
|
||||
nav a.brand { color: var(--fg); font-weight: 600; }
|
||||
|
||||
main { max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
|
||||
h1 { margin: 0 0 0.5rem; font-size: 1.6rem; }
|
||||
h2 { font-size: 1.25rem; margin: 0 0 0.75rem; }
|
||||
h3 { font-size: 1rem; margin: 1.25rem 0 0.5rem; }
|
||||
.lead { color: var(--muted); margin-top: 0; }
|
||||
a { color: var(--accent); }
|
||||
code { font-family: var(--mono); font-size: 0.9em; }
|
||||
.hint { color: var(--muted); font-weight: normal; font-size: 0.85em; }
|
||||
|
||||
.tools { list-style: none; padding: 0; display: grid; gap: 0.75rem; }
|
||||
.tools li { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
|
||||
.tools a { font-weight: 600; font-size: 1.05rem; }
|
||||
.tools p { margin: 0.25rem 0 0; color: var(--muted); }
|
||||
|
||||
form label { display: block; font-weight: 600; margin: 1rem 0 0.3rem; }
|
||||
textarea, input, select {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
color: var(--fg);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
textarea { font-family: var(--mono); font-size: 0.9rem; resize: vertical; }
|
||||
textarea:focus, input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
|
||||
fieldset { border: 0; padding: 0; margin: 0; }
|
||||
label.check { display: flex; gap: 0.5rem; align-items: baseline; flex-wrap: wrap; font-weight: 600; }
|
||||
label.check input { width: auto; }
|
||||
.row { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 1rem; }
|
||||
@media (max-width: 720px) { .row { grid-template-columns: 1fr; gap: 0; } }
|
||||
|
||||
.actions { margin-top: 1.25rem; display: flex; gap: 0.75rem; }
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 0.5rem 1.1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
|
||||
|
||||
.error { background: var(--error-bg); color: var(--error-fg); padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0; }
|
||||
.note { color: var(--muted); margin: 0.35rem 0; }
|
||||
|
||||
.result { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); }
|
||||
.summary { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1rem; margin: 0; }
|
||||
.summary dt { color: var(--muted); }
|
||||
.summary dd { margin: 0; }
|
||||
|
||||
table { border-collapse: collapse; width: 100%; background: var(--card); border: 1px solid var(--border); }
|
||||
th, td { text-align: left; padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 0.85rem; }
|
||||
tr.changed td:last-child { font-weight: 600; }
|
||||
|
||||
.dump {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85rem;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.dump .off { color: var(--muted); }
|
||||
.l2 { color: var(--l2); }
|
||||
.l3 { color: var(--l3); }
|
||||
.l7 { color: var(--l7); }
|
||||
.chg { text-decoration: underline; text-underline-offset: 3px; font-weight: 700; }
|
||||
@@ -0,0 +1,94 @@
|
||||
{{define "title"}}Hex → pcap{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{with .Data}}
|
||||
<h1>Hex → pcap</h1>
|
||||
<p class="lead">
|
||||
Paste hex for each layer. The fields are decoded and concatenated into one packet.
|
||||
Any field may be empty, so you can also paste a whole packet into a single field.
|
||||
Spaces, newlines, <code>:</code>, <code>-</code>, <code>0x</code> and <code>\x</code> are ignored.
|
||||
<a href="/hex2pcap?example">Load example</a>.
|
||||
</p>
|
||||
|
||||
{{if .Error}}<div class="error" role="alert">{{.Error}}</div>{{end}}
|
||||
|
||||
<form method="post" action="/hex2pcap">
|
||||
<label for="l2">Layer 2 <span class="hint">e.g. Ethernet header</span></label>
|
||||
<textarea id="l2" name="l2" rows="2" spellcheck="false">{{.Form.L2}}</textarea>
|
||||
|
||||
<label for="l3">Layer 3 <span class="hint">IPv4/IPv6 header with options, optionally followed by the TCP/UDP/ICMP header, or the SCTP header and chunk header(s)</span></label>
|
||||
<textarea id="l3" name="l3" rows="4" spellcheck="false">{{.Form.L3}}</textarea>
|
||||
|
||||
<label for="l7">Payload <span class="hint">the rest of the packet, not interpreted</span></label>
|
||||
<textarea id="l7" name="l7" rows="6" spellcheck="false">{{.Form.L7}}</textarea>
|
||||
|
||||
<fieldset>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="fix"{{if .Form.Fix}} checked{{end}}>
|
||||
Recalculate IP lengths and checksums
|
||||
<span class="hint">IPv4/IPv6 length, IPv4 header checksum, TCP/UDP/ICMP/ICMPv6/SCTP checksum, UDP length.
|
||||
The end of the layer 3 field also sets the TCP data offset and the length of the SCTP chunk the payload belongs to
|
||||
(paste chunk data without padding).</span>
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="linktype">Link type</label>
|
||||
<select id="linktype" name="linktype">
|
||||
{{range .Options}}<option value="{{.Value}}"{{if eq .Value $.Data.Form.LinkType}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="customlinktype">Custom link type <span class="hint">when “Custom…” is selected</span></label>
|
||||
<input id="customlinktype" name="customlinktype" inputmode="numeric" value="{{.Form.CustomLinkType}}" placeholder="e.g. 228">
|
||||
</div>
|
||||
<div>
|
||||
<label for="filename">File name</label>
|
||||
<input id="filename" name="filename" value="{{.Form.Filename}}" placeholder="packet.pcap">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" name="action" value="preview">Preview</button>
|
||||
<button type="submit" name="action" value="download" class="primary">Download pcap</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{with .Result}}
|
||||
<section class="result">
|
||||
<h2>Preview</h2>
|
||||
<dl class="summary">
|
||||
<dt>Packet length</dt><dd>{{len .Packet}} bytes</dd>
|
||||
<dt>Link type</dt><dd>{{.LinkTypeName}} ({{.LinkType}})</dd>
|
||||
<dt>Layers</dt>
|
||||
<dd>
|
||||
<span class="l2">layer 2: {{.L2Len}}</span> ·
|
||||
<span class="l3">layer 3: {{.L3Len}}</span> ·
|
||||
<span class="l7">payload: {{.L7Len}}</span>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{{if .Fixed}}
|
||||
<h3>Fixups <span class="hint">IP header at offset {{.IPOffset}}</span></h3>
|
||||
<table>
|
||||
<thead><tr><th>Field</th><th>Old</th><th>New</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Fix.Changes}}
|
||||
<tr{{if ne .Old .New}} class="changed"{{end}}><td>{{.Field}}</td><td><code>{{.Old}}</code></td><td><code>{{.New}}</code></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{range .Fix.Notes}}<p class="note">{{.}}</p>{{end}}
|
||||
{{end}}
|
||||
|
||||
<h3>Hex dump <span class="hint">changed bytes are underlined</span></h3>
|
||||
<pre class="dump">
|
||||
{{- range .Dump -}}
|
||||
<span class="off">{{.Offset}}</span> {{range .Bytes}}<span class="{{.Class}}{{if .Changed}} chg{{end}}">{{.Hex}}</span>{{.Sep}}{{end}} {{.ASCII}}
|
||||
{{end -}}
|
||||
</pre>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,14 @@
|
||||
{{define "title"}}Home{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>pcap tools</h1>
|
||||
<p class="lead">Small utilities for working with pcap packet captures.</p>
|
||||
<ul class="tools">
|
||||
{{range .Tools}}
|
||||
<li>
|
||||
<a href="{{.Path}}">{{.Name}}</a>
|
||||
<p>{{.Description}}</p>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{define "layout"}}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{template "title" .}} · pcap tools</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/" class="brand">pcap tools</a>
|
||||
{{range .Tools}}<a href="{{.Path}}"{{if eq .Path $.Active}} class="active"{{end}}>{{.Name}}</a>{{end}}
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user