Add hex2pcap

This commit is contained in:
2026-10-05 20:07:31 +02:00
parent 477e6c0ee7
commit 5b5cf211d0
19 changed files with 1694 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/pcap-web-tools
*.pcap
+13
View File
@@ -0,0 +1,13 @@
.PHONY: build run test vet
build:
go build -o pcap-web-tools .
run:
go run . -addr localhost:8080
test:
go test ./...
vet:
go vet ./...
+6
View File
@@ -26,6 +26,12 @@ Builds a single-packet pcap file from pasted hex.
It walks IPv6 extension headers and uses the final destination from a routing header in the pseudo-header. Fragments only get their IP header fixed.
The end of the layer 3 field marks where the headers end. The fix uses that boundary for two more fields:
- **TCP data offset**: set to the length of the TCP header, including options, in the layer 3 field.
- **SCTP chunk length**: set for the last chunk whose header starts in the layer 3 field. It covers the rest of the packet, so paste chunk data without padding. If the payload already includes padding, the tool keeps a chunk length that matches. Chunks bundled before it keep their own lengths.
The IPv4 IHL and the IPv6 extension header lengths are taken as entered, because they are what says where the IP options end. If an IPv4 option doesn't fit inside the IHL, you get a warning. When the layer 3 field is empty, these two fields are left unchanged.
The IP header is taken to start at the layer 3 field. If that field is empty, the tool parses the link-layer header to find it: Ethernet (including VLAN and QinQ), Linux SLL or SLL2.
- **Link type**: *Auto* picks Raw IP when layer 2 is empty and layer 3 is set, and Ethernet otherwise. You can also pick a link type from the list or enter any numeric LINKTYPE.
- **Preview** shows the fixups that were applied and a hex dump coloured by layer, with changed bytes underlined. **Download pcap** returns the file.
+3
View File
@@ -0,0 +1,3 @@
module pcap-web-tools
go 1.27.1
+41
View File
@@ -0,0 +1,41 @@
// Package hexutil decodes hex strings in the loose formats people paste.
package hexutil
import (
"encoding/hex"
"fmt"
"strings"
)
// Decode decodes a hex string, tolerating common formatting:
// whitespace and newlines, ':' '-' ',' '.' separators, and "0x" / "\x" byte prefixes.
// So "45 00 00 3c", "45:00:00:3c", "0x45,0x00", "\x45\x00" and "4500003c" all work.
func Decode(s string) ([]byte, error) {
s = strings.NewReplacer(`\x`, " ", `\X`, " ", "0x", " ", "0X", " ").Replace(s)
digits := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case isHexDigit(c):
digits = append(digits, c)
case c == ' ' || c == '\t' || c == '\n' || c == '\r' ||
c == ':' || c == '-' || c == ',' || c == '.':
// separator
default:
return nil, fmt.Errorf("invalid character %q in hex input", c)
}
}
if len(digits)%2 != 0 {
return nil, fmt.Errorf("odd number of hex digits (%d)", len(digits))
}
out := make([]byte, len(digits)/2)
if _, err := hex.Decode(out, digits); err != nil {
return nil, err
}
return out, nil
}
func isHexDigit(c byte) bool {
return '0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F'
}
+44
View File
@@ -0,0 +1,44 @@
package hexutil
import (
"bytes"
"testing"
)
func TestDecode(t *testing.T) {
want := []byte{0x45, 0x00, 0x00, 0x3c}
for _, in := range []string{
"4500003c",
"45 00 00 3c",
"45:00:00:3C",
"45-00-00-3c",
"0x45, 0x00, 0x00, 0x3c",
`\x45\x00\x00\x3c`,
"4500\n003c\n",
" 45\t00 00\r\n3c ",
} {
got, err := Decode(in)
if err != nil {
t.Errorf("Decode(%q): %v", in, err)
continue
}
if !bytes.Equal(got, want) {
t.Errorf("Decode(%q) = %x, want %x", in, got, want)
}
}
}
func TestDecodeEmpty(t *testing.T) {
got, err := Decode(" \n ")
if err != nil || len(got) != 0 {
t.Errorf("Decode(blank) = %x, %v", got, err)
}
}
func TestDecodeErrors(t *testing.T) {
for _, in := range []string{"450", "45 0g", "45 00 zz"} {
if _, err := Decode(in); err == nil {
t.Errorf("Decode(%q): expected error", in)
}
}
}
+381
View File
@@ -0,0 +1,381 @@
// Package inet recalculates length fields and checksums of IPv4/IPv6 packets
// and their transport headers (TCP, UDP, ICMP, ICMPv6, SCTP).
package inet
import (
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
)
// IP protocol numbers.
const (
protoHopByHop = 0
protoICMP = 1
protoTCP = 6
protoUDP = 17
protoRouting = 43
protoFragment = 44
protoAH = 51
protoICMPv6 = 58
protoDstOpts = 60
protoSCTP = 132
)
// Change records a field rewritten by Fix.
type Change struct {
Field string
Old string
New string
}
// Result describes what Fix did.
type Result struct {
Changes []Change // fields that were rewritten (including ones whose value did not change)
Notes []string // informational messages, e.g. why a checksum was skipped
}
func (r *Result) change16(field string, b []byte, v uint16) {
old := binary.BigEndian.Uint16(b)
binary.BigEndian.PutUint16(b, v)
r.Changes = append(r.Changes, Change{field, fmt.Sprintf("0x%04x (%d)", old, old), fmt.Sprintf("0x%04x (%d)", v, v)})
}
func (r *Result) notef(format string, args ...any) {
r.Notes = append(r.Notes, fmt.Sprintf(format, args...))
}
// Fix rewrites, in place, the length fields and checksums of the IPv4 or IPv6
// packet in pkt. pkt must start at the IP header and extend to the end of the
// packet; everything after the IP header is taken as the IP payload.
//
// hdrLen is the number of leading bytes of pkt that are headers, i.e. where
// the user's header input ends and the payload begins, or 0 if unknown. When
// known it is used to set the TCP data offset and the length of the SCTP
// chunk that the payload belongs to; when unknown those fields are left alone.
// The IPv4 IHL and IPv6 extension header lengths are always taken as given.
func Fix(pkt []byte, hdrLen int) (Result, error) {
if len(pkt) == 0 {
return Result{}, errors.New("no IP header data")
}
switch v := pkt[0] >> 4; v {
case 4:
return fixIPv4(pkt, hdrLen)
case 6:
return fixIPv6(pkt, hdrLen)
default:
return Result{}, fmt.Errorf("not an IPv4/IPv6 packet: IP version nibble is %d", v)
}
}
func fixIPv4(pkt []byte, hdrLen int) (Result, error) {
var r Result
if len(pkt) < 20 {
return r, fmt.Errorf("IPv4 header truncated: %d bytes, need at least 20", len(pkt))
}
ihl := int(pkt[0]&0x0f) * 4
if ihl < 20 {
return r, fmt.Errorf("invalid IPv4 header length (IHL) %d bytes", ihl)
}
if ihl > len(pkt) {
return r, fmt.Errorf("IPv4 header length (IHL) %d exceeds packet length %d", ihl, len(pkt))
}
if len(pkt) > 0xffff {
return r, fmt.Errorf("IPv4 packet length %d exceeds 65535", len(pkt))
}
if off := badIPv4Option(pkt[20:ihl]); off >= 0 {
r.notef("IPv4 option at offset %d does not fit in the header length (IHL %d bytes); check IHL", 20+off, ihl)
}
r.change16("IPv4 total length", pkt[2:4], uint16(len(pkt)))
binary.BigEndian.PutUint16(pkt[10:12], 0)
r.change16("IPv4 header checksum", pkt[10:12], checksum(pkt[:ihl], 0))
flagsFrag := binary.BigEndian.Uint16(pkt[6:8])
if flagsFrag&0x2000 != 0 || flagsFrag&0x1fff != 0 {
r.notef("IPv4 packet is a fragment; transport header not changed")
return r, nil
}
proto := pkt[9]
payload := pkt[ihl:]
pseudo := sum(pkt[12:16], 0)
pseudo = sum(pkt[16:20], pseudo)
pseudo += uint32(proto) + uint32(len(payload))
fixTransport(&r, proto, payload, transportHdrLen(&r, hdrLen, ihl), pseudo, false)
return r, nil
}
// badIPv4Option walks IPv4 options and returns the offset of the first option
// that runs past the end of opts, or -1 if they are well formed.
func badIPv4Option(opts []byte) int {
for i := 0; i < len(opts); {
switch opts[i] {
case 0: // end of option list, the rest is padding
return -1
case 1: // no-op
i++
continue
}
if i+1 >= len(opts) || opts[i+1] < 2 || i+int(opts[i+1]) > len(opts) {
return i
}
i += int(opts[i+1])
}
return -1
}
func fixIPv6(pkt []byte, hdrLen int) (Result, error) {
var r Result
if len(pkt) < 40 {
return r, fmt.Errorf("IPv6 header truncated: %d bytes, need 40", len(pkt))
}
if len(pkt)-40 > 0xffff {
return r, fmt.Errorf("IPv6 payload length %d exceeds 65535 (jumbograms not supported)", len(pkt)-40)
}
r.change16("IPv6 payload length", pkt[4:6], uint16(len(pkt)-40))
dst := pkt[24:40]
next := pkt[6]
off := 40
for isIPv6ExtHeader(next) {
if off+8 > len(pkt) {
r.notef("IPv6 extension header truncated; transport header not changed")
return r, nil
}
var hlen int
switch next {
case protoFragment:
hlen = 8
fragOffMore := binary.BigEndian.Uint16(pkt[off+2 : off+4])
if fragOffMore&0xfff9 != 0 { // fragment offset != 0 or M flag set
r.notef("IPv6 packet is a fragment; transport header not changed")
return r, nil
}
case protoAH:
hlen = (int(pkt[off+1]) + 2) * 4
default:
hlen = (int(pkt[off+1]) + 1) * 8
}
if off+hlen > len(pkt) {
r.notef("IPv6 extension header truncated; transport header not changed")
return r, nil
}
if next == protoRouting {
if d := routingFinalDst(pkt[off : off+hlen]); d != nil {
dst = d
}
}
next = pkt[off]
off += hlen
}
payload := pkt[off:]
pseudo := sum(pkt[8:24], 0)
pseudo = sum(dst, pseudo)
pseudo += uint32(len(payload)) + uint32(next)
fixTransport(&r, next, payload, transportHdrLen(&r, hdrLen, off), pseudo, true)
return r, nil
}
// transportHdrLen converts the header length given to Fix into the number of
// transport-layer header bytes, or -1 if unknown.
func transportHdrLen(r *Result, hdrLen, ipHdrLen int) int {
if hdrLen <= 0 {
return -1
}
if hdrLen < ipHdrLen {
r.notef("IP header (%d bytes including options/extension headers) extends past the layer 3 field (%d bytes)", ipHdrLen, hdrLen)
return 0
}
return hdrLen - ipHdrLen
}
func isIPv6ExtHeader(proto byte) bool {
switch proto {
case protoHopByHop, protoRouting, protoFragment, protoAH, protoDstOpts:
return true
}
return false
}
// routingFinalDst returns the final destination address carried in an IPv6
// routing header, which the upper-layer pseudo header must use (RFC 8200 8.1).
// It returns nil when the IPv6 destination address field should be used.
func routingFinalDst(rh []byte) []byte {
routingType, segmentsLeft := rh[2], rh[3]
if segmentsLeft == 0 {
return nil
}
switch routingType {
case 0: // deprecated source route: last address in the list
n := int(rh[1]) / 2
if n > 0 && 8+n*16 <= len(rh) {
return rh[8+(n-1)*16 : 8+n*16]
}
case 2, 4: // Mobile IPv6 home address / Segment Routing: first address in the list
if len(rh) >= 24 {
return rh[8:24]
}
}
return nil
}
// fixTransport fixes the transport header at the start of payload. hdrLen is
// the number of transport header bytes the user entered before the payload,
// or -1 if unknown.
func fixTransport(r *Result, proto byte, payload []byte, hdrLen int, pseudo uint32, ipv6 bool) {
switch {
case proto == protoTCP:
if len(payload) < 20 {
r.notef("TCP header truncated (%d bytes); TCP checksum not recalculated", len(payload))
return
}
fixTCPDataOffset(r, payload, hdrLen)
binary.BigEndian.PutUint16(payload[16:18], 0)
r.change16("TCP checksum", payload[16:18], checksum(payload, pseudo))
case proto == protoUDP:
if len(payload) < 8 {
r.notef("UDP header truncated (%d bytes); UDP length and checksum not recalculated", len(payload))
return
}
r.change16("UDP length", payload[4:6], uint16(len(payload)))
binary.BigEndian.PutUint16(payload[6:8], 0)
cs := checksum(payload, pseudo)
if cs == 0 {
cs = 0xffff // zero means "no checksum" in UDP
}
r.change16("UDP checksum", payload[6:8], cs)
case proto == protoICMP && !ipv6:
if len(payload) < 4 {
r.notef("ICMP header truncated (%d bytes); ICMP checksum not recalculated", len(payload))
return
}
binary.BigEndian.PutUint16(payload[2:4], 0)
r.change16("ICMP checksum", payload[2:4], checksum(payload, 0))
case proto == protoICMPv6 && ipv6:
if len(payload) < 4 {
r.notef("ICMPv6 header truncated (%d bytes); ICMPv6 checksum not recalculated", len(payload))
return
}
binary.BigEndian.PutUint16(payload[2:4], 0)
r.change16("ICMPv6 checksum", payload[2:4], checksum(payload, pseudo))
case proto == protoSCTP:
if len(payload) < 12 {
r.notef("SCTP common header truncated (%d bytes); SCTP checksum not recalculated", len(payload))
return
}
fixSCTPChunkLength(r, payload, hdrLen)
old := binary.BigEndian.Uint32(payload[8:12])
binary.BigEndian.PutUint32(payload[8:12], 0)
// CRC32c is transmitted least significant byte first (RFC 9260 appendix A).
binary.LittleEndian.PutUint32(payload[8:12], crc32.Checksum(payload, castagnoli))
v := binary.BigEndian.Uint32(payload[8:12])
r.Changes = append(r.Changes, Change{"SCTP checksum (CRC32c)", fmt.Sprintf("0x%08x", old), fmt.Sprintf("0x%08x", v)})
default:
r.notef("IP protocol %d: no transport checksum to recalculate", proto)
}
}
// fixTCPDataOffset sets the TCP data offset to the TCP header length (with
// options) entered in the layer 3 field.
func fixTCPDataOffset(r *Result, tcp []byte, hdrLen int) {
switch {
case hdrLen < 0:
return
case hdrLen == 0:
r.notef("TCP header is in the payload field; TCP data offset not changed")
return
case hdrLen < 20 || hdrLen > 60 || hdrLen%4 != 0:
r.notef("TCP header in the layer 3 field is %d bytes (must be 20-60 and a multiple of 4); TCP data offset not changed", hdrLen)
return
}
old := int(tcp[12] >> 4)
tcp[12] = byte(hdrLen/4)<<4 | tcp[12]&0x0f
r.Changes = append(r.Changes, Change{"TCP data offset",
fmt.Sprintf("%d (%d bytes)", old, old*4), fmt.Sprintf("%d (%d bytes)", hdrLen/4, hdrLen)})
}
// fixSCTPChunkLength sets the length of the SCTP chunk that the payload
// belongs to: the last chunk whose header starts in the layer 3 field. Chunks
// before it are skipped using their own length fields. The chunk is taken to
// extend to the end of the packet, so the payload must not include padding
// unless the chunk length is already correct.
func fixSCTPChunkLength(r *Result, sctp []byte, hdrLen int) {
switch {
case len(sctp) == 12: // no chunks
return
case hdrLen < 0:
r.notef("SCTP chunk lengths are only recalculated when the layer 3 field is used; not changed")
return
case hdrLen < 16:
r.notef("SCTP chunk header is not in the layer 3 field; chunk length not changed")
return
}
off := 12
for {
clen := int(binary.BigEndian.Uint16(sctp[off+2:]))
next := off + (clen+3)&^3
if clen >= 4 && next+4 <= hdrLen {
off = next
continue
}
rem := len(sctp) - off
newLen := rem
if clen >= 4 && clen <= rem && (clen+3)&^3 == rem {
newLen = clen // already correct; the rest is padding
}
if newLen > 0xffff {
r.notef("SCTP chunk would be %d bytes, more than 65535; chunk length not changed", newLen)
return
}
r.change16(fmt.Sprintf("SCTP %s chunk length", sctpChunkName(sctp[off])), sctp[off+2:off+4], uint16(newLen))
if rem%4 != 0 {
r.notef("SCTP %s chunk is %d bytes, not padded to a multiple of 4", sctpChunkName(sctp[off]), rem)
}
return
}
}
func sctpChunkName(t byte) string {
names := map[byte]string{
0: "DATA", 1: "INIT", 2: "INIT ACK", 3: "SACK", 4: "HEARTBEAT", 5: "HEARTBEAT ACK",
6: "ABORT", 7: "SHUTDOWN", 8: "SHUTDOWN ACK", 9: "ERROR", 10: "COOKIE ECHO",
11: "COOKIE ACK", 14: "SHUTDOWN COMPLETE", 64: "I-DATA",
}
if n, ok := names[t]; ok {
return n
}
return fmt.Sprintf("type %d", t)
}
var castagnoli = crc32.MakeTable(crc32.Castagnoli)
// sum adds data as big-endian 16-bit words to s (RFC 1071).
func sum(data []byte, s uint32) uint32 {
n := len(data) &^ 1
for i := 0; i < n; i += 2 {
s += uint32(data[i])<<8 | uint32(data[i+1])
}
if len(data)%2 == 1 {
s += uint32(data[len(data)-1]) << 8
}
return s
}
// checksum returns the Internet checksum of data, starting from the partial sum s.
func checksum(data []byte, s uint32) uint16 {
s = sum(data, s)
for s>>16 != 0 {
s = s&0xffff + s>>16
}
return ^uint16(s)
}
+244
View File
@@ -0,0 +1,244 @@
package inet
import (
"bytes"
"encoding/hex"
"strings"
"testing"
"pcap-web-tools/internal/pcap"
)
func mustHex(t *testing.T, s string) []byte {
t.Helper()
b, err := hex.DecodeString(strings.ReplaceAll(s, " ", ""))
if err != nil {
t.Fatal(err)
}
return b
}
// The want packets were generated by Fix and validated with tshark's checksum checks.
// Each test zeroes the length/checksum fields and checks Fix restores them.
func TestFix(t *testing.T) {
tests := []struct {
name string
want string
hdrLen int // header length passed to Fix (0 = unknown)
zero [][2]int // byte ranges cleared before calling Fix
changes int // expected number of changes, if not len(zero)
notes []string // expected substrings of the notes, in order
}{
{
name: "IPv4 UDP",
want: "45000028000140004011b970c0a80001c0a80002303904d20014117668656c6c6f2c207063617021",
zero: [][2]int{{2, 4}, {10, 12}, {24, 26}, {26, 28}},
},
{
name: "IPv4 with options, TCP, odd length payload",
want: "46000036123440004006118b0a0000010a00000201010100c350005000000001000000005018fffff9700000474554202f0d0a0d0a2e",
hdrLen: 44,
zero: [][2]int{{2, 4}, {10, 12}, {36, 37}, {40, 42}},
},
{
name: "IPv4 ICMP",
want: "45000021000100004001aa22c0a80001080808080800f54babcd00017071727374",
zero: [][2]int{{2, 4}, {10, 12}, {22, 24}},
},
{
name: "IPv4 SCTP",
want: "45000034000040004084b8f2c0a80001c0a800020b5904d200000000a05c7b6a0100001400000001000100000001000100000001",
zero: [][2]int{{2, 4}, {10, 12}, {28, 32}},
notes: []string{"only recalculated when the layer 3 field is used"},
},
{
name: "IPv6 hop-by-hop UDP",
want: "600000000016000020010db800000000000000000000000120010db800000000000000000000000211000000000000001f900035000e1bfcdeadbeefcafe",
zero: [][2]int{{4, 6}, {52, 54}, {54, 56}},
},
{
name: "IPv6 ICMPv6",
want: "60000000000d3a40fe800000000000000000000000000001fe800000000000000000000000000002800046b7123400016162636465",
zero: [][2]int{{4, 6}, {42, 44}},
},
{
name: "IPv6 segment routing header UDP uses final destination",
want: "6000000000242b4020010db800000000000000000000000120010db80000000000000000000000aa110204010000000020010db80000000000000000000000ff1f900035000c7f9901020304",
zero: [][2]int{{4, 6}, {68, 70}, {70, 72}},
},
{
name: "IPv4 options, TCP SYN with options and no payload",
want: "460000401234400040067f7d0a0000010a00000294040000c35000500000000100000000a002ffff705b0000020405b40402080a000000010000000001030307",
hdrLen: 64,
zero: [][2]int{{2, 4}, {10, 12}, {36, 37}, {40, 42}},
},
{
name: "IPv4 options, TCP with options and payload",
want: "4600003d1234400040067f800a0000010a00000294040000c350005000000001000000008018ffffd4a500000101080a0000000100000000474554202f",
hdrLen: 56,
zero: [][2]int{{2, 4}, {10, 12}, {36, 37}, {40, 42}},
},
{
name: "IPv4 options, SCTP DATA without padding",
want: "460000391234400040847f060a0000010a00000294040000c35004d200000001866854b30003001500000001000100000000000068656c6c6f",
hdrLen: 52,
zero: [][2]int{{2, 4}, {10, 12}, {32, 36}, {38, 40}},
notes: []string{"not padded"},
},
{
name: "IPv4 options, SCTP DATA with padding keeps correct chunk length",
want: "4600003c1234400040847f030a0000010a00000294040000c35004d200000001ef89c5b10003001500000001000100000000000068656c6c6f000000",
hdrLen: 52,
zero: [][2]int{{2, 4}, {10, 12}, {32, 36}},
changes: 4,
},
{
name: "SCTP INIT entirely in layer 3",
want: "460000381234400040847f070a0000010a00000294040000c35004d200000001c3d6cafe0100001400000001000100000001000100000001",
hdrLen: 56,
zero: [][2]int{{2, 4}, {10, 12}, {32, 36}, {38, 40}},
},
{
name: "IPv6 SCTP SACK bundled before DATA",
want: "600000000034844020010db800000000000000000000000120010db8000000000000000000000002c35004d200000001634380c903000010000000000000ffff00000000000300180000000100010000000000000102030405060708",
hdrLen: 84,
zero: [][2]int{{4, 6}, {48, 52}, {70, 72}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
want := mustHex(t, tt.want)
pkt := bytes.Clone(want)
for _, z := range tt.zero {
clear(pkt[z[0]:z[1]])
}
res, err := Fix(pkt, tt.hdrLen)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(pkt, want) {
t.Errorf("got %x\nwant %x", pkt, want)
}
changes := tt.changes
if changes == 0 {
changes = len(tt.zero)
}
if len(res.Changes) != changes {
t.Errorf("got %d changes, want %d: %+v", len(res.Changes), changes, res.Changes)
}
checkNotes(t, res.Notes, tt.notes)
})
}
}
func checkNotes(t *testing.T, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Errorf("notes %q, want %q", got, want)
return
}
for i := range want {
if !strings.Contains(got[i], want[i]) {
t.Errorf("note %q does not contain %q", got[i], want[i])
}
}
}
func TestFixNotes(t *testing.T) {
const (
ip4TCP = "45000000000140004006 0000 c0a80001c0a80002"
tcpHdr = "c35000500000000100000000 5002ffff 0000 0000"
ip4SCTP = "45000000000140004084 0000 c0a80001c0a80002"
sctpHdr = "c35004d2 00000001 00000000"
)
tests := []struct {
name string
pkt string
hdrLen int
notes []string
}{
{"TCP header in payload field", ip4TCP + tcpHdr, 20, []string{"TCP header is in the payload field"}},
{"TCP header length not a multiple of 4", ip4TCP + tcpHdr + "0101", 42, []string{"must be 20-60"}},
{"IP header extends past layer 3", ip4TCP + tcpHdr, 12, []string{"extends past the layer 3 field", "TCP header is in the payload field"}},
{"IPv4 option overruns IHL", "46000000000140004006 0000 c0a80001c0a80002 01 44 08 00" + tcpHdr, 0, []string{"does not fit in the header length"}},
{"SCTP chunk header in payload field", ip4SCTP + sctpHdr + "00030000 00000001 00010000 00000000", 32, []string{"chunk header is not in the layer 3 field"}},
{"SCTP without chunks", ip4SCTP + sctpHdr, 32, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res, err := Fix(mustHex(t, tt.pkt), tt.hdrLen)
if err != nil {
t.Fatal(err)
}
checkNotes(t, res.Notes, tt.notes)
})
}
}
func TestFixIPv4Fragment(t *testing.T) {
// More-fragments flag set: header fixed, UDP left alone.
pkt := mustHex(t, "45000000000120004011 0000 c0a80001c0a80002 303904d200000000 aabb")
res, err := Fix(pkt, 0)
if err != nil {
t.Fatal(err)
}
if len(res.Changes) != 2 || len(res.Notes) != 1 || !strings.Contains(res.Notes[0], "fragment") {
t.Errorf("changes %+v notes %v", res.Changes, res.Notes)
}
if !bytes.Equal(pkt[24:28], []byte{0, 0, 0, 0}) {
t.Errorf("UDP header modified: %x", pkt[20:28])
}
}
func TestFixTruncatedTransport(t *testing.T) {
pkt := mustHex(t, "45000000000140004006 0000 c0a80001c0a80002 c350")
res, err := Fix(pkt, 0)
if err != nil {
t.Fatal(err)
}
if len(res.Notes) != 1 || !strings.Contains(res.Notes[0], "TCP header truncated") {
t.Errorf("notes %v", res.Notes)
}
}
func TestFixErrors(t *testing.T) {
for name, in := range map[string]string{
"empty": "",
"not IP": "0011223344",
"short IPv4": "4500001c",
"IHL too small": "41000000000000004011000000000000000000000000",
"IHL beyond end": "4f000000000000004011000000000000000000000000",
"short IPv6": "6000000000001140",
} {
if _, err := Fix(mustHex(t, in), 0); err == nil {
t.Errorf("%s: expected error", name)
}
}
}
func TestFindIP(t *testing.T) {
tests := []struct {
name string
frame string
linkType uint32
want int
wantErr bool
}{
{"ethernet", "001122334455 66778899aabb 0800 45", pcap.LinkTypeEthernet, 14, false},
{"ethernet IPv6", "001122334455 66778899aabb 86dd 60", pcap.LinkTypeEthernet, 14, false},
{"vlan", "001122334455 66778899aabb 8100 0064 0800 45", pcap.LinkTypeEthernet, 18, false},
{"qinq", "001122334455 66778899aabb 88a8 0001 8100 0064 86dd 60", pcap.LinkTypeEthernet, 22, false},
{"arp", "ffffffffffff 66778899aabb 0806 0001", pcap.LinkTypeEthernet, 0, true},
{"truncated", "001122334455", pcap.LinkTypeEthernet, 0, true},
{"raw", "45", pcap.LinkTypeRaw, 0, false},
{"sll", "0000 0001 0006 66778899aabb 0000 0800 45", pcap.LinkTypeLinuxSLL, 16, false},
{"sll2", "86dd 0000 00000002 0001 00 06 66778899aabb0000 60", pcap.LinkTypeLinuxSLL2, 20, false},
{"unsupported", "00", pcap.LinkTypeIEEE80211, 0, true},
}
for _, tt := range tests {
got, err := FindIP(mustHex(t, tt.frame), tt.linkType)
if (err != nil) != tt.wantErr || got != tt.want {
t.Errorf("%s: FindIP = %d, %v; want %d, err=%v", tt.name, got, err, tt.want, tt.wantErr)
}
}
}
+58
View File
@@ -0,0 +1,58 @@
package inet
import (
"encoding/binary"
"fmt"
"pcap-web-tools/internal/pcap"
)
const (
etherTypeIPv4 = 0x0800
etherTypeIPv6 = 0x86dd
etherTypeVLAN = 0x8100
etherTypeQinQ = 0x88a8
)
// FindIP returns the offset of the IP header within a link-layer frame of the
// given pcap link type, by parsing the link-layer header.
func FindIP(frame []byte, linkType uint32) (int, error) {
switch linkType {
case pcap.LinkTypeRaw:
return 0, nil
case pcap.LinkTypeEthernet:
off := 12
for {
if off+2 > len(frame) {
return 0, fmt.Errorf("Ethernet header truncated")
}
et := binary.BigEndian.Uint16(frame[off:])
if et == etherTypeVLAN || et == etherTypeQinQ {
off += 4
continue
}
return etherIP(et, off+2)
}
case pcap.LinkTypeLinuxSLL:
if len(frame) < 16 {
return 0, fmt.Errorf("Linux cooked (SLL) header truncated")
}
return etherIP(binary.BigEndian.Uint16(frame[14:]), 16)
case pcap.LinkTypeLinuxSLL2:
if len(frame) < 20 {
return 0, fmt.Errorf("Linux cooked v2 (SLL2) header truncated")
}
return etherIP(binary.BigEndian.Uint16(frame[0:]), 20)
}
return 0, fmt.Errorf("cannot locate the IP header for link type %s", pcap.LinkTypeName(linkType))
}
func etherIP(etherType uint16, off int) (int, error) {
if etherType != etherTypeIPv4 && etherType != etherTypeIPv6 {
return 0, fmt.Errorf("link-layer payload is not IP (EtherType 0x%04x)", etherType)
}
return off, nil
}
+81
View File
@@ -0,0 +1,81 @@
// Package pcap writes classic libpcap capture files.
//
// Format reference: https://www.ietf.org/archive/id/draft-ietf-opsawg-pcap-04.html
package pcap
import (
"encoding/binary"
"fmt"
"io"
"time"
)
// DefaultSnapLen is the snapshot length written to the file header.
const DefaultSnapLen = 262144
const magicMicroseconds = 0xa1b2c3d4
// Common link-layer header types (LINKTYPE_* values from tcpdump.org).
const (
LinkTypeEthernet uint32 = 1
LinkTypeRaw uint32 = 101
LinkTypeIEEE80211 uint32 = 105
LinkTypeLinuxSLL uint32 = 113
LinkTypeLinuxSLL2 uint32 = 276
)
// LinkTypeName returns a human readable name for well known link types.
func LinkTypeName(lt uint32) string {
switch lt {
case LinkTypeEthernet:
return "Ethernet"
case LinkTypeRaw:
return "Raw IP"
case LinkTypeIEEE80211:
return "IEEE 802.11"
case LinkTypeLinuxSLL:
return "Linux cooked (SLL)"
case LinkTypeLinuxSLL2:
return "Linux cooked v2 (SLL2)"
}
return fmt.Sprintf("LINKTYPE %d", lt)
}
// Writer writes packets to a pcap file. The file header is written by NewWriter.
type Writer struct {
w io.Writer
snapLen uint32
}
// NewWriter writes a pcap file header (microsecond resolution, little endian)
// for the given link type and returns a Writer for appending packets.
func NewWriter(w io.Writer, linkType uint32) (*Writer, error) {
var hdr [24]byte
binary.LittleEndian.PutUint32(hdr[0:], magicMicroseconds)
binary.LittleEndian.PutUint16(hdr[4:], 2) // version major
binary.LittleEndian.PutUint16(hdr[6:], 4) // version minor
// hdr[8:16]: thiszone and sigfigs, always zero
binary.LittleEndian.PutUint32(hdr[16:], DefaultSnapLen)
binary.LittleEndian.PutUint32(hdr[20:], linkType)
if _, err := w.Write(hdr[:]); err != nil {
return nil, err
}
return &Writer{w: w, snapLen: DefaultSnapLen}, nil
}
// WritePacket appends one packet record with the given timestamp.
func (w *Writer) WritePacket(ts time.Time, data []byte) error {
if len(data) > int(w.snapLen) {
return fmt.Errorf("packet length %d exceeds snaplen %d", len(data), w.snapLen)
}
var hdr [16]byte
binary.LittleEndian.PutUint32(hdr[0:], uint32(ts.Unix()))
binary.LittleEndian.PutUint32(hdr[4:], uint32(ts.Nanosecond()/1000))
binary.LittleEndian.PutUint32(hdr[8:], uint32(len(data))) // captured length
binary.LittleEndian.PutUint32(hdr[12:], uint32(len(data))) // original length
if _, err := w.w.Write(hdr[:]); err != nil {
return err
}
_, err := w.w.Write(data)
return err
}
+36
View File
@@ -0,0 +1,36 @@
package pcap
import (
"bytes"
"encoding/hex"
"testing"
"time"
)
func TestWriter(t *testing.T) {
var buf bytes.Buffer
w, err := NewWriter(&buf, LinkTypeRaw)
if err != nil {
t.Fatal(err)
}
ts := time.Unix(0x01020304, 5000)
if err := w.WritePacket(ts, []byte{0xaa, 0xbb}); err != nil {
t.Fatal(err)
}
want := "d4c3b2a1" + "0200" + "0400" + "00000000" + "00000000" + "00000400" + "65000000" +
"04030201" + "05000000" + "02000000" + "02000000" + "aabb"
if got := hex.EncodeToString(buf.Bytes()); got != want {
t.Errorf("got %s\nwant %s", got, want)
}
}
func TestWriterRejectsOversizedPacket(t *testing.T) {
w, err := NewWriter(&bytes.Buffer{}, LinkTypeEthernet)
if err != nil {
t.Fatal(err)
}
if err := w.WritePacket(time.Now(), make([]byte, DefaultSnapLen+1)); err == nil {
t.Error("expected error for oversized packet")
}
}
+268
View File
@@ -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
}
+157
View File
@@ -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)
}
}
+83
View File
@@ -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)
}
+120
View File
@@ -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; }
+94
View File
@@ -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}}
+14
View File
@@ -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}}
+21
View File
@@ -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}}
+28
View File
@@ -0,0 +1,28 @@
// Command pcap-web-tools serves a web UI with tools for pcap packet captures.
package main
import (
"flag"
"log"
"net/http"
"time"
"pcap-web-tools/internal/web"
)
func main() {
addr := flag.String("addr", "localhost:8080", "listen address")
flag.Parse()
srv, err := web.New()
if err != nil {
log.Fatal(err)
}
httpSrv := &http.Server{
Addr: *addr,
Handler: srv,
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("listening on http://%s", *addr)
log.Fatal(httpSrv.ListenAndServe())
}